"""
Centralized settings module for OpenScientist.

Validates all environment variables at startup using Pydantic v2 BaseSettings.
Provides clear error messages when configuration is invalid.
"""

import hashlib
import hmac
import logging
import os
import re
from functools import lru_cache

from pydantic import Field, ValidationInfo, field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict

logger = logging.getLogger(__name__)
_SIMPLE_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+$")


class DevSettings(BaseSettings):
    """Development mode settings."""

    model_config = SettingsConfigDict(
        env_file=".env",
        env_file_encoding="utf-8",
        extra="ignore",
    )

    dev_mode: bool = Field(default=False, alias="OPENSCIENTIST_DEV_MODE")
    simulate_provider_error: bool = Field(default=False, alias="SIMULATE_PROVIDER_ERROR")


class ProviderSettings(BaseSettings):
    """Provider configuration for model access."""

    model_config = SettingsConfigDict(
        env_file=".env",
        env_file_encoding="utf-8",
        extra="ignore",
    )

    # Provider selection
    provider_id: str = Field(
        default="",
        alias="OPENSCIENTIST_PROVIDER",
        description=(
            "Provider id (anthropic, cborg, vertex, bedrock, foundry, openai, "
            "azure-openai, ollama). Required: there is no default provider."
        ),
    )

    # GitHub token for skill syncing
    github_token: str | None = Field(default=None, alias="GITHUB_TOKEN")

    # Anthropic direct API
    anthropic_api_key: str | None = Field(default=None, alias="ANTHROPIC_API_KEY")

    # CBORG (Berkeley Lab) / OAuth tokens
    anthropic_auth_token: str | None = Field(default=None, alias="ANTHROPIC_AUTH_TOKEN")
    claude_code_oauth_token: str | None = Field(default=None, alias="CLAUDE_CODE_OAUTH_TOKEN")
    anthropic_base_url: str | None = Field(default=None, alias="ANTHROPIC_BASE_URL")

    # OpenAI (Codex agent backend)
    openai_api_key: str | None = Field(default=None, alias="OPENAI_API_KEY")

    # Azure OpenAI Service (Codex agent backend, OpenAI models via Azure).
    # Azure routes by deployment name in the URL, so the deployment is carried
    # separately from the model. Distinct from the foundry provider, which
    # serves Anthropic models via Azure AI Foundry.
    azure_openai_api_key: str | None = Field(default=None, alias="AZURE_OPENAI_API_KEY")
    azure_openai_resource: str | None = Field(default=None, alias="AZURE_OPENAI_RESOURCE")
    azure_openai_deployment: str | None = Field(default=None, alias="AZURE_OPENAI_DEPLOYMENT")
    azure_openai_api_version: str | None = Field(default=None, alias="AZURE_OPENAI_API_VERSION")
    # How many times codex reconnects through Azure's intermittent streaming
    # disconnects before failing the turn (openai/codex#9936). Raise it for
    # lower-capacity Azure deployments that disconnect more often.
    azure_openai_stream_max_retries: int = Field(
        default=10, alias="AZURE_OPENAI_STREAM_MAX_RETRIES"
    )

    # Ollama (Codex agent backend, open-weight models served locally through
    # Ollama's OpenAI-compatible Responses endpoint). Local and keyless, so
    # codex is told the provider needs no OpenAI auth.
    ollama_base_url: str = Field(default="http://localhost:11434/v1", alias="OLLAMA_BASE_URL")
    ollama_model: str = Field(default="gpt-oss:20b", alias="OLLAMA_MODEL")

    # Model settings
    model: str | None = Field(default=None, alias="OPENSCIENTIST_MODEL")
    model_context_tokens: int | None = Field(
        default=None,
        alias="OPENSCIENTIST_MODEL_CONTEXT_TOKENS",
        description=(
            "Override the model's usable context window (tokens) used to budget "
            "prompt size. When unset, the window is probed (Ollama) or looked up "
            "for known API models, falling back to a conservative default."
        ),
    )
    anthropic_chat_model: str | None = Field(
        default=None,
        alias="ANTHROPIC_CHAT_MODEL",
        description=(
            "Model used for in-page chat. Falls back to OPENSCIENTIST_MODEL if unset. "
            "Useful when the discovery model rejects chat-style prompts under its "
            "Usage Policy enforcement (e.g. Claude Opus 4.6 on Foundry). Operators "
            "can set this to a smaller, less-restrictive deployment such as Haiku."
        ),
    )
    anthropic_small_fast_model: str | None = Field(default=None, alias="ANTHROPIC_SMALL_FAST_MODEL")
    anthropic_default_sonnet_model: str | None = Field(
        default=None, alias="ANTHROPIC_DEFAULT_SONNET_MODEL"
    )
    anthropic_default_haiku_model: str | None = Field(
        default=None, alias="ANTHROPIC_DEFAULT_HAIKU_MODEL"
    )
    anthropic_default_opus_model: str | None = Field(
        default=None, alias="ANTHROPIC_DEFAULT_OPUS_MODEL"
    )

    # AWS Bedrock
    aws_region: str | None = Field(default=None, alias="AWS_REGION")
    aws_access_key_id: str | None = Field(default=None, alias="AWS_ACCESS_KEY_ID")
    aws_secret_access_key: str | None = Field(default=None, alias="AWS_SECRET_ACCESS_KEY")
    aws_profile: str | None = Field(default=None, alias="AWS_PROFILE")
    aws_bearer_token_bedrock: str | None = Field(default=None, alias="AWS_BEARER_TOKEN_BEDROCK")

    # Google Vertex AI
    anthropic_vertex_project_id: str | None = Field(
        default=None, alias="ANTHROPIC_VERTEX_PROJECT_ID"
    )
    google_application_credentials: str | None = Field(
        default=None, alias="GOOGLE_APPLICATION_CREDENTIALS"
    )
    # Host path for GCP credentials (for agent container mounts when running in Docker)
    gcp_credentials_host_path: str | None = Field(default=None, alias="GCP_CREDENTIALS_HOST_PATH")
    # Host path to the codex CLI auth file. The runner copies it into the
    # per-job CODEX_HOME so the Codex backend can authenticate via ChatGPT
    # login when no API key is set.
    codex_auth_host_path: str | None = Field(default=None, alias="CODEX_AUTH_HOST_PATH")
    gcp_billing_account_id: str | None = Field(default=None, alias="GCP_BILLING_ACCOUNT_ID")
    cloud_ml_region: str | None = Field(default=None, alias="CLOUD_ML_REGION")
    vertex_region_claude_4_5_sonnet: str | None = Field(
        default=None, alias="VERTEX_REGION_CLAUDE_4_5_SONNET"
    )
    vertex_region_claude_4_5_haiku: str | None = Field(
        default=None, alias="VERTEX_REGION_CLAUDE_4_5_HAIKU"
    )

    # Azure AI Foundry
    anthropic_foundry_resource: str | None = Field(default=None, alias="ANTHROPIC_FOUNDRY_RESOURCE")
    anthropic_foundry_base_url: str | None = Field(default=None, alias="ANTHROPIC_FOUNDRY_BASE_URL")
    anthropic_foundry_api_key: str | None = Field(default=None, alias="ANTHROPIC_FOUNDRY_API_KEY")
    # Azure Cost Management (optional, enables billing data for foundry provider)
    azure_subscription_id: str | None = Field(default=None, alias="AZURE_SUBSCRIPTION_ID")
    azure_resource_group: str | None = Field(default=None, alias="AZURE_RESOURCE_GROUP")
    # Azure AD service principal (for Cost Management API)
    azure_tenant_id: str | None = Field(default=None, alias="AZURE_TENANT_ID")
    azure_client_id: str | None = Field(default=None, alias="AZURE_CLIENT_ID")
    azure_client_secret: str | None = Field(default=None, alias="AZURE_CLIENT_SECRET")

    @staticmethod
    def _warn_if_missing(value: str | None, message: str, warnings: list[str]) -> None:
        if not value:
            warnings.append(message)

    def _anthropic_warnings(self) -> list[str]:
        warnings: list[str] = []
        if not self.anthropic_api_key and not self.claude_code_oauth_token:
            warnings.append(
                "ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN is required "
                "when OPENSCIENTIST_PROVIDER=anthropic. "
                "Get your API key from https://console.anthropic.com "
                "or run 'claude login' for OAuth."
            )
        return warnings

    def _cborg_warnings(self) -> list[str]:
        warnings: list[str] = []
        self._warn_if_missing(
            self.anthropic_auth_token,
            "ANTHROPIC_AUTH_TOKEN is required when OPENSCIENTIST_PROVIDER=cborg",
            warnings,
        )
        self._warn_if_missing(
            self.anthropic_base_url,
            "ANTHROPIC_BASE_URL is required when OPENSCIENTIST_PROVIDER=cborg "
            "(should be https://api.cborg.lbl.gov)",
            warnings,
        )
        return warnings

    def _vertex_warnings(self) -> list[str]:
        warnings: list[str] = []
        self._warn_if_missing(
            self.anthropic_vertex_project_id,
            "ANTHROPIC_VERTEX_PROJECT_ID is required for Vertex AI",
            warnings,
        )
        if not self.google_application_credentials:
            warnings.append(
                "GOOGLE_APPLICATION_CREDENTIALS is required for Vertex AI "
                "(path to service account JSON)"
            )
        elif not os.path.exists(os.path.expanduser(self.google_application_credentials)):
            warnings.append(
                f"GOOGLE_APPLICATION_CREDENTIALS file not found: "
                f"{self.google_application_credentials}"
            )
        self._warn_if_missing(
            self.gcp_billing_account_id,
            "GCP_BILLING_ACCOUNT_ID is required for Vertex AI cost tracking",
            warnings,
        )
        self._warn_if_missing(
            self.cloud_ml_region,
            "CLOUD_ML_REGION is required for Vertex AI (e.g., us-east5)",
            warnings,
        )
        return warnings

    def _bedrock_warnings(self) -> list[str]:
        warnings: list[str] = []
        self._warn_if_missing(
            self.aws_region,
            "AWS_REGION is required for Bedrock (e.g., us-east-1)",
            warnings,
        )
        has_access_key = self.aws_access_key_id and self.aws_secret_access_key
        has_profile = bool(self.aws_profile)
        has_bearer = bool(self.aws_bearer_token_bedrock)
        if not (has_access_key or has_profile or has_bearer):
            warnings.append(
                "AWS credentials required for Bedrock. Set one of: "
                "AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY, AWS_PROFILE, "
                "or AWS_BEARER_TOKEN_BEDROCK"
            )
        return warnings

    @staticmethod
    def _unknown_provider_warnings(provider: str) -> list[str]:
        return [
            f"Unknown provider '{provider}'. "
            "Valid options: anthropic, cborg, vertex, bedrock, foundry, openai, "
            "azure-openai, ollama"
        ]

    _LEGACY_ENV_VAR_RENAMES = (
        ("CLAUDE_PROVIDER", "OPENSCIENTIST_PROVIDER"),
        ("ANTHROPIC_MODEL", "OPENSCIENTIST_MODEL"),
    )

    @model_validator(mode="after")
    def reject_legacy_env_vars(self) -> "ProviderSettings":
        """Raise a clear error when a removed env-var name is still set.

        We do not silently accept legacy aliases. Users upgrading must
        rename their environment variables explicitly so that the
        running configuration matches what the code reads.
        """
        for legacy, canonical in self._LEGACY_ENV_VAR_RENAMES:
            if os.environ.get(legacy):
                raise ValueError(
                    f"{legacy} has been renamed to {canonical}. "
                    f"Rename the variable in your environment (and .env file) and unset "
                    f"{legacy}. The legacy name is no longer accepted."
                )
        return self

    @model_validator(mode="after")
    def require_provider_selected(self) -> "ProviderSettings":
        """There is no default provider. An unset ``OPENSCIENTIST_PROVIDER`` is
        an error so the running configuration is always explicit rather than
        silently falling back to one vendor.
        """
        if not self.provider_id:
            raise ValueError(
                "OPENSCIENTIST_PROVIDER is not set and there is no default "
                "provider. Set OPENSCIENTIST_PROVIDER to one of: anthropic, "
                "cborg, vertex, bedrock, foundry, openai, azure-openai, ollama."
            )
        return self

    # Per-provider model-name format. Mismatches raise at settings load so
    # users cannot pair a model with a provider that would reject it at runtime.
    _MODEL_FORMAT_BY_PROVIDER: dict[str, tuple[re.Pattern[str], str]] = {
        "anthropic": (
            re.compile(r"^claude-"),
            "an Anthropic model name (expected to start with 'claude-')",
        ),
        "cborg": (
            re.compile(r"^claude-"),
            "an Anthropic model name on CBORG (expected to start with 'claude-')",
        ),
        "vertex": (
            re.compile(r"^claude-.+@\d{8}$"),
            "a Vertex Anthropic model id ('claude-<name>@<YYYYMMDD>')",
        ),
        "bedrock": (
            re.compile(r"^([a-z]+\.anthropic\.claude-.+-v\d+:\d+|arn:aws:bedrock:)"),
            "a Bedrock model id ('<region>.anthropic.claude-<name>-v<n>:<n>' or an inference-profile ARN)",
        ),
    }

    def _validate_model_format(self) -> str | None:
        """Return an error message when the configured model does not match
        the provider's expected naming convention. Returns None when no
        model is set or when the provider has no enforced pattern.
        """
        if not self.model:
            return None
        spec = self._MODEL_FORMAT_BY_PROVIDER.get(self.provider_id.lower())
        if spec is None:
            return None
        pattern, description = spec
        if pattern.match(self.model):
            return None
        return (
            f"OPENSCIENTIST_MODEL={self.model!r} does not look like {description}. "
            f"Either change the model id or change OPENSCIENTIST_PROVIDER."
        )

    @model_validator(mode="after")
    def validate_provider_requirements(self) -> "ProviderSettings":
        """Validate provider config: warn on missing credentials, raise on
        model-name mismatches.

        Credential checks remain warn-only so that settings can always be
        constructed (e.g. during testing or when only a subset of env vars
        is available); the authoritative credential validation lives in each
        provider's ``validate_required_config``. Model-name mismatches do
        raise here because a wrong-family model id is a hard incompatibility
        the user must fix before any agent call can succeed.
        """
        from collections.abc import Callable

        provider = self.provider_id.lower()
        warning_builders: dict[str, Callable[[], list[str]]] = {
            "anthropic": self._anthropic_warnings,
            "cborg": self._cborg_warnings,
            "vertex": self._vertex_warnings,
            "bedrock": self._bedrock_warnings,
            "foundry": lambda: [],
            "openai": lambda: [],
            "azure-openai": lambda: [],
            "ollama": lambda: [],
        }
        warnings = warning_builders.get(
            provider, lambda: self._unknown_provider_warnings(provider)
        )()
        for warning in warnings:
            logger.warning("Provider config: %s", warning)

        model_error = self._validate_model_format()
        if model_error:
            raise ValueError(model_error)

        return self

    @staticmethod
    def _set_env_if_present(env_vars: dict[str, str], key: str, value: str | None) -> None:
        if value:
            env_vars[key] = value

    def _apply_model_env_vars(self, env_vars: dict[str, str]) -> None:
        self._set_env_if_present(env_vars, "OPENSCIENTIST_MODEL", self.model)
        self._set_env_if_present(
            env_vars, "ANTHROPIC_SMALL_FAST_MODEL", self.anthropic_small_fast_model
        )
        self._set_env_if_present(
            env_vars, "ANTHROPIC_DEFAULT_SONNET_MODEL", self.anthropic_default_sonnet_model
        )
        self._set_env_if_present(
            env_vars, "ANTHROPIC_DEFAULT_HAIKU_MODEL", self.anthropic_default_haiku_model
        )
        self._set_env_if_present(
            env_vars, "ANTHROPIC_DEFAULT_OPUS_MODEL", self.anthropic_default_opus_model
        )

    def _apply_auth_env_vars(self, env_vars: dict[str, str]) -> None:
        self._set_env_if_present(env_vars, "ANTHROPIC_API_KEY", self.anthropic_api_key)
        self._set_env_if_present(env_vars, "ANTHROPIC_AUTH_TOKEN", self.anthropic_auth_token)
        self._set_env_if_present(env_vars, "CLAUDE_CODE_OAUTH_TOKEN", self.claude_code_oauth_token)
        self._set_env_if_present(env_vars, "ANTHROPIC_BASE_URL", self.anthropic_base_url)

    def _apply_openai_env_vars(self, env_vars: dict[str, str]) -> None:
        self._set_env_if_present(env_vars, "OPENAI_API_KEY", self.openai_api_key)
        # Signal to the agent container that codex auth was provisioned into the
        # per-job CODEX_HOME (the runner copies the file in). The container does
        # not read the host path itself, only its presence, so the provider's
        # config validation passes.
        self._set_env_if_present(env_vars, "CODEX_AUTH_HOST_PATH", self.codex_auth_host_path)

    def _apply_azure_openai_env_vars(self, env_vars: dict[str, str]) -> None:
        self._set_env_if_present(env_vars, "AZURE_OPENAI_API_KEY", self.azure_openai_api_key)
        self._set_env_if_present(env_vars, "AZURE_OPENAI_RESOURCE", self.azure_openai_resource)
        self._set_env_if_present(env_vars, "AZURE_OPENAI_DEPLOYMENT", self.azure_openai_deployment)
        self._set_env_if_present(
            env_vars, "AZURE_OPENAI_API_VERSION", self.azure_openai_api_version
        )
        self._set_env_if_present(
            env_vars, "AZURE_OPENAI_STREAM_MAX_RETRIES", str(self.azure_openai_stream_max_retries)
        )

    def _apply_ollama_env_vars(self, env_vars: dict[str, str]) -> None:
        # Both have defaults, so they are always forwarded. The base URL must be
        # reachable from inside the agent container (e.g. host.docker.internal
        # rather than localhost when Ollama runs on the host).
        env_vars["OLLAMA_BASE_URL"] = self.ollama_base_url
        env_vars["OLLAMA_MODEL"] = self.ollama_model

    def _apply_vertex_env_vars(
        self,
        env_vars: dict[str, str],
        gcp_credentials_container_path: str | None,
    ) -> None:
        if self.provider_id.lower() == "vertex":
            env_vars["CLAUDE_CODE_USE_VERTEX"] = "1"
        self._set_env_if_present(
            env_vars, "ANTHROPIC_VERTEX_PROJECT_ID", self.anthropic_vertex_project_id
        )
        self._set_env_if_present(env_vars, "GCP_BILLING_ACCOUNT_ID", self.gcp_billing_account_id)
        self._set_env_if_present(env_vars, "CLOUD_ML_REGION", self.cloud_ml_region)
        self._set_env_if_present(
            env_vars, "VERTEX_REGION_CLAUDE_4_5_SONNET", self.vertex_region_claude_4_5_sonnet
        )
        self._set_env_if_present(
            env_vars, "VERTEX_REGION_CLAUDE_4_5_HAIKU", self.vertex_region_claude_4_5_haiku
        )
        if self.google_application_credentials:
            env_vars["GOOGLE_APPLICATION_CREDENTIALS"] = (
                gcp_credentials_container_path or self.google_application_credentials
            )

    def _apply_bedrock_env_vars(self, env_vars: dict[str, str]) -> None:
        if self.provider_id.lower() == "bedrock":
            env_vars["CLAUDE_CODE_USE_BEDROCK"] = "1"
        self._set_env_if_present(env_vars, "AWS_REGION", self.aws_region)
        self._set_env_if_present(env_vars, "AWS_ACCESS_KEY_ID", self.aws_access_key_id)
        self._set_env_if_present(env_vars, "AWS_SECRET_ACCESS_KEY", self.aws_secret_access_key)
        self._set_env_if_present(env_vars, "AWS_PROFILE", self.aws_profile)
        self._set_env_if_present(
            env_vars, "AWS_BEARER_TOKEN_BEDROCK", self.aws_bearer_token_bedrock
        )

    def _apply_foundry_env_vars(self, env_vars: dict[str, str]) -> None:
        if self.provider_id.lower() == "foundry":
            env_vars["CLAUDE_CODE_USE_FOUNDRY"] = "1"

        self._set_env_if_present(
            env_vars, "ANTHROPIC_FOUNDRY_RESOURCE", self.anthropic_foundry_resource
        )
        # Claude Code treats resource/base_url as mutually exclusive.
        if not self.anthropic_foundry_resource:
            self._set_env_if_present(
                env_vars, "ANTHROPIC_FOUNDRY_BASE_URL", self.anthropic_foundry_base_url
            )
        self._set_env_if_present(
            env_vars, "ANTHROPIC_FOUNDRY_API_KEY", self.anthropic_foundry_api_key
        )

    def get_container_env_vars(
        self,
        gcp_credentials_container_path: str | None = None,
    ) -> dict[str, str]:
        """
        Get environment variables to pass to agent containers.

        Args:
            gcp_credentials_container_path: Container path for GCP credentials file.

        Returns:
            Dict of env var names to values (only includes set values).
        """
        env_vars: dict[str, str] = {"OPENSCIENTIST_PROVIDER": self.provider_id}
        self._apply_model_env_vars(env_vars)
        self._apply_auth_env_vars(env_vars)
        self._apply_openai_env_vars(env_vars)
        self._apply_azure_openai_env_vars(env_vars)
        self._apply_ollama_env_vars(env_vars)
        self._apply_vertex_env_vars(env_vars, gcp_credentials_container_path)
        self._apply_bedrock_env_vars(env_vars)
        self._apply_foundry_env_vars(env_vars)
        return env_vars


