/* A small WebGL renderer for the X2's URDF model.

   Deliberately hand-rolled rather than pulled from a 3D library. What this
   needs is narrow - one shader, flat shading, a rigid-body tree of 41 links -
   and the alternative is several hundred kilobytes fetched over the robot's
   own Wi-Fi, from a CDN the robot cannot reach.

   Geometry arrives as uint16 positions quantised inside each mesh's bounding
   box (see build_model.py); the shader expands them back to metres, so the
   4x saving over float32 costs two extra instructions per vertex.

   Normals are computed per-face in the fragment shader from screen-space
   derivatives. That means the vertex buffer carries positions and nothing
   else - no normal buffer, no smoothing groups, no index-order assumptions -
   and flat shading is the honest look for a machined-metal robot anyway.
*/

const VERTEX_SHADER = `
attribute vec3 a_quantised;
uniform mat4 u_viewProjection;
uniform mat4 u_model;
uniform vec3 u_lo;
uniform vec3 u_span;
varying vec3 v_world;
void main() {
  vec3 local = u_lo + (a_quantised / 65535.0) * u_span;
  vec4 world = u_model * vec4(local, 1.0);
  v_world = world.xyz;
  gl_Position = u_viewProjection * world;
}`;

const FRAGMENT_SHADER = `
precision mediump float;
varying vec3 v_world;
uniform vec3 u_colour;
uniform float u_alpha;
void main() {
  // Face normal straight from the derivative of world position across the
  // triangle. No normal attribute needed, and it cannot disagree with the
  // geometry the way a stale baked normal can.
  vec3 normal = normalize(cross(dFdx(v_world), dFdy(v_world)));
  vec3 keyDir = normalize(vec3(0.45, 0.7, 0.85));
  vec3 fillDir = normalize(vec3(-0.6, -0.3, 0.4));
  float key = max(dot(normal, keyDir), 0.0);
  float fill = max(dot(normal, fillDir), 0.0) * 0.35;
  // A little rim light so the silhouette stays readable against a dark panel.
  float rim = pow(1.0 - abs(normal.z), 2.0) * 0.18;
  vec3 shaded = u_colour * (0.30 + 0.72 * key + fill) + rim;
  gl_FragColor = vec4(shaded, u_alpha);
}`;

/* -- Small matrix helpers (column-major, as WebGL expects) ----------------- */

export function identity() {
  return new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
}

export function multiply(a, b) {
  const out = new Float32Array(16);
  for (let col = 0; col < 4; col += 1) {
    for (let row = 0; row < 4; row += 1) {
      out[col * 4 + row] =
        a[row] * b[col * 4] +
        a[4 + row] * b[col * 4 + 1] +
        a[8 + row] * b[col * 4 + 2] +
        a[12 + row] * b[col * 4 + 3];
    }
  }
  return out;
}

/** URDF fixed-axis roll-pitch-yaw: R = Rz(yaw) * Ry(pitch) * Rx(roll). */
export function fromOrigin(xyz, rpy) {
  const [x, y, z] = xyz;
  const [roll, pitch, yaw] = rpy;
  const cr = Math.cos(roll), sr = Math.sin(roll);
  const cp = Math.cos(pitch), sp = Math.sin(pitch);
  const cy = Math.cos(yaw), sy = Math.sin(yaw);

  return new Float32Array([
    cy * cp, sy * cp, -sp, 0,
    cy * sp * sr - sy * cr, sy * sp * sr + cy * cr, cp * sr, 0,
    cy * sp * cr + sy * sr, sy * sp * cr - cy * sr, cp * cr, 0,
    x, y, z, 1,
  ]);
}

/** Rotation of `angle` about an arbitrary unit axis (Rodrigues). */
export function fromAxisAngle(axis, angle) {
  let [x, y, z] = axis;
  const length = Math.hypot(x, y, z) || 1;
  x /= length; y /= length; z /= length;
  const c = Math.cos(angle), s = Math.sin(angle), t = 1 - c;

  return new Float32Array([
    t * x * x + c, t * x * y + s * z, t * x * z - s * y, 0,
    t * x * y - s * z, t * y * y + c, t * y * z + s * x, 0,
    t * x * z + s * y, t * y * z - s * x, t * z * z + c, 0,
    0, 0, 0, 1,
  ]);
}

export function perspective(fovY, aspect, near, far) {
  const f = 1 / Math.tan(fovY / 2);
  return new Float32Array([
    f / aspect, 0, 0, 0,
    0, f, 0, 0,
    0, 0, (far + near) / (near - far), -1,
    0, 0, (2 * far * near) / (near - far), 0,
  ]);
}

