use crate::DbPool;
use crate::api::ClientVersion;
use crate::providers::Provider;
use crate::status::*;
use crate::util::parse_abbreviated_number;
use crate::util::cache::VideoCache;
use crate::util::discord::{format_error_chain, send_discord_error_report};
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 error_chain::error_chain;
use htmlentity::entity::{ICodedDataTrait, decode};
use std::sync::{Arc, RwLock};
use std::{thread, vec};
use titlecase::Titlecase;
use url::Url;
use wreq::Version;

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

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

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

impl PimpbunnyProvider {
    const FIREFOX_USER_AGENT: &'static str =
        "Mozilla/5.0 (X11; Linux x86_64; rv:147.0) Gecko/20100101 Firefox/147.0";
    const HTML_ACCEPT: &'static str =
        "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8";
    pub fn new() -> Self {
        let provider = Self {
            url: "https://pimpbunny.com".to_string(),
            stars: Arc::new(RwLock::new(vec![])),
            categories: Arc::new(RwLock::new(vec![])),
        };
        provider.spawn_initial_load();
        provider
    }

    fn build_channel(&self, clientversion: ClientVersion) -> Channel {
        let _ = clientversion;
        Channel {
            id: "pimpbunny".to_string(),
            name: "Pimpbunny".to_string(),
            description: "Watch Porn!".to_string(),
            premium: false,
            favicon: "https://www.google.com/s2/favicons?sz=64&domain=pimpbunny.com".to_string(),
            status: "active".to_string(),
            categories: self
                .categories
                .read()
                .map(|categories| categories.iter().map(|c| c.title.clone()).collect())
                .unwrap_or_else(|e| {
                    crate::providers::report_provider_error_background(
                        "pimpbunny",
                        "build_channel.categories_read",
                        &e.to_string(),
                    );
                    vec![]
                }),
            options: vec![ChannelOption {
                id: "sort".to_string(),
                title: "Sort".to_string(),
                description: "Sort the Videos".to_string(),
                systemImage: "list.number".to_string(),
                colorName: "blue".to_string(),
                options: vec![
                    FilterOption {
                        id: "featured".into(),
                        title: "Featured".into(),
                    },
                    FilterOption {
                        id: "most recent".into(),
                        title: "Most Recent".into(),
                    },
                    FilterOption {
                        id: "most viewed".into(),
                        title: "Most Viewed".into(),
                    },
                    FilterOption {
                        id: "best rated".into(),
                        title: "Best Rated".into(),
                    },
                ],
                multiSelect: false,
            }],
            nsfw: true,
            cacheDuration: None,
            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
        }
    }

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

