"""Command-line interface for simple conversions.
Usage examples:
  python -m doc2md.cli input.pdf -o output.md
  python -m doc2md.cli file.docx
  python -m doc2md.cli table.csv
"""
from __future__ import annotations

import argparse
import asyncio
import difflib
import json
import os
import tomllib
from pathlib import Path
import shlex
import sys
import time
from typing import Callable, Optional

from . import __version__
from . import history_db
from .converter import (
    ConversionError,
    UnsupportedFormatError,
    csv_to_markdown,
    docx_to_markdown,
    image_to_markdown,
    pdf_to_markdown,
)

SUPPORTED_FORMATS = {"pdf", "docx", "csv", "image"}
SUPPORTED_PDF_ENGINES = {"pdfminer", "pdftext", "marker"}
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".tif", ".tiff", ".bmp"}
DEFAULT_OCR_VERSION = "PP-OCRv5"
DEFAULT_INPUT_DIR = Path("docs_in")
DEFAULT_OUTPUT_DIR = Path("docs_out")
OCR_MODES = {"never", "auto", "always"}
HISTORY_COMMANDS = {"history", "search", "rerun"}
PROFILE_COMMANDS = {"profile"}
JUMP_COMMANDS = {"jump"}
RECENT_COMMANDS = {"recent"}
EXPLAIN_COMMANDS = {"explain"}
UI_COMMANDS = {"ui"}
THEME_NAMES = ["auto", "light", "dark", "minimal", "dense"]
MAX_INTERACTIVE_HISTORY_LINES = 400
MENU_MAX_VISIBLE_ITEMS = 8

THEMES: dict[str, dict[str, str]] = {
    "light": {
        "": "bg:#f7f7f2 fg:#1c1c1c",
        "banner": "bg:#f7f7f2 fg:#1c1c1c",
        "banner.rule": "bg:#f7f7f2 fg:#d4d4d8",
        "banner.accent": "bg:#f7f7f2 fg:#dc2626 bold",
        "banner.meta": "bg:#f7f7f2 fg:#4b5563",
        "card.border": "fg:#f97316",
        "card.title": "fg:#ea580c bold",
        "card.section": "fg:#ea580c bold",
        "card.text": "fg:#1c1c1c",
        "card.muted": "fg:#6b7280",
        "card.accent": "fg:#f97316 bold",
        "card.logo": "fg:#ea580c",
        "pill.label": "bg:#e2e8f0 fg:#334155",
        "pill.value": "bg:#dbeafe fg:#1e293b bold",
        "keycap": "bg:#e2e8f0 fg:#111827",
        "menu.container": "bg:#f7f7f2",
        "menu": "bg:#f7f7f2 fg:#1c1c1c",
        "menu.meta": "bg:#f7f7f2 fg:#5c6370",
        "menu.selected": "bg:#e2e8f0 fg:#111111",
        "menu.selected.meta": "bg:#e2e8f0 fg:#334155",
        "menu.spacer": "bg:#f7f7f2",
        "help.title": "bg:#f7f7f2 fg:#111111 bold",
        "help.section": "bg:#f7f7f2 fg:#4b5563",
        "help.text": "bg:#f7f7f2 fg:#1c1c1c",
        "help.spacer": "bg:#f7f7f2",
        "hint": "fg:#6b7280",
        "footer": "fg:#6b7280",
        "input.container": "bg:#f7f7f2",
        "input.text": "bg:#f7f7f2 fg:#111111",
        "input.prompt": "bg:#f7f7f2 fg:#dc2626",
        "frame.border": "fg:#d4d4d8",
        "frame.label": "fg:#9ca3af",
    },
    "dark": {
        "": "bg:#0f1115 fg:#d7d7d7",
        "banner": "bg:#0f1115 fg:#d7d7d7",
        "banner.rule": "bg:#0f1115 fg:#2b3440",
        "banner.accent": "bg:#0f1115 fg:#ff2d55 bold",
        "banner.meta": "bg:#0f1115 fg:#9aa0a6",
        "card.border": "fg:#f59e0b",
        "card.title": "fg:#f59e0b bold",
        "card.section": "fg:#fb923c bold",
        "card.text": "fg:#d7d7d7",
        "card.muted": "fg:#9aa0a6",
        "card.accent": "fg:#f59e0b bold",
        "card.logo": "fg:#fbbf24",
        "pill.label": "bg:#1f2937 fg:#cbd5f5",
        "pill.value": "bg:#111827 fg:#ffffff bold",
        "keycap": "bg:#1f2937 fg:#e5e7eb",
        "menu.container": "bg:#0f1115",
        "menu": "bg:#0f1115 fg:#d7d7d7",
        "menu.meta": "bg:#0f1115 fg:#9aa0a6",
        "menu.selected": "bg:#1f2937 fg:#ffffff",
        "menu.selected.meta": "bg:#1f2937 fg:#cbd5f5",
        "menu.spacer": "bg:#0f1115",
        "help.title": "bg:#0f1115 fg:#ffffff bold",
        "help.section": "bg:#0f1115 fg:#9aa0a6",
        "help.text": "bg:#0f1115 fg:#d7d7d7",
        "help.spacer": "bg:#0f1115",
        "hint": "fg:#7a7f86",
        "footer": "fg:#7a7f86",
        "input.container": "bg:#0f1115",
        "input.text": "bg:#0f1115 fg:#ffffff",
        "input.prompt": "bg:#0f1115 fg:#ff2d55",
        "frame.border": "fg:#2b3440",
        "frame.label": "fg:#6b7280",
    },
    "minimal": {
        "": "bg:#000000 fg:#ffffff",
        "banner": "bg:#000000 fg:#ffffff",
        "banner.rule": "bg:#000000 fg:#c0c0c0",
        "banner.accent": "bg:#000000 fg:#ffffff bold",
        "banner.meta": "bg:#000000 fg:#c0c0c0",
        "card.border": "fg:#ffffff",
        "card.title": "fg:#ffffff bold",
        "card.section": "fg:#ffffff bold",
        "card.text": "fg:#ffffff",
        "card.muted": "fg:#c0c0c0",
        "card.accent": "fg:#ffffff bold",
        "card.logo": "fg:#ffffff",
        "pill.label": "bg:#000000 fg:#ffffff",
        "pill.value": "bg:#000000 fg:#ffffff bold",
        "keycap": "bg:#000000 fg:#ffffff",
        "menu.container": "bg:#000000",
        "menu": "bg:#000000 fg:#ffffff",
        "menu.meta": "bg:#000000 fg:#c0c0c0",
        "menu.selected": "bg:#000000 fg:#ffffff bold",
        "menu.selected.meta": "bg:#000000 fg:#c0c0c0",
        "menu.spacer": "bg:#000000",
        "help.title": "bg:#000000 fg:#ffffff bold",
        "help.section": "bg:#000000 fg:#c0c0c0",
        "help.text": "bg:#000000 fg:#ffffff",
        "help.spacer": "bg:#000000",
        "hint": "fg:#c0c0c0",
        "footer": "fg:#c0c0c0",
        "input.container": "bg:#000000",
        "input.text": "bg:#000000 fg:#ffffff",
        "input.prompt": "bg:#000000 fg:#ffffff",
        "frame.border": "fg:#c0c0c0",
        "frame.label": "fg:#c0c0c0",
    },
    "dense": {
        "": "bg:#0b0f14 fg:#d7d7d7",
        "banner": "bg:#0b0f14 fg:#d7d7d7",
        "banner.rule": "bg:#0b0f14 fg:#1f2937",
        "banner.accent": "bg:#0b0f14 fg:#38bdf8 bold",
        "banner.meta": "bg:#0b0f14 fg:#8b949e",
        "card.border": "fg:#f59e0b",
        "card.title": "fg:#f59e0b bold",
        "card.section": "fg:#fb923c bold",
        "card.text": "fg:#d7d7d7",
        "card.muted": "fg:#8b949e",
        "card.accent": "fg:#f59e0b bold",
        "card.logo": "fg:#fbbf24",
        "pill.label": "bg:#1f2937 fg:#d7d7d7",
        "pill.value": "bg:#0f172a fg:#ffffff bold",
        "keycap": "bg:#1f2937 fg:#e2e8f0",
        "menu.container": "bg:#0b0f14",
        "menu": "bg:#0b0f14 fg:#d7d7d7",
        "menu.meta": "bg:#0b0f14 fg:#8b949e",
        "menu.selected": "bg:#111827 fg:#ffffff",
        "menu.selected.meta": "bg:#111827 fg:#e2e8f0",
        "menu.spacer": "bg:#0b0f14",
        "help.title": "bg:#0b0f14 fg:#ffffff bold",
        "help.section": "bg:#0b0f14 fg:#8b949e",
        "help.text": "bg:#0b0f14 fg:#d7d7d7",
        "help.spacer": "bg:#0b0f14",
        "hint": "fg:#8b949e",
        "footer": "fg:#8b949e",
        "input.container": "bg:#0b0f14",
        "input.text": "bg:#0b0f14 fg:#ffffff",
        "input.prompt": "bg:#0b0f14 fg:#38bdf8",
        "frame.border": "fg:#38bdf8",
        "frame.label": "fg:#38bdf8",
    },
}
CORE_SLASH_COMMANDS = [
    "/help",
    "/files",
    "/format",
    "/engine",
    "/output",
    "/ocr",
    "/more",
    "/exit",
]

ADVANCED_SLASH_COMMANDS = [
    "/history",
    "/profile",
    "/ui",
    "/session",
    "/debug",
]

SLASH_COMMANDS = [*CORE_SLASH_COMMANDS, *ADVANCED_SLASH_COMMANDS]
SLASH_COMMAND_NAMES = {command[1:] for command in SLASH_COMMANDS}
SLASH_COMMAND_DETAILS = {
    "/help": "Show available commands",
    "/files": "Select files from docs_in",
    "/format": "Set or clear format override",
    "/engine": "Set PDF engine",
    "/output": "Set output path or stdout",
    "/ocr": "OCR settings (mode/lang/device/threshold)",
    "/history": "History tools (list/search/rerun/explain)",
    "/profile": "Profiles (list/show/apply)",
    "/ui": "UI actions (theme/open)",
    "/session": "Session toggles (preview/diff/yes/stats/quiet)",
    "/debug": "Toggle debug logging",
    "/more": "Advanced commands index",
    "/exit": "Exit interactive mode",
}
SLASH_COMMAND_OPTIONS = {
    "format": [
        ("pdf", "Force PDF format"),
        ("docx", "Force DOCX format"),
        ("csv", "Force CSV format"),
        ("image", "Force image format"),
        ("auto", "Clear format override"),
    ],
    "engine": [
        ("pdfminer", "Default engine"),
        ("pdftext", "Fast PDF text engine"),
        ("marker", "Marker PDF engine"),
    ],
    "output": [
        ("default", "Reset to docs_out/<input>.md"),
        ("-", "Write to stdout"),
        ("docs_out", "Write into docs_out directory"),
    ],
    "ocr": [
        ("auto", "OCR only when PDF text is empty"),
        ("never", "Disable OCR"),
        ("always", "Always OCR PDFs"),
        ("mode", "Set OCR mode"),
        ("lang", "Set OCR language"),
        ("device", "Set OCR device"),
        ("render-scale", "Set OCR render scale"),
        ("min-score", "Set OCR minimum score"),
    ],
    "history": [
        ("list", "List recent runs"),
        ("search", "Search runs"),
        ("rerun", "Rerun a run id"),
        ("explain", "Explain a run id"),
        ("jump", "List recent input paths"),
        ("recent", "List recent inputs/projects"),
    ],
    "profile": [("list", "List profiles"), ("show", "Show profile"), ("apply", "Apply profile")],
    "ui": [("theme", "Set interactive theme"), ("open", "Launch cockpit UI")],
    "session": [
        ("preview", "Toggle preview mode"),
        ("diff", "Toggle unified diff"),
        ("yes", "Toggle overwrite auto-approval"),
        ("stats", "Toggle timing stats"),
        ("quiet", "Toggle quiet mode"),
    ],
    "theme": [
        ("auto", "Auto-detect theme"),
        ("light", "Light theme"),
        ("dark", "Dark theme"),
        ("minimal", "High-contrast theme"),
        ("dense", "Dense theme"),
    ],
    "debug": [("on", "Enable debug logging"), ("off", "Disable debug logging")],
    "more": [
        ("history", "History tools"),
        ("profile", "Profiles"),
        ("ui", "UI actions"),
        ("session", "Session toggles"),
        ("debug", "Toggle debug logging"),
    ],
}


class UserCancelled(Exception):
    """Raised when the user exits an interactive flow."""


def _looks_like_slash_command(token: str) -> bool:
    if not token.startswith("/") or token == "/":
        return False
    ext = os.path.splitext(token)[1].lower()
    if ext in {".pdf", ".docx", ".csv", *IMAGE_EXTENSIONS}:
        return False
    name_value = token[1:]
    name = name_value.split(":", 1)[0].strip().lower()
    return name in SLASH_COMMAND_NAMES




def _slash_command_candidates(prefix: str) -> list[str]:
    if not prefix.startswith("/"):
        return []
    if " " in prefix.strip():
        return []
    if prefix == "/":
        return CORE_SLASH_COMMANDS
    lowered = prefix.lower()
    return [command for command in SLASH_COMMANDS if command.startswith(lowered)]


def _slash_command_items(prefix: str) -> list[tuple[str, str]]:
    return [
        (command, SLASH_COMMAND_DETAILS.get(command, ""))
        for command in _slash_command_candidates(prefix)
    ]


def _split_slash_text(text: str) -> tuple[str, str]:
    if not text.startswith("/"):
        raise ValueError("Not a slash command.")
    stripped = text[1:]
    if not stripped:
        raise ValueError("Missing command.")
    name, _, rest = stripped.partition(" ")
    return name.strip().lower(), rest.lstrip()


