package OLLM::Executor;

use v5.30;
use strict;
use warnings;

use Cwd qw(abs_path);
use Errno qw(EAGAIN EWOULDBLOCK);
use Fcntl qw(:flock);
use File::Basename qw(dirname);
use File::Copy qw(copy);
use File::Path qw(make_path);
use File::Spec;
use OLLM::BuildFile;
use OLLM::Path;
use OLLM::State;
use OLLM::Version qw($VERSION);

# Lines OLLM writes into its generated TeX files that change with every
# attempt without changing the document: the attempt's own generation id in
# the build file, and generation id and generation paths in the registry.
our $VOLATILE_LINE =
    '^\s*(?:generation-id|generation)=\{[0-9a-f]{64}\},?\s*$'
  . '|^\s*(?:aux|pdf)=\{[^{}]*generations.[0-9a-f]{64}.[^{}]*\},?\s*$';

sub execute {
  my ($class, %arg) = @_;
  my $resolved = $arg{resolved} // die "missing resolved build request";
  my $latexmk_rc = $arg{latexmk_rc};
  die "latexmk configuration not found: $latexmk_rc"
    if defined($latexmk_rc) && !-f $latexmk_rc;
  my $request = $resolved->{request};
  if ($request->{context} eq 'standalone') {
    return $class->_execute_standalone(
      $resolved, $latexmk_rc, $arg{runner},
    );
  }
  my @specs = $resolved->{build_specs}
    ? @{ $resolved->{build_specs} }
    : $resolved->{build_spec}
      ? ($resolved->{build_spec})
      : die "resolved request contains no concrete build specifications";
  $class->validate_request($resolved);
  my $action = _latexmk_action($request->{latexmk_args});
  @specs = ($specs[0]) if $action eq 'information';

  for my $spec (@specs) {
    die "source file not found: $spec->{source}" if !-f $spec->{source};
    my $lock;
    if ($action ne 'information') {
      _prepare_build_directory($spec);
      $lock = _acquire_lock($spec);
      if (!$lock) {
        print STDERR "ollm: build '$spec->{job_id}' is already active in "
          . "$spec->{build_directory}\n";
        return 1;
      }
    }
    if ($action eq 'build') {
      OLLM::State->start_attempt($spec);
      OLLM::BuildFile->write_for_spec($spec);
    }
    my @command = $class->command_for_spec($spec, $request, $latexmk_rc);
    my $separator = $^O eq 'MSWin32' ? ';' : ':';
    local $ENV{TEXINPUTS} = join $separator,
      $spec->{build_directory},
      $spec->{shared_tex_directory},
      ($ENV{TEXINPUTS} // '');
    # biber looks up databases it cannot find locally through kpsewhich,
    # i.e. BIBINPUTS; shared .bib files live next to projectconfig.tex.
    local $ENV{BIBINPUTS} = join $separator,
      $spec->{shared_tex_directory},
      ($ENV{BIBINPUTS} // '');
    my $status;
    if ($arg{runner}) {
      $status = $arg{runner}->(\@command, $spec);
    } else {
      $status = system { $command[0] } @command;
    }
    if ($status == -1) {
      print STDERR "ollm: cannot start latexmk: $!\n" if !$arg{runner};
      return 69;
    }
    my $signal = $status & 127;
    if ($signal) {
      print STDERR "ollm: latexmk terminated by signal $signal\n"
        if !$arg{runner} && $signal != 2;
      return 128 + $signal;
    }
    my $exit_code = $status >> 8;
    return 1 if $exit_code != 0;
    die "latexmk reported success but artifact is missing: $spec->{artifact}"
      if $action eq 'build' && !$arg{runner}
        && (!-f $spec->{artifact} || !-s _);
    if ($action eq 'build' && !$arg{runner}) {
      my $outcome = OLLM::State->attempt_outcome($spec);
      if ($outcome eq 'current') {
        print "ollm: $spec->{job_id} is up to date\n";
        next;
      }
      if ($outcome eq 'unpromoted') {
        # latexmk considers the output current, but it belongs to an
        # attempt that never reached the promoted state.  Build it again
        # so that result and state agree.
        die "build '$spec->{job_id}' produced no result for this attempt\n"
          if $spec->{force_run};
        $spec->{force_run} = 1;
        redo;
      }
      if (($spec->{target} // '') eq 'handout') {
        my $result = OLLM::State->read_result($spec);
        my $layout = $result->{handout_layout} // '';
        $class->_impose_handout(
          $spec, $layout, $result->{handout_tagging}, $latexmk_rc, $arg{runner},
        ) if $layout ne '' && $layout ne '1 on 1';
      }
      OLLM::State->promote($spec);
    }
  }
  return 0;
}

# A handout build whose active projectconfig.tex declared a layout (see
# osglecture.dtx's mode/handout setup area and DESIGN.md's "N-up imposition"
# note) is not the deliverable on its own -- it is the source PDF for a
# second, independent compile. This runs that compile and repoints
# $spec->{artifact} at its output, so the one promote() call right after
# still publishes exactly one artifact per spec, just the imposed one
# instead of the plain one. A failure here aborts the whole build rather
# than silently publishing the plain PDF: the author asked for the imposed
# layout, so a plain fallback would quietly ignore that request.
#
# Which compile that is depends on $tagging_active (osglecture's
# tag_if_active: report from the primary build, forwarded via
# \OsgLectureHandoutResult): tagpax needs a tagged source PDF to import --
# a profile that forbids \DocumentMetadata (e.g. beamer) or an author who
# has tagging switched off must not lose N-up handouts over that, so this
# falls back to plain pdfpages imposition instead of erroring. That
# fallback cannot offer the 'ruled' note strip -- BuildFile rejects it
# with a clear message rather than silently dropping it.
sub _impose_handout {
  my ($class, $spec, $layout, $tagging_active, $latexmk_rc, $runner) = @_;
  my $source = $tagging_active
    ? OLLM::BuildFile->write_handout_layout_for_spec($spec, $layout)
    : OLLM::BuildFile->write_handout_layout_plain_for_spec($spec, $layout);
  my $job_id = OLLM::BuildFile->handout_layout_job_id($spec);
  my $engine = join ' ',
    'lualatex', _shell_escape_flag('off'), '--synctex=1',
    '--interaction=nonstopmode', '--halt-on-error', '%O', '%P';
  my @command = (
    'latexmk',
    '-norc',
    (defined($latexmk_rc) ? ('-r', $latexmk_rc) : ()),
    '-lualatex',
    '-recorder',
    '-cd',
    "-jobname=$job_id",
    "-outdir=$spec->{build_directory}",
    "-auxdir=$spec->{build_directory}",
    "-pdflualatex=$engine",
    $source,
  );
  my $status = $runner ? $runner->(\@command, $spec) : system { $command[0] } @command;
  die "ollm: cannot start latexmk for handout imposition: $!\n" if $status == -1;
  my $signal = $status & 127;
  die "ollm: latexmk (handout imposition) terminated by signal $signal\n"
    if $signal;
  die "handout imposition failed for layout '$layout' (job '$job_id')\n"
    if ($status >> 8) != 0;
  my $imposed = File::Spec->catfile($spec->{build_directory}, "$job_id.pdf");
  _replace_artifact_with_imposed($spec, $imposed) if !$runner;
}

# Overwrites the canonical artifact path in place -- not just $spec's
# in-memory reference to it -- so every consumer that finds the handout PDF
# by its well-known <job-id>.pdf path (a human browsing the build directory,
# not only promote()'s generation copy) sees the imposed layout too.
# Anything reading $spec->{artifact} afterwards keeps working unchanged
# since the path itself never changes, only its content.
sub _replace_artifact_with_imposed {
  my ($spec, $imposed) = @_;
  die "latexmk reported success but imposed handout artifact is missing: "
    . "$imposed\n"
    if !-f $imposed || !-s _;
  copy($imposed, $spec->{artifact})
    or die "cannot replace handout artifact '$spec->{artifact}' with "
      . "imposed PDF '$imposed': $!\n";
}

sub validate_request {
  my ($class, $resolved) = @_;
  my $request = $resolved->{request}
    // die "resolved build request has no request data";
  my $build_count = $request->{context} eq 'standalone' ? 1
    : $resolved->{build_specs}
    ? scalar @{ $resolved->{build_specs} }
    : $resolved->{build_spec} ? 1 : 0;
  die "resolved request contains no concrete build specifications"
    if !$build_count;
  if ($request->{context} eq 'standalone') {
    die "resolved standalone request has no execution specification"
      if !$resolved->{standalone_spec};
    _validate_latexmk_args($request, 1, standalone => 1);
    return 1;
  }
  my $path_separator = $^O eq 'MSWin32' ? ';' : ':';
  my @specs = $resolved->{build_specs}
    ? @{ $resolved->{build_specs} }
    : ($resolved->{build_spec});
  for my $spec (@specs) {
    for my $field (qw(source shared_tex_directory build_directory
                      aux_directory artifact)) {
      my $path = $spec->{$field};
      die "BuildSpec $field path contains a line break: $path\n"
        if $path =~ /[\r\n]/;
    }
    die "build directory '$spec->{build_directory}' contains the TEXINPUTS "
      . "path separator '$path_separator' and cannot be represented safely\n"
      if index($spec->{build_directory}, $path_separator) >= 0;
    die "shared TeX directory '$spec->{shared_tex_directory}' contains the "
      . "TEXINPUTS path separator '$path_separator' and cannot be represented "
      . "safely\n"
      if index($spec->{shared_tex_directory}, $path_separator) >= 0;
  }
  _validate_latexmk_args($request, $build_count);
  return 1;
}

sub _acquire_lock {
  my ($spec) = @_;
  my $path = File::Spec->catfile($spec->{build_directory}, '.ollm.lock');
  open my $handle, '>>', $path
    or die "cannot open build lock '$path': $!\n";
  if (!flock($handle, LOCK_EX | LOCK_NB)) {
    return if $! == EAGAIN || $! == EWOULDBLOCK;
    die "cannot lock build state '$path' on this platform: $!\n";
  }
  seek $handle, 0, 0;
  truncate $handle, 0
    or die "cannot update build lock '$path': $!\n";
  print {$handle} "$$\n"
    or die "cannot write build lock '$path': $!\n";
  return $handle;
}

sub _prepare_build_directory {
  my ($spec) = @_;
  my $root = abs_path($spec->{project_root})
    // die "project root not found: $spec->{project_root}\n";
  my $ancestor = $spec->{build_directory};
  while (!-e $ancestor) {
    my $parent = dirname($ancestor);
    die "cannot find an existing ancestor for build directory "
      . "'$spec->{build_directory}'\n"
      if $parent eq $ancestor;
    $ancestor = $parent;
  }
  my $canonical_ancestor = abs_path($ancestor)
    // die "cannot resolve build-directory ancestor '$ancestor'\n";
  _require_within(
    $canonical_ancestor, $root, 'existing build-directory ancestor',
  );
  make_path($spec->{build_directory});
  my $canonical_build = abs_path($spec->{build_directory})
    // die "cannot resolve build directory '$spec->{build_directory}'\n";
  _require_within($canonical_build, $root, 'build directory');
}

sub _require_within {
  my ($path, $root, $label) = @_;
  OLLM::Path::require_within(
    $path, $root, "$label '$path' resolves outside project root '$root'",
  );
}

my %SHELL_ESCAPE_FLAG = (
  off        => '--no-shell-escape',
  restricted => '--shell-restricted',
  full       => '--shell-escape',
);

sub _shell_escape_flag {
  my ($policy) = @_;
  return $SHELL_ESCAPE_FLAG{$policy}
    // die "unknown shell-escape policy '$policy'";
}

sub command_for_spec {
  my ($class, $spec, $request, $latexmk_rc) = @_;
  my $shell = _shell_escape_flag($spec->{shell_escape});
  my $engine = join ' ',
    'lualatex',
    $shell,
    '--synctex=1',
    '--interaction=nonstopmode',
    '--halt-on-error',
    '%O',
    '%P';
  my $build_file = File::Spec->catfile(
    $spec->{build_directory}, "$spec->{job_id}.osgbuild.tex",
  );
  my $manifest = $spec->{project_manifest}
    // die "BuildSpec has no project manifest";
  for my $entry (
    ['build-file', $build_file], ['project-manifest', $manifest],
  ) {
    die "$entry->[0] path contains unsafe TeX filename characters: $entry->[1]"
      if $entry->[1] =~ /[{}%#\r\n]/;
  }
  $build_file =~ s{\\}{/}g;
  $manifest =~ s{\\}{/}g;
  my $pretex = "\\edef\\OSGLectureProjectManifestFile"
    . "{\\detokenize{$manifest}}"
    . "\\edef\\OSGLectureJobFile{\\detokenize{$build_file}}";
  for my $symbol (
    ['RequestedTarget', $spec->{target}],
    ['RequestedDoctype', $spec->{doctype}],
    ['RequestedProfileClass', $spec->{profile_class}],
    ['RequestedDocumentMetadataPolicy', $spec->{document_metadata_policy}],
    ['RequestedLanguage', $spec->{language}],
  ) {
    my ($name, $value) = @$symbol;
    die "build value '$value' cannot be represented safely in metadata pre-TeX"
      if !defined($value) || $value !~ /\A[A-Za-z0-9._+-]+\z/;
    $pretex .= "\\def\\OsgLecture$name\{$value}";
  }
  if ($spec->{document_metadata}) {
    my $path = $spec->{document_metadata}{path};
    die "document metadata path contains unsafe TeX filename characters: $path"
      if $path =~ /[{}%#\r\n]/;
    $path =~ s{\\}{/}g;
    $pretex .= "\\input{\"$path\"}";
  }
  return (
    'latexmk',
    '-norc',
    (defined($latexmk_rc) ? ('-r', $latexmk_rc) : ()),
    '-lualatex',
    '-recorder',
    '-cd',
    "-jobname=$spec->{job_id}",
    "-outdir=$spec->{build_directory}",
    "-auxdir=$spec->{aux_directory}",
    "-pdflualatex=$engine",
    # -pretex (not -usepretex) only sets the %U substitution; -usepretex
    # additionally calls latexmk's alt_tex_cmds, which unconditionally
    # resets *every* engine command template (including -pdflualatex set
    # above) to a bare "%O %P" -- silently discarding --halt-on-error and
    # the other engine flags.
    "-pretex=$pretex",
    # Every attempt gets a fresh generation id, and the registry names the
    # promoted generations by id and path.  For a unit these lines must not
    # count as a source change, or no build would ever be up to date; the
    # registry's "% export" lines carry the real dependency.  Integration
    # documents include the promoted PDFs themselves and keep the old
    # behaviour: they are rebuilt on every request.
    (($spec->{unit_role} // '') ne 'i'
      ? ('-e', '$hash_calc_ignore_pattern{tex} = q!' . $VOLATILE_LINE . '!;')
      : ()),
    ($request->{rebuild} || $spec->{force_run} ? ('-gg') : ()),
    @{ $request->{latexmk_args} // [] },
    $spec->{source},
  );
}

sub _validate_latexmk_args {
  my ($request, $build_count, %arg) = @_;
  my $arguments = $request->{latexmk_args} // [];
  my $action = _latexmk_action($arguments);
  my ($continuous, $continuous_option, $preview, $preview_option, $print);
  my $view = 'default';
  for my $argument (@{ $arguments // [] }) {
    my $option = $argument;
    $option =~ s/\A--/-/;
    die "latexmk option '$argument' is not allowed because additional rc "
      . "files or startup Perl code could override OLLM's engine, paths, "
      . "and security policy\n"
      if $argument =~ /\A--?(?:r|e)(?:=|\z)/;
    die "latexmk option '$argument' selects a non-LuaLaTeX engine or output "
      . "mode; osglecture builds are LuaLaTeX-to-PDF only\n"
      if $argument =~ /\A--?(?:
          dvi(?:lua)?-? | hnt | pdf(?:dvi|ps|xe)?-? |
          ps-? | xdv-? | xelatex
        )\z/x
        || $argument =~ /\A--?(?:
          dvilualatex | latex | output-format | pdflatex | pdfxelatex
        )=/x;
    die "latexmk option '$argument' is not allowed yet because '-out2dir' "
      . "would move the final artifact outside the BuildSpec path; a future "
      . "multi-artifact contract may add explicit support\n"
      if !$arg{standalone} && $argument =~ /\A--?out2dir(?:=|\z)/;
    die "latexmk option '$argument' is not allowed because OLLM, rather than "
      . "latexmk's use-make fallback, owns orchestration of missing inputs "
      . "and dependent builds\n"
      if $argument =~ /\A--?use-?make\z/;
    die "latexmk option '$argument' is not allowed because it can inject "
      . "unvalidated options into the controlled LuaLaTeX command\n"
      if $argument =~ /\A--?latexoption(?:=|\z)/;
    die "latexmk option '$argument' conflicts with OLLM's required source "
      . "working directory\n"
      if $argument =~ /\A--?cd-\z/;
    die "latexmk option '$argument' conflicts with OLLM's required recorder "
      . "dependency data\n"
      if $argument =~ /\A--?recorder-\z/;
    die "latexmk option '$argument' conflicts with the controlled build contract\n"
      if $argument =~ /\A--?(?:
          jobname | (?:pdf)?lualatex | recorder | cd | e
        )(?:=|\z)/x
      || !$arg{standalone} && $argument =~ /\A--?(?:
          auxdir | aux-directory | outdir | output-directory
        )(?:=|\z)/x;
    die "latexmk option '$argument' is not allowed because OLLM owns the "
      . "pre-TeX hook used for project metadata\n"
      if $argument =~ /\A--?(?:pretex|usepretex)(?:=|\z)/;
    die "latexmk option '$argument' may not override shell-escape policy\n"
      if $argument =~ /shell-(?:escape|restricted)/;
    if ($option eq '-cc') {
      ($continuous, $continuous_option, $preview, $view)
        = (1, $argument, 0, 'none');
    } elsif ($option eq '-pvc') {
      ($continuous, $continuous_option, $preview, $preview_option)
        = (1, $argument, 1, $argument);
    } elsif ($option eq '-pvc-') {
      $continuous = 0;
    } elsif ($option eq '-pv') {
      ($continuous, $preview, $preview_option) = (0, 1, $argument);
    } elsif ($option eq '-pv-') {
      $preview = 0;
    } elsif ($option =~ /\A-view=(default|dvi|hnt|none|ps|pdf)\z/) {
      $view = $1;
    } elsif ($option eq '-p') {
      $print = 1;
    }
  }
  if ($action ne 'build' && $request->{rebuild}) {
    die "--rebuild requests a document build, but latexmk option "
      . "'" . _action_option($arguments) . "' requests $action instead; "
      . "remove one of the conflicting options\n";
  }
  if ($action eq 'build' && $request->{rebuild} && $continuous) {
    die "--rebuild cannot be combined with latexmk continuous mode "
      . "'$continuous_option' because latexmk disables force mode when "
      . "continuous preview starts; run one rebuild first, then restart "
      . "OLLM in continuous mode\n";
  }
  if ($action eq 'build' && $request->{rebuild}
      && grep { /\A--?g-\z/ } @$arguments) {
    die "--rebuild cannot be combined with latexmk option '-g-', which "
      . "turns off the rebuild mode requested by OLLM\n";
  }
  if ($action eq 'information' && $build_count > 1) {
    die "the selected build scope requests multiple builds, but latexmk option "
      . "'" . _action_option($arguments) . "' only reports information; "
      . "restrict the build to one projection\n";
  }
  if ($action eq 'information') {
    my @selection = grep { $request->{"${_}_explicit"} }
      qw(target language source);
    if (@selection) {
      die "latexmk option '" . _action_option($arguments) . "' only reports "
        . "information, so the explicit OLLM "
        . join('/', @selection)
        . " selection would not describe a build; remove that selection or "
        . "run latexmk directly\n";
    }
  }
  if ($action ne 'build' && ($continuous || $preview || $print)) {
    die "latexmk option '" . _action_option($arguments) . "' requests "
      . "$action instead of a build and cannot be combined with watch, "
      . "viewer, or print options\n";
  }
  if ($build_count > 1 && $continuous) {
    die "--all cannot be combined with latexmk continuous mode "
      . "'$continuous_option': the first build would keep running and later "
      . "target/language builds would never start; select one build or omit "
      . "$continuous_option\n";
  }
  if ($request->{non_interactive} && $preview && $view ne 'none') {
    die "--non-interactive cannot start the latexmk viewer requested by "
      . "'$preview_option'; use '-view=none' for continuous compilation "
      . "without a viewer, or omit $preview_option\n";
  }
  if ($request->{non_interactive} && $print) {
    die "--non-interactive cannot be combined with latexmk option '-p', "
      . "which starts an external print action\n";
  }
}

sub _execute_standalone {
  my ($class, $resolved, $latexmk_rc, $runner) = @_;
  $class->validate_request($resolved);
  my $request = $resolved->{request};
  my $spec = $resolved->{standalone_spec};
  my $engine = join ' ',
    'lualatex', _shell_escape_flag($spec->{shell_escape}),
    '--synctex=1', '--interaction=nonstopmode', '--halt-on-error', '%O', '%S';
  my @command = (
    'latexmk', '-norc',
    (defined($latexmk_rc) ? ('-r', $latexmk_rc) : ()),
    '-lualatex', '-recorder', '-cd', "-pdflualatex=$engine",
    ($request->{rebuild} ? ('-gg') : ()),
    @{ $request->{latexmk_args} // [] },
    $spec->{source},
  );
  my $status = $runner
    ? $runner->(\@command, $spec)
    : system { $command[0] } @command;
  if ($status == -1) {
    print STDERR "ollm: cannot start latexmk: $!\n" if !$runner;
    return 69;
  }
  my $signal = $status & 127;
  if ($signal) {
    print STDERR "ollm: latexmk terminated by signal $signal\n"
      if !$runner && $signal != 2;
    return 128 + $signal;
  }
  return ($status >> 8) == 0 ? 0 : 1;
}

sub _latexmk_action {
  my ($arguments) = @_;
  my ($clean, $information);
  for my $argument (@{ $arguments // [] }) {
    $clean = 1 if $argument =~ /\A--?(?:c|C|CA)\z/;
    $information = 1
      if $argument =~ /\A--?(?:commands|dir-report-only|h|help|v|version)\z/;
  }
  return 'information' if $information;
  return 'clean' if $clean;
  return 'build';
}

sub _action_option {
  my ($arguments) = @_;
  my @action = grep {
    /\A--?(?:c|C|CA|commands|dir-report-only|h|help|v|version)\z/
  } @{ $arguments // [] };
  return $action[-1] // '<unknown>';
}

1;
