"""Tests for the R1 scraper options: per-source toggles (MusicBrainz /
Cover Art Archive), per-field auto-apply toggles (names / year / genres /
cover art), and the review-queue order preference.

Same no-network contract as the P8/R3 suites: both transports are faked
over their single seams (`_mb_http_get`, `_caa_http_get`) and the network
flag is only force-enabled where a test needs the pipeline to run.
"""

import importlib
import enrichment
import io as _io
import sys

import pytest
from fastapi.testclient import TestClient
from PIL import Image


@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)


class FakeMB:
    """Canned MusicBrainz over the `_mb_http_get` seam (the P8 fixture)."""

    def __init__(self):
        self.calls = []
        self.search_response = {"recordings": []}

    def __call__(self, path, params):
        self.calls.append((path, dict(params)))
        if path == "recording":
            return self.search_response
        raise AssertionError(f"unexpected MB path {path!r}")


@pytest.fixture()
def mb(server, monkeypatch):
    fake = FakeMB()
    monkeypatch.setattr(enrichment, "_mb_http_get", fake)
    monkeypatch.setattr(enrichment, "_enrich_network_enabled", lambda: True)
    return fake


def _put(server, fn, title="Thunderstruck (v2)", artist="ACDC", album="",
         duration=292, year="1990", mtime=0):
    server.meta_db.put(fn, mtime, 0, {
        "title": title, "artist": artist, "album": album, "year": year,
        "duration": duration, "arrangements": [{"name": "Lead", "index": 0}],
    })


def mb_doc(rid="rec-1", title="Thunderstruck", artist="AC/DC", artist_id="art-1",
           album="The Razors Edge", date="1990-09-24", length_ms=292000, score=100):
    return {
        "id": rid, "score": score, "title": title, "length": length_ms,
        "isrcs": ["AUAP09000045"],
        "artist-credit": [{"name": artist, "artist": {
            "id": artist_id, "name": artist, "sort-name": artist}}],
        "releases": [{"id": "rel-1", "title": album, "status": "Official",
                      "date": date, "release-group": {"primary-type": "Album"}}],
        "tags": [{"name": "hard rock", "count": 7}],
    }


# ── settings keys ─────────────────────────────────────────────────────────────

BOOL_KEYS = ("enrich_src_musicbrainz", "enrich_src_caa", "enrich_apply_names",
             "enrich_apply_year", "enrich_apply_genres", "enrich_apply_art")


def test_scraper_option_keys_validate_and_persist(client):
    for key in BOOL_KEYS:
        bad = client.post("/api/settings", json={key: "yes"}).json()
        assert "error" in bad
        client.post("/api/settings", json={key: False})
        assert client.get("/api/settings").json()[key] is False
    assert "error" in client.post(
        "/api/settings", json={"enrich_review_order": "random"}).json()
    assert "error" in client.post(
        "/api/settings", json={"enrich_review_order": 42}).json()
    client.post("/api/settings", json={"enrich_review_order": "artist"})
    assert client.get("/api/settings").json()["enrich_review_order"] == "artist"


def test_defaults_present(server):
    d = server._default_settings()
    for key in BOOL_KEYS:
        assert d[key] is True
    assert d["enrich_review_order"] == "missing_first"


# ── per-source: MusicBrainz ───────────────────────────────────────────────────

def test_musicbrainz_source_off_stamps_without_matching(server, mb, client):
    client.post("/api/settings", json={"enrich_src_musicbrainz": False})
    _put(server, "a.sloppak")
    mb.search_response = {"recordings": [mb_doc()]}
    enrichment._background_enrich()
    assert mb.calls == []
    row = server.meta_db.get_enrichment("a.sloppak")
    assert row["match_state"] == "unscanned"       # hash stamped, no match
    # Re-enabling picks the same row up on the next pass.
    client.post("/api/settings", json={"enrich_src_musicbrainz": True})
    enrichment._background_enrich()
    assert server.meta_db.get_enrichment("a.sloppak")["match_state"] == "matched"


# ── per-field auto-apply ──────────────────────────────────────────────────────

