"""Bloom — the daily word wheel. Locks the design/acceptance split:

  • DESIGN (deterministic, stored): wheel + tiers + pangram + Full-Bloom target,
    from the COMMON list. The PERMANENT guardrail — Flourishing reachable with
    common words — still holds.
  • ACCEPTANCE (broad + dynamic): every valid word buildable from the wheel,
    computed live as broad dict ∪ {allow} − {block}; runtime admin overrides +
    player reports drive curation with no deploy.
"""
import os
from pathlib import Path

import pytest
from fastapi.testclient import TestClient

from goodnews import bloom, games
from goodnews.db import connect, init_db

DATES = [f"2026-06-{d:02d}" for d in range(10, 25)]  # 15 sample days


@pytest.fixture(scope="module")
def designs():
    return {d: bloom.build_puzzle(d) for d in DATES}


@pytest.fixture
def conn(tmp_path):
    c = connect(str(tmp_path / "t.db"))
    init_db(c)
    c.execute("INSERT INTO users (email) VALUES ('a@b.c')")
    c.commit()
    return c


def _letters(p):
    return frozenset(p["center"]) | frozenset(p["outer"])


def _commons_for(p):
    """COMMON words for a center-mode wheel (the designed puzzle)."""
    L = _letters(p)
    return [w for w in bloom._COMMON if p["center"] in w and frozenset(w) <= L]


def _assert_no_answer_leak(resp):
    assert "words" not in resp
    assert resp["accepted"] and all(
        isinstance(h, str) and len(h) == 64 and set(h) <= set("0123456789abcdef")
        for h in resp["accepted"])


# --- DESIGN (deterministic, common-based) --------------------------------------

def test_build_is_deterministic():
    assert bloom.build_puzzle("2026-06-15") == bloom.build_puzzle("2026-06-15")


@pytest.mark.parametrize("date", DATES)
def test_design_shape(designs, date):
    p = designs[date]
    L = _letters(p)
    assert len(L) == 7 and "s" not in L
    assert p["center"] in L and len(p["outer"]) == 6
    assert bloom.MIN_COMMON_WORDS <= len(_commons_for(p)) <= bloom.MAX_COMMON_WORDS
    assert frozenset(p["pangram"]) == L                  # display pangram uses all 7
    assert p["pangram"] in bloom._COMMON and p["pangram"] not in bloom._AVOID


@pytest.mark.parametrize("date", DATES)
def test_PERMANENT_top_tier_reachable_with_common_words(designs, date):
    """Flourishing reachable from COMMON words alone — never obscure-word hunting."""
    p = designs[date]
    flourishing = next(t["score"] for t in p["tiers"] if t["name"] == "Flourishing")
    assert bloom.score_words(p, _commons_for(p)) >= flourishing


def test_tiers_are_8_30_70_of_common_and_max_is_common_total():
    p = bloom.build_puzzle("2026-06-15")
    assert [t["name"] for t in p["tiers"]] == ["Sprouting", "Budding", "Blooming", "Flourishing"]
    common_total = bloom.score_words(p, _commons_for(p))
    assert p["max_score"] == common_total                # Full Bloom = the designed puzzle
    flour = next(t["score"] for t in p["tiers"] if t["name"] == "Flourishing")
    assert flour == int(0.70 * common_total) and flour <= p["max_score"]


# --- ACCEPTANCE (broad + dynamic) ----------------------------------------------

def test_accept_is_broad_and_obeys_center_rule(conn):
    p = bloom.build_puzzle("2026-06-15")
    acc = bloom.accepted_words(conn, p["center"], p["outer"], require_center=True)
    L = _letters(p)
    for w in acc:
        assert len(w) >= 4 and "s" not in w and frozenset(w) <= L and p["center"] in w
    # broad accept is a SUPERSET of the common puzzle (bonus words beyond design)
    assert set(_commons_for(p)) <= set(acc)
    assert len(acc) > len(_commons_for(p))

