///|
/// Equivalent forms (parallel papers, A/B 卷).
///
/// The claim here is *structural equivalence*, not textual: form B is made by
/// swapping every item of form A for a *different* item with the same
/// difficulty and the same structural tags. The two forms therefore have the
/// same level histogram and the same tag profile, so a teacher can hand them to
/// two groups and grade both against the same rubric.
///
/// Matching is greedy and deterministic. An exact-tag replacement is preferred;
/// when none is left, a same-level replacement is accepted and the fact is
/// recorded so the caller can decide whether to reject the form.
fn count_str(xs : Array[String], x : String) -> Int32 {
  let mut n = 0
  for y in xs {
    if y == x {
      n = n + 1
    }
  }
  n
}

///|
/// Order-insensitive equality of tag lists.
fn same_tags(a : Array[String], b : Array[String]) -> Bool {
  if a.length() != b.length() {
    return false
  }
  for x in a {
    if count_str(a, x) != count_str(b, x) {
      return false
    }
  }
  true
}

///|
/// A single replacement decision: the chosen item and whether the match was on
/// exact tags (`true`) or only on difficulty (`false`).
priv struct Pick {
  item : Item
  exact : Bool
}

///|
/// Find one replacement for `slot` from the pool, excluding fingerprints that
/// are already in form A or already taken into form B.
fn pick_replacement(
  pool : Pool,
  level : Int32,
  tags : Array[String],
  taken : Map[String, Bool],
  rng : Rng,
) -> Pick? {
  let exact : Array[Item] = []
  let fallback : Array[Item] = []
  for it in pool.items {
    if it.level == level && taken.get(it.fingerprint) is None {
      if same_tags(it.tags, tags) {
        exact.push(it)
      } else {
        fallback.push(it)
      }
    }
  }
  shuffle(rng, exact)
  shuffle(rng, fallback)
  if exact.length() > 0 {
    let chosen = exact[0]
    taken[chosen.fingerprint] = true
    return Some({ item: chosen, exact: true, })
  }
  if fallback.length() > 0 {
    let chosen = fallback[0]
    taken[chosen.fingerprint] = true
    return Some({ item: chosen, exact: false, })
  }
  None
}

///|
/// Build a parallel form.
///
/// `seed` selects the swap; the same seed always yields the same form. Fails
/// with a precise message when any slot cannot be filled — a short paper is
/// never returned.
fn build_form(
  pool : Pool,
  slots : Array[Slot],
  schema_name : String,
  seed : Int32,
) -> Result[Paper, String] {
  let taken : Map[String, Bool] = Map([])
  // Everything already on form A is off-limits.
  for slot in slots {
    taken[slot.fingerprint] = true
  }
  let rng = Rng::new(seed)
  let items : Array[Item] = []
  let mut relaxed = 0
  for i, slot in slots {
    match pick_replacement(pool, slot.level, slot.tags, taken, rng) {
      Some(p) => {
        if !p.exact {
          relaxed = relaxed + 1
        }
        items.push(p.item)
      }
      None =>
        return Err(
          "第 \{i + 1} 题(L\{slot.level})找不到等价替换,题池已耗尽或结构过于单一",
        )
    }
  }
  if relaxed > 0 {
    return Err(
      "\{relaxed} 道题只能按难度等价、无法按结构标签精确匹配,请放宽 schema 的 domain 后重试",
    )
  }
  // Describe the form by its actual difficulty spread, exactly like assemble.
  let parts : Array[String] = []
  for level in 1..<=5 {
    let mut n = 0
    for it in items {
      if it.level == level {
        n = n + 1
      }
    }
    if n > 0 {
      parts.push("L\{level}×\{n}")
    }
  }
  let request = join_with(parts, " ") + "(平行卷)"
  Ok({ schema_name, items, seed, request, })
}

///|
/// The structural profile of one slot: difficulty plus tags. Both the in-memory
/// `Paper` and the on-disk `PaperFile` reduce to this, so the same matcher works
/// for either.
pub(all) struct Slot {
  level : Int32
  tags : Array[String]
  fingerprint : String
}

