"""Player progression engine: instrument paths, challenges, quests, wallet.

Pure evaluation logic for the progression system (spec 010). The only IO in
this module is ``load_content()`` reading the bundled JSON content files under
``data/progression/`` — everything else is deterministic functions over plain
dicts so the whole engine is unit-testable without a database.

Vocabulary
----------
content    The validated bundle of path/quest/shop definitions (see
           ``load_content``). Definitions are data: adding a path, level,
           challenge, quest or shop item is a JSON edit, never a code change.
snapshot   The caller-built view of current player state fed to
           ``evaluate_event``::

               {
                 "calibration_status": "pending" | "completed" | "skipped",
                 "paths": {path_id: level, ...},          # selected paths only
                 "challenges": {challenge_id: {"count", "completed", "detail"}},
                 "quests": [{"period_type", "quest_id", "count", "completed",
                             "reward_db", "detail"}, ...],  # current periods only
                 "streak": int,                            # current day streak
                 "xp_total": int,                          # lifetime dB earned
               }
event      ``{"type": <goal event type>, "payload": {...}}`` — the single
           choke point unit. ``song_completed`` payloads carry
           ``{filename, instrument, accuracy, score, is_diagnostic}``;
           ``minigame_run`` payloads carry ``{game_id, score}``;
           ``quest_completed`` payloads carry ``{period_type, quest_id}``.

Flat-importable (``from progression import ...``) per constitution
Principle V. Covered by tests/test_progression.py.
"""

from __future__ import annotations

import json
import random
import re
from datetime import date, datetime, timedelta
from pathlib import Path

__all__ = [
    "COUNT_GOAL_TYPES",
    "GOAL_TYPES",
    "SHOP_SLOTS",
    "THRESHOLD_GOAL_TYPES",
    "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",
]

# Count goals increment per matching event; threshold goals complete the
# moment a snapshot value crosses the line (checked on every event).
COUNT_GOAL_TYPES = frozenset(
    {"song_completed", "songs_played_total", "minigame_run", "quest_completed"}
)
THRESHOLD_GOAL_TYPES = frozenset({"streak_reached", "db_earned"})
GOAL_TYPES = COUNT_GOAL_TYPES | THRESHOLD_GOAL_TYPES

SHOP_SLOTS = frozenset({"theme", "avatar_frame"})

CALIBRATION_ACCURACY = 0.9999  # >= this counts as the 100% calibration run


# ---------------------------------------------------------------------------
# Content loading / validation
# ---------------------------------------------------------------------------


def _valid_goal(goal, warnings: list, where: str) -> bool:
    """Validate one goal dict, appending human-readable warnings."""
    if not isinstance(goal, dict):
        warnings.append(f"{where}: goal is not an object")
        return False
    gtype = goal.get("type")
    if gtype not in GOAL_TYPES:
        warnings.append(f"{where}: unknown goal type {gtype!r}")
        return False
    if gtype in COUNT_GOAL_TYPES:
        target = goal.get("target")
        if not isinstance(target, int) or isinstance(target, bool) or target < 1:
            warnings.append(f"{where}: goal target must be a positive integer")
            return False
    elif gtype == "streak_reached":
        days = goal.get("days")
        if not isinstance(days, int) or isinstance(days, bool) or days < 1:
            warnings.append(f"{where}: streak_reached needs positive integer 'days'")
            return False
    elif gtype == "db_earned":
        amount = goal.get("amount")
        if not isinstance(amount, int) or isinstance(amount, bool) or amount < 1:
            warnings.append(f"{where}: db_earned needs positive integer 'amount'")
            return False
    for frac_key in ("min_accuracy",):
        if frac_key in goal:
            v = goal[frac_key]
            if not isinstance(v, (int, float)) or isinstance(v, bool) or not (0 < v <= 1):
                warnings.append(f"{where}: {frac_key} must be a fraction in (0, 1]")
                return False
    return True


def _load_json(path: Path, warnings: list):
    try:
        with open(path, "r", encoding="utf-8") as fh:
            return json.load(fh)
    except (OSError, ValueError) as exc:
        warnings.append(f"{path.name}: unreadable content file ({exc})")
        return None


