"""Configuration loading and validation.

This module provides the :func:`create_config` factory function to load PyRel configuration
from various sources (YAML config files, Snowflake config, DBT profiles, or programmatically
from Python dictionaries). The configuration is represented as a validated Pydantic model
with nested sections for connections, execution settings, data settings, compiler settings,
model settings, reasoners settings, and debug settings.

Set the ``RAI_CONFIG_FILE_PATH`` environment variable to pin an explicit config file path,
bypassing auto-discovery entirely.

Examples
--------
Loading from a YAML config file (auto-discovered):

>>> from relationalai.config import create_config
>>> cfg = create_config()
>>> session = cfg.get_session()  # Gets session from default connection

Notes
-----
The classes and functions in this module are re-exported in the base
:mod:`relationalai.config` namespace, so you can import them directly from there
instead of from this submodule.
"""

from __future__ import annotations

import os
from abc import ABC
from dataclasses import dataclass
from pathlib import Path
from typing import Any, TypeVar, overload, Literal, TYPE_CHECKING, cast

from confocal import BaseConfig, ConfocalSettingsConfigDict, find_upwards
from pydantic import Field, model_validator
from pydantic_settings import SettingsConfigDict

from ..util.docutils import include_in_docs

# include this module in documentation
__include_in_docs__ = True

if TYPE_CHECKING:
    import snowflake.snowpark
    import duckdb  # type: ignore[import-not-found]
    import requests

from .connections import ConnectionConfig, BaseConnection, SnowflakeConnection, DuckDBConnection, LocalConnection
from .connections.snowflake import SnowflakeConnectionBase
from .config_fields import DataConfig, CompilerConfig, ModelConfig, DebugConfig, JobsConfig
from .config_fields import ExecutionConfig
from .config_reasoners_fields import ReasonersConfig
from .deprecations import _DEPRECATED_FIELDS
from .external.dbt_converter import convert_dbt_to_rai
from .external.snowflake_converter import convert_snowflake_to_rai
from .external.raiconfig_converter import convert_raiconfig_to_rai
from .external.utils import find_dbt_profiles_file, find_snowflake_config_file, find_raiconfig_toml_file

# Error handling
from .errors import ConfigFileNotFoundError, AttemptedSource
from .errors.utils import raise_config_error, create_context_from_config_class, print_and_raise
from .constants import RAI_CONFIG_FILE_PATH_ENV_VAR

# TypeVar for generic connection retrieval
T = TypeVar("T", bound=BaseConnection)


@dataclass
class ConfigSource:
    """
    Information about a config source to try loading.

    Attributes:
        class_name: Name of the config class (e.g., "RAIConfig")
        config_class: The actual config class to instantiate
        file_paths: List of possible file paths to search for (e.g., ["raiconfig.yaml", "raiconfig.yml"])
    """
    class_name: str
    config_class: type[Config]
    file_paths: list[str | None]


