"""SDK typing surface regression tests.

Prevents recurrence of issues from engineering_typing_followup_report.md:
- T1: Missing imports for annotation types
- T2: **kwargs in public signatures
- T3: -> Any return types
- T4: Paginator/retrieve type precision
- T5: Shared merge architecture wired into code gen
- T6: Static typing gate (mypy)

These tests validate the generated Python SDK code, not documentation.
"""

from __future__ import annotations

import re
from pathlib import Path

import pytest

V2_DIR = Path(__file__).parent.parent.parent
PYTHON_DIR = V2_DIR / "python"
API_DIR = PYTHON_DIR / "goodmem" / "api"
BASE_DIR = PYTHON_DIR / "goodmem" / "_api_base"
OVERRIDES = PYTHON_DIR / "goodmem" / "_overrides.py"
PAGINATION = PYTHON_DIR / "goodmem" / "pagination.py"
CLIENT_GEN = V2_DIR / "client_gen.sh"


class TestNoKwargsInSignatures:
    """No **kwargs in any public function signature."""

    def test_convenience_layer(self):
        for f in API_DIR.glob("*.py"):
            if f.name == "__init__.py":
                continue
            for i, line in enumerate(f.read_text().splitlines(), 1):
                if re.match(r"\s+(async )?def \w+\(", line) and "**kwargs" in line:
                    pytest.fail(f"api/{f.name}:{i} has **kwargs")

    def test_base_classes(self):
        for f in BASE_DIR.glob("*.py"):
            if f.name == "__init__.py":
                continue
            for i, line in enumerate(f.read_text().splitlines(), 1):
                if re.match(r"\s+(async )?def \w+\(", line) and "**kwargs" in line:
                    pytest.fail(f"_api_base/{f.name}:{i} has **kwargs")


class TestNoAnyReturns:
    """No -> Any return type on any public method."""

    def test_convenience_layer(self):
        for f in API_DIR.glob("*.py"):
            if f.name == "__init__.py":
                continue
            for i, line in enumerate(f.read_text().splitlines(), 1):
                if re.match(r"\s+(async )?def \w+\(", line) and "-> Any:" in line:
                    pytest.fail(f"api/{f.name}:{i} returns Any")

    def test_base_classes(self):
        for f in BASE_DIR.glob("*.py"):
            if f.name == "__init__.py":
                continue
            for i, line in enumerate(f.read_text().splitlines(), 1):
                if re.match(r"\s+(async )?def \w+\(", line) and "-> Any:" in line:
                    pytest.fail(f"_api_base/{f.name}:{i} returns Any")

    def test_overrides(self):
        for i, line in enumerate(OVERRIDES.read_text().splitlines(), 1):
            if re.match(r".*def \w+\(.*\) -> Any:", line):
                pytest.fail(f"_overrides.py:{i} returns Any")


class TestAllParamsTypeAnnotated:
    """Every param on every public method must have a type annotation."""

    def _check_methods(self, directory, label):
        import importlib
        import inspect
        for f in directory.glob("*.py"):
            if f.name == "__init__.py":
                continue
            mod_name = f"goodmem.{'api' if 'api' == directory.name else '_api_base'}.{f.stem}"
            mod = importlib.import_module(mod_name)
            for cls_name in dir(mod):
                cls = getattr(mod, cls_name)
                if not isinstance(cls, type):
                    continue
                for method_name, method in cls.__dict__.items():
                    if method_name.startswith("_"):
                        continue
                    if not callable(method):
                        continue
                    sig = inspect.signature(method)
                    for pname, param in sig.parameters.items():
                        if pname == "self":
                            continue
                        if param.kind == inspect.Parameter.VAR_KEYWORD:
                            continue
                        assert param.annotation is not inspect.Parameter.empty, (
                            f"{label}/{f.name}: {cls_name}.{method_name}() "
                            f"param '{pname}' has no type annotation"
                        )
                    assert sig.return_annotation is not inspect.Signature.empty, (
                        f"{label}/{f.name}: {cls_name}.{method_name}() "
                        f"has no return type annotation"
                    )

    def test_base_classes(self):
        self._check_methods(BASE_DIR, "_api_base")

    def test_convenience_layer(self):
        self._check_methods(API_DIR, "api")


