# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements.  See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License.  You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

use strict;
use warnings;

package Clownfish::CFC::Perl::Build;
use base qw( Module::Build );
our $VERSION = '0.006003';
$VERSION = eval $VERSION;

use File::Spec::Functions qw( catdir catfile curdir updir abs2rel rel2abs );
use File::Path qw( mkpath );
use Config;
use Carp;

# Add a custom Module::Build hashref property to pass additional build
# parameters
if ( $Module::Build::VERSION <= 0.30 ) {
    __PACKAGE__->add_property( clownfish_params => {} );
}
else {
    # TODO: add sub for property check
    __PACKAGE__->add_property(
        'clownfish_params',
        default => {},
    );
}

# Rationale

# When the distribution tarball for the Perl bindings is built, core/, and any
# other needed files/directories are copied into the perl/ directory within the
# main source directory.  Then the distro is built from the contents of the
# perl/ directory, leaving out all the files in ruby/, etc. However, during
# development, the files are accessed from their original locations.

my $IS_CPAN_DIST = -d 'core' || -d 'cfcore';
my @BASE_PATH;
push(@BASE_PATH, updir()) unless $IS_CPAN_DIST;

my $AUTOGEN_DIR  = 'autogen';
my $LIB_DIR      = 'lib';
my $BUILDLIB_DIR = 'buildlib';

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

    # TODO: use Charmonizer to determine whether pthreads are userland.
    if ( $Config{osname} =~ /openbsd/i && $Config{usethreads} ) {
        my $extra_ldflags = $self->extra_linker_flags;
        push @$extra_ldflags, '-lpthread';
        $self->extra_linker_flags(@$extra_ldflags);
    }

    my $autogen_header = $self->clownfish_params('autogen_header');
    if ( !defined($autogen_header) ) {
        $self->clownfish_params( autogen_header => <<'END_AUTOGEN' );
***********************************************

!!!! DO NOT EDIT !!!!

This file was auto-generated by Build.PL.

***********************************************
END_AUTOGEN
    }

    return $self;
}

# Return the list of Clownfish include dirs. Uses directories from
# - Module::Build parameter
# - Environment variable CLOWNFISH_INCLUDE
# - Clownfish include directories found in @INC
# Note that this function must be called at build time because dependencies
# might not be installed yet at configure time.
sub cf_include_dirs {
    my $self = shift;

    my $cf_include = $self->clownfish_params('include');
    my @dirs;
    @dirs = ref($cf_include) ? @$cf_include : ( $cf_include )
        if defined($cf_include);

    # Add include dirs from CLOWNFISH_INCLUDE environment variable.
    if ($ENV{CLOWNFISH_INCLUDE}) {
        push( @dirs, split( /:/, $ENV{CLOWNFISH_INCLUDE} ) );
    }

    # Add include dirs from @INC.
    for my $dir (@INC) {
        my $cf_incdir = catdir( $dir, 'Clownfish', '_include' );
        push( @dirs, $cf_incdir ) if -d $cf_incdir;
    }

    return @dirs;
}

# Return the list of Clownfish C include dirs.
# Note that this function must be called at build time because dependencies
# might not be installed yet at configure time.
sub cf_c_include_dirs {
    my $self = shift;

    my $include_dirs = $self->include_dirs;
    my @dirs;
    @dirs = ref($include_dirs) ? @$include_dirs : ( $include_dirs )
        if defined($include_dirs);

    push( @dirs,
        curdir(), # for ppport.h
        catdir( $AUTOGEN_DIR, 'include' ),
        $self->cf_include_dirs,
    );

    return @dirs;
}

sub cf_base_path {
    my $self_or_class = shift;
    return @BASE_PATH;
}

sub cf_linker_flags {
    my $self = shift;

    my $dlext = $Config{dlext};
    # Only needed on Windows
    return () if $dlext !~ /dll$/i;

    # Link against import library on MSVC
    my $ext = $Config{cc} =~ /^cl\b/ ? 'lib' : $dlext;

    my @linker_flags;

    for my $module_name (@_) {
        # Find library to link against
        my @module_parts = split( '::', $module_name );
        my $class_name   = $module_parts[-1];
        my $lib_file;
        my $found;

        for my $dir ( catdir( $self->blib, 'arch' ), @INC ) {
            $lib_file = catfile(
                $dir, 'auto', @module_parts, "$class_name.$ext",
            );
            if ( -f $lib_file ) {
                $found = 1;
                last;
            }
        }

        die("No Clownfish library file found for module $module_name")
            if !$found;

        push( @linker_flags, $lib_file );
    }

    return @linker_flags;
}

