//! "Show solution" — solve the current deal off-thread and auto-play
//! the winning line through the normal move pipeline.
//!
//! The pause menu's "Show solution" button fires
//! [`crate::events::ShowSolutionRequestEvent`]. This plugin snapshots
//! the live [`GameState`], runs [`GameState::winning_line`] on
//! [`AsyncComputeTaskPool`] (the solver plus `clean_solution` can take
//! seconds — §2.4 never block the main thread), then steps the returned
//! instructions on a cadence, one `MoveRequestEvent` /
//! `DrawRequestEvent` per tick — the same events player input produces,
//! so animations, scoring, undo history, and win detection all behave
//! exactly as if the player made the moves.
//!
//! Playback cancels on Esc, on pause, on undo / new-game requests, and
//! on any rejected move (which is what a player interfering mid-line
//! produces — their move diverges the state, the next scripted step
//! becomes illegal, and the rejection stops the run cleanly).

use std::collections::VecDeque;

use bevy::prelude::*;
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};

use solitaire_core::game_state::GameState;
use solitaire_core::{
    DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, KlondikeInstruction,
};

use crate::events::{
    DrawRequestEvent, InfoToastEvent, MoveRejectedEvent, MoveRequestEvent, NewGameRequestEvent,
    ShowSolutionRequestEvent, UndoRequestEvent,
};
use crate::game_plugin::GameMutation;
use crate::pause_plugin::PausedResource;
use crate::resources::GameStateResource;

/// Seconds between scripted moves — slow enough to follow, fast enough
/// not to drag on a 100-move line.
const STEP_INTERVAL_SECS: f32 = 0.45;

/// Initial delay before the first scripted move, giving the pause modal
/// time to close and the player a beat to see what's happening.
const FIRST_STEP_DELAY_SECS: f32 = 0.9;

/// In-flight solver task plus the `move_count` snapshot used to detect
/// a stale result (player moved while the solver ran). Mirrors
/// `PendingHintTask`.
#[derive(Resource, Default)]
pub struct SolutionSolveTask {
    inner: Option<(u32, Task<SolveTaskOutput>)>,
}

/// What the solver task carries back to the main thread.
enum SolveTaskOutput {
    Line(Vec<KlondikeInstruction>),
    Unwinnable,
    Inconclusive,
}

/// Queue of instructions currently being auto-played, or empty when no
/// playback is active. HUD/UI may read `is_active` to badge the state.
#[derive(Resource, Default)]
pub struct SolutionPlayback {
    queue: VecDeque<KlondikeInstruction>,
    cooldown: f32,
}

impl SolutionPlayback {
    /// `true` while a solution line is being auto-played.
    pub fn is_active(&self) -> bool {
        !self.queue.is_empty()
    }

    fn stop(&mut self) {
        self.queue.clear();
    }
}

/// Bevy plugin for the Show-solution flow. See the module docs.
pub struct SolutionPlaybackPlugin;

impl Plugin for SolutionPlaybackPlugin {
    fn build(&self, app: &mut App) {
        // add_message is idempotent — GamePlugin registers most of these
        // too, but this plugin must also boot standalone under
        // MinimalPlugins in tests.
        app.init_resource::<SolutionSolveTask>()
            .init_resource::<SolutionPlayback>()
            .add_message::<ShowSolutionRequestEvent>()
            .add_message::<InfoToastEvent>()
            .add_message::<MoveRequestEvent>()
            .add_message::<DrawRequestEvent>()
            .add_message::<MoveRejectedEvent>()
            .add_message::<UndoRequestEvent>()
            .add_message::<NewGameRequestEvent>()
            .add_systems(
                Update,
                (
                    handle_show_solution_request,
                    poll_solution_task,
                    cancel_playback_on_interrupt,
                    drive_solution_playback,
                )
                    .chain()
                    .before(GameMutation),
            );
    }
}

/// Starts a solver task from the live game state. A repeat request
/// while one is already in flight (or playback is running) is ignored
/// — the button is idempotent, not a queue.
fn handle_show_solution_request(
    mut requests: MessageReader<ShowSolutionRequestEvent>,
    game: Option<Res<GameStateResource>>,
    mut task: ResMut<SolutionSolveTask>,
    playback: Res<SolutionPlayback>,
    mut toast: MessageWriter<InfoToastEvent>,
) {
    if requests.is_empty() {
        return;
    }
    requests.clear();
    if task.inner.is_some() || playback.is_active() {
        return;
    }
    let Some(game) = game else { return };
    if game.0.is_won() {
        return;
    }

    toast.write(InfoToastEvent("Searching for a solution…".to_string()));
    let snapshot: GameState = game.0.clone();
    let move_count = snapshot.move_count();
    let handle = AsyncComputeTaskPool::get().spawn(async move {
        match snapshot.winning_line(DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET) {
            Ok(Some(line)) => SolveTaskOutput::Line(line),
            Ok(None) => SolveTaskOutput::Unwinnable,
            Err(_) => SolveTaskOutput::Inconclusive,
        }
    });
    task.inner = Some((move_count, handle));
}

