"""Tests for lib/progression.py — the pure progression engine (spec 010)."""

import json
from datetime import datetime
from pathlib import Path

import pytest

from progression import (
    active_challenges,
    evaluate_event,
    goal_matches_event,
    instrument_for_arrangement,
    load_content,
    mastery_rank,
    path_max_level,
    period_keys,
    period_resets_at,
    select_quests,
    threshold_goal_met,
    wallet_balance,
)

REPO_ROOT = Path(__file__).resolve().parents[1]
BUNDLED_CONTENT = REPO_ROOT / "data" / "progression"


# ---------------------------------------------------------------------------
# Content loading
# ---------------------------------------------------------------------------


def test_bundled_content_loads_clean():
    content, warnings = load_content(BUNDLED_CONTENT)
    assert warnings == []
    assert set(content["paths"]) == {"guitar", "bass", "drums", "keys"}
    assert content["challenge_index"]
    assert content["quests"]["daily"]["count"] == 3
    assert content["quests"]["weekly"]["count"] == 2
    assert content["quests"]["daily"]["pool"]
    assert content["shop"]
    # Every indexed challenge id is namespaced under its path.
    for cid, entry in content["challenge_index"].items():
        assert cid.startswith(entry["path_id"] + ".")


def _write_content(root: Path, paths=None, quests=None, shop=None):
    (root / "paths").mkdir(parents=True, exist_ok=True)
    for name, data in (paths or {}).items():
        (root / "paths" / f"{name}.json").write_text(
            data if isinstance(data, str) else json.dumps(data)
        )
    (root / "quests.json").write_text(json.dumps(quests if quests is not None else {}))
    (root / "shop.json").write_text(json.dumps(shop if shop is not None else {}))


def _minimal_path(pid="guitar", level=1, required=1, challenges=None):
    return {
        "id": pid,
        "name": pid.title(),
        "levels": [
            {
                "level": level,
                "required": required,
                "challenges": challenges
                or [
                    {
                        "id": f"{pid}.l{level}.c1",
                        "title": "C1",
                        "goal": {"type": "song_completed", "target": 1},
                    }
                ],
            }
        ],
    }


def test_load_content_skips_bad_json_file(tmp_path):
    _write_content(tmp_path, paths={"guitar": _minimal_path()})
    (tmp_path / "paths" / "broken.json").write_text("{not json")
    content, warnings = load_content(tmp_path)
    assert "guitar" in content["paths"]
    assert any("broken.json" in w for w in warnings)


def test_load_content_rejects_unknown_goal_type(tmp_path):
    bad = _minimal_path(
        challenges=[
            {"id": "guitar.l1.bad", "goal": {"type": "moonwalk", "target": 1}},
            {"id": "guitar.l1.ok", "goal": {"type": "song_completed", "target": 1}},
        ]
    )
    _write_content(tmp_path, paths={"guitar": bad})
    content, warnings = load_content(tmp_path)
    ids = list(content["challenge_index"])
    assert ids == ["guitar.l1.ok"]
    assert any("unknown goal type" in w for w in warnings)


def test_load_content_rejects_duplicate_challenge_ids(tmp_path):
    p = _minimal_path(
        challenges=[
            {"id": "guitar.l1.dup", "goal": {"type": "song_completed", "target": 1}},
            {"id": "guitar.l1.dup", "goal": {"type": "minigame_run", "target": 1}},
        ]
    )
    _write_content(tmp_path, paths={"guitar": p})
    content, warnings = load_content(tmp_path)
    assert list(content["challenge_index"]) == ["guitar.l1.dup"]
    assert content["challenge_index"]["guitar.l1.dup"]["challenge"]["goal"]["type"] == (
        "song_completed"
    )
    assert any("duplicate challenge id" in w for w in warnings)


