﻿"""Aideify Form Filler HTTP client (Python 3.10+, standard library only).
Use AIDEIFY_API_KEY for a reusable server key. Otherwise a temporary task key is
explicitly authorized by the customer and revoked when this process finishes.
"""
import argparse
import json
import os
from pathlib import Path
import secrets
import sys
import time
import urllib.error
import urllib.request


def say(*args):
    print(*args, flush=True)


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--target')
    parser.add_argument('--source', action='append', default=[])
    parser.add_argument('--job', help='Resume an existing owned job')
    parser.add_argument('--output', required=True)
    parser.add_argument('--approve', action='store_true', help='Explicitly approve current non-omitted answers without an interactive prompt')
    parser.add_argument('--cloud', action='store_true', help='Permit cloud-assisted analysis')
    parser.add_argument('--base', default='https://aideify.com/api/v1/form-filler')
    args = parser.parse_args()
    if not args.job and (not args.target or not args.source):
        parser.error('--target and --source are required unless --job is supplied')
    if Path(args.output).exists():
        raise FileExistsError('Output already exists: ' + args.output)
    for filename in ([args.target] if args.target else []) + args.source:
        if not Path(filename).is_file():
            parser.error('File not found: ' + filename)
    base = args.base.rstrip('/')
    if not base.startswith('https://') and not base.startswith('http://127.0.0.1:'):
        parser.error('Use HTTPS or a loopback test server')
    key = os.environ.get('AIDEIFY_API_KEY')
    task_key_id = None

    def call(path, body=None, method=None, raw=False, headers=None):
        data = body if isinstance(body, bytes) else json.dumps(body).encode() if body is not None else None
        request_headers = dict(headers or {})
        if key:
            request_headers['Authorization'] = 'Bearer ' + key
        if body is not None and not isinstance(body, bytes):
            request_headers['Content-Type'] = 'application/json'
        request = urllib.request.Request(base + path, data=data, headers=request_headers,
                                         method=method or ('POST' if body is not None else 'GET'))
        try:
            with urllib.request.urlopen(request, timeout=120) as response:
                content = response.read()
                return content if raw else json.loads(content) if content else None
        except urllib.error.HTTPError as exc:
            try:
                detail = json.loads(exc.read())
            except (ValueError, UnicodeError):
                detail = {}
            raise RuntimeError(f"HTTP {exc.code}: {detail.get('error', 'Request failed')} {detail.get('requestId', '')}") from None

    def wait_for_payment(order):
        say('Customer payment approval required:', order['url'])
        say('Waiting for verified payment. Do not pay twice. Your task is preserved.')
        deadline = time.time() + 900
        while time.time() < deadline:
            payment = call('/payments/' + order['orderId'])
            if payment['state'] == 'paid':
                return
            if payment['state'] in ('expired', 'canceled'):
                raise RuntimeError('Checkout ' + payment['state'] + '. Your job is preserved; restart with --job and its printed ID.')
            time.sleep(5)
        raise RuntimeError('Payment is still unconfirmed. Your job is preserved; resume its printed ID rather than paying again.')

    try:
        if not key:
            authorization = call('/authorizations', {'name': 'Python form workflow ' + time.strftime('%Y%m%d %H%M%S', time.gmtime()), 'intent': 'key_only' if args.job else 'form_completion'})
            say('Authorize account access and any one-credit API activation:', authorization['authorizationUrl'])
            deadline = time.time() + 86400
            checkout_started = False
            while time.time() < deadline:
                try:
                    result = call('/authorizations/' + authorization['id'] + '/token', {'secret': authorization['secret']})
                except RuntimeError as exc:
                    if 'HTTP 401' not in str(exc):
                        raise
                    renewed = call('/authorizations/' + authorization['id'] + '/renew', {'secret': authorization['secret']})
                    say('Authorization resumed:', renewed['authorizationUrl'])
                    continue
                if result['state'] == 'authorized':
                    key, task_key_id = result['key'], result['id']
                    break
                if result['state'] == 'denied':
                    raise RuntimeError('The customer declined authorization. No task will run.')
                if result['state'] == 'credits_required' and checkout_started:
                    pending = result.get('order')
                    if pending:
                        payment = call('/authorizations/' + authorization['id'] + '/payments/' + pending['id'], {'secret': authorization['secret']})
                        if payment['state'] in ('expired', 'canceled'):
                            raise RuntimeError('Checkout ' + payment['state'] + '. Confirmed funds remain on your account. Run the client again to resume setup.')
                if result['state'] == 'credits_required' and not checkout_started:
                    funding = result['funding']
                    order = call('/authorizations/' + authorization['id'] + '/checkout',
                                 {'secret': authorization['secret'], 'cents': funding['suggestedTopUpCents'],
                                  'requestKey': secrets.token_hex(16)})
                    say('Customer approval required: purchase credits for activation and this form:', order['url'])
                    say('Only verified payment adds credits. This authorization and task will resume.')
                    checkout_started = True
                time.sleep(authorization['interval'])
            if not key:
                raise RuntimeError('Authorization expired. Start a new authorization; confirmed funds remain on your account.')

        job = call('/jobs/' + args.job) if args.job else call('/jobs', {}, headers={'Idempotency-Key': secrets.token_hex(16)})
        path = '/jobs/' + job['id']
        say('Job:', job['id'], 'Resume with --job', job['id'])
        needs_target = not job.get('hasTarget', bool(job.get('fields')))
        needs_sources = not job.get('documents') and not job.get('sample')
        if needs_target and not args.target:
            raise RuntimeError('This saved job still needs a target. Resume with --job, --target and any missing --source files.')
        if needs_sources and not args.source:
            raise RuntimeError('This saved job still needs source data. Resume with --job and --source files.')

        if not job.get('sample') and job['completionPriceCents'] > 0 and call('/balance')['creditsRemaining'] < 1:
            funding = call('/funding?credits=1')
            order = call('/checkout', {'cents': funding['suggestedTopUpCents'], 'requestKey': secrets.token_hex(16), 'jobId': job['id']})
            wait_for_payment(order)
            job = call(path)

        if needs_target or needs_sources:
            boundary = 'aideify' + secrets.token_hex(16)
            parts = []

            def part(name, value, filename=None):
                disposition = '--' + boundary + '\r\nContent-Disposition: form-data; name="' + name + '"'
                if filename:
                    disposition += '; filename="' + filename.replace('"', '').replace('\r', '').replace('\n', '') + '"'
                parts.append((disposition + '\r\n\r\n').encode() + value + b'\r\n')

            part('revision', str(job['revision']).encode())
            part('packageRevision', str(job['packageRevision']).encode())
            part('allowRemote', str(args.cloud).lower().encode())
            if needs_target:
                part('target', Path(args.target).read_bytes(), Path(args.target).name)
            if needs_sources:
                for source in args.source:
                    part('sources', Path(source).read_bytes(), Path(source).name)
            data = b''.join(parts) + ('--' + boundary + '--\r\n').encode()
            job = call(path + '/uploads', data, headers={'Content-Type': 'multipart/form-data; boundary=' + boundary})
        if job['state'] not in ('review', 'processing'):
            job = call(path + '/process', {'revision': job['revision'], 'packageRevision': job['packageRevision'], 'allowRemote': args.cloud})
        deadline = time.time() + 900
        while job['state'] == 'processing' and time.time() < deadline:
            time.sleep(2)
            job = call(path)
        if job['state'] != 'review':
            raise RuntimeError('Processing did not reach review. Keep the printed job ID to retry.')

        if not args.approve:
            say('Review and correct answers:', job['links']['review'])
            input('After review, press Enter to approve the current non-omitted answers and download (blank fields stay blank): ')
            job = call(path)
        for field in job['fields']:
            if field.get('pending'):
                raise RuntimeError('Resolve source changes in hosted review, then resume this same job.')
            if field['decision'] in ('approved', 'omitted') or not field['value'].strip():
                continue
            if field.get('placement', {}).get('required') and not field['placement'].get('confirmed'):
                raise RuntimeError('Confirm scanned-form placements in hosted review, then resume this same job.')
            job = call(path + '/review', {'revision': job['revision'], 'packageRevision': job['packageRevision'],
                                         'fieldId': field['id'], 'action': 'approve'}, method='PATCH')
        result = call(path + '/exports', {'revision': job['revision'], 'packageRevision': job['packageRevision'],
                                         'expectedCents': job['completionPriceCents'], 'allowPartial': True})
        pdf = call(path + '/output?revision=' + str(result['revision']), raw=True)
        if not pdf.startswith(b'%PDF'):
            raise RuntimeError('The server did not return a PDF')
        with open(args.output, 'xb') as output:
            output.write(pdf)
        say('Saved', args.output, 'Remaining credits:', call('/balance')['creditsRemaining'])
    finally:
        if task_key_id:
            try:
                call('/keys/' + task_key_id, method='DELETE')
            except (RuntimeError, OSError):
                say('Temporary key cleanup could not be confirmed. Revoke the task key in Developer keys.')


if __name__ == '__main__':
    try:
        main()
    except (RuntimeError, FileExistsError, KeyboardInterrupt) as exc:
        raise SystemExit(str(exc) or 'Stopped. Keep the printed job ID to resume.')
