"""Tests for main.py functions: _build_config, collect_device_inventory, process_device_inventory."""

import pytest
from unittest.mock import MagicMock, patch

import click
from pynetbox.core.query import RequestError

from inventory_monitor.main import (
    _build_config,
    CliOverrides,
    collect_device_inventory,
    process_device_inventory,
)
from inventory_monitor.models import AppConfig, NetBoxConfig, SnmpConfig


class TestBuildConfig:
    """Tests for _build_config() CLI override merging."""

    def test_snmp_version_override(self, monkeypatch):
        monkeypatch.setenv("NETBOX_API_TOKEN", "test-token")
        monkeypatch.setenv("NETBOX_INSTANCE_URL", "http://netbox.test")
        monkeypatch.setenv("SNMP_VERSION", "3")
        overrides = CliOverrides(snmp_version="2")
        cfg = _build_config(overrides)
        assert cfg.snmp.versions == [2]

    def test_snmp_version_multiple(self, monkeypatch):
        monkeypatch.setenv("NETBOX_API_TOKEN", "test-token")
        monkeypatch.setenv("NETBOX_INSTANCE_URL", "http://netbox.test")
        overrides = CliOverrides(snmp_version="3,2")
        cfg = _build_config(overrides)
        assert cfg.snmp.versions == [3, 2]

    def test_snmp_version_invalid_raises(self, monkeypatch):
        monkeypatch.setenv("NETBOX_API_TOKEN", "test-token")
        monkeypatch.setenv("NETBOX_INSTANCE_URL", "http://netbox.test")
        overrides = CliOverrides(snmp_version="5")
        with pytest.raises(click.BadParameter, match="SNMP versions must be 2 or 3"):
            _build_config(overrides)

    def test_snmp_version_non_numeric_raises(self, monkeypatch):
        monkeypatch.setenv("NETBOX_API_TOKEN", "test-token")
        monkeypatch.setenv("NETBOX_INSTANCE_URL", "http://netbox.test")
        overrides = CliOverrides(snmp_version="abc")
        with pytest.raises(click.BadParameter):
            _build_config(overrides)

    def test_snmp_v2_community_override(self, monkeypatch):
        monkeypatch.setenv("NETBOX_API_TOKEN", "test-token")
        monkeypatch.setenv("NETBOX_INSTANCE_URL", "http://netbox.test")
        monkeypatch.setenv("SNMP_COMMUNITY", "old_community")
        overrides = CliOverrides(snmp_community="new_community")
        cfg = _build_config(overrides)
        assert cfg.snmp.v2.community == "new_community"

    def test_snmp_v3_credential_overrides(self, monkeypatch):
        monkeypatch.setenv("NETBOX_API_TOKEN", "test-token")
        monkeypatch.setenv("NETBOX_INSTANCE_URL", "http://netbox.test")
        overrides = CliOverrides(
            snmp_user="testuser",
            snmp_auth="testauth",
            snmp_auth_method="SHA",
            snmp_privacy="testpriv",
            snmp_privacy_method="AES",
            snmp_security="authPriv",
        )
        cfg = _build_config(overrides)
        assert cfg.snmp.v3.user == "testuser"
        assert cfg.snmp.v3.auth_password == "testauth"
        assert cfg.snmp.v3.auth_protocol == "SHA"
        assert cfg.snmp.v3.privacy_password == "testpriv"
        assert cfg.snmp.v3.privacy_protocol == "AES"
        assert cfg.snmp.v3.security_level == "authPriv"

    def test_snmp_timeout_retries_override(self, monkeypatch):
        monkeypatch.setenv("NETBOX_API_TOKEN", "test-token")
        monkeypatch.setenv("NETBOX_INSTANCE_URL", "http://netbox.test")
        overrides = CliOverrides(snmp_timeout=30, snmp_retries=5)
        cfg = _build_config(overrides)
        assert cfg.snmp.timeout == 30
        assert cfg.snmp.retries == 5

    def test_netbox_token_and_url_override(self, monkeypatch):
        monkeypatch.setenv("NETBOX_API_TOKEN", "old_token")
        monkeypatch.setenv("NETBOX_INSTANCE_URL", "http://old.netbox.test")
        overrides = CliOverrides(
            netbox_api_token="new_token", netbox_url="http://new.netbox.test"
        )
        cfg = _build_config(overrides)
        assert cfg.netbox.api_token == "new_token"
        assert cfg.netbox.instance_url == "http://new.netbox.test"

    def test_netbox_tags_override(self, monkeypatch):
        monkeypatch.setenv("NETBOX_API_TOKEN", "test-token")
        monkeypatch.setenv("NETBOX_INSTANCE_URL", "http://netbox.test")
        overrides = CliOverrides(netbox_tags="prod, core")
        cfg = _build_config(overrides)
        assert cfg.netbox.tags == ["prod", "core"]

    def test_netbox_tags_exclude_override(self, monkeypatch):
        monkeypatch.setenv("NETBOX_API_TOKEN", "test-token")
        monkeypatch.setenv("NETBOX_INSTANCE_URL", "http://netbox.test")
        overrides = CliOverrides(netbox_tags_exclude="decom, staging")
        cfg = _build_config(overrides)
        assert cfg.netbox.tags_exclude == ["decom", "staging"]

    def test_model_excludes_override(self, monkeypatch):
        monkeypatch.setenv("NETBOX_API_TOKEN", "test-token")
        monkeypatch.setenv("NETBOX_INSTANCE_URL", "http://netbox.test")
        overrides = CliOverrides(model_excludes="Model1, Model2")
        cfg = _build_config(overrides)
        assert cfg.model_name_excludes == ["Model1", "Model2"]

    def test_log_level_override_uppercased(self, monkeypatch):
        monkeypatch.setenv("NETBOX_API_TOKEN", "test-token")
        monkeypatch.setenv("NETBOX_INSTANCE_URL", "http://netbox.test")
        overrides = CliOverrides(log_level="debug")
        cfg = _build_config(overrides)
        assert cfg.log_level == "DEBUG"

    def test_parallel_jobs_override(self, monkeypatch):
        monkeypatch.setenv("NETBOX_API_TOKEN", "test-token")
        monkeypatch.setenv("NETBOX_INSTANCE_URL", "http://netbox.test")
        overrides = CliOverrides(parallel_jobs=10)
        cfg = _build_config(overrides)
        assert cfg.parallel_jobs == 10

    def test_none_values_do_not_override(self, monkeypatch):
        monkeypatch.setenv("NETBOX_API_TOKEN", "test-token")
        monkeypatch.setenv("NETBOX_INSTANCE_URL", "http://netbox.test")
        monkeypatch.setenv("SNMP_VERSION", "3")
        monkeypatch.setenv("SNMP_TIMEOUT", "100")
        overrides = CliOverrides(snmp_version=None, snmp_timeout=None)
        cfg = _build_config(overrides)
        assert cfg.snmp.versions == [3]  # From env, not overridden
        assert cfg.snmp.timeout == 100  # From env, not overridden

    def test_frozen_dataclass_immutability(self, monkeypatch):
        monkeypatch.setenv("NETBOX_API_TOKEN", "test-token")
        monkeypatch.setenv("NETBOX_INSTANCE_URL", "http://netbox.test")
        overrides = CliOverrides(snmp_version="2")
        cfg = _build_config(overrides)
        # Attempting to mutate should raise FrozenInstanceError
        with pytest.raises(Exception):  # dataclasses.FrozenInstanceError
            cfg.snmp.versions = [3]

    def test_env_file_default(self, monkeypatch, tmp_path):
        """Test default .env file loading when env_file is None."""
        monkeypatch.setenv("NETBOX_API_TOKEN", "test-token")
        monkeypatch.setenv("NETBOX_INSTANCE_URL", "http://netbox.test")
        overrides = CliOverrides(env_file=None)
        cfg = _build_config(overrides)
        # Should successfully load (even if .env doesn't exist, os.environ provides values)
        assert cfg.netbox.api_token == "test-token"

    def test_env_file_custom(self, monkeypatch, tmp_path):
        """Test custom env file loading."""
        # Create a custom .env file
        custom_env = tmp_path / ".env.custom"
        custom_env.write_text(
            "NETBOX_API_TOKEN=custom-token\n"
            "NETBOX_INSTANCE_URL=http://custom.netbox.test\n"
            "SNMP_COMMUNITY=custom_community\n"
        )

        # Change to tmp_path so the custom env file is found
        monkeypatch.chdir(tmp_path)

        overrides = CliOverrides(env_file=".env.custom")
        cfg = _build_config(overrides)

        assert cfg.netbox.api_token == "custom-token"
        assert cfg.netbox.instance_url == "http://custom.netbox.test"
        assert cfg.snmp.v2.community == "custom_community"

    def test_env_file_with_cli_override(self, monkeypatch, tmp_path):
        """Test that CLI overrides take precedence over custom env file."""
        # Create a custom .env file
        custom_env = tmp_path / ".env.test"
        custom_env.write_text(
            "NETBOX_API_TOKEN=file-token\n"
            "NETBOX_INSTANCE_URL=http://file.netbox.test\n"
            "SNMP_USER=file_user\n"
        )

        monkeypatch.chdir(tmp_path)

        overrides = CliOverrides(
            env_file=".env.test",
            snmp_user="cli_user"  # CLI override
        )
        cfg = _build_config(overrides)

        # File values should be loaded
        assert cfg.netbox.api_token == "file-token"
        assert cfg.netbox.instance_url == "http://file.netbox.test"
        # CLI override should win
        assert cfg.snmp.v3.user == "cli_user"

    def test_env_file_os_environ_precedence(self, monkeypatch, tmp_path):
        """Test that os.environ takes precedence over env file."""
        # Create a custom .env file
        custom_env = tmp_path / ".env.precedence"
        custom_env.write_text(
            "NETBOX_API_TOKEN=file-token\n"
            "SNMP_COMMUNITY=file_community\n"
        )

        monkeypatch.chdir(tmp_path)
        # Set an os.environ value
        monkeypatch.setenv("SNMP_COMMUNITY", "env_community")
        monkeypatch.setenv("NETBOX_INSTANCE_URL", "http://netbox.test")

        overrides = CliOverrides(env_file=".env.precedence")
        cfg = _build_config(overrides)

        # File value
        assert cfg.netbox.api_token == "file-token"
        # os.environ should win over file
        assert cfg.snmp.v2.community == "env_community"


