"""Verify SDK function signatures match between code, generated docs, and mypy.

This test catches the class of bugs where:
- The emitter generates a default that doesn't match the spec (e.g., = None vs = False)
- The doc renderer shows a different default than the code
- A default is a raw string instead of a proper Python literal (e.g., = /chat/completions)
- A list default contains strings instead of enum values (mypy: list[str] vs list[Modality])

These are the exact issues that kept recurring in the typing/docs reports.
"""

from __future__ import annotations

import ast
import inspect
import re
from pathlib import Path

import pytest

from _doc_gen.catalogue import PUBLIC_METHODS, _sync_api_class_for_slug

V2_DIR = Path(__file__).parent.parent.parent
API_DIR = V2_DIR / "python" / "goodmem" / "api"
BASE_DIR = V2_DIR / "python" / "goodmem" / "_api_base"


# ---------------------------------------------------------------------------
# Collect all (slug, method, param_name, default, annotation) from the SDK
# ---------------------------------------------------------------------------

def _sdk_param_defaults() -> list[tuple[str, str, str, object, str]]:
    """Return (slug, method, param, default, annotation_str) for all public params."""
    results = []
    for slug, methods in PUBLIC_METHODS.items():
        cls = _sync_api_class_for_slug(slug)
        if not cls:
            continue
        for m in methods:
            if "." in m:
                continue
            method = getattr(cls, m, None)
            if not method:
                continue
            sig = inspect.signature(method)
            for name, p in sig.parameters.items():
                if name == "self":
                    continue
                if p.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
                    continue
                ann = p.annotation
                ann_str = "" if ann is inspect.Parameter.empty else str(ann)
                results.append((slug, m, name, p.default, ann_str))
    return results


_ALL_PARAMS = _sdk_param_defaults()


# ---------------------------------------------------------------------------
# Test: no default is an invalid Python literal
# ---------------------------------------------------------------------------

class TestDefaultsAreValidPython:
    """Every generated file must parse without SyntaxError."""

    @pytest.mark.parametrize("path", sorted(API_DIR.glob("*.py")))
    def test_convenience_parses(self, path: Path):
        if path.name == "__init__.py":
            pytest.skip()
        source = path.read_text()
        try:
            ast.parse(source)
        except SyntaxError as e:
            pytest.fail(f"{path.name} has SyntaxError: {e}")

    @pytest.mark.parametrize("path", sorted(BASE_DIR.glob("*.py")))
    def test_base_parses(self, path: Path):
        if path.name == "__init__.py":
            pytest.skip()
        source = path.read_text()
        try:
            ast.parse(source)
        except SyntaxError as e:
            pytest.fail(f"{path.name} has SyntaxError: {e}")


# ---------------------------------------------------------------------------
# Test: code defaults match expected values for known params
# ---------------------------------------------------------------------------

# Known non-None defaults that must be preserved in the generated code.
# (slug, method, param) -> expected default value
_EXPECTED_DEFAULTS: dict[tuple[str, str, str], object] = {
    # memories.list: server defaults — signature uses None (SDK omits, server applies)
    ("memories", "list", "include_content"): None,
    ("memories", "list", "include_processing_history"): None,
    # memories.retrieve: all maps_to params use = None (server defaults in prose)
    ("memories", "retrieve", "chronological_resort"): None,
    # fetch_memory and fetch_memory_content: server defaults (issue #893 fixed).
    # Signature uses = None; server applies true/false.
    ("memories", "retrieve", "fetch_memory"): None,
    ("memories", "retrieve", "fetch_memory_content"): None,
    ("memories", "retrieve", "gen_token_budget"): None,
    ("memories", "retrieve", "llm_temp"): None,
    ("memories", "retrieve", "max_results"): None,
    ("memories", "retrieve", "stream"): True,
    # spaces.create: server default — signature uses None
    ("spaces", "create", "public_read"): None,
    # embedders.create: convenience default
    ("embedders", "create", "distribution_type"): "DENSE",
}