@include_in_docs
class Config(BaseConfig, ABC):
    """Represents a validated PyRel configuration.

    Use :func:`~relationalai.config.create_config` to load your ``raiconfig.yaml`` and
    optionally override specific settings in code.

    Instances are typically created by :func:`~relationalai.config.create_config`, either from a
    supported config file or programmatically. The config validates that at least one
    connection exists, can auto-select ``default_connection`` when only one connection is
    defined, and provides helpers such as :meth:`get_connection` and :meth:`get_session`.

    Attributes
    ----------
    active_profile : str, optional
        Name of the profile currently applied.
    profiles : dict
        Profile overlays keyed by name (YAML key ``profile``).
    connections : dict
        Named connection definitions.
    default_connection : str, optional
        Connection name used when no explicit name is provided.
    execution : ExecutionConfig
        Execution middleware configuration. See :class:`~relationalai.config.config_fields.ExecutionConfig` for details.
    data : DataConfig
        Data loading and streaming configuration. See :class:`~relationalai.config.config_fields.DataConfig` for details.
    compiler : CompilerConfig
        Compiler configuration. See :class:`~relationalai.config.config_fields.CompilerConfig` for details.
    model : ModelConfig
        Model configuration. See :class:`~relationalai.config.config_fields.ModelConfig` for details.
    reasoners : ReasonersConfig
        Reasoners configuration. See :class:`~relationalai.config.config_reasoners_fields.ReasonersConfig` for details.
    debug : DebugConfig
        Debug configuration. See :class:`~relationalai.config.config_fields.DebugConfig` for details.
    jobs : JobsConfig
        Job execution configuration. See :class:`~relationalai.config.config_fields.JobsConfig` for details.

    Notes
    -----
    For Snowflake connections, the ``authenticator`` setting defaults to ``"username_password"`` when omitted.

    Examples
    --------
    Load a config and override a specific setting:

    >>> from relationalai.config import create_config
    >>> cfg = create_config(jobs={"print_progress": False})
    """

    active_profile: str | None = Field(
        default=None, description="Currently active profile name (auto-defaults if only one profile)"
    )

    profiles: dict[str, dict[str, Any]] = Field(
        default_factory=dict,
        alias="profile",
        description="Profiles available (dev, prod, staging, etc.) - profiles overlay config fields",
    )

    connections: dict[str, ConnectionConfig] = Field(
        default_factory=dict, description="Connections available (snowflake, duckdb, etc.)"
    )

    default_connection: str | None = Field(
        default=None, description="Default connection name (auto-defaults if only one connection)"
    )

    install_mode:bool = Field(default=False, description="Enable experimental install mode")
    use_graph_index: bool = Field(default=True, description="Enabling graph index")
    direct_access: bool = Field(default=False, description="Default Direct Access toggle for compatible services.")
    enable_otel_handler: bool = Field(default=False, description="Enable OpenTelemetry handler")

    # Nested config objects
    execution: ExecutionConfig = Field(default_factory=ExecutionConfig, description="Execution middleware configuration")
    data: DataConfig = Field(default_factory=DataConfig, description="Data loading and streaming configuration")
    compiler: CompilerConfig = Field(default_factory=CompilerConfig, description="Compiler configuration")
    model: ModelConfig = Field(default_factory=ModelConfig, description="Model configuration")
    reasoners: ReasonersConfig = Field(default_factory=ReasonersConfig, description="Reasoners configuration")
    debug: DebugConfig = Field(default_factory=DebugConfig, description="Debug configuration")
    jobs: JobsConfig = Field(default_factory=JobsConfig, description="Job execution configuration")

    @model_validator(mode="before")
    @classmethod
    def warn_deprecated_fields(cls, data: Any) -> Any:
        """Warn on deprecated top-level fields; forward renamed ones to their replacement."""
        if not isinstance(data, dict):
            return data
        from ..util.error import warn
        updates = {}
        for old, deprecated in _DEPRECATED_FIELDS.items():
            if old not in data:
                continue
            from ..util.error import Part
            parts: list[Part] | None = [f"Use [cyan]'{deprecated.replacement}'[/cyan] instead."] if deprecated.replacement else None
            warn("DeprecatedConfigField", deprecated.message, parts=parts)
            if deprecated.replacement is not None and deprecated.replacement not in data:
                updates[deprecated.replacement] = data[old]
        if updates:
            data = {**data, **updates}
        return data

    @model_validator(mode="before")
    @classmethod
    def set_default_snowflake_authenticator(cls, data: Any):
        """Set default authenticator for Snowflake connections in raw data."""
        if not isinstance(data, dict):
            return data

        def process_connections(connections):
            if not isinstance(connections, dict):
                return
            for conn_data in connections.values():
                if isinstance(conn_data, dict) and conn_data.get("type") == "snowflake":
                    conn_data.setdefault("authenticator", "username_password")

        # Process top-level connections
        if "connections" in data:
            process_connections(data["connections"])

        # Process connections in profiles (before overlay)
        if "profiles" in data and isinstance(data["profiles"], dict):
            for profile_data in data["profiles"].values():
                if isinstance(profile_data, dict) and "connections" in profile_data:
                    process_connections(profile_data["connections"])

        return data

    @model_validator(mode="after")
    def validate_connections(self):
        if not self.connections:
            raise ValueError("Config must have at least one connection")

        # Auto-set default_connection if only one connection exists and not already set
        if len(self.connections) == 1 and self.default_connection is None:
            self.default_connection = next(iter(self.connections.keys()))

        return self

    @include_in_docs
    def get_default_connection(self) -> ConnectionConfig:
        """Return the connection named by :attr:`~relationalai.config.config.Config.default_connection`.

        Returns
        -------
        ConnectionConfig
            The selected default connection.

        Notes
        -----
        If :attr:`~relationalai.config.config.Config.default_connection` is unset and exactly one
        connection is defined, that connection is returned.

        Raises
        ------
        ValueError
            Raised in the following cases:

            - Multiple connections exist but ``default_connection`` is not set.
            - ``default_connection`` names a connection that is not present.
        """
        default_conn_name = self.default_connection

        if default_conn_name is None:
            if len(self.connections) == 1:
                default_conn_name = list(self.connections.keys())[0]
            else:
                raise ValueError(
                    f"Multiple connections available but no default_connection specified. "
                    f"Available: {list(self.connections.keys())}"
                )

        if default_conn_name not in self.connections:
            raise ValueError(
                f"default_connection '{default_conn_name}' not found. Available: {list(self.connections.keys())}"
            )

        return self.connections[default_conn_name]

    @include_in_docs
    def get_connection(self, connection_type: type[T], name: str | None = None) -> T:
        """Return a configured connection, optionally by name.

        If ``name`` is omitted, this returns the default connection (see
        :meth:`~relationalai.config.config.Config.get_default_connection`). The selected
        connection is validated to be an instance of ``connection_type``.

        Parameters
        ----------
        connection_type : type[T]
            Expected connection class.
        name : str, optional
            Connection name to look up in :attr:`~relationalai.config.config.Config.connections`.
            Defaults to :attr:`~relationalai.config.config.Config.default_connection`.

        Returns
        -------
        T
            The selected connection.

        Raises
        ------
        ValueError
            Raised in the following cases:

            - ``name`` is provided but no such connection exists.
            - No default connection can be determined.
            - The selected connection is not an instance of ``connection_type``.
        """
        if name is None:
            connection = self.get_default_connection()
        else:
            if name not in self.connections:
                raise ValueError(f"Connection '{name}' not found. Available: {list(self.connections.keys())}")
            connection = self.connections[name]

        if not isinstance(connection, connection_type):
            # Provide helpful error message with the actual connection class hierarchy
            actual_type = type(connection).__name__
            # Check if it's a Snowflake connection
            if isinstance(connection, SnowflakeConnection):
                connection_class = "SnowflakeConnection"
            elif isinstance(connection, DuckDBConnection):
                connection_class = "DuckDBConnection"
            elif isinstance(connection, LocalConnection):
                connection_class = "LocalConnection"
            else:
                connection_class = actual_type

            raise ValueError(
                f"Connection '{name or self.default_connection}' is not of type {connection_type.__name__}. "
                f"Got: {connection_class} (actual: {actual_type})"
            )

        return cast(T, connection)

    @overload
    def get_session(self) -> snowflake.snowpark.Session | duckdb.DuckDBPyConnection | requests.Session: ...

    @overload
    def get_session(self, connection_type: type[SnowflakeConnection]) -> snowflake.snowpark.Session: ...

    @overload
    def get_session(self, connection_type: type[DuckDBConnection]) -> duckdb.DuckDBPyConnection: ...

    @overload
    def get_session(self, connection_type: type[LocalConnection]) -> requests.Session: ...

    @include_in_docs
    def get_session(
        self, connection_type: type[SnowflakeConnection] | type[DuckDBConnection] | type[LocalConnection] | None = None
    ) -> snowflake.snowpark.Session | duckdb.DuckDBPyConnection | requests.Session:
        """Return a session for a configured connection.

        When ``connection_type`` is omitted, this uses the default connection (see
        :meth:`~relationalai.config.config.Config.get_default_connection`). When provided,
        it resolves a connection of that type via :meth:`~relationalai.config.config.Config.get_connection`.

        Parameters
        ----------
        connection_type : type[SnowflakeConnection] | type[DuckDBConnection] | type[LocalConnection], optional
            Connection class to select when you have multiple connections configured.

        Returns
        -------
        snowflake.snowpark.Session | duckdb.DuckDBPyConnection | requests.Session
            The underlying session object for the selected connection.
        """
        if connection_type is None:
            connection = self.get_default_connection()
            return connection.get_session()
        else:
            connection = self.get_connection(connection_type)
            return connection.get_session()


