"""Approvals CLI — human-in-the-loop review of pending approval requests.

This module exposes the ``approvals`` Typer sub-application, which lets a local
operator inspect, approve, deny and audit the approval requests produced by the
security plane (``PermissionGateway`` returning ``REQUIRE_APPROVAL``). It only
reads and writes approval state through :class:`ApprovalManager`; it never
constructs a ``PolicyEngine``, never changes the policy mode (SAFE remains the
default everywhere) and never relaxes the sandbox.

Semantics this CLI deliberately surfaces instead of hiding
---------------------------------------------------------
* ``approve`` / ``deny`` return ``None`` both when the request does not exist
  and when it is no longer ``PENDING`` (already decided, expired or cancelled).
  The commands therefore map that ``None`` to an explicit non-zero exit code
  plus a diagnostic; they never report a silent success.
* ``check_expired()`` is lazy: nothing expires until it is called. The
  ``pending`` command calls it first so the printed listing is truthful.
* A ``pending`` request is a blocking gate.
  ``AuthorizedExecutionService._wait_for_approval`` polls ``get_request`` in a
  loop, proceeds when the status resolves to ``APPROVED`` and raises
  ``SecurityError`` on any other terminal status (denied, expired, cancelled,
  missing). Approving is therefore the only way to unblock a waiting execution.
* Approvals are bound to a ``request_id`` and to the recorded ``action``,
  ``resource`` and ``context``. There is **no cryptographic binding** between
  the approval and the code that will eventually run: nothing hashes or signs
  the execution payload, so this record alone cannot prove which concrete code
  an approval was spent on. That is a documented limitation of the current
  prototype, not a security guarantee, and the commands say so.

Display of request payloads
---------------------------
``context`` and ``metadata`` are agent-supplied and may contain arbitrary data.
They are rendered as JSON **unredacted** because this is a local operator's
audit view; the CLI never interprets or executes them and never prints
credentials it generated itself. Treat the terminal output as sensitive.
"""

from __future__ import annotations

import getpass
import json
import os
import time
from datetime import UTC, datetime
from pathlib import Path
from typing import Annotated, Any

import typer

from cyberbrain26.product.local_store import open_database, resolve_db_path
from cyberbrain26.security_plane.approval import (
    ApprovalManager,
    ApprovalRecord,
    ApprovalRequest,
    ApprovalStatus,
)
from cyberbrain26.security_plane.audit import AuditEventType, AuditLog

__all__ = ["approvals_app"]

approvals_app = typer.Typer(
    name="approvals",
    help="Review, approve, deny and audit human-in-the-loop approval requests.",
    no_args_is_help=True,
)

_DB_HELP = "Explicit path to the CyberBrain26 SQLite database file."
_DATA_DIR_HELP = "Explicit data directory holding the database file."
_NO_PERSIST_HELP = "Operate on an in-process manager only (nothing is written to disk)."


def _default_reviewer() -> str:
    """Return the local OS user name used as the default reviewer identity."""
    try:
        return getpass.getuser()
    except (OSError, KeyError):
        return os.environ.get("USER") or os.environ.get("USERNAME") or "local-user"


def _fmt_time(value: float | None) -> str:
    """Format a POSIX timestamp as ISO-8601 UTC, or ``-`` when it is absent."""
    if value is None:
        return "-"
    return datetime.fromtimestamp(value, tz=UTC).isoformat(timespec="seconds").replace("+00:00", "Z")


def _fmt_json(payload: dict[str, Any]) -> str:
    """Render a JSON-compatible mapping as pretty JSON.

    The mapping is local approval/audit data owned by the agent that raised the
    request. It is printed unredacted and is never interpreted or executed here.
    """
    return json.dumps(payload, indent=2, sort_keys=True, default=str)


