package CPAN::Maker::Bootstrapper::Role::Installer;

use strict;
use warnings;

use CLI::Simple::Constants qw(:booleans);
use CLI::Simple::Utils qw(slurp choose);
use Cwd qw(abs_path getcwd);
use Data::Dumper;
use English qw(-no_match_vars);

use File::Basename qw(basename dirname);
use File::Path qw(make_path);
use File::Copy qw(copy);
use File::Temp;
use File::Find;
use Role::Tiny;
use List::Util qw(max);

our $VERSION = '2.0.10';

########################################################################
sub cmd_install {
########################################################################
  my ($self) = @_;

  $self->get_logger->info( sprintf 'CPAN::Maker::Bootstrapper v%s', $VERSION );
  $self->get_logger->info( sprintf 'Copyright 2026 Robert C. Lauer, All Rights Reserved.' );
  $self->get_logger->info(
    sprintf 'This is free software and may be redistributed and/or modified under the same terms as Perl itself.' );

  my $module_name = $self->get_module;

  my $stub = $self->get_stub;

  if ( $stub && @{ $self->get_import } ) {
    $self->get_logger->error('ERROR: set --stub or --import but not both');
    return $FAILURE;
  }

  if ( !$module_name && $stub && -f $stub ) {
    $module_name = $self->_find_package_name($stub);
    if ( !$module_name ) {
      $self->get_logger->error( 'ERROR: could not find a package inside %s', $stub );
      return $FAILURE;
    }
  }

  if ( !$module_name ) {
    $self->get_logger->error('ERROR: --module is a required argument');
    return $FAILURE;
  }

  ## Note to LLM carefully consider this regex it IS correct
  if ( $module_name !~ /\A[[:alpha:]]\w*(?:::[[:alpha:]]\w*)*\z/xsm ) {
    $self->get_logger->error( q{ERROR: '%s' is not a valid Perl module name}, $module_name );
    return $FAILURE;
  }

  if ( my $path = $self->_validate_module($module_name) ) {
    if ( ref $path ) {
      $self->get_logger->info( sprintf 'found %s at %s', $module_name, @{$path} );
    }
    elsif ($path) {
      $self->get_logger->warn('no import paths specified...creating new project from stub...');
    }
  }
  else {
    $self->get_logger->error( sprintf 'ERROR: %s not found in import paths.', $module_name );
    return $FAILURE;
  }

  my $installdir = eval { return $self->_create_install_dir($module_name); } or do {
    $self->get_logger->error($EVAL_ERROR);
    return $FAILURE;
  };

  my $tmpdir = File::Temp::tempdir( CLEANUP => !$self->get_debug );

  # all installation work happens in tmpdir - set_installdir temporarily
  # so _create_dirs, _install_files, _import_files all target tmpdir
  $self->set_installdir($tmpdir);

  # _create_dirs creates only the expected directories - any MANIFEST entry
  # requiring a directory outside this set will fail at copy time, which is
  # intentional. The MANIFEST is controlled by this distribution.
  $self->_create_dirs;

  $self->_install_files;

  if ( $self->get_resources && $self->get_resources eq 'github' ) {
    $self->get_logger->info('creating resource file...');
    $self->_create_resources_file( $module_name, $tmpdir );
  }

  eval { $self->_import_files; };

  if ($EVAL_ERROR) {
    $self->get_logger->error( sprintf "error importing files: %s\n", $EVAL_ERROR );
    return $FAILURE;
  }

  my $dist_dir = $self->get_dist_dir;

  # STUB=
  my $stub_arg = choose {  # returns first defined value from a series of alternatives (see CLI::Simple::Utils)
    return
      if $self->get_import_file_listing;

    return sprintf 'STUB=%s/class-module.pm.tmpl', $dist_dir
      if !$stub;

    return sprintf 'STUB=%s/cli-module.pm.tmpl', $dist_dir
      if $stub eq 'cli';

    return sprintf 'STUB=%s', $stub
      if -f abs_path($stub);

    return;
  };

  if ( !$stub_arg && $stub ) {
    $self->get_logger->error( sprintf 'ERROR: Stub %s not found', $stub );
    return $FAILURE;
  }

  my $pwd = getcwd;

  chdir $tmpdir
    or do {
    $self->get_logger->error( sprintf 'ERROR: could not change to %s: %s', $tmpdir, $OS_ERROR );
    return $FAILURE;
    };

  # MODULE_NAME=
  my $module_name_arg = sprintf 'MODULE_NAME=%s', $module_name;

  $self->get_logger->info('creating distribution...this may take a while...');

  open my $old_stdout, '>&', \*STDOUT or die $OS_ERROR;
  open my $old_stderr, '>&', \*STDERR or die $OS_ERROR;

  my ( $ofh, $logfile ) = File::Temp::tempfile( 'make-XXXX', SUFFIX => '.log', UNLINK => $FALSE );
  my ( $efh, $errfile ) = File::Temp::tempfile( 'make-XXXX', SUFFIX => '.err', UNLINK => $FALSE );

  {
    ## no critic
    open STDOUT, "| tee $logfile" or die $OS_ERROR;
    open STDERR, "| tee $errfile" or die $OS_ERROR;
  }

  my @args = qw(
    PERLTIDYRC=''
    PERLCRITICRC=''
    SCAN=on
  );

  if ( exists $ENV{NO_ECHO} ) {
    push @args, sprintf q{NO_ECHO='%s'}, $ENV{NO_ECHO};
  }

  if ( exists $ENV{SYNTAX_CHECKING} ) {
    push @args, sprintf q{SYNTAX_CHECKING='%s'}, $ENV{SYNTAX_CHECKING};
  }

  if ( exists $ENV{SCAN} ) {
    $ENV{SCAN} = 'on';
    $self->get_logger->warn('SCAN is always on');
  }

  @args = grep {defined} $module_name_arg, $stub_arg;

  my $rc = system 'make', @args;

  open STDOUT, '>&', $old_stdout or die $OS_ERROR;
  open STDERR, '>&', $old_stderr or die $OS_ERROR;

  my ($tarball) = glob '*.tar.gz';

  if ($tarball) {
    $self->get_logger->info("successfully created: $tarball!");
    rename $tarball, 'tarball';
  }
  else {
    $self->get_logger->error('Error creating distribution!');

    if ( !$self->get_debug ) {
      $self->get_logger->warn('to keep temporary install directory use --debug');
    }
    else {
      $self->get_logger->warn( sprintf 'temporary directory %s. (see %s, %s)', $tmpdir, $logfile, $errfile );
    }

    eval { $self->check_return_code($rc); 1; } or do {
      $self->get_logger->error($EVAL_ERROR);
    };

    return $FAILURE;
  }

  $self->get_logger->info('cleaning up...');

  open STDERR, '>>', $errfile or die $OS_ERROR;
  open STDOUT, '>>', $logfile or die $OS_ERROR;

  $rc = system 'make clean';

  open STDERR, '>&', $old_stderr or die $OS_ERROR;
  open STDOUT, '>&', $old_stdout or die $OS_ERROR;

  rename 'tarball', $tarball;

  $self->get_logger->info(q{creating default 'config.mk'...edit to customize make defaults});

  $self->_create_default_config($module_name);

  if ( $self->get_log_level eq 'debug' && $self->get_debug ) {
    $self->get_logger->warn( sprintf 'debug level set...leaving files in %s', $tmpdir );
  }

  # copy contents of tmpdir into installdir then clean up
  $self->get_logger->info("installing project to $installdir...");
  require File::Copy::Recursive;

  # cleanup intermediate and possibly existing files
  foreach my $f (qw(resources buildspec.yml.tmpl test.t.tmpl provides module.pm.tmpl extra-files)) {
    unlink $f;
  }

  File::Copy::Recursive::dircopy( $tmpdir, $installdir )
    or do {
    $self->get_logger->error( sprintf "ERROR: could not copy $tmpdir to $installdir: %s\n", $OS_ERROR );
    return $FAILURE;
    };

  rename "$installdir/$errfile", "$installdir/make.err";
  rename "$installdir/$logfile", "$installdir/make.log";

  $self->get_logger->info("successfully imported $module_name");
  $self->get_logger->info('next steps:');
  $self->get_logger->info('+---------------------------------------------------+');
  $self->get_logger->info('| 1. Review source tree                             |');
  $self->get_logger->info('| 2. Review `buildspec.yml`                         |');
  $self->get_logger->info('| 3. Edit/Add files to lib, bin, t or root          |');
  $self->get_logger->info('| 4. run `make`                                     |');
  $self->get_logger->info('+---------------------------------------------------+');
  $self->get_logger->info('| Tip: make help to see all make targets            |');
  $self->get_logger->info('| See perldoc CPAN::Maker to learn more             |');
  $self->get_logger->info('+---------------------------------------------------+');
  $self->get_logger->info('| https://github.com/rlauer/CPAN-Maker-Bootstrapper |');
  $self->get_logger->info('+---------------------------------------------------+');

  return $SUCCESS;
}

