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, VideoFormat, VideoItem};

use async_trait::async_trait;
use chrono::{DateTime, NaiveDateTime, Utc};
use error_chain::error_chain;
use htmlentity::entity::{ICodedDataTrait, decode};
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
use regex::Regex;
use serde::Deserialize;
use std::sync::{Arc, RwLock};

pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
    crate::providers::ProviderChannelMetadata {
        group_id: "tiktok",
        tags: &["shorts", "vertical", "tiktok"],
    };

const BASE_URL: &str = "https://tik.porn";
const API_BASE: &str = "https://apiv2.tik.porn";
const CHANNEL_ID: &str = "tikporn";
const BROWSER_UA: &str =
    "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0 Safari/537.36";
// apiv2 listing endpoints return ~10 rows per call regardless of `limit`; step the
// offset by this so pages stay contiguous (no skipped or duplicated rows).
const FEED_PAGE_SIZE: u32 = 10;

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

#[derive(Debug, Clone)]
pub struct TikpornProvider {
    url: String,
    build_id: Arc<RwLock<Option<String>>>,
}

#[derive(Debug, Clone)]
enum Target {
    /// Default browse feed, served from the apiv2 JSON API (paginates by offset).
    Feed { kind: FeedKind },
    /// Keyword search, served from the Next.js `/?s=` SSR payload.
    Search { query: String },
    /// Tag/action/model archive, served from the Next.js SSR payload.
    Archive { path: String },
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FeedKind {
    /// Newest uploads first (`/getrecentvideos`); deeper pages fall back to popular
    /// because the recent endpoint is a live feed and does not honor `offset`.
    Newest,
    /// Most popular shorts (`/videos/popular`); honors `offset` for clean paging.
    Popular,
}

// ---- apiv2 listing rows (https://apiv2.tik.porn/{getrecentvideos,videos/popular}) ----

#[derive(Debug, Deserialize, Default)]
struct ApiResponse {
    #[serde(default)]
    data: Vec<ApiVideo>,
}

#[derive(Debug, Deserialize, Default)]
struct ApiVideo {
    #[serde(default)]
    video_id: u64,
    #[serde(default, deserialize_with = "de_u32")]
    duration: u32,
    #[serde(default, deserialize_with = "de_u32")]
    view_count: u32,
    #[serde(default)]
    published: String,
    #[serde(default)]
    video_date: String,
    #[serde(default)]
    action_name: String,
    #[serde(default)]
    producer_name: Option<String>,
    #[serde(default)]
    producer_slug: Option<String>,
    #[serde(default)]
    pornstars: Vec<ApiEntity>,
    #[serde(default)]
    tags: Vec<ApiEntity>,
    #[serde(default)]
    thumbnail_url: String,
    #[serde(default)]
    medium_thumb: String,
    #[serde(default)]
    small_thumb: String,
    #[serde(default)]
    poster_url: String,
    #[serde(default)]
    hls_url: Option<String>,
    #[serde(default)]
    mp4_url: Option<String>,
    #[serde(default)]
    video_text: ApiVideoText,
}

#[derive(Debug, Deserialize, Default)]
struct ApiEntity {
    #[serde(default)]
    name: String,
    #[serde(default)]
    slug: String,
}

#[derive(Debug, Deserialize, Default)]
struct ApiVideoText {
    #[serde(default)]
    meta_title: ApiLocalizedText,
}

#[derive(Debug, Deserialize, Default)]
struct ApiLocalizedText {
    #[serde(default)]
    default: ApiTextValue,
}

#[derive(Debug, Deserialize, Default)]
struct ApiTextValue {
    #[serde(default)]
    text: String,
}

/// Tik.Porn returns sentinel `-1`/`null` values and occasional stringly-typed numbers
/// in integer fields. A single such value would otherwise abort the whole list parse,
/// so coerce anything unexpected to `0`.
fn de_u32<'de, D>(deserializer: D) -> std::result::Result<u32, D::Error>
where
    D: serde::Deserializer<'de>,
{
    Ok(match serde_json::Value::deserialize(deserializer)? {
        serde_json::Value::Number(number) => number
            .as_u64()
            .or_else(|| number.as_i64().filter(|value| *value >= 0).map(|v| v as u64))
            .map(|value| value.min(u64::from(u32::MAX)) as u32)
            .unwrap_or(0),
        serde_json::Value::String(text) => text.trim().parse().unwrap_or(0),
        _ => 0,
    })
}

#[derive(Debug, Deserialize, Clone, Default)]
struct NextData {
    #[serde(default, rename = "buildId")]
    build_id: String,
    #[serde(default)]
    props: NextProps,
}

#[derive(Debug, Deserialize, Clone, Default)]
struct NextProps {
    #[serde(default, rename = "pageProps")]
    page_props: PageProps,
}

#[derive(Debug, Deserialize, Clone, Default)]
struct PageProps {
    #[serde(default)]
    videos: Option<VideoList>,
    #[serde(default, rename = "initialVideoResults")]
    initial_video_results: Option<VideoList>,
}

#[derive(Debug, Deserialize, Clone, Default)]
struct VideoList {
    #[serde(default)]
    data: Vec<TikVideo>,
    #[serde(default)]
    pagination: Pagination,
}

#[derive(Debug, Deserialize, Clone, Default)]
struct Pagination {
    #[serde(default, rename = "hasMore")]
    has_more: bool,
}

#[derive(Debug, Deserialize, Clone, Default)]
struct TikVideo {
    #[serde(default)]
    id: u64,
    #[serde(default, rename = "basePath")]
    base_path: String,
    #[serde(default)]
    texts: Texts,
    #[serde(default)]
    action: Option<Entity>,
    #[serde(default)]
    producer: Option<Entity>,
    #[serde(default)]
    pornstars: Vec<Entity>,
    #[serde(default)]
    user: Option<Entity>,
    #[serde(default)]
    creator: Vec<Entity>,
    #[serde(default)]
    tags: Vec<Entity>,
    #[serde(default)]
    source: Option<Source>,
    #[serde(default)]
    sources: Vec<Source>,
    #[serde(default)]
    poster: String,
    #[serde(default)]
    thumbnails: Thumbnails,
    #[serde(default, deserialize_with = "de_u32")]
    duration: u32,
    #[serde(default, deserialize_with = "de_u32")]
    likes: u32,
    #[serde(default, deserialize_with = "de_u32")]
    views: u32,
    #[serde(default)]
    metadata: Metadata,
}

#[derive(Debug, Deserialize, Clone, Default)]
struct Texts {
    #[serde(default)]
    video: TextValue,
}

#[derive(Debug, Deserialize, Clone, Default)]
struct TextValue {
    #[serde(default)]
    text: String,
    #[serde(default)]
    parsed_text: String,
}

#[derive(Debug, Deserialize, Clone, Default)]
struct Entity {
    #[serde(default)]
    id: u64,
    #[serde(default)]
    name: String,
    #[serde(default)]
    slug: String,
    #[serde(default, rename = "basePath")]
    base_path: String,
}

#[derive(Debug, Deserialize, Clone, Default)]
struct Source {
    #[serde(default)]
    src: String,
    #[serde(default, rename = "type")]
    mime_type: String,
}

#[derive(Debug, Deserialize, Clone, Default)]
struct Thumbnails {
    #[serde(default)]
    sm: String,
    #[serde(default)]
    md: String,
}

#[derive(Debug, Deserialize, Clone, Default)]
struct Metadata {
    #[serde(default)]
    title: String,
    #[serde(default)]
    thumbnail: String,
    #[serde(default, rename = "uploadDate")]
    upload_date: String,
}

impl TikpornProvider {
    pub fn new() -> Self {
        Self {
            url: BASE_URL.to_string(),
            build_id: Arc::new(RwLock::new(None)),
        }
    }

