"""Scanner engine: packet capture, channel hopping, AP management."""

import logging
import subprocess
import threading
import time
from datetime import datetime

from scapy.all import sniff
from scapy.layers.dot11 import Dot11

from ap_scanner.models import AccessPoint
from ap_scanner.parser import parse_packet, parse_probe_request

logger = logging.getLogger("ap_scanner.scanner")

# Memory bounds for tracking maps
MAX_CLIENTS = 10000
MAX_PROBE_SSIDS = 5000
_CLEANUP_INTERVAL = 60  # seconds


class ChannelHopper:
    """Hops through WiFi channels in a background thread."""

    def __init__(
        self,
        interface: str,
        channels: list[int],
        dwell_time: float = 0.25,
    ):
        self.interface = interface
        self.channels = channels
        self.dwell_time = dwell_time
        self._stop_event = threading.Event()
        self._thread: threading.Thread | None = None
        self._current_channel: int = 0
        self._lock = threading.Lock()

    @property
    def current_channel(self) -> int:
        with self._lock:
            return self._current_channel

    def start(self) -> None:
        self._stop_event.clear()
        self._thread = threading.Thread(target=self._hop_loop, daemon=True)
        self._thread.start()

    def stop(self) -> None:
        self._stop_event.set()
        if self._thread and self._thread.is_alive():
            self._thread.join(timeout=3)
            if self._thread.is_alive():
                logger.warning("Channel hopper thread did not stop within timeout")

    def _hop_loop(self) -> None:
        while not self._stop_event.is_set():
            for ch in self.channels:
                if self._stop_event.is_set():
                    break
                try:
                    subprocess.run(
                        ["iw", "dev", self.interface, "set", "channel", str(ch)],
                        capture_output=True,
                        timeout=2,
                    )
                    with self._lock:
                        self._current_channel = ch
                except (subprocess.TimeoutExpired, FileNotFoundError):
                    pass
                self._stop_event.wait(self.dwell_time)