def test_load_content_clamps_required_and_warns_on_level_gap(tmp_path):
    p = _minimal_path(required=5)
    p["levels"].append(
        {
            "level": 3,  # gap: level 2 missing
            "required": 1,
            "challenges": [
                {"id": "guitar.l3.c1", "goal": {"type": "minigame_run", "target": 1}}
            ],
        }
    )
    _write_content(tmp_path, paths={"guitar": p})
    content, warnings = load_content(tmp_path)
    assert content["paths"]["guitar"]["levels"][0]["required"] == 1  # clamped
    assert any("clamped" in w for w in warnings)
    assert any("numbering gap" in w for w in warnings)


def test_load_content_validates_quests_and_shop(tmp_path):
    _write_content(
        tmp_path,
        paths={"guitar": _minimal_path()},
        quests={
            "daily": {
                "count": 2,
                "pool": [
                    {"id": "d.ok", "reward_db": 10, "goal": {"type": "minigame_run", "target": 1}},
                    {"id": "d.neg", "reward_db": -5, "goal": {"type": "minigame_run", "target": 1}},
                    {"id": "d.badgoal", "reward_db": 10, "goal": {"type": "nope", "target": 1}},
                ],
            }
        },
        shop={
            "items": [
                {"id": "theme.ok", "slot": "theme", "cost": 100, "payload": {"colors": {}}},
                {"id": "hat.bad", "slot": "hat", "cost": 100, "payload": {}},
                {"id": "theme.negcost", "slot": "theme", "cost": -1, "payload": {}},
            ]
        },
    )
    content, warnings = load_content(tmp_path)
    assert list(content["quests"]["daily"]["pool"]) == ["d.ok"]
    assert any("missing" in w and "weekly" in w for w in warnings)
    assert list(content["shop"]) == ["theme.ok"]
    assert any("unknown slot" in w for w in warnings)
    assert any("cost" in w for w in warnings)
    assert any("reward_db" in w for w in warnings)


def test_load_content_missing_root_is_nonfatal(tmp_path):
    content, warnings = load_content(tmp_path / "nope")
    assert content["paths"] == {}
    assert warnings


# ---------------------------------------------------------------------------
# Instrument attribution
# ---------------------------------------------------------------------------


@pytest.mark.parametrize(
    "entry,expected",
    [
        ({"type": "lead"}, "guitar"),
        ({"type": "rhythm"}, "guitar"),
        ({"type": "combo"}, "guitar"),
        ({"type": "bass"}, "bass"),
        ({"type": "drums"}, "drums"),
        ({"name": "Bass"}, "bass"),
        ({"name": "Drum Kit"}, "drums"),
        ({"name": "Percussion"}, "drums"),
        ({"name": "Vocals"}, "vocals"),
        ({"name": "Lead"}, "guitar"),
        ({"type": "piano"}, "keys"),
        ({"type": "keys"}, "keys"),
        ({"name": "Keys"}, "keys"),
        ({"name": "Piano"}, "keys"),
        ({"name": "Keyboard"}, "keys"),
        ({"name": "Synth Lead"}, "keys"),
        # bass name check wins before the keys word-match
        ({"name": "Bass Synth"}, "bass"),
        # word-boundary: "keys" inside another word is not keys
        ({"name": "Monkeys Medley"}, "guitar"),
        ({}, "guitar"),
        (None, "guitar"),
        # name overrides generic guitar type (legacy archive keys arrangements)
        ({"type": "lead", "name": "Keys"}, "keys"),
        ({"type": "combo", "name": "Piano"}, "keys"),
        ({"type": "lead", "name": "Bass"}, "bass"),
    ],
)
def test_instrument_for_arrangement(entry, expected):
    assert instrument_for_arrangement(entry) == expected


# ---------------------------------------------------------------------------
# Quest periods
# ---------------------------------------------------------------------------


def test_period_keys():
    keys = period_keys(datetime(2026, 6, 12, 15, 30))
    assert keys == {"daily": "2026-06-12", "weekly": "2026-W24"}


def test_period_keys_iso_year_boundary():
    # 2024-12-30 is a Monday belonging to ISO week 1 of 2025.
    keys = period_keys(datetime(2024, 12, 30, 8, 0))
    assert keys["daily"] == "2024-12-30"
    assert keys["weekly"] == "2025-W01"


