"""Configuration management for pygeoipupdate."""

from __future__ import annotations

import logging
import os
import re
from dataclasses import dataclass, field
from datetime import timedelta
from pathlib import Path
from typing import Self, cast
from urllib.parse import urlparse, urlunparse

from pygeoipupdate._defaults import (
    get_default_config_file,
    get_default_database_directory,
)
from pygeoipupdate.errors import ConfigError

logger = logging.getLogger(__name__)

_SCHEME_RE = re.compile(r"(?i)\A([a-z][a-z0-9+\-.]*)://")

# Sentinel so __post_init__ can distinguish "caller didn't provide a
# lock_file" from an explicit value.  Go always uses a lock file.
_UNSET_LOCK_FILE = Path("\x00unset")


@dataclass(frozen=True)
class Config:
    """Configuration for pygeoipupdate.

    Attributes:
        account_id: MaxMind account ID.
        license_key: MaxMind license key.
        edition_ids: Database edition IDs to download.
        database_directory: Directory to store database files.
        host: MaxMind update server URL.
        proxy: Proxy URL (http, https, or socks5).
        preserve_file_times: Whether to preserve file modification times.
        lock_file: Path to lock file for preventing concurrent runs.
            Defaults to ``<database_directory>/.geoipupdate.lock``.
        retry_for: Duration to retry failed downloads.
        parallelism: Number of parallel downloads.
        verbose: Enable verbose output.
        output: Enable JSON output.

    """

    account_id: int
    license_key: str
    edition_ids: tuple[str, ...]
    database_directory: Path = field(default_factory=get_default_database_directory)
    host: str = "https://updates.maxmind.com"
    proxy: str | None = None
    preserve_file_times: bool = False
    lock_file: Path = field(default_factory=lambda: _UNSET_LOCK_FILE)
    retry_for: timedelta = field(default_factory=lambda: timedelta(minutes=5))
    parallelism: int = 1
    verbose: bool = False
    output: bool = False

    def __post_init__(self) -> None:
        """Validate and set derived values after initialization."""
        if self.lock_file is _UNSET_LOCK_FILE:
            object.__setattr__(
                self, "lock_file", self.database_directory / ".geoipupdate.lock"
            )
        if not isinstance(self.edition_ids, tuple):
            object.__setattr__(self, "edition_ids", tuple(self.edition_ids))
        if self.account_id < 1:
            raise ConfigError("account_id must be a positive integer")
        if not self.edition_ids:
            raise ConfigError("the `EditionIDs` option is required")
        if not self.license_key:
            raise ConfigError("the `LicenseKey` option is required")
        if self.parallelism < 1:
            msg = f"parallelism should be greater than 0, got '{self.parallelism}'"
            raise ConfigError(msg)
        if self.account_id == 999999 and self.license_key == "000000000000":
            msg = "pygeoipupdate requires a valid AccountID and LicenseKey combination"
            raise ConfigError(msg)
        if not self.host or not self.host.startswith(("http://", "https://")):
            msg = "host must be an http:// or https:// URL"
            raise ConfigError(msg)
        if self.retry_for < timedelta(0):
            raise ConfigError("retry_for must be non-negative")

    @classmethod
    def from_file(
        cls,
        config_file: Path | None = None,
        *,
        database_directory: Path | None = None,
        parallelism: int | None = None,
        verbose: bool = False,
        output: bool = False,
    ) -> Self:
        """Load configuration with precedence: defaults < file < env < args.

        Args:
            config_file: Path to configuration file. If None, uses default.
            database_directory: Override for database directory.
            parallelism: Override for parallelism.
            verbose: Enable verbose output.
            output: Enable JSON output.

        Returns:
            Loaded configuration.

        Raises:
            ConfigError: If configuration is invalid or incomplete.

        """
        # Start with defaults
        config_data: dict[str, object] = {
            "host": "https://updates.maxmind.com",
            "database_directory": get_default_database_directory(),
            "retry_for": timedelta(minutes=5),
            "parallelism": 1,
            "preserve_file_times": False,
            "verbose": False,
            "output": False,
        }

        # Load from config file if it exists
        if config_file is None:
            default_file = get_default_config_file()
            if default_file.exists():
                config_file = default_file

        if config_file is not None:
            file_config = _parse_config_file(config_file)
            config_data.update(file_config)

        # Override with environment variables
        env_config = _parse_environment()
        config_data.update(env_config)

        # Override with CLI arguments
        if database_directory is not None:
            config_data["database_directory"] = database_directory
        if parallelism is not None and parallelism > 0:
            config_data["parallelism"] = parallelism
        if verbose:
            config_data["verbose"] = True
        if output:
            config_data["output"] = True

        # Validate required fields
        _validate_config(config_data)

        # Build proxy URL if needed
        proxy = _build_proxy_url(
            cast("str | None", config_data.get("_proxy_url")),
            cast("str | None", config_data.get("_proxy_user_password")),
        )
        if proxy:
            config_data["proxy"] = proxy

        # Remove internal keys
        config_data.pop("_proxy_url", None)
        config_data.pop("_proxy_user_password", None)

        kwargs: dict[str, object] = {
            "account_id": int(cast("str | int", config_data["account_id"])),
            "license_key": str(config_data["license_key"]),
            "edition_ids": tuple(config_data["edition_ids"]),  # type: ignore[arg-type]
            "database_directory": Path(config_data["database_directory"]),  # type: ignore[arg-type]
            "host": str(config_data["host"]),
            "proxy": config_data.get("proxy"),
            "preserve_file_times": bool(config_data.get("preserve_file_times", False)),
            "retry_for": config_data["retry_for"],
            "parallelism": int(cast("str | int", config_data["parallelism"])),
            "verbose": bool(config_data.get("verbose", False)),
            "output": bool(config_data.get("output", False)),
        }
        if config_data.get("lock_file"):
            kwargs["lock_file"] = Path(config_data["lock_file"])  # type: ignore[arg-type]

        return cls(**kwargs)  # type: ignore[arg-type]


