//! crossterm-driven animation renderer.
//!
//! The public [`render`] entry point owns the side-effecting
//! presentation loop: choose the appropriate scene for the fired
//! trigger and current width bucket, enter the alternate screen via
//! [`super::terminal::TerminalGuard`], draw each frame at the home
//! cursor position, sleep a fixed tick, and rely on guard drop for
//! restoration on every exit path.

use std::{io::Write, time::Duration};

use crate::{
    anim::{fallback, scene, terminal},
    term::width::{bucket, WidthBucket},
    trigger::parser::Outcome,
    Result,
};

/// Fixed per-frame hold time.
///
/// The renderer sleeps after every successful draw, including the
/// final frame, so the last visible gag remains on screen briefly
/// before [`terminal::TerminalGuard`] restores the original screen.
pub const FRAME_DELAY: Duration = Duration::from_millis(50);

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PlanKind {
    Fallback,
    Tiny,
    TinyBang,
    Q,
    Wq,
    WqOversized,
    Bang,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct RenderPlan {
    pub(crate) kind: PlanKind,
    pub(crate) frames: Vec<String>,
}

/// Render one fired trigger's animation for the given terminal width.
pub fn render(outcome: Outcome, cols: u16) -> Result<()> {
    let mut out = std::io::stdout();
    render_with(
        outcome,
        cols,
        terminal::acquire,
        |frame| draw_frame(&mut out, frame),
        std::thread::sleep,
    )
}

/// Test seam covering plan selection, terminal-guard lifetime, and
/// draw/sleep ordering without touching the real terminal.
pub(crate) fn render_with<A, D, S>(
    outcome: Outcome,
    cols: u16,
    acquire: A,
    mut draw: D,
    mut sleep: S,
) -> Result<()>
where
    A: FnOnce() -> Result<terminal::TerminalGuard>,
    D: FnMut(&str) -> Result<()>,
    S: FnMut(Duration),
{
    let Some(plan) = render_plan(outcome, cols) else {
        return Ok(());
    };
    if plan.frames.is_empty() {
        return Ok(());
    }

    let _guard = acquire()?;
    for frame in &plan.frames {
        draw(frame)?;
        sleep(FRAME_DELAY);
    }
    Ok(())
}

/// Build the pure render plan for one trigger + width combination.
///
/// Degrade policy is centralized here so boundary tests can pin it:
///
/// - `< 40` columns    → one-line trigger-specific fallback gag
/// - `40..=79`         → tiny ambulance, except `:q!` keeps a tiny parade
/// - `80..=119`        → standard `:q` scene; `:wq` degrades here because
///   the big 418-labeled asset does not fit
/// - `120..=159`       → `:wq` upgrades to the big labeled scene, `:q!`
///   keeps the parade, `:q` stays on the standard car
/// - `>= 160`          → `:wq` uses the oversized locomotive scene per
///   `docs/anim-large-art-contract.md`; `:q` and `:q!` stay on their
///   large-bucket scenes unchanged
pub(crate) fn render_plan(outcome: Outcome, cols: u16) -> Option<RenderPlan> {
    let b = bucket(cols);
    match outcome {
        Outcome::None => None,
        Outcome::Q => Some(match b {
            WidthBucket::Tiny => fallback_plan(fallback::Trigger::Q),
            WidthBucket::Small => RenderPlan {
                kind: PlanKind::Tiny,
                frames: scene::tiny(cols),
            },
            WidthBucket::Medium | WidthBucket::Large | WidthBucket::Oversized => RenderPlan {
                kind: PlanKind::Q,
                frames: scene::q(cols),
            },
        }),
        Outcome::Wq => Some(match b {
            WidthBucket::Tiny => fallback_plan(fallback::Trigger::Wq),
            WidthBucket::Small => RenderPlan {
                kind: PlanKind::Tiny,
                frames: scene::tiny(cols),
            },
            WidthBucket::Medium => RenderPlan {
                kind: PlanKind::Q,
                frames: scene::q(cols),
            },
            WidthBucket::Large => RenderPlan {
                kind: PlanKind::Wq,
                frames: scene::wq(cols),
            },
            WidthBucket::Oversized => RenderPlan {
                kind: PlanKind::WqOversized,
                frames: scene::wq_oversized(cols),
            },
        }),
        Outcome::QBang => Some(match b {
            WidthBucket::Tiny => fallback_plan(fallback::Trigger::Bang),
            WidthBucket::Small => RenderPlan {
                kind: PlanKind::TinyBang,
                frames: scene::tiny_bang(cols),
            },
            WidthBucket::Medium | WidthBucket::Large | WidthBucket::Oversized => RenderPlan {
                kind: PlanKind::Bang,
                frames: scene::bang(cols),
            },
        }),
    }
}

pub(crate) fn render_frame_count(outcome: Outcome, cols: u16) -> Option<usize> {
    let b = bucket(cols);
    match outcome {
        Outcome::None => None,
        Outcome::Q => Some(match b {
            WidthBucket::Tiny => 1,
            WidthBucket::Small => scene::tiny_frame_count(cols),
            WidthBucket::Medium | WidthBucket::Large | WidthBucket::Oversized => {
                scene::q_frame_count(cols)
            }
        }),
        Outcome::Wq => Some(match b {
            WidthBucket::Tiny => 1,
            WidthBucket::Small => scene::tiny_frame_count(cols),
            WidthBucket::Medium => scene::q_frame_count(cols),
            WidthBucket::Large => scene::wq_frame_count(cols),
            WidthBucket::Oversized => scene::wq_oversized_frame_count(cols),
        }),
        Outcome::QBang => Some(match b {
            WidthBucket::Tiny => 1,
            WidthBucket::Small => scene::tiny_bang_frame_count(cols),
            WidthBucket::Medium | WidthBucket::Large | WidthBucket::Oversized => {
                scene::bang_frame_count(cols)
            }
        }),
    }
}

fn fallback_plan(trigger: fallback::Trigger) -> RenderPlan {
    RenderPlan {
        kind: PlanKind::Fallback,
        frames: vec![fallback::fallback(trigger).to_string()],
    }
}

pub(crate) fn draw_frame<W>(out: &mut W, frame: &str) -> Result<()>
where
    W: Write,
{
    crossterm::queue!(
        out,
        crossterm::cursor::MoveTo(0, 0),
        crossterm::terminal::Clear(crossterm::terminal::ClearType::All),
    )?;
    write!(out, "{frame}")?;
    out.flush()?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::{
        panic::AssertUnwindSafe,
        sync::{
            atomic::{AtomicUsize, Ordering},
            Arc, Mutex,
        },
    };

    fn assert_render_plan(
        outcome: Outcome,
        cols: u16,
        expected_kind: PlanKind,
        expected_frames: Vec<String>,
    ) {
        let plan = render_plan(outcome, cols).unwrap();

        assert_eq!(
            plan.kind, expected_kind,
            "{outcome:?} at {cols} cols chose an unexpected plan kind"
        );
        assert_eq!(
            plan.frames, expected_frames,
            "{outcome:?} at {cols} cols chose unexpected frames"
        );
    }

    #[test]
    fn render_plan_returns_none_when_no_trigger_fired() {
        assert_eq!(render_plan(Outcome::None, 120), None);
    }

    #[test]
    fn render_frame_count_matches_plan_lengths() {
        for (outcome, cols) in [
            (Outcome::Q, 39),
            (Outcome::Q, 40),
            (Outcome::Q, 80),
            (Outcome::Q, 120),
            (Outcome::Q, 160),
            (Outcome::Wq, 0),
            (Outcome::Wq, 40),
            (Outcome::Wq, 119),
            (Outcome::Wq, 120),
            (Outcome::Wq, 159),
            (Outcome::Wq, 160),
            (Outcome::QBang, 12),
            (Outcome::QBang, 40),
            (Outcome::QBang, 80),
            (Outcome::QBang, 120),
            (Outcome::QBang, 160),
        ] {
            let plan = render_plan(outcome, cols).expect("non-none outcomes must render");
            let count = render_frame_count(outcome, cols).expect("non-none outcomes must count");
            assert_eq!(
                count,
                plan.frames.len(),
                "{outcome:?} at {cols} cols counted differently from its plan"
            );
        }
    }

    #[test]
    fn render_plan_uses_fallback_below_forty_columns() {
        let q = render_plan(Outcome::Q, 39).unwrap();
        let wq = render_plan(Outcome::Wq, 0).unwrap();
        let bang = render_plan(Outcome::QBang, 12).unwrap();

        assert_eq!(q.kind, PlanKind::Fallback);
        assert_eq!(
            q.frames,
            vec![fallback::fallback(fallback::Trigger::Q).to_string()]
        );
        assert_eq!(wq.kind, PlanKind::Fallback);
        assert_eq!(
            wq.frames,
            vec![fallback::fallback(fallback::Trigger::Wq).to_string()]
        );
        assert_eq!(bang.kind, PlanKind::Fallback);
        assert_eq!(
            bang.frames,
            vec![fallback::fallback(fallback::Trigger::Bang).to_string()]
        );
    }

    #[test]
    fn render_plan_selects_expected_bucket_variants() {
        assert_eq!(render_plan(Outcome::Q, 40).unwrap().kind, PlanKind::Tiny);
        assert_eq!(render_plan(Outcome::Q, 79).unwrap().kind, PlanKind::Tiny);
        assert_eq!(render_plan(Outcome::Q, 80).unwrap().kind, PlanKind::Q);
        assert_eq!(render_plan(Outcome::Q, 140).unwrap().kind, PlanKind::Q);
        assert_eq!(render_plan(Outcome::Q, 160).unwrap().kind, PlanKind::Q);

        assert_eq!(render_plan(Outcome::Wq, 40).unwrap().kind, PlanKind::Tiny);
        assert_eq!(render_plan(Outcome::Wq, 119).unwrap().kind, PlanKind::Q);
        assert_eq!(render_plan(Outcome::Wq, 120).unwrap().kind, PlanKind::Wq);
        assert_eq!(render_plan(Outcome::Wq, 159).unwrap().kind, PlanKind::Wq);
        assert_eq!(
            render_plan(Outcome::Wq, 160).unwrap().kind,
            PlanKind::WqOversized
        );

        assert_eq!(
            render_plan(Outcome::QBang, 79).unwrap().kind,
            PlanKind::TinyBang
        );
        assert_eq!(
            render_plan(Outcome::QBang, 80).unwrap().kind,
            PlanKind::Bang
        );
        assert_eq!(
            render_plan(Outcome::QBang, 140).unwrap().kind,
            PlanKind::Bang
        );
        assert_eq!(
            render_plan(Outcome::QBang, 160).unwrap().kind,
            PlanKind::Bang
        );
    }

    #[test]
    fn render_plan_covers_q_width_boundaries() {
        assert_render_plan(
            Outcome::Q,
            39,
            PlanKind::Fallback,
            vec![fallback::fallback(fallback::Trigger::Q).to_string()],
        );
        assert_render_plan(Outcome::Q, 40, PlanKind::Tiny, scene::tiny(40));
        assert_render_plan(Outcome::Q, 79, PlanKind::Tiny, scene::tiny(79));
        assert_render_plan(Outcome::Q, 80, PlanKind::Q, scene::q(80));
        assert_render_plan(Outcome::Q, 119, PlanKind::Q, scene::q(119));
        assert_render_plan(Outcome::Q, 120, PlanKind::Q, scene::q(120));
        assert_render_plan(Outcome::Q, 140, PlanKind::Q, scene::q(140));
    }

    #[test]
    fn render_plan_covers_wq_width_boundaries() {
        assert_render_plan(
            Outcome::Wq,
            39,
            PlanKind::Fallback,
            vec![fallback::fallback(fallback::Trigger::Wq).to_string()],
        );
        assert_render_plan(Outcome::Wq, 40, PlanKind::Tiny, scene::tiny(40));
        assert_render_plan(Outcome::Wq, 79, PlanKind::Tiny, scene::tiny(79));
        assert_render_plan(Outcome::Wq, 80, PlanKind::Q, scene::q(80));
        assert_render_plan(Outcome::Wq, 119, PlanKind::Q, scene::q(119));
        assert_render_plan(Outcome::Wq, 120, PlanKind::Wq, scene::wq(120));
        assert_render_plan(Outcome::Wq, 159, PlanKind::Wq, scene::wq(159));
        assert_render_plan(
            Outcome::Wq,
            160,
            PlanKind::WqOversized,
            scene::wq_oversized(160),
        );
        assert_render_plan(
            Outcome::Wq,
            200,
            PlanKind::WqOversized,
            scene::wq_oversized(200),
        );
    }

    #[test]
    fn render_plan_covers_bang_width_boundaries() {
        assert_render_plan(
            Outcome::QBang,
            39,
            PlanKind::Fallback,
            vec![fallback::fallback(fallback::Trigger::Bang).to_string()],
        );
        assert_render_plan(Outcome::QBang, 40, PlanKind::TinyBang, scene::tiny_bang(40));
        assert_render_plan(Outcome::QBang, 79, PlanKind::TinyBang, scene::tiny_bang(79));
        assert_render_plan(Outcome::QBang, 80, PlanKind::Bang, scene::bang(80));
        assert_render_plan(Outcome::QBang, 119, PlanKind::Bang, scene::bang(119));
        assert_render_plan(Outcome::QBang, 120, PlanKind::Bang, scene::bang(120));
        assert_render_plan(Outcome::QBang, 140, PlanKind::Bang, scene::bang(140));
    }

    #[test]
    fn render_plan_small_bucket_uses_tiny_timelines() {
        let q = render_plan(Outcome::Q, 40).unwrap();
        let bang = render_plan(Outcome::QBang, 40).unwrap();

        assert_eq!(q.frames, scene::tiny(40));
        assert_eq!(bang.frames, scene::tiny_bang(40));
    }

    #[test]
    fn render_plan_medium_wq_degrades_to_the_standard_scene() {
        let plan = render_plan(Outcome::Wq, 119).unwrap();

        assert_eq!(plan.kind, PlanKind::Q);
        assert_eq!(plan.frames, scene::q(119));
        assert!(plan
            .frames
            .iter()
            .all(|frame| !frame.contains("418 I'm an AI agent")));
    }

    #[test]
    fn render_plan_large_wq_preserves_the_big_418_scene() {
        let plan = render_plan(Outcome::Wq, 120).unwrap();

        assert_eq!(plan.kind, PlanKind::Wq);
        assert_eq!(plan.frames, scene::wq(120));
        assert!(plan
            .frames
            .iter()
            .any(|frame| frame.contains("418 I'm an AI agent")));
    }

    #[test]
    fn render_plan_oversized_wq_uses_oversized_scene() {
        let plan = render_plan(Outcome::Wq, 160).unwrap();

        assert_eq!(plan.kind, PlanKind::WqOversized);
        assert_eq!(plan.frames, scene::wq_oversized(160));
        assert!(
            plan.frames.iter().any(|frame| frame.contains("418")),
            "oversized :wq scene must contain 418 label"
        );
        assert!(
            plan.frames.iter().any(|frame| frame.contains("AI AGENT")),
            "oversized :wq scene must contain AI AGENT label"
        );
    }

    #[test]
    fn render_plan_oversized_wq_frame_count_is_within_budget() {
        for cols in [160u16, 200, 300, u16::MAX] {
            let count = render_frame_count(Outcome::Wq, cols).unwrap();
            assert!(
                count <= 60,
                "oversized :wq at {cols} cols has {count} frames, exceeds 60-frame budget"
            );
        }
    }

    #[test]
    fn render_plan_large_159_is_still_big_wq_not_oversized() {
        let plan = render_plan(Outcome::Wq, 159).unwrap();
        assert_eq!(plan.kind, PlanKind::Wq);
    }

    #[test]
    fn snapshot_wq_oversized_160() {
        let plan = render_plan(Outcome::Wq, 160).unwrap();
        insta::assert_snapshot!("wq_oversized_160", snapshot_frames(&plan.frames));
    }

    #[test]
    fn render_with_none_is_a_no_op() {
        let draws = Arc::new(AtomicUsize::new(0));
        let sleeps = Arc::new(AtomicUsize::new(0));
        let acquires = Arc::new(AtomicUsize::new(0));

        let draw_counter = Arc::clone(&draws);
        let sleep_counter = Arc::clone(&sleeps);
        let acquire_counter = Arc::clone(&acquires);

        render_with(
            Outcome::None,
            120,
            move || {
                acquire_counter.fetch_add(1, Ordering::SeqCst);
                Ok(terminal::TerminalGuard::noop())
            },
            move |_frame| {
                draw_counter.fetch_add(1, Ordering::SeqCst);
                Ok(())
            },
            move |_delay| {
                sleep_counter.fetch_add(1, Ordering::SeqCst);
            },
        )
        .unwrap();

        assert_eq!(acquires.load(Ordering::SeqCst), 0);
        assert_eq!(draws.load(Ordering::SeqCst), 0);
        assert_eq!(sleeps.load(Ordering::SeqCst), 0);
    }

    #[test]
    fn render_with_draws_sleeps_and_restores_for_single_frame_fallback() {
        let events = Arc::new(Mutex::new(Vec::<String>::new()));
        let acquire_events = Arc::clone(&events);
        let draw_events = Arc::clone(&events);
        let sleep_events = Arc::clone(&events);

        render_with(
            Outcome::Q,
            39,
            move || {
                Ok(terminal::TerminalGuard::with_restore_hook(move || {
                    acquire_events.lock().unwrap().push("restore".to_string());
                }))
            },
            move |frame| {
                draw_events.lock().unwrap().push(format!("draw:{frame}"));
                Ok(())
            },
            move |delay| {
                sleep_events
                    .lock()
                    .unwrap()
                    .push(format!("sleep:{delay:?}"));
            },
        )
        .unwrap();

        let events = events.lock().unwrap().clone();
        assert_eq!(
            events,
            vec![
                format!("draw:{}", fallback::fallback(fallback::Trigger::Q)),
                format!("sleep:{FRAME_DELAY:?}"),
                "restore".to_string(),
            ]
        );
    }

    #[test]
    fn render_with_sleeps_once_per_drawn_frame() {
        let draws = Arc::new(AtomicUsize::new(0));
        let sleeps = Arc::new(AtomicUsize::new(0));

        let draw_counter = Arc::clone(&draws);
        let sleep_counter = Arc::clone(&sleeps);

        render_with(
            Outcome::Q,
            40,
            || Ok(terminal::TerminalGuard::noop()),
            move |_frame| {
                draw_counter.fetch_add(1, Ordering::SeqCst);
                Ok(())
            },
            move |_delay| {
                sleep_counter.fetch_add(1, Ordering::SeqCst);
            },
        )
        .unwrap();

        let expected = scene::tiny(40).len();
        assert_eq!(draws.load(Ordering::SeqCst), expected);
        assert_eq!(sleeps.load(Ordering::SeqCst), expected);
    }

    #[test]
    fn render_with_restores_when_draw_fails_after_acquire() {
        let restores = Arc::new(AtomicUsize::new(0));
        let restore_counter = Arc::clone(&restores);

        let err = render_with(
            Outcome::Q,
            39,
            move || {
                Ok(terminal::TerminalGuard::with_restore_hook(move || {
                    restore_counter.fetch_add(1, Ordering::SeqCst);
                }))
            },
            |_frame| Err(std::io::Error::other("draw failed").into()),
            |_delay| {},
        )
        .unwrap_err();

        assert!(matches!(err, crate::Error::Terminal(_)));
        assert_eq!(restores.load(Ordering::SeqCst), 1);
    }

    #[test]
    fn render_with_restores_when_draw_panics() {
        let restores = Arc::new(AtomicUsize::new(0));
        let restore_counter = Arc::clone(&restores);

        let result = std::panic::catch_unwind(AssertUnwindSafe(|| {
            let _ = render_with(
                Outcome::Q,
                39,
                move || {
                    Ok(terminal::TerminalGuard::with_restore_hook(move || {
                        restore_counter.fetch_add(1, Ordering::SeqCst);
                    }))
                },
                |_frame| -> Result<()> {
                    panic!("boom");
                },
                |_delay| {},
            );
        }));

        assert!(result.is_err());
        assert_eq!(restores.load(Ordering::SeqCst), 1);
    }

    fn reviewable_ansi(bytes: &[u8]) -> String {
        let mut out = String::new();
        for &byte in bytes {
            match byte {
                0x1b => out.push_str("<ESC>"),
                b'\n' => out.push('\n'),
                b'\r' => out.push_str("<CR>"),
                0x20..=0x7e => out.push(byte as char),
                _ => out.push_str(&format!("\\x{byte:02X}")),
            }
        }
        out
    }

    fn snapshot_frames(frames: &[String]) -> String {
        let mut out = String::new();
        for (idx, frame) in frames.iter().enumerate() {
            if idx > 0 {
                out.push('\n');
            }
            out.push_str(&format!("--- frame {idx:03} ---\n"));

            let mut buf = Vec::new();
            draw_frame(&mut buf, frame).expect("render snapshot frame");
            out.push_str(&reviewable_ansi(&buf));
            if !out.ends_with('\n') {
                out.push('\n');
            }
        }
        out
    }

    #[test]
    fn snapshot_fallback_q_39() {
        let plan = render_plan(Outcome::Q, 39).unwrap();
        insta::assert_snapshot!("fallback_q_39", snapshot_frames(&plan.frames));
    }

    #[test]
    fn snapshot_fallback_wq_39() {
        let plan = render_plan(Outcome::Wq, 39).unwrap();
        insta::assert_snapshot!("fallback_wq_39", snapshot_frames(&plan.frames));
    }

    #[test]
    fn snapshot_fallback_bang_39() {
        let plan = render_plan(Outcome::QBang, 39).unwrap();
        insta::assert_snapshot!("fallback_bang_39", snapshot_frames(&plan.frames));
    }

    #[test]
    fn snapshot_tiny_q_40() {
        let plan = render_plan(Outcome::Q, 40).unwrap();
        insta::assert_snapshot!("tiny_q_40", snapshot_frames(&plan.frames));
    }

    #[test]
    fn snapshot_tiny_bang_40() {
        let plan = render_plan(Outcome::QBang, 40).unwrap();
        insta::assert_snapshot!("tiny_bang_40", snapshot_frames(&plan.frames));
    }

    #[test]
    fn snapshot_q_80() {
        let plan = render_plan(Outcome::Q, 80).unwrap();
        insta::assert_snapshot!("q_80", snapshot_frames(&plan.frames));
    }

    #[test]
    fn snapshot_wq_120() {
        let plan = render_plan(Outcome::Wq, 120).unwrap();
        insta::assert_snapshot!("wq_120", snapshot_frames(&plan.frames));
    }

    #[test]
    fn snapshot_bang_80() {
        let plan = render_plan(Outcome::QBang, 80).unwrap();
        insta::assert_snapshot!("bang_80", snapshot_frames(&plan.frames));
    }
}
