//! Transient user feedback: toasts, ephemeral tips, mode-switch banners,
//! terminal-size notes, clipboard-copy feedback, and their tick timers.

use super::{
    ActivePane, AgentView, CLIPBOARD_TOAST_DEBOUNCE_MS, MODE_BANNER_TOTAL_TICKS, PromptInputMode,
};
#[cfg(test)]
use super::{AgentPane, test_fixtures};
use crate::app::actions::Action;
use std::time::Instant;

impl AgentView {
    /// Show a brief toast message (e.g., "Copied!").
    ///
    /// Displayed for ~3 seconds (90 ticks at 30fps). Previous transient toast
    /// is replaced; [`Self::sticky_toast`] is preserved and returns after this
    /// expires or is dismissed.
    pub fn show_toast(&mut self, msg: &str) {
        let msg = crate::glyphs::legacy_glyph_fallback(msg).into_owned();
        self.toast = Some((msg, 90));
    }

    /// Show an ephemeral tip in the banner row above the prompt, gated by the
    /// app-level per-session `seen_counts` map (`AppView::tip_seen_counts`).
    /// Returns true when the tip was newly shown (and the per-session count
    /// incremented in place — never persisted to disk).
    ///
    /// No-op while the row cannot paint (an occluding view — permission,
    /// question, modal, subagent takeover, fullscreen viewer, `/gboom`, the
    /// extensions/agents modals, the goal-detail overlay, or an open prompt
    /// dropdown — a short terminal, the unknown size before the first draw, or a
    /// pending re-measure right after a resize event) so counts, TTL, and
    /// telemetry never burn on an invisible tip.
    pub fn show_ephemeral_tip(
        &mut self,
        tip: crate::tips::EphemeralTip,
        seen_counts: &mut std::collections::HashMap<&'static str, u32>,
    ) -> bool {
        if !self.ephemeral_tip_renderable(self.last_terminal_size.1) {
            return false;
        }
        self.ephemeral_tip.show(tip, seen_counts)
    }

    /// Whether the tip row could paint right now (last drawn size). Lets
    /// app-level triggers skip work that the show gate would refuse anyway.
    pub(crate) fn ephemeral_tip_can_render(&self) -> bool {
        self.ephemeral_tip_renderable(self.last_terminal_size.1)
    }

    /// Single definition of the agent-level eligibility for the clipboard-image
    /// tip: the tip row can paint, no image chips are already attached, and the
    /// current model accepts image input.
    pub(crate) fn clipboard_image_tip_eligible(&self) -> bool {
        self.ephemeral_tip_can_render()
            && self.prompt.images.is_empty()
            && self.session.models.current_model_accepts_images()
    }

    /// One-shot undo-tip show signal from the last `PromptWidget::handle_key`,
    /// routed as an action so dispatch can reach `app.tip_seen_counts`. Fires
    /// only on a qualifying wipe (set exclusively on `PromptEvent::Edited`).
    pub(super) fn take_prompt_tip_signal(&mut self) -> Option<Action> {
        // Undo (wipe-to-empty) takes precedence; the wipe and the typed-keyword
        // nudge are mutually exclusive on a single keypress, so one
        // `Option<Action>` suffices. The plan nudge is suppressed when already
        // in plan mode (the optimistic read), while the turn is busy, or in a
        // special prompt input mode (bash/feedback/remember).
        if self.prompt.take_undo_tip_fire() {
            return Some(Action::ShowUndoTip);
        }
        let in_plan = self.plan_mode_pending.unwrap_or(self.plan_mode_active);
        if self.prompt.take_plan_nudge_fire()
            && !in_plan
            && self.session.state.is_idle()
            && self.prompt_input_mode == PromptInputMode::Normal
        {
            return Some(Action::ShowPlanNudge);
        }
        None
    }

    /// Whether the ephemeral tip needs tick / animation this frame.
    /// Ambient tips freeze under EVERY occluder (permission ask, modal,
    /// dropdown): their TTL burns only while the row can paint, so an
    /// occluder pauses rather than expires them off-screen.
    pub(crate) fn ephemeral_tip_needs_tick(&self) -> bool {
        self.ephemeral_tip.is_active()
            && (!self.ephemeral_tip.active_is_ambient() || self.ephemeral_tip_can_render())
    }

