"""Tests for the ThreadingInstrumentor patch installed by agent_server.py.

The patch exists because strands-agents calls
``ThreadingInstrumentor().instrument()`` at Tracer construction time, which
recursively wraps ThreadPoolExecutor.submit and triggers RecursionError
during tool-rendering requests.

We verify:
  * ``ThreadingInstrumentor.instrument`` has been replaced,
  * the replacement returns ``self`` (not ``None``) so fluent callers
    don't AttributeError,
  * calling ``.instrument()`` is a no-op (doesn't actually wrap anything),
  * the import-order guard is implemented as ``if/raise`` (NOT ``assert``)
    so it survives ``python -O``,
  * pre-imported strands is detected and raises.
"""

from __future__ import annotations

import pytest


def _install_patch_without_imports():
    """Apply the same patch agent_server.py applies, without importing
    ag_ui_strands / strands (which aren't available in the local venv).

    Mirrors the logic in ``agent_server.py`` verbatim so this test catches
    regressions where the two diverge.
    """
    from opentelemetry.instrumentation.threading import ThreadingInstrumentor

    def _disabled_instrument(self, *args, **kwargs):
        return self

    ThreadingInstrumentor.instrument = _disabled_instrument  # type: ignore[method-assign]
    return ThreadingInstrumentor, _disabled_instrument


def test_instrument_returns_self_not_none():
    ThreadingInstrumentor, _ = _install_patch_without_imports()

    instance = ThreadingInstrumentor()
    result = instance.instrument()

    # Must return ``self`` so fluent chains don't blow up with
    # AttributeError: 'NoneType' object has no attribute ...
    assert result is instance
    assert result is not None


def test_instrument_accepts_args_and_kwargs():
    """Upstream signature may evolve; the patch must accept arbitrary args."""
    ThreadingInstrumentor, _ = _install_patch_without_imports()

    instance = ThreadingInstrumentor()
    # Any combination of args/kwargs must be accepted without error.
    assert instance.instrument() is instance
    assert instance.instrument("x") is instance
    assert instance.instrument(foo="bar") is instance
    assert instance.instrument("x", y=1) is instance


def test_patch_replaces_original_method():
    ThreadingInstrumentor, sentinel = _install_patch_without_imports()

    assert ThreadingInstrumentor.instrument is sentinel


def test_agent_server_guards_use_if_raise_not_assert():
    """The import-order guards in agent_server.py must be implemented as
    ``if not ...: raise RuntimeError(...)``, NOT as ``assert`` statements.

    Why this matters: ``assert`` is stripped when Python runs with ``-O``
    (some Docker base images, optimized CPython builds). If the guards
    use ``assert``, they silently vanish under ``-O`` and the documented
    RecursionError returns with no signal.

    This test reads the source of ``agent_server.py`` and fails if any
    guard is written as ``assert``.
    """
    import importlib.util

    spec = importlib.util.find_spec("agent_server")
    if spec is None or spec.origin is None:
        pytest.skip("agent_server module not locatable on sys.path")

    with open(spec.origin, "r", encoding="utf-8") as fh:
        source = fh.read()

    # The guards we care about are named functions. The invariant we
    # enforce: their bodies must raise, not assert. We grep for the
    # former and reject the latter.
    assert "_assert_strands_not_preimported" in source, (
        "agent_server.py must expose _assert_strands_not_preimported() "
        "so tests can monkey-patch the guard cleanly"
    )
    assert "_assert_instrumentor_patched" in source, (
        "agent_server.py must expose _assert_instrumentor_patched() "
        "for the same reason"
    )

    # Check that neither of the guard strings appears behind an ``assert``.
    # We match ``assert "strands" not in sys.modules`` and ``assert
    # _ThreadingInstrumentor.instrument is``. Both are the shapes that
    # existed before the -O fix. If either regex matches, we've regressed.
    import re

    forbidden_patterns = [
        r'^\s*assert\s+"strands"\s+not\s+in\s+sys\.modules\b',
        r'^\s*assert\s+_ThreadingInstrumentor\.instrument\s+is\b',
    ]
    for pat in forbidden_patterns:
        assert not re.search(pat, source, flags=re.MULTILINE), (
            f"agent_server.py contains a raw ``assert`` guard matching {pat!r}; "
            "these are stripped under ``python -O``. Use ``if/raise RuntimeError`` instead."
        )

    # Positive assertion: the named functions must raise RuntimeError
    # (not AssertionError) so the guard survives -O and also so callers
    # don't need ``__debug__`` to be true.
    assert "raise RuntimeError(" in source, (
        "agent_server.py guards must use ``raise RuntimeError(...)`` — "
        "RuntimeError (not AssertionError) so the semantics are identical "
        "under ``python -O``"
    )


