///|
/// A FASTA sequence with its identifier and optional description.
pub struct FastaRecord {
  id : String
  description : String
  sequence : String
} derive(Debug, Eq)

///|
/// A sequence window and coordinates in the original sequence.
pub struct SequenceWindow {
  start : Int
  end : Int
  sequence : String
  gc_percent : Double
} derive(Debug, Eq)

///|
/// One k-mer and its number of occurrences.
pub struct KmerCount {
  kmer : String
  count : Int
} derive(Debug, Eq)

///|
/// Translation details useful for validation reports.
pub struct TranslationReport {
  protein : String
  codons : Int
  stops : Int
  ambiguous : Int
  trailing_bases : Int
} derive(Debug, Eq)

///|
/// Composition and diversity statistics for a nucleotide sequence.
pub struct CompositionReport {
  length : Int
  a : Int
  c : Int
  g : Int
  t : Int
  ambiguous : Int
  gc_percent : Double
  entropy : Double
} derive(Debug, Eq)

///|
/// Find every overlapping occurrence of a motif, using zero-based positions.
pub fn find_motif_positions(sequence : String, motif : String) -> Array[Int] {
  let seq = sequence.to_upper()
  let needle = motif.to_upper()
  let hits : Array[Int] = []
  if needle == "" || needle.length() > seq.length() {
    return hits
  }
  for i in 0..<=(seq.length() - needle.length()) {
    if seq.unsafe_substring(start=i, end=i + needle.length()) == needle {
      hits.push(i)
    }
  }
  hits
}

///|
/// Return overlapping windows. Invalid window sizes produce an empty array.
pub fn sequence_windows(
  sequence : String,
  window_size : Int,
  step : Int,
) -> Array[SequenceWindow] {
  let windows : Array[SequenceWindow] = []
  if window_size <= 0 || step <= 0 || window_size > sequence.length() {
    return windows
  }
  let mut start = 0
  while start + window_size <= sequence.length() {
    let end = start + window_size
    let piece = sequence.unsafe_substring(start~, end~).to_upper()
    let stats = composition_report(piece)
    windows.push({
      start: start + 1,
      end,
      sequence: piece,
      gc_percent: stats.gc_percent,
    })
    start += step
  }
  windows
}

///|
/// Count k-mers in first-seen order, making reports stable across targets.
pub fn kmer_profile(sequence : String, k : Int) -> Array[KmerCount] {
  let result : Array[KmerCount] = []
  let upper = sequence.to_upper()
  if k <= 0 || k > upper.length() {
    return result
  }
  for i in 0..<=(upper.length() - k) {
    let kmer = upper.unsafe_substring(start=i, end=i + k)
    match result.search_by(fn(item) { item.kmer == kmer }) {
      Some(index) => {
        let item = result[index]
        result[index] = { kmer: item.kmer, count: item.count + 1 }
      }
      None => result.push({ kmer, count: 1 })
    }
  }
  result
}

///|
/// Whether a character is one of the 15 standard IUPAC nucleotide symbols.
pub fn is_iupac_base(ch : Char) -> Bool {
  match ch {
    'A'
    | 'C'
    | 'G'
    | 'T'
    | 'U'
    | 'R'
    | 'Y'
    | 'S'
    | 'W'
    | 'K'
    | 'M'
    | 'B'
    | 'D'
    | 'H'
    | 'V'
    | 'N' => true
    'a'
    | 'c'
    | 'g'
    | 't'
    | 'u'
    | 'r'
    | 'y'
    | 's'
    | 'w'
    | 'k'
    | 'm'
    | 'b'
    | 'd'
    | 'h'
    | 'v'
    | 'n' => true
    _ => false
  }
}

///|
/// Expand an IUPAC code to sorted canonical bases.
pub fn iupac_expansion(ch : Char) -> Array[Char] {
  match ch.to_string().to_upper() {
    "A" => ['A']
    "C" => ['C']
    "G" => ['G']
    "T" | "U" => ['T']
    "R" => ['A', 'G']
    "Y" => ['C', 'T']
    "S" => ['G', 'C']
    "W" => ['A', 'T']
    "K" => ['G', 'T']
    "M" => ['A', 'C']
    "B" => ['C', 'G', 'T']
    "D" => ['A', 'G', 'T']
    "H" => ['A', 'C', 'T']
    "V" => ['A', 'C', 'G']
    "N" => ['A', 'C', 'G', 'T']
    _ => []
  }
}

