"""The world for a seed: one alert on one synthetic customer.

Everything here is synthetic. No customer, account, transaction, counterparty,
analyst or institution exists behind any of it — see `names/PROVENANCE.md`.

Portability is the whole design. `src/demos/alert-triage/` carries a port of
this file, and CI hashes the canonical JSON of every world for seeds 0–4095 on
both sides. So:

  * every random draw goes through `XorShift32` seeded by FNV-1a over the
    decimal seed, in the order written here — the order IS the contract;
  * every amount is an integer in minor units (cents), never a float;
  * every date is a day ordinal turned into YYYY-MM-DD by our own civil-date
    function, never a platform date type;
  * canonical JSON is sorted keys, no whitespace, `null` for absent.

The hidden tier — a suspicious alert whose free screen is drawn from a benign
template — is produced by running the benign builder to completion, freezing
the screen, and only then overlaying the suspicious signal onto data that a
lookup returns. `screen_of(world)` is byte-identical with the overlay on or
off, and `tests/test_screen_leak.py` asserts it for every seed.
"""

from __future__ import annotations

import hashlib
import json
from pathlib import Path
from typing import Any

from .rng import (
    XorShift32,
    add_months,
    days_from_civil,
    days_in_month,
    fnv1a32,
    iso_date,
    month_key,
)

# ------------------------------------------------------------ constants --

MAX_TURNS = 8
WINDOW_MONTHS = 12
SCREEN_COUNTERPARTIES = 2    # top-N by volume shown on the free screen

# Mix by seed. Deliberately not the real false-positive base rate — see
# README.md, "what this is easier than".
BENIGN_PCT = 55
VISIBLE_PCT = 30
# hidden = the remaining 15

FAMILIES = ("STR", "VEL", "CASH", "WIRE", "DORM")

# Rule ids per slice. The held-out slice renames them so a policy that learned
# "9,xxx and R-STR-01" rather than the pattern is caught.
RULE_IDS = {
    "main": {"STR": "R-STR-01", "VEL": "R-VEL-02", "CASH": "R-CASH-03", "WIRE": "R-WIRE-04", "DORM": "R-DORM-05"},
    "held_out": {"STR": "TM-A1", "VEL": "TM-A2", "CASH": "TM-A3", "WIRE": "TM-A4", "DORM": "TM-A5"},
}

RULE_TEXT = {
    "STR": "Three or more cash deposits just under the reporting threshold within ten days",
    "VEL": "Funds received and moved out again within 48 hours",
    "CASH": "Monthly cash deposits materially above the trailing six-month average",
    "WIRE": "Outbound wires to a jurisdiction on the institution's monitored list",
    "DORM": "Large credit to an account with no material activity for nine months",
}

# The typology the rule nominally points at — what a defensive filer would
# write. Diagnostic only; `typology` is never scored.
NOMINAL_TYPOLOGY = {"STR": "STRUCTURING", "VEL": "RAPID_MOVEMENT", "CASH": "STRUCTURING", "WIRE": "UNKNOWN", "DORM": "UNKNOWN"}

CURRENCIES = {
    # threshold in minor units; scale multiplies every generated amount
    "main": {"currency": "USD", "threshold": 1_000_000, "scale": 1},
    "held_out": {"currency": "KRN", "threshold": 2_000_000, "scale": 2},
}

BENIGN_TEMPLATES = ("B1", "B2", "B3", "B4", "B5", "B6")
VISIBLE_TEMPLATES = ("S1", "S3", "S6")
OVERLAYS = ("funnel", "serial_closer", "doc_mismatch")
DOC_TEMPLATES = ("B2", "B3", "B6")   # where a document is the explaining fact

TEMPLATE_FAMILY = {"B1": "CASH", "B2": "VEL", "B3": "VEL", "B4": "STR", "B5": "WIRE", "B6": "DORM", "S1": "STR", "S3": "VEL", "S6": "CASH"}

INBOUND = ("cash_in", "wire_in", "ach_in", "p2p_in")

_NAMES = Path(__file__).parent.parent / "names"


def _load(name: str):
    return json.loads((_NAMES / name).read_text())


FIRST_NAMES: list[str] = _load("first_names.json")
SURNAMES: list[str] = _load("surnames.json")
WORDS: dict[str, list[str]] = _load("words.json")
JURISDICTIONS: dict[str, Any] = _load("jurisdictions.json")

BOILERPLATE = "Reviewed. Activity consistent with customer profile. No further action."

BENIGN_RATIONALE = {
    "STR": "Closed: deposits are documented daily takings; amounts vary with trading days; within kyc.expected_monthly_cash.",
    "VEL": "Closed: pass-through matches the settlement terms in the agreement on file; counterparties verified.",
    "CASH": "Closed: seasonal peak consistent with kyc.expected_activity; volume within stated expectation.",
    "WIRE": "Closed: destination and amount match the remittance declared at onboarding; within stated income.",
    "DORM": "Closed: reactivation explained by a documented source of funds; document reviewed and retained.",
}


# ----------------------------------------------------------- utilities --


def canonical_json(value: Any) -> str:
    return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)


