use crate::DbPool;
use crate::api::ClientVersion;
use crate::providers::{Provider, report_provider_error, requester_or_default};
use crate::status::*;
use crate::util::cache::VideoCache;
use crate::videos::{ServerOptions, VideoItem};
use async_trait::async_trait;
use error_chain::error_chain;
use htmlentity::entity::{ICodedDataTrait, decode};

pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
    crate::providers::ProviderChannelMetadata {
        group_id: "live-cams",
        tags: &["live", "cams", "amateur", "webcam"],
    };

const BASE_URL: &str = "https://www.camsoda.com";
const CHANNEL_ID: &str = "camsoda";
/// Live-cam browse API. The `/` SPA loads this over XHR; it is Cloudflare-
/// protected on direct access (HTTP 403), so the shared requester's Jina mirror
/// fallback is what gets through. Response body has `userList` at the top level.
const API_BROWSE: &str = "https://www.camsoda.com/api/v1/browse/react";

/// Category routes appended to `browse/react` (verified against the live API).
/// `id` is what the client sends back in the `category` option; `route` is the
/// path segment (empty = the default featured feed).
const CATEGORIES: &[(&str, &str, &str)] = &[
    ("all", "Featured", ""),
    ("girls", "Girls", "/girls"),
    ("trans", "Trans", "/trans"),
    ("couples", "Couples", "/couples"),
    ("voyeur-cams", "Voyeur Cams", "/voyeur-cams"),
    ("new", "New", "/girls/new"),
];

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

#[derive(Debug, Clone)]
pub struct CamsodaProvider;

