"""Main GUI composition root.

This module wires UI/controller mixins together and owns application-level
state shared across them (runtime services, readers, history buffers, timer).
"""

from __future__ import annotations

from collections import deque
from datetime import datetime
import html
import json
import os
from pathlib import Path
import traceback

from PyQt6.QtCore import QTimer
from PyQt6.QtGui import QTextCursor
from PyQt6.QtWidgets import QMainWindow, QMessageBox

from python_app.gui.controllers.app_window_config_mixin import AppWindowConfigMixin
from python_app.gui.controllers.app_window_pipeline_mixin import AppWindowPipelineMixin
from python_app.gui.controllers.app_window_plot_mixin import AppWindowPlotMixin
from python_app.gui.controllers.app_window_preprocess_mixin import AppWindowPreprocessMixin
from python_app.gui.controllers.app_window_snapshot_mixin import AppWindowSnapshotMixin
from python_app.gui.controllers.app_window_ui_mixin import AppWindowUiMixin
from python_app.gui.preprocess_dialog import PreprocessDialog
from python_app.models.dataset_model import ResultCollection, SweepCollection
from python_app.models.gui_profile_model import GuiProfileModel
from python_app.models.run_config_model import RunConfigModel
from python_app.orchestration.config_writer import ConfigWriter
from python_app.orchestration.gui_session_state import GuiSessionState, GuiSessionStateStore
from python_app.orchestration.live_processing_config import ProcessingLiveConfigWriter
from python_app.orchestration.locator_runtime import LocatorTcpService
from python_app.orchestration.preprocess_assets import VISIBLE_PREPROCESS_ASSET_KEYS, preprocess_asset_model
from python_app.orchestration.process_supervisor import ProcessSupervisor
from python_app.orchestration.shm_reader import ShmRingReader
from python_app.storage.npz_store import NpzStore
from python_app.workflows.multi_radar_capture_workflow import MultiRadarSequentialCaptureSession
from python_app.workflows.radar_config_variants import RadarConfigScanSummary, RadarConfigVariant
from python_app.workflows.sequential_capture_workflow import SequentialCaptureSession


