"""Low-level serial port communication with the wafer device.

Mirrors the C# Form1.cs wafer protocol:
  - Detect    : s0 + 510×'F' → 1024 hex chars → WaferInfo
  - ReadMemory: D1 + 510×'F' → N hex chars (retry on bad length)
  - Erase     : p1 + 510×'F' → no response (~15s blocking)

All commands are 512-char strings: 2-char command + 510×'F' padding.
"""

import logging
from contextlib import contextmanager
from dataclasses import dataclass
from typing import Iterator, Optional

import serial as pyserial

log = logging.getLogger(__name__)


@dataclass
class WaferInfo:
    """Parsed wafer identification from the s0 detect response."""

    family_code: str  # "P", "X", "A", "B", "C", "D", "E", "F"
    serial_number: str  # e.g. "P00001"
    sensor_count: int
    mfg_date_hex: str  # raw 8 hex chars
    sensor_assigned_hex: str  # raw 2 hex chars
    locked_hex: str  # raw 2 hex chars
    runtime: int  # seconds
    cycle_count: int


class SerialPort:
    """Low-level serial transport for the temperature-sensing wafer.

    The serial port is opened per-operation (matching C# behaviour)
    rather than kept open persistently.
    """

    BAUDRATE = 888888
    COMMAND_PAD = "F" * 510  # pad to 512 chars total

    def __init__(self, port_name: str) -> None:
        self._port_name = port_name

    # ------------------------------------------------------------------
    # Port opening (shared between scoped and long-lived use)
    # ------------------------------------------------------------------

    @staticmethod
    def open_port(port_name: str, timeout: float | None = None) -> pyserial.Serial:
        """Open a serial port with baud-rate fallback.

        Tries SerialPort.BAUDRATE first, falls back to 115200 on failure.
        The caller owns the lifecycle of the returned Serial object."""
        kwargs = dict(
            parity=pyserial.PARITY_NONE,
            bytesize=8,
            stopbits=pyserial.STOPBITS_ONE,
            xonxoff=False,
            rtscts=False,
            dsrdtr=False,
        )
        if timeout is not None:
            kwargs["timeout"] = timeout
        # Write timeout must never be None (= block forever): on macOS,
        # pseudo-ports like Bluetooth-Incoming-Port accept the open and then
        # hang the write, which froze detect in "Detecting..." permanently.
        kwargs["write_timeout"] = timeout if timeout is not None else 2.0

        try:
            return pyserial.Serial(port_name, SerialPort.BAUDRATE, **kwargs)
        except Exception as exc:
            log.warning("Baud rate %d failed on %s, falling back to 115200: %s", SerialPort.BAUDRATE, port_name, exc)
            return pyserial.Serial(port_name, 115200, **kwargs)

    # ------------------------------------------------------------------
    # Context manager for scoped port access
    # ------------------------------------------------------------------

    @contextmanager
    def _open(self, timeout: float | None = None) -> Iterator[pyserial.Serial]:
        """Open the serial port, yield it, then close on exit."""
        port = self.open_port(self._port_name, timeout=timeout)
        try:
            yield port
        finally:
            port.close()

    # ------------------------------------------------------------------
    # Public API
    # ------------------------------------------------------------------

    def detect(self, timeout_ms: int = 5000) -> Optional[WaferInfo]:
        """Try s0 command. Returns WaferInfo if response is 1024 hex chars."""
        with self._open(timeout_ms / 1000) as port:
            port.write(("s0" + self.COMMAND_PAD).encode())
            response = port.readline().decode().strip()
            if len(response) == 1024:
                return self._parse_wafer_info(response)
            return None

    def read_memory(
        self,
        cmd: str = "D1",
        expected_hex_len: int = 393216,
        timeout_ms: int = 120000,
        retries: int = 8,
    ) -> Optional[bytes]:
        """Send read command, retry on bad length or odd-length string.

        The wafer sends hex-encoded bytes.  A dropped byte corrupts the
        alignment, so the entire buffer must be retried.

        Returns raw bytes on success, None on failure.
        """
        with self._open(timeout_ms / 1000) as port:
            port.write((cmd + self.COMMAND_PAD).encode())

            for attempt in range(retries, 0, -1):
                data_string = port.readline().decode(errors="ignore").strip()

                # Trim trailing odd nibble — wafer sometimes drops 1 char
                # at the tail; one stray nibble is harmless, the rest is good.
                if len(data_string) % 2 != 0:
                    data_string = data_string[:-1]

                # Defensively cap at expected length if the wafer over-sends.
                if expected_hex_len > 0 and len(data_string) > expected_hex_len:
                    data_string = data_string[:expected_hex_len]

                # Accept if we got at least 99% of expected (matches C# tolerance).
                threshold = int(expected_hex_len * 0.99) if expected_hex_len > 0 else 1
                if len(data_string) >= threshold:
                    break

                log.warning(
                    "Short hex string (len=%d, expected~%d), retries left=%d",
                    len(data_string),
                    expected_hex_len,
                    attempt - 1,
                )

                if attempt > 1:
                    port.reset_input_buffer()
                    # Re-issue the command — reset_input_buffer alone leaves
                    # the wafer waiting for nothing.
                    port.write((cmd + self.COMMAND_PAD).encode())
                    continue

                log.error("Retries exhausted after bad read")
                return None

            try:
                return bytes.fromhex(data_string)
            except ValueError as exc:
                log.error("Failed to decode hex: %s", exc)
                return None

    def erase_memory(self, timeout_ms: int = 5000) -> bool:
        """Send p1 erase command. Wafer takes ~15s to erase.

        Returns True if command was sent successfully.
        """
        with self._open(timeout_ms / 1000) as port:
            try:
                port.write(("p1" + self.COMMAND_PAD).encode())
                return True
            except Exception as exc:
                log.error("Erase command failed: %s", exc)
                return False

    # ------------------------------------------------------------------
    # Helpers
    # ------------------------------------------------------------------

    @staticmethod
    def _hex_to_ascii(hex_str: str) -> str:
        """Convert hex string (e.g. '50') to ASCII char ('P')."""
        hex_str = hex_str.replace(" ", "")
        result = []
        for i in range(0, len(hex_str), 2):
            hex_char = hex_str[i : i + 2]
            if len(hex_char) == 2:
                result.append(chr(int(hex_char, 16)))
        return "".join(result)

    def _parse_wafer_info(self, hex_response: str) -> WaferInfo:
        """Parse the 1024-char hex response into WaferInfo.

        Layout (from C# checkPort):
          bytes 0-1   : FamilyCode    (2 hex chars → ASCII)
          bytes 2-5   : Serial number (3 bytes hex → decimal, 5 digits)
          bytes 6-7   : Sensor count  (1 byte hex)
          bytes 8-15  : Mfg date      (4 bytes hex)
          bytes 16-17 : Sensor assigned (1 byte hex)
          bytes 18-19 : Locked        (1 byte hex)
          bytes 1016-1019 : Runtime   (2 bytes hex → decimal seconds)
          bytes 1020-1023 : Cycle time (2 bytes hex → decimal cycles)
        """

        family_code = self._hex_to_ascii(hex_response[0:2])
        serial_num = f"{family_code}{int(hex_response[2:6], 16):05d}"
        sensor_count = int(hex_response[6:8], 16)
        mfg_date_hex = hex_response[8:16]
        sensor_assigned_hex = hex_response[16:18]
        locked_hex = hex_response[18:20]
        runtime = int(hex_response[1016:1020], 16)
        cycle_count = int(hex_response[1020:1024], 16)

        return WaferInfo(
            family_code=family_code,
            serial_number=serial_num,
            sensor_count=sensor_count,
            mfg_date_hex=mfg_date_hex,
            sensor_assigned_hex=sensor_assigned_hex,
            locked_hex=locked_hex,
            runtime=runtime,
            cycle_count=cycle_count,
        )
