# SPDX-License-Identifier: Apache-2.0
"""HPI API client — plain dicts in, plain dicts out."""

from __future__ import annotations

import json
from typing import Any
from urllib.parse import quote, urlencode

import httpx


class HPIError(Exception):
    """Raised when the HPI API returns a non-success status code."""

    def __init__(self, status: int, error: str, details: Any = None):
        self.status = status
        self.error = error
        self.details = details
        detail_str = f" — {json.dumps(details)}" if details else ""
        super().__init__(f"HTTP {status}: {error}{detail_str}")


class HPIClient:
    """Synchronous Python client for the HPI API.

    All methods accept and return plain dicts — no model classes needed.

    Args:
        base_url: Base URL of the HPI instance (e.g. ``"https://app.hpi.dev"``).
        token: API token prefixed with ``htk_``.
        timeout: Request timeout in seconds (default 30).

    Example::

        from hpi_client import HPIClient

        client = HPIClient("https://app.hpi.dev", "htk_your-token")

        task = client.create_task(
            title="Approve deploy",
            steps=[{
                "id": "review",
                "title": "Review",
                "blocks": [
                    {"type": "mdsvex", "content": "Approve **v2.1**?"},
                    {"type": "input", "field": {"name": "ok", "label": "Approve?", "kind": "checkbox"}},
                ],
            }],
        )

        result = client.wait_for_task(task["task_id"])
        print(result["data"])  # {"ok": True}
    """

    def __init__(self, base_url: str, token: str, *, timeout: float = 30):
        self._base_url = base_url.rstrip("/")
        self._token = token
        self._timeout = timeout
        self._http = httpx.Client(
            base_url=f"{self._base_url}/api/v1",
            headers={
                "Authorization": f"Bearer {token}",
                "Content-Type": "application/json",
                "X-HPI-Source": "python-sdk",
            },
            timeout=timeout,
        )

    def close(self) -> None:
        """Close the underlying HTTP connection."""
        self._http.close()

    def __enter__(self) -> HPIClient:
        return self

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

    # ── internal ─────────────────────────────────────────────────

    def _request(self, method: str, path: str, body: Any = None) -> dict[str, Any]:
        kwargs: dict[str, Any] = {"method": method, "url": path}
        if body is not None:
            kwargs["content"] = json.dumps(body)
        resp = self._http.request(**kwargs)
        try:
            data = resp.json()
        except Exception:
            raise HPIError(resp.status_code, f"{resp.reason_phrase} (non-JSON response)")
        if resp.status_code >= 400:
            error = data.get("error", resp.reason_phrase) if isinstance(data, dict) else resp.reason_phrase
            details = data.get("details") if isinstance(data, dict) else None
            raise HPIError(resp.status_code, error, details)
        return data

    # ── tasks ────────────────────────────────────────────────────

    def create_task(
        self,
        *,
        title: str,
        steps: list[dict[str, Any]],
        task_id: str | None = None,
        instructions_mdsvex: str | None = None,
        template_id: str | None = None,
        metadata: dict[str, str] | None = None,
        assignee_member_id: str | None = None,
        assignee_email: str | None = None,
        assignee_phone: str | None = None,
        assignee_name: str | None = None,
        require_otp: bool | None = None,
        expires_in: int | None = None,
        sinks: list[dict[str, Any]] | None = None,
        org_id: str | None = None,
        correlation_key: str | None = None,
        process_id: str | None = None,
        process_step: str | None = None,
    ) -> dict[str, Any]:
        """Create a new human task.

        Returns dict with ``task_id``, ``link_id``, ``url``, ``requires_otp``, ``created_at``.
        """
        body: dict[str, Any] = {"title": title, "steps": steps}
        for key in (
            "task_id", "instructions_mdsvex", "template_id", "metadata",
            "assignee_member_id", "assignee_email", "assignee_phone",
            "assignee_name", "require_otp", "expires_in", "sinks",
            "org_id", "correlation_key", "process_id", "process_step",
        ):
            val = locals()[key]
            if val is not None:
                body[key] = val
        return self._request("POST", "/tasks", body)

    def get_task(self, task_id: str, *, include_definition: bool = False) -> dict[str, Any]:
        """Get task status and data."""
        query = "?include_definition=true" if include_definition else ""
        return self._request("GET", f"/tasks/{quote(task_id, safe='')}{query}")

    def list_tasks(
        self,
        *,
        status: str | None = None,
        search: str | None = None,
        sort: str | None = None,
        assignee: str | None = None,
        unassigned: bool | None = None,
        process_id: str | None = None,
        template_id: str | None = None,
        correlation_key: str | None = None,
        metadata: dict[str, str] | None = None,
        limit: int | None = None,
        offset: int | None = None,
    ) -> dict[str, Any]:
        """List tasks with optional filters.

        Returns dict with ``tasks``, ``total``, ``limit``, ``offset``.
        """
        params: dict[str, str] = {}
        for key in ("status", "search", "sort", "assignee", "process_id", "template_id", "correlation_key"):
            val = locals()[key]
            if val is not None:
                params[key] = val
        if unassigned:
            params["unassigned"] = "true"
        if metadata:
            for k, v in metadata.items():
                params[f"meta.{k}"] = v
        if limit is not None:
            params["limit"] = str(limit)
        if offset is not None:
            params["offset"] = str(offset)
        query = f"?{urlencode(params)}" if params else ""
        return self._request("GET", f"/tasks{query}")

    def cancel_task(self, task_id: str, *, reason: str | None = None) -> dict[str, Any]:
        """Cancel a pending task."""
        body = {"reason": reason} if reason else None
        return self._request("POST", f"/tasks/{quote(task_id, safe='')}/cancel", body)

    def complete_task(self, task_id: str, *, data: dict[str, Any] | None = None) -> dict[str, Any]:
        """Complete a pending task programmatically."""
        body = {"data": data} if data else None
        return self._request("POST", f"/tasks/{quote(task_id, safe='')}/complete", body)

    def notify_task(self, task_id: str, *, escalate: bool = False) -> dict[str, Any]:
        """Send reminder notification to task assignee."""
        query = "?escalate=true" if escalate else ""
        return self._request("POST", f"/tasks/{quote(task_id, safe='')}/notify{query}")

    def wait_for_task(self, task_id: str, *, timeout: int = 300) -> dict[str, Any]:
        """Long-poll wait for task completion.

        Returns the task status dict. If the wait times out, the dict
        will contain ``"timed_out": True`` with ``"status": "pending"``.
        """
        url = f"/tasks/{quote(task_id, safe='')}/wait?timeout={timeout}"
        try:
            resp = self._http.get(url, timeout=timeout + 10)
        except httpx.TimeoutException:
            return {"task_id": task_id, "status": "pending", "timed_out": True}
        try:
            data = resp.json()
        except Exception:
            raise HPIError(resp.status_code, f"{resp.reason_phrase} (non-JSON response)")
        if resp.status_code >= 400 and resp.status_code != 408:
            error = data.get("error", resp.reason_phrase) if isinstance(data, dict) else resp.reason_phrase
            raise HPIError(resp.status_code, error)
        if isinstance(data, dict) and data.get("timed_out"):
            data["timed_out"] = True
        return data

    def list_task_templates(self) -> dict[str, Any]:
        """List distinct template IDs used across tasks."""
        return self._request("GET", "/tasks/templates")

    # ── task links ───────────────────────────────────────────────

    def create_task_link(
        self,
        task_id: str,
        *,
        assignee_email: str | None = None,
        assignee_name: str | None = None,
        assignee_phone: str | None = None,
        require_otp: bool | None = None,
        expires_in: int | None = None,
    ) -> dict[str, Any]:
        """Create a magic link for a task."""
        body: dict[str, Any] = {}
        for key in ("assignee_email", "assignee_name", "assignee_phone", "require_otp", "expires_in"):
            val = locals()[key]
            if val is not None:
                body[key] = val
        return self._request("POST", f"/tasks/{quote(task_id, safe='')}/links", body or None)

    def list_task_links(self, task_id: str) -> dict[str, Any]:
        """List magic links for a task."""
        return self._request("GET", f"/tasks/{quote(task_id, safe='')}/links")

    def delete_task_link(self, task_id: str, link_id: str) -> dict[str, Any]:
        """Delete a magic link."""
        return self._request("DELETE", f"/tasks/{quote(task_id, safe='')}/links?link_id={quote(link_id, safe='')}")

    # ── files ────────────────────────────────────────────────────

    def upload_file(self, file_data: bytes, file_name: str, content_type: str) -> dict[str, Any]:
        """Upload a file. Returns dict with ``url``, ``key``, ``name``, ``size``, ``type``."""
        # Build a separate request without the default Content-Type header
        # so httpx sets the multipart boundary automatically.
        upload_headers = {
            "Authorization": f"Bearer {self._token}",
            "X-HPI-Source": "python-sdk",
        }
        resp = self._http.post(
            "/files",
            files={"file": (file_name, file_data, content_type)},
            headers=upload_headers,
        )
        try:
            data = resp.json()
        except Exception:
            raise HPIError(resp.status_code, f"{resp.reason_phrase} (non-JSON response)")
        if resp.status_code >= 400:
            error = data.get("error", resp.reason_phrase) if isinstance(data, dict) else resp.reason_phrase
            details = data.get("details") if isinstance(data, dict) else None
            raise HPIError(resp.status_code, error, details)
        # Ensure URL is absolute
        if isinstance(data, dict) and data.get("url") and not data["url"].startswith("http"):
            data["url"] = f"{self._base_url}{data['url']}"
        return data

    # ── processes ────────────────────────────────────────────────

    def create_process(
        self,
        *,
        namespace: str,
        name: str,
        steps: list[dict[str, Any]],
        process_id: str | None = None,
        description: str | None = None,
        template_id: str | None = None,
        metadata: dict[str, str] | None = None,
    ) -> dict[str, Any]:
        """Create a tracked multi-step process."""
        body: dict[str, Any] = {"namespace": namespace, "name": name, "steps": steps}
        for key in ("process_id", "description", "template_id", "metadata"):
            val = locals()[key]
            if val is not None:
                body[key] = val
        return self._request("POST", "/processes", body)

    def get_process(self, process_id: str) -> dict[str, Any]:
        """Get process state with timeline."""
        return self._request("GET", f"/processes/{quote(process_id, safe='')}")

    def list_processes(
        self,
        *,
        status: str | None = None,
        namespace: str | None = None,
        search: str | None = None,
        sort: str | None = None,
        template_id: str | None = None,
        metadata: dict[str, str] | None = None,
        limit: int | None = None,
        offset: int | None = None,
    ) -> dict[str, Any]:
        """List processes with optional filters."""
        params: dict[str, str] = {}
        for key in ("status", "namespace", "search", "sort", "template_id"):
            val = locals()[key]
            if val is not None:
                params[key] = val
        if metadata:
            for k, v in metadata.items():
                params[f"meta.{k}"] = v
        if limit is not None:
            params["limit"] = str(limit)
        if offset is not None:
            params["offset"] = str(offset)
        query = f"?{urlencode(params)}" if params else ""
        return self._request("GET", f"/processes{query}")

    def update_process(self, process_id: str, update: dict[str, Any]) -> dict[str, Any]:
        """Push a process update (step_started, step_completed, progress, etc.)."""
        return self._request("POST", f"/processes/{quote(process_id, safe='')}/update", update)

    def list_process_templates(self) -> dict[str, Any]:
        """List distinct process template IDs."""
        return self._request("GET", "/processes/templates")

    # ── process links ────────────────────────────────────────────

    def create_process_link(
        self,
        process_id: str,
        *,
        assignee_email: str,
        assignee_name: str | None = None,
        assignee_phone: str | None = None,
        require_otp: bool | None = None,
        expires_in: int | None = None,
    ) -> dict[str, Any]:
        """Create a magic link for a process."""
        body: dict[str, Any] = {"assignee_email": assignee_email}
        for key in ("assignee_name", "assignee_phone", "require_otp", "expires_in"):
            val = locals()[key]
            if val is not None:
                body[key] = val
        return self._request("POST", f"/processes/{quote(process_id, safe='')}/links", body)

    def list_process_links(self, process_id: str) -> dict[str, Any]:
        """List magic links for a process."""
        return self._request("GET", f"/processes/{quote(process_id, safe='')}/links")
