"""Phase 3 of the v0.3 closed-box redesign — registry tests.

Covers the four registries shipped in Phase 3:

  - ``register_crash_sink`` (closes FEEDBACK API-07).
  - ``register_redaction_pattern_group`` (Phase 3 wiring of the
    field shipped in Phase 1).
  - ``register_log_handler`` (registry only — full wiring lands
    in a later phase).
  - ``register_span_processor`` (registry only — full wiring
    lands in a later phase).

Each registry follows the same shape:

  - ``@register_*("name")`` decorator that registers a factory.
  - ``unregister_*("name")`` removes (no-op if unknown).
  - Re-registering the same name MUST raise ``ValueError``.
  - Internal ``_registered_*()`` returns a snapshot for tests +
    diagnostic CLI.
"""

from __future__ import annotations

import logging
import re
from typing import TYPE_CHECKING, Any

import pytest

if TYPE_CHECKING:
    from pathlib import Path

from mgf.common.observability import (
    CrashSink,
    LogHandlerFactory,
    PatternGroupFactory,
    SpanProcessorFactory,
    register_crash_sink,
    register_log_handler,
    register_redaction_pattern_group,
    register_span_processor,
    unregister_crash_sink,
    unregister_log_handler,
    unregister_redaction_pattern_group,
    unregister_span_processor,
)
from mgf.common.observability.crash_reporter import _registered_crash_sinks
from mgf.common.observability.logging_setup import _registered_log_handlers
from mgf.common.observability.otel_setup import _registered_span_processors
from mgf.common.observability.redaction import (
    _registered_pattern_groups,
    _resolve_pattern_groups,
)

# ---------------------------------------------------------------------------
# Crash-sink registry (closes FEEDBACK API-07)
# ---------------------------------------------------------------------------


class TestCrashSinkRegistry:
    """The headline win of Phase 3."""

    def test_register_then_lookup(self) -> None:
        @register_crash_sink("test-sink-A")
        def _ship(report_path: Path, payload: dict[str, Any]) -> None:
            return None

        try:
            registered = _registered_crash_sinks()
            assert "test-sink-A" in registered
            assert registered["test-sink-A"] is _ship
        finally:
            unregister_crash_sink("test-sink-A")

    def test_re_register_same_name_raises(self) -> None:
        @register_crash_sink("test-sink-B")
        def _ship_a(report_path: Path, payload: dict[str, Any]) -> None:
            return None

        try:
            with pytest.raises(ValueError, match="already registered"):

                @register_crash_sink("test-sink-B")
                def _ship_b(report_path: Path, payload: dict[str, Any]) -> None:
                    return None
        finally:
            unregister_crash_sink("test-sink-B")

    def test_unregister_unknown_is_noop(self) -> None:
        # No exception for unknown name.
        unregister_crash_sink("never-registered")

    def test_builtin_sinks_pre_registered(self) -> None:
        """The v0.1 hardcoded sinks (sentry / otlp) are now pre-
        registered entries — backward compat preserved."""
        registered = _registered_crash_sinks()
        assert "sentry" in registered
        assert "otlp" in registered

    def test_crashsink_type_alias_is_callable_type(self) -> None:
        """The exported type alias is usable in consumer signatures."""
        # The alias is `Callable[[Path, dict[str, Any]], None]`.
        # We can't easily introspect that at runtime in a stable way;
        # this test pins that the name IS importable and not None.
        assert CrashSink is not None


