"""Configuration reader and formatter for ``[tool.docvet]`` in pyproject.toml.

Loads and validates the ``[tool.docvet]`` configuration table from
``pyproject.toml``. Supports ``extend-exclude`` for additive pattern
merging on top of defaults or an explicit ``exclude`` list. Uses
composable validation helpers (``_validate_string_list``,
``_resolve_fail_warn``) to keep individual parsers focused. Exposes
``EnrichmentConfig``, ``FreshnessConfig``, and ``PresenceConfig``
dataclasses with sensible defaults for all check modules.

Provides ``format_config_toml`` and ``format_config_json`` for rendering
the effective configuration with source annotations. TOML formatting
is decomposed into ``_fmt_toml_value``, ``_get_annotation``, and
``_format_toml_section`` helpers to keep individual functions focused.

Examples:
    Load configuration from the project root:

    ```python
    from docvet.config import load_config

    config = load_config()
    enrichment = config.enrichment
    ```

See Also:
    [`docvet.cli`][]: CLI entry point that loads config on startup.
    [`docvet.checks`][]: Check functions that consume config objects.
"""

from __future__ import annotations

import dataclasses
import json
import sys
import tomllib
from dataclasses import dataclass, field
from pathlib import Path

__all__ = [
    "DocvetConfig",
    "EnrichmentConfig",
    "FreshnessConfig",
    "PresenceConfig",
    "format_config_json",
    "format_config_toml",
    "get_user_keys",
    "load_config",
]

# ---------------------------------------------------------------------------
# Frozen dataclasses
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class FreshnessConfig:
    """Configuration for the freshness check.

    Attributes:
        drift_threshold (int): Maximum days since last docstring update
            before flagging drift. Defaults to 30.
        age_threshold (int): Maximum days since initial docstring creation
            before flagging age. Defaults to 90.

    Examples:
        Use defaults (30-day drift, 90-day age):

        ```python
        cfg = FreshnessConfig()
        ```

        Tighten thresholds for a fast-moving codebase:

        ```python
        cfg = FreshnessConfig(drift_threshold=14, age_threshold=60)
        ```
    """

    drift_threshold: int = 30
    age_threshold: int = 90


@dataclass(frozen=True)
class EnrichmentConfig:
    """Configuration for the enrichment check.

    Attributes:
        require_examples (list[str]): Symbol kinds that must have an
            ``Examples:`` section. Defaults to
            ``["class", "protocol", "dataclass", "enum"]``.
        require_raises (bool): Require ``Raises:`` sections. Defaults
            to ``True``.
        require_yields (bool): Require ``Yields:`` sections. Defaults
            to ``True``.
        require_warns (bool): Require ``Warns:`` sections. Defaults
            to ``True``.
        require_receives (bool): Require ``Receives:`` sections.
            Defaults to ``True``.
        require_other_parameters (bool): Require ``Other Parameters:``
            sections. Defaults to ``True``.
        require_typed_attributes (bool): Require typed attribute format.
            Defaults to ``True``.
        require_cross_references (bool): Require ``See Also:`` in
            module docstrings. Defaults to ``True``.
        prefer_fenced_code_blocks (bool): Prefer fenced code blocks
            over indented blocks. Defaults to ``True``.
        require_attributes (bool): Require ``Attributes:`` sections.
            Defaults to ``True``.

    Examples:
        Use defaults (all rules enabled):

        ```python
        cfg = EnrichmentConfig()
        ```

        Disable yields checking for a project without generators:

        ```python
        cfg = EnrichmentConfig(require_yields=False)
        ```
    """

    require_examples: list[str] = field(
        default_factory=lambda: ["class", "protocol", "dataclass", "enum"]
    )
    require_raises: bool = True
    require_yields: bool = True
    require_warns: bool = True
    require_receives: bool = True
    require_other_parameters: bool = True
    require_typed_attributes: bool = True
    require_cross_references: bool = True
    prefer_fenced_code_blocks: bool = True
    require_attributes: bool = True


