"""Rebuild the article's two-note reproduction of Battuta issue #26.

Requires Python 3 + mido, FluidSynth, and Battuta 0.1.2 (mid) on PATH.
Usage: python3 reproduce.py --soundfont /path/to/GeneralUser-GS.sf2 --out /tmp/ambiguity
The soundfont is not redistributed. Output WAVs are unnormalised stereo PCM.
"""

import argparse
from array import array
import hashlib
import json
import math
from pathlib import Path
import subprocess
import sys
import wave

import mido


def run(args):
    return subprocess.run(args, capture_output=True, text=True, check=True)


def sha(path):
    return hashlib.sha256(path.read_bytes()).hexdigest()


def pcm(path):
    with wave.open(str(path), "rb") as wav:
        assert (wav.getnchannels(), wav.getsampwidth(), wav.getframerate()) == (2, 2, 44100)
        result = array("h", wav.readframes(wav.getnframes()))
    if sys.byteorder != "little":
        result.byteswap()
    return result


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--soundfont", required=True, type=Path)
    parser.add_argument("--mid", default="mid")
    parser.add_argument("--out", required=True, type=Path)
    args = parser.parse_args()
    if args.out.exists():
        parser.error("--out must name a new directory; existing files will not be overwritten")
    args.out.mkdir(parents=True)
    source = args.out / "input.mid"

    # Exact musical data from tests/unranked.rs::apart and its shared builder
    # at OliviaMIDI 2ae726881311eb00f8d2655094e2c20b61a7a72b.
    # Track and channel numbers below use Battuta's zero-based indexing.
    midi = mido.MidiFile(type=1, ticks_per_beat=960)
    midi.tracks.append(mido.MidiTrack([
        mido.MetaMessage("time_signature", numerator=4, denominator=4, time=0),
        mido.MetaMessage("set_tempo", tempo=500000, time=0),
        mido.MetaMessage("end_of_track", time=0),
    ]))
    midi.tracks.append(mido.MidiTrack([
        mido.Message("note_on", channel=0, note=60, velocity=64, time=0),
        mido.Message("note_off", channel=0, note=60, velocity=0, time=1920),
        mido.Message("note_on", channel=0, note=64, velocity=64, time=0),
        mido.Message("note_off", channel=0, note=64, velocity=0, time=1920),
        mido.MetaMessage("end_of_track", time=0),
    ]))
    midi.tracks.append(mido.MidiTrack([
        mido.Message("program_change", channel=0, program=40, time=0),
        mido.MetaMessage("end_of_track", time=0),
    ]))
    midi.save(source)

    for name, track in [("a-unranked", 2), ("b-ordered", 1)]:
        edit = [{"kind": "set_program", "track": track, "channel": 0,
                 "tick": 1920, "program": 56}]
        edit_path = args.out / f"{name}.json"
        edit_path.write_text(json.dumps({"edits": edit}, indent=2) + "\n")
        target = args.out / f"{name}.mid"
        # Refusal must be checked before creating A with a named exception.
        if name == "a-unranked":
            refused_path = args.out / "must-not-exist.mid"
            assert not refused_path.exists()
            refused = subprocess.run([args.mid, "apply", str(source), str(edit_path),
                                      "-o", str(refused_path)], capture_output=True, text=True)
            assert refused.returncode != 0 and not refused_path.exists(), refused.stderr
            assert "t2:c0:s1920" in refused.stderr, refused.stderr
            (args.out / "refusal.txt").write_text(refused.stderr)
        command = [args.mid, "apply", str(source), str(edit_path), "-o", str(target)]
        if name == "a-unranked":
            command += ["--allow-unranked", "t2:c0:s1920"]
        run(command)
        inspected = run([args.mid, "inspect", str(target), "--json"])
        (args.out / f"{name}-inspect.json").write_text(inspected.stdout)

    rendering = ["fluidsynth", "-ni", "-q", "-r", "44100", "-g", "1.0",
                 "-R", "0", "-C", "0", "-T", "wav", "-O", "s16"]
    for name in ["input", "a-unranked", "b-ordered"]:
        run(rendering + ["-F", str(args.out / f"{name}.wav"),
                         str(args.soundfont), str(args.out / f"{name}.mid")])

    baseline, a, b = [pcm(args.out / f"{name}.wav")
                      for name in ["input", "a-unranked", "b-ordered"]]
    assert a == baseline, "A should still sound exactly like the unedited input on this renderer"
    assert a[:88200] == b[:88200], "The first second should match"
    # Preserve each full release tail; append silence to the shorter render.
    # No trimming, time shift, gain adjustment, or independent normalisation.
    original_frames = {"input": len(baseline) // 2, "a-unranked": len(a) // 2, "b-ordered": len(b) // 2}
    length = max(len(a), len(b), len(baseline))
    for name, samples in [("input", baseline), ("a-unranked", a), ("b-ordered", b)]:
        samples.extend([0] * (length - len(samples)))
        encoded = array("h", samples)
        if sys.byteorder != "little":
            encoded.byteswap()
        with wave.open(str(args.out / f"{name}.wav"), "wb") as wav:
            wav.setparams((2, 2, 44100, 0, "NONE", "not compressed"))
            wav.writeframes(encoded.tobytes())
    difference = [x - y for x, y in zip(a, b)]
    peak = max(abs(x) for x in difference) / 32768
    assert peak > 0
    assert all(abs(x) < 32767 for x in a) and all(abs(x) < 32767 for x in b)
    manifest = {
        "origin": "Reconstructed from OliviaMIDI tests/unranked.rs::apart; not original session audio",
        "source_commit": "2ae726881311eb00f8d2655094e2c20b61a7a72b",
        "mid_version": run([args.mid, "--version"]).stdout.strip(),
        "fluidsynth_version": run(["fluidsynth", "--version"]).stdout.strip(),
        "soundfont": {"file": args.soundfont.name, "sha256": sha(args.soundfont)},
        "render": {"sample_rate": 44100, "channels": 2, "pcm_bits": 16,
                   "gain": 1.0, "reverb": False, "chorus": False, "normalisation": "none",
                   "alignment": "original start times; shorter tails padded with silence",
                   "original_frames": original_frames},
        "measurement": {"method": "20 * log10(max(abs(A[i] - B[i])) / 32768), all PCM samples",
                        "difference_peak_dbfs": 20 * math.log10(peak),
                        "duration_seconds": len(a) / 88200,
                        "a_equals_unedited_input": a == baseline,
                        "first_second_identical": a[:88200] == b[:88200],
                        "a_peak_dbfs": 20 * math.log10(max(abs(x) for x in a) / 32768),
                        "b_peak_dbfs": 20 * math.log10(max(abs(x) for x in b) / 32768)},
        "files": {p.name: sha(p) for p in sorted(args.out.iterdir())
                  if p.suffix in (".mid", ".wav", ".json", ".txt") and p.name != "measurement.json"},
    }
    (args.out / "measurement.json").write_text(json.dumps(manifest, indent=2) + "\n")
    print(json.dumps(manifest["measurement"], indent=2))


if __name__ == "__main__":
    main()
