"""``mgf-fed app-doctor`` — install-state and wiring health checks.

Usage::

    mgf-fed app-doctor

Prints a checklist of the install-state and runtime-wiring
checks that ``mgf-common`` cares about: package install path,
``py.typed`` marker presence, optional-extra availability, state
+ log directory writability, env-var detection that would change
auto-preset behaviour, registered-extension counts, and a
recommended-actions footer.

Exit code:
  - ``0`` — every check passed (✓ or ⚠).
  - ``EXIT_OPERATION_ERROR (2)`` — at least one check FAILED (✗).

The ⚠ tier is for findings that are NOT errors but that the
operator should know about (e.g. ``[qt]`` extra not installed
when the consumer might need it).
"""

from __future__ import annotations

import importlib
import importlib.metadata
import importlib.util
import os
import sys
import warnings
from pathlib import Path
from typing import TYPE_CHECKING, Any

from mgf.common.cli import EXIT_OPERATION_ERROR, EXIT_SUCCESS

from mgf.fed import _pipeline

if TYPE_CHECKING:
    import io
    from collections.abc import Iterable

# Marker characters used in the per-check output.
_OK = "✓"
_WARN = "⚠"
_FAIL = "✗"
_INFO = "ℹ"  # Informational hint — neither pass nor fail, just "here's what you can do."


def doctor(out: io.TextIOBase | None = None) -> int:
    """Run the full check list; return ``0`` if no failure, else
    :data:`EXIT_OPERATION_ERROR`."""
    if out is None:
        out = sys.stdout  # type: ignore[assignment]
    assert out is not None

    has_failure = False
    recommendations: list[str] = []

    # The doctor REPORTS environment state; it does not enforce the host's warning
    # policy. Under `filterwarnings = ["error"]` (the fleet's own default, TS-05) a
    # warning emitted while a check probes an optional surface is raised as an
    # exception and silently costs us the REST of the checklist — the operator sees a
    # truncated report and no error. It presented as an order-dependent test: once any
    # earlier caller had imported the probed module, sys.modules served it
    # warning-free and the missing checks reappeared, so the doctor's own output
    # depended on what had been imported before it (2026-07-17). Scoped to this loop:
    # it never changes the caller's policy for the caller's own code.
    with warnings.catch_warnings():
        warnings.simplefilter("ignore")
        checks = list(_all_checks())

    for marker, msg, action in checks:
        print(f"{marker} {msg}", file=out)
        if marker == _FAIL:
            has_failure = True
        if action:
            recommendations.append(action)

    print(file=out)
    print("Recommended actions:", file=out)
    if recommendations:
        for action in recommendations:
            print(f"  - {action}", file=out)
    else:
        print("  - None — all checks pass.", file=out)

    return EXIT_OPERATION_ERROR if has_failure else EXIT_SUCCESS


def _all_checks() -> Iterable[tuple[str, str, str | None]]:
    """Yield ``(marker, message, optional_action)`` per check.

    The action — when present — is appended to the
    "Recommended actions" footer.
    """
    yield from _check_install()
    yield from _check_py_typed()
    yield from _check_extras()
    yield from _check_state_dirs()
    yield from _check_auto_detection_signals()
    yield from _check_registries()
    yield from _check_deprecation_state()
    yield from _check_test_supervisor()
    yield from _check_supervisor_coverage_patch()


def _check_install() -> Iterable[tuple[str, str, str | None]]:
    # Read __version__ from the imported package (always live with
    # the source tree, per AP-13 sync) rather than
    # importlib.metadata.version, which caches the install-time
    # value and goes stale during editable-install dev workflows
    # (`pip install -e ../mgf_common` after a series of version
    # bumps without re-installing). Closes PAPER-18.
    try:
        from mgf.common import __version__ as version
    except ImportError:
        yield (
            _FAIL,
            "mgf-common package import FAILED — install via `pip install mgf-common`",
            "install mgf-common with `pip install mgf-common[observability,vault]`",
        )
        return

    import mgf

    install_root = Path(mgf.__file__).parent.parent if mgf.__file__ else Path("(namespace)")
    yield (
        _OK,
        f"mgf-common v{version} installed at {install_root}",
        None,
    )


