# -*- coding: utf-8 -*-
#
# By F5NLG / Jean-Philippe Piers and Chatgpt 5.2
#
# CHIRP Live driver for Guohtec PMR-171
# Complete consolidated version (with FIXED SPLIT behavior):
#  - Robust serial I/O (retries + resync)
#  - Per-memory CTCSS TX/RX (indexes per manual/table)
#  - Large +/- offsets supported up to MAX_OFFSET_HZ
#  - TRUE SPLIT support for CHIRP:
#       * In CHIRP, when duplex="split", the "Offset" field typically holds the TX FREQUENCY
#         (not a delta). This driver now:
#           - exposes TX frequency in mem.offset when duplex=split
#           - uses mem.tx_freq if present, otherwise uses mem.offset as TX frequency if it
#             looks like a real frequency
#  - CSV import friendliness:
#       * Accept "FM" (maps to radio NFM index 6)
#       * Broad valid bands (100 kHz..2 GHz)
#       * validate_memory override to prevent false "out of range" rejections
#  - DTCS not supported (best-effort hide)
#
# Frame format:
#   A5 A5 A5 A5 | LEN | CMD | DATA... | CRC_H | CRC_L
# LEN includes CRC bytes:
#   LEN = len(CMD + DATA + CRC(2))
# CRC16/CCITT-FALSE over: LEN + CMD + DATA (CRC excluded)
#
# Memory read:
#   TX: A5.. 05 41 00 NN CRC
#   RX: A5.. 1D 41 00 NN <24 bytes> CRC
# Memory write:
#   TX: A5.. 1D 40 00 NN <24 bytes> CRC
#
# 24-byte memory layout (confirmed by Windows terminal captures):
#   [0]     mode index (protocol: 0..8)
#   [1]     unknown (kept as-is)
#   [2:6]   RX freq uint32 BE (Hz)
#   [6:10]  TX freq uint32 BE (Hz)
#   [10]    CTCSS TX index (0=off, 1=67.0, 2=69.3, ... 9=88.5, ...)
#   [11]    CTCSS RX index (same table)
#   [12:24] name (12 ASCII bytes, NUL padded)

import sys
import time
import struct
import threading

from chirp import chirp_common, errors, directory

HEADER = b"\xA5\xA5\xA5\xA5"
MAX_MEMORIES = 1000

# Allow large offsets to stay as +/- (instead of forcing split)
MAX_OFFSET_HZ = 1_000_000_000  # 1 GHz (raise if you ever need more)

# I/O stability tuning
INTER_CMD_DELAY = 0.03
READ_TIMEOUT = 3.0
FRAME_TIMEOUT = 1.0
RETRIES = 3

DEBUG = False  # True => debug logs to stderr


def dbg(msg: str) -> None:
    if DEBUG:
        print(f"[PMR171] {msg}", file=sys.stderr, flush=True)


def crc16_ccitt_false(data: bytes) -> int:
    crc = 0xFFFF
    for b in data:
        crc ^= (b << 8) & 0xFFFF
        for _ in range(8):
            if crc & 0x8000:
                crc = ((crc << 1) ^ 0x1021) & 0xFFFF
            else:
                crc = (crc << 1) & 0xFFFF
    return crc & 0xFFFF


# CTCSS tone table (index 0 = OFF), from your manual
CTCSS_TONES = [
    0.0,
    67.0, 69.3, 71.9, 74.4, 77.0, 79.7, 82.5, 85.4, 88.5, 91.5,
    94.8, 97.4, 100.0, 103.5, 107.2, 110.9, 114.8, 118.8, 123.0,
    127.3, 131.8, 136.5, 141.3, 146.2, 150.0, 151.4, 156.7, 159.8,
    162.2, 165.5, 167.9, 171.3, 173.8, 177.3, 179.9, 183.5, 186.2,
    189.9, 192.8, 196.6, 199.5, 203.5, 206.5, 210.7, 213.8, 218.1,
    221.3, 225.7, 229.1, 233.6, 237.1, 241.8, 245.5, 250.3, 254.1,
]


def ctcss_index_from_tone(tone: float) -> int:
    """Return table index for a tone (0=off)."""
    if not tone:
        return 0
    tt = float(tone)
    for i, t in enumerate(CTCSS_TONES):
        if i == 0:
            continue
        if abs(t - tt) < 0.05:
            return i
    return 0


def ctcss_tone_from_index(idx: int) -> float:
    if 0 <= idx < len(CTCSS_TONES):
        return float(CTCSS_TONES[idx])
    return 0.0


# CHIRP-friendly modes:
# - We accept "FM" from CSV/UI, and map it to radio index 6 (NFM).
IDX_TO_MODE = {
    0: "USB",
    1: "LSB",
    2: "CWR",
    3: "CWL",
    4: "AM",
    5: "WFM",
    6: "FM",   # show as FM for CSV compatibility (radio uses NFM here)
    7: "DIGI",
    8: "PKT",
}