def _parse_config_file(path: Path) -> dict[str, object]:
    """Parse a GeoIP.conf configuration file.

    Args:
        path: Path to the configuration file.

    Returns:
        Dictionary of configuration values.

    Raises:
        ConfigError: If the file cannot be parsed.

    """
    config: dict[str, object] = {}
    keys_seen: set[str] = set()

    try:
        with path.open() as f:
            for line_num, line in enumerate(f, 1):
                line = line.strip()
                if not line or line.startswith("#"):
                    continue

                parts = line.split(None, 1)
                if len(parts) < 2:
                    msg = f"invalid format on line {line_num}"
                    raise ConfigError(msg)

                key, value = parts[0], parts[1]

                if key in keys_seen:
                    msg = f"`{key}' is in the config multiple times"
                    raise ConfigError(msg)
                keys_seen.add(key)

                _set_config_value(config, keys_seen, key, value, line_num)
    except OSError as e:
        msg = f"error opening file: {e}"
        raise ConfigError(msg) from e

    return config


def _set_config_value(
    config: dict[str, object],
    keys_seen: set[str],
    key: str,
    value: str,
    line_num: int,
) -> None:
    """Set a configuration value from a parsed key-value pair.

    Args:
        config: Configuration dictionary to update.
        keys_seen: Set of keys already seen.
        key: Configuration key.
        value: Configuration value.
        line_num: Line number for error messages.

    Raises:
        ConfigError: If the value is invalid.

    """
    if key in ("AccountID", "UserId"):
        try:
            config["account_id"] = int(value)
        except ValueError as e:
            msg = "invalid account ID format"
            raise ConfigError(msg) from e
        keys_seen.add("AccountID")
        keys_seen.add("UserId")

    elif key == "DatabaseDirectory":
        config["database_directory"] = Path(value)

    elif key in ("EditionIDs", "ProductIds"):
        config["edition_ids"] = value.split()
        keys_seen.add("EditionIDs")
        keys_seen.add("ProductIds")

    elif key == "Host":
        config["host"] = _parse_host(value)

    elif key == "LicenseKey":
        config["license_key"] = value

    elif key == "LockFile":
        config["lock_file"] = Path(value)

    elif key == "PreserveFileTimes":
        if value not in ("0", "1"):
            msg = "`PreserveFileTimes' must be 0 or 1"
            raise ConfigError(msg)
        config["preserve_file_times"] = value == "1"

    elif key == "Proxy":
        config["_proxy_url"] = value

    elif key == "ProxyUserPassword":
        config["_proxy_user_password"] = value

    elif key == "RetryFor":
        config["retry_for"] = _parse_duration(value)

    elif key == "Parallelism":
        try:
            parallelism = int(value)
        except ValueError as e:
            msg = f"'{value}' is not a valid parallelism value"
            raise ConfigError(msg) from e
        if parallelism <= 0:
            msg = f"parallelism should be greater than 0, got '{parallelism}'"
            raise ConfigError(msg)
        config["parallelism"] = parallelism

    else:
        msg = f"unknown option on line {line_num}"
        raise ConfigError(msg)


