#!/usr/bin/python3
"""
cargo2rpm-pclos: RPM Provides/Requires generator for PCLinuxOS crate packages.

PCLinuxOS packages each crate as a single rust-<crate>-devel package instead
of Fedora-style rust-<crate>+<feature>-devel subpackages. That package
provides:

  - crate(<crate>) and crate(<crate>/default)
  - every feature that needs no optional dependency beyond those already
    enabled by "default"
  - any extra features listed in the spec's %crate_features macro
    (passed in with --features)

Its Requires cover everything those provided features need. Features that
would pull in other optional crates are not provided, so a package needing
one fails at dependency resolution instead of during the cargo build.

Paths to Cargo.toml files are read from stdin (RPM generator protocol).
"""

import argparse
import subprocess
import sys

from cargo2rpm.__main__ import break_the_build, pretty_called_process_error
from cargo2rpm.cli import get_cargo_toml_paths_from_stdin
from cargo2rpm.metadata import Dependency, Metadata, Package
from cargo2rpm.rpm import InvalidFeatureError, _provides_crate, _provides_feature, _requires_crate


def dep_requires(dep: Dependency, feature: str | None = None) -> set[str]:
    """RPM requirements for enabling an optional dependency (or one of its features)."""

    if dep.is_path_or_git():
        return set()
    if feature is not None:
        return {dep.to_rpm(feature)}

    reqs = {dep.to_rpm("default" if dep.uses_default_features else None)}
    reqs.update(dep.to_rpm(f) for f in dep.features)
    return reqs


class Resolver:
    def __init__(self, package: Package):
        self.package = package
        self.features = package.get_feature_names()
        self.required = [package.get_normal_dependencies(False), package.get_build_dependencies(False)]
        self.optional = [package.get_normal_dependencies(True), package.get_build_dependencies(True)]

    @staticmethod
    def lookup(maps: list[dict[str, Dependency]], name: str) -> list[Dependency]:
        return [m[name] for m in maps if name in m]

    def closure(self, features: set[str]):
        """
        Resolves the transitive closure of a set of features of this crate.

        Returns (own features, enabled optional deps, (dep, feature) pairs).
        Weak "dep?/feature" entries only count if "dep" is enabled anyway.
        """

        own: set[str] = set()
        enabled: set[str] = set()
        dep_features: set[tuple[str, str]] = set()
        weak: set[tuple[str, str]] = set()

        todo = list(features)
        while todo:
            feature = todo.pop()
            if feature in own:
                continue
            if feature != "default" and feature not in self.features:
                raise InvalidFeatureError(f"Unknown feature: {feature}")
            own.add(feature)

            for item in self.package.features.get(feature, []):
                if item.startswith("dep:"):
                    enabled.add(item.removeprefix("dep:"))
                elif "?/" in item:
                    weak.add(tuple(item.split("?/", 1)))
                elif "/" in item:
                    name, feat = item.split("/", 1)
                    dep_features.add((name, feat))
                    if self.lookup(self.optional, name):
                        enabled.add(name)
                else:
                    todo.append(item)

        for name, feat in weak:
            if name in enabled or self.lookup(self.required, name):
                dep_features.add((name, feat))

        return own, enabled, dep_features

    def provided_features(self, opt_in: set[str]) -> set[str]:
        _, base_enabled, _ = self.closure({"default"} | opt_in)
        # optional deps that are also non-optional deps are always available
        base_enabled.update(name for m in self.required for name in m)

        wanted = {"default"} | opt_in
        for feature in self.features:
            _, enabled, _ = self.closure({feature})
            if enabled <= base_enabled:
                wanted.add(feature)

        own, _, _ = self.closure(wanted)
        return own

    def provides(self, opt_in: set[str]) -> set[str]:
        provides = {_provides_crate(self.package)}
        provides.update(_provides_feature(self.package, f) for f in self.provided_features(opt_in))
        return provides

    def requires(self, opt_in: set[str]) -> set[str]:
        requires = _requires_crate(self.package)
        _, enabled, dep_features = self.closure(self.provided_features(opt_in))

        for name in enabled:
            for dep in self.lookup(self.optional, name):
                requires.update(dep_requires(dep))
        for name, feat in dep_features:
            for dep in self.lookup(self.required, name) + self.lookup(self.optional, name):
                requires.update(dep_requires(dep, feat))

        return requires


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("action", choices=["provides", "requires"])
    parser.add_argument("-f", "--features", default="", help="Comma-separated list of extra features to provide")
    args = parser.parse_args()

    opt_in = {f.strip() for f in args.features.split(",") if f.strip()}

    try:
        for path in sorted(get_cargo_toml_paths_from_stdin()):
            metadata = Metadata.from_cargo(path)
            if metadata.is_workspace():
                print(f"Skipping generation of {args.action} for cargo workspace: {path}", file=sys.stderr)
                continue

            resolver = Resolver(metadata.packages[0])
            items = resolver.provides(opt_in) if args.action == "provides" else resolver.requires(opt_in)
            for item in sorted(items):
                print(item)

    except subprocess.CalledProcessError as exc:
        break_the_build(pretty_called_process_error(exc))
        exit(1)

    except InvalidFeatureError as exc:
        break_the_build(exc.error)
        exit(1)

    except (AssertionError, ValueError) as exc:
        break_the_build(str(exc))
        exit(1)


if __name__ == "__main__":
    main()
