"""Tests for the ``aiopnsense_dump`` live script."""

from __future__ import annotations

import argparse
import asyncio
from collections.abc import Callable
import importlib.util
import json
from pathlib import Path
import sys
from types import ModuleType
from typing import Any, Self
from unittest.mock import AsyncMock, MagicMock

import pytest


def load_dump_module() -> ModuleType:
    """Load the script as an importable module for direct unit testing.

    Returns:
        ModuleType: Imported dump-script module under test.
    """
    module_path = Path(__file__).parents[1] / "scripts" / "aiopnsense_dump.py"
    sys.path.insert(0, str(module_path.parent))
    spec = importlib.util.spec_from_file_location("aiopnsense_dump", module_path)
    assert spec is not None
    assert spec.loader is not None
    module = importlib.util.module_from_spec(spec)
    sys.modules[spec.name] = module
    spec.loader.exec_module(module)
    return module


class FakeClient:
    """Small async fake matching just the script-call surface."""

    validate: AsyncMock
    async_close: AsyncMock
    get_system_info: AsyncMock
    get_firmware_update_info: AsyncMock
    get_nut_ups_status: AsyncMock
    stream_interface_traffic_calls: int
    stream_poll_intervals: list[int]
    stream_samples: tuple[dict[str, Any], ...]

    def __init__(
        self,
        system_info_return: dict[str, Any] | None = None,
        firmware_update_return: dict[str, Any] | None = None,
        stream_samples: tuple[dict[str, Any], ...] = (),
    ) -> None:
        """Create the async fakes used by run_endpoint tests.

        Args:
            system_info_return (dict[str, Any] | None): Canned system-info response.
            firmware_update_return (dict[str, Any] | None): Canned firmware-update response.
            stream_samples (tuple[dict[str, Any], ...]): Samples emitted by the traffic stream.
        """
        self.validate = AsyncMock()
        self.async_close = AsyncMock()
        self.get_system_info = AsyncMock(return_value=system_info_return or {"system": "ok"})
        self.get_firmware_update_info = AsyncMock(
            return_value=firmware_update_return or {"firmware": "ok"}
        )
        self.get_nut_ups_status = AsyncMock(return_value={"status": {"ups.status": "OL"}})
        self.stream_interface_traffic_calls = 0
        self.stream_poll_intervals = []
        self.stream_samples = stream_samples

    def stream_interface_traffic(self, poll_interval: int = 1) -> Any:
        """Return a bounded async sequence for streaming endpoint tests.

        Args:
            poll_interval (int): Requested interval recorded for later assertions.

        Returns:
            Any: Async iterator over the configured traffic samples.
        """
        self.stream_interface_traffic_calls += 1
        self.stream_poll_intervals.append(poll_interval)

        async def _stream() -> Any:
            for sample in self.stream_samples:
                yield sample

        return _stream()


class FakeClientSession:
    """Async context manager session stub used by async_main tests."""

    def __init__(self) -> None:
        """Create awaitable enter/exit methods."""
        self.enter = AsyncMock(return_value=self)
        self.exit = AsyncMock(return_value=None)

    async def __aenter__(self) -> Self:
        return await self.enter()

    async def __aexit__(self, *_args: object) -> None:
        await self.exit()