def _check_py_typed() -> Iterable[tuple[str, str, str | None]]:
    import mgf.common

    common_root = Path(mgf.common.__file__).parent
    marker = common_root / "py.typed"
    if marker.exists():
        yield (
            _OK,
            "py.typed marker present (downstream mypy --strict OK)",
            None,
        )
    else:
        yield (
            _FAIL,
            f"py.typed marker MISSING at {marker} — downstream "
            f"mypy --strict will reject `import mgf.common.*`",
            f"file a bug — the wheel should ship {marker}",
        )


def _check_extras() -> Iterable[tuple[str, str, str | None]]:
    """Each optional extra: present → ✓; missing → ⚠ or ℹ (per the
    per-extra severity policy, PAPER-58 v0.41.6+).

    ``observability`` (OTel + Sentry) is the federation default —
    consumers who don't install it are missing the recommended
    instrumentation surface; a missing observability extra stays a
    ⚠ so the operator notices.

    ``vault`` is genuinely optional — many consumers (vm-vmanager-app,
    figrecovery, …) ship their own credential layer and never touch
    ``mgf.common.vault.*``. A missing ``vault`` extra is purely
    informational (ℹ); the recommended action still tells the
    operator how to install it should they want the surface.
    """
    extras: dict[str, list[str]] = {
        "observability": [
            "opentelemetry",
            "sentry_sdk",
        ],
        "vault": [
            "keyring",
            "cryptography",
        ],
    }
    # PAPER-58: per-extra severity for the missing case. Default is
    # ⚠ ("operator should notice"); a per-extra entry can downgrade
    # to ℹ ("informational, BYO is a normal choice").
    missing_severity: dict[str, str] = {
        "vault": _INFO,
    }
    for extra_name, modules in extras.items():
        missing = [m for m in modules if not _module_installed(m)]
        if not missing:
            yield (
                _OK,
                f"[{extra_name}] extra installed ({', '.join(modules)} available)",
                None,
            )
        else:
            severity = missing_severity.get(extra_name, _WARN)
            yield (
                severity,
                f"[{extra_name}] extra not installed "
                f"(missing: {', '.join(missing)}) — "
                f"corresponding mgf.common surface unavailable",
                (
                    f"install with `pip install mgf-common[{extra_name}]` "
                    f"if you need the {extra_name} surface"
                ),
            )


def _module_installed(name: str) -> bool:
    """Lightweight installed-check via importlib.util.find_spec.

    Doesn't import the module, so it doesn't trigger any
    side-effects from the optional dep.
    """
    return importlib.util.find_spec(name) is not None


def _check_state_dirs() -> Iterable[tuple[str, str, str | None]]:
    """Probe whether the default crash + log dirs are writable.

    Uses the active app identity (or the placeholder ``"app"`` if
    none configured) so the path matches what the library would
    actually write to. Reads the private identity state directly
    to avoid emitting :class:`mgf.common.UnconfiguredWarning` —
    the doctor reports environment state; it does not enforce
    bootstrap() discipline.
    """
    from mgf.common import app_name, current_context

    # Only read app_name() when bootstrapped — otherwise it emits an
    # UnconfiguredWarning, which the doctor deliberately avoids (it reports
    # environment state, it doesn't enforce bootstrap() discipline).
    app = app_name() if current_context() is not None else "app"
    xdg = os.environ.get("XDG_STATE_HOME")
    base = Path(xdg) if xdg else Path.home() / ".local" / "state"

    crash_dir = base / app / "crashes"
    log_dir = base / app / "logs"

    for label, path in (("crash", crash_dir), ("log", log_dir)):
        marker, msg = _probe_dir_writable(path)
        action = (
            f"create + chmod the parent {path.parent} so {label} files can be written"
            if marker == _FAIL
            else None
        )
        yield marker, f"Default {label} dir {msg}: {path}", action


def _probe_dir_writable(path: Path) -> tuple[str, str]:
    """Return ``(marker, status-text)`` for ``path``.

    Tries to ensure the directory exists; reports writability of
    the parent if the dir doesn't exist + can't be created.
    """
    try:
        path.mkdir(parents=True, exist_ok=True)
    except OSError as e:
        return _FAIL, f"NOT writable ({e.__class__.__name__})"

    if not os.access(path, os.W_OK):
        return _FAIL, "exists but NOT writable"

    return _OK, "writable"


