///|
/// 多画板项目:真实原型有多个页面(登录→首页→详情→设置),
/// 画板之间有导航流(Flow),共享设计令牌与组件库。
pub(all) struct ArtboardMeta {
  id : String
  name : String
  width : Double
  height : Double
  mut doc : Document
}

///|
pub(all) struct FlowEdge {
  from : String // artboard id
  to : String
  trigger : String // "tap:login" / "swipe:left" 等
}

///|
pub(all) struct Project {
  mut artboards : Map[String, ArtboardMeta]
  mut flows : Array[FlowEdge]
  mut tokens : DesignTokens
  mut components : Map[String, ComponentDef]
  mut user_components : Map[String, UserComponentDef]
  /// 组件多态状态:node_id → 状态定义(default/pressed/disabled/… 的补丁集)。
  /// 存于节点名标记,会话内可变模型由此重建;作用于渲染前的节点变换。
  mut node_states : Map[String, ComponentStateDef]
  mut revision : Int
  /// 令牌自定义覆盖(set_token 产生):随 canonical MBT frontmatter
  /// `tokens:` 段往返;load 时在主题恢复后叠加应用。
  mut token_overrides : Map[String, String]?
  /// 当前主题名(light/dark/nord/…):随 canonical MBT frontmatter 往返,
  /// 决定渲染层画布底色等 tokens 派生视觉。节点 fill 已是字面量,
  /// 由 apply_theme 的角色重着色负责,加载路径只恢复 tokens 不再重着色。
  mut active_theme : String
  /// 宿主批注透传(issue #20):canonical 扫描时收集的非 moonviz 顶层
  /// HTML 注释。写路径再生 canonical 时原样回写;clone/replace_with 随行。
  mut doc_comments : Array[DocComment]
  /// frontmatter 未知键透传(issue #20 下半场):canonical 扫描时收集的
  /// 非引擎权威键行(如顶层 extra_section / moonviz 下 deepdesign_ink),
  /// 写路径原样回写;clone/replace_with 随行。
  mut extra_front_matter : Array[String]
  /// issue #24:当前生效的 design spec(JSON 文本;空 = 未注入)。
  /// norms 的档位判定以 spec 为准(内置仅补 spec 未覆盖的项)。
  mut design_spec : String
}

///|
/// 文档级宿主批注(issue #20):引擎零语义,原文透传。
/// anchor 为其后最近的画板 id/name(None = 文档级,序列化在文末)。
pub(all) struct DocComment {
  text : String
  anchor : String?
}

///|
pub fn Project::new() -> Project {
  {
    artboards: Map::new(),
    flows: [],
    tokens: DesignTokens::default(),
    components: builtin_components(),
    user_components: Map::new(),
    node_states: Map::new(),
    revision: 1,
    active_theme: "light",
    token_overrides: None,
    doc_comments: [],
    extra_front_matter: [],
    design_spec: "",
  }
}

