"""Configuration loading, API key resolution, and campaign directory management.

Reads user config from ~/.unfamiliar/config.toml. API keys are resolved
from environment variables only.
"""

from __future__ import annotations

import hashlib
import logging
import os
import tomllib
from pathlib import Path

import yaml
from pydantic import BaseModel, Field

from unfamiliar.constants import DEFAULT_PROVIDER, DEFAULT_THEME, DEFAULT_TOKEN_BUDGET

logger = logging.getLogger(__name__)

# Provider-specific env var names
_PROVIDER_ENV_VARS: dict[str, str] = {
    "anthropic": "ANTHROPIC_API_KEY",
    "glm": "GLM_API_KEY",
}
_GENERIC_ENV_VAR = "UNFAMILIAR_API_KEY"

_CONFIG_FILENAME = "config.toml"
_CAMPAIGNS_DIR = "campaigns"

_DEFAULT_CONFIG_CONTENT = f"""\
# Unfamiliar configuration
# See documentation for all options

[general]
provider = "{DEFAULT_PROVIDER}"
token_budget = {DEFAULT_TOKEN_BUDGET}
theme = "{DEFAULT_THEME}"

# Optional: Override LLM provider defaults
# [llm]
# model = "glm-4-flash"           # Override default model
# base_url = "https://..."        # Custom API endpoint
# api_key_env = "MY_API_KEY"      # Custom env var for API key
# max_tokens = 4096               # Max output tokens
"""


class LLMConfig(BaseModel):
    """LLM provider configuration overrides.

    All fields default to None, meaning registry defaults are used.
    Setting a field overrides the built-in default for the selected provider.
    """

    model: str | None = Field(default=None)
    base_url: str | None = Field(default=None)
    api_key_env: str | None = Field(default=None)
    max_tokens: int | None = Field(default=None, gt=0)


class UnfamiliarConfig(BaseModel):
    """User configuration loaded from ~/.unfamiliar/config.toml."""

    provider: str = Field(default=DEFAULT_PROVIDER)
    token_budget: int = Field(default=DEFAULT_TOKEN_BUDGET, gt=0)
    theme: str = Field(default=str(DEFAULT_THEME))
    llm: LLMConfig = Field(default_factory=LLMConfig)
    config_path: Path | None = Field(default=None, exclude=True)


class ConfigError(Exception):
    """Raised when configuration is invalid or cannot be loaded."""


class APIKeyError(Exception):
    """Raised when an API key cannot be resolved for the given provider."""


class RepoFilterConfig(BaseModel):
    """Per-repository filter configuration.

    Loaded from ~/.unfamiliar/repos/<hash>/config.yaml where hash is
    the first 8 characters of SHA-256 of the repo path.
    """

    include_patterns: list[str] = Field(default_factory=list)
    exclude_patterns: list[str] = Field(default_factory=list)


def get_repo_config_path(repo_path: Path, base_dir: Path | None = None) -> Path:
    """Get the per-repo config directory path.

    Args:
        repo_path: Path to the repository.
        base_dir: Base directory for unfamiliar data. Defaults to ~/.unfamiliar.

    Returns:
        Path to the repo's config directory (e.g., ~/.unfamiliar/repos/<hash>/)
    """
    _base = base_dir or (Path.home() / ".unfamiliar")
    absolute_path = str(repo_path.resolve())
    path_hash = hashlib.sha256(absolute_path.encode()).hexdigest()[:8]
    return _base / "repos" / path_hash


def load_repo_filter_config(
    repo_path: Path,
    base_dir: Path | None = None,
) -> RepoFilterConfig:
    """Load per-repository filter configuration.

    Config file path: ~/.unfamiliar/repos/<hash>/config.yaml
    Where <hash> is sha256(repo_path)[:8].

    Args:
        repo_path: Path to the repository.
        base_dir: Base directory for unfamiliar data. Defaults to ~/.unfamiliar.

    Returns:
        RepoFilterConfig with include/exclude patterns.
        Returns empty config if file doesn't exist.

    Raises:
        ConfigError: If the config file exists but contains invalid YAML.
    """
    config_dir = get_repo_config_path(repo_path, base_dir)
    config_file = config_dir / "config.yaml"

    if not config_file.exists():
        logger.debug("No repo filter config at %s", config_file)
        return RepoFilterConfig()

    try:
        raw = config_file.read_text(encoding="utf-8")
        if not raw.strip():
            return RepoFilterConfig()

        data = yaml.safe_load(raw)
        if data is None:
            return RepoFilterConfig()

        return RepoFilterConfig(
            include_patterns=data.get("include_patterns", []),
            exclude_patterns=data.get("exclude_patterns", []),
        )
    except yaml.YAMLError as exc:
        raise ConfigError(f"Failed to parse {config_file}: {exc}") from exc
    except OSError as exc:
        logger.warning("Could not read repo config %s: %s", config_file, exc)
        return RepoFilterConfig()