def test_field_toggles_strip_auto_applied_fields(server, mb, client):
    client.post("/api/settings", json={"enrich_apply_year": False,
                                       "enrich_apply_genres": False})
    _put(server, "a.sloppak")
    mb.search_response = {"recordings": [mb_doc()]}
    enrichment._background_enrich()
    row = server.meta_db.get_enrichment("a.sloppak")
    assert row["match_state"] == "matched"
    assert row["canon_artist"] == "AC/DC"
    assert row["canon_title"] == "Thunderstruck"
    assert row["canon_album"] == "The Razors Edge"
    assert row["canon_year"] is None               # toggled off
    assert row["genres"] == []                     # toggled off
    # Identity ids always stamp — the art fetch and re-matching need them.
    assert row["mb_recording_id"] == "rec-1"
    assert row["mb_release_id"] == "rel-1"


def test_names_toggle_keeps_ids_and_other_fields(server, mb, client):
    client.post("/api/settings", json={"enrich_apply_names": False})
    _put(server, "a.sloppak")
    mb.search_response = {"recordings": [mb_doc()]}
    enrichment._background_enrich()
    row = server.meta_db.get_enrichment("a.sloppak")
    assert row["match_state"] == "matched"
    assert row["canon_artist"] is None
    assert row["canon_title"] is None
    assert row["canon_album"] is None
    assert row["canon_year"] == "1990"
    assert row["genres"] == ["hard rock"]
    assert row["mb_recording_id"] == "rec-1"


def test_review_accept_applies_all_fields_despite_toggles(server, mb, client):
    """A match the USER confirms is their intent — the auto-apply toggles
    gate only what happens without them."""
    client.post("/api/settings", json={"enrich_apply_names": False,
                                       "enrich_apply_year": False,
                                       "enrich_apply_genres": False})
    # Partial artist agreement → review tier (candidates stored unfiltered).
    _put(server, "a.sloppak", artist="AC/DC ft Nobody")
    mb.search_response = {"recordings": [mb_doc()]}
    enrichment._background_enrich()
    assert server.meta_db.get_enrichment("a.sloppak")["match_state"] == "review"
    r = client.post("/api/enrichment/review/a.sloppak/accept",
                    json={"recording_id": "rec-1"})
    assert r.status_code == 200
    row = server.meta_db.get_enrichment("a.sloppak")
    assert row["match_state"] == "manual"
    assert row["canon_artist"] == "AC/DC"
    assert row["canon_year"] == "1990"
    assert row["genres"] == ["hard rock"]


# ── per-field: nothing-forfeited (re-enable backfills, no partial seeding) ─────

def test_reenabling_field_backfills_matched_row(server, mb, client):
    """A field toggled OFF strips the value AND records it in apply_mask, so
    turning the field back on re-queues the (unchanged-hash) matched row and
    backfills — the same "nothing forfeited" contract the source/art toggles
    keep."""
    client.post("/api/settings", json={"enrich_apply_year": False})
    _put(server, "a.sloppak")
    mb.search_response = {"recordings": [mb_doc()]}
    enrichment._background_enrich()
    row = server.meta_db.get_enrichment("a.sloppak")
    assert row["match_state"] == "matched"
    assert row["canon_year"] is None                    # suppressed
    assert row["apply_mask"] == "enrich_apply_year"     # …and remembered
    # Re-enable → next pass re-queues and backfills the year (hash unchanged).
    client.post("/api/settings", json={"enrich_apply_year": True})
    enrichment._background_enrich()
    row = server.meta_db.get_enrichment("a.sloppak")
    assert row["match_state"] == "matched"
    assert row["canon_year"] == "1990"                  # backfilled
    assert row["apply_mask"] in (None, "")              # fully applied now
    # Converged: a fully-applied row is not re-queued again.
    assert server.meta_db.enrichment_pending(
        allowed_keys=frozenset(enrichment._ENRICH_APPLY_FIELDS)) == []


def test_partial_match_is_not_a_cache_donor(server):
    """A row that suppressed a display field must not seed a sibling chart of
    the same recording with its blank — enrichment_cache_lookup skips it, so
    the sibling falls through to its own (correctly-filtered) match. A fully-
    applied row IS a donor."""
    h = server.meta_db.enrichment_content_hash("ACDC", "Thunderstruck (v2)", "", 292)
    # Partial donor: matched with the year suppressed (apply_mask set).
    server.meta_db.apply_enrichment_match(
        "a.sloppak", h, "matched", source="text", score=1.0,
        cand={"recording_id": "rec-1", "artist": "AC/DC", "title": "Thunderstruck"},
        apply_mask="enrich_apply_year")
    assert server.meta_db.enrichment_cache_lookup(
        h, exclude_filename="b.sloppak") is None
    # Fully-applied donor (no apply_mask): offered, with its year intact.
    server.meta_db.apply_enrichment_match(
        "c.sloppak", h, "matched", source="text", score=1.0,
        cand={"recording_id": "rec-1", "artist": "AC/DC",
              "title": "Thunderstruck", "year": "1990"})
    donor = server.meta_db.enrichment_cache_lookup(h, exclude_filename="b.sloppak")
    assert donor is not None and donor["year"] == "1990"


