"""Tests for the R4a gap-fill write-back — the §7 contract made executable:
user-initiated, adds ABSENT keys only (author bytes preserved verbatim),
spec'd-keys allowlist, values only from a CONFIRMED match, atomic + .bak.

No network anywhere: matches are seeded straight into the enrichment cache
(as the P8 matcher would have written them).
"""

import importlib
import sys
import zipfile

import pytest
import yaml
from fastapi.testclient import TestClient


@pytest.fixture()
def server(tmp_path, monkeypatch, isolate_logging):
    monkeypatch.setenv("CONFIG_DIR", str(tmp_path / "config"))
    dlc = tmp_path / "dlc"
    dlc.mkdir()
    monkeypatch.setenv("DLC_DIR", str(dlc))
    monkeypatch.setenv("FEEDBACK_SKIP_STARTUP_TASKS", "1")
    sys.modules.pop("server", None)
    srv = importlib.import_module("server")
    try:
        yield srv
    finally:
        conn = getattr(getattr(srv, "meta_db", None), "conn", None)
        if conn is not None:
            getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
            conn.close()
        sys.modules.pop("server", None)


@pytest.fixture()
def client(server):
    return TestClient(server.app)


BASE_MANIFEST = ("# my hand-made pack\n"
                 "title: Thunderstruck\n"
                 "artist: AC/DC   # the real one\n"
                 "duration: 292\n"
                 "arrangements: []\n"
                 "stems: []\n")


def make_dir_sloppak(server, name, manifest=BASE_MANIFEST):
    d = server.DLC_DIR / name
    d.mkdir(parents=True)
    (d / "manifest.yaml").write_text(manifest, encoding="utf-8")
    _put_db(server, name)
    return d


def make_zip_sloppak(server, name, manifest=BASE_MANIFEST):
    p = server.DLC_DIR / name
    with zipfile.ZipFile(p, "w", zipfile.ZIP_DEFLATED) as z:
        z.writestr("manifest.yaml", manifest)
        z.writestr("stems/full.ogg", b"OggS-fake")
    _put_db(server, name)
    return p


def _put_db(server, name):
    server.meta_db.put(name, 0, 0, {
        "title": "Thunderstruck", "artist": "AC/DC", "album": "", "year": "",
        "duration": 292, "arrangements": [{"name": "Lead", "index": 0}],
    })


def seed_match(server, fn, state="matched", **overrides):
    """Seed a confirmed enrichment row as the P8 matcher would have."""
    cand = {"recording_id": "12345678-abcd-4ef0-9876-0123456789ab",
            "release_id": "rel-1", "artist_id": "art-1",
            "artist": "AC/DC", "title": "Thunderstruck",
            "album": "The Razors Edge", "year": "1990",
            "genres": ["hard rock", "rock"], "isrc": "AUAP09000045"}
    cand.update(overrides)
    song = server.meta_db.enrichment_song_row(fn)
    h = server.meta_db.enrichment_content_hash(
        song["artist"], song["title"], song["album"], song["duration"])
    server.meta_db.apply_enrichment_match(
        fn, h, state, source="text", score=1.0, cand=cand,
        candidates=([cand] if state == "review" else None))


# ── preview ───────────────────────────────────────────────────────────────────

def test_preview_offers_only_absent_confirmed_keys(server, client):
    make_dir_sloppak(server, "a.sloppak")
    seed_match(server, "a.sloppak")
    d = client.get("/api/song/a.sloppak/gap-fill").json()
    assert d["eligible"] is True
    got = {m["key"]: m["value"] for m in d["missing"]}
    assert got == {"album": "The Razors Edge", "year": 1990,
                   "genres": ["hard rock", "rock"],
                   "mbid": "12345678-abcd-4ef0-9876-0123456789ab",
                   "isrc": "AUAP09000045"}


def test_preview_excludes_author_set_keys(server, client):
    make_dir_sloppak(server, "a.sloppak",
                     BASE_MANIFEST + "album: Live Bootleg\nyear: 1991\n")
    seed_match(server, "a.sloppak")
    got = {m["key"] for m in client.get("/api/song/a.sloppak/gap-fill").json()["missing"]}
    assert "album" not in got and "year" not in got
    assert {"genres", "mbid", "isrc"} <= got