sub _cfh_filepaths {
    my $self = shift;
    my @paths;
    my $source_dirs = $self->clownfish_params('source');
    for my $source_dir (@$source_dirs) {
        next unless -e $source_dir;
        push @paths, @{ $self->rscan_dir( $source_dir, qr/\.cf[hp]$/ ) };
    }
    return \@paths;
}

sub cf_copy_include_file {
    my ($self, @path) = @_;

    my $dest_dir     = catdir( $self->blib, 'arch', 'Clownfish', '_include' );
    for my $include_dir ($self->cf_c_include_dirs) {
        my $file = catfile ( $include_dir, @path );
        if ( -e $file ) {
            $self->copy_if_modified(
                from => $file,
                to   => catfile( $dest_dir, @path ),
            );
            return;
        }
    }
    die( "Clownfish include file " . catfile(@path) . " not found" );
}

my %hierarchy_cache;

sub _compile_clownfish {
    my $self = shift;

    require Clownfish::CFC::Model::Hierarchy;
    require Clownfish::CFC::Binding::Perl;
    require Clownfish::CFC::Binding::Perl::Class;

    return @hierarchy_cache{ qw( hierarchy binding ) }
        if %hierarchy_cache;

    # Compile Clownfish.
    my $hierarchy = Clownfish::CFC::Model::Hierarchy->new(
        dest => $AUTOGEN_DIR,
    );
    my $source_dirs  = $self->clownfish_params('source');
    for my $source_dir (@$source_dirs) {
        $hierarchy->add_source_dir($source_dir);
    }
    for my $include_dir ($self->cf_include_dirs) {
        $hierarchy->add_include_dir($include_dir);
    }
    $hierarchy->build;
    $hierarchy->read_host_data_json;

    # Process all Binding classes in buildlib.
    local @INC = ( @INC, '.' );
    my $pm_filepaths = $self->rscan_dir( $BUILDLIB_DIR, qr/\.pm$/ );
    for my $pm_filepath (@$pm_filepaths) {
        next unless $pm_filepath =~ /Binding/;
        require $pm_filepath;
        my $package_name = $pm_filepath;
        $package_name =~ s/buildlib\/(.*)\.pm$/$1/;
        $package_name =~ s/\//::/g;
        $package_name->bind_all($hierarchy);
    }

    my $binding = Clownfish::CFC::Binding::Perl->new(
        hierarchy  => $hierarchy,
        lib_dir    => $LIB_DIR,
        header     => $self->clownfish_params('autogen_header'),
        footer     => '',
    );

    @hierarchy_cache{ qw( hierarchy binding ) } = ( $hierarchy, $binding );

    return ( $hierarchy, $binding );
}

# Deprecated.
sub ACTION_pod {
    my $self = shift;
    $self->depends_on('clownfish');
}

