#!/usr/bin/env python3
"""Download and verify Channelix 1.5.2 (12) source/relink archives.

Python 3 standard library only. No extraction, execution, installation or signing.
The original archive bytes are reassembled from ordered, hashed static chunks.
"""
import argparse
import hashlib
import ipaddress
import json
import os
from pathlib import Path, PurePosixPath
import re
import shutil
import sys
import tempfile
import time
from urllib.error import URLError, HTTPError
from urllib.parse import urljoin, urlsplit
from urllib.request import Request, urlopen

DEFAULT_MANIFEST = "https://channelix.boscolo.io/downloads/1.5.2-12/manifest.json"
DEFAULT_MANIFEST_SHA256 = "4c00de96b064d1b960704d62ec96dfeca88a69492654491b67cb304e035921ef"
MAX_MANIFEST_BYTES = 1024 * 1024
MAX_CHUNK_BYTES = 20 * 1024 * 1024
SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
PATH_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]*$")


def require(condition, message):
    if not condition:
        raise ValueError(message)


def is_remote(location):
    return urlsplit(str(location)).scheme in ("https", "http")


def validate_remote_url(location):
    parsed = urlsplit(location)
    require(not parsed.username and not parsed.password, "Credentials in download URLs are not supported.")
    if parsed.scheme == "https":
        require(bool(parsed.hostname), "Missing HTTPS host.")
    elif parsed.scheme == "http":
        try:
            loopback = ipaddress.ip_address(parsed.hostname or "").is_loopback
        except ValueError:
            loopback = parsed.hostname == "localhost"
        require(loopback, "HTTP is allowed only on loopback for local verification; use HTTPS otherwise.")
    else:
        raise ValueError("Unsupported URL scheme.")


def relative_path(value, description):
    require(isinstance(value, str) and bool(PATH_RE.fullmatch(value)), "Invalid " + description)
    path = PurePosixPath(value)
    require(not path.is_absolute() and all(part not in (".", "..") for part in path.parts), "Unsafe " + description)
    require("//" not in value and not value.endswith("/"), "Unsafe " + description)
    return path


def sha256_file(path):
    digest = hashlib.sha256()
    with path.open("rb") as stream:
        for block in iter(lambda: stream.read(1024 * 1024), b""):
            digest.update(block)
    return digest.hexdigest()


def read_bytes(location, limit):
    if is_remote(location):
        validate_remote_url(location)
        request = Request(str(location), headers={"User-Agent": "Channelix-materials/1.5.2-12", "Accept-Encoding": "identity"})
        stream = urlopen(request, timeout=60)
        validate_remote_url(stream.geturl())
    else:
        require(not urlsplit(str(location)).scheme or (os.name == "nt" and Path(location).is_absolute()), "Use a local path or an HTTPS URL.")
        stream = Path(location).open("rb")
    with stream:
        data = bytearray()
        while True:
            block = stream.read(min(1024 * 1024, limit + 1 - len(data)))
            if not block:
                return data
            data.extend(block)
            require(len(data) <= limit, "Download exceeded its expected size.")


def read_manifest(location, expected_sha):
    data = read_bytes(location, MAX_MANIFEST_BYTES)
    if expected_sha:
        require(bool(SHA256_RE.fullmatch(expected_sha)), "Invalid manifest SHA-256.")
        require(hashlib.sha256(data).hexdigest() == expected_sha, "Manifest SHA-256 mismatch.")
    manifest = json.loads(data)
    require(manifest.get("schema_version") == 1, "Unsupported manifest version.")
    packages = manifest.get("packages")
    require(isinstance(packages, list) and packages, "Manifest has no packages.")
    seen_ids, seen_outputs = set(), set()
    for package in packages:
        require(isinstance(package, dict), "Invalid package.")
        package_id = package.get("id")
        require(package_id in ("source", "relink") and package_id not in seen_ids, "Invalid or duplicate package ID.")
        seen_ids.add(package_id)
        output = str(relative_path(package.get("output"), "package output path"))
        require(output not in seen_outputs, "Duplicate output path.")
        seen_outputs.add(output)
        require(type(package.get("bytes")) is int and package["bytes"] > 0, "Invalid package size.")
        require(isinstance(package.get("sha256"), str) and bool(SHA256_RE.fullmatch(package["sha256"])), "Invalid package SHA-256.")
        chunks = package.get("chunks")
        require(isinstance(chunks, list) and chunks, "Package has no chunks.")
        total = 0
        seen_chunks = set()
        for position, chunk in enumerate(chunks, 1):
            require(isinstance(chunk, dict), "Invalid chunk.")
            require(chunk.get("index") == position, "Chunks are not in the declared order.")
            chunk_path = str(relative_path(chunk.get("path"), "chunk path"))
            require(chunk_path not in seen_chunks, "Duplicate chunk path.")
            seen_chunks.add(chunk_path)
            require(type(chunk.get("bytes")) is int and 0 < chunk["bytes"] <= MAX_CHUNK_BYTES, "Invalid chunk size.")
            require(isinstance(chunk.get("sha256"), str) and bool(SHA256_RE.fullmatch(chunk["sha256"])), "Invalid chunk SHA-256.")
            total += chunk["bytes"]
        require(total == package["bytes"], "Chunk sizes do not add up to the package size.")
    return manifest


