from __future__ import annotations

import json
import subprocess
import types

import pytest

from pymulator.core import can as can_module


# ---------------------------------------------------------------------------
# _configure_socketcan_interface unit tests — direct ip happy path
# ---------------------------------------------------------------------------


def test_configure_classic_bitrate(monkeypatch: pytest.MonkeyPatch) -> None:
    """Classic mode produces the expected ip-link sequence (no escalation needed)."""
    calls: list[list[str]] = []

    def _fake_run(cmd, **_kw):
        calls.append(cmd)
        return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")

    monkeypatch.setattr(can_module.subprocess, "run", _fake_run)

    can_module._configure_socketcan_interface("can0", 500000)

    assert calls == [
        ["ip", "-json", "-detail", "link", "show", "can0"],
        ["ip", "link", "set", "can0", "down"],
        [
            "ip", "link", "set", "can0", "type", "can",
            "bitrate", "500000",
            "listen-only", "off",
        ],
        ["ip", "link", "set", "can0", "up"],
    ]


def test_configure_fd_bitrate(monkeypatch: pytest.MonkeyPatch) -> None:
    """FD mode includes dbitrate and fd on."""
    calls: list[list[str]] = []

    def _fake_run(cmd, **_kw):
        calls.append(cmd)
        return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")

    monkeypatch.setattr(can_module.subprocess, "run", _fake_run)

    can_module._configure_socketcan_interface("can0", 500000, fd=True, data_bitrate=8000000)

    config_cmd = calls[2]
    assert "dbitrate" in config_cmd
    assert "8000000" in config_cmd
    assert "fd" in config_cmd
    assert config_cmd[config_cmd.index("fd") + 1] == "on"


def test_configure_listen_only(monkeypatch: pytest.MonkeyPatch) -> None:
    """listen_only=True appends 'listen-only on' to the config command."""
    calls: list[list[str]] = []

    def _fake_run(cmd, **_kw):
        calls.append(cmd)
        return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")

    monkeypatch.setattr(can_module.subprocess, "run", _fake_run)

    can_module._configure_socketcan_interface("can0", 500000, listen_only=True)

    config_cmd = calls[2]
    idx = config_cmd.index("listen-only")
    assert config_cmd[idx + 1] == "on"


def test_configure_listen_only_off(monkeypatch: pytest.MonkeyPatch) -> None:
    """listen_only=False appends 'listen-only off'."""
    calls: list[list[str]] = []

    def _fake_run(cmd, **_kw):
        calls.append(cmd)
        return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")

    monkeypatch.setattr(can_module.subprocess, "run", _fake_run)

    can_module._configure_socketcan_interface("can0", 500000, listen_only=False)

    config_cmd = calls[2]
    idx = config_cmd.index("listen-only")
    assert config_cmd[idx + 1] == "off"


def test_configure_ip_not_found(monkeypatch: pytest.MonkeyPatch) -> None:
    """FileNotFoundError from subprocess raises UnsupportedConfiguration."""

    def _fake_run(cmd, **_kw):
        raise FileNotFoundError("ip not found")

    monkeypatch.setattr(can_module.subprocess, "run", _fake_run)

    with pytest.raises(can_module.UnsupportedConfiguration, match="'ip' command not found"):
        can_module._configure_socketcan_interface("can0", 500000)


def test_configure_generic_failure(monkeypatch: pytest.MonkeyPatch) -> None:
    """Non-zero exit with non-permission stderr includes the error text (no escalation)."""

    def _fake_run(cmd, **_kw):
        return subprocess.CompletedProcess(
            cmd, 1, stdout="", stderr='Cannot find device "can99"'
        )

    monkeypatch.setattr(can_module.subprocess, "run", _fake_run)

    with pytest.raises(
        can_module.UnsupportedConfiguration, match='Cannot find device "can99"'
    ):
        can_module._configure_socketcan_interface("can99", 500000)


# ---------------------------------------------------------------------------
# Privilege escalation tests — pkexec / sudo helper
# ---------------------------------------------------------------------------