def test_preview_excludes_locked_fields(server, client):
    """A field LOCKED in the Fix-metadata popup is never gap-filled — writing
    the matched value would be exactly the clobber the lock prevents — even
    though the match has a value and the manifest lacks it."""
    make_dir_sloppak(server, "a.sloppak")
    seed_match(server, "a.sloppak")
    server.meta_db.set_song_override("a.sloppak", "album", locked=True)
    server.meta_db.set_song_override("a.sloppak", "year", locked=True)
    got = {m["key"] for m in client.get("/api/song/a.sloppak/gap-fill").json()["missing"]}
    assert "album" not in got and "year" not in got
    assert {"genres", "mbid", "isrc"} <= got        # unlocked keys still offered


def test_preview_excludes_present_but_empty_keys(server, client):
    """Gap-fill is append-only, so a present-but-empty value (album: '',
    year: 0) is NOT a gap the writer can fill — appending would duplicate the
    key, and the never-clobber guard refuses any present key. The preview must
    therefore not offer it (those are the metadata editor's job to re-serialize),
    while genuinely-absent keys are still offered."""
    make_dir_sloppak(server, "a.sloppak", BASE_MANIFEST + "album: ''\nyear: 0\n")
    seed_match(server, "a.sloppak")
    got = {m["key"] for m in client.get("/api/song/a.sloppak/gap-fill").json()["missing"]}
    assert "album" not in got and "year" not in got
    assert {"genres", "mbid", "isrc"} <= got


def test_write_present_but_empty_key_is_refused_not_500(server, client):
    """The preview↔writer contract must agree: a POST for a present-but-empty
    key is turned away with a clean 409 (never offered → skipped), never a 500
    from the writer's never-clobber guard, and the file is left untouched."""
    d = make_dir_sloppak(server, "a.sloppak", BASE_MANIFEST + "album: ''\nyear: 0\n")
    seed_match(server, "a.sloppak")
    before = (d / "manifest.yaml").read_text(encoding="utf-8")
    r = client.post("/api/song/a.sloppak/gap-fill", json={"keys": ["album", "year"]})
    assert r.status_code == 409
    assert sorted(r.json()["skipped"]) == ["album", "year"]
    assert (d / "manifest.yaml").read_text(encoding="utf-8") == before
    assert not (d / "manifest.yaml.bak").exists()   # nothing written → no backup
    # A genuinely-absent key alongside the empty ones still writes cleanly.
    r = client.post("/api/song/a.sloppak/gap-fill", json={"keys": ["album", "genres"]})
    assert r.status_code == 200
    assert r.json() == {"ok": True, "written": {"genres": ["hard rock", "rock"]},
                        "skipped": ["album"]}


def test_preview_requires_confirmed_match(server, client):
    make_dir_sloppak(server, "a.sloppak")
    d = client.get("/api/song/a.sloppak/gap-fill").json()
    assert d["eligible"] is False and d["reason"] == "no-match"
    seed_match(server, "a.sloppak", state="review")
    d = client.get("/api/song/a.sloppak/gap-fill").json()
    assert d["eligible"] is False and d["reason"] == "review"
    # A user-pinned match is confirmed.
    seed_match(server, "a.sloppak", state="manual")
    assert client.get("/api/song/a.sloppak/gap-fill").json()["eligible"] is True


# ── writing ───────────────────────────────────────────────────────────────────

def test_write_dir_form_appends_and_preserves_author_bytes(server, client):
    d = make_dir_sloppak(server, "a.sloppak")
    seed_match(server, "a.sloppak")
    r = client.post("/api/song/a.sloppak/gap-fill",
                    json={"keys": ["album", "year", "genres", "mbid", "isrc"]})
    assert r.status_code == 200
    body = r.json()
    assert set(body["written"]) == {"album", "year", "genres", "mbid", "isrc"}
    text = (d / "manifest.yaml").read_text(encoding="utf-8")
    # The author's original bytes — comments included — survive verbatim as a
    # prefix; the additions are appended after them.
    assert text.startswith(BASE_MANIFEST)
    manifest = yaml.safe_load(text)
    assert manifest["album"] == "The Razors Edge"
    assert manifest["year"] == 1990
    assert manifest["genres"] == ["hard rock", "rock"]
    assert manifest["mbid"] == "12345678-abcd-4ef0-9876-0123456789ab"
    assert manifest["isrc"] == "AUAP09000045"
    # Backup + DB sync (the row must match what the scanner would derive).
    assert (d / "manifest.yaml.bak").read_text(encoding="utf-8") == BASE_MANIFEST
    row = client.get("/api/song/a.sloppak").json()
    assert row["album"] == "The Razors Edge"
    assert str(row["year"]) == "1990"


