#!/usr/bin/env python3
"""Generate ScrapLinux port recipes from the package manifest.

Hand-writing several hundred recipes means several hundred chances to get a
configure flag wrong. Instead the manifest carries what is actually specific to
a package - name, version, licence, dependencies, upstream URL - and the build
system template supplies the rest. This is the same split every large
distribution ends up making.

    ./gen-ports.py                  regenerate every recipe from manifest.tsv
    ./gen-ports.py --check          verify recipes match the manifest
    ./gen-ports.py --stats          count packages per repo

Manifest columns, tab separated:
    repo  name  version  buildsys  license  depends  makedepends  url  source  desc

`depends` and `makedepends` are comma separated. `source` may contain {name},
{version} and {V_} (version with underscores); if it is "-" the package is a
meta package with no payload.
"""
import os
import re
import sys
import collections

HERE = os.path.dirname(os.path.abspath(__file__))
MANIFEST = os.path.join(HERE, "manifest.tsv")

# --------------------------------------------------------------------- templates

HEADER = """# {name} - {desc}
# Generated from manifest.tsv by gen-ports.py. Edit the manifest, not this file,
# unless the package needs something the template cannot express - in which case
# drop a `recipe.local` beside it and it will be preserved.
name={name}
version={version}
release=1
desc="{desc}"
url="{url}"
license="{license}"
depend="{depends}"
makedepend="{makedepends}"
source="{source}"
sha256="{sums}"
"""