    fn build_channel(&self, _clientversion: ClientVersion) -> Channel {
        Channel {
            id: CHANNEL_ID.to_string(),
            name: "Tik Porn".to_string(),
            description: "Tik.Porn short-form vertical videos with search, tag/action/model shortcuts, and direct CDN formats.".to_string(),
            premium: false,
            favicon: "https://tik.porn/favicon.ico".to_string(),
            status: "active".to_string(),
            categories: vec![
                "Shorts".to_string(),
                "Vertical".to_string(),
                "TikTok-style".to_string(),
            ],
            options: vec![ChannelOption {
                id: "sort".to_string(),
                title: "Sort".to_string(),
                description: "Newest uploads or most popular shorts. Search and tag:/action:/model: shortcuts are also supported.".to_string(),
                systemImage: "sparkles.tv".to_string(),
                colorName: "pink".to_string(),
                options: vec![
                    FilterOption {
                        id: "new".to_string(),
                        title: "Newest".to_string(),
                    },
                    FilterOption {
                        id: "trending".to_string(),
                        title: "Trending".to_string(),
                    },
                ],
                multiSelect: false,
            }],
            nsfw: true,
            cacheDuration: Some(900),
            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
        }
    }

    fn resolve_target(query: Option<&str>, sort: &str) -> Target {
        let Some(raw_query) = query.map(str::trim).filter(|value| !value.is_empty()) else {
            return Target::Feed {
                kind: Self::feed_kind(sort),
            };
        };

        if let Some(path) = Self::prefixed_archive(raw_query) {
            return Target::Archive { path };
        }

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

    fn feed_kind(sort: &str) -> FeedKind {
        // The server defaults `sort` to "date" when the client omits it.
        match sort.trim().to_ascii_lowercase().as_str() {
            "trending" | "popular" | "hot" | "best" | "views" | "most-viewed" => FeedKind::Popular,
            _ => FeedKind::Newest,
        }
    }

    fn prefixed_archive(query: &str) -> Option<String> {
        let (prefix, value) = query.split_once(':')?;
        let value = value.trim();
        if value.is_empty() {
            return None;
        }

        match prefix.trim().to_ascii_lowercase().as_str() {
            "tag" => Some(format!("/tag/{}", Self::slugify(value))),
            "action" | "category" | "cat" => Some(format!("/action/{}", Self::slugify(value))),
            "model" | "pornstar" | "creator" | "uploader" | "studio" | "site" => {
                Some(format!("/{}", Self::slugify(value)))
            }
            _ => None,
        }
    }

    fn slugify(value: &str) -> String {
        value
            .trim()
            .trim_matches('/')
            .to_ascii_lowercase()
            .chars()
            .map(|ch| {
                if ch.is_ascii_alphanumeric() {
                    ch
                } else {
                    '-'
                }
            })
            .collect::<String>()
            .split('-')
            .filter(|part| !part.is_empty())
            .collect::<Vec<_>>()
            .join("-")
    }

    fn page_url(&self, target: &Target, page: u8) -> String {
        match target {
            // Feed targets are served from the apiv2 API, not the SSR page URL.
            Target::Feed { .. } => format!("{}/", self.url),
            Target::Search { query } => {
                let encoded = utf8_percent_encode(query, NON_ALPHANUMERIC).to_string();
                if page <= 1 {
                    format!("{}/?s={encoded}", self.url)
                } else {
                    format!("{}/?s={encoded}&page={page}", self.url)
                }
            }
            Target::Archive { path } => {
                let path = path.trim_start_matches('/');
                if page <= 1 {
                    format!("{}/{path}", self.url)
                } else {
                    format!("{}/{path}?page={page}", self.url)
                }
            }
        }
    }

    async fn fetch_page_props(
        &self,
        requester: &mut crate::util::requester::Requester,
        target: &Target,
        page: u8,
    ) -> Result<PageProps> {
        if let Target::Search { query } = target {
            if let Some(build_id) = self.current_build_id() {
                let encoded = utf8_percent_encode(query, NON_ALPHANUMERIC).to_string();
                let data_url = if page <= 1 {
                    format!("{}/_next/data/{build_id}/search.json?s={encoded}", self.url)
                } else {
                    format!(
                        "{}/_next/data/{build_id}/search.json?s={encoded}&page={page}",
                        self.url
                    )
                };
                if let Ok(body) = Self::fetch_text(requester, &data_url, &self.url).await {
                    if let Ok(next) = serde_json::from_str::<NextProps>(&body) {
                        return Ok(next.page_props);
                    }
                }
            }
        }

        let page_url = self.page_url(target, page);
        let body = Self::fetch_text(requester, &page_url, &self.url).await?;
        let next = Self::extract_next_data(&body)?;
        if !next.build_id.is_empty() {
            self.set_build_id(next.build_id);
        }
        Ok(next.props.page_props)
    }

    async fn fetch_text(
        requester: &mut crate::util::requester::Requester,
        url: &str,
        referer: &str,
    ) -> Result<String> {
        requester
            .get_with_headers(
                url,
                vec![
                    ("Referer".to_string(), referer.to_string()),
                    ("User-Agent".to_string(), BROWSER_UA.to_string()),
                    (
                        "Accept".to_string(),
                        "text/html,application/json;q=0.9,*/*;q=0.8".to_string(),
                    ),
                ],
                None,
            )
            .await
            .map_err(|error| Error::from(ErrorKind::Parse(error.to_string())))
    }

    fn extract_next_data(html: &str) -> Result<NextData> {
        let regex = Regex::new(
            r#"<script id="__NEXT_DATA__" type="application/json">(?s:(.*?))</script>"#,
        )?;
        let captures = regex.captures(html).ok_or_else(|| {
            Error::from(ErrorKind::Parse(
                "__NEXT_DATA__ script not found".to_string(),
            ))
        })?;
        let raw = captures.get(1).map(|value| value.as_str()).unwrap_or("");
        serde_json::from_str::<NextData>(raw).map_err(Error::from)
    }

    fn current_build_id(&self) -> Option<String> {
        self.build_id.read().ok().and_then(|value| value.clone())
    }

    fn set_build_id(&self, build_id: String) {
        if let Ok(mut stored) = self.build_id.write() {
            *stored = Some(build_id);
        }
    }

    fn collect_videos(target: &Target, props: PageProps) -> (Vec<TikVideo>, bool) {
        match target {
            // Feed targets never reach the SSR collector.
            Target::Feed { .. } => (Vec::new(), false),
            Target::Search { .. } => props
                .initial_video_results
                .map(|list| (Self::dedupe(list.data), list.pagination.has_more))
                .unwrap_or_default(),
            Target::Archive { .. } => props
                .videos
                .map(|list| (Self::dedupe(list.data), list.pagination.has_more))
                .unwrap_or_default(),
        }
    }

    fn dedupe(items: Vec<TikVideo>) -> Vec<TikVideo> {
        let mut seen = std::collections::HashSet::new();
        items
            .into_iter()
            .filter(|item| item.id != 0 && seen.insert(item.id))
            .collect()
    }

    fn video_to_item(video: TikVideo) -> Option<VideoItem> {
        if video.id == 0 {
            return None;
        }

        let page_path = if video.base_path.trim().is_empty() {
            format!("/video/{}", video.id)
        } else {
            video.base_path.clone()
        };
        let page_url = Self::absolute_url(&page_path);
        let title = Self::best_title(&video);
        let thumb = Self::best_thumb(&video);
        if title.is_empty() || thumb.is_empty() {
            return None;
        }

        let mut item = VideoItem::new(
            video.id.to_string(),
            title,
            page_url.clone(),
            CHANNEL_ID.to_string(),
            thumb,
            video.duration,
        );

        if video.views > 0 {
            item.views = Some(video.views);
        }
        if video.likes > 0 {
            item.rating = Some(100.0);
        }
        if let Some(uploaded_at) = Self::parse_uploaded_at(&video.metadata.upload_date) {
            item.uploadedAt = Some(uploaded_at);
        }

        let uploader = Self::best_uploader(&video);
        if let Some(entity) = uploader {
            item.uploader = Some(entity.name.clone());
            item.uploaderUrl = Some(Self::absolute_url(&entity.base_path));
            item.uploaderId = Some(format!(
                "{CHANNEL_ID}:{}:{}",
                Self::entity_kind(&entity.base_path),
                if entity.id > 0 {
                    entity.id.to_string()
                } else {
                    entity.slug.clone()
                }
            ));
        }

        let mut tags = Vec::new();
        if let Some(action) = &video.action {
            if !action.name.trim().is_empty() {
                tags.push(action.name.trim().to_string());
            }
        }
        for entity in video.tags.iter().chain(video.pornstars.iter()) {
            if !entity.name.trim().is_empty() {
                tags.push(entity.name.trim().to_string());
            }
        }
        tags.sort();
        tags.dedup();
        if !tags.is_empty() {
            item.tags = Some(tags);
        }

        let formats = Self::formats(&video, &page_url);
        if !formats.is_empty() {
            item.formats = Some(formats);
        }

        if let Some(source) = &video.source {
            if source.mime_type.contains("video/") && !source.src.is_empty() {
                item.preview = Some(source.src.clone());
            }
        }

        Some(item)
    }

    fn best_title(video: &TikVideo) -> String {
        let raw = if !video.metadata.title.trim().is_empty() {
            video.metadata.title.as_str()
        } else if !video.texts.video.parsed_text.trim().is_empty() {
            video.texts.video.parsed_text.as_str()
        } else {
            video.texts.video.text.as_str()
        };

        let title = decode(raw.as_bytes())
            .to_string()
            .unwrap_or_else(|_| raw.to_string())
            .replace(" | Tik.Porn", "")
            .replace("{{", "")
            .replace("}}", "");
        title.split_whitespace().collect::<Vec<_>>().join(" ")
    }

    fn best_thumb(video: &TikVideo) -> String {
        if !video.metadata.thumbnail.trim().is_empty() {
            return video.metadata.thumbnail.clone();
        }
        if !video.thumbnails.md.trim().is_empty() {
            return video.thumbnails.md.clone();
        }
        if !video.thumbnails.sm.trim().is_empty() {
            return video.thumbnails.sm.clone();
        }
        video.poster.clone()
    }

    fn best_uploader(video: &TikVideo) -> Option<Entity> {
        video
            .creator
            .iter()
            .chain(video.pornstars.iter())
            .chain(video.producer.iter())
            .chain(video.user.iter())
            .find(|entity| !entity.name.trim().is_empty() && !entity.base_path.trim().is_empty())
            .cloned()
    }

    fn entity_kind(path: &str) -> &'static str {
        if path.starts_with("/tag/") {
            "tag"
        } else if path.starts_with("/action/") {
            "action"
        } else {
            "profile"
        }
    }

