"""Credential + configuration loading for mgf-cloud.

Infomaniak Public Cloud speaks OpenStack Keystone. The automation-grade
credential is an **Application Credential** (id + secret). This module resolves a
validated :class:`Credentials` from, in order: an explicit ``clouds.yaml`` path,
the ``$MGF_CLOUD_CLOUDS_YAML`` override, the ``OS_*`` environment, then the
standard OpenStack ``clouds.yaml`` search paths. Secrets are held as pydantic
``SecretStr`` so they never appear in logs, reprs, or tracebacks (SC/EH).

No provider SDK is imported here — only stdlib + pyyaml + pydantic — so the core
stays light and this is unit-testable without a live cloud.
"""

from __future__ import annotations

import os
from pathlib import Path
from typing import TYPE_CHECKING

import yaml
from pydantic import BaseModel, ConfigDict, SecretStr

from mgf.cloud.exceptions import CredentialsError

if TYPE_CHECKING:
    from collections.abc import Mapping

__all__ = ["CloudConfig", "Credentials", "from_clouds_yaml", "from_env", "load"]

_ENV_OVERRIDE = "MGF_CLOUD_CLOUDS_YAML"


def _default_clouds_yaml_paths() -> tuple[Path, ...]:
    """Standard OpenStack ``clouds.yaml`` search locations (federation consumers
    pass an explicit path to their ``my_stuff`` copy instead of relying on these)."""
    return (
        Path.home() / ".config" / "openstack" / "clouds.yaml",
        Path("/etc/openstack/clouds.yaml"),
        Path.cwd() / "clouds.yaml",
    )


class Credentials(BaseModel):
    """Validated OpenStack auth — either an application credential (preferred for
    automation) or username/password. Secrets are :class:`~pydantic.SecretStr`."""

    model_config = ConfigDict(frozen=True)

    auth_url: str
    region_name: str | None = None
    interface: str = "public"
    identity_api_version: int = 3
    # Application credential (preferred).
    application_credential_id: str | None = None
    application_credential_secret: SecretStr | None = None
    # Username/password (alternative).
    username: str | None = None
    password: SecretStr | None = None
    project_id: str | None = None
    project_name: str | None = None
    user_domain_name: str | None = None
    project_domain_name: str | None = None

    @property
    def has_app_credential(self) -> bool:
        return bool(self.application_credential_id and self.application_credential_secret)

    @property
    def has_password(self) -> bool:
        return bool(self.username and self.password)

    @property
    def auth_type(self) -> str:
        """The OpenStack ``auth_type`` these credentials imply."""
        return "v3applicationcredential" if self.has_app_credential else "v3password"

    def require_complete(self) -> Credentials:
        """Return self if usable; else raise :class:`CredentialsError` naming what's missing."""
        missing: list[str] = []
        if not self.auth_url:
            missing.append("auth_url")
        if not (self.has_app_credential or self.has_password):
            missing.append("application_credential_id+secret (or username+password)")
        if missing:
            raise CredentialsError(
                "incomplete OpenStack credentials: missing " + ", ".join(missing),
                missing=tuple(missing),
            )
        return self


def _secret(value: str | None) -> SecretStr | None:
    """Wrap a plain string secret as ``SecretStr`` (or pass ``None`` through)."""
    return SecretStr(value) if value is not None else None


def _credentials_from_auth(
    auth: Mapping[str, object],
    *,
    region_name: str | None,
    interface: str,
    identity_api_version: int,
) -> Credentials:
    def s(key: str) -> str | None:
        value = auth.get(key)
        return str(value) if value is not None else None

    return Credentials(
        auth_url=s("auth_url") or "",
        region_name=region_name,
        interface=interface,
        identity_api_version=identity_api_version,
        application_credential_id=s("application_credential_id"),
        application_credential_secret=_secret(s("application_credential_secret")),
        username=s("username"),
        password=_secret(s("password")),
        project_id=s("project_id"),
        project_name=s("project_name"),
        user_domain_name=s("user_domain_name"),
        project_domain_name=s("project_domain_name"),
    )