def test_agent_server_module_installs_patch():
    """Importing ``agent_server`` must leave the instrumentor patched.

    We stub out the strands imports that ``agent_server`` would otherwise
    require, so the test can run in environments where strands isn't
    installed.
    """
    import sys
    import types

    # Stub modules that agent_server transitively imports, so the module
    # loads without needing the real strands stack installed.
    class _AcceptsAnything:
        def __init__(self, *args, **kwargs):
            self.args = args
            self.kwargs = kwargs
            self._agents_by_thread: dict = {}

    class _FakeFastAPI:
        """Accepts the decorator calls agent_server applies to ``app``."""

        def _decorator(self, *a, **k):
            def _wrap(fn):
                return fn
            return _wrap

        get = post = put = delete = patch = _decorator

        # agent_server.py installs a ``HealthMiddleware`` via
        # ``app.add_middleware(...)`` after creating the Strands app — the
        # stub must accept that call so module execution completes.
        def add_middleware(self, *a, **k):
            return None

        # agent_server.py mounts the voice sub-app via ``app.mount(...)``
        def mount(self, *a, **k):
            return None

    fake_ag_ui_strands = types.ModuleType("ag_ui_strands")
    fake_ag_ui_strands.create_strands_app = lambda *a, **k: _FakeFastAPI()  # type: ignore[attr-defined]
    fake_ag_ui_strands.StrandsAgent = _AcceptsAnything  # type: ignore[attr-defined]
    fake_ag_ui_strands.StrandsAgentConfig = _AcceptsAnything  # type: ignore[attr-defined]
    fake_ag_ui_strands.ToolBehavior = _AcceptsAnything  # type: ignore[attr-defined]

    fake_strands = types.ModuleType("strands")
    fake_strands.Agent = _AcceptsAnything  # type: ignore[attr-defined]
    fake_strands.tool = lambda f=None, **_: (f if callable(f) else (lambda g: g))  # type: ignore[attr-defined]

    fake_hooks = types.ModuleType("strands.hooks")
    for name in (
        "AfterToolCallEvent",
        "BeforeInvocationEvent",
        "BeforeToolCallEvent",
        "HookProvider",
        "HookRegistry",
    ):
        setattr(fake_hooks, name, type(name, (), {}))

    fake_openai_mod = types.ModuleType("strands.models.openai")

    class _FakeOpenAIModel:
        def __init__(self, *args, **kwargs):
            self.args = args
            self.kwargs = kwargs

    fake_openai_mod.OpenAIModel = _FakeOpenAIModel  # type: ignore[attr-defined]
    fake_models = types.ModuleType("strands.models")

    # uvicorn is imported at module level but only invoked from ``main()``.
    if "uvicorn" not in sys.modules:
        fake_uvicorn = types.ModuleType("uvicorn")
        fake_uvicorn.run = lambda *a, **k: None  # type: ignore[attr-defined]
        sys.modules["uvicorn"] = fake_uvicorn

    # dotenv similarly only invoked at load_dotenv() call time.
    if "dotenv" not in sys.modules:
        fake_dotenv = types.ModuleType("dotenv")
        fake_dotenv.load_dotenv = lambda *a, **k: None  # type: ignore[attr-defined]
        sys.modules["dotenv"] = fake_dotenv

    # ``build_showcase_agent`` fails fast if OPENAI_API_KEY is missing;
    # set a dummy value so the module-level call succeeds under the stubs.
    import os as _os

    _os.environ.setdefault("OPENAI_API_KEY", "test-key-for-instrumentor-patch")

    # Drop any pre-existing strands / agent_server entries so the
    # import-order guard passes cleanly. After the guard runs, install
    # the stubs via a meta-path finder so the post-patch
    # ``from ag_ui_strands import ...`` lines resolve.
    sys.modules.pop("agent_server", None)
    for _stale in (
        "strands",
        "strands.hooks",
        "strands.models",
        "strands.models.openai",
        "ag_ui_strands",
    ):
        sys.modules.pop(_stale, None)

    _STUB_MAP = {
        "strands": fake_strands,
        "strands.hooks": fake_hooks,
        "strands.models": fake_models,
        "strands.models.openai": fake_openai_mod,
        "ag_ui_strands": fake_ag_ui_strands,
    }

    class _StubLoader:
        """Minimal loader that returns a pre-built module object."""

        def __init__(self, module):
            self._module = module

        def create_module(self, spec):
            return self._module

        def exec_module(self, module):
            # Module body already populated; nothing to execute.
            return None

    class _LazyStubFinder:
        """Serves stub modules for strands / ag_ui_strands names on demand.

        agent_server's ``_assert_strands_not_preimported()`` runs BEFORE
        any of these modules are referenced, so sys.modules is clean at
        guard time. The first post-patch ``from ag_ui_strands import ...``
        triggers this finder, which returns a proper ModuleSpec whose
        loader yields our pre-built stub.
        """

        @classmethod
        def find_spec(cls, name, path, target=None):
            mod = _STUB_MAP.get(name)
            if mod is None:
                return None
            import importlib.util as _u

            return _u.spec_from_loader(name, _StubLoader(mod))

    import importlib.util as _util

    spec = _util.find_spec("agent_server")
    if spec is None or spec.origin is None:
        pytest.skip("agent_server module not locatable on sys.path")

    sys.meta_path.insert(0, _LazyStubFinder)
    try:
        with open(spec.origin, "r", encoding="utf-8") as fh:
            source = fh.read()
        module_ns: dict = {
            "__name__": "agent_server_under_test",
            "__file__": spec.origin,
        }
        # Execute agent_server's source verbatim — no regex surgery. The
        # guard runs against an empty-of-strands ``sys.modules`` and passes;
        # downstream imports hit the lazy stub finder.
        exec(compile(source, spec.origin, "exec"), module_ns)
    finally:
        sys.meta_path.remove(_LazyStubFinder)

    from opentelemetry.instrumentation.threading import ThreadingInstrumentor

    # The replacement must return self.
    instance = ThreadingInstrumentor()
    assert instance.instrument() is instance