class ScannerEngine:
    """Orchestrates packet capture and channel hopping to discover APs."""

    def __init__(
        self,
        interface: str,
        channels: list[int],
        on_update: "callable | None" = None,
    ):
        self.interface = interface
        self.channels = channels
        self.on_update = on_update  # callback(ap_dict) called after each new/updated AP

        self._aps: dict[str, AccessPoint] = {}
        # client_mac -> set of BSSIDs they communicate with
        self._client_ap_map: dict[str, set[str]] = {}
        # SSID -> set of client MACs that probed for it
        self._probe_ssids: dict[str, set[str]] = {}
        self._lock = threading.Lock()
        self._stop_event = threading.Event()
        self._hopper = ChannelHopper(interface, channels)
        self._capture_thread: threading.Thread | None = None
        self._last_cleanup = time.monotonic()

    @property
    def access_points(self) -> dict[str, AccessPoint]:
        """Return a copy of the current AP dictionary."""
        with self._lock:
            return dict(self._aps)

    @property
    def current_channel(self) -> int:
        return self._hopper.current_channel

    def start(self) -> None:
        """Start scanning: channel hopper + packet capture."""
        self._stop_event.clear()
        self._hopper.start()
        self._capture_thread = threading.Thread(target=self._capture_loop, daemon=True)
        self._capture_thread.start()

    def stop(self) -> None:
        """Stop scanning and clean up threads."""
        self._stop_event.set()
        # Suppress Scapy socket warnings during intentional shutdown
        logging.getLogger("scapy.runtime").setLevel(logging.CRITICAL)
        logging.getLogger("scapy").setLevel(logging.CRITICAL)
        self._hopper.stop()
        if self._capture_thread and self._capture_thread.is_alive():
            self._capture_thread.join(timeout=5)
            if self._capture_thread.is_alive():
                logger.warning("Capture thread did not stop within timeout")

    def clear(self) -> None:
        """Clear all discovered APs."""
        with self._lock:
            self._aps.clear()

    def _capture_loop(self) -> None:
        """Run Scapy sniff in the capture thread.

        Uses a short timeout so the loop re-checks stop_event even when
        no packets arrive (stop_filter only fires on packet receipt).
        """
        try:
            while not self._stop_event.is_set():
                sniff(
                    iface=self.interface,
                    prn=self._on_packet,
                    store=False,
                    stop_filter=lambda _: self._stop_event.is_set(),
                    timeout=2,
                    monitor=True,
                )
        except OSError as e:
            if not self._stop_event.is_set():
                logger.error("Capture error on %s: %s", self.interface, e)

    def _maybe_cleanup(self) -> None:
        """Periodically evict old entries from tracking maps to bound memory."""
        now = time.monotonic()
        if now - self._last_cleanup < _CLEANUP_INTERVAL:
            return
        self._last_cleanup = now
        with self._lock:
            if len(self._client_ap_map) > MAX_CLIENTS:
                keys = list(self._client_ap_map.keys())
                for k in keys[: len(keys) // 2]:
                    del self._client_ap_map[k]
            if len(self._probe_ssids) > MAX_PROBE_SSIDS:
                keys = list(self._probe_ssids.keys())
                for k in keys[: len(keys) // 2]:
                    del self._probe_ssids[k]

    def _on_packet(self, pkt) -> None:
        """Callback for each captured packet."""
        self._maybe_cleanup()

        # Track client-AP associations from any Data/Auth frame
        self._track_client_ap(pkt)

        # Try to resolve hidden SSIDs from Probe Requests
        probe = parse_probe_request(pkt)
        if probe:
            ssid, client_mac = probe
            with self._lock:
                self._probe_ssids.setdefault(ssid, set()).add(client_mac)
                self._try_resolve_hidden(ssid, client_mac)

        ap = parse_packet(pkt)
        if ap is None:
            return

        with self._lock:
            existing = self._aps.get(ap.bssid)
            if existing:
                # Update existing AP
                existing.last_seen = datetime.now()
                existing.beacon_count += 1
                if ap.rssi is not None:
                    existing.rssi = ap.rssi
                # Update SSID if previously hidden and now revealed
                if existing.is_hidden and not ap.is_hidden:
                    existing.ssid = ap.ssid
                # Update security if we have better data
                if ap.encryption != "OPN" and existing.encryption == "OPN":
                    existing.encryption = ap.encryption
                    existing.cipher = ap.cipher
                    existing.auth = ap.auth
                if ap.wpa2 and not existing.wpa2:
                    existing.wpa2 = ap.wpa2
                if ap.wpa3 and not existing.wpa3:
                    existing.wpa3 = ap.wpa3
                if ap.wpa and not existing.wpa:
                    existing.wpa = ap.wpa
                if ap.wep and not existing.wep:
                    existing.wep = ap.wep
                # Update standards, width, vendor, wps if better data
                if ap.wifi_standard and not existing.wifi_standard:
                    existing.wifi_standard = ap.wifi_standard
                if ap.width > existing.width:
                    existing.width = ap.width
                    existing.channel_desc = ap.channel_desc
                if ap.vendor and not existing.vendor:
                    existing.vendor = ap.vendor
                if ap.wps and not existing.wps:
                    existing.wps = ap.wps
            else:
                # New AP
                self._aps[ap.bssid] = ap
                # Check if any probe request SSIDs match this new hidden AP
                if ap.is_hidden:
                    self._try_resolve_hidden_for_ap(ap.bssid)

    def _track_client_ap(self, pkt) -> None:
        """Track which clients are communicating with which APs.

        From Data frames, addr1=BSSID(to-AP) or addr2=BSSID(from-AP).
        This lets us correlate Probe Request SSIDs to hidden APs.
        """
        if not pkt.haslayer(Dot11):
            return

        dot11 = pkt[Dot11]
        frame_type = dot11.type

        # Data frames (type=2) or Auth/Assoc frames
        if frame_type not in (0, 2):
            return

        addr1 = dot11.addr1  # receiver
        addr2 = dot11.addr2  # transmitter
        addr3 = dot11.addr3  # BSSID (in most cases)

        if not addr2 or not addr3:
            return

        addr2 = addr2.upper()
        addr3 = addr3.upper()

        # To-DS: addr1=BSSID, addr2=client, addr3=BSSID
        # From-DS: addr1=client, addr2=BSSID, addr3=source
        # Figure out client vs AP
        with self._lock:
            if addr3 in self._aps:
                # addr3 is the BSSID, addr2 is the client
                if addr2 != addr3:
                    self._client_ap_map.setdefault(addr2, set()).add(addr3)
            elif addr2 in self._aps:
                # addr2 is the BSSID
                if addr1:
                    client = addr1.upper()
                    if client != addr2:
                        self._client_ap_map.setdefault(client, set()).add(addr2)

    def _try_resolve_hidden(self, ssid: str, client_mac: str) -> None:
        """Try to match a probe request SSID to a hidden AP via client association.

        If this client is associated with a hidden AP, the probed SSID is likely
        the hidden AP's real name.
        """
        bssids = self._client_ap_map.get(client_mac, set())
        for bssid in bssids:
            ap = self._aps.get(bssid)
            if ap and ap.is_hidden:
                ap.ssid = ssid

    def _try_resolve_hidden_for_ap(self, bssid: str) -> None:
        """When a new hidden AP appears, check if any known clients can reveal its SSID."""
        # Find clients associated with this BSSID
        for client_mac, associated_bssids in self._client_ap_map.items():
            if bssid in associated_bssids:
                # Check if this client has probed for any SSIDs
                for ssid, probing_clients in self._probe_ssids.items():
                    if client_mac in probing_clients:
                        ap = self._aps.get(bssid)
                        if ap and ap.is_hidden:
                            ap.ssid = ssid
                            return
