// hentai.tv — large subbed/raw hentai catalogue.
//
// The site is a Next.js app backed by a clean JSON API (no RSC scraping needed):
//   GET /api/browse?page=N&sort=<Label>&genres=<ExactName> -> {videos:[28], total, pages}
//   GET /api/search?q=Q                                     -> {videos:[...]}
// `browse` honours both `sort` (labels: "Most Recent" | "Most Viewed" | "Trending") and `genres`
// (the *exact, case-sensitive* stored genre name, e.g. "Big Boobs", "incest"), and paginates;
// `search` is single-page (it ignores `page`). Episode JSON carries everything for a card plus an
// `embedUrl` of the form `https://nhplayer.com/v/{embedId}/`.
//
// Playback: yt-dlp can resolve neither the watch page nor nhplayer, and the real MP4 sits on a
// Cloudflare/JA3-guarded CDN, so `video.url` is the (reachable) watch page and the media is served
// through `/proxy/hentaitv/{embedId}.mp4`, a redirect proxy that replicates nhplayer's browser
// challenge to mint a signed CDN URL and 302s the client to it (see src/proxies/hentaitv.rs).

use crate::DbPool;
use crate::api::ClientVersion;
use crate::providers::{
    Provider, build_proxy_url, report_provider_error, report_provider_error_background,
    requester_or_default,
};
use crate::status::*;
use crate::util::cache::VideoCache;
use crate::util::time::parse_time_to_seconds;
use crate::videos::{ServerOptions, VideoFormat, VideoItem};
use async_trait::async_trait;
use serde::Deserialize;
use std::collections::HashSet;
use std::sync::{Arc, RwLock};

pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
    crate::providers::ProviderChannelMetadata {
        group_id: "hentai-animation",
        tags: &["hentai", "anime", "subbed"],
    };

const CHANNEL_ID: &str = "hentaitv";
const BASE_URL: &str = "https://hentai.tv";
const DEFAULT_PER_PAGE: usize = 28;
// One cheap JSON request per feed page; reuse it briefly so paging back and forth is instant.
const CACHE_TTL_SECS: u64 = 300;

#[derive(Debug, Clone, Deserialize)]
struct ApiVideo {
    #[serde(default)]
    slug: String,
    #[serde(default)]
    title: String,
    #[serde(default)]
    ep: Option<u32>,
    #[serde(default)]
    views: Option<u64>,
    #[serde(default)]
    rating: Option<f32>,
    #[serde(default)]
    brand: Option<String>,
    #[serde(default)]
    quality: Option<String>,
    #[serde(default)]
    duration: Option<String>,
    #[serde(default)]
    tags: Option<Vec<String>>,
    #[serde(default)]
    cover: Option<String>,
    #[serde(default)]
    thumb: Option<String>,
    #[serde(default)]
    backdrop: Option<String>,
    #[serde(rename = "embedUrl", default)]
    embed_url: Option<String>,
}

#[derive(Debug, Deserialize)]
struct ApiResponse {
    #[serde(default)]
    videos: Vec<ApiVideo>,
}

#[derive(Debug, Clone)]
enum Target {
    /// Latest / sorted catalogue feed (paginated).
    Browse { sort: String },
    /// A single genre archive (paginated), keyed by the site's exact genre name.
    Genre { name: String, sort: String },
    /// Keyword search. The site's search is single-page, so page > 1 is empty.
    Search { query: String },
}

#[derive(Debug, Clone)]
pub struct HentaitvProvider {
    /// Exact-cased genre names loaded from the `/browse` page, used both for the `categories`
    /// option and for routing keyword queries to a genre archive.
    genres: Arc<RwLock<Vec<String>>>,
}

impl HentaitvProvider {
    pub fn new() -> Self {
        let provider = Self {
            genres: Arc::new(RwLock::new(Vec::new())),
        };
        provider.spawn_genre_load();
        provider
    }

