"""Tests for the progression + shop endpoints (spec 010) and the
/api/stats → progression event wiring."""

import importlib
import json
import sys

import pytest
from fastapi.testclient import TestClient


def _write_fixture_content(root):
    """Small deterministic content bundle (single-quest pools so rotation is
    fixed regardless of date)."""
    (root / "paths").mkdir(parents=True)
    (root / "paths" / "guitar.json").write_text(json.dumps({
        "id": "guitar", "name": "Guitar", "icon": "guitar", "order": 1,
        "levels": [
            {"level": 1, "required": 1, "challenges": [
                {"id": "guitar.l1.c1", "title": "Clean Run",
                 "description": "80%+ accuracy on a guitar song.",
                 "goal": {"type": "song_completed", "instrument": "guitar",
                          "min_accuracy": 0.8, "target": 1}},
            ]},
            {"level": 2, "required": 1, "challenges": [
                {"id": "guitar.l2.c1", "title": "Daily Pair",
                 "description": "Complete 2 daily quests.",
                 "goal": {"type": "quest_completed", "period": "daily", "target": 2}},
            ]},
        ],
    }))
    (root / "paths" / "bass.json").write_text(json.dumps({
        "id": "bass", "name": "Bass", "icon": "bass", "order": 2,
        "levels": [
            {"level": 1, "required": 1, "challenges": [
                {"id": "bass.l1.c1", "title": "First Groove",
                 "description": "Finish a bass song.",
                 "goal": {"type": "song_completed", "instrument": "bass", "target": 1}},
            ]},
        ],
    }))
    (root / "quests.json").write_text(json.dumps({
        "daily": {"count": 1, "pool": [
            {"id": "d.one", "title": "Quick Set", "description": "Finish a song.",
             "reward_db": 50, "goal": {"type": "song_completed", "target": 1}},
        ]},
        "weekly": {"count": 1, "pool": [
            {"id": "w.mini", "title": "Arcade Pair", "description": "2 rounds.",
             "reward_db": 100, "goal": {"type": "minigame_run", "target": 2}},
        ]},
    }))
    (root / "shop.json").write_text(json.dumps({
        "items": [
            {"id": "theme.test", "slot": "theme", "name": "Test Theme",
             "description": "", "cost": 100, "payload": {"colors": {"bg": "#000000"}}},
            {"id": "frame.test", "slot": "avatar_frame", "name": "Test Frame",
             "description": "", "cost": 50, "payload": {"frame_style": "box-shadow: 0 0 0 1px red"}},
        ],
    }))


@pytest.fixture()
def server(tmp_path, monkeypatch, isolate_logging):
    content_dir = tmp_path / "progression-content"
    _write_fixture_content(content_dir)
    monkeypatch.setenv("CONFIG_DIR", str(tmp_path / "config"))
    monkeypatch.setenv("FEEDBACK_PROGRESSION_DATA", str(content_dir))
    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:
            conn.close()
        sys.modules.pop("server", None)


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


def _scored_play(client, filename="song.archive", accuracy=0.9, score=900, arrangement=0):
    return client.post("/api/stats", json={
        "filename": filename, "arrangement": arrangement,
        "score": score, "accuracy": accuracy,
    })


# ── Overview / onboarding ─────────────────────────────────────────────────────

def test_fresh_overview(client):
    r = client.get("/api/progression")
    assert r.status_code == 200
    data = r.json()
    assert data["mastery_rank"] == 0
    assert data["onboarding"]["calibration_status"] == "pending"
    assert data["onboarding"]["diagnostic_filename"].startswith("diagnostics-builtin/")
    assert data["paths"] == []
    assert [p["id"] for p in data["available_paths"]] == ["guitar", "bass"]
    # Current quest periods are lazily instantiated on read.
    assert [q["id"] for q in data["quests"]["daily"]["quests"]] == ["d.one"]
    assert [q["id"] for q in data["quests"]["weekly"]["quests"]] == ["w.mini"]
    assert data["quests"]["daily"]["resets_at"] > data["quests"]["daily"]["period_key"]
    assert data["wallet"] == {"balance": 0, "lifetime_db": 0, "spent": 0}


