#!/usr/bin/env python3
"""LyricPatch installation-free, standard-library-only Python 3.10+ runner.

Keep the same private state through payment and edit recovery. No wallet, pip,
plugin, automatic payment, automatic top-up, redirects, or fresh paid retries.
Read https://lyricpatch.com/agents/quickstart.md before use.
"""
from __future__ import annotations

import argparse
import copy
import ctypes
import hashlib
import json
import math
import os
from pathlib import Path
import re
import secrets
import stat
import tempfile
import time
from datetime import datetime
from decimal import Decimal, InvalidOperation
from email.utils import parsedate_to_datetime
from urllib.error import HTTPError, URLError
from urllib.parse import urlsplit
from urllib.request import HTTPRedirectHandler, Request, build_opener
from uuid import UUID

ORIGIN = "https://api.lyricpatch.com"
USER_AGENT = "LyricPatch-Install-Free/1.0 (+https://lyricpatch.com/agents)"
CHECKOUT_MESSAGE = ("LyricPatch needs credits to make this edit. Its $1.99 for 40 credits. "
                    "Pay securely here, then I can continue.")


class Failure(Exception):
    def __init__(self, code, status=0, retry_after=5):
        self.code, self.status, self.retry_after = code, status, max(5, retry_after)
        super().__init__(code)


class Pending(Exception):
    def __init__(self, retry_after=5):
        self.retry_after = max(5, retry_after)


def digest(value):
    return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(",", ":"),
                                     allow_nan=False).encode()).hexdigest()


def file_hash(path):
    value = hashlib.sha256()
    with Path(path).open("rb") as source:
        for chunk in iter(lambda: source.read(1024 * 1024), b""):
            value.update(chunk)
    return value.hexdigest()


def protect(data, decrypt=False):
    """POSIX owner-only files; Windows current-user DPAPI encryption at rest."""
    if os.name != "nt":
        return data

    class Blob(ctypes.Structure):
        _fields_ = [("size", ctypes.c_ulong), ("data", ctypes.POINTER(ctypes.c_ubyte))]

    buffer = ctypes.create_string_buffer(data)
    source = Blob(len(data), ctypes.cast(buffer, ctypes.POINTER(ctypes.c_ubyte)))
    output = Blob()
    crypt32, kernel32 = ctypes.WinDLL("crypt32"), ctypes.WinDLL("kernel32")
    kernel32.LocalFree.argtypes = [ctypes.c_void_p]
    kernel32.LocalFree.restype = ctypes.c_void_p
    if decrypt:
        ok = crypt32.CryptUnprotectData(ctypes.byref(source), None, None, None, None,
                                      1, ctypes.byref(output))
    else:
        ok = crypt32.CryptProtectData(ctypes.byref(source), "LyricPatch", None,
                                     None, None, 1, ctypes.byref(output))
    if not ok:
        raise Failure("private_state_unreadable_keep_original")
    try:
        return ctypes.string_at(output.data, output.size)
    finally:
        kernel32.LocalFree(output.data)


