//! HUD feedback effects: action-bar fades, score pulses and floaters,
//! and streak flourishes.

use super::*;



/// Auto-fade state for the action button bar. The bar fades out when
/// the cursor is in the play area (below the HUD band) and back in when
/// the cursor approaches the top of the window — same UX as a video
/// player's auto-hide controls. Buttons remain fully interactive when
/// visible; when faded out they're geometrically out of cursor reach
/// (hover requires the cursor to be on a button), so no extra
/// pointer-events guard is needed.
#[derive(Resource, Debug, Clone, Copy)]
pub struct HudActionFade {
    /// Currently displayed alpha. Lerped toward `target` each frame.
    pub alpha: f32,
    /// Where `alpha` is heading — 0.0 (faded out) or 1.0 (visible).
    pub target: f32,
}

impl Default for HudActionFade {
    fn default() -> Self {
        // Start visible so the player sees the controls on first launch
        // before they've moved the cursor anywhere.
        Self {
            alpha: 1.0,
            target: 1.0,
        }
    }
}

/// How many pixels from the bottom edge the cursor must be to reveal the bar.
/// Set slightly taller than `HUD_BAND_HEIGHT` so the bar fades in as the
/// cursor approaches, not only when it crosses into the band itself.
#[cfg(not(target_os = "android"))]
const ACTION_FADE_REVEAL_PX: f32 = HUD_BAND_HEIGHT + 32.0;

/// Lerp rate for fading (per second). 6.0 ≈ 167 ms for a full
/// transition — fast enough to feel responsive without flashing on
/// brief cursor wanders into the reveal zone.
#[cfg(not(target_os = "android"))]
const ACTION_FADE_RATE_PER_SEC: f32 = 6.0;

/// Updates the fade state from cursor position. Sets `target = 1.0` if
/// the cursor is in the reveal zone (bottom of window) or off-screen
/// (player is using keyboard); `0.0` otherwise. Lerps `alpha` toward
/// `target` at a fixed rate so the visual transition is smooth across
/// variable framerates.
#[cfg(not(target_os = "android"))]
pub(super) fn update_action_fade(windows: Query<&Window>, time: Res<Time>, mut fade: ResMut<HudActionFade>) {
    let Ok(window) = windows.single() else {
        return;
    };
    let height = window.resolution.height();
    fade.target = match window.cursor_position() {
        Some(pos) if pos.y >= height - ACTION_FADE_REVEAL_PX => 1.0,
        Some(_) => 0.0,
        // Off-window cursor: assume keyboard navigation and keep the
        // bar visible so Tab cycling doesn't lead to invisible focus.
        None => 1.0,
    };

    let dt = time.delta_secs();
    let max_step = ACTION_FADE_RATE_PER_SEC * dt;
    let diff = fade.target - fade.alpha;
    fade.alpha = (fade.alpha + diff.clamp(-max_step, max_step)).clamp(0.0, 1.0);
}

/// Applies the current fade alpha to every action button's
/// `BackgroundColor` and to its child label / hotkey-chip text. Runs in
/// `Last` (after `paint_action_buttons`) so a hover-state change in the
/// same frame doesn't override the fade with an opaque idle / hover
/// colour.
#[cfg(not(target_os = "android"))]
#[allow(clippy::type_complexity)]
pub(super) fn apply_action_fade(
    fade: Res<HudActionFade>,
    // Excludes `PopoverRow` so the auto-fade only applies to the
    // top-level action bar buttons. Popover rows live inside an
    // explicitly-opened dropdown panel and need to stay visible
    // regardless of the bar's fade state — without the exclusion
    // the rows fade to invisible while the popover container stays
    // visible, leaving a solid background block with no readable
    // content.
    mut buttons: Query<
        (&Children, &mut BackgroundColor),
        (With<ActionButton>, Without<PopoverRow>),
    >,
    mut text_q: Query<&mut TextColor>,
) {
    for (children, mut bg) in &mut buttons {
        let mut c = bg.0;
        c.set_alpha(fade.alpha);
        bg.0 = c;
        for child in children.iter() {
            if let Ok(mut tc) = text_q.get_mut(child) {
                let mut cc = tc.0;
                cc.set_alpha(fade.alpha);
                tc.0 = cc;
            }
        }
    }
}

