package OLLM::BuildFile;

use v5.30;
use strict;
use warnings;

use Cwd qw(abs_path);
use Digest::SHA qw(sha256_hex);
use File::Basename qw(basename dirname);
use File::Path qw(make_path);
use File::Spec;
use File::Temp qw(tempfile);
use JSON::PP;
use OLLM::Path;
use OLLM::Version qw($VERSION);
our $SCHEMA = 1;

sub build_spec {
  my ($class, %arg) = @_;
  my $resolved = $arg{resolved} // die "missing resolved configuration";
  my $request = $resolved->{request};
  my $configuration = $resolved->{configuration};
  die "build files require series context"
    if $request->{context} ne 'series';
  my $target_name = $arg{target} // $request->{target};
  my $language = $arg{language} // $request->{language};
  die "build files require one concrete target" if !defined $target_name;
  die "build files require one concrete language" if !defined $language;

  my $unit_directory = $arg{unit_directory} // '.';
  $unit_directory = abs_path($unit_directory)
    // die "unit directory not found: $unit_directory";
  my $physical_unit = basename($unit_directory);
  my ($number, $unit_scope, $role, $slug) = _parse_unit($physical_unit);
  my $target = $configuration->{definitions}{targets}{$target_name}
    // die "resolved target '$target_name' has no definition";
  my $doctype = $target->{doctype};
  _validate_job_atom('series id', $request->{series_id});
  _validate_job_atom('document type', $doctype);
  _validate_job_atom('language', $language);
  _validate_job_atom('unit slug', $slug, 1);
  my $job_id = join '-',
    $request->{series_id}, $number, $doctype, $language, $slug;
  my $project_root = abs_path($request->{project_root})
    // die "project root not found: $request->{project_root}";
  _require_within($unit_directory, $project_root, 'series unit directory');
  my $project_tex = $arg{manifest}{project}{tex} // {};
  my $shared_tex_relative = File::Spec->canonpath(
    $project_tex->{directory} // 'Include',
  );
  my $shared_tex_candidate = File::Spec->rel2abs(
    $shared_tex_relative, $project_root,
  );
  _require_within($shared_tex_candidate, $project_root, 'shared TeX directory');
  my $shared_tex_directory = -e $shared_tex_candidate
    ? abs_path($shared_tex_candidate)
      // die "cannot resolve shared TeX directory: $shared_tex_candidate"
    : $shared_tex_candidate;
  _require_within($shared_tex_directory, $project_root, 'shared TeX directory');
  die "shared TeX path is not a directory: $shared_tex_directory"
    if -e $shared_tex_directory && !-d $shared_tex_directory;
  my $project_config_file = $project_tex->{config} // 'projectconfig.tex';
  my $project_config_candidate = File::Spec->catfile(
    $shared_tex_directory, $project_config_file,
  );
  my $project_config_signature;
  if (-e $project_config_candidate) {
    my $canonical = abs_path($project_config_candidate)
      // die "cannot resolve project configuration file: $project_config_candidate";
    _require_within($canonical, $shared_tex_directory,
      'project configuration file');
    die "project configuration path is not a regular file: $canonical"
      if !-f $canonical;
    open my $config_handle, '<:raw', $canonical
      or die "cannot read project configuration file '$canonical': $!";
    local $/;
    my $config_source = <$config_handle>;
    close $config_handle
      or die "cannot close project configuration file '$canonical': $!";
    $project_config_signature = sha256_hex($config_source);
  }
  my $logical_ordinal = _logical_ordinal(
    $configuration->{structure}{units}, $physical_unit,
    $target->{unit_scopes} // [],
  );
  my $source_candidate = File::Spec->rel2abs(
    $request->{source}, $unit_directory,
  );
  my $source = abs_path($source_candidate)
    // die "source file not found: $source_candidate";
  die "source is not a regular file: $source" if !-f $source;
  my $build_directory = File::Spec->catdir(
    $project_root, '.osglecture', 'build', $physical_unit,
    $target_name, $language,
  );
  my $artifact = File::Spec->catfile($build_directory, "$job_id.pdf");
  my $profile_class = $target->{profile_class}
    // die "target '$target_name' has no profile class";
  my $profile = $target->{profile}
    // die "target '$target_name' has no resolved document profile";
  my $document_metadata_policy = $target->{document_metadata}
    // die "target '$target_name' has no document metadata policy";
  my $shell_escape = $arg{manifest}{security}{shell_escape} // 'restricted';
  my $document_metadata;
  my $document_metadata_contract;
  my $document_metadata_file = 'documentmetadata.tex';
  my $candidate = File::Spec->catfile(
    $shared_tex_directory, $document_metadata_file,
  );
  if ($document_metadata_policy eq 'enabled') {
    die "document metadata file required for target '$target_name': $candidate"
      if !-e $candidate;
    my $canonical = abs_path($candidate)
      // die "cannot resolve document metadata file: $candidate";
    die "document metadata path is not a regular file: $canonical"
      if !-f $canonical;
    _require_within($canonical, $shared_tex_directory, 'document metadata file');
    open my $metadata_handle, '<:raw', $canonical
      or die "cannot read document metadata file '$canonical': $!";
    local $/;
    my $metadata_source = <$metadata_handle>;
    close $metadata_handle
      or die "cannot close document metadata file '$canonical': $!";
    $document_metadata = {
      path      => $canonical,
      signature => sha256_hex($metadata_source),
    };
    $document_metadata_contract = {
      path      => File::Spec->catfile(
        $shared_tex_relative, $document_metadata_file,
      ),
      signature => $document_metadata->{signature},
    };
  }
  my $structure_signature =
    $configuration->{structure}{signature}
      // die "resolved configuration has no structure signature";

  my $config_signature = sha256_hex(
    JSON::PP->new->canonical->encode({
      manifest    => {
        schema    => $arg{manifest}{schema},
        bundle_preset => $arg{manifest}{bundle_preset},
        project   => $arg{manifest}{project},
        languages => $arg{manifest}{languages},
        target    => $arg{manifest}{targets}{$target_name},
        security  => $arg{manifest}{security} // {},
      },
      bundle_preset =>
        $configuration->{definitions}{bundle_preset}{signature},
      target_definition => $target->{signature},
      target      => $target_name,
      doctype     => $doctype,
      profile     => $profile,
      profile_definition => $target->{profile_signature},
      language    => $language,
      physical_unit => $physical_unit,
      structure_signature => $structure_signature,
      shell_escape => $shell_escape,
      document_metadata => $document_metadata_contract,
      document_metadata_policy => $document_metadata_policy,
      project_tex => {
        directory => $shared_tex_relative,
        config    => $project_config_file,
        (defined($project_config_signature)
          ? (signature => $project_config_signature) : ()),
      },
    }),
  );
  return {
    schema              => $SCHEMA,
    job_id              => $job_id,
    context             => 'series',
    series_id           => $request->{series_id},
    physical_unit       => $physical_unit,
    physical_number     => $number,
    logical_ordinal     => $logical_ordinal,
    unit_scope          => $unit_scope,
    unit_role           => $role,
    unit_id             => undef,
    target              => $target_name,
    doctype             => $doctype,
    applicable_unit_scopes => $target->{unit_scopes} // [],
    profile_class       => $profile_class,
    profile             => $profile,
    document_metadata_policy => $document_metadata_policy,
    language            => $language,
    available_languages => $arg{manifest}{languages}{available},
    selectable_languages => $arg{manifest}{targets}{$target_name}{languages},
    bundle_preset       =>
      $configuration->{definitions}{bundle_preset}{reference},
    project_root        => $project_root,
    project_manifest    => abs_path($arg{resolved}{configuration}{path})
      // die("project manifest not found: "
        . $arg{resolved}{configuration}{path}),
    shared_tex_directory => $shared_tex_directory,
    shared_tex_relative  => $shared_tex_relative,
    project_config_file  => $project_config_file,
    project_config_signature => $project_config_signature,
    source              => $source,
    source_directory    => dirname($source),
    build_directory     => $build_directory,
    aux_directory       => $build_directory,
    artifact            => $artifact,
    shell_escape        => $shell_escape,
    document_metadata   => $document_metadata,
    structure_signature => $structure_signature,
    config_signature    => $config_signature,
  };
}