///|
/// 深拷贝当前项目状态(会话路径的失败回滚用:克隆上应用 op,通过门检
/// 查才 replace_with 回会话,apply 失败不污染会话)。
pub fn Project::clone(self : Project) -> Project {
  // 必须深拷贝:replace_with 是 Map 引用赋值,浅拷贝会与原 Project 共享
  // artboards 等容器——在克隆上的任何 op 会直接改到原体会话。
  let copy = Project::new()
  for k in self.artboards.keys() {
    match self.artboards.get(k) {
      Some(ab) =>
        copy.artboards.set(k, {
          id: ab.id,
          name: ab.name,
          width: ab.width,
          height: ab.height,
          doc: ab.doc.clone(),
        })
      None => ()
    }
  }
  for f in self.flows.iter() {
    copy.flows.push(f)
  }
  copy.tokens = DesignTokens::default()
  for role in self.tokens.colors.keys() {
    match self.tokens.colors.get(role) {
      Some(v) => copy.tokens.colors.set(role, v)
      None => ()
    }
  }
  for k in self.tokens.spacing.keys() {
    match self.tokens.spacing.get(k) {
      Some(v) => copy.tokens.spacing.set(k, v)
      None => ()
    }
  }
  for k in self.tokens.radii.keys() {
    match self.tokens.radii.get(k) {
      Some(v) => copy.tokens.radii.set(k, v)
      None => ()
    }
  }
  for k in self.tokens.typography.keys() {
    match self.tokens.typography.get(k) {
      Some(v) => copy.tokens.typography.set(k, v)
      None => ()
    }
  }
  for cid in self.components.keys() {
    match self.components.get(cid) {
      Some(def) => copy.components.set(cid, def)
      None => ()
    }
  }
  for uid in self.user_components.keys() {
    match self.user_components.get(uid) {
      Some(def) => copy.user_components.set(uid, def)
      None => ()
    }
  }
  for nid in self.node_states.keys() {
    match self.node_states.get(nid) {
      // ComponentStateDef 值对象深拷贝:states Map 与 mut current 若共享,
      // 克隆上的 set-state op 会原地写穿原会话(别名污染)
      Some(def) => {
        let states_copy : Map[String, NodePatch] = Map::new()
        for st in def.states.keys() {
          match def.states.get(st) {
            Some(patch) => states_copy.set(st, patch)
            None => ()
          }
        }
        copy.node_states.set(nid, {
          node_id: def.node_id,
          states: states_copy,
          current: def.current,
        })
      }
      None => ()
    }
  }
  copy.revision = self.revision
  copy.active_theme = self.active_theme
  match self.token_overrides {
    Some(ov) => {
      let ov_copy : Map[String, String] = Map::new()
      for role in ov.keys() {
        match ov.get(role) {
          Some(v) => ov_copy.set(role, v)
          None => ()
        }
      }
      copy.token_overrides = Some(ov_copy)
    }
    None => copy.token_overrides = None
  }
  // 宿主批注随快照深拷贝(issue #20):apply 失败回滚语义下,注释与
  // 文档状态同生共死——克隆上不带注释会让通过门检查的替换悄悄丢批注。
  copy.doc_comments = []
  for cm in self.doc_comments.iter() {
    copy.doc_comments.push({ text: cm.text, anchor: cm.anchor, })
  }
  copy.extra_front_matter = []
  for line in self.extra_front_matter.iter() {
    copy.extra_front_matter.push(line)
  }
  copy.design_spec = self.design_spec
  copy
}

///|
/// 用从唯一 MBT 源码重建的项目快照原子替换临时计算缓存。
///
/// Studio/CLI 不会持久化这个 Project;每次载入都应来自 `.mbt.md`,它只是
/// 当前命令批次内的可变计算模型。
pub fn Project::replace_with(self : Project, source : Project) -> Unit {
  self.artboards = source.artboards
  self.flows = source.flows
  self.tokens = source.tokens
  self.components = source.components
  // 会话级注册表同样必须随快照替换:漏掉它们会让 load 后重建的
  // 组件状态表与用户组件注册表凭空消失(前者是状态多态的行为来源)。
  self.user_components = source.user_components
  self.node_states = source.node_states
  self.revision = source.revision
  self.active_theme = source.active_theme
  self.token_overrides = source.token_overrides
  self.doc_comments = source.doc_comments
  self.extra_front_matter = source.extra_front_matter
  self.design_spec = source.design_spec
}

// ---------------------------------------------------------------------------
// 画板管理
// ---------------------------------------------------------------------------

///|
pub fn Project::create_artboard(
  self : Project,
  name~ : String,
  width? : Double = 390.0,
  height? : Double = 844.0,
) -> String {
  let id = sanitize_id(name)
  let doc = Document::new({ width, height, })
  self.artboards.set(id, { id, name, width, height, doc, })
  self.revision = self.revision + 1
  id
}

///|
/// 在画板上放置组件实例

///|
/// place 后尺寸直设(issue #18):AgentGate 在最终 bbox 上评估,消除
/// 「默认尺寸 place → update 修正」的中间态拒绝(真实 LLM run 111 次
/// 拒绝的主根因)。w/h ≤ 0 表示不覆盖(沿用组件默认尺寸)。
fn apply_place_size(
  doc : Document,
  instance_id : String,
  w : Double,
  h : Double,
) -> Unit {
  if w > 0.0 || h > 0.0 {
    let _ = doc.update_node(instance_id, fn(x) {
      {
        ..x,
        width: if w > 0.0 {
          Fixed(w)
        } else {
          x.width
        },
        height: if h > 0.0 {
          Fixed(h)
        } else {
          x.height
        },
      }
    })
  }
}