class PrivateState:
    """Use an existing private directory, outside source control/shared folders."""
    def __init__(self, path):
        raw = Path(path).absolute()
        if raw.is_symlink():
            raise Failure("state_symlink_rejected")
        self.path, self.lock = raw.resolve(), None

    def __enter__(self):
        if not self.path.parent.is_dir():
            raise Failure("create_private_state_directory_first")
        if os.name != "nt":
            info = self.path.parent.stat()
            if info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) & 0o077:
                raise Failure("state_directory_must_be_owner_only")
        lock_path = self.path.with_name(self.path.name + ".lock")
        if lock_path.is_symlink() or self.path.is_symlink():
            raise Failure("state_symlink_rejected")
        fd = os.open(lock_path, os.O_CREAT | os.O_RDWR | getattr(os, "O_NOFOLLOW", 0), 0o600)
        self.lock = os.fdopen(fd, "r+b")
        try:
            if os.name == "nt":
                import msvcrt
                if not os.fstat(fd).st_size:
                    self.lock.write(b"0")
                    self.lock.flush()
                self.lock.seek(0)
                msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
            else:
                import fcntl
                fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
        except OSError:
            self.lock.close()
            self.lock = None
            raise Failure("state_in_use_keep_same_state") from None
        return self

    def __exit__(self, *_):
        if self.lock:
            self.lock.close()
            self.lock = None

    def load(self):
        if self.lock is None:
            raise Failure("state_lock_required")
        if not self.path.exists():
            return None
        info = self.path.lstat()
        if not stat.S_ISREG(info.st_mode) or info.st_size > 2_000_000:
            raise Failure("invalid_private_state_keep_original")
        if os.name != "nt" and (info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) & 0o077):
            raise Failure("state_file_must_be_owner_only")
        try:
            value = json.loads(protect(self.path.read_bytes(), decrypt=True))
            if not isinstance(value, dict):
                raise ValueError()
            return value
        except (ValueError, UnicodeError):
            raise Failure("invalid_private_state_keep_original") from None

    def save(self, value):
        if self.lock is None:
            raise Failure("state_lock_required")
        data = protect(json.dumps(value, separators=(",", ":"), allow_nan=False).encode())
        fd, name = tempfile.mkstemp(prefix=".lp-state-", dir=self.path.parent)
        try:
            with os.fdopen(fd, "wb") as stream:
                stream.write(data)
                stream.flush()
                os.fsync(stream.fileno())
            os.replace(name, self.path)
            if os.name != "nt":
                fd = os.open(self.path.parent, os.O_RDONLY)
                try:
                    os.fsync(fd)
                finally:
                    os.close(fd)
        finally:
            if os.path.exists(name):
                os.unlink(name)


def safe_url(url, storage=False, checkout=False):
    try:
        parsed = urlsplit(url)
        if (parsed.scheme != "https" or not parsed.hostname or parsed.username
                or parsed.password or parsed.port not in (None, 443)):
            raise ValueError()
        host = parsed.hostname.lower()
        if checkout:
            if host != "checkout.stripe.com" or parsed.query:
                raise ValueError()
            if not re.fullmatch(r"/c/pay/cs_(live|test)_[A-Za-z0-9]+|/pay/cs_(live|test)_[A-Za-z0-9]+", parsed.path):
                raise ValueError()
        else:
            if parsed.fragment:
                raise ValueError()
            allowed = (host == "api.lyricpatch.com" or
                       (storage and host.endswith(".r2.cloudflarestorage.com")))
            if not allowed:
                raise ValueError()
    except (ValueError, TypeError):
        raise Failure("untrusted_url_rejected") from None
    return url


class NoRedirect(HTTPRedirectHandler):
    def redirect_request(self, req, fp, code, msg, headers, newurl):
        return None


def retry_delay(value):
    try:
        return max(5, math.ceil(float(value)))
    except (ValueError, TypeError, OverflowError):
        try:
            return max(5, math.ceil(parsedate_to_datetime(value).timestamp() - time.time()))
        except (ValueError, TypeError, OverflowError):
            return 5


