from __future__ import annotations

import json
from dataclasses import replace
from pathlib import Path

import pytest

from k1link.simulation import (
    AckermannControlSetpoint,
    AuthorityProfile,
    CommandAuthorityScope,
    DifferentialControlSetpoint,
    ProviderPin,
    QualificationArtifact,
    QualificationRun,
    QualificationRunConflictError,
    QualificationRunIntegrityError,
    QualificationRunStore,
    QualificationRunTransitionError,
    ReproducibilityTier,
    RunKind,
    RunState,
    SimulationContractError,
)

SHA_A = "a" * 64
SHA_B = "b" * 64
SHA_C = "c" * 64


def _run(
    *,
    run_id: str = "run-s1-001",
    episode_id: str = "episode-s1-001",
    kind: RunKind = RunKind.SIMULATION_CLOSED_LOOP,
) -> QualificationRun:
    return QualificationRun(
        run_id=run_id,
        episode_id=episode_id,
        kind=kind,
        state=RunState.ADMITTED,
        scenario_generation="stock-rover-v1",
        scenario_sha256=SHA_A,
        profile_generation="s1-ackermann-v1",
        profile_sha256=SHA_B,
        mission_core_commit="a19053dfd47577fb66cc63f605ba29ea2314f6aa",
        providers=(
            ProviderPin(
                identifier="px4-autopilot",
                version="v1.17.0",
                revision="v1.17.0",
            ),
            ProviderPin(
                identifier="gazebo",
                version="harmonic",
                revision="8.9.0",
            ),
        ),
        host_profile_id="mission-gpu-s0",
        host_profile_sha256=SHA_C,
        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-24T17:00:00Z",
    )


def _start(store: QualificationRunStore, run_id: str = "run-s1-001") -> QualificationRun:
    starting = store.transition(
        run_id,
        RunState.STARTING,
        expected_revision=0,
        observed_at_utc="2026-07-24T17:00:01Z",
        host_monotonic_ns=1,
    )
    return store.transition(
        run_id,
        RunState.RUNNING,
        expected_revision=starting.revision,
        observed_at_utc="2026-07-24T17:00:02Z",
        host_monotonic_ns=2,
        sim_time_ns=0,
    )


def test_closed_loop_run_accepts_provider_neutral_simulation_clock() -> None:
    run = replace(_run(), clock_domain="unreal:fixed-step")

    assert run.clock_domain == "unreal:fixed-step"
    with pytest.raises(SimulationContractError, match="clock domain"):
        replace(run, clock_domain="../../host-clock")


def test_run_manifest_is_immutable_and_runtime_replays_from_events(tmp_path: Path) -> None:
    store = QualificationRunStore(tmp_path / "runs")
    created = store.create(_run())

    assert created.state is RunState.ADMITTED
    manifest_before = (tmp_path / "runs/run-s1-001/manifest.json").read_bytes()

    running = _start(store)
    restored = QualificationRunStore(tmp_path / "runs").load(running.run_id)

    assert restored.state is RunState.RUNNING
    assert restored.revision == 2
    assert restored.started_at_utc == "2026-07-24T17:00:02Z"
    assert (tmp_path / "runs/run-s1-001/manifest.json").read_bytes() == manifest_before
    assert len(tuple((tmp_path / "runs/run-s1-001/events").iterdir())) == 2


def test_read_only_store_replays_evidence_without_mutating_repository(
    tmp_path: Path,
) -> None:
    root = tmp_path / "runs"
    writer = QualificationRunStore(root)
    writer.create(_run())
    running = _start(writer)
    mode_before = root.stat().st_mode

    reader = QualificationRunStore(root, read_only=True)

    assert reader.load(running.run_id).state is RunState.RUNNING
    assert reader.list_runs()[0].run_id == running.run_id
    assert root.stat().st_mode == mode_before
    with pytest.raises(QualificationRunTransitionError, match="read-only"):
        reader.transition(
            running.run_id,
            RunState.PAUSED,
            expected_revision=running.revision,
            observed_at_utc="2026-07-24T17:00:03Z",
            host_monotonic_ns=3,
        )


