"""API-02 — multi-tenancy via scoped :class:`AppContext`.

Closes the v0.4-era last open 🔴 blocker per Option C in
FEEDBACK §6: hybrid model — process-wide global for the 95%
case (``bootstrap()``), per-task contextvar for the 5%
library-in-library use cases (``with AppContext(...):``).

Two construction modes for ``AppContext``:

  - **Bootstrap-owned** (`_wiring is not None`) — set by
    :func:`bootstrap`. The 95% path. Already covered by
    `tests/unit/test_lifecycle.py`.

  - **Scoped** (`_wiring is None`) — constructed directly. Pushes
    onto the contextvar on `__enter__`, pops on `__exit__`. NEW
    in v0.4.x — these are the API-02 tests.

The two modes share a class but differ in semantics; this test
file pins the scoped-context contract.
"""

from __future__ import annotations

import asyncio
import threading
from typing import TYPE_CHECKING

import pytest

import mgf.common
from mgf.common._lifecycle import AppContext
from mgf.common.settings import LoggingSettings, MgfSettings

if TYPE_CHECKING:
    from pathlib import Path


@pytest.fixture
def _isolated_state(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
    monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path / "state"))
    monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config"))
    monkeypatch.setenv("MGF_DISABLE_PYPROJECT", "1")
    return tmp_path


@pytest.fixture
def _isolated_logging() -> None:
    import logging as _logging

    from mgf.common import _identity

    prior_handlers = list(_logging.root.handlers)
    prior_level = _logging.root.level
    prior_app = _identity._APP_NAME
    prior_ver = _identity._APP_VERSION

    yield

    for h in list(_logging.root.handlers):
        _logging.root.removeHandler(h)
    for h in prior_handlers:
        _logging.root.addHandler(h)
    _logging.root.setLevel(prior_level)
    _identity._APP_NAME = prior_app
    _identity._APP_VERSION = prior_ver

    from mgf.common import _lifecycle

    _lifecycle._GLOBAL_CONTEXT = None


@pytest.fixture
def _isolated_excepthook() -> None:
    import sys

    prior_sys = sys.excepthook
    prior_thread = threading.excepthook
    yield
    sys.excepthook = prior_sys
    threading.excepthook = prior_thread


# ---------------------------------------------------------------------------
# Scoped construction — direct AppContext(...)
# ---------------------------------------------------------------------------


class TestScopedConstruction:
    """``AppContext("name", "version")`` works without _wiring."""

    def test_minimal_construction(self) -> None:
        ctx = AppContext("plugin", "1.0")
        assert ctx.app_name == "plugin"
        assert ctx.app_version == "1.0"
        assert isinstance(ctx.settings, MgfSettings)
        assert ctx._wiring is None

    def test_with_explicit_settings(self) -> None:
        custom = MgfSettings(logging=LoggingSettings(level="DEBUG"))
        ctx = AppContext("plugin", "1.0", settings=custom)
        assert ctx.settings.logging.level == "DEBUG"

    def test_default_settings_is_fresh_instance(self) -> None:
        """Each AppContext gets its OWN MgfSettings (not a shared
        class-level default)."""
        a = AppContext("a", "1.0")
        b = AppContext("b", "1.0")
        assert a.settings is not b.settings


# ---------------------------------------------------------------------------
# Contextvar push/pop on with-block
# ---------------------------------------------------------------------------


class TestScopedWithBlock:
    """Scoped contexts push to the contextvar; bootstrap-owned
    contexts set the global."""

    def test_with_block_routes_current_context(self) -> None:
        """Inside the with-block, current_context() returns the
        scoped context. Outside, it returns whatever was active
        before (None if no bootstrap)."""
        assert mgf.common.current_context() is None

        scoped = AppContext("plugin", "1.0")
        with scoped as ctx:
            assert ctx is scoped
            assert mgf.common.current_context() is scoped

        # After: contextvar reset, no global
        assert mgf.common.current_context() is None

    def test_nested_with_bootstrap(
        self,
        _isolated_state: Path,
        _isolated_logging: None,
        _isolated_excepthook: None,
    ) -> None:
        """Bootstrap-owned context sets the global; a scoped
        sub-context overlays it; exiting the sub-context reveals
        the global again."""
        outer = mgf.common.bootstrap(app_name="outer", app_version="1.0")
        try:
            assert mgf.common.current_context() is outer

            sub = AppContext("plugin", "2.0")
            with sub:
                assert mgf.common.current_context() is sub
                assert mgf.common.app_name() == "plugin"

            # After: global restored
            assert mgf.common.current_context() is outer
            assert mgf.common.app_name() == "outer"
        finally:
            outer.shutdown()

    def test_double_nested_scopes(
        self,
        _isolated_state: Path,
        _isolated_logging: None,
        _isolated_excepthook: None,
    ) -> None:
        """Scopes nest correctly — innermost wins."""
        outer = mgf.common.bootstrap(app_name="outer", app_version="1.0")
        try:
            with AppContext("middle", "2.0"):
                assert mgf.common.app_name() == "middle"
                with AppContext("inner", "3.0"):
                    assert mgf.common.app_name() == "inner"
                assert mgf.common.app_name() == "middle"
            assert mgf.common.app_name() == "outer"
        finally:
            outer.shutdown()

    def test_exception_inside_scope_still_pops(self) -> None:
        """Exiting a with-block via exception still pops the
        contextvar."""
        scoped = AppContext("plugin", "1.0")
        with pytest.raises(RuntimeError, match="boom"), scoped:
            assert mgf.common.current_context() is scoped
            raise RuntimeError("boom")
        # Contextvar must be popped even though we exited via
        # exception.
        assert mgf.common.current_context() is None


