"""Tests for gen_rescue_netscript.

Network-affecting subprocess calls (ip, resolvectl, blkid, mount, umount)
are stubbed via monkeypatching ``subprocess.run`` rather than requiring
real block devices, root, or a real network stack.
"""
# Test function names are self-documenting (test_what_it_checks); adding
# a docstring to each of the 70+ tests here would be pure restatement.
# pylint: disable=missing-function-docstring
import ipaddress
import json
import subprocess
from types import SimpleNamespace

import pytest

import gen_rescue_netscript as grn


def fake_completed_process(returncode=0, stdout="", stderr=""):
    """Build a stand-in for subprocess.CompletedProcess."""
    return SimpleNamespace(returncode=returncode, stdout=stdout, stderr=stderr)


def ip4(text):
    return ipaddress.ip_address(text)


def net4(text):
    return ipaddress.ip_network(text)


# ---------------------------------------------------------------------------
# EUI-64 / link-local
# ---------------------------------------------------------------------------
def test_compute_eui64_link_local():
    addr = grn.compute_eui64_link_local("52:54:00:12:34:56")
    assert str(addr) == "fe80::5054:ff:fe12:3456"


def test_compute_eui64_link_local_invalid_mac():
    assert grn.compute_eui64_link_local("not-a-mac") is None
    assert grn.compute_eui64_link_local("") is None


# ---------------------------------------------------------------------------
# find_link / build_addresses
# ---------------------------------------------------------------------------
ADDR_DATA_FIXTURE = [
    {
        "ifname": "lo",
        "addr_info": [{"family": "inet", "local": "127.0.0.1", "prefixlen": 8, "scope": "host"}],
    },
    {
        "ifname": "eth0",
        "address": "52:54:00:12:34:56",
        "addr_info": [
            {
                "family": "inet",
                "local": "192.0.2.10",
                "prefixlen": 24,
                "scope": "global",
                "dynamic": True,
            },
            {
                "family": "inet6",
                "local": "2001:db8::10",
                "prefixlen": 64,
                "scope": "global",
                "dynamic": True,
                "mngtmpaddr": True,
            },
            {
                "family": "inet6",
                "local": "2001:db8::1234:5678:9abc:def0",
                "prefixlen": 64,
                "scope": "global",
                "dynamic": True,
            },
            {
                "family": "inet6",
                "local": "2001:db8::aaaa",
                "prefixlen": 64,
                "scope": "global",
                "dynamic": True,
                "temporary": True,
            },
            {
                "family": "inet6",
                "local": "fe80::5054:ff:fe12:3456",
                "prefixlen": 64,
                "scope": "link",
            },
            {"family": "inet6", "local": "fe80::1", "prefixlen": 64, "scope": "link"},
            {"family": "inet", "local": "169.254.1.2", "prefixlen": 16, "scope": "link"},
        ],
    },
]


def test_find_link_finds_by_ifname():
    link = grn.find_link(ADDR_DATA_FIXTURE, "eth0")
    assert link is not None
    assert link["ifname"] == "eth0"


def test_find_link_returns_none_when_absent():
    assert grn.find_link(ADDR_DATA_FIXTURE, "eth99") is None


def _cidrs(ifname):
    link = grn.find_link(ADDR_DATA_FIXTURE, ifname)
    return [str(a.interface) for a in grn.build_addresses(link)]


# ---------------------------------------------------------------------------
# select_target_interface: agreement, preference, and conflict cases
# ---------------------------------------------------------------------------
def test_select_target_interface_single_default():
    routes = [grn.Route(dst=net4("0.0.0.0/0"), dev="eth0", gateway=ip4("192.0.2.1"))]
    assert grn.select_target_interface(routes) == "eth0"


def test_select_target_interface_dual_stack_agrees():
    routes = [
        grn.Route(dst=net4("0.0.0.0/0"), dev="eth0", gateway=ip4("192.0.2.1")),
        grn.Route(dst=ipaddress.ip_network("::/0"), dev="eth0", gateway=ip4("2001:db8::1")),
    ]
    assert grn.select_target_interface(routes) == "eth0"