def test_read_only_store_requires_an_existing_regular_repository(tmp_path: Path) -> None:
    with pytest.raises(QualificationRunIntegrityError, match="missing"):
        QualificationRunStore(tmp_path / "missing", read_only=True)

    linked = tmp_path / "linked"
    linked.symlink_to(tmp_path, target_is_directory=True)
    with pytest.raises(QualificationRunIntegrityError, match="symlink"):
        QualificationRunStore(linked, read_only=True)


def test_lifecycle_rejects_stale_illegal_and_post_terminal_changes(tmp_path: Path) -> None:
    store = QualificationRunStore(tmp_path)
    store.create(_run())

    with pytest.raises(QualificationRunTransitionError, match="not allowed"):
        store.transition(
            "run-s1-001",
            RunState.RUNNING,
            expected_revision=0,
            observed_at_utc="2026-07-24T17:00:01Z",
            host_monotonic_ns=1,
        )

    running = _start(store)
    with pytest.raises(QualificationRunConflictError, match="revision"):
        store.transition(
            running.run_id,
            RunState.PAUSED,
            expected_revision=0,
            observed_at_utc="2026-07-24T17:00:03Z",
            host_monotonic_ns=3,
        )

    stopping = store.transition(
        running.run_id,
        RunState.STOPPING,
        expected_revision=running.revision,
        observed_at_utc="2026-07-24T17:00:03Z",
        host_monotonic_ns=3,
    )
    completed = store.transition(
        running.run_id,
        RunState.COMPLETED,
        expected_revision=stopping.revision,
        observed_at_utc="2026-07-24T17:00:04Z",
        host_monotonic_ns=4,
        reason="goal-reached",
    )

    assert completed.terminal_reason == "goal-reached"
    assert completed.ended_at_utc == "2026-07-24T17:00:04Z"
    with pytest.raises(QualificationRunTransitionError, match="immutable"):
        store.append_event(
            completed.run_id,
            event_type="metric.observed",
            observed_at_utc="2026-07-24T17:00:05Z",
            host_monotonic_ns=5,
            payload={"name": "distance", "value": 1.0},
        )


def test_pause_resume_and_domain_event_share_monotonic_journal(tmp_path: Path) -> None:
    store = QualificationRunStore(tmp_path)
    store.create(_run())
    running = _start(store)
    event = store.append_event(
        running.run_id,
        event_type="watchdog.heartbeat",
        observed_at_utc="2026-07-24T17:00:03Z",
        host_monotonic_ns=3,
        sim_time_ns=2_000_000,
        payload={"authority_generation": 1},
        expected_revision=2,
    )
    paused = store.transition(
        running.run_id,
        RunState.PAUSED,
        expected_revision=event.sequence,
        observed_at_utc="2026-07-24T17:00:04Z",
        host_monotonic_ns=4,
        sim_time_ns=2_000_000,
    )
    resumed = store.transition(
        running.run_id,
        RunState.RUNNING,
        expected_revision=paused.revision,
        observed_at_utc="2026-07-24T17:00:05Z",
        host_monotonic_ns=5,
        sim_time_ns=2_000_000,
    )

    assert event.sequence == 3
    assert resumed.revision == 5
    assert resumed.started_at_utc == "2026-07-24T17:00:02Z"


