#!/usr/bin/env python3
"""AUROFLOW beta client. Python 3.9+, standard library, one submit per intent."""
import argparse
import base64
import getpass
import hashlib
from http.client import HTTPException
import json
import math
import mimetypes
import os
from pathlib import Path
import re
import sys
import tempfile
import time
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode, urljoin, urlsplit
from urllib.request import HTTPRedirectHandler, Request, build_opener


class APIError(Exception):
    def __init__(self, status, code, message, *, request_id=None, stage='upstream', recoverable=False, uncertain=False, action_required='inspect_service_status', resource_url=None, dispatched=None):
        self.status, self.code, self.request_id = status, code, request_id
        self.stage, self.recoverable, self.uncertain = stage, recoverable, uncertain
        self.action_required, self.resource_url = action_required, resource_url
        self.dispatched=dispatched if type(dispatched) is bool else None
        suffix = f" (X-Auro-Request-ID={request_id})" if request_id else ""
        super().__init__(message + suffix)

    def as_dict(self):
        value={'status':self.status,'code':self.code,'message':str(self),'stage':self.stage,
               'recoverable':self.recoverable,'uncertain':self.uncertain,'action_required':self.action_required,'automatic_retry':False}
        if self.resource_url:value['resource_url']=self.resource_url
        if self.request_id:value['request_id']=self.request_id
        if self.dispatched is not None:value['dispatched']=self.dispatched
        return value


def safe_failure(value):
    """Keep actionable identifiers without echoing private server messages."""
    if not isinstance(value,dict):return {}
    details={}
    for key in ('code','stage','action_required'):
        if isinstance(value.get(key),str) and re.fullmatch(r'[a-z][a-z0-9_]{0,95}',value[key]):details[key]=value[key]
    for key in ('recoverable','uncertain','dispatched'):
        if type(value.get(key)) is bool:details[key]=value[key]
    if isinstance(value.get('resource_url'),str) and re.fullmatch(r'/v1/(?:jobs/api-|sequences/seq-|voices/jobs/tts-)[a-z0-9][a-z0-9_-]{0,94}',value['resource_url']):details['resource_url']=value['resource_url']
    return details


def resource_context(path,payload=None):
    route=urlsplit(path).path
    match=re.fullmatch(r'(/v1/(?:jobs/api-|sequences/seq-|voices/jobs/tts-)[a-z0-9][a-z0-9_-]{0,94})(?:/recover)?',route)
    if match:return match.group(1)
    if route in ('/v1/jobs','/v1/sequences','/v1/voices/jobs') and isinstance(payload,dict):
        identifier=payload.get('id');prefix='tts-' if route=='/v1/voices/jobs' else 'api-' if route.endswith('jobs') else 'seq-'
        if isinstance(identifier,str) and re.fullmatch(prefix+r'[a-z0-9][a-z0-9_-]{0,94}',identifier):return route+'/'+identifier
    return None


def mutation_path(path):
    route=urlsplit(path).path
    return route in ('/v1/jobs','/v1/sequences','/v1/uploads','/v1/voices/jobs') or bool(re.fullmatch(r'/v1/(?:jobs/api-|sequences/seq-|voices/jobs/tts-)[a-z0-9][a-z0-9_-]{0,94}/recover',route))


def reconciliation_action(path):
    return 'reconcile_existing_upload_catalog' if urlsplit(path).path=='/v1/uploads' else 'inspect_existing_resource'


def validate_wait_bounds(timeout,interval=10):
    if type(timeout) not in (int,float) or not math.isfinite(timeout) or not 0<timeout<=86400 or type(interval) not in (int,float) or not math.isfinite(interval) or not 0<interval<=60:
        raise ValueError('Wait timeout must be 0..86400 seconds and interval 0..60 seconds, both greater than zero.')