def test_arraign_class_words_auto_accepted():
    # broad dict includes real-but-rare words without any include-list
    for w in ("arraign", "feign", "crwth"):
        assert w in set(bloom.ACCEPT)

def test_overrides_block_and_allow(conn):
    p = bloom.build_puzzle("2026-06-15")
    acc0 = set(bloom.accepted_words(conn, p["center"], p["outer"], True))
    victim = sorted(acc0)[0]
    bloom.set_override(conn, victim, "block", by="t")
    assert victim not in set(bloom.accepted_words(conn, p["center"], p["outer"], True))
    # allow a made-up letter-combo that fits the wheel + center
    fake = (p["center"] + "".join(p["outer"][:3]))[:5]
    if "s" not in fake and len(fake) >= 4:
        bloom.set_override(conn, fake, "allow", by="t")
        assert fake in set(bloom.accepted_words(conn, p["center"], p["outer"], True))
    bloom.clear_override(conn, victim)
    assert victim in set(bloom.accepted_words(conn, p["center"], p["outer"], True))

def test_allow_override_rejects_inert_hard_rule_words(conn):
    # an allow that could never count (too short / has 's') is rejected, not stored
    assert bloom.set_override(conn, "cat", "allow") is False      # < 4 letters
    assert bloom.set_override(conn, "roses", "allow") is False    # contains 's'
    assert bloom.set_override(conn, "bloom", "allow") is True     # valid → stored
    allow, _ = bloom.overrides(conn)
    assert allow == {"bloom"}
    # block stays permissive (can block anything)
    assert bloom.set_override(conn, "roses", "block") is True


def test_wild_accepts_words_without_center(conn):
    p = bloom.build_free("seed-w", "wild")
    acc = bloom.accepted_words(conn, p["center"], p["outer"], require_center=False)
    assert any(p["center"] not in w for w in acc)        # Wild's defining trait
    assert all(frozenset(w) <= _letters(p) for w in acc)


# --- responses + storage -------------------------------------------------------

def test_generate_is_idempotent_and_stored(conn):
    a = bloom.generate_bloom_puzzle(conn, "2026-06-15")
    assert a == bloom.generate_bloom_puzzle(conn, "2026-06-15") == bloom.stored_payload(conn, "2026-06-15")
    assert "words" not in a                              # design payload holds no answers

def test_response_no_leak_and_hash_roundtrip(conn):
    r = bloom.bloom_response(conn, "2026-06-15")
    _assert_no_answer_leak(r)
    p = bloom.stored_payload(conn, "2026-06-15")
    real = bloom.accepted_words(conn, p["center"], p["outer"], True)[0]
    assert bloom.word_hash("2026-06-15", real) in set(r["accepted"])
    assert bloom.word_hash("2026-06-15", "zzzzq") not in set(r["accepted"])
    assert r["max_score"] == p["max_score"]

def test_free_endpoint_resumes_and_leaks_nothing(api_app):
    tc = TestClient(api_app)
    r1 = tc.get("/api/puzzle/bloom/free?format=wild").json()
    seed = r1["seed"]
    assert r1["mode"] == "free" and r1["format"] == "wild" and seed
    r2 = tc.get(f"/api/puzzle/bloom/free?format=wild&seed={seed}").json()
    assert r2["center"] == r1["center"] and r2["outer"] == r1["outer"]
    _assert_no_answer_leak(r1)


# --- server-side state ---------------------------------------------------------

def test_sanitize_drops_junk_recomputes_score_and_full(conn):
    p = bloom.generate_bloom_puzzle(conn, "2026-06-15")
    acc = bloom.accepted_words(conn, p["center"], p["outer"], True)
    good = acc[:3]
    clean = games.sanitize_game_state(conn, "bloom", "", "2026-06-15",
                                      {"found": good + ["zzzz", "ab", good[0], 9], "score": 9999})
    assert sorted(clean["found"]) == sorted(set(good))
    assert clean["score"] == bloom.score_words(p, good)
    assert "full" not in clean
    # finding the whole common puzzle ⇒ Full Bloom (score ≥ max_score)
    full = games.sanitize_game_state(conn, "bloom", "", "2026-06-15", {"found": _commons_for(p)})
    assert full.get("full") is True