def _check_auto_detection_signals() -> Iterable[tuple[str, str, str | None]]:
    """Surface env vars that would change ``preset='auto'`` behaviour."""
    journal = os.environ.get("JOURNAL_STREAM")
    otel = os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT")

    if journal:
        yield (
            _WARN,
            f"JOURNAL_STREAM env var set ({journal!r}) — "
            f"journald sink would auto-attach with preset='auto' or 'systemd'",
            None,
        )
    if otel:
        yield (
            _WARN,
            f"OTEL_EXPORTER_OTLP_ENDPOINT env var set ({otel!r}) — "
            f"OTel would auto-enable with preset='auto'",
            None,
        )


def _check_registries() -> Iterable[tuple[str, str, str | None]]:
    """Report the count + names of every registered extension.

    Phase 3 + Phase 5 ship registries for crash sinks / log
    handlers / redaction pattern groups / span processors / vault
    backends. The doctor surfaces what's currently registered so
    the operator can confirm a custom decorator-registered entry
    actually fired.
    """
    yield from _check_one_registry(
        label="crash sinks",
        importer=lambda: _import_attr(
            "mgf.common.observability.crash_reporter",
            "_registered_crash_sinks",
        ),
    )
    yield from _check_one_registry(
        label="log handlers",
        importer=lambda: _import_attr(
            "mgf.common.observability.logging_setup",
            "_registered_log_handlers",
        ),
    )
    yield from _check_one_registry(
        label="redaction pattern groups",
        importer=lambda: _import_attr(
            "mgf.common.observability.redaction",
            "_registered_pattern_groups",
        ),
    )
    yield from _check_one_registry(
        label="span processors",
        importer=lambda: _import_attr(
            "mgf.common.observability.otel_setup",
            "_registered_span_processors",
        ),
    )
    yield from _check_one_registry(
        label="vault backends",
        importer=lambda: _import_attr(
            "mgf.common.vault._registry",
            "_registered_vault_backends",
        ),
    )
    # v0.5 Phase F: surface CLI subcommands. Closes the audit
    # finding that consumers reach for Click/Typer because the
    # registry wasn't visible.
    yield from _check_cli_subcommands()


def _check_cli_subcommands() -> Iterable[tuple[str, str, str | None]]:
    """Report registered CLI subcommands."""
    try:
        from mgf.common.cli._subcommand_registry import (
            _registered_cli_subcommands,
        )

        names = sorted(_registered_cli_subcommands())
    except Exception as exc:
        yield (
            _WARN,
            f"CLI subcommands: introspection failed: {exc}",
            None,
        )
        return
    if not names:
        yield (
            _WARN,
            "CLI subcommands: 0 registered (use @register_cli_subcommand to add your own)",
            None,
        )
        return
    yield (
        _OK,
        f"CLI subcommands: {len(names)} registered ({', '.join(names)})",
        None,
    )


def _check_one_registry(
    *,
    label: str,
    importer: Any,
) -> Iterable[tuple[str, str, str | None]]:
    try:
        snapshot_fn = importer()
        snapshot = snapshot_fn()
    except Exception as e:
        yield _WARN, f"Registered {label}: introspection failed ({e})", None
        return
    names = sorted(snapshot)
    yield _OK, f"Registered {label}: [{', '.join(names) or '(none)'}]", None


def _import_attr(module_path: str, attr: str) -> Any:
    mod = importlib.import_module(module_path)
    return getattr(mod, attr)


def _check_deprecation_state() -> Iterable[tuple[str, str, str | None]]:
    """Best-effort signal: mention if any v0.1 deprecated names look used.

    Phase 6b doesn't have a real warning-capture mechanism — we
    can't see the runtime warning history of the operator's own
    process. We surface the policy + a pointer to the warning
    class.
    """
    yield (
        _OK,
        "Deprecation warnings emit MgfDeprecationWarning "
        "(set `-W error::mgf.common._warnings.MgfDeprecationWarning` to fail-on-deprecated)",
        None,
    )


