"""Service for acquiring sweeps from the external Kamil ADC collector."""

from __future__ import annotations

from contextlib import suppress
from dataclasses import dataclass, field
import errno
import logging
import os
from pathlib import Path
import select
import signal
import stat
import struct
import subprocess
import time

import numpy as np

from python_app.hardware_full.librevna_driver.models import SweepResult
from python_app.models.run_config_model import RadarSweepModel, RunConfigModel

logger = logging.getLogger(__name__)

KAMIL_ADC_MARKER = 0x000A
KAMIL_ADC_START_STEP = 0xFFFF
KAMIL_ADC_FRAME_BYTES = 8
KAMIL_ADC_MAX_STEP = 0xFFFE

_RAW_FRAME_STRUCT = struct.Struct("<HHHH")
_POINT_FRAME_STRUCT = struct.Struct("<HHhh")
_START_FRAME = _RAW_FRAME_STRUCT.pack(
    KAMIL_ADC_MARKER,
    KAMIL_ADC_START_STEP,
    KAMIL_ADC_START_STEP,
    KAMIL_ADC_START_STEP,
)


class KamilAdcFrameParser:
    """Strict parser for Kamil ADC 4-word TTY frames."""

    @staticmethod
    def is_packet_start(frame: bytes) -> bool:
        """Return whether `frame` is the packet-start marker."""
        return frame == _START_FRAME

    @staticmethod
    def parse_point(frame: bytes, expected_step: int) -> complex:
        """Parse one `0x000A step real imag` frame and validate ordering."""
        if len(frame) != KAMIL_ADC_FRAME_BYTES:
            raise ValueError(
                f"Kamil ADC frame must be {KAMIL_ADC_FRAME_BYTES} bytes, got {len(frame)}"
            )
        marker, step, real, imag = _POINT_FRAME_STRUCT.unpack(frame)
        if marker != KAMIL_ADC_MARKER:
            raise ValueError(f"Kamil ADC marker mismatch: got 0x{marker:04x}, expected 0x000a")
        if step != expected_step:
            raise ValueError(f"Kamil ADC step mismatch: got {step}, expected {expected_step}")
        return complex(real, imag)


