#!/usr/bin/python3
"""
rust2pclos: write a PCLinuxOS rust-<crate> spec file for a crates.io crate.

Downloads <crate>-<version>.crate into %_sourcedir (if it is not there yet),
reads its Cargo.toml with cargo2rpm and writes %_specdir/pclos-rust-<crate>.spec
following the standard PCLinuxOS crate spec layout. Review the result before
building: Summary, License and %description come straight from Cargo.toml.

With --compat the package is named after the crate's semver-compatible
series (rust-syn2, rust-nom7, rust-bitflags0.9 ...) so that an older major
version can be installed next to the main rust-<crate> package.

usage: rust2pclos [--force] [--compat] <crate> [version]
"""

import argparse
import datetime
import json
import os
import re
import subprocess
import sys
import tarfile
import tempfile
import urllib.error
import urllib.request
import importlib.machinery
import importlib.util

import rpm

from cargo2rpm.metadata import FeatureFlags, Metadata
from cargo2rpm.rpm import buildrequires
from cargo2rpm.semver import Version

API_URL = "https://crates.io/api/v1/crates"
DOWNLOAD_URL = "https://static.crates.io/crates"
USER_AGENT = "rust2pclos (PCLinuxOS rust packaging)"

LICENSE_PREFIXES = ("LICENSE", "LICENCE", "COPYING", "NOTICE", "UNLICENSE")
DOC_PREFIXES = ("README",)

TEMPLATE = """\
%global crate {crate}
{features_comment}
Name:           rust-%{{crate}}{suffix}
Version:        {version}
Release:        %mkrel 1
Summary:        {summary}
Group:          Development/Rust
License:        {license}
URL:            https://crates.io/crates/%{{crate}}
Source:         %{{crates_source}}
BuildArch:      noarch

BuildRequires:  cargo-rpm-macros

%description
{description}

%package        devel
Summary:        %{{summary}}
BuildArch:      noarch

%description    devel
This package contains library source intended for building other packages which
use the "%{{crate}}" crate.

%prep
%autosetup -n %{{crate}}-%{{version_no_tilde}} -p1
%cargo_prep

%generate_buildrequires
%cargo_generate_buildrequires

%build
%cargo_build

%install
%cargo_install

%files devel
{files}%{{crate_instdir}}/

%changelog
* {date} {packager} - {version}-{release}
- New package
"""


def rpm_eval(expr: str) -> str:
    return subprocess.run(["rpm", "--eval", expr], capture_output=True, text=True, check=True).stdout.strip()


def fetch(url: str) -> bytes:
    request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
    with urllib.request.urlopen(request) as response:
        return response.read()


def compat_series(version: Version) -> tuple[int, ...]:
    """The semver-compatible series of a version: 2.x.y -> (2,), 0.9.x -> (0, 9)."""

    if version.major > 0:
        return (version.major,)
    if version.minor > 0:
        return (0, version.minor)
    return (0, 0, version.patch)


DEP_CLAUSE = re.compile(r"(\S+) (<=|>=|=|<|>) (\S+)")
COMPARE_OK = {"<": (-1,), "<=": (-1, 0), "=": (0,), ">=": (0, 1), ">": (1,)}


def has_provider(dep: str) -> bool:
    """
    Whether an installed package satisfies a BuildRequires such as
    "crate(foo/default) >= 1.2.0" or the rich form cargo2rpm writes for a
    semver range, "(crate(foo/default) >= 1.2.0 with crate(foo/default) < 2.0.0~)".
    One provided version has to meet every clause.
    """

    clauses = DEP_CLAUSE.findall(dep.strip("()"))
    if not clauses:
        clauses = [(dep, None, None)]
    name = clauses[0][0]

    ts = rpm.TransactionSet()
    for hdr in ts.dbMatch("providename", name):
        for prov_name, prov_version in zip(hdr[rpm.RPMTAG_PROVIDENAME], hdr[rpm.RPMTAG_PROVIDEVERSION]):
            if prov_name != name:
                continue
            if all(op is None or (prov_version and
                                  rpm.labelCompare(("0", prov_version, ""), ("0", version, "")) in COMPARE_OK[op])
                   for _, op, version in clauses):
                return True
    return False


def load_generator():
    """Load cargo2rpm-pclos (installed, or next to this script) to reuse its feature resolver."""

    for path in (os.path.join(rpm_eval("%_rpmconfigdir"), "cargo2rpm-pclos"),
                 os.path.join(os.path.dirname(os.path.abspath(__file__)), "cargo2rpm-pclos")):
        if os.path.exists(path):
            loader = importlib.machinery.SourceFileLoader("cargo2rpm_pclos", path)
            module = importlib.util.module_from_spec(importlib.util.spec_from_loader(loader.name, loader))
            loader.exec_module(module)
            return module
    return None


