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::uploaders::{
    UploaderChannelStat, UploaderLayoutRow, UploaderProfile, UploaderVideoRef,
};
use crate::util::cache::VideoCache;
use crate::util::parse_abbreviated_number;
use crate::util::requester::Requester;
use crate::util::time::parse_time_to_seconds;
use crate::videos::{ServerOptions, VideoFormat, VideoItem};
use async_trait::async_trait;
use chrono::{DateTime, Duration as ChronoDuration, NaiveDate, Utc};
use error_chain::error_chain;
use futures::stream::{self, StreamExt};
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::time::Duration as StdDuration;
use std::{thread, vec};
use tokio::time::timeout;
use url::Url;
use wreq::Version;

pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
    crate::providers::ProviderChannelMetadata {
        group_id: "chinese",
        tags: &["amateur", "chinese", "homemade"],
    };

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

const BASE_URL: &str = "https://hsex.tv";
const CHANNEL_ID: &str = "hsex";
const FIREFOX_UA: &str =
    "Mozilla/5.0 (X11; Linux x86_64; rv:146.0) Gecko/20100101 Firefox/146.0";
const HTML_ACCEPT: &str =
    "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8";

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

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ArchiveMode {
    Latest,
    Hot,
    Weekly,
    Monthly,
    FiveMinLatest,
    FiveMinHot,
    TenMinLatest,
    TenMinHot,
}

#[derive(Debug, Clone)]
enum Target {
    Archive(ArchiveMode),
    Search {
        query: String,
        sort: Option<String>,
    },
    Uploader {
        author: String,
    },
}