def test_eperm_escalates_to_pkexec_helper(monkeypatch: pytest.MonkeyPatch) -> None:
    """EPERM on direct ip triggers a single pkexec <helper> call."""
    calls: list[list[str]] = []
    helper_path = "/usr/local/libexec/ecumulator-socketcan-configure"

    monkeypatch.setattr(can_module, "_find_helper_script", lambda: helper_path)

    def _fake_run(cmd, **_kw):
        calls.append(cmd)
        if cmd[0] == "ip":
            return subprocess.CompletedProcess(
                cmd, 2, stdout="", stderr="RTNETLINK answers: Operation not permitted"
            )
        # pkexec helper succeeds
        return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")

    monkeypatch.setattr(can_module.subprocess, "run", _fake_run)

    can_module._configure_socketcan_interface("can0", 500000)

    # calls[0] = ip -json probe (also returns EPERM, so match check returns False),
    # calls[1] = first ip link set (EPERM), calls[2] = pkexec helper.
    assert calls[0][:2] == ["ip", "-json"]
    assert calls[1][0] == "ip"
    assert calls[2][0] == "pkexec"
    assert calls[2][1] == helper_path
    assert "can0" in calls[2]
    assert "500000" in calls[2]
    # 3 calls total: 1 ip -json probe + 1 direct ip + 1 pkexec.
    assert len(calls) == 3


def test_permission_error_escalates_to_pkexec_helper(monkeypatch: pytest.MonkeyPatch) -> None:
    """PermissionError (Python-level) on direct ip triggers pkexec escalation."""
    calls: list[list[str]] = []
    helper_path = "/usr/local/libexec/ecumulator-socketcan-configure"

    monkeypatch.setattr(can_module, "_find_helper_script", lambda: helper_path)

    def _fake_run(cmd, **_kw):
        calls.append(cmd)
        if cmd[0] == "ip":
            raise PermissionError("not allowed")
        return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")

    monkeypatch.setattr(can_module.subprocess, "run", _fake_run)

    can_module._configure_socketcan_interface("can0", 500000)

    assert calls[0][:2] == ["ip", "-json"]
    assert calls[1][0] == "ip"
    assert calls[2][0] == "pkexec"
    assert len(calls) == 3


def test_pkexec_not_found_falls_back_to_sudo_helper(monkeypatch: pytest.MonkeyPatch) -> None:
    """When pkexec is not installed, falls back to sudo -n <helper>."""
    calls: list[list[str]] = []
    helper_path = "/usr/local/libexec/ecumulator-socketcan-configure"

    monkeypatch.setattr(can_module, "_find_helper_script", lambda: helper_path)

    def _fake_run(cmd, **_kw):
        calls.append(cmd)
        if cmd[0] == "ip":
            return subprocess.CompletedProcess(
                cmd, 2, stdout="", stderr="RTNETLINK answers: Operation not permitted"
            )
        if cmd[0] == "pkexec":
            raise FileNotFoundError("pkexec not found")
        # sudo -n helper succeeds
        return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")

    monkeypatch.setattr(can_module.subprocess, "run", _fake_run)

    can_module._configure_socketcan_interface("can0", 500000)

    assert calls[0][:2] == ["ip", "-json"]
    assert calls[1][0] == "ip"
    assert calls[2][0] == "pkexec"
    assert calls[3][:2] == ["sudo", "-n"]
    assert calls[3][2] == helper_path


def test_pkexec_fails_falls_back_to_sudo_helper(monkeypatch: pytest.MonkeyPatch) -> None:
    """When pkexec exits non-zero (e.g. dismissed), falls back to sudo -n."""
    calls: list[list[str]] = []
    helper_path = "/usr/local/libexec/ecumulator-socketcan-configure"

    monkeypatch.setattr(can_module, "_find_helper_script", lambda: helper_path)

    def _fake_run(cmd, **_kw):
        calls.append(cmd)
        if cmd[0] == "ip":
            return subprocess.CompletedProcess(
                cmd, 2, stdout="", stderr="RTNETLINK answers: Operation not permitted"
            )
        if cmd[0] == "pkexec":
            return subprocess.CompletedProcess(cmd, 126, stdout="", stderr="")
        # sudo helper succeeds
        return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")

    monkeypatch.setattr(can_module.subprocess, "run", _fake_run)

    can_module._configure_socketcan_interface("can0", 500000)

    assert calls[0][:2] == ["ip", "-json"]
    assert calls[1][0] == "ip"
    assert calls[2][0] == "pkexec"
    assert calls[3][:2] == ["sudo", "-n"]