# =============================================================================
# Config Source Classes
# =============================================================================


@include_in_docs
class RAIConfig(Config):
    """Load PyRel configuration from ``raiconfig.yaml`` / ``raiconfig.yml``.

    This is the primary YAML-backed config source used by :func:`~relationalai.config.create_config`.
    Most users should call :func:`~relationalai.config.create_config` to auto-discover the
    highest-priority available source.

    The ``RAI_CONFIG_FILE_PATH`` environment variable, when set, overrides the default
    file search and loads directly from the specified path.
    """

    source: Literal["rai"] = Field(default="rai", exclude=True)

    model_config = ConfocalSettingsConfigDict(
        yaml_file=["raiconfig.yaml", "raiconfig.yml"],
        env_file_override=RAI_CONFIG_FILE_PATH_ENV_VAR,
        extra="ignore",
        nested_model_default_partial_update=True,
    )


@include_in_docs
class ConfigFromSnowflake(Config):
    """Load PyRel configuration from Snowflake's ``config.toml``.

    This config source is used by :func:`~relationalai.config.config.Config` as a
    fallback when no ``raiconfig.yaml`` / ``raiconfig.yml`` is found. It reads the
    Snowflake config file discovered by
    :func:`~relationalai.config.external.utils.find_snowflake_config_file` and
    converts it into the standard PyRel config shape.
    """

    source: Literal["snowflake"] = Field(default="snowflake", exclude=True)

    model_config = SettingsConfigDict(
        toml_file=find_snowflake_config_file(),
        extra="ignore",
        nested_model_default_partial_update=True,
    )

    @model_validator(mode="before")
    @classmethod
    def convert_snowflake_structure(cls, data: Any):
        if not isinstance(data, dict):
            return data
        return convert_snowflake_to_rai(data)


