use crate::DbPool;
use crate::api::ClientVersion;
use crate::providers::{Provider, report_provider_error, report_provider_error_background, requester_or_default};
use crate::status::*;
use crate::util::cache::VideoCache;
use crate::util::requester::Requester;
use crate::videos::{ServerOptions, VideoItem};
use async_trait::async_trait;
use error_chain::error_chain;
use htmlentity::entity::{ICodedDataTrait, decode};
use percent_encoding::{NON_ALPHANUMERIC, percent_decode_str, utf8_percent_encode};
use regex::Regex;
use scraper::{ElementRef, Html, Selector};
use std::sync::{Arc, RwLock};
use std::thread;
use std::time::Duration as StdDuration;
use tokio::time::timeout;
use wreq::Version;

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

error_chain! {
    foreign_links {
        Io(std::io::Error);
    }
    errors {
        Parse(msg: String) {
            description("parse error")
            display("parse error: {}", msg)
        }
    }
}

const BASE_URL: &str = "https://xlecx.one";
const CHANNEL_ID: &str = "xlecx";
const USER_AGENT: &str =
    "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36";
const HTML_ACCEPT: &str =
    "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8";
const RESULTS_PER_PAGE: u32 = 18;

#[derive(Debug, Clone)]
pub struct XlecxProvider {
    url: String,
    tags: Arc<RwLock<Vec<FilterOption>>>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Category {
    Videos,
    HentaiVideo,
}

impl Category {
    fn from_option(value: Option<&str>) -> Self {
        match value {
            Some("hentai-video") => Category::HentaiVideo,
            _ => Category::Videos,
        }
    }

    fn cat_param(self) -> &'static str {
        match self {
            Category::Videos => "18",
            Category::HentaiVideo => "20",
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SortMode {
    Date,
    Title,
    Comments,
    Views,
    Rating,
}

impl SortMode {
    fn from_sort(sort: &str) -> Self {
        match sort {
            "title" => SortMode::Title,
            "commented" => SortMode::Comments,
            "popular" => SortMode::Views,
            "rated" => SortMode::Rating,
            _ => SortMode::Date,
        }
    }

    fn sort_param(self) -> &'static str {
        match self {
            SortMode::Date => "date",
            SortMode::Title => "title",
            SortMode::Comments => "comm_num",
            SortMode::Views => "news_read",
            SortMode::Rating => "rating",
        }
    }
}

#[derive(Debug, Clone)]
enum Target {
    Archive { category: Category, sort: SortMode },
    Tag { slug: String },
    Search { query: String },
}

impl XlecxProvider {
    pub fn new() -> Self {
        let provider = Self {
            url: BASE_URL.to_string(),
            tags: Arc::new(RwLock::new(Vec::new())),
        };
        provider.spawn_initial_load();
        provider
    }

    fn spawn_initial_load(&self) {
        let url = self.url.clone();
        let tags = Arc::clone(&self.tags);

        thread::spawn(move || {
            let runtime = match tokio::runtime::Builder::new_current_thread()
                .enable_all()
                .build()
            {
                Ok(runtime) => runtime,
                Err(error) => {
                    report_provider_error_background(
                        CHANNEL_ID,
                        "spawn_initial_load.runtime_build",
                        &error.to_string(),
                    );
                    return;
                }
            };

            runtime.block_on(async move {
                if let Err(error) = Self::load_tags(&url, Arc::clone(&tags)).await {
                    report_provider_error_background(CHANNEL_ID, "load_tags", &error.to_string());
                }
            });
        });
    }

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

