///|
/// The font stack of archify. The SVG has no font data.
let font_stack = "'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, 'DejaVu Sans Mono', 'Liberation Mono', monospace"

///|
/// The CSS custom properties of `p`, one on each line with `indent`.
fn variables(p : @theme.Palette, indent : String) -> String {
  let buf = StringBuilder()
  let put = (name : String, c : @theme.Color) => {
    buf.write_string("\{indent}--\{name}: \{c.to_css()};\n")
  }
  put("bg", p.bg)
  put("text", p.text)
  put("muted", p.muted)
  put("mask", p.mask)
  put("arrow", p.arrow)
  for f in @theme.all_families {
    let c = p.family(f)
    put("\{f.name()}-fill", c.fill)
    put("\{f.name()}-stroke", c.stroke)
    put("\{f.name()}-ink", c.ink)
  }
  buf.to_string()
}

///|
/// The style sheet of the adaptive mode. Dark is the default. The viewer's
/// light preference selects light, and a `data-theme` attribute on the root
/// overrides both.
fn style_sheet(t : @theme.Theme, selector~ : String = "svg") -> String {
  let buf = StringBuilder()
  buf.write_string("\n\{selector} {\n\{variables(t.dark, "  ")}}\n")
  buf.write_string(
    "@media (prefers-color-scheme: light) {\n  \{selector} {\n\{variables(t.light, "    ")}  }\n}\n",
  )
  buf.write_string(
    "\{selector}[data-theme=\"dark\"] {\n\{variables(t.dark, "  ")}}\n",
  )
  buf.write_string(
    "\{selector}[data-theme=\"light\"] {\n\{variables(t.light, "  ")}}\n",
  )
  buf.to_string()
}

///|
/// The palette selected by fixed output.
pub(all) enum Variant {
  Dark
  Light
} derive(Eq, Debug)

///|
/// Adaptive uses CSS variables; fixed writes literal presentation attributes.
pub(all) enum Mode {
  Adaptive
  Fixed(Variant)
} derive(Eq, Debug)

///|
priv struct Paint {
  theme : @theme.Theme
  mode : Mode
}

///|
fn Paint::color(self : Paint, token : String) -> String {
  match self.mode {
    Adaptive => "var(--\{token})"
    Fixed(v) => {
      let p = match v {
        Dark => self.theme.dark
        Light => self.theme.light
      }
      let c = match token {
        "bg" => p.bg
        "mask" => p.mask
        "text" => p.text
        "muted" => p.muted
        "arrow" => p.arrow
        _ => {
          let mut found = p.text
          for f in @theme.all_families {
            let c = p.family(f)
            if token == "\{f.name()}-fill" {
              found = c.fill
            }
            if token == "\{f.name()}-stroke" {
              found = c.stroke
            }
            if token == "\{f.name()}-ink" {
              found = c.ink
            }
          }
          found
        }
      }
      c.to_css()
    }
  }
}

///|
fn Paint::ink(self : Paint, ink : @layout.Ink) -> String {
  match ink {
    Primary => self.color("text")
    Muted => self.color("muted")
    Accent(f) => self.color("\{f.name()}-ink")
    Color(c) => c.to_css()
  }
}

///|
fn rect(
  class : String,
  r : @layout.Rect,
  radius : Int32,
  fill : String,
  stroke? : String,
) -> @writer.Node {
  let attrs = [
    ("class", class),
    ("x", r.x.to_string()),
    ("y", r.y.to_string()),
    ("width", r.width.to_string()),
    ("height", r.height.to_string()),
    ("rx", radius.to_string()),
    ("fill", fill),
  ]
  if stroke is Some(s) {
    attrs.push(("stroke", s))
    attrs.push(("stroke-width", "1.5"))
  }
  @writer.element("rect", attrs~)
}

///|
fn path(
  class : String,
  d : String,
  stroke : String,
  dash? : String,
  width~ : String = "1.4",
) -> @writer.Node {
  let attrs = [
    ("class", class),
    ("d", d),
    ("fill", "none"),
    ("stroke", stroke),
    ("stroke-width", width),
  ]
  if dash is Some(d) {
    attrs.push(("stroke-dasharray", d))
  }
  @writer.element("path", attrs~)
}