def test_all_escalation_paths_fail(monkeypatch: pytest.MonkeyPatch) -> None:
    """When direct ip, pkexec, and sudo all fail, raises clear error."""
    helper_path = "/usr/local/libexec/ecumulator-socketcan-configure"

    monkeypatch.setattr(can_module, "_find_helper_script", lambda: helper_path)

    def _fake_run(cmd, **_kw):
        if cmd[0] == "ip":
            return subprocess.CompletedProcess(
                cmd, 2, stdout="", stderr="RTNETLINK answers: Operation not permitted"
            )
        if cmd[0] == "pkexec":
            return subprocess.CompletedProcess(cmd, 126, stdout="", stderr="")
        # sudo also fails
        return subprocess.CompletedProcess(
            cmd, 1, stdout="", stderr="sudo: a password is required"
        )

    monkeypatch.setattr(can_module.subprocess, "run", _fake_run)

    with pytest.raises(can_module.UnsupportedConfiguration, match="Insufficient permissions"):
        can_module._configure_socketcan_interface("can0", 500000)


def test_no_helper_found_raises_error(monkeypatch: pytest.MonkeyPatch) -> None:
    """When helper script cannot be found, raises clear error after EPERM."""
    monkeypatch.setattr(can_module, "_find_helper_script", lambda: None)

    def _fake_run(cmd, **_kw):
        return subprocess.CompletedProcess(
            cmd, 2, stdout="", stderr="RTNETLINK answers: Operation not permitted"
        )

    monkeypatch.setattr(can_module.subprocess, "run", _fake_run)

    with pytest.raises(can_module.UnsupportedConfiguration, match="Insufficient permissions"):
        can_module._configure_socketcan_interface("can0", 500000)


def test_eperm_pkexec_helper_passes_fd_args(monkeypatch: pytest.MonkeyPatch) -> None:
    """pkexec helper receives --fd and --listen-only flags correctly."""
    calls: list[list[str]] = []
    helper_path = "/usr/local/libexec/ecumulator-socketcan-configure"

    monkeypatch.setattr(can_module, "_find_helper_script", lambda: helper_path)

    def _fake_run(cmd, **_kw):
        calls.append(cmd)
        if cmd[0] == "ip":
            return subprocess.CompletedProcess(
                cmd, 2, stdout="", stderr="RTNETLINK answers: Operation not permitted"
            )
        return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")

    monkeypatch.setattr(can_module.subprocess, "run", _fake_run)

    can_module._configure_socketcan_interface(
        "can0", 500000, fd=True, data_bitrate=2000000, listen_only=True
    )

    pkexec_cmd = calls[2]
    assert pkexec_cmd[0] == "pkexec"
    assert pkexec_cmd[1] == helper_path
    assert "can0" in pkexec_cmd
    assert "500000" in pkexec_cmd
    assert "--fd" in pkexec_cmd
    assert "2000000" in pkexec_cmd
    assert "--listen-only" in pkexec_cmd


# ---------------------------------------------------------------------------
# Integration with SocketCANInterface.open()
# ---------------------------------------------------------------------------


