"""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"
MODELS_DIR = PYTHON_DIR / "goodmem" / "models"
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 TestNoBareCollectionTypes:
    """list and dict params must have type parameters (e.g. list[str], not bare list)."""

    def test_no_bare_list_in_base_signatures(self):
        """No bare 'list' without item type in base class method signatures."""
        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):
                    # Check for bare 'list' not followed by '[' in param annotations
                    # Match ': list ' or ': list,' or ': list|' but not ': list['
                    if re.search(r":\s*list\s*[,|)\s]", line) or re.search(r":\s*list\s*\|", line):
                        pytest.fail(
                            f"_api_base/{f.name}:{i} has bare 'list' without type param"
                        )

    @pytest.mark.parametrize("layer,directory", [("_api_base", BASE_DIR), ("api", API_DIR)])
    def test_no_bare_dict_in_signatures(self, layer, directory):
        """No bare 'dict' without type params in method signatures (should be dict[str, str])."""
        for f in directory.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):
                    if re.search(r":\s*dict\s*[,|)\s]", line) or re.search(r":\s*dict\s*\|", line):
                        pytest.fail(
                            f"{layer}/{f.name}:{i} has bare 'dict' without type params"
                        )


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]"),
    ]

    _ASYNC_OVERRIDDEN_RETURNS = [
        ("goodmem._api_base.memories", "AsyncMemoriesAPIBase", "list", "AsyncPage[Memory]"),
        ("goodmem._api_base.memories", "AsyncMemoriesAPIBase", "retrieve", "AsyncStream[RetrieveMemoryEvent]"),
        ("goodmem._api_base.memories", "AsyncMemoriesAPIBase", "pages", "AsyncPage[MemoryPageImage]"),
        ("goodmem._api_base.spaces", "AsyncSpacesAPIBase", "list", "AsyncPage[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}'"
        )

    @pytest.mark.parametrize(
        "module_path,class_name,method_name,expected_return",
        _ASYNC_OVERRIDDEN_RETURNS,
        ids=[f"{c[1]}.{c[2]}" for c in _ASYNC_OVERRIDDEN_RETURNS],
    )
    def test_async_docstring_uses_async_types(
        self, module_path, class_name, method_name, expected_return
    ):
        """Async class docstrings must reference AsyncPage/AsyncStream, not Page/Stream.

        Regression: the emitter copied sync docstrings verbatim into async classes,
        so AsyncMemoriesAPIBase.list() said 'Page[Memory]' instead of 'AsyncPage[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_stream_types_exported(self):
        from goodmem import RetrieveMemoryStream, AsyncRetrieveMemoryStream
        assert RetrieveMemoryStream is not None
        assert AsyncRetrieveMemoryStream 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 TestAddModelImports:
    """Unit tests for the emitter's _add_model_imports helper.

    Regression: generic types like list[SpaceKey] were not imported because the
    old logic only checked _t[0].isupper(), which is False for 'list[...]'.
    """

    def _call(self, type_str: str) -> set[str]:
        from _client_gen.python.emitters import _add_model_imports
        imports: set[str] = set()
        _add_model_imports(imports, type_str)
        return imports

    def test_plain_model_type(self):
        result = self._call("SpaceKey")
        assert "from goodmem.models.space_key import SpaceKey" in result

    def test_generic_list_model(self):
        result = self._call("list[SpaceEmbedderConfig]")
        assert "from goodmem.models.space_embedder_config import SpaceEmbedderConfig" in result

    def test_generic_list_builtin(self):
        result = self._call("list[str]")
        assert result == set()

    def test_builtin_types_ignored(self):
        for t in ("str", "int", "float", "bool", "dict", "list", "bytes"):
            assert self._call(t) == set(), f"{t} should not generate an import"

    def test_empty_string(self):
        assert self._call("") == set()

    def test_none_like(self):
        assert self._call("") == set()

    def test_multiple_generics(self):
        """e.g. dict[str, SpaceKey] should import SpaceKey."""
        result = self._call("dict[str, SpaceKey]")
        assert "from goodmem.models.space_key import SpaceKey" in result
        assert len(result) == 1


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"
            )