/// Polls the solver; on completion either starts playback or explains
/// why there is nothing to play. A result computed for a position the
/// player has since moved past is discarded silently.
fn poll_solution_task(
    mut task: ResMut<SolutionSolveTask>,
    game: Option<Res<GameStateResource>>,
    mut playback: ResMut<SolutionPlayback>,
    mut toast: MessageWriter<InfoToastEvent>,
) {
    let Some((move_count_at_spawn, handle)) = task.inner.as_mut() else {
        return;
    };
    let Some(output) = future::block_on(future::poll_once(handle)) else {
        return;
    };
    let move_count_at_spawn = *move_count_at_spawn;
    task.inner = None;

    let Some(game) = game else { return };
    if game.0.move_count() != move_count_at_spawn {
        return; // Stale — the board moved while we were solving.
    }

    match output {
        SolveTaskOutput::Line(line) if line.is_empty() => {}
        SolveTaskOutput::Line(line) => {
            toast.write(InfoToastEvent(format!(
                "Solution found — playing {} moves. Press Esc to stop.",
                line.len()
            )));
            playback.queue = line.into();
            playback.cooldown = FIRST_STEP_DELAY_SECS;
        }
        SolveTaskOutput::Unwinnable => {
            toast.write(InfoToastEvent(
                "No winning line exists from this position.".to_string(),
            ));
        }
        SolveTaskOutput::Inconclusive => {
            toast.write(InfoToastEvent(
                "Couldn't find a solution within the search budget.".to_string(),
            ));
        }
    }
}

/// Stops playback on Esc, pause, undo / new-game requests, or a
/// rejected move (the signature of the player diverging the board
/// mid-line). Runs before `drive_solution_playback` so a cancel takes
/// effect without one extra scripted move slipping out.
fn cancel_playback_on_interrupt(
    mut playback: ResMut<SolutionPlayback>,
    keys: Option<Res<ButtonInput<KeyCode>>>,
    paused: Option<Res<PausedResource>>,
    mut rejected: MessageReader<MoveRejectedEvent>,
    mut undos: MessageReader<UndoRequestEvent>,
    mut new_games: MessageReader<NewGameRequestEvent>,
    mut toast: MessageWriter<InfoToastEvent>,
) {
    if !playback.is_active() {
        rejected.clear();
        undos.clear();
        new_games.clear();
        return;
    }
    let esc = keys.is_some_and(|k| k.just_pressed(KeyCode::Escape));
    let interrupted = esc
        || paused.is_some_and(|p| p.0)
        || rejected.read().next().is_some()
        || undos.read().next().is_some()
        || new_games.read().next().is_some();
    if interrupted {
        playback.stop();
        toast.write(InfoToastEvent("Solution playback stopped.".to_string()));
    }
}

