"""Additional coverage for system.py - DNS methods, fallback interface detection,
utility functions."""

from __future__ import annotations

import socket
import subprocess
from unittest.mock import MagicMock, patch

import pytest

from cli.system import (
    _dns_from_networkmanager,
    _dns_from_resolvectl,
    _dns_from_scutil,
    _get_interfaces_fallback,
    check_dns_server,
    check_wireguard,
    find_free_udp_port,
    get_dns_servers,
    get_hostname,
    get_routes_via_interface,
    get_suggested_beacon_name,
    interface_exists,
    is_root,
)


# ---------------------------------------------------------------------------
# get_hostname
# ---------------------------------------------------------------------------


class TestGetHostname:
    def test_returns_string(self):
        result = get_hostname()
        assert isinstance(result, str)
        assert len(result) > 0

    def test_delegates_to_socket(self):
        with patch("socket.gethostname", return_value="test-host.example.com"):
            assert get_hostname() == "test-host.example.com"


# ---------------------------------------------------------------------------
# get_suggested_beacon_name
# ---------------------------------------------------------------------------


class TestGetSuggestedBeaconName:
    def test_simple_hostname_passthrough(self):
        with patch("socket.gethostname", return_value="myhost"):
            assert get_suggested_beacon_name() == "myhost"

    def test_fqdn_dots_become_hyphens(self):
        with patch("socket.gethostname", return_value="my-host.example.com"):
            assert get_suggested_beacon_name() == "my-host-example-com"

    def test_consecutive_dots_collapse_to_single_hyphen(self):
        with patch("socket.gethostname", return_value="host..local"):
            assert get_suggested_beacon_name() == "host-local"

    def test_underscore_is_preserved(self):
        with patch("socket.gethostname", return_value="my_host_01"):
            assert get_suggested_beacon_name() == "my_host_01"

    def test_strips_leading_and_trailing_hyphens(self):
        with patch("socket.gethostname", return_value=".hostname."):
            assert get_suggested_beacon_name() == "hostname"

    def test_mixed_special_chars(self):
        with patch("socket.gethostname", return_value="My-Machine_01.local"):
            assert get_suggested_beacon_name() == "My-Machine_01-local"


# ---------------------------------------------------------------------------
# is_root
# ---------------------------------------------------------------------------


class TestIsRoot:
    def test_true_when_euid_is_zero(self):
        with patch("os.geteuid", return_value=0):
            assert is_root() is True

    def test_false_when_euid_is_nonzero(self):
        with patch("os.geteuid", return_value=1000):
            assert is_root() is False


# ---------------------------------------------------------------------------
# check_wireguard
# ---------------------------------------------------------------------------


class TestCheckWireguard:
    def test_true_when_both_commands_found(self):
        with patch("subprocess.check_output", return_value=b"/usr/bin/wg\n"):
            assert check_wireguard() is True

    def test_false_when_wg_missing(self):
        with patch("subprocess.check_output", side_effect=subprocess.CalledProcessError(1, "which")):
            assert check_wireguard() is False

    def test_false_when_wg_quick_missing(self):
        call_count = [0]

        def side_effect(cmd, **kwargs):
            call_count[0] += 1
            if call_count[0] == 2:  # second call (wg-quick) fails
                raise subprocess.CalledProcessError(1, "which")
            return b"/usr/bin/wg\n"

        with patch("subprocess.check_output", side_effect=side_effect):
            assert check_wireguard() is False


# ---------------------------------------------------------------------------
# find_free_udp_port
# ---------------------------------------------------------------------------


class TestFindFreeUdpPort:
    def test_returns_port_in_range(self):
        port = find_free_udp_port(51820, 51920)
        assert 51820 <= port <= 51920

    def test_skips_occupied_ports(self):
        call_count = [0]
        original_bind = socket.socket.bind

        def fake_bind(self, addr):
            call_count[0] += 1
            if addr[1] == 51820:  # first port occupied
                raise OSError("already in use")
            return original_bind(self, addr)

        with patch.object(socket.socket, "bind", fake_bind):
            port = find_free_udp_port(51820, 51825)
        assert port == 51821  # should skip 51820

    def test_returns_start_when_all_occupied(self):
        with patch.object(socket.socket, "bind", side_effect=OSError("in use")):
            port = find_free_udp_port(51820, 51822)
        assert port == 51820  # fallback to start


# ---------------------------------------------------------------------------
# _dns_from_resolvectl
# ---------------------------------------------------------------------------