class TestKnownDefaults:
    """Non-None defaults must match expected values."""

    @pytest.mark.parametrize(
        "key,expected",
        sorted(_EXPECTED_DEFAULTS.items()),
        ids=[f"{s}.{m}.{p}" for (s, m, p) in sorted(_EXPECTED_DEFAULTS)],
    )
    def test_default_matches(self, key, expected):
        slug, method, param = key
        cls = _sync_api_class_for_slug(slug)
        sig = inspect.signature(getattr(cls, method))
        actual = sig.parameters[param].default
        assert actual == expected, (
            f"{slug}.{method}({param}): expected default {expected!r}, got {actual!r}"
        )


# ---------------------------------------------------------------------------
# Test: no enum/model type has a raw string/list default (mypy would fail)
# ---------------------------------------------------------------------------

# Types where a raw string or list default would cause mypy errors
_MODEL_TYPES = {"Modality", "ProviderType", "LLMProviderType", "DistributionType",
                "SortOrder", "SpaceEmbedderConfig", "ChunkingConfiguration",
                "EndpointAuthentication", "LLMCapabilities", "LLMSamplingParams",
                "PostProcessor", "HnswOptions", "ContextItem", "SpaceKey"}


class TestNoRawEnumDefaults:
    """Parameters with enum/model types must not default to raw strings or lists."""

    def test_no_string_default_for_model_type(self):
        for slug, method, param, default, ann_str in _ALL_PARAMS:
            if default is inspect.Parameter.empty or default is None:
                continue
            # Check if annotation references a known model type
            for model in _MODEL_TYPES:
                if model in ann_str and isinstance(default, (str, list)):
                    # Exception: DistributionType with "DENSE" is a convenience default
                    # that gets validated by pydantic — acceptable
                    if model == "DistributionType" and default == "DENSE":
                        continue
                    pytest.fail(
                        f"{slug}.{method}({param}): has raw {type(default).__name__} "
                        f"default {default!r} for type {model}"
                    )


# ---------------------------------------------------------------------------
# Test: convenience layer defaults match base layer where both exist
# ---------------------------------------------------------------------------

class TestDocRendererMatchesCode:
    """resolve_default() (signature defaults) must match the actual code default."""

    def test_resolve_default_matches_code(self):
        """For every param with a non-None code default, resolve_default must agree."""
        from _client_gen.convenience_emitter import _load_ir, _ir_params_for_method
        from _client_gen.param_merge import resolve_default
        from _client_gen.param_merge import merge_params
        from goodmem._transforms import CONVENIENCE_REGISTRY

        ir = _load_ir()
        mismatches = []
        for slug, method, param, code_default, _ann in _ALL_PARAMS:
            if code_default is inspect.Parameter.empty:
                continue
            # Get the MergedParam for this param
            t2 = _ir_params_for_method(ir, slug, method)
            entry = CONVENIENCE_REGISTRY.get((slug, method), {})
            merged = merge_params(slug, method, t2, entry)
            mp = next((m for m in merged if m.name == param), None)
            if mp is None:
                continue
            doc_default = resolve_default(mp.default, mp.spec_default)
            # Compare: code_default is a Python value, doc_default is a string or None.
            # Base class methods intentionally use None defaults (server applies
            # its own), while docs show the spec default. This divergence is
            # expected for non-convenience methods.
            if code_default is None:
                # code=None is always valid: base class delegates to server.
                # Doc may show server default — that's fine for documentation.
                pass
            else:
                if doc_default is None:
                    mismatches.append(
                        f"{slug}.{method}.{param}: code={code_default!r}, doc=None (shows = None)"
                    )
                elif isinstance(doc_default, str) and doc_default.startswith("$"):
                    # SDK constant reference — code has the actual object,
                    # doc has "$CONSTANT_NAME". Check the constant exists and
                    # is the same object as the code default.
                    const_name = doc_default[1:]
                    from goodmem import _transforms
                    actual = getattr(_transforms, const_name, None)
                    if actual is None or actual != code_default:
                        mismatches.append(
                            f"{slug}.{method}.{param}: code={code_default!r}, "
                            f"doc=${const_name} but constant doesn't match"
                        )
                else:
                    # Normalize: strip outer quotes for comparison
                    # convenience.json uses '"DENSE"', code has 'DENSE'
                    doc_str = str(doc_default).strip('"').strip("'")
                    code_str = str(code_default).strip('"').strip("'")
                    if doc_str != code_str:
                        mismatches.append(
                            f"{slug}.{method}.{param}: code={code_default!r}, doc={doc_default!r}"
                        )
        assert not mismatches, "Doc defaults don't match code:\n  " + "\n  ".join(mismatches)


