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::hoster_proxy::{proxy_name_for_url, rewrite_hoster_url};
use crate::util::parse_abbreviated_number;
use crate::util::requester::Requester;
use crate::videos::{ServerOptions, 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 regex::Regex;
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: "jav",
        tags: &["jav", "asian", "metadata"],
    };

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

const BASE_URL: &str = "https://sextb.net";
const MIRROR_PREFIX: &str = "https://r.jina.ai/http://";
const CHANNEL_ID: &str = "sextb";

#[derive(Debug, Clone)]
pub struct SextbProvider {
    url: String,
    genres: Arc<RwLock<Vec<FilterOption>>>,
    studios: Arc<RwLock<Vec<FilterOption>>>,
    actresses: Arc<RwLock<Vec<FilterOption>>>,
}

#[derive(Debug, Clone)]
enum Target {
    Section {
        section: String,
        sort: String,
    },
    Search {
        slug: String,
    },
    Genre {
        slug: String,
    },
    Studio {
        slug: String,
    },
    Actress {
        slug: String,
    },
}

#[derive(Debug, Clone, Default)]
struct ListItemSeed {
    id: String,
    title: String,
    url: String,
    thumb: String,
    duration: u32,
    quality: Option<String>,
    code: Option<String>,
    tags: Vec<String>,
}