def _slash_option_items(text: str) -> list[tuple[str, str]]:
    if not text.startswith("/") or text == "/":
        return []
    try:
        name, arg = _split_slash_text(text)
    except ValueError:
        return []
    options = SLASH_COMMAND_OPTIONS.get(name)
    if not options:
        return []

    def filter_options(items: list[tuple[str, str]], needle: str) -> list[tuple[str, str]]:
        if not needle:
            return items
        lowered = needle.lower()
        return [option for option in items if option[0].lower().startswith(lowered)]

    if name == "ocr":
        if not arg:
            return options
        tokens = arg.split()
        head = tokens[0].lower() if tokens else ""
        rest = " ".join(tokens[1:]) if len(tokens) > 1 else ""
        if head in {"mode"}:
            return filter_options(
                [("never", "Disable OCR"), ("auto", "OCR only when PDF text is empty"), ("always", "Always OCR PDFs")],
                rest,
            )
        if head in {"lang"}:
            return filter_options([("es", "Spanish"), ("en", "English")], rest)
        if head in {"device"}:
            return filter_options([("auto", "Let OCR choose"), ("cpu", "CPU"), ("gpu:0", "First GPU")], rest)
        if head in {"render-scale"}:
            return filter_options([("2.0", "Default"), ("1.0", "Faster"), ("3.0", "Higher quality")], rest)
        if head in {"min-score"}:
            return filter_options([("off", "No filtering"), ("0.5", "Medium"), ("0.8", "Strict")], rest)
        return filter_options(options, arg)

    if name == "history":
        if not arg:
            return options
        tokens = arg.split()
        head = tokens[0].lower() if tokens else ""
        rest = " ".join(tokens[1:]) if len(tokens) > 1 else ""
        if head == "recent":
            return filter_options([("inputs", "Recent inputs"), ("projects", "Recent projects"), ("all", "Both")], rest)
        return filter_options(options, arg)

    if name == "ui":
        if not arg:
            return options
        tokens = arg.split()
        head = tokens[0].lower() if tokens else ""
        rest = " ".join(tokens[1:]) if len(tokens) > 1 else ""
        if head == "theme":
            return filter_options(SLASH_COMMAND_OPTIONS.get("theme", []), rest)
        return filter_options(options, arg)

    if name == "session":
        if not arg:
            return options
        tokens = arg.split()
        head = tokens[0].lower() if tokens else ""
        rest = " ".join(tokens[1:]) if len(tokens) > 1 else ""
        if head in {"preview", "diff", "yes", "stats", "quiet"}:
            return filter_options([("on", "Enable"), ("off", "Disable")], rest)
        return filter_options(options, arg)

    if name == "profile":
        return filter_options(options, arg)

    return filter_options(options, arg)


def _history_path() -> Path:
    return Path.home() / ".doc2md_history"


def _clamp_index(index: int, total: int) -> int:
    if total <= 0:
        return 0
    return max(0, min(index, total - 1))


def _command_buffer_text(command: str) -> str:
    if command in {
        "/format",
        "/engine",
        "/output",
        "/more",
        "/ocr",
        "/profile",
        "/history",
        "/ui",
        "/session",
        "/debug",
    }:
        return f"{command} "
    return command


def _parse_toggle(arg: Optional[str], current: bool) -> tuple[Optional[bool], Optional[str]]:
    if not arg:
        return (not current, None)
    value = arg.strip().lower()
    if value in {"on", "true", "1", "yes"}:
        return (True, None)
    if value in {"off", "false", "0", "no"}:
        return (False, None)
    return (None, "Usage: <on|off>")


def _render_banner_text(
    title: str,
    version: str,
    cwd: Optional[Path],
    *,
    width: int,
) -> list[tuple[str, str]]:
    width = max(50, width)
    project = cwd.name if cwd else "project"

    def fill_line(left: list[tuple[str, str]], right: list[tuple[str, str]]) -> list[tuple[str, str]]:
        left_len = sum(len(text) for _, text in left)
        right_len = sum(len(text) for _, text in right)
        if left_len + right_len >= width:
            return left
        gap = width - left_len - right_len
        return [*left, ("class:banner.rule", " " * gap), *right]

    line1_left: list[tuple[str, str]] = [
        ("class:banner.accent", f" {title} "),
        ("class:pill.label", " Project "),
        ("class:pill.value", f" {project} "),
        ("class:pill.label", " Version "),
        ("class:pill.value", f" v{version} "),
    ]
    line1 = fill_line(line1_left, [])

    line2_left_text = " Convert PDFs, DOCX, CSV -> Markdown "
    line2_right = [
        ("class:keycap", "[Enter]"),
        ("class:banner.meta", " Convert "),
        ("class:keycap", "[/]"),
        ("class:banner.meta", " Commands "),
        ("class:keycap", "[/more]"),
        ("class:banner.meta", " Advanced "),
    ]
    right_len = sum(len(text) for _, text in line2_right)
    if right_len >= width:
        remaining = width
        truncated_right: list[tuple[str, str]] = []
        for style, text in line2_right:
            if remaining <= 0:
                break
            chunk = text[:remaining]
            truncated_right.append((style, chunk))
            remaining -= len(chunk)
        line2 = truncated_right
    else:
        available = width - right_len
        left_text = line2_left_text[:available]
        padding = max(0, available - len(left_text))
        left_frag = [("class:banner.meta", left_text + (" " * padding))]
        line2 = [*left_frag, *line2_right]
    rule = [("class:banner.rule", "-" * width)]
    return [*line1, ("", "\n"), *line2, ("", "\n"), *rule]