    fn build_channel(&self, _clientversion: ClientVersion) -> Channel {
        let genres = self.genres.read().map(|g| g.clone()).unwrap_or_default();

        let mut cat_options: Vec<FilterOption> = vec![FilterOption {
            id: "all".to_string(),
            title: "All".to_string(),
        }];
        for name in &genres {
            cat_options.push(FilterOption {
                id: name.clone(),
                title: name.clone(),
            });
        }

        Channel {
            id: CHANNEL_ID.to_string(),
            name: "Hentai.tv".to_string(),
            description: "Subbed and raw hentai from hentai.tv with latest, trending, and genre browsing.".to_string(),
            premium: false,
            favicon: "https://www.google.com/s2/favicons?sz=64&domain=hentai.tv".to_string(),
            status: "active".to_string(),
            categories: genres,
            options: vec![
                ChannelOption {
                    id: "sort".to_string(),
                    title: "Sort".to_string(),
                    description: "Order the feed by newest, most viewed, or trending.".to_string(),
                    systemImage: "arrow.up.arrow.down".to_string(),
                    colorName: "blue".to_string(),
                    options: vec![
                        FilterOption { id: "new".to_string(), title: "Most Recent".to_string() },
                        FilterOption { id: "views".to_string(), title: "Most Viewed".to_string() },
                        FilterOption { id: "trending".to_string(), title: "Trending".to_string() },
                    ],
                    multiSelect: false,
                },
                ChannelOption {
                    id: "categories".to_string(),
                    title: "Genre".to_string(),
                    description: "Limit results to a single genre archive.".to_string(),
                    systemImage: "tag".to_string(),
                    colorName: "pink".to_string(),
                    options: cat_options,
                    multiSelect: false,
                },
            ],
            nsfw: true,
            cacheDuration: Some(1800),
            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
        }
    }

