"""Top-level CLI exception → exit-code translator.

Closes the closed-box leak around CLI top-level handlers — every
consumer was hand-rolling the same ``except AppError`` ladder
that mapped categories to integers + called ``report_now`` on
the catch-all branch.

Two entry points:

  - :func:`run_and_translate` — call once from your ``main()``
    function. Pass it the inner function (your real CLI logic)
    and your CLI's argv; it returns the exit code. Routes
    untyped exceptions through
    :func:`mgf.common.observability.report_now`.

  - :func:`translate_exceptions` — decorator form for callers
    who already structure their CLI as ``def main(argv): ...``.
    Wraps the callable; the resulting object is `int`-returning.

Exit-code resolution (most-specific-first via MRO):

  1. If the exception type appears as a key in ``exit_code_map``,
     use that value.
  2. Else, walk the exception's MRO. For each class, look it up
     in the merged map (default + override). The first match
     wins.
  3. If no AppError ancestor matches, the exception is "untyped"
     — fall through to the catch-all branch (crash report +
     :data:`EXIT_UNKNOWN`).

This shape lets consumers add project-specific codes::

    from mgf.common.cli import run_and_translate, EXIT_OPERATION_ERROR
    from myapp.exceptions import LicenseError

    return run_and_translate(
        main_fn,
        argv,
        exit_code_map={LicenseError: 7},
    )

The default map is built lazily so importing this module does
NOT pull every exception class at import time.
"""

from __future__ import annotations

import sys
from functools import wraps
from typing import TYPE_CHECKING

from mgf.common.cli._exit_codes import (
    EXIT_APP_ERROR,
    EXIT_CONFIG_ERROR,
    EXIT_ENVIRONMENT_ERROR,
    EXIT_GUEST_ERROR,
    EXIT_OPERATION_ERROR,
    EXIT_PROVIDER_ERROR,
    EXIT_SUCCESS,
    EXIT_UNKNOWN,
    EXIT_VAULT_ERROR,
)
from mgf.common.observability import get_logger

if TYPE_CHECKING:
    from collections.abc import Callable, Sequence

_LOG = get_logger(__name__)


def _default_exit_code_map() -> dict[type[BaseException], int]:
    """Build the default exception → exit-code map.

    Lazy build (first call) so importing this module does NOT
    eagerly import every exception class. Returns a fresh dict
    every call so callers can mutate without affecting the
    library's internal state.
    """
    from mgf.common.exceptions import (
        AppError,
        ConfigError,
        GuestError,
        HostEnvironmentError,
        OperationError,
        ProviderError,
        VaultError,
    )

    return {
        ConfigError: EXIT_CONFIG_ERROR,
        OperationError: EXIT_OPERATION_ERROR,
        GuestError: EXIT_GUEST_ERROR,
        HostEnvironmentError: EXIT_ENVIRONMENT_ERROR,
        ProviderError: EXIT_PROVIDER_ERROR,
        VaultError: EXIT_VAULT_ERROR,
        # Base-level fallback (rule EH-04): any AppError subclass not
        # caught by a more-specific entry above resolves here via the
        # MRO walk in _resolve_exit_code. The six specific families win
        # because they appear earlier in their own MRO; SchedulerError,
        # the HttpError family, and any future direct AppError subclass
        # land on EXIT_APP_ERROR — a TYPED exit with no crash report,
        # instead of EXIT_UNKNOWN + a spurious crash file.
        AppError: EXIT_APP_ERROR,
    }


def _resolve_exit_code(
    exc: BaseException,
    overrides: dict[type[BaseException], int] | None,
) -> int | None:
    """Return the exit code for ``exc``, or ``None`` if no match.

    The ``None`` return signals "untyped exception — let the
    catch-all branch handle it" (crash report + :data:`EXIT_UNKNOWN`).

    Resolution: walk the exception's MRO. The merged map
    (default + override) is consulted at each step; the first
    hit wins. Override entries take precedence at the SAME MRO
    level (more-specific-first beats default-more-specific only
    if the override lives on the same class).
    """
    merged = _default_exit_code_map()
    if overrides:
        merged.update(overrides)

    for cls in type(exc).__mro__:
        if cls in merged:
            return merged[cls]
    return None


