use std::collections::HashMap;
use std::time::Instant;

use anyhow::{Result, anyhow};
use reqwest::{Client, RequestBuilder, Url};
use scraper::{ElementRef, Html, Selector};
use tracing::debug;

use super::config::NexusPhpConfig;
use super::profile::NexusPhpSite;
use crate::site::query::MediaResourceQuery;
use crate::site::types::{ResolvedSiteDownload, ResourceCandidate, ResourcePayload};

pub struct NexusPhpEngine<'a> {
    client: Client,
    config: &'a NexusPhpConfig<'a>,
    site: &'a NexusPhpSite,
}

impl<'a> NexusPhpEngine<'a> {
    pub fn new(config: &'a NexusPhpConfig<'a>, site: &'a NexusPhpSite) -> Result<Self> {
        if config.cookie_header.trim().is_empty() {
            return Err(anyhow!("nexusphp provider requires a cookie header"));
        }

        if site.base_url.trim().is_empty() {
            return Err(anyhow!("nexusphp provider requires a site base url"));
        }

        if Url::parse(&site.base_url).is_err() {
            return Err(anyhow!("nexusphp provider requires a valid site base url"));
        }

        let mut builder = Client::builder();

        if let Some(user_agent) = config.user_agent {
            builder = builder.user_agent(user_agent);
        }

        Ok(Self {
            client: builder.build()?,
            config,
            site,
        })
    }

    pub async fn check_alive(&self) -> Result<u64> {
        let start = Instant::now();
        let response = self.request_page(None).send().await?;

        if !response.status().is_success() {
            return Err(anyhow!("unexpected status code: {}", response.status()));
        }

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

    pub async fn search_resources(
        &self,
        query: &MediaResourceQuery,
    ) -> Result<Vec<ResourceCandidate>> {
        self.fetch_resources(Some(query)).await
    }

    pub async fn latest_resources(&self) -> Result<Vec<ResourceCandidate>> {
        self.fetch_resources(None).await
    }

    async fn fetch_resources(
        &self,
        query: Option<&MediaResourceQuery>,
    ) -> Result<Vec<ResourceCandidate>> {
        let response = self.request_page(query).send().await?;
        let html = response.text().await?;

        parse_resources(&html, self.site)
    }

    pub async fn resolve(&self, candidate: &ResourceCandidate) -> Result<ResolvedSiteDownload> {
        let torrent_url = candidate.payload.torrent_url.clone().ok_or_else(|| {
            anyhow!(
                "missing torrent url for resource {}",
                candidate.payload.resource_key
            )
        })?;

        Ok(ResolvedSiteDownload {
            file_name: Some(format!("{}.torrent", candidate.payload.resource_key)),
            download_url: torrent_url,
            headers: if self.config.cookie_header.is_empty() {
                vec![]
            } else {
                vec![("Cookie".to_string(), self.config.cookie_header.to_string())]
            },
        })
    }

    fn request_page(&self, query: Option<&MediaResourceQuery>) -> RequestBuilder {
        let request = self
            .client
            .get(list_url(&self.site.base_url, &self.site.list_path));
        let request = if self.site.query_params.is_empty() {
            request
        } else {
            request.query(&self.site.query_params)
        };
        let request = if let Some(query) = query {
            request.query(&[(self.site.search_param.as_str(), query.title.as_str())])
        } else {
            request
        };

        request
            .header(
                "Accept",
                "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
            )
            .header("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
            .header("Cache-Control", "no-cache")
            .header("Pragma", "no-cache")
            .header("Cookie", self.config.cookie_header)
    }
}

#[derive(Default)]
struct PendingResource {
    title: Option<String>,
    torrent_url: Option<String>,
    labels: Vec<String>,
}

fn parse_resources(html: &str, site: &NexusPhpSite) -> Result<Vec<ResourceCandidate>> {
    let document = Html::parse_document(html);
    let link_selector = Selector::parse(&site.link_selector)
        .map_err(|error| anyhow!("invalid link selector: {error}"))?;
    let mut resources_by_id = HashMap::<String, PendingResource>::new();
    let mut ordered_ids = Vec::new();

    for link in document.select(&link_selector) {
        let Some(href) = link.value().attr("href") else {
            continue;
        };

        let Some(resource_key) = extract_query_param(href, "id") else {
            continue;
        };

        let pending = resources_by_id
            .entry(resource_key.clone())
            .or_insert_with(|| {
                ordered_ids.push(resource_key.clone());
                PendingResource::default()
            });

        if href_matches_route(href, &site.detail_path) {
            if pending.title.is_none() {
                pending.title = extract_link_title(&link);
            }

            if pending.labels.is_empty() {
                pending.labels = extract_link_labels(&link);
            }
        } else if href_matches_route(href, &site.download_path) && pending.torrent_url.is_none() {
            pending.torrent_url = Some(href_to_download_url(
                &site.base_url,
                href,
                &resource_key,
                &site.download_path,
            ));
        }
    }

    let mut resources = Vec::new();

    for resource_key in ordered_ids {
        let Some(mut pending) = resources_by_id.remove(&resource_key) else {
            continue;
        };

        let Some(title) = pending.title.take() else {
            debug!("nexusphp web item skipped without title: {}", resource_key);

            continue;
        };

        let url = absolutize_url(
            &site.base_url,
            &format!(
                "{}?id={resource_key}",
                site.detail_path.trim_start_matches('/')
            ),
        );
        let torrent_url = Some(pending.torrent_url.take().unwrap_or_else(|| {
            href_to_download_url(&site.base_url, "", &resource_key, &site.download_path)
        }));

        resources.push(ResourceCandidate {
            provider: site.key.clone(),
            title,
            detail_url: url,
            labels: pending.labels,
            payload: ResourcePayload {
                resource_key,
                torrent_url,
            },
        });
    }

    Ok(resources)
}

fn extract_link_title(link: &ElementRef<'_>) -> Option<String> {
    link.value()
        .attr("title")
        .map(str::trim)
        .filter(|title| !title.is_empty())
        .map(ToString::to_string)
        .or_else(|| {
            let text = link.text().collect::<Vec<_>>().join(" ").trim().to_string();
            (!text.is_empty()).then_some(text)
        })
}

fn extract_link_labels(link: &ElementRef<'_>) -> Vec<String> {
    let Some(row) = nearest_ancestor(link, "tr") else {
        return vec![];
    };
    let td_selector = Selector::parse("td").expect("valid td selector");
    let img_selector = Selector::parse("img[alt], img[title]").expect("valid img selector");
    let span_title_selector = Selector::parse("span[title]").expect("valid span selector");
    let badge_text_selector = Selector::parse("span, font").expect("valid badge selector");
    let mut labels = Vec::new();

    for cell in row.select(&td_selector).take(2) {
        for image in cell.select(&img_selector) {
            if let Some(label) = image
                .value()
                .attr("alt")
                .or_else(|| image.value().attr("title"))
                .and_then(normalize_label)
            {
                push_unique_label(&mut labels, label);
            }
        }

        for span in cell.select(&span_title_selector) {
            if let Some(label) = span.value().attr("title").and_then(normalize_label) {
                push_unique_label(&mut labels, label);
            }
        }

        for badge in cell.select(&badge_text_selector) {
            let text = badge.text().collect::<Vec<_>>().join(" ");

            if let Some(label) = normalize_label(&text) {
                push_unique_label(&mut labels, label);
            }
        }
    }

    labels
}

fn nearest_ancestor<'a>(element: &ElementRef<'a>, tag_name: &str) -> Option<ElementRef<'a>> {
    let mut current = element.parent();

