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::{ElementRef, Html, Selector};
use std::collections::HashMap;
use std::collections::HashSet;
use std::sync::{Arc, RwLock};
use std::thread;
use url::Url;

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

const BASE_URL: &str = "https://www.pornhub.com";
const CHANNEL_ID: &str = "pornhub";

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

#[derive(Debug, Clone)]
pub struct PornhubProvider {
    url: String,
    tag_map: Arc<RwLock<HashMap<String, TagInfo>>>,
}

#[derive(Debug, Clone, Copy)]
enum ListingScope {
    Browse,
    Search,
    Creator,
}

#[derive(Debug, Clone, Copy)]
enum QueryTargetKind {
    Channel,
    Pornstar,
    Model,
    User,
}

#[derive(Debug, Clone)]
struct QueryTarget {
    kind: QueryTargetKind,
    slug: String,
}

#[derive(Debug, Clone)]
struct TagInfo {
    kind: QueryTargetKind,
    slug: String,
    title: String,
}

impl QueryTargetKind {
    fn path_segment(self) -> &'static str {
        match self {
            Self::Channel => "channels",
            Self::Pornstar => "pornstar",
            Self::Model => "model",
            Self::User => "users",
        }
    }
}

impl PornhubProvider {
    pub fn new() -> Self {
        let provider = Self {
            url: BASE_URL.to_string(),
            tag_map: Arc::new(RwLock::new(HashMap::new())),
        };
        provider.spawn_initial_load();
        provider
    }

    fn spawn_initial_load(&self) {
        let url = self.url.clone();
        let tag_map = Arc::clone(&self.tag_map);
        thread::spawn(move || {
            let rt = tokio::runtime::Builder::new_current_thread()
                .enable_all()
                .build()
                .unwrap();
            rt.block_on(async {
                let _ = Self::load_tags(&url, tag_map).await;
            });
        });
    }

    async fn load_tags(
        base_url: &str,
        tag_map: Arc<RwLock<HashMap<String, TagInfo>>>,
    ) -> Result<()> {
        Self::load_kind(base_url, "channel", QueryTargetKind::Channel, &tag_map).await?;
        Self::load_kind(base_url, "pornstar", QueryTargetKind::Pornstar, &tag_map).await?;
        Self::load_kind(base_url, "model", QueryTargetKind::Model, &tag_map).await?;
        Self::load_kind(base_url, "user", QueryTargetKind::User, &tag_map).await?;
        Ok(())
    }

    async fn load_kind(
        base_url: &str,
        path_segment: &str,
        kind: QueryTargetKind,
        tag_map: &Arc<RwLock<HashMap<String, TagInfo>>>,
    ) -> Result<()> {
        let url = format!("{}/{}/top", base_url, path_segment);
        let mut requester = crate::util::requester::Requester::new();
        let body = requester
            .get(&url, None)
            .await
            .map_err(|e| Error::from(ErrorKind::Parse(format!("http request failed: {e}"))))?;
        let document = Html::parse_document(&body);
        let selector = Self::selector(&format!("a[href^='/{}/']", path_segment))?;
        for element in document.select(&selector) {
            if let Some(href) = element.attr("href") {
                if let Some(slug) = Self::slug_from_url(href, path_segment) {
                    let title = element.text().collect::<String>().trim().to_string();
                    if !title.is_empty() && !slug.is_empty() {
                        let info = TagInfo {
                            kind,
                            slug: slug.clone(),
                            title: title.clone(),
                        };
                        let mut map = tag_map.write().unwrap();
                        map.insert(title.to_ascii_lowercase(), info.clone());
                        map.insert(slug.to_ascii_lowercase(), info);
                    }
                }
            }
        }
        Ok(())
    }

    fn slug_from_url(url: &str, path_segment: &str) -> Option<String> {
        let parsed = Url::parse(url).ok()?;
        let mut segments = parsed.path_segments()?;
        if segments.next() == Some(path_segment) {
            segments.next().map(|s| s.to_string())
        } else {
            None
        }
    }