class TestDnsFromResolvectl:
    def test_parses_dns_servers_line(self):
        output = (
            "Link 2 (eth0)\n"
            "      Current DNS Server: 8.8.8.8\n"
            "             DNS Servers: 8.8.8.8 8.8.4.4\n"
        )
        with patch("subprocess.check_output", return_value=output):
            servers = _dns_from_resolvectl()
        assert "8.8.8.8" in servers
        assert "8.8.4.4" in servers

    def test_filters_loopback(self):
        output = "             DNS Servers: 127.0.0.53 1.1.1.1\n"
        with patch("subprocess.check_output", return_value=output):
            servers = _dns_from_resolvectl()
        assert "127.0.0.53" not in servers
        assert "1.1.1.1" in servers

    def test_returns_empty_when_command_not_found(self):
        with patch("subprocess.check_output", side_effect=FileNotFoundError):
            assert _dns_from_resolvectl() == []

    def test_returns_empty_when_command_fails(self):
        with patch(
            "subprocess.check_output",
            side_effect=subprocess.CalledProcessError(1, "resolvectl"),
        ):
            assert _dns_from_resolvectl() == []


# ---------------------------------------------------------------------------
# _dns_from_networkmanager
# ---------------------------------------------------------------------------


class TestDnsFromNetworkManager:
    def test_parses_ip4_dns_lines(self):
        output = "IP4.DNS[1]:10.0.0.1\nIP4.DNS[2]:10.0.0.2\n"
        with patch("subprocess.check_output", return_value=output):
            servers = _dns_from_networkmanager()
        assert "10.0.0.1" in servers
        assert "10.0.0.2" in servers

    def test_filters_loopback(self):
        output = "IP4.DNS[1]:127.0.0.1\nIP4.DNS[2]:192.168.1.1\n"
        with patch("subprocess.check_output", return_value=output):
            servers = _dns_from_networkmanager()
        assert "127.0.0.1" not in servers
        assert "192.168.1.1" in servers

    def test_returns_empty_when_nmcli_not_found(self):
        with patch("subprocess.check_output", side_effect=FileNotFoundError):
            assert _dns_from_networkmanager() == []

    def test_returns_empty_when_nmcli_fails(self):
        with patch(
            "subprocess.check_output",
            side_effect=subprocess.CalledProcessError(10, "nmcli"),
        ):
            assert _dns_from_networkmanager() == []


# ---------------------------------------------------------------------------
# _dns_from_scutil (macOS)
# ---------------------------------------------------------------------------


class TestDnsFromScutil:
    def test_returns_empty_on_linux(self):
        with patch("platform.system", return_value="Linux"):
            assert _dns_from_scutil() == []

    def test_parses_nameserver_lines_on_darwin(self):
        output = (
            "resolver #1\n"
            "  nameserver[0] : 192.168.1.1\n"
            "  nameserver[1] : 8.8.8.8\n"
        )
        with patch("platform.system", return_value="Darwin"), \
             patch("subprocess.check_output", return_value=output):
            servers = _dns_from_scutil()
        assert "192.168.1.1" in servers
        assert "8.8.8.8" in servers

    def test_filters_loopback_on_darwin(self):
        output = "  nameserver[0] : 127.0.0.1\n  nameserver[1] : 10.0.0.1\n"
        with patch("platform.system", return_value="Darwin"), \
             patch("subprocess.check_output", return_value=output):
            servers = _dns_from_scutil()
        assert "127.0.0.1" not in servers
        assert "10.0.0.1" in servers

    def test_returns_empty_when_scutil_fails(self):
        with patch("platform.system", return_value="Darwin"), \
             patch(
                 "subprocess.check_output",
                 side_effect=subprocess.CalledProcessError(1, "scutil"),
             ):
            assert _dns_from_scutil() == []


# ---------------------------------------------------------------------------
# check_dns_server
# ---------------------------------------------------------------------------


class TestCheckDnsServer:
    def test_returns_true_when_server_responds(self):
        mock_sock = MagicMock()
        mock_sock.recvfrom.return_value = (b"\x12\x34\x81\x80" + b"\x00" * 8, ("1.1.1.1", 53))
        with patch("socket.socket", return_value=mock_sock):
            assert check_dns_server("1.1.1.1") is True

    def test_returns_false_on_timeout(self):
        mock_sock = MagicMock()
        mock_sock.recvfrom.side_effect = socket.timeout
        with patch("socket.socket", return_value=mock_sock):
            assert check_dns_server("10.0.0.1") is False

    def test_returns_false_on_os_error(self):
        mock_sock = MagicMock()
        mock_sock.sendto.side_effect = OSError("network unreachable")
        with patch("socket.socket", return_value=mock_sock):
            assert check_dns_server("10.0.0.1") is False

    def test_socket_is_always_closed(self):
        mock_sock = MagicMock()
        mock_sock.recvfrom.side_effect = socket.timeout
        with patch("socket.socket", return_value=mock_sock):
            check_dns_server("10.0.0.1")
        mock_sock.close.assert_called_once()