def _load_path_file(path: Path, seen_challenge_ids: set, warnings: list):
    raw = _load_json(path, warnings)
    if not isinstance(raw, dict):
        if raw is not None:
            warnings.append(f"{path.name}: path file is not an object")
        return None
    pid = raw.get("id")
    if not isinstance(pid, str) or not pid:
        warnings.append(f"{path.name}: missing path id")
        return None
    levels_raw = raw.get("levels")
    if not isinstance(levels_raw, list) or not levels_raw:
        warnings.append(f"{path.name}: path {pid!r} has no levels")
        return None

    levels = []
    expected = 1
    for entry in levels_raw:
        where = f"{path.name}:{pid}"
        if not isinstance(entry, dict):
            warnings.append(f"{where}: level entry is not an object")
            continue
        lvl = entry.get("level")
        if not isinstance(lvl, int) or isinstance(lvl, bool) or lvl < 1:
            warnings.append(f"{where}: level number must be a positive integer")
            continue
        if lvl != expected:
            warnings.append(
                f"{where}: level numbering gap (expected {expected}, got {lvl})"
            )
        expected = lvl + 1
        challenges = []
        for ch in entry.get("challenges") or []:
            cid = isinstance(ch, dict) and ch.get("id")
            cwhere = f"{where} L{lvl} challenge {cid or '?'}"
            if not isinstance(cid, str) or not cid:
                warnings.append(f"{cwhere}: missing challenge id")
                continue
            if cid in seen_challenge_ids:
                warnings.append(f"{cwhere}: duplicate challenge id")
                continue
            if not _valid_goal(ch.get("goal"), warnings, cwhere):
                continue
            seen_challenge_ids.add(cid)
            challenges.append(
                {
                    "id": cid,
                    "title": str(ch.get("title") or cid),
                    "description": str(ch.get("description") or ""),
                    "goal": ch["goal"],
                }
            )
        required = entry.get("required")
        if not isinstance(required, int) or isinstance(required, bool) or required < 1:
            warnings.append(f"{where} L{lvl}: 'required' must be a positive integer")
            continue
        if not challenges:
            warnings.append(f"{where} L{lvl}: no valid challenges, level skipped")
            continue
        if required > len(challenges):
            warnings.append(
                f"{where} L{lvl}: required {required} > {len(challenges)} challenges; clamped"
            )
            required = len(challenges)
        levels.append({"level": lvl, "required": required, "challenges": challenges})

    if not levels:
        warnings.append(f"{path.name}: path {pid!r} has no valid levels")
        return None
    return {
        "id": pid,
        "name": str(raw.get("name") or pid),
        "icon": str(raw.get("icon") or ""),
        "order": raw.get("order") if isinstance(raw.get("order"), int) else 0,
        "levels": levels,
    }


def _load_quest_pool(raw, period_type: str, warnings: list):
    if not isinstance(raw, dict):
        warnings.append(f"quests.json: missing {period_type!r} section")
        return {"count": 0, "pool": {}}
    count = raw.get("count")
    if not isinstance(count, int) or isinstance(count, bool) or count < 1:
        warnings.append(f"quests.json: {period_type} count must be a positive integer")
        count = 0
    pool = {}
    for q in raw.get("pool") or []:
        qid = isinstance(q, dict) and q.get("id")
        where = f"quests.json {period_type} quest {qid or '?'}"
        if not isinstance(qid, str) or not qid:
            warnings.append(f"{where}: missing quest id")
            continue
        if qid in pool:
            warnings.append(f"{where}: duplicate quest id")
            continue
        reward = q.get("reward_db")
        if not isinstance(reward, int) or isinstance(reward, bool) or reward < 0:
            warnings.append(f"{where}: reward_db must be a non-negative integer")
            continue
        if not _valid_goal(q.get("goal"), warnings, where):
            continue
        pool[qid] = {
            "id": qid,
            "title": str(q.get("title") or qid),
            "description": str(q.get("description") or ""),
            "reward_db": reward,
            "goal": q["goal"],
        }
    return {"count": count, "pool": pool}


def _load_shop(raw, warnings: list):
    items = {}
    if not isinstance(raw, dict):
        return items
    for item in raw.get("items") or []:
        iid = isinstance(item, dict) and item.get("id")
        where = f"shop.json item {iid or '?'}"
        if not isinstance(iid, str) or not iid:
            warnings.append(f"{where}: missing item id")
            continue
        if iid in items:
            warnings.append(f"{where}: duplicate item id")
            continue
        slot = item.get("slot")
        if slot not in SHOP_SLOTS:
            warnings.append(f"{where}: unknown slot {slot!r}")
            continue
        cost = item.get("cost")
        if not isinstance(cost, int) or isinstance(cost, bool) or cost < 0:
            warnings.append(f"{where}: cost must be a non-negative integer")
            continue
        payload = item.get("payload")
        if not isinstance(payload, dict):
            warnings.append(f"{where}: payload must be an object")
            continue
        items[iid] = {
            "id": iid,
            "slot": slot,
            "name": str(item.get("name") or iid),
            "description": str(item.get("description") or ""),
            "cost": cost,
            "payload": payload,
        }
    return items


