use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};

use claudette::agent::AgentSession;
use tokio::sync::{RwLock, Semaphore};

use claudette::claude_help::ClaudeFlagDef;
use claudette::env_provider::{EnvCache, EnvWatcher};
use claudette::file_watcher::FileWatcher;
use claudette::plugin_runtime::PluginRegistry;
use claudette::scm::types::{CiCheck, CiOverallStatus, PullRequest};
use serde::{Deserialize, Serialize};

use crate::boot_probation::BootProbationState;
use crate::commands::agent_backends::BackendGateway;
use crate::commands::apps::DetectedApp;
use crate::remote::DiscoveredServer;
use crate::usage::UsageCacheEntry;
#[cfg(feature = "voice")]
use crate::voice::VoiceProviderRegistry;
use claudette::agent::codex_app_server::CodexRateLimitSnapshot;

/// Set during `RunEvent::Exit` before we tear down PTY shells and agent
/// subprocesses. PTY reader threads use this to avoid reporting shutdown
/// cleanup as a natural shell exit to the frontend.
pub static APP_SHUTTING_DOWN: AtomicBool = AtomicBool::new(false);

/// Re-export for use in tray module without direct tauri::tray import.
pub type TrayIcon = tauri::tray::TrayIcon;

/// What kind of attention the agent needs from the user.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AttentionKind {
    /// Agent asked a question (AskUserQuestion).
    Ask,
    /// Agent wants plan approval (ExitPlanMode).
    Plan,
}

