from __future__ import annotations

import hashlib
import os
import sys
from pathlib import Path, PurePosixPath

import pytest

from k1link.simulation import (
    ActiveQualificationRunError,
    AuthorityProfile,
    PosixProcessSupervisor,
    ProcessSpec,
    ProcessSupervisorError,
    ProviderPin,
    QualificationRun,
    QualificationRunConflictError,
    QualificationRunStore,
    ReproducibilityTier,
    RunKind,
    RunState,
    S0WorkerGuard,
    SimulationApplicationService,
    StockRoverTargetPaths,
    WorkerAdmissionError,
    WorkerStartResult,
    WorkerStopResult,
    load_stock_rover_lifecycle_profile,
    stock_rover_process_specs,
)

PROFILE = Path(__file__).resolve().parents[1] / "simulation/s0/qualification-profile.yaml"
S1B_PROFILE = Path(__file__).resolve().parents[1] / "simulation/s1/stock-rover-lifecycle.yaml"
SHA_A = "a" * 64
SHA_B = "b" * 64


def _run(
    run_id: str = "run-s1b-001",
    episode_id: str = "episode-s1b-001",
    *,
    host_profile_sha256: str | None = None,
) -> QualificationRun:
    return QualificationRun(
        run_id=run_id,
        episode_id=episode_id,
        kind=RunKind.SIMULATION_CLOSED_LOOP,
        state=RunState.ADMITTED,
        scenario_generation="stock-rover-v1",
        scenario_sha256=SHA_A,
        profile_generation="s1-ackermann-v1",
        profile_sha256=SHA_B,
        mission_core_commit="12fba7126d247f809f8d135c5f68922eb08d4683",
        providers=(
            ProviderPin("px4-autopilot", "v1.17.0", "v1.17.0"),
            ProviderPin("gazebo", "harmonic", "8.14.0"),
        ),
        host_profile_id="ai-worker-px4-gazebo-v1",
        host_profile_sha256=host_profile_sha256 or ("c" * 64),
        seed=42,
        reproducibility_tier=ReproducibilityTier.R1,
        authority=AuthorityProfile(
            generation=1,
            command_ttl_max_ns=250_000_000,
            heartbeat_timeout_monotonic_ns=500_000_000,
        ),
        clock_domain="gazebo:/clock",
        created_at_utc="2026-07-24T18:00:00Z",
    )


class _FakeWorker:
    def __init__(self, profile_sha256: str = "c" * 64) -> None:
        self.profile_sha256 = profile_sha256
        self.calls: list[str] = []
        self.fail_start = False
        self.residue: tuple[str, ...] = ()

    def start(self, run: QualificationRun) -> WorkerStartResult:
        self.calls.append(f"start:{run.run_id}")
        if self.fail_start:
            raise RuntimeError("synthetic start failure")
        return WorkerStartResult(("gazebo-server", "px4-sitl"), self.profile_sha256)

    def pause(self, run: QualificationRun) -> None:
        self.calls.append(f"pause:{run.run_id}")

    def resume(self, run: QualificationRun) -> None:
        self.calls.append(f"resume:{run.run_id}")

    def step(self, run: QualificationRun, step_count: int) -> int:
        self.calls.append(f"step:{run.run_id}:{step_count}")
        return step_count * 2_000_000

    def stop(self, run: QualificationRun) -> WorkerStopResult:
        self.calls.append(f"stop:{run.run_id}")
        return WorkerStopResult(("px4-sitl", "gazebo-server"), self.residue)

    def reconcile(self, run: QualificationRun) -> WorkerStopResult:
        self.calls.append(f"reconcile:{run.run_id}")
        return WorkerStopResult(("px4-sitl", "gazebo-server"), self.residue)


def _service(tmp_path: Path) -> tuple[SimulationApplicationService, _FakeWorker]:
    store = QualificationRunStore(tmp_path)
    store.create(_run())
    worker = _FakeWorker()
    return SimulationApplicationService(store, worker), worker


