///|
pub struct Conversion {
  flowchart : @model.Flowchart?
  diagnostics : ReadOnlyArray[@diagrams.Diagnostic]
} derive(Eq, Debug)

///|
fn group_label(value : String) -> String {
  if value.has_prefix("`") && value.has_suffix("`") && value.length() >= 2 {
    value[1:value.length() - 1].to_owned()
  } else {
    value
  }
}

///|
priv struct Scope {
  id : String
  label : String
  direction : @model.Direction?
  members : Array[String]
  expanded : Bool
}

///|
fn marker(value : String) -> @model.Marker {
  match value {
    "arrow" => Arrow
    "circle" => Circle
    "cross" => Cross
    _ => None
  }
}

///|
fn style(value : String) -> @model.LineStyle {
  match value {
    "thick" => Thick
    "dotted" => Dotted
    "invisible" => Invisible
    _ => Solid
  }
}

///|
fn truthy(value : Json) -> Bool {
  match value {
    Null | False => false
    String(value) => value != ""
    Number(value, ..) => value != 0.0 && !value.is_nan()
    _ => true
  }
}

///|
pub fn to_flowchart(
  source : @flow_ast.Diagram,
  limits~ : @syntax.Limits = @syntax.Limits::new(),
  id~ : String = "flowchart",
) -> Conversion {
  let diagram = source.shared()
  let diagnostics = []
  let nodes : Array[@model.Node] = []
  let node_index : Map[String, Int32] = Map([])
  let scopes : Array[Scope] = []
  let scope_index : Map[String, Int32] = Map([])
  let scope_order : Array[Int32] = []
  let stack : Array[Int32] = []
  let member_owner : Map[String, String] = Map([])
  let connections : Array[@model.Connection] = []
  let edge_ids : Map[String, Bool] = Map([])
  let pair_counts : Map[String, Int32] = Map([])
  let asset_shapes : Map[String, String] = Map([])
  let mut current_span : @diagrams.Span? = None
  let mut current_statement = 0
  let links : Map[String, String] = Map([])
  let mut work = 0
  let mut failed = false
  let mut title : String? = None
  let mut accessibility_title : String? = None
  let mut accessibility_description : String? = None
  fn error(code : String, message : String, span : @diagrams.Span?) {
    failed = true
    let diagnostic = @diagrams.Diagnostic::error(
      "flowchart/" + code,
      message,
      span?,
    )
    if diagnostics.length() < limits.diagnostics.max(1) {
      diagnostics.push(diagnostic)
    } else {
      diagnostics[diagnostics.length() - 1] = diagnostic
    }
  }
  fn warning(message : String, span : @diagrams.Span?) {
    if diagnostics.length() < limits.diagnostics.max(1) {
      diagnostics.push(
        @diagrams.Diagnostic::warning(
          "flowchart/presentation-metadata",
          message,
          span?,
        ),
      )
    }
  }
  fn charge(units : Int32) -> Bool {
    if units < 0 || units > limits.work - work {
      error("work-limit", "Conversion work limit exceeded", None)
      false
    } else {
      work += units
      true
    }
  }
  fn node(decl : @ast.NodeDecl) {
    if !charge(1) || failed {
      return
    }
    if !charge(decl.id.length()) ||
      !charge(decl.label.map(value => value.length()).unwrap_or(0)) ||
      !charge(decl.shape.map(value => value.length()).unwrap_or(0)) ||
      !charge(decl.metadata.length()) ||
      !charge(decl.classes.length()) {
      return
    }
    for data in decl.metadata {
      if !charge(data.0.length()) ||
        !charge(
          @ast.canonical_json(
            data.1,
            depth_limit=limits.metadata_depth,
            work_limit=(limits.work - work).max(0),
          )
          .unwrap_or_else(() => {
            error("work-limit", "Metadata limit exceeded", None)
            ""
          })
          .length(),
        ) {
        return
      }
    }
    for name in decl.classes {
      if !charge(name.length()) {
        return
      }
    }
    if scope_index.get(decl.id) is Some(index) {
      if stack.last() is Some(parent) {
        scopes[parent].members.push(decl.id)
      }
      for data in decl.metadata {
        if data.0 == "view" && data.1 == Json("collapsed") {
          let old = scopes[index]
          scopes[index] = { ..old, expanded: false, }
        } else {
          warning("Subgraph presentation metadata remains source data", None)
        }
      }
      return
    }
    if edge_ids.contains(decl.id) {
      warning("Edge presentation metadata remains source data", None)
      return
    }
    for data in decl.metadata {
      if data.0 == "shape" && truthy(data.1) {
        match data.1 {
          String(value) =>
            if value.to_array().any(c => (c >= 'A' && c <= 'Z') || c == '_') {
              error(
                "invalid-ast",
                "Modern shape names must be lowercase without underscores: " +
                value,
                None,
              )
              return
            }
          _ => {
            error(
              "invalid-ast",
              "A truthy shape declaration must be a string",
              None,
            )
            return
          }
        }
      }
      if data.0 == "img" && truthy(data.1) {
        asset_shapes[decl.id] = "image"
      }
      if data.0 == "icon" &&
        truthy(data.1) &&
        asset_shapes.get(decl.id) != Some("image") {
        asset_shapes[decl.id] = "icon"
      }
    }
    let previous = node_index.get(decl.id)
    if previous == None && nodes.length() >= limits.nodes {
      error("node-limit", "Node limit exceeded", None)
      return
    }
    let old = match previous {
      Some(index) => nodes[index]
      None => @model.Node::new(decl.id)
    }
    let declared_shape = match decl.shape {
      Some(shape) => shape
      None => old.shape
    }
    let shape = asset_shapes.get(decl.id).unwrap_or(declared_shape)
    let source_label = decl.label.unwrap_or(old.label)
    let label = if asset_shapes.contains(decl.id) &&
      source_label == decl.id &&
      decl.label.map(value => value.trim().is_empty()).unwrap_or(true) {
      ""
    } else {
      source_label
    }
    let label_kind = if decl.label == None {
      old.label_kind
    } else {
      match decl.label_kind {
        "markdown" => @model.LabelKind::Markdown
        "html" => Html
        _ => Text
      }
    }
    if !charge(old.metadata.length()) {
      return
    }
    let merged = old.metadata.iter().to_array()
    let metadata_indices : Map[String, Int32] = Map([])
    for index, pair in merged {
      metadata_indices[pair.0] = index
    }
    for data in decl.metadata {
      if (data.0 == "img" || data.0 == "icon") &&
        !truthy(data.1) &&
        metadata_indices.contains(data.0) {
        continue
      }
      let value = match data.1 {
        String(value) => value
        _ =>
          @ast.canonical_json(
            data.1,
            depth_limit=limits.metadata_depth,
            work_limit=(limits.work - work).max(0),
          ).unwrap_or("")
      }
      match metadata_indices.get(data.0) {
        Some(index) => merged[index] = (data.0, value)
        None => {
          metadata_indices[data.0] = merged.length()
          merged.push((data.0, value))
        }
      }
    }
    let metadata = ReadOnlyArray::from_array(merged[:])
    let value = @model.Node::new(
      decl.id,
      label~,
      shape~,
      label_kind~,
      metadata=metadata[:],
      origin=@model.SourceBinding::new(
        "mermaid",
        "statement/" + current_statement.to_string() + "/node/" + decl.id,
        span?=current_span,
      ),
    )
    match previous {
      Some(index) => nodes[index] = value
      None => {
        node_index[decl.id] = nodes.length()
        nodes.push(value)
      }
    }
    if stack.last() is Some(index) {
      scopes[index].members.push(decl.id)
    }
    if !decl.classes.is_empty() {
      warning("Mermaid classes are retained as source data", None)
    }
  }
  fn close_scope() {
    if stack.pop() is Some(index) {
      let scope = scopes[index]
      let accepted = []
      for child in scope.members {
        if !charge(1) {
          break
        }
        if child != scope.id && !member_owner.contains(child) {
          member_owner[child] = scope.id
          accepted.push(child)
        }
      }
      match scope_index.get(scope.id) {
        Some(existing) =>
          for child in accepted {
            scopes[existing].members.push(child)
          }
        None => {
          if scope_index.length() >= limits.nodes {
            error("node-limit", "Group limit exceeded", None)
            return
          }
          scopes[index] = { ..scope, members: accepted, }
          scope_index[scope.id] = index
          scope_order.push(index)
        }
      }
    }
  }
  if !["flowchart", "graph", "flowchart-elk"].contains(source.header) {
    error("invalid-ast", "Unsupported flowchart header " + source.header, None)
    return {
      flowchart: None,
      diagnostics: ReadOnlyArray::from_array(diagnostics[:]),
    }
  }
  let anonymous : Map[Int32, String] = Map([])
  let open : Array[Int32] = []
  let mut subgraph_count = 0
  for index, statement in diagram.statements {
    if !charge(1) {
      break
    }
    match statement.kind {
      Subgraph(..) => open.push(index)
      End =>
        if open.pop() is Some(start) {
          if diagram.statements[start].kind is Subgraph(id="", label~) {
            let value = group_label(label)
            anonymous[start] = if value != "" &&
              !value
              .to_array()
              .any(c => @syntax.is_space(c.to_int()) || c == '\n' || c == '\r') {
              value
            } else {
              "subGraph" + subgraph_count.to_string()
            }
          }
          subgraph_count += 1
        }
      _ => ()
    }
  }
  while open.pop() is Some(start) {
    if diagram.statements[start].kind is Subgraph(id="", label~) {
      let value = group_label(label)
      anonymous[start] = if value != "" &&
        !value
        .to_array()
        .any(c => @syntax.is_space(c.to_int()) || c == '\n' || c == '\r') {
        value
      } else {
        "subGraph" + subgraph_count.to_string()
      }
    }
    subgraph_count += 1
  }
  let direction = match diagram.direction {
    Some(value) =>
      match @model.direction(value) {
        Some(direction) => direction
        None => {
          error("direction", "Unsupported direction " + value, None)
          @model.Direction::TB
        }
      }
    None => @model.Direction::TB
  }
  if diagram.front_matter is Some(front) {
    let (value, failure) = @syntax.front_matter_title(front, limits~)
    title = value
    if failure is Some(code) {
      error(code, "Invalid or excessive flowchart front matter", None)
    }
  }
  if source.header == "flowchart-elk" {
    warning(
      "The flowchart-elk layout request remains source data; explicit library options apply",
      None,
    )
  }
  if diagram.front_matter != None || !diagram.directives.is_empty() {
    warning(
      "Mermaid renderer configuration remains source data; explicit library options apply",
      None,
    )
  }
  for statement_index, statement in diagram.statements {
    if !charge(1) || failed {
      break
    }
    current_statement = statement_index
    current_span = Some(statement.span)
    match statement.kind {
      Nodes(decls) =>
        for decl in decls {
          node(decl)
        }
      Edges(chain) => {
        if chain.endpoints.is_empty() ||
          chain.links.length() != chain.endpoints.length() - 1 {
          error(
            "invalid-ast",
            "Connection chain has inconsistent endpoints",
            Some(statement.span),
          )
          break
        }
        for endpoint in chain.endpoints {
          for decl in endpoint {
            node(decl)
          }
        }
        for i, link in chain.links {
          if !charge(link.label.length()) ||
            !charge(link.id.map(value => value.length()).unwrap_or(0)) {
            break
          }
          let sources = chain.endpoints[i]
          let targets = chain.endpoints[i + 1]
          if sources.length() > 0 &&
            targets.length() >
            (limits.edges - connections.length()) / sources.length() {
            error(
              "edge-limit",
              "Expanded connection limit exceeded",
              Some(statement.span),
            )
            break
          }
          for source_index, source in sources {
            for target_index, target in targets {
              if !charge(1) {
                break
              }
              let pair_key = source.id.length().to_string() +
                ":" +
                source.id +
                target.id.length().to_string() +
                ":" +
                target.id
              let count = pair_counts.get(pair_key).unwrap_or(0)
              let generated = "L_" +
                source.id +
                "_" +
                target.id +
                "_" +
                (if count == 0 { 0 } else { count + 1 }).to_string()
              let edge_id = match link.id {
                Some(name) if source_index == sources.length() - 1 &&
                  target_index == 0 &&
                  !edge_ids.contains(name) => name
                _ => generated
              }
              pair_counts[pair_key] = count + 1
              edge_ids[edge_id] = true
              connections.push(
                @model.Connection::new(
                  edge_id,
                  source.id,
                  target.id,
                  label=link.label,
                  label_kind=match link.label_kind {
                    "markdown" => @model.LabelKind::Markdown
                    "html" => Html
                    _ => Text
                  },
                  style=style(link.style),
                  source_marker=marker(link.source_marker),
                  target_marker=marker(link.target_marker),
                  min_length=link.length.min(10),
                  origin=@model.SourceBinding::new(
                    "mermaid",
                    "statement/" +
                    statement_index.to_string() +
                    "/edge/" +
                    i.to_string() +
                    "/" +
                    source_index.to_string() +
                    "/" +
                    target_index.to_string(),
                    span=statement.span,
                  ),
                ),
              )
            }
          }
        }
      }
      Subgraph(id~, label~) => {
        if !charge(id.length()) || !charge(label.length()) {
          error("node-limit", "Group limit exceeded", Some(statement.span))
          break
        }
        let id = if id == "" {
          anonymous
          .get(statement_index)
          .unwrap_or("subGraph" + statement_index.to_string())
        } else {
          id
        }
        if stack.length() >= limits.depth {
          error(
            "depth-limit",
            "Subgraph depth limit exceeded",
            Some(statement.span),
          )
          break
        }
        let label = group_label(label)
        let index = scopes.length()
        scopes.push({ id, label, direction: None, members: [], expanded: true, })
        if stack.last() is Some(parent) {
          scopes[parent].members.push(id)
        }
        stack.push(index)
      }
      End => close_scope()
      Direction(value) =>
        match @model.direction(value) {
          Some(direction) =>
            if stack.last() is Some(index) {
              let old = scopes[index]
              scopes[index] = { ..old, direction: Some(direction), }
            }
          None =>
            error(
              "direction",
              "Unsupported local direction " + value,
              Some(statement.span),
            )
        }
      Title(value) => title = Some(value)
      AccTitle(value) => accessibility_title = Some(value)
      AccDescription(value) => accessibility_description = Some(value)
      Metadata(keyword~, text~) => {
        if !charge(text.length()) {
          break
        }
        if keyword == "click" {
          let pieces = text.split(" ").filter(piece => piece != "").to_array()
          if pieces.length() >= 2 {
            let target = pieces[0].to_owned()
            let rest = text[target.length():].trim().to_owned()
            let rest = if rest.has_prefix("href ") {
              rest[5:].trim().to_owned()
            } else {
              rest
            }
            if rest.has_prefix("\"") {
              let tail = rest[1:]
              if tail.find("\"") is Some(end) {
                links[target] = tail[:end].to_owned()
              }
            }
          }
        }
        if keyword == "style" {
          let mut end = 0
          while end < text.length() && !@syntax.is_space(text.at(end).to_int()) {
            end += 1
          }
          if end > 0 {
            let old_stack = stack.iter().to_array()
            stack.clear()
            node({
              id: text[:end].to_owned(),
              label: None,
              label_kind: "text",
              shape: None,
              metadata: [],
              classes: [],
              spelling: "",
            })
            for index in old_stack {
              stack.push(index)
            }
          }
        }
        warning(
          "Mermaid " +
          keyword +
          " behavior is retained as source data and is not applied",
          Some(statement.span),
        )
      }
      Comment(_) => ()
    }
  }
  while !stack.is_empty() {
    close_scope()
  }
  let groups = []
  fn ancestry(id : String) -> Array[String] {
    let path = []
    let mut cursor = Some(id)
    let seen : Map[String, Bool] = Map([])
    while cursor is Some(current) {
      if !charge(1) || seen.contains(current) || path.length() > limits.depth {
        error("ownership-cycle", "Invalid group ancestry " + current, None)
        break
      }
      seen[current] = true
      match scope_index.get(current) {
        Some(index) => {
          let scope = scopes[index]
          path.push(scope.id)
          cursor = member_owner.get(scope.id)
        }
        None => {
          error("ownership", "Missing group " + current, None)
          break
        }
      }
    }
    let ordered = []
    for i = path.length() - 1; i >= 0; i = i - 1 {
      ordered.push(path[i])
    }
    ordered
  }
  for index in scope_order {
    let scope = scopes[index]
    let parent = member_owner.get(scope.id)
    groups.push(
      @model.Group::new(
        scope.id,
        label=scope.label,
        parent?,
        direction?=scope.direction,
        expanded=scope.expanded,
      ),
    )
  }
  let final_nodes = []
  for node in nodes {
    if scope_index.contains(node.id) {
      continue
    }
    let path = match member_owner.get(node.id) {
      Some(owner) => ancestry(owner)
      None => []
    }
    let properties = match links.get(node.id) {
      Some(link) => Some(@model.RenderProperties::new(link~))
      None => node.properties
    }
    final_nodes.push(
      @model.Node::new(
        node.id,
        label=node.label,
        label_kind=node.label_kind,
        shape=node.shape,
        metadata=node.metadata[:],
        groups=path[:],
        properties?,
        origin?=node.origin,
      ),
    )
  }
  let model = @model.Flowchart::new(
    id,
    final_nodes[:],
    connections[:],
    direction~,
    groups=groups[:],
    title?,
    accessibility_title?,
    accessibility_description?,
  )
  let validation = @model.validate(
    model,
    nodes_limit=limits.nodes,
    edges_limit=limits.edges,
    depth_limit=limits.depth,
    work_limit=(limits.work - work).max(0),
  )
  for finding in validation {
    if diagnostics.length() < limits.diagnostics.max(1) {
      diagnostics.push(finding)
    }
    failed = true
  }
  {
    flowchart: if failed {
      None
    } else {
      Some(model)
    },
    diagnostics: ReadOnlyArray::from_array(diagnostics[:]),
  }
}

///|
pub extend Conversion with Eq::{equal, not_equal}

///|
pub extend Conversion with @debug.Debug::{to_repr}

///|
/// Stable conversion findings. Model validation findings are listed separately
/// by diagrams/flowchart.
pub let diagnostic_codes : ReadOnlyArray[String] = [
  "flowchart/work-limit", "flowchart/node-limit", "flowchart/edge-limit", "flowchart/depth-limit",
  "flowchart/direction", "flowchart/ownership-cycle", "flowchart/ownership", "flowchart/invalid-ast",
  "flowchart/presentation-metadata", "flowchart/syntax",
]