"""Bloom — the daily word wheel (Center Circle / Wild Bloom).

DESIGN and ACCEPTANCE are decoupled:

  • DESIGN (wheel selection, tiers, pangram, the Full-Bloom target) uses the small
    COMMON list only — deterministic, stored in daily_puzzles, and unaffected by
    curation. Tiers are scored on COMMON so "Flourishing" is always reachable with
    everyday vocabulary, and "Full Bloom" = finding the whole *designed* puzzle
    (the broad bonus words are extra credit beyond it, never required).

  • ACCEPTANCE is BROAD and DYNAMIC — every valid dictionary word buildable from
    the wheel, computed at RESPONSE TIME as: broad dict ∪ {allow} − {block}, where
    allow/block are runtime admin overrides (bloom_word_overrides). So a missed
    word can be allowed (or a junk word blocked) with NO deploy or regeneration.

Accept words never sit in the network response: clients validate against salted
hashes and compute their own score/tier/pangram from the 7 letters.
"""
from __future__ import annotations

import hashlib
import json
import random
import sqlite3
from itertools import combinations
from pathlib import Path

_DATA = Path(__file__).parent / "data"
_W = json.loads((_DATA / "bloom_words.json").read_text())
ACCEPT: list[str] = _W["accept"]                 # broad: all valid dictionary words
_COMMON: set[str] = set(_W["common"])            # tight: design / tiers / pangrams only
_COMMON_LS: list[tuple[str, frozenset]] = [(w, frozenset(w)) for w in _COMMON]
_AVOID: set[str] = set(json.loads((_DATA / "bloom_avoid.json").read_text()))

# Broad accept words bucketed by distinct-letter set, so the accepted set for a
# 7-letter wheel is gathered by unioning its ≤127 letter-subsets (fast) — no scan
# of the whole ~68k list per request.
_BY_SET: dict[frozenset, list[str]] = {}
for _w in ACCEPT:
    _BY_SET.setdefault(frozenset(_w), []).append(_w)

# Candidate wheels = letter-sets of 7-distinct-letter COMMON words (every wheel
# has ≥1 recognizable pangram). Sorted for deterministic order.
_PANGRAM_SETS: dict[frozenset, list[str]] = {}
for _w in _COMMON:
    _s = frozenset(_w)
    if len(_s) == 7:
        _PANGRAM_SETS.setdefault(_s, []).append(_w)
_CANDIDATES: list[frozenset] = sorted(_PANGRAM_SETS, key=lambda s: "".join(sorted(s)))

MIN_COMMON_WORDS, MAX_COMMON_WORDS = 14, 45
PANGRAM_BONUS = 7
# 8 / 30 / 70 — Flourishing at 70% keeps Bloom from becoming a completionist
# grind. Do NOT raise Flourishing above 0.70 (Codex).
TIER_PCTS: tuple[tuple[str, float], ...] = (
    ("Sprouting", 0.0), ("Budding", 0.08), ("Blooming", 0.30), ("Flourishing", 0.70),
)
TOP_TIER_PCT = 0.70


def score_word(word: str) -> int:
    """4-letter word = 1 point; longer = its length. Pangram bonus added on top."""
    return 1 if len(word) == 4 else len(word)


def score_words(payload: dict, words) -> int:
    """Score found words for a wheel (pangram = uses all 7 letters). Used for the
    player's running score AND the Full-Bloom check (vs the design's max_score)."""
    letters = frozenset(payload["center"]) | frozenset(payload["outer"])
    total = 0
    for w in words:
        total += score_word(w)
        if frozenset(w) == letters:
            total += PANGRAM_BONUS
    return total


# --- DESIGN: common-only, deterministic, stored --------------------------------

def tiers_for(common_max: int) -> list[dict]:
    return [{"name": n, "score": int(p * common_max)} for n, p in TIER_PCTS]


def _design(letters: frozenset, center: str):
    """Center-mode design from the COMMON list only."""
    commons = [w for (w, s) in _COMMON_LS if center in w and s <= letters]
    pangrams = [w for w in commons if frozenset(w) == letters]
    common_max = sum(score_word(w) for w in commons) + PANGRAM_BONUS * len(pangrams)
    display = sorted((p for p in pangrams if p not in _AVOID), key=lambda p: (len(p), p))
    return commons, display, common_max


def _design_wild(letters: frozenset):
    """Wild design (no required center) from the COMMON list only."""
    commons = [w for (w, s) in _COMMON_LS if s <= letters]
    pangrams = [w for w in commons if frozenset(w) == letters]
    common_max = sum(score_word(w) for w in commons) + PANGRAM_BONUS * len(pangrams)
    display = sorted((p for p in pangrams if p not in _AVOID), key=lambda p: (len(p), p))
    vowels = [c for c in sorted(letters) if c in "aeiou"]
    return commons, display, common_max, (vowels[0] if vowels else sorted(letters)[0])


