"""FunctionClient for generating and consuming typed clients.

This module provides the FunctionClient class, a developer tool for generating
typed Python clients from Cognite Functions. The client connects to either a
local devserver or deployed function to enable interactive exploration and
production-ready client generation.

Key Features:
    - Notebook-first design: Optimized for interactive exploration
    - Dynamic calling: Use methods directly without code generation
    - Two-tier model approach:
        * discover(): Creates simplified models from OpenAPI (fast, for exploration)
        * materialize(): Extracts complete models with validators (production-ready)
    - Runtime model discovery: Get Pydantic models on-demand for validation
    - Progressive enhancement from dynamic to fully typed
    - Self-contained generated clients with no manual imports needed
    - Support for deployed Cognite Functions via CogniteClient

Workflow Tiers:
    Tier 1 (Quick Exploration):
        Use dynamic client with dicts - fast, no validation needed

    Tier 2 (Interactive Validation):
        Use discover() to get runtime models with automatic response parsing

    Tier 3 (Production):
        Use materialize() to generate fully typed client

Example Usage (Local Devserver):
    ```python
    from cognite_typed_functions import FunctionClient

    # Create client (no I/O, safe constructor)
    client = FunctionClient(base_url="http://localhost:8000")

    # Tier 1: Quick exploration with dicts
    result = client.get_item(item_id=42)  # Returns dict

    # Tier 2: Discover and use runtime models
    models = client.discover()
    # ✓ Connected to Asset Management API v1.0.0
    # Available methods: get_item, create_item, ...
    # Models: Item, ItemResponse

    # After discover(), responses are automatically parsed to models!
    item = models.Item(name="Widget", price=99.99)  # Validated request
    result = client.create_item(item=item)  # Returns ItemResponse (not dict!)
    print(result.total_price)  # Direct typed access

    # Explore a specific method
    client.describe("create_item")

    # Tier 3: Generate typed client for production
    client.materialize("clients/my_client.py")
    ```

Example Usage (Deployed Function):
    ```python
    from cognite.client import CogniteClient
    from cognite_typed_functions import FunctionClient

    # User has CogniteClient for other purposes
    cognite_client = CogniteClient(...)

    # Create FunctionClient - function is retrieved lazily on first use
    client = FunctionClient(
        cognite_client=cognite_client,
        function_external_id="my-function"
    )
    # or with function ID:
    # client = FunctionClient(cognite_client=cognite_client, function_id=5199938255797384)

    # Function is retrieved automatically on first discover() or method call
    models = client.discover()
    result = client.get_item(item_id=42)
    ```

Note:
    This module requires httpx, which is available as an optional dependency:
    `pip install cognite-typed-functions[client]`

    For deployed functions, cognite-sdk is required:
    `pip install cognite-sdk`
"""

import re
import warnings
from pathlib import Path
from types import SimpleNamespace
from typing import Any, cast, overload

from cognite.client import CogniteClient
from cognite.client.exceptions import CogniteException
from pydantic import BaseModel, ValidationError, create_model

from .base_client import BaseFunctionClient
from .models import CogniteTypedError, CogniteTypedResponse, HTTPMethod, Json


