# -----------------------
# File: components/i2c/i2c_logic.py
#
# Purpose
# -------
# Unified I²C logic that supports:
#   1) One-shot command send under an exclusive "capture window"
#   2) A continuous background logger (thread) that politely backs off during capture
#
# Key Ideas
# ---------
# - We reuse the global coordinator (components.scheduler.coordinator) to mark an
#   exclusive "capture window" while a one-shot I²C command is in flight.
#   During this window, other sends are rejected, and the logger throttles
#   output to keep the console readable and the UI snappy — same as UART.
# - The logger runs in its own daemon thread and performs periodic READs from a
#   chosen register/length at a configurable interval.
# - All SMBus interactions are serialized by a single RLock so disconnect/close
#   cannot race against in-flight reads/writes (prevents SIGSEGV).
#
# Tuning / Knobs
# --------------
# - self._logger_sleep_s: how often the logger loop yields when idle/throttled
#   (default ~50 Hz). Increase for less CPU, decrease for more responsiveness.
# - _throttle_ns (inside _logger_loop): during capture, the logger emits at most
#   once every 50 ms. Increase to further reduce noise.
# - The default capture window in send_command() is ~300 ms; adjust if needed.
#
# Logging Format
# --------------
# - TX (read):   "➡️ | addr 0xAA | reg 0xRR | action READ  | len N"
# - RX (n bytes): "⬅️ | addr 0xAA | reg 0xRR | data [0xV0, 0xV1, ...]"
# - TX (write1): "➡️ | addr 0xAA | reg 0xRR | action WRITE | value 0xVV"
# - TX (writeN): "➡️ | addr 0xAA | reg 0xRR | action WRITE | bytes [0x.., ...]"
# - OK (write):  "✅ | addr 0xAA | reg 0xRR | status OK"
# - Errors:      "❌ | addr 0xAA | reg 0xRR | error <text>"
#
# ------------------------------------------------------------------------------

from __future__ import annotations

import os
import time
import threading
from enum import Enum, auto
from typing import List, Optional, Union
import statistics
import smbus2
import config.config as config

from components.console.console_registry import log_main_console
from components.data.db import get_i2c_commands

# Shared coordinator + timebase (UART-style exclusivity/throttling)
from components.scheduler.coordinator import (
    claim_i2c_capture,
    release_i2c_capture,
    i2c_capture_active,
)
from components.scheduler.timebase import now_ns


# ---------------------------
# Helpers (parsing and formatting)
# ---------------------------


def _coerce_int(val: Union[str, int], base: int = 10) -> int:
    """Turn '1', '0x40', '64' or 1 into int. Raises ValueError on failure."""
    if isinstance(val, int):
        return val
    s = str(val).strip().lower()
    if s.startswith("0x"):
        return int(s, 16)
    return int(s, base)


def _parse_hex_bytes_list(s: str) -> List[int]:
    """
    Parse strings like '0x01,0x02,0x03' or '01 02 03' or '01, 02, 03' into a list of ints.
    """
    if not s:
        return []
    raw = [tok.strip() for tok in s.replace(",", " ").split()]
    out = []
    for t in raw:
        if not t:
            continue
        # accept '0x..' or plain hex/dec
        out.append(int(t, 0))
    return out


# ---------------------------
# State Machine
# ---------------------------


class I2CState(Enum):
    DISCONNECTED = auto()
    CONNECTED_IDLE = auto()
    LOGGING = auto()
    CAPTURE_ACTIVE = auto()


# ---------------------------
# Main logic
# ---------------------------


