///|
pub struct ReadResult {
  nodes : ReadOnlyArray[@cst.Node]
  document : Document?
  diagnostics : ReadOnlyArray[@diagrams.Diagnostic]
}

///|
pub let diagnostic_codes : ReadOnlyArray[String] = [
  "activity/syntax", "activity/ambiguous-dialect", "activity/missing-dialect", "activity/unclosed-control",
  "activity/unexpected-control", "activity/limit", "activity/unresolved-label", "activity/invalid-ast",
  "activity/unapplied-presentation", "activity/external-asset",
]

///|
fn trim(text : String) -> String {
  @envelope.trim(text)
}

///|
fn part(text : String, start : Int32, end : Int32) -> String {
  @cst.slice(text, start, end)
}

///|
fn after(text : String, prefix : String) -> String {
  trim(part(text, prefix.length(), text.length()))
}

///|
fn word(text : String, prefix : String) -> Bool {
  text == prefix ||
  text.has_prefix(prefix + " ") ||
  text.has_prefix(prefix + "(")
}

///|
fn unquote(text : String) -> String {
  let text = trim(text)
  if text.length() >= 2 && text.has_prefix("\"") && text.has_suffix("\"") {
    part(text, 1, text.length() - 1)
    .replace_all(old="\\n", new="\n")
    .replace_all(old="\\\"", new="\"")
    .replace_all(old="\\\\", new="\\")
  } else {
    text
  }
}

///|
fn parens(text : String) -> Array[String] {
  let out = []
  let mut depth = 0
  let mut start = 0
  let mut quote = false
  for i = 0; i < text.length(); i = i + 1 {
    let c = text.code_unit_at(i).to_int()
    if c == 34 {
      quote = !quote
    }
    if !quote {
      if c == 40 {
        if depth == 0 {
          start = i + 1
        }
        depth += 1
      } else if c == 41 && depth > 0 {
        depth -= 1
        if depth == 0 {
          out.push(part(text, start, i))
        }
      }
    }
  }
  out
}

///|
fn argument(text : String, index : Int32) -> String {
  parens(text).get(index).unwrap_or("")
}

///|
fn quoted_end(text : String) -> Int32? {
  let mut escaped = false
  for i = 1; i < text.length(); i = i + 1 {
    let c = text.code_unit_at(i).to_int()
    if c == 34 && !escaped {
      return Some(i)
    }
    escaped = c == 92 && !escaped
  }
  None
}

///|
fn endpoint(text : String) -> Endpoint? {
  let text = trim(text)
  if text == "(*)" || text == "(*top)" {
    Some(Start(text == "(*top)"))
  } else if text.has_prefix("==") && text.has_suffix("===") {
    Some(
      Synchronization(
        trim(text.trim_start(chars="=").trim_end(chars="=").to_owned()),
      ),
    )
  } else if text.has_prefix("\"") {
    guard quoted_end(text) is Some(end) else { return None }
    let tail = trim(part(text, end + 1, text.length()))
    if !tail.is_empty() &&
      !tail.has_prefix("as ") &&
      !tail.has_prefix("<<") &&
      !tail.has_prefix("#") {
      None
    } else {
      let code = if tail.has_prefix("as ") {
        Some(after(tail, "as").split(" ").next().unwrap_or("").to_owned())
      } else {
        None
      }
      let decoration = match code {
        Some(code) => trim(after(tail, "as " + code))
        None => tail
      }
      Some(Action(unquote(part(text, 0, end + 1)), code, decoration))
    }
  } else if !text.is_empty() && !text.contains(" ") {
    Some(Alias(text))
  } else {
    None
  }
}

///|
fn legacy_edge(text : String) -> Kind? {
  let mut quote = false
  let mut a = -1
  let mut b = -1
  for i = 0; i < text.length(); i = i + 1 {
    let c = text.code_unit_at(i).to_int()
    if c == 34 {
      quote = !quote
    }
    if !quote &&
      (
        c == 45 ||
        (
          c == 46 &&
          i + 1 < text.length() &&
          [46, 62, 91].contains(text.code_unit_at(i + 1).to_int())
        )
      ) &&
      a < 0 {
      a = i
    }
    if !quote && c == 62 && a >= 0 {
      b = i + 1
      break
    }
  }
  guard a >= 0 && b > a else { return None }
  let from = trim(part(text, 0, a))
  let arrow = part(text, a, b)
  let mut tail = trim(part(text, b, text.length()))
  let mut label = ""
  if tail.has_prefix("[") {
    guard tail.find("]") is Some(close) else { return None }
    label = part(tail, 1, close)
    tail = trim(part(tail, close + 1, tail.length()))
  }
  guard endpoint(tail) is Some(target) else { return None }
  let source = if from.is_empty() { None } else { endpoint(from) }
  if !from.is_empty() && source == None {
    return None
  }
  Some(Edge(source, arrow, target, label))
}