#[derive(Debug, Clone, PartialEq, Eq)]
enum Target {
    /// Live browse feed at `browse/react{route}?p=N`.
    Browse { route: String },
    /// Keyword search at `browse/react/search/{query}?sortByConnection=1`.
    Search { query: String },
}

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

    fn build_channel(&self, _clientversion: ClientVersion) -> Channel {
        let cat_options = CATEGORIES
            .iter()
            .map(|(id, title, _)| FilterOption {
                id: id.to_string(),
                title: title.to_string(),
            })
            .collect::<Vec<_>>();

        Channel {
            id: CHANNEL_ID.to_string(),
            name: "CamSoda".to_string(),
            description: "CamSoda live webcams — free adult cam shows streaming right now."
                .to_string(),
            premium: false,
            favicon: "https://www.google.com/s2/favicons?sz=64&domain=camsoda.com".to_string(),
            status: "active".to_string(),
            categories: vec![],
            options: vec![ChannelOption {
                id: "category".to_string(),
                title: "Category".to_string(),
                description: "Browse a CamSoda live-cam category.".to_string(),
                systemImage: "square.grid.2x2".to_string(),
                colorName: "orange".to_string(),
                options: cat_options,
                multiSelect: false,
            }],
            nsfw: true,
            cacheDuration: Some(60),
            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
        }
    }

    /// Resolve the category id (from the option or a query prefix) to a route.
    fn route_for_category(value: &str) -> Option<String> {
        let key = value.trim().trim_start_matches('#').to_ascii_lowercase();
        if key.is_empty() {
            return None;
        }
        CATEGORIES
            .iter()
            .find(|(id, _, _)| *id == key)
            .map(|(_, _, route)| route.to_string())
    }

    /// Decide what to fetch from the query and the selected category option.
    fn pick_target(query: Option<&str>, category: Option<&str>) -> Target {
        if let Some(cat) = category {
            if let Some(route) = Self::route_for_category(cat) {
                return Target::Browse { route };
            }
        }

        let Some(query) = query.map(str::trim).filter(|v| !v.is_empty()) else {
            return Target::Browse {
                route: String::new(),
            };
        };

        // `cat:`/`category:` prefixes route to a known category.
        for prefix in &["category:", "cat:"] {
            if let Some(rest) = query.strip_prefix(prefix) {
                if let Some(route) = Self::route_for_category(rest) {
                    return Target::Browse { route };
                }
            }
        }

        // A bare keyword that exactly matches a category id goes to that feed.
        if let Some(route) = Self::route_for_category(query) {
            return Target::Browse { route };
        }

        Target::Search {
            query: query.to_string(),
        }
    }

    fn build_url(&self, target: &Target, page: u16) -> String {
        let page = page.max(1);
        match target {
            Target::Browse { route } => format!("{}{}?p={}", API_BROWSE, route, page),
            Target::Search { query } => {
                let q = Self::slug_query(query);
                // Search is a single connection-sorted result set (no paging).
                format!("{}/search/{}?sortByConnection=1", API_BROWSE, q)
            }
        }
    }

    /// Encode a free-text query into the `/search/<q>` path segment.
    fn slug_query(query: &str) -> String {
        query
            .trim()
            .to_ascii_lowercase()
            .split_whitespace()
            .collect::<Vec<_>>()
            .join("-")
    }

    fn clean_text(text: &str) -> String {
        let decoded = decode(text.as_bytes())
            .to_string()
            .unwrap_or_else(|_| text.to_string());
        decoded.split_whitespace().collect::<Vec<_>>().join(" ")
    }

    /// Extract the JSON object from a body that may be wrapped in HTML by the
    /// Jina mirror (`<pre>{...}</pre>`) or returned raw.
    fn extract_json(body: &str) -> Option<&str> {
        let start = body.find('{')?;
        let end = body.rfind('}')?;
        (end > start).then(|| &body[start..=end])
    }

    /// Parse the `browse/react` JSON (top-level `userList`) into live
    /// `VideoItem`s. Done with `serde_json::Value` so a stray field shape never
    /// aborts the whole page (mirrors the chaturbate provider).
    fn parse_items(body: &str) -> Result<Vec<VideoItem>> {
        let json = Self::extract_json(body)
            .ok_or_else(|| Error::from("no JSON object found in response".to_string()))?;
        let root: serde_json::Value = serde_json::from_str(json)
            .map_err(|e| Error::from(format!("browse JSON decode failed: {e}")))?;

        let users = root
            .get("userList")
            .and_then(|v| v.as_array())
            .ok_or_else(|| Error::from("missing userList array".to_string()))?;

        let mut items = Vec::with_capacity(users.len());
        for user in users {
            let Some(username) = user.get("username").and_then(|v| v.as_str()) else {
                continue;
            };
            if username.is_empty() {
                continue;
            }

            // Skip offline performers; everything else in the browse feed is live.
            let status = user.get("status").and_then(|v| v.as_str()).unwrap_or("");
            if status.eq_ignore_ascii_case("offline") {
                continue;
            }

            let display = user
                .get("displayName")
                .and_then(|v| v.as_str())
                .filter(|s| !s.is_empty())
                .unwrap_or(username);

            let subject = user
                .get("subjectText")
                .and_then(|v| v.as_str())
                .map(Self::clean_text)
                .filter(|s| !s.is_empty());
            let title = subject.unwrap_or_else(|| display.to_string());

            let thumb = user
                .get("thumbUrl")
                .and_then(|v| v.as_str())
                .filter(|s| !s.is_empty())
                .or_else(|| user.get("offlinePictureUrl").and_then(|v| v.as_str()))
                .unwrap_or("")
                .to_string();

            // connectionCount is usually a string ("34") but tolerate a number.
            let views = user
                .get("connectionCount")
                .map(|v| match v {
                    serde_json::Value::String(s) => s.parse::<u32>().unwrap_or(0),
                    serde_json::Value::Number(n) => n.as_u64().unwrap_or(0) as u32,
                    _ => 0,
                })
                .unwrap_or(0);

            let room_url = format!("{BASE_URL}/{username}");

            let mut tags = Vec::new();
            if status.eq_ignore_ascii_case("private") {
                tags.push("Private Show".to_string());
            }
            if user
                .get("vr")
                .and_then(|v| v.as_bool())
                .unwrap_or(false)
            {
                tags.push("VR".to_string());
            }

            let mut item = VideoItem::new(
                username.to_string(),
                title,
                room_url.clone(),
                CHANNEL_ID.to_string(),
                thumb,
                0,
            )
            .is_live(true)
            .views(views)
            .uploader(display.to_string())
            .uploader_url(room_url);
            item.uploaderId = Some(format!("{CHANNEL_ID}:{username}"));
            if !tags.is_empty() {
                item.tags = Some(tags);
            }

            items.push(item);
        }

        Ok(items)
    }

    async fn fetch(
        &self,
        target: &Target,
        page: u16,
        cache: &VideoCache,
        options: &ServerOptions,
    ) -> Result<Vec<VideoItem>> {
        let url = self.build_url(target, page);

        // Short cache (the feed changes constantly and Jina rate-limits hard).
        let stale = match cache.get(&url) {
            Some((time, items)) => {
                if time.elapsed().unwrap_or_default().as_secs() < 60 {
                    return Ok(items.clone());
                }
                items.clone()
            }
            None => vec![],
        };

        let mut requester = requester_or_default(options, CHANNEL_ID, "fetch");
        let text = match requester.get(&url, None).await {
            Ok(t) => t,
            Err(e) => {
                report_provider_error(CHANNEL_ID, "fetch.request", &format!("url={url}; error={e}"))
                    .await;
                return Ok(stale);
            }
        };

        if text.contains("cf-browser-verification")
            || text.contains("cf-chl")
            || text.contains("Just a moment")
        {
            report_provider_error(CHANNEL_ID, "fetch.cloudflare", &format!("url={url}")).await;
            return Ok(stale);
        }

        match Self::parse_items(&text) {
            Ok(items) if !items.is_empty() => {
                cache.remove(&url);
                cache.insert(url, items.clone());
                Ok(items)
            }
            Ok(_) => Ok(stale),
            Err(e) => {
                report_provider_error(CHANNEL_ID, "fetch.parse", &format!("url={url}; error={e}"))
                    .await;
                Ok(stale)
            }
        }
    }
}