@dataclass(frozen=True)
class PresenceConfig:
    """Configuration for the presence check.

    Attributes:
        enabled (bool): Whether the presence check is active.
            Defaults to ``True``.
        min_coverage (float): Minimum docstring coverage percentage
            (0.0–100.0). Enforced by the CLI layer (Story 28.2).
            Defaults to ``0.0`` (no threshold).
        ignore_init (bool): Skip ``__init__`` methods when checking
            for missing docstrings. Defaults to ``True``.
        ignore_magic (bool): Skip dunder methods (except
            ``__init__``) when checking for missing docstrings.
            Defaults to ``True``.
        ignore_private (bool): Skip single-underscore-prefixed
            symbols when checking for missing docstrings. Defaults
            to ``True``.

    Examples:
        Use defaults (all ignore flags on, no threshold):

        ```python
        cfg = PresenceConfig()
        ```

        Require 95% coverage and check ``__init__`` methods:

        ```python
        cfg = PresenceConfig(min_coverage=95.0, ignore_init=False)
        ```
    """

    enabled: bool = True
    min_coverage: float = 0.0
    ignore_init: bool = True
    ignore_magic: bool = True
    ignore_private: bool = True


@dataclass(frozen=True)
class DocvetConfig:
    """Top-level docvet configuration.

    Attributes:
        src_root (str): Source directory relative to project root.
            Defaults to ``"."`` (auto-detects ``src/`` layout).
        package_name (str | None): Explicit package name override.
            Defaults to ``None`` (auto-detected).
        exclude (list[str]): Directory names to exclude from checks.
            Defaults to ``["tests", "scripts"]``.
        fail_on (list[str]): Check names that cause exit code 1.
            Defaults to ``[]``.
        warn_on (list[str]): Check names reported without failing.
            Defaults to all five checks.
        freshness (FreshnessConfig): Freshness check settings.
        enrichment (EnrichmentConfig): Enrichment check settings.
        presence (PresenceConfig): Presence check settings.
        project_root (Path): Resolved project root directory.

    Examples:
        Load from ``pyproject.toml`` (typical usage):

        ```python
        from docvet.config import load_config

        cfg = load_config()
        ```

        Construct directly for programmatic use:

        ```python
        cfg = DocvetConfig(fail_on=["enrichment", "freshness"])
        ```
    """

    src_root: str = "."
    package_name: str | None = None
    exclude: list[str] = field(default_factory=lambda: ["tests", "scripts"])
    fail_on: list[str] = field(default_factory=list)
    warn_on: list[str] = field(
        default_factory=lambda: [
            "presence",
            "freshness",
            "enrichment",
            "griffe",
            "coverage",
        ]
    )
    freshness: FreshnessConfig = field(default_factory=FreshnessConfig)
    enrichment: EnrichmentConfig = field(default_factory=EnrichmentConfig)
    presence: PresenceConfig = field(default_factory=PresenceConfig)
    project_root: Path = field(default_factory=Path.cwd)


# ---------------------------------------------------------------------------
# Valid-key constants
# ---------------------------------------------------------------------------

_VALID_TOP_KEYS: frozenset[str] = frozenset(
    {
        "src-root",
        "package-name",
        "exclude",
        "extend-exclude",
        "fail-on",
        "warn-on",
        "freshness",
        "enrichment",
        "presence",
    }
)

_VALID_FRESHNESS_KEYS: frozenset[str] = frozenset({"drift-threshold", "age-threshold"})

_VALID_ENRICHMENT_KEYS: frozenset[str] = frozenset(
    {
        "prefer-fenced-code-blocks",
        "require-attributes",
        "require-cross-references",
        "require-examples",
        "require-other-parameters",
        "require-raises",
        "require-receives",
        "require-typed-attributes",
        "require-warns",
        "require-yields",
    }
)

_VALID_CHECK_NAMES: frozenset[str] = frozenset(
    {"enrichment", "freshness", "coverage", "griffe", "presence"}
)

_VALID_PRESENCE_KEYS: frozenset[str] = frozenset(
    {"enabled", "min-coverage", "ignore-init", "ignore-magic", "ignore-private"}
)

_TOOL_SECTION = "[tool.docvet]"


