///|
/// Versioned export data carries the generated scene and validated plan into an
/// offline worker. It is a consistency protocol, not a signature or trust token.
pub struct GifDataResult {
  data : Json?
  diagnostics : ReadOnlyArray[RasterDiagnostic]
}

///|
/// Protocol entries share the XML-node budget; repeated metadata strings share
/// the source-unit budget even when a host Json value shares its backing arrays.
priv struct GifProtocolBudget {
  limits : RasterLimits
  mut entries : Int32
  mut units : Int32
}

///|
fn GifProtocolBudget::count(
  self : GifProtocolBudget,
  count : Int32,
) -> Unit raise RasterFailure {
  guard count <= self.limits.max_svg_nodes - self.entries else {
    raise raster_fail(
      "raster/gif-data", "GIF aggregate binding/step/group budget exhausted",
    )
  }
  self.entries += count
}

///|
fn GifProtocolBudget::text(
  self : GifProtocolBudget,
  text : String,
) -> Unit raise RasterFailure {
  guard text.length() <= self.limits.max_source_units - self.units else {
    raise raster_fail(
      "raster/source-limit", "GIF aggregate binding text budget exhausted",
    )
  }
  self.units += text.length()
}

///|
fn GifProtocolBudget::value(
  self : GifProtocolBudget,
  value : Json,
) -> Unit raise RasterFailure {
  if value is String(text) {
    self.text(text)
  }
}

///|
fn gif_kind_parts(kind : @svg.NodeBindingKind) -> (String, String) {
  match kind {
    Bpmn(id) => ("bpmn", id)
    Message(index) => ("message", index.to_string())
    Frame(index) => ("frame", index.to_string())
    Swimlane(id) => ("swimlane", id)
    Flowchart(id) => ("flowchart", id)
    State(id) => ("state", id)
    Architecture(id) => ("architecture", id)
    RailroadTerm(id) => ("term", id)
    DataRow(id) => ("row", id)
    DataBox(id) => ("box", id)
  }
}

///|
fn gif_kind_key(kind : @svg.NodeBindingKind) -> String {
  let (tag, id) = gif_kind_parts(kind)
  tag + ":" + id
}