export function lookAt(eye, target, up) {
  const zx = eye[0] - target[0], zy = eye[1] - target[1], zz = eye[2] - target[2];
  const zl = Math.hypot(zx, zy, zz) || 1;
  const z = [zx / zl, zy / zl, zz / zl];

  const xx = up[1] * z[2] - up[2] * z[1];
  const xy = up[2] * z[0] - up[0] * z[2];
  const xz = up[0] * z[1] - up[1] * z[0];
  const xl = Math.hypot(xx, xy, xz) || 1;
  const x = [xx / xl, xy / xl, xz / xl];

  const y = [
    z[1] * x[2] - z[2] * x[1],
    z[2] * x[0] - z[0] * x[2],
    z[0] * x[1] - z[1] * x[0],
  ];

  return new Float32Array([
    x[0], y[0], z[0], 0,
    x[1], y[1], z[1], 0,
    x[2], y[2], z[2], 0,
    -(x[0] * eye[0] + x[1] * eye[1] + x[2] * eye[2]),
    -(y[0] * eye[0] + y[1] * eye[1] + y[2] * eye[2]),
    -(z[0] * eye[0] + z[1] * eye[1] + z[2] * eye[2]),
    1,
  ]);
}

/* -- Renderer -------------------------------------------------------------- */

export class RobotModel {
  constructor(canvas) {
    this.canvas = canvas;
    this.gl = canvas.getContext('webgl', {
      antialias: true, alpha: false, depth: true, preserveDrawingBuffer: false,
    });
    if (!this.gl) throw new Error('This browser has no WebGL support.');

    // Flat shading needs dFdx/dFdy, which is an extension in WebGL 1. Without
    // it the shader still links on most drivers but every face comes out
    // uniformly lit, so say so rather than render something misleading.
    this.derivatives = this.gl.getExtension('OES_standard_derivatives');

    this.model = null;
    this.parts = [];
    this.jointAngles = new Map();
    this.jointsByChild = new Map();
    this.linkParent = new Map();
    this.colour = [0.62, 0.66, 0.72];
    this.accent = [0.22, 0.53, 0.90];
    this.highlight = new Set();
    // link name -> [r, g, b]. Anything not listed falls back to this.colour, so
    // a link whose joint reports nothing stays neutral grey rather than
    // rendering as "zero load", which would read as a measurement.
    this.linkColours = new Map();

    this._buildProgram();
  }

  _buildProgram() {
    const gl = this.gl;
    const prefix = this.derivatives ? '#extension GL_OES_standard_derivatives : enable\n' : '';

    const compile = (type, source) => {
      const shader = gl.createShader(type);
      gl.shaderSource(shader, source);
      gl.compileShader(shader);
      if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
        throw new Error(`Shader failed to compile: ${gl.getShaderInfoLog(shader)}`);
      }
      return shader;
    };

