# Copyright (c) Microsoft. All rights reserved.

import logging
from collections import OrderedDict
from collections.abc import Generator
from typing import TYPE_CHECKING, Any, Final

from prance import ResolvingParser
from prance.util.resolver import RESOLVE_FILES, RESOLVE_HTTP, RESOLVE_INTERNAL

from semantic_kernel.connectors.openapi_plugin.models.rest_api_expected_response import RestApiExpectedResponse
from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation import RestApiOperation
from semantic_kernel.connectors.openapi_plugin.models.rest_api_parameter import RestApiParameter
from semantic_kernel.connectors.openapi_plugin.models.rest_api_parameter_location import RestApiParameterLocation
from semantic_kernel.connectors.openapi_plugin.models.rest_api_payload import RestApiPayload
from semantic_kernel.connectors.openapi_plugin.models.rest_api_payload_property import RestApiPayloadProperty
from semantic_kernel.connectors.openapi_plugin.models.rest_api_security_requirement import RestApiSecurityRequirement
from semantic_kernel.connectors.openapi_plugin.models.rest_api_security_scheme import RestApiSecurityScheme
from semantic_kernel.exceptions.function_exceptions import PluginInitializationError

if TYPE_CHECKING:
    from semantic_kernel.connectors.openapi_plugin.openapi_function_execution_parameters import (
        OpenAPIFunctionExecutionParameters,
    )

logger: logging.Logger = logging.getLogger(__name__)