sub ACTION_clownfish {
    my $self = shift;

    $self->add_to_cleanup($AUTOGEN_DIR);

    my $modules = $self->clownfish_params('modules');
    my @xs_filepaths;

    for my $module (@$modules) {
        my @module_dir  = split( '::', $module->{name} );
        my $class_name  = pop(@module_dir);
        push @xs_filepaths, catfile( $LIB_DIR, @module_dir, "$class_name.xs" );
    }

    my $buildlib_pm_filepaths = $self->rscan_dir( $BUILDLIB_DIR, qr/\.pm$/ );
    my $cfh_filepaths = $self->_cfh_filepaths;
    my $log_filepath = catfile( $AUTOGEN_DIR, 'hierarchy.json' );

    # Don't bother parsing Clownfish files if everything's up to date.
    return
        if $self->up_to_date(
        [ @$cfh_filepaths, @$buildlib_pm_filepaths ],
        [ @xs_filepaths,   $log_filepath, ]
        );

    # Write out all autogenerated files.
    print "Parsing Clownfish files...\n";
    my ( $hierarchy, $perl_binding ) = $self->_compile_clownfish;
    require Clownfish::CFC::Binding::Core;
    my $core_binding = Clownfish::CFC::Binding::Core->new(
        hierarchy => $hierarchy,
        header    => $self->clownfish_params('autogen_header'),
        footer    => '',
    );
    print "Writing Clownfish autogenerated files...\n";
    my $inc_dir = catdir( $self->blib, 'arch', 'Clownfish', '_include' );
    my $cfh_modified = $core_binding->write_all_modified;
    if ($cfh_modified) {
        unlink('typemap');
        print "Writing typemap...\n";
        $self->add_to_cleanup('typemap');
        $perl_binding->write_xs_typemap;
        $core_binding->copy_headers($inc_dir);
    }

    # Rewrite XS if either any .cfh files or relevant .pm files were modified.
    my $buildlib_modified = ! $self->up_to_date(
        \@$buildlib_pm_filepaths, [ @xs_filepaths, $log_filepath ]
    );

    if ( $cfh_modified || $buildlib_modified ) {
        $self->add_to_cleanup(@xs_filepaths);
        $perl_binding->write_host_code;
        $perl_binding->write_hostdefs;

        for my $module (@$modules) {
            $perl_binding->write_bindings(
                boot_class => $module->{name},
                parcels    => $module->{parcels},
            );
        }

        $core_binding->write_host_data_json($inc_dir);

        unless ($IS_CPAN_DIST) {
            print "Writing POD...\n";
            my $pod_files = $perl_binding->write_pod;
            $self->add_to_cleanup($_) for @$pod_files;
        }
    }

    # Touch autogenerated files in case the modifications were inconsequential
    # and didn't trigger a rewrite, so that we won't have to check them again
    # next pass.
    for my $xs_filepath (@xs_filepaths) {
        if (!$self->up_to_date(
                [ @$cfh_filepaths, @$buildlib_pm_filepaths ], $xs_filepath
            )
            )
        {
            utime( time, time, $xs_filepath );    # touch
        }
    }

    $hierarchy->write_log;
}

# Write ppport.h, which supplies some XS routines not found in older Perls and
# allows us to use more up-to-date XS API while still supporting Perls back to
# 5.8.3.
#
# The Devel::PPPort docs recommend that we distribute ppport.h rather than
# require Devel::PPPort itself, but ppport.h isn't compatible with the Apache
# license.
sub ACTION_ppport {
    my $self = shift;
    if ( !-e 'ppport.h' ) {
        require Devel::PPPort;
        $self->add_to_cleanup('ppport.h');
        Devel::PPPort::WriteFile();
    }
}

sub ACTION_compile_custom_xs {
    my $self = shift;

    $self->depends_on('ppport');

    # Make sure all parcels are read and registered.
    $self->_compile_clownfish();

    my $modules = $self->clownfish_params('modules');
    for my $module (@$modules) {
        $self->_compile_custom_xs($module);
    }
}

