"""Configuration management for MCP server.

Loads configuration from environment variables only.

Supports two authentication providers:
- Keycloak: Standard OIDC with PKCE (default)
- CAS: Legacy eRegistrations OAuth2 server (no PKCE, Basic Auth)

Instance Isolation:
Each BPA instance gets its own data directory based on hostname.
This ensures tokens, audit logs, and rollback states are isolated.
"""

import os
from enum import StrEnum
from pathlib import Path
from typing import Any
from urllib.parse import urlparse

from pydantic import BaseModel, Field, SecretStr, field_validator, model_validator

from mcp_eregistrations_bpa.exceptions import ConfigurationError
from mcp_eregistrations_common.config import (
    _generate_instance_slug as _generate_instance_slug,  # re-exported for compat
)


class AuthProvider(StrEnum):
    """Authentication provider type."""

    KEYCLOAK = "keycloak"  # Standard OIDC with PKCE
    CAS = "cas"  # Legacy eRegistrations OAuth2 (no PKCE, Basic Auth)


# XDG-compliant data directory
CONFIG_DIR = Path.home() / ".config" / "mcp-eregistrations-bpa"


def get_instance_data_dir(bpa_url: str | None = None) -> Path:
    """Get the instance-specific data directory.

    Each BPA instance gets its own subdirectory under CONFIG_DIR.
    This isolates databases, tokens, and logs per instance.

    Args:
        bpa_url: BPA instance URL. If None, reads from environment.

    Returns:
        Path to instance-specific data directory.
        Falls back to CONFIG_DIR if no URL configured.
        Creates the directory if it doesn't exist.
    """
    if bpa_url is None:
        bpa_url = os.environ.get("BPA_INSTANCE_URL")

    if not bpa_url:
        # Fallback to base config dir (backward compatible)
        CONFIG_DIR.mkdir(parents=True, exist_ok=True)
        return CONFIG_DIR

    slug = _generate_instance_slug(bpa_url)
    instance_dir = CONFIG_DIR / "instances" / slug
    instance_dir.mkdir(parents=True, exist_ok=True)
    return instance_dir


def get_current_instance_id() -> str | None:
    """Get the instance ID for the current BPA instance (from env var).

    Returns:
        Instance slug or None if not configured.
    """
    bpa_url = os.environ.get("BPA_INSTANCE_URL")
    if not bpa_url:
        return None
    return _generate_instance_slug(bpa_url)