#[derive(Debug, Clone, Default)]
struct DetailMetadata {
    aliases: Vec<String>,
    director: Option<String>,
    label: Option<String>,
    studio: Option<(String, String)>,
    cast: Vec<(String, String)>,
    genres: Vec<(String, String)>,
    quality: Option<String>,
    release_date: Option<u64>,
    added_at: Option<u64>,
    views: Option<u32>,
    rating: Option<f32>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct PlayerEpisode {
    label: String,
    film_id: String,
    episode_id: String,
}

impl SextbProvider {
    pub fn new() -> Self {
        let provider = Self {
            url: BASE_URL.to_string(),
            genres: Arc::new(RwLock::new(vec![FilterOption {
                id: "all".to_string(),
                title: "All".to_string(),
            }])),
            studios: Arc::new(RwLock::new(vec![FilterOption {
                id: "all".to_string(),
                title: "All".to_string(),
            }])),
            actresses: 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 genres = Arc::clone(&self.genres);
        let studios = Arc::clone(&self.studios);
        let actresses = Arc::clone(&self.actresses);

        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_genres(&url, Arc::clone(&genres)).await {
                    report_provider_error_background(CHANNEL_ID, "load_genres", &error.to_string());
                }
                if let Err(error) = Self::load_studios(&url, Arc::clone(&studios)).await {
                    report_provider_error_background(
                        CHANNEL_ID,
                        "load_studios",
                        &error.to_string(),
                    );
                }
                if let Err(error) = Self::load_actresses(&url, Arc::clone(&actresses)).await {
                    report_provider_error_background(
                        CHANNEL_ID,
                        "load_actresses",
                        &error.to_string(),
                    );
                }
            });
        });
    }

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

        Channel {
            id: CHANNEL_ID.to_string(),
            name: "SEXTB".to_string(),
            description:
                "JAV archive pages with section, genre, studio, actress, and rich detail metadata."
                    .to_string(),
            premium: false,
            favicon: "https://www.google.com/s2/favicons?sz=64&domain=sextb.net".to_string(),
            status: "active".to_string(),
            categories: genres.iter().map(|value| value.title.clone()).collect(),
            options: vec![
                ChannelOption {
                    id: "sort".to_string(),
                    title: "Sort".to_string(),
                    description: "Sort a section archive page.".to_string(),
                    systemImage: "list.number".to_string(),
                    colorName: "blue".to_string(),
                    options: vec![
                        FilterOption {
                            id: "latest".to_string(),
                            title: "Latest".to_string(),
                        },
                        FilterOption {
                            id: "release".to_string(),
                            title: "New Releases".to_string(),
                        },
                        FilterOption {
                            id: "liked".to_string(),
                            title: "Most Liked".to_string(),
                        },
                        FilterOption {
                            id: "viewed".to_string(),
                            title: "Most Viewed".to_string(),
                        },
                        FilterOption {
                            id: "favorite".to_string(),
                            title: "Most Favorite".to_string(),
                        },
                    ],
                    multiSelect: false,
                },
            ],
            nsfw: true,
            cacheDuration: Some(1800),
            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
        }
    }

    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 normalize_title(text: &str) -> String {
        Self::collapse_whitespace(text)
            .trim_matches('"')
            .trim()
            .to_string()
    }

    fn mirror_url(&self, url: &str) -> String {
        format!("{MIRROR_PREFIX}{url}")
    }

    fn slugify(value: &str) -> String {
        let mut output = String::new();
        let mut needs_dash = false;

        for ch in value.chars() {
            if ch.is_ascii_alphanumeric() {
                if needs_dash && !output.is_empty() {
                    output.push('-');
                }
                output.push(ch.to_ascii_lowercase());
                needs_dash = false;
                continue;
            }

            if matches!(ch, ' ' | '-' | '_' | '/' | '&' | '.' | '+' | '\'') {
                needs_dash = !output.is_empty();
            }
        }

        output.trim_matches('-').to_string()
    }

    fn parse_abbrev(text: &str) -> Option<u32> {
        let cleaned = text.replace(',', "").trim().to_string();
        parse_abbreviated_number(&cleaned)
    }

    fn parse_duration(text: &str) -> u32 {
        text.trim().parse::<u32>().unwrap_or(0) * 60
    }

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

        if let Some(value) = Self::parse_absolute_date(trimmed) {
            return Some(value);
        }

        let regex = Regex::new(
            r"(?i)^(?P<amount>\d+)\s+(?P<unit>minute|minutes|hour|hours|day|days|week|weeks|month|months|year|years)\s+ago$",
        )
        .ok()?;
        let captures = regex.captures(trimmed)?;
        let amount = captures.name("amount")?.as_str().parse::<i64>().ok()?;
        let unit = captures.name("unit")?.as_str().to_ascii_lowercase();
        let now = Utc::now();
        let timestamp = match unit.as_str() {
            "minute" | "minutes" => now - ChronoDuration::minutes(amount),
            "hour" | "hours" => now - ChronoDuration::hours(amount),
            "day" | "days" => now - ChronoDuration::days(amount),
            "week" | "weeks" => now - ChronoDuration::weeks(amount),
            "month" | "months" => now - ChronoDuration::days(amount * 30),
            "year" | "years" => now - ChronoDuration::days(amount * 365),
            _ => return None,
        };
        Some(timestamp.timestamp() as u64)
    }

    fn parse_absolute_date(text: &str) -> Option<u64> {
        let normalized = text.replace('.', "");
        let date = NaiveDate::parse_from_str(&normalized, "%b %d, %Y").ok()?;
        let dt = date.and_hms_opt(0, 0, 0)?;
        Some(DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc).timestamp() as u64)
    }

    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.eq_ignore_ascii_case(&item.id)
                    || existing.title.eq_ignore_ascii_case(&item.title)
            }) {
                values.push(item);
            }
        }
    }

    async fn fetch_markdown(&self, requester: &mut Requester, url: &str) -> Result<String> {
        let mirror_url = self.mirror_url(url);
        requester
            .get(&mirror_url, Some(Version::HTTP_11))
            .await
            .map_err(|error| Error::from(format!("request failed for {url}: {error}")))
    }

    fn pagination_last_page(markdown: &str) -> Option<u16> {
        let regex = Regex::new(r"\[(?P<page>\d+)\]\(https://sextb\.net/[^)]*pg-(?P=page)\)")
            .ok()?;
        regex
            .captures_iter(markdown)
            .filter_map(|captures| captures.name("page")?.as_str().parse::<u16>().ok())
            .max()
    }

    fn parse_genre_options(markdown: &str) -> Result<Vec<FilterOption>> {
        let regex = Self::regex(
            r#"(?m)^\*\s+\[(?P<title>[^\]]+)\]\(https://sextb\.net/genre/(?P<slug>[^)\s]+)[^)]*\)\((?P<count>\d+)\)$"#,
        )?;
        let mut items = Vec::new();
        for captures in regex.captures_iter(markdown) {
            let title = Self::normalize_title(captures.name("title").map(|value| value.as_str()).unwrap_or_default());
            let slug = captures
                .name("slug")
                .map(|value| value.as_str())
                .unwrap_or_default()
                .to_string();
            if title.is_empty() || slug.is_empty() {
                continue;
            }
            items.push(FilterOption { id: slug, title });
        }
        Ok(items)
    }

    fn parse_studio_options(markdown: &str) -> Result<Vec<FilterOption>> {
        let regex = Self::regex(
            r#"(?m)^\[(?P<title>[^\]]+)\]\(https://sextb\.net/studio/(?P<slug>[^)\s]+)[^)]*\)"#,
        )?;
        let mut items = Vec::new();
        for captures in regex.captures_iter(markdown) {
            let title =
                Self::normalize_title(captures.name("title").map(|value| value.as_str()).unwrap_or_default());
            let slug = captures
                .name("slug")
                .map(|value| value.as_str())
                .unwrap_or_default()
                .to_string();
            if title.is_empty() || slug.is_empty() {
                continue;
            }
            items.push(FilterOption { id: slug, title });
        }
        Ok(items)
    }

    fn parse_actress_options(markdown: &str) -> Result<Vec<FilterOption>> {
        let regex = Self::regex(
            r#"(?m)^\[\!\[Image \d+: (?P<title>[^\]]+)\]\(https://[^)]+\)\s+(?P=title)\s+[0-9.]+[KM]\s+\d{2}/\d{2}/\d{4}\]\(https://sextb\.net/actress/[^)\s]+\)$"#,
        )?;
        let mut items = Vec::new();
        for captures in regex.captures_iter(markdown) {
            let title =
                Self::normalize_title(captures.name("title").map(|value| value.as_str()).unwrap_or_default());
            if title.is_empty() {
                continue;
            }
            let slug_source = title.split('(').next().unwrap_or(&title).trim();
            let slug = Self::slugify(slug_source);
            if slug.is_empty() {
                continue;
            }
            items.push(FilterOption { id: slug, title: slug_source.to_string() });
        }
        Ok(items)
    }

    async fn load_genres(base_url: &str, genres: Arc<RwLock<Vec<FilterOption>>>) -> Result<()> {
        let provider = Self {
            url: base_url.to_string(),
            genres: Arc::clone(&genres),
            studios: Arc::new(RwLock::new(vec![])),
            actresses: Arc::new(RwLock::new(vec![])),
        };
        let mut requester = Requester::new();
        let markdown = provider.fetch_markdown(&mut requester, &format!("{base_url}/genres")).await?;
        for item in Self::parse_genre_options(&markdown)? {
            Self::push_unique(&genres, item);
        }
        Ok(())
    }

    async fn load_studios(base_url: &str, studios: Arc<RwLock<Vec<FilterOption>>>) -> Result<()> {
        let provider = Self {
            url: base_url.to_string(),
            genres: Arc::new(RwLock::new(vec![])),
            studios: Arc::clone(&studios),
            actresses: Arc::new(RwLock::new(vec![])),
        };
        let mut requester = Requester::new();
        let markdown =
            provider.fetch_markdown(&mut requester, &format!("{base_url}/list-studios")).await?;
        for item in Self::parse_studio_options(&markdown)? {
            Self::push_unique(&studios, item);
        }
        Ok(())
    }

    async fn load_actresses(
        base_url: &str,
        actresses: Arc<RwLock<Vec<FilterOption>>>,
    ) -> Result<()> {
        let provider = Self {
            url: base_url.to_string(),
            genres: Arc::new(RwLock::new(vec![])),
            studios: Arc::new(RwLock::new(vec![])),
            actresses: Arc::clone(&actresses),
        };
        let mut requester = Requester::new();
        let first_page =
            provider.fetch_markdown(&mut requester, &format!("{base_url}/list-actress")).await?;
        for item in Self::parse_actress_options(&first_page)? {
            Self::push_unique(&actresses, item);
        }

        let last_page = Self::pagination_last_page(&first_page).unwrap_or(1).min(80);
        for page in 2..=last_page {
            let markdown = provider
                .fetch_markdown(&mut requester, &format!("{base_url}/list-actress/pg-{page}"))
                .await?;
            for item in Self::parse_actress_options(&markdown)? {
                Self::push_unique(&actresses, item);
            }
        }
        Ok(())
    }

    fn resolve_section(options: &ServerOptions) -> String {
        match options.filter.as_deref().unwrap_or("censored") {
            "uncensored" => "uncensored".to_string(),
            "subtitle" => "subtitle".to_string(),
            "amateur" => "amateur".to_string(),
            _ => "censored".to_string(),
        }
    }

    fn resolve_sort(sort: &str) -> String {
        match sort {
            "release" => "release".to_string(),
            "liked" => "liked".to_string(),
            "viewed" | "popular" => "viewed".to_string(),
            "favorite" => "favorite".to_string(),
            _ => "latest".to_string(),
        }
    }

    fn find_option_slug(options: &Arc<RwLock<Vec<FilterOption>>>, value: &str) -> Option<String> {
        let normalized = value.trim().to_ascii_lowercase();
        let slug = Self::slugify(&normalized);
        let options = options.read().ok()?;
        let option = options.iter().find(|item| {
            item.id.eq_ignore_ascii_case(value)
                || item.title.eq_ignore_ascii_case(value)
                || Self::slugify(&item.title) == slug
        })?;
        Some(option.id.clone())
    }

    fn resolve_option_target(&self, options: &ServerOptions, sort: &str) -> Target {
        if let Some(value) = options.stars.as_deref() {
            if let Some(slug) = Self::find_option_slug(&self.actresses, value) {
                return Target::Actress { slug };
            }
        }
        if let Some(value) = options.sites.as_deref() {
            if let Some(slug) = Self::find_option_slug(&self.studios, value) {
                return Target::Studio { slug };
            }
        }
        if let Some(value) = options.categories.as_deref() {
            if let Some(slug) = Self::find_option_slug(&self.genres, value) {
                return Target::Genre { slug };
            }
        }
        Target::Section {
            section: Self::resolve_section(options),
            sort: Self::resolve_sort(sort),
        }
    }

    fn resolve_query_target(&self, query: &str, sort: &str) -> Target {
        if let Some(slug) = Self::find_option_slug(&self.actresses, query) {
            return Target::Actress { slug };
        }
        if let Some(slug) = Self::find_option_slug(&self.studios, query) {
            return Target::Studio { slug };
        }
        if let Some(slug) = Self::find_option_slug(&self.genres, query) {
            return Target::Genre { slug };
        }

        let slug = Self::slugify(query);
        if slug.is_empty() {
            return Target::Section {
                section: "censored".to_string(),
                sort: Self::resolve_sort(sort),
            };
        }
        Target::Search { slug }
    }

    fn build_url_for_target(&self, target: &Target, page: u16) -> String {
        match target {
            Target::Section { section, sort } => {
                let mut url = Url::parse(&format!("{}/{}", self.url, section))
                    .expect("valid section base url");
                {
                    let mut pairs = url.query_pairs_mut();
                    pairs.append_pair("genre", "all");
                    pairs.append_pair("studio", "all");
                    pairs.append_pair("quality", "all");
                    pairs.append_pair("year", "all");
                    if sort != "latest" {
                        pairs.append_pair("sort", sort);
                    }
                    if page > 1 {
                        pairs.append_pair("pg", &page.to_string());
                    }
                }
                url.to_string()
            }
            Target::Search { slug } => {
                if page > 1 {
                    format!("{}/search/{}/pg-{}", self.url, slug, page)
                } else {
                    format!("{}/search/{}", self.url, slug)
                }
            }
            Target::Genre { slug } => {
                if page > 1 {
                    format!("{}/genre/{}/pg-{}", self.url, slug, page)
                } else {
                    format!("{}/genre/{}", self.url, slug)
                }
            }
            Target::Studio { slug } => {
                if page > 1 {
                    format!("{}/studio/{}/pg-{}", self.url, slug, page)
                } else {
                    format!("{}/studio/{}", self.url, slug)
                }
            }
            Target::Actress { slug } => {
                if page > 1 {
                    format!("{}/actress/{}/pg-{}", self.url, slug, page)
                } else {
                    format!("{}/actress/{}", self.url, slug)
                }
            }
        }
    }

    fn parse_list_prefix_tags(prefix: &str) -> Vec<String> {
        let cleaned = Self::collapse_whitespace(prefix);
        if cleaned.is_empty() {
            return vec![];
        }

        let bracket_regex = Regex::new(r"\[(?P<tag>[^\]]+)\]").ok();
        let mut tags = Vec::new();
        if let Some(regex) = bracket_regex {
            for captures in regex.captures_iter(&cleaned) {
                if let Some(tag) = captures.name("tag").map(|value| value.as_str().trim()) {
                    if !tag.is_empty() {
                        tags.push(tag.to_string());
                    }
                }
            }
        }

        let without_brackets = Regex::new(r"\[[^\]]+\]")
            .ok()
            .map(|regex| regex.replace_all(&cleaned, " ").to_string())
            .unwrap_or(cleaned.clone());
        for token in without_brackets.split("  ") {
            let value = Self::collapse_whitespace(token);
            if !value.is_empty() {
                tags.push(value);
            }
        }

        tags.sort();
        tags.dedup();
        tags
    }

    fn parse_list_items(markdown: &str) -> Result<Vec<ListItemSeed>> {
        let regex = Self::regex(
            r#"(?s)\[\!\[Image \d+: (?P<alt>.*?)\]\((?P<thumb>https?://[^)]+)\)(?P<body>.*?)\]\((?P<url>https://sextb\.net/[^)]+)\)"#,
        )?;
        let meta_regex = Self::regex(
            r#"(?s)^(?P<head>.*?)\s+(?P<quality>HD|SD)\s+(?P<duration>\d+)\s+min\s+(?P<code>[A-Za-z0-9\-]+)$"#,
        )?;
        let mut items = Vec::new();

        for captures in regex.captures_iter(markdown) {
            let url = captures
                .name("url")
                .map(|value| value.as_str())
                .unwrap_or_default()
                .to_string();
            if !url.starts_with("https://sextb.net/") {
                continue;
            }

            let body = Self::collapse_whitespace(
                captures.name("body").map(|value| value.as_str()).unwrap_or_default(),
            );
            let meta = match meta_regex.captures(&body) {
                Some(meta) => meta,
                None => continue,
            };

            let alt_title = Self::normalize_title(
                captures.name("alt").map(|value| value.as_str()).unwrap_or_default(),
            );
            let head = Self::normalize_title(meta.name("head").map(|value| value.as_str()).unwrap_or_default());
            let title = if !alt_title.is_empty() && head.contains(&alt_title) {
                alt_title.clone()
            } else if !alt_title.is_empty() && !alt_title.eq_ignore_ascii_case("Julia") {
                alt_title.clone()
            } else {
                head.clone()
            };
            if title.is_empty() {
                continue;
            }

            let prefix = if !alt_title.is_empty() && head.contains(&alt_title) {
                let prefix = head.split(&alt_title).next().unwrap_or_default();
                prefix.to_string()
            } else {
                String::new()
            };

            let slug = url
                .trim_start_matches("https://sextb.net/")
                .trim_end_matches('/')
                .to_string();
            if slug.is_empty() {
                continue;
            }

            let thumb = captures
                .name("thumb")
                .map(|value| value.as_str())
                .unwrap_or_default()
                .to_string();
            if thumb.is_empty() {
                continue;
            }

            let quality = meta
                .name("quality")
                .map(|value| value.as_str().to_string())
                .filter(|value| !value.is_empty());
            let code = meta
                .name("code")
                .map(|value| value.as_str().to_string())
                .filter(|value| !value.is_empty());
            let duration = meta
                .name("duration")
                .map(|value| Self::parse_duration(value.as_str()))
                .unwrap_or(0);

            let mut tags = Self::parse_list_prefix_tags(&prefix);
            if let Some(value) = quality.clone() {
                tags.push(value);
            }
            if let Some(value) = code.clone() {
                tags.push(value.clone());
                tags.push(value.to_ascii_uppercase());
            }
            tags.sort();
            tags.dedup();

            items.push(ListItemSeed {
                id: slug.clone(),
                title,
                url,
                thumb,
                duration,
                quality,
                code,
                tags,
            });
        }

        Ok(items)
    }

    fn extract_link_texts(line: &str) -> Result<Vec<(String, String)>> {
        let regex = Self::regex(r#"\[(?P<title>[^\]]+)\]\((?P<url>https://sextb\.net/[^)\s]+)"#)?;
        Ok(regex
            .captures_iter(line)
            .filter_map(|captures| {
                let title = captures.name("title")?.as_str().trim().trim_matches('*').to_string();
                let url = captures.name("url")?.as_str().to_string();
                Some((title, url))
            })
            .collect())
    }

    fn detail_link_slug(url: &str, prefix: &str) -> Option<String> {
        let path = url.strip_prefix("https://sextb.net/")?;
        let path = path.strip_prefix(prefix)?;
        Some(path.trim_start_matches('/').trim_end_matches('/').to_string())
    }

    fn parse_detail(markdown: &str) -> Result<DetailMetadata> {
        let mut metadata = DetailMetadata::default();

        let aliases_regex = Self::regex(r#"(?m)^# \*\*(?P<body>.+?)\*\*$"#)?;
        if let Some(captures) = aliases_regex.captures(markdown) {
            let body = captures.name("body").map(|value| value.as_str()).unwrap_or_default();
            let link_regex = Self::regex(r#"\[(?P<alias>[^\]]+)\]\(https://sextb\.net/search/[^)]+\)"#)?;
            for alias_match in link_regex.captures_iter(body) {
                if let Some(alias) = alias_match.name("alias").map(|value| value.as_str().trim()) {
                    if !alias.is_empty() {
                        metadata.aliases.push(alias.to_string());
                    }
                }
            }
        }

        let line_regex = Self::regex(r#"(?m)^\s*(?P<key>Director|Label|Studio|Cast\(s\)|Genre\(s\)|Quality|Release Date|Runtimes|Added|Viewed|Description):\s*(?P<value>.+)$"#)?;
        for captures in line_regex.captures_iter(markdown) {
            let key = captures.name("key").map(|value| value.as_str()).unwrap_or_default();
            let value = captures.name("value").map(|value| value.as_str()).unwrap_or_default().trim();
            match key {
                "Director" => {
                    metadata.director = Self::extract_link_texts(value)?
                        .first()
                        .map(|(title, _)| title.clone());
                }
                "Label" => {
                    metadata.label = Self::extract_link_texts(value)?
                        .first()
                        .map(|(title, _)| title.clone());
                }
                "Studio" => {
                    if let Some((title, url)) = Self::extract_link_texts(value)?.into_iter().next() {
                        let slug =
                            Self::detail_link_slug(&url, "studio").unwrap_or_else(|| Self::slugify(&title));
                        metadata.studio = Some((title, slug));
                    }
                }
                "Cast(s)" => {
                    let mut cast = Vec::new();
                    for (title, url) in Self::extract_link_texts(value)? {
                        let slug = Self::detail_link_slug(&url, "actress")
                            .unwrap_or_else(|| Self::slugify(&title));
                        cast.push((title, slug));
                    }
                    metadata.cast = cast;
                }
                "Genre(s)" => {
                    let mut genres = Vec::new();
                    for (title, url) in Self::extract_link_texts(value)? {
                        let slug = Self::detail_link_slug(&url, "genre")
                            .unwrap_or_else(|| Self::slugify(&title));
                        genres.push((title, slug));
                    }
                    metadata.genres = genres;
                }
                "Quality" => {
                    let quality = value.trim_matches('*').trim().to_string();
                    if !quality.is_empty() {
                        metadata.quality = Some(quality);
                    }
                }
                "Release Date" => {
                    let cleaned = value.trim_matches('*').trim();
                    metadata.release_date = Self::parse_absolute_date(cleaned);
                }
                "Runtimes" => {}
                "Added" => {
                    let cleaned = value.trim_matches('*').trim();
                    metadata.added_at = Self::parse_relative_or_date(cleaned);
                }
                "Viewed" => {
                    metadata.views = Self::parse_abbrev(value.trim());
                }
                "Description" => {}
                _ => {}
            }
        }

        let stats_regex =
            Self::regex(r#"(?m)(?P<likes>[0-9.]+[KM]?)\s+Liked(?:\s+(?P<dislikes>[0-9.]+[KM]?)\s+Disliked)?(?:\s+(?P<favorites>[0-9.]+[KM]?)\s+Favorited)?\s+(?P<views>[0-9.]+[KM]?)\s+Views"#)?;
        if let Some(captures) = stats_regex.captures(markdown) {
            if metadata.views.is_none() {
                metadata.views = captures
                    .name("views")
                    .and_then(|value| Self::parse_abbrev(value.as_str()));
            }
            if let (Some(likes), Some(dislikes)) = (
                captures
                    .name("likes")
                    .and_then(|value| Self::parse_abbrev(value.as_str())),
                captures
                    .name("dislikes")
                    .and_then(|value| Self::parse_abbrev(value.as_str())),
            ) {
                let total = likes + dislikes;
                if total > 0 {
                    metadata.rating = Some((likes as f32 / total as f32) * 100.0);
                }
            }
        }

        metadata.aliases.sort();
        metadata.aliases.dedup();
        Ok(metadata)
    }

    fn parse_player_episodes(html: &str) -> Result<Vec<PlayerEpisode>> {
        let regex = Self::regex(
            r#"(?is)<(?:button|a)[^>]*data-source="(?P<film>\d+)"[^>]*data-id="(?P<episode>\d+)"[^>]*>(?P<body>.*?)</(?:button|a)>"#,
        )?;
        let body_text_regex = Self::regex(r#"(?is)<[^>]+>"#)?;
        let mut episodes = regex
            .captures_iter(html)
            .filter_map(|captures| {
                let body = captures.name("body")?.as_str();
                let body_text = body_text_regex.replace_all(body, " ");
                let body_upper = body_text.to_ascii_uppercase();
                let label = ["DD", "TB", "SW", "FL", "US", "PP"]
                    .iter()
                    .find(|candidate| body_upper.contains(**candidate))
                    .map(|candidate| (*candidate).to_string())
                    .or_else(|| {
                        body_upper
                            .split_whitespace()
                            .find(|token| token.len() >= 2 && token.len() <= 3)
                            .map(|token| token.to_string())
                    })
                    .unwrap_or_default();
                if label.is_empty() {
                    return None;
                }
                let film_id = captures.name("film")?.as_str().trim().to_string();
                let episode_id = captures.name("episode")?.as_str().trim().to_string();
                Some(PlayerEpisode {
                    label,
                    film_id,
                    episode_id,
                })
            })
            .collect::<Vec<_>>();

        episodes.sort_by_key(|episode| {
            if episode.label == "DD" {
                0
            } else {
                1
            }
        });
        episodes.dedup();

        Ok(episodes)
    }

    fn normalize_iframe_url(url: &str) -> Option<String> {
        let trimmed = url.trim();
        if let Some(without) = trimmed.strip_prefix("//") {
            return Some(format!("https://{without}"));
        }
        if trimmed.starts_with("https://") || trimmed.starts_with("http://") {
            return Some(trimmed.to_string());
        }
        None
    }

    fn extract_hoster_url_from_text(text: &str) -> Option<String> {
        let regex = Regex::new(r#"https?://[^\s"'<>\\]+"#).ok()?;
        regex.find_iter(text).find_map(|match_value| {
            let candidate = match_value.as_str().trim();
            proxy_name_for_url(candidate).map(|_| candidate.to_string())
        })
    }

    fn parse_player_iframe_url(response: &str) -> Option<String> {
        let iframe_regex =
            Regex::new(r#"(?is)<iframe[^>]+src=['"](?P<url>(?:https?:)?//[^'"]+)['"]"#).ok()?;
        let src_json_regex = Regex::new(r#"(?i)"src"\s*:\s*"(?P<url>https?:\\/\\/[^"]+)""#).ok()?;

        let player_html = serde_json::from_str::<serde_json::Value>(response)
            .ok()
            .and_then(|value| value.get("player").and_then(|player| player.as_str()).map(str::to_string));

        let from_iframe = player_html
            .as_deref()
            .and_then(|html| iframe_regex.captures(html))
            .or_else(|| iframe_regex.captures(response))
            .and_then(|captures| captures.name("url"))
            .and_then(|value| Self::normalize_iframe_url(value.as_str()));

        if from_iframe.is_some() {
            return from_iframe;
        }

        if let Some(url) = src_json_regex
            .captures(response)
            .and_then(|captures| captures.name("url"))
            .map(|value| value.as_str().replace("\\/", "/"))
        {
            return Self::normalize_iframe_url(&url);
        }

        Self::extract_hoster_url_from_text(response)
    }

    async fn resolve_proxy_video_url(
        &self,
        seed_url: &str,
        options: &ServerOptions,
    ) -> Option<String> {
        let mut requester = requester_or_default(options, CHANNEL_ID, "resolve_proxy_video_url");
        let html = requester.get(seed_url, Some(Version::HTTP_2)).await.ok()?;
        if let Some(url) = Self::extract_hoster_url_from_text(&html) {
            return Some(rewrite_hoster_url(options, &url));
        }
        let episodes = Self::parse_player_episodes(&html).ok()?;

        for episode in episodes {
            let ajax_url = format!(
                "{}/ajax/player?episode={}&filmId={}",
                self.url, episode.episode_id, episode.film_id
            );
            let headers = vec![
                ("Referer".to_string(), seed_url.to_string()),
                ("X-Requested-With".to_string(), "XMLHttpRequest".to_string()),
                ("Accept".to_string(), "application/json, text/html, */*".to_string()),
            ];
            let Some(response) = requester
                .get_with_headers(&ajax_url, headers, Some(Version::HTTP_2))
                .await
                .ok()
            else {
                continue;
            };
            let iframe_or_hoster = Self::parse_player_iframe_url(&response)
                .or_else(|| Self::extract_hoster_url_from_text(&response));
            let Some(iframe_url) = iframe_or_hoster else {
                continue;
            };
            if proxy_name_for_url(&iframe_url).is_some() {
                return Some(rewrite_hoster_url(options, &iframe_url));
            }
        }

        None
    }

    fn apply_detail_metadata(&self, seed: ListItemSeed, detail: DetailMetadata) -> VideoItem {
        let mut item = VideoItem::new(
            seed.id,
            seed.title,
            seed.url,
            CHANNEL_ID.to_string(),
            seed.thumb.clone(),
            seed.duration,
        );

        item.preview = Some(seed.thumb);
        item.views = detail.views;
        item.rating = detail.rating;
        item.uploadedAt = detail.added_at.or(detail.release_date);

        if !detail.cast.is_empty() {
            item.uploader = Some(
                detail
                    .cast
                    .iter()
                    .map(|(name, _)| name.clone())
                    .collect::<Vec<_>>()
                    .join(", "),
            );
            if let Some((_, slug)) = detail.cast.first() {
                item.uploaderUrl = Some(format!("{}/actress/{}", self.url, slug));
            }
        } else if let Some((studio, slug)) = detail.studio.as_ref() {
            item.uploader = Some(studio.clone());
            item.uploaderUrl = Some(format!("{}/studio/{}", self.url, slug));
        }

        let mut tags = seed.tags;
        if let Some(quality) = seed.quality {
            tags.push(quality);
        }
        if let Some(code) = seed.code {
            tags.push(code);
        }
        for alias in detail.aliases {
            tags.push(alias);
        }
        if let Some(director) = detail.director {
            tags.push(director);
        }
        if let Some(label) = detail.label {
            tags.push(label);
        }
        if let Some((studio, _)) = detail.studio {
            tags.push(studio);
        }
        for (cast, _) in detail.cast {
            tags.push(cast);
        }
        for (genre, _) in detail.genres {
            tags.push(genre);
        }
        if let Some(quality) = detail.quality {
            tags.push(quality);
        }

        tags.retain(|value| !value.trim().is_empty());
        tags.sort();
        tags.dedup();
        if !tags.is_empty() {
            item.tags = Some(tags);
        }

        item
    }

    async fn enrich_seed(&self, seed: ListItemSeed, options: &ServerOptions) -> VideoItem {
        let mut requester = requester_or_default(options, CHANNEL_ID, "enrich_seed");
        let detail_fetch = timeout(
            StdDuration::from_secs(10),
            self.fetch_markdown(&mut requester, &seed.url),
        )
        .await;

        let mut item = match detail_fetch {
            Ok(Ok(markdown)) => match Self::parse_detail(&markdown) {
                Ok(detail) => self.apply_detail_metadata(seed.clone(), detail),
                Err(error) => {
                    report_provider_error_background(CHANNEL_ID, "parse_detail", &error.to_string());
                    self.apply_detail_metadata(seed, DetailMetadata::default())
                }
            },
            Ok(Err(error)) => {
                report_provider_error_background(CHANNEL_ID, "fetch_detail", &error.to_string());
                self.apply_detail_metadata(seed, DetailMetadata::default())
            }
            Err(_) => {
                report_provider_error_background(CHANNEL_ID, "fetch_detail_timeout", &seed.url);
                self.apply_detail_metadata(seed, DetailMetadata::default())
            }
        };

        match timeout(
            StdDuration::from_secs(10),
            self.resolve_proxy_video_url(&item.url, options),
        )
        .await
        {
            Ok(Some(proxy_url)) => item.url = proxy_url,
            Ok(None) => {}
            Err(_) => {
                report_provider_error_background(CHANNEL_ID, "resolve_proxy_video_url_timeout", &item.url);
            }
        }

        item
    }

    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 * 15 {
                return Ok(items.clone());
            }
        }

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

        let seeds = Self::parse_list_items(&markdown)?
            .into_iter()
            .take(per_page_limit.max(1))
            .collect::<Vec<_>>();
        if seeds.is_empty() {
            return Ok(vec![]);
        }

        let items = stream::iter(seeds.into_iter().map(|seed| {
            let provider = self.clone();
            let options = options.clone();
            async move { provider.enrich_seed(seed, &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, &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, &options)
            .await
    }
}

#[async_trait]
impl Provider for SextbProvider {
    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))
    }
}

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

    fn provider() -> SextbProvider {
        SextbProvider {
            url: BASE_URL.to_string(),
            genres: Arc::new(RwLock::new(vec![FilterOption {
                id: "big-tits".to_string(),
                title: "Big Tits".to_string(),
            }])),
            studios: Arc::new(RwLock::new(vec![FilterOption {
                id: "madonna".to_string(),
                title: "Madonna".to_string(),
            }])),
            actresses: Arc::new(RwLock::new(vec![FilterOption {
                id: "yui-hatano".to_string(),
                title: "Yui Hatano".to_string(),
            }])),
        }
    }

    #[test]
    fn builds_section_page_two_url() {
        let url = provider().build_url_for_target(
            &Target::Section {
                section: "amateur".to_string(),
                sort: "viewed".to_string(),
            },
            2,
        );
        assert_eq!(
            url,
            "https://sextb.net/amateur?genre=all&studio=all&quality=all&year=all&sort=viewed&pg=2"
        );
    }

    #[test]
    fn builds_search_page_two_url() {
        let url = provider().build_url_for_target(
            &Target::Search {
                slug: "yui-hatano".to_string(),
            },
            2,
        );
        assert_eq!(url, "https://sextb.net/search/yui-hatano/pg-2");
    }

    #[test]
    fn matches_query_to_filter_targets() {
        let provider = provider();

        match provider.resolve_query_target("Big Tits", "latest") {
            Target::Genre { slug } => assert_eq!(slug, "big-tits"),
            other => panic!("unexpected target: {other:?}"),
        }

        match provider.resolve_query_target("Madonna", "latest") {
            Target::Studio { slug } => assert_eq!(slug, "madonna"),
            other => panic!("unexpected target: {other:?}"),
        }

        match provider.resolve_query_target("Yui Hatano", "latest") {
            Target::Actress { slug } => assert_eq!(slug, "yui-hatano"),
            other => panic!("unexpected target: {other:?}"),
        }
    }

    #[test]
    fn parses_detail_metadata_block() {
        let markdown = r#"
57 Liked 16 Disliked 88 Favorited 45.2K Views

 Director: [**Himurokku**](https://sextb.net/director/himurokku "Himurokku")
 Label: [**Madonna**](https://sextb.net/label/madonna "Madonna")
 Studio: [**Madonna**](https://sextb.net/studio/madonna "MADONNA")
 Cast(s): [**Miyuu Imai**](https://sextb.net/actress/miyuu-imai "Miyuu Imai")
 Genre(s): [**Big Tits**](https://sextb.net/genre/big-tits "Big Tits"), [**Creampie**](https://sextb.net/genre/creampie "Creampie")
 Quality: **HD**
 Release Date: **Mar. 24, 2026**
 Added: **2 days ago**
 Viewed: 45.2K
"#;

        let detail = SextbProvider::parse_detail(markdown).expect("detail should parse");
        assert_eq!(detail.director.as_deref(), Some("Himurokku"));
        assert_eq!(detail.label.as_deref(), Some("Madonna"));
        assert_eq!(detail.studio.as_ref().map(|value| value.0.as_str()), Some("Madonna"));
        assert_eq!(detail.cast.len(), 1);
        assert_eq!(detail.genres.len(), 2);
        assert_eq!(detail.views, Some(45200));
        assert!(detail.rating.unwrap_or_default() > 70.0);
        assert_eq!(detail.quality.as_deref(), Some("HD"));
        assert!(detail.release_date.is_some());
        assert!(detail.added_at.is_some());
    }

    #[test]
    fn parses_dd_player_buttons_from_detail_html() {
        let html = r#"
            <div class="episode-list">
                <button class="btn-player episode active" data-source="16900830" data-id="3708319"><i class="fa fa-play-circle"></i> TB</button>
                <button class="btn-player episode" data-source="16900830" data-id="3708330"><i class="fa fa-play-circle"></i> SW</button>
                <button class="btn-player episode" data-source="16900830" data-id="3708339"><i class="fa fa-play-circle"></i> DD</button>
            </div>
        "#;

        let episodes = SextbProvider::parse_player_episodes(html).expect("episodes should parse");
        assert_eq!(
            episodes.first(),
            Some(&PlayerEpisode {
                label: "DD".to_string(),
                film_id: "16900830".to_string(),
                episode_id: "3708339".to_string(),
            })
        );
    }

    #[test]
    fn parses_dd_player_anchor_layout() {
        let html = r#"
            <div class="episode-list">
                <a class="episode" data-source="200" data-id="301"><span>SW</span></a>
                <a class="episode active" data-source="200" data-id="302"><span>DD</span></a>
            </div>
        "#;
        let episodes = SextbProvider::parse_player_episodes(html).expect("episodes should parse");
        assert_eq!(
            episodes.first(),
            Some(&PlayerEpisode {
                label: "DD".to_string(),
                film_id: "200".to_string(),
                episode_id: "302".to_string(),
            })
        );
    }

    #[test]
    fn parses_iframe_url_from_ajax_player_response() {
        let response = r#"{"player":"<iframe src=\"https://trailerhg.xyz/e/ttdc7a6qpskt\" width=\"100%\" height=\"100%\" frameborder=\"0\" allowfullscreen=\"true\"></iframe>"}"#;
        assert_eq!(
            SextbProvider::parse_player_iframe_url(response).as_deref(),
            Some("https://trailerhg.xyz/e/ttdc7a6qpskt")
        );
    }

    #[test]
    fn parses_protocol_relative_iframe_url_from_ajax_player_response() {
        let response = r#"{"player":"<iframe src=\"//turboplayers.xyz/t/69bdfb21cc640\"></iframe>"}"#;
        assert_eq!(
            SextbProvider::parse_player_iframe_url(response).as_deref(),
            Some("https://turboplayers.xyz/t/69bdfb21cc640")
        );
    }
}
