///|
fn validate_index_document_ids(
  documents : Array[RomanDocument],
) -> Result[Unit, RomanIndexConstructionError] {
  let seen : @hashmap.HashMap[String, Int] = @hashmap.HashMap([])
  for index = 0; index < documents.length(); index = index + 1 {
    if is_blank_batch_id(documents[index].id) {
      return Err(EmptyIndexDocumentId(index))
    }
    if seen.contains(documents[index].id) {
      return Err(DuplicateIndexDocumentId(documents[index].id))
    }
    seen.set(documents[index].id, index)
  }
  Ok(())
}

///|
fn accumulate_index_statistic(total : Int64, increment : Int) -> Int64 {
  total + increment.to_int64()
}

///|
fn validate_index_report_span(
  document_id : String,
  source_chars : Array[Char],
  kind : RomanIndexEvidenceKind,
  item_index : Int,
  span : SourceSpan,
  retained_source : String,
) -> Result[Unit, RomanIndexConstructionError] {
  if span.start < 0 ||
    span.end <= span.start ||
    span.end > source_chars.length() {
    return Err(
      IndexReportInvalidSpan(
        document_id,
        kind,
        item_index,
        span,
        source_chars.length(),
      ),
    )
  }
  let actual_source = scan_substring(source_chars, span.start, span.end)
  if actual_source != retained_source {
    return Err(
      IndexReportSourceMismatch(
        document_id, kind, item_index, actual_source, retained_source,
      ),
    )
  }
  Ok(())
}

///|
fn validate_index_match_policy(
  matched : RomanScanMatch,
  config : ScanConfig,
) -> Bool {
  let source_length = matched.span.end - matched.span.start
  source_length <= config.max_candidate_length &&
  (
    config.include_single_symbol ||
    matched.report.normalized.to_array().length() != 1
  )
}

///|
fn validate_index_report_matches(
  document_id : String,
  source_chars : Array[Char],
  config : ScanConfig,
  matches : Array[RomanScanMatch],
) -> Result[Unit, RomanIndexConstructionError] {
  for index = 0; index < matches.length(); index = index + 1 {
    let matched = matches[index]
    match
      validate_index_report_span(
        document_id,
        source_chars,
        IndexMatchEvidence,
        index,
        matched.span,
        matched.source_text,
      ) {
      Err(error) => return Err(error)
      Ok(_) => ()
    }
    if index > 0 {
      if matched.span.start < matches[index - 1].span.start {
        return Err(
          IndexReportOutOfOrder(document_id, IndexMatchEvidence, index),
        )
      }
      if matched.span.start < matches[index - 1].span.end {
        return Err(
          IndexReportOverlappingEvidence(
            document_id,
            IndexMatchEvidence,
            index - 1,
            IndexMatchEvidence,
            index,
          ),
        )
      }
    }
    let reparsed = match
      parse_with_config(matched.source_text, config.parse_config) {
      Err(error) =>
        return Err(IndexReportMatchParseFailed(document_id, index, error))
      Ok(report) => report
    }
    if reparsed != matched.report {
      return Err(IndexReportMatchEvidenceMismatch(document_id, index))
    }
    if !validate_index_match_policy(matched, config) {
      return Err(IndexReportMatchPolicyMismatch(document_id, index))
    }
  }
  Ok(())
}

///|
fn index_rejection_reason_matches(
  rejected : RomanScanRejection,
  config : ScanConfig,
) -> Bool {
  let source_length = rejected.span.end - rejected.span.start
  match rejected.reason {
    CandidateTooLong(retained_length) =>
      source_length > config.max_candidate_length &&
      retained_length == source_length
    SingleSymbolExcluded =>
      if source_length > config.max_candidate_length ||
        config.include_single_symbol {
        false
      } else {
        match parse_with_config(rejected.source_text, config.parse_config) {
          Ok(report) => report.normalized.to_array().length() == 1
          Err(_) => false
        }
      }
    CandidateParseFailed(retained_error) =>
      if source_length > config.max_candidate_length {
        false
      } else {
        match parse_with_config(rejected.source_text, config.parse_config) {
          Err(actual_error) => actual_error == retained_error
          Ok(_) => false
        }
      }
  }
}

