#!/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
import warnings
from pathlib import Path
from typing import Any

LEGAL_ACCEPTANCE_V1 = "FARPY_LEGAL_V1"
MAX_UPLOAD_BYTES = 104857600
QUOTE_PUBLIC_KEYS = (
    "quote_id",
    "frame_start",
    "frame_end",
    "frame_step",
    "frame_count",
    "price_cents",
    "qualified_1c",
    "pricing_version",
    "status",
    "expires_at",
    "expiry_policy",
)


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


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.origin = (
            self.base_url[:-3].rstrip("/")
            if self.base_url.endswith("/v1")
            else self.base_url
        )
        if not self.origin:
            self.origin = self.base_url
        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,
        root: str | None = None,
    ) -> dict[str, Any]:
        headers = {
            "Accept": "application/json",
            "User-Agent": "farpy-python-client/1.1",
        }

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

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

        if extra_headers:
            headers.update(extra_headers)

        if not path.startswith("/"):
            path = f"/{path}"
        request = urllib.request.Request(
            f"{(root or self.base_url).rstrip('/')}{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")
                retryable = error.get("retryable")
                suggested_action = str(error.get("suggested_action") or "")
                request_id = str(error.get("request_id") or "")
                body_retry_after = error.get("retry_after_seconds")
            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")
                retryable = None
                suggested_action = ""
                request_id = ""
                body_retry_after = None

            retry_after_value = exc.headers.get("Retry-After")
            retry_after = self._retry_after_seconds(retry_after_value)
            if retry_after is None and isinstance(body_retry_after, (int, float)):
                retry_after = float(body_retry_after)

            raise FarpyError(
                status=exc.code,
                code=code,
                message=message,
                detail=detail,
                retry_after=retry_after,
                retryable=retryable if isinstance(retryable, bool) else None,
                suggested_action=suggested_action,
                request_id=request_id,
            ) 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,
            exc.retryable, exc.suggested_action, exc.request_id
        )

    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.retryable if exc.retryable is not None else (
                    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,
        root: 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,
            root=root,
        )

    def _job_json(
        self,
        method: str,
        path: str,
        payload: dict[str, Any] | None = None,
    ) -> dict[str, Any]:
        return self._json(method, path, payload, root=self.origin)

    def capabilities(self) -> dict[str, Any]:
        """Return the current public execution capabilities."""
        return self._json(
            "GET",
            "/capabilities",
            authenticated=False,
        )

    def pricing(self) -> dict[str, Any]:
        """Return the current public pricing contract."""
        return self._json(
            "GET",
            "/pricing",
            authenticated=False,
        )

    def limits(self) -> dict[str, Any]:
        """Return the current public API and workload limits."""
        return self._json(
            "GET",
            "/limits",
            authenticated=False,
        )

    @staticmethod
    def _public_quote(payload: dict[str, Any]) -> dict[str, Any]:
        nested = payload.get("quote") if isinstance(payload.get("quote"), dict) else {}
        out: dict[str, Any] = {}
        for key in QUOTE_PUBLIC_KEYS:
            if key in nested:
                out[key] = nested[key]
            elif key in payload:
                out[key] = payload[key]
        return out

    def _multipart_blend(self, file_path: str | os.PathLike[str]) -> tuple[str, bytes, str]:
        path = Path(file_path)
        if not path.is_file():
            raise FileNotFoundError(path)
        if path.stat().st_size > MAX_UPLOAD_BYTES:
            raise ValueError("input file exceeds the 100 MiB upload limit")
        if path.suffix.lower() != ".blend":
            raise ValueError("input must be a .blend file")
        canonical_filename = f"{path.stem}.blend"
        file_bytes = path.read_bytes()
        boundary = f"farpy-{secrets.token_hex(16)}"
        mime_type = mimetypes.guess_type(canonical_filename)[0] or "application/octet-stream"
        body = (
            (
                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
            + f"\r\n--{boundary}--\r\n".encode("utf-8")
        )
        return canonical_filename, body, f"multipart/form-data; boundary={boundary}"

    def inspect(self, file_path: str | os.PathLike[str]) -> dict[str, Any]:
        """Upload a .blend and return a locked quote. Does not reserve or spend."""
        _filename, body, content_type = self._multipart_blend(file_path)
        result = self._request(
            "POST",
            "/node/v1/uploads/inspect",
            body=body,
            content_type=content_type,
            root=self.origin,
        )
        quote = self._public_quote(result)
        result["quote"] = quote
        result["spend"] = False
        return result

    def quote(
        self,
        upload_id: str,
        *,
        frame_start: int,
        frame_end: int,
        frame_step: int = 1,
    ) -> dict[str, Any]:
        """Issue a new locked quote for a sub-range. Does not reserve or spend."""
        if frame_start < 1 or frame_end < frame_start or frame_step < 1:
            raise ValueError("invalid frame range")
        upload_id = str(upload_id).strip()
        if not upload_id:
            raise ValueError("upload_id is required")
        result = self._job_json(
            "POST",
            f"/node/v1/uploads/{urllib.parse.quote(upload_id, safe='')}/quote",
            {
                "frame_start": frame_start,
                "frame_end": frame_end,
                "frame_step": frame_step,
            },
        )
        result["quote"] = self._public_quote(result)
        result["spend"] = False
        return result

    def prepare(
        self,
        file_path: str | os.PathLike[str],
        *,
        frame_start: int | None = None,
        frame_end: int | None = None,
        frame_step: int = 1,
    ) -> dict[str, Any]:
        """Inspect a .blend and return upload_id + locked quote. Does not reserve or spend.

        Call start(upload_id, quote_id=..., legal_acceptance='FARPY_LEGAL_V1') to spend.
        """
        inspected = self.inspect(file_path)
        upload_id = str(inspected.get("upload_id") or "").strip()
        if not upload_id:
            raise FarpyError(0, "invalid_response", "inspect response did not contain upload_id")
        quoted = inspected
        if frame_start is not None or frame_end is not None:
            start = int(frame_start if frame_start is not None else inspected.get("frame_start") or 1)
            end = int(frame_end if frame_end is not None else inspected.get("frame_end") or start)
            quoted = self.quote(
                upload_id,
                frame_start=start,
                frame_end=end,
                frame_step=frame_step,
            )
        quote = self._public_quote(quoted)
        if not quote.get("quote_id"):
            raise FarpyError(0, "invalid_response", "prepare did not receive a locked quote")
        return {
            "ok": True,
            "spend": False,
            "reservation": None,
            "upload_id": upload_id,
            "filename": inspected.get("filename"),
            "frame_start": quote.get("frame_start"),
            "frame_end": quote.get("frame_end"),
            "frame_count": quote.get("frame_count"),
            "quote_id": quote.get("quote_id"),
            "price_cents": quote.get("price_cents"),
            "qualified_1c": quote.get("qualified_1c"),
            "pricing_version": quote.get("pricing_version"),
            "quote": quote,
            "inspect": inspected,
        }

    def start(
        self,
        upload_id: str,
        *,
        quote_id: str,
        legal_acceptance: str,
        frame_start: int | None = None,
        frame_end: int | None = None,
    ) -> dict[str, Any]:
        """Explicit spend/reservation boundary. Requires quote_id and FARPY_LEGAL_V1."""
        upload_id = str(upload_id).strip()
        quote_id = str(quote_id).strip().upper()
        if not upload_id:
            raise ValueError("upload_id is required")
        if not quote_id:
            raise ValueError("quote_id is required")
        if legal_acceptance != LEGAL_ACCEPTANCE_V1:
            raise ValueError(f'legal_acceptance must be "{LEGAL_ACCEPTANCE_V1}"')
        payload: dict[str, Any] = {
            "quote_id": quote_id,
            "legal_acceptance": legal_acceptance,
        }
        if frame_start is not None:
            payload["frame_start"] = frame_start
        if frame_end is not None:
            payload["frame_end"] = frame_end
        result = self._job_json(
            "POST",
            f"/node/v1/uploads/{urllib.parse.quote(upload_id, safe='')}/start",
            payload,
        )
        result["spend"] = True
        return result

    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]:
        warnings.warn(
            "FarpyClient.upload() is the legacy Public API V1 path and can reserve wallet funds "
            "before a locked quote is accepted. Prefer inspect()/prepare() then start().",
            DeprecationWarning,
            stacklevel=2,
        )
        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}"
        )
        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]:
        job_id = str(job_id).strip()
        if self.api_key.startswith("farpy_agent_"):
            return self._job_json(
                "GET",
                f"/node/v1/jobs/{urllib.parse.quote(job_id, safe='')}",
            )
        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]:
        job_id = str(job_id).strip()
        if self.api_key.startswith("farpy_agent_"):
            job = self.status(job_id)
            return {
                "ok": True,
                "job_id": job_id,
                "download_url": job.get("download_url"),
                "status": job.get("status"),
            }
        return self._json(
            "GET",
            f"/renders/{urllib.parse.quote(job_id, safe='')}/download",
        )

    def receipt(self, job_id: str) -> dict[str, Any]:
        job_id = str(job_id).strip()
        if self.api_key.startswith("farpy_agent_"):
            job = self.status(job_id)
            return {
                "ok": True,
                "job_id": job_id,
                "receipt_url": job.get("receipt_url"),
                "output_sha256": job.get("output_sha256"),
                "status": job.get("status"),
            }
        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,
        frame_start: int = 1,
        frame_end: int = 1,
        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]:
        warnings.warn(
            "FarpyClient.render() is deprecated. It uploads and submits without a locked quote "
            "and can reserve wallet funds. Use prepare() (no spend), then start(quote_id=..., "
            f"legal_acceptance='{LEGAL_ACCEPTANCE_V1}').",
            DeprecationWarning,
            stacklevel=2,
        )
        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 frame_start < 1 or frame_end < frame_start:
            raise ValueError("invalid frame range")
        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,
                frame_start=frame_start,
                frame_end=frame_end,
                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="FARPY client: inspect → quote → explicit start")
    parser.add_argument(
        "--api-key",
        default=os.environ.get("FARPY_API_KEY") or os.environ.get("FARPY_AGENT_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)

    prepare_parser = subparsers.add_parser("prepare", help="Inspect a .blend and return a locked quote. Does not spend.")
    prepare_parser.add_argument("file")
    prepare_parser.add_argument("--frame-start", type=int)
    prepare_parser.add_argument("--frame-end", type=int)

    inspect_parser = subparsers.add_parser("inspect", help="Inspect a .blend. Does not spend.")
    inspect_parser.add_argument("file")

    quote_parser = subparsers.add_parser("quote", help="Requote a sub-range. Does not spend.")
    quote_parser.add_argument("upload_id")
    quote_parser.add_argument("--frame-start", type=int, required=True)
    quote_parser.add_argument("--frame-end", type=int, required=True)

    start_parser = subparsers.add_parser("start", help="Spend/reservation boundary. Requires quote_id.")
    start_parser.add_argument("upload_id")
    start_parser.add_argument("--quote-id", required=True)
    start_parser.add_argument(
        "--legal-acceptance",
        required=True,
        help=f'Must be {LEGAL_ACCEPTANCE_V1}',
    )

    upload_parser = subparsers.add_parser("upload", help="Legacy Public API V1 upload (can reserve). Prefer prepare.")
    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 == "prepare":
            result = client.prepare(
                args.file,
                frame_start=args.frame_start,
                frame_end=args.frame_end,
            )
        elif args.command == "inspect":
            result = client.inspect(args.file)
        elif args.command == "quote":
            result = client.quote(
                args.upload_id,
                frame_start=args.frame_start,
                frame_end=args.frame_end,
            )
        elif args.command == "start":
            result = client.start(
                args.upload_id,
                quote_id=args.quote_id,
                legal_acceptance=args.legal_acceptance,
            )
        elif 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())