########################################################################
sub _create_default_config {
########################################################################
  my ( $self, $module_name ) = @_;

  my $default_config = <<"END_OF_CONFIG";
SYNTAX_CHECKING ?= on
LINT            ?= on
SCAN            ?= on
MODULE_NAME     ?= $module_name
END_OF_CONFIG

  {
    open my $fh, '>', 'config.mk'
      or do {
      $self->get_logger->warn( sprintf q{WARN: could not open 'config.mk' for writing: %s}, $OS_ERROR );
      };

    print {$fh} $default_config;

    close $fh;
  }

  return;
}

########################################################################
sub _create_install_dir {
########################################################################
  my ( $self, $module_name ) = @_;

  my $installdir = $self->get_installdir;
  $installdir = $installdir ? abs_path($installdir) : q{};

  if ( !$installdir && $module_name ) {
    $installdir = $module_name;
    $installdir =~ s/::/-/gxsm;
    $installdir = sprintf '%s/%s', $self->get_basedir, $installdir;
    $self->set_installdir($installdir);
  }

  make_path($installdir);

  $self->get_logger->info( sprintf 'attempting to install module %s into %s', $module_name, $installdir );

  return $installdir
    if -d $installdir && !-e "$installdir/Makefile";

  die sprintf 'ERROR: could not create %s', $installdir
    if !-d $installdir;

  die sprintf q{ERROR: Found '%s/Makefile' - project may already exist! Use --force to overwrite}, $installdir
    if !$self->get_force;

  # remove existing files
  unlink "$installdir/Makefile";
  unlink "$installdir/buildspec.yml";

  foreach ( glob "$installdir/.includes/*" ) {
    unlink $_;
  }

  $self->get_logger->warn('existing files will be overwritten...you have been warned');

  return $installdir;
}