TEMPLATES = {
    # ------------------------------------------------------------- autotools
    "autotools": """
build() {{
	cd "{srcdir}"
	# Some release tarballs are raw git-tag archives with no generated
	# ./configure at all (only configure.ac/Makefile.am) - a proper "make
	# dist" tarball ships one, a GitHub/GitLab auto-generated source
	# archive does not. Bootstrap it ourselves when that happens.
	[ -x ./configure ] || autoreconf -fi
	# Not every configure script accepts the same options - sqlite rejects
	# --disable-nls outright, for instance - so only pass the ones this one
	# actually advertises. Guessing wrong fails the whole build.
	_opts="--prefix=/usr --sysconfdir=/etc --localstatedir=/var --libdir=/usr/lib"
	_help=$(./configure --help 2>/dev/null)
	case "$_help" in *--disable-static*)  _opts="$_opts --disable-static" ;; esac
	case "$_help" in *--disable-nls*)     _opts="$_opts --disable-nls" ;; esac
	# bmake is not GNU make - automake's dependency-tracking .deps
	# fragments need nested variable expansion bmake doesn't have.
	case "$_help" in *--disable-dependency-tracking*) _opts="$_opts --disable-dependency-tracking" ;; esac
	# shellcheck disable=SC2086
	./configure $_opts
	gmake -j"$JOBS"
}}

package() {{
	cd "{srcdir}"
	gmake DESTDIR="$pkgdir" install
}}
""",
    # -------------------------------------------------------- autotools, no nls
    "autotools-nodisable": """
build() {{
	cd "{srcdir}"
	./configure --prefix=/usr --sysconfdir=/etc --localstatedir=/var
	gmake -j"$JOBS"
}}

package() {{
	cd "{srcdir}"
	gmake DESTDIR="$pkgdir" install
}}
""",
    # ------------------------------------------------------------------ cmake
    "cmake": """
build() {{
	# CMAKE_PREFIX_PATH pinned to /usr, and find_package() barred from
	# every source of an unwanted host-installed hit:
	#
	# - CMAKE_FIND_USE_PACKAGE_REGISTRY / _SYSTEM_PACKAGE_REGISTRY=OFF:
	#   without this cmake's own registry can find a host-installed copy
	#   of the same library ahead of the one actually being built against
	#   (real, hit by qt6-svg finding the host's Qt6 instead of ScrapLinux's).
	# - CMAKE_FIND_USE_SYSTEM_ENVIRONMENT_PATH=OFF: cmake derives extra
	#   find_package() search prefixes from every PATH entry (stripping a
	#   trailing /bin or /sbin), and this build's PATH includes
	#   /host/usr/bin and /host/bin as a fallback for host tools not yet
	#   packaged for ScrapLinux - without this, that becomes a search prefix
	#   of /host/usr, and find_package quietly resolves a real dependency
	#   to a host copy that was never built against ScrapLinux at all (hit by
	#   pcmanfm-qt finding a host-installed fm-qt6 this way).
	#
	# Deliberately NOT set: CMAKE_FIND_USE_CMAKE_SYSTEM_PATH=OFF and
	# CMAKE_FIND_ROOT_PATH=/usr, both tried and reverted. The former also
	# gates whether find_library()/find_path() (not just find_package())
	# consult CMAKE_PREFIX_PATH at all - turning it off broke plain
	# find_library(OpenGL) against /usr entirely, taking every downstream
	# Qt6Gui consumer down with it. The latter only takes effect while
	# cross-compiling (CMAKE_CROSSCOMPILING true), which this never is -
	# a no-op on its own, but compounded the find_library breakage above
	# when combined with it. CMAKE_PREFIX_PATH=/usr is what actually does
	# the pinning; neither of these was pulling its weight.
	#
	# CMAKE_CXX_FLAGS below adds every Qt module's versioned private-header
	# dir directly: third-party consumers of Qt private headers (LXQt and
	# friends) set -DQt6Foo_PRIVATE_INCLUDE_DIRS by reading
	# Qt6::FooPrivate's INTERFACE_INCLUDE_DIRECTORIES via
	# get_target_property() at configure time - but that property is a
	# generator expression gated on a TARGET_PROPERTY check, and
	# get_target_property() captures it unevaluated, so the variable ends
	# up empty and every private header 404s. Harmless for anything that
	# isn't consuming Qt private headers at all.
	cmake -S "{srcdir}" -B build -G Ninja \\
		-DCMAKE_INSTALL_PREFIX=/usr \\
		-DCMAKE_INSTALL_LIBDIR=lib \\
		-DCMAKE_BUILD_TYPE=Release \\
		-DBUILD_SHARED_LIBS=ON \\
		-DCMAKE_PREFIX_PATH=/usr \\
		-DCMAKE_FIND_USE_PACKAGE_REGISTRY=OFF \\
		-DCMAKE_FIND_USE_SYSTEM_PACKAGE_REGISTRY=OFF \\
		-DCMAKE_FIND_USE_SYSTEM_ENVIRONMENT_PATH=OFF \\
		-DCMAKE_POLICY_VERSION_MINIMUM=3.5 \\
		-DCMAKE_CXX_FLAGS="-I/usr/include/QtCore/6.10.1 -I/usr/include/QtCore/6.10.1/QtCore -I/usr/include/QtGui/6.10.1 -I/usr/include/QtGui/6.10.1/QtGui -I/usr/include/QtWidgets/6.10.1 -I/usr/include/QtWidgets/6.10.1/QtWidgets -I/usr/include/QtDBus/6.10.1 -I/usr/include/QtDBus/6.10.1/QtDBus -I/usr/include/QtNetwork/6.10.1 -I/usr/include/QtNetwork/6.10.1/QtNetwork"
	# cmake itself (a host binary in this chroot) needs LD_LIBRARY_PATH to
	# find its own dependencies, but that same setting poisons anything
	# the build produces and then runs on itself (Qt's own qmlcachegen,
	# built here, loading the host's Qt 6.11 instead of the one just
	# built here alongside it - "undefined symbol ... version
	# Qt_6_PRIVATE_API", a real symbol at a real address in the correct
	# library, just not the one that got loaded).
	unset LD_LIBRARY_PATH
	ninja -C build -j"$JOBS"
}}

package() {{
	DESTDIR="$pkgdir" ninja -C build install
}}
""",
    # ------------------------------------------------------------------ meson
    "meson": """
build() {{
	cd "{srcdir}"
	meson setup build \\
		--prefix=/usr \\
		--libdir=/usr/lib \\
		--sysconfdir=/etc \\
		--localstatedir=/var \\
		--buildtype=release \\
		--wrap-mode=nodownload \\
		-Db_lto=true
	ninja -C build -j"$JOBS"
}}

package() {{
	cd "{srcdir}"
	DESTDIR="$pkgdir" ninja -C build install
}}
""",
    # ------------------------------------------------------------------ cargo
    "cargo": """
build() {{
	cd "{srcdir}"
	# --offline needs a vendored dependency tree that nothing here produces -
	# every crate the recipe does not vendor itself fails with "no matching
	# package found" before a single line of Rust compiles. The build
	# sandbox already reaches the network to fetch the source tarball
	# itself; letting cargo reach crates.io the same way is the same trust
	# boundary, not a new one.
	#
	# CARGO_HOME defaults to ~/.cargo - under the sandbox's read-only host
	# bind when building as root, so cargo's own registry cache had nowhere
	# to write and every download failed with "Read-only file system".
	export CARGO_HOME="${{CARGO_HOME:-/tmp/scraplinux-cargo-home}}"
	# Not clang/lld - nothing else in this tree builds with them (every other
	# recipe just uses cc, which is gcc here), and forcing a linker that is
	# not actually installed failed every single Rust package the same way.
	cargo build --release --locked
}}

package() {{
	cd "{srcdir}"
	install -Dm755 "target/release/{name}" "$pkgdir/usr/bin/{name}"
	for d in README.md README LICENSE; do
		[ -f "$d" ] && install -Dm644 "$d" "$pkgdir/usr/share/doc/{name}/$d"
	done
	true
}}
""",
    # ----------------------------------------------------------------- python
    "python": """
build() {{
	cd "{srcdir}"
	python -m build --wheel --no-isolation
}}

package() {{
	cd "{srcdir}"
	python -m installer --destdir="$pkgdir" dist/*.whl
}}
""",
    # --------------------------------------------------------------------- go
    "go": """
build() {{
	cd "{srcdir}"
	export CGO_CPPFLAGS="$CPPFLAGS" CGO_CFLAGS="$CFLAGS" CGO_LDFLAGS="$LDFLAGS"
	export GOFLAGS="-buildmode=pie -trimpath -mod=readonly -modcacherw"
	go build -o "{name}" .
}}

package() {{
	cd "{srcdir}"
	install -Dm755 "{name}" "$pkgdir/usr/bin/{name}"
}}
""",
    # ------------------------------------------------------------ plain make
    "make": """
build() {{
	cd "{srcdir}"
	make -j"$JOBS" PREFIX=/usr
}}

package() {{
	cd "{srcdir}"
	make PREFIX=/usr DESTDIR="$pkgdir" install
}}
""",
    # ------------------------------------------- suckless: configure by editing
    "suckless": """
build() {{
	cd "{srcdir}"
	# Suckless software is configured in the source. ScrapLinux ships a config.h
	# with the ScrapLinux palette; replace it and rebuild to change anything.
	[ -f config.def.h ] && cp -f config.def.h config.h
	make -j"$JOBS" PREFIX=/usr
}}

package() {{
	cd "{srcdir}"
	make PREFIX=/usr DESTDIR="$pkgdir" install
	[ -f config.h ] && install -Dm644 config.h "$pkgdir/usr/share/{name}/config.h"
	true
}}
""",
    # ---------------------------------------------------------- kernel module
    "dkms": """
# Built against every installed kernel. scraps re-runs this from the package's
# post_upgrade hook whenever a kernel changes, which is what dkms is for.
#
# gmake, not make: Linux's kbuild is written against GNU make and bmake cannot
# drive it. /usr/bin/make on ScrapLinux is bmake, so kernel modules ask for gmake
# explicitly.
build() {{
	cd "{srcdir}"
	for kdir in /usr/lib/modules/*/build; do
		[ -d "$kdir" ] || continue
		gmake -C "$kdir" M="$PWD" modules
	done
}}

package() {{
	cd "{srcdir}"
	for kver in $(ls /usr/lib/modules); do
		install -d "$pkgdir/usr/lib/modules/$kver/extra"
		for ko in *.ko; do
			[ -f "$ko" ] && install -Dm644 "$ko" \\
				"$pkgdir/usr/lib/modules/$kver/extra/$ko"
		done
	done
	true
}}
""",
    # ------------------------------------------------------------------- qmake
    "qmake": """
build() {{
	cd "{srcdir}"
	qmake6 PREFIX=/usr
	make -j"$JOBS"
}}

package() {{
	cd "{srcdir}"
	make INSTALL_ROOT="$pkgdir" install
}}
""",
    # -------------------------------------------------------------------- meta
    "meta": """
# A meta package: the dependency list is the whole payload. Installing it pulls
# in a working setup and nothing else.
package() {{
	install -d "$pkgdir/usr/share/scraplinux/meta"
	printf '%s\\n' "{depends}" >"$pkgdir/usr/share/scraplinux/meta/{name}"
}}
""",
    # ------------------------------------------------------- prebuilt binaries
    "binary": """
# Repackaged upstream build. ScrapLinux prefers to compile from source, but a few
# things ship only as binaries; those live here with the licence stated plainly.
options="!strip"

package() {{
	cd "{srcdir}"
	install -d "$pkgdir/opt/{name}"
	cp -a . "$pkgdir/opt/{name}/"
	install -d "$pkgdir/usr/bin"
	ln -sf "/opt/{name}/{name}" "$pkgdir/usr/bin/{name}"
}}
""",
    # --------------------------------------------------------------- firmware
    "firmware": """
options="!strip"

package() {{
	cd "{srcdir}"
	install -d "$pkgdir/usr/lib/firmware"
	cp -a . "$pkgdir/usr/lib/firmware/"
	find "$pkgdir/usr/lib/firmware" -name 'Makefile' -delete
	true
}}
""",
    # ----------------------------------------------------------- xorg driver
    "xorg": """
build() {{
	cd "{srcdir}"
	./configure --prefix=/usr
	make -j"$JOBS"
}}

package() {{
	cd "{srcdir}"
	make DESTDIR="$pkgdir" install
	find "$pkgdir" -name '*.la' -delete
	true
}}
""",
}


