"""Tests for modules/connectivity/bridge — transparent inline bridge.

Validates bridge setup/teardown logic, ebtables rule application,
watchdog failure counting, status reporting, and health checks
without requiring root, ip/bridge commands, or physical interfaces.
"""

import os
import subprocess
import threading
import time
from pathlib import Path
from unittest.mock import MagicMock, patch, call

import pytest

from modules.base import BaseModule
from modules.connectivity.bridge import (
    Bridge, BRIDGE_NAME, WATCHDOG_INTERVAL, WATCHDOG_MAX_FAILURES,
)


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------

@pytest.fixture
def bridge_config(tmp_path):
    """Return a valid bridge config dict."""
    scripts_dir = tmp_path / "scripts"
    scripts_dir.mkdir()
    return {
        "connectivity": {
            "bridge": {
                "interface1": "eth0",
                "interface2": "usb0",
            }
        },
        "device": {
            "install_path": str(tmp_path),
        },
    }


@pytest.fixture
def bridge(mock_bus, mock_state, bridge_config):
    """Return a Bridge instance (not started)."""
    return Bridge(mock_bus, mock_state, bridge_config)


# ---------------------------------------------------------------------------
# Structure
# ---------------------------------------------------------------------------

class TestBridgeStructure:

    def test_is_base_module(self):
        assert issubclass(Bridge, BaseModule)

    def test_module_attributes(self):
        assert Bridge.name == "bridge"
        assert Bridge.module_type == "connectivity"
        assert Bridge.requires_root is True
        assert Bridge.priority == -300

    def test_bridge_name_constant(self):
        assert BRIDGE_NAME == "br0"


# ---------------------------------------------------------------------------
# Config parsing
# ---------------------------------------------------------------------------

class TestConfigParsing:

    def test_no_bridge_config_sets_error(self, mock_bus, mock_state):
        mod = Bridge(mock_bus, mock_state, {})
        mod.start()
        assert mod._running is not True

    def test_missing_interface2_sets_error(self, mock_bus, mock_state):
        config = {"connectivity": {"bridge": {"interface1": "eth0"}}}
        mod = Bridge(mock_bus, mock_state, config)
        mod.start()
        assert mod._running is not True

    def test_interfaces_parsed_from_config(self, bridge, bridge_config):
        br_cfg = bridge_config["connectivity"]["bridge"]
        assert br_cfg["interface1"] == "eth0"
        assert br_cfg["interface2"] == "usb0"


# ---------------------------------------------------------------------------
# Setup bridge (inline)
# ---------------------------------------------------------------------------

class TestInlineSetup:

    @patch("subprocess.run")
    def test_setup_bridge_inline_success(self, mock_run, bridge):
        mock_run.return_value = MagicMock(returncode=0)
        bridge._scripts_dir = "/nonexistent"

        result = bridge._setup_bridge_inline("eth0", "usb0")

        assert result is True
        cmds = [c[0][0] for c in mock_run.call_args_list]
        cmd_strs = [" ".join(c) for c in cmds]
        assert any("add name br0 type bridge" in s for s in cmd_strs)
        assert any("eth0 master br0" in s for s in cmd_strs)
        assert any("usb0 master br0" in s for s in cmd_strs)

    @patch("subprocess.run")
    def test_setup_bridge_inline_failure_teardown(self, mock_run, bridge):
        mock_run.side_effect = subprocess.CalledProcessError(1, "ip", stderr=b"error")
        bridge._scripts_dir = "/nonexistent"
        bridge._if1 = "eth0"
        bridge._if2 = "usb0"

        result = bridge._setup_bridge_inline("eth0", "usb0")
        assert result is False


# ---------------------------------------------------------------------------
# Teardown bridge (inline)
# ---------------------------------------------------------------------------

class TestInlineTeardown:

    @patch("subprocess.run")
    def test_teardown_runs_commands(self, mock_run, bridge):
        mock_run.return_value = MagicMock(returncode=0)
        bridge._if1 = "eth0"
        bridge._if2 = "usb0"

        result = bridge._teardown_bridge_inline()
        assert result is True

        cmd_strs = [" ".join(c[0][0]) for c in mock_run.call_args_list]
        assert any("del br0" in s for s in cmd_strs)

    @patch("subprocess.run")
    def test_teardown_is_best_effort(self, mock_run, bridge):
        mock_run.side_effect = subprocess.CalledProcessError(1, "ip", stderr=b"")
        bridge._if1 = "eth0"
        bridge._if2 = "usb0"

        result = bridge._teardown_bridge_inline()
        assert result is True


# ---------------------------------------------------------------------------
# Script-based setup/teardown
# ---------------------------------------------------------------------------

class TestScriptBasedOps:

    @patch("subprocess.run")
    def test_setup_uses_script_if_available(self, mock_run, bridge, tmp_path):
        script = tmp_path / "scripts" / "setup_bridge.sh"
        script.write_text("#!/bin/bash\necho ok")
        bridge._scripts_dir = str(tmp_path / "scripts")

        mock_run.return_value = MagicMock(returncode=0)
        result = bridge.setup_bridge("eth0", "usb0")

        assert result is True

    @patch("subprocess.run")
    def test_setup_falls_back_to_inline(self, mock_run, bridge):
        bridge._scripts_dir = "/nonexistent"
        mock_run.return_value = MagicMock(returncode=0)

        result = bridge.setup_bridge("eth0", "usb0")
        assert result is True