def test_open_calls_configure_for_socketcan(monkeypatch: pytest.MonkeyPatch) -> None:
    """SocketCANInterface.open() calls _configure_socketcan_interface for socketcan backend."""
    monkeypatch.setattr(can_module.sys, "platform", "linux")

    config_calls: list[tuple] = []

    def _fake_configure(channel, bitrate, *, fd=False, data_bitrate=None, listen_only=False):
        config_calls.append((channel, bitrate, fd, data_bitrate, listen_only))

    monkeypatch.setattr(can_module, "_configure_socketcan_interface", _fake_configure)

    class _FakeCan:
        class Message:  # pragma: no cover - structural stub
            def __init__(self, **kwargs):
                self.kwargs = kwargs

        @staticmethod
        def Bus(**kwargs):
            return types.SimpleNamespace(**kwargs)

    monkeypatch.setattr(can_module, "python_can", _FakeCan)

    wires = can_module.SocketCANInterface("can0")
    wires.open(500000, fd=True, data_bitrate=2000000)

    assert config_calls == [("can0", 500000, True, 2000000, False)]


def test_open_does_not_call_configure_for_gs_usb(monkeypatch: pytest.MonkeyPatch) -> None:
    """SocketCANInterface.open() does NOT call _configure_socketcan_interface for gs_usb."""
    monkeypatch.setattr(can_module.sys, "platform", "darwin")
    monkeypatch.setattr(can_module, "_should_apply_macos_gs_usb_shim", lambda: False)

    config_calls: list[tuple] = []

    def _fake_configure(channel, bitrate, *, fd=False, data_bitrate=None, listen_only=False):
        config_calls.append((channel, bitrate, fd, data_bitrate, listen_only))

    monkeypatch.setattr(can_module, "_configure_socketcan_interface", _fake_configure)

    class _FakeCan:
        class Message:  # pragma: no cover - structural stub
            def __init__(self, **kwargs):
                self.kwargs = kwargs

        @staticmethod
        def Bus(**kwargs):
            return types.SimpleNamespace(**kwargs)

    monkeypatch.setattr(can_module, "python_can", _FakeCan)
    monkeypatch.setattr(can_module.importlib.util, "find_spec", lambda _name: True)

    wires = can_module.SocketCANInterface("gs_usb:0")
    wires.open(500000)

    assert config_calls == []


# ---------------------------------------------------------------------------
# _current_interface_matches — skip redundant reconfiguration
# ---------------------------------------------------------------------------


def _ip_json_output(
    operstate: str = "UP",
    bitrate: int = 500000,
    data_bitrate: int | None = None,
) -> str:
    """Build a realistic ``ip -json -detail link show`` response."""
    info_data: dict = {
        "bittiming": {"bitrate": bitrate},
    }
    if data_bitrate is not None:
        info_data["data_bittiming"] = {"bitrate": data_bitrate}
    return json.dumps([{
        "ifname": "can0",
        "operstate": operstate,
        "linkinfo": {"info_kind": "can", "info_data": info_data},
    }])


def test_configure_skips_when_already_configured(monkeypatch: pytest.MonkeyPatch) -> None:
    """When interface is already up with matching bitrate, skip reconfiguration."""
    calls: list[list[str]] = []

    def _fake_run(cmd, **_kw):
        calls.append(cmd)
        if cmd[:2] == ["ip", "-json"]:
            return subprocess.CompletedProcess(
                cmd, 0, stdout=_ip_json_output(operstate="UP", bitrate=500000), stderr=""
            )
        return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")

    monkeypatch.setattr(can_module.subprocess, "run", _fake_run)

    can_module._configure_socketcan_interface("can0", 500000)

    # Only the ip -json probe should have run — no ip link set commands.
    assert len(calls) == 1
    assert calls[0][:2] == ["ip", "-json"]


def test_configure_skips_when_fd_already_configured(monkeypatch: pytest.MonkeyPatch) -> None:
    """FD interface with matching bitrate and data_bitrate skips reconfiguration."""
    calls: list[list[str]] = []

    def _fake_run(cmd, **_kw):
        calls.append(cmd)
        if cmd[:2] == ["ip", "-json"]:
            return subprocess.CompletedProcess(
                cmd, 0,
                stdout=_ip_json_output(operstate="UP", bitrate=500000, data_bitrate=2000000),
                stderr="",
            )
        return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")

    monkeypatch.setattr(can_module.subprocess, "run", _fake_run)

    can_module._configure_socketcan_interface("can0", 500000, fd=True, data_bitrate=2000000)

    assert len(calls) == 1
    assert calls[0][:2] == ["ip", "-json"]