@dataclass(slots=True)
class KamilAdcTtyReader:
    """Read full Kamil ADC sweep packets from a nonblocking TTY stream."""

    tty_path: str
    _fd: int | None = field(init=False, default=None, repr=False)
    _buffer: bytearray = field(init=False, default_factory=bytearray, repr=False)
    _packet_start_pending: bool = field(init=False, default=False, repr=False)

    def open(self) -> None:
        """Open the configured TTY path for binary reads."""
        if self._fd is not None:
            return
        self._fd = os.open(self.tty_path, os.O_RDONLY | os.O_NOCTTY | os.O_NONBLOCK)

    def close(self) -> None:
        """Close the TTY file descriptor."""
        if self._fd is None:
            return
        try:
            os.close(self._fd)
        finally:
            self._fd = None
            self._buffer.clear()
            self._packet_start_pending = False

    def read_sweep(
        self,
        *,
        timeout_s: float,
        process: subprocess.Popen[bytes] | None = None,
        expected_points: int | None = None,
    ) -> np.ndarray:
        """Read one full packet, optionally discarding packets with an unexpected point count."""
        if self._fd is None:
            raise RuntimeError("Kamil ADC TTY reader is not open")
        if expected_points is not None:
            if expected_points <= 0:
                raise ValueError("Kamil ADC expected points must be > 0")
            if expected_points > KAMIL_ADC_MAX_STEP:
                raise ValueError(f"Kamil ADC expected points must be <= {KAMIL_ADC_MAX_STEP}")

        deadline = time.monotonic() + float(timeout_s)
        while True:
            values = self._read_one_sweep(deadline, process)
            if expected_points is None or int(values.size) == int(expected_points):
                return values
            logger.warning(
                "Discarding Kamil ADC sweep with %d points; expected %d",
                int(values.size),
                int(expected_points),
            )

    def _read_one_sweep(
        self,
        deadline: float,
        process: subprocess.Popen[bytes] | None,
    ) -> np.ndarray:
        """Read one packet from start marker to the next start marker."""
        if self._packet_start_pending:
            self._packet_start_pending = False
        else:
            self._read_until_packet_start(deadline, process)

        values: list[complex] = []
        expected_step = 1
        while True:
            frame = self._read_frame(deadline, process, received_points=len(values))
            if KamilAdcFrameParser.is_packet_start(frame):
                if not values:
                    continue
                self._packet_start_pending = True
                return np.asarray(values, dtype=np.complex64)

            if expected_step > KAMIL_ADC_MAX_STEP:
                raise RuntimeError(f"Kamil ADC sweep exceeded {KAMIL_ADC_MAX_STEP} points without packet end")
            values.append(KamilAdcFrameParser.parse_point(frame, expected_step))
            expected_step += 1

    def discard_pending(self, process: subprocess.Popen[bytes] | None = None) -> None:
        """Discard stale bytes while keeping the newest packet-start boundary."""
        if self._fd is None:
            raise RuntimeError("Kamil ADC TTY reader is not open")
        self._buffer.clear()
        self._packet_start_pending = False
        fd = self._require_fd()
        while True:
            self._raise_if_process_exited(process)
            try:
                readable, _, _ = select.select([fd], [], [], 0.0)
            except InterruptedError:
                continue
            if not readable:
                return
            try:
                chunk = os.read(fd, 4096)
            except BlockingIOError:
                return
            except OSError as exc:
                if exc.errno in {errno.EAGAIN, errno.EWOULDBLOCK}:
                    return
                raise RuntimeError(f"Failed to drain Kamil ADC TTY `{self.tty_path}`: {exc}") from exc
            if not chunk:
                raise RuntimeError(f"Kamil ADC TTY `{self.tty_path}` closed while draining")
            self._buffer.extend(chunk)
            self._keep_latest_packet_start_tail()

    def _keep_latest_packet_start_tail(self) -> None:
        """Keep only bytes from the latest complete packet-start marker onward."""
        start_index = self._buffer.rfind(_START_FRAME)
        if start_index >= 0:
            del self._buffer[:start_index]
            return
        if len(self._buffer) >= KAMIL_ADC_FRAME_BYTES:
            del self._buffer[:-KAMIL_ADC_FRAME_BYTES + 1]

    def _read_until_packet_start(
        self,
        deadline: float,
        process: subprocess.Popen[bytes] | None,
    ) -> None:
        while True:
            start_index = self._buffer.find(_START_FRAME)
            if start_index >= 0:
                del self._buffer[: start_index + KAMIL_ADC_FRAME_BYTES]
                return
            if len(self._buffer) >= KAMIL_ADC_FRAME_BYTES:
                del self._buffer[:-KAMIL_ADC_FRAME_BYTES + 1]
            self._read_available(deadline, process)

    def _read_frame(
        self,
        deadline: float,
        process: subprocess.Popen[bytes] | None,
        *,
        received_points: int,
        expected_points: int | None = None,
    ) -> bytes:
        while len(self._buffer) < KAMIL_ADC_FRAME_BYTES:
            self._read_available(deadline, process, received_points, expected_points)
        frame = bytes(self._buffer[:KAMIL_ADC_FRAME_BYTES])
        del self._buffer[:KAMIL_ADC_FRAME_BYTES]
        return frame

    def _read_available(
        self,
        deadline: float,
        process: subprocess.Popen[bytes] | None,
        received_points: int | None = None,
        expected_points: int | None = None,
    ) -> None:
        self._raise_if_process_exited(process)
        remaining_s = deadline - time.monotonic()
        if remaining_s <= 0.0:
            if received_points is None or expected_points is None:
                if received_points is not None:
                    raise TimeoutError(
                        f"Timed out waiting for Kamil ADC sweep end: received {received_points} points"
                    )
                raise TimeoutError("Timed out waiting for Kamil ADC packet-start marker")
            raise TimeoutError(
                f"Timed out waiting for Kamil ADC sweep: received {received_points}/{expected_points} points"
            )

        fd = self._require_fd()
        wait_s = min(0.05, remaining_s)
        try:
            readable, _, _ = select.select([fd], [], [], wait_s)
        except InterruptedError:
            return
        if not readable:
            return

        try:
            chunk = os.read(fd, 4096)
        except BlockingIOError:
            return
        except OSError as exc:
            if exc.errno in {errno.EAGAIN, errno.EWOULDBLOCK}:
                return
            raise RuntimeError(f"Failed to read Kamil ADC TTY `{self.tty_path}`: {exc}") from exc
        if not chunk:
            raise RuntimeError(f"Kamil ADC TTY `{self.tty_path}` closed while reading")
        self._buffer.extend(chunk)

    def _require_fd(self) -> int:
        if self._fd is None:
            raise RuntimeError("Kamil ADC TTY reader is not open")
        return self._fd

    @staticmethod
    def _raise_if_process_exited(process: subprocess.Popen[bytes] | None) -> None:
        if process is None:
            return
        return_code = process.poll()
        if return_code is not None:
            raise RuntimeError(f"Kamil ADC process exited with code {return_code}")


