use crate::DbPool;
use crate::api::ClientVersion;
use crate::providers::{
    Provider, build_proxy_url, report_provider_error, requester_or_default, strip_url_scheme,
};
use crate::status::*;
use crate::uploaders::{UploaderChannelStat, UploaderLayoutRow, UploaderProfile, UploaderVideoRef};
use crate::util::cache::VideoCache;
use crate::videos::{ServerOptions, VideoFormat, VideoItem};
use async_trait::async_trait;
use chrono::DateTime;
use error_chain::error_chain;
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
use serde::Deserialize;

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

const BASE_URL: &str = "https://fikfap.com";
const API_BASE: &str = "https://api.fikfap.com";
const CHANNEL_ID: &str = "fikfap";
const DEFAULT_PER_PAGE: usize = 20;
const MAX_PER_PAGE: usize = 40;
// FikFap pagination is cursor-based (`afterId`), not page-number based, so reaching
// page N requires walking N sequential requests from the start of the feed.
const MAX_PAGE_WALK: u16 = 25;

// A small curated set of well-known FikFap hashtags. The site exposes a hashtag
// catalog only through a randomized "discover" sample, so there is no stable full
// catalog to background-load; any hashtag label works directly as a routing target.
const CURATED_HASHTAGS: &[&str] = &[
    "anal",
    "ass",
    "milf",
    "threesome",
    "blonde",
    "brunette",
    "redhead",
    "natural",
    "hardcore",
    "lingerie",
    "masturbation",
    "cumshot",
    "squirting",
    "creampie",
    "bbc",
    "gonewild",
    "blowjob",
    "doggystyle",
    "lesbian",
    "deepthroat",
];

