from __future__ import annotations

import asyncio
import json
import threading
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from uuid import uuid4

import pytest
from missioncore_plugin_sdk.v0alpha2 import (
    RuntimeActionInvocation,
    RuntimeActionResult,
    RuntimeHandshakeRequest,
    RuntimeHandshakeResult,
    RuntimePluginDescriptor,
)
from pydantic import ValidationError

import k1link.web.device_plugin_composition as plugin_composition
from k1link.device_plugins.xgrids_k1.facade import (
    ACTION_DEVICE_CALIBRATION_SNAPSHOT_READ,
    ACTION_DISCOVERY_SCAN,
    ACTION_NETWORK_PROVISION,
    ACTION_STREAM_START_LIVE,
    ACTION_STREAM_START_REPLAY,
    ACTION_STREAM_STOP,
    ACTION_VIEWER_SETTINGS_UPDATE,
    XGRIDS_K1_PLUGIN_ID,
    XGRIDS_K1_PLUGIN_VERSION,
    CompatibilityAttestationRequest,
    ConnectRequest,
    ViewerSettingsRequest,
    XgridsK1PluginFacade,
)
from k1link.web.device_plugin_composition import (
    DevicePluginCompositionError,
    InstalledDevicePluginEnvironment,
    load_installed_device_plugins,
)
from k1link.web.plugin_catalog import DevicePluginCatalog
from k1link.web.plugin_runtime import (
    DevicePluginDispatcher,
    DevicePluginRuntimeContribution,
    InProcessDevicePluginRuntime,
    PluginActionNotFoundError,
    PluginExecutionError,
    PluginNotFoundError,
    PluginRuntimeCompatibilityError,
    PluginRuntimeUnavailableError,
)


class FakeXgridsService:
    def __init__(self) -> None:
        self.calls: list[tuple[str, object]] = []

    def state(self) -> dict[str, Any]:
        self.calls.append(("state", None))
        return {"phase": "idle"}

    def read_device_calibration_snapshot(self) -> dict[str, Any]:
        self.calls.append(("calibration", None))
        return {"status": "available", "snapshot_id": "fixture-snapshot"}

    async def scan_ble(self, duration_seconds: float) -> dict[str, Any]:
        self.calls.append(("scan", duration_seconds))
        return {"phase": "idle", "devices": []}

    async def connect(self, request: ConnectRequest) -> dict[str, Any]:
        self.calls.append(("connect", request))
        return {"phase": "connected", "k1_ip": "192.168.1.20"}

    def start_live(
        self,
        project_name: str,
        host: str | None,
        duration_seconds: float | None,
        compatibility_attestation: CompatibilityAttestationRequest,
    ) -> dict[str, Any]:
        self.calls.append(
            ("live", (project_name, host, duration_seconds, compatibility_attestation))
        )
        return {"phase": "live"}

    def start_replay(self, path: str, speed: float, loop: bool) -> dict[str, Any]:
        self.calls.append(("replay", (path, speed, loop)))
        return {"phase": "replay"}

    def stop(self) -> dict[str, Any]:
        self.calls.append(("stop", None))
        return {"phase": "idle"}

    def update_viewer_settings(self, request: ViewerSettingsRequest) -> dict[str, Any]:
        self.calls.append(("viewer", request))
        return {"phase": "idle", "viewer_settings": request.model_dump()}


def _in_process_runtime(
    adapter: Any,
    *,
    plugin_version: str = XGRIDS_K1_PLUGIN_VERSION,
    host_api_version: str = "missioncore.nodedc/v1alpha2",
    close: Any | None = None,
    activate: bool = True,
) -> InProcessDevicePluginRuntime:
    runtime = InProcessDevicePluginRuntime(
        adapter,
        RuntimePluginDescriptor(
            plugin_id=adapter.plugin_id,
            plugin_version=plugin_version,
            supported_host_api_versions=(host_api_version,),
            action_ids=tuple(sorted(adapter.action_ids)),
        ),
        **({"close": close} if close is not None else {}),
    )
    if activate:
        runtime.handshake(
            RuntimeHandshakeRequest(
                handshake_id=uuid4().hex,
                plugin_id=adapter.plugin_id,
                plugin_version=plugin_version,
                host_api_version=host_api_version,
                requested_at=datetime.now(UTC),
                required_action_ids=tuple(sorted(adapter.action_ids)),
            )
        )
    return runtime