def _payload(letters: frozenset, center: str, display, common_max: int) -> dict:
    return {
        "center": center,
        "outer": sorted(letters - {center}),
        "pangram": display[0],
        "tiers": tiers_for(common_max),
        # Full Bloom = finding the whole designed (common) puzzle; broad bonus
        # words push score past this but are never required.
        "max_score": common_max,
    }


def _generate(seed_str: str, fmt: str) -> dict:
    """Deterministically pick a wheel design for a seed + format."""
    rng = random.Random(int(hashlib.sha256(seed_str.encode()).hexdigest(), 16))
    order = _CANDIDATES[:]
    rng.shuffle(order)
    for letters in order:
        if fmt == "wild":
            commons, display, cmax, center = _design_wild(letters)
            if len(commons) >= MIN_COMMON_WORDS and display:
                return _payload(letters, center, display, cmax)
        else:
            centers = sorted(letters)
            rng.shuffle(centers)
            for center in centers:
                commons, display, cmax = _design(letters, center)
                if MIN_COMMON_WORDS <= len(commons) <= MAX_COMMON_WORDS and display:
                    return _payload(letters, center, display, cmax)
    raise RuntimeError("bloom: no valid wheel found")  # impossible with the vendored dict


def build_puzzle(date: str) -> dict:
    """The day's shared Center Circle wheel design (deterministic by date)."""
    return {"date": date, **_generate(f"bloom:{date}", "center")}


def build_free(seed: str, fmt: str = "center") -> dict:
    """A free-play wheel design (deterministic by seed) — Center Circle or Wild."""
    fmt = "wild" if fmt == "wild" else "center"
    return {"seed": seed, "format": fmt, **_generate(f"free:{fmt}:{seed}", fmt)}


# --- ACCEPTANCE: broad + runtime overrides, computed at response time ----------

def overrides(conn: sqlite3.Connection) -> tuple[set, set]:
    allow, block = set(), set()
    for r in conn.execute("SELECT word, action FROM bloom_word_overrides"):
        (allow if r["action"] == "allow" else block).add(r["word"])
    return allow, block


def _broad_words_for(letters: frozenset) -> list[str]:
    """Every broad-dictionary word buildable from `letters` (distinct-set ⊆ letters)."""
    ls = sorted(letters)
    out = []
    for r in range(1, len(ls) + 1):
        for combo in combinations(ls, r):
            out.extend(_BY_SET.get(frozenset(combo), ()))
    return out


def accepted_words(conn: sqlite3.Connection, center: str, outer, require_center: bool) -> list[str]:
    """The wheel's accepted set RIGHT NOW: broad words buildable from the letters
    (optionally requiring the center), plus allow-overrides, minus block-overrides."""
    letters = frozenset(outer) | {center}
    allow, block = overrides(conn)
    seen, out = set(), []
    for w in _broad_words_for(letters):
        if w in seen or w in block:
            continue
        if require_center and center not in w:
            continue
        seen.add(w)
        out.append(w)
    for w in allow:   # allow words that may not be in the broad dict
        if w in seen or w in block or len(w) < 4 or "s" in w:
            continue
        if not (frozenset(w) <= letters) or (require_center and center not in w):
            continue
        seen.add(w)
        out.append(w)
    return sorted(out)


# --- daily_puzzles storage -----------------------------------------------------

def generate_bloom_puzzle(conn: sqlite3.Connection, date: str) -> dict:
    """Ensure the day's Bloom DESIGN exists in daily_puzzles. Idempotent, pure code."""
    existing = conn.execute(
        "SELECT payload_json FROM daily_puzzles WHERE puzzle_date=? AND game='bloom' AND variant=''", (date,)
    ).fetchone()
    if existing:
        return json.loads(existing["payload_json"])
    payload = build_puzzle(date)
    conn.execute(
        "INSERT OR IGNORE INTO daily_puzzles (puzzle_date, game, variant, payload_json) VALUES (?, 'bloom', '', ?)",
        (date, json.dumps(payload)),
    )
    conn.commit()
    row = conn.execute(
        "SELECT payload_json FROM daily_puzzles WHERE puzzle_date=? AND game='bloom' AND variant=''", (date,)
    ).fetchone()
    return json.loads(row["payload_json"])


def stored_payload(conn: sqlite3.Connection, date: str) -> dict | None:
    """The day's design IF it already exists — never generates (used by the state
    sanitizer, which must not trigger generation)."""
    row = conn.execute(
        "SELECT payload_json FROM daily_puzzles WHERE puzzle_date=? AND game='bloom' AND variant=''", (date,)
    ).fetchone()
    return json.loads(row["payload_json"]) if row else None


