//! Pure MVU state tree for the Capo TUI.

use serde_json::Value;
use tokio::sync::oneshot;

use capo_agent::{BranchTree, Decision, PermissionChoice, UiToolResult};

/// Top-level TUI state.
#[derive(Debug, Default)]
pub struct AppState {
    pub messages: Vec<MessageBlock>,
    pub in_flight: Option<InFlight>,
    pub editor: EditorState,
    pub footer: FooterState,
    /// When `Some`, the bottom pane (approval list / etc.) takes the
    /// editor's slot. `None` = normal editor visible.
    pub bottom_pane: Option<BottomPane>,
    /// Project file list for the `@file` picker, built once at startup.
    pub file_index: crate::file_index::FileIndex,
    /// Recent sessions for the `/resume` picker, newest first. Loaded once
    /// by the binary at startup.
    pub sessions: Vec<SessionSummary>,
    /// Active-branch user messages for the `/fork` picker, newest first.
    /// Seeded by the binary after each turn.
    pub fork_candidates: Vec<ForkCandidate>,
    /// When `Some`, the next user message branches from this entry instead
    /// of continuing linearly. Set when a `ForkPicker`/`BranchTree` selection
    /// is made; cleared after the message is sent.
    pub pending_fork: Option<String>,
    /// Optimistic user-message metadata for a just-submitted turn. If
    /// capo-agent rejects the attachment or an extension cancels before
    /// starting a turn, this lets the TUI remove the ghost transcript row
    /// and restore the original composer buffer for editing.
    pub pending_turn_submit: Option<PendingTurnSubmit>,
    /// Slash commands registered by extensions, populated from
    /// `UiEvent::ExtensionList` at startup. The palette filter merges
    /// these with the built-in commands so `/todo` (if registered) appears
    /// alongside `/help`, `/quit`, etc.
    pub extension_commands: Vec<String>,
    /// The session branch tree for the `/tree` view. Seeded by the binary
    /// at startup and refreshed after every turn (via `UiEvent::BranchTree`).
    pub branch_tree: Option<BranchTree>,
    /// Snapshot of the live settings, used by the `/settings` editor pane.
    /// Populated by the binary at startup and refreshed by
    /// `UiEvent::SettingsSnapshot` after successful settings changes.
    pub settings_snapshot: Option<capo_agent::Settings>,
    /// v0.10 Phase A: current permission mode (mirrors App's mode via
    /// UiEvent::PermissionModeChanged). Display only; mutations flow
    /// out via Command::SetPermissionMode → App.
    pub permission_mode: capo_agent::PermissionMode,
    /// v0.9 Phase A: global expand-all in-session flag.
    /// Toggled by Ctrl+E from anywhere in the TUI. When true, every
    /// collapse rule (settings flags + per-block `collapsed: bool`) is
    /// overridden — all blocks render full. Not persisted to settings.toml;
    /// restart resets to false.
    pub expanded_all: bool,
    /// v0.9 Phase B: in-session history-navigation mode. True = F3-entered,
    /// editor disabled, arrow keys move `nav_selected`. Toggled by F3 / Esc.
    pub nav_mode: bool,
    /// v0.9 Phase B: index into `messages` of the currently-highlighted block
    /// while in nav mode. `None` if no visible blocks exist or nav_mode is
    /// false. On F3 entry, snaps to the most-recent visible block.
    pub nav_selected: Option<usize>,
    /// v0.9 Phase B: tracks `gg` two-key sequence. Set on first `g` press;
    /// any other key (or 1-second timeout, checked at second-`g`) clears it.
    /// Spec §4.3.
    pub nav_g_pending_at: Option<std::time::Instant>,
    /// v0.9 Phase B review fix: explicit per-index collapse overrides.
    /// `MessageBlock::*::collapsed` stores the chosen bool, but defaults can
    /// also collapse blocks, so this records whether Space means force
    /// collapsed (`true`) or force expanded (`false`) for the session.
    pub collapse_overrides: std::collections::BTreeMap<usize, bool>,
    /// v0.10 Phase B-2: session ID of the displayed transcript. Used to
    /// distinguish same-session rebuilds (preserve `collapse_overrides`) from
    /// true session changes (clear index-keyed overrides).
    pub current_session_id: Option<String>,
    pub should_quit: bool,
    pub last_ctrl_c: Option<std::time::Instant>,
    /// Manual transcript scrollback in *wrapped rows from the current
    /// bottom*. `0` = auto-follow (user is at the bottom; new content keeps
    /// the view anchored). Positive values pin the viewport that many rows
    /// above the bottom; when new content arrives the user stays at the
    /// same logical reading position because the offset is computed
    /// relative-to-bottom every frame. PgUp increments, PgDn decrements
    /// (saturating to 0 = resume auto-follow), Home jumps to a large
    /// sentinel (render clamps to top), End resets to 0.
    pub scroll_lines_from_bottom: u16,
    /// v0.10 Phase B-3: last-known messages viewport `(width, height)`,
    /// captured by the messages widget via interior mutability so render
    /// signatures stay `&AppState`.
    pub last_messages_viewport: std::cell::Cell<Option<(u16, u16)>>,
}