///|
fn gif_source_id(canonical : String, display : String) -> String {
  let mut hash : UInt64 = 14695981039346656037
  for source in [canonical, display] {
    for i in 0.. Json {
  let (tag, value) = gif_kind_parts(kind)
  Json([Json(tag), Json(value)])
}

///|
/// Prepare a source-bound scene once. Browser route changes may supply another
/// bounded schedule over these same visible occurrence keys.
pub fn gif_export_data(
  scene : @svg.Scene,
  plan : @animation.PlaybackPlan,
  limits~ : RasterLimits = RasterLimits::default(),
) -> GifDataResult {
  if !plan.matches(scene.catalog) {
    return {
      data: None,
      diagnostics: gif_diagnostic(
        "raster/plan-mismatch",
        "Export scene and playback plan name different sources",
        scene.catalog.view_id,
      ),
    }
  }
  try {
    validate_raster_limits(limits)
    guard plan.duration_ms > 0L && !plan.steps.is_empty() else {
      raise raster_fail(
        "raster/gif-data", "GIF playback needs a positive duration and at least one occurrence",
      )
    }
    guard scene.canonical_svg.length() <= limits.max_source_units &&
      scene.display_svg.length() <= limits.max_source_units &&
      scene.paths.length() <= limits.max_svg_nodes &&
      scene.nodes.length() <= limits.max_svg_nodes &&
      plan.steps.length() <= limits.max_svg_nodes else {
      raise raster_fail(
        "raster/source-limit", "Export data exceeds its source/binding budget",
      )
    }
    let budget = GifProtocolBudget::{ limits, entries: 0, units: 0, }
    budget.count(scene.paths.length())
    budget.count(scene.nodes.length())
    budget.count(plan.steps.length())
    for path in scene.paths {
      budget.text(path.occurrence.key)
      budget.text(path.id)
      budget.text(path.path)
      if path.mask is Some(mask) {
        budget.text(mask)
      }
    }
    for node in scene.nodes {
      budget.text(node.endpoint)
      let (tag, id) = gif_kind_parts(node.kind)
      budget.text(tag)
      budget.text(id)
    }
    for step in plan.steps {
      budget.count(step.concurrent_groups.length())
      budget.text(step.occurrence.key)
      budget.text(step.start_ms.to_string())
      budget.text(step.duration_ms.to_string())
    }
    let source_id = gif_source_id(scene.canonical_svg, scene.display_svg)
    let paths = scene.paths
      .iter()
      .map(path => {
        Json({
          "key": Json(path.occurrence.key),
          "id": Json(path.id),
          "path": Json(path.path),
          "mask": path.mask.map(value => Json(value)).unwrap_or(Json(null)),
        })
      })
      .to_array()
    let nodes = scene.nodes
      .iter()
      .map(node => {
        Json({ "key": Json(node.endpoint), "kind": gif_kind_data(node.kind) })
      })
      .to_array()
    let steps = plan.steps
      .iter()
      .map(step => {
        Json({
          "key": Json(step.occurrence.key),
          "ordinal": Json(step.ordinal),
          "start_ms": Json(step.start_ms.to_string()),
          "duration_ms": Json(step.duration_ms.to_string()),
          "reversed": Json(step.reversed),
          "visit": Json(step.visit),
          "groups": Json(
            step.concurrent_groups.iter().map(group => Json(group)).to_array(),
          ),
        })
      })
      .to_array()
    let data : Json = {
      "version": 1,
      "source_id": Json(source_id),
      "canonical": Json(scene.canonical_svg),
      "display": Json(scene.display_svg),
      "paths": Json(paths),
      "nodes": Json(nodes),
      "plan": {
        "source_id": Json(source_id),
        "mode": Json(
          if plan.mode == Finite {
            "finite"
          } else {
            "illustrative"
          },
        ),
        "duration_ms": Json(plan.duration_ms.to_string()),
        "steps": Json(steps),
      },
    }
    { data: Some(data), diagnostics: [], }
  } catch {
    error => { data: None, diagnostics: [raster_diagnostic(error)], }
  }
}

///|
fn gif_field(value : Json, name : String) -> Json raise RasterFailure {
  guard value is Object(fields) && fields.get(name) is Some(value) else {
    raise raster_fail("raster/gif-data", "Missing export field: " + name)
  }
  value
}

///|
fn gif_string(value : Json) -> String raise RasterFailure {
  guard value is String(value) else {
    raise raster_fail("raster/gif-data", "Export field must be a string")
  }
  value
}

///|
fn gif_boolean(value : Json) -> Bool raise RasterFailure {
  match value {
    True => true
    False => false
    _ => raise raster_fail("raster/gif-data", "Export field must be a boolean")
  }
}

///|
fn gif_number(value : Json) -> Int64 raise RasterFailure {
  let number = match value {
    Number(number, ..) => number
    String(text) => {
      guard text.length() <= 32 else {
        raise raster_fail("raster/gif-data", "Export integer text is excessive")
      }
      @string.parse_double(text) catch {
        _ =>
          raise raster_fail("raster/gif-data", "Export time must be an integer")
      }
    }
    _ => raise raster_fail("raster/gif-data", "Export field must be an integer")
  }
  guard number >= 0.0 &&
    number <= 9007199254740991.0 &&
    number.floor() == number else {
    raise raster_fail(
      "raster/gif-data", "Export integer is outside the exact logical-time range",
    )
  }
  number.to_int64()
}

///|
fn gif_array(value : Json, limit : Int32) -> Array[Json] raise RasterFailure {
  guard value is Array(values) && values.length() <= limit else {
    raise raster_fail(
      "raster/gif-data", "Export array exceeds its binding budget",
    )
  }
  values
}

///|
/// Check aggregate metadata before parsing SVG, paths, fonts or group values.
fn gif_protocol_arrays(
  data : Json,
  plan : Json,
  limits : RasterLimits,
) -> (Array[Json], Array[Json], Array[Json]) raise RasterFailure {
  let paths = gif_array(gif_field(data, "paths"), limits.max_svg_nodes)
  let nodes = gif_array(gif_field(data, "nodes"), limits.max_svg_nodes)
  let steps = gif_array(gif_field(plan, "steps"), limits.max_svg_nodes)
  let budget = GifProtocolBudget::{ limits, entries: 0, units: 0, }
  budget.count(paths.length())
  budget.count(nodes.length())
  budget.count(steps.length())
  // Charge group array lengths before examining any individual group value.
  for step in steps {
    budget.count(
      gif_array(gif_field(step, "groups"), limits.max_svg_nodes).length(),
    )
  }
  for path in paths {
    for name in ["key", "id", "path"] {
      budget.text(gif_string(gif_field(path, name)))
    }
    let mask = gif_field(path, "mask")
    if !(mask is Null) {
      budget.text(gif_string(mask))
    }
  }
  for node in nodes {
    budget.text(gif_string(gif_field(node, "key")))
    let kind = gif_array(gif_field(node, "kind"), 2)
    guard kind.length() == 2 else {
      raise raster_fail(
        "raster/gif-data", "Node binding needs a kind and identity",
      )
    }
    for value in kind {
      budget.text(gif_string(value))
    }
  }
  for step in steps {
    budget.text(gif_string(gif_field(step, "key")))
    for name in ["ordinal", "start_ms", "duration_ms"] {
      budget.value(gif_field(step, name))
    }
    for group in gif_array(gif_field(step, "groups"), limits.max_svg_nodes) {
      budget.value(group)
    }
  }
  (paths, nodes, steps)
}

///|
fn gif_kind(value : Json) -> @svg.NodeBindingKind raise RasterFailure {
  let pair = gif_array(value, 2)
  guard pair.length() == 2 else {
    raise raster_fail(
      "raster/gif-data", "Node binding needs a kind and identity",
    )
  }
  let id = gif_string(pair[1])
  match gif_string(pair[0]) {
    "bpmn" => Bpmn(id)
    "swimlane" => Swimlane(id)
    "flowchart" => Flowchart(id)
    "state" => State(id)
    "architecture" => Architecture(id)
    "term" => RailroadTerm(id)
    "row" => DataRow(id)
    "box" => DataBox(id)
    "message" => {
      let n = gif_number(pair[1])
      guard n <= 2147483647L else {
        raise raster_unsupported("Invalid message binding")
      }
      Message(n.to_int())
    }
    "frame" => {
      let n = gif_number(pair[1])
      guard n <= 2147483647L else {
        raise raster_unsupported("Invalid frame binding")
      }
      Frame(n.to_int())
    }
    _ => raise raster_fail("raster/gif-data", "Unknown generated node binding")
  }
}

///|
priv struct GifWirePath {
  id : String
  path : String
  mask : String?
  geometry : GifPath
}

///|
priv struct GifWireStep {
  key : String
  start : Int64
  duration : Int64
  reversed : Bool
  visit : Bool
}

///|
fn export_gif_data_explicit(
  data : Json,
  selected : Json?,
  options : GifOptions,
  font_source : () -> FontSet,
  limits : RasterLimits,
) -> RasterResult {
  try {
    validate_raster_limits(limits)
    guard gif_number(gif_field(data, "version")) == 1L else {
      raise raster_fail(
        "raster/gif-data", "Unsupported GIF export protocol version",
      )
    }
    let canonical = gif_string(gif_field(data, "canonical"))
    let display = gif_string(gif_field(data, "display"))
    guard canonical.length() <= limits.max_source_units &&
      display.length() <= limits.max_source_units else {
      raise raster_fail(
        "raster/source-limit", "GIF export source exceeds its budget",
      )
    }
    let source_id = gif_string(gif_field(data, "source_id"))
    guard source_id == gif_source_id(canonical, display) else {
      raise raster_fail(
        "raster/plan-mismatch", "GIF export source fingerprint differs",
      )
    }
    let plan = selected.unwrap_or_else(() => gif_field(data, "plan"))
    guard gif_string(gif_field(plan, "source_id")) == source_id else {
      raise raster_fail(
        "raster/plan-mismatch", "Selected GIF plan belongs to another scene",
      )
    }
    let mode = gif_string(gif_field(plan, "mode"))
    guard mode == "finite" || mode == "illustrative" else {
      raise raster_fail("raster/gif-data", "Unknown playback mode")
    }
    let duration = gif_number(gif_field(plan, "duration_ms"))
    guard duration > 0L else {
      raise raster_fail(
        "raster/gif-data", "GIF playback duration must be positive",
      )
    }
    if mode == "finite" &&
      options.interval is Interval(end_ms~, ..) &&
      end_ms > duration {
      raise raster_fail(
        "raster/gif-options", "GIF interval exceeds the finite plan",
      )
    }
    let (path_values, node_values, step_values) = gif_protocol_arrays(
      data, plan, limits,
    )
    let (width, height) = still_dimensions(canonical, options.size, limits)
    let pixel_count = width.to_int64() * height.to_int64()
    let times = gif_times(duration, options, limits, pixels=pixel_count) catch {
      GifInvalid(message) => raise raster_fail("raster/gif-options", message)
      RasterInvalid(code, message, source_id) => raise RasterInvalid(code, message, source_id)
      error => raise raster_fail("raster/gif-options", error.to_string())
    }
    let tree = parse_svg_xml(display, max_nodes=limits.max_svg_nodes)
    let visit_indices = gif_visit_indices(tree)
    let ids : Map[String, Int32] = Map([])
    for index, node in tree {
      if gif_attr(node, "id") is Some(id) {
        guard !ids.contains(id) else {
          raise raster_fail(
            "raster/gif-data", "Duplicate generated SVG identity",
          )
        }
        ids[id] = index
      }
    }
    let paths : Map[String, GifWirePath] = Map([])
    let mut segments = limits.max_svg_nodes
    for value in path_values {
      let key = gif_string(gif_field(value, "key"))
      let id = gif_string(gif_field(value, "id"))
      let path = gif_string(gif_field(value, "path"))
      let mask = match gif_field(value, "mask") {
        Null => None
        value => Some(gif_string(value))
      }
      guard !paths.contains(key) &&
        ids.get(id) is Some(index) &&
        tree[index].raw_name == "path" &&
        gif_attr(tree[index], "d") == Some(path) &&
        gif_attr(tree[index], "mask") == mask else {
        raise raster_fail(
          "raster/plan-mismatch", "GIF connection binding differs from its generated path",
        )
      }
      guard @svg.ConnectorPath::parse(path) is Some(parsed) else {
        raise raster_fail("raster/gif-data", "Malformed GIF bound path")
      }
      let geometry = gif_path(parsed, segments.min(8192)) catch {
        GifInvalid(message) => raise raster_fail("raster/gif-data", message)
      }
      segments -= geometry.segments.length()
      paths[key] = { id, path, mask, geometry, }
    }
    let nodes : Map[String, @svg.NodeBindingKind] = Map([])
    for value in node_values {
      let key = gif_string(gif_field(value, "key"))
      let kind = gif_kind(gif_field(value, "kind"))
      guard !nodes.contains(key) && visit_indices.contains(gif_kind_key(kind)) else {
        raise raster_fail(
          "raster/plan-mismatch", "GIF node binding is outside the generated scene",
        )
      }
      nodes[key] = kind
    }
    let steps : Array[GifWireStep] = []
    let ordinals : Map[Int64, Bool] = Map([])
    for value in step_values {
      let key = gif_string(gif_field(value, "key"))
      let ordinal = gif_number(gif_field(value, "ordinal"))
      guard ordinal <= limits.max_svg_nodes.to_int64() &&
        !ordinals.contains(ordinal) else {
        raise raster_fail(
          "raster/gif-data", "Duplicate or excessive occurrence ordinal",
        )
      }
      ordinals[ordinal] = true
      let start = gif_number(gif_field(value, "start_ms"))
      let step_duration = gif_number(gif_field(value, "duration_ms"))
      let reversed = gif_boolean(gif_field(value, "reversed"))
      let visit = gif_boolean(gif_field(value, "visit"))
      guard step_duration > 0L &&
        start <= duration &&
        step_duration <= duration - start &&
        (if visit {
          nodes.contains(key) && !reversed
        } else {
          paths.contains(key)
        }) else {
        raise raster_fail(
          "raster/gif-data", "GIF step timing or target is invalid",
        )
      }
      for group in gif_array(gif_field(value, "groups"), limits.max_svg_nodes) {
        guard gif_number(group) <= limits.max_svg_nodes.to_int64() else {
          raise raster_fail(
            "raster/gif-data", "GIF concurrent group exceeds its budget",
          )
        }
      }
      steps.push({ key, start, duration: step_duration, reversed, visit, })
    }
    guard !steps.is_empty() else {
      raise raster_fail(
        "raster/gif-data", "GIF playback needs at least one occurrence",
      )
    }
    let fonts = font_source()
    let delays = times.iter().map(time => time.1).to_array()
    let bytes = encode_gif_frames(
      width,
      height,
      delays,
      options.loop_behavior,
      limits,
      index => {
        let time = times[index].0
        let logical = if mode == "illustrative" {
          time % duration
        } else {
          time.min(duration)
        }
        let paints : Array[GifPaint] = []
        for step in steps {
          if logical < step.start || logical >= step.start + step.duration {
            continue
          }
          let numerator = logical - step.start
          let target = if step.visit {
            GifTarget::Visit(nodes[step.key])
          } else {
            let path = paths[step.key]
            let fraction = numerator.to_double() / step.duration.to_double()
            GifTarget::Connection(
              id=path.id,
              path=path.path,
              mask=path.mask,
              point=path.geometry.point(
                if step.reversed {
                  1.0 - fraction
                } else {
                  fraction
                },
              ),
            )
          }
          paints.push({
            target,
            numerator,
            denominator: step.duration,
            reversed: step.reversed,
          })
        }
        let source = gif_paint_svg(
          display,
          source_id,
          paints,
          limits,
          visit_indices~,
        )
        render_rgba(
          source,
          width,
          height,
          options.theme,
          fonts,
          options.background,
          limits,
        )
      },
    )
    {
      image: Some({
        bytes,
        media_type: "image/gif",
        width,
        height,
        format: Gif,
      }),
      diagnostics: [],
    }
  } catch {
    error => { image: None, diagnostics: [raster_diagnostic(error)], }
  }
}

///|
/// Decode the versioned scene/plan data emitted by gif_export_data. A selected
/// route must carry the same source_id and use its validated visible bindings.
pub fn export_gif_data(
  data : Json,
  plan? : Json,
  options~ : GifOptions = GifOptions::new(),
  fonts? : FontSet,
  limits~ : RasterLimits = RasterLimits::default(),
) -> RasterResult {
  export_gif_data_explicit(
    data,
    plan,
    options,
    () => fonts.unwrap_or_else(FontSet::default),
    limits,
  )
}