//! Pane status detection for real-time monitoring.
//!
//! Two-path classification: Claude Code panes use hook-based detection (primary)
//! with content heuristics as fallback. Non-Claude panes use command + content analysis.
//! Hook status files are written by a shell script invoked by Claude Code's hooks API.

use ratatui::{
    style::{Color, Style},
    text::Span,
};
use std::collections::HashMap;
use std::fs;
use std::time::Duration;

/// Status of a single pane, ordered by priority for aggregation.
/// `max()` yields the "worst" (most attention-needed) status.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum PaneStatus {
    Unknown,
    Idle,
    Active,
    Error,
    Waiting,
}

/// Pre-computed status cache, rebuilt each update cycle.
pub struct PaneMonitor {
    pane_statuses: HashMap<String, PaneStatus>,
    session_statuses: HashMap<String, PaneStatus>,
    window_statuses: HashMap<(String, u32), PaneStatus>,
}

impl PaneMonitor {
    pub fn new() -> Self {
        Self {
            pane_statuses: HashMap::new(),
            session_statuses: HashMap::new(),
            window_statuses: HashMap::new(),
        }
    }

    pub fn set(&mut self, pane_id: &str, session_name: &str, window_index: u32, status: PaneStatus) {
        self.pane_statuses.insert(pane_id.to_string(), status);

        let session_entry = self.session_statuses
            .entry(session_name.to_string())
            .or_insert(PaneStatus::Unknown);
        *session_entry = (*session_entry).max(status);

        let window_entry = self.window_statuses
            .entry((session_name.to_string(), window_index))
            .or_insert(PaneStatus::Unknown);
        *window_entry = (*window_entry).max(status);
    }

    pub fn begin_update(&mut self) {
        self.pane_statuses.clear();
        self.session_statuses.clear();
        self.window_statuses.clear();
    }

    pub fn get_pane(&self, pane_id: &str) -> PaneStatus {
        self.pane_statuses.get(pane_id).copied().unwrap_or(PaneStatus::Unknown)
    }

    pub fn get_session(&self, session_name: &str) -> PaneStatus {
        self.session_statuses.get(session_name).copied().unwrap_or(PaneStatus::Unknown)
    }

    pub fn get_window(&self, session_name: &str, window_index: u32) -> PaneStatus {
        self.window_statuses
            .get(&(session_name.to_string(), window_index))
            .copied()
            .unwrap_or(PaneStatus::Unknown)
    }

    pub fn clear(&mut self) {
        self.begin_update();
    }
}

pub const HOOK_STATUS_DIR: &str = "/tmp/tango-status";
const HOOK_STALENESS: Duration = Duration::from_secs(60);

/// Classify a pane using its foreground command, captured content, and hook status.
///
/// Decision tree:
///   1. Content has waiting patterns → Waiting
///   2. Content has error patterns → Error
///   3. Claude pane → `classify_claude()` (hooks → heuristics → default Active)
///   4. Shell command → Idle
///   5. Otherwise → Active
pub fn classify(command: &str, content: Option<&str>, pane_id: &str) -> PaneStatus {
    if let Some(text) = content {
        if has_waiting_pattern(text) {
            return PaneStatus::Waiting;
        }
        if has_error_pattern(text) {
            return PaneStatus::Error;
        }
    }

    if is_claude(command) {
        return classify_claude(content, pane_id);
    }

    if is_shell(command) {
        PaneStatus::Idle
    } else {
        PaneStatus::Active
    }
}

/// Claude-specific classification: hooks first, then content heuristics.
fn classify_claude(content: Option<&str>, pane_id: &str) -> PaneStatus {
    if let Some(status) = read_hook_status(pane_id) {
        return status;
    }
    if let Some(text) = content {
        if has_claude_working_indicators(text) {
            return PaneStatus::Active;
        }
        if is_claude_idle_strict(text) {
            return PaneStatus::Idle;
        }
    }
    PaneStatus::Active
}