class Config(BaseModel):
    """MCP server configuration."""

    bpa_instance_url: str
    ds_url: str | None = None  # Display System (applicant portal) URL
    gdb_url: str | None = None  # GDB (Generic Database Builder) URL

    # Keycloak configuration (standard OIDC with PKCE)
    keycloak_client_id: str = "mcp-eregistrations-bpa"
    keycloak_url: str | None = None
    keycloak_realm: str | None = None

    # CAS configuration (legacy eRegistrations OAuth2)
    cas_url: str | None = None
    cas_client_id: str | None = None
    cas_client_secret: SecretStr | None = Field(
        default=None, repr=False
    )  # Required for CAS (no PKCE)
    cas_callback_port: int = 8914  # Fixed port for CAS redirect_uri

    @field_validator("bpa_instance_url", "ds_url", "gdb_url", "keycloak_url", "cas_url")
    @classmethod
    def validate_https(cls, v: str | None) -> str | None:
        """Validate that URLs use HTTPS and have valid structure."""
        if v is None:
            return None
        if not v:
            raise ValueError("URL cannot be empty")
        if v.startswith("http://"):
            raise ValueError(
                "URL must use HTTPS: "
                "Security requires encrypted connections. "
                "Update URL to use https:// scheme."
            )
        if not v.startswith("https://"):
            raise ValueError("URL must start with https://")
        # Validate URL structure
        parsed = urlparse(v)
        if not parsed.netloc:
            raise ValueError("URL must have a valid host")
        if parsed.query or parsed.fragment:
            raise ValueError("URL must not contain query string or fragment")
        return v.rstrip("/")  # Normalize: remove trailing slash

    @field_validator("cas_url")
    @classmethod
    def normalize_cas_url(cls, v: str | None) -> str | None:
        """Auto-append /cback/v1.0 if CAS URL has no API path.

        eRegistrations CAS servers use /cback/v1.0 as the OAuth base.
        Users often pass the bare hostname without this path.
        """
        if v is None:
            return None
        v = v.rstrip("/")
        if "/cback/" not in v:
            v = v + "/cback/v1.0"
        return v

    @model_validator(mode="after")
    def validate_cas_config(self) -> "Config":
        """Validate CAS configuration if CAS URL is provided."""
        if self.cas_url:
            if not self.cas_client_id:
                raise ValueError(
                    "CAS_CLIENT_ID required when CAS_URL is set. "
                    "CAS authentication requires a client ID."
                )
            # Check SecretStr: must exist and have non-empty value
            if (
                not self.cas_client_secret
                or not self.cas_client_secret.get_secret_value()
            ):
                raise ValueError(
                    "CAS_CLIENT_SECRET required when CAS_URL is set. "
                    "CAS uses Basic Auth instead of PKCE."
                )
        return self

    @property
    def auth_provider(self) -> AuthProvider:
        """Detect authentication provider based on configuration.

        Returns CAS if cas_url is configured, otherwise Keycloak.
        """
        if self.cas_url:
            return AuthProvider.CAS
        return AuthProvider.KEYCLOAK

    @property
    def oidc_discovery_url(self) -> str:
        """Get the OIDC discovery URL (Keycloak only).

        If keycloak_url and keycloak_realm are provided, constructs
        the standard Keycloak realm discovery URL. Otherwise, falls
        back to BPA instance URL for discovery.

        Note: CAS does not support OIDC discovery.

        Returns:
            The URL for .well-known/openid-configuration discovery.
        """
        if self.keycloak_url and self.keycloak_realm:
            # Standard Keycloak realm URL pattern
            return f"{self.keycloak_url}/realms/{self.keycloak_realm}"
        elif self.keycloak_url:
            # Keycloak URL provided but no realm - use as base
            return self.keycloak_url
        else:
            # Default: assume OIDC discovery at BPA URL
            return self.bpa_instance_url

    @property
    def cas_authorization_url(self) -> str | None:
        """Get the CAS authorization URL (CAS only).

        Returns:
            The CAS SPA authorization URL, or None if not using CAS.
        """
        if not self.cas_url:
            return None
        return f"{self.cas_url}/cas/spa.html"

    @property
    def cas_token_url(self) -> str | None:
        """Get the CAS token endpoint URL (CAS only).

        Returns:
            The CAS token URL, or None if not using CAS.
        """
        if not self.cas_url:
            return None
        return f"{self.cas_url}/access_token"

    @property
    def cas_public_key_url(self) -> str | None:
        """Get the CAS public key URL for JWT validation (CAS only).

        Returns:
            The CAS public key URL, or None if not using CAS.
        """
        if not self.cas_url:
            return None
        return f"{self.cas_url}/user/publicKey"

    @property
    def partc_url(self) -> str | None:
        """Derive PARTC URL from CAS URL.

        PARTC shares the same base as CAS, just different path:
        - CAS:   https://eid.test.cuba.eregistrations.org/cback/v1.0
        - PARTC: https://eid.test.cuba.eregistrations.org/partc/v1.0

        Returns:
            The PARTC base URL, or None if CAS not configured or URL
            doesn't contain /cback/ path.
        """
        if not self.cas_url:
            return None
        if "/cback/" not in self.cas_url:
            # Non-standard CAS URL - cannot derive PARTC URL
            return None
        return self.cas_url.replace("/cback/", "/partc/")

    @property
    def partc_user_attributes_url(self) -> str | None:
        """Get the PARTC user attributes URL (CAS only).

        Returns:
            The PARTC URL for fetching user roles, or None if not configured.
        """
        if not self.partc_url:
            return None
        return f"{self.partc_url}/user/attributes"

    @property
    def instance_id(self) -> str:
        """Get the unique instance ID for this BPA instance.

        Used for data isolation (database, logs, etc.).

        Returns:
            A filesystem-safe slug like "els-dev-a1b2c3".
        """
        return _generate_instance_slug(self.bpa_instance_url)

    @property
    def instance_data_dir(self) -> Path:
        """Get the instance-specific data directory.

        Returns:
            Path to the directory for this instance's data.
        """
        return get_instance_data_dir(self.bpa_instance_url)


