#!/usr/bin/env python3
"""The probe ladder: does this reward actually measure anything?

A reward function is a claim, and an unprobed one is an unchecked claim. The
ladder plays a set of deliberately-crafted policies — from doing nothing at all
to the best play we can write — and asserts that the reward orders them the way
a person would, per weighted component rather than as one blended float.

A blended float hides the failure this is built to catch: a component that is
flat across every policy is measuring nothing, and it will still move the total
because the other components move.

Run: uv run python envs/probe.py
Exits non-zero on any violated assertion.
"""

from __future__ import annotations

import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent / "wordle_five"))

from wordle_five import solver as S  # noqa: E402
from wordle_five.engine import (  # noqa: E402
    MAX_GUESSES,
    Game,
    answer_for_seed,
    answers,
    score_guess,
)
from wordle_five.protocol import parse_guess  # noqa: E402
from wordle_five.reward import ROLES, WEIGHTS, Episode, metrics, score, total  # noqa: E402

SEEDS = list(range(16))
COMPONENTS = ("solved", "economy", "consistency")


# ---------------------------------------------------------------- policies --
# Each policy is a function (game) -> reply text. They return TEXT, not moves,
# so the parser and the rejection path are exercised exactly as a model would
# exercise them. A policy that returns unparseable prose is a valid policy.


def policy_inaction(game: Game) -> str:
    """Reasons at length and never commits. The floor: must measure 0.000."""
    return "Let me think carefully about which word would be most informative here."


def policy_spammer(game: Game) -> str:
    """Multiple bracketed tokens. Only the first is read; the rest are noise."""
    return "Maybe [crane] or [slate] or [adieu]?"


def policy_crude(game: Game) -> str:
    """One valid word, forever. Legal, useless, and it repeats itself."""
    return "[tares]"


def policy_plausible(game: Game) -> str:
    """Keeps greens, drops known-absent letters. What a person does unaided."""
    pool = answers()
    if not game.history:
        return "[tares]"
    greens: dict[int, str] = {}
    absent: set[str] = set()
    for guess, pattern in game.history:
        for i, tile in enumerate(pattern):
            if tile == "G":
                greens[i] = guess[i]
            elif tile == "X":
                absent.add(guess[i])
    played = {g for g, _ in game.history}
    for word in pool:
        if word in played:
            continue
        if any(word[i] != c for i, c in greens.items()):
            continue
        if any(c in absent for c in word):
            continue
        return f"[{word}]"
    return "[tares]"


def policy_candidate_only(game: Game) -> str:
    """Only ever guesses words that could still be the answer.

    This is the policy the counterweight rewards. It never wastes a guess, and
    it is measurably slower than the oracle because it cannot buy information
    with a word that has no chance of winning.
    """
    alive = S.consistent_candidates(game.history)
    played = {g for g, _ in game.history}
    for word in alive:
        if word not in played:
            return f"[{word}]"
    return "[tares]"


def policy_oracle(game: Game) -> str:
    """Greedy maximum entropy — the reference player, and the economy denominator."""
    import numpy as np

    pool = answers()
    if not game.history:
        return f"[{pool[S._best_opener()]}]"
    alive = S.consistent_candidates(game.history)
    if not alive:
        return "[tares]"
    index = np.array([pool.index(w) for w in alive])
    return f"[{pool[S.best_guess(index)]}]"


def policy_exhaustive(game: Game) -> str:
    """Knows the answer but burns three turns on known-wrong probes first.

    Proves the turn budget actually binds: this must score strictly below the
    oracle, or `economy` is not doing its job.
    """
    if len(game.history) < 3:
        probes = ["fuzzy", "jumbo", "whelp"]
        pick = probes[len(game.history)]
        return f"[{pick if pick != game.answer else 'tares'}]"
    return f"[{game.answer}]"


POLICIES = {
    "inaction": policy_inaction,
    "spammer": policy_spammer,
    "crude": policy_crude,
    "plausible": policy_plausible,
    "candidate_only": policy_candidate_only,
    "exhaustive": policy_exhaustive,
    "oracle": policy_oracle,
}


# ------------------------------------------------------------------ runner --


