//! Where a viewer is in a TV series.
//!
//! This is domain policy, not presentation: it encodes what Jellyfin's user-data
//! means ("in progress", "played") and what Jellyfin's season numbering means
//! (season 0 is specials). The frontend asks for *the* current episode and
//! renders it; it does not get to decide what "current" means.
//!
//! Split into a pure half (`pick_current_episode`, `sort_series_order`) and an
//! I/O half (`fetch_series_episodes`, `resolve_current_episode`) so the policy
//! can be unit-tested without standing up a repository.
//!
//! TRACES: UR-062 | DR-101

use super::{GetItemsOptions, MediaItem, MediaRepository, RepoError};

/// Jellyfin files specials under season 0.
const SPECIALS_SEASON: i32 = 0;

/// Below this fraction watched, a position is a false start rather than
/// progress — the same threshold the resume dialog uses.
const MIN_PROGRESS_FRACTION: f64 = 0.01;

/// Above this fraction watched, an episode is effectively finished; resuming it
/// would drop the viewer into the closing credits.
const MAX_PROGRESS_FRACTION: f64 = 0.95;

/// Sort key for a season number. Specials sort *after* every numbered season:
/// a viewer works through S1, S2, … and only then the extras, so season 0 must
/// not lead just because `0 < 1`.
fn season_rank(season: Option<i32>) -> i64 {
    match season {
        Some(SPECIALS_SEASON) => i64::MAX,
        Some(n) => n as i64,
        None => i64::MAX - 1,
    }
}

/// Order episodes as the series is watched: season ascending, then episode,
/// specials last.
pub fn sort_series_order(episodes: &mut [MediaItem]) {
    episodes.sort_by(|a, b| {
        season_rank(a.parent_index_number)
            .cmp(&season_rank(b.parent_index_number))
            .then(
                a.index_number
                    .unwrap_or(0)
                    .cmp(&b.index_number.unwrap_or(0)),
            )
    });
}

/// Is this episode genuinely part-watched (not a false start, not finished)?
fn is_in_progress(item: &MediaItem) -> bool {
    let Some(user_data) = item.user_data.as_ref() else {
        return false;
    };
    if user_data.is_played.unwrap_or(false) {
        return false;
    }

    let position_ms = user_data
        .playback_position_ms
        .or_else(|| user_data.playback_position_ticks.map(|t| t / 10_000))
        .unwrap_or(0);
    if position_ms <= 0 {
        return false;
    }

    // Without a duration we cannot tell "2 minutes in" from "2 minutes left",
    // so any recorded position counts as progress.
    let Some(duration_ms) = item.duration_ms.filter(|d| *d > 0) else {
        return true;
    };

    let fraction = position_ms as f64 / duration_ms as f64;
    (MIN_PROGRESS_FRACTION..MAX_PROGRESS_FRACTION).contains(&fraction)
}

fn is_played(item: &MediaItem) -> bool {
    item.user_data
        .as_ref()
        .and_then(|u| u.is_played)
        .unwrap_or(false)
}

fn belongs_to_series(item: &MediaItem, series_id: &str) -> bool {
    item.series_id.as_deref() == Some(series_id)
}

