//! Terminal process/session registry.

use std::cell::RefCell;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::rc::Rc;
use std::time::Instant;

use thiserror::Error;

use crate::ansi::AnsiParserProfile;
use crate::buffer::{Buffer, BufferId};
use crate::cell::{Cell, CellCoord, CellSize};
use crate::editor_core::EditorCore;
use crate::key::{Chord, parse_chord};
use crate::process::{
    ProcessEventKind, ProcessId, ProcessMode, ProcessSpec, ProcessState, ProcessSupervisor,
    RestartPolicy, StdinMode, TerminalMode,
};
use crate::terminal::screen::TerminalScreen;
use crate::terminal::view::{TerminalController, TerminalViewKey, TerminalViewState};
use crate::terminal::{
    MAX_TERMINAL_COLS, MAX_TERMINAL_HISTORY_CELLS, MAX_TERMINAL_METADATA_BYTES, MAX_TERMINAL_ROWS,
    MAX_TERMINAL_VISIBLE_CELLS, TerminalFrame, TerminalProcessState, TerminalSelectionSpan,
};

/// Shared single-owner terminal registry used by editor and future Lua bindings.
pub type SharedTerminalManager = Rc<RefCell<TerminalManager>>;

/// Complete owned description of a terminal child and its initial screen.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TerminalSpec {
    /// Executable path or name resolved through `PATH`.
    pub command: String,
    /// Child arguments, excluding argv[0].
    pub args: Vec<String>,
    /// Working directory, or the editor process directory when absent.
    pub cwd: Option<PathBuf>,
    /// Environment overrides inherited by the child. `TERM` defaults to
    /// `xterm-256color` when the caller does not provide it.
    pub env: Vec<(String, String)>,
    /// Identity-buffer name. Defaults to `*terminal:<command>*`.
    pub name: Option<String>,
    /// Initial terminal rows.
    pub rows: u16,
    /// Initial terminal columns.
    pub cols: u16,
    /// Retained main-screen scrollback row cap.
    pub scrollback_rows: usize,
}

impl TerminalSpec {
    /// Construct a conventional 24x80 terminal specification.
    #[must_use]
    pub fn new(command: impl Into<String>) -> Self {
        Self {
            command: command.into(),
            args: Vec::new(),
            cwd: None,
            env: Vec::new(),
            name: None,
            rows: 24,
            cols: 80,
            scrollback_rows: crate::terminal::DEFAULT_TERMINAL_SCROLLBACK_ROWS,
        }
    }

    /// Validate every raw field before any buffer or process is created.
    pub fn validate(&self) -> Result<(), TerminalError> {
        if self.command.is_empty() {
            return Err(TerminalError::InvalidSpec(
                "command must not be empty".into(),
            ));
        }
        reject_nul("command", self.command.as_bytes())?;
        for arg in &self.args {
            reject_nul("argument", arg.as_bytes())?;
        }
        if let Some(cwd) = &self.cwd {
            if cwd.as_os_str().is_empty() {
                return Err(TerminalError::InvalidSpec(
                    "cwd must not be an empty path".into(),
                ));
            }
            reject_nul("cwd", cwd.as_os_str().as_encoded_bytes())?;
        }
        let mut env_names = HashSet::with_capacity(self.env.len());
        for (name, value) in &self.env {
            if name.is_empty() || name.contains('=') {
                return Err(TerminalError::InvalidSpec(format!(
                    "environment name {name:?} must be non-empty and contain no '='"
                )));
            }
            reject_nul("environment name", name.as_bytes())?;
            reject_nul("environment value", value.as_bytes())?;
            if !env_names.insert(name) {
                return Err(TerminalError::InvalidSpec(format!(
                    "duplicate environment name {name:?}"
                )));
            }
        }
        if let Some(name) = &self.name {
            if name.is_empty() {
                return Err(TerminalError::InvalidSpec(
                    "buffer name must not be empty".into(),
                ));
            }
            reject_nul("buffer name", name.as_bytes())?;
            if name.contains(['\r', '\n']) {
                return Err(TerminalError::InvalidSpec(
                    "buffer name must fit on one line".into(),
                ));
            }
        }
        validate_size(self.rows, self.cols)?;
        if self.scrollback_rows > MAX_TERMINAL_HISTORY_CELLS {
            return Err(TerminalError::InvalidSpec(format!(
                "scrollback row cap {} exceeds terminal history cell budget {}",
                self.scrollback_rows, MAX_TERMINAL_HISTORY_CELLS
            )));
        }
        Ok(())
    }