def srcdir_for(name, version, source):
    """Work out the directory a tarball unpacks into."""
    if source == "-":
        return "."
    first = source.split()[0]
    fn = first.split("::")[0] if "::" in first else os.path.basename(first)
    for ext in (".tar.gz", ".tar.xz", ".tar.bz2", ".tar.zst", ".tgz", ".zip", ".tar"):
        if fn.endswith(ext):
            fn = fn[: -len(ext)]
            break
    # A github archive tarball named v1.2.3.tar.gz OR bare 1.2.3.tar.gz (not
    # every project tags with a leading v - ripgrep and helix do not) unpacks
    # to name-1.2.3: GitHub always prefixes the repo name onto the tag,
    # whichever way the tag itself was spelled. Only kicks in when fn is
    # actually bare-version-shaped; a project's own release tarball already
    # names itself correctly and is left alone.
    # A GitHub archive tarball is named after the *tag*, but always unpacks
    # into <repo>-<tag>. The two only coincide when the tag is a bare version:
    # libxkbcommon tags "xkbcommon-1.13.2", so its tarball unpacks into
    # libxkbcommon-xkbcommon-1.13.2, and guessing from the filename alone put
    # the build in a directory that does not exist. Take the repo name from
    # the URL instead of inferring it.
    # A Forgejo/Gitea archive (codeberg.org and friends) unpacks into a bare
    # <repo>/ with no version in it at all, which is why foot's recipe says
    # cd foot and fcft's generated "cd fcft-3.3.1" found nothing.
    m = re.search(r"codeberg\.org/[^/]+/([^/]+)/archive/", first)
    if m:
        return m.group(1)
    m = re.search(
        r"github\.com/[^/]+/([^/]+)/archive/(?:refs/tags/)?(.+?)"
        r"\.(?:tar\.gz|tar\.xz|tar\.bz2|tgz|zip)$", first)
    if m:
        repo_name, tag = m.group(1), m.group(2)
        if tag.startswith("v") and tag[1:2].isdigit():
            tag = tag[1:]
        return f"{repo_name}-{tag}"
    bare = fn[1:] if fn.startswith("v") and fn[1:2].isdigit() else fn
    if bare and bare[0].isdigit():
        fn = f"{name}-{bare}"
    return fn or f"{name}-{version}"


