#!/usr/bin/env node

import { createWebGpuImports } from "../moui_web_renderer/runtime.js";

globalThis.GPUBufferUsage = { VERTEX: 1, COPY_DST: 2 };
globalThis.GPUTextureUsage = {
  RENDER_ATTACHMENT: 1,
  TEXTURE_BINDING: 2,
  COPY_SRC: 4,
  COPY_DST: 8,
};

class FakeDomElement {
  constructor(tagName) {
    this.tagName = tagName.toUpperCase();
    this.className = "";
    this.style = {};
    this.dataset = {};
    this.children = [];
    this.parentElement = null;
    this.listeners = new Map();
    this.textContent = "";
  }

  appendChild(child) {
    child.remove?.();
    this.children.push(child);
    child.parentElement = this;
    return child;
  }

  remove() {
    if (!this.parentElement) return;
    this.parentElement.children = this.parentElement.children.filter(child => child !== this);
    this.parentElement = null;
  }

  setAttribute() {}

  addEventListener(name, handler) {
    const handlers = this.listeners.get(name) ?? [];
    handlers.push(handler);
    this.listeners.set(name, handlers);
  }

  dispatchEvent(event) {
    for (const handler of this.listeners.get(event.type) ?? []) handler(event);
    return true;
  }
}

class FakeCanvas {
  constructor() {
    this.width = 100;
    this.height = 60;
    this.style = {};
    this.parentElement = null;
    this.listeners = new Map();
  }

  getContext(kind) {
    if (kind === "webgpu") return fakeContext;
    const context = {
      font: "",
      textAlign: "left",
      textBaseline: "alphabetic",
      fillStyle: "white",
      lastText: "",
      clearRect() {
        this.lastText = "";
      },
      fillText(text) {
        this.lastText = `${text ?? ""}`;
      },
      drawImage() {},
      getImageData(_x, _y, width, height) {
        const data = new Uint8ClampedArray(width * height * 4);
        const color = this.lastText.includes("👩‍💻");
        for (let i = 0; i < data.length; i += 4) {
          data[i] = color ? 245 : 255;
          data[i + 1] = color ? 80 : 255;
          data[i + 2] = color ? 20 : 255;
          data[i + 3] = 255;
        }
        return { data };
      },
      measureText(text) {
        return {
          width: Array.from(`${text ?? ""}`).length * 8,
          actualBoundingBoxLeft: 0,
          actualBoundingBoxRight: Array.from(`${text ?? ""}`).length * 8,
          actualBoundingBoxAscent: 12,
          actualBoundingBoxDescent: 4,
        };
      },
    };
    return {
      ...context,
    };
  }

  addEventListener(name, handler) {
    const handlers = this.listeners.get(name) ?? [];
    handlers.push(handler);
    this.listeners.set(name, handlers);
  }

  dispatchEvent(event) {
    for (const handler of this.listeners.get(event.type) ?? []) handler(event);
    return true;
  }
}

const fakeTexture = () => ({
  createView() {
    return {};
  },
  destroy() {},
});

const fakePass = {
  setScissorRect() {},
  setPipeline() {},
  setVertexBuffer() {},
  setBindGroup() {},
  draw() {},
  end() {},
};

const fakeContext = {
  configure() {},
  getCurrentTexture() {
    return fakeTexture();
  },
};

const shaderSources = [];
const uploadedBuffers = [];
const bindGroupEntryCounts = [];
const copiedImageSources = [];
const fakePipeline = {
  getBindGroupLayout() {
    return {};
  },
};

const fakeDevice = {
  createShaderModule({ code }) {
    shaderSources.push(code);
    return {};
  },
  createRenderPipeline() {
    return fakePipeline;
  },
  createSampler() {
    return {};
  },
  createTexture() {
    return fakeTexture();
  },
  createBindGroup(descriptor) {
    bindGroupEntryCounts.push(descriptor?.entries?.length ?? 0);
    return {};
  },
  createBuffer(descriptor) {
    return { descriptor };
  },
  createCommandEncoder() {
    return {
      beginRenderPass() {
        return fakePass;
      },
      copyTextureToTexture() {},
      finish() {
        return {};
      },
    };
  },
  queue: {
    writeBuffer(_buffer, _offset, data) {
      uploadedBuffers.push(Array.from(data));
    },
    writeTexture() {},
    copyExternalImageToTexture(source) {
      copiedImageSources.push(source?.source);
    },
    submit() {},
  },
};

