//! Async H-key hint solver, modelled on `PendingNewGameSeed` in
//! `game_plugin`.
//!
//! The synchronous version (v0.17.0) called the solver on the main thread
//! on every H press. Median latency was ~2 ms but pathological positions
//! can hit the default solve budget at ~120 ms, which is a noticeable
//! input-stall on the same frame the player sees the hint request.
//!
//! This module hosts the resource and polling system that move the
//! solver call onto `AsyncComputeTaskPool`. `handle_keyboard_hint`
//! (input_plugin) becomes a thin spawn point: snapshot the state,
//! spawn the task, store the handle. The polling system takes the
//! result one frame later and surfaces the hint visuals via the
//! shared `emit_hint_visuals` helper.
//!
//! Cancel-on-replace: a fresh H press while a previous task is in
//! flight drops the previous task. Bevy's `Task` `Drop` cancels
//! cooperatively at the next await point.
//!
//! Stale-state drop: any `StateChangedEvent` (move applied, undo,
//! new game) drops the in-flight task — the position the solver was
//! reasoning about no longer exists, and surfacing a hint for the
//! old state would be confusing.

use bevy::prelude::*;
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
use solitaire_core::KlondikeInstruction;
use solitaire_core::game_state::GameState;

use crate::card_plugin::CardEntity;
use crate::events::{HintVisualEvent, InfoToastEvent, StateChangedEvent};
use crate::input_plugin::{emit_hint_visuals, find_heuristic_hint, hint_piles};
use crate::resources::{GameStateResource, HintCycleIndex};

/// In-flight async work for the H-key hint.
///
/// `handle_keyboard_hint` writes here when the player presses H;
/// `poll_pending_hint_task` reads from here, polls the task, and
/// emits the hint visuals once the task completes. At most one task
/// is ever in flight: a fresh H press while a previous task is
/// running drops the previous task and queues the new one.
#[derive(Resource, Default)]
pub struct PendingHintTask {
    /// `Some` while the solver is still working on a verdict.
    inner: Option<HintTask>,
}

impl PendingHintTask {
    /// Whether a hint task is currently in flight.
    pub fn is_pending(&self) -> bool {
        self.inner.is_some()
    }

    /// Drop any in-flight task. Bevy's `Task` `Drop` cancels the
    /// underlying future cooperatively at the next await point.
    pub fn cancel(&mut self) {
        self.inner = None;
    }

    /// Spawn a new solver task for `state` with the given solve budgets.
    /// Drops any previously in-flight task first (cancel-on-replace).
    pub fn spawn(&mut self, state: GameState, moves_budget: u64, states_budget: u64) {
        let move_count_at_spawn = state.move_count();
        let handle = AsyncComputeTaskPool::get().spawn(async move {
            // Winnable (`Ok(Some)`) carries the first move on a winning path;
            // unwinnable (`Ok(None)`) and inconclusive (`Err`) both fall back
            // to the live-state heuristic so H always produces feedback.
            match state.solve_first_move(moves_budget, states_budget) {
                Ok(Some(first_move)) => HintTaskOutput::SolverMove(first_move),
                Ok(None) | Err(_) => HintTaskOutput::NeedsHeuristic,
            }
        });
        self.inner = Some(HintTask {
            handle,
            move_count_at_spawn,
        });
    }
}

/// One in-flight hint search plus the snapshot data needed to detect
/// a stale result if the live state moved while the solver ran.
struct HintTask {
    handle: Task<HintTaskOutput>,
    /// `GameState.move_count` at spawn time. The poll system discards
    /// the result if the live move_count has advanced — the player
    /// applied a move while the solver ran, so the hint would be
    /// stale even if the StateChangedEvent drop didn't fire first.
    move_count_at_spawn: u32,
}

/// What the solver task carries back to the main thread.
enum HintTaskOutput {
    /// Solver verdict was winnable; here is the first move on the solution
    /// path. Converted to highlighted `(from, to)` piles by the poll system
    /// via [`crate::input_plugin::hint_piles`].
    SolverMove(KlondikeInstruction),
    /// Solver was `Unwinnable` or `Inconclusive`. The poll system
    /// runs the legacy heuristic against the live `GameState` so the
    /// H key always produces feedback while any legal move exists.
    NeedsHeuristic,
}

/// Drop the in-flight hint task whenever the live `GameState` shifts.
///
/// The position the solver was reasoning about no longer matches the
/// live state, so its result would be stale. Mirrors the semantics
/// of `reset_hint_cycle_on_state_change` for `HintCycleIndex`.
pub fn drop_pending_hint_on_state_change(
    mut state_events: MessageReader<StateChangedEvent>,
    mut pending: ResMut<PendingHintTask>,
) {
    if state_events.read().next().is_some() {
        pending.cancel();
    }
}