def run_and_translate(
    main_fn: Callable[[Sequence[str]], int | None],
    argv: Sequence[str],
    *,
    exit_code_map: dict[type[BaseException], int] | None = None,
    crash_source: str = "cli",
) -> int:
    """Run ``main_fn(argv)`` and translate exceptions to exit codes.

    Returns the exit code (NEVER raises). The catch-all branch
    ALSO ships an untyped exception through
    :func:`mgf.common.observability.report_now` so the crash
    file is on disk for the user to attach to a bug report.

    Parameters
    ----------
    main_fn
        Your CLI's real entry point. Signature
        ``(argv: Sequence[str]) -> int | None``. ``None`` is
        treated as :data:`EXIT_SUCCESS`; any non-zero int is
        returned verbatim.
    argv
        The argument vector passed to ``main_fn`` (typically
        ``sys.argv[1:]``).
    exit_code_map
        Per-CLI overrides. Keys are exception classes (or
        bases); values are the exit code to return when one of
        those is raised. Override entries take precedence over
        defaults at the same MRO level.
    crash_source
        The ``source`` label passed to
        :func:`mgf.common.observability.report_now` for untyped
        exceptions. Defaults to ``"cli"``; pass a more specific
        label (``"my-cli:dump"``) when one CLI binary has
        multiple entry points worth distinguishing in crash
        reports.
    """
    try:
        result = main_fn(argv)
    except SystemExit as e:
        # Argparse + a few stdlib helpers raise SystemExit. Honour
        # the requested code; coerce non-int ("error message" forms)
        # to EXIT_UNKNOWN.
        return e.code if isinstance(e.code, int) else EXIT_UNKNOWN
    except KeyboardInterrupt:
        # The standard rule (DESIGN_PRINCIPLES.md §8): cancellation
        # MUST raise a typed exception. KeyboardInterrupt that escapes
        # to here is the bare-Ctrl+C-not-handled case — exit with the
        # conventional 130 (128 + SIGINT) so shell scripts can detect
        # it.
        _LOG.info("CLI interrupted by user (SIGINT)")
        return 130
    except BaseException as e:
        code = _resolve_exit_code(e, exit_code_map)
        if code is not None:
            # Typed AppError — log at WARNING (the message itself was
            # written for humans) and return the mapped code.
            _LOG.warning(
                "CLI command failed with typed error",
                extra={
                    "audit": True,
                    "event": "cli.command_failed",
                    "error_type": type(e).__name__,
                    "error_message": str(e),
                    "exit_code": code,
                },
            )
            return code

        # Untyped exception — ship a crash report and return
        # EXIT_UNKNOWN. report_now NEVER raises (rule EH-10).
        from mgf.common.observability import report_now

        report_path = report_now(e, source=crash_source)
        _LOG.exception(
            "unhandled exception in CLI",
            extra={
                "audit": True,
                "event": "cli.unhandled_exception",
                "error_type": type(e).__name__,
                "crash_report_path": str(report_path),
                "hint": f"inspect the crash report at {report_path} and file an issue with the redacted JSON",
            },
        )
        return EXIT_UNKNOWN

    if result is None:
        return EXIT_SUCCESS
    return result


def translate_exceptions(
    *,
    exit_code_map: dict[type[BaseException], int] | None = None,
    crash_source: str = "cli",
) -> Callable[
    [Callable[[Sequence[str]], int | None]],
    Callable[[Sequence[str]], int],
]:
    """Decorator form of :func:`run_and_translate`.

    Wraps a ``main(argv) -> int | None`` callable so the result is
    always ``int`` (the exit code). Equivalent to::

        @translate_exceptions(exit_code_map={...})
        def main(argv): ...

        # is the same as

        def main_inner(argv): ...

        def main(argv):
            return run_and_translate(main_inner, argv, exit_code_map={...})

    Useful when ``main`` is the canonical entry-point name + the
    consumer wires it up via ``sys.exit(main(sys.argv[1:]))`` at
    the bottom of the file.
    """

    def decorator(
        fn: Callable[[Sequence[str]], int | None],
    ) -> Callable[[Sequence[str]], int]:
        @wraps(fn)
        def wrapper(argv: Sequence[str]) -> int:
            return run_and_translate(
                fn,
                argv,
                exit_code_map=exit_code_map,
                crash_source=crash_source,
            )

        return wrapper

    return decorator


def main_entry(
    main_fn: Callable[[Sequence[str]], int | None],
    *,
    exit_code_map: dict[type[BaseException], int] | None = None,
    crash_source: str = "cli",
) -> None:
    """Convenience: read ``sys.argv``, translate, and ``sys.exit``.

    The shortest possible top-level. Equivalent to::

        if __name__ == "__main__":
            sys.exit(run_and_translate(main, sys.argv[1:]))

    Use it when you don't need to do anything else in the
    ``if __name__`` block.
    """
    sys.exit(
        run_and_translate(
            main_fn,
            sys.argv[1:],
            exit_code_map=exit_code_map,
            crash_source=crash_source,
        )
    )


__all__ = [
    "main_entry",
    "run_and_translate",
    "translate_exceptions",
]
