"""Synchronous HTTP session for the SDK.

This file is automatically @generated from the Meraki API specification.
Do not edit this file manually.
"""

import logging
import random
import time
import urllib.parse
from collections.abc import Callable, Iterator
from datetime import UTC, datetime
from typing import Any, Generic, Literal, Self, TypeVar, overload

import httpx
import pydantic
from pydantic import BaseModel, model_validator

from meraki_client._common import BaseURL, format_user_agent_caller, handle_3xx
from meraki_client.exceptions import (
    InvalidResponseError,
    MerakiConnectionError,
    MerakiHTTPError,
    MerakiTimeoutError,
    _raise_http_error,
)

log = logging.getLogger(__name__)

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


class _PaginatedWrapper(BaseModel, Generic[T]):
    """Wrapper for paginated responses that normalizes different formats."""

    items: list[T]
    is_events: bool = False
    meta: dict[str, Any] | None = None

    @model_validator(mode="before")
    @classmethod
    def normalize_input(cls, data: Any) -> dict[str, Any]:
        """Normalize list, items, or events responses to a common format."""
        if isinstance(data, list):
            return {"items": data}
        if isinstance(data, dict):
            if "events" in data:
                return {"items": data["events"], "is_events": True, "meta": data.get("meta")}
            if "items" in data:
                return {"items": data["items"], "meta": data.get("meta")}
            raise ValueError("Paginated response did not contain 'items' or 'events' key")
        return {"items": []}


class PaginatedResponse(Iterator[T], Generic[T]):
    """Lazy paginated response that can be iterated or collected.

    Attributes:
        meta: Metadata from the most recently fetched page, when present.
        meta_pages: Metadata from all fetched pages in order.

    """

    def __init__(self, page_fetcher: Callable[[], Iterator[T]]) -> None:
        self._page_fetcher = page_fetcher
        self._iterator: Iterator[T] | None = None
        self._exhausted = False
        self.meta: dict[str, Any] | None = None
        self.meta_pages: list[dict[str, Any]] = []

    def _ensure_iterator(self) -> Iterator[T]:
        if self._iterator is None:
            self._iterator = self._page_fetcher()
        return self._iterator

    def __iter__(self) -> Self:
        return self

    def __next__(self) -> T:
        if self._exhausted:
            raise StopIteration
        try:
            return next(self._ensure_iterator())
        except StopIteration:
            self._exhausted = True
            raise

    def collect(self) -> list[T]:
        """Collect all remaining items into a list."""
        return list(self)


def encode_params(
    data: dict[str, Any] | None,
) -> list[tuple[str, str | int | float | None]] | None:
    """Encode parameters for Meraki API.

    Handles the "array of objects" query parameter format used by Meraki:
    {"param": [{"key_1":"value_1"}, {"key_2":"value_2"}]} => ?param[]key_1=value_1&param[]key_2=value_2

    For simple values, produces standard encoding:
    {"param": ["a", "b"]} => ?param=a&param=b
    """
    if data is None:
        return None

    result: list[tuple[str, str | int | float | None]] = []
    for key, value in data.items():
        if value is None:
            continue
        if isinstance(value, (list, tuple)):
            for item in value:
                if isinstance(item, dict):
                    for k, v in item.items():
                        result.append((f"{key}{k}", v if isinstance(v, (int, float)) else str(v)))
                elif isinstance(item, (int, float)):
                    result.append((key, item))
                else:
                    result.append((key, str(item)))
        elif isinstance(value, (int, float)):
            result.append((key, value))
        else:
            result.append((key, str(value)))
    return result


