"""Production runtime for the LabCobot backend and development UI.

The production, gateway, operator, and workcell profiles run the same headless
CPython service backend. The official LVGL touchscreen is a separate,
least-privilege process. Only the explicit development profile loads the
compatibility ``MainApp`` and a CPython LVGL timer.

This module keeps platform/process concerns out of both frontends: clean signal
handling, a bounded retry policy for runtime tick failures, and optional
systemd readiness/watchdog notifications. It has no third-party dependencies
so it can report useful startup errors when the application environment is
broken.
"""

from __future__ import annotations

import logging
import os
import signal
import socket
import sys
import threading
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Mapping, Optional, Sequence, Tuple

LOGGER = logging.getLogger("labcobot.runtime")
RELEASE_VERSION_FILENAME = ".labcobot-version"


def _read_float(
    environment: Mapping[str, str],
    name: str,
    default: float,
    *,
    minimum: float,
    maximum: float,
) -> float:
    raw_value = environment.get(name)
    if raw_value is None or not raw_value.strip():
        return default
    try:
        value = float(raw_value)
    except ValueError as exc:
        raise ValueError(f"{name} must be a number, got {raw_value!r}") from exc
    if not minimum <= value <= maximum:
        raise ValueError(f"{name} must be between {minimum} and {maximum}, got {value}")
    return value


def _read_int(
    environment: Mapping[str, str],
    name: str,
    default: int,
    *,
    minimum: int,
    maximum: int,
) -> int:
    raw_value = environment.get(name)
    if raw_value is None or not raw_value.strip():
        return default
    try:
        value = int(raw_value)
    except ValueError as exc:
        raise ValueError(f"{name} must be an integer, got {raw_value!r}") from exc
    if not minimum <= value <= maximum:
        raise ValueError(f"{name} must be between {minimum} and {maximum}, got {value}")
    return value


@dataclass(frozen=True)
class RuntimeSettings:
    """Validated settings controlling the backend/development process loop."""

    tick_interval_seconds: float = 0.01
    max_consecutive_ui_errors: int = 5
    error_backoff_max_seconds: float = 1.0
    watchdog_interval_seconds: float = 10.0

    @classmethod
    def from_environment(
        cls, environment: Optional[Mapping[str, str]] = None
    ) -> "RuntimeSettings":
        env = os.environ if environment is None else environment

        watchdog_default = 10.0
        watchdog_usec = env.get("WATCHDOG_USEC", "").strip()
        if watchdog_usec:
            try:
                # Notify twice per systemd watchdog window, as recommended.
                watchdog_default = max(0.5, int(watchdog_usec) / 2_000_000)
            except ValueError as exc:
                raise ValueError(
                    f"WATCHDOG_USEC must be an integer, got {watchdog_usec!r}"
                ) from exc

        return cls(
            tick_interval_seconds=_read_float(
                env,
                "LABCOBOT_UI_TICK_SECONDS",
                0.01,
                minimum=0.001,
                maximum=1.0,
            ),
            max_consecutive_ui_errors=_read_int(
                env,
                "LABCOBOT_MAX_CONSECUTIVE_UI_ERRORS",
                5,
                minimum=1,
                maximum=100,
            ),
            error_backoff_max_seconds=_read_float(
                env,
                "LABCOBOT_ERROR_BACKOFF_MAX_SECONDS",
                1.0,
                minimum=0.01,
                maximum=30.0,
            ),
            watchdog_interval_seconds=_read_float(
                env,
                "LABCOBOT_WATCHDOG_INTERVAL_SECONDS",
                watchdog_default,
                minimum=0.5,
                maximum=300.0,
            ),
        )


class SystemdNotifier:
    """Small dependency-free implementation of the systemd notify protocol."""

    def __init__(
        self,
        environment: Optional[Mapping[str, str]] = None,
        process_id: Optional[int] = None,
    ) -> None:
        env = os.environ if environment is None else environment
        self._socket_path = env.get("NOTIFY_SOCKET")
        self._process_id = os.getpid() if process_id is None else process_id

        watchdog_pid = env.get("WATCHDOG_PID", "").strip()
        self.watchdog_enabled = bool(env.get("WATCHDOG_USEC")) and (
            not watchdog_pid or watchdog_pid == str(self._process_id)
        )

    def notify(self, message: str) -> bool:
        """Send a notification, returning ``False`` outside systemd."""

        if not self._socket_path:
            return False
        address = self._socket_path
        if address.startswith("@"):
            # systemd uses @ to represent Linux abstract Unix sockets.
            address = "\0" + address[1:]
        try:
            with socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) as client:
                client.connect(address)
                client.sendall(message.encode("utf-8"))
            return True
        except OSError:
            # A missing notification socket must never take the UI down.
            LOGGER.warning("Could not notify systemd", exc_info=True)
            return False