    /// Advance tip TTL only when the tip is allowed to tick (see
    /// [`Self::ephemeral_tip_needs_tick`]).
    pub(crate) fn tick_ephemeral_tip(&mut self) -> bool {
        // Word-select tip lifecycle: any prompt divergence since the tip was
        // shown (typed, pasted, dropped — every edit path funnels into the
        // prompt text) retires it, and the snapshot drops once the tip is
        // gone for any reason. A visible tip is always ticking (it arms the
        // metronome), so the sweep runs within a frame of the edit.
        if self.ephemeral_tip.current_key() == Some(crate::tips::word_select::WORD_SELECT_TIP_KEY) {
            if self.word_select_tip_prompt_snapshot.as_deref() != Some(self.prompt.text()) {
                self.ephemeral_tip
                    .clear(crate::tips::word_select::WORD_SELECT_TIP_KEY);
                self.word_select_tip_prompt_snapshot = None;
                return true;
            }
        } else if self.word_select_tip_prompt_snapshot.is_some() {
            self.word_select_tip_prompt_snapshot = None;
        }
        if !self.ephemeral_tip_needs_tick() {
            return false;
        }
        self.ephemeral_tip.tick()
    }

    /// Unified visibility for the ephemeral tip row: no occluding view, a
    /// tall-enough screen, and no resize since the height was measured. Shared
    /// by the show gate and the draw path (reserve + paint), so a view opening
    /// over an already-shown tip also stops the row's reservation until it
    /// closes.
    ///
    /// Most occluders leave an edit-contextual tip active with TTL still
    /// burning (tip may repaint on close). AMBIENT tips freeze under any
    /// occluder: paint yields **and** [`Self::tick_ephemeral_tip`] freezes TTL
    /// so a long-lived occluder cannot burn the tip off-screen or keep
    /// `needs_animation` hot.
    ///
    /// An occluder is anything that, later in the same frame, keeps the banner
    /// row from reaching the user. The transient mode-switch banner and the
    /// inline `/btw` panel are deliberately NOT occluders: the banner owns the
    /// slot ~2 s while the tip's TTL ticks, and `/btw` has its own layout slot
    /// above the banner.
    ///
    /// Drift warning: banner-covering views are also enumerated in two sibling
    /// hand-maintained lists — the pre-overlay inline-media clear in `draw` and
    /// the per-frame `frame_occluder_rects` (dropdowns + goal detail). A new
    /// banner-covering view must be added here too.
    pub(super) fn ephemeral_tip_renderable(&self, screen_height: u16) -> bool {
        let occluded = !self.permission_queue.is_empty()
            || self.question_view.is_some()
            || self.active_modal.is_some()
            // Subagent fullscreen takeover: draw early-returns into
            // draw_subagent_fullscreen and never paints the parent banner.
            || self.active_subagent.is_some()
            // Fullscreen viewers render after the banner paints: image/video/
            // block dim the whole region down to the shortcuts row (banner
            // included). line_viewer's overlay stops at turn_status.y when a
            // turn status shows, so it does NOT always cover the banner — kept
            // anyway as a safe over-refusal (the gate cannot know layout
            // heights, and a tip during viewer reading is unwanted regardless).
            || self.line_viewer.is_some()
            || self.image_viewer.is_some()
            || self.video_viewer.is_some()
            || self.block_viewer.is_some()
            // /gboom dims the same down-to-shortcuts region as the video viewer.
            || self.gboom.is_some()
            // Extensions/agents modals are centered popups (render_modal_window)
            // that capture all input and early-return out of draw; distinct
            // from active_modal. persona_detail only renders atop the agents
            // modal. A tip could at most peek beside the modal, so refuse.
            || self.extensions_modal.is_some()
            || self.agents_modal.is_some()
            // Goal-detail is a vertically-centered overlay painted after the
            // tip; its box only reaches the banner row for tall/content-rich
            // goals, but kept unconditional as a safe over-refusal (like the
            // modals and line_viewer) since a tip during goal reading is
            // unwanted regardless.
            || (self.show_goal_detail && self.goal_state.is_some())
            // Prompt dropdowns (@/slash/completion/history) render in the
            // row directly above the prompt — the banner row — clearing it.
            || self.prompt.any_dropdown_open();
        !self.terminal_size_stale && crate::tips::tip_row_renderable(occluded, screen_height)
    }

