"""Turning model text into one action, and engine results into text.

The one rule, as in wordle_five: **never raise**. A malformed reply is a
thing the model did, not an error in the harness. It becomes a rejection that
costs a turn and shows up in `rejected_replies`.

The parse rules are pinned here because this is where two runtimes disagree,
and CI hashes a committed corpus of reply strings through this parser and the
engine on both sides (digest (c) in CONFORMANCE.txt):

  1. If a ```json fence appears anywhere, its contents are the candidate.
     A bare `{` before the fence does not win.
  2. Otherwise the candidate is the first string-aware balanced `{…}` span:
     braces inside JSON strings do not count, backslash escapes are honoured.
  3. If the candidate is not valid JSON, or is JSON but not an object, the
     reply is rejected. There is no further scanning for a second candidate.
  4. Enum values are matched exact-case. Cites are deduplicated in order and
     matched exact-case against what the engine has shown.
"""

from __future__ import annotations

import json
import re
from typing import Any

from .generator import MAX_TURNS

ACTIONS = ("lookup", "close", "escalate")
LOOKUPS = ("history", "counterparty", "prior_alerts", "documents")
CLOSE_REASONS = ("CONSISTENT_WITH_PROFILE", "DOCUMENTED_SOURCE_OF_FUNDS", "PREVIOUSLY_REVIEWED", "RULE_ARTEFACT")
TYPOLOGIES = ("STRUCTURING", "FUNNEL_ACCOUNT", "MONEY_MULE", "RAPID_MOVEMENT", "UNKNOWN")

_FENCE = re.compile(r"```json\s*(.*?)```", re.S)
_MONTH = re.compile(r"^\d{4}-\d{2}$")


def _reject_constant(name: str):
    # Python's json accepts NaN/Infinity by default; JSON.parse does not.
    raise ValueError(name)


def extract_candidate(text: str) -> str | None:
    match = _FENCE.search(text)
    if match:
        return match.group(1)
    start = text.find("{")
    if start < 0:
        return None
    depth = 0
    in_string = False
    escaped = False
    for i in range(start, len(text)):
        ch = text[i]
        if in_string:
            if escaped:
                escaped = False
            elif ch == "\\":
                escaped = True
            elif ch == '"':
                in_string = False
            continue
        if ch == '"':
            in_string = True
        elif ch == "{":
            depth += 1
        elif ch == "}":
            depth -= 1
            if depth == 0:
                return text[start : i + 1]
    return None


def parse_reply(text: str | None) -> tuple[dict[str, Any] | None, str | None]:
    """(object, None) or (None, reason). Validation of fields is the engine's job."""
    if not text:
        return None, "no JSON object found in the reply"
    candidate = extract_candidate(text)
    if candidate is None:
        return None, "no JSON object found in the reply"
    try:
        value = json.loads(candidate, parse_constant=_reject_constant)
    except (ValueError, RecursionError):
        return None, "the JSON object could not be parsed"
    if not isinstance(value, dict):
        return None, "the reply parsed but is not a JSON object"
    return value, None


# -------------------------------------------------------------- render --

SYSTEM_PROMPT = """You are a transaction-monitoring analyst at a bank. One alert has been assigned to you.

All data is synthetic. No real customer, account, transaction or counterparty exists.

Your screen shows the alert, the transactions that triggered it, the customer's KYC file and a twelve-month summary. Reading it has already cost {screen} analyst-hours. You have {turns} replies. Each reply must be exactly ONE JSON object (in a ```json fence, or as the first {{...}} in the reply), and every reply costs {turn} hours whether or not it is accepted.

Lookups (each may be made once):
  {{"action":"lookup","what":"history","month":"YYYY-MM"}}       itemised transactions for a month in the window     +{lookup} h
  {{"action":"lookup","what":"counterparty","id":"CP-n"}}        name, type, jurisdiction, first seen, how many OTHER alerted accounts share it, its transactions     +{lookup} h
  {{"action":"lookup","what":"prior_alerts"}}                    prior alerts on this customer with disposition, analyst and rationale     +{lookup} h
  {{"action":"lookup","what":"documents"}}                       documents on file     +{doc} h

Dispositions (end the episode):
  {{"action":"close","reason":R,"cites":[ids],"note":"..."}}
      R is one of CONSISTENT_WITH_PROFILE, DOCUMENTED_SOURCE_OF_FUNDS, PREVIOUSLY_REVIEWED, RULE_ARTEFACT
  {{"action":"escalate","typology":T,"cites":[ids],"narrative":"..."}}
      T is one of STRUCTURING, FUNNEL_ACCOUNT, MONEY_MULE, RAPID_MOVEMENT, UNKNOWN

`cites` must be a non-empty list of ids you have actually been shown: kyc.<field>, T-<n>, CP-<n>, A-<n> (a prior alert), doc.<id>. Citing an id you have not been shown is rejected. The note or narrative is displayed and never scored.

A close on an alert that should have been escalated scores zero. An escalation on a clean customer scores half on the counterweight and zero on the rest. Cite the facts that decide the case, not everything you saw. If you reach your last reply without a disposition the alert stays in the queue and the episode scores zero."""