    fn buffer_name(&self) -> String {
        self.name.clone().unwrap_or_else(|| {
            let command = Path::new(&self.command)
                .file_name()
                .and_then(|name| name.to_str())
                .filter(|name| !name.is_empty())
                .unwrap_or(self.command.as_str());
            format!("*terminal:{command}*")
        })
    }
}

/// Owned, renderer-safe terminal state captured after a manager tick.
#[derive(Clone, Debug, PartialEq)]
pub struct TerminalSnapshot {
    /// Identity buffer backing this terminal.
    pub buffer_id: BufferId,
    /// Visible grid dimensions.
    pub size: CellSize,
    /// Row-major visible cells.
    pub cells: Vec<Cell>,
    /// Visible child cursor, if enabled.
    pub cursor: Option<CellCoord>,
    /// Sanitized child title.
    pub title: Option<String>,
    /// Published screen generation.
    pub screen_generation: u64,
    /// Context selection. Empty in context-free Stage 1 snapshots.
    pub selection: Vec<TerminalSelectionSpan>,
    /// Context scrollback offset. Zero in Stage 1 snapshots.
    pub scroll_offset: u32,
    /// Whether this context follows the bottom. Always true in Stage 1.
    pub at_bottom: bool,
    /// Exact operating-system process id for this session generation.
    pub pid: u32,
    /// Latest observed process state.
    pub process: TerminalProcessState,
}

impl TerminalSnapshot {
    /// Convert an owned snapshot into its protocol-v19 wire form.
    ///
    /// The two shapes are deliberately distinct types even though their
    /// fields line up: the snapshot is core-owned state the TUI also
    /// consumes, while [`TerminalFrame`] is wire input a peer may forge.
    /// The conversion is total, but the CALLER must still
    /// [`TerminalFrame::validate`] before emitting — the aggregate glyph
    /// bound is a wire limit the screen does not enforce, so a child
    /// that builds a legal-but-huge internal snapshot must be caught
    /// here rather than sent truncated.
    #[must_use]
    pub fn into_terminal_frame(self) -> TerminalFrame {
        TerminalFrame {
            buffer_id: self.buffer_id,
            size: self.size,
            cells: self.cells,
            cursor: self.cursor,
            title: self.title,
            screen_generation: self.screen_generation,
            selection: self.selection,
            scroll_offset: self.scroll_offset,
            at_bottom: self.at_bottom,
            pid: self.pid,
            process: self.process,
        }
    }
}

/// Terminal session/registry failures.
#[derive(Debug, Error)]
pub enum TerminalError {
    /// Specification validation failed before creation began.
    #[error("invalid terminal specification: {0}")]
    InvalidSpec(String),
    /// The synchronous PTY spawn failed; no session is published.
    #[error("terminal spawn failed: {0}")]
    Spawn(String),
    /// Buffer registry work failed during transactional creation.
    #[error("terminal buffer operation failed: {0}")]
    Buffer(String),
    /// Screen construction or resize failed.
    #[error("terminal screen operation failed: {0}")]
    Screen(String),
    /// No session owns the requested identity buffer.
    #[error("buffer {0:?} is not a terminal")]
    NotTerminal(BufferId),
    /// Process I/O, resize, signal, or cleanup failed.
    #[error("terminal process operation failed: {0}")]
    Process(String),
}

pub(super) struct TerminalSession {
    pub(super) process_id: ProcessId,
    pub(super) pid: u32,
    pub(super) screen: TerminalScreen,
    pub(super) process: TerminalProcessState,
    pub(super) annotated: bool,
    /// Resolved `terminal.escape-key` for this terminal (Q#TC4c).
    ///
    /// The cache lives HERE, not in an editor-side map, because a
    /// session is created in [`TerminalManager::open`] and dropped on
    /// kill/prune — so its lifetime is exactly the cache's, with no
    /// purge hook to forget. An editor-side map would leak an entry per
    /// terminal; a single last-entry cache would reparse (and re-report
    /// an invalid value) every time focus alternates between two
    /// terminals.
    pub(super) escape: Option<EscapeCache>,
}

