"""pydantic models for Open API."""

import json
import os
import re
from enum import StrEnum
from typing import Annotated, Any

import httpx
import jsonref
import yaml
from openapi_pydantic.v3.v3_0 import OpenAPI, Operation, PathItem
from openapi_pydantic.v3.v3_0.parameter import Parameter
from pydantic import BaseModel, Field, field_validator
from requests.exceptions import HTTPError


class HttpMethod(StrEnum):
    """Enum for HTTP methods."""

    get = "get"
    post = "post"
    put = "put"
    patch = "patch"
    delete = "delete"
    options = "options"
    head = "head"
    trace = "trace"


class ApiOperation(BaseModel):
    """Represents an OpenAPI operation with associated metadata and parameters.

    Attributes:
        service_name (str): Name of the service this operation belongs to.
        operationId (str): Unique identifier for the operation.
        method (Optional[HttpMethod]): HTTP method (e.g., GET, POST) for the operation.
        path (str): URL path for the operation.
        parameters (dict): Dictionary of parameters by name with full Parameter objects
        body (Optional[dict]): Request body for the operation, if applicable.

    Methods:
        append_parameters(parameters: List[Union[Parameter, Reference]]):
            Appends a list of parameters to the operation.
    """

    service_name: Annotated[
        str,
        Field(
            "not-set",
            description="",
        ),
    ]

    operation_id: Annotated[
        str,
        Field(
            "not-set",
            description="",
        ),
    ]
    method: HttpMethod | None = None
    path: str
    parameters: dict[str, Parameter] = {}
    body: dict | None = None

    def append_parameters(self, parameters: list[Parameter]) -> None:
        """Append parameters to the operation.

        Stores full Parameter objects with their metadata (required, type, etc.)
        for validation against step definitions.
        """
        for param in parameters:
            if param is None:
                continue

            # Store full Parameter object by name
            self.parameters[param.name] = param


class OperationRegistry(BaseModel):
    """Registry for OpenAPI operations."""

    operations: dict[str, ApiOperation] = Field(
        {},
        description="Dictionary of operations keyed by ID",
    )

    @classmethod
    @field_validator("operations")
    def check_unique_ids(cls: Any, v: dict[str, OpenAPI]) -> dict[str, OpenAPI]:
        """Ensure that all operation IDs are unique inside a workflow."""
        if len(v) != len(set(v.keys())):
            raise ValueError("Duplicate IDs found in operations")
        return v

    def append(self, openapi_spec: str) -> None:
        """Append operations from an OpenAPI specification to the registry."""
        self.operations.update(OpenApiLoader.load(url=openapi_spec))


class OpenApiLoader:
    """Loader for OpenAPI specifications."""

    @staticmethod
    def _is_remote(url: str) -> bool:
        """Check if the URL is a remote URL."""
        # Regular expression pattern for URL validation
        pattern = re.compile(
            r"^(https?|ftp)://"  # http:// or https:// or ftp://
            r"(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?)|"  # domain...
            r"localhost|"  # localhost...
            r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})"  # ...or ip
            r"(?::\d+)?"  # optional port
            r"(?:/?|[/?]\S+)$",
            re.IGNORECASE,
        )

        return re.match(pattern, url) is not None

    @staticmethod
    def _resolve_url(url: str, spec_path: str | None = None) -> str:
        """Resolve a source URL relative to the specification file.

        Args:
            url: The URL to resolve (can be absolute or relative)
            spec_path: Optional path to the specification file (used for relative URL resolution)

        Returns:
            The resolved URL
        """
        # If URL is remote, return as-is
        if url.startswith(("http://", "https://", "ftp://")):
            return url

        # If we have a spec path and the URL is relative, resolve it
        if spec_path and not os.path.isabs(url):
            spec_dir = os.path.dirname(os.path.abspath(spec_path))
            return os.path.normpath(os.path.join(spec_dir, url))

        return url

    @staticmethod
    def _download_file(url: str) -> dict:
        """Download a file from a URL and return its content as a dictionary."""
        # Send a GET request to the URL
        try:
            response = httpx.get(url)
            response.raise_for_status()
        except httpx.HTTPStatusError as exc:
            raise HTTPError(f"Failed to download file. Status code: {exc.response.status_code}") from exc
        except httpx.RequestError as exc:
            raise HTTPError(f"An error occurred while requesting {url}: {exc}") from exc

        if url.endswith(".json"):
            return response.json()
        if url.endswith((".yaml", ".yml")):
            return yaml.safe_load(response.text)
        raise ValueError(
            "Unsupported file type. Only JSON and YAML files are supported.",
        )

    @staticmethod
    def _process_operation(
        path_item: PathItem,
        operation_method: HttpMethod,
        operation_data: Operation,
        operation: ApiOperation,
    ) -> None:
        """Helper function to process an operation method."""
        if operation_data.operationId is None:
            raise ValueError("Operation ID is required but not provided in the OpenAPI specification.")

        operation.operation_id = operation_data.operationId
        operation.method = operation_method
        parameters_merged: list[Any] = []
        if path_item.parameters is not None:
            parameters_merged.extend(path_item.parameters)
        if operation_data.parameters is not None:
            parameters_merged.extend(operation_data.parameters)
        operation.append_parameters(parameters_merged)

    @staticmethod
    def load(url: str) -> dict[str, ApiOperation]:
        """Load OpenAPI specification from a URL or file and return operations."""
        operations = {}
        spec_dict = None

        # detect if http or path
        if OpenApiLoader._is_remote(url):
            spec_dict = OpenApiLoader._download_file(url)
        else:
            with open(url) as file:
                if url.endswith(".json"):
                    spec_dict = json.load(file)
                if url.endswith((".yaml", ".yml")):
                    spec_dict = yaml.safe_load(file)

        # resolve all $ref
        resolved_data = jsonref.loads(json.dumps(spec_dict))

        open_api_spec = OpenAPI(**resolved_data)

        # just accumulate all parameters at the operation level

        for path_name, path_item in (open_api_spec.paths or {}).items():
            method_handlers = {
                "post": (HttpMethod.post, path_item.post),
                "get": (HttpMethod.get, path_item.get),
                "put": (HttpMethod.put, path_item.put),
                "delete": (HttpMethod.delete, path_item.delete) if hasattr(path_item, "delete") else None,
                "patch": (HttpMethod.patch, path_item.patch) if hasattr(path_item, "patch") else None,
            }

            method_handlers = {k: v for k, v in method_handlers.items() if v is not None}

            for _, (http_method, operation_data) in method_handlers.items(): #type: ignore[misc]
                if operation_data is not None:
                    operation = ApiOperation(
                        service_name=open_api_spec.info.title,
                        operation_id="no-set",
                        method=None,
                        path=path_name,
                        parameters={},
                        body=None,
                    )
                    OpenApiLoader._process_operation(path_item, http_method, operation_data, operation)
                    operations[operation.operation_id] = operation

        return operations
