# SPDX-FileCopyrightText: 2026-present Ibon Zalbide <ibon.zalbie@kliskatek.com>
#
# SPDX-License-Identifier: MIT

import base64
import json
import logging
import threading
from dataclasses import dataclass
from enum import IntEnum
from typing import Callable, List, Optional

import requests
from requests.auth import HTTPBasicAuth

logger = logging.getLogger(__name__)

DEFAULT_USER = "root"
DEFAULT_PASS = "impinj"
DEFAULT_SESSION = 2
DEFAULT_SEARCH_MODE = "dual-target"
DEFAULT_TAG_POPULATION = 32


class RfMode(IntEnum):
    """Impinj R700 RF Modes. Values are the mode IDs used by the IoT API."""
    MAX_THROUGHPUT = 0                   # FM0,  BLF 640kHz, Tari 7.5us
    HYBRID = 1                           # FM0,  BLF 640kHz, Tari 7.5us, DR 8
    DENSE_READER_M4 = 2                  # M4,   BLF 250kHz, Tari 20us
    DENSE_READER_M8 = 3                  # M8,   BLF 250kHz, Tari 20us
    MAX_MILLER = 5                       # M4,   BLF 250kHz, Tari 20us
    AUTOSET = 204                        # FM0,  BLF 640kHz, Tari 7.5us
    AUTOSET_STATIC_FAST = 227            # M2,   BLF 250kHz, Tari 20us
    AUTOSET_STATIC_DRM = 245             # M4,   BLF 250kHz, Tari 20us
    AUTOSET_DRM = 246                    # M4,   BLF 250kHz, Tari 20us
    AUTOSET_CUSTOM = 288                 # auto
    AUTOSET_CUSTOM_2 = 289               # auto
    AUTOSET_CUSTOM_3 = 290               # auto
    AUTOSET_DENSE_READER = 1002          # M4,   BLF 256kHz, Tari 20us
    AUTOSET_DENSE_READER_DEEP_SCAN = 1003  # M4, BLF 256kHz, Tari 20us
    MAX_MILLER_1004 = 1004               # M4,   BLF 256kHz, Tari 20us
    AUTOSET_SINGLE_READER = 1006         # FM0,  BLF 640kHz, Tari 7.5us
    AUTOSET_REGION = 1210                # auto (region default)
    AUTOSET_REGION_2 = 1211              # auto
    AUTOSET_REGION_3 = 1212              # auto


# RF Mode details from Impinj documentation
RF_MODE_INFO = {
    RfMode.MAX_THROUGHPUT:       {"modulation": "FM0",  "blf_khz": 640,  "tari_us": 7.5,  "dr": "64/3"},
    RfMode.HYBRID:               {"modulation": "FM0",  "blf_khz": 640,  "tari_us": 7.5,  "dr": "8"},
    RfMode.DENSE_READER_M4:      {"modulation": "M4",   "blf_khz": 250,  "tari_us": 20.0, "dr": "64/3"},
    RfMode.DENSE_READER_M8:      {"modulation": "M8",   "blf_khz": 250,  "tari_us": 20.0, "dr": "64/3"},
    RfMode.MAX_MILLER:           {"modulation": "M4",   "blf_khz": 250,  "tari_us": 20.0, "dr": "64/3"},
    RfMode.AUTOSET:              {"modulation": "FM0",  "blf_khz": 640,  "tari_us": 7.5,  "dr": "64/3"},
    RfMode.AUTOSET_STATIC_FAST:  {"modulation": "M2",   "blf_khz": 250,  "tari_us": 20.0, "dr": "64/3"},
    RfMode.AUTOSET_STATIC_DRM:   {"modulation": "M4",   "blf_khz": 250,  "tari_us": 20.0, "dr": "64/3"},
    RfMode.AUTOSET_DRM:          {"modulation": "M4",   "blf_khz": 250,  "tari_us": 20.0, "dr": "64/3"},
    RfMode.AUTOSET_CUSTOM:       {"modulation": "auto", "blf_khz": None, "tari_us": None, "dr": "auto"},
    RfMode.AUTOSET_CUSTOM_2:     {"modulation": "auto", "blf_khz": None, "tari_us": None, "dr": "auto"},
    RfMode.AUTOSET_CUSTOM_3:     {"modulation": "auto", "blf_khz": None, "tari_us": None, "dr": "auto"},
    RfMode.AUTOSET_DENSE_READER: {"modulation": "M4",   "blf_khz": 256,  "tari_us": 20.0, "dr": "64/3"},
    RfMode.AUTOSET_DENSE_READER_DEEP_SCAN: {"modulation": "M4", "blf_khz": 256, "tari_us": 20.0, "dr": "64/3"},
    RfMode.MAX_MILLER_1004:      {"modulation": "M4",   "blf_khz": 256,  "tari_us": 20.0, "dr": "64/3"},
    RfMode.AUTOSET_SINGLE_READER: {"modulation": "FM0", "blf_khz": 640,  "tari_us": 7.5,  "dr": "64/3"},
    RfMode.AUTOSET_REGION:       {"modulation": "auto", "blf_khz": None, "tari_us": None, "dr": "auto"},
    RfMode.AUTOSET_REGION_2:     {"modulation": "auto", "blf_khz": None, "tari_us": None, "dr": "auto"},
    RfMode.AUTOSET_REGION_3:     {"modulation": "auto", "blf_khz": None, "tari_us": None, "dr": "auto"},
}