def test_period_resets_at_daily():
    resets = period_resets_at("daily", datetime(2026, 6, 12, 23, 59))
    assert resets == datetime(2026, 6, 13, 0, 0)


def test_period_resets_at_weekly():
    # 2026-06-12 is a Friday; next Monday is 2026-06-15.
    assert period_resets_at("weekly", datetime(2026, 6, 12, 10, 0)) == datetime(2026, 6, 15)
    # From a Monday the period runs a full week.
    assert period_resets_at("weekly", datetime(2026, 6, 15, 0, 0)) == datetime(2026, 6, 22)


def test_period_resets_at_rejects_unknown():
    with pytest.raises(ValueError):
        period_resets_at("hourly", datetime(2026, 6, 12))


def test_select_quests_deterministic():
    pool = [f"q{i}" for i in range(10)]
    first = select_quests(pool, "daily", "2026-06-12", 3)
    assert first == select_quests(list(reversed(pool)), "daily", "2026-06-12", 3)
    assert len(first) == 3
    assert first == sorted(first)
    assert set(first) <= set(pool)


def test_select_quests_varies_across_periods():
    pool = [f"q{i}" for i in range(10)]
    selections = {
        tuple(select_quests(pool, "daily", f"2026-06-{day:02d}", 3))
        for day in range(1, 15)
    }
    assert len(selections) > 1


def test_select_quests_small_pool_returns_all():
    assert select_quests(["b", "a"], "weekly", "2026-W24", 5) == ["a", "b"]


# ---------------------------------------------------------------------------
# Goal matching
# ---------------------------------------------------------------------------


def _song_event(**payload):
    base = {"filename": "song.archive", "instrument": "guitar", "accuracy": 0.9, "score": 1000}
    base.update(payload)
    return {"type": "song_completed", "payload": base}


def test_goal_matches_song_accuracy_boundary():
    goal = {"type": "song_completed", "min_accuracy": 0.8, "target": 1}
    assert goal_matches_event(goal, _song_event(accuracy=0.8))
    assert not goal_matches_event(goal, _song_event(accuracy=0.799))
    assert not goal_matches_event(goal, _song_event(accuracy=None))


def test_goal_matches_song_filters():
    assert not goal_matches_event(
        {"type": "song_completed", "instrument": "bass", "target": 1}, _song_event()
    )
    assert goal_matches_event(
        {"type": "song_completed", "instrument": "guitar", "target": 1}, _song_event()
    )
    assert goal_matches_event(
        {"type": "song_completed", "filename": "song.archive", "target": 1}, _song_event()
    )
    assert not goal_matches_event(
        {"type": "song_completed", "filename": "other.archive", "target": 1}, _song_event()
    )
    assert not goal_matches_event(
        {"type": "song_completed", "min_score": 2000, "target": 1}, _song_event()
    )


def test_goal_matches_songs_played_total():
    goal = {"type": "songs_played_total", "target": 3}
    assert goal_matches_event(goal, _song_event(instrument="bass", accuracy=0.1))
    assert not goal_matches_event(goal, {"type": "minigame_run", "payload": {}})


def test_diagnostic_plays_do_not_count_as_regular_songs():
    # The calibration sloppak must not feed song challenges/quests — finishing
    # onboarding at 100% yields rank 1 exactly, not rank 1 + first-challenge
    # completions (it IS a perfect guitar play, after all).
    diag = _song_event(filename="diagnostics-builtin/diag.sloppak",
                       is_diagnostic=True, accuracy=1.0)
    assert not goal_matches_event(
        {"type": "song_completed", "instrument": "guitar", "target": 1}, diag)
    assert not goal_matches_event(
        {"type": "song_completed", "min_accuracy": 0.8, "target": 1}, diag)
    assert not goal_matches_event({"type": "songs_played_total", "target": 3}, diag)
    # …unless a goal explicitly targets the diagnostic by filename.
    assert goal_matches_event(
        {"type": "song_completed", "filename": "diagnostics-builtin/diag.sloppak", "target": 1},
        diag)