def load_content(root) -> tuple[dict, list]:
    """Load and validate the progression content bundle under ``root``.

    Returns ``(content, warnings)``. Invalid entries are skipped with a
    warning — bad content must never be fatal. ``content``::

        {
          "paths": {path_id: path},          # sorted by (order, id) when listed
          "challenge_index": {challenge_id: {"path_id", "level", "challenge"}},
          "quests": {"daily": {"count", "pool": {qid: quest}}, "weekly": {...}},
          "shop": {item_id: item},
        }
    """
    root = Path(root)
    warnings: list = []
    paths: dict = {}
    challenge_index: dict = {}
    seen_challenge_ids: set = set()

    paths_dir = root / "paths"
    if paths_dir.is_dir():
        for path_file in sorted(paths_dir.glob("*.json")):
            loaded = _load_path_file(path_file, seen_challenge_ids, warnings)
            if loaded is None:
                continue
            if loaded["id"] in paths:
                warnings.append(f"{path_file.name}: duplicate path id {loaded['id']!r}")
                continue
            paths[loaded["id"]] = loaded
            for level in loaded["levels"]:
                for ch in level["challenges"]:
                    challenge_index[ch["id"]] = {
                        "path_id": loaded["id"],
                        "level": level["level"],
                        "challenge": ch,
                    }
    else:
        warnings.append(f"missing content directory {paths_dir}")

    quests_raw = _load_json(root / "quests.json", warnings) or {}
    quests = {
        "daily": _load_quest_pool(quests_raw.get("daily"), "daily", warnings),
        "weekly": _load_quest_pool(quests_raw.get("weekly"), "weekly", warnings),
    }

    shop = _load_shop(_load_json(root / "shop.json", warnings) or {}, warnings)

    content = {
        "paths": paths,
        "challenge_index": challenge_index,
        "quests": quests,
        "shop": shop,
    }
    return content, warnings


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


def instrument_for_arrangement(arr_entry) -> str:
    """Map a library arrangement entry to a progression instrument.

    archive/loose entries carry ``type`` (lead/rhythm/bass/combo); sloppaks may
    only carry ``name``. Vocals are recognised so they never count toward
    guitar challenges; everything else defaults to guitar.
    """
    if not isinstance(arr_entry, dict):
        return "guitar"
    arr_type = str(arr_entry.get("type") or "").strip().lower()
    name = str(arr_entry.get("name") or "").strip().lower()
    if arr_type == "bass":
        return "bass"
    if arr_type == "drums":
        return "drums"
    if arr_type in ("piano", "keys"):
        return "keys"
    # Check name before committing to a guitar type — legacy archive keys
    # arrangements often carry a generic type (lead/rhythm/combo) but have a
    # name like "Keys" or "Piano".  Name overrides the generic type for all
    # well-known non-guitar instruments so that scored keys runs advance the
    # keys path and quests even when the chart XML type was not updated.
    if "bass" in name:
        return "bass"
    if "drum" in name or "percussion" in name:
        return "drums"
    if "vocal" in name:
        return "vocals"
    # Word-boundary match so e.g. "Monkeys Medley" doesn't read as keys.
    if re.search(r"\b(?:keys|piano|keyboard|synth)\b", name):
        return "keys"
    if arr_type in ("lead", "rhythm", "combo"):
        return "guitar"
    return "guitar"


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


def period_keys(now: datetime) -> dict:
    """Period keys for ``now`` (local time): daily ``YYYY-MM-DD``, weekly
    ISO-week ``YYYY-Www`` (Monday-started)."""
    iso_year, iso_week, _ = now.date().isocalendar()
    return {
        "daily": now.date().isoformat(),
        "weekly": f"{iso_year}-W{iso_week:02d}",
    }


def period_resets_at(period_type: str, now: datetime) -> datetime:
    """When the current period rolls over: next local midnight (daily) or next
    Monday 00:00 local (weekly)."""
    midnight = datetime.combine(now.date() + timedelta(days=1), datetime.min.time())
    if period_type == "daily":
        return midnight
    if period_type == "weekly":
        days_to_monday = 7 - now.date().weekday()
        return datetime.combine(
            now.date() + timedelta(days=days_to_monday), datetime.min.time()
        )
    raise ValueError(f"unknown period type {period_type!r}")


