/*
    This file is part of jellything (https://codeberg.org/metamuffin/jellything)
    which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
    Copyright (C) 2026 metamuffin <metamuffin.org>
*/
use crate::{
    USER_AGENT,
    helpers::get_or_insert_slug,
    plugins::{ImportPlugin, PluginContext, PluginInfo},
    source_rank::ObjectImportSourceExt,
};
use anyhow::{Context, Result, anyhow, bail};
use jellycache::{Cache, HashKey};
use jellycommon::{
    jellyobject::{EMPTY, OBB, Tag},
    *,
};
use jellydb::RowNum;
use log::info;
use reqwest::{
    Client, ClientBuilder,
    header::{HeaderMap, HeaderName, HeaderValue},
};
use serde::{Deserialize, Serialize};
use std::{collections::BTreeMap, fmt::Display, sync::Arc};
use tokio::runtime::Handle;

pub struct Trakt {
    client: Client,
}

impl Trakt {
    pub fn new(api_key: &str) -> Self {
        let client = ClientBuilder::new()
            .default_headers(HeaderMap::from_iter([
                (
                    HeaderName::from_static("trakt-api-key"),
                    HeaderValue::from_str(api_key).unwrap(),
                ),
                (
                    HeaderName::from_static("trakt-api-version"),
                    HeaderValue::from_static("2"),
                ),
                (
                    HeaderName::from_static("content-type"),
                    HeaderValue::from_static("application/json"),
                ),
                (
                    HeaderName::from_static("user-agent"),
                    HeaderValue::from_static(USER_AGENT),
                ),
            ]))
            .build()
            .unwrap();
        Self { client }
    }

    pub fn search(
        &self,
        cache: &Cache,
        kinds: &[TraktKind],
        query: &str,
        rt: &Handle,
    ) -> Result<Arc<Vec<TraktSearchResult>>> {
        cache
            .cache_memory(
                &format!("ext/trakt/search/{}.json", HashKey(query)),
                move || {
                    rt.block_on(async {
                        let url = format!(
                            "https://api.trakt.tv/search/{}?query={}&extended=full",
                            kinds
                                .iter()
                                .map(|t| t.singular())
                                .collect::<Vec<_>>()
                                .join(","),
                            urlencoding::encode(query),
                        );
                        let res = self.client.get(url).send().await?.error_for_status()?;
                        Ok(res.json().await?)
                    })
                },
            )
            .context("trakt search")
    }

    pub fn lookup(
        &self,
        cache: &Cache,
        kind: TraktKind,
        id: u64,
        rt: &Handle,
    ) -> Result<Arc<TraktMediaObject>> {
        cache
            .cache_memory(&format!("ext/trakt/lookup/{kind}-{id}.json"), move || {
                rt.block_on(async {
                    info!("trakt lookup {kind:?}:{id:?}");
                    let url = format!("https://api.trakt.tv/{}/{id}?extended=full", kind.plural());
                    let res = self.client.get(url).send().await?.error_for_status()?;
                    Ok(res.json().await?)
                })
            })
            .context("trakt lookup")
    }

    pub fn people(
        &self,
        cache: &Cache,
        kind: TraktKind,
        id: u64,
        rt: &Handle,
    ) -> Result<Arc<TraktPeople>> {
        cache
            .cache_memory(&format!("ext/trakt/people/{kind}-{id}.json"), move || {
                rt.block_on(async {
                    info!("trakt people {kind:?}:{id:?}");
                    let url = format!(
                        "https://api.trakt.tv/{}/{id}/people?extended=full",
                        kind.plural()
                    );
                    let res = self.client.get(url).send().await?.error_for_status()?;
                    Ok(res.json().await?)
                })
            })
            .context("trakt people")
    }

