#!/usr/bin/perl
use warnings;
use strict;
use Cwd;

# Bypass the wrapper if CMake seems to have run already in this directory or if
# we are given an option to do something other than configuring the build.
if (-e 'CMakeCache.txt' or
    grep m[^(--build$
            |-E
            |-P
            |--find-package$
            |--graphviz$
            |--system-information$
            |-N
            |-L
            |-U
            |/V|--version$|-version$
            |-h|-H|-usage|--help|-help # deliberately no `$` here
            )]x, @ARGV)
{
    exec "cmake", @ARGV or die;
}

print "cmake-wrapper: invoked with args @ARGV\n";
print "               in directory ", getcwd, "\n";

# https://cmake.org/cmake/help/v3.5/command/find_library.html
my $FIND_LIBRARY_KW = [qw(
    NAMES NAMES_PER_DIR HINTS ENV PATHS ENV PATH_SUFFIXES DOC NO_DEFAULT_PATH
    NO_CMAKE_ENVIRONMENT_PATH NO_CMAKE_PATH NO_SYSTEM_ENVIRONMENT_PATH
    NO_CMAKE_SYSTEM_PATH CMAKE_FIND_ROOT_PATH_BOTH ONLY_CMAKE_FIND_ROOT_PATH
    NO_CMAKE_FIND_ROOT_PATH
)];

# Standard search path for find_library. These work well with Ubuntu.
my @LIB_PATHS = qw(
    /usr/lib
    /usr/lib/x86_64-linux-gnu
);

# This global var keeps track of all directories where we might trigger an
# installation of a library file with find_library.
my %all_lib_paths_seen = map { $_ => 1 } @LIB_PATHS;

$| = 1; # disable stdout buffering

# Parameters:
#   A CMake argument string, followed by a list ref of keywords.
# Returns:
#   A hash that maps keywords to lists of arguments, where the special key "",
#   which is always present, gives the arguments that came before the first
#   keyword.
# Example:
#   parse_command_with_keywords(
#       "z NAMES_PER_DIR HINTS /usr/lib/x86_64-linux-gnu/hdf5/serial",
#       [qw(NAMES NAMES_PER_DIR HINTS PATHS)]
#   )
#   returns
#   ( '' => ['z'],
#     HINTS => ['/usr/lib/x86_64-linux-gnu/hdf5/serial'],
#     NAMES_PER_DIR => [] )
sub parse_command_with_keywords {
    # The input text has been pretty-printed by CMake, so it's in a somewhat
    # canonical form with no tabs, newlines, etc. even if such characters were
    # present in the source. Lists that were created programmatically, like
    # with CMake's APPEND command, will have elements separated by semicolons.
    my @words = split /[ ;]+/, $_[0];
    my %is_keyword = map { $_ => 1 } @{$_[1]};

    my %result = ("" => []);

    my $current_kw = "";
    for my $word (@words) {
        if ($is_keyword{$word}) {
            $current_kw = $word;
            $result{$current_kw} ||= [];
        } else {
            push @{$result{$current_kw}}, $word;
        }
    }
    return %result;
}

# Returns undef or the lower-cased package name
sub parse_find_package {
    $_[0] =~ /^([^\s]+)/ or return;
    return lc $1;
}

# Returns a list of candidate files (full paths)
sub parse_find_library {
    my %parsed = parse_command_with_keywords($_[0], $FIND_LIBRARY_KW);
    my @positional = @{$parsed{""}};
    shift @positional; # first arg is a variable name

    my @names = (@positional,
                 @{$parsed{NAMES} || []});

    my @paths;
    push @paths, grep m#/#, @{$parsed{HINTS} || []};
    push @paths, @LIB_PATHS unless $parsed{NO_DEFAULT_PATH};
    push @paths, grep m#/#, @{$parsed{PATHS} || []};

    my @path_suffixes = (".",
                         @{$parsed{PATH_SUFFIXES} || []});

    my @results;
    for my $path_prefix (@paths) {
        for my $path_suffix (@path_suffixes) {
            my $path = "$path_prefix/$path_suffix";
            $path =~ s#/\.$##;
            $all_lib_paths_seen{$path} = 1;
            for my $name (@names) {
                # CMake will add platform-specific prefixes/suffixes to the
                # library name if needed. This regex does not accurately mirror
                # what CMake does but should work in practice.
                if ($name =~ /^lib.*\.so/) {
                    push @results, "$path/$name";
                } else {
                    push @results, "$path/lib$name.so";
                }
            }
        }
    }
    return @results;
}

