# SPDX-FileCopyrightText: 2012, 2013, 2016, 2018, 2022 Michael Pyne <mpyne@kde.org>
# SPDX-FileCopyrightText: 2023 Kristen McWilliam <kmcwilliampublic@gmail.com>
# SPDX-FileCopyrightText: 2023, 2024 Andrew Shark <ashark@linuxcomp.ru>
#
# SPDX-License-Identifier: GPL-2.0-or-later

package ksb::BuildSystem::KDECMake 0.20;

# Class responsible for building CMake-based modules, with special support for KDE modules.

use ksb;

use parent qw(ksb::BuildSystem);

use ksb::BuildContext 0.30;
use ksb::Debug;
use ksb::Util qw(:DEFAULT :await run_logged_p);
use ksb::Util::LoggedSubprocess;

use List::Util qw(first);

my $BASE_GENERATOR_MAP = {
    'Ninja' => {
        optionsName => 'ninja-options',
        installTarget => 'install',
        requiredPrograms => [
            qw{ninja cmake qmake}
        ],
        buildCommands => [
            qw{ninja}
        ]
    },
    'Unix Makefiles' => {
        optionsName => 'make-options',
        installTarget => 'install/fast',
        requiredPrograms => [
            qw{cmake qmake}
        ],
        # Non Linux systems can sometimes fail to build when GNU Make would work,
        # so prefer GNU Make if present, otherwise try regular make.
        buildCommands => [
            qw{gmake make}
        ]
    }
};

# Extra generators that are compatible to the base generators above.
# See: https://cmake.org/cmake/help/latest/manual/cmake-generators.7.html#extra-generators
my $GENERATOR_MAP = {
    'Ninja' => $BASE_GENERATOR_MAP->{'Ninja'},
    'CodeBlocks - Ninja' => $BASE_GENERATOR_MAP->{'Ninja'},
    'CodeLite - Ninja' => $BASE_GENERATOR_MAP->{'Ninja'},
    'Sublime Text 2 - Ninja' => $BASE_GENERATOR_MAP->{'Ninja'},
    'Kate - Ninja' => $BASE_GENERATOR_MAP->{'Ninja'},
    'Eclipse CDT4 - Ninja' => $BASE_GENERATOR_MAP->{'Ninja'},

    'Unix Makefiles' => $BASE_GENERATOR_MAP->{'Unix Makefiles'},
    'CodeBlocks - Unix Makefiles' => $BASE_GENERATOR_MAP->{'Unix Makefiles'},
    'CodeLite - Unix Makefiles' => $BASE_GENERATOR_MAP->{'Unix Makefiles'},
    'Sublime Text 2 - Unix Makefiles' => $BASE_GENERATOR_MAP->{'Unix Makefiles'},
    'Kate - Unix Makefiles' => $BASE_GENERATOR_MAP->{'Unix Makefiles'},
    'Eclipse CDT4 - Unix Makefiles' => $BASE_GENERATOR_MAP->{'Unix Makefiles'}
};

sub _checkGeneratorIsWhitelisted
{
    my $generator = shift;

    return exists ($GENERATOR_MAP->{$generator});
}

sub _stripGeneratorFromCMakeOptions
{
    my $nextShouldBeGenerator = 0;
    my @filtered = grep {
        my $accept = 1;
        if ($nextShouldBeGenerator) {
            $nextShouldBeGenerator = 0;
            $accept = 0;
        } else {
            my $maybeGenerator = $_;
            if ($maybeGenerator =~ /^-G(\S*(\s*\S)*)\s*/) {
                my $generator = $1 // '';
                $nextShouldBeGenerator = 1 if ($generator eq '');
                $accept = 0;
            }
        }

        $accept == 1;
    } (@_);
    return @filtered;
}

