"""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.get: spec says False
    ("memories", "get", "include_content"): False,
    ("memories", "get", "include_processing_history"): False,
    # memories.list: convenience list, spec says False
    ("memories", "list", "include_content"): False,
    ("memories", "list", "include_processing_history"): False,
    # memories.retrieve: convenience defaults
    ("memories", "retrieve", "chronological_resort"): True,
    ("memories", "retrieve", "fetch_memory"): True,
    ("memories", "retrieve", "fetch_memory_content"): False,
    ("memories", "retrieve", "gen_token_budget"): 512,
    ("memories", "retrieve", "llm_temp"): 0.3,
    ("memories", "retrieve", "max_results"): 10,
    ("memories", "retrieve", "stream"): True,
    # spaces.create: spec default
    ("spaces", "create", "public_read"): False,
    # 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:
    """The doc renderer (_resolve_default) must produce the same default as the code."""

    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
            if code_default is None:
                if doc_default is not None:
                    mismatches.append(
                        f"{slug}.{method}.{param}: code=None, doc={doc_default!r}"
                    )
            else:
                expected_str = repr(code_default) if not isinstance(code_default, str) else f'"{code_default}"'
                if doc_default is None:
                    mismatches.append(
                        f"{slug}.{method}.{param}: code={code_default!r}, doc=None (shows = None)"
                    )
                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."""

    def test_memories_list_include_content(self):
        """list should have same include_content default as get."""
        cls = _sync_api_class_for_slug("memories")
        get_sig = inspect.signature(cls.get)
        list_sig = inspect.signature(cls.list)
        get_default = get_sig.parameters["include_content"].default
        list_default = list_sig.parameters["include_content"].default
        assert get_default == list_default, (
            f"memories.get has include_content={get_default!r}, "
            f"memories.list has {list_default!r}"
        )

    def test_memories_list_include_processing_history(self):
        cls = _sync_api_class_for_slug("memories")
        get_sig = inspect.signature(cls.get)
        list_sig = inspect.signature(cls.list)
        get_default = get_sig.parameters["include_processing_history"].default
        list_default = list_sig.parameters["include_processing_history"].default
        assert get_default == list_default