class I2CLogic:
    """
    Single class for:
      - Connecting / disconnecting an I²C bus
      - Sending one-shot register read/write commands under an exclusive capture window
      - Running a continuous logger on the same bus (polls reg/length at an interval)

    Threading model
    ---------------
    - The logger runs in a background daemon thread started by start_logger().
    - All SMBus calls (read/write/close) are serialized by self._bus_lock (RLock).
    - The command send path (send_command) runs in the UI/main thread, but it
      briefly "claims" exclusivity via the shared coordinator so:
        * Other sends are rejected during capture,
        * The logger throttles its output during the capture window.
    """

    def __init__(self):
        # Connection
        self.bus: Optional[smbus2.SMBus] = None
        self.bus_id: int = 1  # Raspberry Pi default /dev/i2c-1

        # Threading & safety
        self._bus_lock = threading.RLock()  # serialize ALL SMBus I/O
        self._logger_thread: Optional[threading.Thread] = None
        self._logger_running: bool = False
        self._logger_sleep_s: float = 0.05  # ~20 Hz idle yield
        self._throttle_ns: int = 50_000_000  # 50 ms between emissions during capture

        # Logger configuration
        self._log_addr: Optional[int] = None
        self._log_reg: int = 0
        self._log_len: int = 1
        self._log_interval_s: float = 0.20  # default 200 ms between polls
        self._shutting_down = False  # prevent new work during disconnects
        self._2_bytes_read_counter = (
            0  # to cinfurm how many times we were in the read 2 bytes function
        )
        # State
        self._state = I2CState.DISCONNECTED

        # Logger injection (UI provides console logger)
        self.log = lambda level, msg: None  # default: drop logs until UI injects one

    # Inject the console logger (must be a callable: (type, message) -> None)
    # components/i2c/i2c_logic.py
    def set_logger(self, log_func):
        if callable(log_func):
            self.log = log_func

    # ----- convenience formatters -----
    def _fmt_hex(self, v: int, width: int = 2) -> str:
        return f"0x{v:0{width}X}"

    def _fmt_bytes(self, data) -> str:
        if data is None:
            return "[]"
        return "[" + ", ".join(f"0x{b:02X}" for b in data) + "]"

    def _to_int_any(self, v, default=None):
        try:
            return int(str(v).strip(), 0)  # accepts "0xFE" or "254"
        except Exception:
            return default

    def _parse_hex_list(self, s: str) -> List[int]:
        return _parse_hex_bytes_list(s)

    # ---------------------------
    # State & Channels
    # ---------------------------

    def state(self) -> I2CState:
        return self._state

    def get_channels(self) -> List[str]:
        """List available /dev/i2c-* buses as strings of bus ids."""
        i2c_devices = []
        try:
            for entry in os.listdir("/dev"):
                if entry.startswith("i2c-"):
                    try:
                        bus_id = int(entry.split("-")[1])
                        i2c_devices.append(str(bus_id))
                    except ValueError:
                        continue
        except Exception as e:
            self.log("warn", f"Could not list /dev for i2c buses: {e}")
        return sorted(i2c_devices)

    def scan_bus(self) -> List[str]:
        """
        Scan the currently opened bus for devices.
        Returns like ['0x40', '0x41'].
        """
        found: List[str] = []
        with self._bus_lock:
            if self.bus is None:
                return found
            try:
                for address in range(0x03, 0x78):  # valid 7-bit range
                    try:
                        # write_quick is the classic probe; some devices NACK writes.
                        self.bus.write_quick(address)
                        found.append(f"0x{address:02X}")
                    except OSError:
                        continue
                    except Exception as e:
                        self.log("warn", f"Unexpected at 0x{address:02X}: {e}")
                        continue
                self.log("info", f"Scan complete. Found devices: {found}")
            except Exception as e:
                self.log("error", f"Scan failed: {e}")
        return found

    # ---------------------------
    # Connection lifecycle
    # ---------------------------

    def connect(self, port: Union[int, str]) -> bool:
        """
        Open the I²C bus. 'port' can be an int or string ('1' -> /dev/i2c-1).
        """
        if config.DEBUG_MODE:
            self._state = I2CState.CONNECTED_IDLE
            self.log("info", "DEBUG: simulate I²C connect")
            self.log("success", f"🔗 Connected to I²C bus {port} (DEBUG)")
            return True

        try:
            self.bus_id = _coerce_int(port)
            with self._bus_lock:
                self.bus = smbus2.SMBus(self.bus_id)
            self._state = I2CState.CONNECTED_IDLE
            self.log("success", f"🔗 Connected to I²C bus {self.bus_id}")
            return True
        except FileNotFoundError:
            self.log("error", f"I²C bus {port} not found (no /dev/i2c-{port}).")
        except Exception as e:
            self.log("error", f"❌ Connection failed: {e}")

        self._state = I2CState.DISCONNECTED
        return False

    def disconnect(self) -> bool:
        """
        Stop logger (join) and close the bus cleanly.
        Safe to call even if already disconnected.
        """
        if config.DEBUG_MODE:
            self._state = I2CState.DISCONNECTED
            self.log("info", "DEBUG: simulate I²C disconnect")
            return True

        try:
            # 1) Stop logger first so no thread touches the bus.
            self.stop_logger()

            # 2) Close the bus under the same lock used for I/O.
            with self._bus_lock:
                if self.bus:
                    try:
                        self.bus.close()
                    except Exception:
                        pass
                    self.bus = None

            self._state = I2CState.DISCONNECTED
            self.log("info", f"🔌 Disconnected from I²C bus {self.bus_id}")
            return True
        except Exception as e:
            self.log("error", f"❌ Disconnection error: {e}")
            return False

    # ---------------------------
    # One-shot command + capture window (UART-style)
    # ---------------------------

    def send_command(self, command, device_address=0x40):
        """
        Execute a single I²C action under an exclusive capture window.

        command: either
          - str (raw): "01,02,03" (block write to reg 0x00)
          - dict: {
                'action'/'operation': 'read'|'write',
                'reg'/'register': int|str,
                'length': int (for read),
                'value': int (single-byte write),
                'bytes': '01,02,03' (block write string)
            }
        device_address: int or str ('0x40' accepted). If None, attempt to read
                        from command['address'] if present.
        """
        if self._shutting_down:
            self.log("warning", "⛔ I²C is shutting down; command ignored.")
            return None

        # must be connected in real mode
        if not config.DEBUG_MODE and not self.bus:
            self.log("error", "❌ | bus not connected")
            return None

        # Claim capture (exclusive) — reject if already in use
        deadline_ns = now_ns() + 300_000_000  # ~300 ms default window
        if not claim_i2c_capture(deadline_ns, owner="I2C_CMD"):
            self.log("warning", "⛔ Busy: capture window active. Try again shortly.")
            return None

        prev_state = self._state
        self._state = I2CState.CAPTURE_ACTIVE
        try:
            # Normalize device address
            if device_address is not None:
                addr = self._to_int_any(device_address, default=0x40)
            elif isinstance(command, dict) and "address" in command:
                addr = self._to_int_any(command["address"], default=0x40)
            else:
                self.log("error", "❌ | no device address provided")
                return None

            # RAW string mode (block write to reg 0x00)
            if isinstance(command, str):
                data = self._parse_hex_list(command)
                self.log(
                    "info",
                    f"➡️ | addr {self._fmt_hex(addr)} | reg 0x00 | action WRITE | bytes {self._fmt_bytes(data)}",
                )
                with self._bus_lock:
                    if not config.DEBUG_MODE:
                        self.bus.write_i2c_block_data(addr, 0x00, data)
                self.log(
                    "success", f"✅ | addr {self._fmt_hex(addr)} | reg 0x00 | status OK"
                )
                return True

            # Dict mode
            action = (
                command.get("action") or command.get("operation") or "read"
            ).upper()
            reg = self._to_int_any(
                command.get("reg", command.get("register", 0)), default=0
            )

            if action == "READ":
                length = int(command.get("length", 1))
                self.log(
                    "info",
                    f"➡️ | addr {self._fmt_hex(addr)} | reg {self._fmt_hex(reg)} | action READ  | len {length}",
                )
                with self._bus_lock:
                    if not config.DEBUG_MODE:
                        if length <= 1:
                            data = [self.bus.read_byte_data(addr, reg)]
                        else:
                            data = self.bus.read_i2c_block_data(addr, reg, length)
                    else:
                        # synthetic data in DEBUG
                        data = [0xBE] if length <= 1 else list(range(length))
                self.log(
                    "success",
                    f"⬅️ | addr {self._fmt_hex(addr)} | reg {self._fmt_hex(reg)} | data {self._fmt_bytes(data)}",
                )
                return data if length > 1 else data[0]

            elif action == "WRITE":
                if "bytes" in command and command["bytes"]:
                    data = self._parse_hex_list(command["bytes"])
                    self.log(
                        "info",
                        f"➡️ | addr {self._fmt_hex(addr)} | reg {self._fmt_hex(reg)} | action WRITE | bytes {self._fmt_bytes(data)}",
                    )
                    with self._bus_lock:
                        if not config.DEBUG_MODE:
                            self.bus.write_i2c_block_data(addr, reg, data)
                    self.log(
                        "success",
                        f"✅ | addr {self._fmt_hex(addr)} | reg {self._fmt_hex(reg)} | status OK",
                    )
                    return True
                else:
                    value = self._to_int_any(command.get("value", 0))
                    self.log(
                        "info",
                        f"➡️ | addr {self._fmt_hex(addr)} | reg {self._fmt_hex(reg)} | action WRITE | value {self._fmt_hex(value)}",
                    )
                    with self._bus_lock:
                        if not config.DEBUG_MODE:
                            self.bus.write_byte_data(addr, reg, value)
                    self.log(
                        "success",
                        f"✅ | addr {self._fmt_hex(addr)} | reg {self._fmt_hex(reg)} | status OK",
                    )
                    return True

            else:
                self.log(
                    "warning",
                    f"⚠️ | addr {self._fmt_hex(addr)} | reg {self._fmt_hex(reg)} | unknown action '{action}'",
                )
                return None

        except Exception as e:
            self.log("error", f"❌ | error: {e}")
            return None
        finally:
            release_i2c_capture()
            # restore state (back to LOGGING if logger is on)
            self._state = (
                I2CState.LOGGING if self._logger_running else I2CState.CONNECTED_IDLE
            )

    # ---------------------------
    # Hot-path primitive (no logging, no waits)
    # ---------------------------
    def read_2_bytes(self, addr_7bit: int, reg: int) -> dict:
        """
        Fast, on-demand I²C read of exactly 2 bytes from 'reg' at 7-bit address.
        - Immediate-or-skip capture; no waits, no prints, no retries.
        """
        # must be connected in real (non-debug) mode
        if not config.DEBUG_MODE and self.bus is None:
            return {"status": "ERR_NOT_CONNECTED", "addr": addr_7bit, "reg": reg}

        # Give the coordinator a tiny future window so claim is valid
        deadline_ns = now_ns() + 5_000
        if not claim_i2c_capture(deadline_ns, owner="I2C_PKT"):
            return {"status": "SKIPPED_BUSY", "addr": addr_7bit, "reg": reg}

        try:
            with self._bus_lock:
                data = self.bus.read_i2c_block_data(addr_7bit, reg, 2)
            if not isinstance(data, list) or len(data) != 2:
                return {"status": "ERR", "addr": addr_7bit, "reg": reg}
            return {
                "status": "OK",
                "bytes": [data[0], data[1]],
            }
        except Exception:
            return {"status": "ERR", "addr": addr_7bit, "reg": reg}
        finally:
            release_i2c_capture()

    def measure_zero_raw14(self, duration_ms: int = 200, interval_ms: int = 10):
        """
        Measure zero for the I²C angle sensor.
        Saves the result bytes into config.SESSION_ZERO = [MSB, LSB] (big-endian).
        Returns the median raw14 (int) or None.
        """
        samples = []
        deadline = time.time() + (duration_ms / 1000.0)

        try:
            if self.connect(1):
                self.log("info", "I²C auto-initialized on /dev/i2c-1 to set zero")
                while time.time() < deadline:
                    rep = self.read_2_bytes(
                        0x40, 0xFE
                    )  # dict: {"status":"OK","bytes":[b0,b1]}
                    if isinstance(rep, dict) and rep.get("status") == "OK":
                        by = rep.get("bytes")
                        if isinstance(by, list) and len(by) == 2:
                            raw16 = ((by[0] & 0xFF) << 8) | (by[1] & 0xFF)  # big-endian
                            raw14 = raw16 & 0x3FFF
                            samples.append(raw14)
                    time.sleep(interval_ms / 1000.0)

            if not samples:
                self.log("warn", "I²C zero measurement collected no valid samples")
                return None

            med = int(statistics.median(samples))
            msb = (med >> 8) & 0xFF
            lsb = med & 0xFF

            # Store exactly the two bytes
            config.SESSION_ZERO = [msb, lsb]

            self.log(
                "info", f"I²C zero raw14={med} -> SESSION_ZERO={msb:02X} {lsb:02X}"
            )
            return med

        except Exception as e:
            self.log("error", f"I²C zero measurement exception: {e}")
            return None

    # ---------------------------
    # Continuous Logger (thread) — UART-style
    # ---------------------------

    def start_logger(self, device_address, reg, length, interval_ms: int = 200) -> bool:
        """
        Start the background I²C logger thread.

        - If already running: no-op, returns True.
        - If not connected (and not in DEBUG): returns False.

        Behavior:
        - At each interval, performs a READ of 'length' bytes at 'reg' from 'device_address'.
        - During a capture window, emissions are throttled to at most once every _throttle_ns.
        """
        if self._logger_running:
            self.log("info", "ℹ️ I²C logger already running.")
            return True
        if not config.DEBUG_MODE and not self.bus:
            self.log("error", "⚠️ Cannot start I²C logger: not connected.")
            return False

        addr = self._to_int_any(device_address)
        r = self._to_int_any(reg)
        ln = self._to_int_any(length, default=1)
        if addr is None or r is None or ln is None or ln <= 0:
            self.log("warning", "⚠️ Invalid logger params (addr/reg/length).")
            return False

        self._log_addr = addr
        self._log_reg = r
        self._log_len = ln
        self._log_interval_s = max(0.001, (interval_ms or 200) / 1000.0)

        self._logger_running = True
        self._logger_thread = threading.Thread(
            target=self._logger_loop, name="I2CLogger", daemon=True
        )
        self._logger_thread.start()
        self._state = I2CState.LOGGING
        self.log(
            "success",
            f"🟢 I²C logger started | addr 0x{addr:02X} | reg 0x{r:02X} | len {ln} | interval {interval_ms}ms",
        )
        return True

    def stop_logger(self) -> bool:
        """
        Stop the background I²C logger thread if running.
        Joins with a short timeout so shutdown remains responsive.
        """
        if not self._logger_running:
            return True
        self._logger_running = False
        t = self._logger_thread
        self._logger_thread = None

        # Wait up to ~2s for a clean exit (covers a read that’s mid-call + pacing sleep)
        if t and t.is_alive():
            t.join(timeout=2.0)
            # If still alive, wait a tiny bit more — but don't hang the UI
            if t.is_alive():
                time.sleep(0.05)

        if self.bus:
            self._state = I2CState.CONNECTED_IDLE
        self.log("info", "🔴 I²C logger stopped.")
        return True

    def _logger_loop(self):
        """
        Background poller (daemon thread):
        - DEBUG_MODE: emits synthetic lines periodically so you can test the UI without hardware.
        - Real bus: performs read(s) at ~_log_interval_s cadence.
        - During capture: throttle emissions (drop extras) to one per _throttle_ns.
        """
        addr = self._log_addr
        r = self._log_reg
        ln = self._log_len
        pace = self._log_interval_s

        last_emit_ns = now_ns()
        try:
            while self._logger_running:
                try:
                    # Respect capture throttle (like UART)
                    if self._shutting_down:
                        break  # exit ASAP on shutdown
                    if i2c_capture_active():
                        now = now_ns()
                        if now - last_emit_ns < self._throttle_ns:
                            time.sleep(self._logger_sleep_s)
                            continue
                        last_emit_ns = now

                    # TX line
                    self.log(
                        "info",
                        f"➡️ | addr 0x{addr:02X} | reg 0x{r:02X} | action READ  | len {ln}",
                    )

                    # Perform read under the same bus lock as send_command
                    with self._bus_lock:
                        if not self.bus and not config.DEBUG_MODE:
                            break
                        if not self.bus and not config.DEBUG_MODE:
                            break

                        if config.DEBUG_MODE:
                            data = [0xBE] if ln <= 1 else list(range(ln))
                        else:
                            if ln <= 1:
                                data = [self.bus.read_byte_data(addr, r)]
                            else:
                                data = self.bus.read_i2c_block_data(addr, r, ln)

                    # RX line
                    self.log(
                        "success",
                        f"⬅️ | addr 0x{addr:02X} | reg 0x{r:02X} | data {self._fmt_bytes(data)}",
                    )

                except Exception as e:
                    self.log(
                        "error", f"❌ | addr 0x{addr:02X} | reg 0x{r:02X} | error {e}"
                    )

                # pacing
                time.sleep(pace or self._logger_sleep_s)
        finally:
            self._logger_running = False

    # ---------------------------
    # Commands catalog passthrough
    # ---------------------------

    def get_predefined_commands(self):
        """
        Load predefined I²C commands for your command table.
        The DB returns rows mapped by components.data.db.get_i2c_commands().
        """
        return get_i2c_commands()
