///|
/// Run a weighted rule set against text.
pub fn audit_text(
  title : StringView,
  text : StringView,
  rules : ArrayView[Rule],
) -> Audit {
  let source = text.to_owned()
  let lines = split_lines(source)
  let shape = analyze_shape(source)
  let findings : Array[Finding] = []
  let mut max_score = 0
  let mut earned_score = 0
  for rule in rules {
    let finding = evaluate_rule(rule, source, lines)
    max_score += positive_weight(rule.weight)
    earned_score += finding.earned
    findings.push(finding)
  }
  let score = if max_score == 0 { 100 } else { earned_score * 100 / max_score }
  let stats = summarize_stats(source, findings, shape)
  {
    title: title.to_owned(),
    score,
    max_score,
    earned_score,
    grade: grade_for(score),
    findings,
    stats,
  }
}

///|
fn positive_weight(weight : Int) -> Int {
  if weight < 0 {
    0
  } else {
    weight
  }
}

///|
fn evaluate_rule(
  rule : Rule,
  source : String,
  lines : Array[String],
) -> Finding {
  match rule.kind {
    MustContainAny(terms) => {
      let evidence = collect_evidence(lines, terms, 4)
      let passed = !evidence.is_empty()
      make_finding(
        rule,
        passed,
        if passed {
          "found one of: \{terms.join(", ")}"
        } else {
          "missing all of: \{terms.join(", ")}"
        },
        evidence,
      )
    }
    MustNotContainAny(terms) => {
      let evidence = collect_evidence(lines, terms, 4)
      let passed = evidence.is_empty()
      make_finding(
        rule,
        passed,
        if passed {
          "none of the forbidden terms were found"
        } else {
          "found forbidden term(s): \{terms.join(", ")}"
        },
        evidence,
      )
    }
    MinOccurrences(needle, min) => {
      let count = count_occurrences(source, needle)
      let evidence = collect_evidence(lines, [needle], 4)
      make_finding(
        rule,
        count >= min,
        "matched '\{needle}' \{count} time(s), required \{min}",
        evidence,
      )
    }
    MaxLineLength(max) => {
      let evidence = collect_long_lines(lines, max)
      make_finding(
        rule,
        evidence.is_empty(),
        if evidence.is_empty() {
          "all lines are within \{max} characters"
        } else {
          "\{evidence.length()} sampled line(s) exceed \{max} characters"
        },
        evidence,
      )
    }
    RequiresPair(left, right) => {
      let has_left = contains_folded(source, left)
      let has_right = contains_folded(source, right)
      let evidence = collect_evidence(lines, [left, right], 4)
      make_finding(
        rule,
        !has_left || has_right,
        if !has_left {
          "'\{left}' is absent, so '\{right}' is not required"
        } else if has_right {
          "'\{left}' is paired with '\{right}'"
        } else {
          "'\{left}' appears without required pair '\{right}'"
        },
        evidence,
      )
    }
    SectionOrder(sections) => {
      let evidence : Array[Evidence] = []
      let missing : Array[String] = []
      let mut cursor = 0
      for section in sections {
        match find_line_after(lines, section, cursor) {
          Some(index) => {
            evidence.push({
              line: index + 1,
              snippet: clip(lines[index], 96),
              matched: section,
            })
            cursor = index + 1
          }
          None => missing.push(section)
        }
      }
      make_finding(
        rule,
        missing.is_empty(),
        if missing.is_empty() {
          "sections appear in order: \{sections.join(" -> ")}"
        } else {
          "missing or out-of-order section(s): \{missing.join(", ")}"
        },
        evidence,
      )
    }
  }
}

///|
fn make_finding(
  rule : Rule,
  passed : Bool,
  message : String,
  evidence : Array[Evidence],
) -> Finding {
  let weight = positive_weight(rule.weight)
  {
    rule_id: rule.id,
    title: rule.title,
    category: rule.category,
    severity: rule.severity,
    passed,
    weight,
    earned: if passed {
      weight
    } else {
      0
    },
    message,
    hint: rule.hint,
    evidence,
  }
}

///|
fn summarize_stats(
  source : String,
  findings : Array[Finding],
  shape : DocumentShape,
) -> AuditStats {
  let mut matched_rules = 0
  let mut failed_rules = 0
  let mut error_count = 0
  let mut warning_count = 0
  for finding in findings {
    if finding.passed {
      matched_rules += 1
    } else {
      failed_rules += 1
      match finding.severity {
        Error => error_count += 1
        Warning => warning_count += 1
        Info => ()
      }
    }
  }
  {
    line_count: shape.lines,
    char_count: source.char_length(),
    matched_rules,
    failed_rules,
    error_count,
    warning_count,
    heading_count: shape.headings,
    bullet_count: shape.bullets,
  }
}

///|
fn grade_for(score : Int) -> String {
  if score >= 90 {
    "A"
  } else if score >= 75 {
    "B"
  } else if score >= 60 {
    "C"
  } else {
    "D"
  }
}