///|
fn action_end(text : String) -> Int32? {
  for i = 0; i < text.length(); i = i + 1 {
    if text.code_unit_at(i).to_int() == 59 {
      let tail = trim(part(text, i + 1, text.length()))
      if tail.is_empty() ||
        tail.has_prefix("<<") ||
        tail.has_prefix("#") ||
        tail.has_prefix("[[") {
        return Some(i)
      }
    }
  }
  None
}

///|
fn modern(text : String) -> Kind? {
  let lower = text.to_lower()
  if text == "start" {
    Some(Start)
  } else if text == "stop" || text.has_prefix("stop ") {
    Some(Stop)
  } else if text == "end" {
    Some(End)
  } else if word(text, "if") && text.contains("(") {
    Some(If(argument(text, 0), argument(text, 1)))
  } else if text.has_prefix("(") && text.contains("elseif") {
    Some(
      ElseIfIncoming(argument(text, 0), argument(text, 1), argument(text, 2)),
    )
  } else if word(text, "elseif") {
    Some(ElseIf(argument(text, 0), argument(text, 1)))
  } else if word(text, "else") {
    Some(Else(argument(text, 0)))
  } else if lower == "endif" || lower == "end if" {
    Some(EndIf)
  } else if word(text, "switch") {
    Some(Switch(argument(text, 0)))
  } else if word(text, "case") {
    Some(Case(argument(text, 0)))
  } else if text == "endswitch" || text == "end switch" {
    Some(EndSwitch)
  } else if word(text, "while") {
    Some(While(argument(text, 0), argument(text, 1)))
  } else if word(text, "endwhile") {
    Some(EndWhile(argument(text, 0)))
  } else if text.has_prefix("repeat while") || text.has_prefix("repeatwhile") {
    Some(RepeatWhile(argument(text, 0), argument(text, 1), argument(text, 2)))
  } else if word(text, "repeat") {
    Some(Repeat(after(text, "repeat")))
  } else if text.has_prefix("backward:") || text.has_prefix("backward :") {
    Some(
      Backward(
        trim(after(after(text, "backward"), ":").trim_end(chars=";").to_owned()),
      ),
    )
  } else if text == "break" {
    Some(Break)
  } else if text == "fork" {
    Some(Fork)
  } else if text == "fork again" {
    Some(ForkAgain)
  } else if word(text, "end fork") || word(text, "endfork") {
    Some(
      EndFork(false, if text.contains("{") { argument_brace(text) } else { "" }),
    )
  } else if text == "end merge" || text == "endmerge" {
    Some(EndFork(true, ""))
  } else if text == "split" {
    Some(Split)
  } else if text == "split again" {
    Some(SplitAgain)
  } else if text == "end split" || text == "endsplit" {
    Some(EndSplit)
  } else if text == "kill" {
    Some(Kill)
  } else if text == "detach" {
    Some(Detach)
  } else if text.has_prefix("label ") {
    Some(Label(after(text, "label")))
  } else if text.has_prefix("goto ") {
    Some(Goto(after(text, "goto")))
  } else if text.has_prefix("(") && text.has_suffix(")") {
    Some(Connector(argument(text, 0)))
  } else if text.has_prefix("|") && text.contains("|") {
    let pieces = text.split("|").map(s => s.to_owned()).collect()
    if pieces.length() >= 3 {
      let color = if pieces[1].has_prefix("#") { pieces[1] } else { "" }
      let at = if color.is_empty() { 1 } else { 2 }
      if pieces.length() <= at + 1 {
        None
      } else {
        let id = pieces[at]
        let label = trim(pieces[at + 1])
        Some(Lane(id, if label.is_empty() { id } else { label }, color))
      }
    } else {
      None
    }
  } else if (
      word(text, "partition") ||
      word(text, "group") ||
      word(text, "package") ||
      word(text, "rectangle") ||
      word(text, "card")
    ) &&
    text.has_suffix("{") {
    let first = text.split(" ").next().unwrap_or("").to_owned()
    let mut value = trim(part(text, first.length(), text.length() - 1))
    let mut before = ""
    if value.has_prefix("#") {
      let at = value.find(" ").unwrap_or(value.length())
      before = part(value, 0, at)
      value = trim(part(value, at, value.length()))
    }
    let (label, decoration) = if value.has_prefix("\"") &&
      quoted_end(value) is Some(at) {
      (
        unquote(part(value, 0, at + 1)),
        trim(part(value, at + 1, value.length())),
      )
    } else {
      let color = value.find(" #").unwrap_or(value.length())
      let stereo = value.find("<<").unwrap_or(value.length())
      let at = color.min(stereo)
      (trim(part(value, 0, at)), trim(part(value, at, value.length())))
    }
    Some(Group(first, label, before, decoration))
  } else if text == "}" || text == "end group" {
    Some(EndGroup)
  } else if text.has_prefix("->") ||
    text.has_prefix("-[") ||
    text.has_prefix("-->") {
    Some(
      Arrow(
        if text.contains("hidden") {
          "invisible"
        } else if text.contains("dashed") || text.contains("dotted") {
          "dotted"
        } else if text.contains("bold") {
          "thick"
        } else {
          "solid"
        },
        text.trim_end(chars=";").to_owned(),
      ),
    )
  } else if text.has_prefix("<<") ||
    text.has_prefix("#") ||
    text.has_prefix("link ") ||
    text.has_prefix("-<") {
    Some(Presentation(text))
  } else {
    None
  }
}