#[derive(Debug, Clone)]
pub enum MessageBlock {
    User {
        text: String,
        /// Filenames (NOT full paths) of attachments in this user
        /// message. Empty for text-only messages. The renderer composes
        /// the `↳ image: ...` continuation line from these.
        attachment_filenames: Vec<String>,
    },
    Assistant {
        text: String,
        /// v0.9 Phase B: per-block collapse toggle for the F3 nav-mode Space
        /// key. Default `false` (full view). Settings `collapse_history_after`
        /// can still collapse-by-default at render time (per §2.3 predicates).
        collapsed: bool,
    },
    ToolCall {
        id: String,
        name: String,
        args: Value,
        collapsed: bool,
    },
    ToolResult {
        id: String,
        result: UiToolResult,
        collapsed: bool,
    },
    Error(String),
    /// A `!!cmd` inline-bash result (or other non-agent transcript note).
    Notice {
        title: String,
        body: String,
    },
    /// v0.9 Phase A: a thinking/reasoning block produced by Claude
    /// (or other reasoning-capable model). Extracted from
    /// `AssistantContent::Reasoning` in motosan-agent-loop's completed
    /// Messages; emitted by capo-agent as `UiEvent::ThinkingComplete`.
    /// Rendering: dim italic in full view, `💭 thinking (N lines)` 1-liner
    /// when `collapsed=true`. Hidden entirely when
    /// `settings.ui.hide_thinking_blocks = true`.
    Thinking {
        text: String,
        collapsed: bool,
    },
}

#[derive(Debug, Clone)]
pub enum InFlight {
    Thinking,
    StreamingText {
        partial: String,
    },
    ToolCall {
        id: String,
        name: String,
        args: Value,
    },
    ToolRunning {
        id: String,
        name: String,
        stdout: String,
        stderr: String,
    },
}

#[derive(Debug, Default)]
pub struct EditorState {
    pub text: String,
    pub cursor_byte: usize,
}

#[derive(Debug, Clone)]
pub struct PendingTurnSubmit {
    pub composer_text: String,
    pub message_index: usize,
}

#[derive(Debug, Clone)]
pub struct FooterState {
    pub cwd: String,
    pub session_name: Option<String>,
    pub input_tokens: u64,
    pub output_tokens: u64,
    /// Whether to render the cumulative-tokens element. Mirrors
    /// `Settings::ui::footer_show_cost`; populated by the binary at
    /// `AppBuilder::build` time. Default `true` matches the existing
    /// scaffold (the field was unconditional before v0.8).
    pub show_cost: bool,
    /// Number of TurnStats events observed by the TUI for this session.
    /// Used to distinguish a fresh 0/0 session from a provider that
    /// completed a turn without surfacing token counts.
    pub turn_count: u64,
    pub cost_usd: f64,
    pub context_pct: Option<f32>,
    pub model: String,
    pub skills_loaded: usize,
}

