"""Console audit event schema, retention, and append-only sink (#633).

``gitea_audit`` records MCP-side *mutations*: which profile and Gitea user
performed which tool call. It carries no console actor, no identity source, no
correlation identifier, and no retention class, so it cannot answer the
question #633 exists to answer — *who sat at the console, what did they
attempt, and was it authorized?* An authorization denial is not a mutation and
would never appear there at all.

This module adds the console-side record. It does not replace ``gitea_audit``:
when a Phase 2 action eventually reaches MCP, both fire, correlated by
``correlation.request_id``.

Design constraints:

- **Redact before persist.** Every record passes through
  ``webui.console_redaction.redact_payload`` before serialization, so an
  unredacted field is never durable.
- **Append-only.** Records are appended as JSON lines. Nothing here updates or
  deletes; retention is metadata on each record, enforced by an operator-run
  policy, never by silent rewriting.
- **Never raises.** Auditing must not break the request it describes. A failed
  write returns ``False``.
- **Off by default.** With ``WEBUI_CONSOLE_AUDIT_LOG`` unset, events are still
  *built* (so callers and tests see the schema) but nothing is written.

A record looks like this (synthetic values):

    {"schema_version": 1, "event_id": "evt-0001",
     "timestamp": "2026-07-22T10:16:42+00:00",
     "actor": {"subject": "ops@example.com", "role": "operator",
               "identity_source": "access_proxy", "authenticated": true},
     "action": "merge_pr", "action_class": "privileged",
     "target": {"kind": "pr", "ref": "#123"},
     "result": "denied", "reason_code": "insufficient_role",
     "correlation": {"request_id": "req-abc", "session_id": null,
                     "mcp_task": "merge_pr", "mcp_permission": "gitea.pr.merge"},
     "retention": {"class": "privileged", "days": 365,
                   "expires_at": "2027-07-22T10:16:42+00:00"},
     "redacted": true}

Timestamps are timezone-aware ISO-8601 in UTC.
"""

from __future__ import annotations

import datetime
import json
import os
import uuid
from typing import Any

from webui import console_authz
from webui.console_redaction import redact_payload, scan_for_secrets

SCHEMA_VERSION = 1

AUDIT_LOG_ENV = "WEBUI_CONSOLE_AUDIT_LOG"

# Result vocabulary. ``denied`` is the one ``gitea_audit`` has no equivalent
# for: an authorization refusal never reaches the MCP layer.
RESULT_ALLOWED = "allowed"
RESULT_DENIED = "denied"
RESULT_PREVIEWED = "previewed"
RESULT_FAILED = "failed"
RESULT_SUCCEEDED = "succeeded"

RESULTS = frozenset(
    {
        RESULT_ALLOWED,
        RESULT_DENIED,
        RESULT_PREVIEWED,
        RESULT_FAILED,
        RESULT_SUCCEEDED,
    }
)

# Retention classes and default lifetimes in days. Privileged and break-glass
# records outlive routine ones because they are what an incident review needs.
RETENTION_STANDARD = "standard"
RETENTION_PRIVILEGED = "privileged"
RETENTION_BREAK_GLASS = "break_glass"

RETENTION_DAYS: dict[str, int] = {
    RETENTION_STANDARD: 90,
    RETENTION_PRIVILEGED: 365,
    RETENTION_BREAK_GLASS: 730,
}

# Fields every record must carry. Asserted by the test suite so a future edit
# cannot quietly drop one.
REQUIRED_FIELDS: tuple[str, ...] = (
    "schema_version",
    "event_id",
    "timestamp",
    "actor",
    "action",
    "action_class",
    "target",
    "result",
    "reason_code",
    "correlation",
    "retention",
    "redacted",
)

REQUIRED_ACTOR_FIELDS: tuple[str, ...] = (
    "subject",
    "role",
    "identity_source",
    "authenticated",
)

REQUIRED_CORRELATION_FIELDS: tuple[str, ...] = (
    "request_id",
    "session_id",
    "mcp_task",
    "mcp_permission",
)


def audit_log_path() -> str | None:
    """Configured sink path, or ``None`` when console auditing is off."""
    return (os.environ.get(AUDIT_LOG_ENV) or "").strip() or None


def audit_enabled() -> bool:
    return audit_log_path() is not None


def retention_class_for(action: console_authz.ConsoleAction | None) -> str:
    """Classify retention from the action, defaulting to the longest-lived.

    An unknown action is treated as privileged rather than standard: for a
    safety control the conservative direction is to keep the record longer.
    """
    if action is None:
        return RETENTION_PRIVILEGED
    if action.break_glass:
        return RETENTION_BREAK_GLASS
    if action.privileged:
        return RETENTION_PRIVILEGED
    return RETENTION_STANDARD


