"""Custom pydantic-settings source for ``[tool.mgf]`` in pyproject.toml.

v0.4 Phase C: gives consumers a third config source between explicit
constructor kwargs and env vars — the project root's pyproject.toml.
Matches the convention used by every mainstream Python tool (ruff,
mypy, pytest, hatch, poetry); consumers can keep all their
``mgf-common`` configuration in one place alongside their build /
lint / test config.

Source precedence in :class:`mgf.common.settings.MgfSettings` (highest
wins, applied via ``settings_customise_sources``):

  1. Explicit constructor kwargs (``MgfSettings(logging=...)``)
  2. ``MGF_*`` environment variables
  3. ``[tool.mgf]`` from pyproject.toml (THIS SOURCE)
  4. ``.env`` file (if configured — not used by default)
  5. File secrets directory (if configured — not used by default)
  6. Field defaults

The preset semantics (``MgfSettings.preset``) layer ON TOP of all
sources via the ``model_validator(mode="after")`` — preset values
are only applied to fields that no source supplied (per the §6
design from the v0.3 closed-box redesign).

Lookup behaviour
----------------

The source walks from ``Path.cwd()`` upward looking for
``pyproject.toml``. The first one found wins; further parents are
NOT consulted. If no pyproject.toml is found, or it has no
``[tool.mgf]`` section, the source contributes an empty dict —
identical behaviour to "this source isn't installed".

Test isolation
--------------

Tests typically don't want this source firing — production
pyproject.toml in the repo root would leak into every test
construction of ``MgfSettings``. The mitigation:

  - For unit tests that *don't* care about pyproject lookup: the
    project's own pyproject.toml has no ``[tool.mgf]`` section, so
    the source returns ``{}`` and nothing leaks. (Pinned by
    ``test_phase_c::TestPyprojectSource::test_returns_empty_when_no_tool_mgf_section``.)

  - For tests that exercise pyproject behaviour explicitly: use
    ``monkeypatch.chdir(tmp_path)`` and write a fake
    ``pyproject.toml`` in ``tmp_path``. The source picks it up
    (no other pyproject above ``tmp_path``).

  - For tests that need to *disable* pyproject lookup unconditionally:
    pass ``MGF_DISABLE_PYPROJECT=1`` in the env. The source
    returns ``{}`` immediately without doing any I/O.
"""

from __future__ import annotations

import os
import tomllib
from pathlib import Path
from typing import TYPE_CHECKING, Any

from pydantic_settings.sources import PydanticBaseSettingsSource

if TYPE_CHECKING:
    from pydantic.fields import FieldInfo


class PyprojectSource(PydanticBaseSettingsSource):
    """Reads ``[tool.mgf]`` from the nearest ``pyproject.toml``.

    Walks upward from ``Path.cwd()`` (or the explicit
    ``pyproject_path`` constructor arg, used by tests) until it finds
    a ``pyproject.toml``. Reads ``[tool.mgf]``, returns the dict
    untouched. Nested sections (``[tool.mgf.logging]`` etc.) round-trip
    as nested Python dicts, matching what
    :class:`pydantic_settings.sources.EnvSettingsSource` returns for
    ``MGF_LOGGING__LEVEL=...`` env vars — pydantic-settings merges
    the two source shapes uniformly.

    The class is private; consumers don't construct it directly. It's
    wired into :class:`mgf.common.settings.MgfSettings` via
    ``settings_customise_sources``.
    """

    def __init__(
        self,
        settings_cls: type,
        pyproject_path: Path | None = None,
    ) -> None:
        super().__init__(settings_cls)
        self._data: dict[str, Any] = self._load(pyproject_path)

    def _load(self, explicit_path: Path | None) -> dict[str, Any]:
        """Return the ``[tool.mgf]`` table, or ``{}`` on any miss."""
        # Bypass entirely if the env-var kill switch is set. Useful
        # for test runners that want hermetic settings construction.
        if os.environ.get("MGF_DISABLE_PYPROJECT") == "1":
            return {}

        path = explicit_path if explicit_path is not None else _find_pyproject()
        if path is None:
            return {}

        try:
            with path.open("rb") as f:
                data = tomllib.load(f)
        except (OSError, tomllib.TOMLDecodeError):
            # Best-effort: a malformed or unreadable pyproject MUST
            # NOT crash bootstrap. The diagnostic CLI's `validate`
            # subcommand is the right place to surface TOML errors.
            return {}

        section = data.get("tool", {}).get("mgf", {})
        if not isinstance(section, dict):
            return {}
        return section

    def get_field_value(
        self,
        field: FieldInfo,
        field_name: str,
    ) -> tuple[Any, str, bool]:
        """Required by :class:`PydanticBaseSettingsSource`.

        Returns ``(value, key, value_is_complex)``. We mark every
        value as "complex" so pydantic-settings descends into nested
        dicts (matching env-source behaviour for nested sub-models).
        """
        if field_name not in self._data:
            return None, field_name, False
        return self._data[field_name], field_name, True

    def __call__(self) -> dict[str, Any]:
        """Return the full ``[tool.mgf]`` dict.

        pydantic-settings calls this once per construction; the result
        is merged with the other sources' contributions per the
        precedence list in this module's docstring.
        """
        return dict(self._data)


def _find_pyproject(start: Path | None = None) -> Path | None:
    """Walk from ``start`` (default ``Path.cwd()``) upward, returning
    the first ``pyproject.toml`` found, or ``None`` if none exists
    in the directory chain.

    Stops at the filesystem root. Does NOT follow symlinks beyond
    what ``Path.parents`` returns (which dereferences each ancestor
    only as needed).
    """
    cwd = start or Path.cwd()
    for d in (cwd, *cwd.parents):
        candidate = d / "pyproject.toml"
        if candidate.exists():
            return candidate
    return None


__all__ = ["PyprojectSource"]
