#!/usr/bin/env python3

# Copyright 2022, TUF contributors
# SPDX-License-Identifier: MIT OR Apache-2.0

"""verify_release - verify that published release matches a locally built one

Builds a release from current commit and verifies that the release artifacts
on GitHub and PyPI match the built release artifacts.
"""

from __future__ import annotations

import argparse
import os
import subprocess
import sys
from filecmp import cmp
from tempfile import TemporaryDirectory

try:
    import build as _  # type: ignore[import-not-found] # noqa: F401
    from urllib3 import request
except ImportError:
    print("Error: verify_release requires modules 'urllib3' and 'build':")
    print("    pip install urllib3 build")
    sys.exit(1)

# Project variables
# Note that only these project artifacts are supported:
# [f"{PYPI_PROJECT}-{VER}-none-any.whl",  f"{PYPI_PROJECT}-{VER}.tar.gz"]
GITHUB_ORG = "theupdateframework"
GITHUB_PROJECT = "python-tuf"
PYPI_PROJECT = "tuf"

HTTP_TIMEOUT = 5


def build(build_dir: str) -> str:
    """Build release locally. Return version as string"""
    orig_dir = os.path.dirname(os.path.abspath(__file__))

    with TemporaryDirectory() as src_dir:
        # fresh git clone: this prevents uncommitted files from affecting build
        git_cmd = ["git", "clone", "--quiet", orig_dir, src_dir]
        subprocess.run(git_cmd, stdout=subprocess.DEVNULL, check=True)

        # patch env to constrain build backend version as we do in cd.yml
        env = os.environ.copy()
        env["PIP_CONSTRAINT"] = "requirements/build.txt"

        build_cmd = ["python3", "-m", "build", "--outdir", build_dir, src_dir]
        subprocess.run(
            build_cmd, stdout=subprocess.DEVNULL, check=True, env=env
        )

    for filename in os.listdir(build_dir):
        prefix, postfix = f"{PYPI_PROJECT}-", ".tar.gz"
        if filename.startswith(prefix) and filename.endswith(postfix):
            return filename[len(prefix) : -len(postfix)]

    raise RuntimeError("Build version not found")


def get_git_version() -> str:
    """Return version string from git describe"""
    cmd = ["git", "describe"]
    process = subprocess.run(cmd, text=True, capture_output=True, check=True)
    if not process.stdout.startswith("v") or not process.stdout.endswith("\n"):
        raise RuntimeError(f"Unexpected git version {process.stdout}")

    return process.stdout[1:-1]


def get_github_version() -> str:
    """Return version string of latest GitHub release"""
    release_json = f"https://api.github.com/repos/{GITHUB_ORG}/{GITHUB_PROJECT}/releases/latest"
    releases = request("GET", release_json, timeout=HTTP_TIMEOUT).json()
    return releases["tag_name"][1:]


def get_pypi_pip_version() -> str:
    """Return latest version string available on PyPI according to pip"""
    # pip can't tell us what the newest available version is... So we download
    # newest tarball and figure out the version from the filename
    with TemporaryDirectory() as pypi_dir:
        cmd = ["pip", "download", "--no-deps", "--dest", pypi_dir]
        source_download = [*cmd, "--no-binary", PYPI_PROJECT, PYPI_PROJECT]
        subprocess.run(source_download, stdout=subprocess.DEVNULL, check=True)
        for filename in os.listdir(pypi_dir):
            prefix, postfix = f"{PYPI_PROJECT}-", ".tar.gz"
            if filename.startswith(prefix) and filename.endswith(postfix):
                return filename[len(prefix) : -len(postfix)]
        raise RuntimeError("PyPI version not found")


def verify_github_release(version: str, compare_dir: str) -> bool:
    """Verify that given GitHub version artifacts match expected artifacts"""
    base_url = (
        f"https://github.com/{GITHUB_ORG}/{GITHUB_PROJECT}/releases/download"
    )
    tar = f"{PYPI_PROJECT}-{version}.tar.gz"
    wheel = f"{PYPI_PROJECT}-{version}-py3-none-any.whl"
    with TemporaryDirectory() as github_dir:
        for filename in [tar, wheel]:
            url = f"{base_url}/v{version}/{filename}"
            response = request(
                "GET", url, preload_content=False, timeout=HTTP_TIMEOUT
            )
            with open(os.path.join(github_dir, filename), "wb") as f:
                for data in response.stream():  #  noqa: FURB122
                    f.write(data)

        return cmp(
            os.path.join(github_dir, tar),
            os.path.join(compare_dir, tar),
            shallow=False,
        ) and cmp(
            os.path.join(github_dir, wheel),
            os.path.join(compare_dir, wheel),
            shallow=False,
        )