def render(row):
    repo, name, version, buildsys, lic, deps, mdeps, url, source, desc = row

    src = (source
           .replace("{name}", name)
           .replace("{version}", version)
           .replace("{V_}", version.replace(".", "_")))
    if src == "-":
        src = ""

    nsrc = len(src.split()) if src else 0
    sums = "\n\t".join(["SKIP"] * nsrc) if nsrc else ""

    body = TEMPLATES.get(buildsys)
    if body is None:
        raise SystemExit(f"{name}: unknown build system '{buildsys}'")

    text = HEADER.format(
        name=name, version=version, desc=desc, url=url, license=lic,
        depends=" ".join(d.strip() for d in deps.split(",") if d.strip() and d != "-"),
        makedepends=" ".join(d.strip() for d in mdeps.split(",") if d.strip() and d != "-"),
        source=src, sums=sums,
    )
    text += body.format(
        name=name, version=version,
        srcdir=srcdir_for(name, version, src or "-"),
        depends=" ".join(d.strip() for d in deps.split(",") if d.strip() and d != "-"),
    )
    return text


def load():
    rows = []
    with open(MANIFEST) as f:
        for lineno, line in enumerate(f, 1):
            line = line.rstrip("\n")
            if not line or line.startswith("#"):
                continue
            parts = line.split("\t")
            if len(parts) != 10:
                raise SystemExit(
                    f"manifest.tsv:{lineno}: expected 10 columns, found {len(parts)}\n  {line[:90]}")
            rows.append(parts)
    return rows