///|
fn segment(s : @layout.Segment) -> String {
  "M\{s.x1} \{s.y1} L\{s.x2} \{s.y2}"
}

///|
fn Paint::label(
  self : Paint,
  class : String,
  l : @layout.Label,
) -> @writer.Node {
  let children = l.content
    .split("\n")
    .to_array()
    .mapi((i, line) => {
      @writer.element(
        "tspan",
        attrs=[
          ("x", l.x.to_string()),
          ("y", (l.baseline + i * (l.size + 4)).to_string()),
        ],
        children=[@writer.text(line.to_owned())],
      )
    })
  @writer.element(
    "text",
    attrs=[
      ("class", class),
      ("x", l.x.to_string()),
      ("y", l.baseline.to_string()),
      ("text-anchor", "middle"),
      ("font-family", font_stack),
      ("font-size", l.size.to_string()),
      ("font-weight", l.weight.to_string()),
      ("fill", self.ink(l.ink)),
    ],
    children~,
  )
}

///|
fn metadata(
  attrs : Array[(String, String)],
  link : @sequence.Link?,
  description : String?,
) -> Unit {
  if link is Some(l) {
    let scheme = match l.scheme {
      Smithy => "smithy"
      OpenRpc => "openrpc"
    }
    attrs.push(("data-link", "\{scheme}:\{l.target}"))
  }
  if description is Some(d) {
    attrs.push(("data-description", d))
  }
}

///|
fn head_name(h : @sequence.Head) -> String {
  match h {
    NoHead => "none"
    Arrow => "arrow"
    OpenArrow => "open"
    Cross => "cross"
    Async => "async"
    HalfArrow(upper~, filled~) =>
      "half-\{if upper { "upper" } else { "lower" }}-\{if filled { "filled" } else { "open" }}"
    Central(h) => "central-\{head_name(h)}"
  }
}

///|
/// Markers point along the path; auto-start-reverse turns a sender head back.
fn marker(
  id : String,
  head : @sequence.Head,
  stroke : String,
  reversed : Bool,
) -> @writer.Node {
  let (d, filled) = match head {
    Arrow => ("M-9,-4 L0,0 L-9,4 z", true)
    OpenArrow => ("M-9,-4 L0,0 L-9,4", false)
    Cross => ("M-4,-4 L4,4 M-4,4 L4,-4", false)
    Async => ("M-9,-4 L0,0", false)
    HalfArrow(upper~, filled~) => {
      let upper = if reversed { !upper } else { upper }
      (
        (if upper { "M-9,-4 L0,0" } else { "M-9,4 L0,0" }) +
        (if filled { " L-9,0 z" } else { "" }),
        filled,
      )
    }
    Central(h) => return marker(id, h, stroke, reversed)
    NoHead => ("", false)
  }
  @writer.element(
    "marker",
    attrs=[
      ("id", id),
      ("viewBox", "-10 -5 15 10"),
      ("refX", "0"),
      ("refY", "0"),
      ("markerWidth", "15"),
      ("markerHeight", "10"),
      ("markerUnits", "userSpaceOnUse"),
      ("orient", "auto-start-reverse"),
    ],
    children=[
      @writer.element("path", attrs=[
        ("d", d),
        ("fill", if filled { stroke } else { "none" }),
        ("stroke", stroke),
        ("stroke-width", "1.4"),
      ]),
    ],
  )
}

///|
fn description(s : @sequence.Sequence, m : Int32) -> String {
  let ids = s.participants.map(p => p.id).iter().to_array()
  let n = ids.length()
  let parts = if n == 1 { "1 participant" } else { "\{n} participants" }
  let list = if n > 0 { " (\{ids.join(", ")})" } else { "" }
  let msgs = if m == 1 { "1 message" } else { "\{m} messages" }
  "Sequence diagram with \{parts}\{list} and \{msgs}."
}

