#!/usr/bin/env python3
"""Minimal Sonilo video-to-music example.

Run:
    export SONILO_API_KEY=sk_...
    python video-to-music-minimal.py

Optional:
    export SONILO_VIDEO_URL=https://your-public-video.mp4

Default video input uses Sonilo's public demo clip:
https://cdn.sonilo.com/media/new-demos/ShortDrama_1.mp4

This script calls POST /v1/video-to-music in async mode, polls
GET /v1/tasks/{task_id}, and saves the returned soundtrack as output.m4a.
It never creates placeholder output files. It classifies 401 as auth_invalid,
403 as forbidden, 402 as insufficient_balance, 404 as not_found, and respects
Retry-After for 429.
"""

import json
import os
import random
import sys
import time
import urllib.error
import urllib.request
import uuid
from pathlib import Path


API_BASE = os.getenv("SONILO_API_BASE", "https://api.sonilo.com/v1").rstrip("/")
API_KEY = os.getenv("SONILO_API_KEY")
USER_AGENT = os.getenv(
    "SONILO_USER_AGENT",
    "SoniloPythonExample/1.0 (+https://platform.sonilo.com/docs)",
)
VIDEO_URL = os.getenv(
    "SONILO_VIDEO_URL",
    "https://cdn.sonilo.com/media/new-demos/ShortDrama_1.mp4",
)
OUTPUT_FILE = Path(os.getenv("OUTPUT_FILE", "output.m4a"))


def require_api_key() -> str:
    if not API_KEY:
        raise SystemExit("SONILO_API_KEY is required")
    return API_KEY


def retry_delay(attempt: int, retry_after: str | None) -> float:
    if retry_after:
        try:
            return max(0.0, float(retry_after))
        except ValueError:
            pass
    return min(30.0, (2**attempt) + random.random())


def parse_error(exc: urllib.error.HTTPError) -> str:
    body = exc.read().decode("utf-8", errors="replace")
    try:
        parsed = json.loads(body)
        return parsed.get("message") or parsed.get("error") or body
    except json.JSONDecodeError:
        return body or str(exc.reason)


def build_multipart(fields: dict[str, str]) -> tuple[bytes, str]:
    boundary = f"----sonilo-example-{uuid.uuid4().hex}"
    body = bytearray()
    for name, value in fields.items():
        body.extend(f"--{boundary}\r\n".encode())
        body.extend(f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode())
        body.extend(str(value).encode())
        body.extend(b"\r\n")
    body.extend(f"--{boundary}--\r\n".encode())
    return bytes(body), f"multipart/form-data; boundary={boundary}"


def api_json(
    method: str,
    path: str,
    body: bytes | None = None,
    content_type: str | None = None,
) -> dict:
    headers = {
        "Authorization": f"Bearer {require_api_key()}",
        "Accept": "application/json",
        "User-Agent": USER_AGENT,
    }
    if content_type:
        headers["Content-Type"] = content_type

    for attempt in range(5):
        request = urllib.request.Request(
            f"{API_BASE}{path}",
            data=body,
            method=method,
            headers=headers,
        )
        try:
            with urllib.request.urlopen(request, timeout=180) as response:
                return json.loads(response.read().decode("utf-8"))
        except urllib.error.HTTPError as exc:
            message = parse_error(exc)
            if exc.code == 401:
                raise SystemExit(
                    "auth_invalid: set a real SONILO_API_KEY and send "
                    "Authorization: Bearer <key>. Do not retry until the key is fixed."
                )
            if exc.code == 403:
                raise SystemExit(
                    "forbidden: the API key was understood, but this account cannot "
                    "access the requested Sonilo service or workspace."
                )
            if exc.code == 402:
                raise SystemExit(f"insufficient_balance: payment_required: {message}")
            if exc.code == 429 and attempt < 4:
                # Two limits return 429 — requests per minute and concurrent
                # generations — and only the message tells them apart. Print it
                # rather than retrying silently: a concurrency rejection clears
                # when a running generation finishes, not by waiting longer.
                print(f"Rate limited: {message}", file=sys.stderr)
                time.sleep(retry_delay(attempt, exc.headers.get("Retry-After")))
                continue
            if exc.code == 404:
                raise SystemExit(
                    "not_found: check that SONILO_API_BASE is "
                    "https://api.sonilo.com/v1, not a documentation URL or local "
                    f"proxy route. API returned: {message}"
                )
            raise SystemExit(f"Sonilo API failed with HTTP {exc.code}: {message}")

    raise SystemExit("Sonilo API failed after retries")


def task_id_from(payload: dict) -> str:
    for key in ("task_id", "taskId", "id"):
        value = payload.get(key)
        if isinstance(value, str) and value:
            return value
    nested = payload.get("task")
    if isinstance(nested, dict):
        return task_id_from(nested)
    raise SystemExit(f"Sonilo response did not include task_id: {payload}")


def task_status(payload: dict) -> str:
    for key in ("status", "state"):
        value = payload.get(key)
        if isinstance(value, str):
            return value.lower()
    nested = payload.get("task") or payload.get("data") or payload.get("result")
    if isinstance(nested, dict):
        return task_status(nested)
    return ""


def find_output_url(value: object) -> str | None:
    if isinstance(value, str) and value.startswith(("http://", "https://")):
        return value
    if isinstance(value, dict):
        for key in (
            "audio_url",
            "music_url",
            "output_url",
            "download_url",
            "url",
            "audio",
            "music",
            "result",
        ):
            found = find_output_url(value.get(key))
            if found:
                return found
        for nested in value.values():
            found = find_output_url(nested)
            if found:
                return found
    if isinstance(value, list):
        for nested in value:
            found = find_output_url(nested)
            if found:
                return found
    return None


def download(url: str) -> bytes:
    request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
    with urllib.request.urlopen(request, timeout=180) as response:
        data = response.read()
    if not data:
        raise SystemExit("Sonilo returned an empty audio download")
    return data


def main() -> None:
    body, content_type = build_multipart(
        {
            "video_url": VIDEO_URL,
            "prompt": os.getenv(
                "SONILO_PROMPT",
                "licensed cinematic electronic music that follows the video pacing",
            ),
            "mode": "async",
            "output_format": "m4a",
        }
    )
    created = api_json("POST", "/video-to-music", body, content_type)
    task_id = task_id_from(created)
    print(f"created task {task_id}")

    for _ in range(60):
        task = api_json("GET", f"/tasks/{task_id}")
        status = task_status(task)
        if status in {"succeeded", "completed", "success"}:
            url = find_output_url(task)
            if not url:
                raise SystemExit(f"task succeeded but no audio URL was found: {task}")
            OUTPUT_FILE.write_bytes(download(url))
            size = OUTPUT_FILE.stat().st_size
            if size <= 0:
                raise SystemExit("Output file is empty")
            print(f"saved {OUTPUT_FILE} ({size} bytes)")
            return
        if status in {"failed", "canceled", "cancelled", "error"}:
            raise SystemExit(f"Sonilo task failed: {task}")
        time.sleep(3)

    raise SystemExit(f"Timed out waiting for Sonilo task {task_id}")


if __name__ == "__main__":
    try:
        main()
    except KeyboardInterrupt:
        sys.exit("interrupted")