/// Read a hook-written status file for the given pane.
/// Returns `None` if the file is missing, unreadable, or stale (>60s).
fn read_hook_status(pane_id: &str) -> Option<PaneStatus> {
    let sanitized = pane_id.replace('%', "_");
    let path = format!("{}/{}", HOOK_STATUS_DIR, sanitized);
    let content = fs::read_to_string(&path).ok()?;

    let metadata = fs::metadata(&path).ok()?;
    let age = metadata.modified().ok()?.elapsed().ok()?;
    if age > HOOK_STALENESS {
        return None;
    }

    match content.trim() {
        "active" => Some(PaneStatus::Active),
        "idle" => Some(PaneStatus::Idle),
        "waiting" => Some(PaneStatus::Waiting),
        _ => None,
    }
}

const SHELLS: &[&str] = &[
    "bash", "zsh", "fish", "sh", "ksh", "csh", "dash", "tcsh",
    "ash", "nushell", "nu", "elvish", "oil", "pwsh", "powershell",
    "login", "-bash", "-zsh", "-sh", "-fish",
];

pub fn is_shell(command: &str) -> bool {
    let cmd = command.rsplit('/').next().unwrap_or(command);
    SHELLS.iter().any(|s| cmd.eq_ignore_ascii_case(s))
}

fn is_claude(command: &str) -> bool {
    let cmd = command.rsplit('/').next().unwrap_or(command);
    cmd.eq_ignore_ascii_case("claude")
}

/// Strict idle check: `❯` must appear in the last 4 non-empty lines.
/// When Claude is idle, the prompt sits above a multi-line status bar
/// (model, context, cost, mode). 4 lines accommodates a 3-line status bar.
/// When working, new output pushes `❯` well beyond position 4.
fn is_claude_idle_strict(content: &str) -> bool {
    content
        .lines()
        .rev()
        .filter(|l| !l.trim().is_empty())
        .take(4)
        .any(|l| l.trim().starts_with('❯'))
}

/// Detect positive signals that Claude is actively working.
/// Scans deeper than `is_claude_idle_strict` so working indicators
/// always take precedence over a stale `❯` in the same region.
fn has_claude_working_indicators(content: &str) -> bool {
    content
        .lines()
        .rev()
        .filter(|l| !l.trim().is_empty())
        .take(5)
        .any(|l| {
            let t = l.trim();
            // Tool execution marker
            t.starts_with('⏺')
            // Braille spinner characters (progress indicators)
            || t.starts_with('⠋') || t.starts_with('⠙') || t.starts_with('⠹')
            || t.starts_with('⠸') || t.starts_with('⠼') || t.starts_with('⠴')
            || t.starts_with('⠦') || t.starts_with('⠧') || t.starts_with('⠇')
            || t.starts_with('⠏')
        })
}

/// Check the last non-empty lines for patterns indicating the pane
/// is waiting for user input.
///
/// Two-tier scan: navigation hints (always rendered at the very bottom by
/// interactive CLIs) are checked in the last 3 lines only — so they stop
/// matching as soon as new output scrolls them up. Explicit prompt text
/// (Y/n, "Allow", etc.) uses a deeper 15-line scan.
pub fn has_waiting_pattern(content: &str) -> bool {
    let lines: Vec<&str> = content
        .lines()
        .rev()
        .filter(|l| !l.trim().is_empty())
        .take(5)
        .collect();

    // Tier 1: navigation hints — only the last 2 non-empty lines.
    // Interactive CLIs always render these at the very bottom.
    for line in lines.iter().take(2) {
        let trimmed = line.trim();
        if trimmed.contains("Enter to select")
            || trimmed.contains("Esc to cancel")
            || trimmed.contains("Arrow keys to navigate")
            || trimmed.contains("Tab/Arrow keys")
            || trimmed.contains("shift+tab to approve")
        {
            return true;
        }
    }

    // Tier 2: compact prompts (Y/n, Allow) — last 5 lines.
    // Tall menus (Claude Code approval) are already caught by tier 1.
    for line in &lines {
        let trimmed = line.trim();

        // Y/n confirmation prompts
        if trimmed.contains("(Y/n)")
            || trimmed.contains("(y/N)")
            || trimmed.contains("[Y/n]")
            || trimmed.contains("[y/N]")
            || trimmed.contains("(yes/no)")
        {
            return true;
        }

        // Claude Code permission prompts
        if trimmed.starts_with("Allow") || trimmed.starts_with("allow") {
            return true;
        }

        // Press enter prompts (e.g. "Press ENTER to continue")
        if trimmed.contains("Press ENTER") || trimmed.contains("press enter") {
            return true;
        }
    }
    false
}

