///|
pub(all) struct BindingFinding {
  code : String
  variable : String
  span : Span?
} derive(Debug)

///|
fn append_finding(
  findings : Array[BindingFinding],
  finding : BindingFinding,
  limit : Int32,
) -> Bool {
  if findings.length() == limit {
    return false
  }
  findings.push(finding)
  true
}

///|
fn finding_limit() -> TemplateError {
  problem("FindingLimit", "findings exceed budget", 0, 0)
}

///|
fn check_contract_input(
  variables : Variables,
  required : Array[String],
  limits : ExpansionLimits,
) -> Result[Unit, TemplateError] {
  let mut total = match check_input_budget(variables, limits) {
    Err(error) => return Err(error)
    Ok(remaining) => remaining
  }
  if required.length() > limits.max_bindings {
    return Err(
      problem(
        "BindingLimit", "required names exceed binding count budget", 0, 0,
      ),
    )
  }
  for name in required {
    total = consume_input_text(name, limits.max_value_codepoints, total)
    if total == -1 {
      return Err(problem("ValueLimit", "required name exceeds budget", 0, 0))
    }
    if total == -2 {
      return Err(
        problem(
          "TotalValueLimit", "bindings and required names exceed budget", 0, 0,
        ),
      )
    }
  }
  Ok(())
}

///|
/// Collect business-contract defects; optional omissions are not errors.
/// Invalid budgets / excessive reports return Err, never an incomplete clean report.
pub fn Template::check_contract(
  self : Template,
  variables : Variables,
  required~ : Array[String] = [],
  reject_unused~ : Bool = false,
  limits~ : ExpansionLimits = ExpansionLimits::new(),
  max_findings~ : Int32 = 256,
) -> Result[Array[BindingFinding], TemplateError] {
  if max_findings < 0 {
    return Err(problem("InvalidLimit", "negative finding budget", 0, 0))
  }
  match check_contract_input(variables, required, limits) {
    Err(error) => return Err(error)
    Ok(_) => ()
  }
  let first : Map[String, Span] = Map([])
  for segment in self.segments {
    if segment is Expression(_, specs) {
      for spec in specs {
        if !first.contains(spec.name) {
          first[spec.name] = spec.span
        }
      }
    }
  }
  let findings : Array[BindingFinding] = []
  let seen_required : Map[String, Bool] = Map([])
  for name in required {
    if seen_required.contains(name) {
      continue
    }
    seen_required[name] = true
    let defect = match first.get(name) {
      None => Some({ code: "UnknownRequired", variable: name, span: None, })
      Some(span) =>
        match variables.values.get(name) {
          None =>
            Some({ code: "MissingBinding", variable: name, span: Some(span), })
          Some(value) =>
            if !defined(value) {
              Some({ code: "OmittedBinding", variable: name, span: Some(span), })
            } else {
              None
            }
        }
    }
    if defect is Some(finding) {
      if !append_finding(findings, finding, max_findings) {
        return Err(finding_limit())
      }
    }
  }
  if reject_unused {
    // Sorting just the bounded input keys makes diagnostics deterministic.
    let names = variables.values.keys().to_array()
    names.sort()
    for name in names {
      if !first.contains(name) {
        if !append_finding(
            findings,
            { code: "UnusedBinding", variable: name, span: None, },
            max_findings,
          ) {
          return Err(finding_limit())
        }
      }
    }
  }
  for segment in self.segments {
    if segment is Expression(_, specs) {
      for spec in specs {
        if spec.modifier is Prefix(_) &&
          variables.values.get(spec.name) is Some(value) {
          if defined(value) && !(value is Scalar(_)) {
            if !append_finding(
                findings,
                {
                  code: "PrefixOnComposite",
                  variable: spec.name,
                  span: Some(spec.span),
                },
                max_findings,
              ) {
              return Err(finding_limit())
            }
          }
        }
      }
    }
  }
  Ok(findings)
}

///|
pub(all) struct CatalogEntryAudit {
  name : String
  expression_count : Int32
  variable_count : Int32
  max_level : Int32
  findings : Array[TemplateError]
} derive(Debug)

///|
pub(all) struct CatalogAudit {
  entries : Array[CatalogEntryAudit]
  total_findings : Int32
} derive(Debug)

///|
pub fn CatalogAudit::is_clean(self : CatalogAudit) -> Bool {
  self.total_findings == 0
}

///|
pub fn TemplateCatalog::contains(self : TemplateCatalog, name : String) -> Bool {
  self.entries.contains(name)
}

