///|
priv enum ActivePoField {
  ActiveContext
  ActiveId
  ActivePluralId
  ActiveTranslation(Int)
}

///|
priv struct PoEntryBuilder {
  comments : Array[PoComment]
  mut context : String?
  mut msgid : String
  mut msgid_plural : String?
  translations : Map[Int, String]
  mut active : ActivePoField?
  mut saw_msgid : Bool
  mut obsolete : Bool
}

///|
fn PoEntryBuilder::new() -> PoEntryBuilder {
  {
    comments: [],
    context: None,
    msgid: "",
    msgid_plural: None,
    translations: {},
    active: None,
    saw_msgid: false,
    obsolete: false,
  }
}

///|
fn PoEntryBuilder::has_content(self : PoEntryBuilder) -> Bool {
  self.saw_msgid ||
  self.context is Some(_) ||
  !self.comments.is_empty() ||
  !self.translations.is_empty()
}

///|
fn PoEntryBuilder::append_active(
  self : PoEntryBuilder,
  value : String,
  line : Int,
) -> Unit raise GettextError {
  match self.active {
    Some(ActiveContext) =>
      self.context = Some(self.context.unwrap_or("") + value)
    Some(ActiveId) => self.msgid = self.msgid + value
    Some(ActivePluralId) =>
      self.msgid_plural = Some(self.msgid_plural.unwrap_or("") + value)
    Some(ActiveTranslation(index)) => {
      let previous = self.translations.get(index).unwrap_or("")
      self.translations[index] = previous + value
    }
    None =>
      raise PoSyntax(
        line~,
        column=1,
        message="quoted continuation has no preceding field",
      )
  }
}

///|
fn PoEntryBuilder::finish(
  self : PoEntryBuilder,
  line : Int,
) -> PoEntry raise GettextError {
  if !self.saw_msgid {
    raise PoSyntax(
      line~,
      column=1,
      message="entry has comments or fields but no msgid",
    )
  }
  let mut max_index = -1
  for index, _ in self.translations {
    if index < 0 {
      raise PoSyntax(line~, column=1, message="msgstr index cannot be negative")
    }
    if index > max_index {
      max_index = index
    }
  }
  let translations = if max_index < 0 {
    if self.msgid_plural is Some(_) {
      []
    } else {
      [""]
    }
  } else {
    let values = Array::make(max_index + 1, "")
    for index, value in self.translations {
      values[index] = value
    }
    values
  }
  {
    comments: self.comments.copy(),
    context: self.context,
    msgid: self.msgid,
    msgid_plural: self.msgid_plural,
    translations,
    obsolete: self.obsolete,
  }
}

///|
fn parse_comment_line(line : String) -> PoComment {
  if line.has_prefix("#.") {
    PoComment::new(Extracted, line[2:].trim_start().to_owned())
  } else if line.has_prefix("#:") {
    PoComment::new(Reference, line[2:].trim_start().to_owned())
  } else if line.has_prefix("#,") {
    PoComment::new(Flag, line[2:].trim_start().to_owned())
  } else if line.has_prefix("#|") {
    PoComment::new(Previous, line[2:].trim_start().to_owned())
  } else if line == "#" {
    PoComment::new(Translator, "")
  } else {
    PoComment::new(Translator, line[1:].trim_start().to_owned())
  }
}

///|
fn quoted_value(text : String, line_number : Int) -> String raise GettextError {
  let trimmed = text.trim().to_owned()
  if trimmed.length() < 2 ||
    !trimmed.has_prefix("\"") ||
    !trimmed.has_suffix("\"") {
    raise PoSyntax(
      line=line_number,
      column=1,
      message="expected one double-quoted PO string",
    )
  }
  let body = trimmed[1:trimmed.length() - 1].to_owned()
  try unescape_po_string(body) catch {
    PoSyntax(column~, message~, ..) =>
      raise PoSyntax(line=line_number, column~, message~)
    error => raise error
  } noraise {
    value => value
  }
}