/// Emits the next scripted instruction every [`STEP_INTERVAL_SECS`]
/// while playback is active, translated to the same request events
/// player input produces. Instructions that no longer decode against
/// the live state stop the run instead of guessing.
fn drive_solution_playback(
    mut playback: ResMut<SolutionPlayback>,
    game: Option<Res<GameStateResource>>,
    time: Res<Time>,
    mut moves: MessageWriter<MoveRequestEvent>,
    mut draws: MessageWriter<DrawRequestEvent>,
    mut toast: MessageWriter<InfoToastEvent>,
) {
    if !playback.is_active() {
        return;
    }
    let Some(game) = game else {
        playback.stop();
        return;
    };
    if game.0.is_won() {
        playback.stop();
        return;
    }

    playback.cooldown -= time.delta_secs();
    if playback.cooldown > 0.0 {
        return;
    }
    playback.cooldown = STEP_INTERVAL_SECS;

    let Some(instruction) = playback.queue.pop_front() else {
        return;
    };

    match instruction {
        KlondikeInstruction::RotateStock => {
            draws.write(DrawRequestEvent);
        }
        other => {
            // Decode against the LIVE state — tableau run lengths depend on
            // the current face-up counts, so this must happen at step time,
            // not at solve time.
            let Some((from, to, count)) = game.0.instruction_to_piles(other) else {
                playback.stop();
                toast.write(InfoToastEvent("Solution playback stopped.".to_string()));
                return;
            };
            moves.write(MoveRequestEvent { from, to, count });
        }
    }
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;
    use crate::game_plugin::GamePlugin;
    use crate::table_plugin::TablePlugin;
    use bevy::ecs::message::Messages;
    use solitaire_core::DrawStockConfig;

    /// Seed proven Winnable at 5k budgets by the core
    /// `budget_is_passed_through_not_clamped` test. Solved here under
    /// standard rules (no take-from-foundation) to match that baseline.
    const WINNABLE_SEED: u64 = 0xD1FF_0000_0000_0012;

    fn winnable_state() -> GameState {
        let mut game = GameState::new(WINNABLE_SEED, DrawStockConfig::DrawOne);
        game.take_from_foundation = false;
        game
    }

    /// Full pipeline: GamePlugin consumes the Move/Draw requests the
    /// playback driver emits, exactly as in production.
    fn headless_app() -> App {
        let mut app = App::new();
        app.add_plugins(MinimalPlugins)
            .add_plugins(GamePlugin)
            .add_plugins(TablePlugin)
            .add_plugins(SolutionPlaybackPlugin);
        app.init_resource::<ButtonInput<KeyCode>>();
        app.update();
        app
    }

    fn request_solution(app: &mut App) {
        app.world_mut()
            .resource_mut::<Messages<ShowSolutionRequestEvent>>()
            .write(ShowSolutionRequestEvent);
    }

    /// Pump updates until the solver task resolves (wall-clock bounded,
    /// mirroring `winnable_solver_emits_hint_after_async_completes`).
    fn pump_until_solved(app: &mut App) {
        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
        while app.world().resource::<SolutionSolveTask>().inner.is_some() {
            app.update();
            std::thread::yield_now();
            if std::time::Instant::now() >= deadline {
                break;
            }
        }
        assert!(
            app.world().resource::<SolutionSolveTask>().inner.is_none(),
            "solver task should have completed within 30 s wall-clock",
        );
    }

    #[test]
    fn request_solves_and_arms_playback() {
        let mut app = headless_app();
        app.insert_resource(GameStateResource(winnable_state()));

        request_solution(&mut app);
        app.update();
        assert!(
            app.world().resource::<SolutionSolveTask>().inner.is_some(),
            "request must spawn a solver task",
        );
        pump_until_solved(&mut app);
        assert!(
            app.world().resource::<SolutionPlayback>().is_active(),
            "a winnable position must arm playback",
        );
    }

    #[test]
    fn playback_reaches_win_through_normal_pipeline() {
        let mut app = headless_app();
        app.insert_resource(GameStateResource(winnable_state()));
        request_solution(&mut app);
        app.update();
        pump_until_solved(&mut app);
        assert!(app.world().resource::<SolutionPlayback>().is_active());

        // Force each step instead of waiting out the real cadence; a line
        // is at most a few hundred instructions.
        for _ in 0..600 {
            app.world_mut().resource_mut::<SolutionPlayback>().cooldown = 0.0;
            app.update();
            if app.world().resource::<GameStateResource>().0.is_won() {
                break;
            }
        }
        assert!(
            app.world().resource::<GameStateResource>().0.is_won(),
            "auto-played line must drive the real game to a win",
        );
        assert!(
            !app.world().resource::<SolutionPlayback>().is_active(),
            "playback must deactivate once the game is won",
        );
    }

    #[test]
    fn escape_cancels_playback() {
        let mut app = headless_app();
        app.insert_resource(GameStateResource(winnable_state()));
        app.world_mut()
            .resource_mut::<SolutionPlayback>()
            .queue
            .push_back(KlondikeInstruction::RotateStock);

        app.world_mut()
            .resource_mut::<ButtonInput<KeyCode>>()
            .press(KeyCode::Escape);
        app.update();
        assert!(
            !app.world().resource::<SolutionPlayback>().is_active(),
            "Esc must stop playback",
        );
    }

    #[test]
    fn repeat_request_is_ignored_while_active() {
        let mut app = headless_app();
        app.insert_resource(GameStateResource(winnable_state()));
        app.world_mut()
            .resource_mut::<SolutionPlayback>()
            .queue
            .push_back(KlondikeInstruction::RotateStock);

        request_solution(&mut app);
        app.update();
        assert!(
            app.world().resource::<SolutionSolveTask>().inner.is_none(),
            "a request during active playback must not spawn a solver task",
        );
    }
}