def test_application_service_owns_idempotent_start_pause_step_resume_stop(
    tmp_path: Path,
) -> None:
    service, worker = _service(tmp_path)
    running = service.start(
        "run-s1b-001",
        idempotency_key="start-001",
        observed_at_utc="2026-07-24T18:00:01Z",
        host_monotonic_ns=1,
    )
    repeated = service.start(
        "run-s1b-001",
        idempotency_key="start-001",
        observed_at_utc="2026-07-24T18:00:02Z",
        host_monotonic_ns=2,
    )
    paused = service.pause(
        running.run_id,
        observed_at_utc="2026-07-24T18:00:03Z",
        host_monotonic_ns=3,
        sim_time_ns=2_000_000,
    )
    advanced = service.step(
        running.run_id,
        step_count=2,
        observed_at_utc="2026-07-24T18:00:04Z",
        host_monotonic_ns=4,
        sim_time_ns=2_000_000,
    )
    resumed = service.resume(
        running.run_id,
        observed_at_utc="2026-07-24T18:00:05Z",
        host_monotonic_ns=5,
        sim_time_ns=6_000_000,
    )
    completed = service.stop(
        running.run_id,
        idempotency_key="stop-001",
        observed_at_utc="2026-07-24T18:00:06Z",
        host_monotonic_ns=6,
        sim_time_ns=6_000_000,
    )
    repeated_stop = service.stop(
        running.run_id,
        idempotency_key="stop-001",
        observed_at_utc="2026-07-24T18:00:07Z",
        host_monotonic_ns=7,
        sim_time_ns=6_000_000,
    )

    assert running.state is RunState.RUNNING
    assert repeated.state is RunState.RUNNING
    assert paused.state is RunState.PAUSED
    assert advanced == 4_000_000
    assert resumed.state is RunState.RUNNING
    assert completed.state is RunState.COMPLETED
    assert repeated_stop == completed
    assert worker.calls.count("start:run-s1b-001") == 1
    assert worker.calls.count("stop:run-s1b-001") == 1


def test_application_service_rejects_second_active_owner_and_rebound_keys(
    tmp_path: Path,
) -> None:
    store = QualificationRunStore(tmp_path)
    store.create(_run())
    store.create(_run("run-s1b-002", "episode-s1b-002"))
    worker = _FakeWorker()
    service = SimulationApplicationService(store, worker)
    service.start(
        "run-s1b-001",
        idempotency_key="start-001",
        observed_at_utc="2026-07-24T18:00:01Z",
        host_monotonic_ns=1,
    )

    with pytest.raises(ActiveQualificationRunError, match="owned"):
        service.start(
            "run-s1b-002",
            idempotency_key="start-002",
            observed_at_utc="2026-07-24T18:00:02Z",
            host_monotonic_ns=2,
        )
    with pytest.raises(QualificationRunConflictError, match="another start"):
        service.start(
            "run-s1b-001",
            idempotency_key="different",
            observed_at_utc="2026-07-24T18:00:02Z",
            host_monotonic_ns=2,
        )


def test_start_failure_and_profile_drift_fail_terminal(tmp_path: Path) -> None:
    service, worker = _service(tmp_path)
    worker.fail_start = True
    failed = service.start(
        "run-s1b-001",
        idempotency_key="start-fail",
        observed_at_utc="2026-07-24T18:00:01Z",
        host_monotonic_ns=1,
    )
    assert failed.state is RunState.FAILED
    assert failed.terminal_reason == "orchestrator-start-failed"
    assert any(
        event.payload.get("detail") == "RuntimeError: synthetic start failure"
        for event in service.store.list_events("run-s1b-001")
    )

    store = QualificationRunStore(tmp_path / "drift")
    store.create(_run())
    drift_worker = _FakeWorker("d" * 64)
    drift_service = SimulationApplicationService(store, drift_worker)
    drifted = drift_service.start(
        "run-s1b-001",
        idempotency_key="start-drift",
        observed_at_utc="2026-07-24T18:00:01Z",
        host_monotonic_ns=1,
    )
    assert drifted.state is RunState.FAILED
    assert drifted.terminal_reason == "orchestrator-profile-drift-failed"
    assert "stop:run-s1b-001" in drift_worker.calls


def test_reset_stops_parent_and_creates_new_identity(tmp_path: Path) -> None:
    service, _ = _service(tmp_path)
    service.start(
        "run-s1b-001",
        idempotency_key="start-001",
        observed_at_utc="2026-07-24T18:00:01Z",
        host_monotonic_ns=1,
    )
    reset = service.reset(
        "run-s1b-001",
        idempotency_key="reset-001",
        new_run_id="run-s1b-002",
        new_episode_id="episode-s1b-002",
        observed_at_utc="2026-07-24T18:00:02Z",
        host_monotonic_ns=2,
        sim_time_ns=2_000_000,
    )

    assert service.store.load("run-s1b-001").state is RunState.ABORTED
    assert reset.state is RunState.ADMITTED
    assert reset.parent_run_id == "run-s1b-001"


