from __future__ import annotations

import logging
import threading
import time
from dataclasses import dataclass
from typing import Any

import pytest

from k1link.device_plugins.xgrids_k1.protocol import application_session as session_module
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
    ApplicationControlAuthority,
    LiveDeviceControlBinding,
)
from k1link.device_plugins.xgrids_k1.protocol.application_session import (
    InteractiveApplicationControlSession,
    OperatorPresenceConfirmation,
)

APPLICATION_KEY = "00000000-0000-0000-0000-000000000000"


class FakeAuthorityLoader:
    def __init__(self) -> None:
        self.calls = 0

    def load(self) -> ApplicationControlAuthority:
        self.calls += 1
        return ApplicationControlAuthority(openapi_key=APPLICATION_KEY)


@dataclass
class FakeTransportSnapshot:
    state: str
    ready: bool

    def as_dict(self) -> dict[str, object]:
        return {
            "state": self.state,
            "publish_attempts": 0,
            "qos2_completions": 0,
            "correlated_responses": 0,
            "ignored_known_responses": 0,
            "late_known_responses": 0,
            "latest_device_session_state": "ready" if self.ready else "scanning",
            "latest_device_project_bound": not self.ready,
            "automatic_retry": False,
            "automatic_reconnect": False,
        }


class FakeTransport:
    def __init__(self, host: str) -> None:
        self.host = host
        self.state = "new"
        self.ready = True

    def open(self) -> FakeTransportSnapshot:
        self.state = "ready"
        return self.snapshot()

    def close(self) -> None:
        self.state = "closed"

    def snapshot(self) -> FakeTransportSnapshot:
        return FakeTransportSnapshot(self.state, self.ready)


class FakeExecutor:
    records: list[str] = []

    def __init__(self, transport: FakeTransport) -> None:
        self.transport = transport
        self.binding = LiveDeviceControlBinding(
            vendor_device_id="device-id",
            device_serial="serial-id",
            software_version="3.0.2",
            system_version="3.0.2",
            device_model="LixelKity K1",
            device_type="A4",
            is_activated=True,
        )

    def run_connection_stage(self, _orchestrator: object) -> LiveDeviceControlBinding:
        self.records.append("connection:1-6")
        return self.binding

    def wait_for_operator_checkpoint(
        self,
        event: str,
        observed: Any,
    ) -> str:
        self.records.append(f"wait:{event}")
        deadline = time.monotonic() + 2.0
        while not observed():
            if time.monotonic() >= deadline:
                raise TimeoutError(f"test did not release {event}")
            threading.Event().wait(0.005)
        return event

    def run_workspace_entry_stage(
        self,
        _orchestrator: object,
        _checkpoint: object,
    ) -> LiveDeviceControlBinding:
        self.records.append("workspace:7")
        return self.binding

    def run_project_prompt_stage(
        self,
        _orchestrator: object,
        _checkpoint: object,
    ) -> LiveDeviceControlBinding:
        self.records.append("project:8-10")
        return self.binding

    def execute_canonical_start(self, *_args: object, **_kwargs: object) -> object:
        self.records.append("start:11-14")
        self.transport.ready = False
        return object()

    def maintain_active_until_stop_requested(self, observed: Any) -> None:
        self.records.append("wait:stop")
        while not observed():
            threading.Event().wait(0.005)

    def execute_canonical_stop(self, *_args: object, **_kwargs: object) -> object:
        self.records.append("stop")
        self.transport.ready = True
        return object()

    def maintain_post_stop_until_standby(self) -> None:
        self.records.append("wait:device-standby")

    def snapshot(self) -> dict[str, object]:
        return {
            "records": list(self.records),
            "dialogue_stage": "test-stage",
            "start_attempted": False,
            "stop_attempted": False,
            "automatic_retry": False,
        }


def _confirmation() -> OperatorPresenceConfirmation:
    return OperatorPresenceConfirmation(
        operator_present=True,
        owner_controlled_device=True,
        lixelgo_closed=True,
        battery_storage_confirmed=True,
        expected_physical_state_confirmed=True,
    )


def _wait_phase(
    session: InteractiveApplicationControlSession,
    expected: str,
) -> dict[str, object]:
    deadline = time.monotonic() + 2.0
    while time.monotonic() < deadline:
        snapshot = session.snapshot()
        if snapshot["state"] == expected:
            return snapshot
        threading.Event().wait(0.005)
    raise AssertionError(f"session did not reach {expected}: {session.snapshot()}")


