"""Textual inspector app for `acidcat tui`.

Read + edit front-end over the same engine the CLI uses: walk_file for the
structure, core.anomalies for the forensic findings, and the write engine
(commands.write._edit + core.writer.commit) for the "sane" metadata editor.

Two edit modes:
- press e on a field to edit its raw bytes in place (a power tool: change
  sample_rate = 44100 to 69 and it patches those exact bytes). Where a field's
  displayed value round-trips to its bytes with a known struct format, you type
  the value and the hex pane previews the new bytes live; otherwise you edit the
  hex directly. Length-preserving, atomic, leaves a _original backup.
- press w for the exiftool-style metadata editor (title/artist/bpm/key/...).

Imported lazily by commands/tui.py so textual stays behind the [tui] extra and
the core remains zero-dependency.
"""
import os
import shutil
import struct
import tempfile

from rich.text import Text

from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, Vertical, VerticalScroll
from textual.screen import ModalScreen
from textual.widgets import (
    Button, DataTable, DirectoryTree, Footer, Input, Label, Static, Tree,
)

from acidcat.core.walk import walk_file, Unsupported
from acidcat.core import anomalies as ac_anom
from acidcat.core import locate as locatemod
from acidcat.core import transforms as transformsmod
from acidcat.core import cdxa as cdxamod
from acidcat.core import vag as vagmod
from acidcat.core import iso9660 as isomod
from acidcat.core import audioscan as audioscanmod
from acidcat.core import writer
from acidcat.core import viz
from acidcat.commands.carve import _EXT as _CARVE_EXT
from acidcat.util import play
from acidcat.core.edits import EditError
from acidcat.commands.write import _edit as _write_edit, _strip as _write_strip


# brand theme (ink / gunmetal + teal/orange accents); source of truth is
# acidcat/tui_theme.py, imported by the playground TUI too so they cannot drift.
from acidcat.tui_theme import (
    PALETTE, ACCENT, FG, SOFT, DIM, GUTTER, PEND, AMBER, SEV, byte_color,
)

_SPIN = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"  # braille scan spinner
_BAR_W = 18            # width of the scan progress bar

_HEX_CAP = 1024        # most bytes to render in the hex pane for one node
_ROW_CAP = 400         # most per-element rows (events/frames) to list per chunk
_HEXEDIT_CAP = 512     # refuse editing a byte region bigger than this (pick a field)
_UNDO_CAP = 50         # most undo deltas to keep
_UNDO_BYTES_CAP = 64 * 1024 * 1024   # total delta bytes kept (latest always kept)
_DIFF_CAP = 200        # most changed regions to list in the pending-changes view
_LARGE_FILE = 64 * 1024 * 1024       # above this, browse in place (no working copy)
_SCAN_SEG = 16 * 1024 * 1024         # scan a blob in segments: live progress + cancel

# the field value<->bytes engine (struct inference, named codecs, the three
# bit-field encodings) lives in core/fieldcodec.py so the CLI and tests share
# it without a textual dependency; names are re-exported here for existing
# importers.
from acidcat.core.fieldcodec import (  # noqa: F401
    _BE_FMTS, _BITMAPS, _CODECS, _DYNMAPS, _field_abs, _resolve_in_map,
    bitfield_apply, bitfield_extract, decode_value, enc_size, encode_value,
    infer_enc, parse_bitfield, parse_bitsdyn, parse_bitsmap, resolve_bitsmap,
)


def _read(path, off, length):
    try:
        with open(path, "rb") as f:
            f.seek(off)
            return f.read(length)
    except OSError:
        return b""


def _fuzzy(query, text):
    """fzf-style subsequence match: every char of `query` appears in `text`, in
    order, case-insensitively. The default TUI search over field names/values."""
    q, t = query.lower(), text.lower()
    i = 0
    for ch in t:
        if i < len(q) and ch == q[i]:
            i += 1
    return i == len(q)


def hex_text(path, off, length, accent, spans=None):
    """A colored hex dump (offset gutter + hex columns + ascii) of up to
    _HEX_CAP bytes starting at off. Bytes render in `accent`; when `spans` (a
    list of (abs_offset, len) field ranges) is given, each field's bytes take a
    distinct palette color so a chunk's field structure shows in the hex.
    Non-printable ascii dims out."""
    t = Text()
    if off is None or length in (None, 0):
        t.append("  (no byte range for this node)", style=DIM)
        return t
    shown = min(length, _HEX_CAP)
    raw = _read(path, off, shown)
    _hex_rows(t, off, raw, accent, _spans_cmap(off, spans, shown) if spans else None)
    if length > shown:
        t.append(f"  .. {length - shown:,} more bytes\n", style=DIM)
    return t


def _spans_cmap(base_off, spans, limit):
    """Map each shown byte position (relative to base_off) to a per-field color,
    cycling the palette across the fields."""
    cmap = {}
    for i, (ao, ln) in enumerate(spans):
        color = PALETTE[i % len(PALETTE)]
        start = ao - base_off
        for p in range(max(0, start), min(limit, start + ln)):
            cmap[p] = color
    return cmap


def _hex_rows(t, off, raw, byte_style, cmap=None):
    """Append hex-dump rows (gutter + hex + ascii) for `raw` to Text `t`. With a
    `cmap` (position -> color), each byte takes its field color."""
    for row in range(0, len(raw), 16):
        chunk = raw[row:row + 16]
        t.append(f"{off + row:08x}  ", style=GUTTER)
        for i in range(16):
            if i < len(chunk):
                style = cmap.get(row + i, byte_style) if cmap else byte_style
                t.append(f"{chunk[i]:02x} ", style=style)
            else:
                t.append("   ")
            if i == 7:
                t.append(" ")
        t.append(" ")
        for b in chunk:
            ch = chr(b) if 32 <= b < 127 else "."
            t.append(ch, style=FG if 32 <= b < 127 else DIM)
        t.append("\n")


# editable-field profiles, mirroring what the write engine accepts per format.
# (field, label) -- field is the --set name commands.write understands.
_WAV_FIELDS = [("title", "title"), ("artist", "artist"), ("album", "album"),
               ("genre", "genre"), ("comment", "comment"), ("date", "date"),
               ("bpm", "bpm"), ("key", "key"),
               ("root_note", "root note (C3 or 60)")]
_AIFF_FIELDS = [("title", "title"), ("artist", "artist"), ("comment", "comment")]
_TAGGED_FIELDS = [("title", "title"), ("artist", "artist"), ("album", "album"),
                  ("genre", "genre"), ("comment", "comment"), ("date", "date"),
                  ("bpm", "bpm"), ("key", "key")]
_VITAL_FIELDS = [("name", "preset name"), ("author", "author"),
                 ("comment", "comments")]


def edit_profile(path):
    """Return (profile_name, [(field, label), ...]) for the file's format, or
    None where the write engine has no editor (or editing is disabled, e.g.
    Bitwig/NI). Routing mirrors commands.write._edit so the form only offers
    fields a save can actually apply."""
    ext = os.path.splitext(path)[1].lower()
    with open(path, "rb") as f:
        head = f.read(16)
    if ext == ".vital" or head[:1] == b"{":
        return ("Vital", _VITAL_FIELDS)
    if head[:4] == b"RIFF" and head[8:12] == b"WAVE":
        return ("WAV", _WAV_FIELDS)
    # Bitwig / NI preset writing is disabled in the engine; do not offer it.
    if (head[:4] == b"BtWg" or head[12:16] == b"hsin" or head[:4] == b"-in-"
            or (head[:4] == b"RIFF" and head[8:12] == b"NIKS")):
        return None
    if head[:4] == b"FORM" and head[8:12] in (b"AIFF", b"AIFC"):
        return ("AIFF", _AIFF_FIELDS)
    tagged = (head[:4] == b"fLaC" or head[:3] == b"ID3" or head[:4] == b"OggS"
              or head[4:8] == b"ftyp"
              or ext in (".mp3", ".flac", ".ogg", ".oga", ".opus", ".m4a", ".mp4"))
    if tagged:
        return ("tagged", _TAGGED_FIELDS)
    return None


class HexPane(Static):
    """The right-hand hex view. Focusable so it can host in-place hex editing:
    when the app is in hex-edit mode, key events route to the app's handler
    (cursor movement + nibble overwrite); otherwise it behaves as a plain
    read-only pane."""
    can_focus = True

    def on_key(self, event):
        if getattr(self.app, "_hexedit", None):
            self.app._hexedit_key(event)


# tagged-audio text fields the write engine (mutagen) can set, keyed by the
# walker's field name: ID3 frame ids (mp3) and Vorbis comment keys (flac/ogg).
_ID3_TEXT = {"TIT2": "title", "TPE1": "artist", "TALB": "album", "TCON": "genre",
             "COMM": "comment", "TDRC": "date", "TYER": "date", "TBPM": "bpm",
             "TKEY": "key", "TRCK": "track"}
_VORBIS_TEXT = {"TITLE": "title", "ARTIST": "artist", "ALBUM": "album",
                "GENRE": "genre", "COMMENT": "comment", "DESCRIPTION": "comment",
                "DATE": "date", "BPM": "bpm", "KEY": "key", "INITIALKEY": "key",
                "TRACKNUMBER": "track"}


def text_field_for(profile, field_name):
    """If `field_name` (a walker field name) is a variable-length text field the
    write engine can edit, return the engine field name to route it through;
    else None. These must NOT be same-length byte-patched -- a longer title
    shifts the file -- so the editor re-serializes via the metadata engine."""
    if profile == "WAV":
        from acidcat.core.edit_riff import _INFO_TAGS
        rev = {v.decode("latin1").strip(): k for k, v in _INFO_TAGS.items()}
        return rev.get(field_name)
    if profile == "AIFF":
        from acidcat.core.edit_aiff import _AIFF_TEXT
        rev = {v.decode("latin1").strip(): k for k, v in _AIFF_TEXT.items()}
        return rev.get(field_name)
    if profile == "tagged":
        n = field_name.strip()
        return _ID3_TEXT.get(n) or _VORBIS_TEXT.get(n.upper())
    return None


class BrowseScreen(ModalScreen):
    """A file picker: navigate a directory tree, enter selects, esc cancels.
    dismiss()es with the chosen path string, or None on cancel."""

    CSS = """
    BrowseScreen { align: center middle; }
    #browsebox { width: 80%; height: 80%; border: round #08F9DF;
                 background: #16181C; padding: 1 2; }
    #browsehint { color: #8A9099; padding-bottom: 1; }
    DirectoryTree { background: #16181C; }
    """
    BINDINGS = [("escape", "cancel", "cancel")]

    def __init__(self, start):
        super().__init__()
        self.start = start

    def compose(self) -> ComposeResult:
        with Vertical(id="browsebox"):
            yield Static(Text("open a file  (enter selects, esc cancels)",
                              style=f"bold {ACCENT}"), id="browsehint")
            yield DirectoryTree(self.start, id="dtree")

    def on_directory_tree_file_selected(self, event):
        self.dismiss(str(event.path))

    def action_cancel(self):
        self.dismiss(None)


class EditScreen(ModalScreen):
    """The exiftool-style metadata editor: the write engine's supported fields
    for this format. Blank inputs are left unchanged; typed values are applied
    via commands.write._edit + core.writer.commit (atomic, leaves a _original
    backup). dismiss()es with a result dict on a successful write, else None."""

    CSS = """
    EditScreen { align: center middle; }
    #editbox { width: 72; height: auto; max-height: 90%; border: round #FF4D00;
               background: #16181C; padding: 1 2; }
    #edittitle { color: #FF4D00; text-style: bold; padding-bottom: 1; }
    #edithint { color: #565B63; padding-bottom: 1; }
    #editstatus { color: #FF4D00; padding-top: 1; }
    EditScreen Label { color: #8A9099; }
    EditScreen Input { margin-bottom: 1; }
    #editbtns { height: auto; padding-top: 1; }
    """
    BINDINGS = [("escape", "cancel", "cancel"), ("ctrl+s", "save", "save")]

    def __init__(self, path, profile, fields):
        super().__init__()
        self.path = path
        self.profile = profile
        self.fields = fields

    def compose(self) -> ComposeResult:
        with VerticalScroll(id="editbox"):
            yield Static(f"edit metadata  [{self.profile}]", id="edittitle")
            yield Static("type to set a field; leave blank to keep current. "
                         "ctrl+s saves (makes a _original backup), esc cancels.",
                         id="edithint")
            for field, label in self.fields:
                yield Label(label)
                yield Input(id=f"f_{field}", placeholder=f"{field} (unchanged)")
            yield Static("", id="editstatus")
            with Horizontal(id="editbtns"):
                yield Button("save", id="save", variant="warning")
                yield Button("cancel", id="cancel")

    def on_button_pressed(self, event):
        if event.button.id == "save":
            self.action_save()
        else:
            self.dismiss(None)

    def action_cancel(self):
        self.dismiss(None)

    def action_save(self):
        changes = {}
        for field, _ in self.fields:
            val = self.query_one(f"#f_{field}", Input).value.strip()
            if val:
                changes[field] = val
        if not changes:
            self.dismiss(None)
            return
        status = self.query_one("#editstatus", Static)
        try:
            _fmt, new_data, applied = _write_edit(self.path, changes)
        except (EditError, OSError, ValueError) as e:
            status.update(Text(f"error: {e}", style=SEV["alert"]))
            return
        self.dismiss({"new_data": new_data, "applied": applied})