def chunk_location(manifest_location, chunk_path):
    relative_path(chunk_path, "chunk path")
    if is_remote(manifest_location):
        location = urljoin(str(manifest_location), chunk_path)
        require(urlsplit(location).netloc == urlsplit(str(manifest_location)).netloc, "A chunk changed download origin.")
        return location
    parent = Path(manifest_location).resolve().parent
    location = (parent / chunk_path).resolve()
    require(location.is_relative_to(parent), "A local chunk escaped its manifest directory.")
    return location


def verified_chunk(location, chunk):
    last_error = None
    for attempt in range(3):
        try:
            data = read_bytes(location, chunk["bytes"])
            require(len(data) == chunk["bytes"], "Chunk size mismatch.")
            require(hashlib.sha256(data).hexdigest() == chunk["sha256"], "Chunk SHA-256 mismatch.")
            return data
        except (OSError, URLError, HTTPError, ValueError) as error:
            last_error = error
            if attempt < 2:
                time.sleep(attempt + 1)
    raise ValueError("Chunk could not be verified: " + str(last_error))


def download_package(package, manifest_location, destination):
    target = destination.joinpath(*relative_path(package["output"], "output path").parts)
    require(target.resolve().is_relative_to(destination), "Output path leaves the selected folder.")
    if target.exists():
        require(target.is_file() and not target.is_symlink(), "Existing output is not a regular file.")
        require(target.stat().st_size == package["bytes"] and sha256_file(target) == package["sha256"], "Existing output differs; it has not been overwritten: " + str(target))
        print("Already verified:", target)
        return
    target.parent.mkdir(parents=True, exist_ok=True)
    require(shutil.disk_usage(target.parent).free >= package["bytes"] + MAX_CHUNK_BYTES, "Insufficient free disk space for " + package["id"])
    temporary = None
    try:
        with tempfile.NamedTemporaryFile(mode="wb", prefix="." + target.name + ".", suffix=".part", dir=target.parent, delete=False) as output:
            temporary = Path(output.name)
            digest = hashlib.sha256()
            written = 0
            for chunk in package["chunks"]:
                data = verified_chunk(chunk_location(manifest_location, chunk["path"]), chunk)
                output.write(data)
                digest.update(data)
                written += len(data)
                print(package["id"] + ": " + str(chunk["index"]) + "/" + str(len(package["chunks"])) + " chunks verified", flush=True)
            require(written == package["bytes"] and digest.hexdigest() == package["sha256"], "Reassembled archive SHA-256 mismatch.")
            output.flush()
            os.fsync(output.fileno())
        require(not target.exists(), "Output appeared during download; it has not been overwritten.")
        os.replace(temporary, target)
        temporary = None
        print("Verified archive:", target)
        print("SHA-256:", package["sha256"])
    finally:
        if temporary is not None:
            try:
                temporary.unlink()
            except FileNotFoundError:
                pass


def main(argv=None):
    parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    parser.add_argument("--manifest", default=DEFAULT_MANIFEST, help="Manifest HTTPS URL or local file path.")
    parser.add_argument("--manifest-sha256", help="Expected manifest SHA-256 (the default manifest is pinned automatically).")
    parser.add_argument("--package", choices=("all", "source", "relink"), default="all")
    parser.add_argument("--output-dir", default="Channelix-1.5.2-12", help="Folder in which to save verified archives.")
    args = parser.parse_args(argv)
    expected = args.manifest_sha256 or (DEFAULT_MANIFEST_SHA256 if args.manifest == DEFAULT_MANIFEST else None)
    manifest = read_manifest(args.manifest, expected)
    packages = [package for package in manifest["packages"] if args.package == "all" or package["id"] == args.package]
    require(bool(packages), "Requested package is absent from the manifest.")
    destination = Path(args.output_dir).expanduser().resolve()
    destination.mkdir(parents=True, exist_ok=True)
    for package in packages:
        download_package(package, args.manifest, destination)
    print("Complete. Archives were not extracted or executed.")


if __name__ == "__main__":
    try:
        main()
    except (ValueError, OSError, URLError, json.JSONDecodeError) as error:
        print("Error:", error, file=sys.stderr)
        sys.exit(1)