///|
fn validate_index_report_rejections(
  document_id : String,
  source_chars : Array[Char],
  config : ScanConfig,
  rejections : Array[RomanScanRejection],
) -> Result[Unit, RomanIndexConstructionError] {
  if !config.retain_rejected && !rejections.is_empty() {
    return Err(IndexReportUnexpectedRejectionDetails(document_id))
  }
  for index = 0; index < rejections.length(); index = index + 1 {
    let rejected = rejections[index]
    match
      validate_index_report_span(
        document_id,
        source_chars,
        IndexRejectionEvidence,
        index,
        rejected.span,
        rejected.source_text,
      ) {
      Err(error) => return Err(error)
      Ok(_) => ()
    }
    if index > 0 {
      if rejected.span.start < rejections[index - 1].span.start {
        return Err(
          IndexReportOutOfOrder(document_id, IndexRejectionEvidence, index),
        )
      }
      if rejected.span.start < rejections[index - 1].span.end {
        return Err(
          IndexReportOverlappingEvidence(
            document_id,
            IndexRejectionEvidence,
            index - 1,
            IndexRejectionEvidence,
            index,
          ),
        )
      }
    }
    if !index_rejection_reason_matches(rejected, config) {
      return Err(IndexReportRejectionReasonMismatch(document_id, index))
    }
  }
  Ok(())
}

///|
struct IndexCrossOverlapScan {
  overlap : (Int, Int)?
  steps : Int
} derive(Eq, Debug)

///|
fn scan_index_cross_overlaps(
  match_spans : Array[SourceSpan],
  rejection_spans : Array[SourceSpan],
) -> IndexCrossOverlapScan {
  let mut match_index = 0
  let mut rejection_index = 0
  let mut steps = 0
  while match_index < match_spans.length() &&
        rejection_index < rejection_spans.length() {
    steps = steps + 1
    let matched = match_spans[match_index]
    let rejected = rejection_spans[rejection_index]
    if matched.start < rejected.end && rejected.start < matched.end {
      return { overlap: Some((match_index, rejection_index)), steps }
    }
    if matched.end <= rejected.start {
      match_index = match_index + 1
    } else {
      rejection_index = rejection_index + 1
    }
  }
  { overlap: None, steps }
}

///|
fn validate_index_report_cross_overlaps(
  document_id : String,
  matches : Array[RomanScanMatch],
  rejections : Array[RomanScanRejection],
) -> Result[Unit, RomanIndexConstructionError] {
  let match_spans : Array[SourceSpan] = []
  let rejection_spans : Array[SourceSpan] = []
  for matched in matches {
    match_spans.push(matched.span)
  }
  for rejected in rejections {
    rejection_spans.push(rejected.span)
  }
  match scan_index_cross_overlaps(match_spans, rejection_spans).overlap {
    Some((match_index, rejection_index)) =>
      Err(
        IndexReportOverlappingEvidence(
          document_id,
          IndexMatchEvidence,
          match_index,
          IndexRejectionEvidence,
          rejection_index,
        ),
      )
    None => Ok(())
  }
}

///|
fn validate_index_scan_report_against_expected(
  document_id : String,
  source : String,
  config : ScanConfig,
  expected : RomanScanReport,
  report : RomanScanReport,
) -> Result[Unit, RomanIndexConstructionError] {
  match validate_scan_config(config) {
    Err(error) => return Err(IndexDocumentScanFailed(document_id, error))
    Ok(_) => ()
  }
  let retained_count = report.matches.length() + report.rejections.length()
  if report.candidates_examined < retained_count {
    return Err(
      IndexReportCandidateCountTooSmall(
        document_id,
        report.candidates_examined,
        retained_count,
      ),
    )
  }
  let source_chars = source.to_array()
  match
    validate_index_report_matches(
      document_id,
      source_chars,
      config,
      report.matches,
    ) {
    Err(error) => return Err(error)
    Ok(_) => ()
  }
  match
    validate_index_report_rejections(
      document_id,
      source_chars,
      config,
      report.rejections,
    ) {
    Err(error) => return Err(error)
    Ok(_) => ()
  }
  match
    validate_index_report_cross_overlaps(
      document_id,
      report.matches,
      report.rejections,
    ) {
    Err(error) => return Err(error)
    Ok(_) => ()
  }
  if expected != report {
    return Err(IndexScanReportMismatch(document_id))
  }
  Ok(())
}