class HTTP:
    def __init__(self, opener=None):
        # Honor the host's normal proxy/network policy; never bypass restrictions.
        self.opener = opener or build_opener(NoRedirect())

    def open(self, method, url, headers=None, data=None, storage=False):
        safe_url(url, storage=storage)
        request = Request(url, data=data, headers={"User-Agent": USER_AGENT, **(headers or {})}, method=method)
        try:
            response = self.opener.open(request, timeout=45)
            if not 200 <= response.status < 300:
                response.close()
                raise Failure("service_request_failed_keep_state", response.status)
            return response
        except HTTPError as exc:
            delay, status = retry_delay(exc.headers.get("Retry-After")), exc.code
            exc.close()
            raise Failure("service_request_failed_keep_state", status, delay) from None
        except (URLError, TimeoutError, OSError):
            raise Failure("connection_uncertain_resume_same_state") from None

    def api(self, method, path, body=None, secret=None, token=None, key=None):
        if not path.startswith("/") or ".." in path or "?" in path or "#" in path:
            raise Failure("invalid_api_path")
        headers = {"Accept": "application/json", "X-LyricPatch-Client": "python_client"}
        if secret:
            headers["X-LyricPatch-Session"] = secret
        if token:
            headers["Authorization"] = "Bearer " + token
        if key:
            headers["Idempotency-Key"] = key
        data = None
        if body is not None:
            data = json.dumps(body, allow_nan=False).encode()
            headers["Content-Type"] = "application/json"
        with self.open(method, ORIGIN + "/v1/agent" + path, headers, data) as response:
            raw = response.read(2_000_001)
            if len(raw) > 2_000_000:
                raise Failure("service_response_invalid_keep_state")
            try:
                value = json.loads(raw) if raw else {}
                if not isinstance(value, dict):
                    raise ValueError()
                return value
            except (ValueError, UnicodeError):
                raise Failure("service_response_invalid_keep_state") from None

    def upload(self, source, upload):
        if upload.get("method") != "PUT":
            raise Failure("signed_put_required")
        fields = upload.get("fields") or {}
        if any(name.lower() not in {"content-type", "x-upload-size"} for name in fields):
            raise Failure("untrusted_upload_headers")
        headers = {**fields, "Content-Type": "audio/mpeg", "Content-Length": str(source.stat().st_size)}
        with source.open("rb") as stream:
            with self.open("PUT", upload["url"], headers, stream, storage=True):
                pass  # No API/session credentials are ever sent to storage.

    def download(self, url, target):
        with self.open("GET", url, storage=True) as response, target.open("xb") as stream:
            os.chmod(target, 0o600)
            total = 0
            for chunk in iter(lambda: response.read(1024 * 1024), b""):
                total += len(chunk)
                if total > 400_000_000:
                    raise Failure("download_size_limit")
                stream.write(chunk)
            if not total:
                raise Failure("empty_audio_download")
            stream.flush()
            os.fsync(stream.fileno())


def identifier(value):
    try:
        return str(UUID(value))
    except (ValueError, TypeError, AttributeError):
        raise Failure("invalid_server_identifier_keep_state") from None


def make_intent(song, original, replacement, start, end, max_credits, export_format):
    path = Path(song).resolve()
    if path.suffix.lower() != ".mp3" or not path.is_file():
        raise Failure("local_mp3_required")
    size = path.stat().st_size
    if not 0 < size <= 104_857_600:
        raise Failure("mp3_must_be_at_most_100mb")
    try:
        first, last = Decimal(str(start)), Decimal(str(end))
        if not first.is_finite() or not last.is_finite():
            raise InvalidOperation()
        if first < 0 or last > 900 or not Decimal("0.1") <= last - first <= 6:
            raise Failure("known_selection_must_be_0_1_to_6_seconds")
    except (InvalidOperation, ValueError):
        raise Failure("invalid_selection") from None
    if (type(max_credits) is not int or not 0 <= max_credits <= 10000
            or math.ceil(last - first) + 5 > max_credits):
        raise Failure("budget_must_cover_generation_plus_5_credit_export")
    if export_format not in {"mp3", "wav"}:
        raise Failure("invalid_export_format")
    original, replacement = original.strip(), replacement.strip()
    if not 1 <= len(original) <= 2000 or not 1 <= len(replacement) <= 2000:
        raise Failure("lyrics_must_be_1_to_2000_characters")
    return {"song": str(path), "sha256": file_hash(path), "size_bytes": size,
            "original": original, "replacement": replacement, "start": str(first.normalize()),
            "end": str(last.normalize()), "max_credits": max_credits, "format": export_format,
            "rights_confirmed": True, "media_processing_authorized": True}