def test_add_paths_validates_and_is_idempotent(client):
    assert client.post("/api/progression/paths", json={"add": []}).status_code == 400
    assert client.post("/api/progression/paths", json={"add": "guitar"}).status_code == 400
    assert client.post("/api/progression/paths", json={"add": ["keytar"]}).status_code == 400

    r = client.post("/api/progression/paths", json={"add": ["guitar"]})
    assert r.status_code == 200
    paths = r.json()["paths"]
    assert [p["id"] for p in paths] == ["guitar"]
    assert paths[0]["level"] == 0
    assert paths[0]["max_level"] == 2
    assert paths[0]["next"]["level"] == 1
    assert [c["id"] for c in paths[0]["next"]["challenges"]] == ["guitar.l1.c1"]
    assert [p["id"] for p in r.json()["available_paths"]] == ["bass"]

    # Re-adding never resets the level.
    r2 = client.post("/api/progression/paths", json={"add": ["guitar"]})
    assert [p["id"] for p in r2.json()["paths"]] == ["guitar"]


def test_skip_calibration_reaches_rank_one(client):
    # Spec invariant: skipping requires at least one selected path first.
    assert client.post("/api/progression/onboarding", json={"action": "skip"}).status_code == 400
    client.post("/api/progression/paths", json={"add": ["guitar"]})

    r = client.post("/api/progression/onboarding", json={"action": "skip"})
    assert r.status_code == 200
    assert r.json()["mastery_rank"] == 1
    assert r.json()["onboarding"]["calibration_status"] == "skipped"
    # Idempotent; bad action rejected.
    assert client.post("/api/progression/onboarding", json={"action": "skip"}).json()[
        "onboarding"]["calibration_status"] == "skipped"
    assert client.post("/api/progression/onboarding", json={"action": "reset"}).status_code == 400


def test_skip_allowed_when_content_defines_no_paths(client, server, monkeypatch):
    # Robustness carve-out: broken/empty content must never brick onboarding.
    content = {**server._get_progression_content(), "paths": {}}
    monkeypatch.setattr(server, "_progression_content", content)
    assert client.post("/api/progression/onboarding", json={"action": "skip"}).status_code == 200


# ── Scored-play wiring ────────────────────────────────────────────────────────

def test_scored_play_advances_challenge_quest_and_levels(client, server):
    client.post("/api/progression/paths", json={"add": ["guitar"]})
    client.post("/api/progression/onboarding", json={"action": "skip"})

    r = _scored_play(client, accuracy=0.9, score=900)
    assert r.status_code == 200
    summary = r.json()["progression"]
    assert summary is not None
    assert [c["id"] for c in summary["challenges_completed"]] == ["guitar.l1.c1"]
    assert summary["level_ups"] == [{"path_id": "guitar", "new_level": 1}]
    assert [q["id"] for q in summary["quests_completed"]] == ["d.one"]
    assert summary["mastery_rank"] == 2  # skip (1) + guitar level 1

    overview = client.get("/api/progression").json()
    assert overview["mastery_rank"] == 2
    guitar = overview["paths"][0]
    assert guitar["level"] == 1
    # Now working the level-2 set; the d.one completion already counted 1/2
    # via the quest_completed re-entry.
    assert guitar["next"]["level"] == 2
    assert guitar["next"]["challenges"][0]["count"] == 1
    # Quest reward landed in the unified store under source "quests".
    from xp import xp_for_run
    assert overview["wallet"]["lifetime_db"] == xp_for_run(900) + 50
    row = server.meta_db.conn.execute(
        "SELECT xp FROM xp_sources WHERE source = 'quests'").fetchone()
    assert row[0] == 50


def test_low_accuracy_play_does_not_complete_gated_challenge(client):
    client.post("/api/progression/paths", json={"add": ["guitar"]})
    r = _scored_play(client, accuracy=0.5, score=100)
    summary = r.json()["progression"]
    assert summary["challenges_completed"] == []
    assert summary["level_ups"] == []
    # The unfiltered daily quest still advances (and completes at target 1).
    assert [q["id"] for q in summary["quests_completed"]] == ["d.one"]


