# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import importlib.util
import logging
from pathlib import Path
from typing import Any

import pytest
import torch
from torch import fx
from torch.fx.experimental.proxy_tensor import make_fx

import vllm.ir.op
from vllm.ir.op import RESERVED_PROVIDERS, IrOp, IrOpImpl


class CustomError(Exception):
    pass


@pytest.fixture
def custom_add_op(fake_vllm_ir):
    """Register ``_custom_add`` plus impl_a, impl_b, impl_even for this test."""

    @vllm.ir.register_op(allow_inplace=True)
    def _custom_add(x: torch.Tensor, y: torch.Tensor) -> torch.Tensor:
        return x + y

    @_custom_add.register_impl("impl_a")
    def impl_a(x: torch.Tensor, y: torch.Tensor) -> torch.Tensor:
        return x + y + 10

    @_custom_add.register_impl("impl_b", inplace=True)
    def impl_b(x: torch.Tensor, y: torch.Tensor) -> torch.Tensor:
        """Computes x+y+20"""
        x.add_(y)
        x.add_(20)
        return x

    @_custom_add.register_impl(
        "impl_even", supports_args=lambda x, y: x.size(1) % 2 == 0
    )
    def impl_even(x: torch.Tensor, y: torch.Tensor) -> torch.Tensor:
        return x + y + 50

    return _custom_add


def test_registration_overloads(fake_vllm_ir):
    assert all(
        n not in IrOp.registry for n in ["_custom_sub", "_custom_mul", "_custom_div"]
    )

    # Calling with decorator
    @vllm.ir.register_op()
    def _custom_sub(x: torch.Tensor, y: torch.Tensor) -> torch.Tensor:
        return x - y

    assert _custom_sub.name == "_custom_sub"
    assert _custom_sub is IrOp.registry["_custom_sub"]

    # Custom name
    @vllm.ir.register_op(name="_custom_mul")
    def custom_mul(x: torch.Tensor, y: torch.Tensor) -> torch.Tensor:
        return x * y

    assert custom_mul.name == "_custom_mul"
    assert custom_mul is IrOp.registry["_custom_mul"]

    # Direct construction does not register directly
    def _custom_div(x: torch.Tensor, y: torch.Tensor) -> torch.Tensor:
        return x / y

    custom_div = IrOp("_custom_div", _custom_div)
    assert custom_div.name == "_custom_div"
    assert "_custom_div" not in IrOp.registry

    # Duplicate op registration not allowed
    with pytest.raises(AssertionError):

        @vllm.ir.register_op
        def _custom_mul(x: torch.Tensor, y: torch.Tensor) -> torch.Tensor:
            return x * y - 100


def test_no_kw_only_args(fake_vllm_ir):
    # kw-only args not supported
    with pytest.raises(ValueError, match="keyword-only arguments"):

        @vllm.ir.register_op
        def _custom_kwarg_op(
            x: torch.Tensor, y: torch.Tensor, *, kwarg: int = 0
        ) -> torch.Tensor:
            return x + y + kwarg

    assert "_custom_kwarg_op" not in IrOp.registry


