"""Config framework — INI-backed dataclasses with argparse integration.

A config dataclass is the single source of truth for settings.  Values
come from three layers (later wins):

    1. Dataclass field defaults
    2. INI file sections
    3. CLI argument overrides

The argparse integration introspects the dataclass annotations to
generate CLI flags automatically.  No duplication between config
schema and argument definitions.

This module is a migration candidate for byteb4rb1e-utils.
"""

import configparser
from argparse import ArgumentParser, Namespace
from dataclasses import MISSING, dataclass, fields, field
from pathlib import Path
from typing import Any, Type, TypeVar, get_type_hints

T = TypeVar("T")


def _parse_bool(value: str) -> bool:
    """Parse a boolean from INI/CLI string."""
    return value.lower() in ("true", "yes", "1", "on")


_TYPE_MAP = {
    int: int,
    float: float,
    str: str,
    bool: _parse_bool,
}


def load_ini(
    cls: Type[T],
    path: Path,
    section: str | None = None,
) -> T:
    """Load a config dataclass from an INI file.

    If *section* is not given, the dataclass name (lowercased,
    without trailing "Config") is used.

    Unknown keys in the INI file raise ValueError.  Missing keys
    use the dataclass default.
    """
    if section is None:
        name = cls.__name__
        if name.endswith("Config"):
            name = name[: -len("Config")]
        section = name.lower()

    parser = configparser.ConfigParser(
        comment_prefixes=("#", ";"),
        inline_comment_prefixes=("#", ";"),
    )
    parser.read(path)

    if not parser.has_section(section):
        return cls()  # type: ignore[call-arg]

    hints = _resolve_hints(cls)
    field_names = {f.name for f in fields(cls)}
    kwargs: dict[str, Any] = {}

    for key, raw_value in parser.items(section):
        if key not in field_names:
            raise ValueError(
                f"Unknown config key '{key}' in"
                f" [{section}]. Valid keys:"
                f" {sorted(field_names)}"
            )

        field_type = hints.get(key, str)
        coerce = _TYPE_MAP.get(field_type, field_type)
        kwargs[key] = coerce(raw_value)

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


def add_config_arguments(
    cls: Type[T],
    parser: ArgumentParser,
    prefix: str = "",
) -> None:
    """Add CLI arguments for each field in a config dataclass.

    Field names are converted to CLI flags: ``heart_rate_resolution``
    becomes ``--heart-rate-resolution`` (or ``--<prefix>-heart-rate-resolution``
    if a prefix is given).

    Types are inferred from annotations.  Defaults come from the
    dataclass field defaults.
    """
    hints = _resolve_hints(cls)

    for f in fields(cls):
        flag_name = f.name.replace("_", "-")
        if prefix:
            flag_name = f"{prefix}-{flag_name}"

        field_type = hints.get(f.name, str)

        kwargs: dict[str, Any] = {
            "dest": f.name,
        }

        if field_type is bool:
            kwargs["action"] = (
                "store_false"
                if f.default is True
                else "store_true"
            )
            kwargs["default"] = None
        else:
            kwargs["type"] = _TYPE_MAP.get(
                field_type, field_type
            )
            kwargs["default"] = None
            kwargs["metavar"] = field_type.__name__.upper()

        parser.add_argument(f"--{flag_name}", **kwargs)


def apply_cli_overrides(
    config: T,
    args: Namespace,
) -> T:
    """Apply CLI argument values to a config instance.

    Only overrides fields that were explicitly set on the command
    line (not None).  Returns a new instance.
    """
    overrides = {}
    for f in fields(config):  # type: ignore[arg-type]
        cli_value = getattr(args, f.name, None)
        if cli_value is not None:
            overrides[f.name] = cli_value

    if not overrides:
        return config

    from dataclasses import asdict
    merged = asdict(config)  # type: ignore[arg-type]
    merged.update(overrides)
    return type(config)(**merged)  # type: ignore[return-value]


def ensure_ini(
    cls: Type[T],
    path: Path,
    section: str | None = None,
) -> T:
    """Load config from INI, creating the file with defaults if
    it does not exist.

    On first run, writes a commented INI file with all fields and
    their default values.  On subsequent runs, reads the existing
    file.  Never writes back CLI overrides.
    """
    if section is None:
        name = cls.__name__
        if name.endswith("Config"):
            name = name[: -len("Config")]
        section = name.lower()

    if not path.exists():
        _write_default_ini(cls, path, section)

    return load_ini(cls, path, section)


def _write_default_ini(
    cls: Type,
    path: Path,
    section: str,
) -> None:
    """Write an INI file with all fields as commented defaults."""
    lines = [f"[{section}]"]
    hints = _resolve_hints(cls)

    for f in fields(cls):
        field_type = hints.get(f.name, str)
        type_name = field_type.__name__

        if f.default is not MISSING:
            default = f.default
        elif f.default_factory is not MISSING:  # type: ignore[arg-type]
            default = f.default_factory()  # type: ignore[misc]
        else:
            continue

        lines.append(f"# {f.name} ({type_name})")
        lines.append(f"{f.name} = {default}")
        lines.append("")

    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text("\n".join(lines) + "\n")


def _resolve_hints(cls: Type) -> dict[str, type]:
    """Resolve type hints, handling both evaluated and string
    annotations."""
    try:
        return get_type_hints(cls)
    except Exception:
        return {
            f.name: f.type if isinstance(f.type, type)
            else str
            for f in fields(cls)
        }