def test_select_target_interface_no_default_dies(capsys):
    with pytest.raises(SystemExit):
        grn.select_target_interface([])
    assert "no default route found" in capsys.readouterr().err


def test_select_target_interface_cross_family_disagreement_prefers_ipv6_by_default():
    routes = [
        grn.Route(dst=net4("0.0.0.0/0"), dev="eth0", gateway=ip4("192.0.2.1")),
        grn.Route(dst=ipaddress.ip_network("::/0"), dev="eth1", gateway=ip4("2001:db8::1")),
    ]
    assert grn.select_target_interface(routes) == "eth1"


def test_select_target_interface_cross_family_disagreement_can_prefer_ipv4():
    routes = [
        grn.Route(dst=net4("0.0.0.0/0"), dev="eth0", gateway=ip4("192.0.2.1")),
        grn.Route(dst=ipaddress.ip_network("::/0"), dev="eth1", gateway=ip4("2001:db8::1")),
    ]
    assert grn.select_target_interface(routes, prefer="ipv4") == "eth0"


def test_select_target_interface_only_ipv4_present():
    routes = [grn.Route(dst=net4("0.0.0.0/0"), dev="eth0", gateway=ip4("192.0.2.1"))]
    assert grn.select_target_interface(routes, prefer="ipv6") == "eth0"


# ---------------------------------------------------------------------------
# config file loading (PyYAML is a hard dependency, no local fallback)
# ---------------------------------------------------------------------------
def test_load_config_missing_file_returns_empty(tmp_path):
    assert grn.load_config(str(tmp_path / "nonexistent.conf")) == {}


# ---------------------------------------------------------------------------
# gather() end-to-end, via monkeypatched subprocess.run
# ---------------------------------------------------------------------------
def _fake_ip_run(addr=None, route4=None, route6=None):
    addr = addr if addr is not None else ADDR_DATA_FIXTURE
    route4 = route4 if route4 is not None else []
    route6 = route6 if route6 is not None else []

    def fake_run(cmd, **_kwargs):
        assert cmd[0] == "ip"
        assert cmd[1] == "-json"
        args = cmd[2:]
        if args == ["addr", "show"]:
            return fake_completed_process(stdout=json.dumps(addr))
        if args == ["route", "show"]:
            return fake_completed_process(stdout=json.dumps(route4))
        if args == ["-6", "route", "show"]:
            return fake_completed_process(stdout=json.dumps(route6))
        raise AssertionError(f"unexpected ip invocation: {args}")

    return fake_run


def test_default_route_gateways_collects_distinct_values():
    routes = [
        grn.Route(dst=net4("0.0.0.0/0"), dev="eth0", gateway=ip4("192.0.2.1")),
        grn.Route(dst=ipaddress.ip_network("::/0"), dev="eth0", gateway=ip4("2001:db8::1")),
        grn.Route(dst=net4("198.51.100.0/24"), dev="eth0", gateway=ip4("192.0.2.254")),
    ]
    gateways = grn.default_route_gateways(routes)
    assert gateways == [ip4("192.0.2.1"), ipaddress.ip_address("2001:db8::1")]


def test_default_route_gateways_dedups():
    routes = [
        grn.Route(dst=net4("0.0.0.0/0"), dev="eth0", gateway=ip4("192.0.2.1")),
        grn.Route(dst=net4("0.0.0.0/0"), dev="eth0", gateway=ip4("192.0.2.1")),
    ]
    assert grn.default_route_gateways(routes) == [ip4("192.0.2.1")]


# ---------------------------------------------------------------------------
# /etc/resolv.conf discovery
# ---------------------------------------------------------------------------
def test_nameservers_from_resolv_conf_filters_loopback(tmp_path):
    path = tmp_path / "resolv.conf"
    path.write_text("nameserver 127.0.0.53\noptions edns0\n", encoding="utf-8")
    assert grn.nameservers_from_resolv_conf(str(path)) == []