        thread::spawn(async move || {
            let rt = match tokio::runtime::Builder::new_current_thread()
                .enable_all()
                .build()
            {
                Ok(rt) => rt,
                Err(e) => {
                    eprintln!("tokio runtime failed: {e}");
                    send_discord_error_report(
                        e.to_string(),
                        Some(format_error_chain(&e)),
                        Some("Pimpbunny Provider"),
                        Some("Failed to create tokio runtime"),
                        file!(),
                        line!(),
                        module_path!(),
                    )
                    .await;
                    return;
                }
            };

            rt.block_on(async {
                if let Err(e) = Self::load_stars(&url, Arc::clone(&stars)).await {
                    eprintln!("load_stars failed: {e}");
                    send_discord_error_report(
                        e.to_string(),
                        Some(format_error_chain(&e)),
                        Some("Pimpbunny Provider"),
                        Some("Failed to load stars during initial load"),
                        file!(),
                        line!(),
                        module_path!(),
                    )
                    .await;
                }
                if let Err(e) = Self::load_categories(&url, Arc::clone(&categories)).await {
                    eprintln!("load_categories failed: {e}");
                    send_discord_error_report(
                        e.to_string(),
                        Some(format_error_chain(&e)),
                        Some("Pimpbunny Provider"),
                        Some("Failed to load categories during initial load"),
                        file!(),
                        line!(),
                        module_path!(),
                    )
                    .await;
                }
            });
        });
    }

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

    fn proxied_thumb(&self, options: &ServerOptions, thumb: &str) -> String {
        let _ = options;
        thumb.to_string()
    }

    fn is_allowed_detail_url(url: &str) -> bool {
        let Some(url) = Url::parse(url).ok() else {
            return false;
        };
        if url.scheme() != "https" {
            return false;
        }
        let Some(host) = url.host_str() else {
            return false;
        };
        matches!(host, "pimpbunny.com" | "www.pimpbunny.com")
            && !url.path().starts_with("/contents/videos_screenshots/")
    }

    fn proxied_video(&self, options: &ServerOptions, page_url: &str) -> String {
        if page_url.is_empty() || !Self::is_allowed_detail_url(page_url) {
            return page_url.to_string();
        }

        crate::providers::build_proxy_url(
            options,
            "pimpbunny",
            &crate::providers::strip_url_scheme(page_url),
        )
    }

    fn root_referer(&self) -> String {
        format!("{}/", self.url.trim_end_matches('/'))
    }

    fn sort_by(sort: &str) -> &'static str {
        match sort {
            "best rated" => "rating",
            "most viewed" => "video_viewed",
            _ => "post_date",
        }
    }

    fn build_search_path_query(query: &str, separator: &str) -> String {
        query.split_whitespace().collect::<Vec<_>>().join(separator)
    }

    fn append_archive_query(url: String, sort: &str) -> String {
        let separator = if url.contains('?') { '&' } else { '?' };
        format!("{url}{separator}sort_by={}", Self::sort_by(sort))
    }

    fn page_family_referer(&self, request_url: &str) -> String {
        let Some(url) = Url::parse(request_url).ok() else {
            return self.root_referer();
        };

        let path = url.path();
        let referer_path = if path.starts_with("/videos/") {
            "/videos/".to_string()
        } else if path.starts_with("/search/") {
            let parts: Vec<_> = path.trim_matches('/').split('/').collect();
            if parts.len() >= 2 {
                format!("/search/{}/", parts[1])
            } else {
                "/search/".to_string()
            }
        } else if path.starts_with("/categories/") {
            let parts: Vec<_> = path.trim_matches('/').split('/').collect();
            if parts.len() >= 2 {
                format!("/categories/{}/", parts[1])
            } else {
                "/categories/".to_string()
            }
        } else if path.starts_with("/onlyfans-models/") {
            let parts: Vec<_> = path.trim_matches('/').split('/').collect();
            if parts.len() >= 2 {
                format!("/onlyfans-models/{}/", parts[1])
            } else {
                "/onlyfans-models/".to_string()
            }
        } else {
            "/".to_string()
        };

        format!("{}{}", self.url.trim_end_matches('/'), referer_path)
    }

    fn build_browse_url(&self, page: u8, sort: &str) -> String {
        let base = if page <= 1 {
            format!("{}/videos/", self.url)
        } else {
            format!("{}/videos/{page}/", self.url)
        };
        Self::append_archive_query(base, sort)
    }

    fn build_search_url(&self, query: &str, page: u8, sort: &str) -> String {
        let path_query = Self::build_search_path_query(query, "-");
        let base = if page <= 1 {
            format!("{}/search/{path_query}/", self.url)
        } else {
            format!("{}/search/{path_query}/{page}/", self.url)
        };
        Self::append_archive_query(base, sort)
    }

    fn build_common_archive_url(&self, archive_path: &str, page: u8, sort: &str) -> String {
        let canonical = format!(
            "{}/{}",
            self.url.trim_end_matches('/'),
            archive_path.trim_start_matches('/')
        );
        let base = if page <= 1 {
            canonical
        } else {
            format!("{}/{}", canonical.trim_end_matches('/'), page)
        };
        let base = if base.ends_with('/') {
            base
        } else {
            format!("{base}/")
        };
        Self::append_archive_query(base, sort)
    }

    fn navigation_headers(
        referer: Option<&str>,
        sec_fetch_site: &'static str,
    ) -> Vec<(String, String)> {
        let mut headers = vec![
            (
                "User-Agent".to_string(),
                Self::FIREFOX_USER_AGENT.to_string(),
            ),
            ("Accept".to_string(), Self::HTML_ACCEPT.to_string()),
            ("Accept-Language".to_string(), "en-US,en;q=0.9".to_string()),
            ("Cache-Control".to_string(), "no-cache".to_string()),
            ("Pragma".to_string(), "no-cache".to_string()),
            ("Priority".to_string(), "u=0, i".to_string()),
            ("Connection".to_string(), "keep-alive".to_string()),
            ("TE".to_string(), "trailers".to_string()),
            ("Sec-Fetch-Dest".to_string(), "document".to_string()),
            ("Sec-Fetch-Mode".to_string(), "navigate".to_string()),
            ("Sec-Fetch-Site".to_string(), sec_fetch_site.to_string()),
            ("Sec-Fetch-User".to_string(), "?1".to_string()),
            ("Upgrade-Insecure-Requests".to_string(), "1".to_string()),
        ];
        if let Some(referer) = referer {
            headers.push(("Referer".to_string(), referer.to_string()));
        }
        headers
    }

    fn headers_with_cookies(
        &self,
        requester: &Requester,
        request_url: &str,
        referer: Option<&str>,
        sec_fetch_site: &'static str,
    ) -> Vec<(String, String)> {
        let mut headers = Self::navigation_headers(referer, sec_fetch_site);
        if let Some(cookie) = requester.cookie_header_for_url(request_url) {
            headers.push(("Cookie".to_string(), cookie));
        }
        headers
    }

    fn is_cloudflare_challenge(html: &str) -> bool {
        html.contains("cf-turnstile-response")
            || html.contains("Performing security verification")
            || html.contains("__cf_chl_rt_tk")
            || html.contains("cUPMDTk:\"")
            || html.contains("Just a moment...")
    }

    fn extract_challenge_path(html: &str) -> Option<String> {
        html.split("cUPMDTk:\"")
            .nth(1)
            .and_then(|s| s.split('"').next())
            .map(str::to_string)
            .or_else(|| {
                html.split("__cf_chl_rt_tk=")
                    .nth(1)
                    .and_then(|s| s.split('"').next())
                    .map(|token| format!("/?__cf_chl_rt_tk={token}"))
            })
    }

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

    async fn fetch_html(
        &self,
        requester: &mut Requester,
        request_url: &str,
        referer: Option<&str>,
        sec_fetch_site: &'static str,
    ) -> Result<String> {
        let headers = self.headers_with_cookies(requester, request_url, referer, sec_fetch_site);
        let response = requester
            .get_raw_with_headers(request_url, headers.clone())
            .await
            .map_err(Error::from)?;
        let status = response.status();
        let body = response.text().await.map_err(Error::from)?;

        if status.is_success() || status.as_u16() == 404 {
            return Ok(body);
        }

        if status.as_u16() == 403 && Self::is_cloudflare_challenge(&body) {
            if let Some(challenge_path) = Self::extract_challenge_path(&body) {
                let challenge_url = self.absolute_site_url(&challenge_path);
                let challenge_headers = self.headers_with_cookies(
                    requester,
                    &challenge_url,
                    Some(request_url),
                    "same-origin",
                );
                let _ = requester
                    .get_raw_with_headers(&challenge_url, challenge_headers)
                    .await;
            }
        }

        let retry_headers =
            self.headers_with_cookies(requester, request_url, referer, sec_fetch_site);
        requester
            .get_with_headers(request_url, retry_headers, Some(Version::HTTP_11))
            .await
            .map_err(|e| Error::from(format!("{e}")))
    }

    async fn warm_root_session(&self, requester: &mut Requester) {
        let root_url = self.root_referer();
        let _ = self
            .fetch_html(requester, &root_url, None, "none")
            .await;
    }

    async fn warm_root_session_for_base(base: &str, requester: &mut Requester) {
        let root_url = format!("{}/", base.trim_end_matches('/'));
        let _ = requester
            .get_with_headers(
                &root_url,
                Self::navigation_headers(None, "none"),
                Some(Version::HTTP_11),
            )
            .await;
    }

    async fn load_stars(base: &str, stars: Arc<RwLock<Vec<FilterOption>>>) -> Result<()> {
        let mut requester = Requester::new();
        Self::warm_root_session_for_base(base, &mut requester).await;
        let request_url = format!("{base}/onlyfans-models/?models_per_page=20");
        let headers = {
            let root_url = format!("{}/", base.trim_end_matches('/'));
            let mut headers = Self::navigation_headers(Some(&root_url), "same-origin");
            if let Some(cookie) = requester.cookie_header_for_url(&request_url) {
                headers.push(("Cookie".to_string(), cookie));
            }
            headers
        };
        let text = requester
            .get_with_headers(
                &request_url,
                headers,
                Some(Version::HTTP_2),
            )
            .await
            .map_err(|e| Error::from(format!("{}", e)))?;

        let block = text
            .split("vt_list_models_with_advertising_custom_models_list_items")
            .last()
            .ok_or_else(|| ErrorKind::Parse("missing stars block".into()))?
            .split("pb-page-description")
            .next()
            .unwrap_or("");

        for el in block.split("<div class=\"col\">").skip(1) {
            if el.contains("pb-promoted-link")
                || !el.contains("href=\"https://pimpbunny.com/onlyfans-models/")
            {
                continue;
            }

            let id = el
                .split("href=\"https://pimpbunny.com/onlyfans-models/")
                .nth(1)
                .and_then(|s| s.split("/\"").next())
                .ok_or_else(|| ErrorKind::Parse(format!("star id: {el}").into()))?
                .to_string();

            let title = el
                .split("ui-card-title")
                .nth(1)
                .and_then(|s| s.split('<').next())
                .ok_or_else(|| ErrorKind::Parse(format!("star title: {el}").into()))?
                .to_string();

            Self::push_unique(&stars, FilterOption { id, title });
        }
        Ok(())
    }

    async fn load_categories(base: &str, cats: Arc<RwLock<Vec<FilterOption>>>) -> Result<()> {
        let mut requester = Requester::new();
        Self::warm_root_session_for_base(base, &mut requester).await;
        let request_url = format!("{base}/categories/?items_per_page=120");
        let headers = {
            let root_url = format!("{}/", base.trim_end_matches('/'));
            let mut headers = Self::navigation_headers(Some(&root_url), "same-origin");
            if let Some(cookie) = requester.cookie_header_for_url(&request_url) {
                headers.push(("Cookie".to_string(), cookie));
            }
            headers
        };
        let text = requester
            .get_with_headers(
                &request_url,
                headers,
                Some(Version::HTTP_2),
            )
            .await
            .map_err(|e| Error::from(format!("{}", e)))?;

        let block = text
            .split("list_categories_categories_list_items")
            .last()
            .ok_or_else(|| ErrorKind::Parse("categories block".into()))?
            .split("pb-pagination-wrapper")
            .next()
            .unwrap_or("");

        for el in block.split("<div class=\"col\">").skip(1) {
            let id = el
                .split("href=\"https://pimpbunny.com/categories/")
                .nth(1)
                .and_then(|s| s.split("/\"").next())
                .ok_or_else(|| ErrorKind::Parse(format!("category id: {el}").into()))?
                .to_string();

            let title = el
                .split("ui-heading-h3")
                .nth(1)
                .and_then(|s| s.split('<').next())
                .ok_or_else(|| ErrorKind::Parse(format!("category title: {el}").into()))?
                .titlecase();

            Self::push_unique(&cats, FilterOption { id, title });
        }
        Ok(())
    }

    async fn get(
        &self,
        cache: VideoCache,
        page: u8,
        sort: &str,
        options: ServerOptions,
    ) -> Result<Vec<VideoItem>> {
        let video_url = self.build_browse_url(page, sort);
        let old_items = match cache.get(&video_url) {
            Some((time, items)) => {
                if time.elapsed().unwrap_or_default().as_secs() < 60 * 5 {
                    return Ok(items.clone());
                } else {
                    items.clone()
                }
            }
            None => {
                vec![]
            }
        };
        let mut requester =
            crate::providers::requester_or_default(&options, module_path!(), "missing_requester");
        self.warm_root_session(&mut requester).await;
        let referer = self.page_family_referer(&video_url);
        let text = match self
            .fetch_html(
                &mut requester,
                &video_url,
                Some(&referer),
                "same-origin",
            )
            .await
        {
            Ok(text) => text,
            Err(e) => {
                crate::providers::report_provider_error(
                    "pimpbunny",
                    "get.request",
                    &format!("url={video_url}; error={e}"),
                )
                .await;
                return Ok(old_items);
            }
        };
        let video_items = self.get_video_items_from_html(text.clone(), &options);
        if !video_items.is_empty() {
            cache.remove(&video_url);
            cache.insert(video_url.clone(), video_items.clone());
        } else {
            return Ok(old_items);
        }
        Ok(video_items)
    }

    async fn query(
        &self,
        cache: VideoCache,
        page: u8,
        query: &str,
        options: ServerOptions,
    ) -> Result<Vec<VideoItem>> {
        let search_string = query.trim().to_string();
        let sort = options.sort.as_deref().unwrap_or("");
        let mut video_url = self.build_search_url(&search_string, page, sort);
        if let Ok(stars) = self.stars.read() {
            if let Some(star) = stars
                .iter()
                .find(|s| s.title.to_ascii_lowercase() == search_string.to_ascii_lowercase())
            {
                video_url = self.build_common_archive_url(
                    &format!("/onlyfans-models/{}/", star.id),
                    page,
                    sort,
                );
            }
        } else {
            crate::providers::report_provider_error_background(
                "pimpbunny",
                "query.stars_read",
                "failed to lock stars",
            );
        }
        if let Ok(categories) = self.categories.read() {
            if let Some(cat) = categories
                .iter()
                .find(|c| c.title.to_ascii_lowercase() == search_string.to_ascii_lowercase())
            {
                video_url =
                    self.build_common_archive_url(&format!("/categories/{}/", cat.id), page, sort);
            }
        } else {
            crate::providers::report_provider_error_background(
                "pimpbunny",
                "query.categories_read",
                "failed to lock categories",
            );
        }
        // Check our Video Cache. If the result is younger than 1 hour, we return it.
        let old_items = match cache.get(&video_url) {
            Some((time, items)) => {
                if time.elapsed().unwrap_or_default().as_secs() < 60 * 5 {
                    return Ok(items.clone());
                } else {
                    let _ = cache.check().await;
                    return Ok(items.clone());
                }
            }
            None => {
                vec![]
            }
        };

        let mut requester =
            crate::providers::requester_or_default(&options, module_path!(), "missing_requester");
        self.warm_root_session(&mut requester).await;
        let referer = self.page_family_referer(&video_url);
        let text = match self
            .fetch_html(
                &mut requester,
                &video_url,
                Some(&referer),
                "same-origin",
            )
            .await
        {
            Ok(text) => text,
            Err(e) => {
                crate::providers::report_provider_error(
                    "pimpbunny",
                    "query.request",
                    &format!("url={video_url}; error={e}"),
                )
                .await;
                return Ok(old_items);
            }
        };
        let video_items = self.get_video_items_from_html(text.clone(), &options);
        if !video_items.is_empty() {
            cache.remove(&video_url);
            cache.insert(video_url.clone(), video_items.clone());
        } else {
            return Ok(old_items);
        }
        Ok(video_items)
    }

    fn get_video_items_from_html(&self, html: String, options: &ServerOptions) -> Vec<VideoItem> {
        if html.is_empty() || html.contains("404 Not Found") {
            return vec![];
        }

        let block = match html
            .split("-pagination-wrapper")
            .next()
            .and_then(|s| s.split("videos_videos_list").nth(2))
        {
            Some(b) => b,
            None => return vec![],
        };

        block
            .split("<div class=\"col\">")
            .skip(1)
            .filter_map(|el| self.get_video_item(el.to_string(), options).ok())
            .into_iter()
            .collect()
    }

    fn extract_duration_from_segment(&self, seg: &str) -> u32 {
        for token in seg.split(|ch: char| ch == '<' || ch == '>' || ch.is_whitespace()) {
            let candidate = token.trim();
            if candidate.is_empty() || !candidate.contains(':') {
                continue;
            }
            if let Some(parsed) = parse_time_to_seconds(candidate) {
                return parsed as u32;
            }
        }
        0
    }

    fn extract_views_from_segment(&self, seg: &str) -> u32 {
        let Some(before_views) = seg.split("Views").next() else {
            return 0;
        };
        let candidate = before_views
            .split(|ch: char| ch == '<' || ch == '>' || ch.is_whitespace())
            .filter(|value| !value.trim().is_empty())
            .next_back()
            .unwrap_or("")
            .trim_matches(|ch: char| ch == '(' || ch == ')' || ch == ',');

        parse_abbreviated_number(candidate).unwrap_or(0)
    }

    fn get_video_item(&self, seg: String, options: &ServerOptions) -> Result<VideoItem> {
        let video_url = seg
            .split(" href=\"")
            .nth(1)
            .and_then(|s| s.split('"').next())
            .ok_or_else(|| ErrorKind::Parse("video url".into()))?
            .to_string();

        let mut title = seg
            .split("card-title")
            .nth(1)
            .and_then(|s| s.split('>').nth(1))
            .and_then(|s| s.split('<').next())
            .ok_or_else(|| ErrorKind::Parse("video title".into()))?
            .trim()
            .to_string();
        title = decode(title.as_bytes())
            .to_string()
            .unwrap_or(title)
            .titlecase();

        let id = video_url
            .split('/')
            .nth(4)
            .and_then(|s| s.split('.').next())
            .ok_or_else(|| ErrorKind::Parse("video id".into()))?
            .to_string();

        let thumb_block = seg
            .split("card-thumbnail")
            .nth(1)
            .ok_or_else(|| ErrorKind::Parse("thumb block".into()))?;

        let mut thumb = thumb_block
            .split("src=\"")
            .nth(1)
            .and_then(|s| s.split('"').next())
            .unwrap_or("")
            .to_string();

        if thumb.contains("data:image/jpg;base64") {
            thumb = thumb_block
                .split("data-original=\"")
                .nth(1)
                .and_then(|s| s.split('"').next())
                .unwrap_or("")
                .to_string();
        }

        let preview = thumb_block
            .split("data-preview=\"")
            .nth(1)
            .and_then(|s| s.split('"').next())
            .unwrap_or("")
            .to_string();
        let proxy_url = self.proxied_video(options, &video_url);
        let views = self.extract_views_from_segment(&seg);
        let duration = self.extract_duration_from_segment(&seg);
        let formats = vec![
            VideoFormat::new(proxy_url.clone(), "auto".into(), "video/mp4".into())
                .format_id("auto".into())
                .format_note("proxied".into())
                .http_header("Referer".to_string(), video_url.clone())
                .http_header("Accept-Language".to_string(), "en-US,en;q=0.9".to_string())
                ,
        ];

        Ok(
            VideoItem::new(id, title, video_url, "pimpbunny".into(), thumb, duration)
                .formats(formats)
                .preview(preview)
                .views(views),
        )
    }
}