class TestCollectDeviceInventory:
    """Tests for collect_device_inventory() device discovery and filtering."""

    def test_single_device_found(self, monkeypatch):
        """Single device mode: fetch by name, return dict with one device."""
        monkeypatch.setenv("NETBOX_API_TOKEN", "test-token")
        monkeypatch.setenv("NETBOX_INSTANCE_URL", "http://netbox.test")

        class FakeDevice:
            name = "router-1"
            primary_ip4 = MagicMock(address="10.0.0.1")
            platform = None
            device_type = None

        fake_netbox = MagicMock()
        fake_netbox.api.dcim.devices.filter.return_value = [FakeDevice()]

        fake_monitor = MagicMock()
        fake_monitor.process_device.return_value = (FakeDevice(), {"inventory": []})

        fake_config = MagicMock()

        result = collect_device_inventory(
            fake_netbox, fake_config, fake_monitor, device_name="router-1"
        )

        assert len(result) == 1
        fake_netbox.api.dcim.devices.filter.assert_called_once_with(name="router-1")

    def test_single_device_not_found(self, monkeypatch):
        """Single device not found → returns empty dict with error logged."""
        monkeypatch.setenv("NETBOX_API_TOKEN", "test-token")
        monkeypatch.setenv("NETBOX_INSTANCE_URL", "http://netbox.test")

        fake_netbox = MagicMock()
        fake_netbox.api.dcim.devices.filter.return_value = []

        fake_monitor = MagicMock()
        fake_config = MagicMock()

        result = collect_device_inventory(
            fake_netbox, fake_config, fake_monitor, device_name="missing-device"
        )

        assert result == {}

    def test_single_device_request_error(self, monkeypatch):
        """Single device RequestError → returns empty dict with error logged."""
        monkeypatch.setenv("NETBOX_API_TOKEN", "test-token")
        monkeypatch.setenv("NETBOX_INSTANCE_URL", "http://netbox.test")

        fake_netbox = MagicMock()
        # RequestError needs a mock request object with status_code
        mock_request = MagicMock()
        mock_request.status_code = 500
        fake_netbox.api.dcim.devices.filter.side_effect = RequestError(mock_request)

        fake_monitor = MagicMock()
        fake_config = MagicMock()

        result = collect_device_inventory(
            fake_netbox, fake_config, fake_monitor, device_name="router-1"
        )

        assert result == {}

    def test_multi_device_basic_filter(self, monkeypatch):
        """Multi-device mode: builds filter (active, has_primary_ip)."""
        monkeypatch.setenv("NETBOX_API_TOKEN", "test-token")
        monkeypatch.setenv("NETBOX_INSTANCE_URL", "http://netbox.test")

        class FakeDevice:
            name = "router-1"
            primary_ip4 = MagicMock(address="10.0.0.1")
            platform = None
            device_type = None

        fake_netbox = MagicMock()
        fake_netbox.api.dcim.devices.filter.return_value = [FakeDevice(), FakeDevice()]

        fake_monitor = MagicMock()
        fake_monitor.process_device.return_value = (FakeDevice(), {"inventory": []})

        fake_config = MagicMock()
        fake_config.netbox.tags = []
        fake_config.netbox.tags_exclude = []
        fake_config.parallel_jobs = 2

        with patch("inventory_monitor.main.Parallel") as mock_parallel:
            # Mock Parallel to directly call the function
            mock_parallel.return_value = MagicMock()
            mock_parallel.return_value.__enter__ = lambda self: lambda fn: [
                fn(dev) for dev in fake_netbox.api.dcim.devices.filter()
            ]
            mock_parallel.return_value.__exit__ = lambda *args: None

            result = collect_device_inventory(fake_netbox, fake_config, fake_monitor)

        fake_netbox.api.dcim.devices.filter.assert_called_once_with(
            status="active", has_primary_ip=True
        )
        assert isinstance(result, dict)  # Verify correct return type

    def test_multi_device_with_tags(self, monkeypatch):
        """Multi-device mode: includes tags if config.netbox.tags set."""
        monkeypatch.setenv("NETBOX_API_TOKEN", "test-token")
        monkeypatch.setenv("NETBOX_INSTANCE_URL", "http://netbox.test")

        fake_netbox = MagicMock()
        fake_netbox.api.dcim.devices.filter.return_value = []

        fake_monitor = MagicMock()

        fake_config = MagicMock()
        fake_config.netbox.tags = ["prod", "core"]
        fake_config.netbox.tags_exclude = []
        fake_config.parallel_jobs = 50

        with patch("inventory_monitor.main.Parallel") as mock_parallel:
            mock_parallel.return_value = MagicMock()
            mock_parallel.return_value.__enter__ = lambda self: lambda fn: []
            mock_parallel.return_value.__exit__ = lambda *args: None

            result = collect_device_inventory(fake_netbox, fake_config, fake_monitor)

        fake_netbox.api.dcim.devices.filter.assert_called_once_with(
            status="active", has_primary_ip=True, tag=["prod", "core"]
        )

    def test_multi_device_with_tags_exclude(self, monkeypatch):
        """Multi-device mode: includes tag__n if config.netbox.tags_exclude set."""
        monkeypatch.setenv("NETBOX_API_TOKEN", "test-token")
        monkeypatch.setenv("NETBOX_INSTANCE_URL", "http://netbox.test")

        fake_netbox = MagicMock()
        fake_netbox.api.dcim.devices.filter.return_value = []

        fake_monitor = MagicMock()

        fake_config = MagicMock()
        fake_config.netbox.tags = []
        fake_config.netbox.tags_exclude = ["decom"]
        fake_config.parallel_jobs = 50

        with patch("inventory_monitor.main.Parallel") as mock_parallel:
            mock_parallel.return_value = MagicMock()
            mock_parallel.return_value.__enter__ = lambda self: lambda fn: []
            mock_parallel.return_value.__exit__ = lambda *args: None

            result = collect_device_inventory(fake_netbox, fake_config, fake_monitor)

        fake_netbox.api.dcim.devices.filter.assert_called_once_with(
            status="active", has_primary_ip=True, tag__n=["decom"]
        )

    def test_multi_device_no_devices_found(self, monkeypatch):
        """No devices found → returns empty dict with warning logged."""
        monkeypatch.setenv("NETBOX_API_TOKEN", "test-token")
        monkeypatch.setenv("NETBOX_INSTANCE_URL", "http://netbox.test")

        fake_netbox = MagicMock()
        fake_netbox.api.dcim.devices.filter.return_value = []

        fake_monitor = MagicMock()

        fake_config = MagicMock()
        fake_config.netbox.tags = []
        fake_config.netbox.tags_exclude = []
        fake_config.parallel_jobs = 50

        result = collect_device_inventory(fake_netbox, fake_config, fake_monitor)

        assert result == {}

    def test_multi_device_request_error(self, monkeypatch):
        """Multi-device RequestError → returns empty dict with error logged."""
        monkeypatch.setenv("NETBOX_API_TOKEN", "test-token")
        monkeypatch.setenv("NETBOX_INSTANCE_URL", "http://netbox.test")

        fake_netbox = MagicMock()
        mock_request = MagicMock()
        mock_request.status_code = 500
        fake_netbox.api.dcim.devices.filter.side_effect = RequestError(mock_request)

        fake_monitor = MagicMock()

        fake_config = MagicMock()
        fake_config.netbox.tags = []
        fake_config.netbox.tags_exclude = []
        fake_config.parallel_jobs = 50

        result = collect_device_inventory(fake_netbox, fake_config, fake_monitor)

        assert result == {}