def test_restart_reconcile_never_resumes_and_records_residue(tmp_path: Path) -> None:
    service, worker = _service(tmp_path)
    service.start(
        "run-s1b-001",
        idempotency_key="start-001",
        observed_at_utc="2026-07-24T18:00:01Z",
        host_monotonic_ns=1,
    )
    worker.residue = ("px4-sitl",)

    reconciled = service.reconcile(
        observed_at_utc="2026-07-24T18:01:00Z",
        host_monotonic_ns=60,
    )

    assert reconciled[0].state is RunState.FAILED
    assert reconciled[0].terminal_reason == "orchestrator-recovery-interrupted"
    assert any(
        event.event_type == "orchestrator.recovery-residue"
        for event in service.store.list_events("run-s1b-001")
    )


@pytest.mark.skipif(os.name != "posix", reason="target supervisor is POSIX-only")
def test_process_supervisor_owns_groups_and_stops_in_declared_order(
    tmp_path: Path,
) -> None:
    order_path = tmp_path / "order.log"
    child = (
        "import os,signal,time;"
        "p=os.environ['ORDER_FILE'];n=os.environ['NAME'];"
        "signal.signal(signal.SIGINT,lambda *_:(open(p,'a').write(n+'\\n'),exit(0)));"
        "time.sleep(60)"
    )
    supervisor = PosixProcessSupervisor(tmp_path / "runtime")
    records = supervisor.start(
        "run-process-001",
        (
            ProcessSpec(
                process_id="gazebo-server",
                argv=(sys.executable, "-c", child),
                start_order=1,
                shutdown_order=20,
                environment=(("ORDER_FILE", str(order_path)), ("NAME", "gazebo")),
            ),
            ProcessSpec(
                process_id="px4-sitl",
                argv=(sys.executable, "-c", child),
                start_order=2,
                shutdown_order=10,
                environment=(("ORDER_FILE", str(order_path)), ("NAME", "px4")),
            ),
        ),
    )

    assert [record.process_id for record in records] == ["gazebo-server", "px4-sitl"]
    assert all(record.pid == record.pgid for record in records)
    result = supervisor.stop()

    assert result.residue_process_ids == ()
    assert result.stopped_process_ids == ("px4-sitl", "gazebo-server")
    assert order_path.read_text(encoding="utf-8").splitlines() == ["px4", "gazebo"]
    assert supervisor.residue() == ()


@pytest.mark.skipif(os.name != "posix", reason="target supervisor is POSIX-only")
def test_process_supervisor_rolls_back_partial_start(tmp_path: Path) -> None:
    supervisor = PosixProcessSupervisor(tmp_path / "runtime")
    with pytest.raises(ProcessSupervisorError, match="exited during startup"):
        supervisor.start(
            "run-process-001",
            (
                ProcessSpec(
                    process_id="long-lived",
                    argv=(sys.executable, "-c", "import time;time.sleep(60)"),
                    start_order=1,
                    shutdown_order=10,
                ),
                ProcessSpec(
                    process_id="failed",
                    argv=(sys.executable, "-c", "raise SystemExit(7)"),
                    start_order=2,
                    shutdown_order=20,
                ),
            ),
        )
    assert supervisor.residue() == ()


@pytest.mark.skipif(os.name != "posix", reason="target supervisor is POSIX-only")
def test_process_supervisor_waits_for_all_declared_ready_markers(tmp_path: Path) -> None:
    child = (
        "import signal,sys,time;"
        "signal.signal(signal.SIGINT,lambda *_:exit(0));"
        "print('provider booting',flush=True);"
        "time.sleep(0.05);"
        "sys.stdout.write('transport ');sys.stdout.flush();"
        "time.sleep(0.05);"
        "print('connected',flush=True);"
        "time.sleep(60)"
    )
    supervisor = PosixProcessSupervisor(tmp_path / "runtime")

    records = supervisor.start(
        "run-ready-001",
        (
            ProcessSpec(
                process_id="ready-provider",
                argv=(sys.executable, "-c", child),
                start_order=1,
                shutdown_order=1,
                ready_log_patterns=("provider booting", "transport connected"),
                health_timeout_seconds=1,
                health_poll_interval_seconds=0.01,
            ),
        ),
    )

    assert tuple(record.process_id for record in records) == ("ready-provider",)
    assert supervisor.stop().residue_process_ids == ()


