///|
pub struct Conversion {
  state : @model.StateDiagram?
  diagnostics : ReadOnlyArray[@diagrams.Diagnostic]
}

///|
fn role_of(decoration : String, composite : Bool) -> @model.Role {
  let decoration = decoration.to_lower()
  if composite {
    Composite
  } else if decoration.contains("<>") {
    Initial
  } else if decoration.contains("<>") {
    Final
  } else if decoration.contains("<>") {
    Choice
  } else if decoration.contains("<>") {
    Fork
  } else if decoration.contains("<>") {
    Join
  } else if decoration.contains("<>") {
    DeepHistory
  } else if decoration.contains("<>") {
    ShallowHistory
  } else if decoration.contains("<>") {
    EntryPoint
  } else if decoration.contains("<>") {
    ExitPoint
  } else if decoration.contains("<>") {
    InputPin
  } else if decoration.contains("<>") {
    OutputPin
  } else if decoration.contains("<>") {
    ExpansionInput
  } else if decoration.contains("<>") {
    ExpansionOutput
  } else if decoration.contains("<>") {
    Unsupported("sdlreceive")
  } else {
    Simple
  }
}

///|
priv struct Scope {
  owner : String?
  scope_path : String?
  id : String
  separator : @model.Separator
  mut direction : @flowchart.Direction?
}

///|
fn qualified(scope : String?, code : String) -> String {
  if code.contains(".") {
    code
  } else {
    scope.map(s => s + "." + code).unwrap_or(code)
  }
}