class DatabaseSettings(BaseSettings):
    """Database configuration."""

    model_config = SettingsConfigDict(
        env_file=".env",
        env_file_encoding="utf-8",
        extra="ignore",
    )

    database_url: str = Field(alias="DATABASE_URL")

    # Admin database URL for elevated operations (bypasses RLS via DB role).
    # If not set, falls back to DATABASE_URL.
    admin_database_url: str | None = Field(default=None, alias="ADMIN_DATABASE_URL")

    # Debug settings
    sql_echo: bool = Field(default=False, alias="SQL_ECHO")

    @property
    def effective_database_url(self) -> str:
        """Get the database URL."""
        return self.database_url

    @property
    def effective_admin_database_url(self) -> str:
        """Get the admin database URL for elevated operations (bypasses RLS)."""
        return self.admin_database_url or self.database_url


class AuthSettings(BaseSettings):
    """Authentication configuration."""

    model_config = SettingsConfigDict(
        env_file=".env",
        env_file_encoding="utf-8",
        extra="ignore",
    )

    # General auth settings
    app_url: str = Field(default="http://localhost:8080", alias="APP_URL")
    session_duration_days: int = Field(default=30, alias="SESSION_DURATION_DAYS")

    # Derived from OPENSCIENTIST_SECRET_KEY (populated by Settings.derive_secrets)
    storage_secret: str = Field(default="")
    token_encryption_key: str | None = Field(default=None)

    # Google OAuth
    google_client_id: str | None = Field(default=None, alias="GOOGLE_CLIENT_ID")
    google_client_secret: str | None = Field(default=None, alias="GOOGLE_CLIENT_SECRET")

    # GitHub OAuth
    github_client_id: str | None = Field(default=None, alias="GITHUB_CLIENT_ID")
    github_client_secret: str | None = Field(default=None, alias="GITHUB_CLIENT_SECRET")

    # ORCID OAuth
    orcid_client_id: str | None = Field(default=None, alias="ORCID_CLIENT_ID")
    orcid_client_secret: str | None = Field(default=None, alias="ORCID_CLIENT_SECRET")

    bootstrap_admin_emails: str | None = Field(default=None, alias="BOOTSTRAP_ADMIN_EMAILS")

    @staticmethod
    def _parse_bootstrap_admin_emails(raw_value: str | None) -> set[str]:
        """Parse and validate BOOTSTRAP_ADMIN_EMAILS as normalized email set."""
        if not raw_value:
            return set()

        emails: set[str] = set()
        for token in raw_value.split(","):
            normalized = token.strip().lower()
            if not normalized:
                continue
            if not _SIMPLE_EMAIL_RE.fullmatch(normalized):
                raise ValueError(
                    "BOOTSTRAP_ADMIN_EMAILS must be a comma-separated list of email addresses; "
                    f"invalid value: '{token.strip()}'"
                )
            emails.add(normalized)
        return emails

    @field_validator("bootstrap_admin_emails")
    @classmethod
    def validate_bootstrap_admin_emails(cls, value: str | None) -> str | None:
        """Validate BOOTSTRAP_ADMIN_EMAILS format if set."""
        cls._parse_bootstrap_admin_emails(value)
        return value

    @model_validator(mode="after")
    def validate_oauth_pairs(self) -> "AuthSettings":
        """Validate that OAuth client ID and secret are paired."""
        errors = []

        if self.google_client_id and not self.google_client_secret:
            errors.append("GOOGLE_CLIENT_SECRET is required when GOOGLE_CLIENT_ID is set")
        if self.google_client_secret and not self.google_client_id:
            errors.append("GOOGLE_CLIENT_ID is required when GOOGLE_CLIENT_SECRET is set")

        if self.github_client_id and not self.github_client_secret:
            errors.append("GITHUB_CLIENT_SECRET is required when GITHUB_CLIENT_ID is set")
        if self.github_client_secret and not self.github_client_id:
            errors.append("GITHUB_CLIENT_ID is required when GITHUB_CLIENT_SECRET is set")

        if self.orcid_client_id and not self.orcid_client_secret:
            errors.append("ORCID_CLIENT_SECRET is required when ORCID_CLIENT_ID is set")
        if self.orcid_client_secret and not self.orcid_client_id:
            errors.append("ORCID_CLIENT_ID is required when ORCID_CLIENT_SECRET is set")

        if errors:
            raise ValueError(
                "OAuth configuration errors:\n" + "\n".join(f"  - {e}" for e in errors)
            )

        return self

    @property
    def is_oauth_configured(self) -> bool:
        """Check if at least one OAuth provider is configured."""
        return bool(self.google_client_id or self.github_client_id or self.orcid_client_id)

    @property
    def bootstrap_admin_emails_set(self) -> set[str]:
        """Get BOOTSTRAP_ADMIN_EMAILS as normalized email set."""
        return self._parse_bootstrap_admin_emails(self.bootstrap_admin_emails)


