# A debhelper build system class for handling pkg-js-autopkgtest test.
#
# Copyright: 2019, Xavier Guimard <yadd@debian.org>
# License: GPL-2+

package Debian::Debhelper::Buildsystem::nodejs;

use strict;
use warnings;
use Debian::Debhelper::Dh_Lib;
use File::Find;
use JSON;
use parent qw(Debian::Debhelper::Buildsystem);

use constant MAX_BUILD_LOOP => 3;

# echo, touch and ls are here to help tests
my @known_build_commands = (qw(grunt echo touch ls));

#(qw(babel grunt gulp node rollup tsc webpack cat echo touch ls));

# Order is important here, example: tsc must be launched before other
my @knwonBuildFiles = (
    [ 'Gruntfile.js'     => ['grunt'] ],
    [ 'Gruntfile.coffee' => ['grunt'] ],

    #[ 'tsconfig.json'     => ['tsc'] ],
    #[ 'gulpfile.js' => ['gulp'] ],
    #[ 'rollup.config.js'  => [ 'rollup', '-c' ] ],
    #[ 'webpack.config.js' => ['webpack'] ],
    #[ 'webpackfile.js'    => ['webpack'] ],
);

my $knownCommandMap = { babel => 'babeljs', };

sub DEBUG     { $ENV{DEB_BUILD_PKG_JS_DEBUG} }
sub DEBUGFILE { $ENV{DEB_BUILD_PKG_JS_DEBUG_FILE} }

sub DESCRIPTION {
    "pkg-js-tools test";
}

### CONSTANTS

# Regexp compiled from dh_nodejs/dh_files.excluded
my $fileIgnored =
qr/^(?:a(?:ut(?:otest\.watchr|hors?)|ppveyor\.yml)|c(?:o(?:ntribute|pying)|hange(?:log|s?))|.*\.(?:m(?:arkdown|d)|pdf|txt)|(?:package-lock|bower)\.json|r(?:ollup\.config\.js|eadme)|t(?:sconfig\.json|ests?\.js)|g(?:runt|ulp)file\.js|(?:license|\.).*|yarn\.lock|makefile|history)$/i;

# Regexp compiled from dh_nodejs/dh_dirs.excluded
my $dirIgnored = qr{^(?:(?:example|test|doc)s?|node_modules|\..*)(?:/.*)?}i;

# Extensions that overrides $fileIgnored if set in package.json#files
my $authorizedExt = qr/\.(?:js)$/;

### PRIVATE METHODS

sub open_file {
    my ( $self, $file ) = @_;
    die unless $file;
    my $f;
    open( $f, $file ) or die $!;
    my @lines = map {
        chomp;
        s/#.*$//;
        s/^\s*(.*?)\s*/$1/;
        $_ ? $_ : ();
    } <$f>;
    close $f;
    return @lines;
}

my %json;

# package.json cache
sub pjson {
    my ( $self, $dir ) = @_;
    unless ( -e "$dir/package.json" ) {
        print STDERR "/!\\ $dir/package.json not found\n";
        return undef;
    }
    return $json{$dir} if $json{$dir};
    my $pkgjson;
    open $pkgjson, "$dir/package.json";
    my $content = join '', <$pkgjson>;
    close $pkgjson;
    return $json{$dir} = JSON::from_json($content);
}

sub component_list {
    my $self = shift;
    my ( @components, %packages );
    if ( -e 'debian/watch' ) {
        map { push @components, $1 if (/component=([\w\-\.]+)/) }
          $self->open_file('debian/watch');
    }
    foreach my $component (@components) {
        if ( -d $component ) {
            my $package;
            eval { $package = $self->pjson($component)->{name} };
            print STDERR $@ if $@;
            next if ($@);
            $packages{$component} = $package;
        }
        else {
            print STDERR `ls -l`;
            die "Can't find $component directory in " . `pwd`;
        }
    }
    return \%packages;
}

