package OLLM::Migration;

use v5.30;
use strict;
use warnings;

use Cwd qw(abs_path);
use File::Basename qw(basename dirname);
use File::Path qw(make_path);
use File::Spec;

# The two profile sets a generated manifest can start from. 'taggable' pairs
# the profiles that run with \DocumentMetadata and tagging; 'classic' is the
# pair the legacy osgbeamer world produced.
my %PROFILE_SET = (
  taggable => {
    presentation_profile => 'ltx-talk', longform_profile => 'book',
    document_metadata => 'enabled',
  },
  classic => {
    presentation_profile => 'beamer', longform_profile => 'scrbook',
  },
);

# Shown instead of a bare "option missing": the choice changes which classes
# the whole project builds with, so the message has to carry enough to decide
# without the manual.
sub profiles_help {
  return <<'TEXT';
newproject needs --profiles=taggable or --profiles=classic

The choice decides which LaTeX classes the generated ollmconfig.toml builds
with, and whether the PDFs are tagged (accessible):

  --profiles=taggable
      slides, handout, screen -> ltx-talk       script -> book
      Every target runs with \DocumentMetadata and tagging switched on, so
      the PDFs carry structure for screen readers (Tagged PDF).
      Include/documentmetadata.tex is created for that.
      Choose this for new material that should be accessible.
      Consequences: needs a current TeX Live. ltx-talk is a young class with
      few themes; beamer themes and beamer-specific code (\setbeamertemplate,
      \usetheme, ...) do not work with it.

  --profiles=classic
      slides, handout, screen -> beamer         script -> scrbook
      No \DocumentMetadata, no tagging: the PDFs are untagged.
      Choose this for existing beamer material, for beamer themes, or for
      an older TeX installation.
      Consequences: no Tagged PDF. Features that depend on it are missing,
      for example the 'ruled' handout layout and the presenter clock of the
      screen target.

Either way only [targets.defaults] in ollmconfig.toml is preset. You can mix
later by hand: set profile = "..." in a single [targets.<name>] table.
TEXT
}