class TestIrOpCustomAdd:
    # Registration invariants
    def test_decorated_object(self, custom_add_op):
        """Make sure that referring directly to an op is correct"""
        _custom_add = custom_add_op
        assert isinstance(_custom_add, IrOp)
        assert "_custom_add" in IrOp.registry
        assert _custom_add is IrOp.registry["_custom_add"]

    def test_torch_op_is_registered(self, custom_add_op):
        _custom_add = custom_add_op
        torch_ops = getattr(torch.ops, vllm.ir.op.vllm_ir_torch_lib.ns)
        assert hasattr(torch_ops, "_custom_add")
        assert callable(torch_ops._custom_add.default)
        assert _custom_add.torch_op is torch_ops._custom_add.default

    # Semantic correctness
    def test_semantics_match_native(self, custom_add_op):
        _custom_add = custom_add_op
        x = torch.randn(4, 5)
        y = torch.randn(4, 5)

        # Calls native by default
        out = _custom_add(x, y)
        ref = x + y

        torch.testing.assert_close(out, ref)

    # -------------------------
    # Implementation registration
    # -------------------------

    def test_register_impl_is_non_intrusive(self, custom_add_op):
        _custom_add = custom_add_op

        @_custom_add.register_impl("dummy_provider")
        def dummy_impl(x: torch.Tensor, y: torch.Tensor) -> torch.Tensor:
            return x + y + 123

        assert "dummy_provider" in _custom_add.impls
        assert isinstance(_custom_add.impls["dummy_provider"], IrOpImpl)

        x = torch.ones(2, 2)
        y = torch.ones(2, 2)

        # Native semantics must still hold
        torch.testing.assert_close(_custom_add(x, y), x + y)

    def test_schema_contains_tensor_signature(self, custom_add_op):
        _custom_add = custom_add_op
        schema = _custom_add._schema_str

        assert "Tensor" in schema
        assert "-> Tensor" in schema

    # -------------------------
    # FX visibility
    # -------------------------

    @pytest.mark.parametrize("enable_torch_wrap", [True, False])
    @pytest.mark.parametrize("symbolic_trace", [True, False])
    @pytest.mark.parametrize("overload", ["default", "maybe_inplace"])
    def test_trace_sees_single_custom_op(
        self,
        custom_add_op,
        symbolic_trace: bool,
        enable_torch_wrap: bool,
        overload: str,
    ):
        _custom_add = custom_add_op
        op_fn = _custom_add if overload == "default" else _custom_add.maybe_inplace
        torch_op = (
            _custom_add.torch_op
            if overload == "default"
            else _custom_add.maybe_inplace.torch_op
        )

        def fn(x, y):
            return op_fn(x, y)

        def find_fn(target: Any, gm: fx.GraphModule):
            return gm.graph.find_nodes(op="call_function", target=target)

        with pytest.raises(CustomError), vllm.ir.enable_torch_wrap(enable_torch_wrap):
            if symbolic_trace:
                gm = torch.fx.symbolic_trace(fn)
            else:
                gm = make_fx(fn)(torch.randn(2, 2), torch.randn(2, 2))

            x1, y1 = torch.rand(5, 4), torch.rand(5, 4)
            out_fx = gm(x1, y1)
            out_eager = fn(x1, y1)

            # raise error to check enable_torch_wrap context restored correctly
            raise CustomError

        # check behavior matches eager in all cases
        torch.testing.assert_close(out_fx, out_eager)

        # check that IR nodes only appear if enable_torch_wrap=True
        ir_nodes = find_fn(torch_op, gm)
        if enable_torch_wrap:
            assert len(ir_nodes) == 1, gm.code
        else:
            assert len(ir_nodes) == 0, gm.code

        # with torch wrapping enabled (default), IR nodes appear
        if symbolic_trace:
            gm = torch.fx.symbolic_trace(fn)
        else:
            gm = make_fx(fn)(torch.randn(2, 2), torch.randn(2, 2))

        ir_nodes = find_fn(torch_op, gm)
        assert len(ir_nodes) == 1, gm.code


