#!/usr/bin/perl
# Copyright 2026 Juan Manuel Méndez Rey <juan.mendezr@proton.me>
# 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 3 of the License, or
# (at your option) any later version.

=encoding utf8

=head1 NAME

dh-make-scala - start a Debian package for a Scala library from Maven Central

=head1 SYNOPSIS

B<dh-make-scala make> [I<options>] I<group>B<:>I<artifact>[B<:>I<version>]

B<dh-make-scala estimate> [B<--scala> I<list>] I<group>B<:>I<artifact>[B<:>I<version>]

B<dh-make-scala check> [B<--coords> I<group>B<:>I<id>B<:>I<version>] I<file>...

=head1 DESCRIPTION

B<dh-make-scala> writes a F<debian/> directory for a Scala library, to be
built with scala-debian-helper (see L<Debian::Debhelper::Buildsystem::scala(3pm)> and
L<scala-build(5)>), from what upstream publishes: the POM on Maven Central and
the git tag of the release. The artifact is given without its Scala suffix
(B<scala-xml>, not B<scala-xml_2.13>).

Network access happens only in these commands, never in a package build.

=head1 COMMANDS

=over

=item B<make>

Finds the release (the newest one unless a version is given) and the Scala
binary versions it was published for, fetches the POMs, finds the git tag in
the repository named by the POM's B<scm>, writes
F<I<source>_I<version>.orig.tar.xz> from the tag and unpacks it into
F<I<source>/>, then writes F<I<source>/debian/>:

=over

=item *

F<debian/scala-build>: one stanza per module and Scala version. The source
directories follow sbt's rules (B<scala>, B<java>, B<scala->I<major>,
B<scala->I<binver>, B<scala->I<X.Y>B<+> and B<scala->I<X.Y>B<->), under the
module's directory and the B<shared>/B<jvm> directories of sbt-crossproject.
Compile dependencies become B<Classpath> entries; dependencies on other
modules of the same source become B<@>I<module>. Upstream's
B<scalacOptions> lines are copied as comments. A module with Java sources gets
B<Javac-Options: --release> I<N>, where I<N> is the Java release upstream's
classes target (read from their class file version).

=item *

F<debian/control>: one source, one binary package per module and Scala
version, named B<lib>I<module>B<-scala>I<binver>B<-java>, with the Build-Depends
that exist in Debian; Vcs fields for the Java team.

=item *

F<debian/poms/>: upstream's POMs, unchanged.

=item *

F<debian/copyright> from L<licensecheck(1)>, F<debian/watch> (version 5, git
mode on the tag), F<debian/upstream/metadata>, F<debian/changelog>,
F<debian/rules>, F<debian/gbp.conf>, F<debian/salsa-ci.yml>,
F<debian/source/format>.

=item *

F<debian/tests/control> running scala-debian-helper's smoke test,
F</usr/share/scala-debian-helper/smoke-test>: every class of each installed jar must
load, with its signatures resolved, using the class path of the installed
Depends. It catches a missing Depends; it does not run upstream's tests, so
it is marked B<superficial>.

=back

It ends with a list of what the packager must review. Every generated value
that cannot be known is marked B<FIXME>. Copyright holders are never taken
from the POM's B<developers>, which name authors, not holders.

=item B<estimate>

Prints the dependency tree of the artifact (compile and runtime scopes) with,
for each dependency, the Debian package that ships it in
F</usr/share/maven-repo>, or B<MISSING>. Missing dependencies are followed
recursively. Uses L<dpkg-query(1)> and L<apt-file(1)>.

=item B<check>

Compares built jars with upstream's jars on Maven Central. Each I<file> is a
B<.deb> built with scala-debian-helper (its jars and coordinates are read from the
package) or a jar given with B<--coords>. It reports missing and extra classes,
other entries present on one side only, the B<Automatic-Module-Name>, and how
many entries are byte-identical.