def test_diagnostic_play_still_completes_calibration_but_no_challenges():
    outcome = evaluate_event(
        _song_event(is_diagnostic=True, accuracy=1.0),
        _content(),
        _snapshot(calibration_status="pending"),
    )
    assert outcome["calibration_completed"] is True
    assert outcome["challenges"] == []
    assert outcome["level_ups"] == []


def test_goal_matches_minigame():
    event = {"type": "minigame_run", "payload": {"game_id": "chord_sprint", "score": 500}}
    assert goal_matches_event({"type": "minigame_run", "target": 1}, event)
    assert goal_matches_event(
        {"type": "minigame_run", "game_id": "chord_sprint", "min_score": 500, "target": 1}, event
    )
    assert not goal_matches_event(
        {"type": "minigame_run", "game_id": "flappy_bend", "target": 1}, event
    )
    assert not goal_matches_event(
        {"type": "minigame_run", "min_score": 501, "target": 1}, event
    )


def test_goal_matches_quest_completed():
    event = {"type": "quest_completed", "payload": {"period_type": "daily", "quest_id": "d.x"}}
    assert goal_matches_event({"type": "quest_completed", "target": 3}, event)
    assert goal_matches_event({"type": "quest_completed", "period": "daily", "target": 3}, event)
    assert not goal_matches_event(
        {"type": "quest_completed", "period": "weekly", "target": 3}, event
    )


def test_threshold_goals():
    assert threshold_goal_met({"type": "streak_reached", "days": 7}, {"streak": 7})
    assert not threshold_goal_met({"type": "streak_reached", "days": 7}, {"streak": 6})
    assert threshold_goal_met({"type": "db_earned", "amount": 100}, {"xp_total": 100})
    assert not threshold_goal_met({"type": "db_earned", "amount": 100}, {"xp_total": 99})
    assert not threshold_goal_met({"type": "song_completed", "target": 1}, {})


# ---------------------------------------------------------------------------
# evaluate_event
# ---------------------------------------------------------------------------


def _content(levels=None, daily_pool=None, weekly_pool=None):
    """Small in-memory content fixture."""
    levels = levels or [
        {
            "level": 1,
            "required": 2,
            "challenges": [
                {
                    "id": "guitar.l1.any",
                    "title": "Any",
                    "description": "",
                    "goal": {"type": "song_completed", "instrument": "guitar", "target": 1},
                },
                {
                    "id": "guitar.l1.acc",
                    "title": "Acc",
                    "description": "",
                    "goal": {
                        "type": "song_completed",
                        "instrument": "guitar",
                        "min_accuracy": 0.8,
                        "target": 1,
                    },
                },
                {
                    "id": "guitar.l1.three",
                    "title": "Three",
                    "description": "",
                    "goal": {"type": "song_completed", "target": 3},
                },
            ],
        },
        {
            "level": 2,
            "required": 1,
            "challenges": [
                {
                    "id": "guitar.l2.distinct",
                    "title": "Distinct",
                    "description": "",
                    "goal": {"type": "song_completed", "distinct": True, "target": 2},
                }
            ],
        },
    ]
    paths = {"guitar": {"id": "guitar", "name": "Guitar", "icon": "", "order": 1, "levels": levels}}
    index = {}
    for entry in levels:
        for ch in entry["challenges"]:
            index[ch["id"]] = {"path_id": "guitar", "level": entry["level"], "challenge": ch}
    return {
        "paths": paths,
        "challenge_index": index,
        "quests": {
            "daily": {"count": 1, "pool": {q["id"]: q for q in (daily_pool or [])}},
            "weekly": {"count": 1, "pool": {q["id"]: q for q in (weekly_pool or [])}},
        },
        "shop": {},
    }


def _snapshot(**overrides):
    snap = {
        "calibration_status": "completed",
        "paths": {"guitar": 0},
        "challenges": {},
        "quests": [],
        "streak": 0,
        "xp_total": 0,
    }
    snap.update(overrides)
    return snap


