//! `hjkl` — standalone vim-modal terminal editor.

// mimalloc as the global Rust allocator. Tree-sitter parsing dominates
// the hot path and is heavily allocation-bound (every subtree node is a
// short-lived `malloc`); mimalloc's segmented free-list outperforms
// glibc's ptmalloc on this exact workload. The TS C core is routed
// through mimalloc separately by `hjkl_bonsai::ensure_mimalloc_allocator()`.
#[global_allocator]
static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;

mod app;
mod completion;
mod embed;
mod headless;
mod host;
mod keymap_actions;
mod keymap_translate;
pub(crate) mod menu;
mod nvim_api;
mod picker;
mod picker_action;
mod picker_git;
mod picker_sources;
mod render;
mod save;
mod set_tokens;
mod start_screen;
mod syntax;
mod theme;

#[cfg(test)]
pub(crate) mod test_cwd;

use anyhow::{Context, Result};
use clap::Parser;
use crossterm::{
    event,
    event::{KeyCode, KeyEvent, KeyModifiers},
    execute, terminal,
};
use ratatui::{Terminal, backend::CrosstermBackend};
use std::fmt::Write as _;
use std::io::{self, stdout};
use std::path::PathBuf;
use tracing_subscriber::EnvFilter;

/// ASCII-art banner. Regenerate with:
///
/// ```sh
/// figlet -f "ANSI Regular" hjkl > apps/hjkl/src/art.txt
/// ```
const LONG_ABOUT: &str = concat!(
    "\n",
    include_str!("art.txt"),
    "\nvim-modal terminal editor · v",
    env!("CARGO_PKG_VERSION"),
);

/// Pre-flight CLI surface. clap handles `--help` / `--version` / `-R`
/// natively; vim-style `+N`, `+/pattern`, `+picker` are
/// pre-processed out of `argv` before clap sees it (see [`split_vim_tokens`]).
#[derive(Parser, Debug)]
#[command(
    name = "hjkl",
    version,
    about = "vim-modal terminal editor",
    long_about = LONG_ABOUT,
    after_help = "Vim-style tokens (interspersed with FILEs):\n  +N           jump to 1-based line N on open\n  +/PATTERN    search for PATTERN on open\n  +picker      open the file picker\n  +CMD         run any other text as an ex command (e.g. +vsp, +'vsp other.rs', +set\\ nomouse)",
)]
struct Cli {
    /// Open files read-only.
    #[arg(short = 'R', long)]
    readonly: bool,

    /// Override the user config path (default: $XDG_CONFIG_HOME/hjkl/config.toml).
    /// Bundled defaults are still applied; the file is layered on top.
    /// `-u <PATH>` is an nvim-compatible alias for this flag; `-u NONE` and
    /// `-u NORC` (case-sensitive, matching nvim) behave like `--clean`.
    #[arg(short = 'u', long, value_name = "PATH")]
    config: Option<PathBuf>,

    /// Start from the bundled defaults only: ignore the user config file at
    /// the default location (and any `--config` path), and do not persist
    /// runtime changes (e.g. dock resizes) back to disk. Mirrors
    /// `nvim --clean`.
    #[arg(long)]
    clean: bool,

    /// Run without a terminal: load FILEs, dispatch any +cmd / -c CMD ex
    /// commands in order, write back to disk if a command asks (e.g. +':wq'),
    /// then exit. No ratatui, no crossterm. Useful for scripted edits in CI.
    #[arg(long)]
    headless: bool,

    /// Run without a terminal: speak JSON-RPC 2.0 over stdin/stdout.
    /// Requests: one JSON object per line. Responses: one JSON object per line.
    /// See `docs/embed-rpc.md` for the method catalogue. Implies --headless;
    /// if both are passed it is a no-op. Note: +cmd / -c CMD flags are ignored
    /// in embed mode — commands come over RPC instead.
    #[arg(long)]
    embed: bool,

    /// Run without a terminal: speak msgpack-rpc over stdin/stdout using
    /// nvim-compatible method names. The wire protocol matches the neovim
    /// msgpack-rpc spec so existing nvim-rs clients can drive hjkl unchanged.
    /// See `docs/embed-rpc.md` ("nvim-api mode") for the method catalogue.
    /// Implies headless; +cmd / -c CMD are ignored.
    #[arg(long = "nvim-api")]
    nvim_api: bool,

    /// Ex command to run after loading FILEs (without leading ':'). Repeatable.
    /// All -c commands run first, then all +cmd tokens in argv order. Works
    /// in both TUI and headless mode (e.g. `hjkl -c 'vsp other.rs' main.rs`).
    #[arg(short = 'c', long = "command", value_name = "CMD", action = clap::ArgAction::Append)]
    commands: Vec<String>,

    /// Quickfix mode: read <ERRORFILE> (default: errors.err) through
    /// 'errorformat', populate the quickfix list, and jump to the first
    /// error. Mirrors `nvim -q`.
    #[arg(short = 'q', value_name = "ERRORFILE", num_args = 0..=1, default_missing_value = "errors.err")]
    quickfix: Option<String>,

    /// Open each file in a horizontal split (nvim `-o`).
    #[arg(short = 'o', conflicts_with_all = ["open_vsplit", "open_tabs"])]
    open_hsplit: bool,

    /// Open each file in a vertical split (nvim `-O`).
    #[arg(short = 'O', conflicts_with_all = ["open_hsplit", "open_tabs"])]
    open_vsplit: bool,

    /// Open each file in a tab page (nvim `-p`).
    #[arg(short = 'p', conflicts_with_all = ["open_hsplit", "open_vsplit"])]
    open_tabs: bool,

    /// No swap file: edits live only in memory for this session, with no
    /// crash-recovery file written or updated. Mirrors `vim -n` / `nvim -n`.
    #[arg(short = 'n')]
    no_swap: bool,

    /// Recovery mode. Bare `-r` lists swap files and exits; `-r <FILE>`
    /// opens <FILE> and recovers it from its swap (via the on-open
    /// recovery prompt). Mirrors `vim -r`.
    #[arg(short = 'r', value_name = "FILE", num_args = 0..=1, default_missing_value = "")]
    recover: Option<String>,

    /// Replay keystrokes from <SCRIPTIN> at startup, as if typed (Normal-mode
    /// keys, inserts, and `:` ex commands all run). Mirrors `vim -s`.
    #[arg(short = 's', value_name = "SCRIPTIN")]
    script_in: Option<PathBuf>,

    /// Allow shell-out commands (`:!cmd`, `:r !cmd`, `:[range]!cmd`, range
    /// filters) in `--embed` / `--nvim-api` / `--headless` modes. Those modes
    /// disable shell-out by default because the driving client may not be the
    /// local user. No effect in interactive TUI, where shell-out is always on.
    #[arg(long = "allow-shell")]
    allow_shell: bool,

    /// Files to open. First is the active buffer; the rest are loaded into
    /// additional slots in argv order. If empty, a fresh buffer is started.
    files: Vec<PathBuf>,
}

/// How CLI files beyond the first are laid out on startup.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub enum FileLayout {
    #[default]
    Buffers, // default: extra files become buffers (bufferline)
    HSplit, // -o: horizontal splits
    VSplit, // -O: vertical splits
    Tabs,   // -p: tab pages
}