sub execute {
  my ($class, %arg) = @_;
  my $action = $arg{action} // die "missing migration action";
  my $profiles = $arg{profiles};
  die "invalid --profiles '$profiles'; expected 'taggable' or 'classic'\n\n"
    . $class->profiles_help
    if defined $profiles && !exists $PROFILE_SET{$profiles};
  my $start = abs_path($arg{start_dir} // '.')
    // die "migration directory not found";
  my $root = defined $arg{project_root}
    ? File::Spec->rel2abs($arg{project_root}, $start) : $start;
  if (!defined $arg{project_root} && !defined $arg{config}) {
    my $candidate = $start;
    while (1) {
      if (-f File::Spec->catfile($candidate, 'ollmconfig.toml')
          || -f File::Spec->catfile($candidate, 'ollmconfig.pl')) {
        $root = $candidate;
        last;
      }
      my $parent = dirname($candidate);
      last if $parent eq $candidate;
      $candidate = $parent;
    }
  }
  $root = abs_path($root) // die "project root not found: $root";
  die "project root is not a directory: $root" if !-d $root;

  my $toml = File::Spec->catfile($root, 'ollmconfig.toml');
  my $perl = defined $arg{config}
    ? File::Spec->rel2abs($arg{config}, $start)
    : File::Spec->catfile($root, 'ollmconfig.pl');
  $root = dirname(abs_path($perl)) if defined $arg{config} && -f $perl;
  $toml = File::Spec->catfile($root, 'ollmconfig.toml');

  my @warnings;
  my ($source, $manifest_kept);
  if (-e $toml) {
    # A readable, structurally complete manifest may have been written by an
    # earlier run or edited by hand; never overwrite it. Only an unusable
    # leftover (truncated write, missing core sections) is replaced.
    if (_toml_looks_complete($toml)) {
      $source = _read_file($toml);
      $manifest_kept = 1;
    }
    else {
      push @warnings, "replaced an incomplete ollmconfig.toml left by an "
        . "earlier migration; review it and re-run if it held manual edits";
    }
  }

  if (!$manifest_kept) {
    die "legacy configuration not found: $perl"
      if $action eq 'convertproject' && !-f $perl;
    # newproject makes the author choose; convertproject keeps what the
    # legacy build produced unless told otherwise.
    die $class->profiles_help if !defined $profiles && $action eq 'newproject';
    $profiles //= 'classic';
    if (-f $perl) {
      ($source, my @convert_warnings) =
        $class->convert_source($perl, $root, $profiles);
      push @warnings, @convert_warnings;
      push @warnings, "the converted project builds its presentation targets "
        . "with ltx-talk (--profiles=taggable); legacy beamer-specific code "
        . "in the units needs manual conversion"
        if $profiles eq 'taggable';
    }
    else {
      $source = $class->generic_source($root, $profiles);
    }
  }
  elsif (defined $profiles) {
    push @warnings, "--profiles=$profiles was ignored because the existing "
      . "ollmconfig.toml was kept";
  }

  my $tex_directory = _manifest_tex_directory($source);
  my $include = File::Spec->catdir($root, $tex_directory);
  my $project_config = File::Spec->catfile($include, 'projectconfig.tex');

  # A kept manifest can name any directory; warn rather than silently write
  # projectconfig.tex somewhere TeX or Windows cannot reliably read back.
  push @warnings, "the shared TeX directory in ollmconfig.toml is '$1'; spaces "
    . "and quotes are unreliable in TeX file lookups and invalid on Windows -- "
    . "prefer a plain relative name"
    if $manifest_kept
    && $source =~ /^\s*tex_directory\s*=\s*["']([^\n]*?[\s"][^\n]*?)["']\s*(?:#.*)?$/m;

  die "ollmconfig.toml already exists: $toml"
    if $manifest_kept && -e $project_config;

  make_path($include) if !-d $include;
  if (!$manifest_kept) {
    open my $handle, '>:raw', $toml
      or die "cannot create '$toml': $!";
    print {$handle} $source or die "cannot write '$toml': $!";
    close $handle or die "cannot close '$toml': $!";
  }
  else {
    push @warnings, "kept the existing ollmconfig.toml; created only "
      . "Include/projectconfig.tex to match it";
  }

  my $languages = _manifest_languages($source);
  my $project_config_created = !-e $project_config;
  if ($project_config_created) {
    my $lectdates = File::Spec->catfile($include, 'lectdates.tex');
    my ($tex_source, @tex_warnings);
    if (-f $lectdates) {
      ($tex_source, @tex_warnings) =
        $class->convert_lectdates($lectdates, $languages);
    }
    else {
      $tex_source = $class->generic_project_config($languages);
      push @tex_warnings, "no lectdates.tex found at '$lectdates'; wrote "
        . "placeholder metadata -- copy it from the legacy file manually if it "
        . "lives elsewhere"
        if -f $perl;
    }
    push @warnings, @tex_warnings;
    open my $tex_handle, '>:raw', $project_config
      or die "cannot create '$project_config': $!";
    print {$tex_handle} $tex_source
      or die "cannot write '$project_config': $!";
    close $tex_handle or die "cannot close '$project_config': $!";
  }

  # Only a manifest generated just now is known to enable document metadata;
  # a kept one decides that for itself.
  my $document_metadata = File::Spec->catfile($include, 'documentmetadata.tex');
  my $document_metadata_created = !$manifest_kept
    && defined $PROFILE_SET{$profiles}{document_metadata}
    && !-e $document_metadata;
  if ($document_metadata_created) {
    open my $metadata_handle, '>:raw', $document_metadata
      or die "cannot create '$document_metadata': $!";
    print {$metadata_handle} $class->generic_document_metadata
      or die "cannot write '$document_metadata': $!";
    close $metadata_handle or die "cannot close '$document_metadata': $!";
  }

  return {
    path => $toml, project_config_path => $project_config,
    project_config_created => $project_config_created,
    document_metadata_path => $document_metadata,
    document_metadata_created => $document_metadata_created ? 1 : 0,
    profiles => $manifest_kept ? undef : $profiles,
    manifest_kept => $manifest_kept ? 1 : 0,
    converted => (!$manifest_kept && -f $perl) ? 1 : 0,
    warnings => \@warnings,
  };
}

sub _read_file {
  my ($path) = @_;
  open my $fh, '<:raw', $path or die "cannot read '$path': $!";
  local $/;
  my $content = <$fh>;
  close $fh or die "cannot close '$path': $!";
  return $content;
}

# A manifest is worth preserving when it parses and carries the sections the
# generator always emits. A truncated write drops trailing tables and fails one
# of these checks; if no TOML parser is available the file is kept untouched.
sub _toml_looks_complete {
  my ($path) = @_;
  my $source = eval { _read_file($path) };
  return 0 if !defined $source || $source !~ /\S/;
  return 1 if !eval { require TOML::Tiny::Parser; 1 };
  my $parsed = eval { TOML::Tiny::Parser->new(strict => 1)->parse($source) };
  return 0 if ref $parsed ne 'HASH';
  return (exists $parsed->{schema}
    && ref $parsed->{project} eq 'HASH'
    && ref $parsed->{targets} eq 'HASH'
    && ref $parsed->{targets}{defaults} eq 'HASH') ? 1 : 0;
}

sub _manifest_languages {
  my ($source) = @_;
  return [] if $source !~ /^languages\s*=\s*\[([^\]]*)\]/m;
  return [ $1 =~ /['"]([^'"]*)['"]/g ];
}