/// A `control_request: can_use_tool` the CLI is waiting on the host to
/// resolve via `control_response`. Keyed in `AgentSessionState::pending_permissions`
/// by tool_use_id so UI callbacks (AgentQuestionCard, PlanApprovalCard) can
/// resolve them using the tool_use_id they already track.
#[derive(Debug, Clone)]
pub struct PendingPermission {
    pub request_id: String,
    pub tool_name: String,
    /// Original tool input sent by the model — used verbatim as the base for
    /// `updatedInput` when approving (we layer user-collected answers on top).
    pub original_input: serde_json::Value,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ClaudeRemoteControlLifecycle {
    Disabled,
    Enabling,
    Ready,
    Connected,
    Reconnecting,
    Error,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ClaudeRemoteControlStatus {
    pub state: ClaudeRemoteControlLifecycle,
    pub session_url: Option<String>,
    pub connect_url: Option<String>,
    pub environment_id: Option<String>,
    pub detail: Option<String>,
    pub last_error: Option<String>,
}

impl ClaudeRemoteControlStatus {
    pub fn disabled() -> Self {
        Self {
            state: ClaudeRemoteControlLifecycle::Disabled,
            session_url: None,
            connect_url: None,
            environment_id: None,
            detail: None,
            last_error: None,
        }
    }
}

/// Per-session agent state managed on the Rust side. One of these per
/// active chat session (i.e. per tab). The parent workspace is tracked so
/// tray/notification code can aggregate across sessions in the same worktree.
pub struct AgentSessionState {
    /// The workspace this session belongs to. Used for reverse lookups:
    /// worktree path for spawning, tray grouping, notification routing.
    pub workspace_id: String,
    /// Claude CLI `--resume` UUID. Empty until the first turn completes.
    /// This is the CLI session ID; the `chat_sessions.id` that keys
    /// `AppState.agents` is referred to as `chat_session_id` throughout
    /// the codebase to keep the two distinct.
    pub session_id: String,
    pub turn_count: u32,
    /// PID of the currently running agent process, if any.
    pub active_pid: Option<u32>,
    /// Cached custom instructions resolved on first turn.
    pub custom_instructions: Option<String>,
    /// True when the agent is waiting for user input (question, plan approval, permissions).
    pub needs_attention: bool,
    /// What kind of input the agent needs, if any.
    pub attention_kind: Option<AttentionKind>,
    /// Set when the deferred attention notification has been fired for the
    /// current attention cycle. Cleared whenever `needs_attention` is cleared
    /// (i.e. when the user responds). Prevents repeated banner/sound when
    /// multiple `can_use_tool` prompts queue inside a single cycle.
    pub attention_notification_sent: bool,
    /// Long-lived agent process handle reused across turns when the selected
    /// harness supports persistent sessions.
    ///
    /// Claude Code sessions also use this to keep CLI-side MCP servers alive
    /// across turns; other harnesses may persist different protocol state.
    pub persistent_session: Option<Arc<AgentSession>>,
    /// Ephemeral Claude Code Remote Control status for the current persistent
    /// process. This is intentionally not persisted across app restarts.
    pub claude_remote_control: ClaudeRemoteControlStatus,
    /// PID whose stdout stream is currently monitored for remote-origin turns.
    pub claude_remote_control_monitor_pid: Option<u32>,
    /// User-message UUIDs Claudette wrote to the persistent CLI over stdin.
    /// When `--replay-user-messages` is active, the Remote Control monitor
    /// receives those prompts back from stdout and must not classify them as
    /// remote-origin turns.
    pub local_user_message_uuids: HashSet<String>,
    /// Set when MCP server config changes while a turn is in flight.
    /// The next call to `send_chat_message` tears down the persistent session
    /// and starts a fresh one with updated `--mcp-config`, then clears
    /// the flag. This avoids killing an agent mid-turn.
    pub mcp_config_dirty: bool,
    /// `--permission-mode plan` flag baked into the current `persistent_session`.
    /// Checked each turn against the requested `plan_mode`; a mismatch forces
    /// a teardown + respawn so the CLI actually exits plan mode after approval.
    pub session_plan_mode: bool,
    /// `--allowedTools` list the current `persistent_session` was spawned with.
    /// A mismatch on the next turn (e.g. permission level changed, or plan
    /// approval elevates access) forces a teardown + respawn.
    pub session_allowed_tools: Vec<String>,
    /// Fast-mode value baked into the current `persistent_session`. Claude
    /// Code receives this via spawn settings; Codex app-server receives it as
    /// serviceTier, so either harness needs a respawn when the toolbar flips.
    pub session_fast_mode: bool,
    /// `CLAUDE_CODE_DISABLE_1M_CONTEXT` value baked into the current
    /// `persistent_session` at spawn. A mismatch on the next turn (user
    /// switched from 200k to 1M model variant, or vice versa) forces a
    /// teardown + respawn so the env var matches the new selection.
    pub session_disable_1m_context: bool,
    /// Hash of alternate-backend environment baked into the current
    /// persistent session. A mismatch means the Claude Code process must
    /// restart so provider/base-url/auth changes take effect.
    pub session_backend_hash: String,
    /// Outstanding `can_use_tool` control requests awaiting a `control_response`,
    /// keyed by tool_use_id. See [`PendingPermission`].
    pub pending_permissions: HashMap<String, PendingPermission>,
    /// Agent-owned background Bash tasks that are still believed to be running.
    /// Keys begin as tool_use_id while the task is starting, then switch to the
    /// CLI task_id once the tool result binds it.
    pub running_background_tasks: std::collections::HashSet<String>,
    /// True while Claudette has an internal wake turn queued/running to let
    /// Claude Code drain task-notification messages for background jobs.
    pub background_wake_active: bool,
    /// Trusted output files reported by Claude Code's Bash tool result when a
    /// background task is bound. Task notifications can echo an output-file
    /// path, but the wake-up prompt only reads paths that were first observed
    /// from the tool result to avoid trusting model-visible notification text.
    pub background_task_output_paths: HashMap<String, String>,
    /// Set when the agent emits `ExitPlanMode` during the current persistent
    /// session. The plan phase is over even if the frontend fails to flip
    /// `plan_mode=false` on the next turn, so we force a teardown regardless
    /// of the requested flag. Reset after teardown.
    pub session_exited_plan: bool,
    /// Snapshot of the env-provider resolved env `vars` map baked into
    /// the current persistent session at spawn. Each new turn re-resolves
    /// and compares; any divergence (user edited `.envrc`, ran
    /// `direnv allow`, toggled a provider, changed a plugin setting)
    /// forces a teardown so the fresh env reaches the agent subprocess.
    /// Stored as a plain map because `EnvMap` already is one; keeping a
    /// snapshot lets the comparison be a single equality check.
    pub session_resolved_env: claudette::env_provider::types::EnvMap,
    /// Fingerprint of successful env-provider source evaluations baked
    /// into the current persistent session. Complements
    /// `session_resolved_env`: a provider can re-run and export the same
    /// vars while still changing shell-side setup the agent process only
    /// sees after a restart.
    pub session_resolved_env_signature: String,
    /// Lifecycle handle for the in-process MCP server bridge that powers
    /// `mcp__claudette__send_to_user`. Created alongside `persistent_session`
    /// at spawn time; dropped when `persistent_session = None` so the listener
    /// task is cancelled and the socket file unlinked. `None` between spawns.
    pub mcp_bridge: Option<Arc<claudette::agent_mcp::bridge::McpBridgeSession>>,
    /// `id` of the user message that triggered the most recent turn, used as
    /// the FK anchor for any agent-authored attachments produced during that
    /// turn. Updated each time a new user message is inserted; cleared on
    /// session teardown. See `agent_mcp_sink::ChatBridgeSink`.
    pub last_user_msg_id: Option<String>,
    /// Set after we've posted a trust-error system message into the
    /// chat for the current resolved env. Cleared when an existing
    /// persistent session is torn down because the resolved env drifted
    /// (e.g. after `direnv allow` / `mise trust`, config edits, or
    /// provider toggles), so a fresh failure re-emits once after that
    /// restart. Stays sticky until then so we don't spam every turn
    /// while the user is fixing the underlying issue.
    pub posted_env_trust_warning: bool,
    /// Cross-harness migration prelude queued for the next user turn.
    ///
    /// Set by `prepare_cross_harness_migration` when the user switches
    /// a chat session to a model whose harness can't read the prior
    /// harness's transcript (Claude CLI -> Codex, Codex -> Pi, etc.).
    /// The next `send_chat_message` prepends this string to the user's
    /// content *before* the spawn, then clears it. The persisted user
    /// message in `chat_messages` stays as the bare user input — the
    /// prelude is invisible to the UI but visible to the model as
    /// part of turn 1.
    ///
    /// Lives in memory only: if the app exits between migration and
    /// the user's next turn, the migration is forgotten and the new
    /// harness starts with no context. That's an acceptable trade —
    /// migrations are typically followed by a send within seconds,
    /// and adding a DB column would force a schema migration for a
    /// transient state.
    pub pending_history_prelude: Option<String>,
}

impl AgentSessionState {
    /// Reset every attention-related field to its quiescent default.
    ///
    /// All four "the user no longer needs to engage" paths (per-session
    /// `clear_attention`, workspace-wide tray clear, AskUserQuestion answer,
    /// plan approval) must clear the same trio: `needs_attention` for tray
    /// state, `attention_kind` for tab/sidebar icons, and
    /// `attention_notification_sent` so the next attention cycle in the
    /// session isn't deduped against a stale "already notified" flag.
    /// Routing every clear path through this helper prevents the kind of
    /// "first prompt notifies, second prompt silently doesn't" bug that
    /// follows from any path forgetting one of the three.
    pub fn reset_attention(&mut self) {
        self.needs_attention = false;
        self.attention_kind = None;
        self.attention_notification_sent = false;
    }

    pub fn remember_local_user_message_uuid(&mut self, uuid: String) {
        // Cap the set at 1024 entries — clear before inserting the entry that
        // would push us past the cap so the post-insert size never exceeds it.
        // `>` would let one extra entry slip in (cap → 1025) before the next
        // insert noticed; `>=` keeps the post-insert max at exactly 1024.
        if self.local_user_message_uuids.len() >= 1024 {
            self.local_user_message_uuids.clear();
        }
        self.local_user_message_uuids.insert(uuid);
    }

    pub fn take_local_user_message_uuid(&mut self, uuid: &str) -> bool {
        self.local_user_message_uuids.remove(uuid)
    }
}

/// Handle to an active PTY process.
/// The inner fields are `Send` but not `Sync`, so we wrap them in `Mutex`
/// to satisfy Tauri's `State<AppState>: Send + Sync` requirement.
pub struct PtyHandle {
    /// Writer for sending input to the PTY.
    pub writer: Mutex<Box<dyn std::io::Write + Send>>,
    /// Master side — kept alive for resize operations.
    pub master: Mutex<Box<dyn portable_pty::MasterPty + Send>>,
    pub child: Mutex<Box<dyn portable_pty::Child + Send>>,

    /// Wakes the foreground-process-group polling task in `pty_tracker` so it
    /// drops its duplicated master FD and exits before this handle is freed.
    /// `None` on platforms where the tracker is not spawned (Windows).
    pub tracker_cancel: Option<Arc<tokio::sync::Notify>>,
}

pub struct AgentTaskTailHandle {
    pub cancel: Arc<tokio::sync::Notify>,
}

/// State of the embedded claudette-server subprocess.
pub struct LocalServerState {
    /// Handle to the running server process.
    pub child: tokio::process::Child,
    /// The PID captured at spawn time for reliable synchronous cleanup.
    /// `tokio::process::Child::id()` returns `None` after the child is reaped,
    /// so we store the PID eagerly. Only read on Unix, where `Drop` passes
    /// it to `kill_process_sync` to send SIGTERM/SIGKILL + reap; on Windows
    /// `child.start_kill()` alone suffices and this field is held only to
    /// keep the struct layout consistent across platforms.
    #[cfg_attr(windows, allow(dead_code))]
    pub pid: u32,
    /// The connection string printed by the server on startup.
    pub connection_string: String,
}

impl Drop for LocalServerState {
    fn drop(&mut self) {
        // If tokio can still reach the child, check whether it already exited
        // (e.g. crash, external kill). If so, skip the PID-based cleanup to
        // avoid signaling a recycled PID that now belongs to another process.
        if let Ok(Some(_status)) = self.child.try_wait() {
            tracing::info!(target: "claudette::cleanup", "server process already exited");
            return;
        }
        // Best-effort tokio-level kill (may fail if runtime is gone). On
        // Windows this invokes `TerminateProcess`, which is immediate and
        // leaves no zombie state — so the extra synchronous cleanup below
        // is only meaningful on Unix where SIGTERM → grace → SIGKILL →
        // `waitpid` is needed to reap the child.
        let _ = self.child.start_kill();
        #[cfg(unix)]
        kill_process_sync(self.pid);
    }
}

/// Synchronously try to terminate a process and wait for it to exit.
///
/// On Unix, this first sends `SIGTERM` and allows a short grace period for
/// graceful shutdown. If the process does not exit in time, it escalates to
/// `SIGKILL` and reaps the process. This is safe to call from `Drop` impls
/// and the `RunEvent::Exit` handler where async code cannot run. Uses raw
/// libc calls so it does not depend on the tokio runtime.
#[cfg(unix)]
pub fn kill_process_sync(pid: u32) {
    use std::time::{Duration, Instant};
    let pid = pid as i32;
    // A pid of 0 would signal our own process group — guard against it.
    if pid <= 0 {
        return;
    }

    // First try SIGTERM for a graceful shutdown.
    // SAFETY: pid is a valid positive i32.
    let ret = unsafe { libc::kill(pid, libc::SIGTERM) };
    if ret != 0 {
        let err = std::io::Error::last_os_error();
        if err.raw_os_error() == Some(libc::ESRCH) {
            tracing::info!(target: "claudette::cleanup", pid, "server process already exited");
        } else {
            tracing::warn!(
                target: "claudette::cleanup",
                pid,
                error = %err,
                "failed to send SIGTERM to server process"
            );
        }
        return;
    }

    // Give the server up to 500ms to exit gracefully.
    let deadline = Instant::now() + Duration::from_millis(500);
    while Instant::now() < deadline {
        // SAFETY: waitpid with WNOHANG is a standard POSIX call.
        let mut status: libc::c_int = 0;
        let ret = unsafe { libc::waitpid(pid, &mut status, libc::WNOHANG) };
        if ret == pid {
            tracing::info!(target: "claudette::cleanup", pid, "stopped local claudette-server");
            return;
        }
        if ret == -1 {
            let err = std::io::Error::last_os_error();
            if err.raw_os_error() == Some(libc::ECHILD) {
                // No such child — already reaped.
                tracing::info!(target: "claudette::cleanup", pid, "stopped local claudette-server");
                return;
            }
            // EINTR or other transient error — retry.
        }
        std::thread::sleep(Duration::from_millis(10));
    }

    // Graceful shutdown timed out — force kill.
    // SAFETY: pid is a valid positive i32.
    unsafe { libc::kill(pid, libc::SIGKILL) };

    // Try to reap the process without blocking indefinitely. If it does not
    // become waitable in time (e.g. stuck in uninterruptible sleep), give up
    // so app shutdown cannot hang forever.
    let reap_deadline = Instant::now() + Duration::from_millis(500);
    while Instant::now() < reap_deadline {
        let mut status: libc::c_int = 0;
        // SAFETY: waitpid with WNOHANG is a standard POSIX call.
        let ret = unsafe { libc::waitpid(pid, &mut status, libc::WNOHANG) };
        if ret == pid {
            tracing::warn!(target: "claudette::cleanup", pid, "force-killed local claudette-server");
            return;
        }
        if ret == -1 {
            let err = std::io::Error::last_os_error();
            match err.raw_os_error() {
                Some(libc::ECHILD) => {
                    tracing::warn!(target: "claudette::cleanup", pid, "force-killed local claudette-server");
                    return;
                }
                Some(libc::EINTR) => {} // Retry.
                _ => {
                    tracing::warn!(
                        target: "claudette::cleanup",
                        pid,
                        error = %err,
                        "sent SIGKILL to claudette-server but could not reap"
                    );
                    return;
                }
            }
        }
        std::thread::sleep(Duration::from_millis(10));
    }
    tracing::warn!(
        target: "claudette::cleanup",
        pid,
        "sent SIGKILL to claudette-server but timed out waiting to reap"
    );
}

/// Result of running `claude --help` at startup, parsed into `ClaudeFlagDef`s.
/// Lives in `AppState` so the Settings UI can render the available flag list
/// without re-spawning the CLI on every render.
#[derive(Debug, Clone)]
pub enum ClaudeFlagDiscovery {
    /// Discovery task is in flight (set at app boot before the spawned task
    /// finishes). Settings UI shows a "loading" state.
    Loading,
    /// Parsed flag definitions, filtered against `RESERVED_FLAGS`.
    Ok(Vec<ClaudeFlagDef>),
    /// Spawn / parse / timeout failure. Settings UI shows a Retry banner.
    Err(String),
}

/// Per-workspace CI status for detecting failure transitions.
pub struct CiTransitionState {
    pub overall_status: Option<CiOverallStatus>,
    pub last_auto_fix_triggered: Option<Instant>,
}

/// Cached SCM data for a specific (repo_id, branch) pair.
pub struct ScmCacheEntry {
    pub pull_request: Option<PullRequest>,
    pub ci_checks: Vec<CiCheck>,
    pub last_fetched: Instant,
    pub error: Option<String>,
}

/// In-memory cache for SCM data, keyed by (repo_id, branch_name).
pub struct ScmCache {
    pub entries: RwLock<HashMap<(String, String), ScmCacheEntry>>,
}

impl ScmCache {
    pub fn new() -> Self {
        Self {
            entries: RwLock::new(HashMap::new()),
        }
    }
}

/// One cached repo-wide SCM list: open issues or open PRs (per scope).
///
/// Separate from [`ScmCacheEntry`] because the data shape is fundamentally
/// different (a list of items vs. a single PR + its checks) and the key
/// space is `(repo_id, list_kind)` rather than `(repo_id, branch_name)`.
/// Co-locating both in `ScmCache` would force an enum payload and conflate
/// two independent TTL / invalidation cycles.
pub struct RepoListEntry {
    /// Plugin operation result, deserialized as a JSON array of items.
    /// The Tauri command extracts `Vec<Issue>` or `Vec<PullRequest>` from
    /// this on read. Stored as `Value` so we don't need a typed enum in
    /// state and so the eventual SQLite persist is a single JSON string.
    pub payload: serde_json::Value,
    pub last_fetched: Instant,
    pub error: Option<String>,
    /// `true` when the resolved plugin's manifest does not declare the
    /// operation (e.g. a user-authored SCM plugin without `list_issues`).
    /// Distinct from `error` so the UI can render a muted "not supported"
    /// hint instead of treating it as a transient failure.
    pub unsupported: bool,
    pub provider: Option<String>,
}

/// In-memory cache for repo-wide SCM lists (open issues, open PRs by scope).
/// Persisted to `repo_scm_lists_cache` so the project view has data on
/// boot without waiting for the first poll.
pub struct RepoScmListsCache {
    pub entries: RwLock<HashMap<(String, String), RepoListEntry>>,
}

impl RepoScmListsCache {
    pub fn new() -> Self {
        Self {
            entries: RwLock::new(HashMap::new()),
        }
    }
}

/// Cached `git merge-base <base_branch> HEAD` lookup for a single workspace.
///
/// Note `base_branch` is intentionally NOT part of the entry. If the user
/// changes the repo's base branch in Settings, a stale entry can serve the
/// previous-branch SHA for up to one TTL window, after which the natural
/// expiration recomputes against the new branch. We accept that 10s
/// transient over the alternative — a synchronous DB read on every cache
/// hit just to detect a near-zero-frequency config change.
pub struct MergeBaseCacheEntry {
    pub sha: String,
    pub worktree_path: String,
    pub fetched_at: Instant,
}

/// Short-TTL cache for `git merge-base` lookups, keyed by `workspace_id`.
///
/// Exists because `merge-base` is by far the slowest piece of
/// `load_diff_files`: on a huge repo whose `origin/<base>` has drifted
/// far from `HEAD` (e.g. a `nixpkgs` fork whose master is 60k+ commits
/// behind upstream after a fast-forward of `upstream/master`), each call
/// can take 4–6 seconds. The right-sidebar Changes tab and the file
/// viewer's git gutter both call it on a polling cadence whether anything
/// moved or not, so without a cache every tick spawns a `git` process even
/// though the answer is the same.
///
/// The frontend dedup guards against pile-up within a single panel; this
/// cache cuts the baseline cost so even the unguarded one-shot callers
/// (workspace selection, post-stop refresh, file viewer gutter) stop
/// paying full price when the answer hasn't moved. TTL is intentionally
/// short — `merge-base` only changes when HEAD or `origin/<base>` move,
/// both of which are user-initiated git ops, so a 10s window is invisible
/// in practice while still bounding staleness after a manual `git fetch`.
pub struct MergeBaseCache {
    pub entries: RwLock<HashMap<String, MergeBaseCacheEntry>>,
}

impl MergeBaseCache {
    pub fn new() -> Self {
        Self {
            entries: RwLock::new(HashMap::new()),
        }
    }
}

/// TTL for `MergeBaseCache` entries. See `MergeBaseCache` doc comment for
/// the full rationale on why this is short.
pub const MERGE_BASE_CACHE_TTL: Duration = Duration::from_secs(10);

/// Application-wide managed state, shared across all Tauri commands.
pub struct AppState {
    pub db_path: PathBuf,
    pub worktree_base_dir: RwLock<PathBuf>,
    /// Agent sessions keyed by `chat_sessions.id` (the tab/session id).
    /// Each value carries its owning `workspace_id` for reverse lookups
    /// (tray aggregation, notifications, worktree path resolution).
    pub agents: RwLock<HashMap<String, AgentSessionState>>,
    /// Active PTY processes keyed by pty_id.
    pub ptys: RwLock<HashMap<u64, PtyHandle>>,
    /// Active file-tail streams for agent-owned background task terminal tabs.
    pub agent_task_tailers: RwLock<HashMap<i64, AgentTaskTailHandle>>,
    /// Counter for generating unique PTY IDs.
    pub next_pty_id: AtomicU64,
    /// mDNS-discovered servers on the local network.
    pub discovered_servers: RwLock<Vec<DiscoveredServer>>,
    /// Embedded local claudette-server process (when "Share this machine" is active).
    pub local_server: RwLock<Option<LocalServerState>>,
    /// Detected apps cache (populated on startup, read by open_workspace_in_app for TUI wrapping).
    pub detected_apps: RwLock<Vec<DetectedApp>>,
    /// Loopback Anthropic-compatible gateway used for experimental
    /// OpenAI-compatible Claude Code backends.
    #[cfg_attr(not(feature = "alternative-backends"), allow(dead_code))]
    pub backend_gateway: BackendGateway,
    /// System tray icon handle (None when tray is disabled).
    pub tray_handle: Mutex<Option<TrayIcon>>,
    /// Monotonic counter for generating unique tray IDs. Each call to
    /// `setup_tray` uses a new id so that Linux/libayatana-appindicator
    /// does not see a DBus-path collision when the user toggles the tray
    /// off then on (the previous tray's DBus objects release asynchronously
    /// on the GLib main loop, which can race with our re-registration).
    pub next_tray_seq: AtomicU64,
    /// Cached Claude Code OAuth token and usage data.
    pub usage_cache: RwLock<Option<UsageCacheEntry>>,
    /// Latest Codex `account/rateLimits/read` snapshot, kept fresh by
    /// a subscriber task spawned alongside each Codex app-server
    /// session in `commands::chat::send`. Read by `commands::usage`'s
    /// `get_session_usage` for any `CodexNative` / `CodexSubscription`
    /// session — when populated it supplies real subscription quotas
    /// (primary/secondary windows + credits) instead of falling back
    /// to the local-aggregate token totals. `None` until the first
    /// Codex session of the app lifetime publishes its seed.
    pub codex_rate_limits: RwLock<Option<CodexRateLimitSnapshot>>,
    /// SCM + env-provider + language-grammar plugin registry.
    ///
    /// Wrapped as `RwLock<Arc<PluginRegistry>>` (rather than the simpler
    /// `RwLock<PluginRegistry>`) so callers can [`AppState::plugins_snapshot`]
    /// — clone the `Arc` and release the outer lock immediately. This
    /// matters because env-provider resolves can run for ~120 seconds
    /// (the new default timeout); holding the outer read lock for that
    /// long stalls every other Tauri command that also reads plugins
    /// (notably `list_claudette_plugins`, which is what the Plugins
    /// settings page awaits on mount). The snapshot pattern keeps the
    /// lock-hold-time bounded to the few microseconds it takes to
    /// `Arc::clone`, regardless of how long the resolve runs.
    ///
    /// Reseed paths swap the Arc by writing a fresh `Arc::new(...)`;
    /// in-flight snapshots keep working against the old registry until
    /// they drop.
    pub plugins: RwLock<Arc<PluginRegistry>>,
    #[cfg(feature = "voice")]
    /// Native voice provider registry and model cache metadata.
    pub voice: Arc<VoiceProviderRegistry>,
    /// mtime-keyed cache of env-provider exports. One entry per
    /// `(worktree, plugin_name)` pair, invalidated when any watched
    /// file (`.envrc`, `mise.toml`, `.env`, `flake.lock`, etc.) changes.
    pub env_cache: Arc<EnvCache>,
    /// Filesystem watcher that proactively evicts `env_cache` entries
    /// when any plugin's `watched` paths change on disk. Set at
    /// startup from `main.rs` once the `AppHandle` is available so
    /// the change callback can emit an `env-cache-invalidated` Tauri
    /// event; `None` before setup finishes or if watcher construction
    /// failed (logged, lazy mtime invalidation still covers).
    pub env_watcher: RwLock<Option<Arc<EnvWatcher>>>,
    /// Filesystem watcher for the file viewer's open buffers. Lets the
    /// frontend follow disk changes (agent edits, external git ops,
    /// out-of-app saves) without polling. Set at startup from
    /// `main.rs` once the `AppHandle` is available so the change
    /// callback can emit a `workspace-file-changed` Tauri event.
    /// `None` before setup finishes or if construction failed (logged;
    /// the file viewer falls back to its initial-load-only path).
    pub file_watcher: RwLock<Option<Arc<FileWatcher>>>,
    /// Cached PR/CI status data keyed by (repo_id, branch_name).
    pub scm_cache: ScmCache,
    /// Cached repo-wide SCM lists (open issues / open PRs by scope) keyed
    /// by `(repo_id, list_kind)`. Powers the project-view sections.
    pub repo_scm_lists_cache: RepoScmListsCache,
    /// Short-TTL cache for `git merge-base <base_branch> HEAD`, keyed by
    /// workspace_id. See `MergeBaseCache` doc comment for the full rationale.
    pub merge_base_cache: MergeBaseCache,
    /// Per-workspace CI transition state for auto-fix triggering.
    pub ci_last_status: RwLock<HashMap<String, CiTransitionState>>,
    /// Limits concurrent SCM CLI invocations.
    pub scm_semaphore: Arc<Semaphore>,
    /// The workspace the user is currently viewing. The polling loop reads
    /// this to keep the focused workspace on a 30s cadence while other
    /// workspaces back off. `None` when the user is on the dashboard or a
    /// repository overview rather than a specific workspace.
    pub selected_workspace_id: RwLock<Option<String>>,
    /// Last-known activity instant per workspace. Written on workspace
    /// selection and on agent turn start; read by the SCM polling loop to
    /// compute the tier interval. Seeded at startup from the most recent
    /// `chat_messages.created_at` per workspace so workspaces with recent
    /// chat history don't all start as stale.
    pub workspace_activity: RwLock<HashMap<String, Instant>>,
    /// When each workspace was last successfully polled by the SCM loop.
    /// Combined with `workspace_activity` to decide whether a workspace is
    /// due for another poll on the current tick.
    pub scm_last_polled: RwLock<HashMap<String, Instant>>,
    /// Pending updater handle from the most recent `check_for_updates_with_channel`
    /// call. The Update struct holds the downloaded payload + signature context
    /// and is not Serialize, so it lives here instead of crossing the IPC boundary.
    pub pending_update: tokio::sync::Mutex<Option<tauri_plugin_updater::Update>>,
    /// Boot-health probation state used after an updater install. The frontend
    /// clears the sentinel via `boot_ok`; otherwise the startup timer rolls back.
    pub boot_probation: Arc<BootProbationState>,
    /// CESP sound pack playback state (no-repeat + debounce tracking).
    pub cesp_playback: Mutex<claudette::cesp::SoundPlaybackState>,
    /// Cached `claude --help` parse result. Populated by a background task
    /// spawned at startup; `Loading` until it completes. Settings reads this
    /// to render the user-toggleable flags list.
    pub claude_flag_defs: Arc<RwLock<ClaudeFlagDiscovery>>,
    /// Cancellation signal for an in-flight `claude auth login` subprocess.
    /// The waiter task owns the `Child` directly and selects between
    /// `child.wait()` and this receiver; sending on the paired sender asks
    /// the waiter to kill the process and emit a cancelled completion event.
    /// `Some` while a flow is running, `None` otherwise.
    pub auth_login_cancel: tokio::sync::Mutex<Option<tokio::sync::oneshot::Sender<()>>>,
    /// Writable stdin for the in-flight `claude auth login` subprocess.
    /// Claude Code's browser flow may ask the user to paste a one-time code;
    /// the frontend forwards that code here so clean profiles can complete
    /// login without an attached terminal.
    pub auth_login_stdin: tokio::sync::Mutex<Option<tokio::process::ChildStdin>>,
    /// Cancellation flags for in-flight bulk-cleanup runs, keyed by the
    /// frontend-supplied `request_id`. The Tauri command inserts an
    /// `Arc<AtomicBool>` on start and removes it on completion;
    /// `cancel_workspaces_bulk(request_id)` flips the flag. Both the
    /// per-row DB loop (in `ops::workspace::delete_workspaces_bulk`) and
    /// the post-DB worktree/branch cleanup loop check the same flag, so
    /// the user's Cancel click really does stop further work.
    pub bulk_cleanup_cancels: RwLock<HashMap<String, Arc<AtomicBool>>>,
    /// Repository IDs with a GUI-initiated `create_workspace` currently in
    /// flight. A create runs for several seconds (git worktree add + setup
    /// script + env resolve). The frontend's `creationInFlight` latch is a
    /// webview-scoped JS variable that a reload resets to `false`, so on its
    /// own it can't stop a reload-then-reclick from minting a duplicate `-N`
    /// workspace. This set lives in the Rust process — unaffected by webview
    /// reloads — and `create_workspace` consults it before each create.
    /// Keyed per repo so unrelated repos still create concurrently. See
    /// issue #896.
    pub workspace_creates_in_flight: Mutex<HashSet<String>>,
    /// Nudges the native agent scheduler after a wakeup/routine is created,
    /// deleted, or manually fired so the polling loop can recompute its next
    /// deadline immediately instead of waiting for the fallback tick.
    pub scheduler_notify: Arc<tokio::sync::Notify>,
}

impl AppState {
    pub fn new(db_path: PathBuf, worktree_base_dir: PathBuf, plugins: PluginRegistry) -> Self {
        // Hydrate the Codex rate-limits cache from SQLite so the
        // composer usage meter has real plan quotas to render the
        // first time the user selects a Codex backend after launch.
        // Best-effort: a missing/corrupt row falls back to `None` and
        // the next chat turn (or prefetch command) repopulates the
        // in-memory + on-disk copies together.
        let codex_rate_limits = claudette::db::Database::open(&db_path)
            .ok()
            .and_then(|db| db.load_codex_rate_limits().ok().flatten());

        Self {
            db_path,
            worktree_base_dir: RwLock::new(worktree_base_dir),
            agents: RwLock::new(HashMap::new()),
            ptys: RwLock::new(HashMap::new()),
            agent_task_tailers: RwLock::new(HashMap::new()),
            next_pty_id: AtomicU64::new(1),
            discovered_servers: RwLock::new(Vec::new()),
            local_server: RwLock::new(None),
            detected_apps: RwLock::new(Vec::new()),
            backend_gateway: BackendGateway::new(),
            tray_handle: Mutex::new(None),
            next_tray_seq: AtomicU64::new(1),
            usage_cache: RwLock::new(None),
            codex_rate_limits: RwLock::new(codex_rate_limits),
            plugins: RwLock::new(Arc::new(plugins)),
            #[cfg(feature = "voice")]
            voice: Arc::new(VoiceProviderRegistry::new(
                VoiceProviderRegistry::default_model_root(),
            )),
            env_cache: Arc::new(EnvCache::new()),
            env_watcher: RwLock::new(None),
            file_watcher: RwLock::new(None),
            scm_cache: ScmCache::new(),
            repo_scm_lists_cache: RepoScmListsCache::new(),
            merge_base_cache: MergeBaseCache::new(),
            ci_last_status: RwLock::new(HashMap::new()),
            scm_semaphore: Arc::new(Semaphore::new(4)),
            selected_workspace_id: RwLock::new(None),
            workspace_activity: RwLock::new(HashMap::new()),
            scm_last_polled: RwLock::new(HashMap::new()),
            pending_update: tokio::sync::Mutex::new(None),
            boot_probation: Arc::new(BootProbationState::default()),
            cesp_playback: Mutex::new(claudette::cesp::SoundPlaybackState::new()),
            claude_flag_defs: Arc::new(RwLock::new(ClaudeFlagDiscovery::Loading)),
            auth_login_cancel: tokio::sync::Mutex::new(None),
            auth_login_stdin: tokio::sync::Mutex::new(None),
            bulk_cleanup_cancels: RwLock::new(HashMap::new()),
            workspace_creates_in_flight: Mutex::new(HashSet::new()),
            scheduler_notify: Arc::new(tokio::sync::Notify::new()),
        }
    }

    pub fn next_pty_id(&self) -> u64 {
        self.next_pty_id.fetch_add(1, Ordering::Relaxed)
    }

    /// Try to claim the workspace-creation slot for `repo_id`.
    ///
    /// Returns `true` if the slot was free (the caller may proceed with the
    /// create) and `false` if a create for this repo is already in flight.
    /// The matching release is [`AppState::end_workspace_create`], normally
    /// run from a `Drop` guard so it fires on every exit path. See the
    /// `workspace_creates_in_flight` field and issue #896.
    pub fn try_begin_workspace_create(&self, repo_id: &str) -> bool {
        self.workspace_creates_in_flight
            .lock()
            .unwrap_or_else(|poisoned| poisoned.into_inner())
            .insert(repo_id.to_string())
    }

    /// Release the workspace-creation slot for `repo_id`. Idempotent —
    /// releasing a repo that holds no slot is a harmless no-op.
    pub fn end_workspace_create(&self, repo_id: &str) {
        self.workspace_creates_in_flight
            .lock()
            .unwrap_or_else(|poisoned| poisoned.into_inner())
            .remove(repo_id);
    }

    /// Snapshot the plugin registry for use across `await` points.
    ///
    /// The lock is held only long enough to `Arc::clone` the inner
    /// pointer; the returned `Arc<PluginRegistry>` keeps working even
    /// if a concurrent reseed swaps the registry mid-flight (the old
    /// Arc lives until the last snapshot is dropped).
    ///
    /// Callers MUST prefer this over `state.plugins.read().await`
    /// whenever the registry is needed across an `await` that could
    /// take more than a few milliseconds — env-provider resolves are
    /// the canonical example, but agent spawns and PTY creates also
    /// qualify. Direct `read()` is fine for the snapshot itself
    /// (read-then-clone-then-drop) and for the swap-on-reseed write
    /// path; everything else should go through this helper to keep
    /// the Plugins settings page from stalling while env loads.
    pub async fn plugins_snapshot(&self) -> Arc<PluginRegistry> {
        Arc::clone(&*self.plugins.read().await)
    }
}

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

    /// Helper: spawn a long-running `sleep` process and return its PID.
    fn spawn_sleep() -> (tokio::process::Child, u32) {
        let rt = tokio::runtime::Runtime::new().unwrap();
        rt.block_on(async {
            let child = claudette::process::command("sleep")
                .arg("3600")
                .kill_on_drop(true)
                .spawn()
                .expect("failed to spawn sleep");
            let pid = child.id().expect("missing pid");
            (child, pid)
        })
    }

    /// Returns true if the given PID is still alive.
    fn is_alive(pid: u32) -> bool {
        // kill(pid, 0) checks existence without sending a signal.
        unsafe { libc::kill(pid as i32, 0) == 0 }
    }

    #[test]
    fn kill_process_sync_terminates_running_process() {
        let (child, pid) = spawn_sleep();
        assert!(is_alive(pid), "process should be alive after spawn");

        // Forget the tokio child so kill_on_drop doesn't interfere.
        std::mem::forget(child);

        kill_process_sync(pid);

        // Give the OS a moment to update process table.
        std::thread::sleep(std::time::Duration::from_millis(50));
        assert!(
            !is_alive(pid),
            "process should be dead after kill_process_sync"
        );
    }

    #[test]
    fn kill_process_sync_noop_for_dead_process() {
        let (child, pid) = spawn_sleep();
        std::mem::forget(child);

        // Kill it once.
        kill_process_sync(pid);
        std::thread::sleep(std::time::Duration::from_millis(50));
        assert!(!is_alive(pid));

        // Calling again on a dead PID should not panic.
        kill_process_sync(pid);
    }

    #[test]
    fn kill_process_sync_noop_for_zero_pid() {
        // pid 0 would signal our own process group — must be a no-op.
        kill_process_sync(0);
    }

    #[test]
    fn local_server_state_drop_kills_child() {
        let rt = tokio::runtime::Runtime::new().unwrap();
        let (child, pid) = rt.block_on(async {
            let child = claudette::process::command("sleep")
                .arg("3600")
                .kill_on_drop(true)
                .spawn()
                .expect("failed to spawn sleep");
            let pid = child.id().expect("missing pid");
            (child, pid)
        });
        assert!(is_alive(pid));

        let state = LocalServerState {
            child,
            pid,
            connection_string: String::new(),
        };
        // Dropping the state should kill the process.
        drop(state);

        std::thread::sleep(std::time::Duration::from_millis(50));
        assert!(
            !is_alive(pid),
            "child should be dead after LocalServerState is dropped"
        );
    }

    // ---- Regression: Plugins page hangs while env loads ---------------------
    //
    // The `state.plugins` lock pattern is what keeps the Plugins settings page
    // (which awaits `list_claudette_plugins`) responsive while a long-running
    // env-provider resolve is in flight elsewhere in the app. Before the
    // `Arc<PluginRegistry>` snapshot fix, the env-resolve held the outer
    // `RwLock` for the full ~120s of its `host.exec` budget, and any
    // concurrent `list_claudette_plugins` call queued behind it (especially
    // problematic given tokio's writer-preferring fairness — a brief setting
    // write would freeze every reader behind it).
    //
    // These tests pin the invariant so a future refactor that re-introduces
    // long-held outer locks fails fast in CI rather than as a UX bug report.

    use claudette::plugin_runtime::PluginRegistry;

    fn empty_registry() -> PluginRegistry {
        // Empty plugin dir — no plugins, no Lua, no I/O. We're testing the
        // lock pattern, not what's inside the registry.
        let dir = tempfile::tempdir().unwrap();
        PluginRegistry::discover(dir.path())
    }

    /// `plugins_snapshot()` must not hold the outer lock past return —
    /// otherwise a concurrent `list_claudette_plugins` would queue behind a
    /// long-running env resolve. Holding a snapshot is fine; it's the outer
    /// `RwLock` that must stay free.
    #[tokio::test]
    async fn plugins_snapshot_releases_outer_lock_for_concurrent_writes() {
        let lock = RwLock::new(Arc::new(empty_registry()));

        // Take a snapshot — this is what env-resolve does at its top.
        let snapshot = Arc::clone(&*lock.read().await);

        // Even with the snapshot still held, a write swap must succeed
        // immediately. 50ms is deliberately tight: any real lock contention
        // would blow past this on even the slowest CI runner.
        let write_result = tokio::time::timeout(Duration::from_millis(50), async {
            let mut guard = lock.write().await;
            *guard = Arc::new(empty_registry());
        })
        .await;
        assert!(
            write_result.is_ok(),
            "write must not be blocked by a held snapshot — \
             see AppState::plugins_snapshot doc"
        );

        // Snapshot is still valid even after the swap — Arc keeps the old
        // registry alive until every snapshot drops. This is what lets in-
        // flight env-resolves keep working across a reseed.
        assert_eq!(snapshot.plugins.len(), 0);
    }

    /// While one task is mid-resolve (holding a snapshot), another task's
    /// `plugins_snapshot()` must return within milliseconds. This is the
    /// exact contract `list_claudette_plugins` relies on to keep the
    /// Plugins settings page from showing "Loading…" indefinitely while
    /// `prepare_workspace_environment` runs.
    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn second_snapshot_during_long_resolve_returns_immediately() {
        let lock = Arc::new(RwLock::new(Arc::new(empty_registry())));

        // Simulate a long-running env-resolve: spawn a task that takes a
        // snapshot and holds it (with the snapshot still alive) for the
        // duration of the test. The fix means the OUTER lock isn't held;
        // only an Arc reference is.
        let lock_for_holder = Arc::clone(&lock);
        let (started_tx, started_rx) = tokio::sync::oneshot::channel::<()>();
        let (finish_tx, finish_rx) = tokio::sync::oneshot::channel::<()>();
        let holder = tokio::spawn(async move {
            let _snapshot = Arc::clone(&*lock_for_holder.read().await);
            started_tx.send(()).unwrap();
            // Hold the snapshot until the test signals "you can drop it now".
            // Mirrors how a real resolve awaits the Lua VM.
            let _ = finish_rx.await;
        });

        started_rx.await.unwrap();

        // The Plugins settings page does this on mount: take a snapshot to
        // build its `ClaudettePluginInfo` list. Even with the resolve task
        // holding its own snapshot, this must return well under 50ms.
        let elapsed = {
            let started = std::time::Instant::now();
            let _list_snapshot = Arc::clone(&*lock.read().await);
            started.elapsed()
        };
        assert!(
            elapsed < Duration::from_millis(50),
            "list_claudette_plugins-equivalent snapshot took {elapsed:?} \
             while a concurrent env-resolve was in flight; the Arc \
             snapshot pattern must keep the outer lock contention-free"
        );

        // Cleanup.
        finish_tx.send(()).unwrap();
        holder.await.unwrap();
    }
}

// ---- Regression: duplicate workspace creation (issue #896) --------------
//
// Cross-platform (not `#[cfg(unix)]`) because the in-flight slot logic has
// no OS-specific code. Pins the per-repo single-flight contract that stops
// a webview reload + re-click from minting a duplicate `-N` workspace.
#[cfg(test)]
mod create_slot_tests {
    use super::AppState;
    use claudette::plugin_runtime::PluginRegistry;