def word_hash(salt: str, word: str) -> str:
    return hashlib.sha256(f"{salt}:{word}".encode()).hexdigest()


def _response(salt: str, p: dict, words: list[str], extra: dict) -> dict:
    return {
        "game": "bloom",
        "center": p["center"],
        "outer": p["outer"],
        "accepted": [word_hash(salt, w) for w in words],   # NO plaintext words leak
        "max_score": p["max_score"],                       # Full Bloom = designed puzzle
        "tiers": p["tiers"],
        **extra,
    }


def bloom_response(conn: sqlite3.Connection, date: str) -> dict:
    """Daily Center Circle — accepted set computed live (broad + overrides)."""
    p = generate_bloom_puzzle(conn, date)
    words = accepted_words(conn, p["center"], p["outer"], require_center=True)
    return _response(date, p, words, {"date": date})


def bloom_free_response(conn: sqlite3.Connection, seed: str, fmt: str) -> dict:
    """Free-play wheel keyed by `seed` (resumable). Accepted set computed live."""
    p = build_free(seed, fmt)
    words = accepted_words(conn, p["center"], p["outer"], require_center=p["format"] != "wild")
    return _response(seed, p, words, {"mode": "free", "format": p["format"], "seed": p["seed"]})


# --- runtime curation: overrides + player reports ------------------------------

def set_override(conn: sqlite3.Connection, word: str, action: str, reason: str | None = None,
                 by: str | None = None) -> bool:
    word = (word or "").strip().lower()
    if not (word.isalpha() and action in ("allow", "block")):
        return False
    # An ALLOW that violates Bloom's hard rules (≥4 letters, no 'S') could never
    # count — reject it rather than store an inert override. BLOCK stays permissive.
    if action == "allow" and (len(word) < 4 or "s" in word):
        return False
    conn.execute(
        "INSERT INTO bloom_word_overrides (word, action, reason, created_by) VALUES (?,?,?,?) "
        "ON CONFLICT(word) DO UPDATE SET action=excluded.action, reason=excluded.reason, "
        "created_by=excluded.created_by, created_at=CURRENT_TIMESTAMP",
        (word, action, reason, by),
    )
    conn.commit()
    return True


def clear_override(conn: sqlite3.Connection, word: str) -> None:
    conn.execute("DELETE FROM bloom_word_overrides WHERE word=?", ((word or "").strip().lower(),))
    conn.commit()


def list_overrides(conn: sqlite3.Connection) -> list[dict]:
    return [dict(r) for r in conn.execute(
        "SELECT word, action, reason, created_by, created_at FROM bloom_word_overrides ORDER BY created_at DESC")]


def add_report(conn: sqlite3.Connection, word: str, puzzle_date, mode, fmt, letters, reason) -> bool:
    word = (word or "").strip().lower()
    if not (word.isalpha() and 4 <= len(word) <= 24):
        return False
    # Don't pile up duplicate pending reports for the same word.
    dup = conn.execute(
        "SELECT 1 FROM bloom_word_reports WHERE word=? AND status='pending'", (word,)).fetchone()
    if dup:
        return True
    conn.execute(
        "INSERT INTO bloom_word_reports (word, puzzle_date, mode, format, letters, reason) "
        "VALUES (?,?,?,?,?,?)",
        (word, str(puzzle_date or "")[:16], str(mode or "")[:8], str(fmt or "")[:8],
         str(letters or "")[:16], str(reason or "")[:60]),
    )
    conn.commit()
    return True


def list_reports(conn: sqlite3.Connection, status: str = "pending", limit: int = 100) -> list[dict]:
    return [dict(r) for r in conn.execute(
        "SELECT id, word, puzzle_date, mode, format, letters, reason, status, created_at "
        "FROM bloom_word_reports WHERE status=? ORDER BY created_at DESC LIMIT ?", (status, limit))]


def resolve_report(conn: sqlite3.Connection, report_id: int, action: str, by: str | None = None) -> bool:
    """action: 'approve' (→ allow override) | 'block' (→ block override) | 'dismiss'."""
    status = {"approve": "approved", "block": "blocked", "dismiss": "dismissed"}.get(action)
    row = conn.execute("SELECT word FROM bloom_word_reports WHERE id=?", (report_id,)).fetchone()
    if not row or not status:
        return False
    if action == "approve":
        if not set_override(conn, row["word"], "allow", reason="report", by=by):
            return False   # can't allow (hard rule) — leave pending; dismiss instead
    elif action == "block":
        set_override(conn, row["word"], "block", reason="report", by=by)
    conn.execute("UPDATE bloom_word_reports SET status=? WHERE id=?", (status, report_id))
    conn.commit()
    return True
