//! Terminal view — full-screen view hosting one or more PTY-backed tabs.
//!
//! Layout (no separators, maximum terminal space):
//!
//!   ┌──────────────────────────────────────┐
//!   │  bash  ▏  cargo test  ▏  zsh         │   ← 1 row: TerminalTabBar
//!   │$                                     │   ← remaining rows: TerminalWidget
//!   │                                      │
//!   └──────────────────────────────────────┘
//!
//! Key bindings (normal mode):
//!   Ctrl+T          — new tab
//!   Ctrl+W          — close active tab
//!   Ctrl+R          — open rename/close popup for active tab
//!   Alt+Left/Right  — switch tabs
//!   PgUp / PgDn     — scroll scrollback
//!   Esc             — return to editor / file selector
//!   Everything else — forwarded to the PTY as raw bytes

use std::cell::{Cell, RefCell};
use std::io::Write as _;
use std::path::{Path, PathBuf};

use portable_pty::{CommandBuilder, MasterPty, PtySize, native_pty_system};
use ratatui::{
    Frame,
    layout::Rect,
    style::{Color, Style},
    widgets::{Block, Borders, Paragraph},
};

/// Strip ANSI/VT100 escape sequences from a string, returning plain text.
///
/// Handles CSI sequences (`\x1b[...`), OSC sequences (`\x1b]...`), and
/// simple two-character escapes.  Used to produce plain text for diagnostic
/// extraction from raw PTY output.
/// Extract diagnostic issues from a `StyledLine`, handling two formats:
///
/// 1. **GNU-style** (`path:line:col: error: msg`) — matched directly.
/// 2. **rustc/cargo multi-line** — the caller passes a `prev_sev` slot:
///    - A severity-header line (`error[E0425]: msg`) sets `prev_sev`.
///    - The following ` --> path:line:col` arrow line consumes `prev_sev`
///      and produces the combined issue.
fn extract_issues_with_state(
    ex: &crate::diagnostics_extractor::DiagnosticsExtractor,
    line: &crate::vt_parser::StyledLine,
    prev_sev: &mut Option<(crate::issue_registry::Severity, String)>,
) -> Vec<crate::issue_registry::NewIssue> {
    let text = &line.text;

    // Try rustc arrow line: ` --> path:line:col`
    if let Some((path, ln, col)) = ex.try_rustc_arrow(text) {
        if let Some((sev, msg)) = prev_sev.take() {
            return vec![ex.make_issue(sev, msg, Some(path), Some(ln), Some(col))];
        }
        // Arrow without a preceding header — ignore.
        return Vec::new();
    }

    // Try rustc header line: `error[...]: msg` or `warning: msg`
    if let Some(header) = ex.try_rustc_header(text) {
        *prev_sev = Some(header);
        return Vec::new();
    }

    // Non-rustc line: clear any buffered header and try GNU patterns.
    *prev_sev = None;
    ex.extract_from_line(line)
}

const MAX_SCROLL_STEP: u16 = 1000;
/// Default scroll size when settings not loaded
const DEFAULT_SCROLL_SIZE: u16 = 5;
/// Maximum configurable scroll size
const MAX_SCROLL_SIZE: u16 = 100;
use serde::{Deserialize, Serialize};
use tokio::sync::mpsc::UnboundedSender;

use crate::prelude::*;

use input::{Key, KeyEvent, Modifiers, MouseButton, MouseEvent, MouseEventKind};
use operation::{Event, Operation, TerminalOp};
use settings::Settings;
use settings::adapters::terminal;
use views::View;
use widgets::button::Menu;
use widgets::input_field::InputField;

// ---------------------------------------------------------------------------
// Types stored per tab
// ---------------------------------------------------------------------------

pub struct TerminalTab {
    pub id: u64,
    pub title: String,
    /// Original command used to launch the shell/process.
    pub command: String,
    pub cwd: PathBuf,
    pub master: Box<dyn MasterPty + Send>,
    pub writer: Box<dyn std::io::Write + Send>,
    pub parser: RefCell<vt100::Parser>,
    /// Detected clickable links in the visible buffer (row/col coordinates).
    pub links: Vec<crate::widgets::terminal::Link>,
    /// Number of lines scrolled back from live view (0 = live).
    pub scroll_offset: u16,
    /// Maximum scrollback buffer size (lines).
    pub scrollback_len: usize,
    pub exited: bool,
    /// Styled scrollback produced by the PTY async task via
    /// `TerminalOp::AppendScrollback`.  Enables state persistence and reuse
    /// of `DiagnosticsExtractor::extract_from_line` on the main thread.
    pub scrollback_lines: Vec<crate::vt_parser::StyledLine>,
}

// ---------------------------------------------------------------------------
// Popup state
// ---------------------------------------------------------------------------

enum TabPopup {
    /// Two-item context menu: 0 = Rename, 1 = Close.
    Menu { id: u64, cursor: usize },
    /// Inline rename input.
    Rename { id: u64, input: InputField },
    /// Tab-switcher list showing all tab names.
    Selector { cursor: usize },
}

const MENU_ITEMS: &[&str] = &["Rename", "Close"];

// ---------------------------------------------------------------------------
// Terminal search state
// ---------------------------------------------------------------------------

pub struct TerminalSearch {
    /// Query input field (reuses editor InputField UI).
    pub query: crate::widgets::input_field::InputField,
    /// Search options (regex, ignore-case, smart-case).
    pub opts: crate::views::editor::SearchOptions,
    /// Matches found across scrollback: (row_index, byte_start, byte_end)
    pub matches: Vec<(usize, usize, usize)>,
    /// Index into `matches` currently active/selected.
    pub current: Option<usize>,
}

/// Find matches across StyledLine buffer using the same semantics as editor.find_matches.
fn find_matches_styled(
    lines: &[crate::vt_parser::StyledLine],
    query: &str,
    opts: &crate::views::editor::SearchOptions,
) -> Vec<(usize, usize, usize)> {
    if query.is_empty() {
        return Vec::new();
    }
    let ignore = opts.ignore_case || (opts.smart_case && !query.chars().any(|c| c.is_uppercase()));
    let mut out = Vec::new();
    if opts.regex {
        if let Ok(re) = regex::RegexBuilder::new(query).case_insensitive(ignore).build() {
            for (row, line) in lines.iter().enumerate() {
                for m in re.find_iter(&line.text) {
                    out.push((row, m.start(), m.end()));
                }
            }
        }
    } else {
        let q = if ignore { query.to_lowercase() } else { query.to_owned() };
        for (row, line) in lines.iter().enumerate() {
            let l = if ignore { line.text.to_lowercase() } else { line.text.clone() };
            let mut start = 0usize;
            while let Some(pos) = l[start..].find(&q) {
                let abs = start + pos;
                out.push((row, abs, abs + q.len()));
                start = abs + 1;
            }
        }
    }
    out
}

// ---------------------------------------------------------------------------
// TerminalView
// ---------------------------------------------------------------------------

pub struct TerminalView {
    pub tabs: Vec<TerminalTab>,
    pub active: usize,
    popup: Option<TabPopup>,
    /// Active search bar state (None = bar closed).
    pub search: Option<TerminalSearch>,
    /// Retained search state for post-close F3 navigation (like editor's last_search).
    pub last_search: Option<TerminalSearch>,
    /// Monotonically-increasing counter for new tab IDs.
    pub next_id: u64,
    /// Last known terminal dimensions (cols, rows) — used to detect resize.
    last_size: Cell<(u16, u16)>,
    /// Whether detected filename/URL links should be highlighted (true when Ctrl held).
    pub show_links: bool,
    /// Number of lines to scroll with PageUp/PageDown and mouse wheel.
    scroll_size: u16,
}

impl std::fmt::Debug for TerminalView {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("TerminalView")
            .field("active", &self.active)
            .field("next_id", &self.next_id)
            .field("tabs_len", &self.tabs.len())
            .finish()
    }
}

impl Default for TerminalView {
    fn default() -> Self {
        Self::new()
    }
}

impl TerminalView {
    pub fn new() -> Self {
        // Try to get the current terminal size from crossterm
        let size = crossterm::terminal::size().unwrap_or((0, 0));
        Self {
            tabs: Vec::new(),
            active: 0,
            popup: None,
            search: None,
            last_search: None,
            next_id: 1,
            last_size: Cell::new(size),
            show_links: false,
            scroll_size: DEFAULT_SCROLL_SIZE,
        }
    }

