"""Presa API client."""

from __future__ import annotations

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

from presa.errors import ApiError, AuthenticationError, PresaError, TimeoutError
from presa.types import (
    AssetInfo,
    AssetListResponse,
    AsyncJobResponse,
    BatchItem,
    BatchResult,
    BatchStatus,
    CursorPage,
    CustomFormat,
    GenerateOptions,
    GenerateResult,
    JobStatus,
    PdfMetadata,
    PresaConfig,
    RenderOptions,
    StoredResponse,
    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
        self._jobs: JobsClient | None = None
        self._batch: BatchClient | None = None
        self._assets: AssetsClient | None = None

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

    @property
    def jobs(self) -> JobsClient:
        """Access the jobs sub-client."""
        if self._jobs is None:
            self._jobs = JobsClient(self)
        return self._jobs

    @property
    def batch(self) -> BatchClient:
        """Access the batch sub-client."""
        if self._batch is None:
            self._batch = BatchClient(self)
        return self._batch

    @property
    def assets(self) -> AssetsClient:
        """Access the assets sub-client."""
        if self._assets is None:
            self._assets = AssetsClient(self)
        return self._assets

    def generate(
        self, options: GenerateOptions
    ) -> Union[GenerateResult, AsyncJobResponse, StoredResponse]:
        """Generate a PDF from HTML content."""
        body = _build_request_body(options)
        if options.async_mode:
            resp = self._request_json("POST", "/v1/pdf/from-html", body=body)
            return _parse_async_job_response(resp["data"])
        if options.store:
            resp = self._request_json("POST", "/v1/pdf/from-html", body=body)
            return _parse_stored_response(resp["data"])
        accept = options.accept or "application/pdf"
        return self._request_binary("POST", "/v1/pdf/from-html", body, accept)

    def from_url(
        self, options: GenerateOptions
    ) -> Union[GenerateResult, AsyncJobResponse, StoredResponse]:
        """Generate a PDF from a URL."""
        body = _build_request_body(options)
        if options.async_mode:
            resp = self._request_json("POST", "/v1/pdf/from-url", body=body)
            return _parse_async_job_response(resp["data"])
        if options.store:
            resp = self._request_json("POST", "/v1/pdf/from-url", body=body)
            return _parse_stored_response(resp["data"])
        accept = options.accept or "application/pdf"
        return self._request_binary("POST", "/v1/pdf/from-url", body, accept)

    def merge(
        self, sources: list[str], store: bool = False
    ) -> Union[GenerateResult, StoredResponse]:
        """Merge multiple PDFs into one."""
        body: dict[str, Any] = {"sources": sources}
        if store:
            body["store"] = True
            resp = self._request_json("POST", "/v1/pdf/merge", body=body)
            return _parse_stored_response(resp["data"])
        return self._request_binary("POST", "/v1/pdf/merge", body)

    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 _request_multipart(
        self,
        method: str,
        path: str,
        fields: dict[str, str],
        file_data: bytes,
        file_name: str,
        file_content_type: str,
    ) -> Any:
        """Make an HTTP request with multipart/form-data body."""
        boundary = "----PresaPythonSDKBoundary"
        body_parts: list[bytes] = []

        for key, value in fields.items():
            body_parts.append(f"--{boundary}\r\n".encode())
            body_parts.append(
                f'Content-Disposition: form-data; name="{key}"\r\n\r\n'.encode()
            )
            body_parts.append(f"{value}\r\n".encode())

        body_parts.append(f"--{boundary}\r\n".encode())
        body_parts.append(
            f'Content-Disposition: form-data; name="file"; filename="{file_name}"\r\n'.encode()
        )
        body_parts.append(
            f"Content-Type: {file_content_type}\r\n\r\n".encode()
        )
        body_parts.append(file_data)
        body_parts.append(f"\r\n--{boundary}--\r\n".encode())

        data = b"".join(body_parts)

        url = f"{self._base_url}{path}"
        request = urllib.request.Request(
            url,
            data=data,
            headers={
                "Content-Type": f"multipart/form-data; boundary={boundary}",
                "Accept": "application/json",
                "Authorization": f"Bearer {self._api_key}",
            },
            method=method,
        )

        try:
            with urllib.request.urlopen(request, timeout=self._timeout) as response:
                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
        )


class JobsClient:
    """Sub-client for async job operations."""

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

    def get(self, job_id: str) -> JobStatus:
        """Get the status of an async job."""
        resp = self._client._request_json("GET", f"/v1/jobs/{job_id}")
        return _parse_job_status(resp["data"])

    def poll(
        self,
        job_id: str,
        interval: float = 1.0,
        timeout: float = 300.0,
    ) -> JobStatus:
        """Poll a job until it completes or fails.

        Args:
            job_id: The job ID to poll.
            interval: Seconds between polls. Defaults to 1.0.
            timeout: Maximum seconds to wait. Defaults to 300.0.

        Returns:
            The final JobStatus once completed or failed.

        Raises:
            TimeoutError: If the job does not complete within the timeout.
        """
        deadline = time.monotonic() + timeout
        while True:
            status = self.get(job_id)
            if status.status in ("completed", "failed"):
                return status
            if time.monotonic() >= deadline:
                raise TimeoutError(timeout)
            time.sleep(interval)