/// Parsed arguments after pre-processing the `+N` / `+/pattern` tokens.
pub struct Args {
    pub files: Vec<PathBuf>,
    pub line: Option<usize>,
    pub pattern: Option<String>,
    pub readonly: bool,
    pub picker: bool,
    pub config: Option<PathBuf>,
    /// Ignore the user config file (default location and `--config`) and start
    /// from the bundled defaults, without persisting runtime changes to disk.
    /// Mirrors `nvim --clean`.
    pub clean: bool,
    /// Run without a terminal (no ratatui/crossterm). Phase 1 of issue #26.
    pub headless: bool,
    /// Run as JSON-RPC 2.0 server over stdin/stdout. Phase 2 of issue #26.
    /// Implies headless; +cmd / -c CMD are ignored in this mode.
    pub embed: bool,
    /// Run as msgpack-rpc server with nvim-compatible methods. Phase 3 of issue #26.
    /// Implies headless; +cmd / -c CMD are ignored in this mode.
    pub nvim_api: bool,
    /// Ex commands to dispatch in headless mode. `-c` commands precede `+cmd`
    /// tokens; argv interleaving within each group is preserved.
    pub commands: Vec<String>,
    /// `-q [ERRORFILE]`: read `ERRORFILE` (default `errors.err`) through
    /// `&errorformat`, populate the quickfix list, and jump to the first
    /// error at startup. Mirrors `nvim -q`. `None` when the flag is absent.
    pub quickfix: Option<String>,
    /// `-o` / `-O` / `-p`: how files beyond the first are laid out on
    /// startup. Mirrors nvim's layout flags (bare form only — no `-o2`
    /// window-count variant).
    pub file_layout: FileLayout,
    /// `-n`: disable swap-file writes for the session (memory only).
    /// Mirrors `vim -n` / `nvim -n`.
    pub no_swap: bool,
    /// `-r [FILE]`: `None` when absent. `Some("")` (bare `-r`) means "list
    /// swap files and exit" — handled entirely in `main()` before any
    /// `App` is built. `Some(path)` (non-empty) means "recover `path`";
    /// `parse_argv` folds `path` into `files` so it becomes the file that
    /// gets opened, and the existing on-open recovery prompt
    /// (`App::check_recovery_on_open`) does the rest. Mirrors `vim -r`.
    pub recover: Option<String>,
    /// `-s <SCRIPTIN>`: replay the file's characters as startup keystrokes
    /// (Normal-mode keys, inserts, and `:` ex commands), then continue
    /// interactively. Mirrors `vim -s` / `nvim -s`. TUI mode only; runs
    /// after `-c`/`+cmd` and before raw mode is entered.
    pub script_in: Option<PathBuf>,
    /// `--allow-shell`: permit shell-out in `--embed`/`--nvim-api`/`--headless`
    /// modes (off by default in those modes; always on in the TUI).
    pub allow_shell: bool,
}

/// `true` when `p` is the literal `-` (vim/nvim convention: read the buffer
/// from stdin instead of opening a file). `split_vim_tokens` never treats a
/// bare `-` specially, so it survives into `args.files` as a normal
/// positional `PathBuf("-")` — this just tests for that sentinel.
fn is_stdin_arg(p: &std::path::Path) -> bool {
    p.as_os_str() == "-"
}

/// Decode ONE character from a `-s <scriptin>` file into the crossterm
/// [`KeyEvent`] a live terminal would have produced for that keystroke.
/// Mirrors `vim -s` / `nvim -s`: the scriptin file is a raw byte stream of
/// what the user would have typed, not a structured script format.
///
/// - `\x1b` (Esc) → `KeyCode::Esc`
/// - `\r` / `\n` → `KeyCode::Enter`
/// - `\t` → `KeyCode::Tab`
/// - `\x08` (BS) / `\x7f` (DEL) → `KeyCode::Backspace`
/// - other C0 control chars `\x01..=\x1a` → `Ctrl-<letter>` (e.g. `\x17` is
///   Ctrl-w, matching the ASCII convention `ctrl+letter == letter - 0x60`)
/// - everything else → the plain `KeyCode::Char`, no modifiers
///
/// KNOWN LIMITATION: this decodes one **character** at a time, not one
/// terminal **key event**. A real terminal coalesces multi-byte escape
/// sequences (arrow keys as `ESC [ A`, function keys, etc.) into a single
/// composite `KeyEvent`; fed through this decoder byte-by-byte they instead
/// become a lone `Esc` followed by unrelated printable-char keys, which does
/// NOT reproduce the original keystroke. Scriptin files built from plain
/// typing — motions, inserts, `:` ex commands — essentially never contain
/// these sequences, so this is a non-issue in practice.
pub(crate) fn scriptin_char_to_key(c: char) -> KeyEvent {
    match c {
        '\x1b' => KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
        '\r' | '\n' => KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
        '\t' => KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE),
        '\x08' | '\x7f' => KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE),
        // Other C0 control chars: \x01 = Ctrl-a … \x1a = Ctrl-z. The arms
        // above already intercept \x08 (BS), \x09 (Tab), \x0d (CR) before
        // this range is reached.
        c2 @ '\x01'..='\x1a' => {
            let letter = (b'a' + (c2 as u8 - 1)) as char;
            KeyEvent::new(KeyCode::Char(letter), KeyModifiers::CONTROL)
        }
        c2 => KeyEvent::new(KeyCode::Char(c2), KeyModifiers::NONE),
    }
}

/// Split raw `argv` into (tokens-clap-handles, vim-style-`+`-prefixed-tokens).
/// Preserves the binary name in the clap stream so clap's prog detection
/// stays correct.
///
/// Conventions:
/// - The argv\[0\] (binary name) is never treated as a vim token.
/// - A bare `+` (length 1) is *not* a vim token — it falls through to clap
///   as a positional (treated as a literal filename `+`).
/// - `--` ends vim-token processing: everything after stays in the clap
///   stream verbatim, matching POSIX end-of-options semantics. This lets
///   `hjkl -- +42` open a file literally named `+42`.
fn split_vim_tokens(raw: Vec<String>) -> (Vec<String>, Vec<String>) {
    let mut clap_args: Vec<String> = Vec::with_capacity(raw.len());
    let mut vim_tokens: Vec<String> = Vec::new();
    let mut after_dashdash = false;
    for (i, arg) in raw.into_iter().enumerate() {
        if !after_dashdash && i > 0 && arg == "--" {
            after_dashdash = true;
            clap_args.push(arg);
            continue;
        }
        if !after_dashdash && i > 0 && arg.starts_with('+') && arg.len() > 1 {
            vim_tokens.push(arg);
        } else {
            clap_args.push(arg);
        }
    }
    (clap_args, vim_tokens)
}

/// Apply parsed `+`-prefixed tokens onto an `Args` builder. Returns a list
/// of warnings (reserved — currently always empty). The caller decides how
/// to surface them; `main` prints to stderr.
///
/// Last-write-wins: repeated `+N` overwrites `args.line`; repeated `+/PAT`
/// overwrites `args.pattern`.
///
/// Unrecognised `+<text>` tokens (and `+:<text>` with a leading colon) are
/// treated as ex commands and pushed onto `args.commands` regardless of
/// `args.headless`. TUI dispatch runs them after files are loaded; headless
/// dispatch runs them in the existing scripted-mode path. Errors surface at
/// the ex layer (unknown commands, bad ranges) rather than here, matching
/// vim's `+cmd` behaviour. The leading `:` is stripped so `+vsp` and `+:vsp`
/// work identically.
fn apply_vim_tokens(args: &mut Args, vim_tokens: &[String]) -> Vec<String> {
    let warnings: Vec<String> = Vec::new();
    for tok in vim_tokens {
        let rest = &tok[1..];
        if let Some(pat) = rest.strip_prefix('/') {
            args.pattern = Some(pat.to_string());
        } else if let Ok(n) = rest.parse::<usize>() {
            args.line = Some(n);
        } else if rest == "picker" {
            args.picker = true;
        } else {
            // Strip the optional leading colon so `+:vsp` and `+vsp` both work.
            let cmd = rest.strip_prefix(':').unwrap_or(rest).to_string();
            args.commands.push(cmd);
        }
    }
    warnings
}