def main():
    parser = argparse.ArgumentParser(description="Write a PCLinuxOS spec file for a crates.io crate.")
    parser.add_argument("crate", help="crate name")
    parser.add_argument("version", nargs="?", help="crate version (default: latest stable)")
    parser.add_argument("--force", action="store_true", help="overwrite an existing spec file")
    parser.add_argument("--compat", action="store_true",
                        help="write a compat package for an older series (e.g. rust-syn2); needs an explicit version")
    args = parser.parse_args()

    if args.compat and not args.version:
        parser.error("--compat needs an explicit version")

    try:
        info = json.loads(fetch(f"{API_URL}/{args.crate}"))["crate"]
    except urllib.error.HTTPError as exc:
        sys.exit(f"crates.io lookup for {args.crate!r} failed: {exc}")

    crate = info["name"]
    version = args.version or info.get("max_stable_version") or info["max_version"]

    sourcedir = rpm_eval("%_sourcedir")
    specdir = rpm_eval("%_specdir")
    os.makedirs(sourcedir, exist_ok=True)
    os.makedirs(specdir, exist_ok=True)
    suffix = ".".join(map(str, compat_series(Version.parse(version)))) if args.compat else ""
    spec_path = os.path.join(specdir, f"pclos-rust-{crate}{suffix}.spec")
    if os.path.exists(spec_path) and not args.force:
        sys.exit(f"{spec_path} already exists (use --force to overwrite)")

    crate_file = os.path.join(sourcedir, f"{crate}-{version}.crate")
    if not os.path.exists(crate_file):
        print(f"Downloading {crate}-{version}.crate", file=sys.stderr)
        try:
            data = fetch(f"{DOWNLOAD_URL}/{crate}/{crate}-{version}.crate")
        except urllib.error.HTTPError as exc:
            sys.exit(f"download of {crate} {version} failed: {exc}")
        with open(crate_file, "wb") as f:
            f.write(data)

    with tempfile.TemporaryDirectory() as tmp:
        with tarfile.open(crate_file) as tar:
            tar.extractall(tmp, filter="data")
        srcdir = os.path.join(tmp, f"{crate}-{version}")
        package = Metadata.from_cargo(os.path.join(srcdir, "Cargo.toml")).packages[0]
        top_files = sorted(os.listdir(srcdir))

    if not package.is_lib():
        sys.exit(f"{crate} has no library target: write an application spec by hand")

    licenses = [f for f in top_files if f.upper().startswith(LICENSE_PREFIXES)]
    docs = [f for f in top_files if f.upper().startswith(DOC_PREFIXES)]
    files = "".join(f"%license %{{crate_instdir}}/{f}\n" for f in licenses)
    files += "".join(f"%doc %{{crate_instdir}}/{f}\n" for f in docs)

    # BuildRequires are generated at build time by %cargo_generate_buildrequires,
    # which uses the same cargo2rpm function: report what is not installed yet
    brs = sorted(buildrequires(package, FeatureFlags(), False))
    missing = [br for br in brs if br.lstrip("(").startswith("crate(") and not has_provider(br)]

    features_comment = ""
    generator = load_generator()
    if generator is not None:
        provided = generator.Resolver(package).provided_features(set())
        extra = sorted(package.get_feature_names() - provided)
        if extra:
            features_comment = (
                "# Features not provided unless listed here (they pull in more crates):\n"
                f"#   {' '.join(extra)}\n"
                "# %%global crate_features feat1,feat2\n"
            )

    summary = (package.get_summary() or "FIXME").rstrip(".")
    description = package.get_description() or "FIXME"

    rpm_version = Version.parse(version).to_rpm()
    spec = TEMPLATE.format(
        crate=crate,
        suffix=suffix,
        features_comment=features_comment,
        version=rpm_version,
        summary=summary,
        license=package.license or "FIXME",
        description=description,
        files=files,
        date=datetime.date.today().strftime("%a %b %d %Y"),
        packager=rpm_eval("%packager"),
        release=rpm_eval("%mkrel 1"),
    )

    with open(spec_path, "w") as f:
        f.write(spec)
    print(f"Wrote {spec_path}")

    if not package.license:
        print("warning: Cargo.toml has no SPDX license field, fill in License by hand", file=sys.stderr)
    if missing:
        print("Build dependencies not installed (build/install these first):", file=sys.stderr)
        for br in missing:
            print(f"  {br}", file=sys.stderr)


if __name__ == "__main__":
    main()
