#!/usr/bin/env python3
"""Upload or resume a file using Filecast API v1. Requires Python 3.9+."""
import argparse
import hashlib
import json
import os
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path


class NoRedirect(urllib.request.HTTPRedirectHandler):
    def redirect_request(self, req, fp, code, msg, headers, newurl):
        raise RuntimeError("Unexpected redirect; credentials were not forwarded.")


opener = urllib.request.build_opener(NoRedirect)
BASE = "https://fileca.st/api/v1"


def request(url, token, data=None, method="GET", binary=False):
    headers = {"Authorization": "Bearer " + token}
    if data is not None:
        headers["Content-Type"] = "application/octet-stream" if binary else "application/json"
        if not binary:
            data = json.dumps(data).encode()
    req = urllib.request.Request(url, data=data, headers=headers, method=method)
    try:
        with opener.open(req, timeout=600) as response:
            return json.load(response)
    except urllib.error.HTTPError as error:
        message = error.read(8192).decode(errors="replace")
        raise RuntimeError(f"HTTP {error.code}: {message}") from None


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("file", type=Path)
    parser.add_argument("--hosts", required=True, help="Comma-separated host IDs")
    parser.add_argument("--resume", help="An existing upload ID")
    args = parser.parse_args()
    token = os.environ.get("FILECAST_TOKEN", "")
    if not token:
        raise RuntimeError("Set FILECAST_TOKEN to a Filecast upload API token.")
    path = args.file
    size = path.stat().st_size
    if not path.is_file() or not 0 < size <= 1073741824:
        raise RuntimeError("Choose a non-empty file up to 1 GiB.")
    hosts = list(dict.fromkeys(h.strip() for h in args.hosts.split(",") if h.strip()))
    catalog = request(BASE + "/hosts", token)["hosts"]
    if not hosts or any(not any(h["id"] == host and h["enabled"] for h in catalog) for host in hosts):
        raise RuntimeError("Choose enabled host IDs from GET /hosts.")
    if args.resume:
        upload_id = args.resume
        status = request(BASE + "/uploads/" + urllib.parse.quote(upload_id, safe=""), token)
        if status["name"] != path.name or status["size"] != size:
            raise RuntimeError("The local file does not match this upload's name and size.")
        if status["state"] == "ready":
            with path.open("rb") as source:
                hash_value = hashlib.sha256()
                for block in iter(lambda: source.read(8388608), b""):
                    hash_value.update(block)
            if hash_value.hexdigest() != status["sha256"]:
                raise RuntimeError("The local checksum differs from the completed upload.")
            session = None
        else:
            session = request(BASE + "/uploads/" + upload_id + "/parts", token)
    else:
        session = request(BASE + "/uploads", token, {"name": path.name, "size": size}, "POST")
        upload_id = session["id"]
    print("Upload ID:", upload_id, flush=True)
    print("Keep this ID to resume after an interruption.", flush=True)
    if session:
        destination = urllib.parse.urlparse(session["uploadUrl"])
        if destination.scheme != "https" or destination.netloc not in ("fileca.st", "upload.fileca.st"):
            raise RuntimeError("Unexpected upload origin.")
        existing = {p["partNumber"]: p["etag"] for p in session.get("parts", [])}
        receipts = []
        with path.open("rb") as source:
            number = 0
            while True:
                chunk = source.read(session["chunkSize"])
                if not chunk:
                    break
                number += 1
                etag = hashlib.sha256(chunk).hexdigest()
                if number in existing and existing[number] != etag:
                    raise RuntimeError("A received chunk differs from the local file. Start a new upload.")
                if number not in existing:
                    for attempt in range(3):
                        try:
                            receipt = request(session["uploadUrl"] + "?part=" + str(number), session["token"], chunk, "PUT", True)
                            if receipt != {"partNumber": number, "etag": etag}:
                                raise RuntimeError("Upload receipt failed checksum verification.")
                            break
                        except (RuntimeError, OSError):
                            if attempt == 2:
                                raise
                            time.sleep(2 ** attempt)
                receipts.append({"partNumber": number, "etag": etag})
                print(f"Staged {min(number * session['chunkSize'], size):,} / {size:,} bytes", flush=True)
        request(BASE + "/uploads/" + upload_id + "/complete", token, {"parts": receipts}, "POST")
    for host in hosts:
        result = request(BASE + "/uploads/" + upload_id + "/transfers", token, {"host": host}, "POST")
        print(host + ": job " + result["jobId"])
    print(request(BASE + "/uploads/" + upload_id, token)["shareUrl"])


if __name__ == "__main__":
    try:
        main()
    except (RuntimeError, OSError, ValueError, KeyboardInterrupt) as error:
        print("Upload stopped: " + str(error), file=sys.stderr)
        print("The session was kept. Use --resume with its upload ID.", file=sys.stderr)
        sys.exit(1)