    /// Map a `sort` option id to the exact label `/api/browse?sort=` expects.
    fn sort_label(sort: &str) -> &'static str {
        match sort {
            "views" | "popular" | "most-viewed" => "Most Viewed",
            "trending" | "hot" => "Trending",
            _ => "Most Recent",
        }
    }

    /// Normalise a free-text genre query so `school-girl`, `School Girl`, and `school girl` all
    /// resolve to the same catalogue entry.
    fn normalize_genre(value: &str) -> String {
        value
            .trim()
            .to_ascii_lowercase()
            .replace(['-', '_', '+'], " ")
            .split_whitespace()
            .collect::<Vec<_>>()
            .join(" ")
    }

    /// Resolve an arbitrary input to the site's exact-cased genre name, if any.
    fn resolve_genre(&self, input: &str) -> Option<String> {
        let needle = Self::normalize_genre(input);
        if needle.is_empty() {
            return None;
        }
        let genres = self.genres.read().ok()?;
        genres
            .iter()
            .find(|name| Self::normalize_genre(name) == needle)
            .cloned()
    }

    fn pick_target(&self, query: Option<&str>, sort: &str, options: &ServerOptions) -> Target {
        let sort = Self::sort_label(sort).to_string();

        if let Some(raw) = query {
            let q = raw.trim();
            if !q.is_empty() {
                for prefix in ["genre:", "category:", "cat:"] {
                    if let Some(rest) = q.strip_prefix(prefix) {
                        let name = self
                            .resolve_genre(rest)
                            .unwrap_or_else(|| rest.trim().to_string());
                        return Target::Genre { name, sort };
                    }
                }
                // A bare keyword that exactly matches a genre goes to that archive, otherwise fall
                // back to the site's keyword search.
                if let Some(name) = self.resolve_genre(q) {
                    return Target::Genre { name, sort };
                }
                return Target::Search { query: q.to_string() };
            }
        }

        // An explicit genre selection from the `categories` option.
        if let Some(selected) = options.categories.as_deref() {
            let selected = selected.trim();
            if !selected.is_empty() && selected != "all" {
                let name = self
                    .resolve_genre(selected)
                    .unwrap_or_else(|| selected.to_string());
                return Target::Genre { name, sort };
            }
        }

        Target::Browse { sort }
    }

    fn encode(value: &str) -> String {
        url::form_urlencoded::byte_serialize(value.as_bytes()).collect()
    }

    /// Build the JSON API URL for a target/page. Returns `None` when the request would be empty by
    /// construction (search beyond page 1).
    fn build_api_url(target: &Target, page: u32) -> Option<String> {
        let page = page.max(1);
        match target {
            Target::Browse { sort } => Some(format!(
                "{BASE_URL}/api/browse?page={page}&sort={}",
                Self::encode(sort)
            )),
            Target::Genre { name, sort } => Some(format!(
                "{BASE_URL}/api/browse?page={page}&sort={}&genres={}",
                Self::encode(sort),
                Self::encode(name)
            )),
            Target::Search { query } => {
                if page > 1 {
                    None
                } else {
                    Some(format!("{BASE_URL}/api/search?q={}", Self::encode(query)))
                }
            }
        }
    }

    fn abs_url(path: &str) -> String {
        if path.starts_with("http://") || path.starts_with("https://") {
            path.to_string()
        } else if path.starts_with('/') {
            format!("{BASE_URL}{path}")
        } else {
            format!("{BASE_URL}/{path}")
        }
    }

    fn pick_thumb(video: &ApiVideo) -> String {
        for candidate in [&video.thumb, &video.backdrop, &video.cover] {
            if let Some(path) = candidate.as_deref().filter(|s| !s.is_empty()) {
                return Self::abs_url(path);
            }
        }
        String::new()
    }

    fn extract_embed_id(embed_url: &str) -> Option<String> {
        let after = embed_url.split("/v/").nth(1)?;
        let id = after.trim_matches('/').split('/').next()?.split('?').next()?;
        (!id.is_empty()).then(|| id.to_string())
    }

    fn build_item(video: &ApiVideo, options: &ServerOptions) -> Option<VideoItem> {
        if video.slug.is_empty() {
            return None;
        }
        let embed_id = Self::extract_embed_id(video.embed_url.as_deref().unwrap_or_default())?;

        let base_title = video.title.trim();
        let title = match video.ep {
            _ if base_title.is_empty() => video.slug.replace('-', " "),
            Some(ep) if ep >= 1 => format!("{base_title} Episode {ep}"),
            _ => base_title.to_string(),
        };

        let url = format!("{BASE_URL}/hentai/{}", video.slug);
        let thumb = Self::pick_thumb(video);
        let duration = video
            .duration
            .as_deref()
            .and_then(parse_time_to_seconds)
            .and_then(|s| u32::try_from(s).ok())
            .unwrap_or(0);

        let quality = video.quality.as_deref().unwrap_or_default().to_string();
        let label = if quality.is_empty() {
            "mp4".to_string()
        } else {
            quality.clone()
        };
        let proxy_url = build_proxy_url(options, CHANNEL_ID, &format!("{embed_id}.mp4"));
        let mut format = VideoFormat::new(proxy_url, label, "mp4".to_string()).ext("mp4".to_string());
        if let Some(height) = quality
            .chars()
            .filter(|c| c.is_ascii_digit())
            .collect::<String>()
            .parse::<u32>()
            .ok()
            .filter(|h| *h > 0)
        {
            format = format.height(height);
        }

        let mut item = VideoItem::new(
            video.slug.clone(),
            title,
            url,
            CHANNEL_ID.to_string(),
            thumb,
            duration,
        )
        .formats(vec![format])
        .aspect_ratio(16.0 / 9.0);

        if let Some(views) = video.views {
            item.views = Some(views.min(u32::MAX as u64) as u32);
        }
        if let Some(rating) = video.rating {
            // Site rating is 0-10; expose the usual 0-100 scale.
            item.rating = Some((rating * 10.0).clamp(0.0, 100.0));
        }
        if let Some(tags) = video.tags.clone().filter(|t| !t.is_empty()) {
            item.tags = Some(tags);
        }
        if let Some(brand) = video.brand.as_deref().filter(|b| !b.trim().is_empty()) {
            item.uploader = Some(brand.to_string());
        }

        Some(item)
    }

    async fn fetch_page(
        &self,
        target: Target,
        page: u32,
        per_page: usize,
        cache: &VideoCache,
        options: &ServerOptions,
    ) -> Vec<VideoItem> {
        let Some(api_url) = Self::build_api_url(&target, page) else {
            return vec![];
        };

        if let Some((time, items)) = cache.get(&api_url) {
            let fresh = time
                .elapsed()
                .map(|e| e.as_secs() < CACHE_TTL_SECS)
                .unwrap_or(false);
            if fresh && !items.is_empty() {
                return items;
            }
        }

        let mut requester = requester_or_default(options, CHANNEL_ID, "fetch_page");
        let body = match requester
            .get_with_headers(
                &api_url,
                vec![
                    ("Referer".to_string(), format!("{BASE_URL}/")),
                    ("Accept".to_string(), "application/json".to_string()),
                ],
                None,
            )
            .await
        {
            Ok(body) => body,
            Err(e) => {
                report_provider_error(CHANNEL_ID, "fetch_page.request", &format!("url={api_url}; error={e}")).await;
                return cache.get(&api_url).map(|(_, items)| items).unwrap_or_default();
            }
        };

        let response: ApiResponse = match serde_json::from_str(&body) {
            Ok(response) => response,
            Err(e) => {
                report_provider_error(CHANNEL_ID, "fetch_page.parse", &format!("url={api_url}; error={e}")).await;
                return cache.get(&api_url).map(|(_, items)| items).unwrap_or_default();
            }
        };

        let items: Vec<VideoItem> = response
            .videos
            .iter()
            .take(per_page)
            .filter_map(|video| Self::build_item(video, options))
            .collect();

        if !items.is_empty() {
            cache.insert(api_url, items.clone());
            items
        } else {
            cache.get(&api_url).map(|(_, items)| items).unwrap_or_default()
        }
    }

    fn spawn_genre_load(&self) {
        let genres = self.genres.clone();
        std::thread::spawn(move || {
            let runtime = match tokio::runtime::Builder::new_current_thread().enable_all().build() {
                Ok(rt) => rt,
                Err(e) => {
                    report_provider_error_background("hentaitv", "spawn_genre_load.runtime", &e.to_string());
                    return;
                }
            };
            runtime.block_on(async move {
                let mut requester = crate::util::requester::Requester::new();
                let html = match requester
                    .get_with_headers(
                        &format!("{BASE_URL}/browse"),
                        vec![("Referer".to_string(), format!("{BASE_URL}/"))],
                        None,
                    )
                    .await
                {
                    Ok(html) => html,
                    Err(e) => {
                        report_provider_error_background("hentaitv", "spawn_genre_load.fetch", &e.to_string());
                        return;
                    }
                };
                let parsed = Self::parse_genres(&html);
                if !parsed.is_empty() {
                    if let Ok(mut guard) = genres.write() {
                        *guard = parsed;
                    }
                }
            });
        });
    }

    /// Pull the exact-cased genre names out of the `/browse` page payload, where they appear as
    /// `"genres":[{"name":"Big Boobs","count":2219}, ...]`.
    fn parse_genres(html: &str) -> Vec<String> {
        let unescaped = html.replace("\\\"", "\"");
        let mut out = Vec::new();
        let mut seen = HashSet::new();
        for chunk in unescaped.split("\"name\":\"").skip(1) {
            let Some(end) = chunk.find('"') else { continue };
            let name = &chunk[..end];
            // Only accept entries immediately followed by a "count" field — that is what
            // distinguishes the genre catalogue from other `"name"` objects on the page.
            let tail = chunk[end..].trim_start_matches('"').trim_start();
            if name.is_empty() || !tail.starts_with(",\"count\"") {
                continue;
            }
            if seen.insert(name.to_string()) {
                out.push(name.to_string());
            }
        }
        out
    }
}