# XXX invoke these unit tests with `--unit-test`
if (@ARGV > 0 and $ARGV[0] eq "--unit-test") {
    my $has_failed = 0;
    @LIB_PATHS = qw(/1 /2); # simple predictable names for test

    use Data::Dumper;
    sub do_test {
        my ($input, @expected) = @_;
        @expected = sort { $a cmp $b } @expected;
        my @output = sort { $a cmp $b } parse_find_library($input);
        if (Dumper(\@output) eq Dumper(\@expected)) {
            print "ok: $input\n";
        } else {
            $has_failed = 1;
            printf "FAIL: %s\nOutput: %sExpected: %s",
                $input, Dumper(\@output), Dumper(\@expected);
        }
    }

    do_test("X GL", qw(/1/libGL.so /2/libGL.so));
    do_test("X PATHS /lib64 NAMES GL", qw(/lib64/libGL.so /1/libGL.so /2/libGL.so));
    do_test("X GL PATHS /lib64", qw(/lib64/libGL.so /1/libGL.so /2/libGL.so));

    # From https://github.com/fogleman/Craft
    do_test("OPENGL_gl_LIBRARY NAMES GL MesaGL PATHS /opt/graphics/OpenGL/lib /usr/openwin/lib /usr/shlib /usr/X11R6/lib",
        '/1/libGL.so',
        '/1/libMesaGL.so',
        '/2/libGL.so',
        '/2/libMesaGL.so',
        '/opt/graphics/OpenGL/lib/libGL.so',
        '/opt/graphics/OpenGL/lib/libMesaGL.so',
        '/usr/X11R6/lib/libGL.so',
        '/usr/X11R6/lib/libMesaGL.so',
        '/usr/openwin/lib/libGL.so',
        '/usr/openwin/lib/libMesaGL.so',
        '/usr/shlib/libGL.so',
        '/usr/shlib/libMesaGL.so'
    );

    do_test("OPENGL_glu_LIBRARY NAMES GLU MesaGLU PATHS OPENGL_gl_LIBRARY-NOTFOUND /opt/graphics/OpenGL/lib /usr/openwin/lib /usr/shlib /usr/X11R6/lib",
        '/1/libGLU.so',
        '/1/libMesaGLU.so',
        '/2/libGLU.so',
        '/2/libMesaGLU.so',
        '/opt/graphics/OpenGL/lib/libGLU.so',
        '/opt/graphics/OpenGL/lib/libMesaGLU.so',
        '/usr/X11R6/lib/libGLU.so',
        '/usr/X11R6/lib/libMesaGLU.so',
        '/usr/openwin/lib/libGLU.so',
        '/usr/openwin/lib/libMesaGLU.so',
        '/usr/shlib/libGLU.so',
        '/usr/shlib/libMesaGLU.so',
    );

    # From https://github.com/conradsnicta/armadillo-code
    do_test("HDF5_C_LIBRARY_hdf5 NAMES  hdf5 NAMES_PER_DIR HINTS /usr/lib/x86_64-linux-gnu/hdf5/serial",
        '/1/libhdf5.so',
        '/2/libhdf5.so',
        '/usr/lib/x86_64-linux-gnu/hdf5/serial/libhdf5.so'
    );

    # From https://github.com/Gnucash/gnucash
    do_test("LIBDBI_DRIVERS_DIR NAMES dbdmysql dbdpgsql dbdsqlite3 NAMES_PER_DIR PATH_SUFFIXES dbd libdbi-drivers/dbd HINTS LIBDBI_LIBRARY PATHS GNC_DBD_DIR DOC Libdbi Drivers Directory",
        '/1/libdbdmysql.so',
        '/1/dbd/libdbdmysql.so',
        '/1/libdbi-drivers/dbd/libdbdmysql.so',
        '/2/libdbdmysql.so',
        '/2/dbd/libdbdmysql.so',
        '/2/libdbi-drivers/dbd/libdbdmysql.so',
        '/1/libdbdpgsql.so',
        '/1/dbd/libdbdpgsql.so',
        '/1/libdbi-drivers/dbd/libdbdpgsql.so',
        '/2/libdbdpgsql.so',
        '/2/dbd/libdbdpgsql.so',
        '/2/libdbi-drivers/dbd/libdbdpgsql.so',
        '/1/libdbdsqlite3.so',
        '/1/dbd/libdbdsqlite3.so',
        '/1/libdbi-drivers/dbd/libdbdsqlite3.so',
        '/2/libdbdsqlite3.so',
        '/2/dbd/libdbdsqlite3.so',
        '/2/libdbi-drivers/dbd/libdbdsqlite3.so',
    );

    # From https://github.com/xmidt-org/xmidt-agent
    do_test('CURL_LIBRARY_DIR NAMES libcurl.so PATHS ./build PATH_SUFFIXES lib lib64 NO_DEFAULT_PATH',
        './build/lib/libcurl.so',
        './build/lib64/libcurl.so',
        './build/libcurl.so',
    );

    exit $has_failed;
}

