"""Server-level tests for the P8 MusicBrainz matcher + Match-Review flow.

The HTTP transport is a fake installed over `enrichment._mb_http_get` — the ONE
seam enrichment uses to reach the network — so nothing here ever opens a
socket. The offline default is itself under test: without explicitly
enabling the network flag, a pass must skip matching entirely (pytest can
never hit MusicBrainz, whatever a test triggers).
"""

import importlib
import enrichment
import sys

import pytest
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)


class FakeMB:
    """Canned MusicBrainz: records every call, serves per-path responses."""

    def __init__(self):
        self.calls = []
        self.search_response = {"recordings": []}
        self.recording_lookups = {}   # mbid → recording doc
        self.isrc_lookups = {}        # isrc → {"recordings": [...]}
        self.raise_transport = False

    def __call__(self, path, params):
        if self.raise_transport:
            raise enrichment.EnrichTransportError("fake network down")
        self.calls.append((path, dict(params)))
        if path == "recording":
            return self.search_response
        if path.startswith("recording/"):
            return self.recording_lookups.get(path.split("/", 1)[1])
        if path.startswith("isrc/"):
            return self.isrc_lookups.get(path.split("/", 1)[1])
        raise AssertionError(f"unexpected MB path {path!r}")

    @property
    def search_calls(self):
        return [c for c in self.calls if c[0] == "recording"]


@pytest.fixture()
def mb(server, monkeypatch):
    """Install the fake transport AND enable the network flag (the test env
    disables it by default — see test_offline_default_skips_matching)."""
    fake = FakeMB()
    fake._srv = server
    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"):
    server.meta_db.put(fn, 0, 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}],
    }


# ── strict-then-loose search fallback ────────────────────────────────────────

def test_search_falls_back_to_loose_when_strict_is_empty(server, monkeypatch):
    """The strict field-phrase query misses a non-Latin-primary artist; the
    loose retry (no field scoping) searches aliases and finds it."""
    calls = []

    def _routed(path, params):
        q = params.get("query", "")
        calls.append(q)
        if q.startswith("recording:"):        # strict phrase → nothing
            return {"recordings": []}
        return {"recordings": [mb_doc(rid="rec-x", title="Telephone Number")]}

    monkeypatch.setattr(enrichment, "_mb_http_get", _routed)
    cands = enrichment._mb_search_recordings("Junko Ohashi", "Telephone Number")
    assert len(cands) == 1
    assert len(calls) == 2                     # strict first, then the loose retry
    assert calls[0].startswith("recording:")   # strict is the field-phrase form
    assert "artist:" not in calls[1] and '"' not in calls[1]   # loose retry


def test_search_does_not_retry_when_strict_hits(server, monkeypatch):
    """A strict hit must not spend a second (throttled) request on the loose
    query."""
    calls = []

    def _routed(path, params):
        calls.append(params.get("query", ""))
        return {"recordings": [mb_doc()]}

    monkeypatch.setattr(enrichment, "_mb_http_get", _routed)
    cands = enrichment._mb_search_recordings("AC/DC", "Thunderstruck")
    assert len(cands) == 1
    assert len(calls) == 1


# ── alias-aware scoring (non-Latin-primary artists) ──────────────────────────

_AID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"


def test_artist_aliases_fetched_and_cached(server, monkeypatch):
    calls = []

    def fake(path, params):
        calls.append(path)
        return {"sort-name": "Ohashi, Junko",
                "aliases": [{"name": "Junko Ohashi"}, {"name": "大橋 純子"}]}

    monkeypatch.setattr(enrichment, "_mb_http_get", fake)
    names = enrichment._mb_artist_aliases(_AID)
    assert "Junko Ohashi" in names and "Ohashi, Junko" in names
    enrichment._mb_artist_aliases(_AID)          # cached → no second request
    assert len(calls) == 1


def test_artist_aliases_rejects_bad_id(server, monkeypatch):
    def boom(path, params):
        raise AssertionError("must not fetch for a non-UUID id")

    monkeypatch.setattr(enrichment, "_mb_http_get", boom)
    assert enrichment._mb_artist_aliases("not-a-uuid") == []