class BudgetSettings(BaseSettings):
    """Budget and cost tracking configuration."""

    model_config = SettingsConfigDict(
        env_file=".env",
        env_file_encoding="utf-8",
        extra="ignore",
    )

    max_project_spend_warn: float = Field(default=100.0, alias="MAX_PROJECT_SPEND_WARN")
    max_project_spend_hard: float = Field(default=500.0, alias="MAX_PROJECT_SPEND_HARD")
    max_job_cost_usd: float = Field(default=10.0, alias="MAX_JOB_COST_USD")
    app_max_budget_usd: float = Field(default=1000.0, alias="APP_MAX_BUDGET_USD")

    # Provider-agnostic budget limits (used by check_budget_limits)
    max_project_spend_total_usd: float = Field(
        default=float("inf"), alias="MAX_PROJECT_SPEND_TOTAL_USD"
    )
    max_project_spend_24h_usd: float = Field(
        default=float("inf"), alias="MAX_PROJECT_SPEND_24H_USD"
    )
    warn_project_spend_24h_usd: float = Field(
        default=float("inf"), alias="WARN_PROJECT_SPEND_24H_USD"
    )

    @field_validator(
        "max_project_spend_warn",
        "max_project_spend_hard",
        "max_job_cost_usd",
        "app_max_budget_usd",
    )
    @classmethod
    def validate_positive(cls, v: float, info: ValidationInfo) -> float:
        """Validate that budget values are positive."""
        if v <= 0:
            raise ValueError(f"{info.field_name} must be positive, got {v}")
        return v