def _retention_block(
    retention_class: str, now: datetime.datetime
) -> dict[str, Any]:
    days = RETENTION_DAYS.get(
        retention_class, RETENTION_DAYS[RETENTION_PRIVILEGED]
    )
    return {
        "class": retention_class,
        "days": days,
        "expires_at": (now + datetime.timedelta(days=days)).isoformat(),
    }


def build_event(
    *,
    action_id: str,
    result: str,
    decision: console_authz.AuthorizationDecision | None = None,
    principal: console_authz.Principal | None = None,
    target: dict[str, Any] | None = None,
    reason_code: str | None = None,
    request_id: str | None = None,
    session_id: str | None = None,
    detail: str | None = None,
    metadata: dict[str, Any] | None = None,
    now: datetime.datetime | None = None,
    event_id: str | None = None,
) -> dict[str, Any]:
    """Build one redacted, JSON-able console audit record.

    Redaction runs here rather than at write time so an in-memory record handed
    to a template or an API response is already clean.
    """
    ts = now or datetime.datetime.now(datetime.timezone.utc)
    action = console_authz.get_action(action_id)
    who = principal or (
        decision.principal if decision else console_authz.ANONYMOUS
    )
    resolved_result = result if result in RESULTS else RESULT_FAILED
    resolved_reason = reason_code or (
        decision.reason_code if decision else "unspecified"
    )
    retention_class = retention_class_for(action)

    event: dict[str, Any] = {
        "schema_version": SCHEMA_VERSION,
        "event_id": event_id or f"evt-{uuid.uuid4().hex}",
        "timestamp": ts.isoformat(),
        "actor": who.to_dict(),
        "action": action_id,
        "action_class": action.action_class if action else "unknown",
        "target": dict(target or {}),
        "result": resolved_result,
        "reason_code": resolved_reason,
        "correlation": {
            "request_id": request_id,
            "session_id": session_id,
            "mcp_task": action.task_key if action else None,
            "mcp_permission": action.mcp_permission if action else None,
        },
        "retention": _retention_block(retention_class, ts),
        "redacted": True,
        "detail": detail,
        "metadata": dict(metadata or {}),
    }
    if decision is not None:
        # Deliberately *not* named "authorization": ``gitea_audit`` treats that
        # substring as a secret key hint (it matches the HTTP Authorization
        # header) and would replace this whole block with the placeholder.
        event["decision"] = {
            "allowed": decision.allowed,
            "required_role": decision.required_role,
            "requires_confirmation": decision.requires_confirmation,
            "dual_control": decision.dual_control,
            "break_glass": decision.break_glass,
            "execution_enabled": decision.execution_enabled,
        }

    redacted = redact_payload(event)
    if not isinstance(redacted, dict):  # pragma: no cover - defensive
        return {"schema_version": SCHEMA_VERSION, "redacted": True}
    return redacted


def write_event(event: dict[str, Any], path: str | None = None) -> bool:
    """Append *event* as one JSON line. Never raises.

    Returns ``True`` when a line was written, ``False`` when auditing is off or
    the write failed. A record that still trips a secret detector is dropped
    rather than persisted.
    """
    sink = path or audit_log_path()
    if not sink:
        return False
    try:
        if scan_for_secrets(event):
            return False
        line = json.dumps(event, default=str, sort_keys=True)
        with open(sink, "a", encoding="utf-8") as handle:
            handle.write(line + "\n")
        return True
    except Exception:
        return False


def record_event(**kwargs: Any) -> dict[str, Any]:
    """Build and persist one record; return the record either way.

    Callers get the record back so it can be surfaced in a response or a test
    regardless of whether a sink is configured.
    """
    event = build_event(**kwargs)
    written = write_event(event)
    return {"event": event, "written": written}


def audit_policy() -> dict[str, Any]:
    """Machine-readable audit schema and retention defaults (never secrets)."""
    return {
        "schema_version": SCHEMA_VERSION,
        "required_fields": list(REQUIRED_FIELDS),
        "required_actor_fields": list(REQUIRED_ACTOR_FIELDS),
        "required_correlation_fields": list(REQUIRED_CORRELATION_FIELDS),
        "results": sorted(RESULTS),
        "retention_defaults_days": dict(RETENTION_DAYS),
        "sink_env": AUDIT_LOG_ENV,
        "enabled": audit_enabled(),
        "append_only": True,
        "redact_before_persist": True,
        "timestamp_format": "ISO-8601, timezone-aware, UTC",
        "relationship_to_mcp_audit": (
            "webui.console_audit records console intent and authorization "
            "outcomes; gitea_audit records MCP mutations. A Phase 2 action "
            "emits both, correlated by correlation.request_id."
        ),
    }