@include_in_docs
class ConfigFromDBT(Config):
    """Load PyRel configuration from DBT's ``profiles.yml``.

    This config source is used by :func:`~relationalai.config.config.Config` as a
    fallback when no higher-priority config file is found. It reads the profiles
    file discovered by :func:`~relationalai.config.external.utils.find_dbt_profiles_file`
    and converts it into the standard PyRel config shape.
    """

    source: Literal["dbt"] = Field(default="dbt", exclude=True)

    model_config = SettingsConfigDict(
        yaml_file=find_dbt_profiles_file(),
        extra="ignore",
        nested_model_default_partial_update=True,
    )

    @model_validator(mode="before")
    @classmethod
    def convert_dbt_structure(cls, data: Any):
        """Convert DBT profiles.yml format to RAI Config format."""
        if not isinstance(data, dict):
            return data

        return convert_dbt_to_rai(data)


@include_in_docs
class ConfigFromRAIConfigToml(Config):
    """Load PyRel configuration from deprecated ``raiconfig.toml``.

    This config source is used by :func:`~relationalai.config.config.Config` as a
    fallback when no ``raiconfig.yaml`` / ``raiconfig.yml`` is found. The TOML file
    is converted to the standard PyRel config shape via
    :func:`~relationalai.config.external.raiconfig_converter.convert_raiconfig_to_rai`,
    and a :class:`DeprecationWarning` is emitted.

    Notes
    -----
    Prefer ``raiconfig.yaml`` / ``raiconfig.yml`` for new projects.
    """

    source: Literal["raiconfig_toml"] = Field(default="raiconfig_toml", exclude=True)

    model_config = ConfocalSettingsConfigDict(
        toml_file=find_raiconfig_toml_file(),
        env_file_override=RAI_CONFIG_FILE_PATH_ENV_VAR,
        extra="ignore",
        nested_model_default_partial_update=True,
    )

    @model_validator(mode="before")
    @classmethod
    def convert_raiconfig_structure(cls, data: Any):
        """Convert deprecated raiconfig.toml format to new format."""
        if not isinstance(data, dict):
            return data

        from relationalai.util.error import warn
        warn(
            "DeprecatedConfig",
            "raiconfig.toml is deprecated. Run 'rai init' to migrate or create a raiconfig.yaml.",
        )

        return convert_raiconfig_to_rai(data)