    /// Update configuration from settings. Call this when settings change
    /// or when the terminal view becomes active.
    pub fn update_from_settings(&mut self, settings: &Settings) {
        let size = *terminal::scroll_size(settings);
        // Clamp to reasonable range [1, MAX_SCROLL_SIZE] to avoid extreme values
        self.scroll_size = size.clamp(1, MAX_SCROLL_SIZE as i64) as u16;
    }

    // -----------------------------------------------------------------------
    // Tab management (called from app.rs apply_operation)
    // -----------------------------------------------------------------------

    /// Allocate a unique tab ID.
    fn alloc_id(&mut self) -> u64 {
        let id = self.next_id;
        self.next_id += 1;
        id
    }

    /// Spawn a new PTY tab running `command` (defaults to configured/env shell).
    /// Starts an async read task that forwards output via `op_tx`.
    /// `scrollback` is the number of history lines to keep per tab.
    pub fn spawn_tab(
        &mut self,
        command: Option<String>,
        shell_setting: &str,
        cwd: &Path,
        op_tx: &UnboundedSender<Vec<Operation>>,
        scrollback: usize,
    ) {
        let id = self.alloc_id();

        // Determine terminal size: use last known or safe defaults.
        let (cols, rows) = self.last_size.get();
        let cols = if cols == 0 { 80 } else { cols };
        let rows = if rows == 0 { 24 } else { rows };
        // Reserve one row for the global status bar so child PTY and the
        // vt100 parser use the visible area size. Without this the child
        // process thinks the terminal is larger and draws under the status bar.
        let term_rows = rows.saturating_sub(1).max(1);
        log::debug!(
            "terminal.spawn_tab: cols={} rows={} term_rows={}",
            cols,
            rows,
            term_rows
        );

        // Determine the shell command.
        let shell = if let Some(ref cmd) = command {
            cmd.clone()
        } else if !shell_setting.is_empty() {
            shell_setting.to_string()
        } else {
            std::env::var("SHELL").unwrap_or_else(|_| {
                if cfg!(windows) {
                    std::env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".to_string())
                } else {
                    "/bin/bash".to_string()
                }
            })
        };

        // Derive the tab title from the executable name.
        let title = shell
            .split_whitespace()
            .next()
            .and_then(|s| s.rsplit('/').next())
            .unwrap_or("shell")
            .to_string();

        // Open PTY pair.
        let pty_system = native_pty_system();
        let pair = match pty_system.openpty(PtySize {
            rows: term_rows,
            cols,
            pixel_width: 0,
            pixel_height: 0,
        }) {
            Ok(p) => p,
            Err(e) => {
                log::error!("terminal: openpty failed: {e}");
                return;
            }
        };

        // Build and launch the command.
        let mut cmd = CommandBuilder::new(&shell);
        cmd.cwd(cwd);

        let child = match pair.slave.spawn_command(cmd) {
            Ok(c) => c,
            Err(e) => {
                log::error!("terminal: spawn_command failed: {e}");
                return;
            }
        };
        // Drop the slave end in the parent — the child holds it open.
        drop(pair.slave);

        let writer = match pair.master.take_writer() {
            Ok(w) => w,
            Err(e) => {
                log::error!("terminal: take_writer failed: {e}");
                return;
            }
        };
        let reader = match pair.master.try_clone_reader() {
            Ok(r) => r,
            Err(e) => {
                log::error!("terminal: try_clone_reader failed: {e}");
                return;
            }
        };

        let parser = RefCell::new(vt100::Parser::new(term_rows, cols, scrollback));

        self.tabs.push(TerminalTab {
            id,
            title,
            command: shell,
            cwd: cwd.to_path_buf(),
            master: pair.master,
            writer,
            parser,
            links: Vec::new(),
            scroll_offset: 0,
            scrollback_len: scrollback,
            exited: false,
            scrollback_lines: Vec::new(),
        });
        self.active = self.tabs.len() - 1;

        // Async read task: forwards PTY output through the operation channel.
        // Uses `TerminalParser` (the same SGR parser used by task runners) to
        // produce `StyledLine` objects, which are forwarded via
        // `AppendScrollback` and used for diagnostic extraction via
        // `extract_from_line` (same API as task output).
        let tx = op_tx.clone();
        let extractor = std::sync::Arc::new(
            crate::diagnostics_extractor::DiagnosticsExtractor::new(
                format!("terminal:{id}"),
                "terminal",
            ),
        );
        let ex = std::sync::Arc::clone(&extractor);
        tokio::task::spawn_blocking(move || {
            let _child = child; // keep child handle alive until the process exits
            let mut reader = reader;
            let mut buf = [0u8; 4096];
            // SGR parser — produces StyledLines from raw PTY bytes.
            let mut tp = crate::vt_parser::TerminalParser::new();
            // Pending rustc/cargo header: (severity, message) waiting for ` --> ` arrow.
            let mut prev_sev: Option<(crate::issue_registry::Severity, String)> = None;

            loop {
                match std::io::Read::read(&mut reader, &mut buf) {
                    Ok(0) | Err(_) => {
                        // Flush any partial line remaining in the parser.
                        if let Some(line) = tp.flush() {
                            let issues = extract_issues_with_state(&ex, &line, &mut prev_sev);
                            let mut ops =
                                vec![Operation::TerminalLocal(TerminalOp::AppendScrollback {
                                    id,
                                    lines: vec![line],
                                })];
                            ops.extend(issues.into_iter().map(|i| Operation::AddIssue { issue: i }));
                            let _ = tx.send(ops);
                        }
                        let _ =
                            tx.send(vec![Operation::TerminalLocal(TerminalOp::ProcessExited {
                                id,
                            })]);
                        break;
                    }
                    Ok(n) => {
                        let data = buf[..n].to_vec();
                        // Forward raw bytes to the vt100 screen parser for rendering.
                        let _ = tx.send(vec![Operation::TerminalLocal(TerminalOp::Output {
                            id,
                            data: data.clone(),
                        })]);
                        // Run through the SGR parser to get completed StyledLines.
                        let styled_lines = tp.push(&data);
                        if !styled_lines.is_empty() {
                            let mut issue_ops: Vec<Operation> = Vec::new();
                            for line in &styled_lines {
                                issue_ops.extend(
                                    extract_issues_with_state(&ex, line, &mut prev_sev)
                                        .into_iter()
                                        .map(|i| Operation::AddIssue { issue: i }),
                                );
                            }
                            let mut ops =
                                vec![Operation::TerminalLocal(TerminalOp::AppendScrollback {
                                    id,
                                    lines: styled_lines,
                                })];
                            ops.extend(issue_ops);
                            let _ = tx.send(ops);
                        }
                    }
                }
            }
        });
    }

