"""Configuration system for Nexus."""

import os
from pathlib import Path
from typing import Any

import yaml
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator

# Import OAuthConfig - required for OAuth configuration
# Canonical path: nexus.contracts.oauth_types (#3230, moved from bricks.auth.oauth.config)
from nexus.contracts.oauth_types import OAuthConfig


class DockerImageTemplate(BaseModel):
    """Configuration for a single Docker image template."""

    image: str | None = Field(
        default=None,
        description="Pre-built Docker image name",
    )
    dockerfile: str | None = Field(
        default=None,
        description="Path to Dockerfile (relative to project root)",
    )
    dockerfile_override: str | None = Field(
        default=None,
        description="Inline Dockerfile content to override/customize the base image",
    )
    context: str | None = Field(
        default=".",
        description="Docker build context directory",
    )


class DockerTemplateConfig(BaseModel):
    """Configuration for Docker sandbox images.

    Maps template names to Docker images or Dockerfiles for custom sandbox environments.
    """

    templates: dict[str, DockerImageTemplate] = Field(
        default_factory=dict,
        description="Map of template name to image/dockerfile configuration",
    )
    default_image: str = Field(
        default="nexus-sandbox:latest",
        description="Default image if no template specified",
    )


class FeaturesConfig(BaseModel):
    """Per-brick feature flag overrides.

    These override the deployment profile defaults. When a flag is None,
    the profile default is used. When explicitly True/False, it overrides.

    Issue #1389: Expanded from 6 hard-coded flags to per-brick overrides.
    """

    # Services
    eventlog: bool | None = Field(default=None, description="Enable event log service")
    namespace: bool | None = Field(default=None, description="Enable namespace manager")
    permissions: bool | None = Field(default=None, description="Enable permission enforcement")
    scheduler: bool | None = Field(default=None, description="Enable task scheduler")

    # Infrastructure bricks
    cache: bool | None = Field(default=None, description="Enable cache subsystem")
    observability: bool | None = Field(default=None, description="Enable observability (OTEL)")
    uploads: bool | None = Field(default=None, description="Enable chunked uploads")
    resiliency: bool | None = Field(default=None, description="Enable resiliency manager")

    # Feature bricks
    search: bool | None = Field(default=None, description="Enable search brick")
    semantic_search: bool | None = Field(
        default=None,
        description="Enable semantic search initialization/config wiring",
    )
    pay: bool | None = Field(default=None, description="Enable payment brick")
    llm: bool | None = Field(default=None, description="Enable LLM brick")
    skills: bool | None = Field(default=None, description="Enable skills brick")
    sandbox: bool | None = Field(default=None, description="Enable sandbox brick")
    workflows: bool | None = Field(default=None, description="Enable workflow brick")
    discovery: bool | None = Field(default=None, description="Enable agent discovery")
    mcp: bool | None = Field(default=None, description="Enable MCP server brick")
    memory: bool | None = Field(default=None, description="Enable agent memory")
    federation: bool | None = Field(default=None, description="Enable federation brick")

    # Deprecated — accepted but ignored to avoid startup failures on upgrade.
    # The A2A brick was removed in #2979; MCP covers the use case.
    a2a: bool | None = Field(default=None, description="Deprecated: A2A brick removed in #2979")

    model_config = ConfigDict(extra="forbid")

    def to_overrides(self) -> dict[str, bool]:
        """Convert non-None fields to a brick override dict.

        Returns:
            Dict of brick_name -> enabled for fields that are explicitly set.
        """
        _skip = {"semantic_search", "a2a"}
        overrides: dict[str, bool] = {}
        for field_name, value in self:
            if field_name in _skip:
                continue
            if value is not None:
                overrides[field_name] = value
        return overrides


