"""Presa API client."""

from __future__ import annotations

import json
import os
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import asdict
from typing import Any, NoReturn

from presa.errors import ApiError, AuthenticationError, PresaError, TimeoutError
from presa.types import (
    CursorPage,
    CustomFormat,
    GenerateOptions,
    GenerateResult,
    Margins,
    PresaConfig,
    RenderOptions,
    TemplateInfo,
)


class Presa:
    """Client for the Presa HTML-to-PDF API."""

    def __init__(self, config: PresaConfig | None = None) -> None:
        cfg = config or PresaConfig()
        api_key = cfg.api_key or os.environ.get("PRESA_API_KEY")
        if not api_key:
            raise AuthenticationError()
        self._api_key = api_key
        self._base_url = cfg.base_url.rstrip("/")
        self._timeout = cfg.timeout
        self._templates: TemplatesClient | None = None

    @property
    def templates(self) -> TemplatesClient:
        """Access the templates sub-client."""
        if self._templates is None:
            self._templates = TemplatesClient(self)
        return self._templates

    def generate(self, options: GenerateOptions) -> GenerateResult:
        """Generate a PDF from HTML content."""
        body = _build_request_body(options)
        accept = options.accept or "application/pdf"
        return self._request_binary("POST", "/v1/pdf/from-html", body, accept)

    def from_url(self, options: GenerateOptions) -> GenerateResult:
        """Generate a PDF from a URL."""
        body = _build_request_body(options)
        accept = options.accept or "application/pdf"
        return self._request_binary("POST", "/v1/pdf/from-url", body, accept)

    def _request_binary(
        self,
        method: str,
        path: str,
        body: dict[str, Any],
        accept: str = "application/pdf",
    ) -> GenerateResult:
        """Make an HTTP request expecting a binary response."""
        url = f"{self._base_url}{path}"
        request = urllib.request.Request(
            url,
            data=json.dumps(body).encode("utf-8"),
            headers={
                "Content-Type": "application/json",
                "Accept": accept,
                "Authorization": f"Bearer {self._api_key}",
            },
            method=method,
        )
        return self._do_binary_request(request)

    def _request_json(
        self,
        method: str,
        path: str,
        body: dict[str, Any] | None = None,
        params: dict[str, str] | None = None,
    ) -> Any:
        """Make an HTTP request expecting a JSON response."""
        url = f"{self._base_url}{path}"
        if params:
            url = f"{url}?{urllib.parse.urlencode(params)}"

        data: bytes | None = None
        if body is not None:
            data = json.dumps(body).encode("utf-8")

        headers: dict[str, str] = {
            "Accept": "application/json",
            "Authorization": f"Bearer {self._api_key}",
        }
        if data is not None:
            headers["Content-Type"] = "application/json"

        request = urllib.request.Request(
            url,
            data=data,
            headers=headers,
            method=method,
        )

        try:
            with urllib.request.urlopen(request, timeout=self._timeout) as response:
                if response.status == 204:
                    return None
                return json.loads(response.read().decode("utf-8"))
        except urllib.error.HTTPError as exc:
            self._handle_http_error(exc)
        except urllib.error.URLError as exc:
            if "timed out" in str(exc.reason):
                raise TimeoutError(self._timeout) from exc
            raise PresaError(f"Network error: {exc.reason}") from exc

    def _do_binary_request(self, request: urllib.request.Request) -> GenerateResult:
        """Execute a request and return binary data as GenerateResult."""
        try:
            with urllib.request.urlopen(request, timeout=self._timeout) as response:
                raw_bytes = response.read()
                return GenerateResult(buffer=raw_bytes, size=len(raw_bytes))
        except urllib.error.HTTPError as exc:
            self._handle_http_error(exc)
        except urllib.error.URLError as exc:
            if "timed out" in str(exc.reason):
                raise TimeoutError(self._timeout) from exc
            raise PresaError(f"Network error: {exc.reason}") from exc

    def _handle_http_error(self, exc: urllib.error.HTTPError) -> NoReturn:
        """Handle HTTP errors from the API."""
        if exc.code == 401:
            raise AuthenticationError(
                "Invalid API key. Check your PRESA_API_KEY value."
            ) from exc

        error_body: dict[str, Any] | None = None
        try:
            error_body = json.loads(exc.read().decode("utf-8"))
        except (json.JSONDecodeError, UnicodeDecodeError):
            pass

        if error_body and "error" in error_body:
            err = error_body["error"]
            raise ApiError(
                code=err.get("code", "UNKNOWN"),
                message=err.get("message", "Unknown error"),
                status=err.get("status", exc.code),
            ) from exc

        raise PresaError(
            f"API request failed with status {exc.code}"
        ) from exc