class OpenApiParser:
    """NOTE: SK Python only supports the OpenAPI Spec >=3.0.

    Import an OpenAPI file.

    Args:
        openapi_file: The path to the OpenAPI file which can be local or a URL.

    Returns:
        The parsed OpenAPI file


    :param openapi_file: The path to the OpenAPI file which can be local or a URL.
    :return: The parsed OpenAPI file
    """

    PAYLOAD_PROPERTIES_HIERARCHY_MAX_DEPTH: int = 10
    SUPPORTED_MEDIA_TYPES: Final[list[str]] = ["application/json", "text/plain"]

    def parse(
        self,
        openapi_document: str,
        enable_file_ref_resolution: bool = False,
        enable_http_ref_resolution: bool = False,
    ) -> Any | dict[str, Any] | None:
        """Parse the OpenAPI document.

        Args:
            openapi_document: The path or URL to the OpenAPI document.
            enable_file_ref_resolution: Whether to resolve local file $ref references.
                Disabled by default. When False, only internal JSON pointer references
                are resolved. Set to True if your OpenAPI spec is split across multiple
                local files.
            enable_http_ref_resolution: Whether to resolve external HTTP $ref references.
                Disabled by default.
        """
        resolve_types = RESOLVE_INTERNAL
        if enable_file_ref_resolution:
            resolve_types |= RESOLVE_FILES
        if enable_http_ref_resolution:
            resolve_types |= RESOLVE_HTTP
        parser = ResolvingParser(openapi_document, resolve_types=resolve_types)
        return parser.specification

    def _parse_parameters(self, parameters: list[dict[str, Any]]):
        """Parse the parameters from the OpenAPI document."""
        result: list[RestApiParameter] = []
        for param in parameters:
            name: str = param["name"]
            if not param.get("in"):
                raise PluginInitializationError(f"Parameter {name} is missing 'in' field")
            if param.get("content", None) is not None:
                # The schema and content fields are mutually exclusive.
                raise PluginInitializationError(f"Parameter {name} cannot have a 'content' field. Expected: schema.")
            location = RestApiParameterLocation(param["in"])
            description: str | None = param.get("description", None)
            is_required: bool = param.get("required", False)
            default_value = param.get("default", None)
            schema: dict[str, Any] | None = param.get("schema", None)

            result.append(
                RestApiParameter(
                    name=name,
                    type=schema.get("type", "string") if schema else "string",
                    location=location,
                    description=description,
                    is_required=is_required,
                    default_value=default_value,
                    schema=schema if schema else {"type": "string"},
                )
            )
        return result

    def _get_payload_properties(self, operation_id, schema, required_properties, level=0):
        if schema is None:
            return []

        if level > OpenApiParser.PAYLOAD_PROPERTIES_HIERARCHY_MAX_DEPTH:
            raise Exception(
                f"Max level {OpenApiParser.PAYLOAD_PROPERTIES_HIERARCHY_MAX_DEPTH} of "
                f"traversing payload properties of `{operation_id}` operation is exceeded."
            )

        result = []

        for property_name, property_schema in schema.get("properties", {}).items():
            default_value = property_schema.get("default", None)

            property = RestApiPayloadProperty(
                name=property_name,
                type=property_schema.get("type", None),
                is_required=property_name in required_properties,
                properties=self._get_payload_properties(operation_id, property_schema, required_properties, level + 1),
                description=property_schema.get("description", None),
                schema=property_schema,
                default_value=default_value,
            )

            result.append(property)

        return result

    def _create_rest_api_operation_payload(
        self, operation_id: str, request_body: dict[str, Any]
    ) -> RestApiPayload | None:
        if request_body is None or request_body.get("content") is None:
            return None

        content = request_body.get("content")
        if content is None:
            return None

        media_type = next((mt for mt in OpenApiParser.SUPPORTED_MEDIA_TYPES if mt in content), None)
        if media_type is None:
            raise Exception(f"Neither of the media types of {operation_id} is supported.")

        media_type_metadata = content[media_type]
        payload_properties = self._get_payload_properties(
            operation_id, media_type_metadata["schema"], media_type_metadata["schema"].get("required", set())
        )
        return RestApiPayload(
            media_type,
            payload_properties,
            request_body.get("description"),
            schema=media_type_metadata.get("schema", None),
        )

    def _create_response(self, responses: dict[str, Any]) -> Generator[tuple[str, RestApiExpectedResponse], None, None]:
        for response_key, response_value in responses.items():
            media_type = next(
                (mt for mt in OpenApiParser.SUPPORTED_MEDIA_TYPES if mt in response_value.get("content", {})), None
            )
            if media_type is not None:
                matching_schema = response_value["content"][media_type].get("schema", {})
                description = response_value.get("description") or matching_schema.get("description", "")
                yield (
                    response_key,
                    RestApiExpectedResponse(
                        description=description,
                        media_type=media_type,
                        schema=matching_schema if matching_schema else None,
                    ),
                )

    def _parse_security_schemes(self, components: dict) -> dict[str, dict]:
        security_schemes = {}
        schemes = components.get("securitySchemes", {})
        for scheme_name, scheme_data in schemes.items():
            security_schemes[scheme_name] = scheme_data
        return security_schemes

    def _create_rest_api_security_scheme(self, security_scheme_data: dict) -> RestApiSecurityScheme:
        return RestApiSecurityScheme(
            security_scheme_type=security_scheme_data.get("type", ""),
            description=security_scheme_data.get("description"),
            name=security_scheme_data.get("name", ""),
            in_=security_scheme_data.get("in", ""),
            scheme=security_scheme_data.get("scheme", ""),
            bearer_format=security_scheme_data.get("bearerFormat"),
            flows=security_scheme_data.get("flows"),
            open_id_connect_url=security_scheme_data.get("openIdConnectUrl", ""),
        )

    def _create_security_requirements(
        self,
        security: list[dict[str, list[str]]],
        security_schemes: dict[str, dict],
    ) -> list[RestApiSecurityRequirement]:
        security_requirements: list[RestApiSecurityRequirement] = []

        for requirement in security:
            for scheme_name, scopes in requirement.items():
                scheme_data = security_schemes.get(scheme_name)
                if not scheme_data:
                    raise PluginInitializationError(f"Security scheme '{scheme_name}' is not defined in components.")
                scheme = self._create_rest_api_security_scheme(scheme_data)
                security_requirements.append(RestApiSecurityRequirement({scheme: scopes}))

        return security_requirements

    def create_rest_api_operations(
        self,
        parsed_document: Any,
        execution_settings: "OpenAPIFunctionExecutionParameters | None" = None,
    ) -> dict[str, RestApiOperation]:
        """Create REST API operations from the parsed OpenAPI document.

        Args:
            parsed_document: The parsed OpenAPI document.
            execution_settings: The execution settings.

        Returns:
            A dictionary of RestApiOperation instances.
        """
        from semantic_kernel.connectors.openapi_plugin import OperationSelectionPredicateContext

        components = parsed_document.get("components", {})
        security_schemes = self._parse_security_schemes(components)

        paths = parsed_document.get("paths", {})
        request_objects = {}
        unique_operation_ids_registered: set[str] = set()

        servers = parsed_document.get("servers", [])

        server_urls: list[dict[str, Any]] = []

        if execution_settings and execution_settings.server_url_override:
            # Override the servers with the provided URL
            server_urls = [{"url": execution_settings.server_url_override, "variables": {}}]
        elif servers:
            # Process servers, ensuring we capture their variables
            for server in servers:
                server_entry = {
                    "url": server.get("url", "/"),
                    "variables": server.get("variables", {}),
                    "description": server.get("description", ""),
                }
                server_urls.append(server_entry)
        else:
            # Default server if none specified
            server_urls = [{"url": "/", "variables": {}, "description": ""}]

        for path, methods in paths.items():
            for method, details in methods.items():
                request_method = method.lower()
                # Validate that operationId exists
                if "operationId" not in details:
                    raise PluginInitializationError(f"operationId missing, path: '{path}', method: '{method}'")
                operationId = details["operationId"]
                if operationId in unique_operation_ids_registered:
                    raise PluginInitializationError(
                        f"Duplicate operationId: '{operationId}', path: '{path}', method: '{method}'"
                    )
                unique_operation_ids_registered.add(operationId)

                summary = details.get("summary", None)
                description = details.get("description", None)

                # Exclude operations whose path would resolve to a different effective request target
                # than the one offered to the selection predicate: a dot-segment (encoded or literal)
                # or a non-relative (absolute / authority-changing) path. Excluding them here keeps
                # operation selection and request construction on one canonical target so such a path
                # cannot bypass an include/exclude operation-selection filter.
                if RestApiOperation._contains_dot_segment(path) or RestApiOperation._is_non_relative_path(path):
                    logger.warning(
                        f"Skipping operation {operationId} at path '{path}' because it does not resolve to a "
                        f"relative path on the configured server."
                    )
                    continue

                context = OperationSelectionPredicateContext(operationId, path, method, description)
                if (
                    execution_settings
                    and execution_settings.operation_selection_predicate
                    and not execution_settings.operation_selection_predicate(context)
                ):
                    logger.info(f"Skipping operation {operationId} based on custom predicate.")
                    continue

                if execution_settings and operationId in execution_settings.operations_to_exclude:
                    logger.info(f"Skipping operation {operationId} as it is excluded.")
                    continue

                parameters = details.get("parameters", [])
                parsed_params = self._parse_parameters(parameters)
                request_body = self._create_rest_api_operation_payload(operationId, details.get("requestBody", None))
                responses = dict(self._create_response(details.get("responses", {})))

                operation_security = details.get("security", [])
                security_requirements = self._create_security_requirements(operation_security, security_schemes)

                rest_api_operation = RestApiOperation(
                    id=operationId,
                    method=request_method,
                    servers=server_urls,
                    path=path,
                    params=parsed_params,
                    request_body=request_body,
                    summary=summary,
                    description=description,
                    responses=OrderedDict(responses),
                    security_requirements=security_requirements,
                )

                request_objects[operationId] = rest_api_operation
        return request_objects