    fn build_channel(&self, _clientversion: ClientVersion) -> Channel {
        Channel {
            id: CHANNEL_ID.to_string(),
            name: "Pornhub".to_string(),
            description: "Pornhub listings with creator queries and direct HLS playback links."
                .to_string(),
            premium: false,
            favicon: "https://www.google.com/s2/favicons?sz=64&domain=pornhub.com".to_string(),
            status: "active".to_string(),
            categories: vec![],
            options: vec![ChannelOption {
                id: "sort".to_string(),
                title: "Sort".to_string(),
                description: "Browse Pornhub charts by sort order.".to_string(),
                systemImage: "list.number".to_string(),
                colorName: "blue".to_string(),
                options: vec![
                    FilterOption {
                        id: "mr".to_string(),
                        title: "Featured Recently".to_string(),
                    },
                    FilterOption {
                        id: "mv".to_string(),
                        title: "Most Viewed".to_string(),
                    },
                    FilterOption {
                        id: "tr".to_string(),
                        title: "Top Rated".to_string(),
                    },
                    FilterOption {
                        id: "ht".to_string(),
                        title: "Hottest".to_string(),
                    },
                    FilterOption {
                        id: "lg".to_string(),
                        title: "Longest".to_string(),
                    },
                    FilterOption {
                        id: "cm".to_string(),
                        title: "Newest".to_string(),
                    },
                ],
                multiSelect: false,
            }],
            nsfw: true,
            cacheDuration: Some(1800),
            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
        }
    }

    fn selector(value: &str) -> Result<Selector> {
        Selector::parse(value).map_err(|error| {
            Error::from(ErrorKind::Parse(format!(
                "selector parse failed for {value}: {error}"
            )))
        })
    }

    fn text_of(element: &ElementRef<'_>) -> String {
        element
            .text()
            .collect::<Vec<_>>()
            .join(" ")
            .split_whitespace()
            .collect::<Vec<_>>()
            .join(" ")
    }

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

    fn normalize_url(&self, value: &str) -> String {
        let trimmed = value.trim();
        if trimmed.is_empty() {
            return String::new();
        }
        if let Ok(url) = Url::parse(trimmed) {
            return url.to_string();
        }
        Url::parse(BASE_URL)
            .and_then(|base| base.join(trimmed))
            .map(|value| value.to_string())
            .unwrap_or_default()
    }

