#
# Copyright 2026 The Dapr Authors
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#     http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

import json
import logging
from collections.abc import Iterable
from typing import (
    Annotated,
    Any,
    Dict,
    List,
    Literal,
    Optional,
    Type,
    TypeVar,
    Union,
    get_args,
    get_origin,
)

from pydantic import BaseModel, Field, TypeAdapter, ValidationError, create_model

from dapr_agents.tool.utils.function_calling import to_function_call_definition
from dapr_agents.types import (
    AssistantMessage,
    OAIJSONSchema,
    OAIResponseFormatSchema,
    StructureError,
)

logger = logging.getLogger(__name__)

T = TypeVar("T", bound=BaseModel)


class StructureHandler:
    @staticmethod
    def is_json_string(input_string: str) -> bool:
        """
        Check if the given input is a valid JSON string.

        Args:
            input_string (str): The string to check.

        Returns:
            bool: True if the input is a valid JSON string, False otherwise.
        """
        try:
            json.loads(input_string)
            return True
        except json.JSONDecodeError:
            return False

    @staticmethod
    def normalize_iterable_format(tp: Any) -> Any:
        origin = get_origin(tp)
        args = get_args(tp)

        if origin in (list, List, tuple, Iterable) and args:
            item_type = args[0]
            if isinstance(item_type, type) and issubclass(item_type, BaseModel):
                logger.debug(
                    "Detected iterable of BaseModel. Wrapping in generated Pydantic model."
                )
                return StructureHandler.create_iterable_model(item_type)

        return tp

    @staticmethod
    def generate_request(
        response_format: Union[Type[T], Dict[str, Any], Iterable[Type[T]]],
        llm_provider: str,
        structured_mode: Literal["json", "function_call"] = "json",
        **params,
    ) -> Dict[str, Any]:
        """
        Generates a structured request that conforms to a specified API format using the given Pydantic model.
        This function prepares a request configuration that includes the model as a tool specification and
        sets the necessary parameters for the API call.

        Args:
            response_format (Union[Type[T], Dict[str, Any], Iterable[Type[T]]]): Defines the response structure.
                - If `structured_mode="json"`: Can be a Pydantic model (converted to JSON schema) or a JSON schema dictionary.
                - If `structured_mode="function_call"`: Must be a Pydantic model or an iterable of models.
                - If an iterable of models is provided in either mode, it is treated as a list schema.
            llm_provider (str): The LLM provider (e.g., "openai", "claude").
            structured_mode (Literal["json", "function_call"]): Determines the response structure.
                - "json": Generates and enforces a strict JSON schema.
                - "function_call": Converts a Pydantic model to a function-call definition.
            **params: Additional request parameters.

        Returns:
            Dict[str, Any]: Updated request parameters, including tools or response format.

        Raises:
            ValueError: If an unsupported `structured_mode` is provided.
            TypeError: If `response_format` is invalid for the selected mode.
        """
        logger.debug(f"Structured response mode: {structured_mode}")

        response_format = StructureHandler.normalize_iterable_format(response_format)

        # Handle iterable models in both modes
        if structured_mode == "function_call":
            model_cls = StructureHandler.resolve_response_model(response_format)
            if not model_cls:
                raise TypeError(
                    "function_call mode requires a single, unambiguous Pydantic model."
                )

            name = model_cls.__name__
            description = model_cls.__doc__ or ""
            model_tool_format = to_function_call_definition(
                name, description, model_cls, llm_provider
            )

            params["tools"] = [model_tool_format]
            params["tool_choice"] = {
                "type": "function",
                "function": {"name": model_tool_format["function"]["name"]},
            }
            return params

        elif structured_mode == "json":
            try:
                logger.debug(
                    f"generate_request called with type={type(response_format)}, mode={structured_mode}, provider={llm_provider}"
                )
                # If it's a dict, assume it's already a JSON schema; otherwise, try to create from model
                if isinstance(response_format, dict):
                    raw_schema = response_format
                    name = response_format.get("name", "custom_schema")
                    description = response_format.get("description")
                elif isinstance(response_format, type) and issubclass(
                    response_format, BaseModel
                ):
                    raw_schema = response_format.model_json_schema()
                    name = response_format.__name__
                    description = response_format.__doc__
                else:
                    raise TypeError("json mode requires a dict or a Pydantic model.")

                # Enforce strict JSON schema (process $refs, $defs, etc.)
                logger.debug(f"Raw Schema: {raw_schema}")
                strict_schema = StructureHandler.enforce_strict_json_schema(raw_schema)

                # Construct the JSON schema object using OAIJSONSchema
                json_schema_obj = OAIJSONSchema(
                    name=name,
                    description=description,
                    schema_=strict_schema,
                    strict=True,
                )

                # Wrap it in the top-level response format object
                response_format_obj = OAIResponseFormatSchema(
                    json_schema=json_schema_obj
                )

                logger.debug(
                    f"Generated JSON schema: {response_format_obj.model_dump()}"
                )

                # Use model_dump() to serialize the response format into a dictionary
                params["response_format"] = response_format_obj.model_dump(
                    by_alias=True
                )

            except ValidationError as e:
                logger.error(f"Validation error in JSON schema: {e}")
                raise ValueError(f"Invalid response_format provided: {e}")

            return params

        else:
            raise ValueError(
                f"Unsupported structured_mode: {structured_mode}. Must be 'json' or 'function_call'."
            )

    @staticmethod
    def create_iterable_model(
        model: Type[BaseModel],
        model_name: Optional[str] = None,
        model_description: Optional[str] = None,
    ) -> Type[BaseModel]:
        """
        Constructs an iterable Pydantic model for a given Pydantic model.

        Args:
            model (Type[BaseModel]): The original Pydantic model to capture a list of objects of the original model type.
            model_name (Optional[str]): The name of the new iterable model. Defaults to None.
            model_description (Optional[str]): The description of the new iterable model. Defaults to None.

        Returns:
            Type[BaseModel]: A new Pydantic model class representing a list of the original Pydantic model.
        """
        model_name = model.__name__ if model_name is None else model_name
        iterable_model_name = f"Iterable{model_name}"

        objects_field = (
            List[model],
            Field(..., description=f"A list of `{model_name}` objects"),
        )

        iterable_model = create_model(
            iterable_model_name, objects=objects_field, __base__=(BaseModel,)
        )

        iterable_model.__doc__ = (
            f"A Pydantic model to capture `{iterable_model_name}` objects"
            if model_description is None
            else model_description
        )

        return iterable_model

    @staticmethod
    def extract_structured_response(
        message: AssistantMessage,
        llm_provider: str,
        structured_mode: Literal["json", "function_call"] = "json",
    ) -> Union[str, Dict[str, Any]]:
        """
        Extracts the structured JSON string or content from the response.

        Args:
            message (AssistantMessage): The API response data to extract.
            llm_provider (str): The LLM provider (e.g., 'openai').
            structured_mode (Literal["json", "function_call"]): The structured response mode.

        Returns:
            Union[str, Dict[str, Any]]: The extracted structured response.

        Raises:
            StructureError: If the structured response is not found or extraction fails.
        """
        try:
            logger.debug(f"Processing structured response for mode: {structured_mode}")
            if llm_provider in ("openai", "nvidia", "huggingface", "dapr"):
                if structured_mode == "function_call":
                    tool_calls = getattr(message, "tool_calls", None)
                    if tool_calls:
                        function = getattr(tool_calls[0], "function", None)
                        if function and hasattr(function, "arguments"):
                            extracted_response = function.arguments
                            logger.debug(
                                f"Extracted function-call response: {extracted_response}"
                            )
                            return extracted_response
                    # Fallback: LLM ignored tool_choice and returned plain JSON text
                    content = getattr(message, "content", None)
                    if content and isinstance(content, str):
                        try:
                            json.loads(content)
                            logger.warning(
                                "function_call mode: no tool_calls in response; "
                                "falling back to content as JSON."
                            )
                            return content
                        except json.JSONDecodeError:
                            pass
                    raise StructureError("No tool_calls found for function_call mode.")

                elif structured_mode == "json":
                    content = getattr(message, "content", None)
                    refusal = getattr(message, "refusal", None)

                    if refusal:
                        logger.warning(
                            f"Model refused to fulfill the request: {refusal}"
                        )
                        raise StructureError(f"Request refused by the model: {refusal}")

                    if not content:
                        raise StructureError("No content found for JSON mode.")

                    # Try to parse content as JSON first
                    try:
                        if isinstance(content, str):
                            parsed = json.loads(content)
                            logger.debug(f"Successfully parsed JSON content: {parsed}")
                            return parsed
                    except json.JSONDecodeError:
                        pass

                    # If parsing fails or content is not a string, return as is
                    logger.debug(f"Returning raw content: {content}")
                    return content

                else:
                    raise ValueError(
                        f"Unsupported structured_mode: {structured_mode}. Must be 'json' or 'function_call'."
                    )
            else:
                raise StructureError(f"Unsupported LLM provider: {llm_provider}")
        except Exception as e:
            logger.error(f"Error while extracting structured response: {e}")
            raise StructureError(f"Extraction failed: {e}")

    @staticmethod
    def validate_response(response: Union[str, dict], model: Type[T]) -> T:
        """
        Validates a JSON string or a dictionary using a specified Pydantic model.

        This method checks whether the response is a JSON string or a dictionary.
        If the response is a JSON string, it validates it using the `model_validate_json` method.
        If the response is a dictionary, it validates it using the `model_validate` method.

        Args:
            response (Union[str, dict]): The JSON string or dictionary to validate.
            model (Type[T]): The Pydantic model that defines the expected structure of the response.

        Returns:
            T: An instance of the Pydantic model populated with the validated data.

        Raises:
            StructureError: If the validation fails.
        """
        try:
            if isinstance(response, str) and StructureHandler.is_json_string(response):
                return model.model_validate_json(response)
            elif isinstance(response, dict):
                # If it's a dictionary, use model_validate
                return model.model_validate(response)
            else:
                raise ValueError("Response must be a JSON string or a dictionary.")
        except ValidationError as e:
            logger.error(f"Validation error while parsing structured response: {e}")
            raise StructureError(f"Validation failed for structured response: {e}")

    @staticmethod
    def expand_local_refs(part: Dict[str, Any], root: Dict[str, Any]) -> Dict[str, Any]:
        """
        Recursively expand all local $refs in the schema, including nested references.

        Args:
            part (Dict[str, Any]): The schema part to process.
            root (Dict[str, Any]): The root schema for resolving $refs.

        Returns:
            Dict[str, Any]: The schema part with all $refs expanded.
        """
        ref = part.pop("$ref", None)
        if ref:
            logger.debug(f"Found $ref: {ref}")
            if not ref.startswith("#/$defs/"):
                raise ValueError(f"Unexpected $ref format: {ref}")

            ref_name = ref.split("/")[-1]
            defs_section = root.get("$defs", {})
            if ref_name not in defs_section:
                raise ValueError(f"Reference '{ref_name}' not found in $defs.")

            # Merge the referenced schema with the current part, resolving nested $refs
            merged = {
                **defs_section[ref_name],
                **{k: v for k, v in part.items() if k != "$ref"},
            }
            return StructureHandler.expand_local_refs(merged, root)

        # Process objects and their properties
        if part.get("type") == "object" and "properties" in part:
            for key, value in part["properties"].items():
                part["properties"][key] = StructureHandler.expand_local_refs(
                    value, root
                )

        # Process arrays and their items
        if part.get("type") == "array" and "items" in part:
            part["items"] = StructureHandler.expand_local_refs(part["items"], root)

        # Process anyOf and allOf schemas
        for key in ("anyOf", "allOf"):
            if key in part and isinstance(part[key], list):
                part[key] = [
                    StructureHandler.expand_local_refs(subschema, root)
                    for subschema in part[key]
                ]

        return part

    @staticmethod
    def enforce_strict_json_schema(schema: Dict[str, Any]) -> Dict[str, Any]:
        """
        Enforces strict JSON schema constraints while making it OpenAI-compatible.

        - Expands all local $refs by resolving them from the $defs section.
        - Ensures "additionalProperties": false for object schemas.
        - Removes default values and replaces optional fields with `anyOf: [{"type": T}, {"type": "null"}]`.
        - Converts optional arrays to `anyOf: [{"type": "array", "items": T}, {"type": "null"}]`.
        - Ensures all `array` schemas define `items`.
        - Converts optional integers and floats to `anyOf: [{"type": T}, {"type": "null"}]`.
        - Prevents the use of `anyOf` at the root level of an array.

        Args:
            schema (Dict[str, Any]): The JSON schema dictionary to process.

        Returns:
            Dict[str, Any]: The updated schema with strict constraints applied.
        """
        # Expand all $refs (resolves and removes them)
        schema = StructureHandler.expand_local_refs(schema, schema)

        # Ensure "additionalProperties": false for all objects
        if schema.get("type") == "object":
            schema.setdefault("additionalProperties", False)

            required_fields = set(schema.get("required", []))

            for key, value in schema.get("properties", {}).items():
                schema["properties"][key] = StructureHandler.enforce_strict_json_schema(
                    value
                )

                # Remove default values (not allowed by OpenAI)
                schema["properties"][key].pop("default", None)

                # Convert optional fields (string, number, integer) to `anyOf`
                if key not in required_fields:
                    field_type = schema["properties"][key].get("type")

                    if field_type and not isinstance(
                        field_type, list
                    ):  # Ensure it's not already `anyOf`
                        if field_type in ["string", "integer", "number"]:
                            schema["properties"][key]["anyOf"] = [
                                {"type": field_type},
                                {"type": "null"},
                            ]
                            schema["properties"][key].pop(
                                "type", None
                            )  # Remove direct "type" field

                    # Ensure field is included in "required" (even if it allows null)
                    required_fields.add(key)

                # Handle optional arrays inside object properties
                if schema["properties"][key].get("anyOf") and isinstance(
                    schema["properties"][key]["anyOf"], list
                ):
                    for subschema in schema["properties"][key]["anyOf"]:
                        if subschema.get("type") == "array":
                            schema["properties"][key] = {
                                "anyOf": [
                                    {
                                        "type": "array",
                                        "items": subschema.get("items", {}),
                                    },
                                    {"type": "null"},
                                ]
                            }

            # Ensure all required fields are explicitly listed
            schema["required"] = list(required_fields)

        # Process arrays and enforce strictness
        if schema.get("type") == "array":
            # Ensure `items` is always present in arrays
            if "items" not in schema:
                raise ValueError(f"Array schema missing 'items': {schema}")

            schema["items"] = StructureHandler.enforce_strict_json_schema(
                schema["items"]
            )

            # Convert optional arrays from `anyOf` to `anyOf: [{"type": "array", "items": T}, {"type": "null"}]`
            if "anyOf" in schema and isinstance(schema["anyOf"], list):
                if any(
                    subschema.get("type") == "array" for subschema in schema["anyOf"]
                ):
                    schema["anyOf"] = [
                        {"type": "array", "items": schema["items"]},
                        {"type": "null"},
                    ]
                    schema.pop("type", None)  # Remove direct "type" field
                    schema.pop(
                        "minItems", None
                    )  # Remove `minItems`, not needed with null

        # Process $defs and remove after expansion
        if "$defs" in schema:
            for def_name, def_schema in schema["$defs"].items():
                schema["$defs"][def_name] = StructureHandler.enforce_strict_json_schema(
                    def_schema
                )
            schema.pop("$defs", None)

        # Process anyOf and allOf schemas recursively
        for key in ("anyOf", "allOf"):
            if key in schema and isinstance(schema[key], list):
                schema[key] = [
                    StructureHandler.enforce_strict_json_schema(subschema)
                    for subschema in schema[key]
                ]

        return schema

    @staticmethod
    def unwrap_annotated_type(tp: Any) -> Any:
        origin = get_origin(tp)
        if origin is Annotated:
            args = get_args(tp)
            return StructureHandler.unwrap_annotated_type(args[0])
        if hasattr(tp, "__supertype__"):  # for NewType
            return StructureHandler.unwrap_annotated_type(tp.__supertype__)
        return tp

    @staticmethod
    def resolve_all_pydantic_models(tp: Any) -> List[Type[BaseModel]]:
        models = []

        tp = StructureHandler.unwrap_annotated_type(tp)
        origin = get_origin(tp)
        args = get_args(tp)

        if isinstance(tp, type):
            try:
                if issubclass(tp, BaseModel):
                    return [tp]
            except TypeError:
                pass

        if origin in (list, List, tuple, Iterable) and args:
            inner = args[0]
            if isinstance(inner, type):
                try:
                    if issubclass(inner, BaseModel):
                        return [inner]
                except TypeError:
                    pass
            else:
                logger.debug(
                    f"[resolve] Skipping non-class inner: {inner} ({type(inner)})"
                )

        if origin is Union:
            for arg in args:
                if isinstance(arg, type):
                    try:
                        if issubclass(arg, BaseModel):
                            models.append(arg)
                    except TypeError:
                        continue

        return list(dict.fromkeys(models))

    @staticmethod
    def resolve_response_model(tp: Any) -> Optional[Type[BaseModel]]:
        """
        Resolves a single Pydantic model from a type annotation if available.

        This method attempts to extract exactly one BaseModel from the given type. It is used
        to determine if structured output formatting (e.g., JSON schema or function call) should be applied.

        - If the annotation is a BaseModel or a container of one (e.g., List[BaseModel]), it returns the model.
        - If no Pydantic models are found (e.g., str, int), it returns None without logging.
        - If multiple Pydantic models are found (e.g., Union[ModelA, ModelB]), it logs a warning and returns None.

        Args:
            tp (Any): The return type annotation to analyze.

        Returns:
            Optional[Type[BaseModel]]: The resolved model class, or None if not applicable or ambiguous.
        """
        tp = StructureHandler.unwrap_annotated_type(tp)
        models = StructureHandler.resolve_all_pydantic_models(tp)

        if len(models) == 1:
            return models[0]
        elif len(models) == 0:
            return None  # No model = primitive or unsupported type → silently skip
        else:
            logger.warning(
                f"Ambiguous model resolution: found multiple models in {tp}. Returning None."
            )
            return None

    @staticmethod
    def validate_against_signature(result: Any, expected_type: Any) -> Any:
        """
        Validates a result against an expected return annotation type.

        Supports:
        - Single BaseModel
        - List[BaseModel]
        - Union[BaseModel, ...]
        - Primitives (int, str, bool, etc.)
        - Dict[str, Any], List[Dict[str, Any]]

        Returns:
            Any: The validated and possibly transformed result (e.g., model_dump()).

        Raises:
            TypeError: If validation fails or types mismatch.
        """
        expected_type = StructureHandler.unwrap_annotated_type(expected_type)

        origin = get_origin(expected_type)
        args = get_args(expected_type)

        # Handle one or more BaseModels
        models = StructureHandler.resolve_all_pydantic_models(expected_type)
        if models:
            validation_errors = {}
            for model_cls in models:
                try:
                    # Always validate the entire result against the model class
                    # Don't try to validate individual list items
                    validated = StructureHandler.validate_response(result, model_cls)
                    return validated.model_dump()
                except ValidationError as e:
                    validation_errors[model_cls.__name__] = e
                    continue

            # If we get here, all models failed validation
            if validation_errors:
                error_details = "\n".join(
                    f"{model}: {error}" for model, error in validation_errors.items()
                )
                raise TypeError(
                    f"Validation failed for all possible models:\n{error_details}"
                )

        # Handle Union[str, dict, etc.]
        if origin is Union:
            for variant in args:
                if isinstance(variant, type) and isinstance(result, variant):
                    return result

        # Handle Dict[str, Any]
        if origin is dict and isinstance(result, dict):
            return result

        # Handle List[Dict[str, Any]]
        if origin is list and args and args[0] is dict and isinstance(result, list):
            return result

        # Fallback for primitives via TypeAdapter
        try:
            logger.debug(f"Falling back to TypeAdapter for type: {expected_type}")
            adapter = TypeAdapter(expected_type)
            return adapter.validate_python(result)
        except ValidationError as e:
            raise TypeError(f"Validation failed for type {expected_type}: {e}")

    @staticmethod
    def ensure_json_only_system_prompt(params: Dict[str, Any]) -> Dict[str, Any]:
        """
        Dapr's chat client (today) does NOT forward OpenAI-style `response_format`
        (e.g., {"type":"json_schema", ...}). That means the model won't be hard-constrained
        to your schema. As a fallback, we prepend a system message that instructs the
        model to return strict JSON so downstream parsing doesn't break.

        Note:
        - Dapr uses "inputs" (not "messages") for the message array.
        - If "inputs" isn't present (future providers), we fall back to "messages".
        """
        collection_key = "inputs" if "inputs" in params else "messages"
        msgs = list(params.get(collection_key, []))
        msgs.insert(
            0,
            {
                "role": "system",
                "content": (
                    "Return ONLY a valid JSON object that matches the provided schema. "
                    "No markdown, no code fences, no explanations—JSON object only."
                ),
            },
        )
        params[collection_key] = msgs
        return params