class LabCobotRuntime:
    """Run one application instance until shutdown or persistent tick failure."""

    def __init__(
        self,
        app_factory: Callable[[], object],
        timer_handler: Callable[[], object],
        *,
        settings: Optional[RuntimeSettings] = None,
        notifier: Optional[SystemdNotifier] = None,
        services: Optional[object] = None,
        monotonic: Callable[[], float] = time.monotonic,
    ) -> None:
        self.app_factory = app_factory
        self.timer_handler = timer_handler
        self.settings = settings or RuntimeSettings.from_environment()
        self.notifier = notifier or SystemdNotifier()
        self.services = services
        self.monotonic = monotonic
        self.stop_event = threading.Event()
        self._signals_installed = False
        self._restart_requested = False

    def request_stop(self, signum: Optional[int] = None, _frame: object = None) -> None:
        if signum is not None:
            LOGGER.info("Received signal %s; stopping", signum)
        self.stop_event.set()

    def request_restart(self) -> None:
        """Exit non-zero so systemd restarts after an installed OTA release."""

        self._restart_requested = True
        self.stop_event.set()

    def _install_signal_handlers(self) -> None:
        try:
            signal.signal(signal.SIGTERM, self.request_stop)
            signal.signal(signal.SIGINT, self.request_stop)
            self._signals_installed = True
        except ValueError:
            # Tests and embedded callers may run this outside the main thread.
            LOGGER.debug("Signal handlers can only be installed in the main thread")

    def run(self) -> int:
        """Start the selected backend/frontend and return a process exit status."""

        self._install_signal_handlers()
        self.notifier.notify("STATUS=Initializing LabCobot runtime")
        exit_code = 1

        try:
            # Keep a strong reference for the lifetime of the selected app.
            application = self.app_factory()
            if application is None:
                raise RuntimeError("Application factory returned no application")
            if self.services is not None:
                self.services.start(restart_callback=self.request_restart)
                failure_check = getattr(self.services, "raise_if_failed", None)
                if callable(failure_check):
                    failure_check()

            self.notifier.notify("READY=1\nSTATUS=LabCobot runtime running")
            next_watchdog = self.monotonic()
            consecutive_errors = 0

            while not self.stop_event.is_set():
                tick_started = self.monotonic()
                try:
                    failure_check = getattr(self.services, "raise_if_failed", None)
                    if callable(failure_check):
                        failure_check()
                    self.timer_handler()
                    consecutive_errors = 0
                except Exception as exc:
                    consecutive_errors += 1
                    failure_recorder = getattr(
                        self.services,
                        "record_runtime_tick_failure",
                        None,
                    )
                    if callable(failure_recorder):
                        try:
                            failure_recorder(consecutive_errors=consecutive_errors)
                        except Exception:
                            LOGGER.exception(
                                "Could not invalidate OTA boot health after a "
                                "runtime tick failure"
                            )
                    LOGGER.exception(
                        "Runtime tick handler failed (%d/%d)",
                        consecutive_errors,
                        self.settings.max_consecutive_ui_errors,
                    )
                    self.notifier.notify(
                        "STATUS=Runtime tick error "
                        f"{consecutive_errors}/"
                        f"{self.settings.max_consecutive_ui_errors}: "
                        f"{type(exc).__name__}"
                    )
                    if consecutive_errors >= self.settings.max_consecutive_ui_errors:
                        raise RuntimeError(
                            "Runtime tick handler exceeded the consecutive "
                            "error limit"
                        ) from exc
                    backoff = min(
                        self.settings.error_backoff_max_seconds,
                        self.settings.tick_interval_seconds
                        * (2 ** (consecutive_errors - 1)),
                    )
                    self.stop_event.wait(backoff)

                now = self.monotonic()
                if self.notifier.watchdog_enabled and now >= next_watchdog:
                    self.notifier.notify("WATCHDOG=1")
                    next_watchdog = now + self.settings.watchdog_interval_seconds

                elapsed = self.monotonic() - tick_started
                remaining = self.settings.tick_interval_seconds - elapsed
                if remaining > 0:
                    self.stop_event.wait(remaining)

            exit_code = 75 if self._restart_requested else 0
        except Exception:
            LOGGER.exception("LabCobot runtime stopped after a fatal error")
            failure_handler = getattr(self.services, "handle_runtime_failure", None)
            if callable(failure_handler):
                try:
                    if failure_handler():
                        self.notifier.notify(
                            "STATUS=Pending OTA release rolled back after boot failure"
                        )
                except Exception:
                    LOGGER.exception(
                        "Could not roll back pending OTA release after failure"
                    )
            exit_code = 1
        finally:
            if self.services is not None:
                try:
                    self.services.stop()
                except Exception:
                    LOGGER.exception("Could not stop workcell services cleanly")
                    exit_code = 1
            self.notifier.notify("STOPPING=1\nSTATUS=LabCobot runtime stopped")
        return exit_code


def configure_logging() -> None:
    level_name = os.environ.get("LABCOBOT_LOG_LEVEL", "INFO").upper()
    level = getattr(logging, level_name, logging.INFO)
    logging.basicConfig(
        level=level,
        format="%(asctime)s %(levelname)s %(name)s: %(message)s",
    )