class FileSettings(BaseSettings):
    """File handling configuration."""

    model_config = SettingsConfigDict(
        env_file=".env",
        env_file_encoding="utf-8",
        extra="ignore",
    )

    max_file_size_mb: int = Field(default=1000, alias="MAX_FILE_SIZE_MB")

    @field_validator("max_file_size_mb")
    @classmethod
    def validate_positive(cls, v: int) -> int:
        """Validate that file size is positive."""
        if v <= 0:
            raise ValueError(f"MAX_FILE_SIZE_MB must be positive, got {v}")
        return v


class ContainerSettings(BaseSettings):
    """Container isolation configuration."""

    model_config = SettingsConfigDict(
        env_file=".env",
        env_file_encoding="utf-8",
        extra="ignore",
    )

    executor_image: str = Field(
        default="openscientist-executor:latest", alias="OPENSCIENTIST_EXECUTOR_IMAGE"
    )
    agent_image: str = Field(
        default="openscientist-agent:latest",
        alias="OPENSCIENTIST_AGENT_IMAGE",
        description=(
            "Docker image tag used to spawn per-job agent containers. Override to "
            "point staging at separately-tagged images (e.g. openscientist-agent:staging) "
            "without colliding with the prod :latest image."
        ),
    )
    executor_memory: str = Field(default="2g", alias="OPENSCIENTIST_EXECUTOR_MEMORY")
    executor_cpu: float = Field(default=0.5, alias="OPENSCIENTIST_EXECUTOR_CPU")
    executor_timeout: int = Field(default=120, alias="OPENSCIENTIST_EXECUTOR_TIMEOUT")

    # Agent container resource limits
    agent_memory: str = Field(default="8g", alias="OPENSCIENTIST_AGENT_MEMORY")
    agent_cpu: float = Field(default=2.0, alias="OPENSCIENTIST_AGENT_CPU")
    agent_network: str | None = Field(
        default=None,
        alias="OPENSCIENTIST_AGENT_NETWORK",
        description="Docker network for agent containers. Auto-detected from the web server container if not set.",
    )
    agent_platform: str | None = Field(
        default=None,
        alias="OPENSCIENTIST_AGENT_PLATFORM",
        description="Docker platform for agent containers (e.g. linux/amd64). "
        "Set to linux/amd64 on Apple Silicon to run x86_64 Phenix via Rosetta.",
    )
    agent_timeout: int = Field(
        default=4 * 3600,
        alias="OPENSCIENTIST_AGENT_TIMEOUT",
        description="Wall-clock seconds before a running job container is failed as timed out. "
        "Raise it for slow local models (e.g. large open-weight models on Ollama) that need "
        "more than the 4-hour default to finish all iterations.",
    )

    # Host path mapping for sibling container volume mounts (executor containers)
    # When the main container runs inside Docker and spawns sibling containers,
    # paths need to be translated from container paths to host paths.
    # Example: /app inside container maps to /home/user/openscientist on host
    container_app_dir: str = Field(default="/app", alias="OPENSCIENTIST_CONTAINER_APP_DIR")
    host_project_dir: str | None = Field(
        default=None,
        alias="OPENSCIENTIST_HOST_PROJECT_DIR",
        description="Host path for project directory. Required when using agent containers.",
    )