    /// Write raw bytes to the PTY of the tab with the given ID.
    pub fn write_input(&mut self, id: u64, data: &[u8]) {
        if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == id)
            && let Err(e) = tab.writer.write_all(data)
        {
            log::warn!("terminal: write_input failed for tab {id}: {e}");
        }
    }

    /// Resize the PTY and vt100 parser for every tab.
    pub fn resize_all(&mut self, rows: u16, cols: u16) {
        let term_rows = rows.saturating_sub(1).max(1); // subtract tab bar row
        for tab in &mut self.tabs {
            let _ = tab.master.resize(PtySize {
                rows: term_rows,
                cols,
                pixel_width: 0,
                pixel_height: 0,
            });
            tab.parser.borrow_mut().set_size(term_rows, cols);
        }
        self.last_size.set((cols, rows));
    }

    // -----------------------------------------------------------------------
    // Internal helpers
    // -----------------------------------------------------------------------

    pub(crate) fn active_id(&self) -> Option<u64> {
        self.tabs.get(self.active).map(|t| t.id)
    }

    pub fn detect_links_for_tab(tab: &TerminalTab) -> Vec<crate::widgets::terminal::Link> {
        // Ensure the parser's scrollback offset matches the tab's scroll_offset so
        // that `screen()` reflects the currently visible rows for link detection.
        let mut parser = tab.parser.borrow_mut();
        parser.set_scrollback(tab.scroll_offset as usize);
        crate::widgets::terminal::detect_links_from_screen(&parser, &tab.cwd)
    }

    fn close_tab_by_id(&mut self, id: u64) {
        if let Some(idx) = self.tabs.iter().position(|t| t.id == id) {
            self.tabs.remove(idx);
            if self.active >= self.tabs.len() && !self.tabs.is_empty() {
                self.active = self.tabs.len() - 1;
            }
        }
    }

    // -----------------------------------------------------------------------
    // Persistence
    // -----------------------------------------------------------------------

    pub fn to_state(&self) -> TerminalStateStore {
        TerminalStateStore {
            tabs: self
                .tabs
                .iter()
                .map(|t| TerminalTabState {
                    id: t.id,
                    title: t.title.clone(),
                    command: t.command.clone(),
                    cwd: t.cwd.clone(),
                })
                .collect(),
            active: self.active,
            next_id: self.next_id,
        }
    }

    // -----------------------------------------------------------------------
    // Key → PTY byte encoding
    // -----------------------------------------------------------------------

    fn key_to_bytes(key: KeyEvent) -> Option<Vec<u8>> {
        let ctrl = key.modifiers.contains(Modifiers::CTRL);
        let alt = key.modifiers.contains(Modifiers::ALT);

        match key.key {
            Key::Char(c) if ctrl => {
                let lc = c.to_ascii_lowercase();
                if lc.is_ascii_lowercase() {
                    Some(vec![lc as u8 - b'a' + 1])
                } else {
                    None
                }
            }
            Key::Char(c) if alt => {
                // Alt+char → ESC + char
                Some(vec![0x1b, c as u8])
            }
            Key::Char(c) => Some(c.to_string().into_bytes()),
            Key::Enter => Some(b"\r".to_vec()),
            Key::Backspace => Some(vec![0x7f]),
            Key::Tab => Some(b"\t".to_vec()),
            Key::Delete => Some(b"\x1b[3~".to_vec()),
            Key::ArrowUp => Some(b"\x1b[A".to_vec()),
            Key::ArrowDown => Some(b"\x1b[B".to_vec()),
            Key::ArrowRight => Some(b"\x1b[C".to_vec()),
            Key::ArrowLeft => Some(b"\x1b[D".to_vec()),
            Key::Home => Some(b"\x1b[H".to_vec()),
            Key::End => Some(b"\x1b[F".to_vec()),
            Key::PageUp => Some(b"\x1b[5~".to_vec()),
            Key::PageDown => Some(b"\x1b[6~".to_vec()),
            Key::F(1) => Some(b"\x1bOP".to_vec()),
            Key::F(2) => Some(b"\x1bOQ".to_vec()),
            Key::F(3) => Some(b"\x1bOR".to_vec()),
            Key::F(4) => Some(b"\x1bOS".to_vec()),
            Key::F(n) => {
                // F5–F12 use the Xterm encoding.
                let code: u8 = match n {
                    5 => 15,
                    6 => 17,
                    7 => 18,
                    8 => 19,
                    9 => 20,
                    10 => 21,
                    11 => 23,
                    12 => 24,
                    _ => return None,
                };
                Some(format!("\x1b[{}~", code).into_bytes())
            }
            _ => None,
        }
    }

    // -----------------------------------------------------------------------
    // Popup rendering helpers
    // -----------------------------------------------------------------------

    fn render_menu_popup(&self, frame: &mut Frame, _id: u64, cursor: usize) {
        let area = frame.area();
        let popup_rect = Rect {
            x: area.x,
            y: area.y + 1,
            width: 18,
            height: (MENU_ITEMS.len() + 2) as u16,
        };
        if popup_rect.bottom() > area.bottom() || popup_rect.right() > area.right() {
            return;
        }

        let menu = Menu::new(MENU_ITEMS).cursor(cursor);
        frame.render_widget(menu, popup_rect);
    }

    fn render_rename_popup(&self, frame: &mut Frame, _id: u64, input: &str) {
        let area = frame.area();
        let popup_rect = Rect {
            x: area.x,
            y: area.y + 1,
            width: 30.min(area.width),
            height: 3,
        };
        if popup_rect.bottom() > area.bottom() {
            return;
        }
        let display = format!(" {}_ ", input);
        let p = Paragraph::new(display).block(
            Block::default()
                .borders(Borders::ALL)
                .title(" Rename ")
                .style(
                    Style::default()
                        .fg(Color::Rgb(100, 100, 100))
                        .bg(Color::Rgb(40, 40, 40)),
                ),
        );
        frame.render_widget(p, popup_rect);
    }


    fn render_selector_popup(&self, frame: &mut Frame, area: Rect, cursor: usize) {
        let n = self.tabs.len();
        if n == 0 {
            return;
        }
        let height = (n as u16 + 2).min(area.height);
        let width = self
            .tabs
            .iter()
            .map(|t| t.title.chars().count())
            .max()
            .unwrap_or(10) as u16
            + 4;
        let width = width.min(area.width).max(14);
        // Anchor to bottom-left of the terminal area (just above the status bar).
        let y = area.bottom().saturating_sub(height);
        let popup_rect = Rect { x: area.x + 1, y, width, height };
        let tab_names: Vec<&str> = self.tabs.iter().map(|t| t.title.as_str()).collect();
        let menu = Menu::new(&tab_names).cursor(cursor);
        frame.render_widget(menu, popup_rect);
    }
}

// ---------------------------------------------------------------------------
// Paste forwarding
// ---------------------------------------------------------------------------

impl TerminalView {
    /// Forward bracketed-paste content to the active PTY tab as raw bytes.
    pub fn handle_paste(&self, text: &str) -> Vec<Operation> {
        let Some(id) = self.active_id() else {
            return vec![];
        };
        let data = text.as_bytes().to_vec();
        vec![
            Operation::TerminalLocal(TerminalOp::ScrollReset),
            Operation::TerminalInput { id, data },
        ]
    }
}

// ---------------------------------------------------------------------------
// View trait
// ---------------------------------------------------------------------------

impl View for TerminalView {
    const KIND: crate::views::ViewKind = crate::views::ViewKind::Primary;

    fn save_state(&mut self, app: &mut crate::app_state::AppState) {
        crate::views::save_state::terminal_pre_save(self, app);
    }

    fn status_bar(
        &self,
        _state: &crate::app_state::AppState,
        bar: &mut crate::widgets::status_bar::StatusBarBuilder,
    ) {
        let name = self
            .tabs
            .get(self.active)
            .map(|t| t.title.as_str())
            .unwrap_or("(no tab)");
        bar.menu(
            format!("tab: {}", name),
            crate::commands::CommandId::new_static("terminal", "open_tab_selector"),
        );
    }

