///|
/// A source position is retained on every parsed record and diagnostic.
pub struct Source {
  filename : String
  line : Int
}

///|
pub struct CodePointRange {
  start : Int
  end : Int
}

///|
pub struct PropertyRecord {
  range : CodePointRange
  property : String
  source : Source
}

///|
pub struct DecompositionRecord {
  code_point : Int
  mapping : Array[Int]
  canonical : Bool
  source : Source
}

///|
pub struct CccRecord {
  code_point : Int
  ccc : Int
  source : Source
}

///|
pub struct ConformanceVector {
  columns : Array[Array[Int]]
  source : Source
}

///|
pub enum ParseErrorKind {
  MalformedScalar
  MalformedRecord
  OverlappingRange
  ConflictingDuplicate
  MissingField
  InvalidVector
}

///|
pub struct ParseError {
  kind : ParseErrorKind
  source : Source
  detail : String
}

///|
fn error(
  kind : ParseErrorKind,
  filename : String,
  line : Int,
  detail : String,
) -> ParseError {
  { kind, source: { filename, line }, detail }
}

///|
pub fn ParseError::to_string(self : ParseError) -> String {
  let kind = match self.kind {
    MalformedScalar => "malformed scalar"
    MalformedRecord => "malformed record"
    OverlappingRange => "overlapping range"
    ConflictingDuplicate => "conflicting duplicate"
    MissingField => "missing field"
    InvalidVector => "invalid conformance vector"
  }
  self.source.filename +
  ":" +
  self.source.line.to_string() +
  ": " +
  kind +
  ": " +
  self.detail
}

///|
fn hex_digit(c : Char) -> Int? {
  if c >= '0' && c <= '9' {
    Some(c.to_int() - '0'.to_int())
  } else if c >= 'A' && c <= 'F' {
    Some(c.to_int() - 'A'.to_int() + 10)
  } else if c >= 'a' && c <= 'f' {
    Some(c.to_int() - 'a'.to_int() + 10)
  } else {
    None
  }
}

///|
fn parse_hex(
  text : StringView,
  filename : String,
  line : Int,
) -> Result[Int, ParseError] {
  if text.length() == 0 {
    return Err(error(MalformedScalar, filename, line, "empty code point"))
  }
  let mut value = 0
  for c in text {
    match hex_digit(c) {
      Some(d) => value = value * 16 + d
      None =>
        return Err(
          error(
            MalformedScalar,
            filename,
            line,
            "invalid hexadecimal code point",
          ),
        )
    }
  }
  if value > 0x10FFFF || (value >= 0xD800 && value <= 0xDFFF) {
    return Err(
      error(
        MalformedScalar,
        filename,
        line,
        "code point is not a Unicode scalar",
      ),
    )
  }
  Ok(value)
}

///|
fn parse_range(
  text : StringView,
  filename : String,
  line : Int,
) -> Result[CodePointRange, ParseError] {
  let pieces = text.split("..").to_array()
  match pieces {
    [one] => parse_hex(one, filename, line).map(fn(x) { { start: x, end: x } })
    [lo, hi] => {
      let start = match parse_hex(lo, filename, line) {
        Ok(x) => x
        Err(e) => return Err(e)
      }
      let end = match parse_hex(hi, filename, line) {
        Ok(x) => x
        Err(e) => return Err(e)
      }
      if start > end {
        Err(error(MalformedRecord, filename, line, "range start exceeds end"))
      } else {
        Ok({ start, end })
      }
    }
    _ => Err(error(MalformedRecord, filename, line, "invalid code point range"))
  }
}

///|
fn data_part(line : String) -> StringView {
  match line.split("#").to_array() {
    [head, ..] => head.trim_space()
    _ => "" // split always yields at least one part
  }
}

///|
fn check_overlap(
  ranges : Array[CodePointRange],
  candidate : CodePointRange,
  source : Source,
) -> ParseError? {
  for old in ranges {
    if candidate.start <= old.end && old.start <= candidate.end {
      return Some(
        error(
          OverlappingRange,
          source.filename,
          source.line,
          "range overlaps an earlier record",
        ),
      )
    }
  }
  None
}

///|
/// Parse UCD semicolon/property files such as GraphemeBreakProperty.txt and emoji-data.txt.
pub fn parse_property_file(
  filename : String,
  input : String,
) -> Result[Array[PropertyRecord], ParseError] {
  let records : Array[PropertyRecord] = []
  let ranges : Array[CodePointRange] = []
  let mut line_no = 0
  for raw in input.split("\n") {
    line_no += 1
    let line = data_part(raw.to_string())
    if line == "" {
      continue
    }
    let fields = line.split(";").to_array()
    if fields.length() != 2 {
      return Err(
        error(MalformedRecord, filename, line_no, "expected range ; property"),
      )
    }
    let range = match parse_range(fields[0].trim_space(), filename, line_no) {
      Ok(x) => x
      Err(e) => return Err(e)
    }
    let property = fields[1].trim_space().to_string()
    if property == "" {
      return Err(error(MissingField, filename, line_no, "property is empty"))
    }
    for i = 0; i < records.length(); i = i + 1 {
      let old = records[i]
      if range.start <= old.range.end && old.range.start <= range.end {
        let kind = if range.start == old.range.start &&
          range.end == old.range.end &&
          property != old.property {
          ConflictingDuplicate
        } else {
          OverlappingRange
        }
        return Err(
          error(kind, filename, line_no, "range overlaps an earlier record"),
        )
      }
    }
    ranges.push(range)
    records.push({ range, property, source: { filename, line: line_no } })
  }
  Ok(records)
}