def _render_hero_text(
    title: str,
    version: str,
    cwd: Optional[Path],
    output_label: str,
    recent_lines: Optional[list[str]] = None,
    *,
    width: int,
) -> list[tuple[str, str]]:
    width = max(40, width)
    project = cwd.name if cwd else "project"
    inner_width = width - 2
    separator = " | "
    min_col = 16
    if inner_width < (min_col * 2 + len(separator)):
        left_width = max(0, (inner_width - len(separator)) // 2)
        right_width = max(0, inner_width - len(separator) - left_width)
    else:
        left_width = (inner_width - len(separator)) // 2
        right_width = inner_width - len(separator) - left_width

    def _pad(text: str, limit: int) -> str:
        if limit <= 0:
            return ""
        if len(text) > limit:
            return text[:limit]
        return text.ljust(limit)

    top_label = f"{title} v{version}"
    top_text = f" {top_label} "
    if len(top_text) > inner_width:
        top_text = top_text[:inner_width]

    recent_lines = recent_lines or ["No recent activity yet"]
    if len(recent_lines) > 2:
        recent_lines = recent_lines[:2]

    lines: list[tuple[str, str]] = [
        ("class:card.border", "+"),
        ("class:card.title", top_text),
        ("class:card.border", "-" * (inner_width - len(top_text))),
        ("class:card.border", "+"),
        ("", "\n"),
    ]

    left_lines = [
        "Welcome back!",
        " _____  _____    *",
        "|     ||     |  \\|/",
        "|  *  ||  *  | --*--",
        "|_____| |____|  /|\\",
        f"Project: {project}",
        f"Out: {output_label}",
    ]
    left_styles = [
        "class:card.accent",
        "class:card.logo",
        "class:card.logo",
        "class:card.logo",
        "class:card.logo",
        "class:card.text",
        "class:card.muted",
    ]
    right_lines = [
        "Tips for getting started",
        "Run /files to select documents",
        "Type / for commands",
        "Use /more for advanced tools",
        "Recent activity",
        *recent_lines,
    ]
    right_styles = [
        "class:card.section",
        "class:card.text",
        "class:card.text",
        "class:card.text",
        "class:card.section",
        *["class:card.muted"] * len(recent_lines),
    ]

    total_lines = max(len(left_lines), len(right_lines))
    for idx in range(total_lines):
        left_text = left_lines[idx] if idx < len(left_lines) else ""
        right_text = right_lines[idx] if idx < len(right_lines) else ""
        left_style = left_styles[idx] if idx < len(left_styles) else "class:card.text"
        right_style = right_styles[idx] if idx < len(right_styles) else "class:card.text"
        lines.extend(
            [
                ("class:card.border", "|"),
                (left_style, _pad(left_text, left_width)),
                ("class:card.border", separator),
                (right_style, _pad(right_text, right_width)),
                ("class:card.border", "|"),
                ("", "\n"),
            ]
        )

    lines.extend(
        [
            ("class:card.border", "+"),
            ("class:card.border", "-" * inner_width),
            ("class:card.border", "+"),
        ]
    )
    return lines


def _recent_activity_lines(*, history_path: Optional[Path] = None) -> list[str]:
    path = history_path if history_path is not None else history_db.history_db_path()
    if path is None:
        return ["No recent activity yet"]
    if not path.exists():
        return ["No recent activity yet"]
    try:
        runs = history_db.list_runs(path, limit=1)
    except Exception:
        return ["Recent activity unavailable"]
    if not runs:
        return ["No recent activity yet"]
    run = runs[0]
    run_id = run.get("id", "?")
    status = run.get("status") or "unknown"
    count = run.get("input_count") or 0
    fmt = run.get("format") or "auto"
    engine = run.get("engine") or "auto"
    summary = f"Last run #{run_id}: {status} - {count} file(s)"
    detail = f"{fmt}/{engine}"
    return [summary, detail]

def _resolve_theme_name(theme: str, color_depth: Optional[int]) -> str:
    if theme != "auto":
        return theme
    if color_depth is None:
        return "dark"
    if isinstance(color_depth, str):
        lowered = color_depth.lower()
        if "1_bit" in lowered or "4_bit" in lowered:
            return "minimal"
        return "dark"
    try:
        if int(color_depth) <= 4:
            return "minimal"
    except (TypeError, ValueError):
        return "dark"
    return "dark"


def _theme_style(theme: str) -> dict[str, str]:
    return THEMES.get(theme, THEMES["dark"])


def _build_ui_app(
    args: argparse.Namespace,
    debug: Callable[[str], None],
    *,
    input=None,
    output=None,
):
    try:
        from prompt_toolkit.application import Application
        from prompt_toolkit.key_binding import KeyBindings
        from prompt_toolkit.layout import Dimension, HSplit, Layout, VSplit, Window
        from prompt_toolkit.layout.controls import FormattedTextControl
        from prompt_toolkit.clipboard import ClipboardData
        from prompt_toolkit.styles import Style
        from prompt_toolkit.widgets import Frame
    except Exception as exc:
        raise RuntimeError("prompt_toolkit is required") from exc

    state = {"mode": "ui", "status_lines": ["Ready."], "output_lines": []}
    recent_activity = {"lines": _recent_activity_lines()}

    def _pane_text(lines: list[str]) -> str:
        return "\n".join(lines) if lines else ""

    def _header_text():
        width = 80
        try:
            out = output
            if out is not None:
                width = max(40, out.get_size().columns)
        except Exception:
            width = 80
        return _render_hero_text(
            "documint2md",
            __version__,
            Path.cwd(),
            _output_label(args.output),
            recent_activity["lines"],
            width=width,
        )

    def _box(title: str, text_fn, height: Optional[int] = None):
        window = Window(
            content=FormattedTextControl(text=text_fn),
            height=height,
            wrap_lines=True,
        )
        return Frame(window, title=title)

    left = HSplit(
        [
            _box(
                "Tasks",
                lambda: "Drop files into docs_in or use /files in interactive mode.",
            ),
            _box(
                "History / Search",
                lambda: "Keys: H history, S search, J jump, N recent",
            ),
        ]
    )
    middle = HSplit(
        [
            _box("Preview", lambda: _pane_text(state["output_lines"])),
            _box("Profiles", lambda: "doc2md profile list/show/apply"),
        ]
    )
    right = HSplit(
        [
            _box("Run Log / Status", lambda: _pane_text(state["status_lines"])),
            _box(
                "Actions",
                lambda: "R: rerun  D: diff  O: outputs  C: copy path  U: UI exit",
            ),
        ]
    )
    body_container = VSplit(
        [left, Window(width=1, char="|"), middle, Window(width=1, char="|"), right]
    )
    header_window = Window(
        content=FormattedTextControl(_header_text),
        height=9,
        style="class:banner",
    )
    root_container = HSplit([header_window, body_container])

    kb = KeyBindings()

    def _history_db_path() -> Optional[Path]:
        path = history_db.history_db_path()
        if path is None:
            state["status_lines"] = ["History is disabled."]
            return None
        if not path.exists():
            state["status_lines"] = ["History database not found."]
            return None
        return path

    def _set_status(lines: list[str]) -> None:
        state["status_lines"] = lines

    def _set_output(lines: list[str]) -> None:
        state["output_lines"] = lines

    def _format_timings(run: dict) -> list[str]:
        timings = run.get("timings") or []
        if not timings:
            return ["No timing data recorded."]
        lines = ["Timings:"]
        for item in timings:
            lines.append(f"  {item['stage']}: {item['duration_ms']:.2f} ms")
        return lines

    def _load_latest_run() -> Optional[dict]:
        db_path = _history_db_path()
        if db_path is None:
            return None
        run = _latest_run(db_path)
        if run is None:
            _set_status(["No runs in history."])
            return None
        return run

    def _rerun_latest() -> None:
        run = _load_latest_run()
        if run is None:
            return
        options = _run_options(run)
        inputs = [Path(item["path"]) for item in run["inputs"]]
        for path in inputs:
            if not path.exists():
                _set_status([f"Input not found: {path}"])
                return
        rerun_args = _rerun_args(options, args)
        exit_code = _run_conversion(inputs, rerun_args, debug)
        db_path = _history_db_path()
        latest = _latest_run(db_path) if db_path else None
        status_lines = [f"Rerun exit code: {exit_code}"]
        if latest:
            status_lines.extend(_format_timings(latest))
            if latest.get("warnings"):
                status_lines.append("Warnings:")
                status_lines.extend(
                    f"  {item['code']}: {item['message']}"
                    for item in latest["warnings"]
                )
        _set_status(status_lines)

    def _diff_latest() -> None:
        run = _load_latest_run()
        if run is None:
            return
        lines = _ui_diff_for_run(run)
        _set_output(lines)
        _set_status(["Diff loaded for latest run."])

    def _show_outputs() -> None:
        run = _load_latest_run()
        if run is None:
            return
        outputs = [item["path"] for item in run.get("outputs", []) if item.get("path")]
        if not outputs:
            _set_status(["No outputs recorded for run."])
            return
        _set_output(outputs)
        _set_status(["Output paths listed in preview pane."])

    def _copy_output_path() -> None:
        run = _load_latest_run()
        if run is None:
            return
        outputs = [item["path"] for item in run.get("outputs", []) if item.get("path")]
        if not outputs:
            _set_status(["No outputs recorded for run."])
            return
        try:
            app.clipboard.set_data(ClipboardData(outputs[0]))
            _set_status([f"Copied: {outputs[0]}"])
        except Exception as exc:
            _set_status([f"Clipboard failed: {exc}"])

    @kb.add("escape")
    @kb.add("c-c")
    @kb.add("c-q")
    def _exit(event):
        event.app.exit(result="exit")

    @kb.add("r")
    def _rerun(event):
        _rerun_latest()
        event.app.invalidate()

    @kb.add("d")
    def _diff(event):
        _diff_latest()
        event.app.invalidate()

    @kb.add("o")
    def _outputs(event):
        _show_outputs()
        event.app.invalidate()

    @kb.add("c")
    def _copy(event):
        _copy_output_path()
        event.app.invalidate()

    @kb.add("h")
    def _history(event):
        run = _load_latest_run()
        if run is None:
            return
        _set_status(["Latest run selected. Press R to rerun or D to diff."])
        event.app.invalidate()

    @kb.add("s")
    def _search(event):
        _set_status(["Use /search in interactive mode for full search."])
        event.app.invalidate()

    @kb.add("j")
    def _jump(event):
        _set_status(["Use /jump in interactive mode for recent inputs."])
        event.app.invalidate()

    @kb.add("n")
    def _recent(event):
        _set_status(["Use /recent in interactive mode for recent inputs/projects."])
        event.app.invalidate()

    if output is None:
        from prompt_toolkit.output import create_output

        output = create_output()
    color_depth = None
    try:
        color_depth = output.get_default_color_depth()
    except Exception as exc:
        debug(f"UI color depth unavailable: {exc}")
        color_depth = None
    color_depth_value = getattr(color_depth, "value", color_depth)
    theme_name = _resolve_theme_name(args.theme, color_depth_value)
    style = Style.from_dict(_theme_style(theme_name))

    app = Application(
        layout=Layout(root_container),
        key_bindings=kb,
        full_screen=True,
        style=style,
        input=input,
        output=output,
    )
    return app, state


def _render_command_list(
    items: list[tuple[str, str]],
    selected: int,
    *,
    max_visible: Optional[int] = None,
) -> list[tuple[str, str]]:
    if not items:
        return [("class:hint", "No matching commands.")]
    try:
        from prompt_toolkit.application.current import get_app
        width = max(40, get_app().output.get_size().columns)
    except Exception:
        width = 80
    total = len(items)
    max_visible = max_visible or total
    max_visible = max(1, min(max_visible, total))
    if total <= max_visible:
        start = 0
    else:
        start = max(0, min(selected - max_visible // 2, total - max_visible))
    end = min(total, start + max_visible)
    visible = items[start:end]
    name_width = max(10, max(len(command.lstrip("/")) for command, _ in items))
    lines: list[tuple[str, str]] = []
    lines.append(("class:menu.spacer", " " * width))
    lines.append(("", "\n"))

    def add_indicator(label: str) -> None:
        text = f"  {label}"
        padding = max(0, width - len(text))
        lines.append(("class:menu.meta", text))
        lines.append(("class:menu.meta", " " * padding))
        lines.append(("", "\n"))

    if start > 0:
        add_indicator("...")
    for idx, (command, description) in enumerate(visible, start=start):
        is_selected = idx == selected
        prefix = "> " if is_selected else "  "
        name = command.lstrip("/")
        line_style = "class:menu.selected" if is_selected else "class:menu"
        left = f"{prefix}{name:<{name_width}}  "
        main = f"{left}{description}"
        padding = max(0, width - len(main))
        lines.append((line_style, main))
        lines.append((line_style, " " * padding))
        if idx < end - 1:
            lines.append(("", "\n"))
    if end < total:
        lines.append(("", "\n"))
        add_indicator("...")
    lines.append(("class:menu.spacer", " " * width))
    return lines


def _render_output_list(lines: list[str]) -> list[tuple[str, str]]:
    if not lines:
        return [("class:hint", "No results.")]
    try:
        from prompt_toolkit.application.current import get_app
        width = max(40, get_app().output.get_size().columns)
    except Exception:
        width = 80
    rendered: list[tuple[str, str]] = []
    rendered.append(("class:menu.spacer", " " * width))
    rendered.append(("", "\n"))
    for idx, line in enumerate(lines):
        text = line.rstrip("\n")
        padding = max(0, width - len(text))
        rendered.append(("class:menu", text))
        rendered.append(("class:menu", " " * padding))
        if idx < len(lines) - 1:
            rendered.append(("", "\n"))
    rendered.append(("", "\n"))
    rendered.append(("class:menu.spacer", " " * width))
    return rendered


def _render_file_list(files: list[Path], selected_index: int, selected_files: set[Path]) -> list[tuple[str, str]]:
    if not files:
        return [("class:hint", "No files found in docs_in.")]
    try:
        from prompt_toolkit.application.current import get_app
        width = max(40, get_app().output.get_size().columns)
    except Exception:
        width = 80
    lines: list[tuple[str, str]] = []
    lines.append(("class:menu.spacer", " " * width))
    lines.append(("", "\n"))
    for idx, path in enumerate(files):
        is_selected = idx == selected_index
        prefix = "> " if is_selected else "  "
        checked = "[x]" if path in selected_files else "[ ]"
        name = path.name
        line_style = "class:menu.selected" if is_selected else "class:menu"
        main = f"{prefix}{checked} {name}"
        padding = max(0, width - len(main))
        lines.append((line_style, main))
        lines.append((line_style, " " * padding))
        if idx < len(files) - 1:
            lines.append(("", "\n"))
    lines.append(("", "\n"))
    lines.append(("class:menu.spacer", " " * width))
    return lines


def _command_count_text(items: list[tuple[str, str]], selected: int) -> str:
    total = len(items)
    if total <= 0:
        return "(0/0)"
    return f"▾  ({selected + 1}/{total})"


def _file_count_text(files: list[Path], selected_files: set[Path]) -> str:
    total = len(files)
    if total <= 0:
        return "(0/0)"
    return f"▾  ({len(selected_files)}/{total})"


def _progress_bar_text(current: int, total: int, width: int = 24) -> str:
    if total <= 0:
        return "[------------------------] 0%"
    clamped = max(0, min(current, total))
    percent = int(round((clamped / total) * 100))
    bar_width = max(8, width)
    filled = int(round((clamped / total) * bar_width))
    filled = max(0, min(filled, bar_width))
    bar = "#" * filled + "-" * (bar_width - filled)
    return f"[{bar}] {percent}%"


def _help_lines() -> list[str]:
    return [
        "documint2md interactive help",
        "",
        "Commands",
        "  /help                         Show this help screen",
        "  /files                        Select files from docs_in",
        "  /format <pdf|docx|csv|image|auto>    Set or clear format override",
        "  /engine <pdfminer|pdftext|marker>  Set PDF engine",
        "  /output <path|->               Output file/dir or stdout",
        "  /ocr <mode|...>                OCR controls (mode/lang/device/etc)",
        "  /more                          Advanced commands index",
        "  /history <list|search|rerun|explain|jump|recent> ...",
        "  /profile <list|show|apply> ...  Profile operations",
        "  /ui <open|theme> ...           UI actions (theme/open)",
        "  /session <preview|diff|yes|stats|quiet> <on|off>",
        "  /debug <on|off>                 Toggle debug logging",
        "  /exit                          Exit interactive mode",
        "",
        "Tips",
        "  Type / to open the command list and filter it.",
        "  In /files: Up/Down move, Space toggles, Enter converts.",
        "  Ctrl+P/Ctrl+N navigate history. Esc exits.",
        "  PageUp/PageDown scroll. Home/End jump to top/bottom.",
        "  Footer shows current settings and confirmations.",
    ]


def _render_help_text() -> list[tuple[str, str]]:
    try:
        from prompt_toolkit.application.current import get_app
        width = max(40, get_app().output.get_size().columns)
    except Exception:
        width = 80

    lines: list[tuple[str, str]] = []

    def add_line(text: str, style: str = "class:help.text") -> None:
        padding = max(0, width - len(text))
        lines.append((style, text))
        lines.append((style, " " * padding))
        lines.append(("", "\n"))

    help_lines = _help_lines()
    for line in help_lines:
        style = "class:help.text"
        if line == "documint2md interactive help":
            style = "class:help.title"
        elif line in {"Commands", "Tips"}:
            style = "class:help.section"
        add_line(line, style)
    lines.append(("class:help.spacer", " " * width))
    return lines


def _output_label(output: Optional[str]) -> str:
    if output is None:
        return "docs_out"
    if output == "-":
        return "stdout"
    try:
        name = Path(output).name
    except Exception:
        return output
    return name or output


def _settings_summary(args: argparse.Namespace) -> str:
    fmt = args.format or "auto"
    engine = args.engine
    output = _output_label(args.output)
    ocr_mode = args.ocr_mode
    flags: list[str] = []
    if getattr(args, "preview", False):
        flags.append("Prev")
    if getattr(args, "diff", False):
        flags.append("Diff")
    if getattr(args, "yes", False):
        flags.append("Yes")
    if getattr(args, "stats", False):
        flags.append("Stats")
    if getattr(args, "quiet", False):
        flags.append("Quiet")
    flag_text = ""
    if flags:
        flag_text = " " + " ".join(flags)
    if ocr_mode != "never":
        return (
            f"Fmt:{fmt} Eng:{engine} OCR:{ocr_mode} Lang:{args.ocr_lang} "
            f"Out:{output}{flag_text}"
        )
    return f"Fmt:{fmt} Eng:{engine} Out:{output}{flag_text}"


def _default_options_for_format(fmt: str) -> dict:
    defaults = {
        "pdf": {"engine": "pdfminer"},
        "docx": {},
        "csv": {"csv_na": "", "csv_float_format": None},
        "image": {},
    }
    return defaults.get(fmt, {})


def _format_stats_line(record: dict) -> str:
    name = Path(record["input"]).name
    stages = record["stages_ms"]
    return (
        "Stats: "
        f"{name} "
        f"detect_ms={stages.get('detect', 0):.2f} "
        f"convert_ms={stages.get('convert', 0):.2f} "
        f"write_ms={stages.get('write', 0):.2f} "
        f"total_ms={record.get('total_ms', 0):.2f}"
    )


def _diff_text(path: Path, new_content: str) -> str:
    try:
        old_content = path.read_text(encoding="utf-8")
    except Exception:
        old_content = path.read_text(encoding="utf-8", errors="replace")
    diff = difflib.unified_diff(
        old_content.splitlines(keepends=True),
        new_content.splitlines(keepends=True),
        fromfile=str(path),
        tofile=f"{path}.new",
    )
    return "".join(diff)


def _history_options(args: argparse.Namespace) -> dict:
    return {
        "format": args.format,
        "engine": args.engine,
        "output": args.output,
        "preview": args.preview,
        "diff": args.diff,
        "yes": args.yes,
        "stats": args.stats,
        "profile_report": args.profile_report,
        "theme": args.theme,
        "csv_na": args.csv_na,
        "csv_float_format": args.csv_float_format,
        "ocr_mode": args.ocr_mode,
        "ocr_lang": args.ocr_lang,
        "ocr_device": args.ocr_device,
        "ocr_render_scale": args.ocr_render_scale,
        "ocr_min_score": args.ocr_min_score,
        "quiet": args.quiet,
        "debug": args.debug,
    }


def _init_history_logger(debug: Callable[[str], None]) -> Optional[history_db.HistoryLogger]:
    path = history_db.history_db_path()
    if path is None:
        return None
    try:
        return history_db.HistoryLogger(path)
    except Exception as exc:
        debug(f"History disabled: {exc}")
        return None


def _history_command_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description="documint2md history commands",
        add_help=True,
    )
    parser.add_argument("command", choices=sorted(HISTORY_COMMANDS))
    parser.add_argument("query", nargs="?", help="Search query or run id")
    parser.add_argument("--json", action="store_true", help="Emit JSON output")
    parser.add_argument("--limit", type=int, default=20, help="Limit result count")
    parser.add_argument(
        "--rerun",
        action="store_true",
        help="When used with search, rerun the top match",
    )
    parser.add_argument("--keep", type=int, default=None, help="Keep N runs (history prune)")
    return parser


def _profile_path() -> Path:
    return Path.cwd() / "doc2md.toml"


def _load_profiles(path: Path) -> dict:
    if not path.exists():
        raise FileNotFoundError(path)
    with open(path, "rb") as handle:
        data = tomllib.load(handle)
    profiles = data.get("profiles") or data.get("profile")
    if profiles is None:
        return {}
    if not isinstance(profiles, dict):
        raise ValueError("Profiles section must be a table.")
    return profiles


def _apply_profile_to_args(args: argparse.Namespace, profile: dict) -> None:
    def set_if_default(attr: str, default, value):
        if getattr(args, attr) == default and value is not None:
            setattr(args, attr, value)

    set_if_default("format", None, profile.get("format"))
    set_if_default("output", None, profile.get("output"))
    set_if_default("engine", "pdfminer", profile.get("engine"))
    set_if_default("csv_na", "", profile.get("csv_na"))
    set_if_default("csv_float_format", None, profile.get("csv_float_format"))
    set_if_default("ocr", False, profile.get("ocr"))
    set_if_default("ocr_mode", "never", profile.get("ocr_mode"))
    set_if_default("ocr_lang", "es", profile.get("ocr_lang"))
    set_if_default("ocr_device", None, profile.get("ocr_device"))
    set_if_default("ocr_render_scale", 2.0, profile.get("ocr_render_scale"))
    set_if_default("ocr_min_score", None, profile.get("ocr_min_score"))
    set_if_default("theme", "auto", profile.get("theme"))

    if not args.preview and profile.get("preview"):
        args.preview = True
    if not args.diff and profile.get("diff"):
        args.diff = True
    if not args.yes and profile.get("yes"):
        args.yes = True
    if not args.quiet and profile.get("quiet"):
        args.quiet = True
    if not args.debug and profile.get("debug"):
        args.debug = True


def _profile_command_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description="documint2md profile commands",
        add_help=True,
    )
    parser.add_argument("command", choices=["list", "show", "apply"])
    parser.add_argument("name", nargs="?", help="Profile name")
    parser.add_argument("--json", action="store_true", help="Emit JSON output")
    return parser


def _run_profile_command(argv: list[str]) -> Optional[int]:
    if not argv or argv[0] not in PROFILE_COMMANDS:
        return None
    parser = _profile_command_parser()
    try:
        args = parser.parse_args(argv[1:])
    except SystemExit as exc:
        return int(exc.code)
    path = _profile_path()
    try:
        profiles = _load_profiles(path)
    except FileNotFoundError:
        sys.stderr.write("Profile file not found.\n")
        return 2
    except tomllib.TOMLDecodeError as exc:
        sys.stderr.write(f"Profile file is invalid TOML: {exc}\n")
        return 2
    except ValueError as exc:
        sys.stderr.write(f"{exc}\n")
        return 2
    if args.command == "list":
        names = sorted(profiles.keys())
        if args.json:
            sys.stdout.write(json.dumps(names, sort_keys=True))
            return 0
        for name in names:
            sys.stdout.write(f"{name}\n")
        return 0
    if args.command == "show":
        if not args.name:
            sys.stderr.write("Profile name is required.\n")
            return 2
        profile = profiles.get(args.name)
        if profile is None:
            sys.stderr.write("Profile not found.\n")
            return 2
        if args.json:
            sys.stdout.write(json.dumps(profile, sort_keys=True))
            return 0
        for key in sorted(profile.keys()):
            sys.stdout.write(f"{key}={profile[key]}\n")
        return 0
    if args.command == "apply":
        if not args.name:
            sys.stderr.write("Profile name is required.\n")
            return 2
        base = profiles.get("default", {})
        profile = profiles.get(args.name)
        if profile is None:
            sys.stderr.write("Profile not found.\n")
            return 2
        merged = {}
        if isinstance(base, dict):
            merged.update(base)
        if isinstance(profile, dict):
            merged.update(profile)
        if args.json:
            sys.stdout.write(json.dumps(merged, sort_keys=True))
            return 0
        for key in sorted(merged.keys()):
            sys.stdout.write(f"{key}={merged[key]}\n")
        return 0
    sys.stderr.write("Unknown profile command.\n")
    return 2


def _jump_command_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description="documint2md jump command",
        add_help=True,
    )
    parser.add_argument("query", nargs="?", help="Search query")
    parser.add_argument("--json", action="store_true", help="Emit JSON output")
    parser.add_argument("--limit", type=int, default=10, help="Limit result count")
    return parser