#[async_trait]
impl Provider for PimpbunnyProvider {
    async fn get_videos(
        &self,
        cache: VideoCache,
        _pool: DbPool,
        sort: String,
        query: Option<String>,
        page: String,
        _per_page: String,
        options: ServerOptions,
    ) -> Vec<VideoItem> {
        let page = page.parse::<u8>().unwrap_or(1);
        let thumb_options = options.clone();

        let res = match query {
            Some(q) => self.to_owned().query(cache, page, &q, options).await,
            None => self.get(cache, page, &sort, options).await,
        };

        res.unwrap_or_else(|e| {
            eprintln!("pimpbunny error: {e}");
            vec![]
        })
        .into_iter()
        .map(|mut item| {
            if !item.thumb.is_empty() {
                item.thumb = self.proxied_thumb(&thumb_options, &item.thumb);
            }
            item
        })
        .collect()
    }

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

#[cfg(test)]
mod tests {
    use super::PimpbunnyProvider;
    use crate::videos::ServerOptions;
    use std::sync::{Arc, RwLock};

    fn test_provider() -> PimpbunnyProvider {
        PimpbunnyProvider {
            url: "https://pimpbunny.com".to_string(),
            stars: Arc::new(RwLock::new(vec![])),
            categories: Arc::new(RwLock::new(vec![])),
        }
    }