        Channel {
            id: CHANNEL_ID.to_string(),
            name: "XlecX".to_string(),
            description: "Hentai videos and parody animations.".to_string(),
            premium: false,
            favicon: "https://www.google.com/s2/favicons?sz=64&domain=xlecx.one".to_string(),
            status: "active".to_string(),
            categories: tags.iter().map(|value| value.title.clone()).collect(),
            options: vec![
                ChannelOption {
                    id: "sort".to_string(),
                    title: "Sort".to_string(),
                    description: "Sort the videos".to_string(),
                    systemImage: "list.number".to_string(),
                    colorName: "blue".to_string(),
                    options: vec![
                        FilterOption {
                            id: "new".to_string(),
                            title: "Most Recent".to_string(),
                        },
                        FilterOption {
                            id: "popular".to_string(),
                            title: "Most Viewed".to_string(),
                        },
                        FilterOption {
                            id: "commented".to_string(),
                            title: "Most Commented".to_string(),
                        },
                        FilterOption {
                            id: "rated".to_string(),
                            title: "Top Rated".to_string(),
                        },
                    ],
                    multiSelect: false,
                },
                ChannelOption {
                    id: "category".to_string(),
                    title: "Category".to_string(),
                    description: "Filter by category".to_string(),
                    systemImage: "square.grid.2x2".to_string(),
                    colorName: "orange".to_string(),
                    options: vec![
                        FilterOption {
                            id: "videos".to_string(),
                            title: "Videos".to_string(),
                        },
                        FilterOption {
                            id: "hentai-video".to_string(),
                            title: "Hentai Video".to_string(),
                        },
                    ],
                    multiSelect: false,
                },
                ChannelOption {
                    id: "filter".to_string(),
                    title: "Tags".to_string(),
                    description: "Video tags.".to_string(),
                    systemImage: "tag.fill".to_string(),
                    colorName: "green".to_string(),
                    options: tags,
                    multiSelect: false,
                },
            ],
            nsfw: true,
            cacheDuration: Some(1800),
            ytdlpCommand: Some("yt-dlp".to_string()),
        }
    }

    fn selector(value: &str) -> Result<Selector> {
        Selector::parse(value)
            .map_err(|error| Error::from(format!("selector `{value}` parse failed: {error}")))
    }

    fn collapse_whitespace(text: &str) -> String {
        text.split_whitespace().collect::<Vec<_>>().join(" ")
    }

    fn decode_html_entities(text: &str) -> String {
        decode(text.as_bytes())
            .to_string()
            .unwrap_or_else(|_| text.to_string())
            .replace('\u{a0}', " ")
            .trim()
            .to_string()
    }

    fn absolute_url(&self, value: &str) -> String {
        if value.starts_with("http://") || value.starts_with("https://") {
            return value.to_string();
        }
        format!(
            "{}/{}",
            self.url.trim_end_matches('/'),
            value.trim_start_matches('/')
        )
    }

    fn html_headers(&self, referer: &str) -> Vec<(String, String)> {
        vec![
            ("Referer".to_string(), referer.to_string()),
            ("User-Agent".to_string(), USER_AGENT.to_string()),
            ("Accept".to_string(), HTML_ACCEPT.to_string()),
            ("Accept-Language".to_string(), "en-US,en;q=0.9".to_string()),
        ]
    }

    async fn fetch_html(
        &self,
        requester: &mut Requester,
        url: &str,
        referer: &str,
    ) -> Result<String> {
        requester
            .get_with_headers(url, self.html_headers(referer), Some(Version::HTTP_11))
            .await
            .map_err(|error| Error::from(format!("request failed for {url}: {error}")))
    }

    fn push_unique(target: &Arc<RwLock<Vec<FilterOption>>>, item: FilterOption) {
        if item.id.is_empty() || item.title.is_empty() {
            return;
        }
        if let Ok(mut values) = target.write() {
            if !values
                .iter()
                .any(|existing| existing.id == item.id || existing.title == item.title)
            {
                values.push(item);
            }
        }
    }