# A pointer, not a setting: the document profiles are chosen in
# ollmconfig.toml ([targets.defaults].presentation_profile / longform_profile,
# or profile on a single target), because the class needs the metadata
# contract before it runs. projectconfig.tex keeps only LaTeX-side semantics.
sub _project_config_profiles {
  return <<'TEX';
% Document profiles (beamer/ltx-talk, book/scrbook) are chosen in
% ollmconfig.toml, not here. newproject presets them from --profiles; to
% change one, set presentation_profile / longform_profile in
% [targets.defaults], or profile on a single [targets.<name>].
TEX
}

sub generic_document_metadata {
  return <<'TEX';
% Early \DocumentMetadata call for targets that run with document metadata.
% OLLM inserts this file before \documentclass.
\DocumentMetadata{
  lang = \OsgLectureRequestedLanguage,
  tagging = on
}
TEX
}

# langselect derives the bilingual helper macro name from the selectable-language
# order: de,en -> \ldeen ; en,de -> \lende. Only two-language projects get one;
# other counts fall through to a plain (or trilingual) configuration.
sub _bilingual_macro {
  my ($languages) = @_;
  return undef if !$languages || @$languages != 2;
  return 'l' . $languages->[0] . $languages->[1];
}

# The selectable languages come from the manifest for an OLLM build; the class
# wires them into langselect from the build file. projectconfig.tex only keeps
# the LaTeX-variant map, so the converter emits a stub, not a selectable list.
sub _language_setup_line {
  my ($languages) = @_;
  return '' if !$languages || @$languages < 2;
  return "% Language selection comes from ollmconfig.toml. Add the langselect\n"
    . "% variant mapping here if needed, e.g.:\n"
    . "% \\LectureProjectSetup{languages={map={"
    . join(',', map { "$_=..." } @$languages) . "}}}\n";
}

sub generic_project_config {
  my ($class, $languages) = @_;
  my $macro = _bilingual_macro($languages);
  my $setup = _language_setup_line($languages);

  my $body = "% Shared metadata for the lecture project. Replace these dummy "
    . "values.\n";
  if (defined $macro) {
    $body .= "% This project builds in several languages; wrap language-"
      . "specific metadata\n% as \\$macro" . '{...}{...} (first argument '
      . "$languages->[0], second $languages->[1]).\n";
  }
  elsif ($setup ne '') {
    $body .= "% This project builds in several languages; wrap language-"
      . "specific metadata\n% in the langselect macro generated for the "
      . "languages below.\n";
  }
  $body .= $setup;
  $body .= defined $macro
    ? "\\title{\\$macro" . '{Kurstitel}{Course title}}' . "\n"
    : "\\title{Course title}\n";
  $body .= "\\author{First name Last name}\n"
    . "\\date{Term and year}\n"
    . "\\institute{Institution}\n\n"
    . _project_config_profiles();
  return $body;
}

