"""Virtual network infrastructure using Linux network namespaces.

Creates isolated "machines" connected by a bridge, each with its own
network stack.  Processes launched inside a namespace can only communicate
over the virtual LAN — this is real kernel networking, not simulation.

Topology::

    ┌──────────┐   veth    ┌──────────┐   veth    ┌──────────┐
    │  node-0  │◄─────────►│  bridge   │◄─────────►│  node-1  │
    │ 10.99.0.2│           │ 10.99.0.1 │           │ 10.99.0.3│
    └──────────┘           └──────────┘           └──────────┘

Usage::

    net = VirtualNetwork("testlan")
    net.setup(num_nodes=3)

    # Run a command inside node 0's network namespace
    net.exec(0, ["curl", "-s", "http://10.99.0.3:11434/api/tags"])

    net.teardown()
"""

from __future__ import annotations

import logging
import os
import signal
import subprocess
import time
from dataclasses import dataclass, field

logger = logging.getLogger(__name__)

# Proxy env vars that must be stripped so namespace traffic stays on the
# virtual LAN instead of being routed through the container egress proxy.
_PROXY_VARS = {
    "HTTP_PROXY",
    "http_proxy",
    "HTTPS_PROXY",
    "https_proxy",
    "GLOBAL_AGENT_HTTP_PROXY",
    "GLOBAL_AGENT_HTTPS_PROXY",
    "YARN_HTTP_PROXY",
    "YARN_HTTPS_PROXY",
    "ELECTRON_GET_USE_PROXY",
}


def _clean_env(extra: dict[str, str] | None = None) -> dict[str, str]:
    """Return an environment dict with proxy variables removed."""
    env = {k: v for k, v in os.environ.items() if k not in _PROXY_VARS}
    # Override no_proxy to include our test subnets
    env["no_proxy"] = "10.99.0.0/24,localhost,127.0.0.1"
    env["NO_PROXY"] = "10.99.0.0/24,localhost,127.0.0.1"
    if extra:
        env.update(extra)
    return env


@dataclass
class VirtualNode:
    """A single namespace-isolated network node."""

    index: int
    name: str  # namespace name, e.g. "testlan-n0"
    ip: str  # e.g. "10.99.0.2"
    veth_host: str  # host-side veth name
    veth_ns: str  # namespace-side veth name
    _processes: list[subprocess.Popen] = field(default_factory=list, repr=False)

    def exec(
        self,
        cmd: list[str],
        *,
        timeout: float = 30.0,
        capture: bool = True,
        env: dict[str, str] | None = None,
    ) -> subprocess.CompletedProcess:
        """Run a command inside this node's network namespace."""
        full_cmd = ["ip", "netns", "exec", self.name, *cmd]
        run_env = _clean_env(env)
        return subprocess.run(
            full_cmd,
            capture_output=capture,
            text=True,
            timeout=timeout,
            env=run_env,
        )

    def spawn(
        self,
        cmd: list[str],
        *,
        env: dict[str, str] | None = None,
        stdout: int | None = subprocess.DEVNULL,
        stderr: int | None = subprocess.DEVNULL,
    ) -> subprocess.Popen:
        """Spawn a long-running process inside this node's namespace.

        The process is tracked and killed on teardown.
        """
        full_cmd = ["ip", "netns", "exec", self.name, *cmd]
        run_env = _clean_env(env)
        proc = subprocess.Popen(full_cmd, env=run_env, stdout=stdout, stderr=stderr)
        self._processes.append(proc)
        return proc

    def kill_all(self) -> None:
        """Terminate all spawned processes."""
        for proc in self._processes:
            try:
                proc.send_signal(signal.SIGTERM)
                proc.wait(timeout=5)
            except (subprocess.TimeoutExpired, ProcessLookupError):
                try:
                    proc.kill()
                    proc.wait(timeout=2)
                except (ProcessLookupError, subprocess.TimeoutExpired):
                    pass
        self._processes.clear()


