//! Zimuku Provider.
//!
//! Pure-JS captcha bypassing part comes from https://github.com/morpheus65535/bazarr/blob/9fce3b1147e3cdab1be32b2d89d6d988d584ca90/custom_libs/subliminal_patch/providers/zimuku.py#L143
use std::fmt::Write;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, LazyLock};
use std::time::{Duration, Instant};

use anyhow::{Result, bail};
use async_trait::async_trait;
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use mediarium_ocr::CaptchaOcr;
use regex::Regex;
use reqwest::cookie::Jar;
use reqwest::redirect::Policy;
use reqwest::{Client, Url};
use scraper::{Html, Selector};
use tracing::{debug, warn};

use crate::subtitle::{
    MediaQuery, Provider, ResolvedSubtitle, ResolvedSubtitleFormat, ResolvedSubtitleHeader,
    SubtitleCandidate, SubtitlePayload, SubtitleProvider,
};
use crate::types::ProviderStatus;

const ZIMUKU_BASE_URL: &str = "https://zimuku.org";
const MAX_WAF_RETRIES: usize = 5;

/// Extracts the JS redirect path from Yunsuo WAF challenge pages.
/// Matches: `self.location = "/some/path" + stringToHex(`
static LOCATION_RE: LazyLock<Regex> =
    LazyLock::new(|| Regex::new(r#"self\.location\s*=\s*"([^"]+)"\s*\+\s*stringToHex\("#).unwrap());

/// Extracts the base64-encoded BMP captcha image from WAF challenge pages.
static VERIFY_IMAGE_RE: LazyLock<Regex> =
    LazyLock::new(|| Regex::new(r#"<img[^>]+src="data:image/bmp;base64,([^"]+)""#).unwrap());

/// Extracts JS-based URL prefix redirects in search result pages.
/// Matches: `url = '/prefix' + url`
static SEARCH_REDIRECT_RE: LazyLock<Regex> =
    LazyLock::new(|| Regex::new(r"url\s*=\s*'([^']*)'\s*\+\s*url").unwrap());

pub struct ZimukuProvider {
    client: Client,
    /// A separate client with redirects disabled, used for the WAF verification
    /// step.
    no_redirect_client: Client,
    jar: Arc<Jar>,
    ocr: Option<Arc<CaptchaOcr>>,
    enabled: AtomicBool,
}

impl ZimukuProvider {
    #[must_use]
    pub fn new(ocr: Option<Arc<CaptchaOcr>>) -> Self {
        let jar = Arc::new(Jar::default());

        let client = Client::builder()
            .user_agent(
                "Mozilla/5.0 (Windows NT 10.0; Win64; x64) \
                 AppleWebKit/537.36 (KHTML, like Gecko) \
                 Chrome/131.0.0.0 Safari/537.36",
            )
            .cookie_provider(Arc::clone(&jar))
            .timeout(Duration::from_secs(15))
            .build()
            .expect("failed to build reqwest client");

        let no_redirect_client = Client::builder()
            .user_agent(
                "Mozilla/5.0 (Windows NT 10.0; Win64; x64) \
                 AppleWebKit/537.36 (KHTML, like Gecko) \
                 Chrome/131.0.0.0 Safari/537.36",
            )
            .cookie_provider(Arc::clone(&jar))
            .redirect(Policy::none())
            .timeout(Duration::from_secs(15))
            .build()
            .expect("failed to build no-redirect reqwest client");

        Self {
            client,
            no_redirect_client,
            jar,
            ocr,
            enabled: AtomicBool::new(true),
        }
    }

    async fn request_waf_bypass(&self, url: &str, code: &str) -> Result<()> {
        let base_url = Url::parse(ZIMUKU_BASE_URL)?;
        self.jar
            .add_cookie_str(&format!("srcurl={}", string_to_hex(url)), &base_url);

        let hex_code = string_to_hex(code);
        let mut verify_url = Url::parse(url)?;
        verify_url
            .query_pairs_mut()
            .append_pair("security_verify_img", &hex_code);
        let verify_url = verify_url.to_string();

        debug!("hitting verification URL: {verify_url}");

        match self.client.get(&verify_url).send().await {
            Ok(resp) => match resp.text().await {
                Ok(_) => {}
                Err(e) => {
                    debug!("failed to read verification response from {verify_url}: {e}");
                }
            },
            Err(e) => {
                debug!("verification request failed for {verify_url}: {e}");
            }
        }

        Ok(())
    }

    async fn request_js_bypass(&self, url: &str, html: &str) -> Result<()> {
        let Some(caps) = LOCATION_RE.captures(html) else {
            return Ok(());
        };

        let base_url: Url = ZIMUKU_BASE_URL.parse()?;
        self.jar
            .add_cookie_str(&format!("srcurl={}", string_to_hex(url)), &base_url);

        let path = &caps[1];
        let verify_url = format!("{ZIMUKU_BASE_URL}{path}{}", string_to_hex("1920,1080"));

        match self.no_redirect_client.get(&verify_url).send().await {
            Ok(resp) => {
                if let Err(e) = resp.text().await {
                    debug!("failed to read JS verification response from {verify_url}: {e}");
                }
            }
            Err(e) => {
                debug!("JS verification request failed for {verify_url}: {e}");
            }
        }

        Ok(())
    }

    fn build_search_keywords(query: &MediaQuery) -> Vec<String> {
        let mut keywords = Vec::with_capacity(3);

        if let Some(season) = query.season {
            if let Some(episode) = query.episode {
                keywords.push(format!("{} S{season:02}E{episode:02}", query.title));
            }

            keywords.push(format!("{} S{season:02}", query.title));
        }

        keywords.push(query.title.clone());
        keywords.dedup();

        keywords
    }

    /// Fetch a URL, automatically bypassing Yunsuo WAF challenges.
    ///
    /// Yunsuo WAF signals a challenge with HTTP 404. The challenge page
    /// contains either a captcha image (solved via OCR) or a pure JS
    /// challenge (solved with fixed screen dimensions). The flow:
    ///
    /// 1. GET the URL
    /// 2. If 404 → extract challenge code, set `srcurl` cookie, hit
    ///    verification URL
    /// 3. Retry until the response no longer contains the WAF challenge
    async fn get_html(&self, url: &str) -> Result<String> {
        for attempt in 0..MAX_WAF_RETRIES {
            let (status, html) = match self.client.get(url).send().await {
                Ok(resp) => {
                    let status = resp.status();
                    match resp.text().await {
                        Ok(html) => (status, html),
                        Err(e) => {
                            warn!("failed to read {url} on attempt {attempt}: {e}");

                            continue;
                        }
                    }
                }
                Err(e) => {
                    warn!("failed to fetch {url} on attempt {attempt}: {e}");

                    continue;
                }
            };

            // Yunsuo WAF signals a challenge via HTTP 404, but can also serve
            // challenge JS in non-404 pages. Check both conditions.
            let has_captcha =
                VERIFY_IMAGE_RE.is_match(&html) || html.contains("class=\"verifyimg\"");
            let is_waf = status.as_u16() == 404 || LOCATION_RE.is_match(&html) || has_captcha;
            if !is_waf {
                return Ok(html);
            }

            debug!("WAF challenge detected for {url} (attempt {attempt}, status {status})");

            if let Some(caps) = VERIFY_IMAGE_RE.captures(&html) {
                let Some(ocr) = &self.ocr else {
                    bail!("yunsuo captcha encountered but OCR model not available");
                };
                let image_bytes = STANDARD.decode(&caps[1])?;
                let answer = ocr.recognize(&image_bytes)?;

                debug!("captcha OCR result: {answer}");

                self.request_waf_bypass(url, &answer).await?;
            } else {
                debug!("no captcha image, applying JS bypass flow");
                self.request_js_bypass(url, &html).await?;
            }
        }

        bail!("yunsuo WAF bypass failed after {MAX_WAF_RETRIES} attempts for {url}")
    }

    async fn search_detail_urls(&self, keyword: &str) -> Result<Vec<String>> {
        let search_url = format!("{ZIMUKU_BASE_URL}/search?q={}", urlencoding(keyword));
        let html = self.get_html(&search_url).await?;

        // Handle JS-based redirects in search results: `url = '/prefix' + url`
        let html = self.follow_search_redirects(&search_url, html).await?;

        let document = Html::parse_document(&html);
        let selector = Selector::parse("p.tt.clearfix a").expect("invalid selector");

        let urls: Vec<String> = document
            .select(&selector)
            .filter_map(|el| el.value().attr("href"))
            .filter(|href| href.contains("/subs/"))
            .map(resolve_url)
            .take(3)
            .collect();

        Ok(urls)
    }

    /// Follow JavaScript-based redirects in search result pages.
    ///
    /// Zimuku sometimes returns a page with `url = '/prefix' + url` JS that
    /// constructs a new URL from prefix parts. We extract all parts, reverse
    /// them, join, and follow the redirect.
    async fn follow_search_redirects(&self, search_url: &str, mut html: String) -> Result<String> {
        loop {
            let parts: Vec<String> = SEARCH_REDIRECT_RE
                .captures_iter(&html)
                .map(|c| c[1].to_string())
                .collect();

            if parts.is_empty() {
                return Ok(html);
            }

            // Reverse and join the prefix parts to form the redirect path
            let mut reversed = parts;
            reversed.reverse();
            let redirect_path: String = reversed.join("");
            let redirect_url = format!("{ZIMUKU_BASE_URL}{redirect_path}");

            debug!("following search JS redirect: {search_url} → {redirect_url}");
            html = self.get_html(&redirect_url).await?;
        }
    }

    /// Parse the subtitle table from a film detail page.
    async fn parse_subtitle_list(&self, detail_url: &str) -> Result<Vec<SubtitleCandidate>> {
        let html = self.get_html(detail_url).await?;

        let document = Html::parse_document(&html);
        let row_selector = Selector::parse("#subtb tbody tr").expect("invalid selector");
        let link_selector = Selector::parse("a").expect("invalid selector");
        let lang_selector = Selector::parse("img").expect("invalid selector");
        let ext_selector = Selector::parse("span.label-info").expect("invalid selector");

        let mut candidates = vec![];

        for (idx, row) in document.select(&row_selector).enumerate() {
            let Some(link) = row.select(&link_selector).next() else {
                continue;
            };
            let title = link.value().attr("title").unwrap_or_default().to_string();
            let Some(href) = link.value().attr("href") else {
                continue;
            };

            let sub_url = resolve_url(href);

            // Language from img alt attributes
            let lang: String = row
                .select(&lang_selector)
                .filter_map(|img| img.value().attr("alt"))
                .collect::<Vec<_>>()
                .join(",");

            let is_bilingual =
                lang.contains("双语") || (lang.contains("简") && lang.contains("英"));

            // File extension
            let file_name = row
                .select(&ext_selector)
                .next()
                .map(|el| el.text().collect::<String>())
                .map(|ext| format!("{title}.{ext}"))
                .unwrap_or_else(|| title.clone());

            let payload = SubtitlePayload {
                file_name,
                url: sub_url,
            };
            candidates.push(SubtitleCandidate::new(
                "zimuku".to_string(),
                lang,
                is_bilingual,
                payload,
                (10 - idx as i32).max(0),
            ));
        }

        Ok(candidates)
    }
}

fn string_to_hex(s: &str) -> String {
    let mut out = String::with_capacity(s.len() * 2);

    for byte in s.bytes() {
        let _ = write!(out, "{byte:02x}");
    }

    out
}

/// Resolve a possibly-relative href to an absolute URL.
fn resolve_url(href: &str) -> String {
    if href.starts_with("http") {
        href.to_string()
    } else if href.starts_with("//") {
        format!("https:{href}")
    } else {
        format!("{ZIMUKU_BASE_URL}{href}")
    }
}

/// Minimal percent-encoding for search query parameters.
fn urlencoding(s: &str) -> String {
    let mut out = String::with_capacity(s.len() * 3);
    for byte in s.bytes() {
        match byte {
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
                out.push(byte as char);
            }
            b' ' => out.push('+'),
            _ => {
                out.push('%');
                let _ = write!(out, "{byte:02X}");
            }
        }
    }
    out
}

#[async_trait]
impl Provider for ZimukuProvider {
    fn key(&self) -> &'static str {
        "zimuku"
    }

    fn name(&self) -> &'static str {
        "Zimuku"
    }

    async fn health_check(&self) -> ProviderStatus {
        if !self.is_enabled() {
            return ProviderStatus::Unhealthy;
        }

        match self.check_alive().await {
            Ok(latency_ms) => ProviderStatus::Healthy { latency_ms },
            Err(_) => ProviderStatus::Unhealthy,
        }
    }
}

#[async_trait]
impl SubtitleProvider for ZimukuProvider {
    fn is_enabled(&self) -> bool {
        self.enabled.load(Ordering::Relaxed)
    }

    fn download_client(&self) -> &Client {
        &self.client
    }

    async fn check_alive(&self) -> Result<u64> {
        let start = Instant::now();
        self.client.head(ZIMUKU_BASE_URL).send().await?;

        Ok(start.elapsed().as_millis() as u64)
    }

    async fn search(&self, query: &MediaQuery) -> Result<Vec<SubtitleCandidate>> {
        let mut detail_urls = vec![];

        for keyword in Self::build_search_keywords(query) {
            detail_urls = self.search_detail_urls(&keyword).await?;

            if !detail_urls.is_empty() {
                break;
            }
        }

        if detail_urls.is_empty() {
            debug!("no results for '{}'", query.title);

            return Ok(vec![]);
        }

        let mut all_candidates = vec![];

        for url in &detail_urls {
            match self.parse_subtitle_list(url).await {
                Ok(candidates) => all_candidates.extend(candidates),
                Err(e) => {
                    warn!("failed to parse {url}: {e}");
                }
            }
        }

        Ok(all_candidates)
    }

    async fn resolve(&self, candidate: &SubtitleCandidate) -> Result<ResolvedSubtitle> {
        // Fetch the subtitle entry page
        let entry_html = self.get_html(&candidate.payload.url).await?;
        let down1_url = {
            let entry_doc = Html::parse_document(&entry_html);
            let down1_selector = Selector::parse("a#down1").expect("invalid selector");
            let Some(down1_el) = entry_doc.select(&down1_selector).next() else {
                bail!("could not find a#down1 on subtitle entry page");
            };
            let Some(href) = down1_el.value().attr("href") else {
                bail!("a#down1 has no href attribute");
            };
            resolve_url(href)
        };
        // Fetch the download page
        let down_html = self.get_html(&down1_url).await?;
        let down_doc = Html::parse_document(&down_html);

        // Parse a[rel="nofollow"] for the final CDN/file URL
        let final_url = {
            let final_selector = Selector::parse("a[rel=\"nofollow\"]").expect("invalid selector");
            let Some(final_el) = down_doc.select(&final_selector).next() else {
                bail!("could not find a[rel=\"nofollow\"] on download page");
            };
            let Some(href) = final_el.value().attr("href") else {
                bail!("a[rel=\"nofollow\"] has no href attribute");
            };
            resolve_url(href)
        };

        let format = ResolvedSubtitleFormat::from_file_name(&candidate.payload.file_name);

        Ok(ResolvedSubtitle {
            provider: self.key().to_string(),
            header: ResolvedSubtitleHeader {
                file_name: candidate.payload.file_name.clone(),
                format,
                language: candidate.language.clone(),
                is_bilingual: candidate.is_bilingual,
            },
            download_url: final_url,
        })
    }
}

#[cfg(test)]
mod tests {
    use std::path::Path;
    use std::sync::LazyLock;

    use super::*;

    fn movie_query(title: &str) -> MediaQuery {
        MediaQuery {
            title: title.to_string(),
            season: None,
            episode: None,
            file_path: String::new(),
        }
    }

    fn series_query(title: &str, season: i32, episode: i32) -> MediaQuery {
        MediaQuery {
            title: title.to_string(),
            season: Some(season),
            episode: Some(episode),
            file_path: String::new(),
        }
    }

    #[test]
    fn test_string_to_hex() {
        assert_eq!(string_to_hex("abc"), "616263");
        assert_eq!(string_to_hex("1234"), "31323334");
    }

    #[test]
    fn test_urlencoding() {
        assert_eq!(urlencoding("Inception"), "Inception");
        assert_eq!(urlencoding("hello world"), "hello+world");
        assert_eq!(urlencoding("a&b=c"), "a%26b%3Dc");
    }

    #[test]
    fn test_waf_detection_by_regex() {
        let waf_html = r#"<script>self.location = "/waf/verify" + stringToHex(code)</script>"#;
        let normal_html = "<html><body>Normal page</body></html>";

        assert!(LOCATION_RE.is_match(waf_html));
        assert!(!LOCATION_RE.is_match(normal_html));
    }

    #[test]
    fn test_location_regex_extraction() {
        let html = r#"<script>self.location = "/sec/verify?token=" + stringToHex(code)</script>"#;
        let caps = LOCATION_RE.captures(html).unwrap();
        assert_eq!(&caps[1], "/sec/verify?token=");
    }

    #[test]
    fn test_resolve_url() {
        assert_eq!(
            resolve_url("//zimuku.org/subs/24195.html"),
            "https://zimuku.org/subs/24195.html"
        );
        // Absolute: pass through
        assert_eq!(
            resolve_url("https://example.com/foo"),
            "https://example.com/foo"
        );
        // Site-relative: prepend base URL
        assert_eq!(
            resolve_url("/subs/123.html"),
            "https://zimuku.org/subs/123.html"
        );
    }

    #[test]
    fn test_search_redirect_regex() {
        let html = "var url = '/search' + url";
        let caps: Vec<String> = SEARCH_REDIRECT_RE
            .captures_iter(html)
            .map(|c| c[1].to_string())
            .collect();
        assert_eq!(caps, vec!["/search"]);
    }

    #[tokio::test]
    async fn check_alive() {
        let provider = ZimukuProvider::new(None);
        let latency = provider.check_alive().await;

        assert!(latency.is_ok(), "check_alive should succeed");
    }

    static OCR: LazyLock<Option<Arc<CaptchaOcr>>> =
        LazyLock::new(|| match CaptchaOcr::load(Path::new("../../models")) {
            Ok(ocr) => Some(Arc::new(ocr)),
            Err(e) => {
                warn!("OCR model not available, zimuku captcha bypass disabled: {e}");

                None
            }
        });

    #[tokio::test]
    async fn search_movie_inception() {
        let provider = ZimukuProvider::new(OCR.clone());
        let query = movie_query("Inception");
        let results = provider.search(&query).await.unwrap();

        assert!(!results.is_empty(), "Inception should have subtitles");
        insta::assert_yaml_snapshot!(results);
    }

    #[tokio::test]
    async fn search_series_breaking_bad() {
        let provider = ZimukuProvider::new(OCR.clone());
        let query = series_query("Breaking Bad", 1, 1);
        let results = provider.search(&query).await.unwrap();

        assert!(!results.is_empty(), "Breaking Bad should have subtitles");
        insta::assert_yaml_snapshot!(results);
    }

    #[tokio::test]
    async fn download_movie_interstellar() {
        let provider = ZimukuProvider::new(OCR.clone());
        let query = movie_query("Interstellar");
        let candidates = provider.search(&query).await.unwrap();

        assert!(!candidates.is_empty(), "Interstellar should have subtitles");

        let mut last_error = None;

        for candidate in &candidates {
            let resolved = match provider.resolve(candidate).await {
                Ok(resolved) => resolved,
                Err(error) => {
                    last_error = Some(format!(
                        "resolve failed for {}: {error}",
                        candidate.payload.url
                    ));

                    continue;
                }
            };

            let downloaded = match provider.download(&resolved).await {
                Ok(downloaded) => downloaded,
                Err(error) => {
                    last_error = Some(format!(
                        "download failed for {}: {error}",
                        resolved.download_url
                    ));

                    continue;
                }
            };

            assert!(
                !resolved.download_url.is_empty(),
                "resolved subtitle should include a download url"
            );
            assert!(
                !downloaded.header.file_name.is_empty(),
                "resolved subtitle should include a file name"
            );
            assert!(
                !downloaded.content.is_empty(),
                "downloaded subtitle content should not be empty"
            );

            return;
        }

        panic!(
            "expected at least one Interstellar subtitle to download successfully, last error: {}",
            last_error.unwrap_or_else(|| "no candidates returned".to_string())
        );
    }
}