class ConfirmScreen(ModalScreen):
    """Unsaved-changes prompt. dismiss()es with 'save', 'discard', or None
    (cancel)."""

    CSS = """
    ConfirmScreen { align: center middle; }
    #confbox { width: 60; height: auto; border: round #FF4D00;
               background: #16181C; padding: 1 2; }
    #confmsg { color: #C9CDD3; padding-bottom: 1; }
    #confbtns { height: auto; }
    """
    BINDINGS = [("escape", "cancel", "cancel")]

    def __init__(self, prompt):
        super().__init__()
        self.prompt = prompt

    def compose(self) -> ComposeResult:
        with Vertical(id="confbox"):
            yield Static(Text(self.prompt, style=f"bold {PEND}"), id="confmsg")
            with Horizontal(id="confbtns"):
                yield Button("save", id="save", variant="success")
                yield Button("discard", id="discard", variant="error")
                yield Button("cancel", id="cancel")

    def on_button_pressed(self, event):
        self.dismiss(event.button.id if event.button.id != "cancel" else None)

    def action_cancel(self):
        self.dismiss(None)


class HelpScreen(ModalScreen):
    """Key reference overlay. Any of esc / ? closes it."""

    CSS = """
    HelpScreen { align: center middle; }
    #helpbox { width: 74; height: auto; max-height: 90%; border: round #08F9DF;
               background: #16181C; padding: 1 2; }
    """
    BINDINGS = [("escape", "close", "close"), ("question_mark", "close", "close")]

    def compose(self) -> ComposeResult:
        t = Text()
        t.append("acidcat tui  --  keys\n\n", style=f"bold {ACCENT}")
        rows = [
            ("arrows / enter", "move + expand the tree"),
            ("a / c", "expand all / collapse all"),
            ("g", "goto offset (0x.. or decimal)"),
            ("/", "search: text=fuzzy name/value, 0x..=hex, \"..\"=ascii"),
            ("n / N", "next / previous search match"),
            ("f", "jump to the next forensics finding"),
            ("x", "follow a pointer field to where it points (flags dangling)"),
            ("m", "byte map: where the file's bytes go, biggest regions first"),
            ("b", "byte view: cycle hex / entropy / hilbert / histogram of the file"),
            ("p", "play the selected region as raw PCM (. stops); needs ffplay"),
            ("v", "validate structure: constraint violations, r to repair them"),
            ("y", "yank the selected bytes as hex to the clipboard"),
            ("d", "review all pending changes (offset old->new) before save"),
            ("e", "edit the selected field (value or hex)"),
            ("ctrl+t", "toggle the edit between value and raw hex"),
            ("tab", "hex-edit the field in the pane (arrows move, 0-9a-f type)"),
            ("w", "edit tags (metadata form)"),
            ("s", "strip identifying metadata"),
            ("ctrl+s", "save to the original (writes a _original backup)"),
            ("ctrl+z / ctrl+r", "undo / redo the last edit"),
            ("o", "open another file"),
            ("l", "locate audio regions in a blob / disk image (auto for a blob)"),
            ("u", "from a region, go back up to the region browser"),
            ("esc", "cancel the current edit / prompt"),
            ("q", "quit"),
        ]
        for k, d in rows:
            t.append(f"  {k:16}", style=f"bold {PEND}")
            t.append(f"{d}\n", style=SOFT)
        t.append("\nRegion browser (a blob opens straight into it): enter descends "
                 "into a region as if it were a file, x / e extract one / all, "
                 "m cycles the forensics mode (strict/normal/aggressive), t toggles "
                 "the transform lens (audio hidden under XOR/rotate/nibble-swap), "
                 "c carves an arbitrary offset+length, / searches raw bytes. A big "
                 "image scans in segments with live progress: space pauses/resumes, "
                 "enter keeps what was found so far, esc discards and backs out.",
                 style=DIM)
        t.append("\nDisc audio browser (a PS1 / CD-XA disc image opens into it): the "
                 "game's .STR soundtrack tracks and .VB/.VAG SPU sound banks, from the "
                 "ISO filesystem. enter / p auditions the selected track, x / a extract "
                 "one / all to WAV.", style=DIM)
        t.append("\nEdits go to a temp working copy; nothing touches the original "
                 "until ctrl+s.", style=DIM)
        with Vertical(id="helpbox"):
            yield Static(t)

    def action_close(self):
        self.dismiss(None)


class DiffScreen(ModalScreen):
    """Review all pending byte changes (working copy vs the original) before a
    save. Any of esc / d closes it."""

    CSS = """
    DiffScreen { align: center middle; }
    #diffbox { width: 82; height: auto; max-height: 90%; border: round #FF4D00;
               background: #16181C; padding: 1 2; }
    """
    BINDINGS = [("escape", "close", "close"), ("d", "close", "close")]

    def __init__(self, regions, src_len, work_len):
        super().__init__()
        self.regions = regions
        self.src_len = src_len
        self.work_len = work_len

    def compose(self) -> ComposeResult:
        t = Text()
        t.append("pending changes  ", style=f"bold {ACCENT}")
        if self.src_len != self.work_len:
            t.append(f"(file size {self.src_len:,} -> {self.work_len:,} bytes)\n",
                     style=SOFT)
        elif not self.regions:
            t.append("none -- working copy matches the original\n", style=SOFT)
        else:
            t.append(f"{len(self.regions)} region(s) vs the original\n", style=SOFT)
        for off, old, new in self.regions[:_DIFF_CAP]:
            t.append(f"\n0x{off:08x}  ", style=f"bold {PEND}")
            t.append(f"{len(old)}B\n", style=DIM)
            t.append("  old ", style=SOFT)
            t.append(old[:24].hex(" ") + (" .." if len(old) > 24 else ""), style=DIM)
            t.append("\n  new ", style=SOFT)
            t.append(new[:24].hex(" ") + (" .." if len(new) > 24 else ""),
                     style=PEND)
            t.append("\n")
        if len(self.regions) > _DIFF_CAP:
            t.append(f"\n.. {len(self.regions) - _DIFF_CAP} more regions\n", style=DIM)
        t.append("\nctrl+s to save, esc to keep editing.", style=DIM)
        with Vertical(id="diffbox"):
            yield Static(t)

    def action_close(self):
        self.dismiss(None)


class MapScreen(ModalScreen):
    """A byte-budget map: where the file's bytes actually go, top-level regions
    biggest first with a proportional bar. Any of esc / m closes it."""

    CSS = """
    MapScreen { align: center middle; }
    #mapbox { width: 86; height: auto; max-height: 90%; border: round #08F9DF;
              background: #16181C; padding: 1 2; }
    """
    BINDINGS = [("escape", "close", "close"), ("m", "close", "close")]

    def __init__(self, segments, fsize, unaccounted):
        super().__init__()
        self.segments = segments
        self.fsize = fsize
        self.unaccounted = unaccounted

    def compose(self) -> ComposeResult:
        t = Text()
        t.append("byte map  ", style=f"bold {ACCENT}")
        t.append(f"{self.fsize:,} bytes, {len(self.segments)} top-level region(s)\n",
                 style=SOFT)
        for i, (cid, off, size, pct, accent) in enumerate(self.segments):
            bar = "#" * max(1, round(pct / 100 * 40)) if size else ""
            t.append(f"\n0x{off:08x}  ", style=DIM)
            t.append(f"{cid:<8}", style=f"bold {accent}")
            t.append(f"{size:>12,}  {pct:5.1f}%\n", style=SOFT)
            t.append("  " + bar + "\n", style=accent)
        if self.unaccounted > 0:
            t.append(f"\n{self.unaccounted:,} bytes unaccounted "
                     f"({self.unaccounted / self.fsize * 100:.1f}%): gaps, chunk "
                     f"headers, or trailing data\n", style=f"bold {SEV['warn']}")
        t.append("\nesc / m to close.", style=DIM)
        with Vertical(id="mapbox"):
            yield Static(t)

    def action_close(self):
        self.dismiss(None)


class ValidateScreen(ModalScreen):
    """The constraint model's read-only face inside the TUI: the derived-field
    violations of the working copy, each with the witness that makes it fixable.
    `r` applies the witnessed repairs to the working copy (still unsaved); esc /
    v close."""

    CSS = """
    ValidateScreen { align: center middle; }
    #valbox { width: 86; height: auto; max-height: 90%; border: round #08F9DF;
              background: #16181C; padding: 1 2; }
    """
    BINDINGS = [("escape", "close", "close"), ("v", "close", "close"),
                ("r", "repair", "repair")]

    def __init__(self, report):
        super().__init__()
        self.report = report

    def compose(self) -> ComposeResult:
        t = Text()
        t.append("validate  ", style=f"bold {ACCENT}")
        t.append(f"[{self.report.label}]\n", style=SOFT)
        vios = self.report.violations
        fixable = self.report.repairable
        self._can_repair = bool(fixable)
        if not vios:
            t.append("\nstructurally consistent -- every derived field matches "
                     "its function.\n", style=f"bold {SEV['notice']}")
        else:
            t.append(f"\n{len(vios)} violation(s), {len(fixable)} witnessed\n",
                     style=SOFT)
            for v in vios:
                t.append(f"\n  {v.kind:<7}", style=f"bold {ACCENT}")
                t.append(f"{v.describe()}\n", style=SOFT)
                wit = f"witness: {v.witness}" if v.witness else "no witness -- left as-is"
                t.append(f"          {wit}\n", style=DIM)
        if self._can_repair:
            t.append(f"\nr to repair {len(fixable)} witnessed field(s) "
                     f"(unsaved), esc / v to close.", style=DIM)
        else:
            t.append("\nesc / v to close.", style=DIM)
        with Vertical(id="valbox"):
            yield Static(t)

    def action_repair(self):
        self.dismiss("repair" if getattr(self, "_can_repair", False) else None)

    def action_close(self):
        self.dismiss(None)


class RegionsScreen(ModalScreen):
    """The blob region browser: the `locate` results as a navigable table.
    enter descends into the selected region (opened as if it were a standalone
    file), x extracts it, e extracts every region. dismiss()es with a dict
    {action: descend|extract|extract_all, index: row}, or None on esc."""

    CSS = """
    RegionsScreen { align: center middle; }
    #regbox { width: 92%; height: 84%; border: round #08F9DF;
              background: #16181C; padding: 1 2; }
    #reghint { color: #8A9099; padding-bottom: 1; }
    #regtable { height: 1fr; }
    DataTable { background: #16181C; }
    """
    BINDINGS = [
        ("x", "extract", "extract one"),
        ("e", "extract_all", "extract all"),
        ("m", "mode", "cycle mode"),
        ("t", "transforms", "transform lens"),
        ("c", "carve", "manual carve"),
        ("slash", "search", "byte search"),
        ("escape", "cancel", "back"),
    ]

    def __init__(self, regions, blob_name, mode="normal", transforms=False):
        super().__init__()
        self.regions = regions
        self.blob_name = blob_name
        self.mode = mode
        self.transforms = transforms

    def compose(self) -> ComposeResult:
        with Vertical(id="regbox"):
            nc = sum(1 for r in self.regions if r["kind"] == "container")
            nt = sum(1 for r in self.regions if r["kind"] == "transformed")
            nb = len(self.regions) - nc - nt
            lens = "  lens:ON" if self.transforms else ""
            yield Static(
                Text(f"{self.blob_name}  --  {len(self.regions)} region(s): "
                     f"{nc} container / {nb} blob"
                     + (f" / {nt} transformed" if nt else "")
                     + f"   [mode:{self.mode}{lens}]", style=f"bold {ACCENT}"),
                id="reghint")
            yield Static(
                Text("enter descend   x/e extract one/all   m mode   "
                     "t transform-lens   c carve range   / byte-search   esc back",
                     style=SOFT), id="regkeys")
            # populate before yield so the table never depends on post-mount
            # query timing (which was flaky for a re-pushed screen)
            t = DataTable(id="regtable")
            t.cursor_type = "row"
            t.zebra_stripes = True
            t.add_columns("#", "offset", "end", "kind", "format", "conf", "length", "geometry")
            for i, r in enumerate(self.regions):
                geo = r.get("geometry") or {}
                gs = ""
                if geo:
                    ch = "stereo" if geo.get("channels") == 2 else "mono"
                    gs = (f"float{geo['width']}" if geo.get("float")
                          else f"{geo.get('endian') or '?'}-{geo.get('width')}bit") + f" {ch}"
                fmt = (r.get("transform") or r.get("format")
                       or (r.get("probe") or {}).get("top") or "raw-pcm")
                t.add_row(str(i), f"0x{r['offset']:08x}", f"0x{r['end']:08x}",
                          r["kind"], fmt,
                          f"{r['confidence']:.2f}", f"{r['length']:,}", gs)
            yield t

    def on_mount(self):
        try:
            self.query_one("#regtable", DataTable).focus()
        except Exception:
            pass

    def on_data_table_row_selected(self, event):
        self.dismiss({"action": "descend", "index": event.cursor_row})

    def _cursor(self):
        return self.query_one("#regtable", DataTable).cursor_row

    def action_extract(self):
        self.dismiss({"action": "extract", "index": self._cursor()})

    def action_extract_all(self):
        self.dismiss({"action": "extract_all", "index": -1})

    def action_mode(self):
        nxt = {"strict": "normal", "normal": "aggressive",
               "aggressive": "strict"}[self.mode]
        self.dismiss({"action": "rescan", "mode": nxt, "transforms": self.transforms})

    def action_transforms(self):
        self.dismiss({"action": "rescan", "mode": self.mode,
                      "transforms": not self.transforms})

    def action_carve(self):
        self.dismiss({"action": "carve"})

    def action_search(self):
        self.dismiss({"action": "search"})

    def action_cancel(self):
        self.dismiss(None)