///|
/// Parse FASTA records while preserving the first-line description.
pub fn parse_fasta(text : String) -> Array[FastaRecord] raise GenBankError {
  let records : Array[FastaRecord] = []
  let mut current_id = ""
  let mut current_description = ""
  let sequence = StringBuilder()
  for line in text.split("\n") {
    let trimmed = line.trim().to_owned()
    if trimmed == "" {
      continue
    }
    if trimmed.has_prefix(">") {
      if current_id != "" {
        records.push({
          id: current_id,
          description: current_description,
          sequence: sequence.to_string().to_upper(),
        })
        sequence.reset()
      }
      let header = trimmed.unsafe_substring(start=1, end=trimmed.length())
      let words = header.split(" ").map(fn(item) { item.to_owned() }).to_array()
      current_id = if words.length() == 0 { "" } else { words[0] }
      current_description = if words.length() <= 1 {
        ""
      } else {
        words.view(start=1).join(" ")
      }
    } else {
      if current_id == "" {
        raise GenBankError::MissingSection("FASTA header")
      }
      for ch in trimmed {
        if is_iupac_base(ch) {
          sequence.write_char(ch)
        }
      }
    }
  }
  if current_id != "" {
    records.push({
      id: current_id,
      description: current_description,
      sequence: sequence.to_string().to_upper(),
    })
  }
  records
}

///|
/// Translate a nucleotide sequence and retain quality diagnostics.
pub fn translate_dna_report(seq : String) -> TranslationReport {
  let upper = seq.to_upper().replace_all(old="U", new="T")
  let codon_count = upper.length() / 3
  let amino : Array[String] = []
  let mut stops = 0
  let mut ambiguous = 0
  for i in 0.. CompositionReport {
  let mut a = 0
  let mut c = 0
  let mut g = 0
  let mut t = 0
  let mut ambiguous = 0
  for ch in sequence.to_upper() {
    match ch {
      'A' => a += 1
      'C' => c += 1
      'G' => g += 1
      'T' | 'U' => t += 1
      _ => ambiguous += 1
    }
  }
  let length = a + c + g + t + ambiguous
  let gc_percent = if length == 0 {
    0.0
  } else {
    (g + c).to_double() * 100.0 / length.to_double()
  }
  let entropy = entropy_from_counts([a, c, g, t], length)
  { length, a, c, g, t, ambiguous, gc_percent, entropy }
}

///|
fn entropy_from_counts(counts : Array[Int], total : Int) -> Double {
  if total == 0 {
    return 0.0
  }
  let mut value = 0.0
  for count in counts {
    if count > 0 {
      let p = count.to_double() / total.to_double()
      value -= p * log2(p)
    }
  }
  value
}

///|
fn log2(value : Double) -> Double {
  // A compact approximation is sufficient for a deterministic diversity
  // report and keeps the package independent from platform-specific FFI.
  let mut x = value
  let mut exponent = 0
  while x < 1.0 {
    x *= 2.0
    exponent -= 1
  }
  while x >= 2.0 {
    x /= 2.0
    exponent += 1
  }
  let y = (x - 1.0) / (x + 1.0)
  let y2 = y * y
  let mut term = y
  let mut sum = 0.0
  for n in 0..<10 {
    sum += term / (2.0 * n.to_double() + 1.0)
    term *= y2
  }
  2.0 * sum / 0.6931471805599453 + exponent.to_double()
}

///|
pub fn FastaRecord::to_fasta(self : FastaRecord, line_width : Int) -> String {
  let width = if line_width <= 0 { 60 } else { line_width }
  let header = if self.description == "" {
    ">" + self.id
  } else {
    ">" + self.id + " " + self.description
  }
  let out = StringBuilder()
  out.write_string(header)
  out.write_char('\n')
  let mut start = 0
  while start < self.sequence.length() {
    let end = Int::min(start + width, self.sequence.length())
    out.write_string(self.sequence.unsafe_substring(start~, end~))
    out.write_char('\n')
    start = end
  }
  out.to_string()
}

///|
pub fn CompositionReport::to_json_string(self : CompositionReport) -> String {
  "{\"length\":" +
  self.length.to_string() +
  ",\"a\":" +
  self.a.to_string() +
  ",\"c\":" +
  self.c.to_string() +
  ",\"g\":" +
  self.g.to_string() +
  ",\"t\":" +
  self.t.to_string() +
  ",\"ambiguous\":" +
  self.ambiguous.to_string() +
  ",\"gc_percent\":" +
  self.gc_percent.to_string() +
  ",\"entropy\":" +
  self.entropy.to_string() +
  "}"
}

///|
pub fn TranslationReport::to_json_string(self : TranslationReport) -> String {
  "{\"protein\":" +
  json_string(self.protein) +
  ",\"codons\":" +
  self.codons.to_string() +
  ",\"stops\":" +
  self.stops.to_string() +
  ",\"ambiguous\":" +
  self.ambiguous.to_string() +
  ",\"trailing_bases\":" +
  self.trailing_bases.to_string() +
  "}"
}