#!/usr/bin/env python3

from __future__ import annotations

import hashlib
import inspect
import email.utils
import random
import time
import json
import mimetypes
import os
import secrets
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Any


class FarpyError(RuntimeError):
    def __init__(
        self,
        status: int,
        code: str,
        message: str,
        detail: Any = None,
        retry_after: float | None = None,
    ) -> None:
        super().__init__(f"{status} {code}: {message}")
        self.status = status
        self.code = code
        self.message = message
        self.detail = detail
        self.retry_after = retry_after


class FarpyClient:
    def __init__(
        self,
        api_key: str,
        base_url: str = "https://farpy.com/v1",
        timeout: float = 120.0,
    ) -> None:
        api_key = api_key.strip()
        if not api_key:
            raise ValueError("api_key is required")

        self.api_key = api_key
        self.base_url = base_url.rstrip("/")
        self.timeout = timeout

    def _request(
        self,
        method: str,
        path: str,
        *,
        body: bytes | None = None,
        content_type: str | None = None,
        authenticated: bool = True,
        extra_headers: dict[str, str] | None = None,
    ) -> dict[str, Any]:
        headers = {
            "Accept": "application/json",
            "User-Agent": "farpy-python-client/1.0",
        }

        if authenticated:
            headers["Authorization"] = f"Bearer {self.api_key}"

        if content_type:
            headers["Content-Type"] = content_type

        if extra_headers:
            headers.update(extra_headers)

        request = urllib.request.Request(
            f"{self.base_url}{path}",
            data=body,
            headers=headers,
            method=method,
        )

        try:
            with urllib.request.urlopen(request, timeout=self.timeout) as response:
                raw = response.read()
                return json.loads(raw.decode("utf-8")) if raw else {}
        except urllib.error.HTTPError as exc:
            raw = exc.read()
            payload: dict[str, Any] = {}

            try:
                payload = json.loads(raw.decode("utf-8")) if raw else {}
            except Exception:
                payload = {}

            error = payload.get("error")
            if isinstance(error, dict):
                code = str(error.get("code") or "request_failed")
                message = str(error.get("message") or exc.reason)
                detail = error.get("detail")
            else:
                code = str(payload.get("error") or payload.get("err") or "request_failed")
                message = str(payload.get("message") or exc.reason)
                detail = payload.get("detail")

            retry_after_value = exc.headers.get("Retry-After")
            retry_after = self._retry_after_seconds(retry_after_value)

            raise FarpyError(
                status=exc.code,
                code=code,
                message=message,
                detail=detail,
                retry_after=retry_after,
            ) from exc
        except urllib.error.URLError as exc:
            raise FarpyError(
                status=0,
                code="network_error",
                message=str(exc.reason),
            ) from exc

    @staticmethod
    def _retry_after_seconds(value: str | None) -> float | None:
        if not value:
            return None
        value = value.strip()
        if value.isdigit():
            return float(value)
        try:
            parsed = email.utils.parsedate_to_datetime(value)
            return max(0.0, parsed.timestamp() - time.time())
        except (TypeError, ValueError, OverflowError):
            return None

    def _safe_error(self, exc: FarpyError) -> FarpyError:
        secret = self.api_key
        message = exc.message.replace(secret, "[REDACTED]") if secret else exc.message
        detail = exc.detail
        if isinstance(detail, str) and secret:
            detail = detail.replace(secret, "[REDACTED]")
        return FarpyError(exc.status, exc.code, message, detail, exc.retry_after)

    def _retry_call(self, operation, *, deadline: float, maximum_retries: int):
        attempt = 0
        while True:
            if time.monotonic() >= deadline:
                raise TimeoutError("FARPY render workflow timed out")
            try:
                return operation()
            except FarpyError as exc:
                retryable = exc.status in (0, 429, 502, 503, 504) or exc.code == "temporary_unavailable"
                if not retryable or attempt >= maximum_retries:
                    raise self._safe_error(exc) from exc
                delay = exc.retry_after
                if delay is None:
                    delay = min(30.0, 0.5 * (2 ** attempt)) + random.uniform(0.0, 0.25)
                remaining = deadline - time.monotonic()
                if remaining <= 0:
                    raise TimeoutError("FARPY render workflow timed out") from exc
                time.sleep(min(delay, remaining))
                attempt += 1

    def _json(
        self,
        method: str,
        path: str,
        payload: dict[str, Any] | None = None,
        *,
        authenticated: bool = True,
        extra_headers: dict[str, str] | None = None,
    ) -> dict[str, Any]:
        body = None
        if payload is not None:
            body = json.dumps(
                payload,
                separators=(",", ":"),
                ensure_ascii=False,
            ).encode("utf-8")

        return self._request(
            method,
            path,
            body=body,
            content_type="application/json" if body is not None else None,
            authenticated=authenticated,
            extra_headers=extra_headers,
        )

    def preflight(
        self,
        filename: str,
        *,
        frame_start: int = 1,
        frame_end: int | None = None,
        frame_count: int | None = None,
    ) -> dict[str, Any]:
        payload: dict[str, Any] = {
            "filename": filename,
            "renderer": "blender",
            "frame_start": frame_start,
        }

        if frame_end is not None:
            payload["frame_end"] = frame_end

        if frame_count is not None:
            payload["frame_count"] = frame_count

        return self._json(
            "POST",
            "/renders/preflight",
            payload,
            authenticated=False,
        )

    def upload(
        self,
        file_path: str | os.PathLike[str],
        *,
        idempotency_key: str | None = None,
        frame_start: int = 1,
        frame_end: int = 1,
        webhook_url: str | None = None,
        webhook_secret: str | None = None,
    ) -> dict[str, Any]:
        path = Path(file_path)

        if not path.is_file():
            raise FileNotFoundError(path)

        if frame_start < 1 or frame_end < frame_start:
            raise ValueError("invalid frame range")

        frame_count = frame_end - frame_start + 1
        canonical_filename = f"{path.stem}{path.suffix.lower()}"
        file_bytes = path.read_bytes()
        size_bytes = len(file_bytes)
        content_sha256 = hashlib.sha256(file_bytes).hexdigest()

        if webhook_secret and not webhook_url:
            raise ValueError("webhook_secret requires webhook_url")

        webhook_secret_sha256 = (
            hashlib.sha256(webhook_secret.encode("utf-8")).hexdigest()
            if webhook_secret
            else ""
        )

        fingerprint_payload = (
            f"filename={canonical_filename}\n"
            f"renderer=blender\n"
            f"frame_start={frame_start}\n"
            f"frame_end={frame_end}\n"
            f"frame_count={frame_count}\n"
            f"size_bytes={size_bytes}\n"
            f"sha256={content_sha256}\n"
            f"webhook_url={webhook_url or ''}\n"
            f"webhook_secret_sha256={webhook_secret_sha256}"
        )
        request_fingerprint = hashlib.sha256(
            fingerprint_payload.encode("utf-8")
        ).hexdigest()

        boundary = f"farpy-{secrets.token_hex(16)}"
        mime_type = (
            mimetypes.guess_type(canonical_filename)[0]
            or "application/octet-stream"
        )

        parts: list[bytes] = []

        if webhook_url:
            parts.append(
                (
                    f"--{boundary}\r\n"
                    'Content-Disposition: form-data; name="webhook_url"\r\n'
                    "\r\n"
                    f"{webhook_url}\r\n"
                ).encode("utf-8")
            )

        if webhook_secret:
            parts.append(
                (
                    f"--{boundary}\r\n"
                    'Content-Disposition: form-data; name="webhook_secret"\r\n'
                    "\r\n"
                    f"{webhook_secret}\r\n"
                ).encode("utf-8")
            )

        parts.append(
            (
                f"--{boundary}\r\n"
                f'Content-Disposition: form-data; name="file"; '
                f'filename="{canonical_filename}"\r\n'
                f"Content-Type: {mime_type}\r\n"
                "\r\n"
            ).encode("utf-8")
            + file_bytes
            + b"\r\n"
        )

        body = b"".join(parts) + f"--{boundary}--\r\n".encode("utf-8")

        return self._request(
            "POST",
            "/renders",
            body=body,
            content_type=f"multipart/form-data; boundary={boundary}",
            extra_headers={
                "Idempotency-Key": idempotency_key or secrets.token_hex(16),
                "X-Farpy-Request-Fingerprint": request_fingerprint,
                "X-Farpy-Filename": canonical_filename,
                "X-Farpy-Renderer": "blender",
                "X-Farpy-Frame-Start": str(frame_start),
                "X-Farpy-Frame-End": str(frame_end),
                "X-Farpy-Frame-Count": str(frame_count),
                "X-Farpy-Size-Bytes": str(size_bytes),
                "X-Farpy-Content-SHA256": content_sha256,
            },
        )

    def submit(
        self,
        job_id: str,
        *,
        idempotency_key: str | None = None,
    ) -> dict[str, Any]:
        extra_headers = (
            {"Idempotency-Key": idempotency_key}
            if idempotency_key
            else None
        )
        return self._json(
            "POST",
            f"/renders/{urllib.parse.quote(job_id, safe='')}/submit",
            extra_headers=extra_headers,
        )

    def status(self, job_id: str) -> dict[str, Any]:
        return self._json(
            "GET",
            f"/renders/{urllib.parse.quote(job_id, safe='')}",
        )

    def cancel(self, job_id: str) -> dict[str, Any]:
        return self._json(
            "POST",
            f"/renders/{urllib.parse.quote(job_id, safe='')}/cancel",
        )

    def download(self, job_id: str) -> dict[str, Any]:
        return self._json(
            "GET",
            f"/renders/{urllib.parse.quote(job_id, safe='')}/download",
        )

    def receipt(self, job_id: str) -> dict[str, Any]:
        return self._json(
            "GET",
            f"/renders/{urllib.parse.quote(job_id, safe='')}/receipt",
        )

    def proof(self, job_id: str) -> dict[str, Any]:
        return self._json(
            "GET",
            f"/renders/{urllib.parse.quote(job_id, safe='')}/proof",
        )

    def render(
        self,
        input_path: str | os.PathLike[str],
        output_path: str | os.PathLike[str],
        *,
        webhook_url: str | None = None,
        poll_interval: float = 2.0,
        overall_timeout: float = 3600.0,
        maximum_retries: int = 4,
        overwrite: bool = False,
        idempotency_key: str | None = None,
    ) -> dict[str, Any]:
        source = Path(input_path)
        target = Path(output_path)
        if not source.is_file():
            raise FileNotFoundError(source)
        if source.stat().st_size > 104857600:
            raise ValueError("input file exceeds the 100 MiB upload limit")
        if target.exists() and not overwrite:
            raise FileExistsError(target)
        if poll_interval < 0 or overall_timeout <= 0 or maximum_retries < 0:
            raise ValueError("invalid render timing or retry controls")

        key = idempotency_key or secrets.token_hex(16)
        deadline = time.monotonic() + overall_timeout
        created = self._retry_call(
            lambda: self.upload(source, idempotency_key=key, webhook_url=webhook_url),
            deadline=deadline, maximum_retries=maximum_retries,
        )
        job_id = str(created.get("job_id") or "")
        if not job_id:
            raise FarpyError(0, "invalid_response", "upload response did not contain job_id")
        submit_parameters = inspect.signature(self.submit).parameters.values()
        submit_accepts_idempotency = any(
            parameter.name == "idempotency_key"
            or parameter.kind == inspect.Parameter.VAR_KEYWORD
            for parameter in submit_parameters
        )
        def submit_operation() -> dict[str, Any]:
            if submit_accepts_idempotency:
                result = self.submit(job_id, idempotency_key=key)
            else:
                result = self.submit(job_id)
            if callable(result) or not isinstance(result, dict):
                raise FarpyError(
                    0,
                    "invalid_response",
                    "submit response must be a JSON object",
                )
            return result
        submitted = self._retry_call(
            submit_operation,
            deadline=deadline, maximum_retries=maximum_retries,
        )
        latest = submitted
        while True:
            latest = self._retry_call(
                lambda: self.status(job_id), deadline=deadline, maximum_retries=maximum_retries,
            )
            state = str(latest.get("state") or latest.get("status") or "").lower()
            if state in {"complete", "completed", "done"}:
                break
            if state in {"failed", "error"}:
                raise FarpyError(0, "render_failed", "render entered a terminal failed state", latest)
            if state in {"cancelled", "canceled"}:
                raise FarpyError(0, "render_cancelled", "render entered a terminal cancelled state", latest)
            remaining = deadline - time.monotonic()
            if remaining <= 0:
                raise TimeoutError("FARPY render workflow timed out")
            time.sleep(min(poll_interval, remaining))

        download_info = self._retry_call(
            lambda: self.download(job_id), deadline=deadline, maximum_retries=maximum_retries,
        )
        download_url = str(
            download_info.get("download_url") or download_info.get("url") or download_info.get("artifact_url") or ""
        )
        if not download_url:
            raise FarpyError(0, "invalid_response", "download response did not contain a URL")
        target.parent.mkdir(parents=True, exist_ok=True)
        partial = target.with_name(target.name + ".part")
        try:
            request = urllib.request.Request(download_url, headers={"User-Agent": "farpy-python-client/1.0"})
            with urllib.request.urlopen(request, timeout=min(self.timeout, max(1.0, deadline - time.monotonic()))) as response:
                with partial.open("wb") as handle:
                    while True:
                        chunk = response.read(1024 * 1024)
                        if not chunk:
                            break
                        handle.write(chunk)
            if target.exists() and not overwrite:
                raise FileExistsError(target)
            os.replace(partial, target)
        except Exception:
            partial.unlink(missing_ok=True)
            raise

        receipt = self._retry_call(
            lambda: self.receipt(job_id), deadline=deadline, maximum_retries=maximum_retries,
        )
        proof = self._retry_call(
            lambda: self.proof(job_id), deadline=deadline, maximum_retries=maximum_retries,
        )
        request_id = str(
            latest.get("request_id") or submitted.get("request_id") or created.get("request_id") or ""
        )
        return {
            "job_id": job_id,
            "state": str(latest.get("state") or latest.get("status") or "complete"),
            "artifact_path": str(target),
            "receipt": receipt,
            "proof": proof,
            "request_id": request_id,
        }

    def workspace_renders(self) -> dict[str, Any]:
        return self._json("GET", "/workspace/renders")