def _render_table(headers: list[str], rows: list[list[str]]) -> str:
    """Render a fixed-width plain-text table with a separator line."""
    widths = [len(header) for header in headers]
    for row in rows:
        for index, cell in enumerate(row):
            widths[index] = max(widths[index], len(cell))
    lines = ["  ".join(h.ljust(widths[i]) for i, h in enumerate(headers)).rstrip()]
    lines.append("  ".join("-" * width for width in widths).rstrip())
    for row in rows:
        lines.append("  ".join(cell.ljust(widths[i]) for i, cell in enumerate(row)).rstrip())
    return "\n".join(lines)


def _open_manager(
    db: Path | None = None,
    data_dir: Path | None = None,
    persist: bool = True,
) -> ApprovalManager:
    """Open an :class:`ApprovalManager` on the local store.

    Args:
        db: Optional explicit database file path.
        data_dir: Optional data directory used to derive the default database path.
        persist: When ``False``, no database file is opened or written.

    Returns:
        A manager bound to the opened database, or an in-memory manager when
        ``persist`` is ``False``.

    Raises:
        typer.BadParameter: If ``persist`` is ``False`` and the resolved database
            file does not exist. Nothing could be read or decided in that case,
            and pretending otherwise would hide a real failure.
    """
    if not persist:
        path = resolve_db_path(db, data_dir=data_dir)
        if not path.exists():
            message = (
                f"--no-persist was requested but the database {path} does not exist. "
                "Without persistence no approval can be read or written, and a decision "
                "taken with --no-persist cannot affect another process (such as the "
                "execution blocked on this request). Run without --no-persist, or point "
                "--db/--data-dir at the existing store."
            )
            raise typer.BadParameter(message)
        typer.echo(
            f"WARNING: --no-persist is active; decisions are not written to {path} and "
            "cannot unblock a waiting execution in another process.",
            err=True,
        )
        return ApprovalManager(database=open_database(db_path=db, data_dir=data_dir, persist=False))
    return ApprovalManager(database=open_database(db_path=db, data_dir=data_dir, persist=True))


def _record_audit(
    manager: ApprovalManager,
    event_type: AuditEventType,
    *,
    actor: str,
    action: str,
    approval_id: str,
    result: str,
    command: str,
    agent_id: str = "",
    resource: str = "",
    error: str = "",
    metadata: dict[str, Any] | None = None,
) -> None:
    """Write an audit event for a mutating approvals command.

    Read-only commands (``pending``, ``show``, ``history``) are deliberately not
    audited; only state changes are.

    Args:
        manager: Manager whose database receives the audit record.
        event_type: Audit event type for this command.
        actor: Identity of the operator running the command.
        action: Action recorded on the approval request.
        approval_id: The approval request id.
        result: Outcome label, e.g. ``approved``, ``denied`` or ``not_applied``.
        command: The CLI command name, stored in metadata.
        agent_id: Optional agent id that raised the request.
        resource: Optional resource the request targets.
        error: Optional error description when the command did not apply.
        metadata: Optional extra metadata; must never contain credentials.
    """
    audit = AuditLog(manager.database)
    payload: dict[str, Any] = {"command": command}
    if metadata:
        payload.update(metadata)
    audit.record(
        event_type,
        actor=actor,
        agent_id=agent_id,
        action=action,
        resource=resource,
        approval_id=approval_id,
        result=result,
        error=error,
        metadata=payload,
    )


def _request_payload(request: ApprovalRequest, now: float) -> dict[str, Any]:
    """Build the JSON-serialisable view of an approval request."""
    return {
        "request_id": request.request_id,
        "agent_id": request.agent.agent_id,
        "agent_role": request.agent.role,
        "agent_trust_level": getattr(request.agent.trust_level, "value", request.agent.trust_level),
        "action": request.action,
        "resource": request.resource,
        "context": request.context,
        "risk_score": request.risk_score,
        "status": request.status.value,
        "decision": request.decision.value if request.decision else None,
        "created_at": request.created_at,
        "created_at_iso": _fmt_time(request.created_at),
        "reviewed_at": request.reviewed_at,
        "reviewed_at_iso": _fmt_time(request.reviewed_at),
        "reviewer": request.reviewer,
        "reason": request.reason,
        "metadata": request.metadata,
        "age_seconds": round(max(0.0, now - request.created_at), 3),
    }


