"""MP3 structural walker: ID3v2/ID3v1 tags, the MPEG frame run, and the
Xing/LAME/VBRI first-frame headers. Frame and tag primitives live in
core/mp3.py; this module shapes them into the chunk model."""

import os
import re
import struct

from acidcat.core import mp3 as mp3mod
from acidcat.core.walk.base import (
    _FRAME_LISTING_CAP, _ID3_READ_CAP, _bu16, _bu32, _f,
)

_ID3_TEXT_FRAMES = {
    "TIT2": "title", "TPE1": "artist", "TALB": "album", "TCON": "genre",
    "TBPM": "bpm", "TKEY": "initial key", "TYER": "year", "TDRC": "year",
    "TRCK": "track", "TSSE": "encoder settings", "TENC": "encoded by",
    "COMM": "comment", "TPE2": "album artist", "TPE3": "conductor",
    "TCOM": "composer", "TPOS": "disc", "TPUB": "publisher",
    "TCOP": "copyright", "TSRC": "ISRC", "TEXT": "lyricist",
    "TCMP": "compilation", "TIT1": "grouping", "TIT3": "subtitle",
}

# ID3v2.2 used 3-character frame ids. without this map a v2.2 tag lists frame
# sizes but never decodes its title/artist/etc.
_ID3V22_TEXT_FRAMES = {
    "TT2": "title", "TP1": "artist", "TAL": "album", "TCO": "genre",
    "TBP": "bpm", "TKE": "initial key", "TYE": "year", "TRK": "track",
    "TSS": "encoder settings", "TEN": "encoded by", "COM": "comment",
}

_VBR_METHODS = {
    0: "unknown", 1: "CBR", 2: "ABR", 3: "VBR (rh)", 4: "VBR (mtrh)",
    5: "VBR (rh2)", 6: "VBR (constrained)",
}

# the ID3v1 genre table lives in core/mp3.py (shared with the MP4 gnre atom)
_ID3_GENRES = mp3mod.ID3V1_GENRES


def _lame_replaygain(word):
    """Decode a LAME 16-bit replay-gain word; None if unset (0x0000)."""
    if word == 0:
        return None
    name = (word >> 13) & 0x07
    sign = (word >> 9) & 0x01
    mag = word & 0x1FF
    db = (-1 if sign else 1) * mag / 10.0
    kind = {1: "radio", 2: "audiophile"}.get(name, "")
    return f"{db:+.1f} dB" + (f" ({kind})" if kind else "")


def _id3v1_fields(tag):
    """Decode a 128-byte ID3v1/v1.1 trailer into display fields; also
    return the title for the chunk summary."""
    def s(a, b):
        return tag[a:b].decode("latin-1", errors="replace").split("\x00")[0].rstrip("\x00 ")
    fields = [
        _f(0x03, 30, "title", s(3, 33)),
        _f(0x21, 30, "artist", s(33, 63)),
        _f(0x3F, 30, "album", s(63, 93)),
        _f(0x5D, 4, "year", s(93, 97)),
    ]
    # ID3v1.1: byte 125 is zero and byte 126 (track) is nonzero, so the
    # comment is only 28 bytes. Otherwise it is a full 30-byte v1.0 comment.
    if tag[125] == 0 and tag[126] != 0:
        fields.append(_f(0x61, 28, "comment", s(97, 125)))
        fields.append(_f(0x7E, 1, "track", tag[126]))
    else:
        fields.append(_f(0x61, 30, "comment", s(97, 127)))
    g = tag[127]
    gname = _ID3_GENRES[g] if g < len(_ID3_GENRES) else ("none" if g == 255
                                                          else f"unknown {g}")
    # value is the canonical ID3v1 genre code (already number-editable); the
    # label is the note. A name-picker would drop the standard code, so keep it.
    fields.append(_f(0x7F, 1, "genre", g, gname))
    return fields, s(3, 33)


def _decode_id3_text(raw):
    """Decode an ID3v2 text-frame payload (leading encoding byte)."""
    if not raw:
        return ""
    enc = raw[0]
    body = raw[1:]
    codecs = {0: "latin-1", 1: "utf-16", 2: "utf-16-be", 3: "utf-8"}
    try:
        text = body.decode(codecs.get(enc, "latin-1"), errors="replace")
    except Exception:
        text = body.decode("latin-1", errors="replace")
    return text.replace("\x00", " ").strip()