    fn formats(video: &TikVideo, referer: &str) -> Vec<VideoFormat> {
        let mut formats = Vec::new();
        for source in video.sources.iter().chain(video.source.iter()) {
            let url = source.src.trim();
            if url.is_empty() {
                continue;
            }
            let mut format = if source.mime_type.contains("mpegURL") || url.contains(".m3u8") {
                VideoFormat::m3u8(url.to_string(), "hls".to_string(), "m3u8".to_string())
            } else if source.mime_type.contains("mp4") || url.contains(".mp4") {
                VideoFormat::new(url.to_string(), "direct".to_string(), "mp4".to_string())
            } else {
                continue;
            };
            format.add_http_header("Referer".to_string(), referer.to_string());
            format.add_http_header("User-Agent".to_string(), BROWSER_UA.to_string());
            formats.push(format);
        }
        formats
    }

    fn parse_uploaded_at(value: &str) -> Option<u64> {
        DateTime::parse_from_rfc3339(value)
            .ok()
            .map(|date| date.with_timezone(&Utc).timestamp())
            .and_then(|timestamp| u64::try_from(timestamp).ok())
    }

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

    /// Search/archive targets are served by the Next.js SSR `__NEXT_DATA__` payload.
    async fn fetch_ssr(
        &self,
        requester: &mut crate::util::requester::Requester,
        target: &Target,
        page: u8,
    ) -> Result<Vec<VideoItem>> {
        let props = self.fetch_page_props(requester, target, page).await?;
        let (videos, _has_more) = Self::collect_videos(target, props);
        Ok(videos.into_iter().filter_map(Self::video_to_item).collect())
    }