class BatchClient:
    """Sub-client for batch generation operations."""

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

    def create(self, items: list[BatchItem]) -> BatchStatus:
        """Create a batch generation job."""
        serialized_items = [_build_batch_item_body(item) for item in items]
        resp = self._client._request_json(
            "POST", "/v1/batch", body={"items": serialized_items}
        )
        return _parse_batch_status(resp["data"])

    def get(self, batch_id: str) -> BatchStatus:
        """Get the status of a batch job."""
        resp = self._client._request_json("GET", f"/v1/batch/{batch_id}")
        return _parse_batch_status(resp["data"])

    def results(self, batch_id: str) -> list[BatchResult]:
        """Get the results of a completed batch job."""
        resp = self._client._request_json("GET", f"/v1/batch/{batch_id}/results")
        return [_parse_batch_result(r) for r in resp["data"]]


class AssetsClient:
    """Sub-client for asset storage operations."""

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

    def upload(
        self, file_data: bytes, name: str, content_type: str
    ) -> AssetInfo:
        """Upload an asset file."""
        resp = self._client._request_multipart(
            "POST",
            "/v1/assets",
            fields={"name": name},
            file_data=file_data,
            file_name=name,
            file_content_type=content_type,
        )
        return _parse_asset_info(resp["data"])

    def list(self, limit: int = 50, offset: int = 0) -> AssetListResponse:
        """List uploaded assets."""
        params: dict[str, str] = {
            "limit": str(limit),
            "offset": str(offset),
        }
        resp = self._client._request_json("GET", "/v1/assets", params=params)
        assets = [_parse_asset_info(a) for a in resp["data"]]
        return AssetListResponse(data=assets, total=resp.get("total", len(assets)))

    def delete(self, asset_id: str) -> None:
        """Delete an asset."""
        self._client._request_json("DELETE", f"/v1/assets/{asset_id}")


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 _parse_async_job_response(data: dict[str, Any]) -> AsyncJobResponse:
    """Parse an async job response from camelCase JSON."""
    return AsyncJobResponse(
        job_id=data["jobId"],
        status=data["status"],
        poll_url=data["pollUrl"],
    )


def _parse_job_status(data: dict[str, Any]) -> JobStatus:
    """Parse a job status from camelCase JSON."""
    return JobStatus(
        id=data["id"],
        status=data["status"],
        storage_url=data.get("storageUrl"),
        expires_at=data.get("expiresAt"),
        content_type=data.get("contentType"),
        size_bytes=data.get("sizeBytes"),
        error_code=data.get("errorCode"),
        error_message=data.get("errorMessage"),
        created_at=data.get("createdAt", ""),
        completed_at=data.get("completedAt"),
    )


def _parse_stored_response(data: dict[str, Any]) -> StoredResponse:
    """Parse a stored response from camelCase JSON."""
    return StoredResponse(
        storage_url=data["storageUrl"],
        expires_at=data["expiresAt"],
        content_type=data["contentType"],
        size_bytes=data["sizeBytes"],
    )


def _parse_batch_status(data: dict[str, Any]) -> BatchStatus:
    """Parse a batch status from camelCase JSON."""
    return BatchStatus(
        id=data["id"],
        status=data["status"],
        total_items=data["totalItems"],
        completed_items=data["completedItems"],
        failed_items=data["failedItems"],
        created_at=data["createdAt"],
        completed_at=data.get("completedAt"),
    )


def _parse_batch_result(data: dict[str, Any]) -> BatchResult:
    """Parse a batch result from camelCase JSON."""
    return BatchResult(
        item_index=data["itemIndex"],
        job_id=data["jobId"],
        status=data["status"],
        storage_url=data.get("storageUrl"),
        error_code=data.get("errorCode"),
        error_message=data.get("errorMessage"),
    )


def _parse_asset_info(data: dict[str, Any]) -> AssetInfo:
    """Parse an asset info from camelCase JSON."""
    return AssetInfo(
        id=data["id"],
        name=data["name"],
        uri=data["uri"],
        content_type=data["contentType"],
        size_bytes=data["sizeBytes"],
        created_at=data["createdAt"],
    )


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_metadata_body(metadata: PdfMetadata) -> dict[str, Any]:
    """Build metadata dict from PdfMetadata, omitting None values."""
    result: dict[str, Any] = {}
    if metadata.title is not None:
        result["title"] = metadata.title
    if metadata.author is not None:
        result["author"] = metadata.author
    if metadata.subject is not None:
        result["subject"] = metadata.subject
    if metadata.keywords is not None:
        result["keywords"] = metadata.keywords
    if metadata.creator is not None:
        result["creator"] = metadata.creator
    return result


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
    if options.metadata is not None:
        body["metadata"] = _build_metadata_body(options.metadata)
    if options.async_mode is not None:
        body["async"] = options.async_mode
    if options.store is not None:
        body["store"] = options.store
    return body


def _build_batch_item_body(item: BatchItem) -> dict[str, Any]:
    """Build request body for a single batch item."""
    body: dict[str, Any] = {}
    if item.html is not None:
        body["html"] = item.html
    if item.url is not None:
        body["url"] = item.url
    if item.format is not None:
        if isinstance(item.format, CustomFormat):
            body["format"] = asdict(item.format)
        else:
            body["format"] = item.format
    if item.landscape is not None:
        body["landscape"] = item.landscape
    if item.margins is not None:
        margins = {k: v for k, v in asdict(item.margins).items() if v is not None}
        if margins:
            body["margins"] = margins
    if item.print_background is not None:
        body["printBackground"] = item.print_background
    if item.scale is not None:
        body["scale"] = item.scale
    if item.header_html is not None:
        body["headerHtml"] = item.header_html
    if item.footer_html is not None:
        body["footerHtml"] = item.footer_html
    if item.width is not None:
        body["width"] = item.width
    if item.height is not None:
        body["height"] = item.height
    if item.metadata is not None:
        body["metadata"] = _build_metadata_body(item.metadata)
    return body