    fn handle_key(&self, key: KeyEvent) -> Vec<Operation> {
        // ── Popup: rename input ──────────────────────────────────────────────
        if let Some(TabPopup::Rename { input, .. }) = &self.popup {
            return match key.key {
                Key::Escape => vec![Operation::TerminalLocal(TerminalOp::RenameCancel)],
                Key::Enter => vec![Operation::TerminalLocal(TerminalOp::RenameConfirm)],
                _ => {
                    if let Some(field_op) = crate::widgets::input_field::key_to_op(key) {
                        let mut f = input.clone();
                        f.apply(&field_op);
                        vec![Operation::TerminalLocal(TerminalOp::RenameChanged(
                            f.text().to_owned(),
                        ))]
                    } else {
                        vec![]
                    }
                }
            };
        }

        // ── Popup: tab selector ──────────────────────────────────────────────
        if let Some(TabPopup::Selector { .. }) = &self.popup {
            return match key.key {
                Key::Escape => vec![Operation::TerminalLocal(TerminalOp::CloseMenu)],
                Key::Enter => vec![Operation::TerminalLocal(TerminalOp::MenuConfirm)],
                Key::ArrowUp => vec![Operation::NavigateUp],
                Key::ArrowDown => vec![Operation::NavigateDown],
                _ => vec![],
            };
        }

        // ── Popup: context menu ──────────────────────────────────────────────
        if let Some(TabPopup::Menu { .. }) = &self.popup {
            return match key.key {
                Key::Escape => vec![Operation::TerminalLocal(TerminalOp::CloseMenu)],
                Key::Enter => vec![Operation::TerminalLocal(TerminalOp::MenuConfirm)],
                Key::ArrowUp => vec![Operation::NavigateUp],
                Key::ArrowDown => vec![Operation::NavigateDown],
                _ => vec![],
            };
        }

        // ── Search bar active — capture all keys ─────────────────────────────
        if self.search.is_some() {
            let alt = key.modifiers.contains(Modifiers::ALT);
            return match (alt, key.key) {
                (_, Key::Escape) => vec![Operation::SearchLocal(crate::operation::SearchOp::Close)],
                (_, Key::F(3)) if !key.modifiers.contains(Modifiers::SHIFT) => {
                    vec![Operation::SearchLocal(crate::operation::SearchOp::NextMatch)]
                }
                (_, Key::F(3)) => {
                    vec![Operation::SearchLocal(crate::operation::SearchOp::PrevMatch)]
                }
                (true, Key::Char('c')) | (true, Key::Char('C')) => {
                    vec![Operation::SearchLocal(crate::operation::SearchOp::ToggleIgnoreCase)]
                }
                (true, Key::Char('r')) | (true, Key::Char('R')) => {
                    vec![Operation::SearchLocal(crate::operation::SearchOp::ToggleRegex)]
                }
                (true, Key::Char('s')) | (true, Key::Char('S')) => {
                    vec![Operation::SearchLocal(crate::operation::SearchOp::ToggleSmartCase)]
                }
                _ => {
                    if let Some(field_op) = crate::widgets::input_field::key_to_op(key) {
                        vec![Operation::SearchLocal(crate::operation::SearchOp::QueryInput(field_op))]
                    } else {
                        vec![]
                    }
                }
            };
        }

        // ── Normal terminal mode ─────────────────────────────────────────────
        let ctrl = key.modifiers.contains(Modifiers::CTRL);
        let alt = key.modifiers.contains(Modifiers::ALT);

        // Tab management shortcuts (not forwarded to PTY).
        if ctrl && !alt {
            match key.key {
                Key::Char('t') | Key::Char('T') => {
                    return vec![Operation::TerminalLocal(TerminalOp::NewTab {
                        command: None,
                    })];
                }
                Key::Char('w') | Key::Char('W') => {
                    if let Some(id) = self.active_id() {
                        return vec![Operation::TerminalLocal(TerminalOp::CloseTab { id })];
                    }
                    return vec![];
                }
                Key::Char('r') | Key::Char('R') => {
                    if let Some(id) = self.active_id() {
                        return vec![Operation::TerminalLocal(TerminalOp::OpenMenu { id })];
                    }
                    return vec![];
                }
                Key::Char('f') | Key::Char('F') => {
                    return vec![Operation::SearchLocal(crate::operation::SearchOp::Open { replace: false })];
                }
                _ => {}
            }
        }

        if alt {
            match key.key {
                Key::ArrowLeft => return vec![Operation::TerminalLocal(TerminalOp::PrevTab)],
                Key::ArrowRight => return vec![Operation::TerminalLocal(TerminalOp::NextTab)],
                _ => {}
            }
        }

        // Escape returns to the previous screen.

        // PageUp/PageDown scroll the scrollback buffer.
        if !ctrl && !alt {
            match key.key {
                Key::PageUp => {
                    return vec![Operation::NavigatePageUp];
                }
                Key::PageDown => {
                    return vec![Operation::NavigatePageDown];
                }
                _ => {}
            }
        }

        // Function keys: F3/Shift+F3 navigate search matches (uses last_search when bar closed).
        if let Key::F(3) = key.key {
            if key.modifiers.contains(Modifiers::SHIFT) {
                return vec![Operation::SearchLocal(crate::operation::SearchOp::PrevMatch)];
            } else {
                return vec![Operation::SearchLocal(crate::operation::SearchOp::NextMatch)];
            }
        }

        // Everything else: encode as PTY input bytes.
        if let Some(id) = self.active_id()
            && let Some(data) = Self::key_to_bytes(key)
        {
            // Reset scrollback so the user sees the live view.
            // Additionally, when the user presses Enter assume a new command is
            // being executed and clear any ephemeral issues that belong to this
            // terminal tab (marker = "terminal:{id}"). This prevents stale
            // diagnostics from persisting across commands that have run.
            if let Key::Enter = key.key {
                return vec![
                    Operation::TerminalLocal(TerminalOp::ScrollReset),
                    Operation::ClearIssuesByMarker { marker: format!("terminal:{}", id) },
                    Operation::TerminalInput { id, data },
                ];
            }
            return vec![
                Operation::TerminalLocal(TerminalOp::ScrollReset),
                Operation::TerminalInput { id, data },
            ];
        }

        vec![]
    }