class Runner:
    def __init__(self, store, http=None, clock=time.time, sleep=time.sleep):
        self.store, self.http, self.clock, self.sleep = store, http or HTTP(), clock, sleep
        self.state = store.load()
        if self.state is None:
            self.state = {}
            self.save(version=1, origin=ORIGIN, secret="lp_session_" + secrets.token_hex(32),
                      run=secrets.token_hex(16), operations={}, not_before={})
        s = self.state
        if (s.get("version") != 1 or s.get("origin") != ORIGIN
                or not re.fullmatch(r"lp_session_[0-9a-f]{64}", str(s.get("secret", "")))
                or not re.fullmatch(r"[0-9a-f]{32}", str(s.get("run", "")))
                or not isinstance(s.get("operations"), dict) or not isinstance(s.get("not_before"), dict)
                or (s.get("intent") and digest(s["intent"]) != s.get("intent_hash"))):
            raise Failure("invalid_private_state_keep_original")
        self.deadline = self.clock() + 60

    def save(self, **updates):
        candidate = {**self.state, **copy.deepcopy(updates)}
        self.store.save(candidate)
        self.state = candidate

    def request(self, method, path, body=None, key=None, session=False):
        remaining = self.state["not_before"].get(path, 0) - self.clock()
        if remaining > 0:
            if self.clock() + remaining >= self.deadline:
                raise Pending(math.ceil(remaining))
            self.sleep(remaining)
        if method == "GET" and (path == "/sessions/current" or path.startswith("/jobs/")):
            self.save(not_before={**self.state["not_before"], path: self.clock() + 5})
        try:
            return self.http.api(method, path, body, key=key,
                                 secret=self.state["secret"] if session else None,
                                 token=None if session else self.state.get("api_key"))
        except Failure as exc:
            if exc.status == 429:
                self.save(not_before={**self.state["not_before"], path: self.clock() + exc.retry_after})
            raise

    def mutate(self, name, method, path, body=None):
        operations = self.state["operations"]
        saved = operations.get(name)
        key = "standalone." + self.state["run"] + "." + name
        if path in {"/generations", "/exports"}:
            body = {**body, "idempotency_key": key}
        desired = {"key": key, "method": method, "path": path, "body": body}
        if saved and saved != desired:
            raise Failure("operation_intent_changed_keep_original")
        if not saved:
            self.save(operations={**operations, name: desired})
        return self.request(method, path, body, key)

    def set_intent(self, intent):
        if self.state.get("intent"):
            if digest(intent) != self.state["intent_hash"]:
                raise Failure("edit_intent_changed_use_original_state")
        else:
            self.save(intent=intent, intent_hash=digest(intent))

    def offer(self):
        data = self.http.api("GET", "/sessions/offer")
        if data.get("amount_cents") != 199 or data.get("credits") != 40 or data.get("currency") != "usd":
            raise Failure("offer_changed_review_before_checkout")
        version = data.get("legal_version")
        if not isinstance(version, str) or not 1 <= len(version) <= 50:
            raise Failure("invalid_legal_version")
        return {"status": "checkout_authorization_required", "amount_cents": 199,
                "credits": 40, "currency": "usd", "legal_version": version,
                "terms_url": "https://lyricpatch.com/legal/terms",
                "privacy_url": "https://lyricpatch.com/legal/privacy"}

    def session_result(self, result):
        prior_id = self.state.get("payment_id")
        payment_id = result.get("payment_id") or result.get("id")
        if prior_id and payment_id and prior_id != payment_id:
            raise Failure("session_payment_identity_changed")
        if result.get("session_secret", self.state["secret"]) != self.state["secret"]:
            raise Failure("session_identity_changed")
        if payment_id:
            self.save(payment_id=identifier(payment_id))
        self.save(last_payment_status=result.get("status") or result.get("session_status"),
                  payment_method=result.get("payment_method", self.state.get("payment_method")))
        active = result.get("session_status") == "active" and result.get("funding_status") == "paid"
        key = result.get("api_key")
        if active:
            if not isinstance(key, str) or not re.fullmatch(r"lp_agent_[A-Za-z0-9_]+", key):
                raise Failure("active_session_key_unavailable")
            self.save(api_key=key, session_active=True)
            return {"status": "session_active", "next": "resume_preview_with_same_state"}
        self.save(api_key=None, session_active=False)
        status = result.get("status") or result.get("session_status")
        if status in {"refunded", "disputed", "failed", "expired", "revoked"}:
            return {"status": "payment_requires_attention", "next": "check_same_payment_do_not_buy_again"}
        if status == "awaiting_payment" and result.get("checkout_url"):
            return {"status": "payment_required", "message": CHECKOUT_MESSAGE,
                    "checkout_url": safe_url(result["checkout_url"], checkout=True),
                    "next": "human_pays_then_status_with_same_state"}
        return {"status": "payment_pending", "retry_after_seconds": 5,
                "next": "recover_same_session_do_not_pay_again"}

    def recover(self):
        try:
            result = self.request("GET", "/sessions/current", session=True)
        except Failure as exc:
            if exc.status != 404:
                raise
            return {"status": "checkout_authorization_required",
                    "next": "connect_with_explicit_consent_keep_same_state"}
        return self.session_result(result)

    def connect(self, consent=None):
        if consent is not None:
            names = {"adult_attested", "terms_accepted", "rights_attested",
                     "spending_authorized", "media_processing_authorized"}
            if (set(consent) != names | {"legal_version"}
                    or any(consent.get(name) is not True for name in names)
                    or not isinstance(consent.get("legal_version"), str)
                    or not 1 <= len(consent["legal_version"]) <= 50):
                raise Failure("explicit_age_terms_rights_media_spending_consent_required")
        current = self.recover()
        if current["status"] in {"session_active", "payment_required", "payment_requires_attention"}:
            return current
        if not consent:
            return current if self.state.get("checkout_consent") else self.offer()
        saved = self.state.get("checkout_consent")
        if saved and saved != consent:
            raise Failure("checkout_consent_changed_keep_original")
        if self.state.get("payment_id") and not (
                self.state.get("last_payment_status") == "creating"
                and self.state.get("payment_method") == "stripe_checkout"
                and saved == consent):
            return current
        if consent["legal_version"] != self.offer()["legal_version"]:
            raise Failure("review_current_legal_version")
        self.save(checkout_consent=consent, checkout_submitted=True)
        return self.session_result(self.request("POST", "/sessions/checkout", consent, session=True))

    def check_file(self):
        intent = self.state.get("intent")
        if not intent or digest(intent) != self.state.get("intent_hash"):
            raise Failure("saved_preview_intent_required")
        source = Path(intent["song"])
        if (not source.is_file() or source.stat().st_size != intent["size_bytes"]
                or file_hash(source) != intent["sha256"]):
            raise Failure("source_file_changed_use_original")
        return source

    def output_ready(self, path, kind):
        target = Path(path).absolute()
        forbidden = {self.store.path, Path(self.state["intent"]["song"]),
                     self.store.path.with_name(self.store.path.name + ".lock")}
        if target.is_symlink() or target.resolve() in forbidden:
            raise Failure("unsafe_output_path")
        if not target.parent.is_dir():
            raise Failure("output_directory_missing")
        record = self.state.get(kind + "_download")
        if target.exists():
            if not (record and record["path"] == str(target) and target.is_file()
                    and file_hash(target) == record["sha256"]):
                raise Failure("output_exists_choose_new_path")
            self.save(**{kind + "_download_complete": True})
            return {"status": kind + "_ready", "output": str(target)}
        return None

    def download(self, url, path, kind):
        ready = self.output_ready(path, kind)
        if ready:
            return ready
        target = Path(path).absolute()
        with tempfile.TemporaryDirectory(prefix=".lp-audio-", dir=target.parent) as folder:
            staged = Path(folder) / "audio"
            self.http.download(url, staged)
            self.save(**{kind + "_download": {"path": str(target), "sha256": file_hash(staged)}})
            try:
                os.link(staged, target)
            except FileExistsError:
                raise Failure("output_exists_choose_new_path") from None
        self.save(**{kind + "_download_complete": True})
        return {"status": kind + "_ready", "output": str(target)}

    def wait_job(self, job_id):
        path = "/jobs/" + identifier(job_id)
        while self.clock() < self.deadline:
            try:
                job = self.request("GET", path)
            except Failure as exc:
                if exc.status != 429:
                    raise
                continue
            if job.get("status") == "succeeded":
                return
            if job.get("status") in {"failed", "cancelled"}:
                raise Failure("job_" + job["status"] + "_do_not_regenerate")
        raise Pending()

    def project(self):
        return self.request("GET", "/projects/" + identifier(self.state["project_id"]))

    def check_edit(self, detail):
        edits, intent = detail.get("edits", []), self.state["intent"]
        if len(edits) != 1 or edits[0].get("id") != self.state["edit_id"]:
            raise Failure("remote_edit_intent_changed")
        edit = edits[0]
        expected = {"start_sample": self.state["start_sample"], "end_sample": self.state["end_sample"],
                    "corrected_lyrics": intent["original"], "target_lyrics": intent["replacement"]}
        if any(edit.get(k) != v for k, v in expected.items()):
            raise Failure("remote_edit_intent_changed")
        return edit

    def quote(self, kind, refresh):
        field, intent = kind + "_quote", self.state["intent"]
        submitted = kind in self.state["operations"]
        if refresh:
            if submitted:
                raise Failure("cannot_refresh_submitted_quote_resume_same_operation")
            self.save(**{field: None, field + "_round": self.state.get(field + "_round", 0) + 1})
        ceiling = intent["max_credits"] - 5 if kind == "generation" else min(
            5, intent["max_credits"] - self.state["generation_quote"]["credits"])
        quote = self.state.get(field)
        if quote is None:
            body = {"operation": "generate" if kind == "generation" else "export",
                    "project_id": self.state["project_id"], "max_credits": ceiling}
            body.update({"edit_ids": [self.state["edit_id"]]} if kind == "generation"
                        else {"format": intent["format"]})
            result = self.mutate(field + str(self.state.get(field + "_round", 0)), "POST", "/quotes", body)
            quote = {"id": identifier(result["id"]), "credits": result["credits"],
                     "expires_at": result["expires_at"]}
            if type(quote["credits"]) is not int or not 0 <= quote["credits"] <= ceiling:
                raise Failure("quote_exceeds_saved_total_budget")
            self.save(**{field: quote})
        if type(quote["credits"]) is not int or not 0 <= quote["credits"] <= ceiling:
            raise Failure("quote_exceeds_saved_total_budget")
        if not submitted:
            expiry = datetime.fromisoformat(quote["expires_at"].replace("Z", "+00:00"))
            if expiry.tzinfo is None or expiry.timestamp() <= self.clock():
                raise Failure("quote_expired_use_refresh_quote_before_submission")
        return quote

    def preview(self, output, authorize=False, refresh=False):
        source = self.check_file()
        ready = self.output_ready(output, "preview")
        if ready:
            return ready
        session = self.recover()
        if session["status"] != "session_active":
            return session
        intent = self.state["intent"]
        if "project_id" not in self.state:
            if "upload_key" not in self.state:
                upload = self.mutate("upload", "POST", "/uploads/presign",
                                     {"filename": source.name, "size_bytes": intent["size_bytes"],
                                      "content_type": "audio/mpeg"})
                self.http.upload(source, upload)
                self.check_file()
                self.save(upload_key=upload["object_key"])
            account = self.request("GET", "/account")
            project = self.mutate("project", "POST", "/projects",
                                  {"object_key": self.state["upload_key"], "original_filename": source.name,
                                   "content_type": "audio/mpeg", "legal_version": account["legal_version"],
                                   "rights_attested": True})
            self.save(project_id=identifier(project["id"]))
        project_path = "/projects/" + self.state["project_id"]
        if "ingest_id" not in self.state:
            result = self.mutate("ingest", "POST", project_path + "/ingest")
            self.save(ingest_id=identifier(result["job_id"]))
        self.wait_job(self.state["ingest_id"])
        if "edit_id" not in self.state:
            rate = self.project()["source"]["sample_rate"]
            if type(rate) is not int or not 8000 <= rate <= 384000:
                raise Failure("invalid_source_sample_rate")
            start, end = (round(Decimal(intent[k]) * rate) for k in ("start", "end"))
            edit = self.mutate("edit", "POST", project_path + "/edits",
                               {"start_sample": start, "end_sample": end})
            self.save(edit_id=identifier(edit["id"]), start_sample=start, end_sample=end)
        if not self.state.get("words_saved"):
            self.mutate("words", "PATCH", "/edits/" + self.state["edit_id"],
                        {"corrected_lyrics": intent["original"], "target_lyrics": intent["replacement"]})
            self.save(words_saved=True)
        self.check_edit(self.project())
        quote = self.quote("generation", refresh)
        if not authorize and "generation" not in self.state["operations"]:
            return {"status": "generation_approval_required", "credits": quote["credits"],
                    "reserved_export_credits": 5, "max_total_credits": intent["max_credits"],
                    "expires_at": quote["expires_at"]}
        if "batch_id" not in self.state:
            result = self.mutate("generation", "POST", "/generations", {"quote_id": quote["id"]})
            self.save(batch_id=identifier(result["id"]), generation_jobs=[identifier(j) for j in result["job_ids"]])
        if not self.state["generation_jobs"]:
            raise Failure("generation_jobs_missing_check_same_batch")
        for job in self.state["generation_jobs"]:
            self.wait_job(job)
        edit = self.check_edit(self.project())
        candidates = edit.get("generations", []) + [edit.get("latest_generation")]
        version = next((g for g in candidates if g and g.get("job_id") in self.state["generation_jobs"]), None)
        if not version or version.get("status") != "succeeded" or not version.get("playback_url"):
            raise Failure("preview_unavailable_resume_same_state")
        self.save(generation_id=identifier(version["id"]))
        return {**self.download(version["playback_url"], output, "preview"),
                "generation_credits": quote["credits"], "next": "listen_then_explicitly_approve_export"}

    def export(self, output, approve=False, authorize=False, refresh=False):
        self.check_file()
        if not approve:
            raise Failure("explicit_preview_approval_required")
        ready = self.output_ready(output, "export")
        if ready:
            return ready
        if not self.state.get("generation_id") or not self.state.get("preview_download_complete"):
            raise Failure("download_and_listen_to_preview_first")
        session = self.recover()
        if session["status"] != "session_active":
            return session
        edit = self.check_edit(self.project())
        if self.state.get("accepted"):
            if edit.get("accepted_generation_id") != self.state["generation_id"]:
                raise Failure("accepted_version_changed")
        else:
            self.mutate("accept", "POST", "/generations/" + self.state["generation_id"] + "/accept")
            self.save(accepted=True)
        quote = self.quote("export", refresh)
        if not authorize and "export" not in self.state["operations"]:
            return {"status": "export_approval_required", "credits": quote["credits"],
                    "max_total_credits": self.state["intent"]["max_credits"], "expires_at": quote["expires_at"]}
        if "export_id" not in self.state:
            result = self.mutate("export", "POST", "/exports", {"quote_id": quote["id"]})
            self.save(export_id=identifier(result["id"]), export_job=result.get("job_id"))
        if self.state.get("export_job"):
            self.wait_job(self.state["export_job"])
        exported = next((e for e in self.project().get("available_exports", [])
                         if e.get("id") == self.state["export_id"]), None)
        if (not exported or not exported.get("download_url")
                or exported.get("format") != self.state["intent"]["format"]):
            raise Failure("export_unavailable_resume_same_state")
        return {**self.download(exported["download_url"], output, "export"),
                "maximum_credits_used": self.state["generation_quote"]["credits"] + quote["credits"]}