    /// Build a throwaway `AppState`. The temp dir is returned so the caller
    /// keeps it alive — `AppState::new` opens a SQLite DB under it during
    /// construction, but the slot methods under test never touch disk.
    fn test_state() -> (AppState, tempfile::TempDir) {
        let dir = tempfile::tempdir().unwrap();
        let registry = PluginRegistry::discover(dir.path());
        let state = AppState::new(
            dir.path().join("claudette.db"),
            dir.path().join("worktrees"),
            registry,
        );
        (state, dir)
    }

    #[test]
    fn workspace_create_slot_is_single_flight_per_repo() {
        let (state, _dir) = test_state();

        // First claim for a repo wins.
        assert!(state.try_begin_workspace_create("repo-a"));
        // A second claim while the first is in flight is rejected — this is
        // the guard that survives a webview reload.
        assert!(!state.try_begin_workspace_create("repo-a"));
        // A different repo is unaffected: concurrent creates across repos
        // are still allowed.
        assert!(state.try_begin_workspace_create("repo-b"));

        // Releasing repo-a's slot makes it claimable again.
        state.end_workspace_create("repo-a");
        assert!(state.try_begin_workspace_create("repo-a"));

        // Releasing a repo that holds no slot is a harmless no-op.
        state.end_workspace_create("never-claimed");
    }
}