def test_manifest_and_runtime_facade_declare_identical_actions() -> None:
    repository_root = Path(__file__).resolve().parents[1]
    manifest = next(
        candidate
        for candidate in DevicePluginCatalog(repository_root).manifests()
        if candidate.metadata.id == XGRIDS_K1_PLUGIN_ID
    )

    assert {action.id for action in manifest.spec.actions} == XgridsK1PluginFacade.action_ids


def test_calibration_snapshot_action_calls_the_read_only_service_method() -> None:
    service = FakeXgridsService()
    dispatcher = DevicePluginDispatcher(
        [_in_process_runtime(XgridsK1PluginFacade(service))]
    )

    result = asyncio.run(
        dispatcher.invoke(
            XGRIDS_K1_PLUGIN_ID,
            ACTION_DEVICE_CALIBRATION_SNAPSHOT_READ,
            {},
        )
    )

    assert result == {"status": "available", "snapshot_id": "fixture-snapshot"}
    assert service.calls == [("calibration", None)]


def test_repository_runtime_composition_exactly_matches_catalog() -> None:
    repository_root = Path(__file__).resolve().parents[1]
    environment = load_installed_device_plugins(repository_root)
    try:
        assert set(environment.dispatcher.action_declarations) == {
            manifest.metadata.id for manifest in environment.catalog.manifests()
        }
    finally:
        environment.close()