# ---------------------------------------------------------------------------
# Regression: async @overload for streaming (TYPE-03)
# ---------------------------------------------------------------------------

class TestAsyncOverloads:
    """Async streaming methods must have @overload signatures for type
    narrowing based on stream=True/False, just like their sync counterparts."""

    def test_async_memories_retrieve_has_overloads(self):
        import inspect
        from goodmem._api_base.memories import AsyncMemoriesAPIBase
        source = inspect.getsource(AsyncMemoriesAPIBase)
        overload_count = source.count("@overload")
        assert overload_count >= 2, (
            f"AsyncMemoriesAPIBase should have at least 2 @overload signatures "
            f"for retrieve(), found {overload_count}"
        )


# ---------------------------------------------------------------------------
# Regression: required field overrides (PROTO-04, PROTO-06, BUG-06)
# ---------------------------------------------------------------------------

class TestRequiredFieldOverrides:
    """Fields overridden to required in _FIELD_OVERRIDES must be enforced."""

    def test_system_info_version_required(self):
        from goodmem.models.system_info_response import SystemInfoResponse
        assert SystemInfoResponse.model_fields["version"].is_required(), (
            "SystemInfoResponse.version must be required (server always returns it)"
        )

    def test_ocr_content_required(self):
        from goodmem.models.ocr_document_request import OcrDocumentRequest
        assert OcrDocumentRequest.model_fields["content"].is_required(), (
            "OcrDocumentRequest.content must be required (base64 doc bytes)"
        )

    def test_ocr_content_rejects_none(self):
        from pydantic import ValidationError
        from goodmem.models.ocr_document_request import OcrDocumentRequest
        with pytest.raises(ValidationError):
            OcrDocumentRequest(content=None)

    def test_admin_purge_older_than_required(self):
        from goodmem.models.admin_purge_jobs_request import AdminPurgeJobsRequest
        assert AdminPurgeJobsRequest.model_fields["older_than"].is_required(), (
            "AdminPurgeJobsRequest.older_than must be required (server rejects missing)"
        )

    def test_memory_page_image_status_required(self):
        from goodmem.models.memory import Memory
        assert Memory.model_fields["page_image_status"].is_required(), (
            "Memory.page_image_status must be required (server always returns it)"
        )

    def test_memory_page_image_count_required(self):
        from goodmem.models.memory import Memory
        assert Memory.model_fields["page_image_count"].is_required(), (
            "Memory.page_image_count must be required (server always returns it)"
        )


# ---------------------------------------------------------------------------
# Regression: batch_delete extra fields (BUG-05)
# ---------------------------------------------------------------------------

class TestBatchDeleteFields:
    """BatchMemoryResponse/Result must include delete-specific fields."""

    def test_batch_memory_response_has_total_deleted(self):
        from goodmem.models.batch_memory_response import BatchMemoryResponse
        assert "total_deleted" in BatchMemoryResponse.model_fields

    def test_batch_memory_result_has_delete_fields(self):
        from goodmem.models.batch_memory_result import BatchMemoryResult
        assert "request_index" in BatchMemoryResult.model_fields
        assert "deleted_count" in BatchMemoryResult.model_fields

    def test_deserialize_delete_response(self):
        from goodmem.models.batch_memory_response import BatchMemoryResponse
        resp = BatchMemoryResponse.model_validate({
            "results": [
                {"success": True, "requestIndex": 0, "deletedCount": 3},
                {"success": False, "requestIndex": 1, "deletedCount": 0,
                 "error": {"code": 404, "message": "not found"}},
            ],
            "totalDeleted": 3,
        })
        assert resp.total_deleted == 3
        assert resp.results[0].request_index == 0
        assert resp.results[0].deleted_count == 3


# ---------------------------------------------------------------------------
# Regression: _base.py create_model typing (TYPE-06)
# ---------------------------------------------------------------------------