@dataclass(slots=True)
class KamilAdcService:
    """Launch `kamil_adc` and acquire TTY sweeps."""

    config: RunConfigModel
    _process: subprocess.Popen[bytes] | None = field(init=False, default=None, repr=False)
    _reader: KamilAdcTtyReader | None = field(init=False, default=None, repr=False)
    _settings: RadarSweepModel | None = field(init=False, default=None, repr=False)
    _frequency_hz: np.ndarray | None = field(init=False, default=None, repr=False)
    _expected_points: int | None = field(init=False, default=None, repr=False)

    def __post_init__(self) -> None:
        self._validate_config()

    @property
    def command(self) -> list[str]:
        """Return external collector command including the generated TTY argument."""
        adc = self.config.radar.kamil_adc
        executable_path = str(Path(adc.executable_path).expanduser())
        return [executable_path, *adc.args, f"tty:{adc.tty_path}"]

    def open(self) -> None:
        """Launch the collector and open its TTY stream."""
        if self._reader is not None:
            return

        previous_tty_identity = _prepare_tty_path_for_collector(self.config.radar.kamil_adc.tty_path)
        try:
            self._start_process()
            self._wait_for_tty(previous_tty_identity)
            reader = KamilAdcTtyReader(self.config.radar.kamil_adc.tty_path)
            reader.open()
            self._reader = reader
        except Exception:
            self.close()
            raise

    def close(self) -> None:
        """Close TTY and stop the external collector."""
        if self._reader is not None:
            with suppress(Exception):
                self._reader.close()
            self._reader = None

        self._stop_process()

    def configure(self, sweep: RadarSweepModel) -> None:
        """Store sweep settings used to construct the synthetic frequency axis."""
        self._validate_sweep(sweep)
        self._settings = sweep
        self._frequency_hz = None
        self._expected_points = None

    def read_device_limits(self) -> dict[str, float | int]:
        """Kamil ADC has no runtime-readable sweep limit API."""
        raise RuntimeError("Kamil ADC device limits are not available")

    def acquire(self) -> SweepResult:
        """Acquire one Kamil ADC sweep as S21; fill S11 with explicit zeros."""
        if self._settings is None:
            raise RuntimeError("Kamil ADC service is not configured")
        if self._reader is None:
            raise RuntimeError("Kamil ADC service is not open")
        process = self._process
        if process is None or process.poll() is not None:
            code = None if process is None else process.poll()
            raise RuntimeError(f"Kamil ADC process is not running (code={code})")

        self._reader.discard_pending(process)
        s21 = self._reader.read_sweep(
            timeout_s=self.config.radar.kamil_adc.sweep_timeout_s,
            process=process,
            expected_points=self._expected_points,
        )
        points = int(s21.size)
        if points <= 0:
            raise RuntimeError("Kamil ADC sweep contained no points")
        if self._expected_points is None:
            self._expected_points = points
            self._frequency_hz = self._build_frequency_axis(points)
            logger.info("Kamil ADC sweep point count locked to %d", points)
        if self._frequency_hz is None:
            self._frequency_hz = self._build_frequency_axis(points)
        return SweepResult(
            x=self._frequency_hz.copy(),
            traces={
                "s11": np.zeros(points, dtype=np.complex64),
                "s21": s21,
            },
        )

    def _start_process(self) -> None:
        if self._process is not None and self._process.poll() is None:
            return

        adc = self.config.radar.kamil_adc
        env = os.environ.copy()
        env.update(adc.env)
        logger.info("Starting Kamil ADC collector: %s", " ".join(self.command))
        self._process = subprocess.Popen(
            self.command,
            cwd=str(Path(adc.project_dir).expanduser()),
            env=env,
            stdin=subprocess.DEVNULL,
            stdout=subprocess.DEVNULL,
            stderr=subprocess.STDOUT,
            start_new_session=True,
        )

    def _stop_process(self) -> None:
        process = self._process
        self._process = None
        if process is None:
            return
        if process.poll() is not None:
            return

        with suppress(ProcessLookupError):
            os.killpg(process.pid, signal.SIGTERM)
        try:
            process.wait(timeout=self.config.radar.kamil_adc.stop_timeout_s)
            return
        except subprocess.TimeoutExpired:
            pass

        with suppress(ProcessLookupError):
            os.killpg(process.pid, signal.SIGKILL)
        process.wait(timeout=1.0)

    def _wait_for_tty(self, previous_identity: tuple[object, ...] | None) -> None:
        adc = self.config.radar.kamil_adc
        deadline = time.monotonic() + adc.startup_timeout_s
        while time.monotonic() < deadline:
            KamilAdcTtyReader._raise_if_process_exited(self._process)
            identity = _tty_identity(adc.tty_path)
            if identity is not None and identity != previous_identity:
                return
            time.sleep(0.05)
        raise TimeoutError(
            f"Timed out waiting for Kamil ADC TTY `{adc.tty_path}` to be created by the collector"
        )

    def _validate_config(self) -> None:
        if not self.config.is_kamil_adc:
            raise RuntimeError("KamilAdcService requires radar.model='kamil_adc'")
        if self.config.radar.driver_mode != "native":
            raise RuntimeError("Kamil ADC requires radar.driver_mode='native'")

        adc = self.config.radar.kamil_adc
        if not adc.project_dir:
            raise ValueError("radar.kamil_adc.project_dir is required")
        if not adc.executable_path:
            raise ValueError("radar.kamil_adc.executable_path is required")
        if not adc.tty_path:
            raise ValueError("radar.kamil_adc.tty_path is required")
        if any(arg.startswith("tty:") for arg in adc.args):
            raise ValueError("radar.kamil_adc.args must not contain tty:<path>; use tty_path instead")
        if adc.startup_timeout_s <= 0.0:
            raise ValueError("radar.kamil_adc.startup_timeout_s must be > 0")
        if adc.sweep_timeout_s <= 0.0:
            raise ValueError("radar.kamil_adc.sweep_timeout_s must be > 0")
        if adc.stop_timeout_s <= 0.0:
            raise ValueError("radar.kamil_adc.stop_timeout_s must be > 0")

        project_dir = Path(adc.project_dir).expanduser()
        if not project_dir.is_dir():
            raise RuntimeError(f"radar.kamil_adc.project_dir is not a directory: {project_dir}")
        executable_path = Path(adc.executable_path).expanduser()
        if not executable_path.is_file():
            raise RuntimeError(f"radar.kamil_adc.executable_path is not a file: {executable_path}")
        if not os.access(executable_path, os.X_OK):
            raise RuntimeError(f"radar.kamil_adc.executable_path is not executable: {executable_path}")

    @staticmethod
    def _validate_sweep(sweep: RadarSweepModel) -> None:
        if float(sweep.stop_hz) < float(sweep.start_hz):
            raise ValueError("Kamil ADC sweep stop_hz must be >= start_hz")

    def _build_frequency_axis(self, points: int) -> np.ndarray:
        if self._settings is None:
            raise RuntimeError("Kamil ADC service is not configured")
        return np.linspace(
            float(self._settings.start_hz),
            float(self._settings.stop_hz),
            int(points),
            dtype=np.float32,
        )