/// Parse a raw `argv` (including `argv[0]` binary name) into `(args, warnings)`.
/// Pure function — no env, no stderr — so tests can drive every branch.
///
/// Ordering of ex commands in headless mode: all `-c` flags (from clap) come
/// first, then all `+cmd` tokens in the order they appear in argv. This
/// simplifies the implementation at the cost of strict argv interleaving;
/// document this in your scripts if ordering matters.
fn parse_argv(raw: Vec<String>) -> Result<(Args, Vec<String>)> {
    let (clap_argv, vim_tokens) = split_vim_tokens(raw);
    let cli = Cli::parse_from(clap_argv);
    // Seed headless flag + -c commands before apply_vim_tokens so that the
    // `args.headless` check inside it can route unknown +cmd tokens correctly.
    let mut args = Args {
        files: cli.files,
        line: None,
        pattern: None,
        readonly: cli.readonly,
        picker: false,
        config: cli.config,
        clean: cli.clean,
        headless: cli.headless || cli.embed || cli.nvim_api,
        embed: cli.embed,
        nvim_api: cli.nvim_api,
        // -c commands come first; +cmd tokens are appended by apply_vim_tokens.
        commands: cli.commands,
        quickfix: cli.quickfix,
        // clap's `conflicts_with_all` on the three flags guarantees at most
        // one of these bools is set.
        file_layout: if cli.open_tabs {
            FileLayout::Tabs
        } else if cli.open_vsplit {
            FileLayout::VSplit
        } else if cli.open_hsplit {
            FileLayout::HSplit
        } else {
            FileLayout::Buffers
        },
        no_swap: cli.no_swap,
        recover: cli.recover,
        script_in: cli.script_in,
        allow_shell: cli.allow_shell,
    };
    // `-r <FILE>` (non-empty value only — bare `-r` is list mode, handled in
    // `main()` before this function's caller ever gets here): hjkl already
    // shows a crash-recovery prompt on open whenever a newer swap exists
    // (`App::check_recovery_on_open`, run from `App::new`), so `-r <FILE>`
    // needs nothing new for the recovery itself — it just has to make
    // `FILE` the file that gets opened. Prepend it so it lands in slot 0
    // (the active buffer) even if the user also passed other FILEs.
    if let Some(path) = args.recover.as_deref()
        && !path.is_empty()
    {
        args.files.insert(0, PathBuf::from(path));
    }
    // nvim compatibility: `-u NONE` disables plugins + config, `-u NORC` skips
    // just the init file. hjkl has no plugin system and a single TOML config,
    // so both sentinels collapse onto the same thing: bundled defaults only,
    // no user file, no write-back — i.e. `--clean`. Case-sensitive, matching
    // nvim's own sentinel handling.
    if let Some(path) = args.config.as_deref().and_then(|p| p.to_str())
        && (path == "NONE" || path == "NORC")
    {
        args.clean = true;
        args.config = None;
    }
    let warnings = apply_vim_tokens(&mut args, &vim_tokens);
    Ok((args, warnings))
}

fn parse_args() -> Result<Args> {
    let raw: Vec<String> = std::env::args().collect();
    let (args, warnings) = parse_argv(raw)?;
    for w in warnings {
        eprintln!("{w}");
    }
    Ok(args)
}

/// `-r` (bare): enumerate `hjkl_app::swap::swap_dir()` for `*.swp` files,
/// read each header, and print a concise listing to stdout. Mirrors
/// `vim -r` (bare form, which lists swap files instead of opening one).
/// Unreadable / non-swap entries in the directory are silently skipped, same
/// tolerance `scan_orphan_scratch_swaps_in` applies elsewhere.
fn list_swap_files() -> io::Result<()> {
    let dir = hjkl_app::swap::swap_dir()?;
    let mut entries: Vec<(PathBuf, hjkl_app::swap::SwapHeader, bool)> = Vec::new();
    if let Ok(rd) = std::fs::read_dir(&dir) {
        for entry in rd.flatten() {
            let path = entry.path();
            if path.extension().and_then(|e| e.to_str()) != Some("swp") {
                continue;
            }
            if let Ok((header, _body)) = hjkl_app::swap::read_swap(&path) {
                let alive = hjkl_app::swap::pid_is_alive(header.writer_pid);
                entries.push((path, header, alive));
            }
        }
    }
    print!("{}", format_swap_listing(&entries));
    Ok(())
}

/// Pure formatter for [`list_swap_files`]'s `-r` (bare) listing — factored
/// out so it's unit-testable without touching real XDG directories. `alive`
/// is whether `header.writer_pid` is still a live process (crash vs. an
/// active concurrent session).
fn format_swap_listing(entries: &[(PathBuf, hjkl_app::swap::SwapHeader, bool)]) -> String {
    if entries.is_empty() {
        return "No swap files found\n".to_string();
    }
    // Stable, greppable order regardless of readdir's OS-dependent ordering.
    let mut sorted: Vec<&(PathBuf, hjkl_app::swap::SwapHeader, bool)> = entries.iter().collect();
    sorted.sort_by(|a, b| a.0.cmp(&b.0));

    let mut out = String::new();
    out.push_str("Swap files found:\n");
    for (swap_path, header, alive) in sorted {
        let file = if header.canonical_path.is_empty() {
            "[No Name]"
        } else {
            header.canonical_path.as_str()
        };
        let status = if *alive {
            "still running"
        } else {
            "process gone"
        };
        let _ = writeln!(out, "  {}", swap_path.display());
        let _ = writeln!(out, "    file:  {file}");
        let _ = writeln!(out, "    pid:   {} ({status})", header.writer_pid);
        let _ = writeln!(
            out,
            "    saved: {} ms since epoch",
            header.write_time_unix_ms
        );
    }
    out
}

/// Prepend the anvil `bin/` directory to `$PATH` so any tool installed via
/// `:Anvil install <name>` is visible to LSP spawners and shell invocations.
///
/// # Safety
///
/// `set_var` is `unsafe` in Rust 2024 because it is not thread-safe. This
/// function is called at the very top of `main`, strictly before any threads
/// are spawned (tracing subscriber, LSP manager, etc.), so the invariant is
/// upheld here.
fn prepend_anvil_path() {
    let Ok(bin_dir) = hjkl_anvil::store::bin_dir() else {
        return; // no $HOME — give up silently
    };
    if !bin_dir.exists() {
        let _ = std::fs::create_dir_all(&bin_dir);
    }
    let existing = std::env::var_os("PATH").unwrap_or_default();
    let mut entries = std::env::split_paths(&existing).collect::<Vec<_>>();
    // Deduplicate: remove any stale prepend from a previous hjkl session.
    entries.retain(|p| p != &bin_dir);
    entries.insert(0, bin_dir);
    if let Ok(joined) = std::env::join_paths(&entries) {
        // SAFETY: single-threaded at this call site — no threads have been
        // spawned yet. The OS process environment is mutable without risk.
        unsafe {
            std::env::set_var("PATH", joined);
        }
    }
}