class TestBaseCreateModelTyping:
    """_prepare_create_payload create_model must be type[BaseModel], not bare type."""

    def test_create_model_typed_as_base_model(self):
        source = (V2_DIR / "python" / "goodmem" / "_base.py").read_text()
        assert "type[BaseModel]" in source, (
            "_base.py create_model should be type[BaseModel] | None, not bare type"
        )


# ---------------------------------------------------------------------------
# Regression: no label.* in docstrings (DOC-04)
# ---------------------------------------------------------------------------

class TestNoLabelWildcardInDocstrings:
    """Generated docstrings must use 'labels' param name, not 'label.*'."""

    @pytest.mark.parametrize("path", sorted(BASE_DIR.glob("*.py")))
    def test_no_label_wildcard_in_docstrings(self, path: Path):
        if path.name == "__init__.py":
            pytest.skip()
        source = path.read_text()
        in_docstring = False
        for i, line in enumerate(source.splitlines(), 1):
            stripped = line.strip()
            if '"""' in stripped:
                count = stripped.count('"""')
                if count == 1:
                    in_docstring = not in_docstring
                continue
            if in_docstring and "label.*" in line:
                pytest.fail(
                    f"{path.name}:{i} has 'label.*' in docstring. "
                    f"Should use 'labels' as the parameter name."
                )


# ---------------------------------------------------------------------------
# Regression: no bare dict/list in model annotations
# ---------------------------------------------------------------------------

class TestListReturnTypes:
    """Non-paginated convenience list() must return list[ItemType], not the wrapper."""

    @pytest.mark.parametrize("cls_path,item_type_name", [
        ("goodmem.api.embedders.EmbeddersAPI", "EmbedderResponse"),
        ("goodmem.api.embedders.AsyncEmbeddersAPI", "EmbedderResponse"),
        ("goodmem.api.llms.LlmsAPI", "LLMResponse"),
        ("goodmem.api.llms.AsyncLlmsAPI", "LLMResponse"),
        ("goodmem.api.rerankers.RerankersAPI", "RerankerResponse"),
        ("goodmem.api.rerankers.AsyncRerankersAPI", "RerankerResponse"),
    ])
    def test_list_returns_unwrapped_type(self, cls_path, item_type_name):
        import inspect as _inspect
        module_path, cls_name = cls_path.rsplit(".", 1)
        mod = __import__(module_path, fromlist=[cls_name])
        cls = getattr(mod, cls_name)
        ann = _inspect.signature(cls.list).return_annotation
        ann_str = str(ann) if not isinstance(ann, str) else ann
        assert f"list[{item_type_name}]" in ann_str, (
            f"{cls_path}.list() should return list[{item_type_name}], got {ann_str}"
        )


class TestConvenienceDocstringsExist:
    """Generated convenience overrides must have docstrings (DOC-01/DOC-03)."""

    @pytest.mark.parametrize("cls_path,method", [
        ("goodmem.api.memories.MemoriesAPI", "create"),
        ("goodmem.api.memories.MemoriesAPI", "get"),
        ("goodmem.api.memories.MemoriesAPI", "list"),
        ("goodmem.api.memories.MemoriesAPI", "retrieve"),
        ("goodmem.api.memories.MemoriesAPI", "batch_create"),
        ("goodmem.api.spaces.SpacesAPI", "create"),
        ("goodmem.api.spaces.SpacesAPI", "list"),
        ("goodmem.api.llms.LlmsAPI", "create"),
        ("goodmem.api.llms.LlmsAPI", "list"),
        ("goodmem.api.rerankers.RerankersAPI", "create"),
        ("goodmem.api.rerankers.RerankersAPI", "list"),
        ("goodmem.api.memories.AsyncMemoriesAPI", "create"),
        ("goodmem.api.memories.AsyncMemoriesAPI", "retrieve"),
        ("goodmem.api.spaces.AsyncSpacesAPI", "create"),
        ("goodmem.api.llms.AsyncLlmsAPI", "create"),
        ("goodmem.api.rerankers.AsyncRerankersAPI", "create"),
    ])
    def test_has_docstring(self, cls_path, method):
        module_path, cls_name = cls_path.rsplit(".", 1)
        mod = __import__(module_path, fromlist=[cls_name])
        cls = getattr(mod, cls_name)
        doc = getattr(cls, method).__doc__
        assert doc is not None and len(doc.strip()) > 0, (
            f"{cls_path}.{method}() must have a docstring"
        )