class TestIrOpImplDispatch:
    def test_register_impl(self, custom_add_op):
        _custom_add = custom_add_op
        assert "impl_a" in _custom_add.impls
        impl = _custom_add.impls["impl_a"]

        assert impl is _custom_add.impls["impl_a"]
        assert impl.op is _custom_add
        assert impl.provider == "impl_a"
        assert callable(impl.impl_fn)

        # Test duplicate registration rejected
        with pytest.raises(AssertionError):

            @_custom_add.register_impl("impl_a")
            def impl_a_dup(x: torch.Tensor, y: torch.Tensor) -> torch.Tensor:
                return x + y + 30

        # Check the original impl is still intact
        assert _custom_add.impls["impl_a"] is impl

        # Check support all args
        assert _custom_add.impls["impl_a"].supports_all_args
        assert _custom_add.impls["impl_b"].supports_all_args
        assert not _custom_add.impls["impl_even"].supports_all_args

    def test_reserved_provider_rejected(self, custom_add_op):
        _custom_add = custom_add_op
        for provider in RESERVED_PROVIDERS:
            with pytest.raises(AssertionError):

                @_custom_add.register_impl(provider)
                def bad_impl(x, y):
                    return x + y

    def test_set_priority_scoped(self, custom_add_op):
        _custom_add = custom_add_op
        assert _custom_add.get_priority() == []

        with _custom_add.set_priority(["impl_even", "impl_b"]):
            assert _custom_add.get_priority() == ["impl_even", "impl_b"]

            # Check nesting
            with _custom_add.set_priority(["impl_b"]):
                assert _custom_add.get_priority() == ["impl_b"]

            # Restored
            assert _custom_add.get_priority() == ["impl_even", "impl_b"]

            # Check that exception restores priority
            with pytest.raises(CustomError), _custom_add.set_priority(["impl_a"]):
                assert _custom_add.get_priority() == ["impl_a"]
                raise CustomError

            # Restored again
            assert _custom_add.get_priority() == ["impl_even", "impl_b"]

        # Restored to empty
        assert _custom_add.get_priority() == []

    @pytest.mark.parametrize(
        "default,override",
        [
            (["impl_even", "impl_b"], ["impl_a"]),
            (["impl_a"], ["impl_even", "impl_b"]),
        ],
    )
    def test_set_default_priority(
        self, custom_add_op, default: list[str], override: list[str]
    ):
        _custom_add = custom_add_op
        assert _custom_add.get_priority() == []

        _custom_add.set_default(default)
        assert _custom_add.get_priority() == default

        # Priority doesn't change after exiting the set_priority context.
        with _custom_add.set_priority(override):
            assert _custom_add.get_priority() == override
        assert _custom_add.get_priority() == default

        # Should override the previous default.
        _custom_add.set_default(override)
        assert _custom_add.get_priority() == override

    @pytest.mark.parametrize("overload", ["default", "maybe_inplace"])
    def test_dispatch_priority_order(self, custom_add_op, overload: str):
        _custom_add = custom_add_op
        op_fn = _custom_add if overload == "default" else _custom_add.maybe_inplace
        torch_op = (
            _custom_add.torch_op
            if overload == "default"
            else _custom_add.maybe_inplace.torch_op
        )

        x = torch.tensor(1, dtype=torch.int32)
        y = torch.tensor(2, dtype=torch.int32)

        with _custom_add.set_priority(["impl_b", "impl_a"]):
            assert _custom_add.dispatch(x, y) is _custom_add.impls["impl_b"]
            out1 = op_fn(x.clone(), y)
            out2 = torch_op(x.clone(), y)

            with _custom_add.set_priority(["impl_a"]):
                assert _custom_add.dispatch(x, y) is _custom_add.impls["impl_a"]
                out3 = op_fn(x.clone(), y)
                out4 = torch_op(x.clone(), y)

        # impl_b
        assert out1.item() == 1 + 2 + 20
        assert out2.item() == 1 + 2 + 20
        # impl_a
        assert out3.item() == 1 + 2 + 10
        assert out4.item() == 1 + 2 + 10

    def test_unsupported_impl_filtered(self, custom_add_op):
        _custom_add = custom_add_op

        @_custom_add.register_impl("impl_unsupported", supported=False)
        def impl_unsupported(x: torch.Tensor, y: torch.Tensor) -> torch.Tensor:
            return x + y + 999

        x = torch.tensor(1, dtype=torch.int32)
        y = torch.tensor(2, dtype=torch.int32)

        with _custom_add.set_priority(["impl_unsupported", "impl_a"]):
            assert _custom_add.get_priority() == ["impl_a"]
            out = _custom_add(x, y)

        # impl_unsupported skipped → impl_a
        assert out.item() == 1 + 2 + 10

    def test_supports_args_runtime_dispatch_and_warning(
        self, custom_add_op, caplog_vllm: pytest.LogCaptureFixture
    ):
        _custom_add = custom_add_op
        x1 = torch.ones((2, 2), dtype=torch.int32)
        y1 = torch.full((2, 2), 2, dtype=torch.int32)

        x2 = torch.ones((2, 3), dtype=torch.int32)
        y2 = torch.full((2, 3), 2, dtype=torch.int32)

        with (
            caplog_vllm.at_level(logging.WARNING),
            _custom_add.set_priority(["impl_even"]),
        ):
            # Test the warning about native fallback is logged (before even dispatching)
            assert len(caplog_vllm.records) == 1
            message = caplog_vllm.records[0].message
            assert "_custom_add" in message
            assert "fallback to native" in message
            assert "priority" in message

            # Check dispatching
            assert _custom_add.get_priority() == ["impl_even", "native"]
            assert _custom_add.dispatch(x1, y1) is _custom_add.impls["impl_even"]
            assert _custom_add.dispatch(x2, y2) is _custom_add.impls["native"]

            out1 = _custom_add(x1, y1)  # size(1) == 2 → impl_even
            out2 = _custom_add(x2, y2)  # size(1) == 3 → native fallback

        # no other warnings
        assert len(caplog_vllm.records) == 1
        assert torch.all(out1 == 1 + 2 + 50)
        assert torch.all(out2 == 1 + 2)

    def test_default_priority(
        self,
        custom_add_op,
        caplog_vllm: pytest.LogCaptureFixture,
        disable_log_dedup,
    ):
        _custom_add = custom_add_op
        # Make sure logs are not deduplicated to properly test the warning
        x = torch.tensor([3], dtype=torch.int32)
        y = torch.tensor([4], dtype=torch.int32)

        # No priority set → falls back to native
        assert _custom_add.get_priority() == []
        with caplog_vllm.at_level(logging.WARNING):
            # Native by default
            assert _custom_add.dispatch(x, y) is _custom_add.impls["native"]
            out = _custom_add(x, y)

        # Check dispatching to native by default
        assert out.item() == 3 + 4

        # Check warning
        assert len(caplog_vllm.records) == 2
        message = caplog_vllm.records[0].message.lower()
        assert "_custom_add" in message
        assert "priority not set" in message


