"""Tests for wireguard.py - config I/O and wg-quick operations."""

from __future__ import annotations

from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest

from cli import wireguard as wg
from cli.exceptions import BeaconSystemError



SAMPLE_WG_CONFIG = """[Interface]
PrivateKey = abc123privatekey
Address = 10.99.0.2/32
ListenPort = 51820

[Peer]
PublicKey = xyz789publickey
AllowedIPs = 0.0.0.0/0
Endpoint = 1.2.3.4:5000
PersistentKeepalive = 25
"""


# ---------------------------------------------------------------------------
# write_config / remove_config
# ---------------------------------------------------------------------------


class TestWriteConfig:
    def test_writes_config_to_etc_wireguard(self, tmp_path, monkeypatch):
        monkeypatch.setattr(wg, "WG_CONF_DIR", tmp_path)
        path = wg.write_config("wg0", SAMPLE_WG_CONFIG)
        assert path.exists()
        assert path.read_text() == SAMPLE_WG_CONFIG

    def test_config_has_restricted_permissions(self, tmp_path, monkeypatch):
        monkeypatch.setattr(wg, "WG_CONF_DIR", tmp_path)
        path = wg.write_config("wg0", SAMPLE_WG_CONFIG)
        import stat
        mode = stat.S_IMODE(path.stat().st_mode)
        assert mode == 0o600

    def test_config_filename_matches_interface(self, tmp_path, monkeypatch):
        monkeypatch.setattr(wg, "WG_CONF_DIR", tmp_path)
        path = wg.write_config("wg1", SAMPLE_WG_CONFIG)
        assert path.name == "wg1.conf"


class TestRemoveConfig:
    def test_removes_existing_config(self, tmp_path, monkeypatch):
        monkeypatch.setattr(wg, "WG_CONF_DIR", tmp_path)
        path = wg.write_config("wg0", SAMPLE_WG_CONFIG)
        wg.remove_config("wg0")
        assert not path.exists()

    def test_no_error_when_config_missing(self, tmp_path, monkeypatch):
        monkeypatch.setattr(wg, "WG_CONF_DIR", tmp_path)
        wg.remove_config("wg99")  # must not raise


# ---------------------------------------------------------------------------
# up / down
# ---------------------------------------------------------------------------


class TestUp:
    def test_calls_wg_quick_up(self, tmp_path, monkeypatch):
        monkeypatch.setattr(wg, "WG_CONF_DIR", tmp_path)
        with patch("subprocess.run") as mock_run:
            mock_run.return_value = MagicMock(returncode=0)
            wg.up("wg0")
        mock_run.assert_called_once_with(
            ["wg-quick", "up", str(tmp_path / "wg0.conf")], capture_output=True, text=True
        )

    def test_raises_on_nonzero_returncode(self, tmp_path, monkeypatch):
        monkeypatch.setattr(wg, "WG_CONF_DIR", tmp_path)
        with patch("subprocess.run") as mock_run:
            mock_run.return_value = MagicMock(
                returncode=1, stderr="Operation not permitted", stdout=""
            )
            with pytest.raises(BeaconSystemError, match="Operation not permitted"):
                wg.up("wg0")

    def test_raises_when_wg_quick_not_found(self, tmp_path, monkeypatch):
        monkeypatch.setattr(wg, "WG_CONF_DIR", tmp_path)
        with patch("subprocess.run", side_effect=FileNotFoundError):
            with pytest.raises(BeaconSystemError, match="wg-quick not found"):
                wg.up("wg0")


class TestDown:
    def test_calls_wg_quick_down_when_conf_exists(self, tmp_path, monkeypatch):
        monkeypatch.setattr(wg, "WG_CONF_DIR", tmp_path)
        (tmp_path / "wg0.conf").write_text("[Interface]\n")
        with patch("subprocess.run") as mock_run:
            mock_run.return_value = MagicMock(returncode=0)
            wg.down("wg0")
        mock_run.assert_called_once_with(
            ["wg-quick", "down", str(tmp_path / "wg0.conf")], capture_output=True, text=True
        )

    def test_falls_back_to_ip_link_delete_when_conf_missing(self, tmp_path, monkeypatch):
        monkeypatch.setattr(wg, "WG_CONF_DIR", tmp_path)
        # No conf file written → fallback path
        with patch("subprocess.run") as mock_run:
            mock_run.return_value = MagicMock(returncode=0)
            wg.down("wg0")
        mock_run.assert_called_once_with(
            ["ip", "link", "delete", "wg0"], capture_output=True, check=True
        )


# ---------------------------------------------------------------------------
# is_up / get_status
# ---------------------------------------------------------------------------


class TestIsUp:
    def test_returns_true_when_interface_active(self):
        wg_output = "interface: wg0\n  public key: ...\n"
        with patch("subprocess.check_output", return_value=wg_output):
            assert wg.is_up("wg0") is True

    def test_returns_false_when_command_fails(self):
        import subprocess
        with patch("subprocess.check_output", side_effect=subprocess.CalledProcessError(1, "wg")):
            assert wg.is_up("wg0") is False

    def test_returns_false_when_wg_not_found(self):
        with patch("subprocess.check_output", side_effect=FileNotFoundError):
            assert wg.is_up("wg0") is False