def test_enrich_auto_matches_japanese_primary_via_alias(server, monkeypatch):
    # A pack whose (romanized) artist MB stores under a Japanese primary name.
    _put(server, "x.sloppak", title="Telephone Number", artist="Junko Ohashi")

    def _routed(path, params):
        if path.startswith("artist/"):                      # alias lookup
            return {"sort-name": "Ohashi, Junko",
                    "aliases": [{"name": "Junko Ohashi"}]}
        q = params.get("query", "")
        if q.startswith("recording:"):                      # strict phrase → nothing
            return {"recordings": []}
        return {"recordings": [mb_doc(rid="rec-jp", title="Telephone Number",
                                      artist="大橋純子", artist_id=_AID)]}   # loose hit

    monkeypatch.setattr(enrichment, "_mb_http_get", _routed)
    monkeypatch.setattr(enrichment, "_enrich_network_enabled", lambda: True)
    enrichment._background_enrich()
    row = server.meta_db.get_enrichment("x.sloppak")
    # The romanized alias lifts the artist over the auto floor → auto-confirmed.
    assert row["match_state"] == "matched"
    assert row["mb_recording_id"] == "rec-jp"


# ── per-song field locks respected by the auto-matcher ───────────────────────

def test_locked_field_not_canonicalized_by_auto_match(server, monkeypatch):
    _put(server, "x.sloppak")                       # title "Thunderstruck (v2)", artist "ACDC"
    server.meta_db.set_song_override("x.sloppak", "artist", locked=True)
    monkeypatch.setattr(enrichment, "_mb_http_get",
                        lambda path, params: {"recordings": [mb_doc()]})
    monkeypatch.setattr(enrichment, "_enrich_network_enabled", lambda: True)
    enrichment._background_enrich()
    row = server.meta_db.get_enrichment("x.sloppak")
    assert row["match_state"] == "matched"          # still matches (identity applies)…
    assert row["canon_artist"] is None              # …but the LOCKED artist isn't canonicalized
    assert row["canon_title"] == "Thunderstruck"    # unlocked display fields still apply
    assert row["mb_recording_id"]                   # identity keys still stored (art needs them)


# ── offline safety (the pytest-never-hits-network contract) ──────────────────

def test_offline_default_skips_matching(server, monkeypatch):
    """Under the test env (FEEDBACK_SKIP_STARTUP_TASKS) a pass stamps hashes
    but never matches — even with a transport installed."""
    fake = FakeMB()
    fake._srv = server
    monkeypatch.setattr(enrichment, "_mb_http_get", fake)
    _put(server, "a.sloppak")
    enrichment._background_enrich()
    assert fake.calls == []
    assert server.meta_db.get_enrichment("a.sloppak")["match_state"] == "unscanned"


def test_real_transport_refuses_when_offline(server):
    """_mb_http_get itself raises (before any socket) when the network is
    disabled — defence in depth under pytest."""
    with pytest.raises(enrichment.EnrichTransportError):
        enrichment._mb_http_get("recording", {"query": "x"})


def test_transport_error_pauses_pass_without_burning_attempts(server, mb):
    _put(server, "a.sloppak")
    _put(server, "b.sloppak", title="Other Song")
    mb.raise_transport = True
    enrichment._background_enrich()
    for fn in ("a.sloppak", "b.sloppak"):
        row = server.meta_db.get_enrichment(fn)
        assert row["match_state"] == "unscanned"
        assert row["attempts"] == 0
    # Network comes back → the next kick matches both.
    mb.raise_transport = False
    mb.search_response = {"recordings": [mb_doc()]}
    enrichment._background_enrich()
    assert server.meta_db.get_enrichment("a.sloppak")["match_state"] == "matched"


# ── text tiers ────────────────────────────────────────────────────────────────

def test_high_confidence_auto_matches_and_settles(server, mb):
    _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["match_source"] == "text"
    assert row["match_score"] >= 0.95
    assert row["mb_recording_id"] == "rec-1"
    assert row["canon_artist"] == "AC/DC"
    assert row["canon_title"] == "Thunderstruck"
    assert row["canon_album"] == "The Razors Edge"
    assert row["canon_year"] == "1990"
    assert row["genres"] == ["hard rock"]
    # Settled: another pass makes NO further network calls…
    n = len(mb.calls)
    enrichment._background_enrich()
    assert len(mb.calls) == n
    # …until the identity changes, which re-matches.
    _put(server, "a.sloppak", title="Back in Black")
    mb.search_response = {"recordings": [mb_doc(rid="rec-2", title="Back in Black",
                                                album="Back in Black", date="1980-07-25")]}
    enrichment._background_enrich()
    row = server.meta_db.get_enrichment("a.sloppak")
    assert row["mb_recording_id"] == "rec-2"