def _run_jump_command(argv: list[str]) -> Optional[int]:
    if not argv or argv[0] not in JUMP_COMMANDS:
        return None
    parser = _jump_command_parser()
    try:
        args = parser.parse_args(argv[1:])
    except SystemExit as exc:
        return int(exc.code)
    db_path = history_db.history_db_path()
    if db_path is None:
        sys.stderr.write("History is disabled.\n")
        return 2
    if not db_path.exists():
        sys.stderr.write("History database not found.\n")
        return 2
    items = history_db.list_input_paths(db_path, args.query, args.limit)
    if args.json:
        sys.stdout.write(json.dumps(items, sort_keys=True))
        return 0
    for item in items:
        sys.stdout.write(f"{item['path']}\n")
    return 0


def _recent_command_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description="documint2md recent command",
        add_help=True,
    )
    parser.add_argument("--json", action="store_true", help="Emit JSON output")
    parser.add_argument("--limit", type=int, default=20, help="Limit result count")
    parser.add_argument("--inputs", action="store_true", help="Show recent inputs")
    parser.add_argument("--projects", action="store_true", help="Show recent projects")
    return parser


def _run_recent_command(argv: list[str]) -> Optional[int]:
    if not argv or argv[0] not in RECENT_COMMANDS:
        return None
    parser = _recent_command_parser()
    try:
        args = parser.parse_args(argv[1:])
    except SystemExit as exc:
        return int(exc.code)
    db_path = history_db.history_db_path()
    if db_path is None:
        sys.stderr.write("History is disabled.\n")
        return 2
    if not db_path.exists():
        sys.stderr.write("History database not found.\n")
        return 2
    want_inputs = args.inputs or not args.projects
    want_projects = args.projects or not args.inputs
    inputs = (
        history_db.list_recent_inputs(db_path, args.limit) if want_inputs else []
    )
    projects = (
        history_db.list_recent_projects(db_path, args.limit)
        if want_projects
        else []
    )
    if args.json:
        if want_inputs and not want_projects:
            sys.stdout.write(json.dumps(inputs, sort_keys=True))
            return 0
        if want_projects and not want_inputs:
            sys.stdout.write(json.dumps(projects, sort_keys=True))
            return 0
        sys.stdout.write(
            json.dumps({"inputs": inputs, "projects": projects}, sort_keys=True)
        )
        return 0
    if want_inputs:
        sys.stdout.write("Recent inputs:\n")
        for item in inputs:
            sys.stdout.write(f"{item['path']}\n")
        if want_projects:
            sys.stdout.write("\n")
    if want_projects:
        sys.stdout.write("Recent projects:\n")
        for item in projects:
            sys.stdout.write(f"{item['path']}\n")
    return 0


def _explain_command_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description="documint2md explain command",
        add_help=True,
    )
    parser.add_argument("run_id", help="History run id")
    parser.add_argument("--json", action="store_true", help="Emit JSON output")
    return parser


def _run_explain_command(argv: list[str]) -> Optional[int]:
    if not argv or argv[0] not in EXPLAIN_COMMANDS:
        return None
    parser = _explain_command_parser()
    try:
        args = parser.parse_args(argv[1:])
    except SystemExit as exc:
        return int(exc.code)
    db_path = history_db.history_db_path()
    if db_path is None:
        sys.stderr.write("History is disabled.\n")
        return 2
    if not db_path.exists():
        sys.stderr.write("History database not found.\n")
        return 2
    try:
        run_id = int(args.run_id)
    except ValueError:
        sys.stderr.write("Run id must be an integer.\n")
        return 2
    run = history_db.load_run(db_path, run_id)
    if run is None:
        sys.stderr.write("Run not found.\n")
        return 2
    try:
        options = json.loads(run["options_json"] or "{}")
    except Exception:
        options = {}
    payload = {
        "id": run["id"],
        "status": run["status"],
        "started_at": run["started_at"],
        "finished_at": run["finished_at"],
        "engine": run["engine"],
        "format": run["format"],
        "exit_code": run["exit_code"],
        "cwd": run["cwd"],
        "version": run["version"],
        "options": options,
        "inputs": run["inputs"],
        "outputs": run["outputs"],
        "warnings": run["warnings"],
        "timings": run["timings"],
    }
    if args.json:
        sys.stdout.write(json.dumps(payload, sort_keys=True))
        return 0
    sys.stdout.write(f"Run {run['id']} ({run['status']})\n")
    sys.stdout.write(f"Started: {run['started_at']}\n")
    if run["finished_at"]:
        sys.stdout.write(f"Finished: {run['finished_at']}\n")
    sys.stdout.write(f"Exit code: {run['exit_code']}\n")
    sys.stdout.write(f"Engine: {run['engine']}\n")
    sys.stdout.write(f"Format: {run['format']}\n")
    sys.stdout.write(f"CWD: {run['cwd']}\n")
    if run["version"]:
        sys.stdout.write(f"Version: {run['version']}\n")
    if options:
        sys.stdout.write("Options:\n")
        for key in sorted(options.keys()):
            sys.stdout.write(f"  {key}={options[key]}\n")
    sys.stdout.write("Inputs:\n")
    for item in run["inputs"]:
        sys.stdout.write(f"  {item['path']}\n")
    sys.stdout.write("Outputs:\n")
    for item in run["outputs"]:
        sys.stdout.write(f"  {item['path']}\n")
    if run["warnings"]:
        sys.stdout.write("Warnings:\n")
        for item in run["warnings"]:
            sys.stdout.write(f"  {item['code']}: {item['message']}\n")
    return 0


def _ui_command_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description="documint2md UI command",
        add_help=True,
    )
    parser.add_argument(
        "--theme",
        choices=THEME_NAMES,
        default="auto",
        help="Interactive UI theme (auto, light, dark, minimal, dense)",
    )
    return parser


def _run_ui_command(argv: list[str]) -> Optional[int]:
    if not argv or argv[0] not in UI_COMMANDS:
        return None
    parser = _ui_command_parser()
    try:
        args = parser.parse_args(argv[1:])
    except SystemExit as exc:
        return int(exc.code)

    def debug(_msg: str) -> None:
        return

    try:
        app, _state = _build_ui_app(args, debug)
    except RuntimeError:
        sys.stderr.write("UI mode requires prompt_toolkit.\n")
        return 2
    try:
        app.run()
    except (EOFError, KeyboardInterrupt):
        return 0
    return 0


def _run_history_command(argv: list[str]) -> Optional[int]:
    if not argv or argv[0] not in HISTORY_COMMANDS:
        return None
    parser = _history_command_parser()
    try:
        args = parser.parse_args(argv)
    except SystemExit as exc:
        return int(exc.code)
    db_path = history_db.history_db_path()
    if db_path is None:
        sys.stderr.write("History is disabled.\n")
        return 2
    if not db_path.exists():
        sys.stderr.write("History database not found.\n")
        return 2

    if args.command == "history":
        if args.query == "prune":
            if args.keep is None:
                sys.stderr.write("History prune requires --keep.\n")
                return 2
            try:
                deleted, remaining = history_db.prune_runs(db_path, args.keep)
            except ValueError as exc:
                sys.stderr.write(f"{exc}\n")
                return 2
            if args.json:
                sys.stdout.write(
                    json.dumps({"deleted": deleted, "remaining": remaining}, sort_keys=True)
                )
                return 0
            sys.stdout.write(
                f"Pruned {deleted} run(s). Remaining: {remaining}.\n"
            )
            return 0
        if args.query:
            sys.stderr.write("History does not take a query.\n")
            return 2
        runs = history_db.list_runs(db_path, limit=args.limit)
        if args.json:
            sys.stdout.write(json.dumps(runs, sort_keys=True))
            return 0
        for run in runs:
            sys.stdout.write(
                f"{run['id']}\t{run['status']}\t{run['started_at']}\t"
                f"in:{run['input_count']}\tout:{run['output_count']}\n"
            )
        return 0

    if args.command == "search":
        if not args.query:
            sys.stderr.write("Search requires a query.\n")
            return 2
        runs = history_db.search_runs(db_path, args.query, limit=args.limit)
        if args.rerun:
            if not runs:
                sys.stderr.write("No matching runs to rerun.\n")
                return 2
            run_id = runs[0]["id"]
            run = history_db.load_run(db_path, run_id)
            if run is None:
                sys.stderr.write("Run not found.\n")
                return 2
            try:
                options = json.loads(run["options_json"] or "{}")
            except Exception:
                options = {}
            inputs = [Path(item["path"]) for item in run["inputs"]]
            for path in inputs:
                if not path.exists():
                    sys.stderr.write(f"Input not found: {path}\n")
                    return 4
            rerun_args = _rerun_args(options, args)

            def debug(_msg: str) -> None:
                return

            exit_code = _run_conversion(inputs, rerun_args, debug)
            if args.json:
                sys.stdout.write(
                    json.dumps(
                        {"rerun_of": run_id, "exit_code": exit_code},
                        sort_keys=True,
                    )
                )
            return exit_code
        if args.json:
            sys.stdout.write(json.dumps(runs, sort_keys=True))
            return 0
        for run in runs:
            sys.stdout.write(
                f"{run['id']}\t{run['status']}\t{run['started_at']}\t"
                f"in:{run['input_count']}\tout:{run['output_count']}\n"
            )
        return 0

    if args.command == "rerun":
        if not args.query:
            sys.stderr.write("Rerun requires a run id.\n")
            return 2
        try:
            run_id = int(args.query)
        except ValueError:
            sys.stderr.write("Run id must be an integer.\n")
            return 2
        run = history_db.load_run(db_path, run_id)
        if run is None:
            sys.stderr.write("Run not found.\n")
            return 2
        try:
            options = json.loads(run["options_json"] or "{}")
        except Exception:
            options = {}
        inputs = [Path(item["path"]) for item in run["inputs"]]
        for path in inputs:
            if not path.exists():
                sys.stderr.write(f"Input not found: {path}\n")
                return 4
        rerun_args = _rerun_args(options, args)

        def debug(_msg: str) -> None:
            return

        exit_code = _run_conversion(inputs, rerun_args, debug)
        if args.json:
            sys.stdout.write(
                json.dumps({"rerun_of": run_id, "exit_code": exit_code}, sort_keys=True)
            )
        return exit_code

    sys.stderr.write("Unknown history command.\n")
    return 2
    try:
        return history_db.HistoryLogger(path)
    except Exception as exc:
        debug(f"History disabled: {exc}")
        return None

def _detect_format(path: str) -> str:
    ext = os.path.splitext(path)[1].lower()
    if ext == ".pdf":
        return "pdf"
    if ext == ".docx":
        return "docx"
    if ext == ".csv":
        return "csv"
    if ext in IMAGE_EXTENSIONS:
        return "image"
    raise UnsupportedFormatError(f"Unsupported extension: {ext}")


def _convert(
    path: str,
    fmt: str,
    engine: str,
    csv_na: str,
    csv_float_format: Optional[str],
    ocr_mode: str,
    ocr_lang: str,
    ocr_device: Optional[str],
    ocr_render_scale: float,
    ocr_min_score: Optional[float],
) -> str:
    if fmt == "pdf":
        return pdf_to_markdown(
            path,
            engine=engine,
            ocr_mode=ocr_mode,
            ocr_lang=ocr_lang,
            ocr_version=DEFAULT_OCR_VERSION,
            ocr_device=ocr_device,
            ocr_render_scale=ocr_render_scale,
            ocr_min_score=ocr_min_score,
        )
    if fmt == "docx":
        return docx_to_markdown(path)
    if fmt == "csv":
        return csv_to_markdown(path, index=False, na_rep=csv_na, float_format=csv_float_format)
    if fmt == "image":
        return image_to_markdown(
            path,
            ocr_lang=ocr_lang,
            ocr_version=DEFAULT_OCR_VERSION,
            ocr_device=ocr_device,
            min_score=ocr_min_score,
        )
    raise UnsupportedFormatError(f"Unsupported format: {fmt}")


def _parse_slash_command(text: str) -> tuple[str, Optional[str]]:
    if not text.startswith("/"):
        raise ValueError("Not a slash command.")
    cmdline = text[1:].strip()
    if not cmdline:
        raise ValueError("Missing command.")
    if ":" in cmdline:
        name, value = cmdline.split(":", 1)
        value = value.strip()
        return name.strip().lower(), value or None
    parts = shlex.split(cmdline, posix=False)
    name = parts[0].strip().lower()
    arg = " ".join(parts[1:]).strip() if len(parts) > 1 else None
    return name, arg or None


def _parse_slash_args(arg: Optional[str]) -> list[str]:
    if not arg:
        return []
    try:
        return shlex.split(arg, posix=False)
    except Exception:
        return arg.split()


