"""
Backparq Configuration Module

Handles loading, parsing, and validating YAML configuration files.
Supports environment variable expansion and config file includes.
"""

import datetime as dt
import logging
import os
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Optional

import yaml

logger = logging.getLogger(__name__)

# Defaults - no longer hardcoded restrictions
DEFAULT_BASE_DIR = Path.cwd() / "backparq-data"
DEFAULT_CUTOFF_EXCLUSIVE = ""  # Empty means no cutoff (full table)
DEFAULT_S3_PREFIX = "db-archive"
DEFAULT_ORDER_BY = "created_at"
DEFAULT_COMPRESSION = "snappy"
DEFAULT_PRIMARY_KEY = "id"


class ConfigError(ValueError):
    """Raised when configuration is invalid."""

    pass


@dataclass(frozen=True)
class DatabaseConfig:
    """PostgreSQL connection configuration."""

    host: str
    port: int
    name: str
    user: str
    password: str
    sslmode: str = ""
    connect_timeout: int = 10

    def dsn(self) -> str:
        parts = [
            f"host={self.host}",
            f"port={self.port}",
            f"dbname={self.name}",
            f"user={self.user}",
            f"password={self.password}",
            f"connect_timeout={self.connect_timeout}",
        ]
        if self.sslmode:
            parts.append(f"sslmode={self.sslmode}")
        return " ".join(parts)


@dataclass(frozen=True)
class S3Config:
    """S3 storage configuration."""

    bucket: str
    prefix: str = DEFAULT_S3_PREFIX
    region: Optional[str] = None
    access_key_id: Optional[str] = None
    secret_access_key: Optional[str] = None
    session_token: Optional[str] = None
    endpoint_url: Optional[str] = None
    use_ssl: bool = True
    verify_ssl: bool = True
    addressing_style: str = ""
    sse: str = ""
    kms_key_id: str = ""


@dataclass(frozen=True)
class ParquetEncryptionConfig:
    """Parquet encryption configuration (optional)."""

    enabled: bool = False
    footer_key: str = ""
    column_keys: dict[str, str] = field(default_factory=dict)
    key_map: dict[str, str] = field(default_factory=dict)


@dataclass(frozen=True)
class ParquetConfig:
    """Parquet file configuration."""

    compression: str = DEFAULT_COMPRESSION
    encryption: ParquetEncryptionConfig = field(default_factory=ParquetEncryptionConfig)


@dataclass(frozen=True)
class RetentionConfig:
    """Retention policy for pruning old backups."""

    enabled: bool = False
    days: int = 0
    months: int = 0


@dataclass(frozen=True)
class TableConfig:
    """Per-table configuration including primary key."""

    name: str
    primary_key: str = DEFAULT_PRIMARY_KEY

    def __str__(self) -> str:
        return self.name


@dataclass(frozen=True)
class ArchiveConfig:
    """Archive job configuration."""

    tables: list[TableConfig]
    mode: str  # "backup" or "offload"
    cutoff_exclusive: Optional[dt.datetime]
    base_dir: Path
    fetch_size: int = 10_000
    overwrite: bool = False
    dry_run: bool = False
    perform_delete: bool = False
    delete_batch_size: int = 10_000
    order_by: str = DEFAULT_ORDER_BY
    concurrency: int = 1
    chunk_concurrency: int = 1
    retention: RetentionConfig = field(default_factory=RetentionConfig)

    def __post_init__(self):
        if self.mode not in ("backup", "offload"):
            raise ConfigError(f"Invalid mode '{self.mode}'. Must be 'backup' or 'offload'.")
        if self.mode == "backup" and self.perform_delete:
            raise ConfigError(
                "Mode 'backup' cannot be combined with 'perform_delete'. "
                "Backup mode creates full snapshots without deletion."
            )

    def get_table_config(self, table_name: str) -> TableConfig:
        """Get config for a specific table by name."""
        for tc in self.tables:
            if tc.name == table_name:
                return tc
        raise ConfigError(f"Table '{table_name}' not found in config.")

    @property
    def table_names(self) -> list[str]:
        """Return list of table names for iteration."""
        return [t.name for t in self.tables]


@dataclass(frozen=True)
class CronConfig:
    """Cron scheduling configuration."""

    enabled: bool = False
    schedule: str = ""
    command: str = ""