def test_medium_confidence_goes_to_review_not_canonical(server, mb):
    # Partial artist agreement → medium confidence.
    _put(server, "a.sloppak", artist="AC/DC ft Nobody")
    mb.search_response = {"recordings": [mb_doc()]}
    enrichment._background_enrich()
    row = server.meta_db.get_enrichment("a.sloppak")
    assert row["match_state"] == "review"
    assert row["match_source"] == "text"
    # Review before auto-canonicalize: NO canonical values written yet.
    assert row["canon_artist"] is None
    assert row["mb_recording_id"] is None
    assert row["candidates"] and row["candidates"][0]["recording_id"] == "rec-1"
    # A review row is settled while its identity is unchanged — no re-query.
    n = len(mb.calls)
    enrichment._background_enrich()
    assert len(mb.calls) == n


def test_low_confidence_fails_with_backoff(server, mb):
    _put(server, "a.sloppak")
    mb.search_response = {"recordings": [mb_doc(rid="rec-x", title="Sunrise",
                                                artist="Norah Jones")]}
    enrichment._background_enrich()
    row = server.meta_db.get_enrichment("a.sloppak")
    assert row["match_state"] == "failed"
    assert row["attempts"] == 1
    assert row["last_attempt_at"] is not None
    # Immediately after, the backoff hasn't elapsed → no retry, no network.
    n = len(mb.calls)
    enrichment._background_enrich()
    assert len(mb.calls) == n
    assert server.meta_db.get_enrichment("a.sloppak")["attempts"] == 1
    # Rewind the clock two hours → eligible again, attempts increments.
    with server.meta_db._lock:
        server.meta_db.conn.execute(
            "UPDATE song_enrichment SET last_attempt_at = last_attempt_at - 7200")
        server.meta_db.conn.commit()
    enrichment._background_enrich()
    assert len(mb.calls) == n + 1
    assert server.meta_db.get_enrichment("a.sloppak")["attempts"] == 2


def test_no_results_fails(server, mb):
    _put(server, "a.sloppak")
    mb.search_response = {"recordings": []}
    enrichment._background_enrich()
    assert server.meta_db.get_enrichment("a.sloppak")["match_state"] == "failed"


# ── the content-hash match cache ──────────────────────────────────────────────

def test_cache_hit_copies_match_without_network(server, mb):
    _put(server, "a.sloppak")
    _put(server, "b.sloppak")   # identical identity → same content_hash
    mb.search_response = {"recordings": [mb_doc()]}
    enrichment._background_enrich()
    assert len(mb.search_calls) == 1          # ONE search covered both charts
    a = server.meta_db.get_enrichment("a.sloppak")
    b = server.meta_db.get_enrichment("b.sloppak")
    assert a["match_state"] == b["match_state"] == "matched"
    assert {a["match_source"], b["match_source"]} == {"text", "cache"}
    assert a["mb_recording_id"] == b["mb_recording_id"] == "rec-1"


# ── exact keys from the manifest (tier 0 / tier 1) ────────────────────────────

def _write_sloppak_manifest(server, name, extra_yaml=""):
    d = server.DLC_DIR / name
    d.mkdir(parents=True)
    (d / "manifest.yaml").write_text(
        "title: Thunderstruck\nartist: AC/DC\nduration: 292\n"
        "arrangements: []\nstems: []\n" + extra_yaml,
        encoding="utf-8")


def test_manifest_mbid_tier0(server, mb):
    mbid = "12345678-abcd-4ef0-9876-0123456789ab"
    _write_sloppak_manifest(server, "a.sloppak", f"mbid: {mbid}\n")
    _put(server, "a.sloppak")
    mb.recording_lookups[mbid] = mb_doc(rid=mbid)
    enrichment._background_enrich()
    row = server.meta_db.get_enrichment("a.sloppak")
    assert row["match_state"] == "matched"
    assert row["match_source"] == "mbid"
    assert row["match_score"] == 1.0
    assert row["mb_recording_id"] == mbid
    assert mb.search_calls == []              # trusted key — no text search