class TestGetStatus:
    def test_parses_active_interface(self):
        wg_output = (
            "interface: wg0\n"
            "  public key: abc\n\n"
            "peer: xyz\n"
            "  latest handshake: 1 minute, 30 seconds ago\n"
        )
        with patch("subprocess.check_output", return_value=wg_output):
            status = wg.get_status("wg0")
        assert status["active"] is True
        assert status["peers"] == 1
        assert "1 minute" in status["latest_handshake"]

    def test_returns_inactive_on_error(self):
        import subprocess
        with patch("subprocess.check_output", side_effect=subprocess.CalledProcessError(1, "wg")):
            status = wg.get_status("wg0")
        assert status["active"] is False
        assert status["peers"] == 0
        assert status["latest_handshake"] is None


# ---------------------------------------------------------------------------
# down - error path when ip link delete fails
# ---------------------------------------------------------------------------


class TestDownErrors:
    def test_raises_when_ip_link_delete_fails(self, tmp_path, monkeypatch):
        monkeypatch.setattr(wg, "WG_CONF_DIR", tmp_path)
        import subprocess
        with patch("subprocess.run", side_effect=subprocess.CalledProcessError(1, "ip")):
            with pytest.raises(BeaconSystemError, match="Failed to bring down"):
                wg.down("wg0")

    def test_raises_when_ip_not_found(self, tmp_path, monkeypatch):
        monkeypatch.setattr(wg, "WG_CONF_DIR", tmp_path)
        with patch("subprocess.run", side_effect=FileNotFoundError):
            with pytest.raises(BeaconSystemError, match="Failed to bring down"):
                wg.down("wg0")


# ---------------------------------------------------------------------------
# get_active_interfaces
# ---------------------------------------------------------------------------


class TestGetActiveInterfaces:
    def test_returns_interface_names(self):
        ip_output = (
            "4: wg0@NONE: <POINTOPOINT,NOARP,UP,LOWER_UP> ...\n"
            "5: wg1@NONE: <POINTOPOINT,NOARP,UP,LOWER_UP> ...\n"
        )
        with patch("subprocess.check_output", return_value=ip_output):
            interfaces = wg.get_active_interfaces()
        assert interfaces == ["wg0", "wg1"]

    def test_returns_empty_list_when_no_interfaces(self):
        with patch("subprocess.check_output", return_value=""):
            assert wg.get_active_interfaces() == []

    def test_returns_empty_list_on_command_error(self):
        import subprocess
        with patch(
            "subprocess.check_output",
            side_effect=subprocess.CalledProcessError(1, "ip"),
        ):
            assert wg.get_active_interfaces() == []

    def test_returns_empty_list_when_ip_not_found(self):
        with patch("subprocess.check_output", side_effect=FileNotFoundError):
            assert wg.get_active_interfaces() == []


# ---------------------------------------------------------------------------
# teardown_all
# ---------------------------------------------------------------------------


class TestTeardownAll:
    def test_deletes_all_active_interfaces(self):
        with patch("cli.wireguard.get_active_interfaces", return_value=["wg0", "wg1"]), \
             patch("subprocess.run") as mock_run:
            mock_run.return_value = MagicMock(returncode=0)
            wg.teardown_all()
        assert mock_run.call_count == 2
        calls = [c[0][0] for c in mock_run.call_args_list]
        assert ["ip", "link", "delete", "wg0"] in calls
        assert ["ip", "link", "delete", "wg1"] in calls

    def test_does_nothing_when_no_active_interfaces(self):
        with patch("cli.wireguard.get_active_interfaces", return_value=[]), \
             patch("subprocess.run") as mock_run:
            wg.teardown_all()
        mock_run.assert_not_called()

    def test_continues_when_one_interface_fails(self):
        import subprocess
        with patch("cli.wireguard.get_active_interfaces", return_value=["wg0", "wg1"]), \
             patch("subprocess.run") as mock_run:
            mock_run.side_effect = [
                subprocess.CalledProcessError(1, "ip"),
                MagicMock(returncode=0),
            ]
            wg.teardown_all()  # should not raise
        assert mock_run.call_count == 2


# ---------------------------------------------------------------------------
# enable_ip_forwarding
# ---------------------------------------------------------------------------


class TestEnableIpForwarding:
    def test_calls_sysctl(self):
        with patch("subprocess.run") as mock_run:
            mock_run.return_value = MagicMock(returncode=0)
            wg.enable_ip_forwarding()
        mock_run.assert_called_once_with(
            ["sysctl", "-w", "net.ipv4.ip_forward=1"],
            check=True,
            capture_output=True,
        )

    def test_does_not_raise_on_failure(self):
        import subprocess
        with patch("subprocess.run", side_effect=subprocess.CalledProcessError(1, "sysctl")):
            wg.enable_ip_forwarding()  # must not raise

    def test_does_not_raise_when_sysctl_not_found(self):
        with patch("subprocess.run", side_effect=FileNotFoundError):
            wg.enable_ip_forwarding()  # must not raise


# ---------------------------------------------------------------------------
# is_installed
# ---------------------------------------------------------------------------


class TestIsInstalled:
    def test_true_when_both_commands_found(self):
        with patch("cli.wireguard.shutil.which", return_value="/usr/bin/wg"):
            assert wg.is_installed() is True

    def test_false_when_command_missing(self):
        with patch("cli.wireguard.shutil.which", return_value=None):
            assert wg.is_installed() is False