class SSRFConfig(BaseModel):
    """SSRF validator configuration (Issue #3792).

    Knobs here are minimal by design — metadata and loopback are always
    blocked. ``allow_private`` is the opt-in for dev / self-hosted hub
    deployments that reach internal services by design.
    """

    allow_private: bool = Field(
        default=False,
        description=(
            "Allow RFC1918 / ULA private ranges through the SSRF validator. "
            "Metadata and loopback remain blocked. Set True for dev or "
            "self-hosted hub deployments that must reach internal services."
        ),
    )
    extra_deny_cidrs: tuple[str, ...] = Field(
        default=(),
        description=(
            "Operator-supplied CIDRs to block in addition to the built-in "
            "ranges (e.g. internal service-mesh subnets)."
        ),
    )

    model_config = ConfigDict(extra="forbid")

    @field_validator("extra_deny_cidrs", mode="before")
    @classmethod
    def _coerce_sequence(cls, v: Any) -> tuple[str, ...]:
        if v is None:
            return ()
        if isinstance(v, str):
            return (v,)
        return tuple(v)

    @field_validator("extra_deny_cidrs")
    @classmethod
    def _validate_cidrs(cls, v: tuple[str, ...]) -> tuple[str, ...]:
        import ipaddress

        for cidr in v:
            try:
                ipaddress.ip_network(cidr, strict=False)
            except ValueError as exc:
                raise ValueError(f"Invalid CIDR in extra_deny_cidrs: {cidr!r}") from exc
        return v


class SecurityConfig(BaseModel):
    """Top-level security settings.

    Grows as additional security subsections land (e.g. rate limiting,
    content policy). SSRF is the first subsection (Issue #3792).
    """

    ssrf: SSRFConfig = Field(default_factory=SSRFConfig)

    model_config = ConfigDict(extra="forbid")