/// Check the last few non-empty lines for error indicators.
pub fn has_error_pattern(content: &str) -> bool {
    let lines: Vec<&str> = content
        .lines()
        .rev()
        .filter(|l| !l.trim().is_empty())
        .take(10)
        .collect();

    for line in &lines {
        let trimmed = line.trim();
        let lower = trimmed.to_ascii_lowercase();

        // "error:" or "error[" (Rust compiler style)
        if lower.starts_with("error:") || lower.starts_with("error[") {
            return true;
        }
        // "fatal:" at start of line
        if lower.starts_with("fatal:") || lower.starts_with("fatal error") {
            return true;
        }
        // Rust panics
        if lower.contains("panicked at") {
            return true;
        }
        // Python tracebacks
        if trimmed.starts_with("Traceback (most recent call last)") {
            return true;
        }
        // Test suite failures
        if trimmed.contains("FAILED") && (trimmed.contains("test") || trimmed.contains("TEST")) {
            return true;
        }
        // npm/node errors
        if trimmed.starts_with("ERR!") || lower.starts_with("npm err!") {
            return true;
        }
        // Command not found
        if lower.ends_with("command not found") || lower.ends_with("not found") && lower.contains("error") {
            return true;
        }
        // Segfault / abort
        if lower.contains("segmentation fault") || lower.contains("core dumped") {
            return true;
        }
    }
    false
}

/// Read hook status from an explicit path (for testing).
#[cfg(test)]
fn read_hook_status_from_path(path: &std::path::Path) -> Option<PaneStatus> {
    let content = fs::read_to_string(path).ok()?;
    let metadata = fs::metadata(path).ok()?;
    let age = metadata.modified().ok()?.elapsed().ok()?;
    if age > HOOK_STALENESS {
        return None;
    }
    match content.trim() {
        "active" => Some(PaneStatus::Active),
        "idle" => Some(PaneStatus::Idle),
        "waiting" => Some(PaneStatus::Waiting),
        _ => None,
    }
}