# ---------------------------------------------------------------------------
# get_dns_servers (integration of all sub-methods)
# ---------------------------------------------------------------------------


class TestGetDnsServers:
    def test_deduplicates_across_sources(self):
        with patch("cli.system._dns_from_resolv_conf", return_value=["8.8.8.8", "1.1.1.1"]), \
             patch("cli.system._dns_from_resolvectl", return_value=["8.8.8.8"]), \
             patch("cli.system._dns_from_networkmanager", return_value=[]), \
             patch("cli.system._dns_from_scutil", return_value=[]):
            servers = get_dns_servers()
        assert servers.count("8.8.8.8") == 1
        assert "1.1.1.1" in servers

    def test_preserves_order_first_seen(self):
        with patch("cli.system._dns_from_resolv_conf", return_value=["192.168.1.1"]), \
             patch("cli.system._dns_from_resolvectl", return_value=["8.8.8.8"]), \
             patch("cli.system._dns_from_networkmanager", return_value=[]), \
             patch("cli.system._dns_from_scutil", return_value=[]):
            servers = get_dns_servers()
        assert servers[0] == "192.168.1.1"
        assert servers[1] == "8.8.8.8"

    def test_returns_empty_list_when_all_fail(self):
        with patch("cli.system._dns_from_resolv_conf", return_value=[]), \
             patch("cli.system._dns_from_resolvectl", return_value=[]), \
             patch("cli.system._dns_from_networkmanager", return_value=[]), \
             patch("cli.system._dns_from_scutil", return_value=[]):
            assert get_dns_servers() == []


# ---------------------------------------------------------------------------
# _get_interfaces_fallback (ip addr show parser)
# ---------------------------------------------------------------------------


class TestGetInterfacesFallback:
    IP_ADDR_OUTPUT = (
        "1: lo: <LOOPBACK,UP> mtu 65536\n"
        "    inet 127.0.0.1/8 scope host lo\n"
        "2: eth0: <BROADCAST,MULTICAST,UP> mtu 1500\n"
        "    inet 192.168.1.100/24 brd 192.168.1.255 scope global eth0\n"
        "3: wg0: <POINTOPOINT,UP> mtu 1420\n"
        "    inet 10.99.0.2/32 scope global wg0\n"
    )

    def test_parses_interfaces_from_ip_addr_show(self):
        with patch("subprocess.check_output", return_value=self.IP_ADDR_OUTPUT):
            ifaces = _get_interfaces_fallback(include_virtual=True)
        names = [i.name for i in ifaces]
        assert "eth0" in names

    def test_filters_virtual_when_requested(self):
        with patch("subprocess.check_output", return_value=self.IP_ADDR_OUTPUT):
            ifaces = _get_interfaces_fallback(include_virtual=False)
        names = [i.name for i in ifaces]
        assert "lo" not in names
        assert "wg0" not in names

    def test_extracts_cidrs_correctly(self):
        with patch("subprocess.check_output", return_value=self.IP_ADDR_OUTPUT):
            ifaces = _get_interfaces_fallback(include_virtual=True)
        eth0 = next(i for i in ifaces if i.name == "eth0")
        assert "192.168.1.0/24" in eth0.cidrs

    def test_returns_empty_when_ip_not_found(self):
        with patch("subprocess.check_output", side_effect=FileNotFoundError):
            assert _get_interfaces_fallback(include_virtual=False) == []

    def test_returns_empty_when_ip_fails(self):
        with patch(
            "subprocess.check_output",
            side_effect=subprocess.CalledProcessError(1, "ip"),
        ):
            assert _get_interfaces_fallback(include_virtual=False) == []

    def test_handles_interface_with_no_ipv4(self):
        output = (
            "2: eth0: <BROADCAST,MULTICAST,UP>\n"
            "    inet6 fe80::1/64 scope link eth0\n"
        )
        with patch("subprocess.check_output", return_value=output):
            ifaces = _get_interfaces_fallback(include_virtual=True)
        # eth0 has no IPv4 → should not appear
        assert not any(i.name == "eth0" for i in ifaces)

    def test_cgnat_address_normalized_to_100_64_slash_10(self):
        """A CGNAT IP (100.64.x.x–100.127.x.x) with a wide prefix should be normalized."""
        output = (
            "2: eth0: <BROADCAST,MULTICAST,UP> mtu 1500\n"
            "    inet 192.168.1.100/24 brd 192.168.1.255 scope global eth0\n"
            "5: utun100: <POINTOPOINT,UP> mtu 1280\n"
            "    inet 100.78.95.254/8 scope global utun100\n"
        )
        with patch("subprocess.check_output", return_value=output):
            ifaces = _get_interfaces_fallback(include_virtual=True)
        utun = next(i for i in ifaces if i.name == "utun100")
        assert utun.cidrs == ["100.64.0.0/10"]