def system_prompt() -> str:
    from .reward import DOC_MINUTES, LOOKUP_MINUTES, SCREEN_MINUTES, TURN_MINUTES

    return SYSTEM_PROMPT.format(
        screen=f"{SCREEN_MINUTES / 60:.2f}",
        turn=f"{TURN_MINUTES / 60:.2f}",
        lookup=f"{LOOKUP_MINUTES / 60:.2f}",
        doc=f"{DOC_MINUTES / 60:.2f}",
        turns=MAX_TURNS,
    )


def money(cents: int, currency: str) -> str:
    sign = "-" if cents < 0 else ""
    cents = abs(cents)
    return f"{sign}{cents // 100:,}.{cents % 100:02d} {currency}"


def _txn_line(t: dict[str, Any], currency: str) -> str:
    where = f"branch {t['branch']}" if t["branch"] else f"{t['cp']}"
    return f"  {t['id']:<6} {t['date']}  {t['channel']:<9} {money(t['amount'], currency):>18}  {where}"


def render_screen(screen: dict[str, Any]) -> str:
    cur = screen["currency"]
    c = screen["customer"]
    a = screen["alert"]
    alert_block = [
        f"ALERT {a['id']}  rule {a['rule']} — {a['rule_text']}",
        f"  fired {a['fired']}",
        "  triggering transactions:",
        *[_txn_line(t, cur) for t in screen["triggering_transactions"]],
    ]
    kyc_lines = [f"KYC FILE  {c['id']}  {c['name']}"]
    for key in sorted(c):
        if key in ("id", "name"):
            continue
        value = c[key]
        if key in ("stated_income", "expected_monthly_cash"):
            value = money(value, cur)
        kyc_lines.append(f"  kyc.{key} = {value}")
    rows = screen["summary"]
    summary = ["TWELVE-MONTH SUMMARY (" + cur + ")",
               f"  {'month':<8}{'cash in':>16}{'cash out':>16}{'wires in':>16}{'wires out':>16}{'ach in':>16}{'ach out':>16}{'txns':>6}"]
    for r in rows:
        summary.append(
            f"  {r['month']:<8}{r['cash_in'] // 100:>16,}{r['cash_out'] // 100:>16,}{r['wires_in'] // 100:>16,}"
            f"{r['wires_out'] // 100:>16,}{r['ach_in'] // 100:>16,}{r['ach_out'] // 100:>16,}{r['count']:>6}"
        )
    cps = ["COUNTERPARTIES on screen (top by twelve-month volume):"]
    for cp in screen["screen_counterparties"]:
        cps.append(f"  {cp['id']:<6} {cp['direction']:<4} {cp['count']:>3} txns  {money(cp['total'], cur)}")
    blocks = [kyc_lines, alert_block] if screen["screen_order"] == "kyc_first" else [alert_block, kyc_lines]
    blocks += [summary, cps, [f"Reporting threshold in this jurisdiction: {money(screen['threshold'], cur)}",
                              "SYNTHETIC DATA — no real customer, account or transaction."]]
    return "\n".join("\n".join(b) for b in blocks)


def render_lookup(what: str, result: dict[str, Any], currency: str) -> str:
    if what == "history":
        lines = [f"HISTORY {result['month']}: {len(result['transactions'])} transactions"]
        lines += [_txn_line(t, currency) for t in result["transactions"]]
        return "\n".join(lines)
    if what == "counterparty":
        r = result
        return "\n".join([
            f"COUNTERPARTY {r['id']}  {r['name']}",
            f"  type {r['type']}; jurisdiction {r['jurisdiction']}; first seen {r['first_seen']}",
            f"  other alerted accounts sharing this counterparty in twelve months: {r['other_alerted_accounts']}",
            f"  transactions: {', '.join(r['transactions'])}",
        ])
    if what == "prior_alerts":
        if not result["alerts"]:
            return "PRIOR ALERTS: none on file."
        lines = ["PRIOR ALERTS:"]
        for p in result["alerts"]:
            lines.append(f"  {p['id']}  {p['rule']}  fired {p['fired']}  {p['disposition']} after {p['elapsed_days']} day(s) by {p['analyst']}")
            lines.append(f"         {p['rationale']}")
        return "\n".join(lines)
    if what == "documents":
        lines = ["DOCUMENTS ON FILE:"]
        for d in result["documents"]:
            lines.append(f"  {d['id']}  [{d['kind']}]  {d['description']}")
        return "\n".join(lines)
    return json.dumps(result)


def render_rejection(reason: str, turns_left: int) -> str:
    plural = "y" if turns_left == 1 else "ies"
    return f"That reply was not accepted: {reason}. It still cost a turn. You have {turns_left} repl{plural} left."


def render_cost(minutes_spent: int, turns_left: int) -> str:
    plural = "y" if turns_left == 1 else "ies"
    return f"Hours spent so far: {minutes_spent / 60:.2f}. You have {turns_left} repl{plural} left."
