use std::fmt::Write as FmtWrite;
use std::io::{self, IsTerminal};
use std::process::ExitCode;

use std::path::PathBuf;

use tess::app::{self, RebuildSpec};
use tess::batch::{self, BatchDestination, BatchSpec};
use tess::cli::Args;
use tess::error::{Error, Result};
use tess::filter::{CompiledFilter, FilterSpec};
use tess::grep::GrepPredicate;
use tess::format;
use tess::line_index::LineIndex;
use tess::prettify::{self, PrettifyMode, ResolvedType};
use tess::source::{find_tail_offset, MockSource, Source, StdinSource, TransformingSource};
use tess::terminal::{install_panic_hook, install_signal_flag, TerminalGuard};
use tess::viewport::Viewport;
use clap::Parser;

const MANUAL_TEXT: &str = include_str!("../MANUAL.md");

/// Resolve the `{{VERSION}}` token in the manual to the compiled crate version.
/// The committed MANUAL.md keeps the token (it is a template); only rendered
/// output shows the number.
fn inject_version(template: &str, version: &str) -> String {
    template.replace("{{VERSION}}", version)
}

use colored::Colorize;

fn examples_section(buf: &mut String, title: &str) {
    let _ = writeln!(buf, "  {}", title.yellow().bold());
    let _ = writeln!(buf);
}

fn examples_example(buf: &mut String, desc: &str, commands: &[&str]) {
    let _ = writeln!(buf, "    {}", desc.bold());
    for cmd in commands {
        let _ = writeln!(buf, "      {}", cmd.cyan());
    }
    let _ = writeln!(buf);
}

fn examples_note(buf: &mut String, text: &str) {
    let _ = writeln!(buf, "    {} {}", "note:".dimmed().bold(), text.dimmed());
    let _ = writeln!(buf);
}

fn build_examples_text() -> String {
    let mut buf = String::new();
    let _ = writeln!(buf);
    let _ = writeln!(buf, "{}", "tess — usage examples".bold());
    let _ = writeln!(buf);

    examples_section(&mut buf, "Plain viewing");
    examples_example(&mut buf, "Open a file", &[
        "tess Cargo.toml",
        "tess -N -S src/main.rs",
        "tess --tab-width 4 Makefile",
    ]);

    examples_section(&mut buf, "Piped input");
    examples_example(&mut buf, "Pipe output through tess", &[
        "git log | tess",
        "cargo build 2>&1 | tess",
        "ls --color=always | tess",
    ]);

    examples_section(&mut buf, "Big files: --head / --tail");
    examples_example(&mut buf, "Cheap views of large files", &[
        "tess --head 50 schema.sql",
        "tess --tail 1000 huge.log",
        "tess -f --tail 1000 huge.log",
    ]);

    examples_section(&mut buf, "Pretty-printing structured files");
    examples_example(&mut buf, "Auto-detect from extension or force a content type", &[
        "tess --prettify config.json",
        "tess --prettify schema.yaml",
        "tess --prettify Cargo.toml",
        "tess --prettify page.html",
        "tess --prettify rows.csv",
        "tess --content-type=json data.bin",
    ]);
    examples_note(&mut buf, "Inside the pager: Shift-P toggles, -Pj/y/t/x/h/c/a/r switches type.");

    examples_section(&mut buf, "Following live output");
    examples_example(&mut buf, "Watch a log file or a file rewritten in place", &[
        "tess -f /var/log/syslog",
        "tail -F /var/log/access.log | tess -f",
        "tess --live src/main.rs",
        "tess --live notes.md",
    ]);

    examples_section(&mut buf, "Plain-text grep (no format needed)");
    examples_example(&mut buf, "Filter lines by regex — no format required", &[
        r"tess --grep error access.log",
        r"tess --grep error --grep '^\[' access.log",
    ]);

    examples_section(&mut buf, "Apache log analysis (built-in formats)");
    examples_example(&mut buf, "Filter by status code, URL, or combine grep and filter", &[
        "tess --format apache-combined --filter status~^5 access.log",
        "tess --format apache-combined --filter status~^5 --filter url~^/api/ access.log",
        "tess --format apache-combined --filter 'status!=200' access.log",
        "tess --format apache-combined --filter 'status>=500' access.log",
        "tess --format apache-combined --filter status~^5 --dim access.log",
        "tess -f --tail 100 --format apache-combined --filter status~^5 access.log",
        "tess --format apache-combined --filter status=500 --grep timeout access.log",
    ]);
    examples_note(&mut buf, "Single-quote filters that use `!` or `<`/`>` — bash's history expansion eats `!`, and `<`/`>` are I/O redirection without quotes.");

    examples_section(&mut buf, "Batch (non-interactive) output");
    examples_example(&mut buf, "Write filtered output to a file or stdout", &[
        "tess --filter status~^5 --format apache-combined -o errors.log access.log",
        "tess --head 1000 --stdout huge.log | grep -c something",
        "tess --prettify --stdout config.json > pretty.json",
        "tess -f --format app --filter level=ERROR -o /tmp/live-errors.log app.log",
    ]);

    examples_section(&mut buf, "Display templates (reformat each line)");
    examples_example(&mut buf, "Reformat matched lines with a custom template", &[
        "tess --format apache-combined --display '[<status>] <method> <url>' access.log",
        "tess --format app --display '[<ts>] <level> <msg>' --filter 'level>=WARN' app.log",
        r"tess --format apache-combined --display '<status>: <url>' \",
        r"     --filter 'status>=500' -o errors.log access.log",
    ]);
    examples_note(&mut buf, "Or set the default per format: add `display = '[<ts>] <level> <msg>'` under `[format.app]` in ~/.config/tess/formats.toml.");

    examples_section(&mut buf, "Custom format (declare in ~/.config/tess/formats.toml)");
    examples_example(&mut buf, "Define a named format with a regex and optional display template", &[
        r"# [format.app]",
        r"# regex = '^(?P<ts>\S+ \S+) (?P<level>\w+) \[(?P<reqid>[0-9a-f]+)\] (?P<msg>.*)$'",
        "",
        "tess --list-formats",
        "tess --format app --filter level=ERROR app.log",
        "tess --format app --filter 'level~^(ERROR|WARN)$' app.log",
        "tess -f --tail 200 --format app --filter level=ERROR app.log",
    ]);

    examples_section(&mut buf, "Groups (shortcut bundles, also in formats.toml)");
    examples_example(&mut buf, "Expand --<groupname> into a fixed flag bundle", &[
        "# [group.errorlog]",
        r#"# format = "app""#,
        r#"# file   = "/var/log/myapp/app.log""#,
        "# follow = true",
        "# tail   = 1000",
        r#"# filter = ["level=ERROR"]"#,
        "",
        "tess --errorlog",
        "tess --errorlog 'msg~timeout'",
        "tess --errorlog --tail 50",
    ]);

    examples_section(&mut buf, "OR-group filtering (complex log queries)");
    examples_example(&mut buf, "Match lines where ANY condition in a group is satisfied, AND'd with required filters", &[
        r"tess --format app --filter 'level>=WARN' \",
        r"     --or-grep timeout --or-grep 'connection refused' app.log",
        r"tess --format app --or-group svc1 --or-filter svc=api \",
        r"     --or-group svc2 --or-filter svc=worker app.log",
    ]);
    examples_note(&mut buf, "Each --or-group is a named bucket; --or-filter/--or-grep join the most recent group. Every group must have ≥1 match. Groups are AND'd together.");

    examples_section(&mut buf, "Side-by-side split and diff");
    examples_example(&mut buf, "View two files side by side, or compare two versions", &[
        "tess --split src/old.rs src/new.rs",
        "tess --diff before.txt after.txt",
        "tess --gitdiff src/main.rs",
        "tess --gitdiff --staged src/main.rs",
        "tess --hsplit a.log b.log",
    ]);
    examples_note(&mut buf, "Inside a split: Tab switches panes, = toggles scroll-lock, :diff enters diff mode. :vsplit / :hsplit / :only / :close at runtime. Pane zoom: Ctrl-X z (or :zoom; bind zoom-pane in keys.toml).");

    examples_section(&mut buf, "Named layouts (multi-pane, from formats.toml)");
    examples_example(&mut buf, "Launch a named layout defined in ~/.config/tess/formats.toml", &[
        "# [layout.myview]",
        r#"# orientation = "vertical""#,
        r#"# [[layout.myview.pane]]"#,
        r#"# file = "/var/log/api.log""#,
        r#"# filter = ["level=ERROR"]"#,
        r#"# [[layout.myview.pane]]"#,
        r#"# file = "/var/log/worker.log""#,
        r#"# filter = ["level=ERROR"]"#,
        "",
        "tess --myview",
    ]);
    examples_note(&mut buf, "Runtime: :layout myview reloads the named layout. layout names are disjoint from group names and mutually exclusive with --split/--diff.");

    examples_section(&mut buf, "Hex dump (binary inspection)");
    examples_example(&mut buf, "xxd-style hex dump of any file", &[
        "tess --hex binary.bin",
        "tess --hex --hex-group 16 core.dump",
        "tess --hex /dev/urandom | head",
    ]);

    examples_section(&mut buf, "Image rendering");
    examples_example(&mut buf, "Render PNG / JPEG / GIF / WebP as colored ASCII art or true pixels", &[
        "tess photo.jpg",
        "tess --blocks logo.png",
        "tess --image-protocol sixel diagram.gif",
        "tess --image-width 60 thumbnail.png",
    ]);
    examples_note(&mut buf, "Protocol auto-detect: Kitty > Sixel > ASCII. Animated GIFs auto-play; p pauses, ./,  step, Backspace restarts. Export: tess --stdout photo.jpg > art.txt");

    examples_section(&mut buf, "Clipboard integration");
    examples_example(&mut buf, "Read from clipboard, copy filtered output, or yank lines interactively", &[
        "tess --from-clipboard",
        "tess --to-clipboard --grep error app.log",
        "tess --to-clipboard --format apache-combined --filter 'status>=500' access.log",
        "tess --clipboard app.log",
    ]);
    examples_note(&mut buf, "Inside --clipboard mode: :yank copies the current line. Bind a key via clipboard-yank-line in keys.toml.");

    examples_section(&mut buf, "Charset / encoding");
    examples_example(&mut buf, "Decode non-UTF-8 files; search and filter operate on decoded text", &[
        "tess --encoding iso-8859-1 legacy.log",
        "tess --encoding windows-1252 report.txt",
        "tess --encoding shift_jis japanese.log",
    ]);
    examples_note(&mut buf, "Runtime: :encoding iso-8859-1. Uses WHATWG labels — iso-8859-1 maps to windows-1252. UTF-16 is not supported.");

    examples_section(&mut buf, "Multi-line records (stack traces, structured blocks)");
    examples_example(&mut buf, "Group continuation lines into one record for search and filter", &[
        r"tess --record-start '^\[' app.log",
        r"tess --record-start '^[A-Z][a-z]+Exception' stacktrace.log",
        r"tess --record-start '^---' --grep timeout events.log",
    ]);
    examples_note(&mut buf, "With --record-start, search (/pat) and --grep match whole records, not individual lines.");

    examples_section(&mut buf, "Tag jumping (ctags / etags)");
    examples_example(&mut buf, "Jump to a function or symbol defined in a tags file", &[
        "tess -t my_function src/",
        "tess -t main -T .git/tags src/main.rs",
    ]);
    examples_note(&mut buf, "Inside tess: Ctrl-] prompts for a tag (Tab-complete), Ctrl-T pops the stack. :tnext / :tprev cycle multi-match results.");

    examples_section(&mut buf, "Interactive keys (inside tess)");
    examples_example(&mut buf, "Search, scroll, and toggle display options", &[
        "/ pat <Enter>     forward regex search       n / N    repeat search",
        "? pat <Enter>     backward regex search      g / G    top / bottom",
        "Space / b         page down / up             Shift-F  toggle follow",
        "-N / -S / -F      toggle line numbers / chop / follow",
        "R                 force-reload from disk (with --live)",
        "q                 quit",
    ]);

    let _ = writeln!(buf, "  {}", "See `tess --manual` for the full reference, or `tess --help` for a flag list.".dimmed());
    let _ = writeln!(buf);

    buf
}