error_chain! {
    foreign_links {
        Json(serde_json::Error);
    }
    errors {
        Request(msg: String) {
            description("request error")
            display("request error: {}", msg)
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FeedSort {
    New,
    Trending,
    Random,
}

impl FeedSort {
    fn api_value(self) -> &'static str {
        match self {
            FeedSort::New => "new",
            FeedSort::Trending => "trending",
            FeedSort::Random => "random",
        }
    }

    fn from_sort_id(value: &str) -> Self {
        match value.trim().to_ascii_lowercase().as_str() {
            "trending" | "hot" => FeedSort::Trending,
            "random" | "foryou" | "for_you" => FeedSort::Random,
            _ => FeedSort::New,
        }
    }
}

#[derive(Debug, Clone)]
enum Target {
    Feed(FeedSort),
    Hashtag(String),
    User(String),
    Search(String),
}

#[derive(Debug, Deserialize, Clone, Default)]
#[serde(rename_all = "camelCase")]
struct Post {
    post_id: i64,
    #[serde(default)]
    label: String,
    #[serde(default)]
    views_count: u32,
    #[serde(default)]
    duration: Option<u32>,
    #[serde(default)]
    video_stream_url: String,
    #[serde(default)]
    thumbnail_stream_url: String,
    #[serde(default)]
    published_at: Option<String>,
    #[serde(default)]
    created_at: String,
    #[serde(default)]
    author: Author,
    #[serde(default)]
    hashtags: Vec<HashtagEntry>,
}

#[derive(Debug, Deserialize, Clone, Default)]
#[serde(rename_all = "camelCase")]
struct Author {
    #[serde(default)]
    username: String,
    #[serde(default)]
    is_verified: bool,
}

#[derive(Debug, Deserialize, Clone, Default)]
struct HashtagEntry {
    #[serde(default)]
    label: String,
}

#[derive(Debug, Deserialize, Clone, Default)]
struct SearchResponse {
    #[serde(default)]
    posts: Vec<Post>,
}

#[derive(Debug, Deserialize, Clone, Default)]
#[serde(rename_all = "camelCase")]
struct UserProfile {
    #[serde(default)]
    username: String,
    #[serde(default)]
    count_posts: u64,
    #[serde(default)]
    count_total_views: u64,
    #[serde(default)]
    is_verified: bool,
    #[serde(default)]
    description: Option<String>,
    #[serde(default)]
    thumbnail_url: String,
}

#[derive(Debug, Clone)]
pub struct FikfapProvider {
    anon_id: String,
}

impl FikfapProvider {
    pub fn new() -> Self {
        Self {
            anon_id: Self::generate_anon_id(),
        }
    }

    fn generate_anon_id() -> String {
        let mut bytes = [0u8; 16];
        rand::fill(&mut bytes);
        bytes[6] = (bytes[6] & 0x0f) | 0x40;
        bytes[8] = (bytes[8] & 0x3f) | 0x80;
        format!(
            "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
            bytes[0],
            bytes[1],
            bytes[2],
            bytes[3],
            bytes[4],
            bytes[5],
            bytes[6],
            bytes[7],
            bytes[8],
            bytes[9],
            bytes[10],
            bytes[11],
            bytes[12],
            bytes[13],
            bytes[14],
            bytes[15]
        )
    }

    fn anon_headers(&self) -> Vec<(String, String)> {
        vec![
            ("Referer".to_string(), format!("{BASE_URL}/")),
            ("Authorization-Anonymous".to_string(), self.anon_id.clone()),
            ("IsLoggedIn".to_string(), "false".to_string()),
        ]
    }

    fn build_channel(&self, _clientversion: ClientVersion) -> Channel {
        let category_titles = CURATED_HASHTAGS
            .iter()
            .map(|label| Self::title_case(label))
            .collect::<Vec<_>>();
        let category_options = CURATED_HASHTAGS
            .iter()
            .map(|label| FilterOption {
                id: label.to_string(),
                title: Self::title_case(label),
            })
            .collect::<Vec<_>>();

        Channel {
            id: CHANNEL_ID.to_string(),
            name: "FikFap".to_string(),
            description:
                "FikFap swipe-style short clips with direct HLS playback, hashtag browsing, and creator pages."
                    .to_string(),
            premium: false,
            favicon: "https://www.google.com/s2/favicons?sz=64&domain=fikfap.com".to_string(),
            status: "active".to_string(),
            categories: category_titles,
            options: vec![
                ChannelOption {
                    id: "sort".to_string(),
                    title: "Sort".to_string(),
                    description: "Browse FikFap by latest, trending, or a randomized For You feed."
                        .to_string(),
                    systemImage: "arrow.up.arrow.down".to_string(),
                    colorName: "blue".to_string(),
                    options: vec![
                        FilterOption {
                            id: "new".to_string(),
                            title: "Latest".to_string(),
                        },
                        FilterOption {
                            id: "trending".to_string(),
                            title: "Trending".to_string(),
                        },
                        FilterOption {
                            id: "random".to_string(),
                            title: "For You".to_string(),
                        },
                    ],
                    multiSelect: false,
                },
                ChannelOption {
                    id: "categories".to_string(),
                    title: "Hashtags".to_string(),
                    description: "Open a FikFap hashtag feed directly.".to_string(),
                    systemImage: "tag".to_string(),
                    colorName: "orange".to_string(),
                    options: category_options,
                    multiSelect: false,
                },
            ],
            nsfw: true,
            cacheDuration: Some(60),
            ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
        }
    }

    fn title_case(label: &str) -> String {
        let mut chars = label.chars();
        match chars.next() {
            Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
            None => String::new(),
        }
    }

    fn normalize_hashtag(value: &str) -> String {
        value
            .trim()
            .trim_start_matches('#')
            .to_ascii_lowercase()
            .replace(' ', "")
    }

    fn resolve_query_target(query: &str) -> Target {
        let trimmed = query.trim();

        if let Some((kind, value)) = trimmed.split_once(':') {
            let value = value.trim();
            if !value.is_empty() {
                match kind.trim().to_ascii_lowercase().as_str() {
                    "tag" | "hashtag" | "hash" => {
                        return Target::Hashtag(Self::normalize_hashtag(value));
                    }
                    "user" | "uploader" | "creator" => {
                        return Target::User(value.to_string());
                    }
                    _ => {}
                }
            }
        }

        if let Some(hashtag) = trimmed.strip_prefix('#') {
            if !hashtag.trim().is_empty() {
                return Target::Hashtag(Self::normalize_hashtag(hashtag));
            }
        }

        Target::Search(trimmed.to_string())
    }

    fn resolve_option_target(options: &ServerOptions) -> Option<Target> {
        if let Some(category) = options.categories.as_deref() {
            if category != "all" && !category.trim().is_empty() {
                return Some(Target::Hashtag(Self::normalize_hashtag(category)));
            }
        }
        None
    }

    fn pick_target(query: Option<&str>, sort: &str, options: &ServerOptions) -> Target {
        if let Some(query) = query {
            if !query.trim().is_empty() {
                return Self::resolve_query_target(query);
            }
        }

        if let Some(target) = Self::resolve_option_target(options) {
            return target;
        }

        Target::Feed(FeedSort::from_sort_id(sort))
    }

    fn parse_timestamp(value: &str) -> Option<u64> {
        let value = value.trim();
        if value.is_empty() {
            return None;
        }
        DateTime::parse_from_rfc3339(value)
            .ok()
            .map(|parsed| parsed.timestamp().max(0) as u64)
    }

    fn proxied_thumb(thumb_url: &str, options: &ServerOptions) -> Option<String> {
        let trimmed = thumb_url.trim();
        if trimmed.is_empty() {
            return None;
        }
        let stripped = strip_url_scheme(trimmed);
        Some(build_proxy_url(options, "fikfap-thumb", &stripped))
    }

    fn build_video_item(&self, post: Post, options: &ServerOptions) -> Option<VideoItem> {
        if post.video_stream_url.trim().is_empty() {
            return None;
        }

        let id = post.post_id.to_string();
        let title = if post.label.trim().is_empty() {
            format!("FikFap post {}", post.post_id)
        } else {
            post.label.trim().to_string()
        };
        let url = format!("{BASE_URL}/post/{}", post.post_id);
        let thumb = Self::proxied_thumb(&post.thumbnail_stream_url, options)
            .unwrap_or_else(|| post.thumbnail_stream_url.clone());
        let duration = post.duration.unwrap_or(0);

        // The CDN requires a Referer on every request in the HLS session (master
        // playlist, sub-playlists, and segments). Hot Tub clients apply a format's
        // `http_headers` to the whole HLS playback session, not just the initial
        // manifest fetch, so the documented `httpHeaders` mechanism is sufficient
        // here without needing to reverse-proxy the media ourselves.
        let format = VideoFormat::m3u8(
            post.video_stream_url.clone(),
            "auto".to_string(),
            "hls".to_string(),
        )
        .http_header("Referer".to_string(), format!("{BASE_URL}/"));

        let mut item = VideoItem::new(id, title, url, CHANNEL_ID.to_string(), thumb, duration);
        item.views = Some(post.views_count);
        item.uploadedAt = post
            .published_at
            .as_deref()
            .filter(|value| !value.trim().is_empty())
            .and_then(Self::parse_timestamp)
            .or_else(|| Self::parse_timestamp(&post.created_at));

        if !post.author.username.trim().is_empty() {
            item.uploader = Some(post.author.username.clone());
            item.uploaderUrl = Some(format!("{BASE_URL}/user/{}", post.author.username));
            item.uploaderId = Some(format!("{CHANNEL_ID}:{}", post.author.username));
        }
        item.verified = Some(post.author.is_verified);

        let tags = post
            .hashtags
            .iter()
            .map(|entry| entry.label.clone())
            .filter(|label| !label.trim().is_empty())
            .collect::<Vec<_>>();
        item.tags = (!tags.is_empty()).then_some(tags);
        item.formats = Some(vec![format]);

        Some(item)
    }

    async fn fetch_posts_page(
        &self,
        target_path: &str,
        sort: Option<&str>,
        after_id: Option<i64>,
        amount: usize,
        options: &ServerOptions,
    ) -> Result<Vec<Post>> {
        let mut url = format!("{API_BASE}/{target_path}?amount={amount}");
        if let Some(sort) = sort {
            url.push_str(&format!("&sort={sort}"));
        }
        if let Some(after_id) = after_id {
            url.push_str(&format!("&afterId={after_id}"));
        }

        let mut requester = requester_or_default(options, CHANNEL_ID, "fetch_posts_page");
        let text = requester
            .get_with_headers(&url, self.anon_headers(), None)
            .await
            .map_err(|error| Error::from(format!("request failed for {url}: {error}")))?;
        Ok(serde_json::from_str(&text)?)
    }

    async fn fetch_cursor_page(
        &self,
        target_path: &str,
        sort: Option<&str>,
        page: u16,
        per_page: usize,
        options: &ServerOptions,
    ) -> Result<Vec<Post>> {
        let page = page.max(1).min(MAX_PAGE_WALK);
        let mut after_id: Option<i64> = None;
        let mut items = Vec::new();

        for _ in 0..page {
            let batch = self
                .fetch_posts_page(target_path, sort, after_id, per_page, options)
                .await?;
            if batch.is_empty() {
                items = Vec::new();
                break;
            }
            after_id = batch.last().map(|post| post.post_id);
            items = batch;
        }

        Ok(items)
    }

    async fn fetch_search(&self, query: &str, options: &ServerOptions) -> Result<Vec<Post>> {
        let encoded_query: String = url::form_urlencoded::byte_serialize(query.as_bytes()).collect();
        let url = format!("{API_BASE}/search?q={encoded_query}");

        let mut requester = requester_or_default(options, CHANNEL_ID, "fetch_search");
        let text = requester
            .get_with_headers(&url, self.anon_headers(), None)
            .await
            .map_err(|error| Error::from(format!("request failed for {url}: {error}")))?;
        let response: SearchResponse = serde_json::from_str(&text)?;
        Ok(response.posts)
    }

    async fn fetch_target_posts(
        &self,
        target: &Target,
        page: u16,
        per_page: usize,
        options: &ServerOptions,
    ) -> Result<Vec<Post>> {
        match target {
            Target::Feed(feed_sort) => {
                self.fetch_cursor_page("posts", Some(feed_sort.api_value()), page, per_page, options)
                    .await
            }
            Target::Hashtag(label) => {
                let encoded = utf8_percent_encode(label, NON_ALPHANUMERIC).to_string();
                let path = format!("hashtags/label/{encoded}/posts");
                self.fetch_cursor_page(&path, Some("new"), page, per_page, options)
                    .await
            }
            Target::User(username) => {
                let encoded = utf8_percent_encode(username, NON_ALPHANUMERIC).to_string();
                let path = format!("profile/username/{encoded}/posts");
                self.fetch_cursor_page(&path, None, page, per_page, options)
                    .await
            }
            Target::Search(query) => {
                // FikFap's search endpoint returns a single fixed-size batch with no
                // cursor or page parameter, so later pages have nothing new to offer.
                if page > 1 {
                    Ok(Vec::new())
                } else {
                    self.fetch_search(query, options).await
                }
            }
        }
    }

    async fn fetch_target_items(
        &self,
        target: Target,
        page: u16,
        per_page: usize,
        options: &ServerOptions,
    ) -> Result<Vec<VideoItem>> {
        let posts = self.fetch_target_posts(&target, page, per_page, options).await?;
        Ok(posts
            .into_iter()
            .filter_map(|post| self.build_video_item(post, options))
            .collect())
    }

    async fn fetch_user_profile(
        &self,
        username: &str,
        options: &ServerOptions,
    ) -> Result<Option<UserProfile>> {
        let encoded = utf8_percent_encode(username, NON_ALPHANUMERIC).to_string();
        let url = format!("{API_BASE}/profile/username/{encoded}");

        let mut requester = requester_or_default(options, CHANNEL_ID, "fetch_user_profile");
        let text = match requester.get_with_headers(&url, self.anon_headers(), None).await {
            Ok(text) => text,
            Err(_) => return Ok(None),
        };

        match serde_json::from_str::<UserProfile>(&text) {
            Ok(profile) if !profile.username.trim().is_empty() => Ok(Some(profile)),
            _ => Ok(None),
        }
    }

    async fn build_uploader_profile(
        &self,
        username: &str,
        query: Option<&str>,
        profile_content: bool,
        options: &ServerOptions,
    ) -> Result<Option<UploaderProfile>> {
        let Some(profile) = self.fetch_user_profile(username, options).await? else {
            return Ok(None);
        };

        let canonical_id = format!("{CHANNEL_ID}:{}", profile.username);
        let mut videos = None;

        if profile_content {
            let items = self
                .fetch_target_items(Target::User(profile.username.clone()), 1, 24, options)
                .await?;

            let filtered_items = if let Some(query) = query.filter(|value| !value.trim().is_empty()) {
                let normalized_query = query.to_ascii_lowercase();
                items
                    .into_iter()
                    .filter(|item| {
                        let haystack = format!(
                            "{} {}",
                            item.title,
                            item.tags.as_ref().map(|values| values.join(" ")).unwrap_or_default()
                        )
                        .to_ascii_lowercase();
                        haystack.contains(&normalized_query)
                    })
                    .collect::<Vec<_>>()
            } else {
                items
            };

            let refs = filtered_items
                .iter()
                .map(|item| UploaderVideoRef::from_video_item(item, &profile.username, &canonical_id))
                .collect::<Vec<_>>();
            videos = Some(refs);
        }

        Ok(Some(UploaderProfile {
            id: canonical_id,
            name: profile.username.clone(),
            url: Some(format!("{BASE_URL}/user/{}", profile.username)),
            channel: Some(CHANNEL_ID.to_string()),
            verified: profile.is_verified,
            videoCount: profile.count_posts,
            totalViews: profile.count_total_views,
            channels: Some(vec![UploaderChannelStat {
                channel: CHANNEL_ID.to_string(),
                videoCount: profile.count_posts,
                firstSeenAt: None,
                lastSeenAt: None,
            }]),
            avatar: Self::proxied_thumb(&profile.thumbnail_url, options),
            description: profile.description.clone(),
            bio: profile.description,
            videos,
            tapes: Some(vec![]),
            playlists: Some(vec![]),
            layout: Some(vec![UploaderLayoutRow::videos(Some("Posts".to_string()))]),
        }))
    }
}

#[async_trait]
impl Provider for FikfapProvider {
    async fn get_videos(
        &self,
        cache: VideoCache,
        pool: DbPool,
        sort: String,
        query: Option<String>,
        page: String,
        per_page: String,
        options: ServerOptions,
    ) -> Vec<VideoItem> {
        let _ = cache;
        let _ = pool;

        let page = page.parse::<u16>().unwrap_or(1).max(1);
        let per_page = per_page
            .parse::<usize>()
            .unwrap_or(DEFAULT_PER_PAGE)
            .clamp(1, MAX_PER_PAGE);
        let normalized_query = query
            .as_deref()
            .map(str::trim)
            .filter(|value| !value.is_empty());
        let target = Self::pick_target(normalized_query, &sort, &options);

        match self.fetch_target_items(target, page, per_page, &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))
    }

    async fn get_uploader(
        &self,
        cache: VideoCache,
        pool: DbPool,
        uploader_id: Option<String>,
        uploader_name: Option<String>,
        query: Option<String>,
        profile_content: bool,
        options: ServerOptions,
    ) -> std::result::Result<Option<UploaderProfile>, String> {
        let _ = cache;
        let _ = pool;

        let username = uploader_id
            .as_deref()
            .and_then(|id| id.strip_prefix(&format!("{CHANNEL_ID}:")))
            .map(ToOwned::to_owned)
            .or(uploader_name.clone())
            .map(|value| value.trim().to_string())
            .filter(|value| !value.is_empty());

        let Some(username) = username else {
            return Ok(None);
        };

        self.build_uploader_profile(&username, query.as_deref(), profile_content, &options)
            .await
            .map_err(|error| error.to_string())
    }
}

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

    #[test]
    fn resolves_query_shortcuts() {
        match FikfapProvider::resolve_query_target("tag:Big Tits") {
            Target::Hashtag(label) => assert_eq!(label, "bigtits"),
            other => panic!("expected hashtag target, got {other:?}"),
        }

        match FikfapProvider::resolve_query_target("#blonde") {
            Target::Hashtag(label) => assert_eq!(label, "blonde"),
            other => panic!("expected hashtag target, got {other:?}"),
        }

        match FikfapProvider::resolve_query_target("user:AdultPrime") {
            Target::User(username) => assert_eq!(username, "AdultPrime"),
            other => panic!("expected user target, got {other:?}"),
        }

        match FikfapProvider::resolve_query_target("blonde teen") {
            Target::Search(query) => assert_eq!(query, "blonde teen"),
            other => panic!("expected search target, got {other:?}"),
        }
    }

    #[test]
    fn maps_sort_ids() {
        assert_eq!(FeedSort::from_sort_id("new"), FeedSort::New);
        assert_eq!(FeedSort::from_sort_id("trending"), FeedSort::Trending);
        assert_eq!(FeedSort::from_sort_id("random"), FeedSort::Random);
        assert_eq!(FeedSort::from_sort_id("unknown"), FeedSort::New);
    }

    #[test]
    fn parses_post_payload() {
        let json = r#"{
            "postId": 123,
            "label": "Test post",
            "viewsCount": 42,
            "duration": 12,
            "videoStreamUrl": "https://vz-x.b-cdn.net/abc/playlist.m3u8",
            "thumbnailStreamUrl": "https://vz-x.b-cdn.net/abc/thumbnail.jpg",
            "publishedAt": "2026-01-01T00:00:00.000Z",
            "createdAt": "2026-01-01T00:00:00.000Z",
            "author": {
                "username": "creator",
                "isVerified": true,
                "countPosts": 10,
                "countTotalViews": 100,
                "thumbnailUrl": "https://example.com/avatar.jpg",
                "description": null
            },
            "hashtags": [{"label": "blonde"}]
        }"#;

        let post: Post = serde_json::from_str(json).expect("parses");
        assert_eq!(post.post_id, 123);
        assert_eq!(post.author.username, "creator");
        assert_eq!(post.hashtags.len(), 1);
    }
}
