#!/usr/bin/env python3
"""Packet capture bus — single AF_PACKET socket, demuxed to per-module queues.

One privileged capture process reads raw frames.  Modules subscribe with a
BPF filter expression and receive matching packets via per-module queues.
Backpressure: if a module's queue is full, oldest packets are dropped from
that queue only.
"""

import os
import time
import struct
import socket
import logging
import threading
import multiprocessing
from typing import Optional, Callable

logger = logging.getLogger(__name__)

# ETH_P_ALL for capturing all protocols
ETH_P_ALL = 0x0003


class SubscriberQueue:
    """Per-module packet queue with configurable depth and drop-oldest backpressure."""

    __slots__ = ("name", "queue", "bpf_filter", "dropped", "delivered")

    def __init__(self, name: str, bpf_filter: str = "", maxsize: int = 5000):
        self.name = name
        self.queue: multiprocessing.Queue = multiprocessing.Queue(maxsize=maxsize)
        self.bpf_filter = bpf_filter
        self.dropped = 0
        self.delivered = 0

    def put(self, packet: bytes, timestamp: float) -> None:
        """Push a packet, dropping oldest if full."""
        msg = (timestamp, packet)
        try:
            self.queue.put_nowait(msg)
            self.delivered += 1
        except Exception:
            # Queue full — drop oldest and retry
            try:
                self.queue.get_nowait()
            except Exception:
                pass
            try:
                self.queue.put_nowait(msg)
                self.delivered += 1
            except Exception:
                pass
            self.dropped += 1

    def get(self, timeout: float = 1.0) -> Optional[tuple]:
        """Get (timestamp, packet) from queue. Returns None on timeout."""
        try:
            return self.queue.get(timeout=timeout)
        except Exception:
            return None


