"""Consumer of the ``manual_actions`` queue.

The GUI (and other out-of-band tooling) records operator intent in the
SQLite ``manual_actions`` table; this consumer pulls those rows and
dispatches them through the same primitives the engine uses internally
(``KillSwitch.arm`` / ``disarm``, ``Orchestrator.run_*``) so the audit
chain remains the single source of truth for state transitions.

Supported kinds:

* ``arm_kill`` — payload ``{"reason": str}``; arms the kill switch.
* ``disarm_kill`` — payload ``{"reason": str}``; disarms it.
* ``run_cycle`` — payload ``{"cycle": "entry"|"monitor"|"health"}``;
  forces an immediate run of the named cycle. Only available when the
  consumer is invoked with a ``cycle_runners`` mapping (the orchestrator
  populates it at scheduler-install time).

Future kinds (``force_close``, ``approve_proposal``,
``reject_proposal``) are recognised by the ``ManualAction`` schema but
not yet wired up — the consumer marks them as
``result="not_supported"`` so they don't sit in the queue forever.
"""

from __future__ import annotations

import json
import logging
from collections.abc import Awaitable, Callable
from datetime import UTC, datetime
from typing import TYPE_CHECKING

from cerbero_bite.safety.kill_switch import KillSwitchError
from cerbero_bite.state import connect, transaction

if TYPE_CHECKING:
    from cerbero_bite.runtime.dependencies import RuntimeContext

__all__ = ["CycleRunner", "consume_manual_actions"]


CycleRunner = Callable[[], Awaitable[object]]


_log = logging.getLogger("cerbero_bite.runtime.manual_actions")
_CONSUMER_ID = "engine"


def _parse_payload(raw: str | None) -> dict[str, object]:
    if not raw:
        return {}
    try:
        parsed = json.loads(raw)
    except (TypeError, ValueError):
        return {}
    return parsed if isinstance(parsed, dict) else {}


async def consume_manual_actions(
    ctx: RuntimeContext,
    *,
    cycle_runners: dict[str, CycleRunner] | None = None,
    now: datetime | None = None,
) -> int:
    """Drain the queue. Return the number of actions processed.

    The function is synchronous at heart (SQLite + KillSwitch), but kept
    ``async def`` so the orchestrator can register it as an APScheduler
    coroutine without an extra wrapper. Each iteration fetches the next
    unconsumed row and processes it; the loop terminates when the queue
    is empty so a single tick can catch up after a long pause.
    """
    reference = (now or datetime.now(UTC)).astimezone(UTC)
    processed = 0

    while True:
        conn = connect(ctx.db_path)
        try:
            action = ctx.repository.next_unconsumed_action(conn)
        finally:
            conn.close()
        if action is None:
            break
        if action.id is None:
            _log.warning("manual_action without id, skipping")
            break

        payload = _parse_payload(action.payload_json)
        result = "ok"

        try:
            if action.kind == "arm_kill":
                reason = str(payload.get("reason", "manual via GUI"))
                ctx.kill_switch.arm(reason=reason, source="manual_gui")
            elif action.kind == "disarm_kill":
                reason = str(payload.get("reason", "manual via GUI"))
                ctx.kill_switch.disarm(reason=reason, source="manual_gui")
            elif action.kind == "run_cycle":
                cycle = str(payload.get("cycle", "")).strip().lower()
                if cycle_runners is None:
                    result = "not_supported"
                    _log.warning(
                        "run_cycle dispatched without cycle_runners; "
                        "falling back to not_supported"
                    )
                elif cycle not in cycle_runners:
                    result = f"error: unknown cycle '{cycle}'"
                else:
                    await cycle_runners[cycle]()
                    result = f"ok: ran {cycle}"
            else:
                result = "not_supported"
                _log.warning(
                    "manual_action kind=%s not supported yet", action.kind
                )
        except KillSwitchError as exc:
            _log.exception("kill switch transition failed")
            result = f"error: {type(exc).__name__}: {exc}"
        except Exception as exc:  # pragma: no cover — defensive
            _log.exception("manual_action dispatch failed")
            result = f"error: {type(exc).__name__}: {exc}"

        conn = connect(ctx.db_path)
        try:
            with transaction(conn):
                ctx.repository.mark_action_consumed(
                    conn,
                    action.id,
                    consumed_by=_CONSUMER_ID,
                    result=result,
                    now=reference,
                )
        finally:
            conn.close()
        processed += 1

    if processed:
        _log.info("processed %d manual_actions", processed)
    return processed
