package App::ArduinoBuilder::Builder;

use strict;
use warnings;
use utf8;

use App::ArduinoBuilder::Config 'get_os_name';
use App::ArduinoBuilder::DepCheck 'check_dep';
use App::ArduinoBuilder::FilePath 'find_all_files_with_extensions';
use App::ArduinoBuilder::System 'execute_cmd';
use File::Path 'make_path';
use File::Spec::Functions 'catfile';
use Parallel::TaskExecutor 'default_executor';
use Log::Any::Simple ':default';

my @supported_source_extensions = qw(c cpp S ino);

sub new {
  my ($class, $config) = @_;
  return bless {config => $config}, $class;
}

sub _run_recipe_pattern {
  # It’s undocumented but any recipe can have several patterns (and this is used
  # by several cores).
  my ($this, $recipe_name, %options) = @_;
  my $recipes = $this->{config}->filter("recipe.${recipe_name}");
  my $is_hook = $recipe_name =~ m/^hooks\./;
  my $nb_recipes = $recipes->size();
  unless ($is_hook || $nb_recipes) {
    error "Can’t find recipe for '${recipe_name}'";
    return;
  }
  debug "Running %d hook%s for '${recipe_name}", $nb_recipes, ($nb_recipes > 1 ? 's' : '') if $is_hook && $nb_recipes;
  for my $k (sort $recipes->keys()) {
    if ($options{is_objcopy}) {
      fatal "Invalid objcopy recipe name: recipe.${recipe_name}.${k}" unless $k =~ m/^\w+\.(?:\d+\.)?pattern$/;
    } elsif ($options{is_size}) {
     next unless $k =~ m/^(?:\d+\.)?pattern$/;
    } else {
      fatal "Invalid recipe name: recipe.${recipe_name}.${k}" unless $k =~ m/^(?:\d+\.)?pattern$/;
    }
    execute_cmd($recipes->get($k, base => $this->{config}, %options), %options);
  }
  return;
}

# This method is a very partial implementation of:
# https://arduino.github.io/arduino-cli/0.32/sketch-build-process/#pre-processing
# TODO: theoritically all .ino and .pde (?) files should be concatenated together
# rather than processed separately.
# TODO: we need to generate prototypes for the methods, using ctags.
sub _ino_to_cpp {
  my ($this, $source, $target) = @_;

  open my $fi, '<', "${source}" or fatal "Can’t open the source file '${source}' for reading: $!";
  open my $fo, '>', "${target}.cpp-pre" or fatal "Can’t open intermediate file '${target}.cpp-pre' for writing: $!";
  print $fo "#include \"Arduino.h\"\n";
  print $fo "#line 1 \"${source}\"\n";
  while (my $l = <$fi>) {
    print $fo $l;
  }
  close $fi;
  close $fo;

  my $recipe;
  # This is ugly but we can’t easily do this elsewhere because we need the recipe command to be
  # resolved to be able to find the MMD flag, but we can’t resolve it too early because some
  # parameters will vary with each call.
  if (!$this->{config}->exists('recipe.preproc.macros')) {
    # We assume that this recipe exists (and that it’s not a multi-pattern recipe).
    $recipe = $this->{config}->get('recipe.cpp.o.pattern', allow_partial => 1, with => {source_file => "${target}.cpp-pre", object_file => "${target}.cpp"});
    # This is a heuristic because the '-x c++' must appear before the source file, so we can’t put
    # it at the end. It’s unclear how this command is actually generated by the Arduino GUI.
    $recipe =~ s/(?<=\s)-MMD\b/-w -x c++ -E -CC/ or fatal "Can’t modify the recipe.cpp.o.pattern command: ${recipe}";
  } else {
    # It’s weird but this is not a "pattern" recipe. Why?
    $recipe = $this->{config}->get('recipe.preproc.macros', with => {source_file => "${target}.cpp-pre", preprocessed_file_path => "${target}.cpp"});
  }
  execute_cmd($recipe);

  # TODO: there is a step in the real Arduino tool that is badly documented but which uses ctags to extract the
  # prototype of the functions in the .ino files (and adds them at the beginning of the C++ file), so that the
  # functions can be called before they are declared.
  # TODO: if the correct null output is needed again, move this line to System.pm
  # my $null = get_os_name() eq 'windows' ? 'nul' : '/dev/null';
  return;
}

# dirname($target) must already exist.
sub build_file {
  my ($this, $source, $target) = @_;
  my @ext = $source =~ m/\.([^.]+)$/;
  fatal "File '$source' has no recognizable extension." unless @ext;
  my $ext = $ext[0];
  if ($ext eq 'ino') {
    debug "Converting '${source}' to C++";
    $this->_ino_to_cpp($source, $target);
    $source = $target.'.cpp';
    $ext = 'cpp';
  }
  debug "Building '${source}'";
  $this->_run_recipe_pattern("${ext}.o", with => {source_file => $source, object_file => $target});
  return;
}

sub _add_to_archive {
  my ($this, $object_file, $archive) = @_;
  $this->_run_recipe_pattern('ar', with => {object_file => $object_file, archive_file => $archive, archive_file_path => catfile($this->{config}->get('build.path'), $archive)});
  return;
}

