///|
fn linkage_id_order(left : String, right : String) -> Int {
  left.lexical_compare(right)
}

///|
fn linkage_endpoint_available(
  used_left_ids : Array[String],
  used_right_ids : Array[String],
  candidate : LinkCandidate,
) -> Bool {
  !used_left_ids.contains(candidate.left_id) &&
  !used_right_ids.contains(candidate.right_id)
}

///|
fn accept_linkage_candidate(
  links : Array[AcceptedLink],
  used_left_ids : Array[String],
  used_right_ids : Array[String],
  candidate : LinkCandidate,
) -> Unit {
  links.push({
    left_id: candidate.left_id,
    right_id: candidate.right_id,
    evidence: candidate.evidence,
  })
  used_left_ids.push(candidate.left_id)
  used_right_ids.push(candidate.right_id)
}

///|
fn collect_unmatched_linkage_ids(
  records : Array[LinkRecord],
  used_ids : Array[String],
) -> Array[String] {
  let unmatched : Array[String] = []
  for record in records {
    if !used_ids.contains(record.id) {
      unmatched.push(record.id)
    }
  }
  unmatched.sort_by(linkage_id_order)
  unmatched
}

///|
fn assign_linkage_candidates(
  prepared : PreparedLinkageInputs,
  batch : LinkageCandidateBatch,
  config : LinkageConfig,
) -> LinkageReport {
  let links : Array[AcceptedLink] = []
  let rejected_candidates : Array[LinkCandidate] = []
  let used_left_ids : Array[String] = []
  let used_right_ids : Array[String] = []
  for candidate in batch.candidates {
    if candidate.accepted &&
      linkage_endpoint_available(used_left_ids, used_right_ids, candidate) {
      accept_linkage_candidate(links, used_left_ids, used_right_ids, candidate)
    } else if config.retain_rejected {
      rejected_candidates.push(candidate)
    }
  }
  rejected_candidates.sort_by(linkage_candidate_order)
  let unmatched_left_ids = collect_unmatched_linkage_ids(
    prepared.left,
    used_left_ids,
  )
  let unmatched_right_ids = collect_unmatched_linkage_ids(
    prepared.right,
    used_right_ids,
  )
  let rejected_candidate_count = batch.evaluated_pair_count - links.length()
  {
    links,
    unmatched_left_ids,
    unmatched_right_ids,
    rejected_candidates,
    stats: {
      left_input_count: prepared.left.length(),
      right_input_count: prepared.right.length(),
      blocked_candidate_count: batch.blocked_candidate_count,
      evaluated_pair_count: batch.evaluated_pair_count,
      accepted_link_count: links.length(),
      rejected_candidate_count,
      unmatched_left_count: unmatched_left_ids.length(),
      unmatched_right_count: unmatched_right_ids.length(),
    },
    notices: [
      "one-to-one links use deterministic greedy score order; global optimality is not claimed",
    ],
  }
}

///|
/// Links two independent Latin-name datasets with deterministic greedy assignment.
///
/// Candidate generation is limited by shared phonetic blocking keys and the
/// configured per-left evaluation cap. A returned link records only that the
/// configured deterministic matcher accepted the pair; it is not proof of
/// real-world identity. Assignment favors higher scores, then lexical IDs, and
/// does not claim a globally optimal bipartite solution.
pub fn link_name_datasets(
  left : Array[LinkRecord],
  right : Array[LinkRecord],
  config : LinkageConfig,
) -> Result[LinkageReport, LinkageError] {
  let prepared = match prepare_linkage_inputs(left, right, config) {
    Err(error) => return Err(error)
    Ok(value) => value
  }
  let batch = match collect_linkage_candidates(prepared, config) {
    Err(error) => return Err(error)
    Ok(value) => value
  }
  Ok(assign_linkage_candidates(prepared, batch, config))
}