def play(policy, seed: int) -> Episode:
    game = Game(seed=seed)
    for _ in range(MAX_GUESSES * 3):   # room for rejected replies
        if game.over:
            break
        guess = parse_guess(policy(game))
        if guess is None:
            game.rejected += 1
            # An unparseable reply still costs a turn of patience, or a policy
            # that never guesses would loop until the budget above runs out and
            # look identical to one that guessed badly.
            if game.rejected >= MAX_GUESSES:
                break
            continue
        game.play(guess)
    return Episode(
        answer=game.answer,
        guesses=[g for g, _ in game.history],
        patterns=[p for _, p in game.history],
        rejected=game.rejected,
        reference_depth=S.reference_depth(game.answer),
    )


def run() -> int:
    print(f"Probing wordle-five over {len(SEEDS)} seeds\n")
    results: dict[str, dict[str, float]] = {}
    extra: dict[str, dict[str, float]] = {}

    for name, policy in POLICIES.items():
        episodes = [play(policy, s) for s in SEEDS]
        scores = [score(e) for e in episodes]
        results[name] = {c: sum(s[c] for s in scores) / len(scores) for c in COMPONENTS}
        results[name]["total"] = sum(total(e) for e in episodes) / len(episodes)
        mets = [metrics(e) for e in episodes]
        extra[name] = {k: sum(m[k] for m in mets) / len(mets) for k in mets[0]}

    width = max(len(n) for n in POLICIES)
    header = f"{'policy':<{width}}  " + "  ".join(f"{c:>12}" for c in COMPONENTS) + f"  {'TOTAL':>8}"
    print(header)
    print("-" * len(header))
    for name in POLICIES:
        row = results[name]
        cells = "  ".join(f"{row[c]:>12.4f}" for c in COMPONENTS)
        print(f"{name:<{width}}  {cells}  {row['total']:>8.4f}")

    print(f"\nweights: {WEIGHTS}")
    print(f"roles:   {ROLES}")
    print("\nguesses used (mean):", {n: round(extra[n]['guesses_used'], 2) for n in POLICIES})

    failures: list[str] = []

    # 1. Doing nothing must measure exactly nothing. If a policy that never
    #    plays can collect reward, every number above it is inflated.
    if abs(results["inaction"]["total"]) > 1e-9:
        failures.append(f"inaction scored {results['inaction']['total']:.6f}, must be 0.000")

    # 2. The reference player must reach the top of both objective components,
    #    or `economy`'s denominator disagrees with the policy that defines it.
    if abs(results["oracle"]["solved"] - 1.0) > 1e-9:
        failures.append(f"oracle solved {results['oracle']['solved']:.4f}, must be 1.0")
    if abs(results["oracle"]["economy"] - 1.0) > 1e-9:
        failures.append(f"oracle economy {results['oracle']['economy']:.4f}, must be 1.0")

    # 3. The counterweight must actually bite the policy it is aimed at. The
    #    oracle buys information with guesses that cannot win; if that were
    #    free, `consistency` would be a gate wearing a counterweight's name.
    if results["oracle"]["consistency"] >= 1.0 - 1e-9:
        failures.append("oracle scored a perfect consistency — the counterweight is not in tension")

    # 4. And the policy that pays the counterweight's price must collect it.
    if abs(results["candidate_only"]["consistency"] - 1.0) > 1e-9:
        failures.append(
            f"candidate_only consistency {results['candidate_only']['consistency']:.4f}, must be 1.0"
        )

    # 5. Neither of the two good policies may dominate the other on every
    #    component. If one did, the reward would encode a single right answer
    #    and the reward editor would be theatre.
    oracle, cand = results["oracle"], results["candidate_only"]
    if all(oracle[c] >= cand[c] - 1e-9 for c in COMPONENTS):
        failures.append("oracle dominates candidate_only on every component — no real trade-off")
    if all(cand[c] >= oracle[c] - 1e-9 for c in COMPONENTS):
        failures.append("candidate_only dominates oracle on every component — no real trade-off")

    # 6. Burning turns on known-wrong probes must cost.
    if results["exhaustive"]["total"] >= results["oracle"]["total"] - 1e-9:
        failures.append("exhaustive >= oracle — the turn budget does not bind")

    # 7. No weighted component may be flat. A component with the same value for
    #    every policy is measuring nothing and still moves the total.
    for component in COMPONENTS:
        values = [results[n][component] for n in POLICIES]
        if max(values) - min(values) < 1e-9:
            failures.append(f"component '{component}' is flat across the ladder — it measures nothing")

    print()
    if failures:
        for f in failures:
            print(f"FAIL: {f}")
        return 1
    print(f"All {7} ladder assertions hold.")
    return 0


if __name__ == "__main__":
    raise SystemExit(run())