///|
pub fn Project::place_component(
  self : Project,
  artboard~ : String,
  component_id~ : String,
  instance_id~ : String,
  variant? : String = "",
  parent_id? : String = "",
  x? : Double = 0.0,
  y? : Double = 0.0,
  w? : Double = 0.0,
  h? : Double = 0.0,
  args? : Map[String, String] = Map::new(),
) -> Result[String, String] {
  match self.artboards.get(artboard) {
    None => Err("unknown_artboard: \{artboard}")
    Some(ab) =>
      // 用户组件优先:自定义组件与内置同权,同名时自定义遮蔽内置
      match self.user_components.get(component_id) {
        Some(udef) =>
          match
            place_user_component(
              ab.doc,
              udef,
              instance_id,
              variant~,
              args~,
              dx=x,
              dy=y,
            ) {
            Err(error) => Err(error)
            Ok(root_id) => {
              apply_place_size(ab.doc, root_id, w, h)
              self.revision = self.revision + 1
              // node 透传实例真实根节点 id(instance::):
              // 调用方据此做 move/update/query(见 usercomp.mbt 的 id 命名契约)。
              Ok(
                "{\"ok\":true,\"op\":\"place\",\"node\":\"\{root_id}\",\"instance\":\"\{instance_id}\",\"component\":\"\{component_id}\",\"source\":\"user\",\"artboard\":\"\{artboard}\"}",
              )
            }
          }
        None =>
          match self.components.get(component_id) {
            None => Err("unknown_component: \{component_id}")
            Some(def) => {
              // 未知变体与用户组件路径同权报错(usercomp.mbt 同名检查):
              // 拼写错误不再被静默回退吞掉
              let v = if variant == "" { def.default_variant } else { variant }
              if v != def.default_variant && def.variants.contains(v) == false {
                // 错误即文档(对齐 constrain 词表先例):真实 run 里模型最常犯的是
                // 漏写 variant 槽——坐标串滑进变体槽(如 `place ab rect id 200 100`
                // 的 "200" 被当变体名),只回 `unknown_variant:200` 模型要再烧
                // 2-3 轮试错才摸到语法。消息就地携带合法变体与语法模板。
                let mut names = ""
                for k in def.variants.keys() {
                  names = names + k + " "
                }
                return Err(
                  "unknown_variant:\{v}(组件 \{component_id} 合法变体:\{names}默认:\{def.default_variant})——place 语法:place <板> <组件>  [variant|-] [x] [y] [w] [h] [k=v...],不需要变体时该槽写 -",
                )
              }
              let node = instantiate_component(
                def,
                instance_id,
                variant~,
                tokens=self.tokens,
              )
              // 统一边界策略:负坐标夹回 0;右上不夹紧——按默认尺寸夹紧会在
              // 先 place 后 resize 的流程里留下被夹坏的坐标(模板 2×2 网格因此
              // 重叠)。越界由 contained_in_parent 谓词显式报告为视觉债。
              let safe_x = if x < 0.0 { 0.0 } else { x }
              let safe_y = if y < 0.0 { 0.0 } else { y }
              let placed = {
                ..node,
                x: AlignStart(safe_x),
                y: AlignStart(safe_y),
              }
              let pid = if parent_id == "" { "root" } else { parent_id }
              match pid {
                "root" => {
                  let _ = ab.doc.insert_node(placed, "root")
                  apply_place_size(ab.doc, instance_id, w, h)
                  self.revision = self.revision + 1
                  Ok(
                    "{\"ok\":true,\"op\":\"place\",\"node\":\"\{instance_id}\",\"x\":\{safe_x},\"y\":\{safe_y},\"artboard\":\"\{artboard}\"}",
                  )
                }
                _ =>
                  match ab.doc.insert_node(placed, pid) {
                    true => {
                      apply_place_size(ab.doc, instance_id, w, h)
                      self.revision = self.revision + 1
                      Ok(
                        "{\"ok\":true,\"op\":\"place\",\"node\":\"\{instance_id}\",\"x\":\{safe_x},\"y\":\{safe_y},\"parent\":\"\{pid}\"}",
                      )
                    }
                    false => Err("unknown_parent: \{pid}")
                  }
              }
            }
          }
      }
  }
}

///|
fn size_value(spec : SizeSpec, fallback : Double) -> Double {
  match spec {
    Fixed(v) => v
    _ => fallback
  }
}