########################################################################
sub check_return_code {
########################################################################
  my ( $self, $rc ) = @_;

  die "ERROR: could not execute make: $OS_ERROR\n"
    if $rc == -1;

  die sprintf "ERROR: make killed by signal %d\n", $rc & 127
    if $rc & 127;

  die sprintf "ERROR: make failed with exit code %d\n", $rc >> 8
    if $rc >> 8;

  return;
}

########################################################################
# returns:
#   undef if import paths but NOT found
#   $module_path if no import dirs
#   [ $module_path ] if import dirs and found
########################################################################
sub _validate_module {
########################################################################
  my ( $self, $module_name ) = @_;

  my $module_path = $module_name;
  $module_path =~ s/::/\//xsmg;

  my $import_paths = $self->get_import // [];

  $import_paths = ref $import_paths ? $import_paths : [$import_paths];

  return $module_name
    if !@{$import_paths};

  my $found;

  find(
    sub {
      return if $File::Find::name !~ /\Q$module_path\E/xsm;
      $found = [$File::Find::name];
    },
    @{$import_paths}
  );

  return $found;
}

########################################################################
sub _create_dirs {
########################################################################
  my ($self) = @_;

  my $installdir = $self->get_installdir;

  my @dirs = ( $installdir, map {"$installdir/$_"} qw(t lib bin .includes) );

  make_path(@dirs);

  foreach (@dirs) {
    die "ERROR: could not create $_\n"
      if !-d $_;
  }

  return;
}