    #[test]
    fn rewrites_allowed_thumbs_to_proxy_urls() {
        let provider = test_provider();
        let options = ServerOptions {
            client_version: None,
            featured: None,
            category: None,
            sites: None,
            filter: None,
            language: None,
            public_url_base: Some("https://example.com".to_string()),
            requester: None,
            network: None,
            stars: None,
            categories: None,
            duration: None,
            sort: None,
            sexuality: None,
        };

        let proxied = provider.proxied_thumb(
            &options,
            "https://pimpbunny.com/contents/videos_screenshots/517000/517329/800x450/1.jpg",
        );

        assert_eq!(
            proxied,
            "https://pimpbunny.com/contents/videos_screenshots/517000/517329/800x450/1.jpg"
        );
    }

    #[test]
    fn rewrites_video_pages_to_redirect_proxy() {
        let provider = test_provider();
        let options = ServerOptions {
            client_version: None,
            featured: None,
            category: None,
            sites: None,
            filter: None,
            language: None,
            public_url_base: Some("https://example.com".to_string()),
            requester: None,
            network: None,
            stars: None,
            categories: None,
            duration: None,
            sort: None,
            sexuality: None,
        };

        let proxied = provider.proxied_video(
            &options,
            "https://pimpbunny.com/videos/example-video/",
        );

        assert_eq!(
            proxied,
            "https://example.com/proxy/pimpbunny/pimpbunny.com/videos/example-video/"
        );
    }