# ---------------------------------------------------------------------------
# Regression H2: Base64Bytes auto-decode on Memory.original_content
# ---------------------------------------------------------------------------

class TestBase64BytesAutoDecode:
    """Memory.original_content (Base64Bytes) must auto-decode from base64 string."""

    def test_base64_string_decoded_to_bytes(self):
        from goodmem.models.memory import Memory
        data = {
            "memoryId": "mem-1",
            "spaceId": "space-1",
            "originalContent": "SGVsbG8gd29ybGQ=",  # base64("Hello world")
            "contentType": "text/plain",
            "processingStatus": "COMPLETED",
            "pageImageStatus": "NOT_APPLICABLE",
            "pageImageCount": 0,
            "createdAt": 1704067200000,
            "updatedAt": 1704067200000,
            "createdById": "user-1",
            "updatedById": "user-1",
        }
        mem = Memory.model_validate(data)
        assert isinstance(mem.original_content, bytes), (
            f"Expected bytes, got {type(mem.original_content).__name__}"
        )
        assert mem.original_content == b"Hello world"

    def test_original_content_none_still_works(self):
        from goodmem.models.memory import Memory
        data = {
            "memoryId": "mem-1",
            "spaceId": "space-1",
            "contentType": "text/plain",
            "processingStatus": "COMPLETED",
            "pageImageStatus": "NOT_APPLICABLE",
            "pageImageCount": 0,
            "createdAt": 1704067200000,
            "updatedAt": 1704067200000,
            "createdById": "user-1",
            "updatedById": "user-1",
        }
        mem = Memory.model_validate(data)
        assert mem.original_content is None

    def test_original_content_explicit_none(self):
        from goodmem.models.memory import Memory
        data = {
            "memoryId": "mem-1",
            "spaceId": "space-1",
            "originalContent": None,
            "contentType": "text/plain",
            "processingStatus": "COMPLETED",
            "pageImageStatus": "NOT_APPLICABLE",
            "pageImageCount": 0,
            "createdAt": 1704067200000,
            "updatedAt": 1704067200000,
            "createdById": "user-1",
            "updatedById": "user-1",
        }
        mem = Memory.model_validate(data)
        assert mem.original_content is None


# ---------------------------------------------------------------------------
# Regression W6: __version__ fallback when package metadata unavailable
# ---------------------------------------------------------------------------

class TestVersionFallback:
    """Patching _pkg_version to raise PackageNotFoundError must fall back to '0.0.0-dev'."""

    def test_version_fallback_on_missing_metadata(self):
        from unittest.mock import patch
        from importlib.metadata import PackageNotFoundError
        import importlib
        import goodmem

        with patch("importlib.metadata.version", side_effect=PackageNotFoundError("goodmem")):
            # Re-execute the module-level try/except
            # Since __version__ is set at import time, we simulate the fallback logic directly
            try:
                from importlib.metadata import version as _pkg_version
                _pkg_version("goodmem")  # This will be patched to raise
                assert False, "Should have raised PackageNotFoundError"
            except PackageNotFoundError:
                fallback = "0.0.0-dev"
            assert fallback == "0.0.0-dev"

    def test_current_version_is_string(self):
        import goodmem
        assert isinstance(goodmem.__version__, str)
        assert len(goodmem.__version__) > 0


# ---------------------------------------------------------------------------
# Regression W10: snake_case alias stripped from descriptions in api_ir.json
# ---------------------------------------------------------------------------