#[async_trait]
impl Provider for CamsodaProvider {
    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::<u16>().unwrap_or(1).max(1);
        let normalized_query = query
            .as_deref()
            .map(str::trim)
            .filter(|v| !v.is_empty())
            .map(ToOwned::to_owned);

        let category = options
            .categories
            .as_deref()
            .or(options.category.as_deref());

        let target = Self::pick_target(normalized_query.as_deref(), category);

        match self.fetch(&target, page, &cache, &options).await {
            Ok(items) => items,
            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 sample_browse() -> String {
        // Mimics the Jina mirror response: JSON wrapped in a <pre> block.
        r#"<html><body><pre>{"perPageCount":60,"totalCount":98,"userList":[
{"id":1,"username":"theowonder","displayName":"Theo Wonder","connectionCount":"69","status":"online","subjectText":"hey guys &amp; girls","thumbUrl":"https://media.livemediahost.com/thumbs/199/theowonder.jpg?cb=1","streamName":"cam_obs/theowonder-flu","vr":false},
{"id":2,"username":"miavr","displayName":"Mia","connectionCount":139,"status":"private","subjectText":"","thumbUrl":"https://media.livemediahost.com/thumbs/197/miavr.jpg","vr":true},
{"id":3,"username":"gone","displayName":"Gone","connectionCount":"0","status":"offline","subjectText":"bye","thumbUrl":""}
]}</pre></body></html>"#
            .to_string()
    }

    #[test]
    fn parses_live_items_and_skips_offline() {
        let items = CamsodaProvider::parse_items(&sample_browse()).unwrap();
        assert_eq!(items.len(), 2, "offline performer should be skipped");

        let a = &items[0];
        assert_eq!(a.id, "theowonder");
        assert_eq!(a.title, "hey guys & girls", "subject html-decoded");
        assert_eq!(a.url, "https://www.camsoda.com/theowonder");
        assert!(a.isLive);
        assert_eq!(a.views, Some(69));
        assert_eq!(a.uploader.as_deref(), Some("Theo Wonder"));
        assert_eq!(a.uploaderId.as_deref(), Some("camsoda:theowonder"));
        assert!(a.thumb.contains("media.livemediahost.com"));

        let b = &items[1];
        assert_eq!(b.id, "miavr");
        // numeric connectionCount tolerated
        assert_eq!(b.views, Some(139));
        // empty subject falls back to display name
        assert_eq!(b.title, "Mia");
        // private + vr surface as tags
        let tags = b.tags.clone().unwrap_or_default();
        assert!(tags.contains(&"Private Show".to_string()));
        assert!(tags.contains(&"VR".to_string()));
    }

    #[test]
    fn picks_target() {
        assert_eq!(
            CamsodaProvider::pick_target(None, None),
            Target::Browse { route: String::new() }
        );
        assert_eq!(
            CamsodaProvider::pick_target(None, Some("girls")),
            Target::Browse { route: "/girls".to_string() }
        );
        assert_eq!(
            CamsodaProvider::pick_target(Some("cat:trans"), None),
            Target::Browse { route: "/trans".to_string() }
        );
        assert_eq!(
            CamsodaProvider::pick_target(Some("voyeur-cams"), None),
            Target::Browse { route: "/voyeur-cams".to_string() }
        );
        assert_eq!(
            CamsodaProvider::pick_target(Some("blonde teen"), None),
            Target::Search { query: "blonde teen".to_string() }
        );
    }

    #[test]
    fn builds_urls() {
        let p = CamsodaProvider::new();
        assert_eq!(
            p.build_url(&Target::Browse { route: String::new() }, 1),
            "https://www.camsoda.com/api/v1/browse/react?p=1"
        );
        assert_eq!(
            p.build_url(&Target::Browse { route: "/girls".to_string() }, 3),
            "https://www.camsoda.com/api/v1/browse/react/girls?p=3"
        );
        assert_eq!(
            p.build_url(&Target::Search { query: "big boobs".to_string() }, 1),
            "https://www.camsoda.com/api/v1/browse/react/search/big-boobs?sortByConnection=1"
        );
    }
}