    async fn load_tags(base_url: &str, tags: Arc<RwLock<Vec<FilterOption>>>) -> Result<()> {
        let mut requester = Requester::new();
        let provider = Self {
            url: base_url.to_string(),
            tags: Arc::clone(&tags),
        };
        let html = provider
            .fetch_html(
                &mut requester,
                &format!("{}/tags/", base_url),
                &format!("{}/", base_url),
            )
            .await?;
        let document = Html::parse_document(&html);
        // Tag cloud entries are plain <a href="/tags/{slug}/">{title}</a> links
        let a_selector = Self::selector(".tags-list1 a[href*='/tags/']")?;
        for element in document.select(&a_selector) {
            let Some(href) = element.value().attr("href") else {
                continue;
            };
            let raw_slug = href.trim_end_matches('/').rsplit('/').next().unwrap_or("");
            if raw_slug.is_empty() || raw_slug.eq_ignore_ascii_case("tags") {
                continue;
            }
            let slug = percent_decode_str(raw_slug)
                .decode_utf8()
                .map(|value| value.to_string())
                .unwrap_or_else(|_| raw_slug.to_string());
            let title = Self::decode_html_entities(&Self::collapse_whitespace(
                &element.text().collect::<String>(),
            ));
            if title.is_empty() {
                continue;
            }
            Self::push_unique(&tags, FilterOption { id: slug, title });
        }
        Ok(())
    }

    fn resolve_target(&self, options: &ServerOptions, sort: &str, query: Option<&str>) -> Target {
        if let Some(q) = query {
            if let Some(target) = self.find_tag_target_in_options(q) {
                return target;
            }
            return Target::Search {
                query: q.trim().to_string(),
            };
        }
        if let Some(value) = options.filter.as_deref() {
            if let Some(target) = self.find_tag_target_in_options(value) {
                return target;
            }
        }
        let category = Category::from_option(options.category.as_deref());
        Target::Archive {
            category,
            sort: SortMode::from_sort(sort),
        }
    }

    fn find_tag_target_in_options(&self, value: &str) -> Option<Target> {
        let normalized = value.trim().to_lowercase();
        let tags = self.tags.read().ok()?;
        let option = tags.iter().find(|item| {
            item.id.eq_ignore_ascii_case(value) || item.title.trim().to_lowercase() == normalized
        })?;
        Some(Target::Tag {
            slug: option.id.clone(),
        })
    }

    fn build_url_for_target(&self, target: &Target, page: u32) -> String {
        match target {
            Target::Archive { category, sort } => self.build_archive_url(*category, *sort, page),
            Target::Tag { slug } => self.build_tag_url(slug, page),
            Target::Search { query } => self.build_search_url(query, page),
        }
    }

    fn build_archive_url(&self, category: Category, sort: SortMode, page: u32) -> String {
        let base = format!(
            "{}/f/cat={}/sort={}/order=desc/",
            self.url,
            category.cat_param(),
            sort.sort_param()
        );
        if page <= 1 {
            base
        } else {
            format!("{base}page/{page}/")
        }
    }

    fn build_tag_url(&self, slug: &str, page: u32) -> String {
        let encoded_slug = utf8_percent_encode(slug, NON_ALPHANUMERIC).to_string();
        if page <= 1 {
            format!("{}/tags/{}/", self.url, encoded_slug)
        } else {
            format!("{}/tags/{}/page/{}/", self.url, encoded_slug, page)
        }
    }

    fn build_search_url(&self, query: &str, page: u32) -> String {
        let encoded_query = utf8_percent_encode(query, NON_ALPHANUMERIC).to_string();
        if page <= 1 {
            format!(
                "{}/index.php?do=search&subaction=search&story={}",
                self.url, encoded_query
            )
        } else {
            format!(
                "{}/index.php?do=search&subaction=search&search_start={}&full_search=0&story={}",
                self.url, page, encoded_query
            )
        }
    }