def strict_json(raw):
    def unique(pairs):
        result={}
        for key,value in pairs:
            if key in result:raise ValueError('JSON fields must be unique.')
            result[key]=value
        return result
    def constant(_value):raise ValueError('JSON numbers must be finite.')
    try:
        result=json.loads(raw,object_pairs_hook=unique,parse_constant=constant)
        json.dumps(result,ensure_ascii=False,allow_nan=False).encode('utf-8')
        return result
    except (RecursionError,UnicodeError):raise ValueError('Invalid JSON Unicode or nesting.') from None


class NoRedirect(HTTPRedirectHandler):
    def redirect_request(self, *args, **kwargs):
        # Never forward credentials to a URL supplied in a redirect.
        return None


def job_path(job_id):
    if not isinstance(job_id,str) or not re.fullmatch(r"api-[a-z0-9][a-z0-9_-]{0,94}", job_id):
        raise ValueError("Use the original api-... job ID.")
    return "/v1/jobs/" + job_id


def sequence_path(sequence_id):
    if not isinstance(sequence_id,str) or not re.fullmatch(r'seq-[a-z0-9][a-z0-9_-]{0,94}',sequence_id):
        raise ValueError('Use the original seq-... sequence ID.')
    return '/v1/sequences/'+sequence_id


def film_path(film_id):
    if not isinstance(film_id,str) or not re.fullmatch(r'film-[a-z0-9][a-z0-9_-]{0,78}',film_id):
        raise ValueError('Use the original film-... project ID.')
    return '/v1/films/'+film_id