const canvas = new FakeCanvas();
const canvasHost = new FakeDomElement("div");
canvasHost.appendChild(canvas);
globalThis.HTMLCanvasElement = FakeCanvas;
globalThis.document = {
  createElement(tag) {
    return tag === "canvas" ? new FakeCanvas() : new FakeDomElement(tag);
  },
  getElementById(id) {
    return id === "radial-canvas" ? canvas : null;
  },
  body: new FakeDomElement("body"),
};
globalThis.window = { devicePixelRatio: 1 };
class FakeImage {
  constructor() {
    this.complete = false;
    this.naturalWidth = 32;
    this.naturalHeight = 18;
    this.width = 32;
    this.height = 18;
  }

  set src(value) {
    this.value = value;
    this.complete = true;
    queueMicrotask(() => this.onload?.());
  }
}
globalThis.Image = FakeImage;
globalThis.WheelEvent = class {
  constructor(type, options) {
    this.type = type;
    Object.assign(this, options);
  }
};

const assert = (condition, message) => {
  if (!condition) {
    console.error(message);
    process.exit(1);
  }
};

const assertNear = (actual, expected, message) => {
  assert(
    Math.abs(Number(actual) - Number(expected)) < 0.0001,
    `${message}: expected ${expected}, got ${actual}`,
  );
};

const observationEvents = [];
globalThis.__mouiWebRuntimeObservation = {
  recordEvent(event) {
    observationEvents.push(event);
  },
};

const imports = createWebGpuImports({
  device: fakeDevice,
  format: "rgba8unorm",
  textSelection: { enabled: true },
});

const stringHandle = value => {
  const handle = imports.begin_create_string();
  for (const ch of value) imports.string_append_char(handle, ch.codePointAt(0));
  return imports.finish_create_string(handle);
};

const canvasIdHandle = stringHandle("radial-canvas");
const surface = imports.create_surface(canvasIdHandle, 100, 60, 1);
assert(surface > 0, "expected fake WebGPU surface");
assert(
  imports.create_surface(canvasIdHandle, 100, 60, 1) === 0,
  "WebGPU string handles should be consumed after one host call",
);
const renderer = imports.create_renderer(surface);
assert(renderer > 0, "expected fake WebGPU renderer");

assert(
  shaderSources.some(source => source.includes("radial_gradient_color")),
  "visual shader must include radial_gradient_color",
);
assert(
  shaderSources.some(source =>
    source.includes("return brush_color(in.local, in.blurStart.zw, in.end, in.color0, in.color1, in.blurStart.y);"),
  ),
  "visual shader must shade path mode through brush_color",
);
assert(
  shaderSources.some(source => source.includes("in.color.r < -0.5")),
  "text shader must preserve RGBA color glyph atlas payloads",
);

assert(imports.begin_frame(renderer, 100, 60) === 0, "begin_frame failed");
assert(
  imports.fill_rounded_rect_radial(
    renderer,
    10,
    12,
    40,
    24,
    6,
    24,
    24,
    18,
    1,
    0,
    0,
    1,
    0,
    0,
    1,
    1,
  ) === 0,
  "fill_rounded_rect_radial failed",
);
assert(
  imports.stroke_rounded_rect_radial(
    renderer,
    10,
    12,
    40,
    24,
    6,
    3,
    24,
    24,
    18,
    1,
    0,
    0,
    1,
    0,
    0,
    1,
    1,
  ) === 0,
  "stroke_rounded_rect_radial failed",
);
assert(imports.present(renderer) === 0, "present failed");

const visual = uploadedBuffers.find(buffer => buffer.length === 12 * 22);
assert(visual, "expected one uploaded visual vertex buffer for radial fill/stroke");

assertNear(visual[7], 0, "fill mode");
assertNear(visual[8], 0, "fill stroke width");
assertNear(visual[9], 1, "fill brush kind");
assertNear(visual[10], 14, "fill radial center x");
assertNear(visual[11], 12, "fill radial center y");
assertNear(visual[12], 18, "fill radial radius");
assertNear(visual[13], 0, "fill radial payload reserved y");

const strokeOffset = 6 * 22;
assertNear(visual[strokeOffset + 7], 1, "stroke mode");
assertNear(visual[strokeOffset + 8], 3, "stroke width");
assertNear(visual[strokeOffset + 9], 1, "stroke brush kind");
assertNear(visual[strokeOffset + 10], 14, "stroke radial center x");
assertNear(visual[strokeOffset + 11], 12, "stroke radial center y");
assertNear(visual[strokeOffset + 12], 18, "stroke radial radius");

