"""Concrete :class:`AuthFlow` implementations.

See: ``__init__.py`` for usage notes. Each class satisfies the
:class:`mgf.apiprobe.core.auth.AuthFlow` runtime-checkable Protocol.

Memory model (SH-03)
--------------------

Every concrete flow here stores the credential string on the flow
instance (``self._token``, ``self._password``, ``self._value``, etc.)
from the moment it is read — either at ``__init__`` for direct
values, or at the first ``prepare()`` call for env-sourced values.
The string lives until the flow instance is garbage-collected,
typically when the suite run finishes.

Python does **not** permit deterministic erasure of ``str`` memory.
Strings are immutable and interned; ``del`` only releases the
reference, not the bytes. The C-level small-string cache can retain
the value past GC. For most consumers this is fine — secrets live in
memory anyway between probes, and the suite is a short-lived process.

For high-stakes contexts (rotating production credentials,
multi-tenant test harnesses sharing a Python process, attacker-with-
memory-access threat models) the right shape is **process
isolation**:

  - Run mgf-apiprobe in a short-lived subprocess per suite (or per
    tenant). The OS reclaims the page at process exit.
  - Rotate credentials between runs so a memory snapshot has limited
    forward value.
  - Read credentials from a vault that issues short-lived tokens
    (e.g., AWS STS, HashiCorp Vault dynamic secrets) rather than
    long-lived API keys.

See ``docs/recipes/how-to-handle-high-stakes-credentials.md`` for
the subprocess pattern (added with this tracker item).
"""

from __future__ import annotations

import base64
import os
from typing import TYPE_CHECKING

from mgf.apiprobe.core.auth import AuthCredentials
from mgf.apiprobe.exceptions import ApiprobeConfigError, AuthError

if TYPE_CHECKING:
    from collections.abc import Callable


def _resolve_credential(
    *,
    direct: str | None,
    env: str | None,
    flow_name: str,
    label: str,
) -> str:
    """Resolve a credential from direct OR env. Exactly one must be set.

    Construction-time errors raise :class:`ApiprobeConfigError`; runtime
    resolution errors (env var unset) raise :class:`AuthError`. The
    split matters — config errors get caught at suite-build time;
    auth errors surface during execution.
    """
    if direct is not None and env is not None:
        raise ApiprobeConfigError(f"{flow_name}: both {label} and {label}_env provided; choose one")
    if direct is not None:
        return direct
    if env is not None:
        value = os.environ.get(env)
        if value is None or value == "":
            raise AuthError(
                flow_name=flow_name,
                reason=f"environment variable {env!r} is unset or empty",
            )
        return value
    raise ApiprobeConfigError(f"{flow_name}: provide either {label}= or {label}_env=")


class BearerAuth:
    """``Authorization: <scheme> <token>`` — default scheme ``Bearer``.

    Construct with either the token directly or the name of an env var
    holding it::

        BearerAuth(token="abc-123")                     # → "Bearer abc-123"
        BearerAuth(env="MY_API_TOKEN")                  # → "Bearer <env value>"
        BearerAuth(token="abc-123", scheme="Token")     # → "Token abc-123"

    The ``scheme=`` kwarg defaults to ``"Bearer"`` (RFC 6750) so
    existing call sites are unaffected. Override it for Django REST
    Framework's ``Token`` scheme, GitHub PATs (``token`` lowercase),
    or any in-house API whose chosen scheme isn't ``Bearer``.

    The scheme is validated at construction: it must be a non-empty
    string of token characters (no whitespace, no control chars) per
    RFC 7235's ``auth-scheme`` ABNF. An invalid scheme raises
    :class:`ApiprobeConfigError` so the failure points at the
    misconfigured flow, not at a confusing header injection later.

    PAPER-40 (closes the "BearerAuth hardcodes Bearer" feedback from
    inthewords' DRF Suite adoption).
    """

    name: str = "bearer"

    def __init__(
        self,
        *,
        token: str | None = None,
        env: str | None = None,
        scheme: str = "Bearer",
    ) -> None:
        # Construction-time validation: catch the obvious mistakes
        # before the suite builds.
        if token is None and env is None:
            raise ApiprobeConfigError("BearerAuth: provide either token= or env=")
        if token is not None and env is not None:
            raise ApiprobeConfigError("BearerAuth: provide either token= or env=, not both")
        # RFC 7235 auth-scheme = token (1*tchar). Reject any
        # whitespace or control chars; reject empty. The check is
        # conservative — any printable non-whitespace ASCII passes,
        # so unusual schemes (vendor-specific like "AWS4-HMAC-SHA256")
        # still work.
        if not scheme or any(c.isspace() or ord(c) < 0x21 or ord(c) > 0x7E for c in scheme):
            raise ApiprobeConfigError(
                f"BearerAuth: scheme= must be a non-empty token (no whitespace / "
                f"control chars). Got: {scheme!r}"
            )
        self._token = token
        self._env = env
        self._scheme = scheme

    def prepare(self) -> AuthCredentials:
        token = _resolve_credential(
            direct=self._token,
            env=self._env,
            flow_name=self.name,
            label="token",
        )
        return AuthCredentials(headers=(("Authorization", f"{self._scheme} {token}"),))