def test_endpoint_registry_contains_expected_read_only_entries() -> None:
    """Registry matches the exact read-only contract required by the task."""
    module = load_dump_module()
    entries = module.list_endpoints()
    endpoint_map = {entry["endpoint"]: (entry["method"], entry["warning"]) for entry in entries}
    expected_entries = {
        "arp_table": ("get_arp_table", None),
        "carp": ("get_carp", None),
        "certificates": ("get_certificates", None),
        "device_unique_id": ("get_device_unique_id", None),
        "dhcp_leases": ("get_dhcp_leases", None),
        "firewall": ("get_firewall", None),
        "firmware_update_info": (
            "get_firmware_update_info",
            "May trigger a firmware check if OPNsense cached firmware status is stale.",
        ),
        "gateways": ("get_gateways", None),
        "host_firmware_version": ("get_host_firmware_version", None),
        "interfaces": ("get_interfaces", None),
        "interface_traffic": ("get_interface_traffic", None),
        "interface_traffic_stream": ("stream_interface_traffic", None),
        "notices": ("get_notices", None),
        "nut_ups_status": ("get_nut_ups_status", None),
        "openvpn": ("get_openvpn", None),
        "services": ("get_services", None),
        "smart": ("get_smart", None),
        "speedtest": ("get_speedtest", None),
        "system_info": ("get_system_info", None),
        "telemetry": ("get_telemetry", None),
        "unbound_blocklist": ("get_unbound_blocklist", None),
        "upgrade_status": ("upgrade_status", None),
        "vnstat": ("get_vnstat", None),
        "wireguard": ("get_wireguard", None),
    }

    assert endpoint_map == expected_entries


def test_build_parser_returns_argparse_parser_for_docs() -> None:
    """The dump script exposes a zero-argument parser factory for Sphinx docs."""
    module = load_dump_module()

    parser = module.build_parser()

    assert isinstance(parser, argparse.ArgumentParser)
    parsed = parser.parse_args(["--endpoint", "system_info"])
    assert parsed.endpoint == "system_info"


def test_build_parser_documents_endpoint_choices_in_option_help() -> None:
    """Endpoint choices render in option help instead of bloating usage."""
    module = load_dump_module()
    parser = module.build_parser()

    help_text = " ".join(parser.format_help().split())

    assert "--endpoint ENDPOINT" in help_text
    assert "--endpoint {arp_table" not in help_text
    assert "Available endpoints: arp_table, carp" in help_text
    assert "wireguard." in help_text


def test_parse_args_resolves_documented_default_env_file() -> None:
    """Runtime parsing resolves the documented env file path to the script directory."""
    module = load_dump_module()

    parsed = module.parse_args(["--endpoint", "system_info"])

    assert parsed.env_file == module.DEFAULT_ENV_FILE


def test_build_parser_documents_relative_default_env_file() -> None:
    """Sphinx parser docs should show the user-facing default env path."""
    module = load_dump_module()
    parser = module.build_parser()

    parsed = parser.parse_args(["--endpoint", "system_info"])

    assert parsed.env_file == Path("scripts/aiopnsense.env")