use tess::layout::{
    build_pane_from_source, build_second_pane, build_panes_from_sections,
    parse_header_spec, parse_tab_stops, resolve_pane_predicates, ResolvedPredicates,
};

#[derive(Debug, PartialEq, Eq)]
enum RightKind { WorkingTree, Index, Rev(String) }

#[derive(Debug, PartialEq, Eq)]
struct GitDiffSpec {
    file: std::path::PathBuf,
    left_rev: String,
    right: RightKind,
}

/// Map the `--gitdiff` positionals (+ `--staged`) to which rev/target each side
/// is. Last positional = file; the 0/1/2 before it are revisions. Pure.
fn parse_gitdiff_spec(files: &[std::path::PathBuf], staged: bool) -> std::result::Result<GitDiffSpec, String> {
    if files.is_empty() || files.len() > 3 {
        return Err("--gitdiff takes a file, optionally preceded by one or two revisions".to_string());
    }
    let file = files[files.len() - 1].clone();
    let revs: Vec<String> = files[..files.len() - 1]
        .iter().map(|p| p.to_string_lossy().into_owned()).collect();
    if staged && revs.len() == 2 {
        return Err("--gitdiff --staged can't be combined with two revisions".to_string());
    }
    let left_rev = revs.first().cloned().unwrap_or_else(|| "HEAD".to_string());
    let right = if staged {
        RightKind::Index
    } else if revs.len() == 2 {
        RightKind::Rev(revs[1].clone())
    } else {
        RightKind::WorkingTree
    };
    Ok(GitDiffSpec { file, left_rev, right })
}

enum GitDiffRight { WorkingTree(std::path::PathBuf), Blob(Vec<u8>, String) }

struct GitDiffSources {
    left_bytes: Vec<u8>,
    left_label: String,
    right: GitDiffRight,
}

fn resolve_gitdiff_sources(files: &[std::path::PathBuf], staged: bool) -> Result<GitDiffSources> {
    let spec = parse_gitdiff_spec(files, staged).map_err(Error::Runtime)?;
    let gf = tess::git::resolve(&spec.file).map_err(Error::Runtime)?;
    let (left_bytes, left_absent) = match tess::git::rev_blob(&gf, &spec.left_rev).map_err(Error::Runtime)? {
        tess::git::BlobOutcome::Bytes(b) => (b, false),
        tess::git::BlobOutcome::Absent => (Vec::new(), true),
    };
    let (right, right_absent) = match &spec.right {
        RightKind::WorkingTree => (GitDiffRight::WorkingTree(spec.file.clone()), !spec.file.exists()),
        RightKind::Index => match tess::git::index_blob(&gf).map_err(Error::Runtime)? {
            tess::git::BlobOutcome::Bytes(b) => (GitDiffRight::Blob(b, format!("{} (index)", gf.rel_path)), false),
            tess::git::BlobOutcome::Absent => (GitDiffRight::Blob(Vec::new(), format!("{} (index)", gf.rel_path)), true),
        },
        RightKind::Rev(r) => match tess::git::rev_blob(&gf, r).map_err(Error::Runtime)? {
            tess::git::BlobOutcome::Bytes(b) => (GitDiffRight::Blob(b, format!("{r}:{}", gf.rel_path)), false),
            tess::git::BlobOutcome::Absent => (GitDiffRight::Blob(Vec::new(), format!("{r}:{}", gf.rel_path)), true),
        },
    };
    if left_absent && right_absent {
        return Err(Error::Runtime(format!(
            "nothing to diff: {} is absent on both sides", spec.file.display())));
    }
    Ok(GitDiffSources { left_bytes, left_label: format!("{}:{}", spec.left_rev, gf.rel_path), right })
}