class AppWindow(
    AppWindowUiMixin,
    AppWindowConfigMixin,
    AppWindowPreprocessMixin,
    AppWindowPlotMixin,
    AppWindowPipelineMixin,
    AppWindowSnapshotMixin,
    QMainWindow,
):
    """Top-level window coordinating GUI state and acquisition runtime."""

    def __init__(self, project_root: Path) -> None:
        """Initialize all app subsystems in deterministic order."""
        super().__init__()

        self._init_paths(project_root)
        self._init_runtime_services()
        self._init_config_profile_state()
        self._init_reader_handles()
        self._init_preprocess_state()
        self._init_capture_state()
        self._init_history_state()
        self._init_runtime_limits()
        self._init_polling_timer()
        self._bootstrap_ui_runtime()

    def _init_paths(self, project_root: Path) -> None:
        """Initialize static project paths and startup log queue."""
        self._project_root = project_root
        self._root_profile_path = project_root / "run_config.json"
        self._active_profile_path = self._root_profile_path
        self._pending_startup_log_entries: list[tuple[str, str, str | None]] = []

    def _init_runtime_services(self) -> None:
        """Initialize long-lived service objects used by mixins."""
        runtime_dir = self._project_root / "python_app/runtime"
        self._runtime_dir = runtime_dir
        self._store = NpzStore(self._project_root / "python_app/data")
        self._config_writer = ConfigWriter(runtime_dir)
        self._supervisor = ProcessSupervisor(self._project_root)
        self._live_config_writer = ProcessingLiveConfigWriter(runtime_dir / "processing_live.json")
        self._gui_session_state_store = GuiSessionStateStore(runtime_dir / "gui_session_state.json")
        self._locator_service: LocatorTcpService | None = None

    def _init_config_profile_state(self) -> None:
        """Resolve startup profile path, load active profile, and queue fallback notices."""
        active_profile_path = self._resolve_startup_profile_path()
        try:
            profile = GuiProfileModel.load_from_path(active_profile_path)
        except Exception as exc:
            if active_profile_path == self._root_profile_path:
                raise
            self._queue_startup_log_entry(
                "WARN",
                "Failed to load the last selected config profile; falling back to root run_config.json.",
                details=self._exception_details(exc),
            )
            profile = GuiProfileModel.load_from_path(self._root_profile_path)
            active_profile_path = self._root_profile_path

        self._active_profile_path = active_profile_path
        self._defaults_config = profile.run_config.clone()
        if profile.gui is not None:
            self._gui_defaults = profile.gui
        else:
            self._gui_defaults = self._default_gui_state_for_config(self._defaults_config)
            if active_profile_path != self._root_profile_path:
                self._queue_startup_log_entry(
                    "INFO",
                    f"Loaded legacy run config without GUI defaults: {active_profile_path}",
                )
        self._locator_service = self._build_locator_service(self._defaults_config)
        self._remember_active_profile_path(active_profile_path, startup=True)

    def _init_reader_handles(self) -> None:
        """Initialize SHM readers as detached (not connected) handles."""
        self._raw_reader: ShmRingReader | None = None
        self._pre_reader: ShmRingReader | None = None
        self._result_reader: ShmRingReader | None = None

    def _init_preprocess_state(self) -> None:
        """Initialize preprocessing dialog and selected set names."""
        self._preprocess_dialog: PreprocessDialog | None = None
        self._preprocess_set_name = str(self._gui_defaults.preprocess_dialog.set_name)
        self._preprocess_radar_config_dir = str(self._gui_defaults.preprocess_dialog.radar_config_dir)
        self._preprocess_use_all_radar_configs = bool(self._gui_defaults.preprocess_dialog.use_all_radar_configs)
        self._preprocess_median_sweep_count = max(
            1, int(self._gui_defaults.preprocess_dialog.median_sweep_count)
        )
        self._preprocess_radar_variants: list[RadarConfigVariant] = []
        self._preprocess_radar_scan_summary = RadarConfigScanSummary(
            directory_path=self._preprocess_radar_config_dir,
            json_file_count=0,
            valid_variant_count=0,
            skipped_file_count=0,
            duplicate_variant_count=0,
            issues=(),
        )
        self._selected_preprocess_sets = {
            key: str(preprocess_asset_model(self._defaults_config, key).set_name)
            for key in VISIBLE_PREPROCESS_ASSET_KEYS
        }
        self._selected_preprocess_radar_key = self._radar_key(self._defaults_config)

    def _init_capture_state(self) -> None:
        """Initialize one-shot capture and sequence-control flags."""
        self._capture_session: SequentialCaptureSession | MultiRadarSequentialCaptureSession | None = None
        self._resume_pipeline_after_capture = False
        self._single_capture_active = False
        self._single_capture_start_ns: int | None = None
        self._single_capture_seen_raw = False
        self._single_capture_target_collection_id: int | None = None

    def _init_history_state(self) -> None:
        """Initialize runtime history buffers and render-cache state."""
        history_limit = self._history_limit_from_config()
        self._raw_history: deque[SweepCollection] = deque(maxlen=history_limit)
        self._pre_history: deque[SweepCollection] = deque(maxlen=history_limit)
        self._result_history: deque[ResultCollection] = deque(maxlen=history_limit)

        # Sequence id must survive GUI restarts so history commands stay monotonic.
        self._history_command_seq = self._load_history_command_seq(self._live_config_writer.path)
        self._bscan_history_limit = history_limit
        self._bscan_history_by_combo = {}
        self._bscan_depth_axis_by_combo = {}
        self._bscan_history_floor_collection_id = 0
        self._bscan_render_signature = None
        self._gpr_lookup_table = None
        self._gpr_image_item = None
        self._gpr_tx_item = None
        self._gpr_rx_item = None
        self._gpr_points_item = None
        self._gpr_region_centers_item = None
        self._gpr_point_labels = []
        self._gpr_region_center_labels = []
        self._gpr_region_mask_items = []
        self._gpr_region_contours = []
        self._gpr_geometry_signature = None
        self._gpr_selected_geometry = None
        self._phase_viewbox = None
        self._history_run_signature = None
        self._processor_run_signature = None
        self._active_processing_mode = "pass_through"
        self._radar_limits: dict[str, float | int] | None = None

    def _history_limit_from_config(self) -> int:
        """Return unified GUI history limit derived from configured ring capacities."""
        return max(
            1,
            min(
                int(self._defaults_config.rings.raw_tap.capacity),
                int(self._defaults_config.rings.preprocessed_tap.capacity),
                int(self._defaults_config.rings.results.capacity),
            ),
        )

    def _init_runtime_limits(self) -> None:
        """Initialize read/drain loop limits used by polling and snapshot code."""
        self._max_pop_per_poll = 256
        self._max_pop_per_snapshot_drain = 4096
        self._last_reader_error_signature: tuple[str, str] | None = None
        self._logged_once_keys: set[str] = set()

    def _init_polling_timer(self) -> None:
        """Create periodic timer that polls SHM rings for new data."""
        self._timer = QTimer(self)
        self._timer.setInterval(50)
        self._timer.timeout.connect(self._poll_rings)

    def _bootstrap_ui_runtime(self) -> None:
        """Build UI and apply initial runtime-bound state after widgets exist."""
        self._build_ui()
        self._flush_pending_startup_log_entries()
        self._log(f"Active config profile: {self._active_profile_path}")
        self._refresh_preprocess_summary_labels()
        self._apply_initial_radar_limits()
        self._start_locator_service()
        self._on_processing_mode_changed(self._processing_mode.currentText())
        self._write_live_processing_config()
        self._timer.start()
        self._maybe_auto_start_pipeline()

    def _start_locator_service(self) -> None:
        """Start embedded locator TCP service without failing the GUI."""
        try:
            if self._locator_service is None:
                self._locator_service = self._build_locator_service(self._defaults_config)
            self._locator_service.start()
            self._locator_service.publish_empty()
            self._log(
                f"Locator TCP server listening on "
                f"{self._locator_service.host}:{self._locator_service.port}"
            )
        except Exception as exc:  # noqa: BLE001
            self._log_exception("Failed to start locator TCP server", exc, level="WARN")

    def _build_locator_service(self, config: RunConfigModel) -> LocatorTcpService:
        """Create locator service instance from stable run config."""
        locator_server = config.runtime.locator_server
        return LocatorTcpService(
            host=str(locator_server.host),
            port=int(locator_server.port),
            device_id=int(locator_server.device_id),
            protocol_version=int(locator_server.protocol_version),
            max_payload_bytes=int(locator_server.max_payload_bytes),
            client_queue_size=int(locator_server.client_queue_size),
            logger_name=str(locator_server.logger_name),
        )

    def _reload_locator_service_from_config(self) -> None:
        """Rebuild locator service using current stable config and restart if needed."""
        previous_service = self._locator_service
        was_running = previous_service is not None and previous_service.is_running()
        if previous_service is not None:
            previous_service.stop()

        self._locator_service = self._build_locator_service(self._defaults_config)
        if not was_running:
            return

        try:
            self._locator_service.start()
            self._locator_service.publish_empty()
            self._log(
                "Locator TCP server reloaded from config: "
                f"{self._locator_service.host}:{self._locator_service.port}"
            )
        except Exception as exc:  # noqa: BLE001
            self._log_exception("Failed to reload locator TCP server from config", exc, level="WARN")

    def _resolve_startup_profile_path(self) -> Path:
        """Resolve active profile path from session-state or root fallback path."""
        env_profile_path = os.environ.get("RADAR_SYSTEM_PROFILE", "").strip()
        if env_profile_path:
            profile_path = Path(env_profile_path).expanduser()
            if not profile_path.is_absolute():
                profile_path = (self._project_root / profile_path).resolve(strict=False)
            return profile_path

        try:
            session_state = self._gui_session_state_store.load()
        except Exception as exc:
            self._queue_startup_log_entry(
                "WARN",
                "Failed to read GUI session-state; using root run_config.json.",
                details=self._exception_details(exc),
            )
            return self._root_profile_path

        raw_path = session_state.last_profile_path.strip()
        if not raw_path:
            return self._root_profile_path

        profile_path = Path(raw_path).expanduser()
        if not profile_path.is_absolute():
            profile_path = (self._project_root / profile_path).resolve(strict=False)
        return profile_path

    def _maybe_auto_start_pipeline(self) -> None:
        """Schedule pipeline start when requested by launcher environment."""
        auto_start = os.environ.get("RADAR_SYSTEM_AUTO_START", "").strip().lower()
        if auto_start not in {"1", "true", "yes", "on"}:
            return
        self._log("Auto-start requested by launcher.")
        QTimer.singleShot(500, self._start_run)

    def _normalize_profile_path(self, path: Path) -> Path:
        """Return normalized absolute profile path."""
        return path.expanduser().resolve(strict=False)

    def _remember_active_profile_path(self, path: Path, *, startup: bool = False) -> None:
        """Persist last successfully used config profile path."""
        normalized_path = self._normalize_profile_path(path)
        self._active_profile_path = normalized_path
        try:
            self._gui_session_state_store.write(GuiSessionState(last_profile_path=str(normalized_path)))
        except Exception as exc:
            if startup:
                self._queue_startup_log_entry(
                    "WARN",
                    "Failed to update GUI session-state with the active config profile path.",
                    details=self._exception_details(exc),
                )
            else:
                self._log_exception(
                    "Failed to update GUI session-state with the active config profile path",
                    exc,
                    level="WARN",
                )

    def _queue_startup_log_entry(self, level: str, text: str, *, details: str | None = None) -> None:
        """Queue startup log entry until log widget exists."""
        self._pending_startup_log_entries.append((level.upper(), text, details))

    def _flush_pending_startup_log_entries(self) -> None:
        """Flush startup log entries into the runtime log box after UI creation."""
        if not self._pending_startup_log_entries:
            return

        for level, text, details in self._pending_startup_log_entries:
            self._append_log_entry(level, text, details=details)
        self._pending_startup_log_entries.clear()

    def _apply_initial_radar_limits(self) -> None:
        """Apply startup radar-limits strategy according to selected radar mode."""
        if self._defaults_config.radar.driver_mode == "native":
            self._refresh_radar_limits_from_device()
            return
        self._apply_radar_limits_to_ui(None)

    @staticmethod
    def _escape_log_text(text: str) -> str:
        """Escape log text for insertion into rich-text log widget."""
        return html.escape(text).replace("\n", "<br>")

    @staticmethod
    def _exception_summary(exc: Exception) -> str:
        """Build compact one-line exception summary."""
        message = str(exc).strip()
        if message:
            return f"{type(exc).__name__}: {message}"
        return type(exc).__name__

    @staticmethod
    def _exception_details(exc: Exception) -> str:
        """Return full chained traceback for error dialogs and log details."""
        return "".join(traceback.TracebackException.from_exception(exc).format(chain=True)).strip()

    def _append_log_entry(
        self,
        level: str,
        text: str,
        *,
        details: str | None = None,
        once_key: str | None = None,
    ) -> None:
        """Append formatted log entry with timestamp and optional details."""
        if once_key is not None:
            if once_key in self._logged_once_keys:
                return
            self._logged_once_keys.add(once_key)

        level_upper = level.upper()
        palette = {
            "INFO": ("#1d5fbf", "#1f2937", "#526277"),
            "WARN": ("#9a5b00", "#5c4300", "#7a6640"),
            "ERROR": ("#c43d4d", "#6b1f2a", "#8b5d66"),
        }
        accent_color, message_color, detail_color = palette.get(level_upper, palette["INFO"])
        timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3]
        header_html = (
            f"<span style='color:{accent_color}; font-weight:700;'>{html.escape(level_upper)}</span>"
            f" <span style='color:#60758d;'>{html.escape(timestamp)}</span>"
            f" <span style='color:{message_color};'>{self._escape_log_text(text)}</span>"
        )

        body_parts = [header_html]
        if details:
            body_parts.append(
                "<pre style='margin:3px 0 0 16px; color:"
                f"{detail_color};'>{html.escape(details)}</pre>"
            )

        entry_html = "<div style='margin:0 0 6px 0;'>" + "".join(body_parts) + "</div>"
        cursor = self._log_box.textCursor()
        cursor.movePosition(QTextCursor.MoveOperation.End)
        self._log_box.setTextCursor(cursor)
        self._log_box.insertHtml(entry_html)
        self._log_box.insertPlainText("\n")
        self._log_box.ensureCursorVisible()

        if level_upper == "ERROR" and hasattr(self, "_status_label"):
            self._status_label.setText("Status: error")

    def _log(self, text: str, *, once_key: str | None = None) -> None:
        """Append informational message to runtime log panel."""
        self._append_log_entry("INFO", text, once_key=once_key)

    def _log_warning(self, text: str, *, details: str | None = None, once_key: str | None = None) -> None:
        """Append warning message to runtime log panel."""
        self._append_log_entry("WARN", text, details=details, once_key=once_key)

    def _log_error(self, text: str, *, details: str | None = None, once_key: str | None = None) -> None:
        """Append error message to runtime log panel."""
        self._append_log_entry("ERROR", text, details=details, once_key=once_key)

    def _log_exception(self, context: str, exc: Exception, *, level: str = "ERROR") -> tuple[str, str]:
        """Log exception with detailed traceback and return `(message, details)`."""
        message = f"{context}: {self._exception_summary(exc)}"
        details = self._exception_details(exc)
        if level.upper() == "WARN":
            self._log_warning(message, details=details)
        else:
            self._log_error(message, details=details)
        return message, details

    def _process_state_details(self) -> str:
        """Return formatted summary of managed pipeline process state."""
        if not hasattr(self, "_supervisor"):
            return "Managed processes: unavailable"
        pid_map = self._supervisor.pids()
        if not pid_map:
            return "Managed processes: none"
        return "Managed processes:\n" + "\n".join(
            f"- {name}: pid={pid}"
            for name, pid in sorted(pid_map.items())
        )

    def _runtime_history_details(self) -> str:
        """Return formatted summary of buffered runtime history counts."""
        return (
            "Runtime history:\n"
            f"- raw={len(getattr(self, '_raw_history', []))}\n"
            f"- preprocessed={len(getattr(self, '_pre_history', []))}\n"
            f"- results={len(getattr(self, '_result_history', []))}"
        )

    def _capture_state_details(self) -> str:
        """Return formatted summary of active preprocess capture state."""
        session = getattr(self, "_capture_session", None)
        if session is None:
            return "Capture session: none"
        state = session.state()
        lines = [
            "Capture session:",
            f"- kind={state.kind}",
            f"- progress={state.captured_count}/{state.total_count}",
        ]
        if state.current_combo is not None:
            lines.append(
                f"- current_combo=input={state.current_combo.input}, output={state.current_combo.output}"
            )
        return "\n".join(lines)

    @staticmethod
    def _load_history_command_seq(config_path: Path) -> int:
        """Load previously used live-command sequence from runtime config file."""
        try:
            payload = json.loads(config_path.read_text(encoding="utf-8"))
        except Exception:  # noqa: BLE001
            # Missing or malformed file should not block startup.
            return 0

        raw_value = payload.get("history_command_seq", 0)
        if isinstance(raw_value, bool):
            return 0
        if isinstance(raw_value, (int, float)):
            return max(0, int(raw_value))
        return 0

    def _show_error(self, message: str, *, details: str | None = None) -> None:
        """Log and present an error in a modal dialog with optional detail text."""
        self._log_error(message, details=details)
        dialog = QMessageBox(self)
        dialog.setIcon(QMessageBox.Icon.Critical)
        dialog.setWindowTitle("Error")
        dialog.setText(message)
        if details:
            dialog.setDetailedText(details)
        dialog.exec()

    def _show_exception(self, context: str, exc: Exception) -> None:
        """Log full exception details and show modal dialog with expandable traceback."""
        message, details = self._log_exception(context, exc, level="ERROR")
        dialog = QMessageBox(self)
        dialog.setIcon(QMessageBox.Icon.Critical)
        dialog.setWindowTitle("Error")
        dialog.setText(message)
        dialog.setDetailedText(details)
        dialog.exec()

    def closeEvent(self, event) -> None:  # noqa: N802
        """Ensure workers and dialogs are closed before window destruction."""
        try:
            self._resume_pipeline_after_capture = False
            # 1) Abort active capture first (releases exclusive hardware resources).
            self._abort_capture_sequence(resume_pipeline=False)
            # 2) Stop all managed processes/readers.
            self._stop_all_processes()
            # 3) Stop embedded locator service.
            if self._locator_service is not None:
                self._locator_service.stop()
            # 3) Close auxiliary dialog windows.
            if self._preprocess_dialog is not None:
                self._preprocess_dialog.close()
        finally:
            super().closeEvent(event)
