///|
/// Generated SVG normalization; qualification lives in tests/raster-spike.
fn xml_escape(s : String) -> String {
  s
  .replace(old="&", new="&")
  .replace(old="\"", new=""")
  .replace(old="<", new="<")
  .replace(old=">", new=">")
}

///|
fn node_attr(node : SvgXmlNode, name : String) -> String? {
  for a in node.attributes {
    if a.raw_name == name {
      return Some(a.value)
    }
  }
  None
}

///|
fn num(s : String) -> Double raise RasterFailure {
  let value = @string.parse_double(s) catch {
    _ => raise raster_unsupported("invalid generated numeric attribute: " + s)
  }
  guard raster_finite(value) else {
    raise raster_unsupported("generated numeric attributes must be finite")
  }
  value
}

///|
/// Generated stylesheet rules only set namespaced palette variables. The
/// explicit data-theme block is authoritative, independent of ambient media.
fn palette(source : String, theme : String) -> Map[String, String] {
  let result : Map[String, String] = Map([])
  let wanted = "[data-theme=\"" + theme + "\"]"
  for block in source.split("}") {
    let pieces = block.split("{").to_array()
    if pieces.length() < 2 {
      continue
    }
    let header = pieces[pieces.length() - 2].to_owned()
    if !header.contains(wanted) {
      continue
    }
    for decl in pieces[pieces.length() - 1].split(";") {
      let pair = decl.split(":").to_array()
      if pair.length() == 2 {
        let key = pair[0].trim().to_owned()
        if key.has_prefix("--") {
          result[key] = pair[1].trim().to_owned()
        }
      }
    }
  }
  result
}

///|
fn resolve(
  value : String,
  vars : Map[String, String],
) -> String raise RasterFailure {
  let mut result = value
  for key, color in vars {
    result = result.replace(old="var(" + key + ")", new=color)
  }
  if result.contains("var(") {
    raise raster_unsupported("unresolved generated palette: " + value)
  }
  result
}

///|
fn text_paths(
  text : String,
  x : Double,
  y : Double,
  size : Double,
  anchor : String,
  face : ParsedFontFace,
  fonts : ParsedFontSet,
  source_id : String,
  limits : RasterLimits,
) -> String raise RasterFailure {
  let shaped : Array[(ParsedFontFace, Int32, Double, Double)] = []
  let mut width = 0.0
  let mut previous : ParsedFontFace? = None
  let mut previous_gid = 0
  for scalar in text.iter() {
    let cp = scalar.to_int()
    guard !((cp >= 0x300 && cp <= 0x36f) ||
      (cp >= 0x590 && cp <= 0x8ff) ||
      (cp >= 0x900 && cp <= 0xdff)) else {
      raise raster_fail(
        "raster/font-shaping",
        "This text needs shaping outside the explicit static glyph profile",
        source_id~,
      )
    }
    let selected = glyph_face(fonts, face, cp, source_id)
    let gid = selected.font.glyph_index(cp)
    let advance = selected.font.glyph_metrics(gid).advance_width.to_double() *
      size /
      selected.font.units_per_em.to_double()
    let kern = match previous {
      Some(before) =>
        if before.face.data == selected.face.data {
          selected.font.kern_pair(previous_gid, gid).to_double() *
          size /
          selected.font.units_per_em.to_double()
        } else {
          0.0
        }
      None => 0.0
    }
    shaped.push((selected, cp, advance, kern))
    width += advance + kern
    previous = Some(selected)
    previous_gid = gid
  }
  let shift = match anchor {
    "middle" => width / 2.0
    "end" => width
    "start" => 0.0
    _ =>
      raise raster_fail(
        "raster/text-anchor",
        "Unsupported text anchor",
        source_id~,
      )
  }
  let output = SvgWriter::new(limits.max_source_units)
  let mut cursor = x - shift
  let mut units = 0
  for (selected, cp, advance, kern) in shaped {
    cursor += kern
    let d = @font.path_commands_to_svg_d(selected.font.scaled_outline(cp, size))
    if d != "" {
      let part = ""
      guard part.length() <= limits.max_source_units - units else {
        raise raster_fail(
          "raster/normalized-limit",
          "Outlined text exceeds the derived SVG budget",
          source_id~,
        )
      }
      units += part.length()
      output.write_string(part)
    }
    cursor += advance
  }
  output.to_string()
}

///|
fn normalize_generated_svg(
  source : String,
  theme : String,
  fonts : ParsedFontSet,
  limits : RasterLimits,
) -> String raise RasterFailure {
  guard theme == "light" || theme == "dark" else {
    raise raster_unsupported("explicit theme required")
  }
  guard source.length() <= 16_777_216 else {
    raise raster_unsupported("source budget")
  }
  let nodes = parse_svg_xml(source, max_nodes=limits.max_svg_nodes)
  // Reject the aggregate before any font lookup, layout or outline allocation.
  validate_text_glyph_budget(nodes, limits)
  let roots = nodes[0].children.filter(i => nodes[i].kind == Element)
  let backgrounds = nodes[roots[0]].children.filter(i => {
    is_export_background(nodes[i], nodes[roots[0]])
  })
  let sheets = StringBuilder()
  for node in nodes {
    if node.raw_name == "style" {
      for child in node.children {
        let css = nodes[child].value
        for block in css.split("}") {
          let pieces = block.split("{").to_array()
          if pieces.length() < 2 {
            continue
          }
          for decl in pieces[pieces.length() - 1].split(";") {
            let pair = decl.split(":").to_array()
            if pair.length() == 2 && !pair[0].trim().has_prefix("--") {
              raise raster_fail(
                "raster/css-profile", "Only generated palette variables are supported in stylesheet normalization",
              )
            }
          }
        }
        sheets.write_string(css)
      }
    }
  }
  let vars = palette(sheets.to_string(), theme)
  let filters : Map[String, Int32] = Map([])
  for i, node in nodes {
    if node.raw_name == "filter" {
      if node_attr(node, "id") is Some(id) {
        filters[id] = i
      }
    }
  }
  let out = SvgWriter::new(limits.max_source_units)
  let mut emitted_units = 0
  fn walk(
    index : Int32,
    inherited_size : Double,
    inherited_anchor : String,
    inherited_weight : String,
    inherited_family : String,
    inherited_style : String,
  ) -> Unit raise RasterFailure {
    let node = nodes[index]
    match node.kind {
      Document =>
        for child in node.children {
          walk(
            child, inherited_size, inherited_anchor, inherited_weight, inherited_family,
            inherited_style,
          )
        }
      Text => out.write_string(xml_escape(node.value))
      Element => {
        let tag = node.raw_name
        if backgrounds.contains(index) {
          return
        }
        if tag == "style" || tag == "title" || tag == "desc" || tag == "filter" {
          return
        }
        let allowed = [
          "svg", "defs", "g", "rect", "path", "circle", "ellipse", "line", "polyline",
          "polygon", "marker", "symbol", "use", "linearGradient", "radialGradient",
          "stop", "clipPath", "mask", "text", "tspan", "filter", "feFlood", "feBlend",
        ]
        guard allowed.contains(tag) else {
          raise raster_unsupported("generated element not qualified: " + tag)
        }
        let size = match node_attr(node, "font-size") {
          Some(s) => num(s)
          None => inherited_size
        }
        let anchor = node_attr(node, "text-anchor").unwrap_or(inherited_anchor)
        let weight = node_attr(node, "font-weight").unwrap_or(inherited_weight)
        let family = node_attr(node, "font-family").unwrap_or(inherited_family)
        let style = node_attr(node, "font-style").unwrap_or(inherited_style)
        let source_id = node_attr(node, "id").unwrap_or(
          "svg-node-" + index.to_string(),
        )
        let is_text = tag == "text" || tag == "tspan"
        let filtered = node_attr(node, "filter")
        if filtered is Some(reference) {
          let id = reference
            .replace(old="url(#", new="")
            .replace(old=")", new="")
          guard filters.get(id) is Some(fi) else {
            raise raster_unsupported("unknown generated filter")
          }
          let filter = nodes[fi]
          let content = filter.children.filter(i => nodes[i].kind == Element)
          guard content.length() == 2 &&
            nodes[content[0]].raw_name == "feFlood" &&
            nodes[content[1]].raw_name == "feBlend" else {
            raise raster_unsupported("unqualified generated filter")
          }
          let flood = nodes[content[0]]
          let blend = nodes[content[1]]
          guard node_attr(blend, "in") == Some("SourceGraphic") &&
            node_attr(blend, "in2") == node_attr(flood, "result") else {
            raise raster_unsupported("unqualified filter blend")
          }
          out.write_string("",
          )
        }
        let output_tag = if is_text { "g" } else { tag }
        out.write_string("<" + output_tag)
        for attr in node.attributes {
          let name = attr.raw_name
          if name == "style" || name.has_prefix("on") {
            raise raster_fail(
              "raster/attribute-profile",
              "Inline styles and event handlers are outside generated SVG",
              source_id~,
            )
          }
          if name == "data-theme" ||
            name == "font-family" ||
            name == "font-size" ||
            name == "font-weight" ||
            name == "font-style" ||
            name == "text-anchor" ||
            (is_text && (name == "x" || name == "y")) {
            continue
          }
          if name == "filter" || (filtered is Some(_) && name == "transform") {
            continue
          }
          let mut value = resolve(attr.value, vars)
          if name == "orient" &&
            value == "auto-start-reverse" &&
            !node_attr(node, "id").unwrap_or("").has_suffix("-start") {
            value = "auto"
          }
          if (name == "fill" || name == "stroke") && value.has_prefix("rgba(") {
            let channels = value
              .replace(old="rgba(", new="")
              .replace(old=")", new="")
              .split(",")
              .to_array()
            guard channels.length() == 4 else {
              raise raster_unsupported("malformed rgba")
            }
            value = "rgb(" +
              channels[0].trim().to_owned() +
              "," +
              channels[1].trim().to_owned() +
              "," +
              channels[2].trim().to_owned() +
              ")"
            out.write_string(
              " " + name + "-opacity=\"" + channels[3].trim().to_owned() + "\"",
            )
          }
          out.write_string(" " + name + "=\"" + xml_escape(value) + "\"")
        }
        if tag == "rect" {
          if node_attr(node, "rx") is Some(rx) && node_attr(node, "ry") is None {
            out.write_string(" ry=\"" + xml_escape(rx) + "\"")
          }
          if node_attr(node, "ry") is Some(ry) && node_attr(node, "rx") is None {
            out.write_string(" rx=\"" + xml_escape(ry) + "\"")
          }
        }
        if tag == "svg" {
          out.write_string(" data-theme=\"" + theme + "\"")
        }
        out.write_string(">")
        if is_text {
          let font = font_for_text(fonts, family, weight, style, source_id)
          if tag == "tspan" {
            guard node_attr(node, "x") is Some(_) &&
              node_attr(node, "y") is Some(_) else {
              raise raster_fail(
                "raster/text-profile",
                "Generated tspan requires explicit baseline coordinates",
                source_id~,
              )
            }
          }
          let x = match node_attr(node, "x") {
            Some(s) => num(s)
            None => 0.0
          }
          let y = match node_attr(node, "y") {
            Some(s) => num(s)
            None => 0.0
          }
          for child in node.children {
            let c = nodes[child]
            if c.kind == Text {
              if c.value.trim() != "" {
                let outlined = text_paths(
                  c.value,
                  x,
                  y,
                  size,
                  anchor,
                  font,
                  fonts,
                  source_id,
                  limits,
                )
                guard outlined.length() <= limits.max_source_units - emitted_units else {
                  raise raster_fail(
                    "raster/glyph-limit",
                    "Outlined SVG exceeds the glyph/source budget",
                    source_id~,
                  )
                }
                emitted_units += outlined.length()
                out.write_string(outlined)
              }
            } else {
              walk(child, size, anchor, weight, family, style)
            }
          }
        } else {
          for child in node.children {
            walk(child, size, anchor, weight, family, style)
          }
        }
        out.write_string("")
        if filtered is Some(_) {
          out.write_string("")
        }
      }
    }
  }
  walk(0, 12.0, "start", "400", "monospace", "normal")
  out.to_string()
}

///|
/// Every normalization write checks the remaining UTF-16 budget before it
/// appends. This includes outlined labels and resource definitions.
priv struct SvgWriter {
  builder : StringBuilder
  mut units : Int32
  limit : Int32
}

///|
fn SvgWriter::new(limit : Int32) -> SvgWriter {
  { builder: StringBuilder(), units: 0, limit, }
}

///|
fn SvgWriter::write_string(
  self : SvgWriter,
  value : String,
) -> Unit raise RasterFailure {
  guard value.length() <= self.limit - self.units else {
    raise raster_fail(
      "raster/normalized-limit", "Derived SVG exceeds the source budget",
    )
  }
  self.units += value.length()
  self.builder.write_string(value)
}

///|
fn SvgWriter::to_string(self : SvgWriter) -> String {
  self.builder.to_string()
}

///|
/// Only the emitted complete canvas background participates in an explicit
/// export background override. A partial or nested rectangle remains content.
fn is_export_background(
  node : SvgXmlNode,
  root : SvgXmlNode,
) -> Bool raise RasterFailure {
  if node.raw_name != "rect" || node_attr(node, "class") != Some("bg") {
    return false
  }
  let mut width = node_attr(root, "width").unwrap_or("0")
  let mut height = node_attr(root, "height").unwrap_or("0")
  if node_attr(root, "viewBox") is Some(box) {
    let parts = box
      .replace(old=",", new=" ")
      .split(" ")
      .filter(part => part.trim() != "")
      .to_array()
    guard parts.length() == 4 else {
      raise raster_fail("raster/malformed-svg", "Invalid generated viewBox")
    }
    if num(parts[0].to_owned()) != 0.0 || num(parts[1].to_owned()) != 0.0 {
      return false
    }
    width = parts[2].to_owned()
    height = parts[3].to_owned()
  }
  num(node_attr(node, "x").unwrap_or("0")) == 0.0 &&
  num(node_attr(node, "y").unwrap_or("0")) == 0.0 &&
  num(node_attr(node, "width").unwrap_or("0")) == num(width) &&
  num(node_attr(node, "height").unwrap_or("0")) == num(height)
}

///|
fn validate_text_glyph_budget(
  nodes : Array[SvgXmlNode],
  limits : RasterLimits,
) -> Unit raise RasterFailure {
  let mut remaining = limits.max_glyphs
  for node in nodes {
    if node.raw_name == "text" || node.raw_name == "tspan" {
      for child in node.children {
        let content = nodes[child]
        if content.kind == Text && content.value.trim() != "" {
          for _ in content.value.iter() {
            guard remaining > 0 else {
              raise raster_fail(
                "raster/glyph-limit",
                "Text exceeds the aggregate glyph budget",
                source_id=node_attr(node, "id").unwrap_or("text"),
              )
            }
            remaining -= 1
          }
        }
      }
    }
  }
}