from __future__ import annotations

import threading
from contextlib import suppress
from dataclasses import dataclass
from typing import Protocol

from k1link.simulation.contracts import QualificationEvent, QualificationRun, RunState
from k1link.simulation.run_store import (
    ACTIVE_RECOVERY_STATES,
    QualificationRunConflictError,
    QualificationRunStore,
    QualificationRunTransitionError,
)


class SimulationOrchestratorError(RuntimeError):
    """The server-owned S1 lifecycle could not be completed safely."""


class ActiveQualificationRunError(SimulationOrchestratorError):
    """Another qualification run already owns the worker authority."""


@dataclass(frozen=True, slots=True)
class WorkerStartResult:
    provider_ids: tuple[str, ...]
    profile_sha256: str


@dataclass(frozen=True, slots=True)
class WorkerStopResult:
    stopped_provider_ids: tuple[str, ...]
    residue_provider_ids: tuple[str, ...]

    @property
    def clean(self) -> bool:
        return not self.residue_provider_ids


class SimulationWorkerPort(Protocol):
    """Transport-neutral worker boundary; no browser/PX4 shortcut exists."""

    def start(self, run: QualificationRun) -> WorkerStartResult: ...

    def pause(self, run: QualificationRun) -> None: ...

    def resume(self, run: QualificationRun) -> None: ...

    def step(self, run: QualificationRun, step_count: int) -> int: ...

    def stop(self, run: QualificationRun) -> WorkerStopResult: ...

    def reconcile(self, run: QualificationRun) -> WorkerStopResult: ...