def _parse_host(value: str) -> str:
    """Parse a host value, adding https scheme if missing.

    Args:
        value: Host value from configuration.

    Returns:
        Full URL with scheme.

    Raises:
        ConfigError: If the URL is invalid, has a non-http(s) scheme,
            or lacks a hostname.

    """
    if not _SCHEME_RE.match(value):
        parsed = urlparse(f"https://{value}")
    else:
        parsed = urlparse(value)
    if parsed.scheme not in ("http", "https"):
        msg = f"Host scheme must be http or https, got '{parsed.scheme}'"
        raise ConfigError(msg)
    if not parsed.hostname:
        msg = "Host must include a hostname"
        raise ConfigError(msg)
    return urlunparse(parsed)


def _parse_duration(value: str) -> timedelta:
    """Parse a duration string using Go's time.ParseDuration format.

    Supports units: h, m, s, ms, us/µs, ns. Components can appear in
    any order. Fractional values like "1.5m" are accepted. Sign prefixes
    are not supported (only non-negative durations are valid).

    Args:
        value: Duration string (e.g. "5m", "1h30m", "300ms", "1.5s").

    Returns:
        Parsed timedelta.

    Raises:
        ConfigError: If the duration cannot be parsed.

    """
    if not value:
        msg = f"'{value}' is not a valid duration"
        raise ConfigError(msg)

    if value == "0":
        return timedelta()

    # Match Go's time.ParseDuration format (without sign):
    # one or more (number, unit) pairs.  The numeric part allows
    # leading-dot (.5s), trailing-dot (5.s), and normal (5.0s) forms.
    pattern = re.compile(r"^(\d+\.?\d*|\d*\.?\d+)(ns|us|µs|ms|s|m|h)")
    remaining = value
    total_us = 0.0

    # Unit values in microseconds
    unit_us = {
        "ns": 0.001,
        "us": 1.0,
        "µs": 1.0,
        "ms": 1_000.0,
        "s": 1_000_000.0,
        "m": 60_000_000.0,
        "h": 3_600_000_000.0,
    }

    while remaining:
        match = pattern.match(remaining)
        if not match or not match.group(1):
            msg = f"'{value}' is not a valid duration"
            raise ConfigError(msg)
        amount = float(match.group(1))
        unit = match.group(2)
        total_us += amount * unit_us[unit]
        remaining = remaining[match.end() :]

    return timedelta(microseconds=total_us)