    #[test]
    fn parses_listing_without_detail_requests() {
        let provider = test_provider();
        let options = ServerOptions {
            client_version: None,
            featured: None,
            category: None,
            sites: None,
            filter: None,
            language: None,
            public_url_base: Some("https://example.com".to_string()),
            requester: None,
            network: None,
            stars: None,
            categories: None,
            duration: None,
            sort: None,
            sexuality: None,
        };

        let html = r#"
            <div class="videos_videos_list"></div>
            <div class="videos_videos_list">
                <div class="col">
                    <a href="https://pimpbunny.com/videos/example-video/">
                        <div class="card-thumbnail">
                            <img src="https://pimpbunny.com/contents/videos_screenshots/517000/517329/800x450/1.jpg" data-preview="https://pimpbunny.com/preview.mp4" />
                        </div>
                        <div class="card-title">sample &amp; title</div>
                        <div>12:34</div>
                        <div>1.2K Views</div>
                    </a>
                </div>
            -pagination-wrapper
        "#;

        let items = provider.get_video_items_from_html(html.to_string(), &options);

        assert_eq!(items.len(), 1);
        assert_eq!(
            items[0].url,
            "https://example.com/proxy/pimpbunny/pimpbunny.com/videos/example-video/"
        );
        assert_eq!(items[0].duration, 754);
        assert_eq!(items[0].views, Some(1200));
        assert_eq!(items[0].formats.as_ref().map(|f| f.len()), Some(1));
        assert_eq!(
            items[0].formats.as_ref().and_then(|formats| formats.first()).map(
                |format| format.http_headers_pairs().contains(&(
                    "Referer".to_string(),
                    "https://pimpbunny.com/videos/example-video/".to_string(),
                ))
            ),
            Some(true)
        );
    }