def test_merge_unions_found():
    m = games.merge_game_state("bloom", {"found": ["able", "bake"]}, {"found": ["bake", "tale"]})
    assert sorted(m["found"]) == ["able", "bake", "tale"]

def test_block_override_takes_effect_without_regen(conn):
    # the live response reflects an override with no puzzle regeneration
    p = bloom.generate_bloom_puzzle(conn, "2026-06-15")
    victim = bloom.accepted_words(conn, p["center"], p["outer"], True)[0]
    before = set(bloom.bloom_response(conn, "2026-06-15")["accepted"])
    bloom.set_override(conn, victim, "block", by="t")
    after = set(bloom.bloom_response(conn, "2026-06-15")["accepted"])
    assert bloom.word_hash("2026-06-15", victim) in before
    assert bloom.word_hash("2026-06-15", victim) not in after


# --- reports → admin queue → overrides -----------------------------------------

def test_report_then_approve_creates_allow_override(conn):
    assert bloom.add_report(conn, "arraign", "2026-06-15", "daily", "center", "aceglnr", "not in the word list")
    assert bloom.add_report(conn, "arraign", "2026-06-15", "daily", "center", "aceglnr", "x")  # dedup pending
    pending = bloom.list_reports(conn, "pending")
    assert len(pending) == 1 and pending[0]["word"] == "arraign"
    assert bloom.resolve_report(conn, pending[0]["id"], "approve", by="admin")
    allow, _ = bloom.overrides(conn)
    assert "arraign" in allow
    assert not bloom.list_reports(conn, "pending")
    assert bloom.list_reports(conn, "approved")

def test_report_block_creates_block_override(conn):
    bloom.add_report(conn, "uglyword", None, "free", "wild", "abcdefg", "x")
    rid = bloom.list_reports(conn, "pending")[0]["id"]
    bloom.resolve_report(conn, rid, "block", by="admin")
    _, block = bloom.overrides(conn)
    assert "uglyword" in block


# --- API: public report + admin endpoints --------------------------------------

@pytest.fixture
def api_app(tmp_path, monkeypatch):
    db = tmp_path / "t.sqlite3"
    monkeypatch.setenv("GOODNEWS_DB", str(db))
    monkeypatch.setenv("GOODNEWS_PUBLIC_BASE_URL", "http://testserver")
    monkeypatch.setenv("GOODNEWS_ADMIN_EMAILS", "admin@b.com")
    import importlib
    import goodnews.api as api
    importlib.reload(api)
    c = connect(str(db)); init_db(c); c.commit(); c.close()
    return api.create_app()


def _admin(app):
    tc = TestClient(app)
    sent = {}
    import goodnews.email_send as es
    orig = es.send_magic_link
    es.send_magic_link = lambda to, link: sent.update(link=link)
    try:
        tc.post("/api/auth/email/start", json={"email": "admin@b.com"})
        tc.post("/api/auth/email/verify", json={"token": sent["link"].split("token=")[1]})
    finally:
        es.send_magic_link = orig
    return tc


def test_public_report_then_admin_queue_flow(api_app):
    pub = TestClient(api_app)
    assert pub.post("/api/bloom/report", json={"word": "arraign", "date": "2026-06-15",
                    "mode": "daily", "format": "center", "letters": "aceglnr",
                    "reason": "not in the word list"}).json()["ok"]
    # admin-only queue
    assert TestClient(api_app).get("/api/admin/bloom/reports").status_code == 401
    tc = _admin(api_app)
    q = tc.get("/api/admin/bloom/reports").json()
    assert len(q["reports"]) == 1
    rid = q["reports"][0]["id"]
    assert tc.post(f"/api/admin/bloom/reports/{rid}", json={"action": "approve"}).json()["ok"]
    ovr = tc.get("/api/admin/bloom/reports").json()["overrides"]
    assert any(o["word"] == "arraign" and o["action"] == "allow" for o in ovr)