def load_config() -> Config:
    """Load configuration from environment variables.

    Required environment variables:
        BPA_INSTANCE_URL: The BPA instance URL (required)

    Optional environment variables:
        KEYCLOAK_URL: Keycloak server URL
        KEYCLOAK_REALM: Keycloak realm name
        KEYCLOAK_CLIENT_ID: Keycloak client ID (default: mcp-eregistrations-bpa)
        CAS_URL: CAS server URL (for legacy auth, PARTC URL derived automatically)
        CAS_CLIENT_ID: CAS client ID
        CAS_CLIENT_SECRET: CAS client secret

    Returns:
        Validated Config object.

    Raises:
        ConfigurationError: If required configuration is missing or invalid.
    """
    url = os.environ.get("BPA_INSTANCE_URL")

    if not url:
        raise ConfigurationError(
            "BPA_INSTANCE_URL environment variable is required. "
            "Set it in your MCP server configuration."
        )

    # Build config from environment variables
    # Use Any because Pydantic handles type coercion (str -> SecretStr, etc.)
    config_kwargs: dict[str, Any] = {"bpa_instance_url": url}

    if ds_url := os.environ.get("DS_URL"):
        config_kwargs["ds_url"] = ds_url

    if gdb_url := os.environ.get("GDB_URL"):
        config_kwargs["gdb_url"] = gdb_url

    # Keycloak configuration
    if keycloak_client_id := os.environ.get("KEYCLOAK_CLIENT_ID"):
        config_kwargs["keycloak_client_id"] = keycloak_client_id

    if keycloak_url := os.environ.get("KEYCLOAK_URL"):
        config_kwargs["keycloak_url"] = keycloak_url

    if keycloak_realm := os.environ.get("KEYCLOAK_REALM"):
        config_kwargs["keycloak_realm"] = keycloak_realm

    # CAS configuration
    if cas_url := os.environ.get("CAS_URL"):
        config_kwargs["cas_url"] = cas_url

    if cas_client_id := os.environ.get("CAS_CLIENT_ID"):
        config_kwargs["cas_client_id"] = cas_client_id

    if cas_client_secret := os.environ.get("CAS_CLIENT_SECRET"):
        config_kwargs["cas_client_secret"] = cas_client_secret

    if cas_callback_port := os.environ.get("CAS_CALLBACK_PORT"):
        config_kwargs["cas_callback_port"] = int(cas_callback_port)

    # Validate and return config
    try:
        return Config(**config_kwargs)
    except ValueError as e:
        raise ConfigurationError(str(e)) from e


# ---------------------------------------------------------------------------
# Named-instance profile registry
# ---------------------------------------------------------------------------

PROFILES_PATH = CONFIG_DIR / "profiles.json"


def load_profiles() -> dict[str, dict[str, Any]]:
    """Load instance profiles from the unified store."""
    from mcp_eregistrations_common.config import load_profiles as _common_load

    return _common_load()


def save_profiles(profiles: dict[str, dict[str, Any]]) -> None:
    """Save instance profiles to the unified store."""
    from mcp_eregistrations_common.config import save_profiles as _common_save

    _common_save(profiles)


def _normalize_profile_name(name: str) -> str:
    """Normalize profile name: lowercase, strip whitespace and 'bpa-' prefix."""
    name = name.strip().lower()
    if name.startswith("bpa-"):
        name = name[4:]
    return name


