/**
 * xy render client.
 *
 * A thin GPU render client (design dossier §32): receives a data-less spec +
 * offset-encoded f32 columns as raw binary (§29 — no JSON numbers, no parse),
 * uploads them once to WebGL2 buffers, and draws with instanced/point
 * primitives. Pan/zoom is a uniform update — it never touches data buffers (§7).
 *
 * Full scatter support:
 *  - per-point color: constant, continuous (colormap LUT), categorical (palette)
 *  - per-point size: constant or continuous (mapped to a px range)
 *  - GPU picking → exact-row hover tooltip (§7/§17 Tier-0 hover; exact values
 *    come from the kernel's f64 canonical store, §16)
 *  - Tier-2 density surface for massive scatter (§5): a kernel-binned count
 *    grid whose log-normalized count drives the ALPHA channel; channel-bearing
 *    traces add a per-cell mean point-color plane (premultiplied RGBA8
 *    texture), constant-color traces tint a 1-byte count texture (LOD doc §2).
 *    Re-binned on zoom via a kernel round-trip (stale grid stays drawn until
 *    then, §17)
 *
 * Dependency-free: this file is the whole client. DOM is used only for chrome —
 * title, axis tick labels, legend, tooltip (§7).
 */

// v6: symlog axis scale (`scale: "symlog"` + `constant`) and scale-coordinate
// density grids — a v5 client would render both silently wrong.
// v7: `colormap` may be explicit RGB stops rather than a built-in name, and the
// spec may carry a chart `palette`. A v6 client indexes COLORMAP_STOPS with the
// stop array, misses, and paints viridis without erroring.
// v8: legend/colorbar geometry, extra colormap names, and match-fill strokes
// add wire values an older v7 client would accept but silently misrender.
// v9: explicit minor axis ticks/styles, log nonpositive behavior,
// `axis.tick_sides`/`axis.tick_label_sides`, scalar-normalization scale,
// colorbar padding/explicit-axes placement, and contour-line overlays add wire
// values a cached v8 client would accept but silently misrender.
// v10: `title_options` carries independent left/center/right title artists,
// including axes-fraction y and pixel padding. A v9 client would silently omit
// non-center slots and their placement, so v10 rejects it before rendering.
// v11: the chart-level `coords: "polar"` key and the angular axis descriptors
// (`theta_unit`, `theta_zero`, `theta_direction`). A v10 client ignores
// `coords` and draws the (theta, r) columns as cartesian x/y, so it must
// reject the payload rather than render a plausible wrong picture.
// v11 also adds the `ribbon` trace kind (flow bands; Sankey). markOf() falls
// back to scatter for unknown kinds, so a v10 client would render ribbons as
// a point cloud with no error.
// v12: polar angular axes carry `sector`/`grid_shape`, and radial axes carry
// `hole`/`r_origin`. A v11 client would accept those fields but silently draw
// full circular, centre-origin geometry.
// v13: the `funnel` trace kind. markOf() falls back to scatter for unknown
// kinds, so a v12 client would silently render funnel quads as a point cloud.
export const PROTOCOL = 13;

// One wire-column -> generic GL-slot contract for funnel geometry. Object
// insertion order is also the instance-attribute order used by build, filter,
// animation, mix, and draw; keeping both facts here prevents one path from
// silently swapping a quad edge while the others continue to look correct.
export const FUNNEL_SLOTS = Object.freeze({
  pos0: "x0",
  pos1: "x1",
  lo0: "y0",
  hi0: "y1",
  lo1: "x2",
  hi1: "y2",
});

// Every GL buffer field a built trace — or a drill / sample-overlay clone of
// one — can own. Teardown reads this list instead of a hand-kept subset: the
// previous subset covered geometry only, so every rebuilt trace (a state-driven
// data update, an append that cannot patch in place, an animated spec swap)
// orphaned its style, direct-rgba colour, stroke, corner-radius, LOD-blend and
// dashed line-length buffers, and repeated updates walked the GPU out of
// memory. It lives here, in the leaf module, because both the ChartView
// teardown and the LOD drill teardown must delete exactly the same set and
// 45_lod cannot import from 50_chartview. `test_trace_teardown_deletes_every_gpu_buffer`
// pins that every `<name>Buf` field assigned anywhere in js/src appears here,
// so adding a channel buffer cannot silently reintroduce the leak.
export const TRACE_GPU_BUFFERS = [
  "xBuf", "yBuf", "cBuf", "sBuf", "selBuf", "baseBuf",
  "x0Buf", "x1Buf", "x2Buf", "y0Buf", "y1Buf", "y2Buf",
  "t0Buf", "t1Buf",
  "posBuf", "value1Buf", "value0Buf",
  "rgbaBuf", "rgba2Buf", "styleBuf", "strokeBuf", "radiusBuf", "dBuf",
  "_lenBuf", "_segmentDashOffsetBuf", "_segmentDashDirBuf",
  "_transitionPrevXBuf", "_transitionPrevYBuf",
  "_transitionPrevPosBuf", "_transitionPrevValue1Buf", "_transitionPrevValue0Buf",
];