///|
/// 删除画板:至少保留一个画板;同时清理引用该画板的交互流。
/// 节点随画板 doc 整体移除,无需逐个 delete。
pub fn Project::delete_artboard(
  self : Project,
  id~ : String,
) -> Result[String, String] {
  if self.artboards.size() <= 1 {
    return Err("cannot_delete_last_artboard")
  }
  match self.artboards.get(id) {
    None => Err("unknown_artboard: \{id}")
    Some(_) => {
      self.artboards.remove(id)
      let kept : Array[FlowEdge] = self.flows.filter(f => {
        f.from != id && f.to != id
      })
      let removed_flows = self.flows.length() - kept.length()
      self.flows = kept
      self.revision = self.revision + 1
      Ok(
        "{\"ok\":true,\"op\":\"delete_artboard\",\"artboard\":\"\{id}\",\"flows_removed\":\{removed_flows}}",
      )
    }
  }
}

///|
fn max_coord(v : Double) -> Double {
  if v < 0.0 {
    0.0
  } else {
    v
  }
}

///|
fn clamp_coord(v : Double, lo : Double, hi : Double) -> Double {
  if v < lo {
    lo
  } else if v > hi {
    hi
  } else {
    v
  }
}

// ---------------------------------------------------------------------------
// 导航流
// ---------------------------------------------------------------------------

///|
pub fn Project::add_flow(
  self : Project,
  from~ : String,
  to~ : String,
  trigger? : String = "tap",
) -> Unit {
  // 审查修复:canonical 含 flows 段——变更必须推进 revision 以失效
  // 会话级 canon_cache(否则新导航边不出现在 canonical 里)
  self.flows.push({ from, to, trigger, })
  self.revision = self.revision + 1
}

///|

///|
/// 删除单条导航边(issue #12):画错一条边不再需要整板删除重建。
/// trigger 写法与 add_flow 的调用约定对齐("tap:")。
pub fn Project::remove_flow(
  self : Project,
  from~ : String,
  to~ : String,
  trigger~ : String,
) -> Result[Unit, String] {
  let before = self.flows.length()
  self.flows = self.flows.filter(fn(f) {
    !(f.from == from && f.to == to && f.trigger == trigger)
  })
  if self.flows.length() == before {
    Err("unknown_flow: \{from} -> \{to} (\{trigger})")
  } else {
    self.revision = self.revision + 1
    Ok(())
  }
}

///|
pub fn Project::flow_json(self : Project) -> String {
  let parts : Array[String] = []
  for e in self.flows.iter() {
    parts.push(
      "{\"from\":\"\{e.from}\",\"to\":\"\{e.to}\",\"trigger\":\"\{e.trigger}\"}",
    )
  }
  let j = join_str4(parts)
  "[\{j}]"
}

///|
fn join_str4(parts : Array[String]) -> String {
  let sb = StringBuilder::new()
  for i in 0.. 0 {
      sb.write_string(",")
    }
    sb.write_string(parts[i])
  }
  sb.to_string()
}

// ---------------------------------------------------------------------------
// 查询
// ---------------------------------------------------------------------------

///|
pub fn Project::list_artboards_json(self : Project) -> String {
  let parts : Array[String] = []
  for id in self.artboards.keys() {
    match self.artboards.get(id) {
      Some(ab) => {
        let nv = ab.doc.nodes.size()
        parts.push(
          "{\"id\":\"\{ab.id}\",\"name\":\"\{ab.name}\",\"width\":\{ab.width},\"height\":\{ab.height},\"nodes\":\{nv}}",
        )
      }
      None => ()
    }
  }
  let j = join_str4(parts)
  "[\{j}]"
}