/// Visual feedback for every action button — paints idle / hover / pressed
/// states by mutating `BackgroundColor` whenever the interaction state
/// changes. One query covers all action buttons via the shared
/// `ActionButton` marker.
#[allow(clippy::type_complexity)]
pub(super) fn paint_action_buttons(
    mut buttons: Query<
        (&Interaction, &mut BackgroundColor),
        (With<ActionButton>, Changed<Interaction>),
    >,
) {
    for (interaction, mut bg) in &mut buttons {
        bg.0 = match interaction {
            Interaction::Pressed => ACTION_BTN_PRESSED,
            Interaction::Hovered => ACTION_BTN_HOVER,
            Interaction::None => ACTION_BTN_IDLE,
        };
    }
}

/// Triangular 1.0 → 1.1 → 1.0 curve used by the score pulse. Pure
/// function so the test suite can assert on the curve directly
/// without spinning up a Bevy app.
///
/// The brief proposed `if t < 0.5 { 1.0 + 0.2*t } else { 1.2 - 0.2*(t-0.5) }`,
/// but that yields a discontinuity at t=0.5 (jumps from 1.1 → 1.2) and
/// ends at 1.1 instead of 1.0. The corrected form below preserves the
/// intent ("1.0 → 1.1 → 1.0 over the duration") with a continuous
/// triangle peaking at 1.1.
pub(super) fn score_pulse_scale(t: f32) -> f32 {
    let clamped = t.clamp(0.0, 1.0);
    if clamped < 0.5 {
        1.0 + 0.2 * clamped
    } else {
        1.1 - 0.2 * (clamped - 0.5)
    }
}

/// Vertical pixels the floating "+N" drifts up over its lifetime.
const FLOATER_DRIFT_PX: f32 = 40.0;

/// Diffs the current `GameStateResource.score` against
/// [`PreviousScore`]. On a positive delta:
///
/// - Inserts (or refreshes) a [`ScorePulse`] on every [`HudScore`] entity
///   so the readout pulses 1.0 → 1.1 → 1.0.
/// - When the delta is ≥ [`SCORE_FLOATER_THRESHOLD`], spawns a floating
///   "+N" UI text in `ACCENT_PRIMARY` anchored just below the score
///   readout (see the doc comment on [`ScoreFloater`] for why this is a
///   UI Node rather than a `Text2d`).
pub(super) fn detect_score_change(
    game: Res<GameStateResource>,
    settings: Option<Res<SettingsResource>>,
    mut prev: ResMut<PreviousScore>,
    font_res: Option<Res<FontResource>>,
    score_q: Query<Entity, With<HudScore>>,
    mut commands: Commands,
) {
    let current = game.0.score();
    let delta = current - prev.0;
    prev.0 = current;
    if delta <= 0 {
        return;
    }

    let reduce_motion = settings.as_deref().is_some_and(|s| s.0.reduce_motion_mode);
    if reduce_motion {
        return;
    }

    let speed = settings
        .as_ref()
        .map(|s| s.0.animation_speed)
        .unwrap_or_default();
    let pulse_secs = scaled_duration(MOTION_SCORE_PULSE_SECS, speed);
    let floater_secs = scaled_duration(MOTION_SCORE_PULSE_SECS * 2.0, speed);

    // Refresh ScorePulse on every score readout entity (in practice
    // there's exactly one, but iterating is cheaper than asserting).
    for entity in &score_q {
        commands.entity(entity).insert(ScorePulse {
            elapsed: 0.0,
            duration: pulse_secs,
        });
    }

    if delta < SCORE_FLOATER_THRESHOLD {
        return;
    }

    let font = TextFont {
        font: font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(),
        font_size: TYPE_BODY_LG,
        ..default()
    };
    // Spawned as an absolutely-positioned UI Node so the floater rides
    // the same screen-coordinate system as the score readout. Using a
    // `Text2d` here would require translating UI layout coordinates to
    // world space every frame; a UI node piggybacks on the same
    // anchoring `update_hud` already uses for the score and stays
    // testable under `MinimalPlugins`.
    commands.spawn((
        ScoreFloater {
            elapsed: 0.0,
            duration: floater_secs,
        },
        Node {
            position_type: PositionType::Absolute,
            // Anchored next to the HUD column; matches the
            // `spawn_hud` left/top offsets so the floater appears
            // overlaid on the score line and drifts up from there.
            left: VAL_SPACE_3,
            top: Val::Px(0.0),
            ..default()
        },
        ZIndex(Z_HUD_TOP),
        Text::new(format!("+{delta}")),
        font,
        TextColor(ACCENT_PRIMARY),
    ));
}

