#!/usr/bin/env python3
"""Innocuous MAC profile manager — select and apply a believable device identity.

Queries data/innocuous_macs.db for consumer device profiles (Fire TV, iPhone,
printers, etc.), sets MAC + DHCP hostname + vendor class + TCP stack params to
match the selected device.  The goal: look like something that belongs on every
network.
"""

import json
import logging
import os
import random
import socket
import sqlite3
import time
from pathlib import Path
from typing import Optional

from modules.base import BaseModule
from utils.networking import (
    get_mac,
    detect_interface_with_retry,
    get_wifi_interfaces,
    set_mac,
)
from utils.stealth import set_sysctl, set_tcp_timestamps, set_tcp_window, set_ttl

logger = logging.getLogger(__name__)

# Category preferences per network type
_BUSINESS_CATEGORIES = ("printers", "network", "smart_home")
_HOME_CATEGORIES = ("streaming", "smart_home", "phones", "gaming")
_DEFAULT_CATEGORIES = ("streaming", "smart_home", "phones")

# Map config shorthand to DB device_type values
_CATEGORY_MAP = {
    "streaming": ("smart_tv", "streaming"),
    "phones": ("phone", "tablet"),
    "smart_home": ("smart_speaker", "iot"),
    "printers": ("printer",),
    "gaming": ("gaming",),
    "network": ("printer", "iot"),
}