def test_manifest_isrc_tier1(server, mb):
    _write_sloppak_manifest(server, "a.sloppak", "isrc: AUAP09000045\n")
    _put(server, "a.sloppak")
    mb.isrc_lookups["AUAP09000045"] = {"recordings": [mb_doc()]}
    enrichment._background_enrich()
    row = server.meta_db.get_enrichment("a.sloppak")
    assert row["match_state"] == "matched"
    assert row["match_source"] == "isrc"
    assert mb.search_calls == []


def test_manifest_isrc_display_hyphens_stripped(server, mb):
    """Spec 1.14.0: the hyphenated display form (AU-AP0-90-00045) is
    presentation only — the reader strips separators, so a hand-authored
    manifest still hits the exact tier with the bare 12-char code."""
    _write_sloppak_manifest(server, "a.sloppak", "isrc: AU-AP0-90-00045\n")
    _put(server, "a.sloppak")
    mb.isrc_lookups["AUAP09000045"] = {"recordings": [mb_doc()]}
    enrichment._background_enrich()
    row = server.meta_db.get_enrichment("a.sloppak")
    assert row["match_state"] == "matched"
    assert row["match_source"] == "isrc"
    assert mb.search_calls == []


def test_bad_manifest_mbid_falls_through_to_text(server, mb):
    mbid = "12345678-abcd-4ef0-9876-0123456789ab"
    _write_sloppak_manifest(server, "a.sloppak", f"mbid: {mbid}\n")
    _put(server, "a.sloppak")
    mb.recording_lookups.clear()              # lookup 404s (typo'd manifest)
    mb.search_response = {"recordings": [mb_doc()]}
    enrichment._background_enrich()
    row = server.meta_db.get_enrichment("a.sloppak")
    assert row["match_state"] == "matched"
    assert row["match_source"] == "text"


# ── manual is sacred ──────────────────────────────────────────────────────────

def test_manual_never_overwritten_by_matcher(server, mb):
    _put(server, "a.sloppak")
    server.meta_db.set_enrichment_manual(
        "a.sloppak", {"recording_id": "user-pick", "title": "Thunderstruck",
                      "artist": "AC/DC"}, source="search")
    mb.search_response = {"recordings": [mb_doc(rid="machine-pick")]}
    enrichment._background_enrich()
    row = server.meta_db.get_enrichment("a.sloppak")
    assert row["match_state"] == "manual"
    assert row["mb_recording_id"] == "user-pick"
    # The writer refuses machine writes onto manual outright.
    ok = server.meta_db.apply_enrichment_match(
        "a.sloppak", row["content_hash"], "matched", source="text", score=1.0,
        cand={"recording_id": "machine-pick", "title": "X"})
    assert ok is False


# ── review routes ─────────────────────────────────────────────────────────────

def _seed_review(server, mb, fn="a.sloppak", title="Thunderstruck (v2)"):
    # Distinct raw titles give distinct content hashes (else the match cache
    # legitimately copies an earlier row instead of running the text tiers).
    _put(server, fn, title=title, artist="AC/DC ft Nobody")
    mb.search_response = {"recordings": [mb_doc()]}
    enrichment._background_enrich()
    assert server.meta_db.get_enrichment(fn)["match_state"] == "review"


def test_review_queue_route(server, mb, client):
    _seed_review(server, mb)
    body = client.get("/api/enrichment/review").json()
    assert body["total_review"] == 1
    assert body["songs"][0]["filename"] == "a.sloppak"
    assert body["songs"][0]["candidates"][0]["recording_id"] == "rec-1"


def test_review_accept_route(server, mb, client):
    _seed_review(server, mb)
    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["match_source"] == "review"
    assert row["canon_artist"] == "AC/DC"
    assert client.get("/api/enrichment/review").json()["total_review"] == 0
    # Accepting a candidate that isn't in the stored list → 404.
    _seed_review(server, mb, fn="b.sloppak", title="Thunderstruck (Live)")
    r = client.post("/api/enrichment/review/b.sloppak/accept",
                    json={"recording_id": "nope"})
    assert r.status_code == 404