    const program = gl.createProgram();
    gl.attachShader(program, compile(gl.VERTEX_SHADER, VERTEX_SHADER));
    gl.attachShader(program, compile(gl.FRAGMENT_SHADER, prefix + FRAGMENT_SHADER));
    gl.linkProgram(program);
    if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
      throw new Error(`Shader failed to link: ${gl.getProgramInfoLog(program)}`);
    }

    this.program = program;
    this.attribs = { quantised: gl.getAttribLocation(program, 'a_quantised') };
    this.uniforms = {
      viewProjection: gl.getUniformLocation(program, 'u_viewProjection'),
      model: gl.getUniformLocation(program, 'u_model'),
      lo: gl.getUniformLocation(program, 'u_lo'),
      span: gl.getUniformLocation(program, 'u_span'),
      colour: gl.getUniformLocation(program, 'u_colour'),
      alpha: gl.getUniformLocation(program, 'u_alpha'),
    };
  }

  /** Upload the baked model. `geometry` is the raw model.bin ArrayBuffer. */
  load(model, geometry) {
    const gl = this.gl;
    this.model = model;
    this.parts = [];

    for (const joint of model.joints) {
      this.jointsByChild.set(joint.child, joint);
      this.linkParent.set(joint.child, joint.parent);
      if (joint.type === 'revolute') this.jointAngles.set(joint.name, 0);
    }

    for (const [linkName, link] of Object.entries(model.links)) {
      for (const mesh of link.meshes) {
        const vertexBuffer = gl.createBuffer();
        gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
        gl.bufferData(gl.ARRAY_BUFFER,
          new Uint16Array(geometry, mesh.vertex_offset, mesh.vertex_count * 3),
          gl.STATIC_DRAW);

        const indexBuffer = gl.createBuffer();
        gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
        const indices = mesh.index_bits === 16
          ? new Uint16Array(geometry, mesh.index_offset, mesh.index_count)
          : new Uint32Array(geometry, mesh.index_offset, mesh.index_count);
        gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW);

        this.parts.push({
          link: linkName,
          vertexBuffer,
          indexBuffer,
          count: mesh.index_count,
          type: mesh.index_bits === 16 ? gl.UNSIGNED_SHORT : gl.UNSIGNED_INT,
          lo: mesh.lo,
          span: mesh.span,
          local: fromOrigin(mesh.origin.xyz, mesh.origin.rpy),
        });
      }
    }

    // 32-bit indices are an extension in WebGL 1. Every mesh here is under
    // 65535 vertices so it should never come up, but a silent wrong-geometry
    // render is worse than a clear failure.
    if (this.parts.some((part) => part.type === gl.UNSIGNED_INT)
        && !gl.getExtension('OES_element_index_uint')) {
      throw new Error('This browser cannot draw 32-bit indices (OES_element_index_uint).');
    }
  }

  setJoint(name, radians) {
    if (this.jointAngles.has(name)) this.jointAngles.set(name, radians);
  }

  setJoints(values) {
    for (const [name, radians] of Object.entries(values)) this.setJoint(name, radians);
  }

  /** World transform for a link, walking up to the root through its joints. */
  worldTransform(linkName) {
    const chain = [];
    let current = linkName;
    // Guarded against a malformed tree: a cycle would otherwise hang the frame.
    for (let depth = 0; current && depth < 64; depth += 1) {
      const joint = this.jointsByChild.get(current);
      if (!joint) break;
      chain.push(joint);
      current = joint.parent;
    }

    let matrix = identity();
    for (const joint of chain) {
      let local = fromOrigin(joint.origin.xyz, joint.origin.rpy);
      if (joint.type === 'revolute') {
        const angle = this.jointAngles.get(joint.name) || 0;
        local = multiply(local, fromAxisAngle(joint.axis, angle));
      }
      matrix = multiply(local, matrix);
    }
    return matrix;
  }

  resize() {
    const gl = this.gl;
    // Cap the backing store at 2x CSS pixels: past that the extra fragments
    // cost real milliseconds on a phone and buy nothing visible.
    const ratio = Math.min(window.devicePixelRatio || 1, 2);
    const width = Math.round(this.canvas.clientWidth * ratio);
    const height = Math.round(this.canvas.clientHeight * ratio);
    if (width && height && (this.canvas.width !== width || this.canvas.height !== height)) {
      this.canvas.width = width;
      this.canvas.height = height;
    }
    gl.viewport(0, 0, this.canvas.width, this.canvas.height);
  }

  render(camera, background = [0.09, 0.10, 0.12]) {
    const gl = this.gl;
    this.resize();

    gl.clearColor(background[0], background[1], background[2], 1);
    gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
    if (!this.model) return;

    gl.enable(gl.DEPTH_TEST);
    // Backface culling stays off: vertex clustering does not preserve winding
    // reliably, so culling by winding punches holes in the model. Depth testing
    // alone is correct here; the cost is shading some faces we then overwrite.
    gl.disable(gl.CULL_FACE);

    const aspect = this.canvas.width / Math.max(1, this.canvas.height);
    const eye = [
      camera.target[0] + camera.distance * Math.cos(camera.pitch) * Math.cos(camera.yaw),
      camera.target[1] + camera.distance * Math.cos(camera.pitch) * Math.sin(camera.yaw),
      camera.target[2] + camera.distance * Math.sin(camera.pitch),
    ];
    const viewProjection = multiply(
      perspective(camera.fov ?? 0.8, aspect, 0.05, 60),
      // Z is up in URDF, so the camera's up vector is Z, not Y.
      lookAt(eye, camera.target, [0, 0, 1]),
    );

    gl.useProgram(this.program);
    gl.uniformMatrix4fv(this.uniforms.viewProjection, false, viewProjection);
    gl.enableVertexAttribArray(this.attribs.quantised);

    const worldCache = new Map();

    for (const part of this.parts) {
      let world = worldCache.get(part.link);
      if (!world) {
        world = this.worldTransform(part.link);
        worldCache.set(part.link, world);
      }

      gl.uniformMatrix4fv(this.uniforms.model, false, multiply(world, part.local));
      gl.uniform3fv(this.uniforms.lo, part.lo);
      gl.uniform3fv(this.uniforms.span, part.span);

      const tinted = this.linkColours.get(part.link);
      const colour = tinted || (this.highlight.has(part.link) ? this.accent : this.colour);
      gl.uniform3fv(this.uniforms.colour, colour);
      gl.uniform1f(this.uniforms.alpha, 1.0);

      gl.bindBuffer(gl.ARRAY_BUFFER, part.vertexBuffer);
      // normalized=false: the shader divides by 65535 itself, because
      // normalising here would also apply to the index-style values and makes
      // the dequantisation harder to follow.
      gl.vertexAttribPointer(this.attribs.quantised, 3, gl.UNSIGNED_SHORT, false, 0, 0);
      gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, part.indexBuffer);
      gl.drawElements(gl.TRIANGLES, part.count, part.type, 0);
    }
  }

  dispose() {
    const gl = this.gl;
    for (const part of this.parts) {
      gl.deleteBuffer(part.vertexBuffer);
      gl.deleteBuffer(part.indexBuffer);
    }
    this.parts = [];
    gl.deleteProgram(this.program);
    // Free the drawing buffer immediately rather than waiting for GC. Browsers
    // cap the number of live WebGL contexts (often 16), and navigating between
    // tabs repeatedly would otherwise start losing them.
    gl.getExtension('WEBGL_lose_context')?.loseContext();
  }
}