def test_nameservers_from_resolv_conf_keeps_real_servers(tmp_path):
    path = tmp_path / "resolv.conf"
    path.write_text("nameserver 9.9.9.9\nnameserver 149.112.112.112\n", encoding="utf-8")
    assert grn.nameservers_from_resolv_conf(str(path)) == [ip4("9.9.9.9"), ip4("149.112.112.112")]


def test_nameservers_from_resolv_conf_missing_file(tmp_path):
    assert grn.nameservers_from_resolv_conf(str(tmp_path / "nonexistent")) == []


def test_nameservers_from_resolvectl_parses_per_link(monkeypatch):
    def fake_run(cmd, **_kwargs):
        assert cmd[0] == "resolvectl"
        if cmd[1:] == ["dns", "eth0"]:
            return fake_completed_process(stdout="Link 2 (eth0): 192.0.2.53 2001:db8::53\n")
        raise AssertionError(f"unexpected resolvectl call: {cmd}")

    monkeypatch.setattr(subprocess, "run", fake_run)
    assert grn.nameservers_from_resolvectl("eth0") == [ip4("192.0.2.53"), ip4("2001:db8::53")]


def test_nameservers_from_resolvectl_not_installed(monkeypatch):
    def fake_run(_cmd, **_kwargs):
        raise FileNotFoundError

    monkeypatch.setattr(subprocess, "run", fake_run)
    assert grn.nameservers_from_resolvectl("eth0") == []


def test_nameservers_from_resolvectl_no_json_flag_used(monkeypatch):
    # resolvectl's --json is deliberately not used (too new for a real fleet)
    def fake_run(cmd, **_kwargs):
        assert "--json" not in " ".join(cmd)
        assert "--json=short" not in cmd
        return fake_completed_process(stdout="Link 2 (eth0): 192.0.2.53\n")

    monkeypatch.setattr(subprocess, "run", fake_run)
    grn.nameservers_from_resolvectl("eth0")


def test_determine_nameservers_priority_chain(monkeypatch):
    monkeypatch.setattr(grn, "nameservers_from_resolvectl", lambda _dev: [])
    monkeypatch.setattr(grn, "nameservers_from_resolv_conf", lambda: [ip4("9.9.9.9")])
    assert grn.determine_nameservers("eth0") == [ip4("9.9.9.9")]

    monkeypatch.setattr(grn, "nameservers_from_resolvectl", lambda _dev: [ip4("192.0.2.53")])
    assert grn.determine_nameservers("eth0") == [ip4("192.0.2.53")]

    monkeypatch.setattr(grn, "nameservers_from_resolvectl", lambda _dev: [])
    monkeypatch.setattr(grn, "nameservers_from_resolv_conf", lambda: [])
    assert grn.determine_nameservers("eth0") == [grn.FALLBACK_NAMESERVER]


# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def test_parse_args_efi():
    assert grn.parse_args(["--efi"]).efi is True


def test_parse_args_interface():
    assert grn.parse_args(["--interface", "eth3"]).interface == "eth3"


def test_parse_args_prefer():
    assert grn.parse_args(["--prefer", "ipv4"]).prefer == "ipv4"


def test_parse_args_prefer_rejects_invalid_choice():
    with pytest.raises(SystemExit):
        grn.parse_args(["--prefer", "ipv5"])


# ---------------------------------------------------------------------------
# run_cmd's replace-then-add fallback (shell logic embedded in the
# generated script template) -- exercised as actual shell, not just Python.
# ---------------------------------------------------------------------------
def _make_fake_ip(tmp_path, body):
    """Write an executable fake `ip` script and return a bin dir to prepend to PATH."""
    bindir = tmp_path / "bin"
    bindir.mkdir()
    ip_path = bindir / "ip"
    ip_path.write_text("#!/bin/sh\n" + body, encoding="utf-8")
    ip_path.chmod(0o755)
    return str(bindir)
