#!/usr/bin/env python3
"""Publish a browser build to Arcade with Python 3 and no installed packages."""
import argparse
import contextlib
import json
import os
from pathlib import Path, PurePosixPath
import re
import sys
import tempfile
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode, urlsplit
from urllib.request import HTTPRedirectHandler, Request, build_opener
import zipfile

ORIGIN = 'https://arcade.fun'
RECEIPT = '.arcade-publish.json'
INPUTS = ('keyboard', 'mouse', 'touch', 'gamepad', 'voice', 'camera')


class Rejected(Exception):
    """The server returned a clear rejection."""


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


class Arcade:
    def __init__(self, origin):
        self.origin = origin
        self.opener = build_opener(NoRedirect())

    def request(self, path, method='GET', data=None, headers=None):
        try:
            with self.opener.open(Request(self.origin + path, data=data, method=method,
                                          headers=headers or {}), timeout=150) as reply:
                return json.load(reply)
        except HTTPError as error:
            try:
                message = json.load(error).get('error', '')
            except (ValueError, AttributeError):
                message = ''
            if 400 <= error.code < 500:
                raise Rejected(message or f'Arcade returned HTTP {error.code}.') from None
            raise ValueError(f'Arcade returned HTTP {error.code}. Check status before retrying.') from None

    def call(self, name, arguments):
        reply = self.request('/mcp', 'POST', json.dumps({
            'jsonrpc': '2.0', 'id': 1, 'method': 'tools/call',
            'params': {'name': name, 'arguments': arguments},
        }).encode(), {'Content-Type': 'application/json', 'Accept': 'application/json'})
        if 'error' in reply:
            raise Rejected(reply['error']['message'])
        result = reply['result']
        payload = result['structuredContent']
        if result.get('isError'):
            if payload.get('status', 500) < 500:
                raise Rejected(payload['error'])
            raise ValueError(payload['error'])
        return payload

    def status(self, receipt):
        return self.call('experience_status', {k: receipt[k] for k in ('game', 'key')})

    def upload(self, path, receipt, entry, threads):
        query = urlencode({k: v for k, v in {'entry': entry, 'threads': threads}.items() if v})
        target = f"/api/uploads/{receipt['version']}/files" + ('?' + query if query else '')
        with path.open('rb') as data:
            return self.request(target, 'PUT', data, {
                'Content-Type': 'application/zip' if path.suffix.lower() == '.zip' else 'text/html',
                'Content-Length': str(path.stat().st_size), 'X-Manage-Key': receipt['key'],
            })


def private_file(name):
    return (name.startswith('.') or name.endswith(('.pem', '.key', '.p12', '.pfx', '.keystore'))
            or name in ('node_modules', '__pycache__', 'id_rsa', 'id_ed25519'))


@contextlib.contextmanager
def prepared_build(source, receipt, entry, max_bytes, max_expanded):
    if source.is_symlink():
        raise ValueError('Choose an actual build file or directory.')
    if source.is_dir():
        if not (source / (entry or 'index.html')).is_file():
            raise ValueError('Choose the exported web build with index.html at its root, or supply --entry.')
        with tempfile.TemporaryDirectory(prefix='arcade-publish-') as temp:
            path = Path(temp) / 'build.zip'
            with zipfile.ZipFile(path, 'w', zipfile.ZIP_DEFLATED) as archive:
                for directory, children, files in os.walk(source):
                    children[:] = sorted(name for name in children if not private_file(name))
                    for name in sorted(files):
                        file = Path(directory) / name
                        if private_file(name) or file.resolve() == receipt.resolve():
                            continue
                        if file.is_symlink() or not file.is_file():
                            raise ValueError(f'The build contains a link or special file: {file.relative_to(source)}')
                        archive.write(file, file.relative_to(source).as_posix())
            with prepared_build(path, receipt, entry, max_bytes, max_expanded) as build:
                yield build
        return

    if not source.is_file() or source.suffix.lower() not in ('.html', '.htm', '.zip'):
        raise ValueError('Choose a single HTML file, a ZIP, or an exported build directory.')
    if not 0 < source.stat().st_size <= max_bytes:
        raise ValueError(f'The build is empty or exceeds the {max_bytes // (1024 * 1024)} MB upload limit.')
    if source.suffix.lower() == '.zip':
        with zipfile.ZipFile(source) as archive:
            members = archive.infolist()
            if sum(item.file_size for item in members) > max_expanded:
                raise ValueError('The expanded build exceeds the upload limit.')
            pages = set()
            for item in members:
                name = PurePosixPath(item.filename)
                if (name.is_absolute() or '\\' in item.filename or '..' in name.parts
                        or any(private_file(part) for part in name.parts)
                        or name.name in {RECEIPT, receipt.name}):
                    raise ValueError(f'Remove the private or unsafe ZIP entry: {item.filename}')
                if not item.is_dir() and name.suffix.lower() in ('.html', '.htm'):
                    pages.add(name.as_posix())
            if entry and entry not in pages:
                raise ValueError('The requested entry page is not in the ZIP.')
            if not pages:
                raise ValueError('The ZIP has no HTML entry page.')
    yield source


def save_receipt(path, receipt):
    fd, name = tempfile.mkstemp(prefix='.arcade-receipt-', dir=path.parent)
    try:
        with os.fdopen(fd, 'w') as file:
            json.dump(receipt, file, indent=2)
            file.write('\n')
        os.replace(name, path)
    finally:
        Path(name).unlink(missing_ok=True)