def test_diagnostic_at_100_completes_calibration(client, server):
    diag = server._builtin_diagnostic_filename()
    # A near-miss leaves calibration pending.
    _scored_play(client, filename=diag, accuracy=0.97, score=500)
    assert client.get("/api/progression").json()["onboarding"]["calibration_status"] == "pending"

    _scored_play(client, filename=diag, accuracy=1.0, score=500)
    data = client.get("/api/progression").json()
    assert data["onboarding"]["calibration_status"] == "completed"
    assert data["mastery_rank"] == 1


def test_diagnostic_play_does_not_feed_challenges_or_quests(client, server):
    # The calibration run is a perfect guitar play — it must yield rank 1
    # EXACTLY, advancing neither the guitar path nor the daily song quest.
    client.post("/api/progression/paths", json={"add": ["guitar"]})
    r = _scored_play(client, filename=server._builtin_diagnostic_filename(),
                     accuracy=1.0, score=500)
    summary = r.json()["progression"]
    assert summary["calibration_completed"] is True
    assert summary["challenges_completed"] == []
    assert summary["level_ups"] == []
    assert summary["quests_completed"] == []
    assert summary["mastery_rank"] == 1

    data = client.get("/api/progression").json()
    assert data["mastery_rank"] == 1
    assert data["paths"][0]["level"] == 0
    assert all(q["count"] == 0 for q in data["quests"]["daily"]["quests"])


def test_pathless_diagnostic_run_still_completes_calibration(client, server):
    """INTENDED (spec FR-007b): the ≥1-path invariant binds the onboarding
    wizard and the explicit skip action, NOT the stats plane. A 100% diagnostic
    run is an earned achievement and must count even before any path is
    selected (e.g. a pre-progression profile playing the diagnostic as a
    hardware test) — yielding a valid pathless rank-1 state."""
    _scored_play(client, filename=server._builtin_diagnostic_filename(),
                 accuracy=1.0, score=500)
    data = client.get("/api/progression").json()
    assert data["onboarding"]["calibration_status"] == "completed"
    assert data["mastery_rank"] == 1
    assert data["paths"] == []


def test_diagnostic_upgrades_skipped_without_rank_change(client, server):
    client.post("/api/progression/paths", json={"add": ["guitar"]})
    r = client.post("/api/progression/onboarding", json={"action": "skip"})
    assert r.json()["onboarding"]["calibration_status"] == "skipped"
    _scored_play(client, filename=server._builtin_diagnostic_filename(), accuracy=1.0, score=500)
    data = client.get("/api/progression").json()
    assert data["onboarding"]["calibration_status"] == "completed"
    assert data["mastery_rank"] == 1


def test_progression_failure_never_drops_stat_write(client, server, monkeypatch):
    def _boom(*args, **kwargs):
        raise RuntimeError("engine exploded")
    monkeypatch.setattr(server.meta_db, "record_progression_event", _boom)
    r = _scored_play(client, accuracy=0.9, score=400)
    assert r.status_code == 200
    assert r.json()["stats"]["plays"] == 1
    assert r.json()["progression"] is None
    assert r.json()["progress"] is not None  # XP/streak side-effects intact


# ── Generic event intake ──────────────────────────────────────────────────────

def test_events_endpoint_whitelist(client):
    # song_completed is server-derived: rejected from external intake.
    r = client.post("/api/progression/events",
                    json={"type": "song_completed", "payload": {"accuracy": 1.0}})
    assert r.status_code == 400
    assert client.post("/api/progression/events", json={"type": "nope"}).status_code == 400


def test_events_endpoint_validates_payload(client):
    bad_big = {f"k{i}": i for i in range(17)}
    assert client.post("/api/progression/events",
                       json={"type": "minigame_run", "payload": bad_big}).status_code == 400
    assert client.post("/api/progression/events",
                       json={"type": "minigame_run", "payload": {"meta": {"nested": 1}}}).status_code == 400
    assert client.post("/api/progression/events",
                       json={"type": "minigame_run", "payload": {"x": "y" * 257}}).status_code == 400


