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

from __future__ import annotations

import importlib.util
import runpy
from pathlib import Path
import sys
from types import ModuleType
from typing import Any
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) -> "FakeResponse":
        return self

    async def __aexit__(self, *_args: Any) -> 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) -> "FakeSession":
        self.enter_count += 1
        return self

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

    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"


def test_main_converts_live_config_error_to_system_exit(monkeypatch: pytest.MonkeyPatch) -> None:
    """main() maps LiveConfigError from async_main to SystemExit.

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

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

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

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

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


@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


def test_main_converts_client_error_to_system_exit(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """main() converts aiohttp client transport exceptions into SystemExit.

    Args:
        monkeypatch (pytest.MonkeyPatch): Fixture that makes ``async_main`` raise a client error.
    """
    module = load_api_call_module()

    async def raise_connector_error() -> None:
        raise 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"),
        )

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

    assert "ClientConnectorError" in str(excinfo.value)


def test_main_converts_timeout_error_to_system_exit(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """main() converts timeout exceptions into concise SystemExit messages.

    Args:
        monkeypatch (pytest.MonkeyPatch): Fixture that makes ``async_main`` raise a timeout.
    """
    module = load_api_call_module()

    async def raise_timeout_error() -> None:
        raise TimeoutError("timeout while waiting for response")

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

    assert "TimeoutError" in str(excinfo.value)


def test_main_converts_os_error_to_system_exit(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """main() converts I/O errors into concise SystemExit messages.

    Args:
        monkeypatch (pytest.MonkeyPatch): Fixture that makes ``async_main`` raise an I/O error.
    """
    module = load_api_call_module()

    async def raise_os_error() -> None:
        raise OSError("output is unavailable")

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

    assert str(excinfo.value) == "OSError: output is unavailable"
