"""Network plan executor.

Takes a ResolvedNetworkPlan and applies it: writes networkd configs, loads
nftables rulesets, sets up policy routes, and configures container DNS.

Teardown reverses the setup atomically — deleting nftables tables, removing
ip rules/routes, and cleaning up networkd configs.

All operations require sudo (host-level networking). Commands use ``sudo -n``
(non-interactive) to avoid hanging on password prompts.
"""

from __future__ import annotations

import logging
import subprocess
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path

from gaol.networking.archetypes import (
    NetworkArchetype,
    NetworkdConfig,
    PolicyRoute,
    ResolvedNetworkPlan,
)

logger = logging.getLogger(__name__)

# Where host networkd configs live
NETWORKD_DIR = Path("/etc/systemd/network")


class StepStatus(str, Enum):
    """Status of a single execution step."""

    OK = "ok"
    FAILED = "failed"
    SKIPPED = "skipped"


@dataclass
class StepResult:
    """Result of a single execution step."""

    name: str
    status: StepStatus
    detail: str = ""

    @property
    def ok(self) -> bool:
        return self.status != StepStatus.FAILED


@dataclass
class ExecutionResult:
    """Result of executing a full network plan."""

    steps: list[StepResult] = field(default_factory=list)

    @property
    def ok(self) -> bool:
        return all(s.ok for s in self.steps)

    @property
    def failed_steps(self) -> list[StepResult]:
        return [s for s in self.steps if s.status == StepStatus.FAILED]

    def summary(self) -> str:
        total = len(self.steps)
        ok = sum(1 for s in self.steps if s.status == StepStatus.OK)
        failed = sum(1 for s in self.steps if s.status == StepStatus.FAILED)
        skipped = sum(1 for s in self.steps if s.status == StepStatus.SKIPPED)
        return f"{ok}/{total} ok, {failed} failed, {skipped} skipped"


def _run(
    *args: str, sudo: bool = True, input_text: str | None = None
) -> tuple[int, str, str]:
    """Run a command, optionally with sudo."""
    cmd = list(args)
    if sudo:
        cmd = ["sudo", "-n", *cmd]
    logger.debug("exec: %s", " ".join(cmd))
    result = subprocess.run(
        cmd,
        capture_output=True,
        text=True,
        input=input_text,
    )
    if result.returncode != 0 and result.stderr:
        logger.debug("stderr: %s", result.stderr.strip())
    return result.returncode, result.stdout, result.stderr


# ---------------------------------------------------------------------------
# Individual setup steps
# ---------------------------------------------------------------------------


def write_networkd_config(config: NetworkdConfig) -> StepResult:
    """Write a networkd .network file to /etc/systemd/network/."""
    path = NETWORKD_DIR / config.filename
    name = f"networkd:{config.filename}"

    # Write via tee (needs sudo for /etc/systemd/network/)
    rc, _, stderr = _run(
        "tee", str(path), sudo=True, input_text=config.content
    )
    if rc != 0:
        return StepResult(name, StepStatus.FAILED, stderr.strip())

    return StepResult(name, StepStatus.OK)


def remove_networkd_config(config: NetworkdConfig) -> StepResult:
    """Remove a networkd .network file."""
    path = NETWORKD_DIR / config.filename
    name = f"networkd:rm:{config.filename}"

    if not path.exists():
        return StepResult(name, StepStatus.SKIPPED, "file does not exist")

    rc, _, stderr = _run("rm", str(path), sudo=True)
    if rc != 0:
        return StepResult(name, StepStatus.FAILED, stderr.strip())

    return StepResult(name, StepStatus.OK)


def reload_networkd() -> StepResult:
    """Reload systemd-networkd to pick up config changes."""
    rc, _, stderr = _run("networkctl", "reload", sudo=True)
    if rc != 0:
        return StepResult("networkd:reload", StepStatus.FAILED, stderr.strip())
    return StepResult("networkd:reload", StepStatus.OK)