def _decode_txxx(raw, fid):
    """Decode a user-defined TXXX/WXXX frame as 'description = value'. TXXX is
    [enc][description NUL][value]; WXXX's value is always latin-1 (a URL)."""
    if not raw:
        return ""
    enc = raw[0]
    body = raw[1:]
    codecs = {0: "latin-1", 1: "utf-16", 2: "utf-16-be", 3: "utf-8"}
    codec = codecs.get(enc, "latin-1")
    sep = b"\x00\x00" if enc in (1, 2) else b"\x00"
    idx = body.find(sep)
    if idx < 0:
        desc, val = body, b""
    else:
        desc, val = body[:idx], body[idx + len(sep):]
    d = desc.decode(codec, "replace").strip()
    vcodec = "latin-1" if fid == "WXXX" else codec
    v = val.decode(vcodec, "replace").replace("\x00", " ").strip()
    return f"{d} = {v}" if d else v


def _decode_comm(raw):
    """Decode a COMM/USLT-shaped frame: [enc][language 3][description NUL][text].
    Shown as the text, prefixed with the description in brackets when one is
    set and suffixed with the language when it is meaningful."""
    if len(raw) < 4:
        return ""
    enc = raw[0]
    lang = raw[1:4].decode("latin-1", "replace").strip("\x00 ")
    body = raw[4:]
    codecs = {0: "latin-1", 1: "utf-16", 2: "utf-16-be", 3: "utf-8"}
    codec = codecs.get(enc, "latin-1")
    sep = b"\x00\x00" if enc in (1, 2) else b"\x00"
    idx = body.find(sep)
    if idx < 0:
        desc, text = b"", body
    else:
        desc, text = body[:idx], body[idx + len(sep):]
    d = desc.decode(codec, "replace").strip()
    t = text.decode(codec, "replace").replace("\x00", " ").strip()
    out = f"[{d}] {t}" if d else t
    if lang and lang.lower() not in ("", "xxx", "und"):
        out += f" ({lang})"
    return out


def _resolve_tcon(text):
    """Resolve numeric TCON genre references against the ID3v1 table: v2.3
    parenthesized '(17)' (possibly several, with an optional refinement after)
    and a bare v2.4 numeric string. Text genres pass through unchanged."""
    def name(num):
        return (_ID3_GENRES[num] if num < len(_ID3_GENRES)
                else f"unknown {num}")
    if text.isdigit() and len(text) <= 3:
        return name(int(text))
    out = re.sub(r"\((\d{1,3})\)", lambda m: name(int(m.group(1))) + " ", text)
    out = out.replace("(RX)", "Remix ").replace("(CR)", "Cover ")
    return " ".join(out.split())


def _frame_extra_skip(major, fflags):
    """Bytes of per-frame header extras that precede the payload, and a label
    for payloads that cannot be decoded as text. v2.3 appends decompressed-size
    (4), encryption method (1), group id (1) in that order; v2.4 appends group
    id (1), encryption method (1), data-length indicator (4) in flag order."""
    skip, opaque = 0, ""
    if major == 4:
        if fflags & 0x0040:
            skip += 1
        if fflags & 0x0004:
            skip += 1
            opaque = "encrypted"
        if fflags & 0x0008:
            opaque = opaque or "zlib-compressed"
        if fflags & 0x0001:
            skip += 4
    else:
        if fflags & 0x0080:
            skip += 4
            opaque = "zlib-compressed"
        if fflags & 0x0040:
            skip += 1
            opaque = "encrypted"
        if fflags & 0x0020:
            skip += 1
    return skip, opaque


# self-delimited media that can ride inside an ID3 picture frame: (magic prefix,
# end-of-file terminator, name). a payload that both starts with the magic and
# ends at the terminator is a byte-exact standalone file a carver recovers whole.
_EMBEDDED_MAGIC = (
    (b"\xff\xd8\xff", b"\xff\xd9", "JPEG"),
    (b"\x89PNG\r\n\x1a\n", b"IEND\xaeB`\x82", "PNG"),
    (b"GIF89a", b"\x3b", "GIF"),
    (b"GIF87a", b"\x3b", "GIF"),
)


def _embedded_file(payload):
    """If `payload` begins with a known image magic, return (name, extent) where
    extent is the byte length up to and including the format's terminator, or
    (name, None) when the magic is present but its terminator is not. Returns
    (None, None) for an unrecognised payload."""
    for magic, term, name in _EMBEDDED_MAGIC:
        if payload.startswith(magic):
            end = payload.rfind(term)
            return name, (end + len(term) if end >= 0 else None)
    return None, None