def test_choose_endpoint_from_menu_prints_method_and_warning(
    monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
    """Menu rows include endpoint-to-method mapping and warning annotation.

    Args:
        monkeypatch (pytest.MonkeyPatch): Fixture that supplies the menu selection.
        capsys (pytest.CaptureFixture[str]): Fixture that captures rendered menu rows.
    """
    module = load_dump_module()
    monkeypatch.setattr("builtins.input", lambda _prompt="": "3")

    selected = module.choose_endpoint_from_menu(["system_info", "services", "firmware_update_info"])

    lines = capsys.readouterr().out.splitlines()
    assert lines[0] == "1. system_info -> get_system_info"
    assert (
        lines[2]
        == "3. firmware_update_info -> get_firmware_update_info [May trigger a firmware check if OPNsense cached firmware status is stale.]"
    )
    assert selected == "firmware_update_info"


def test_choose_endpoint_from_menu_accepts_number(monkeypatch: pytest.MonkeyPatch) -> None:
    """Menu input of a 1-based number returns the selected endpoint.

    Args:
        monkeypatch (pytest.MonkeyPatch): Fixture that supplies the numeric menu selection.
    """
    module = load_dump_module()
    monkeypatch.setattr("builtins.input", lambda _prompt="": "2")

    selected = module.choose_endpoint_from_menu(["system_info", "services", "smart"])

    assert selected == "services"


@pytest.mark.parametrize("selection", ["0", "4", "abc"])
def test_choose_endpoint_from_menu_rejects_invalid_number(
    selection: str, monkeypatch: pytest.MonkeyPatch
) -> None:
    """Any invalid menu input should exit with a clear error.

    Args:
        selection (str): Invalid menu input expected to be rejected.
        monkeypatch (pytest.MonkeyPatch): Fixture that supplies the invalid selection.
    """
    module = load_dump_module()
    monkeypatch.setattr("builtins.input", lambda _prompt="": selection)

    with pytest.raises(SystemExit):
        module.choose_endpoint_from_menu(["system_info", "services", "smart"])


@pytest.mark.parametrize("prompt_error", [EOFError, KeyboardInterrupt])
def test_choose_endpoint_from_menu_rejects_prompt_cancellation(
    prompt_error: type[BaseException], monkeypatch: pytest.MonkeyPatch
) -> None:
    """Prompt cancellation exits through the same controlled invalid-selection path.

    Args:
        prompt_error (type[BaseException]): Prompt cancellation exception to simulate.
        monkeypatch (pytest.MonkeyPatch): Fixture that replaces input with the failing prompt.
    """
    module = load_dump_module()

    def raise_prompt_error(_prompt: str = "") -> None:
        """Raise the configured prompt cancellation error.

        Args:
            _prompt (str): Input prompt ignored before raising the configured error.

        Raises:
            prompt_error: The configured prompt cancellation error.
        """
        raise prompt_error

    monkeypatch.setattr("builtins.input", raise_prompt_error)

    with pytest.raises(SystemExit, match="Invalid endpoint selection"):
        module.choose_endpoint_from_menu(["system_info", "services", "smart"])


@pytest.mark.asyncio
async def test_run_endpoint_calls_public_method() -> None:
    """Non-stream endpoints call their mapped public no-arg method."""
    module = load_dump_module()
    client = FakeClient(system_info_return={"system": "running"})

    result = await module.run_endpoint(client, "system_info", stream_seconds=30.0)

    client.get_system_info.assert_awaited_once()
    assert result["endpoint"] == "system_info"
    assert result["method"] == "get_system_info"
    assert result["warning"] is None
    assert result["data"] == {"system": "running"}


@pytest.mark.asyncio
async def test_run_endpoint_includes_warning_for_refreshing_method() -> None:
    """Firmware check warnings appear in the dump payload."""
    module = load_dump_module()
    client = FakeClient(firmware_update_return={"firmware": "ready"})

    result = await module.run_endpoint(client, "firmware_update_info", stream_seconds=30.0)

    assert (
        result["warning"]
        == "May trigger a firmware check if OPNsense cached firmware status is stale."
    )
    assert result["data"] == {"firmware": "ready"}


@pytest.mark.asyncio
async def test_run_endpoint_collects_available_stream_samples_quickly() -> None:
    """Streaming endpoint collects all immediately-available samples quickly."""
    module = load_dump_module()
    stream_samples = (
        {"sample": 1},
        {"sample": 2},
        {"sample": 3},
        {"sample": 4},
        {"sample": 5},
    )
    client = FakeClient(stream_samples=stream_samples)

    result = await module.run_endpoint(client, "interface_traffic_stream", stream_seconds=2.0)

    assert client.stream_poll_intervals == [1]
    assert result["endpoint"] == "interface_traffic_stream"
    assert result["data"] == [
        {"sample": 1},
        {"sample": 2},
        {"sample": 3},
        {"sample": 4},
        {"sample": 5},
    ]


@pytest.mark.asyncio
async def test_run_endpoint_returns_empty_when_stream_seconds_is_zero() -> None:
    """Non-positive stream duration should return an empty sample list without polling."""
    module = load_dump_module()
    stream_samples = ({"sample": 1}, {"sample": 2})
    client = FakeClient(stream_samples=stream_samples)

    result = await module.run_endpoint(client, "interface_traffic_stream", stream_seconds=0.0)

    assert client.stream_interface_traffic_calls == 0
    assert result["data"] == []
    assert result["warning"] is None


@pytest.mark.asyncio
async def test_run_endpoint_collects_empty_stream_with_short_timeout() -> None:
    """A stalled iterator respects timeout and closes cleanly."""
    module = load_dump_module()
    client = FakeClient(stream_samples=())

    class StalledStream:
        """Slow iterator that supports explicit close."""

        def __init__(self) -> None:
            """Initialize the stalled stream state."""
            self.closed = False
            self.opened = False

        def __aiter__(self) -> StalledStream:
            """Return the stream iterator.

            Returns:
                'StalledStream': The stream instance used for async iteration.
            """
            return self

        async def __anext__(self) -> dict[str, Any]:
            """Wait briefly and then end iteration.

            Returns:
                dict[str, Any]: Next traffic sample, if the simulated wait completes.

            Raises:
                StopAsyncIteration: Raised after the simulated wait ends.
            """
            await module.asyncio.sleep(1.0)
            raise StopAsyncIteration

        async def aclose(self) -> None:
            """Mark the stream as closed."""
            self.closed = True

    stream = StalledStream()

    def fake_stream(poll_interval: int = 1) -> StalledStream:
        """Expose the stalled stream with capture for the polling interval.

        Args:
            poll_interval (int): Requested interval ignored by the stalled stream.

        Returns:
            StalledStream: Configured stalled stream instance.
        """
        del poll_interval
        client.stream_interface_traffic_calls += 1
        stream.opened = True
        return stream

    original_stream = client.stream_interface_traffic
    monkeypatch = pytest.MonkeyPatch()
    monkeypatch.setattr(client, "stream_interface_traffic", fake_stream)

    start = module.time.monotonic()
    result = await module.run_endpoint(client, "interface_traffic_stream", stream_seconds=0.1)
    elapsed = module.time.monotonic() - start

    assert client.stream_interface_traffic_calls == 1
    assert stream.opened
    assert stream.closed
    assert elapsed < 0.5
    assert result["data"] == []
    monkeypatch.setattr(client, "stream_interface_traffic", original_stream)


@pytest.mark.asyncio
async def test_run_endpoint_propagates_stream_timeout_error_and_closes_stream(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Iterator TimeoutError must propagate and the stream must still close.

    Args:
        monkeypatch (pytest.MonkeyPatch): Fixture that injects the timeout stream.
    """
    module = load_dump_module()

    class TimeoutStream:
        """Slow iterator that fails with stream-level TimeoutError."""

        def __init__(self) -> None:
            """Initialize the timeout stream state."""
            self.closed = False
            self.opened = False

        def __aiter__(self) -> TimeoutStream:
            """Return the stream iterator.

            Returns:
                TimeoutStream: The stream instance used for async iteration.
            """
            return self

        async def __anext__(self) -> dict[str, Any]:
            """Raise the simulated stream timeout.

            Returns:
                dict[str, Any]: Next traffic sample before the simulated timeout.

            Raises:
                TimeoutError: Always raised to simulate a stream timeout.
            """
            raise TimeoutError("stream-level timeout")

        async def aclose(self) -> None:
            """Mark the stream as closed."""
            self.closed = True

    stream = TimeoutStream()

    client = FakeClient(stream_samples=())
    original_stream = client.stream_interface_traffic

    def fake_stream(poll_interval: int = 1) -> TimeoutStream:
        """Expose the timeout stream with capture for the polling interval.

        Args:
            poll_interval (int): Requested interval ignored by the timeout stream.

        Returns:
            TimeoutStream: Configured timeout stream instance.
        """
        del poll_interval
        client.stream_interface_traffic_calls += 1
        stream.opened = True
        return stream

    monkeypatch.setattr(client, "stream_interface_traffic", fake_stream)

    with pytest.raises(TimeoutError, match="stream-level timeout"):
        await module.run_endpoint(client, "interface_traffic_stream", stream_seconds=5.0)

    assert client.stream_interface_traffic_calls == 1
    assert stream.opened
    assert stream.closed
    monkeypatch.setattr(client, "stream_interface_traffic", original_stream)


@pytest.mark.asyncio
async def test_run_endpoint_stream_error_beats_close_error(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Primary stream errors preserve when stream close fails too.

    Args:
        monkeypatch (pytest.MonkeyPatch): Fixture that injects the failing-close stream.
    """
    module = load_dump_module()

    class TimeoutStream:
        """Slow iterator that errors and cannot be closed."""

        def __init__(self) -> None:
            """Initialize the timeout stream state."""
            self.closed = False
            self.opened = False

        def __aiter__(self) -> TimeoutStream:
            """Return the stream iterator.

            Returns:
                TimeoutStream: The stream instance used for async iteration.
            """
            return self

        async def __anext__(self) -> dict[str, Any]:
            """Raise the simulated stream timeout.

            Returns:
                dict[str, Any]: Next traffic sample before the simulated timeout.

            Raises:
                TimeoutError: Always raised to simulate a stream timeout.
            """
            raise TimeoutError("stream-level timeout")

        async def aclose(self) -> None:
            """Raise when the stream cannot be closed cleanly.

            Raises:
                RuntimeError: Always raised to simulate a close failure.
            """
            self.closed = True
            raise RuntimeError("close failed")

    stream = TimeoutStream()

    client = FakeClient(stream_samples=())

    def fake_stream(poll_interval: int = 1) -> TimeoutStream:
        """Expose the failing-close stream for polling capture.

        Args:
            poll_interval (int): Requested interval ignored by the failing-close stream.

        Returns:
            TimeoutStream: Configured timeout stream instance.
        """
        del poll_interval
        client.stream_interface_traffic_calls += 1
        stream.opened = True
        return stream

    monkeypatch.setattr(client, "stream_interface_traffic", fake_stream)

    with pytest.raises(TimeoutError, match="stream-level timeout"):
        await module.run_endpoint(client, "interface_traffic_stream", stream_seconds=5.0)

    assert client.stream_interface_traffic_calls == 1
    assert stream.opened
    assert stream.closed


@pytest.mark.asyncio
async def test_async_main_list_uses_write_output_and_skips_env_load(
    monkeypatch: pytest.MonkeyPatch,
    tmp_path: Path,
    capsys: pytest.CaptureFixture[str],
) -> None:
    """--list uses write_output and does not load live config.

    Args:
        monkeypatch (pytest.MonkeyPatch): Fixture that spies on config loading and output writing.
        tmp_path (Path): Temporary directory containing the endpoint-list output.
        capsys (pytest.CaptureFixture[str]): Fixture that captures the rendered endpoint list.
    """
    module = load_dump_module()
    load_live_config = MagicMock(
        side_effect=AssertionError("load_live_config should not be called")
    )
    write_calls: list[tuple[Any, Path | None]] = []
    original_write_output = module.write_output

    monkeypatch.setattr(module, "load_live_config", load_live_config)

    def spy_write_output(payload: Any, output: Path | None) -> None:
        """Capture payload and output path while preserving write behavior.

        Args:
            payload (Any): Endpoint-list payload passed to the writer.
            output (Path | None): Optional JSON output path passed to the writer.
        """
        write_calls.append((payload, output))
        original_write_output(payload, output)

    monkeypatch.setattr(module, "write_output", spy_write_output)
    output_path = tmp_path / "endpoints.json"

    exit_code = await module.async_main(["--list", "--output", str(output_path)])

    assert load_live_config.call_count == 0
    assert exit_code == 0
    assert write_calls == [(module.list_endpoints(), output_path)]
    assert output_path.exists()

    rendered = json.loads(output_path.read_text(encoding="utf-8"))
    assert rendered == module.list_endpoints()

    captured = json.loads(capsys.readouterr().out.strip())
    assert captured == module.list_endpoints()


@pytest.mark.asyncio
async def test_async_main_returns_zero_on_success(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Normal execution path returns zero from async_main.

    Args:
        monkeypatch (pytest.MonkeyPatch): Fixture that replaces live dependencies with fakes.
    """
    module = load_dump_module()
    config = object()
    session = FakeClientSession()
    client = FakeClient()

    monkeypatch.setattr(module, "load_live_config", MagicMock(return_value=config))
    monkeypatch.setattr(module.aiohttp, "ClientSession", lambda: session)
    monkeypatch.setattr(module, "create_client", lambda _config, _session: client)
    monkeypatch.setattr(module, "run_endpoint", AsyncMock(return_value={"endpoint": "system_info"}))
    monkeypatch.setattr(module, "write_output", MagicMock())

    exit_code = await module.async_main(["--endpoint", "system_info"])

    assert exit_code == 0
    session.enter.assert_awaited_once()
    session.exit.assert_awaited_once()


@pytest.mark.asyncio
async def test_async_main_wires_options_into_dependencies(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Endpoint, env file, output file, and stream seconds are passed through.

    Args:
        monkeypatch (pytest.MonkeyPatch): Fixture that captures wired async dependencies.
    """
    module = load_dump_module()
    config = object()
    session = FakeClientSession()
    created: dict[str, Any] = {}
    client = FakeClient()
    endpoint_result = {
        "endpoint": "system_info",
        "method": "get_system_info",
        "warning": None,
        "data": {"system": "running"},
    }
    run_calls: list[tuple[object, str, float]] = []
    write_calls: list[tuple[dict[str, Any], Path | None]] = []

    monkeypatch.setattr(module, "load_live_config", MagicMock(return_value=config))
    monkeypatch.setattr(module.aiohttp, "ClientSession", lambda: session)

    def fake_create_client(config_value: object, session_value: object) -> FakeClient:
        """Capture the resolved dependencies from async_main wiring.

        Args:
            config_value (object): Loaded config passed to client construction.
            session_value (object): HTTP session passed to client construction.

        Returns:
            FakeClient: Client stub used by the async entry point.
        """
        created["cfg"] = config_value
        created["session"] = session_value
        return client

    async def fake_run_endpoint(
        client_arg: FakeClient, endpoint_name: str, stream_seconds: float
    ) -> dict[str, Any]:
        """Record the endpoint wiring and return the canned result.

        Args:
            client_arg (FakeClient): Client created by the async entry point.
            endpoint_name (str): Endpoint selected by parsed CLI options.
            stream_seconds (float): Stream duration selected by parsed CLI options.

        Returns:
            dict[str, Any]: Canned endpoint result written to output.
        """
        run_calls.append((client_arg, endpoint_name, stream_seconds))
        return endpoint_result

    def fake_write_output(payload: dict[str, Any], output: Path | None) -> None:
        """Capture output write intent for assertable side effects.

        Args:
            payload (dict[str, Any]): Endpoint result passed to the output writer.
            output (Path | None): Optional file path passed to the output writer.
        """
        write_calls.append((payload, output))

    monkeypatch.setattr(module, "create_client", fake_create_client)
    monkeypatch.setattr(module, "run_endpoint", fake_run_endpoint)
    monkeypatch.setattr(module, "write_output", fake_write_output)

    env_path = Path("/tmp/custom.env")
    output_path = Path("/tmp/dump.json")

    exit_code = await module.async_main(
        [
            "--endpoint",
            "system_info",
            "--env-file",
            str(env_path),
            "--output",
            str(output_path),
            "--stream-seconds",
            "9.25",
        ]
    )
    assert exit_code == 0
    module.load_live_config.assert_called_once_with(env_path)
    assert created["cfg"] is config
    assert created["session"] is session
    assert run_calls == [(client, "system_info", 9.25)]
    assert write_calls == [(endpoint_result, output_path)]
    client.validate.assert_awaited_once()
    client.async_close.assert_awaited_once()
    session.enter.assert_awaited_once()
    session.exit.assert_awaited_once()


@pytest.mark.asyncio
async def test_async_main_closes_client_when_run_endpoint_fails(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """``async_close`` is still awaited when run_endpoint raises.

    Args:
        monkeypatch (pytest.MonkeyPatch): Fixture that makes endpoint execution fail.
    """
    module = load_dump_module()
    config = object()
    session = FakeClientSession()
    client = FakeClient()

    monkeypatch.setattr(module, "load_live_config", MagicMock(return_value=config))
    monkeypatch.setattr(module.aiohttp, "ClientSession", lambda: session)
    monkeypatch.setattr(module, "create_client", lambda _config, _session: client)
    monkeypatch.setattr(module, "run_endpoint", AsyncMock(side_effect=RuntimeError("boom")))
    monkeypatch.setattr(module, "write_output", MagicMock())

    with pytest.raises(RuntimeError, match="boom"):
        await module.async_main(["--endpoint", "system_info"])

    client.validate.assert_awaited_once()
    client.async_close.assert_awaited_once()
    module.write_output.assert_not_called()
    session.enter.assert_awaited_once()
    session.exit.assert_awaited_once()


@pytest.mark.asyncio
async def test_async_main_preserves_run_endpoint_error_when_close_fails(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """``async_close`` failures do not mask the primary endpoint error.

    Args:
        monkeypatch (pytest.MonkeyPatch): Fixture that makes endpoint execution and close fail.
    """
    module = load_dump_module()
    config = object()
    session = FakeClientSession()
    client = FakeClient()
    client.async_close = AsyncMock(side_effect=RuntimeError("close failed"))

    monkeypatch.setattr(module, "load_live_config", MagicMock(return_value=config))
    monkeypatch.setattr(module.aiohttp, "ClientSession", lambda: session)
    monkeypatch.setattr(module, "create_client", lambda _config, _session: client)
    monkeypatch.setattr(module, "run_endpoint", AsyncMock(side_effect=RuntimeError("boom")))
    monkeypatch.setattr(module, "write_output", MagicMock())

    with pytest.raises(RuntimeError, match="boom"):
        await module.async_main(["--endpoint", "system_info"])

    client.validate.assert_awaited_once()
    client.async_close.assert_awaited_once()
    module.write_output.assert_not_called()
    session.enter.assert_awaited_once()
    session.exit.assert_awaited_once()


@pytest.mark.asyncio
async def test_async_main_preserves_primary_oserror_when_close_raises_oserror(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """`OSError` from the endpoint remains visible when close also raises `OSError`.

    Args:
        monkeypatch (pytest.MonkeyPatch): Fixture that simulates competing I/O failures.
    """
    module = load_dump_module()
    config = object()
    session = FakeClientSession()
    client = FakeClient()
    client.async_close = AsyncMock(side_effect=OSError("close failed"))

    monkeypatch.setattr(module, "load_live_config", MagicMock(return_value=config))
    monkeypatch.setattr(module.aiohttp, "ClientSession", lambda: session)
    monkeypatch.setattr(module, "create_client", lambda _config, _session: client)
    monkeypatch.setattr(module, "run_endpoint", AsyncMock(side_effect=OSError("primary failed")))
    monkeypatch.setattr(module, "write_output", MagicMock())

    with pytest.raises(OSError, match="primary failed"):
        await module.async_main(["--endpoint", "system_info"])

    client.validate.assert_awaited_once()
    client.async_close.assert_awaited_once()
    module.write_output.assert_not_called()
    session.enter.assert_awaited_once()
    session.exit.assert_awaited_once()


@pytest.mark.asyncio
async def test_async_main_preserves_cancelled_error_when_close_fails(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """``async_close`` failures do not mask cancellation.

    Args:
        monkeypatch (pytest.MonkeyPatch): Fixture that simulates cancellation and close failure.
    """
    module = load_dump_module()
    config = object()
    session = FakeClientSession()
    client = FakeClient()
    client.async_close = AsyncMock(side_effect=RuntimeError("close failed"))

    monkeypatch.setattr(module, "load_live_config", MagicMock(return_value=config))
    monkeypatch.setattr(module.aiohttp, "ClientSession", lambda: session)
    monkeypatch.setattr(module, "create_client", lambda _config, _session: client)
    monkeypatch.setattr(module, "run_endpoint", AsyncMock(side_effect=asyncio.CancelledError()))
    monkeypatch.setattr(module, "write_output", MagicMock())

    with pytest.raises(asyncio.CancelledError):
        await module.async_main(["--endpoint", "system_info"])

    client.validate.assert_awaited_once()
    client.async_close.assert_awaited_once()
    module.write_output.assert_not_called()
    session.enter.assert_awaited_once()
    session.exit.assert_awaited_once()


@pytest.mark.asyncio
async def test_async_main_closes_client_when_validate_fails(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """``async_close`` is still awaited when ``validate`` raises.

    Args:
        monkeypatch (pytest.MonkeyPatch): Fixture that makes client validation fail.
    """
    module = load_dump_module()
    config = object()
    session = FakeClientSession()
    client = FakeClient()
    client.validate = AsyncMock(side_effect=RuntimeError("validation failed"))

    monkeypatch.setattr(module, "load_live_config", MagicMock(return_value=config))
    monkeypatch.setattr(module.aiohttp, "ClientSession", lambda: session)
    monkeypatch.setattr(module, "create_client", lambda _config, _session: client)
    monkeypatch.setattr(module, "run_endpoint", AsyncMock())
    monkeypatch.setattr(module, "write_output", MagicMock())

    with pytest.raises(RuntimeError, match="validation failed"):
        await module.async_main(["--endpoint", "system_info"])

    client.validate.assert_awaited_once()
    client.async_close.assert_awaited_once()
    module.run_endpoint.assert_not_awaited()
    module.write_output.assert_not_called()
    session.enter.assert_awaited_once()
    session.exit.assert_awaited_once()


def test_main_returns_zero_on_success(monkeypatch: pytest.MonkeyPatch) -> None:
    """``main`` returns 0 when async_main completes successfully.

    Args:
        monkeypatch (pytest.MonkeyPatch): Fixture that makes ``async_main`` succeed.
    """
    module = load_dump_module()

    monkeypatch.setattr(module, "async_main", AsyncMock(return_value=0))

    assert module.main() == 0


def test_main_turns_live_config_error_into_system_exit(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """``main`` converts ``LiveConfigError`` from async_main into ``SystemExit``.

    Args:
        monkeypatch (pytest.MonkeyPatch): Fixture that makes ``async_main`` raise config failure.
    """
    module = load_dump_module()

    async def raise_config_error() -> None:
        raise module.LiveConfigError("bad config")

    monkeypatch.setattr(module, "async_main", raise_config_error)

    with pytest.raises(SystemExit) as excinfo:
        module.main()

    assert "bad config" in str(excinfo.value)


@pytest.mark.parametrize(
    ("error_factory", "expected_error"),
    [
        pytest.param(
            lambda module: module.OPNsenseError("connection failed"),
            "OPNsenseError: connection failed",
            id="opnsense-error",
        ),
        pytest.param(
            lambda module: module.aiohttp.ClientConnectionError("connection failed"),
            "ClientConnectionError: connection failed",
            id="aiohttp-client-error",
        ),
        pytest.param(
            lambda _module: RuntimeError("runtime validation failed"),
            "RuntimeError: runtime validation failed",
            id="runtime-error",
        ),
        pytest.param(
            lambda _module: TimeoutError("request timed out"),
            "TimeoutError: request timed out",
            id="timeout-error",
        ),
        pytest.param(
            lambda _module: OSError("output is unavailable"),
            "OSError: output is unavailable",
            id="os-error",
        ),
    ],
)
def test_main_turns_errors_into_system_exit(
    monkeypatch: pytest.MonkeyPatch,
    error_factory: Callable[[ModuleType], Exception],
    expected_error: str,
) -> None:
    """``main`` converts expected live failures into concise ``SystemExit``.

    Args:
        monkeypatch (pytest.MonkeyPatch): Fixture that makes ``async_main`` fail.
        error_factory (Callable[[ModuleType], Exception]): Factory for the expected failure.
        expected_error (str): Error text expected in the resulting ``SystemExit``.
    """
    module = load_dump_module()

    async def raise_error() -> None:
        """Raise the failure generated by the test case's factory.

        Raises:
            error_factory: The parameterized failure generated for this test case.
        """
        raise error_factory(module)

    monkeypatch.setattr(module, "async_main", raise_error)

    with pytest.raises(SystemExit) as excinfo:
        module.main()

    assert expected_error in str(excinfo.value)