uploadedBuffers.length = 0;
assert(imports.begin_frame(renderer, 100, 60) === 0, "begin_frame for radial path failed");
const radialPathPayload = [
  10, 10, 1, 20, 20, 16, 0, 1, 0, 0, 1, 0, 0, 1, 1,
  42, 10, 1, 20, 20, 16, 0, 1, 0, 0, 1, 0, 0, 1, 1,
  10, 42, 1, 20, 20, 16, 0, 1, 0, 0, 1, 0, 0, 1, 1,
].join(",");
assert(
  imports.draw_path_mesh(renderer, stringHandle(radialPathPayload)) === 0,
  "draw_path_mesh radial payload failed",
);
assert(imports.present(renderer) === 0, "present radial path failed");
const pathVisual = uploadedBuffers.find(buffer => buffer.length === 3 * 22);
assert(pathVisual, "expected uploaded visual vertex buffer for radial path");
assertNear(pathVisual[2], 10, "path local x");
assertNear(pathVisual[3], 10, "path local y");
assertNear(pathVisual[7], 3, "path mode");
assertNear(pathVisual[9], 1, "path radial brush kind");
assertNear(pathVisual[10], 20, "path radial center x");
assertNear(pathVisual[11], 20, "path radial center y");
assertNear(pathVisual[12], 16, "path radial radius");
assertNear(pathVisual[18], 0, "path edge red");
assertNear(pathVisual[19], 0, "path edge green");
assertNear(pathVisual[20], 1, "path edge blue");

uploadedBuffers.length = 0;
assert(imports.begin_frame(renderer, 100, 60) === 0, "begin_frame for malformed path failed");
const malformedPathValues = radialPathPayload.split(",");
malformedPathValues[17] = "NaN";
assert(
  imports.draw_path_mesh(renderer, stringHandle(malformedPathValues.join(","))) === 0,
  "draw_path_mesh malformed payload should be ignored without failing",
);
assert(imports.present(renderer) === 0, "present malformed path failed");
assert(
  !uploadedBuffers.some(buffer => buffer.length === 3 * 22),
  "malformed path payload must not upload misaligned path vertices",
);

uploadedBuffers.length = 0;
assert(imports.begin_frame(renderer, 100, 60) === 0, "begin_frame for partial triangle path failed");
assert(
  imports.draw_path_mesh(
    renderer,
    stringHandle([
      radialPathPayload,
      [
        42, 42, 1, 20, 20, 16, 0, 1, 0, 0, 1, 0, 0, 1, 1,
      ].join(","),
    ].join(",")),
  ) === 0,
  "draw_path_mesh partial triangle payload should be ignored without failing",
);
assert(imports.present(renderer) === 0, "present partial triangle path failed");
assert(
  !uploadedBuffers.some(buffer => buffer.length === 4 * 22),
  "partial triangle path payload must not upload non-triangle vertices",
);

uploadedBuffers.length = 0;
bindGroupEntryCounts.length = 0;
assert(imports.begin_frame(renderer, 100, 60) === 0, "begin_frame for clipped filter failed");
assert(imports.push_clip(renderer, 4, 6, 80, 42) === 0, "push_clip failed");
assert(imports.push_filter(renderer, 1, 1.08, stringHandle("")) === 0, "push_filter failed");
assert(
  imports.fill_rounded_rect(renderer, 12, 14, 32, 20, 6, 0, 0.8, 0.9, 1) === 0,
  "fill_rounded_rect inside clipped filter failed",
);
assert(imports.pop_filter(renderer) === 0, "pop_filter should composite a clipped unmasked filter");
assert(imports.pop_clip(renderer) === 0, "pop_clip failed");
assert(imports.present(renderer) === 0, "present for clipped filter failed");
assert(
  uploadedBuffers.some(buffer => buffer.length > 0),
  "expected clipped filter render path to upload vertices",
);
assert(
  bindGroupEntryCounts.some(count => count === 3),
  "clipped unmasked filter should create an advanced backdrop bind group",
);

uploadedBuffers.length = 0;
bindGroupEntryCounts.length = 0;
assert(imports.begin_frame(renderer, 100, 60) === 0, "begin_frame for overlay layer failed");
assert(
  imports.push_layer(renderer, 0.75, 3, 0, 0, 0, 0, 0, 0, true) === 0,
  "push_layer overlay failed",
);
assert(
  imports.fill_rounded_rect(renderer, 20, 18, 34, 24, 8, 1, 0.2, 0.1, 1) === 0,
  "fill_rounded_rect inside overlay layer failed",
);
assert(imports.pop_layer(renderer) === 0, "pop_layer overlay failed");
assert(imports.present(renderer) === 0, "present for overlay layer failed");
assert(
  bindGroupEntryCounts.some(count => count === 2),
  "overlay snapshot copy should use a two-entry bind group",
);
assert(
  bindGroupEntryCounts.some(count => count === 3),
  "overlay advanced composite should use a three-entry bind group",
);

