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::util::parse_abbreviated_number;
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 scraper::{Html, Selector};
use std::collections::HashSet;
use std::vec;
use url::form_urlencoded::Serializer;

pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
    crate::providers::ProviderChannelMetadata {
        group_id: "fetish-kink",
        tags: &["freeuse", "hypno", "mind-control"],
    };

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

#[derive(Debug, Clone)]
pub struct FreeusepornProvider {
    url: String,
}

impl FreeusepornProvider {
    pub fn new() -> Self {
        Self {
            url: "https://www.freeuseporn.com".to_string(),
        }
    }

    fn build_channel(&self, _clientversion: ClientVersion) -> Channel {
        Channel {
            id: "freeuseporn".to_string(),
            name: "FreeusePorn".to_string(),
            description: "FreeusePorn streams freeuse, hypno, mind control, ignored sex, and related fetish videos.".to_string(),
            premium: false,
            favicon: "https://www.google.com/s2/favicons?sz=64&domain=freeuseporn.com".to_string(),
            status: "active".to_string(),
            categories: 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: "recent".to_string(),
                            title: "Most Recent".to_string(),
                        },
                        FilterOption {
                            id: "viewed".to_string(),
                            title: "Most Viewed".to_string(),
                        },
                        FilterOption {
                            id: "rated".to_string(),
                            title: "Top Rated".to_string(),
                        },
                        FilterOption {
                            id: "favorites".to_string(),
                            title: "Top Favorites".to_string(),
                        },
                        FilterOption {
                            id: "watched".to_string(),
                            title: "Being Watched".to_string(),
                        },
                    ],
                    multiSelect: false,
                },
                ChannelOption {
                    id: "category".to_string(),
                    title: "Category".to_string(),
                    description: "Filter by category".to_string(),
                    systemImage: "square.grid.2x2".to_string(),
                    colorName: "orange".to_string(),
                    options: vec![
                        FilterOption {
                            id: "all".to_string(),
                            title: "All".to_string(),
                        },
                        FilterOption {
                            id: "mind-control".to_string(),
                            title: "Mind Control".to_string(),
                        },
                        FilterOption {
                            id: "general-freeuse".to_string(),
                            title: "General Freeuse".to_string(),
                        },
                        FilterOption {
                            id: "free-service".to_string(),
                            title: "Free Service".to_string(),
                        },
                        FilterOption {
                            id: "forced".to_string(),
                            title: "Forced".to_string(),
                        },
                        FilterOption {
                            id: "japanese".to_string(),
                            title: "Japanese".to_string(),
                        },
                        FilterOption {
                            id: "time-stop".to_string(),
                            title: "Time Stop".to_string(),
                        },
                        FilterOption {
                            id: "ignored-sex".to_string(),
                            title: "Ignored Sex".to_string(),
                        },
                        FilterOption {
                            id: "glory-hole".to_string(),
                            title: "Glory Hole".to_string(),
                        },
                    ],
                    multiSelect: false,
                },
            ],
            nsfw: true,
            cacheDuration: Some(1800),
            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
        }
    }

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

    fn sort_param(sort: &str) -> &'static str {
        match sort {
            "viewed" => "mv",
            "rated" => "tr",
            "favorites" => "tf",
            "watched" => "bw",
            _ => "mr",
        }
    }

    fn append_sort_and_page(&self, base_url: &str, sort: &str, page: u8) -> String {
        let mut params = vec![format!("o={}", Self::sort_param(sort))];
        if page > 1 {
            params.push(format!("page={page}"));
        }

        if params.is_empty() {
            return base_url.to_string();
        }

        let separator = if base_url.contains('?') { "&" } else { "?" };
        format!("{base_url}{separator}{}", params.join("&"))
    }

    fn build_list_url(&self, sort: &str, page: u8, category: Option<&str>) -> String {
        let path = if let Some(category) = category
            .map(str::trim)
            .filter(|value| !value.is_empty() && *value != "all")
        {
            format!("/videos/{}", category)
        } else {
            "/videos".to_string()
        };

        let base_url = format!("{}{}", self.url, path);
        self.append_sort_and_page(&base_url, sort, page)
    }

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

    async fn resolve_search_url(&self, query: &str, options: &ServerOptions) -> Result<String> {
        let search_url = format!("{}/search/videos", self.url);
        let search_body = Self::build_search_request_body(query);
        let referer = format!("{}/videos", self.url);
        let mut requester = requester_or_default(options, module_path!(), "missing_requester");
        let response = requester
            .post(
                &search_url,
                &search_body,
                vec![
                    ("Content-Type", "application/x-www-form-urlencoded"),
                    ("Referer", referer.as_str()),
                ],
            )
            .await
            .map_err(|error| format!("search submit failed url={search_url}; error={error}"))?;

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

    fn build_formats(&self, id: &str) -> Vec<VideoFormat> {
        let hd = VideoFormat::new(
            format!("{}/media/videos/h264/{}_720p.mp4", self.url, id),
            "720p".to_string(),
            "video/mp4".to_string(),
        )
        .format_id("720p".to_string())
        .format_note("720p".to_string());
        let sd = VideoFormat::new(
            format!("{}/media/videos/h264/{}_480p.mp4", self.url, id),
            "480p".to_string(),
            "video/mp4".to_string(),
        )
        .format_id("480p".to_string())
        .format_note("480p".to_string());
        vec![hd, sd]
    }

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

    fn decode_text(value: &str) -> String {
        decode(value.as_bytes())
            .to_string()
            .unwrap_or_else(|_| value.to_string())
    }

    fn parse_views(value: &str) -> Option<u32> {
        let digits = value
            .chars()
            .filter(|character| character.is_ascii_digit() || *character == '.' || *character == 'K' || *character == 'M' || *character == 'B' || *character == 'k' || *character == 'm' || *character == 'b')
            .collect::<String>();
        if digits.is_empty() {
            return None;
        }
        parse_abbreviated_number(&digits).map(|views| views as u32)
    }

    fn parse_rating(value: &str) -> Option<f32> {
        value
            .trim()
            .trim_end_matches('%')
            .parse::<f32>()
            .ok()
    }

    fn parse_video_item_from_anchor(
        &self,
        anchor: scraper::ElementRef<'_>,
        selectors: &FreeusepornSelectors,
    ) -> Option<VideoItem> {
        let href = anchor.value().attr("href")?;
        if !href.contains("/video/") {
            return None;
        }

        let absolute_url = self.absolute_url(href);
        let id = absolute_url.split('/').nth(4)?.to_string();
        if id.is_empty() {
            return None;
        }

        let title_raw = anchor
            .select(&selectors.title)
            .next()
            .map(|element| Self::normalized_text(&element.text().collect::<Vec<_>>().join(" ")))
            .filter(|value| !value.is_empty())
            .or_else(|| anchor.value().attr("title").map(Self::normalized_text))
            .or_else(|| {
                anchor
                    .select(&selectors.image)
                    .next()
                    .and_then(|element| element.value().attr("alt"))
                    .map(Self::normalized_text)
            })?;
        let title = Self::decode_text(&title_raw);

        let thumb = anchor
            .select(&selectors.image)
            .next()
            .and_then(|element| element.value().attr("src"))
            .map(|src| self.absolute_url(src))
            .unwrap_or_default();

        let duration = anchor
            .select(&selectors.duration)
            .next()
            .map(|element| Self::normalized_text(&element.text().collect::<Vec<_>>().join(" ")))
            .and_then(|value| parse_time_to_seconds(&value))
            .unwrap_or(0) as u32;

        let mut stats = anchor
            .select(&selectors.video_stat)
            .map(|element| Self::normalized_text(&element.text().collect::<Vec<_>>().join(" ")))
            .collect::<Vec<_>>();
        stats.retain(|value| !value.is_empty());
        let views = stats.first().and_then(|value| Self::parse_views(value));
        let rating = stats.get(1).and_then(|value| Self::parse_rating(value));

        let mut item = VideoItem::new(
            id.clone(),
            title,
            absolute_url,
            "freeuseporn".to_string(),
            thumb,
            duration,
        )
        .views(views.unwrap_or(0));

        if views.is_none() {
            item.views = None;
        }
        item.rating = rating;
        item.formats = Some(self.build_formats(&id));

        Some(item)
    }

    fn get_video_items_from_html(&self, html: &str) -> Vec<VideoItem> {
        if html.trim().is_empty() {
            return vec![];
        }

        let document = Html::parse_document(html);
        let selectors = FreeusepornSelectors::new();
        let primary_anchors = document
            .select(&selectors.list_anchor)
            .collect::<Vec<_>>();
        let anchors = if primary_anchors.is_empty() {
            document
                .select(&selectors.fallback_anchor)
                .collect::<Vec<_>>()
        } else {
            primary_anchors
        };

        let mut seen = HashSet::new();
        let mut items = Vec::new();

        for anchor in anchors {
            let Some(item) = self.parse_video_item_from_anchor(anchor, &selectors) else {
                continue;
            };
            if seen.insert(item.id.clone()) {
                items.push(item);
            }
        }

        items
    }

    async fn fetch_listing(
        &self,
        cache: VideoCache,
        url: String,
        options: ServerOptions,
        error_context: &str,
    ) -> Result<Vec<VideoItem>> {
        let old_items = match cache.get(&url) {
            Some((time, items)) => {
                if time.elapsed().unwrap_or_default().as_secs() < 60 * 5 {
                    return Ok(items.clone());
                }
                items.clone()
            }
            None => vec![],
        };

        let mut requester = requester_or_default(&options, module_path!(), "missing_requester");
        let text = match requester.get(&url, None).await {
            Ok(text) => text,
            Err(error) => {
                report_provider_error(
                    "freeuseporn",
                    error_context,
                    &format!("url={url}; error={error}"),
                )
                .await;
                return Ok(old_items);
            }
        };

        let items = self.get_video_items_from_html(&text);
        if items.is_empty() {
            return Ok(old_items);
        }

        cache.remove(&url);
        cache.insert(url, items.clone());
        Ok(items)
    }

    async fn get(
        &self,
        cache: VideoCache,
        page: u8,
        sort: &str,
        options: ServerOptions,
    ) -> Result<Vec<VideoItem>> {
        let url = self.build_list_url(sort, page, options.category.as_deref());
        self.fetch_listing(cache, url, options, "get.request").await
    }

    async fn query(
        &self,
        cache: VideoCache,
        page: u8,
        query: &str,
        sort: &str,
        options: ServerOptions,
    ) -> Result<Vec<VideoItem>> {
        let search_base = match self.resolve_search_url(query, &options).await {
            Ok(url) => url,
            Err(error) => {
                report_provider_error(
                    "freeuseporn",
                    "query.search_submit",
                    &error.to_string(),
                )
                .await;
                return Ok(vec![]);
            }
        };
        let url = self.append_sort_and_page(&search_base, sort, page);
        self.fetch_listing(cache, url, options, "query.request").await
    }
}