def verify_pypi_release(version: str, compare_dir: str) -> bool:
    """Verify that given PyPI version artifacts match expected artifacts"""
    tar = f"{PYPI_PROJECT}-{version}.tar.gz"
    wheel = f"{PYPI_PROJECT}-{version}-py3-none-any.whl"

    with TemporaryDirectory() as pypi_dir:
        cmd = ["pip", "download", "--no-deps", "--dest", pypi_dir]
        target = f"{PYPI_PROJECT}=={version}"
        binary_download = [*cmd, target]
        source_download = [*cmd, "--no-binary", PYPI_PROJECT, target]

        subprocess.run(binary_download, stdout=subprocess.DEVNULL, check=True)
        subprocess.run(source_download, stdout=subprocess.DEVNULL, check=True)

        return cmp(
            os.path.join(pypi_dir, wheel),
            os.path.join(compare_dir, wheel),
            shallow=False,
        ) and cmp(
            os.path.join(pypi_dir, tar),
            os.path.join(compare_dir, tar),
            shallow=False,
        )


def sign_release_artifacts(
    version: str, build_dir: str, key_id: str | None = None
) -> None:
    """Sign built release artifacts with gpg and write signature files to cwd"""
    sdist = f"{PYPI_PROJECT}-{version}.tar.gz"
    wheel = f"{PYPI_PROJECT}-{version}-py3-none-any.whl"
    cmd = ["gpg", "--detach-sign", "--armor"]

    if key_id is not None:
        cmd += ["--local-user", key_id]

    for filename in [sdist, wheel]:
        artifact_path = os.path.join(build_dir, filename)
        signature_path = f"{filename}.asc"
        subprocess.run(
            [*cmd, "--output", signature_path, artifact_path], check=True
        )

        if not os.path.exists(signature_path):
            raise RuntimeError("Signing failed, signature not found")


def finished(s: str) -> None:
    """Displays a finished message."""
    # clear line
    sys.stdout.write("\033[K")
    print(f"* {s}")


def progress(s: str) -> None:
    """Displays a progress message."""
    # clear line
    sys.stdout.write("\033[K")
    # carriage return but no newline: next print will overwrite this one
    print(f"  {s}...", end="\r", flush=True)


def main() -> int:  # noqa: D103
    parser = argparse.ArgumentParser()
    parser.add_argument(
        "--skip-pypi",
        action="store_true",
        dest="skip_pypi",
        help="Skip PyPI release check.",
    )
    parser.add_argument(
        "--sign",
        nargs="?",
        const=True,
        metavar="<key id>",
        dest="sign",
        help="Sign release artifacts with 'gpg'. If no <key id> is passed,"
        " the default signing key is used. Resulting '*.asc' files are written"
        " to CWD.",
    )
    args = parser.parse_args()

    success = True
    with TemporaryDirectory() as build_dir:
        progress("Building release")
        build_version = build(build_dir)
        finished(f"Built release {build_version}")

        git_version = get_git_version()
        if not git_version.startswith(build_version):
            raise RuntimeError(
                f"Git version is {git_version}, expected {build_version}"
            )
        if git_version != build_version:
            finished(f"WARNING: Git describes version as {git_version}")

        progress("Checking GitHub latest version")
        github_version = get_github_version()
        if github_version != build_version:
            finished(f"WARNING: GitHub latest version is {github_version}")

        if not args.skip_pypi:
            progress("Checking PyPI latest version")
            pypi_version = get_pypi_pip_version()
            if pypi_version != build_version:
                finished(f"WARNING: PyPI latest version is {pypi_version}")

            progress("Downloading release from PyPI")
            if not verify_pypi_release(build_version, build_dir):
                # This is expected while build is not reproducible
                finished("ERROR: PyPI artifacts do not match built release")
                success = False
            else:
                finished("PyPI artifacts match the built release")

        progress("Downloading release from GitHub")
        if not verify_github_release(build_version, build_dir):
            # This is expected while build is not reproducible
            finished("ERROR: GitHub artifacts do not match built release")
            success = False
        else:
            finished("GitHub artifacts match the built release")

        # NOTE: 'gpg' might prompt for password or ask if it should
        # override files...
        if args.sign:
            progress("Signing built release with gpg")
            if success:
                key_id = args.sign if args.sign is not True else None

                sign_release_artifacts(build_version, build_dir, key_id)
                finished("Created signatures in cwd (see '*.asc' files)")
            else:
                finished("WARNING: Skipped signing of non-matching artifacts")

    return 0 if success else 1


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