/// Poll the in-flight hint solver task. When the task resolves, run
/// `emit_hint_visuals` on the result — either the solver's
/// provably-best first move (Winnable verdict) or a heuristic hint
/// over the live state (Unwinnable / Inconclusive).
///
/// Discards the result when `GameState.move_count` has moved past the
/// snapshot taken at spawn time — the player applied a move during
/// the solve and `drop_pending_hint_on_state_change` should have
/// already cleared the resource, but we double-check here for the
/// rare case where the solver task completed in the same frame the
/// move was applied.
#[allow(clippy::too_many_arguments)]
pub fn poll_pending_hint_task(
    mut pending: ResMut<PendingHintTask>,
    game: Option<Res<GameStateResource>>,
    mut hint_cycle: ResMut<HintCycleIndex>,
    mut commands: Commands,
    card_entities: Query<(Entity, &CardEntity, &mut Sprite)>,
    mut info_toast: MessageWriter<InfoToastEvent>,
    mut hint_visual: MessageWriter<HintVisualEvent>,
) {
    let Some(p) = pending.inner.as_mut() else {
        return;
    };
    let Some(output) = future::block_on(future::poll_once(&mut p.handle)) else {
        return;
    };
    let move_count_at_spawn = p.move_count_at_spawn;
    pending.inner = None;

    let Some(g) = game else { return };
    if g.0.move_count() != move_count_at_spawn {
        return;
    }

    // Resolve the solver's first move to highlighted piles; fall back to the
    // live-state heuristic when there's no solver move or it maps to a no-op.
    let solver_pair = match output {
        HintTaskOutput::SolverMove(instruction) => hint_piles(&g.0, instruction),
        HintTaskOutput::NeedsHeuristic => None,
    };
    let (from, to) = match solver_pair.or_else(|| find_heuristic_hint(&g.0, &mut hint_cycle)) {
        Some(pair) => pair,
        None => {
            info_toast.write(InfoToastEvent("No hints available".to_string()));
            return;
        }
    };
    emit_hint_visuals(
        &g.0,
        &from,
        &to,
        &mut commands,
        card_entities,
        &mut info_toast,
        &mut hint_visual,
    );
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::events::HintVisualEvent;
    use crate::input_plugin::HintSolverConfig;
    use solitaire_core::{Foundation, KlondikePile, Tableau};
    use solitaire_core::{Card, Deck, Rank, Suit};
    use solitaire_core::{DrawStockConfig, game_state::GameState};

    /// Build a minimal Bevy app exercising only the polling system
    /// and the resources/messages it touches.
    fn pending_hint_app() -> App {
        let mut app = App::new();
        app.add_plugins(MinimalPlugins);
        app.add_message::<InfoToastEvent>();
        app.add_message::<HintVisualEvent>();
        app.add_message::<StateChangedEvent>();
        app.init_resource::<HintCycleIndex>();
        app.init_resource::<HintSolverConfig>();
        app.init_resource::<PendingHintTask>();
        // Chain the drop-on-state-change system before the poll
        // system, mirroring how `InputPlugin::build` wires them.
        // Without this, system order is unspecified and the
        // state_change_drops_in_flight_task test sometimes sees the
        // poll fire before the drop.
        app.add_systems(
            Update,
            (drop_pending_hint_on_state_change, poll_pending_hint_task).chain(),
        );
        app
    }

    /// Same near-finished fixture used by the v0.17 hint tests:
    /// foundations hold A..Q for each suit, four Kings sit on
    /// tableau columns 0..3, stock and waste empty.
    fn near_finished_state() -> GameState {
        let mut game = GameState::new(1, DrawStockConfig::DrawOne);
        game.set_test_stock_cards(Vec::new());
        game.set_test_waste_cards(Vec::new());
        for foundation in [
            Foundation::Foundation1,
            Foundation::Foundation2,
            Foundation::Foundation3,
            Foundation::Foundation4,
        ] {
            game.set_test_foundation_cards(foundation, Vec::new());
        }
        for tableau in solitaire_core::TABLEAUS {
            game.set_test_tableau_cards(tableau, Vec::new());
        }
        let suits = Suit::SUITS;
        let ranks_below_king = &Rank::RANKS[..12]; // everything below King
        for (foundation, suit) in [
            Foundation::Foundation1,
            Foundation::Foundation2,
            Foundation::Foundation3,
            Foundation::Foundation4,
        ]
        .into_iter()
        .zip(suits.iter())
        {
            let mut cards = Vec::new();
            for rank in ranks_below_king.iter() {
                cards.push(Card::new(Deck::Deck1, *suit, *rank));
            }
            game.set_test_foundation_cards(foundation, cards);
        }
        for (tableau, suit) in [
            Tableau::Tableau1,
            Tableau::Tableau2,
            Tableau::Tableau3,
            Tableau::Tableau4,
        ]
        .into_iter()
        .zip(suits.iter())
        {
            game.set_test_tableau_cards(
                tableau,
                vec![Card::new(Deck::Deck1, *suit, Rank::King)],
            );
        }
        game
    }

    /// Spawning a task and pumping update() until it completes must
    /// emit a HintVisualEvent. Mirrors the `winnable_seed_search_*`
    /// pattern in game_plugin tests — drives a wall-clock-bounded
    /// loop so the shared AsyncComputeTaskPool can schedule the
    /// future under cargo-test parallelism.
    #[test]
    fn winnable_solver_emits_hint_after_async_completes() {
        let mut app = pending_hint_app();
        app.insert_resource(GameStateResource(near_finished_state()));
        let cfg = *app.world().resource::<HintSolverConfig>();
        app.world_mut()
            .resource_mut::<PendingHintTask>()
            .spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget);

        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
        while app.world().resource::<PendingHintTask>().is_pending() {
            app.update();
            std::thread::yield_now();
            if std::time::Instant::now() >= deadline {
                break;
            }
        }
        assert!(
            !app.world().resource::<PendingHintTask>().is_pending(),
            "hint task should have completed within 15 s wall-clock",
        );
        let messages = app.world().resource::<Messages<HintVisualEvent>>();
        let mut cursor = messages.get_cursor();
        let collected: Vec<HintVisualEvent> = cursor.read(messages).cloned().collect();
        assert_eq!(
            collected.len(),
            1,
            "exactly one HintVisualEvent must fire when the solver returns Winnable",
        );
        assert!(
            matches!(collected[0].dest_pile, KlondikePile::Foundation(_)),
            "solver hint destination must be a foundation slot; got {:?}",
            collected[0].dest_pile,
        );
    }

    /// A StateChangedEvent fired while the task is in flight must
    /// drop the task; the polling system must not emit any visuals
    /// once the result eventually arrives.
    #[test]
    fn state_change_drops_in_flight_task() {
        let mut app = pending_hint_app();
        app.insert_resource(GameStateResource(near_finished_state()));
        let cfg = *app.world().resource::<HintSolverConfig>();
        app.world_mut()
            .resource_mut::<PendingHintTask>()
            .spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget);
        assert!(
            app.world().resource::<PendingHintTask>().is_pending(),
            "task is in flight after spawn",
        );

        // Fire a StateChangedEvent before draining the task. The
        // drop-on-state-change system runs in the same Update tick
        // and clears the resource.
        app.world_mut().write_message(StateChangedEvent);
        app.update();

        assert!(
            !app.world().resource::<PendingHintTask>().is_pending(),
            "StateChangedEvent must drop the in-flight hint task",
        );
        // No HintVisualEvent should ever have fired.
        let messages = app.world().resource::<Messages<HintVisualEvent>>();
        let mut cursor = messages.get_cursor();
        assert_eq!(
            cursor.read(messages).count(),
            0,
            "dropped hint task must not emit any visuals",
        );
    }

    /// Cancel-on-replace: spawning a fresh task while a previous one
    /// is in flight must drop the previous task. Only the second
    /// spawn's result is allowed to surface.
    #[test]
    fn second_spawn_drops_first_in_flight_task() {
        let mut app = pending_hint_app();
        app.insert_resource(GameStateResource(near_finished_state()));
        let cfg = *app.world().resource::<HintSolverConfig>();

        // First spawn.
        app.world_mut()
            .resource_mut::<PendingHintTask>()
            .spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget);
        let first_handle_present = app.world().resource::<PendingHintTask>().is_pending();
        assert!(first_handle_present);

        // Second spawn. The `spawn` helper drops the prior task
        // before assigning the new one — at no point are two tasks
        // in flight.
        app.world_mut()
            .resource_mut::<PendingHintTask>()
            .spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget);
        // Resource still pending (the second task), but the first
        // is gone. We can't directly observe the first handle once
        // it's been overwritten — what we *can* assert is that the
        // resource still holds a single task, and that task
        // eventually completes producing exactly one hint visual.
        assert!(app.world().resource::<PendingHintTask>().is_pending());

        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
        while app.world().resource::<PendingHintTask>().is_pending() {
            app.update();
            std::thread::yield_now();
            if std::time::Instant::now() >= deadline {
                break;
            }
        }
        assert!(
            !app.world().resource::<PendingHintTask>().is_pending(),
            "second hint task should have completed within 15 s wall-clock",
        );
        let messages = app.world().resource::<Messages<HintVisualEvent>>();
        let mut cursor = messages.get_cursor();
        let collected: Vec<HintVisualEvent> = cursor.read(messages).cloned().collect();
        assert_eq!(
            collected.len(),
            1,
            "cancel-on-replace: only the surviving task's result emits a visual",
        );
    }
}
