"""Tests for ``mgf.apiprobe.auth.flows``."""

from __future__ import annotations

import base64

import pytest

from mgf.apiprobe.auth.flows import (
    ApiKeyAuth,
    BasicAuth,
    BearerAuth,
    CookieAuth,
    CustomAuth,
)
from mgf.apiprobe.core.auth import AuthCredentials, AuthFlow
from mgf.apiprobe.exceptions import ApiprobeConfigError, AuthError


class TestProtocolConformance:
    @pytest.mark.parametrize(
        "build",
        [
            lambda: BearerAuth(token="x"),
            lambda: BasicAuth(username="u", password="p"),
            lambda: ApiKeyAuth(header="X-Api-Key", value="v"),
            lambda: CookieAuth(cookie_name="session", value="s"),
            lambda: CustomAuth(name="custom", prepare_func=lambda: AuthCredentials()),
        ],
    )
    def test_satisfies_authflow_protocol(self, build) -> None:
        flow = build()
        assert isinstance(flow, AuthFlow)


# ---------------------------------------------------------------------------
# BearerAuth
# ---------------------------------------------------------------------------


class TestBearerAuth:
    def test_direct_token(self) -> None:
        creds = BearerAuth(token="abc-123").prepare()
        assert creds.headers == (("Authorization", "Bearer abc-123"),)
        assert creds.cookies == ()
        assert creds.query_params == ()

    def test_env_token(self, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.setenv("MY_TOKEN", "env-resolved-token")
        creds = BearerAuth(env="MY_TOKEN").prepare()
        assert creds.headers == (("Authorization", "Bearer env-resolved-token"),)

    def test_construction_requires_one_source(self) -> None:
        with pytest.raises(ApiprobeConfigError, match="provide either"):
            BearerAuth()

    def test_construction_rejects_both_sources(self) -> None:
        with pytest.raises(ApiprobeConfigError, match="not both"):
            BearerAuth(token="x", env="MY_TOKEN")

    def test_unset_env_raises_auth_error(self, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.delenv("DEFINITELY_UNSET", raising=False)
        with pytest.raises(AuthError, match="DEFINITELY_UNSET"):
            BearerAuth(env="DEFINITELY_UNSET").prepare()

    def test_empty_env_raises_auth_error(self, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.setenv("EMPTY_TOKEN", "")
        with pytest.raises(AuthError, match="unset or empty"):
            BearerAuth(env="EMPTY_TOKEN").prepare()

    def test_name(self) -> None:
        assert BearerAuth(token="x").name == "bearer"

    # ── PAPER-40: configurable scheme ────────────────────────────────────

    def test_default_scheme_is_bearer(self) -> None:
        # Existing callers passed no scheme= — they must keep emitting
        # "Authorization: Bearer <token>" unchanged.
        creds = BearerAuth(token="abc").prepare()
        assert creds.headers == (("Authorization", "Bearer abc"),)

    def test_drf_token_scheme(self) -> None:
        # Django REST Framework's stock token-auth scheme is `Token`.
        creds = BearerAuth(token="abc", scheme="Token").prepare()
        assert creds.headers == (("Authorization", "Token abc"),)

    def test_github_lowercase_token_scheme(self) -> None:
        # GitHub PATs use lowercase `token`.
        creds = BearerAuth(token="ghp_xxx", scheme="token").prepare()
        assert creds.headers == (("Authorization", "token ghp_xxx"),)

    def test_vendor_specific_scheme(self) -> None:
        # AWS-style and other bespoke schemes — anything printable
        # ASCII without whitespace is accepted.
        creds = BearerAuth(token="sig", scheme="AWS4-HMAC-SHA256").prepare()
        assert creds.headers == (("Authorization", "AWS4-HMAC-SHA256 sig"),)

    def test_empty_scheme_rejected(self) -> None:
        with pytest.raises(ApiprobeConfigError, match="non-empty token"):
            BearerAuth(token="x", scheme="")

    def test_scheme_with_whitespace_rejected(self) -> None:
        with pytest.raises(ApiprobeConfigError, match="non-empty token"):
            BearerAuth(token="x", scheme="Two Words")

    def test_scheme_with_control_char_rejected(self) -> None:
        with pytest.raises(ApiprobeConfigError, match="non-empty token"):
            BearerAuth(token="x", scheme="Bad\x01Scheme")


# ---------------------------------------------------------------------------
# BasicAuth
# ---------------------------------------------------------------------------


class TestBasicAuth:
    def test_direct_password(self) -> None:
        creds = BasicAuth(username="alice", password="hunter2").prepare()
        # base64("alice:hunter2") = "YWxpY2U6aHVudGVyMg=="
        expected_token = base64.b64encode(b"alice:hunter2").decode()
        assert creds.headers == (("Authorization", f"Basic {expected_token}"),)

    def test_env_password(self, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.setenv("PASSWORD", "envpass")
        creds = BasicAuth(username="alice", password_env="PASSWORD").prepare()
        expected_token = base64.b64encode(b"alice:envpass").decode()
        assert creds.headers == (("Authorization", f"Basic {expected_token}"),)

    def test_empty_username_rejected(self) -> None:
        with pytest.raises(ApiprobeConfigError, match="must not be empty"):
            BasicAuth(username="", password="x")

    def test_no_password_source_rejected(self) -> None:
        with pytest.raises(ApiprobeConfigError, match="provide either"):
            BasicAuth(username="alice")

    def test_both_password_sources_rejected(self) -> None:
        with pytest.raises(ApiprobeConfigError, match="not both"):
            BasicAuth(username="alice", password="x", password_env="PASSWORD")

    def test_unset_env_raises_auth_error(self, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.delenv("BASIC_PW_UNSET", raising=False)
        with pytest.raises(AuthError):
            BasicAuth(username="alice", password_env="BASIC_PW_UNSET").prepare()


# ---------------------------------------------------------------------------
# ApiKeyAuth
# ---------------------------------------------------------------------------


class TestApiKeyAuth:
    def test_header_with_direct_value(self) -> None:
        creds = ApiKeyAuth(header="X-Api-Key", value="public-demo").prepare()
        assert creds.headers == (("X-Api-Key", "public-demo"),)
        assert creds.query_params == ()

    def test_header_with_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.setenv("APIKEY", "from-env")
        creds = ApiKeyAuth(header="X-Api-Key", env="APIKEY").prepare()
        assert creds.headers == (("X-Api-Key", "from-env"),)

    def test_query_with_direct_value(self) -> None:
        creds = ApiKeyAuth(query="api_key", value="qval").prepare()
        assert creds.query_params == (("api_key", "qval"),)
        assert creds.headers == ()

    def test_must_have_header_or_query(self) -> None:
        with pytest.raises(ApiprobeConfigError, match="header= or query="):
            ApiKeyAuth(value="v")

    def test_cannot_have_both_header_and_query(self) -> None:
        with pytest.raises(ApiprobeConfigError, match="not both"):
            ApiKeyAuth(header="X", query="x", value="v")

    def test_must_have_value_or_env(self) -> None:
        with pytest.raises(ApiprobeConfigError, match="value= or env="):
            ApiKeyAuth(header="X-Api-Key")

    def test_cannot_have_both_value_and_env(self) -> None:
        with pytest.raises(ApiprobeConfigError, match="not both"):
            ApiKeyAuth(header="X-Api-Key", value="v", env="X")

    def test_name_includes_location(self) -> None:
        assert ApiKeyAuth(header="X-Api-Key", value="v").name == "api-key:X-Api-Key"
        assert ApiKeyAuth(query="api_key", value="v").name == "api-key:api_key"


# ---------------------------------------------------------------------------
# CookieAuth
# ---------------------------------------------------------------------------


class TestCookieAuth:
    def test_direct_value(self) -> None:
        creds = CookieAuth(cookie_name="session", value="abc-123").prepare()
        assert creds.cookies == (("session", "abc-123"),)

    def test_env_value(self, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.setenv("SESSION_TOKEN", "env-cookie")
        creds = CookieAuth(cookie_name="session", env="SESSION_TOKEN").prepare()
        assert creds.cookies == (("session", "env-cookie"),)

    def test_empty_cookie_name_rejected(self) -> None:
        with pytest.raises(ApiprobeConfigError, match="must not be empty"):
            CookieAuth(cookie_name="", value="x")

    def test_no_value_source_rejected(self) -> None:
        with pytest.raises(ApiprobeConfigError, match="value= or env="):
            CookieAuth(cookie_name="session")

    def test_both_sources_rejected(self) -> None:
        with pytest.raises(ApiprobeConfigError, match="not both"):
            CookieAuth(cookie_name="session", value="x", env="X")

    def test_name_includes_cookie_name(self) -> None:
        assert CookieAuth(cookie_name="session", value="x").name == "cookie:session"


# ---------------------------------------------------------------------------
# CustomAuth
# ---------------------------------------------------------------------------


class TestCustomAuth:
    def test_returns_what_callable_returns(self) -> None:
        expected = AuthCredentials(headers=(("X", "Y"),))

        def _prepare() -> AuthCredentials:
            return expected

        flow = CustomAuth(name="x", prepare_func=_prepare)
        assert flow.prepare() is expected

    def test_empty_name_rejected(self) -> None:
        with pytest.raises(ApiprobeConfigError, match="must not be empty"):
            CustomAuth(name="", prepare_func=lambda: AuthCredentials())

    def test_non_callable_rejected(self) -> None:
        with pytest.raises(ApiprobeConfigError, match="callable"):
            CustomAuth(name="x", prepare_func="not callable")  # type: ignore[arg-type]

    def test_callable_raising_wraps_in_auth_error(self) -> None:
        def _bad() -> AuthCredentials:
            raise RuntimeError("network down")

        flow = CustomAuth(name="x", prepare_func=_bad)
        with pytest.raises(AuthError, match="network down"):
            flow.prepare()

    def test_callable_raising_auth_error_propagates_unchanged(self) -> None:
        def _bad() -> AuthCredentials:
            raise AuthError(flow_name="custom", reason="explicit")

        flow = CustomAuth(name="custom", prepare_func=_bad)
        with pytest.raises(AuthError, match="explicit"):
            flow.prepare()

    def test_callable_returning_wrong_type_rejected(self) -> None:
        def _bad() -> AuthCredentials:
            return "not credentials"  # type: ignore[return-value]

        flow = CustomAuth(name="x", prepare_func=_bad)
        with pytest.raises(AuthError, match="expected AuthCredentials"):
            flow.prepare()


# ---------------------------------------------------------------------------
# Probe integration smoke
# ---------------------------------------------------------------------------


class TestProbeIntegration:
    def test_probe_with_auth_carries_flow(self) -> None:
        from mgf.apiprobe.core.probe import Probe

        flow = BearerAuth(token="x")
        probe = Probe.get("/x").with_auth(flow)
        assert probe.auth is flow
        # Confirm auth.prepare works through the probe-stored ref.
        creds = probe.auth.prepare()  # type: ignore[union-attr]
        assert creds.headers == (("Authorization", "Bearer x"),)