sub pattern {
    my ( $self, $line ) = @_;
    my ( $p, $pattern );
    print "Parsing expression $line\n";
    $line =~ m/^(.*?)(\*.*)$/ or die "Bad line $line";
    my $tmp = $line;
    my ( $dir, $expr ) = ( $1, $2 );
    if ($dir) {
        $dir =~ s#/+([^/]*)$##;
        $expr = "$1$expr";
        $p    = $dir;
    }
    else {
        $p = '.';
    }
    my @dirs = split( m#\*\*/#, $expr );
    @dirs = map { $_ = quotemeta($_); s#\\\*#[^/]*#g; $_ } @dirs;
    my $pat = "^$p/" . join( '.*(?<=/)', @dirs ) . '$';
    print "Line $tmp becomes: $pat\n";
    eval { $pattern = qr/$pat/ };
    die << "EOF" if ($@);

Unable to parse expression: "$tmp" (converted into "$pat")
If it is correct, please fill a bug against pkg-js-tools.
To workaround, you can overwrite "files" field in debian/nodejs. See
/usr/share/doc/pkg-js-tools/README.md.

EOF
    return ( $p, $pattern );
}

# Private method to build modules
sub build_module {
    my ( $self, $dir ) = @_;
    my $buildCommand;

    # Instead of an override, maintainers can write a debian/nodejs/build
    # or debian/nodejs/<component>/build
    my $builddir = $self->get_buildpath . ( $dir eq '.' ? '' : "/$dir" );
    if ( -e "debian/nodejs/$dir/build" ) {
        print "Found debian/nodejs/$dir/build\n";
        print_and_doit(
            { chdir => $builddir },
            'sh', '-e',
            (
                $dir eq '.'
                ? 'debian/nodejs/build'
                : "../debian/nodejs/$dir/build"
            )
        );
        return 1;
    }
    $buildCommand = $self->pjson($dir)->{scripts}->{build};
    my @commands;
    if ($buildCommand) {
        @commands = $self->resolve_command( $dir, $buildCommand );
    }
    else {
        print "No build command found, searching known files\n";
        foreach my $i (@knwonBuildFiles) {
            if ( -e $i->[0] ) {
                push @commands, $i->[1];
            }
        }
    }
    my $failures = 0;
    my $success  = 0;
    foreach my $cmd (@commands) {

        #$self->_generic_doit_in_dir( $dir, \&print_and_doit, @$_ );
        my $res = print_and_doit_noerror( { chdir => $builddir }, @$cmd );
        if ($res) {
            print 'Command "' . join( ' ', @$cmd ) . qq'" succeeded in $dir\n';
            $success++;
        }
        else {
            print STDERR '### Command "'
              . join( ' ', @$cmd )
              . qq'" failed in $dir\n';
            $failures++;
        }
    }
    if ( $failures and not $success ) {
        print STDERR <<EOF;
############################################################################
$failures failures, unable to build automatically.
Install a debian/nodejs/$dir/build or add a "override_dh_auto_build:" target
in debian/rules
############################################################################
EOF
    }
    return $failures ? $success : 1;
}

sub resolve_command {
    my ( $self, $dir, $command, $count ) = @_;
    $count //= 0;
    if ( $count > MAX_BUILD_LOOP ) {
        print STDERR "Max loop command exceed, aborting";
        return ();
    }
    my @tmp = split /\s*&&\s*/, $command;
    my @commands;
    foreach my $inst (@tmp) {
        my ( $cmd, @rargs ) = split /\s+/, $inst;
        my @args;
        while ( my $c = shift @rargs ) {
            if ( $c =~ s/^(["'])// ) {
                my $sep = $1;
                while ( not( $c =~ s/$sep$// ) and @rargs ) {
                    $c = "$c " . shift(@rargs);
                }
            }
            push @args, $c;
        }
        if ( $cmd eq 'npm' and $args[0] and $args[0] eq 'run' ) {
            my $target = $self->pjson($dir)->{scripts}->{ $args[1] };
            if ( $target and $target ne $command ) {
                $count++;
                push @commands, $self->resolve_command( $dir, $target, $count );
            }
            next;
        }
        elsif ( grep { $cmd eq $_ } @known_build_commands ) {
            $cmd = $knownCommandMap->{$cmd} if $knownCommandMap->{$cmd};
            push @commands, [ $cmd, @args ];
        }
    }
    return @commands;
}

sub dolink {
    my ( $self, $src, $dest ) = @_;
    my $dir = $dest;
    $self->doit_in_builddir( 'mkdir', '-p', $dir )
      if $dir =~ s#/[^/]*$##
      and
      not doit_noerror( { chdir => $self->get_buildpath }, 'test', '-e', $dir );
    my @count = ( $dir =~ m#(/)#g );
    $src = ( '../' x ( scalar(@count) + 1 ) ) . $src unless $src =~ m#^/#;
    $self->doit_in_builddir( 'ln', '-s', $src, $dest );
}

sub do_unlink {
    my ( $self, $link ) = @_;
    unlink $link;
    while ( $link =~ s#/[^/]*?$## ) {
        rmdir $link;
    }
}

# Private method to install modules: search for package.json#files field, if
# not found, install any files except ignored by regexp
sub install_module {
    my ( $self, $destdir, $archPath, $path, $dir, @excludedDirs ) = @_;
    my $re = (
        @excludedDirs
        ? '^(?:' . join( '|', @excludedDirs ) . ')(?:/.*)?$'
        : '##'
    );
    $re = qr/$re/;

    # If debian/nodejs/$dir/install or debian/nodejs/$dir/files or
    # "package.json#files" field exists, only its paths will be examined
    my @files;
    my $noFilesField = 1;
    my $skipFiles    = 1;
    if ( -e "debian/nodejs/$dir/install" ) {
        map {
            s/^\s*(.*?)\s*/$1/;
            next if /^\s*#/;
            my $dest = $path;
            my $src  = $_;
            if (/^(.+?)\s+(.+)$/) {
                $src = $1;
                my $tmp = $2;
                if ( $tmp =~ m{^/} ) {
                    $dest = "$destdir/$tmp";
                }
                else {
                    $dest = "$archPath/$tmp";
                }
            }

            $self->doit_in_builddir( 'mkdir', '-p', $dest )
              unless doit_noerror( { chdir => $self->get_buildpath },
                'test', '-e', $dest );
            $self->doit_in_builddir( 'cp', '--reflink=auto', '-a', "$dir/$src",
                $dest );
        } $self->open_file("debian/nodejs/$dir/install");
        return;
    }
    if ( -e "debian/nodejs/$dir/files" ) {
        @files =
          $self->readFilesField( $dir,
            $self->open_file("debian/nodejs/$dir/files") );
        $noFilesField = 0;
        $skipFiles    = 0;
    }
    elsif ( $self->pjson($dir)->{files} ) {
        print qq{Found "files" field in $dir/package.json, using it\n};
        @files = $self->readFilesField( $dir,
            ref $self->pjson($dir)->{files}
            ? ( @{ $self->pjson($dir)->{files} } )
            : ( "$dir/" . $self->pjson($dir)->{files} ) );
        $noFilesField = 0;
    }
    else {
        print qq{No "files" field in $dir/package.json, install all files\n};
        @files = ($dir);
    }
    my @dest;
    my $mainFile =
      "$dir/" . $self->toJs( $dir, $self->pjson($dir)->{main} || "index.js" );
    $mainFile = "$mainFile/index.js" if -d $mainFile;
    print STDERR "MAIN: $mainFile\n" if (DEBUG);
    $mainFile =~ s#//+#/#g;
    my $foundMain = 0;
    foreach my $p (@files) {
        my $pattern;
        if ( $p =~ m/\*/ ) {
            ( $p, $pattern ) = $self->pattern($p);
        }
        unless ( -e $p ) {
            print STDERR "### Missing $p, skipping\n";
            next;
        }
        find(
            sub {
                my $d = $File::Find::dir;
                return if $pattern and "$d/$_" !~ $pattern;
                $d =~ s#^$dir/?##;

                # Exclusion tests:
                # ----------------
                #
                #  - $skipFiles=1 unless Debian maintainer specified a
                #                 debian/nodejs/install or debian/nodejs/files
                #  - $noFilesField = 1 unless:
                #                      - DM specified a debian/nodejs/install
                #                      - DM specified a debian/nodejs/files
                #                      - package.json contains a "files" field
                my @tests = (

                    # Ignore directories
                    ( -d $_ ),

                    # Don't parse components sub dir for main module
                    ( $d and $d =~ $re and $skipFiles ),

                    # Ignore our debian/ dir
                    ( $File::Find::dir =~ m{^(?:./)?debian} and $skipFiles ),

                    # Ignore license, changelog, readme,... files
                    ( $_ =~ $fileIgnored and $noFilesField and !$d ),
                    (
                              $_ =~ $fileIgnored
                          and ( $noFilesField or $_ !~ $authorizedExt )
                          and !$d
                    ),

                  # Ignore test directory unless specified in package.json#files
                    ( $d and $d =~ $dirIgnored and $noFilesField ),

                    # Ignore test.js file unless specified in package.json#files
                    (
                              $File::Find::dir eq $dir
                          and /^(?:example|test)s?\.js$/i
                          and $noFilesField
                    ),

                    # Exclude any .eslint* unless DM specified it explicitely
                    ( /^\.eslint.*/ and ( $pattern or $p !~ /$_$/ ) ),
                );

                # Install files unless one exclusion test match
                unless ( grep { $_ } @tests ) {
                    push @dest, [ $d, $File::Find::name ];
                    $foundMain = 1 if $File::Find::name eq $mainFile;
                }

                # Debug
                elsif ( DEBUG and not DEBUGFILE ) {
                    print STDERR "# FILE $File::Find::name\n";
                    for ( my $i = 0 ; $i < @tests ; $i++ ) {
                        print STDERR "#     Test$i: "
                          . ( $tests[$i] || 0 ) . "\n";
                    }
                }
                if ( DEBUGFILE and $_ eq DEBUGFILE ) {
                    print STDERR <<EOF;
# FILE $File::Find::name
#    D:            $d
#    PATH:         $File::Find::dir
#    DIR:          $dir
#    SKIP:         $skipFiles
#    NOFILESFIELD: $noFilesField
EOF
                    print STDERR '# '
                      . ( ( grep { $_ } @tests ) ? 'EXCLUDED' : 'INSTALLED' )
                      . ":\n";
                    for ( my $i = 0 ; $i < @tests ; $i++ ) {
                        print STDERR "#     Test$i: "
                          . ( $tests[$i] || 0 ) . "\n";
                    }
                }
            },
            $p
        );
    }
    unless ( $foundMain and -e $mainFile ) {
        print STDERR
          "package.json#main file not installed, forcing it ($mainFile)\n";
        push @dest, [ '', $mainFile ] if -e $mainFile;
    }
    foreach (@dest) {
        $self->doit_in_builddir( 'mkdir', '-p', "$path/$_->[0]" )
          unless doit_noerror( { chdir => $self->get_buildpath },
            'test', '-e', "$path/$_->[0]" );
        my $mode = ( -x $_->[1] ? '755' : '644' );
        $self->doit_in_builddir( 'cp', '--reflink=auto', '-a', $_->[1],
            "$path/$_->[0]/" );
    }
}

sub readFilesField {
    my ( $self, $dir, @lines ) = @_;
    my @files = map {
        $_ = $self->toJs( $dir, $_ );
        print STDERR "$dir#files: $_ does not exists\n"
          unless $_ and -e "$dir/$_" or /\*/;
        $_ ? "$dir/$_" : ();
    } @lines;
    push @files, "$dir/package.json"
      unless grep { $_ eq 'package.json' } @files;
    return @files;
}

sub toJs {
    my ( $self, $dir, $file ) = @_;
    return (
        ( -e "$dir/$file.js" and not -e "$dir/$file" ) ? "$file.js" : "$file" );
}

sub cmp_ordered_list {
    my ( $self, $components ) = @_;
    $components //= $self->component_list;
    my @dirs = ( sort keys %$components );
    my @res;
    if ( -e 'debian/nodejs/build_order' ) {
        @res = map {
            my $cmp = $_;
            die "Unknown component $_" unless $components->{$cmp};
            @dirs = grep { $_ ne $cmp } @dirs;
            $cmp;
        } $self->open_file('debian/nodejs/build_order');
    }
    if ( -e 'debian/nodejs/component_links' ) {
        my %lt;
        map {
            die "Malformed line $. in debian/nodejs/component_links: $_"
              unless /^([\w\-\.]+)\s+([\w\-\.]+)$/;
            $lt{$1} = $2;
        } $self->open_file('debian/nodejs/component_links');
        @dirs =
          sort {
                ( $lt{$a} and $lt{$a} eq $b ) ? -1
              : ( $lt{$b} and $lt{$b} eq $a ) ? 1
              : $a cmp $b
          } @dirs;
    }
    push @res, @dirs;
    return @res;
}

# PUBLIC METHODS
# --------------

sub new {
    my $class = shift;
    return $class->SUPER::new(@_);
}

sub check_auto_buildable {
    my $self = shift;
    if ( -e 'package.json' ) {
        return 1;
    }
    return 0;
}

# auto_configure step: if component are found, create node_modules links
sub configure {
    my $self       = shift;
    my $components = $self->component_list;
    my @dirs       = ( sort keys %$components );
    return unless @dirs;
    $self->doit_in_builddir( 'mkdir', 'node_modules' )
      unless -e 'node_modules';

    # External links
    foreach my $dir ( '.', @dirs ) {
        if ( -e "debian/nodejs/$dir/extlinks" ) {
            map {
                my $src = `nodepath $_`;
                chomp $src;
                $self->dolink( $src, "$dir/node_modules/" );
            } $self->open_file("debian/nodejs/$dir/extlinks");
        }
    }

    # Link each component in node_modules
    foreach my $component (@dirs) {
        my $package = $components->{$component};
        $self->dolink( "$component", "node_modules/$package" )
          unless -e "debian/nodejs/$component/nolink";
    }

    # Links between components
    if ( -e 'debian/nodejs/component_links' ) {
        my %links = map {
            die "Malformed line $. in debian/nodejs/component_links: $_"
              unless /^([\w\-\.]+)\s+([\w\-\.]+)$/;
            my ( $src, $dest ) = ( $1, $2 );
            foreach my $s ( $src, $dest ) {
                die "component_links: Unknown component $s"
                  unless $components->{$s};
            }
            ( $src => $dest );
        } $self->open_file('debian/nodejs/component_links');
        foreach ( keys %links ) {
            $self->dolink( $_, "$links{$_}/node_modules/$components->{$_}" );
        }
    }
}

# build step
sub build {
    my $self       = shift;
    my $components = $self->component_list;
    my @dirs       = $self->cmp_ordered_list($components);
    my $res        = 0;
    foreach my $dir ( @dirs, '.' ) {
        $res += $self->build_module($dir);
    }
    unless ($res) {
        print STDERR "Aborting auto_build\n";

        # To avoid hard transition, this step never fail
        #exit 1;
    }
}

# test step: simple require or real test if declared
sub test {
    my $self       = shift;
    my $components = $self->component_list;
    my @dirs       = ( sort keys %$components );
    foreach (@dirs) {
        if ( -e "debian/nodejs/$_/test" ) {
            print_and_doit( { chdir => $self->get_buildpath . "/$_" },
                'sh', '-e', "../debian/nodejs/$_/test" );
        }
    }
    if ( -e 'debian/tests/pkg-js/test' ) {
        $self->doit_in_builddir( '/bin/sh', '-e', 'debian/tests/pkg-js/test' );
    }
    else {
        $self->doit_in_builddir( '/usr/bin/node', '-e', 'require(".")' );
    }
}

# install step:
#  - if no debian/install file found, install automatically module
#  - install components

sub install {
    my $self    = shift;
    my $destdir = shift;
    my @pkgs    = getpackages();
    my ($package) =
      $#pkgs ? ( grep { $_ =~ /^node-/ } getpackages() ) : @pkgs;
    ($package) = @pkgs unless ($package);
    my $realArchPath = (
        package_is_arch_all($package)
        ? '/usr/share/nodejs'
        : '/usr/lib/'
          . dpkg_architecture_value("DEB_HOST_GNU_TYPE")
          . '/nodejs'
    );
    my $archPath    = "$destdir/$realArchPath";
    my $node_module = $self->pjson('.')->{name};
    my $components  = $self->component_list;
    my @dirs        = ( sort keys %$components );

    # main install
    my $path = "$archPath/$node_module";
    $self->install_module( $destdir, $archPath, $path, '.', @dirs );

    # Links
    if ( -e "debian/nodejs/links" ) {
        my $tmp = tmpdir($package);
        map {
            my $line = $_;
            my ( $src, $dest ) =
              map { m#^/# ? $_ : "$realArchPath/$_" } split /\s+/, $line;
            unless ( $src and $dest ) {
                die "Bad line in debian/nodejs/links: $_";
            }
            print "Linking $dest to $src\n";
            make_symlink( $dest, $src, $tmp );
        } $self->open_file("debian/nodejs/links");
    }

    # Component install
    return unless (@dirs);

    # Search root components
    my %root_cmp;
    if ( -e 'debian/nodejs/root_modules' ) {
        %root_cmp =
          map { ( $_ => 1 ) } $self->open_file('debian/nodejs/root_modules');
    }

    $path = "$path/node_modules";
    if ( -e 'debian/nodejs/submodules' ) {
        my @d = map { chomp; /^\w/ ? $_ : () }
          $self->open_file('debian/nodejs/submodules');
        unless (@d) {
            print
"Components auto-install is skipped by empty debian/nodejs/submodules";
            return;
        }
        my @tmp;
      CMP: foreach my $cmp (@d) {
            foreach my $dir (@dirs) {
                if ( $dir eq $cmp or $components->{$dir} eq $cmp ) {
                    push @tmp, $dir;
                    next CMP;
                }
            }
            die qq{In debian/nodejs/submodules, "$cmp" matches nothing};
        }
        @dirs = @tmp;
    }
    my @provides;
    foreach my $dir (@dirs) {
        $self->install_module(
            $destdir,
            $archPath,
            (
                $root_cmp{$dir}
                ? "$destdir/$realArchPath"
                : $path
              )
              . "/$components->{$dir}",
            $dir
        );
        if ( $root_cmp{$dir} ) {
            my $tmp = 'node-' . $self->pjson($dir)->{name};
            $tmp =~ s/_/-/g;
            if ( my $v = $self->pjson($dir)->{version} ) {
                $tmp .= " (= $v)";
            }
            push @provides, $tmp;
        }
    }
    addsubstvar( $package, 'nodejs:Provides', join( ', ', @provides ) )
      if @provides;
}

# clean step: try to clean node_modules directories
sub clean {
    my $self       = shift;
    my $components = $self->component_list;
    my @dirs       = ( sort keys %$components );
    foreach my $dir ( '.', @dirs ) {
        $self->doit_in_sourcedir( 'rm', '-rf', "$dir/node_modules/.cache" );
        if ( -e "debian/nodejs/$dir/extlinks" ) {
            map { $self->do_unlink("$dir/node_modules/$_"); }
              $self->open_file("debian/nodejs/$dir/extlinks");
        }
    }
    if ( -e 'debian/nodejs/component_links' ) {
        map {
            die "Malformed line $. in debian/nodejs/component_links: $_"
              unless /^([\w\-\.]+)\s+([\w\-\.]+)$/;
            my ( $src, $dest ) = ( $1, $2 );
            $self->do_unlink("$dest/node_modules/$components->{$src}");
        } $self->open_file('debian/nodejs/component_links');
    }
    foreach my $component (@dirs) {
        my $package = $components->{$component};

        # Error not catched here
        $self->do_unlink("node_modules/$package");
    }
}

1;