sub convert_lectdates {
  my ($class, $path, $languages) = @_;
  open my $handle, '<:raw', $path or die "cannot read '$path': $!";
  local $/;
  my $legacy = <$handle>;
  close $handle or die "cannot close '$path': $!";

  # osglecture provides these directly or as a documented legacy alias.
  my @copy = qw(
    title subtitle author date course event lehrveranstaltung conference institute
  );
  # Accepted by the legacy TUC beamer theme but undefined under osglecture:
  # keep the text, commented out, so nothing vanishes and nothing breaks.
  my @comment = qw(tucurl logo);

  my @found;
  push @found, map { [@$_, 0] } _extract_tex_commands($legacy, $_) for @copy;
  push @found, map { [@$_, 1] } _extract_tex_commands($legacy, $_) for @comment;
  @found = sort { $a->[0] <=> $b->[0] } @found;

  my ($normalized, @lines);
  for my $entry (@found) {
    my (undef, $text, $commented) = @$entry;
    if ($commented) {
      push @lines, "% $text";
      next;
    }
    # \ldeenr was the pre-langselect robust spelling of \ldeen; langselect's
    # \ldeen is robust on its own, so normalize to it.
    $normalized = 1 if $text =~ s/\\ldeenr(?![a-zA-Z])/\\ldeen/g;
    push @lines, $text;
  }
  my $metadata = join("\n", @lines);

  # Comments never carry an active command; strip them before scanning.
  my $active = $legacy =~ s/(?<!\\)%.*//rg;
  my @warnings;
  push @warnings, "no recognizable metadata found in '$path'" if !@found;

  my @present = grep { $active =~ /\\\Q$_\E(?![a-zA-Z])/ } @comment;
  push @warnings, "commands with no osglecture equivalent were commented out in "
    . "the converted configuration: " . join(', ', map { "\\$_" } @present)
    if @present;

  push @warnings, "'\\ldeenr' in '$path' was rewritten to '\\ldeen'"
    if $normalized;

  my $de_en = $languages && @$languages == 2
    && $languages->[0] eq 'de' && $languages->[1] eq 'en';
  push @warnings, "copied metadata in '$path' uses '\\ldeen', but this project's "
    . "language order does not generate that macro; check the argument order "
    . "against '\\" . (_bilingual_macro($languages) // 'lende') . "' or set the "
    . "languages to de, en"
    if $metadata =~ /\\ldeen(?![a-zA-Z])/ && !$de_en;

  my $includes = '';
  for my $name ($active =~ /\\input\s*\{\s*([^}]+?)\s*\}/g) {
    (my $bare = $name) =~ s/\.tex\z//;
    if ($bare =~ m{[\\/]}) {
      $includes .= "% \\IncludeOsgLecturePreamble{$bare} "
        . "% path outside the shared TeX directory -- convert manually\n";
      push @warnings, "'\\input{$name}' in '$path' points outside the shared "
        . "TeX directory; convert it manually";
    }
    else {
      $includes .= "\\IncludeOsgLecturePreamble{$bare}\n";
      push @warnings, "'\\input{$name}' in '$path' became "
        . "'\\IncludeOsgLecturePreamble{$bare}'; the fragment now runs after "
        . "\\LoadClass -- use the starred form if it must run earlier";
    }
  }
  $includes = "\n% '\\input' fragments from the legacy lectdates.tex. Unlike "
    . "\\input, these\n% are replayed only after the document class has loaded; "
    . "use\n% \\IncludeOsgLecturePreamble* for a fragment that must run "
    . "earlier.\n" . $includes
    if $includes ne '';

  push @warnings, "legacy class options in '$path' require manual conversion"
    if $active =~ /\\(?:SetGlobalClassOptions|EnforceGlobalClassOptions)(?![a-zA-Z])/;

  my $body = "% Converted from lectdates.tex; review the copied metadata.\n";
  $body .= _language_setup_line($languages);
  $body .= "$metadata\n" if $metadata ne '';
  $body .= $includes;
  $body .= "\n" . _project_config_profiles();
  return ($body, @warnings);
}

sub _extract_tex_commands {
  my ($source, $name) = @_;
  my @found;
  while ($source =~ /\\\Q$name\E(?=\s*[<\[{])/g) {
    my $start = $-[0];
    my $pos = pos($source);
    my $line_start = rindex($source, "\n", $start - 1) + 1;
    my $prefix = substr($source, $line_start, $start - $line_start);
    $prefix =~ s/\\%//g;
    next if $prefix =~ /%/;
    $pos++ while substr($source, $pos, 1) =~ /\s/;
    for my $pair (['<', '>'], ['[', ']']) {
      while (substr($source, $pos, 1) eq $pair->[0]) {
        $pos = _balanced_end($source, $pos, @$pair);
        return @found if !defined $pos;
        $pos++ while substr($source, $pos, 1) =~ /\s/;
      }
    }
    next if substr($source, $pos, 1) ne '{';
    my $end = _balanced_end($source, $pos, '{', '}');
    next if !defined $end;
    push @found, [$start, substr($source, $start, $end - $start)];
    pos($source) = $end;
  }
  return @found;
}

sub _balanced_end {
  my ($source, $start, $open, $close) = @_;
  my $depth = 0;
  for (my $i = $start; $i < length($source); ++$i) {
    my $char = substr($source, $i, 1);
    next if $i > 0 && substr($source, $i - 1, 1) eq '\\';
    ++$depth if $char eq $open;
    if ($char eq $close) {
      --$depth;
      return $i + 1 if $depth == 0;
    }
  }
  return;
}

# Reads the shared TeX directory ([project].tex_directory) back from a manifest
# -- the freshly generated one, or an existing file that is kept as-is. Accepts
# either quote style; a value with an embedded quote (or none at all) is left
# to the caller's fallback rather than parsed into a broken path.
sub _manifest_tex_directory {
  my ($source) = @_;
  return $1 if $source =~ /^\s*tex_directory\s*=\s*"([^"]+)"\s*(?:#.*)?$/m;
  return $1 if $source =~ /^\s*tex_directory\s*=\s*'([^']+)'\s*(?:#.*)?$/m;
  return 'Include';
}