fn main() -> Result<()> {
    // Prepend the anvil bin dir to PATH before any threads (tracing, LSP) start.
    prepend_anvil_path();

    init_tracing();

    let args = parse_args()?;

    // `-r` (bare, no value): vim `-r` — list swap files and exit. A terminal
    // action like `--version`/`--help`, not an editing mode, so it runs
    // before any App/TUI/headless setup. `-r <FILE>` (non-empty) is NOT
    // handled here — `parse_argv` already folded it into `args.files`, so it
    // falls through to the normal TUI open below.
    if args.recover.as_deref() == Some("") {
        list_swap_files()?;
        std::process::exit(0);
    }

    if args.nvim_api || args.embed || args.headless {
        if args.clean || args.config.is_some() {
            anyhow::bail!("--config and --clean are unsupported outside TUI mode");
        }
        let (cfg, _) = hjkl_app::config::load().context("config error")?;
        use hjkl_config::Validate;
        cfg.validate().context("config validation")?;
        // Non-TUI modes may be driven by a remote/automated client, not the
        // local user. Disable shell-out unless explicitly opted in.
        if !args.allow_shell {
            hjkl_engine::policy::disable_shell();
        }
        // The RPC server modes take file paths from that same untrusted caller,
        // so confine `:w`/`:e`/`:r` to the working-directory subtree. Headless
        // (`-c` scripts) is local/user-driven, so it keeps full filesystem
        // access.
        if args.embed || args.nvim_api {
            hjkl_engine::policy::restrict_fs();
        }
    }

    // nvim-api mode (msgpack-rpc server, nvim-compatible) — check FIRST since
    // it is the most specific. +cmd / -c CMD are silently ignored.
    if args.nvim_api {
        let code = nvim_api::run(args.files)?;
        std::process::exit(code);
    }

    // Embed mode (JSON-RPC 2.0 server) — check BEFORE headless since it is
    // more specific. +cmd / -c CMD are silently ignored in this mode.
    if args.embed {
        let code = embed::run(args.files)?;
        std::process::exit(code);
    }

    // Headless script mode — no TUI, no crossterm, no ratatui.
    if args.headless {
        let code = headless::run(args.files, args.commands)?;
        std::process::exit(code);
    }

    // `--clean` and `--config` are mutually exclusive intents: clean starts
    // from the bundled defaults only, so an explicit `--config` path would be
    // silently ignored. Warn rather than fail so `--clean` stays a drop-in.
    if args.clean && args.config.is_some() {
        eprintln!("hjkl: warning: --clean ignores --config");
    }

    // Load user config. `--clean` uses the bundled defaults and reads NO file.
    // Otherwise `--config <PATH>` reads an explicit file, and the bare form
    // uses the XDG path — in both of those the bundled `src/config.toml`
    // defaults are applied first and the user file is deep-merged on top.
    let cfg = if args.clean {
        Ok((
            hjkl_app::config::Config::default(),
            hjkl_config::ConfigSource::Defaults,
        ))
    } else {
        match args.config.as_deref() {
            Some(path) => hjkl_app::config::load_from(path)
                .map(|c| (c, hjkl_config::ConfigSource::File(path.to_path_buf()))),
            None => hjkl_app::config::load(),
        }
    };
    let (cfg, cfg_source) = match cfg {
        Ok(pair) => pair,
        Err(e) => {
            eprintln!("hjkl: config error: {e}");
            std::process::exit(2);
        }
    };
    // Resolved config file path, for dock-resize write-back
    // (`App::persist_dock_width`). `ConfigSource::Defaults` means no file
    // exists yet at the default XDG path — resolve that path anyway (rather
    // than leaving `config_path` unset) so the FIRST interactive resize can
    // still create a minimal file there, matching `write_key_at`'s
    // create-if-missing behavior.
    //
    // Under `--clean` we deliberately leave the path UNSET: a clean session
    // must never touch the user's config on disk, so runtime changes (dock
    // resizes, `:set` write-back) stay in-memory only.
    let cfg_path = if args.clean {
        None
    } else {
        match cfg_source {
            hjkl_config::ConfigSource::File(p) => Some(p),
            hjkl_config::ConfigSource::Defaults => {
                hjkl_config::config_path::<hjkl_app::config::Config>().ok()
            }
        }
    };
    // Bounds-check the parsed config (tab_width range, etc.). Schema-level
    // validation already ran during parse; this catches semantic values that
    // parsed cleanly but would break the editor.
    {
        use hjkl_config::Validate;
        if let Err(e) = cfg.validate() {
            eprintln!("hjkl: config validation: {e}");
            std::process::exit(2);
        }
    }
    // `hjkl -` (vim/nvim `-`): read the buffer from stdin instead of opening
    // a file. Only recognised here in the TUI path — `--headless -` falls
    // through to headless's own file-loading, which treats `-` as a literal
    // filename (out of scope for this feature).
    let read_stdin = args.files.first().is_some_and(|p| is_stdin_arg(p));

    // Build app state (may read file from disk) before entering alternate screen
    // so we can print errors to the normal terminal if the file is unreadable.
    // When reading stdin, pass no initial file — `-` is not a real path, and
    // the unnamed scratch buffer App::new builds is exactly what the stdin
    // content gets loaded into below.
    let initial_file = if read_stdin {
        None
    } else {
        args.files.first().cloned()
    };
    let base_app = app::App::new(initial_file, args.readonly, args.line, args.pattern)?
        .with_config(cfg.clone());
    let base_app = match cfg_path {
        Some(p) => base_app.with_config_path(p),
        None => base_app,
    };

    let mut app = if cfg.lsp.enabled {
        let mgr = hjkl_lsp::LspManager::spawn(cfg.lsp.clone());
        base_app.with_lsp(mgr)
    } else {
        base_app
    };
    // Apply the configured colorscheme (#303). `App::new` seeds the dark theme;
    // resolve the config name to a bundled scheme (`tokyonight`/`dark`/`light`)
    // and install its UI chrome + syntax. Unknown non-empty names warn and keep
    // the dark fallback rather than aborting, so a typo still opens the editor.
    if !app.apply_named_theme(&cfg.theme.name) {
        eprintln!(
            "hjkl: warning: theme.name = {:?} is not a bundled colorscheme; falling back to \"dark\"",
            cfg.theme.name
        );
    }
    // `-n`: disable swap files for the rest of the session. Must run before
    // stdin loading and the extra-files loop below so nothing opened this
    // session ever arms or writes a swap. This also removes the swap `new()`
    // already armed on slot 0 (a fresh session always arms it before this
    // flag can take effect).
    if args.no_swap {
        app.disable_swapfiles();
    }
    // `hjkl -`: read all of stdin into the unnamed active buffer built above.
    // Safe to consume fd 0 here even though we're about to go interactive —
    // crossterm's Unix event source reads keystrokes from `/dev/tty`, not
    // fd 0, so draining stdin never steals input the TUI needs. When stdin
    // is a real TTY (no pipe) this blocks until EOF (Ctrl-D), same as vim.
    // The 256 MiB cap (security fix H3 — `hjkl - < /dev/zero` must not allocate
    // until OOM) now comes from the `hjkl-fs` seam. Same limit, cleaner failure:
    // exceeding it is a `FileTooLarge` error instead of a silent truncation that
    // could land mid-UTF-8-character and surface as `InvalidData`.
    if read_stdin {
        match hjkl_fs::read_to_string_capped_from(
            std::io::stdin().lock(),
            hjkl_fs::read::caps::STDIN,
        ) {
            Ok(stdin_text) => app.load_stdin_buffer(&stdin_text),
            Err(e) => eprintln!("hjkl: reading stdin: {e}"),
        }
    }
    // Load any additional files (argv order beyond the first). By default
    // they become extra buffers (bufferline); `-o`/`-O`/`-p` instead lay
    // each one out in its own horizontal split / vertical split / tab page,
    // mirroring nvim's layout flags. Errors are printed to stderr but do not
    // abort — the editor opens with whatever could be loaded. This only
    // affects the TUI path — headless/embed/nvim-api already returned above.
    let extra_files: Vec<PathBuf> = args.files.into_iter().skip(1).collect();
    match args.file_layout {
        FileLayout::Buffers => {
            for path in extra_files {
                if let Err(e) = app.open_extra(path) {
                    eprintln!("hjkl: {e}");
                }
            }
        }
        FileLayout::HSplit => {
            for path in &extra_files {
                app.dispatch_ex(&format!("split {}", path.display()));
            }
            if !extra_files.is_empty() {
                // Return focus to the first file's window (id 0, allocated
                // by `App::new` before any splits ran), matching nvim's
                // "first file ends up top-left" behavior.
                app.switch_focus(0);
            }
        }
        FileLayout::VSplit => {
            for path in &extra_files {
                app.dispatch_ex(&format!("vsplit {}", path.display()));
            }
            if !extra_files.is_empty() {
                app.switch_focus(0);
            }
        }
        FileLayout::Tabs => {
            for path in &extra_files {
                app.dispatch_ex(&format!("tabnew {}", path.display()));
            }
            if !extra_files.is_empty() {
                // Return focus to the first tab (index 0), matching nvim's
                // "first file ends up in the first tab" behavior.
                app.switch_tab(0);
            }
        }
    }
    if args.picker {
        app.open_picker();
    }
    // Reopen the left explorer dock if the user asked for it to start open
    // (#63 Phase C — `explorer.open` in config, written only by
    // `:set explorer.open=…`). Runs after `with_config`/`with_config_path` (needs
    // the loaded config) and after the CLI files are open (so the dock's
    // initial reveal-cursor lands on the file the user is actually editing,
    // same as an interactive `<leader>e` would). The bottom quickfix/
    // location-list dock is never restored — see
    // `dock::restore_dock_state_from_config`'s doc comment for why.
    app.restore_dock_state_from_config();
    // Recover any orphan scratch swaps from a previous crashed session.
    // This runs after all CLI files are open so recovered unnamed buffers
    // come last in the slot list; it does NOT run inside App::new so that
    // tests and headless/embed modes are free of real-XDG scanning.
    app.recover_orphan_scratch_buffers();
    // `-q [ERRORFILE]` (nvim "quickfix mode"): read the errorfile through
    // `&errorformat`, populate the quickfix list, and jump to the first
    // error. Runs BEFORE `-c`/`+cmd` — nvim populates the quickfix list
    // during startup, then runs `-c`/`+cmd` afterward, so a `-c cnext` or
    // similar can act on the list `-q` just built. `:cfile` already
    // implements exactly this read-parse-populate-jump sequence (see
    // `App::qf_run_from_file`), so `-q` just dispatches it as an ex command.
    if let Some(errfile) = &args.quickfix {
        app.dispatch_ex(&format!("cfile {errfile}"));
    }
    // Run any +cmd / -c CMD tokens before entering raw mode. Errors surface
    // as toasts on the notification bus and become visible on the first frame.
    // Matches vim/nvim: `nvim +vsp file.txt` opens the file then runs `:vsp`.
    for cmd in &args.commands {
        app.dispatch_ex(cmd);
        if app.exit_requested {
            // A `+wq` / `+q!` style command requested exit before the loop
            // even runs — honour it without entering the alternate screen.
            // `with_lsp` may already have attached a manager above, so it
            // still needs a clean shutdown here.
            app.shutdown();
            return Ok(());
        }
    }

    // `-s <SCRIPTIN>`: replay the file's characters as startup keystrokes,
    // as if the user had typed them interactively. Runs AFTER `-c`/`+cmd`
    // (so those set up state first — e.g. `-c 'set ...'` before a script
    // that depends on it) and BEFORE raw mode, so a script ending in `:wq`
    // can exit cleanly without ever opening the alternate screen. Threaded
    // through `App::handle_keypress` — the SAME app-level input path
    // `App::run`'s event loop below drives every interactive keystroke
    // through — rather than the lower-level `hjkl_engine`/`hjkl_vim`
    // dispatch, because that engine-level path does not execute `:` ex
    // commands, and scriptin files routinely end in one (`:wq`, `:x`, …).
    // A missing/unreadable scriptin file is a warning, not a fatal error —
    // matches vim's tolerant behavior for a bad `-s` path.
    if let Some(path) = &args.script_in {
        match std::fs::read_to_string(path) {
            Ok(script) => {
                for c in script.chars() {
                    let key = scriptin_char_to_key(c);
                    // Mirror `App::run`'s primary key-read arm: `handle_keypress`
                    // consumes overlay/prefix/`:`-command keys inline, but
                    // Insert-mode text and Normal/Visual-mode motions return
                    // `FallThrough` and must be driven through
                    // `dispatch_fallthrough_key` the same way `run()` does —
                    // otherwise scripted inserts would never actually reach
                    // the buffer.
                    match app.handle_keypress(key) {
                        app::event_loop::KeyOutcome::FallThrough => {
                            app.dispatch_fallthrough_key(key);
                        }
                        app::event_loop::KeyOutcome::Continue
                        | app::event_loop::KeyOutcome::Break => {}
                    }
                    if app.exit_requested {
                        // A scripted `:wq` / `:q!` requested exit — honour it
                        // before entering the alternate screen, same as the
                        // `-c`/`+cmd` loop above.
                        app.shutdown();
                        return Ok(());
                    }
                }
            }
            Err(e) => {
                eprintln!("hjkl: -s {}: {e}", path.display());
            }
        }
    }

    terminal::enable_raw_mode()?;
    execute!(
        stdout(),
        terminal::EnterAlternateScreen,
        event::EnableFocusChange,
        // Bracketed paste: the terminal wraps pasted text in ESC[200~ … ESC[201~
        // so it arrives as one atomic `Event::Paste(text)` with real newlines.
        // Without this, in raw mode crossterm maps a pasted `\n` (0x0A) to Ctrl+J
        // — which the insert dispatcher drops — so pasted lines bunch together.
        event::EnableBracketedPaste
    )?;
    if app.mouse_enabled {
        execute!(stdout(), event::EnableMouseCapture)?;
    }
    // Push kitty keyboard enhancement (DISAMBIGUATE_ESCAPE_CODES) unconditionally.
    // Non-supporting terminals silently ignore this escape; no blocking query needed.
    let _ = hjkl_kitty::enable(&mut stdout());
    let backend = CrosstermBackend::new(stdout());
    let mut terminal = Terminal::new(backend)?;

    // Start event-driven autoreload (#242). Best-effort: on failure the
    // poll-based `:checktime` / focus-regain path still autoreloads.
    app.enable_fs_watch();

    let result = app.run(&mut terminal);

    // Restore terminal regardless of outcome. The capture command
    // sequence is idempotent on most terminals; emit unconditionally
    // to recover from a runtime `:set mouse` that may have toggled
    // state since startup.
    let _ = hjkl_kitty::disable(&mut io::stdout());
    let _ = terminal::disable_raw_mode();
    let _ = execute!(
        io::stdout(),
        event::DisableMouseCapture,
        event::DisableFocusChange,
        event::DisableBracketedPaste,
        terminal::LeaveAlternateScreen
    );

    // Shut the LSP subsystem down on every normal exit path — including the
    // error path — so a spawned language server (rust-analyzer, gopls,
    // tsserver, …) is never orphaned. `App` has no `Drop` impl; without this
    // call the process would exit while `Drop for LspManager` only
    // fire-and-forgets a shutdown notification on a background thread that
    // the process exit races (audit finding B1).
    app.shutdown();

    result
}