@pytest.mark.skipif(os.name != "posix", reason="target supervisor is POSIX-only")
def test_process_supervisor_readiness_timeout_rolls_back_without_residue(
    tmp_path: Path,
) -> None:
    supervisor = PosixProcessSupervisor(tmp_path / "runtime")

    with pytest.raises(ProcessSupervisorError, match="missing markers: never-ready"):
        supervisor.start(
            "run-ready-timeout-001",
            (
                ProcessSpec(
                    process_id="not-ready",
                    argv=(sys.executable, "-c", "import time;time.sleep(60)"),
                    start_order=1,
                    shutdown_order=1,
                    ready_log_patterns=("never-ready",),
                    health_timeout_seconds=0.05,
                    health_poll_interval_seconds=0.01,
                ),
            ),
        )

    assert supervisor.residue() == ()


@pytest.mark.skipif(os.name != "posix", reason="target supervisor is POSIX-only")
def test_process_supervisor_rejects_exit_before_readiness(tmp_path: Path) -> None:
    supervisor = PosixProcessSupervisor(tmp_path / "runtime")

    with pytest.raises(ProcessSupervisorError, match="exited before readiness"):
        supervisor.start(
            "run-ready-exit-001",
            (
                ProcessSpec(
                    process_id="early-exit",
                    argv=(sys.executable, "-c", "raise SystemExit(7)"),
                    start_order=1,
                    shutdown_order=1,
                    ready_log_patterns=("never-ready",),
                    health_timeout_seconds=1,
                    health_poll_interval_seconds=0.01,
                ),
            ),
        )

    assert supervisor.residue() == ()


def test_stock_rover_profile_builds_exact_reviewed_provider_graph() -> None:
    profile = load_stock_rover_lifecycle_profile(S1B_PROFILE)
    paths = StockRoverTargetPaths.from_d_root(Path("/mnt/d/NDC_MISSIONCORE/simulation"))

    specs = stock_rover_process_specs(paths, profile)

    assert profile.profile_id == "s1b-stock-rover-lifecycle-v1"
    assert tuple(spec.process_id for spec in specs) == (
        "micro-xrce-dds-agent",
        "px4-gazebo-stock-rover",
    )
    assert specs[0].ready_log_patterns == ("running...",)
    assert specs[1].argv[-2:] == ("px4_sitl", "gz_rover_ackermann")
    assert specs[1].ready_log_patterns[-1] == (
        "successfully created rt/fmu/out/vehicle_status_v1 data writer"
    )
    assert specs[1].keep_stdin_open
    assert dict(specs[1].environment) == {
        "HEADLESS": "1",
        "PX4_SIM_SPEED_FACTOR": "1",
    }


def test_ready_patterns_require_a_positive_timeout() -> None:
    with pytest.raises(ValueError, match="positive health timeout"):
        ProcessSpec(
            process_id="unsafe-readiness",
            argv=(sys.executable, "-c", "pass"),
            start_order=1,
            shutdown_order=1,
            ready_log_patterns=("ready",),
        )


def test_s0_worker_guard_binds_exact_digest_and_d_only_run_path() -> None:
    guard = S0WorkerGuard(PROFILE)
    run = _run(host_profile_sha256=hashlib.sha256(PROFILE.read_bytes()).hexdigest())
    root = PurePosixPath("/mnt/d/NDC_MISSIONCORE/simulation/artifacts/s1/runs/run-s1b-001")
    process_root = PurePosixPath("/mnt/d/NDC_MISSIONCORE/simulation/runtime/s1/runs/run-s1b-001")

    admission = guard.admit(run, root, process_root)

    assert admission.profile.profile_id == "ai-worker-px4-gazebo-v1"
    assert admission.profile_sha256 == run.host_profile_sha256
    with pytest.raises(WorkerAdmissionError, match="escapes"):
        guard.admit(run, PurePosixPath("/tmp/run-s1b-001"), process_root)
    with pytest.raises(WorkerAdmissionError, match="process root"):
        guard.admit(run, root, PurePosixPath("/tmp/run-s1b-001"))
    with pytest.raises(WorkerAdmissionError, match="digest"):
        guard.admit(_run(), root, process_root)