def _apic_image_span(raw, is_v22):
    """Byte offset of the raw image data inside an ID3 picture-frame body (APIC
    in v2.3/2.4, PIC in v2.2) and the declared MIME/format string. The header is
    encoding byte, MIME/format, picture-type byte, NUL-terminated description,
    then the image to end of frame. Returns (offset, mime) or (None, "")."""
    if not raw:
        return None, ""
    enc = raw[0]
    if is_v22:                                   # PIC: fixed 3-char image format
        fmt = raw[1:4].decode("ascii", "replace")
        p = 4
    else:                                        # APIC: NUL-terminated MIME string
        nul = raw.find(b"\x00", 1)
        if nul < 0:
            return None, ""
        fmt = raw[1:nul].decode("ascii", "replace")
        p = nul + 1
    p += 1                                       # picture-type byte
    if enc in (1, 2):                            # UTF-16 description: 2-byte NUL
        term = raw.find(b"\x00\x00", p)
        while 0 <= term and (term - p) % 2:
            term = raw.find(b"\x00\x00", term + 1)
        p = term + 2 if term >= 0 else -1
    else:                                        # ISO-8859-1 / UTF-8: 1-byte NUL
        term = raw.find(b"\x00", p)
        p = term + 1 if term >= 0 else -1
    if p < 0 or p > len(raw):
        return None, fmt
    return p, fmt