    fn handle_mouse(&self, mouse: MouseEvent) -> Vec<Operation> {
        // Update link highlight state based on Ctrl modifier on mouse events.
        // Only use the current mouse modifiers so highlighting is active only while
        // Ctrl is held; avoid OR-ing with self.show_links which made it sticky.
        let highlight = mouse.modifiers.contains(Modifiers::CTRL);
        let mut ops: Vec<Operation> = vec![Operation::TerminalLocal(TerminalOp::SetLinkHighlight { enabled: highlight })];

        // Handle scroll events (mouse wheel)
        match mouse.kind {
            MouseEventKind::ScrollUp => {
                ops.push(Operation::NavigatePageUp);
                return ops;
            }
            MouseEventKind::ScrollDown => {
                ops.push(Operation::NavigatePageDown);
                return ops;
            }
            _ => {}
        }

        // Only react to button-press events — ignore Up, Move, Drag.
        let is_down = matches!(mouse.kind, MouseEventKind::Down(_));

        // Right-click anywhere in the terminal → open generic context menu.
        if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right)) {
            use crate::commands::CommandId;
            ops.push(Operation::OpenContextMenu {
                items: vec![
                    ("New Tab".to_string(),    CommandId::new_static("terminal", "new_tab"), Some(true)),
                    ("Rename Tab".to_string(), CommandId::new_static("terminal", "rename_active_tab"), Some(true)),
                    ("Close Tab".to_string(),  CommandId::new_static("terminal", "close_active_tab"), Some(true)),
                    ("Clear".to_string(),      CommandId::new_static("terminal", "clear_active_tab"), Some(true)),
                ],
                x: mouse.column,
                y: mouse.row,
            });
            return ops;
        }

        // ── Menu popup ────────────────────────────────────────────────────────
        if let Some(TabPopup::Menu { .. }) = &self.popup {
            let menu = Menu::new(MENU_ITEMS);
            let menu_area = Rect {
                x: 0,
                y: 1,
                width: 18,
                height: (MENU_ITEMS.len() + 2) as u16,
            };

            if let Some(clicked_item) = menu.hit_test((mouse.column, mouse.row), menu_area) {
                if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
                    let current_cursor = if let Some(TabPopup::Menu { cursor, .. }) = &self.popup {
                        *cursor
                    } else {
                        0
                    };
                    let mut inner_ops: Vec<Operation> = vec![];
                    if clicked_item < current_cursor {
                        for _ in 0..(current_cursor - clicked_item) {
                            inner_ops.push(Operation::NavigateUp);
                        }
                    } else {
                        for _ in 0..(clicked_item - current_cursor) {
                            inner_ops.push(Operation::NavigateDown);
                        }
                    }
                    inner_ops.push(Operation::TerminalLocal(TerminalOp::MenuConfirm));
                    ops.extend(inner_ops);
                    return ops;
                }
                return ops;
            }

            // Click outside menu → close it.
            if is_down {
                ops.push(Operation::TerminalLocal(TerminalOp::CloseMenu));
                return ops;
            }
            return ops;
        }

        // ── Tab selector popup ───────────────────────────────────────────────
        if let Some(TabPopup::Selector { cursor }) = &self.popup {
            let n = self.tabs.len();
            let (cols, rows) = self.last_size.get();
            // Subtract 1 from rows to match the view_area height used by
            // render_selector_popup (the status bar occupies the last row).
            let view_rows = rows.saturating_sub(1).max(1);
            let width = self
                .tabs
                .iter()
                .map(|t| t.title.chars().count())
                .max()
                .unwrap_or(10) as u16
                + 4;
            let width = width.min(cols).max(14);
            let height = (n as u16 + 2).min(view_rows);
            let y = view_rows.saturating_sub(height);
            let selector_area = Rect { x: 1, y, width, height };

            if let Some(clicked_item) = Menu::new(&self.tabs.iter().map(|t| t.title.as_str()).collect::<Vec<_>>()).hit_test((mouse.column, mouse.row), selector_area) {
                if is_down {
                    let current = *cursor;
                    let mut inner_ops: Vec<Operation> = vec![];
                    if clicked_item < current { for _ in 0..(current - clicked_item) { inner_ops.push(Operation::NavigateUp); } } else { for _ in 0..(clicked_item - current) { inner_ops.push(Operation::NavigateDown); } }
                    inner_ops.push(Operation::TerminalLocal(TerminalOp::MenuConfirm));
                    ops.extend(inner_ops);
                    return ops;
                }
                return ops;
            }
            if is_down {
                ops.push(Operation::TerminalLocal(TerminalOp::CloseMenu));
                return ops;
            }
        }

        // Clicks on links (left button) — open URL or file.
        if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left))
            && let Some(tab) = self.tabs.get(self.active) {
                let col = mouse.column;
                let row = mouse.row;
                if let Some(link) = tab.links.iter().find(|l| l.row == row && col >= l.start_col && col < l.end_col) {
                    match &link.kind {
                        crate::widgets::terminal::LinkKind::Url(u) => {
                            ops.push(Operation::OpenUrl { url: u.clone() });
                            return ops;
                        }
                        crate::widgets::terminal::LinkKind::File { path, line, column } => {
                            ops.push(Operation::OpenFile { path: path.clone() });
                            if let Some(l) = line {
                                // Convert 1-based file:line[:col] to 0-based
                                let row_idx = l.saturating_sub(1);
                                let col_idx = column.unwrap_or(1).saturating_sub(1);
                                ops.push(Operation::GoToLineLocal(
                                    crate::operation::GoToLineOp::JumpTo {
                                        line: row_idx,
                                        column: col_idx,
                                    },
                                ));
                            }
                            return ops;
                        }
                        // Diagnostic links are row-level visual indicators; they don't
                        // have a distinct click action (the File link within the same row
                        // handles navigation). Search links are visual only as well.
                        crate::widgets::terminal::LinkKind::Diagnostic { .. } => {}
                        crate::widgets::terminal::LinkKind::Search | crate::widgets::terminal::LinkKind::SearchCurrent => {}
                    }
                }
            }

        ops
    }

    fn handle_operation(&mut self, op: &Operation, settings: &Settings) -> Option<Event> {
        match op {
            // ── PTY output ─────────────────────────────────────────────────
            Operation::TerminalLocal(TerminalOp::Output { id, data }) => {
                if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == *id) {
                    tab.parser.borrow_mut().process(data);
                    // Reset scroll to live view when new output arrives
                    tab.scroll_offset = 0;
                    // Recompute clickable links based on the visible screen (only when highlighting is enabled).
                    if self.show_links {
                        tab.links = Self::detect_links_for_tab(tab);
                    } else {
                        tab.links.clear();
                    }
                }
                Some(Event::applied("terminal", op.clone()))
            }

            // ── Styled scrollback lines produced by the async task ──────────
            Operation::TerminalLocal(TerminalOp::AppendScrollback { id, lines }) => {
                if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == *id) {
                    tab.scrollback_lines.extend(lines.iter().cloned());
                }
                Some(Event::applied("terminal", op.clone()))
            }

            // ── Process exited (handled here after app.rs spawns replacement) ─
            Operation::TerminalLocal(TerminalOp::ProcessExited { id }) => {
                if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == *id) {
                    tab.exited = true;
                }
                // Removal and optional new-tab spawning is done in apply_operation.
                Some(Event::applied("terminal", op.clone()))
            }

            // ── Tab navigation ──────────────────────────────────────────────
            Operation::TerminalLocal(TerminalOp::SwitchToTab { index }) => {
                if *index < self.tabs.len() {
                    self.active = *index;
                }
                Some(Event::applied("terminal", op.clone()))
            }
            Operation::TerminalLocal(TerminalOp::NextTab) => {
                if self.tabs.len() > 1 {
                    self.active = (self.active + 1) % self.tabs.len();
                }
                Some(Event::applied("terminal", op.clone()))
            }
            Operation::TerminalLocal(TerminalOp::PrevTab) => {
                if self.tabs.len() > 1 {
                    self.active = (self.active + self.tabs.len() - 1) % self.tabs.len();
                }
                Some(Event::applied("terminal", op.clone()))
            }

            // ── Scrollback ──────────────────────────────────────────────────
            // Note: scroll_offset must not exceed the parser's scrollback buffer size
            Operation::NavigatePageUp => {
                if let Some(tab) = self.tabs.get_mut(self.active) {
                    let lines = self.scroll_size.min(MAX_SCROLL_STEP);
                    let max_scrollback = tab.scrollback_len as u16;
                    tab.scroll_offset = (tab.scroll_offset + lines).min(max_scrollback);
                    // Recompute visible links after scrolling up so they reflect the
                    // current visible screen rather than the previous bottom-of-buffer.
                    if self.show_links {
                        tab.links = Self::detect_links_for_tab(tab);
                    } else {
                        tab.links.clear();
                    }
                }
                Some(Event::applied("terminal", op.clone()))
            }
            Operation::NavigatePageDown => {
                if let Some(tab) = self.tabs.get_mut(self.active) {
                    let lines = self.scroll_size.min(MAX_SCROLL_STEP);
                    tab.scroll_offset = tab.scroll_offset.saturating_sub(lines);
                    // Recompute visible links after scrolling down so they reflect the
                    // current visible screen.
                    if self.show_links {
                        tab.links = Self::detect_links_for_tab(tab);
                    } else {
                        tab.links.clear();
                    }
                }
                Some(Event::applied("terminal", op.clone()))
            }
            Operation::TerminalLocal(TerminalOp::ScrollReset) => {
                if let Some(tab) = self.tabs.get_mut(self.active) {
                    tab.scroll_offset = 0;
                    // Recompute visible links when resetting scroll so the link set
                    // matches the top-of-buffer view.
                    if self.show_links {
                        tab.links = Self::detect_links_for_tab(tab);
                    } else {
                        tab.links.clear();
                    }
                }
                Some(Event::applied("terminal", op.clone()))
            }

            // ── Popup: open menu ────────────────────────────────────────────
            Operation::TerminalLocal(TerminalOp::SetLinkHighlight { enabled }) => {
                // Toggle whether links are highlighted (enabled when Ctrl held).
                self.show_links = *enabled;
                if let Some(tab) = self.tabs.get_mut(self.active) {
                    if *enabled {
                        tab.links = TerminalView::detect_links_for_tab(tab);
                    } else {
                        tab.links.clear();
                    }
                }
                Some(Event::applied("terminal", op.clone()))
            }
            Operation::TerminalLocal(TerminalOp::OpenMenu { id }) => {
                self.popup = Some(TabPopup::Menu { id: *id, cursor: 0 });
                Some(Event::applied("terminal", op.clone()))
            }
            // ── Popup: open tab selector ─────────────────────────────────────
            Operation::TerminalLocal(TerminalOp::OpenTabSelector) => {
                self.popup = Some(TabPopup::Selector { cursor: self.active });
                Some(Event::applied("terminal", op.clone()))
            }
            // ── Popup: open rename input directly (from generic context menu) ──
            Operation::TerminalLocal(TerminalOp::OpenRename { id }) => {
                let current = self
                    .tabs
                    .iter()
                    .find(|t| t.id == *id)
                    .map(|t| t.title.clone())
                    .unwrap_or_default();
                let mut field = InputField::new("Rename");
                field.set_text(current);
                self.popup = Some(TabPopup::Rename { id: *id, input: field });
                Some(Event::applied("terminal", op.clone()))
            }
            // ── Clear tab screen ─────────────────────────────────────────────
            Operation::TerminalLocal(TerminalOp::ClearTab { id }) => {
                if let Some(idx) = self.tabs.iter().position(|t| t.id == *id) {
                    // Route clear request through the PTY so the shell/child can handle it.
                    // Use Ctrl+L (0x0c) which shells commonly interpret as "clear screen".
                    self.write_input(*id, b"\x0c");
                    if let Some(tab) = self.tabs.get_mut(idx) {
                        tab.scroll_offset = 0;
                    }
                }
                Some(Event::applied("terminal", op.clone()))
            }
            Operation::NavigateUp => {
                match &mut self.popup {
                    Some(TabPopup::Menu { cursor, .. }) if *cursor > 0 => {
                        *cursor -= 1;
                    }
                    Some(TabPopup::Selector { cursor }) if *cursor > 0 => {
                        *cursor -= 1;
                    }
                    _ => {}
                }
                Some(Event::applied("terminal", op.clone()))
            }
            Operation::NavigateDown => {
                match &mut self.popup {
                    Some(TabPopup::Menu { cursor, .. }) => {
                        *cursor = (*cursor + 1).min(MENU_ITEMS.len() - 1);
                    }
                    Some(TabPopup::Selector { cursor }) => {
                        *cursor = (*cursor + 1).min(self.tabs.len().saturating_sub(1));
                    }
                    _ => {}
                }
                Some(Event::applied("terminal", op.clone()))
            }
            Operation::TerminalLocal(TerminalOp::CloseMenu) => {
                self.popup = None;
                Some(Event::applied("terminal", op.clone()))
            }
            Operation::TerminalLocal(TerminalOp::MenuConfirm) => {
                match self.popup.take() {
                    Some(TabPopup::Menu { id, cursor }) => match cursor {
                        0 => {
                            // Rename: transition to rename popup pre-filled with current title.
                            let current = self
                                .tabs
                                .iter()
                                .find(|t| t.id == id)
                                .map(|t| t.title.clone())
                                .unwrap_or_default();
                            let mut field = InputField::new("Rename");
                            field.set_text(current);
                            self.popup = Some(TabPopup::Rename { id, input: field });
                        }
                        _ => {
                            // Close tab.
                            self.close_tab_by_id(id);
                        }
                    },
                    Some(TabPopup::Selector { cursor }) => {
                        // Switch to the selected tab.
                        self.active = cursor.min(self.tabs.len().saturating_sub(1));
                    }
                    _ => {
                        self.popup = None;
                    }
                }
                Some(Event::applied("terminal", op.clone()))
            }

            // ── Popup: rename ────────────────────────────────────────────────
            Operation::TerminalLocal(TerminalOp::RenameChanged(s)) => {
                if let Some(TabPopup::Rename { input, .. }) = &mut self.popup {
                    input.set_text(s.clone());
                }
                Some(Event::applied("terminal", op.clone()))
            }
            Operation::TerminalLocal(TerminalOp::RenameConfirm) => {
                if let Some(TabPopup::Rename { id, input }) = self.popup.take()
                    && let Some(tab) = self.tabs.iter_mut().find(|t| t.id == id)
                    && !input.is_empty()
                {
                    tab.title = input.text().to_owned();
                }
                Some(Event::applied("terminal", op.clone()))
            }
            Operation::TerminalLocal(TerminalOp::RenameCancel) => {
                self.popup = None;
                Some(Event::applied("terminal", op.clone()))
            }
            // ── Inline search bar (reuses editor SearchOp semantics) ─────────
            Operation::SearchLocal(sop) => {
                use crate::operation::SearchOp;
                // Helper: scroll to a match line
                fn scroll_to_match(
                    match_line: usize,
                    tab: &mut TerminalTab,
                    _rows: usize,
                    show_links: bool,
                ) {
                    // match_line is an index into the combined vector used for matching
                    // (scrollback_lines followed by the live screen rows). Compute a
                    // top_index clamped to at most the scrollback length so we can
                    // derive the correct scroll_offset.
                    let total = tab.scrollback_lines.len();
                    let top_index = if match_line <= total { match_line } else { total };
                    tab.scroll_offset = total.saturating_sub(top_index) as u16;
                    if show_links {
                        tab.links = TerminalView::detect_links_for_tab(tab);
                    } else {
                        tab.links.clear();
                    }
                }
                let rows = self.last_size.get().1.saturating_sub(1).max(1) as usize;
                match sop {
                    SearchOp::Open { .. } => {
                        let mut field = InputField::new("Search");
                        // Restore previous query + opts from last_search when available.
                        let (prev_text, prev_opts) = self.last_search.as_ref()
                            .map(|s| (s.query.text().to_owned(), s.opts.clone()))
                            .unwrap_or_else(|| (String::new(), crate::views::editor::SearchOptions::default()));
                        field.set_text(prev_text.clone());
                        let opts = prev_opts;
                        let matches = if let Some(tab) = self.tabs.get(self.active) {
                            // Combine stored scrollback lines with the current visible screen
                            // so searches include the freshest output that may not yet be
                            // present in `tab.scrollback_lines`.
                            let mut all_lines = tab.scrollback_lines.clone();
                            let parser_ref = tab.parser.borrow();
                            let screen = parser_ref.screen();
                            let (rows, cols) = screen.size();
                            for r in 0..rows {
                                let mut row_text = String::with_capacity(cols as usize);
                                for c in 0..cols {
                                    if let Some(cell) = screen.cell(r, c) {
                                        let s = cell.contents();
                                        row_text.push_str(if s.is_empty() { " " } else { &s });
                                    } else {
                                        row_text.push(' ');
                                    }
                                }
                                all_lines.push(crate::vt_parser::StyledLine { text: row_text, spans: Vec::new() });
                            }
                            find_matches_styled(&all_lines, &prev_text, &opts)
                        } else { Vec::new() };
                        self.search = Some(TerminalSearch { query: field, opts, matches, current: None });
                    }
                    SearchOp::Close => {
                        // Save to last_search for post-close F3.
                        self.last_search = self.search.take();
                    }
                    SearchOp::QueryInput(field_op) => {
                        if let Some(s) = &mut self.search {
                            s.query.apply(field_op);
                            if let Some(tab) = self.tabs.get(self.active) {
                                let q = s.query.text().to_owned();
                                // Combine scrollback and visible screen for freshest results.
                                let mut all_lines = tab.scrollback_lines.clone();
                                let parser_ref = tab.parser.borrow();
                                let screen = parser_ref.screen();
                                let (rows, cols) = screen.size();
                                for r in 0..rows {
                                    let mut row_text = String::with_capacity(cols as usize);
                                    for c in 0..cols {
                                        if let Some(cell) = screen.cell(r, c) {
                                            let s_cell = cell.contents();
                                            row_text.push_str(if s_cell.is_empty() { " " } else { &s_cell });
                                        } else {
                                            row_text.push(' ');
                                        }
                                    }
                                    all_lines.push(crate::vt_parser::StyledLine { text: row_text, spans: Vec::new() });
                                }
                                s.matches = find_matches_styled(&all_lines, &q, &s.opts);
                                s.current = None;
                                log::debug!("terminal: QueryInput q='{}' matches_total={}", q, s.matches.len());
                            }
                        }
                    }
                    SearchOp::ToggleIgnoreCase => {
                        if let Some(s) = &mut self.search {
                            s.opts.ignore_case = !s.opts.ignore_case;
                            if let Some(tab) = self.tabs.get(self.active) {
                                let q = s.query.text().to_owned();
                                // Combine scrollback and visible screen for freshest results.
                                let mut all_lines = tab.scrollback_lines.clone();
                                let parser_ref = tab.parser.borrow();
                                let screen = parser_ref.screen();
                                let (rows, cols) = screen.size();
                                for r in 0..rows {
                                    let mut row_text = String::with_capacity(cols as usize);
                                    for c in 0..cols {
                                        if let Some(cell) = screen.cell(r, c) {
                                            let s_cell = cell.contents();
                                            row_text.push_str(if s_cell.is_empty() { " " } else { &s_cell });
                                        } else {
                                            row_text.push(' ');
                                        }
                                    }
                                    all_lines.push(crate::vt_parser::StyledLine { text: row_text, spans: Vec::new() });
                                }
                                s.matches = find_matches_styled(&all_lines, &q, &s.opts);
                                s.current = None;
                                log::debug!("terminal: ToggleIgnoreCase q='{}' matches_total={}", q, s.matches.len());
                            }
                        }
                    }
                    SearchOp::ToggleRegex => {
                        if let Some(s) = &mut self.search {
                            s.opts.regex = !s.opts.regex;
                            if let Some(tab) = self.tabs.get(self.active) {
                                let q = s.query.text().to_owned();
                                let mut all_lines = tab.scrollback_lines.clone();
                                let parser_ref = tab.parser.borrow();
                                let screen = parser_ref.screen();
                                let (rows, cols) = screen.size();
                                for r in 0..rows {
                                    let mut row_text = String::with_capacity(cols as usize);
                                    for c in 0..cols {
                                        if let Some(cell) = screen.cell(r, c) {
                                            let s_cell = cell.contents();
                                            row_text.push_str(if s_cell.is_empty() { " " } else { &s_cell });
                                        } else {
                                            row_text.push(' ');
                                        }
                                    }
                                    all_lines.push(crate::vt_parser::StyledLine { text: row_text, spans: Vec::new() });
                                }
                                s.matches = find_matches_styled(&all_lines, &q, &s.opts);
                                s.current = None;
                                log::debug!("terminal: ToggleRegex q='{}' matches_total={}", q, s.matches.len());
                            }
                        }
                    }
                    SearchOp::ToggleSmartCase => {
                        if let Some(s) = &mut self.search {
                            s.opts.smart_case = !s.opts.smart_case;
                            if let Some(tab) = self.tabs.get(self.active) {
                                let q = s.query.text().to_owned();
                                let mut all_lines = tab.scrollback_lines.clone();
                                let parser_ref = tab.parser.borrow();
                                let screen = parser_ref.screen();
                                let (rows, cols) = screen.size();
                                for r in 0..rows {
                                    let mut row_text = String::with_capacity(cols as usize);
                                    for c in 0..cols {
                                        if let Some(cell) = screen.cell(r, c) {
                                            let s_cell = cell.contents();
                                            row_text.push_str(if s_cell.is_empty() { " " } else { &s_cell });
                                        } else {
                                            row_text.push(' ');
                                        }
                                    }
                                    all_lines.push(crate::vt_parser::StyledLine { text: row_text, spans: Vec::new() });
                                }
                                s.matches = find_matches_styled(&all_lines, &q, &s.opts);
                                s.current = None;
                                log::debug!("terminal: ToggleSmartCase q='{}' matches_total={}", q, s.matches.len());
                            }
                        }
                    }
                    SearchOp::NextMatch => {
                        // Operate on active search, or fall back to last_search (post-close F3).
                        if let Some(s) = self.search.as_mut().or(self.last_search.as_mut()) && !s.matches.is_empty() {
                                let next = match s.current { Some(i) => (i + 1) % s.matches.len(), None => 0 };
                                s.current = Some(next);
                                let (match_line, _, _) = s.matches[next];
                                log::debug!("terminal: NextMatch idx={} total={} match_line={}", next, s.matches.len(), match_line);
                                if let Some(tab) = self.tabs.get_mut(self.active) {
                                    scroll_to_match(match_line, tab, rows, self.show_links);
                                }
                        }
                    }
                    SearchOp::PrevMatch => {
                        if let Some(s) = self.search.as_mut().or(self.last_search.as_mut()) && !s.matches.is_empty() {
                                let prev = match s.current {
                                    Some(0) | None => s.matches.len().saturating_sub(1),
                                    Some(i) => i - 1,
                                };
                                s.current = Some(prev);
                                let (match_line, _, _) = s.matches[prev];
                                log::debug!("terminal: PrevMatch idx={} total={} match_line={}", prev, s.matches.len(), match_line);
                                if let Some(tab) = self.tabs.get_mut(self.active) {
                                    scroll_to_match(match_line, tab, rows, self.show_links);
                                }
                        }
                    }
                    _ => {}
                }
                Some(Event::applied("terminal", op.clone()))
            }

            // ── Close via op (app.rs handles spawning the replacement if needed) ─
            Operation::TerminalLocal(TerminalOp::CloseTab { id }) => {
                self.close_tab_by_id(*id);
                Some(Event::applied("terminal", op.clone()))
            }

            // ── Resize ──────────────────────────────────────────────────────
            Operation::TerminalLocal(TerminalOp::Resize { cols, rows }) => {
                self.last_size.set((*cols, *rows));
                self.update_from_settings(settings);
                self.resize_all(*rows, *cols);
                Some(Event::applied("terminal", op.clone()))
            }

            _ => None,
        }
    }

    fn render(&self, frame: &mut Frame, area: Rect, _theme: &crate::theme::Theme) {
        // Fallback sizing: if crossterm::terminal::size() failed at startup and
        // we got (0, 0), use the actual frame area. Normal resize events from
        // the event loop handle size changes via TerminalOp::Resize.
        if self.last_size.get() == (0, 0) && area.width > 0 && area.height > 0 {
            self.last_size.set((area.width, area.height));
        }
        log::debug!(
            "TerminalView::render: area={}x{} last_size={:?}",
            area.width,
            area.height,
            self.last_size.get()
        );

        // When search bar is open, reserve the top row for it.
        let search_active = self.search.is_some();
        let (content_area, bar_area) = if search_active && area.height > 1 {
            let bar = Rect { y: area.y, height: 1, ..area };
            let content = Rect { y: area.y + 1, height: area.height - 1, ..area };
            (content, Some(bar))
        } else {
            (area, None)
        };

        // ── Terminal content ────────────────────────────────────────────────
        if let Some(tab) = self.tabs.get(self.active) {
            {
                let mut parser = tab.parser.borrow_mut();
                parser.set_scrollback(tab.scroll_offset as usize);
            }
            // Build visible search highlights: active search OR last_search (for post-close F3).
            let parser_ref = tab.parser.borrow();
            let mut links = tab.links.clone();
            let active_search = self.search.as_ref().or(self.last_search.as_ref());
            if let Some(search) = active_search {
                log::debug!("terminal.render: query='{}' matches_total={} current={:?} existing_links={}", search.query.text(), search.matches.len(), search.current, tab.links.len());
                let q = search.query.text();
                if !q.is_empty() {
                    let screen = parser_ref.screen();
                    let (rows, cols) = screen.size();
                    let ignore = search.opts.ignore_case
                        || (search.opts.smart_case && !q.chars().any(|c| c.is_uppercase()));
                    if search.opts.regex {
                        if let Ok(re) = regex::RegexBuilder::new(q).case_insensitive(ignore).build() {
                            for r in 0..rows {
                                let mut row_text = String::with_capacity(cols as usize);
                                for c in 0..cols {
                                    if let Some(cell) = screen.cell(r, c) {
                                        let s = cell.contents();
                                        row_text.push_str(if s.is_empty() { " " } else { &s });
                                    } else {
                                        row_text.push(' ');
                                    }
                                }
                                for m in re.find_iter(&row_text) {
                                    links.push(crate::widgets::terminal::Link {
                                        kind: crate::widgets::terminal::LinkKind::Search,
                                        row: r,
                                        start_col: m.start() as u16,
                                        end_col: m.end() as u16,
                                        text: row_text[m.start()..m.end()].to_string(),
                                    });
                                }
                            }
                        }
                    } else {
                        let q_cmp = if ignore { q.to_lowercase() } else { q.to_owned() };
                        for r in 0..rows {
                            let mut row_text = String::with_capacity(cols as usize);
                            for c in 0..cols {
                                if let Some(cell) = screen.cell(r, c) {
                                    let s = cell.contents();
                                    row_text.push_str(if s.is_empty() { " " } else { &s });
                                } else {
                                    row_text.push(' ');
                                }
                            }
                            let l = if ignore {row_text.to_lowercase() } else { row_text.clone() };
                            let mut start = 0usize;
                            while let Some(found) = l[start..].find(&q_cmp) {
                                let abs = start + found;
                                let end = abs + q.len();
                                links.push(crate::widgets::terminal::Link {
                                    kind: crate::widgets::terminal::LinkKind::Search,
                                    row: r,
                                    start_col: abs as u16,
                                    end_col: end as u16,
                                    text: row_text[abs..end].to_string(),
                                });
                                start = end;
                            }
                        }
                    }

                                    // Additionally, if there is a current match index, add a SearchCurrent
                    // link so the widget can render it with a distinct style.
                    if let Some(cur_idx) = search.current && cur_idx < search.matches.len() {
                        let (match_line, start_byte, end_byte) = search.matches[cur_idx];
                        let total = tab.scrollback_lines.len();
                        let rows_usize = rows as usize;
                        // top_index is the combined-vector index of the first visible line.
                        // Matches were computed against a vector that is scrollback_lines
                        // followed by the current screen rows, so the first visible line
                        // has index == total - scroll_offset.
                        let top_index = total.saturating_sub(tab.scroll_offset as usize);
                        if match_line >= top_index && match_line < top_index + rows_usize {
                            let visible_row = (match_line - top_index) as u16;
                            // Extract matched text: from scrollback if within that range,
                            // otherwise from the live screen rows appended after scrollback.
                            let (text, start_col_vis, end_col_vis) = if match_line < total {
                                let l = &tab.scrollback_lines[match_line];
                                let text = l.text.get(start_byte..end_byte).unwrap_or("").to_string();
                                let start_col = crate::widgets::text_area::byte_to_screen_col(&l.text, start_byte, 8);
                                let end_col = crate::widgets::text_area::byte_to_screen_col(&l.text, end_byte, 8);
                                (text, start_col as u16, end_col as u16)
                            } else {
                                let vis_idx = match_line - total;
                                let mut row_text = String::with_capacity(cols as usize);
                                for c in 0..cols {
                                    if let Some(cell) = screen.cell(vis_idx as u16, c) {
                                        let s = cell.contents();
                                        row_text.push_str(if s.is_empty() { " " } else { &s });
                                    } else {
                                        row_text.push(' ');
                                    }
                                }
                                let text = row_text.get(start_byte..end_byte).unwrap_or("").to_string();
                                let start_col = crate::widgets::text_area::byte_to_screen_col(&row_text, start_byte, 8);
                                let end_col = crate::widgets::text_area::byte_to_screen_col(&row_text, end_byte, 8);
                                (text, start_col as u16, end_col as u16)
                            };
                            links.push(crate::widgets::terminal::Link {
                                kind: crate::widgets::terminal::LinkKind::SearchCurrent,
                                row: visible_row,
                                start_col: start_col_vis,
                                end_col: end_col_vis,
                                text,
                            });
                        }
                    }
                }
            }
            frame.render_widget(
                crate::widgets::terminal::TerminalWidget { parser: parser_ref, links, show_links: self.show_links },
                content_area,
            );
        }

        // ── Inline search bar (same visual as editor) ───────────────────────
        if let (Some(bar), Some(search)) = (bar_area, self.search.as_ref()) {
            self.render_search_bar(frame, bar, search);
        }

        // ── Popup overlay ───────────────────────────────────────────────────
        let popup_snapshot = match &self.popup {
            Some(TabPopup::Menu { id, cursor }) => Some(PopupSnapshot::Menu { id: *id, cursor: *cursor }),
            Some(TabPopup::Rename { id, input }) => Some(PopupSnapshot::Rename { id: *id, input: input.text().to_owned() }),
            Some(TabPopup::Selector { cursor }) => Some(PopupSnapshot::Selector { cursor: *cursor }),
            None => None,
        };
        if let Some(snap) = popup_snapshot {
            match snap {
                PopupSnapshot::Menu { id, cursor } => self.render_menu_popup(frame, id, cursor),
                PopupSnapshot::Rename { id, input } => self.render_rename_popup(frame, id, &input),
                PopupSnapshot::Selector { cursor } => self.render_selector_popup(frame, area, cursor),
            }
        }
    }
}