@pytest.mark.parametrize("default", [True, False])
def test_set_default_torch_wrap(default: bool):
    """set_default_torch_wrap permanently flips the global flag."""
    original = vllm.ir.op._ENABLE_TORCH_WRAP
    try:
        vllm.ir.set_default_torch_wrap(default)
        assert vllm.ir.op._ENABLE_TORCH_WRAP is default

        # Flag doesn't change after exiting the enable_torch_wrap context.
        with vllm.ir.enable_torch_wrap(not default):
            assert vllm.ir.op._ENABLE_TORCH_WRAP is (not default)
        assert vllm.ir.op._ENABLE_TORCH_WRAP is default

        # Should override the previous default.
        vllm.ir.set_default_torch_wrap(not default)
        assert vllm.ir.op._ENABLE_TORCH_WRAP is (not default)
    finally:
        vllm.ir.op._ENABLE_TORCH_WRAP = original


@pytest.fixture
def custom_mm_op(fake_vllm_ir):
    """Fixture that registers ``_custom_mm`` (isolated by ``fake_vllm_ir``)."""

    @vllm.ir.register_op
    def _custom_mm(
        x: torch.Tensor, y: torch.Tensor, bias: torch.Tensor | None = None
    ) -> torch.Tensor:
        tmp = x @ y
        return tmp if bias is None else tmp + bias

    return _custom_mm