// HTTP binary frame v1 (spec/design/wire-protocol.md §7; Python side in
// python/xy/_framing.py). The chart spec's PROTOCOL
// above versions renderer semantics; this separately versions the transport
// envelope so either layer can fail loudly without coupling their evolution.
const XY_FRAME_MAGIC = [0x58, 0x59, 0x42, 0x46]; // "XYBF"
const XY_FRAME_VERSION = 1;
const XY_FRAME_HEADER_SIZE = 24;
const XY_FRAME_ALIGNMENT = 8;
const XY_FRAME_DEFAULT_LIMITS = Object.freeze({
  maxFrameBytes: 512 * 1024 * 1024,
  maxMetadataBytes: 8 * 1024 * 1024,
  maxBuffers: 4096,
  maxBufferBytes: 256 * 1024 * 1024,
});

export function xyByteSpan(value, label = "buffer") {
  if (value instanceof ArrayBuffer) return new Uint8Array(value);
  if (ArrayBuffer.isView(value)) {
    return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
  }
  throw new TypeError(`${label} must be an ArrayBuffer or ArrayBuffer view`);
}

export function bytesToSpan(b) {
  const span = xyByteSpan(b, "chart payload");
  // anywidget/third-party callers may hand us an oddly-offset DataView. Keep
  // the normal aligned path zero-copy; preserve compatibility with one narrow
  // view-sized copy only when f32 columns could not be constructed in place.
  return span.byteOffset % 4 === 0 ? span : new Uint8Array(span);
}

/** True when every column in the spec fits inside the supplied buffers.
 * Trait-transported appends can observe a torn update on hosts that set
 * spec and buffers non-atomically (one change event fires between the two
 * writes); a torn pair must be deferred to the other change event, not
 * applied and not treated as fatal. Message- and first-paint transports
 * deliver the pair together, so for them a mismatch stays a loud
 * `payloadBuffers`/`_columnView` error, never this soft check. */
export function payloadCoherent(spec, raw) {
  const cols = spec?.columns;
  if (!Array.isArray(cols)) return false;
  const size = (c) => (c.dtype === "u8" ? 1 : 4);
  if (spec.buffer_layout === "split") {
    if (!Array.isArray(raw)) return false;
    return cols.every((c) => {
      if (!Number.isInteger(c.buf)) return true; // raster-only borrowed span
      const b = raw[c.buf];
      return !!b && c.len * size(c) <= b.byteLength;
    });
  }
  if (Array.isArray(raw) || !raw) return false;
  return cols.every((c) => c.byte_offset + c.len * size(c) <= raw.byteLength);
}

/** Wire buffers in the shape the spec declares (§29): packed is one blob;
 * split is one span per column. Aligned views stay zero-copy; only a
 * legacy unaligned view pays a narrow view-sized copy. A spec/transport
 * disagreement is a bug, never a fallback. Used at first paint and by the
 * streaming-append apply path (both ride the same layouts). */
export function payloadBuffers(spec, raw) {
  if (spec.buffer_layout === "split") {
    if (!Array.isArray(raw)) {
      throw new Error("xy: spec says buffer_layout=split but the transport delivered one buffer");
    }
    return raw.map(bytesToSpan);
  }
  if (Array.isArray(raw)) {
    throw new Error("xy: transport delivered a buffer list but the spec is not split-layout");
  }
  return bytesToSpan(raw);
}

function xyFrameLimit(limits, name) {
  const fallback = XY_FRAME_DEFAULT_LIMITS[name];
  const value = limits && limits[name] != null ? limits[name] : fallback;
  if (!Number.isSafeInteger(value) || value <= 0) {
    throw new RangeError(`${name} must be a positive safe integer`);
  }
  return value;
}

function xyAlign8(value) {
  return Math.ceil(value / XY_FRAME_ALIGNMENT) * XY_FRAME_ALIGNMENT;
}

function xyFrameU64(view, offset, label) {
  const value = view.getBigUint64(offset, true);
  if (value > BigInt(Number.MAX_SAFE_INTEGER)) {
    throw new RangeError(`${label} exceeds JavaScript's safe integer range`);
  }
  return Number(value);
}

function xyRequireZeroPadding(bytes, start, end, label) {
  if (end > bytes.byteLength) throw new RangeError(`truncated ${label} padding`);
  for (let i = start; i < end; i++) {
    if (bytes[i] !== 0) throw new RangeError(`non-zero ${label} padding`);
  }
}