    while let Some(node) = current {
        if let Some(ancestor) = ElementRef::wrap(node)
            && ancestor.value().name() == tag_name
        {
            return Some(ancestor);
        }

        current = node.parent();
    }

    None
}

fn normalize_label(value: &str) -> Option<String> {
    let label = value
        .replace(['[', ']'], "")
        .split_whitespace()
        .collect::<Vec<_>>()
        .join(" ")
        .trim()
        .to_string();

    if label.is_empty()
        || label.len() > 16
        || matches!(
            label.as_str(),
            "download" | "Download" | "Unbookmarked" | "收藏" | "下载本种"
        )
        || label.contains("剩余时间")
        || is_countdown_label(&label)
    {
        return None;
    }

    Some(label)
}

fn push_unique_label(labels: &mut Vec<String>, label: String) {
    if !labels.iter().any(|existing| existing == &label) {
        labels.push(label);
    }
}

fn is_countdown_label(label: &str) -> bool {
    let has_digit = label.chars().any(|ch| ch.is_ascii_digit());
    let has_time_unit = ["天", "时", "分钟", "秒"]
        .iter()
        .any(|unit| label.contains(unit));

    has_digit && has_time_unit
}

fn href_matches_route(href: &str, route_path: &str) -> bool {
    let route_name = route_path.trim_start_matches('/');
    let path = href.split_once('?').map(|(path, _)| path).unwrap_or(href);
    let path = path.trim_start_matches('/');
    let path_name = path.rsplit('/').next().unwrap_or(path);

    path_name == route_name
}

fn extract_query_param(url: &str, key: &str) -> Option<String> {
    let query = url.split_once('?')?.1;

    query.split('&').find_map(|pair| {
        let (name, value) = pair.split_once('=')?;
        (name == key).then(|| value.to_string())
    })
}

fn absolutize_url(base_url: &str, href: &str) -> Option<String> {
    if href.starts_with("http://") || href.starts_with("https://") {
        return Some(href.to_string());
    }

    let normalized_href = if href.starts_with('/') {
        href.to_string()
    } else {
        format!("/{href}")
    };

    Url::parse(base_url)
        .ok()?
        .join(&normalized_href)
        .ok()
        .map(|url| url.to_string())
}