def test_evaluate_event_increments_and_completes_challenges():
    outcome = evaluate_event(_song_event(accuracy=0.95), _content(), _snapshot())
    by_id = {c["challenge_id"]: c for c in outcome["challenges"]}
    assert by_id["guitar.l1.any"]["completed"] is True
    assert by_id["guitar.l1.acc"]["completed"] is True
    assert by_id["guitar.l1.three"]["count"] == 1
    assert by_id["guitar.l1.three"]["completed"] is False
    # Two completions in one event meet required=2 -> level up.
    assert outcome["level_ups"] == [{"path_id": "guitar", "new_level": 1}]


def test_evaluate_event_no_levelup_below_required():
    outcome = evaluate_event(_song_event(accuracy=0.5), _content(), _snapshot())
    by_id = {c["challenge_id"]: c for c in outcome["challenges"]}
    assert by_id["guitar.l1.any"]["completed"] is True
    assert "guitar.l1.acc" not in by_id
    assert outcome["level_ups"] == []


def test_evaluate_event_counts_prior_completions_toward_levelup():
    snapshot = _snapshot(challenges={"guitar.l1.acc": {"count": 1, "completed": True}})
    outcome = evaluate_event(_song_event(accuracy=0.5), _content(), snapshot)
    assert outcome["level_ups"] == [{"path_id": "guitar", "new_level": 1}]


def test_evaluate_event_distinct_dedupes_replays():
    content = _content()
    snapshot = _snapshot(paths={"guitar": 1})  # working level-2 set
    first = evaluate_event(_song_event(filename="a.archive"), content, snapshot)
    ch = first["challenges"][0]
    assert ch["count"] == 1 and not ch["completed"]
    assert ch["detail"] == {"seen": ["a.archive"]}

    snapshot["challenges"] = {
        "guitar.l2.distinct": {"count": 1, "completed": False, "detail": ch["detail"]}
    }
    replay = evaluate_event(_song_event(filename="a.archive"), content, snapshot)
    assert replay["challenges"] == []  # same song again: no advance

    other = evaluate_event(_song_event(filename="b.archive"), content, snapshot)
    ch2 = other["challenges"][0]
    assert ch2["count"] == 2 and ch2["completed"]
    assert other["level_ups"] == [{"path_id": "guitar", "new_level": 2}]


def test_evaluate_event_default_counts_replays():
    # guitar.l1.three has no distinct flag: the same song three times completes it.
    content = _content()
    snapshot = _snapshot(challenges={"guitar.l1.three": {"count": 2, "completed": False}})
    outcome = evaluate_event(_song_event(filename="same.archive", accuracy=0.1), content, snapshot)
    by_id = {c["challenge_id"]: c for c in outcome["challenges"]}
    assert by_id["guitar.l1.three"]["count"] == 3
    assert by_id["guitar.l1.three"]["completed"] is True


def test_evaluate_event_threshold_challenges_complete_on_any_event():
    levels = [
        {
            "level": 1,
            "required": 1,
            "challenges": [
                {
                    "id": "guitar.l1.streak",
                    "title": "Streak",
                    "description": "",
                    "goal": {"type": "streak_reached", "days": 3},
                }
            ],
        }
    ]
    content = _content(levels=levels)
    quiet = evaluate_event({"type": "minigame_run", "payload": {}}, content, _snapshot(streak=2))
    assert quiet["challenges"] == []
    outcome = evaluate_event({"type": "minigame_run", "payload": {}}, content, _snapshot(streak=3))
    assert outcome["challenges"][0]["completed"] is True
    assert outcome["level_ups"] == [{"path_id": "guitar", "new_level": 1}]