def apply_nftables(plan: ResolvedNetworkPlan) -> list[StepResult]:
    """Apply all nftables rulesets in the plan."""
    results: list[StepResult] = []
    for ruleset in plan.nftables_rulesets:
        name = f"nftables:{ruleset.table_name}"
        rc, _, stderr = ruleset.apply(use_sudo=True)
        if rc != 0:
            results.append(StepResult(name, StepStatus.FAILED, stderr.strip()))
        else:
            results.append(StepResult(name, StepStatus.OK))
    return results


def destroy_nftables(plan: ResolvedNetworkPlan) -> list[StepResult]:
    """Destroy all nftables tables from the plan."""
    results: list[StepResult] = []
    for ruleset in plan.nftables_rulesets:
        name = f"nftables:rm:{ruleset.table_name}"
        rc, _, stderr = ruleset.destroy(use_sudo=True)
        if rc != 0:
            # Table may not exist — that's fine during cleanup
            if "No such file or directory" in stderr or "does not exist" in stderr:
                results.append(StepResult(name, StepStatus.SKIPPED, "table not found"))
            else:
                results.append(StepResult(name, StepStatus.FAILED, stderr.strip()))
        else:
            results.append(StepResult(name, StepStatus.OK))
    return results


def add_policy_route(route: PolicyRoute) -> StepResult:
    """Add an ip rule entry for policy routing."""
    name = f"ip-rule:table-{route.table_id}"

    if route.from_addr:
        rc, _, stderr = _run(
            "ip", "rule", "add",
            "from", route.from_addr,
            "lookup", str(route.table_id),
            "priority", str(route.priority),
        )
    elif route.iif:
        rc, _, stderr = _run(
            "ip", "rule", "add",
            "iif", route.iif,
            "lookup", str(route.table_id),
            "priority", str(route.priority),
        )
    else:
        return StepResult(name, StepStatus.SKIPPED, "no from_addr or iif")

    if rc != 0:
        # EEXIST is ok — rule already exists
        if "File exists" in stderr:
            return StepResult(name, StepStatus.OK, "already exists")
        return StepResult(name, StepStatus.FAILED, stderr.strip())

    return StepResult(name, StepStatus.OK)


def remove_policy_route(route: PolicyRoute) -> StepResult:
    """Remove an ip rule entry."""
    name = f"ip-rule:rm:table-{route.table_id}"

    if route.from_addr:
        rc, _, stderr = _run(
            "ip", "rule", "del",
            "from", route.from_addr,
            "lookup", str(route.table_id),
            "priority", str(route.priority),
        )
    elif route.iif:
        rc, _, stderr = _run(
            "ip", "rule", "del",
            "iif", route.iif,
            "lookup", str(route.table_id),
            "priority", str(route.priority),
        )
    else:
        return StepResult(name, StepStatus.SKIPPED, "no from_addr or iif")

    if rc != 0:
        if "No such file or directory" in stderr:
            return StepResult(name, StepStatus.SKIPPED, "rule not found")
        return StepResult(name, StepStatus.FAILED, stderr.strip())

    return StepResult(name, StepStatus.OK)


def set_route_gateway(route: PolicyRoute) -> StepResult:
    """Set the default gateway in a routing table."""
    name = f"ip-route:table-{route.table_id}"

    if not route.gateway:
        return StepResult(name, StepStatus.SKIPPED, "no gateway specified")

    # Replace (add or update) the default route in the table
    rc, _, stderr = _run(
        "ip", "route", "replace",
        "default", "via", route.gateway,
        "table", str(route.table_id),
    )
    if rc != 0:
        return StepResult(name, StepStatus.FAILED, stderr.strip())

    return StepResult(name, StepStatus.OK)


def flush_route_table(table_id: int) -> StepResult:
    """Flush all routes from a routing table."""
    name = f"ip-route:flush:table-{table_id}"
    rc, _, stderr = _run("ip", "route", "flush", "table", str(table_id))
    if rc != 0:
        return StepResult(name, StepStatus.FAILED, stderr.strip())
    return StepResult(name, StepStatus.OK)


def enable_ip_forward() -> StepResult:
    """Enable IPv4 forwarding via sysctl."""
    rc, _, stderr = _run(
        "sysctl", "-w", "net.ipv4.ip_forward=1", sudo=True
    )
    if rc != 0:
        return StepResult("sysctl:ip_forward", StepStatus.FAILED, stderr.strip())
    return StepResult("sysctl:ip_forward", StepStatus.OK)