class _ActiveSessionConnection(SnowflakeConnectionBase):
    """SnowflakeConnection proxy backed by the active Snowpark session.

    Used only internally by ConfigFromActiveSession. The ``account`` and
    ``warehouse`` fields are unused — the session is obtained directly from
    ``get_active_session()``, which ``SnowflakeConnectionBase.get_session()``
    already calls first before attempting to create a new session.
    """

    authenticator: str = "active_session"
    account: str = ""   # unused — session comes from get_active_session()
    warehouse: str = "" # unused — session comes from get_active_session()

    def _get_connection_params(self) -> dict:
        raise NotImplementedError(
            "_ActiveSessionConnection uses the active Snowpark session directly"
        )


class ConfigFromActiveSession(Config):
    """
    Config for running inside Snowflake with an active Snowpark session.

    This is used when code is executed within Snowflake environments like:
    - Snowflake Notebooks
    - Snowflake Stored Procedures
    - Snowflake UDFs/UDTFs
    - Any other Snowpark execution context

    When no config files are found but an active session exists, this provides
    sensible defaults while preserving the active session for use.
    """
    source: Literal["active_session"] = "active_session"
    rai_app_name: str = Field(default="RELATIONALAI", description="RAI application name in Snowflake")

    @model_validator(mode='before')
    @classmethod
    def check_active_session(cls, data: Any):
        """
        Verify we have an active Snowpark session.

        Raises:
            ValueError: If no active Snowpark session is available.
        """
        try:
            from snowflake.snowpark.context import get_active_session
            _ = get_active_session()  # Check session exists, actual session accessed when needed
            # Session exists - return data with defaults
            # The session will be available via get_active_session() when needed
            return data if isinstance(data, dict) else {}
        except Exception:
            raise ValueError("No active Snowpark session found")

    @model_validator(mode="after")
    def validate_connections(self):
        """
        Override parent validation - connections are not required when using active session.
        The active Snowpark session will be accessed via get_active_session() when needed.
        """
        # Skip connection validation - active session will be used
        return self

    def get_default_connection(self) -> ConnectionConfig:
        """Return a connection backed by the active Snowpark session."""
        return cast(ConnectionConfig, _ActiveSessionConnection(rai_app_name=self.rai_app_name))

    def get_connection(self, connection_type: type[T], name: str | None = None) -> T:
        """Return a connection backed by the active Snowpark session.

        Only Snowflake connections are supported. The ``name`` parameter is
        accepted for API compatibility but ignored — there is exactly one
        implicit connection when using an active session.
        """
        if not issubclass(connection_type, SnowflakeConnection):
            raise ValueError(
                f"ConfigFromActiveSession only supports Snowflake connections, got {connection_type.__name__}"
            )
        return cast(T, _ActiveSessionConnection(rai_app_name=self.rai_app_name))

    @overload
    def get_session(self) -> snowflake.snowpark.Session: ...

    @overload
    def get_session(self, connection_type: type[SnowflakeConnection]) -> snowflake.snowpark.Session: ...

    @overload
    def get_session(self, connection_type: type[DuckDBConnection]) -> duckdb.DuckDBPyConnection: ...

    @overload
    def get_session(self, connection_type: type[LocalConnection]) -> requests.Session: ...

    def get_session(
        self, connection_type: type[SnowflakeConnection] | type[DuckDBConnection] | type[LocalConnection] | None = None
    ) -> snowflake.snowpark.Session | duckdb.DuckDBPyConnection | requests.Session:
        """
        Override parent get_session to use active Snowpark session directly.

        When running inside Snowflake (sprocs, notebooks, etc), we don't use the
        connections system - we use the active session provided by Snowpark.
        """
        if connection_type is not None and not issubclass(connection_type, SnowflakeConnection):
            raise ValueError(
                f"ConfigFromActiveSession only supports Snowflake connections, got {connection_type.__name__}"
            )

        from snowflake.snowpark.context import get_active_session
        return get_active_session()


