from __future__ import annotations

import threading
from collections.abc import Callable, Iterable, Mapping
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
from typing import Any, Literal
from uuid import UUID, uuid4

OperationStatus = Literal[
    "accepted",
    "running",
    "operator_action_required",
    "succeeded",
    "failed",
    "cancelled",
    "timed_out",
    "interrupted",
]
AcquisitionState = Literal[
    "preparing",
    "prepared",
    "awaiting_external_start",
    "starting",
    "acquiring",
    "awaiting_external_stop",
    "stopping",
    "finalizing",
    "completed",
    "failed",
    "aborted",
    "interrupted",
]
ControlMode = Literal["operator-manual", "plugin-commanded", "observe-only"]

TERMINAL_OPERATION_STATUSES: frozenset[OperationStatus] = frozenset(
    {"succeeded", "failed", "cancelled", "timed_out", "interrupted"}
)
TERMINAL_ACQUISITION_STATES: frozenset[AcquisitionState] = frozenset(
    {"completed", "failed", "aborted", "interrupted"}
)


def utc_now() -> datetime:
    return datetime.now(UTC)


def _iso(value: datetime | None) -> str | None:
    return value.isoformat().replace("+00:00", "Z") if value is not None else None


def _identifier(value: str | None, *, prefix: str) -> str:
    if value is None:
        return f"{prefix}-{uuid4()}"
    candidate = value.strip()
    if not candidate or len(candidate) > 128:
        raise ValueError(f"{prefix} id must contain 1..128 characters")
    try:
        UUID(candidate.removeprefix(f"{prefix}-"))
    except ValueError as exc:
        raise ValueError(f"{prefix} id must be a generated UUID identifier") from exc
    return candidate


@dataclass(slots=True)
class OperationRecord:
    operation_id: str
    action: str
    status: OperationStatus
    accepted_at: datetime
    device_id: str | None = None
    device_session_id: str | None = None
    idempotency_key: str | None = None
    deadline_at: datetime | None = None
    stage_code: str = "accepted"
    message_code: str = "operation.accepted"
    sequence: int = 1
    state_revision: int = 1
    completed_at: datetime | None = None
    cancellable: bool = False
    cancel_requested: bool = False
    result: dict[str, Any] | None = None
    error: dict[str, Any] | None = None
    evidence_refs: tuple[str, ...] = ()
    # A keyed, non-reversible digest supplied by the service. It is deliberately
    # excluded from API snapshots: callers only need mismatch detection, while
    # the journal must never retain action inputs or secret material.
    request_fingerprint: str | None = field(default=None, repr=False)

    def as_dict(self) -> dict[str, Any]:
        return {
            "schema_version": "missioncore.operation-snapshot/v1alpha2",
            "operation_id": self.operation_id,
            "action": self.action,
            "status": self.status,
            "accepted_at": _iso(self.accepted_at),
            "completed_at": _iso(self.completed_at),
            "deadline_at": _iso(self.deadline_at),
            "device_id": self.device_id,
            "device_session_id": self.device_session_id,
            "idempotency_key": self.idempotency_key,
            "stage_code": self.stage_code,
            "message_code": self.message_code,
            "sequence": self.sequence,
            "state_revision": self.state_revision,
            "cancellable": self.cancellable,
            "cancel_requested": self.cancel_requested,
            "result": dict(self.result) if self.result is not None else None,
            "error": dict(self.error) if self.error is not None else None,
            "evidence_refs": list(self.evidence_refs),
        }