class Parser(argparse.ArgumentParser):
    def error(self, _):
        raise Failure("invalid_arguments_use_help")


def parser():
    root = Parser(description=__doc__)
    commands = root.add_subparsers(dest="command", required=True, parser_class=Parser)
    commands.add_parser("offer", help="Public price/legal version; creates no session.")
    for name in ("connect", "status", "preview", "export"):
        command = commands.add_parser(name)
        command.add_argument("--state", required=True, type=Path)
        command.add_argument("--wait-timeout", type=int, default=60, help="Bounded wait, 1 to 600 seconds.")
        if name == "connect":
            for flag in ("authorize-1-99-checkout", "adult", "accept-terms", "rights-confirmed", "allow-media-processing"):
                command.add_argument("--" + flag, action="store_true")
            command.add_argument("--legal-version")
        if name in {"preview", "export"}:
            command.add_argument("--output", required=True, type=Path)
            command.add_argument("--refresh-quote", action="store_true")
        if name == "preview":
            for flag in ("song", "original-file", "replacement-file"):
                command.add_argument("--" + flag, type=Path)
            for flag in ("start", "end"):
                command.add_argument("--" + flag)
            command.add_argument("--max-credits", type=int)
            command.add_argument("--format", choices=("mp3", "wav"))
            for flag in ("rights-confirmed", "allow-media-processing", "authorize-generation"):
                command.add_argument("--" + flag, action="store_true")
        if name == "export":
            command.add_argument("--approve-version", action="store_true")
            command.add_argument("--authorize-export", action="store_true")
    return root