sub _compile_custom_xs {
    my ( $self, $module ) = @_;

    require ExtUtils::CBuilder;
    require ExtUtils::ParseXS;

    my $module_name  = $module->{name};
    my @module_parts = split( '::', $module_name );
    my @module_dir   = @module_parts;
    my $class_name   = pop(@module_dir);

    my $cbuilder = ExtUtils::CBuilder->new( config => $self->config );
    my $libdir   = catdir( $LIB_DIR, @module_dir );
    my $archdir  = catdir( $self->blib, 'arch', 'auto', @module_parts );
    mkpath( $archdir, 0, 0777 ) unless -d $archdir;
    my @objects;

    # Make objects.
    if ($module->{make_target}) {
        my $make_objects = $self->cf_make_objects($module->{make_target});
        push @objects, @$make_objects;
    }

    # Compile C source files.
    my $c_files = [];
    my $source_dirs = $module->{c_source_dirs} || [];
    for my $source_dir (@$source_dirs) {
        push @$c_files, @{ $self->rscan_dir( $source_dir, qr/\.c$/ ) };
    }
    my $autogen_src_dir = catdir( $AUTOGEN_DIR, 'source' );
    for my $parcel_name ( @{ $module->{parcels} } ) {
        my $parcel = Clownfish::CFC::Model::Parcel->acquire($parcel_name);
        my $prefix = $parcel->get_prefix;
        # Assume *_parcel.c is built with make.
        push @$c_files, catfile( $autogen_src_dir, "${prefix}parcel.c" )
            if !$module->{make_target};
        push @$c_files, catfile( $autogen_src_dir, "${prefix}perl.c" );
    }
    my $extra_cflags = $self->clownfish_params('cflags');
    for my $c_file (@$c_files) {
        my $o_file   = $c_file;
        my $ccs_file = $c_file;
        $o_file   =~ s/\.c$/$Config{_o}/ or die "no match";
        $ccs_file =~ s/\.c$/.ccs/        or die "no match";
        push @objects, $o_file;
        next if $self->up_to_date( $c_file, $o_file );
        $self->add_to_cleanup($o_file);
        $self->add_to_cleanup($ccs_file);
        $cbuilder->compile(
            source               => $c_file,
            extra_compiler_flags => $extra_cflags,
            include_dirs         => [ $self->cf_c_include_dirs ],
            object_file          => $o_file,
        );
    }

    # .xs => .c
    my $xs_filepath         = catfile( $libdir, "$class_name.xs" );
    my $perl_binding_c_file = catfile( $libdir, "$class_name.c" );
    $self->add_to_cleanup($perl_binding_c_file);
    if ( !$self->up_to_date( $xs_filepath, $perl_binding_c_file ) ) {
        ExtUtils::ParseXS::process_file(
            filename   => $xs_filepath,
            prototypes => 0,
            output     => $perl_binding_c_file,
        );
    }

    # .c => .o
    my $version = $self->dist_version;
    my $perl_binding_o_file = catfile( $libdir, "$class_name$Config{_o}" );
    unshift @objects, $perl_binding_o_file;
    $self->add_to_cleanup($perl_binding_o_file);
    if ( !$self->up_to_date( $perl_binding_c_file, $perl_binding_o_file ) ) {
        # Don't use Clownfish compiler flags for XS
        $cbuilder->compile(
            source               => $perl_binding_c_file,
            extra_compiler_flags => $self->extra_compiler_flags,
            include_dirs         => [ $self->cf_c_include_dirs ],
            object_file          => $perl_binding_o_file,
            # 'defines' is an undocumented parameter to compile(), so we
            # should officially roll our own variant and generate compiler
            # flags.  However, that involves writing a bunch of
            # platform-dependent code, so we'll just take the chance that this
            # will break.
            defines => {
                VERSION    => qq|"$version"|,
                XS_VERSION => qq|"$version"|,
            },
        );
    }

    # Create .bs bootstrap file, needed by Dynaloader.
    my $bs_file = catfile( $archdir, "$class_name.bs" );
    $self->add_to_cleanup($bs_file);
    if ( !$self->up_to_date( $perl_binding_o_file, $bs_file ) ) {
        require ExtUtils::Mkbootstrap;
        ExtUtils::Mkbootstrap::Mkbootstrap($bs_file);
        if ( !-f $bs_file ) {
            # Create file in case Mkbootstrap didn't do anything.
            open( my $fh, '>', $bs_file )
                or confess "Can't open $bs_file: $!";
        }
        utime( (time) x 2, $bs_file );    # touch
    }

    # Clean up after CBuilder under MSVC.
    $self->add_to_cleanup('compilet*');
    $self->add_to_cleanup('*.ccs');
    $self->add_to_cleanup( catfile( $libdir, "$class_name.ccs" ) );
    $self->add_to_cleanup( catfile( $libdir, "$class_name.def" ) );
    $self->add_to_cleanup( catfile( $libdir, "${class_name}_def.old" ) );
    $self->add_to_cleanup( catfile( $libdir, "$class_name.exp" ) );
    $self->add_to_cleanup( catfile( $libdir, "$class_name.lib" ) );
    $self->add_to_cleanup( catfile( $libdir, "$class_name.lds" ) );
    $self->add_to_cleanup( catfile( $libdir, "$class_name.base" ) );

    # .o => .(a|bundle)
    my $lib_file = catfile( $archdir, "$class_name.$Config{dlext}" );
    if ( !$self->up_to_date( [ @objects, $AUTOGEN_DIR ], $lib_file ) ) {
        my $linker_flags = $self->extra_linker_flags;
        my @xs_prereqs = $self->_cf_find_xs_prereqs($module);
        push @$linker_flags, $self->cf_linker_flags(@xs_prereqs);
        $cbuilder->link(
            module_name        => $module_name,
            objects            => \@objects,
            lib_file           => $lib_file,
            extra_linker_flags => $linker_flags,
        );
        # Install .lib file on Windows
        # TODO: Install .dll.a when building with GCC on Windows?
        my $implib_file = catfile( $libdir, "$class_name.lib" );
        if ( -e $implib_file ) {
            $self->copy_if_modified(
                from => $implib_file,
                to   => catfile( $archdir, "$class_name.lib" ),
            );
        }
    }
}