Byte differences are expected when upstream used another compiler version
than Debian: the upstream version comes from the jar's properties file, the
Debian one from the B<.buildinfo> next to the B<.deb>. With the same compiler
version, differing bytes point at B<Scalac-Options>. A different class file
version is reported with the B<--release> that matches upstream, except for
Scala 3.8 and later, which cannot target a Java release older than 17: there
the difference is expected and the package needs Java 17. Exits with status 1 when
a class is missing or extra, or the module name differs. Upstream jars are
kept in F<~/.cache/dh-make-scala/>.

=back

=head1 OPTIONS

=over

=item B<--scala> I<list>

Comma-separated Scala binary versions (B<2.12>, B<2.13>, B<3>). Default: every
version upstream published that Debian has a compiler for.

=item B<--module> I<list>

Comma-separated modules (artifacts without suffix) of the same repository,
built from one source package. Default: the artifact given.

=item B<--source> I<name>

Source package name. Default: the repository's name.

=item B<--root> I<module>B<=>I<dir>

Source root of a module (a directory with F<src/main>), when the
guess is wrong. Can be repeated.

=item B<--git-url> I<url>

Upstream repository, when the POM has none or a wrong one.

=item B<--outdir> I<dir>

Where F<I<source>/> and the orig tarball are written. Default: the current
directory.

=item B<--maintainer> I<"Name E<lt>emailE<gt>">

Uploader. Default: B<DEBFULLNAME> and B<DEBEMAIL>.

=back

=head1 ENVIRONMENT

=over

=item B<DH_MAKE_SCALA_REPO>

Maven repository to read; default L<https://repo1.maven.org/maven2>.

=back

=head1 EXAMPLES

