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

///|
/// Convert explicit state structure without guard evaluation or state execution.
/// Ordinary Mermaid identifiers are global. Initial/final identities are scoped
/// to their owning region. Source order and parallel transition IDs are retained.
pub fn to_state(
  diagram : @ast.Diagram,
  limits~ : @syntax.Limits = @syntax.Limits::new(),
  id~ : String = "state",
) -> Conversion {
  let checked = @printer.print(diagram, limits~)
  if checked.output == None {
    return { state: None, diagnostics: checked.diagnostics, }
  }
  let states : Array[@model.State] = []
  let indices : Map[String, Int32] = Map([])
  let regions : Array[@model.Region] = [@model.Region::new("root")]
  let region_indices : Map[String, Int32] = Map([("root", 0)])
  let transitions : Array[@model.Transition] = []
  let notes : Array[@model.Note] = []
  let diagnostics : Array[@diagrams.Diagnostic] = []
  let owners : Array[String] = []
  let parent_regions : Array[String] = []
  let region_numbers : Array[Int32] = []
  let mut region = "root"
  let mut direction : @flowchart.Direction = TB
  let mut accessibility_title : String? = None
  let mut accessibility_description : String? = None
  let mut title : String? = None
  let mut exhausted = false
  let mut work = 0
  fn finding(
    code : String,
    message : String,
    span : @diagrams.Span,
    warning : Bool,
  ) {
    if diagnostics.length() >= limits.diagnostics {
      exhausted = true
      if !diagnostics.is_empty() {
        diagnostics[diagnostics.length() - 1] = @diagrams.Diagnostic::error(
          "state/diagnostic-limit", "Conversion diagnostic budget exhausted",
        )
      }
      return
    }
    diagnostics.push(
      if warning {
        @diagrams.Diagnostic::warning("state/" + code, message, span~)
      } else {
        @diagrams.Diagnostic::error("state/" + code, message, span~)
      },
    )
  }
  fn ensure(
    name : String,
    role : @model.Role,
    span : @diagrams.Span,
  ) -> String {
    if name == "root" {
      finding(
        "reserved-id", "Pinned Mermaid reserves root and omits it from state topology",
        span, false,
      )
      exhausted = true
      return name
    }
    let name = if name == "[*]" {
      region + (if role == Initial { "::initial" } else { "::final" })
    } else {
      name
    }
    work += name.length() + 1
    if work > limits.work {
      finding("work-limit", "Conversion work budget exhausted", span, false)
      exhausted = true
      return name
    }
    match indices.get(name) {
      Some(index) => {
        let value = states[index]
        // Pinned dataFetcher retains a nested owner when a state is referenced
        // at the root, but a later nested occurrence updates its parent.
        if region != "root" && value.region != region {
          states[index] = @model.State::new(
            value.id,
            label=value.label,
            region~,
            role=value.role,
            descriptions=value.descriptions[:],
            expanded=value.expanded,
            direction?=value.direction,
            origin?=value.origin,
          )
        }
      }
      None => {
        if states.length() >= limits.states {
          finding("state-limit", "State budget exhausted", span, false)
          exhausted = true
          return name
        }
        indices[name] = states.length()
        states.push(
          @model.State::new(
            name,
            region~,
            role~,
            origin=@flowchart.SourceBinding::new("mermaid-state", name, span~),
          ),
        )
      }
    }
    name
  }
  fn describe(name : String, text : String) {
    if indices.get(name) is Some(index) {
      let value = states[index]
      let has_description = value.descriptions.length() > 0 ||
        value.label != value.id
      states[index] = if has_description {
        value.with_descriptions([..value.descriptions, text])
      } else {
        value.with_label(text)
      }
    }
  }
  if diagram.front_matter is Some(front) {
    work += front.length()
    for line in front.split("\n") {
      let text = @syntax.trim(line.to_owned())
      if text.has_prefix("title:") {
        title = Some(@syntax.trim(text[6:].to_owned()))
      }
    }
  }
  for statement in diagram.statements {
    if exhausted {
      break
    }
    work += 1
    if work > limits.work {
      finding(
        "work-limit",
        "Conversion work budget exhausted",
        statement.span,
        false,
      )
      exhausted = true
      break
    }
    match statement.kind {
      State(id~, label~, role~, description~, classes~) => {
        let r : @model.Role = if id == "[*]" {
          Initial
        } else {
          match role {
            Some("choice") => Choice
            Some("fork") => Fork
            Some("join") => Join
            _ => Simple
          }
        }
        let name = ensure(id, r, statement.span)
        if label is Some(text) {
          describe(name, text)
        }
        if description is Some(text) {
          describe(name, text)
        }
        if !classes.is_empty() {
          finding(
            "unapplied-presentation",
            "Inline classes are retained without applying source CSS",
            statement.span,
            true,
          )
        }
      }
      Description(id~, text~) => {
        let name = ensure(id, Simple, statement.span)
        describe(name, text)
      }
      Composite(id~, label~) => {
        let name = ensure(id, Composite, statement.span)
        if indices.get(name) is Some(index) {
          states[index] = @model.State::new(
            states[index].id,
            label=states[index].label,
            region=states[index].region,
            role=Composite,
            descriptions=states[index].descriptions[:],
            expanded=states[index].expanded,
            direction?=states[index].direction,
            origin?=states[index].origin,
          )
        }
        if label is Some(text) {
          describe(name, text)
        }
        owners.push(name)
        parent_regions.push(region)
        region_numbers.push(0)
        region = name + "::region-0"
        if regions.length() >= limits.regions {
          finding(
            "region-limit",
            "Region budget exhausted",
            statement.span,
            false,
          )
          exhausted = true
          break
        }
        if region_indices.contains(region) {
          finding(
            "redeclaration",
            "A composite body may be declared once",
            statement.span,
            false,
          )
          exhausted = true
          break
        }
        region_indices[region] = regions.length()
        regions.push(
          @model.Region::new(
            region,
            owner=name,
            origin=@flowchart.SourceBinding::new(
              "mermaid-state",
              region,
              span=statement.span,
            ),
          ),
        )
      }
      EndComposite => {
        ignore(owners.pop())
        ignore(region_numbers.pop())
        region = parent_regions.pop().unwrap_or("root")
      }
      Concurrent => {
        guard owners.last() is Some(owner) else {
          finding(
            "ownership",
            "Concurrent region requires an owner",
            statement.span,
            false,
          )
          continue
        }
        let last = region_numbers.length() - 1
        region_numbers[last] += 1
        region = owner + "::region-\{region_numbers[last]}"
        if regions.length() >= limits.regions {
          finding(
            "region-limit",
            "Region budget exhausted",
            statement.span,
            false,
          )
          exhausted = true
          break
        }
        region_indices[region] = regions.length()
        regions.push(
          @model.Region::new(
            region,
            owner~,
            origin=@flowchart.SourceBinding::new(
              "mermaid-state",
              region,
              span=statement.span,
            ),
          ),
        )
      }
      Transition(source~, target~, label~, source_classes~, target_classes~) => {
        let source = ensure(
          source,
          if source == "[*]" {
            Initial
          } else {
            Simple
          },
          statement.span,
        )
        let target = ensure(
          target,
          if target == "[*]" {
            Final
          } else {
            Simple
          },
          statement.span,
        )
        if transitions.length() >= limits.transitions {
          finding(
            "transition-limit",
            "Transition budget exhausted",
            statement.span,
            false,
          )
          exhausted = true
          break
        }
        let edge_id = "transition-\{transitions.length()}"
        transitions.push(
          @model.Transition::new(
            edge_id,
            source,
            target,
            label=label.unwrap_or(""),
            origin=@flowchart.SourceBinding::new(
              "mermaid-state",
              edge_id,
              span=statement.span,
            ),
          ),
        )
        if !source_classes.is_empty() || !target_classes.is_empty() {
          finding(
            "unapplied-presentation",
            "Inline classes are retained without applying source CSS",
            statement.span,
            true,
          )
        }
      }
      Note(target~, placement~, text~) => {
        let target = ensure(target, Simple, statement.span)
        let note_id = "note-\{notes.length()}"
        notes.push(
          @model.Note::new(
            note_id,
            text,
            targets=[target],
            placement~,
            region~,
            origin=@flowchart.SourceBinding::new(
              "mermaid-state",
              note_id,
              span=statement.span,
            ),
          ),
        )
      }
      FloatingNote(id~, text~) => {
        // Pinned grammar accepts floating notes but does not add them to the
        // upstream topology. Retain their text for inspection with a finding.
        notes.push(
          @model.Note::new(
            "floating-" + id,
            text,
            placement="floating",
            region~,
            origin=@flowchart.SourceBinding::new(
              "mermaid-state",
              id,
              span=statement.span,
            ),
          ),
        )
        finding(
          "unapplied-presentation",
          "Pinned Mermaid accepts but does not draw floating notes",
          statement.span,
          true,
        )
      }
      Direction(d) => {
        let value = @flowchart.direction(d).unwrap_or(TB)
        if owners.last() is Some(owner) {
          if indices.get(owner) is Some(index) {
            states[index] = @model.State::new(
              states[index].id,
              label=states[index].label,
              region=states[index].region,
              role=states[index].role,
              descriptions=states[index].descriptions[:],
              expanded=states[index].expanded,
              direction=value,
              origin?=states[index].origin,
            )
          }
          let index = region_indices.get(region).unwrap()
          regions[index] = @model.Region::new(
            regions[index].id,
            owner?=regions[index].owner,
            members=regions[index].members[:],
            separator=regions[index].separator,
            direction=value,
            origin?=regions[index].origin,
          )
        } else {
          direction = value
        }
      }
      AccTitle(text) => accessibility_title = Some(text)
      AccDescription(text) => accessibility_description = Some(text)
      Presentation(_) =>
        finding(
          "unapplied-presentation",
          "Source presentation is retained as inert data",
          statement.span,
          true,
        )
      Comment(_) => ()
      Invalid(_) =>
        finding(
          "invalid-ast",
          "Invalid source prevents complete conversion",
          statement.span,
          false,
        )
    }
  }
  for directive in diagram.directives {
    work += directive.length()
  }
  if work > limits.work {
    diagnostics.push(
      @diagrams.Diagnostic::error(
        "state/work-limit", "Conversion work budget exhausted",
      ),
    )
    exhausted = true
  }
  if diagram.front_matter != None || !diagram.directives.is_empty() {
    if diagnostics.length() >= limits.diagnostics {
      exhausted = true
      diagnostics[diagnostics.length() - 1] = @diagrams.Diagnostic::error(
        "state/diagnostic-limit", "Conversion diagnostic budget exhausted",
      )
    } else {
      diagnostics.push(
        @diagrams.Diagnostic::warning(
          "state/unapplied-presentation", "Source configuration is retained and cannot override caller options",
        ),
      )
    }
  }
  if exhausted || diagnostics.iter().any(d => d.severity == Error) {
    return { state: None, diagnostics: ReadOnlyArray::from_array(diagnostics), }
  }
  let members : Map[String, Array[String]] = Map([])
  for value in states {
    let list = members.get_or_init(value.region, () => [])
    list.push(value.id)
  }
  for i in 0..= limits.diagnostics {
      exhausted = true
      diagnostics[diagnostics.length() - 1] = @diagrams.Diagnostic::error(
        "state/diagnostic-limit", "Conversion diagnostic budget exhausted",
      )
      break
    } else {
      diagnostics.push(d)
    }
  }
  {
    state: if exhausted || diagnostics.iter().any(d => d.severity == Error) {
      None
    } else {
      Some(model)
    },
    diagnostics: ReadOnlyArray::from_array(diagnostics),
  }
}

///|
pub let diagnostic_codes : ReadOnlyArray[String] = [
  "state/invalid-ast", "state/state-limit", "state/region-limit", "state/transition-limit",
  "state/work-limit", "state/unapplied-presentation", "state/composite-descriptions",
  "state/redeclaration", "state/ownership", "state/reserved-id", "state/diagnostic-limit",
]