///|
/// Build explicit state topology; descriptions and history never execute.
pub fn to_state(
  document : Document,
  id~ : String = "state",
  limits~ : @envelope.Limits = @envelope.Limits::new(),
) -> Conversion {
  let diagnostics : Array[@diagrams.Diagnostic] = []
  if print_body(document, limits~) is Err(ds) {
    return { state: None, diagnostics: ds, }
  }
  let reserved : Map[String, Bool] = Map([])
  for item in document.items {
    match item.kind {
      Declaration(code, _, _, _) | Description(code, _) => reserved[code] = true
      Transition(a, _, b, _) => {
        reserved[a] = true
        reserved[b] = true
      }
      Note(_, _, _, Some(code)) => reserved[code] = true
      _ => ()
    }
  }
  fn fresh(prefix : String) -> String {
    let mut candidate = prefix
    while reserved.contains(candidate) {
      candidate += "_"
    }
    reserved[candidate] = true
    candidate
  }
  let regions : Array[Scope] = [
    {
      owner: None,
      scope_path: None,
      id: fresh("root"),
      separator: Horizontal,
      direction: None,
    },
  ]
  let contexts : Array[Int32] = []
  let stack : Array[Int32] = []
  let declared : Map[String, Int32] = Map([])
  let declaration_roles : Map[String, @model.Role] = Map([])
  let composite_labels : Map[String, String] = Map([])
  let declared_regions : Map[String, Int32] = Map([])
  let composite_regions : Map[String, Int32] = Map([])
  let mut current = 0
  let mut concurrent = 0
  let mut work = 0
  let mut failed = false
  fn error(code : String, text : String, span : @diagrams.Span?) {
    failed = true
    if diagnostics.length() < limits.diagnostics {
      diagnostics.push(@diagrams.Diagnostic::error(code, text, span?))
    }
  }
  for item_index, item in document.items {
    contexts.push(current)
    work += 1
    match item.kind {
      Declaration(code, label, decoration, composite) => {
        let name = qualified(regions[current].scope_path, code)
        declared[name] = item_index
        if composite && item.explicit_label {
          composite_labels[name] = label
        }
        if composite || !declaration_roles.contains(name) {
          declaration_roles[name] = role_of(decoration, composite)
        }
        let parts : Array[String] = []
        for value in name.split(".") {
          if parts.length() >= limits.depth {
            error(
              "state/limit",
              "Qualified state name depth limit exceeded",
              item.span,
            )
            break
          }
          parts.push(value.to_owned())
        }
        if failed {
          break
        }
        work += name.length()
        let mut parent_region = current
        let mut path = ""
        for n = 0; n + 1 < parts.length(); n = n + 1 {
          work += path.length() + parts[n].length() + 1
          if work > limits.work {
            error(
              "state/limit",
              "Qualified parent path work limit exceeded",
              item.span,
            )
            break
          }
          path = if path.is_empty() { parts[n] } else { path + "." + parts[n] }
          declaration_roles[path] = Composite
          if composite_regions.get(path) is Some(existing) {
            parent_region = existing
          } else {
            if regions.length() >= limits.regions || work > limits.work {
              error(
                "state/limit",
                "Region or work limit exceeded before parent expansion",
                item.span,
              )
              break
            }
            let index = regions.length()
            declared_regions[path] = parent_region
            declared[path] = item_index
            composite_regions[path] = index
            regions.push({
              owner: Some(path),
              scope_path: Some(path),
              id: fresh(path + "::region0"),
              separator: Horizontal,
              direction: None,
            })
            parent_region = index
          }
        }
        declared_regions[name] = parent_region
        if composite {
          stack.push(current)
          current = regions.length()
          composite_regions[name] = current
          regions.push({
            owner: Some(name),
            scope_path: Some(name),
            id: fresh(name + "::region0"),
            separator: Horizontal,
            direction: None,
          })
        }
      }
      Close => if !stack.is_empty() { current = stack.pop().unwrap() }
      Separator(vertical) => {
        let scope = regions[current]
        if scope.owner == None {
          error(
            "state/invalid-ast",
            "Concurrent region requires an owning state",
            item.span,
          )
        } else {
          current = regions.length()
          concurrent += 1
          let scope_path = scope.owner.unwrap() +
            ".CONC" +
            concurrent.to_string()
          composite_regions[scope_path] = current
          regions.push({
            owner: scope.owner,
            scope_path: Some(scope_path),
            id: fresh(scope_path + "::region0"),
            separator: if vertical {
              Vertical
            } else {
              Horizontal
            },
            direction: None,
          })
        }
      }
      Direction(direction) => regions[current].direction = Some(direction)
      _ => ()
    }
    if regions.length() > limits.regions ||
      stack.length() > limits.depth ||
      work > limits.work {
      error("state/limit", "Region, nesting or work limit exceeded", item.span)
      break
    }
  }
  if failed {
    return { state: None, diagnostics: ReadOnlyArray::from_array(diagnostics), }
  }
  let states : Array[@model.State] = []
  let transitions : Array[@model.Transition] = []
  let notes : Array[@model.Note] = []
  let known : Map[String, Int32] = Map([])
  let known_regions : Map[String, Int32] = Map([])
  fn resolve(code : String, region : Int32) -> String {
    if code.contains(".") {
      return code
    }
    let mut owner = regions[region].scope_path
    while owner is Some(path) {
      work += path.length() + code.length() + 1
      if work > limits.work {
        error("state/limit", "Name resolution work limit exceeded", None)
        return code
      }
      let candidate = path + "." + code
      if declared.contains(candidate) || known.contains(candidate) {
        return candidate
      }
      let mut at = -1
      for i = 0; i < path.length(); i = i + 1 {
        if path.code_unit_at(i).to_int() == 46 {
          at = i
        }
      }
      owner = if at < 0 { None } else { Some(part(path, 0, at)) }
      work += 1
      if work > limits.work {
        return code
      }
    }
    if declared.contains(code) || known.contains(code) {
      return code
    }
    // PlantUML resolves an unqualified name to a unique already declared child.
    let mut unique : String? = None
    let mut matches = 0
    for name in declared.keys() {
      work += name.length() + code.length() + 1
      if work > limits.work {
        error("state/limit", "Unique name lookup work limit exceeded", None)
        return code
      }
      if name.has_suffix("." + code) {
        unique = Some(name)
        matches += 1
      }
    }
    for name in known.keys() {
      if declared.contains(name) {
        continue
      }
      work += name.length() + code.length() + 1
      if work > limits.work {
        error("state/limit", "Implicit name lookup work limit exceeded", None)
        return code
      }
      if name.has_suffix("." + code) {
        unique = Some(name)
        matches += 1
      }
    }
    if matches == 1 {
      unique.unwrap()
    } else {
      qualified(regions[region].scope_path, code)
    }
  }
  fn ensure(
    name : String,
    region : Int32,
    role : @model.Role,
    origin : @flowchart.SourceBinding,
  ) -> String {
    let actual_region = declared_regions
      .get(name)
      .unwrap_or_else(() => known_regions.get(name).unwrap_or(region))
    if actual_region != region &&
      (
        regions[region].scope_path.unwrap_or("").contains(".CONC") ||
        regions[actual_region].scope_path.unwrap_or("").contains(".CONC")
      ) {
      error(
        "state/refused-scope",
        "The pinned PlantUML profile refuses this cross-region state reference",
        origin.span,
      )
      return name
    }
    if !known.contains(name) {
      if states.length() >= limits.nodes || work >= limits.work {
        error(
          "state/limit",
          "State count or conversion work limit exceeded",
          origin.span,
        )
        return name
      }
      let actual = declared_regions.get(name).unwrap_or(region)
      known[name] = states.length()
      known_regions[name] = actual
      let label = if role == Initial ||
        role == Final ||
        role == ShallowHistory ||
        role == DeepHistory {
        ""
      } else {
        name.split(".").to_array().last().unwrap_or(name[:]).to_owned()
      }
      states.push(
        @model.State::new(
          name,
          label~,
          region=regions[actual].id,
          role~,
          origin~,
        ),
      )
      work += 1
    }
    name
  }
  fn endpoint(
    code : String,
    region : Int32,
    source : Bool,
    origin : @flowchart.SourceBinding,
  ) -> String {
    if code == "[*]" {
      let marker = if source { "*start*" } else { "*end*" }
      let name = match regions[region].scope_path {
        None => marker
        Some(path) =>
          path +
          "." +
          marker +
          path.split(".").to_array().last().unwrap_or(path[:]).to_owned()
      }
      return ensure(name, region, if source { Initial } else { Final }, origin)
    }
    if code.has_suffix("[H]") || code.has_suffix("[H*]") {
      let deep = code.has_suffix("[H*]")
      let prefix = part(code, 0, code.length() - (if deep { 4 } else { 3 }))
      let scope = if prefix.is_empty() {
        region
      } else {
        let name = resolve(prefix, region)
        match composite_regions.get(name) {
          Some(index) => index
          None => {
            error(
              "state/invalid-ast",
              "History target must name a composite state",
              origin.span,
            )
            region
          }
        }
      }
      let marker = if deep { "*deephistory*" } else { "*historical*" }
      let name = match regions[scope].scope_path {
        None => marker
        Some(path) =>
          path +
          "." +
          marker +
          path.split(".").to_array().last().unwrap_or(path[:]).to_owned()
      }
      return ensure(
        name,
        scope,
        if deep {
          DeepHistory
        } else {
          ShallowHistory
        },
        origin,
      )
    }
    if code.has_prefix("==") && code.has_suffix("==") {
      let code = code.trim_start(chars="=").trim_end(chars="=").to_owned()
      let name = resolve(code, region)
      return ensure(name, region, Fork, origin)
    }
    let name = resolve(code, region)
    ensure(name, region, Simple, origin)
  }
  for item_index, item in document.items {
    let region = contexts[item_index]
    let origin = @flowchart.SourceBinding::new(
      "plantuml",
      item_index.to_string(),
      span?=item.span,
    )
    work += 1
    if work > limits.work {
      error("state/limit", "Conversion work limit exceeded", item.span)
      break
    }
    match item.kind {
      Declaration(code, label, decoration, composite) => {
        let name = qualified(regions[region].scope_path, code)
        let role = declaration_roles
          .get(name)
          .unwrap_or(role_of(decoration, composite))
        let parts = name.split(".").map(s => s.to_owned()).to_array()
        let mut path = ""
        for n = 0; n + 1 < parts.length(); n = n + 1 {
          work += path.length() + parts[n].length() + 1
          if work > limits.work {
            error(
              "state/limit",
              "Qualified parent path work limit exceeded",
              item.span,
            )
            break
          }
          path = if path.is_empty() { parts[n] } else { path + "." + parts[n] }
          if !path.contains(".CONC") {
            let _ = ensure(
              path,
              declared_regions.get(path).unwrap_or(region),
              Composite,
              origin,
            )
          }
        }
        let _ = ensure(name, region, role, origin)
        if known.get(name) is Some(index) {
          states[index] = @model.State::new(
            name,
            label=composite_labels.get(name).unwrap_or(label),
            region=states[index].region,
            role~,
            descriptions=states[index].descriptions[:],
            origin~,
          )
        }
        if !decoration.is_empty() && diagnostics.length() < limits.diagnostics {
          diagnostics.push(
            @diagrams.Diagnostic::warning(
              "state/unapplied-presentation",
              "Colors and stereotypes remain author data",
              span?=item.span,
            ),
          )
        }
      }
      Description(code, text) => {
        let name = endpoint(code, region, true, origin)
        if known.get(name) is Some(index) {
          let descriptions = states[index].descriptions.iter().to_array()
          for line in text.replace_all(old="\\n", new="\n").split("\n") {
            descriptions.push(line.to_owned())
          }
          states[index] = states[index].with_descriptions(descriptions[:])
        }
      }
      Transition(from, arrow, target, label) => {
        let reverse = arrow.has_prefix("<") && !arrow.has_suffix(">")
        let a = endpoint(
          if reverse {
            target
          } else {
            from
          },
          region,
          true,
          origin,
        )
        let b = endpoint(
          if reverse {
            from
          } else {
            target
          },
          region,
          false,
          origin,
        )
        if transitions.length() >= limits.edges {
          error("state/limit", "Transition count limit exceeded", item.span)
          break
        }
        let hint = arrow_hint(arrow)
        let style : @flowchart.LineStyle = if arrow.contains("hidden") {
          Invisible
        } else if arrow.contains("dashed") ||
          arrow.contains("dotted") ||
          arrow.contains(".") {
          Dotted
        } else if arrow.contains("bold") {
          Thick
        } else {
          Solid
        }
        transitions.push(
          @model.Transition::new(
            fresh("transition/" + item_index.to_string()),
            a,
            b,
            label=label.replace_all(old="\\n", new="\n"),
            style~,
            source_marker=if arrow.has_prefix("x") {
              Cross
            } else if arrow.has_prefix("<") && arrow.has_suffix(">") {
              Arrow
            } else {
              None
            },
            target_marker=if arrow.has_suffix("o") {
              Circle
            } else if arrow.contains(">") || arrow.contains("<") {
              Arrow
            } else {
              None
            },
            direction_hint?=hint,
            origin~,
          ),
        )
      }
      Note(placement, target, text, code) => {
        let targets = match target {
          Some(target) =>
            if target == "@link" {
              []
            } else {
              [endpoint(target, region, true, origin)]
            }
          None => []
        }
        let connection_targets = if target == Some("@link") {
          match transitions.last() {
            Some(edge) => [edge.id]
            None => {
              error(
                "state/invalid-ast",
                "Link note has no preceding transition",
                item.span,
              )
              []
            }
          }
        } else {
          []
        }
        notes.push(
          @model.Note::new(
            code.unwrap_or_else(() => fresh("note/" + item_index.to_string())),
            text.replace_all(old="\\n", new="\n"),
            targets=targets[:],
            connection_targets=connection_targets[:],
            placement~,
            region=regions[region].id,
            origin~,
          ),
        )
      }
      _ => ()
    }
  }
  if failed {
    return { state: None, diagnostics: ReadOnlyArray::from_array(diagnostics), }
  }
  let members : Map[String, Array[String]] = Map([])
  for state in states {
    members.get_or_init(state.region, () => []).push(state.id)
  }
  let model_regions : Array[@model.Region] = []
  for region in regions {
    model_regions.push(
      @model.Region::new(
        region.id,
        owner?=region.owner,
        members=members.get(region.id).unwrap_or([])[:],
        separator=region.separator,
        direction?=region.direction,
      ),
    )
  }
  let mut title = None
  for statement in document.statements {
    if statement is Title(text) {
      title = Some(text)
    }
  }
  {
    state: Some(
      @model.StateDiagram::new(
        id,
        states[:],
        transitions[:],
        regions=model_regions[:],
        notes=notes[:],
        direction=regions[0].direction.unwrap_or(TB),
        title?,
      ),
    ),
    diagnostics: ReadOnlyArray::from_array(diagnostics),
  }
}