# ---------------------------------------------------------------------------
# validate_cidrs_input
# ---------------------------------------------------------------------------


from cli.system import validate_cidrs_input, validate_name_input


class TestValidateNameInput:
    def test_valid_name(self):
        assert validate_name_input("my-beacon") is True

    def test_valid_name_with_underscore(self):
        assert validate_name_input("my_beacon_01") is True

    def test_empty_name_returns_error(self):
        result = validate_name_input("")
        assert isinstance(result, str)
        assert "empty" in result.lower()

    def test_name_with_space_returns_error(self):
        result = validate_name_input("my beacon")
        assert isinstance(result, str)

    def test_name_with_special_chars_returns_error(self):
        result = validate_name_input("beacon!")
        assert isinstance(result, str)

    def test_name_with_dot_returns_error(self):
        result = validate_name_input("beacon.1")
        assert isinstance(result, str)


class TestValidateCidrsInput:
    def test_valid_private_cidr(self):
        assert validate_cidrs_input("10.0.0.0/8") is True

    def test_valid_multiple_cidrs(self):
        assert validate_cidrs_input("10.0.0.0/8, 192.168.1.0/24") is True

    def test_bare_ip_returns_error(self):
        result = validate_cidrs_input("10.0.0.1")
        assert isinstance(result, str)
        assert "/24" in result  # suggests adding prefix

    def test_invalid_cidr_returns_error(self):
        result = validate_cidrs_input("not-a-cidr/8")
        assert isinstance(result, str)

    def test_public_cidr_returns_error(self):
        result = validate_cidrs_input("8.8.8.0/24")
        assert isinstance(result, str)
        assert "private" in result.lower()

    def test_empty_input_returns_error(self):
        result = validate_cidrs_input("")
        assert isinstance(result, str)
        assert "required" in result.lower()

    def test_rfc6598_is_accepted(self):
        assert validate_cidrs_input("100.64.0.0/10") is True


# ---------------------------------------------------------------------------
# interface_exists
# ---------------------------------------------------------------------------


class TestInterfaceExists:
    def test_returns_true_for_existing_interface(self):
        with patch("socket.if_nametoindex", return_value=1):
            assert interface_exists("eth0") is True

    def test_returns_false_for_missing_interface(self):
        with patch("socket.if_nametoindex", side_effect=OSError("no such device")):
            assert interface_exists("lmao") is False


# ---------------------------------------------------------------------------
# get_routes_via_interface
# ---------------------------------------------------------------------------


class TestGetRoutesViaInterface:
    IP_ROUTE_OUTPUT = (
        "default via 192.168.1.1 dev eth0 proto dhcp\n"
        "192.168.1.0/24 dev eth0 proto kernel scope link src 192.168.1.5\n"
        "10.100.0.0/16 via 192.168.1.254 dev eth0 proto static\n"
        "172.16.0.0/12 via 192.168.1.254 dev eth1 proto static\n"  # different iface
    )

    def test_returns_routes_for_interface(self):
        with patch("subprocess.check_output", return_value=self.IP_ROUTE_OUTPUT):
            routes = get_routes_via_interface("eth0")
        assert "192.168.1.0/24" in routes
        assert "10.100.0.0/16" in routes

    def test_excludes_other_interfaces(self):
        with patch("subprocess.check_output", return_value=self.IP_ROUTE_OUTPUT):
            routes = get_routes_via_interface("eth0")
        assert "172.16.0.0/12" not in routes

    def test_excludes_default_route(self):
        with patch("subprocess.check_output", return_value=self.IP_ROUTE_OUTPUT):
            routes = get_routes_via_interface("eth0")
        assert "default" not in routes
        assert not any("0.0.0.0" in r for r in routes)

    def test_deduplicates_routes(self):
        output = (
            "10.0.0.0/8 via 192.168.1.1 dev eth0\n"
            "10.0.0.0/8 via 192.168.1.2 dev eth0\n"  # duplicate prefix
        )
        with patch("subprocess.check_output", return_value=output):
            routes = get_routes_via_interface("eth0")
        assert routes.count("10.0.0.0/8") == 1

    def test_returns_empty_when_ip_not_found(self):
        with patch("subprocess.check_output", side_effect=FileNotFoundError):
            assert get_routes_via_interface("eth0") == []

    def test_returns_empty_when_ip_fails(self):
        with patch(
            "subprocess.check_output",
            side_effect=subprocess.CalledProcessError(1, "ip"),
        ):
            assert get_routes_via_interface("eth0") == []