# ---------------------------------------------------------------------------
# Private helpers
# ---------------------------------------------------------------------------


def _kebab_to_snake(key: str) -> str:
    """Convert a TOML kebab-case key to Python snake_case.

    Args:
        key: The kebab-case key string.

    Returns:
        The snake_case equivalent.
    """
    return key.replace("-", "_")


def _snake_to_kebab(key: str) -> str:
    """Convert a Python snake_case key to TOML kebab-case.

    Args:
        key: The snake_case key string.

    Returns:
        The kebab-case equivalent.
    """
    return key.replace("_", "-")


def _validate_keys(
    data: dict[str, object],
    valid_keys: frozenset[str],
    section: str,
) -> None:
    """Validate that all keys in *data* are recognised.

    Args:
        data: Parsed TOML section dictionary.
        valid_keys: Set of allowed key names (kebab-case).
        section: Human-readable section label for error messages.
    """
    unknown = sorted(k for k in data if k not in valid_keys)
    if unknown:
        keys_csv = ", ".join(unknown)
        valid_csv = ", ".join(sorted(valid_keys))
        msg = (
            f"docvet: unknown config keys in {section}: "
            f"{keys_csv}. Valid keys: {valid_csv}"
        )
        print(msg, file=sys.stderr)
        sys.exit(1)


def _validate_check_names(names: list[str], field_name: str) -> None:
    """Validate that every entry is a known check name.

    Args:
        names: List of check name strings to validate.
        field_name: Config field name for error messages.
    """
    invalid = sorted(n for n in names if n not in _VALID_CHECK_NAMES)
    if invalid:
        names_csv = ", ".join(invalid)
        valid_csv = ", ".join(sorted(_VALID_CHECK_NAMES))
        msg = (
            f"docvet: unknown checks in {field_name}: "
            f"{names_csv}. Valid checks: {valid_csv}"
        )
        print(msg, file=sys.stderr)
        sys.exit(1)


def _validate_type(
    value: object,
    expected: type,
    key: str,
    section: str,
) -> None:
    """Type-check a single config value.

    Rejects ``bool`` when *expected* is ``int`` (since ``bool`` is a
    subclass of ``int`` in Python). Used directly for scalar fields
    and composed by :func:`_validate_string_list` for list entries.

    Args:
        value: The value to check.
        expected: The expected Python type.
        key: Config key name for error messages.
        section: Section label for error messages.
    """
    # bool is a subclass of int — reject bools when expecting int.
    if expected is int and isinstance(value, bool):
        msg = f"docvet: '{key}' in {section} must be int, got bool"
        print(msg, file=sys.stderr)
        sys.exit(1)
    if not isinstance(value, expected):
        actual = type(value).__name__
        msg = f"docvet: '{key}' in {section} must be {expected.__name__}, got {actual}"
        print(msg, file=sys.stderr)
        sys.exit(1)


def _validate_string_list(
    data: dict[str, object],
    key: str,
    section: str,
    *,
    check_names: bool = False,
) -> None:
    """Validate that ``data[key]`` is a list of strings.

    Checks that the value is a ``list``, that every entry is a ``str``,
    and optionally that each string is a recognised check name.

    Args:
        data: Parsed config dictionary containing *key*.
        key: The snake_case key to validate inside *data*.
        section: Human-readable TOML section label (kebab-case) used in
            error messages passed to :func:`_validate_type`.
        check_names: When ``True``, additionally validate entries
            against :data:`_VALID_CHECK_NAMES` via
            :func:`_validate_check_names`.
    """
    _validate_type(data[key], list, section, _TOOL_SECTION)
    for entry in data[key]:  # type: ignore[union-attr]
        _validate_type(entry, str, section, _TOOL_SECTION)
    if check_names:
        _validate_check_names(data[key], section)  # type: ignore[arg-type]


# ---------------------------------------------------------------------------
# Discovery
# ---------------------------------------------------------------------------