    /// Draw-path re-measure: record the size of the rect this view painted
    /// into, invalidating Kitty image IDs when it changed (terminals clear
    /// GPU data on resize), and mark the measurement fresh again.
    ///
    /// Only draw calls this — the rect can be smaller than the terminal
    /// (dashboard overlay header band/popup, dev tracing split), so a
    /// resize event must NOT write an extrapolated size here; it flags
    /// staleness via `note_terminal_resize` instead and the next draw
    /// re-measures.
    pub(crate) fn note_terminal_size(&mut self, size: (u16, u16)) {
        if self.last_terminal_size != (0, 0) && self.last_terminal_size != size {
            self.inline_media_ids.clear();
            self.inline_media_iterm_emitted.clear();
            crate::terminal::overlay::reset_owner();
        }
        self.last_terminal_size = size;
        self.terminal_size_stale = false;
    }

    /// Event-path resize note: the terminal changed size, so the height in
    /// `last_terminal_size` no longer describes what this view can paint —
    /// chrome (dashboard overlay header/popup, dev tracing split) means the
    /// view's rect is not derivable from the event's full-terminal size.
    /// The ephemeral-tip show gate refuses until the next draw re-measures;
    /// resize draws are debounced (`RESIZE_DEBOUNCE`), so that window is a
    /// frame's worth of events, and a refusal burns nothing.
    pub(crate) fn note_terminal_resize(&mut self) {
        self.terminal_size_stale = true;
    }

    /// Set or clear the sticky status banner (process-wide indicators should
    /// use [`Self::set_sticky_toast_recursive`] on every agent view).
    pub fn set_sticky_toast(&mut self, msg: Option<&str>) {
        self.sticky_toast = msg.map(|m| crate::glyphs::legacy_glyph_fallback(m).into_owned());
    }

    /// Propagate sticky status to this view and every nested subagent view.
    pub fn set_sticky_toast_recursive(&mut self, msg: Option<&str>) {
        self.set_sticky_toast(msg);
        for child in self.subagent_views.values_mut() {
            child.set_sticky_toast_recursive(msg);
        }
    }

    /// Show a toast with an explicit tick duration.
    pub fn show_toast_ticks(&mut self, msg: &str, ticks: u8) {
        let msg = crate::glyphs::legacy_glyph_fallback(msg).into_owned();
        self.toast = Some((msg, ticks));
    }

    /// Message currently drawn in the toast slot: transient wins while active,
    /// otherwise sticky status (if any).
    pub(super) fn active_toast_message(&self) -> Option<&str> {
        if let Some((ref msg, _)) = self.toast {
            return Some(msg.as_str());
        }
        let sticky = self.sticky_toast.as_deref()?;
        // The mouse-off banner advertises how to re-enable. `Ctrl+R` only works
        // from scrollback, so when the prompt is focused show the
        // `/toggle-mouse-reporting` command instead (it toggles from any pane).
        // Storage keeps the scrollback form; swap the displayed text here.
        if sticky == crate::app::MOUSE_OFF_HINT_SCROLLBACK && self.active_pane == ActivePane::Prompt
        {
            return Some(crate::app::MOUSE_OFF_HINT_PROMPT);
        }
        Some(sticky)
    }

    /// Show a transient "Switched to mode: ..." banner above the prompt.
    ///
    /// Triggered on Shift+Tab mode cycles.
    /// Renders at full visibility for 2 s, then fades out over the final 0.3 s.
    pub fn show_mode_switch_banner(&mut self, mode_name: &str) {
        let msg = format!("Switched to mode: {}", mode_name);
        self.mode_switch_banner = Some((msg, MODE_BANNER_TOTAL_TICKS));
    }

