"""Phase 2 advisory writes for ticket #000037.

Reads a ControllerDecision from arborist.substrate.prometheus and
emits rows to the sibling `controller_events` SQLite table. Does
NOT touch the audit chain (audit_events.event_hash preimage
unchanged). Advisory only — operators can query / aggregate /
prune controller_events without integrity risk.

The decision shape is Phase 1's territory; this module never
computes decisions, only persists them.

Four event kinds emitted per ControllerDecision:
  controller_decision               — one row, label + entropy +
                                      selected_branch_id
  controller_difficulty             — one row, difficulty_next
  controller_budget_allocation      — one row per branch with nonzero
                                      allocation
  controller_falsification_proposal — one row per §13 Step 11 proposal
                                      (witness_divergence ≥ threshold);
                                      lets a downstream harvester read
                                      the proposal stream from the
                                      audit table

Idempotency is enforced by the table's
``UNIQUE (event_kind, body_hash)`` constraint. body_hash is the
sha256 of canonical-JSON-encoded body. Re-calling
``emit_controller_events`` with the same input is a safe no-op.
"""

from __future__ import annotations

import hashlib
import json
import sqlite3
import time
from typing import TYPE_CHECKING, Optional

if TYPE_CHECKING:
    from arborist.substrate.prometheus import ControllerDecision


def canonical_body(body: dict) -> tuple[str, str]:
    """Canonical-JSON encode + sha256.

    Returns ``(canonical_str, sha_hex)``. Canonicalization uses
    ``sort_keys=True`` and ``separators=(",", ":")`` so the output
    is byte-stable across Python versions and platforms — the same
    discipline ``arborist.store._canonical_json`` applies to audit-
    chain bodies (sibling format; this is a parallel write, not a
    chain mutation).

    The sha is hex-encoded to keep it text-shaped for the
    ``body_hash`` column.
    """
    canonical_str = json.dumps(
        body, sort_keys=True, separators=(",", ":"), ensure_ascii=False
    )
    sha_hex = hashlib.sha256(
        canonical_str.encode("utf-8", errors="surrogatepass")
    ).hexdigest()
    return canonical_str, sha_hex


def _insert_row(
    conn: sqlite3.Connection,
    *,
    organism_root: str,
    branch_id: Optional[str],
    event_kind: str,
    label: Optional[str],
    entropy: Optional[float],
    difficulty: Optional[float],
    allocation: Optional[float],
    body: dict,
    ts: int,
) -> Optional[int]:
    """Insert one controller_events row. Returns event_id, or None
    if the row was a duplicate (UNIQUE constraint on event_kind +
    body_hash).
    """
    body_blob, body_hash = canonical_body(body)
    cur = conn.execute(
        "INSERT OR IGNORE INTO controller_events ("
        " organism_root, branch_id, event_kind, label,"
        " entropy, difficulty, allocation,"
        " body_blob, body_hash, recorded_at"
        ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
        (
            organism_root,
            branch_id,
            event_kind,
            label,
            entropy,
            difficulty,
            allocation,
            body_blob,
            body_hash,
            ts,
        ),
    )
    if cur.rowcount == 0:
        return None
    return int(cur.lastrowid)


def emit_controller_events(
    conn: sqlite3.Connection,
    decision: "ControllerDecision",
    organism_root: str,
    *,
    ts: Optional[int] = None,
) -> list[int]:
    """Write one decision row + one difficulty row + per-branch
    allocation rows for the ControllerDecision. Idempotent on
    (event_kind, body_hash). Returns a list of event_id rows
    written (empty list if all were duplicates).

    Phase 1 owns the decision; this helper is a pure write surface.
    Callers should wrap this in a ``store.transaction(conn)`` block
    when emitting alongside other DB state.
    """
    if ts is None:
        ts = int(time.time())

    written: list[int] = []

    # --- 1. controller_decision row -----------------------------------
    veto_reasons_obj = {
        branch: list(reasons)
        for branch, reasons in decision.veto_reasons.items()
    }
    decision_body = {
        "kind": "controller_decision",
        "organism_root": organism_root,
        "selected_branch_id": decision.selected_branch_id,
        "label": decision.label,
        "entropy": decision.entropy,
        "veto_reasons": veto_reasons_obj,
        "notes": list(decision.notes),
    }
    eid = _insert_row(
        conn,
        organism_root=organism_root,
        branch_id=decision.selected_branch_id,
        event_kind="controller_decision",
        label=decision.label,
        entropy=float(decision.entropy),
        difficulty=None,
        allocation=None,
        body=decision_body,
        ts=ts,
    )
    if eid is not None:
        written.append(eid)

    # --- 2. controller_difficulty row ---------------------------------
    difficulty_body = {
        "kind": "controller_difficulty",
        "organism_root": organism_root,
        "difficulty_next": decision.difficulty_next,
    }
    eid = _insert_row(
        conn,
        organism_root=organism_root,
        branch_id=None,
        event_kind="controller_difficulty",
        label=None,
        entropy=None,
        difficulty=float(decision.difficulty_next),
        allocation=None,
        body=difficulty_body,
        ts=ts,
    )
    if eid is not None:
        written.append(eid)

    # --- 3. per-branch controller_budget_allocation rows --------------
    # Deterministic ordering — sort by branch_id so canonical-body
    # hashes are stable across dict-iteration variance.
    for branch_id, alloc in sorted(decision.allocations.items()):
        if not alloc:
            # Nonzero filter: skip 0.0, -0.0, and anything falsy.
            continue
        alloc_body = {
            "kind": "controller_budget_allocation",
            "organism_root": organism_root,
            "branch_id": branch_id,
            "allocation": float(alloc),
        }
        eid = _insert_row(
            conn,
            organism_root=organism_root,
            branch_id=branch_id,
            event_kind="controller_budget_allocation",
            label=None,
            entropy=None,
            difficulty=None,
            allocation=float(alloc),
            body=alloc_body,
            ts=ts,
        )
        if eid is not None:
            written.append(eid)

    # --- 4. per-proposal controller_falsification_proposal rows -------
    # §13 Step 11 proposals are emitted in-memory by `controller_decide`
    # but were previously discarded at the persist boundary. Persisting
    # them as a 4th event kind closes the live-harvest loop with
    # `bench/scripts/harvest_falsification_proposals.py` — the harvester
    # can read this stream alongside its providence_cache scan.
    # Deterministic ordering by (branch_id, witness_divergence) so the
    # canonical body hashes are stable across tuple-iteration variance.
    proposals = tuple(decision.falsification_proposals)
    for prop in sorted(
        proposals, key=lambda p: (p.branch_id, p.witness_divergence)
    ):
        proposal_body = {
            "kind": "controller_falsification_proposal",
            "organism_root": organism_root,
            "branch_id": prop.branch_id,
            "witness_divergence": float(prop.witness_divergence),
            "reason": prop.reason,
        }
        eid = _insert_row(
            conn,
            organism_root=organism_root,
            branch_id=prop.branch_id,
            event_kind="controller_falsification_proposal",
            label=prop.reason,
            entropy=None,
            difficulty=None,
            allocation=None,
            body=proposal_body,
            ts=ts,
        )
        if eid is not None:
            written.append(eid)

    return written