def _record_payload(record: ApprovalRecord) -> dict[str, Any]:
    """Build the JSON-serialisable view of an approval record."""
    return {
        "request_id": record.request_id,
        "agent_id": record.agent_id,
        "action": record.action,
        "resource": record.resource,
        "decision": record.decision.value,
        "reviewer": record.reviewer,
        "timestamp": record.timestamp,
        "timestamp_iso": _fmt_time(record.timestamp),
        "reason": record.reason,
    }


@approvals_app.command("pending")
def pending(
    data_dir: Annotated[Path | None, typer.Option("--data-dir", help=_DATA_DIR_HELP)] = None,
    db: Annotated[Path | None, typer.Option("--db", help=_DB_HELP)] = None,
    no_persist: Annotated[bool, typer.Option("--no-persist", help=_NO_PERSIST_HELP)] = False,
    as_json: Annotated[bool, typer.Option("--json", help="Print a JSON array.")] = False,
) -> None:
    """List pending approval requests, oldest first.

    ``check_expired()`` runs before the listing because expiry is lazy: without
    it, timed-out requests would still be shown as actionable.
    """
    manager = _open_manager(db=db, data_dir=data_dir, persist=not no_persist)
    expired = manager.check_expired()
    requests = manager.list_pending()
    now = time.time()
    if as_json:
        typer.echo(json.dumps([_request_payload(r, now) for r in requests], indent=2, default=str))
        return
    for request in expired:
        typer.echo(f"expired during this call: {request.request_id} (action={request.action})")
    if not requests:
        typer.echo("No pending approvals.")
        return
    headers = [
        "request_id",
        "agent",
        "action",
        "resource",
        "risk_score",
        "age_seconds",
        "reason",
    ]
    rows = [
        [
            request.request_id,
            request.agent.agent_id,
            request.action,
            request.resource or "-",
            f"{request.risk_score:.2f}",
            f"{max(0.0, now - request.created_at):.1f}",
            request.reason or "-",
        ]
        for request in requests
    ]
    typer.echo(_render_table(headers, rows))
    typer.echo(f"\n{len(requests)} pending request(s). Decide with: approvals approve|deny <id>")