def select_quests(pool_ids, period_type: str, period_key: str, count: int) -> list:
    """Deterministically pick ``count`` quest ids for a period.

    Same (period_type, period_key, pool) always yields the same selection, so
    quest rotation survives restarts without any persisted scheduler state.
    """
    ordered = sorted(pool_ids)
    if count >= len(ordered):
        return ordered
    rng = random.Random(f"{period_type}:{period_key}")  # noqa: S311 — deterministic, non-cryptographic sampling for quest rotation
    return sorted(rng.sample(ordered, count))


# ---------------------------------------------------------------------------
# Goal evaluation
# ---------------------------------------------------------------------------


def goal_matches_event(goal: dict, event: dict) -> bool:
    """Whether a count-based goal is advanced by this event (threshold goals
    never match events — see ``threshold_goal_met``).

    Diagnostic (calibration) plays are deliberately NOT regular songs: they
    only count toward a song goal that explicitly targets them by filename,
    so finishing onboarding at 100% yields exactly Mastery Rank 1 instead of
    also completing the first guitar challenges."""
    gtype = goal.get("type")
    etype = event.get("type")
    payload = event.get("payload") or {}
    if gtype == "songs_played_total":
        return etype == "song_completed" and not payload.get("is_diagnostic")
    if gtype == "song_completed":
        if etype != "song_completed":
            return False
        if payload.get("is_diagnostic") and goal.get("filename") != payload.get("filename"):
            return False
        instrument = goal.get("instrument")
        if instrument and payload.get("instrument") != instrument:
            return False
        if goal.get("filename") and payload.get("filename") != goal["filename"]:
            return False
        min_accuracy = goal.get("min_accuracy")
        if min_accuracy is not None:
            accuracy = payload.get("accuracy")
            if not isinstance(accuracy, (int, float)) or accuracy < min_accuracy:
                return False
        min_score = goal.get("min_score")
        if min_score is not None:
            score = payload.get("score")
            if not isinstance(score, (int, float)) or score < min_score:
                return False
        return True
    if gtype == "minigame_run":
        if etype != "minigame_run":
            return False
        if goal.get("game_id") and payload.get("game_id") != goal["game_id"]:
            return False
        min_score = goal.get("min_score")
        if min_score is not None:
            score = payload.get("score")
            if not isinstance(score, (int, float)) or score < min_score:
                return False
        return True
    if gtype == "quest_completed":
        if etype != "quest_completed":
            return False
        period = goal.get("period")
        if period and payload.get("period_type") != period:
            return False
        return True
    return False


def threshold_goal_met(goal: dict, snapshot: dict) -> bool:
    """Whether a threshold goal is satisfied by current snapshot values."""
    gtype = goal.get("type")
    if gtype == "streak_reached":
        return int(snapshot.get("streak") or 0) >= int(goal.get("days") or 0)
    if gtype == "db_earned":
        return int(snapshot.get("xp_total") or 0) >= int(goal.get("amount") or 0)
    return False


def _advance_counter(goal: dict, event: dict, count: int, detail):
    """Apply one matching event to a count-based goal.

    Returns ``(new_count, new_detail, advanced)``. ``distinct`` song goals
    keep the set of seen filenames in ``detail["seen"]`` so replays of the
    same song don't advance the counter.
    """
    detail = detail if isinstance(detail, dict) else {}
    if goal.get("type") == "song_completed" and goal.get("distinct"):
        filename = (event.get("payload") or {}).get("filename")
        if not filename:
            return count, detail, False
        seen = detail.get("seen")
        seen = list(seen) if isinstance(seen, list) else []
        if filename in seen:
            return count, detail, False
        seen.append(filename)
        return count + 1, {**detail, "seen": seen}, True
    return count + 1, detail, True