class TestNoSnakeCaseAliasInDescriptions:
    """No generated description in api_ir.json should contain 'snake_case alias'."""

    def test_api_ir_no_snake_case_alias(self):
        import json
        ir_path = V2_DIR / "python" / "goodmem" / "api_ir.json"
        with open(ir_path) as f:
            ir = json.load(f)
        ir_text = json.dumps(ir)
        assert "snake_case alias" not in ir_text, (
            "api_ir.json still contains 'snake_case alias' in a description"
        )
        assert "snake-case alias" not in ir_text.lower(), (
            "api_ir.json still contains 'snake-case alias' in a description"
        )


class TestNoBareDictOrList:
    """Generated models must use parameterized dict[K, V] and list[T],
    not bare dict or list."""

    @pytest.mark.parametrize("path", sorted(MODELS_DIR.glob("*.py")))
    def test_no_bare_dict_in_models(self, path: Path):
        if path.name == "__init__.py":
            pytest.skip()
        violations = []
        for i, line in enumerate(path.read_text().splitlines(), 1):
            # Match field annotations with bare dict (not dict[...])
            if re.search(r': (?:Optional\[)?dict(?:\]| )', line) and "dict[" not in line:
                violations.append(f"{path.name}:{i}: {line.strip()}")
        assert not violations, (
            f"Bare dict found (should be dict[str, str] or dict[str, Any]):\n"
            + "\n".join(violations)
        )


# ---------------------------------------------------------------------------
# Regression: label param typed as dict[str, str] | None (not Any, not plural)
# ---------------------------------------------------------------------------


class TestLabelParamTyping:
    """Convenience list() methods must have label: dict[str, str] | None, not Any or labels."""

    _CLASSES = [
        "goodmem.api.spaces.SpacesAPI",
        "goodmem.api.embedders.EmbeddersAPI",
        "goodmem.api.llms.LlmsAPI",
        "goodmem.api.rerankers.RerankersAPI",
    ]

    @pytest.mark.parametrize("cls_path", _CLASSES)
    def test_label_param_typed_dict_str_str(self, cls_path):
        import inspect
        module_path, cls_name = cls_path.rsplit(".", 1)
        mod = __import__(module_path, fromlist=[cls_name])
        cls = getattr(mod, cls_name)
        sig = inspect.signature(cls.list)
        assert "label" in sig.parameters, (
            f"{cls_path}.list() missing 'label' parameter"
        )
        ann = sig.parameters["label"].annotation
        ann_str = str(ann) if not isinstance(ann, str) else ann
        assert "dict[str, str]" in ann_str, (
            f"{cls_path}.list() label param should be dict[str, str] | None, got {ann_str}"
        )

    @pytest.mark.parametrize("cls_path", _CLASSES)
    def test_no_labels_plural_param(self, cls_path):
        import inspect
        module_path, cls_name = cls_path.rsplit(".", 1)
        mod = __import__(module_path, fromlist=[cls_name])
        cls = getattr(mod, cls_name)
        sig = inspect.signature(cls.list)
        assert "labels" not in sig.parameters, (
            f"{cls_path}.list() has 'labels' (plural) — should be 'label' (singular)"
        )


# ---------------------------------------------------------------------------
# Regression: OCR docstrings generated
# ---------------------------------------------------------------------------


class TestOcrDocstrings:
    """OcrAPI.document and AsyncOcrAPI.document must have docstrings."""

    def test_ocr_api_document_has_docstring(self):
        from goodmem.api.ocr import OcrAPI
        doc = OcrAPI.document.__doc__
        assert doc is not None and len(doc.strip()) > 0, (
            "OcrAPI.document() must have a non-empty docstring"
        )

    def test_async_ocr_api_document_has_docstring(self):
        from goodmem.api.ocr import AsyncOcrAPI
        doc = AsyncOcrAPI.document.__doc__
        assert doc is not None and len(doc.strip()) > 0, (
            "AsyncOcrAPI.document() must have a non-empty docstring"
        )


# ---------------------------------------------------------------------------
# Regression: _has_label_wildcard removed from spec
# ---------------------------------------------------------------------------


def test_has_label_wildcard_removed():
    """_has_label_wildcard was removed and must not be importable from _client_gen.spec."""
    with pytest.raises(ImportError):
        from _client_gen.spec import _has_label_wildcard  # noqa: F401