    #[test]
    fn extracts_cloudflare_challenge_path() {
        let html = r#"
            <script type="text/javascript">
                (function(){
                    window._cf_chl_opt = {
                        cUPMDTk:"/?mode=async&function=get_block&block_id=videos_videos_list&videos_per_page=8&sort_by=post_date&from=1&__cf_chl_tk=test-token"
                    };
                }());
            </script>
        "#;

        assert!(PimpbunnyProvider::is_cloudflare_challenge(html));
        assert_eq!(
            PimpbunnyProvider::extract_challenge_path(html).as_deref(),
            Some(
                "/?mode=async&function=get_block&block_id=videos_videos_list&videos_per_page=8&sort_by=post_date&from=1&__cf_chl_tk=test-token"
            )
        );
    }

    #[test]
    fn builds_async_browse_url_instead_of_numbered_videos_path() {
        let provider = test_provider();
        assert_eq!(
            provider.build_browse_url(1, "most recent"),
            "https://pimpbunny.com/videos/?sort_by=post_date"
        );
        assert_eq!(
            provider.build_browse_url(2, "most recent"),
            "https://pimpbunny.com/videos/2/?sort_by=post_date"
        );
    }

    #[test]
    fn builds_search_url_with_query_and_pagination() {
        let provider = test_provider();
        assert_eq!(
            provider.build_search_url("adriana chechik", 1, "most viewed"),
            "https://pimpbunny.com/search/adriana-chechik/?sort_by=video_viewed"
        );
        assert_eq!(
            provider.build_search_url("adriana chechik", 3, "most viewed"),
            "https://pimpbunny.com/search/adriana-chechik/3/?sort_by=video_viewed"
        );
    }

