// Scenegraph to SVG text, following vega-scenegraph's SVGStringRenderer
// (BSD-3-Clause). Accessibility roles and labels (`role`, `aria-label`,
// `aria-roledescription`) are not emitted; `aria-hidden` is.

///|
fn inner_text(s : String) -> String {
  s
  .replace_all(old="&", new="&")
  .replace_all(old="<", new="<")
  .replace_all(old=">", new=">")
}

///|
fn attr_text(s : String) -> String {
  inner_text(s)
  .replace_all(old="\"", new=""")
  .replace_all(old="\t", new="	")
  .replace_all(old="\n", new="
")
  .replace_all(old="\r", new="
")
}

///|
/// An ordered list of attributes; entries whose value is None are skipped.
priv struct Attrs {
  items : Array[(String, String?)]
}

///|
fn Attrs::new() -> Attrs {
  { items: [], }
}

///|
fn Attrs::emit(self : Attrs, name : String, value : String?) -> Unit {
  // re-emitting a name keeps its original position (JavaScript object semantics)
  for i in 0.. Markup {
  { buf: StringBuilder(), outer: "", inner: "", stack: [], }
}

///|
fn Markup::clear(self : Markup) -> Unit {
  self.outer = ""
  self.inner = ""
}

///|
fn Markup::open(self : Markup, tag : String, attrs : Array[Attrs]) -> Unit {
  if self.outer != "" {
    self.buf.write_string(self.outer + ">" + self.inner)
    self.clear()
  }
  self.stack.push(tag)
  self.outer = "<" + tag
  for set in attrs {
    for pair in set.items {
      match pair.1 {
        Some(v) =>
          self.outer = self.outer + " " + pair.0 + "=\"" + attr_text(v) + "\""
        None => ()
      }
    }
  }
}

///|
fn Markup::close(self : Markup) -> Unit {
  let tag = self.stack.pop().unwrap_or("")
  if self.outer != "" {
    self.buf.write_string(
      self.outer +
      (if self.inner != "" {
        ">" + self.inner + ""
      } else {
        "/>"
      }),
    )
  } else {
    self.buf.write_string("")
  }
  self.clear()
}

///|
/// Write ready-made markup in place of an element (an element the caller drew itself).
fn Markup::raw(self : Markup, markup : String) -> Unit {
  if self.outer != "" {
    self.buf.write_string(self.outer + ">" + self.inner)
    self.clear()
  }
  self.buf.write_string(markup)
}

///|
fn Markup::text(self : Markup, t : String) -> Unit {
  self.inner = self.inner + inner_text(t)
}

// ---- rendering ----

///|
/// What a `Shape` is, i.e. which part of the scenegraph it was made from.
pub(all) enum ShapeKind {
  Rect
  Rule
  Symbol
  Line
  Area
  Arc
  /// the background of a group (the plot frame)
  Background
  /// the stroke of a group drawn above its content
  Foreground
}

///|
/// The outline of a `Shape`.
pub(all) enum Geometry {
  /// an axis-aligned rectangle with square corners: x, y, width, height
  Box(Double, Double, Double, Double)
  /// a straight line: x1, y1, x2, y2
  Segment(Double, Double, Double, Double)
  /// anything else, as SVG path data (symbols are centred on the origin, see `Shape::transform`)
  Path(String)
}

///|
/// A graphic element the renderer is about to write, offered to `SvgOptions::shapes`.
pub(all) struct Shape {
  kind : ShapeKind
  /// the scenegraph item with the style (`fill`, `stroke`, `strokeWidth`, `opacity`, ...)
  item : @scene.Item
  geometry : Geometry
  /// the `transform` of the element (symbols), if it has one
  transform : String?
  /// where the shape is in the scenegraph: the index of the item, of the mark inside its group, ...
  position : Array[Int32]
}

///|
/// Output options for `render_svg`.
pub struct SvgOptions {
  width : Double
  height : Double
  /// translation of the root group (the view origin)
  origin : (Double, Double)
  background : String?
  scale : Double
  /// Draws a shape itself: the markup it returns replaces the element. `None` keeps the
  /// standard element. Text is never offered.
  shapes : ((Shape) -> String?)?
}

///|
pub fn SvgOptions::new(
  width : Double,
  height : Double,
  origin~ : (Double, Double) = (0.0, 0.0),
  background? : String,
  scale~ : Double = 1.0,
  shapes? : (Shape) -> String?,
) -> SvgOptions {
  { width, height, origin, background, scale, shapes, }
}

///|
/// Gradient ids are numbered per rendered document.
let gradient_counter : Ref[Int32] = { val: 0, }

///|
priv struct Defs {
  clips : Array[(String, @scene.Item)]
  mut next_clip : Int32
  gradients : Array[(String, @value.Value)]
  shapes : ((Shape) -> String?)?
  /// position of the item being rendered (see `Shape::position`)
  path : Array[Int32]
}

///|
/// A rect-like item: a box, unless a corner is rounded.
fn box_of(item : @scene.Item, x : Double, y : Double) -> Geometry {
  if item.has_corner_radius() {
    Path(@scene.rect_path(item, x~, y~))
  } else {
    Box(x, y, item.num0("width"), item.num0("height"))
  }
}

///|
/// Let `SvgOptions::shapes` draw a shape; true when it did.
fn Markup::try_shape(
  self : Markup,
  defs : Defs,
  kind : ShapeKind,
  item : @scene.Item,
  geometry : () -> Geometry,
  transform : String?,
) -> Bool {
  match defs.shapes {
    None => false
    Some(draw) =>
      match
        draw({
          kind,
          item,
          geometry: geometry(),
          transform,
          position: defs.path.copy(),
        }) {
        Some(markup) => {
          self.raw(markup)
          true
        }
        None => false
      }
  }
}

///|
fn js_num(x : Double) -> String {
  @util.js_number_to_string(x)
}

///|
fn translate(x : Double, y : Double) -> String {
  "translate(" + js_num(x) + "," + js_num(y) + ")"
}

///|
fn translate_item(item : @scene.Item) -> String {
  translate(item.num0("x"), item.num0("y"))
}

///|
fn rotate_item(item : @scene.Item) -> String {
  translate_item(item) +
  (if item.truthy("angle") {
    " rotate(" + @value.Value::to_js_string(item.get("angle")) + ")"
  } else {
    ""
  })
}

///|
fn css_class(mark : @scene.Mark) -> String {
  "mark-" +
  mark.marktype.name() +
  (match mark.role {
    Some(r) if r != "" => " role-" + r
    _ => ""
  }) +
  (match mark.name {
    Some(n) if n != "" => " " + n
    _ => ""
  })
}

///|
fn tag_of(t : @scene.MarkType) -> String {
  match t {
    Group => "g"
    Rect | Symbol | Line | Area | Arc => "path"
    Rule => "line"
    Text => "text"
  }
}

///|
/// Rectangle offset that keeps thin strokes crisp (vega `group.js` `offset`).
fn group_offset(item : @scene.Item) -> Double {
  let sw = item.num_or("strokeWidth", 1.0)
  match item.opt_num("strokeOffset") {
    Some(o) => o
    None =>
      if item.truthy("stroke") && sw > 0.5 && sw < 1.5 {
        0.5 - (sw - 1.0).abs()
      } else {
        0.0
      }
  }
}

///|
fn opt_str(v : @value.Value) -> String? {
  match v {
    Null | Undef => None
    other => Some(other.to_js_string())
  }
}

///|
/// vega's `style()` helper: presentation attributes of an item. `tag` is the
/// element being rendered; "bgrect" / "bgfore" are group background and
/// foreground paths.
fn style_attrs(
  attrs : Attrs,
  item : @scene.Item,
  interactive : Bool,
  tag : String,
  defs : Defs,
) -> Unit {
  if tag == "bgrect" && !interactive {
    attrs.emit("pointer-events", Some("none"))
  }
  if tag == "bgfore" {
    if !interactive {
      attrs.emit("pointer-events", Some("none"))
    }
    attrs.emit("display", Some("none"))
    if !(item.get("fill") is Null) {
      return
    }
  }
  if tag == "text" {
    attrs.emit(
      "font-family",
      Some(
        match item.get("font") {
          v if v.truthy() => v.to_js_string()
          _ => "sans-serif"
        },
      ),
    )
    attrs.emit("font-size", Some(js_num(@scene.font_size(item)) + "px"))
    attrs.emit("font-style", opt_str(item.get("fontStyle")))
    attrs.emit("font-variant", opt_str(item.get("fontVariant")))
    attrs.emit("font-weight", opt_str(item.get("fontWeight")))
  }
  let table = [
    ("fill", "fill"),
    ("fillOpacity", "fill-opacity"),
    ("stroke", "stroke"),
    ("strokeOpacity", "stroke-opacity"),
    ("strokeWidth", "stroke-width"),
    ("strokeCap", "stroke-linecap"),
    ("strokeJoin", "stroke-linejoin"),
    ("strokeDash", "stroke-dasharray"),
    ("strokeDashOffset", "stroke-dashoffset"),
    ("strokeMiterLimit", "stroke-miterlimit"),
    ("opacity", "opacity"),
  ]
  for pair in table {
    let name = pair.1
    match item.get(pair.0) {
      Null | Undef => ()
      Str("transparent") if name == "fill" || name == "stroke" => ()
      Obj(g) if g.contains("gradient") => {
        let id = "gradient_" + gradient_counter.val.to_string()
        gradient_counter.val = gradient_counter.val + 1
        defs.gradients.push((id, Obj(g)))
        attrs.emit(name, Some("url(#" + id + ")"))
      }
      v => attrs.emit(name, Some(v.to_js_string()))
    }
  }
  match item.get("blend") {
    Null | Undef => ()
    v => attrs.emit("style", Some("mix-blend-mode: " + v.to_js_string() + ";"))
  }
}

///|
fn item_aria(attrs : Attrs, item : @scene.Item) -> Unit {
  attrs.emit(
    "aria-hidden",
    if item.get("aria") is Bool(false) {
      Some("true")
    } else {
      None
    },
  )
}

///|
fn clip_ref(defs : Defs, item : @scene.Item) -> String {
  let id = "clip" + defs.next_clip.to_string()
  defs.next_clip = defs.next_clip + 1
  defs.clips.push((id, item))
  "url(#" + id + ")"
}

///|
fn render_mark(
  m : Markup,
  mark : @scene.Mark,
  parent : @scene.Item?,
  defs : Defs,
) -> Unit raise @scene.SceneError {
  let tag = tag_of(mark.marktype)
  let g_attrs = Attrs::new()
  g_attrs.emit("class", Some(css_class(mark)))
  g_attrs.emit(
    "clip-path",
    if mark.clip {
      match parent {
        Some(p) => Some(clip_ref(defs, p))
        None => None
      }
    } else {
      None
    },
  )
  let aria_attrs = Attrs::new()
  if !mark.aria {
    aria_attrs.emit("aria-hidden", Some("true"))
  }
  let pe = Attrs::new()
  pe.emit(
    "pointer-events",
    if tag != "g" && !mark.interactive {
      Some("none")
    } else {
      None
    },
  )
  m.open("g", [g_attrs, aria_attrs, pe])
  if mark.marktype.is_nested() {
    if mark.items.length() > 0 {
      let item = mark.items[0]
      let a = Attrs::new()
      item_aria(a, item)
      let d = if mark.marktype is Line {
        @scene.line_path(mark.items)
      } else {
        @scene.area_path(mark.items)
      }
      defs.path.push(0)
      let drawn = d != "" &&
        m.try_shape(
          defs,
          if mark.marktype is Line {
            Line
          } else {
            Area
          },
          item,
          fn() { Path(d) },
          None,
        )
      ignore(defs.path.pop())
      if !drawn {
        a.emit("d", if d == "" { None } else { Some(d) })
        style_attrs(a, item, mark.interactive, tag, defs)
        m.open(tag, [a])
        m.close()
      }
    }
  } else {
    for i in 0.. Unit raise @scene.SceneError {
  let drawn = match mark.marktype {
    Rect =>
      m.try_shape(
        defs,
        Rect,
        item,
        fn() { box_of(item, item.num0("x"), item.num0("y")) },
        None,
      )
    Rule =>
      m.try_shape(
        defs,
        Rule,
        item,
        fn() {
          let x = item.num0("x")
          let y = item.num0("y")
          Segment(
            x,
            y,
            match item.opt_num("x2") {
              Some(v) => v
              None => x
            },
            match item.opt_num("y2") {
              Some(v) => v
              None => y
            },
          )
        },
        None,
      )
    Symbol =>
      if defs.shapes is Some(_) {
        let d = @scene.symbol_path(item)
        m.try_shape(
          defs,
          Symbol,
          item,
          fn() { Path(d) },
          Some(rotate_item(item)),
        )
      } else {
        false
      }
    Arc =>
      if defs.shapes is Some(_) {
        let d = @scene.arc_path(item)
        m.try_shape(defs, Arc, item, fn() { Path(d) }, Some(rotate_item(item)))
      } else {
        false
      }
    _ => false
  }
  if drawn {
    return
  }
  let a = Attrs::new()
  item_aria(a, item)
  match mark.marktype {
    Group => a.emit("transform", Some(translate_item(item)))
    Rect => a.emit("d", Some(@scene.rect_path(item)))
    Rule => {
      a.emit("transform", Some(translate_item(item)))
      a.emit(
        "x2",
        Some(
          match item.opt_num("x2") {
            Some(v) => js_num(v - item.num0("x"))
            None => "0"
          },
        ),
      )
      a.emit(
        "y2",
        Some(
          match item.opt_num("y2") {
            Some(v) => js_num(v - item.num0("y"))
            None => "0"
          },
        ),
      )
    }
    Text => {
      let anchor = match item.str("align") {
        Some("center") => "middle"
        Some("right") => "end"
        _ => "start"
      }
      a.emit("text-anchor", Some(anchor))
      let dx = item.num0("dx")
      let dy = item.num0("dy") + @scene.baseline_offset(item)
      let x = item.num0("x")
      let y = item.num0("y")
      let angle = item.num0("angle")
      a.emit(
        "transform",
        Some(
          if angle != 0.0 {
            translate(x, y) +
            " rotate(" +
            js_num(angle) +
            ")" +
            (if dx != 0.0 || dy != 0.0 { " " + translate(dx, dy) } else { "" })
          } else {
            translate(x + dx, y + dy)
          },
        ),
      )
    }
    Symbol => {
      a.emit("transform", Some(rotate_item(item)))
      let d = @scene.symbol_path(item)
      a.emit("d", if d == "" { None } else { Some(d) })
    }
    Arc => {
      a.emit("transform", Some(rotate_item(item)))
      let d = @scene.arc_path(item)
      a.emit("d", if d == "" { None } else { Some(d) })
    }
    _ => ()
  }
  if tag != "g" {
    style_attrs(a, item, mark.interactive, tag, defs)
  }
  m.open(tag, [a])
  match mark.marktype {
    Text =>
      match @scene.text_lines(item) {
        Lines(lines) =>
          for i in 0.. 0 {
              ts.emit("x", Some("0"))
              ts.emit("dy", Some(js_num(@scene.line_height(item))))
            }
            m.open("tspan", [ts])
            m.text(@scene.text_value(item, lines[i]))
            m.close()
          }
        Single(v) => m.text(@scene.text_value(item, v))
      }
    Group => render_group_content(m, mark, item, defs)
    _ => ()
  }
  m.close()
}

///|
fn render_group_content(
  m : Markup,
  mark : @scene.Mark,
  item : @scene.Item,
  defs : Defs,
) -> Unit raise @scene.SceneError {
  let off = group_offset(item)
  let fore = item.truthy("strokeForeground")
  let stroke = item.get("stroke")
  let has_fore_stroke = fore && stroke.truthy()
  // background (without the stroke when it is drawn in the foreground)
  let bg_item = if has_fore_stroke {
    with_prop(item, "stroke", Null)
  } else {
    item
  }
  let frame = fn() { box_of(item, off, off) }
  if !m.try_shape(defs, Background, bg_item, frame, None) {
    let bg = Attrs::new()
    bg.emit("class", Some("background"))
    bg.emit("aria-hidden", Some("true"))
    bg.emit("d", Some(@scene.rect_path(item, x=off, y=off)))
    style_attrs(bg, bg_item, mark.interactive, "bgrect", defs)
    m.open("path", [bg])
    m.close()
  }
  // content
  let content = Attrs::new()
  if item.truthy("clip") {
    content.emit("clip-path", Some(clip_ref(defs, item)))
  }
  m.open("g", [content])
  let children = ordered_marks(item)
  for j in 0.. @scene.Item {
  let props : Map[String, @value.Value] = Map([])
  for k, x in item.props {
    props[k] = x
  }
  props[key] = v
  @scene.Item::new(props~)
}

///|
fn ordered_marks(item : @scene.Item) -> Array[@scene.Mark] {
  let plain : Array[@scene.Mark] = []
  let z : Array[(Int32, Int32, @scene.Mark)] = []
  for i in 0.. z.push((zi, i, c))
      _ => plain.push(c)
    }
  }
  if z.length() == 0 {
    return plain
  }
  // stable insertion sort by zindex
  let sorted : Array[(Int32, Int32, @scene.Mark)] = []
  for e in z {
    let mut pos = sorted.length()
    while pos > 0 && sorted[pos - 1].0 > e.0 {
      pos = pos - 1
    }
    sorted.insert(pos, e)
  }
  for e in sorted {
    plain.push(e.2)
  }
  plain
}

///|
/// Render a scenegraph to an SVG document string.
pub fn render_svg(
  scene : @scene.Mark,
  opts : SvgOptions,
) -> String raise @scene.SceneError {
  let m = Markup::new()
  gradient_counter.val = 0
  let defs : Defs = {
    clips: [],
    next_clip: 1,
    gradients: [],
    shapes: opts.shapes,
    path: [],
  }
  let root = Attrs::new()
  root.emit("xmlns", Some("http://www.w3.org/2000/svg"))
  root.emit("xmlns:xlink", Some("http://www.w3.org/1999/xlink"))
  root.emit("version", Some("1.1"))
  let sized = Attrs::new()
  sized.emit("class", Some("marks"))
  sized.emit("width", Some(js_num(opts.width * opts.scale)))
  sized.emit("height", Some(js_num(opts.height * opts.scale)))
  sized.emit(
    "viewBox",
    Some("0 0 " + js_num(opts.width) + " " + js_num(opts.height)),
  )
  m.open("svg", [root, sized])
  match opts.background {
    Some(bg) if bg != "transparent" && bg != "none" && bg != "" => {
      let r = Attrs::new()
      r.emit("width", Some(js_num(opts.width)))
      r.emit("height", Some(js_num(opts.height)))
      r.emit("fill", Some(bg))
      m.open("rect", [r])
      m.close()
    }
    _ => ()
  }
  let g1 = Attrs::new()
  g1.emit("fill", Some("none"))
  g1.emit("stroke-miterlimit", Some("10"))
  let g2 = Attrs::new()
  g2.emit(
    "transform",
    Some(
      "translate(" + js_num(opts.origin.0) + "," + js_num(opts.origin.1) + ")",
    ),
  )
  m.open("g", [g1, g2])
  render_mark(m, scene, None, defs)
  m.close()
  if defs.clips.length() > 0 || defs.gradients.length() > 0 {
    m.open("defs", [])
    for gd in defs.gradients {
      let ga = Attrs::new()
      ga.emit("id", Some(gd.0))
      let g = match gd.1 {
        Obj(o) => o
        _ => Map([])
      }
      let num_or = fn(k : String, d : Double) {
        match g.get(k) {
          Some(Num(x)) => js_num(x)
          _ => js_num(d)
        }
      }
      ga.emit("x1", Some(num_or("x1", 0.0)))
      ga.emit("x2", Some(num_or("x2", 1.0)))
      ga.emit("y1", Some(num_or("y1", 0.0)))
      ga.emit("y2", Some(num_or("y2", 0.0)))
      m.open("linearGradient", [ga])
      match g.get("stops") {
        Some(Arr(stops)) =>
          for st in stops {
            match st {
              Obj(so) => {
                let sa = Attrs::new()
                sa.emit(
                  "offset",
                  Some(
                    match so.get("offset") {
                      Some(v) => v.to_js_string()
                      None => "0"
                    },
                  ),
                )
                sa.emit(
                  "stop-color",
                  Some(
                    match so.get("color") {
                      Some(v) => v.to_js_string()
                      None => ""
                    },
                  ),
                )
                m.open("stop", [sa])
                m.close()
              }
              _ => ()
            }
          }
        _ => ()
      }
      m.close()
    }
    for c in defs.clips {
      let ca = Attrs::new()
      ca.emit("id", Some(c.0))
      m.open("clipPath", [ca])
      if c.1.has_corner_radius() {
        let pa = Attrs::new()
        pa.emit("d", Some(@scene.rect_path(c.1, x=0.0, y=0.0)))
        m.open("path", [pa])
        m.close()
      } else {
        let ra = Attrs::new()
        ra.emit("x", Some("0"))
        ra.emit("y", Some("0"))
        ra.emit("width", Some(js_num(c.1.num0("width"))))
        ra.emit("height", Some(js_num(c.1.num0("height"))))
        m.open("rect", [ra])
        m.close()
      }
      m.close()
    }
    m.close()
  }
  m.close()
  m.buf.to_string()
}