/// The episode a viewer should land on when they open `series_id`.
///
/// Order of preference, and why:
///
/// 1. **An episode in progress.** That is literally where playback stopped;
///    Next Up would skip past it. On a tie the earliest in series order wins, so
///    a viewer who dipped into a later episode still returns to the one they are
///    working through.
/// 2. **The server's Next Up** for this series — it accounts for watch history
///    we do not cache locally.
/// 3. **The episode after the furthest-watched one**, falling back to the first
///    unwatched episode when nothing has been watched or the series is finished.
///    This is the offline path: `OfflineRepository::get_next_up_episodes`
///    returns an empty vec, so without this rung the whole feature would be
///    online-only. It deliberately does *not* return the first unwatched
///    episode outright — an unwatched episode behind the viewer's furthest
///    point was skipped on purpose, and sending them back to it is the bug
///    DR-101 was reopened for.
/// 4. **The first episode**, so a never-watched series opens on its premiere
///    rather than on nothing.
///
/// `next_up` / `resume` entries are honoured even when absent from `episodes`
/// (the season fan-out can miss an id the server returns), but only when they
/// belong to this series.
pub fn pick_current_episode(
    series_id: &str,
    episodes: &[MediaItem],
    next_up: &[MediaItem],
    resume: &[MediaItem],
) -> Option<MediaItem> {
    // 1. In progress — prefer a match inside the ordered episode list so the
    //    "earliest in series order" tie-break is meaningful; fall back to the
    //    resume feed for an episode the fan-out missed.
    if let Some(found) = episodes.iter().find(|e| is_in_progress(e)) {
        return Some(found.clone());
    }
    if let Some(found) = resume
        .iter()
        .find(|e| belongs_to_series(e, series_id) && is_in_progress(e))
    {
        return Some(found.clone());
    }

    // 2. Next Up for this series.
    if let Some(found) = next_up
        .iter()
        .find(|e| e.series_id.is_none() || belongs_to_series(e, series_id))
    {
        // Prefer the copy from `episodes` when we have one: it carries the
        // user-data and images the list already fetched.
        let matched = episodes.iter().find(|e| e.id == found.id);
        return Some(matched.unwrap_or(found).clone());
    }

    // 3. The episode after the furthest-watched one. Not simply the first
    //    unwatched: a viewer who skipped the pilot but is deep into season 3
    //    must not be dragged back to S1E1. An earlier gap is a deliberate skip;
    //    where they stopped is the *last* thing they watched.
    if let Some(furthest) = episodes.iter().rposition(is_played) {
        if let Some(found) = episodes.get(furthest + 1) {
            return Some(found.clone());
        }
    }

    // Nothing watched yet (or the furthest-watched episode is the finale):
    // the first unwatched episode in series order.
    if let Some(found) = episodes.iter().find(|e| !is_played(e)) {
        return Some(found.clone());
    }

    // 4. First episode — a fully-watched series reopens at the start.
    episodes.first().cloned()
}

/// Every episode of a series, in series order.
///
/// Jellyfin hangs episodes off season folders, except for "flat" series whose
/// children are episodes directly. Both shapes are provider vocabulary, so the
/// fan-out and the fallback live here rather than in the frontend.
pub async fn fetch_series_episodes(
    repo: &dyn MediaRepository,
    series_id: &str,
) -> Result<Vec<MediaItem>, RepoError> {
    let children = repo.get_items(series_id, list_options()).await?;

    let mut episodes: Vec<MediaItem> = Vec::new();
    for season in children.items.iter().filter(|i| is_season(i)) {
        // One failing season must not blank the whole show.
        match repo.get_items(&season.id, list_options()).await {
            Ok(result) => episodes.extend(result.items.into_iter().filter(is_episode)),
            Err(e) => {
                log::warn!(
                    "[series] season {} of {} failed to load: {:?}",
                    season.id,
                    series_id,
                    e
                );
            }
        }
    }

    // Flat series: the children *are* the episodes.
    if episodes.is_empty() {
        episodes.extend(children.items.into_iter().filter(is_episode));
    }

    sort_series_order(&mut episodes);
    Ok(episodes)
}

/// Resolve the current episode, fetching everything the policy needs.
///
/// Next Up and resume are best-effort: offline they fail or come back empty, and
/// `pick_current_episode` has fallbacks for exactly that.
pub async fn resolve_current_episode(
    repo: &dyn MediaRepository,
    series_id: &str,
) -> Result<Option<MediaItem>, RepoError> {
    let episodes = fetch_series_episodes(repo, series_id).await?;

    let next_up = repo
        .get_next_up_episodes(Some(series_id), Some(1))
        .await
        .unwrap_or_default();
    let resume = repo
        .get_resume_items(Some(series_id), Some(10))
        .await
        .unwrap_or_default();

    Ok(pick_current_episode(
        series_id, &episodes, &next_up, &resume,
    ))
}

fn list_options() -> Option<GetItemsOptions> {
    Some(GetItemsOptions {
        limit: Some(500),
        ..Default::default()
    })
}

fn is_season(item: &MediaItem) -> bool {
    item.item_type == "Season" || matches!(item.kind, crate::domain::MediaKind::Season)
}