def read_receipt(path, origin):
    if path.is_symlink() or (os.name == 'posix' and path.stat().st_mode & 0o077):
        raise ValueError('The receipt must be a private file. On macOS/Linux, use chmod 600.')
    raw = path.read_text()
    if not raw:
        raise ValueError('The initial registration was interrupted. This empty receipt prevents an accidental duplicate listing.')
    data = json.loads(raw)
    if not isinstance(data, dict) or data.get('origin') != origin:
        raise ValueError('The publishing receipt belongs to another server.')
    for key, pattern in {'game': r'[a-f0-9]{12}', 'version': r'[a-f0-9]{24}', 'key': r'[A-Za-z0-9_-]{20,80}'}.items():
        if not isinstance(data.get(key), str) or not re.fullmatch(pattern, data[key]):
            raise ValueError('The publishing receipt is incomplete.')
    return data


def report(api, version, receipt):
    state = version['status']
    print(f"Experience {receipt['game']} · version {receipt['version']} · {state}")
    if state == 'approved' and version.get('playPath'):
        print('Live: ' + api.origin + version['playPath'])
    elif state == 'needs-entry':
        print('Choose the entry with the entry command: ' + ', '.join(version.get('choices', [])))
    else:
        waiting = version.get('waiting') or {}
        print(waiting.get('reason') or version.get('note') or 'Run the status command to check this submission.')
        for message in waiting.get('blocking', []):
            print('- ' + message)
        if version.get('url'):
            print('Preview: ' + version['url'])
    return 0


def find_version(api, receipt):
    return next((row for row in api.status(receipt)['versions'] if row['id'] == receipt['version']), None)


def run(args, api):
    path = args.receipt
    if args.command in ('status', 'entry'):
        receipt = read_receipt(path, api.origin)
        version = find_version(api, receipt) if args.command == 'status' else api.call(
            'set_entry', {'version': receipt['version'], 'key': receipt['key'], 'entry': args.entry})
        return report(api, version or {'status': 'waiting for upload'}, receipt)
    info = api.request('/api/status')
    if not info.get('publishing') or info['publishing'].get('listingFee') != 0:
        raise ValueError('This Arcade server does not advertise free publishing.')
    limits = info['limits']
    with prepared_build(args.build, path, args.entry, limits['uploadMB'] * 1024**2,
                        limits['unpackedMB'] * 1024**2) as build:
        if args.command == 'launch':
            # Reserve the receipt before any mutation to prevent duplicate drafts. An ambiguous
            # network failure leaves the reservation in place so the agent won't retry blindly.
            descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
            os.close(descriptor)
            fields = {'title': args.title, 'author': args.author, 'email': args.email, 'inputs': args.inputs,
                      'devices': args.devices, 'tagline': args.tagline, 'how': args.how,
                      'rights': args.confirm_rights, 'terms': args.accept_terms}
            try:
                created = api.call('create_experience', fields)
            except Rejected:
                path.unlink()
                raise
            receipt = {key: created[key] for key in ('game', 'version', 'key')}
            receipt['origin'] = api.origin
            save_receipt(path, receipt)
            print(f'Private publishing receipt saved to {path}.', flush=True)
        else:
            receipt = read_receipt(path, api.origin)
            current = find_version(api, receipt)
            if args.command == 'resume' and current:
                return report(api, current, receipt)
            if args.command == 'update':
                if not current:
                    raise ValueError('The current version has not been uploaded. Use resume first.')
                fields = {key: receipt[key] for key in ('game', 'key')}
                if args.change:
                    fields['releaseNotes'] = {'title': 'Game update', 'changes': args.change}
                receipt['version'] = api.call('start_version', fields)['version']
                save_receipt(path, receipt)
        return report(api, api.upload(build, receipt, args.entry, args.threads), receipt)


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--receipt', type=Path, default=Path(RECEIPT), help='Private receipt for one experience.')
    parser.add_argument('--server', default=ORIGIN, help=argparse.SUPPRESS)
    commands = parser.add_subparsers(dest='command', required=True)
    for name in ('launch', 'update', 'resume'):
        cmd = commands.add_parser(name)
        cmd.add_argument('build', type=Path)
        cmd.add_argument('--entry', default='')
        cmd.add_argument('--threads', choices=('yes', 'no'))
        if name == 'launch':
            for field in ('title', 'author', 'email'):
                cmd.add_argument('--' + field, required=True)
            cmd.add_argument('--inputs', nargs='+', choices=INPUTS, required=True)
            cmd.add_argument('--devices', nargs='+', choices=('desktop', 'mobile'), required=True)
            cmd.add_argument('--tagline', default='')
            cmd.add_argument('--how', default='')
            cmd.add_argument('--confirm-rights', action='store_true', required=True)
            cmd.add_argument('--accept-terms', action='store_true', required=True)
        if name == 'update':
            cmd.add_argument('--change', action='append', help='One public release note. May be repeated.')
    commands.add_parser('status')
    commands.add_parser('entry').add_argument('entry')
    args = parser.parse_args()
    server = args.server.rstrip('/')
    parsed = urlsplit(server)
    if server != ORIGIN and (parsed.scheme, parsed.hostname) not in (('http', 'localhost'), ('http', '127.0.0.1')):
        parser.error('Use https://arcade.fun or a local development server.')
    if parsed.username or parsed.password or parsed.path or parsed.query or parsed.fragment:
        parser.error('Choose an Arcade origin without a path or credentials.')
    try:
        return run(args, Arcade(server))
    except FileExistsError:
        print('A receipt already exists. Use status, resume, or update, or choose a different receipt for a different game.', file=sys.stderr)
    except (OSError, URLError, ValueError, Rejected, zipfile.BadZipFile) as error:
        print(f'Publishing stopped: {error}', file=sys.stderr)
        if args.command in ('launch', 'resume', 'update') and args.receipt.exists():
            print('Keep the private receipt. Check status before retrying, then use resume for an unfinished upload.', file=sys.stderr)
    return 1


if __name__ == '__main__':
    sys.exit(main())