/// Build the extra-panes Vec for `--split`/`--diff` startup.
///
/// - `--diff`: requires exactly two files; returns one extra pane from `files[1]`
///   (with `--right-*` applied).
/// - `--split` with ≥2 files: returns one pane per `files[1..]`; `--right-*`
///   applies to the first extra pane only (pane B); panes C, D, … get shared
///   display config only.
/// - `--split` with 1 file: returns one extra pane from `files[0]` (two views
///   of the same file), with `--right-*` applied.
/// - Neither flag active, or no files: returns an empty Vec.
#[allow(clippy::too_many_arguments)]
fn build_extra_panes(
    files: &[std::path::PathBuf],
    args: &Args,
    ansi_mode: tess::render::AnsiMode,
    cols: u16,
    rows: u16,
    preprocessor: Option<&tess::preprocess::Preprocessor>,
    record_start_regex: Option<&regex::bytes::Regex>,
    case_mode: tess::viewport::CaseMode,
    gitdiff_right: Option<&GitDiffRight>,
) -> Result<Vec<tess::pane::Pane>> {
    if args.gitdiff {
        let rp = ResolvedPredicates::empty();
        let right = gitdiff_right.ok_or_else(|| Error::Runtime("internal: gitdiff right source missing".into()))?;
        let pane = match right {
            GitDiffRight::WorkingTree(path) => {
                if path.exists() {
                    build_second_pane(path, args, ansi_mode, cols, rows,
                        preprocessor, record_start_regex, case_mode, rp)?
                } else {
                    let label = path.file_name().map(|n| n.to_string_lossy().into_owned())
                        .unwrap_or_else(|| path.display().to_string());
                    build_pane_from_source(Box::new(tess::source::MemorySource::new(Vec::new())),
                        label, None, args, ansi_mode, cols, rows, record_start_regex, case_mode, rp)?
                }
            }
            GitDiffRight::Blob(bytes, lbl) => {
                build_pane_from_source(Box::new(tess::source::MemorySource::new(bytes.clone())),
                    lbl.clone(), None, args, ansi_mode, cols, rows, record_start_regex, case_mode, rp)?
            }
        };
        return Ok(vec![pane]);
    }

    if args.diff {
        if files.len() != 2 {
            return Err(Error::Runtime("--diff takes exactly two files".into()));
        }
        let rp = resolve_pane_predicates(
            &args.right_grep, &args.right_filter,
            args.right_format.as_deref(), args.right_display.as_deref(),
            case_mode,
        )?;
        let pane = build_second_pane(
            &files[1], args, ansi_mode, cols, rows,
            preprocessor, record_start_regex, case_mode, rp,
        )?;
        return Ok(vec![pane]);
    }

    if args.split || args.hsplit {
        if files.len() >= 2 {
            let mut panes = Vec::with_capacity(files.len() - 1);
            for (i, path) in files[1..].iter().enumerate() {
                let (rp, pane_case) = if i == 0 {
                    let rp = resolve_pane_predicates(
                        &args.right_grep, &args.right_filter,
                        args.right_format.as_deref(), args.right_display.as_deref(),
                        case_mode,
                    )?;
                    (rp, case_mode)
                } else {
                    (ResolvedPredicates::empty(), tess::viewport::CaseMode::Sensitive)
                };
                let pane = build_second_pane(
                    path, args, ansi_mode, cols, rows,
                    preprocessor, record_start_regex, pane_case, rp,
                )?;
                panes.push(pane);
            }
            return Ok(panes);
        } else if let Some(path) = files.first() {
            let rp = resolve_pane_predicates(
                &args.right_grep, &args.right_filter,
                args.right_format.as_deref(), args.right_display.as_deref(),
                case_mode,
            )?;
            let pane = build_second_pane(
                path, args, ansi_mode, cols, rows,
                preprocessor, record_start_regex, case_mode, rp,
            )?;
            return Ok(vec![pane]);
        }
    }

    Ok(vec![])
}

/// Validate the globals (`args`, from section 0) against the per-pane `--` form.
/// `section_count` is the total number of panes (sections). The `--` form is
/// mutually exclusive with `--split`/`--right-*`; `--diff` requires exactly two
/// sections; and section 0 (the first pane) must name at most one file (a stray
/// extra file there would be a confusing mix of `:n` working-set and panes).
fn validate_per_pane_argv(args: &Args, section_count: usize) -> Result<()> {
    let right_used = !args.right_grep.is_empty()
        || !args.right_filter.is_empty()
        || args.right_format.is_some()
        || args.right_display.is_some()
        || args.right_ignore_case
        || args.right_IGNORE_case;
    if args.split || right_used {
        return Err(Error::Runtime(
            "the `--` per-pane form can't be combined with --split or --right-*".to_string(),
        ));
    }
    if args.diff && section_count != 2 {
        return Err(Error::Runtime(
            "--diff with the `--` form needs exactly two panes".to_string(),
        ));
    }
    if args.files.len() > 1 {
        return Err(Error::Runtime(
            "the first pane (before `--`) must name at most one file".to_string(),
        ));
    }
    Ok(())
}

fn main() -> ExitCode {
    install_panic_hook();
    match real_main() {
        Ok(()) => ExitCode::from(0),
        Err(e) => {
            eprintln!("{}", e);
            ExitCode::from(e.exit_code() as u8)
        }
    }
}

/// Redirect fd 0 to /dev/tty so crossterm can read keyboard events after
/// stdin has been fully consumed from a pipe. Opened read+write because
/// crossterm needs both directions on the tty fd.
#[cfg(unix)]
fn redirect_stdin_to_tty() -> std::io::Result<()> {
    use std::os::unix::io::AsRawFd;
    let tty = std::fs::OpenOptions::new()
        .read(true)
        .write(true)
        .open("/dev/tty")?;
    unsafe {
        if libc::dup2(tty.as_raw_fd(), libc::STDIN_FILENO) < 0 {
            return Err(std::io::Error::last_os_error());
        }
    }
    Ok(())
}

fn resolve_ansi_mode(args: &Args) -> tess::render::AnsiMode {
    use tess::render::AnsiMode;
    if args.raw_control_chars {
        return AnsiMode::Raw;
    }
    if args.no_color {
        return AnsiMode::Strict;
    }
    if let Ok(v) = std::env::var("NO_COLOR") {
        if !v.is_empty() {
            return AnsiMode::Strict;
        }
    }
    if std::env::var("CLICOLOR").as_deref() == Ok("0") {
        return AnsiMode::Strict;
    }
    AnsiMode::Interpret
}

fn resolve_truecolor(args: &Args) -> std::result::Result<bool, String> {
    use tess::render::TrueColor;
    match args.truecolor.as_str() {
        "always" => Ok(true),
        "never" => Ok(false),
        "auto" => Ok(TrueColor::Auto.resolve()),
        other => Err(format!(
            "--truecolor: unknown mode `{other}` (expected auto, always, never)"
        )),
    }
}

fn page_bytes(label: &str, content: &[u8], ansi_mode: tess::render::AnsiMode) -> Result<()> {
    let src = MockSource::new();
    src.append(content);
    src.finish();

    // We need keyboard input on fd 0. If the user piped something into us
    // (e.g. `cat x | tess --manual`), redirect fd 0 to /dev/tty first.
    #[cfg(unix)]
    if !io::stdin().is_terminal() {
        let _ = redirect_stdin_to_tty();
    }

    let sigterm = install_signal_flag();
    // page_bytes is the internal helper used for --manual / --examples;
    // it always enters the alt-screen so the help-style content paints
    // over the user's shell rather than appending to it.
    let _guard = TerminalGuard::enter(false, true)
        .map_err(|e| Error::Runtime(format!("terminal init: {}", e)))?;
    let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24));
    let mut viewport = Viewport::new(cols, rows, label.to_string());
    viewport.set_ansi_mode(ansi_mode);
    let idx = LineIndex::new();
    let keymap = tess::keys::KeyMap::load_layered()
        .unwrap_or_else(|_| tess::keys::KeyMap::empty());
    let file_set = tess::file_set::FileSet::new(vec![std::path::PathBuf::from(label)]);
    let stub_args = Args::parse_from(["tess"]);
    app::run(
        Box::new(src),
        viewport,
        idx,
        sigterm,
        RebuildSpec::default(),
        keymap,
        file_set,
        None,
        stub_args,
        false,
        None,
        None,
        vec![],
        tess::app::Orientation::Vertical,
        #[cfg(feature = "image")]
        (tess::viewport::ImageProtocol::Ascii, None),
    )?;
    Ok(())
}

/// less-style `+CMD` startup command parsed off argv before clap sees it.
#[derive(Debug, Clone, PartialEq, Eq)]
enum PlusCmd {
    /// `+G` → jump to bottom on startup.
    GotoBottom,
    /// `+NUM` → jump to 1-indexed line NUM on startup.
    Goto(usize),
    /// `+/pattern` → forward search; jump to first match.
    SearchForward(String),
    /// `+?pattern` → backward search; jump to first prior match.
    SearchBackward(String),
}

fn parse_plus_cmd(s: &str) -> std::result::Result<PlusCmd, String> {
    debug_assert!(s.starts_with('+') && s.len() > 1);
    let rest = &s[1..];
    if rest == "G" {
        return Ok(PlusCmd::GotoBottom);
    }
    if let Some(p) = rest.strip_prefix('/') {
        return Ok(PlusCmd::SearchForward(p.to_string()));
    }
    if let Some(p) = rest.strip_prefix('?') {
        return Ok(PlusCmd::SearchBackward(p.to_string()));
    }
    if let Ok(n) = rest.parse::<usize>() {
        return Ok(PlusCmd::Goto(n));
    }
    Err(format!(
        "unrecognized startup command `{s}` (expected +G, +N, +/pat, or +?pat)"
    ))
}