def test_canonical_stages_require_operator_events_but_device_standby_does_not(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    FakeExecutor.records = []
    monkeypatch.setattr(
        session_module,
        "PhysicalAcceptanceDialogueExecutor",
        FakeExecutor,
    )
    loader = FakeAuthorityLoader()
    transport = FakeTransport("192.168.1.20")
    session = InteractiveApplicationControlSession(
        loader,
        transport_factory=lambda _host: transport,  # type: ignore[arg-type]
        epoch_seconds=lambda: 1_752_680_000,
    )

    session.open(
        host="192.168.1.20",
        timezone_name="Europe/Moscow",
        confirmation=_confirmation(),
    )
    _wait_phase(session, "connection-ready")
    threading.Event().wait(0.02)
    assert FakeExecutor.records == ["connection:1-6", "wait:workspace-entered"]

    session.enter_workspace()
    _wait_phase(session, "workspace-ready")
    assert FakeExecutor.records[-1] == "wait:project-prompt-opened"

    session.open_project_prompt()
    _wait_phase(session, "project-ready")
    assert FakeExecutor.records[-1] == "wait:start-confirmed"

    session.request_start(project_name="TEST001", confirmation=_confirmation())
    _wait_phase(session, "scanning")
    assert FakeExecutor.records[-1] == "wait:stop"

    session.request_stop(confirmation=_confirmation())
    completed = _wait_phase(session, "completed")
    assert completed["can_confirm_standby"] is False
    assert completed["pending_operator_action"] is None
    assert loader.calls == 1
    assert completed["automatic_retry"] is False
    assert completed["scripted_transitions"] is False
    assert FakeExecutor.records == [
        "connection:1-6",
        "wait:workspace-entered",
        "workspace:7",
        "wait:project-prompt-opened",
        "project:8-10",
        "wait:start-confirmed",
        "start:11-14",
        "wait:stop",
        "stop",
        "wait:device-standby",
    ]


def test_prestart_failure_is_reported_and_requires_a_new_operator_click(
    monkeypatch: pytest.MonkeyPatch,
    caplog: pytest.LogCaptureFixture,
) -> None:
    caplog.set_level(logging.ERROR)

    class BootstrapFailureExecutor(FakeExecutor):
        def run_connection_stage(
            self,
            _orchestrator: object,
        ) -> LiveDeviceControlBinding:
            raise RuntimeError("bootstrap correlation failed")

        def snapshot(self) -> dict[str, object]:
            return {
                "dialogue_stage": "connection",
                "start_attempted": False,
                "stop_attempted": False,
                "response_evidence": [],
                "automatic_retry": False,
            }

    monkeypatch.setattr(
        session_module,
        "PhysicalAcceptanceDialogueExecutor",
        BootstrapFailureExecutor,
    )
    session = InteractiveApplicationControlSession(
        FakeAuthorityLoader(),
        transport_factory=lambda host: FakeTransport(host),  # type: ignore[arg-type]
    )

    session.open(
        host="192.168.1.20",
        timezone_name="Europe/Moscow",
        confirmation=_confirmation(),
    )
    _wait_phase(session, "failed")
    failed_thread = session._thread  # noqa: SLF001
    assert failed_thread is not None
    failed_thread.join(timeout=2.0)
    assert not failed_thread.is_alive()
    failed = session.snapshot()

    assert failed["outcome_unknown"] is False
    assert failed["can_open"] is True
    assert failed["automatic_retry"] is False
    assert failed["failure"] == {
        "code": "RuntimeError",
        "reason_code": "unexpected_runtime_error",
        "message": "bootstrap correlation failed",
        "failed_phase": "connecting",
        "dialogue_stage": "connection",
        "transport_state": "ready",
        "publish_attempts": 0,
        "qos2_completions": 0,
        "correlated_responses": 0,
        "ignored_known_responses": 0,
        "late_known_responses": 0,
        "modeling_command_attempted": False,
        "diagnostic_snapshot_unavailable": [],
        "diagnostic_evidence_unavailable": [],
        "correlation_failure": None,
        "compatibility_failure": None,
        "safe_to_retry": True,
    }
    assert "reason_code=unexpected_runtime_error" in caplog.text


def test_correlated_read_only_profile_mismatch_allows_only_a_fresh_explicit_attempt(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    class ProfileMismatchError(RuntimeError):
        reason_code = "compatibility_profile_mismatch"

    @dataclass
    class CorrelatedReadSnapshot:
        state: str

        def as_dict(self) -> dict[str, object]:
            return {
                "state": self.state,
                "publish_attempts": 1,
                "qos2_completions": 1,
                "correlated_responses": 1,
                "ignored_known_responses": 0,
                "late_known_responses": 0,
                "automatic_retry": False,
                "automatic_reconnect": False,
            }

    class CorrelatedReadTransport(FakeTransport):
        def snapshot(self) -> CorrelatedReadSnapshot:
            return CorrelatedReadSnapshot(self.state)

    class ProfileMismatchExecutor(FakeExecutor):
        def run_connection_stage(
            self,
            _orchestrator: object,
        ) -> LiveDeviceControlBinding:
            raise ProfileMismatchError("live DeviceInfo profile mismatch")

        def snapshot(self) -> dict[str, object]:
            return {
                "dialogue_stage": "new",
                "start_attempted": False,
                "stop_attempted": False,
                "response_evidence": [],
                "correlation_failure": None,
                "compatibility_failure": {
                    "operation_key": "bootstrap:1:DeviceInfoRequest",
                    "reason_code": "compatibility_profile_mismatch",
                },
                "automatic_retry": False,
            }

    monkeypatch.setattr(
        session_module,
        "PhysicalAcceptanceDialogueExecutor",
        ProfileMismatchExecutor,
    )
    session = InteractiveApplicationControlSession(
        FakeAuthorityLoader(),
        transport_factory=lambda host: CorrelatedReadTransport(host),  # type: ignore[arg-type]
    )

    session.open(
        host="192.168.1.20",
        timezone_name="Europe/Moscow",
        confirmation=_confirmation(),
    )
    failed_thread = session._thread  # noqa: SLF001
    assert failed_thread is not None
    failed_thread.join(timeout=2.0)
    assert not failed_thread.is_alive()

    failed = session.snapshot()
    assert failed["state"] == "failed"
    assert failed["outcome_unknown"] is False
    assert failed["automatic_retry"] is False
    assert failed["can_open"] is True
    failure = failed["failure"]
    assert isinstance(failure, dict)
    assert failure["reason_code"] == "compatibility_profile_mismatch"
    assert failure["modeling_command_attempted"] is False
    assert failure["safe_to_retry"] is True


def test_new_explicit_session_waits_for_old_worker_transport_retirement(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    old_close_entered = threading.Event()
    release_old_close = threading.Event()
    transports: list[TaggedTransport] = []

    @dataclass
    class TaggedTransportSnapshot:
        state: str
        ready: bool
        tag: str

        def as_dict(self) -> dict[str, object]:
            return {
                "state": self.state,
                "tag": self.tag,
                "publish_attempts": 0,
                "qos2_completions": 0,
                "correlated_responses": 0,
                "ignored_known_responses": 0,
                "late_known_responses": 0,
                "latest_device_session_state": "ready" if self.ready else "scanning",
                "latest_device_project_bound": not self.ready,
                "automatic_retry": False,
                "automatic_reconnect": False,
            }

    class TaggedTransport(FakeTransport):
        def __init__(self, host: str, tag: str) -> None:
            super().__init__(host)
            self.tag = tag
            self.close_calls = 0

        def close(self) -> None:
            if self.tag == "generation-1":
                old_close_entered.set()
                assert release_old_close.wait(timeout=2.0)
            self.close_calls += 1
            super().close()

        def snapshot(self) -> TaggedTransportSnapshot:
            return TaggedTransportSnapshot(self.state, self.ready, self.tag)

    class RacingExecutor(FakeExecutor):
        instances = 0

        def __init__(self, transport: TaggedTransport) -> None:
            super().__init__(transport)
            type(self).instances += 1
            self.instance = type(self).instances

        def run_connection_stage(
            self,
            orchestrator: object,
        ) -> LiveDeviceControlBinding:
            if self.instance == 1:
                raise RuntimeError("first generation failed before publish")
            return super().run_connection_stage(orchestrator)

    def transport_factory(host: str) -> TaggedTransport:
        transport = TaggedTransport(host, f"generation-{len(transports) + 1}")
        transports.append(transport)
        return transport

    FakeExecutor.records = []
    monkeypatch.setattr(
        session_module,
        "PhysicalAcceptanceDialogueExecutor",
        RacingExecutor,
    )
    session = InteractiveApplicationControlSession(
        FakeAuthorityLoader(),
        transport_factory=transport_factory,  # type: ignore[arg-type]
    )

    session.open(
        host="192.168.1.20",
        timezone_name="Europe/Moscow",
        confirmation=_confirmation(),
    )
    _wait_phase(session, "failed")
    assert old_close_entered.wait(timeout=2.0)
    old_thread = session._thread  # noqa: SLF001
    assert old_thread is not None

    # The failure is safe to retry at the protocol level, but the old worker
    # still owns its socket. A new explicit click must not construct or open a
    # second transport until close() completes and the worker exits.
    retiring = session.snapshot()
    assert retiring["failure"]["safe_to_retry"] is True  # type: ignore[index]
    assert retiring["can_open"] is False
    with pytest.raises(
        session_module.ApplicationAcceptanceError,
        match="already open or requires manual recovery",
    ):
        session.open(
            host="192.168.1.20",
            timezone_name="Europe/Moscow",
            confirmation=_confirmation(),
        )
    assert len(transports) == 1
    assert transports[0].close_calls == 0

    release_old_close.set()
    old_thread.join(timeout=2.0)
    assert not old_thread.is_alive()
    retired = session.snapshot()
    assert retired["state"] == "failed"
    assert retired["can_open"] is True
    assert transports[0].close_calls == 1

    # Only a fresh explicit action after retirement creates generation 2.
    session.open(
        host="192.168.1.20",
        timezone_name="Europe/Moscow",
        confirmation=_confirmation(),
    )
    new_thread = session._thread  # noqa: SLF001
    assert new_thread is not None
    current = _wait_phase(session, "connection-ready")
    assert current["transport"]["tag"] == "generation-2"  # type: ignore[index]
    assert session._transport is transports[1]  # noqa: SLF001
    assert transports[1].close_calls == 0

    # Complete the second synthetic session so no background waiter remains.
    session.enter_workspace()
    _wait_phase(session, "workspace-ready")
    session.open_project_prompt()
    _wait_phase(session, "project-ready")
    session.request_start(project_name="TEST002", confirmation=_confirmation())
    _wait_phase(session, "scanning")
    session.request_stop(confirmation=_confirmation())
    _wait_phase(session, "completed")
    new_thread.join(timeout=2.0)
    assert not new_thread.is_alive()
    assert transports[1].close_calls == 1


def test_start_outcome_unknown_blocks_reopen_even_when_transport_is_closed(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    class StartFailureExecutor(FakeExecutor):
        start_attempted = False

        def execute_canonical_start(self, *_args: object, **_kwargs: object) -> object:
            type(self).start_attempted = True
            raise session_module.ApplicationCommandOutcomeUnknown(
                "START response was not correlated"
            )

        def snapshot(self) -> dict[str, object]:
            return {
                "dialogue_stage": "start-attempted",
                "start_attempted": type(self).start_attempted,
                "stop_attempted": False,
                "response_evidence": [],
                "automatic_retry": False,
            }

    FakeExecutor.records = []
    StartFailureExecutor.start_attempted = False
    monkeypatch.setattr(
        session_module,
        "PhysicalAcceptanceDialogueExecutor",
        StartFailureExecutor,
    )
    session = InteractiveApplicationControlSession(
        FakeAuthorityLoader(),
        transport_factory=lambda host: FakeTransport(host),  # type: ignore[arg-type]
    )

    session.open(
        host="192.168.1.20",
        timezone_name="Europe/Moscow",
        confirmation=_confirmation(),
    )
    _wait_phase(session, "connection-ready")
    session.enter_workspace()
    _wait_phase(session, "workspace-ready")
    session.open_project_prompt()
    _wait_phase(session, "project-ready")
    session.request_start(project_name="TEST001", confirmation=_confirmation())
    failed = _wait_phase(session, "failed")

    assert failed["outcome_unknown"] is True
    assert failed["can_open"] is False
    assert failed["failure"]["modeling_command_attempted"] is True  # type: ignore[index]
    assert failed["failure"]["safe_to_retry"] is False  # type: ignore[index]


def test_unavailable_transport_snapshot_after_publish_blocks_reopen(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    executor_entered = threading.Event()
    release_failure = threading.Event()

    class SnapshotFailureTransport(FakeTransport):
        def __init__(self, host: str) -> None:
            super().__init__(host)
            self.simulated_publish_attempts = 0
            self.snapshot_unavailable = False

        def snapshot(self) -> FakeTransportSnapshot:
            if self.snapshot_unavailable:
                raise RuntimeError("simulated transport snapshot failure")
            return super().snapshot()

    class PublishedThenFailedExecutor(FakeExecutor):
        def run_connection_stage(
            self,
            _orchestrator: object,
        ) -> LiveDeviceControlBinding:
            executor_entered.set()
            assert release_failure.wait(timeout=2.0)
            assert isinstance(self.transport, SnapshotFailureTransport)
            self.transport.simulated_publish_attempts = 1
            self.transport.snapshot_unavailable = True
            raise RuntimeError("bootstrap failed after simulated publish")

    monkeypatch.setattr(
        session_module,
        "PhysicalAcceptanceDialogueExecutor",
        PublishedThenFailedExecutor,
    )
    transport = SnapshotFailureTransport("192.168.1.20")
    session = InteractiveApplicationControlSession(
        FakeAuthorityLoader(),
        transport_factory=lambda _host: transport,  # type: ignore[arg-type]
    )

    session.open(
        host="192.168.1.20",
        timezone_name="Europe/Moscow",
        confirmation=_confirmation(),
    )
    assert executor_entered.wait(timeout=2.0)
    failed_thread = session._thread  # noqa: SLF001
    assert failed_thread is not None
    release_failure.set()
    failed_thread.join(timeout=2.0)
    assert not failed_thread.is_alive()
    failed = session.snapshot()

    assert transport.simulated_publish_attempts == 1
    assert failed["state"] == "failed"
    assert failed["outcome_unknown"] is True
    assert failed["can_open"] is False
    failure = failed["failure"]
    assert isinstance(failure, dict)
    assert failure["transport_state"] is None
    assert failure["publish_attempts"] is None
    assert failure["diagnostic_snapshot_unavailable"] == ["transport"]
    assert failure["diagnostic_evidence_unavailable"] == ["transport.publish_attempts"]
    assert failure["modeling_command_attempted"] is False
    assert failure["safe_to_retry"] is False
    with pytest.raises(
        session_module.ApplicationAcceptanceError,
        match="already open or requires manual recovery",
    ):
        session.open(
            host="192.168.1.20",
            timezone_name="Europe/Moscow",
            confirmation=_confirmation(),
        )


def test_unavailable_dialogue_snapshot_is_outcome_unknown(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    class DialogueSnapshotFailureExecutor(FakeExecutor):
        def run_connection_stage(
            self,
            _orchestrator: object,
        ) -> LiveDeviceControlBinding:
            raise RuntimeError("bootstrap failed with unavailable dialogue evidence")

        def snapshot(self) -> dict[str, object]:
            raise RuntimeError("simulated dialogue snapshot failure")

    monkeypatch.setattr(
        session_module,
        "PhysicalAcceptanceDialogueExecutor",
        DialogueSnapshotFailureExecutor,
    )
    session = InteractiveApplicationControlSession(
        FakeAuthorityLoader(),
        transport_factory=lambda host: FakeTransport(host),  # type: ignore[arg-type]
    )

    session.open(
        host="192.168.1.20",
        timezone_name="Europe/Moscow",
        confirmation=_confirmation(),
    )
    failed_thread = session._thread  # noqa: SLF001
    assert failed_thread is not None
    failed_thread.join(timeout=2.0)
    assert not failed_thread.is_alive()
    failed = session.snapshot()

    assert failed["state"] == "failed"
    assert failed["outcome_unknown"] is True
    assert failed["can_open"] is False
    failure = failed["failure"]
    assert isinstance(failure, dict)
    assert failure["publish_attempts"] == 0
    assert failure["modeling_command_attempted"] is None
    assert failure["diagnostic_snapshot_unavailable"] == ["dialogue"]
    assert failure["diagnostic_evidence_unavailable"] == ["dialogue.modeling_command_attempted"]
    assert failure["safe_to_retry"] is False


def test_pretransport_authority_failure_remains_safe_after_worker_retirement() -> None:
    class FailingAuthorityLoader(FakeAuthorityLoader):
        def load(self) -> ApplicationControlAuthority:
            self.calls += 1
            raise RuntimeError("authority unavailable before transport creation")

    transport_factory_calls = 0

    def transport_factory(host: str) -> FakeTransport:
        nonlocal transport_factory_calls
        transport_factory_calls += 1
        return FakeTransport(host)

    session = InteractiveApplicationControlSession(
        FailingAuthorityLoader(),
        transport_factory=transport_factory,  # type: ignore[arg-type]
    )

    session.open(
        host="192.168.1.20",
        timezone_name="Europe/Moscow",
        confirmation=_confirmation(),
    )
    failed_thread = session._thread  # noqa: SLF001
    assert failed_thread is not None
    failed_thread.join(timeout=2.0)
    assert not failed_thread.is_alive()
    failed = session.snapshot()

    assert transport_factory_calls == 0
    assert failed["state"] == "failed"
    assert failed["outcome_unknown"] is False
    assert failed["can_open"] is True
    failure = failed["failure"]
    assert isinstance(failure, dict)
    assert failure["publish_attempts"] is None
    assert failure["modeling_command_attempted"] is False
    assert failure["diagnostic_snapshot_unavailable"] == []
    assert failure["diagnostic_evidence_unavailable"] == []
    assert failure["safe_to_retry"] is True