    /// The default browse feed is served by the apiv2 JSON API, which returns fully
    /// hydrated rows (signed HLS/MP4, thumbnails, title, tags, pornstars).
    async fn fetch_feed(
        &self,
        requester: &mut crate::util::requester::Requester,
        kind: FeedKind,
        page: u8,
    ) -> Result<Vec<VideoItem>> {
        // `/getrecentvideos` is a live feed that ignores `offset`, so only page 1 uses
        // it; deeper "newest" pages continue through the popular feed, which paginates.
        let (path, offset) = match (kind, page) {
            (FeedKind::Newest, 1) => ("getrecentvideos", 0),
            (FeedKind::Newest, p) => ("videos/popular", u32::from(p - 2) * FEED_PAGE_SIZE),
            (FeedKind::Popular, p) => ("videos/popular", u32::from(p - 1) * FEED_PAGE_SIZE),
        };
        let url = format!("{API_BASE}/{path}?limit={FEED_PAGE_SIZE}&offset={offset}");
        let body = Self::fetch_text(requester, &url, &format!("{}/", self.url)).await?;
        let response = serde_json::from_str::<ApiResponse>(&body)?;
        Ok(response
            .data
            .into_iter()
            .filter_map(Self::api_video_to_item)
            .collect())
    }

    fn api_video_to_item(video: ApiVideo) -> Option<VideoItem> {
        if video.video_id == 0 {
            return None;
        }

        let page_url = format!("{BASE_URL}/video/{}", video.video_id);
        let title = {
            let cleaned = Self::clean_title(&video.video_text.meta_title.default.text);
            if cleaned.is_empty() {
                video.action_name.trim().to_string()
            } else {
                cleaned
            }
        };
        let thumb = [
            &video.medium_thumb,
            &video.small_thumb,
            &video.thumbnail_url,
            &video.poster_url,
        ]
        .into_iter()
        .map(|value| value.trim())
        .find(|value| !value.is_empty())
        .unwrap_or("")
        .to_string();
        if title.is_empty() || thumb.is_empty() {
            return None;
        }

        let mut item = VideoItem::new(
            video.video_id.to_string(),
            title,
            page_url,
            CHANNEL_ID.to_string(),
            thumb,
            video.duration,
        );

        if video.view_count > 0 {
            item.views = Some(video.view_count);
        }

        let uploaded = if video.published.trim().is_empty() {
            video.video_date.as_str()
        } else {
            video.published.as_str()
        };
        if let Some(timestamp) = Self::parse_api_date(uploaded) {
            item.uploadedAt = Some(timestamp);
        }

        // Uploader: prefer the studio/producer, then the first named pornstar.
        let uploader = match (video.producer_name.as_deref(), video.producer_slug.as_deref()) {
            (Some(name), Some(slug)) if !name.trim().is_empty() && !slug.trim().is_empty() => {
                Some((name.trim().to_string(), slug.trim().to_string()))
            }
            _ => video
                .pornstars
                .iter()
                .find(|entity| !entity.name.trim().is_empty() && !entity.slug.trim().is_empty())
                .map(|entity| (entity.name.trim().to_string(), entity.slug.trim().to_string())),
        };
        if let Some((name, slug)) = uploader {
            item.uploader = Some(name);
            item.uploaderUrl = Some(format!("{BASE_URL}/{slug}"));
            item.uploaderId = Some(format!("{CHANNEL_ID}:profile:{slug}"));
        }

        let mut tags = Vec::new();
        if !video.action_name.trim().is_empty() {
            tags.push(video.action_name.trim().to_string());
        }
        for entity in video.tags.iter().chain(video.pornstars.iter()) {
            if !entity.name.trim().is_empty() {
                tags.push(entity.name.trim().to_string());
            }
        }
        tags.sort();
        tags.dedup();
        if !tags.is_empty() {
            item.tags = Some(tags);
        }

        let referer = format!("{BASE_URL}/");
        let mut formats = Vec::new();
        if let Some(hls) = video
            .hls_url
            .as_deref()
            .map(str::trim)
            .filter(|value| !value.is_empty())
        {
            let mut format =
                VideoFormat::m3u8(hls.to_string(), "hls".to_string(), "m3u8".to_string());
            format.add_http_header("Referer".to_string(), referer.clone());
            format.add_http_header("User-Agent".to_string(), BROWSER_UA.to_string());
            formats.push(format);
        }
        if let Some(mp4) = video
            .mp4_url
            .as_deref()
            .map(str::trim)
            .filter(|value| !value.is_empty())
        {
            let mut format =
                VideoFormat::new(mp4.to_string(), "direct".to_string(), "mp4".to_string());
            format.add_http_header("Referer".to_string(), referer);
            format.add_http_header("User-Agent".to_string(), BROWSER_UA.to_string());
            formats.push(format);
        }
        if !formats.is_empty() {
            item.formats = Some(formats);
        }

        Some(item)
    }