///|
/// Parse UnicodeData.txt fields, retaining canonical decomposition and CCC.
pub fn parse_unicode_data(
  filename : String,
  input : String,
) -> Result[(Array[DecompositionRecord], Array[CccRecord]), ParseError] {
  let decompositions : Array[DecompositionRecord] = []
  let cccs : Array[CccRecord] = []
  let mut line_no = 0
  for raw in input.split("\n") {
    line_no += 1
    let line = data_part(raw.to_string())
    if line == "" {
      continue
    }
    let fields = line.split(";").to_array()
    if fields.length() < 6 {
      return Err(
        error(
          MissingField,
          filename,
          line_no,
          "UnicodeData record has fewer than six fields",
        ),
      )
    }
    let cp = match parse_hex(fields[0].trim_space(), filename, line_no) {
      Ok(x) => x
      Err(e) => return Err(e)
    }
    let ccc = match parse_decimal(fields[3].trim_space(), filename, line_no) {
      Ok(x) => x
      Err(e) => return Err(e)
    }
    cccs.push({ code_point: cp, ccc, source: { filename, line: line_no } })
    let decomposition = fields[5].trim_space().to_string()
    if decomposition != "" {
      let parts = decomposition.split(" ").to_array()
      let canonical = !parts[0].contains("<")
      let start = if canonical { 0 } else { 1 }
      if parts.length() <= start {
        return Err(
          error(
            MissingField,
            filename,
            line_no,
            "decomposition mapping is empty",
          ),
        )
      }
      let mapping : Array[Int] = []
      for i = start; i < parts.length(); i = i + 1 {
        match parse_hex(parts[i], filename, line_no) {
          Ok(x) => mapping.push(x)
          Err(e) => return Err(e)
        }
      }
      decompositions.push({
        code_point: cp,
        mapping,
        canonical,
        source: { filename, line: line_no },
      })
    }
  }
  Ok((decompositions, cccs))
}

///|
fn parse_decimal(
  text : StringView,
  filename : String,
  line : Int,
) -> Result[Int, ParseError] {
  if text.length() == 0 {
    return Err(error(MissingField, filename, line, "decimal field is empty"))
  }
  let mut value = 0
  for c in text {
    if c < '0' || c > '9' {
      return Err(
        error(MalformedRecord, filename, line, "invalid decimal field"),
      )
    }
    value = value * 10 + c.to_int() - '0'.to_int()
  }
  Ok(value)
}

///|
/// Parse CompositionExclusions.txt, which is a one-column scalar/range file.
pub fn parse_exclusions(
  filename : String,
  input : String,
) -> Result[Array[CodePointRange], ParseError] {
  let records : Array[CodePointRange] = []
  let mut line_no = 0
  for raw in input.split("\n") {
    line_no += 1
    let line = data_part(raw.to_string())
    if line == "" {
      continue
    }
    let range = match parse_range(line, filename, line_no) {
      Ok(x) => x
      Err(e) => return Err(e)
    }
    match check_overlap(records, range, { filename, line: line_no }) {
      Some(e) => return Err(e)
      None => ()
    }
    records.push(range)
  }
  Ok(records)
}

///|
/// Parse NormalizationTest.txt or GraphemeBreakTest.txt vector rows.
pub fn parse_conformance_file(
  filename : String,
  input : String,
) -> Result[Array[ConformanceVector], ParseError] {
  let vectors : Array[ConformanceVector] = []
  let mut line_no = 0
  for raw in input.split("\n") {
    line_no += 1
    let line = data_part(raw.to_string())
    if line == "" {
      continue
    }
    let columns = line.split(";").to_array()
    if columns.length() < 2 {
      return Err(
        error(
          InvalidVector,
          filename,
          line_no,
          "expected at least two semicolon-separated columns",
        ),
      )
    }
    let parsed : Array[Array[Int]] = []
    for column in columns {
      let scalars : Array[Int] = []
      let text = column.trim_space()
      if text != "" {
        for token in text.split(" ") {
          if token.trim_space() != "" {
            match parse_hex(token.trim_space(), filename, line_no) {
              Ok(x) => scalars.push(x)
              Err(e) => return Err(e)
            }
          }
        }
      }
      parsed.push(scalars)
    }
    vectors.push({ columns: parsed, source: { filename, line: line_no } })
  }
  Ok(vectors)
}