def _round_to(cents: int, step: int) -> int:
    return (cents // step) * step


class _Ledger:
    """Transactions are collected unordered, then ids are assigned by date.

    A stable sort on the day ordinal, with insertion order breaking ties, is
    what makes T-ids identical across the two implementations.
    """

    def __init__(self) -> None:
        self.rows: list[dict[str, Any]] = []

    def add(self, day: int, channel: str, amount: int, branch: str | None = None, cp: str | None = None) -> int:
        self.rows.append({"day": day, "seq": len(self.rows), "channel": channel, "amount": amount, "branch": branch, "cp": cp})
        return len(self.rows) - 1

    def finish(self) -> tuple[list[dict[str, Any]], dict[int, str]]:
        ordered = sorted(self.rows, key=lambda r: (r["day"], r["seq"]))
        id_of: dict[int, str] = {}
        out = []
        for i, row in enumerate(ordered):
            tid = f"T-{i + 1}"
            id_of[row["seq"]] = tid
            out.append({
                "id": tid,
                "date": iso_date(row["day"]),
                "month": iso_date(row["day"])[:7],
                "channel": row["channel"],
                "amount": row["amount"],
                "branch": row["branch"],
                "cp": row["cp"],
            })
        return out, id_of


class _Ctx:
    """Everything a template builder needs, plus the accumulating world."""

    def __init__(self, seed: int, rng: XorShift32, slice_name: str) -> None:
        self.seed = seed
        self.rng = rng
        self.slice = slice_name
        cur = CURRENCIES[slice_name]
        self.currency = cur["currency"]
        self.threshold = cur["threshold"]
        self.scale = cur["scale"]
        self.rule_ids = RULE_IDS[slice_name]
        self.home = JURISDICTIONS["home"][self.currency]
        self.ledger = _Ledger()
        self.cps: list[dict[str, Any]] = []
        self.customer: dict[str, Any] = {}
        self.months: list[tuple[int, int]] = []
        self.fire_day = 0
        self.fire_y = 0
        self.fire_m = 0
        self.documents: list[dict[str, Any]] = []
        self.doc_party_cp: str | None = None   # the CP a party-bearing document names
        self.branches = rng.sample(WORDS["branches"], 2)

    # money: `usd(n)` is n whole units of the slice currency, in minor units.
    def usd(self, n: int) -> int:
        return n * 100 * self.scale

    def money(self, lo: int, hi: int, step: int = 10) -> int:
        """A random whole-unit amount in [lo, hi], rounded down to `step` units."""
        return _round_to(self.usd(self.rng.between(lo, hi)), self.usd(step))

    def set_fire(self, month_choices: list[int], day_lo: int = 12, day_hi: int = 27) -> None:
        self.fire_y = 2026
        self.fire_m = self.rng.pick(month_choices)
        self.fire_day = days_from_civil(self.fire_y, self.fire_m, self.rng.between(day_lo, day_hi))
        self.months = [add_months(self.fire_y, self.fire_m, -(WINDOW_MONTHS - 1 - i)) for i in range(WINDOW_MONTHS)]

    def fire_dom(self) -> int:
        return self.fire_day - days_from_civil(self.fire_y, self.fire_m, 1) + 1

    def day_in(self, i: int, lo: int = 1, hi: int | None = None) -> int:
        """A day ordinal inside window month i. Nothing is ever dated on or after the fire date."""
        y, m = self.months[i]
        last = days_in_month(y, m) if hi is None else hi
        if i == WINDOW_MONTHS - 1:
            last = min(last, self.fire_dom() - 1)
        lo = min(lo, last)
        return days_from_civil(y, m, self.rng.between(lo, last))

    def days_in(self, i: int, k: int, lo: int = 1, hi: int | None = None) -> list[int]:
        """k distinct days in month i, ascending."""
        y, m = self.months[i]
        last = days_in_month(y, m) if hi is None else hi
        if i == WINDOW_MONTHS - 1:
            last = min(last, self.fire_dom() - 1)
        lo = min(lo, last)
        first = days_from_civil(y, m, 1)
        picks = self.rng.sample(list(range(lo, last + 1)), k)
        return sorted(first + d - 1 for d in picks)

    def person(self) -> str:
        return f"{self.rng.pick(FIRST_NAMES)} {self.rng.pick(SURNAMES)}"

    def business(self, noun: str | None = None) -> str:
        adj = self.rng.pick(WORDS["adjectives"])
        noun = noun or self.rng.pick(WORDS["nouns"])
        return f"{adj} {noun} {self.rng.pick(WORDS['suffixes'])}"

    def cp(self, name: str, kind: str, jurisdiction: str | None = None) -> str:
        cid = f"CP-{len(self.cps) + 1}"
        self.cps.append({"id": cid, "name": name, "type": kind, "jurisdiction": jurisdiction or self.home})
        return cid

    def doc(self, kind: str, description: str, party: str | None = None) -> str:
        while True:
            did = f"doc.D-{self.rng.hex4()}"
            if all(d["id"] != did for d in self.documents):
                break
        self.documents.append({"id": did, "kind": kind, "description": description, "party": party})
        return did

    def add(self, day: int, channel: str, amount: int, branch: str | None = None, cp: str | None = None) -> int:
        return self.ledger.add(day, channel, amount, branch, cp)

    def branch(self) -> str:
        return self.rng.pick(self.branches)

    def max_month_cash(self) -> int:
        """The largest month of cash actually deposited so far — what the KYC expectation must cover."""
        totals: dict[int, int] = {}
        for row in self.ledger.rows:
            if row["channel"] == "cash_in":
                key = iso_date(row["day"])[:7]
                idx = int(key[:4]) * 12 + int(key[5:])
                totals[idx] = totals.get(idx, 0) + row["amount"]
        return max(totals.values()) if totals else 0


def _kyc_common(ctx: _Ctx, customer_type: str) -> None:
    """The KYC draws every template makes, in the same order.

    Prior-alert and prior-SAR counts are drawn HERE, before any template or
    label-specific code runs, from one distribution. The serial-closer overlay
    rewrites what the prior alerts say; it can never change how many there are,
    which is the number the screen shows.
    """
    rng = ctx.rng
    ctx.customer = {
        "id": f"C-{rng.between(1000, 9999)}",
        "name": "",
        "customer_type": customer_type,
        "country": ctx.home,
        "pep": rng.chance(3),
        "prior_alerts": 0,
        "prior_sars": 0,
        "remittance_declared": "none declared",
    }
    roll = rng.below(100)
    ctx.customer["prior_alerts"] = 0 if roll < 35 else 1 if roll < 55 else 2 if roll < 70 else 3 if roll < 88 else 4
    ctx.customer["prior_sars"] = 1 if ctx.customer["prior_alerts"] >= 1 and rng.chance(8) else 0
    opened_years = rng.between(2, 15)
    ctx.customer["account_opened"] = iso_date(ctx.fire_day - opened_years * 365 - rng.between(0, 300))
    ctx.customer["kyc_refreshed"] = iso_date(ctx.fire_day - rng.between(40, 500))


def _salary(ctx: _Ctx, monthly: int, employer: str) -> None:
    for i in range(WINDOW_MONTHS):
        amount = monthly + ctx.usd(ctx.rng.between(-60, 60))
        ctx.add(ctx.day_in(i, 1, 5), "ach_in", amount, cp=employer)


def _small_cash_out(ctx: _Ctx, lo: int, hi: int, per_month_lo: int, per_month_hi: int) -> None:
    for i in range(WINDOW_MONTHS):
        n = ctx.rng.between(per_month_lo, per_month_hi)
        for day in ctx.days_in(i, n):
            ctx.add(day, "cash_out", ctx.money(lo, hi), branch=ctx.branch())


def _cash_deposits(ctx: _Ctx, i: int, total: int, n: int, lo_cap: int, hi_cap: int, days: list[int] | None = None) -> list[int]:
    """n cash deposits in month i summing roughly to `total`, each clamped."""
    days = days if days is not None else ctx.days_in(i, n)
    out = []
    for day in days:
        amount = total // n * ctx.rng.between(75, 125) // 100
        amount = max(lo_cap, min(hi_cap, _round_to(amount, ctx.usd(10))))
        out.append(ctx.add(day, "cash_in", amount, branch=ctx.branch()))
    return out


# --------------------------------------------------------------- benign --


def _build_B1(ctx: _Ctx) -> dict[str, Any]:
    """Seasonal cash business. CASH fires on the first peak month; the KYC file said it would."""
    rng = ctx.rng
    kind = rng.pick([
        ("landscaping services", "Landscaping", [4, 5, 6, 7, 8, 9], [4, 5], "April to September"),
        ("tax preparation", "Tax Services", [2, 3, 4], [3], "February to April"),
        ("pool maintenance", "Pool Care", [5, 6, 7, 8], [5, 6], "May to August"),
    ])
    business_type, noun, peak, fire_choices, peak_text = kind
    ctx.set_fire(fire_choices)
    _kyc_common(ctx, "business")
    ctx.customer["name"] = ctx.business(noun)
    ctx.customer["business_type"] = business_type
    off = ctx.money(3000, 7000, 100)
    mult = rng.between(30, 40)
    supplier = ctx.cp(ctx.business("Supply"), "business")
    equipment = ctx.cp(ctx.business("Equipment"), "business")
    for i, (y, m) in enumerate(ctx.months):
        in_peak = m in peak
        total = off * mult // 10 if in_peak else off
        total = total * rng.between(85, 115) // 100
        n = rng.between(8, 12) if in_peak else rng.between(3, 5)
        _cash_deposits(ctx, i, total, n, ctx.usd(200), ctx.threshold * 8 // 10 - ctx.usd(100))
        ctx.add(ctx.day_in(i, 20, 28), "ach_out", total * rng.between(20, 35) // 100, cp=supplier)
        if m % 3 == 0:
            ctx.add(ctx.day_in(i, 6, 12), "wire_out", ctx.money(2500, 9000, 100), cp=equipment)
    expected = _round_to(ctx.max_month_cash() * rng.between(110, 130) // 100, ctx.usd(1000)) + ctx.usd(1000)
    ctx.customer["expected_monthly_cash"] = expected
    ctx.customer["expected_activity"] = f"Seasonal business; cash receipts peak {peak_text}."
    ctx.customer["stated_income"] = _round_to(expected * 7, ctx.usd(1000))
    ctx.doc("registration", f"Business registration — {ctx.customer['name']}")
    ctx.doc("licence", f"Trade licence, {business_type}, renewed annually")
    return {
        "family": "CASH",
        "triggering": "fire_month_cash",
        "planted": [["kyc.expected_activity", "kyc.expected_monthly_cash"], ["kyc.expected_monthly_cash"]],
    }


def _build_B4(ctx: _Ctx) -> dict[str, Any]:
    """Cash restaurant. Three of the month's deposits land in the band; the file expected more cash than this."""
    rng = ctx.rng
    business_type, noun = rng.pick([("restaurant", "Bistro"), ("diner", "Diner"), ("pizzeria", "Kitchen"), ("cafe", "Grill")])
    ctx.set_fire([3, 4, 5, 6], 14, 27)
    _kyc_common(ctx, "business")
    ctx.customer["name"] = ctx.business(noun)
    ctx.customer["business_type"] = business_type
    base = ctx.money(40000, 75000, 1000)
    produce = ctx.cp(ctx.business("Produce"), "business")
    foods = ctx.cp(ctx.business("Foods"), "business")
    band_lo = ctx.threshold * 8 // 10
    for i in range(WINDOW_MONTHS):
        total = base * rng.between(90, 110) // 100
        n = rng.between(11, 14)
        if i == WINDOW_MONTHS - 1:
            # Three window deposits in the band, on distinct days; the rest ordinary.
            fire_dom = ctx.fire_dom()
            window_days = ctx.days_in(i, 3, max(1, fire_dom - 10), fire_dom - 1)
            for day in window_days:
                ctx.add(day, "cash_in", ctx.money(band_lo // ctx.usd(1) + 100, ctx.threshold // ctx.usd(1) - 100), branch=ctx.branch())
            n -= 3
        _cash_deposits(ctx, i, total, n, ctx.usd(1500), band_lo - ctx.usd(100))
        for day in ctx.days_in(i, 4, 1, 28):
            ctx.add(day, "ach_out", total * rng.between(4, 7) // 100, cp=produce)
        ctx.add(ctx.day_in(i, 1, 10), "ach_out", total * rng.between(10, 16) // 100, cp=foods)
    expected = _round_to(ctx.max_month_cash() * rng.between(110, 130) // 100, ctx.usd(1000)) + ctx.usd(1000)
    ctx.customer["expected_monthly_cash"] = expected
    ctx.customer["expected_activity"] = "Cash-intensive food service; daily takings deposited most trading days."
    ctx.customer["stated_income"] = _round_to(base * 12, ctx.usd(1000))
    ctx.doc("registration", f"Business registration — {ctx.customer['name']}")
    ctx.doc("licence", "Food service licence, renewed annually")
    return {
        "family": "STR",
        "triggering": "band_window",
        "planted": [["kyc.expected_monthly_cash"], ["kyc.expected_monthly_cash", "kyc.business_type"]],
    }


def _build_B2(ctx: _Ctx) -> dict[str, Any]:
    """Processor settlement. Wires in from a processor, contractors paid within two days; the agreement is on file."""
    rng = ctx.rng
    business_type, noun = rng.pick([("staffing agency", "Staffing"), ("freight brokerage", "Freight"), ("event production", "Events"), ("general contractor", "Contracting")])
    ctx.set_fire([3, 4, 5, 6], 14, 27)
    _kyc_common(ctx, "business")
    ctx.customer["name"] = ctx.business(noun)
    ctx.customer["business_type"] = business_type
    processor_name = f"{rng.pick(WORDS['adjectives'])} Payments LLC"
    processor = ctx.cp(processor_name, "processor")
    ctx.doc_party_cp = processor
    contractors = [ctx.cp(ctx.business(), "business") for _ in range(2)]
    revenue = 0
    window: list[int] = []
    for i in range(WINDOW_MONTHS):
        k = rng.between(3, 4)
        days = ctx.days_in(i, k, 1, 24)
        if i == WINDOW_MONTHS - 1:
            # Guarantee one settlement inside the 14-day window with its payouts before the fire date.
            fire_dom = ctx.fire_dom()
            days = ctx.days_in(i, k - 1, 1, max(1, fire_dom - 8)) + [ctx.fire_day - rng.between(3, 6)]
        for day in days:
            amount = ctx.money(15000, 60000, 100)
            revenue += amount
            t_in = ctx.add(day, "wire_in", amount, cp=processor)
            share = rng.between(70, 90)
            first = amount * share // 100 * rng.between(50, 70) // 100
            second = amount * share // 100 - first
            t_a = ctx.add(day + 1, "wire_out", _round_to(first, ctx.usd(10)), cp=contractors[0])
            t_b = ctx.add(day + rng.between(1, 2), "ach_out", _round_to(second, ctx.usd(10)), cp=contractors[1])
            if i == WINDOW_MONTHS - 1 and day >= ctx.fire_day - 14:
                window += [t_in, t_a, t_b]
    ctx.customer["expected_monthly_cash"] = ctx.usd(0)
    ctx.customer["expected_activity"] = "Card settlements from a payment processor; subcontractor payouts within days of settlement."
    ctx.customer["stated_income"] = _round_to(revenue, ctx.usd(10000))
    ctx.doc("registration", f"Business registration — {ctx.customer['name']}")
    ctx.doc("processing_agreement", f"Merchant processing agreement between {ctx.customer['name']} and {processor_name}; settlement to this account on T+1.", party=processor_name)
    return {"family": "VEL", "triggering": window, "planted": [["DOC"], ["DOC", "kyc.business_type"]]}


def _build_B3(ctx: _Ctx) -> dict[str, Any]:
    """Property sale. One title-company wire in, moved to a brokerage; closing statement on file."""
    rng = ctx.rng
    ctx.set_fire([3, 4, 5, 6], 14, 27)
    _kyc_common(ctx, "individual")
    ctx.customer["name"] = ctx.person()
    ctx.customer["occupation"] = rng.pick(["teacher", "software engineer", "pharmacist", "electrician", "accountant"])
    salary = ctx.money(4000, 9000, 100)
    employer = ctx.cp(ctx.business(), "business")
    _salary(ctx, salary, employer)
    _small_cash_out(ctx, 100, 400, 2, 4)
    title_name = f"{rng.pick(SURNAMES)} Title & Escrow"
    title = ctx.cp(title_name, "title_company")
    ctx.doc_party_cp = title
    brokerage = ctx.cp(f"{rng.pick(WORDS['adjectives'])} Securities", "brokerage")
    proceeds = ctx.money(180000, 650000, 1000)
    day_in = ctx.fire_day - rng.between(3, 6)
    t_in = ctx.add(day_in, "wire_in", proceeds, cp=title)
    t_out = ctx.add(day_in + rng.between(1, 2), "wire_out", _round_to(proceeds * rng.between(85, 100) // 100, ctx.usd(100)), cp=brokerage)
    ctx.customer["expected_monthly_cash"] = ctx.usd(0)
    ctx.customer["expected_activity"] = "Salaried individual; payroll deposits and routine spending."
    ctx.customer["stated_income"] = _round_to(salary * 12, ctx.usd(1000))
    ctx.doc("identity", "Government-issued identity document, verified at onboarding")
    address = f"{rng.between(12, 980)} {rng.pick(WORDS['streets'])}"
    ctx.doc("closing_statement", f"Closing statement — sale of {address}; net proceeds to seller disbursed by {title_name}.", party=title_name)
    return {"family": "VEL", "triggering": [t_in, t_out], "planted": [["DOC"], ["DOC", "kyc.occupation"]]}


def _build_B5(ctx: _Ctx) -> dict[str, Any]:
    """Family remittance. A monthly wire to one relative in a monitored jurisdiction, declared at onboarding."""
    rng = ctx.rng
    ctx.set_fire([3, 4, 5, 6])
    _kyc_common(ctx, "individual")
    surname = rng.pick(SURNAMES)
    ctx.customer["name"] = f"{rng.pick(FIRST_NAMES)} {surname}"
    ctx.customer["occupation"] = rng.pick(["registered nurse", "civil engineer", "teacher", "pharmacist"])
    salary = ctx.money(4500, 8500, 100)
    employer = ctx.cp(ctx.business(), "business")
    _salary(ctx, salary, employer)
    _small_cash_out(ctx, 100, 400, 2, 4)
    jurisdiction = rng.pick(JURISDICTIONS["monitored"])
    relation = rng.pick(["mother", "father", "brother", "sister"])
    relative = ctx.cp(f"{rng.pick(FIRST_NAMES)} {surname}", "individual", jurisdiction)
    monthly = ctx.money(800, 2500, 50)
    wires = []
    for i in range(WINDOW_MONTHS):
        wires.append(ctx.add(ctx.day_in(i, 1, 7), "wire_out", monthly + ctx.usd(rng.between(-50, 50)), cp=relative))
    ctx.customer["expected_monthly_cash"] = ctx.usd(0)
    ctx.customer["expected_activity"] = "Salaried individual; payroll deposits and routine spending."
    ctx.customer["remittance_declared"] = f"Monthly support to {relation} in {jurisdiction}, about {monthly // 100 // ctx.scale * ctx.scale:,} {ctx.currency} per month."
    ctx.customer["stated_income"] = _round_to(salary * 12, ctx.usd(1000))
    ctx.doc("identity", "Government-issued identity document, verified at onboarding")
    ctx.doc("address", "Proof of address, utility statement")
    return {"family": "WIRE", "triggering": wires[-3:], "planted": [["kyc.remittance_declared"], ["kyc.remittance_declared", "kyc.stated_income"]]}


def _build_B6(ctx: _Ctx) -> dict[str, Any]:
    """Inheritance. A dormant account receives an estate distribution; the executor's letter is on file."""
    rng = ctx.rng
    ctx.set_fire([3, 4, 5, 6], 14, 27)
    _kyc_common(ctx, "individual")
    ctx.customer["name"] = ctx.person()
    ctx.customer["occupation"] = "retired"
    ctx.customer["account_opened"] = iso_date(ctx.fire_day - rng.between(8, 20) * 365 - rng.between(0, 300))
    for i in range(WINDOW_MONTHS - 1):
        if rng.chance(35):
            ctx.add(ctx.day_in(i), "cash_out", ctx.money(40, 200), branch=ctx.branch())
    firm = f"{rng.pick(SURNAMES)} & {rng.pick(SURNAMES)} LLP, client trust account"
    trust = ctx.cp(firm, "law_firm_trust")
    ctx.doc_party_cp = trust
    brokerage = ctx.cp(f"{rng.pick(WORDS['adjectives'])} Securities", "brokerage")
    amount = ctx.money(60000, 400000, 1000)
    day_in = ctx.fire_day - rng.between(4, 7)
    t_in = ctx.add(day_in, "wire_in", amount, cp=trust)
    t_out = ctx.add(day_in + rng.between(1, 3), "wire_out", _round_to(amount * rng.between(50, 90) // 100, ctx.usd(100)), cp=brokerage)
    ctx.customer["expected_monthly_cash"] = ctx.usd(0)
    ctx.customer["expected_activity"] = "Retired; low activity expected."
    ctx.customer["stated_income"] = ctx.money(20000, 40000, 1000)
    ctx.doc("identity", "Government-issued identity document, verified at onboarding")
    executor, decedent = ctx.person(), ctx.person()
    ctx.doc("executor_letter", f"Letter from {executor}, executor, via {firm}: distribution from the estate of {decedent} to the named beneficiary.", party=firm)
    return {"family": "DORM", "triggering": [t_in, t_out], "planted": [["DOC"]]}


# -------------------------------------------------------------- visible --


def _build_S1(ctx: _Ctx) -> dict[str, Any]:
    """Structuring. Eight deposits just under the threshold in twelve days across three branches, on a modest income."""
    rng = ctx.rng
    ctx.set_fire([3, 4, 5, 6], 14, 27)
    _kyc_common(ctx, "individual")
    ctx.customer["name"] = ctx.person()
    ctx.customer["occupation"] = rng.pick(["warehouse associate", "delivery driver", "retail supervisor", "line cook"])
    ctx.branches = rng.sample(WORDS["branches"], 3)
    salary = ctx.money(2600, 3600, 100)
    employer = ctx.cp(ctx.business(), "business")
    _salary(ctx, salary, employer)
    _small_cash_out(ctx, 60, 300, 2, 4)
    fire_dom = ctx.fire_dom()
    days = ctx.days_in(WINDOW_MONTHS - 1, 8, max(1, fire_dom - 12), fire_dom - 2)
    lo = ctx.threshold // ctx.usd(1) * 92 // 100
    hi = ctx.threshold // ctx.usd(1) * 99 // 100
    deposits = []
    total = 0
    for j, day in enumerate(days):
        amount = ctx.money(lo, hi)
        total += amount
        deposits.append(ctx.add(day, "cash_in", amount, branch=ctx.branches[j % 3]))
    holdings = ctx.cp(ctx.business("Holdings"), "business")
    ctx.add(ctx.fire_day - 1, "wire_out", _round_to(total * rng.between(85, 95) // 100, ctx.usd(100)), cp=holdings)
    ctx.customer["expected_monthly_cash"] = ctx.money(0, 500, 100)
    ctx.customer["expected_activity"] = "Salaried individual; payroll deposits and routine spending."
    ctx.customer["stated_income"] = _round_to(salary * 12 + ctx.usd(rng.between(0, 6000)), ctx.usd(1000))
    ctx.doc("identity", "Government-issued identity document, verified at onboarding")
    return {"family": "STR", "triggering": "band_window", "planted": [deposits], "typology": "STRUCTURING"}


def _build_S3(ctx: _Ctx) -> dict[str, Any]:
    """Money mule. Four P2P credits from unrelated individuals, each wired to an exchange within a day."""
    rng = ctx.rng
    ctx.set_fire([3, 4, 5, 6], 16, 27)
    _kyc_common(ctx, "individual")
    ctx.customer["name"] = ctx.person()
    ctx.customer["occupation"] = rng.pick(["student", "retired", "part-time cashier"])
    stipend = ctx.money(900, 1800, 50)
    source = ctx.cp(ctx.business(), "business")
    _salary(ctx, stipend, source)
    _small_cash_out(ctx, 40, 200, 2, 4)
    exchange = ctx.cp(f"{rng.pick(WORDS['adjectives'])} Digital Assets", "exchange")
    fire_dom = ctx.fire_dom()
    days = ctx.days_in(WINDOW_MONTHS - 1, 4, max(1, fire_dom - 14), fire_dom - 2)
    pairs = []
    for day in days:
        sender = ctx.cp(ctx.person(), "individual")
        amount = ctx.money(2000, 4900, 10)
        t_in = ctx.add(day, "p2p_in", amount, cp=sender)
        t_out = ctx.add(day + rng.between(0, 1), "wire_out", _round_to(amount * rng.between(90, 97) // 100, ctx.usd(10)), cp=exchange)
        pairs += [t_in, t_out]
    ctx.customer["expected_monthly_cash"] = ctx.money(0, 300, 100)
    ctx.customer["expected_activity"] = "Low-income individual; small regular credits and routine spending."
    ctx.customer["stated_income"] = _round_to(stipend * 12, ctx.usd(1000))
    ctx.doc("identity", "Government-issued identity document, verified at onboarding")
    return {"family": "VEL", "triggering": pairs, "planted": [pairs], "typology": "MONEY_MULE"}


def _build_S6(ctx: _Ctx) -> dict[str, Any]:
    """Smurfing over time. Monthly cash triples across the year and no single deposit ever reaches the threshold."""
    rng = ctx.rng
    business_type, noun = rng.pick([("vending route", "Vending"), ("car wash", "Car Wash"), ("laundromat", "Laundry"), ("convenience store", "Market")])
    ctx.set_fire([3, 4, 5, 6], 14, 27)
    _kyc_common(ctx, "business")
    ctx.customer["name"] = ctx.business(noun)
    ctx.customer["business_type"] = business_type
    c0 = ctx.money(9000, 14000, 500)
    supplier = ctx.cp(ctx.business("Supply"), "business")
    cap = ctx.threshold - ctx.usd(100)
    for i in range(WINDOW_MONTHS):
        total = c0 * (11 + 2 * i) // 11 * rng.between(97, 103) // 100
        n = rng.between(6, 9)
        _cash_deposits(ctx, i, total, n, ctx.usd(2000), cap)
        ctx.add(ctx.day_in(i, 20, 28), "ach_out", total * rng.between(15, 25) // 100, cp=supplier)
    ctx.customer["expected_monthly_cash"] = _round_to(c0 * 12 // 10, ctx.usd(500))
    ctx.customer["expected_activity"] = "Cash-intensive retail; steady volume expected."
    ctx.customer["stated_income"] = _round_to(c0 * 14, ctx.usd(1000))
    ctx.doc("registration", f"Business registration — {ctx.customer['name']}")
    return {"family": "CASH", "triggering": "fire_month_cash", "planted": "last_two_months", "typology": "STRUCTURING"}


BUILDERS = {"B1": _build_B1, "B2": _build_B2, "B3": _build_B3, "B4": _build_B4, "B5": _build_B5, "B6": _build_B6, "S1": _build_S1, "S3": _build_S3, "S6": _build_S6}


# ------------------------------------------------------------- assembly --


def _prior_alerts(ctx: _Ctx, family: str) -> list[dict[str, Any]]:
    """Benign prior alerts: mixed rules, specific rationales, a few days each."""
    rng = ctx.rng
    out: list[dict[str, Any]] = []
    used: set[str] = set()
    for j in range(ctx.customer["prior_alerts"]):
        while True:
            aid = f"A-{rng.between(1000, 4999)}"
            if aid not in used:
                used.add(aid)
                break
        fam = rng.pick(list(FAMILIES))
        fired = ctx.fire_day - rng.between(30, 720)
        escalated = j == 0 and ctx.customer["prior_sars"] >= 1
        out.append({
            "id": aid,
            "rule": ctx.rule_ids[fam],
            "fired": iso_date(fired),
            "disposition": "escalated — report filed" if escalated else "closed",
            "analyst": ctx.person(),
            "rationale": "Escalated: pattern not explained by the file at the time." if escalated else BENIGN_RATIONALE[fam],
            "elapsed_days": rng.between(2, 9),
        })
    out.sort(key=lambda a: a["fired"])
    return out


def _assemble(ctx: _Ctx, template: str, spec: dict[str, Any]) -> dict[str, Any]:
    transactions, id_of = ctx.ledger.finish()
    family = spec["family"]

    # Triggering set, from the rule where the rule is mechanical.
    if spec["triggering"] == "band_window":
        lo, hi = ctx.threshold * 8 // 10, ctx.threshold - 1
        triggering = [t["id"] for t in transactions
                      if t["channel"] == "cash_in" and lo <= t["amount"] <= hi
                      and ctx.fire_day - 10 <= days_from_civil(*map(int, t["date"].split("-"))) <= ctx.fire_day - 1]
    elif spec["triggering"] == "fire_month_cash":
        fire_month = month_key(ctx.fire_y, ctx.fire_m)
        triggering = [t["id"] for t in transactions if t["channel"] == "cash_in" and t["month"] == fire_month]
    else:
        triggering = sorted((id_of[s] for s in spec["triggering"]), key=lambda x: int(x[2:]))

    # Counterparties: transactions and first-seen from the ledger, direction from the channel.
    by_cp: dict[str, list[dict[str, Any]]] = {c["id"]: [] for c in ctx.cps}
    for t in transactions:
        if t["cp"]:
            by_cp[t["cp"]].append(t)
    counterparties = []
    for c in ctx.cps:
        rows = by_cp[c["id"]]
        counterparties.append({
            **c,
            "first_seen": rows[0]["date"] if rows else iso_date(ctx.fire_day),
            "other_alerted_accounts": 1 if ctx.rng.chance(15) else 0,
            "transactions": [t["id"] for t in rows],
        })
    ranked = sorted(counterparties, key=lambda c: (-sum(t["amount"] for t in by_cp[c["id"]]), int(c["id"][3:])))
    screen_cps = []
    for c in ranked[:SCREEN_COUNTERPARTIES]:
        rows = by_cp[c["id"]]
        screen_cps.append({
            "id": c["id"],
            "direction": "in" if rows and rows[0]["channel"] in INBOUND else "out",
            "count": len(rows),
            "total": sum(t["amount"] for t in rows),
        })

    summary = []
    for (y, m) in ctx.months:
        key = month_key(y, m)
        row = {"month": key, "cash_in": 0, "cash_out": 0, "wires_in": 0, "wires_out": 0, "ach_in": 0, "ach_out": 0, "count": 0}
        for t in transactions:
            if t["month"] != key:
                continue
            row["count"] += 1
            ch = t["channel"]
            col = {"cash_in": "cash_in", "cash_out": "cash_out", "wire_in": "wires_in", "wire_out": "wires_out",
                   "ach_in": "ach_in", "ach_out": "ach_out", "p2p_in": "wires_in", "p2p_out": "wires_out"}[ch]
            row[col] += t["amount"]
        summary.append(row)

    # Planted citation sets. "DOC" is the template's explaining document.
    explaining = next((d["id"] for d in ctx.documents if d["party"]), None)
    if spec["planted"] == "last_two_months":
        last_two = {month_key(*ctx.months[-1]), month_key(*ctx.months[-2])}
        planted = [[t["id"] for t in transactions if t["channel"] == "cash_in" and t["month"] in last_two]]
    else:
        planted = []
        for alt in spec["planted"]:
            planted.append([
                (explaining if x == "DOC" else id_of[x] if isinstance(x, int) else x) for x in alt
            ])

    world = {
        "seed": ctx.seed,
        "held_out": ctx.slice == "held_out",
        "currency": ctx.currency,
        "threshold": ctx.threshold,
        "screen_order": "kyc_first" if ctx.slice == "held_out" else "alert_first",
        "customer": ctx.customer,
        "months": [month_key(y, m) for (y, m) in ctx.months],
        "transactions": transactions,
        "counterparties": counterparties,
        "screen_counterparties": screen_cps,
        "alert": {
            "id": f"A-{ctx.rng.between(5000, 9999)}",
            "rule": ctx.rule_ids[family],
            "family": family,
            "rule_text": RULE_TEXT[family],
            "fired": iso_date(ctx.fire_day),
            "triggering": triggering,
        },
        "prior_alerts": _prior_alerts(ctx, family),
        "documents": ctx.documents,
        "summary": summary,
        "template": template,
        "overlay": None,
        "label": "suspicious" if template.startswith("S") else "benign",
        "typology": spec.get("typology"),
        "planted": planted,
        "doc_party_cp": ctx.doc_party_cp,
    }
    return world


# -------------------------------------------------------------- overlay --


def compatible_overlays(world: dict[str, Any]) -> list[str]:
    """Which hidden signals this benign world can carry. Depends on the screen, never on the label."""
    kinds = ["funnel"]
    if world["customer"]["prior_alerts"] >= 3 and world["customer"]["prior_sars"] == 0:
        kinds.append("serial_closer")
    if world["template"] in DOC_TEMPLATES:
        kinds.append("doc_mismatch")
    return kinds


def _overlay_urn(kinds: list[str]) -> list[str]:
    """funnel is always available, so the other two are drawn twice as often where they can be."""
    urn: list[str] = []
    for kind in kinds:
        urn += [kind] * (1 if kind == "funnel" else 2)
    return urn


def _outbound_target(world: dict[str, Any]) -> str:
    """The highest-volume outbound counterparty on the screen. Every benign template has one."""
    for c in world["screen_counterparties"]:
        if c["direction"] == "out":
            return c["id"]
    raise AssertionError(f"template {world['template']} has no outbound counterparty on screen")


def apply_overlay(world: dict[str, Any], kind: str, rng: XorShift32) -> None:
    """Overlay a suspicious signal onto lookup data only. Mutates `world`.

    The screen — alert, triggering set, KYC block, summary, screen
    counterparties — is not touched, and `tests/test_screen_leak.py` proves it.
    """
    family = world["alert"]["family"]
    triggering = world["alert"]["triggering"]
    world["overlay"] = kind
    world["label"] = "suspicious"

    if kind == "funnel":
        target = _outbound_target(world)
        cp = next(c for c in world["counterparties"] if c["id"] == target)
        cp["other_alerted_accounts"] = rng.between(5, 14)
        world["typology"] = "FUNNEL_ACCOUNT"
        world["planted"] = [[target] + cp["transactions"], list(cp["transactions"])]

    elif kind == "serial_closer":
        priors = world["prior_alerts"]
        analysts = []
        while len(analysts) < len(priors):
            name = f"{rng.pick(FIRST_NAMES)} {rng.pick(SURNAMES)}"
            if name not in analysts:
                analysts.append(name)
        fire_day = days_from_civil(*map(int, world["alert"]["fired"].split("-")))
        days = sorted(rng.sample(list(range(fire_day - 330, fire_day - 30)), len(priors)))
        for j, prior in enumerate(priors):
            prior["rule"] = world["alert"]["rule"]
            prior["fired"] = iso_date(days[j])
            prior["disposition"] = "closed"
            prior["analyst"] = analysts[j]
            prior["rationale"] = BOILERPLATE
            prior["elapsed_days"] = rng.between(0, 1)
        world["typology"] = NOMINAL_TYPOLOGY[family]
        ids = [p["id"] for p in priors]
        world["planted"] = [ids + triggering, ids]

    elif kind == "doc_mismatch":
        target = world["doc_party_cp"]
        cp = next(c for c in world["counterparties"] if c["id"] == target)
        cp["name"] = f"{rng.pick(WORDS['adjectives'])} {rng.pick(['Holdings', 'Ventures', 'Trading'])} {rng.pick(WORDS['suffixes'])}"
        doc = next(d["id"] for d in world["documents"] if d["party"])
        inbound = [t["id"] for t in world["transactions"] if t["id"] in triggering and t["cp"] == target]
        world["typology"] = NOMINAL_TYPOLOGY[family]
        world["planted"] = [[doc, target] + inbound, [doc, target]]
    else:
        raise ValueError(kind)


# -------------------------------------------------------------- public --


def is_held_out(seed: int) -> bool:
    return fnv1a32(str(seed)) % 8 == 7


def generate(seed: int, overlay: str | None = "auto") -> dict[str, Any]:
    """The world before reference hours. `overlay` is for tests:
    "auto" follows the seed; None forces the benign twin; a kind forces it on."""
    h = fnv1a32(str(seed))
    slice_name = "held_out" if h % 8 == 7 else "main"
    rng = XorShift32(h)
    ctx = _Ctx(seed, rng, slice_name)

    roll = rng.below(100)
    tier = "benign" if roll < BENIGN_PCT else "visible" if roll < BENIGN_PCT + VISIBLE_PCT else "hidden"
    if tier == "visible":
        template = VISIBLE_TEMPLATES[rng.below(len(VISIBLE_TEMPLATES))]
    else:
        template = BENIGN_TEMPLATES[rng.below(len(BENIGN_TEMPLATES))]

    spec = BUILDERS[template](ctx)
    world = _assemble(ctx, template, spec)
    world["tier"] = tier

    if tier != "visible":
        kinds = compatible_overlays(world)
        if overlay == "auto":
            if tier == "hidden":
                apply_overlay(world, rng.pick(_overlay_urn(kinds)), rng)
        elif overlay is not None:
            apply_overlay(world, overlay, rng)
    del world["doc_party_cp"]
    return world


def world_for_seed(seed: int) -> dict[str, Any]:
    """The world with `reference_minutes` attached — part of the digest.

    The reference is the cheapest shipped policy that reached the correct
    disposition on this seed, found by running them through the engine. The
    policies read only the engine's view, never this dict, so attaching their
    result here is not circular; it is the same move as wordle's
    `reference_depth`, made part of the world so the browser recomputes it
    instead of trusting the recorded number.
    """
    from .policies import reference_for  # local import: policies -> engine -> generator

    world = generate(seed)
    minutes, policy = reference_for(world)
    world["reference_minutes"] = minutes
    world["reference_policy"] = policy
    return world


SCREEN_KEYS = ("seed", "held_out", "currency", "threshold", "screen_order", "customer", "months",
               "summary", "screen_counterparties", "alert")


def screen_of(world: dict[str, Any]) -> dict[str, Any]:
    """Exactly what turn 0 shows, including the triggering transactions in full."""
    triggering = set(world["alert"]["triggering"])
    screen = {k: world[k] for k in SCREEN_KEYS}
    screen["triggering_transactions"] = [t for t in world["transactions"] if t["id"] in triggering]
    return screen


def world_digest(seeds=range(4096)) -> str:
    """Digest (a): SHA-256 over the canonical world for each seed, newline-joined."""
    digest = hashlib.sha256()
    for seed in seeds:
        digest.update(canonical_json(world_for_seed(seed)).encode())
        digest.update(b"\n")
    return digest.hexdigest()