# ── per-source / per-field: cover art ─────────────────────────────────────────

def png_bytes(color=(200, 30, 30)):
    buf = _io.BytesIO()
    Image.new("RGB", (4, 4), color).save(buf, "PNG")
    return buf.getvalue()


def make_sloppak(server, name, title="Song", artist="Artist"):
    d = server.DLC_DIR / name
    d.mkdir(parents=True)
    (d / "manifest.yaml").write_text(
        f"title: {title}\nartist: {artist}\nduration: 100\n"
        "arrangements: []\nstems: []\n", encoding="utf-8")
    server.meta_db.put(name, 0, 0, {
        "title": title, "artist": artist, "album": "", "year": "",
        "duration": 100, "arrangements": [{"name": "Lead", "index": 0}],
    })
    return d


def _match_row(server, fn, release_id="rel-1"):
    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, "matched", source="text", score=1.0,
        cand={"recording_id": "rec-1", "release_id": release_id,
              "title": song["title"], "artist": song["artist"]})


@pytest.fixture()
def caa(server, monkeypatch):
    calls = []
    art = {"rel-1": png_bytes((60, 60, 200))}

    def fake(release_id):
        calls.append(release_id)
        return art.get(release_id)
    fake.calls = calls
    monkeypatch.setattr(enrichment, "_caa_http_get", fake)
    monkeypatch.setattr(enrichment, "_enrich_network_enabled", lambda: True)
    return fake


def test_caa_source_toggle_gates_art_fetch(server, client, caa):
    make_sloppak(server, "a.sloppak")
    _match_row(server, "a.sloppak")
    client.post("/api/settings", json={"enrich_src_caa": False})
    enrichment._background_enrich()
    assert caa.calls == []
    row = server.meta_db.get_enrichment("a.sloppak")
    assert row["art_state"] is None                # not forfeited, just skipped
    # Re-enable → the same row is picked up.
    client.post("/api/settings", json={"enrich_src_caa": True})
    enrichment._background_enrich()
    assert server.meta_db.get_enrichment("a.sloppak")["art_state"] == "caa"


def test_apply_art_toggle_gates_art_fetch(server, client, caa):
    make_sloppak(server, "a.sloppak")
    _match_row(server, "a.sloppak")
    client.post("/api/settings", json={"enrich_apply_art": False})
    enrichment._background_enrich()
    assert caa.calls == []
    assert server.meta_db.get_enrichment("a.sloppak")["art_state"] is None


# ── review-queue order ────────────────────────────────────────────────────────

def _review_row(server, fn):
    server.meta_db.apply_enrichment_match(
        fn, "h", "review", source="text", score=0.7,
        candidates=[{"recording_id": "rec-1", "title": "T", "artist": "A"}])


def _review_filenames(client):
    return [s["filename"] for s in
            client.get("/api/enrichment/review").json()["songs"]]


def test_review_queue_order_setting(server, client):
    # a: complete, artist Alpha, oldest; b: missing album+year, artist Zeta,
    # middle; c: missing year, artist Mid, newest — the three orders differ.
    _put(server, "a.sloppak", title="Song A", artist="Alpha",
         album="Full", year="1990", mtime=100)
    _put(server, "b.sloppak", title="Song B", artist="Zeta",
         album="", year="", mtime=200)
    _put(server, "c.sloppak", title="Song C", artist="Mid",
         album="Full", year="", mtime=300)
    for fn in ("a.sloppak", "b.sloppak", "c.sloppak"):
        _review_row(server, fn)

    # Default: missing-data-first.
    assert _review_filenames(client) == ["b.sloppak", "c.sloppak", "a.sloppak"]
    client.post("/api/settings", json={"enrich_review_order": "artist"})
    assert _review_filenames(client) == ["a.sloppak", "c.sloppak", "b.sloppak"]
    client.post("/api/settings", json={"enrich_review_order": "recent"})
    assert _review_filenames(client) == ["c.sloppak", "b.sloppak", "a.sloppak"]
    # An unknown stored value degrades to the default order, never an error.
    assert server.meta_db.enrichment_review_queue(order="bogus")[0]["filename"] == "b.sloppak"