class DiscScreen(ModalScreen):
    """CD-XA disc audio browser: the game's .STR soundtrack tracks and .VB/.VAG
    SPU sound banks, from the ISO 9660 filesystem. enter/p auditions the selected
    entry, x/a extract one/all. dismiss()es with {action: play|extract|extract_all,
    index} or None on esc."""

    CSS = """
    DiscScreen { align: center middle; }
    #discbox { width: 92%; height: 84%; border: round #08F9DF;
               background: #16181C; padding: 1 2; }
    #dischint { color: #8A9099; padding-bottom: 1; }
    #disctable { height: 1fr; }
    DataTable { background: #16181C; }
    """
    BINDINGS = [
        ("x", "extract", "extract one"),
        ("a", "extract_all", "extract all"),
        ("p", "play", "audition"),
        ("escape", "cancel", "back"),
    ]
    _KIND = {"XA": "soundtrack (XA)", "VB": "sound bank (SPU)", "VAG": "sample (SPU)"}

    def __init__(self, entries, disc_name):
        super().__init__()
        self.entries = entries
        self.disc_name = disc_name

    def compose(self) -> ComposeResult:
        with Vertical(id="discbox"):
            nx = sum(1 for e in self.entries if e["kind"] == "XA")
            nb = sum(1 for e in self.entries if e["kind"] in ("VB", "VAG"))
            yield Static(
                Text(f"{self.disc_name}  --  {len(self.entries)} audio file(s): "
                     f"{nx} soundtrack / {nb} sound bank", style=f"bold {ACCENT}"),
                id="dischint")
            yield Static(Text("enter/p audition   x/a extract one/all   esc back",
                              style=SOFT), id="disckeys")
            t = DataTable(id="disctable")
            t.cursor_type = "row"
            t.zebra_stripes = True
            t.add_columns("#", "name", "kind", "size")
            for i, e in enumerate(self.entries):
                t.add_row(str(i), e["path"], self._KIND.get(e["kind"], e["kind"]),
                          f"{e['size']:,}")
            yield t

    def on_mount(self):
        try:
            self.query_one("#disctable", DataTable).focus()
        except Exception:
            pass

    def _cursor(self):
        return self.query_one("#disctable", DataTable).cursor_row

    def on_data_table_row_selected(self, event):
        self.app._audition_disc(self.entries[event.cursor_row])

    def action_play(self):
        self.app._audition_disc(self.entries[self._cursor()])

    def action_extract(self):
        self.app._extract_disc([self.entries[self._cursor()]])

    def action_extract_all(self):
        self.app._extract_disc(list(self.entries))

    def action_cancel(self):
        self.app.action_stop_play()
        self.dismiss(None)


class PromptScreen(ModalScreen):
    """A one-line text prompt (output dir, carve range, search pattern...). Enter
    submits, esc cancels. dismiss()es with the typed string, or None."""

    CSS = """
    PromptScreen { align: center middle; }
    #dpbox { width: 76; height: auto; border: round #FF4D00;
             background: #16181C; padding: 1 2; }
    #dphint { color: #8A9099; padding-bottom: 1; }
    """
    BINDINGS = [("escape", "cancel", "cancel")]

    def __init__(self, prompt, default):
        super().__init__()
        self.prompt = prompt
        self.default = default

    def compose(self) -> ComposeResult:
        with Vertical(id="dpbox"):
            yield Static(Text(self.prompt, style=f"bold {ACCENT}"), id="dphint")
            yield Input(value=self.default, id="dpinput")

    def on_mount(self):
        self.query_one(Input).focus()

    def on_input_submitted(self, event):
        self.dismiss(event.value.strip() or None)

    def action_cancel(self):
        self.dismiss(None)