# lower-case package name => list of .cmake files
my %cmake_config_files;
{
    open my $db, "<", "$ENV{CODEQL_DEPTRACE_CMAKE_CONFIGS}" or die;
    while (<$db>) {
        if (m#(.*/([^/]+)(?:Config|-config)\.cmake)$#) {
            my ($file, $package) = ($1, $2);
            push @{$cmake_config_files{lc $package}}, $file;
        }
    }
}

sub list_packages {
    open my $dpkg_query, "-|",
        qw(dpkg-query --show --showformat ${binary:Package}\n) or die;
    my @packages = <$dpkg_query>;
    close $dpkg_query;
    return @packages;
}

# This map keeps track of all the Debian packages we have observed to be
# installed on the system at some point. Eventually this map stops growing,
# which means we should stop iterating.
my %packages_seen = map { $_ => 1 } list_packages();

# Iterate this loop as long as new packages are getting installed. Because we
# are reacting to the debug output of CMake, we're always one step behind it so
# it will likely fail before we can install the packages it's looking for. It
# might also succeed if the packages are optional, in which case we also want
# to iterate the loop again to ensure it picks up the optional packages.
for my $iteration (1 .. 40) { # limit max iterations
    print "cmake-wrapper: Running cmake, capturing its output...\n";
    my $child_pid = open(my $fh, "-|") // die;
    if ($child_pid == 0) {
        open STDERR, ">&", \*STDOUT or die; # 2>&1
        exec "cmake", "--trace-expand", @ARGV or die;
    }

    while (<$fh>) {
        print ".";
        if (m[^/.*?\(\d+\):  find_library\(\s*(.*?)\s*\)]i) {
            print "\ncmake-wrapper: find_library($1)\n";
            for my $file (parse_find_library($1)) {
                print "cmake-wrapper: probing for $file\n";
                -e $file;
            }

        } elsif (m[^/.*?\(\d+\):  find_package\(\s*(.*?)\s*\)]i) {
            print "\ncmake-wrapper: find_package($1)\n";
            if (my $package = parse_find_package($1)) {
                print "cmake-wrapper: lowercase package: $package\n";
                for my $file (@{$cmake_config_files{$package}}) {
                    print "cmake-wrapper: probing for $file\n";
                    -e $file;
                }
            }
        }
    }
    print "\n";

    close $fh;
    unlink "CMakeCache.txt";

    my $new_packages = 0;
    for my $package (list_packages()) {
        if (not $packages_seen{$package}) {
            $packages_seen{$package} = 1;
            $new_packages++;
        }
    }

    print "Iteration $iteration installed $new_packages new packages\n";
    last if $new_packages == 0;
}

# Do a final run of CMake so the user can see its non-trace output and diagnose
# any problems there might be, whether it succeeds or not. This is done with
# CMakeCache.txt deleted since otherwise it does not print the full output.
print "cmake-wrapper: final run of cmake...\n";
exec "cmake", @ARGV or die;