class Session:
    """HTTP session for the SDK."""

    def __init__(
        self,
        *,
        api_key: str,
        base_url: BaseURL,
        single_request_timeout: int,
        total_request_timeout: int,
        certificate_path: str | None,
        proxy: str | None,
        wait_on_rate_limit: bool,
        maximum_retries: int,
        caller: str | None,
        version: str,
    ) -> None:
        self._base_url = base_url.value
        self._single_request_timeout = single_request_timeout
        self._total_request_timeout = total_request_timeout
        self._certificate_path = certificate_path
        self._proxy = proxy
        self._wait_on_rate_limit = wait_on_rate_limit
        self._maximum_retries = maximum_retries

        # Initialize httpx client
        self._client = httpx.Client(
            headers={
                "Authorization": f"Bearer {api_key}",
                "Content-Type": "application/json",
                "User-Agent": f"meraki-client-python/{version} {format_user_agent_caller(caller)}",
            },
            timeout=httpx.Timeout(single_request_timeout),
            verify=certificate_path or True,
            proxy=proxy,
        )

    def close(self) -> None:
        """Close the HTTP client."""
        self._client.close()

    def __enter__(self) -> Self:
        return self

    def __exit__(self, *args: object) -> None:
        self.close()

    def _check_total_timeout(self, start_time: float, operation: str) -> None:
        """Check if total timeout has been exceeded."""
        elapsed = time.monotonic() - start_time
        if elapsed >= self._total_request_timeout:
            raise MerakiTimeoutError(
                f"{operation} - Total timeout of {self._total_request_timeout}s exceeded "
                f"(elapsed: {elapsed:.1f}s)"
            )

    def _request(
        self,
        *,
        operation: str,
        method: str,
        path: str,
        params: dict[str, Any] | None = None,
        json: dict[str, Any] | None = None,
        current_page: int | None = None,
    ) -> httpx.Response:
        """Make an HTTP request to the API endpoint."""
        url = path if path.startswith(("http://", "https://")) else f"{self._base_url}{path}"
        start_time = time.monotonic()

        retries = self._maximum_retries
        redirects = 0

        response: httpx.Response
        while retries >= 0:
            self._check_total_timeout(start_time, operation)
            if current_page is not None:
                log.debug(f"{operation} - {method} {url} (page={current_page})")
            else:
                log.debug(f"{operation} - {method} {url}")
            try:
                response = self._client.request(
                    method,
                    url,
                    params=encode_params(params),
                    json=json,
                    follow_redirects=False,
                )
            except httpx.RequestError as e:
                if retries == 0:
                    raise MerakiConnectionError(cause=e) from e
                retries -= 1
                log.warning(f"{operation} - {e}, retrying in 1 second")
                self._check_total_timeout(start_time, operation)
                time.sleep(1)
                continue

            reason = response.reason_phrase or "ERROR"
            status = response.status_code

            # Handle 3xx redirects manually
            if 300 <= status < 400:
                if redirects >= 3:
                    raise MerakiHTTPError(
                        f"Maximum number of redirects reached: {redirects}",
                        response=response,
                    )
                url, base_url = handle_3xx(response)
                self._base_url = base_url
                redirects += 1
                continue

            # Handle 2xx success
            if 200 <= status < 300:
                return response

            # Handle rate limiting
            if status == 429 and self._wait_on_rate_limit and retries > 0:
                wait = int(response.headers.get("Retry-After", random.randint(2, 5)))
                log.warning(f"{operation} - {status} {reason}, retrying in {wait} seconds")
                self._check_total_timeout(start_time, operation)
                time.sleep(wait)
                retries -= 1
                continue

            # Handle 4xx errors
            if 400 <= status < 500:
                raise _raise_http_error(response)

            # Handle 5xx errors
            if 500 <= status < 600:
                if retries == 0:
                    raise _raise_http_error(response)
                retries -= 1
                log.warning(f"{operation} - {status} {reason}, retrying in 1 second")
                self._check_total_timeout(start_time, operation)
                time.sleep(1)
                continue

            raise _raise_http_error(response)

        raise RuntimeError(
            f"Maximum number of retries reached: {retries}. This should never happen."
        )

    @overload
    def get(
        self,
        *,
        scope: str,
        operation_id: str,
        path: str,
        params: dict[str, Any] | None = None,
    ) -> None: ...

    @overload
    def get(
        self,
        *,
        scope: str,
        operation_id: str,
        path: str,
        params: dict[str, Any] | None = None,
        response_schema: type[T],
    ) -> T: ...

    @overload
    def get(
        self,
        *,
        scope: str,
        operation_id: str,
        path: str,
        params: dict[str, Any] | None = None,
        response_schema: type[T],
        optional_response: Literal[False],
    ) -> T: ...

    @overload
    def get(
        self,
        *,
        scope: str,
        operation_id: str,
        path: str,
        params: dict[str, Any] | None = None,
        response_schema: type[T],
        optional_response: Literal[True],
    ) -> T | None: ...

    def get(
        self,
        *,
        scope: str,
        operation_id: str,
        path: str,
        params: dict[str, Any] | None = None,
        response_schema: type[T] | None = None,
        optional_response: bool = False,
    ) -> T | None:
        """Make a GET request to the API endpoint."""
        response = self._request(
            operation=f"{scope}.{operation_id}", method="GET", path=path, params=params
        )
        if response_schema is None or (optional_response and response.status_code == 204):
            return None
        try:
            return response_schema.model_validate_json(response.text)
        except pydantic.ValidationError as e:
            raise InvalidResponseError(
                f"Response validation failed for {operation_id}",
                cause=e,
                response_body=response.text,
                response=response,
            ) from e

    def get_pages(
        self,
        *,
        scope: str,
        operation_id: str,
        path: str,
        item_schema: type[T],
        params: dict[str, Any] | None = None,
        total_pages: int | Literal["all"] = -1,
        direction: Literal["next", "prev"] = "next",
        event_log_end_time: str | None = None,
    ) -> PaginatedResponse[T]:
        """Make a GET request to the API endpoint with pagination.

        Returns a PaginatedResponse that can be iterated for individual items
        or collected with .collect() to get all results as a list.
        Metadata from fetched pages is available on `.meta` and `.meta_pages`.
        """
        if isinstance(total_pages, str) and total_pages.lower() == "all":
            total_pages = -1
        elif isinstance(total_pages, int):
            pass
        else:
            raise ValueError(
                f"total_pages must be either an integer or 'all' as a string. Got {total_pages}",
            )

        def parse_and_extract(
            response: httpx.Response,
        ) -> tuple[list[T], bool, dict[str, Any] | None]:
            """Parse JSON and extract items and metadata."""
            response_content = response.content
            try:
                wrapper = _PaginatedWrapper[item_schema].model_validate_json(  # type: ignore[valid-type]
                    response_content
                )
            except pydantic.ValidationError as e:
                raise InvalidResponseError(
                    f"Response validation failed for {operation_id}",
                    cause=e,
                    response_body=response_content.decode(errors="replace"),
                    response=response,
                ) from e
            return wrapper.items, wrapper.is_events, wrapper.meta

        paginated_response: PaginatedResponse[T] | None = None

        def get_link_param(link_url: str, param: str) -> str | None:
            """Safely extract a query parameter from a pagination link."""
            parsed = urllib.parse.urlparse(str(link_url))
            query_params = urllib.parse.parse_qs(parsed.query)
            values = query_params.get(param)
            return values[0] if values else None

        def should_stop_event_pagination(link_url: str) -> bool:
            """Check if getNetworkEvents pagination should stop to prevent infinite loops."""
            if direction == "next":
                starting_after = get_link_param(link_url, "startingAfter")
                if not starting_after:
                    return False
                delta = datetime.now(tz=UTC) - datetime.fromisoformat(starting_after)
                if delta.total_seconds() < 300:
                    return True
                if event_log_end_time and starting_after > event_log_end_time:
                    return True
            else:
                ending_before = get_link_param(link_url, "endingBefore")
                if ending_before and ending_before < "2014-01-01":
                    return True
            return False

        def fetch_pages() -> Iterator[T]:
            current_page = 1
            operation = f"{scope}.{operation_id}"
            next_url: str | None = path
            remaining_pages = total_pages

            while next_url and remaining_pages != 0:
                response = self._request(
                    operation=operation,
                    method="GET",
                    path=next_url,
                    params=params if current_page == 1 else None,
                    current_page=current_page,
                )
                items, is_events, meta = parse_and_extract(response)
                if meta is not None and paginated_response is not None:
                    paginated_response.meta = meta
                    paginated_response.meta_pages.append(meta)
                # getNetworkEvents returns events in reverse chronological order,
                # reverse when paginating forward to restore chronological order
                if is_events and direction == "next":
                    items = items[::-1]
                links = response.links

                yield from items

                remaining_pages -= 1
                next_url = None

                link_key = direction
                if link_key in links:
                    link_url = str(links[link_key]["url"])
                    if operation_id == "getNetworkEvents" and should_stop_event_pagination(
                        link_url
                    ):
                        break
                    next_url = link_url
                    current_page += 1

        paginated_response = PaginatedResponse(fetch_pages)
        return paginated_response

    @overload
    def post(
        self,
        *,
        scope: str,
        operation_id: str,
        path: str,
        json: dict[str, Any] | None = None,
    ) -> None: ...

    @overload
    def post(
        self,
        *,
        scope: str,
        operation_id: str,
        path: str,
        json: dict[str, Any] | None = None,
        response_schema: type[T],
    ) -> T: ...

    def post(
        self,
        *,
        scope: str,
        operation_id: str,
        path: str,
        json: dict[str, Any] | None = None,
        response_schema: type[T] | None = None,
    ) -> T | None:
        """Make a POST request to the API endpoint."""
        response = self._request(
            operation=f"{scope}.{operation_id}", method="POST", path=path, json=json
        )
        if response_schema is None:
            return None
        try:
            return response_schema.model_validate_json(response.text)
        except pydantic.ValidationError as e:
            raise InvalidResponseError(
                f"Response validation failed for {operation_id}",
                cause=e,
                response_body=response.text,
                response=response,
            ) from e

    @overload
    def put(
        self,
        *,
        scope: str,
        operation_id: str,
        path: str,
        json: dict[str, Any] | None = None,
    ) -> None: ...

    @overload
    def put(
        self,
        *,
        scope: str,
        operation_id: str,
        path: str,
        json: dict[str, Any] | None = None,
        response_schema: type[T],
    ) -> T: ...

    def put(
        self,
        *,
        scope: str,
        operation_id: str,
        path: str,
        json: dict[str, Any] | None = None,
        response_schema: type[T] | None = None,
    ) -> T | None:
        """Make a PUT request to the API endpoint."""
        response = self._request(
            operation=f"{scope}.{operation_id}", method="PUT", path=path, json=json
        )
        if response_schema is None:
            return None
        try:
            return response_schema.model_validate_json(response.text)
        except pydantic.ValidationError as e:
            raise InvalidResponseError(
                f"Response validation failed for {operation_id}",
                cause=e,
                response_body=response.text,
                response=response,
            ) from e

    def delete(
        self, *, scope: str, operation_id: str, path: str, json: dict[str, Any] | None = None
    ) -> None:
        """Make a DELETE request to the API endpoint."""
        self._request(operation=f"{scope}.{operation_id}", method="DELETE", path=path, json=json)