class BasicAuth:
    """``Authorization: Basic <base64(user:pass)>``.

    The username is always direct (it's not secret). The password
    follows the direct/env split.
    """

    name: str = "basic"

    def __init__(
        self,
        *,
        username: str,
        password: str | None = None,
        password_env: str | None = None,
    ) -> None:
        if not username:
            raise ApiprobeConfigError("BasicAuth: username must not be empty")
        if password is None and password_env is None:
            raise ApiprobeConfigError("BasicAuth: provide either password= or password_env=")
        if password is not None and password_env is not None:
            raise ApiprobeConfigError(
                "BasicAuth: provide either password= or password_env=, not both"
            )
        self._username = username
        self._password = password
        self._password_env = password_env

    def prepare(self) -> AuthCredentials:
        password = _resolve_credential(
            direct=self._password,
            env=self._password_env,
            flow_name=self.name,
            label="password",
        )
        token = base64.b64encode(f"{self._username}:{password}".encode()).decode("ascii")
        return AuthCredentials(headers=(("Authorization", f"Basic {token}"),))


class ApiKeyAuth:
    """API key in a header OR a query param.

    Exactly one of ``header=`` / ``query=`` must be set::

        ApiKeyAuth(header="X-Api-Key", env="MY_KEY")
        ApiKeyAuth(query="api_key", value="public-demo-key")
    """

    def __init__(
        self,
        *,
        header: str | None = None,
        query: str | None = None,
        value: str | None = None,
        env: str | None = None,
    ) -> None:
        if header is None and query is None:
            raise ApiprobeConfigError("ApiKeyAuth: provide either header= or query=")
        if header is not None and query is not None:
            raise ApiprobeConfigError("ApiKeyAuth: provide either header= or query=, not both")
        if value is None and env is None:
            raise ApiprobeConfigError("ApiKeyAuth: provide either value= or env=")
        if value is not None and env is not None:
            raise ApiprobeConfigError("ApiKeyAuth: provide either value= or env=, not both")
        self._header = header
        self._query = query
        self._value = value
        self._env = env
        # Public name surfaces the location for AuthError + reports.
        self.name = f"api-key:{header or query}"

    def prepare(self) -> AuthCredentials:
        value = _resolve_credential(
            direct=self._value,
            env=self._env,
            flow_name=self.name,
            label="value",
        )
        if self._header is not None:
            return AuthCredentials(headers=((self._header, value),))
        # _query is set (guaranteed by constructor).
        assert self._query is not None
        return AuthCredentials(query_params=((self._query, value),))


class CookieAuth:
    """Session cookie.

    The cookie name + value pair is set on every request. Cookie jars,
    multi-cookie flows, and `Set-Cookie` echoing are out of scope for
    v0.1; for those use :class:`CustomAuth`.
    """

    def __init__(
        self,
        *,
        cookie_name: str,
        value: str | None = None,
        env: str | None = None,
    ) -> None:
        if not cookie_name:
            raise ApiprobeConfigError("CookieAuth: cookie_name must not be empty")
        if value is None and env is None:
            raise ApiprobeConfigError("CookieAuth: provide either value= or env=")
        if value is not None and env is not None:
            raise ApiprobeConfigError("CookieAuth: provide either value= or env=, not both")
        self._cookie_name = cookie_name
        self._value = value
        self._env = env
        self.name = f"cookie:{cookie_name}"

    def prepare(self) -> AuthCredentials:
        value = _resolve_credential(
            direct=self._value,
            env=self._env,
            flow_name=self.name,
            label="value",
        )
        return AuthCredentials(cookies=((self._cookie_name, value),))


class CustomAuth:
    """User-supplied :meth:`prepare` callable.

    The escape hatch for any auth scheme not covered by the four
    concrete flows above. Common uses: vault-backed token resolution,
    request signing (HMAC, AWS SigV4 in v0.6+), or any flow that needs
    to combine multiple fragments::

        def _prepare() -> AuthCredentials:
            sig = hmac_sign(...)
            return AuthCredentials(
                headers=(
                    ("X-Auth-Token", token),
                    ("X-Auth-Signature", sig),
                ),
            )

        auth = CustomAuth(name="hmac:my-service", prepare_func=_prepare)

    The callable is invoked once per request that uses this flow. It
    MUST return :class:`AuthCredentials`; any other type raises
    :class:`AuthError` at runtime.
    """

    def __init__(
        self,
        *,
        name: str,
        prepare_func: Callable[[], AuthCredentials],
    ) -> None:
        if not name:
            raise ApiprobeConfigError("CustomAuth: name must not be empty")
        if not callable(prepare_func):
            raise ApiprobeConfigError(
                f"CustomAuth: prepare_func must be callable; got {prepare_func!r}"
            )
        self.name = name
        self._prepare_func = prepare_func

    def prepare(self) -> AuthCredentials:
        try:
            result = self._prepare_func()
        except AuthError:
            raise  # already typed; let it propagate.
        except Exception as exc:
            raise AuthError(
                flow_name=self.name,
                reason=f"prepare_func raised: {exc}",
            ) from exc
        if not isinstance(result, AuthCredentials):
            raise AuthError(
                flow_name=self.name,
                reason=(f"prepare_func returned {type(result).__name__}, expected AuthCredentials"),
            )
        return result


__all__ = [
    "ApiKeyAuth",
    "BasicAuth",
    "BearerAuth",
    "CookieAuth",
    "CustomAuth",
]