    #[test]
    fn builds_common_archive_url_with_async_block() {
        let provider = test_provider();
        assert_eq!(
            provider.build_common_archive_url("/categories/amateur/", 1, "best rated"),
            "https://pimpbunny.com/categories/amateur/?sort_by=rating"
        );
        assert_eq!(
            provider.build_common_archive_url("/categories/amateur/", 4, "best rated"),
            "https://pimpbunny.com/categories/amateur/4/?sort_by=rating"
        );
    }

    #[test]
    fn derives_page_family_referer() {
        let provider = test_provider();
        assert_eq!(
            provider.page_family_referer("https://pimpbunny.com/videos/2/?sort_by=post_date"),
            "https://pimpbunny.com/videos/"
        );
        assert_eq!(
            provider.page_family_referer(
                "https://pimpbunny.com/categories/blowjob/2/?sort_by=post_date"
            ),
            "https://pimpbunny.com/categories/blowjob/"
        );
        assert_eq!(
            provider.page_family_referer(
                "https://pimpbunny.com/search/adriana-chechik/3/?sort_by=video_viewed"
            ),
            "https://pimpbunny.com/search/adriana-chechik/"
        );
        assert_eq!(
            provider.page_family_referer(
                "https://pimpbunny.com/onlyfans-models/momoitenshi/3/?sort_by=post_date"
            ),
            "https://pimpbunny.com/onlyfans-models/momoitenshi/"
        );
    }
}