class PhenixSettings(BaseSettings):
    """Phenix structural biology tools configuration."""

    model_config = SettingsConfigDict(
        env_file=".env",
        env_file_encoding="utf-8",
        extra="ignore",
    )

    phenix_path: str | None = Field(default=None, alias="PHENIX_PATH")
    phenix_host_path: str | None = Field(default=None, alias="PHENIX_HOST_PATH")

    @staticmethod
    def _validate_absolute_path(
        value: str | None,
        *,
        env_name: str,
        example: str,
    ) -> str | None:
        """Validate a configured filesystem path without requiring existence."""
        if value is None or value == "":
            return None

        # Must be an absolute path
        if not value.startswith("/"):
            raise ValueError(
                f"{env_name} must be an absolute path (starting with '/'), got: '{value}'\n"
                f"  Example: {env_name}={example}"
            )

        # Must not contain path traversal
        if ".." in value:
            raise ValueError(f"{env_name} must not contain path traversal (..): '{value}'")

        return value

    @field_validator("phenix_path")
    @classmethod
    def validate_phenix_path(cls, v: str | None) -> str | None:
        """
        Validate PHENIX_PATH format if set.

        Only validates format (absolute path, no traversal). Existence is
        checked by `is_available` property to match the original behavior
        of phenix_setup.py where invalid paths return None rather than raise.
        """
        return cls._validate_absolute_path(
            v,
            env_name="PHENIX_PATH",
            example="/opt/phenix-2.0-5936",
        )

    @field_validator("phenix_host_path")
    @classmethod
    def validate_phenix_host_path(cls, v: str | None) -> str | None:
        """Validate PHENIX_HOST_PATH format if set."""
        return cls._validate_absolute_path(
            v,
            env_name="PHENIX_HOST_PATH",
            example="/home/user/phenix-2.0-5936",
        )

    @property
    def is_available(self) -> bool:
        """
        Check if Phenix is configured and available.

        This checks for actual existence on the filesystem, complementing
        the format validation done by the validator. Expects the Phenix
        2.x install layout (`bin/phenix.about`).
        """
        if not self.phenix_path:
            return False
        if not os.path.isdir(self.phenix_path):
            return False
        about = os.path.join(self.phenix_path, "bin", "phenix.about")
        return os.path.exists(about)