/** Decode one production xy HTTP frame.
 *
 * Binary buffers are Uint8Array spans into the supplied ArrayBuffer; they are
 * not copied. Response.arrayBuffer() supplies an aligned base. Passing an
 * unaligned subview is rejected rather than silently slicing the whole frame.
 */
export function decodeFrame(body, limits = null) {
  const bytes = xyByteSpan(body, "frame body");
  const maxFrameBytes = xyFrameLimit(limits, "maxFrameBytes");
  const maxMetadataBytes = xyFrameLimit(limits, "maxMetadataBytes");
  const maxBuffers = xyFrameLimit(limits, "maxBuffers");
  const maxBufferBytes = xyFrameLimit(limits, "maxBufferBytes");
  if (maxMetadataBytes > maxFrameBytes) {
    throw new RangeError("maxMetadataBytes cannot exceed maxFrameBytes");
  }
  if (maxBufferBytes > maxFrameBytes) {
    throw new RangeError("maxBufferBytes cannot exceed maxFrameBytes");
  }
  if (bytes.byteOffset % XY_FRAME_ALIGNMENT !== 0) {
    throw new RangeError("frame body must start on an 8-byte boundary");
  }
  if (bytes.byteLength > maxFrameBytes) {
    throw new RangeError(`frame length ${bytes.byteLength} exceeds limit ${maxFrameBytes}`);
  }
  if (bytes.byteLength < XY_FRAME_HEADER_SIZE) throw new RangeError("truncated frame header");

  const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
  for (let i = 0; i < XY_FRAME_MAGIC.length; i++) {
    if (view.getUint8(i) !== XY_FRAME_MAGIC[i]) throw new RangeError("invalid frame magic");
  }
  const version = view.getUint8(4);
  if (version !== XY_FRAME_VERSION) throw new RangeError(`unsupported frame version ${version}`);
  const flags = view.getUint8(5);
  if (flags !== 0) throw new RangeError(`unsupported frame flags 0x${flags.toString(16)}`);
  const headerSize = view.getUint16(6, true);
  if (headerSize !== XY_FRAME_HEADER_SIZE) {
    throw new RangeError(`unsupported frame header size ${headerSize}`);
  }
  const metadataLength = view.getUint32(8, true);
  const bufferCount = view.getUint32(12, true);
  const totalLength = xyFrameU64(view, 16, "declared frame length");
  if (totalLength !== bytes.byteLength) {
    throw new RangeError(
      `declared frame length ${totalLength} does not match body length ${bytes.byteLength}`
    );
  }
  if (metadataLength > maxMetadataBytes) {
    throw new RangeError(`metadata length ${metadataLength} exceeds limit ${maxMetadataBytes}`);
  }
  if (bufferCount > maxBuffers) {
    throw new RangeError(`buffer count ${bufferCount} exceeds limit ${maxBuffers}`);
  }

  const metadataEnd = XY_FRAME_HEADER_SIZE + metadataLength;
  if (metadataEnd > bytes.byteLength) throw new RangeError("truncated frame metadata");
  let message;
  try {
    const metadataBytes = new Uint8Array(
      bytes.buffer,
      bytes.byteOffset + XY_FRAME_HEADER_SIZE,
      metadataLength
    );
    message = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(metadataBytes));
  } catch (error) {
    throw new RangeError(`invalid frame metadata JSON: ${error}`);
  }
  if (!message || Array.isArray(message) || typeof message !== "object") {
    throw new RangeError("frame metadata must decode to an object");
  }

  let position = xyAlign8(metadataEnd);
  xyRequireZeroPadding(bytes, metadataEnd, position, "metadata");
  const buffers = [];
  for (let i = 0; i < bufferCount; i++) {
    if (position + 8 > bytes.byteLength) throw new RangeError(`truncated buffer ${i} length`);
    const bufferLength = xyFrameU64(view, position, `buffer ${i} length`);
    position += 8;
    if (bufferLength > maxBufferBytes) {
      throw new RangeError(`buffer ${i} length ${bufferLength} exceeds limit ${maxBufferBytes}`);
    }
    const end = position + bufferLength;
    if (end > bytes.byteLength) throw new RangeError(`truncated buffer ${i}`);
    const absoluteOffset = bytes.byteOffset + position;
    if (absoluteOffset % XY_FRAME_ALIGNMENT !== 0) {
      throw new RangeError(`buffer ${i} is not 8-byte aligned`);
    }
    buffers.push(new Uint8Array(bytes.buffer, absoluteOffset, bufferLength));
    const paddedEnd = xyAlign8(end);
    xyRequireZeroPadding(bytes, end, paddedEnd, `buffer ${i}`);
    position = paddedEnd;
  }
  if (position !== bytes.byteLength) {
    throw new RangeError(`frame has ${bytes.byteLength - position} trailing bytes`);
  }
  return { message, buffers, version: XY_FRAME_VERSION, byteLength: bytes.byteLength };
}