def remove_systemd_nat_masquerade() -> StepResult:
    """Remove systemd-networkd's auto-generated masquerade entries.

    systemd-networkd re-adds io.systemd.nat masquerade entries when it
    reloads, which conflict with chain routing. This removes them.
    """
    name = "nftables:rm-systemd-nat"

    # Try to delete the io.systemd.nat table (may not exist)
    rc, _, stderr = _run(
        "nft", "delete", "table", "ip", "io.systemd.nat", sudo=True
    )
    if rc != 0:
        if "No such file or directory" in stderr or "does not exist" in stderr:
            return StepResult(name, StepStatus.SKIPPED, "table not found")
        return StepResult(name, StepStatus.FAILED, stderr.strip())

    return StepResult(name, StepStatus.OK)


# ---------------------------------------------------------------------------
# Plan-level execution
# ---------------------------------------------------------------------------


def apply_plan(plan: ResolvedNetworkPlan) -> ExecutionResult:
    """Apply a complete network plan.

    Execution order:
    1. Enable IP forwarding (if chain archetype)
    2. Write host networkd configs
    3. Reload networkd
    4. Remove systemd-networkd NAT entries (if chain archetype)
    5. Apply nftables rulesets
    6. Add policy routes
    7. Set route gateways

    Args:
        plan: The resolved network plan to apply.

    Returns:
        ExecutionResult with status of each step.
    """
    result = ExecutionResult()
    is_chain = plan.archetype in (
        NetworkArchetype.VPN_EXIT,
        NetworkArchetype.TOR_VPN_EXIT,
    )

    # Simple archetypes have nothing to execute on the host
    if plan.archetype in (NetworkArchetype.HOST, NetworkArchetype.SHARED):
        result.steps.append(
            StepResult(f"archetype:{plan.archetype.value}", StepStatus.OK, "no host setup needed")
        )
        return result

    # 1. Enable IP forwarding for chain archetypes
    if is_chain:
        result.steps.append(enable_ip_forward())

    # 2. Write networkd configs
    for config in plan.host_networkd:
        result.steps.append(write_networkd_config(config))

    # 3. Reload networkd (only if we wrote configs)
    if plan.host_networkd:
        result.steps.append(reload_networkd())

    # 4. Remove systemd-networkd NAT entries
    if is_chain:
        result.steps.append(remove_systemd_nat_masquerade())

    # 5. Apply nftables rulesets
    result.steps.extend(apply_nftables(plan))

    # 6. Add policy routes
    for route in plan.policy_routes:
        result.steps.append(add_policy_route(route))

    # 7. Set route gateways
    for route in plan.policy_routes:
        if route.gateway:
            result.steps.append(set_route_gateway(route))

    return result


def teardown_plan(plan: ResolvedNetworkPlan) -> ExecutionResult:
    """Tear down a network plan (reverse of apply).

    Execution order:
    1. Flush routing tables
    2. Remove policy routes
    3. Destroy nftables tables
    4. Remove networkd configs
    5. Reload networkd

    Args:
        plan: The plan to tear down.

    Returns:
        ExecutionResult with status of each step.
    """
    result = ExecutionResult()

    if plan.archetype in (NetworkArchetype.HOST, NetworkArchetype.SHARED):
        result.steps.append(
            StepResult(f"teardown:{plan.archetype.value}", StepStatus.OK, "nothing to tear down")
        )
        return result

    # 1. Flush routing tables
    table_ids = {r.table_id for r in plan.policy_routes}
    for tid in sorted(table_ids):
        result.steps.append(flush_route_table(tid))

    # 2. Remove policy routes
    for route in plan.policy_routes:
        result.steps.append(remove_policy_route(route))

    # 3. Destroy nftables tables
    result.steps.extend(destroy_nftables(plan))

    # 4. Remove networkd configs
    for config in plan.host_networkd:
        result.steps.append(remove_networkd_config(config))

    # 5. Reload networkd
    if plan.host_networkd:
        result.steps.append(reload_networkd())

    return result
