#!/usr/bin/env python3
"""Upload an IPA through Apple's build-upload REST API."""
from __future__ import annotations

import argparse
from concurrent.futures import ThreadPoolExecutor
import hashlib
import json
import os
from pathlib import Path
import plistlib
import time
from urllib.parse import urlsplit
import zipfile

import asc_common
import requests
import upload_checksums
import upload_receipt
from upload_receipt import Receipt, UploadError

# Provider refusals the uploader classifies itself instead of letting the shared
# client exit with the response body in its message.
CLASSIFIED_STATUSES = {400, 401, 403, 404, 409, 412, 422}


def api(method: str, path: str, body=None, params=None) -> dict:
    token = asc_common.make_jwt(os.environ["ASC_KEY_ID"], os.environ["ASC_ISSUER_ID"],
                                os.environ["ASC_KEY_PATH"])
    # An uncertain create is discovered from the provider on the next invocation,
    # never repeated automatically under a second upload identity.
    response = asc_common.request(method, path, token, json_body=body, params=params,
                                  allow_status=CLASSIFIED_STATUSES, max_attempts=3 if method == "GET" else 1)
    if response.status_code >= 400:
        try:
            error = (response.json().get("errors") or [{}])[0]
        except ValueError:
            error = {}
        raise UploadError("provider_request_failed", f"Apple refused {method} {path}",
                          provider_status=response.status_code, provider_code=str(error.get("code") or ""),
                          provider_pointer=str((error.get("source") or {}).get("pointer") or ""))
    return response.json()


def identity(ipa: Path) -> dict:
    try:
        with zipfile.ZipFile(ipa) as archive:
            names = [name for name in archive.namelist()
                     if name.startswith("Payload/") and name.endswith(".app/Info.plist") and name.count("/") == 2]
            if len(names) != 1:
                raise UploadError("ipa_invalid", "IPA must contain one top-level app Info.plist")
            info = plistlib.loads(archive.read(names[0]))
    except (zipfile.BadZipFile, plistlib.InvalidFileException, KeyError) as exc:
        raise UploadError("ipa_invalid", "IPA is not a readable app archive") from exc
    with ipa.open("rb") as stream:
        digest = hashlib.file_digest(stream, "sha256").hexdigest()
    return {"bundle_id": str(info["CFBundleIdentifier"]),
            "cfBundleVersion": str(info["CFBundleVersion"]),
            "cfBundleShortVersionString": str(info["CFBundleShortVersionString"]),
            "sha256": digest, "fileName": digest + ".ipa", "fileSize": ipa.stat().st_size}


def upload_record(app_id: str, info: dict) -> dict:
    params = {"filter[cfBundleVersion]": info["cfBundleVersion"],
              "filter[cfBundleShortVersionString]": info["cfBundleShortVersionString"],
              "filter[platform]": "IOS", "limit": 200}
    rows = api("GET", f"/apps/{app_id}/buildUploads", params=params).get("data") or []
    rows = [row for row in rows if ((row.get("attributes") or {}).get("state") or {}).get("state") != "FAILED"]
    if len(rows) > 1:
        raise UploadError("upload_identity_conflict",
                          "multiple matching Apple build uploads; resolve their provider state before retrying")
    if rows:
        return rows[0]
    return api("POST", "/buildUploads", {"data": {
        "type": "buildUploads", "attributes": {"platform": "IOS", **{
            key: info[key] for key in ("cfBundleVersion", "cfBundleShortVersionString")}},
        "relationships": {"app": {"data": {"type": "apps", "id": app_id}}},
    }})["data"]


def upload_file(record: dict, info: dict) -> dict:
    rows = api("GET", f"/buildUploads/{record['id']}/buildUploadFiles").get("data") or []
    assets = [row for row in rows if (row.get("attributes") or {}).get("assetType") == "ASSET"]
    if assets:
        if len(assets) != 1 or any(assets[0]["attributes"].get(key) != info[key]
                                  for key in ("fileName", "fileSize")):
            raise UploadError("upload_identity_conflict",
                              "existing Apple build upload belongs to different IPA bytes; do not overwrite it",
                              upload_id=record["id"], file_id=str(assets[0].get("id") or ""))
        return assets[0]
    return api("POST", "/buildUploadFiles", {"data": {
        "type": "buildUploadFiles", "attributes": {
            "assetType": "ASSET", "uti": "com.apple.ipa", "fileName": info["fileName"], "fileSize": info["fileSize"]},
        "relationships": {"buildUpload": {"data": {"type": "buildUploads", "id": record["id"]}}},
    }})["data"]


