anitrack 0.1.7

CLI/TUI companion for ani-cli with watch-progress tracking
use std::env;
use std::time::Duration;

use serde_json::Value;

use super::super::episode::{parse_title_and_total_eps, sanitize_title_for_search};
use crate::db::SeenEntry;
use crate::http::get_text_with_retries;

#[derive(Debug, Clone, Default)]
pub(crate) struct SelectNthResolution {
    pub(crate) index: Option<u32>,
    pub(crate) warnings: Vec<String>,
}

#[derive(Debug, Clone, Default)]
pub(crate) struct SearchEntriesFetchOutcome {
    pub(crate) entries: Option<Vec<SearchResultEntry>>,
    pub(crate) warning: Option<String>,
}

pub(crate) fn resolve_select_nth_for_item_with_diagnostics(
    item: &SeenEntry,
) -> SelectNthResolution {
    #[cfg(test)]
    if let Some(override_index) = resolve_select_nth_test_override() {
        return SelectNthResolution {
            index: Some(override_index),
            warnings: Vec::new(),
        };
    }

    let cleaned_title = sanitize_title_for_search(&item.title);
    let raw_title = item.title.trim().to_string();
    let queries = if cleaned_title == raw_title {
        vec![cleaned_title]
    } else {
        vec![cleaned_title, raw_title]
    };

    let env_mode = env::var("ANI_CLI_MODE").unwrap_or_else(|_| "sub".to_string());
    let mut modes = vec![env_mode, "sub".to_string(), "dub".to_string()];
    modes.dedup();
    let mut warnings = Vec::new();

    for query in queries {
        for mode in &modes {
            let fetch_outcome = fetch_search_result_entries_with_diagnostics(&query, mode);
            if let Some(warning) = fetch_outcome.warning {
                warnings.push(warning);
            }
            let Some(entries) = fetch_outcome.entries else {
                continue;
            };
            if let Some(index) = find_select_nth_index_by_id(&entries, &item.ani_id) {
                return SelectNthResolution {
                    index: Some(index),
                    warnings,
                };
            }
            if let Some(index) = find_select_nth_index_by_title(&entries, &item.title) {
                return SelectNthResolution {
                    index: Some(index),
                    warnings,
                };
            }
        }
    }
    SelectNthResolution {
        index: None,
        warnings,
    }
}

#[cfg(test)]
fn resolve_select_nth_test_override() -> Option<u32> {
    let raw = env::var("ANI_TRACK_TEST_SELECT_NTH").ok()?;
    let parsed = raw.trim().parse::<u32>().ok()?;
    (parsed > 0).then_some(parsed)
}

pub(crate) fn fetch_search_result_entries_with_diagnostics(
    query: &str,
    mode: &str,
) -> SearchEntriesFetchOutcome {
    let gql = "query( $search: SearchInput $limit: Int $page: Int $translationType: VaildTranslationTypeEnumType $countryOrigin: VaildCountryOriginEnumType ) { shows( search: $search limit: $limit page: $page translationType: $translationType countryOrigin: $countryOrigin ) { edges { _id name availableEpisodes __typename } }}";
    let escaped_query = json_escape(query);
    let escaped_mode = json_escape(mode);
    let variables = format!(
        "{{\"search\":{{\"allowAdult\":false,\"allowUnknown\":false,\"query\":\"{escaped_query}\"}},\"limit\":40,\"page\":1,\"translationType\":\"{escaped_mode}\",\"countryOrigin\":\"ALL\"}}"
    );
    let query_params = vec![
        ("variables".to_string(), variables),
        ("query".to_string(), gql.to_string()),
    ];
    let raw = match get_text_with_retries(
        "https://api.allanime.day/api",
        "https://allmanga.to",
        &query_params,
        Duration::from_secs(3),
        Duration::from_secs(6),
        3,
        Duration::from_secs(1),
    ) {
        Ok(raw) => raw,
        Err(err) => {
            let warning =
                format!("show search request failed for query={query:?} mode={mode}: {err}");
            return SearchEntriesFetchOutcome {
                entries: None,
                warning: Some(warning),
            };
        }
    };

    let entries = parse_search_result_entries(&raw);
    if entries.is_empty() {
        return SearchEntriesFetchOutcome {
            entries: None,
            warning: None,
        };
    }

    SearchEntriesFetchOutcome {
        entries: Some(entries),
        warning: None,
    }
}

#[derive(Debug, Clone)]
pub(crate) struct SearchResultEntry {
    pub(crate) id: String,
    pub(crate) title: String,
}

pub(crate) fn parse_search_result_entries(raw: &str) -> Vec<SearchResultEntry> {
    let parsed: Value = match serde_json::from_str(raw) {
        Ok(value) => value,
        Err(_) => return Vec::new(),
    };
    let Some(edges) = parsed
        .pointer("/data/shows/edges")
        .and_then(serde_json::Value::as_array)
    else {
        return Vec::new();
    };

    edges
        .iter()
        .filter_map(|edge| {
            let id = edge.get("_id")?.as_str()?.trim();
            let title = edge.get("name")?.as_str()?.trim();
            if id.is_empty() || title.is_empty() {
                return None;
            }
            Some(SearchResultEntry {
                id: id.to_string(),
                title: title.to_string(),
            })
        })
        .collect()
}

pub(crate) fn find_select_nth_index_by_id(
    entries: &[SearchResultEntry],
    ani_id: &str,
) -> Option<u32> {
    entries
        .iter()
        .position(|entry| entry.id == ani_id)
        .map(|idx| (idx + 1) as u32)
}

pub(crate) fn find_select_nth_index_by_title(
    entries: &[SearchResultEntry],
    title: &str,
) -> Option<u32> {
    let target = normalize_title_for_match(title);
    entries
        .iter()
        .position(|entry| normalize_title_for_match(&entry.title) == target)
        .map(|idx| (idx + 1) as u32)
}

pub(crate) fn normalize_title_for_match(raw: &str) -> String {
    let base = parse_title_and_total_eps(raw).0;
    base.to_lowercase()
        .chars()
        .map(|ch| {
            if ch.is_alphanumeric() || ch.is_whitespace() {
                ch
            } else {
                ' '
            }
        })
        .collect::<String>()
        .split_whitespace()
        .collect::<Vec<_>>()
        .join(" ")
}

pub(crate) fn json_escape(raw: &str) -> String {
    let mut out = String::with_capacity(raw.len());
    for ch in raw.chars() {
        match ch {
            '"' => out.push_str("\\\""),
            '\\' => out.push_str("\\\\"),
            '\n' => out.push_str("\\n"),
            '\r' => out.push_str("\\r"),
            '\t' => out.push_str("\\t"),
            c if c.is_control() => {
                let code = c as u32;
                out.push_str(&format!("\\u{code:04x}"));
            }
            c => out.push(c),
        }
    }
    out
}