/// One terminal's parsed escape chord, valid for one config epoch.
pub(super) struct EscapeCache {
    /// The `ConfigRegistry::value_epoch` this was parsed at. The key is
    /// `(this session, epoch)`: the epoch alone is not enough, because
    /// it does not advance when focus moves between terminals with
    /// different buffer-local values.
    pub(super) epoch: u64,
    /// The effective chord — the parsed spelling, or the `C-c` fallback.
    pub(super) chord: Chord,
    /// The invalid spelling already reported for this terminal, if any.
    /// Reporting is once per terminal per effective invalid value: an
    /// unchanged bad value stays quiet, a *different* bad value reports
    /// again because it is a new mistake.
    pub(super) reported_invalid: Option<String>,
}

/// Owns the one-buffer/one-process/one-screen terminal registry.
#[derive(Default)]
pub struct TerminalManager {
    pub(super) sessions: HashMap<BufferId, TerminalSession>,
    /// Total escape-key parses performed (Q#TC4c observability).
    escape_parses: u64,
    process_to_buffer: HashMap<ProcessId, BufferId>,
    /// Removed buffers whose children are still being reaped. Their events
    /// remain manager-owned so Lua/LSP/MCP consumers cannot steal a batch.
    closing: HashSet<ProcessId>,
    /// Per-frontend/window projections over the one session screen.
    pub(super) views: HashMap<TerminalViewKey, TerminalViewState>,
    /// At most one authenticated frontend/window controls each session PTY.
    pub(super) controllers: HashMap<BufferId, TerminalController>,
}

impl TerminalManager {
    /// Construct an empty manager.
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    /// Number of published terminal sessions.
    #[must_use]
    pub fn len(&self) -> usize {
        self.sessions.len()
    }

