#!/usr/bin/python3
#
# OBS Source Service to vendor all crates.io and dependencies for a
# Rust project locally.
#
# (C) 2019 SUSE LLC
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
# See http://www.gnu.org/licenses/gpl-2.0.html for full license text.
#
# The following code is a derivative work of the code from obs-service-go_modules,
# available at: https://github.com/openSUSE/obs-service-go_modules
"""\
OBS Source Service to vendor all crates.io and dependencies for a
Rust project locally, by calling:

cargo vendor  <path/to/project/vendor>

This requires a decompressed version of you sources. Either you need to
provide this manually, or you can use obs_scm to generate this as part
of the osc services.

obs-service-cargo_vendor will a create vendor tarball, compressed with
the specified method (default to "gz"), containing the
vendor/ directory populated by cargo vendor.

See README.md for additional documentation.
"""

import logging
import argparse
import re
import tarfile
import tempfile
import os
import shutil
import zstandard
import glob

from pathlib import Path
from subprocess import check_output
from subprocess import CalledProcessError
from subprocess import DEVNULL

service_name = "obs-service-cargo_vendor"

description = __doc__

if os.getenv('DEBUG') is not None:
    logging.basicConfig(level=logging.DEBUG)
else:
    logging.basicConfig(level=logging.INFO)

log = logging.getLogger(service_name)


vendor_example = """
Examples of how to modify your spec file to use vendored libraries can be found online:

https://en.opensuse.org/Packaging_Rust_Software#Creating_the_Package

WARNING: To avoid cargo install rebuilding the binary in the install stage
         all environment variables must be the same as in the build stage.
"""


def find_file(path, filename):
    for root, dirs, files in os.walk(path):
        if filename in files:
            log.debug(os.path.join(root, filename))
            return os.path.join(root, filename)


def run_cargo(runDirectory, command, argsList=[]):
    try:
        log.info(f"Running cargo {command} in directory: {runDirectory}")
        output = check_output(["cargo", command] + argsList, cwd=runDirectory, stderr=DEVNULL).decode("utf-8").strip()
        log.debug(output)
        log.info(f"✅ cargo {command} success")
        return output
    except CalledProcessError as e:
        log.info(f"❌ cargo {command} failed")
        error = e.output.decode("utf-8").strip()
        if error:
            log.error(error)
        return None


def cargo_vendor(appDirectory, update, outdir, tag, cargotoml=[], argsList=[]):
    if update:
        log.info("Updating deps before vendor")
        run_cargo(appDirectory, "update")
    log.info(f"Vendoring Cargo.toml deps to {appDirectory}/vendor")
    sync_args = []

    if cargotoml:
        # Manifest Path
        if cargotoml[0]:
            log.info(f"🌟 Custom Manifest Path: {cargotoml[0]}")
            sync_args.append("--manifest-path")
            sync_args.append(cargotoml[0])

        # Additional Cargo.toml to sync and vendor
        if cargotoml[1:]:
            for toml in cargotoml[1:]:
                log.info(f"😘 Adding path '{toml}' to sync options")
                sync_args.append("--sync")
                sync_args.append(toml)

    output = run_cargo(appDirectory, "vendor", argsList + sync_args + ["--locked", "--", "vendor"])
    if output:
        log.info(vendor_example)
        if tag:
            config_file_path = os.path.join(outdir, f"cargo_config_{tag}")
        else:
            config_file_path = os.path.join(outdir, "cargo_config")
        config_file = open(config_file_path, 'w')
        config_file.write(output)
        config_file.close()
    # End cargo_vendor.


def filter_tarinfo(tarinfo):
    tarinfo.uid = 0
    tarinfo.gid = 0
    tarinfo.uname = "root"
    tarinfo.gname = "root"
    return tarinfo