def main():
    rows = load()
    mode = sys.argv[1] if len(sys.argv) > 1 else "--write"

    per_repo = collections.Counter(r[0] for r in rows)
    names = collections.Counter(r[1] for r in rows)
    dupes = [n for n, c in names.items() if c > 1]
    if dupes:
        raise SystemExit(f"duplicate package names in the manifest: {', '.join(sorted(dupes))}")

    if mode == "--stats":
        total = 0
        for repo, n in sorted(per_repo.items()):
            print(f"  {repo:<14} {n:>4}")
            total += n
        print(f"  {'total':<14} {total:>4}")
        buildsys = collections.Counter(r[3] for r in rows)
        print("\n  by build system")
        for b, n in buildsys.most_common():
            print(f"  {b:<14} {n:>4}")
        return

    written = 0
    for row in rows:
        repo, name = row[0], row[1]
        d = os.path.join(HERE, repo, name)
        os.makedirs(d, exist_ok=True)
        path = os.path.join(d, "recipe")

        # A recipe.local means a human took over; never clobber it.
        if os.path.exists(os.path.join(d, "recipe.local")):
            continue
        text = render(row)

        if mode == "--check":
            if not os.path.exists(path) or open(path).read() != text:
                print(f"  stale: {repo}/{name}")
            continue

        # Hand-written recipes (the core packages) are kept as they are.
        if os.path.exists(path) and "Generated from manifest.tsv" not in open(path).read():
            continue

        with open(path, "w") as f:
            f.write(text)
        written += 1

    if mode != "--check":
        print(f"wrote {written} recipes across {len(per_repo)} repositories")
        print(f"total packages in the manifest: {len(rows)}")


if __name__ == "__main__":
    main()