/// Advances every [`ScorePulse`], scaling its entity's `Transform`
/// using [`score_pulse_scale`]. Removes the component once
/// `elapsed >= duration` (or immediately under
/// [`AnimSpeed::Instant`](solitaire_data::AnimSpeed) where duration is
/// 0) and pins the scale back to 1.0 so no float drift survives.
pub(super) fn advance_score_pulse(
    time: Res<Time>,
    mut commands: Commands,
    mut q: Query<(Entity, &mut ScorePulse, &mut Transform)>,
) {
    let dt = time.delta_secs();
    for (entity, mut pulse, mut transform) in &mut q {
        let t = if pulse.duration <= 0.0 {
            1.0
        } else {
            pulse.elapsed += dt;
            (pulse.elapsed / pulse.duration).clamp(0.0, 1.0)
        };
        let scale = score_pulse_scale(t);
        transform.scale = Vec3::new(scale, scale, 1.0);
        if t >= 1.0 {
            transform.scale = Vec3::ONE;
            commands.entity(entity).remove::<ScorePulse>();
        }
    }
}

/// Advances every [`ScoreFloater`]: drifts the node upward by up to
/// [`FLOATER_DRIFT_PX`] and fades the text colour to transparent over
/// its lifetime. Despawns the entity once `elapsed >= duration`.
pub(super) fn advance_score_floater(
    time: Res<Time>,
    mut commands: Commands,
    mut nodes: Query<(Entity, &mut ScoreFloater, &mut Node, &mut TextColor)>,
) {
    let dt = time.delta_secs();
    for (entity, mut floater, mut node, mut color) in &mut nodes {
        let t = if floater.duration <= 0.0 {
            1.0
        } else {
            floater.elapsed += dt;
            (floater.elapsed / floater.duration).clamp(0.0, 1.0)
        };
        // Drift upward: top decreases as t grows. Starting top=0 keeps
        // the floater on the score line; ending at -FLOATER_DRIFT_PX
        // pulls it up off the readout.
        node.top = Val::Px(-FLOATER_DRIFT_PX * t);
        // Linear fade: ACCENT_PRIMARY at t=0 → fully transparent at t=1.
        let mut c = ACCENT_PRIMARY;
        c.set_alpha(1.0 - t);
        color.0 = c;
        if t >= 1.0 {
            commands.entity(entity).despawn();
        }
    }
}

// ---------------------------------------------------------------------------
// Streak-milestone flourish
//
// Per the 2026-04-30 UX overhaul plan, the foundation flourish is the per-suit
// completion celebration; the streak flourish is its lifetime equivalent —
// when the player's `win_streak_current` crosses 3, 5, or 10, the HUD score
// readout pulses larger than a normal score-change pulse and tints magenta
// (`ACCENT_SECONDARY`) before snapping back to its resting state.
//
// Why the score readout: there is no always-on streak number on the HUD
// today (the readout lives in the Stats overlay), and the score is the
// most prominent always-visible HUD figure. The accompanying `InfoToastEvent`
// fired by `stats_plugin` carries the explicit "Win streak: N!" text so a
// player who isn't watching the score still sees the celebration land.
// ---------------------------------------------------------------------------

/// Pure helper for unit tests — returns the per-frame scale factor for
/// the streak flourish at `elapsed_secs` over `duration_secs`.
///
/// Triangular curve, mirroring [`foundation_flourish_scale`](crate::feedback_anim_plugin::foundation_flourish_scale):
/// at `t = 0.0` returns `1.0`, at `t = 0.5` returns
/// [`STREAK_FLOURISH_PEAK_SCALE`], at `t = 1.0` returns `1.0`.
/// Out-of-range values are clamped so the score readout never freezes
/// at a non-1.0 scale on the frame after the flourish ends.
///
/// Returns `1.0` whenever `duration_secs <= 0.0` so callers running
/// under `AnimSpeed::Instant` (zeroed durations) skip the flourish
/// without dividing by zero.
pub fn streak_flourish_scale(elapsed_secs: f32, duration_secs: f32) -> f32 {
    if duration_secs <= 0.0 {
        return 1.0;
    }
    let t = (elapsed_secs / duration_secs).clamp(0.0, 1.0);
    let peak = STREAK_FLOURISH_PEAK_SCALE;
    if t < 0.5 {
        // Climb from 1.0 at t=0 to peak at t=0.5.
        1.0 + (peak - 1.0) * (t / 0.5)
    } else {
        // Descend from peak at t=0.5 back to 1.0 at t=1.0.
        peak - (peak - 1.0) * ((t - 0.5) / 0.5)
    }
}

