///|
pub struct Conversion { swimlane : @model.Swimlane?; diagnostics : ReadOnlyArray[@diagrams.Diagnostic] } derive(Eq, Debug)
///|
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_swimlane(diagram : @ast.Diagram, limits? : @syntax.Limits = @syntax.Limits::new(), id? : String = "swimlane") -> Conversion {
 let diagnostics=[]; let nodes : Array[@model.Node]=[]; let node_index : Map[String,Int]=Map([]); let scopes : Array[Scope]=[]; let scope_index : Map[String,Int]=Map([]); let scope_order : Array[Int]=[]; let stack : Array[Int]=[]; let member_owner : Map[String,String]=Map([]); let connections : Array[@model.Connection]=[]; let edge_ids : Map[String,Bool]=Map([]); let pair_counts : Map[String,Int]=Map([]); let asset_shapes : 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("swimlane/"+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("swimlane/presentation-metadata",message,span?)) } }
 fn charge(units : Int) -> 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,Int]=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[:])
  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)
    }
   }
  }
 }
 let anonymous : Map[Int,String]=Map([]); let open : Array[Int]=[]; 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="",..) { anonymous[start]="subGraph"+subgraph_count.to_string() }
    subgraph_count+=1
   }
   _ => ()
  }
 }
 while open.pop() is Some(start) {
  if diagram.statements[start].kind is Subgraph(id="",..) { anonymous[start]="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 != 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 }
  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)))
      } }
    }
   }
   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 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 == "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 lanes=[]; let groups=[]
 fn ancestry(id : String) -> (String?,Array[String]) {
  let path=[]; let mut cursor=Some(id); let mut lane : String?=None; 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]; if member_owner.get(scope.id) == None { lane=Some(scope.id); cursor=None } else { 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]) }; (lane,ordered)
 }
 for index in scope_order {
  let scope=scopes[index]; let parent=member_owner.get(scope.id)
  if parent == None { lanes.push(@model.Lane::new(scope.id,label=scope.label,direction?=scope.direction,expanded=scope.expanded)) }
  else {
   let (lane,_)=ancestry(scope.id)
   groups.push(@model.Group::new(scope.id,label=scope.label,parent?=parent.filter(parent => member_owner.get(parent) != None),lane?,direction?=scope.direction,expanded=scope.expanded))
  }
 }
 let final_nodes=[]
 for node in nodes {
  if scope_index.contains(node.id) { continue }
  let (lane,path)=match member_owner.get(node.id) { Some(owner) => ancestry(owner); None => (None,[]) }
  final_nodes.push(@model.Node::new(node.id,label=node.label,label_kind=node.label_kind,shape=node.shape,metadata=node.metadata[:],lane?,groups=path[:],properties?=node.properties))
 }
 let model=@model.Swimlane::new(id,final_nodes[:],connections[:],direction~,lanes=lanes[:],groups=groups[:],title?,accessibility_title?,accessibility_description?)
 let validation=@model.validate(model,nodes_limit=limits.nodes,edges_limit=limits.edges,depth_limit=limits.metadata_depth,work_limit=(limits.work-work).max(0))
 for finding in validation { if diagnostics.length() < limits.diagnostics.max(1) { diagnostics.push(finding) }; failed=true }
 {swimlane: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/swimlane.
pub let diagnostic_codes : ReadOnlyArray[String] = ["swimlane/work-limit","swimlane/node-limit","swimlane/edge-limit","swimlane/depth-limit","swimlane/direction","swimlane/ownership-cycle","swimlane/ownership","swimlane/invalid-ast","swimlane/presentation-metadata"]