"""The game itself: pure, seeded, and with no dependency on verifiers.

This module is the reference implementation. `src/demos/wordle/engine.ts` is a
port of it, and CI proves the two agree by scoring every (guess, answer) pair
in the answer list through both and comparing a SHA-256 of the result. If you
change anything here, that hash changes and the TypeScript must change with it.
"""

from __future__ import annotations

import hashlib
import json
from collections import Counter
from dataclasses import dataclass, field
from functools import lru_cache
from pathlib import Path

WORD_LENGTH = 5
MAX_GUESSES = 6

GREEN, YELLOW, GREY = "G", "Y", "X"

_WORDS = Path(__file__).parent.parent / "words"


@lru_cache(maxsize=1)
def answers() -> tuple[str, ...]:
    return tuple(json.loads((_WORDS / "answers.json").read_text()))


@lru_cache(maxsize=1)
def guesses() -> frozenset[str]:
    return frozenset(json.loads((_WORDS / "guesses.json").read_text()))


def score_guess(guess: str, answer: str) -> str:
    """Green/yellow/grey feedback, as two passes.

    The two passes are not a stylistic choice. A letter may be marked non-grey
    at most as many times as it occurs in the answer, and greens have first
    claim on that allocation — so every green in the whole word must be
    resolved before any yellow is assigned. Doing it in one pass marks the
    first `SASSY` S yellow when `BASIS` has already spent both its S's on the
    greens that follow, which is the single most common bug in Wordle
    implementations. It is the bug that put a correction video on the most
    watched explanation of this game ever made.
    """
    guess, answer = guess.lower(), answer.lower()
    if len(guess) != len(answer):
        raise ValueError(f"length mismatch: {guess!r} vs {answer!r}")

    n = len(answer)
    pattern = [GREY] * n
    remaining: Counter[str] = Counter()

    # Pass 1 — greens claim their letters out of the pool.
    for i in range(n):
        if guess[i] == answer[i]:
            pattern[i] = GREEN
        else:
            remaining[answer[i]] += 1

    # Pass 2 — yellows take only what pass 1 left, left to right.
    for i in range(n):
        if pattern[i] is GREEN or pattern[i] == GREEN:
            continue
        if remaining[guess[i]] > 0:
            pattern[i] = YELLOW
            remaining[guess[i]] -= 1

    return "".join(pattern)


def is_consistent(candidate: str, guess: str, pattern: str) -> bool:
    """Would `candidate` have produced `pattern` for `guess`?

    This is the whole of constraint filtering, and it is also how the
    `consistency` reward decides whether a guess contradicts what the player
    was already told: a guess is consistent iff it is still a viable answer
    given every previous piece of feedback.
    """
    return score_guess(guess, candidate) == pattern


def hard_mode_violation(guess: str, prev_guess: str, prev_pattern: str) -> str | None:
    """NYT hard-mode legality, or None if the guess is legal.

    Three details are routinely got wrong and are deliberate here: greens are
    positional and locked; yellows are counted, not merely present, so a guess
    must carry at least as many copies as were revealed; and grey letters are
    NOT banned — hard mode places no constraint at all on known-absent letters.
    """
    guess, prev_guess = guess.lower(), prev_guess.lower()
    for i, tile in enumerate(prev_pattern):
        if tile == GREEN and guess[i] != prev_guess[i]:
            return f"{prev_guess[i].upper()} must stay in position {i + 1}"

    need = Counter(prev_guess[i] for i, t in enumerate(prev_pattern) if t in (GREEN, YELLOW))
    have = Counter(guess)
    for letter, count in need.items():
        if have[letter] < count:
            plural = "" if count == 1 else f" {count} copies of"
            return f"guess must contain{plural} {letter.upper()}"
    return None


def fnv1a32(text: str) -> int:
    """FNV-1a, 32-bit. Chosen because it is trivial to reproduce exactly.

    A language's built-in RNG is not portable: `random.Random(7)` is a
    Mersenne Twister and there is no honest one-line JavaScript equivalent, so
    seed 7 would pick one word here and a different one in the browser. Every
    permalink on the site would then disagree with the recorded run it claims
    to show. A hash sidesteps the whole problem — both sides compute the same
    integer from the same string, and there is nothing to keep in step.
    """
    h = 0x811C9DC5
    for byte in text.encode():
        h ^= byte
        h = (h * 0x01000193) & 0xFFFFFFFF
    return h


def answer_for_seed(seed: int) -> str:
    """The hidden word for a seed. Identical in engine.ts — see fnv1a32."""
    pool = answers()
    return pool[fnv1a32(str(seed)) % len(pool)]


@dataclass
class Game:
    """One episode. Deterministic in `seed`, and never raises on bad input."""

    seed: int
    answer: str = ""
    max_guesses: int = MAX_GUESSES
    hard_mode: bool = False
    history: list[tuple[str, str]] = field(default_factory=list)
    rejected: int = 0

    def __post_init__(self) -> None:
        if not self.answer:
            self.answer = answer_for_seed(self.seed)

    @property
    def solved(self) -> bool:
        return bool(self.history) and self.history[-1][1] == GREEN * WORD_LENGTH

    @property
    def over(self) -> bool:
        return self.solved or len(self.history) >= self.max_guesses

    def rejection_reason(self, word: str) -> str | None:
        """Why this guess would not be accepted, or None if it is playable."""
        word = word.lower().strip()
        if len(word) != WORD_LENGTH:
            return f"'{word}' is not {WORD_LENGTH} letters"
        if word not in guesses():
            return f"'{word}' is not in the word list"
        if any(word == prev for prev, _ in self.history):
            return f"'{word}' has already been guessed"
        if self.hard_mode and self.history:
            prev, pattern = self.history[-1]
            violation = hard_mode_violation(word, prev, pattern)
            if violation:
                return violation
        return None

    def play(self, word: str) -> tuple[str | None, str | None]:
        """Play a guess. Returns (pattern, rejection) — exactly one is None.

        A rejected guess costs a turn of the model's patience but not a row of
        the board, which is how the real game behaves and what keeps a
        malformed reply from silently ending the episode.
        """
        if self.over:
            return None, "the game is already over"
        reason = self.rejection_reason(word)
        if reason:
            self.rejected += 1
            return None, reason
        word = word.lower().strip()
        pattern = score_guess(word, self.answer)
        self.history.append((word, pattern))
        return pattern, None


def conformance_digest() -> str:
    """SHA-256 over every (guess, answer) pair in the answer list.

    5,3xx,xxx patterns hashed in a fixed order. This is the cross-language
    gate: the TypeScript port computes the same digest over the same pairs, and
    CI fails if they differ. It is strictly stronger than a hand-picked vector
    file, which only ever catches the cases somebody thought of.
    """
    pool = answers()
    digest = hashlib.sha256()
    for answer in pool:
        digest.update("".join(score_guess(g, answer) for g in pool).encode())
    return digest.hexdigest()