def _latest_run(db_path: Path) -> Optional[dict]:
    runs = history_db.list_runs(db_path, limit=1)
    if not runs:
        return None
    return history_db.load_run(db_path, runs[0]["id"])


def _run_options(run: dict) -> dict:
    try:
        return json.loads(run.get("options_json") or "{}")
    except Exception:
        return {}


def _rerun_args(options: dict, args: argparse.Namespace) -> argparse.Namespace:
    return argparse.Namespace(
        format=options.get("format"),
        engine=options.get("engine", "pdfminer"),
        output=options.get("output"),
        preview=bool(options.get("preview", False)),
        diff=bool(options.get("diff", False)),
        yes=bool(options.get("yes", False)),
        stats=bool(options.get("stats", False)),
        profile_report=options.get("profile_report"),
        theme=options.get("theme", getattr(args, "theme", "auto")),
        quiet=bool(options.get("quiet", False)),
        debug=bool(options.get("debug", False)),
        csv_na=options.get("csv_na", ""),
        csv_float_format=options.get("csv_float_format"),
        ocr=bool(options.get("ocr", False)),
        ocr_mode=options.get("ocr_mode", "never"),
        ocr_lang=options.get("ocr_lang", "es"),
        ocr_device=options.get("ocr_device"),
        ocr_render_scale=options.get("ocr_render_scale", 2.0),
        ocr_min_score=options.get("ocr_min_score"),
    )


def _ui_diff_for_run(run: dict) -> list[str]:
    options = _run_options(run)
    if not run.get("inputs"):
        return ["No inputs recorded for run."]
    output_path = None
    for item in run.get("outputs", []):
        if item.get("path"):
            output_path = Path(item["path"])
            break
    if output_path is None:
        return ["No output path recorded for run."]
    input_path = Path(run["inputs"][0]["path"])
    fmt = run.get("format") or _detect_format(str(input_path))
    content = _convert(
        str(input_path),
        fmt=fmt,
        engine=options.get("engine", "pdfminer"),
        csv_na=options.get("csv_na", ""),
        csv_float_format=options.get("csv_float_format"),
        ocr_mode=options.get("ocr_mode", "never"),
        ocr_lang=options.get("ocr_lang", "es"),
        ocr_device=options.get("ocr_device"),
        ocr_render_scale=options.get("ocr_render_scale", 2.0),
        ocr_min_score=options.get("ocr_min_score"),
    )
    diff_text = _diff_text(output_path, content)
    if not diff_text:
        return ["No diff (output matches current conversion)."]
    return diff_text.splitlines()


def _load_input_files(base_dir: Path) -> list[Path]:
    if not base_dir.exists():
        return []
    candidates = [path for path in base_dir.iterdir() if path.is_file()]
    supported = [
        path
        for path in candidates
        if path.suffix.lower() in (".pdf", ".docx", ".csv", *IMAGE_EXTENSIONS)
    ]
    return sorted(supported)


def _resolve_outputs(inputs: list[Path], output: Optional[str]) -> list[Optional[Path]]:
    if output == "-":
        if len(inputs) != 1:
            raise ValueError("Standard output is only supported for a single input.")
        return [None]
    if len(inputs) == 1:
        if output is None:
            default_name = f"{inputs[0].name}.md"
            return [DEFAULT_OUTPUT_DIR / default_name]
        return [Path(output)]

    output_dir = DEFAULT_OUTPUT_DIR if output is None else Path(output)
    if output_dir.exists() and output_dir.is_file():
        raise ValueError("Output must be a directory when converting multiple files.")
    return [output_dir / f"{path.name}.md" for path in inputs]


def _apply_interactive_command(
    command: str,
    arg: Optional[str],
    args: argparse.Namespace,
    debug: Callable[[str], None],
) -> Optional[str]:
    if command == "help":
        return "Use the command list below to pick an action."
    if command == "exit":
        raise UserCancelled("Exited interactive mode.")
    if command == "format":
        if not arg or arg.lower() in {"auto", "none"}:
            args.format = None
            debug("Format override cleared.")
            return "Format override cleared (auto-detect enabled)."
        fmt = arg.lower()
        if fmt not in SUPPORTED_FORMATS:
            return f"Unsupported format: {arg}"
        args.format = fmt
        debug(f"Format override set to {fmt}.")
        return f"Format override set to {fmt}."
    if command == "engine":
        if not arg:
            return "Usage: /engine <pdfminer|pdftext|marker>"
        engine = arg.lower()
        if engine not in SUPPORTED_PDF_ENGINES:
            return f"Unsupported engine: {arg}"
        args.engine = engine
        debug(f"Engine set to {engine}.")
        return f"Engine set to {engine}."
    if command == "output":
        if not arg:
            return "Usage: /output <path|->"
        if arg.lower() in {"default", "auto"}:
            args.output = None
            debug("Output reset to default.")
            return "Output reset to docs_out/<input>.md."
        args.output = arg
        debug(f"Output set to {arg}.")
        label = _output_label(arg)
        if arg == "-":
            return "Output set to stdout."
        return f"Output set to {label}."
    if command == "ocr":
        if not arg:
            return "Usage: /ocr <mode|lang|device|render-scale|min-score> ..."
        tokens = _parse_slash_args(arg)
        if not tokens:
            return "Usage: /ocr <mode|lang|device|render-scale|min-score> ..."

        head = tokens[0].lower()
        tail = tokens[1:] if len(tokens) > 1 else []

        # Back-compat shortcut: /ocr auto
        if head in OCR_MODES and not tail:
            args.ocr_mode = head
            args.ocr = head != "never"
            return f"OCR mode set to {head}."

        if head == "mode":
            if not tail:
                return "Usage: /ocr mode <never|auto|always>"
            mode = tail[0].lower()
            if mode not in OCR_MODES:
                return f"Unsupported OCR mode: {tail[0]}"
            args.ocr_mode = mode
            args.ocr = mode != "never"
            return f"OCR mode set to {mode}."
        if head == "lang":
            if not tail:
                return "Usage: /ocr lang <code>"
            args.ocr_lang = tail[0]
            return f"OCR language set to {tail[0]}."
        if head == "device":
            if not tail:
                return "Usage: /ocr device <cpu|gpu:0|auto>"
            value = tail[0].strip().lower()
            if value in {"auto", "none", "off"}:
                args.ocr_device = None
                return "OCR device reset to auto."
            args.ocr_device = tail[0]
            return f"OCR device set to {tail[0]}."
        if head == "render-scale":
            if not tail:
                return "Usage: /ocr render-scale <float>"
            try:
                scale = float(tail[0])
            except ValueError:
                return "OCR render scale must be a number."
            if scale <= 0:
                return "OCR render scale must be positive."
            args.ocr_render_scale = scale
            return f"OCR render scale set to {scale}."
        if head == "min-score":
            if not tail:
                return "Usage: /ocr min-score <float|off>"
            value = tail[0].strip().lower()
            if value in {"off", "none", "default"}:
                args.ocr_min_score = None
                return "OCR min score cleared."
            try:
                score = float(tail[0])
            except ValueError:
                return "OCR min score must be a number."
            args.ocr_min_score = score
            return f"OCR min score set to {score}."
        return "Usage: /ocr <mode|lang|device|render-scale|min-score> ..."
    if command == "debug":
        if not arg:
            args.debug = not args.debug
            return f"Debug {'on' if args.debug else 'off'}."
        value = arg.lower()
        if value in {"on", "true", "1", "yes"}:
            args.debug = True
        elif value in {"off", "false", "0", "no"}:
            args.debug = False
        else:
            return "Usage: /debug <on|off>"
        return f"Debug {'on' if args.debug else 'off'}."
    return "Unknown command. Use /help for options."


