///|
/// A validation finding tied to a FASTA record identifier.
pub struct FastaIssue {
  id : String
  code : String
  message : String
  position : Int
} derive(Debug, Eq)

///|
/// A strict batch validation result for FASTA input.
pub struct FastaValidationReport {
  record_count : Int
  total_bases : Int
  duplicate_ids : Array[String]
  issues : Array[FastaIssue]
  valid : Bool
} derive(Debug, Eq)

///|
/// Parse FASTA and reject missing identifiers, duplicate identifiers, and
/// symbols outside the IUPAC alphabet instead of silently dropping them.
pub fn parse_fasta_strict(
  text : String,
) -> Array[FastaRecord] raise GenBankError {
  let records = parse_fasta(text)
  let report = validate_fasta_records(records)
  if !report.valid {
    match report.issues.get(0) {
      Some(issue) =>
        raise GenBankError::MissingSection(issue.code + ": " + issue.message)
      None => raise GenBankError::MissingSection("invalid FASTA")
    }
  }
  records
}

///|
/// Validate parsed FASTA records without changing their sequences.
pub fn validate_fasta_records(
  records : Array[FastaRecord],
) -> FastaValidationReport {
  let issues : Array[FastaIssue] = []
  let ids : Array[String] = []
  let duplicate_ids : Array[String] = []
  let mut total = 0
  for record in records {
    if record.id == "" {
      issues.push({
        id: "",
        code: "EMPTY_ID",
        message: "record has no identifier",
        position: 0,
      })
    }
    if ids.contains(record.id) {
      if !duplicate_ids.contains(record.id) {
        duplicate_ids.push(record.id)
      }
      issues.push({
        id: record.id,
        code: "DUPLICATE_ID",
        message: "identifier occurs more than once",
        position: 0,
      })
    } else {
      ids.push(record.id)
    }
    total += record.sequence.length()
    for position in invalid_base_positions(record.sequence) {
      issues.push({
        id: record.id,
        code: "INVALID_BASE",
        message: "symbol is outside the IUPAC nucleotide alphabet",
        position,
      })
    }
  }
  {
    record_count: records.length(),
    total_bases: total,
    duplicate_ids,
    issues,
    valid: issues.length() == 0,
  }
}

///|
/// Validate FASTA text using the same parser used by production workflows.
pub fn validate_fasta_text(
  text : String,
) -> FastaValidationReport raise GenBankError {
  validate_fasta_records(parse_fasta(text))
}

///|
/// Sort records by descending sequence length, with source order as a tie-break.
pub fn sort_fasta_by_length(records : Array[FastaRecord]) -> Array[FastaRecord] {
  let result = records.copy()
  result.sort_by(fn(left, right) {
    right.sequence.length() - left.sequence.length()
  })
  result
}

///|
/// Return the longest sequence in a FASTA batch.
pub fn longest_fasta_record(records : Array[FastaRecord]) -> FastaRecord? {
  if records.length() == 0 {
    return None
  }
  Some(sort_fasta_by_length(records)[0])
}

///|
/// Render a FASTA batch with explicit wrapping and normalized bases.
pub fn format_fasta_records(
  records : Array[FastaRecord],
  line_width : Int,
) -> String {
  let width = if line_width <= 0 { 60 } else { line_width }
  records.map(fn(record) { record.to_fasta(width) }).join("")
}

///|
/// Compute a length histogram using inclusive bins.
pub fn fasta_length_histogram(
  records : Array[FastaRecord],
  bin_width : Int,
) -> Array[(Int, Int)] {
  let result : Array[(Int, Int)] = []
  if bin_width <= 0 {
    return result
  }
  for record in records {
    let bin = record.sequence.length() / bin_width * bin_width
    match result.search_by(fn(item) { item.0 == bin }) {
      Some(index) => {
        let item = result[index]
        result[index] = (item.0, item.1 + 1)
      }
      None => result.push((bin, 1))
    }
  }
  result
}

///|
/// Emit a compact validation report for logs and CI artifacts.
pub fn FastaValidationReport::to_json_string(
  self : FastaValidationReport,
) -> String {
  let issue_json = self.issues
    .map(fn(item) {
      "{\"id\":" +
      json_string(item.id) +
      ",\"code\":" +
      json_string(item.code) +
      ",\"message\":" +
      json_string(item.message) +
      ",\"position\":" +
      item.position.to_string() +
      "}"
    })
    .join(",")
  "{\"record_count\":" +
  self.record_count.to_string() +
  ",\"total_bases\":" +
  self.total_bases.to_string() +
  ",\"duplicate_ids\":[" +
  self.duplicate_ids.map(json_string).join(",") +
  "],\"issues\":[" +
  issue_json +
  "]" +
  ",\"valid\":" +
  self.valid.to_string() +
  "}"
}

///|
pub fn FastaIssue::to_text(self : FastaIssue) -> String {
  self.id +
  " " +
  self.code +
  " at " +
  self.position.to_string() +
  ": " +
  self.message
}