impl Default for FooterState {
    fn default() -> Self {
        Self {
            cwd: String::new(),
            session_name: None,
            input_tokens: 0,
            output_tokens: 0,
            show_cost: true,
            turn_count: 0,
            cost_usd: 0.0,
            context_pct: None,
            model: String::new(),
            skills_loaded: 0,
        }
    }
}

/// Bottom-pane surfaces — exactly one variant at a time displaces the
/// editor. Mirrors codex's `BottomPaneView` idea (cf.
/// `codex-rs/tui/src/bottom_pane/`): one shared surface used for
/// approvals, popups, model pickers, etc. Today only approvals exist.
#[derive(Debug)]
pub enum BottomPane {
    Approval(ApprovalState),
    /// The `/`-triggered slash-command palette.
    Palette(crate::slash::PaletteState),
    /// The `@`-triggered fuzzy file picker.
    FilePicker(FilePickerState),
    /// `/model` — pick or type a model id.
    ModelPicker(ModelPickerState),
    /// `/resume` — pick a stored session.
    ResumePicker(ResumePickerState),
    /// `/fork` — pick a past message to branch from.
    ForkPicker(ForkPickerState),
    /// `/tree` — the session branch tree viewer.
    BranchTree(BranchTreeState),
    /// `/settings` — inline editor for ~/.capo/agent/settings.toml.
    /// See spec §4 of `docs/superpowers/specs/2026-05-21-capo-v0.8-design.md`.
    Settings(crate::settings_editor::SettingsState),
}

/// `/tree` pane state — a cursor over `AppState.branch_tree`'s nodes.
#[derive(Debug, Default)]
pub struct BranchTreeState {
    /// Index into `BranchTree.nodes` of the highlighted row.
    pub selected: usize,
}

/// A `/fork` candidate row — a past user message: its `EntryId` and a preview.
#[derive(Debug, Clone)]
pub struct ForkCandidate {
    pub id: String,
    pub preview: String,
}

/// `/fork` picker state — a cursor over `AppState.fork_candidates`.
#[derive(Debug, Default)]
pub struct ForkPickerState {
    pub selected: usize,
}

/// One row in the `/resume` picker. Built by the binary from the session
/// store's `SessionMeta` — the pure `update()` cannot list the store.
#[derive(Debug, Clone)]
pub struct SessionSummary {
    pub id: String,
    /// Display label: short id + timestamp + first-message preview.
    pub label: String,
}

/// `@file` picker state. `anchor_byte` is where the `@` was typed — on
/// accept, the text from `anchor_byte` to the cursor is replaced by the
/// chosen path.
#[derive(Debug, Default)]
pub struct FilePickerState {
    pub query: String,
    pub anchor_byte: usize,
    pub matches: Vec<String>,
    pub selected: usize,
}

/// `/model` picker. A static list of known model ids plus a free-type
/// row; `query` is the free-type buffer.
#[derive(Debug, Default)]
pub struct ModelPickerState {
    pub query: String,
    pub selected: usize,
}

/// `/resume` picker over `AppState.sessions`.
#[derive(Debug, Default)]
pub struct ResumePickerState {
    pub selected: usize,
}

/// Pending permission approval rendered as a navigable list.
///
/// Replaces the M2 centered `Modal::PermissionPrompt`. Layout-wise the
/// approval pane takes the editor's slot, so users can read the request
/// + transcript above without losing visual context.
#[derive(Debug)]
pub struct ApprovalState {
    /// Question header rendered above the option list, e.g. "Allow capo
    /// to run this `write` call?".
    pub question: String,
    /// Compact preview of the tool args (one or more lines, JSON-ish).
    /// Rendered between the question and the option list. Empty string
    /// → skipped.
    pub args_preview: String,
    /// Available decisions, in display order. The agent could in
    /// principle hand us a dynamic list (codex does this for
    /// network-approval / patch / MCP elicitation paths), but the
    /// current M2 PermissionExtension protocol only knows about
    /// AllowOnce/AllowSession/Deny — capo synthesises the three fixed
    /// options at UiEvent::PermissionRequested handling time.
    pub options: Vec<ApprovalOption>,
    /// Currently-highlighted option index. `Up`/`Down` move it.
    pub selected: usize,
    /// Routing fields preserved from the M2 modal so
    /// `Command::ResolvePermission` still has what `PermissionExtension`
    /// expects on the other side.
    pub tool: String,
    pub args: Value,
    pub resolver: Option<oneshot::Sender<Decision>>,
}