def _find_pyproject(start: Path | None = None) -> Path | None:
    """Walk up from *start* to find the nearest ``pyproject.toml``.

    Args:
        start: Directory to begin searching from. Defaults to CWD.

    Returns:
        Path to the discovered file, or *None* if not found.
    """
    path = (start or Path.cwd()).resolve()
    try:
        while True:
            candidate = path / "pyproject.toml"
            if candidate.is_file():
                return candidate
            if (path / ".git").exists():
                return None
            parent = path.parent
            if parent == path:
                return None
            path = parent
    except OSError:
        return None


def _resolve_src_root(
    project_root: Path,
    configured: str | None,
) -> str:
    """Resolve the source root, applying the ``src/`` heuristic.

    Args:
        project_root: Directory containing the discovered pyproject.toml.
        configured: Explicitly configured ``src-root``, or *None*.

    Returns:
        The resolved source root string.
    """
    if configured is not None:
        return configured
    if (project_root / "src").is_dir():
        return "src"
    return "."


# ---------------------------------------------------------------------------
# Section parsers
# ---------------------------------------------------------------------------


def _parse_freshness(data: dict[str, object]) -> FreshnessConfig:
    """Parse and validate ``[tool.docvet.freshness]``.

    Args:
        data: Raw TOML dict for the freshness section.

    Returns:
        A validated :class:`FreshnessConfig`.
    """
    _validate_keys(data, _VALID_FRESHNESS_KEYS, "[tool.docvet.freshness]")
    section = "[tool.docvet.freshness]"
    kwargs: dict[str, object] = {}
    for key, value in data.items():
        _validate_type(value, int, key, section)
        kwargs[_kebab_to_snake(key)] = value
    return FreshnessConfig(**kwargs)  # type: ignore[arg-type]


def _parse_enrichment(data: dict[str, object]) -> EnrichmentConfig:
    """Parse and validate ``[tool.docvet.enrichment]``.

    Args:
        data: Raw TOML dict for the enrichment section.

    Returns:
        A validated :class:`EnrichmentConfig`.
    """
    _validate_keys(data, _VALID_ENRICHMENT_KEYS, "[tool.docvet.enrichment]")
    section = "[tool.docvet.enrichment]"
    kwargs: dict[str, object] = {}
    for key, value in data.items():
        if key == "require-examples":
            _validate_type(value, list, key, section)
            for entry in value:  # type: ignore[union-attr]
                _validate_type(entry, str, key, section)
        else:
            _validate_type(value, bool, key, section)
        kwargs[_kebab_to_snake(key)] = value
    return EnrichmentConfig(**kwargs)  # type: ignore[arg-type]


def _parse_presence(data: dict[str, object]) -> PresenceConfig:
    """Parse and validate ``[tool.docvet.presence]``.

    Args:
        data: Raw TOML dict for the presence section.

    Returns:
        A validated :class:`PresenceConfig`.
    """
    _validate_keys(data, _VALID_PRESENCE_KEYS, "[tool.docvet.presence]")
    section = "[tool.docvet.presence]"
    kwargs: dict[str, object] = {}
    for key, value in data.items():
        if key == "min-coverage":
            if isinstance(value, bool):
                msg = f"docvet: 'min-coverage' in {section} must be float, got bool"
                print(msg, file=sys.stderr)
                sys.exit(1)
            if isinstance(value, int | float):
                kwargs[_kebab_to_snake(key)] = float(value)
            else:
                actual = type(value).__name__
                msg = f"docvet: 'min-coverage' in {section} must be float, got {actual}"
                print(msg, file=sys.stderr)
                sys.exit(1)
        else:
            _validate_type(value, bool, key, section)
            kwargs[_kebab_to_snake(key)] = value
    return PresenceConfig(**kwargs)  # type: ignore[arg-type]