# ---------------------------------------------------------------------------
# Ebtables rules
# ---------------------------------------------------------------------------

class TestEbtablesRules:

    @patch("subprocess.run")
    def test_apply_ebtables_rules(self, mock_run, bridge):
        mock_run.return_value = MagicMock(returncode=0)
        bridge._apply_ebtables_rules("eth0", "usb0")

        cmd_strs = [" ".join(c[0][0]) for c in mock_run.call_args_list]
        assert any("01:80:C2:00:00:00" in s for s in cmd_strs)  # STP
        assert any("01:00:0C:CC:CC:CC" in s for s in cmd_strs)  # CDP
        assert any("01:80:C2:00:00:0E" in s for s in cmd_strs)  # LLDP

    @patch("subprocess.run", side_effect=FileNotFoundError("ebtables not found"))
    def test_apply_ebtables_graceful_on_missing(self, mock_run, bridge):
        bridge._apply_ebtables_rules("eth0", "usb0")

    @patch("subprocess.run")
    def test_remove_ebtables_flushes(self, mock_run, bridge):
        mock_run.return_value = MagicMock(returncode=0)
        bridge._remove_ebtables_rules()

        cmd_strs = [" ".join(c[0][0]) for c in mock_run.call_args_list]
        assert any("ebtables -F" in s for s in cmd_strs)
        assert any("ebtables -X" in s for s in cmd_strs)


# ---------------------------------------------------------------------------
# Health check
# ---------------------------------------------------------------------------

class TestHealthCheck:

    @patch("subprocess.run")
    def test_healthy_bridge(self, mock_run, bridge):
        bridge._if1 = "eth0"
        bridge._if2 = "usb0"

        def fake_run(cmd, **kwargs):
            return MagicMock(returncode=0, stdout="br0 stuff")

        mock_run.side_effect = fake_run
        assert bridge.is_healthy() is True

    @patch("subprocess.run")
    def test_unhealthy_bridge_missing(self, mock_run, bridge):
        bridge._if1 = "eth0"
        bridge._if2 = "usb0"
        mock_run.return_value = MagicMock(returncode=1, stdout="")

        assert bridge.is_healthy() is False

    @patch("subprocess.run", side_effect=subprocess.TimeoutExpired("ip", 5))
    def test_unhealthy_on_timeout(self, mock_run, bridge):
        bridge._if1 = "eth0"
        bridge._if2 = "usb0"
        assert bridge.is_healthy() is False


# ---------------------------------------------------------------------------
# Watchdog logic
# ---------------------------------------------------------------------------

class TestWatchdog:

    def test_consecutive_failures_tracked(self, bridge):
        bridge._consecutive_failures = 0
        bridge._consecutive_failures += 1
        assert bridge._consecutive_failures == 1

    def test_watchdog_max_failures_constant(self):
        assert WATCHDOG_MAX_FAILURES == 3

    def test_watchdog_interval_constant(self):
        assert WATCHDOG_INTERVAL == 5.0


# ---------------------------------------------------------------------------
# Status
# ---------------------------------------------------------------------------

class TestStatus:

    def test_status_not_running(self, bridge):
        s = bridge.status()
        assert s["running"] is False
        assert s["bridge_up"] is False
        assert s["bridge_name"] == "br0"

    def test_status_tracks_interfaces(self, bridge):
        bridge._if1 = "eth0"
        bridge._if2 = "usb0"
        s = bridge.status()
        assert s["interface1"] == "eth0"
        assert s["interface2"] == "usb0"

    def test_status_tracks_failures(self, bridge):
        bridge._consecutive_failures = 2
        s = bridge.status()
        assert s["consecutive_failures"] == 2


# ---------------------------------------------------------------------------
# Start/stop lifecycle (mocked)
# ---------------------------------------------------------------------------

class TestLifecycle:

    @patch.object(Bridge, "setup_bridge", return_value=True)
    @patch.object(Bridge, "is_healthy", return_value=True)
    def test_start_sets_running(self, mock_health, mock_setup, bridge):
        bridge.start()
        assert bridge._running is True
        assert bridge._bridge_up is True

    @patch.object(Bridge, "setup_bridge", return_value=True)
    @patch.object(Bridge, "teardown_bridge", return_value=True)
    @patch.object(Bridge, "is_healthy", return_value=True)
    def test_stop_tears_down(self, mock_health, mock_teardown, mock_setup, bridge):
        bridge.start()
        bridge.stop()
        assert bridge._running is False
        assert bridge._bridge_up is False
        mock_teardown.assert_called_once()

    @patch.object(Bridge, "setup_bridge", return_value=False)
    def test_start_fails_on_setup_error(self, mock_setup, bridge):
        bridge.start()
        assert bridge._running is not True