def test_ackermann_and_differential_commands_are_typed_and_sequenced(
    tmp_path: Path,
) -> None:
    store = QualificationRunStore(tmp_path)
    store.create(_run())
    _start(store)

    first = AckermannControlSetpoint(
        run_id="run-s1-001",
        command_id="command-1",
        sequence=1,
        issued_at_sim_ns=10,
        valid_until_sim_ns=200_000_010,
        authority_generation=1,
        speed_mps=1.5,
        steering_normalized=-0.25,
    )
    second = DifferentialControlSetpoint(
        run_id="run-s1-001",
        command_id="command-2",
        sequence=2,
        issued_at_sim_ns=20,
        valid_until_sim_ns=200_000_020,
        authority_generation=1,
        speed_mps=0.5,
        yaw_rate_rps=0.2,
    )

    store.submit_command(first)
    store.submit_command(second)

    assert store.list_commands("run-s1-001") == (first, second)
    assert first.to_dict()["authority_scope"] == "virtual-only"
    assert first.to_dict()["source"] == "simulation-orchestrator"


def test_commands_fail_closed_on_state_sequence_authority_and_ttl(tmp_path: Path) -> None:
    store = QualificationRunStore(tmp_path)
    store.create(_run())
    command = AckermannControlSetpoint(
        run_id="run-s1-001",
        command_id="command-1",
        sequence=1,
        issued_at_sim_ns=0,
        valid_until_sim_ns=100,
        authority_generation=1,
        speed_mps=1.0,
        steering_normalized=0.0,
    )

    with pytest.raises(QualificationRunTransitionError, match="only while"):
        store.submit_command(command)
    _start(store)
    with pytest.raises(QualificationRunConflictError, match="authority"):
        store.submit_command(replace(command, authority_generation=2))
    with pytest.raises(QualificationRunConflictError, match="sequence"):
        store.submit_command(replace(command, sequence=2))
    with pytest.raises(QualificationRunTransitionError, match="TTL"):
        store.submit_command(replace(command, valid_until_sim_ns=250_000_001))


def test_command_contract_rejects_nonfinite_and_direct_actuator_authority() -> None:
    with pytest.raises(SimulationContractError, match="finite"):
        AckermannControlSetpoint(
            run_id="run-s1-001",
            command_id="command-1",
            sequence=1,
            issued_at_sim_ns=0,
            valid_until_sim_ns=100,
            authority_generation=1,
            speed_mps=float("nan"),
            steering_normalized=0.0,
        )
    with pytest.raises(SimulationContractError, match="-1..1"):
        AckermannControlSetpoint(
            run_id="run-s1-001",
            command_id="command-1",
            sequence=1,
            issued_at_sim_ns=0,
            valid_until_sim_ns=100,
            authority_generation=1,
            speed_mps=1.0,
            steering_normalized=1.1,
        )
    with pytest.raises(SimulationContractError, match="without actuator"):
        AuthorityProfile(
            generation=1,
            command_ttl_max_ns=100,
            heartbeat_timeout_monotonic_ns=100,
            direct_actuator_setpoints_allowed=True,
        )
    with pytest.raises(SimulationContractError, match="virtual-only"):
        replace(
            AckermannControlSetpoint(
                run_id="run-s1-001",
                command_id="command-scope",
                sequence=1,
                issued_at_sim_ns=0,
                valid_until_sim_ns=100,
                authority_generation=1,
                speed_mps=0.0,
                steering_normalized=0.0,
            ),
            authority_scope="physical",  # type: ignore[arg-type]
        )
    assert CommandAuthorityScope.VIRTUAL_ONLY.value == "virtual-only"
    with pytest.raises(SimulationContractError, match="valid explicit UTC"):
        replace(_run(), created_at_utc="2026-02-30T17:00:00Z")


def test_replay_and_ungated_hil_cannot_acquire_command_authority(tmp_path: Path) -> None:
    store = QualificationRunStore(tmp_path)
    store.create(_run(kind=RunKind.REPLAY_SHADOW))
    _start(store)
    command = AckermannControlSetpoint(
        run_id="run-s1-001",
        command_id="command-1",
        sequence=1,
        issued_at_sim_ns=0,
        valid_until_sim_ns=100,
        authority_generation=1,
        speed_mps=1.0,
        steering_normalized=0.0,
    )

    with pytest.raises(QualificationRunTransitionError, match="cannot causally"):
        store.submit_command(command)
    with pytest.raises(SimulationContractError, match="separate physical"):
        _run(kind=RunKind.HIL)