/// Returns a colored status indicator span for tree rendering.
pub fn status_indicator(status: PaneStatus) -> Span<'static> {
    match status {
        PaneStatus::Waiting => Span::styled("●", Style::default().fg(Color::Yellow)),
        PaneStatus::Error => Span::styled("●", Style::default().fg(Color::Red)),
        PaneStatus::Active => Span::styled("●", Style::default().fg(Color::Green)),
        PaneStatus::Idle => Span::styled("○", Style::default().fg(Color::DarkGray)),
        PaneStatus::Unknown => Span::styled("?", Style::default().fg(Color::DarkGray)),
    }
}

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

    #[test]
    fn test_pane_status_ordering() {
        assert!(PaneStatus::Waiting > PaneStatus::Error);
        assert!(PaneStatus::Error > PaneStatus::Active);
        assert!(PaneStatus::Active > PaneStatus::Idle);
        assert!(PaneStatus::Idle > PaneStatus::Unknown);
    }

    #[test]
    fn test_aggregation_via_max() {
        let statuses = vec![PaneStatus::Idle, PaneStatus::Active, PaneStatus::Unknown];
        assert_eq!(statuses.into_iter().max(), Some(PaneStatus::Active));

        let statuses = vec![PaneStatus::Idle, PaneStatus::Waiting];
        assert_eq!(statuses.into_iter().max(), Some(PaneStatus::Waiting));

        let statuses = vec![PaneStatus::Error, PaneStatus::Active];
        assert_eq!(statuses.into_iter().max(), Some(PaneStatus::Error));
    }

    // classify() tests — use empty pane_id to skip hook lookup

    #[test]
    fn test_shell_is_idle() {
        assert_eq!(classify("bash", None, ""), PaneStatus::Idle);
        assert_eq!(classify("zsh", None, ""), PaneStatus::Idle);
        assert_eq!(classify("fish", None, ""), PaneStatus::Idle);
        assert_eq!(classify("-bash", None, ""), PaneStatus::Idle);
    }

    #[test]
    fn test_non_shell_is_active() {
        assert_eq!(classify("node", None, ""), PaneStatus::Active);
        assert_eq!(classify("python", None, ""), PaneStatus::Active);
        assert_eq!(classify("vim", None, ""), PaneStatus::Active);
        assert_eq!(classify("claude", None, ""), PaneStatus::Active);
    }

    #[test]
    fn test_waiting_overrides_command() {
        let content = "output\nProceed? (Y/n)";
        assert_eq!(classify("bash", Some(content), ""), PaneStatus::Waiting);
        assert_eq!(classify("node", Some(content), ""), PaneStatus::Waiting);
    }

    #[test]
    fn test_non_shell_without_waiting_is_active() {
        let content = "Compiling project...\nBuilding 42 crates";
        assert_eq!(classify("cargo", Some(content), ""), PaneStatus::Active);
    }

    #[test]
    fn test_shell_without_waiting_is_idle() {
        let content = "user@host:~$ ls\nfile1  file2";
        assert_eq!(classify("bash", Some(content), ""), PaneStatus::Idle);
    }

    // is_shell() tests

    #[test]
    fn test_is_shell_common() {
        assert!(is_shell("bash"));
        assert!(is_shell("zsh"));
        assert!(is_shell("fish"));
        assert!(is_shell("sh"));
        assert!(is_shell("dash"));
    }

    #[test]
    fn test_is_shell_login_variants() {
        assert!(is_shell("-bash"));
        assert!(is_shell("-zsh"));
        assert!(is_shell("-sh"));
        assert!(is_shell("login"));
    }

    #[test]
    fn test_is_shell_full_path() {
        assert!(is_shell("/bin/bash"));
        assert!(is_shell("/usr/bin/zsh"));
    }

    #[test]
    fn test_is_not_shell() {
        assert!(!is_shell("node"));
        assert!(!is_shell("python"));
        assert!(!is_shell("vim"));
        assert!(!is_shell("claude"));
        assert!(!is_shell("cargo"));
    }

    // has_waiting_pattern() tests

    #[test]
    fn test_waiting_yn_prompt() {
        assert!(has_waiting_pattern("Some output\nDo you want to continue? (Y/n)"));
    }

    #[test]
    fn test_waiting_allow_prompt() {
        assert!(has_waiting_pattern("Reading file...\nAllow Read access to /tmp/foo?"));
    }

    #[test]
    fn test_waiting_interactive_menu() {
        assert!(has_waiting_pattern("Pick an option:\n❯ 1. Foo\nEnter to select · Esc to cancel"));
    }

    #[test]
    fn test_waiting_press_enter() {
        assert!(has_waiting_pattern("Installation complete.\nPress ENTER to continue"));
    }

    #[test]
    fn test_waiting_yes_no_variations() {
        assert!(has_waiting_pattern("Continue? [Y/n]"));
        assert!(has_waiting_pattern("Overwrite? [y/N]"));
        assert!(has_waiting_pattern("Save changes? (yes/no)"));
    }

    #[test]
    fn test_no_waiting_pattern() {
        assert!(!has_waiting_pattern("The answer is 42"));
        assert!(!has_waiting_pattern("Compiling...\nBuilding crates"));
        assert!(!has_waiting_pattern("user@host:~$"));
    }

    // classify() with errors

    #[test]
    fn test_error_rust_compiler() {
        let content = "Compiling foo v0.1.0\nerror[E0308]: mismatched types";
        assert_eq!(classify("cargo", Some(content), ""), PaneStatus::Error);
    }

    #[test]
    fn test_error_fatal() {
        let content = "fatal: not a git repository";
        assert_eq!(classify("git", Some(content), ""), PaneStatus::Error);
    }

    #[test]
    fn test_error_panic() {
        let content = "thread 'main' panicked at 'index out of bounds'";
        assert_eq!(classify("myapp", Some(content), ""), PaneStatus::Error);
    }

    #[test]
    fn test_error_python_traceback() {
        let content = "Traceback (most recent call last)\n  File \"app.py\"\nNameError: x";
        assert_eq!(classify("python", Some(content), ""), PaneStatus::Error);
    }

    #[test]
    fn test_error_test_failure() {
        let content = "test result: FAILED. 2 passed; 1 failed; 0 ignored";
        assert_eq!(classify("cargo", Some(content), ""), PaneStatus::Error);
    }

    #[test]
    fn test_error_segfault() {
        let content = "Segmentation fault (core dumped)";
        assert_eq!(classify("myapp", Some(content), ""), PaneStatus::Error);
    }

    #[test]
    fn test_error_on_shell_pane() {
        let content = "$ cargo build\nerror[E0433]: failed to resolve";
        assert_eq!(classify("bash", Some(content), ""), PaneStatus::Error);
    }

    #[test]
    fn test_waiting_overrides_error() {
        let content = "error: build failed\nRetry? (Y/n)";
        assert_eq!(classify("bash", Some(content), ""), PaneStatus::Waiting);
    }

    #[test]
    fn test_no_false_positive_error() {
        let content = "Processing error-handling module...\nDone.";
        assert_eq!(classify("cargo", Some(content), ""), PaneStatus::Active);
    }

    // has_error_pattern() tests

    #[test]
    fn test_npm_error() {
        assert!(has_error_pattern("npm ERR! code ENOENT"));
    }

    #[test]
    fn test_command_not_found() {
        assert!(has_error_pattern("foobar: command not found"));
    }

    // PaneMonitor tests

    #[test]
    fn test_monitor_set_and_get() {
        let mut m = PaneMonitor::new();
        m.set("%0", "main", 0, PaneStatus::Active);
        assert_eq!(m.get_pane("%0"), PaneStatus::Active);
        assert_eq!(m.get_session("main"), PaneStatus::Active);
        assert_eq!(m.get_window("main", 0), PaneStatus::Active);
    }

    #[test]
    fn test_monitor_aggregation() {
        let mut m = PaneMonitor::new();
        m.set("%0", "dev", 0, PaneStatus::Idle);
        m.set("%1", "dev", 0, PaneStatus::Waiting);
        m.set("%2", "dev", 1, PaneStatus::Active);

        assert_eq!(m.get_session("dev"), PaneStatus::Waiting);
        assert_eq!(m.get_window("dev", 0), PaneStatus::Waiting);
        assert_eq!(m.get_window("dev", 1), PaneStatus::Active);
    }

    #[test]
    fn test_monitor_clear() {
        let mut m = PaneMonitor::new();
        m.set("%0", "main", 0, PaneStatus::Active);
        m.clear();
        assert_eq!(m.get_pane("%0"), PaneStatus::Unknown);
        assert_eq!(m.get_session("main"), PaneStatus::Unknown);
    }

    #[test]
    fn test_monitor_begin_update_resets() {
        let mut m = PaneMonitor::new();
        m.set("%0", "main", 0, PaneStatus::Active);
        m.begin_update();
        assert_eq!(m.get_pane("%0"), PaneStatus::Unknown);
    }

    // Claude Code detection tests (heuristic fallback, no hook files)

    #[test]
    fn test_claude_idle_prompt() {
        let content = "Done writing file.\n\n❯ \n";
        assert_eq!(classify("claude", Some(content), ""), PaneStatus::Idle);
    }

    #[test]
    fn test_claude_idle_with_status_bar() {
        let content = "● Write(file.rs)\n\n❯ \n\n  project (main) | Opus 4.6 (1M context)";
        assert_eq!(classify("claude", Some(content), ""), PaneStatus::Idle);
    }

    #[test]
    fn test_claude_active_no_prompt() {
        let content = "Compiling project...\nBuilding 42 crates\nRunning tests";
        assert_eq!(classify("claude", Some(content), ""), PaneStatus::Active);
    }

    #[test]
    fn test_claude_waiting_overrides_idle() {
        let content = "❯ \nAllow Read access to /tmp/foo?";
        assert_eq!(classify("claude", Some(content), ""), PaneStatus::Waiting);
    }

    #[test]
    fn test_claude_error_overrides_idle() {
        let content = "❯ \nerror: build failed";
        assert_eq!(classify("claude", Some(content), ""), PaneStatus::Error);
    }

    // Bug fix: stale ❯ prompt pushed past last 2 lines

    #[test]
    fn test_claude_active_stale_prompt() {
        let content = "❯ \nproject (main) | Opus 4.6\n⏺ Read(src/main.rs)\nReading file...\nProcessing content";
        assert_eq!(classify("claude", Some(content), ""), PaneStatus::Active);
    }

    #[test]
    fn test_claude_active_tool_execution() {
        let content = "Some previous output\n⏺ Write(src/lib.rs)";
        assert_eq!(classify("claude", Some(content), ""), PaneStatus::Active);
    }

    #[test]
    fn test_claude_no_content_is_active() {
        assert_eq!(classify("claude", None, ""), PaneStatus::Active);
    }

    #[test]
    fn test_claude_idle_prompt_only() {
        let content = "❯ ";
        assert_eq!(classify("claude", Some(content), ""), PaneStatus::Idle);
    }

    #[test]
    fn test_non_claude_unaffected_by_prompt_char() {
        let content = "❯ some output";
        assert_eq!(classify("vim", Some(content), ""), PaneStatus::Active);
        assert_eq!(classify("bash", Some(content), ""), PaneStatus::Idle);
    }

    // Hook-based detection tests

    #[test]
    fn test_hook_status_active() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("_99");
        fs::write(&path, "active").unwrap();

        let result = read_hook_status_from_path(&path);
        assert_eq!(result, Some(PaneStatus::Active));
    }

    #[test]
    fn test_hook_status_idle() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("_99");
        fs::write(&path, "idle").unwrap();

        let result = read_hook_status_from_path(&path);
        assert_eq!(result, Some(PaneStatus::Idle));
    }

    #[test]
    fn test_hook_status_waiting() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("_99");
        fs::write(&path, "waiting").unwrap();

        let result = read_hook_status_from_path(&path);
        assert_eq!(result, Some(PaneStatus::Waiting));
    }

    #[test]
    fn test_hook_status_unknown_content() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("_99");
        fs::write(&path, "garbage").unwrap();

        let result = read_hook_status_from_path(&path);
        assert_eq!(result, None);
    }

    #[test]
    fn test_hook_status_missing_file() {
        let result = read_hook_status("%nonexistent_test_pane");
        assert_eq!(result, None);
    }

    // Working indicator tests

    #[test]
    fn test_working_indicator_tool_marker() {
        assert!(has_claude_working_indicators("output\n⏺ Edit(file.rs)"));
    }

    #[test]
    fn test_working_indicator_spinner() {
        assert!(has_claude_working_indicators("output\n⠋ Processing..."));
    }

    #[test]
    fn test_no_working_indicator() {
        assert!(!has_claude_working_indicators("just regular output\nmore text"));
    }

    // Strict idle tests

    #[test]
    fn test_strict_idle_prompt_on_last_line() {
        assert!(is_claude_idle_strict("output\n❯ "));
    }

    #[test]
    fn test_strict_idle_multi_line_status_bar() {
        // Claude with 3-line status bar below the prompt
        let content = "previous output\n❯ \n  project (main) | Opus 4.6 (1M context)\n  cost: $0.05\n  auto mode";
        assert!(is_claude_idle_strict(content));
    }

    #[test]
    fn test_strict_idle_prompt_too_far_up() {
        assert!(!is_claude_idle_strict("❯ \nline2\nline3\nline4\nline5"));
    }
}