class OperationJournal:
    """Bounded, secret-free operation journal for one in-process plugin runtime.

    The journal deliberately stores lifecycle metadata only. Action inputs, BLE
    frames, MQTT payloads and credentials never enter operation events.
    """

    def __init__(
        self,
        *,
        max_records: int = 128,
        clock: Callable[[], datetime] = utc_now,
    ) -> None:
        if max_records < 1:
            raise ValueError("max_records must be positive")
        self._max_records = max_records
        self._clock = clock
        self._lock = threading.Lock()
        self._records: dict[str, OperationRecord] = {}
        self._order: list[str] = []
        self._idempotency: dict[str, str] = {}

    def begin(
        self,
        action: str,
        *,
        operation_id: str | None = None,
        idempotency_key: str | None = None,
        device_id: str | None = None,
        device_session_id: str | None = None,
        deadline_seconds: float | None = None,
        cancellable: bool = False,
        request_fingerprint: str | None = None,
    ) -> tuple[OperationRecord, bool]:
        action = action.strip()
        if not action:
            raise ValueError("operation action cannot be blank")
        if idempotency_key is not None:
            idempotency_key = idempotency_key.strip()
            if not idempotency_key or len(idempotency_key) > 160:
                raise ValueError("idempotency key must contain 1..160 characters")
        if deadline_seconds is not None and not 0 < deadline_seconds <= 86_400:
            raise ValueError("operation deadline must be within 1..86400 seconds")

        with self._lock:
            if idempotency_key is not None and idempotency_key in self._idempotency:
                existing_idempotent = self._records[self._idempotency[idempotency_key]]
                if existing_idempotent.action != action:
                    raise ValueError("idempotency key is already bound to another action")
                if existing_idempotent.request_fingerprint != request_fingerprint:
                    raise ValueError("idempotency key is already bound to a different request")
                return existing_idempotent, False

            resolved_id = _identifier(operation_id, prefix="op")
            existing_by_id = self._records.get(resolved_id)
            if existing_by_id is not None:
                if existing_by_id.action != action:
                    raise ValueError("operation id is already bound to another action")
                if existing_by_id.request_fingerprint != request_fingerprint:
                    raise ValueError("operation id is already bound to a different request")
                return existing_by_id, False

            now = self._clock()
            record = OperationRecord(
                operation_id=resolved_id,
                action=action,
                status="accepted",
                accepted_at=now,
                device_id=device_id,
                device_session_id=device_session_id,
                idempotency_key=idempotency_key,
                deadline_at=(
                    now + timedelta(seconds=deadline_seconds)
                    if deadline_seconds is not None
                    else None
                ),
                cancellable=cancellable,
                request_fingerprint=request_fingerprint,
            )
            self._records[resolved_id] = record
            self._order.append(resolved_id)
            if idempotency_key is not None:
                self._idempotency[idempotency_key] = resolved_id
            self._trim_locked()
            return record, True

    def transition(
        self,
        operation_id: str,
        status: OperationStatus,
        *,
        stage_code: str,
        message_code: str,
        result: Mapping[str, Any] | None = None,
        error: Mapping[str, Any] | None = None,
        evidence_refs: Iterable[str] = (),
    ) -> OperationRecord:
        with self._lock:
            record = self._require_locked(operation_id)
            if record.status in TERMINAL_OPERATION_STATUSES:
                if record.status == status:
                    return record
                raise ValueError(f"operation {operation_id} is already terminal")
            record.status = status
            record.stage_code = stage_code
            record.message_code = message_code
            record.sequence += 1
            record.state_revision += 1
            record.result = dict(result) if result is not None else None
            record.error = dict(error) if error is not None else None
            record.evidence_refs = tuple(evidence_refs)
            if status in TERMINAL_OPERATION_STATUSES:
                record.completed_at = self._clock()
                self._trim_locked()
            return record

    def request_cancel(self, operation_id: str) -> OperationRecord:
        with self._lock:
            record = self._require_locked(operation_id)
            if record.status in TERMINAL_OPERATION_STATUSES:
                return record
            if not record.cancellable:
                raise ValueError(f"operation {operation_id} is not cancellable")
            record.cancel_requested = True
            record.sequence += 1
            record.state_revision += 1
            record.stage_code = "cancellation-requested"
            record.message_code = "operation.cancellation_requested"
            return record

    def transition_if_pending(
        self,
        operation_id: str | None,
        status: OperationStatus,
        *,
        stage_code: str,
        message_code: str,
        result: Mapping[str, Any] | None = None,
        error: Mapping[str, Any] | None = None,
        evidence_refs: Iterable[str] = (),
    ) -> OperationRecord | None:
        """Atomically transition an existing non-terminal operation.

        Lifecycle reconciliation and explicit stop/abort paths can race. This
        helper makes terminalization idempotent without exposing mutable journal
        records or turning an already-completed operation into an error.
        """

        with self._lock:
            if operation_id is None:
                return None
            record = self._records.get(operation_id)
            if record is None or record.status in TERMINAL_OPERATION_STATUSES:
                return record
            record.status = status
            record.stage_code = stage_code
            record.message_code = message_code
            record.sequence += 1
            record.state_revision += 1
            record.result = dict(result) if result is not None else None
            record.error = dict(error) if error is not None else None
            record.evidence_refs = tuple(evidence_refs)
            if status in TERMINAL_OPERATION_STATUSES:
                record.completed_at = self._clock()
                self._trim_locked()
            return record

    def get(self, operation_id: str) -> OperationRecord:
        with self._lock:
            return self._require_locked(operation_id)

    def latest(self) -> OperationRecord | None:
        with self._lock:
            return self._records[self._order[-1]] if self._order else None

    def snapshot(self, *, limit: int = 20) -> list[dict[str, Any]]:
        if limit < 1:
            return []
        with self._lock:
            return [self._records[item].as_dict() for item in self._order[-limit:]]

    def _require_locked(self, operation_id: str) -> OperationRecord:
        try:
            return self._records[operation_id]
        except KeyError as exc:
            raise KeyError(f"unknown operation: {operation_id}") from exc

    def _trim_locked(self) -> None:
        while len(self._order) > self._max_records:
            oldest_id = next(
                (
                    operation_id
                    for operation_id in self._order
                    if self._records[operation_id].status in TERMINAL_OPERATION_STATUSES
                ),
                None,
            )
            # Never evict an operation that still needs reconciliation. A brief
            # overrun is safer than turning a later device observation into an
            # unknown-operation failure.
            if oldest_id is None:
                return
            self._order.remove(oldest_id)
            oldest = self._records.pop(oldest_id)
            if oldest.idempotency_key is not None:
                self._idempotency.pop(oldest.idempotency_key, None)