@dataclass(frozen=True)
class BackparqConfig:
    """Root configuration object."""

    database: DatabaseConfig
    s3: S3Config
    parquet: ParquetConfig
    archive: ArchiveConfig
    cron: CronConfig = field(default_factory=CronConfig)


# =============================================================================
# Parsing Utilities
# =============================================================================


def parse_utc_datetime(value: str) -> dt.datetime:
    """Parse a datetime string to UTC timezone-aware datetime."""
    if not value:
        raise ConfigError("Empty datetime string")
    if "T" in value:
        parsed = dt.datetime.fromisoformat(value)
    else:
        parsed = dt.datetime.fromisoformat(value + "T00:00:00")
    if parsed.tzinfo is None:
        parsed = parsed.replace(tzinfo=dt.timezone.utc)
    return parsed.astimezone(dt.timezone.utc)


def _require_section(data: dict[str, Any], key: str) -> dict[str, Any]:
    """Require a config section to exist and be a dict."""
    value = data.get(key)
    if value is None or not isinstance(value, dict):
        raise ConfigError(f"Missing or invalid '{key}' section in config.")
    return value


def _require_str(data: dict[str, Any], key: str) -> str:
    """Require a non-empty string value."""
    value = data.get(key)
    if not isinstance(value, str) or not value.strip():
        raise ConfigError(f"Missing or invalid '{key}' value in config.")
    return value.strip()


def _optional_str(data: dict[str, Any], key: str, default: str = "") -> str:
    """Get optional string value with default."""
    value = data.get(key, default)
    if value is None:
        return default
    if not isinstance(value, str):
        raise ConfigError(f"Invalid '{key}' value in config; must be string.")
    return value.strip()


def _optional_int(data: dict[str, Any], key: str, default: int) -> int:
    """Get optional integer value with default."""
    value = data.get(key, default)
    if isinstance(value, bool):
        raise ConfigError(f"Invalid '{key}' value in config (bool not allowed).")
    try:
        return int(value)
    except (TypeError, ValueError) as exc:
        raise ConfigError(f"Invalid '{key}' value in config.") from exc


def _optional_bool(data: dict[str, Any], key: str, default: bool = False) -> bool:
    """Get optional boolean value with default."""
    value = data.get(key, default)
    if isinstance(value, bool):
        return value
    raise ConfigError(f"Invalid '{key}' value in config; must be boolean.")


def _optional_str_map(data: dict[str, Any], key: str) -> dict[str, str]:
    """Get optional string->string mapping."""
    value = data.get(key, {})
    if value is None:
        return {}
    if not isinstance(value, dict):
        raise ConfigError(f"Invalid '{key}' value in config; must be mapping.")
    parsed: dict[str, str] = {}
    for map_key, map_value in value.items():
        if not isinstance(map_key, str) or not isinstance(map_value, str):
            raise ConfigError(f"Invalid '{key}' mapping; keys/values must be strings.")
        parsed[map_key] = map_value
    return parsed


# =============================================================================
# Section Parsers
# =============================================================================


def _parse_database(data: dict[str, Any]) -> DatabaseConfig:
    """Parse database configuration section."""
    return DatabaseConfig(
        host=_require_str(data, "host"),
        port=_optional_int(data, "port", 5432),
        name=_require_str(data, "name"),
        user=_require_str(data, "user"),
        password=_require_str(data, "password"),
        sslmode=_optional_str(data, "sslmode"),
        connect_timeout=_optional_int(data, "connect_timeout", 10),
    )


def _parse_s3(data: dict[str, Any]) -> S3Config:
    """Parse S3 configuration section."""
    return S3Config(
        bucket=_require_str(data, "bucket"),
        prefix=_optional_str(data, "prefix", DEFAULT_S3_PREFIX),
        region=_optional_str(data, "region") or None,
        access_key_id=_optional_str(data, "access_key_id") or None,
        secret_access_key=_optional_str(data, "secret_access_key") or None,
        session_token=_optional_str(data, "session_token") or None,
        endpoint_url=_optional_str(data, "endpoint_url") or None,
        use_ssl=_optional_bool(data, "use_ssl", True),
        verify_ssl=_optional_bool(data, "verify_ssl", True),
        addressing_style=_optional_str(data, "addressing_style"),
        sse=_optional_str(data, "sse"),
        kms_key_id=_optional_str(data, "kms_key_id"),
    )