def load_config(
    config_path: Path | None = None,
    base_dir: Path | None = None,
) -> UnfamiliarConfig:
    """Load configuration from TOML file.

    Creates the ~/.unfamiliar/ directory and a default config file if they
    don't exist. Returns defaults if the config file is missing or empty.

    Args:
        config_path: Explicit path to config file. Overrides base_dir.
        base_dir: Base directory for unfamiliar data. Defaults to ~/.unfamiliar.
    """
    _base = base_dir or (Path.home() / ".unfamiliar")
    path = config_path or (_base / _CONFIG_FILENAME)

    if not path.parent.exists():
        path.parent.mkdir(parents=True, mode=0o700)

    if not path.exists():
        path.write_text(_DEFAULT_CONFIG_CONTENT)
        return UnfamiliarConfig(config_path=path)

    try:
        raw = path.read_bytes()
        if not raw.strip():
            return UnfamiliarConfig(config_path=path)

        data = tomllib.loads(raw.decode())
    except tomllib.TOMLDecodeError as exc:
        raise ConfigError(f"Invalid TOML in {path}: {exc}") from exc
    except OSError as exc:
        raise ConfigError(f"Cannot read config file {path}: {exc}") from exc

    general = data.get("general", {})

    # Parse [llm] section for provider overrides
    llm_data = data.get("llm", {})
    llm_config = LLMConfig(
        model=llm_data.get("model"),
        base_url=llm_data.get("base_url"),
        api_key_env=llm_data.get("api_key_env"),
        max_tokens=llm_data.get("max_tokens"),
    )

    return UnfamiliarConfig(
        provider=general.get("provider", DEFAULT_PROVIDER),
        token_budget=general.get("token_budget", DEFAULT_TOKEN_BUDGET),
        theme=general.get("theme", str(DEFAULT_THEME)),
        llm=llm_config,
        config_path=path,
    )


def resolve_api_key(
    provider: str,
    config: UnfamiliarConfig,
    api_key_env_override: str | None = None,
) -> str:
    """Resolve an API key for the given provider.

    Lookup order:
    1. Config-specified env var (api_key_env_override or config.llm.api_key_env)
    2. Provider-specific environment variable (e.g., ANTHROPIC_API_KEY)
    3. Generic UNFAMILIAR_API_KEY environment variable

    Raises APIKeyError if no key is found.
    """
    # Priority 1: Explicit override (from provider registry or config)
    env_override = api_key_env_override or config.llm.api_key_env
    if env_override:
        key = os.environ.get(env_override)
        if key:
            return key

    # Priority 2: Legacy provider-specific env var
    env_var = _PROVIDER_ENV_VARS.get(provider)
    if env_var:
        key = os.environ.get(env_var)
        if key:
            return key

    # Priority 3: Generic fallback
    generic_key = os.environ.get(_GENERIC_ENV_VAR)
    if generic_key:
        return generic_key

    env_hint = f" ({env_override or env_var})" if (env_override or env_var) else ""
    raise APIKeyError(
        f"No API key found for provider '{provider}'. "
        f"Set the{env_hint} environment variable."
    )


def get_campaign_dir(
    repo_path: Path,
    base_dir: Path | None = None,
) -> Path:
    """Return the campaign storage directory for a given repository.

    Format: ~/.unfamiliar/campaigns/<repo-name>-<hash>/
    Hash is first 6 characters of SHA-256 of the absolute repo path.
    Appends -1, -2, etc. on hash collision with a different repo path.

    Args:
        repo_path: Path to the repository.
        base_dir: Base directory for unfamiliar data. Defaults to ~/.unfamiliar.
    """
    _base = base_dir or (Path.home() / ".unfamiliar")
    campaigns_base = _base / _CAMPAIGNS_DIR
    campaigns_base.mkdir(parents=True, exist_ok=True)

    absolute_path = str(repo_path.resolve())
    path_hash = hashlib.sha256(absolute_path.encode()).hexdigest()[:6]
    repo_name = repo_path.resolve().name

    candidate = campaigns_base / f"{repo_name}-{path_hash}"
    logger.debug(
        "get_campaign_dir: repo_path=%s → candidate=%s (exists=%s)",
        absolute_path,
        candidate,
        candidate.exists(),
    )

    if not candidate.exists():
        return candidate

    # Check if existing directory belongs to this repo
    if _campaign_matches_repo(candidate, absolute_path):
        return candidate

    # Hash collision — try suffixed variants
    for suffix in range(1, 100):
        candidate = campaigns_base / f"{repo_name}-{path_hash}-{suffix}"
        if not candidate.exists():
            return candidate
        if _campaign_matches_repo(candidate, absolute_path):
            return candidate

    raise ConfigError(f"Too many campaign directory hash collisions for '{repo_name}'")


def _campaign_matches_repo(campaign_dir: Path, repo_absolute_path: str) -> bool:
    """Check if a campaign directory belongs to the given repo path.

    Reads the campaign's stored repo_path from its database or marker file.
    Returns False if the directory exists but can't be read or doesn't match.
    """
    # Marker file stores the original repo path for quick lookup
    marker = campaign_dir / ".repo_path"
    if marker.exists():
        try:
            stored_path = marker.read_text().strip()
            return stored_path == repo_absolute_path
        except OSError:
            return False
    return False