def test_configure_proceeds_when_bitrate_differs(monkeypatch: pytest.MonkeyPatch) -> None:
    """When interface is up but bitrate differs, reconfiguration proceeds."""
    calls: list[list[str]] = []

    def _fake_run(cmd, **_kw):
        calls.append(cmd)
        if cmd[:2] == ["ip", "-json"]:
            return subprocess.CompletedProcess(
                cmd, 0, stdout=_ip_json_output(operstate="UP", bitrate=250000), stderr=""
            )
        return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")

    monkeypatch.setattr(can_module.subprocess, "run", _fake_run)

    can_module._configure_socketcan_interface("can0", 500000)

    # ip -json probe + 3 ip link set commands.
    assert len(calls) == 4
    assert calls[0][:2] == ["ip", "-json"]
    assert calls[1] == ["ip", "link", "set", "can0", "down"]


def test_configure_proceeds_when_data_bitrate_differs(monkeypatch: pytest.MonkeyPatch) -> None:
    """FD interface with mismatched data_bitrate triggers reconfiguration."""
    calls: list[list[str]] = []

    def _fake_run(cmd, **_kw):
        calls.append(cmd)
        if cmd[:2] == ["ip", "-json"]:
            return subprocess.CompletedProcess(
                cmd, 0,
                stdout=_ip_json_output(operstate="UP", bitrate=500000, data_bitrate=4000000),
                stderr="",
            )
        return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")

    monkeypatch.setattr(can_module.subprocess, "run", _fake_run)

    can_module._configure_socketcan_interface("can0", 500000, fd=True, data_bitrate=2000000)

    assert len(calls) == 4


def test_configure_proceeds_when_interface_down(monkeypatch: pytest.MonkeyPatch) -> None:
    """When interface is down, reconfiguration proceeds."""
    calls: list[list[str]] = []

    def _fake_run(cmd, **_kw):
        calls.append(cmd)
        if cmd[:2] == ["ip", "-json"]:
            return subprocess.CompletedProcess(
                cmd, 0, stdout=_ip_json_output(operstate="DOWN", bitrate=500000), stderr=""
            )
        return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")

    monkeypatch.setattr(can_module.subprocess, "run", _fake_run)

    can_module._configure_socketcan_interface("can0", 500000)

    assert len(calls) == 4
    assert calls[1] == ["ip", "link", "set", "can0", "down"]


def test_configure_proceeds_when_ip_json_fails(monkeypatch: pytest.MonkeyPatch) -> None:
    """When ip -json fails (e.g. interface doesn't exist), reconfiguration proceeds."""
    calls: list[list[str]] = []

    def _fake_run(cmd, **_kw):
        calls.append(cmd)
        if cmd[:2] == ["ip", "-json"]:
            return subprocess.CompletedProcess(
                cmd, 1, stdout="", stderr='Device "can0" does not exist.'
            )
        return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")

    monkeypatch.setattr(can_module.subprocess, "run", _fake_run)

    can_module._configure_socketcan_interface("can0", 500000)

    assert len(calls) == 4


def test_configure_proceeds_when_ip_json_throws(monkeypatch: pytest.MonkeyPatch) -> None:
    """When ip -json raises an exception, reconfiguration proceeds gracefully."""
    calls: list[list[str]] = []
    first_call = [True]

    def _fake_run(cmd, **_kw):
        calls.append(cmd)
        if cmd[:2] == ["ip", "-json"] and first_call[0]:
            first_call[0] = False
            raise OSError("unexpected error")
        return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")

    monkeypatch.setattr(can_module.subprocess, "run", _fake_run)

    can_module._configure_socketcan_interface("can0", 500000)

    # The ip -json call threw, then 3 ip link set commands ran.
    assert len(calls) == 4