def _parse_docvet_section(
    data: dict[str, object],
) -> dict[str, object]:
    """Validate and parse a non-empty ``[tool.docvet]`` dict.

    Converts kebab-case keys to snake_case, validates types for all
    top-level keys, and delegates nested sections (``freshness``,
    ``enrichment``, ``presence``) to their respective parsers.
    List-of-string fields (``exclude``, ``extend-exclude``,
    ``fail-on``, ``warn-on``) are validated via
    :func:`_validate_string_list`.

    Args:
        data: Mutable copy of the raw TOML ``[tool.docvet]`` section.

    Returns:
        Processed dict ready for :class:`DocvetConfig` construction.
    """
    _validate_keys(data, _VALID_TOP_KEYS, _TOOL_SECTION)

    converted: dict[str, object] = {_kebab_to_snake(k): v for k, v in data.items()}

    freshness_data = converted.pop("freshness", None)
    enrichment_data = converted.pop("enrichment", None)
    presence_data = converted.pop("presence", None)

    if freshness_data is not None:
        _validate_type(freshness_data, dict, "freshness", _TOOL_SECTION)
        converted["freshness"] = _parse_freshness(freshness_data)  # type: ignore[arg-type]

    if enrichment_data is not None:
        _validate_type(enrichment_data, dict, "enrichment", _TOOL_SECTION)
        converted["enrichment"] = _parse_enrichment(enrichment_data)  # type: ignore[arg-type]

    if presence_data is not None:
        _validate_type(presence_data, dict, "presence", _TOOL_SECTION)
        converted["presence"] = _parse_presence(presence_data)  # type: ignore[arg-type]

    if "src_root" in converted:
        _validate_type(converted["src_root"], str, "src-root", _TOOL_SECTION)
    if "package_name" in converted:
        _validate_type(converted["package_name"], str, "package-name", _TOOL_SECTION)
    if "exclude" in converted:
        _validate_string_list(converted, "exclude", "exclude")
    if "extend_exclude" in converted:
        _validate_string_list(converted, "extend_exclude", "extend-exclude")
    if "fail_on" in converted:
        _validate_string_list(converted, "fail_on", "fail-on", check_names=True)
    if "warn_on" in converted:
        _validate_string_list(converted, "warn_on", "warn-on", check_names=True)

    return converted


# ---------------------------------------------------------------------------
# Load helpers
# ---------------------------------------------------------------------------


def _read_docvet_toml(pyproject_path: Path) -> dict[str, object]:
    """Read ``pyproject.toml`` and return the raw ``[tool.docvet]`` dict.

    Args:
        pyproject_path: Path to the ``pyproject.toml`` file.

    Returns:
        The raw TOML dict for ``[tool.docvet]``, or an empty dict when
        the section is absent.
    """
    with open(pyproject_path, "rb") as f:
        toml_data = tomllib.load(f)
    tool_section = toml_data.get("tool")
    if tool_section is None:
        return {}
    if not isinstance(tool_section, dict):
        print(
            "docvet: [tool] in pyproject.toml must be a table",
            file=sys.stderr,
        )
        sys.exit(1)
    docvet_section = tool_section.get("docvet")
    if docvet_section is None:
        return {}
    if not isinstance(docvet_section, dict):
        print(
            "docvet: [tool.docvet] in pyproject.toml must be a table",
            file=sys.stderr,
        )
        sys.exit(1)
    return docvet_section


def _find_pyproject_path(path: Path | None) -> Path | None:
    """Resolve an explicit or discovered ``pyproject.toml`` path.

    When *path* is given, validates it exists; otherwise falls back
    to :func:`_find_pyproject` for upward directory traversal.

    Args:
        path: Explicit path provided by the caller, or *None* for
            automatic discovery.

    Returns:
        Resolved path to ``pyproject.toml``, or *None* when discovery
        finds nothing.

    Raises:
        FileNotFoundError: If an explicit *path* does not exist.
    """
    if path is not None:
        if not path.is_file():
            raise FileNotFoundError(path)
        return path
    return _find_pyproject()