def lyrics(path):
    if path.stat().st_size > 16000:
        raise Failure("lyrics_file_too_large")
    return path.read_text(encoding="utf-8-sig")


def execute(args):
    if args.command == "offer":
        runner = object.__new__(Runner)
        runner.http = HTTP()
        return runner.offer()
    if not 1 <= args.wait_timeout <= 600:
        raise Failure("wait_timeout_must_be_1_to_600_seconds")
    with PrivateState(args.state) as store:
        runner = Runner(store)
        runner.deadline = runner.clock() + args.wait_timeout
        if args.command == "status":
            return runner.recover()
        if args.command == "connect":
            consent = None
            if args.authorize_1_99_checkout:
                if not (args.adult and args.accept_terms and args.rights_confirmed
                        and args.allow_media_processing and args.legal_version):
                    raise Failure("explicit_age_terms_rights_media_spending_consent_required")
                consent = {"legal_version": args.legal_version, "adult_attested": True,
                           "terms_accepted": True, "rights_attested": True,
                           "spending_authorized": True, "media_processing_authorized": True}
            return runner.connect(consent)
        if args.command == "preview":
            inputs = (args.song, args.original_file, args.replacement_file, args.start, args.end, args.max_credits)
            if any(v is not None for v in inputs) or args.format is not None:
                if any(v is None for v in inputs):
                    raise Failure("provide_all_initial_intent_fields_or_none_to_resume")
                if not (args.rights_confirmed and args.allow_media_processing):
                    raise Failure("explicit_rights_and_media_processing_consent_required")
                runner.set_intent(make_intent(args.song, lyrics(args.original_file), lyrics(args.replacement_file),
                                             args.start, args.end, args.max_credits, args.format or "mp3"))
            return runner.preview(args.output, args.authorize_generation, args.refresh_quote)
        return runner.export(args.output, args.approve_version, args.authorize_export, args.refresh_quote)


def main(argv=None):
    code = 0
    try:
        result = execute(parser().parse_args(argv))
    except Pending as exc:
        result = {"status": "work_pending", "retry_after_seconds": exc.retry_after,
                  "next": "rerun_same_command_and_state_no_new_purchase"}
    except Failure as exc:
        code = 1
        result = {"status": "error", "code": exc.code, "http_status": exc.status,
                  "retry_after_seconds": exc.retry_after,
                  "next": "keep_state_resume_same_operation_never_buy_or_generate_again"}
    except Exception:
        code = 1
        result = {"status": "error", "code": "local_input_or_state_error_keep_original",
                  "next": "check_private_files_and_resume_same_state"}
    print(json.dumps(result, separators=(",", ":")))
    return code


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