class TestPageTypePrecision:
    """Page must carry item type param."""

    def test_page_return_has_type_params(self):
        for f in API_DIR.glob("*.py"):
            text = f.read_text()
            for match in re.finditer(r"-> (Async)?Page(?!\[)", text):
                pytest.fail(f"api/{f.name} has bare Page without type params")

    def test_retrieve_has_overloads(self):
        text = (API_DIR / "memories.py").read_text()
        assert "@overload" in text, "memories.py missing @overload for retrieve"


class TestGeneratorArchitecture:
    """Emitter uses IR for return types; mypy gate exists."""

    def test_emitter_uses_ir_response_type(self):
        text = (V2_DIR / "_client_gen" / "convenience_emitter.py").read_text()
        assert "_ir_response_type" in text

    def test_mypy_gate_in_client_gen(self):
        text = CLIENT_GEN.read_text()
        assert "mypy" in text, "client_gen.sh missing mypy step"


class TestAsyncMethodConsistency:
    """Async API classes must use async def for methods whose base class is async."""

    def _async_api_classes(self):
        """Yield (module_path, class) for every Async*API convenience class."""
        import importlib
        for f in API_DIR.glob("*.py"):
            if f.name == "__init__.py":
                continue
            mod = importlib.import_module(f"goodmem.api.{f.stem}")
            for name in dir(mod):
                if name.startswith("Async") and name.endswith("API"):
                    yield f.name, getattr(mod, name)

    def test_async_list_is_coroutinefunction(self):
        """Async list() must be async def — not sync def returning AsyncPage."""
        import inspect
        for fname, cls in self._async_api_classes():
            method = getattr(cls, "list", None)
            if method is None:
                continue
            # Only check classes that override list (have it in their own __dict__)
            if "list" not in cls.__dict__:
                continue
            assert inspect.iscoroutinefunction(method), (
                f"{cls.__name__}.list() in api/{fname} is not async def"
            )

    def test_async_overrides_match_base(self):
        """Every method overridden in Async*API must be async if the base is async."""
        import inspect
        for fname, cls in self._async_api_classes():
            bases = cls.__mro__[1:]  # skip cls itself
            for method_name in cls.__dict__:
                if method_name.startswith("_"):
                    continue
                method = getattr(cls, method_name)
                if not callable(method):
                    continue
                # Find the base method
                for base in bases:
                    base_method = base.__dict__.get(method_name)
                    if base_method is not None:
                        if inspect.iscoroutinefunction(base_method):
                            assert inspect.iscoroutinefunction(method), (
                                f"{cls.__name__}.{method_name}() in api/{fname} "
                                f"overrides async base but is not async def"
                            )
                        break


class TestDocstringReturnTypes:
    """Docstring Returns: must match actual return type annotation."""

    _UNWRAPPED_LISTS = [
        ("goodmem._api_base.embedders", "EmbeddersAPIBase", "list"),
        ("goodmem._api_base.llms", "LlmsAPIBase", "list"),
        ("goodmem._api_base.rerankers", "RerankersAPIBase", "list"),
        ("goodmem._api_base.apikeys", "ApikeysAPIBase", "list"),
    ]

    @pytest.mark.parametrize(
        "module_path,class_name,method_name",
        _UNWRAPPED_LISTS,
        ids=[f"{c[1]}.{c[2]}" for c in _UNWRAPPED_LISTS],
    )
    def test_docstring_matches_return_annotation(self, module_path, class_name, method_name):
        import importlib
        import inspect
        mod = importlib.import_module(module_path)
        cls = getattr(mod, class_name)
        method = getattr(cls, method_name)
        sig = inspect.signature(method)
        ret_str = str(sig.return_annotation)
        docstring = method.__doc__ or ""
        # Docstring should say "list[" for unwrapped list methods, not the wrapper type
        assert "Returns:" in docstring, f"{class_name}.{method_name} docstring has no Returns:"
        returns_section = docstring[docstring.index("Returns:"):]
        assert "List" not in returns_section.split("\n")[1] or "list[" in returns_section, (
            f"{class_name}.{method_name} docstring Returns: doesn't match "
            f"actual return type {ret_str}"
        )

    # Methods whose docstring Returns: must NOT reference the raw spec response
    # model (e.g. MemoryListResponse, ListSpacesResponse) because the emitter
    # overrides the return type to Page[T] or Stream[T].
    _OVERRIDDEN_RETURNS = [
        ("goodmem._api_base.memories", "MemoriesAPIBase", "list", "Page[Memory]"),
        ("goodmem._api_base.memories", "MemoriesAPIBase", "retrieve", "Stream[RetrieveMemoryEvent]"),
        ("goodmem._api_base.spaces", "SpacesAPIBase", "list", "Page[Space]"),
    ]

    @pytest.mark.parametrize(
        "module_path,class_name,method_name,expected_return",
        _OVERRIDDEN_RETURNS,
        ids=[f"{c[1]}.{c[2]}" for c in _OVERRIDDEN_RETURNS],
    )
    def test_docstring_return_uses_overridden_type(
        self, module_path, class_name, method_name, expected_return
    ):
        """Docstring Returns: must use the SDK return type, not the raw spec response model.

        Regression: the emitter used to copy the OpenAPI response model name
        (e.g. MemoryListResponse) instead of the resolved type (Page[Memory]).
        """
        import importlib
        mod = importlib.import_module(module_path)
        cls = getattr(mod, class_name)
        method = getattr(cls, method_name)
        docstring = method.__doc__ or ""
        assert "Returns:" in docstring, f"{class_name}.{method_name} has no Returns: in docstring"
        returns_section = docstring[docstring.index("Returns:"):]
        returns_line = returns_section.split("\n")[1].strip()
        assert expected_return in returns_line, (
            f"{class_name}.{method_name} docstring Returns: says '{returns_line}' "
            f"but should contain '{expected_return}'"
        )