class Client:
    def __init__(self, email, password, base="https://api.auroflow.club", *, opener=None, token=None):
        url = urlsplit(base)
        if url.scheme != "https" or not url.hostname or url.username or url.password or url.path not in ("", "/") or url.query or url.fragment:
            raise ValueError("API base must be an HTTPS origin without credentials or path.")
        if token is not None:
            if email or password or not isinstance(token, str) or not re.fullmatch(r'afk_[a-f0-9]{32}_[A-Za-z0-9_-]{43}', token):
                raise ValueError('Use one valid authentication source.')
            authorization = 'Bearer ' + token
        else:
            if not isinstance(email, str) or not isinstance(password, str) or ":" in email or any(c in email + password for c in "\r\n"):
                raise ValueError("Invalid credentials format.")
            authorization = "Basic " + base64.b64encode((email + ":" + password).encode()).decode()
        self.base = base.rstrip("/")
        self.authorization = authorization
        self.opener = opener or build_opener(NoRedirect())

    def agent_start(self):
        """Read the compact agent entry point; never infer or generate."""
        return self.request('/v1/agent-start')

    def films(self, *, limit=5):
        if type(limit) is not int or not 1<=limit<=20:
            raise ValueError('Film list limit must be an integer from 1 to 20.')
        return self.request('/v1/films?compact=1&limit='+str(limit))

    def film(self, film_id):
        return self.request(film_path(film_id))

    def film_next(self, film_id):
        return self.request(film_path(film_id)+'/next')

    def graphics_styles(self):
        """Read graphic direction and supported style/layout directives."""
        return self.request('/v1/graphics/styles')

    def graphics_templates(self, request):
        """Read compact curated recipes or one unresolved template; no render."""
        if not isinstance(request,dict):
            raise ValueError('Graphics template selection must be an object.')
        return self.request('/v1/graphics/templates',request)

    def suggest_graphics(self, request):
        """Prepare data-only suggestions; never save, infer or render."""
        if not isinstance(request,dict):
            raise ValueError('Graphics suggestion must be an object.')
        return self.request('/v1/graphics/suggest',request)

    def review_graphics(self, request):
        """Estimate a typed layout without fetching media or starting work."""
        if not isinstance(request,dict) or not isinstance(request.get('composition'),dict):
            raise ValueError('Graphics review requires a composition object.')
        cost=request['composition'].get('cost_max')
        if type(cost) is not int or cost!=0:
            raise ValueError('Composition cost_max must be integer zero.')
        return self.request('/v1/graphics/review',request)

    def url(self, path):
        url = urljoin(self.base + "/", path)
        parsed = urlsplit(url)
        if (parsed.scheme, parsed.netloc) != (urlsplit(self.base).scheme, urlsplit(self.base).netloc) or not parsed.path.startswith("/v1/") or parsed.fragment:
            raise ValueError("Refusing an API URL outside the authenticated origin.")
        return url

    def open(self, path, *, method="GET", data=None, content_type=None, timeout=60):
        headers = {"Authorization": self.authorization, "User-Agent": "AUROFLOW-Client/1.0-beta.1", "Accept": "application/json"}
        if content_type:
            headers["Content-Type"] = content_type
        try:
            return self.opener.open(Request(self.url(path), method=method, data=data, headers=headers), timeout=timeout)
        except HTTPError as exc:
            details={'stage':'upstream','recoverable':method=='GET' and exc.code in {429,502,503,504},
                     'uncertain':method=='POST' and mutation_path(path) and exc.code>=500,
                     'action_required':reconciliation_action(path) if method=='POST' and mutation_path(path) else 'check_service_status'}
            code = "http_error"
            request_id = exc.headers.get("X-Auro-Request-ID") if exc.headers else None
            if not isinstance(request_id, str) or not re.fullmatch(r"[a-f0-9]{24}", request_id):
                request_id = None
            try:
                value = json.loads(exc.read(65536))
                safe=safe_failure(value.get('error',{}));candidate=safe.pop('code',code);details.update(safe)
                if isinstance(candidate, str) and re.fullmatch(r"[a-z0-9_]{1,80}", candidate):
                    code = candidate
            except (ValueError, AttributeError):
                pass
            finally:
                exc.close()
            raise APIError(exc.code, code, f"HTTP {exc.code}: {code}", request_id=request_id,**details) from None
        except (URLError, OSError, TimeoutError):
            write=method=='POST' and mutation_path(path)
            upload=urlsplit(path).path=='/v1/uploads'
            message='Upload outcome unknown. Inspect the existing image catalog by SHA-256 and role before another upload.' if write and upload else 'Network outcome unknown. Query the saved resource ID before another action.' if write else 'Read interrupted. Retry this read of the existing resource.'
            raise APIError(0, "network_uncertain",message,
                           stage='connection',recoverable=not write,uncertain=write,action_required=reconciliation_action(path) if write else 'retry_read',resource_url=resource_context(path)) from None

    def request(self, path, payload=None, *, timeout=60):
        data = json.dumps(payload, ensure_ascii=False,allow_nan=False).encode('utf-8') if payload is not None else None
        return self._request_json(path,data=data,content_type='application/json' if data is not None else None,payload=payload,timeout=timeout)

    def _request_json(self,path,*,data=None,content_type=None,payload=None,timeout=60):
        write=data is not None and mutation_path(path)
        resource=resource_context(path,payload)
        uncertain_message='Upload response could not be confirmed. Inspect the existing image catalog by SHA-256 and role; do not re-upload.' if urlsplit(path).path=='/v1/uploads' else 'Response could not be confirmed. Query the saved resource; do not resend.'
        try:
            with self.open(path, method="POST" if data is not None else "GET", data=data,
                           content_type=content_type,timeout=timeout) as response:
                raw = response.read(4 * 1024 * 1024 + 1)
                if len(raw) > 4 * 1024 * 1024:raise ValueError('Response exceeds the client JSON limit.')
                value=strict_json(raw)
                if not isinstance(value,dict):raise ValueError('API response must be an object.')
                return value
        except APIError as error:
            if error.resource_url is None:error.resource_url=resource
            raise
        except (HTTPException,OSError,TimeoutError):
            raise APIError(0,'response_uncertain' if write else 'network_read_failed',uncertain_message if write else 'Read interrupted. Retry this read of the existing resource.',
                           stage='upstream',uncertain=write,recoverable=not write,action_required=reconciliation_action(path) if write else 'retry_read',resource_url=resource) from None
        except (ValueError,UnicodeError):
            raise APIError(0,'response_uncertain' if write else 'invalid_response',uncertain_message if write else 'API response could not be read. Inspect service status.',
                           stage='upstream',uncertain=write,action_required=reconciliation_action(path) if write else 'inspect_service_status',resource_url=resource) from None

    def upload(self,image,*,role):
        if role not in ('character','environment','asset','style','start','end'):raise ValueError('Use a supported image role.')
        image=Path(image)
        if not 0<image.stat().st_size<=10*1024*1024:raise ValueError('Image must contain 1 byte..10 MiB.')
        mime=mimetypes.guess_type(str(image))[0]
        if mime not in {'image/png','image/jpeg','image/webp'}:raise ValueError('Use PNG, JPEG or WebP.')
        data=image.read_bytes()
        if not 0<len(data)<=10*1024*1024:raise ValueError('Image must contain 1 byte..10 MiB.')
        query=urlencode({'role':role,'filename':image.name})
        return self._request_json('/v1/uploads?'+query,data=data,content_type=mime)

    def uploads(self):
        return self.request('/v1/uploads')

    def render(self, spec):
        if not isinstance(spec,dict):raise ValueError('Shot specification must be an object.')
        job_path(spec.get("id"))
        # No retry decorator here, including on 429 or an unreadable response.
        return self.request("/v1/jobs", spec)

    def jobs(self, *, offset=0, limit=20):
        if type(offset) is not int or not 0 <= offset <= 1000000 or type(limit) is not int or not 1 <= limit <= 40:
            raise ValueError("History offset must be 0..1000000 and limit 1..40.")
        return self.request('/v1/jobs?' + urlencode({'offset': offset, 'limit': limit}))

    def wait(self, job_id, *, timeout=900, interval=10, sleep=time.sleep):
        return self._wait_resource(job_path(job_id),'job',timeout=timeout,interval=interval,sleep=sleep)

    def sequence_compile(self,spec):
        if not isinstance(spec,dict):raise ValueError('Sequence specification must be an object.')
        sequence_path(spec.get('id'))
        return self.request('/v1/sequences/compile',spec)

    def sequence_create(self,spec):
        if not isinstance(spec,dict):raise ValueError('Sequence specification must be an object.')
        sequence_path(spec.get('id'))
        return self.request('/v1/sequences',spec)

    def sequence_get(self,sequence_id):
        return self.request(sequence_path(sequence_id))

    def sequence_recover(self,sequence_id):
        # One explicit recovery, never invoked by a wait or a download.
        return self.request(sequence_path(sequence_id)+'/recover',{})

    def sequence_wait(self,sequence_id,*,timeout=900,interval=10,sleep=time.sleep):
        return self._wait_resource(sequence_path(sequence_id),'sequence',timeout=timeout,interval=interval,sleep=sleep)

    def _wait_resource(self,path,key,*,timeout,interval,sleep):
        validate_wait_bounds(timeout,interval)
        deadline = time.monotonic() + timeout
        delay = interval
        while time.monotonic() < deadline:
            try:
                record = self.request(path,timeout=min(60,max(.001,deadline-time.monotonic()))).get(key)
                if not isinstance(record,dict) or record.get('id')!=path.rsplit('/',1)[-1] or not isinstance(record.get('state'),str):
                    raise APIError(0,'invalid_response','Resource identity or state is missing.',stage='upstream',resource_url=path)
                state = record['state']
                if state == "completed":
                    return record
                failure=safe_failure(record.get('failure')) if key=='sequence' else {}
                if state in {"blocked", "failed", "cancelled_verified"}:
                    code=failure.pop('code',state);failure.setdefault('stage','admission');failure.setdefault('action_required','inspect_existing_resource');failure.setdefault('resource_url',path)
                    raise APIError(409,code,f'{key.capitalize()} {record["id"]}: {state}. Inspect it; do not resubmit.',**failure)
                attention=state in ('action_required','needs_audio_bed') or (key=='sequence' and state=='recovering') or (key=='job' and (record.get('delivery') or {}).get('status')=='action_required') or (key=='job' and state in ('recovering','partial') and (record.get('delivery') or {}).get('status')!='recovering')
                if attention:
                    code=failure.pop('code',state);failure.setdefault('stage','admission');failure.setdefault('action_required','inspect_existing_sequence' if key=='sequence' else 'inspect_existing_job');failure['resource_url']=path
                    raise APIError(409,code,f'{key.capitalize()} {record["id"]}: {state}. Follow action_required before explicit recovery.',**failure)
                allowed={'queued','assembling','recovery_queued'} if key=='sequence' else {'queued','preparing','prepared','reserved','submitting','collecting','partial','recovering','recovery_queued','recovery_collecting'}
                if state not in allowed:raise APIError(0,'invalid_response','Resource state is not recognized. Inspect the existing resource.',stage='upstream',resource_url=path,action_required='inspect_existing_resource')
                delay = interval
            except APIError as exc:
                if not exc.recoverable or exc.status not in {0, 429, 502, 503, 504}:
                    raise
                delay = min(max(delay * 2, 5), 40)
            sleep(min(delay, max(0, deadline - time.monotonic())))
        raise APIError(0, "wait_timeout", f'{key.capitalize()} {path.rsplit("/",1)[-1]} still pending. Continue waiting on this ID.',stage='upstream',recoverable=True,action_required='continue_waiting_existing_resource',resource_url=path)

    def sequence_video(self,sequence_id,destination,*,kind='final'):
        if kind not in ('final','preview','native','hybrid'):raise ValueError('Sequence kind must be final, preview, native or hybrid.')
        path=sequence_path(sequence_id);record=self.sequence_get(sequence_id).get('sequence')
        if not isinstance(record,dict) or record.get('id')!=sequence_id:raise APIError(0,'invalid_response','Sequence identity is missing.',resource_url=path)
        final_kind=record.get('final_kind');selected='hybrid' if kind=='final' and final_kind=='hybrid_post' else 'native' if kind=='final' else kind
        if kind=='final' and record.get('state')!='completed':raise APIError(409,'sequence_not_completed','Final video is not available. Continue with the existing sequence ID.',stage='media',resource_url=path,action_required='inspect_existing_resource_media_state')
        if kind=='final' and final_kind not in ('native','hybrid_post'):raise APIError(409,'sequence_video_unavailable','Final output provenance is not confirmed.',stage='media',resource_url=path,action_required='inspect_existing_resource_media_state')
        field={'preview':'local_preview','native':'flow_scene','hybrid':'hybrid_post'}[selected]
        url_field='video_url' if kind=='final' else {'preview':'local_preview_url','native':'native_video_url','hybrid':'hybrid_video_url'}[kind]
        endpoint=path+'/'+{'final':'video','preview':'preview-video','native':'native-video','hybrid':'hybrid-video'}[kind]
        metadata=record.get(field) or {};url=record.get(url_field)
        sha=metadata.get('sha256') if isinstance(metadata,dict) else None
        if not isinstance(url,str) or not isinstance(sha,str) or not re.fullmatch(r'[a-f0-9]{64}',sha):raise APIError(409,'sequence_video_unavailable','Requested sequence video has no confirmed file and SHA-256.',stage='media',resource_url=path,action_required='inspect_existing_resource_media_state')
        allowed_urls={self.url(endpoint)}
        if kind=='final':allowed_urls.add(self.url(path+'/'+{'native':'native-video','hybrid':'hybrid-video'}[selected]))
        if self.url(url) not in allowed_urls:raise APIError(409,'sequence_media_identity_mismatch','Video URL does not identify this sequence and requested output kind.',stage='media',resource_url=path,action_required='inspect_existing_resource_media_state')
        receipt=self.download(url,destination,sha,expected_size=metadata.get('bytes'))
        receipt.update(sequence_id=sequence_id,kind=kind,provenance={'preview':'api_preview','native':'flow_native_scene','hybrid':'hybrid_post'}[selected],human_audiovisual_approval=False)
        return receipt

    def download(self, url, destination, expected_hash=None, *, expected_size=None, expected_content_type="video/mp4"):
        self.url(url)  # Validate before creating a local file.
        if expected_content_type not in ("video/mp4", "audio/wav"):raise ValueError("Unsupported media type.")
        destination = Path(destination)
        if destination.exists():
            raise ValueError("Output exists; choose another destination.")
        if expected_hash is not None and (not isinstance(expected_hash,str) or not re.fullmatch(r'[a-f0-9]{64}',expected_hash)):raise ValueError('Expected SHA-256 must contain 64 lowercase hexadecimal characters.')
        if expected_size is not None and (type(expected_size) is not int or not 0<expected_size<=512*1024*1024):raise ValueError('Expected video size must be 1..512 MiB.')
        destination.parent.mkdir(parents=True,exist_ok=True)
        digest = hashlib.sha256()
        received=0;part=None;reading=False
        try:
            with tempfile.NamedTemporaryFile(prefix='.'+destination.name+'-',suffix='.part',dir=destination.parent,delete=False) as output:
                part=Path(output.name)
                with self.open(url) as response:
                    if response.headers.get("Content-Type", "").split(";",1)[0].strip()!=expected_content_type:
                        raise ValueError("Download is not an MP4.")
                    declared=response.headers.get('Content-Length')
                    if declared is not None and (not isinstance(declared,str) or not declared.isdigit() or not 0<int(declared)<=512*1024*1024):raise ValueError('Invalid MP4 Content-Length.')
                    while True:
                        reading=True
                        chunk = response.read(65536)
                        reading=False
                        if not chunk:
                            break
                        received+=len(chunk)
                        if received>512*1024*1024:raise ValueError('MP4 exceeds the 512 MiB download limit.')
                        digest.update(chunk)
                        output.write(chunk)
                    if declared is not None and received!=int(declared):raise ValueError('MP4 download was interrupted.')
                if expected_size is not None and received!=expected_size:raise ValueError('MP4 size does not match the server receipt.')
                if expected_hash and digest.hexdigest() != expected_hash:
                    raise ValueError("MP4 hash does not match the server receipt.")
                output.flush();os.fsync(output.fileno())
            # Installation fails atomically if another file appeared.
            os.link(part, destination)
        except (HTTPException,TimeoutError):
            raise APIError(0,'download_interrupted','Video read was interrupted. Retry its existing download URL.',stage='media',recoverable=True,action_required='retry_existing_media_download') from None
        except OSError:
            if not reading:raise
            raise APIError(0,'download_interrupted','Video read was interrupted. Retry its existing download URL.',stage='media',recoverable=True,action_required='retry_existing_media_download') from None
        finally:
            if part is not None:part.unlink(missing_ok=True)
        return {"file": str(destination.resolve()), "sha256": digest.hexdigest(),'bytes':received}