fn is_episode(item: &MediaItem) -> bool {
    item.item_type == "Episode" || matches!(item.kind, crate::domain::MediaKind::Episode)
}

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

    const SERIES: &str = "series-1";

    fn episode(id: &str, season: i32, number: i32) -> MediaItem {
        MediaItem {
            id: id.to_string(),
            name: format!("S{season}E{number}"),
            item_type: "Episode".to_string(),
            series_id: Some(SERIES.to_string()),
            parent_index_number: Some(season),
            index_number: Some(number),
            duration_ms: Some(1_000_000),
            ..Default::default()
        }
    }

    fn watched(mut item: MediaItem) -> MediaItem {
        item.user_data = Some(UserData {
            is_played: Some(true),
            ..Default::default()
        });
        item
    }

    fn in_progress(mut item: MediaItem, fraction: f64) -> MediaItem {
        let duration = item.duration_ms.unwrap_or(1_000_000) as f64;
        item.user_data = Some(UserData {
            is_played: Some(false),
            playback_position_ms: Some((duration * fraction) as i64),
            ..Default::default()
        });
        item
    }

    fn season(n: i32, count: i32) -> Vec<MediaItem> {
        (1..=count)
            .map(|i| episode(&format!("s{n}e{i}"), n, i))
            .collect()
    }

    #[test]
    fn sorts_by_season_then_episode() {
        let mut eps = vec![
            episode("b", 2, 1),
            episode("d", 1, 10),
            episode("a", 1, 2),
            episode("c", 2, 2),
        ];
        sort_series_order(&mut eps);
        let ids: Vec<&str> = eps.iter().map(|e| e.id.as_str()).collect();
        assert_eq!(ids, ["a", "d", "b", "c"]);
    }

    #[test]
    fn sorts_specials_after_numbered_seasons() {
        let mut eps = vec![episode("special", 0, 1), episode("premiere", 1, 1)];
        sort_series_order(&mut eps);
        let ids: Vec<&str> = eps.iter().map(|e| e.id.as_str()).collect();
        assert_eq!(ids, ["premiere", "special"]);
    }

    #[test]
    fn picks_the_in_progress_episode_over_next_up() {
        let mut eps = season(1, 5);
        eps[0] = watched(eps[0].clone());
        eps[1] = in_progress(eps[1].clone(), 0.4);
        // The server would send us past it; the half-watched episode wins.
        let next_up = vec![episode("s1e3", 1, 3)];

        let current = pick_current_episode(SERIES, &eps, &next_up, &[]).unwrap();
        assert_eq!(current.id, "s1e2");
    }

    #[test]
    fn picks_the_earliest_in_progress_episode() {
        let mut eps = season(1, 5);
        eps[1] = in_progress(eps[1].clone(), 0.3);
        eps[3] = in_progress(eps[3].clone(), 0.5);

        let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
        assert_eq!(current.id, "s1e2");
    }

    #[test]
    fn ignores_a_false_start_and_a_finished_episode() {
        let mut eps = season(1, 5);
        eps[0] = watched(eps[0].clone());
        eps[1] = in_progress(eps[1].clone(), 0.001); // barely started
        eps[2] = in_progress(eps[2].clone(), 0.99); // effectively over

        // Neither counts as progress, so Next Up decides.
        let next_up = vec![episode("s1e4", 1, 4)];
        let current = pick_current_episode(SERIES, &eps, &next_up, &[]).unwrap();
        assert_eq!(current.id, "s1e4");
    }

    #[test]
    fn falls_back_to_next_up_when_nothing_is_in_progress() {
        let eps = season(1, 5);
        let next_up = vec![episode("s1e3", 1, 3)];

        let current = pick_current_episode(SERIES, &eps, &next_up, &[]).unwrap();
        assert_eq!(current.id, "s1e3");
    }

    #[test]
    fn next_up_from_another_series_is_ignored() {
        let eps = season(1, 3);
        let mut foreign = episode("other-show-ep", 1, 1);
        foreign.series_id = Some("series-2".to_string());

        let current = pick_current_episode(SERIES, &eps, &[foreign], &[]).unwrap();
        assert_eq!(current.id, "s1e1");
    }

    /// The offline path: `OfflineRepository::get_next_up_episodes` returns an
    /// empty vec, so the first unwatched episode has to carry the feature.
    #[test]
    fn falls_back_to_first_unwatched_when_next_up_is_empty() {
        let mut eps = [season(1, 3), season(2, 3)].concat();
        for ep in eps.iter_mut().take(4) {
            *ep = watched(ep.clone());
        }

        let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
        assert_eq!(current.id, "s2e2");
    }

    /// A viewer deep in season 3 who never watched the pilot must not be sent
    /// back to it: the gap was a skip, not the place they stopped.
    #[test]
    fn resumes_after_the_furthest_watched_episode_not_the_first_gap() {
        let mut eps = [season(1, 4), season(2, 4), season(3, 4)].concat();
        for ep in eps.iter_mut() {
            // Everything through S3E3 watched, except the never-watched pilot.
            let watched_through = ep.parent_index_number < Some(3) || ep.index_number <= Some(3);
            if watched_through && ep.id != "s1e1" {
                *ep = watched(ep.clone());
            }
        }

        let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
        assert_eq!(current.id, "s3e4");
    }

    /// The furthest-watched episode being a finale must still roll into the
    /// next season rather than stopping the series.
    #[test]
    fn resumes_into_the_next_season_after_a_skipped_earlier_episode() {
        let mut eps = [season(1, 3), season(2, 3)].concat();
        for ep in eps.iter_mut() {
            if ep.parent_index_number == Some(1) && ep.id != "s1e1" {
                *ep = watched(ep.clone());
            }
        }

        let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
        assert_eq!(current.id, "s2e1");
    }

    /// Specials sort last, so watching one must not mark the series finished
    /// while numbered episodes remain.
    #[test]
    fn a_watched_special_does_not_end_the_series() {
        let mut eps = [season(1, 3), vec![episode("s0e1", 0, 1)]].concat();
        sort_series_order(&mut eps);
        for ep in eps.iter_mut() {
            if ep.id == "s1e1" || ep.id == "s0e1" {
                *ep = watched(ep.clone());
            }
        }

        let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
        assert_eq!(current.id, "s1e2");
    }

    #[test]
    fn crosses_a_season_boundary_when_a_season_is_finished() {
        let mut eps = [season(1, 3), season(2, 3)].concat();
        for ep in eps.iter_mut().take(3) {
            *ep = watched(ep.clone());
        }

        let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
        assert_eq!(current.id, "s2e1");
    }

    #[test]
    fn a_never_watched_series_opens_on_its_premiere() {
        let eps = [season(2, 3), season(1, 3)].concat();
        let mut ordered = eps.clone();
        sort_series_order(&mut ordered);

        let current = pick_current_episode(SERIES, &ordered, &[], &[]).unwrap();
        assert_eq!(current.id, "s1e1");
    }

    #[test]
    fn a_fully_watched_series_reopens_at_the_start() {
        let eps: Vec<MediaItem> = season(1, 3).into_iter().map(watched).collect();

        let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
        assert_eq!(current.id, "s1e1");
    }

    #[test]
    fn honours_a_resume_entry_missing_from_the_episode_list() {
        // Season fan-out returned nothing usable, but the resume feed knows.
        let resume = vec![in_progress(episode("s3e7", 3, 7), 0.5)];

        let current = pick_current_episode(SERIES, &[], &[], &resume).unwrap();
        assert_eq!(current.id, "s3e7");
    }

    #[test]
    fn resume_entries_from_other_series_are_ignored() {
        let mut foreign = in_progress(episode("other", 1, 1), 0.5);
        foreign.series_id = Some("series-2".to_string());

        assert!(pick_current_episode(SERIES, &[], &[], &[foreign]).is_none());
    }

    #[test]
    fn a_series_with_no_episodes_has_no_current_episode() {
        assert!(pick_current_episode(SERIES, &[], &[], &[]).is_none());
    }

    #[test]
    fn an_episode_without_a_duration_still_counts_as_in_progress() {
        let mut ep = episode("s1e2", 1, 2);
        ep.duration_ms = None;
        ep.user_data = Some(UserData {
            is_played: Some(false),
            playback_position_ms: Some(120_000),
            ..Default::default()
        });

        let current = pick_current_episode(SERIES, &[episode("s1e1", 1, 1), ep], &[], &[]).unwrap();
        assert_eq!(current.id, "s1e2");
    }

    #[test]
    fn legacy_tick_positions_still_register_as_progress() {
        let mut ep = episode("s1e2", 1, 2);
        ep.user_data = Some(UserData {
            is_played: Some(false),
            // 400_000 ms expressed in Jellyfin ticks, no ms field.
            playback_position_ticks: Some(400_000 * 10_000),
            ..Default::default()
        });

        let current = pick_current_episode(SERIES, &[episode("s1e1", 1, 1), ep], &[], &[]).unwrap();
        assert_eq!(current.id, "s1e2");
    }
}
