package OLLM::Config;

use v5.30;
use strict;
use warnings;

use Cwd qw(abs_path getcwd);
use Digest::SHA qw(sha256_hex);
use File::Basename qw(dirname);
use File::Find qw(find);
use File::Spec;
use JSON::PP;
use OLLM::Version qw($VERSION);
our $MANIFEST_SCHEMA = 2;
our $DEFINITION_SCHEMA = 1;
our $LOCAL_SCHEMA = 1;
our $USER_SCHEMA = 1;
our $PARSER_NAME = 'TOML::Tiny::Parser';
our $PARSER_ERROR;
our $PARSER_LOADED;
# Boolean keys of deployment permissions tables; `print` is the only
# non-boolean key. OLLM::Deployment maps them onto qpdf options.
our @PERMISSION_FLAGS = qw(extract modify annotate form assemble);

sub resolve_request {
  my ($class, %arg) = @_;
  my $plan = $arg{plan} // die "missing CLI plan";
  my $start = _absolute_directory(
    $arg{start_dir} // getcwd(), 'request start directory',
  );
  my $standalone = $plan->{standalone} ? 1 : 0;
  my $located =
      $standalone
      && !defined $plan->{config}
      && !defined $plan->{project_root}
    ? { kind => 'none' }
    : $class->find_manifest(
        start_dir    => $start,
        config       => $plan->{config},
        project_root => $plan->{project_root},
      );

  my $request = _base_request($plan, $standalone, $located);

  die "only a Perl configuration was found at $located->{path}; run "
    . "'ollm convertproject' to create ollmconfig.toml\n"
    if $located->{kind} eq 'unconverted';

  return $class->_resolve_none_request($request, $standalone, $start)
    if $located->{kind} eq 'none';

  return $class->_resolve_toml_request($request, $located, $start, %arg);
}

sub _base_request {
  my ($plan, $standalone, $located) = @_;
  my %request = (
    action          => $plan->{action},
    all             => $plan->{all} ? 1 : 0,
    context         => $standalone
      ? 'standalone'
      : $located->{kind} eq 'toml' ? 'series' : 'none',
    dry_run         => $plan->{dry_run} ? 1 : 0,
    latexmk_args    => [@{ $plan->{latexmk_args} }],
    non_interactive => $plan->{non_interactive} ? 1 : 0,
    rebuild         => $plan->{rebuild} ? 1 : 0,
    resolve         => $plan->{resolve} ? 1 : 0,
    scope           => $plan->{scope} // 'current',
    source          => $plan->{source} // 'main.tex',
    target          => $plan->{target},
    target_explicit => $plan->{target_explicit} ? 1 : 0,
    language_explicit => $plan->{language_explicit} ? 1 : 0,
    source_explicit => $plan->{source_explicit} ? 1 : 0,
  );
  $request{language} = $plan->{language} if defined $plan->{language};
  $request{debug} = $plan->{debug} if defined $plan->{debug};
  return \%request;
}

sub _resolve_none_request {
  my ($class, $request, $standalone, $start) = @_;
  if (!$standalone) {
    return {
      request => $request,
      configuration => {
        kind   => 'none',
        parser => $class->parser_info,
      },
    };
  }
  die "standalone builds take document type and language from "
    . "osglecture class options; remove the explicit OLLM target/language"
    if $request->{target_explicit} || $request->{language_explicit};
  die "--all requires a series manifest and is not available in standalone mode"
    if $request->{all};
  my $source_candidate = File::Spec->rel2abs($request->{source}, $start);
  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 ($volume, $directories, $filename) = File::Spec->splitpath($source);
  (my $job_id = $filename) =~ s/\.[^.]+\z//;
  die "cannot derive a job name from standalone source '$source'"
    if $job_id eq '';
  $request->{source} = $source;
  return {
    request => $request,
    configuration => {
      kind   => 'none',
      parser => $class->parser_info,
    },
    standalone_spec => {
      context          => 'standalone',
      source           => $source,
      source_directory => dirname($source),
      job_id           => $job_id,
      shell_escape     => 'restricted',
    },
  };
}

sub _resolve_toml_request {
  my ($class, $request, $located, $start, %arg) = @_;
  my $manifest = $class->load_manifest($located->{path});
  my $user_defaults = $class->load_user_defaults;
  my $root = dirname($located->{path});
  my $structure = $class->structure_snapshot(project_root => $root);
  my $definitions = $class->resolve_definitions(
    manifest       => $manifest,
    manifest_path  => $located->{path},
    project_root   => $root,
    bundle_path    => $arg{definitions_dir},
  );
  $request->{project_root} = $root;
  $request->{series_id} = $manifest->{project}{id};

  my @builds = _select_builds(
    request       => $request,
    manifest      => $manifest,
    definitions   => $definitions,
    structure     => $structure,
    project_root  => $root,
    start         => $start,
    manifest_path => $located->{path},
  );
  $request->{builds} = [map {
    +{
      target        => $_->{target},
      language      => $_->{language},
      source        => $request->{source},
      physical_unit => $_->{physical_unit},
    }
  } @builds];
  if (@builds == 1) {
    $request->{target} = $builds[0]{target};
    $request->{language} = $builds[0]{language};
  } else {
    delete $request->{target};
    delete $request->{language};
  }

  my $resolved = {
    manifest => $manifest,
    request => $request,
    configuration => {
      kind    => 'toml',
      path    => $located->{path},
      bundle_preset => $manifest->{bundle_preset},
      definitions => $definitions,
      structure => $structure,
      user_defaults => $user_defaults,
      schema  => $manifest->{schema},
      parser  => $class->parser_info,
      resolve_max_rounds => exists($manifest->{build})
          && exists($manifest->{build}{resolve})
          && exists($manifest->{build}{resolve}{max_rounds})
        ? $manifest->{build}{resolve}{max_rounds} : 8,
    },
  };
  _attach_build_specs($resolved, $manifest, $root, @builds);
  return $resolved;
}

