"""Per-tick observability facts the scaffold hands to the intake.

A scanner tick produces nothing on roughly 99% of ticks, so the only thing a
live screen can show about a working box is what the tick itself reports. These
facts are assembled here, once per tick, and ride the POST the delivery sink
already makes every tick — `/signals` on a clean tick, `/errors` on a failed
one. There is no second channel and no extra request.

ABSENT IS NOT ZERO. Every field is omitted when its value is unknown, never
defaulted. A `candidate_count` of 0 is a scanner that found nothing; an absent
one would be a scanner that did not say. The distinction has to survive from
here to the screen, so nothing in this module substitutes one for the other.

The state projection is the LAST COMMITTED row, which makes it stale by one tick
whenever the current tick failed. That is deliberate: a tick that could not
commit is exactly when the last agreed state is worth seeing.
"""

from __future__ import annotations

from typing import Any, Optional


# Per-value character ceiling inside a projected row. An author may park a whole
# serialised payload in one state key; this bounds what one tick can put on the
# wire without bounding what the author may store.
MAX_STATE_VALUE_CHARS = 1024

# Keys projected out of one state row. Bounds an author's open-ended row the way
# the runtime bounds an open attribute bag.
MAX_STATE_KEYS = 32


def _is_scalar(value: Any) -> bool:
    """A value a consumer can parse: str, bool, or a FINITE number.

    bool is checked before the numeric branch only implicitly — it is accepted
    either way. NaN and the infinities are rejected: they are not JSON, and a
    consumer reading one has no way to tell it from a real measurement.
    """
    if isinstance(value, bool) or isinstance(value, str):
        return True
    if isinstance(value, (int, float)):
        return value == value and value not in (float("inf"), float("-inf"))
    return False


def project_state_row(row: Any) -> dict:
    """The scalar fields of one state row, capped and bounded.

    Returns {} for anything that is not a dict — a row the author replaced with
    a list or a string projects to nothing rather than raising inside the loop.
    """
    if not isinstance(row, dict):
        return {}
    projected: dict = {}
    for key in list(row.keys())[:MAX_STATE_KEYS]:
        if not isinstance(key, str):
            continue
        value = row[key]
        if not _is_scalar(value):
            continue
        projected[key] = value[:MAX_STATE_VALUE_CHARS] if isinstance(value, str) else value
    return projected


def count_changed(current: dict, previous: Optional[dict]) -> int:
    """How many projected fields moved since the previously reported row.

    A first row counts every field as changed: nothing was known before it, so
    every value is new information. A key that disappeared counts too — its
    absence is a change the operator should see.
    """
    if previous is None:
        return len(current)
    keys = set(current) | set(previous)
    return sum(1 for key in keys if current.get(key) != previous.get(key))


def build_tick_facts(
    *,
    tick_id: str,
    status: str,
    candidate_count: int,
    duration_ms: int,
    commit: Optional[str],
    quiet_ticks: int,
    lag_ms: Optional[int],
    mcp_snapshot: Optional[dict],
    call_snapshot: Optional[dict],
    state_row: Any,
    state_fields: Optional[int],
    state_changed: Optional[int],
) -> dict:
    """Assemble one tick's facts for the wire.

    `mcp_snapshot` / `call_snapshot` are the MCP boundary's own per-tick tallies,
    or None when the tick ran without an MCP client — which is not the same as a
    tick that made no calls, so both keys are then left off entirely.
    """
    facts: dict = {
        "tick_id": tick_id,
        "status": status,
        "candidate_count": candidate_count,
        "duration_ms": duration_ms,
        # Always known: the loop either commits, rolls back, or fails to persist.
        **({"commit": commit} if commit else {}),
        # Zero is a real answer here (this tick produced a signal), so it is sent.
        "quiet_ticks": quiet_ticks,
    }
    # Unknown on the very first tick of a process: there is no previous tick to
    # measure the gap against, and a 0 would claim the scanner was exactly on time.
    if lag_ms is not None:
        facts["lag_ms"] = lag_ms
    if mcp_snapshot is not None:
        # Every failure the boundary saw this tick. The overwhelming case is a failure the
        # author's own `except` caught and turned into an empty return — the one thing that
        # otherwise leaves no trace at all. A failure that escaped scan() is in the count too,
        # and needs no separating out: it already failed the tick, so `status` names it.
        facts["mcp_failures"] = int(mcp_snapshot.get("count") or 0)
    if call_snapshot is not None:
        facts["mcp_ms"] = call_snapshot.get("total_ms", 0)
        facts["mcp_calls"] = call_snapshot.get("calls", [])
    projected = project_state_row(state_row)
    if state_fields is not None:
        facts["state"] = projected
        facts["state_fields"] = state_fields
        if state_changed is not None:
            facts["state_changed"] = state_changed
    return facts
