from __future__ import annotations

import logging
import threading
import time
from collections.abc import Callable
from dataclasses import dataclass
from typing import Literal

from k1link.device_plugins.xgrids_k1.protocol.application_acceptance import (
    ApplicationAcceptanceError,
    PhysicalAcceptanceChecklist,
    PhysicalAcceptanceDialogueExecutor,
    PhysicalAcceptancePermit,
)
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
    ApplicationControlAuthority,
    LiveDeviceControlBinding,
    ShadowApplicationBootstrapOrchestrator,
    build_canonical_post_start_observation,
)
from k1link.device_plugins.xgrids_k1.protocol.application_execution import (
    ApplicationAuthorityLoader,
)
from k1link.device_plugins.xgrids_k1.protocol.application_mqtt import (
    ApplicationCommandOutcomeUnknown,
    ReviewedApplicationMqttTransport,
)
from k1link.device_plugins.xgrids_k1.protocol.modeling_control import (
    CommandHeaderIdentity,
    ModelingAction,
    MountType,
    RecordMode,
    ScanMode,
    encode_modeling_start,
    encode_modeling_stop,
)
from k1link.device_plugins.xgrids_k1.protocol.modeling_safety import (
    ShadowModelingCommand,
)

ApplicationControlPhase = Literal[
    "idle",
    "connecting",
    "connection-ready",
    "workspace-requested",
    "workspace-ready",
    "project-requested",
    "project-ready",
    "start-requested",
    "initializing",
    "scanning",
    "stop-requested",
    "stopping",
    "awaiting-standby-confirmation",
    "completed",
    "closed",
    "failed",
]

logger = logging.getLogger(__name__)


TransportFactory = Callable[[str], ReviewedApplicationMqttTransport]


@dataclass(frozen=True, slots=True)
class OperatorPresenceConfirmation:
    operator_present: Literal[True]
    owner_controlled_device: Literal[True]
    lixelgo_closed: Literal[True]
    battery_storage_confirmed: Literal[True]
    expected_physical_state_confirmed: Literal[True]

    def checklist(self, action: ModelingAction) -> PhysicalAcceptanceChecklist:
        return PhysicalAcceptanceChecklist(
            action=action,
            operator_present=self.operator_present,
            owner_controlled_device=self.owner_controlled_device,
            lixelgo_closed=self.lixelgo_closed,
            battery_storage_confirmed=self.battery_storage_confirmed,
            expected_physical_state_confirmed=self.expected_physical_state_confirmed,
        )