def from_clouds_yaml(path: str | Path, cloud: str = "openstack") -> Credentials:
    """Load credentials from a ``clouds.yaml`` file's ``clouds.<cloud>`` entry."""
    p = Path(path)
    if not p.is_file():
        raise CredentialsError(f"clouds.yaml not found at {p}", source=str(p))
    data = yaml.safe_load(p.read_text(encoding="utf-8")) or {}
    clouds = data.get("clouds", {}) if isinstance(data, dict) else {}
    entry = clouds.get(cloud) if isinstance(clouds, dict) else None
    if not isinstance(entry, dict):
        have = ", ".join(clouds) if isinstance(clouds, dict) and clouds else "(none)"
        raise CredentialsError(
            f"cloud {cloud!r} not found in {p}; defined clouds: {have}", source=str(p)
        )
    auth = entry.get("auth", {})
    if not isinstance(auth, dict):
        raise CredentialsError(f"clouds.{cloud}.auth is malformed in {p}", source=str(p))
    creds = _credentials_from_auth(
        auth,
        region_name=entry.get("region_name"),
        interface=str(entry.get("interface", "public")),
        identity_api_version=int(entry.get("identity_api_version", 3)),
    )
    try:
        return creds.require_complete()
    except CredentialsError as exc:
        raise CredentialsError(str(exc), source=str(p), missing=exc.missing) from exc


def from_env(environ: Mapping[str, str] | None = None) -> Credentials:
    """Load credentials from the standard ``OS_*`` environment variables."""
    env = environ if environ is not None else os.environ
    if not env.get("OS_AUTH_URL"):
        raise CredentialsError("OS_AUTH_URL is not set", source="env", missing=("OS_AUTH_URL",))
    creds = Credentials(
        auth_url=env["OS_AUTH_URL"],
        region_name=env.get("OS_REGION_NAME"),
        interface=env.get("OS_INTERFACE", "public"),
        identity_api_version=int(env.get("OS_IDENTITY_API_VERSION", "3")),
        application_credential_id=env.get("OS_APPLICATION_CREDENTIAL_ID"),
        application_credential_secret=_secret(env.get("OS_APPLICATION_CREDENTIAL_SECRET")),
        username=env.get("OS_USERNAME"),
        password=_secret(env.get("OS_PASSWORD")),
        project_id=env.get("OS_PROJECT_ID"),
        project_name=env.get("OS_PROJECT_NAME"),
        user_domain_name=env.get("OS_USER_DOMAIN_NAME"),
        project_domain_name=env.get("OS_PROJECT_DOMAIN_NAME"),
    )
    try:
        return creds.require_complete()
    except CredentialsError as exc:
        raise CredentialsError(str(exc), source="env", missing=exc.missing) from exc


def load(
    clouds_yaml: str | Path | None = None,
    cloud: str = "openstack",
    environ: Mapping[str, str] | None = None,
) -> Credentials:
    """Resolve credentials: explicit path → ``$MGF_CLOUD_CLOUDS_YAML`` → ``OS_*`` env
    → standard ``clouds.yaml`` search paths. Raises :class:`CredentialsError` if none found."""
    env = environ if environ is not None else os.environ
    if clouds_yaml:
        return from_clouds_yaml(clouds_yaml, cloud)
    override = env.get(_ENV_OVERRIDE)
    if override:
        return from_clouds_yaml(override, cloud)
    if env.get("OS_AUTH_URL"):
        return from_env(env)
    tried: list[str] = []
    for candidate in _default_clouds_yaml_paths():
        tried.append(str(candidate))
        if candidate.is_file():
            return from_clouds_yaml(candidate, cloud)
    raise CredentialsError(
        "no OpenStack credentials found — set OS_* env, pass clouds_yaml=, "
        f"set ${_ENV_OVERRIDE}, or place a clouds.yaml in a standard location",
        source="search",
        missing=tuple(tried),
    )


class CloudConfig(BaseModel):
    """A provider choice + its resolved credentials (what the CLI/web build from)."""

    model_config = ConfigDict(frozen=True)

    provider: str = "infomaniak"
    credentials: Credentials

    @classmethod
    def load(
        cls,
        provider: str = "infomaniak",
        clouds_yaml: str | Path | None = None,
        cloud: str = "openstack",
        environ: Mapping[str, str] | None = None,
    ) -> CloudConfig:
        """Resolve credentials (see :func:`load`) and bundle them with ``provider``."""
        return cls(provider=provider, credentials=load(clouds_yaml, cloud, environ))
