"""Setup Core V2 state-machine engine.

Pure functions — no I/O, no network calls, no filesystem access.
All input arrives via :class:`~otto.setup_core.models.SetupChoice`;
all output is captured in :class:`~otto.setup_core.models.SetupPlan`.

Requirement matrix
------------------

+---------------------+-----------+------------------------+---------------------+
| channels            | auth_mode | requires_telegram_tok  | requires_api_key    |
+=====================+===========+========================+=====================+
| cli only            | api_key   | False                  | True (if env known) |
| cli only            | oauth     | False                  | False               |
| telegram (+ cli)    | api_key   | True                   | True (if env known) |
| telegram (+ cli)    | oauth     | True                   | False               |
| any                 | auto+cred | (as oauth path)        | (as oauth path)     |
| any                 | auto-cred | (as api_key path)      | (as api_key path)   |
+---------------------+-----------+------------------------+---------------------+

Additional rules
----------------
* OAuth mode is only valid for providers in ``OAUTH_CAPABLE_PROVIDERS``.
* Local providers (e.g. ``ollama``) map to ``None`` env var -> no API key required.
* ``auto`` resolves to ``oauth`` when ``choice.oauth_credentials_present`` is True
  AND the model has an OAuth-capable provider; otherwise resolves to ``api_key``.
"""

from otto.setup_core.models import (
    OAUTH_CAPABLE_PROVIDERS,
    OAUTH_MODEL_PREFIXES,
    PROVIDER_ENV_VARS,
    CredentialGap,
    SetupChoice,
    SetupPlan,
    SetupRequirements,
    SetupValidationError,
)

# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------


def _normalize_channels(channels: tuple[str, ...]) -> tuple[str, ...]:
    """Deduplicate, strip, lowercase, and sort channel names."""
    cleaned: set[str] = set()
    for raw in channels:
        stripped = raw.strip().lower()
        if stripped:
            cleaned.add(stripped)
    if not cleaned:
        return ("cli",)
    return tuple(sorted(cleaned))


def _infer_model_provider(model: str) -> str | None:
    """Return the canonical provider name from a ``provider/model`` ID, or ``None``."""
    stripped = model.strip()
    if "/" not in stripped:
        return None
    return stripped.split("/", 1)[0].strip().lower()


def _infer_oauth_provider(model: str) -> str | None:
    """Return the OAuth provider name for *model*, or ``None`` if not OAuth-capable."""
    normalized = model.strip().lower()
    for provider, prefixes in OAUTH_MODEL_PREFIXES.items():
        for prefix in prefixes:
            norm_prefix = prefix.strip().lower().rstrip("/")
            if normalized == norm_prefix or normalized.startswith(f"{norm_prefix}/"):
                return provider
    return None


def _provider_env_var(provider: str | None) -> str | None:
    """Return the env-var name for *provider*, or a generated fallback.

    Returns ``None`` for providers that explicitly need no key (local providers).
    """
    if provider is None:
        return None
    key = provider.strip().lower().rstrip("/")
    if not key:
        return None
    if key in PROVIDER_ENV_VARS:
        return PROVIDER_ENV_VARS[key]
    # Unknown provider -- generate a conventional name.
    return f"{key.upper()}_API_KEY"


def _resolve_auth_mode(
    requested: str,
    *,
    oauth_provider: str | None,
    oauth_credentials_present: bool,
) -> str:
    """Resolve ``"auto"`` to a concrete mode; validate ``"oauth"`` capability.

    Raises :class:`SetupValidationError` if ``"oauth"`` is requested but the
    model provider does not support it.
    """
    mode = requested.strip().lower() if requested else "api_key"
    if mode not in {"api_key", "oauth", "auto"}:
        raise SetupValidationError(f"Unknown auth_mode: {requested!r}")

    if mode == "auto":
        # Resolve to oauth only when the provider supports it AND creds exist.
        return "oauth" if (oauth_provider is not None and oauth_credentials_present) else "api_key"

    if mode == "oauth" and oauth_provider is None:
        raise SetupValidationError(
            "OAuth authentication is not supported for the selected model. "
            f"Supported OAuth providers: {', '.join(sorted(OAUTH_CAPABLE_PROVIDERS))}."
        )

    return mode


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------