impl TerminalView {
    /// Render the one-row inline search bar — identical visual to the editor's search bar.
    fn render_search_bar(&self, frame: &mut Frame, area: Rect, search: &TerminalSearch) {
        use ratatui::layout::{Constraint, Direction, Layout};
        use ratatui::text::{Line, Span};

        let bar_bg = Color::Rgb(30, 45, 70);
        let active_bg = Color::Rgb(50, 70, 110);
        let btn_on = Style::default().fg(Color::Black).bg(Color::Rgb(80, 170, 220));
        let btn_off = Style::default().fg(Color::DarkGray).bg(Color::Rgb(40, 55, 80));

        const BTN_W: u16 = 27;
        let left_w = area.width.saturating_sub(BTN_W);

        let btn_row = Line::from(vec![
            Span::styled(" ", Style::default().bg(bar_bg)),
            Span::styled(" IgnCase ", if search.opts.ignore_case { btn_on } else { btn_off }),
            Span::styled(" ", Style::default().bg(bar_bg)),
            Span::styled(" Regex ", if search.opts.regex { btn_on } else { btn_off }),
            Span::styled(" ", Style::default().bg(bar_bg)),
            Span::styled(" Smart ", if search.opts.smart_case { btn_on } else { btn_off }),
            Span::styled(" ", Style::default().bg(bar_bg)),
        ]);

        let count_str = if search.matches.is_empty() {
            " No matches".to_owned()
        } else {
            let cur = search.current.map(|i| i + 1).unwrap_or(0);
            format!(" {}/{}", cur, search.matches.len())
        };

        let query_line = format!(" Find: {}  {}", search.query.text(), count_str);

        let [left_area, right_area] = Layout::default()
            .direction(Direction::Horizontal)
            .constraints([Constraint::Length(left_w), Constraint::Length(BTN_W)])
            .split(area)[..]
        else { return; };

        frame.render_widget(
            Paragraph::new(query_line).style(Style::default().fg(Color::White).bg(active_bg)),
            left_area,
        );
        frame.render_widget(
            Paragraph::new(btn_row).style(Style::default().bg(bar_bg)),
            right_area,
        );
    }
}