@approvals_app.command("show")
def show(
    request_id: Annotated[str, typer.Argument(help="Approval request id.")],
    data_dir: Annotated[Path | None, typer.Option("--data-dir", help=_DATA_DIR_HELP)] = None,
    db: Annotated[Path | None, typer.Option("--db", help=_DB_HELP)] = None,
    no_persist: Annotated[bool, typer.Option("--no-persist", help=_NO_PERSIST_HELP)] = False,
    as_json: Annotated[bool, typer.Option("--json", help="Print a JSON object.")] = False,
) -> None:
    """Show the full detail of one approval request and its decision record."""
    manager = _open_manager(db=db, data_dir=data_dir, persist=not no_persist)
    request = manager.get_request(request_id)
    if request is None:
        typer.echo(
            f"Approval request {request_id!r} was not found in the local store. "
            "Check the id and the --db/--data-dir options.",
            err=True,
        )
        raise typer.Exit(1)
    record = manager.get_record(request_id)
    if as_json:
        payload: dict[str, Any] = _request_payload(request, time.time())
        payload["record"] = _record_payload(record) if record else None
        typer.echo(json.dumps(payload, indent=2, default=str))
        return
    typer.echo(f"request_id    : {request.request_id}")
    typer.echo(f"status        : {request.status.value}")
    typer.echo(f"decision      : {request.decision.value if request.decision else '-'}")
    typer.echo(f"agent         : {request.agent.agent_id} (role={request.agent.role})")
    typer.echo(f"trust_level   : {getattr(request.agent.trust_level, 'value', '-')}")
    typer.echo(f"action        : {request.action}")
    typer.echo(f"resource      : {request.resource or '-'}")
    typer.echo(f"risk_score    : {request.risk_score:.2f}")
    typer.echo(f"created_at    : {_fmt_time(request.created_at)}")
    typer.echo(f"reviewed_at   : {_fmt_time(request.reviewed_at)}")
    typer.echo(f"reviewer      : {request.reviewer or '-'}")
    typer.echo(f"reason        : {request.reason or '-'}")
    # context/metadata are agent-supplied local audit data: rendered unredacted.
    typer.echo("context       :")
    typer.echo(_fmt_json(request.context))
    typer.echo("metadata      :")
    typer.echo(_fmt_json(request.metadata))
    typer.echo("record        :")
    if record is None:
        typer.echo("  none (no decision has been recorded for this request)")
    else:
        typer.echo(f"  decision     : {record.decision.value}")
        typer.echo(f"  reviewer     : {record.reviewer}")
        typer.echo(f"  timestamp    : {_fmt_time(record.timestamp)}")
        typer.echo(f"  reason       : {record.reason or '-'}")
    typer.echo(
        "\nNote: this approval is bound to the request id and the recorded "
        "action/resource/context only. It is not cryptographically bound to the "
        "code that will run."
    )
    if request.status == ApprovalStatus.PENDING:
        typer.echo(
            "Status PENDING blocks execution: the waiting execution proceeds only on "
            "APPROVED and raises SecurityError on any other terminal status."
        )


def _decide(
    decision: str,
    request_id: str,
    data_dir: Path | None,
    db: Path | None,
    no_persist: bool,
    reviewer: str,
    reason: str,
) -> None:
    """Shared implementation of the ``approve`` and ``deny`` commands.

    Raises:
        typer.Exit: With code 1 when the request is missing and code 2 when it was
            already decided or changed state concurrently. Neither case is silent.
    """
    manager = _open_manager(db=db, data_dir=data_dir, persist=not no_persist)
    existing = manager.get_request(request_id)
    if existing is None:
        typer.echo(f"Approval request {request_id!r} was not found in the local store.", err=True)
        raise typer.Exit(1)
    if existing.status != ApprovalStatus.PENDING:
        typer.echo(
            f"Approval request {request_id} is {existing.status.value}, not pending; "
            f"{decision} was not applied.",
            err=True,
        )
        _record_audit(
            manager,
            AuditEventType.USER_ACTION,
            actor=reviewer,
            action=existing.action,
            approval_id=request_id,
            result="not_applied",
            command=f"approvals {decision}",
            agent_id=existing.agent.agent_id,
            resource=existing.resource or "",
            error=f"request is {existing.status.value}",
        )
        raise typer.Exit(2)
    if decision == "approve":
        updated = manager.approve(request_id, reviewer, reason)
        event_type = AuditEventType.APPROVAL_GRANTED
    else:
        updated = manager.deny(request_id, reviewer, reason)
        event_type = AuditEventType.APPROVAL_DENIED
    if updated is None:
        # Another reviewer changed the state underneath us; refuse to claim success.
        typer.echo(
            f"Approval request {request_id} could not be {decision}d; its state changed "
            "concurrently. Run 'approvals show' to see the current status.",
            err=True,
        )
        _record_audit(
            manager,
            AuditEventType.USER_ACTION,
            actor=reviewer,
            action=existing.action,
            approval_id=request_id,
            result="not_applied",
            command=f"approvals {decision}",
            agent_id=existing.agent.agent_id,
            resource=existing.resource or "",
            error="concurrent state change",
        )
        raise typer.Exit(2)
    _record_audit(
        manager,
        event_type,
        actor=reviewer,
        action=updated.action,
        approval_id=request_id,
        result=f"{decision}d",
        command=f"approvals {decision}",
        agent_id=updated.agent.agent_id,
        resource=updated.resource or "",
        metadata={"risk_score": updated.risk_score, "reason": reason},
    )
    typer.echo(
        f"Approval request {request_id} {decision}d by {reviewer} "
        f"at {_fmt_time(time.time())}."
    )
    if decision == "approve":
        typer.echo(
            "A waiting execution blocked on this request will now proceed. The approval "
            "is not cryptographically bound to the code that will run."
        )
    else:
        typer.echo("A waiting execution blocked on this request will raise SecurityError.")