def test_default_args(custom_mm_op):
    _custom_mm = custom_mm_op

    # Test that default args are properly applied when dispatching and calling
    @_custom_mm.register_impl("impl_mm", supports_args=lambda x, y, bias=None: True)
    def impl_mm(
        x: torch.Tensor, y: torch.Tensor, bias: torch.Tensor | None = None
    ) -> torch.Tensor:
        tmp = x @ y
        return tmp + 50 if bias is None else tmp + bias + 100

    x1 = torch.tensor([1, 2], dtype=torch.int32)
    x2 = torch.tensor([3, 4], dtype=torch.int32)

    # Test that supports_args receives the defaulted args
    assert impl_mm.supports_args(x1, x2)
    with _custom_mm.set_priority(["impl_mm", "native"]):
        assert _custom_mm.dispatch(x1, x2) is impl_mm


def test_bad_impl_registrations(custom_mm_op):
    _custom_mm = custom_mm_op
    # Check bad schema
    with pytest.raises(ValueError, match="does not match native schema"):

        @_custom_mm.register_impl("impl_mm_bad_schema")
        def impl_mm_bad_schema(x: torch.Tensor, y: torch.Tensor) -> torch.Tensor:
            return x @ y - 1

    with pytest.raises(ValueError, match="does not match native schema"):

        @_custom_mm.register_impl("impl_mm_bad_schema_2")
        def impl_mm_bad_schema_2(
            x: torch.Tensor, y: torch.Tensor, b: torch.Tensor | None = None
        ) -> torch.Tensor:
            return x @ y + b - 2

    with pytest.raises(ValueError, match="does not match native schema"):

        @_custom_mm.register_impl("impl_mm_bad_schema_3")
        def impl_mm_bad_schema_3(
            x: torch.Tensor, y: torch.Tensor, bias: torch.Tensor
        ) -> torch.Tensor:
            return x @ y + bias - 5

    # check supports_args with incorrect params
    with pytest.raises(ValueError, match="supports_args must be a callable"):

        @_custom_mm.register_impl("impl_mm_bad_supports_args", supports_args=True)
        def impl_mm_bad_supports_args(
            x: torch.Tensor, y: torch.Tensor, bias: torch.Tensor | None = None
        ) -> torch.Tensor:
            return x @ y + 10

    with pytest.raises(ValueError, match="number of parameters"):

        @_custom_mm.register_impl(
            "impl_mm_bad_supports_args_2", supports_args=lambda x, y: True
        )
        def impl_mm_bad_supports_args(
            x: torch.Tensor, y: torch.Tensor, bias: torch.Tensor | None = None
        ) -> torch.Tensor:
            return x @ y + 10

    with pytest.raises(ValueError, match="keyword-only parameters"):

        @_custom_mm.register_impl(
            "impl_mm_bad_supports_args_3", supports_args=lambda x, y, *, b: True
        )
        def impl_mm_bad_supports_args_2(
            x: torch.Tensor, y: torch.Tensor, bias: torch.Tensor | None = None
        ) -> torch.Tensor:
            return x @ y + 20

    with pytest.raises(ValueError, match="does not match native parameter"):

        @_custom_mm.register_impl(
            "impl_mm_bad_supports_args_4", supports_args=lambda x, y, b: True
        )
        def impl_mm_bad_supports_args_4(
            x: torch.Tensor, y: torch.Tensor, bias: torch.Tensor | None = None
        ) -> torch.Tensor:
            return x @ y + 30

    with pytest.raises(ValueError, match="does not match native default"):

        @_custom_mm.register_impl(
            "impl_mm_bad_supports_args_5", supports_args=lambda x, y, bias=1: True
        )
        def impl_mm_bad_supports_args_5(
            x: torch.Tensor, y: torch.Tensor, bias: torch.Tensor | None = None
        ) -> torch.Tensor:
            return x @ y + 40

    # With fixture, each test gets a fresh op with only "native" impl
    assert set(_custom_mm.impls.keys()) == {"native"}


IMPL_OOT_SRC = """
import torch

@_custom_mm.register_impl("impl_mm_oot")
def impl_mm_oot(
    x: torch.Tensor, y: torch.Tensor, bias: torch.Tensor | None = None
) -> torch.Tensor:
    return x @ y - 99
"""