class TestCrashSinkFanOut:
    """Multi-sink fan-out via MgfSettings.crash.sinks (the new shape)."""

    def test_settings_driven_fan_out(
        self,
        monkeypatch: pytest.MonkeyPatch,
        tmp_path: Path,
    ) -> None:
        """Registering two sinks + listing them in
        MgfSettings.crash.sinks → both fire on a crash."""
        import mgf.common

        # Pristine state.
        from mgf.common import _identity, _lifecycle
        from mgf.common.settings import CrashSettings, MgfSettings

        monkeypatch.setattr(_identity, "_APP_NAME", "app")
        monkeypatch.setattr(_identity, "_APP_VERSION", "unknown")
        monkeypatch.setattr(_identity, "_CONFIGURED", False)
        monkeypatch.setattr(_identity, "_UNCONFIGURED_WARNED", False)
        monkeypatch.setattr(_lifecycle, "_GLOBAL_CONTEXT", None)
        monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path))
        monkeypatch.setenv("HOME", str(tmp_path))
        monkeypatch.delenv("JOURNAL_STREAM", raising=False)

        sink_a_calls: list[dict[str, Any]] = []
        sink_b_calls: list[dict[str, Any]] = []

        @register_crash_sink("test-fanout-a")
        def _sink_a(report_path: Path, payload: dict[str, Any]) -> None:
            sink_a_calls.append(payload)

        @register_crash_sink("test-fanout-b")
        def _sink_b(report_path: Path, payload: dict[str, Any]) -> None:
            sink_b_calls.append(payload)

        try:
            settings = MgfSettings(
                crash=CrashSettings(sinks=["local", "test-fanout-a", "test-fanout-b"]),
            )
            ctx = mgf.common.bootstrap(
                app_name="fanout",
                app_version="0.0",
                settings=settings,
            )
            try:
                from mgf.common.observability import report_now

                try:
                    raise RuntimeError("synthetic crash")
                except RuntimeError as e:
                    report_now(e, source="test")

                # Both sinks fired exactly once.
                assert len(sink_a_calls) == 1
                assert len(sink_b_calls) == 1
                assert sink_a_calls[0]["exception"]["message"] == "synthetic crash"
            finally:
                ctx.shutdown()
        finally:
            unregister_crash_sink("test-fanout-a")
            unregister_crash_sink("test-fanout-b")
            # Close any handlers
            import contextlib

            for h in list(logging.root.handlers):
                with contextlib.suppress(Exception):
                    h.close()
                logging.root.removeHandler(h)

    def test_unknown_sink_logs_warning_does_not_crash(
        self,
        monkeypatch: pytest.MonkeyPatch,
        tmp_path: Path,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """An unknown sink in settings.crash.sinks is logged at
        WARNING + skipped (rule EH-10 best-effort observer)."""
        import mgf.common
        from mgf.common import _identity, _lifecycle
        from mgf.common.settings import CrashSettings, MgfSettings

        monkeypatch.setattr(_identity, "_APP_NAME", "app")
        monkeypatch.setattr(_identity, "_APP_VERSION", "unknown")
        monkeypatch.setattr(_identity, "_CONFIGURED", False)
        monkeypatch.setattr(_lifecycle, "_GLOBAL_CONTEXT", None)
        monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path))

        settings = MgfSettings(
            crash=CrashSettings(sinks=["local", "ghost-sink-not-registered"]),
        )
        ctx = mgf.common.bootstrap(
            app_name="unknown",
            app_version="0.0",
            settings=settings,
        )
        try:
            from mgf.common.observability import report_now

            try:
                raise RuntimeError("crash")
            except RuntimeError as e:
                report_now(e, source="test")
            # Warning logged to stderr (the configured console sink).
            captured = capsys.readouterr()
            assert "ghost-sink-not-registered" in captured.err, (
                f"warning about unknown sink not in stderr; got: {captured.err}"
            )
        finally:
            ctx.shutdown()
            import contextlib

            for h in list(logging.root.handlers):
                with contextlib.suppress(Exception):
                    h.close()
                logging.root.removeHandler(h)


# ---------------------------------------------------------------------------
# Redaction-pattern-group registry (Phase 3 wiring of MgfSettings field)
# ---------------------------------------------------------------------------


class TestRedactionPatternGroupRegistry:
    """The "builtin" group is pre-registered; consumers add named
    groups via the decorator + enable via MgfSettings.redaction.
    enabled_pattern_groups."""

    def test_builtin_pre_registered(self) -> None:
        registered = _registered_pattern_groups()
        assert "builtin" in registered
        # The builtin group resolves to a tuple of compiled regexes.
        builtin_patterns = registered["builtin"]()
        assert isinstance(builtin_patterns, tuple)
        assert all(isinstance(p, re.Pattern) for p in builtin_patterns)

    def test_register_then_resolve(self) -> None:
        @register_redaction_pattern_group("test-hv-tokens")
        def _patterns() -> tuple[re.Pattern[str], ...]:
            return (re.compile(r"\bhv-tok-\d{4}\b"),)

        try:
            resolved = _resolve_pattern_groups(["test-hv-tokens"])
            assert len(resolved) == 1
            assert resolved[0].pattern == r"\bhv-tok-\d{4}\b"
        finally:
            unregister_redaction_pattern_group("test-hv-tokens")

    def test_re_register_raises(self) -> None:
        @register_redaction_pattern_group("test-rereg")
        def _p() -> tuple[re.Pattern[str], ...]:
            return ()

        try:
            with pytest.raises(ValueError, match="already registered"):

                @register_redaction_pattern_group("test-rereg")
                def _p2() -> tuple[re.Pattern[str], ...]:
                    return ()
        finally:
            unregister_redaction_pattern_group("test-rereg")

    def test_unknown_group_logged_not_raised(
        self,
        caplog: pytest.LogCaptureFixture,
    ) -> None:
        """Unknown group names are skipped with a WARNING (best-
        effort observer)."""
        with caplog.at_level("WARNING"):
            resolved = _resolve_pattern_groups(["definitely-not-registered"])
        assert resolved == ()
        assert any("definitely-not-registered" in r.getMessage() for r in caplog.records)

    def test_pattern_group_factory_type_alias(self) -> None:
        assert PatternGroupFactory is not None