    pub fn show_seasons(
        &self,
        cache: &Cache,
        id: u64,
        rt: &Handle,
    ) -> Result<Arc<Vec<TraktSeason>>> {
        cache
            .cache_memory(&format!("ext/trakt/seasons/{id}.json"), move || {
                rt.block_on(async {
                    info!("trakt seasons {id:?}");
                    let url = format!("https://api.trakt.tv/shows/{id}/seasons?extended=full");
                    let res = self.client.get(url).send().await?.error_for_status()?;
                    Ok(res.json().await?)
                })
            })
            .context("trakt show seasons")
    }

    pub fn show_season_episodes(
        &self,
        cache: &Cache,
        id: u64,
        season: u64,
        rt: &Handle,
    ) -> Result<Arc<Vec<TraktEpisode>>> {
        cache
            .cache_memory(
                &format!("ext/trakt/episodes/{id}-S{season}.json"),
                move || {
                    rt.block_on(async {
                        info!("trakt episodes {id:?} season={season}");
                        let url = format!(
                            "https://api.trakt.tv/shows/{id}/seasons/{season}?extended=full"
                        );
                        let res = self.client.get(url).send().await?.error_for_status()?;
                        Ok(res.json().await?)
                    })
                },
            )
            .context("trakt show season episodes")
    }
}

#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct TraktSeason {
    pub number: u64,
    pub ids: TraktIds,
    pub rating: f64,
    pub votes: usize,
    pub episode_count: usize,
    pub aired_count: Option<usize>,
    pub title: String,
    pub overview: Option<String>,
    pub network: Option<String>,
}

#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct TraktEpisode {
    pub season: Option<usize>,
    pub number: u64,
    pub number_abs: Option<usize>,
    pub ids: TraktIds,
    pub rating: Option<f64>,
    pub votes: usize,
    pub title: String,
    pub runtime: Option<f64>,
    pub overview: Option<String>,
    pub available_translations: Vec<String>,
    pub first_aired: Option<String>,
    pub episode_type: String,
}

#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct TraktPeople {
    #[serde(default)]
    pub cast: Vec<TraktAppearance>,
    #[serde(default)]
    pub crew: BTreeMap<TraktPeopleGroup, Vec<TraktAppearance>>,
}

#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct TraktAppearance {
    #[serde(default)]
    pub jobs: Vec<String>,
    #[serde(default)]
    pub characters: Vec<String>,
    pub person: TraktPerson,
}