MODE_TO_IDX = {
    "USB": 0,
    "LSB": 1,
    "CWR": 2,
    "CWL": 3,
    "AM": 4,
    "WFM": 5,
    "FM": 6,    # IMPORTANT: generic FM -> radio NFM index
    "NFM": 6,   # accept if it appears
    "DIGI": 7,
    "PKT": 8,
}


def _looks_like_frequency_hz(v: int) -> bool:
    return 100_000 <= v <= 2_000_000_000


@directory.register
class PMR171Radio(chirp_common.LiveRadio):
    VENDOR = "Guohetec"
    MODEL = "PMR-171"
    BAUD_RATE = 115200

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._io_lock = threading.RLock()
        self._ready = False
        self._configured = False

    # ---------------- CHIRP features ----------------

    def get_features(self):
        rf = chirp_common.RadioFeatures()
        rf.has_settings = False
        rf.has_bank = False
        rf.has_name = True
        rf.memory_bounds = (0, MAX_MEMORIES - 1)
        rf.valid_name_length = 12
        rf.valid_characters = chirp_common.CHARSET_ASCII

        # Modes: include "FM" for CSV friendliness
        try:
            rf.has_mode = True
            rf.valid_modes = ["USB", "LSB", "CWR", "CWL", "AM", "WFM", "FM", "NFM", "DIGI", "PKT"]
        except Exception:
            pass

        # Duplex types
        try:
            rf.valid_duplexes = ["", "+", "-", "split"]
        except Exception:
            pass

        # Requested tuning steps (best-effort; depends on CHIRP build)
        try:
            rf.valid_tuning_steps = [1.0, 5.0, 6.25, 10.0, 12.5, 25.0]
        except Exception:
            pass

        # Broad frequency limits for validation (many CHIRP builds use valid_bands)
        try:
            rf.valid_bands = [(100_000, 2_000_000_000)]  # 100 kHz .. 2 GHz
        except Exception:
            pass
        try:
            rf.valid_frequency_ranges = [(100_000, 2_000_000_000)]
        except Exception:
            pass

        # Allow very large offsets in UI (best-effort)
        try:
            rf.max_offset = 2_000_000_000  # 2 GHz
        except Exception:
            pass

        # Hide DTCS/DCS columns (radio has no DTCS) — best-effort
        for attr, val in [
            ("has_dtcs", False),
            ("has_rx_dtcs", False),
            ("has_tx_dtcs", False),
            ("has_dtcs_polarity", False),
            ("has_cross", False),
        ]:
            try:
                setattr(rf, attr, val)
            except Exception:
                pass

        # Enable tone UI (CTCSS)
        for attr, val in [
            ("has_ctone", True),
            ("has_rtone", True),
            ("has_tone", True),
        ]:
            try:
                setattr(rf, attr, val)
            except Exception:
                pass

        # Provide valid tone list if supported by this CHIRP build
        try:
            rf.valid_tones = [t for t in CTCSS_TONES if t != 0.0]
        except Exception:
            pass

        return rf

    def validate_memory(self, mem):
        """
        Prevent false rejections during CSV import (e.g., 446.00625 MHz).
        We remove only "out of supported range" errors when frequency is within
        the true radio range.
        """
        msgs = []
        try:
            msgs = chirp_common.LiveRadio.validate_memory(self, mem)
        except Exception:
            msgs = []

        try:
            f = int(mem.freq)
            if _looks_like_frequency_hz(f):
                msgs = [m for m in msgs if "out of supported range" not in str(m)]
        except Exception:
            pass

        return msgs

    # ---------------- Serial config ----------------

    def _configure_port(self):
        if self._configured:
            return
        try:
            self.pipe.baudrate = self.BAUD_RATE
        except Exception:
            pass
        try:
            self.pipe.rtscts = False
            self.pipe.dsrdtr = False
        except Exception:
            pass
        try:
            self.pipe.timeout = 0.1
        except Exception:
            pass
        self._configured = True

    def _flush(self):
        try:
            self.pipe.reset_input_buffer()
            self.pipe.reset_output_buffer()
        except Exception:
            pass

    # ---------------- Frames ----------------

    def _build_frame(self, cmd: int, data: bytes = b"") -> bytes:
        length = 1 + len(data) + 2
        len_b = bytes([length])
        cmd_data = bytes([cmd]) + data
        crc = crc16_ccitt_false(len_b + cmd_data)
        return HEADER + len_b + cmd_data + struct.pack(">H", crc)

    def _read_frame(self, timeout: float) -> dict:
        start = time.time()
        window = b""

        while time.time() - start < timeout:
            b = self.pipe.read(1)
            if not b:
                continue
            window = (window + b)[-4:]
            if window == HEADER:
                break
        else:
            raise errors.RadioError("PMR-171: no header")

        len_b = self.pipe.read(1)
        if not len_b:
            raise errors.RadioError("PMR-171: no length")

        length = len_b[0]
        rest = self.pipe.read(length)
        if len(rest) != length:
            raise errors.RadioError("PMR-171: short frame")

        if length < 3:
            raise errors.RadioError("PMR-171: invalid length")

        cmd = rest[0]
        cmd_data = rest[:-2]
        data = cmd_data[1:]
        crc_b = rest[-2:]

        calc = crc16_ccitt_false(len_b + cmd_data)
        recv = int.from_bytes(crc_b, "big")
        if calc != recv:
            raise errors.RadioError("PMR-171: CRC mismatch")

        return {"cmd": cmd, "data": data}

    def _drain(self, seconds: float = 0.25):
        try:
            end = time.time() + seconds
            while time.time() < end:
                _ = self.pipe.read(4096)
        except Exception:
            pass

    def _resync(self):
        dbg("resync()")
        self._ready = False
        self.sync_in()

    def _cmd_expect(self, cmd: int, data: bytes, expect_cmd: int, timeout: float) -> dict:
        frame = self._build_frame(cmd, data)
        self.pipe.write(frame)
        self.pipe.flush()
        time.sleep(INTER_CMD_DELAY)

        start = time.time()
        last_err = None
        while time.time() - start < timeout:
            try:
                fr = self._read_frame(timeout=FRAME_TIMEOUT)
                if fr["cmd"] == expect_cmd:
                    return fr
            except Exception as e:
                last_err = e

        if last_err:
            raise last_err
        raise errors.RadioError("PMR-171: timeout")

    # ---------------- Handshake ----------------

    def sync_in(self):
        with self._io_lock:
            self._configure_port()
            self._flush()
            time.sleep(0.2)

            frame = self._build_frame(0x07, b"\x01")
            for _ in range(3):
                self.pipe.write(frame)
                self.pipe.flush()
                time.sleep(0.08)

            self._drain(0.4)
            self._ready = True

    def _ensure_ready(self):
        if not self._ready:
            self.sync_in()

    # ---------------- Memory helpers ----------------

    def _read_memory_raw24_once(self, number: int) -> bytes:
        self._ensure_ready()
        addr = struct.pack(">H", number)
        fr = self._cmd_expect(0x41, addr, expect_cmd=0x41, timeout=READ_TIMEOUT)
        data = fr["data"]

        if len(data) < 26:
            raise errors.RadioError("PMR-171: short memory payload")
        if data[0:2] != addr:
            raise errors.RadioError("PMR-171: address mismatch")

        return data[2:26]

    def _read_memory_raw24(self, number: int) -> bytes:
        last = None
        for attempt in range(1, RETRIES + 1):
            try:
                return self._read_memory_raw24_once(number)
            except errors.RadioError as e:
                last = e
                msg = str(e)
                dbg(f"read mem={number} attempt={attempt} err={msg}")

                if "no header" in msg or "no length" in msg or "short frame" in msg:
                    self._drain(0.2)
                    self._resync()
                    time.sleep(0.1)
                    continue

                if "CRC mismatch" in msg:
                    self._drain(0.2)
                    continue

                raise
        raise last or errors.RadioError("PMR-171: read failed")

    def _write_memory_raw24(self, number: int, raw24: bytes):
        if len(raw24) != 24:
            raise errors.RadioError("PMR-171: raw memory must be 24 bytes")

        self._ensure_ready()
        addr = struct.pack(">H", number)
        payload = addr + raw24
        frame = self._build_frame(0x40, payload)

        # Windows sends multiple times; do same
        for _ in range(3):
            self.pipe.write(frame)
            self.pipe.flush()
            time.sleep(0.05)

        self._drain(0.4)

    # ---------------- CHIRP API ----------------

    def get_memory(self, number: int):
        mem = chirp_common.Memory()
        mem.number = number

        with self._io_lock:
            raw = self._read_memory_raw24(number)

        rx = struct.unpack(">I", raw[2:6])[0]
        tx = struct.unpack(">I", raw[6:10])[0]
        tx_tone_idx = raw[10]
        rx_tone_idx = raw[11]
        name = raw[12:24].rstrip(b"\x00").decode("ascii", "ignore")

        dbg(f"mem={number} raw24={raw.hex(' ')}")

        if rx == 0:
            mem.empty = True
            return mem

        mem.empty = False
        mem.freq = rx
        mem.name = name

        # Mode (CSV-friendly)
        idx = raw[0]
        if idx in IDX_TO_MODE:
            mem.mode = IDX_TO_MODE[idx]

        # If TX absent, assume TX=RX
        if tx == 0:
            tx = rx

        # Duplex/offset: allow large offsets up to MAX_OFFSET_HZ, otherwise SPLIT
        if tx == rx:
            mem.duplex = ""
            mem.offset = 0
        else:
            diff = tx - rx
            if abs(diff) <= MAX_OFFSET_HZ:
                mem.duplex = "+" if diff > 0 else "-"
                mem.offset = abs(diff)
            else:
                mem.duplex = "split"
                # IMPORTANT: CHIRP split convention: offset holds TX FREQUENCY
                mem.offset = tx
                try:
                    mem.tx_freq = tx
                except Exception:
                    pass

        # CTCSS mapping
        tx_tone = ctcss_tone_from_index(tx_tone_idx)
        rx_tone = ctcss_tone_from_index(rx_tone_idx)

        if tx_tone_idx == 0 and rx_tone_idx == 0:
            mem.tmode = ""
            # Clear tones so UI can actually show "None"
            try:
                mem.rtone = 0.0
                mem.ctone = 0.0
            except Exception:
                pass
        elif tx_tone_idx != 0 and rx_tone_idx == 0:
            mem.tmode = "Tone"
            mem.rtone = tx_tone
        elif tx_tone_idx != 0 and rx_tone_idx != 0 and abs(tx_tone - rx_tone) < 0.05:
            mem.tmode = "TSQL"
            mem.ctone = tx_tone
        else:
            # Different TX/RX tones: store best-effort
            mem.tmode = "Tone"
            mem.rtone = tx_tone if tx_tone_idx else 0.0
            try:
                mem.ctone = rx_tone if rx_tone_idx else 0.0
            except Exception:
                pass

        return mem

    def set_memory(self, mem: chirp_common.Memory):
        if mem.number < 0 or mem.number >= MAX_MEMORIES:
            raise errors.InvalidMemoryLocation()

        with self._io_lock:
            # Read-modify-write to preserve unknown bytes
            try:
                base = bytearray(self._read_memory_raw24(mem.number))
            except Exception:
                base = bytearray(24)

            if getattr(mem, "empty", False):
                self._write_memory_raw24(mem.number, bytes(24))
                return

            # Force integer frequency (helps with CSV floats)
            try:
                mem.freq = int(mem.freq)
            except Exception:
                pass

            rx = int(mem.freq)

            # Compute TX
            tduplex = getattr(mem, "duplex", "") or ""
            if tduplex == "+":
                tx = rx + int(getattr(mem, "offset", 0))
            elif tduplex == "-":
                tx = rx - int(getattr(mem, "offset", 0))
            elif tduplex == "split":
                # IMPORTANT: many CHIRP builds store split TX frequency in mem.offset
                tx = int(getattr(mem, "tx_freq", 0) or 0)

                if not tx:
                    off = int(getattr(mem, "offset", 0) or 0)
                    # If offset looks like an actual frequency in Hz, use it directly
                    if _looks_like_frequency_hz(off):
                        tx = off

                if not tx:
                    tx = rx
            else:
                tx = rx

            if tx == 0:
                tx = rx

            base[2:6] = struct.pack(">I", rx)
            base[6:10] = struct.pack(">I", tx)

            # Mode mapping (accept "FM" from CSV/UI)
            m = getattr(mem, "mode", None)
            if m in MODE_TO_IDX:
                base[0] = MODE_TO_IDX[m] & 0xFF

            # Name
            name = (getattr(mem, "name", "") or "").encode("ascii", "ignore")[:12].ljust(12, b"\x00")
            base[12:24] = name

            # ---- CTCSS (FORCED OFF handling) ----
            # If tmode is not exactly "Tone" or "TSQL", we force OFF to defeat CHIRP UI quirks.
            tmode = (getattr(mem, "tmode", "") or "").strip()

            if tmode == "Tone":
                tx_idx = ctcss_index_from_tone(getattr(mem, "rtone", 0.0))
                rx_idx = 0
            elif tmode == "TSQL":
                tone = getattr(mem, "ctone", 0.0)
                tx_idx = ctcss_index_from_tone(tone)
                rx_idx = ctcss_index_from_tone(tone)
            else:
                tx_idx = 0
                rx_idx = 0

            base[10] = tx_idx & 0xFF
            base[11] = rx_idx & 0xFF

            # Write with retry/resync if needed
            last = None
            for attempt in range(1, RETRIES + 1):
                try:
                    self._write_memory_raw24(mem.number, bytes(base))
                    return
                except errors.RadioError as e:
                    last = e
                    dbg(f"write mem={mem.number} attempt={attempt} err={e}")
                    self._drain(0.2)
                    self._resync()

            raise last or errors.RadioError("PMR-171: write failed")