def main(argv=None):
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--base", default="https://api.auroflow.club")
    sub = parser.add_subparsers(dest="command", required=True)
    for name in ("status", "credits", "capabilities", "uploads", "native-references", "workflows", "agent-start", "graphics-styles"):
        sub.add_parser(name)
    for name in ('suggest-graphics','review-graphics','graphics-templates'):
        cmd=sub.add_parser(name);cmd.add_argument('spec',type=Path)
    films = sub.add_parser('films');films.add_argument('--limit',type=int,default=5)
    for name in ('film','film-next'):
        cmd=sub.add_parser(name);cmd.add_argument('film_id')
    workflow = sub.add_parser('plan-workflow')
    workflow.add_argument('spec', type=Path)
    history = sub.add_parser('jobs')
    history.add_argument('--offset', type=int, default=0)
    history.add_argument('--limit', type=int, default=20)
    for name in ("quote", "compile", "render"):
        cmd = sub.add_parser(name)
        cmd.add_argument("spec", type=Path)
        if name == "render":
            cmd.add_argument("--output", type=Path)
            cmd.add_argument('--timeout',type=float,default=900)
    for name in ("wait", "get", "recover"):
        cmd = sub.add_parser(name)
        cmd.add_argument("job_id")
        if name == "wait":
            cmd.add_argument("--output", type=Path)
            cmd.add_argument('--timeout',type=float,default=900)
    for name in ('sequence-compile','sequence-create'):
        cmd=sub.add_parser(name);cmd.add_argument('spec',type=Path)
        if name=='sequence-create':
            cmd.add_argument('--output',type=Path);cmd.add_argument('--kind',choices=('final','preview','native','hybrid'),default='final');cmd.add_argument('--timeout',type=float,default=900)
    for name in ('sequence-get','sequence-wait','sequence-recover'):
        cmd=sub.add_parser(name);cmd.add_argument('sequence_id')
        if name=='sequence-wait':
            cmd.add_argument('--output',type=Path);cmd.add_argument('--kind',choices=('final','preview','native','hybrid'),default='final');cmd.add_argument('--timeout',type=float,default=900)
    cmd=sub.add_parser('sequence-video');cmd.add_argument('sequence_id');cmd.add_argument('output',type=Path);cmd.add_argument('--kind',choices=('final','preview','native','hybrid'),default='final')
    upload = sub.add_parser("upload")
    upload.add_argument("image", type=Path)
    upload.add_argument("--role", choices=["character", "environment", "asset", "style", "start", "end"], required=True)
    args = parser.parse_args(argv)
    email, password = os.getenv("AUROFLOW_API_EMAIL"), os.getenv("AUROFLOW_API_PASSWORD")
    token = os.getenv('AUROFLOW_API_TOKEN')
    if not token and (not email or not password):
        if not sys.stdin.isatty():
            parser.error("Provide AUROFLOW_API_EMAIL and AUROFLOW_API_PASSWORD through your secure environment.")
        email = email or input("AUROFLOW email: ")
        password = password or getpass.getpass("AUROFLOW password: ")
    try:
        client = Client(email, password, args.base, token=token)
        if hasattr(args,'timeout'):validate_wait_bounds(args.timeout)
        if getattr(args,'output',None) and args.output.exists():raise ValueError('Output exists; choose another destination before executing.')
        if args.command=='agent-start':
            value=client.agent_start()
        elif args.command=='graphics-styles':
            value=client.graphics_styles()
        elif args.command in ('suggest-graphics','review-graphics','graphics-templates'):
            spec=strict_json(args.spec.read_text(encoding='utf-8'))
            operation={'suggest-graphics':client.suggest_graphics,'review-graphics':client.review_graphics,
                       'graphics-templates':client.graphics_templates}[args.command]
            value=operation(spec)
        elif args.command=='films':
            value=client.films(limit=args.limit)
        elif args.command in {'film','film-next'}:
            value=client.film_next(args.film_id) if args.command=='film-next' else client.film(args.film_id)
        elif args.command == "status":
            value = {"account": client.request("/v1/me"), "service": client.request("/v1/status")}
        elif args.command == 'jobs':
            value = client.jobs(offset=args.offset, limit=args.limit)
        elif args.command in ("credits",'capabilities','native-references'):
            value = client.request("/v1/" + args.command)
        elif args.command == 'workflows':
            value = client.request('/v1/agent-workflows')
        elif args.command == 'plan-workflow':
            spec = strict_json(args.spec.read_text(encoding='utf-8'))
            if not isinstance(spec, dict):
                raise ValueError('Workflow specification must be an object.')
            if type(spec.get('cost_max')) is not int or spec['cost_max'] != 0:
                raise ValueError('Workflow cost_max must be integer zero.')
            value = client.request('/v1/agent-workflows/plan', spec)
        elif args.command == 'uploads':
            value = client.uploads()
        elif args.command == "upload":
            value = client.upload(args.image,role=args.role)
        elif args.command in {"quote", "compile", "render"}:
            spec = strict_json(args.spec.read_text(encoding="utf-8"))
            if not isinstance(spec,dict):raise ValueError('Shot specification must be an object.')
            if args.command == "render":
                print(json.dumps({"job_id": spec.get("id"), "submission": "once"}), flush=True)
                value = client.render(spec)
                if args.output:
                    value = client.wait(spec["id"],timeout=args.timeout)
            else:
                value = client.request("/v1/" + args.command, spec)
        elif args.command == "recover":
            value = client.request(job_path(args.job_id) + "/recover", {})
        elif args.command == "get":
            value = client.request(job_path(args.job_id))
        elif args.command in ('sequence-compile','sequence-create'):
            spec=strict_json(args.spec.read_text(encoding='utf-8'))
            if not isinstance(spec,dict):raise ValueError('Sequence specification must be an object.')
            if args.command=='sequence-compile':value=client.sequence_compile(spec)
            else:
                print(json.dumps({'sequence_id':spec.get('id'),'submission':'once'}),flush=True)
                value=client.sequence_create(spec)
                if args.output:
                    if args.kind!='preview':client.sequence_wait(spec['id'],timeout=args.timeout)
                    value=client.sequence_video(spec['id'],args.output,kind=args.kind)
        elif args.command=='sequence-get':value=client.sequence_get(args.sequence_id)
        elif args.command=='sequence-recover':value=client.sequence_recover(args.sequence_id)
        elif args.command=='sequence-video':value=client.sequence_video(args.sequence_id,args.output,kind=args.kind)
        elif args.command=='sequence-wait':
            value=client.sequence_wait(args.sequence_id,timeout=args.timeout)
            if args.output:value=client.sequence_video(args.sequence_id,args.output,kind=args.kind)
        else:
            value = client.wait(args.job_id,timeout=args.timeout)
        if args.command in ('render','wait') and getattr(args, "output", None) and value.get("state") == "completed":
            variants = value.get("variants") or [value]
            if len(variants)>1:
                ordinals=[item.get('ordinal') for item in variants if isinstance(item,dict)]
                if len(ordinals)!=len(variants) or any(type(number) is not int or not 1<=number<=4 for number in ordinals) or len(set(ordinals))!=len(ordinals):raise ValueError('Returned variants need unique observed ordinals 1..4.')
                variants=sorted(variants,key=lambda item:item['ordinal'])
            files = []
            for index, item in enumerate(variants, 1):
                ordinal=item.get('ordinal',1) if len(variants)==1 else item['ordinal']
                output = args.output if len(variants) == 1 else args.output.with_name(f"{args.output.stem}-{ordinal:02}{args.output.suffix}")
                receipt=client.download(item["video_url"], output, item.get("sha256") or (item.get("result") or {}).get("sha256"));receipt['ordinal']=ordinal;files.append(receipt)
            value = {"job_id": value["id"], "state": "completed", "files": files,'human_audiovisual_approval':False}
        print(json.dumps(value, ensure_ascii=False, indent=2))
        return 0
    except APIError as exc:
        print(json.dumps({'error':exc.as_dict()},ensure_ascii=False),file=sys.stderr)
        return 1
    except (ValueError, OSError, KeyError,TypeError) as exc:
        print(json.dumps({'error':{'code':'client_input_or_output_invalid','message':str(exc),'stage':'request_validation','uncertain':False,'automatic_retry':False,'action_required':'correct_client_input_or_output'}},ensure_ascii=False),file=sys.stderr)
        return 1


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