def read_application_version(
    app_root: Path,
    application_config: Mapping[str, object],
) -> str:
    """Read the signed OTA release marker, falling back to initial config."""

    marker = Path(app_root) / RELEASE_VERSION_FILENAME
    try:
        if marker.is_symlink() or not marker.is_file() or marker.stat().st_size > 128:
            raise ValueError("unsafe release marker")
        candidate = marker.read_text(encoding="ascii").strip()
        if (
            not candidate
            or len(candidate) > 80
            or any(
                not (character.isalnum() or character in {".", "!", "+", "-", "_"})
                for character in candidate
            )
        ):
            raise ValueError("invalid release marker")
        return candidate
    except (OSError, UnicodeError, ValueError):
        return str(application_config.get("version") or "0.0.0")


def _deployment_mode_from_arguments(arguments: Sequence[str]) -> str:
    """Return an explicit runtime mode while preserving ``--production``."""

    runtime_mode = ""
    legacy_production = False
    index = 0
    while index < len(arguments):
        argument = str(arguments[index])
        if argument == "--production":
            legacy_production = True
        elif argument == "--runtime-mode":
            index += 1
            if index >= len(arguments):
                raise ValueError("--runtime-mode requires a value")
            candidate = str(arguments[index]).strip().lower()
            if runtime_mode and candidate != runtime_mode:
                raise ValueError("--runtime-mode may only be specified once")
            runtime_mode = candidate
        elif argument.startswith("--runtime-mode="):
            candidate = argument.split("=", 1)[1].strip().lower()
            if runtime_mode and candidate != runtime_mode:
                raise ValueError("--runtime-mode may only be specified once")
            runtime_mode = candidate
        index += 1

    if runtime_mode and runtime_mode not in {
        "development",
        "production",
        "gateway",
        "operator",
        "workcell",
    }:
        raise ValueError(
            "--runtime-mode must be development, production, gateway, operator, "
            "or workcell"
        )
    if legacy_production and runtime_mode and runtime_mode != "production":
        raise ValueError("--production conflicts with --runtime-mode")
    return runtime_mode or ("production" if legacy_production else "")


def run_application(app_factory: Callable[[], object]) -> int:
    """Run ``app_factory`` with the real LVGL timer handler."""

    import lvgl as lv

    runtime = LabCobotRuntime(app_factory, lv.timer_handler)
    return runtime.run()


class HeadlessBackendApplication:
    """Dependency-free lifetime marker for a headless CPython backend.

    Production, gateway, operator, and commissioned workcell services never
    depend on an unofficial CPython LVGL package merely to keep hardware
    services alive.
    """


# Backward-compatible import name for callers that referenced the original
# gateway-only marker before the other headless modes adopted the same backend.
HeadlessGatewayApplication = HeadlessBackendApplication


def _headless_backend_tick() -> None:
    """Provide a harmless watchdog/signal tick for headless services."""


_headless_gateway_tick = _headless_backend_tick


def _runtime_frontend(
    deployment_mode: str,
) -> Tuple[Callable[[object], object], Callable[[], object]]:
    """Load LVGL only for explicit development compatibility mode."""

    if deployment_mode in {"production", "gateway", "operator", "workcell"}:
        LOGGER.info(
            "Starting the headless CPython %s backend; the optional "
            "touch frontend is the separate official LVGL MicroPython process",
            deployment_mode,
        )
        return (
            lambda _services: HeadlessBackendApplication(),
            _headless_backend_tick,
        )

    if __package__:
        from .app import MainApp
    else:
        from app import MainApp
    import lvgl as lv

    return (
        lambda services: MainApp(workcell_services=services),
        lv.timer_handler,
    )


def main(argv: Optional[Sequence[str]] = None) -> int:
    """Validate configuration and start the selected runtime backend."""

    configure_logging()
    arguments = tuple(sys.argv[1:] if argv is None else argv)
    try:
        deployment_mode = _deployment_mode_from_arguments(arguments)
        settings = RuntimeSettings.from_environment()
    except ValueError as exc:
        LOGGER.error("Invalid runtime configuration: %s", exc)
        return 2

    try:
        if __package__:
            from .config import load_config
            from .integrations.settings import IntegrationSettings
            from .integrations.workcell import build_workcell_services
        else:
            from config import load_config
            from integrations.settings import IntegrationSettings
            from integrations.workcell import build_workcell_services
        application_factory, timer_handler = _runtime_frontend(deployment_mode)
    except Exception:
        LOGGER.exception("Could not import the LabCobot runtime backend")
        return 1

    try:
        app_root = Path(__file__).resolve().parent.parent
        application_config = load_config(
            {"version": "0.0.0"},
            str(app_root / "config.json"),
        )
        integration_settings = IntegrationSettings.from_environment(
            install_root=app_root,
            current_version=read_application_version(
                app_root,
                application_config,
            ),
            deployment_mode=deployment_mode,
        )
        services = build_workcell_services(integration_settings)
    except Exception:
        LOGGER.exception("Invalid workcell integration configuration")
        return 2

    return LabCobotRuntime(
        lambda: application_factory(services),
        timer_handler,
        settings=settings,
        services=services,
    ).run()


if __name__ == "__main__":
    raise SystemExit(main())