def main() -> int:
    import argparse

    parser = argparse.ArgumentParser(description="Minimal FARPY Public API V1 client")
    parser.add_argument(
        "--api-key",
        default=os.environ.get("FARPY_API_KEY", ""),
    )
    parser.add_argument(
        "--base-url",
        default=os.environ.get("FARPY_BASE_URL", "https://farpy.com/v1"),
    )

    subparsers = parser.add_subparsers(dest="command", required=True)

    upload_parser = subparsers.add_parser("upload")
    upload_parser.add_argument("file")
    upload_parser.add_argument("--idempotency-key")

    for command in ("submit", "status", "cancel", "download", "receipt", "proof"):
        command_parser = subparsers.add_parser(command)
        command_parser.add_argument("job_id")

    subparsers.add_parser("list")

    args = parser.parse_args()

    client = FarpyClient(
        api_key=args.api_key,
        base_url=args.base_url,
    )

    try:
        if args.command == "upload":
            result = client.upload(
                args.file,
                idempotency_key=args.idempotency_key,
            )
        elif args.command == "list":
            result = client.workspace_renders()
        else:
            result = getattr(client, args.command)(args.job_id)

        print(json.dumps(result, indent=2, sort_keys=True))
        return 0
    except FarpyError as exc:
        print(
            json.dumps(
                {
                    "ok": False,
                    "status": exc.status,
                    "error": {
                        "code": exc.code,
                        "message": exc.message,
                        "detail": exc.detail,
                    },
                    "retry_after": exc.retry_after,
                },
                indent=2,
                sort_keys=True,
            )
        )
        return 1


if __name__ == "__main__":
    raise SystemExit(main())