#[async_trait]
impl Provider for HentaitvProvider {
    async fn get_videos(
        &self,
        cache: VideoCache,
        _pool: DbPool,
        sort: String,
        query: Option<String>,
        page: String,
        per_page: String,
        options: ServerOptions,
    ) -> Vec<VideoItem> {
        let page = page.parse::<u32>().unwrap_or(1).max(1);
        let per_page = per_page
            .parse::<usize>()
            .unwrap_or(DEFAULT_PER_PAGE)
            .clamp(1, DEFAULT_PER_PAGE);

        let normalized_query = query
            .as_deref()
            .map(str::trim)
            .filter(|q| !q.is_empty())
            .map(ToOwned::to_owned);

        let target = self.pick_target(normalized_query.as_deref(), &sort, &options);

        self.fetch_page(target, page, per_page, &cache, &options).await
    }

    fn get_channel(&self, clientversion: ClientVersion) -> Option<Channel> {
        Some(self.build_channel(clientversion))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn sort_labels_map_to_api_values() {
        assert_eq!(HentaitvProvider::sort_label("new"), "Most Recent");
        assert_eq!(HentaitvProvider::sort_label("views"), "Most Viewed");
        assert_eq!(HentaitvProvider::sort_label("trending"), "Trending");
        assert_eq!(HentaitvProvider::sort_label("whatever"), "Most Recent");
    }

    #[test]
    fn builds_api_urls() {
        let browse = Target::Browse { sort: "Most Recent".to_string() };
        assert_eq!(
            HentaitvProvider::build_api_url(&browse, 2).unwrap(),
            "https://hentai.tv/api/browse?page=2&sort=Most+Recent"
        );

        let genre = Target::Genre { name: "Big Boobs".to_string(), sort: "Most Viewed".to_string() };
        assert_eq!(
            HentaitvProvider::build_api_url(&genre, 1).unwrap(),
            "https://hentai.tv/api/browse?page=1&sort=Most+Viewed&genres=Big+Boobs"
        );

        let search = Target::Search { query: "school nurse".to_string() };
        assert_eq!(
            HentaitvProvider::build_api_url(&search, 1).unwrap(),
            "https://hentai.tv/api/search?q=school+nurse"
        );
        // Search has no pagination.
        assert!(HentaitvProvider::build_api_url(&search, 2).is_none());
    }

    #[test]
    fn extracts_embed_id() {
        assert_eq!(
            HentaitvProvider::extract_embed_id("https://nhplayer.com/v/M2WMSkkRmf5wJvq/").as_deref(),
            Some("M2WMSkkRmf5wJvq")
        );
        assert_eq!(
            HentaitvProvider::extract_embed_id("https://nhplayer.com/v/abc123?x=1").as_deref(),
            Some("abc123")
        );
        assert_eq!(HentaitvProvider::extract_embed_id("https://example.com/foo"), None);
    }

    #[test]
    fn parses_genre_catalogue() {
        let html = r#"...,"genres":[{"name":"Big Boobs","count":2219},{"name":"incest","count":476}],"blacklist":[]..."#;
        let genres = HentaitvProvider::parse_genres(html);
        assert_eq!(genres, vec!["Big Boobs".to_string(), "incest".to_string()]);
    }

    #[test]
    fn normalizes_genres() {
        assert_eq!(HentaitvProvider::normalize_genre("Big Boobs"), "big boobs");
        assert_eq!(HentaitvProvider::normalize_genre("school-girl"), "school girl");
        assert_eq!(HentaitvProvider::normalize_genre("  NTR  "), "ntr");
    }
}