class TestConvenienceMatchesBase:
    """When convenience overrides a base method, shared params must have same defaults.

    Note: base class methods use None defaults (server applies its own defaults).
    Convenience layer may override with explicit defaults for user convenience.
    These tests verify consistency within the convenience layer only.
    """

    def test_memories_list_include_flags_use_none(self):
        """Convenience list() should use None for server-default booleans."""
        cls = _sync_api_class_for_slug("memories")
        list_sig = inspect.signature(cls.list)
        assert list_sig.parameters["include_content"].default is None
        assert list_sig.parameters["include_processing_history"].default is None

    def test_memories_get_include_flags_use_none(self):
        """Convenience get() should use None for server-default booleans."""
        cls = _sync_api_class_for_slug("memories")
        get_sig = inspect.signature(cls.get)
        assert get_sig.parameters["include_content"].default is None
        assert get_sig.parameters["include_processing_history"].default is None


# ---------------------------------------------------------------------------
# Regression: base class optional params must default to None (TYPE-02/08, SURP-03/08)
# ---------------------------------------------------------------------------

_PARAMS_THAT_MUST_DEFAULT_TO_NONE = [
    ("embedders", "create", "supported_modalities"),
    ("embedders", "create", "api_path"),
    ("llms", "create", "supported_modalities"),
    ("llms", "create", "api_path"),
    ("rerankers", "create", "supported_modalities"),
    ("rerankers", "create", "api_path"),
    ("spaces", "list", "sort_order"),
    ("memories", "list", "sort_order"),
]


class TestBaseClassDefaultsAreNone:
    """Base class optional params must default to None so the server can
    apply its own defaults.  Previously, some params had non-None defaults
    like ('TEXT',) or 'DESCENDING' which were always sent to the server."""

    @pytest.mark.parametrize(
        "slug,method,param",
        _PARAMS_THAT_MUST_DEFAULT_TO_NONE,
        ids=[f"{s}.{m}.{p}" for s, m, p in _PARAMS_THAT_MUST_DEFAULT_TO_NONE],
    )
    def test_base_class_defaults_to_none(self, slug, method, param):
        from _doc_gen.catalogue import _sync_base_class_for_slug
        cls = _sync_base_class_for_slug(slug)
        sig = inspect.signature(getattr(cls, method))
        default = sig.parameters[param].default
        assert default is None, (
            f"_api_base {slug}.{method}({param}) should default to None, "
            f"got {default!r}. Base class must not override server defaults."
        )


class TestBaseClassConditionalPacking:
    """Generated base class code must use 'if X is not None' guards for all
    optional params, never unconditional assignment."""

    @pytest.mark.parametrize("path", sorted(BASE_DIR.glob("*.py")))
    def test_no_unconditional_optional_packing(self, path: Path):
        if path.name == "__init__.py":
            pytest.skip()
        lines = path.read_text().splitlines()
        violations = []
        for i, line in enumerate(lines, 1):
            m = re.match(r"^        _params\['(.+)'\] = (\w+)$", line)
            if m:
                prev = lines[i - 2].strip() if i >= 2 else ""
                if not prev.startswith("_params = {") and not prev.startswith("_params: dict"):
                    violations.append(f"{path.name}:{i}: {line.strip()}")
        assert not violations, (
            f"Unconditional param packing found (should use 'if X is not None'):\n"
            + "\n".join(violations)
        )


# ---------------------------------------------------------------------------
# BUG-06 — admin.background_jobs.purge() must require older_than
# ---------------------------------------------------------------------------