#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct TraktPerson {
    pub name: String,
    pub ids: TraktIds,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct TraktSearchResult {
    pub r#type: TraktKind,
    pub score: f64,
    #[serde(flatten)]
    pub inner: TraktKindObject,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TraktKindObject {
    Movie(TraktMediaObject),
    Show(TraktMediaObject),
    Season(TraktMediaObject),
    Episode(TraktMediaObject),
    Person(TraktMediaObject),
    User(TraktMediaObject),
}

impl TraktKindObject {
    pub fn inner(&self) -> &TraktMediaObject {
        match self {
            TraktKindObject::Movie(x)
            | TraktKindObject::Show(x)
            | TraktKindObject::Season(x)
            | TraktKindObject::Episode(x)
            | TraktKindObject::Person(x)
            | TraktKindObject::User(x) => x,
        }
    }
}

#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Clone, Copy)]
pub enum TraktPeopleGroup {
    #[serde(rename = "production")]
    Production,
    #[serde(rename = "art")]
    Art,
    #[serde(rename = "crew")]
    Crew,
    #[serde(rename = "costume & make-up")]
    CostumeMakeup,
    #[serde(rename = "directing")]
    Directing,
    #[serde(rename = "writing")]
    Writing,
    #[serde(rename = "sound")]
    Sound,
    #[serde(rename = "camera")]
    Camera,
    #[serde(rename = "visual effects")]
    VisualEffects,
    #[serde(rename = "lighting")]
    Lighting,
    #[serde(rename = "editing")]
    Editing,
    #[serde(rename = "created by")]
    CreatedBy,
}
impl TraktPeopleGroup {
    pub fn as_credit_category(self) -> Tag {
        match self {
            TraktPeopleGroup::Production => CRCAT_PRODUCTION,
            TraktPeopleGroup::Art => CRCAT_ART,
            TraktPeopleGroup::Crew => CRCAT_CREW,
            TraktPeopleGroup::CostumeMakeup => CRCAT_COSTUME_MAKEUP,
            TraktPeopleGroup::Directing => CRCAT_DIRECTING,
            TraktPeopleGroup::Writing => CRCAT_WRITING,
            TraktPeopleGroup::Sound => CRCAT_SOUND,
            TraktPeopleGroup::Camera => CRCAT_CAMERA,
            TraktPeopleGroup::VisualEffects => CRCAT_VFX,
            TraktPeopleGroup::Lighting => CRCAT_LIGHTING,
            TraktPeopleGroup::Editing => CRCAT_EDITING,
            TraktPeopleGroup::CreatedBy => CRCAT_CREATED_BY,
        }
    }
}

#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct TraktMediaObject {
    pub title: String,
    pub year: Option<u32>,
    pub ids: TraktIds,

    pub tagline: Option<String>,
    pub overview: Option<String>,
    pub released: Option<String>,
    pub runtime: Option<usize>,
    pub country: Option<String>,
    pub trailer: Option<String>,
    pub homepage: Option<String>,
    pub status: Option<String>,
    pub rating: Option<f64>,
    pub votes: Option<usize>,
    pub comment_count: Option<usize>,
    pub language: Option<String>,
    pub available_translations: Option<Vec<String>>,
    pub genres: Option<Vec<String>>,
}

#[derive(Debug, Serialize, Deserialize, Clone, Default)]
pub struct TraktIds {
    pub trakt: Option<u64>,
    pub slug: Option<String>,
    pub tvdb: Option<u64>,
    pub imdb: Option<String>,
    pub tmdb: Option<u64>,
}

impl Display for TraktSearchResult {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_fmt(format_args!(
            "{} ({}) \x1b[2m{} {}\x1b[0m",
            self.inner.inner().title,
            self.inner.inner().year.unwrap_or(0),
            self.r#type,
            self.inner.inner().ids.trakt.unwrap_or_default()
        ))
    }
}

#[derive(Debug, Serialize, Deserialize, Clone, Copy, Hash, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum TraktKind {
    Movie,
    Show,
    Season,
    Episode,
    Person,
    User,
}

impl TraktKind {
    pub fn as_node_kind(self) -> Tag {
        use jellycommon::*;
        match self {
            TraktKind::Movie => KIND_MOVIE,
            TraktKind::Show => KIND_SHOW,
            TraktKind::Season => KIND_SEASON,
            TraktKind::Episode => KIND_EPISODE,
            TraktKind::Person => KIND_CHANNEL,
            TraktKind::User => KIND_CHANNEL,
        }
    }
}

impl TraktKind {
    pub fn singular(self) -> &'static str {
        match self {
            TraktKind::Movie => "movie",
            TraktKind::Show => "show",
            TraktKind::Season => "season",
            TraktKind::Episode => "episode",
            TraktKind::Person => "person",
            TraktKind::User => "user",
        }
    }
    pub fn plural(self) -> &'static str {
        match self {
            TraktKind::Movie => "movies",
            TraktKind::Show => "shows",
            TraktKind::Season => "seasons",
            TraktKind::Episode => "episodes",
            TraktKind::Person => "people",
            TraktKind::User => "users", // not used in API
        }
    }
}
impl Display for TraktKind {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(match self {
            TraktKind::Movie => "Movie",
            TraktKind::Show => "Show",
            TraktKind::Season => "Season",
            TraktKind::Episode => "Episode",
            TraktKind::Person => "Person",
            TraktKind::User => "User",
        })
    }
}