///|
fn paint(
  sequence : @sequence.Sequence,
  theme : @theme.Theme,
  mode : Mode,
  id_prefix : String?,
  geometry? : @layout.Geometry,
) -> (String, Array[@diagrams.Diagnostic]) {
  let g = geometry.unwrap_or_else(() => @layout.layout(sequence))
  let p = Paint::{ theme, mode, }
  let diagnostics : Array[@diagrams.Diagnostic] = []
  let defs : Array[@writer.Node] = []
  if mode == Adaptive {
    defs.push(
      @writer.element("style", children=[@writer.text(style_sheet(theme))]),
    )
  }
  let bg = p.color("bg")
  let mask = p.color("mask")
  let arrow = p.color("arrow")
  let backgrounds : Array[@writer.Node] = []
  let foregrounds : Array[@writer.Node] = []
  // A theme-background mask makes every author tint independent of nesting.
  fn band(class : String, box : @layout.Rect, colour : String?) -> Unit {
    backgrounds.push(rect("band-mask", box, 6, bg))
    let fill = match colour {
      Some(raw) =>
        match @theme.parse_color(raw) {
          Some(c) => c.tint().to_css()
          None => {
            diagnostics.push(
              @diagrams.Diagnostic::warning(
                "svg/invalid-colour",
                "Cannot read colour '\{raw}'; using the theme colour.",
              ),
            )
            p.color("external-fill")
          }
        }
      None => p.color("external-fill")
    }
    backgrounds.push(rect(class, box, 6, fill, stroke=arrow))
  }
  for group in g.groups {
    band("box-group", group.box, group.group.colour)
    foregrounds.push(rect("mask", group.chip, 3, mask))
    foregrounds.push(p.label("group-label", group.label))
  }
  for frame in g.frames {
    band("frame", frame.box, frame.colour)
    if frame.chip is Some(c) {
      foregrounds.push(rect("mask", c, 3, mask))
    }
    if frame.header is Some(l) {
      foregrounds.push(p.label("frame-label", l))
    }
    for section in frame.sections {
      if section.divider is Some(s) {
        foregrounds.push(path("section-divider", segment(s), arrow, dash="4 4"))
      }
      if section.chip is Some(c) {
        foregrounds.push(rect("mask", c, 3, mask))
      }
      if section.label is Some(l) {
        foregrounds.push(p.label("section-label", l))
      }
    }
  }
  let lifelines : Array[@writer.Node] = []
  for participant in g.participants {
    lifelines.push(
      path(
        "lifeline",
        "M\{participant.lifeline_x} \{participant.lifeline_top} V\{participant.lifeline_bottom}",
        arrow,
        dash="3 7",
        width="0.8",
      ),
    )
    if participant.destroyed {
      let x = participant.lifeline_x
      let y = participant.lifeline_bottom
      foregrounds.push(
        path(
          "destruction",
          "M\{x - 6} \{y - 6} L\{x + 6} \{y + 6} M\{x - 6} \{y + 6} L\{x + 6} \{y - 6}",
          p.color("security-stroke"),
        ),
      )
    }
  }
  let bars : Array[@writer.Node] = []
  for a in g.activations {
    let mut family : @theme.Family = Backend
    for part in g.participants {
      if part.id == a.participant {
        family = part.family
      }
    }
    bars.push(rect("activation-mask", a.box, 0, mask))
    bars.push(
      rect(
        "activation",
        a.box,
        0,
        p.color("\{family.name()}-fill"),
        stroke=p.color("\{family.name()}-stroke"),
      ),
    )
  }
  let messages : Array[@writer.Node] = []
  for m in g.messages {
    let family = m.family
    let stroke = if m.message.line == Dotted && family != Security {
      arrow
    } else {
      p.color("\{family.name()}-stroke")
    }
    let attrs = [
      ("class", "message f-\{family.name()}"),
      ("data-index", m.index.to_string()),
      ("data-from", m.sender),
      ("data-to", m.receiver),
      ("data-sender-head", head_name(m.message.sender_head)),
      ("data-receiver-head", head_name(m.message.receiver_head)),
    ]
    metadata(
      attrs,
      m.message.link,
      m.message.properties.bind(props => props.description),
    )
    let d = StringBuilder()
    for i, s in m.path {
      if i == 0 {
        d.write_string("M\{s.x1} \{s.y1}")
      }
      d.write_string(" L\{s.x2} \{s.y2}")
    }
    let line_attrs = [
      ("class", "m-line"),
      ("d", d.to_string()),
      ("fill", "none"),
      ("stroke", stroke),
      ("stroke-width", "1.4"),
    ]
    if m.message.line == Dotted {
      line_attrs.push(("stroke-dasharray", "4 4"))
    }
    for
      (end, head) in [
        ("start", m.message.sender_head),
        ("end", m.message.receiver_head),
      ] {
      if head != NoHead && head != Central(NoHead) {
        let id = "head-\{m.index}-\{end}"
        let edge = if end == "start" {
          m.path[0]
        } else {
          m.path[m.path.length() - 1]
        }
        let reversed = if end == "start" {
          edge.x2 > edge.x1
        } else {
          edge.x2 < edge.x1
        }
        defs.push(marker(id, head, stroke, reversed))
        line_attrs.push(("marker-\{end}", "url(#\{id})"))
      }
    }
    let children = [
      @writer.element("path", attrs=line_attrs),
      rect("mask", m.chip, 3, mask),
      p.label("m-label", m.label),
    ]
    for
      (id, head, y) in [
        (m.sender, m.message.sender_head, m.y),
        (m.receiver, m.message.receiver_head, m.path[m.path.length() - 1].y2),
      ] {
      if head is Central(_) {
        for part in g.participants {
          if part.id == id {
            children.push(
              @writer.element("circle", attrs=[
                ("class", "central-connection"),
                ("cx", part.lifeline_x.to_string()),
                ("cy", y.to_string()),
                ("r", "5"),
                ("fill", mask),
                ("stroke", stroke),
                ("stroke-width", "1.4"),
              ]),
            )
          }
        }
      }
    }
    if m.sublabel is Some(l) {
      children.push(p.label("sublabel", l))
    }
    messages.push(@writer.element("g", attrs~, children~))
  }
  let notes : Array[@writer.Node] = []
  for n in g.notes {
    notes.push(rect("note-mask", n.box, 4, mask))
    notes.push(
      rect(
        "note",
        n.box,
        4,
        p.color("external-fill"),
        stroke=p.color("external-stroke"),
      ),
    )
    notes.push(p.label("note-label", n.label))
  }
  let participants : Array[@writer.Node] = []
  for part in g.participants {
    let attrs = [
      ("class", "participant f-\{part.family.name()}"),
      ("data-id", part.id),
    ]
    metadata(
      attrs,
      part.participant.link,
      part.participant.properties.bind(props => props.description),
    )
    let children = [
      rect("mask", part.box, 6, mask),
      rect(
        "box",
        part.box,
        6,
        p.color("\{part.family.name()}-fill"),
        stroke=p.color("\{part.family.name()}-stroke"),
      ),
      p.label("p-label", part.label),
    ]
    if part.sublabel is Some(l) {
      children.push(p.label("sublabel", l))
    }
    participants.push(@writer.element("g", attrs~, children~))
  }
  if g.title_box is Some(b) {
    foregrounds.push(rect("mask", b, 3, mask))
  }
  if g.title is Some(l) {
    foregrounds.push(p.label("diagram-title", l))
  }
  let root = @writer.element(
    "svg",
    attrs=[
      ("xmlns", "http://www.w3.org/2000/svg"),
      ("viewBox", "0 0 \{g.width} \{g.height}"),
      ("width", g.width.to_string()),
      ("height", g.height.to_string()),
      ("role", "img"),
      ("data-diagram", "sequence"),
    ],
    children=[
      @writer.element("title", children=[
        @writer.text(
          sequence.accessibility_title.unwrap_or(
            sequence.title.unwrap_or("Sequence diagram"),
          ),
        ),
      ]),
      @writer.element("desc", children=[
        @writer.text(
          sequence.accessibility_description.unwrap_or(
            description(sequence, g.messages.length()),
          ),
        ),
      ]),
      @writer.element("defs", children=defs),
      rect("bg", { x: 0, y: 0, width: g.width, height: g.height, }, 0, bg),
      @writer.element("g", attrs=[("class", "bands")], children=backgrounds),
      @writer.element("g", attrs=[("class", "lifelines")], children=lifelines),
      @writer.element("g", attrs=[("class", "activations")], children=bars),
      @writer.element("g", attrs=[("class", "messages")], children=messages),
      @writer.element("g", attrs=[("class", "notes")], children=notes),
      @writer.element(
        "g",
        attrs=[("class", "participants")],
        children=participants,
      ),
      @writer.element(
        "g",
        attrs=[("class", "annotations")],
        children=foregrounds,
      ),
    ],
  )
  let palettes = match mode {
    Adaptive => [("dark", theme.dark), ("light", theme.light)]
    Fixed(Dark) => [("dark", theme.dark)]
    Fixed(Light) => [("light", theme.light)]
  }
  for (name, palette) in palettes {
    for f in @layout.check(g, palette) {
      diagnostics.push(
        @diagrams.Diagnostic::warning("svg/legibility", "\{name}: \{f.message}"),
      )
    }
  }
  let prefix = match id_prefix {
    Some(value) => {
      let encoded = StringBuilder()
      for c in value {
        encoded.write_string("\{c.to_int().to_string(radix=16)}-")
      }
      "seq-u-\{encoded.to_string()}"
    }
    None => {
      // FNV-1a over UTF-16 units keeps IDs deterministic on every backend.
      // Hash the complete unscoped output, including both palettes and mode.
      let source = @writer.to_string(root)
      let mut hash : UInt64 = 14695981039346656037
      for i in 0.. String {
  paint(sequence, theme, mode, id_prefix).0
}

///|
/// A renderer that writes one standalone SVG document.
pub struct SvgRenderer {
  theme : @theme.Theme
  mode : Mode
  id_prefix : String?
  bpmn_policy : @bpmn.GeometryPolicy
  bpmn_limits : @bpmn_layout.Limits
  swimlane_limits : @swimlane_layout.Limits
}

///|
pub fn SvgRenderer::new(
  theme~ : @theme.Theme = @theme.Theme::classic(),
  mode~ : Mode = Adaptive,
  id_prefix? : String,
  bpmn_policy~ : @bpmn.GeometryPolicy = PreferDi,
  bpmn_limits~ : @bpmn_layout.Limits = @bpmn_layout.Limits::new(),
  swimlane_limits~ : @swimlane_layout.Limits = @swimlane_layout.Limits::new(),
) -> SvgRenderer {
  { theme, mode, id_prefix, bpmn_policy, bpmn_limits, swimlane_limits, }
}

///|
pub impl @render.Renderer for SvgRenderer with fn render(self, diagram) {
  match diagram {
    Sequence(s) => {
      let (svg, diagnostics) = paint(s, self.theme, self.mode, self.id_prefix)
      @render.Rendered::ok(svg, "image/svg+xml", diagnostics~)
    }
    Bpmn(b) =>
      paint_bpmn(
        b,
        self.theme,
        self.mode,
        self.id_prefix,
        self.bpmn_policy,
        self.bpmn_limits,
      )
    Swimlane(s) =>
      paint_swimlane(
        s,
        self.theme,
        self.mode,
        self.id_prefix,
        self.swimlane_limits,
      )
  }
}

///|
pub extend SvgRenderer with @render.Renderer::{render}

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

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

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

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

///|
/// Rewrite only generated identifiers and fragment references, never user text.
/// Scope the stylesheet too, so adjacent SVGs can have different custom themes.
fn scope_svg(
  node : @writer.Node,
  prefix : String,
  theme : @theme.Theme,
) -> @writer.Node {
  match node {
    Text(_) => node
    Element(name, attributes, children) => {
      let attrs = attributes
        .map(pair => {
          let (key, value) = pair
          let value = if key == "id" {
            prefix + value
          } else if key == "marker-start" || key == "marker-end" {
            "url(#\{prefix}\{value.view(start_offset=5, end_offset=value.length() - 1)})"
          } else {
            value
          }
          (key, value)
        })
        .iter()
        .to_array()
      if name == "svg" {
        attrs.push(("id", prefix + "diagram"))
      }
      let children = if name == "style" {
        [@writer.text(style_sheet(theme, selector="svg#\{prefix}diagram"))]
      } else {
        children.map(c => scope_svg(c, prefix, theme)).iter().to_array()
      }
      @writer.element(name, attrs~, children~)
    }
  }
}