"""SoundFont 2 (.sf2) reader and sample extractor.

SF2 is a RIFF file (form 'sfbk') with three LIST chunks: INFO (metadata), sdta
(one 'smpl' chunk holding every sample's 16-bit PCM back to back), and pdta
(the preset/instrument/sample structure, ending in 'shdr' -- a table of sample
headers giving each sample's name, start/end index into smpl, loop, and rate).
Extracting a sample is a carve of smpl[start*2:end*2] wrapped in a WAV header.

Open, no access control -- codec/container work. SF3 (MuseScore) keeps the same
sfbk layout but stores each sample as an Ogg Vorbis stream inside smpl, marked
by sample-type bit 0x10, with the shdr start/end fields repurposed as byte
offsets into smpl. acidcat maps SF3 structure and extracts the Ogg streams
verbatim; decoding Vorbis to PCM needs a codec it does not bundle. The MuseScore
writer also omits RIFF pad bytes, which the chunk walker tolerates.
"""

import struct

_SHDR_LEN = 46          # bytes per sample header record
_INFO_STR = {b"INAM": "name", b"isng": "sound_engine", b"IPRD": "product",
             b"IENG": "engineer", b"ISFT": "software", b"ICMT": "comment",
             b"ICOP": "copyright", b"ICRD": "date", b"IART": "author"}


class Sf2Error(ValueError):
    """The bytes are not a decodable SF2 soundfont."""


def is_sf2(data):
    return len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"sfbk"


def _riff_id_ok(data, pos):
    """A plausible RIFF chunk id starts here (4 printable-ASCII bytes)."""
    return pos + 4 <= len(data) and all(0x20 <= b < 0x7f for b in data[pos:pos + 4])


def _iter_riff(data, start, end):
    """Yield (id, data_offset, size) for chunks in [start, end). LIST chunks
    yield their own id 'LIST' with the list type as the first 4 payload bytes.

    RIFF pads an odd-sized chunk to an even boundary, but MuseScore's SF3 writer
    omits the pad byte, which desyncs a strict reader on the first odd chunk (the
    smpl blob). Skip the pad only when it keeps us aligned to a real chunk id;
    when the padded position is garbage but the unpadded one is a valid id, this
    writer did not pad."""
    pos = start
    while pos + 8 <= end:
        cid = data[pos:pos + 4]
        size = struct.unpack_from("<I", data, pos + 4)[0]
        if pos + 8 + size > end:
            break
        yield cid, pos + 8, size
        nxt = pos + 8 + size
        if size & 1 and nxt < end:
            if _riff_id_ok(data, nxt) and not _riff_id_ok(data, nxt + 1):
                pass                       # unpadded writer (SF3)
            else:
                nxt += 1                   # spec even-alignment pad
        pos = nxt


def parse_sf2(data):
    """Decode an SF2 into {version, info, sample_count, smpl_offset, smpl_size,
    samples: [{name, start, end, loop_start, loop_end, rate, pitch, type}]}.
    Samples exclude the terminal EOS record and empty-named entries. Raises
    Sf2Error on malformed input."""
    if not is_sf2(data):
        raise Sf2Error("not a RIFF/sfbk SoundFont")
    riff_size = struct.unpack_from("<I", data, 4)[0]
    end = min(len(data), 8 + riff_size)
    info = {}
    version = None
    smpl_off = smpl_size = None
    shdr_off = shdr_size = None
    for cid, off, size in _iter_riff(data, 12, end):
        if cid != b"LIST":
            continue
        ltype = data[off:off + 4]
        for scid, soff, ssize in _iter_riff(data, off + 4, off + size):
            if ltype == b"INFO":
                if scid == b"ifil" and ssize >= 4:
                    version = f"{struct.unpack_from('<H', data, soff)[0]}." \
                              f"{struct.unpack_from('<H', data, soff + 2)[0]}"
                elif scid in _INFO_STR:
                    info[_INFO_STR[scid]] = data[soff:soff + ssize].split(
                        b"\x00")[0].decode("latin-1", "replace").strip()
            elif ltype == b"sdta" and scid == b"smpl":
                smpl_off, smpl_size = soff, ssize
            elif ltype == b"pdta" and scid == b"shdr":
                shdr_off, shdr_size = soff, ssize
    if smpl_off is None or shdr_off is None:
        raise Sf2Error("missing the smpl (sample data) or shdr (sample header) chunk")

    # SF3 (MuseScore) keeps the sfbk layout but stores each sample as an Ogg
    # Vorbis stream inside smpl and marks it with sample-type bit 0x10; the
    # shdr start/end fields then hold byte offsets into smpl, not sample indices.
    smpl_samples = smpl_size // 2
    samples = []
    any_compressed = False
    for i in range(shdr_size // _SHDR_LEN):
        o = shdr_off + i * _SHDR_LEN
        name = data[o:o + 20].split(b"\x00")[0].decode("latin-1", "replace").strip()
        start, end_i, ls, le, rate = struct.unpack_from("<IIIII", data, o + 20)
        pitch, corr, link, stype = struct.unpack_from("<BbHH", data, o + 40)
        if name == "EOS" or not name:
            continue
        compressed = bool(stype & 0x10)
        if compressed:
            # start/end are byte offsets; the region is an Ogg stream
            byte_off, byte_len = smpl_off + start, end_i - start
            in_range = 0 <= start < end_i <= smpl_size
        else:
            byte_off, byte_len = smpl_off + start * 2, (end_i - start) * 2
            in_range = start < end_i <= smpl_samples
        # a sane, in-range header only (a lied-about index must not carve garbage)
        if not (rate and in_range):
            continue
        any_compressed = any_compressed or compressed
        samples.append({"name": name, "start": start, "end": end_i,
                        "loop_start": ls, "loop_end": le, "rate": rate,
                        "pitch": pitch, "correction": corr,
                        "type": stype & 0x0f, "compressed": compressed,
                        "byte_off": byte_off, "byte_len": byte_len})
    return {"version": version, "info": info, "sample_count": len(samples),
            "smpl_offset": smpl_off, "smpl_size": smpl_size,
            "sf3": any_compressed, "samples": samples}


def sample_bytes(data, sample):
    """The raw sample region: 16-bit PCM for SF2, an Ogg Vorbis stream for SF3."""
    return data[sample["byte_off"]:sample["byte_off"] + sample["byte_len"]]


def sample_wav(data, smpl_offset, sample):
    """A mono 16-bit WAV for one uncompressed (SF2) sample header. Raises for a
    compressed SF3 sample, whose Ogg stream must be extracted with
    ``sample_bytes`` (decoding Vorbis to PCM needs a codec acidcat does not
    bundle)."""
    if sample.get("compressed"):
        raise Sf2Error("SF3 sample is Ogg Vorbis; extract with sample_bytes")
    pcm = sample_bytes(data, sample)
    rate = sample["rate"]
    fmt = struct.pack("<HHIIHH", 1, 1, rate, rate * 2, 2, 16)
    body = (b"WAVE" + b"fmt " + struct.pack("<I", 16) + fmt
            + b"data" + struct.pack("<I", len(pcm)) + pcm)
    return b"RIFF" + struct.pack("<I", len(body)) + body