impl HsexProvider {
    pub fn new() -> Self {
        let provider = Self {
            url: BASE_URL.to_string(),
            tags: Arc::new(RwLock::new(vec![FilterOption {
                id: "all".to_string(),
                title: "All".to_string(),
            }])),
            uploaders: Arc::new(RwLock::new(vec![FilterOption {
                id: "all".to_string(),
                title: "All".to_string(),
            }])),
        };
        provider.spawn_initial_load();
        provider
    }

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

        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_hot_searches(&url, Arc::clone(&tags)).await {
                    report_provider_error_background(
                        CHANNEL_ID,
                        "load_hot_searches",
                        &error.to_string(),
                    );
                }
                if let Err(error) = Self::load_uploaders(&url, Arc::clone(&uploaders)).await {
                    report_provider_error_background(
                        CHANNEL_ID,
                        "load_uploaders",
                        &error.to_string(),
                    );
                }
            });
        });
    }

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

        Channel {
            id: CHANNEL_ID.to_string(),
            name: "Hsex".to_string(),
            description:
                "Hsex.tv videos with hot-search filters, uploader archives, and direct HLS formats."
                    .to_string(),
            premium: false,
            favicon: "https://www.google.com/s2/favicons?sz=64&domain=hsex.tv".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: "Browse Hsex archive pages.".to_string(),
                    systemImage: "list.number".to_string(),
                    colorName: "blue".to_string(),
                    options: vec![
                        FilterOption {
                            id: "new".to_string(),
                            title: "Latest".to_string(),
                        },
                        FilterOption {
                            id: "hot".to_string(),
                            title: "Hottest".to_string(),
                        },
                        FilterOption {
                            id: "weekly".to_string(),
                            title: "Weekly Top".to_string(),
                        },
                        FilterOption {
                            id: "monthly".to_string(),
                            title: "Monthly Top".to_string(),
                        },
                        FilterOption {
                            id: "five_min_new".to_string(),
                            title: "5-10 Min Latest".to_string(),
                        },
                        FilterOption {
                            id: "five_min_hot".to_string(),
                            title: "5-10 Min Hot".to_string(),
                        },
                        FilterOption {
                            id: "ten_min_new".to_string(),
                            title: "10+ Min Latest".to_string(),
                        },
                        FilterOption {
                            id: "ten_min_hot".to_string(),
                            title: "10+ Min Hot".to_string(),
                        },
                    ],
                    multiSelect: false,
                },
                ChannelOption {
                    id: "filter".to_string(),
                    title: "Tags".to_string(),
                    description: "Home page hot-search shortcuts.".to_string(),
                    systemImage: "tag.fill".to_string(),
                    colorName: "green".to_string(),
                    options: tags,
                    multiSelect: false,
                },
                ChannelOption {
                    id: "sites".to_string(),
                    title: "Uploaders".to_string(),
                    description: "Uploader archive pages discovered from Hsex lists.".to_string(),
                    systemImage: "person.crop.square".to_string(),
                    colorName: "purple".to_string(),
                    options: uploaders,
                    multiSelect: false,
                },
            ],
            nsfw: true,
            cacheDuration: Some(1800),
            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
        }
    }

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

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

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

    fn decode_text(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(), FIREFOX_UA.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_hot_searches(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),
            uploaders: Arc::new(RwLock::new(vec![])),
        };
        let html = provider.fetch_html(&mut requester, base_url, &format!("{base_url}/")).await?;
        let document = Html::parse_document(&html);
        let selector = Self::selector("div.hot_search a[href]")?;
        for element in document.select(&selector) {
            let Some(href) = element.value().attr("href") else {
                continue;
            };
            let title = Self::decode_text(&element.text().collect::<String>());
            Self::push_unique(
                &tags,
                FilterOption {
                    id: href.to_string(),
                    title,
                },
            );
        }
        Ok(())
    }

    fn collect_uploaders_from_html(
        html: &str,
        uploaders: &Arc<RwLock<Vec<FilterOption>>>,
    ) -> Result<()> {
        let document = Html::parse_document(html);
        let selector = Self::selector("a[href*='user.htm?author=']")?;
        for element in document.select(&selector) {
            let Some(href) = element.value().attr("href") else {
                continue;
            };
            let title = Self::decode_text(&element.text().collect::<String>());
            if title.is_empty() {
                continue;
            }
            Self::push_unique(
                uploaders,
                FilterOption {
                    id: href.to_string(),
                    title,
                },
            );
        }
        Ok(())
    }

    async fn load_uploaders(base_url: &str, uploaders: Arc<RwLock<Vec<FilterOption>>>) -> Result<()> {
        let provider = Self {
            url: base_url.to_string(),
            tags: Arc::new(RwLock::new(vec![])),
            uploaders: Arc::clone(&uploaders),
        };
        let mut requester = Requester::new();
        let root_referer = format!("{base_url}/");
        let seed_urls = vec![
            format!("{base_url}/"),
            format!("{base_url}/list-1.htm"),
            format!("{base_url}/list-1.htm?sort=hot"),
            format!("{base_url}/top7_list-1.htm"),
            format!("{base_url}/top_list-1.htm"),
            format!("{base_url}/5min_list-1.htm"),
            format!("{base_url}/long_list-1.htm"),
        ];

        for url in seed_urls {
            let html = provider.fetch_html(&mut requester, &url, &root_referer).await?;
            Self::collect_uploaders_from_html(&html, &uploaders)?;
        }

        Ok(())
    }

    fn archive_from_sort(sort: &str) -> ArchiveMode {
        match sort {
            "hot" | "popular" => ArchiveMode::Hot,
            "weekly" | "top7" | "trending" => ArchiveMode::Weekly,
            "monthly" | "top" => ArchiveMode::Monthly,
            "five_min_new" | "5min" => ArchiveMode::FiveMinLatest,
            "five_min_hot" => ArchiveMode::FiveMinHot,
            "ten_min_new" | "long" => ArchiveMode::TenMinLatest,
            "ten_min_hot" => ArchiveMode::TenMinHot,
            _ => ArchiveMode::Latest,
        }
    }

    fn resolve_option_target(&self, options: &ServerOptions, sort: &str) -> Target {
        if let Some(value) = options.sites.as_deref() {
            if let Some(target) = self.find_target_in_options(&self.uploaders, value) {
                return target;
            }
        }
        if let Some(value) = options.filter.as_deref() {
            if let Some(target) = self.find_target_in_options(&self.tags, value) {
                return target;
            }
        }
        Target::Archive(Self::archive_from_sort(sort))
    }

    fn resolve_query_target(&self, query: &str, sort: &str) -> Target {
        if let Some(target) = self.find_target_in_options(&self.uploaders, query) {
            return target;
        }
        if let Some(target) = self.find_target_in_options(&self.tags, query) {
            return target;
        }
        Target::Search {
            query: query.trim().to_string(),
            sort: match sort {
                "hot" | "popular" => Some("hot".to_string()),
                "new" | "latest" => Some("new".to_string()),
                _ => None,
            },
        }
    }

    fn find_target_in_options(
        &self,
        options: &Arc<RwLock<Vec<FilterOption>>>,
        value: &str,
    ) -> Option<Target> {
        let normalized = value.trim().to_lowercase();
        let options = options.read().ok()?;
        let option = options.iter().find(|item| {
            item.id.eq_ignore_ascii_case(value) || item.title.trim().to_lowercase() == normalized
        })?;
        self.target_from_filter_id(&option.id)
    }

    fn target_from_filter_id(&self, id: &str) -> Option<Target> {
        if id.starts_with("search") {
            let url = Url::parse(&self.absolute_url(id)).ok()?;
            let mut search = None;
            let mut sort = None;
            for (key, value) in url.query_pairs() {
                if key == "search" {
                    search = Some(value.to_string());
                }
                if key == "sort" {
                    sort = Some(value.to_string());
                }
            }
            return search.map(|query| Target::Search { query, sort });
        }

        if id.starts_with("user") {
            let url = Url::parse(&self.absolute_url(id)).ok()?;
            for (key, value) in url.query_pairs() {
                if key == "author" {
                    return Some(Target::Uploader {
                        author: value.to_string(),
                    });
                }
            }
        }

        None
    }

    fn build_url_for_target(&self, target: &Target, page: u16) -> String {
        match target {
            Target::Archive(mode) => self.build_archive_url(*mode, page),
            Target::Search { query, sort } => self.build_search_url(query, sort.as_deref(), page),
            Target::Uploader { author } => self.build_uploader_url(author, page),
        }
    }

    fn build_archive_url(&self, mode: ArchiveMode, page: u16) -> String {
        match mode {
            ArchiveMode::Latest => format!("{}/list-{}.htm?sort=new", self.url, page.max(1)),
            ArchiveMode::Hot => format!("{}/list-{}.htm?sort=hot", self.url, page.max(1)),
            ArchiveMode::Weekly => format!("{}/top7_list-{}.htm", self.url, page.max(1)),
            ArchiveMode::Monthly => format!("{}/top_list-{}.htm", self.url, page.max(1)),
            ArchiveMode::FiveMinLatest => {
                format!("{}/5min_list-{}.htm?sort=new", self.url, page.max(1))
            }
            ArchiveMode::FiveMinHot => {
                format!("{}/5min_list-{}.htm?sort=hot", self.url, page.max(1))
            }
            ArchiveMode::TenMinLatest => {
                format!("{}/long_list-{}.htm?sort=new", self.url, page.max(1))
            }
            ArchiveMode::TenMinHot => {
                format!("{}/long_list-{}.htm?sort=hot", self.url, page.max(1))
            }
        }
    }

    fn build_search_url(&self, query: &str, sort: Option<&str>, page: u16) -> String {
        let mut serializer = url::form_urlencoded::Serializer::new(String::new());
        serializer.append_pair("search", query);
        if let Some(sort) = sort {
            serializer.append_pair("sort", sort);
        }
        let query_string = serializer.finish();
        if page <= 1 {
            format!("{}/search.htm?{query_string}", self.url)
        } else {
            format!("{}/search-{}.htm?{query_string}", self.url, page)
        }
    }

    fn build_uploader_url(&self, author: &str, page: u16) -> String {
        let mut serializer = url::form_urlencoded::Serializer::new(String::new());
        serializer.append_pair("author", author);
        let query_string = serializer.finish();
        if page <= 1 {
            format!("{}/user.htm?{query_string}", self.url)
        } else {
            format!("{}/user-{}.htm?{query_string}", self.url, page)
        }
    }

    fn canonical_uploader_id(author: &str) -> String {
        format!(
            "{CHANNEL_ID}:{}",
            utf8_percent_encode(author, NON_ALPHANUMERIC)
        )
    }

    fn author_from_uploader_id(value: &str) -> Option<String> {
        let suffix = match value.split_once(':') {
            Some((channel, suffix)) if channel.eq_ignore_ascii_case(CHANNEL_ID) => suffix,
            Some(_) => return None,
            None => value,
        };
        percent_decode_str(suffix)
            .decode_utf8()
            .ok()
            .map(|value| value.into_owned())
            .and_then(|value| (!value.trim().is_empty()).then_some(value))
    }

    fn author_from_uploader_href(&self, href: &str) -> Option<String> {
        let url = Url::parse(&self.absolute_url(href)).ok()?;
        url.query_pairs()
            .find(|(key, _)| key == "author")
            .map(|(_, value)| value.to_string())
    }

    fn pagination_last_page(html: &str) -> Option<u16> {
        let regex = Regex::new(r#"user-(?P<page>\d+)\.htm\?author="#).ok()?;
        regex
            .captures_iter(html)
            .filter_map(|captures| captures.name("page")?.as_str().parse::<u16>().ok())
            .max()
    }

    fn uploader_option_by_name(&self, uploader_name: &str) -> Option<FilterOption> {
        let normalized = uploader_name.trim();
        if normalized.is_empty() {
            return None;
        }
        let lowered = normalized.to_lowercase();
        self.uploaders
            .read()
            .ok()?
            .iter()
            .find(|option| {
                option.title == normalized
                    || option.title.to_lowercase() == lowered
                    || option.id.eq_ignore_ascii_case(normalized)
            })
            .cloned()
    }

    fn resolve_uploader_author(
        &self,
        uploader_id: Option<&str>,
        uploader_name: Option<&str>,
    ) -> Option<String> {
        if let Some(uploader_id) = uploader_id {
            if let Some(author) = Self::author_from_uploader_id(uploader_id) {
                return Some(author);
            }
        }
        if let Some(uploader_name) = uploader_name {
            if let Some(option) = self.uploader_option_by_name(uploader_name) {
                if let Some(Target::Uploader { author }) = self.target_from_filter_id(&option.id) {
                    return Some(author);
                }
            }
            let trimmed = uploader_name.trim();
            if !trimmed.is_empty() {
                return Some(trimmed.to_string());
            }
        }
        None
    }

    fn display_name_for_uploader(
        &self,
        author: &str,
        requested_name: Option<&str>,
        first_page_items: &[VideoItem],
    ) -> String {
        if let Some(requested_name) = requested_name {
            let trimmed = requested_name.trim();
            if !trimmed.is_empty() {
                return trimmed.to_string();
            }
        }
        if let Some(name) = first_page_items
            .iter()
            .find_map(|item| item.uploader.as_deref())
            .filter(|value| !value.trim().is_empty())
        {
            return name.to_string();
        }
        if let Some(option) = self
            .uploaders
            .read()
            .ok()
            .and_then(|values| {
                values
                    .iter()
                    .find(|value| value.id.contains(author) || value.title == author)
                    .cloned()
            })
        {
            return option.title;
        }
        author.to_string()
    }

    fn rank_videos_for_query(
        videos: &[UploaderVideoRef],
        query: Option<&str>,
    ) -> Vec<UploaderVideoRef> {
        let Some(query) = query.map(|value| value.trim()).filter(|value| !value.is_empty()) else {
            return videos.to_vec();
        };
        let query = query.to_lowercase();
        let mut ranked = videos.to_vec();
        ranked.sort_by(|a, b| {
            let score = |video: &UploaderVideoRef| {
                let mut score = 0u8;
                if video.title.to_lowercase().contains(&query) {
                    score += 2;
                }
                if video.uploader.to_lowercase().contains(&query) {
                    score += 1;
                }
                score
            };
            score(b)
                .cmp(&score(a))
                .then(b.views.cmp(&a.views))
                .then_with(|| a.id.cmp(&b.id))
        });
        ranked
    }

    async fn build_uploader_profile(
        &self,
        cache: VideoCache,
        author: &str,
        requested_name: Option<&str>,
        query: Option<&str>,
        profile_content: bool,
        options: &ServerOptions,
    ) -> Result<Option<UploaderProfile>> {
        let first_page_url = self.build_uploader_url(author, 1);
        let first_page_items = self
            .fetch_items_for_url(
                cache.clone(),
                first_page_url.clone(),
                64,
                profile_content,
                options,
            )
            .await?;
        if first_page_items.is_empty() {
            return Ok(None);
        }

        let mut requester = requester_or_default(options, CHANNEL_ID, "get_uploader.profile_page");
        let first_page_html = self
            .fetch_html(&mut requester, &first_page_url, &format!("{}/", self.url))
            .await?;
        let last_page = Self::pagination_last_page(&first_page_html).unwrap_or(1);
        let first_page_size = first_page_items.len() as u64;

        let last_page_items = if last_page > 1 {
            self.fetch_items_for_url(
                cache,
                self.build_uploader_url(author, last_page),
                64,
                false,
                options,
            )
            .await
            .unwrap_or_default()
        } else {
            Vec::new()
        };

        let display_name = self.display_name_for_uploader(author, requested_name, &first_page_items);
        let canonical_id = Self::canonical_uploader_id(author);
        let mut videos = first_page_items
            .iter()
            .map(|item| UploaderVideoRef::from_video_item(item, &display_name, &canonical_id))
            .collect::<Vec<_>>();
        let ranked_videos = Self::rank_videos_for_query(&videos, query);
        let horizontal_ids = ranked_videos
            .iter()
            .take(12)
            .map(|video| video.id.clone())
            .collect::<Vec<_>>();

        let newest_seen = first_page_items
            .iter()
            .filter_map(|item| item.uploadedAt)
            .max();
        let oldest_seen = last_page_items
            .iter()
            .filter_map(|item| item.uploadedAt)
            .min()
            .or_else(|| first_page_items.iter().filter_map(|item| item.uploadedAt).min());

        let video_count = if last_page > 1 {
            ((last_page as u64 - 1) * first_page_size) + last_page_items.len() as u64
        } else {
            first_page_size
        };
        let total_views = first_page_items
            .iter()
            .chain(last_page_items.iter())
            .filter_map(|item| item.views)
            .map(u64::from)
            .sum();

        for item in &mut videos {
            item.uploader = display_name.clone();
            item.uploaderId = canonical_id.clone();
        }

        let layout = if horizontal_ids.is_empty() {
            vec![UploaderLayoutRow::videos(None)]
        } else {
            vec![
                UploaderLayoutRow::horizontal(Some("For You".to_string()), horizontal_ids),
                UploaderLayoutRow::videos(None),
            ]
        };

        Ok(Some(UploaderProfile {
            id: canonical_id,
            name: display_name,
            url: Some(first_page_url),
            channel: Some(CHANNEL_ID.to_string()),
            verified: false,
            videoCount: video_count,
            totalViews: total_views,
            channels: Some(vec![UploaderChannelStat {
                channel: CHANNEL_ID.to_string(),
                videoCount: video_count,
                firstSeenAt: crate::uploaders::iso_timestamp_from_unix(oldest_seen),
                lastSeenAt: crate::uploaders::iso_timestamp_from_unix(newest_seen),
            }]),
            avatar: None,
            description: None,
            bio: None,
            videos: profile_content.then_some(videos),
            tapes: profile_content.then_some(Vec::new()),
            playlists: profile_content.then_some(Vec::new()),
            layout: Some(layout),
        }))
    }

    fn first_video_link<'a>(&self, element: &'a ElementRef<'a>) -> Result<Option<ElementRef<'a>>> {
        let selector = Self::selector("a[href]")?;
        Ok(element.select(&selector).find(|link| {
            link.value()
                .attr("href")
                .map(|href| href.contains("video-"))
                .unwrap_or(false)
        }))
    }

    fn extract_thumb(style: &str) -> Option<String> {
        let regex =
            Regex::new(r#"background-image:\s*url\(['"]?(?P<url>[^'")]+)['"]?\)"#).ok()?;
        regex
            .captures(style)
            .and_then(|captures| captures.name("url").map(|value| value.as_str().to_string()))
    }

    fn parse_views(text: &str) -> Option<u32> {
        let regex = Regex::new(r"([0-9]+(?:\.[0-9]+)?[kKmM]?)次观看").ok()?;
        let captures = regex.captures(text)?;
        parse_abbreviated_number(captures.get(1)?.as_str())
    }

    fn parse_uploaded_at(text: &str) -> Option<u64> {
        let trimmed = text.trim();
        if trimmed.is_empty() {
            return None;
        }

        if let Ok(date) = NaiveDate::parse_from_str(trimmed, "%Y-%m-%d") {
            let dt = date.and_hms_opt(0, 0, 0)?;
            return Some(DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc).timestamp() as u64);
        }

        let regex = Regex::new(r"^([0-9]+)(分钟|小时|天|月|年)前$").ok()?;
        let captures = regex.captures(trimmed)?;
        let amount = captures.get(1)?.as_str().parse::<i64>().ok()?;
        let unit = captures.get(2)?.as_str();
        let now = Utc::now();
        let timestamp = match unit {
            "分钟" => now - ChronoDuration::minutes(amount),
            "小时" => now - ChronoDuration::hours(amount),
            "天" => now - ChronoDuration::days(amount),
            "月" => now - ChronoDuration::days(amount * 30),
            "年" => now - ChronoDuration::days(amount * 365),
            _ => return None,
        };
        Some(timestamp.timestamp() as u64)
    }

    fn parse_info_date(text: &str) -> Option<String> {
        let regex = Regex::new(r"次观看\s*(.+)$").ok()?;
        let captures = regex.captures(text)?;
        let value = captures.get(1)?.as_str().trim();
        (!value.is_empty()).then_some(value.to_string())
    }

    fn parse_list_videos(&self, html: &str) -> Result<Vec<VideoItem>> {
        let document = Html::parse_document(html);
        let item_selector = Self::selector("div.thumbnail")?;
        let title_selector = Self::selector("div.caption.title a[href]")?;
        let image_selector = Self::selector("div.image[style]")?;
        let duration_selector = Self::selector("var.duration")?;
        let uploader_selector = Self::selector("div.info a[href*='user.htm?author=']")?;
        let info_selector = Self::selector("div.info p")?;

        let mut items = Vec::new();
        for element in document.select(&item_selector) {
            let Some(link) = self.first_video_link(&element)? else {
                continue;
            };
            let Some(href) = link.value().attr("href") else {
                continue;
            };
            if !href.contains("video-") {
                continue;
            }

            let id = href
                .trim_start_matches("video-")
                .trim_end_matches(".htm")
                .to_string();
            if id.is_empty() {
                continue;
            }

            let title = element
                .select(&title_selector)
                .next()
                .map(|value| Self::decode_text(&value.text().collect::<String>()))
                .unwrap_or_default();
            if title.is_empty() {
                continue;
            }

            let thumb = element
                .select(&image_selector)
                .next()
                .and_then(|value| value.value().attr("style"))
                .and_then(Self::extract_thumb)
                .map(|value| self.absolute_url(&value))
                .unwrap_or_default();
            if thumb.is_empty() {
                continue;
            }

            let duration = element
                .select(&duration_selector)
                .next()
                .map(|value| Self::collapse_whitespace(&value.text().collect::<String>()))
                .and_then(|value| parse_time_to_seconds(&value))
                .unwrap_or(0) as u32;

            let url = self.absolute_url(href);
            let mut item = VideoItem::new(
                id,
                title,
                url,
                CHANNEL_ID.to_string(),
                thumb,
                duration,
            );

            if let Some(uploader) = element.select(&uploader_selector).next() {
                let uploader_name = Self::decode_text(&uploader.text().collect::<String>());
                if !uploader_name.is_empty() {
                    item.uploader = Some(uploader_name);
                }
            if let Some(uploader_href) = uploader.value().attr("href") {
                item.uploaderUrl = Some(self.absolute_url(uploader_href));
                item.uploaderId = self
                    .author_from_uploader_href(uploader_href)
                    .map(|author| Self::canonical_uploader_id(&author));
            }
        }

            if let Some(info) = element.select(&info_selector).next() {
                let info_text = Self::decode_text(&Self::collapse_whitespace(
                    &info.text().collect::<String>(),
                ));
                item.views = Self::parse_views(&info_text);
                if let Some(date_text) = Self::parse_info_date(&info_text) {
                    item.uploadedAt = Self::parse_uploaded_at(&date_text);
                }
            }

            items.push(item);
        }

        Ok(items)
    }

    fn extract_meta_content(document: &Html, selector: &str) -> Result<Option<String>> {
        let selector = Self::selector(selector)?;
        Ok(document
            .select(&selector)
            .next()
            .and_then(|value| value.value().attr("content"))
            .map(Self::decode_text))
    }

    fn apply_detail_video(&self, mut item: VideoItem, html: &str, page_url: &str) -> Result<VideoItem> {
        let document = Html::parse_document(html);
        let source_selector = Self::selector("video source[src]")?;
        let author_selector = Self::selector(".panel-body a[href*='user.htm?author=']")?;
        let heading_title_selector = Self::selector("h3.panel-title")?;
        let panel_body_selector = Self::selector(".panel-body")?;

        if item.title.is_empty() {
            if let Some(title) = document
                .select(&heading_title_selector)
                .next()
                .map(|value| Self::decode_text(&value.text().collect::<String>()))
            {
                if !title.is_empty() {
                    item.title = title;
                }
            }
        }

        if item.thumb.is_empty() {
            if let Some(thumb) =
                Self::extract_meta_content(&document, "meta[property='og:image']")?
            {
                item.thumb = thumb;
            }
        }

        if item.uploader.is_none() || item.uploaderUrl.is_none() {
            if let Some(author) = document.select(&author_selector).next() {
                let author_name = Self::decode_text(&author.text().collect::<String>());
                if !author_name.is_empty() {
                    item.uploader = Some(author_name);
                }
                if let Some(href) = author.value().attr("href") {
                    item.uploaderUrl = Some(self.absolute_url(href));
                    item.uploaderId = self
                        .author_from_uploader_href(href)
                        .map(|author| Self::canonical_uploader_id(&author));
                }
            }
        }

        if let Some(source_url) = document
            .select(&source_selector)
            .next()
            .and_then(|value| value.value().attr("src"))
            .map(|value| self.absolute_url(value))
        {
            let format = VideoFormat::new(
                source_url,
                "master".to_string(),
                "hls".to_string(),
            )
            .http_header("Referer".to_string(), page_url.to_string())
            .http_header("User-Agent".to_string(), FIREFOX_UA.to_string());
            item.formats = Some(vec![format]);
        }

        if html.contains("vjs-16-9") && item.aspectRatio.is_none() {
            item.aspectRatio = Some(16.0 / 9.0);
        }

        let mut tags = item.tags.take().unwrap_or_default();
        if let Some(keywords) = Self::extract_meta_content(&document, "meta[name='keywords']")? {
            tags.extend(
                keywords
                    .split(',')
                    .map(Self::decode_text)
                    .filter(|value| !value.is_empty()),
            );
        }
        if let Some(class_name) =
            Self::extract_meta_content(&document, "meta[itemprop='class']")?
        {
            if !class_name.is_empty() {
                tags.push(class_name);
            }
        }
        if let Some(area) = Self::extract_meta_content(&document, "meta[itemprop='contentLocation']")?
        {
            if !area.is_empty() {
                tags.push(area);
            }
        }
        if let Some(actor) = Self::extract_meta_content(&document, "meta[itemprop='actor']")? {
            if !actor.is_empty() {
                if item.uploader.is_none() {
                    item.uploader = Some(actor.clone());
                }
                tags.push(actor);
            }
        }
        tags.sort();
        tags.dedup();
        if !tags.is_empty() {
            item.tags = Some(tags);
        }

        if let Some(panel_body) = document.select(&panel_body_selector).next() {
            let body_text = Self::decode_text(&Self::collapse_whitespace(
                &panel_body.text().collect::<String>(),
            ));
            if item.views.is_none() {
                let view_regex = Self::regex(r"观看：([0-9]+(?:\.[0-9]+)?[kKmM]?)")?;
                if let Some(captures) = view_regex.captures(&body_text) {
                    item.views = captures
                        .get(1)
                        .and_then(|value| parse_abbreviated_number(value.as_str()));
                }
            }
            if item.uploadedAt.is_none() {
                let date_regex = Self::regex(r"日期：([0-9年月天小时分钟前\-]+)")?;
                if let Some(captures) = date_regex.captures(&body_text) {
                    if let Some(value) = captures.get(1) {
                        item.uploadedAt = Self::parse_uploaded_at(value.as_str());
                    }
                }
            }
        }

        Ok(item)
    }

    async fn enrich_video(&self, item: VideoItem, options: &ServerOptions) -> VideoItem {
        let mut requester = requester_or_default(options, CHANNEL_ID, "enrich_video");
        let detail_fetch = timeout(
            StdDuration::from_secs(6),
            self.fetch_html(&mut requester, &item.url, &format!("{}/", self.url)),
        )
        .await;
        match detail_fetch {
            Ok(Ok(html)) => match self.apply_detail_video(item.clone(), &html, &item.url) {
                Ok(enriched) => enriched,
                Err(error) => {
                    report_provider_error_background(
                        CHANNEL_ID,
                        "apply_detail_video",
                        &error.to_string(),
                    );
                    item
                }
            },
            Ok(Err(error)) => {
                report_provider_error_background(CHANNEL_ID, "fetch_detail", &error.to_string());
                item
            }
            Err(_) => {
                report_provider_error_background(CHANNEL_ID, "fetch_detail_timeout", &item.url);
                item
            }
        }
    }

    async fn fetch_items_for_url(
        &self,
        cache: VideoCache,
        url: String,
        per_page_limit: usize,
        enrich_details: bool,
        options: &ServerOptions,
    ) -> Result<Vec<VideoItem>> {
        if let Some((time, items)) = cache.get(&url) {
            if time.elapsed().unwrap_or_default().as_secs() < 60 * 15 {
                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, &format!("{}/", self.url)),
        )
        .await
        .map_err(|_| Error::from(format!("list request timed out for {url}")))??;
        let list_items = self.parse_list_videos(&html)?;
        if list_items.is_empty() {
            return Ok(vec![]);
        }

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

        if !enrich_details {
            cache.insert(url, limited_items.clone());
            return Ok(limited_items);
        }

        let items = stream::iter(limited_items.into_iter().map(|item| {
            let provider = self.clone();
            let options = options.clone();
            async move { provider.enrich_video(item, &options).await }
        }))
        .buffer_unordered(4)
        .collect::<Vec<_>>()
        .await;

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

        Ok(items)
    }

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

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

#[async_trait]
impl Provider for HsexProvider {
    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::<u16>().unwrap_or(1);
        let per_page_limit = per_page.parse::<usize>().unwrap_or(30);

        let result = match query {
            Some(query) if !query.trim().is_empty() => {
                self.query(cache, page, &sort, &query, per_page_limit, options)
                    .await
            }
            _ => self.get(cache, page, &sort, 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))
    }

    async fn get_uploader(
        &self,
        cache: VideoCache,
        pool: DbPool,
        uploader_id: Option<String>,
        uploader_name: Option<String>,
        query: Option<String>,
        profile_content: bool,
        options: ServerOptions,
    ) -> std::result::Result<Option<UploaderProfile>, String> {
        let _ = pool;
        let Some(author) =
            self.resolve_uploader_author(uploader_id.as_deref(), uploader_name.as_deref())
        else {
            return Ok(None);
        };
        self.build_uploader_profile(
            cache,
            &author,
            uploader_name.as_deref(),
            query.as_deref(),
            profile_content,
            &options,
        )
        .await
        .map_err(|error| error.to_string())
    }
}

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

    fn provider() -> HsexProvider {
        HsexProvider {
            url: BASE_URL.to_string(),
            tags: Arc::new(RwLock::new(vec![FilterOption {
                id: "search.htm?search=%E4%BA%BA%E5%A6%BB&sort=new".to_string(),
                title: "人妻".to_string(),
            }])),
            uploaders: Arc::new(RwLock::new(vec![FilterOption {
                id: "user.htm?author=xihongshiddd".to_string(),
                title: "xihongshiddd".to_string(),
            }])),
        }
    }

    #[test]
    fn builds_search_page_two_url() {
        let provider = provider();
        let url = provider.build_url_for_target(
            &Target::Search {
                query: "体育生".to_string(),
                sort: Some("new".to_string()),
            },
            2,
        );

        assert_eq!(
            url,
            "https://hsex.tv/search-2.htm?search=%E4%BD%93%E8%82%B2%E7%94%9F&sort=new"
        );
    }

    #[test]
    fn builds_uploader_page_two_url() {
        let provider = provider();
        let url = provider.build_url_for_target(
            &Target::Uploader {
                author: "xihongshiddd".to_string(),
            },
            2,
        );

        assert_eq!(url, "https://hsex.tv/user-2.htm?author=xihongshiddd");
    }

    #[test]
    fn preserves_list_thumb_when_detail_has_og_image() {
        let provider = provider();
        let item = VideoItem::new(
            "1183662".to_string(),
            "Example".to_string(),
            "https://hsex.tv/video-1183662.htm".to_string(),
            CHANNEL_ID.to_string(),
            "https://img.ml0987.com/thumb/1183662.webp".to_string(),
            1141,
        );

        let html = r#"
            <meta property="og:image" content="https://p.tengxuny.com/thumb/1183662.jpg" />
            <meta name="keywords" content="顶级,体育生" />
            <video><source src="https://cdn1.hdcdn.online/hls/1183662/index.m3u8" /></video>
        "#;

        let enriched = provider
            .apply_detail_video(item, html, "https://hsex.tv/video-1183662.htm")
            .expect("detail parsing should succeed");

        assert_eq!(enriched.thumb, "https://img.ml0987.com/thumb/1183662.webp");
        assert_eq!(enriched.tags.as_ref().map(|v| v.len()), Some(2));
        assert_eq!(
            enriched
                .formats
                .as_ref()
                .and_then(|formats| formats.first())
                .map(|format| format.url.as_str()),
            Some("https://cdn1.hdcdn.online/hls/1183662/index.m3u8")
        );
    }

    #[test]
    fn canonical_uploader_id_round_trips() {
        let canonical = HsexProvider::canonical_uploader_id("xihongshiddd");
        assert_eq!(canonical, "hsex:xihongshiddd");
        assert_eq!(
            HsexProvider::author_from_uploader_id(&canonical).as_deref(),
            Some("xihongshiddd")
        );
    }

    #[test]
    fn parses_last_page_from_pagination() {
        let html = r#"
            <ul class="pagination1">
                <li><a href="user-1.htm?author=xihongshiddd">1</a></li>
                <li><a href="user-2.htm?author=xihongshiddd">2</a></li>
                <li><a href="user-7.htm?author=xihongshiddd">7</a></li>
            </ul>
        "#;

        assert_eq!(HsexProvider::pagination_last_page(html), Some(7));
    }

    #[tokio::test]
    #[ignore]
    async fn fetches_page_two_items() {
        let provider = provider();
        let options = ServerOptions {
            client_version: None,
            featured: None,
            category: None,
            sites: None,
            filter: Some("new".to_string()),
            language: None,
            public_url_base: None,
            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(), 2, "new", 10, options)
            .await
            .expect("page 2 should load");

        assert!(!videos.is_empty(), "page 2 should return items");
    }
}