fn href_to_download_url(
    base_url: &str,
    href: &str,
    site_resource_id: &str,
    download_path: &str,
) -> String {
    if href_matches_route(href, download_path) {
        return absolutize_url(base_url, href).unwrap_or_else(|| href.to_string());
    }

    let download_path = download_path.trim_start_matches('/');

    absolutize_url(base_url, &format!("{download_path}?id={site_resource_id}"))
        .unwrap_or_else(|| format!("{download_path}?id={site_resource_id}"))
}

fn list_url(base_url: &str, list_path: &str) -> String {
    absolutize_url(base_url, list_path).unwrap_or_else(|| base_url.to_string())
}

#[cfg(test)]
mod tests {
    use super::{absolutize_url, href_to_download_url, list_url, parse_resources};
    use crate::site::nexusphp::NexusPhpSite;

    fn test_site() -> NexusPhpSite {
        NexusPhpSite {
            key: "test-site".to_string(),
            name: "Test Site".to_string(),
            base_url: "https://site.invalid".to_string(),
            list_path: "torrents.php".to_string(),
            detail_path: "details.php".to_string(),
            download_path: "download.php".to_string(),
            search_param: "search".to_string(),
            query_params: vec![],
            link_selector: ".torrent-link".to_string(),
        }
    }

    #[test]
    fn list_url_builds_torrents_page_from_site_root() {
        let list_url = list_url("https://site.invalid", "torrents.php");

        assert_eq!(list_url, "https://site.invalid/torrents.php");
    }

    #[test]
    fn absolutize_url_joins_against_site_root_for_relative_php_pages() {
        let absolute_url = absolutize_url("https://site.invalid", "details.php?id=81833&hit=1");

        assert_eq!(
            absolute_url.as_deref(),
            Some("https://site.invalid/details.php?id=81833&hit=1")
        );
    }

    #[test]
    fn href_to_download_url_builds_root_relative_download_link() {
        let download_url = href_to_download_url(
            "https://site.invalid",
            "details.php?id=81833",
            "81833",
            "download.php",
        );

        assert_eq!(download_url, "https://site.invalid/download.php?id=81833");
    }

    #[test]
    fn parse_resources_uses_link_selector_and_custom_routes() {
        let site = NexusPhpSite {
            list_path: "browse.php".to_string(),
            detail_path: "view.php".to_string(),
            download_path: "grab.php".to_string(),
            ..test_site()
        };
        let html = r#"
            <html><body>
                <a class="torrent-link" href="view.php?id=42" title="Custom Title">Custom Title</a>
                <a class="torrent-link" href="grab.php?id=42">Download</a>
            </body></html>
        "#;

        let resources = parse_resources(html, &site).unwrap();

        assert_eq!(resources.len(), 1);
        assert_eq!(resources[0].payload.resource_key, "42");
        assert_eq!(resources[0].title, "Custom Title");
        assert_eq!(
            resources[0].detail_url.as_deref(),
            Some("https://site.invalid/view.php?id=42")
        );
        assert_eq!(
            resources[0].payload.torrent_url.as_deref(),
            Some("https://site.invalid/grab.php?id=42")
        );
    }

    #[test]
    fn parse_resources_extracts_labels_from_row_context() {
        let html = r#"
            <table>
                <tr>
                    <td class="rowfollow nowrap"><a href="?cat=405"><img alt="动漫" title="动漫" /></a></td>
                    <td class="rowfollow">
                        <a class="torrent-link" href="details.php?id=42" title="Custom Title"><b>Custom Title</b></a>
                        <b>[<font class="hot">热门</font>]</b>
                        <span style="background:#2f90b9">中字</span>
                        <img class="pro_free" alt="Free" title="Free" />
                        <span title="通过"><svg></svg></span>
                        <a class="torrent-link" href="download.php?id=42">Download</a>
                    </td>
                </tr>
            </table>
        "#;

        let resources = parse_resources(html, &test_site()).unwrap();

        assert_eq!(
            resources[0].labels,
            vec!["动漫", "Free", "通过", "热门", "中字"]
        );
    }

    #[test]
    fn parse_resources_falls_back_to_download_route_when_missing_download_link() {
        let html = r#"
            <html><body>
                <a class="torrent-link" href="details.php?id=42" title="Fallback Title">Fallback Title</a>
            </body></html>
        "#;

        let resources = parse_resources(html, &test_site()).unwrap();

        assert_eq!(resources.len(), 1);
        assert_eq!(
            resources[0].payload.torrent_url.as_deref(),
            Some("https://site.invalid/download.php?id=42")
        );
    }

    #[test]
    fn absolutize_url_keeps_absolute_urls() {
        let absolute_url =
            absolutize_url("https://site.invalid", "https://cdn.invalid/file.torrent");

        assert_eq!(
            absolute_url.as_deref(),
            Some("https://cdn.invalid/file.torrent")
        );
    }

    #[test]
    fn absolutize_url_handles_slash_prefixed_relative_paths() {
        let absolute_url = absolutize_url("https://site.invalid", "/details.php?id=7");

        assert_eq!(
            absolute_url.as_deref(),
            Some("https://site.invalid/details.php?id=7")
        );
    }
}