    /// Whether no terminal session is currently published.
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.sessions.is_empty()
    }

    /// Transactionally create an internal terminal identity, PTY, and screen.
    pub fn open(
        &mut self,
        spec: TerminalSpec,
        core: &mut EditorCore,
        supervisor: &mut ProcessSupervisor,
    ) -> Result<BufferId, TerminalError> {
        spec.validate()?;
        let size = CellSize::new(u32::from(spec.rows), u32::from(spec.cols));
        let screen = TerminalScreen::new(size, spec.scrollback_rows)
            .map_err(|error| TerminalError::Screen(error.to_string()))?;

        let base_name = spec.buffer_name();
        let buffer_name = if spec.name.is_some() {
            base_name
        } else {
            unique_terminal_name(core, &base_name)
        };
        let buffer_id = BufferId::next();
        let mut buffer = Buffer::new(buffer_id, buffer_name.clone());
        buffer.set_read_only(true);
        core.registry.borrow_mut().insert(buffer);

        let mut process_spec = ProcessSpec::new(buffer_name, spec.command);
        process_spec.args = spec.args;
        process_spec.cwd = spec.cwd;
        process_spec.env = spec.env;
        if !process_spec.env.iter().any(|(name, _)| name == "TERM") {
            process_spec
                .env
                .push(("TERM".into(), "xterm-256color".into()));
        }
        process_spec.mode = ProcessMode::Pty {
            rows: spec.rows,
            cols: spec.cols,
            mode: TerminalMode::Raw,
        };
        process_spec.restart = RestartPolicy::Never;
        process_spec.ansi_events = true;
        process_spec.ansi_profile = AnsiParserProfile::FullScreen;
        process_spec.stdin = StdinMode::Piped;
        process_spec.group = false;

        let process_id = match supervisor.spawn_terminal(process_spec) {
            Ok(id) => id,
            Err(error) => {
                core.registry
                    .borrow_mut()
                    .remove(buffer_id)
                    .map_err(|rollback| {
                        TerminalError::Buffer(format!(
                            "spawn failed ({error}); buffer rollback failed: {rollback}"
                        ))
                    })?;
                return Err(TerminalError::Spawn(error));
            }
        };
        let pid =
            if let Some(ProcessState::Running { pid, .. } | ProcessState::Exiting { pid, .. }) =
                supervisor.state(process_id)
            {
                *pid
            } else {
                let _ = supervisor.terminate(process_id);
                let _ = core.registry.borrow_mut().remove(buffer_id);
                return Err(TerminalError::Spawn(
                    "supervisor published a PTY without a running pid".into(),
                ));
            };

        let previous = self.sessions.insert(
            buffer_id,
            TerminalSession {
                process_id,
                pid,
                screen,
                process: TerminalProcessState::Running,
                annotated: false,
                escape: None,
            },
        );
        debug_assert!(previous.is_none(), "fresh BufferId collided");
        self.process_to_buffer.insert(process_id, buffer_id);
        core.set_round_trip_input(buffer_id, true);
        Ok(buffer_id)
    }

    /// Whether `buffer_id` identifies a published terminal session.
    #[must_use]
    pub fn is_terminal(&self, buffer_id: BufferId) -> bool {
        self.sessions.contains_key(&buffer_id)
    }

    /// Owned process id for a terminal buffer. The OS pid stays in snapshots.
    #[must_use]
    pub fn process_id(&self, buffer_id: BufferId) -> Option<ProcessId> {
        self.sessions
            .get(&buffer_id)
            .map(|session| session.process_id)
    }

    /// Monotonic terminal BEL count used for per-frontend delivery baselines.
    #[must_use]
    pub fn bell_count(&self, buffer_id: BufferId) -> Option<u64> {
        self.sessions
            .get(&buffer_id)
            .map(|session| session.screen.bell_count())
    }

    /// Ensure an exact terminal view exists without changing its controller.
    ///
    /// Returns `false` when the key's buffer is not a published terminal.
    pub fn register_view(&mut self, key: TerminalViewKey) -> bool {
        if !self.sessions.contains_key(&key.buffer_id) {
            return false;
        }
        self.views.entry(key).or_default();
        true
    }

    /// Borrow fresh mutable state for an already registered exact view.
    pub fn view_state_mut(&mut self, key: TerminalViewKey) -> Option<&mut TerminalViewState> {
        self.views.get_mut(&key)
    }

    /// Borrow fresh state for an already registered exact view.
    #[must_use]
    pub fn view_state(&self, key: TerminalViewKey) -> Option<&TerminalViewState> {
        self.views.get(&key)
    }

    /// Retain only `live` views belonging to one authenticated frontend.
    pub fn retain_frontend_views(
        &mut self,
        frontend_id: crate::protocol::FrontendId,
        live: &HashSet<TerminalViewKey>,
    ) {
        self.views.retain(|key, _| {
            key.frontend_id != frontend_id
                || (live.contains(key) && self.sessions.contains_key(&key.buffer_id))
        });
        self.controllers.retain(|buffer_id, controller| {
            controller.frontend_id != frontend_id
                || live.contains(&TerminalViewKey::new(
                    frontend_id,
                    controller.window_id,
                    *buffer_id,
                ))
        });
    }

    /// Drop all view and controller state owned by a detached frontend.
    pub fn detach_frontend(&mut self, frontend_id: crate::protocol::FrontendId) {
        self.views.retain(|key, _| key.frontend_id != frontend_id);
        self.controllers
            .retain(|_, controller| controller.frontend_id != frontend_id);
    }

    /// Give an exact registered view durable PTY control for its session.
    ///
    /// A frontend controls at most one session. Claiming another registered
    /// view atomically releases that frontend's previous session first.
    pub fn claim_controller(&mut self, key: TerminalViewKey) -> bool {
        if !self.views.contains_key(&key) || !self.sessions.contains_key(&key.buffer_id) {
            return false;
        }
        self.controllers.retain(|buffer_id, controller| {
            controller.frontend_id != key.frontend_id || *buffer_id == key.buffer_id
        });
        self.controllers
            .insert(key.buffer_id, TerminalController::from_view(key));
        true
    }

    /// Release control only when `key` is the current controller.
    pub fn release_controller(&mut self, key: TerminalViewKey) -> bool {
        if self
            .controllers
            .get(&key.buffer_id)
            .is_some_and(|controller| controller.matches(key))
        {
            self.controllers.remove(&key.buffer_id);
            true
        } else {
            false
        }
    }

    /// Current durable controller for one terminal session.
    #[must_use]
    pub fn controller(&self, buffer_id: BufferId) -> Option<TerminalController> {
        self.controllers.get(&buffer_id).copied()
    }

    /// Capture context-free owned visible state after the latest tick.
    #[must_use]
    pub fn snapshot(&self, buffer_id: BufferId) -> Option<TerminalSnapshot> {
        let session = self.sessions.get(&buffer_id)?;
        let screen = session.screen.snapshot();
        Some(TerminalSnapshot {
            buffer_id,
            size: screen.size,
            cells: screen.cells,
            cursor: screen.cursor,
            title: screen.title.map(|title| sanitize_metadata(&title)),
            screen_generation: screen.generation,
            selection: Vec::new(),
            scroll_offset: 0,
            at_bottom: true,
            pid: session.pid,
            process: session.process.clone(),
        })
    }

    /// Drain only terminal-owned process IDs after the supervisor tick.
    pub fn tick(&mut self, supervisor: &mut ProcessSupervisor) {
        let process_ids: Vec<ProcessId> = self.process_to_buffer.keys().copied().collect();
        for process_id in process_ids {
            let Some(buffer_id) = self.process_to_buffer.get(&process_id).copied() else {
                continue;
            };
            let events = supervisor.take_events(process_id);
            let Some(session) = self.sessions.get_mut(&buffer_id) else {
                continue;
            };
            let mut outcome = None;
            for event in events {
                match event.kind {
                    ProcessEventKind::Started { pid } => session.pid = pid,
                    ProcessEventKind::Ansi(events) => {
                        for event in events {
                            if let Some(response) = session.screen.apply_event(event) {
                                let _ = supervisor.write_stdin(process_id, &response);
                            }
                        }
                    }
                    ProcessEventKind::Exited { code } => {
                        outcome = Some(TerminalProcessState::Exited(code));
                    }
                    ProcessEventKind::Signaled { signal } => {
                        outcome = Some(TerminalProcessState::Signaled(sanitize_metadata(&signal)));
                    }
                    ProcessEventKind::Crashed { error } => {
                        outcome = Some(TerminalProcessState::Crashed(sanitize_metadata(&error)));
                    }
                    ProcessEventKind::Stdout(_)
                    | ProcessEventKind::Stderr(_)
                    | ProcessEventKind::Restarting { .. } => {}
                }
            }
            let _ = session.screen.synchronized_watchdog_expired(Instant::now());
            if let Some(outcome) = outcome {
                finish_session(session, outcome);
            }
        }

        let closing: Vec<ProcessId> = self.closing.iter().copied().collect();
        for process_id in closing {
            // Continue owning and discarding every final batch until reaped.
            let _ = supervisor.take_events(process_id);
            if matches!(
                supervisor.state(process_id),
                Some(ProcessState::Terminated(_)) | None
            ) {
                let _ = supervisor.forget(process_id);
                self.closing.remove(&process_id);
            }
        }
    }

    /// Queue raw terminal input for a running child.
    pub fn send(
        &self,
        buffer_id: BufferId,
        bytes: &[u8],
        supervisor: &mut ProcessSupervisor,
    ) -> Result<(), TerminalError> {
        let session = self
            .sessions
            .get(&buffer_id)
            .ok_or(TerminalError::NotTerminal(buffer_id))?;
        supervisor
            .write_stdin(session.process_id, bytes)
            .map_err(TerminalError::Process)
    }

    /// Resolve this terminal's effective escape chord, parsing at most
    /// once per `(terminal, config epoch)` (Q#TC4c).
    ///
    /// `spelling` is the caller-resolved `terminal.escape-key` value and
    /// `epoch` the registry's `value_epoch()` it was read at. Returns the
    /// effective chord plus, at most once per terminal per effective
    /// invalid value, a message the caller should surface.
    ///
    /// An unparseable spelling falls back to `C-c` rather than leaving the
    /// terminal with no escape at all (Q#TC4a): without one, every key goes
    /// to the child and the user cannot reach the binding that would fix
    /// the setting that broke it.
    pub fn escape_chord(
        &mut self,
        buffer_id: BufferId,
        epoch: u64,
        spelling: &str,
    ) -> (Chord, Option<String>) {
        let fallback = default_escape_chord();
        if let Some(session) = self.sessions.get(&buffer_id)
            && let Some(cache) = session.escape.as_ref()
            && cache.epoch == epoch
        {
            return (cache.chord, None);
        }
        self.escape_parses = self.escape_parses.saturating_add(1);
        let Some(session) = self.sessions.get_mut(&buffer_id) else {
            return (fallback, None);
        };
        let previously_reported = session
            .escape
            .as_ref()
            .and_then(|cache| cache.reported_invalid.clone());
        let (chord, reported_invalid, report) = match parse_chord(spelling) {
            Ok(chord) => (chord, None, None),
            Err(error) => {
                let already = previously_reported.as_deref() == Some(spelling);
                let message = (!already).then(|| {
                    format!(
                        "terminal.escape-key {spelling:?} is not a valid chord ({error}); using C-c"
                    )
                });
                (fallback, Some(spelling.to_owned()), message)
            }
        };
        session.escape = Some(EscapeCache {
            epoch,
            chord,
            reported_invalid,
        });
        (chord, report)
    }

    /// How many escape-key spellings this manager has parsed.
    ///
    /// An observability seam for Q#TC4c's cache contract, which is
    /// otherwise unpinnable for a VALID setting: a correct per-session
    /// cache and a single last-entry cache produce identical behavior
    /// there and differ only in how often they parse. Counting reports
    /// covers the invalid case; this covers the valid one.
    #[must_use]
    pub fn escape_parses(&self) -> u64 {
        self.escape_parses
    }

    /// How many terminals currently hold a cached escape chord.
    ///
    /// The LIFETIME half of Q#TC4c's cache contract, which `escape_parses`
    /// cannot cover: parse counting says a valid setting is read once, but
    /// says nothing about whether the cache is ever released. Because the
    /// cache lives on [`TerminalSession`], this count falls with the
    /// session set by construction — which is exactly the property worth
    /// pinning, since the rejected alternative (an editor-side
    /// `HashMap<BufferId, EscapeCache>`) has no purge hook and would hold
    /// this at its high-water mark while sessions drained.
    #[must_use]
    pub fn escape_caches(&self) -> usize {
        self.sessions
            .values()
            .filter(|session| session.escape.is_some())
            .count()
    }

    /// Resize a terminal screen and its PTY after validating shared limits.
    pub fn resize(
        &mut self,
        buffer_id: BufferId,
        rows: u16,
        cols: u16,
        supervisor: &mut ProcessSupervisor,
    ) -> Result<(), TerminalError> {
        validate_size(rows, cols)?;
        let session = self
            .sessions
            .get_mut(&buffer_id)
            .ok_or(TerminalError::NotTerminal(buffer_id))?;
        if matches!(session.process, TerminalProcessState::Running) {
            supervisor
                .resize_pty(session.process_id, rows, cols)
                .map_err(TerminalError::Process)?;
        }
        session
            .screen
            .resize(CellSize::new(u32::from(rows), u32::from(cols)))
            .map_err(|error| TerminalError::Screen(error.to_string()))
    }

    /// Request SIGTERM. Snapshot state stays `Running` until the outcome event.
    pub fn terminate(
        &mut self,
        buffer_id: BufferId,
        supervisor: &mut ProcessSupervisor,
    ) -> Result<(), TerminalError> {
        let session = self
            .sessions
            .get(&buffer_id)
            .ok_or(TerminalError::NotTerminal(buffer_id))?;
        if matches!(session.process, TerminalProcessState::Running) {
            supervisor
                .terminate(session.process_id)
                .map_err(TerminalError::Process)?;
        }
        Ok(())
    }

    /// Tear down sessions whose identity buffers were removed by any path.
    pub fn prune(&mut self, core: &mut EditorCore, supervisor: &mut ProcessSupervisor) {
        let removed: Vec<BufferId> = {
            let registry = core.registry.borrow();
            self.sessions
                .keys()
                .copied()
                .filter(|buffer_id| !registry.contains(*buffer_id))
                .collect()
        };
        for buffer_id in removed {
            core.set_round_trip_input(buffer_id, false);
            let Some(session) = self.sessions.remove(&buffer_id) else {
                continue;
            };
            self.process_to_buffer.remove(&session.process_id);
            self.views.retain(|key, _| key.buffer_id != buffer_id);
            self.controllers.remove(&buffer_id);
            match supervisor.state(session.process_id) {
                Some(
                    ProcessState::Starting
                    | ProcessState::Running { .. }
                    | ProcessState::Exiting { .. },
                ) => {
                    let _ = supervisor.terminate(session.process_id);
                    self.closing.insert(session.process_id);
                }
                Some(ProcessState::Terminated(_)) => {
                    let _ = supervisor.take_events(session.process_id);
                    let _ = supervisor.forget(session.process_id);
                }
                None => {}
            }
        }
    }

    /// Terminate every terminal child and unpublish all sessions.
    ///
    /// The editor follows this with the supervisor's bounded global shutdown,
    /// which performs final TERM/KILL escalation for terminal and non-terminal
    /// processes alike.
    pub fn shutdown(&mut self, supervisor: &mut ProcessSupervisor) {
        let process_ids: Vec<ProcessId> = self.process_to_buffer.keys().copied().collect();
        for process_id in process_ids {
            if matches!(
                supervisor.state(process_id),
                Some(
                    ProcessState::Running { .. }
                        | ProcessState::Exiting { .. }
                        | ProcessState::Starting
                )
            ) {
                let _ = supervisor.terminate(process_id);
            }
            self.closing.insert(process_id);
        }
        self.sessions.clear();
        self.process_to_buffer.clear();
        self.views.clear();
        self.controllers.clear();
    }
}