impl ImportPlugin for Trakt {
    fn info(&self) -> PluginInfo {
        PluginInfo {
            name: "trakt",
            tag: MSOURCE_TRAKT,
            handle_instruction: true,
            handle_process: true,
            ..Default::default()
        }
    }
    fn instruction(&self, ct: &PluginContext, node: RowNum, line: &str) -> Result<()> {
        use jellycommon::*;
        if let Some(value) = line.strip_prefix("trakt-").or(line.strip_prefix("trakt=")) {
            let (ty, id) = value.split_once(":").unwrap_or(("movie", value));
            let ty = match ty {
                "movie" => IDENT_TRAKT_MOVIE,
                "show" => IDENT_TRAKT_SHOW,
                "season" => IDENT_TRAKT_SEASON,
                "episode" => IDENT_TRAKT_EPISODE,
                _ => bail!("unknown trakt kind"),
            };
            ct.ic.update_node(node, |node| {
                node.update(NO_IDENTIFIERS, |idents| idents.insert(ty, id))
            })?;
        }
        Ok(())
    }
    fn process(&self, ct: &PluginContext, node: RowNum) -> Result<()> {
        self.process_primary(ct, node)?;
        self.process_episode(ct, node)?;
        Ok(())
    }
}

impl Trakt {
    fn process_primary(&self, ct: &PluginContext, node_row: RowNum) -> Result<()> {
        let data = ct.ic.get_node(node_row)?.unwrap();
        let (trakt_kind, trakt_id): (_, u64) = if let Some(id) = data
            .get(NO_IDENTIFIERS)
            .unwrap_or(EMPTY)
            .get(IDENT_TRAKT_SHOW)
        {
            (TraktKind::Show, id.parse()?)
        } else if let Some(id) = data
            .get(NO_IDENTIFIERS)
            .unwrap_or(EMPTY)
            .get(IDENT_TRAKT_MOVIE)
        {
            (TraktKind::Movie, id.parse()?)
        } else {
            return Ok(());
        };

        let details = self.lookup(&ct.ic.cache, trakt_kind, trakt_id, ct.rt)?;
        let people = self.people(&ct.ic.cache, trakt_kind, trakt_id, ct.rt)?;

        let mut appearances = Vec::new();
        for p in people.cast.iter() {
            appearances.push((CRCAT_CAST, p.to_owned()));
        }
        for (group, people) in people.crew.iter() {
            for p in people {
                appearances.push((group.as_credit_category(), p.to_owned()));
            }
        }

        ct.ic.db.transaction(&mut |txn| {
            let mut node = txn.get(node_row)?.unwrap();

            node = node.insert_s(ct.is, NO_KIND, trakt_kind.as_node_kind());
            node = node.insert_s(ct.is, NO_TITLE, &details.title);
            if let Some(overview) = &details.overview {
                node = node.insert_s(ct.is, NO_DESCRIPTION, &overview);
            }
            if let Some(tagline) = &details.tagline {
                node = node.insert_s(ct.is, NO_TAGLINE, &tagline);
            }
            if let Some(x) = details.ids.imdb.clone() {
                node = node.update(NO_IDENTIFIERS, |idents| {
                    idents.insert_s(ct.is, IDENT_IMDB, &x)
                });
            }
            if let Some(x) = details.ids.tvdb.clone() {
                node = node.update(NO_IDENTIFIERS, |idents| {
                    idents.insert_s(ct.is, IDENT_TVDB, &x.to_string())
                });
            }
            if let Some(x) = details.ids.tmdb.clone() {
                if let Some(key) = match trakt_kind {
                    TraktKind::Movie => Some(IDENT_TMDB_MOVIE),
                    TraktKind::Show => Some(IDENT_TMDB_SERIES),
                    _ => None,
                } {
                    node = node.update(NO_IDENTIFIERS, |idents| idents.insert(key, &x.to_string()));
                };
            }
            if let Some(rating) = details.rating {
                node = node.update(NO_RATINGS, |idents| idents.insert(RTYP_TRAKT, rating));
            }

            let mut credits = Vec::new();
            for (crcat, ap) in &appearances {
                if let Some(traktid) = ap.person.ids.trakt {
                    let slug = format!("trakt-person-{traktid}");
                    let role = ap
                        .characters
                        .iter()
                        .cloned()
                        .chain(ap.jobs.iter().cloned())
                        .collect::<Vec<String>>()
                        .join(", ");

                    let row = get_or_insert_slug(txn, &slug)?;

                    let mut c = txn.get(row)?.unwrap();
                    c = c.insert_s(ct.is, NO_KIND, KIND_PERSON);
                    c = c.insert_s(ct.is, NO_VISIBILITY, VISI_VISIBLE);
                    c = c.insert_s(ct.is, NO_TITLE, &ap.person.name);
                    c = c.update(NO_IDENTIFIERS, |ids| {
                        let mut ids = ids.insert(IDENT_TRAKT_PERSON, &traktid.to_string());
                        if let Some(tmdbid) = ap.person.ids.tmdb {
                            ids = ids.insert_s(ct.is, IDENT_TMDB_PERSON, &tmdbid.to_string());
                        }
                        if let Some(imdbid) = &ap.person.ids.imdb {
                            ids = ids.insert_s(ct.is, IDENT_IMDB_PERSON, imdbid);
                        }
                        ids
                    });

                    txn.update(row, c)?;

                    credits.push(
                        OBB::new()
                            .with(CR_KIND, *crcat)
                            .with(CR_ROLE, &role)
                            .with(CR_NODE, row)
                            .finish(),
                    );

                    ct.pending_nodes.lock().unwrap().insert(row);
                }
            }

            node = node.extend_object(NO_CREDIT, CR_NODE.0, &credits);

            txn.update(node_row, node)?;
            Ok(())
        })?;
        Ok(())
    }
    fn process_episode(&self, ct: &PluginContext, node: RowNum) -> Result<()> {
        let node_data = ct.ic.get_node(node)?.unwrap();

        let (Some(episode), Some(season)) =
            (node_data.get(NO_INDEX), node_data.get(NO_SEASON_INDEX))
        else {
            return Ok(());
        };
        let mut show_id = None;
        ct.ic.db.transaction(&mut |txn| {
            for parent in node_data.iter(NO_PARENT) {
                let parent_data = txn.get(parent)?.ok_or(anyhow!("parent missing"))?;
                if let Some(id) = parent_data
                    .get(NO_IDENTIFIERS)
                    .unwrap_or(EMPTY)
                    .get(IDENT_TRAKT_SHOW)
                {
                    show_id = Some(id.parse::<u64>()?);
                    break;
                }
            }
            Ok(())
        })?;
        let Some(show_id) = show_id else {
            return Ok(());
        };

        let seasons = self.show_seasons(&ct.ic.cache, show_id, ct.rt)?;
        if seasons.iter().any(|x| x.number == season) {
            let episodes = self.show_season_episodes(&ct.ic.cache, show_id, season, ct.rt)?;
            if let Some(episode) = episodes.get(episode.saturating_sub(1) as usize) {
                ct.ic.update_node(node, |mut node| {
                    node = node.insert_s(ct.is, NO_KIND, KIND_EPISODE);
                    node = node.insert_s(ct.is, NO_INDEX, episode.number);
                    node = node.insert_s(ct.is, NO_TITLE, &episode.title);
                    if let Some(overview) = &episode.overview {
                        node = node.insert_s(ct.is, NO_DESCRIPTION, &overview);
                    }
                    if let Some(r) = episode.rating {
                        node = node.update(NO_RATINGS, |rats| rats.insert_s(ct.is, RTYP_TRAKT, r));
                    }
                    node
                })?;
            }
        }
        Ok(())
    }
}
