import { buildSpatialIndex, type Leaf } from "./quadtree";

/* ── Shaders ────────────────────────────────────────────── */

const VERT = `#version 300 es
precision highp float;
in vec2 a_pos;
uniform vec2 u_center;
uniform vec2 u_scale;
uniform float u_ptSize;
void main() {
  gl_Position = vec4((a_pos - u_center) * u_scale, 0.0, 1.0);
  gl_PointSize = u_ptSize;
}
`;

const FRAG = `#version 300 es
precision mediump float;
out vec4 o;
void main() {
  vec2 c = gl_PointCoord * 2.0 - 1.0;
  if (dot(c, c) > 1.0) discard;
  o = vec4(0.3, 0.55, 1.0, 0.5);
}
`;

const LINE_FRAG = `#version 300 es
precision mediump float;
out vec4 o;
void main() {
  o = vec4(0.3, 0.55, 1.0, 0.15); // faint lines
}
`;

const SELECTED_FRAG = `#version 300 es
precision mediump float;
out vec4 o;
void main() {
  vec2 c = gl_PointCoord * 2.0 - 1.0;
  if (dot(c, c) > 1.0) discard;
  o = vec4(1.0, 0.5, 0.0, 0.9); // orange for selected
}
`;

const NEIGHBOR_FRAG = `#version 300 es
precision mediump float;
out vec4 o;
void main() {
  vec2 c = gl_PointCoord * 2.0 - 1.0;
  if (dot(c, c) > 1.0) discard;
  o = vec4(1.0, 0.9, 0.0, 0.8); // yellow for neighbors
}
`;

/* ── Types ──────────────────────────────────────────────── */

export interface RenderStats {
  drawnCount: number;
  mode: string;
  zoom: number;
  ptSize: number;
}

/* ── Constants ──────────────────────────────────────────── */

const WORLD_RADIUS = 4.0; // sphere world-space radius
const MAX_DRAW = 2_000_000; // max particles to draw per frame

/* ── Renderer ───────────────────────────────────────────── */

export class Renderer {
  private gl: WebGL2RenderingContext;
  private canvas: HTMLCanvasElement;
  private program: WebGLProgram;
  private lineProgram: WebGLProgram;
  private selectedProgram: WebGLProgram;
  private neighborProgram: WebGLProgram;
  private vao: WebGLVertexArrayObject;

  // Data
  private leaves: Leaf[] = [];
  private sorted: Float32Array = new Float32Array(0);
  // order[sortedIdx] = originalIdx (original ordering matches input arrays)
  private sortedToOriginal: Uint32Array = new Uint32Array(0);
  private vertexIdToSortedIndex: Map<number, number> = new Map();
  private nodeCount = 0;
  private edgeCount = 0;
  private leafEdgeStarts: Uint32Array = new Uint32Array(0);
  private leafEdgeCounts: Uint32Array = new Uint32Array(0);
  private maxPtSize = 256;

  // Multi-draw extension
  private multiDrawExt: any = null;
  private visibleLeafIndices: Uint32Array = new Uint32Array(0);
  private startsArray: Int32Array = new Int32Array(0);
  private countsArray: Int32Array = new Int32Array(0);

  // Uniform locations
  private uCenter: WebGLUniformLocation;
  private uScale: WebGLUniformLocation;
  private uPtSize: WebGLUniformLocation;

  private uCenterLine: WebGLUniformLocation;
  private uScaleLine: WebGLUniformLocation;

  private uCenterSelected: WebGLUniformLocation;
  private uScaleSelected: WebGLUniformLocation;
  private uPtSizeSelected: WebGLUniformLocation;

  private uCenterNeighbor: WebGLUniformLocation;
  private uScaleNeighbor: WebGLUniformLocation;
  private uPtSizeNeighbor: WebGLUniformLocation;

  private linesIbo: WebGLBuffer;

  // Selection
  private selectionIbo: WebGLBuffer;
  private selectionCount = 0;

  // Neighbors
  private neighborIbo: WebGLBuffer;
  private neighborCount = 0;

  // Camera state
  private cx = 0;
  private cy = 0;
  private zoom = 0.06; // pixels per world unit (starts zoomed out to see everything)