///|
pub fn TemplateCatalog::audit(
  self : TemplateCatalog,
  policy : TemplatePolicy,
  max_findings~ : Int32 = 256,
) -> Result[CatalogAudit, CatalogError] {
  // Empty catalogs must not hide invalid options.
  match parse("").unwrap().audit(policy, max_findings~) {
    Err(error) => return Err({ name: "", error, })
    Ok(_) => ()
  }
  let entries : Array[CatalogEntryAudit] = []
  let mut remaining = max_findings
  for index, name in self.ordered_names {
    let t = self.entries[name]
    let findings = match t.audit(policy, max_findings=remaining) {
      Err(error) => return Err({ name, error, })
      Ok(findings) => findings
    }
    remaining = remaining - findings.length()
    let metadata = self.metadata[index]
    entries.push({
      name,
      expression_count: metadata.expression_count,
      variable_count: metadata.variable_count,
      max_level: metadata.max_level,
      findings,
    })
  }
  Ok({ entries, total_findings: max_findings - remaining, })
}

///|
pub(all) struct RequestInspection {
  index : Int32
  name : String
  findings : Array[BindingFinding]
} derive(Debug)

///|
pub(all) struct RequestAudit {
  requests : Array[RequestInspection]
  total_findings : Int32
} derive(Debug)

///|
pub fn RequestAudit::is_clean(self : RequestAudit) -> Bool {
  self.total_findings == 0
}

///|
/// All requests are inspected; budgets fail closed with an indexed error.
/// Output budgets belong to measure_batch / expand_batch, not binding inspection.
pub fn TemplateCatalog::inspect_requests(
  self : TemplateCatalog,
  requests : Array[CatalogRequest],
  reject_unused~ : Bool = false,
  limits~ : ExpansionLimits = ExpansionLimits::new(),
  batch_limits~ : BatchLimits = BatchLimits::new(),
  max_findings~ : Int32 = 256,
) -> Result[RequestAudit, CatalogBatchError] {
  if max_findings < 0 ||
    batch_limits.max_items < 0 ||
    batch_limits.max_total_input_codepoints < 0 ||
    batch_limits.max_total_output_bytes < 0 {
    return Err({
      index: -1,
      name: "",
      error: problem("InvalidLimit", "negative audit budget", 0, 0),
    })
  }
  if requests.length() > batch_limits.max_items {
    return Err({
      index: -1,
      name: "",
      error: problem("BatchItemLimit", "request count exceeds budget", 0, 0),
    })
  }
  match check_input_budget(Variables::new(), limits) {
    Err(error) => return Err({ index: -1, name: "", error, })
    Ok(_) => ()
  }
  let results : Array[RequestInspection] = []
  let mut remaining = max_findings
  let mut input = batch_limits.max_total_input_codepoints
  for index, request in requests {
    let name = request.name
    match check_contract_input(request.variables, request.required, limits) {
      Err(error) => return Err({ index, name, error, })
      Ok(_) => ()
    }
    let texts = input_texts(request.variables)
    texts.push(name)
    texts.push_iter(request.required.iter())
    for text in texts {
      for _ in text.iter() {
        if input == 0 {
          return Err({
            index,
            name,
            error: problem(
              "BatchInputLimit", "audit input exceeds budget", 0, 0,
            ),
          })
        }
        input = input - 1
      }
    }
    let findings = match self.entries.get(name) {
      None => {
        if remaining == 0 {
          return Err({ index, name, error: finding_limit(), })
        }
        [{ code: "UnknownTemplate", variable: "", span: None, }]
      }
      Some(t) =>
        match
          t.check_contract(
            request.variables,
            required=request.required,
            reject_unused~,
            limits~,
            max_findings=remaining,
          ) {
          Err(error) => return Err({ index, name, error, })
          Ok(findings) => findings
        }
    }
    remaining = remaining - findings.length()
    results.push({ index, name, findings, })
  }
  Ok({ requests: results, total_findings: max_findings - remaining, })
}

///|
pub(all) struct CatalogContractDiff {
  added : Array[String]
  removed : Array[String]
  changed : Array[(String, DetailedContractDiff)]
} derive(Debug)

///|
pub fn TemplateCatalog::diff(
  self : TemplateCatalog,
  newer : TemplateCatalog,
) -> CatalogContractDiff {
  let old = self.names()
  let next = newer.names()
  let changed : Array[(String, DetailedContractDiff)] = []
  for name in old {
    if newer.entries.get(name) is Some(t) {
      let before = self.entries[name]
      if before.source_text != t.source_text {
        changed.push((name, before.diff(t)))
      }
    }
  }
  {
    added: next.filter(n => !self.entries.contains(n)),
    removed: old.filter(n => !newer.entries.contains(n)),
    changed,
  }
}

///|
pub extend BindingFinding with Debug::{to_repr}

///|
pub extend CatalogEntryAudit with Debug::{to_repr}

///|
pub extend CatalogAudit with Debug::{to_repr}

///|
pub extend RequestInspection with Debug::{to_repr}

///|
pub extend RequestAudit with Debug::{to_repr}

///|
pub extend CatalogContractDiff with Debug::{to_repr}