fn init_tracing() {
    let data_dir = match hjkl_config::data_dir("hjkl") {
        Ok(dir) => dir,
        Err(e) => {
            eprintln!("hjkl: tracing disabled (data_dir): {e}");
            return;
        }
    };

    let log_dir = data_dir.join("logs");
    if let Err(e) = std::fs::create_dir_all(&log_dir) {
        eprintln!(
            "hjkl: tracing disabled (create log dir {}): {e}",
            log_dir.display()
        );
        return;
    }

    let log_path = log_dir.join("hjkl.log");
    let file = match std::fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(&log_path)
    {
        Ok(f) => f,
        Err(e) => {
            eprintln!(
                "hjkl: tracing disabled (open log file {}): {e}",
                log_path.display()
            );
            return;
        }
    };

    let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));

    let subscriber = tracing_subscriber::fmt()
        .with_env_filter(env_filter)
        .with_ansi(false)
        .with_writer(move || file.try_clone().expect("clone hjkl.log file handle"))
        .finish();

    if let Err(e) = tracing::subscriber::set_global_default(subscriber) {
        eprintln!("hjkl: tracing disabled (set global subscriber): {e}");
    }
}

#[cfg(test)]
mod cli_tests {
    use super::*;
    use clap::CommandFactory;

    #[test]
    fn version_flag_returns_pkg_version() {
        let cmd = Cli::command();
        let version = cmd.render_version();
        assert!(
            version.contains(env!("CARGO_PKG_VERSION")),
            "render_version output {version:?} missing CARGO_PKG_VERSION"
        );
    }