fn real_main() -> Result<()> {
    // Extract `+CMD` startup tokens before clap sees the argv — clap doesn't
    // recognize the `+` prefix natively. Order is preserved so multiple
    // `+CMD`s apply in argv order against the viewport just before the
    // event loop starts.
    let raw_argv: Vec<String> = std::env::args().collect();

    // Expand any user-defined groups (`[group.X]` in formats.toml) before clap
    // parses. A `--<groupname>` token becomes the group's flags inline, and
    // remaining bare positionals become `--filter <arg>` pairs.
    let groups = format::load_groups().map_err(Error::Runtime)?;

    // Layouts (`[layout.X]` in formats.toml) compile a `--<layoutname>` token
    // into the `--` per-pane form before the argv is split into sections.
    let layouts = format::load_layouts().map_err(Error::Runtime)?;
    if let Some(name) = layouts.keys().find(|k| groups.contains_key(*k)) {
        return Err(Error::Runtime(format!("`{name}` is defined as both a [layout] and a [group]")));
    }
    let (raw_argv, layout_horizontal) = format::expand_layout_argv(raw_argv, &layouts);

    // Split on standalone `--` into per-pane sections. Zero `--` → one section
    // (the existing single-view path, unchanged). Each section is prefixed with
    // argv[0].
    let sections = tess::cli::split_argv_sections(&raw_argv);
    let per_pane = sections.len() > 1;

    // Section 0 runs the full existing pipeline: +CMD extraction, group
    // expansion, OR-group extraction, then clap parse → the globals + pane 0.
    let s0 = &sections[0];
    let plus_cmds: Vec<String> = s0
        .iter()
        .skip(1)
        .filter(|a| a.starts_with('+') && a.len() > 1)
        .cloned()
        .collect();
    let cleaned_argv: Vec<String> = s0
        .iter()
        .enumerate()
        .filter(|(i, a)| *i == 0 || !(a.starts_with('+') && a.len() > 1))
        .map(|(_, a)| a.clone())
        .collect();

    let argv = format::expand_argv(cleaned_argv, &groups);
    let or_spec = tess::or::extract_from_argv(&argv);
    let args = Args::parse_from(argv);

    // Sections 1..N: each group-expands and parses into its own per-view `Args`.
    // OR-groups and `+CMD` are section-0-only. `+CMD` is only stripped from
    // section 0, so a `+token` in a later section reaches clap as a positional
    // (a second "file") — harmless but ignored; `--or-*` in a later section is
    // rejected in `build_panes_from_sections`. `+CMD`/OR belong in section 0.
    let section_args: Vec<Args> = if per_pane {
        sections[1..]
            .iter()
            .map(|sec| {
                let expanded = format::expand_argv(sec.clone(), &groups);
                Args::parse_from(expanded)
            })
            .collect()
    } else {
        Vec::new()
    };

    if per_pane {
        validate_per_pane_argv(&args, sections.len())?;
    }

    if args.staged && !args.gitdiff {
        return Err(Error::Runtime("--staged/--cached only applies with --gitdiff".to_string()));
    }
    let gitdiff_sources: Option<GitDiffSources> = if args.gitdiff {
        if per_pane || args.diff || args.split
            || !args.right_grep.is_empty() || !args.right_filter.is_empty()
            || args.right_format.is_some() || args.right_display.is_some()
            || args.right_ignore_case || args.right_IGNORE_case
        {
            return Err(Error::Runtime(
                "--gitdiff can't be combined with --diff / --split / --right-* / the `--` form".to_string()));
        }
        if args.from_clipboard {
            return Err(Error::Runtime("--gitdiff reads a file, not the clipboard".to_string()));
        }
        Some(resolve_gitdiff_sources(&args.files, args.staged)?)
    } else {
        None
    };

    if args.hsplit {
        if per_pane || args.split || args.diff || args.gitdiff
            || !args.right_grep.is_empty() || !args.right_filter.is_empty()
            || args.right_format.is_some() || args.right_display.is_some()
            || args.right_ignore_case || args.right_IGNORE_case
        {
            return Err(Error::Runtime(
                "--hsplit can't be combined with --split / --diff / --gitdiff / --right-* / the `--` form".to_string()));
        }
        if args.files.is_empty() {
            return Err(Error::Runtime("--hsplit needs at least one file".to_string()));
        }
    }

    // A layout already compiled to a `--` per-pane form, so combining it with
    // explicit split flags is contradictory.
    if layout_horizontal.is_some()
        && (args.split || args.hsplit || args.diff || args.gitdiff
            || !args.right_grep.is_empty() || !args.right_filter.is_empty()
            || args.right_format.is_some() || args.right_display.is_some()
            || args.right_ignore_case || args.right_IGNORE_case)
    {
        return Err(Error::Runtime(
            "a layout can't be combined with --split / --hsplit / --diff / --gitdiff / --right-*".to_string()));
    }

    // Parse +CMD tokens up front so a typo fails before raw-mode entry.
    let parsed_plus_cmds: Vec<PlusCmd> = plus_cmds
        .iter()
        .map(|s| parse_plus_cmd(s).map_err(Error::Runtime))
        .collect::<Result<Vec<_>>>()?;

    // Info-only flags. When stdout is a TTY, page through tess itself so the
    // content doesn't fly past — the user gets scroll/search/quit. When stdout
    // is redirected (`tess --manual | grep …`, `> out.txt`), print plain text.
    let ansi_mode = resolve_ansi_mode(&args);
    // Validate --truecolor early; the resolved bool is consumed inside app::run.
    resolve_truecolor(&args).map_err(Error::Runtime)?;
    // Validate --image-protocol early so a typo is rejected regardless of input
    // type (mirrors --truecolor). Pass is_tty=false so this validation never
    // queries the terminal — `auto` detection stays lazy until an image opens.
    #[cfg(feature = "image")]
    resolve_image_protocol(&args.image_protocol, false)?;

    // `--follow-name` is accepted for compatibility but already matches our
    // default behavior (rotation/truncation handled by re-opening the path).
    // When given without `-f`, surface a one-line stderr note so users don't
    // think they've also enabled follow mode.
    if args.follow_name && !args.follow {
        eprintln!("tess: --follow-name has no effect without -f / --follow");
    }
    if args.manual {
        let text = inject_version(MANUAL_TEXT, env!("CARGO_PKG_VERSION"));
        if io::stdout().is_terminal() {
            return page_bytes("(manual)", text.as_bytes(), ansi_mode);
        }
        print!("{}", text);
        return Ok(());
    }
    if args.examples {
        let is_tty = io::stdout().is_terminal();
        colored::control::set_override(is_tty);
        let text = build_examples_text();
        if is_tty {
            return page_bytes("(examples)", text.as_bytes(), ansi_mode);
        }
        print!("{}", text);
        return Ok(());
    }
    if args.list_formats {
        let formats = format::load_all().map_err(Error::Runtime)?;
        format::print_format_list(&formats);
        return Ok(());
    }

    // Validate format/filter combination up front so errors land cleanly to
    // stderr without entering raw mode.
    if !args.filter.is_empty() && args.format.is_none() {
        return Err(Error::Runtime(
            "--filter requires --format".to_string(),
        ));
    }
    if args.display.is_some() && args.format.is_none() {
        return Err(Error::Runtime(
            "--display requires --format".to_string(),
        ));
    }
    if or_spec.has_filters() && args.format.is_none() {
        return Err(Error::Runtime(
            "--or-filter requires --format".to_string(),
        ));
    }
    if args.dim && args.filter.is_empty() && args.grep.is_empty() && or_spec.is_empty() {
        return Err(Error::Runtime(
            "--dim has no effect without --filter, --grep, or --or-filter/--or-grep".to_string(),
        ));
    }
    if args.live && args.files.is_empty() {
        return Err(Error::Runtime(
            "--live requires a file path (stdin can't be re-stat'd)".to_string(),
        ));
    }

    // Batch (`--output` / `--stdout`) is incompatible with `--live`: live mode
    // is "watch a file rewrite, render the new view" — there's no view to
    // render in batch.
    let batch_destination: Option<BatchDestination> = if args.to_clipboard {
        Some(BatchDestination::Clipboard)
    } else if args.stdout {
        Some(BatchDestination::Stdout)
    } else if let Some(path) = args.output.as_deref() {
        if path == "-" { Some(BatchDestination::Stdout) }
        else { Some(BatchDestination::File(PathBuf::from(path))) }
    } else {
        None
    };
    if batch_destination.is_some() && args.live {
        return Err(Error::Runtime(
            "--output / --stdout is not compatible with --live".to_string(),
        ));
    }
    if args.to_clipboard && args.follow {
        return Err(Error::Runtime(
            "--to-clipboard is not compatible with --follow".to_string(),
        ));
    }

    // Resolve --content-type now (parse + validation) so errors land cleanly.
    let explicit_content_type: Option<PrettifyMode> = match args.content_type.as_deref() {
        Some(name) => prettify::parse_content_type(name).map_err(Error::Runtime)?,
        None => None,
    };
    // Setting --content-type to a concrete (non-raw, non-auto) type implies
    // --prettify is on. `raw` explicitly disables prettify even if --prettify
    // is also passed.
    let want_prettify = match explicit_content_type {
        Some(PrettifyMode::Off) => false,
        Some(_) => true,
        None => args.prettify,
    };
    if want_prettify {
        if args.follow {
            return Err(Error::Runtime(
                "--prettify is not supported with --follow (streaming partial \
documents can't be parsed)".to_string(),
            ));
        }
        if args.live {
            return Err(Error::Runtime(
                "--prettify is not supported with --live".to_string(),
            ));
        }
        if !args.filter.is_empty() {
            return Err(Error::Runtime(
                "--prettify is not supported with --filter".to_string(),
            ));
        }
        if !args.grep.is_empty() {
            return Err(Error::Runtime(
                "--prettify is not supported with --grep".to_string(),
            ));
        }
        if args.display.is_some() {
            return Err(Error::Runtime(
                "--prettify is not supported with --display".to_string(),
            ));
        }
        if !or_spec.is_empty() {
            return Err(Error::Runtime(
                "--prettify is not supported with --or-filter/--or-grep".to_string(),
            ));
        }
    }

    // Resolve the preprocessor from --preprocess or $LESSOPEN (pipe-mode only).
    // --no-preprocess suppresses both. Stdin sources skip preprocessing entirely.
    let preprocessor: Option<tess::preprocess::Preprocessor> = if args.no_preprocess {
        None
    } else {
        let raw = args.preprocess.clone()
            .or_else(|| std::env::var("LESSOPEN").ok());
        match raw {
            Some(r) => Some(tess::preprocess::Preprocessor::parse(&r)
                .map_err(Error::Runtime)?),
            None => None,
        }
    };

    // Resolve source. Track whether we actually consumed stdin — only then
    // do we need to redirect fd 0 to /dev/tty for keyboard input. Also track
    // whether `--tail` is meaningful for this source (streaming stdin can't
    // do random-access tail).
    // `--from-clipboard` reads the clipboard into an in-memory source — no
    // file args (clap enforces `conflicts_with = "files"`) and no piped stdin.
    // Following has no producer to follow, so reject -f / --live here.
    if args.from_clipboard {
        if args.follow {
            return Err(Error::Runtime(
                "--from-clipboard is not compatible with --follow".to_string(),
            ));
        }
        if args.live {
            return Err(Error::Runtime(
                "--from-clipboard is not compatible with --live".to_string(),
            ));
        }
    }

    let file_set = tess::file_set::FileSet::new(args.files.clone());
    let mut consumed_stdin = false;
    let mut source_supports_tail = true;
    let mut preprocess_failure: Option<String> = None;
    let (src, label): (Box<dyn Source>, String) = if let Some(g) = gitdiff_sources.as_ref() {
        (Box::new(tess::source::MemorySource::new(g.left_bytes.clone())), g.left_label.clone())
    } else if args.from_clipboard {
        let bytes = tess::clipboard::read().map_err(Error::Runtime)?;
        (Box::new(tess::source::MemorySource::new(bytes)), "(clipboard)".to_string())
    } else { match args.files.first() {
        Some(path) => {
            let (s, l, pf) = tess::open::open_source_for_path(path, &args, preprocessor.as_ref())?;
            preprocess_failure = pf;
            (s, l)
        }
        None if !io::stdin().is_terminal() => {
            let ss = if args.follow {
                source_supports_tail = false;
                StdinSource::spawn_streaming()
                    .map_err(|e| Error::Runtime(format!("stdin: {}", e)))?
            } else {
                StdinSource::read_all()
                    .map_err(|e| Error::Runtime(format!("stdin: {}", e)))?
            };
            consumed_stdin = true;
            (Box::new(ss), "(stdin)".to_string())
        }
        None => {
            return Err(Error::NoInput);
        }
    }};

    // Resolve --encoding early: needed by the prettify transform and by batch/
    // interactive paths alike. Peek the first bytes for BOM detection.
    // Error before entering raw mode so diagnostics print cleanly.
    let resolved_enc = {
        let head_len = src.len().min(4);
        let head = src.bytes(0..head_len);
        tess::open::resolve_encoding(&args.encoding, &head)
            .map_err(Error::Runtime)?
    };

    // If the user wants prettification, resolve the mode against the inner
    // source's first bytes + the path (if any) and wrap the source. Failure
    // to detect under `--prettify` (no --content-type given) is a soft fall:
    // print a stderr note and proceed with the raw view.
    let (src, prettify_label): (Box<dyn Source>, Option<String>) = if want_prettify {
        let head = src.bytes(0..src.len().min(512)).to_vec();
        let path_for_detect = args.files.first().map(|p| p.as_path());
        let resolved = prettify::resolve(explicit_content_type, path_for_detect, &head);
        match resolved {
            ResolvedType::Mode(PrettifyMode::Off) => (src, None),
            ResolvedType::Mode(mode) => {
                let label = mode.label().to_string();
                let wrapped = TransformingSource::wrap(src, mode, resolved_enc);
                if let Some(err) = wrapped.last_error() {
                    eprintln!("tess: prettify ({label}) failed: {err} \u{2014} showing raw");
                    (Box::new(wrapped), Some(format!("{label}:err")))
                } else {
                    (Box::new(wrapped), Some(label))
                }
            }
            ResolvedType::Undetected => {
                eprintln!(
                    "tess: --prettify requested but content type could not be detected; \
showing raw (use --content-type=NAME to override)"
                );
                (src, None)
            }
        }
    } else {
        (src, None)
    };

    // Apply --tail by computing a starting byte offset for the LineIndex.
    // Streaming stdin (with -f) can't do this — bytes arrive over time.
    let mut idx = match args.tail {
        Some(n) if source_supports_tail => {
            let off = find_tail_offset(src.as_ref(), n);
            LineIndex::new_starting_at(off)
        }
        Some(_) => {
            eprintln!("tess: --tail is not supported on streaming stdin (-f); ignoring");
            LineIndex::new()
        }
        None => LineIndex::new(),
    };
    if let Some(n) = args.head {
        idx.set_head_cap(n);
    }

    // Resolve --record-start: CLI flag takes priority; fall back to the active
    // format's record_start (if a --format was given and defines one).
    // Must be set BEFORE any idx.extend_* call.
    let record_start_regex: Option<regex::bytes::Regex> = {
        let fmt_record_start: Option<String> = if let Some(name) = args.format.as_deref() {
            let formats = format::load_all().map_err(Error::Runtime)?;
            formats.get(name).and_then(|f| {
                f.record_start.as_ref().map(|r| r.as_str().to_string())
            })
        } else {
            None
        };
        let record_start_pattern: Option<String> = args.record_start
            .clone()
            .or(fmt_record_start);
        if let Some(pat) = record_start_pattern {
            let re = regex::bytes::Regex::new(&pat)
                .map_err(|e| Error::Runtime(format!("--record-start: {e}")))?;
            idx.set_record_start(re.clone());
            Some(re)
        } else {
            None
        }
    };

    // Only redirect fd 0 to /dev/tty if we actually drained stdin from a pipe.
    // For file inputs, stdin is already the user's terminal — replacing it with
    // a read-only /dev/tty fd would break crossterm's event source.
    #[cfg(unix)]
    if consumed_stdin {
        let _ = redirect_stdin_to_tty();
    }

    // Compile --grep patterns up front (no --format required). A failing
    // pattern errors cleanly to stderr without entering raw mode.
    //
    // Resolve case policy from -I / -i. `-I` wins over `-i` (clap also
    // enforces mutual exclusion); both unset → sensitive.
    let case_mode = if args.IGNORE_CASE {
        tess::viewport::CaseMode::Insensitive
    } else if args.ignore_case {
        tess::viewport::CaseMode::Smart
    } else {
        tess::viewport::CaseMode::Sensitive
    };
    // Pane B's case mode is INDEPENDENT of the global -i/-I (matches the
    // no-inheritance rule of the other --right-* flags); default Sensitive.
    let right_case_mode = if args.right_IGNORE_case {
        tess::viewport::CaseMode::Insensitive
    } else if args.right_ignore_case {
        tess::viewport::CaseMode::Smart
    } else {
        tess::viewport::CaseMode::Sensitive
    };
    let compiled_grep = if !args.grep.is_empty() {
        Some(
            GrepPredicate::compile(&args.grep, case_mode)
                .map_err(Error::Runtime)?,
        )
    } else {
        None
    };

    // Compile filter specs and resolve the display template against the chosen
    // format BEFORE entering raw mode so errors print cleanly. The
    // `DisplayRenderer` bundles the (CLI-overridable) template with the
    // format's regex so rendering is a single call later.
    let (compiled_filter, display_renderer) = if let Some(name) = args.format.as_deref() {
        let formats = format::load_all().map_err(Error::Runtime)?;
        let fmt = formats.get(name).ok_or_else(|| {
            Error::Runtime(format!(
                "unknown format `{name}` (run --list-formats to see available)"
            ))
        })?;
        let filter = if !args.filter.is_empty() {
            let specs: Vec<FilterSpec> = args.filter.iter()
                .map(|s| FilterSpec::parse(s).map_err(Error::Runtime))
                .collect::<Result<_>>()?;
            Some(CompiledFilter::compile(fmt, specs, case_mode).map_err(Error::Runtime)?)
        } else {
            None
        };
        // CLI --display overrides the format's default; otherwise use the
        // format's default (if any).
        let template = if let Some(cli_tmpl) = args.display.as_deref() {
            Some(
                format::DisplayTemplate::compile(cli_tmpl, &fmt.field_names)
                    .map_err(|e| Error::Runtime(format!("--display: {e}")))?,
            )
        } else {
            fmt.display.clone()
        };
        let renderer = template.map(|t| format::DisplayRenderer::new(t, fmt.regex.clone()));
        (filter, renderer)
    } else {
        (None, None)
    };

    // Compile OR-groups. Reuse the format if one is set; OR-greps work without
    // it (OR-filters without a format were already rejected above). `LogFormat`
    // is NOT Clone, so borrow `fmt` from a `formats` map kept alive inside each
    // match arm for the duration of the compile call.
    let compiled_or = if or_spec.is_empty() {
        tess::or::OrGroups::default()
    } else {
        match args.format.as_deref() {
            Some(name) => {
                let formats = format::load_all().map_err(Error::Runtime)?;
                let fmt = formats.get(name).ok_or_else(|| Error::Runtime(format!(
                    "unknown format `{name}` (run --list-formats to see available)"
                )))?;
                tess::or::OrGroups::compile(&or_spec, Some(fmt), case_mode)
                    .map_err(Error::Runtime)?
            }
            None => tess::or::OrGroups::compile(&or_spec, None, case_mode)
                .map_err(Error::Runtime)?,
        }
    };

    let sigterm = install_signal_flag();

    // Batch mode: skip the terminal guard entirely and route through
    // `batch::run` instead of the interactive event loop.
    if let Some(destination) = batch_destination {
        let _ = prettify_label; // batch keeps the prettified bytes; label unused

        #[cfg(feature = "image")]
        if !args.hex && !args.no_image {
            let head_len = src.len().min(64);
            let head = src.bytes(0..head_len);
            if tess::image_render::sniff_image_format(&head).is_some() {
                let all = src.bytes(0..src.len());
                // Animation is interactive-only; batch/export decodes a static image.
                let rgba = tess::image_render::decode_image(&all)
                    .map_err(|e| Error::Runtime(format!("image decode failed: {e}")))?;
                let style = if args.blocks {
                    tess::image_render::AsciiStyle::Blocks
                } else {
                    tess::image_render::AsciiStyle::Ramp
                };
                let width = args.image_width.map(|w| w.clamp(1, u16::MAX as usize) as u16).unwrap_or(80);
                let grid = tess::image_render::render_image(&rgba, width, style, !args.no_color);
                use std::io::Write as _;
                // Explicit terminal-graphics protocols export the encoded escape
                // blob verbatim instead of the ASCII grid. `auto`/`ascii` fall
                // through to write_grid (no terminal to detect against in batch).
                let proto_bytes: Option<Vec<u8>> = match args.image_protocol.as_str() {
                    "kitty" => Some(tess::image_protocol::encode_kitty(&rgba)),
                    "sixel" => Some(tess::image_protocol::encode_sixel(&rgba)),
                    _ => None,
                };
                if matches!(destination, BatchDestination::Clipboard) {
                    let mut buf: Vec<u8> = Vec::new();
                    if let Some(bytes) = &proto_bytes {
                        buf.extend_from_slice(bytes);
                    } else {
                        tess::image_export::write_grid(&mut buf, &grid, !args.no_color)
                            .map_err(|e| Error::Runtime(e.to_string()))?;
                    }
                    tess::clipboard::write(&buf).map_err(Error::Runtime)?;
                    return Ok(());
                }
                let mut w: Box<dyn std::io::Write> = match &destination {
                    BatchDestination::Stdout => Box::new(std::io::stdout().lock()),
                    BatchDestination::File(p) => Box::new(std::fs::File::create(p)
                        .map_err(|e| Error::Runtime(format!("{}: {e}", p.display())))?),
                    BatchDestination::Clipboard => unreachable!("clipboard handled above"),
                };
                if let Some(bytes) = &proto_bytes {
                    w.write_all(bytes).map_err(|e| Error::Runtime(e.to_string()))?;
                } else {
                    tess::image_export::write_grid(&mut w, &grid, !args.no_color)
                        .map_err(|e| Error::Runtime(e.to_string()))?;
                }
                return Ok(());
            }
        }

        let spec = BatchSpec {
            destination,
            follow: args.follow,
            poll_interval: std::time::Duration::from_millis(250),
        };
        return batch::run(src, idx, compiled_filter, compiled_grep, compiled_or, display_renderer, spec, sigterm, resolved_enc);
    }

    // `-F` / `--quit-if-one-screen`: if the entire source fits in one
    // screen, write it to stdout and exit — no pager. Skipped when the
    // source can still grow (follow on piped stdin) since "one screen"
    // is meaningless for an open producer.
    if args.quit_if_one_screen && !args.follow && src.is_complete() {
        let (_cols, rows) = crossterm::terminal::size().unwrap_or((80, 24));
        let body_rows = rows.saturating_sub(1) as usize;
        let total_len = src.len();
        let bytes = src.bytes(0..total_len);
        let trailing_nl = bytes.last().is_none_or(|&b| b == b'\n');
        let line_count = bytes.iter().filter(|&&b| b == b'\n').count()
            + if trailing_nl { 0 } else { 1 };
        if line_count <= body_rows {
            use std::io::Write;
            let mut stdout = std::io::stdout().lock();
            stdout.write_all(&bytes).map_err(|e| Error::Runtime(format!("stdout: {e}")))?;
            if !trailing_nl {
                let _ = stdout.write_all(b"\n");
            }
            return Ok(());
        }
    }

    let mouse_settings = tess::format::load_settings().unwrap_or_default();
    let mouse_on = tess::cli::resolve_mouse(args.mouse, args.no_mouse, mouse_settings.mouse);
    let _guard = TerminalGuard::enter(mouse_on, !args.no_init)
        .map_err(|e| Error::Runtime(format!("terminal init: {}", e)))?;

    let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24));
    let mut viewport = Viewport::new(cols, rows, label);
    if args.line_numbers { viewport.toggle_line_numbers(); }
    if args.chop { viewport.toggle_chop(); }
    viewport.opts.tab_width = args.tab_width;
    if let Some(spec) = args.tabs.as_deref() {
        let stops = parse_tab_stops(spec).map_err(Error::Runtime)?;
        if stops.len() == 1 {
            viewport.opts.tab_width = stops[0].clamp(1, u8::MAX as usize) as u8;
        } else {
            viewport.opts.tab_stops = Some(stops);
        }
    }
    viewport.set_follow_mode(args.follow);
    viewport.set_live_mode(args.live);
    viewport.set_prettify_label(prettify_label);
    if let Some(f) = compiled_filter {
        viewport.set_filter(Some(f));
    }
    if let Some(g) = compiled_grep {
        viewport.set_grep(Some(g));
    }
    if compiled_or.is_active() {
        viewport.set_or_groups(compiled_or);
    }
    if args.dim {
        viewport.set_dim_mode(true);
    }
    if let Some(d) = display_renderer {
        viewport.set_display(Some(d));
    }
    if args.hex {
        viewport.set_hex_mode(true);
        let bpg = tess::hex::hex_chars_to_bytes_per_group(args.hex_group)
            .ok_or_else(|| Error::Runtime(format!(
                "--hex-group must be one of 2, 4, 8, 16, 32 (got {})",
                args.hex_group
            )))?;
        viewport.set_hex_group_size(bpg);
    }
    viewport.set_encoding(resolved_enc);
    viewport.set_ansi_mode(ansi_mode);
    viewport.set_case_mode(case_mode);
    viewport.set_hilite_search(!args.no_hilite_search);
    viewport.set_search_skip_screen(args.search_skip_screen);
    viewport.set_hilite_only_match(args.hilite_only_match);
    viewport.set_tilde_eof(args.tilde);
    viewport.set_incsearch(args.incsearch);
    let qae = if args.QUIT_AT_EOF {
        tess::viewport::QuitAtEof::First
    } else if args.quit_at_eof {
        tess::viewport::QuitAtEof::Second
    } else {
        tess::viewport::QuitAtEof::Off
    };
    viewport.set_quit_at_eof(qae);
    viewport.set_squeeze_blanks(args.squeeze_blanks);
    viewport.set_status_column(args.status_column);
    if let Some(spec) = args.header.as_deref() {
        let (lines, cols) = parse_header_spec(spec).map_err(Error::Runtime)?;
        viewport.set_header(lines, cols);
    }
    viewport.opts.rscroll_char = args.rscroll.chars().next();
    viewport.opts.word_wrap = args.word_wrap;
    viewport.set_page_size(args.window);
    viewport.set_preprocess_failure(preprocess_failure);

    // Resolve --prompt: CLI flag takes priority; fall back to the active
    // format's prompt (if a --format was given and defines one).
    {
        let (fmt_prompt, fmt_prompt_style): (Option<tess::prompt::ParsedPrompt>, Option<tess::ansi::Style>) =
            if let Some(name) = args.format.as_deref() {
                let formats = format::load_all().map_err(Error::Runtime)?;
                let entry = formats.get(name);
                (
                    entry.and_then(|f| f.prompt.clone()),
                    entry.and_then(|f| f.prompt_style),
                )
            } else {
                (None, None)
            };
        let prompt_template: Option<tess::prompt::ParsedPrompt> = match args.prompt.as_deref() {
            Some(s) => Some(tess::prompt::ParsedPrompt::parse(s)
                .map_err(|e| Error::Runtime(format!("--prompt: {e}")))?),
            None => fmt_prompt,
        };
        let prompt_active = prompt_template.is_some();
        viewport.set_prompt(prompt_template);

        // Resolve status / prompt theming once at startup.
        // - `--status-style` is the base (default `reverse`).
        // - When a custom prompt is active, `--prompt-style` (if non-empty)
        //   wins, otherwise per-format `prompt_style` wins, otherwise
        //   `--status-style` is used.
        let status_style_base = tess::style_spec::parse(&args.status_style)
            .map_err(|e| Error::Runtime(format!("--status-style: {e}")))?;
        let cli_prompt_style = if args.prompt_style.trim().is_empty() {
            None
        } else {
            Some(tess::style_spec::parse(&args.prompt_style)
                .map_err(|e| Error::Runtime(format!("--prompt-style: {e}")))?)
        };
        let resolved_status_style = if prompt_active {
            cli_prompt_style
                .or(fmt_prompt_style)
                .unwrap_or(status_style_base)
        } else {
            status_style_base
        };
        viewport.set_status_style(resolved_status_style);
    }
    viewport.set_format_label(args.format.clone());
    viewport.set_file_index(0, file_set.len());

    let rebuild_spec = RebuildSpec {
        head: args.head,
        tail: if source_supports_tail { args.tail } else { None },
    };
    let keymap = tess::keys::KeyMap::load_layered()
        .map_err(Error::Runtime)?;

    let tag_file: Option<tess::tags::TagFile> = if let Some(path) = &args.tag_file {
        match tess::tags::TagFile::load(path) {
            Ok(tf) => Some(tf),
            Err(e) => {
                eprintln!("tess: {e}");
                std::process::exit(1);
            }
        }
    } else {
        let start = args
            .files
            .first()
            .map(|p| p.as_path())
            .unwrap_or_else(|| std::path::Path::new("."));
        if let Some(found) = tess::tags::TagFile::find_walking_up(start) {
            tess::tags::TagFile::load(&found).ok()
        } else {
            None
        }
    };

    if args.tag.is_some() && tag_file.is_none() {
        eprintln!("tess: tags file not found (required by -t)");
        std::process::exit(1);
    }

    // Apply `+CMD` startup commands against the live viewport before the
    // event loop spins up. Search compiles use the resolved case mode so
    // `+/foo` honors `-i` / `-I`.
    for cmd in &parsed_plus_cmds {
        match cmd {
            PlusCmd::Goto(n) if *n > 0 => viewport.goto_line(n - 1, src.as_ref(), &mut idx),
            PlusCmd::Goto(_) => viewport.goto_top(),
            PlusCmd::GotoBottom => viewport.goto_bottom(src.as_ref(), &mut idx),
            PlusCmd::SearchForward(p) => {
                viewport
                    .set_search(p.clone(), tess::viewport::SearchDirection::Forward)
                    .map_err(|e| Error::Runtime(format!("+/{p}: {e}")))?;
                viewport.search_repeat(src.as_ref(), &mut idx, false);
            }
            PlusCmd::SearchBackward(p) => {
                viewport
                    .set_search(p.clone(), tess::viewport::SearchDirection::Backward)
                    .map_err(|e| Error::Runtime(format!("+?{p}: {e}")))?;
                viewport.search_repeat(src.as_ref(), &mut idx, false);
            }
        }
    }

    // `-p PATTERN` is equivalent to `+/PATTERN`: open at the first match. Applied
    // after explicit +CMDs (last jump wins).
    if let Some(pat) = args.pattern.as_deref() {
        viewport
            .set_search(pat.to_string(), tess::viewport::SearchDirection::Forward)
            .map_err(|e| Error::Runtime(format!("-p {pat}: {e}")))?;
        viewport.search_repeat(src.as_ref(), &mut idx, false);
    }

    // Resolve the startup image protocol once: it pins the first viewport's
    // image rendering AND is handed to `app::run` so `:only` can restore it
    // after a split (split forces ASCII; `:only` undoes that).
    #[cfg(feature = "image")]
    let startup_image_protocol = {
        let is_tty = std::io::stdout().is_terminal();
        resolve_image_protocol(&args.image_protocol, is_tty)?
    };

    // Image auto-detection: sniff the source's leading bytes. If it is a
    // supported image and the user did not force raw (--no-image) or hex
    // (--hex wins), decode and switch the viewport into ASCII-art mode.
    #[cfg(feature = "image")]
    if !args.hex && !args.no_image {
        let head_len = src.len().min(64);
        let head = src.bytes(0..head_len);
        if let Some(fmt) = tess::image_render::sniff_image_format(&head) {
            let all = src.bytes(0..src.len());
            let style = if args.blocks {
                tess::image_render::AsciiStyle::Blocks
            } else {
                tess::image_render::AsciiStyle::Ramp
            };

            // Prefer animated playback when the source decodes as a
            // multi-frame image and the user did not ask for a static view.
            // Decodes all frames eagerly (a frame-count cap could be added if
            // huge animations become a memory concern).
            use tess::image_render::AnimationDecode;
            let decoded = if args.no_animate {
                AnimationDecode::Static
            } else {
                tess::image_render::decode_animation(&all)
            };
            // `Unsupported` means the source IS animated but its frames can't be
            // decoded (e.g. a 16-bit APNG) — show the static first frame and
            // hint, instead of silently dropping the animation.
            let unsupported = matches!(decoded, AnimationDecode::Unsupported(_));

            let loaded = if let AnimationDecode::Animated(anim) = decoded {
                viewport.set_animation(anim, fmt, style, args.image_width);
                true
            } else {
                match tess::image_render::decode_image(&all) {
                    Ok(rgba) => {
                        viewport.set_image(rgba, fmt, style, args.image_width);
                        if unsupported {
                            viewport.flash("couldn't decode animation; showing first frame", 20);
                        }
                        true
                    }
                    Err(e) => {
                        eprintln!("tess: image decode failed ({e}); showing raw");
                        false
                    }
                }
            };

            if loaded {
                viewport.set_image_no_color(args.no_color);
                let (proto, cell_px) = startup_image_protocol;
                viewport.set_image_protocol(proto, cell_px);
            }
        }
    }

    // Build extra panes for `--split`/`--diff` before `args`/`preprocessor`/
    // `record_start_regex` are moved into `app::run`. For `--split` with N files,
    // one pane per files[1..] is built; `--right-*` applies only to the first
    // extra pane (pane B). `other_pane_init` inside `app::run` resizes all panes,
    // so `cols`/`rows` here are provisional.
    let extra_panes: Vec<tess::pane::Pane> = if per_pane {
        build_panes_from_sections(
            &section_args,
            ansi_mode,
            cols,
            rows,
            preprocessor.as_ref(),
        )?
    } else {
        match build_extra_panes(
            &args.files,
            &args,
            ansi_mode,
            cols,
            rows,
            preprocessor.as_ref(),
            record_start_regex.as_ref(),
            right_case_mode,
            gitdiff_sources.as_ref().map(|g| &g.right),
        ) {
            Ok(panes) => panes,
            Err(e) => {
                eprintln!("tess: pane setup: {e}");
                vec![]
            }
        }
    };

    let orientation = match layout_horizontal {
        Some(true) => tess::app::Orientation::Horizontal,
        Some(false) => tess::app::Orientation::Vertical,
        None => if args.hsplit { tess::app::Orientation::Horizontal } else { tess::app::Orientation::Vertical },
    };
    app::run(
        src,
        viewport,
        idx,
        sigterm,
        rebuild_spec,
        keymap,
        file_set,
        record_start_regex,
        args,
        mouse_on,
        preprocessor,
        tag_file,
        extra_panes,
        orientation,
        #[cfg(feature = "image")]
        startup_image_protocol,
    )?;
    Ok(())
}