///|
/// 按条件查询画板中的节点
pub fn Project::query_nodes(
  self : Project,
  artboard~ : String,
  kind? : String = "",
  component? : String = "",
  text_contains? : String = "",
) -> Result[String, String] {
  match self.artboards.get(artboard) {
    None => Err("unknown_artboard: \{artboard}")
    Some(ab) => {
      let results : Array[String] = []
      let layout = solve(ab.doc)
      for id in ab.doc.nodes.keys() {
        match ab.doc.nodes.get(id) {
          Some(n) => {
            let kind_match = kind == "" || kind_of(n) == kind
            let comp_match = component == "" || n.component == component
            let text_match = text_contains == "" ||
              n.text.contains(text_contains)
            if kind_match && comp_match && text_match && id != "root" {
              match layout.rect_of(id) {
                Some(r) => {
                  // parent 字段(issue #13):图层树的父子关系此前在 wasm
                  // 各读面均不可达;root 顶层节点的 parent 即 "root"
                  let parent = match ab.doc.find_parent(id) {
                    Some(p) => p
                    None => ""
                  }
                  results.push(query_node_json(id, n, r, parent))
                }
                None => ()
              }
            }
          }
          None => ()
        }
      }
      Ok("[\{join_str4(results)}]")
    }
  }
}

///|
/// 单个节点的查询 JSON。
/// 必须与 NodeStyle / Node 的完整字段保持同步:Studio 属性面板、
/// 图层名与布局控件都从这里取值;漏字段会让引擎已支持的能力在界面上不可达。
fn query_node_json(id : String, n : Node, r : Rect, parent : String) -> String {
  let layout_json = match n.layout {
    Some(l) =>
      "{\"dir\":\"" +
      (match l.dir {
        Vertical => "vertical"
        Horizontal => "horizontal"
      }) +
      "\",\"gap\":" +
      l.gap.to_string() +
      ",\"justify\":\"" +
      (match l.justify {
        Start => "start"
        Center => "center"
        End => "end"
      }) +
      "\",\"padding\":" +
      l.padding.top.to_string() +
      "}"
    None => "null"
  }
  let out = StringBuilder::new()
  out.write_string("{\"id\":\"" + id + "\",")
  out.write_string("\"parent\":\"" + json_escape_field(parent) + "\",")
  out.write_string("\"kind\":\"" + kind_of(n) + "\",")
  out.write_string("\"component\":\"" + json_escape_field(n.component) + "\",")
  out.write_string("\"name\":\"" + json_escape_field(n.name) + "\",")
  out.write_string("\"text\":\"" + json_escape_field(n.text) + "\",")
  out.write_string(
    "\"rect\":{\"x\":" +
    r.x.to_string() +
    ",\"y\":" +
    r.y.to_string() +
    ",\"w\":" +
    r.width.to_string() +
    ",\"h\":" +
    r.height.to_string() +
    "},",
  )
  out.write_string("\"constraint\":\"" + n.constraint + "\",")
  out.write_string("\"layout\":" + layout_json + ",")
  out.write_string("\"width_mode\":\"" + size_mode(n.width) + "\",")
  out.write_string("\"height_mode\":\"" + size_mode(n.height) + "\",")
  out.write_string("\"style\":{")
  out.write_string("\"fill\":\"" + n.style.fill + "\",")
  out.write_string("\"text_color\":\"" + n.style.text_color + "\",")
  out.write_string("\"stroke\":\"" + n.style.stroke + "\",")
  out.write_string("\"stroke_width\":" + n.style.stroke_width.to_string() + ",")
  out.write_string("\"radius\":" + n.style.corner_radius.to_string() + ",")
  out.write_string("\"opacity\":" + n.style.opacity.to_string() + ",")
  out.write_string("\"font_size\":" + n.style.font_size.to_string() + ",")
  out.write_string("\"weight\":\"" + n.style.font_weight + "\",")
  out.write_string("\"shadow\":\"" + n.style.shadow + "\",")
  out.write_string("\"rotate\":" + n.style.rotate.to_string() + ",")
  out.write_string("\"blur\":" + n.style.blur.to_string() + ",")
  out.write_string("\"blend\":\"" + n.style.blend + "\",")
  out.write_string("\"line\":" + n.style.line_height.to_string() + ",")
  out.write_string("\"tracking\":" + n.style.tracking.to_string() + ",")
  out.write_string("\"align\":\"" + n.style.text_align + "\",")
  out.write_string("\"italic\":\"" + n.style.font_style + "\",")
  out.write_string("\"dash\":\"" + n.style.stroke_dash + "\",")
  out.write_string(
    "\"visible\":" + (if n.style.visible { "true" } else { "false" }),
  )
  out.write_string("}}")
  out.to_string()
}

///|
/// 尺寸规格 → 模式名(供属性面板回显)
fn size_mode(s : SizeSpec) -> String {
  match s {
    Fixed(_) => "fixed"
    Fill => "fill"
    Hug => "hug"
  }
}