    fn normalize_sort(sort: &str) -> &'static str {
        match sort.trim().to_ascii_lowercase().as_str() {
            "mv" => "mv",
            "tr" => "tr",
            "ht" => "ht",
            "lg" => "lg",
            "cm" => "cm",
            "mr" | "new" => "mr",
            value if value.contains("date") => "mr",
            _ => "mr",
        }
    }

    fn parse_query_target(&self, query: &str) -> Option<QueryTarget> {
        let normalized = query.trim().to_ascii_lowercase();
        if let Some(info) = self.tag_map.read().unwrap().get(&normalized) {
            return Some(QueryTarget {
                kind: info.kind,
                slug: info.slug.clone(),
            });
        }
        // Fallback to kind:slug without @
        let trimmed = query.trim();
        let (kind_str, raw_slug) = trimmed.split_once(':')?;
        let slug = raw_slug
            .trim()
            .trim_matches('/')
            .replace(' ', "-")
            .to_ascii_lowercase();
        if slug.is_empty() {
            return None;
        }

        let kind = match kind_str.trim().to_ascii_lowercase().as_str() {
            "channel" | "channels" => QueryTargetKind::Channel,
            "pornstar" | "pornstars" => QueryTargetKind::Pornstar,
            "model" | "models" => QueryTargetKind::Model,
            "user" | "users" => QueryTargetKind::User,
            _ => return None,
        };

        Some(QueryTarget { kind, slug })
    }

    fn build_browse_url(&self, page: u8, sort: &str) -> String {
        let order = Self::normalize_sort(sort);
        if order == "mr" {
            format!("{}/video?page={page}", self.url)
        } else {
            format!("{}/video?o={order}&page={page}", self.url)
        }
    }

    fn build_creator_url(&self, page: u8, sort: &str, target: &QueryTarget) -> String {
        let mut url = format!(
            "{}/{}/{}/videos?page={page}",
            self.url,
            target.kind.path_segment(),
            target.slug
        );

        let mapped_sort = match target.kind {
            QueryTargetKind::Channel => match Self::normalize_sort(sort) {
                "mv" => Some("vi"),
                "tr" => Some("ra"),
                _ => None,
            },
            _ => match Self::normalize_sort(sort) {
                "mv" => Some("mv"),
                "tr" => Some("tr"),
                "lg" => Some("lg"),
                _ => None,
            },
        };

        if let Some(order) = mapped_sort {
            url.push_str("&o=");
            url.push_str(order);
        }

        url
    }

    fn build_listing_request(
        &self,
        page: u8,
        sort: &str,
        query: Option<&str>,
    ) -> (String, ListingScope) {
        match query.map(str::trim).filter(|value| !value.is_empty()) {
            Some(query) => {
                if let Some(target) = self.parse_query_target(query) {
                    (
                        self.build_creator_url(page, sort, &target),
                        ListingScope::Creator,
                    )
                } else {
                    let encoded = query.to_ascii_lowercase().replace(' ', "+");
                    (
                        format!("{}/video/search?search={encoded}&page={page}", self.url),
                        ListingScope::Search,
                    )
                }
            }
            None => (self.build_browse_url(page, sort), ListingScope::Browse),
        }
    }

    fn parse_listing_page(&self, html: &str, scope: ListingScope) -> Result<Vec<VideoItem>> {
        let document = Html::parse_document(html);
        let item_selector = Self::selector("li.pcVideoListItem")?;

        let container_selectors = match scope {
            ListingScope::Browse => vec!["#videoCategory"],
            ListingScope::Search => vec!["#videoSearchResult"],
            ListingScope::Creator => vec!["#showAllChanelVideos", "#mostRecentVideosSection"],
        };

        for selector_text in container_selectors {
            let container_selector = Self::selector(selector_text)?;
            if let Some(container) = document.select(&container_selector).next() {
                if container.select(&item_selector).next().is_some() {
                    return self.parse_listing_items(container);
                }
            }
        }

        Err(ErrorKind::Parse(format!("missing listing container for scope {scope:?}")).into())
    }

    fn parse_listing_items(&self, container: ElementRef<'_>) -> Result<Vec<VideoItem>> {
        let item_selector = Self::selector("li.pcVideoListItem")?;
        let link_selector = Self::selector("a[href*=\"/view_video.php\"]")?;
        let title_selector = Self::selector(".title a, .thumbnailTitle, span.title a")?;
        let image_selector = Self::selector("img")?;
        let duration_selector = Self::selector(".duration")?;
        let views_selector = Self::selector(".views var, .views")?;
        let rating_selector =
            Self::selector(".value, .rating, .ratingInfo, .percent, .ratingPercent")?;
        let tag_link_selector = Self::selector(
            "a[href*=\"/categories/\"], a[href*=\"/video/search\"], a[href*=\"/pornstar/\"], a[href*=\"/model/\"], a[href*=\"/channels/\"], a[href*=\"/users/\"]",
        )?;
        let uploader_selector = Self::selector(
            ".videoUploaderBlock a[href], .usernameWrap a[href], .usernameWrapper a[href]",
        )?;
        let verified_selector = Self::selector(".verified-icon, .channel-icon")?;

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

        for card in container.select(&item_selector) {
            let Some(link) = card.select(&link_selector).next() else {
                continue;
            };

            let href = link.value().attr("href").unwrap_or_default();
            let page_url = self.normalize_url(href);
            if page_url.is_empty() || !page_url.contains("/view_video.php") {
                continue;
            }

            let id = card
                .value()
                .attr("data-video-vkey")
                .filter(|value| !value.trim().is_empty())
                .map(ToOwned::to_owned)
                .or_else(|| Self::viewkey_from_url(&page_url))
                .or_else(|| {
                    card.value()
                        .attr("data-video-id")
                        .filter(|value| !value.trim().is_empty())
                        .map(ToOwned::to_owned)
                });
            let Some(id) = id else {
                continue;
            };
            if !seen_ids.insert(id.clone()) {
                continue;
            }

            let title = link
                .value()
                .attr("title")
                .filter(|value| !value.trim().is_empty())
                .map(Self::decode_html)
                .or_else(|| {
                    card.select(&title_selector)
                        .next()
                        .map(|value| Self::decode_html(&Self::text_of(&value)))
                })
                .unwrap_or_default();
            if title.is_empty() {
                continue;
            }

            let image = card.select(&image_selector).next();
            let thumb = image
                .as_ref()
                .and_then(|value| {
                    value
                        .value()
                        .attr("src")
                        .or_else(|| value.value().attr("data-mediumthumb"))
                        .or_else(|| value.value().attr("data-path"))
                        .or_else(|| value.value().attr("data-src"))
                })
                .map(|value| self.normalize_url(value))
                .unwrap_or_default();

            let duration = card
                .select(&duration_selector)
                .next()
                .map(|value| Self::text_of(&value))
                .and_then(|value| parse_time_to_seconds(&value))
                .unwrap_or(0) as u32;

            let views = card.select(&views_selector).find_map(|value| {
                let text = Self::text_of(&value);
                parse_abbreviated_number(&text)
                    .or_else(|| parse_abbreviated_number(text.replace("views", "").trim()))
            });
            let rating = card.select(&rating_selector).find_map(|value| {
                let text = Self::text_of(&value);
                let cleaned = text
                    .trim()
                    .trim_end_matches('%')
                    .replace(',', "")
                    .replace(' ', "");
                cleaned.parse::<f32>().ok()
            });

            let uploader_link = card.select(&uploader_selector).next();
            let uploader = uploader_link
                .as_ref()
                .map(|value| Self::decode_html(&Self::text_of(value)))
                .filter(|value| !value.is_empty());
            let uploader_url = uploader_link
                .and_then(|value| value.value().attr("href"))
                .map(|value| self.normalize_url(value))
                .filter(|value| !value.is_empty());

            let mut item =
                VideoItem::new(id, title, page_url, CHANNEL_ID.to_string(), thumb, duration);
            item.views = views;
            let preview_url = image
                .and_then(|value| value.value().attr("data-mediabook"))
                .map(|value| self.normalize_url(value))
                .filter(|value| !value.is_empty());
            item.preview = preview_url.clone();
            // if preview_url.is_some() {
            //     let mut format = VideoFormat::new(
            //         item.url.clone(),
            //         "preview".to_string(),
            //         "video/mp4".to_string(),
            //     );
            //     format.add_http_header("Referer".to_string(), item.url.clone());
            //     item.formats = Some(vec![format]);
            // }
            item.verified = card.select(&verified_selector).next().map(|_| true);
            item.uploader = uploader.clone();
            item.uploaderUrl = uploader_url.clone();
            item.uploaderId = uploader_url
                .as_deref()
                .and_then(Self::uploader_identity_from_url);
            item.rating = rating;

            let mut tags = Vec::new();
            if let Some(tag) = uploader_url
                .as_deref()
                .and_then(|url| self.query_tag_from_uploader_url(url))
            {
                Self::push_unique(&mut tags, tag);
            }
            for tag_link in card.select(&tag_link_selector) {
                let tag = Self::decode_html(&Self::text_of(&tag_link));
                Self::push_unique(&mut tags, tag);
            }
            if !tags.is_empty() {
                item.tags = Some(tags);
            }

            items.push(item);
        }

        Ok(items)
    }

    fn viewkey_from_url(url: &str) -> Option<String> {
        let parsed = Url::parse(url).ok()?;
        parsed
            .query_pairs()
            .find(|(key, _)| key == "viewkey")
            .map(|(_, value)| value.into_owned())
    }

    fn uploader_identity_from_url(url: &str) -> Option<String> {
        let parsed = Url::parse(url).ok()?;
        let mut segments = parsed.path_segments()?;
        let kind = segments.next()?.trim_matches('/');
        let slug = segments.next()?.trim_matches('/');
        if kind.is_empty() || slug.is_empty() {
            return None;
        }
        Some(format!("{CHANNEL_ID}:{kind}:{slug}"))
    }

    fn query_tag_from_uploader_url(&self, url: &str) -> Option<String> {
        let parsed = Url::parse(url).ok()?;
        let mut segments = parsed.path_segments()?;
        let kind_str = segments.next()?.trim_matches('/');
        let slug = segments.next()?.trim_matches('/');
        if kind_str.is_empty() || slug.is_empty() {
            return None;
        }
        let normalized_slug = slug.to_ascii_lowercase();
        if let Some(info) = self.tag_map.read().unwrap().get(&normalized_slug) {
            return Some(info.title.clone());
        }
        Some(slug.replace('-', " "))
    }

    fn push_unique(values: &mut Vec<String>, value: String) {
        let normalized = value.trim();
        if normalized.is_empty() {
            return;
        }
        if values
            .iter()
            .any(|existing| existing.eq_ignore_ascii_case(normalized))
        {
            return;
        }
        values.push(normalized.to_string());
    }

    async fn fetch_listing(
        &self,
        cache: VideoCache,
        page: u8,
        sort: &str,
        query: Option<&str>,
        options: ServerOptions,
    ) -> Result<Vec<VideoItem>> {
        if query.is_some() && self.tag_map.read().unwrap().is_empty() {
            let _ = Self::load_tags(&self.url, Arc::clone(&self.tag_map)).await;
        }

        let (video_url, scope) = self.build_listing_request(page, sort, query);
        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());
            }
            Some((_, items)) => items.clone(),
            None => vec![],
        };

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

        let items = match self.parse_listing_page(&text, scope) {
            Ok(items) => items,
            Err(error) => {
                report_provider_error(
                    CHANNEL_ID,
                    "fetch_listing.parse",
                    &format!("url={video_url}; error={error}"),
                )
                .await;
                return Ok(old_items);
            }
        };

        if items.is_empty() {
            return Ok(old_items);
        }

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