#[cfg(feature = "image")]
fn resolve_image_protocol(flag: &str, is_tty: bool)
    -> std::result::Result<(tess::viewport::ImageProtocol, Option<(u16, u16)>), Error> {
    use tess::viewport::ImageProtocol;
    match flag {
        "ascii" => Ok((ImageProtocol::Ascii, None)),
        "kitty" => Ok((ImageProtocol::Kitty, None)),
        "sixel" => Ok((ImageProtocol::Sixel, None)),
        "auto" => {
            if !is_tty {
                return Ok((ImageProtocol::Ascii, None));
            }
            let g = tess::term_query::detect();
            // Preference: Kitty > Sixel > ASCII.
            let proto = if g.kitty { ImageProtocol::Kitty }
                else if g.sixel { ImageProtocol::Sixel }
                else { ImageProtocol::Ascii };
            Ok((proto, g.cell_px))
        }
        other => Err(Error::Runtime(format!(
            "--image-protocol: unknown value '{other}' (expected auto, kitty, sixel, or ascii)"))),
    }
}

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

    #[test]
    fn inject_version_replaces_token() {
        assert_eq!(inject_version("a {{VERSION}} b", "9.9.9"), "a 9.9.9 b");
        assert_eq!(inject_version("none here", "9.9.9"), "none here");
    }

    #[test]
    fn parse_plus_g_is_goto_bottom() {
        assert_eq!(parse_plus_cmd("+G"), Ok(PlusCmd::GotoBottom));
    }

    #[test]
    fn per_pane_excludes_split_and_right() {
        let mut a = Args::parse_from(["tess", "x"]);
        a.split = true;
        assert!(validate_per_pane_argv(&a, 2).is_err());

        let mut b = Args::parse_from(["tess", "x"]);
        b.right_grep = vec!["E".into()];
        assert!(validate_per_pane_argv(&b, 2).is_err());

        let c = Args::parse_from(["tess", "x"]);
        assert!(validate_per_pane_argv(&c, 2).is_ok());
    }

    #[test]
    fn per_pane_diff_needs_exactly_two_sections() {
        let mut a = Args::parse_from(["tess", "x"]);
        a.diff = true;
        assert!(validate_per_pane_argv(&a, 2).is_ok());
        assert!(validate_per_pane_argv(&a, 1).is_err());
        assert!(validate_per_pane_argv(&a, 3).is_err());
    }

    #[test]
    fn per_pane_section0_at_most_one_file() {
        // stdin (0 files) and a single file are fine; >1 file in section 0 is an error.
        assert!(validate_per_pane_argv(&Args::parse_from(["tess"]), 2).is_ok());
        assert!(validate_per_pane_argv(&Args::parse_from(["tess", "a"]), 2).is_ok());
        assert!(validate_per_pane_argv(&Args::parse_from(["tess", "a", "b"]), 2).is_err());
    }

    #[cfg(feature = "image")]
    #[test]
    fn resolve_image_protocol_explicit_and_errors() {
        use tess::viewport::ImageProtocol;
        assert_eq!(resolve_image_protocol("ascii", true).unwrap().0, ImageProtocol::Ascii);
        assert_eq!(resolve_image_protocol("kitty", true).unwrap().0, ImageProtocol::Kitty);
        assert_eq!(resolve_image_protocol("sixel", false).unwrap().0, ImageProtocol::Sixel);
        // auto with no tty → ASCII (never queries the terminal).
        assert_eq!(resolve_image_protocol("auto", false).unwrap().0, ImageProtocol::Ascii);
        // unknown value errors.
        assert!(resolve_image_protocol("bogus", true).is_err());
        // The eager-validation call uses is_tty=false; valid values must resolve
        // without error (and without touching the terminal).
        assert!(resolve_image_protocol("auto", false).is_ok());
        assert!(resolve_image_protocol("kitty", false).is_ok());
    }

    #[test]
    fn parse_plus_num_is_goto() {
        assert_eq!(parse_plus_cmd("+42"), Ok(PlusCmd::Goto(42)));
        assert_eq!(parse_plus_cmd("+1"), Ok(PlusCmd::Goto(1)));
    }

    #[test]
    fn parse_plus_slash_is_search_forward() {
        assert_eq!(
            parse_plus_cmd("+/error"),
            Ok(PlusCmd::SearchForward("error".into()))
        );
    }

    #[test]
    fn parse_plus_question_is_search_backward() {
        assert_eq!(
            parse_plus_cmd("+?warning"),
            Ok(PlusCmd::SearchBackward("warning".into()))
        );
    }

    #[test]
    fn parse_plus_unknown_errors() {
        assert!(parse_plus_cmd("+xyzzy").is_err());
        assert!(parse_plus_cmd("+abc").is_err());
    }

    #[test]
    fn parse_plus_empty_pattern_still_parses() {
        // `+/` with empty pattern is structurally a search forward;
        // the regex compile would catch the empty pattern downstream.
        assert_eq!(
            parse_plus_cmd("+/"),
            Ok(PlusCmd::SearchForward("".into()))
        );
    }

    #[test]
    fn build_extra_panes_one_per_file_for_split() {
        use std::io::Write;
        let mk = |s: &str| { let mut f = tempfile::NamedTempFile::new().unwrap(); writeln!(f, "{s}").unwrap(); f.flush().unwrap(); f };
        let (a, b, c) = (mk("A"), mk("B"), mk("C"));
        let files = vec![a.path().to_path_buf(), b.path().to_path_buf(), c.path().to_path_buf()];
        let args = Args::parse_from(["tess", "--split", "x", "y", "z"]);
        let extras = build_extra_panes(
            &files, &args, tess::render::AnsiMode::Strict, 80, 24, None, None,
            tess::viewport::CaseMode::Sensitive, None,
        ).unwrap();
        assert_eq!(extras.len(), 2); // panes for files[1] and files[2]
    }

    #[test]
    fn gitdiff_spec_forms() {
        use std::path::PathBuf;
        let p = |s: &str| PathBuf::from(s);
        let s = parse_gitdiff_spec(&[p("a.rs")], false).unwrap();
        assert_eq!(s.left_rev, "HEAD"); assert_eq!(s.right, RightKind::WorkingTree); assert_eq!(s.file, p("a.rs"));
        let s = parse_gitdiff_spec(&[p("HEAD~3"), p("a.rs")], false).unwrap();
        assert_eq!(s.left_rev, "HEAD~3"); assert_eq!(s.right, RightKind::WorkingTree);
        let s = parse_gitdiff_spec(&[p("v1"), p("v2"), p("a.rs")], false).unwrap();
        assert_eq!(s.left_rev, "v1"); assert_eq!(s.right, RightKind::Rev("v2".into()));
        let s = parse_gitdiff_spec(&[p("a.rs")], true).unwrap();
        assert_eq!(s.left_rev, "HEAD"); assert_eq!(s.right, RightKind::Index);
        let s = parse_gitdiff_spec(&[p("HEAD~1"), p("a.rs")], true).unwrap();
        assert_eq!(s.left_rev, "HEAD~1"); assert_eq!(s.right, RightKind::Index);
    }

    #[test]
    fn gitdiff_spec_rejects_bad_arity_and_staged_two_revs() {
        use std::path::PathBuf;
        let p = |s: &str| PathBuf::from(s);
        assert!(parse_gitdiff_spec(&[], false).is_err());
        assert!(parse_gitdiff_spec(&[p("a"),p("b"),p("c"),p("d")], false).is_err());
        assert!(parse_gitdiff_spec(&[p("r1"),p("r2"),p("a.rs")], true).is_err());
    }

}