def evaluate_requirements(choice: SetupChoice) -> SetupRequirements:
    """Compute what credentials and tokens are required for *choice*.

    This is the core decision function.  It is idempotent and has no side effects.

    :param choice: The user's declared setup inputs.
    :returns: A :class:`SetupRequirements` instance describing what must be provided.
    :raises SetupValidationError: When *choice* contains contradictory or invalid values.
    """
    model = choice.model.strip() if choice.model else ""
    if not model:
        raise SetupValidationError("Model cannot be empty.")

    channels = _normalize_channels(choice.channels)

    model_provider = _infer_model_provider(model)
    oauth_provider = _infer_oauth_provider(model)

    # Use provider_hint to fill in env-var lookup when model_provider is unknown.
    provider_for_env = model_provider or (
        choice.provider_hint.strip().lower() if choice.provider_hint else None
    )
    env_var = _provider_env_var(provider_for_env)

    auth_mode = _resolve_auth_mode(
        choice.auth_mode,
        oauth_provider=oauth_provider,
        oauth_credentials_present=choice.oauth_credentials_present,
    )

    requires_telegram_token = "telegram" in channels
    requires_provider_api_key = auth_mode == "api_key" and env_var is not None
    requires_oauth_credentials = auth_mode == "oauth"

    return SetupRequirements(
        channels=channels,
        auth_mode=auth_mode,
        model_provider=model_provider,
        oauth_provider=oauth_provider,
        provider_env_var=env_var,
        requires_telegram_token=requires_telegram_token,
        requires_provider_api_key=requires_provider_api_key,
        requires_oauth_credentials=requires_oauth_credentials,
    )


def build_plan(choice: SetupChoice) -> SetupPlan:
    """Evaluate requirements and identify any credential gaps for *choice*.

    The returned :class:`SetupPlan` is always structurally valid.  Missing
    credentials surface as :class:`CredentialGap` entries rather than exceptions.
    :func:`evaluate_requirements` *can* raise :class:`SetupValidationError` for
    structurally invalid inputs (e.g. empty model, unsupported OAuth provider).

    When ``plan.is_complete`` is ``True``, ``plan.secrets`` contains the env-var
    name -> credential value mapping ready for the apply layer to write.

    :param choice: The user's declared setup inputs.
    :returns: A :class:`SetupPlan` with ``is_complete=True`` when all creds are present.
    :raises SetupValidationError: When *choice* is structurally invalid.
    """
    requirements = evaluate_requirements(choice)
    gaps = _find_gaps(choice, requirements)
    secrets = _collect_secrets(choice, requirements) if not gaps else {}
    return SetupPlan(requirements=requirements, gaps=tuple(gaps), secrets=secrets, choice=choice)


def _find_gaps(choice: SetupChoice, requirements: SetupRequirements) -> list[CredentialGap]:
    """Return the list of credentials that are required but not yet present."""
    gaps: list[CredentialGap] = []

    if requirements.requires_telegram_token and not choice.telegram_token:
        gaps.append(
            CredentialGap(
                kind="telegram_token",
                env_var="TELEGRAM_BOT_TOKEN",
                message=(
                    "A Telegram bot token is required because the 'telegram' channel "
                    "is selected. Obtain one from @BotFather."
                ),
            )
        )

    if requirements.requires_provider_api_key and not choice.provider_api_key:
        env_var = requirements.provider_env_var
        provider = requirements.model_provider or choice.provider_hint or "selected provider"
        gaps.append(
            CredentialGap(
                kind="provider_api_key",
                env_var=env_var,
                provider=provider,
                message=(
                    f"An API key is required for {provider}. "
                    f"Set the {env_var} environment variable."
                ),
            )
        )

    if requirements.requires_oauth_credentials and not choice.oauth_credentials_present:
        provider = requirements.oauth_provider or "selected provider"
        gaps.append(
            CredentialGap(
                kind="oauth_credentials",
                provider=provider,
                message=(
                    f"OAuth credentials are required for {provider}. "
                    f"Run: otto auth login {provider}"
                ),
            )
        )

    return gaps


def _collect_secrets(choice: SetupChoice, requirements: SetupRequirements) -> dict[str, str]:
    """Build the env-var -> value secrets map from a complete choice."""
    secrets: dict[str, str] = {}

    if requirements.requires_provider_api_key and choice.provider_api_key:
        env_var = requirements.provider_env_var
        if env_var:
            secrets[env_var] = choice.provider_api_key

    if requirements.requires_telegram_token and choice.telegram_token:
        secrets["TELEGRAM_BOT_TOKEN"] = choice.telegram_token

    return secrets