def _fuzzy_match_profile(
    instance: str, profiles: dict[str, dict[str, Any]]
) -> str | None:
    """Try to match an instance name against profiles with normalization.

    Attempts exact match first, then normalized match (case-insensitive,
    strip 'bpa-' prefix).

    Returns:
        Matched profile key, or None if no match.
    """
    if instance in profiles:
        return instance

    normalized = _normalize_profile_name(instance)
    for key in profiles:
        if _normalize_profile_name(key) == normalized:
            return key

    return None


def resolve_instance_config(instance: str | None) -> Config:
    """Resolve instance name → Config from profiles.json.

    Supports fuzzy matching: case-insensitive, strips 'BPA-' prefix.

    Args:
        instance: Instance profile name (from instance_list).

    Returns:
        Resolved Config.

    Raises:
        ConfigurationError: If instance is None or named profile not found.
    """
    if instance is None:
        profiles = load_profiles()
        if len(profiles) == 1:
            # Auto-select when there's exactly one profile
            instance = next(iter(profiles))
        elif profiles:
            available = ", ".join(sorted(profiles.keys()))
            raise ConfigurationError(
                f"No instance specified. Pass instance='<name>'. "
                f"Available profiles: {available}."
            )
        else:
            raise ConfigurationError(
                "No instance specified and no profiles registered. "
                "Use instance_add to register a BPA instance profile."
            )

    profiles = load_profiles()
    matched_key = _fuzzy_match_profile(instance, profiles)
    if matched_key is None:
        available = ", ".join(sorted(profiles.keys())) or "none"
        raise ConfigurationError(
            f"Instance profile '{instance}' not found. "
            f"Available profiles: {available}. "
            "Use instance_add to register a new profile."
        )

    profile = dict(profiles[matched_key])  # shallow copy before mutating

    # Unified store uses bpa_url; Config expects bpa_instance_url
    if "bpa_url" in profile:
        profile["bpa_instance_url"] = profile.pop("bpa_url")

    # Resolve CAS secret from auth_store if cas_url present but no secret
    if profile.get("cas_url") and not profile.get("cas_client_secret"):
        try:
            from mcp_eregistrations_common.auth_store import get_cas_secret

            secret = get_cas_secret(matched_key)
            if secret:
                profile["cas_client_secret"] = secret
        except Exception:
            pass

    # Strip fields that are not in Config.model_fields
    valid_keys = set(Config.model_fields.keys())
    profile = {k: v for k, v in profile.items() if k in valid_keys}

    try:
        return Config(**profile)
    except ValueError as e:
        raise ConfigurationError(
            f"Invalid configuration for profile '{matched_key}': {e}"
        ) from e


def load_known_instances() -> dict[str, dict[str, str]]:
    """Load canonical BPA instances from bundled instances.yaml.

    Returns:
        Mapping of BPA URL -> instance metadata (name, auth, keycloak/cas config).
    """
    from importlib.resources import files

    import yaml

    text = files("mcp_eregistrations_bpa").joinpath("instances.yaml").read_text()
    data = yaml.safe_load(text)

    result: dict[str, dict[str, str]] = {}
    for inst in data["instances"]:
        url = inst["bpa_url"].rstrip("/")
        entry: dict[str, str] = {"name": inst["name"], "auth": inst["auth"]}
        if inst.get("ds_url"):
            entry["ds_url"] = inst["ds_url"]
        if inst.get("gdb_url"):
            entry["gdb_url"] = inst["gdb_url"]
        if inst["auth"] == "keycloak":
            entry["keycloak_url"] = inst["keycloak_url"]
            entry["keycloak_realm"] = inst["keycloak_realm"]
        elif inst["auth"] == "cas":
            entry["cas_url"] = inst["cas_url"]
            entry["cas_client_id"] = inst.get("cas_client_id", "mcp-bpa")
        result[url] = entry
    return result


def resolve_db_path(instance: str | None) -> Path:
    """Resolve instance name or None → Path to data.db.

    Args:
        instance: Named profile or None for env-var default.

    Returns:
        Path to instance-specific SQLite database.
    """
    config = resolve_instance_config(instance)
    return get_instance_data_dir(config.bpa_instance_url) / "data.db"