  constructor(canvas: HTMLCanvasElement) {
    this.canvas = canvas;
    const gl = canvas.getContext("webgl2", { antialias: false, alpha: false });
    if (!gl) throw new Error("WebGL2 not supported");
    this.gl = gl;

    this.multiDrawExt = gl.getExtension('WEBGL_multi_draw');

    // Compile programs
    this.program = this.compileProgram(VERT, FRAG);
    this.lineProgram = this.compileProgram(VERT, LINE_FRAG);
    this.selectedProgram = this.compileProgram(VERT, SELECTED_FRAG);
    this.neighborProgram = this.compileProgram(VERT, NEIGHBOR_FRAG);

    gl.useProgram(this.program);

    this.uCenter = gl.getUniformLocation(this.program, "u_center")!;
    this.uScale = gl.getUniformLocation(this.program, "u_scale")!;
    this.uPtSize = gl.getUniformLocation(this.program, "u_ptSize")!;

    this.uCenterLine = gl.getUniformLocation(this.lineProgram, "u_center")!;
    this.uScaleLine = gl.getUniformLocation(this.lineProgram, "u_scale")!;

    this.uCenterSelected = gl.getUniformLocation(this.selectedProgram, "u_center")!;
    this.uScaleSelected = gl.getUniformLocation(this.selectedProgram, "u_scale")!;
    this.uPtSizeSelected = gl.getUniformLocation(this.selectedProgram, "u_ptSize")!;

    this.uCenterNeighbor = gl.getUniformLocation(this.neighborProgram, "u_center")!;
    this.uScaleNeighbor = gl.getUniformLocation(this.neighborProgram, "u_scale")!;
    this.uPtSizeNeighbor = gl.getUniformLocation(this.neighborProgram, "u_ptSize")!;

    // Query hardware max point size
    const range = gl.getParameter(gl.ALIASED_POINT_SIZE_RANGE) as Float32Array;
    this.maxPtSize = range[1] || 256;

    // Create VAO + VBO (empty for now)
    this.vao = gl.createVertexArray()!;
    gl.bindVertexArray(this.vao);
    const vbo = gl.createBuffer()!;
    gl.bindBuffer(gl.ARRAY_BUFFER, vbo);

    // We forced a_pos to location 0 in compileProgram
    gl.enableVertexAttribArray(0);
    gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
    gl.bindVertexArray(null);

    this.linesIbo = gl.createBuffer()!;
    this.selectionIbo = gl.createBuffer()!;
    this.neighborIbo = gl.createBuffer()!;

    // Blending
    gl.enable(gl.BLEND);
    gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
    gl.clearColor(0.02, 0.02, 0.05, 1.0);
  }

