use crate::DbPool;
use crate::api::ClientVersion;
use crate::providers::{
    Provider, build_proxy_url, report_provider_error, requester_or_default, strip_url_scheme,
};
use crate::status::*;
use crate::util::cache::VideoCache;
use crate::videos::{ServerOptions, VideoItem};
use async_trait::async_trait;
use base64::{Engine as _, engine::general_purpose::STANDARD};
use chrono::{Duration as ChronoDuration, Utc};
use error_chain::error_chain;
use regex::Regex;
use scraper::{Html, Selector};
use url::form_urlencoded::Serializer;

pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
    crate::providers::ProviderChannelMetadata {
        group_id: "mainstream-tube",
        tags: &["tube", "aggregator", "mixed"],
    };

const BASE_URL: &str = "https://www.melonstube.com";
const CHANNEL_ID: &str = "melonstube";
const BROWSER_UA: &str =
    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";

// Destination hosts we have a dedicated resolver proxy for (yt-dlp cannot
// solve these directly).
const HARD_HOSTS: &[&str] = &["manysex.com", "videomanysex.com"];

// Destination hosts observed to be dead-end funnels (paywall/signup, no
// free playable video) rather than genuine hard-to-resolve hosts. Cards
// pointing at these are dropped entirely so they don't surface as
// false-positive "ad" results.
const JUNK_HOSTS: &[&str] = &["fhgte.com"];

error_chain! {
    foreign_links {
        Io(std::io::Error);
        HttpRequest(wreq::Error);
    }
}

#[derive(Debug, Clone)]
enum Target {
    Latest { page: u32 },
    Popular { page: u32 },
    Rating { page: u32 },
    Search { query: String, page: u32 },
}

#[derive(Debug, Clone)]
pub struct MelonstubeProvider {}

impl MelonstubeProvider {
    pub fn new() -> Self {
        Self {}
    }