# Emits exactly the keys classified "OLLM" (core of the build decision) or
# "Grenzfall" (target-registry-derived) in osglecture/ARCHITECTURE.md
# section 12. Everything classified "gemeinsames Modul" there --
# physical-unit, physical-number, logical-ordinal, unit-scope, unit-role,
# available-languages, bundle-preset, shared-tex-directory,
# source-directory, project-config-file -- is project content the class
# now reads itself via osglecture-manifest.lua / osglecture-series-index.lua,
# not the build decision, and is deliberately absent here. $spec itself
# still carries all of it: OLLM's own state management (structure
# signatures, --all discovery) keeps needing it internally.
sub render {
  my ($class, $spec) = @_;
  my @lines = (
    '% Generated by OLLM. Do not edit.',
    "\\OsgLectureBuildSetup{",
    "  schema={" . $spec->{schema} . "},",
    "  job-id={" . _tex_atom($spec->{job_id}) . "},",
    "  context={" . _tex_atom($spec->{context}) . "},",
    "  series-id={" . _tex_atom($spec->{series_id}) . "},",
    (defined($spec->{unit_id})
      ? "  unit-id={" . _tex_atom($spec->{unit_id}) . "},"
      : ()),
    (defined($spec->{generation_id})
      ? "  generation-id={" . _tex_atom($spec->{generation_id}) . "},"
      : ()),
    "  target={" . _tex_atom($spec->{target}) . "},",
    "  profile-class={" . _tex_atom($spec->{profile_class}) . "},",
    "  profile={" . _tex_atom($spec->{profile}) . "},",
    "  document-metadata-policy={"
      . _tex_atom($spec->{document_metadata_policy}) . "},",
    "  doctype={" . _tex_atom($spec->{doctype}) . "},",
    "  applicable-unit-scopes={" . join(',',
      map { _tex_atom($_) } @{ $spec->{applicable_unit_scopes} }) . "},",
    "  language={" . _tex_atom($spec->{language}) . "},",
    "  selectable-languages={" . join(',',
      map { _tex_atom($_) } @{ $spec->{selectable_languages} // [] }) . "},",
    "  shell-escape={" . _tex_atom($spec->{shell_escape}) . "},",
    "  structure-signature={" . _tex_atom($spec->{structure_signature}) . "},",
    "  config-signature={" . _tex_atom($spec->{config_signature}) . "}",
    "}",
    '',
  );
  return join "\n", @lines;
}

sub _logical_ordinal {
  my ($units, $physical, $scopes) = @_;
  my %scope = map { $_ => 1 } @$scopes;
  my ($ordinal, $excursus, $appendix) = (0, 0, 0);
  for my $unit (@$units) {
    my $applies = ($unit->{unit_scope} // '') eq ''
      || $scope{$unit->{unit_scope} // ''};
    next if !$applies;
    return '' if $unit->{physical_unit} eq $physical
      && ($unit->{unit_role} // '') eq 'i';
    next if ($unit->{unit_role} // '') eq 'i';
    my $role = $unit->{unit_role} // 'content';
    my $value;
    if ($role eq 'excursus' || $role eq 'e') {
      die "excursus '$unit->{physical_unit}' has no preceding content unit"
        if !$ordinal;
      $value = $ordinal . 'e' . ++$excursus;
    } elsif ($role eq 'appendix' || $role eq 'a') {
      ++$ordinal;
      $value = $ordinal . 'ap' . ++$appendix;
    } else {
      ++$ordinal;
      ($excursus, $appendix) = (0, 0);
      $value = $ordinal;
    }
    return $value if $unit->{physical_unit} eq $physical;
  }
  die "cannot determine logical ordinal for physical unit '$physical'";
}

sub write_atomic {
  my ($class, $path, $content) = @_;
  my ($volume, $directories, $filename) = File::Spec->splitpath($path);
  my $directory = File::Spec->catpath($volume, $directories, '');
  $directory = '.' if $directory eq '';
  my ($handle, $temporary) = tempfile('.ollm-build-XXXXXX', DIR => $directory);
  binmode $handle, ':raw';
  print {$handle} $content or die "cannot write '$temporary': $!";
  close $handle or die "cannot close '$temporary': $!";
  if (-f $path) {
    open my $old, '<:raw', $path or die "cannot read '$path': $!";
    local $/;
    my $existing = <$old>;
    close $old;
    if ($existing eq $content) {
      unlink $temporary or die "cannot remove '$temporary': $!";
      return 0;
    }
  }
  if (!rename $temporary, $path) {
    my $rename_error = $!;
    if ($^O eq 'MSWin32' && -f $path) {
      my $backup = "$path.ollm-old-$$";
      rename $path, $backup
        or die "cannot prepare Windows replacement of '$path': $!";
      if (!rename $temporary, $path) {
        my $replacement_error = $!;
        rename $backup, $path
          or die "cannot replace '$path' ($replacement_error) or restore it: $!";
        die "cannot replace '$path' with '$temporary': $replacement_error";
      }
      unlink $backup or die "cannot remove replaced build file '$backup': $!";
    } else {
      die "cannot replace '$path' with '$temporary': $rename_error";
    }
  }
  return 1;
}

sub write_for_spec {
  my ($class, $spec) = @_;
  make_path($spec->{build_directory});
  my $path = File::Spec->catfile(
    $spec->{build_directory}, "$spec->{job_id}.osgbuild.tex",
  );
  $class->write_atomic($path, $class->render($spec));
  return $path;
}

# A requested handout layout (osglecture.dtx \OsgLectureHandoutResult, read
# back from .osgresult.aux) never travels through the -pretex \def channel
# Executor::command_for_spec uses for the primary build -- that channel's
# character class forbids the spaces and '|' every layout value needs (e.g.
# "4 on 1", "2 on 1|ruled"). It only needs to reach this companion source,
# which OLLM writes directly as plain text, so it is validated once here
# against tagpax's own accepted shapes instead of being forced through that
# unrelated, stricter contract.
sub _validate_handout_layout {
  my ($layout) = @_;
  die "handout layout '$layout' cannot be represented safely in generated TeX\n"
    if $layout !~ /\A[0-9]+ on 1(?:\|[a-z]+)?\z/;
}

sub handout_layout_job_id { return "$_[1]->{job_id}-layout" }

# A plain N-up grid packs several equally-sized cells of an inherently wide
# (widescreen or 4:3) slide; a portrait sheet forces every cell narrower
# than the page is tall, wasting most of the sheet. Landscape fits that
# grid far better. The ruled/notes single-column layouts are the opposite
# shape -- a stack of wide slides next to a narrow note strip -- which
# wants the sheet's long axis vertical, i.e. portrait. The modifier is
# therefore also what picks the orientation, not just tagpax's own
# rendering choice.
sub _handout_geometry_options {
  my ($layout) = @_;
  return $layout =~ /\|/ ? 'a4paper,margin=1.5cm' : 'a4paper,landscape,margin=1.5cm';
}

# Renders the small companion master that imposes an already-built handout
# PDF via tagpax (see development-docs/tagpax/DESIGN.md and
# osglecture-adapters.dtx for why this stays a second, independent LaTeX
# document rather than something the handout source produces itself: tagpax
# imposes a finished PDF, it does not exist yet while that PDF is being
# built). The primary handout PDF and its tagpax IR are referenced by
# basename only -- both live next to this file in the same build directory.
sub render_handout_layout {
  my ($class, $spec, $layout) = @_;
  _validate_handout_layout($layout);
  my $pdf_name = basename($spec->{artifact});
  (my $ir_name = $pdf_name) =~ s/\.pdf\z/.tagpax/;
  my $geometry = _handout_geometry_options($layout);
  my @lines = (
    '% Generated by OLLM. Do not edit.',
    '\IfFormatAtLeastTF{2025-06-01}',
    '  {\DocumentMetadata{tagging=on}}',
    '  {\DocumentMetadata{testphase={phase-III,title,table,math,firstaid}}}',
    '\documentclass{article}',
    "\\usepackage[$geometry]{geometry}",
    '\usepackage{tagpax}',
    '\begin{document}',
    "\\tagpaxextract[$ir_name]{$pdf_name}",
    "\\tagpaxinclude[ir=$ir_name, layout=$layout]{$pdf_name}",
    '\end{document}',
    '',
  );
  return join "\n", @lines;
}

sub write_handout_layout_for_spec {
  my ($class, $spec, $layout) = @_;
  make_path($spec->{build_directory});
  my $job_id = $class->handout_layout_job_id($spec);
  my $path = File::Spec->catfile($spec->{build_directory}, "$job_id.tex");
  $class->write_atomic($path, $class->render_handout_layout($spec, $layout));
  return $path;
}

# tagpax has nothing to import from a PDF with no structure tree at all --
# an author (or an entire profile, e.g. beamer, which forbids
# \DocumentMetadata outright) may legitimately not be tagging. This maps
# the same "N on 1" vocabulary onto plain pdfpages nup= imposition instead,
# so an untagged handout still gets an N-up variant; it just cannot also
# get the 'ruled' note strip, which has no meaning without tagpax placing
# it next to an imported, still-taggable page.
my %GRID_BY_COUNT = (
  2 => { rows => 2, cols => 1 },
  3 => { rows => 3, cols => 1 },
  4 => { rows => 2, cols => 2 },
  6 => { rows => 3, cols => 2 },
);

sub render_handout_layout_plain {
  my ($class, $spec, $layout) = @_;
  _validate_handout_layout($layout);
  my ($count, $modifier) = $layout =~ /\A([0-9]+) on 1(?:\|([a-z]+))?\z/;
  die "handout layout '$layout' has no untagged fallback: 'ruled' needs "
    . "tagpax to place drawn note lines next to an imported page, which "
    . "requires a tagged source PDF; enable tagging for this build, or use "
    . "layout '$count on 1' without the modifier\n"
    if defined $modifier;
  my $grid = $GRID_BY_COUNT{$count}
    // die "unsupported handout layout count '$count'\n";
  my $pdf_name = basename($spec->{artifact});
  my $nup = "$grid->{cols}x$grid->{rows}";
  my $geometry = _handout_geometry_options($layout);
  my @lines = (
    '% Generated by OLLM. Do not edit.',
    '\documentclass{article}',
    "\\usepackage[$geometry]{geometry}",
    '\usepackage{pdfpages}',
    '\begin{document}',
    "\\includepdf[pages=-, nup=$nup, delta=3mm 3mm, frame=false]{$pdf_name}",
    '\end{document}',
    '',
  );
  return join "\n", @lines;
}

sub write_handout_layout_plain_for_spec {
  my ($class, $spec, $layout) = @_;
  make_path($spec->{build_directory});
  my $job_id = $class->handout_layout_job_id($spec);
  my $path = File::Spec->catfile($spec->{build_directory}, "$job_id.tex");
  $class->write_atomic(
    $path, $class->render_handout_layout_plain($spec, $layout),
  );
  return $path;
}

sub _parse_unit {
  my ($name) = @_;
  die "invalid series unit directory '$name'; expected "
    . "<three digits><optional scope code>-<optional role-><slug>"
    if $name !~ /\A(\d{3})([a-z]{0,2})-(?:(a|e|i)-)?(.+)\z/;
  my ($number, $unit_scope, $role, $slug) = ($1, $2, $3 // 'content', $4);
  die "invalid empty unit slug in '$name'" if $slug eq '';
  return ($number, $unit_scope, $role, $slug);
}

sub _validate_job_atom {
  my ($label, $value, $allow_hyphen) = @_;
  die "$label is missing" if !defined $value || $value eq '';
  my $pattern = $allow_hyphen
    ? qr/\A[A-Za-z0-9._]+(?:-[A-Za-z0-9._]+)*\z/
    : qr/\A[A-Za-z0-9._]+\z/;
  die "invalid $label '$value' for a portable job name"
    if $value !~ $pattern;
}

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

sub _tex_atom {
  my ($value) = @_;
  $value //= '';
  die "value '$value' cannot be represented safely in a TeX build file"
    if $value !~ /\A[A-Za-z0-9 ._+\/=-]*\z/;
  return $value;
}

sub _tex_path {
  my ($value) = @_;
  $value //= '';
  $value =~ s{\\}{/}g;
  die "path '$value' cannot be represented safely in a TeX build file"
    if $value =~ /[{}%#\r\n]/;
  return $value;
}

1;