  /**
   * Load particles from pre-computed positions and edges, build quadtree, upload to GPU.
   * vertexIds: original vertex IDs from CSV (parallel to xs/ys)
   * edges: flat array of [srcVertexId, dstVertexId, ...]
   * Call once at startup. Returns build time in ms.
   */
  init(
    xs: Float32Array,
    ys: Float32Array,
    vertexIds: Uint32Array,
    edges: Uint32Array
  ): number {
    const t0 = performance.now();
    const gl = this.gl;
    const count = xs.length;
    const edgeCount = edges.length / 2;
    this.nodeCount = count;

    // Build quadtree (spatially sorts the array)
    const { sorted, leaves, order } = buildSpatialIndex(xs, ys);
    this.leaves = leaves;
    this.sorted = sorted;
    this.sortedToOriginal = order;

    // Pre-allocate arrays for render loop (zero-allocation rendering)
    this.visibleLeafIndices = new Uint32Array(leaves.length);
    this.startsArray = new Int32Array(leaves.length);
    this.countsArray = new Int32Array(leaves.length);

    // Upload sorted particles to GPU as STATIC VBO (never changes)
    gl.bindVertexArray(this.vao);
    gl.bufferData(gl.ARRAY_BUFFER, sorted, gl.STATIC_DRAW);
    gl.bindVertexArray(null);

    // Build vertex ID → original input index mapping
    const vertexIdToOriginal = new Map<number, number>();
    for (let i = 0; i < count; i++) {
      vertexIdToOriginal.set(vertexIds[i], i);
    }

    // Build original input index → sorted index mapping
    // order[sortedIdx] = originalIdx, so invert it
    const originalToSorted = new Uint32Array(count);
    for (let i = 0; i < count; i++) {
      originalToSorted[order[i]] = i;
    }

    // Build vertex ID → sorted index mapping (used by backend-driven neighbor highlighting)
    const vertexIdToSortedIndex = new Map<number, number>();
    for (let i = 0; i < count; i++) {
      vertexIdToSortedIndex.set(vertexIds[i], originalToSorted[i]);
    }
    this.vertexIdToSortedIndex = vertexIdToSortedIndex;

    // Remap edges from vertex IDs to sorted indices
    const lineIndices = new Uint32Array(edgeCount * 2);
    let validEdges = 0;
    for (let i = 0; i < edgeCount; i++) {
      const srcId = edges[i * 2];
      const dstId = edges[i * 2 + 1];
      const srcOrig = vertexIdToOriginal.get(srcId);
      const dstOrig = vertexIdToOriginal.get(dstId);
      if (srcOrig === undefined || dstOrig === undefined) continue;
      lineIndices[validEdges * 2] = originalToSorted[srcOrig];
      lineIndices[validEdges * 2 + 1] = originalToSorted[dstOrig];
      validEdges++;
    }
    this.edgeCount = validEdges;

    // Build per-leaf edge index for efficient visible-only edge drawing
    // Find which leaf each sorted index belongs to
    const nodeToLeaf = new Uint32Array(count);
    for (let li = 0; li < leaves.length; li++) {
      const lf = leaves[li];
      for (let j = lf.start; j < lf.end; j++) {
        nodeToLeaf[j] = li;
      }
    }

    // Count edges per leaf (by source node)
    const leafEdgeCounts = new Uint32Array(leaves.length);
    for (let i = 0; i < validEdges; i++) {
      leafEdgeCounts[nodeToLeaf[lineIndices[i * 2]]]++;
    }

    // Compute prefix sums for edge offsets per leaf
    const leafEdgeOffsets = new Uint32Array(leaves.length);
    for (let i = 1; i < leaves.length; i++) {
      leafEdgeOffsets[i] = leafEdgeOffsets[i - 1] + leafEdgeCounts[i - 1];
    }

    // Sort edges by source leaf into a new buffer
    const sortedEdgeIndices = new Uint32Array(validEdges * 2);
    const leafEdgeCurrent = new Uint32Array(leaves.length);
    for (let i = 0; i < validEdges; i++) {
      const leafIdx = nodeToLeaf[lineIndices[i * 2]];
      const pos = leafEdgeOffsets[leafIdx] + leafEdgeCurrent[leafIdx];
      sortedEdgeIndices[pos * 2] = lineIndices[i * 2];
      sortedEdgeIndices[pos * 2 + 1] = lineIndices[i * 2 + 1];
      leafEdgeCurrent[leafIdx]++;
    }

    this.leafEdgeStarts = leafEdgeOffsets;
    this.leafEdgeCounts = leafEdgeCounts;

    // Upload sorted edges to GPU
    gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.linesIbo);
    gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, sortedEdgeIndices, gl.STATIC_DRAW);
    gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);

    return performance.now() - t0;
  }

  /* ── Camera ───────────────────────────────────────────── */

  pan(dx: number, dy: number): void {
    // dx, dy in CSS pixels; convert to world units
    const dpr = window.devicePixelRatio || 1;
    this.cx -= (dx * dpr) / this.zoom;
    this.cy -= (dy * dpr) / this.zoom;
  }

  zoomAt(factor: number, screenX: number, screenY: number): void {
    const dpr = window.devicePixelRatio || 1;
    const px = screenX * dpr;
    const py = screenY * dpr;

    // World position under cursor before zoom
    const wx = this.cx + (px - this.canvas.width / 2) / this.zoom;
    const wy = this.cy + (py - this.canvas.height / 2) / this.zoom;

    this.zoom *= factor;
    this.zoom = Math.max(1e-4, Math.min(1e7, this.zoom));

    // Adjust pan so the same world point stays under cursor
    this.cx = wx - (px - this.canvas.width / 2) / this.zoom;
    this.cy = wy - (py - this.canvas.height / 2) / this.zoom;
  }

  getZoom(): number {
    return this.zoom;
  }

  getNodeCount(): number {
    return this.nodeCount;
  }

  /**
   * Map a sorted buffer index (what findNodeIndexAt returns) back to the original
   * index in the input arrays used to initialize the renderer.
   */
  sortedIndexToOriginalIndex(sortedIndex: number): number | null {
    if (
      sortedIndex < 0 ||
      sortedIndex >= this.sortedToOriginal.length
    ) {
      return null;
    }
    return this.sortedToOriginal[sortedIndex];
  }

  /**
   * Convert a backend node ID (node.id from /api/graph) to a sorted index used by the renderer.
   */
  vertexIdToSortedIndexOrNull(vertexId: number): number | null {
    const idx = this.vertexIdToSortedIndex.get(vertexId);
    return typeof idx === "number" ? idx : null;
  }

  /**
   * Convert screen coordinates (CSS pixels) to world coordinates.
   */
  screenToWorld(screenX: number, screenY: number): { x: number; y: number } {
    const dpr = window.devicePixelRatio || 1;
    const px = screenX * dpr;
    const py = screenY * dpr;
    const wx = this.cx + (px - this.canvas.width / 2) / this.zoom;
    const wy = this.cy + (py - this.canvas.height / 2) / this.zoom;
    return { x: wx, y: wy };
  }

  /**
   * Find the node closest to the given screen position.
   * Uses the quadtree to narrow down the search.
   * Returns the node's world coordinates if found within the visual radius, or null.
   */
  findNodeAt(screenX: number, screenY: number): { x: number; y: number } | null {
    const result = this.findNodeIndexAt(screenX, screenY);
    return result ? { x: result.x, y: result.y } : null;
  }

  /**
   * Find the node closest to the given screen position.
   * Returns the node's index and world coordinates if found, or null.
   */
  findNodeIndexAt(screenX: number, screenY: number): { index: number; x: number; y: number } | null {
    if (this.sorted.length === 0) return null;

    const world = this.screenToWorld(screenX, screenY);
    const wx = world.x;
    const wy = world.y;

    // Calculate the search radius in world units (based on point size on screen)
    // We use a slightly larger radius for easier hovering
    const dpr = window.devicePixelRatio || 1;
    const ptSizeScreen = Math.max(1.0, Math.min(this.maxPtSize, WORLD_RADIUS * 2 * this.zoom));
    const hitRadius = (ptSizeScreen / this.zoom / dpr) * 0.75; // world units
    const hitRadiusSq = hitRadius * hitRadius;

    let closestDist = Infinity;
    let closestIndex = -1;
    let closestX = 0;
    let closestY = 0;

    // Traverse all leaves and check if they intersect with the hit area
    for (let i = 0; i < this.leaves.length; i++) {
      const lf = this.leaves[i];

      // Quick AABB check: does this leaf possibly contain points near our target?
      if (
        wx + hitRadius < lf.minX ||
        wx - hitRadius > lf.maxX ||
        wy + hitRadius < lf.minY ||
        wy - hitRadius > lf.maxY
      ) {
        continue; // Leaf is too far away
      }

      // Check all points in this leaf
      for (let j = lf.start; j < lf.end; j++) {
        const px = this.sorted[j * 2];
        const py = this.sorted[j * 2 + 1];
        const dx = px - wx;
        const dy = py - wy;
        const distSq = dx * dx + dy * dy;

        if (distSq < hitRadiusSq && distSq < closestDist) {
          closestDist = distSq;
          closestIndex = j;
          closestX = px;
          closestY = py;
        }
      }
    }

    return closestIndex >= 0 ? { index: closestIndex, x: closestX, y: closestY } : null;
  }

  /**
   * Update the selection buffer with the given set of node indices.
   * Neighbor indices are provided by the backend (SPARQL query) and uploaded separately.
   * Call this whenever selection or backend neighbor results change.
   */
  updateSelection(selectedIndices: Set<number>, neighborIndices: Set<number> = new Set()): void {
    const gl = this.gl;

    // Upload selected indices
    const indices = new Uint32Array(selectedIndices);
    this.selectionCount = indices.length;
    gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.selectionIbo);
    gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.DYNAMIC_DRAW);
    gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);

    // Upload neighbor indices
    const neighborIndexArray = new Uint32Array(neighborIndices);
    this.neighborCount = neighborIndexArray.length;
    gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.neighborIbo);
    gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, neighborIndexArray, gl.DYNAMIC_DRAW);
    gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
  }

  /**
   * Get the coordinates of a node by its index.
   */
  getNodeCoords(index: number): { x: number; y: number } | null {
    if (index < 0 || index * 2 + 1 >= this.sorted.length) return null;
    return {
      x: this.sorted[index * 2],
      y: this.sorted[index * 2 + 1],
    };
  }

  /* ── Render ───────────────────────────────────────────── */

  render(): RenderStats {
    const gl = this.gl;
    const canvas = this.canvas;

    // Resize
    const dpr = window.devicePixelRatio || 1;
    const cw = (canvas.clientWidth * dpr) | 0;
    const ch = (canvas.clientHeight * dpr) | 0;
    if (canvas.width !== cw || canvas.height !== ch) {
      canvas.width = cw;
      canvas.height = ch;
    }

    gl.viewport(0, 0, cw, ch);
    gl.clear(gl.COLOR_BUFFER_BIT);
    gl.bindVertexArray(this.vao);

    // Uniforms: transform world coords to NDC
    gl.useProgram(this.program);
    gl.uniform2f(this.uCenter, this.cx, this.cy);
    gl.uniform2f(
      this.uScale,
      (this.zoom * 2) / cw,
      (-this.zoom * 2) / ch
    );

    // Point size: world diameter → screen pixels, clamped
    const ptSize = Math.max(
      1.0,
      Math.min(this.maxPtSize, WORLD_RADIUS * 2 * this.zoom)
    );
    gl.uniform1f(this.uPtSize, ptSize);

    // Frustum bounding box
    const viewW = cw / this.zoom;
    const viewH = ch / this.zoom;
    const vMinX = this.cx - viewW / 2;
    const vMaxX = this.cx + viewW / 2;
    const vMinY = this.cy - viewH / 2;
    const vMaxY = this.cy + viewH / 2;

    let visibleCount = 0;
    let totalVisibleParticles = 0;

    // 1. Find all visible leaves and total particles inside frustum
    for (let i = 0; i < this.leaves.length; i++) {
      const lf = this.leaves[i];
      if (
        lf.maxX < vMinX ||
        lf.minX > vMaxX ||
        lf.maxY < vMinY ||
        lf.minY > vMaxY
      )
        continue;

      this.visibleLeafIndices[visibleCount++] = i;
      totalVisibleParticles += (lf.end - lf.start);
    }

    // 2. Calculate dynamic sampling ratio based ONLY on visible particles
    const ratio = Math.min(1.0, MAX_DRAW / Math.max(1, totalVisibleParticles));

    let drawnCount = 0;

    // 3. Prepare index/count arrays for drawing
    for (let i = 0; i < visibleCount; i++) {
      const leafIdx = this.visibleLeafIndices[i];
      const lf = this.leaves[leafIdx];
      const leafTotal = lf.end - lf.start;

      // Since the leaf is randomly unordered internally, taking the first N points
      // is a perfect uniform spatial sample of this leaf.
      const drawCount = Math.max(1, Math.floor(leafTotal * ratio));

      this.startsArray[i] = lf.start;
      this.countsArray[i] = drawCount;
      drawnCount += drawCount;
    }

    // 4. Draw Points!
    if (visibleCount > 0) {
      if (this.multiDrawExt) {
        this.multiDrawExt.multiDrawArraysWEBGL(
          gl.POINTS,
          this.startsArray, 0,
          this.countsArray, 0,
          visibleCount
        );
      } else {
        // Fallback: batch contiguous runs to minimize draw calls
        let currentStart = this.startsArray[0];
        let currentCount = this.countsArray[0];
        for (let i = 1; i < visibleCount; i++) {
          if (currentStart + currentCount === this.startsArray[i]) {
            currentCount += this.countsArray[i]; // Merge contiguous
          } else {
            gl.drawArrays(gl.POINTS, currentStart, currentCount);
            currentStart = this.startsArray[i];
            currentCount = this.countsArray[i];
          }
        }
        gl.drawArrays(gl.POINTS, currentStart, currentCount);
      }
    }

    // 5. Draw Lines if deeply zoomed in (< 20k total visible particles)
    if (totalVisibleParticles < 20000 && visibleCount > 0) {
      gl.useProgram(this.lineProgram);
      gl.uniform2f(this.uCenterLine, this.cx, this.cy);
      gl.uniform2f(this.uScaleLine, (this.zoom * 2) / cw, (-this.zoom * 2) / ch);

      gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.linesIbo);

      for (let i = 0; i < visibleCount; i++) {
        const leafIdx = this.visibleLeafIndices[i];
        const edgeCount = this.leafEdgeCounts[leafIdx];
        if (edgeCount === 0) continue;
        // Each edge is 2 indices (1 line segment)
        // Offset is in bytes: edgeStart * 2 (indices per edge) * 4 (bytes per uint32)
        const edgeStart = this.leafEdgeStarts[leafIdx];
        gl.drawElements(gl.LINES, edgeCount * 2, gl.UNSIGNED_INT, edgeStart * 2 * 4);
      }

      gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
    }

    // 6. Draw Neighbor Nodes (yellow) - drawn before selected so selected appears on top
    if (this.neighborCount > 0) {
      gl.useProgram(this.neighborProgram);
      gl.uniform2f(this.uCenterNeighbor, this.cx, this.cy);
      gl.uniform2f(this.uScaleNeighbor, (this.zoom * 2) / cw, (-this.zoom * 2) / ch);
      gl.uniform1f(this.uPtSizeNeighbor, ptSize);

      gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.neighborIbo);
      gl.drawElements(gl.POINTS, this.neighborCount, gl.UNSIGNED_INT, 0);
      gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
    }

    // 7. Draw Selected Nodes on top (orange)
    if (this.selectionCount > 0) {
      gl.useProgram(this.selectedProgram);
      gl.uniform2f(this.uCenterSelected, this.cx, this.cy);
      gl.uniform2f(this.uScaleSelected, (this.zoom * 2) / cw, (-this.zoom * 2) / ch);
      gl.uniform1f(this.uPtSizeSelected, ptSize);

      gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.selectionIbo);
      gl.drawElements(gl.POINTS, this.selectionCount, gl.UNSIGNED_INT, 0);
      gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
    }

    gl.bindVertexArray(null);

    const mode = ratio === 1.0
      ? '100% visible nodes'
      : ((ratio * 100).toFixed(1) + '% of visible nodes');

    return { drawnCount, mode, zoom: this.zoom, ptSize };
  }

  /* ── Helpers ──────────────────────────────────────────── */

  private compileProgram(vSrc: string, fSrc: string): WebGLProgram {
    const gl = this.gl;
    const vs = gl.createShader(gl.VERTEX_SHADER)!;
    gl.shaderSource(vs, vSrc);
    gl.compileShader(vs);
    if (!gl.getShaderParameter(vs, gl.COMPILE_STATUS))
      throw new Error("VS: " + gl.getShaderInfoLog(vs));

    const fs = gl.createShader(gl.FRAGMENT_SHADER)!;
    gl.shaderSource(fs, fSrc);
    gl.compileShader(fs);
    if (!gl.getShaderParameter(fs, gl.COMPILE_STATUS))
      throw new Error("FS: " + gl.getShaderInfoLog(fs));

    const prog = gl.createProgram()!;
    gl.attachShader(prog, vs);
    gl.attachShader(prog, fs);

    // Force a_pos to location 0 before linking so both programs match VAO
    gl.bindAttribLocation(prog, 0, "a_pos");

    gl.linkProgram(prog);
    if (!gl.getProgramParameter(prog, gl.LINK_STATUS))
      throw new Error("Link: " + gl.getProgramInfoLog(prog));

    gl.deleteShader(vs);
    gl.deleteShader(fs);
    return prog;
  }
}