def _resolve_fail_warn(
    parsed: dict[str, object],
    defaults: DocvetConfig,
) -> tuple[list[str], list[str]]:
    """Resolve ``fail-on`` and ``warn-on`` lists from parsed config.

    Extracts both lists from *parsed* with fallback to *defaults*.
    Detects overlap and emits a stderr warning per overlapping check
    (only when ``warn-on`` was explicitly set). Filters ``warn-on``
    to exclude items already present in ``fail-on``.

    Args:
        parsed: Validated config dict (snake_case keys).
        defaults: Default :class:`DocvetConfig` for fallback values.

    Returns:
        A ``(fail_on, warn_on)`` tuple ready for
        :class:`DocvetConfig` construction.
    """
    raw_fail = parsed.get("fail_on")
    fail_on: list[str] = (
        [str(x) for x in raw_fail]
        if isinstance(raw_fail, list)
        else list(defaults.fail_on)
    )
    raw_warn = parsed.get("warn_on")
    warn_on: list[str] = (
        [str(x) for x in raw_warn]
        if isinstance(raw_warn, list)
        else list(defaults.warn_on)
    )
    fail_on_set = set(fail_on)
    if "warn_on" in parsed:
        for check in warn_on:
            if check in fail_on_set:
                print(
                    f"docvet: '{check}' appears in both fail-on and warn-on; using fail-on",
                    file=sys.stderr,
                )
    return fail_on, [c for c in warn_on if c not in fail_on_set]


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------


def get_user_keys(
    path: Path | None = None,
) -> tuple[dict[str, object], Path | None]:
    """Return the raw ``[tool.docvet]`` dict and the pyproject.toml path.

    Exposes the user's configuration as-is (kebab-case keys, no
    validation or parsing) so callers can distinguish user-set values
    from defaults.

    Args:
        path: Explicit path to a ``pyproject.toml``. When *None*,
            discovery walks up from CWD.

    Returns:
        A tuple of ``(raw_dict, pyproject_path)``. *raw_dict* is the
        ``[tool.docvet]`` table (empty dict if absent). *pyproject_path*
        is the resolved path, or *None* when no file was found.
    """
    pyproject_path = _find_pyproject_path(path)
    if pyproject_path is None:
        return {}, None
    return _read_docvet_toml(pyproject_path), pyproject_path


# ---------------------------------------------------------------------------
# Formatting helpers
# ---------------------------------------------------------------------------


def _fmt_toml_value(value: object) -> str:
    """Format a Python value as a TOML literal.

    Handles bool, str, int/float, and list types. List elements are
    formatted recursively to ensure consistent escaping.

    Args:
        value: The Python value to format.

    Returns:
        A TOML-compatible string representation.
    """
    if isinstance(value, bool):
        return "true" if value else "false"
    if isinstance(value, str):
        return f'"{value}"'
    if isinstance(value, int | float):
        return str(value)
    if isinstance(value, list):
        items = ", ".join(_fmt_toml_value(v) for v in value)
        return f"[{items}]"
    return str(value)


def _get_annotation(
    kebab_key: str,
    user_keys: dict[str, object],
    section_keys: dict[str, object] | None = None,
) -> str:
    """Return a source annotation comment for a config key.

    Args:
        kebab_key: The kebab-case key to annotate.
        user_keys: Top-level raw user config dict.
        section_keys: Nested section user keys, or *None* for
            top-level lookup.

    Returns:
        An inline TOML comment: ``"# (user)"`` or ``"# (default)"``.
    """
    if section_keys is not None:
        return "# (user)" if kebab_key in section_keys else "# (default)"
    return "# (user)" if kebab_key in user_keys else "# (default)"


def _get_section_user_keys(
    user_keys: dict[str, object],
    key: str,
) -> dict[str, object] | None:
    """Extract user-configured keys for a nested config section.

    Args:
        user_keys: Top-level raw user config dict.
        key: Section name (e.g., ``"freshness"``).

    Returns:
        A dict of user-set keys within the section, or *None*.
    """
    raw = user_keys.get(key)
    if isinstance(raw, dict):
        return {str(k): v for k, v in raw.items()}
    return None


