import { SmcErr } from "./errors.js";
import { SmcProgramBuilder } from "./progbuilder.js";
import { hexToRgba } from "./util.js";

export { Smc, UniformType };

const UniformType = {
  Float1: 0,
  Float2: 1,
  Float3: 2,
  Float4: 3,
  Int1: 4,
  Int2: 5,
  Int3: 6,
  Int4: 7,
};

class Smc {
  #canvas;
  #gl;

  // Position array of a "full-screen" quad (encoded as TRIANGLE_STRIP)
  // Ref: https://en.wikipedia.org/wiki/Triangle_strip
  // NOTE: +x,+y is top-right & -x,-y is bottom-left
  #verticesFullscreen = [
    -1.0, 1.0,
    -1.0, -1.0,
    1.0, 1.0,
    1.0, -1.0,
  ];
  #vertices = this.#verticesFullscreen;
  #attributes = new Map();
  #uniforms = new Map();
  #program = null;
  #clearBitFlags;

  #maxFps;
  #minDeltaTimeMs; // in milliseconds
  #prevTimeMs = 0;

  #errorDelegate = (_, error) => { throw error };
  #initDelegate = (_) => { };
  #resizeDelegate = (_) => { };

  constructor(canvas) {
    this.raiseError = this.raiseError.bind(this);
    this.render = this.render.bind(this);
    this.renderLoop = this.renderLoop.bind(this);
    // DEBUG: is this necessary
    this.setAttribute = this.setAttribute.bind(this);
    this.setUniform = this.setUniform.bind(this);

    this.#canvas = canvas;
    this.#gl = Smc.#getWebGlContext(canvas);
    // NOTE: smc.isWebGlSupported() should be queried prior
    if (this.#gl == null)
      throw new Error("Unable to initialize WebGL. Your browser or machine may not support it.");

    // clear the entire depth buffer when this.#gl.clear is called
    this.#gl.clearDepth(1.0);
    this.#clearBitFlags = this.#gl.COLOR_BUFFER_BIT | this.#gl.DEPTH_BUFFER_BIT;
    // set WebGL's render context (number of pixels to draw)
    this.#gl.viewport(0, 0, this.#gl.canvas.width, this.#gl.canvas.height);

    // set defaults
    this.setMaxFps(30);
    this.setClearColor(0., 0., 0., 255.);
  }

  static #getWebGlContext(canvas) {
    try {
      return canvas.getContext("webgl") ?? canvas.getContext("experimental-webgl");
    } catch {
      return null;
    };
  }

  static isWebGlSupported() {
    try {
      const canvas = document.createElement('canvas');
      return !!window.WebGLRenderingContext && Smc.#getWebGlContext(canvas) != null;
    } catch (e) {
      return false;
    }
  }

  onError(delegate) {
    this.#errorDelegate = delegate;
    return this;
  }

  onInit(delegate) {
    this.#initDelegate = delegate;
    return this;
  }

  onResize(delegate) {
    this.#resizeDelegate = delegate;
    return this;
  }

  setClearColorHex(color) {
    color = hexToRgba(color);
    if (color == null) {
      // this.raiseError isn't needed because this should
      // be treated as a "compilation" error not a "runtime" error
      throw new Error(`setClearColorHex expects an RGB/RGBA hex value, got "${color}"`);
    }
    return this.setClearColor(color.r, color.g, color.b, color.a);
  }

  setClearColor(r, g, b, a) {
    this.#gl.clearColor(r / 255., g / 255., b / 255., a / 255.);
    return this;
  }

  setVertices(positions) {
    this.#vertices = positions;
    return this;
  }

  setMaxFps(fps) {
    this.#maxFps = fps;
    this.#minDeltaTimeMs = fps ? 1000 / fps : null;
    return this;
  }

  setProgram(delegate) {
    const builder = new SmcProgramBuilder(this.#gl, this.raiseError);
    var result = delegate(builder);

    this.#program = result.build();
    if (!this.#gl.getProgramParameter(this.#program, this.#gl.LINK_STATUS)) {
      const infoLog = this.#gl.getProgramInfoLog(this.#program);
      this.raiseError(
        SmcErr.PROGRAM_INIT,
        new Error(`Unable to initialize the shader program: ${infoLog}`)
      )
    }

    this.#addAttribute("aVertex", this.#setVerticesAttribute.bind(this));
    // DEBUG: uncomment afterwards
    this.#addUniform("uResolution", UniformType.Float2, false, (_) => new Float32Array([this.#gl.canvas.width, this.#gl.canvas.height]));
    this.#addUniform("uTime", UniformType.Float1);
    this.#addUniform("uDelta", UniformType.Float1);
    return this;
  }

  run() {
    this.#initDelegate()
    this.setAttribute("aVertex", this.#vertices);
    this.setUniform("uResolution", this.#gl.canvas.width, this.#gl.canvas.height);

    if (this.#maxFps == 0)
      requestAnimationFrame(this.render)
    else
      requestAnimationFrame(this.renderLoop);
  }

  // requestAnimationFrame requests the browser to call the renderLoop
  // callback function before the next repaint.
  // `time` is the milliseconds elapsed since the page loaded.
  renderLoop(time) {
    var delta = time - this.#prevTimeMs;
    this.render(time, delta);

    requestAnimationFrame(this.renderLoop);
    // setTimeout(
    //   () => requestAnimationFrame(this.renderLoop),
    //   Math.max(0, delta - this.#minDeltaTimeMs)
    // );
    this.#prevTimeMs = time;
  }

  render(time, delta) {
    this.setUniform("uTime", time * 0.001);
    this.setUniform("uDelta", delta);
    this.#gl.clear(this.#gl.COLOR_BUFFER_BIT | this.#gl.DEPTH_BUFFER_BIT);
    this.#gl.drawArrays(this.#gl.TRIANGLE_STRIP, 0, this.#vertices.length / 2);
  }

  #addAttribute(name, setDelegate, required = false) {
    var location = this.#gl.getAttribLocation(this.#program, name);
    if (location == -1) {
      if (required) {
        this.raiseError(
          SmcErr.ATTRIBUTE_MISSING,
          `Linked program missing required attribute: "${name}"`
        );
      }
      location = null;
    }
    this.#attributes.set(
      name,
      {
        setDelegate: setDelegate,
        location: location,
      });

  }

  #addUniform(name, type, setEachFrame, setCallback, required = false) {
    const location = this.#gl.getUniformLocation(this.#program, name);
    if (location == -1) {
      if (required) {
        this.raiseError(
          SmcErr.UNIFORM_MISSING,
          `Linked program missing required uniform: "${name}"`
        )
      }
      location = null;
    }

    if (type == UniformType.Float1)
      var uniformfv = (...values) => this.#gl.uniform1f(location, ...values);
    else if (type == UniformType.Float2)
      var uniformfv = (...values) => this.#gl.uniform2f(location, ...values);
    else if (type == UniformType.Float3)
      var uniformfv = (...values) => this.#gl.uniform3f(location, ...values);
    else if (type == UniformType.Float4)
      var uniformfv = (...values) => this.#gl.uniform4f(location, ...values);
    else if (type == UniformType.Int1)
      var uniformfv = (...values) => this.#gl.uniform1i(location, ...values);
    else if (type == UniformType.Int2)
      var uniformfv = (...values) => this.#gl.uniform2i(location, ...values);
    else if (type == UniformType.Int3)
      var uniformfv = (...values) => this.#gl.uniform3i(location, ...values);
    else if (type == UniformType.Int4)
      var uniformfv = (...values) => this.#gl.uniform4i(location, ...values);
    else {
      // this.raiseError isn't needed because this should
      // be treated as a "compilation" error not a "runtime" error
      throw new Error(`Expected type from enum UniformType, but got "${type}"`);
    }

    // simplify function call to a single argument
    this.#uniforms.set(
      name,
      {
        setDelegate: uniformfv,
        location: location,
        setEachFrame: setEachFrame,
        setCallback: setCallback,
      }
    );
  }

  #getAttributeLocation(name) {
    return this.#attributes.get(name).location;
  }

  #getUniformLocation(name) {
    return this.#uniforms.get(name).location;
  }

  setAttribute(name, ...args) {
    if (this.#getAttributeLocation(name) != null)
      this.#attributes.get(name).setDelegate(...args);
  }

  setUniform(name, ...args) {
    if (this.#getUniformLocation(name) != null) {
      this.#uniforms.get(name).setDelegate(...args);
    }
  }

  #setVerticesAttribute(vertices) {
    this.#vertices = vertices;

    const buffer = this.#gl.createBuffer();
    this.#gl.bindBuffer(this.#gl.ARRAY_BUFFER, buffer);
    this.#gl.bufferData(
      this.#gl.ARRAY_BUFFER,
      new Float32Array(vertices),
      this.#gl.STATIC_DRAW
    );

    this.#gl.vertexAttribPointer(
      this.#getAttributeLocation("aVertex"),
      2,              // (size) one vertex == 2 floats
      this.#gl.FLOAT, // (type) vertex positions given as 32bit floats
      false,          // (normalized) don't normalize
      0,              // (stride) buffer offset pointer BETWEEN elements (0 => packed)
      0,              // (offset) buffer offset pointer from START to first element
    )
    this.#gl.enableVertexAttribArray(this.#getAttributeLocation("aVertex"));

    return buffer;
  }

  raiseError(type, error) {
    this.#errorDelegate(type, error);
  }
}