///|
fn argument_brace(text : String) -> String {
  match (text.find("{"), text.find("}")) {
    (Some(a), Some(b)) if b > a => part(text, a + 1, b)
    _ => ""
  }
}

///|
/// Read the activity lines of an already scanned envelope. `dialect` is
/// required for body-only calls; full blocks can select it from statements.
pub fn read(
  scan : @envelope.Scan,
  limits~ : @envelope.Limits = @envelope.Limits::new(),
  dialect? : Dialect,
) -> ReadResult {
  let nodes = scan.line_nodes(line => {
    Some(@cst.Node::new(Data, line.tokens(Text)))
  })
  let diagnostics = scan.diagnostics.iter().collect()
  let items : Array[Statement] = []
  let mut chosen = dialect
  let stack : Array[(String, @diagrams.Span)] = []
  let mut work = 0
  let mut i = 0
  let mut modern_seen = false
  let mut legacy_seen = false
  fn error(code : String, message : String, span : @diagrams.Span?) {
    if diagnostics.length() < limits.diagnostics.max(1) {
      diagnostics.push(@diagrams.Diagnostic::error(code, message, span?))
    }
  }
  fn emit(kind : Kind, span : @diagrams.Span) {
    if items.length() >= limits.nodes {
      error("activity/limit", "Statement limit exceeded", Some(span))
      return
    }
    let open = match kind {
      If(_, _) | LegacyIf(_) | LegacyDecision(_, _, _, _) => Some("if")
      While(_, _) => Some("while")
      Repeat(_) => Some("repeat")
      Fork => Some("fork")
      Split => Some("split")
      Switch(_) => Some("switch")
      Group(_, _, _, _) => Some("group")
      _ => None
    }
    let close = match kind {
      EndIf => Some("if")
      EndWhile(_) => Some("while")
      RepeatWhile(_, _, _) => Some("repeat")
      EndFork(_, _) => Some("fork")
      EndSplit => Some("split")
      EndSwitch => Some("switch")
      EndGroup => Some("group")
      _ => None
    }
    if open is Some(name) {
      if stack.length() >= limits.depth {
        error("activity/limit", "Control nesting limit exceeded", Some(span))
      } else {
        stack.push((name, span))
      }
    }
    if close is Some(name) {
      match stack.last() {
        Some((expected, _)) if expected == name => ignore(stack.pop())
        _ =>
          error(
            "activity/unexpected-control",
            "Unexpected closing " + name,
            Some(span),
          )
      }
    }
    items.push(Statement::new(kind, span~))
  }
  while i < scan.lines.length() {
    let line = scan.lines[i]
    i += 1
    work += line.text.length() + 1
    if work > limits.work {
      error(
        "activity/limit",
        "Activity work limit exceeded",
        Some(scan.span(line.offset, line.offset)),
      )
      break
    }
    if line.role != @envelope.Role::Body {
      continue
    }
    let mut text = trim(line.text)
    if text.is_empty() || text.has_prefix("'") {
      continue
    }
    let begin = line.offset
    let mut end = line.offset + line.text.length()
    let span = scan.span(begin, end)
    if text.has_prefix("note ") ||
      text == "note" ||
      text.has_prefix("floating note") {
      let header = if text.find(":") is Some(at) {
        trim(part(text, 0, at))
      } else {
        text
      }
      let words = header.split(" ").filter(word => !word.is_empty()).to_array()
      let placement = if header.has_prefix("floating note") {
        "floating"
      } else if words.length() > 1 && ["left", "right", "top", "bottom"].contains(words[1].to_owned()) {
        words[1].to_owned()
      } else {
        "right"
      }
      let mut note = ""
      let mut note_alias : String? = None
      if text.find(":") is Some(colon) {
        note = trim(part(text, colon + 1, text.length()))
      } else {
        if text.find(" as ") is Some(at) {
          note_alias = Some(trim(part(text, at + 4, text.length())))
        }
        let out = []
        let mut closed = false
        while i < scan.lines.length() {
          let next = scan.lines[i]
          i += 1
          end = next.offset + next.text.length()
          if trim(next.text) == "end note" || trim(next.text) == "endnote" {
            closed = true
            break
          }
          out.push(next.text)
        }
        note = out.join("\n")
        if !closed {
          error("activity/syntax", "Unclosed note", Some(span))
        }
      }
      let decoration = if header.find("#") is Some(at) {
        trim(part(header, at, header.length()))
      } else {
        ""
      }
      if header.contains("on link") {
        emit(NoteOnLink(note, decoration), scan.span(begin, end))
      } else {
        emit(
          Note(placement, note, note_alias, decoration),
          scan.span(begin, end),
        )
      }
      continue
    }
    if text.has_prefix("#") && text.contains(":(") && text.has_suffix(")") {
      modern_seen = true
      let at = text.find(":").unwrap()
      emit(Connector(argument(part(text, at + 1, text.length()), 0)), span)
      continue
    }
    if text.has_prefix(":") || (text.has_prefix("#") && text.contains(":")) {
      modern_seen = true
      let mut decoration = ""
      if !text.has_prefix(":") {
        let colon = text.find(":").unwrap()
        decoration = part(text, 0, colon)
        text = part(text, colon, text.length())
      }
      let indent = line.text.length() - line.text.trim_start().length()
      let mut content = after(text, ":")
      let mut terminator = ";"
      let mut ended = false
      while true {
        if action_end(content) is Some(at) {
          let trailing = trim(part(content, at + 1, content.length()))
          content = part(content, 0, at)
          if !trailing.is_empty() {
            if trailing.has_prefix("<<") ||
              trailing.has_prefix("#") ||
              trailing.has_prefix("[[") {
              decoration = "after:" + trailing
            } else {
              error(
                "activity/syntax",
                "Unexpected text after activity action",
                Some(span),
              )
            }
          }
          ended = true
          break
        }
        if !content.contains("\n") &&
          ["]", "}", "/", "|", ">", "<"].iter().any(s => content.has_suffix(s)) {
          terminator = part(content, content.length() - 1, content.length())
          content = part(content, 0, content.length() - 1)
          ended = true
          break
        }
        if i >= scan.lines.length() {
          break
        }
        let next = scan.lines[i]
        let candidate = trim(next.text)
        if [
            "start", "stop", "endif", "endwhile", "end fork", "end split", "}", "else",
          ].contains(candidate) ||
          candidate.has_prefix("if (") {
          break
        }
        i += 1
        end = next.offset + next.text.length()
        let leading = next.text.length() - next.text.trim_start().length()
        content = content +
          "\n" +
          part(next.text, indent.min(leading), next.text.length())
      }
      if !ended {
        error("activity/syntax", "Unterminated activity action", Some(span))
      }
      emit(Action(trim(content), terminator, decoration), scan.span(begin, end))
      continue
    }
    if text.has_prefix("-<") {
      modern_seen = true
      let value = part(text, 1, text.length())
      let suffix = value.find("<<")
      let label = match suffix {
        Some(at) => trim(part(value, 0, at))
        None => value
      }
      let decoration = match suffix {
        Some(at) => "after:" + part(value, at, value.length())
        None => ""
      }
      emit(Action(label, ";", decoration), span)
      continue
    }
    if (modern_seen && !legacy_seen) &&
      modern(text) is Some(Arrow(style, value)) {
      let mut value = value
      if value.find(">") is Some(at) &&
        !trim(part(value, at + 1, value.length())).is_empty() &&
        !text.has_suffix(";") {
        while i < scan.lines.length() {
          let next = scan.lines[i]
          let candidate = trim(next.text)
          if candidate.has_prefix(":") || modern(candidate) != None {
            break
          }
          i += 1
          end = next.offset + next.text.length()
          value = value + "\n" + next.text
          if value.has_suffix(";") {
            value = value.trim_end(chars=";").to_owned()
            break
          }
        }
      }
      emit(Arrow(style, value), scan.span(begin, end))
      continue
    }
    if text.has_prefix("-") || text.has_prefix("*") {
      let mut depth = 0
      while depth < text.length() &&
            [42, 45].contains(text.code_unit_at(depth).to_int()) {
        depth += 1
      }
      if depth < text.length() && text.code_unit_at(depth).to_int() == 32 {
        modern_seen = true
        emit(ActionList(trim(part(text, depth, text.length())), depth), span)
        continue
      }
    }
    let mut quote_count = 0
    for unit in text.to_array() {
      if unit == '"' {
        quote_count += 1
      }
    }
    if quote_count % 2 == 1 {
      while i < scan.lines.length() {
        let next = scan.lines[i]
        i += 1
        text = text + "\n" + next.text
        end = next.offset + next.text.length()
        for unit in next.text.to_array() {
          if unit == '"' {
            quote_count += 1
          }
        }
        if quote_count % 2 == 0 {
          break
        }
      }
    }
    if text.to_lower().find("if \"") is Some(position) && position > 0 {
      let prefix = part(text, 0, position)
      if legacy_edge(prefix + "\"__decision\"")
        is Some(Edge(from, arrow, _, label)) {
        let condition = after(part(text, position, text.length()), "if")
        let condition = if condition.to_lower().has_suffix("then") {
          trim(part(condition, 0, condition.length() - 4))
        } else {
          condition
        }
        legacy_seen = true
        emit(
          LegacyDecision(from, arrow, unquote(condition), label),
          scan.span(begin, end),
        )
        continue
      }
    }
    if legacy_edge(text) is Some(edge) {
      legacy_seen = true
      emit(edge, scan.span(begin, end))
      continue
    }
    let lower = text.to_lower()
    if lower.has_prefix("if \"") || lower.has_prefix("if\"") {
      legacy_seen = true
      let condition_text = after(text, "if")
      let condition_text = if condition_text.to_lower().has_suffix("then") {
        trim(part(condition_text, 0, condition_text.length() - 4))
      } else {
        condition_text
      }
      emit(LegacyIf(unquote(condition_text)), span)
      continue
    }
    if modern(text) is Some(kind) {
      if (match kind {
          Start
          | Stop
          | End
          | Action(_, _, _)
          | Connector(_)
          | While(_, _)
          | Repeat(_)
          | Fork
          | Split
          | Lane(_, _, _) => true
          _ => false
        }) {
        modern_seen = true
      }
      emit(kind, span)
    } else {
      error(
        "activity/syntax",
        "Unknown activity statement: " + text,
        Some(span),
      )
    }
  }
  for (name, span) in stack {
    error("activity/unclosed-control", "Unclosed " + name, Some(span))
  }
  if chosen == None {
    if modern_seen && legacy_seen {
      error(
        "activity/ambiguous-dialect",
        "Mixed activity dialects require an explicit supported grammar",
        None,
      )
    } else if modern_seen {
      chosen = Some(Modern)
    } else if legacy_seen {
      chosen = Some(Legacy)
    }
  }
  if chosen == None {
    error(
      "plantuml/unsupported-diagram",
      "The UML block is not an activity diagram",
      None,
    )
  }
  if (chosen == Some(Modern) && legacy_seen) ||
    (chosen == Some(Legacy) && modern_seen) {
    error(
      "activity/ambiguous-dialect",
      "Statements do not match the selected activity dialect",
      None,
    )
  }
  {
    nodes: ReadOnlyArray::from_array(nodes),
    document: chosen.map(dialect => {
      Document::new(
        dialect,
        items[:],
        name?=scan.name,
        statements=scan.statements[:],
      )
    }),
    diagnostics: ReadOnlyArray::from_array(diagnostics),
  }
}