def load_custom_mm_module(file_path: Path, custom_mm_op):
    spec = importlib.util.spec_from_file_location("_custom_mm_oot", file_path)
    assert spec is not None
    module = importlib.util.module_from_spec(spec)

    # Inject the variable into the module's global namespace
    # This allows the @_custom_mm.register_impl decorator to work
    module._custom_mm = custom_mm_op  # type: ignore[attr-defined]

    # Execute the file; this triggers the decorator
    assert spec.loader is not None
    spec.loader.exec_module(module)
    return module


def test_uuid_and_oot(custom_mm_op, tmp_path: Path):
    _custom_mm = custom_mm_op
    file_path = tmp_path / "_custom_mm_oot.py"
    file_path.write_text(IMPL_OOT_SRC)

    assert "impl_mm_oot" not in _custom_mm.impls
    _ = load_custom_mm_module(file_path, _custom_mm)
    assert "impl_mm_oot" in _custom_mm.impls

    uuid = _custom_mm.impls["impl_mm_oot"].uuid()
    del _custom_mm.impls["impl_mm_oot"]

    # Replace file source
    file_path.write_text(IMPL_OOT_SRC + " # added file source")
    assert "impl_mm_oot" not in _custom_mm.impls
    _ = load_custom_mm_module(file_path, _custom_mm)
    assert "impl_mm_oot" in _custom_mm.impls

    uuid1 = _custom_mm.impls["impl_mm_oot"].uuid()
    assert uuid1 != uuid
    del _custom_mm.impls["impl_mm_oot"]

    # Back to original
    file_path.write_text(IMPL_OOT_SRC)
    assert "impl_mm_oot" not in _custom_mm.impls
    _ = load_custom_mm_module(file_path, _custom_mm)
    assert "impl_mm_oot" in _custom_mm.impls

    uuid2 = _custom_mm.impls["impl_mm_oot"].uuid()
    assert uuid2 == uuid
    assert uuid2 != uuid1
    del _custom_mm.impls["impl_mm_oot"]


def _test_native(x: torch.Tensor, y: torch.Tensor) -> torch.Tensor:
    return x + y


def _make_op_with_generator(name: str = "_ig_test"):
    op = IrOp(name, _test_native)

    @op.register_input_generator
    def _gen(n: int = 4):
        x = torch.randn(n, 3)
        y = torch.randn(n, 3)
        return x, y

    return op


def _test_native_single(x: torch.Tensor) -> torch.Tensor:
    return x


class TestInputGenerator:
    def test_no_input_generator_by_default(self):
        op = IrOp("_ig_test_no_gen", _test_native_single)
        assert not op.has_input_generator

    def test_register_input_generator(self):
        op = _make_op_with_generator("_ig_test_reg")
        assert op.has_input_generator

    def test_generate_inputs_returns_tuple(self):
        op = _make_op_with_generator("_ig_test_tuple")
        result = op.generate_inputs(n=2)
        assert isinstance(result, tuple)
        assert len(result) == 2
        assert result[0].shape == (2, 3)
        assert result[1].shape == (2, 3)

    def test_generate_inputs_default_kwargs(self):
        op = _make_op_with_generator("_ig_test_default")
        result = op.generate_inputs()
        assert result[0].shape == (4, 3)

    def test_generate_inputs_without_registration_raises(self):
        op = IrOp("_ig_test_no_gen_raises", _test_native_single)
        with pytest.raises(RuntimeError, match="No input generator"):
            op.generate_inputs()


class TestTolerance:
    def test_override_and_get_tolerance(self):
        op = IrOp("_tol_test", _test_native)

        tol = op.get_tolerance(torch.float32)
        assert tol == {"atol": 1e-5, "rtol": 1.3e-6}

        op.override_tolerance(torch.float32, atol=0.1, rtol=0.2)
        assert op.get_tolerance(torch.float32) == {"atol": 0.1, "rtol": 0.2}
        assert op.get_tolerance(torch.float16) == {"atol": 1e-3, "rtol": 1e-3}

    def test_get_tolerance_raises_for_unknown_dtype(self):
        op = IrOp("_tol_test_unknown", _test_native)
        with pytest.raises(ValueError, match="No tolerance defined"):
            op.get_tolerance(torch.complex64)