def _check_test_supervisor() -> Iterable[tuple[str, str, str | None]]:
    """Federation test supervisor (TS-22..TS-27) presence check.

    The supervisor was extracted from ``mgf-common/scripts/_mtest/``
    to the published sibling library ``mgf-test-supervisor`` v0.1.0
    on 2026-05-26; the import path is ``mgf.test_supervisor``.
    Three outcomes (mirroring the previous in-tree check):

      - **✓** — ``import mgf.test_supervisor`` succeeds AND the
        consumer's ``pyproject.toml::[tool.pytest.ini_options]``
        has ``timeout_method = "thread"`` (TS-23 + pytest-timeout
        integration wired).
      - **⚠** — ``import mgf.test_supervisor`` succeeds but the
        pyproject timeout config is missing (TS-23 incomplete).
      - **ℹ** — ``import mgf.test_supervisor`` fails: the
        supervisor library is not installed in this environment.

    Soft check — federation projects without the supervisor are
    NOT broken, just not yet TS-22..TS-27 conformant. The hint
    points at the install command + adoption recipe; the
    maintainer chooses when to land it.
    """
    supervisor_installed = importlib.util.find_spec("mgf.test_supervisor") is not None
    if not supervisor_installed:
        yield (
            _INFO,
            "Federation test supervisor (TS-22..TS-27): "
            "`mgf.test_supervisor` is NOT installed in this environment.",
            (
                "Install the supervisor sibling: "
                "`pip install mgf-test-supervisor` (or add "
                "`mgf-test-supervisor @ git+ssh://git@codeberg.org/"
                "magogi-admin/mgf-test-supervisor.git` to your dev "
                "extras). Then wire pyproject per "
                "mgf-standard/docs/recipes/test-supervisor-adoption.md. "
                "Soft hint — adoption is L1-binding per TS-22..TS-27 "
                "but the federation accepts decline-with-rationale "
                "in MGF_STANDARDS_CONFORMANCE.md for small consumers."
            ),
        )
        return

    pyproject = _find_consumer_pyproject()
    timeout_wired = False
    dev_dep_declared = False
    if pyproject is not None:
        try:
            text = pyproject.read_text(encoding="utf-8")
            # Cheap line-grep; tomllib import deferred for speed.
            timeout_wired = 'timeout_method = "thread"' in text
            dev_dep_declared = "mgf-test-supervisor" in text
        except OSError:
            pass

    if timeout_wired and dev_dep_declared:
        yield (
            _OK,
            "Federation test supervisor (TS-22..TS-27): "
            "`mgf.test_supervisor` importable; pyproject declares "
            "`mgf-test-supervisor` + timeout_method wired.",
            None,
        )
    elif timeout_wired and not dev_dep_declared:
        yield (
            _WARN,
            "Federation test supervisor (TS-22..TS-27): "
            "`mgf.test_supervisor` importable + timeout wired, but "
            "`mgf-test-supervisor` NOT declared in pyproject dev "
            "extras (install is implicit — won't survive a fresh "
            "`pip install -e .[dev]`).",
            (
                "Add `mgf-test-supervisor` to "
                "[project.optional-dependencies].dev in pyproject.toml. "
                "See mgf-standard/docs/recipes/test-supervisor-adoption.md."
            ),
        )
    else:
        yield (
            _WARN,
            "Federation test supervisor (TS-22..TS-27): "
            "`mgf.test_supervisor` importable BUT pyproject "
            '`timeout_method = "thread"` missing (TS-23 incomplete).',
            (
                "Add to pyproject.toml [tool.pytest.ini_options]: "
                'timeout = 30, timeout_method = "thread", '
                "faulthandler_timeout = 60. "
                "See mgf-standard/docs/recipes/test-supervisor-adoption.md §5."
            ),
        )