class NexusConfig(BaseModel):
    """
    Unified configuration for all Nexus deployment modes.

    Configuration is loaded from (in order of priority):
    1. Explicitly provided config dict/object
    2. Environment variables (NEXUS_*)
    3. Config file (./nexus.yaml, ~/.nexus/config.yaml)
    4. Defaults (standalone mode with ./nexus-data)
    """

    # Deployment profile (capability tier) — Issue #1389
    profile: str = Field(
        default="full",
        description=(
            "Deployment profile controlling which bricks are enabled: "
            "kernel (bare VFS, storage only), embedded (storage+eventlog), "
            "lite (core services), full (all bricks), cloud (all + federation)"
        ),
    )

    # Backend selection
    backend: str = Field(
        default="path_local",
        description="Storage backend: 'path_local' for local filesystem, 'local' for CAS, 'gcs' for Google Cloud Storage",
    )

    # Local backend settings
    data_dir: str | None = Field(
        default=None, description="Data directory for local backend (default: ~/.nexus/data)"
    )

    # GCS backend settings
    gcs_bucket_name: str | None = Field(
        default=None, description="GCS bucket name (required when backend='gcs')"
    )
    gcs_project_id: str | None = Field(
        default=None,
        description="GCP project ID (optional, inferred from credentials if not provided)",
    )
    gcs_credentials_path: str | None = Field(
        default=None,
        description="Path to GCS service account credentials JSON (optional, uses ADC if not provided)",
    )

    # General settings
    cache_size_mb: int = Field(default=100, description="Cache size in megabytes")
    enable_vector_search: bool = Field(default=True, description="Enable vector search")
    enable_llm_cache: bool = Field(default=True, description="Enable LLM KV cache")
    db_path: str | None = Field(
        default=None,
        description="(Deprecated) Legacy alias — sets metastore_path when unset.",
    )
    metastore_path: str | None = Field(
        default=None,
        description="Path for the redb metadata store (auto-derived from data_dir if None)",
    )
    record_store_path: str | None = Field(
        default=None,
        description="Path for the SQLAlchemy record store (SQLite). None = no record store (bare kernel).",
    )
    database_url: str | None = Field(
        default=None,
        description=(
            "Full database URL for the SQLAlchemy record store "
            "(e.g. 'postgresql://user:pass@host/db' or 'sqlite:///path/to/db.sqlite'). "
            "Ignored when record_store_path is set."
        ),
    )

    # In-memory metadata caching settings
    cache_path_size: int = Field(default=512, description="Max entries for path metadata cache")
    cache_list_size: int = Field(default=128, description="Max entries for directory listing cache")
    cache_kv_size: int = Field(default=256, description="Max entries for file metadata KV cache")
    cache_exists_size: int = Field(
        default=1024, description="Max entries for existence check cache"
    )
    cache_ttl_seconds: int | None = Field(
        default=300, description="Cache TTL in seconds (None = no expiry)"
    )

    # v0.5.0: Admin flag for bypassing permission checks
    is_admin: bool = Field(default=False, description="Whether this instance has admin privileges")

    # Custom namespace configurations
    namespaces: list[dict[str, Any]] | None = Field(
        default=None,
        description="Custom namespace configurations (list of dicts with name, requires_zone)",
    )

    auto_parse: bool = Field(
        default=True,
        description="Automatically parse files on upload (default: True)",
    )

    # Parse provider configurations (v0.8.0)
    # Supports multiple providers: unstructured, llamaparse, pdf-inspector
    # Priority determines selection order (higher = preferred)
    # API keys can be set via environment variables or in config
    parse_providers: list[dict[str, Any]] | None = Field(
        default=None,
        description=(
            "Parse provider configurations. Each dict can contain: "
            "name (required), enabled, priority, api_key, api_url, supported_formats. "
            "Providers: unstructured (API), llamaparse (API), pdf-inspector (local PDF, default)"
        ),
    )

    # Permission enforcement settings (v0.3.0)
    # P0-6: CHANGED DEFAULT TO TRUE FOR SECURITY
    # Production builds MUST enforce permissions
    enforce_permissions: bool = Field(
        default=True,
        description="Enable permission enforcement on file operations (P0-6: default True for security)",
    )

    # Admin bypass setting (P0-4, Issue #3063)
    # Default: False for secure-by-default production deployments.
    # Set to True explicitly in dev/test configs that need admin bypass.
    allow_admin_bypass: bool = Field(
        default=False,
        description="Allow admin keys to bypass permission checks (default False for security)",
    )

    # Zone isolation setting (P0-2)
    # Default: True for multi-zone security
    # Set to False ONLY for single-zone environments or development
    # WARNING: Disabling zone isolation allows cross-zone data access
    enforce_zone_isolation: bool = Field(
        default=True,
        description="Enable zone isolation enforcement (default True for security)",
    )

    # Workspace and Memory registry (v0.7.0)
    workspaces: list[dict[str, Any]] | None = Field(
        default=None,
        description="Workspace registry configurations (list of dicts with path, name, description, created_by, metadata)",
    )
    memories: list[dict[str, Any]] | None = Field(
        default=None,
        description="Memory registry configurations (list of dicts with path, name, description, created_by, metadata)",
    )

    # Workflow automation settings (v0.7.0)
    enable_workflows: bool = Field(
        default=True,
        description="Enable automatic workflow triggering on file operations (default: True)",
    )

    # Multi-backend storage configuration (v0.9.0)
    backends: list[dict[str, Any]] | None = Field(
        default=None,
        description="Multiple backend mount configurations (type, mount_point, config, priority)",
    )

    # Cold tiering configuration (v0.9.15, Issue #3406)
    tiering: dict[str, Any] | None = Field(
        default=None,
        description=(
            "Volume cold tiering: upload sealed CAS volumes to S3/GCS. "
            "Keys: enabled, quiet_period, min_volume_size, cloud_backend (s3|gcs), "
            "cloud_bucket, upload_rate_limit, local_cache_size, "
            "burst_read_threshold, burst_read_window"
        ),
    )

    # OAuth provider configuration
    oauth: OAuthConfig | None = Field(
        default=None,
        description="OAuth provider configurations (providers list with name, display_name, provider_class, etc.)",
    )

    # Feature flags (v0.9.0+)
    features: FeaturesConfig = Field(
        default_factory=FeaturesConfig,
        description="Feature flags for optional functionality (semantic search, LLM read, etc.)",
    )

    # Security settings (Issue #3792)
    security: SecurityConfig = Field(
        default_factory=SecurityConfig,
        description="Security settings (SSRF, etc. — Issue #3792)",
    )

    # Resiliency configuration (Issue #1366)
    resiliency: dict[str, Any] | None = Field(
        default=None,
        description="Resiliency policies: timeouts, retries, circuit_breakers, targets",
    )

    # Remote mode settings
    url: str | None = Field(
        default=None, description="Nexus server URL (required for profile='remote')"
    )
    api_key: str | None = Field(default=None, description="API key for authentication")
    timeout: float = Field(default=30.0, description="Request timeout in seconds")

    # Thread pool and operation timeout settings (Issue #932)
    thread_pool_size: int = Field(
        default=200,
        description="Thread pool size for sync operations (AnyIO limiter tokens)",
    )
    operation_timeout: float = Field(
        default=30.0,
        description="Timeout for sync operations in asyncio.to_thread() calls",
    )

    # Cache jitter and refresh-ahead settings (Issue #932)
    cache_ttl_jitter: float = Field(
        default=0.2,
        description="Cache TTL jitter as fraction (0.2 = ±20%) to prevent thundering herd",
    )
    cache_refresh_factor: float = Field(
        default=0.7,
        description="Refresh cache at this fraction of TTL (0.7 = refresh at 70% of TTL)",
    )

    # Identity settings for memory API (v0.4.0)
    zone_id: str | None = Field(default=None, description="Zone ID for memory operations")
    user_id: str | None = Field(default=None, description="User ID for memory operations")
    agent_id: str | None = Field(default=None, description="Agent ID for memory operations")

    # Chunked/resumable upload settings (Issue #788)
    upload_min_chunk_size: int = Field(
        default=5 * 1024 * 1024,
        description="Minimum chunk size in bytes (default: 5MB)",
    )
    upload_max_chunk_size: int = Field(
        default=64 * 1024 * 1024,
        description="Maximum chunk size in bytes (default: 64MB)",
    )
    upload_default_chunk_size: int = Field(
        default=10 * 1024 * 1024,
        description="Default recommended chunk size in bytes (default: 10MB)",
    )
    upload_max_concurrent: int = Field(
        default=20,
        description="Maximum concurrent upload sessions (default: 20)",
    )
    upload_session_ttl_hours: int = Field(
        default=24,
        description="Upload session TTL in hours (default: 24)",
    )
    upload_cleanup_interval_seconds: int = Field(
        default=3600,
        description="Interval between expired session cleanup sweeps in seconds (default: 3600)",
    )

    # Log redaction settings (Issue #86)
    log_redaction_enabled: bool = Field(
        default=True,
        description="Enable secret masking in log output (default: True for security)",
    )

    # Docker sandbox template configuration
    docker: DockerTemplateConfig = Field(
        default_factory=DockerTemplateConfig,
        description="Docker sandbox template configuration",
    )

    @field_validator("profile")
    @classmethod
    def validate_profile(cls, v: str) -> str:
        """Validate deployment profile.

        Valid profiles: cluster, embedded, lite, sandbox, full, cloud, remote, auto
        """
        allowed = [
            "cluster",
            "embedded",
            "lite",
            "sandbox",
            "full",
            "cloud",
            "remote",
            "auto",
        ]
        if v not in allowed:
            raise ValueError(f"profile must be one of {allowed}, got '{v}'")
        return v

    @field_validator("backend")
    @classmethod
    def validate_backend(cls, v: str) -> str:
        """Validate backend type."""
        allowed = ["local", "path_local", "cas_gcs", "path_gcs", "path_s3", "gdrive_connector"]
        if v not in allowed:
            raise ValueError(f"backend must be one of {allowed}, got {v}")
        return v

    @field_validator("gcs_bucket_name")
    @classmethod
    def validate_gcs_bucket(cls, v: str | None, info: Any) -> str | None:
        """Validate GCS bucket name is provided when backend is gcs."""
        backend = info.data.get("backend")
        if backend == "gcs" and not v:
            # Check if we can get from environment
            env_bucket = os.getenv("NEXUS_GCS_BUCKET_NAME")
            if env_bucket:
                return env_bucket
            raise ValueError("gcs_bucket_name is required when backend='gcs'")
        return v

    @model_validator(mode="after")
    def validate_url_for_remote(self) -> "NexusConfig":
        """Validate URL is required for remote profile."""
        if self.profile == "remote" and not self.url:
            env_url = os.getenv("NEXUS_URL")
            if env_url:
                self.url = env_url
            else:
                raise ValueError("url is required for profile='remote'")
        return self

    model_config = ConfigDict(
        frozen=False,  # Allow modifications after creation
    )