class TestOlderThanRequired:
    """older_than is required by proto/server; omitting it must be a TypeError."""

    def test_sync_purge_requires_older_than(self):
        from goodmem._api_base.admin import _AdminBackgroundJobsNamespace
        sig = inspect.signature(_AdminBackgroundJobsNamespace.purge)
        param = sig.parameters["older_than"]
        assert param.default is inspect.Parameter.empty, (
            "older_than must be required (no default) — BUG-06"
        )

    def test_async_purge_requires_older_than(self):
        from goodmem._api_base.admin import _AsyncAdminBackgroundJobsNamespace
        sig = inspect.signature(_AsyncAdminBackgroundJobsNamespace.purge)
        param = sig.parameters["older_than"]
        assert param.default is inspect.Parameter.empty, (
            "older_than must be required (no default) — BUG-06"
        )

    def test_pydantic_model_requires_older_than(self):
        from goodmem.models.admin_purge_jobs_request import AdminPurgeJobsRequest
        field = AdminPurgeJobsRequest.model_fields["older_than"]
        assert field.is_required(), (
            "AdminPurgeJobsRequest.older_than must be required — BUG-06"
        )

    @pytest.mark.parametrize("cls_path,method,param_name", [
        ("goodmem.api.spaces.SpacesAPI", "list", "sort_order"),
        ("goodmem.api.spaces.SpacesAPI", "list", "sort_by"),
        ("goodmem.api.memories.MemoriesAPI", "list", "sort_order"),
        ("goodmem.api.memories.MemoriesAPI", "get", "include_content"),
        ("goodmem.api.memories.MemoriesAPI", "get", "include_processing_history"),
        ("goodmem.api.spaces.SpacesAPI", "create", "public_read"),
    ])
    def test_server_defaults_use_none_in_signature(self, cls_path, method, param_name):
        """All server defaults (including booleans) must use = None in signatures."""
        module_path, cls_name = cls_path.rsplit(".", 1)
        mod = __import__(module_path, fromlist=[cls_name])
        cls = getattr(mod, cls_name)
        sig = inspect.signature(getattr(cls, method))
        param = sig.parameters[param_name]
        assert param.default is None, (
            f"{cls_path}.{method}() signature must use = None for {param_name}, "
            f"got {param.default!r}"
        )


# ---------------------------------------------------------------------------
# TYPE-01/TYPE-07 — _api_base create() required params must have no default
# ---------------------------------------------------------------------------

_REQUIRED_CREATE_PARAMS = [
    # (namespace, param_name)  — required per spec, must not default to None
    ("memories", "space_id"),
    ("memories", "content_type"),
    ("spaces", "name"),
    ("spaces", "space_embedders"),
    ("spaces", "default_chunking_config"),
    ("embedders", "display_name"),
    ("embedders", "provider_type"),
    ("embedders", "endpoint_url"),
    ("embedders", "model_identifier"),
    ("embedders", "dimensionality"),
    ("llms", "display_name"),
    ("llms", "provider_type"),
    ("llms", "endpoint_url"),
    ("llms", "model_identifier"),
    ("rerankers", "display_name"),
    ("rerankers", "provider_type"),
    ("rerankers", "endpoint_url"),
    ("rerankers", "model_identifier"),
]


class TestBaseCreateRequiredParams:
    """Spec-required create() params in _api_base must not default to None.

    TYPE-01: memories, spaces, embedders
    TYPE-07: llms, rerankers
    """

    @pytest.mark.parametrize(
        "slug,param",
        _REQUIRED_CREATE_PARAMS,
        ids=[f"{s}.create.{p}" for s, p in _REQUIRED_CREATE_PARAMS],
    )
    def test_sync_create_param_is_required(self, slug, param):
        from _doc_gen.catalogue import _sync_base_class_for_slug
        cls = _sync_base_class_for_slug(slug)
        sig = inspect.signature(cls.create)
        assert sig.parameters[param].default is inspect.Parameter.empty, (
            f"_api_base {slug}.create({param}) must be required (no default) — TYPE-01/07"
        )

    @pytest.mark.parametrize(
        "slug,param",
        _REQUIRED_CREATE_PARAMS,
        ids=[f"{s}.create.{p}" for s, p in _REQUIRED_CREATE_PARAMS],
    )
    def test_async_create_param_is_required(self, slug, param):
        from _doc_gen.catalogue import _sync_base_class_for_slug
        # Async class lives in same module, named Async{SyncName}
        sync_cls = _sync_base_class_for_slug(slug)
        mod = inspect.getmodule(sync_cls)
        async_cls_name = f"Async{sync_cls.__name__}"
        async_cls = getattr(mod, async_cls_name)
        sig = inspect.signature(async_cls.create)
        assert sig.parameters[param].default is inspect.Parameter.empty, (
            f"_api_base async {slug}.create({param}) must be required (no default) — TYPE-01/07"
        )