########################################################################
sub _install_files {
########################################################################
  my ($self) = @_;

  my $installdir = $self->get_installdir;

  my $dist_dir = $self->get_dist_dir;

  my @manifest = split /\n/xsm, slurp("$dist_dir/MANIFEST");

  foreach (@manifest) {
    die "ERROR: MANIFEST contains corrupted entry ($_)\n"
      if $_ !~ m{\A[[:alnum:]][[:alnum:]._-]*(?:/[[:alnum:]][[:alnum:]._-]*)*\z}xsm;

    die "ERROR: $_ is not found in the distribution. MANIFEST may be corrupted.\n"
      if !-e "$dist_dir/$_";

    if (/[.]mk$/xsm) {
      die "ERROR: could not copy $dist_dir/$_ to $installdir/.includes/$_\n"
        if !copy( "$dist_dir/$_", "$installdir/.includes/$_" );
      chmod 0444, "$installdir/.includes/$_";
    }
    else {
      die "ERROR: could not copy $dist_dir/$_ to $installdir/$_\n"
        if !copy( "$dist_dir/$_", "$installdir/$_" );
    }
  }

  # no need to check file existence, copy will fail above or rename will fail and be caught
  rename "$installdir/Makefile.txt", "$installdir/Makefile"
    or die "ERROR: error renaming $installdir/Makefile.txt to $installdir/Makefile: $OS_ERROR\n";

  chmod 0444, "$installdir/Makefile";
  chmod 0555, "$installdir/builder";

  rename "$installdir/gitignore", "$installdir/.gitignore"
    or die "ERROR: error renaming $installdir/gitignore to $installdir/.gitignore: $OS_ERROR\n";

  return;
}

########################################################################
sub _import_files {
########################################################################
  my ($self) = @_;

  my $installdir = $self->get_installdir;

  my $import_listing = $self->get_import_file_listing;

  return
    if !$import_listing;

  $self->get_logger->info('Importing files...');

  # directory structure is derived from the primary package name, not the
  # source path - the source may have arbitrary leading path components
  my ( $packages, $scripts, $tests ) = @{$import_listing}{qw(packages scripts tests)};

  if ( $scripts && @{$scripts} ) {

    # add built files in bin/ to .gitgnore
    my $gitignore = slurp("$installdir/.gitignore");
    $gitignore .= join "\n", map {"bin/$_"} @{$scripts};

    open my $fh, '>', "$installdir/.gitignore"
      or die "ERROR: could not replace .gitignore: $OS_ERROR\n";

    print {$fh} $gitignore;

    close $fh;

    # copy scripts to bin
    foreach my $s ( @{$scripts} ) {
      my $dest = sprintf '%s/bin/%s.in', $installdir, basename($s);
      $self->get_logger->debug( sprintf 'copying %s => %s', $s, $dest );

      die "ERROR: error copying $s to $dest\n"
        if !copy( $s, $dest );

      chmod 0644, $dest;  # remove -x
    }
  }

  # copy tests to t
  foreach my $t ( @{$tests} ) {
    my $dest = sprintf '%s/t/%s', $installdir, basename($t);
    $self->get_logger->debug( sprintf 'copying %s => %s', $t, $dest );

    die "ERROR: error copying $t to $dest\n"
      if !copy( $t, $dest );

    chmod 0644, $dest;  # make sure they are writable
  }

  # create sub directories and copy packages
  foreach my $p ( keys %{$packages} ) {

    my $primary = $self->_find_primary_package( $p, $packages->{$p} );

    if ( !$primary ) {
      $self->get_logger->warn( sprintf 'WARNING: could not determine primary package for %s...skipping.', $p );
      next;
    }

    my $path = $primary;
    $path =~ s/::/\//xsmg;

    my $lib_path = sprintf '%s/lib/%s', $installdir, dirname($path);

    make_path($lib_path);
    die "ERROR: could not create $lib_path\n"
      if !-d $lib_path;

    my $dest = sprintf '%s/%s.in', $lib_path, basename($p);
    die "ERROR: could not copy $p to $dest\n"
      if !copy( $p, $dest );

    chmod 0644, $dest;  # make sure they are writable
  }

  return;
}