class MacManager(BaseModule):
    """Select and apply a consumer-device MAC profile for network blending."""

    name = "mac_manager"
    module_type = "stealth"
    priority = -400
    requires_root = True

    def __init__(self, bus, state, config, engine=None):
        super().__init__(bus, state, config, engine)
        self._profile: Optional[dict] = None
        self._eth_iface: Optional[str] = None
        self._wifi_iface: Optional[str] = None
        self._original_eth_mac: Optional[str] = None
        self._original_wifi_mac: Optional[str] = None
        self._original_hostname: Optional[str] = None
        self._db_path = self._resolve_db_path()

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

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

        self._eth_iface = self.config.get("network", {}).get(
            "primary_interface", "auto"
        )
        if self._eth_iface == "auto":
            self._eth_iface = detect_interface_with_retry(
                max_retries=3,
                retry_delay=5,
                exponential=True,
                config_interface=None
            )

        wifi_ifaces = get_wifi_interfaces()
        wifi_cfg = self.config.get("network", {}).get("wifi", {}).get("interface", "wlan0")
        self._wifi_iface = wifi_cfg if wifi_cfg in wifi_ifaces else (wifi_ifaces[0] if wifi_ifaces else None)

        # Save originals for restore on stop
        if self._eth_iface:
            try:
                self._original_eth_mac = get_mac(self._eth_iface)
            except Exception:
                pass
        if self._wifi_iface:
            try:
                self._original_wifi_mac = get_mac(self._wifi_iface)
            except Exception:
                pass

        self._original_hostname = socket.gethostname()

        # Select and apply profile
        self._profile = self._select_profile()
        if self._profile is None:
            logger.warning("No MAC profile found — using random consumer OUI fallback")
            self._profile = self._fallback_profile()

        self._apply_profile(self._profile)

        # Persist for other modules
        self.state.set(self.name, "profile", json.dumps(self._profile))
        self.state.set_module_status(self.name, "running", pid=os.getpid())
        self._running = True
        self._pid = os.getpid()
        self._start_time = time.time()

        logger.info(
            "MAC profile applied: %s (%s) — MAC %s, hostname %s",
            self._profile.get("device_name", "unknown"),
            self._profile.get("vendor", "unknown"),
            self._profile.get("applied_mac", "?"),
            self._profile.get("dhcp_hostname", "?"),
        )

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

        # Restore original MACs on clean exit — skip WiFi interfaces to avoid
        # dropping the active association.
        wifi_ifaces = set(get_wifi_interfaces())
        try:
            if self._eth_iface and self._original_eth_mac and self._eth_iface not in wifi_ifaces:
                set_mac(self._eth_iface, self._original_eth_mac)
            if (self._wifi_iface and self._original_wifi_mac
                    and self._wifi_iface != self._eth_iface
                    and self._wifi_iface not in wifi_ifaces):
                set_mac(self._wifi_iface, self._original_wifi_mac)
        except Exception as exc:
            logger.warning("Failed to restore original MAC: %s", exc)

        self._cleanup_dhclient_conf()

        if self._original_hostname:
            try:
                import subprocess
                subprocess.run(
                    ["hostnamectl", "set-hostname", self._original_hostname],
                    check=True, capture_output=True,
                )
                logger.debug("Hostname restored to %s", self._original_hostname)
            except Exception:
                pass

        self._running = False
        self.state.set_module_status(self.name, "stopped")
        logger.info("MacManager stopped — original MACs restored")

    def status(self) -> dict:
        base = {
            "running": self._running,
            "pid": self._pid,
            "uptime": time.time() - self._start_time if self._start_time else 0,
        }
        if self._profile:
            base["profile_name"] = self._profile.get("device_name", "unknown")
            base["applied_mac"] = self._profile.get("applied_mac", "unknown")
            base["dhcp_hostname"] = self._profile.get("dhcp_hostname", "unknown")
            if self._profile.get("applied_hostname"):
                base["applied_hostname"] = self._profile["applied_hostname"]
        if self._eth_iface:
            try:
                base["current_eth_mac"] = get_mac(self._eth_iface)
            except Exception:
                base["current_eth_mac"] = "error"
        return base

    def configure(self, config: dict) -> None:
        self.config.update(config)
        if self._running:
            # Re-select and apply on config change
            self._profile = self._select_profile()
            if self._profile:
                self._apply_profile(self._profile)
                self.state.set(self.name, "profile", json.dumps(self._profile))

    # ------------------------------------------------------------------
    # Profile selection
    # ------------------------------------------------------------------

    def _resolve_db_path(self) -> str:
        """Locate innocuous_macs.db relative to project root."""
        candidates = [
            os.path.join(os.path.dirname(__file__), "..", "..", "data", "innocuous_macs.db"),
            "/opt/.cache/bb/data/innocuous_macs.db",
        ]
        for p in candidates:
            resolved = os.path.realpath(p)
            if os.path.isfile(resolved):
                return resolved
        # Default — will be created/populated by setup
        return os.path.realpath(candidates[0])

    def _select_profile(self) -> Optional[dict]:
        """Select a MAC profile from the database based on config."""
        if not os.path.isfile(self._db_path):
            logger.warning("innocuous_macs.db not found at %s", self._db_path)
            return None

        stealth_cfg = self.config.get("stealth", {})
        mac_profile = stealth_cfg.get("mac_profile", "auto")
        network_type = stealth_cfg.get("mac_network_type", "auto")

        conn = sqlite3.connect(self._db_path)
        conn.row_factory = sqlite3.Row
        try:
            if mac_profile not in ("auto", None) and mac_profile not in _CATEGORY_MAP:
                # Specific device name requested
                row = conn.execute(
                    "SELECT * FROM mac_profiles WHERE device_name = ? LIMIT 1",
                    (mac_profile,),
                ).fetchone()
                if row:
                    return dict(row)

            # Category-based selection
            if mac_profile == "auto" or mac_profile is None:
                categories = self._pick_categories(network_type)
            else:
                categories = _CATEGORY_MAP.get(mac_profile, _DEFAULT_CATEGORIES)

            placeholders = ",".join("?" for _ in categories)
            rows = conn.execute(
                f"SELECT * FROM mac_profiles WHERE device_type IN ({placeholders})",
                categories,
            ).fetchall()
            if rows:
                return dict(random.choice(rows))
            return None
        except sqlite3.OperationalError:
            logger.warning("mac_profiles table missing — DB not seeded, using fallback")
            return None
        finally:
            conn.close()

    def _pick_categories(self, network_type: str) -> tuple:
        """Choose device categories appropriate for the network type."""
        if network_type == "business":
            cat_keys = _BUSINESS_CATEGORIES
        elif network_type == "home":
            cat_keys = _HOME_CATEGORIES
        else:
            # Auto: default safe categories
            cat_keys = _DEFAULT_CATEGORIES

        types = []
        for key in cat_keys:
            types.extend(_CATEGORY_MAP.get(key, ()))
        return tuple(types)

    def _fallback_profile(self) -> dict:
        """Generate a plausible fallback profile without the database."""
        # Amazon Fire TV Stick OUI
        oui = "FC:65:DE"
        suffix = ":".join(f"{random.randint(0, 255):02x}" for _ in range(3))
        return {
            "device_type": "streaming",
            "vendor": "Amazon",
            "device_name": "Fire TV Stick 4K",
            "oui": oui,
            "dhcp_hostname": "amazon-fire-tv",
            "dhcp_vendor_class": "amazon-fire-tv-stick",
            "ttl": 64,
            "tcp_window": 65535,
        }

    # ------------------------------------------------------------------
    # Apply profile to system
    # ------------------------------------------------------------------

    def _apply_profile(self, profile: dict) -> None:
        """Set MAC, DHCP config, TCP stack to match the selected profile."""
        oui = profile.get("oui", "FC:65:DE")
        suffix = ":".join(f"{random.randint(0, 255):02x}" for _ in range(3))
        new_mac = f"{oui}:{suffix}"
        profile["applied_mac"] = new_mac

        wifi_ifaces = set(get_wifi_interfaces())

        # Set MAC on Ethernet — skip if it's also a WiFi interface (changing MAC
        # on an associated WiFi interface drops the connection and causes ENETDOWN
        # on all AF_PACKET sockets bound to it).
        if self._eth_iface:
            if self._eth_iface in wifi_ifaces:
                logger.info(
                    "Skipping MAC change on %s — active WiFi interface, would break association",
                    self._eth_iface,
                )
            else:
                try:
                    set_mac(self._eth_iface, new_mac)
                    logger.debug("Set %s MAC to %s", self._eth_iface, new_mac)
                except Exception as exc:
                    logger.error("Failed to set MAC on %s: %s", self._eth_iface, exc)

        # Set MAC on WiFi only if it's a separate interface from the primary eth
        # (e.g., a dedicated monitor card).  Same interface = already handled above.
        if self._wifi_iface and self._wifi_iface != self._eth_iface:
            wifi_suffix = ":".join(f"{random.randint(0, 255):02x}" for _ in range(3))
            wifi_mac = f"{oui}:{wifi_suffix}"
            try:
                set_mac(self._wifi_iface, wifi_mac)
                profile["applied_wifi_mac"] = wifi_mac
                logger.debug("Set %s MAC to %s", self._wifi_iface, wifi_mac)
            except Exception as exc:
                logger.error("Failed to set WiFi MAC on %s: %s", self._wifi_iface, exc)

        # DHCP configuration
        self._write_dhclient_conf(profile)

        # TCP stack tuning
        ttl = profile.get("ttl", 64)
        tcp_win = profile.get("tcp_window", 65535)
        set_ttl(ttl)
        set_tcp_window(tcp_win)
        set_tcp_timestamps(True)  # Most consumer devices have timestamps enabled

        logger.debug("TCP stack: TTL=%d, window=%d", ttl, tcp_win)

        self._set_system_hostname(profile)

    def _write_dhclient_conf(self, profile: dict) -> None:
        """Write dhclient.conf with hostname + vendor class matching the profile."""
        hostname = profile.get("dhcp_hostname", "localhost")
        vendor_class = profile.get("dhcp_vendor_class", "")

        conf_path = "/etc/dhcp/dhclient.conf.d"
        conf_file = os.path.join(conf_path, "bb-profile.conf")

        try:
            os.makedirs(conf_path, exist_ok=True)
            lines = [
                f'send host-name "{hostname}";',
            ]
            if vendor_class:
                lines.append(f'send vendor-class-identifier "{vendor_class}";')

            with open(conf_file, "w") as f:
                f.write("\n".join(lines) + "\n")

            logger.debug("Wrote DHCP config: hostname=%s, vendor=%s", hostname, vendor_class)
        except (IOError, PermissionError) as exc:
            logger.warning("Failed to write dhclient.conf: %s", exc)

    def _set_system_hostname(self, profile: dict) -> None:
        """Set system hostname to match the device profile for full fingerprint consistency."""
        import subprocess
        hostname = profile.get("dhcp_hostname", "")
        if not hostname:
            return
        suffix = "".join(f"{random.randint(0, 15):x}" for _ in range(4))
        new_hostname = f"{hostname}-{suffix}"
        try:
            subprocess.run(
                ["hostnamectl", "set-hostname", new_hostname],
                check=True, capture_output=True,
            )
            profile["applied_hostname"] = new_hostname
            logger.info("System hostname set to %s", new_hostname)
        except Exception as exc:
            logger.warning("Failed to set hostname: %s", exc)

    def _cleanup_dhclient_conf(self) -> None:
        """Remove our DHCP config on clean exit."""
        conf_file = "/etc/dhcp/dhclient.conf.d/bb-profile.conf"
        try:
            if os.path.isfile(conf_file):
                os.unlink(conf_file)
        except (IOError, PermissionError):
            pass