def _parse_parquet(data: dict[str, Any]) -> ParquetConfig:
    """Parse Parquet configuration section."""
    encryption_data = data.get("encryption", {})
    if encryption_data is None:
        encryption_data = {}
    if not isinstance(encryption_data, dict):
        raise ConfigError("Invalid 'parquet.encryption' section; must be mapping.")

    encryption = ParquetEncryptionConfig(
        enabled=_optional_bool(encryption_data, "enabled", False),
        footer_key=_optional_str(encryption_data, "footer_key"),
        column_keys=_optional_str_map(encryption_data, "column_keys"),
        key_map=_optional_str_map(encryption_data, "key_map"),
    )

    return ParquetConfig(
        compression=_optional_str(data, "compression", DEFAULT_COMPRESSION),
        encryption=encryption,
    )


def _parse_retention(data: dict[str, Any]) -> RetentionConfig:
    """Parse retention configuration section."""
    if not data:
        return RetentionConfig()

    return RetentionConfig(
        enabled=_optional_bool(data, "enabled", False),
        days=_optional_int(data, "days", 0),
        months=_optional_int(data, "months", 0),
    )


def _parse_tables(tables_data: list) -> list[TableConfig]:
    """
    Parse tables configuration.

    Supports both simple strings and dicts with per-table config:
    tables:
      - public.events                    # Simple string
      - table: public.orders             # Dict with options
        primary_key: order_id
    """
    if not isinstance(tables_data, list) or not tables_data:
        raise ConfigError("'archive.tables' must be a non-empty list.")

    parsed_tables: list[TableConfig] = []
    for item in tables_data:
        if isinstance(item, str):
            if not item.strip():
                raise ConfigError("'archive.tables' must not contain empty strings.")
            parsed_tables.append(TableConfig(name=item.strip()))
        elif isinstance(item, dict):
            table_name = item.get("table") or item.get("name")
            if not isinstance(table_name, str) or not table_name.strip():
                raise ConfigError("Table config must have 'table' or 'name' key with string value.")
            pk = _optional_str(item, "primary_key", DEFAULT_PRIMARY_KEY)
            parsed_tables.append(TableConfig(name=table_name.strip(), primary_key=pk))
        else:
            raise ConfigError(f"Invalid table entry: {item}. Must be string or dict.")

    return parsed_tables


def _parse_archive(data: dict[str, Any]) -> ArchiveConfig:
    """Parse archive configuration section."""
    tables = _parse_tables(data.get("tables", []))
    mode = _optional_str(data, "mode", "offload").lower()

    # Cutoff is optional - empty means no cutoff (process all data)
    cutoff_raw = _optional_str(data, "cutoff_exclusive", "")
    cutoff = parse_utc_datetime(cutoff_raw) if cutoff_raw else None

    # Base directory - configurable, defaults to ./backparq-data
    base_dir_str = _optional_str(data, "base_dir", str(DEFAULT_BASE_DIR))
    base_dir = Path(base_dir_str).resolve()

    # Ensure directory exists or can be created
    if not base_dir.exists():
        try:
            base_dir.mkdir(parents=True, exist_ok=True)
            logger.info(f"Created base directory: {base_dir}")
        except OSError as e:
            raise ConfigError(f"Cannot create base directory {base_dir}: {e}") from e

    return ArchiveConfig(
        tables=tables,
        mode=mode,
        cutoff_exclusive=cutoff,
        base_dir=base_dir,
        fetch_size=_optional_int(data, "fetch_size", 10_000),
        overwrite=_optional_bool(data, "overwrite", False),
        dry_run=_optional_bool(data, "dry_run", False),
        perform_delete=_optional_bool(data, "perform_delete", False),
        delete_batch_size=_optional_int(data, "delete_batch_size", 10_000),
        order_by=_optional_str(data, "order_by", DEFAULT_ORDER_BY),
        concurrency=_optional_int(data, "concurrency", 1),
        chunk_concurrency=_optional_int(data, "chunk_concurrency", 1),
        retention=_parse_retention(data.get("retention", {})),
    )