def _parse_environment() -> dict[str, object]:
    """Parse configuration from environment variables.

    Returns:
        Dictionary of configuration values from environment.

    Raises:
        ConfigError: If environment values are invalid.

    """
    config: dict[str, object] = {}

    # Account ID
    if value := os.environ.get("GEOIPUPDATE_ACCOUNT_ID"):
        try:
            config["account_id"] = int(value)
        except ValueError as e:
            msg = "invalid account ID format"
            raise ConfigError(msg) from e

    if file_path := os.environ.get("GEOIPUPDATE_ACCOUNT_ID_FILE"):
        try:
            content = Path(file_path).read_text().strip()
            config["account_id"] = int(content)
        except OSError as e:
            msg = f"failed to open GEOIPUPDATE_ACCOUNT_ID_FILE: {e}"
            raise ConfigError(msg) from e
        except ValueError as e:
            msg = "invalid account ID format"
            raise ConfigError(msg) from e

    # License Key
    if value := os.environ.get("GEOIPUPDATE_LICENSE_KEY"):
        config["license_key"] = value

    if file_path := os.environ.get("GEOIPUPDATE_LICENSE_KEY_FILE"):
        try:
            config["license_key"] = Path(file_path).read_text().strip()
        except OSError as e:
            msg = f"failed to open GEOIPUPDATE_LICENSE_KEY_FILE: {e}"
            raise ConfigError(msg) from e

    # Database directory
    if value := os.environ.get("GEOIPUPDATE_DB_DIR"):
        config["database_directory"] = Path(value)

    # Edition IDs
    if value := os.environ.get("GEOIPUPDATE_EDITION_IDS"):
        config["edition_ids"] = value.split()

    # Host
    if value := os.environ.get("GEOIPUPDATE_HOST"):
        config["host"] = _parse_host(value)

    # Lock file
    if value := os.environ.get("GEOIPUPDATE_LOCK_FILE"):
        config["lock_file"] = Path(value)

    # Parallelism
    if value := os.environ.get("GEOIPUPDATE_PARALLELISM"):
        try:
            parallelism = int(value)
        except ValueError as e:
            msg = f"'{value}' is not a valid parallelism value"
            raise ConfigError(msg) from e
        if parallelism <= 0:
            msg = f"parallelism should be greater than 0, got '{parallelism}'"
            raise ConfigError(msg)
        config["parallelism"] = parallelism

    # Preserve file times
    if value := os.environ.get("GEOIPUPDATE_PRESERVE_FILE_TIMES"):
        if value not in ("0", "1"):
            msg = "`GEOIPUPDATE_PRESERVE_FILE_TIMES' must be 0 or 1"
            raise ConfigError(msg)
        config["preserve_file_times"] = value == "1"

    # Proxy
    if value := os.environ.get("GEOIPUPDATE_PROXY"):
        config["_proxy_url"] = value

    if value := os.environ.get("GEOIPUPDATE_PROXY_USER_PASSWORD"):
        config["_proxy_user_password"] = value

    # Retry for
    if value := os.environ.get("GEOIPUPDATE_RETRY_FOR"):
        config["retry_for"] = _parse_duration(value)

    # Verbose
    if value := os.environ.get("GEOIPUPDATE_VERBOSE"):
        if value not in ("0", "1"):
            msg = "`GEOIPUPDATE_VERBOSE' must be 0 or 1"
            raise ConfigError(msg)
        config["verbose"] = value == "1"

    return config


def _validate_config(config: dict[str, object]) -> None:
    """Validate that required configuration values are present.

    These checks apply to the dict-building phase before Config
    construction.  Invariant checks that also apply to direct
    construction live in Config.__post_init__.

    Args:
        config: Configuration dictionary.

    Raises:
        ConfigError: If required values are missing or invalid.

    """
    if not config.get("edition_ids"):
        msg = "the `EditionIDs` option is required"
        raise ConfigError(msg)

    if not config.get("account_id"):
        msg = "the `AccountID` option is required"
        raise ConfigError(msg)

    if not config.get("license_key"):
        msg = "the `LicenseKey` option is required"
        raise ConfigError(msg)


def _build_proxy_url(
    proxy_url: str | None,
    proxy_user_password: str | None,
) -> str | None:
    """Build a complete proxy URL from components.

    Args:
        proxy_url: Proxy host/URL.
        proxy_user_password: Proxy credentials in "user:password" format.

    Returns:
        Complete proxy URL or None.

    Raises:
        ConfigError: If proxy configuration is invalid.

    """
    if not proxy_url:
        return None

    # Add scheme if missing
    match = _SCHEME_RE.match(proxy_url)
    if not match:
        proxy_url = f"http://{proxy_url}"
    else:
        scheme = match.group(1).lower()
        if scheme not in ("http", "https", "socks5"):
            msg = f"unsupported proxy type: {scheme}"
            raise ConfigError(msg)

    try:
        parsed = urlparse(proxy_url)
    except (ValueError, TypeError) as e:
        msg = f"parsing proxy URL: {e}"
        raise ConfigError(msg) from e

    # Add default port if missing
    host = parsed.hostname or ""
    port = parsed.port
    if port is None:
        port = 1080  # Default proxy port (matching Go implementation)

    netloc = f"{host}:{port}"

    # Add credentials if provided and not already in URL
    if parsed.username is None and proxy_user_password:
        parts = proxy_user_password.split(":", 1)
        if len(parts) != 2:
            msg = "proxy user/password is malformed"
            raise ConfigError(msg)
        username, password = parts
        netloc = f"{username}:{password}@{netloc}"
    elif parsed.username:
        if parsed.password:
            netloc = f"{parsed.username}:{parsed.password}@{netloc}"
        else:
            netloc = f"{parsed.username}@{netloc}"

    return urlunparse((parsed.scheme, netloc, parsed.path, "", "", ""))