    fn build_channel(&self, _clientversion: ClientVersion) -> Channel {
        Channel {
            id: CHANNEL_ID.to_string(),
            name: "Melons Tube".to_string(),
            description: "Meta-search aggregator linking out to videos hosted across many other sites.".to_string(),
            premium: false,
            favicon: "https://www.google.com/s2/favicons?sz=64&domain=melonstube.com".to_string(),
            status: "active".to_string(),
            categories: vec![],
            options: vec![ChannelOption {
                id: "sort".to_string(),
                title: "Sort".to_string(),
                description: "Browse the latest, most popular, or top rated feed.".to_string(),
                systemImage: "list.number".to_string(),
                colorName: "blue".to_string(),
                options: vec![
                    FilterOption {
                        id: "new".to_string(),
                        title: "Latest".to_string(),
                    },
                    FilterOption {
                        id: "popular".to_string(),
                        title: "Popular".to_string(),
                    },
                    FilterOption {
                        id: "rating".to_string(),
                        title: "Top Rated".to_string(),
                    },
                ],
                multiSelect: false,
            }],
            nsfw: true,
            cacheDuration: Some(1800),
            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string()),
        }
    }

    fn build_url(target: &Target) -> String {
        match target {
            Target::Latest { page } => {
                if *page > 1 {
                    format!("{BASE_URL}/new?page={page}")
                } else {
                    format!("{BASE_URL}/new")
                }
            }
            Target::Popular { page } => {
                if *page > 1 {
                    format!("{BASE_URL}/popular?page={page}")
                } else {
                    format!("{BASE_URL}/popular")
                }
            }
            Target::Rating { page } => {
                if *page > 1 {
                    format!("{BASE_URL}/rating?page={page}")
                } else {
                    format!("{BASE_URL}/rating")
                }
            }
            Target::Search { .. } => unreachable!(
                "Target::Search is resolved asynchronously via resolve_search_base_url"
            ),
        }
    }

    fn parse_duration(text: &str) -> u32 {
        let parts: Vec<u32> = text
            .trim()
            .split(':')
            .filter_map(|p| p.parse::<u32>().ok())
            .collect();
        match parts.as_slice() {
            [m, s] => m * 60 + s,
            [h, m, s] => h * 3600 + m * 60 + s,
            _ => 0,
        }
    }

    fn parse_relative_ago(text: &str) -> Option<u64> {
        let trimmed = text.trim();
        if trimmed.is_empty() {
            return None;
        }
        let regex = Regex::new(
            r"(?i)^(?P<amount>\d+)\s+(?P<unit>second|seconds|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() {
            "second" | "seconds" => now - ChronoDuration::seconds(amount),
            "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 clean_title(title: &str) -> String {
        title
            .trim()
            .trim_matches(|c: char| {
                matches!(c, '"' | '\u{201c}' | '\u{201d}' | '\u{2018}' | '\u{2019}')
            })
            .trim()
            .to_string()
    }

    /// Decodes a melonstube `/out/?l=<b64-msgpack>&c=<hash>&v=3&` out-link entirely
    /// locally (no network calls): percent-decoding is handled by `url::Url`'s
    /// query-pair parser, then the `l` payload is base64-decoded (it is standard
    /// MessagePack carrying the embedded destination URL as a plain string), and
    /// the destination URL is recovered with a small regex scan over the decoded
    /// bytes. Returns `(destination_url, stable_id)`.
    fn decode_out_link(href: &str) -> Option<(String, String)> {
        let full = if href.starts_with("http") {
            href.to_string()
        } else {
            format!("{BASE_URL}{href}")
        };
        let parsed = url::Url::parse(&full).ok()?;
        if parsed.path() != "/out/" {
            return None;
        }

        let mut l_val = None;
        let mut c_val = None;
        for (key, value) in parsed.query_pairs() {
            match key.as_ref() {
                "l" => l_val = Some(value.into_owned()),
                "c" => c_val = Some(value.into_owned()),
                _ => {}
            }
        }
        let l_val = l_val?;
        let id = c_val.unwrap_or_default();

        let mut normalized = l_val.trim().to_string();
        while normalized.len() % 4 != 0 {
            normalized.push('=');
        }
        let decoded_bytes = STANDARD.decode(normalized).ok()?;
        let text = String::from_utf8_lossy(&decoded_bytes);
        // The destination URL is one msgpack string embedded among binary
        // fields; a byte class of only legal URI characters keeps the match
        // from bleeding into the msgpack length-prefix byte that immediately
        // follows the string (which lossy-converts to U+FFFD).
        let regex = Regex::new(r#"https?://[A-Za-z0-9\-._~:/?#\[\]@!$&'()*+,;=%]+"#).ok()?;
        let destination = regex.find(&text)?.as_str().to_string();

        Some((destination, id))
    }

    fn is_junk_host(host: &str) -> bool {
        JUNK_HOSTS.contains(&host) || host.starts_with("join.")
    }

    fn is_hard_host(host: &str) -> bool {
        HARD_HOSTS.contains(&host)
    }

    fn parse_listing(&self, html: &str, options: &ServerOptions) -> Vec<VideoItem> {
        let document = Html::parse_document(html);

        let card_sel = match Selector::parse("div.card[data-public-id]") {
            Ok(s) => s,
            Err(_) => return vec![],
        };
        let link_sel = match Selector::parse("a.item-link[href]") {
            Ok(s) => s,
            Err(_) => return vec![],
        };
        let img_sel = match Selector::parse("img.item-image") {
            Ok(s) => s,
            Err(_) => return vec![],
        };
        let model_sel = match Selector::parse("a.pornstar-label") {
            Ok(s) => s,
            Err(_) => return vec![],
        };
        let source_sel = match Selector::parse(r#"a.item-source[href^="/source/"]"#) {
            Ok(s) => s,
            Err(_) => return vec![],
        };

        let dur_re = match Regex::new(r#"class="badge[^"]*"[^>]*>\s*(\d{1,2}:\d{2}(?::\d{2})?)\s*</span>"#) {
            Ok(r) => r,
            Err(_) => return vec![],
        };
        let ago_re = match Regex::new(r#"</a><span class="item-source[^"]*">([^<]+)</span>"#) {
            Ok(r) => r,
            Err(_) => return vec![],
        };

        let mut items = Vec::new();

        for card in document.select(&card_sel) {
            let public_id = match card.value().attr("data-public-id") {
                Some(v) if !v.is_empty() => v.to_string(),
                _ => continue,
            };

            let Some(link) = card.select(&link_sel).next() else {
                continue;
            };
            let href = link.value().attr("href").unwrap_or_default();
            let raw_title = link.value().attr("title").unwrap_or_default();

            let Some((destination_url, _hash_id)) = Self::decode_out_link(href) else {
                continue;
            };
            let Ok(dest_parsed) = url::Url::parse(&destination_url) else {
                continue;
            };
            let Some(host) = dest_parsed.host_str() else {
                continue;
            };
            if Self::is_junk_host(host) {
                continue;
            }

            let title = Self::clean_title(raw_title);
            if title.is_empty() {
                continue;
            }

            let thumb = card
                .select(&img_sel)
                .next()
                .and_then(|img| img.value().attr("src"))
                .unwrap_or_default()
                .to_string();

            let card_html = card.html();
            let duration = dur_re
                .captures(&card_html)
                .and_then(|c| c.get(1))
                .map(|m| Self::parse_duration(m.as_str()))
                .unwrap_or(0);

            let mut item = VideoItem::new(
                public_id,
                title,
                destination_url.clone(),
                CHANNEL_ID.to_string(),
                thumb,
                duration,
            );

            if Self::is_hard_host(host) {
                let proxy_target = strip_url_scheme(&destination_url);
                let proxy_url = build_proxy_url(options, CHANNEL_ID, &proxy_target);
                if !proxy_url.is_empty() {
                    let mut format = crate::videos::VideoFormat::new(
                        proxy_url,
                        "auto".to_string(),
                        "video/mp4".to_string(),
                    );
                    format.add_http_header("Referer".to_string(), destination_url.clone());
                    item = item.formats(vec![format]);
                }
            }

            if let Some(ts) = ago_re
                .captures(&card_html)
                .and_then(|c| c.get(1))
                .and_then(|m| Self::parse_relative_ago(m.as_str()))
            {
                item = item.uploaded_at(ts);
            }

            if let Some(source_link) = card.select(&source_sel).next() {
                let source_href = source_link.value().attr("href").unwrap_or_default();
                let slug = source_href.trim_start_matches("/source/").to_string();
                let name = source_link.text().collect::<String>().trim().to_string();
                if !name.is_empty() {
                    item = item.uploader(name);
                    if !slug.is_empty() {
                        item = item.uploader_url(format!("{BASE_URL}/source/{slug}"));
                        item.uploaderId = Some(format!("{CHANNEL_ID}:{slug}"));
                    }
                }
            }

            let mut models: Vec<String> = card
                .select(&model_sel)
                .filter_map(|m| m.value().attr("title"))
                .map(|s| s.trim().to_string())
                .filter(|s| !s.is_empty())
                .collect();
            models.sort();
            models.dedup();
            if !models.is_empty() {
                item = item.tags(models);
            }

            items.push(item);
        }

        items
    }

    fn resolve_target(query: &str, sort: &str, page: u32) -> Target {
        if !query.is_empty() {
            return Target::Search {
                query: query.to_string(),
                page,
            };
        }
        match sort {
            "popular" | "top" | "views" => Target::Popular { page },
            "rating" | "best" | "top_rated" => Target::Rating { page },
            _ => Target::Latest { page },
        }
    }

    fn build_search_request_body(query: &str) -> String {
        let mut serializer = Serializer::new(String::new());
        serializer.append_pair("search_query[query]", query);
        serializer.finish()
    }

    async fn resolve_search_base_url(query: &str, options: &ServerOptions) -> Result<String> {
        let search_url = format!("{BASE_URL}/searching/by-form");
        let body = Self::build_search_request_body(query);

        let mut requester = requester_or_default(options, CHANNEL_ID, "resolve_search_base_url");

        let response = requester
            .post(
                &search_url,
                &body,
                vec![
                    ("Content-Type", "application/x-www-form-urlencoded"),
                    ("Referer", BASE_URL),
                    ("User-Agent", BROWSER_UA),
                ],
            )
            .await
            .map_err(|e| Error::from(format!("search submit failed url={search_url}: {e}")))?;

        Ok(response.url().to_string().trim_end_matches('/').to_string())
    }

    async fn fetch_and_parse(
        &self,
        cache: VideoCache,
        target: Target,
        options: ServerOptions,
    ) -> Result<Vec<VideoItem>> {
        let url = match &target {
            Target::Search { query, page } => {
                let base = match Self::resolve_search_base_url(query, &options).await {
                    Ok(base) => base,
                    Err(e) => {
                        report_provider_error(CHANNEL_ID, "fetch_and_parse.search", &e.to_string())
                            .await;
                        return Ok(vec![]);
                    }
                };
                if *page > 1 {
                    format!("{base}?page={page}")
                } else {
                    base
                }
            }
            _ => Self::build_url(&target),
        };

        if let Some((time, items)) = cache.get(&url) {
            if time.elapsed().unwrap_or_default().as_secs() < 300 {
                return Ok(items.clone());
            }
        }

        let mut requester = requester_or_default(&options, CHANNEL_ID, "fetch_and_parse");

        let html = requester
            .get_with_headers(
                &url,
                vec![
                    ("user-agent".to_string(), BROWSER_UA.to_string()),
                    (
                        "accept".to_string(),
                        "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8".to_string(),
                    ),
                    ("accept-language".to_string(), "en-US,en;q=0.5".to_string()),
                ],
                Some(wreq::Version::HTTP_11),
            )
            .await
            .map_err(|e| Error::from(format!("request failed url={url}: {e}")))?;

        if html.is_empty() {
            return Ok(vec![]);
        }

        let items = self.parse_listing(&html, &options);

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

        Ok(items)
    }
}

#[async_trait]
impl Provider for MelonstubeProvider {
    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 _ = per_page;
        let page = page.parse::<u32>().unwrap_or(1);
        let query_str = query.unwrap_or_default();
        let target = Self::resolve_target(&query_str, &sort, page);

        match self.fetch_and_parse(cache, target, options).await {
            Ok(items) => items,
            Err(e) => {
                report_provider_error(CHANNEL_ID, "get_videos", &e.to_string()).await;
                vec![]
            }
        }
    }

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

#[cfg(test)]
mod tests {
    use super::MelonstubeProvider;
    use crate::videos::ServerOptions;

    fn make_options() -> ServerOptions {
        ServerOptions {
            client_version: None,
            featured: None,
            category: None,
            sites: None,
            filter: None,
            language: None,
            public_url_base: Some("http://127.0.0.1:18080".to_string()),
            requester: None,
            network: None,
            stars: None,
            categories: None,
            duration: None,
            sort: None,
            sexuality: None,
        }
    }

    #[test]
    fn builds_latest_urls() {
        assert_eq!(
            MelonstubeProvider::build_url(&super::Target::Latest { page: 1 }),
            "https://www.melonstube.com/new"
        );
        assert_eq!(
            MelonstubeProvider::build_url(&super::Target::Latest { page: 2 }),
            "https://www.melonstube.com/new?page=2"
        );
    }

    #[test]
    fn builds_popular_and_rating_urls() {
        assert_eq!(
            MelonstubeProvider::build_url(&super::Target::Popular { page: 1 }),
            "https://www.melonstube.com/popular"
        );
        assert_eq!(
            MelonstubeProvider::build_url(&super::Target::Rating { page: 3 }),
            "https://www.melonstube.com/rating?page=3"
        );
    }

    #[test]
    fn builds_search_request_body() {
        assert_eq!(
            MelonstubeProvider::build_search_request_body("adriana chechik"),
            "search_query%5Bquery%5D=adriana+chechik"
        );
    }

    #[test]
    fn parses_duration() {
        assert_eq!(MelonstubeProvider::parse_duration("28:30"), 1710);
        assert_eq!(MelonstubeProvider::parse_duration("1:02:03"), 3723);
    }

    #[test]
    fn parses_relative_ago() {
        assert!(MelonstubeProvider::parse_relative_ago("2 minutes ago").is_some());
        assert!(MelonstubeProvider::parse_relative_ago("garbage").is_none());
    }

    #[test]
    fn decodes_real_out_link() {
        let href = "/out/?l=3AASFc4gFQqQq3JyVWJiN2xYa1FHAtmxaHR0cHM6Ly93d3cuNDI5bWVuLmNvbS92aWRlb3MvNDY3NTg2L3Vuc2Vlbi1tb21lbnRzLW9mLWVjc3Rhc3ktd2l0aC1qaWxsLWthc3NpZHktdmluYS1za3ktYW5kLW5hb21pLXN3YW5uLz91dG1fc291cmNlPWF3bSZ1dG1fbWVkaXVtPWF3bXRyYWZmaWMmdXRtX2NhbXBhaWduPTQyOW1lbiZzdWJpZDE9NjAwMDAxzQNeonRjAc0H2qRkYXRlAdkweyJhbGwiOiIiLCJvcmllbnRhdGlvbiI6InN0cmFpZ2h0IiwicHJpY2luZyI6IiJ9wM5qS9cBwM4EMO42wNk9W3siMSI6IjA4Wlk3ZkpHb3MzIn0seyIyIjoiQTlVUWQ2MGxOem8ifSx7IjMiOiIwNlJrNVhmSXVhWiJ9XQ%3D%3D&c=abc123&v=3&";
        let (destination, id) = MelonstubeProvider::decode_out_link(href).expect("decodes");
        assert!(destination.starts_with("https://www.429men.com/videos/467586/"));
        assert_eq!(id, "abc123");
    }

    #[test]
    fn classifies_hosts() {
        assert!(MelonstubeProvider::is_hard_host("manysex.com"));
        assert!(MelonstubeProvider::is_hard_host("videomanysex.com"));
        assert!(!MelonstubeProvider::is_hard_host("www.429men.com"));
        assert!(MelonstubeProvider::is_junk_host("fhgte.com"));
        assert!(MelonstubeProvider::is_junk_host("join.nastyclub.com"));
        assert!(!MelonstubeProvider::is_junk_host("www.429men.com"));
    }

    #[test]
    fn parses_cards_from_html() {
        let html = r##"
        <html><body>
        <div class="card sub group relative block space-y-1" data-public-id="rrUbb7lXkQG">
          <a class="item-link rate-link relative rounded h-0 overflow-hidden block pb-[56.25%]" href="/out/?l=3AASFc4gFQqQq3JyVWJiN2xYa1FHAtmxaHR0cHM6Ly93d3cuNDI5bWVuLmNvbS92aWRlb3MvNDY3NTg2L3Vuc2Vlbi1tb21lbnRzLW9mLWVjc3Rhc3ktd2l0aC1qaWxsLWthc3NpZHktdmluYS1za3ktYW5kLW5hb21pLXN3YW5uLz91dG1fc291cmNlPWF3bSZ1dG1fbWVkaXVtPWF3bXRyYWZmaWMmdXRtX2NhbXBhaWduPTQyOW1lbiZzdWJpZDE9NjAwMDAxzQNeonRjAc0H2qRkYXRlAdkweyJhbGwiOiIiLCJvcmllbnRhdGlvbiI6InN0cmFpZ2h0IiwicHJpY2luZyI6IiJ9wM5qS9cBwM4EMO42wNk9W3siMSI6IjA4Wlk3ZkpHb3MzIn0seyIyIjoiQTlVUWQ2MGxOem8ifSx7IjMiOiIwNlJrNVhmSXVhWiJ9XQ%3D%3D&c=abc123&v=3&" target="_blank" title="&#8221;Unseen Moments&#8221;" tabindex="-1" rel="nofollow">
            <img class="item-image relative w-full h-full object-cover" src="https://c4.ttcache.com/thumbnail/rrUbb7lXkQG/288x162/1.jpg">
            <span class="item-meta-container absolute bottom-1 right-1"><span class="badge float-end bg-zinc-100/80 dark:bg-zinc-800/80 inline-block px-1 text-sm rounded">28:30</span></span>
          </a>
          <div class="dropdown-menu-footer">
            <a class="menu-item anchor-link gap-3 pornstar-label" href="/search/Jill%20Kassidy" title="Jill Kassidy"><i class="icon-start far fa-person"></i>Jill Kassidy</a>
          </div>
          <div class="item-source-rating-container"><a class="item-source block text-xsm truncate text-dimmed" href="/source/429men"><i class="far fa-badge-check text-xsm"></i>429Men</a><span class="item-source block text-xsm text-dimmed">2 minutes ago</span></div>
        </div>
        </body></html>
        "##;

        let provider = MelonstubeProvider::new();
        let options = make_options();
        let items = provider.parse_listing(html, &options);
        assert_eq!(items.len(), 1);
        let item = &items[0];
        assert_eq!(item.id, "rrUbb7lXkQG");
        assert_eq!(item.title, "Unseen Moments");
        assert_eq!(item.duration, 1710);
        assert!(item.url.starts_with("https://www.429men.com/videos/467586/"));
        assert!(item.formats.is_none());
        assert_eq!(item.uploader.as_deref(), Some("429Men"));
        assert_eq!(item.tags.as_deref(), Some(&["Jill Kassidy".to_string()][..]));
        assert!(item.uploadedAt.is_some());
    }

    #[test]
    fn hard_host_card_gets_proxy_format() {
        let html = r##"
        <html><body>
        <div class="card sub group relative block space-y-1" data-public-id="hardHostId1">
          <a class="item-link rate-link" href="/out/?l=o3hVOWh0dHBzOi8vdmlkZW9tYW55c2V4LmNvbS92aWRlby8zNTE5MDkzL3RlcmEtbGluay8AZXh0cmE%3D&c=def456&v=3&" target="_blank" title="Tera Link">
            <img class="item-image" src="https://c4.ttcache.com/thumbnail/hardHostId1/288x162/1.jpg">
            <span class="item-meta-container"><span class="badge">8:00</span></span>
          </a>
        </div>
        </body></html>
        "##;

        let provider = MelonstubeProvider::new();
        let options = make_options();
        let items = provider.parse_listing(html, &options);
        assert_eq!(items.len(), 1);
        let item = &items[0];
        assert!(item.url.starts_with("https://videomanysex.com/video/3519093/"));
        let formats = item.formats.as_ref().expect("hard host must proxy");
        assert!(
            formats[0]
                .url
                .starts_with("http://127.0.0.1:18080/proxy/melonstube/videomanysex.com/")
        );
    }
}