def test_naming_validation(fake_vllm_ir):
    """Test that op and provider names are validated ([a-z_][a-z_0-9]*)."""

    # Valid op and provider names
    @vllm.ir.register_op
    def _valid_name_123(x: torch.Tensor) -> torch.Tensor:
        return x

    @_valid_name_123.register_impl("valid_provider_123")
    def valid_impl(x: torch.Tensor) -> torch.Tensor:
        return x + 1

    # Invalid op names should fail
    with pytest.raises(ValueError, match="name.*invalid"):

        @vllm.ir.register_op
        def InvalidName(x: torch.Tensor) -> torch.Tensor:
            return x

    with pytest.raises(ValueError, match="name.*invalid"):

        @vllm.ir.register_op(name="123invalid")
        def some_func(x: torch.Tensor) -> torch.Tensor:
            return x

    # Invalid provider names should fail
    with pytest.raises(ValueError, match="name.*invalid"):

        @_valid_name_123.register_impl("Invalid-Provider")
        def invalid_impl(x: torch.Tensor) -> torch.Tensor:
            return x + 1


def test_registration_stack_traces(fake_vllm_ir):
    """Test that stack traces are captured for ops and impls."""

    @vllm.ir.register_op
    def _test_stack(x: torch.Tensor) -> torch.Tensor:
        return x

    @_test_stack.register_impl("test_provider")
    def test_impl(x: torch.Tensor) -> torch.Tensor:
        return x + 1

    # Verify op stack trace
    assert hasattr(_test_stack, "_registration_stack")
    assert len(_test_stack._registration_stack) > 0
    op_stack_str = "".join(_test_stack._registration_stack)
    assert "test_op.py" in op_stack_str
    # Last frame should be the decorator in user code, not internal decorator logic
    assert "@vllm.ir.register_op" in _test_stack._registration_stack[-1]
    assert "return decorator(f)" not in op_stack_str

    # Verify impl stack trace
    impl = _test_stack.impls["test_provider"]
    assert hasattr(impl, "_registration_stack")
    assert len(impl._registration_stack) > 0
    impl_stack_str = "".join(impl._registration_stack)
    assert "test_op.py" in impl_stack_str
    # Last frame should be the decorator in user code
    assert '@_test_stack.register_impl("test_provider")' in impl._registration_stack[-1]


def test_op_repr_uses_docstring(fake_vllm_ir):
    """Test that __str__ uses the function's docstring and __repr__ is simple."""

    @vllm.ir.register_op
    def _test_repr_with_doc(x: torch.Tensor) -> torch.Tensor:
        """First line of docstring.

        Additional details here.
        """
        return x

    @vllm.ir.register_op
    def _test_repr_no_doc(x: torch.Tensor) -> torch.Tensor:
        return x

    # __str__ with docstring: uses first line only
    str_with = str(_test_repr_with_doc)
    assert "IrOp('_test_repr_with_doc')" in str_with
    assert "First line of docstring." in str_with
    assert "Additional details" not in str_with

    # __str__ without docstring: simple format
    assert str(_test_repr_no_doc) == "IrOp('_test_repr_no_doc')"

    # __repr__ should be simple for both
    assert repr(_test_repr_with_doc) == "IrOp('_test_repr_with_doc')"
    assert repr(_test_repr_no_doc) == "IrOp('_test_repr_no_doc')"


def test_vllm_ir_fixture(fake_vllm_ir):
    """Test that the fake_vllm_ir fixture provides test isolation."""

    @vllm.ir.register_op
    def _test_fixture(x: torch.Tensor) -> torch.Tensor:
        return x

    assert "_test_fixture" in IrOp.registry
    # Fixture will automatically clean up after test