    /// Tick the mode-switch banner timer. Returns true if redraw needed
    /// (active or just expired).
    pub fn tick_mode_banner(&mut self) -> bool {
        if let Some((_, ref mut remaining)) = self.mode_switch_banner {
            if *remaining == 0 {
                self.mode_switch_banner = None;
                return true;
            }
            *remaining = remaining.saturating_sub(1);
            return true; // redraw to advance fade
        }
        false
    }

    /// Copy text to clipboard and show the result toast.
    pub fn copy_to_clipboard(&mut self, text: &str) -> bool {
        let r = crate::clipboard::copy_text(text);
        self.show_toast_ticks(r.message, r.ticks);
        r.success
    }

    /// Like [`copy_to_clipboard`] but debounces the toast to prevent
    /// rapid flickering during quick word/line selections.
    pub(super) fn copy_to_clipboard_debounced(&mut self, text: &str) {
        let now = Instant::now();
        let too_soon = self
            .last_clipboard_toast_at
            .is_some_and(|t| now.duration_since(t).as_millis() < CLIPBOARD_TOAST_DEBOUNCE_MS);
        if too_soon {
            // Still copy, just skip the toast.
            let _ = crate::clipboard::copy_text(text);
            return;
        }
        self.last_clipboard_toast_at = Some(now);
        self.copy_to_clipboard(text);
    }

    /// Returns `true` if the terminal can render pixel images. Shows a
    /// toast and returns `false` when no graphics protocol is available.
    pub(crate) fn guard_image_support(&mut self) -> bool {
        if crate::terminal::image::detect_graphics_protocol().supports_images() {
            return true;
        }
        let msg = match crate::terminal::terminal_context().graphics_protocol_skip_reason() {
            Some("tmux") => "Inline images disabled within tmux.",
            _ => "Image rendering not supported in this terminal",
        };
        self.show_toast_ticks(msg, 60);
        false
    }

    /// Tick the transient toast timer. Call once per animation tick.
    /// Returns true if the transient toast was removed (needs redraw so a
    /// sticky banner can reappear).
    pub fn tick_toast(&mut self) -> bool {
        if let Some((_, ref mut remaining)) = self.toast {
            if *remaining == 0 {
                self.toast = None;
                return true;
            }
            *remaining = remaining.saturating_sub(1);
        }
        false
    }

    /// Tick the extensions modal's transient result notice. Returns true if it
    /// just expired (needs a redraw to erase the badge / status line).
    pub fn tick_extensions_result_notice(&mut self) -> bool {
        self.extensions_modal
            .as_mut()
            .is_some_and(|m| m.tick_result_notice())
    }
}

#[cfg(test)]
mod mouse_off_banner_tests {
    use super::test_fixtures::make_running_agent;
    use super::*;

    #[test]
    fn mouse_off_banner_key_swaps_with_focused_pane() {
        let mut view = make_running_agent();
        view.set_sticky_toast(Some(crate::app::MOUSE_OFF_HINT_SCROLLBACK));

        // Scrollback focus: Ctrl+R works there, so advertise it.
        view.active_pane = AgentPane::Scrollback;
        assert_eq!(
            view.active_toast_message(),
            Some(crate::app::MOUSE_OFF_HINT_SCROLLBACK)
        );

        // Prompt focus: the toggle chord is scrollback-only, so advertise the command.
        view.active_pane = AgentPane::Prompt;
        assert_eq!(
            view.active_toast_message(),
            Some(crate::app::MOUSE_OFF_HINT_PROMPT)
        );

        // A transient toast still wins over the sticky banner, regardless of pane.
        view.show_toast("Copied!");
        assert_eq!(view.active_toast_message(), Some("Copied!"));
    }

    #[test]
    fn non_mouse_sticky_banner_is_not_swapped() {
        let mut view = make_running_agent();
        view.set_sticky_toast(Some("Reconnecting"));
        view.active_pane = AgentPane::Prompt;
        assert_eq!(view.active_toast_message(), Some("Reconnecting"));
    }
}