sub _attach_build_specs {
  my ($resolved, $manifest, $root, @builds) = @_;
  require OLLM::BuildFile;
  my @specs = map {
    OLLM::BuildFile->build_spec(
      resolved       => $resolved,
      manifest       => $manifest,
      unit_directory => File::Spec->catdir($root, $_->{physical_unit}),
      target          => $_->{target},
      language        => $_->{language},
    )
  } @builds;
  my (%job, %directory);
  for my $spec (@specs) {
    my $job_key = lc $spec->{job_id};
    die "build matrix produces job ids that collide on "
      . "case-insensitive filesystems: '$job{$job_key}' and "
      . "'$spec->{job_id}'"
      if exists $job{$job_key};
    $job{$job_key} = $spec->{job_id};
    my $directory_key = lc $spec->{build_directory};
    die "build matrix assigns more than one build to directory "
      . "'$spec->{build_directory}'"
      if $directory{$directory_key}++;
  }
  if (@specs == 1) {
    $resolved->{build_spec} = $specs[0];
  } else {
    $resolved->{build_specs} = \@specs;
  }
  return;
}

sub _select_builds {
  my (%arg) = @_;
  my $request = $arg{request};
  my $manifest = $arg{manifest};
  my $definitions = $arg{definitions};
  my $scope = $request->{scope};
  my @units = @{ $arg{structure}{units} };
  my $current_name = File::Basename::basename($arg{start});
  my ($current) = grep { $_->{physical_unit} eq $current_name } @units;

  die "invalid build scope '$scope'"
    if $scope !~ /\A(?:current|unit|series|collection)\z/;
  die "build --scope=$scope must be run inside a series unit"
    if ($scope eq 'current' || $scope eq 'unit') && !$current;

  my $targets = $manifest->{targets};
  my @target_names = ($scope eq 'unit' || $request->{all})
    ? ($request->{target_explicit}
        ? ($request->{target}) : sort keys %$targets)
    : ($request->{target});
  for my $target (@target_names) {
    die "target '$target' is not configured in $arg{manifest_path}"
      if !defined($target) || !exists $targets->{$target};
  }

  my @selected_units;
  if ($scope eq 'current' || $scope eq 'unit') {
    @selected_units = ($current);
  } elsif ($scope eq 'series') {
    @selected_units = grep { ($_->{unit_role} // 'content') ne 'i' } @units;
  } else {
    my %doctypes = map {
      $definitions->{targets}{$_}{doctype} => 1
    } @target_names;
    for my $doctype (sort keys %doctypes) {
      my @doctype_targets = grep {
        $definitions->{targets}{$_}{doctype} eq $doctype
      } @target_names;
      my @candidates;
      UNIT:
      for my $unit (@units) {
        next if ($unit->{unit_role} // '') ne 'i';
        for my $target (@doctype_targets) {
          if (_unit_allows_definition(
              $unit, $definitions->{targets}{$target}{unit_scopes})) {
            push @candidates, $unit;
            next UNIT;
          }
        }
      }
      die "no integration unit applies to document type '$doctype'"
        if !@candidates;
      die "more than one integration unit applies to document type '$doctype': "
        . join(', ', map { $_->{physical_unit} } @candidates)
        if @candidates > 1;
      push @selected_units, $candidates[0];
    }
  }

  my @builds;
  for my $unit (@selected_units) {
    for my $target (@target_names) {
      my $definition = $definitions->{targets}{$target};
      next if !_unit_allows_definition($unit, $definition->{unit_scopes});
      my @configured = @{ $targets->{$target}{languages} };
      my %configured = map { $_ => 1 } @configured;
      my @languages;
      if ($request->{language_explicit}) {
        if (!$configured{$request->{language}}) {
          die "language '$request->{language}' is not configured for target '$target'"
            if $request->{target_explicit};
          next;
        }
        @languages = ($request->{language});
      } elsif ($scope eq 'unit' || $request->{all}) {
        @languages = @configured;
      } else {
        my $default = $targets->{$target}{default_language}
          // $manifest->{languages}{default};
        @languages = ($configured{$default} ? $default : $configured[0]);
      }
      push @builds, map {
        +{
          physical_unit => $unit->{physical_unit},
          target        => $target,
          language      => $_,
        }
      } @languages;
    }
  }
  die "no configured build matches --scope=$scope" if !@builds;
  return @builds;
}

sub _unit_allows_definition {
  my ($unit, $unit_scopes) = @_;
  return 1 if ($unit->{unit_scope} // '') eq '';
  return scalar grep {
    $_ eq ($unit->{unit_scope} // '')
  } @{ $unit_scopes // [] };
}

# Directory-grammar parsing and discovery live in the shared Lua module
# (osglecture-series-index.lua, called via OLLM::LuaManifest), not here:
# see osglecture/ARCHITECTURE.md section 12 and HISTORY.md. This function
# keeps only what is genuinely OLLM's own concern -- the structure
# signature is OLLM's build-state invalidation value, not something the
# class needs to compute.
sub structure_snapshot {
  my ($class, %arg) = @_;
  my $root = _absolute_directory(
    $arg{project_root} // die("missing project root"),
    'project root',
  );
  require OLLM::LuaManifest;
  my $units = eval { OLLM::LuaManifest::discover_units($root) };
  if (!$units) {
    my $error = $@ || 'unknown error'; chomp $error;
    die "cannot discover series units in '$root': $error\n";
  }
  my $canonical = JSON::PP->new->canonical->encode({
    schema => 1,
    units  => $units,
  });
  return {
    schema    => 1,
    units     => $units,
    signature => sha256_hex($canonical),
  };
}

sub find_manifest {
  my ($class, %arg) = @_;
  my $start = _absolute_directory($arg{start_dir} // getcwd(), 'start directory');

  if (defined $arg{config}) {
    my $path = File::Spec->rel2abs($arg{config}, $start);
    die "configuration file not found: $path" if !-f $path;
    die "--config accepts only a TOML manifest, not '$path'"
      if $path !~ /\.toml\z/i;
    return { kind => 'toml', path => _canonical_path($path) };
  }

  if (defined $arg{project_root}) {
    my $root = _absolute_directory(
      File::Spec->rel2abs($arg{project_root}, $start),
      'project root',
    );
    return _manifest_in($root, 1);
  }

  my $directory = $start;
  while (1) {
    my $found = _manifest_in($directory, 0);
    return $found if $found->{kind} ne 'none';
    my $parent = dirname($directory);
    last if $parent eq $directory;
    $directory = $parent;
  }
  return { kind => 'none' };
}

sub load_manifest {
  my ($class, $path) = @_;
  $class->_load_parser or die
    "TOML parser unavailable: $PARSER_ERROR; reinstall OLLM or install "
    . "TOML::Tiny with 'cpan TOML::Tiny'";
  open my $handle, '<:raw', $path
    or die "cannot read configuration '$path': $!";
  local $/;
  my $source = <$handle>;
  close $handle or die "cannot close configuration '$path': $!";

  my $manifest = eval {
    TOML::Tiny::Parser->new(strict => 1)->parse($source);
  };
  if (!$manifest) {
    my $error = $@ || 'unknown TOML parser error';
    chomp $error;
    die "cannot parse configuration '$path': $error";
  }
  $class->validate_manifest($manifest, $path, _source_map($source));
  return $manifest;
}

sub validate_manifest {
  my ($class, $manifest, $path, $lines) = @_;
  $path //= '<manifest>';
  $lines //= {};
  die "$path: manifest root must be a table" if ref $manifest ne 'HASH';
  _known_keys($manifest,
    [qw(schema bundle_preset project targets security deployment build)],
    $path, $lines, '');
  _validate_schema($manifest, $path, $lines);
  _require_string($manifest, 'bundle_preset', $path)
    if exists $manifest->{bundle_preset};

  _validate_project_section($manifest, $path, $lines);
  _validate_targets_section($manifest, $path, $lines);

  _validate_security_section($manifest, $path, $lines)
    if exists $manifest->{security};
  _validate_deployment($manifest, $path, $lines)
    if exists $manifest->{deployment};
  _validate_build_section($manifest, $path, $lines)
    if exists $manifest->{build};
  _normalize_manifest($manifest);
  return 1;
}

sub _validate_schema {
  my ($manifest, $path, $lines) = @_;
  return if defined($manifest->{schema}) && !ref($manifest->{schema})
    && $manifest->{schema} == $MANIFEST_SCHEMA;
  my $actual = defined($manifest->{schema}) && !ref($manifest->{schema})
    ? $manifest->{schema} : '<missing or non-scalar>';
  _fail_at($path, $lines, 'schema',
    "unsupported project-manifest schema $actual; "
    . "this OLLM supports project-manifest schema $MANIFEST_SCHEMA");
}

sub _validate_project_section {
  my ($manifest, $path, $lines) = @_;
  _require_table($manifest, 'project', $path);
  _known_keys($manifest->{project}, [qw(id tex_directory tex_config)],
    $path, $lines, 'project');
  _require_string($manifest->{project}, 'id', "$path: project");
  my $project = $manifest->{project};
  if (exists $project->{tex_directory}) {
    my $directory = _require_string($project, 'tex_directory', "$path: project");
    _fail_at($path, $lines, 'project.tex_directory',
      'project.tex_directory must be a non-empty project-root-relative path')
      if $directory eq '' || File::Spec->file_name_is_absolute($directory);
  }
  if (exists $project->{tex_config}) {
    my $config = _require_string($project, 'tex_config', "$path: project");
    _fail_at($path, $lines, 'project.tex_config',
      'project.tex_config must be a filename without directory components')
      if $config eq '' || File::Spec->file_name_is_absolute($config)
        || $config =~ m{[\\/]};
  }
}

# Values a target may draw from [targets.defaults] when it does not set its
# own. document_metadata acts only where the resolved profile leaves the
# metadata contract open ('supported'); resolve_definitions rejects it against
# a profile that forces the contract.
sub _validate_document_metadata_key {
  my ($table, $where, $path, $lines) = @_;
  return if !exists $table->{document_metadata};
  my $value = _require_string($table, 'document_metadata', "$path: $where");
  _fail_at($path, $lines, "$where.document_metadata",
    "invalid $where.document_metadata '$value'; expected 'enabled' or 'disabled'")
    if $value !~ /\A(?:enabled|disabled)\z/;
}

sub _validate_targets_section {
  my ($manifest, $path, $lines) = @_;
  _require_table($manifest, 'targets', $path);
  my $targets = $manifest->{targets};

  my $defaults = $targets->{defaults};
  _fail_at($path, $lines, 'targets.defaults',
    'targets.defaults is required and must declare languages and '
    . 'default_language')
    if ref $defaults ne 'HASH';
  _known_keys($defaults,
    [qw(languages default_language presentation_profile longform_profile
        document_metadata)],
    $path, $lines, 'targets.defaults');
  my $available = _require_string_array($defaults, 'languages',
    "$path: targets.defaults");
  _fail_at($path, $lines, 'targets.defaults.languages',
    'targets.defaults.languages must not be empty') if !@$available;
  my %available = map { $_ => 1 } @$available;
  _fail_at($path, $lines, 'targets.defaults.languages',
    'targets.defaults.languages contains duplicates')
    if keys(%available) != @$available;
  _fail_at($path, $lines, 'targets.defaults.languages',
    'targets.defaults.languages contains values that collide on '
    . 'case-insensitive filesystems')
    if (grep { 1 } keys %{{ map { lc($_) => 1 } @$available }}) != @$available;
  my $default_language = _require_string($defaults, 'default_language',
    "$path: targets.defaults");
  _fail_at($path, $lines, 'targets.defaults.default_language',
    "default_language '$default_language' is not listed in "
    . 'targets.defaults.languages')
    if !$available{$default_language};
  _require_string($defaults, $_, "$path: targets.defaults")
    for grep { exists $defaults->{$_} } qw(presentation_profile longform_profile);
  _validate_document_metadata_key($defaults, 'targets.defaults', $path, $lines);

  my @names = grep { $_ ne 'defaults' } keys %$targets;
  _fail_at($path, $lines, 'targets',
    'no build targets are configured (only targets.defaults)')
    if !@names;
  my %folded;
  for my $name (sort @names) {
    my $lc = lc $name;
    _fail_at($path, $lines, "targets.$name",
      "target names '$folded{$lc}' and '$name' collide on "
      . 'case-insensitive filesystems') if exists $folded{$lc};
    $folded{$lc} = $name;
  }
  for my $name (sort @names) {
    my $target = $targets->{$name};
    _fail_at($path, $lines, "targets.$name", "targets.$name must be a table")
      if ref $target ne 'HASH';
    _known_keys($target,
      [qw(languages default_language profile document_metadata)],
      $path, $lines, "targets.$name");
    if (exists $target->{languages}) {
      my $langs = _require_string_array($target, 'languages',
        "$path: targets.$name");
      _fail_at($path, $lines, "targets.$name.languages",
        "targets.$name.languages must not be empty") if !@$langs;
      my %seen;
      for my $lang (@$langs) {
        _fail_at($path, $lines, "targets.$name.languages",
          "targets.$name.languages contains duplicates") if $seen{$lang}++;
        _fail_at($path, $lines, "targets.$name.languages",
          "target '$name' uses language '$lang' not listed in "
          . 'targets.defaults.languages') if !$available{$lang};
      }
    }
    if (exists $target->{default_language}) {
      my $dl = _require_string($target, 'default_language',
        "$path: targets.$name");
      my %effective = map { $_ => 1 } @{ $target->{languages} // $available };
      _fail_at($path, $lines, "targets.$name.default_language",
        "target '$name' default_language '$dl' is not one of its languages")
        if !$effective{$dl};
    }
    _require_string($target, 'profile', "$path: targets.$name")
      if exists $target->{profile};
    _validate_document_metadata_key($target, "targets.$name", $path, $lines);
  }
}

# Schema 2 keeps language and profile declarations in one place -- in
# [targets.defaults] and the individual [targets.<name>] tables. The rest of
# OLLM (build planning, deployment, the build-file writer) expects each target
# to carry its own effective language set and a project-wide [languages]
# block, so both are filled in once here; targets.defaults is lifted out of
# the iterable target set into a private key.
sub _normalize_manifest {
  my ($manifest) = @_;
  return if exists $manifest->{target_defaults};
  my $defaults = delete $manifest->{targets}{defaults};
  $manifest->{target_defaults} = $defaults;
  $manifest->{languages} = {
    available => [ @{ $defaults->{languages} } ],
    default   => $defaults->{default_language},
  };
  for my $name (keys %{ $manifest->{targets} }) {
    my $target = $manifest->{targets}{$name};
    $target->{languages} //= [ @{ $defaults->{languages} } ];
    $target->{default_language} //= $defaults->{default_language};
  }

  my $deployment = $manifest->{deployment};
  if (ref $deployment eq 'HASH' && ref $deployment->{types} eq 'HASH') {
    for my $rule (values %{ $deployment->{types} }) {
      next if ref $rule ne 'HASH';
      $rule->{paths} //= [ @{ $deployment->{paths} } ]
        if ref $deployment->{paths} eq 'ARRAY';
      $rule->{filename} //= $deployment->{filename}
        if defined $deployment->{filename};
      $rule->{collection_filename} //= $deployment->{collection_filename}
        if defined $deployment->{collection_filename};
      $rule->{permissions} //= { %{ $deployment->{permissions} } }
        if ref $deployment->{permissions} eq 'HASH';
    }
  }
}

sub _validate_security_section {
  my ($manifest, $path, $lines) = @_;
  die "$path: security must be a table"
    if ref $manifest->{security} ne 'HASH';
  _known_keys($manifest->{security}, [qw(shell_escape overwrite)],
    $path, $lines, 'security');
  if (exists $manifest->{security}{shell_escape}) {
    my $policy = _require_string(
      $manifest->{security}, 'shell_escape', "$path: security",
    );
    die "$path: invalid security.shell_escape '$policy'"
      if $policy !~ /\A(?:off|restricted|full)\z/;
  }
  if (exists $manifest->{security}{overwrite}) {
    my $overwrite = _require_string(
      $manifest->{security}, 'overwrite', "$path: security",
    );
    die "$path: invalid security.overwrite '$overwrite'"
      if $overwrite !~ /\A(?:explicit|automatic)\z/;
  }
}

sub _validate_build_section {
  my ($manifest, $path, $lines) = @_;
  die "$path: build must be a table" if ref $manifest->{build} ne 'HASH';
  _known_keys($manifest->{build}, [qw(resolve)], $path, $lines, 'build');
  return if !exists $manifest->{build}{resolve};
  my $resolve = $manifest->{build}{resolve};
  die "$path: build.resolve must be a table" if ref $resolve ne 'HASH';
  _known_keys($resolve, [qw(max_rounds)], $path, $lines, 'build.resolve');
  return if !exists $resolve->{max_rounds};
  die "$path: build.resolve.max_rounds must be a positive integer"
    if ref($resolve->{max_rounds})
      || $resolve->{max_rounds} !~ /\A[1-9][0-9]*\z/;
}

sub _validate_deployment {
  my ($manifest, $path, $lines) = @_;
  my $deployment = $manifest->{deployment};
  die "$path: deployment must be a table" if ref $deployment ne 'HASH';
  _known_keys($deployment,
    [qw(series roles types paths filename collection_filename permissions)],
    $path, $lines, 'deployment');
  if (exists $deployment->{series}) {
    my $series = _require_string($deployment, 'series', "$path: deployment");
    die "$path: invalid deployment.series '$series'"
      if $series !~ /\A(?:units|collection|both)\z/;
  }
  if (exists $deployment->{roles}) {
    my $roles = $deployment->{roles};
    die "$path: deployment.roles must be a table" if ref $roles ne 'HASH';
    for my $role (keys %$roles) {
      die "$path: deployment.roles.$role must be a string"
        if !defined($roles->{$role}) || ref($roles->{$role});
    }
  }
  # Default routing every type inherits when it does not set its own; a type
  # override with no paths/filename of its own is only valid with these.
  my $default_paths;
  if (exists $deployment->{paths}) {
    $default_paths = _require_string_array($deployment, 'paths',
      "$path: deployment");
    die "$path: deployment.paths must not be empty" if !@$default_paths;
  }
  _require_string($deployment, 'filename', "$path: deployment")
    if exists $deployment->{filename};
  _require_string($deployment, 'collection_filename', "$path: deployment")
    if exists $deployment->{collection_filename};
  _validate_permissions($deployment, $path, $lines, 'deployment')
    if exists $deployment->{permissions};
  my $types = _require_table($deployment, 'types', "$path: deployment");
  for my $doctype (sort keys %$types) {
    die "$path: invalid deployment document type '$doctype'"
      if $doctype !~ /\A[A-Za-z0-9][A-Za-z0-9._-]*\z/;
    my $rule = $types->{$doctype};
    die "$path: deployment.types.$doctype must be a table"
      if ref $rule ne 'HASH';
    _known_keys($rule,
      [qw(paths filename collection_filename series units permissions)],
      $path, $lines, "deployment.types.$doctype");
    _validate_permissions($rule, $path, $lines, "deployment.types.$doctype")
      if exists $rule->{permissions};
    if (exists $rule->{paths}) {
      my $paths = _require_string_array(
        $rule, 'paths', "$path: deployment.types.$doctype",
      );
      die "$path: deployment.types.$doctype.paths must not be empty"
        if !@$paths;
    }
    elsif (!$default_paths) {
      die "$path: deployment.types.$doctype has no paths and deployment.paths "
        . "is not set";
    }
    _require_string($rule, 'filename', "$path: deployment.types.$doctype")
      if exists $rule->{filename};
    die "$path: deployment.types.$doctype has no filename and "
      . "deployment.filename is not set"
      if !exists $rule->{filename} && !exists $deployment->{filename};
    _require_string($rule, 'collection_filename',
      "$path: deployment.types.$doctype")
      if exists $rule->{collection_filename};
    if (exists $rule->{series}) {
      my $series = _require_string(
        $rule, 'series', "$path: deployment.types.$doctype",
      );
      die "$path: invalid deployment.types.$doctype.series '$series'"
        if $series !~ /\A(?:units|collection|both)\z/;
    }
    next if !exists $rule->{units};
    my $units = $rule->{units};
    die "$path: deployment.types.$doctype.units must be a table"
      if ref $units ne 'HASH';
    for my $unit (sort keys %$units) {
      die "$path: invalid deployment unit-id '$unit'"
        if $unit !~ /\A[^\s,=]+\z/ || index($unit, '...') >= 0;
      my $override = $units->{$unit};
      die "$path: deployment.types.$doctype.units.$unit must be a table"
        if ref $override ne 'HASH';
      _known_keys($override, [qw(filename)], $path, $lines,
        "deployment.types.$doctype.units.$unit");
      _require_string($override, 'filename',
        "$path: deployment.types.$doctype.units.$unit");
    }
  }
}

# PDF permissions applied to deployed copies. Absent keys grant the
# permission; the values are normalized in place to 'full'|'low'|'none' and
# 1|0 so that OLLM::Deployment never sees TOML boolean objects.
sub _validate_permissions {
  my ($table, $path, $lines, $prefix) = @_;
  my $permissions = $table->{permissions};
  die "$path: $prefix.permissions must be a table"
    if ref $permissions ne 'HASH';
  _known_keys($permissions, [@PERMISSION_FLAGS, 'print'], $path, $lines,
    "$prefix.permissions");
  if (exists $permissions->{print}) {
    my $print = _require_string($permissions, 'print',
      "$path: $prefix.permissions");
    die "$path: invalid $prefix.permissions.print '$print'; "
      . "expected full, low, or none"
      if $print !~ /\A(?:full|low|none)\z/;
  }
  for my $flag (grep { exists $permissions->{$_} } @PERMISSION_FLAGS) {
    my $value = $permissions->{$flag};
    my $boolean = ref $value
      ? eval { $value->isa('JSON::PP::Boolean')
          || $value->isa('Types::Serialiser::Boolean') }
      : defined($value) && $value =~ /\A[01]\z/;
    _fail_at($path, $lines, "$prefix.permissions.$flag",
      "$prefix.permissions.$flag must be true or false") if !$boolean;
    $permissions->{$flag} = $value ? 1 : 0;
  }
}

sub load_user_defaults {
  my ($class) = @_;
  my $path;
  if (defined $ENV{OLLM_USER_CONFIG} && $ENV{OLLM_USER_CONFIG} ne '') {
    $path = File::Spec->rel2abs($ENV{OLLM_USER_CONFIG});
    die "OLLM user configuration not found: $path" if !-f $path;
  } elsif ($^O eq 'MSWin32' && defined $ENV{APPDATA}) {
    $path = File::Spec->catfile($ENV{APPDATA}, 'ollm', 'config.toml');
  } elsif (defined $ENV{XDG_CONFIG_HOME} && $ENV{XDG_CONFIG_HOME} ne '') {
    $path = File::Spec->catfile($ENV{XDG_CONFIG_HOME}, 'ollm', 'config.toml');
  } elsif (defined $ENV{HOME} && $ENV{HOME} ne '') {
    $path = File::Spec->catfile($ENV{HOME}, '.config', 'ollm', 'config.toml');
  }
  return {} if !defined $path || !-f $path;
  my ($data, $lines) = $class->_load_toml($path);
  _known_keys($data, [qw(schema bundle_preset)], $path, $lines, '');
  if (!defined $data->{schema} || ref $data->{schema}
      || $data->{schema} != $USER_SCHEMA) {
    my $actual = defined($data->{schema}) && !ref($data->{schema})
      ? $data->{schema} : '<missing or non-scalar>';
    _fail_at($path, $lines, 'schema',
      "unsupported user-configuration schema $actual; "
      . "this OLLM supports user-configuration schema $USER_SCHEMA");
  }
  _require_string($data, 'bundle_preset', $path)
    if exists $data->{bundle_preset};
  $data->{path} = $path;
  return $data;
}

sub resolve_definitions {
  my ($class, %arg) = @_;
  my $manifest = $arg{manifest} // die "missing manifest";
  my $manifest_path = $arg{manifest_path} // '<manifest>';
  my $root = $arg{project_root} // die "missing project root";
  my $manifest_lines = -f $manifest_path
    ? _source_map_from_file($manifest_path)
    : {};
  my @paths;

  my $local_path = File::Spec->catfile($root, '.ollmconfig.local.toml');
  if (-f $local_path) {
    my $local = $class->_load_local_config($local_path);
    for my $path (@{ $local->{definitions}{paths} // [] }) {
      push @paths, File::Spec->file_name_is_absolute($path)
        ? $path
        : File::Spec->catdir($root, $path);
    }
  }
  push @paths, $arg{bundle_path} if defined $arg{bundle_path};

  my (%preset, %target, %profile);
  my %index_for = (
    'bundle-preset' => \%preset,
    'target'        => \%target,
    'profile'       => \%profile,
  );
  for my $path (@paths) {
    die "definition search path not found: $path" if !-d $path;
    my @files;
    find(
      sub { push @files, $File::Find::name if -f $_ && /\.toml\z/i },
      $path,
    );
    for my $file (sort @files) {
      my $definition = $class->_load_definition($file);
      my $index = $index_for{ $definition->{kind} };
      my $reference = $definition->{kind} eq 'bundle-preset'
        ? "$definition->{name}/$definition->{version}"
        : $definition->{name};
      if (exists $index->{$reference}) {
        die "duplicate $definition->{kind} definition '$reference': "
          . "$index->{$reference}{path} and $file";
      }
      $index->{$reference} = {
        path => abs_path($file),
        data => $definition,
      };
    }
  }

  # A manifest without bundle_preset uses the personal default, then the
  # built-in one. Every caller (build, report/check, doctor) resolves through
  # here, and later build steps read the filled-in value from the manifest.
  $manifest->{bundle_preset} //=
    $class->load_user_defaults->{bundle_preset} // 'OSG lecture/1';
  my $preset_name = $manifest->{bundle_preset};
  if (!exists $preset{$preset_name}) {
    my $available = keys(%preset)
      ? join(', ', map { "'$_'" } sort keys %preset)
      : '<none>';
    _fail_at($manifest_path, $manifest_lines, 'bundle_preset',
      "requested bundle preset '$preset_name' was not found; "
      . "available bundle presets: $available; searched: " . join(', ', @paths));
  }

  my %profiles;
  for my $name (sort keys %profile) {
    $profiles{$name} = {
      document_metadata => $profile{$name}{data}{document_metadata},
      doctypes          => $profile{$name}{data}{doctypes},
      profile_class     => $profile{$name}{data}{profile_class},
      path              => $profile{$name}{path},
      version           => $profile{$name}{data}{version},
      signature => sha256_hex(
        JSON::PP->new->canonical->encode($profile{$name}{data}),
      ),
    };
  }

  my $target_defaults = $manifest->{target_defaults} // {};
  my %selected_targets;
  my %selected_target_data;
  for my $name (sort keys %{ $manifest->{targets} }) {
    _fail_at($manifest_path, $manifest_lines, "targets.$name",
      "target '$name' was not found; searched: " . join(', ', @paths))
      if !exists $target{$name};
    my $definition = $target{$name}{data};
    my $profile_class = $definition->{profile_class};
    my $profile_name =
         $manifest->{targets}{$name}{profile}
      // $target_defaults->{"${profile_class}_profile"}
      // $preset{$preset_name}{data}{"${profile_class}_profile"};
    _fail_at($manifest_path, $manifest_lines, "targets.$name",
      "target '$name' has no ${profile_class} profile; set profile in "
      . "targets.$name or ${profile_class}_profile in targets.defaults")
      if !defined $profile_name;
    _fail_at($manifest_path, $manifest_lines, "targets.$name",
      "target '$name' selects profile '$profile_name', which was not found; "
      . 'searched: ' . join(', ', @paths))
      if !exists $profile{$profile_name};
    my $profile_data = $profile{$profile_name}{data};
    _fail_at($manifest_path, $manifest_lines, "targets.$name",
      "target '$name' is a $profile_class target but profile '$profile_name' "
      . "is a $profile_data->{profile_class} profile")
      if $profile_data->{profile_class} ne $profile_class;
    _fail_at($manifest_path, $manifest_lines, "targets.$name",
      "profile '$profile_name' does not support document type "
      . "'$definition->{doctype}'")
      if !grep { $_ eq $definition->{doctype} } @{ $profile_data->{doctypes} };

    my $metadata_choice = $manifest->{targets}{$name}{document_metadata}
      // $target_defaults->{document_metadata};
    my $metadata_policy = _resolve_metadata_policy(
      $profile_data->{document_metadata}, $metadata_choice,
      $manifest_path, $manifest_lines, "targets.$name", $profile_name,
    );

    $selected_targets{$name} = {
      doctype       => $definition->{doctype},
      profile_class => $profile_class,
      profile       => $profile_name,
      profile_signature => $profiles{$profile_name}{signature},
      document_metadata => $metadata_policy,
      path    => $target{$name}{path},
      signature => sha256_hex(
        JSON::PP->new->canonical->encode($definition),
      ),
      version => $definition->{version},
      unit_scopes => $definition->{unit_scopes} // [],
    };
    $selected_target_data{$name} = $definition;
  }
  my $signature = sha256_hex(
    JSON::PP->new->canonical->encode({
      manifest => $manifest,
      bundle_preset => $preset{$preset_name}{data},
      targets  => \%selected_target_data,
    }),
  );
  return {
    definition_signature => $signature,
    local_config => -f $local_path ? abs_path($local_path) : undef,
    bundle_preset => {
      name    => $preset{$preset_name}{data}{name},
      path    => $preset{$preset_name}{path},
      reference => $preset_name,
      version => $preset{$preset_name}{data}{version},
      signature => sha256_hex(
        JSON::PP->new->canonical->encode($preset{$preset_name}{data}),
      ),
    },
    search_paths => [map { abs_path($_) // $_ } @paths],
    targets      => \%selected_targets,
    profiles     => \%profiles,
  };
}

# Turns the profile's metadata capability plus the project's optional
# document_metadata choice into the single build-file policy the class reads.
# 'required'/'forbidden' profiles fix the policy and reject a contradicting
# choice; only a 'supported' profile lets the project decide, defaulting off.
sub _resolve_metadata_policy {
  my ($capability, $choice, $manifest_path, $lines, $where, $profile_name) = @_;
  if ($capability eq 'required') {
    _fail_at($manifest_path, $lines, "$where.document_metadata",
      "$where sets document_metadata = 'disabled', but profile "
      . "'$profile_name' requires document metadata")
      if defined $choice && $choice eq 'disabled';
    return 'enabled';
  }
  if ($capability eq 'forbidden') {
    _fail_at($manifest_path, $lines, "$where.document_metadata",
      "$where sets document_metadata = 'enabled', but profile "
      . "'$profile_name' forbids document metadata")
      if defined $choice && $choice eq 'enabled';
    return 'disabled';
  }
  return defined $choice ? $choice : 'disabled';
}

sub _load_local_config {
  my ($class, $path) = @_;
  my ($data, $lines) = $class->_load_toml($path);
  _known_keys($data, [qw(schema definitions)], $path, $lines, '');
  if (!defined $data->{schema} || ref $data->{schema}
      || $data->{schema} != $LOCAL_SCHEMA) {
    my $actual = defined($data->{schema}) && !ref($data->{schema})
      ? $data->{schema} : '<missing or non-scalar>';
    _fail_at($path, $lines, 'schema',
      "unsupported local-configuration schema $actual; "
      . "this OLLM supports local-configuration schema $LOCAL_SCHEMA");
  }
  _require_table($data, 'definitions', $path);
  _known_keys($data->{definitions}, [qw(paths)], $path, $lines, 'definitions');
  _require_string_array($data->{definitions}, 'paths', "$path: definitions");
  return $data;
}

sub _load_definition {
  my ($class, $path) = @_;
  my ($data, $lines) = $class->_load_toml($path);
  _known_keys($data,
    [qw(schema kind name version doctype doctypes profile_class unit_scopes
        document_metadata presentation_profile longform_profile)],
    $path, $lines, '');
  if (!defined $data->{schema} || ref $data->{schema}
      || $data->{schema} != $DEFINITION_SCHEMA) {
    my $actual = defined($data->{schema}) && !ref($data->{schema})
      ? $data->{schema} : '<missing or non-scalar>';
    _fail_at($path, $lines, 'schema',
      "unsupported definition schema $actual; "
      . "this OLLM supports definition schema $DEFINITION_SCHEMA");
  }
  my $kind = _require_string($data, 'kind', $path);
  _fail_at($path, $lines, 'kind', "unknown definition kind '$kind'")
    if $kind !~ /\A(?:bundle-preset|target|profile)\z/;
  _require_string($data, 'name', $path);
  _require_string($data, 'version', $path);
  if ($kind eq 'bundle-preset') {
    for my $reject (qw(doctype doctypes profile_class unit_scopes
        document_metadata)) {
      _fail_at($path, $lines, $reject,
        "bundle preset must not define '$reject'")
        if exists $data->{$reject};
    }
    _require_string($data, $_, $path)
      for grep { exists $data->{$_} } qw(presentation_profile longform_profile);
  } elsif ($kind eq 'profile') {
    # The profile capability projection mirrors, for OLLM's pre-class use,
    # exactly the \DeclareOsgLectureProfile fields that decide the build
    # (metadata contract, supported doctypes, profile class). Anything about
    # rendering -- backend, adapter, class options -- stays TeX-only.
    for my $reject (qw(doctype unit_scopes presentation_profile
        longform_profile)) {
      _fail_at($path, $lines, $reject,
        "profile definition must not define '$reject'")
        if exists $data->{$reject};
    }
    my $metadata = _require_string($data, 'document_metadata', $path);
    _fail_at($path, $lines, 'document_metadata',
      "invalid profile document_metadata '$metadata'; "
      . "expected 'required', 'supported' or 'forbidden'")
      if $metadata !~ /\A(?:required|supported|forbidden)\z/;
    my $profile_class = _require_string($data, 'profile_class', $path);
    _fail_at($path, $lines, 'profile_class',
      "invalid profile profile_class '$profile_class'; "
      . "expected 'presentation' or 'longform'")
      if $profile_class !~ /\A(?:presentation|longform)\z/;
    my $doctypes = _require_string_array($data, 'doctypes', $path);
    _fail_at($path, $lines, 'doctypes', 'profile doctypes must not be empty')
      if !@$doctypes;
  } else {
    for my $reject (qw(doctypes document_metadata presentation_profile
        longform_profile)) {
      _fail_at($path, $lines, $reject,
        "target definition must not define '$reject'")
        if exists $data->{$reject};
    }
    _require_string($data, 'doctype', $path);
    my $profile_class = _require_string($data, 'profile_class', $path);
    _fail_at($path, $lines, 'profile_class',
      "invalid target profile_class '$profile_class'; "
      . "expected 'presentation' or 'longform'")
      if $profile_class !~ /\A(?:presentation|longform)\z/;
    _fail_at($path, $lines, 'doctype',
      "target name '$data->{name}' and doctype '$data->{doctype}' differ; "
      . "schema 1 has no versioned target/doctype adapter contract")
      if $data->{name} ne $data->{doctype};
    if (exists $data->{unit_scopes}) {
      my $unit_scopes = _require_string_array(
        $data, 'unit_scopes', $path,
      );
      for my $unit_scope (@$unit_scopes) {
        _fail_at($path, $lines, 'unit_scopes',
          "invalid target unit scope '$unit_scope'")
          if $unit_scope !~ /\A[a-z]{1,2}\z/;
      }
    }
  }
  return $data;
}

sub _load_toml {
  my ($class, $path) = @_;
  $class->_load_parser or die "TOML parser unavailable: $PARSER_ERROR";
  open my $handle, '<:raw', $path or die "cannot read configuration '$path': $!";
  local $/;
  my $source = <$handle>;
  close $handle;
  my $data = eval { TOML::Tiny::Parser->new(strict => 1)->parse($source) };
  if (!$data) {
    my $error = $@ || 'unknown TOML parser error';
    chomp $error;
    die "cannot parse configuration '$path': $error";
  }
  return ($data, _source_map($source));
}

sub parser_info {
  my ($class) = @_;
  my $available = $class->_load_parser;
  my $loaded_from = $INC{'TOML/Tiny/Parser.pm'};
  my $source = $available && defined $loaded_from
    && $loaded_from =~ m{(?:^|/)vendor/TOML-Tiny-0[.]22/}
      ? 'bundled'
      : $available ? 'system' : 'missing';
  return {
    available => $available ? 1 : 0,
    error    => $PARSER_ERROR,
    name     => $PARSER_NAME,
    version  => $available ? $TOML::Tiny::Parser::VERSION : undef,
    source   => $source,
    standard => 'TOML 1.0',
  };
}

sub _load_parser {
  return 1 if $PARSER_LOADED;
  my $loaded = eval {
    require TOML::Tiny::Parser;
    1;
  };
  if (!$loaded) {
    $PARSER_ERROR = $@ || 'unknown loader error';
    chomp $PARSER_ERROR;
    return 0;
  }
  $PARSER_LOADED = 1;
  $PARSER_ERROR = undef;
  return 1;
}

sub _manifest_in {
  my ($directory, $required) = @_;
  my $toml = File::Spec->catfile($directory, 'ollmconfig.toml');
  my $perl = File::Spec->catfile($directory, 'ollmconfig.pl');
  return { kind => 'toml', path => _canonical_path($toml) } if -f $toml;
  die "no ollmconfig.toml found in project root $directory"
    if $required;
  return { kind => 'unconverted', path => _canonical_path($perl) }
    if -f $perl;
  return { kind => 'none' };
}

sub _canonical_path {
  my ($path) = @_;
  my $absolute = abs_path($path);
  return defined $absolute ? File::Spec->canonpath($absolute) : undef;
}

sub _absolute_directory {
  my ($path, $label) = @_;
  my $absolute = abs_path($path);
  die "$label not found: $path" if !defined $absolute;
  die "$label is not a directory: $absolute" if !-d $absolute;
  return $absolute;
}

sub _source_map {
  my ($source) = @_;
  my %lines;
  my $table = '';
  my @source_lines = split /\n/, $source, -1;
  for my $index (0 .. $#source_lines) {
    my $line = $source_lines[$index];
    if ($line =~ /^\s*\[\s*([A-Za-z0-9_.-]+)\s*\]\s*(?:#.*)?$/) {
      $table = $1;
      $lines{$table} //= $index + 1;
      next;
    }
    next if $line !~ /^\s*([A-Za-z0-9_-]+)\s*=/;
    my $key = $1;
    my $path = length($table) ? "$table.$key" : $key;
    $lines{$path} //= $index + 1;
  }
  return \%lines;
}

sub _source_map_from_file {
  my ($path) = @_;
  open my $handle, '<:raw', $path
    or die "cannot read configuration '$path': $!";
  local $/;
  my $source = <$handle>;
  close $handle;
  return _source_map($source);
}

sub _known_keys {
  my ($table, $allowed, $file, $lines, $prefix) = @_;
  my %allowed = map { $_ => 1 } @$allowed;
  for my $key (sort keys %$table) {
    next if $allowed{$key};
    my $path = length($prefix) ? "$prefix.$key" : $key;
    _fail_at($file, $lines, $path, "unknown key '$path'");
  }
}

sub _fail_at {
  my ($file, $lines, $path, $message) = @_;
  my $line = $lines->{$path};
  die defined($line) ? "$file:$line: $message" : "$file: $message";
}

sub _require_table {
  my ($table, $key, $where) = @_;
  die "$where: missing table '$key'" if !exists $table->{$key};
  die "$where: '$key' must be a table" if ref $table->{$key} ne 'HASH';
  return $table->{$key};
}

sub _require_string {
  my ($table, $key, $where) = @_;
  die "$where: missing '$key'" if !exists $table->{$key};
  my $value = $table->{$key};
  die "$where: '$key' must be a non-empty string"
    if ref $value || !defined $value || $value eq '';
  return $value;
}

sub _require_string_array {
  my ($table, $key, $where) = @_;
  die "$where: missing '$key'" if !exists $table->{$key};
  my $values = $table->{$key};
  die "$where: '$key' must be an array" if ref $values ne 'ARRAY';
  for my $value (@$values) {
    die "$where: '$key' entries must be non-empty strings"
      if ref $value || !defined $value || $value eq '';
  }
  return $values;
}

1;