///|
fn decimal_index(text : String, line_number : Int) -> Int raise GettextError {
  if text == "" {
    raise PoSyntax(line=line_number, column=1, message="msgstr index is empty")
  }
  let mut value = 0
  for c in text {
    guard c is ('0'..='9') else {
      raise PoSyntax(
        line=line_number,
        column=1,
        message="msgstr index must be a decimal integer",
      )
    }
    value = value * 10 + c.to_int() - '0'.to_int()
  }
  value
}

///|
fn value_after_keyword(
  line : String,
  keyword : String,
  line_number : Int,
) -> String raise GettextError {
  let rest = line[keyword.length():].trim_start().to_owned()
  quoted_value(rest, line_number)
}

///|
fn strip_obsolete_prefix(line : String) -> (String, Bool) {
  if line.has_prefix("#~") {
    (line[2:].trim_start().to_owned(), true)
  } else {
    (line, false)
  }
}

///|
/// Parse a GNU gettext PO or POT document.
///
/// The parser accepts LF and CRLF input, multiline quoted strings, contexts,
/// plurals, the five standard comment classes, and obsolete `#~` entries.
/// Parsed output is semantic rather than lossless: whitespace and line wrapping
/// are normalized when the document is written again.
pub fn parse_po(source : String) -> PoFile raise GettextError {
  let entries : Array[PoEntry] = []
  let mut builder = PoEntryBuilder::new()
  let lines = source.split("\n").to_array()
  for index, raw_line in lines {
    let line_number = index + 1
    let physical_line = raw_line.to_owned().trim_end(chars="\r").to_owned()
    if physical_line.trim().is_empty() {
      if builder.has_content() {
        if builder.saw_msgid {
          entries.push(builder.finish(line_number))
        }
        builder = PoEntryBuilder::new()
      }
      continue
    }

    let (line, is_obsolete) = strip_obsolete_prefix(physical_line)
    if is_obsolete {
      builder.obsolete = true
    }

    if line.has_prefix("#") {
      if builder.saw_msgid && !is_obsolete {
        entries.push(builder.finish(line_number))
        builder = PoEntryBuilder::new()
      }
      builder.comments.push(parse_comment_line(line))
      builder.active = None
      continue
    }

    if line.trim_start().has_prefix("\"") {
      builder.append_active(quoted_value(line, line_number), line_number)
      continue
    }

    if line.has_prefix("msgctxt") {
      if builder.saw_msgid {
        entries.push(builder.finish(line_number))
        builder = PoEntryBuilder::new()
        if is_obsolete {
          builder.obsolete = true
        }
      }
      builder.context = Some(value_after_keyword(line, "msgctxt", line_number))
      builder.active = Some(ActiveContext)
    } else if line.has_prefix("msgid_plural") {
      if !builder.saw_msgid {
        raise PoSyntax(
          line=line_number,
          column=1,
          message="msgid_plural must follow msgid",
        )
      }
      builder.msgid_plural = Some(
        value_after_keyword(line, "msgid_plural", line_number),
      )
      builder.active = Some(ActivePluralId)
    } else if line.has_prefix("msgid") {
      if builder.saw_msgid {
        entries.push(builder.finish(line_number))
        builder = PoEntryBuilder::new()
        if is_obsolete {
          builder.obsolete = true
        }
      }
      builder.msgid = value_after_keyword(line, "msgid", line_number)
      builder.saw_msgid = true
      builder.active = Some(ActiveId)
    } else if line.has_prefix("msgstr[") {
      guard line.find("]") is Some(close) else {
        raise PoSyntax(
          line=line_number,
          column=1,
          message="msgstr index is missing a closing bracket",
        )
      }
      let translation_index = decimal_index(
        line[7:close].to_owned(),
        line_number,
      )
      let value = quoted_value(line[close + 1:].to_owned(), line_number)
      builder.translations[translation_index] = value
      builder.active = Some(ActiveTranslation(translation_index))
    } else if line.has_prefix("msgstr") {
      let value = value_after_keyword(line, "msgstr", line_number)
      builder.translations[0] = value
      builder.active = Some(ActiveTranslation(0))
    } else {
      raise PoSyntax(
        line=line_number,
        column=1,
        message="unknown PO directive: \{line}",
      )
    }
  }
  if builder.has_content() {
    entries.push(builder.finish(lines.length()))
  }
  PoFile::new(entries)
}