#!/usr/bin/env python3
#
# obs-service-cpio_repack
#
# Reads Name and Version from a spec file using rpmspec (honouring all RPM
# macros including %include), then produces a conventionally named
# <name>-<version>.tar.<compression> source tarball from the source tree
# that obs_scm has already extracted into the working directory.
#
# Source directory discovery uses the .obsinfo file written by obs_scm —
# the same mechanism used by obs-service-tar_scm's 'tar' service. The
# .obsinfo file is always present in both the OBS server-side package
# directory and the buildtime build root.
#
# All files required by %include directives in the spec must already be
# present in the working directory. This script makes no attempt to locate
# or fetch them.
#
# (C) 2025 SUSE LLC
# SPDX-License-Identifier: GPL-2.0-or-later

from __future__ import annotations

import argparse
import glob
import os
import subprocess
import sys
import tarfile
import tempfile


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def die(msg: str) -> None:
    print(f"ERROR: {msg}", file=sys.stderr)
    sys.exit(1)


def resolve_glob(pattern: str, label: str) -> str:
    """Expand *pattern* and return exactly one match, or die."""
    matches = glob.glob(pattern) or glob.glob(f"_service:*:{pattern}")
    if len(matches) == 0:
        die(f"no file matched {label} glob '{pattern}'")
    if len(matches) > 1:
        die(f"ambiguous {label} glob '{pattern}' matched: {' '.join(matches)}")
    return matches[0]


def read_obsinfo(obsinfo_path: str, key: str) -> str:
    """Read a single key from a .obsinfo file. Returns '' if key absent."""
    with open(obsinfo_path) as fh:
        for line in fh:
            k, _, v = line.partition(":")
            if k.strip() == key:
                return v.strip()
    return ""


def query_spec(spec_path: str) -> tuple[str, str]:
    """
    Return (name, version) by running rpmspec against *spec_path*.

    _sourcedir is forced to cwd so that %include paths resolve against the
    flat working directory layout that extract_file produces.
    """
    cmd = [
        "rpmspec",
        "--define", f"_sourcedir {os.getcwd()}",
        "-q",
        "--queryformat", "%{NAME} %{VERSION}\n",
        spec_path,
    ]
    try:
        result = subprocess.run(
            cmd,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            check=True,
            text=True,
        )
    except FileNotFoundError:
        die("rpmspec not found — install the 'rpm-build' package")
    except subprocess.CalledProcessError as exc:
        die(
            f"rpmspec failed (exit {exc.returncode}):\n"
            f"{exc.stderr.strip()}"
        )

    # rpmspec may emit one line per subpackage; all share the same Name/Version
    # so the first non-empty line is sufficient.
    for line in result.stdout.splitlines():
        line = line.strip()
        if not line:
            continue
        parts = line.split()
        if len(parts) >= 2:
            return parts[0], parts[1]

    die(f"rpmspec produced no usable NAME/VERSION output from '{spec_path}'")


# Compression format → tarfile open mode for gz/bz2/xz.
# zst is intentionally absent: tarfile has no zst support; it is handled
# separately in create_tarball() via the system zstd binary.
_TARFILE_COMPRESSION_MAP: dict[str, str] = {
    "gz":  "w:gz",
    "bz2": "w:bz2",
    "xz":  "w:xz",
}


def create_tarball(srcdir: str, toplevel: str, outpath: str,
                   compression: str) -> None:
    """
    Pack *srcdir*/*toplevel* into *outpath*.

    gz/bz2/xz: Python's tarfile module.
    zst:        tar | zstd pipeline via subprocess — tarfile has no zst support.
                Mirrors the pipeline used by obs-service-recompress.
    """
    if compression == "zst":
        # Pipe uncompressed tar output through zstd.
        # tar stderr is redirected to DEVNULL to avoid a deadlock: with
        # stdout consumed by zstd and stderr blocked on a full pipe buffer,
        # tar_proc.wait() would never return.
        tar_cmd = [
            "tar", "--create", "--file=-",
            f"--directory={srcdir}", toplevel,
        ]
        zst_cmd = [
            "zstd", "--rsyncable", "-15", "--threads=0", "-c", "-",
        ]
        try:
            tar_proc = subprocess.Popen(
                tar_cmd,
                stdout=subprocess.PIPE,
                stderr=subprocess.DEVNULL,
            )
            with open(outpath, "wb") as out_fh:
                subprocess.run(
                    zst_cmd,
                    stdin=tar_proc.stdout,
                    stdout=out_fh,
                    stderr=subprocess.PIPE,
                    check=True,
                )
            tar_proc.wait()
            if tar_proc.returncode != 0:
                die(f"tar failed (exit {tar_proc.returncode})")
        except FileNotFoundError as exc:
            die(f"required binary not found: {exc.filename}")
        except subprocess.CalledProcessError as exc:
            die(
                f"zstd failed (exit {exc.returncode}):\n"
                f"{exc.stderr.decode(errors='replace').strip()}"
            )
        return

    mode = _TARFILE_COMPRESSION_MAP[compression]
    with tarfile.open(outpath, mode) as tf:
        tf.add(os.path.join(srcdir, toplevel), arcname=toplevel)


