#!/usr/bin/env python3
"""Cellular backup module — LTE modem for out-of-band C2.

Manages an LTE modem (SIM7600/EC25) connected via USB through the 13-pin
header on the Orange Pi Zero 3.  Provides a completely separate network path
from the wired/WiFi connection, enabling out-of-band command and control.

OPi Zero 3+ only — skips on pi_zero tier (no USB bandwidth for modem).
"""

import logging
import os
import re
import subprocess
import time
from typing import Optional

try:
    import serial
except ImportError:
    serial = None

from modules.base import BaseModule

logger = logging.getLogger(__name__)

# Default serial device for modem AT commands
DEFAULT_MODEM_DEVICE = "/dev/ttyUSB2"
DEFAULT_BAUD_RATE = 115200
AT_TIMEOUT = 5  # seconds for AT command responses


class CellularBackup(BaseModule):
    """LTE modem management for out-of-band C2."""

    name = "cellular_backup"
    module_type = "connectivity"
    priority = -50
    requires_root = True

    def __init__(self, bus, state, config, engine=None):
        super().__init__(bus, state, config, engine)
        self._serial: Optional[serial.Serial] = None
        self._modem_device: Optional[str] = None
        self._apn: Optional[str] = None
        self._interface: Optional[str] = None
        self._connected = False
        self._modem_model: Optional[str] = None

    # ------------------------------------------------------------------
    # BaseModule interface
    # ------------------------------------------------------------------

    def start(self) -> None:
        if self._running:
            return

        if serial is None:
            logger.error("pyserial not installed — cellular backup unavailable")
            self.state.set_module_status(self.name, "error",
                                         extra={"reason": "pyserial_missing"})
            return

        # Check hardware tier
        device_tier = self.config.get("device", {}).get("tier", "")
        if device_tier == "pi_zero":
            logger.info("Cellular module skipped — pi_zero tier has no USB bandwidth")
            self.state.set_module_status(self.name, "skipped",
                                         extra={"reason": "pi_zero_tier"})
            return

        cell_cfg = self.config.get("connectivity", {}).get("cellular", {})
        if not cell_cfg:
            logger.error("No cellular config in bigbrother.yaml")
            self.state.set_module_status(self.name, "error")
            return

        self._modem_device = cell_cfg.get("device", DEFAULT_MODEM_DEVICE)
        self._apn = cell_cfg.get("apn")
        self._interface = cell_cfg.get("interface", "wwan0")

        if not self._apn:
            logger.error("Cellular APN not configured")
            self.state.set_module_status(self.name, "error")
            return

        # Open serial connection to modem
        if not self._open_serial():
            self.state.set_module_status(self.name, "error")
            return

        # Initialize modem
        if not self._init_modem():
            self._close_serial()
            self.state.set_module_status(self.name, "error")
            return

        # Enable data connection
        if not self.enable():
            logger.warning("Failed to enable data — modem initialized but no data yet")

        self._running = True
        self._pid = os.getpid()
        self._start_time = time.time()

        self.state.set_module_status(self.name, "running", pid=self._pid,
                                     extra={"modem": self._modem_model,
                                            "apn": self._apn})
        self.bus.emit("CONNECTIVITY_CHANGED",
                      {"module": self.name, "state": "up",
                       "modem": self._modem_model, "apn": self._apn},
                      source_module=self.name)
        logger.info("Cellular backup active — modem=%s, APN=%s",
                     self._modem_model, self._apn)

    def stop(self) -> None:
        if not self._running:
            return

        self.disable()
        self._close_serial()
        self._running = False
        self._connected = False

        self.state.set_module_status(self.name, "stopped")
        self.bus.emit("CONNECTIVITY_CHANGED",
                      {"module": self.name, "state": "down"},
                      source_module=self.name)
        logger.info("Cellular backup stopped")

    def status(self) -> dict:
        signal_info = self.get_signal() if self._running else None
        return {
            "running": self._running,
            "pid": self._pid,
            "uptime": time.time() - self._start_time if self._start_time else 0,
            "connected": self._connected,
            "modem_model": self._modem_model,
            "apn": self._apn,
            "interface": self._interface,
            "signal": signal_info,
            "ip": self.get_ip() if self._connected else None,
        }

    def configure(self, config: dict) -> None:
        self.config.update(config)

    def health_check(self) -> bool:
        if not self._running:
            return False
        # Check modem responds to AT
        resp = self._at_command("AT")
        return resp is not None and "OK" in resp

    # ------------------------------------------------------------------
    # Cellular operations
    # ------------------------------------------------------------------

    def enable(self) -> bool:
        """Configure APN and enable data connection."""
        # Try ModemManager first (mmcli)
        if self._enable_via_mmcli():
            self._connected = True
            return True

        # Fallback: direct AT commands
        if self._enable_via_at():
            self._connected = True
            return True

        logger.error("Failed to enable cellular data via mmcli or AT commands")
        return False

    def disable(self) -> bool:
        """Disable data connection."""
        # Try mmcli disconnect
        try:
            modem_index = self._get_modem_index()
            if modem_index is not None:
                subprocess.run(
                    ["mmcli", "-m", str(modem_index), "--simple-disconnect"],
                    capture_output=True, timeout=15,
                )
        except (subprocess.TimeoutExpired, FileNotFoundError):
            pass

        # AT command fallback
        self._at_command("AT+CGACT=0,1")
        self._at_command("AT+CFUN=4")  # Flight mode

        self._connected = False
        return True

    def get_signal(self) -> Optional[dict]:
        """Get signal quality information."""
        result = {}

        # AT+CSQ — signal quality
        resp = self._at_command("AT+CSQ")
        if resp:
            match = re.search(r'\+CSQ:\s*(\d+),(\d+)', resp)
            if match:
                rssi_raw = int(match.group(1))
                ber = int(match.group(2))
                # Convert to dBm: -113 + (2 * rssi_raw)
                if 0 <= rssi_raw <= 31:
                    result["rssi_dbm"] = -113 + (2 * rssi_raw)
                result["ber"] = ber

        # AT+COPS? — operator
        resp = self._at_command("AT+COPS?")
        if resp:
            match = re.search(r'\+COPS:\s*\d+,\d+,"([^"]+)"', resp)
            if match:
                result["operator"] = match.group(1)

        # AT+CREG? — registration status
        resp = self._at_command("AT+CREG?")
        if resp:
            match = re.search(r'\+CREG:\s*\d+,(\d+)', resp)
            if match:
                reg_status = int(match.group(1))
                result["registered"] = reg_status in (1, 5)  # 1=home, 5=roaming

        return result if result else None

    def get_ip(self) -> Optional[str]:
        """Get the IP address assigned to the cellular interface."""
        # Check interface
        try:
            result = subprocess.run(
                ["ip", "-4", "addr", "show", self._interface],
                capture_output=True, text=True, timeout=5,
            )
            if result.returncode == 0:
                match = re.search(r'inet\s+(\d+\.\d+\.\d+\.\d+)', result.stdout)
                if match:
                    return match.group(1)
        except (subprocess.TimeoutExpired, FileNotFoundError):
            pass

        # Fallback: AT command
        resp = self._at_command("AT+CGPADDR=1")
        if resp:
            match = re.search(r'\+CGPADDR:\s*\d+,"?(\d+\.\d+\.\d+\.\d+)"?', resp)
            if match:
                return match.group(1)

        return None

    def send_sms(self, number: str, msg: str) -> bool:
        """Send an SMS message via the modem."""
        # Set text mode
        resp = self._at_command("AT+CMGF=1")
        if not resp or "OK" not in resp:
            logger.error("Failed to set SMS text mode")
            return False

        # Send message
        self._at_command(f'AT+CMGS="{number}"', expect_prompt=True)
        time.sleep(0.5)

        if self._serial and self._serial.is_open:
            try:
                self._serial.write(msg.encode() + b"\x1a")  # Ctrl+Z to send
                time.sleep(3.0)
                resp = self._serial.read(self._serial.in_waiting).decode(errors='replace')
                if "+CMGS:" in resp:
                    logger.info("SMS sent to %s", number)
                    return True
            except (serial.SerialException, OSError) as exc:
                logger.error("SMS send error: %s", exc)

        return False

    # ------------------------------------------------------------------
    # ModemManager (mmcli) path
    # ------------------------------------------------------------------

    def _enable_via_mmcli(self) -> bool:
        """Enable cellular data via ModemManager."""
        modem_index = self._get_modem_index()
        if modem_index is None:
            return False

        try:
            # Enable modem
            subprocess.run(
                ["mmcli", "-m", str(modem_index), "--enable"],
                check=True, capture_output=True, timeout=15,
            )

            # Connect with APN
            subprocess.run(
                ["mmcli", "-m", str(modem_index), "--simple-connect",
                 f"apn={self._apn}"],
                check=True, capture_output=True, timeout=30,
            )

            # Wait for interface to come up
            time.sleep(3.0)

            # Configure interface
            bearer_result = subprocess.run(
                ["mmcli", "-m", str(modem_index), "--list-bearers"],
                capture_output=True, text=True, timeout=10,
            )

            logger.info("Cellular data enabled via mmcli (modem %d)", modem_index)
            return True

        except subprocess.CalledProcessError as exc:
            logger.debug("mmcli connect failed: %s",
                        exc.stderr.decode(errors='replace') if exc.stderr else "")
            return False
        except (subprocess.TimeoutExpired, FileNotFoundError):
            return False

    def _get_modem_index(self) -> Optional[int]:
        """Get the ModemManager modem index."""
        try:
            result = subprocess.run(
                ["mmcli", "-L"],
                capture_output=True, text=True, timeout=10,
            )
            if result.returncode == 0:
                match = re.search(r'/Modem/(\d+)', result.stdout)
                if match:
                    return int(match.group(1))
        except (subprocess.TimeoutExpired, FileNotFoundError):
            pass
        return None

    # ------------------------------------------------------------------
    # AT command path
    # ------------------------------------------------------------------

    def _enable_via_at(self) -> bool:
        """Enable cellular data via direct AT commands."""
        # Exit flight mode
        resp = self._at_command("AT+CFUN=1")
        if not resp:
            return False

        time.sleep(2.0)

        # Set APN
        resp = self._at_command(f'AT+CGDCONT=1,"IP","{self._apn}"')
        if not resp or "OK" not in resp:
            logger.error("Failed to set APN")
            return False

        # Activate PDP context
        resp = self._at_command("AT+CGACT=1,1")
        if not resp or "OK" not in resp:
            logger.error("Failed to activate PDP context")
            return False

        # Wait for data connection
        time.sleep(3.0)

        # Check registration
        signal = self.get_signal()
        if signal and signal.get("registered"):
            logger.info("Cellular data enabled via AT commands")
            return True

        logger.warning("Modem registered but data connection uncertain")
        return True  # Modem is up; data may need a moment

    # ------------------------------------------------------------------
    # Serial / AT helpers
    # ------------------------------------------------------------------

    def _open_serial(self) -> bool:
        """Open serial connection to modem."""
        try:
            self._serial = serial.Serial(
                port=self._modem_device,
                baudrate=DEFAULT_BAUD_RATE,
                timeout=AT_TIMEOUT,
                write_timeout=AT_TIMEOUT,
            )
            logger.debug("Opened serial port %s", self._modem_device)
            return True
        except serial.SerialException as exc:
            logger.error("Cannot open modem serial port %s: %s",
                        self._modem_device, exc)
            return False

    def _close_serial(self) -> None:
        """Close serial connection."""
        if self._serial and self._serial.is_open:
            try:
                self._serial.close()
            except Exception:
                pass
        self._serial = None

    def _init_modem(self) -> bool:
        """Initialize modem with basic AT commands."""
        # Basic AT test
        resp = self._at_command("AT")
        if not resp or "OK" not in resp:
            logger.error("Modem not responding to AT commands on %s", self._modem_device)
            return False

        # Disable echo
        self._at_command("ATE0")

        # Get modem model
        resp = self._at_command("AT+CGMM")
        if resp:
            lines = [l.strip() for l in resp.splitlines() if l.strip() and l.strip() != "OK"]
            if lines:
                self._modem_model = lines[0]

        # Check SIM
        resp = self._at_command("AT+CPIN?")
        if not resp or "READY" not in resp:
            logger.error("SIM not ready: %s", resp)
            return False

        logger.info("Modem initialized: %s, SIM ready", self._modem_model)
        return True

    def _at_command(self, cmd: str, expect_prompt: bool = False) -> Optional[str]:
        """Send an AT command and return the response."""
        if not self._serial or not self._serial.is_open:
            return None

        try:
            # Flush input
            self._serial.reset_input_buffer()

            # Send command
            self._serial.write((cmd + "\r\n").encode())

            if expect_prompt:
                # Wait for > prompt
                time.sleep(0.5)
                data = self._serial.read(self._serial.in_waiting)
                return data.decode(errors='replace')

            # Read response
            time.sleep(0.5)
            response = b""
            deadline = time.time() + AT_TIMEOUT
            while time.time() < deadline:
                if self._serial.in_waiting:
                    response += self._serial.read(self._serial.in_waiting)
                    # Check for final result codes
                    decoded = response.decode(errors='replace')
                    if any(code in decoded for code in ("OK", "ERROR", "+CME ERROR", "+CMS ERROR")):
                        return decoded
                time.sleep(0.1)

            return response.decode(errors='replace') if response else None

        except (serial.SerialException, OSError) as exc:
            logger.debug("AT command error: %s", exc)
            return None