DEFAULT_RF_MODE = RfMode.DENSE_READER_M4


@dataclass
class ImpinjIotTagReport:
    epc: str
    antenna_port: int = 0
    peak_rssi_cdbm: int = 0
    frequency: int = 0
    transmit_power_cdbm: int = 0


@dataclass
class ImpinjIotReaderInfo:
    manufacturer: str = ""
    model: str = ""
    description: str = ""
    sku: str = ""
    serial_number: str = ""
    firmware_version: str = ""
    region: str = ""
    antenna_count: int = 4
    min_tx_power_cdbm: int = 1000
    max_tx_power_cdbm: int = 3150


class ImpinjIot:
    """Low-level driver for Impinj R700 readers using IoT Device Interface (REST API)."""

    def __init__(self):
        self._session: Optional[requests.Session] = None
        self._base_url: str = ""
        self._connected: bool = False
        self._notification_callback: Optional[Callable[[ImpinjIotTagReport], None]] = None
        self._stream_thread: Optional[threading.Thread] = None
        self._stream_running: bool = False
        self._stream_response: Optional[requests.Response] = None
        self._reader_info: Optional[ImpinjIotReaderInfo] = None
        self._antenna_config: List[bool] = [True, False, False, False]
        self._tx_power_cdbm: int = 3000
        self._rf_mode: int = DEFAULT_RF_MODE

        # Suppress SSL warnings for self-signed certs
        requests.packages.urllib3.disable_warnings(
            requests.packages.urllib3.exceptions.InsecureRequestWarning
        )

    # ── Connection ────────────────────────────

    def connect(self, ip: str, user: str = DEFAULT_USER, password: str = DEFAULT_PASS) -> bool:
        self._base_url = f"https://{ip}"
        self._session = requests.Session()
        self._session.auth = HTTPBasicAuth(user, password)
        self._session.verify = False

        try:
            self._get("/api/v1/system")
            self._connected = True
            logger.info(f"Connected to {ip}")
            return True
        except Exception as e:
            logger.error(f"Connection failed: {e}")
            self._session = None
            return False

    def disconnect(self):
        self.stop()
        self._connected = False
        if self._session:
            self._session.close()
            self._session = None
        logger.info("Disconnected")

    def is_connected(self) -> bool:
        return self._connected

    # ── Notification callback ─────────────────

    def set_notification_callback(self, callback: Callable[[ImpinjIotTagReport], None]):
        self._notification_callback = callback

    # ── Reader info ───────────────────────────

    def get_reader_info(self) -> Optional[ImpinjIotReaderInfo]:
        if self._reader_info is not None:
            return self._reader_info

        try:
            system = self._get("/api/v1/system")
            caps = self._get_or_none("/api/v1/caps")

            antenna_count = 4
            min_power = 1000
            max_power = 3150

            if caps:
                antenna_count = caps.get("antennaCount", 4)
                min_power = caps.get("minTxPowerCdbm", 1000)
                max_power = caps.get("maxTxPowerCdbm", 3150)
            else:
                sku = system.get("productSku", "")
                if "241" in sku:
                    antenna_count = 4
                elif "111" in sku:
                    antenna_count = 1

            fw = system.get("firmwareVersion", "")
            if not fw:
                spec = self._get_or_none("/api/v1/openapi.json")
                if spec and "info" in spec:
                    fw = spec["info"].get("version", "")

            region = ""
            region_info = self._get_or_none("/api/v1/system/region")
            if region_info:
                region = region_info.get("operatingRegion", "")

            self._reader_info = ImpinjIotReaderInfo(
                manufacturer=system.get("manufacturer", ""),
                model=system.get("productModel", ""),
                description=system.get("productDescription", ""),
                sku=system.get("productSku", ""),
                serial_number=system.get("serialNumber", ""),
                firmware_version=fw,
                region=region,
                antenna_count=antenna_count,
                min_tx_power_cdbm=min_power,
                max_tx_power_cdbm=max_power,
            )

            self._antenna_config = [False] * antenna_count
            self._antenna_config[0] = True

            return self._reader_info
        except Exception as e:
            logger.error(f"Failed to get reader info: {e}")
            return None

    # ── TX Power ──────────────────────────────

    def get_tx_power(self) -> float:
        """Returns current TX power in dBm."""
        return self._tx_power_cdbm / 100.0

    def set_tx_power(self, dbm: float):
        """Sets TX power in dBm. Applied on next inventory start."""
        cdbm = int(dbm * 100)
        info = self.get_reader_info()
        if info:
            if cdbm > info.max_tx_power_cdbm:
                cdbm = info.max_tx_power_cdbm
                logger.warning(f"Power clamped to max: {cdbm / 100.0} dBm")
            if cdbm < info.min_tx_power_cdbm:
                cdbm = info.min_tx_power_cdbm
                logger.warning(f"Power clamped to min: {cdbm / 100.0} dBm")
        self._tx_power_cdbm = cdbm

    # ── Antenna config ────────────────────────

    def get_antenna_config(self) -> List[bool]:
        return list(self._antenna_config)

    def set_antenna_config(self, antenna_config: List[bool]):
        if not any(antenna_config):
            antenna_config[0] = True
            logger.warning("At least one antenna must be active. Enabling antenna 1.")
        self._antenna_config = list(antenna_config)

    # ── RF Mode ───────────────────────────────

    def get_rf_mode(self) -> int:
        return self._rf_mode

    def set_rf_mode(self, rf_mode: RfMode | int):
        self._rf_mode = int(rf_mode)

    def get_supported_rf_modes(self) -> Optional[dict]:
        """
        Query the reader for supported RF modes.
        Returns a dict mapping RF mode ID to its details (name, modulation, BLF, Tari, DR).
        """
        spec = self._get_or_none("/api/v1/openapi.json")
        if not spec or "definitions" not in spec:
            return None
        rf_mode_def = spec["definitions"].get("RfMode")
        if not rf_mode_def or "enum" not in rf_mode_def:
            return None

        result = {}
        for mode_id in rf_mode_def["enum"]:
            if mode_id in RF_MODE_INFO:
                result[mode_id] = RF_MODE_INFO[mode_id]
            else:
                result[mode_id] = {"modulation": "?", "blf_khz": None, "tari_us": None, "dr": "?"}
        return result

    # ── Inventory control ─────────────────────

    def start(self) -> bool:
        """Start inventory and NDJSON stream."""
        if not self._connected:
            logger.error("Not connected")
            return False

        antenna_configs = []
        for i, enabled in enumerate(self._antenna_config):
            if enabled:
                antenna_configs.append({
                    "antennaPort": i + 1,
                    "transmitPowerCdbm": self._tx_power_cdbm,
                    "rfMode": self._rf_mode,
                    "inventorySession": DEFAULT_SESSION,
                    "inventorySearchMode": DEFAULT_SEARCH_MODE,
                    "estimatedTagPopulation": DEFAULT_TAG_POPULATION,
                })

        config = {"antennaConfigs": antenna_configs}

        # Start stream thread first
        self._stream_running = True
        self._stream_thread = threading.Thread(target=self._stream_worker, daemon=True)
        self._stream_thread.start()

        try:
            self._post("/api/v1/profiles/inventory/start", data=config)
            logger.info("Inventory started")
            return True
        except requests.HTTPError as e:
            body = e.response.text if e.response is not None else "N/A"
            logger.error(f"Failed to start inventory: {e} - {body}")
            self._stream_running = False
            return False

    def stop(self) -> bool:
        """Stop inventory and stream."""
        self._stream_running = False

        if not self._connected:
            return True

        try:
            self._post("/api/v1/profiles/stop")
            logger.info("Inventory stopped")
            return True
        except Exception as e:
            logger.error(f"Failed to stop inventory: {e}")
            return False

    # ── Stream worker ─────────────────────────

    def _stream_worker(self):
        url = f"{self._base_url}/api/v1/data/stream"
        try:
            self._stream_response = self._session.get(url, stream=True, timeout=None)
            for line in self._stream_response.iter_lines(decode_unicode=True):
                if not self._stream_running:
                    break
                if not line:
                    continue
                try:
                    data = json.loads(line)
                    if "tagInventoryEvent" in data:
                        tag_event = data["tagInventoryEvent"]
                        epc_raw = tag_event.get("epc", "")
                        try:
                            epc = base64.b64decode(epc_raw).hex().upper()
                        except Exception:
                            epc = epc_raw
                        report = ImpinjIotTagReport(
                            epc=epc,
                            antenna_port=tag_event.get("antennaPort", 0),
                            peak_rssi_cdbm=tag_event.get("peakRssiCdbm", 0),
                            frequency=tag_event.get("frequency", 0),
                            transmit_power_cdbm=tag_event.get("transmitPowerCdbm", 0),
                        )
                        if self._notification_callback:
                            self._notification_callback(report)
                except json.JSONDecodeError:
                    logger.debug(f"Non-JSON event: {line[:100]}")
                except Exception as e:
                    logger.error(f"Error processing stream event: {e}")
        except Exception as e:
            if self._stream_running:
                logger.error(f"Stream disconnected: {e}")
        finally:
            self._stream_running = False

    # ── REST helpers ──────────────────────────

    def _get(self, path: str) -> dict:
        resp = self._session.get(f"{self._base_url}{path}", timeout=10)
        resp.raise_for_status()
        return resp.json()

    def _get_or_none(self, path: str) -> Optional[dict]:
        try:
            return self._get(path)
        except Exception:
            return None

    def _post(self, path: str, data: dict = None) -> requests.Response:
        resp = self._session.post(f"{self._base_url}{path}", json=data, timeout=10)
        resp.raise_for_status()
        return resp

    def _put(self, path: str, data: dict) -> requests.Response:
        resp = self._session.put(f"{self._base_url}{path}", json=data, timeout=10)
        resp.raise_for_status()
        return resp