/// Inserts a [`StreakFlourish`] on every [`HudScore`] entity when a
/// [`WinStreakMilestoneEvent`] fires. Captures the readout's current
/// `TextColor` so `advance_streak_flourish` can restore it when the
/// timer expires; reuses any existing flourish's `original_color` so
/// re-entering the system mid-flourish doesn't snapshot the magenta
/// tint as the new "original".
///
/// Removes any concurrent [`ScorePulse`] from the same entity so the
/// flourish takes over the scale slot cleanly — score pulses last
/// 250 ms, the flourish 600 ms, and the streak crossing always
/// coincides with a positive score delta, so the flourish is the
/// louder of the two celebrations.
pub(super) fn start_streak_flourish(
    mut events: MessageReader<WinStreakMilestoneEvent>,
    settings: Option<Res<SettingsResource>>,
    score_q: Query<(Entity, &TextColor, Option<&StreakFlourish>), With<HudScore>>,
    mut commands: Commands,
) {
    let Some(latest) = events.read().last() else {
        return;
    };
    if settings.as_deref().is_some_and(|s| s.0.reduce_motion_mode) {
        return;
    }
    let speed = settings
        .as_ref()
        .map(|s| s.0.animation_speed)
        .unwrap_or_default();
    let duration = scaled_duration(MOTION_STREAK_FLOURISH_SECS, speed);
    for (entity, color, existing) in &score_q {
        let original_color = existing.map_or(color.0, |f| f.original_color);
        commands
            .entity(entity)
            .remove::<ScorePulse>()
            .insert(StreakFlourish {
                streak: latest.streak,
                elapsed: 0.0,
                duration,
                original_color,
            });
    }
}

/// Advances every [`StreakFlourish`], scaling its entity's `Transform`
/// using [`streak_flourish_scale`] and lerping the `TextColor` toward
/// [`ACCENT_SECONDARY`] for the first half then back to the captured
/// `original_color`. Removes the component once `elapsed >= duration`
/// (or immediately under [`AnimSpeed::Instant`](solitaire_data::AnimSpeed)
/// where duration is 0) and pins the scale back to 1.0 / restores the
/// original colour so no half-state is ever shown.
///
/// Filtered with `Without<ScorePulse>` so the streak flourish never
/// races a score pulse for the same `Transform.scale` slot —
/// `start_streak_flourish` strips any concurrent `ScorePulse` from the
/// score entity before this system runs, so the filter is purely a
/// belt-and-braces invariant.
pub(super) fn advance_streak_flourish(
    time: Res<Time>,
    mut commands: Commands,
    mut q: Query<
        (Entity, &mut StreakFlourish, &mut Transform, &mut TextColor),
        Without<ScorePulse>,
    >,
) {
    let dt = time.delta_secs();
    for (entity, mut anim, mut transform, mut color) in &mut q {
        let t = if anim.duration <= 0.0 {
            1.0
        } else {
            anim.elapsed += dt;
            (anim.elapsed / anim.duration).clamp(0.0, 1.0)
        };
        let scale = streak_flourish_scale(anim.elapsed, anim.duration);
        transform.scale = Vec3::new(scale, scale, 1.0);
        // Tint mix: full magenta at t=0..=0.5, fades back to the
        // original colour over t=0.5..=1.0.
        let mix = if t < 0.5 { 1.0 } else { 1.0 - (t - 0.5) / 0.5 };
        color.0 = lerp_text_color(anim.original_color, ACCENT_SECONDARY, mix);
        if t >= 1.0 {
            transform.scale = Vec3::ONE;
            color.0 = anim.original_color;
            commands.entity(entity).remove::<StreakFlourish>();
        }
    }
}

/// sRGB-space linear interpolation between two `Color`s — small local
/// helper so `advance_streak_flourish` stays readable. sRGB-space
/// lerping is fine for a brief decorative tint (a perceptually-uniform
/// space would be overkill).
pub(super) fn lerp_text_color(from: Color, to: Color, t: f32) -> Color {
    let from = from.to_srgba();
    let to = to.to_srgba();
    let t = t.clamp(0.0, 1.0);
    Color::srgba(
        from.red + (to.red - from.red) * t,
        from.green + (to.green - from.green) * t,
        from.blue + (to.blue - from.blue) * t,
        from.alpha + (to.alpha - from.alpha) * t,
    )
}