def contents_identical(path_a: str, path_b: str) -> bool:
    """
    Return True when the two tarballs expand to the same file tree.
    Mirrors the diff-based idempotency check in obs-service-recompress.
    """
    with tempfile.TemporaryDirectory() as tmpdir:
        dir_a = os.path.join(tmpdir, "a")
        dir_b = os.path.join(tmpdir, "b")
        os.makedirs(dir_a)
        os.makedirs(dir_b)
        try:
            subprocess.run(
                ["tar", "--extract", f"--file={path_a}", f"--directory={dir_a}"],
                check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
            )
            subprocess.run(
                ["tar", "--extract", f"--file={path_b}", f"--directory={dir_b}"],
                check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
            )
        except subprocess.CalledProcessError:
            return False
        result = subprocess.run(
            ["diff", "--recursive", "--no-dereference", dir_a, dir_b],
            stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
        )
        return result.returncode == 0


# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------

def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description=(
            "Repack an obs_scm source tree as a versioned source tarball. "
            "The source directory is located via the .obsinfo file written "
            "by obs_scm. Name and Version are read from a spec file via rpmspec."
        )
    )
    parser.add_argument(
        "--outdir", required=True,
        help="Directory where the output tarball is written (provided by OBS).",
    )
    parser.add_argument(
        "--spec", default="*.spec",
        help="Glob matching the .spec file to query for Name and Version. "
             "Default: *.spec",
    )
    parser.add_argument(
        "--compression", default="bz2",
        choices=["gz", "bz2", "xz", "zst"],
        help="Output compression format. Default: bz2",
    )
    return parser.parse_args()


def main() -> None:
    args = parse_args()

    # -- locate .obsinfo ------------------------------------------------------
    # obs_scm always writes a <name>.obsinfo alongside the .obscpio.
    # At buildtime the .obscpio is gone but .obsinfo remains — it is the
    # authoritative record of what was extracted and under what name.
    obsinfo = resolve_glob("*.obsinfo", "obsinfo")
    print(f"Obsinfo : {obsinfo}")

    obs_name    = read_obsinfo(obsinfo, "name")
    obs_version = read_obsinfo(obsinfo, "version")
    if not obs_name:
        die(f"could not read 'name' from '{obsinfo}'")

    # The source tree directory is named <name>-<version> when obs_scm
    # appends a version (the default), or just <name> when without-version
    # is set. Try the versioned form first.
    src_dir = f"{obs_name}-{obs_version}" if obs_version else obs_name
    if not os.path.isdir(src_dir):
        if os.path.isdir(obs_name):
            src_dir = obs_name
        else:
            die(
                f"source directory '{src_dir}' not found in cwd "
                f"(obsinfo: name='{obs_name}' version='{obs_version}')"
            )
    print(f"Source  : {src_dir}/")

    # -- query Name + Version from spec ---------------------------------------
    spec = resolve_glob(args.spec, "spec")
    print(f"Spec    : {spec}")

    name, version = query_spec(spec)
    print(f"Name    : {name}")
    print(f"Version : {version}")

    # -- determine output filename --------------------------------------------
    canonical = f"{name}-{version}"
    out_name  = f"{canonical}.tar.{args.compression}"
    out_path  = os.path.join(args.outdir, out_name)

    # Snapshot existence in outdir *before* writing anything.
    had_existing = os.path.isfile(out_path)

    # -- rename source directory to canonical <name>-<version> ---------------
    if src_dir != canonical:
        os.rename(src_dir, canonical)
        print(f"Renamed '{src_dir}' → '{canonical}'")
        src_dir = canonical

    # -- pack into a staging temp file ----------------------------------------
    # Written to outdir so os.replace() is atomic (same filesystem).
    # A failed create_tarball leaves no partial file at out_path.
    with tempfile.NamedTemporaryFile(
        dir=args.outdir,
        prefix=f".{out_name}.",
        suffix=".tmp",
        delete=False,
    ) as tmp_fh:
        tmp_path = tmp_fh.name

    try:
        create_tarball(os.getcwd(), src_dir, tmp_path, args.compression)

        # -- idempotency check ------------------------------------------------
        # If an identical tarball already exists in outdir (previous service
        # run), discard the new one to avoid a spurious OBS source commit.
        if had_existing and contents_identical(tmp_path, out_path):
            print(f"Identical tarball '{out_name}' already exists, skipping.")
        else:
            # Ensure standard 0644 permissions before moving into place.
            # NamedTemporaryFile creates files with mode 0600 which would
            # trigger rpmlint's strange-permission warning on the source tarball.
            os.chmod(tmp_path, 0o644)
            os.replace(tmp_path, out_path)
            print(f"Produced '{out_name}'")
    finally:
        if os.path.exists(tmp_path):
            os.remove(tmp_path)

    # -- restore source directory name ----------------------------------------
    # Rename back so the build root is not left in a modified state for
    # subsequent services or rpmbuild's %prep step.
    if src_dir != (f"{obs_name}-{obs_version}" if obs_version else obs_name):
        os.rename(src_dir, f"{obs_name}-{obs_version}" if obs_version else obs_name)


if __name__ == "__main__":
    main()