struct FreeusepornSelectors {
    list_anchor: Selector,
    fallback_anchor: Selector,
    title: Selector,
    image: Selector,
    duration: Selector,
    video_stat: Selector,
}

impl FreeusepornSelectors {
    fn new() -> Self {
        Self {
            list_anchor: Selector::parse("#videos-list a[href]").expect("valid freeuseporn list selector"),
            fallback_anchor: Selector::parse("a[href]").expect("valid freeuseporn fallback selector"),
            title: Selector::parse(".v-name").expect("valid freeuseporn title selector"),
            image: Selector::parse("img").expect("valid freeuseporn image selector"),
            duration: Selector::parse(".duration").expect("valid freeuseporn duration selector"),
            video_stat: Selector::parse(".video-stats li").expect("valid freeuseporn stats selector"),
        }
    }
}

#[async_trait]
impl Provider for FreeusepornProvider {
    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 videos = match query {
            Some(query) => self.query(cache, page, &query, &sort, options).await,
            None => self.get(cache, page, &sort, options).await,
        };

        match videos {
            Ok(items) => items,
            Err(error) => {
                eprintln!("freeuseporn provider error: {error}");
                vec![]
            }
        }
    }

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

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

    fn provider() -> FreeusepornProvider {
        FreeusepornProvider::new()
    }

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

        assert_eq!(
            provider.build_list_url("recent", 1, None),
            "https://www.freeuseporn.com/videos?o=mr"
        );
        assert_eq!(
            provider.build_list_url("viewed", 2, Some("mind-control")),
            "https://www.freeuseporn.com/videos/mind-control?o=mv&page=2"
        );
        assert_eq!(
            provider.append_sort_and_page(
                "https://www.freeuseporn.com/search/videos/Nicole-Kitt",
                "favorites",
                3
            ),
            "https://www.freeuseporn.com/search/videos/Nicole-Kitt?o=tf&page=3"
        );
    }

    #[test]
    fn builds_search_request_body_with_form_encoding() {
        assert_eq!(
            FreeusepornProvider::build_search_request_body("Nicole Kitt & Cory Chase"),
            "search_query=Nicole+Kitt+%26+Cory+Chase"
        );
    }

    #[test]
    fn parses_listing_items_and_builds_formats() {
        let provider = provider();
        let html = r#"
            <ul class="grid" id="videos-list">
                <li>
                    <div class="item">
                        <div class="thumbnail">
                            <div class="embed">
                                <iframe src="https://ads.example"></iframe>
                            </div>
                        </div>
                    </div>
                </li>
                <li>
                    <a href="/video/9579/nicole-kitt-shady-slut-keeps-confessing" class="thumb-wrap-link">
                        <div class="item">
                            <div class="thumbnail overlay" id="playvthumb_9579">
                                <div class="sub-data">
                                    <span class="duration">59:09</span>
                                </div>
                                <img src="https://www.freeuseporn.com/media/videos/tmb/9579/1.jpg" alt="Nicole Kitt &amp; The Truth"/>
                            </div>
                            <div class="info">
                                <span class="v-name">Nicole Kitt &amp; The Truth</span>
                                <ul class="video-stats">
                                    <li><i class="far fa-eye"></i>52180</li>
                                    <li><i class="far fa-heart"></i>100%</li>
                                </ul>
                            </div>
                        </div>
                    </a>
                </li>
                <li>
                    <a href="https://www.freeuseporn.com/video/9578/lollipop-time-stop-2">
                        <div class="item">
                            <div class="thumbnail overlay">
                                <div class="sub-data">
                                    <span class="duration">16:27</span>
                                </div>
                                <img src="https://www.freeuseporn.com/media/videos/tmb/9578/1.jpg" alt="Lollipop time stop 2"/>
                            </div>
                            <div class="info">
                                <span class="v-name">Lollipop time stop 2</span>
                                <ul class="video-stats">
                                    <li><i class="far fa-eye"></i>35058</li>
                                    <li><i class="far fa-heart"></i>88%</li>
                                </ul>
                            </div>
                        </div>
                    </a>
                </li>
            </ul>
        "#;

        let items = provider.get_video_items_from_html(html);
        assert_eq!(items.len(), 2);
        assert_eq!(items[0].id, "9579");
        assert_eq!(items[0].title, "Nicole Kitt & The Truth");
        assert_eq!(
            items[0].url,
            "https://www.freeuseporn.com/video/9579/nicole-kitt-shady-slut-keeps-confessing"
        );
        assert_eq!(
            items[0].thumb,
            "https://www.freeuseporn.com/media/videos/tmb/9579/1.jpg"
        );
        assert_eq!(items[0].duration, 3549);
        assert_eq!(items[0].views, Some(52180));
        assert_eq!(items[0].rating, Some(100.0));
        assert_eq!(items[0].formats.as_ref().map(|formats| formats.len()), Some(2));
        assert_eq!(
            items[0]
                .formats
                .as_ref()
                .and_then(|formats| formats.first())
                .map(|format| format.url.as_str()),
            Some("https://www.freeuseporn.com/media/videos/h264/9579_720p.mp4")
        );
        assert_eq!(items[1].id, "9578");
        assert_eq!(items[1].rating, Some(88.0));
    }
}