def test_stale_quest_completion_does_not_double_award(client, server, monkeypatch):
    """A quest completion computed from a stale snapshot (concurrent event won
    the guarded UPDATE) must not pay its reward or re-enter again."""
    _scored_play(client)  # completes daily d.one (+50 dB)
    xp_before = server.meta_db.get_xp()

    real_snapshot = server.meta_db.progression_snapshot
    def stale_snapshot(content, now):
        snap = real_snapshot(content, now)
        for quest in snap["quests"]:
            if quest["quest_id"] == "d.one":
                quest["completed"] = False
                quest["count"] = 0
        return snap
    monkeypatch.setattr(server.meta_db, "progression_snapshot", stale_snapshot)

    summary = server.meta_db.record_progression_event(
        "song_completed",
        {"filename": "y.archive", "instrument": "guitar", "accuracy": 0.5, "score": 100},
        server._get_progression_content(),
    )
    assert all(q["id"] != "d.one" for q in summary["quests_completed"])
    assert server.meta_db.get_xp() == xp_before  # no double reward


def test_minigame_events_advance_weekly_quest(client):
    r1 = client.post("/api/progression/events",
                     json={"type": "minigame_run", "payload": {"game_id": "g", "score": 10}})
    assert r1.status_code == 200
    assert r1.json()["progression"]["quests_completed"] == []

    r2 = client.post("/api/progression/events",
                     json={"type": "minigame_run", "payload": {"game_id": "g", "score": 10}})
    completed = r2.json()["progression"]["quests_completed"]
    assert [q["id"] for q in completed] == ["w.mini"]
    assert completed[0]["reward_db"] == 100
    assert client.get("/api/progression").json()["wallet"]["lifetime_db"] == 100


# ── Shop ──────────────────────────────────────────────────────────────────────

def test_shop_catalog_and_purchase_flow(client, server):
    catalog = client.get("/api/shop").json()
    assert {i["id"] for i in catalog["items"]} == {"theme.test", "frame.test"}
    assert all(not i["owned"] and not i["equipped"] for i in catalog["items"])

    # Insufficient balance: rejected, nothing mutates.
    r = client.post("/api/shop/buy", json={"item_id": "theme.test"})
    assert r.status_code == 402
    assert r.json()["wallet"]["spent"] == 0

    server.meta_db.award_xp(500)
    r = client.post("/api/shop/buy", json={"item_id": "theme.test"})
    assert r.status_code == 200
    assert r.json()["wallet"] == {"balance": 400, "lifetime_db": 500, "spent": 100}

    # Double-buy → 409; unknown item → 400.
    assert client.post("/api/shop/buy", json={"item_id": "theme.test"}).status_code == 409
    assert client.post("/api/shop/buy", json={"item_id": "theme.nope"}).status_code == 400

    # Spending never touches lifetime XP.
    assert server.meta_db.get_xp() == 500


def test_shop_equip_requires_ownership(client, server):
    assert client.post("/api/shop/equip",
                       json={"slot": "theme", "item_id": "theme.test"}).status_code == 403
    assert client.post("/api/shop/equip",
                       json={"slot": "hat", "item_id": "theme.test"}).status_code == 400
    # Slot/item mismatch is a 400 even when the item exists.
    assert client.post("/api/shop/equip",
                       json={"slot": "avatar_frame", "item_id": "theme.test"}).status_code == 400

    server.meta_db.award_xp(500)
    client.post("/api/shop/buy", json={"item_id": "theme.test"})
    r = client.post("/api/shop/equip", json={"slot": "theme", "item_id": "theme.test"})
    assert r.status_code == 200
    assert r.json()["equipped"] == {"theme": "theme.test"}

    # Equipped cosmetics ride along on /api/profile (resolved payloads).
    profile = client.get("/api/profile").json()
    assert profile["cosmetics"]["theme"]["item_id"] == "theme.test"
    assert profile["cosmetics"]["theme"]["payload"] == {"colors": {"bg": "#000000"}}

    # Unequip restores the default.
    r = client.post("/api/shop/equip", json={"slot": "theme", "item_id": None})
    assert r.json()["equipped"] == {}
    assert client.get("/api/profile").json()["cosmetics"] == {}


def test_wallet_balance_clamps_after_source_reset(client, server):
    server.meta_db.award_xp(200, "minigames")
    client.post("/api/shop/buy", json={"item_id": "frame.test"})   # spend 50
    server.meta_db.reset_source_xp("minigames")                    # lifetime → 0
    wallet = client.get("/api/shop").json()["wallet"]
    assert wallet == {"balance": 0, "lifetime_db": 0, "spent": 50}