def _apply_sandbox_defaults(cfg: "NexusConfig") -> "NexusConfig":
    """Apply SANDBOX profile defaults (Issue #3778).

    When profile=sandbox, fill in fields the user did NOT explicitly set
    with lightweight values (local backend, SQLite paths under
    ~/.nexus/sandbox/, small cache, no vector search).

    "Explicitly set" = present in `cfg.model_fields_set`. User values
    always win, even if their value happens to equal a non-sandbox default.

    This runs after env/YAML merge so user overrides are visible via
    `model_fields_set`.
    """
    from pathlib import Path as _Path
    from typing import Any as _Any

    if cfg.profile != "sandbox":
        return cfg

    user_set = cfg.model_fields_set
    updates: dict[str, _Any] = {}

    if "backend" not in user_set:
        # Direct filesystem (no CAS hash-store overhead). Edits/read/write
        # operate on real files at `data_dir` — matches SANDBOX's
        # "lightweight single-process" intent. "local" in this codebase
        # means CAS, which is not what sandbox needs.
        updates["backend"] = "path_local"

    if "data_dir" not in user_set:
        updates["data_dir"] = str(_Path.home() / ".nexus" / "sandbox")
    data_dir = updates.get("data_dir", cfg.data_dir)

    db_path = f"{data_dir}/nexus.db"
    if "db_path" not in user_set:
        updates["db_path"] = db_path
    if "metastore_path" not in user_set:
        updates["metastore_path"] = db_path
    if "record_store_path" not in user_set:
        updates["record_store_path"] = db_path

    if "cache_size_mb" not in user_set:
        updates["cache_size_mb"] = 64

    # Codex review R3 (high): SANDBOX vec is now ON by default (PR
    # #4022) — the [sandbox] extra bundles sqlite-vec + fastembed so
    # offline embeddings work out of the box. Leaving the schema
    # default (True) means ``connect(config={"profile":"sandbox"})``
    # exercises the hybrid path. Users can still opt out via
    # ``enable_vector_search=False`` (config dict or env).
    # No override here: the schema default (True) is now what we want.

    if not updates:
        return cfg
    return cfg.model_copy(update=updates)