uploadedBuffers.length = 0;
observationEvents.length = 0;
assert(imports.begin_frame(renderer, 100, 60) === 0, "begin_frame for text smoke failed");
assert(
  imports.draw_text(
    renderer,
    stringHandle("👩‍💻"),
    2,
    2,
    40,
    24,
    stringHandle("system-ui"),
    stringHandle("normal"),
    18,
    500,
    0,
    0,
    0,
    1,
    0,
  ) === 0,
  "draw_text emoji smoke failed",
);
assert(
  imports.draw_text(
    renderer,
    stringHandle("ABC אבג 123"),
    2,
    28,
    90,
    18,
    stringHandle("system-ui"),
    stringHandle("normal"),
    14,
    500,
    0,
    0,
    0,
    1,
    0,
  ) === 0,
  "draw_text bidi smoke failed",
);
for (let index = 1; index <= 3; index += 1) {
  assert(
    imports.draw_text(
      renderer,
      stringHandle(`Smoke wrap line ${index}`),
      2,
      42 + index * 8,
      90,
      14,
      stringHandle("system-ui"),
      stringHandle("normal"),
      12,
      500,
      0,
      0,
      0,
      1,
      0,
    ) === 0,
    `draw_text paragraph smoke ${index} failed`,
  );
}
assert(imports.present(renderer) === 0, "present text smoke failed");
assert(
  observationEvents.some(event =>
    event.name === "text_color_glyph" &&
    event.format === "rgba" &&
    typeof event.fontFamily === "string" &&
    event.fontFamily.length > 0 &&
    Number(event.fontSize) > 0 &&
    typeof event.glyphKey === "string" &&
    event.glyphKey.length > 0 &&
    Number(event.glyphWidth) > 0 &&
    Number(event.highSaturationPixels) >= 8
  ),
  "text smoke must record high-saturation RGBA glyph observation with font/glyph metadata",
);
assert(
  observationEvents.some(event =>
    event.name === "text_grapheme_layout" &&
    event.singleGraphemeCluster === true &&
    event.noInteriorCaret === true
  ),
  "text smoke must record ZWJ single-grapheme observation",
);
assert(
  observationEvents.some(event => event.name === "text_bidi_layout" && event.visualOrderDiffers === true),
  "text smoke must record bidi visual-order observation",
);
assert(
  observationEvents.filter(event => event.name === "text_paragraph_line").length === 3,
  "text smoke must record paragraph line metrics",
);
assert(
  uploadedBuffers.some(buffer => buffer.some(value => value < -0.5)),
  "text vertices must include the color-glyph RGBA atlas sentinel",
);

const imageSource = "data:image/png;base64,image-smoke";
assert(imports.begin_frame(renderer, 100, 60) === 0, "begin image placeholder frame failed");
assert(
  imports.draw_image(
    renderer,
    stringHandle(imageSource),
    4,
    5,
    32,
    18,
    1,
    0,
  ) === 0,
  "draw_image placeholder failed",
);
assert(imports.present(renderer) === 0, "present image placeholder frame failed");
await Promise.resolve();
await Promise.resolve();
assert(imports.begin_frame(renderer, 100, 60) === 0, "begin image ready frame failed");
assert(
  imports.draw_image(
    renderer,
    stringHandle(imageSource),
    4,
    5,
    32,
    18,
    1,
    0,
  ) === 0,
  "draw_image ready failed",
);
assert(imports.present(renderer) === 0, "present image ready frame failed");
assert(
  copiedImageSources.some(source => source instanceof FakeCanvas),
  "async browser images must upload from the prepared staging canvas",
);
assert(
  observationEvents.some(event => event.name === "image_ready_frame"),
  "prepared staging canvas must render on the repaint frame",
);

const textSelectionLayer = canvasHost.children.find(child =>
  child.className === "moui-text-selection-layer"
);
assert(textSelectionLayer, "enabled text selection should create a DOM layer");
const initialSelectionSpans = [...textSelectionLayer.children];
assert(initialSelectionSpans.length > 0, "drawn text should create selectable spans");
assert(imports.begin_frame(renderer, 100, 60) === 0, "begin_frame for selection reuse failed");
assert(
  imports.draw_text(
    renderer,
    stringHandle("👩‍💻"),
    2,
    2,
    40,
    24,
    stringHandle("system-ui"),
    stringHandle("normal"),
    18,
    500,
    0,
    0,
    0,
    1,
    0,
  ) === 0,
  "draw_text for selection reuse failed",
);
assert(imports.present(renderer) === 0, "present for selection reuse failed");
assert(
  textSelectionLayer.children[0] === initialSelectionSpans[0],
  "selection reconciliation must reuse matching spans rather than replacing the layer",
);
assert(
  !textSelectionLayer.listeners.has("pointermove") &&
    !textSelectionLayer.listeners.has("wheel"),
  "selection layer must leave pointer and wheel routing to the host capture router",
);

console.log("webgpu runtime radial brush tests: ok");