#[async_trait]
impl Provider for PornhubProvider {
    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::<u8>().unwrap_or(1);
        let sort = Self::normalize_sort(&sort).to_string();

        match self
            .fetch_listing(cache, page, &sort, query.as_deref(), 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::*;

    #[test]
    fn parses_creator_queries() {
        let provider = PornhubProvider::new();
        let target = provider
            .parse_query_target("channels:Brazzers")
            .expect("channel target should parse");
        assert!(matches!(target.kind, QueryTargetKind::Channel));
        assert_eq!(target.slug, "brazzers");

        let target = provider
            .parse_query_target("pornstar:Alex Mack")
            .expect("pornstar target should parse");
        assert!(matches!(target.kind, QueryTargetKind::Pornstar));
        assert_eq!(target.slug, "alex-mack");

        assert!(provider.parse_query_target("teacher").is_none());
    }

    #[test]
    fn resolves_query_from_tag_map_by_id_or_title() {
        let provider = PornhubProvider::new();
        {
            let mut map = provider.tag_map.write().unwrap();
            let info = TagInfo {
                kind: QueryTargetKind::Channel,
                slug: "mature-4k".to_string(),
                title: "Mature 4K".to_string(),
            };
            map.insert("mature-4k".to_string(), info.clone());
            map.insert("mature 4k".to_string(), info);
        }

        let by_id = provider
            .parse_query_target("mature-4k")
            .expect("id lookup should resolve");
        assert!(matches!(by_id.kind, QueryTargetKind::Channel));
        assert_eq!(by_id.slug, "mature-4k");

        let by_title = provider
            .parse_query_target("Mature 4K")
            .expect("title lookup should resolve");
        assert!(matches!(by_title.kind, QueryTargetKind::Channel));
        assert_eq!(by_title.slug, "mature-4k");
    }

    #[test]
    fn parses_browse_listing_cards() {
        let provider = PornhubProvider::new();
        let html = r#"
        <ul id="videoCategory" class="nf-videos videos search-video-thumbs">
          <li class="sniperModeEngaged"></li>
          <li class="pcVideoListItem js-pop videoblock videoBox withKebabMenu"
              data-video-id="466705435"
              data-video-vkey="67ed937c986b1">
            <a href="/view_video.php?viewkey=67ed937c986b1" title="Black asian teen"></a>
            <img src="https://example.com/thumb.jpg"
                 data-mediabook="https://example.com/preview.webm" />
            <div class="marker-overlays"><var class="duration">12:18</var></div>
            <div class="videoUploaderBlock">
              <div class="usernameWrap">
                <a href="/model/honeycore">Honeycore</a>
              </div>
            </div>
            <div class="videoDetailsBlock">
              <span class="views"><var>199K</var> views</span>
            </div>
          </li>
        </ul>
        "#;

        let items = provider
            .parse_listing_page(html, ListingScope::Browse)
            .expect("browse listing should parse");
        assert_eq!(items.len(), 1);
        assert_eq!(items[0].id, "67ed937c986b1");
        assert_eq!(items[0].uploader.as_deref(), Some("Honeycore"));
        assert_eq!(
            items[0].uploaderUrl.as_deref(),
            Some("https://www.pornhub.com/model/honeycore")
        );
        assert_eq!(items[0].views, Some(199000));
        assert_eq!(items[0].duration, 738);
        assert_eq!(
            items[0].preview.as_deref(),
            Some("https://example.com/preview.webm")
        );
        assert!(items[0].tags.as_ref().is_some_and(|values| {
            values
                .iter()
                .any(|value| value.eq_ignore_ascii_case("honeycore"))
        }));
    }

    #[test]
    fn parses_listing_metadata_without_detail_fetch() {
        let provider = PornhubProvider::new();
        let html = r#"
        <ul id="videoCategory" class="nf-videos videos search-video-thumbs">
          <li class="pcVideoListItem js-pop videoblock videoBox withKebabMenu"
              data-video-id="466705435"
              data-video-vkey="67ed937c986b1">
            <a href="/view_video.php?viewkey=67ed937c986b1" title="Black asian teen"></a>
            <img data-src="https://example.com/thumb.jpg"
                 data-mediabook="https://example.com/preview.webm" />
            <div class="marker-overlays"><var class="duration">12:18</var></div>
            <div class="videoDetailsBlock">
              <span class="views"><var>199K</var> views</span>
              <span class="value">95%</span>
            </div>
            <a href="/categories/anal">Anal</a>
            <a href="/pornstar/jane-doe">Jane Doe</a>
          </li>
        </ul>
        "#;

        let items = provider
            .parse_listing_page(html, ListingScope::Browse)
            .expect("browse listing should parse");

        assert_eq!(items.len(), 1);
        assert_eq!(items[0].thumb, "https://example.com/thumb.jpg");
        assert_eq!(
            items[0].preview.as_deref(),
            Some("https://example.com/preview.webm")
        );
        assert_eq!(items[0].views, Some(199000));
        assert_eq!(items[0].rating, Some(95.0));
        assert!(
            items[0]
                .tags
                .as_ref()
                .is_some_and(|values| values.iter().any(|value| value == "Anal"))
        );
        assert!(
            items[0]
                .tags
                .as_ref()
                .is_some_and(|values| values.iter().any(|value| value == "Jane Doe"))
        );
    }
}