def load_config(
    config: str | Path | dict[str, Any] | NexusConfig | None = None,
) -> NexusConfig:
    """
    Load Nexus configuration from various sources.

    Args:
        config: Configuration source:
            - None: Auto-discover from environment/files
            - str/Path: Path to config file
            - dict: Configuration dictionary
            - NexusConfig: Already loaded config (passthrough)

    Returns:
        Loaded NexusConfig

    Raises:
        FileNotFoundError: If specified config file doesn't exist
        ValueError: If configuration is invalid
    """
    # Codex review R5 #3 (medium): NexusConfig instances must go
    # through ``_apply_sandbox_defaults`` too — dict/YAML inputs do,
    # so a passthrough here would silently leave SANDBOX-typed
    # ``NexusConfig(profile="sandbox")`` callers with no
    # ``db_path``/``record_store_path`` and the local sqlite-vec
    # backend would skip wiring (no DB path resolved). Source-
    # agnosticism is the whole point of having a single ``connect()``
    # entry point. The defaulter is no-op for non-sandbox profiles.
    if isinstance(config, NexusConfig):
        return _apply_sandbox_defaults(config)

    # Load from dict
    if isinstance(config, dict):
        return _load_from_dict(config)

    # Load from file path
    if isinstance(config, str | Path):
        return _load_from_file(Path(config))

    # Auto-discover
    return _auto_discover()