    #[test]
    fn long_help_contains_ascii_art() {
        let mut cmd = Cli::command();
        let help = cmd.render_long_help().to_string();
        assert!(
            help.contains(include_str!("art.txt")),
            "long_help missing embedded art.txt block; got:\n{help}"
        );
    }

    #[test]
    fn long_help_contains_pkg_version() {
        let mut cmd = Cli::command();
        let help = cmd.render_long_help().to_string();
        assert!(
            help.contains(env!("CARGO_PKG_VERSION")),
            "long_help missing CARGO_PKG_VERSION; got:\n{help}"
        );
    }

    #[test]
    fn long_help_advertises_config_flag() {
        let mut cmd = Cli::command();
        let help = cmd.render_long_help().to_string();
        assert!(
            help.contains("--config"),
            "long_help should advertise --config; got:\n{help}"
        );
    }

    #[test]
    fn split_vim_tokens_separates_plus_args() {
        let raw: Vec<String> = ["hjkl", "src/main.rs", "+42", "+/foo", "+picker", "-R"]
            .iter()
            .map(|s| s.to_string())
            .collect();
        let (clap_argv, vim) = split_vim_tokens(raw);
        assert_eq!(clap_argv, vec!["hjkl", "src/main.rs", "-R"]);
        assert_eq!(vim, vec!["+42", "+/foo", "+picker"]);
    }

    /// `+debug` is an unrecognised `+cmd` token → queued as the `:debug` ex
    /// command, run at startup (enables debug mode).
    #[test]
    fn apply_vim_tokens_plus_debug_queues_ex_command() {
        let mut args = blank_args();
        let warnings = apply_vim_tokens(&mut args, &["+debug".into()]);
        assert_eq!(args.commands, vec!["debug".to_string()]);
        assert!(warnings.is_empty());
    }

    #[test]
    fn apply_vim_tokens_sets_line_pattern_picker() {
        let mut args = blank_args();
        let warnings = apply_vim_tokens(
            &mut args,
            &["+42".into(), "+/needle".into(), "+picker".into()],
        );
        assert_eq!(args.line, Some(42));
        assert_eq!(args.pattern.as_deref(), Some("needle"));
        assert!(args.picker);
        assert!(warnings.is_empty());
    }

    /// A bare `+` (length 1) is not a vim token — it survives into the
    /// clap stream as a positional, equivalent to opening a file literally
    /// named `+`. Vim has the same behavior.
    #[test]
    fn split_vim_tokens_passes_bare_plus_to_clap() {
        let raw: Vec<String> = ["hjkl", "+", "file.txt"]
            .iter()
            .map(|s| s.to_string())
            .collect();
        let (clap_argv, vim) = split_vim_tokens(raw);
        assert_eq!(clap_argv, vec!["hjkl", "+", "file.txt"]);
        assert!(vim.is_empty());
    }

    /// `--` ends vim-token processing. Anything after — including
    /// `+`-prefixed tokens — is passed to clap verbatim. This lets users
    /// open a file literally named `+42` via `hjkl -- +42`.
    #[test]
    fn split_vim_tokens_honors_dashdash_separator() {
        let raw: Vec<String> = ["hjkl", "+10", "--", "+42", "+/notapattern"]
            .iter()
            .map(|s| s.to_string())
            .collect();
        let (clap_argv, vim) = split_vim_tokens(raw);
        assert_eq!(clap_argv, vec!["hjkl", "--", "+42", "+/notapattern"]);
        assert_eq!(vim, vec!["+10"]);
    }

    /// Repeated `+N` / `+/PAT` overwrite — last write wins. Matches vim's
    /// behavior when both `+10` and `+20` are given.
    #[test]
    fn apply_vim_tokens_last_write_wins() {
        let mut args = blank_args();
        let _ = apply_vim_tokens(
            &mut args,
            &[
                "+10".into(),
                "+20".into(),
                "+/first".into(),
                "+/second".into(),
            ],
        );
        assert_eq!(args.line, Some(20));
        assert_eq!(args.pattern.as_deref(), Some("second"));
    }

    /// Unknown `+cmd` tokens are pushed onto `args.commands` for the TUI /
    /// headless dispatcher to run as ex commands (vim/nvim parity). The
    /// leading `:` is stripped, so `+:vsp` and `+vsp` produce the same
    /// command. Errors surface at the ex layer rather than as warnings.
    #[test]
    fn apply_vim_tokens_unknown_token_pushes_ex_command() {
        let mut args = blank_args();
        args.line = Some(7); // pre-existing state must survive
        let warnings = apply_vim_tokens(
            &mut args,
            &["+vsp".into(), "+:wq".into(), "+set nomouse".into()],
        );
        assert!(
            warnings.is_empty(),
            "no warnings expected, got: {warnings:?}"
        );
        assert_eq!(
            args.commands,
            vec![
                "vsp".to_string(),
                "wq".to_string(),
                "set nomouse".to_string()
            ],
            "unknown +cmd tokens should land on args.commands with leading `:` stripped"
        );
        // Pre-existing state untouched.
        assert_eq!(args.line, Some(7));
        assert_eq!(args.pattern, None);
        assert!(!args.picker);
    }