def _build_interactive_app(
    args: argparse.Namespace,
    debug: Callable[[str], None],
    *,
    input=None,
    output=None,
) -> tuple["Application", dict]:
    try:
        from prompt_toolkit.application import Application
        from prompt_toolkit.buffer import Buffer
        from prompt_toolkit.filters import Condition
        from prompt_toolkit.history import FileHistory, InMemoryHistory
        from prompt_toolkit.key_binding.defaults import load_key_bindings
        from prompt_toolkit.key_binding import KeyBindings
        from prompt_toolkit.key_binding.key_bindings import merge_key_bindings
        from prompt_toolkit.layout import Layout
        from prompt_toolkit.layout.containers import HSplit, VSplit, Window
        from prompt_toolkit.layout.scrollable_pane import ScrollablePane
        from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl
        from prompt_toolkit.layout.dimension import Dimension
        from prompt_toolkit.styles import Style
    except Exception as exc:
        raise RuntimeError("prompt_toolkit not available") from exc

    try:
        history = FileHistory(str(_history_path()))
    except Exception:
        history = InMemoryHistory()
    progress = {"active": False, "current": 0, "total": 0, "label": ""}
    buffer = Buffer(history=history, read_only=Condition(lambda: progress["active"]))
    history_buffer = Buffer()
    history_lines: list[str] = []
    file_items: list[Path] = []
    file_selected: set[Path] = set()
    file_cursor = 0
    file_mode_active = False
    help_active = False
    output_active = False
    state = {
        "items": [],
        "selected": 0,
        "status": "",
        "mode": "idle",
        "batch_confirm": False,
        "output_lines": [],
    }

    # Session-only toggles (interactive mode).
    for name, default in {
        "preview": False,
        "diff": False,
        "yes": False,
        "stats": False,
        "profile_report": None,
        "ocr": False,
        "ocr_mode": "never",
        "ocr_lang": "es",
        "ocr_device": None,
        "ocr_render_scale": 2.0,
        "ocr_min_score": None,
    }.items():
        if not hasattr(args, name):
            setattr(args, name, default)
    recent_activity = {"lines": _recent_activity_lines()}
    app: Application | None = None
    scroll_pane: ScrollablePane | None = None
    scroll_state = {"follow": True}

    def _refresh_recent_activity() -> None:
        recent_activity["lines"] = _recent_activity_lines()

    def _content_height() -> int:
        if scroll_pane is None:
            return 0
        width = 80
        try:
            out = app.output if app is not None else output
            if out is not None:
                width = max(20, out.get_size().columns)
        except Exception:
            width = 80
        if scroll_pane.show_scrollbar():
            width = max(1, width - 1)
        dimension = scroll_pane.content.preferred_height(
            width, scroll_pane.max_available_height
        )
        return dimension.preferred

    def _viewport_height() -> int:
        try:
            out = app.output if app is not None else output
            if out is not None:
                return max(1, out.get_size().rows)
        except Exception:
            return 24
        return 24

    def _max_scroll() -> int:
        return max(0, _content_height() - _viewport_height())

    def _scroll_to_bottom() -> None:
        if scroll_pane is None:
            return
        scroll_pane.vertical_scroll = _max_scroll()

    def _scroll_by(lines: int) -> None:
        if scroll_pane is None:
            return
        scroll_state["follow"] = False
        scroll_pane.vertical_scroll = max(
            0, min(_max_scroll(), scroll_pane.vertical_scroll + lines)
        )

    def _scroll_to_top() -> None:
        if scroll_pane is None:
            return
        scroll_state["follow"] = False
        scroll_pane.vertical_scroll = 0

    def _scroll_follow() -> None:
        if scroll_pane is None:
            return
        scroll_state["follow"] = True
        _scroll_to_bottom()

    def _sync_history_buffer() -> None:
        text = "\n".join(history_lines)
        history_buffer.text = text
        history_buffer.cursor_position = len(text)

    def _append_history(lines: list[str], *, spacer: bool = True) -> None:
        if not lines:
            return
        if spacer and history_lines:
            history_lines.append("")
        history_lines.extend(lines)
        if len(history_lines) > MAX_INTERACTIVE_HISTORY_LINES:
            history_lines[:] = history_lines[-MAX_INTERACTIVE_HISTORY_LINES :]
        _sync_history_buffer()
        if scroll_state["follow"]:
            _scroll_to_bottom()
        if app is not None:
            app.invalidate()

    def _record_command(
        command_line: str,
        *,
        output_lines: Optional[list[str]] = None,
        status: Optional[str] = None,
    ) -> None:
        scroll_state["follow"] = True
        lines = [f"> {command_line}"]
        if output_lines:
            lines.extend(output_lines)
        if status:
            lines.append(status)
        _append_history(lines, spacer=True)

    def _header_text():
        width = 80
        try:
            out = app.output if app is not None else output
            if out is not None:
                width = max(40, out.get_size().columns)
        except Exception:
            width = 80
        return _render_hero_text(
            "documint2md",
            __version__,
            Path.cwd(),
            _output_label(args.output),
            recent_activity["lines"],
            width=width,
        )

    def _separator_text():
        width = 80
        try:
            out = app.output if app is not None else output
            if out is not None:
                width = max(40, out.get_size().columns)
        except Exception:
            width = 80
        return [("class:banner.rule", "-" * width)]

    def _input_border_text():
        width = 80
        try:
            out = app.output if app is not None else output
            if out is not None:
                width = max(20, out.get_size().columns)
        except Exception:
            width = 80
        line = "+" + ("-" * max(0, width - 2)) + "+"
        return [("class:input.prompt", line)]

    def _update_state() -> None:
        nonlocal help_active, output_active
        text = buffer.text
        if text.startswith("/"):
            help_active = False
            output_active = False
            option_items = _slash_option_items(text)
            if option_items:
                state["items"] = option_items
                state["selected"] = _clamp_index(state["selected"], len(state["items"]))
                state["mode"] = "options"
            else:
                state["items"] = _slash_command_items(text)
                state["selected"] = _clamp_index(state["selected"], len(state["items"]))
                state["mode"] = "commands"
        else:
            state["items"] = []
            state["selected"] = 0
            if text.strip():
                help_active = False
                output_active = False
            if help_active:
                state["mode"] = "help"
            elif output_active:
                state["mode"] = "output"
            else:
                state["mode"] = "files" if file_mode_active else "idle"
        if app is not None:
            app.invalidate()
        if scroll_state["follow"]:
            _scroll_to_bottom()

    def _set_status(message: str) -> None:
        if not args.quiet:
            state["status"] = message

    def _set_debug(message: str) -> None:
        if args.debug and not args.quiet:
            state["status"] = f"Debug: {message}"
            if app is not None:
                app.invalidate()

    def _report_error(message: str, detail: Optional[str] = None) -> None:
        if args.debug and detail:
            _set_status(f"{message} ({detail})")
        else:
            _set_status(message)

    def _set_output(lines: list[str], status: Optional[str] = None) -> None:
        nonlocal output_active, file_mode_active, help_active
        state["output_lines"] = lines
        output_active = True
        file_mode_active = False
        help_active = False
        if status:
            _set_status(status)
        _update_state()

    def _history_db_path() -> Optional[Path]:
        path = history_db.history_db_path()
        if path is None:
            _report_error("History is disabled.")
            return None
        if not path.exists():
            _report_error("History database not found.")
            return None
        return path

    def _render_runs(runs: list[dict]) -> list[str]:
        lines: list[str] = []
        for run in runs:
            lines.append(
                f"{run['id']}\t{run['status']}\t{run['started_at']}\t"
                f"in:{run['input_count']}\tout:{run['output_count']}"
            )
        return lines

    def _handle_history_command(command: str, arg: Optional[str]) -> Optional[list[str]]:
        db_path = _history_db_path()
        if db_path is None:
            return None
        tokens = _parse_slash_args(arg)
        if command == "history":
            limit = 20
            if tokens:
                try:
                    limit = int(tokens[0])
                except ValueError:
                    _report_error("History limit must be an integer.")
                    return None
            runs = history_db.list_runs(db_path, limit=limit)
            return _render_runs(runs)
        if command == "search":
            if not tokens:
                _report_error("Search requires a query.")
                return None
            query = tokens[0]
            limit = 20
            if len(tokens) > 1:
                try:
                    limit = int(tokens[1])
                except ValueError:
                    _report_error("Search limit must be an integer.")
                    return None
            runs = history_db.search_runs(db_path, query, limit=limit)
            return _render_runs(runs)
        if command == "jump":
            query = None
            limit = 10
            if tokens:
                query = tokens[0]
            if len(tokens) > 1:
                try:
                    limit = int(tokens[1])
                except ValueError:
                    _report_error("Jump limit must be an integer.")
                    return None
            items = history_db.list_input_paths(db_path, query, limit)
            return [item["path"] for item in items]
        if command == "recent":
            mode = "all"
            limit = 20
            if tokens:
                mode = tokens[0].lower()
                if mode not in {"inputs", "projects", "all"}:
                    _report_error("Recent mode must be inputs, projects, or all.")
                    return None
            if len(tokens) > 1:
                try:
                    limit = int(tokens[1])
                except ValueError:
                    _report_error("Recent limit must be an integer.")
                    return None
            lines: list[str] = []
            if mode in {"inputs", "all"}:
                inputs = history_db.list_recent_inputs(db_path, limit)
                lines.append("Recent inputs:")
                lines.extend(item["path"] for item in inputs)
                if mode == "all":
                    lines.append("")
            if mode in {"projects", "all"}:
                projects = history_db.list_recent_projects(db_path, limit)
                lines.append("Recent projects:")
                lines.extend(item["path"] for item in projects)
            return lines
        if command == "explain":
            if not tokens:
                _report_error("Explain requires a run id.")
                return None
            try:
                run_id = int(tokens[0])
            except ValueError:
                _report_error("Run id must be an integer.")
                return None
            run = history_db.load_run(db_path, run_id)
            if run is None:
                _report_error("Run not found.")
                return None
            lines = [
                f"Run {run['id']} ({run['status']})",
                f"Started: {run['started_at']}",
                f"Finished: {run['finished_at']}",
                f"Exit code: {run['exit_code']}",
            ]
            if run["inputs"]:
                lines.append("Inputs:")
                lines.extend(f"  {item['path']}" for item in run["inputs"])
            if run["outputs"]:
                lines.append("Outputs:")
                lines.extend(f"  {item['path']}" for item in run["outputs"])
            if run["warnings"]:
                lines.append("Warnings:")
                lines.extend(
                    f"  {item['code']}: {item['message']}" for item in run["warnings"]
                )
            return lines
        return None

    def _handle_profile_command(arg: Optional[str]) -> Optional[list[str]]:
        tokens = _parse_slash_args(arg)
        if not tokens:
            _report_error("Usage: /profile <list|show|apply> [name]")
            return None
        subcommand = tokens[0].lower()
        name = tokens[1] if len(tokens) > 1 else None
        path = _profile_path()
        try:
            profiles = _load_profiles(path)
        except FileNotFoundError:
            _report_error("Profile file not found.")
            return None
        except tomllib.TOMLDecodeError as exc:
            _report_error(f"Profile file is invalid TOML: {exc}")
            return None
        except ValueError as exc:
            _report_error(str(exc))
            return None
        if subcommand == "list":
            names = sorted(profiles.keys())
            return names or ["No profiles found."]
        if subcommand == "show":
            if not name:
                _report_error("Profile name is required.")
                return None
            profile = profiles.get(name)
            if profile is None:
                _report_error("Profile not found.")
                return None
            lines = [f"Profile {name}:"]
            for key in sorted(profile.keys()):
                lines.append(f"  {key}={profile[key]}")
            return lines
        if subcommand == "apply":
            if not name:
                _report_error("Profile name is required.")
                return None
            base = profiles.get("default", {})
            profile = profiles.get(name)
            if profile is None:
                _report_error("Profile not found.")
                return None
            merged = {}
            if isinstance(base, dict):
                merged.update(base)
            if isinstance(profile, dict):
                merged.update(profile)
            _apply_profile_to_args(args, merged)
            _set_status(f"Profile '{name}' applied.")
            return [f"Profile '{name}' applied."]
        _report_error("Unknown profile command.")
        return None


    def _execute_history(arg: Optional[str], command_text: str) -> bool:
        tokens = _parse_slash_args(arg)
        if not tokens:
            output_lines = _handle_history_command("history", None)
            if output_lines is None:
                _record_command(command_text, status=state["status"])
                return True
            _set_output(output_lines, "History")
            _record_command(command_text, output_lines=output_lines)
            return True
        sub = tokens[0].lower()
        rest = " ".join(tokens[1:]) if len(tokens) > 1 else ""
        if sub in {"list", "history"}:
            output_lines = _handle_history_command("history", rest)
        elif sub in {"search", "jump", "recent", "explain"}:
            output_lines = _handle_history_command(sub, rest)
        elif sub == "rerun":
            # Reuse /more rerun implementation.
            return _execute_more(f"rerun {rest}".strip(), command_text)
        else:
            _report_error("Usage: /history <list|search|rerun|explain|jump|recent> ...")
            _record_command(command_text, status=state["status"])
            return True
        if output_lines is None:
            _record_command(command_text, status=state["status"])
            return True
        _set_output(output_lines, f"History {sub}")
        _record_command(command_text, output_lines=output_lines)
        return True


    def _execute_profile(arg: Optional[str], command_text: str) -> bool:
        output_lines = _handle_profile_command(arg)
        if output_lines is None:
            _record_command(command_text, status=state["status"])
            return True
        _set_output(output_lines, "Profile")
        _record_command(command_text, output_lines=output_lines)
        return True


    def _execute_ui(arg: Optional[str], command_text: str) -> bool:
        tokens = _parse_slash_args(arg)
        if not tokens or tokens[0].lower() == "open":
            if app is not None:
                app.exit(result="ui")
            _record_command(command_text, status="Launching UI.")
            return True
        if tokens[0].lower() == "theme":
            rest = " ".join(tokens[1:]) if len(tokens) > 1 else ""
            if not rest:
                _set_status("Usage: /ui theme <auto|light|dark|minimal|dense>")
                _record_command(command_text, status=state["status"])
                return True
            theme = rest.lower()
            if theme not in THEME_NAMES:
                _set_status(f"Unsupported theme: {rest}")
                _record_command(command_text, status=state["status"])
                return True
            args.theme = theme
            if app is not None:
                app.style = Style.from_dict(_theme_style(theme))
            _set_status(f"Theme set to {theme}.")
            _record_command(command_text, status=state["status"])
            return True
        _set_status("Usage: /ui <open|theme> ...")
        _record_command(command_text, status=state["status"])
        return True


    def _execute_session(arg: Optional[str], command_text: str) -> bool:
        tokens = _parse_slash_args(arg)
        if not tokens:
            _set_status("Usage: /session <preview|diff|yes|stats|quiet> <on|off>")
            _record_command(command_text, status=state["status"])
            return True
        key = tokens[0].lower()
        rest = " ".join(tokens[1:]) if len(tokens) > 1 else ""
        if key not in {"preview", "diff", "yes", "stats", "quiet"}:
            _set_status("Usage: /session <preview|diff|yes|stats|quiet> <on|off>")
            _record_command(command_text, status=state["status"])
            return True
        status = _apply_session_toggle(key, rest)
        if status:
            _set_status(status)
        _record_command(command_text, status=state["status"])
        return True


    def _apply_session_toggle(name: str, arg_text: str) -> Optional[str]:
        current = bool(getattr(args, name, False))
        value, error = _parse_toggle(arg_text, current)
        if error:
            return f"Usage: /session {name} <on|off>"
        setattr(args, name, value)
        return f"{name.capitalize()} {'on' if value else 'off'}."

    def _execute_more(arg: Optional[str], command_text: str) -> bool:
        tokens = _parse_slash_args(arg)
        if not tokens:
            _set_output(
                [
                    "More commands:",
                    "  history <list|search|rerun|explain|jump|recent> ...",
                    "  profile <list|show|apply> [name]",
                    "  ui <open|theme> ...",
                    "  session <preview|diff|yes|stats|quiet> <on|off>",
                    "  debug <on|off>",
                ],
                "More commands",
            )
            _record_command(command_text, output_lines=state["output_lines"])
            return True
        subcommand = tokens[0].lower()
        rest = " ".join(tokens[1:]) if len(tokens) > 1 else ""
        if subcommand == "history":
            return _execute_history(rest, command_text)
        if subcommand == "profile":
            return _execute_profile(rest, command_text)
        if subcommand == "ui":
            return _execute_ui(rest, command_text)
        if subcommand == "session":
            return _execute_session(rest, command_text)
        if subcommand == "debug":
            status = _apply_interactive_command("debug", rest, args, _set_debug)
            if status:
                _set_status(status)
            _record_command(command_text, status=state["status"])
            return True

        # Back-compat: /more rerun <id>
        if subcommand == "rerun":
            tokens = _parse_slash_args(rest)
            if not tokens:
                _set_status("Rerun requires a run id.")
                return True
            try:
                run_id = int(tokens[0])
            except ValueError:
                _set_status("Run id must be an integer.")
                return True
            db_path = _history_db_path()
            if db_path is None:
                return True
            run = history_db.load_run(db_path, run_id)
            if run is None:
                _set_status("Run not found.")
                return True
            options = _run_options(run)
            inputs = [Path(item["path"]) for item in run["inputs"]]
            for path in inputs:
                if not path.exists():
                    _set_status(f"Input not found: {path}")
                    return True
            rerun_args = _rerun_args(options, args)
            exit_code = _run_conversion(inputs, rerun_args, _set_debug)
            _set_status(f"Rerun {run_id} finished with exit code {exit_code}.")
            _record_command(command_text, status=state["status"])
            return True
        _set_status("Unknown /more command.")
        _record_command(command_text, status=state["status"])
        return True

    buffer.on_text_changed += lambda _: _update_state()
    _update_state()
    _set_status("Ready.")
    _append_history(["documint2md interactive ready."])

    def _menu_text():
        if state["mode"] in {"commands", "options"}:
            if not state["items"]:
                message = "No matching commands."
                if state["mode"] == "options":
                    message = "No matching options."
                return [("class:hint", message)]
            return _render_command_list(
                state["items"],
                state["selected"],
                max_visible=MENU_MAX_VISIBLE_ITEMS,
            )
        if state["mode"] == "help":
            return _render_help_text()
        if state["mode"] == "output":
            return _render_output_list(state["output_lines"])
        if state["mode"] == "files":
            return _render_file_list(file_items, file_cursor, file_selected)
        return [("class:hint", "Press / for commands.")]

    def _footer_text():
        status = state["status"]
        if progress["active"] and not args.quiet:
            try:
                width = max(40, app.output.get_size().columns) if app else 80
            except Exception:
                width = 80
            bar_width = max(12, min(32, width - 28))
            bar = _progress_bar_text(progress["current"], progress["total"], bar_width)
            label = progress["label"]
            message = f"  {bar}  ({progress['current']}/{progress['total']})"
            if label:
                message += f"  {label}"
            return message
        settings = _settings_summary(args)
        if state["mode"] in {"commands", "options"}:
            count = _command_count_text(state["items"], state["selected"])
            hint = "Enter apply - /more advanced"
            if state["mode"] == "options":
                hint = "Enter apply - /more advanced"
            if status:
                return f"  {count}  {settings}  {status}"
            return f"  {count}  {settings}  {hint}"
        if state["mode"] == "help":
            if status:
                return f"  {settings}  {status}"
            return f"  {settings}  Hint: / for commands - /more advanced"
        if file_items:
            count = _file_count_text(file_items, file_selected)
            hint = "Space toggle - Enter convert - /more advanced"
            if status:
                return f"  {count}  {settings}  {status}"
            return f"  {count}  {settings}  {hint}"
        if status:
            return f"  {settings}  {status}"
        return f"  {settings}  Ready."

    kb = KeyBindings()
    command_mode = Condition(lambda: state["mode"] in {"commands", "options"} and state["items"])
    file_mode = Condition(lambda: state["mode"] == "files" and file_items)

    @kb.add("up", filter=command_mode)
    def _move_up(event):
        if state["selected"] <= 0:
            state["selected"] = max(0, len(state["items"]) - 1)
        else:
            state["selected"] = _clamp_index(state["selected"] - 1, len(state["items"]))
        event.app.invalidate()

    @kb.add("down", filter=command_mode)
    def _move_down(event):
        if not state["items"]:
            return
        if state["selected"] >= len(state["items"]) - 1:
            state["selected"] = 0
        else:
            state["selected"] = _clamp_index(state["selected"] + 1, len(state["items"]))
        event.app.invalidate()

    @kb.add("up", filter=file_mode)
    def _file_up(event):
        nonlocal file_cursor
        file_cursor = _clamp_index(file_cursor - 1, len(file_items))
        event.app.invalidate()

    @kb.add("down", filter=file_mode)
    def _file_down(event):
        nonlocal file_cursor
        file_cursor = _clamp_index(file_cursor + 1, len(file_items))
        event.app.invalidate()

    @kb.add("c-p")
    def _history_back(event):
        event.current_buffer.history_backward()

    @kb.add("c-n")
    def _history_forward(event):
        event.current_buffer.history_forward()

    @kb.add("space", filter=file_mode)
    def _toggle_file(event):
        if not file_items:
            return
        path = file_items[file_cursor]
        if path in file_selected:
            file_selected.remove(path)
        else:
            file_selected.add(path)
        event.app.invalidate()

    @kb.add("pageup")
    def _scroll_page_up(event):
        _scroll_by(-max(1, _viewport_height() - 2))
        event.app.invalidate()

    @kb.add("pagedown")
    def _scroll_page_down(event):
        _scroll_by(max(1, _viewport_height() - 2))
        event.app.invalidate()

    @kb.add("home")
    def _scroll_top(event):
        _scroll_to_top()
        event.app.invalidate()

    @kb.add("end")
    def _scroll_bottom(event):
        _scroll_follow()
        event.app.invalidate()

    async def _convert_selected_files(event) -> None:
        if progress["active"]:
            _set_status("Conversion already in progress.")
            _record_command("convert", status=state["status"])
            event.app.invalidate()
            return
        if not file_items:
            _set_status("No files found in docs_in.")
            _record_command("convert", status=state["status"])
            event.app.invalidate()
            return
        if not file_selected:
            _set_status("Select one or more files with Space.")
            _record_command("convert", status=state["status"])
            event.app.invalidate()
            return
        if len(file_selected) <= 1:
            state["batch_confirm"] = False
        to_convert = [path for path in file_items if path in file_selected]
        if len(to_convert) == 1:
            command_line = f"convert {to_convert[0].name}"
        else:
            command_line = f"convert {len(to_convert)} files"
        if args.preview and len(to_convert) != 1:
            _set_status("Preview supports a single input.")
            _record_command(command_line, status=state["status"])
            event.app.invalidate()
            return
        if len(to_convert) > 1 and not args.yes:
            if not state.get("batch_confirm", False):
                state["batch_confirm"] = True
                _set_status(
                    f"Confirm batch conversion of {len(to_convert)} files: press Enter again."
                )
                event.app.invalidate()
                return
            state["batch_confirm"] = False
        try:
            outputs = [None] if args.preview else _resolve_outputs(to_convert, args.output)
        except ValueError as exc:
            _report_error("Output configuration is invalid.", str(exc))
            _record_command(command_line, status=state["status"])
            event.app.invalidate()
            return
        if args.engine != "pdfminer":
            if args.format and args.format != "pdf":
                _report_error("--engine is only valid for PDF inputs.")
                _record_command(command_line, status=state["status"])
                event.app.invalidate()
                return
            if not args.format:
                if any(_detect_format(str(path)) != "pdf" for path in to_convert):
                    _report_error("--engine is only valid for PDF inputs.")
                    _record_command(command_line, status=state["status"])
                    event.app.invalidate()
                    return
        total = len(to_convert)
        progress["active"] = True
        progress["current"] = 0
        progress["total"] = total
        progress["label"] = "Starting..."
        event.app.invalidate()
        loop = asyncio.get_running_loop()
        stats_records: list[dict] = []
        stats_started = time.perf_counter()
        for idx, (path, output_path) in enumerate(zip(to_convert, outputs), start=1):
            progress["current"] = idx - 1
            progress["label"] = f"{path.name}"
            event.app.invalidate()
            try:
                detect_start = time.perf_counter()
                fmt = args.format or _detect_format(str(path))
                detect_ms = (time.perf_counter() - detect_start) * 1000.0
            except UnsupportedFormatError as exc:
                _report_error("Unsupported format.", str(exc))
                _record_command(command_line, status=state["status"])
                break
            try:
                convert_start = time.perf_counter()
                content = await loop.run_in_executor(
                    None,
                    _convert,
                    str(path),
                    fmt,
                    args.engine,
                    args.csv_na,
                    args.csv_float_format,
                    args.ocr_mode,
                    args.ocr_lang,
                    args.ocr_device,
                    args.ocr_render_scale,
                    args.ocr_min_score,
                )
                convert_ms = (time.perf_counter() - convert_start) * 1000.0
            except UnsupportedFormatError as exc:
                _report_error("Unsupported format.", str(exc))
                _record_command(command_line, status=state["status"])
                break
            except FileNotFoundError as exc:
                _report_error("File not found.", str(exc))
                _record_command(command_line, status=state["status"])
                break
            except ConversionError as exc:
                _report_error(f"Conversion failed for {path.name}.", str(exc))
                _record_command(command_line, status=state["status"])
                break
            except Exception as exc:
                _report_error(f"Conversion failed for {path.name}.", str(exc))
                _record_command(command_line, status=state["status"])
                break
            write_start = time.perf_counter()
            if output_path is not None:
                if output_path.exists():
                    if args.diff:
                        diff_text = _diff_text(output_path, content)
                        if diff_text:
                            _set_output(diff_text.splitlines(), "Diff shown for overwrite.")
                    if not args.yes:
                        _report_error(
                            f"Refusing to overwrite {output_path}. Use /yes to confirm."
                        )
                        _record_command(command_line, status=state["status"])
                        break
                try:
                    output_path.parent.mkdir(parents=True, exist_ok=True)
                    with open(output_path, "w", encoding="utf-8", newline="\n") as f:
                        f.write(content)
                except OSError as exc:
                    _report_error("Output write failed.", str(exc))
                    _record_command(command_line, status=state["status"])
                    break
            else:
                _set_output(content.splitlines(), "Preview output")
            write_ms = (time.perf_counter() - write_start) * 1000.0
            stats_records.append(
                {
                    "input": str(path),
                    "stages_ms": {
                        "detect": detect_ms,
                        "convert": convert_ms,
                        "write": write_ms,
                    },
                    "total_ms": detect_ms + convert_ms + write_ms,
                }
            )
            progress["current"] = idx
            event.app.invalidate()
        else:
            if args.preview:
                message = f"Previewed {len(to_convert)} file(s)."
            else:
                destination = _output_label(args.output)
                if args.output == "-":
                    message = f"Converted {len(to_convert)} file(s) to stdout."
                else:
                    message = f"Converted {len(to_convert)} file(s) to {destination}."
            _refresh_recent_activity()
            _set_status(message)
            _record_command(command_line, status=message)
            file_selected.clear()
            if args.stats and stats_records:
                for record in stats_records:
                    _append_history([_format_stats_line(record)])
            if args.profile_report:
                report = {
                    "exit_code": 0,
                    "inputs": stats_records,
                    "total_ms": (time.perf_counter() - stats_started) * 1000.0,
                }
                try:
                    report_path = Path(args.profile_report)
                    report_path.parent.mkdir(parents=True, exist_ok=True)
                    report_path.write_text(
                        json.dumps(report, sort_keys=True),
                        encoding="utf-8",
                    )
                except OSError as exc:
                    _report_error(f"Profile report write failed: {exc}")
            if not args.quiet:
                progress["active"] = True
                progress["current"] = total
                progress["total"] = total
                for remaining in range(2, 0, -1):
                    progress["label"] = f"{message} Returning in {remaining}s..."
                    event.app.invalidate()
                    await asyncio.sleep(1)
        progress["active"] = False
        progress["label"] = ""
        event.app.invalidate()

    @kb.add("enter")
    async def _handle_enter(event):
        nonlocal file_cursor, file_mode_active, help_active, output_active
        text = buffer.text.strip()
        if text.startswith("/"):
            items = state["items"]
            commands = {command for command, _ in items}
            if items and state["mode"] == "options":
                try:
                    name, arg = _split_slash_text(text)
                except ValueError as exc:
                    _set_status(str(exc))
                    event.app.invalidate()
                    return
                option_value = items[state["selected"]][0]
                if name == "more":
                    immediate_more = {
                        "history",
                        "profile",
                        "ui",
                        "session",
                        "debug",
                    }
                    if arg and arg.strip().lower() == option_value:
                        if _execute_more(arg, f"/more {arg}"):
                            buffer.text = ""
                            buffer.cursor_position = 0
                            event.app.invalidate()
                        return
                    if not arg and option_value in immediate_more:
                        if _execute_more(option_value, f"/more {option_value}"):
                            buffer.text = ""
                            buffer.cursor_position = 0
                            event.app.invalidate()
                        return
                    buffer.text = f"/more {option_value} "
                    buffer.cursor_position = len(buffer.text)
                    state["status"] = ""
                    event.app.invalidate()
                    return
                try:
                    status = _apply_interactive_command(name, option_value, args, _set_debug)
                except UserCancelled:
                    event.app.exit(result="exit")
                    return
                if status:
                    _set_status(status)
                _record_command(f"/{name} {option_value}", status=status)
                buffer.text = ""
                buffer.cursor_position = 0
                event.app.invalidate()
                return
            if items and (text == "/" or (text not in commands and " " not in text and state["mode"] != "options")):
                selected_command = items[state["selected"]][0]
                if selected_command in {"/help", "/files", "/exit", "/more"}:
                    text = selected_command
                    buffer.text = text
                    buffer.cursor_position = len(text)
                else:
                    buffer.text = _command_buffer_text(selected_command)
                    buffer.cursor_position = len(buffer.text)
                    state["status"] = ""
                    event.app.invalidate()
                    return
            try:
                command, arg = _parse_slash_command(text)
            except ValueError as exc:
                _set_status(str(exc))
                _record_command(text, status=state["status"])
                event.app.invalidate()
                return
            if command == "more":
                if _execute_more(arg, text):
                    buffer.text = ""
                    buffer.cursor_position = 0
                    event.app.invalidate()
                return
            if command == "history":
                if _execute_history(arg, text):
                    buffer.text = ""
                    buffer.cursor_position = 0
                    event.app.invalidate()
                return
            if command == "profile":
                if _execute_profile(arg, text):
                    buffer.text = ""
                    buffer.cursor_position = 0
                    event.app.invalidate()
                return
            if command == "ui":
                if _execute_ui(arg, text):
                    buffer.text = ""
                    buffer.cursor_position = 0
                    event.app.invalidate()
                return
            if command == "session":
                if _execute_session(arg, text):
                    buffer.text = ""
                    buffer.cursor_position = 0
                    event.app.invalidate()
                return
            if command == "debug":
                status = _apply_interactive_command("debug", arg, args, _set_debug)
                if status:
                    _set_status(status)
                _record_command(text, status=state["status"])
                buffer.text = ""
                buffer.cursor_position = 0
                event.app.invalidate()
                return
            if command == "help":
                help_lines = _help_lines()
                help_active = True
                file_mode_active = False
                output_active = False
                file_items.clear()
                file_selected.clear()
                buffer.text = ""
                buffer.cursor_position = 0
                _set_status("Help: commands and usage.")
                _record_command(text, output_lines=help_lines, status=state["status"])
                _update_state()
                event.app.invalidate()
                return
            if command == "files":
                file_items[:] = _load_input_files(DEFAULT_INPUT_DIR)
                file_selected.clear()
                file_cursor = 0
                file_mode_active = True
                help_active = False
                output_active = False
                if file_items:
                    _set_status(f"Select files with Space. {len(file_items)} found.")
                else:
                    _set_status("No files found in docs_in.")
                _record_command(text, status=state["status"])
                buffer.text = ""
                buffer.cursor_position = 0
                event.app.invalidate()
                return
            try:
                status = _apply_interactive_command(command, arg, args, _set_debug)
            except UserCancelled:
                event.app.exit(result="exit")
                return
            if status:
                _set_status(status)
            _record_command(text, status=status)
            buffer.text = ""
            buffer.cursor_position = 0
            event.app.invalidate()
            return
        await _convert_selected_files(event)

    @kb.add("c-c")
    @kb.add("escape")
    def _exit(event):
        event.app.exit(result="exit")

    input_row = VSplit(
        [
            Window(
                content=FormattedTextControl(text="|"),
                width=1,
                height=1,
                style="class:input.prompt",
            ),
            Window(
                content=FormattedTextControl(text="> "),
                width=2,
                height=1,
                style="class:input.prompt",
            ),
            Window(
                content=BufferControl(buffer=buffer),
                height=1,
                style="class:input.text",
            ),
            Window(
                content=FormattedTextControl(text="|"),
                width=1,
                height=1,
                style="class:input.prompt",
            ),
        ],
        style="class:input.container",
    )
    separator_window = Window(
        content=FormattedTextControl(_separator_text),
        height=1,
        style="class:banner",
    )
    header_window = Window(
        content=FormattedTextControl(_header_text),
        height=9,
        style="class:banner",
    )
    menu_window = Window(
        content=FormattedTextControl(_menu_text),
        height=Dimension(preferred=8, min=6, max=12),
        wrap_lines=False,
        style="class:menu.container",
    )
    history_window = Window(
        content=BufferControl(buffer=history_buffer, focusable=False),
        height=lambda: Dimension.exact(len(history_lines)),
        wrap_lines=True,
        style="class:menu.container",
        always_hide_cursor=True,
    )
    footer_window = Window(
        content=FormattedTextControl(_footer_text),
        height=1,
        style="class:footer",
    )
    input_border_top = Window(
        content=FormattedTextControl(_input_border_text),
        height=1,
        style="class:input.prompt",
    )
    input_border_bottom = Window(
        content=FormattedTextControl(_input_border_text),
        height=1,
        style="class:input.prompt",
    )
    scrollable_body = HSplit(
        [
            header_window,
            separator_window,
            history_window,
            input_border_top,
            input_row,
            input_border_bottom,
            menu_window,
            footer_window,
        ]
    )
    scroll_pane = ScrollablePane(
        scrollable_body,
        keep_cursor_visible=False,
        keep_focused_window_visible=False,
        show_scrollbar=True,
    )
    root_container = scroll_pane

    color_depth = None
    try:
        color_depth = output.get_default_color_depth()
    except Exception:
        color_depth = None
    color_depth_value = getattr(color_depth, "value", color_depth)
    theme_name = _resolve_theme_name(args.theme, color_depth_value)
    style = Style.from_dict(_theme_style(theme_name))

    key_bindings = merge_key_bindings([load_key_bindings(), kb])
    app = Application(
        layout=Layout(root_container, focused_element=buffer),
        key_bindings=key_bindings,
        full_screen=True,
        style=style,
        input=input,
        output=output,
    )
    _scroll_to_bottom()

    return app, state