class AcidcatTUI(App):
    CSS = """
    Screen { background: #16181C; }
    #tree { width: 48%; border: round #08F9DF; padding: 0 1; }
    #right { width: 52%; }
    #detail { height: auto; border: round #3A3E45; padding: 0 1; color: #C9CDD3; }
    #hexwrap { border: round #08F9DF; }
    #hex { padding: 0 1; }
    #anomwrap { height: 30%; border: round #FF4D00; }
    #anom { height: auto; padding: 0 1; }
    #editbar { dock: bottom; height: 3; border: round #FF4D00; background: #16181C; }
    #editbar.hidden { display: none; }
    Tree { background: #16181C; }
    Tree > .tree--guides { color: #3A3E45; }
    Tree > .tree--guides-selected { color: #08F9DF; }
    """

    BINDINGS = [
        ("q", "request_quit", "quit"),
        ("g", "goto", "goto offset"),
        ("slash", "search", "search"),
        ("n", "search_next", "next match"),
        ("N", "search_prev", "prev match"),
        ("f", "next_finding", "next finding"),
        ("x", "follow_xref", "follow pointer"),
        ("y", "yank", "yank hex"),
        ("d", "diff", "pending changes"),
        ("m", "map", "byte map"),
        ("b", "cycle_view", "byte view"),
        ("p", "play", "play region"),
        ("full_stop", "stop_play", "stop"),
        ("v", "validate", "validate"),
        ("ctrl+s", "save", "save"),
        ("ctrl+z", "undo", "undo"),
        ("ctrl+r", "redo", "redo"),
        ("o", "open", "open file"),
        ("l", "locate_regions", "locate regions"),
        ("u", "ascend", "up to regions"),
        ("e", "edit_field", "edit field"),
        ("tab", "hex_focus", "hex edit"),
        ("ctrl+t", "toggle_mode", "value/hex"),
        ("w", "edit", "edit tags"),
        ("s", "strip", "strip meta"),
        ("a", "expand_all", "expand"),
        ("c", "collapse_all", "collapse"),
        ("question_mark", "help", "help"),
        ("escape", "cancel_edit", "cancel edit"),
        # scan controls: priority so they beat the tree's own space/enter while a
        # scan runs; check_action keeps them dormant otherwise.
        Binding("space", "pause_scan", "pause scan", priority=True, show=False),
        Binding("enter", "keep_scan", "keep scan", priority=True, show=False),
    ]

    def check_action(self, action, parameters):
        if action in ("pause_scan", "keep_scan"):
            return self._scanning        # only live during a scan
        # while a modal (edit form / file browser / help / diff / map / confirm)
        # is open, the app-global single-letter bindings must not fire under it
        # -- so typing in the browser or a form does not trigger edit/strip/etc.
        if len(self.screen_stack) > 1:
            return False
        return True

    def __init__(self, path=None):
        super().__init__()
        self.src = path           # the file being edited (save target + display name)
        self.work = None          # temp working copy: edits land here until save
        self.dirty = False        # unsaved edits present
        self._backed_up = False   # a _original backup was made this session
        self.fsize = 0
        self.chunks = []
        self.fmt = "?"
        self.warns = []
        self.findings = []
        self._nodemeta = {}       # id(node) -> (off, length, accent)  for the hex pane
        self._nodekey = {}        # id(node) -> stable key, to restore the cursor
                                  # and expansion across the post-edit tree rebuild
        self._editval = {}        # id(node) -> (value, enc, raw)  for field nodes
        self._textfield = {}      # id(node) -> engine field  for variable-length text
        self._profile = None      # edit profile of the current file (WAV/AIFF/...)
        self._prefer_be = False   # format is big-endian: bias infer_enc that way
        self._cur_node = None     # last highlighted tree node
        self._edit_target = None  # active inline edit: dict(off,length,name,mode,fmt,accent)
        self._hexedit = None      # active in-pane hex edit: dict(off,length,buf,cur,nib)
        self._undo = []           # working-copy byte snapshots for undo
        self._redo = []           # snapshots popped by undo, for redo
        self._prompt = None       # active editbar prompt: dict(kind, ...)
        self._allnodes = []       # (node, off, length) for offset/fuzzy navigation
        self._search = None       # active search: dict(desc, hits, idx)
        self._finding_idx = -1    # cursor into self.findings for jump-to-finding
        self._xref = {}           # id(field node) -> absolute target offset (pointer)
        self._view = "hex"        # byte-view mode: hex | entropy | hilbert | histogram
        self._cur_region = (None, None, ACCENT)  # last shown (off, length, accent)
        self._cur_spans = None    # field spans for per-field hex tint (chunk view)
        self._play = None         # handle to a running audio-audition process
        self._regions = None      # locate regions of the blob being browsed, or None
        self._blob_src = None     # path of the blob those regions came from
        self._region_view = None  # (idx, region) when viewing a descended region
        self._region_tmps = []    # carved-region temp files, cleaned on exit
        self._disc_src = None     # path of an opened CD-XA disc image
        self._disc_list = []      # its audio catalog (.STR/.VB/.VAG entries)
        self._locate_mode = "normal"    # strict | normal | aggressive
        self._locate_transforms = False  # also run the XOR/rotate/nibble lens
        self._readonly = False    # a large file is browsed in place (no working copy)
        self._work_is_temp = False  # self.work is a temp we own and must clean up
        self._scanning = False    # a region scan is running in the worker
        self._cancel_scan = False  # set to break the scan loop (keep or discard)
        self._scan_paused = False  # space: freeze the scan at the current segment
        self._scan_discard = False  # esc chose discard (vs enter = keep partial)
        self._scan_partial = False  # last scan was kept early (results are partial)
        self._scan_frame = 0      # spinner phase
        self._scan_last = None    # (done, total, n) for the spinner re-render
        self._scan_timer = None   # set_interval handle animating the spinner

    def compose(self) -> ComposeResult:
        yield Static(id="title")
        with Horizontal():
            yield Tree("file", id="tree")
            with Vertical(id="right"):
                yield Static(id="detail")
                with VerticalScroll(id="hexwrap"):
                    yield HexPane(id="hex")
                with VerticalScroll(id="anomwrap"):
                    yield Static(id="anom")
        yield Input(id="editbar", classes="hidden")
        yield Footer()

    def on_mount(self):
        if self.src:
            self._open_path(self.src)
        else:
            self.query_one("#title", Static).update(
                Text(" acidcat tui   press o to open a file",
                     style=f"bold {ACCENT}"))
            self.action_open()

    def on_unmount(self):
        self.action_stop_play()
        self._discard_work()
        self._clean_region_tmps()

    # ── working copy: all edits apply to a temp file until an explicit save ──

    def _open_path(self, path):
        """Open `path` fresh: drop any region context, make a working copy, load,
        and offer the region browser if it is a blob rather than a single file."""
        self.src = path
        self._regions = None
        self._blob_src = None
        self._region_view = None
        self._clean_region_tmps()
        self._make_work()
        self._load()
        if self._maybe_disc():          # a CD-XA disc opens straight into audio browsing
            return
        self._maybe_regions()

    # ── blob region browsing: locate -> browse -> descend -> extract ──────────

    def _clean_region_tmps(self):
        for p in getattr(self, "_region_tmps", []):
            try:
                if os.path.isfile(p):
                    os.unlink(p)
            except OSError:
                pass
        self._region_tmps = []

    def _maybe_regions(self):
        """Auto-offer the region browser when the opened file is not a single
        recognized format (a disk image, a raw dump, an unknown blob)."""
        if self.fmt not in ("unsupported", "walk failed") or self.fsize < 4096:
            return
        self.action_locate_regions()

    # ── CD-XA disc audio: detect -> browse tracks/banks -> audition/extract ──

    def _maybe_disc(self):
        """If the file is a CD-XA disc image with named audio, open the disc
        audio browser instead of the generic region browser. Returns True if so."""
        try:
            info = cdxamod.detect_cd_image(self.src)
        except Exception:
            return False
        if not info or not info.get("xa"):
            return False
        entries = self._disc_entries(self.src)
        if not entries:
            return False
        self._disc_src = self.src
        self._disc_list = entries
        self.push_screen(DiscScreen(entries, os.path.basename(self.src)))
        return True

    @staticmethod
    def _disc_entries(path):
        """The disc's audio catalog from its ISO 9660 tree: .STR/.XA soundtrack
        and .VB/.VAG SPU sound banks."""
        entries = []
        try:
            for ent in isomod.walk(path):
                up = ent["path"].upper()
                kind = ("XA" if up.endswith((".STR", ".XA"))
                        else "VB" if up.endswith(".VB")
                        else "VAG" if up.endswith(".VAG") else None)
                if kind:
                    entries.append({**ent, "kind": kind})
        except Exception:
            return []
        return entries

    def _decode_entry(self, ent, preview=False):
        """Decode a disc audio entry to (pcm_bytes, info). `preview` caps the
        length for a fast audition. Runs off the UI thread. None on failure."""
        path = self._disc_src
        try:
            if ent["kind"] == "XA":
                count = (ent["size"] + 2047) // 2048
                return cdxamod.decode_range(path, ent["lba"], count,
                                            max_audio=180 if preview else None)
            raw = isomod.read_file(path, ent)
            if ent["kind"] == "VB":
                if preview:
                    raw = raw[:24 * 1024]
                pcm = vagmod.decode_spu(raw, stop_on_end=False)
                return pcm, {"channels": 1, "rate": 22050, "bits": 16}
            info = vagmod.parse_vag(raw)                  # VAG
            data = info["data"][:24 * 1024] if preview else info["data"]
            return vagmod.decode_spu(data), {"channels": 1, "rate": info["rate"], "bits": 16}
        except Exception:
            return None

    def _audition_disc(self, ent):
        if not play.have_audio():
            self.notify("no audio player found (install ffmpeg for ffplay)",
                        severity="warning")
            return
        self.notify(f"decoding {ent['path']} ...")
        self.run_worker(lambda: self._audition_work(ent), thread=True)

    def _audition_work(self, ent):
        r = self._decode_entry(ent, preview=True)
        if r:
            self.call_from_thread(self._play_pcm, r[0], r[1], ent["path"])
        else:
            self.call_from_thread(self.notify, f"could not decode {ent['path']}",
                                  severity="warning")

    def _play_pcm(self, pcm, info, label):
        self.action_stop_play()
        self._play = play.play_bytes(pcm, rate=info["rate"], ch=info["channels"],
                                     bits=16, floating=False)
        secs = len(pcm) / max(1, info["rate"] * info["channels"] * 2)
        self.notify(f"playing {label}  ~{secs:.0f}s (preview) -- . to stop")

    def _extract_disc(self, entries):
        default = os.path.join(os.path.dirname(os.path.abspath(self._disc_src)),
                               os.path.splitext(os.path.basename(self._disc_src))[0]
                               + "_audio")
        self.push_screen(
            PromptScreen(f"extract {len(entries)} audio file(s) to (enter to confirm):",
                         default),
            lambda d: self._do_extract_disc(entries, d))

    def _do_extract_disc(self, entries, outdir):
        if not outdir:
            return
        self.notify(f"extracting {len(entries)} file(s) to {outdir} ...")
        self.run_worker(lambda: self._extract_disc_work(entries, outdir), thread=True)

    def _extract_disc_work(self, entries, outdir):
        import wave
        try:
            os.makedirs(outdir, exist_ok=True)
            n = 0
            for ent in entries:
                r = self._decode_entry(ent, preview=False)
                if not r:
                    continue
                pcm, info = r
                base = ent["path"].rsplit("/", 1)[-1].rsplit(".", 1)[0]
                with wave.open(os.path.join(outdir, f"{n:04d}_{base}.wav"), "wb") as w:
                    w.setnchannels(info["channels"])
                    w.setsampwidth(2)
                    w.setframerate(info["rate"])
                    w.writeframes(pcm)
                n += 1
            self.call_from_thread(self.notify, f"extracted {n} file(s) -> {outdir}")
        except Exception as e:
            self.call_from_thread(self.notify, f"extract failed: {e}", severity="error")

    def action_locate_regions(self, reset_blob=True):
        """Run `locate` over the blob and open the region browser. Works on demand
        (the `l` key on any file) and automatically for a blob; reset_blob=False
        keeps the current blob when re-scanning with a new mode / the lens."""
        if len(self.screen_stack) > 1 or self._scanning:
            return
        if reset_blob:
            self._blob_src = self.src
        self._scanning = True
        self._cancel_scan = False
        self._scan_paused = False
        self._scan_discard = False
        self._scan_frame = 0
        try:
            total0 = os.path.getsize(self._blob_src)
        except OSError:
            total0 = 0
        self._scan_last = (0, total0, 0)                 # a bar at 0% before segment 1
        self._render_scan_title()
        try:                                             # so space/enter reach the
            self.query_one("#tree", Tree).focus()        # scan-control priority binds,
        except Exception:                                # not the hidden editbar Input
            pass
        self._scan_timer = self.set_interval(0.1, self._spin_scan)
        self.run_worker(self._locate_work, thread=True, exclusive=True)

    def _locate_work(self):
        """Scan the blob in segments so progress is visible and a scan can be
        paused, kept early, or discarded. Segments are mmap'd, so the image is
        never read whole into RAM."""
        import mmap
        import time
        regions = []
        try:
            with open(self._blob_src, "rb") as f:
                size = os.fstat(f.fileno()).st_size
                if size == 0:
                    self.call_from_thread(self._finish_scan, [])
                    return
                mm = mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ)
                try:
                    pos = 0
                    while pos < size and not self._cancel_scan:
                        while self._scan_paused and not self._cancel_scan:
                            time.sleep(0.08)             # space froze it; hold here
                        if self._cancel_scan:
                            break
                        seg = mm[pos:pos + _SCAN_SEG]     # one segment, not the whole file
                        segs = locatemod.locate(seg, mode=self._locate_mode)
                        if self._locate_transforms:
                            segs = segs + transformsmod.find_transformed_audio(seg)
                        for r in segs:                   # shift into absolute offsets
                            r["offset"] += pos
                            r["end"] += pos
                        regions.extend(segs)
                        pos += _SCAN_SEG
                        self.call_from_thread(self._scan_progress,
                                              min(pos, size), size, len(regions))
                finally:
                    mm.close()
        except Exception:
            pass
        cancelled = bool(self._cancel_scan)
        regions = self._merge_boundary(sorted(regions, key=lambda r: r["offset"]))
        self.call_from_thread(self._finish_scan, regions, cancelled)

    def _scan_progress(self, done, total, n):
        if not self._scanning:
            return
        self._scan_last = (done, total, n)
        self._render_scan_title()

    def _spin_scan(self):
        """Interval tick: advance the spinner so the status bar stays alive even
        between (coarse) segment completions."""
        if not self._scanning:
            return
        if not self._scan_paused:
            self._scan_frame += 1
        self._render_scan_title()

    def _render_scan_title(self):
        if self._scan_last is None:
            return
        try:
            title = self.query_one("#title", Static)
        except Exception:
            return
        title.update(self._scan_title(*self._scan_last))

    @staticmethod
    def _cap(t, key, label, label_style=SOFT):
        """A keycap: dim brackets, bright key, muted label -- the anti-style bit."""
        t.append(" [", style=DIM)
        t.append(key, style=f"bold {ACCENT}")
        t.append("]", style=DIM)
        t.append(f" {label} ", style=label_style)

    def _scan_title(self, done, total, n):
        pct = done * 100 // total if total else 0
        fill = pct * _BAR_W // 100
        t = Text()
        if self._scan_paused:
            t.append(" ⏸ ", style=f"bold {AMBER}")     # pause glyph
            t.append("paused", style=f"bold {AMBER}")
        else:
            t.append(f" {_SPIN[self._scan_frame % len(_SPIN)]} ", style=f"bold {ACCENT}")
            t.append("scanning", style=f"bold {ACCENT}")
        t.append("  ")
        t.append("█" * fill, style=ACCENT)              # filled bar
        t.append("░" * (_BAR_W - fill), style=GUTTER)   # empty bar
        t.append(f" {pct:>3}%", style=f"bold {FG}")
        t.append(f"   {done >> 20}/{total >> 20} MB", style=SOFT)
        t.append(f"   {n} region{'' if n == 1 else 's'}", style=SOFT)
        t.append(f"   {self._locate_mode}", style=DIM)
        if self._locate_transforms:
            t.append(" +lens", style=DIM)
        t.append("  ")
        self._cap(t, "space", "resume" if self._scan_paused else "pause")
        self._cap(t, "enter", "keep")
        self._cap(t, "esc", "discard", label_style=AMBER)
        return t

    @staticmethod
    def _merge_boundary(regions):
        """Heal a blob that got split across a segment boundary: coalesce exactly
        adjacent regions of the same kind/format."""
        if not regions:
            return regions
        out = [dict(regions[0])]
        for r in regions[1:]:
            prev = out[-1]
            if (r["offset"] == prev["end"] and r["kind"] == prev["kind"]
                    and r.get("format") == prev.get("format")):
                prev["end"] = r["end"]
                prev["length"] = prev["end"] - prev["offset"]
            else:
                out.append(dict(r))
        return out

    def action_pause_scan(self):
        """space: freeze the scan at the current segment, or resume it."""
        if not self._scanning:
            return
        self._scan_paused = not self._scan_paused
        self._render_scan_title()

    def action_keep_scan(self):
        """enter: stop the scan now and browse whatever was found so far."""
        if self._scanning:
            self._scan_paused = False
            self._scan_discard = False
            self._cancel_scan = True

    def action_cancel_scan(self):
        """esc: abandon the scan and discard the partial results."""
        if self._scanning:
            self._scan_paused = False
            self._scan_discard = True
            self._cancel_scan = True

    def _finish_scan(self, regions, cancelled=False):
        if self._scan_timer is not None:
            self._scan_timer.stop()
            self._scan_timer = None
        self._scanning = False
        self._cancel_scan = False
        self._scan_paused = False
        self._scan_last = None
        discard = self._scan_discard
        self._scan_discard = False
        if discard:                       # esc: drop everything, back to the file
            self._scan_partial = False
            self._load()
            return
        self._scan_partial = cancelled    # enter mid-scan: results are partial
        self._show_regions(regions)

    def _classify_regions(self, regions):
        """Rank each blob region -- codec (SPU-ADPCM) vs linear PCM at a geometry
        -- so the browser shows the real interpretation, not a bare 'raw-pcm'."""
        for r in regions:
            if r.get("kind") != "blob" or r.get("format"):
                continue
            try:
                with open(self._blob_src, "rb") as f:
                    f.seek(r["offset"])
                    data = f.read(min(r["end"] - r["offset"], 32768))
                r["probe"] = audioscanmod.classify(data)
            except Exception:
                pass

    def _show_regions(self, regions):
        self._load()                                   # restore the title
        self._classify_regions(regions)
        self._regions = regions
        if not regions:
            self.query_one("#title", Static).update(
                Text(f" {os.path.basename(self.src)}  --  no audio regions located "
                     f"[mode:{self._locate_mode}"
                     f"{'  lens:ON' if self._locate_transforms else ''}]  "
                     "(m mode  t lens  c carve  / search  l rescan)",
                     style=f"bold {ACCENT}"))
            return
        name = os.path.basename(self._blob_src)
        if self._scan_partial:
            name += " (partial -- scan stopped)"
        self.push_screen(
            RegionsScreen(regions, name, self._locate_mode, self._locate_transforms),
            self._on_region_action)

    def _on_region_action(self, result):
        if not result:
            return
        act = result["action"]
        if act == "descend":
            self._descend(result["index"])
        elif act == "extract":
            self._extract([self._regions[result["index"]]])
        elif act == "extract_all":
            self._extract(self._regions)
        elif act == "rescan":
            self._locate_mode = result["mode"]
            self._locate_transforms = result["transforms"]
            self.action_locate_regions(reset_blob=False)
        elif act == "carve":
            self._carve_prompt()
        elif act == "search":
            self._search_prompt()

    def _region_bytes(self, region):
        with open(self._blob_src, "rb") as f:
            f.seek(region["offset"])
            return f.read(region["end"] - region["offset"])

    def _descend(self, idx):
        self._descend_region(self._regions[idx], idx)

    def _descend_region(self, region, label):
        """Carve `region` to a temp file and open it as if it were a standalone
        file; the parent blob's regions stay set so `u` ascends."""
        ext = _CARVE_EXT.get(region.get("format")) or "bin"
        fd, tmp = tempfile.mkstemp(suffix=f".{ext}", prefix="acidcat_region_")
        os.close(fd)
        with open(tmp, "wb") as f:
            f.write(self._region_bytes(region))
        self._region_tmps.append(tmp)
        blob, regions = self._blob_src, self._regions   # preserve across _make_work
        self.src = tmp
        self._region_view = (label, region)
        self._make_work()
        self._load()
        self._blob_src, self._regions = blob, regions

    def action_ascend(self):
        """From a descended region, go back up to the blob's region browser."""
        if self._regions is None or self._blob_src is None:
            return
        self._region_view = None
        self.push_screen(
            RegionsScreen(self._regions, os.path.basename(self._blob_src),
                          self._locate_mode, self._locate_transforms),
            self._on_region_action)

    # ── RE tools inside the browser: manual carve + raw-byte search ───────────

    def _carve_prompt(self):
        self.push_screen(
            PromptScreen("carve range -- offset length (hex ok, e.g. 0x4a00 0x800):", ""),
            self._do_carve)

    def _do_carve(self, spec):
        if not spec:
            return
        try:
            parts = spec.replace(",", " ").split()
            off = int(parts[0], 0)
            length = int(parts[1], 0) if len(parts) > 1 else None
            end = off + length if length is not None else os.path.getsize(self._blob_src)
        except (ValueError, IndexError):
            self.query_one("#title", Static).update(
                Text(" carve: need 'offset length' (0x.. or decimal)",
                     style=f"bold {SEV['alert']}"))
            return
        blob_size = os.path.getsize(self._blob_src)
        off = max(0, min(off, blob_size))
        end = max(off, min(end, blob_size))
        self._descend_region(
            {"offset": off, "end": end, "length": end - off,
             "kind": "carve", "format": None, "confidence": 1.0}, "manual carve")

    def _search_prompt(self):
        self.push_screen(
            PromptScreen('byte search -- 0x48454C4C hex, or "text" ascii:', ""),
            self._do_search)

    def _do_search(self, pat):
        if not pat:
            return
        needle = self._parse_needle(pat)
        if not needle:
            self.query_one("#title", Static).update(
                Text(" search: give hex (0x..) or a \"quoted\" string",
                     style=f"bold {SEV['alert']}"))
            return
        import mmap
        try:
            size = os.path.getsize(self._blob_src)
            with open(self._blob_src, "rb") as f:
                mm = mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ)
                try:
                    hit = mm.find(needle)                    # scans the image, no full read
                finally:
                    mm.close()
        except (OSError, ValueError):
            return
        if hit < 0:
            self.query_one("#title", Static).update(
                Text(f" search: {pat!r} not found", style=f"bold {ACCENT}"))
            self.action_locate_regions(reset_blob=False)   # reopen the browser
            return
        end = min(hit + max(len(needle), 1 << 20), size)   # a 1 MB window from the hit
        self._descend_region(
            {"offset": hit, "end": end, "length": end - hit,
             "kind": "match", "format": None, "confidence": 1.0},
            f"search @ 0x{hit:08x}")

    @staticmethod
    def _parse_needle(pat):
        pat = pat.strip()
        if len(pat) >= 2 and pat[0] == pat[-1] == '"':
            return pat[1:-1].encode("latin-1", "replace")
        low = pat.lower()
        if low.startswith("0x"):
            low = low[2:]
        low = low.replace(" ", "")
        if low and all(c in "0123456789abcdef" for c in low) and len(low) % 2 == 0:
            try:
                return bytes.fromhex(low)
            except ValueError:
                return b""
        return pat.encode("latin-1", "replace")     # bare text -> ascii

    def _extract(self, regions):
        default = os.path.join(os.path.dirname(os.path.abspath(self._blob_src)),
                               os.path.splitext(os.path.basename(self._blob_src))[0]
                               + "_regions")
        self.push_screen(
            PromptScreen(f"extract {len(regions)} region(s) to (enter to confirm):",
                            default),
            lambda d: self._do_extract(regions, d))

    def _do_extract(self, regions, outdir):
        if not outdir:
            return
        try:
            os.makedirs(outdir, exist_ok=True)
            n = 0
            for r in regions:
                ext = _CARVE_EXT.get(r.get("format")) or "raw"
                name = f"{n:04d}_0x{r['offset']:08x}_{r['kind']}.{ext}"
                with open(os.path.join(outdir, name), "wb") as f:
                    f.write(self._region_bytes(r))
                n += 1
            self.query_one("#title", Static).update(
                Text(f" extracted {n} region(s) -> {outdir}", style=f"bold {ACCENT}"))
        except OSError as e:
            self.query_one("#title", Static).update(
                Text(f" extract failed: {e}", style=f"bold {SEV['alert']}"))

    def _display_name(self):
        """The name shown in the title/tree: a breadcrumb when inside a region,
        the plain basename otherwise."""
        if self._region_view is not None:
            label, r = self._region_view
            fmt = r.get("transform") or r.get("format") or r["kind"]
            where = f"region {label}" if isinstance(label, int) else str(label)
            return (f"{os.path.basename(self._blob_src)} > "
                    f"{where} ({fmt} @ 0x{r['offset']:08x})")
        return os.path.basename(self.src)

    def _make_work(self):
        self._discard_work()
        if os.path.getsize(self.src) > _LARGE_FILE:
            # too big to copy on open; browse it in place, read-only (a disk image
            # is not something you edit field-by-field anyway). Descended regions
            # are small and still get an editable working copy.
            self.work = self.src
            self._readonly = True
            self._work_is_temp = False
        else:
            ext = os.path.splitext(self.src)[1]
            fd, self.work = tempfile.mkstemp(suffix=ext or ".bin", prefix="acidcat_tui_")
            os.close(fd)
            shutil.copyfile(self.src, self.work)
            self._readonly = False
            self._work_is_temp = True
        self.dirty = False
        self._backed_up = False
        self._undo = []
        self._src_stat = self._stat_src()
        self._force_stale = False

    def _stat_src(self):
        """(mtime_ns, size) of the source file, or None if it can't be stat'd.
        Taken at open and after each save; save refuses on a mismatch so an
        external change is never silently clobbered (and the _original backup
        never captures bytes the user wasn't editing)."""
        try:
            st = os.stat(self.src)
            return (st.st_mtime_ns, st.st_size)
        except OSError:
            return None

    def _discard_work(self):
        w = self.work
        self.work = None
        # only delete a temp we created; never the original (read-only large file)
        if w and self._work_is_temp and os.path.isfile(w):
            try:
                os.unlink(w)
            except OSError:
                pass
        self._work_is_temp = False

    @staticmethod
    def _minimal_delta(old, new):
        """The minimal changed region between two byte strings as
        (start, old_segment, new_segment) -- common prefix and suffix trimmed.
        A same-length field/hex patch yields a few-byte delta even on a huge
        file, so undo history holds byte ranges, not whole-file snapshots."""
        n = min(len(old), len(new))
        start = 0
        while start < n and old[start] == new[start]:
            start += 1
        # suffix length, not crossing into the prefix on either side
        suf = 0
        while (suf < n - start
               and old[len(old) - 1 - suf] == new[len(new) - 1 - suf]):
            suf += 1
        return start, old[start:len(old) - suf], new[start:len(new) - suf]

    def _apply_to_work(self, new_bytes):
        """Write edited bytes to the working copy (no disk write to the original
        yet), recording a minimal-diff undo delta, and refresh."""
        with open(self.work, "rb") as f:
            old = f.read()
        start, old_seg, new_seg = self._minimal_delta(old, new_bytes)
        if old_seg == new_seg:                # nothing actually changed
            return
        self._undo.append((start, old_seg, new_seg))
        self._redo = []           # a fresh edit invalidates the redo history
        self._undo = self._undo[-_UNDO_CAP:]
        # cap by total delta bytes so history cannot pin gigabytes; the most
        # recent delta always survives.
        while (len(self._undo) > 1
               and sum(len(o) + len(n) for _s, o, n in self._undo) > _UNDO_BYTES_CAP):
            self._undo.pop(0)
        with open(self.work, "wb") as f:
            f.write(new_bytes)
        self.dirty = True         # cheap: no whole-file compare on the hot path
        self._load()

    def _recompute_dirty(self):
        """Dirty iff the working copy differs from the saved file. Only called on
        undo/redo (rare), so the whole-file compare stays off the edit hot path;
        a plain edit sets dirty=True directly."""
        try:
            with open(self.work, "rb") as f:
                w = f.read()
            with open(self.src, "rb") as f:
                self.dirty = w != f.read()
        except OSError:
            self.dirty = True

    def _apply_delta(self, start, seg_out, seg_in):
        """Replace the bytes at `start` currently equal to `seg_out` with
        `seg_in` in the working copy (the shared undo/redo primitive)."""
        with open(self.work, "rb") as f:
            data = f.read()
        with open(self.work, "wb") as f:
            f.write(data[:start] + seg_in + data[start + len(seg_out):])

    def action_undo(self):
        if not self._undo:
            self.notify("nothing to undo")
            return
        start, old_seg, new_seg = self._undo.pop()
        self._apply_delta(start, new_seg, old_seg)      # revert new -> old
        self._redo.append((start, old_seg, new_seg))
        self._redo = self._redo[-_UNDO_CAP:]
        self._recompute_dirty()
        self._load()
        self.notify("undid last edit")

    def action_redo(self):
        if not self._redo:
            self.notify("nothing to redo")
            return
        start, old_seg, new_seg = self._redo.pop()
        self._apply_delta(start, old_seg, new_seg)      # re-apply old -> new
        self._undo.append((start, old_seg, new_seg))
        self._undo = self._undo[-_UNDO_CAP:]
        self._recompute_dirty()
        self._load()
        self.notify("redid last edit")

    def action_save(self):
        if not self.work:
            return
        if self._readonly:
            self.notify("read-only: this file is too large for in-place editing; "
                        "descend into a region to edit it")
            return
        if not self.dirty:
            self.notify("no unsaved changes")
            return
        if self._src_stat is not None and self._stat_src() != self._src_stat:
            if not self._force_stale:
                self._force_stale = True
                # this is action_save, bound to ctrl+s; plain s is strip, so
                # naming the wrong key would silently strip instead of forcing
                self.notify("file changed on disk since it was opened; "
                            "press ctrl+s again to overwrite it anyway",
                            severity="error")
                return
        self._force_stale = False
        try:
            with open(self.work, "rb") as f:
                data = f.read()
            # back up the pristine original only on the first save; later saves
            # overwrite without clobbering that backup.
            _written, backup = writer.commit(self.src, data,
                                             overwrite=self._backed_up)
        except (OSError, ValueError) as e:
            self.notify(f"save failed: {e}", severity="error")
            return
        if backup:
            self._backed_up = True
        self.dirty = False
        self._src_stat = self._stat_src()
        self._load()
        if backup:
            msg = f"saved; backup {os.path.basename(backup)}"
        elif not self._backed_up and os.path.exists(writer.backup_path(self.src)):
            # first save found a <name>_original already on disk and kept it;
            # that file may predate acidcat and not hold this original
            msg = "saved; existing backup kept"
        else:
            msg = "saved"
        self.notify(msg)

    def action_request_quit(self):
        if self.dirty:
            self.push_screen(
                ConfirmScreen("unsaved changes -- save before quitting?"),
                self._resolve_pending(lambda: self.exit()))
        else:
            self.exit()

    def _resolve_pending(self, proceed):
        """Return a ConfirmScreen callback: save/discard run `proceed`, cancel
        stays. Used for both quit and open-another-file with unsaved edits."""
        def cb(choice):
            if choice == "save":
                self.action_save()
                if self.dirty:      # save failed: keep the session and the edits
                    return
                proceed()
            elif choice == "discard":
                self.dirty = False
                proceed()
        return cb

    def _load(self):
        """Walk the working copy and (re)build the tree + panes. Also the refresh
        after an edit or after opening a new file."""
        self.fsize = os.path.getsize(self.work)
        try:
            self.fmt, self.chunks, self.warns = walk_file(self.work, deep=True)
        except Unsupported as e:
            self.fmt, self.chunks, self.warns = "unsupported", [], [str(e)]
        except Exception as e:
            # a crafted/corrupt file may make a walker raise something other
            # than Unsupported; the TUI opens files on mount, so this must not
            # crash the session (the DoS threat model is degrade-not-die)
            self.fmt, self.chunks, self.warns = (
                "walk failed", [], [f"{e.__class__.__name__}: {e}"])
        self._prefer_be = self.fmt in _BE_FMTS
        if self._readonly:
            self.findings = []     # skip the whole-file anomaly scan on a large blob
        else:
            try:
                self.findings = ac_anom.scan(self.work, self.fmt, self.chunks, self.warns)
            except Exception:
                self.findings = []

        head = Text()
        head.append(f" {self._display_name()} ", style=f"bold {ACCENT}")
        head.append(f" {self.fmt}  {self.fsize:,} bytes  "
                    f"{len(self.chunks)} chunks", style=SOFT)
        if self._region_view is not None:
            head.append("   [u back to regions]", style=DIM)
        if self.dirty:
            head.append("   ● UNSAVED", style=f"bold {SEV['alert']}")
        self.query_one("#title", Static).update(head)

        prof = edit_profile(self.work)
        self._profile = prof[0] if prof else None
        tree = self.query_one("#tree", Tree)
        # a rebuild (after an edit / undo / save) must not dump the user at the
        # root with everything collapsed: remember the highlighted node and the
        # expanded chunks by stable key (chunk index / field ordinal), restore
        # them after the rebuild.
        cur_key = (self._nodekey.get(id(self._cur_node))
                   if self._cur_node is not None else None)
        expanded = {self._nodekey[id(n)] for n in tree.root.children
                    if n.is_expanded and id(n) in self._nodekey}
        tree.clear()
        self._nodemeta = {}
        self._editval = {}
        self._textfield = {}
        self._nodekey = {}
        self._allnodes = []       # rebuilt each load, for goto/search/finding jumps
        self._xref = {}
        keyed = {}
        tree.root.set_label(Text(self._display_name(), style=f"bold {FG}"))
        tree.root.data = (0, self.fsize, ACCENT)
        self._nodemeta[id(tree.root)] = (0, self.fsize, ACCENT)
        for i, c in enumerate(self.chunks):
            accent = PALETTE[i % len(PALETTE)]
            lbl = Text()
            lbl.append(f"{str(c.get('id', '?')).strip():<6}", style=f"bold {accent}")
            lbl.append(f"0x{c.get('offset', 0):08x}  ", style=DIM)
            lbl.append(f"{c.get('size', 0):,}b  ", style=SOFT)
            lbl.append(str(c.get("summary", "")), style=FG)
            node = tree.root.add(lbl)
            node.data = (c.get("offset", 0), c.get("size", 0), accent)
            self._nodemeta[id(node)] = node.data
            self._nodekey[id(node)] = ("chunk", i)
            keyed[("chunk", i)] = node
            self._allnodes.append((node, c.get("offset", 0), c.get("size", 0)))
            for j, fl in enumerate(c.get("fields", [])):
                abs_off = _field_abs(c, fl)
                flbl = Text()
                flbl.append(f"{fl['name']}", style=SOFT)
                flbl.append(" = ", style=DIM)
                flbl.append(f"{fl['value']!s}", style=accent)
                if fl.get("note"):
                    flbl.append(f"  {fl['note']}", style=DIM)
                fnode = node.add_leaf(flbl)
                fnode.data = (abs_off, fl.get("len") or 0, accent)
                self._nodemeta[id(fnode)] = fnode.data
                self._nodekey[id(fnode)] = ("field", i, j)
                keyed[("field", i, j)] = fnode
                if fl.get("xref") is not None:
                    self._xref[id(fnode)] = fl["xref"]
                if abs_off is not None:
                    self._allnodes.append((fnode, abs_off, fl.get("len") or 0))
                if abs_off is not None:
                    self._editval[id(fnode)] = (fl.get("value"), fl.get("enc"),
                                                fl.get("raw"))
                    mf = text_field_for(self._profile, fl["name"])
                    if mf is not None:
                        self._textfield[id(fnode)] = mf
            # per-element rows: MIDI events, MP3 frames, device params, etc. --
            # the deep detail inspect --frames/--verbose shows. Rows carry no
            # uniform byte offset, so a row node uses its own if present else the
            # chunk's range for the hex pane.
            rows = c.get("rows") or []
            for k, row in enumerate(rows[:_ROW_CAP]):
                rlbl = Text("  ".join(f"{k2}={v}" for k2, v in row.items()),
                            style=SOFT)
                roff = row.get("offset") if isinstance(row.get("offset"), int) else None
                rlen = row.get("size") if isinstance(row.get("size"), int) else 0
                rnode = node.add_leaf(rlbl)
                rnode.data = ((roff, rlen, accent) if roff is not None
                              else node.data)
                self._nodemeta[id(rnode)] = rnode.data
                self._nodekey[id(rnode)] = ("row", i, k)
                keyed[("row", i, k)] = rnode
            if len(rows) > _ROW_CAP:
                more = node.add_leaf(Text(f"... {len(rows) - _ROW_CAP} more rows",
                                          style=DIM))
                self._nodemeta[id(more)] = node.data
        tree.root.expand()
        for ek in expanded:
            n = keyed.get(ek)
            if n is not None:
                n.expand()
        self._render_anomalies()
        target = keyed.get(cur_key)
        if target is not None:
            if target.parent is not None and not target.parent.is_expanded:
                target.parent.expand()
            self._cur_node = target
            # node lines are computed on the next refresh; moving now lands on -1
            self.call_after_refresh(tree.move_cursor, target)
            off, length, accent = self._nodemeta[id(target)]
            self._show(off, length, accent, self._node_name(target),
                       self._edit_hint(target, off, length))
        else:
            self._show(0, self.fsize, ACCENT, os.path.basename(self.src), "")

    def _render_anomalies(self):
        panel = self.query_one("#anom", Static)
        t = Text()
        t.append("forensics  ", style=f"bold {ACCENT}")
        if not self.findings:
            t.append("clean: no findings", style=SOFT)
            panel.update(t)
            return
        # severity legend so the colors are readable, then every finding
        # numbered (press f to jump the tree/hex to the next one). The panel
        # lives in a VerticalScroll, so findings past the fold stay reachable.
        t.append(f"{len(self.findings)} finding(s)   ", style=SOFT)
        t.append("alert", style=f"bold {SEV['alert']}")
        t.append(" / ", style=DIM)
        t.append("warn", style=f"bold {SEV['warn']}")
        t.append(" / ", style=DIM)
        t.append("notice", style=f"bold {SEV['notice']}")
        t.append("   (f = jump)\n", style=DIM)
        for i, f in enumerate(self.findings):
            sev = f.get("severity", "notice")
            marker = ">" if i == self._finding_idx else " "
            t.append(f" {marker}{i + 1:>2} ", style=f"bold {ACCENT}" if
                     i == self._finding_idx else DIM)
            t.append(f"{sev:<7}", style=f"bold {SEV.get(sev, SOFT)}")
            t.append(f"0x{f.get('offset', 0):08x} ", style=DIM)
            t.append(f"{f.get('message', '')}\n", style=FG)
        panel.update(t)

    @staticmethod
    def _node_name(node):
        lbl = node.label
        return (lbl.plain if isinstance(lbl, Text) else str(lbl)).strip()

    def _show(self, off, length, accent, name, note, spans=None):
        detail = self.query_one("#detail", Static)
        d = Text()
        d.append(name, style=f"bold {accent}")
        if off is not None:
            d.append(f"   @ 0x{off:08x}   {length:,} bytes", style=SOFT)
        else:
            d.append("   (derived, no byte range)", style=DIM)
        if note:
            d.append(f"\n{note}", style=SOFT)
        detail.update(d)
        self._cur_region = (off, length, accent)
        self._cur_spans = spans
        if self._view == "hex":
            self.query_one("#hex", Static).update(
                hex_text(self.work, off, length, accent, spans))
        # in a viz mode the pane shows a whole-file view; a node highlight leaves it

    def action_cycle_view(self):
        """Cycle the hex pane: hex -> entropy -> hilbert -> histogram (whole file)."""
        order = ["hex", "entropy", "hilbert", "histogram"]
        if self._hexedit:
            self._exit_hexedit()
        if self._edit_target:
            self.action_cancel_edit()
        self._view = order[(order.index(self._view) + 1) % len(order)]
        pane = self.query_one("#hex", Static)
        if self._view == "hex":
            off, length, accent = self._cur_region
            pane.update(hex_text(self.work, off, length, accent, self._cur_spans))
        else:
            pane.update(self._viz_render(self._view))
        self.notify(f"byte view: {self._view}")

    def _viz_width(self):
        try:
            return max(24, self.query_one("#hexwrap").size.width - 4)
        except Exception:
            return 72

    def _viz_render(self, mode):
        data = _read(self.work, 0, min(self.fsize, 8 * 1024 * 1024))
        if not data:
            t = Text()
            t.append("  (no bytes to visualize)", style=DIM)
            return t
        if mode == "entropy":
            return self._viz_entropy(data)
        if mode == "hilbert":
            return self._viz_hilbert(data)
        return self._viz_histogram(data)

    def _viz_entropy(self, data):
        ent = viz.windowed_entropy(data, self._viz_width())
        t = Text()
        t.append("entropy  ", style=f"bold {ACCENT}")
        t.append(f"min {min(ent):.1f}  mean {sum(ent) / len(ent):.1f}  "
                 f"max {max(ent):.1f} bits/byte  (whole file)\n\n", style=SOFT)
        blocks = " ▁▂▃▄▅▆▇█"
        for e in ent:
            frac = max(0.0, min(1.0, e / 8))
            t.append(blocks[int(frac * (len(blocks) - 1))],
                     style=PALETTE[min(len(PALETTE) - 1, int(frac * len(PALETTE)))])
        t.append("\n")
        return t

    def _viz_hilbert(self, data):
        grid, side = viz.hilbert_grid(data, order=5)
        t = Text()
        t.append("hilbert  ", style=f"bold {ACCENT}")
        t.append(f"{side}x{side} byte-class map; adjacent cells are adjacent bytes\n\n",
                 style=SOFT)
        for y in range(0, side, 2):
            for x in range(side):
                top = grid[y][x]
                bot = grid[y + 1][x] if y + 1 < side else None
                t.append("▀", style=f"{byte_color(top)} on {byte_color(bot)}")
            t.append("\n")
        t.append("\n")
        for b, label in ((0x41, " ascii  "), (0x80, " high  "), (0x00, " null/ctrl")):
            t.append("▀", style=byte_color(b))
            t.append(label, style=SOFT)
        t.append("\n")
        return t

    def _viz_histogram(self, data):
        t = Text()
        t.append("byte histogram  ", style=f"bold {ACCENT}")
        t.append("(0x00 .. 0xff frequency, whole file)\n\n", style=SOFT)
        for row in viz.byte_histogram(data, width=self._viz_width(), height=8):
            t.append(row + "\n", style=ACCENT)
        return t

    def action_play(self):
        """Audition the selected region's bytes as raw PCM (p); '.' stops."""
        if not play.have_audio():
            self.notify("no audio player found (install ffmpeg for ffplay)",
                        severity="warning")
            return
        off, length, _ = self._cur_region
        if off is None or not length:
            self.notify("highlight a region with bytes to play", severity="warning")
            return
        data = _read(self.work, off, min(length, 4 * 1024 * 1024))
        rate, ch, bits, floating = self._audio_params()
        self.action_stop_play()
        self._play = play.play_bytes(data, rate=rate, ch=ch, bits=bits, floating=floating)
        secs = len(data) / max(1, rate * ch * (bits // 8))
        self.notify(f"playing {len(data):,} bytes as {rate} Hz {ch}ch {bits}-bit "
                    f"(~{secs:.1f}s) -- . to stop")

    def action_stop_play(self):
        if getattr(self, "_play", None):
            play.stop(self._play)
            self._play = None

    def _audio_params(self):
        """(rate, channels, bits, floating) from the file's fmt/COMM chunk, or
        sensible defaults for reinterpreting arbitrary bytes as PCM."""
        rate, ch, bits, floating = 44100, 1, 16, False
        for c in self.chunks:
            if str(c.get("id", "")).strip() not in ("fmt", "COMM"):
                continue
            for f in c.get("fields", []):
                n, v = f.get("name", ""), f.get("value")
                try:
                    if n == "sample_rate":
                        rate = int(v) or rate
                    elif n in ("channels", "num_channels"):
                        ch = int(v) or ch
                    elif n == "bits_per_sample":
                        bits = int(v) or bits
                    elif n == "format_tag" and "float" in str(f.get("note", "")).lower():
                        floating = True
                except (ValueError, TypeError):
                    pass
            break
        return rate, ch, max(8, bits), floating

    def action_help(self):
        self.push_screen(HelpScreen())

    # ── navigation: goto-offset, search, jump-to-finding ──────────────

    def _select_node(self, node):
        """Move the tree cursor to `node`, expanding its parents, and refresh the
        detail/hex panes -- the shared landing used by goto/search/finding."""
        tree = self.query_one("#tree", Tree)
        p = node.parent
        while p is not None:
            if not p.is_expanded:
                p.expand()
            p = p.parent
        self._cur_node = node
        self.call_after_refresh(tree.move_cursor, node)
        data = self._nodemeta.get(id(node))
        if data:
            off, length, accent = data
            self._show(off, length, accent, self._node_name(node),
                       self._edit_hint(node, off, length))

    def _node_containing(self, offset):
        """The most specific tree node whose byte range covers `offset` (a field
        beats its enclosing chunk), or None. Ties break to the smallest range."""
        best = None
        for node, off, length in self._allnodes:
            if length and off <= offset < off + length:
                if best is None or length < best[1]:
                    best = (node, length)
        return best[0] if best else None

    def _jump_to_offset(self, offset, hlen=1, label=""):
        """Land on `offset`: select the node that contains it if any, and show
        the hex there. Used by goto and byte-search hits."""
        node = self._node_containing(offset)
        if node is not None:
            self._select_node(node)
        acc = PEND
        name = label or (self._node_name(node) if node else f"offset 0x{offset:08x}")
        self._show(offset, hlen, acc, name,
                   "" if node else "no chunk covers this offset")

    def _arm_prompt(self, kind, title, initial=""):
        """Reuse #editbar as a one-line prompt (goto/search). Distinct from a
        field edit: on_input_submitted routes on self._prompt first."""
        if self._edit_target:
            self.action_cancel_edit()
        self._prompt = {"kind": kind}
        bar = self.query_one("#editbar", Input)
        bar.value = initial
        bar.remove_class("hidden")
        bar.border_title = title
        bar.focus()

    def action_goto(self):
        self._arm_prompt("goto", "goto offset (0x.. or decimal)  enter  esc")

    def action_search(self):
        self._arm_prompt(
            "search",
            "search: text=fuzzy name/value, 0x..=hex bytes, \"..\"=ascii  n/N cycle")

    def _run_goto(self, text):
        text = text.strip()
        if not text:
            return
        try:
            offset = int(text, 0)
        except ValueError:
            self.notify(f"not an offset: {text!r}", severity="error")
            return
        if not (0 <= offset < self.fsize):
            self.notify(f"offset 0x{offset:x} outside the file (0..{self.fsize:,})",
                        severity="error")
            return
        self._jump_to_offset(offset, 1, f"goto 0x{offset:08x}")

    def _run_search(self, text):
        text = text.strip()
        if not text:
            return
        needle = self._search_needle(text)
        if needle is not None:                       # raw-byte search
            with open(self.work, "rb") as f:
                data = f.read()
            hits, pos = [], data.find(needle)
            while pos != -1 and len(hits) < 4096:
                hits.append(("byte", pos, len(needle)))
                pos = data.find(needle, pos + 1)
            desc = f"{len(needle)} byte(s)"
        else:                                        # fuzzy name/value search
            hits = [("node", n) for n, _o, _l in self._allnodes
                    if _fuzzy(text, self._node_name(n))]
            desc = f"'{text}'"
        if not hits:
            self.notify(f"no match for {desc}", severity="warning")
            self._search = None
            return
        self._search = {"desc": desc, "hits": hits, "idx": -1}
        self.notify(f"{len(hits)} match(es) for {desc}; n/N to cycle")
        self._search_step(1)

    @staticmethod
    def _search_needle(text):
        """Bytes to search for, or None if `text` is a fuzzy (name/value) query.
        0x.. / bare even-length hex -> those bytes; "..'/'.." -> ascii bytes."""
        t = text.strip()
        if len(t) >= 2 and t[0] == t[-1] and t[0] in ("'", '"'):
            return t[1:-1].encode("utf-8", "replace")
        h = t[2:] if t[:2].lower() == "0x" else t
        h = h.replace(" ", "")
        if t[:2].lower() == "0x" or (len(h) >= 2 and len(h) % 2 == 0
                                     and all(c in "0123456789abcdefABCDEF" for c in h)):
            try:
                return bytes.fromhex(h)
            except ValueError:
                return None
        return None

    def _search_step(self, direction):
        s = self._search
        if not s or not s["hits"]:
            self.notify("no active search (press / to search)")
            return
        s["idx"] = (s["idx"] + direction) % len(s["hits"])
        hit = s["hits"][s["idx"]]
        pos = f"{s['idx'] + 1}/{len(s['hits'])}"
        if hit[0] == "byte":
            self._jump_to_offset(hit[1], hit[2],
                                 f"match {pos} @ 0x{hit[1]:08x}  ({s['desc']})")
        else:
            self._select_node(hit[1])
            self.notify(f"match {pos}  {s['desc']}")

    def action_search_next(self):
        self._search_step(1)

    def action_search_prev(self):
        self._search_step(-1)

    def action_next_finding(self):
        if not self.findings:
            self.notify("no forensics findings")
            return
        self._finding_idx = (self._finding_idx + 1) % len(self.findings)
        f = self.findings[self._finding_idx]
        off = f.get("offset", 0)
        self._jump_to_offset(
            off, 1, f"finding {self._finding_idx + 1}/{len(self.findings)}: "
            f"{f.get('message', '')[:60]}")
        self._render_anomalies()

    def action_yank(self):
        """Copy the selected node's bytes (as hex) to the clipboard -- a common
        forensics move (paste an interesting region into another tool)."""
        node = self._cur_node
        data = self._nodemeta.get(id(node)) if node else None
        if not data or data[0] is None or not data[1]:
            self.notify("nothing to yank (highlight a field/chunk)", severity="warning")
            return
        off, length, _ = data
        blob = _read(self.work, off, min(length, _HEX_CAP))
        hexs = blob.hex(" ")
        try:
            self.copy_to_clipboard(hexs)
            where = "clipboard"
        except Exception:
            where = "(clipboard unavailable)"
        note = f", capped at {_HEX_CAP}" if length > _HEX_CAP else ""
        self.notify(f"yanked {len(blob)} bytes as hex -> {where}{note}")

    def _pending_changes(self):
        """(regions, src_len, work_len): the changed byte regions between the
        working copy and the saved original, each (offset, old_bytes, new_bytes).
        For a same-length file every differing run is listed; a length change is
        reported as one region from the first difference (a text re-serialization
        shifts the tail, so per-run diffing there is not meaningful)."""
        try:
            with open(self.work, "rb") as f:
                work = f.read()
            with open(self.src, "rb") as f:
                src = f.read()
        except OSError:
            return [], 0, 0
        if len(src) != len(work):
            start, o, n = self._minimal_delta(src, work)
            return ([(start, o, n)] if o != n else []), len(src), len(work)
        regions = []
        i = 0
        while i < len(src) and len(regions) < _DIFF_CAP + 1:
            if src[i] != work[i]:
                j = i
                while j < len(src) and src[j] != work[j]:
                    j += 1
                regions.append((i, src[i:j], work[i:j]))
                i = j
            else:
                i += 1
        return regions, len(src), len(work)

    def action_diff(self):
        if not self.work:
            return
        regions, sl, wl = self._pending_changes()
        self.push_screen(DiffScreen(regions, sl, wl))

    def _byte_map(self):
        """(segments, unaccounted): the file's top-level byte regions biggest
        first, each (id, offset, size, pct, accent). Excludes the whole-file
        container and any chunk nested inside another (e.g. SF2 samples inside
        smpl), so the map answers 'where do the bytes go' at the top level."""
        cand = [(c["id"], c["offset"], c["size"], PALETTE[i % len(PALETTE)])
                for i, c in enumerate(self.chunks)
                if isinstance(c.get("offset"), int) and isinstance(c.get("size"), int)
                and c["size"] > 0 and not (c["offset"] == 0 and c["size"] >= self.fsize)]

        def nested(off, size):
            return any(o <= off and off + size <= o + s and s > size
                       for _i, o, s, _a in cand)
        top = [(cid, off, size, _a) for cid, off, size, _a in cand
               if not nested(off, size)]
        top.sort(key=lambda x: -x[2])
        fsize = max(1, self.fsize)
        segs = [(str(cid).strip()[:8], off, size, size / fsize * 100, a)
                for cid, off, size, a in top]
        unaccounted = max(0, self.fsize - sum(s for _c, _o, s, _a in top))
        return segs, unaccounted

    def action_map(self):
        if not self.chunks:
            return
        segs, un = self._byte_map()
        self.push_screen(MapScreen(segs, self.fsize, un))

    def action_validate(self):
        """Show the constraint model's violations for the working copy; from the
        panel, `r` applies the witnessed repairs (still unsaved)."""
        if not self.work:
            self.notify("open a file first (o)", severity="warning")
            return
        from acidcat.core import constraints
        with open(self.work, "rb") as f:
            data = f.read()
        report = constraints.analyze(data)
        if report is None:
            self.notify("not a structurally-modeled container (WAV/AIFF/MP4/...)",
                        severity="warning")
            return

        def after(result):
            if result == "repair":
                self._do_repair()
        self.push_screen(ValidateScreen(report), after)

    def _do_repair(self):
        from acidcat.core import constraints
        from acidcat.core.repairers import AudioGuardError
        with open(self.work, "rb") as f:
            data = f.read()
        try:
            new_data, report = constraints.repair(data)
        except AudioGuardError as e:
            self.notify(f"repair refused: {e}", severity="error")
            return
        if not report.repairable:
            self.notify("nothing witnessed to repair")
            return
        self._apply_to_work(new_data)
        self.notify(f"repaired {len(report.repairable)} field(s) "
                    f"(unsaved -- ctrl+s to save)")

    def action_follow_xref(self):
        """Follow the selected field's pointer (its `xref` absolute offset) to
        where it points, flagging a dangling (out-of-bounds) one -- a real
        forensic tell as well as a navigation aid."""
        node = self._cur_node
        target = self._xref.get(id(node)) if node else None
        if target is None:
            self.notify("this field is not a pointer (no xref)", severity="warning")
            return
        if not (0 <= target < self.fsize):
            self.notify(f"DANGLING pointer -> 0x{target:x} is outside the file "
                        f"(0..0x{self.fsize:x})", severity="error")
            return
        self._jump_to_offset(target, 1, f"followed pointer -> 0x{target:08x}")

    def on_tree_node_highlighted(self, event):
        self._cur_node = event.node
        if self._edit_target:            # moving off the field cancels an edit
            self.action_cancel_edit()
        self._hexedit = None             # ditto an abandoned in-pane hex edit
        data = self._nodemeta.get(id(event.node))
        if not data:
            return
        off, length, accent = data
        hint = self._edit_hint(event.node, off, length)
        xref = self._xref.get(id(event.node))
        if xref is not None:
            danger = "" if 0 <= xref < self.fsize else " (DANGLING, out of bounds)"
            ptr = f"pointer -> 0x{xref:08x}{danger} -- press x to follow"
            hint = f"{hint}\n{ptr}" if hint else ptr
        spans = None
        key = self._nodekey.get(id(event.node))
        if key and key[0] == "chunk":                # tint each field within the chunk
            c = self.chunks[key[1]]
            spans = [(_field_abs(c, fl), fl.get("len") or 0)
                     for fl in c.get("fields", [])]
            spans = [(a, ln) for a, ln in spans if a is not None and ln]
        self._show(off, length, accent, self._node_name(event.node), hint, spans)

    def _edit_hint(self, node, off, length):
        """A short note in the detail pane telling the user how the highlighted
        field can be edited (value / enum / hex / text), so it's discoverable."""
        if off is None or not length:
            return ""
        if id(node) in self._textfield:
            return f"text-editable ({self._textfield[id(node)]}) -- press e"
        value, enc, raw = self._editval.get(id(node), (None, None, None))
        rb = _read(self.work, off, length)
        if enc is not None:
            bt = self._bit_target(off, value, enc)
            if bt is not None:
                if bt["mode"] == "bitfield":
                    return (f"value-editable ({bt['width']}-bit packed) -- "
                            "press e, or tab for hex")
                return "enum-editable -- press e, or tab for hex"
            try:
                if encode_value(enc, str(raw if raw is not None else value)) == rb:
                    return f"value-editable ({enc}) -- press e, or tab for hex"
            except (ValueError, struct.error):
                pass
        if infer_enc(value, rb, self._prefer_be) is not None:
            return "value-editable -- press e, or tab for hex"
        if length <= _HEXEDIT_CAP:
            return "hex-editable -- press e or tab"
        return ""

    # ── inline byte / value editor with live hex preview ──────────────

    def _bit_target(self, off, value, enc):
        """If enc is a bits/bitsmap/bitsdyn annotation, verify it against the
        working copy (the declared bits must decode to the displayed value; a
        wrong annotation must never write blind) and return the edit-target
        fields for it. None means not a bit annotation, or it did not verify --
        the caller falls back to plain value/hex editing."""
        for parse in (parse_bitsmap, parse_bitsdyn, parse_bitfield):
            p = parse(enc)
            if p is None:
                continue
            delta, clen, bitpos, width, extra = p
            cont_off = off + delta
            cur = _read(self.work, cont_off, clen)
            if len(cur) != clen or clen * 8 - bitpos - width < 0:
                return None
            tgt = {"off": cont_off, "length": clen, "fmt": None,
                   "bitpos": bitpos, "width": width}
            if parse is parse_bitsmap:
                ok = _BITMAPS.get(extra, {}).get(
                    bitfield_extract(cur, bitpos, width, 0)) == value
                tgt.update(mode="bitsmap", mapid=extra)
            elif parse is parse_bitsdyn:
                ok = _DYNMAPS[extra](cur).get(
                    bitfield_extract(cur, bitpos, width, 0)) == value
                tgt.update(mode="bitsdyn", dynid=extra)
            else:
                ok = bitfield_extract(cur, bitpos, width, extra) == value
                tgt.update(mode="bitfield", bias=extra)
            return tgt if ok else None
        return None

    def _arm_edit(self, target, initial):
        """Activate the edit bar for `target` with `initial` as the text."""
        self._edit_target = target
        bar = self.query_one("#editbar", Input)
        bar.value = initial
        bar.remove_class("hidden")
        self._update_edit_title()
        bar.focus()
        self._render_preview()

    def action_edit_field(self):
        self._view = "hex"
        node = self._cur_node
        data = self._nodemeta.get(id(node)) if node else None
        if not data:
            self.notify("highlight a field first", severity="warning")
            return
        off, length, accent = data
        if off is None or not length:
            self.notify("this node has no editable byte range", severity="warning")
            return
        name = self._node_name(node)
        # variable-length text field: edit as text through the metadata engine,
        # which re-serializes the chunk so a longer/shorter value is valid.
        mf = self._textfield.get(id(node))
        if mf is not None:
            value = self._editval.get(id(node), (None,))[0]
            self._arm_edit({"off": off, "length": length, "name": name,
                            "mode": "text", "fmt": None, "metafield": mf,
                            "accent": accent},
                           str(value) if value is not None else "")
            return
        if length > _HEXEDIT_CAP:
            self.notify(f"region too large to edit ({length:,} bytes); pick a field",
                        severity="warning")
            return
        raw_bytes = _read(self.work, off, length)
        value, enc, raw_val = self._editval.get(id(node), (None, None, None))
        # bit-packed / enum field: read-modify-write inside its container bytes
        # so neighbouring bit-fields survive. Only if the annotation verifies
        # against the working copy; else fall through to plain value/hex.
        if enc is not None:
            bt = self._bit_target(off, value, enc)
            if bt is not None:
                self._arm_edit({**bt, "name": name, "accent": accent}, str(value))
                return
        fmt = initial = None
        # 1) trust the walker's declared encoding ONLY if it reproduces the
        #    current bytes -- a wrong annotation must never write blind.
        if enc is not None:
            cand = raw_val if raw_val is not None else value
            try:
                if encode_value(enc, str(cand)) == raw_bytes:
                    fmt, initial = enc, str(cand)
            except (ValueError, struct.error):
                pass
        # 2) else infer the layout by round-tripping the displayed value.
        if fmt is None:
            fmt = infer_enc(value, raw_bytes, self._prefer_be)
            if fmt is not None:
                initial = str(value)
        # 3) else raw hex.
        if fmt is not None:
            mode = "value"
        else:
            mode, initial = "hex", raw_bytes.hex(" ")
        self._arm_edit({"off": off, "length": length, "name": name,
                        "mode": mode, "fmt": fmt, "accent": accent}, initial)

    def _update_edit_title(self):
        tgt = self._edit_target
        bar = self.query_one("#editbar", Input)
        if tgt["mode"] == "value":
            kind = f"value ({tgt['fmt']})"
        elif tgt["mode"] == "bitsmap":
            vals = list(_BITMAPS.get(tgt["mapid"], {}).values())
            kind = (f"enum ({tgt['mapid']}, {len(vals)} options)" if len(vals) > 8
                    else "enum: " + " | ".join(str(v) for v in vals))
        elif tgt["mode"] == "bitsdyn":
            cur = _read(self.work, tgt["off"], tgt["length"])
            opts = " | ".join(str(v) for v in _DYNMAPS[tgt["dynid"]](cur).values())
            kind = f"enum: {opts}"
        elif tgt["mode"] == "bitfield":
            kind = f"value ({tgt['width']}-bit packed field)"
        elif tgt["mode"] == "text":
            kind = f"text -> {tgt['metafield']} (variable length)"
        else:
            kind = f"raw hex ({tgt['length']}B)"
        toggle = "  ctrl+t=toggle" if tgt["fmt"] else ""
        bar.border_title = (f"edit {tgt['name']} @ 0x{tgt['off']:08x}  {kind}"
                            f"  enter=write  esc=cancel{toggle}")

    def action_toggle_mode(self):
        """Flip the active field edit between value and raw-hex. Only offered
        when the field has a known value encoding (fmt); a hex-only field stays
        hex. Converts the bar's current text to the other representation so the
        live preview stays consistent."""
        tgt = self._edit_target
        if not tgt:
            return
        if tgt["fmt"] is None:
            # enum/packed fields (bitsmap/bitsdyn/bitfield) are value-editable
            # by label or index in place; they just have no fmt to flip to hex
            if tgt.get("mode") in ("bitsmap", "bitsdyn", "bitfield"):
                self.notify("enum/packed field: edit the value in place "
                            "(no separate hex mode)", severity="warning")
            else:
                self.notify("this field is hex-only (no known value encoding)",
                            severity="warning")
            return
        bar = self.query_one("#editbar", Input)
        if tgt["mode"] == "value":
            try:                                  # value -> its bytes as hex
                bar.value = encode_value(tgt["fmt"], bar.value.strip()).hex(" ")
            except (ValueError, struct.error):
                pass                              # keep text; preview flags invalid
            tgt["mode"] = "hex"
        else:
            try:                                  # bytes -> decoded value
                b = bytes.fromhex(bar.value.replace(" ", ""))
                if len(b) == enc_size(tgt["fmt"]):
                    bar.value = str(decode_value(tgt["fmt"], b))
            except (ValueError, struct.error):
                pass
            tgt["mode"] = "value"
        self._update_edit_title()
        self._render_preview()

    def _patch_from_input(self, text):
        """Turn the current editbar text into bytes for the field's byte range, or
        None if invalid/incomplete."""
        tgt = self._edit_target
        try:
            if tgt["mode"] == "bitsmap":
                rawv = resolve_bitsmap(tgt["mapid"], text)
                cur = _read(self.work, tgt["off"], tgt["length"])
                if rawv is None or len(cur) != tgt["length"]:
                    return None
                return bitfield_apply(cur, tgt["bitpos"], tgt["width"], 0, rawv)
            if tgt["mode"] == "bitsdyn":
                cur = _read(self.work, tgt["off"], tgt["length"])
                if len(cur) != tgt["length"]:
                    return None
                rawv = _resolve_in_map(_DYNMAPS[tgt["dynid"]](cur), text)
                if rawv is None:
                    return None
                return bitfield_apply(cur, tgt["bitpos"], tgt["width"], 0, rawv)
            if tgt["mode"] == "bitfield":
                cur = _read(self.work, tgt["off"], tgt["length"])
                if len(cur) != tgt["length"]:
                    return None
                return bitfield_apply(cur, tgt["bitpos"], tgt["width"],
                                      tgt["bias"], int(text.strip(), 0))
            if tgt["mode"] == "value":
                patch = encode_value(tgt["fmt"], text.strip())
            else:
                patch = bytes.fromhex(text.replace(" ", "").replace("\n", ""))
        except (ValueError, struct.error):
            return None
        return patch if len(patch) == tgt["length"] else None

    def _render_preview(self):
        tgt = self._edit_target
        if not tgt:
            return
        if tgt["mode"] == "text":
            d = Text()
            d.append(f"editing {tgt['name']} ", style=f"bold {PEND}")
            d.append(f"as text -> {tgt['metafield']}; re-serialized on write "
                     f"(length may change)", style=SOFT)
            self.query_one("#detail", Static).update(d)
            self.query_one("#hex", Static).update(
                hex_text(self.work, tgt["off"], tgt["length"], PEND))
            return
        text = self.query_one("#editbar", Input).value
        patch = self._patch_from_input(text)
        detail = self.query_one("#detail", Static)
        d = Text()
        d.append(f"editing {tgt['name']}", style=f"bold {PEND}")
        d.append("   ", style=SOFT)
        d.append("valid, enter to write" if patch is not None
                 else "invalid / wrong length", style=SOFT if patch else SEV["alert"])
        detail.update(d)
        t = Text()
        t.append("preview (unsaved)\n", style=f"bold {PEND}")
        if patch is None:
            _hex_rows(t, tgt["off"], _read(self.work, tgt["off"], tgt["length"]), DIM)
        else:
            _hex_rows(t, tgt["off"], patch, PEND)
        self.query_one("#hex", Static).update(t)

    def on_input_changed(self, event):
        if event.input.id == "editbar" and self._edit_target:
            self._render_preview()

    def on_input_submitted(self, event):
        if event.input.id != "editbar":
            return
        if self._prompt:                     # goto / search prompt, not a field edit
            kind, text = self._prompt["kind"], event.value
            self._end_prompt()
            if kind == "goto":
                self._run_goto(text)
            elif kind == "search":
                self._run_search(text)
            return
        if not self._edit_target:
            return
        tgt = self._edit_target
        if tgt["mode"] == "text":
            try:
                _fmt, new_data, _applied = _write_edit(
                    self.work, {tgt["metafield"]: event.value})
            except (EditError, OSError, ValueError) as e:
                self.notify(f"error: {e}", severity="error")
                return
            self._end_edit()
            self._apply_to_work(new_data)
            self.notify(f"set {tgt['metafield']} (unsaved -- ctrl+s to save)")
            return
        patch = self._patch_from_input(event.value)
        if patch is None:
            self.notify(f"invalid value for a {tgt['length']}-byte field",
                        severity="error")
            return
        try:
            with open(self.work, "rb") as f:
                data = f.read()
            new = data[:tgt["off"]] + patch + data[tgt["off"] + tgt["length"]:]
        except OSError as e:
            self.notify(f"error: {e}", severity="error")
            return
        self._end_edit()
        self._apply_to_work(new)
        self.notify(f"patched {len(patch)} bytes (unsaved -- ctrl+s to save)")

    def _end_prompt(self):
        self._prompt = None
        bar = self.query_one("#editbar", Input)
        bar.value = ""
        bar.add_class("hidden")
        self.query_one("#tree", Tree).focus()

    def action_cancel_edit(self):
        if self._scanning:                   # esc discards a running region scan
            self.action_cancel_scan()
            return
        if self._prompt:                     # esc cancels an armed goto/search prompt
            self._end_prompt()
            return
        if not self._edit_target:
            return
        self._end_edit()
        if self._cur_node:
            data = self._nodemeta.get(id(self._cur_node))
            if data:
                self._show(*data, self._node_name(self._cur_node), "")

    def _end_edit(self):
        self._edit_target = None
        bar = self.query_one("#editbar", Input)
        bar.value = ""
        bar.add_class("hidden")
        self.query_one("#tree", Tree).focus()

    # ── in-pane hex editor: Tab into the hex pane and overwrite bytes ──

    def action_hex_focus(self):
        if len(self.screen_stack) > 1:       # a modal is open; leave Tab to it
            return
        if self._hexedit:                    # already editing; Tab must not
            return                           # restart and drop typed nibbles
        if self._edit_target:
            self.action_cancel_edit()
        node = self._cur_node
        data = self._nodemeta.get(id(node)) if node else None
        if not data:
            self.notify("highlight a field first", severity="warning")
            return
        off, length, _accent = data
        if off is None or not length:
            self.notify("this node has no editable byte range", severity="warning")
            return
        if length > _HEXEDIT_CAP:
            self.notify(f"region too large ({length:,} bytes); pick a field",
                        severity="warning")
            return
        self._view = "hex"
        self._hexedit = {"off": off, "length": length, "cur": 0, "nib": 0,
                         "buf": bytearray(_read(self.work, off, length))}
        self.query_one("#hex", HexPane).focus()
        self._render_hexedit()

    def _exit_hexedit(self):
        self._hexedit = None
        if self._cur_node:
            data = self._nodemeta.get(id(self._cur_node))
            if data:
                self._show(*data, self._node_name(self._cur_node), "")
        self.query_one("#tree", Tree).focus()

    def _hexedit_key(self, event):
        he = self._hexedit
        if he is None:
            return
        k = event.key
        if k == "escape":
            event.stop()
            self._exit_hexedit()
            return
        if k in ("enter", "return"):
            event.stop()
            with open(self.work, "rb") as f:
                data = f.read()
            new = (data[:he["off"]] + bytes(he["buf"])
                   + data[he["off"] + he["length"]:])
            length = he["length"]
            self._hexedit = None
            self._apply_to_work(new)
            self.notify(f"patched {length} bytes (unsaved -- ctrl+s to save)")
            self.query_one("#tree", Tree).focus()
            return
        n = he["length"]
        digit = k.lower()
        if k == "right":
            he["cur"], he["nib"] = min(he["cur"] + 1, n - 1), 0
        elif k == "left":
            he["cur"], he["nib"] = max(he["cur"] - 1, 0), 0
        elif k == "down":
            he["cur"], he["nib"] = min(he["cur"] + 16, n - 1), 0
        elif k == "up":
            he["cur"], he["nib"] = max(he["cur"] - 16, 0), 0
        elif len(digit) == 1 and digit in "0123456789abcdef":
            d, i = int(digit, 16), he["cur"]
            if he["nib"] == 0:
                he["buf"][i] = (he["buf"][i] & 0x0f) | (d << 4)
                he["nib"] = 1
            else:
                he["buf"][i] = (he["buf"][i] & 0xf0) | d
                he["nib"] = 0
                he["cur"] = min(he["cur"] + 1, n - 1)
        else:
            return
        event.stop()
        self._render_hexedit()

    def _render_hexedit(self):
        he = self._hexedit
        off, buf, cur, nib = he["off"], he["buf"], he["cur"], he["nib"]
        t = Text()
        t.append("HEX EDIT  ", style=f"bold {SEV['alert']}")
        t.append(f"byte {cur + 1}/{len(buf)} @ 0x{off + cur:08x}"
                 f"{' low-nibble' if nib else ''}   arrows move  0-9a-f overwrite"
                 f"  enter=apply  esc=cancel\n", style=DIM)
        cur_st = "bold #16181C on #FF4D00"
        for row in range(0, len(buf), 16):
            chunk = buf[row:row + 16]
            t.append(f"{off + row:08x}  ", style=GUTTER)
            for i in range(16):
                if i < len(chunk):
                    t.append(f"{chunk[i]:02x} ",
                             style=cur_st if row + i == cur else ACCENT)
                else:
                    t.append("   ")
                if i == 7:
                    t.append(" ")
            t.append(" ")
            for i, b in enumerate(chunk):
                ch = chr(b) if 32 <= b < 127 else "."
                t.append(ch, style=(cur_st if row + i == cur
                                    else (FG if 32 <= b < 127 else DIM)))
            t.append("\n")
        self.query_one("#hex", Static).update(t)

    # ── other actions ─────────────────────────────────────────────────

    def action_open(self):
        if self.dirty:
            self.push_screen(
                ConfirmScreen("unsaved changes -- save before opening another?"),
                self._resolve_pending(self._browse))
        else:
            self._browse()

    def _browse(self):
        start = os.path.dirname(os.path.abspath(self.src)) if self.src else os.getcwd()

        def after(path):
            if path and os.path.isfile(path):
                self._open_path(path)

        self.push_screen(BrowseScreen(start), after)

    def action_edit(self):
        if not self.work:
            self.notify("open a file first (o)", severity="warning")
            return
        prof = edit_profile(self.work)
        if prof is None:
            self.notify(f"no metadata editor for this format ({self.fmt})",
                        severity="warning")
            return

        def after(result):
            if result and result.get("new_data") is not None:
                self._apply_to_work(result["new_data"])
                n = len(result.get("applied", []))
                self.notify(f"edited {n} field(s) (unsaved -- ctrl+s to save)")

        self.push_screen(EditScreen(self.work, prof[0], prof[1]), after)

    def action_strip(self):
        if not self.work:
            self.notify("open a file first (o)", severity="warning")
            return
        try:
            _fmt, new_data, removed = _write_strip(self.work)
        except (EditError, OSError, ValueError) as e:
            self.notify(f"strip failed: {e}", severity="error")
            return
        self._apply_to_work(new_data)
        what = ", ".join(removed) if removed else "nothing to remove"
        self.notify(f"stripped: {what} (unsaved -- ctrl+s to save)")

    def action_expand_all(self):
        self.query_one("#tree", Tree).root.expand_all()

    def action_collapse_all(self):
        for node in self.query_one("#tree", Tree).root.children:
            node.collapse_all()