def _id3v2_frames(filepath, hdr):
    """Enumerate ID3v2 frames into display fields. Decodes common text
    frames to their values; lists every frame id and size otherwise."""
    fields, warns = [], []
    major = hdr["major"]
    tag_size = hdr["size"]
    with open(filepath, "rb") as f:
        f.seek(10)
        body = f.read(min(tag_size, _ID3_READ_CAP))
    fields.append(_f(0x03, 1, "version", f"2.{major}.{hdr['revision']}"))
    flags = hdr["flags"]
    flag_bits = []
    if flags & 0x80:
        flag_bits.append("unsync")
    if flags & 0x40:
        flag_bits.append("extended header")
    if flags & 0x20:
        flag_bits.append("experimental")
    if flags & 0x10:
        flag_bits.append("footer")
    fields.append(_f(0x05, 1, "flags", f"0x{flags:02x}",
                     ", ".join(flag_bits) if flag_bits else "none",
                     enc="B", raw=flags))
    fields.append(_f(0x06, 4, "tag_size", f"{hdr['size']:,}", "synchsafe",
                     enc="synchsafe", raw=hdr["size"]))

    is_v22 = major == 2
    id_len = 3 if is_v22 else 4
    fhdr_len = 6 if is_v22 else 10

    # whole-tag unsynchronisation (flag bit 7) inserts a $00 after every $FF so
    # a frame body cannot masquerade as a frame sync. undo it before reading
    # sizes, or every size past the first $FF byte is wrong. this is a v2.2/v2.3
    # construct: in v2.4 unsync is per-frame and the frame size is the on-disk
    # length, so a global de-escape there would misalign every later frame.
    if flags & 0x80 and major != 4:
        body = body.replace(b"\xff\x00", b"\xff")
        warns.append("tag is unsynchronised; byte offsets shown are logical "
                     "(post-desync), not raw file positions")
    # after a whole-tag de-escape the field offsets are logical, not on-disk, so
    # a carve range computed from them would not line up with the real file.
    tag_desynced = bool(flags & 0x80 and major != 4)

    pos = 0
    # skip the extended header (flag bit 6) so it is not misread as a frame.
    if flags & 0x40 and not is_v22 and len(body) >= 4:
        if major == 4:
            ext_size = mp3mod.synchsafe(body[0:4])       # v2.4: size includes itself
        else:
            ext_size = struct.unpack(">I", body[0:4])[0] + 4  # v2.3: excludes the 4
        if 0 < ext_size <= len(body):
            fields.append(_f(10, ext_size, "extended_header",
                             f"{ext_size} bytes", "skipped"))
            pos = ext_size
    while pos + fhdr_len <= len(body):
        fid = body[pos:pos + id_len]
        if fid[0] == 0:  # padding
            break
        fid_s = fid.decode("ascii", errors="replace")
        if is_v22:
            fsize = struct.unpack(">I", b"\x00" + body[pos + 3:pos + 6])[0]
        elif major == 4:
            fsize = mp3mod.synchsafe(body[pos + 4:pos + 8])
        else:
            fsize = struct.unpack(">I", body[pos + 4:pos + 8])[0]
        data_start = pos + fhdr_len
        if data_start + fsize > tag_size:
            # the frame claims to run past the tag's own declared size:
            # a genuine structural error, compared against the true tag
            # size rather than however much we happened to read.
            warns.append(
                f"frame {fid_s!r} size {fsize} overruns the "
                f"{tag_size:,}-byte tag"
            )
            break
        if data_start + fsize > len(body):
            # fits inside the declared tag but past what we read (embedded
            # art beyond the read cap). record the frame and stop cleanly;
            # this is not a spec violation.
            note = "attached picture" if fid_s in ("APIC", "PIC") else ""
            fields.append(_f(10 + pos, fhdr_len + fsize, fid_s,
                             f"{fsize:,} bytes", note or "beyond read cap"))
            break
        raw = body[data_start:data_start + fsize]
        note = (_ID3V22_TEXT_FRAMES if is_v22 else _ID3_TEXT_FRAMES).get(fid_s, "")
        opaque = ""
        skip = 0
        unsynced = False
        img_field = None
        img_ref = None
        if not is_v22 and pos + 10 <= len(body):
            # v2.3/v2.4 frame headers carry two flag bytes; the format byte can
            # prepend extras (group id, decompressed size / data-length
            # indicator, encryption method) that would otherwise be decoded as
            # the first payload bytes.
            fflags = _bu16(body, pos + 8)
            skip, opaque = _frame_extra_skip(major, fflags)
            if major == 4 and fflags & 0x0002 and not opaque:
                # per-frame unsynchronisation (v2.4): undo before decoding.
                # the extras are synchsafe/single bytes and never unsync-escaped
                raw = raw[:skip] + raw[skip:].replace(b"\xff\x00", b"\xff")
                unsynced = True
            raw = raw[skip:]
        if opaque:
            # compressed/encrypted payloads cannot be shown as text; say why
            # instead of decoding garbage
            value = f"{fsize:,} bytes"
            note = (note + ", " if note else "") + opaque
        elif fid_s in ("TXXX", "WXXX"):
            value = _decode_txxx(raw, fid_s)
            note = "user-defined text" if fid_s == "TXXX" else "user-defined URL"
        elif fid_s in ("COMM", "USLT", "COM", "ULT"):
            value = _decode_comm(raw)
            if not note:
                note = "lyrics" if fid_s in ("USLT", "ULT") else "comment"
        elif fid_s.startswith("T"):        # every T*** frame is text (id3 spec)
            value = _decode_id3_text(raw)
            if fid_s in ("TCON", "TCO"):
                resolved = _resolve_tcon(value)
                if resolved != value:
                    note = f"genre: {resolved}"
        elif fid_s == "APIC" or fid_s == "PIC":
            value = f"{fsize:,} bytes"
            note = "attached picture"
            # locate the raw image inside the frame and, when the on-disk bytes
            # match it (no unsync remap), expose it as a carveable region with an
            # xref. an APIC can carry a byte-exact standalone file: cover art
            # normally is one, and a cavity hides extra bytes past its terminator.
            span, mime = _apic_image_span(raw, is_v22)
            if mime:
                note += f", {mime}"
            if span is not None and not unsynced and not tag_desynced:
                payload = raw[span:]
                img_abs = 10 + data_start + skip + span
                fmt_name, extent = _embedded_file(payload)
                if fmt_name is None:
                    media, img_len = "image data", len(payload)
                elif extent is None:
                    media = f"{fmt_name} data, no terminator (truncated?)"
                    img_len = len(payload)
                else:
                    # the region spans the self-delimited file itself, so a carve
                    # of it opens directly. a NUL run past the terminator is frame
                    # padding; any other trailing bytes are a genuine cavity.
                    img_len = extent
                    trailing = payload[extent:]
                    if trailing.strip(b"\x00"):
                        media = (f"complete {fmt_name} (carveable), "
                                 f"{len(trailing):,} bytes follow it")
                        warns.append(
                            f"{fid_s} image is a complete {fmt_name} with "
                            f"{len(trailing):,} non-padding bytes after its "
                            "terminator")
                    else:
                        media = f"complete {fmt_name} (carveable)"
                img_field = _f(img_abs, img_len, f"{fid_s}:image",
                               f"{img_len:,} bytes", media)
                img_ref = img_abs
        else:
            value = f"{fsize:,} bytes"
        fields.append(_f(10 + pos, fhdr_len + fsize, fid_s, value, note,
                         xref=img_ref))
        if img_field is not None:
            fields.append(img_field)
        pos = data_start + fsize
    return fields, warns