sub _findGeneratorInCMakeOptions
{
    my $nextShouldBeGenerator = 0;
    my @filtered = grep {
        my $accept = 0;
        if ($nextShouldBeGenerator) {
            $nextShouldBeGenerator = 0;
            $accept = 1;
        } else {
            my $maybeGenerator = $_;
            if ($maybeGenerator =~ /^-G(\S*(\s*\S)*)\s*/) {
                my $generator = $1 // '';
                if ($generator ne '') {
                    $accept = 1;
                } else {
                    $nextShouldBeGenerator = 1;
                }
            }
        }

        $accept == 1;
    } (@_);

    for my $found (@filtered) {
        if ($found =~ /^-G(\S*(\s*\S)*)\s*/) {
            $found = $1 // '';
        }
        return $found unless ($found eq '');
    }

    return '';
}

sub _checkToolchainOk
{
    my $toolchain = shift;
    return $toolchain ne '' && -f $toolchain && -r $toolchain;
}

sub _stripToolchainFromCMakeOptions
{
    my @filtered = grep {
        my $accept = 1;
        my $maybeToolchain = $_;
        if ($maybeToolchain =~ /^-DCMAKE_TOOLCHAIN_FILE=(\S*(\s*\S)*)\s*/) {
            $accept = 0;
        }

        $accept == 1;
    } (@_);
    return @filtered;
}

sub _findToolchainInCMakeOptions
{
    my $found = first {
        my $accept = 0;
        my $maybeToolchain = $_;
        if ($maybeToolchain =~ /^-DCMAKE_TOOLCHAIN_FILE=(\S*(\s*\S)*)\s*/) {
            my $file = $1 // '';
            $accept = 1 if (_checkToolchainOk($file));
        }

        $accept == 1;
    } (@_);

    if ($found && $found =~ /^-DCMAKE_TOOLCHAIN_FILE=(\S*(\s*\S)*)\s*/) {
        $found = $1 // '';
        return $found if (_checkToolchainOk($found));
    }

    return '';
}

sub _determineCmakeToolchain
{
    my $self = shift;

    my $module = $self->module();
    my @cmakeOptions = split_quoted_on_whitespace ($module->getOption('cmake-options'));

    my $toolchain = first { _checkToolchainOk($_); } (
        _findToolchainInCMakeOptions(@cmakeOptions),
        $module->getOption('cmake-toolchain')
    );

    return $toolchain // '';
}

sub cmakeToolchain
{
    my $self = shift;
    if (not (exists $self->{cmake_toolchain})) {
        $self->{cmake_toolchain} = $self->_determineCmakeToolchain();
    }
    return $self->{cmake_toolchain};
}

sub hasToolchain
{
    my $self = shift;
    return $self->cmakeToolchain() ne '';
}

# Returns true if CMake is run with ninja, which supports setting -j
# automatically.
sub supportsAutoParallelism ($self)
{
    my $generator     = $self->cmakeGenerator();
    my $generatorOpts = $GENERATOR_MAP->{$generator}->{optionsName};

    return 0
        unless $generatorOpts;

    return 1
        if $generatorOpts eq 'ninja-options';

    return 0;
}

sub _determineCmakeGenerator
{
    my $self = shift;

    my $module = $self->module();
    my @cmakeOptions = split_quoted_on_whitespace ($module->getOption('cmake-options'));

    my $generator = first { _checkGeneratorIsWhitelisted($_); } (
        _findGeneratorInCMakeOptions(@cmakeOptions),
        $module->getOption('cmake-generator'),
        'Unix Makefiles'
    );

    croak_internal("Unable to determine CMake generator for: $module") unless $generator;
    return $generator;
}

sub cmakeGenerator
{
    my $self = shift;
    if (not (exists $self->{cmake_generator})) {
        $self->{cmake_generator} = $self->_determineCmakeGenerator();
    }
    return $self->{cmake_generator};
}

sub needsInstalled
{
    return 1;
}

sub name
{
    return 'KDE CMake';
}