class VirtualNetwork:
    """Manages a bridged virtual LAN of network namespaces.

    Parameters
    ----------
    name:
        Prefix for namespace and interface names.
    subnet:
        /24 subnet prefix, e.g. "10.99.0".  Bridge gets .1, nodes get .2+
    """

    def __init__(self, name: str = "vnet", subnet: str = "10.99.0") -> None:
        self.name = name
        self.subnet = subnet
        self.bridge_name = f"{name}-br0"
        self.bridge_ip = f"{subnet}.1"
        self.nodes: list[VirtualNode] = []
        self._ready = False

    def setup(self, num_nodes: int = 2) -> list[VirtualNode]:
        """Create the bridge and *num_nodes* network namespaces.

        Returns the list of :class:`VirtualNode` instances.
        """
        self._ensure_netns_dir()
        self._create_bridge()

        for i in range(num_nodes):
            node = self._create_node(i)
            self.nodes.append(node)

        self._ready = True
        logger.info(
            "VirtualNetwork '%s' ready: bridge=%s, %d nodes",
            self.name,
            self.bridge_ip,
            len(self.nodes),
        )
        return self.nodes

    def teardown(self) -> None:
        """Destroy all namespaces, veths, and the bridge."""
        for node in self.nodes:
            node.kill_all()
            self._run(["ip", "netns", "del", node.name])
        self.nodes.clear()

        self._run(["ip", "link", "set", self.bridge_name, "down"])
        self._run(["ip", "link", "del", self.bridge_name])
        self._ready = False
        logger.info("VirtualNetwork '%s' torn down", self.name)

    def exec(self, node_index: int, cmd: list[str], **kwargs) -> subprocess.CompletedProcess:
        """Shortcut: run a command in node *node_index*."""
        return self.nodes[node_index].exec(cmd, **kwargs)

    # -- internal helpers ----------------------------------------------------

    def _ensure_netns_dir(self) -> None:
        """Ensure /var/run/netns exists (needed for ``ip netns``)."""
        os.makedirs("/var/run/netns", exist_ok=True)

    def _create_bridge(self) -> None:
        """Create and configure the Linux bridge."""
        self._run(["ip", "link", "add", self.bridge_name, "type", "bridge"])
        self._run(["ip", "addr", "add", f"{self.bridge_ip}/24", "dev", self.bridge_name])
        self._run(["ip", "link", "set", self.bridge_name, "up"])

    def _create_node(self, index: int) -> VirtualNode:
        """Create a single namespace and connect it to the bridge."""
        ns_name = f"{self.name}-n{index}"
        ip = f"{self.subnet}.{index + 2}"
        veth_host = f"{self.name}-vh{index}"
        veth_ns = f"{self.name}-vn{index}"

        # Create namespace
        self._run(["ip", "netns", "add", ns_name])

        # Bring up loopback inside namespace
        self._run(["ip", "netns", "exec", ns_name, "ip", "link", "set", "lo", "up"])

        # Create veth pair
        self._run(["ip", "link", "add", veth_host, "type", "veth", "peer", "name", veth_ns])

        # Move namespace-side veth into the namespace
        self._run(["ip", "link", "set", veth_ns, "netns", ns_name])

        # Attach host-side to bridge
        self._run(["ip", "link", "set", veth_host, "master", self.bridge_name])
        self._run(["ip", "link", "set", veth_host, "up"])

        # Configure IP inside namespace
        self._run(["ip", "netns", "exec", ns_name, "ip", "addr", "add", f"{ip}/24", "dev", veth_ns])
        self._run(["ip", "netns", "exec", ns_name, "ip", "link", "set", veth_ns, "up"])

        # Default route inside namespace points to bridge
        self._run(["ip", "netns", "exec", ns_name, "ip", "route", "add", "default", "via", self.bridge_ip])

        return VirtualNode(
            index=index,
            name=ns_name,
            ip=ip,
            veth_host=veth_host,
            veth_ns=veth_ns,
        )

    @staticmethod
    def _run(cmd: list[str]) -> subprocess.CompletedProcess:
        """Run a system command, raising on failure."""
        result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
        if result.returncode != 0:
            logger.warning("Command failed: %s\nstderr: %s", " ".join(cmd), result.stderr.strip())
        return result


def wait_for_port(host: str, port: int, timeout: float = 30.0, ns_name: str | None = None) -> bool:
    """Wait until *host*:*port* is accepting connections.

    If *ns_name* is provided, the check runs inside that network namespace.
    """
    deadline = time.monotonic() + timeout
    env = _clean_env()
    while time.monotonic() < deadline:
        cmd = [
            "python3",
            "-c",
            f"import socket; s=socket.socket(); s.settimeout(1); s.connect(('{host}',{port})); s.close(); print('OK')",
        ]
        if ns_name:
            cmd = ["ip", "netns", "exec", ns_name, *cmd]
        try:
            result = subprocess.run(cmd, capture_output=True, text=True, timeout=5, env=env)
            if result.returncode == 0 and "OK" in result.stdout:
                return True
        except subprocess.TimeoutExpired:
            pass
        time.sleep(0.5)
    return False