Plan, generate, review, build, compare:

 dh-make-scala estimate org.scalameta:munit
 dh-make-scala make --scala 2.13,3 --module munit-diff,junit-interface \
   org.scalameta:munit:1.3.6
 # review the FIXMEs listed, build with sbuild, then:
 dh-make-scala check ../*.deb

When the guess of a module's directory is wrong:

 dh-make-scala make --root os-lib=os --root os-zip=os/zip \
   --module os-zip com.lihaoyi:os-lib:0.11.8

Complete F<debian/scala-build> files of real packages are in
F</usr/share/doc/scala-debian-helper/examples/>, and a guide in
F</usr/share/doc/scala-debian-helper/guide.md>.

=head1 SEE ALSO

L<scala-build(5)>, L<Debian::Debhelper::Buildsystem::scala(3pm)>, L<dh_scala(1)>

=cut

use strict;
use warnings;
use File::Find;
use File::Temp   qw(tempdir);
use Getopt::Long qw(GetOptionsFromArray);
use Dpkg::Control;
use Dpkg::Deps                   qw(deps_parse);
use Debian::DhScala              qw($BINVER_SUFFIX_RE compiler_for);
use Debian::DhScala::Make        qw(make_package estimate);
use Debian::DhScala::Make::Check qw(check_jar check_report);
use Debian::DhScala::Make::Maven qw(
	parse_coords latest_version published_binvers fetch pom_url
	jar_facts upstream_jar pom_scala_version
);
use Debian::DhScala::Make::Util qw(capture fail);

binmode(STDOUT, ':encoding(UTF-8)');

sub usage {
	print STDERR
	  "usage: dh-make-scala make|estimate|check [options] ARGS (see dh-make-scala(1))\n";
	exit 2;
}

my $cmd = shift @ARGV // usage();
my %o   = (roots => {});
my ($scala, $module, @root, $coords);
GetOptionsFromArray(
	\@ARGV,
	'scala=s' => \$scala, 'module=s' => \$module, 'source=s' => \$o{source},
	'root=s' => \@root, 'git-url=s' => \$o{git_url}, 'outdir=s' => \$o{outdir},
	'maintainer=s' => \$o{maintainer}, 'coords=s' => \$coords,
) or usage();

my $status = eval {
	if    ($cmd eq 'make')     { do_make() }
	elsif ($cmd eq 'estimate') { do_estimate() }
	elsif ($cmd eq 'check')    { do_check() }
	else                       { usage() }
};
if ($@) {
	print STDERR $@;
	exit 1;
}
exit($status // 0);

sub binvers_for {
	my ($g, $artifact, $v) = @_;
	my @pub = published_binvers($g, $artifact, $v);
	fail("$g:$artifact $v is not published for Scala 2.12, 2.13 or 3") unless @pub;
	return @pub unless $scala;
	my @want = split /,/, $scala;
	for my $bv (@want) {
		fail("$g:${artifact}_$bv:$v is not on Maven Central (published: @pub)")
		  unless grep { $_ eq $bv } @pub;
	}
	return @want;
}

sub resolve_version {
	my ($g, $a, $v) = @_;
	return $v if defined $v;
	my %latest;
	for my $bv ('3', '2.13', '2.12') {
		my $x = eval { latest_version($g, "${a}_$bv") };
		$latest{$bv} = $x if $x;
	}
	fail("$g:$a is not on Maven Central for Scala 2.12, 2.13 or 3") unless %latest;
	my $bv = $scala ? (split /,/, $scala)[0] : (sort keys %latest)[-1];
	return $latest{$bv} // (values %latest)[0];
}

sub do_make {
	my ($g, $a, $v) = parse_coords(shift(@ARGV) // usage());
	usage() if @ARGV;
	fail("give the artifact without its Scala suffix ($a)")
	  if $a =~ $BINVER_SUFFIX_RE;
	$o{maintainer} //=
	  ($ENV{DEBFULLNAME} && $ENV{DEBEMAIL})
	  ? "$ENV{DEBFULLNAME} <$ENV{DEBEMAIL}>"
	  : fail("set DEBFULLNAME and DEBEMAIL, or pass --maintainer");
	for my $r (@root) {
		my ($m, $d) = split /=/, $r, 2;
		fail("--root wants module=dir") unless defined $d;
		push @{ $o{roots}{$m} }, $d;
	}
	$v = resolve_version($g, $a, $v);
	my @binvers = binvers_for($g, $a, $v);
	my @modules = $module ? split(/,/, $module) : ($a);
	unshift @modules, $a unless grep { $_ eq $a } @modules;
	print "dh-make-scala: $g:$a $v for Scala @binvers, modules @modules\n";
	my ($tree, $orig, $review) = make_package(
		%o, group => $g, version => $v,
		binvers => \@binvers, modules => \@modules,
	);
	print "wrote $orig\nwrote $tree/debian/\n\nTo review:\n";
	print "  - $_\n" for @$review;
	return 0;
}

sub do_estimate {
	my ($g, $a, $v) = parse_coords(shift(@ARGV) // usage());
	# A Java artifact (no Scala suffix): estimated as it is.
	my $java_version = $v // eval { latest_version($g, $a) };
	if (
		defined $java_version
		&& !published_binvers($g, $a, $java_version)
		&& defined fetch(
			pom_url($g, $a, $java_version)
		)
	) {
		print "$g:$a:$java_version (Java)\n";
		my $lines = estimate($g, $a, $java_version);
		print "$_\n" for @$lines;
		print "  (no dependencies)\n" unless @$lines;
		return 0;
	}
	$v = resolve_version($g, $a, $v);
	for my $bv (binvers_for($g, $a, $v)) {
		print "$g:${a}_$bv:$v\n";
		my $lines = estimate($g, "${a}_$bv", $v);
		print "$_\n" for @$lines;
		print "  (no dependencies besides the Scala standard library)\n" unless @$lines;
	}
	return 0;
}

# Jars of a .deb built with scala-debian-helper: { group, id, version, path }.
sub jars_of_deb {
	my ($deb, $tmp) = @_;
	my $x = tempdir(DIR => $tmp);
	system('dpkg-deb', '-x', $deb, $x) == 0 or fail("cannot extract $deb");
	my @r;
	find(
		{
			no_chdir => 1,
			wanted => sub {
				my ($gp, $id, $v) =
				  m{/usr/share/maven-repo/ (.+) / ([^/]+) / ([^/]+) / \2-\3\.pom $}x;
				return if !defined $v || $v eq 'debian';
				(my $g = $gp) =~ s{/}{.}g;
				# next to the POM: a link to /usr/share/java, whatever the
				# jar is named there (Jar-Name)
				(my $jar = $_) =~ s/\.pom$/.jar/;
				push @r, { group => $g, id => $id, version => $v, path => $jar }
				  if -f $jar;
			},
		},
		"$x/usr/share/maven-repo",
	);
	fail("no jar with a Maven POM in $deb") unless @r;
	return @r;
}

# Version of the Scala standard library a .deb was built against, from the
# .buildinfo of that build: <source>_<version>_*.buildinfo next to the .deb.
sub our_scala_version {
	my ($deb, $id) = @_;
	my ($bv) = $id =~ $BINVER_SUFFIX_RE or return;
	my $lib  = compiler_for($bv)->{stdlib_package};
	my $ctrl = Dpkg::Control->new(type => CTRL_PKG_DEB);
	my $text = capture('dpkg-deb', '-f', $deb);
	open(my $fh, '<', \$text)                  or return;
	eval { $ctrl->parse($fh, "$deb control") } or return;
	close $fh;
	my ($src, $srcver) = ($ctrl->{Source} || $ctrl->{Package} // '') =~ /^(\S+)(?: \((\S+)\))?$/
	  or return;
	(my $ver = $srcver // $ctrl->{Version}) =~ s/^\d+://;
	(my $dir = $deb) =~ s{[^/]*$}{};
	$dir = './' if $dir eq '';

	for my $bi (glob("$dir${src}_${ver}_*.buildinfo")) {
		# a .buildinfo is often signed
		my $info = Dpkg::Control->new(type => CTRL_FILE_BUILDINFO, allow_pgp => 1);
		eval { $info->load($bi) }                                       or next;
		my $deps = deps_parse($info->{'Installed-Build-Depends'} // '') or next;
		for my $d ($deps->get_deps()) {
			next unless ($d->{package} // '') eq $lib && ($d->{relation} // '') eq '=';
			# the upstream version, up to the first character that is not a
			# digit or a dot (3.3.4+ds-1 -> 3.3.4)
			my ($v) = $d->{version}->version() =~ /^([0-9.]+)/;
			return $v if defined $v;
		}
	}
	return;
}

# jar_facts of a jar file.
sub jar_facts_of {
	my ($path) = @_;
	open(my $fh, '<:raw', $path) or fail("cannot read $path: $!");
	my $data = do { local $/ = undef; <$fh> };
	close $fh;
	return jar_facts($data);
}

sub do_check {
	usage() unless @ARGV;
	my $tmp   = tempdir(CLEANUP => 1);
	my $cache = ($ENV{XDG_CACHE_HOME} // "$ENV{HOME}/.cache") . '/dh-make-scala';
	my @jars;
	for my $f (@ARGV) {
		if ($f =~ /\.deb$/) {
			push @jars, map { +{ %$_, deb => $f } } jars_of_deb($f, $tmp);
		} else {
			fail("$f: a jar needs --coords group:id:version") unless $coords;
			my ($g, $id, $v) = parse_coords($coords);
			push @jars, { group => $g, id => $id, version => $v, path => $f };
		}
	}
	my $bad = 0;
	for my $j (@jars) {
		my ($g, $id, $v, $path, $deb) = @$j{ qw(group id version path deb) };
		my $up          = upstream_jar($g, $id, $v, $cache);
		my $r           = check_jar($path, $up);
		my $upf         = jar_facts_of($up);
		my $ourf        = jar_facts_of($path);
		my $uv          = $upf->{scala_version};
		my $uv_from_pom = 0;
		if (!defined $uv) {
			$uv          = pom_scala_version($g, $id, $v);
			$uv_from_pom = defined $uv;
		}
		# Ours: the jar's own version properties (Version-Properties), else
		# the .buildinfo of the build that made the .deb.
		my $ov = $ourf->{scala_version} // ($deb ? our_scala_version($deb, $id) : undef);
		my ($lines, $failed) = check_report(
			result => $r, upstream_facts => $upf, our_facts => $ourf,
			upstream_scala => $uv, our_scala => $ov,
			upstream_scala_from_pom => $uv_from_pom,
			is_java => $id !~ $BINVER_SUFFIX_RE,
		);
		print "$g:$id:$v\n";
		print "$_\n" for @$lines;
		$bad = 1 if $failed;
	}
	return $bad;
}