def evaluate_event(event: dict, content: dict, snapshot: dict) -> dict:
    """Evaluate one progression event against current state.

    Pure: returns the deltas for the caller to persist, never mutates inputs.
    Output::

        {
          "challenges": [{"challenge_id", "path_id", "level", "count",
                          "target", "detail", "completed"}],
          "quests": [{"period_type", "quest_id", "count", "target",
                      "detail", "completed", "reward_db"}],
          "level_ups": [{"path_id", "new_level"}],
          "calibration_completed": bool,
        }

    Only listed (i.e. changed) challenges/quests appear. Threshold goals
    (streak_reached, db_earned) are re-checked on every event since the
    snapshot values they read can move with any award.
    """
    outcome = {
        "challenges": [],
        "quests": [],
        "level_ups": [],
        "calibration_completed": False,
    }

    payload = event.get("payload") or {}
    if (
        event.get("type") == "song_completed"
        and payload.get("is_diagnostic")
        and isinstance(payload.get("accuracy"), (int, float))
        and payload["accuracy"] >= CALIBRATION_ACCURACY
        and snapshot.get("calibration_status") in ("pending", "skipped", None)
    ):
        outcome["calibration_completed"] = True

    challenge_state = snapshot.get("challenges") or {}
    path_levels = snapshot.get("paths") or {}
    # Completed counts per (path, level) so we can detect level-ups after
    # applying this event's challenge completions.
    completed_now: dict = {}

    for path_id, level in path_levels.items():
        for ch in active_challenges(content, path_id, level):
            goal = ch["goal"]
            state = challenge_state.get(ch["id"]) or {}
            if state.get("completed"):
                completed_now[path_id] = completed_now.get(path_id, 0) + 1
                continue
            count = int(state.get("count") or 0)
            detail = state.get("detail")
            gtype = goal.get("type")
            changed = False
            completed = False
            if gtype in THRESHOLD_GOAL_TYPES:
                if threshold_goal_met(goal, snapshot):
                    changed = completed = True
            elif goal_matches_event(goal, event):
                count, detail, advanced = _advance_counter(goal, event, count, detail)
                if advanced:
                    changed = True
                    completed = count >= int(goal.get("target") or 1)
            if changed:
                target = int(goal.get("target") or 1) if gtype in COUNT_GOAL_TYPES else 1
                outcome["challenges"].append(
                    {
                        "challenge_id": ch["id"],
                        "path_id": path_id,
                        "level": level + 1,
                        "count": count if gtype in COUNT_GOAL_TYPES else target,
                        "target": target,
                        "detail": detail if isinstance(detail, dict) and detail else None,
                        "completed": completed,
                    }
                )
            if completed:
                completed_now[path_id] = completed_now.get(path_id, 0) + 1

    for path_id, level in path_levels.items():
        nxt = _level_entry(content, path_id, level + 1)
        if nxt and completed_now.get(path_id, 0) >= nxt["required"]:
            outcome["level_ups"].append({"path_id": path_id, "new_level": level + 1})

    for quest in snapshot.get("quests") or []:
        if quest.get("completed"):
            continue
        period_type = quest.get("period_type")
        pool = (content.get("quests") or {}).get(period_type, {}).get("pool", {})
        qdef = pool.get(quest.get("quest_id"))
        if not qdef:
            continue
        goal = qdef["goal"]
        count = int(quest.get("count") or 0)
        detail = quest.get("detail")
        gtype = goal.get("type")
        changed = False
        completed = False
        if gtype in THRESHOLD_GOAL_TYPES:
            if threshold_goal_met(goal, snapshot):
                changed = completed = True
        elif goal_matches_event(goal, event):
            count, detail, advanced = _advance_counter(goal, event, count, detail)
            if advanced:
                changed = True
                completed = count >= int(goal.get("target") or 1)
        if changed:
            target = int(goal.get("target") or 1) if gtype in COUNT_GOAL_TYPES else 1
            outcome["quests"].append(
                {
                    "period_type": period_type,
                    "quest_id": quest["quest_id"],
                    "count": count if gtype in COUNT_GOAL_TYPES else target,
                    "target": target,
                    "detail": detail if isinstance(detail, dict) and detail else None,
                    "completed": completed,
                    "reward_db": int(quest.get("reward_db") or 0),
                }
            )

    return outcome


# ---------------------------------------------------------------------------
# Rank / paths / wallet math
# ---------------------------------------------------------------------------


def _level_entry(content: dict, path_id: str, level: int):
    path = (content.get("paths") or {}).get(path_id)
    if not path:
        return None
    for entry in path["levels"]:
        if entry["level"] == level:
            return entry
    return None


def active_challenges(content: dict, path_id: str, level: int) -> list:
    """The challenge set a path at ``level`` is working on (level+1's set).
    Empty at max level or for unknown paths."""
    nxt = _level_entry(content, path_id, level + 1)
    return list(nxt["challenges"]) if nxt else []


def path_max_level(content: dict, path_id: str) -> int:
    path = (content.get("paths") or {}).get(path_id)
    if not path or not path["levels"]:
        return 0
    return max(entry["level"] for entry in path["levels"])


def mastery_rank(calibration_status, path_levels) -> int:
    """Mastery Rank = onboarding rank (1 once calibration is completed or
    skipped) + the sum of all selected path levels."""
    onboarding = 0 if (calibration_status or "pending") == "pending" else 1
    return onboarding + sum(int(v or 0) for v in (path_levels or {}).values())


def wallet_balance(xp_total, spent) -> int:
    """Spendable dB. Clamped at 0: a per-source XP reset can lower lifetime
    earnings below the amount already spent."""
    return max(0, int(xp_total or 0) - int(spent or 0))