def _load_from_dict(config_dict: dict[str, Any]) -> NexusConfig:
    """Load configuration from dictionary.

    Preserves provenance (Issue #3778, R1 review): only explicitly supplied
    keys from env or the input dict are passed to NexusConfig, so
    `model_fields_set` reflects user intent and SANDBOX defaults apply only
    to fields the user did NOT set.
    """
    # Environment-sourced overrides (no model_dump round-trip — preserves
    # per-field provenance so _apply_sandbox_defaults can see what was set).
    merged_dict = _build_env_overrides()
    merged_dict.update(config_dict)

    # Convert oauth dict to OAuthConfig if present
    if "oauth" in merged_dict and isinstance(merged_dict["oauth"], dict):
        merged_dict["oauth"] = OAuthConfig(**merged_dict["oauth"])

    return _apply_sandbox_defaults(NexusConfig(**merged_dict))


def _load_from_file(path: Path) -> NexusConfig:
    """Load configuration from file."""
    if not path.exists():
        raise FileNotFoundError(f"Config file not found: {path}")

    with open(path) as f:
        if path.suffix in [".yaml", ".yml"]:
            config_dict = yaml.safe_load(f)
        else:
            raise ValueError(f"Unsupported config file format: {path.suffix}")

    return _load_from_dict(config_dict)