///|
/// Build a parallel form from an assembled paper.
pub fn parallel_form(
  pool : Pool,
  paper : Paper,
  seed : Int32,
) -> Result[Paper, String] {
  let slots : Array[Slot] = []
  for it in paper.items {
    slots.push({ level: it.level, tags: it.tags, fingerprint: it.fingerprint, })
  }
  build_form(pool, slots, paper.schema_name, seed)
}

///|
/// Build a parallel form straight from a paper file, without going through the
/// generator. This is the path the CLI takes: `A.json` in, `B.json` out.
pub fn parallel_paper_file(
  pool : Pool,
  pf : PaperFile,
  seed : Int32,
) -> Result[Paper, String] {
  let slots : Array[Slot] = []
  for it in pf.items {
    slots.push({ level: it.level, tags: it.tags, fingerprint: it.fingerprint, })
  }
  build_form(pool, slots, pf.schema_name, seed)
}

///|
test "parallel form has identical level and tag histograms" {
  let schema = match load_schema(SAMPLE_SCHEMA) {
    Ok(s) => s
    Err(e) => abort(e)
  }
  let pool = generate(schema, 0)
  let a = match assemble(pool, schema.name, Blueprint::new(8, 7)) {
    Ok(p) => p
    Err(e) => abort(e)
  }
  let b = match parallel_form(pool, a, 99) {
    Ok(p) => p
    Err(e) => abort(e)
  }
  inspect(b.size(), content="8")
  let ha = a.tag_counts()
  let hb = b.tag_counts()
  for t in ["zero-root", "no-linear-term", "opposite-signs", "both-negative"] {
    let ca = match ha.get(t) {
      Some(n) => n
      None => 0
    }
    let cb = match hb.get(t) {
      Some(n) => n
      None => 0
    }
    inspect(ca == cb, content="true")
  }
}

///|
test "parallel form never reuses a fingerprint" {
  let schema = match load_schema(SAMPLE_SCHEMA) {
    Ok(s) => s
    Err(e) => abort(e)
  }
  let pool = generate(schema, 0)
  let a = match assemble(pool, schema.name, Blueprint::new(6, 3)) {
    Ok(p) => p
    Err(e) => abort(e)
  }
  let b = match parallel_form(pool, a, 5) {
    Ok(p) => p
    Err(e) => abort(e)
  }
  let used : Array[String] = []
  for it in a.items {
    used.push(it.fingerprint)
  }
  let mut overlap = 0
  for it in b.items {
    if contains_str(used, it.fingerprint) {
      overlap = overlap + 1
    }
  }
  inspect(overlap, content="0")
}

///|
test "parallel form is reproducible per seed" {
  let schema = match load_schema(SAMPLE_SCHEMA) {
    Ok(s) => s
    Err(e) => abort(e)
  }
  let pool = generate(schema, 0)
  let a = match assemble(pool, schema.name, Blueprint::new(5, 11)) {
    Ok(p) => p
    Err(e) => abort(e)
  }
  let b1 = match parallel_form(pool, a, 42) {
    Ok(p) => p
    Err(e) => abort(e)
  }
  let b2 = match parallel_form(pool, a, 42) {
    Ok(p) => p
    Err(e) => abort(e)
  }
  let mut same = true
  for i in 0.. s
    Err(e) => abort(e)
  }
  let pool = generate(schema, 0)
  let a = match assemble(pool, schema.name, Blueprint::new(5, 2)) {
    Ok(p) => p
    Err(e) => abort(e)
  }
  // A synthetic pool that contains only form A's own items: no replacement
  // can exist, and the failure must name the offending slot instead of
  // returning a short paper.
  let exhausted : Pool = {
    items: a.items.copy(),
    attempted: a.size(),
    rejected: 0,
    duplicates: 0,
    rejects: [],
  }
  match parallel_form(exhausted, a, 3) {
    Ok(_) => abort("expected an error: pool has no spare items")
    Err(e) => inspect(e.contains("找不到等价替换"), content="true")
  }
}