def _check_supervisor_coverage_patch() -> Iterable[tuple[str, str, str | None]]:
    """PAPER-122 — the supervisor-coverage ↔ ``patch = ["subprocess"]`` coupling.

    A pipeline that runs ``coverage run … -m mgf.test_supervisor`` runs pytest in
    CHILD processes; without ``[tool.coverage.run] patch = ["subprocess"]``
    (coverage >= 7.10) coverage collects nothing and CI reds with **every test
    passing**. ✗ (FAIL), not ⚠: both halves are read from the repo's own committed
    files, so the finding is exact, and the outcome is a certain build failure whose
    own error message names neither the key nor pyproject. Silent (nothing yielded)
    when the repo has no pipeline, no pyproject, or does not run the supervisor under
    coverage — this check only speaks when the coupling is real.
    """
    import tomllib

    pyproject = _find_consumer_pyproject()
    if pyproject is None:
        return
    wp = pyproject.parent / ".woodpecker.yml"
    if not wp.is_file():
        return
    try:
        pipeline = wp.read_text(encoding="utf-8")
        data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
    except (OSError, tomllib.TOMLDecodeError):
        return
    if not _pipeline.coverage_patch_gap(pipeline, data):
        return
    yield (
        _FAIL,
        f"CI coverage coupling BROKEN ({wp.name} runs the supervisor under "
        f"`coverage run`, {pyproject.name} has no [tool.coverage.run] "
        f'patch = ["subprocess"]) — this build WILL red with every test passing.',
        f"{_pipeline.COVERAGE_PATCH_FIX}",
    )


def _find_consumer_pyproject() -> Path | None:
    """Walk up from CWD looking for ``pyproject.toml``.

    Per-CWD lookup mirrors the federation's "each project owns its
    own config" discipline — the supervisor check reports on the
    config of the repo the operator is currently working in, not
    whichever pyproject happens to be on the import path.
    """
    cwd = Path.cwd()
    for candidate in [cwd, *cwd.parents]:
        pyproject = candidate / "pyproject.toml"
        if pyproject.is_file():
            return pyproject
    return None


def _find_repo_root_for_supervisor() -> Path | None:
    """Walk up from CWD looking for scripts/_mtest/__init__.py.

    Same heuristic ``mgf-fed test`` itself uses (see
    ``cli/_test.py::_find_repo_root``). Returns None if no
    supervisor is in the CWD's ancestor chain. Per-CWD lookup
    (NOT per-import-path) is intentional — honors the federation's
    "each project owns its copy" discipline.
    """
    from pathlib import Path

    cwd = Path.cwd()
    for candidate in [cwd, *cwd.parents]:
        if (candidate / "scripts" / "_mtest" / "__init__.py").is_file():
            return candidate
    return None


def add_doctor_args(subparser: Any) -> None:
    """Wire up the ``doctor`` subcommand to an argparse subparser.

    v0.5 Phase E flags:
      ``--live``       Print the runtime snapshot from
                       ``MGF_DOCTOR_RUNTIME_PATH``.
      ``--redaction``  Read JSON from stdin; print before+after
                       redaction.

    The flags are mutually exclusive — neither runs the standard
    install-state checklist.
    """
    group = subparser.add_mutually_exclusive_group()
    group.add_argument(
        "--live",
        action="store_true",
        help=(
            "Read MGF_DOCTOR_RUNTIME_PATH and print the runtime "
            "snapshot written there by bootstrap()."
        ),
    )
    group.add_argument(
        "--redaction",
        action="store_true",
        help=(
            "Read a JSON object from stdin, run it through the "
            "standard Redactor, and print before+after side-by-side."
        ),
    )
    group.add_argument(
        "--standards",
        action="store_true",
        help=(
            "Audit the current project against a curated subset of "
            "the AP-* / DP-* / TS-* / DOC-* rules in docs/standards/. "
            "Read-only; complements `mgf-fed catch-up`."
        ),
    )


def run_doctor(args: Any) -> int:
    """Argparse-callback wiring for the ``doctor`` subcommand."""
    if getattr(args, "live", False):
        from mgf.fed.cli._doctor_live import doctor_live

        return doctor_live()
    if getattr(args, "redaction", False):
        from mgf.fed.cli._doctor_live import doctor_redaction

        return doctor_redaction()
    if getattr(args, "standards", False):
        from mgf.fed.cli._doctor_standards import doctor_standards

        return doctor_standards()
    return doctor()


__all__ = [
    "add_doctor_args",
    "doctor",
    "run_doctor",
]
