"""tests for acidcat's write/edit capability (safety + per-format editors)."""

import os
import struct

import pytest

from acidcat.core import writer, edits, edit_riff
from acidcat.core.edit_riff import _iter_chunks


# ── helpers ────────────────────────────────────────────────────────

def _chunk(cid, payload):
    raw = cid + struct.pack("<I", len(payload)) + payload
    return raw + (b"\x00" if len(payload) % 2 else b"")


def _wav(*chunks):
    body = b"WAVE" + b"".join(chunks)
    return b"RIFF" + struct.pack("<I", len(body)) + body


def _fmt():
    return _chunk(b"fmt ", struct.pack("<HHIIHH", 1, 2, 44100, 176400, 4, 16))


def _data(n=8):
    return _chunk(b"data", bytes(range(n % 256)) * (n // (n % 256) if n % 256 else 1)
                  if n % 256 else b"\x00" * n)


def _payload(b, cid):
    return next(c[1] for c in _iter_chunks(b)[0] if c[0] == cid)


# ── safety layer ───────────────────────────────────────────────────

def test_commit_inplace_makes_backup(tmp_path):
    p = tmp_path / "f.bin"
    p.write_bytes(b"original")
    written, backup = writer.commit(str(p), b"edited")
    assert p.read_bytes() == b"edited"
    assert backup.endswith("_original.bin")
    assert open(backup, "rb").read() == b"original"


def test_commit_output_copy_leaves_input(tmp_path):
    p = tmp_path / "f.bin"
    p.write_bytes(b"original")
    out = str(tmp_path / "copy.bin")
    written, backup = writer.commit(str(p), b"edited", out=out)
    assert p.read_bytes() == b"original" and backup is None
    assert open(out, "rb").read() == b"edited"


def test_commit_overwrite_skips_backup(tmp_path):
    p = tmp_path / "f.bin"
    p.write_bytes(b"original")
    _, backup = writer.commit(str(p), b"edited", overwrite=True)
    assert backup is None and p.read_bytes() == b"edited"


def test_commit_second_edit_keeps_first_backup(tmp_path):
    # the _original made on the first edit is the true original; a later edit
    # must never overwrite it
    p = tmp_path / "f.bin"
    p.write_bytes(b"original")
    writer.commit(str(p), b"edit1")
    _, backup = writer.commit(str(p), b"edit2")
    assert backup is None
    assert open(writer.backup_path(str(p)), "rb").read() == b"original"


def test_atomic_write_readback_detects_mismatch(tmp_path, monkeypatch):
    # if the bytes on disk after os.replace are not the bytes we wrote
    # (interference, filesystem fault), the write must raise instead of
    # letting the caller report "wrote"/"saved"
    p = tmp_path / "f.bin"
    real_replace = os.replace

    def corrupting_replace(src, dst):
        real_replace(src, dst)
        with open(dst, "wb") as f:
            f.write(b"corrupted")

    monkeypatch.setattr(os, "replace", corrupting_replace)
    with pytest.raises(OSError, match="read-back"):
        writer.atomic_write(str(p), b"edited")


def test_aiff_edit_clear_odd_and_unicode():
    # clearing a chunk, inserting an odd-length utf-8 value (pad byte), and
    # keeping metadata ahead of SSND -- the thin spots in AIFF edit coverage
    from acidcat.core import edit_aiff

    def ck(cid, payload):
        return (cid + struct.pack(">I", len(payload)) + payload
                + (b"\x00" if len(payload) & 1 else b""))
    rate80 = bytes.fromhex("400EAC44000000000000")
    ssnd = struct.pack(">II", 0, 0) + bytes(range(8))
    body = (b"AIFF" + ck(b"COMM", struct.pack(">HIH", 1, 4, 16) + rate80)
            + ck(b"NAME", b"Old") + ck(b"SSND", ssnd))
    data = b"FORM" + struct.pack(">I", len(body)) + body
    new, applied = edit_aiff.edit_aiff(
        data, {"title": None, "comment": "né 13!"})   # 7 utf-8 bytes, odd
    chunks, _ = edit_aiff._iter_chunks(new)
    ids = [c[0] for c in chunks]
    assert b"NAME" not in ids
    assert ids.index(b"ANNO") < ids.index(b"SSND")
    assert next(c[1] for c in chunks if c[0] == b"ANNO").decode("utf-8") == "né 13!"
    assert next(c[1] for c in chunks if c[0] == b"SSND") == ssnd
    assert len(new) % 2 == 0                       # odd payload got its pad
    assert struct.unpack_from(">I", new, 4)[0] == len(new) - 8


def test_verify_audio_preserved_catches_flac_frame_change():
    streaminfo = b"\x80" + (34).to_bytes(3, "big") + bytes(34)  # last-flag block
    old = b"fLaC" + streaminfo + b"FRAMESFRAMES"
    new = b"fLaC" + streaminfo + b"FRAMESFRAMEX"
    edits._verify_audio_preserved(old, old)          # identical passes
    with pytest.raises(edits.EditError):
        edits._verify_audio_preserved(old, new)      # altered frames refused


def test_mp3_audio_digest_ignores_tag_blocks():
    # adding an ID3v2 header and an ID3v1 trailer must not change the audio
    # fingerprint; changing a frame byte must
    frame = b"\xff\xfb\x90\xc0" + b"\x00" * 413
    bare = frame * 2
    id3 = b"ID3\x03\x00\x00" + bytes([0, 0, 0, 20]) + bytes(20)
    tagged = id3 + bare + b"TAG" + bytes(125)
    assert edits._audio_digest(bare) == edits._audio_digest(tagged)
    corrupt = id3 + bare[:100] + b"\x55" + bare[101:] + b"TAG" + bytes(125)
    assert edits._audio_digest(bare) != edits._audio_digest(corrupt)


@pytest.mark.parametrize("name,suffix", [
    ("gs-16b-2c-44100hz.mp3", ".mp3"),
    ("gs-16b-2c-44100hz.flac", ".flac"),
    ("gs-16b-2c-44100hz.ogg", ".ogg"),
    ("gs-16b-2c-44100hz.opus", ".opus"),
    ("gs-16b-2c-44100hz.m4a", ".m4a"),
])
def test_tagged_easy_fields_roundtrip(name, suffix, tmp_path):
    # bpm/genre/date via the easy interface: previously only exercised as a
    # setup step; assert the values actually land and the audio is untouched
    mutagen = pytest.importorskip("mutagen")
    import os
    p = os.path.join("data/test_formats", name)
    if not os.path.isfile(p):
        pytest.skip(f"no {suffix} fixture")
    data = open(p, "rb").read()
    new, applied = edits.edit_tagged(
        data, suffix, {"bpm": "128", "genre": "Techno", "date": "2020"})
    assert {a[0] for a in applied} == {"bpm", "genre", "date"}
    out = tmp_path / ("t" + suffix)
    out.write_bytes(new)
    m = mutagen.File(str(out), easy=True)
    assert m["bpm"] == ["128"]
    assert m["genre"] == ["Techno"]
    assert str(m["date"][0]).startswith("2020")
    assert edits._audio_digest(data) == edits._audio_digest(new)


# ── WAV: INFO tags ─────────────────────────────────────────────────

def test_wav_info_roundtrip_preserves_audio():
    src = _wav(_fmt(), _data(9))  # odd data -> exercises padding
    audio = _payload(src, b"data")
    out, applied = edit_riff.edit_wav(src, {"title": "Kick", "artist": "Me"})
    assert _payload(out, b"data") == audio  # audio untouched
    info = _payload(out, b"LIST")
    assert b"INAM" in info and b"Kick\x00" in info and b"Me\x00" in info


def test_wav_chunks_stay_word_aligned():
    src = _wav(_fmt(), _data(8))
    out, _ = edit_riff.edit_wav(src, {"title": "abc"})  # odd -> "abc\x00" = 4, even
    # every chunk must begin at an even offset; _iter_chunks would desync otherwise
    ids = [c[0] for c in _iter_chunks(out)[0]]
    assert b"data" in ids and b"LIST" in ids
    assert struct.unpack_from("<I", out, 4)[0] == len(out) - 8  # riff size = file-8


# ── WAV: acid bpm/key ──────────────────────────────────────────────

def test_wav_acid_created_with_correct_meter():
    src = _wav(_fmt(), _data())
    out, _ = edit_riff.edit_wav(src, {"bpm": "140"})
    acid = _payload(out, b"acid")
    den, num = struct.unpack_from("<HH", acid, 16)
    assert (den, num) == (4, 4)  # denominator-first, symmetric here
    assert abs(struct.unpack_from("<f", acid, 20)[0] - 140.0) < 1e-4


def test_wav_acid_key_sets_root_and_flag():
    src = _wav(_fmt(), _data())
    out, _ = edit_riff.edit_wav(src, {"key": "C3"})
    acid = _payload(out, b"acid")
    assert struct.unpack_from("<H", acid, 4)[0] == 60  # C3 = MIDI 60
    assert struct.unpack_from("<I", acid, 0)[0] & 0x02  # root-set flag


def test_wav_acid_preserves_asymmetric_meter():
    acid = struct.pack("<IHHfIHHf", 0, 0, 0x8000, 0.0, 6, 8, 3, 120.0)  # 3/8
    src = _wav(_fmt(), _chunk(b"acid", acid), _data())
    out, _ = edit_riff.edit_wav(src, {"bpm": "95"})
    a = _payload(out, b"acid")
    assert struct.unpack_from("<HH", a, 16) == (8, 3)  # meter untouched


# ── WAV: bext deep header ──────────────────────────────────────────

def test_wav_bext_patch_is_size_stable():
    bext = b"desc".ljust(256, b"\x00") + b"orig".ljust(346 - 256, b"\x00") + b"\x00" * 256
    src = _wav(_fmt(), _chunk(b"bext", bext), _data())
    out, _ = edit_riff.edit_wav(src, {"originator": "acidcat"})
    assert len(out) == len(src)  # fixed-field patch, no size change
    assert _payload(out, b"bext")[256:288].split(b"\x00")[0] == b"acidcat"


# ── WAV: refusals ──────────────────────────────────────────────────

def test_wav_refuses_rf64():
    with pytest.raises(edits.EditError):
        edit_riff.edit_wav(b"RF64" + b"\xff" * 4 + b"WAVE" + b"\x00" * 8, {"title": "x"})


def test_wav_refuses_data_before_fmt():
    src = _wav(_data(), _fmt())
    with pytest.raises(edits.EditError):
        edit_riff.edit_wav(src, {"title": "x"})


def test_wav_refuses_overrunning_chunk():
    src = _wav(_fmt(), b"data" + struct.pack("<I", 999999) + b"\x00\x00")
    with pytest.raises(edits.EditError):
        edit_riff.edit_wav(src, {"title": "x"})


def test_wav_unknown_field_rejected():
    with pytest.raises(edits.EditError):
        edit_riff.edit_wav(_wav(_fmt(), _data()), {"nonsense": "x"})


# ── Vital ──────────────────────────────────────────────────────────

def test_vital_edit_preserves_other_keys():
    data = (b'{"synth_version":"1","preset_name":"Old","author":"a",'
            b'"settings":{"osc_1_on":1.0}}')
    out, applied = edits.edit_vital(data, {"name": "New", "author": "acidcat"})
    import json
    obj = json.loads(out)
    assert obj["preset_name"] == "New" and obj["author"] == "acidcat"
    assert obj["settings"] == {"osc_1_on": 1.0}  # untouched


def test_note_to_midi():
    assert edit_riff._note_to_midi("C3") == 60
    assert edit_riff._note_to_midi("A3") == 69
    assert edit_riff._note_to_midi("60") == 60


def test_wav_smpl_root_note():
    src = _wav(_fmt(), _data())
    out, _ = edit_riff.edit_wav(src, {"root": "C3"})
    smpl = _payload(out, b"smpl")
    assert struct.unpack_from("<I", smpl, 12)[0] == 60  # C3 sampler root
    assert _payload(out, b"data") == _payload(src, b"data")  # audio intact


def test_aiff_edit_preserves_audio_big_endian():
    from acidcat.core import edit_aiff
    def bc(cid, p):
        return cid + struct.pack(">I", len(p)) + p + (b"\x00" if len(p) % 2 else b"")
    body = b"AIFF" + bc(b"COMM", b"\x00" * 18) + bc(b"SSND", b"\x01" * 7)
    aiff = b"FORM" + struct.pack(">I", len(body)) + body
    out, _ = edit_aiff.edit_aiff(aiff, {"title": "Snare", "artist": "Me"})
    chs = {c[0]: c[1] for c in edit_aiff._iter_chunks(out)[0]}
    assert chs[b"NAME"] == b"Snare" and chs[b"AUTH"] == b"Me"
    assert chs[b"SSND"] == b"\x01" * 7  # audio intact
    assert struct.unpack_from(">I", out, 4)[0] == len(out) - 8  # BE FORM size


def test_bitwig_meta_splice():
    from acidcat.core import edits
    def field(key, val):
        return (struct.pack(">I", len(key)) + key + b"\x08"
                + struct.pack(">I", len(val)) + val)
    data = (b"BtWg0003000200" + field(b"creator", b"relo")
            + field(b"tags", b"old"))
    out, applied = edits.edit_bitwig(data, {"creator": "me", "tags": "a b c"})
    m = __import__("acidcat.core.bitwig", fromlist=["parse_meta"]).parse_meta(out)
    assert m["creator"] == "me" and m["tags"] == "a b c"


def test_ni_nksf_roundtrip():
    from acidcat.core import ni
    data = open("data/test_formats/corpus/synth/New_Init_2.nksf", "rb").read() \
        if __import__("os").path.exists("data/test_formats/corpus/synth/New_Init_2.nksf") else None
    if data is None:
        import pytest as _pt; _pt.skip("corpus nksf not present")
    out, _ = ni.edit_nksf(data, {"name": "X", "author": "y"})
    m = ni.parse_nksf(out)
    assert m["name"] == "X" and m["author"] == "y"


def test_ni_hsin_roundtrip():
    import os
    from acidcat.core import ni
    p = "data/test_formats/corpus/synth/nicecombo.nmsv"
    if not os.path.exists(p):
        import pytest as _pt; _pt.skip("corpus nmsv not present")
    data = open(p, "rb").read()
    out, _ = ni.edit_hsin(data, {"name": "Renamed Patch", "author": "me"})
    m = ni.parse_hsin(out)
    assert m["name"] == "Renamed Patch" and m["author"] == "me"
    assert m["product"] == "Massive"  # product preserved
    assert struct.unpack_from("<Q", out, 0)[0] == len(out)  # root size cascade
    # same-name edit is byte-identical (no CRC, sizes stable)
    same, _ = ni.edit_hsin(data, {"name": "nicecombo"})
    assert same == data


def test_wav_edit_preserves_trailing_bytes_outside_riff():
    """MED-2: bytes after the RIFF container are preserved and NOT folded into
    the recomputed riff_size."""
    import struct
    from acidcat.core import edit_riff

    def _chunk(cid, p):
        return cid + struct.pack("<I", len(p)) + p + (b"\x00" if len(p) % 2 else b"")
    body = b"WAVE" + _chunk(b"fmt ", struct.pack("<HHIIHH", 1, 2, 44100, 176400, 4, 16)) \
        + _chunk(b"data", b"\x00" * 16)
    wav = b"RIFF" + struct.pack("<I", len(body)) + body + b"PAD!"  # 4-byte tail past RIFF
    out, _ = edit_riff.edit_wav(wav, {"title": "x"})
    assert out.endswith(b"PAD!")                      # trailing preserved verbatim
    riff_size = struct.unpack_from("<I", out, 4)[0]
    assert riff_size == len(out) - 8 - 4              # excludes the 4 trailing bytes


def test_bitwig_write_reenabled_cli(tmp_path):
    # the CLI write path used to hard-refuse Bitwig/NI; it now routes to the editor
    from acidcat.commands.write import _edit

    def field(key, val):
        return (struct.pack(">I", len(key)) + key + b"\x08"
                + struct.pack(">I", len(val)) + val)
    p = tmp_path / "x.bwpreset"
    p.write_bytes(b"BtWg0003000200" + field(b"creator", b"old"))
    fmt, new_data, applied = _edit(str(p), {"creator": "new"})
    assert "Bitwig" in fmt and "experimental" in fmt
    assert applied == [("creator", "old", "new")]
    from acidcat.core.bitwig import parse_meta
    assert parse_meta(new_data)["creator"] == "new"


def test_ni_write_routes_not_refused(tmp_path):
    # an NI hsin-magic file no longer hits the blanket refusal; it reaches edit_ni
    # (which will raise its own specific error on a stub, not the "not enabled" one)
    from acidcat.commands.write import _edit
    from acidcat.core import edits
    p = tmp_path / "x.nmsv"
    p.write_bytes(b"\x00" * 12 + b"hsin" + b"\x00" * 64)
    try:
        _edit(str(p), {"name": "y"})
    except edits.EditError as e:
        assert "not enabled" not in str(e)      # the refusal is gone