// Helper to avoid borrow-checker issues when rendering popup while borrowing self.
enum PopupSnapshot {
    Menu { id: u64, cursor: usize },
    Rename { id: u64, input: String },
    Selector { cursor: usize },
}

// ---------------------------------------------------------------------------
// Persistence types
// ---------------------------------------------------------------------------

#[derive(Debug, Default, Serialize, Deserialize)]
pub struct TerminalStateStore {
    pub tabs: Vec<TerminalTabState>,
    pub active: usize,
    pub next_id: u64,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TerminalTabState {
    pub id: u64,
    pub title: String,
    pub command: String,
    pub cwd: PathBuf,
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::input::{Key, KeyEvent, Modifiers};
    use tokio::sync::mpsc;

    // Verify that pressing Enter in the terminal view emits a
    // ClearIssuesByMarker operation for the active tab (marker = "terminal:{id}").
    #[tokio::test]
    async fn pressing_enter_emits_clear_issues_marker() {
        let mut tv = TerminalView::new();
        let (_tx, _rx) = mpsc::unbounded_channel::<Vec<Operation>>();
        let cwd = std::path::Path::new(".");

        // Create a lightweight tab using openpty (no background read task).
        let pty_system = portable_pty::native_pty_system();
        let pair = pty_system
            .openpty(portable_pty::PtySize { rows: 24, cols: 80, pixel_width: 0, pixel_height: 0 })
            .expect("openpty");
        let writer = pair.master.take_writer().expect("take_writer");
        let parser = std::cell::RefCell::new(vt100::Parser::new(24, 80, 10));
        let tab = TerminalTab {
            id: tv.alloc_id(),
            title: "test".to_string(),
            command: "".to_string(),
            cwd: cwd.to_path_buf(),
            master: pair.master,
            writer,
            parser,
            links: Vec::new(),
            scroll_offset: 0,
            scrollback_len: 10,
            exited: false,
            scrollback_lines: Vec::new(),
        };
        tv.tabs.push(tab);
        tv.active = tv.tabs.len() - 1;
        assert!(!tv.tabs.is_empty(), "a tab should be present");

        let id = tv.tabs[tv.active].id;
        let key = KeyEvent { modifiers: Modifiers::empty(), key: Key::Enter };
        let ops = tv.handle_key(key);

        let found = ops.into_iter().any(|op| match op {
            Operation::ClearIssuesByMarker { marker } => marker == format!("terminal:{}", id),
            _ => false,
        });

        assert!(found, "expected ClearIssuesByMarker for marker terminal:{}", id);
    }
}
