#!/usr/bin/env python3
"""Fileroy multipart uploader. Python 3.9+, standard library only.

Usage: python fileroy-upload.py video.mp4
Set FILEROY_API_KEY for account uploads; omit it for visitor uploads.
Only send API keys to https://fileroy.com/api/v1, never to storage URLs.
"""
import argparse
import concurrent.futures
import json
import os
from pathlib import Path
import sys
import time
import urllib.error
import urllib.parse
import urllib.request

API = 'https://fileroy.com/api/v1'


class UploadError(Exception):
    pass


class NoRedirect(urllib.request.HTTPRedirectHandler):
    # Do not forward an API key to a redirected destination.
    def redirect_request(self, req, fp, code, msg, headers, newurl):
        return None


class Uploader:
    def __init__(self, key=None):
        self.key = key
        self.guest_token = None
        self.upload_id = None
        self.complete = False
        self.opener = urllib.request.build_opener(NoRedirect())

    def control(self, method, path, data=None, retry=True):
        body = json.dumps(data).encode() if data is not None else None
        headers = {'Accept': 'application/json', 'Content-Type': 'application/json',
                   'User-Agent': 'Fileroy-Python-Uploader/1.0'}
        if self.key:
            headers['Authorization'] = 'Bearer ' + self.key
        elif self.guest_token:
            headers['X-Upload-Token'] = self.guest_token
        for attempt in range(4):
            try:
                req = urllib.request.Request(API + path, body, headers, method=method)
                with self.opener.open(req, timeout=90) as response:
                    return json.load(response)
            except urllib.error.HTTPError as error:
                if retry and error.code in (429, 500, 502, 503, 504) and attempt < 3:
                    try:
                        pause = min(120, max(2 ** attempt, int(error.headers.get('Retry-After', 0))))
                    except ValueError:
                        pause = 2 ** attempt
                    time.sleep(pause)
                    continue
                try:
                    payload = json.loads(error.read(32768))
                    details = '; '.join(str(item) for values in payload.get('errors', {}).values() for item in values)
                    message = payload.get('message', 'Request rejected') + (': ' + details if details else '')
                except (ValueError, AttributeError, TypeError):
                    message = 'API request rejected'
                raise UploadError(f'{message} (HTTP {error.code})') from None
            except (urllib.error.URLError, TimeoutError, OSError):
                if retry and attempt < 3:
                    time.sleep(2 ** attempt)
                    continue
                raise UploadError('API network request failed. Check your connection and try again.') from None
        raise UploadError('API request could not be completed.')

    def put_part(self, path, part_size, signed):
        part = signed['part']
        offset = (part - 1) * part_size
        size = signed['size']
        with path.open('rb') as file:
            file.seek(offset)
            chunk = file.read(size)
        if len(chunk) != size:
            raise UploadError('The file changed during upload. Start again with an unchanged file.')
        for attempt in range(4):
            url = signed['url']
            parsed = urllib.parse.urlsplit(url)
            if parsed.scheme != 'https' or not parsed.hostname or not parsed.hostname.endswith('.r2.cloudflarestorage.com'):
                raise UploadError('The API returned an unexpected storage destination.')
            req = urllib.request.Request(url, chunk, {'Content-Length': str(size)}, method='PUT')
            try:
                with self.opener.open(req, timeout=300) as response:
                    if response.status < 200 or response.status >= 300:
                        raise UploadError('The storage provider rejected an upload part.')
                return size
            except urllib.error.HTTPError as error:
                if error.code not in (403, 408, 429, 500, 502, 503, 504) or attempt == 3:
                    raise UploadError(f'Upload part {part} failed (HTTP {error.code}).') from None
            except (urllib.error.URLError, TimeoutError, OSError):
                if attempt == 3:
                    raise UploadError(f'Network failure while uploading part {part}.') from None
            time.sleep(2 ** attempt)
            signed = self.control('POST', f'/uploads/{self.upload_id}/parts', {'parts': [part]})['urls'][0]
        raise UploadError('Part could not be uploaded.')

    def upload(self, path, name=None, private=False, folder=None):
        if not self.key and (private or folder is not None):
            raise UploadError('An account API key is required for private uploads or account folders.')
        total = path.stat().st_size
        limits = self.control('GET', '/limits')
        if not limits['upload_enabled']:
            raise UploadError('Uploads are currently unavailable for this plan.')
        maximum = limits['max_file_size']
        if maximum and total > maximum:
            raise UploadError('File exceeds the current plan maximum: ' + limits['max_file_size_formatted'])
        if not total:
            raise UploadError('Empty files cannot be uploaded.')
        data = {'original_name': path.name, 'name': name or path.stem, 'size': total, 'visibility': not private}
        if folder is not None:
            data['folder'] = folder
        # Do not retry starts automatically: a lost response could create duplicate reservations.
        started = self.control('POST', '/uploads', data, retry=False)
        self.upload_id = started['id']
        self.guest_token = started.get('upload_token')
        uploaded = 0
        try:
            with concurrent.futures.ThreadPoolExecutor(max_workers=3) as workers:
                for first in range(1, started['parts'] + 1, 12):
                    numbers = list(range(first, min(first + 12, started['parts'] + 1)))
                    urls = self.control('POST', f'/uploads/{self.upload_id}/parts', {'parts': numbers})['urls']
                    futures = [workers.submit(self.put_part, path, started['part_size'], signed) for signed in urls]
                    for future in concurrent.futures.as_completed(futures):
                        uploaded += future.result()
                        print(f'\rUploaded {uploaded:,} / {total:,} bytes ({uploaded / total:.1%})', end='', file=sys.stderr, flush=True)
            print('\nVerifying stored file…', file=sys.stderr)
            result = self.control('POST', f'/uploads/{self.upload_id}/complete', {})
            self.complete = True
            return result
        finally:
            if not self.complete:
                try:
                    self.control('DELETE', f'/uploads/{self.upload_id}', {}, retry=False)
                    print('\nUnfinished upload cancelled.', file=sys.stderr)
                except (UploadError, KeyboardInterrupt):
                    print('\nCancellation could not be confirmed. The unfinished upload will expire automatically.', file=sys.stderr)


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('file', type=Path)
    parser.add_argument('--name', help='Display name (defaults to the filename without its extension)')
    parser.add_argument('--private', action='store_true', help='Private visibility for account uploads')
    parser.add_argument('--folder', type=int, help='An existing folder ID in your account')
    args = parser.parse_args()
    if not args.file.is_file():
        parser.error('File does not exist or is not a regular file.')
    if sys.version_info < (3, 9):
        parser.error('Python 3.9 or later is required.')
    try:
        result = Uploader(os.environ.get('FILEROY_API_KEY')).upload(args.file, args.name, args.private, args.folder)
        print(json.dumps(result, indent=2))
        return 0
    except KeyboardInterrupt:
        print('\nUpload interrupted.', file=sys.stderr)
        return 130
    except (UploadError, OSError) as error:
        print('Upload failed: ' + str(error), file=sys.stderr)
        return 1


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