# Called by the module being built before it runs its build/install process. Should
# setup any needed environment variables, build context settings, etc., in preparation
# for the build and install phases.
sub prepareModuleBuildEnvironment
{
    my ($self, $ctx, $module, $prefix) = @_;

    # Suppress injecting qt-install-dir/install-dir related environment variables if a toolchain is also set
    # Let the toolchain files/definitions take care of themselves.
    return if $self->hasToolchain();

    # Avoid moving /usr up in env vars
    if ($prefix ne '/usr') {
        # Find the normal CMake "config" mode files for find_package()
        $ctx->prependEnvironmentValue('CMAKE_PREFIX_PATH', $prefix);
        # Try to ensure that older "module" mode find_package() calls also point to right directory
        $ctx->prependEnvironmentValue('CMAKE_MODULE_PATH', "$prefix/lib64/cmake:$prefix/lib/cmake");
        # Set the plugin path correctly for configuration-time access e.g. by Extra CMake Modules
        $ctx->prependEnvironmentValue('QT_PLUGIN_PATH', "$prefix/lib64/plugins:$prefix/lib/plugins");
        $ctx->prependEnvironmentValue('XDG_DATA_DIRS', "$prefix/share");
    }

    my $qt_installdir = $module->getOption('qt-install-dir');
    if ($qt_installdir && $qt_installdir ne $prefix) {
        # Ensure we can find Qt5's own CMake modules
        $ctx->prependEnvironmentValue('CMAKE_PREFIX_PATH', $qt_installdir);
        $ctx->prependEnvironmentValue('CMAKE_MODULE_PATH', "$qt_installdir/lib/cmake");
    }
}

# This should return a list of executable names that must be present to
# even bother attempting to use this build system. An empty list should be
# returned if there's no required programs.
sub requiredPrograms
{
    my $self = shift;
    my $generator = $self->cmakeGenerator();
    my @required = @{$GENERATOR_MAP->{$generator}->{requiredPrograms}};
    return @required;
}

# Returns a list of possible build commands to run, any one of which should
# be supported by the build system.
sub buildCommands
{
    my $self = shift;
    my $generator = $self->cmakeGenerator();
    my @progs = @{$GENERATOR_MAP->{$generator}->{buildCommands}};
    return @progs;
}

sub configuredModuleFileName
{
    my $self = shift;
    return 'cmake_install.cmake';
}

sub runTestsuite
{
    my $self = assert_isa(shift, 'ksb::BuildSystem::KDECMake');
    my $module = $self->module();

    # Note that we do not run safe_make, which should really be called
    # safe_compile at this point.

    # Step 1: Ensure the tests are built, oh wait we already did that when we ran
    # CMake :)

    my $make_target = 'test';
    if ($module->getOption('run-tests') eq 'upload') {
        $make_target = 'Experimental';
    }

    info ("\tRunning test suite...");

    # Step 2: Run the tests.
    my $buildCommand = $self->defaultBuildCommand();
    my $numTests = 'Some'; # overwritten by a specific number, hopefully

    my $cmd = ksb::Util::LoggedSubprocess->new
        ->module($module)
        ->log_to('test-results')
        ->set_command([ $buildCommand, $make_target ]);

    $cmd->on(child_output => sub ($cmd, $line) {
        $numTests = $1
            if ($line =~ /([0-9]+) tests failed out of/);
    });

    my $result = await_exitcode($cmd->start);

    if (!$result) {
        my $logDir = $module->getLogDir();
        warning ("\t$numTests tests failed for y[$module], consult $logDir/test-results.log for info");
    } else {
        info ("\tAll tests ran successfully.");
    }

    return $result;
}

# Re-implementing the one in BuildSystem since in CMake we want to call
# make install/fast, so it only installs rather than building + installing
sub installInternal
{
    my $self = shift;
    my $module = $self->module();
    my @cmdPrefix = @_;
    my $generator = $self->cmakeGenerator();
    my $target = $GENERATOR_MAP->{$generator}->{installTarget};

    $target = 'install' if $module->getOption('custom-build-command');

    return $self->safe_make ({
            target => $target,
            message => "Installing g[$module]",
            'prefix-options' => [@cmdPrefix],
            logfile => 'install',
           })->{was_successful};
}