class TemplatesClient:
    """Sub-client for template operations."""

    def __init__(self, client: Presa) -> None:
        self._client = client

    def create(self, name: str, html: str) -> TemplateInfo:
        """Create a new template."""
        resp = self._client._request_json(
            "POST", "/v1/templates", body={"name": name, "html": html}
        )
        return _parse_template_info(resp["data"])

    def list(
        self,
        limit: int | None = None,
        after: str | None = None,
    ) -> CursorPage:
        """List templates with cursor pagination."""
        params: dict[str, str] = {}
        if limit is not None:
            params["limit"] = str(limit)
        if after is not None:
            params["after"] = after

        resp = self._client._request_json("GET", "/v1/templates", params=params)
        templates = [_parse_template_info(t) for t in resp["data"]]
        return CursorPage(
            data=templates,
            next_cursor=resp.get("nextCursor"),
            has_more=resp.get("hasMore", False),
        )

    def get(self, id: str) -> TemplateInfo:
        """Get a template by ID."""
        resp = self._client._request_json("GET", f"/v1/templates/{id}")
        return _parse_template_info(resp["data"])

    def update(self, id: str, name: str, html: str) -> TemplateInfo:
        """Update a template."""
        resp = self._client._request_json(
            "PUT", f"/v1/templates/{id}", body={"name": name, "html": html}
        )
        return _parse_template_info(resp["data"])

    def delete(self, id: str) -> None:
        """Delete a template."""
        self._client._request_json("DELETE", f"/v1/templates/{id}")

    def render(
        self,
        id: str,
        variables: dict[str, object] | None = None,
        strict_variables: bool | None = None,
        accept: str | None = None,
        **kwargs: Any,
    ) -> GenerateResult:
        """Render a template to PDF or image."""
        body: dict[str, Any] = {}
        if variables is not None:
            body["variables"] = variables
        if strict_variables is not None:
            body["strictVariables"] = strict_variables

        # Build render-specific options from kwargs
        render_opts = RenderOptions(
            variables=variables,
            strict_variables=strict_variables,
            **kwargs,
        )
        body.update(_build_render_body(render_opts))

        accept_header = accept or "application/pdf"
        return self._client._request_binary(
            "POST", f"/v1/templates/{id}/render", body, accept_header
        )


def _parse_template_info(data: dict[str, Any]) -> TemplateInfo:
    """Parse a template info dict from camelCase JSON to TemplateInfo."""
    return TemplateInfo(
        id=data["id"],
        name=data["name"],
        slug=data["slug"],
        html=data["html"],
        variables=data.get("variables"),
        created_at=data["createdAt"],
        updated_at=data["updatedAt"],
    )


def _build_render_body(options: RenderOptions) -> dict[str, Any]:
    """Build request body from RenderOptions (excluding variables/strictVariables)."""
    body: dict[str, Any] = {}
    if options.format is not None:
        if isinstance(options.format, CustomFormat):
            body["format"] = asdict(options.format)
        else:
            body["format"] = options.format
    if options.landscape is not None:
        body["landscape"] = options.landscape
    if options.margins is not None:
        margins = {k: v for k, v in asdict(options.margins).items() if v is not None}
        if margins:
            body["margins"] = margins
    if options.print_background is not None:
        body["printBackground"] = options.print_background
    if options.scale is not None:
        body["scale"] = options.scale
    if options.header_html is not None:
        body["headerHtml"] = options.header_html
    if options.footer_html is not None:
        body["footerHtml"] = options.footer_html
    if options.width is not None:
        body["width"] = options.width
    if options.height is not None:
        body["height"] = options.height
    return body


def _build_request_body(options: GenerateOptions) -> dict[str, Any]:
    body: dict[str, Any] = {}
    if options.html:
        body["html"] = options.html
    if options.url is not None:
        body["url"] = options.url
    if options.format is not None:
        if isinstance(options.format, CustomFormat):
            body["format"] = asdict(options.format)
        else:
            body["format"] = options.format
    if options.landscape is not None:
        body["landscape"] = options.landscape
    if options.margins is not None:
        margins = {k: v for k, v in asdict(options.margins).items() if v is not None}
        if margins:
            body["margins"] = margins
    if options.print_background is not None:
        body["printBackground"] = options.print_background
    if options.scale is not None:
        body["scale"] = options.scale
    if options.header_html is not None:
        body["headerHtml"] = options.header_html
    if options.footer_html is not None:
        body["footerHtml"] = options.footer_html
    if options.width is not None:
        body["width"] = options.width
    if options.height is not None:
        body["height"] = options.height
    return body