def _tty_identity(path: str) -> tuple[object, ...] | None:
    try:
        if os.path.islink(path):
            stat_result = os.lstat(path)
            return (
                "link",
                os.readlink(path),
                int(stat_result.st_dev),
                int(stat_result.st_ino),
                int(stat_result.st_mtime_ns),
            )
        stat_result = os.stat(path)
    except FileNotFoundError:
        return None
    return (
        "node",
        int(stat_result.st_dev),
        int(stat_result.st_ino),
        int(stat_result.st_mtime_ns),
    )


def _prepare_tty_path_for_collector(path: str) -> tuple[object, ...] | None:
    """Remove stale generated TTY links before starting the external collector."""
    try:
        stat_result = os.lstat(path)
    except FileNotFoundError:
        return None

    if stat.S_ISLNK(stat_result.st_mode) or stat.S_ISREG(stat_result.st_mode):
        os.unlink(path)
        return None
    return None


def apply_kamil_adc_laser_control(config: RunConfigModel) -> bool:
    """Apply Kamil ADC laser settings exactly through the legacy device_main command sequence."""
    laser = config.radar.laser_control
    if not laser.enabled:
        return False

    _validate_laser_control_config(config)

    from python_app.hardware_full.laser_control.controller import DEVICE_MAIN_MESSAGE_ID, LaserController
    from python_app.hardware_full.laser_control.models import VariationType

    controller = LaserController(
        port=laser.port,
        pi_coeff1_p=laser.pi_coeff1_p,
        pi_coeff1_i=laser.pi_coeff1_i,
        pi_coeff2_p=laser.pi_coeff2_p,
        pi_coeff2_i=laser.pi_coeff2_i,
    )
    try:
        controller.connect()
        controller.reset()
        mode = laser.mode.strip().lower()
        if mode == "manual":
            manual = laser.manual
            controller.set_manual_mode(
                temp1=manual.temp1,
                temp2=manual.temp2,
                current1=manual.current1,
                current2=manual.current2,
                message_id=DEVICE_MAIN_MESSAGE_ID,
            )
            return True
        if mode == "variation":
            variation = laser.variation
            try:
                variation_type = VariationType[variation.variation_type]
            except KeyError as exc:
                raise ValueError(
                    f"Unsupported radar.laser_control.variation.variation_type: {variation.variation_type}"
                ) from exc

            controller.set_manual_mode(
                temp1=variation.static_temp1,
                temp2=variation.static_temp2,
                current1=variation.static_current1,
                current2=variation.static_current2,
                message_id=DEVICE_MAIN_MESSAGE_ID,
            )
            controller.start_variation(
                variation_type=variation_type,
                params={
                    "static_temp1": variation.static_temp1,
                    "static_temp2": variation.static_temp2,
                    "static_current1": variation.static_current1,
                    "static_current2": variation.static_current2,
                    "min_value": variation.min_value,
                    "max_value": variation.max_value,
                    "step": variation.step,
                    "time_step": variation.time_step,
                    "delay_time": variation.delay_time,
                },
            )
            return True
        raise RuntimeError(f"Unsupported laser_control mode: {laser.mode}")
    finally:
        controller.disconnect()


def _validate_laser_control_config(config: RunConfigModel) -> None:
    laser = config.radar.laser_control
    if not laser.port:
        raise ValueError("radar.laser_control.port is required when laser_control is enabled")
    mode = laser.mode.strip().lower()
    if mode not in {"manual", "variation"}:
        raise ValueError("radar.laser_control.mode must be 'manual' or 'variation'")
    if mode == "variation" and not laser.variation.variation_type:
        raise ValueError("radar.laser_control.variation.variation_type is required")