def test_import_order_guard_catches_preimported_strands():
    """agent_server.py contains a ``_assert_strands_not_preimported()``
    guard BEFORE the ThreadingInstrumentor patch. If strands was already
    imported (directly or transitively) above that line, the OTel patch
    would be too late — strands' Tracer may have already been constructed.

    Simulate the failure mode: pre-seed ``sys.modules['strands']``, then
    try to import agent_server, and verify the guard raises ``RuntimeError``
    (NOT ``AssertionError`` — the latter would silently disappear under
    ``python -O``).
    """
    import sys
    import types

    # Install a fake 'strands' before agent_server imports. In the real
    # failure mode this would be the genuine package that already ran
    # ThreadingInstrumentor().instrument() — here the presence alone is
    # what the guard checks.
    preexisting_strands = types.ModuleType("strands")
    sys.modules["strands"] = preexisting_strands

    # Ensure agent_server is re-imported fresh so the module-level
    # guard executes.
    sys.modules.pop("agent_server", None)

    try:
        # RuntimeError, NOT AssertionError — the guard is an explicit
        # ``raise`` so ``python -O`` doesn't strip it.
        with pytest.raises(RuntimeError, match="strands imported before"):
            import agent_server  # noqa: F401
    finally:
        # Cleanup: remove the fake strands so subsequent tests can
        # install their own stubs.
        sys.modules.pop("strands", None)
        sys.modules.pop("agent_server", None)


def test_real_strands_agent_signature_integration():
    """Integration-style test: when the real ``strands-agents`` package
    IS installed, construct a real ``strands.Agent`` via the shapes the
    conftest stubs cover. This catches drift between our stubs and the
    real signature — if strands renames an Agent kwarg, the stub still
    passes the unit tests but this integration test fails.

    Skipped gracefully when ``strands-agents`` isn't installed (the
    default in the unit-test venv; it's available in the Docker image
    and CI integration environments).
    """
    import sys
    import types

    # Don't touch the stub modules installed by conftest unless we're
    # actually going to use the real package. Probe without importing.
    try:
        import importlib.util

        spec = importlib.util.find_spec("strands")
    except Exception:
        spec = None

    if spec is None:
        pytest.skip("strands-agents not installed; integration test skipped")

    # If the stub is already in sys.modules, drop it so the real package
    # gets imported fresh.
    for mod_name in [
        "strands",
        "strands.hooks",
        "strands.models",
        "strands.models.openai",
    ]:
        mod = sys.modules.get(mod_name)
        if mod is not None and isinstance(mod, types.ModuleType) and not getattr(mod, "__file__", None):
            sys.modules.pop(mod_name, None)

    try:
        from strands import Agent  # type: ignore[import-not-found]
    except Exception as exc:  # pragma: no cover - defensive
        pytest.skip(f"strands package present but not importable cleanly: {exc}")
        return

    # The minimal constructor should at least accept a ``tools`` kwarg
    # (which our factory relies on). We don't need a real model here --
    # just verify the signature accepts the shape we use.
    try:
        agent = Agent(tools=[])  # type: ignore[call-arg]
    except TypeError as exc:
        # TypeError here == real strands Agent signature drifted from
        # what the factory passes in build_showcase_agent.
        pytest.fail(
            f"real strands.Agent no longer accepts the kwargs build_showcase_agent "
            f"relies on: {exc}"
        )
    except Exception:
        # Any other exception (e.g. model required) is fine — the point
        # is that the Agent class accepted the kwargs.
        pass
    else:
        assert agent is not None