class InteractiveApplicationControlSession:
    """Own one canonical K1 MQTT dialogue across explicit operator UI events.

    Only this background thread touches the MQTT client. UI requests merely
    release one named checkpoint. No checkpoint is advanced by elapsed time,
    and neither START nor STOP has an automatic retry path.
    """

    def __init__(
        self,
        authority_loader: ApplicationAuthorityLoader,
        *,
        transport_factory: TransportFactory = ReviewedApplicationMqttTransport,
        epoch_seconds: Callable[[], int] = lambda: int(time.time()),
    ) -> None:
        self._authority_loader = authority_loader
        self._transport_factory = transport_factory
        self._epoch_seconds = epoch_seconds
        self._lock = threading.RLock()
        self._phase: ApplicationControlPhase = "idle"
        self._host: str | None = None
        self._timezone_name: str | None = None
        self._thread: threading.Thread | None = None
        self._transport: ReviewedApplicationMqttTransport | None = None
        self._workspace_requested = threading.Event()
        self._project_requested = threading.Event()
        self._start_requested = threading.Event()
        self._stop_requested = threading.Event()
        self._cancel_requested = False
        self._start_confirmation: OperatorPresenceConfirmation | None = None
        self._stop_confirmation: OperatorPresenceConfirmation | None = None
        self._project_name: str | None = None
        self._failure: dict[str, object] | None = None
        self._dialogue_snapshot: dict[str, object] | None = None
        self._transport_snapshot: dict[str, object] | None = None
        self._outcome_unknown = False
        self._run_generation = 0

    def open(
        self,
        *,
        host: str,
        timezone_name: str,
        confirmation: OperatorPresenceConfirmation,
    ) -> dict[str, object]:
        # Validate every explicit confirmation before creating a network owner.
        confirmation.checklist(ModelingAction.START)
        with self._lock:
            if self._phase != "idle" and self._can_open_locked():
                self._reset_locked()
            if self._phase != "idle":
                raise ApplicationAcceptanceError(
                    "application control session is already open or requires manual recovery"
                )
            # A previous worker may already have published a terminal phase but
            # still own its local MQTT socket while its ``finally`` block runs.
            # Never create the next network owner until that thread has exited
            # and cleared the closed transport.
            if not self._worker_retired_locked():
                raise ApplicationAcceptanceError(
                    "previous application control worker is still retiring"
                )
            self._host = host
            self._timezone_name = timezone_name
            self._set_phase_locked("connecting")
            self._run_generation += 1
            generation = self._run_generation
            self._thread = threading.Thread(
                target=self._run,
                args=(generation,),
                name="xgrids-k1-canonical-control",
                daemon=True,
            )
            self._thread.start()
        return self.snapshot()

    def enter_workspace(self) -> dict[str, object]:
        with self._lock:
            self._require_phase_locked("connection-ready")
            self._set_phase_locked("workspace-requested")
            self._workspace_requested.set()
        return self.snapshot()

    def open_project_prompt(self) -> dict[str, object]:
        with self._lock:
            self._require_phase_locked("workspace-ready")
            self._set_phase_locked("project-requested")
            self._project_requested.set()
        return self.snapshot()

    def request_start(
        self,
        *,
        project_name: str,
        confirmation: OperatorPresenceConfirmation,
    ) -> dict[str, object]:
        with self._lock:
            self._require_phase_locked("project-ready")
            self._project_name = project_name
            self._start_confirmation = confirmation
            self._set_phase_locked("start-requested")
            self._start_requested.set()
        return self.snapshot()

    def request_stop(
        self,
        *,
        confirmation: OperatorPresenceConfirmation,
    ) -> dict[str, object]:
        with self._lock:
            self._require_phase_locked("scanning")
            self._stop_confirmation = confirmation
            self._set_phase_locked("stop-requested")
            self._stop_requested.set()
        return self.snapshot()

    def close_prestart(self) -> dict[str, object]:
        with self._lock:
            if self._phase not in {
                "connection-ready",
                "workspace-ready",
                "project-ready",
            }:
                raise ApplicationAcceptanceError(
                    "control session can be closed safely only between pre-START checkpoints"
                )
            self._cancel_requested = True
            transport = self._transport
        if transport is not None:
            transport.close()
        return self.snapshot()

    def close(self) -> None:
        """Stop the process-owned socket without ever inventing a device STOP."""

        with self._lock:
            self._cancel_requested = True
            transport = self._transport
            thread = self._thread
        if transport is not None:
            transport.close()
        if thread is not None and thread is not threading.current_thread():
            thread.join(timeout=2.0)

    def snapshot(self) -> dict[str, object]:
        with self._lock:
            transport_snapshot = self._live_transport_snapshot_locked()
            phase = self._phase
            return {
                "mode": "interactive-canonical",
                "state": phase,
                "control_socket_open": phase not in {"idle", "completed", "closed", "failed"},
                "can_open": self._can_open_locked(),
                "can_enter_workspace": phase == "connection-ready",
                "can_prepare_project": phase == "workspace-ready",
                "can_start": phase == "project-ready",
                "can_stop": phase == "scanning",
                # Retained for wire compatibility with the v1alpha2 snapshot.
                # Standby is now concluded solely from live K1 protocol state.
                "can_confirm_standby": False,
                "pending_operator_action": self._pending_operator_action_locked(),
                "scripted_transitions": False,
                "automatic_retry": False,
                "outcome_unknown": self._outcome_unknown,
                "failure": dict(self._failure) if self._failure is not None else None,
                "dialogue": (
                    dict(self._dialogue_snapshot) if self._dialogue_snapshot is not None else None
                ),
                "transport": transport_snapshot,
            }

    def _run(self, generation: int) -> None:
        executor: PhysicalAcceptanceDialogueExecutor | None = None
        transport: ReviewedApplicationMqttTransport | None = None
        try:
            with self._lock:
                host = self._host
                timezone_name = self._timezone_name
            if host is None or timezone_name is None:
                raise ApplicationAcceptanceError("control session inputs are unavailable")

            authority = self._authority_loader.load()
            transport = self._transport_factory(host)
            with self._lock:
                self._transport = transport
            transport.open()
            orchestrator = ShadowApplicationBootstrapOrchestrator(
                authority,
                epoch_seconds=self._epoch_seconds(),
                timezone_name=timezone_name,
            )
            executor = PhysicalAcceptanceDialogueExecutor(transport)
            binding = executor.run_connection_stage(orchestrator)
            self._set_phase("connection-ready")

            workspace = executor.wait_for_operator_checkpoint(
                "workspace-entered",
                self._workspace_requested.is_set,
            )
            executor.run_workspace_entry_stage(orchestrator, workspace)
            self._set_phase("workspace-ready")

            project = executor.wait_for_operator_checkpoint(
                "project-prompt-opened",
                self._project_requested.is_set,
            )
            binding = executor.run_project_prompt_stage(orchestrator, project)
            self._set_phase("project-ready")

            start_checkpoint = executor.wait_for_operator_checkpoint(
                "start-confirmed",
                self._start_requested.is_set,
            )
            project_name, start_confirmation = self._start_request()
            start_permit = PhysicalAcceptancePermit(
                start_confirmation.checklist(ModelingAction.START)
            )
            self._set_phase("initializing")
            executor.execute_canonical_start(
                self._start_command(authority, binding, project_name),
                build_canonical_post_start_observation(authority, binding),
                authority=authority,
                binding=binding,
                permit=start_permit,
                checkpoint=start_checkpoint,
            )
            self._set_phase("scanning")

            executor.maintain_active_until_stop_requested(self._stop_requested.is_set)
            stop_confirmation = self._stop_request()
            stop_permit = PhysicalAcceptancePermit(stop_confirmation.checklist(ModelingAction.STOP))
            self._set_phase("stopping")
            executor.execute_canonical_stop(
                self._stop_command(authority, binding),
                stop_permit,
            )
            self._set_phase("awaiting-standby-confirmation")
            executor.maintain_post_stop_until_standby()
            self._set_phase("completed")
        except Exception as exc:
            dialogue_snapshot, dialogue_snapshot_available = self._executor_snapshot_safely(
                executor
            )
            transport_snapshot, transport_snapshot_available = self._transport_snapshot_safely(
                transport
            )
            with self._lock:
                if self._cancel_requested:
                    self._set_phase_locked("closed")
                else:
                    failed_phase = self._phase
                    transport_created = transport is not None
                    dialogue_executor_created = executor is not None
                    publish_attempts = (
                        self._json_int_or_none(transport_snapshot.get("publish_attempts"))
                        if transport_snapshot_available
                        else None
                    )
                    dialogue_stage = self._json_string(dialogue_snapshot.get("dialogue_stage"))
                    correlation_failure = dialogue_snapshot.get("correlation_failure")
                    compatibility_failure = dialogue_snapshot.get("compatibility_failure")
                    failure_reason_code = self._failure_reason_code(
                        exc,
                        correlation_failure=correlation_failure,
                    )
                    modeling_command_attempted: bool | None = (
                        (
                            dialogue_snapshot.get("start_attempted") is True
                            or dialogue_snapshot.get("stop_attempted") is True
                        )
                        if dialogue_snapshot_available
                        else (False if not dialogue_executor_created else None)
                    )
                    diagnostic_snapshot_unavailable = [
                        component
                        for component, created, available in (
                            (
                                "transport",
                                transport_created,
                                transport_snapshot_available,
                            ),
                            (
                                "dialogue",
                                dialogue_executor_created,
                                dialogue_snapshot_available,
                            ),
                        )
                        if created and not available
                    ]
                    diagnostic_evidence_unavailable = [
                        evidence
                        for evidence, unavailable in (
                            (
                                "transport.publish_attempts",
                                transport_created and publish_attempts is None,
                            ),
                            (
                                "dialogue.modeling_command_attempted",
                                dialogue_executor_created and modeling_command_attempted is None,
                            ),
                        )
                        if unavailable
                    ]
                    outcome_unknown = (
                        isinstance(exc, ApplicationCommandOutcomeUnknown)
                        or modeling_command_attempted is True
                        or bool(diagnostic_evidence_unavailable)
                        or failed_phase
                        in {
                            "scanning",
                            "stop-requested",
                            "stopping",
                            "awaiting-standby-confirmation",
                        }
                    )
                    # A correlated ordinal-1 DeviceInfo profile mismatch is a
                    # completed read-only exchange. It cannot have started or
                    # stopped modeling, so a later explicit operator click may
                    # open a fresh dialogue after the software/profile issue is
                    # corrected. No other post-publish failure is promoted.
                    correlated_read_only_profile_mismatch = (
                        failure_reason_code == "compatibility_profile_mismatch"
                        and modeling_command_attempted is False
                        and publish_attempts == 1
                        and self._json_int_or_none(
                            transport_snapshot.get("correlated_responses")
                        )
                        == 1
                    )
                    safe_to_retry = not outcome_unknown and (
                        not transport_created
                        or (transport_snapshot_available and publish_attempts == 0)
                        or correlated_read_only_profile_mismatch
                    )
                    self._failure = {
                        "code": type(exc).__name__,
                        "reason_code": failure_reason_code,
                        "message": str(exc),
                        "failed_phase": failed_phase,
                        "dialogue_stage": dialogue_stage,
                        "transport_state": self._json_string(transport_snapshot.get("state")),
                        "publish_attempts": publish_attempts,
                        "qos2_completions": self._json_int_or_none(
                            transport_snapshot.get("qos2_completions")
                        ),
                        "correlated_responses": self._json_int_or_none(
                            transport_snapshot.get("correlated_responses")
                        ),
                        "ignored_known_responses": self._json_int_or_none(
                            transport_snapshot.get("ignored_known_responses")
                        ),
                        "late_known_responses": self._json_int_or_none(
                            transport_snapshot.get("late_known_responses")
                        ),
                        "modeling_command_attempted": modeling_command_attempted,
                        "diagnostic_snapshot_unavailable": (diagnostic_snapshot_unavailable),
                        "diagnostic_evidence_unavailable": (diagnostic_evidence_unavailable),
                        "correlation_failure": (
                            dict(correlation_failure)
                            if isinstance(correlation_failure, dict)
                            else None
                        ),
                        "compatibility_failure": (
                            dict(compatibility_failure)
                            if isinstance(compatibility_failure, dict)
                            else None
                        ),
                        "safe_to_retry": safe_to_retry,
                    }
                    self._outcome_unknown = outcome_unknown
                    self._dialogue_snapshot = dialogue_snapshot or None
                    self._transport_snapshot = transport_snapshot or None
                    self._set_phase_locked("failed")
                    logger.error(
                        "K1 application control session failed: code=%s reason_code=%s "
                        "phase=%s dialogue_stage=%s transport_state=%s "
                        "publish_attempts=%s modeling_command_attempted=%s "
                        "diagnostic_snapshot_unavailable=%s "
                        "diagnostic_evidence_unavailable=%s outcome_unknown=%s "
                        "safe_to_retry=%s",
                        type(exc).__name__,
                        failure_reason_code,
                        failed_phase,
                        dialogue_stage,
                        self._json_string(transport_snapshot.get("state")),
                        publish_attempts,
                        modeling_command_attempted,
                        diagnostic_snapshot_unavailable,
                        diagnostic_evidence_unavailable,
                        outcome_unknown,
                        safe_to_retry,
                    )
        finally:
            final_dialogue_snapshot: dict[str, object] | None = None
            final_transport_snapshot: dict[str, object] | None = None
            if executor is not None:
                captured_dialogue, dialogue_available = self._executor_snapshot_safely(executor)
                if dialogue_available:
                    final_dialogue_snapshot = captured_dialogue
            if transport is not None:
                try:
                    captured_transport, transport_available = self._transport_snapshot_safely(
                        transport
                    )
                    if transport_available:
                        final_transport_snapshot = captured_transport
                finally:
                    transport.close()
            with self._lock:
                # The generation guard remains defense in depth for shutdown
                # races, although open() also requires the prior worker to be
                # fully retired before a new generation can be created.
                if generation == self._run_generation:
                    if final_dialogue_snapshot is not None:
                        self._dialogue_snapshot = final_dialogue_snapshot
                    if final_transport_snapshot is not None:
                        self._transport_snapshot = final_transport_snapshot
                    if self._transport is transport:
                        self._transport = None

    @staticmethod
    def _start_command(
        authority: ApplicationControlAuthority,
        binding: LiveDeviceControlBinding,
        project_name: str,
    ) -> ShadowModelingCommand:
        return ShadowModelingCommand.from_command(
            encode_modeling_start(
                CommandHeaderIdentity(
                    device_id=binding.vendor_device_id,
                    openapi_key=authority.openapi_key,
                ),
                project_name=project_name,
                record_mode=RecordMode.RECORD_AND_CALCULATE,
                scan_mode=ScanMode.LCC,
                mount_type=MountType.HANDHELD,
            )
        )

    @staticmethod
    def _stop_command(
        authority: ApplicationControlAuthority,
        binding: LiveDeviceControlBinding,
    ) -> ShadowModelingCommand:
        return ShadowModelingCommand.from_command(
            encode_modeling_stop(
                CommandHeaderIdentity(
                    device_id=binding.vendor_device_id,
                    openapi_key=authority.openapi_key,
                )
            )
        )

    def _start_request(self) -> tuple[str, OperatorPresenceConfirmation]:
        with self._lock:
            if self._project_name is None or self._start_confirmation is None:
                raise ApplicationAcceptanceError("START request is incomplete")
            return self._project_name, self._start_confirmation

    def _stop_request(self) -> OperatorPresenceConfirmation:
        with self._lock:
            if self._stop_confirmation is None:
                raise ApplicationAcceptanceError("STOP request is incomplete")
            return self._stop_confirmation

    def _set_phase(self, phase: ApplicationControlPhase) -> None:
        with self._lock:
            self._set_phase_locked(phase)

    @staticmethod
    def _executor_snapshot_safely(
        executor: PhysicalAcceptanceDialogueExecutor | None,
    ) -> tuple[dict[str, object], bool]:
        if executor is None:
            return {}, False
        try:
            return dict(executor.snapshot()), True
        except Exception:
            logger.error("K1 application dialogue snapshot failed")
            return {}, False

    @staticmethod
    def _transport_snapshot_safely(
        transport: ReviewedApplicationMqttTransport | None,
    ) -> tuple[dict[str, object], bool]:
        if transport is None:
            return {}, False
        try:
            return dict(transport.snapshot().as_dict()), True
        except Exception:
            logger.error("K1 application transport snapshot failed")
            return {}, False

    @staticmethod
    def _json_int_or_none(value: object) -> int | None:
        return value if isinstance(value, int) and not isinstance(value, bool) else None

    @staticmethod
    def _json_string(value: object) -> str | None:
        return value if isinstance(value, str) else None

    @staticmethod
    def _failure_reason_code(
        exc: Exception,
        *,
        correlation_failure: object,
    ) -> str:
        explicit = getattr(exc, "reason_code", None)
        if isinstance(explicit, str) and explicit:
            return explicit
        if isinstance(correlation_failure, dict):
            correlated = correlation_failure.get("reason_code")
            if isinstance(correlated, str) and correlated:
                return correlated
        exception_name = type(exc).__name__
        if "Authority" in exception_name or "Keychain" in exception_name:
            return "application_authority_unavailable"
        if isinstance(exc, TimeoutError):
            return "control_checkpoint_timeout"
        if isinstance(exc, ApplicationAcceptanceError):
            return "application_acceptance_failed"
        if isinstance(exc, RuntimeError):
            return "unexpected_runtime_error"
        return "internal_control_error"

    def _set_phase_locked(self, phase: ApplicationControlPhase) -> None:
        self._phase = phase

    def _require_phase_locked(self, expected: ApplicationControlPhase) -> None:
        if self._phase != expected:
            raise ApplicationAcceptanceError(
                f"control action requires {expected}; current state is {self._phase}"
            )

    def _live_transport_snapshot_locked(self) -> dict[str, object]:
        if self._transport is not None:
            snapshot, available = self._transport_snapshot_safely(self._transport)
            if available:
                return snapshot
            return {
                "state": "snapshot-unavailable",
                "diagnostic_snapshot_available": False,
                "automatic_retry": False,
                "automatic_reconnect": False,
            }
        return (
            dict(self._transport_snapshot)
            if self._transport_snapshot is not None
            else {
                "state": "not-opened",
                "automatic_retry": False,
                "automatic_reconnect": False,
            }
        )

    def _pending_operator_action_locked(self) -> str | None:
        return {
            "connection-ready": "enter-workspace",
            "workspace-ready": "prepare-project",
            "project-ready": "start",
            "scanning": "stop",
        }.get(self._phase)

    def _can_open_locked(self) -> bool:
        reopenable_phase = self._phase in {"idle", "completed", "closed"} or (
            self._phase == "failed"
            and self._failure is not None
            and self._failure.get("safe_to_retry") is True
        )
        return reopenable_phase and self._worker_retired_locked()

    def _worker_retired_locked(self) -> bool:
        thread = self._thread
        return (thread is None or not thread.is_alive()) and self._transport is None

    def _reset_locked(self) -> None:
        self._phase = "idle"
        self._host = None
        self._timezone_name = None
        self._thread = None
        self._transport = None
        self._workspace_requested = threading.Event()
        self._project_requested = threading.Event()
        self._start_requested = threading.Event()
        self._stop_requested = threading.Event()
        self._cancel_requested = False
        self._start_confirmation = None
        self._stop_confirmation = None
        self._project_name = None
        self._failure = None
        self._dialogue_snapshot = None
        self._transport_snapshot = None
        self._outcome_unknown = False