    /// `+/` with empty pattern currently sets `pattern = Some("")`. This
    /// is a documented quirk of the current implementation — a downstream
    /// search-engine layer will treat the empty pattern as a no-op. If the
    /// behavior changes, this test pins the new contract.
    #[test]
    fn apply_vim_tokens_empty_pattern_is_some_empty_string() {
        let mut args = blank_args();
        let _ = apply_vim_tokens(&mut args, &["+/".into()]);
        assert_eq!(args.pattern.as_deref(), Some(""));
    }

    /// End-to-end `parse_argv`: clap-handled flags + vim tokens work
    /// together; unknown +cmd tokens land on args.commands for the TUI /
    /// headless dispatcher.
    #[test]
    fn parse_argv_round_trip_mixed_args() {
        let raw: Vec<String> = ["hjkl", "-R", "+42", "src/main.rs", "+/foo", "+vsp"]
            .iter()
            .map(|s| s.to_string())
            .collect();
        let (args, warnings) = parse_argv(raw).expect("parse_argv");
        assert!(args.readonly);
        assert_eq!(args.line, Some(42));
        assert_eq!(args.pattern.as_deref(), Some("foo"));
        assert_eq!(args.files, vec![PathBuf::from("src/main.rs")]);
        assert!(!args.picker);
        assert!(
            warnings.is_empty(),
            "no warnings expected, got: {warnings:?}"
        );
        assert_eq!(args.commands, vec!["vsp".to_string()]);
    }

    /// `--clean` parses into `args.clean` and defaults to `false` when
    /// absent. The main config path keys off this flag to skip the user
    /// file and disable disk write-back.
    #[test]
    fn parse_argv_clean_flag() {
        let raw: Vec<String> = ["hjkl", "--clean", "src/main.rs"]
            .iter()
            .map(|s| s.to_string())
            .collect();
        let (args, _) = parse_argv(raw).expect("parse_argv");
        assert!(args.clean, "--clean must set args.clean");
        assert_eq!(args.files, vec![PathBuf::from("src/main.rs")]);

        let raw: Vec<String> = ["hjkl", "src/main.rs"]
            .iter()
            .map(|s| s.to_string())
            .collect();
        let (args, _) = parse_argv(raw).expect("parse_argv");
        assert!(!args.clean, "clean defaults to false without the flag");
    }

    /// `-u <PATH>` is a clap alias for `--config <PATH>`; `-u NONE` / `-u NORC`
    /// (nvim's "skip init" sentinels) collapse onto `--clean` instead, since
    /// hjkl has no plugin system and a single config file. Case-sensitive,
    /// matching nvim.
    #[test]
    fn parse_argv_dash_u_config_alias_and_sentinels() {
        let raw: Vec<String> = ["hjkl", "-u", "/some/path.toml", "f.txt"]
            .iter()
            .map(|s| s.to_string())
            .collect();
        let (args, _) = parse_argv(raw).expect("parse_argv");
        assert_eq!(args.config, Some(PathBuf::from("/some/path.toml")));
        assert!(!args.clean);

        let raw: Vec<String> = ["hjkl", "-u", "NONE", "f.txt"]
            .iter()
            .map(|s| s.to_string())
            .collect();
        let (args, _) = parse_argv(raw).expect("parse_argv");
        assert!(args.clean);
        assert_eq!(args.config, None);

        let raw: Vec<String> = ["hjkl", "-u", "NORC"]
            .iter()
            .map(|s| s.to_string())
            .collect();
        let (args, _) = parse_argv(raw).expect("parse_argv");
        assert!(args.clean);
        assert_eq!(args.config, None);
    }

    /// `-q [ERRORFILE]` (nvim "quickfix mode" alias): absent → `None`; bare
    /// `-q` (nothing else follows to be consumed as its value) defaults to
    /// `"errors.err"` (clap's `default_missing_value`); `-q errs.txt`
    /// captures the explicit path — and does NOT swallow a subsequent FILE
    /// positional.
    #[test]
    fn parse_argv_dash_q_quickfix_flag() {
        let raw: Vec<String> = ["hjkl", "-q", "errs.txt", "f.txt"]
            .iter()
            .map(|s| s.to_string())
            .collect();
        let (args, _) = parse_argv(raw).expect("parse_argv");
        assert_eq!(args.quickfix.as_deref(), Some("errs.txt"));
        assert_eq!(args.files, vec![PathBuf::from("f.txt")]);

        // Bare `-q` with nothing after it to consume as a value.
        let raw: Vec<String> = ["hjkl", "-q"].iter().map(|s| s.to_string()).collect();
        let (args, _) = parse_argv(raw).expect("parse_argv");
        assert_eq!(args.quickfix.as_deref(), Some("errors.err"));

        let raw: Vec<String> = ["hjkl", "f.txt"].iter().map(|s| s.to_string()).collect();
        let (args, _) = parse_argv(raw).expect("parse_argv");
        assert_eq!(args.quickfix, None);
    }

    /// `-o` / `-O` / `-p` parse into `args.file_layout`; absent defaults to
    /// `Buffers` (today's bufferline behavior).
    #[test]
    fn parse_argv_file_layout_flags() {
        let raw: Vec<String> = ["hjkl", "-o", "a.txt", "b.txt"]
            .iter()
            .map(|s| s.to_string())
            .collect();
        let (args, _) = parse_argv(raw).expect("parse_argv");
        assert_eq!(args.file_layout, FileLayout::HSplit);

        let raw: Vec<String> = ["hjkl", "-O", "a.txt", "b.txt"]
            .iter()
            .map(|s| s.to_string())
            .collect();
        let (args, _) = parse_argv(raw).expect("parse_argv");
        assert_eq!(args.file_layout, FileLayout::VSplit);

        let raw: Vec<String> = ["hjkl", "-p", "a.txt", "b.txt"]
            .iter()
            .map(|s| s.to_string())
            .collect();
        let (args, _) = parse_argv(raw).expect("parse_argv");
        assert_eq!(args.file_layout, FileLayout::Tabs);

        let raw: Vec<String> = ["hjkl", "a.txt", "b.txt"]
            .iter()
            .map(|s| s.to_string())
            .collect();
        let (args, _) = parse_argv(raw).expect("parse_argv");
        assert_eq!(args.file_layout, FileLayout::Buffers);
    }

    /// `-o` / `-O` / `-p` are mutually exclusive — nvim treats these as
    /// distinct layout choices, not composable flags. Use `Cli::try_parse_from`
    /// directly (not `parse_argv`) since a parse error there calls
    /// `std::process::exit`.
    #[test]
    fn cli_file_layout_flags_are_mutually_exclusive() {
        assert!(Cli::try_parse_from(["hjkl", "-o", "-O", "f.txt"]).is_err());
        assert!(Cli::try_parse_from(["hjkl", "-o", "-p", "f.txt"]).is_err());
        assert!(Cli::try_parse_from(["hjkl", "-O", "-p", "f.txt"]).is_err());
    }