def _xing_offset(hdr):
    """Byte offset of the Xing/Info tag within the first frame, from the frame
    start: the 4-byte header then the version/channel-dependent side info block
    (side info = 32/17 MPEG-1 stereo/mono, 17/9 MPEG-2/2.5 stereo/mono).

    The tag stays at this CRC-absent offset EVEN when the frame is CRC-protected:
    LAME's VbrTag.c deliberately subtracts the two CRC bytes to hold the tag
    where Xing/Info readers expect it, and tag frames essentially never carry a
    CRC in practice. Adding 2 for the CRC halfword here would look past the tag
    and miss it."""
    mono = hdr["channel_mode"] == 0b11
    base = 4
    if hdr["version_id"] == 0b11:        # MPEG 1
        return base + (17 if mono else 32)
    return base + (9 if mono else 17)    # MPEG 2 / 2.5


def _parse_vbri(buf, off):
    """Decode a Fraunhofer VBRI header (the FhG encoder's answer to Xing).
    Returns the Xing-path 4-tuple (fields, warns, frame_count, tag). Offsets
    are frame-relative to match the frame0 chunk's payload_base. Layout after
    'VBRI', all big-endian: version u16, delay u16, quality u16, bytes u32,
    frames u32, TOC entry count u16, TOC scale u16, bytes per TOC entry u16,
    frames per TOC entry u16, then the seek TOC itself."""
    fields, warns = [], []
    version = _bu16(buf, off + 4)
    delay = _bu16(buf, off + 6)
    quality = _bu16(buf, off + 8)
    nbytes = _bu32(buf, off + 10)
    frame_count = _bu32(buf, off + 14)
    fields.append(_f(off, 4, "vbr_tag", "VBRI", "VBR (Fraunhofer)"))
    fields.append(_f(off + 4, 2, "version", version))
    fields.append(_f(off + 6, 2, "encoder_delay", delay, "samples",
                     enc=">H", raw=delay))
    fields.append(_f(off + 8, 2, "quality", quality, enc=">H", raw=quality))
    fields.append(_f(off + 10, 4, "byte_count", f"{nbytes:,}", enc=">I", raw=nbytes))
    fields.append(_f(off + 14, 4, "frame_count", f"{frame_count:,}",
                     enc=">I", raw=frame_count))
    if off + 26 <= len(buf):
        toc_n = _bu16(buf, off + 18)
        scale = _bu16(buf, off + 20)
        esize = _bu16(buf, off + 22)
        fpe = _bu16(buf, off + 24)
        fields.append(_f(off + 18, 2, "toc_entries", toc_n))
        fields.append(_f(off + 20, 2, "toc_scale", scale))
        fields.append(_f(off + 22, 2, "toc_entry_size", esize, "bytes"))
        fields.append(_f(off + 24, 2, "toc_frames_per_entry", fpe))
        if toc_n and esize:
            fields.append(_f(off + 26, toc_n * esize, "toc",
                             f"{toc_n} x {esize}-byte seek entries"))
        if esize not in (0, 1, 2, 3, 4):
            warns.append(f"VBRI TOC entry size {esize} is outside the "
                         "1-4 byte range the spec allows")
    return fields, warns, frame_count, b"VBRI"