sub configureInternal
{
    my $self = assert_isa(shift, 'ksb::BuildSystem::KDECMake');
    my $module = $self->module();

    if ($module->getOption('generate-vscode-project-config')) {
        generateVSCodeConfig($module);
    } else {
        debug ("\tGenerating .vscode directory - disabled for this module");
    }

    # Use cmake to create the build directory (sh script return value
    # semantics).
    if ($self->_safe_run_cmake())
    {
        return 0;
    }

    # handle the linking of compile_commands.json back to source directory if wanted
    # allows stuff like clangd to function out of the box
    if ($module->getOption('compile-commands-linking')) {
        # symlink itself will keep existing files untouched!
        my $builddir = $module->fullpath('build');
        my $srcdir = $module->fullpath('source');
        if (-e "$builddir/compile_commands.json") {
            remake_symlink("$builddir/compile_commands.json", "$srcdir/compile_commands.json");
        }
    }

    return 1;
}

# Generate default config files for VSCode.
#
# This populates the settings VSCode needs to work with most KDE projects,
# such as C++ support, correct build directory, and LSP / IntelliSense.
sub generateVSCodeConfig
{
    if (pretending()) {
        pretend ("\tWould have generated .vscode directory");
        return;
    }
    
    my $module = shift;
    my $projectName = $module->name();
    my $buildDir = $module->fullpath('build');
    my $srcDir = $module->fullpath('source');
    my $installDir = $module->installationPath();
    my $libDir = $module->getOption('libname');
    my $configDir = "$srcDir/.vscode";

    if (-e $configDir) {
        if (-d $configDir) {
            debug ("\tGenerating .vscode directory - skipping as it already exists");
        } elsif (-f $configDir) {
            error ("\tGenerating .vscode directory - cannot proceed, file .vscode exists");
        }  
        return;
    } else {
        debug ("\tGenerating .vscode directory for $projectName: $configDir");
    }

    mkdir($configDir);

    use FindBin;
    my $baseDir = $FindBin::RealBin;
    my $dataDir = "$baseDir/data/vscode";

    # c_cpp_properties.json configures C++, CMake & IntelliSense.
    my $cCppPropertiesJson = _readFile("$dataDir/c_cpp_properties.json.in");

    # settings.json configures the paths for CMake, QML, Qt, etc.
    my $settingsJson = _readFile("$dataDir/settings.json.in");
    $settingsJson =~ s/\$buildDir/$buildDir/g;
    $settingsJson =~ s/\$installDir/$installDir/g;
    $settingsJson =~ s/\$libDir/$libDir/g;

    # extensions.json recommends extensions to install/enable.
    my $extensionsJson = _readFile("$dataDir/extensions.json.in");

    # launch.json configures the run with debugger functionality.
    my $launchJson = _readFile("$dataDir/launch.json.in");

    _writeToFile("$configDir/c_cpp_properties.json", $cCppPropertiesJson);
    _writeToFile("$configDir/settings.json", $settingsJson);
    _writeToFile("$configDir/extensions.json", $extensionsJson);
    _writeToFile("$configDir/launch.json", $launchJson);

    return 1;
}

# Reads the contents of a file.
# 
# Arguments:
#  $file_path: The path to the file to read.
# 
# Returns: The contents of the file as a string.
sub _readFile
{
    my ($file_path) = @_;

    my $content = "";  # Avoid lefting content uninitialized. We still need to return empty string in case file could not be opened.
    open my $file, '<', $file_path or warning("\tCouldn't open $file_path: $!");
    $content = do { local $/; <$file> };
    close $file;

    return $content;
}

# Writes content to a file.
# 
# Arguments:
#  $file_path: The path to the file to write to.
#  $content: The content to write to the file.
#
# Returns: Nothing.
sub _writeToFile
{
    my ($file_path, $content) = @_;

    open my $file, '>', $file_path or warning("\tCouldn't open $file_path: $!");
    print $file $content or warning("\tCouldn't write to $file_path: $!");
    close $file or warning("\tError closing $file_path: $!");
}

# Return value style: hashref to build results object (see ksb::BuildSystem::safe_make)
sub buildInternal
{
    my $self = shift;
    my $generator = $self->cmakeGenerator();
    my $defaultOptionsName = $GENERATOR_MAP->{$generator}->{optionsName};
    my $optionsName = shift // "$defaultOptionsName";

    return $self->SUPER::buildInternal($optionsName);
}