///|
fn validate_index_scan_report(
  document_id : String,
  source : String,
  config : ScanConfig,
  report : RomanScanReport,
) -> Result[Unit, RomanIndexConstructionError] {
  let expected = match scan_roman_text(source, config) {
    Err(error) => return Err(IndexDocumentScanFailed(document_id, error))
    Ok(expected) => expected
  }
  validate_index_scan_report_against_expected(
    document_id, source, config, expected, report,
  )
}

///|
fn index_entry_from_match(
  document_id : String,
  mode : RomanMode,
  matched : RomanScanMatch,
  ordinal : Int,
) -> RomanIndexEntry {
  let parsed = matched.report
  {
    document_id,
    source_span: matched.span,
    source_text: matched.source_text,
    value: parsed.value,
    canonical: parsed.canonical,
    normalized_text: parsed.normalized,
    mode,
    used_unicode_compatibility: parsed.used_unicode_compatibility,
    normalized_input: parsed.original != parsed.normalized,
    trimmed_outer_whitespace: parsed.trimmed_outer_whitespace,
    token_count: parsed.tokens.length(),
    ordinal,
  }
}

///|
fn rejection_summary_from_scan(
  document_id : String,
  rejected : RomanScanRejection,
) -> RomanIndexRejectionSummary {
  {
    document_id,
    source_span: rejected.span,
    source_text: rejected.source_text,
    reason: rejected.reason,
  }
}

///|
fn materialize_verified_scan_index(
  document_id : String,
  source : String,
  config : ScanConfig,
  report : RomanScanReport,
  first_global_ordinal : Int,
) -> RomanDocumentIndex {
  let entries : Array[RomanIndexEntry] = []
  let rejections : Array[RomanIndexRejectionSummary] = []
  let mut used_unicode_compatibility = 0L
  let mut normalized_inputs = 0L
  for matched in report.matches {
    let entry = index_entry_from_match(
      document_id,
      config.parse_config.mode,
      matched,
      first_global_ordinal + entries.length(),
    )
    if entry.used_unicode_compatibility {
      used_unicode_compatibility = accumulate_index_statistic(
        used_unicode_compatibility, 1,
      )
    }
    if entry.normalized_input {
      normalized_inputs = accumulate_index_statistic(normalized_inputs, 1)
    }
    entries.push(entry)
  }
  for rejected in report.rejections {
    rejections.push(rejection_summary_from_scan(document_id, rejected))
  }
  let candidates_examined = accumulate_index_statistic(
    0L,
    report.candidates_examined,
  )
  let accepted_entries = accumulate_index_statistic(0L, report.matches.length())
  let rejected_count = report.candidates_examined - report.matches.length()
  let rejected_candidates = accumulate_index_statistic(0L, rejected_count)
  let first_ordinal = if entries.is_empty() {
    None
  } else {
    Some(first_global_ordinal)
  }
  {
    entries,
    documents: [
      {
        id: document_id,
        source_length: source.to_array().length(),
        parse_mode: config.parse_config.mode,
        candidates_examined: report.candidates_examined,
        accepted_count: report.matches.length(),
        rejected_count,
        first_ordinal,
      },
    ],
    rejections,
    statistics: {
      total_documents: 1L,
      candidates_examined,
      accepted_entries,
      rejected_candidates,
      used_unicode_compatibility,
      normalized_inputs,
    },
  }
}