    fn parse_card(&self, card: ElementRef<'_>) -> Option<VideoItem> {
        let title_sel = Self::selector("h3.thumb__title").ok()?;
        let img_sel = Self::selector("img").ok()?;

        let href = card.value().attr("href")?;
        let id_re = Regex::new(r"/(\d+)-[^/]+\.html$").ok()?;
        let id = id_re.captures(href)?.get(1)?.as_str().to_string();

        let url = if href.starts_with("http") {
            href.to_string()
        } else {
            self.absolute_url(href)
        };

        let title = card
            .select(&title_sel)
            .next()
            .map(|e| Self::decode_html_entities(&Self::collapse_whitespace(&e.text().collect::<String>())))
            .filter(|t| !t.is_empty())?;

        let thumb = card
            .select(&img_sel)
            .next()
            .and_then(|e| e.value().attr("src"))
            .map(|s| {
                if s.starts_with("http") {
                    s.to_string()
                } else {
                    self.absolute_url(s)
                }
            })
            .filter(|s| !s.is_empty())?;

        // Gallery entries show a "N img" label instead of a duration; only keep real videos.
        let card_html = card.html();
        let dur_re = Regex::new(r#"thumb__label--time[^>]*>(\d{1,2}:\d{2}(?::\d{2})?)<"#).ok()?;
        let duration_text = dur_re.captures(&card_html)?.get(1)?.as_str().to_string();
        let duration = parse_duration_mm_ss(&duration_text);

        Some(VideoItem::new(
            id,
            title,
            url,
            CHANNEL_ID.to_string(),
            thumb,
            duration,
        ))
    }

    fn get_video_items_from_html(&self, html: String) -> Result<Vec<VideoItem>> {
        let document = Html::parse_document(&html);
        let card_selector = Self::selector("a.thumb")?;

        let mut items = Vec::new();
        for card in document.select(&card_selector) {
            if let Some(item) = self.parse_card(card) {
                items.push(item);
            }
        }
        Ok(items)
    }

    async fn fetch_items_for_url(
        &self,
        cache: VideoCache,
        url: String,
        per_page_limit: usize,
        options: &ServerOptions,
    ) -> Result<Vec<VideoItem>> {
        if let Some((time, items)) = cache.get(&url) {
            if time.elapsed().unwrap_or_default().as_secs() < 60 * 5 {
                return Ok(items.clone());
            }
        }

        let mut requester = requester_or_default(options, CHANNEL_ID, "fetch_items_for_url");
        let html = timeout(
            StdDuration::from_secs(10),
            self.fetch_html(&mut requester, &url, &url),
        )
        .await
        .map_err(|_| Error::from(format!("list request timed out for {url}")))??;

        let list_items = self.get_video_items_from_html(html)?;
        let limited_items = list_items
            .into_iter()
            .take(per_page_limit.max(1))
            .collect::<Vec<_>>();

        if !limited_items.is_empty() {
            cache.insert(url, limited_items.clone());
        }

        Ok(limited_items)
    }

    async fn get(
        &self,
        cache: VideoCache,
        page: u32,
        sort: &str,
        query: Option<&str>,
        per_page_limit: usize,
        options: ServerOptions,
    ) -> Result<Vec<VideoItem>> {
        let target = self.resolve_target(&options, sort, query);
        let url = self.build_url_for_target(&target, page);
        self.fetch_items_for_url(cache, url, per_page_limit, &options)
            .await
    }
}

/// Parse "MM:SS" or "HH:MM:SS" into total seconds.
fn parse_duration_mm_ss(text: &str) -> u32 {
    let parts: Vec<u32> = text
        .split(':')
        .filter_map(|p| p.trim().parse().ok())
        .collect();
    match parts.as_slice() {
        [h, m, s] => h * 3600 + m * 60 + s,
        [m, s] => m * 60 + s,
        [s] => *s,
        _ => 0,
    }
}

#[async_trait]
impl Provider for XlecxProvider {
    async fn get_videos(
        &self,
        cache: VideoCache,
        pool: DbPool,
        sort: String,
        query: Option<String>,
        page: String,
        per_page: String,
        options: ServerOptions,
    ) -> Vec<VideoItem> {
        let _ = pool;
        let page = page.parse::<u32>().unwrap_or(1);
        let per_page_limit = per_page.parse::<usize>().unwrap_or(RESULTS_PER_PAGE as usize);
        let query_ref = query.as_deref().filter(|q| !q.trim().is_empty());

        let result = self.get(cache, page, &sort, query_ref, per_page_limit, options).await;

        match result {
            Ok(videos) => videos,
            Err(error) => {
                report_provider_error(CHANNEL_ID, "get_videos", &error.to_string()).await;
                vec![]
            }
        }
    }

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

#[cfg(test)]
mod tests {
    use super::*;
    use crate::util::cache::VideoCache;
    use crate::util::requester::Requester;