def _format_toml_section(
    header: str,
    fields: list[tuple[str, str]],
    config_obj: object,
    user_keys: dict[str, object],
    section_keys: dict[str, object] | None,
) -> list[str]:
    """Render a TOML section with annotated key-value pairs.

    Args:
        header: TOML section header (e.g.,
            ``"[tool.docvet.freshness]"``).
        fields: List of ``(attr_name, kebab_key)`` pairs to render.
        config_obj: Config dataclass instance to read values from.
        user_keys: Top-level raw user config dict.
        section_keys: Nested section user keys for annotation.

    Returns:
        Lines for this section, starting with a blank separator.
    """
    lines = ["", header]
    for attr, kebab in fields:
        value = getattr(config_obj, attr)
        annotation = _get_annotation(kebab, user_keys, section_keys)
        lines.append(f"{kebab} = {_fmt_toml_value(value)}  {annotation}")
    return lines


def _convert_keys_to_kebab(d: dict[str, object]) -> dict[str, object]:
    """Recursively convert snake_case dict keys to kebab-case.

    Args:
        d: Dictionary with snake_case keys.

    Returns:
        New dictionary with all keys converted to kebab-case.
    """
    result: dict[str, object] = {}
    for k, v in d.items():
        kebab = _snake_to_kebab(k)
        if isinstance(v, dict):
            result[kebab] = _convert_keys_to_kebab(v)  # type: ignore[arg-type]
        else:
            result[kebab] = v
    return result


# ---------------------------------------------------------------------------
# Public formatters
# ---------------------------------------------------------------------------


def format_config_toml(
    config: DocvetConfig,
    user_keys: dict[str, object],
) -> str:
    """Format effective config as copy-paste-ready TOML.

    Renders the top-level ``[tool.docvet]`` keys inline, then delegates
    each nested section (freshness, enrichment, presence) to
    :func:`_format_toml_section`. Values are formatted via
    :func:`_fmt_toml_value` and annotated via :func:`_get_annotation`.
    Omits ``package-name`` when its value is ``None`` and ``project_root``
    (runtime-only). When ``extend-exclude`` appears in *user_keys*, the
    merged ``exclude`` list is annotated accordingly.

    Args:
        config: The effective :class:`DocvetConfig`.
        user_keys: Raw ``[tool.docvet]`` dict with kebab-case keys.

    Returns:
        A TOML-formatted string suitable for ``pyproject.toml``.
    """
    lines: list[str] = ["[tool.docvet]"]
    has_extend = "extend-exclude" in user_keys
    top_fields = [
        ("src_root", "src-root"),
        ("package_name", "package-name"),
        ("exclude", "exclude"),
        ("fail_on", "fail-on"),
        ("warn_on", "warn-on"),
    ]
    for attr, kebab in top_fields:
        value = getattr(config, attr)
        if attr == "package_name" and value is None:
            continue
        annotation = _get_annotation(kebab, user_keys)
        if attr == "exclude" and has_extend:
            annotation = "# (merged from exclude + extend-exclude)"
        lines.append(f"{kebab} = {_fmt_toml_value(value)}  {annotation}")

    lines.extend(
        _format_toml_section(
            "[tool.docvet.freshness]",
            [
                ("drift_threshold", "drift-threshold"),
                ("age_threshold", "age-threshold"),
            ],
            config.freshness,
            user_keys,
            _get_section_user_keys(user_keys, "freshness"),
        )
    )
    lines.extend(
        _format_toml_section(
            "[tool.docvet.enrichment]",
            [
                ("require_raises", "require-raises"),
                ("require_yields", "require-yields"),
                ("require_warns", "require-warns"),
                ("require_receives", "require-receives"),
                ("require_other_parameters", "require-other-parameters"),
                ("require_typed_attributes", "require-typed-attributes"),
                ("require_cross_references", "require-cross-references"),
                ("prefer_fenced_code_blocks", "prefer-fenced-code-blocks"),
                ("require_attributes", "require-attributes"),
                ("require_examples", "require-examples"),
            ],
            config.enrichment,
            user_keys,
            _get_section_user_keys(user_keys, "enrichment"),
        )
    )
    lines.extend(
        _format_toml_section(
            "[tool.docvet.presence]",
            [
                ("enabled", "enabled"),
                ("min_coverage", "min-coverage"),
                ("ignore_init", "ignore-init"),
                ("ignore_magic", "ignore-magic"),
                ("ignore_private", "ignore-private"),
            ],
            config.presence,
            user_keys,
            _get_section_user_keys(user_keys, "presence"),
        )
    )

    lines.append("")
    return "\n".join(lines)