def test_review_reject_route_never_retries(server, mb, client):
    _seed_review(server, mb)
    r = client.post("/api/enrichment/review/a.sloppak/reject")
    assert r.status_code == 200
    row = server.meta_db.get_enrichment("a.sloppak")
    assert row["match_state"] == "failed"
    assert row["match_source"] == "rejected"
    # Rejected rows are excluded from the retry backoff forever…
    n = len(mb.calls)
    enrichment._background_enrich()
    assert len(mb.calls) == n
    # …but an identity edit re-queues (the user fixed the metadata).
    _put(server, "a.sloppak", artist="AC/DC")
    enrichment._background_enrich()
    assert server.meta_db.get_enrichment("a.sloppak")["match_state"] == "matched"
    # Rejecting a manual row is refused.
    r = client.post("/api/enrichment/review/a.sloppak/reject")
    assert r.status_code == 200               # matched → rejectable
    client.post("/api/enrichment/review/a.sloppak/pick",
                json={"candidate": {"recording_id": "rec-9", "title": "T"}})
    r = client.post("/api/enrichment/review/a.sloppak/reject")
    assert r.status_code == 404


def test_pick_route_fix_match(server, mb, client):
    _put(server, "a.sloppak")
    r = client.post("/api/enrichment/review/a.sloppak/pick", json={"candidate": {
        "recording_id": "rec-77", "title": "Thunderstruck", "artist": "AC/DC",
        "album": "The Razors Edge", "year": "1990", "genres": ["hard rock"],
        "junk_key": "dropped"}})
    assert r.status_code == 200
    row = server.meta_db.get_enrichment("a.sloppak")
    assert row["match_state"] == "manual"
    assert row["match_source"] == "search"
    assert row["mb_recording_id"] == "rec-77"
    # Malformed candidate → 400; unknown song → 404.
    assert client.post("/api/enrichment/review/a.sloppak/pick",
                       json={"candidate": {"title": "no id"}}).status_code == 400
    assert client.post("/api/enrichment/review/ghost.sloppak/pick",
                       json={"candidate": {"recording_id": "x", "title": "t"}}
                       ).status_code == 404


def test_search_proxy(server, mb, client, monkeypatch):
    _put(server, "a.sloppak")
    mb.search_response = {"recordings": [mb_doc()]}
    assert client.get("/api/enrichment/search").status_code == 400
    body = client.get("/api/enrichment/search",
                      params={"title": "Thunderstruck", "artist": "AC/DC",
                              "filename": "a.sloppak"}).json()
    assert body["candidates"][0]["recording_id"] == "rec-1"
    assert body["candidates"][0]["score"] > 0.9
    # Transport failure surfaces as 503, not a 500.
    def _down(path, params):
        raise enrichment.EnrichTransportError("down")
    monkeypatch.setattr(enrichment, "_mb_http_get", _down)
    r = client.get("/api/enrichment/search", params={"title": "x"})
    assert r.status_code == 503


# ── the match facet on /api/library + /api/library/stats ─────────────────────

def test_match_facet_filters_grid_and_stats(server, mb, client, monkeypatch):
    _put(server, "auto.sloppak")                                  # → matched
    _put(server, "rev.sloppak", title="Revsong", artist="AC/DC ft Nobody")   # → review
    _put(server, "fail.sloppak", title="Failsong", artist="Zzz")  # → failed
    _put(server, "pend.sloppak", title="Pendsong", artist="Yyy")  # stays unscanned

    def _routed(path, params):
        q = params.get("query", "")
        if "thunderstruck" in q:
            return {"recordings": [mb_doc()]}
        if "revsong" in q:
            return {"recordings": [mb_doc(rid="rec-r", title="Revsong")]}
        return {"recordings": []}
    monkeypatch.setattr(enrichment, "_mb_http_get", _routed)
    enrichment._background_enrich()
    # Pendsong got failed by the pass (no results); reset it to unscanned to
    # represent the not-yet-scanned band.
    with server.meta_db._lock:
        server.meta_db.conn.execute(
            "UPDATE song_enrichment SET match_state='unscanned', attempts=0, "
            "last_attempt_at=NULL WHERE filename='pend.sloppak'")
        server.meta_db.conn.commit()

    def names(match):
        return sorted(s["filename"] for s in client.get(
            "/api/library", params={"match": match, "size": 50}).json()["songs"])

    assert names("review") == ["rev.sloppak"]
    assert names("matched") == ["auto.sloppak"]
    assert names("unmatched") == ["fail.sloppak"]
    assert names("pending") == ["pend.sloppak"]
    assert names("review,matched") == ["auto.sloppak", "rev.sloppak"]
    # Stats agree with the grid (the rail's lockstep contract).
    total = client.get("/api/library/stats",
                       params={"match": "review"}).json()["total_songs"]
    assert total == 1
    # Unknown values are ignored → unfiltered.
    assert len(names("bogus")) == 4