    fn clean_title(raw: &str) -> String {
        let trimmed = raw.trim();
        let decoded = decode(trimmed.as_bytes())
            .to_string()
            .unwrap_or_else(|_| trimmed.to_string());
        decoded
            .replace(" | Tik.Porn", "")
            .split_whitespace()
            .collect::<Vec<_>>()
            .join(" ")
    }

    fn parse_api_date(value: &str) -> Option<u64> {
        NaiveDateTime::parse_from_str(value.trim(), "%Y-%m-%d %H:%M:%S")
            .ok()
            .map(|naive| naive.and_utc().timestamp())
            .and_then(|timestamp| u64::try_from(timestamp).ok())
    }
}

#[async_trait]
impl Provider for TikpornProvider {
    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).max(1);
        let target = Self::resolve_target(query.as_deref(), &sort);
        let mut requester = requester_or_default(&options, CHANNEL_ID, "get_videos");
        let result = match &target {
            Target::Feed { kind } => self.fetch_feed(&mut requester, *kind, page).await,
            other => self.fetch_ssr(&mut requester, other, page).await,
        };
        match result {
            Ok(items) => items,
            Err(error) => {
                report_provider_error(CHANNEL_ID, "get_videos", &error.to_string()).await;
                Vec::new()
            }
        }
    }

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