"""Tests for smart/dynamic collections (got-feedback/feedBack#636 item 2).

A collection is a saved set of library filter rules, surfaced as a registered
library provider so it inherits the v3 Songs UI. Storage reuses the playlists
table (a `rules` JSON blob → smart collection); membership is the LIVE filter
result, not stored songs.
"""

import importlib
import sys

import pytest
from fastapi.testclient import TestClient


@pytest.fixture()
def server_mod(tmp_path, monkeypatch):
    monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
    sys.modules.pop("server", None)
    mod = importlib.import_module("server")
    yield mod
    conn = getattr(getattr(mod, "meta_db", None), "conn", None)
    if conn is not None:
        conn.close()


@pytest.fixture()
def client(server_mod):
    c = TestClient(server_mod.app)
    try:
        yield c
    finally:
        c.close()


def _put(server_mod, *, filename, title, artist, tuning_name="E Standard", tuning_sort_key=0):
    server_mod.meta_db.put(filename, 1.0, 1, {
        "title": title, "artist": artist, "album": "LP", "year": "", "duration": 1.0,
        "tuning": tuning_name, "arrangements": [], "has_lyrics": False, "format": "archive",
        "stem_count": 0, "stem_ids": [], "tuning_name": tuning_name,
        "tuning_sort_key": tuning_sort_key, "tuning_offsets": "",
    })


def _seed_mixed(server_mod):
    _put(server_mod, filename="d1.archive", title="Drop One", artist="Anna", tuning_name="Drop D", tuning_sort_key=-2)
    _put(server_mod, filename="d2.archive", title="Drop Two", artist="Bea", tuning_name="Drop D", tuning_sort_key=-2)
    _put(server_mod, filename="e1.archive", title="Std One", artist="Cy", tuning_name="E Standard")


# ── CRUD ────────────────────────────────────────────────────────────────────

def test_create_list_delete_collection(client):
    assert client.get("/api/collections").json() == {"collections": []}

    r = client.post("/api/collections", json={"name": "Drop D stuff", "rules": {"tunings": ["Drop D"]}})
    assert r.status_code == 200
    col = r.json()["collection"]
    assert col["name"] == "Drop D stuff"
    assert col["rules"] == {"tunings": "Drop D"}        # raw query-param format
    cid = col["id"]

    listed = client.get("/api/collections").json()["collections"]
    assert [c["name"] for c in listed] == ["Drop D stuff"]

    assert client.request("DELETE", f"/api/collections/{cid}").json() == {"ok": True}
    assert client.get("/api/collections").json() == {"collections": []}


def test_create_requires_name_and_sanitizes_rules(client):
    assert client.post("/api/collections", json={"rules": {}}).status_code == 400
    # Unknown rule keys are dropped (never 500); known ones normalized to the
    # raw query-param format (list→CSV, favorites→1).
    col = client.post("/api/collections", json={
        "name": "Mix", "rules": {"tunings": ["Drop D", "Eb Standard"], "sort": "title", "bogus": "x", "favorites": True},
    }).json()["collection"]
    assert col["rules"] == {"tunings": "Drop D,Eb Standard", "sort": "title", "favorites": 1}


def test_update_collection(client):
    cid = client.post("/api/collections", json={"name": "A", "rules": {"tunings": ["Drop D"]}}).json()["collection"]["id"]
    r = client.put(f"/api/collections/{cid}", json={"name": "B", "rules": {"format": "sloppak"}})
    assert r.status_code == 200
    assert r.json()["collection"]["name"] == "B"
    assert r.json()["collection"]["rules"] == {"format": "sloppak"}
    assert client.put("/api/collections/99999", json={"name": "x"}).status_code == 404


# ── Provider behaviour ──────────────────────────────────────────────────────