class TestProcessDeviceInventory:
    """Tests for process_device_inventory() probe creation and logging."""

    def test_all_probes_succeed(self):
        """Logs success when failures == 0."""

        class FakeDevice:
            name = "router-1"
            platform = MagicMock()
            device_type = MagicMock()

        FakeDevice.platform.name = "ios"
        FakeDevice.device_type.manufacturer = MagicMock()
        FakeDevice.device_type.manufacturer.name = "Cisco"

        fake_monitor = MagicMock()
        fake_monitor.prepare_device_sn_inventory.return_value = {
            "SN001": {"entPhysicalClass": "chassis"}
        }

        fake_netbox = MagicMock()
        fake_netbox.create_or_update_probe.return_value = 0  # success

        devices_dict = {FakeDevice(): {"inventory": [{"serial": "SN001"}]}}

        with patch("inventory_monitor.main.logger") as mock_logger:
            process_device_inventory(fake_netbox, fake_monitor, devices_dict)

            mock_logger.success.assert_called_once()
            call_args = mock_logger.success.call_args[0][0]
            assert "router-1" in call_args
            assert "ios" in call_args
            assert "Cisco" in call_args

    def test_some_probes_fail(self):
        """Logs warning when failures > 0."""

        class FakeDevice:
            name = "router-2"
            platform = MagicMock()
            device_type = MagicMock()

        FakeDevice.platform.name = "junos"
        FakeDevice.device_type.manufacturer = MagicMock()
        FakeDevice.device_type.manufacturer.name = "Juniper"

        fake_monitor = MagicMock()
        fake_monitor.prepare_device_sn_inventory.return_value = {
            "SN001": {"entPhysicalClass": "chassis"},
            "SN002": {"entPhysicalClass": "module"},
        }

        fake_netbox = MagicMock()
        # First call succeeds, second fails
        fake_netbox.create_or_update_probe.side_effect = [0, 1]

        devices_dict = {FakeDevice(): {"inventory": [{"serial": "SN001"}]}}

        with patch("inventory_monitor.main.logger") as mock_logger:
            process_device_inventory(fake_netbox, fake_monitor, devices_dict)

            mock_logger.warning.assert_called_once()
            call_args = mock_logger.warning.call_args[0][0]
            assert "1 probe(s) failed" in call_args
            assert "router-2" in call_args

    def test_no_inventory(self):
        """Logs error when inventory is empty."""

        class FakeDevice:
            name = "router-3"
            platform = None
            device_type = None

        fake_monitor = MagicMock()
        devices_dict = {
            FakeDevice(): {
                "inventory": [],
                "error": True,
                "error_message": "Connection timeout",
            }
        }

        with patch("inventory_monitor.main.logger") as mock_logger:
            process_device_inventory(MagicMock(), fake_monitor, devices_dict)

            assert mock_logger.error.call_count == 2
            first_call = mock_logger.error.call_args_list[0][0][0]
            assert "No inventory found" in first_call
            assert "router-3" in first_call

    def test_handles_none_platform_and_device_type(self):
        """Handles None platform/device_type gracefully."""

        class FakeDevice:
            name = "router-4"
            platform = None
            device_type = None

        fake_monitor = MagicMock()
        fake_monitor.prepare_device_sn_inventory.return_value = {
            "SN001": {"entPhysicalClass": "chassis"}
        }

        fake_netbox = MagicMock()
        fake_netbox.create_or_update_probe.return_value = 0

        devices_dict = {FakeDevice(): {"inventory": [{"serial": "SN001"}]}}

        with patch("inventory_monitor.main.logger") as mock_logger:
            process_device_inventory(fake_netbox, fake_monitor, devices_dict)

            # Should not raise AttributeError, should log success
            mock_logger.success.assert_called_once()
            call_args = mock_logger.success.call_args[0][0]
            assert "router-4" in call_args

    def test_calls_prepare_device_sn_inventory(self):
        """Calls prepare_device_sn_inventory() for each device with inventory."""

        class FakeDevice:
            name = "router-5"
            platform = None
            device_type = None

        fake_monitor = MagicMock()
        fake_monitor.prepare_device_sn_inventory.return_value = {}

        fake_netbox = MagicMock()

        devices_dict = {FakeDevice(): {"inventory": [{"serial": "SN001"}]}}

        process_device_inventory(fake_netbox, fake_monitor, devices_dict)

        fake_monitor.prepare_device_sn_inventory.assert_called_once_with(
            [{"serial": "SN001"}]
        )

    def test_calls_create_or_update_probe_for_each_item(self):
        """Calls create_or_update_probe() for each prepared item."""

        class FakeDevice:
            name = "router-6"
            platform = None
            device_type = None

        fake_monitor = MagicMock()
        fake_monitor.prepare_device_sn_inventory.return_value = {
            "SN001": {"entPhysicalClass": "chassis"},
            "SN002": {"entPhysicalClass": "module"},
        }

        fake_netbox = MagicMock()
        fake_netbox.create_or_update_probe.return_value = 0

        devices_dict = {FakeDevice(): {"inventory": [{"serial": "SN001"}]}}

        process_device_inventory(fake_netbox, fake_monitor, devices_dict)

        assert fake_netbox.create_or_update_probe.call_count == 2
