"""Tests for the raw OPNsense API caller."""

from __future__ import annotations

from collections.abc import Callable
import importlib.util
from pathlib import Path
import runpy
import sys
from types import ModuleType
from typing import Any, Self

import aiohttp
import pytest


def load_api_call_module() -> ModuleType:
    """Load the script module through importlib for direct unit testing.

    Returns:
        ModuleType: Imported raw-API script module under test.
    """
    module_path = Path(__file__).parents[1] / "scripts" / "opnsense_api_call.py"
    sys.path.insert(0, str(module_path.parent))
    spec = importlib.util.spec_from_file_location("opnsense_api_call", 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 FakeResponse:
    """Async response stub with typed return behavior."""

    status: int
    reason: str
    headers: dict[str, str]
    _json: Any
    _text: str
    _json_error: Exception | None

    def __init__(
        self,
        status: int,
        reason: str,
        headers: dict[str, str] | None = None,
        json_payload: Any = None,
        text: str = "",
        json_error: Exception | None = None,
    ) -> None:
        """Create a minimal aiohttp-like response stub.

        Args:
            status (int): HTTP status exposed by the fake response.
            reason (str): HTTP reason phrase exposed by the fake response.
            headers (dict[str, str] | None): Response headers used for content detection.
            json_payload (Any): JSON body returned when parsing succeeds.
            text (str): Text body returned when JSON parsing is unavailable.
            json_error (Exception | None): Parsing error raised by ``json`` when configured.
        """
        self.status = status
        self.reason = reason
        self.headers = headers or {}
        self._json = json_payload
        self._text = text
        self._json_error = json_error

    async def __aenter__(self) -> Self:
        return self

    async def __aexit__(self, *_args: object) -> None:
        return None

    async def json(self, *_args: Any, **_kwargs: Any) -> Any:
        """Parse JSON payload for response body.

        Args:
            _args (Any): Positional arguments accepted by `json`.
            _kwargs (Any): Keyword arguments accepted by `json`.

        Returns:
            Any: Configured JSON response body.

        Raises:
            self._json_error: Raised when the fake response is configured with a JSON error.
        """
        if self._json_error is not None:
            raise self._json_error
        return self._json

    async def text(self) -> str:
        """Return text fallback body.

        Returns:
            str: Configured plain-text response body.
        """
        return self._text


class FakeSession:
    """Session stub that records GET and POST call parameters."""

    get_calls: list[tuple[str, dict[str, Any]]]
    post_calls: list[tuple[str, dict[str, Any]]]
    get_response: FakeResponse
    post_response: FakeResponse
    enter_count: int
    exit_count: int

    def __init__(
        self,
        get_response: FakeResponse | None = None,
        post_response: FakeResponse | None = None,
    ) -> None:
        """Initialize call history and response stubs.

        Args:
            get_response (FakeResponse | None): Response returned by recorded GET requests.
            post_response (FakeResponse | None): Response returned by recorded POST requests.
        """
        self.get_calls = []
        self.post_calls = []
        self.get_response = get_response or FakeResponse(200, "OK", json_payload={"default": True})
        self.post_response = post_response or FakeResponse(
            200, "OK", json_payload={"default": True}
        )
        self.enter_count = 0
        self.exit_count = 0

    async def __aenter__(self) -> Self:
        self.enter_count += 1
        return self

    async def __aexit__(self, *_args: object) -> None:
        self.exit_count += 1

    def get(self, url: str, **kwargs: object) -> FakeResponse:
        """Record GET call and return response context.

        Args:
            url (str): Request URL recorded for the GET call.
            kwargs (object): Keyword arguments accepted by `get`.

        Returns:
            FakeResponse: Configured GET response context manager.
        """
        self.get_calls.append((url, kwargs))
        return self.get_response

    def post(self, url: str, **kwargs: object) -> FakeResponse:
        """Record POST call and return response context.

        Args:
            url (str): Request URL recorded for the POST call.
            kwargs (object): Keyword arguments accepted by `post`.

        Returns:
            FakeResponse: Configured POST response context manager.
        """
        self.post_calls.append((url, kwargs))
        return self.post_response


def test_normalize_endpoint_requires_leading_slash() -> None:
    """Endpoint values are normalized to include a leading slash."""
    module = load_api_call_module()

    assert module.normalize_endpoint("/status") == "/status"
    assert module.normalize_endpoint("status") == "/status"


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

    parsed = module.parse_args(["--endpoint", "status", "--method", "get"])

    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_api_call_module()
    parser = module.build_parser()

    parsed = parser.parse_args(["--endpoint", "status", "--method", "get"])

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


def test_normalize_endpoint_rejects_blank_value() -> None:
    """Blank endpoints are rejected with a clear ``ValueError``."""
    module = load_api_call_module()

    with pytest.raises(ValueError, match="endpoint cannot be blank"):
        module.normalize_endpoint("   ")


def test_load_payload_from_json_string() -> None:
    """Inline payload strings parse only JSON objects."""
    module = load_api_call_module()

    assert module.load_payload('{"ok": true}', None) == {"ok": True}


def test_load_payload_from_file(tmp_path: Path) -> None:
    """Payload file input supports JSON objects.

    Args:
        tmp_path (Path): Temporary directory containing the JSON payload file.
    """
    module = load_api_call_module()
    payload_file = tmp_path / "payload.json"
    payload_file.write_text('{"from": "file"}', encoding="utf-8")

    assert module.load_payload(None, payload_file) == {"from": "file"}


def test_load_payload_without_payload_uses_no_payload_marker() -> None:
    """Absent payload input preserves omitted-body semantics."""
    module = load_api_call_module()

    assert module.load_payload(None, None) is module._NO_PAYLOAD


def test_load_payload_rejects_both_sources() -> None:
    """Rejecting both inline and file payload sources avoids ambiguous payload input."""
    module = load_api_call_module()
    with pytest.raises(ValueError, match="Specify only one"):
        module.load_payload('{"ok": true}', Path("payload.json"))


def test_load_payload_rejects_non_object_json() -> None:
    """Non-object payload values are rejected as invalid body."""
    module = load_api_call_module()

    with pytest.raises(ValueError, match="must be a JSON object"):
        module.load_payload("[1, 2, 3]", None)


def test_load_payload_invalid_json() -> None:
    """Malformed JSON input raises a clear error."""
    module = load_api_call_module()

    with pytest.raises(ValueError, match="Invalid JSON"):
        module.load_payload("{bad json}", None)


@pytest.mark.asyncio
async def test_call_api_get_returns_response_metadata() -> None:
    """GET calls return response metadata and parsed JSON body."""
    module = load_api_call_module()
    config = module.LiveConfig(
        url="https://firewall.example.test/",
        api_key="key",
        api_secret="secret",
        verify_ssl=True,
    )
    response = FakeResponse(
        status=200,
        reason="OK",
        headers={"content-type": "application/json"},
        json_payload={"status": "alive"},
        text='{"status":"alive"}',
    )
    session = FakeSession(get_response=response)

    result = await module.call_api(session, config, "api/v1/status", "get", None)

    assert result["method"] == "GET"
    assert result["endpoint"] == "/api/v1/status"
    assert result["url"] == "https://firewall.example.test/api/v1/status"
    assert result["status"] == 200
    assert result["reason"] == "OK"
    assert result["headers"] == {"content-type": "application/json"}
    assert result["json"] == {"status": "alive"}
    assert result["text"] is None
    assert len(session.get_calls) == 1
    _, kwargs = session.get_calls[0]
    assert isinstance(kwargs["auth"], aiohttp.BasicAuth)
    assert kwargs["auth"].login == "key"
    assert kwargs["auth"].password == "secret"
    assert kwargs["timeout"] == aiohttp.ClientTimeout(total=60)
    assert kwargs["ssl"] is True
    assert "json" not in kwargs


@pytest.mark.asyncio
async def test_call_api_post_sends_payload() -> None:
    """POST calls include requested payload JSON body."""
    module = load_api_call_module()
    config = module.LiveConfig(
        url="https://firewall.example.test/",
        api_key="key",
        api_secret="secret",
        verify_ssl=True,
    )
    response = FakeResponse(
        status=201,
        reason="Created",
        headers={"content-type": "application/json"},
        json_payload={"result": "created"},
    )
    session = FakeSession(post_response=response)

    await module.call_api(
        session,
        config,
        "/api/v1/configure",
        "post",
        {"enabled": True},
    )

    assert len(session.post_calls) == 1
    _, kwargs = session.post_calls[0]
    assert kwargs["json"] == {"enabled": True}
    assert isinstance(kwargs["auth"], aiohttp.BasicAuth)
    assert kwargs["auth"].login == "key"
    assert kwargs["auth"].password == "secret"
    assert kwargs["timeout"] == aiohttp.ClientTimeout(total=60)
    assert kwargs["ssl"] is True


@pytest.mark.asyncio
async def test_call_api_post_without_payload_omits_json_body() -> None:
    """POST calls without payload do not synthesize an empty JSON object."""
    module = load_api_call_module()
    config = module.LiveConfig(
        url="https://firewall.example.test/",
        api_key="key",
        api_secret="secret",
        verify_ssl=True,
    )
    session = FakeSession()

    await module.call_api(
        session,
        config,
        "/api/v1/configure",
        "post",
        module._NO_PAYLOAD,
    )

    assert len(session.post_calls) == 1
    _, kwargs = session.post_calls[0]
    assert "json" not in kwargs


@pytest.mark.asyncio
async def test_call_api_post_sends_empty_object_payload() -> None:
    """POST calls preserve explicit empty JSON object payloads."""
    module = load_api_call_module()
    config = module.LiveConfig(
        url="https://firewall.example.test/",
        api_key="key",
        api_secret="secret",
        verify_ssl=True,
    )
    session = FakeSession()

    await module.call_api(
        session,
        config,
        "/api/v1/configure",
        "post",
        {},
    )

    assert len(session.post_calls) == 1
    _, kwargs = session.post_calls[0]
    assert kwargs["json"] == {}


@pytest.mark.asyncio
async def test_call_api_falls_back_to_text_for_non_json_response() -> None:
    """Non-JSON response bodies fall back to plain text."""
    module = load_api_call_module()
    config = module.LiveConfig(
        url="https://firewall.example.test/",
        api_key="key",
        api_secret="secret",
        verify_ssl=False,
    )
    response = FakeResponse(
        status=200,
        reason="OK",
        headers={"content-type": "text/plain"},
        json_payload={},
        text="plain text response",
        json_error=ValueError("not json"),
    )
    session = FakeSession(get_response=response)

    result = await module.call_api(session, config, "/api/v1/text", "get", None)

    assert result["json"] is None
    assert result["text"] == "plain text response"


@pytest.mark.asyncio
async def test_call_api_preserves_non_2xx_json_body() -> None:
    """Non-2xx response status/reason/body are still returned."""
    module = load_api_call_module()
    config = module.LiveConfig(
        url="https://firewall.example.test/",
        api_key="key",
        api_secret="secret",
        verify_ssl=True,
    )
    response = FakeResponse(
        status=404,
        reason="Not Found",
        headers={"content-type": "application/json"},
        json_payload={"error": "missing"},
        text='{"error": "missing"}',
    )
    session = FakeSession(get_response=response)

    result = await module.call_api(session, config, "/api/v1/missing", "get", None)

    assert result["status"] == 404
    assert result["reason"] == "Not Found"
    assert result["json"] == {"error": "missing"}
    assert result["text"] is None


@pytest.mark.asyncio
async def test_call_api_preserves_non_2xx_text_body() -> None:
    """Non-2xx non-JSON responses preserve status and fallback text."""
    module = load_api_call_module()
    config = module.LiveConfig(
        url="https://firewall.example.test/",
        api_key="key",
        api_secret="secret",
        verify_ssl=True,
    )
    response = FakeResponse(
        status=404,
        reason="Not Found",
        headers={"content-type": "text/plain"},
        text="resource not found",
        json_error=ValueError("not json"),
    )
    session = FakeSession(get_response=response)

    result = await module.call_api(session, config, "/api/v1/missing", "get", None)

    assert result["status"] == 404
    assert result["reason"] == "Not Found"
    assert result["json"] is None
    assert result["text"] == "resource not found"


@pytest.mark.parametrize(
    ("payload", "name"),
    [('{"x":1}', "object"), ("{bad_json}", "invalid-json"), ("[1, 2, 3]", "non-object")],
)
@pytest.mark.asyncio
async def test_async_main_rejects_payload_with_get_method(
    tmp_path: Path,
    capsys: pytest.CaptureFixture[str],
    monkeypatch: pytest.MonkeyPatch,
    payload: str,
    name: str,
) -> None:
    """GET + payload options are rejected before body parsing or output writes.

    Args:
        tmp_path (Path): Temporary directory for the rejected output path.
        capsys (pytest.CaptureFixture[str]): Fixture that captures the parser error.
        monkeypatch (pytest.MonkeyPatch): Fixture that captures output-write attempts.
        payload (str): Inline payload supplied with the invalid GET request.
        name (str): Case label used in the temporary output filename.
    """
    module = load_api_call_module()
    output_file = tmp_path / f"ignored-{name}.json"
    write_calls: list[tuple[dict[str, Any], Path | None]] = []

    def fake_write_output(payload: dict[str, Any], output: Path | None) -> None:
        write_calls.append((payload, output))

    monkeypatch.setattr(module, "write_output", fake_write_output)

    with pytest.raises(SystemExit):
        await module.async_main(
            [
                "--endpoint",
                "/api/v1/status",
                "--method",
                "get",
                "--payload",
                payload,
                "--output",
                str(output_file),
            ]
        )

    assert not write_calls
    assert not output_file.exists()
    assert (
        "--payload and --payload-file are only valid with --method post" in capsys.readouterr().err
    )


@pytest.mark.asyncio
async def test_async_main_rejects_payload_file_with_get_method(
    tmp_path: Path,
    capsys: pytest.CaptureFixture[str],
) -> None:
    """GET + --payload-file is rejected with required parser error text.

    Args:
        tmp_path (Path): Temporary directory containing the payload file.
        capsys (pytest.CaptureFixture[str]): Fixture that captures the parser error.
    """
    module = load_api_call_module()
    payload_file = tmp_path / "payload.json"
    payload_file.write_text('{"ok": true}', encoding="utf-8")

    with pytest.raises(SystemExit):
        await module.async_main(
            [
                "--endpoint",
                "/api/v1/status",
                "--method",
                "get",
                "--payload-file",
                str(payload_file),
            ]
        )

    assert (
        "--payload and --payload-file are only valid with --method post" in capsys.readouterr().err
    )


@pytest.mark.asyncio
async def test_async_main_rejects_blank_endpoint() -> None:
    """Blank endpoint values fail normalization and exit through parser.error."""
    module = load_api_call_module()

    with pytest.raises(SystemExit):
        await module.async_main(["--endpoint", "   ", "--method", "post"])


@pytest.mark.asyncio
async def test_async_main_rejects_invalid_payload_json() -> None:
    """Invalid inline JSON payload errors are surfaced by parser.error."""
    module = load_api_call_module()

    with pytest.raises(SystemExit):
        await module.async_main(
            [
                "--endpoint",
                "/api/v1/test",
                "--method",
                "post",
                "--payload",
                "{bad_json}",
            ]
        )


@pytest.mark.asyncio
async def test_async_main_rejects_non_object_payload_json() -> None:
    """Non-object inline JSON payload errors are surfaced by parser.error."""
    module = load_api_call_module()

    with pytest.raises(SystemExit):
        await module.async_main(
            [
                "--endpoint",
                "/api/v1/test",
                "--method",
                "post",
                "--payload",
                "[1, 2, 3]",
            ]
        )


@pytest.mark.asyncio
async def test_async_main_rejects_both_payload_sources_for_post(
    tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
    """POST requests with both payload sources use load_payload conflict message.

    Args:
        tmp_path (Path): Temporary directory containing the file payload.
        capsys (pytest.CaptureFixture[str]): Fixture that captures the parser error.
    """
    module = load_api_call_module()
    payload_file = tmp_path / "payload.json"
    payload_file.write_text('{"from": "file"}', encoding="utf-8")

    with pytest.raises(SystemExit):
        await module.async_main(
            [
                "--endpoint",
                "/api/v1/status",
                "--method",
                "post",
                "--payload",
                "{}",
                "--payload-file",
                str(payload_file),
            ]
        )

    assert "Specify only one of --payload or --payload-file." in capsys.readouterr().err


@pytest.mark.asyncio
async def test_async_main_rejects_missing_payload_file(
    tmp_path: Path,
    capsys: pytest.CaptureFixture[str],
) -> None:
    """Missing payload files become parser errors instead of filesystem tracebacks.

    Args:
        tmp_path (Path): Temporary directory used to form the missing file path.
        capsys (pytest.CaptureFixture[str]): Fixture that captures the parser error.
    """
    module = load_api_call_module()
    missing_file = tmp_path / "missing.json"

    with pytest.raises(SystemExit):
        await module.async_main(
            [
                "--endpoint",
                "/api/v1/status",
                "--method",
                "post",
                "--payload-file",
                str(missing_file),
            ]
        )

    assert "Unable to read --payload-file" in capsys.readouterr().err


@pytest.mark.asyncio
async def test_async_main_writes_output_and_closes_session(
    monkeypatch: pytest.MonkeyPatch,
    tmp_path: Path,
) -> None:
    """Happy path runs the call and guarantees session lifecycle completion.

    Args:
        monkeypatch (pytest.MonkeyPatch): Fixture that replaces live dependencies with fakes.
        tmp_path (Path): Temporary directory containing the expected output file.
    """
    module = load_api_call_module()
    config = module.LiveConfig(
        url="https://firewall.example.test/",
        api_key="key",
        api_secret="secret",
        verify_ssl=True,
    )
    session = FakeSession()
    output_path = tmp_path / "result.json"
    call_args: list[tuple[Any, ...]] = []
    write_calls: list[tuple[dict[str, Any], Path | None]] = []

    def fake_load_live_config(_env_file: Any) -> Any:
        return config

    async def fake_call_api(
        call_session: Any,
        call_config: Any,
        endpoint: str,
        method: str,
        payload: dict[str, Any] | None,
    ) -> dict[str, Any]:
        call_args.append((call_session, call_config, endpoint, method, payload))
        return {"ok": True}

    monkeypatch.setattr(module, "load_live_config", fake_load_live_config)
    monkeypatch.setattr(module.aiohttp, "ClientSession", lambda: session)
    monkeypatch.setattr(module, "call_api", fake_call_api)

    def fake_write_output(payload: dict[str, Any], output: Path | None) -> None:
        write_calls.append((payload, output))
        output_path.write_text(str(payload), encoding="utf-8")

    monkeypatch.setattr(module, "write_output", fake_write_output)

    exit_code = await module.async_main(
        [
            "--endpoint",
            "api/v1/status",
            "--method",
            "post",
            "--payload",
            "{}",
            "--output",
            str(output_path),
        ]
    )

    assert exit_code == 0
    assert session.enter_count == 1
    assert session.exit_count == 1
    assert call_args == [
        (session, config, "/api/v1/status", "post", {}),
    ]
    assert write_calls == [({"ok": True}, output_path)]
    assert output_path.exists()


@pytest.mark.asyncio
async def test_async_main_without_payload_passes_no_payload_marker(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """POST requests without payload preserve omitted-body semantics through dispatch.

    Args:
        monkeypatch (pytest.MonkeyPatch): Fixture that replaces live dependencies with fakes.
    """
    module = load_api_call_module()
    config = module.LiveConfig(
        url="https://firewall.example.test/",
        api_key="key",
        api_secret="secret",
        verify_ssl=True,
    )
    session = FakeSession()
    call_args: list[tuple[Any, ...]] = []

    def fake_load_live_config(_env_file: Any) -> Any:
        return config

    async def fake_call_api(
        call_session: Any,
        call_config: Any,
        endpoint: str,
        method: str,
        payload: object,
    ) -> dict[str, Any]:
        call_args.append((call_session, call_config, endpoint, method, payload))
        return {"ok": True}

    monkeypatch.setattr(module, "load_live_config", fake_load_live_config)
    monkeypatch.setattr(module.aiohttp, "ClientSession", lambda: session)
    monkeypatch.setattr(module, "call_api", fake_call_api)
    monkeypatch.setattr(module, "write_output", lambda *_args: None)

    exit_code = await module.async_main(
        [
            "--endpoint",
            "api/v1/status",
            "--method",
            "post",
        ]
    )

    assert exit_code == 0
    assert call_args == [
        (session, config, "/api/v1/status", "post", module._NO_PAYLOAD),
    ]


def test_entrypoint_exits_with_help_status_zero() -> None:
    """Executing the script as __main__ with --help exits with status 0."""
    script_path = Path(__file__).parents[1] / "scripts" / "opnsense_api_call.py"
    monkeypatch_argv = ["opnsense_api_call.py", "--help"]
    original_argv = sys.argv
    sys.argv = monkeypatch_argv
    try:
        with pytest.raises(SystemExit) as excinfo:
            runpy.run_path(str(script_path), run_name="__main__")
    finally:
        sys.argv = original_argv

    assert excinfo.value.code == 0


@pytest.mark.parametrize(
    ("exception_factory", "expected_message", "exact"),
    [
        pytest.param(
            lambda module: module.LiveConfigError("bad config"),
            "bad config",
            False,
            id="live-config-error",
        ),
        pytest.param(
            lambda module: module.aiohttp.ClientConnectorError(
                module.aiohttp.client_reqrep.ConnectionKey(
                    host="localhost",
                    port=443,
                    is_ssl=False,
                    ssl=None,
                    proxy=None,
                    proxy_auth=None,
                    proxy_headers_hash=None,
                ),
                OSError("boom"),
            ),
            "ClientConnectorError",
            False,
            id="client-connector-error",
        ),
        pytest.param(
            lambda _module: TimeoutError("timeout while waiting for response"),
            "TimeoutError",
            False,
            id="timeout-error",
        ),
        pytest.param(
            lambda _module: OSError("output is unavailable"),
            "OSError: output is unavailable",
            True,
            id="os-error",
        ),
    ],
)
def test_main_converts_expected_errors_to_system_exit(
    monkeypatch: pytest.MonkeyPatch,
    exception_factory: Callable[[ModuleType], Exception],
    expected_message: str,
    exact: bool,
) -> None:
    """main() maps expected failures to concise user-visible SystemExit messages.

    Args:
        monkeypatch (pytest.MonkeyPatch): Fixture that makes ``async_main`` raise the failure.
        exception_factory (Callable[[ModuleType], Exception]): Factory for the failure case.
        expected_message (str): Exact message or substring expected in ``SystemExit``.
        exact (bool): Whether the complete exit message must match.
    """
    module = load_api_call_module()

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

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

    monkeypatch.setattr(module, "async_main", raise_error)
    with pytest.raises(SystemExit) as excinfo:
        module.main()

    message = str(excinfo.value)
    if exact:
        assert message == expected_message
    else:
        assert expected_message in message