fn unique_terminal_name(core: &EditorCore, base: &str) -> String {
    let registry = core.registry.borrow();
    if registry.find_by_name(base).is_none() {
        return base.to_owned();
    }
    for suffix in 2usize.. {
        let candidate = format!("{base}<{suffix}>");
        if registry.find_by_name(&candidate).is_none() {
            return candidate;
        }
    }
    unreachable!("unbounded terminal suffix search must find a free name")
}

fn finish_session(session: &mut TerminalSession, outcome: TerminalProcessState) {
    if session.annotated {
        session.process = outcome;
        return;
    }
    session.screen.finish_output();
    let annotation = match &outcome {
        TerminalProcessState::Running => return,
        TerminalProcessState::Exited(0) => {
            format!("Process {} exited normally with code 0", session.pid)
        }
        TerminalProcessState::Exited(code) => {
            format!("Process {} exited abnormally with code {code}", session.pid)
        }
        TerminalProcessState::Signaled(signal) => format!(
            "Process {} exited abnormally with signal {signal}",
            session.pid
        ),
        TerminalProcessState::Crashed(error) => {
            format!("Process {} crashed: {error}", session.pid)
        }
    };
    session.screen.append_process_annotation(&annotation);
    session.annotated = true;
    // Publish exit metadata only after final bytes and annotation are applied.
    session.process = outcome;
}