    /// `hjkl -` (vim/nvim convention): a bare `-` FILE arg means "read the
    /// buffer from stdin". `is_stdin_arg` recognises only that exact literal.
    #[test]
    fn is_stdin_arg_recognises_bare_dash_only() {
        assert!(is_stdin_arg(std::path::Path::new("-")));
        assert!(!is_stdin_arg(std::path::Path::new("foo")));
        assert!(!is_stdin_arg(std::path::Path::new("-R")));
        assert!(!is_stdin_arg(std::path::Path::new("")));
    }

    fn blank_args() -> Args {
        Args {
            files: vec![],
            line: None,
            pattern: None,
            readonly: false,
            picker: false,
            config: None,
            clean: false,
            headless: false,
            embed: false,
            nvim_api: false,
            commands: vec![],
            quickfix: None,
            file_layout: FileLayout::Buffers,
            no_swap: false,
            recover: None,
            script_in: None,
            allow_shell: false,
        }
    }

    /// `-n` (no swap file): absent → `false`; present → `true`. Mirrors
    /// `vim -n` / `nvim -n`.
    #[test]
    fn parse_argv_dash_n_no_swap_flag() {
        let raw: Vec<String> = ["hjkl", "f.txt"].iter().map(|s| s.to_string()).collect();
        let (args, _) = parse_argv(raw).expect("parse_argv");
        assert!(!args.no_swap);

        let raw: Vec<String> = ["hjkl", "-n", "f.txt"]
            .iter()
            .map(|s| s.to_string())
            .collect();
        let (args, _) = parse_argv(raw).expect("parse_argv");
        assert!(args.no_swap);
        assert_eq!(args.files, vec![PathBuf::from("f.txt")]);
    }

    /// `-r [FILE]` (vim `-r`): absent → `None`; bare `-r` (list-swaps mode)
    /// → `Some("")`; `-r foo.txt` → `Some("foo.txt")`.
    #[test]
    fn parse_argv_dash_r_recover_flag() {
        let raw: Vec<String> = ["hjkl", "f.txt"].iter().map(|s| s.to_string()).collect();
        let (args, _) = parse_argv(raw).expect("parse_argv");
        assert_eq!(args.recover, None);

        let raw: Vec<String> = ["hjkl", "-r"].iter().map(|s| s.to_string()).collect();
        let (args, _) = parse_argv(raw).expect("parse_argv");
        assert_eq!(args.recover.as_deref(), Some(""));

        let raw: Vec<String> = ["hjkl", "-r", "foo.txt"]
            .iter()
            .map(|s| s.to_string())
            .collect();
        let (args, _) = parse_argv(raw).expect("parse_argv");
        assert_eq!(args.recover.as_deref(), Some("foo.txt"));
    }

    /// `-r <FILE>` (non-empty value): `parse_argv` folds `FILE` into
    /// `args.files` so it becomes the active buffer that `App::new` opens —
    /// the existing on-open recovery prompt then does the actual recovery
    /// (covered end-to-end by `tests/pty_harness/recovery.rs`). If the user
    /// also passed other FILEs, the recover target is prepended so it's
    /// slot 0, not appended after them.
    #[test]
    fn parse_argv_dash_r_recover_file_becomes_active_buffer() {
        let raw: Vec<String> = ["hjkl", "-r", "crashed.txt"]
            .iter()
            .map(|s| s.to_string())
            .collect();
        let (args, _) = parse_argv(raw).expect("parse_argv");
        assert_eq!(args.files, vec![PathBuf::from("crashed.txt")]);

        // Also passed other FILEs on the command line — the recover target
        // must still end up first (the active buffer).
        let raw: Vec<String> = ["hjkl", "-r", "crashed.txt", "other.txt"]
            .iter()
            .map(|s| s.to_string())
            .collect();
        let (args, _) = parse_argv(raw).expect("parse_argv");
        assert_eq!(
            args.files,
            vec![PathBuf::from("crashed.txt"), PathBuf::from("other.txt")]
        );

        // Bare `-r` (list mode, empty value) must NOT touch `args.files`.
        let raw: Vec<String> = ["hjkl", "-r", "-R"].iter().map(|s| s.to_string()).collect();
        let (args, _) = parse_argv(raw).expect("parse_argv");
        assert_eq!(args.recover.as_deref(), Some(""));
        assert!(args.files.is_empty());
    }

    /// `format_swap_listing` — pure formatter unit test, independent of real
    /// XDG I/O (the subprocess route in `tests/recover_list.rs` covers the
    /// real `swap_dir()` + binary end-to-end).
    #[test]
    fn format_swap_listing_empty_and_populated() {
        assert_eq!(format_swap_listing(&[]), "No swap files found\n");

        let header = hjkl_app::swap::SwapHeader {
            magic: hjkl_app::swap::SwapHeader::MAGIC,
            version: hjkl_app::swap::SwapHeader::VERSION,
            canonical_path: "/home/user/crashed.txt".to_string(),
            file_mtime_unix_ms: 1_700_000_000_000,
            write_time_unix_ms: 1_700_000_001_000,
            cursor: (0, 0),
            writer_pid: 999_999_999, // almost certainly dead
        };
        let out =
            format_swap_listing(&[(PathBuf::from("/cache/hjkl/swap/abc123.swp"), header, false)]);
        assert!(out.contains("/home/user/crashed.txt"), "got: {out}");
        assert!(out.contains("/cache/hjkl/swap/abc123.swp"), "got: {out}");
        assert!(out.contains("process gone"), "got: {out}");
        assert!(out.contains("999999999"), "got: {out}");
    }

    /// `-s <SCRIPTIN>` parses into `args.script_in`; absent → `None`.
    #[test]
    fn parse_argv_dash_s_scriptin_flag() {
        let raw: Vec<String> = ["hjkl", "-s", "foo.txt", "file.rs"]
            .iter()
            .map(|s| s.to_string())
            .collect();
        let (args, _) = parse_argv(raw).expect("parse_argv");
        assert_eq!(args.script_in, Some(PathBuf::from("foo.txt")));
        assert_eq!(args.files, vec![PathBuf::from("file.rs")]);

        let raw: Vec<String> = ["hjkl", "file.rs"].iter().map(|s| s.to_string()).collect();
        let (args, _) = parse_argv(raw).expect("parse_argv");
        assert_eq!(args.script_in, None);
    }

    /// `scriptin_char_to_key` — pure per-character decoder used by `-s`
    /// replay. Covers every special-cased control character plus the
    /// plain-printable-char fallback.
    #[test]
    fn scriptin_char_to_key_decodes_special_and_plain_chars() {
        assert_eq!(
            scriptin_char_to_key('a'),
            KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)
        );
        assert_eq!(
            scriptin_char_to_key('\x1b'),
            KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)
        );
        assert_eq!(
            scriptin_char_to_key('\r'),
            KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)
        );
        assert_eq!(
            scriptin_char_to_key('\n'),
            KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)
        );
        assert_eq!(
            scriptin_char_to_key('\t'),
            KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)
        );
        assert_eq!(
            scriptin_char_to_key('\x08'),
            KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)
        );
        assert_eq!(
            scriptin_char_to_key('\x7f'),
            KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)
        );
        assert_eq!(
            scriptin_char_to_key('\x17'),
            KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL)
        );
    }
}