@approvals_app.command("approve")
def approve(
    request_id: Annotated[str, typer.Argument(help="Approval request id.")],
    data_dir: Annotated[Path | None, typer.Option("--data-dir", help=_DATA_DIR_HELP)] = None,
    db: Annotated[Path | None, typer.Option("--db", help=_DB_HELP)] = None,
    no_persist: Annotated[bool, typer.Option("--no-persist", help=_NO_PERSIST_HELP)] = False,
    reviewer: Annotated[
        str,
        typer.Option("--reviewer", help="Identity recorded as the reviewer."),
    ] = "",
    reason: Annotated[str, typer.Option("--reason", help="Why the request is approved.")] = "",
) -> None:
    """Approve a pending request; exits non-zero if it is missing or already decided."""
    _decide(
        "approve",
        request_id,
        data_dir,
        db,
        no_persist,
        reviewer or _default_reviewer(),
        reason,
    )


@approvals_app.command("deny")
def deny(
    request_id: Annotated[str, typer.Argument(help="Approval request id.")],
    data_dir: Annotated[Path | None, typer.Option("--data-dir", help=_DATA_DIR_HELP)] = None,
    db: Annotated[Path | None, typer.Option("--db", help=_DB_HELP)] = None,
    no_persist: Annotated[bool, typer.Option("--no-persist", help=_NO_PERSIST_HELP)] = False,
    reviewer: Annotated[
        str,
        typer.Option("--reviewer", help="Identity recorded as the reviewer."),
    ] = "",
    reason: Annotated[str, typer.Option("--reason", help="Why the request is denied.")] = "",
) -> None:
    """Deny a pending request; exits non-zero if it is missing or already decided."""
    _decide("deny", request_id, data_dir, db, no_persist, reviewer or _default_reviewer(), reason)


@approvals_app.command("history")
def history(
    data_dir: Annotated[Path | None, typer.Option("--data-dir", help=_DATA_DIR_HELP)] = None,
    db: Annotated[Path | None, typer.Option("--db", help=_DB_HELP)] = None,
    no_persist: Annotated[bool, typer.Option("--no-persist", help=_NO_PERSIST_HELP)] = False,
    agent_id: Annotated[str | None, typer.Option("--agent-id", help="Filter by agent id.")] = None,
    as_json: Annotated[bool, typer.Option("--json", help="Print a JSON array.")] = False,
) -> None:
    """List recorded approval decisions, newest first."""
    manager = _open_manager(db=db, data_dir=data_dir, persist=not no_persist)
    records = sorted(manager.list_records(agent_id), key=lambda r: r.timestamp, reverse=True)
    if as_json:
        typer.echo(json.dumps([_record_payload(r) for r in records], indent=2, default=str))
        return
    if not records:
        typer.echo(
            "No approval decisions recorded."
            if agent_id is None
            else f"No approval decisions for agent {agent_id}."
        )
        return
    headers = [
        "request_id",
        "agent_id",
        "action",
        "resource",
        "decision",
        "reviewer",
        "timestamp",
        "reason",
    ]
    rows = [
        [
            record.request_id,
            record.agent_id,
            record.action,
            record.resource or "-",
            record.decision.value,
            record.reviewer,
            _fmt_time(record.timestamp),
            record.reason or "-",
        ]
        for record in records
    ]
    typer.echo(_render_table(headers, rows))
    typer.echo(f"\n{len(records)} decision(s) recorded.")