sub _cf_find_xs_prereqs {
    my ( $self, $module ) = @_;

    my $modules = $self->clownfish_params('modules');
    my @xs_prereqs;

    for my $parcel_name ( @{ $module->{parcels} } ) {
        my $parcel = Clownfish::CFC::Model::Parcel->acquire($parcel_name);

        for my $prereq_parcel ( @{ $parcel->prereq_parcels } ) {
            my $prereq_module;

            if ($prereq_parcel->included) {
                $prereq_module = $prereq_parcel->get_xs_module;
            }
            else {
                my $prereq_parcel_name = $prereq_parcel->get_name;

                for my $candidate (@$modules) {
                    my @matches = grep {
                        $_ eq $prereq_parcel_name
                    } @{ $candidate->{parcels} };

                    if (@matches) {
                        $prereq_module = $candidate->{name};
                        last;
                    }
                }
            }

            die("No XS module found for parcel $parcel_name")
                if !defined($prereq_module);
            push @xs_prereqs, $prereq_module
                if $prereq_module ne $module->{name}
                   && !grep { $_ eq $prereq_module } @xs_prereqs;
        }
    }

    return @xs_prereqs;
}

sub ACTION_code {
    my $self = shift;

    $self->depends_on(qw(
        clownfish
        compile_custom_xs
    ));

    $self->SUPER::ACTION_code;
}

sub ACTION_clean {
    my $self = shift;

    if ( $self->can('cf_make_clean') ) {
        $self->cf_make_clean();
    }

    $self->SUPER::ACTION_clean;
}

# Monkey patch ExtUtils::CBuilder::Platform::Windows::GCC::format_linker_cmd
# to make extensions work on MinGW.
#
# nwellnhof: The original ExtUtils::CBuilder implementation uses dlltool and a
# strange incremental linking scheme. I think this is only needed for ancient
# versions of GNU ld. It somehow breaks exporting of symbols via
# __declspec(dllexport). Starting with version 2.17, one can pass .def files
# to GNU ld directly, which requires only a single command and gets the
# exports right.
{
    no warnings 'redefine';
    require ExtUtils::CBuilder::Platform::Windows::GCC;
    *ExtUtils::CBuilder::Platform::Windows::GCC::format_linker_cmd = sub {
      my ($self, %spec) = @_;
      my $cf = $self->{config};

      # The Config.pm variable 'libperl' is hardcoded to the full name
      # of the perl import library (i.e. 'libperl56.a'). GCC will not
      # find it unless the 'lib' prefix & the extension are stripped.
      $spec{libperl} =~ s/^(?:lib)?([^.]+).*$/-l$1/;

      unshift( @{$spec{other_ldflags}}, '-nostartfiles' )
        if ( $spec{startup} && @{$spec{startup}} );

      # From ExtUtils::MM_Win32:
      #
      ## one thing for GCC/Mingw32:
      ## we try to overcome non-relocateable-DLL problems by generating
      ##    a (hopefully unique) image-base from the dll's name
      ## -- BKS, 10-19-1999
      File::Basename::basename( $spec{output} ) =~ /(....)(.{0,4})/;
      $spec{image_base} = sprintf( "0x%x0000", unpack('n', $1 ^ $2) );

      %spec = $self->write_linker_script(%spec)
        if $spec{use_scripts};

      foreach my $path ( @{$spec{libpath}} ) {
        $path = "-L$path";
      }

      my @cmds; # Stores the series of commands needed to build the module.

      # split off any -arguments included in ld
      my @ld = split / (?=-)/, $spec{ld};

      push @cmds, [ grep {defined && length} (
        @ld                       ,
        '-o', $spec{output}       ,
        "-Wl,--image-base,$spec{image_base}" ,
        @{$spec{lddlflags}}       ,
        @{$spec{libpath}}         ,
        @{$spec{startup}}         ,
        @{$spec{objects}}         ,
        @{$spec{other_ldflags}}   ,
        $spec{libperl}            ,
        @{$spec{perllibs}}        ,
        $spec{def_file}           ,
        $spec{map_file} ? ('-Map', $spec{map_file}) : ''
      ) ];

      return @cmds;
    };
}

1;

__END__