def _interactive_session(args: argparse.Namespace, debug: Callable[[str], None]) -> int:
    try:
        app, _state = _build_interactive_app(args, debug)
    except RuntimeError:
        sys.stderr.write("Interactive mode requires prompt_toolkit for command completion.\n")
        return 2
    try:
        result = app.run()
    except (EOFError, KeyboardInterrupt):
        return 0
    if result == "ui":
        try:
            ui_app, _state = _build_ui_app(args, debug)
        except RuntimeError:
            sys.stderr.write("UI mode requires prompt_toolkit.\n")
            return 2
        try:
            ui_app.run()
        except (EOFError, KeyboardInterrupt):
            return 0
    return 0


def _run_conversion(inputs: list[Path], args: argparse.Namespace, debug: Callable[[str], None]) -> int:
    debug(f"Inputs: {[str(path) for path in inputs]}")
    stats_records: list[dict] = []
    stats_started = time.perf_counter()
    history = _init_history_logger(debug)
    run_id: Optional[int] = None
    if history is not None:
        try:
            run_id = history.start_run(
                engine=args.engine,
                fmt=args.format,
                options=_history_options(args),
                cwd=os.getcwd(),
                version=__version__,
            )
        except Exception as exc:
            debug(f"History start failed: {exc}")
            history.close()
            history = None

    def _finish(exit_code: int) -> int:
        if args.stats and stats_records:
            for record in stats_records:
                sys.stderr.write(_format_stats_line(record) + "\n")
        if args.profile_report:
            report = {
                "exit_code": exit_code,
                "inputs": stats_records,
                "total_ms": (time.perf_counter() - stats_started) * 1000.0,
            }
            try:
                report_path = Path(args.profile_report)
                report_path.parent.mkdir(parents=True, exist_ok=True)
                report_path.write_text(
                    json.dumps(report, sort_keys=True),
                    encoding="utf-8",
                )
            except OSError as exc:
                sys.stderr.write(f"Profile report write failed: {exc}\n")
                if exit_code == 0:
                    exit_code = 5
        if history is not None and run_id is not None:
            status = "success" if exit_code == 0 else "error"
            try:
                history.finish_run(run_id, status=status, exit_code=exit_code)
            except Exception as exc:
                debug(f"History finish failed: {exc}")
            finally:
                history.close()
        return exit_code

    if args.engine != "pdfminer":
        if args.format and args.format != "pdf":
            sys.stderr.write("--engine is only valid for PDF inputs.\n")
            return _finish(2)
        if not args.format:
            if any(_detect_format(str(path)) != "pdf" for path in inputs):
                sys.stderr.write("--engine is only valid for PDF inputs.\n")
                return _finish(2)

    if args.preview:
        if len(inputs) != 1:
            sys.stderr.write("--preview supports a single input.\n")
            return _finish(2)
        outputs = [None]
    else:
        if len(inputs) > 1 and not args.yes:
            if not sys.stdin.isatty():
                sys.stderr.write("Batch conversion requires --yes.\n")
                return _finish(2)
            try:
                response = (
                    input(f"Convert {len(inputs)} files? [y/N] ").strip().lower()
                )
            except EOFError:
                sys.stderr.write("Batch conversion requires --yes.\n")
                return _finish(2)
            if response not in {"y", "yes"}:
                sys.stderr.write("Aborted.\n")
                return _finish(2)
        try:
            outputs = _resolve_outputs(inputs, args.output)
        except ValueError as exc:
            sys.stderr.write(f"{exc}\n")
            return _finish(2)

    for path, output_path in zip(inputs, outputs):
        try:
            detect_start = time.perf_counter()
            fmt = args.format or _detect_format(str(path))
            detect_ms = (time.perf_counter() - detect_start) * 1000.0
        except UnsupportedFormatError as exc:
            sys.stderr.write(f"{exc}\n")
            return _finish(3)

        debug(f"Converting {path} as {fmt}")
        if history is not None and run_id is not None:
            try:
                stat = path.stat()
                input_hash = history_db.sha256_file(path)
                history.add_input(
                    run_id,
                    path=path,
                    sha256=input_hash,
                    size_bytes=stat.st_size,
                    mtime_ns=stat.st_mtime_ns,
                )
            except Exception as exc:
                debug(f"History input log failed for {path}: {exc}")
        try:
            convert_start = time.perf_counter()
            content = _convert(
                str(path),
                fmt=fmt,
                engine=args.engine,
                csv_na=args.csv_na,
                csv_float_format=args.csv_float_format,
                ocr_mode=args.ocr_mode,
                ocr_lang=args.ocr_lang,
                ocr_device=args.ocr_device,
                ocr_render_scale=args.ocr_render_scale,
                ocr_min_score=args.ocr_min_score,
            )
            convert_ms = (time.perf_counter() - convert_start) * 1000.0
        except UnsupportedFormatError as exc:
            sys.stderr.write(f"{exc}\n")
            return _finish(3)
        except FileNotFoundError as exc:
            sys.stderr.write(f"{exc}\n")
            return _finish(4)
        except ConversionError as exc:
            sys.stderr.write(f"{path}: {exc}\n")
            return _finish(4)
        except Exception as exc:
            sys.stderr.write(f"Conversion failed for {path}: {exc}\n")
            return _finish(4)

        content_bytes = content.encode("utf-8")
        write_start = time.perf_counter()
        if output_path is not None:
            if output_path.exists():
                overwrite_approved = False
                if args.diff:
                    diff_text = _diff_text(output_path, content)
                    if diff_text:
                        sys.stderr.write(diff_text)
                        if not diff_text.endswith("\n"):
                            sys.stderr.write("\n")
                if not args.yes:
                    if not sys.stdin.isatty():
                        sys.stderr.write(
                            f"Refusing to overwrite {output_path} without --yes.\n"
                        )
                        return _finish(5)
                    try:
                        response = (
                            input(f"Overwrite {output_path}? [y/N] ").strip().lower()
                        )
                    except EOFError:
                        sys.stderr.write(
                            f"Refusing to overwrite {output_path} without --yes.\n"
                        )
                        return _finish(5)
                    if response not in {"y", "yes"}:
                        sys.stderr.write("Aborted.\n")
                        return _finish(5)
                    overwrite_approved = True
                else:
                    overwrite_approved = True
                if overwrite_approved and history is not None and run_id is not None:
                    try:
                        history.add_warning(
                            run_id,
                            code="overwrite",
                            message=f"Overwriting {output_path}",
                        )
                    except Exception as exc:
                        debug(f"History warning log failed for {output_path}: {exc}")
            try:
                output_path.parent.mkdir(parents=True, exist_ok=True)
                with open(output_path, "w", encoding="utf-8", newline="\n") as f:
                    f.write(content)
                debug(f"Wrote {output_path}")
            except OSError as exc:
                sys.stderr.write(f"Output write failed: {exc}\n")
                return _finish(5)
            if history is not None and run_id is not None:
                try:
                    history.add_output(
                        run_id,
                        path=output_path,
                        bytes_len=len(content_bytes),
                        sha256=history_db.sha256_bytes(content_bytes),
                        written=True,
                    )
                except Exception as exc:
                    debug(f"History output log failed for {output_path}: {exc}")
            if not args.quiet:
                sys.stderr.write(f"Converted {path.name} -> {output_path}\n")
        else:
            sys.stdout.write(content)
            if history is not None and run_id is not None:
                try:
                    history.add_output(
                        run_id,
                        path=None,
                        bytes_len=len(content_bytes),
                        sha256=history_db.sha256_bytes(content_bytes),
                        written=False,
                    )
                except Exception as exc:
                    debug(f"History output log failed for stdout: {exc}")
            if not args.quiet:
                sys.stderr.write(f"Converted {path.name} -> stdout\n")
        write_ms = (time.perf_counter() - write_start) * 1000.0
        total_ms = detect_ms + convert_ms + write_ms
        stats_records.append(
            {
                "input": str(path),
                "output": str(output_path) if output_path is not None else None,
                "stages_ms": {
                    "detect": detect_ms,
                    "convert": convert_ms,
                    "write": write_ms,
                },
                "total_ms": total_ms,
            }
        )
        if history is not None and run_id is not None:
            try:
                timing_meta = {
                    "input": str(path),
                    "output": str(output_path) if output_path is not None else None,
                }
                history.add_timing(
                    run_id,
                    stage="detect",
                    duration_ms=detect_ms,
                    meta=timing_meta,
                )
                history.add_timing(
                    run_id,
                    stage="convert",
                    duration_ms=convert_ms,
                    meta=timing_meta,
                )
                history.add_timing(
                    run_id,
                    stage="write",
                    duration_ms=write_ms,
                    meta=timing_meta,
                )
                history.add_timing(
                    run_id,
                    stage="total",
                    duration_ms=total_ms,
                    meta=timing_meta,
                )
            except Exception as exc:
                debug(f"History timing log failed for {path}: {exc}")
    if not args.quiet and sys.stderr.isatty():
        time.sleep(2)
    return _finish(0)