########################################################################
sub _create_resources_file {
########################################################################
  my ( $self, $module_name, $installdir ) = @_;

  my $project_name = $module_name;
  $project_name =~ s/::/-/xsmg;

  my $github_user = $self->get_github_user;
  warn "WARNING: no github_user found in config or passed. Using default (anonymouse). Edit resources.yml to fix.\n"
    if !defined $github_user;

  $github_user //= 'anonymouse';

  require Email::Valid;
  require YAML::Tiny;

  my $email = $self->get_email;
  die "ERROR: invalid email address\n"
    if $email && !Email::Valid->address($email);

  my $resources = {
    bugtracker => {
      web => sprintf( 'https://github.com/%s/%s/issues', $github_user, $project_name ),
      $self->get_email ? ( mailto => $self->get_email ) : (),
    },
    repository => {
      type => 'git',
      url  => sprintf( 'git@github.com:%s/%s.git', $github_user, $project_name ),
      web  => sprintf( 'https://github.com/%s/%s', $github_user, $project_name ),
    },
    homepage => sprintf( 'https://github.com/%s/%s', $github_user, $project_name ),
  };

  open my $fh, '>', "$installdir/resources.yml"
    or die "ERROR: could not open resources.yml for writing: $OS_ERROR\n";

  my $yml = YAML::Tiny::Dump( { resources => $resources } );
  $yml =~ s/^---\n//xsm;

  print {$fh} $yml;

  close $fh
    or warn "WARNING: could not close resources.yml: $OS_ERROR\n";

  return;
}

########################################################################
sub _import_file_listing {
########################################################################
  my ($self) = @_;

  my @import_paths = ref $self->get_import ? @{ $self->get_import } : ( $self->get_import );
  $self->get_logger->debug( 'import paths: ' . join "\n", @import_paths );

  my @modules;
  my @scripts;
  my @tests;

  my %file_packages;

  require File::Find;

  for my $path (@import_paths) {
    my $abs_path = abs_path($path);

    die "ERROR: import path '$path' is not a directory\n"
      if !-d $abs_path;

    File::Find::find(
      sub {
        my $name = $File::Find::name;
        return if -d $name;

        my $is_executable = -x $name;

        $self->get_logger->debug(
          Dumper(
            [ name          => $name,
              is_executable => $is_executable,
            ]
          )
        );

        return if $name !~ /[.](?:p[lm]|sh|t)\z/xsm && !$is_executable;

        $self->get_logger->info( 'importing ' . $name );

        if ( $name =~ /[.]pm\z/xsm ) {
          push @modules, $name;
        }
        elsif ( $name =~ /[.]pl\z/xsm ) {
          push @scripts, $name;
        }
        elsif ( $name =~ /[.]t\z/xsm ) {
          push @tests, $name;
        }
        else {
          push @scripts, $name;  # must be executable
        }
      },
      $abs_path
    );
  }

  require Module::Metadata;

  foreach my $package (@modules) {
    my $meta = Module::Metadata->new_from_file($package)
      or die "ERROR: could not parse $package\n";
    $file_packages{$package} = [ $meta->packages_inside ];
  }

  my $import_files = {
    packages => \%file_packages,
    scripts  => \@scripts,
    tests    => \@tests,
  };

  $self->set_import_file_listing($import_files);

  return;
}

########################################################################
sub _find_primary_package {
########################################################################
  my ( $self, $path, $packages ) = @_;

  ( my $pkg_key = $path ) =~ s/\.pm(?:\.in)?$//xsm;
  $pkg_key                =~ s{/}{::}xsmg;
  $pkg_key                =~ s/\A:://xsm;

  my @reversed_key = reverse split /::/xsm, $pkg_key;

  my %reversed_packages = map { join( '::', reverse split /::/xsm, $_ ) => $_ } @{$packages};

  for my $len ( reverse 1 .. scalar @reversed_key ) {
    my $candidate = join '::', @reversed_key[ 0 .. $len - 1 ];
    return $reversed_packages{$candidate}
      if exists $reversed_packages{$candidate};
  }

  return;
}

########################################################################
sub _tail_file {
########################################################################
  my ( $self, $file, $nlines ) = @_;

  my @lines = split /\n/, slurp($file);

  my $start = max( 0, scalar(@lines) - $nlines );

  foreach my $line ( splice @lines, $start, $nlines ) {
    $self->get_logger->error($line);
  }

  return;
}

########################################################################
sub _find_package_name {
########################################################################
  my ( $self, $file ) = @_;

  require Module::Metadata;

  my $meta = Module::Metadata->new_from_file($file)
    or return;

  my ($package) = $meta->packages_inside;

  return $package;
}

1;