///|
fn is_frame_kind(n : Node) -> Bool {
  match n.kind {
    Frame => true
    _ => false
  }
}

///|
fn is_rect_kind(n : Node) -> Bool {
  match n.kind {
    Rect => true
    _ => false
  }
}

///|
fn is_text_kind(n : Node) -> Bool {
  match n.kind {
    Text => true
    _ => false
  }
}

///|
fn kind_of(n : Node) -> String {
  match n.kind {
    Frame => "frame"
    Group => "group"
    Rect => "rect"
    Text => "text"
    Image => "image"
  }
}

///|
/// JSON 字符串字段转义(query 输出用)
fn json_escape_field(s : String) -> String {
  let sb = StringBuilder::new()
  for c in s.iter() {
    match c {
      '"' => sb.write_string("\\\"")
      '\\' => sb.write_string("\\\\")
      '\n' => sb.write_string("\\n")
      '\t' => sb.write_string("\\t")
      _ => sb.write_char(c)
    }
  }
  sb.to_string()
}

// ---------------------------------------------------------------------------
// 设计 Lint(超越不崩谓词的质量检查)
// ---------------------------------------------------------------------------

///|
pub(all) struct DesignLint {
  rule : String
  severity : String // "error" | "warning" | "info"
  node_id : String
  message : String
  suggestion : String
}

///|
/// 设计质量检查:对比度/触控目标/间距/一致性
pub fn Project::lint(
  self : Project,
  artboard~ : String,
) -> Result[String, String] {
  match self.artboards.get(artboard) {
    None => Err("unknown_artboard: \{artboard}")
    Some(ab) => {
      let layout = solve(ab.doc)
      let lints : Array[String] = []
      for id in ab.doc.nodes.keys() {
        match ab.doc.nodes.get(id) {
          Some(n) => {
            if id == "root" {
              continue
            }
            match layout.rect_of(id) {
              Some(r) => {
                // L1: 触控目标(最小 44×44)
                if r.width < 44.0 || r.height < 44.0 {
                  if n.component == "button" ||
                    n.component == "text_input" ||
                    is_rect_kind(n) {
                    lints.push(
                      lint_json(
                        "touch_target",
                        "warning",
                        id,
                        "触控目标 \{r.width.to_int()}×\{r.height.to_int()} 小于 44×44",
                        "增大到至少 44×44 以满足无障碍标准",
                      ),
                    )
                  }
                }
                // L2: 文字对比度
                if is_text_kind(n) || n.text != "" {
                  let fg = n.style.text_color
                  let bg = if n.style.fill != "none" && n.style.fill != "" {
                    n.style.fill
                  } else {
                    "#FFFFFF"
                  }
                  let ratio = contrast_ratio(fg, bg)
                  if ratio < 4.5 {
                    lints.push(
                      lint_json(
                        "contrast",
                        "error",
                        id,
                        "文字对比度 \{ratio.to_string()} 低于 WCAG AA 4.5:1",
                        "调整 text_color 或背景色以提高对比度",
                      ),
                    )
                  }
                }
                // L3: 间距一致性(子节点间距应统一)
                if is_frame_kind(n) && n.children.length() > 1 {
                  match n.layout {
                    Some(l) =>
                      if l.gap < 4.0 && n.children.length() > 2 {
                        lints.push(
                          lint_json(
                            "spacing",
                            "info",
                            id,
                            "容器内 \{n.children.length()} 个子元素间距仅 \{l.gap},可能过于紧凑",
                            "建议间距 ≥ 8(令牌 md)",
                          ),
                        )
                      }
                    None => ()
                  }
                }
                // L4: 空容器
                if is_frame_kind(n) &&
                  n.children.length() == 0 &&
                  n.component != "divider" {
                  lints.push(
                    lint_json(
                      "empty_container",
                      "warning",
                      id,
                      "容器「\{n.name}」无子元素",
                      "添加内容或删除空容器",
                    ),
                  )
                }
              }
              None => ()
            }
          }
          None => ()
        }
      }
      Ok("[\{join_str4(lints)}]")
    }
  }
}