/// One row in the approval list — codex-style: full-sentence label, a
/// `PermissionChoice` + `Decision` to fire on select, optional letter
/// shortcut for power users (a/s/d).
#[derive(Debug, Clone)]
pub struct ApprovalOption {
    pub label: String,
    pub choice: PermissionChoice,
    pub decision: Decision,
    pub shortcut: Option<char>,
}

impl AppState {
    pub fn push_user_message(&mut self, text: String, attachment_filenames: Vec<String>) {
        self.messages.push(MessageBlock::User {
            text,
            attachment_filenames,
        });
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn default_state_is_idle() {
        let s = AppState::default();
        assert!(s.messages.is_empty());
        assert!(s.in_flight.is_none());
        assert!(s.bottom_pane.is_none());
        assert!(!s.should_quit);
    }

    #[test]
    fn app_state_default_nav_fields() {
        let s = AppState::default();
        assert!(!s.nav_mode, "nav_mode should default to false");
        assert!(
            s.nav_selected.is_none(),
            "nav_selected should default to None"
        );
        assert!(
            s.nav_g_pending_at.is_none(),
            "nav_g_pending_at should default to None"
        );
        assert!(
            s.collapse_overrides.is_empty(),
            "collapse_overrides should default to empty"
        );
    }

    #[test]
    fn palette_bottom_pane_is_constructible() {
        let pane = BottomPane::Palette(crate::slash::PaletteState::default());
        assert!(matches!(pane, BottomPane::Palette(_)));
    }

    #[test]
    fn file_picker_pane_is_constructible() {
        let pane = BottomPane::FilePicker(FilePickerState::default());
        assert!(matches!(pane, BottomPane::FilePicker(_)));
    }

    #[test]
    fn fork_picker_pane_constructs() {
        let p = BottomPane::ForkPicker(ForkPickerState::default());
        assert!(matches!(p, BottomPane::ForkPicker(_)));
    }

    #[test]
    fn branch_tree_pane_constructs() {
        let p = BottomPane::BranchTree(BranchTreeState::default());
        assert!(matches!(p, BottomPane::BranchTree(_)));
    }

    #[test]
    fn appstate_has_default_empty_file_index() {
        let s = AppState::default();
        assert!(s.file_index.is_empty());
    }

    #[test]
    fn d2_picker_panes_construct() {
        let mp = BottomPane::ModelPicker(ModelPickerState::default());
        let rp = BottomPane::ResumePicker(ResumePickerState::default());
        assert!(matches!(mp, BottomPane::ModelPicker(_)));
        assert!(matches!(rp, BottomPane::ResumePicker(_)));
    }

    #[test]
    fn appstate_has_default_empty_sessions() {
        assert!(AppState::default().sessions.is_empty());
    }

    #[test]
    fn push_user_message_appends_block() {
        let mut s = AppState::default();
        s.push_user_message("hello".into(), Vec::new());
        assert!(matches!(
            s.messages.last(),
            Some(MessageBlock::User { text, attachment_filenames })
                if text == "hello" && attachment_filenames.is_empty()
        ));
    }

    #[test]
    fn message_block_thinking_variant_round_trips_in_state() {
        let mut s = AppState::default();
        s.messages.push(MessageBlock::Thinking {
            text: "considering options...".into(),
            collapsed: false,
        });
        assert_eq!(s.messages.len(), 1);
        match &s.messages[0] {
            MessageBlock::Thinking { text, collapsed } => {
                assert_eq!(text, "considering options...");
                assert!(!*collapsed);
            }
            _ => panic!("expected Thinking variant"),
        }
    }
}
