"""Configuration loading, validation, and serialization.

The middleware is configured through a JSON file, but every field is now also
editable from the built-in web admin UI — so loading is *lenient*: a missing or
half-filled file still produces a usable Config (the admin UI starts, the user
fills the gaps, hits Save, and the device listeners come up). Hard validation is
replaced by capability flags (`can_upload`, runnable devices) the app checks
before starting each background worker.
"""

from __future__ import annotations

import json
import os
from typing import Any, Dict, List

DEFAULT_BARCODE_FIELDS = ["OBR-3", "OBR-2", "SPM-2", "PID-3"]
DEFAULT_VALUE_TYPES = ["NM", "ST", "SN"]
KNOWN_DRIVERS = ["dymind_hl7"]


class ConfigError(Exception):
    """Raised only by the strict CLI loader (kept for backward compatibility)."""


class DeviceConfig:
    """One analyzer device the middleware listens for."""

    def __init__(self, raw: Dict[str, Any]) -> None:
        self.name: str = str(raw.get("name") or "device")
        try:
            self.machine_id: int = int(raw.get("machine_id") or 0)
        except (TypeError, ValueError):
            self.machine_id = 0
        self.driver: str = str(raw.get("driver") or "dymind_hl7")
        self.listen_host: str = str(raw.get("listen_host") or "0.0.0.0")
        try:
            self.listen_port: int = int(raw.get("listen_port") or 0)
        except (TypeError, ValueError):
            self.listen_port = 0
        self.barcode_fields: List[str] = list(raw.get("barcode_fields") or DEFAULT_BARCODE_FIELDS)
        self.value_types: List[str] = list(raw.get("value_types") or DEFAULT_VALUE_TYPES)
        self.send_ack: bool = bool(raw.get("send_ack", True))
        self.enabled: bool = bool(raw.get("enabled", True))

        # Transparent relay: forward the analyzer's raw bytes on to a downstream
        # host:port (e.g. the vendor data manager) so an existing integration
        # keeps receiving identical traffic while we tap it for the cloud. When
        # set, we DON'T send our own ACK — the downstream's ACK is relayed back.
        self.forward_host: str = str(raw.get("forward_host") or "")
        try:
            self.forward_port: int = int(raw.get("forward_port") or 0)
        except (TypeError, ValueError):
            self.forward_port = 0

        # Serial (RS-232 / COM) transport — for ASTM analyzers (Maglumi/Mispa).
        self.com_port: str = str(raw.get("com_port") or "")
        try:
            self.baud_rate: int = int(raw.get("baud_rate") or 9600)
            self.data_bits: int = int(raw.get("data_bits") or 8)
            self.stop_bits: int = int(raw.get("stop_bits") or 1)
        except (TypeError, ValueError):
            self.baud_rate, self.data_bits, self.stop_bits = 9600, 8, 1
        self.parity: str = str(raw.get("parity") or "none")

    @property
    def is_serial(self) -> bool:
        return bool(self.com_port)

    @property
    def runnable(self) -> bool:
        """True when this device has enough config to open a listener/port."""
        if not (self.enabled and self.machine_id > 0):
            return False
        return bool(self.com_port) or (0 < self.listen_port < 65536)

    @property
    def relay_enabled(self) -> bool:
        return bool(self.forward_host) and 0 < self.forward_port < 65536

    def to_dict(self) -> Dict[str, Any]:
        return {
            "name": self.name,
            "machine_id": self.machine_id,
            "driver": self.driver,
            "listen_host": self.listen_host,
            "listen_port": self.listen_port,
            "barcode_fields": self.barcode_fields,
            "value_types": self.value_types,
            "send_ack": self.send_ack,
            "enabled": self.enabled,
            "forward_host": self.forward_host,
            "forward_port": self.forward_port,
            "com_port": self.com_port,
            "baud_rate": self.baud_rate,
            "data_bits": self.data_bits,
            "stop_bits": self.stop_bits,
            "parity": self.parity,
        }