def main(argv: Optional[list] = None) -> int:
    if argv is None:
        argv = sys.argv[1:]
    jump_exit = _run_jump_command(argv)
    if jump_exit is not None:
        return jump_exit
    explain_exit = _run_explain_command(argv)
    if explain_exit is not None:
        return explain_exit
    ui_exit = _run_ui_command(argv)
    if ui_exit is not None:
        return ui_exit
    recent_exit = _run_recent_command(argv)
    if recent_exit is not None:
        return recent_exit
    profile_exit = _run_profile_command(argv)
    if profile_exit is not None:
        return profile_exit
    history_exit = _run_history_command(argv)
    if history_exit is not None:
        return history_exit
    parser = argparse.ArgumentParser(description="Convert documents to Markdown")
    parser.add_argument("input", nargs="?", help="Input file (pdf, docx, csv, image)")
    parser.add_argument(
        "-o",
        "--output",
        help=(
            "Output markdown file (default: docs_out/<input>.md; for multiple inputs,"
            " use a directory; use '-' for stdout)"
        ),
    )
    parser.add_argument(
        "--format",
        choices=sorted(SUPPORTED_FORMATS),
        help="Explicit input format (otherwise inferred from extension)",
    )
    parser.add_argument(
        "--engine",
        choices=sorted(SUPPORTED_PDF_ENGINES),
        default="pdfminer",
        help="PDF engine (pdfminer default; pdftext/marker are opt-in)",
    )
    parser.add_argument(
        "--ocr",
        action="store_true",
        help="Enable OCR fallback for PDFs (equivalent to --ocr-mode auto)",
    )
    parser.add_argument(
        "--ocr-mode",
        choices=["never", "auto", "always"],
        default="never",
        help="OCR behavior for PDFs (default: never)",
    )
    parser.add_argument(
        "--ocr-lang",
        default="es",
        help="OCR language code (default: es)",
    )
    parser.add_argument(
        "--ocr-device",
        default=None,
        help="OCR device override (e.g. cpu, gpu:0)",
    )
    parser.add_argument(
        "--ocr-render-scale",
        type=float,
        default=2.0,
        help="OCR PDF render scale (default: 2.0)",
    )
    parser.add_argument(
        "--ocr-min-score",
        type=float,
        default=None,
        help="Minimum OCR confidence to keep text",
    )
    parser.add_argument(
        "--csv-na",
        default="",
        help="CSV NA representation (default: empty string)",
    )
    parser.add_argument(
        "--csv-float-format",
        default=None,
        help="CSV float format string (default: pandas default)",
    )
    parser.add_argument(
        "--stats",
        action="store_true",
        help="Print per-input timing stats to stderr",
    )
    parser.add_argument(
        "--profile-report",
        help="Write a JSON timing report to the given path",
    )
    parser.add_argument(
        "--theme",
        choices=THEME_NAMES,
        default="auto",
        help="Interactive UI theme (auto, light, dark, minimal, dense)",
    )
    parser.add_argument(
        "--profile",
        help="Profile name to load from doc2md.toml",
    )
    parser.add_argument(
        "--preview",
        action="store_true",
        help="Render output to stdout without writing files (single input only)",
    )
    parser.add_argument(
        "--diff",
        action="store_true",
        help="Show unified diff when overwriting output files",
    )
    parser.add_argument(
        "--yes",
        action="store_true",
        help="Auto-approve overwriting output files",
    )
    parser.add_argument(
        "--interactive",
        action="store_true",
        help="Force interactive mode when no input is provided",
    )
    parser.add_argument(
        "--no-input",
        action="store_true",
        help="Disable interactive prompts and require an explicit input",
    )
    parser.add_argument(
        "--quiet",
        action="store_true",
        help="Suppress non-error output",
    )
    parser.add_argument(
        "--debug",
        action="store_true",
        help="Enable debug logging to stderr",
    )
    parser.add_argument(
        "--version",
        action="version",
        version=f"doc2md {__version__}",
    )

    try:
        args = parser.parse_args(argv)
    except SystemExit as exc:
        return int(exc.code)

    if args.profile:
        try:
            profiles = _load_profiles(_profile_path())
        except FileNotFoundError:
            sys.stderr.write("Profile file not found.\n")
            return 2
        except tomllib.TOMLDecodeError as exc:
            sys.stderr.write(f"Profile file is invalid TOML: {exc}\n")
            return 2
        except ValueError as exc:
            sys.stderr.write(f"{exc}\n")
            return 2
        base = profiles.get("default", {})
        profile = profiles.get(args.profile)
        if profile is None:
            sys.stderr.write("Profile not found.\n")
            return 2
        merged = {}
        if isinstance(base, dict):
            merged.update(base)
        if isinstance(profile, dict):
            merged.update(profile)
        _apply_profile_to_args(args, merged)

    if args.ocr and args.ocr_mode == "never":
        args.ocr_mode = "auto"

    def debug(message: str) -> None:
        if args.debug and not args.quiet:
            sys.stderr.write(f"DEBUG: {message}\n")

    try:
        if args.input:
            if args.interactive:
                debug("Ignoring --interactive because an input path was provided.")
            if args.no_input:
                debug("Ignoring --no-input because an input path was provided.")
            if _looks_like_slash_command(args.input):
                sys.stderr.write("Slash commands are only available in interactive mode.\n")
                return 2
            return _run_conversion([Path(args.input)], args, debug)
        if args.no_input or not (sys.stdin.isatty() and sys.stdout.isatty()):
            message = "Input is required (--no-input is set).\n"
            if not args.no_input:
                message = "Input is required when not running in an interactive terminal.\n"
            sys.stderr.write(message)
            return 2
        return _interactive_session(args, debug)
    except KeyboardInterrupt:
        return 0


if __name__ == "__main__":
    raise SystemExit(main())