def test_artifact_index_is_append_only_confined_and_digest_bound(tmp_path: Path) -> None:
    store = QualificationRunStore(tmp_path)
    store.create(_run())
    admitted = store.register_artifact(
        "run-s1-001",
        QualificationArtifact(
            artifact_id="run-manifest-copy",
            kind="source.manifest",
            relative_path="source/run-manifest.json",
            sha256=SHA_A,
            byte_length=123,
            source_of_record=True,
        ),
    )

    assert admitted.sequence == 1
    assert store.load("run-s1-001").artifacts == (admitted,)
    with pytest.raises(QualificationRunConflictError, match="already registered"):
        store.register_artifact("run-s1-001", admitted)
    with pytest.raises(SimulationContractError, match="confined"):
        QualificationArtifact(
            artifact_id="escape",
            kind="source.log",
            relative_path="../outside.log",
            sha256=SHA_A,
            byte_length=1,
            source_of_record=True,
        )
    with pytest.raises(SimulationContractError, match="confined"):
        replace(admitted, artifact_id="noncanonical", relative_path="source//artifact.json")


def test_reset_creates_new_episode_identity_without_overwriting_parent(tmp_path: Path) -> None:
    store = QualificationRunStore(tmp_path)
    store.create(_run())
    running = _start(store)
    stopping = store.transition(
        running.run_id,
        RunState.STOPPING,
        expected_revision=running.revision,
        observed_at_utc="2026-07-24T17:00:03Z",
        host_monotonic_ns=3,
    )
    store.transition(
        running.run_id,
        RunState.ABORTED,
        expected_revision=stopping.revision,
        observed_at_utc="2026-07-24T17:00:04Z",
        host_monotonic_ns=4,
        reason="operator-reset",
    )

    reset = store.create_reset_episode(
        running.run_id,
        run_id="run-s1-002",
        episode_id="episode-s1-002",
        created_at_utc="2026-07-24T17:00:05Z",
    )

    assert reset.parent_run_id == "run-s1-001"
    assert reset.state is RunState.ADMITTED
    assert store.load("run-s1-001").state is RunState.ABORTED
    assert {item.run_id for item in store.list_runs()} == {"run-s1-001", "run-s1-002"}


def test_restart_reconciliation_fails_active_run_without_hidden_resume(tmp_path: Path) -> None:
    store = QualificationRunStore(tmp_path)
    store.create(_run())
    _start(store)

    recovered = QualificationRunStore(tmp_path).reconcile_interrupted(
        "run-s1-001",
        observed_at_utc="2026-07-24T17:01:00Z",
        host_monotonic_ns=1_000,
    )

    assert recovered.state is RunState.FAILED
    assert recovered.terminal_reason == "orchestrator-recovery-interrupted"
    assert len(QualificationRunStore(tmp_path).list_commands(recovered.run_id)) == 0


def test_corrupt_or_noncontiguous_journal_fails_closed(tmp_path: Path) -> None:
    store = QualificationRunStore(tmp_path)
    store.create(_run())
    _start(store)
    events = tmp_path / "run-s1-001/events"
    (events / "00000000000000000002.json").rename(events / "00000000000000000003.json")

    with pytest.raises(QualificationRunIntegrityError, match="contiguous"):
        store.load("run-s1-001")


def test_manifest_identity_tampering_is_detected(tmp_path: Path) -> None:
    store = QualificationRunStore(tmp_path)
    store.create(_run())
    manifest = tmp_path / "run-s1-001/manifest.json"
    document = json.loads(manifest.read_text(encoding="utf-8"))
    document["run_id"] = "other-run"
    manifest.write_text(json.dumps(document), encoding="utf-8")

    with pytest.raises(QualificationRunIntegrityError, match="identity"):
        store.load("run-s1-001")