def test_composition_closes_a_runtime_that_does_not_match_its_manifest(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    repository_root = Path(__file__).resolve().parents[1]
    manifest = next(
        candidate
        for candidate in DevicePluginCatalog(repository_root).manifests()
        if candidate.metadata.id == XGRIDS_K1_PLUGIN_ID
    )
    mismatched_manifest = manifest.model_copy(
        update={"metadata": manifest.metadata.model_copy(update={"id": "example.other-plugin"})}
    )
    closed: list[bool] = []
    contribution = DevicePluginRuntimeContribution(
        runtime=_in_process_runtime(
            XgridsK1PluginFacade(FakeXgridsService()),
            close=lambda: closed.append(True),
            activate=False,
        ),
    )
    monkeypatch.setattr(
        plugin_composition,
        "_load_factory",
        lambda _: lambda __: contribution,
    )

    with pytest.raises(DevicePluginCompositionError, match="id mismatch"):
        plugin_composition._load_contribution(repository_root, mismatched_manifest)

    assert closed == [True]


def test_composition_rejects_an_uncorrelated_runtime_handshake(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    class UncorrelatedHandshakeRuntime(InProcessDevicePluginRuntime):
        def handshake(
            self,
            request: RuntimeHandshakeRequest,
        ) -> RuntimeHandshakeResult:
            result = super().handshake(request)
            return result.model_copy(update={"handshake_id": "another-handshake"})

    repository_root = Path(__file__).resolve().parents[1]
    manifest = next(
        candidate
        for candidate in DevicePluginCatalog(repository_root).manifests()
        if candidate.metadata.id == XGRIDS_K1_PLUGIN_ID
    )
    closed: list[bool] = []
    adapter = XgridsK1PluginFacade(FakeXgridsService())
    runtime = UncorrelatedHandshakeRuntime(
        adapter,
        RuntimePluginDescriptor(
            plugin_id=adapter.plugin_id,
            plugin_version=XGRIDS_K1_PLUGIN_VERSION,
            supported_host_api_versions=(manifest.apiVersion,),
            action_ids=tuple(sorted(adapter.action_ids)),
        ),
        close=lambda: closed.append(True),
    )
    monkeypatch.setattr(
        plugin_composition,
        "_load_factory",
        lambda _: lambda __: DevicePluginRuntimeContribution(runtime=runtime),
    )

    with pytest.raises(DevicePluginCompositionError, match="handshake is inconsistent"):
        plugin_composition._load_contribution(repository_root, manifest)

    assert closed == [True]


def test_composition_loads_and_dispatches_two_synthetic_plugins(
    monkeypatch: pytest.MonkeyPatch,
    tmp_path: Path,
) -> None:
    class SyntheticAdapter:
        action_ids = frozenset({"state.read"})

        def __init__(self, plugin_id: str) -> None:
            self.plugin_id = plugin_id

        async def invoke(
            self,
            invocation: RuntimeActionInvocation,
        ) -> dict[str, Any]:
            assert invocation.plugin_id == self.plugin_id
            assert invocation.action_id == "state.read"
            assert invocation.parameters == {}
            return {"plugin_id": self.plugin_id}

    closed: list[str] = []
    contributions = {
        f"synthetic:{suffix}": DevicePluginRuntimeContribution(
            runtime=_in_process_runtime(
                SyntheticAdapter(f"example.{suffix}"),
                plugin_version="0.1.0",
                host_api_version="missioncore.nodedc/v1alpha1",
                close=lambda suffix=suffix: closed.append(suffix),
                activate=False,
            ),
        )
        for suffix in ("first", "second")
    }

    for suffix in ("first", "second"):
        document = {
            "apiVersion": "missioncore.nodedc/v1alpha1",
            "kind": "DevicePlugin",
            "metadata": {
                "id": f"example.{suffix}",
                "version": "0.1.0",
                "displayName": f"Example {suffix}",
            },
            "spec": {
                "hostApiRange": "v1alpha1",
                "runtime": {
                    "backendEntrypoint": f"synthetic:{suffix}",
                    "isolation": "transitional-in-process",
                },
                "permissions": [],
                "actions": [{"id": "state.read", "mutating": False, "secretFields": []}],
                "models": [
                    {
                        "id": f"example.{suffix}-model",
                        "vendor": "Example",
                        "displayName": f"Example {suffix}",
                        "category": "Synthetic",
                        "description": "Synthetic runtime composition fixture.",
                        "verified": False,
                        "capabilities": [],
                        "ui": {
                            "slot": "device.connection",
                            "componentKey": f"example.{suffix}.connection",
                        },
                    }
                ],
            },
        }
        manifest_path = tmp_path / "plugins" / suffix / "plugin.manifest.json"
        manifest_path.parent.mkdir(parents=True)
        manifest_path.write_text(json.dumps(document), encoding="utf-8")

    monkeypatch.setattr(
        plugin_composition,
        "_load_factory",
        lambda entrypoint: lambda _: contributions[entrypoint],
    )
    environment = load_installed_device_plugins(tmp_path)
    try:
        for suffix in ("first", "second"):
            state = asyncio.run(
                environment.dispatcher.invoke(f"example.{suffix}", "state.read", {})
            )
            assert state == {"plugin_id": f"example.{suffix}"}
        assert {item["status"] for item in environment.runtime_health} == {"ready"}
    finally:
        environment.close()

    assert closed == ["second", "first"]


def test_environment_shutdown_attempts_every_plugin_after_close_failure(
    tmp_path: Path,
) -> None:
    closed: list[str] = []

    def failing_close() -> None:
        closed.append("failing")
        raise RuntimeError("synthetic close failure")

    environment = InstalledDevicePluginEnvironment(
        catalog=DevicePluginCatalog(tmp_path),
        dispatcher=DevicePluginDispatcher([]),
        legacy_routers=(),
        _contributions=(
            DevicePluginRuntimeContribution(
                runtime=_in_process_runtime(
                    XgridsK1PluginFacade(FakeXgridsService()),
                    close=lambda: closed.append("healthy"),
                ),
            ),
            DevicePluginRuntimeContribution(
                runtime=_in_process_runtime(
                    XgridsK1PluginFacade(FakeXgridsService()),
                    close=failing_close,
                ),
            ),
        ),
    )

    with pytest.raises(DevicePluginCompositionError, match="failed during shutdown") as raised:
        environment.close()

    assert closed == ["failing", "healthy"]
    assert "synthetic close failure" in " ".join(raised.value.__notes__)


def test_dispatcher_routes_allowlisted_action_to_xgrids_facade() -> None:
    service = FakeXgridsService()
    dispatcher = DevicePluginDispatcher([_in_process_runtime(XgridsK1PluginFacade(service))])

    state = asyncio.run(
        dispatcher.invoke(
            XGRIDS_K1_PLUGIN_ID,
            ACTION_DISCOVERY_SCAN,
            {"duration_seconds": 6},
        )
    )

    assert state == {"phase": "idle", "devices": []}
    assert service.calls == [("scan", 6.0)]


def test_runtime_classifies_non_json_plugin_output_as_execution_failure(
    caplog: pytest.LogCaptureFixture,
) -> None:
    retained_marker = "must-not-appear-in-the-server-log"

    class NonJsonAdapter:
        plugin_id = "example.non-json"
        action_ids = frozenset({"state.read"})

        async def invoke(
            self,
            _invocation: RuntimeActionInvocation,
        ) -> dict[str, Any]:
            return {"dialogue": {"response_evidence": (retained_marker,)}}

    dispatcher = DevicePluginDispatcher(
        [
            _in_process_runtime(
                NonJsonAdapter(),
                plugin_version="0.1.0",
                host_api_version="missioncore.nodedc/v1alpha2",
            )
        ]
    )

    with pytest.raises(PluginExecutionError, match="non-JSON"):
        asyncio.run(dispatcher.invoke("example.non-json", "state.read", {}))

    assert retained_marker not in caplog.text


def test_dispatcher_rejects_unknown_plugin_and_action() -> None:
    dispatcher = DevicePluginDispatcher(
        [_in_process_runtime(XgridsK1PluginFacade(FakeXgridsService()))]
    )

    with pytest.raises(PluginNotFoundError):
        asyncio.run(dispatcher.invoke("missing.plugin", ACTION_STREAM_STOP, {}))
    with pytest.raises(PluginActionNotFoundError):
        asyncio.run(dispatcher.invoke(XGRIDS_K1_PLUGIN_ID, "unknown.action", {}))


def test_facade_validates_payload_before_calling_service() -> None:
    service = FakeXgridsService()
    dispatcher = DevicePluginDispatcher([_in_process_runtime(XgridsK1PluginFacade(service))])

    with pytest.raises(ValidationError):
        asyncio.run(
            dispatcher.invoke(
                XGRIDS_K1_PLUGIN_ID,
                ACTION_NETWORK_PROVISION,
                {
                    "device_id": "id",
                    "ssid": "network",
                    "password": "x" * 24,
                    "extra": True,
                },
            )
        )

    assert service.calls == []


@pytest.mark.parametrize(
    ("action_id", "payload", "expected_call"),
    [
        (
            ACTION_STREAM_START_LIVE,
            {
                "project_name": "Plugin runtime test",
                "host": "192.168.1.20",
                "compatibility_attestation": {
                    "firmware_version": "3.0.2",
                    "topology": "direct-lan",
                    "verification": "live-device-info",
                },
            },
            "live",
        ),
        (
            ACTION_STREAM_START_REPLAY,
            {"path": "sessions/capture.k1mqtt", "speed": 1, "loop": False},
            "replay",
        ),
        (ACTION_STREAM_STOP, {}, "stop"),
        (
            ACTION_VIEWER_SETTINGS_UPDATE,
            {
                "point_size": 3,
                "color_mode": "height",
                "palette": "viridis",
                "custom_color": "#102030",
                "accumulation_seconds": 12,
                "show_points": True,
                "show_trajectory": True,
                "show_grid": False,
            },
            "viewer",
        ),
    ],
)
def test_facade_preserves_existing_runtime_operations(
    action_id: str,
    payload: dict[str, object],
    expected_call: str,
) -> None:
    service = FakeXgridsService()
    dispatcher = DevicePluginDispatcher([_in_process_runtime(XgridsK1PluginFacade(service))])

    asyncio.run(dispatcher.invoke(XGRIDS_K1_PLUGIN_ID, action_id, payload))

    assert service.calls[0][0] == expected_call


def test_sync_runtime_actions_run_outside_the_api_event_loop() -> None:
    class ThreadAwareService(FakeXgridsService):
        thread_id: int | None = None

        def stop(self) -> dict[str, Any]:
            self.thread_id = threading.get_ident()
            return super().stop()

    service = ThreadAwareService()
    dispatcher = DevicePluginDispatcher([_in_process_runtime(XgridsK1PluginFacade(service))])
    event_loop_thread = threading.get_ident()

    asyncio.run(dispatcher.invoke(XGRIDS_K1_PLUGIN_ID, ACTION_STREAM_STOP, {}))

    assert service.thread_id is not None
    assert service.thread_id != event_loop_thread


def test_runtime_requires_successful_handshake_before_dispatch() -> None:
    adapter = XgridsK1PluginFacade(FakeXgridsService())
    runtime = _in_process_runtime(adapter, activate=False)
    dispatcher = DevicePluginDispatcher([runtime])

    assert runtime.health().status == "starting"
    with pytest.raises(PluginRuntimeUnavailableError, match="not ready"):
        asyncio.run(dispatcher.invoke(XGRIDS_K1_PLUGIN_ID, ACTION_STREAM_STOP, {}))

    with pytest.raises(PluginRuntimeCompatibilityError, match="host API"):
        runtime.handshake(
            RuntimeHandshakeRequest(
                handshake_id="handshake-incompatible",
                plugin_id=XGRIDS_K1_PLUGIN_ID,
                plugin_version=XGRIDS_K1_PLUGIN_VERSION,
                host_api_version="missioncore.nodedc/v1alpha1",
                requested_at=datetime.now(UTC),
                required_action_ids=tuple(sorted(adapter.action_ids)),
            )
        )

    assert runtime.health().status == "starting"


def test_runtime_health_transitions_to_stopped_and_close_is_idempotent() -> None:
    closed: list[bool] = []
    runtime = _in_process_runtime(
        XgridsK1PluginFacade(FakeXgridsService()),
        close=lambda: closed.append(True),
    )

    assert runtime.health().status == "ready"
    runtime.close()
    runtime.close()

    assert runtime.health().status == "stopped"
    assert closed == [True]


def test_dispatcher_rejects_uncorrelated_transport_result() -> None:
    class UncorrelatedRuntime(InProcessDevicePluginRuntime):
        async def invoke(
            self,
            invocation: RuntimeActionInvocation,
        ) -> RuntimeActionResult:
            result = await super().invoke(invocation)
            return result.model_copy(update={"invocation_id": "another-invocation"})

    adapter = XgridsK1PluginFacade(FakeXgridsService())
    descriptor = RuntimePluginDescriptor(
        plugin_id=adapter.plugin_id,
        plugin_version=XGRIDS_K1_PLUGIN_VERSION,
        supported_host_api_versions=("missioncore.nodedc/v1alpha2",),
        action_ids=tuple(sorted(adapter.action_ids)),
    )
    runtime = UncorrelatedRuntime(adapter, descriptor)
    runtime.handshake(
        RuntimeHandshakeRequest(
            handshake_id="handshake-correlation",
            plugin_id=adapter.plugin_id,
            plugin_version=XGRIDS_K1_PLUGIN_VERSION,
            host_api_version="missioncore.nodedc/v1alpha2",
            requested_at=datetime.now(UTC),
            required_action_ids=descriptor.action_ids,
        )
    )

    with pytest.raises(PluginExecutionError, match="uncorrelated"):
        asyncio.run(
            DevicePluginDispatcher([runtime]).invoke(
                XGRIDS_K1_PLUGIN_ID,
                ACTION_STREAM_STOP,
                {},
            )
        )