class TestTopLevelExports:
    """Key types importable from goodmem top-level."""

    def test_version_exported(self):
        from goodmem import __version__
        assert isinstance(__version__, str)
        assert len(__version__) > 0

    def test_based_on_goodmem_commit_exported(self):
        from goodmem import __based_on_goodmem_commit__
        assert isinstance(__based_on_goodmem_commit__, str)
        assert len(__based_on_goodmem_commit__) > 0

    def test_version_matches_pyproject(self):
        from goodmem import __version__
        import tomllib
        from pathlib import Path
        pyproject = Path(__file__).parent.parent / "pyproject.toml"
        with open(pyproject, "rb") as f:
            data = tomllib.load(f)
        assert __version__ == data["project"]["version"]

    def test_page_exported(self):
        from goodmem import Page, AsyncPage
        assert Page is not None
        assert AsyncPage is not None

    def test_memory_creation_request_exported(self):
        from goodmem import MemoryCreationRequest
        assert MemoryCreationRequest is not None

    def test_error_classes_exported(self):
        from goodmem import NotFoundError, ConflictError, BadRequestError
        assert NotFoundError is not None


class TestStaleNextTokenStripped:
    """Pagination lambda must not carry stale next_token from initial params."""

    def test_second_page_no_stale_token(self, mock_client_factory):
        """When resuming with next_token, subsequent pages should not include the stale token."""
        import httpx
        from tests.conftest import SPACE_RESPONSE

        calls = []

        def handler(request: httpx.Request) -> httpx.Response:
            calls.append(dict(request.url.params))
            if len(calls) == 1:
                return httpx.Response(200, json={
                    "spaces": [SPACE_RESPONSE],
                    "nextToken": "page3_cursor",
                })
            return httpx.Response(200, json={
                "spaces": [{**SPACE_RESPONSE, "spaceId": "space-3"}],
            })

        client = mock_client_factory(handler)
        # Resume from page2 — first fetch uses next_token=page2_cursor
        items = list(client.spaces.list(next_token="page2_cursor"))
        assert len(items) == 2
        # Second call (auto-pagination) should have the NEW token, not the stale one
        assert calls[1]["next_token"] == "page3_cursor", (
            f"Expected page3_cursor but got: {calls[1].get('next_token')}"
        )


class TestCleanup:
    """Stale references removed."""

    def test_no_post_processor_bypass_in_overrides(self):
        assert "post_processor bypass" not in OVERRIDES.read_text()

    def test_no_post_processor_bypass_in_readme(self):
        readme = PYTHON_DIR / "README.md"
        assert "post-processor bypass" not in readme.read_text()

    def test_rest_equivalency_deleted(self):
        assert not (V2_DIR / "_doc_gen" / "rest_equivalency.py").exists()

    def test_no_stale_imports_of_rest_equivalency(self):
        for f in (V2_DIR / "_doc_gen").glob("*.py"):
            if f.name == "__init__.py":
                continue
            assert "from _doc_gen.rest_equivalency" not in f.read_text(), (
                f"{f.name} still imports from deleted rest_equivalency"
            )