///|
fn build_index_from_validated_scan(
  document_id : String,
  source : String,
  config : ScanConfig,
  report : RomanScanReport,
  first_global_ordinal : Int,
) -> Result[RomanDocumentIndex, RomanIndexConstructionError] {
  match validate_index_scan_report(document_id, source, config, report) {
    Err(error) => return Err(error)
    Ok(_) => ()
  }
  Ok(
    materialize_verified_scan_index(
      document_id, source, config, report, first_global_ordinal,
    ),
  )
}

///|
fn append_document_index(
  entries : Array[RomanIndexEntry],
  documents : Array[RomanIndexDocumentMetadata],
  rejections : Array[RomanIndexRejectionSummary],
  document_index : RomanDocumentIndex,
) -> Unit {
  for entry in document_index.entries {
    entries.push(entry)
  }
  documents.push(document_index.documents[0])
  for rejected in document_index.rejections {
    rejections.push(rejected)
  }
}

///|
/// Build from an existing report after rescanning to reject mutable evidence.
/// This correctness check is not a performance optimization.
pub fn build_roman_index_from_scan_report(
  document_id : String,
  source : String,
  config : ScanConfig,
  report : RomanScanReport,
) -> Result[RomanDocumentIndex, RomanIndexConstructionError] {
  let documents : Array[RomanDocument] = [
    { id: document_id, text: source, config },
  ]
  match validate_index_document_ids(documents) {
    Err(error) => return Err(error)
    Ok(_) => ()
  }
  build_index_from_validated_scan(document_id, source, config, report, 0)
}

///|
/// Build an index for one document by rescanning caller-owned source/config.
pub fn build_roman_index_from_scan(
  document_id : String,
  source : String,
  config : ScanConfig,
) -> Result[RomanDocumentIndex, RomanIndexConstructionError] {
  let documents : Array[RomanDocument] = [
    { id: document_id, text: source, config },
  ]
  match validate_index_document_ids(documents) {
    Err(error) => return Err(error)
    Ok(_) => ()
  }
  match scan_roman_text(source, config) {
    Err(error) => Err(IndexDocumentScanFailed(document_id, error))
    Ok(report) =>
      Ok(
        materialize_verified_scan_index(document_id, source, config, report, 0),
      )
  }
}

///|
fn validate_corpus_report_identity(
  inputs : Array[RomanDocument],
  report : RomanCorpusReport,
) -> Result[Unit, RomanIndexConstructionError] {
  if report.documents.length() != inputs.length() {
    return Err(
      IndexReportDocumentCountMismatch(
        inputs.length(),
        report.documents.length(),
      ),
    )
  }
  for index = 0; index < inputs.length(); index = index + 1 {
    if report.documents[index].id != inputs[index].id {
      return Err(
        IndexReportDocumentIdMismatch(
          index,
          inputs[index].id,
          report.documents[index].id,
        ),
      )
    }
    if report.documents[index].text != inputs[index].text {
      return Err(
        IndexReportDocumentTextMismatch(
          index,
          inputs[index].text,
          report.documents[index].text,
        ),
      )
    }
  }
  Ok(())
}

///|
fn validated_corpus_scan(
  input : RomanDocument,
  expected_result : RomanDocumentResult,
  result : RomanDocumentResult,
) -> Result[RomanScanReport, RomanIndexConstructionError] {
  match (expected_result.outcome, result.outcome) {
    (CorpusScanned(expected), CorpusScanned(scan)) =>
      match
        validate_index_scan_report_against_expected(
          input.id,
          input.text,
          input.config,
          expected,
          scan,
        ) {
        Err(error) => Err(error)
        Ok(_) => Ok(scan)
      }
    (CorpusScanned(_), CorpusScanFailed(error)) =>
      Err(IndexReportUnexpectedScanFailure(input.id, error))
    (CorpusScanFailed(expected), CorpusScanned(_)) =>
      Err(IndexReportUnexpectedScanSuccess(input.id, expected))
    (CorpusScanFailed(expected), CorpusScanFailed(actual)) =>
      if expected == actual {
        Err(IndexDocumentScanFailed(input.id, actual))
      } else {
        Err(IndexReportScanFailureMismatch(input.id, expected, actual))
      }
  }
}