def test_evaluate_event_quests():
    daily = [
        {
            "id": "d.two",
            "title": "Two songs",
            "description": "",
            "reward_db": 50,
            "goal": {"type": "song_completed", "target": 2},
        }
    ]
    content = _content(daily_pool=daily)
    quests = [
        {"period_type": "daily", "quest_id": "d.two", "count": 0, "completed": False,
         "reward_db": 50, "detail": None},
        {"period_type": "daily", "quest_id": "d.gone", "count": 0, "completed": False,
         "reward_db": 10, "detail": None},  # no longer in pool: ignored
    ]
    snapshot = _snapshot(quests=quests)
    first = evaluate_event(_song_event(), content, snapshot)
    assert len(first["quests"]) == 1
    q = first["quests"][0]
    assert (q["quest_id"], q["count"], q["completed"]) == ("d.two", 1, False)

    snapshot["quests"][0]["count"] = 1
    second = evaluate_event(_song_event(), content, snapshot)
    q = second["quests"][0]
    assert q["completed"] is True and q["reward_db"] == 50


def test_evaluate_event_quest_completion_feeds_challenges():
    levels = [
        {
            "level": 1,
            "required": 1,
            "challenges": [
                {
                    "id": "guitar.l1.quests",
                    "title": "Quests",
                    "description": "",
                    "goal": {"type": "quest_completed", "period": "daily", "target": 1},
                }
            ],
        }
    ]
    content = _content(levels=levels)
    event = {"type": "quest_completed", "payload": {"period_type": "daily", "quest_id": "d.x"}}
    outcome = evaluate_event(event, content, _snapshot())
    assert outcome["challenges"][0]["completed"] is True
    assert outcome["level_ups"] == [{"path_id": "guitar", "new_level": 1}]


def test_evaluate_event_calibration():
    content = _content()
    diag = _song_event(is_diagnostic=True, accuracy=1.0)
    assert evaluate_event(diag, content, _snapshot(calibration_status="pending"))[
        "calibration_completed"
    ]
    # A skipped player can still upgrade to completed.
    assert evaluate_event(diag, content, _snapshot(calibration_status="skipped"))[
        "calibration_completed"
    ]
    assert not evaluate_event(diag, content, _snapshot(calibration_status="completed"))[
        "calibration_completed"
    ]
    near_miss = _song_event(is_diagnostic=True, accuracy=0.99)
    assert not evaluate_event(near_miss, content, _snapshot(calibration_status="pending"))[
        "calibration_completed"
    ]
    not_diag = _song_event(accuracy=1.0)
    assert not evaluate_event(not_diag, content, _snapshot(calibration_status="pending"))[
        "calibration_completed"
    ]


def test_evaluate_event_ignores_unselected_paths():
    outcome = evaluate_event(_song_event(), _content(), _snapshot(paths={}))
    assert outcome["challenges"] == []
    assert outcome["level_ups"] == []


def test_evaluate_event_at_max_level_is_quiet():
    outcome = evaluate_event(_song_event(), _content(), _snapshot(paths={"guitar": 2}))
    assert outcome["challenges"] == []
    assert outcome["level_ups"] == []


# ---------------------------------------------------------------------------
# Rank / wallet / helpers
# ---------------------------------------------------------------------------


def test_mastery_rank():
    assert mastery_rank("pending", {}) == 0
    assert mastery_rank(None, {}) == 0
    assert mastery_rank("skipped", {}) == 1
    assert mastery_rank("completed", {}) == 1
    assert mastery_rank("completed", {"guitar": 2, "bass": 1}) == 4
    assert mastery_rank("pending", {"guitar": 2}) == 2  # paths count even pre-calibration


def test_wallet_balance():
    assert wallet_balance(1000, 250) == 750
    assert wallet_balance(100, 500) == 0  # source reset below spent: clamp
    assert wallet_balance(None, None) == 0


def test_active_challenges_and_max_level():
    content = _content()
    assert [c["id"] for c in active_challenges(content, "guitar", 0)] == [
        "guitar.l1.any",
        "guitar.l1.acc",
        "guitar.l1.three",
    ]
    assert [c["id"] for c in active_challenges(content, "guitar", 1)] == ["guitar.l2.distinct"]
    assert active_challenges(content, "guitar", 2) == []  # max level
    assert active_challenges(content, "nope", 0) == []
    assert path_max_level(content, "guitar") == 2
    assert path_max_level(content, "nope") == 0