class CaptureBus:
    """Packet demultiplexer.

    Opens a single AF_PACKET/SOCK_RAW socket on the specified interface,
    reads raw Ethernet frames, and distributes them to subscriber queues.

    BPF filter compilation uses the kernel's SO_ATTACH_FILTER.  If a
    subscriber's filter cannot be compiled at the kernel level (requires
    ctypes/tcpdump helper), the filter is applied in userspace as a
    fallback (basic port/protocol matching).
    """

    def __init__(self, interface: str = "eth0"):
        self.interface = interface
        self._subscribers: list[SubscriberQueue] = []
        self._lock = threading.Lock()
        self._capture_thread: Optional[threading.Thread] = None
        self._running = False
        self._sock: Optional[socket.socket] = None
        self._total_packets = 0
        self._compiled_filters: dict[str, Callable] = {}

    # ------------------------------------------------------------------
    # Lifecycle
    # ------------------------------------------------------------------

    def start(self) -> None:
        """Start the capture thread (requires root/CAP_NET_RAW)."""
        if self._running:
            return
        self._running = True
        self._sock = self._open_socket()
        self._capture_thread = threading.Thread(
            target=self._capture_loop, daemon=True, name="sensor-capture"
        )
        self._capture_thread.start()
        logger.info("CaptureBus started on %s", self.interface)

    def stop(self) -> None:
        """Stop capturing."""
        self._running = False
        if self._capture_thread and self._capture_thread.is_alive():
            self._capture_thread.join(timeout=3.0)
        if self._sock:
            self._sock.close()
            self._sock = None
        logger.info("CaptureBus stopped (total_packets=%d)", self._total_packets)

    # ------------------------------------------------------------------
    # Subscriptions
    # ------------------------------------------------------------------

    def subscribe(self, name: str, bpf_filter: str = "",
                  queue_depth: int = 5000) -> SubscriberQueue:
        """Register a module subscriber with a BPF filter expression.

        Args:
            name: Subscriber/module name.
            bpf_filter: tcpdump-style BPF filter (e.g. "udp port 53").
            queue_depth: Max packets buffered for this subscriber.

        Returns:
            SubscriberQueue that the module reads from.
        """
        sq = SubscriberQueue(name=name, bpf_filter=bpf_filter, maxsize=queue_depth)
        if bpf_filter:
            self._compiled_filters[name] = self._compile_filter(bpf_filter)
        with self._lock:
            self._subscribers.append(sq)
        logger.info("Subscriber %s registered (filter=%r, depth=%d)",
                     name, bpf_filter, queue_depth)
        return sq

    def unsubscribe(self, name: str) -> None:
        """Remove a subscriber by name."""
        with self._lock:
            self._subscribers = [s for s in self._subscribers if s.name != name]
            self._compiled_filters.pop(name, None)
        logger.info("Subscriber %s removed", name)

    # ------------------------------------------------------------------
    # Socket
    # ------------------------------------------------------------------

    def _open_socket(self) -> socket.socket:
        """Open a raw AF_PACKET socket bound to the interface."""
        sock = socket.socket(socket.AF_PACKET, socket.SOCK_RAW,
                             socket.htons(ETH_P_ALL))
        sock.bind((self.interface, 0))
        sock.settimeout(1.0)
        # Increase receive buffer
        try:
            sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 4 * 1024 * 1024)
        except OSError:
            pass
        return sock

    # ------------------------------------------------------------------
    # Capture loop
    # ------------------------------------------------------------------

    def _capture_loop(self) -> None:
        """Read frames and dispatch to subscribers."""
        while self._running:
            try:
                raw_packet = self._sock.recv(65535)
            except socket.timeout:
                continue
            except OSError as e:
                if not self._running:
                    break
                logger.warning("Socket read error: %s — reconnecting in 3s", e)
                try:
                    self._sock.close()
                except Exception:
                    pass
                time.sleep(3)
                try:
                    self._sock = self._open_socket()
                    logger.info("CaptureBus reconnected on %s", self.interface)
                except OSError as e2:
                    logger.error("CaptureBus reconnect failed: %s", e2)
                    time.sleep(10)
                continue

            ts = time.time()
            self._total_packets += 1

            with self._lock:
                subscribers = list(self._subscribers)

            for sq in subscribers:
                if sq.bpf_filter:
                    matcher = self._compiled_filters.get(sq.name)
                    if matcher and not matcher(raw_packet):
                        continue
                sq.put(raw_packet, ts)

    # ------------------------------------------------------------------
    # Filter compilation (userspace fallback)
    # ------------------------------------------------------------------

    @staticmethod
    def _compile_filter(bpf_expr: str) -> Callable:
        """Compile a BPF filter expression into a Python matcher function.

        This is a lightweight userspace implementation for common patterns.
        For full BPF support, use tcpdump to compile the filter and attach
        via SO_ATTACH_FILTER (not implemented here to avoid ctypes dep).
        """
        expr = bpf_expr.strip().lower()

        # "udp port N" / "tcp port N"
        if "port" in expr:
            parts = expr.split()
            proto = None
            port = None
            for i, p in enumerate(parts):
                if p in ("tcp", "udp"):
                    proto = p
                if p == "port" and i + 1 < len(parts):
                    try:
                        port = int(parts[i + 1])
                    except ValueError:
                        pass

            if port is not None:
                return _make_port_matcher(proto, port)

        # "ether proto N" (e.g., ARP = 0x0806)
        if "ether proto" in expr:
            parts = expr.split()
            idx = parts.index("proto") + 1
            if idx < len(parts):
                try:
                    etype = int(parts[idx], 0)
                except ValueError:
                    etype = None
                if etype is not None:
                    return lambda pkt, et=etype: (len(pkt) >= 14 and
                                                  struct.unpack("!H", pkt[12:14])[0] == et)

        # Fallback: match everything
        logger.warning("Could not compile BPF filter %r — matching all packets", bpf_expr)
        return lambda pkt: True

    # ------------------------------------------------------------------
    # Stats
    # ------------------------------------------------------------------

    @property
    def total_packets(self) -> int:
        return self._total_packets

    def stats(self) -> dict:
        """Return capture and per-subscriber statistics."""
        with self._lock:
            subs = list(self._subscribers)
        return {
            "interface": self.interface,
            "total_packets": self._total_packets,
            "running": self._running,
            "subscribers": {
                s.name: {
                    "delivered": s.delivered,
                    "dropped": s.dropped,
                    "filter": s.bpf_filter,
                }
                for s in subs
            },
        }


def _make_port_matcher(proto: Optional[str], port: int) -> Callable:
    """Create a matcher that checks TCP/UDP src or dst port."""
    TCP_PROTO = 6
    UDP_PROTO = 17

    def matcher(pkt: bytes) -> bool:
        if len(pkt) < 34:  # 14 eth + 20 ip minimum
            return False
        # Check EtherType = IPv4 (0x0800)
        if struct.unpack("!H", pkt[12:14])[0] != 0x0800:
            return False
        ip_proto = pkt[23]
        if proto == "tcp" and ip_proto != TCP_PROTO:
            return False
        if proto == "udp" and ip_proto != UDP_PROTO:
            return False
        if proto is None and ip_proto not in (TCP_PROTO, UDP_PROTO):
            return False
        # IP header length
        ihl = (pkt[14] & 0x0F) * 4
        transport_offset = 14 + ihl
        if len(pkt) < transport_offset + 4:
            return False
        src_port, dst_port = struct.unpack("!HH", pkt[transport_offset:transport_offset + 4])
        return src_port == port or dst_port == port

    return matcher