### Internal package functions.

# Subroutine to run CMake to create the build directory for a module.
# CMake is not actually run if pretend mode is enabled.
#
# First parameter is the module to run cmake on.
# Return value is the shell return value as returned by log_command().  i.e.
# 0 for success, non-zero for failure.
sub _safe_run_cmake
{
    my $self = shift;
    my $module = $self->module();
    my $generator = $self->cmakeGenerator();
    my $toolchain = $self->cmakeToolchain();
    my $srcdir = $module->fullpath('source');
    my @commands = split_quoted_on_whitespace ($module->getOption('cmake-options'));

    # grep out empty fields
    @commands = grep {!/^\s*$/} @commands;
    @commands = _stripGeneratorFromCMakeOptions(@commands);
    @commands = _stripToolchainFromCMakeOptions(@commands);

    unshift @commands, "-DCMAKE_TOOLCHAIN_FILE=$toolchain" if $toolchain ne '';

    # generate a compile_commands.json if requested for e.g. clangd tooling
    unshift @commands, "-DCMAKE_EXPORT_COMPILE_COMMANDS:BOOL=ON" if $module->getOption('compile-commands-export');

    # Add -DBUILD_foo=OFF options for the directories in do-not-compile.
    # This will only work if the CMakeLists.txt file uses macro_optional_add_subdirectory()
    my @masked_directories = split(' ', $module->getOption('do-not-compile'));
    push @commands, "-DBUILD_$_=OFF" foreach @masked_directories;

    # Get the user's CXXFLAGS, use them if specified and not already given
    # on the command line.
    my $cxxflags = $module->getOption('cxxflags');
    if ($cxxflags and not grep { /^-DCMAKE_CXX_FLAGS(:\w+)?=/ } @commands)
    {
        push @commands, "-DCMAKE_CXX_FLAGS:STRING=$cxxflags";
    }

    my $installdir = $module->installationPath();

    push @commands, "-DCMAKE_INSTALL_PREFIX=$installdir";

    # Add custom Qt to the prefix (but don't overwrite a user-set install-dir)
    my $qt_installdir = $module->getOption('qt-install-dir');
    if ($qt_installdir && $qt_installdir ne $installdir &&
        !grep { /^\s*-DCMAKE_PREFIX_PATH/ } (@commands)
       )
    {
        push @commands, "-DCMAKE_PREFIX_PATH=$qt_installdir";
    }

    if ($module->getOption('run-tests') &&
        !grep { /^\s*-DBUILD_TESTING(:BOOL)?=(ON|TRUE|1)\s*$/ } (@commands)
       )
    {
        whisper ("Enabling tests");
        push @commands, "-DBUILD_TESTING:BOOL=ON";
    }

    if ($module->getOption('run-tests') eq 'upload')
    {
        whisper ("Enabling upload of test results");
        push @commands, "-DBUILD_experimental:BOOL=ON";
    }

    unshift @commands, 'cmake', '-B', '.', '-S', $srcdir, '-G', $generator; # Add to beginning of list.

    my $old_options =
        $module->getPersistentOption('last-cmake-options') || '';
    my $builddir = $module->fullpath('build');

    if (($old_options ne get_list_digest(@commands)) ||
        $module->getOption('reconfigure') ||
        ! -e "$builddir/CMakeCache.txt" # File should exist only on successful cmake run
       )
    {
        info ("\tRunning g[cmake] targeting b[$generator]...");

        # Remove any stray CMakeCache.txt
        safe_unlink ("$srcdir/CMakeCache.txt")   if -e "$srcdir/CMakeCache.txt";
        safe_unlink ("$builddir/CMakeCache.txt") if -e "$builddir/CMakeCache.txt";

        $module->setPersistentOption('last-cmake-options', get_list_digest(@commands));

        # await_result, not await_exitcode, to match return semantic
        return await_result(run_logged_p($module, "cmake", $builddir, \@commands));
    }

    # Skip cmake run
    return 0;
}

1;