///|
fn lint_json(
  rule : String,
  severity : String,
  node_id : String,
  msg : String,
  suggestion : String,
) -> String {
  "{\"rule\":\"\{rule}\",\"severity\":\"\{severity}\",\"node_id\":\"\{node_id}\",\"message\":\"\{msg}\",\"suggestion\":\"\{suggestion}\"}"
}

///|
/// WCAG 相对亮度
fn relative_luminance(hex : String) -> Double {
  let r = hex_val(hex, 0) / 255.0
  let g = hex_val(hex, 2) / 255.0
  let b = hex_val(hex, 4) / 255.0
  let f = fn(c : Double) -> Double {
    if c <= 0.03928 {
      c / 12.92
    } else {
      Double::pow((c + 0.055) / 1.055, 2.4)
    }
  }
  0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b)
}

///|
fn hex_val(hex : String, offset : Int) -> Double {
  let body = if hex.length() > 0 && hex.get_char(0) == Some('#') {
    hex.substring(start=1)
  } else {
    hex
  }
  if body.length() < offset + 2 {
    return 128.0
  }
  let hi = hex_digit_val(body.get_char(offset))
  let lo = hex_digit_val(body.get_char(offset + 1))
  (hi * 16 + lo).to_double()
}

///|
fn hex_digit_val(c : Char?) -> Int {
  match c {
    Some(ch) => {
      let code = ch.to_int()
      if code >= 48 && code <= 57 {
        code - 48
      } else if code >= 97 && code <= 102 {
        code - 87
      } else if code >= 65 && code <= 70 {
        code - 55
      } else {
        8
      }
    }
    None => 8
  }
}

///|
/// WCAG 对比度
pub fn contrast_ratio(fg : String, bg : String) -> Double {
  let l1 = relative_luminance(fg)
  let l2 = relative_luminance(bg)
  let lighter = if l1 > l2 { l1 } else { l2 }
  let darker = if l1 > l2 { l2 } else { l1 }
  (lighter + 0.05) / (darker + 0.05)
}

// ---------------------------------------------------------------------------
// 修复建议
// ---------------------------------------------------------------------------

///|
/// 针对不崩谓词违规给出可执行的修复补丁
pub fn Project::suggest_fix(
  self : Project,
  artboard~ : String,
  node_id~ : String,
) -> Result[String, String] {
  match self.artboards.get(artboard) {
    None => Err("unknown_artboard")
    Some(ab) => {
      let violations = validate(ab.doc)
      let targeted : Array[String] = []
      for v in violations.iter() {
        if v.node_id == node_id {
          let suggestion : String = match v.predicate {
            ContainedInParent =>
              "缩小 width/height 或改用 Fill(自适应父容器),或增大父容器尺寸"
            NoSiblingOverlap =>
              "调整 x/y 位置避免与兄弟重叠,或改用栈布局自动排列"
            ConstraintSolvable =>
              "检查尺寸规格组合(Fill 需有界父容器),简化为 Fixed"
            _ => "检查节点结构"
          }
          targeted.push(
            "{\"predicate\":\"\{v.predicate.name()}\",\"node_id\":\"\{v.node_id}\",\"detail\":\"\{v.detail}\",\"fix\":\"\{suggestion}\"}",
          )
        }
      }
      if targeted.length() == 0 {
        Ok("{\"ok\":true,\"message\":\"节点 \{node_id} 无需修复\"}")
      } else {
        Ok("{\"ok\":false,\"fixes\":[\{join_str4(targeted)}]}")
      }
    }
  }
}

// ---------------------------------------------------------------------------
// 项目导出
// ---------------------------------------------------------------------------

///|
pub fn Project::export_svg(
  self : Project,
  artboard~ : String,
) -> Result[String, String] {
  match self.artboards.get(artboard) {
    None => Err("unknown_artboard")
    Some(ab) =>
      Ok(
        render_svg(
          ab.doc,
          surface=self.tokens.resolve_color("surface"),
          frame=self.tokens.resolve_color("outline"),
        ),
      )
  }
}

///|
fn sanitize_id(name : String) -> String {
  let sb = StringBuilder::new()
  for c in name.iter() {
    let code = c.to_int()
    if (code >= 97 && code <= 122) || (code >= 48 && code <= 57) || code == 95 {
      sb.write_char(c)
    } else if code >= 65 && code <= 90 {
      sb.write_char(Char::from_int(code + 32))
    } else {
      sb.write_char('_')
    }
  }
  sb.to_string()
}