def test_write_zip_form_appends_with_backup(server, client):
    p = make_zip_sloppak(server, "a.sloppak")
    seed_match(server, "a.sloppak")
    r = client.post("/api/song/a.sloppak/gap-fill", json={"keys": ["album", "mbid"]})
    assert r.status_code == 200
    with zipfile.ZipFile(p) as z:
        text = z.read("manifest.yaml").decode("utf-8")
        assert text.startswith(BASE_MANIFEST)
        manifest = yaml.safe_load(text)
        assert manifest["album"] == "The Razors Edge"
        assert manifest["mbid"] == "12345678-abcd-4ef0-9876-0123456789ab"
        assert "year" not in manifest            # unrequested keys untouched
        assert z.read("stems/full.ogg") == b"OggS-fake"
    bak = p.with_name(p.name + ".bak")
    assert bak.exists()
    with zipfile.ZipFile(bak) as z:
        assert z.read("manifest.yaml").decode("utf-8") == BASE_MANIFEST


def test_write_never_replaces_author_values(server, client):
    d = make_dir_sloppak(server, "a.sloppak", BASE_MANIFEST + "album: Live Bootleg\n")
    seed_match(server, "a.sloppak")
    # Requesting a present key: skipped, not replaced; nothing else requested
    # → 409 and the file is untouched.
    r = client.post("/api/song/a.sloppak/gap-fill", json={"keys": ["album"]})
    assert r.status_code == 409
    assert r.json()["skipped"] == ["album"]
    assert (d / "manifest.yaml").read_text(encoding="utf-8").endswith("album: Live Bootleg\n")
    # Mixed request: the gap is written, the author value survives.
    r = client.post("/api/song/a.sloppak/gap-fill", json={"keys": ["album", "year"]})
    assert r.status_code == 200
    assert r.json() == {"ok": True, "written": {"year": 1990}, "skipped": ["album"]}
    manifest = yaml.safe_load((d / "manifest.yaml").read_text(encoding="utf-8"))
    assert manifest["album"] == "Live Bootleg"
    assert manifest["year"] == 1990


def test_writer_last_line_guard(server):
    """The lib-level never-clobber guard holds even if a caller skips the
    proposal check."""
    import songmeta
    d = make_dir_sloppak(server, "a.sloppak", BASE_MANIFEST + "album: Kept\n")
    with pytest.raises(ValueError):
        songmeta.gap_fill_sloppak(d, {"album": "Clobber"})
    assert "Kept" in (d / "manifest.yaml").read_text(encoding="utf-8")


def test_write_validates_keys(server, client):
    make_dir_sloppak(server, "a.sloppak")
    seed_match(server, "a.sloppak")
    assert client.post("/api/song/a.sloppak/gap-fill",
                       json={"keys": ["title"]}).status_code == 400
    assert client.post("/api/song/a.sloppak/gap-fill",
                       json={"keys": []}).status_code == 400
    assert client.post("/api/song/a.sloppak/gap-fill", json={}).status_code == 400


def test_demo_mode_blocks_write(tmp_path, monkeypatch, isolate_logging):
    """The middleware turns the write route away before any handler runs —
    demo visitors can never rewrite pack files."""
    monkeypatch.setenv("CONFIG_DIR", str(tmp_path / "config"))
    dlc = tmp_path / "dlc"
    dlc.mkdir()
    monkeypatch.setenv("DLC_DIR", str(dlc))
    monkeypatch.setenv("FEEDBACK_SKIP_STARTUP_TASKS", "1")
    monkeypatch.setenv("FEEDBACK_DEMO_MODE", "1")
    sys.modules.pop("server", None)
    srv = importlib.import_module("server")
    try:
        r = TestClient(srv.app).post("/api/song/a.sloppak/gap-fill",
                                     json={"keys": ["album"]})
        assert r.status_code == 403
    finally:
        conn = getattr(getattr(srv, "meta_db", None), "conn", None)
        if conn is not None:
            getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
            conn.close()
        sys.modules.pop("server", None)