def do_cargo_vendor(srcdir, srctar, outdir, cargotoml, update=True, compression='zst', tag=None):
    if srcdir and srctar:
        log.error("I'm confused 🥺 - can't work with both a srctar and srcdir!")
        return False

    if compression is None:
        compression = "zst"

    with tempfile.TemporaryDirectory() as tmpdirname:
        if srctar:
            globs = glob.glob(srctar)
            log.debug(f" Globbed srctar to {globs}")
            srctar = globs[0]

            log.debug(f" Unpacking {srctar} into {tmpdirname}")
            if srctar.endswith('.tar.zst') or srctar.endswith('.tzst'):
                log.debug(" zst requested.")
                # Since python has changed from "batteries included" to "we jettisoned half the
                # batteries and the rest have no charge" we have to manually do this manual decompression
                with tempfile.NamedTemporaryFile() as decompressed:
                    with open(srctar, 'rb') as compressed:
                        zstd = zstandard.ZstdDecompressor()
                        zstd.copy_stream(compressed, decompressed)
                    decompressed.seek(0)
                    # Setup the tar.
                    with tarfile.open(fileobj=decompressed, mode="r:") as tar:
                        tar.extractall(path=tmpdirname)
            else:
                # Setup the tar.
                with tarfile.open(f"{srctar}", "r:*") as tar:
                    tar.extractall(path=tmpdirname)
            lsrcdir = tmpdirname
        else:
            log.debug(f" Copying {srcdir}")
            (_, dirname) = os.path.split(srcdir)
            dirpath = os.path.join(tmpdirname, dirname)
            log.debug(f" copying sources into {dirpath}")
            # We previously set dirs_exist_ok, probably for a good reason but I think now with the
            # inclusion of the tmpdir, we don't need it. It also blocked operation on python 3.6
            # lsrcdir = shutil.copytree(srcdir, dirpath, dirs_exist_ok=True)
            lsrcdir = shutil.copytree(srcdir, dirpath, symlinks=True)

        log.info(f"Current work dir {os.getcwd()}")
        log.info(f"Searching for Cargo.toml in {lsrcdir}")

        cargo_toml_path = find_file(lsrcdir, "Cargo.toml")
        if cargo_toml_path:
            app_dir = os.path.dirname(cargo_toml_path)
            log.info(f"Detected Rust app directory: {app_dir}")
        else:
            log.error(f"No Rust app using Cargo.toml found under {lsrcdir}")
            return False

        if cargotoml:
            log.info(f"Multiple Cargo.toml paths are added. To be used for sync option 😘")
            temptoml = [os.path.join(app_dir, c) for c in cargotoml]
            log.info(f"Detected Rust app directory: {app_dir}")
            cargo_vendor(appDirectory=app_dir, update=update, outdir=outdir, tag=tag, cargotoml=temptoml)
        else:
            cargo_vendor(appDirectory=app_dir, update=update, outdir=outdir, tag=tag)

        vendor_dir = os.path.join(app_dir, "vendor")
        vendor_lock = os.path.join(app_dir, "Cargo.lock")
        if tag:
            vendor_tarfile = os.path.join(outdir, f"vendor-{tag}.tar.{compression}")
        else:
            vendor_tarfile = os.path.join(outdir, f"vendor.tar.{compression}")
        log.info("Starting compression ...")
        if compression in ['tzst', 'zst', 'zstd']:
            # Manual compress.
            with tempfile.NamedTemporaryFile() as decompressed:
                with tarfile.open(decompressed.name, f"w:") as tar:
                    tar.add(vendor_dir, arcname=("vendor"), filter=filter_tarinfo)
                    if update:
                        tar.add(vendor_lock, arcname=("Cargo.lock"), filter=filter_tarinfo)
                with open(vendor_tarfile, 'wb') as compressed:
                    zstd = zstandard.ZstdCompressor(level=19,write_checksum=True,threads=-1)
                    zstd.copy_stream(decompressed, compressed)
        else:
            with tarfile.open(vendor_tarfile, f"w:{compression}") as tar:
                tar.add(vendor_dir, arcname=("vendor"), filter=filter_tarinfo)
                if update:
                    tar.add(vendor_lock, arcname=("Cargo.lock"), filter=filter_tarinfo)
        # No longer need cleanup since we used a temp location.
    log.info(f"Success")
    return vendor_tarfile


def main():
    log.info(f"Running OBS Source Service: {service_name}")

    parser = argparse.ArgumentParser(
        description=description, formatter_class=argparse.RawDescriptionHelpFormatter
    )
    # Legacy argument, no longer used.
    parser.add_argument("--strategy", default="vendor")

    # Where to find unpacked sources.
    parser.add_argument("--srcdir", default=None)
    # Where to find packed sources.
    parser.add_argument("--srctar", default=None)
    # Where to put the vendor.tar and cargo_config
    parser.add_argument("--outdir", default=None)
    parser.add_argument("--update", default=False)
    parser.add_argument("--tag", default=None)
    parser.add_argument("--compression", default="zst")

    parser.add_argument("--cargotoml", action="append", default=[])

    args = parser.parse_args()

    # Hack - obs always emits an arg to this so we can't use store_true/store_false.
    update = args.update
    if args.update == "false" or update == '0':
        update = False
    else:
        update = True

    log.debug("%s" % args)

    if not do_cargo_vendor(args.srcdir, args.srctar, args.outdir, args.cargotoml, update, args.compression, args.tag):
        exit(1)


if __name__ == "__main__":
    main()