class BerkeleyLabSettings(BaseSettings):
    """Berkeley Lab data lakehouse configuration."""

    model_config = SettingsConfigDict(
        env_file=".env",
        env_file_encoding="utf-8",
        extra="ignore",
    )

    dremio_user: str | None = Field(default=None, alias="DREMIO_USER")
    dremio_password: str | None = Field(default=None, alias="DREMIO_PASSWORD")


class AgentSettings(BaseSettings):
    """Agent behavior configuration."""

    model_config = SettingsConfigDict(
        env_file=".env",
        env_file_encoding="utf-8",
        extra="ignore",
    )

    max_agent_skills: int = Field(
        default=10,
        alias="MAX_AGENT_SKILLS",
        description="Maximum number of skills an agent can use per job",
    )

    @field_validator("max_agent_skills")
    @classmethod
    def validate_max_agent_skills(cls, v: int) -> int:
        """Validate that max_agent_skills is positive."""
        if v <= 0:
            raise ValueError(f"MAX_AGENT_SKILLS must be positive, got {v}")
        return v


class AirgapSettings(BaseSettings):
    """Master switch for air-gapped features."""

    model_config = SettingsConfigDict(
        env_file=".env",
        env_file_encoding="utf-8",
        extra="ignore",
    )

    enabled: bool = Field(
        default=False,
        alias="OPENSCIENTIST_AIRGAPPED",
        description="Enable air-gapped mode: local corpus, offline executor, and egress firewall.",
    )