fn validate_size(rows: u16, cols: u16) -> Result<(), TerminalError> {
    let cells = usize::from(rows) * usize::from(cols);
    if rows == 0 || rows > MAX_TERMINAL_ROWS {
        return Err(TerminalError::InvalidSpec(format!(
            "rows must be in 1..={MAX_TERMINAL_ROWS}; got {rows}"
        )));
    }
    if cols == 0 || cols > MAX_TERMINAL_COLS {
        return Err(TerminalError::InvalidSpec(format!(
            "cols must be in 1..={MAX_TERMINAL_COLS}; got {cols}"
        )));
    }
    if cells > MAX_TERMINAL_VISIBLE_CELLS {
        return Err(TerminalError::InvalidSpec(format!(
            "visible cell count {cells} exceeds {MAX_TERMINAL_VISIBLE_CELLS}"
        )));
    }
    Ok(())
}

fn reject_nul(field: &str, bytes: &[u8]) -> Result<(), TerminalError> {
    if bytes.contains(&0) {
        Err(TerminalError::InvalidSpec(format!(
            "{field} must not contain NUL"
        )))
    } else {
        Ok(())
    }
}

fn sanitize_metadata(value: &str) -> String {
    let mut clean = String::with_capacity(value.len().min(MAX_TERMINAL_METADATA_BYTES));
    for ch in value.chars() {
        let ch = if ch == '\r' || ch == '\n' || ch.is_control() {
            ' '
        } else {
            ch
        };
        if clean.len() + ch.len_utf8() > MAX_TERMINAL_METADATA_BYTES {
            break;
        }
        clean.push(ch);
    }
    clean
}

/// The built-in terminal escape chord, and the fallback for an
/// unparseable `terminal.escape-key` (Q#TC4a).
pub(super) fn default_escape_chord() -> Chord {
    Chord::new(
        crossterm::event::KeyCode::Char('c'),
        crossterm::event::KeyModifiers::CONTROL,
    )
}