sub convert_source {
  my ($class, $path, $root, $profiles) = @_;
  open my $handle, '<:raw', $path or die "cannot read '$path': $!";
  local $/;
  my $perl = <$handle>;
  close $handle or die "cannot close '$path': $!";

  my %scalar;
  while ($perl =~ /^\s*(?:my\s+)?\$(\w+)\s*=\s*(['"])(.*?)\2\s*;/mg) {
    my ($name, $value) = ($1, $3);
    $value =~ s/\\(['"\\])/$1/g;
    $scalar{$name} = $value;
  }
  while ($perl =~ /^\s*(?:my\s+)?\$(\w+)\s*=\s*([01])\s*;/mg) {
    $scalar{$1} = $2;
  }

  my $default = $scalar{defaultlanguage} // 'de';
  my @languages = ($default);
  push @languages, grep { $_ ne $default } qw(de en);
  my $shell = ($scalar{shell_escape} // 0) ? 'full' : 'restricted';
  my @warnings;
  my $tex_directory = $scalar{shared_source_dir} // 'Include';
  $tex_directory =~ s{\A\.\.[\\/]}{};
  # Spaces, quotes, backslashes and the Windows-reserved characters do not
  # survive TeX file lookups (\IncludeOsgLecturePreamble, the class locating
  # projectconfig.tex) reliably and are invalid in Windows path components.
  if (File::Spec->file_name_is_absolute($tex_directory)
      || $tex_directory =~ m{\A\.\.(?:[\\/]|\z)}
      || $tex_directory =~ /["\s\\<>:|?*]/) {
    push @warnings,
      "shared_source_dir '$tex_directory' is not a portable relative path; "
      . "using 'Include'";
    $tex_directory = 'Include';
  }
  my $deployment = _legacy_deployment($perl);
  my $source = _manifest(
    root => $root, default => $default, languages => \@languages,
    shell_escape => $shell, tex_directory => $tex_directory,
    deployment => $deployment,
    overwrite => ($deployment ne '' ? 'explicit' : undef),
    profiles => $profiles,
  );

  push @warnings, "defaultlanguage could not be read; using 'de'"
    if !exists $scalar{defaultlanguage};
  my @unsupported;
  push @unsupported, 'deployment restrictions/passwords'
    if $perl =~ /\%deploy_restriction\b|\$deploy_pw\b/;
  push @unsupported, 'dynamically assigned deployment settings'
    if $perl =~ /\%(?:deploy_path|deploy_file)\s*=/;
  push @unsupported, 'shared data path'
    if $perl =~ /\$shared_data_dir\b/;
  push @unsupported, 'chapter-number settings'
    if $perl =~ /\$(?:first_chapter_number|lectconfig|lectureprefix)\b|\@first_chapter_number\b/;
  push @warnings, 'not represented in TOML: ' . join(', ', @unsupported)
    if @unsupported;
  push @warnings, 'the Perl file contains executable or unrecognized statements; review the generated manifest'
    if $perl =~ /^\s*(?!#|$|(?:my\s+)?[$@%]\w+\s*=)[^\s]/m;
  return ($source, @warnings);
}

sub generic_source {
  my ($class, $root, $profiles) = @_;
  return _manifest(
    root => $root, default => 'de', languages => [qw(de en)],
    shell_escape => 'restricted', tex_directory => 'Include',
    profiles => $profiles,
  ) . <<'TOML';

# Deployment copies promoted PDF artifacts. Enable and adapt these examples;
# OLLM never creates missing destination directories.
# [deployment]
# series = "both" # units | collection | both
# paths = ["deployment"]
# filename = "{role}{chapter:02}-{unit}-{lang}.pdf"
#
# [deployment.types.handout]
# paths = ["deployment/handouts"]
#
# [security]
# overwrite = "explicit" # explicit | automatic
TOML
}

sub _manifest {
  my (%arg) = @_;
  my $id = lc basename($arg{root});
  $id =~ s/[^a-z0-9._-]+/-/g;
  $id =~ s/\A[-._]+|[-._]+\z//g;
  $id = 'lecture-series' if $id eq '';
  my $languages = join(', ', map { _quote($_) } @{ $arg{languages} });
  my $default = _quote($arg{default});
  my $tex_directory = _quote($arg{tex_directory} // 'Include');
  my $security = "[security]\nshell_escape = " . _quote($arg{shell_escape}) . "\n";
  $security .= "overwrite = " . _quote($arg{overwrite}) . "\n"
    if defined $arg{overwrite};
  my $set = $PROFILE_SET{ $arg{profiles} // 'classic' }
    // die "unknown profile set '$arg{profiles}'";
  my $profiles = join '', map { "$_ = " . _quote($set->{$_}) . "\n" }
    grep { defined $set->{$_} }
    qw(presentation_profile longform_profile document_metadata);
  return "schema = 2\nbundle_preset = \"OSG lecture/1\"\n\n"
    . "[project]\nid = " . _quote($id) . "\n"
    . "tex_directory = $tex_directory\n"
    . "tex_config = \"projectconfig.tex\"\n\n"
    . "[targets.defaults]\nlanguages = [$languages]\n"
    . "default_language = $default\n$profiles\n"
    . join('', map { "[targets.$_]\n\n" } qw(slides handout screen script))
    . "$security\n"
    . ($arg{deployment} // '');
}

sub _legacy_deployment {
  my ($perl) = @_;
  my (%paths, %files);
  while ($perl =~ /\$deploy_path\s*\{\s*['"]?([^}'"\s]+)['"]?\s*\}\s*=\s*([^;]+);/g) {
    my ($key, $value) = ($1, $2);
    my @values = _literal_list($value);
    $paths{_deploy_alias($key)} = \@values if @values;
  }
  while ($perl =~ /\$deploy_file\s*\{\s*['"]?([^}'"\s]+)['"]?\s*\}\s*=\s*(['"])(.*?)\2\s*;/g) {
    $files{_deploy_alias($1)} = $3;
  }
  return '' if !%paths;
  my @types = qw(slides handout script);
  my $output = "[deployment]\nseries = \"both\"\n\n";
  for my $type (@types) {
    my $legacy_paths = $paths{$type} // $paths{all} // next;
    my @converted = map {
      my $path = $_;
      $path =~ s{\A\.\.[\\/]}{};
      $path;
    } @$legacy_paths;
    my $template = $files{$type} // $files{all}
      // '${prefix}-${num}-${doctype}-${lang}-${topic}';
    $template =~ s/\$\{prefix\}/{series}/g;
    $template =~ s/\$\{num\}/{chapter}/g;
    $template =~ s/\$\{doctype\}/{doctype}/g;
    $template =~ s/\$\{lang\}/{lang}/g;
    $template =~ s/\$\{topic\}/{unit}/g;
    $template .= '.pdf' if $template !~ /[.]pdf\z/i;
    $output .= "[deployment.types.$type]\npaths = ["
      . join(', ', map { _quote($_) } @converted) . "]\n"
      . "filename = " . _quote($template) . "\n\n";
  }
  return $output;
}

sub _deploy_alias {
  my ($name) = @_;
  return 'slides' if $name eq 'beamer' || $name eq 'presentation';
  return 'script' if $name eq 'article';
  return $name;
}

sub _literal_list {
  my ($source) = @_;
  $source =~ s/\A\s+|\s+\z//g;
  if ($source =~ /\A(['"])(.*?)\1\z/s) {
    return ($2);
  }
  return () if $source !~ /\A\[(.*)\]\z/s;
  my $inside = $1;
  my @values;
  pos($inside) = 0;
  while ($inside =~ /\G\s*(['"])(.*?)\1\s*(?:,|\z)/gc) {
    push @values, $2;
  }
  return () if (pos($inside) // 0) != length($inside);
  return @values;
}

sub _quote { my ($v) = @_; $v =~ s/([\\"])/\\$1/g; return qq{"$v"}; }

1;