class Config:
    """Top-level middleware configuration (lenient — never raises)."""

    def __init__(self, raw: Dict[str, Any] | None = None) -> None:
        raw = raw or {}
        cloud = raw.get("cloud") or {}
        self.base_url: str = str(cloud.get("base_url") or "").rstrip("/")
        self.token: str = str(cloud.get("token") or "")
        self.auth_header: str = str(cloud.get("auth_header") or "X-Authorization")
        self.verify_ssl: bool = bool(cloud.get("verify_ssl", True))
        self.http_timeout: int = int(cloud.get("timeout_seconds") or 20)

        login = cloud.get("login") or {}
        self.login_enabled: bool = bool(login.get("enabled", False))
        self.login_email: str = str(login.get("email") or "")
        self.login_password: str = str(login.get("password") or "")

        buf = raw.get("buffer") or {}
        self.buffer_db: str = str(buf.get("db_path") or "buffer.sqlite3")
        self.retry_interval: int = int(buf.get("retry_interval_seconds") or 20)
        self.drain_batch: int = int(buf.get("drain_batch") or 25)

        self.heartbeat_interval: int = int(raw.get("heartbeat_interval_seconds") or 60)
        self.log_level: str = str(raw.get("log_level") or "INFO")
        self.log_file: str = str(raw.get("log_file") or "lis-middleware.log")

        # Identity of THIS middleware install (one per branch workstation).
        # Shown in the UI and sent with each heartbeat so the cloud can tell
        # which station a machine was last seen on. Defaults to the hostname.
        import socket
        self.workstation_name: str = str(raw.get("workstation_name") or socket.gethostname())

        admin = raw.get("admin") or {}
        self.admin_host: str = str(admin.get("host") or "127.0.0.1")
        self.admin_port: int = int(admin.get("port") or 8765)
        # Optional Basic-auth password for the admin UI. Empty = open (fine when
        # bound to 127.0.0.1); set it when exposing the UI to the LAN.
        self.admin_password: str = str(admin.get("password") or "")

        self.devices: List[DeviceConfig] = [DeviceConfig(d) for d in (raw.get("devices") or [])]

    @property
    def can_upload(self) -> bool:
        """True when the cloud side is configured enough to send results."""
        return bool(self.base_url) and (bool(self.token) or self.login_enabled)

    def validation_messages(self) -> List[str]:
        """Human-readable gaps, surfaced in the admin UI (not fatal)."""
        msgs: List[str] = []
        if not self.base_url:
            msgs.append("Cloud base URL is not set.")
        if not self.token and not self.login_enabled:
            msgs.append("No cloud token and login is disabled — results can't be uploaded.")
        if not self.devices:
            msgs.append("No devices configured yet.")
        for d in self.devices:
            if d.enabled and not d.runnable:
                msgs.append(f"Device '{d.name}' is enabled but missing machine_id or port.")
        return msgs

    def to_dict(self) -> Dict[str, Any]:
        return {
            "cloud": {
                "base_url": self.base_url,
                "token": self.token,
                "auth_header": self.auth_header,
                "verify_ssl": self.verify_ssl,
                "timeout_seconds": self.http_timeout,
                "login": {
                    "enabled": self.login_enabled,
                    "email": self.login_email,
                    "password": self.login_password,
                },
            },
            "buffer": {
                "db_path": self.buffer_db,
                "retry_interval_seconds": self.retry_interval,
                "drain_batch": self.drain_batch,
            },
            "admin": {"host": self.admin_host, "port": self.admin_port, "password": self.admin_password},
            "workstation_name": self.workstation_name,
            "heartbeat_interval_seconds": self.heartbeat_interval,
            "log_level": self.log_level,
            "log_file": self.log_file,
            "devices": [d.to_dict() for d in self.devices],
        }


def load_or_default(path: str) -> Config:
    """Read the config file if present; otherwise return an empty Config.

    Never raises — the admin UI is the recovery path for a bad/missing file.
    """
    if os.path.isfile(path):
        try:
            with open(path, "r", encoding="utf-8") as fh:
                return Config(json.load(fh))
        except (OSError, ValueError):
            pass
    return Config({})


def save_config(path: str, raw: Dict[str, Any]) -> Config:
    """Atomically write a raw config dict to disk and return the parsed Config."""
    cfg = Config(raw)  # normalise through the model first
    tmp = path + ".tmp"
    with open(tmp, "w", encoding="utf-8") as fh:
        json.dump(cfg.to_dict(), fh, indent=2, ensure_ascii=False)
    os.replace(tmp, path)
    return cfg


def load_config(path: str) -> Config:
    """Strict loader kept for any non-UI callers."""
    if not os.path.isfile(path):
        raise ConfigError(f"config file not found: {path}")
    with open(path, "r", encoding="utf-8") as fh:
        return Config(json.load(fh))