@dataclass(slots=True)
class AcquisitionRecord:
    acquisition_id: str
    device_id: str
    device_session_id: str
    compatibility_profile_id: str
    control_mode: ControlMode
    requested_streams: tuple[str, ...]
    target_host: str
    duration_seconds: float | None
    evidence_policy: Literal["required", "best-effort", "disabled"]
    state: AcquisitionState = "preparing"
    state_revision: int = 1
    created_at: datetime = field(default_factory=utc_now)
    updated_at: datetime = field(default_factory=utc_now)
    message_code: str = "acquisition.preparing"
    operator_instructions: tuple[str, ...] = ()
    result: dict[str, Any] | None = None

    def transition(
        self,
        state: AcquisitionState,
        *,
        message_code: str,
        operator_instructions: Iterable[str] = (),
        result: Mapping[str, Any] | None = None,
    ) -> None:
        if self.state in TERMINAL_ACQUISITION_STATES:
            if self.state == state:
                return
            raise ValueError(f"acquisition {self.acquisition_id} is already terminal")
        self.state = state
        self.state_revision += 1
        self.updated_at = utc_now()
        self.message_code = message_code
        self.operator_instructions = tuple(operator_instructions)
        self.result = dict(result) if result is not None else None

    def as_dict(self) -> dict[str, Any]:
        return {
            "schema_version": "missioncore.acquisition-snapshot/v1alpha2",
            "acquisition_id": self.acquisition_id,
            "device_id": self.device_id,
            "device_session_id": self.device_session_id,
            "compatibility_profile_id": self.compatibility_profile_id,
            "control_mode": self.control_mode,
            "requested_streams": list(self.requested_streams),
            "target_host": self.target_host,
            "duration_seconds": self.duration_seconds,
            "evidence_policy": self.evidence_policy,
            "state": self.state,
            "state_revision": self.state_revision,
            "created_at": _iso(self.created_at),
            "updated_at": _iso(self.updated_at),
            "message_code": self.message_code,
            "operator_instructions": list(self.operator_instructions),
            "result": dict(self.result) if self.result is not None else None,
        }


def new_acquisition_id() -> str:
    return f"acq-{uuid4()}"


def new_device_id() -> str:
    return f"device-{uuid4()}"


def new_device_session_id() -> str:
    return f"device-session-{uuid4()}"