# Object to avoid collision in generated object file names. Both because we
# flatten the directory structure of the sources that we build and also because
# archive files may only reference the file names.
package ObjectNameBuilder {
  use File::Basename 'fileparse';
  use File::Spec::Functions 'catfile';

  sub new {
    my ($class, $target_dir) = @_;
    return bless {target_dir => $target_dir, files => {}}, $class;
  }

  sub object_for {
    my ($this, $source) = @_;
    my $basename = fileparse($source);
    my $count = $this->{files}{$basename}++;
    $basename .= ".${count}" if $count;
    return catfile($this->{target_dir}, "${basename}.o");
  }
}

# target_dir has all the intermediate file, $archive is a file name that goes in build.path.
sub build_archive {
  my ($this, $source_dirs, $target_dir, $archive, $force) = @_;
  make_path($target_dir);
  my $obj_name = ObjectNameBuilder->new($target_dir);
  my @tasks;
  for my $d (@{$source_dirs}) {
    # BUG: There is still a bug here (and in build_object_files) which is that if a file is removed
    # from the sources and there is another file with the same basename, we will do weird things
    # with the object files that will be mixed. This is unavoidable for now, the project needs to be
    # cleaned when files are deleted.
    my @sources = sort (find_all_files_with_extensions($d, [@supported_source_extensions]));
    for my $s (@sources) {
      my $object_file = $obj_name->object_for($s);
      # This line can be commented and the one inside the sub-task can be
      # uncommented to execute the dependency check inside the forked process.
      # However this does not seem to improve the execution speed.
      next unless $force || check_dep($s, $object_file);
      push @tasks, default_executor()->run(
        sub {
          # return unless $force || check_dep($s, $object_file);
          $this->build_file($s, $object_file);
          return $object_file;
        });
    }
  }
  default_executor->wait();
  my $did_something;
  for my $t (@tasks) {
    my $o = $t->data();
    if ($o) {
      $this->_add_to_archive($o, $archive);
      $did_something = 1;
    }
  }
  return $did_something;
}

# Like in find_all_files_with_extensions, $source_dir can be a single directory (scalar)
# or an array-ref of directories.
# excluded_dirs must be an array-ref.
sub build_object_files {
  my ($this, $source_dir, $target_dir, $excluded_dirs, $force, $no_recurse) = @_;
  make_path($target_dir);
  my @sources = sort (find_all_files_with_extensions($source_dir, [@supported_source_extensions], $excluded_dirs, $no_recurse));
  my $obj_name = ObjectNameBuilder->new($target_dir);
  my @tasks;
  for my $s (@sources) {
    my $object_file = $obj_name->object_for($s);
    next unless $force || check_dep($s, $object_file);
    push @tasks, default_executor()->run(
      sub {
        # return unless $force || check_dep($s, $object_file);
        $this->build_file($s, $object_file);
        return 1;
      });
  }
  default_executor->wait();
  for my $t (@tasks) {
    my $o = $t->data();
    if ($o) {
      return 1;
    }
  }
  return 0;
}

sub link_executable {
  my ($this, $object_files, $archive) = @_;
  $this->_run_recipe_pattern('c.combine', with => {object_files => '"'.join('" "', @{$object_files}).'"', archive_file => $archive, archive_file_path => catfile($this->{config}->get('build.path'), $archive)});
  return;
}

sub compute_binary_size {
  my ($this) = @_;
  my $output;
  $this->_run_recipe_pattern('size', capture_output => \$output, is_size => 1);
  # TODO: There is a variant using the 'advanced_size' recipe that can be
  # implemented for more complex scenario and that we are not yet supporting.

  my $bin_size_re = $this->{config}->get('recipe.size.regex', default => undef);
  if ($bin_size_re) {
    my $bin_size = 0;
    while ($output =~ m/${bin_size_re}/mg) {
      $bin_size += $1;
    }
    my $max_bin_size = $this->{config}->get('upload.maximum_size', default => undef);
    if ($max_bin_size) {
      info '  Sketch uses %d bytes (%d%%) of program space. Maximum is %d bytes.', $bin_size, ($bin_size * 100 / $max_bin_size), $max_bin_size;
      fatal 'Sketch is too large' if $bin_size > $max_bin_size;
    } else {
      info '  Sketch uses %d bytes of program space.', $bin_size;
    }
  }

  my $data_size_re =$this->{config}->get('recipe.size.regex.data', default => undef);
  if ($data_size_re) {
    my $data_size = 0;
    while ($output =~ m/${data_size_re}/mg) {
      $data_size += $1;
    }
    my $max_data_size = $this->{config}->get('upload.maximum_data_size', default => undef);
    if ($max_data_size) {
      info '  Global variables use %d bytes (%d%%) of dynamic memory, leaving %d bytes for local variables. Maximum is %d bytes.', $data_size, ($data_size * 100 / $max_data_size), ($max_data_size - $data_size), $max_data_size;
      fatal 'Too much memory used' if $data_size > $max_data_size;
    } else {
      info '  Global variables use %d bytes of dynamic memory.', $data_size;
    }
  }

  return;
}

sub run_hook {
  my ($this, $hook_name) = @_;
  $this->_run_recipe_pattern("hooks.${hook_name}");
  return;
}

sub objcopy {
  my ($this) = @_;
  $this->_run_recipe_pattern('objcopy', is_objcopy => 1);
}

1;