# ---------------------------------------------------------------------------
# Settings inheritance pattern
# ---------------------------------------------------------------------------


class TestSettingsInheritance:
    """Scoped contexts get fresh defaults; consumers explicitly
    inherit from the parent if desired."""

    def test_default_settings_does_not_inherit(
        self,
        _isolated_state: Path,
        _isolated_logging: None,
        _isolated_excepthook: None,
    ) -> None:
        """A scoped context without ``settings=`` gets default
        MgfSettings — does NOT inherit from parent."""
        outer = mgf.common.bootstrap(
            app_name="outer",
            app_version="1.0",
            settings=MgfSettings(logging=LoggingSettings(level="DEBUG")),
        )
        try:
            with AppContext("plugin", "1.0") as sub:
                # Sub got fresh defaults — INFO, not DEBUG
                assert sub.settings.logging.level == "INFO"
        finally:
            outer.shutdown()

    def test_explicit_inheritance_pattern(
        self,
        _isolated_state: Path,
        _isolated_logging: None,
        _isolated_excepthook: None,
    ) -> None:
        """Consumers who want inheritance pass parent.settings explicitly.
        Documented as the canonical pattern."""
        outer = mgf.common.bootstrap(
            app_name="outer",
            app_version="1.0",
            settings=MgfSettings(logging=LoggingSettings(level="DEBUG")),
        )
        try:
            parent = mgf.common.current_context()
            assert parent is not None
            with AppContext(
                "plugin",
                "1.0",
                settings=parent.settings,
            ) as sub:
                assert sub.settings.logging.level == "DEBUG"
                # Same instance — contexts share the settings object
                assert sub.settings is parent.settings
        finally:
            outer.shutdown()


# ---------------------------------------------------------------------------
# shutdown / reload semantics on scoped contexts
# ---------------------------------------------------------------------------


class TestScopedLifecycleMethods:
    """shutdown() is a no-op on scoped contexts; reload() raises."""

    def test_shutdown_on_scoped_is_noop(self) -> None:
        """No wiring to tear down. Multiple calls are fine."""
        ctx = AppContext("plugin", "1.0")
        ctx.shutdown()  # MUST NOT raise
        ctx.shutdown()  # idempotent
        assert ctx._shut_down is True

    def test_reload_on_scoped_raises(self) -> None:
        """reload() requires wiring. Sub-contexts construct a fresh
        AppContext instead."""
        ctx = AppContext("plugin", "1.0")
        with pytest.raises(RuntimeError, match="bootstrap-owned"):
            ctx.reload(MgfSettings())


# ---------------------------------------------------------------------------
# Threading + asyncio: contextvar propagation
# ---------------------------------------------------------------------------


class TestThreadingAndAsyncio:
    """Contextvars follow PEP 567 semantics: each thread / async task
    has its own value."""

    def test_thread_isolation(
        self,
        _isolated_state: Path,
        _isolated_logging: None,
        _isolated_excepthook: None,
    ) -> None:
        """A scoped context in one thread is NOT visible in another
        unless explicitly propagated. The thread sees the global."""
        outer = mgf.common.bootstrap(
            app_name="outer",
            app_version="1.0",
        )
        try:
            seen_in_thread: list[str | None] = []

            def _worker() -> None:
                ctx = mgf.common.current_context()
                seen_in_thread.append(ctx.app_name if ctx else None)

            with AppContext("plugin", "1.0"):
                # Main thread sees "plugin"
                assert mgf.common.app_name() == "plugin"

                t = threading.Thread(target=_worker)
                t.start()
                t.join()

            # The new thread did NOT inherit the contextvar push;
            # it saw the process-wide global.
            assert seen_in_thread == ["outer"]
        finally:
            outer.shutdown()

    @pytest.mark.asyncio
    async def test_asyncio_propagation(
        self,
        _isolated_state: Path,
        _isolated_logging: None,
        _isolated_excepthook: None,
    ) -> None:
        """In asyncio, contextvars propagate across ``await``
        points — child coroutines inherit the parent's context."""
        outer = mgf.common.bootstrap(
            app_name="outer",
            app_version="1.0",
        )
        try:

            async def _read_in_coro() -> str:
                # Yield to the event loop, then read.
                await asyncio.sleep(0)
                ctx = mgf.common.current_context()
                return ctx.app_name if ctx else "<none>"

            with AppContext("plugin", "1.0"):
                # Coroutine sees the scoped value via contextvar.
                got = await _read_in_coro()
                assert got == "plugin"

            # After scope: coroutines read the global.
            assert (await _read_in_coro()) == "outer"
        finally:
            outer.shutdown()


# ---------------------------------------------------------------------------
# Integration: identity flows through observability
# ---------------------------------------------------------------------------


class TestIdentityRouting:
    """The whole point: observability functions read the active
    identity via current_context, so they route correctly inside
    scoped contexts."""

    def test_app_name_routes_through_scope(
        self,
        _isolated_state: Path,
        _isolated_logging: None,
        _isolated_excepthook: None,
    ) -> None:
        outer = mgf.common.bootstrap(
            app_name="outer",
            app_version="1.0",
        )
        try:
            assert mgf.common.app_name() == "outer"

            with AppContext("plugin", "9.9"):
                assert mgf.common.app_name() == "plugin"
                assert mgf.common.app_version() == "9.9"

            assert mgf.common.app_name() == "outer"
            assert mgf.common.app_version() == "1.0"
        finally:
            outer.shutdown()