class FunctionClient(BaseFunctionClient):
    """Developer tool for generating typed clients from Cognite Functions.

    Notebook-first design optimized for interactive exploration with three tiers:

    Tier 1 (Quick Exploration):
        Use dynamic client with dicts - no validation, fast iteration

    Tier 2 (Interactive Validation):
        Use discover() to get runtime models with automatic response parsing

    Tier 3 (Production):
        Use materialize() to generate a fully typed client for production code

    Key Features:
        - Safe constructor (no I/O) - never fails on instantiation
        - Lazy connection - connects when you first interact with methods or discover()
        - Runtime model loading - get Pydantic models from OpenAPI schemas
        - Automatic response parsing - after discover(), model responses are typed
        - Discovery helpers - explore methods interactively with describe()
        - Progressive enhancement - easy path from exploration to production
        - Support for both local devserver and deployed Cognite Functions

    The generated clients (Tier 3) are self-contained Python modules that include:
        - All Pydantic model definitions
        - Type-safe methods for each endpoint
        - Proper imports and type hints
        - Full docstrings

    Attributes:
        base_url: The base URL of the function endpoint (for devserver or direct function URLs)
    """

    @overload
    def __init__(
        self,
        *,
        base_url: str | None = None,
    ) -> None: ...

    @overload
    def __init__(
        self,
        *,
        cognite_client: CogniteClient | None = None,
        function_id: int | None = None,
    ) -> None: ...

    @overload
    def __init__(
        self,
        *,
        cognite_client: CogniteClient | None = None,
        function_external_id: str | None = None,
    ) -> None: ...

    def __init__(
        self,
        *,
        base_url: str | None = None,
        cognite_client: CogniteClient | None = None,
        function_id: int | None = None,
        function_external_id: str | None = None,
    ) -> None:
        """Initialize FunctionClient - no I/O, safe constructor.

        Args:
            base_url: Direct URL to devserver (e.g., "http://localhost:8000")
            cognite_client: CogniteClient instance for deployed functions
            function_id: ID of deployed function to retrieve
            function_external_id: External ID of deployed function to retrieve

        Raises:
            ValueError: If invalid parameter combination is provided
            ImportError: If required dependencies are not installed

        Example:
            ```python
            # For local development
            client = FunctionClient(base_url="http://localhost:8000")

            # For deployed functions
            from cognite.client import CogniteClient

            cognite_client = CogniteClient(...)
            client = FunctionClient(
                cognite_client=cognite_client,
                function_external_id="my-function"
            )
            # Function is retrieved lazily on first discover() or method call
            ```
        """
        # Initialize base client infrastructure
        super().__init__(  # type: ignore[call-overload]
            base_url=base_url,
            cognite_client=cognite_client,
            function_id=function_id,
            function_external_id=function_external_id,
        )

        # FunctionClient-specific state management - no I/O in constructor
        self._connected = False
        self._methods_metadata: dict[str, dict[str, Any]] | None = None
        self._models: dict[str, type[BaseModel]] | None = None  # Store models after discover()

    def discover(self) -> SimpleNamespace:
        """Discover API methods and return simplified runtime Pydantic models.

        Single entry point for interactive exploration in notebooks:
        - Connects to the server (lazy connection)
        - Displays available methods and their signatures
        - Returns simplified Pydantic models from OpenAPI schemas (structure only)
        - Enables automatic response parsing for model return types

        The returned models are simplified (structure only, no Field constraints or
        validators) for fast exploration. Server-side validation handles all constraints.
        For production code with complete models, use materialize() instead.

        After calling discover(), methods that return Pydantic models will
        automatically parse responses into the appropriate model type instead
        of returning raw dicts.

        No caching - each call fetches fresh data (function may have been redeployed).

        Returns:
            SimpleNamespace with Pydantic model classes as attributes

        Raises:
            httpx.HTTPError: If connection fails
            RuntimeError: If model creation fails

        Example:
            ```python
            client = FunctionClient("http://localhost:8000")

            # Before discover: returns dict
            result = client.get_item(item_id=42)
            print(type(result))  # <class 'dict'>

            # Discover and get models
            models = client.discover()
            # ✓ Connected to Asset Management API v1.0.0
            # Available methods:
            #   - get_item(item_id: int, include_tax: bool) -> ItemResponse
            #   - create_item(item: Item) -> ItemResponse
            # Models: Item, ItemResponse

            # After discover: returns typed model automatically!
            result = client.get_item(item_id=42)
            print(type(result))  # <class 'ItemResponse'>
            print(result.total_price)  # Direct typed access

            # Use models for validated requests
            item = models.Item(name="Widget", price=99.99)
            result = client.create_item(item=item)  # Returns ItemResponse

            # Or just explore without saving return value
            client.discover()  # Shows info, return value ignored
            ```
        """
        # Connect and fetch metadata if not already connected
        if not self._connected:
            self._fetch_methods_metadata()

        # Fetch OpenAPI schema for model creation
        schemas = self._fetch_schemas()

        # Print formatted function information
        self._print_function_info(schemas)

        # Create Pydantic models from JSON schemas
        models_namespace = self._create_models_from_schemas(schemas)

        # Store models for automatic response parsing
        self._models = {
            name: getattr(models_namespace, name) for name in dir(models_namespace) if not name.startswith("_")
        }

        return models_namespace

    def describe(self, method_name: str) -> None:
        """Display detailed information about a specific method.

        Alternative to Python's built-in help() with richer formatting.
        Shows method signature, parameters, return type, and HTTP details.

        Args:
            method_name: Name of the method to describe

        Raises:
            ValueError: If method doesn't exist

        Example:
            ```python
            client = FunctionClient("http://localhost:8000")
            client.describe("create_item")
            # create_item(item: Item) -> ItemResponse
            #     Create a new item.
            #
            #     Parameters:
            #       item: Item (required)
            #         Field: name (str, required)
            #         Field: price (float, required)
            #         Field: description (str | None, optional)
            #
            #     Returns: ItemResponse
            #         Field: id (int)
            #         Field: total_price (float)
            #
            #     HTTP: POST /items/
            ```
        """
        # Connect if needed
        if not self._connected:
            self._fetch_methods_metadata()

        # Check if method exists
        if self._methods_metadata is None or method_name not in self._methods_metadata:
            available = list(self._methods_metadata.keys()) if self._methods_metadata else []
            raise ValueError(f"Method '{method_name}' not found. Available methods: {available}")

        metadata = self._methods_metadata[method_name]

        # Get schemas for showing detailed type information
        schemas = self._fetch_schemas()

        # Print method signature
        params = metadata.get("parameters", [])
        param_strs: list[str] = []
        for param in params:
            param_name = param.get("name", "")
            param_type = param.get("type", "Any")
            param_strs.append(f"{param_name}: {param_type}")

        return_type = metadata.get("return_type", "Any")
        signature = f"{method_name}({', '.join(param_strs)}) -> {return_type}"

        print(signature)  # noqa: T201

        # Print description
        description = metadata.get("description", "")
        if description:
            print(f"    {description}")  # noqa: T201
            print()  # noqa: T201

        # Print parameters with details
        if params:
            print("    Parameters:")  # noqa: T201
            for param in params:
                param_name = param.get("name", "")
                param_type = param.get("type", "Any")
                required = "required" if param.get("required", True) else "optional"
                print(f"      {param_name}: {param_type} ({required})")  # noqa: T201

                # Show field details for complex types (if schema available)
                # Extract base type name (e.g., "Item" from "Item" or "list[Item]")
                base_type = param_type.replace("list[", "").replace("]", "")
                if base_type in schemas:
                    schema = schemas[base_type]
                    properties = schema.get("properties", {})
                    required_fields = set(schema.get("required", []))
                    for field_name, field_schema in properties.items():
                        field_type = field_schema.get("type", "any")
                        field_required = "required" if field_name in required_fields else "optional"
                        print(f"        Field: {field_name} ({field_type}, {field_required})")  # noqa: T201
            print()  # noqa: T201

        # Print return type details
        print(f"    Returns: {return_type}")  # noqa: T201
        if return_type in schemas:
            schema = schemas[return_type]
            properties = schema.get("properties", {})
            for field_name, field_schema in properties.items():
                field_type = field_schema.get("type", "any")
                print(f"        Field: {field_name} ({field_type})")  # noqa: T201
        print()  # noqa: T201

        # Print HTTP details
        http_method = metadata.get("http_method", "GET")
        path = metadata.get("path", "/")
        print(f"    HTTP: {http_method} {path}")  # noqa: T201

    def _fetch_methods_metadata(self) -> None:
        """Fetch method metadata from the function for dynamic calling.

        Called lazily on first method access or discover() call.
        Handles both devserver (HTTP) and deployed function (SDK) modes.
        """
        if self._methods_metadata is not None:
            # Already fetched
            return

        if self._is_deployed:
            # Deployed function: use func.call() with introspection endpoint
            # /__client_methods__ is a framework-defined GET endpoint (see introspection.py)
            result = self._call_deployed("/__client_methods__", HTTPMethod.GET)
        else:
            # Devserver: direct HTTP
            import httpx  # type: ignore[import-not-found]

            response = httpx.get(f"{self.base_url}/__client_methods__", timeout=10.0)
            response.raise_for_status()
            result = response.json()

        # Unwrap Cognite Functions response format using Pydantic models
        try:
            response_obj = CogniteTypedResponse.model_validate(result)
            if response_obj.success:
                data_dict = response_obj.data
            else:
                error = CogniteTypedError.model_validate(result)
                raise RuntimeError(f"{error.error_type}: {error.message}")
        except ValidationError as e:
            warnings.warn(
                f"Response validation failed during method discovery - this may indicate a schema mismatch. "
                f"Falling back to raw result. Validation error: {e}",
                UserWarning,
                stacklevel=2,
            )
            data_dict = result

        # Build method name -> metadata mapping
        self._methods_metadata = {}
        if isinstance(data_dict, dict):
            data_dict = cast(dict[str, Any], data_dict)  # Safe to cast because we check for dict above
            methods_raw = data_dict.get("methods", [])
            if isinstance(methods_raw, list):
                methods_raw = cast(list[object], methods_raw)  # Safe to cast because we check for list above
                for method in methods_raw:
                    if isinstance(method, dict) and "name" in method:
                        method = cast(
                            dict[str, Any], method
                        )  # Safe to cast because we check for dict and "name" in method
                        self._methods_metadata[method["name"]] = method

        # Attach methods as instance attributes for IDE tab completion
        self._attach_methods()
        self._connected = True

    def _fetch_full_metadata(self) -> dict[str, Any]:
        """Fetch complete metadata including models for code generation.

        Returns:
            Dictionary with 'methods', 'models', and 'imports' keys
        """
        if self._is_deployed:
            # Deployed function: use func.call()
            # /__client_methods__ is a framework-defined GET endpoint (see introspection.py)
            result = self._call_deployed("/__client_methods__", HTTPMethod.GET)
        else:
            # Devserver: direct HTTP
            import httpx  # type: ignore[import-not-found]

            response = httpx.get(f"{self.base_url}/__client_methods__", timeout=30.0)
            response.raise_for_status()
            result = response.json()

        # Unwrap Cognite Functions response format
        try:
            response_obj = CogniteTypedResponse.model_validate(result)
            if response_obj.success:
                if isinstance(response_obj.data, dict):
                    return response_obj.data
                raise TypeError(f"Expected metadata to be a dict, but got {type(response_obj.data).__name__}")
            else:
                error = CogniteTypedError.model_validate(result)
                raise RuntimeError(f"{error.error_type}: {error.message}")
        except ValidationError as e:
            warnings.warn(
                f"Response validation failed while fetching full metadata - this may indicate a schema mismatch. "
                f"Falling back to raw result. Validation error: {e}",
                UserWarning,
                stacklevel=2,
            )
            if isinstance(result, dict):
                return cast(dict[str, Any], result)  # Safe to cast because we check for dict above
            raise TypeError(f"Expected metadata to be a dict, but got {type(result).__name__}")

    def _fetch_schemas(self) -> dict[str, dict[str, Any]]:
        """Fetch JSON schemas from OpenAPI specification.

        Returns:
            Dictionary of schema_name -> schema_definition
        """
        # Use /__schema__ endpoint which works for both devserver and deployed functions
        # _call_method automatically handles unwrapping the response envelope
        try:
            openapi_spec = self._call_method("/__schema__", HTTPMethod.GET)
        except Exception as e:
            raise RuntimeError(f"Failed to retrieve schema: {e}") from e

        # Extract component schemas (standard OpenAPI structure)
        return openapi_spec.get("components", {}).get("schemas", {})  # type: ignore[reportReturnType]

    def _print_function_info(self, schemas: dict[str, dict[str, Any]]) -> None:
        """Print formatted information about available methods and models.

        Args:
            schemas: Dictionary of schema definitions
        """
        # Get API info from OpenAPI spec using /__schema__ endpoint
        # _call_method automatically handles unwrapping the response envelope
        openapi_spec = self._call_method("/__schema__", HTTPMethod.GET)

        info = openapi_spec.get("info", {})
        title = info.get("title", "API")
        version = info.get("version", "")

        # Print connection success
        print(f"✓ Connected to {title} v{version}")  # noqa: T201
        print()  # noqa: T201

        # Print available methods
        if self._methods_metadata:
            print("Available methods:")  # noqa: T201
            for method_name, metadata in self._methods_metadata.items():
                # Format parameters
                params = metadata.get("parameters", [])
                param_strs: list[str] = []
                for param in params:
                    param_name = param.get("name", "")
                    param_type = param.get("type", "Any")
                    param_strs.append(f"{param_name}: {param_type}")

                # Format return type
                return_type = metadata.get("return_type", "Any")

                # Build signature
                signature = f"{method_name}({', '.join(param_strs)}) -> {return_type}"
                print(f"  - {signature}")  # noqa: T201
            print()  # noqa: T201

        # Print available models
        if schemas:
            model_names = ", ".join(sorted(schemas.keys()))
            print(f"Models: {model_names}")  # noqa: T201
            print()  # noqa: T201

        print("Use help(client.method_name) or client.describe('method_name') for details")  # noqa: T201

    def _create_models_from_schemas(self, schemas: dict[str, dict[str, Any]]) -> SimpleNamespace:
        """Dynamically create Pydantic model classes from JSON schemas.

        Creates simplified models with just structure (no Field constraints).
        Server-side validation handles all constraints anyway.

        Args:
            schemas: Dictionary of schema_name -> schema_definition

        Returns:
            SimpleNamespace with model classes as attributes

        Raises:
            RuntimeError: If model creation fails with details about the error
        """
        if not schemas:
            return SimpleNamespace()

        # Build dependency graph from $ref references
        deps = self._build_dependency_graph(schemas)

        # Topological sort to determine creation order
        creation_order = self._topological_sort(deps)

        # Create models in dependency order
        created_models: dict[str, type[BaseModel]] = {}
        for model_name in creation_order:
            if model_name not in schemas:
                # Dependency not in schemas, skip it
                continue

            schema = schemas[model_name]
            try:
                created_models[model_name] = self._create_model_from_schema(model_name, schema, created_models)
            except (TypeError, ValueError) as e:
                raise RuntimeError(
                    f"Failed to create model '{model_name}' from schema. Schema: {schema}. Error: {e}"
                ) from e

        return SimpleNamespace(**created_models)

    def _create_model_from_schema(
        self, model_name: str, schema: dict[str, Any], existing_models: dict[str, type[BaseModel]]
    ) -> type[BaseModel]:
        """Create a Pydantic model from JSON Schema.

        Creates a simplified model with just structure, no Field constraints.
        Server-side validation handles all constraints anyway.

        Args:
            model_name: Name of the model to create
            schema: JSON Schema definition
            existing_models: Already created models (for resolving references)

        Returns:
            Dynamically created Pydantic model class
        """
        properties = schema.get("properties", {})
        required_fields = set(schema.get("required", []))

        fields: dict[str, Any] = {}
        for field_name, field_schema in properties.items():
            # Parse type from JSON Schema
            field_type = self._json_schema_to_python_type(field_schema, existing_models)

            # Determine if field is required
            if field_name in required_fields:
                fields[field_name] = (field_type, ...)  # Required
            else:
                # Check if schema specifies a default
                default = field_schema.get("default", None)
                fields[field_name] = (field_type, default)

        return create_model(model_name, **fields)

    def _json_schema_to_python_type(self, schema: dict[str, Any], existing_models: dict[str, type[BaseModel]]) -> Any:
        """Convert JSON Schema type to Python type annotation.

        Handles:
        - Primitives: string, number, integer, boolean
        - Arrays: {"type": "array", "items": {...}}
        - Objects: {"type": "object"} -> dict[str, Any]
        - References: {"$ref": "#/components/schemas/Item"} -> Item model
        - anyOf/oneOf for unions (e.g., Optional types)
        - null types

        Args:
            schema: JSON Schema type definition
            existing_models: Already created models (for resolving references)

        Returns:
            Python type annotation
        """
        # Handle $ref (model references)
        if "$ref" in schema:
            ref_path = schema["$ref"]
            model_name = ref_path.split("/")[-1]  # Extract "Item" from "#/components/schemas/Item"
            return existing_models.get(model_name, Any)

        # Handle anyOf/oneOf (unions, including Optional)
        if "anyOf" in schema:
            types = [self._json_schema_to_python_type(s, existing_models) for s in schema["anyOf"]]
            # Filter out NoneType and create Union
            non_none = [t for t in types if t is not type(None)]
            has_none = type(None) in types
            if len(non_none) == 1 and has_none:
                return non_none[0] | None  # type: ignore[reportReturnType]
            # For complex unions, fall back to Any - server validates anyway
            # Creating runtime Union types is complex and not worth it for discovery
            return Any

        # Handle arrays
        if schema.get("type") == "array":
            item_schema = schema.get("items", {})
            item_type = self._json_schema_to_python_type(item_schema, existing_models)
            return list[item_type]  # type: ignore[reportReturnType]

        # Handle primitives
        json_type = schema.get("type")
        type_map: dict[str, Any] = {
            "string": str,
            "integer": int,
            "number": float,
            "boolean": bool,
            "object": dict[str, Any],
            "null": type(None),
        }

        return type_map.get(json_type, Any)  # type: ignore[reportReturnType]

    def _build_dependency_graph(self, schemas: dict[str, dict[str, Any]]) -> dict[str, set[str]]:
        """Build dependency graph from JSON Schema $ref references.

        Args:
            schemas: Dictionary of schema definitions

        Returns:
            Dictionary mapping model_name -> set of model names it depends on
        """
        deps: dict[str, set[str]] = {}

        for model_name, schema in schemas.items():
            deps[model_name] = set()
            # Recursively find all $ref references in the schema
            self._extract_refs(schema, deps[model_name])

        return deps

    def _extract_refs(self, obj: Json, refs: set[str]) -> None:
        """Recursively extract all $ref model names from a schema.

        Args:
            obj: Schema object to search (dict, list, or primitive)
            refs: Set to accumulate found model names
        """
        if isinstance(obj, dict):
            if "$ref" in obj and isinstance(obj.get("$ref"), str):
                ref_path = cast(str, obj["$ref"])  # Safe to cast because we check for str above
                model_name = ref_path.split("/")[-1]
                refs.add(model_name)
            for value in obj.values():
                self._extract_refs(value, refs)
        elif isinstance(obj, list):
            for item in obj:
                self._extract_refs(item, refs)

    def _topological_sort(self, deps: dict[str, set[str]]) -> list[str]:
        """Topologically sort models by dependencies.

        Models with no dependencies come first, then models that depend on them, etc.

        Args:
            deps: Dictionary mapping model_name -> set of dependencies

        Returns:
            List of model names in dependency order

        Raises:
            RuntimeError: If circular dependencies are detected
        """
        # Kahn's algorithm for topological sort
        # in_degree represents how many dependencies a node has
        in_degree: dict[str, int] = {node: len(deps[node]) for node in deps}

        # Queue of nodes with no dependencies (in_degree == 0)
        queue: list[str] = [node for node, degree in in_degree.items() if degree == 0]
        result: list[str] = []

        while queue:
            # Sort for deterministic output
            queue.sort()
            node = queue.pop(0)
            result.append(node)

            # For each other node that depends on this node, reduce its in-degree
            for other_node in deps:
                if node in deps[other_node]:
                    in_degree[other_node] -= 1
                    if in_degree[other_node] == 0:
                        queue.append(other_node)

        # Check for cycles
        if len(result) != len(deps):
            unresolved = set(deps.keys()) - set(result)
            raise RuntimeError(
                f"Circular dependencies detected or missing model definition. Unresolved models: {unresolved}"
            )

        return result

    def _attach_methods(self) -> None:
        """Attach all methods as instance attributes for IDE tab completion.

        This enables IDE autocompletion by making methods discoverable via
        introspection rather than just through __getattr__.
        """
        if self._methods_metadata is None:
            return

        for method_name, metadata in self._methods_metadata.items():
            # Create and attach the method as an instance attribute
            method = self._create_dynamic_method(method_name, metadata)
            setattr(self, method_name, method)

    def __getattr__(self, name: str) -> Any:
        """Dynamically create callable methods from fetched metadata.

        Lazy loads metadata on first access.

        Args:
            name: Method name to call

        Returns:
            Callable that makes the HTTP request

        Raises:
            AttributeError: If method doesn't exist
        """
        import httpx  # type: ignore[import-not-found]

        if name.startswith("_"):
            # Don't intercept private attributes
            raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'")

        # Lazy load metadata on first method access
        if self._methods_metadata is None:
            try:
                self._fetch_methods_metadata()
            except (httpx.HTTPError, RuntimeError, CogniteException) as e:
                raise AttributeError(f"Failed to fetch methods from {self.base_url}. Is the server running?") from e

        if self._methods_metadata and name in self._methods_metadata:
            return self._create_dynamic_method(name, self._methods_metadata[name])

        available = list(self._methods_metadata.keys()) if self._methods_metadata else []
        raise AttributeError(f"Method '{name}' not found. Available methods: {available}")

    def _create_dynamic_method(self, method_name: str, metadata: dict[str, Any]) -> Any:
        """Create a callable method from metadata.

        Args:
            method_name: Name of the method
            metadata: Method metadata including path, http_method, parameters

        Returns:
            Callable that makes the HTTP request or SDK call
        """

        def dynamic_method(**kwargs: Any) -> Any:
            """Dynamically generated method that makes HTTP request or SDK call."""
            path = metadata["path"]
            http_method = metadata["http_method"].lower()
            parameters = metadata["parameters"]

            # Build path parameters
            for param in parameters:
                if param["in"] == "path" and param["name"] in kwargs:
                    param_name = param["name"]
                    if param_name in kwargs:
                        path = path.replace(f"{{{param_name}}}", str(kwargs[param_name]))

            # Build query parameters
            query_params: dict[str, Any] = {}
            for param in parameters:
                if param["in"] == "query" and param["name"] in kwargs:
                    query_params[param["name"]] = kwargs[param["name"]]

            # Build body
            body_data: dict[str, Any] = {}
            for param in parameters:
                if param["in"] == "body" and param["name"] in kwargs:
                    value = kwargs[param["name"]]
                    # Check if it's a Pydantic model
                    if hasattr(value, "model_dump"):
                        body_data[param["name"]] = value.model_dump()
                    else:
                        body_data[param["name"]] = value

            # Make the request using base class method (handles both devserver and deployed)
            try:
                data = self._call_method(
                    path=path,
                    method=http_method.upper(),
                    body=body_data if body_data else None,
                    params=query_params if query_params else None,
                )
            except RuntimeError as e:
                # Re-raise RuntimeError from _call_method (already formatted)
                raise RuntimeError(f"Method '{method_name}' failed: {e}") from e

            except Exception as e:
                # Catch any other unexpected errors
                warnings.warn(
                    f"Request failed for method '{method_name}': {e}",
                    UserWarning,
                    stacklevel=2,
                )
                raise

            # Parse response into Pydantic model if return type is a model
            if self._models:
                return_type = metadata.get("return_type", "")
                # Extract base type name (e.g., "Item" from "Item" or "list[Item]")
                base_type = m.group(1) if (m := re.search(r"([A-Z][a-zA-Z0-9_]*)", return_type)) else ""
                if base_type in self._models:
                    model_class = self._models[base_type]
                    # Handle list responses
                    if return_type.startswith("list["):
                        if isinstance(data, list):
                            data = cast(list[object], data)
                            return [model_class.model_validate(item) for item in data]
                    else:
                        # Single model response
                        return model_class.model_validate(data)

            return data

        # Set docstring
        dynamic_method.__doc__ = metadata.get("description", f"Call {method_name}")
        dynamic_method.__name__ = method_name

        return dynamic_method

    def materialize(self, output_path: str | Path | None = None) -> str | None:
        """Generate a fully typed Python client with complete models and methods.

        Creates a complete, executable Python client that can be imported and used
        in your code. Uses inspect.getsource() to extract the actual Pydantic model
        definitions, preserving all validators, Field constraints, and custom logic.

        The generated client includes:
        - Complete Pydantic models with all validators and Field constraints
        - Type-safe client methods for each endpoint
        - Proper imports and error handling
        - Full docstrings

        The generated client is self-contained and production-ready - no manual imports
        or modifications needed.

        Args:
            output_path: Path where the generated client should be saved. If None, returns the client content.

        Returns:
            Client content as string if output_path is None, otherwise None

        Raises:
            httpx.HTTPError: If the request fails
            ImportError: If jinja2 is not installed

        Example:
            ```python
            client = FunctionClient("http://localhost:8000")

            # Save to file
            client.materialize("clients/asset_management.py")

            # Or print to stdout for testing
            print(client.materialize())

            # Import and use the generated client (when saved)
            from clients.asset_management import AssetManagementClient, Item

            typed_client = AssetManagementClient("http://localhost:8000")
            item = Item(name="Widget", price=99.99)
            result = typed_client.create_item(item)
            ```
        """
        try:
            import jinja2  # type: ignore[import-not-found]  # noqa: F401
        except ImportError as e:
            raise ImportError(
                "Client generation requires jinja2. Install it with: pip install cognite-typed-functions[client]"
            ) from e

        # Fetch complete metadata
        metadata = self._fetch_full_metadata()

        # Generate client content using Jinja2
        content = self._render_client_template(metadata)

        if output_path is None:
            return content

        output_path = Path(output_path)
        output_path.parent.mkdir(parents=True, exist_ok=True)

        with output_path.open("w") as f:
            f.write(content)

        print(f"✓ Generated typed client: {output_path}")  # noqa: T201
        return None

    def _render_client_template(self, metadata: dict[str, Any]) -> str:
        """Render full client using Jinja2 template.

        Args:
            metadata: Metadata dictionary with 'methods', 'models', and 'imports' keys

        Returns:
            Generated client file content
        """
        from jinja2 import Template  # type: ignore[import-not-found]

        # Get app title from OpenAPI spec using /__schema__ endpoint
        # _call_method automatically handles unwrapping the response envelope
        openapi_spec = self._call_method("/__schema__", HTTPMethod.GET)
        info = openapi_spec.get("info", {})
        app_title = info.get("title", "Generated API")

        # Generate class name from app title
        words: list[str] = []
        for word in app_title.split():
            if word.isupper() and len(word) > 1:
                words.append(word)
            else:
                words.append(word.capitalize())
        class_name = "".join(words) + "Client"

        # Load template from package resources
        try:
            # Python 3.9+ importlib.resources approach
            from importlib.resources import files  # type: ignore[attr-defined]

            template_path = files("cognite_typed_functions.templates").joinpath("client_template.jinja2")
            template_content = template_path.read_text()
        except (ImportError, AttributeError):
            # Fallback for older Python versions
            from importlib.resources import read_text  # type: ignore[attr-defined]

            template_content = read_text("cognite_typed_functions.templates", "client_template.jinja2")

        template = Template(template_content)  # type: ignore[reportUnknownVariableType]

        return template.render(  # type: ignore[reportUnknownMemberType,reportReturnType]
            app_title=app_title,
            class_name=class_name,
            methods=metadata.get("methods", []),
            models=metadata.get("models", []),
            imports=metadata.get("imports", []),
        )