    fn provider() -> XlecxProvider {
        XlecxProvider::new()
    }

    #[test]
    fn builds_archive_urls() {
        let provider = provider();
        assert_eq!(
            provider.build_archive_url(Category::Videos, SortMode::Date, 1),
            "https://xlecx.one/f/cat=18/sort=date/order=desc/"
        );
        assert_eq!(
            provider.build_archive_url(Category::Videos, SortMode::Date, 2),
            "https://xlecx.one/f/cat=18/sort=date/order=desc/page/2/"
        );
        assert_eq!(
            provider.build_archive_url(Category::HentaiVideo, SortMode::Views, 1),
            "https://xlecx.one/f/cat=20/sort=news_read/order=desc/"
        );
    }

    #[test]
    fn builds_tag_urls() {
        let provider = provider();
        assert_eq!(
            provider.build_tag_url("yuri", 1),
            "https://xlecx.one/tags/yuri/"
        );
        assert_eq!(
            provider.build_tag_url("2d animation", 2),
            "https://xlecx.one/tags/2d%20animation/page/2/"
        );
    }

    #[test]
    fn builds_search_urls() {
        let provider = provider();
        assert_eq!(
            provider.build_search_url("panty", 1),
            "https://xlecx.one/index.php?do=search&subaction=search&story=panty"
        );
        assert_eq!(
            provider.build_search_url("panty", 2),
            "https://xlecx.one/index.php?do=search&subaction=search&search_start=2&full_search=0&story=panty"
        );
    }

    #[test]
    fn parses_duration() {
        assert_eq!(parse_duration_mm_ss("02:25"), 145);
        assert_eq!(parse_duration_mm_ss("0:29"), 29);
        assert_eq!(parse_duration_mm_ss("1:05:30"), 3930);
    }

    #[test]
    fn parses_card_and_skips_galleries() {
        let provider = provider();
        let html = r#"
        <div id="dle-content">
        <a class="thumb d-flex fd-column grid-item thumb--model" href="https://xlecx.one/62329-panty-and-stocking.html">
            <div class="thumb__desc">
                <h3 class="thumb__title ws-nowrap">Panty and Stocking</h3>
            </div>
            <div class="thumb__img">
                <img src="/uploads/posts/2026-07/weurwe8fhuif9wef.webp" alt="Panty and Stocking">
                <div class="thumb__label thumb__label--time flex-grow-1">02:25</div>
            </div>
        </a>
        <a class="thumb d-flex fd-column grid-item thumb--model" href="https://xlecx.one/9999-some-comic.html">
            <div class="thumb__desc">
                <h3 class="thumb__title ws-nowrap">Some Comic</h3>
            </div>
            <div class="thumb__img">
                <img src="/uploads/posts/2026-07/othercover.webp" alt="Some Comic">
                <div class="thumb__label thumb__label--time flex-grow-1">57 img</div>
            </div>
        </a>
        </div>
        "#;
        let items = provider.get_video_items_from_html(html.to_string()).unwrap();
        assert_eq!(items.len(), 1);
        assert_eq!(items[0].id, "62329");
        assert_eq!(items[0].title, "Panty and Stocking");
        assert_eq!(items[0].duration, 145);
        assert_eq!(items[0].url, "https://xlecx.one/62329-panty-and-stocking.html");
    }

    #[tokio::test]
    #[ignore]
    async fn fetches_and_parses_archive() {
        let provider = provider();
        let options = ServerOptions {
            client_version: None,
            featured: None,
            category: None,
            sites: None,
            filter: None,
            language: None,
            public_url_base: Some("http://127.0.0.1:18080".to_string()),
            requester: Some(Requester::new()),
            network: None,
            stars: None,
            categories: None,
            duration: None,
            sort: Some("new".to_string()),
            sexuality: None,
        };
        let videos = provider.get(VideoCache::new(), 1, "new", None, 10, options).await.unwrap();
        assert!(!videos.is_empty());
    }
}