def _parse_xing_lame(filepath, frame_off, hdr):
    """Decode the Xing/Info VBR header and any LAME extension in the
    first frame. Returns (fields, warns, frame_count, tag) where tag is
    b"Xing" (VBR), b"Info" (CBR), or None if no tag is present."""
    fields, warns = [], []
    xoff = _xing_offset(hdr)
    with open(filepath, "rb") as f:
        f.seek(frame_off)
        buf = f.read(max(hdr["frame_length"], xoff + 200, 64))
    # VBRI (Fraunhofer) sits at a fixed offset, 32 bytes past the 4-byte frame
    # header, regardless of channel mode; Xing/Info sit at the side-info-
    # dependent xoff. a frame carries at most one of them.
    if len(buf) >= 36 + 18 and buf[36:40] == b"VBRI":
        return _parse_vbri(buf, 36)
    if xoff + 8 > len(buf):
        return None, [], None, None
    tag = buf[xoff:xoff + 4]
    if tag not in (b"Xing", b"Info"):
        return None, [], None, None
    kind = "VBR" if tag == b"Xing" else "CBR (LAME)"
    fields.append(_f(xoff, 4, "vbr_tag", tag.decode("ascii"), kind))
    flags = _bu32(buf, xoff + 4)
    pos = xoff + 8
    # each optional field is only present if its flag is set; the tag may be
    # truncated after any of them, so bound every read against the buffer.
    frame_count = None
    if flags & 0x01:
        if pos + 4 > len(buf):
            warns.append("Xing header truncated before frame_count")
            return fields, warns, frame_count, tag
        frame_count = _bu32(buf, pos)
        fields.append(_f(pos, 4, "frame_count", f"{frame_count:,}",
                         enc=">I", raw=frame_count))
        pos += 4
    if flags & 0x02:
        if pos + 4 > len(buf):
            warns.append("Xing header truncated before byte_count")
            return fields, warns, frame_count, tag
        nbytes = _bu32(buf, pos)
        fields.append(_f(pos, 4, "byte_count", f"{nbytes:,}", enc=">I", raw=nbytes))
        pos += 4
    if flags & 0x04:
        if pos + 100 > len(buf):
            warns.append("Xing header truncated before seek table")
            return fields, warns, frame_count, tag
        fields.append(_f(pos, 100, "toc", "100-entry seek table"))
        pos += 100
    if flags & 0x08:
        if pos + 4 > len(buf):
            warns.append("Xing header truncated before quality")
            return fields, warns, frame_count, tag
        quality = _bu32(buf, pos)
        fields.append(_f(pos, 4, "quality", quality, "0=best, 100=worst"))
        pos += 4

    # LAME extension: 9-byte encoder string then 27 bytes of detail
    if pos + 9 <= len(buf) and buf[pos:pos + 4] in (b"LAME", b"L3.9", b"GOGO"):
        version = buf[pos:pos + 9].decode("latin-1", errors="replace").strip()
        fields.append(_f(pos, 9, "encoder", version))
        if pos + 24 <= len(buf):
            vbr_method = buf[pos + 9] & 0x0F
            lowpass = buf[pos + 10] * 100
            # vbr_method is the low nibble of the byte (high nibble is the Info
            # tag revision) -> bit-field over that byte, editable as the raw code
            fields.append(_f(pos + 9, 1, "vbr_method", vbr_method,
                             _VBR_METHODS.get(vbr_method, ""),
                             enc="bits:0:1:4:4:0", raw=vbr_method))
            if lowpass:
                fields.append(_f(pos + 10, 1, "lowpass", f"{lowpass} Hz"))
            rgword = _bu16(buf, pos + 15)
            rg = _lame_replaygain(rgword)
            if rg:
                fields.append(_f(pos + 15, 2, "replay_gain", rg))
                # split the 16-bit word: name (3b), sign (1b), magnitude (9b)
                rg_name = (rgword >> 13) & 0x07
                fields.append(_f(pos + 15, 2, "replay_gain_type", rg_name,
                                 {1: "radio", 2: "audiophile"}.get(rg_name, ""),
                                 enc="bits:0:2:0:3:0", raw=rg_name))
                fields.append(_f(pos + 15, 2, "replay_gain_sign", (rgword >> 9) & 1,
                                 "1 = negative", enc="bits:0:2:6:1:0",
                                 raw=(rgword >> 9) & 1))
                fields.append(_f(pos + 15, 2, "replay_gain_mag", rgword & 0x1FF,
                                 "0.1 dB units", enc="bits:0:2:7:9:0",
                                 raw=rgword & 0x1FF))
            bitrate = buf[pos + 20]
            if bitrate:
                fields.append(_f(pos + 20, 1, "bitrate", f"{bitrate} kbps",
                                 "min for VBR, target for ABR"))
            delay = (buf[pos + 21] << 4) | (buf[pos + 22] >> 4)
            padding = ((buf[pos + 22] & 0x0F) << 8) | buf[pos + 23]
            fields.append(_f(pos + 21, 3, "gapless", f"delay {delay}, pad {padding}",
                             "encoder delay / padding samples"))
            # the two 12-bit halves of the 3-byte word, editable in place
            fields.append(_f(pos + 21, 3, "encoder_delay", delay, "samples",
                             enc="bits:0:3:0:12:0", raw=delay))
            fields.append(_f(pos + 21, 3, "encoder_padding", padding, "samples",
                             enc="bits:0:3:12:12:0", raw=padding))
    return fields, warns, frame_count, tag