def format_config_json(
    config: DocvetConfig,
    user_keys: dict[str, object],
) -> str:
    """Format effective config as a JSON string.

    Produces a JSON object with ``"config"`` (all effective values,
    kebab-case keys, ``project_root`` excluded) and
    ``"user_configured"`` (list of dot-separated kebab-case paths for
    user-set keys). Key conversion is handled by
    :func:`_convert_keys_to_kebab`. Omits ``package-name`` when its
    value is ``None``.

    Args:
        config: The effective :class:`DocvetConfig`.
        user_keys: Raw ``[tool.docvet]`` dict with kebab-case keys.

    Returns:
        A pretty-printed JSON string.
    """
    raw = dataclasses.asdict(config)
    raw.pop("project_root", None)
    converted = _convert_keys_to_kebab(raw)

    if converted.get("package-name") is None:
        converted.pop("package-name", None)

    user_configured: list[str] = []
    for key, value in user_keys.items():
        if key == "extend-exclude":
            continue
        if isinstance(value, dict):
            for sub_key in value:
                user_configured.append(f"{key}.{sub_key}")
        else:
            user_configured.append(key)

    output = {
        "config": converted,
        "user_configured": sorted(user_configured),
    }
    return json.dumps(output, indent=2)


def load_config(path: Path | None = None) -> DocvetConfig:
    """Load docvet configuration from ``pyproject.toml``.

    Merges ``extend-exclude`` patterns on top of the resolved base
    exclude list (explicit ``exclude`` or defaults) before constructing
    the final :class:`DocvetConfig`. Delegates ``fail-on``/``warn-on``
    resolution (including overlap detection and filtering) to
    :func:`_resolve_fail_warn`. Nested ``presence`` section is parsed
    via :func:`_parse_presence`.

    Args:
        path: Explicit path to a ``pyproject.toml``. When *None*,
            discovery walks up from CWD.

    Returns:
        A fully resolved :class:`DocvetConfig`.

    Raises:
        FileNotFoundError: If an explicit *path* does not exist.
    """
    pyproject_path = _find_pyproject_path(path)
    if pyproject_path is not None:
        project_root = pyproject_path.parent.resolve()
        data = _read_docvet_toml(pyproject_path)
        parsed: dict[str, object] = _parse_docvet_section(data) if data else {}
    else:
        project_root = Path.cwd().resolve()
        parsed = {}

    # Use dataclass defaults as single source of truth for omitted keys.
    defaults = DocvetConfig()

    configured_src = parsed.get("src_root")
    resolved_src_root = _resolve_src_root(
        project_root,
        configured_src if isinstance(configured_src, str) else None,
    )

    fail_on, warn_on = _resolve_fail_warn(parsed, defaults)

    raw_pkg = parsed.get("package_name")
    raw_exclude = parsed.get("exclude")
    raw_extend_exclude = parsed.get("extend_exclude")
    raw_freshness = parsed.get("freshness")
    raw_enrichment = parsed.get("enrichment")
    raw_presence = parsed.get("presence")

    base_exclude: list[str] = (
        [str(x) for x in raw_exclude]
        if isinstance(raw_exclude, list)
        else list(defaults.exclude)
    )
    if isinstance(raw_extend_exclude, list):
        base_exclude = base_exclude + [str(x) for x in raw_extend_exclude]

    return DocvetConfig(
        src_root=resolved_src_root,
        package_name=raw_pkg if isinstance(raw_pkg, str) else None,
        exclude=base_exclude,
        fail_on=fail_on,
        warn_on=warn_on,
        freshness=(
            raw_freshness
            if isinstance(raw_freshness, FreshnessConfig)
            else FreshnessConfig()
        ),
        enrichment=(
            raw_enrichment
            if isinstance(raw_enrichment, EnrichmentConfig)
            else EnrichmentConfig()
        ),
        presence=(
            raw_presence
            if isinstance(raw_presence, PresenceConfig)
            else PresenceConfig()
        ),
        project_root=project_root,
    )