def _parse_cron(data: Optional[dict[str, Any]]) -> CronConfig:
    """Parse cron configuration section."""
    if not data:
        return CronConfig()
    if not isinstance(data, dict):
        raise ConfigError("Invalid 'cron' section; must be mapping.")

    return CronConfig(
        enabled=_optional_bool(data, "enabled", False),
        schedule=_optional_str(data, "schedule"),
        command=_optional_str(data, "command"),
    )


# =============================================================================
# YAML Loading and Environment Expansion
# =============================================================================


def _load_yaml(path: Path) -> dict[str, Any]:
    """Load and parse a YAML configuration file."""
    if not path.exists():
        raise ConfigError(f"Config file not found: {path}")
    raw = yaml.safe_load(path.read_text(encoding="utf-8"))
    if raw is None:
        return {}
    if not isinstance(raw, dict):
        raise ConfigError(f"Config file must contain a mapping: {path}")
    return raw


def _merge_dicts(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
    """Deep merge two dictionaries, with override taking precedence."""
    merged = dict(base)
    for key, value in override.items():
        if key in merged and isinstance(merged[key], dict) and isinstance(value, dict):
            merged[key] = _merge_dicts(merged[key], value)
        else:
            merged[key] = value
    return merged


_ENV_PATTERN = re.compile(r"\$\{([^}]+)\}")


def _expand_env(value: Any) -> Any:
    """Recursively expand ${VAR} and ${VAR:default} patterns."""
    if isinstance(value, dict):
        return {key: _expand_env(val) for key, val in value.items()}
    if isinstance(value, list):
        return [_expand_env(item) for item in value]
    if isinstance(value, str):

        def _replace(match: re.Match[str]) -> str:
            raw_key = match.group(1)
            if ":" in raw_key:
                key, default = raw_key.split(":", 1)
            else:
                key, default = raw_key, ""
            return os.getenv(key, default)

        return _ENV_PATTERN.sub(_replace, value)
    return value


# =============================================================================
# Main Configuration Loader
# =============================================================================


def load_config(path: Path) -> BackparqConfig:
    """
    Load and validate configuration from a YAML file.

    Supports:
    - Environment variable expansion: ${VAR} or ${VAR:default}
    - Config file includes via 'include' key
    - Per-table primary key configuration
    - Optional encryption
    """
    logger.info(f"Loading configuration from: {path}")
    raw = _load_yaml(path)

    # Handle includes
    include = raw.pop("include", None)
    merged: dict[str, Any] = {}
    if include:
        include_paths = include if isinstance(include, list) else [include]
        for include_path in include_paths:
            if not isinstance(include_path, str):
                raise ConfigError("Config 'include' entries must be file paths.")
            include_file = (path.parent / include_path).resolve()
            logger.debug(f"Including config: {include_file}")
            merged = _merge_dicts(merged, _load_yaml(include_file))

    merged = _merge_dicts(merged, raw)
    merged = _expand_env(merged)

    # Parse sections
    database = _parse_database(_require_section(merged, "database"))
    s3 = _parse_s3(_require_section(merged, "s3"))
    parquet = _parse_parquet(merged.get("parquet", {}))
    archive = _parse_archive(_require_section(merged, "archive"))
    cron = _parse_cron(merged.get("cron"))

    # Validate encryption config (only if enabled)
    if parquet.encryption.enabled:
        if not parquet.encryption.footer_key:
            raise ConfigError("Parquet encryption enabled but 'footer_key' missing.")
        if not parquet.encryption.key_map:
            raise ConfigError("Parquet encryption enabled but 'key_map' missing.")
        logger.info("Parquet encryption is enabled")
    else:
        logger.debug("Parquet encryption is disabled")

    # Validate S3 addressing style
    if s3.addressing_style and s3.addressing_style not in {"path", "virtual", "auto"}:
        raise ConfigError("Invalid s3.addressing_style; expected 'path', 'virtual', or 'auto'.")

    logger.info(
        f"Configuration loaded: {len(archive.tables)} tables, "
        f"mode={archive.mode}, concurrency={archive.concurrency}"
    )

    return BackparqConfig(
        database=database,
        s3=s3,
        parquet=parquet,
        archive=archive,
        cron=cron,
    )