class SimulationApplicationService:
    """Sole owner of one worker's persisted qualification-run lifecycle."""

    def __init__(self, store: QualificationRunStore, worker: SimulationWorkerPort) -> None:
        self.store = store
        self.worker = worker
        self._lock = threading.RLock()

    def start(
        self,
        run_id: str,
        *,
        idempotency_key: str,
        observed_at_utc: str,
        host_monotonic_ns: int,
    ) -> QualificationRun:
        key = _idempotency_key(idempotency_key)
        with self._lock:
            run = self.store.load(run_id)
            existing = self._start_request(run_id, key)
            if existing:
                if run.state is RunState.ADMITTED:
                    return self._continue_start(
                        run,
                        observed_at_utc=observed_at_utc,
                        host_monotonic_ns=host_monotonic_ns,
                    )
                return run
            if self._has_other_start_request(run_id):
                raise QualificationRunConflictError(
                    "qualification run is already bound to another start request"
                )
            active = tuple(
                candidate
                for candidate in self.store.list_runs()
                if candidate.run_id != run_id and candidate.state in ACTIVE_RECOVERY_STATES
            )
            if active:
                raise ActiveQualificationRunError(
                    f"worker authority is owned by {active[0].run_id}"
                )
            if run.state is not RunState.ADMITTED:
                raise QualificationRunTransitionError(
                    "only an admitted qualification run can start"
                )
            event = self.store.append_event(
                run_id,
                event_type="orchestrator.start-requested",
                observed_at_utc=observed_at_utc,
                host_monotonic_ns=host_monotonic_ns,
                payload={"idempotency_key": key},
                expected_revision=run.revision,
            )
            run = self.store.load(run_id)
            if run.revision != event.sequence:
                raise QualificationRunConflictError("start request journal changed")
            return self._continue_start(
                run,
                observed_at_utc=observed_at_utc,
                host_monotonic_ns=host_monotonic_ns,
            )

    def pause(
        self,
        run_id: str,
        *,
        observed_at_utc: str,
        host_monotonic_ns: int,
        sim_time_ns: int,
    ) -> QualificationRun:
        with self._lock:
            run = self.store.load(run_id)
            if run.state is not RunState.RUNNING:
                raise QualificationRunTransitionError("only a running run can pause")
            try:
                self.worker.pause(run)
            except Exception as exc:
                return self._fail_worker_operation(
                    run,
                    operation="pause",
                    exc=exc,
                    observed_at_utc=observed_at_utc,
                    host_monotonic_ns=host_monotonic_ns,
                    sim_time_ns=sim_time_ns,
                )
            return self.store.transition(
                run_id,
                RunState.PAUSED,
                expected_revision=run.revision,
                observed_at_utc=observed_at_utc,
                host_monotonic_ns=host_monotonic_ns,
                sim_time_ns=sim_time_ns,
            )

    def resume(
        self,
        run_id: str,
        *,
        observed_at_utc: str,
        host_monotonic_ns: int,
        sim_time_ns: int,
    ) -> QualificationRun:
        with self._lock:
            run = self.store.load(run_id)
            if run.state is not RunState.PAUSED:
                raise QualificationRunTransitionError("only a paused run can resume")
            try:
                self.worker.resume(run)
            except Exception as exc:
                return self._fail_worker_operation(
                    run,
                    operation="resume",
                    exc=exc,
                    observed_at_utc=observed_at_utc,
                    host_monotonic_ns=host_monotonic_ns,
                    sim_time_ns=sim_time_ns,
                )
            return self.store.transition(
                run_id,
                RunState.RUNNING,
                expected_revision=run.revision,
                observed_at_utc=observed_at_utc,
                host_monotonic_ns=host_monotonic_ns,
                sim_time_ns=sim_time_ns,
            )

    def step(
        self,
        run_id: str,
        *,
        step_count: int,
        observed_at_utc: str,
        host_monotonic_ns: int,
        sim_time_ns: int,
    ) -> int:
        if step_count < 1:
            raise ValueError("step count must be positive")
        with self._lock:
            run = self.store.load(run_id)
            if run.state is not RunState.PAUSED:
                raise QualificationRunTransitionError("single-step requires a paused run")
            try:
                advanced_ns = self.worker.step(run, step_count)
            except Exception as exc:
                self._fail_worker_operation(
                    run,
                    operation="step",
                    exc=exc,
                    observed_at_utc=observed_at_utc,
                    host_monotonic_ns=host_monotonic_ns,
                    sim_time_ns=sim_time_ns,
                )
                raise SimulationOrchestratorError("worker step failed") from exc
            if advanced_ns < 1:
                self._fail_worker_operation(
                    run,
                    operation="step-invalid-advance",
                    exc=ValueError("worker returned a non-positive clock advance"),
                    observed_at_utc=observed_at_utc,
                    host_monotonic_ns=host_monotonic_ns,
                    sim_time_ns=sim_time_ns,
                )
                raise SimulationOrchestratorError(
                    "worker step returned a non-positive clock advance"
                )
            self.store.append_event(
                run_id,
                event_type="clock.single-step",
                observed_at_utc=observed_at_utc,
                host_monotonic_ns=host_monotonic_ns,
                sim_time_ns=sim_time_ns + advanced_ns,
                payload={"step_count": step_count, "advanced_ns": advanced_ns},
                expected_revision=run.revision,
            )
            return advanced_ns

    def stop(
        self,
        run_id: str,
        *,
        idempotency_key: str,
        observed_at_utc: str,
        host_monotonic_ns: int,
        sim_time_ns: int | None,
        terminal_state: RunState = RunState.COMPLETED,
        reason: str = "operator-stop-clean",
    ) -> QualificationRun:
        key = _idempotency_key(idempotency_key)
        if terminal_state not in {RunState.COMPLETED, RunState.ABORTED}:
            raise ValueError("operator stop terminal state must be completed or aborted")
        with self._lock:
            run = self.store.load(run_id)
            existing = self._stop_request(run_id, key)
            if existing is not None and (
                existing.payload.get("terminal_state") != terminal_state.value
                or existing.payload.get("reason") != reason
            ):
                raise QualificationRunConflictError(
                    "idempotency key is bound to a different stop request"
                )
            if existing is not None and run.state.terminal:
                return run
            if existing is None:
                if self._has_other_stop_request(run_id):
                    raise QualificationRunConflictError(
                        "qualification run is already bound to another stop request"
                    )
                event = self.store.append_event(
                    run_id,
                    event_type="orchestrator.stop-requested",
                    observed_at_utc=observed_at_utc,
                    host_monotonic_ns=host_monotonic_ns,
                    sim_time_ns=sim_time_ns,
                    payload={
                        "idempotency_key": key,
                        "terminal_state": terminal_state.value,
                        "reason": reason,
                    },
                    expected_revision=run.revision,
                )
                run = self.store.load(run_id)
                if run.revision != event.sequence:
                    raise QualificationRunConflictError("stop request journal changed")
            if run.state not in {RunState.RUNNING, RunState.PAUSED, RunState.STOPPING}:
                raise QualificationRunTransitionError(
                    "only a running, paused or stopping run can stop"
                )
            if run.state is not RunState.STOPPING:
                run = self.store.transition(
                    run_id,
                    RunState.STOPPING,
                    expected_revision=run.revision,
                    observed_at_utc=observed_at_utc,
                    host_monotonic_ns=host_monotonic_ns,
                    sim_time_ns=sim_time_ns,
                )
            try:
                outcome = self.worker.stop(run)
            except Exception as exc:
                return self._terminal_failure(
                    run,
                    operation="stop",
                    detail=type(exc).__name__,
                    observed_at_utc=observed_at_utc,
                    host_monotonic_ns=host_monotonic_ns,
                    sim_time_ns=sim_time_ns,
                )
            if not outcome.clean:
                return self._terminal_failure(
                    run,
                    operation="stop-residue",
                    detail=",".join(outcome.residue_provider_ids),
                    observed_at_utc=observed_at_utc,
                    host_monotonic_ns=host_monotonic_ns,
                    sim_time_ns=sim_time_ns,
                )
            event = self.store.append_event(
                run_id,
                event_type="orchestrator.providers-stopped",
                observed_at_utc=observed_at_utc,
                host_monotonic_ns=host_monotonic_ns,
                sim_time_ns=sim_time_ns,
                payload={"provider_ids": list(outcome.stopped_provider_ids)},
                expected_revision=run.revision,
            )
            return self.store.transition(
                run_id,
                terminal_state,
                expected_revision=event.sequence,
                observed_at_utc=observed_at_utc,
                host_monotonic_ns=host_monotonic_ns,
                sim_time_ns=sim_time_ns,
                reason=reason,
            )

    def reset(
        self,
        run_id: str,
        *,
        idempotency_key: str,
        new_run_id: str,
        new_episode_id: str,
        observed_at_utc: str,
        host_monotonic_ns: int,
        sim_time_ns: int | None,
    ) -> QualificationRun:
        with self._lock:
            key = _idempotency_key(idempotency_key)
            if len(key) > 155:
                raise ValueError("reset idempotency key must contain at most 155 characters")
            terminal = self.stop(
                run_id,
                idempotency_key=f"{key}:stop",
                observed_at_utc=observed_at_utc,
                host_monotonic_ns=host_monotonic_ns,
                sim_time_ns=sim_time_ns,
                terminal_state=RunState.ABORTED,
                reason="operator-reset",
            )
            return self.store.create_reset_episode(
                terminal.run_id,
                run_id=new_run_id,
                episode_id=new_episode_id,
                created_at_utc=observed_at_utc,
            )

    def reconcile(
        self,
        *,
        observed_at_utc: str,
        host_monotonic_ns: int,
    ) -> tuple[QualificationRun, ...]:
        reconciled: list[QualificationRun] = []
        with self._lock:
            for run in self.store.list_runs():
                if run.state not in ACTIVE_RECOVERY_STATES:
                    continue
                try:
                    outcome = self.worker.reconcile(run)
                    residue = outcome.residue_provider_ids
                except Exception as exc:
                    residue = (f"reconcile-error:{type(exc).__name__}",)
                if residue:
                    event = self.store.append_event(
                        run.run_id,
                        event_type="orchestrator.recovery-residue",
                        observed_at_utc=observed_at_utc,
                        host_monotonic_ns=host_monotonic_ns,
                        payload={"provider_ids": list(residue)},
                        expected_revision=run.revision,
                    )
                    run = self.store.load(run.run_id)
                    if run.revision != event.sequence:
                        raise QualificationRunConflictError("recovery event journal changed")
                reconciled.append(
                    self.store.reconcile_interrupted(
                        run.run_id,
                        observed_at_utc=observed_at_utc,
                        host_monotonic_ns=host_monotonic_ns,
                    )
                )
        return tuple(reconciled)

    def _continue_start(
        self,
        run: QualificationRun,
        *,
        observed_at_utc: str,
        host_monotonic_ns: int,
    ) -> QualificationRun:
        starting = self.store.transition(
            run.run_id,
            RunState.STARTING,
            expected_revision=run.revision,
            observed_at_utc=observed_at_utc,
            host_monotonic_ns=host_monotonic_ns,
        )
        try:
            outcome = self.worker.start(starting)
        except Exception as exc:
            with suppress(Exception):
                self.worker.stop(starting)
            return self._terminal_failure(
                starting,
                operation="start",
                detail=_exception_detail(exc),
                observed_at_utc=observed_at_utc,
                host_monotonic_ns=host_monotonic_ns,
                sim_time_ns=0,
            )
        if outcome.profile_sha256 != starting.host_profile_sha256:
            with suppress(Exception):
                self.worker.stop(starting)
            return self._terminal_failure(
                starting,
                operation="profile-drift",
                detail=outcome.profile_sha256,
                observed_at_utc=observed_at_utc,
                host_monotonic_ns=host_monotonic_ns,
                sim_time_ns=0,
            )
        event = self.store.append_event(
            run.run_id,
            event_type="orchestrator.providers-started",
            observed_at_utc=observed_at_utc,
            host_monotonic_ns=host_monotonic_ns,
            sim_time_ns=0,
            payload={
                "provider_ids": list(outcome.provider_ids),
                "profile_sha256": outcome.profile_sha256,
            },
            expected_revision=starting.revision,
        )
        return self.store.transition(
            run.run_id,
            RunState.RUNNING,
            expected_revision=event.sequence,
            observed_at_utc=observed_at_utc,
            host_monotonic_ns=host_monotonic_ns,
            sim_time_ns=0,
        )

    def _fail_worker_operation(
        self,
        run: QualificationRun,
        *,
        operation: str,
        exc: Exception,
        observed_at_utc: str,
        host_monotonic_ns: int,
        sim_time_ns: int | None,
    ) -> QualificationRun:
        with suppress(Exception):
            self.worker.stop(run)
        return self._terminal_failure(
            run,
            operation=operation,
            detail=_exception_detail(exc),
            observed_at_utc=observed_at_utc,
            host_monotonic_ns=host_monotonic_ns,
            sim_time_ns=sim_time_ns,
        )

    def _terminal_failure(
        self,
        run: QualificationRun,
        *,
        operation: str,
        detail: str,
        observed_at_utc: str,
        host_monotonic_ns: int,
        sim_time_ns: int | None,
    ) -> QualificationRun:
        event = self.store.append_event(
            run.run_id,
            event_type="orchestrator.operation-failed",
            observed_at_utc=observed_at_utc,
            host_monotonic_ns=host_monotonic_ns,
            sim_time_ns=sim_time_ns,
            payload={"operation": operation, "detail": detail[:256]},
            expected_revision=run.revision,
        )
        return self.store.transition(
            run.run_id,
            RunState.FAILED,
            expected_revision=event.sequence,
            observed_at_utc=observed_at_utc,
            host_monotonic_ns=host_monotonic_ns,
            sim_time_ns=sim_time_ns,
            reason=f"orchestrator-{operation}-failed",
        )

    def _start_request(self, run_id: str, key: str) -> bool:
        return any(
            event.event_type == "orchestrator.start-requested"
            and event.payload.get("idempotency_key") == key
            for event in self.store.list_events(run_id)
        )

    def _has_other_start_request(self, run_id: str) -> bool:
        return any(
            event.event_type == "orchestrator.start-requested"
            for event in self.store.list_events(run_id)
        )

    def _stop_request(self, run_id: str, key: str) -> QualificationEvent | None:
        return next(
            (
                event
                for event in self.store.list_events(run_id)
                if event.event_type == "orchestrator.stop-requested"
                and event.payload.get("idempotency_key") == key
            ),
            None,
        )

    def _has_other_stop_request(self, run_id: str) -> bool:
        return any(
            event.event_type == "orchestrator.stop-requested"
            for event in self.store.list_events(run_id)
        )


def _exception_detail(exc: Exception) -> str:
    message = " ".join(str(exc).split())
    return type(exc).__name__ if not message else f"{type(exc).__name__}: {message}"


def _idempotency_key(value: str) -> str:
    normalized = value.strip()
    if not 1 <= len(normalized) <= 160:
        raise ValueError("idempotency key must contain 1..160 characters")
    return normalized