class TestRedactionWiringEndToEnd:
    """End-to-end: register a custom group, enable it via settings,
    bootstrap, log a record containing the matching pattern, and
    verify it was redacted."""

    def test_consumer_pattern_redacts(
        self,
        monkeypatch: pytest.MonkeyPatch,
        tmp_path: Path,
    ) -> None:

        import mgf.common
        from mgf.common import _identity, _lifecycle
        from mgf.common.observability import get_logger
        from mgf.common.settings import LoggingSettings, MgfSettings, RedactionSettings

        @register_redaction_pattern_group("test-product-tokens")
        def _patterns() -> tuple[re.Pattern[str], ...]:
            return (re.compile(r"\bPROD-[A-Z0-9]{12,}\b"),)

        monkeypatch.setattr(_identity, "_APP_NAME", "app")
        monkeypatch.setattr(_identity, "_APP_VERSION", "unknown")
        monkeypatch.setattr(_identity, "_CONFIGURED", False)
        monkeypatch.setattr(_lifecycle, "_GLOBAL_CONTEXT", None)
        monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path))
        monkeypatch.setenv("HOME", str(tmp_path))

        log_file = tmp_path / "out.log"

        try:
            settings = MgfSettings(
                logging=LoggingSettings(level="DEBUG", format="json", file=log_file),
                redaction=RedactionSettings(
                    enabled_pattern_groups=["builtin", "test-product-tokens"],
                ),
            )
            ctx = mgf.common.bootstrap(
                app_name="redact-e2e",
                app_version="0.0",
                settings=settings,
            )
            try:
                log = get_logger("test")
                log.info("processing token PROD-AB12CD34EF56 inbound")
                # Force flush.
                for h in logging.root.handlers:
                    h.flush()
            finally:
                ctx.shutdown()

            content = log_file.read_text()
            # The custom pattern caught the secret.
            assert "PROD-AB12CD34EF56" not in content
            assert "[REDACTED]" in content
        finally:
            unregister_redaction_pattern_group("test-product-tokens")
            import contextlib

            for h in list(logging.root.handlers):
                with contextlib.suppress(Exception):
                    h.close()
                logging.root.removeHandler(h)


# ---------------------------------------------------------------------------
# Log-handler registry (registry shape + decorator; wiring deferred)
# ---------------------------------------------------------------------------


class TestLogHandlerRegistry:
    def test_register_then_lookup(self) -> None:
        @register_log_handler("test-handler")
        def _make(settings: Any) -> logging.Handler:
            return logging.NullHandler()

        try:
            registered = _registered_log_handlers()
            assert "test-handler" in registered
            assert registered["test-handler"] is _make
        finally:
            unregister_log_handler("test-handler")

    def test_re_register_raises(self) -> None:
        @register_log_handler("test-rereg-handler")
        def _make_a(settings: Any) -> logging.Handler:
            return logging.NullHandler()

        try:
            with pytest.raises(ValueError, match="already registered"):

                @register_log_handler("test-rereg-handler")
                def _make_b(settings: Any) -> logging.Handler:
                    return logging.NullHandler()
        finally:
            unregister_log_handler("test-rereg-handler")

    def test_log_handler_factory_alias(self) -> None:
        assert LogHandlerFactory is not None


# ---------------------------------------------------------------------------
# OTel span-processor registry (registry shape + decorator; wiring deferred)
# ---------------------------------------------------------------------------


class TestSpanProcessorRegistry:
    def test_register_then_lookup(self) -> None:
        @register_span_processor("test-processor")
        def _make(settings: Any) -> Any:
            # Real factory would return a SpanProcessor; we just
            # verify the registry shape here.
            return object()

        try:
            registered = _registered_span_processors()
            assert "test-processor" in registered
            assert registered["test-processor"] is _make
        finally:
            unregister_span_processor("test-processor")

    def test_re_register_raises(self) -> None:
        @register_span_processor("test-rereg-processor")
        def _make_a(settings: Any) -> Any:
            return object()

        try:
            with pytest.raises(ValueError, match="already registered"):

                @register_span_processor("test-rereg-processor")
                def _make_b(settings: Any) -> Any:
                    return object()
        finally:
            unregister_span_processor("test-rereg-processor")

    def test_span_processor_factory_alias(self) -> None:
        assert SpanProcessorFactory is not None
