"""``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
from pathlib import Path
from typing import TYPE_CHECKING, Any

from mgf.common.cli import EXIT_OPERATION_ERROR, EXIT_SUCCESS

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] = []

    for marker, msg, action in _all_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()


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,
    iosRecovery, …) 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 _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",
]