def transfer(ipa: Path, operation: dict) -> None:
    parsed = urlsplit(operation["url"])
    hosts = (".apple.com", ".icloud.com", ".amazonaws.com")
    if parsed.scheme != "https" or not any((parsed.hostname or "").endswith(host) for host in hosts):
        raise UploadError("upload_destination_invalid", "Apple returned an unsupported upload destination")
    if operation.get("method") != "PUT":
        raise UploadError("upload_destination_invalid", "Apple returned an unsupported upload method")
    with ipa.open("rb") as stream:
        stream.seek(operation["offset"])
        data = stream.read(operation["length"])
    if len(data) != operation["length"]:
        raise UploadError("ipa_changed", "IPA changed during upload")
    headers = {row["name"]: row["value"] for row in operation.get("requestHeaders") or []}
    response = _put(operation["url"], headers=headers, data=data)
    if not 200 <= response.status_code < 300:
        raise UploadError("part_refused", "Apple upload part refused; read upload state before retrying",
                          provider_status=response.status_code)


def _put(url: str, **kwargs) -> requests.Response:
    """Apple's signed upload URLs carry credentials, so a transport failure is
    reported without the exception text that would embed one."""
    try:
        return requests.request("PUT", url, timeout=(20, 300), allow_redirects=False, **kwargs)
    except requests.RequestException:
        raise RuntimeError("Apple upload part transfer failed") from None


def transfer_all(ipa: Path, file: dict, info: dict) -> None:
    attributes = file.get("attributes") or {}
    state = (attributes.get("assetDeliveryState") or {}).get("state")
    if state == "COMPLETE":
        upload_checksums.completed(ipa, attributes, info)
        return
    operations = sorted(attributes.get("uploadOperations") or [], key=lambda row: row["offset"])
    offset = 0
    for operation in operations:
        if operation.get("offset") != offset or operation.get("length", 0) <= 0:
            raise UploadError("upload_destination_invalid", "Apple upload operations do not cover the IPA exactly")
        offset += operation["length"]
    if offset != info["fileSize"]:
        raise UploadError("upload_destination_invalid", "Apple upload operations do not cover the IPA exactly")
    with ThreadPoolExecutor(max_workers=3) as executor:
        results = [executor.submit(transfer, ipa, operation) for operation in operations]
        for result in results:
            result.result()
    api("PATCH", f"/buildUploadFiles/{file['id']}", {"data": {
        # Apple's build-upload contract commits with uploaded=true. Its generic
        # checksum enum includes SHA_256, but IPA finalization rejected that
        # optional attribute with ENTITY_ERROR.ATTRIBUTE.INVALID in production.
        "type": "buildUploadFiles", "id": file["id"], "attributes": {"uploaded": True},
    }})


def deliver(ipa: Path, report: Path, timeout: int) -> dict:
    receipt = Receipt(report)
    receipt.update("identity", **identity(ipa))
    app_id = os.environ.get("APP_STORE_APPLE_ID") or ""
    if not app_id:
        raise UploadError("configuration_missing", "APP_STORE_APPLE_ID is empty")
    app = api("GET", f"/apps/{app_id}")["data"]
    if (app.get("attributes") or {}).get("bundleId") != receipt.data["bundle_id"]:
        raise UploadError("app_mismatch", "IPA bundle identity does not match the selected App Store app")
    info = receipt.update("app_verified", app_id=app_id)
    record = upload_record(app_id, info)
    receipt.update("upload_reserved", upload_id=record["id"])
    file = upload_file(record, info)
    receipt.update("file_reserved", file_id=file["id"])
    transfer_all(ipa, file, info)
    receipt.update("transferred")
    deadline = time.monotonic() + timeout
    while True:
        current = api("GET", f"/buildUploads/{record['id']}", params={"include": "build"})["data"]
        state = ((current.get("attributes") or {}).get("state") or {}).get("state")
        build_id = (((current.get("relationships") or {}).get("build") or {}).get("data") or {}).get("id")
        if state == "COMPLETE":
            return receipt.update("complete", state=state, build_id=build_id)
        receipt.update("processing", state=state, build_id=build_id)
        if state == "FAILED":
            raise UploadError("processing_failed",
                              "Apple build upload failed processing; inspect the upload ID's provider errors")
        if time.monotonic() >= deadline:
            raise UploadError("processing_pending",
                              "Apple build processing is pending; reuse this upload ID on continuation")
        time.sleep(10)


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("ipa", type=Path)
    parser.add_argument("--report", required=True, type=Path)
    parser.add_argument("--timeout", type=int, default=900)
    args = parser.parse_args()
    try:
        receipt = deliver(args.ipa, args.report, args.timeout)
    except (OSError, ValueError, KeyError, RuntimeError, SystemExit) as exc:
        # The receipt and annotation carry the classified category and identifiers only;
        # exception text can quote provider bodies or transport details and stays out.
        failure = upload_receipt.record_failure(args.report, exc)
        print("::error title=apple_build_upload_failed::" + json.dumps(failure))
        raise SystemExit(f"Apple REST upload did not complete ({failure['category']} at stage "
                         f"{failure['stage']}); retain its receipt and IPA and resolve the provider state") from None
    print(json.dumps(receipt))


if __name__ == "__main__":
    main()