///|
fn build_index_from_trusted_corpus(
  inputs : Array[RomanDocument],
  report : RomanCorpusReport,
) -> Result[RomanDocumentIndex, RomanIndexConstructionError] {
  let entries : Array[RomanIndexEntry] = []
  let documents : Array[RomanIndexDocumentMetadata] = []
  let rejections : Array[RomanIndexRejectionSummary] = []
  let mut candidates_examined = 0L
  let mut rejected_candidates = 0L
  let mut used_unicode_compatibility = 0L
  let mut normalized_inputs = 0L
  for index = 0; index < inputs.length(); index = index + 1 {
    let scan = match report.documents[index].outcome {
      CorpusScanFailed(error) =>
        return Err(IndexDocumentScanFailed(inputs[index].id, error))
      CorpusScanned(scan) => scan
    }
    let document_index = materialize_verified_scan_index(
      inputs[index].id,
      inputs[index].text,
      inputs[index].config,
      scan,
      entries.length(),
    )
    candidates_examined = candidates_examined +
      document_index.statistics.candidates_examined
    rejected_candidates = rejected_candidates +
      document_index.statistics.rejected_candidates
    used_unicode_compatibility = used_unicode_compatibility +
      document_index.statistics.used_unicode_compatibility
    normalized_inputs = normalized_inputs +
      document_index.statistics.normalized_inputs
    append_document_index(entries, documents, rejections, document_index)
  }
  Ok({
    entries,
    documents,
    rejections,
    statistics: {
      total_documents: inputs.length().to_int64(),
      candidates_examined,
      accepted_entries: entries.length().to_int64(),
      rejected_candidates,
      used_unicode_compatibility,
      normalized_inputs,
    },
  })
}

///|
/// Build after rescanning every result and comparing complete corpus statistics.
/// This correctness check is not a performance optimization.
pub fn build_roman_index_from_corpus(
  inputs : Array[RomanDocument],
  report : RomanCorpusReport,
) -> Result[RomanDocumentIndex, RomanIndexConstructionError] {
  match validate_index_document_ids(inputs) {
    Err(error) => return Err(error)
    Ok(_) => ()
  }
  match validate_corpus_report_identity(inputs, report) {
    Err(error) => return Err(error)
    Ok(_) => ()
  }
  let expected_corpus = match process_roman_corpus(inputs) {
    Err(EmptyDocumentId(index)) => return Err(EmptyIndexDocumentId(index))
    Err(DuplicateDocumentId(id)) => return Err(DuplicateIndexDocumentId(id))
    Ok(expected) => expected
  }
  for index = 0; index < inputs.length(); index = index + 1 {
    match
      validated_corpus_scan(
        inputs[index],
        expected_corpus.documents[index],
        report.documents[index],
      ) {
      Err(error) => return Err(error)
      Ok(_) => ()
    }
  }
  if expected_corpus.statistics != report.statistics {
    return Err(
      IndexCorpusStatisticsMismatch(
        expected_corpus.statistics,
        report.statistics,
      ),
    )
  }
  build_index_from_trusted_corpus(inputs, report)
}

///|
/// Build one stable index by scanning explicit ID-addressed source/config inputs.
pub fn build_roman_document_index(
  inputs : Array[RomanDocument],
) -> Result[RomanDocumentIndex, RomanIndexConstructionError] {
  match process_roman_corpus(inputs) {
    Err(EmptyDocumentId(index)) => Err(EmptyIndexDocumentId(index))
    Err(DuplicateDocumentId(id)) => Err(DuplicateIndexDocumentId(id))
    Ok(report) => build_index_from_trusted_corpus(inputs, report)
  }
}