def _build_env_overrides() -> dict[str, Any]:
    """Return a dict of only the config fields explicitly set via env vars.

    Unlike `_load_from_environment()`, this does NOT construct a NexusConfig
    or apply defaults — so the caller can preserve per-field provenance
    (`model_fields_set`) when layering additional sources on top (Issue #3778).
    """
    env_config: dict[str, Any] = {}

    # Map environment variables to config fields
    env_mapping = {
        "NEXUS_PROFILE": "profile",
        "NEXUS_BACKEND": "backend",
        "NEXUS_DATA_DIR": "data_dir",
        "NEXUS_GCS_BUCKET_NAME": "gcs_bucket_name",
        "NEXUS_GCS_PROJECT_ID": "gcs_project_id",
        "NEXUS_GCS_CREDENTIALS_PATH": "gcs_credentials_path",
        "NEXUS_CACHE_SIZE_MB": "cache_size_mb",
        "NEXUS_ENABLE_VECTOR_SEARCH": "enable_vector_search",
        "NEXUS_ENABLE_LLM_CACHE": "enable_llm_cache",
        "NEXUS_DB_PATH": "db_path",
        "NEXUS_METASTORE_PATH": "metastore_path",
        "NEXUS_RECORD_STORE_PATH": "record_store_path",
        "NEXUS_DATABASE_URL": "database_url",
        "NEXUS_CACHE_PATH_SIZE": "cache_path_size",
        "NEXUS_CACHE_LIST_SIZE": "cache_list_size",
        "NEXUS_CACHE_KV_SIZE": "cache_kv_size",
        "NEXUS_CACHE_EXISTS_SIZE": "cache_exists_size",
        "NEXUS_CACHE_TTL_SECONDS": "cache_ttl_seconds",
        "NEXUS_AUTO_PARSE": "auto_parse",
        "NEXUS_IS_ADMIN": "is_admin",
        "NEXUS_ENFORCE_PERMISSIONS": "enforce_permissions",
        "NEXUS_ALLOW_ADMIN_BYPASS": "allow_admin_bypass",
        "NEXUS_ENFORCE_ZONE_ISOLATION": "enforce_zone_isolation",
        "NEXUS_URL": "url",
        "NEXUS_API_KEY": "api_key",
        "NEXUS_TIMEOUT": "timeout",
        "NEXUS_ZONE_ID": "zone_id",
        "NEXUS_USER_ID": "user_id",
        "NEXUS_AGENT_ID": "agent_id",
        # Thread pool and cache settings (Issue #932)
        "NEXUS_THREAD_POOL_SIZE": "thread_pool_size",
        "NEXUS_OPERATION_TIMEOUT": "operation_timeout",
        "NEXUS_CACHE_TTL_JITTER": "cache_ttl_jitter",
        "NEXUS_CACHE_REFRESH_FACTOR": "cache_refresh_factor",
        # Chunked/resumable upload settings (Issue #788)
        "NEXUS_UPLOAD_MIN_CHUNK_SIZE": "upload_min_chunk_size",
        "NEXUS_UPLOAD_MAX_CHUNK_SIZE": "upload_max_chunk_size",
        "NEXUS_UPLOAD_DEFAULT_CHUNK_SIZE": "upload_default_chunk_size",
        "NEXUS_UPLOAD_MAX_CONCURRENT": "upload_max_concurrent",
        "NEXUS_UPLOAD_SESSION_TTL_HOURS": "upload_session_ttl_hours",
        "NEXUS_UPLOAD_CLEANUP_INTERVAL": "upload_cleanup_interval_seconds",
        # Log redaction settings (Issue #86)
        "NEXUS_LOG_REDACTION_ENABLED": "log_redaction_enabled",
    }

    for env_var, config_key in env_mapping.items():
        value = os.getenv(env_var)
        if value is not None:
            # Type conversion for non-string fields
            converted_value: Any
            if config_key in [
                "cache_size_mb",
                "cache_path_size",
                "cache_list_size",
                "cache_kv_size",
                "cache_exists_size",
                "thread_pool_size",
                "upload_min_chunk_size",
                "upload_max_chunk_size",
                "upload_default_chunk_size",
                "upload_max_concurrent",
                "upload_session_ttl_hours",
                "upload_cleanup_interval_seconds",
            ]:
                converted_value = int(value)
            elif config_key in [
                "timeout",
                "operation_timeout",
                "cache_ttl_jitter",
                "cache_refresh_factor",
            ]:
                converted_value = float(value)
            elif config_key == "cache_ttl_seconds":
                converted_value = int(value) if value.lower() != "none" else None
            elif config_key in [
                "enable_vector_search",
                "enable_llm_cache",
                "auto_parse",
                "is_admin",
                "enforce_permissions",
                "allow_admin_bypass",
                "enforce_zone_isolation",
                "log_redaction_enabled",
            ]:
                converted_value = value.lower() in ["true", "1", "yes", "on"]
            else:
                converted_value = value
            env_config[config_key] = converted_value

    # Auto-discover parse providers from environment variables
    # UNSTRUCTURED_API_KEY, LLAMA_CLOUD_API_KEY enable respective providers
    parse_providers = []

    # Check for Unstructured.io
    unstructured_key = os.getenv("UNSTRUCTURED_API_KEY")
    if unstructured_key:
        parse_providers.append(
            {
                "name": "unstructured",
                "priority": 100,
                "api_key": unstructured_key,
                "api_url": os.getenv(
                    "UNSTRUCTURED_WORKFLOW_ENDPOINT",
                    "https://api.unstructuredapp.io/general/v0/general",
                ),
            }
        )

    # Check for LlamaParse
    llamaparse_key = os.getenv("LLAMA_CLOUD_API_KEY")
    if llamaparse_key:
        parse_providers.append(
            {
                "name": "llamaparse",
                "priority": 90,
                "api_key": llamaparse_key,
            }
        )

    # pdf-inspector: fast local PDF parser (Rust + PyO3), no API key needed
    try:
        import pdf_inspector  # noqa: F401

        parse_providers.append({"name": "pdf-inspector", "priority": 20})
    except ImportError:
        pass

    if parse_providers:
        env_config["parse_providers"] = parse_providers

    return env_config


def _load_from_environment() -> NexusConfig:
    """Load configuration from environment variables."""
    return _apply_sandbox_defaults(NexusConfig(**_build_env_overrides()))


def _auto_discover() -> NexusConfig:
    """
    Auto-discover configuration from standard locations.

    Search order:
    1. ./nexus.yaml
    2. ./nexus.yml
    3. ~/.nexus/config.yaml
    4. Environment variables
    5. Defaults
    """
    # Check current directory
    for filename in ["nexus.yaml", "nexus.yml"]:
        path = Path(filename)
        if path.exists():
            return _load_from_file(path)

    # Check home directory
    home_config = Path.home() / ".nexus" / "config.yaml"
    if home_config.exists():
        return _load_from_file(home_config)

    # Fall back to environment variables and defaults
    return _load_from_environment()