def test_status_counts_by_state(server, mb, client):
    _seed_review(server, mb)
    states = client.get("/api/enrichment/status").json()["states"]
    assert states.get("review") == 1


# ── settings: enable toggle + auto-apply confidence ───────────────────────────

def test_auto_threshold_setting_moves_the_auto_review_boundary(server, mb, client):
    # artist exact (1.0) + title 4/5 token overlap (0.8) and NO year/duration
    # corroboration → combined exactly 0.90.
    mb.search_response = {"recordings": [
        mb_doc(title="Highway Hell", artist="AC/DC", date="", length_ms=None)]}
    client.post("/api/settings", json={"enrich_auto_threshold": 0.95})
    _put(server, "a.sloppak", title="Highway to Hell", artist="AC/DC",
         year="", duration=0)
    enrichment._background_enrich()
    assert server.meta_db.get_enrichment("a.sloppak")["match_state"] == "review"
    # Lower the bar to the default 0.90 → an identity edit re-queues, and the
    # same 0.90-scored candidate now auto-applies.
    client.post("/api/settings", json={"enrich_auto_threshold": 0.9})
    _put(server, "a.sloppak", title="Highway to Hell", artist="AC/DC",
         year="", duration=0, album="Different Album")
    enrichment._background_enrich()
    row = server.meta_db.get_enrichment("a.sloppak")
    assert row["match_state"] == "matched"
    assert abs(row["match_score"] - 0.9) < 1e-6


def test_enrich_enabled_setting_gates_background_matching(server, mb, client):
    client.post("/api/settings", json={"enrich_enabled": False})
    _put(server, "a.sloppak")
    mb.search_response = {"recordings": [mb_doc()]}
    enrichment._background_enrich()
    assert mb.calls == []
    assert server.meta_db.get_enrichment("a.sloppak")["match_state"] == "unscanned"
    # Manual search/fix stays available while the background matcher is off.
    r = client.get("/api/enrichment/search", params={"title": "Thunderstruck"})
    assert r.status_code == 200
    # Re-enable → the next pass matches.
    client.post("/api/settings", json={"enrich_enabled": True})
    enrichment._background_enrich()
    assert server.meta_db.get_enrichment("a.sloppak")["match_state"] == "matched"


def test_settings_validation(server, client):
    assert "error" in client.post(
        "/api/settings", json={"enrich_enabled": "yes"}).json()
    assert "error" in client.post(
        "/api/settings", json={"enrich_auto_threshold": "high"}).json()
    assert "error" in client.post(
        "/api/settings", json={"enrich_auto_threshold": 2.5}).json()
    ok = client.post("/api/settings", json={"enrich_auto_threshold": 1.01}).json()
    assert "error" not in ok
    assert client.get("/api/settings").json()["enrich_auto_threshold"] == 1.01


def test_kick_route(server, mb, client):
    import time as _t
    body = client.post("/api/enrichment/kick").json()
    assert "started" in body
    # Let the kicked pass settle so its daemon thread can't bleed into the
    # fixture teardown (the DB connection closes there).
    for _ in range(200):
        if not client.get("/api/enrichment/status").json()["running"]:
            break
        _t.sleep(0.02)


def test_review_queue_orders_missing_data_first(server, mb, client):
    # Complete chart first alphabetically, incomplete second — the queue must
    # surface the incomplete (missing album + year) one first anyway.
    _seed_review(server, mb, fn="aa.sloppak", title="Thunderstruck (v2)")
    _put(server, "zz.sloppak", title="Thunderstruck (Live)",
         artist="AC/DC ft Nobody", album="", year="")
    enrichment._background_enrich()
    assert server.meta_db.get_enrichment("zz.sloppak")["match_state"] == "review"
    songs = client.get("/api/enrichment/review").json()["songs"]
    assert [s["filename"] for s in songs] == ["zz.sloppak", "aa.sloppak"]