class NoOpConfig(Config):
    """
    Minimal no-op config for import-time initialization.

    This is used when creating the CoreLibrary at module import time, where we don't
    have access to a real config yet. It provides defaults for all fields and skips
    validation to prevent config file lookups during import.

    This config should not be used for actual execution - it exists solely to allow
    module imports to succeed. Real Config objects should be created when needed.
    """
    source: Literal["noop"] = "noop"

    @model_validator(mode='before')
    @classmethod
    def provide_defaults(cls, data: Any):
        """Provide minimal defaults - no validation, no file lookups."""
        return data if isinstance(data, dict) else {}

    @model_validator(mode="after")
    def validate_connections(self):
        """Override parent validation - connections not required for no-op config."""
        return self


# =============================================================================
# Config Factory - Tries each source in order
# =============================================================================


@include_in_docs
def create_config(**data) -> Config:
    """Load configuration programmatically or from the highest-priority available config file source.

    If keyword arguments are provided, this creates a :class:`~relationalai.config.config.RAIConfig`
    from those values. Otherwise, it tries config sources in order (first match wins):

    1. ``RAI_CONFIG_FILE_PATH`` env var — explicit path, skips all auto-discovery
    2. ``raiconfig.yaml`` / ``raiconfig.yml`` (searched upward from current directory)
    3. ``raiconfig.toml`` (deprecated)
    4. ``~/.snowflake/config.toml``
    5. ``~/.dbt/profiles.yml``

    If a source file is found but invalid, an error is raised immediately (no fallback).

    Parameters
    ----------
    **data : Any
        Programmatic configuration values, passed as keyword arguments.

        Supported public keys are:

        - ``connections``: A ``dict`` of connection definitions; see :data:`~relationalai.config.connections.ConnectionConfig` for supported connection types and fields. (required)
        - ``default_connection``: ``str`` (optional; auto-selected when exactly one connection exists)
        - ``profile`` / ``profiles``: dict of named overrides (e.g., ``dev``, ``prod``) applied on top of the base config when selected (YAML key is ``profile``)
        - ``active_profile``: ``str`` (optional; selects which profile override to apply)
        - ``execution``: dict of :class:`~relationalai.config.config_fields.ExecutionConfig` fields
        - ``data``: dict of :class:`~relationalai.config.config_fields.DataConfig` fields
        - ``compiler``: dict of :class:`~relationalai.config.config_fields.CompilerConfig` fields
        - ``model``: dict of :class:`~relationalai.config.config_fields.ModelConfig` fields
        - ``reasoners``: dict of :class:`~relationalai.config.config_reasoners_fields.ReasonersConfig` fields
        - ``debug``: dict of :class:`~relationalai.config.config_fields.DebugConfig` fields
        - ``jobs``: dict of :class:`~relationalai.config.config_fields.JobsConfig` fields

        The following keys are also supported, but are not intended for use by end users and should only be set
        if instructed to do so by RelationalAI support:

        - ``use_graph_index``: ``bool``, optional (default: ``True``)
        - ``install_mode``: ``bool``, optional (default: ``False``)
        - ``enable_otel_handler``: ``bool``, optional (default: ``False``)

    Returns
    -------
    Config
        A validated config instance.

    Raises
    ------
    relationalai.config.errors.exceptions.ConfigValidationError
        If the provided data or a discovered config file fails validation.
    relationalai.config.errors.exceptions.ConfigFileNotFoundError
        If no config files are found and no programmatic data is provided.

    Examples
    --------

    Loading from a config file (auto-discovered):

    >>> from relationalai.config import create_config
    >>> cfg = create_config()

    Programmatic Snowflake config using browser-based auth:

    >>> from relationalai.config import create_config
    >>> cfg = create_config(
    ...     connections={
    ...         "sf": {
    ...             "type": "snowflake",
    ...             "authenticator": "externalbrowser",
    ...             "account": "my_account",
    ...             "warehouse": "my_warehouse",
    ...             "user": "my_user",
    ...         }
    ...     }
    ... )
    >>> cfg.default_connection
    'sf'

    See :mod:`~relationalai.config.connections.snowflake` for more Snowflake connection examples.

    Programmatic DuckDB config:

    >>> cfg = create_config(connections={"db": {"type": "duckdb", "path": ":memory:"}})
    >>> cfg.default_connection
    'db'
    """
    # Case 1: Programmatic config - fail fast with wrapped error
    if data:
        # If user provided connections, use RAIConfig
        if 'connections' in data and data['connections']:
            try:
                return RAIConfig(**data)
            except Exception as e:
                context = create_context_from_config_class("RAIConfig")
                raise_config_error(e, context)
        else:
            # No connections provided - try RAIConfig first (reads YAML, applies profiles),
            # then fall back to active session if no config file is found
            try:
                return RAIConfig(**data)
            except Exception:
                try:
                    return ConfigFromActiveSession(**data)
                except Exception as e:
                    context = create_context_from_config_class("RAIConfig")
                    raise_config_error(e, context)

    # Case 2: RAI_CONFIG_FILE_PATH env var — explicit path, top priority over auto-discovery
    env_config_file = os.environ.get(RAI_CONFIG_FILE_PATH_ENV_VAR)
    if env_config_file:
        env_path = Path(env_config_file)
        if not env_path.exists():
            error = ConfigFileNotFoundError(
                message=f"Config file set by {RAI_CONFIG_FILE_PATH_ENV_VAR} not found: {env_config_file}",
                attempted_sources=[
                    AttemptedSource(
                        source_name=RAI_CONFIG_FILE_PATH_ENV_VAR,
                        error_message=f"File not found: {env_path}",
                        file_path=None,
                    )
                ],
            )
            print_and_raise(error)
        ext = env_path.suffix.lower()
        if ext in (".yaml", ".yml"):
            try:
                return RAIConfig()
            except Exception as e:
                raise_config_error(e, create_context_from_config_class("RAIConfig", env_path))
        elif ext == ".toml":
            try:
                return ConfigFromRAIConfigToml()
            except Exception as e:
                raise_config_error(e, create_context_from_config_class("ConfigFromRAIConfigToml", env_path))
        else:
            error = ConfigFileNotFoundError(
                message=(
                    f"{RAI_CONFIG_FILE_PATH_ENV_VAR} has unsupported extension '{env_path.suffix}'. "
                    "Expected .yaml or .yml."
                ),
                attempted_sources=[
                    AttemptedSource(
                        source_name=RAI_CONFIG_FILE_PATH_ENV_VAR,
                        error_message=f"Unsupported extension: {env_path.suffix}",
                        file_path=None,
                    )
                ],
            )
            print_and_raise(error)

    # Case 3: File-based configs - fail fast if file exists
    # Define sources with their config files
    sources = [
        ConfigSource("RAIConfig", RAIConfig, ["raiconfig.yaml", "raiconfig.yml"]),
        ConfigSource("ConfigFromRAIConfigToml", ConfigFromRAIConfigToml, [find_raiconfig_toml_file()]),
        ConfigSource("ConfigFromSnowflake", ConfigFromSnowflake, [find_snowflake_config_file()]),
        ConfigSource("ConfigFromDBT", ConfigFromDBT, [find_dbt_profiles_file()]),
    ]

    attempted_sources = []

    for source in sources:
        # Check if any of the config files exist
        found_file = None
        for file_path in source.file_paths:
            if file_path is None:
                continue
            # Use confocal's find_upwards to search current and ancestor directories
            found = find_upwards(Path(file_path).expanduser())
            if found:
                found_file = found
                break

        if found_file:
            try:
                return source.config_class()
            except Exception as e:
                context = create_context_from_config_class(source.class_name, found_file)
                # Include active profile in context so the error message shows which
                # profile was applied when the validation failure occurred.
                if found_file.suffix in (".yaml", ".yml"):
                    try:
                        import yaml
                        raw = yaml.safe_load(found_file.read_text())
                        if isinstance(raw, dict):
                            context.profile_name = raw.get("active_profile")
                    except Exception:
                        pass
                raise_config_error(e, context)
        else:
            # File doesn't exist - collect error and try next source
            file_desc = " or ".join(str(f) for f in source.file_paths if f)
            attempted_sources.append(
                AttemptedSource(
                    source_name=source.class_name,
                    error_message=f"Config file not found: {file_desc}",
                    file_path=None
                )
            )

    # All file-based sources failed - try active Snowpark session
    try:
        return ConfigFromActiveSession()
    except Exception:
        # No active session
        pass

    # All sources failed (no files found, no active session, no SF sproc handler session)
    error = ConfigFileNotFoundError(
        message="No config file found in any expected location.",
        attempted_sources=attempted_sources
    )
    print_and_raise(error)