class Settings(BaseSettings):
    """Root settings class with all configuration sections."""

    model_config = SettingsConfigDict(
        env_file=".env",
        env_file_encoding="utf-8",
        extra="ignore",
    )

    # Master secret — all auth secrets are derived from this via HMAC-SHA256
    secret_key: str = Field(alias="OPENSCIENTIST_SECRET_KEY")

    # Server settings
    port: int = Field(default=8080, alias="PORT")
    max_concurrent_jobs: int = Field(default=1, alias="OPENSCIENTIST_MAX_CONCURRENT_JOBS")
    base_url: str = Field(
        default="http://localhost:8080",
        alias="OPENSCIENTIST_BASE_URL",
        description="Base URL for OpenScientist (used in notifications and share links)",
    )

    # Nested settings
    dev: DevSettings = Field(default_factory=DevSettings)
    provider: ProviderSettings = Field(default_factory=ProviderSettings)
    database: DatabaseSettings = Field(default_factory=DatabaseSettings)
    auth: AuthSettings = Field(default_factory=AuthSettings)
    budget: BudgetSettings = Field(default_factory=BudgetSettings)
    file: FileSettings = Field(default_factory=FileSettings)
    container: ContainerSettings = Field(default_factory=ContainerSettings)
    phenix: PhenixSettings = Field(default_factory=PhenixSettings)
    berkeley_lab: BerkeleyLabSettings = Field(default_factory=BerkeleyLabSettings)
    agent: AgentSettings = Field(default_factory=AgentSettings)
    airgap: AirgapSettings = Field(default_factory=AirgapSettings)

    @model_validator(mode="after")
    def derive_secrets(self) -> "Settings":
        """Derive auth secrets from the master OPENSCIENTIST_SECRET_KEY via HMAC-SHA256."""
        key = self.secret_key.encode()
        self.auth.storage_secret = hmac.new(key, b"storage_secret", hashlib.sha256).hexdigest()
        self.auth.token_encryption_key = hmac.new(
            key, b"token_encryption_key", hashlib.sha256
        ).hexdigest()
        return self


@lru_cache(maxsize=1)
def get_settings() -> Settings:
    """
    Validate and return settings singleton.

    This function validates all environment variables at first call
    and caches the result. Subsequent calls return the cached instance.

    Returns:
        Settings: Validated settings object

    Raises:
        ValidationError: If any environment variables are invalid
    """
    return Settings()


def clear_settings_cache() -> None:
    """Clear the settings cache (useful for testing)."""
    get_settings.cache_clear()