def inspect_mp3(filepath, deep=False):
    """Walk an MP3: optional ID3v2 tag, the MPEG frame run (with the
    first frame fully decoded and any Xing/LAME header), and an optional
    ID3v1 trailer. With ``deep``, the frame run carries a per-frame
    listing (offset, bitrate, sample rate, channel mode, size)."""
    file_size = os.path.getsize(filepath)
    chunks = []
    file_warns = []

    audio_start = 0
    hdr = mp3mod.read_id3v2(filepath)
    if hdr:
        flds, warns = _id3v2_frames(filepath, hdr)
        ntext = sum(1 for fl in flds if fl["off"] is not None and fl["off"] >= 10)
        chunks.append({"id": "ID3v2", "offset": 0, "size": hdr["total"],
                       "summary": f"ID3v2.{hdr['major']} tag, {ntext} frame(s)",
                       "fields": flds, "warnings": warns,
                       "payload_base": 0})  # ID3 field offsets are absolute
        audio_start = hdr["total"]

    id3v1_off = mp3mod.find_id3v1(filepath)
    audio_end = id3v1_off if id3v1_off is not None else file_size

    # find the first valid frame at or after audio_start
    first = None
    for off, fh in mp3mod.iter_frames(filepath, audio_start, audio_end, max_frames=1):
        first = (off, fh)
        break
    if first is None:
        file_warns.append("no valid MPEG audio frame found")
        return chunks, file_warns

    frame_off, fh = first
    if frame_off > audio_start:
        file_warns.append(
            f"{frame_off - audio_start} bytes of junk between the tag and the "
            f"first frame sync"
        )
    if fh.get("free_format"):
        # bitrate index 0: the length was measured from sync spacing, so the
        # true bitrate is derived, not tabled -- and not bit-editable
        derived = mp3mod.free_format_bitrate(fh, fh["frame_length"])
        bitrate_field = _f(0x00, 4, "bitrate", "free",
                           f"free format; ~{derived} kbps measured from "
                           f"{fh['frame_length']:,}-byte frames")
    else:
        bitrate_field = _f(0x00, 4, "bitrate", fh["bitrate"],
                           "kbps (first frame)",
                           enc="bitsdyn:0:4:16:4:mpeg_bitrate")
    fields = [
        _f(0x00, 4, "sync", "0x7ff", f"{fh['version']}, {fh['layer']}"),
        _f(0x00, 4, "version", fh["version"], enc="bitsmap:0:4:11:2:mpeg_version"),
        _f(0x00, 4, "layer", fh["layer"], enc="bitsmap:0:4:13:2:mpeg_layer"),
        # bitrate/sample_rate decode from indices in the header word, via tables
        # chosen by the version+layer bits -> context-dependent enum bit-fields.
        bitrate_field,
        _f(0x00, 4, "sample_rate", fh["sample_rate"], "Hz",
           enc="bitsdyn:0:4:20:2:mpeg_samplerate"),
        # channel_mode is bits 7-6 of the 4th header byte -> bitpos 24, width 2
        # in the 4-byte header word at 0x00; enum-mapped to the mode name.
        _f(0x00, 4, "channel_mode", fh["channel_mode_name"],
           enc="bitsmap:0:4:24:2:mpeg_chanmode"),
        # protection bit is inverted (0 = CRC present); enum handles that
        _f(0x00, 4, "crc_protected",
           "protected" if fh["has_crc"] else "unprotected",
           enc="bitsmap:0:4:15:1:mpeg_crc"),
        _f(None, 0, "samples_per_frame", fh["samples_per_frame"]),
    ]
    if fh["emphasis"] != "none":
        # emphasis is bits 1-0 of the header word -> bitpos 30, width 2
        fields.append(_f(0x00, 4, "emphasis", fh["emphasis"],
                         enc="bitsmap:0:4:30:2:mpeg_emphasis"))

    try:
        xing_fields, xing_warns, vbr_frames, vbr_tag = \
            _parse_xing_lame(filepath, frame_off, fh)
    except Exception as e:
        xing_fields, xing_warns, vbr_frames, vbr_tag = None, \
            [f"VBR header parse error: {e.__class__.__name__}"], None, None
    # Xing and VBRI both declare VBR; an Info tag is the same structure as
    # Xing written by LAME for CBR streams and must not force the VBR label.
    is_vbr_header = vbr_tag in (b"Xing", b"VBRI")
    if xing_fields is not None:
        fields.extend(xing_fields)
    kbps_txt = ("free format" if fh.get("free_format")
                else f"{fh['bitrate']} kbps")
    chunks.append({"id": "frame0", "offset": frame_off, "size": fh["frame_length"],
                   "summary": (f"{fh['version']} {fh['layer']}, {kbps_txt}, "
                               f"{fh['sample_rate']} Hz, {fh['channel_mode_name']}"),
                   "fields": fields, "warnings": xing_warns,
                   "payload_base": frame_off})  # fields are frame-relative

    # LAME encoder delay + padding, so the reported duration is the gapless /
    # playable length (matching ffprobe/mutagen), not the raw frame count.
    gap_delay = gap_pad = 0
    for _fl in (xing_fields or []):
        if _fl.get("name") == "gapless":
            try:
                _t = str(_fl.get("value", "")).replace(",", "").split()
                gap_delay, gap_pad = int(_t[1]), int(_t[3])
            except (ValueError, IndexError):
                pass

    # count frames and derive duration. trust the Xing frame count when
    # present (accurate for VBR); otherwise walk the stream. with deep,
    # also record a per-frame row up to the listing cap.
    count = 0
    bitrates = set()
    rows = []
    truncated = False
    # a Xing/VBRI header already carries the authoritative frame count, so on the
    # fast (non-deep) path skip the full frame walk entirely -- it was only kept
    # for the min-max bitrate string and the divergence cross-check, both of
    # which are diagnostics that belong behind --frames (deep). This turns a
    # ~13k-iteration walk on a 5 MB VBR MP3 into a header read.
    walk = deep or vbr_frames is None
    for off, f2 in (mp3mod.iter_frames(filepath, frame_off, audio_end)
                    if walk else ()):
        count += 1
        bitrates.add(f2["bitrate"])
        if deep and len(rows) < _FRAME_LISTING_CAP:
            rows.append({
                "#": len(rows),
                "offset": f"0x{off:08x}",
                "kbps": "free" if f2.get("free_format") else f2["bitrate"],
                "Hz": f2["sample_rate"],
                "mode": f2["channel_mode_name"],
                "bytes": f2["frame_length"],
            })
        elif deep:
            truncated = True
    walked = count
    if vbr_frames:
        count = vbr_frames
    spf = fh["samples_per_frame"]
    duration = (max(0, count * spf - gap_delay - gap_pad) / fh["sample_rate"]
                if fh["sample_rate"] else 0)
    # when the walk was skipped (bitrates empty), the tag type decides: an Info
    # tag is CBR, a Xing/VBRI header is VBR
    cbr = not is_vbr_header and (not walk or len(bitrates) == 1)
    summary = (f"{count:,} frames, {duration:.3f} s, "
               f"{'CBR' if cbr else 'VBR'}")
    if len(bitrates) > 1:
        summary += f", {min(bitrates)}-{max(bitrates)} kbps"
    frames_entry = {"id": "frames", "offset": frame_off,
                    "size": audio_end - frame_off, "summary": summary,
                    "fields": [_f(None, 0, "frame_count", f"{count:,}"),
                               _f(None, 0, "duration", f"{duration:.3f} s"),
                               _f(None, 0, "vbr", not cbr)],
                    "warnings": [], "payload_base": frame_off}
    if vbr_frames and walked and abs(vbr_frames - walked) > max(2, walked // 20):
        frames_entry["warnings"].append(
            f"Xing/VBRI frame_count {vbr_frames:,} diverges from {walked:,} "
            f"frames walked; VBR duration may be wrong")
    if deep:
        frames_entry["rows"] = rows
        if truncated:
            frames_entry["warnings"].append(
                f"frame listing capped at {_FRAME_LISTING_CAP:,}; "
                f"{count:,} frames total"
            )
    chunks.append(frames_entry)

    if id3v1_off is not None:
        with open(filepath, "rb") as f:
            f.seek(id3v1_off)
            tag = f.read(128)
        v1_fields, title = _id3v1_fields(tag)
        chunks.append({"id": "ID3v1", "offset": id3v1_off, "size": 128,
                       "summary": f"ID3v1 trailer, {title or 'untitled'}",
                       "fields": v1_fields,
                       "warnings": [], "payload_base": id3v1_off})
    return chunks, file_warns