def test_collection_registers_as_a_provider(client, server_mod):
    _seed_mixed(server_mod)
    cid = client.post("/api/collections", json={"name": "DropD", "rules": {"tunings": ["Drop D"]}}).json()["collection"]["id"]
    providers = client.get("/api/library/providers").json()["providers"]
    ids = [p["id"] for p in providers]
    assert f"collection:{cid}" in ids


def test_collection_provider_returns_only_matching_songs(client, server_mod):
    _seed_mixed(server_mod)
    cid = client.post("/api/collections", json={"name": "DropD", "rules": {"tunings": ["Drop D"]}}).json()["collection"]["id"]
    pid = f"collection:{cid}"

    page = client.get("/api/library", params={"provider": pid}).json()
    titles = sorted(s["title"] for s in page["songs"])
    assert titles == ["Drop One", "Drop Two"]          # E Standard song excluded

    stats = client.get("/api/library/stats", params={"provider": pid}).json()
    assert stats["total_songs"] == 2


def test_collection_provider_is_local_kind(client, server_mod):
    # kind="local" keeps the client's play/art paths on the local branch (a
    # collection's matched songs are local rows), not the remote-sync branch.
    cid = client.post("/api/collections", json={"name": "C", "rules": {"tunings": ["Drop D"]}}).json()["collection"]["id"]
    prov = next(p for p in client.get("/api/library/providers").json()["providers"]
                if p["id"] == f"collection:{cid}")
    assert prov["kind"] == "local"


def test_collection_tolerates_corrupt_persisted_rules(client, server_mod):
    # A hand-edited / imported bad rules row (int where a string is expected, a
    # list for `sort`) must not crash the query — the provider re-sanitizes on
    # load. Write the bad JSON straight past the API sanitizer.
    _seed_mixed(server_mod)
    cid = client.post("/api/collections", json={"name": "Bad", "rules": {"tunings": ["Drop D"]}}).json()["collection"]["id"]
    # `artist: []` (list for a string field) and `sort: []` (unhashable) are
    # the values that would crash `.strip()` / `sort_map.get` if they reached a
    # query — they must be dropped, leaving the valid `tunings` rule intact.
    server_mod.meta_db.conn.execute(
        "UPDATE playlists SET rules = ? WHERE id = ?",
        ('{"artist": [], "sort": [], "tunings": ["Drop D"]}', cid),
    )
    server_mod.meta_db.conn.commit()
    server_mod._sync_collection_provider(server_mod.meta_db.get_collection(cid))

    r = client.get("/api/library", params={"provider": f"collection:{cid}"})
    assert r.status_code == 200                          # no 500/503 from bad rules
    assert sorted(s["title"] for s in r.json()["songs"]) == ["Drop One", "Drop Two"]


def test_collection_provider_survives_restart(client, server_mod, tmp_path, monkeypatch):
    _seed_mixed(server_mod)
    cid = client.post("/api/collections", json={"name": "DropD", "rules": {"tunings": ["Drop D"]}}).json()["collection"]["id"]
    server_mod.meta_db.conn.close()
    # Re-import the server (same CONFIG_DIR) → boot scan must re-register it.
    sys.modules.pop("server", None)
    mod2 = importlib.import_module("server")
    try:
        ids = [p["id"] for p in mod2.library_providers.list()]
        assert f"collection:{cid}" in ids
    finally:
        mod2.meta_db.conn.close()


# ── Isolation from manual playlists ─────────────────────────────────────────

def test_collections_excluded_from_playlists_and_are_read_only(client):
    cid = client.post("/api/collections", json={"name": "Coll", "rules": {"tunings": ["Drop D"]}}).json()["collection"]["id"]
    # Not listed among manual playlists...
    assert all(p["id"] != cid for p in client.get("/api/playlists").json())
    # ...and manual-playlist mutations 404 on a collection id (get_playlist gate).
    assert client.post(f"/api/playlists/{cid}/songs", json={"filename": "d1.archive"}).status_code == 404
    assert client.get(f"/api/playlists/{cid}").status_code == 404
