/**
 * slugText — GPU-resident vector text for the 2D viewer, after Eric Lengyel's
 * Slug algorithm ("GPU-Centered Font Rendering Directly from Glyph Outlines",
 * JCGT 2017; reference shaders at github.com/EricLengyel/Slug — patent
 * dedicated to the public domain, attribution retained here).
 *
 * Replaces the per-string CanvasTexture sprites: TrueType quadratic Bézier
 * outlines are uploaded once per UNIQUE GLYPH into a float data texture, and
 * the fragment shader computes an antialiased winding number per pixel by
 * intersecting horizontal + vertical rays with the glyph's curves (curves
 * pre-sorted into 8 bands per axis to bound the per-pixel work). Text stays
 * razor sharp at any zoom and memory scales with glyph count, not text count.
 *
 * Font: public/fonts/NanumGothic-Regular.ttf (OFL) — TrueType glyf outlines
 * (quadratic only), Latin + full Hangul.
 */
import * as THREE from 'three';
import { parse as parseFont } from 'opentype.js';

const TEX_W = 1024;   // data texture width in texels (RGBA32F)
const BANDS = 8;      // bands per axis per glyph
const PAD = 0.08;     // em padding around glyph quads (AA spill room)

interface Curve { x0: number; y0: number; cx: number; cy: number; x1: number; y1: number }

interface GlyphRec {
  base: number;          // texel index of the glyph header (-1 when no ink, e.g. space)
  advance: number;       // em units
}

/** Default TrueType font URL (host must serve Hangul-capable TTF). */
let defaultFontUrl = '/fonts/NanumGothic-Regular.ttf';

/** Override the default font path before creating CadViewer / Viewer2D. */
export function setDefaultFontUrl(url: string): void {
  defaultFontUrl = url;
  // reset shared engine so the next shared() uses the new URL
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  (SlugTextEngine as any)._shared = null;
}

/** One shared font engine (font fetch + parse happens once). */
export class SlugTextEngine {
  private static _shared: Promise<SlugTextEngine> | null = null;

  static shared(url: string = defaultFontUrl): Promise<SlugTextEngine> {
    if (!this._shared) {
      this._shared = (async () => {
        const buf = await (await fetch(url)).arrayBuffer();
        return new SlugTextEngine(parseFont(buf));
      })();
      // allow a retry on transient fetch failure instead of caching the rejection
      this._shared.catch(() => { (SlugTextEngine as any)._shared = null; });
    }
    return this._shared;
  }

  readonly capHeightEm: number;
  readonly ascentEm: number;
  readonly descentEm: number;

  private font: any;
  private upem: number;
  private glyphs = new Map<number, GlyphRec>();
  private data = new Float32Array(TEX_W * 4 * 64); // grows ×2 as needed
  private used = 0;                                 // texels consumed
  private _texture: THREE.DataTexture | null = null;
  private _texelsUploaded = 0;

  private constructor(font: any) {
    this.font = font;
    this.upem = font.unitsPerEm;
    this.capHeightEm = (font.tables?.os2?.sCapHeight || font.ascender * 0.88) / this.upem;
    this.ascentEm = font.ascender / this.upem;
    this.descentEm = font.descender / this.upem; // negative
  }

  /** Layout width of one line, in em units. */
  measureEm(line: string): number {
    let w = 0;
    for (const ch of line) w += this.ensureGlyph(ch.codePointAt(0)!).advance;
    return w;
  }

  ensureGlyph(cp: number): GlyphRec {
    let rec = this.glyphs.get(cp);
    if (rec) return rec;

    const glyph = this.font.charToGlyph(String.fromCodePoint(cp));
    const advance = (glyph.advanceWidth ?? this.upem * 0.5) / this.upem;
    // getPath(0,0,1) → coordinates scaled to em units but y-DOWN; flip y.
    const path = glyph.getPath(0, 0, 1);
    const curves: Curve[] = [];
    let sx = 0, sy = 0, px = 0, py = 0;
    for (const c of path.commands) {
      switch (c.type) {
        case 'M': px = sx = c.x; py = sy = -c.y; break;
        case 'L': curves.push(lineCurve(px, py, c.x, -c.y)); px = c.x; py = -c.y; break;
        case 'Q': curves.push({ x0: px, y0: py, cx: c.x1, cy: -c.y1, x1: c.x, y1: -c.y }); px = c.x; py = -c.y; break;
        case 'C': { // glyf fonts shouldn't emit cubics; approximate defensively
          const mx = (c.x1 + c.x2) / 2, my = (-c.y1 + -c.y2) / 2;
          curves.push({ x0: px, y0: py, cx: mx, cy: my, x1: c.x, y1: -c.y });
          px = c.x; py = -c.y; break;
        }
        case 'Z': if (px !== sx || py !== sy) curves.push(lineCurve(px, py, sx, sy)); px = sx; py = sy; break;
      }
    }

    if (!curves.length) {
      rec = { base: -1, advance };
      this.glyphs.set(cp, rec);
      return rec;
    }

    // Conservative bbox from all points (control points included).
    let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
    for (const q of curves) {
      x0 = Math.min(x0, q.x0, q.cx, q.x1); x1 = Math.max(x1, q.x0, q.cx, q.x1);
      y0 = Math.min(y0, q.y0, q.cy, q.y1); y1 = Math.max(y1, q.y0, q.cy, q.y1);
    }
    const w = Math.max(x1 - x0, 1e-6), h = Math.max(y1 - y0, 1e-6);

    // Band membership (conservative, via control-point extents).
    const hBands: number[][] = Array.from({ length: BANDS }, () => []);
    const vBands: number[][] = Array.from({ length: BANDS }, () => []);
    curves.forEach((q, i) => {
      const cy0 = Math.min(q.y0, q.cy, q.y1), cy1 = Math.max(q.y0, q.cy, q.y1);
      const cx0 = Math.min(q.x0, q.cx, q.x1), cx1 = Math.max(q.x0, q.cx, q.x1);
      const hb0 = clampBand((cy0 - y0) / h), hb1 = clampBand((cy1 - y0) / h);
      for (let b = hb0; b <= hb1; b++) hBands[b].push(i);
      const vb0 = clampBand((cx0 - x0) / w), vb1 = clampBand((cx1 - x0) / w);
      for (let b = vb0; b <= vb1; b++) vBands[b].push(i);
    });

    // ── Serialize: header(2) + bands(16) + index lists + curves(2/curve) ──
    const listTexels = (l: number[]) => Math.ceil(l.length / 4);
    const idxTexels = hBands.reduce((s, l) => s + listTexels(l), 0)
                    + vBands.reduce((s, l) => s + listTexels(l), 0);
    const total = 2 + BANDS * 2 + idxTexels + curves.length * 2;
    const base = this.alloc(total);
    const d = this.data;
    const put = (t: number, a: number, b: number, c: number, e: number) => {
      d[t * 4] = a; d[t * 4 + 1] = b; d[t * 4 + 2] = c; d[t * 4 + 3] = e;
    };

    const curveBase = base + 2 + BANDS * 2 + idxTexels;
    put(base, x0, y0, x1, y1);
    put(base + 1, base + 2, base + 2 + BANDS, 0, 0); // hBandBase, vBandBase

    let listCursor = base + 2 + BANDS * 2;
    const writeBands = (bands: number[][], texel: number) => {
      for (let b = 0; b < BANDS; b++) {
        put(texel + b, listCursor, bands[b].length, 0, 0);
        const l = bands[b];
        for (let j = 0; j < l.length; j += 4) {
          put(listCursor++,
            curveBase + l[j] * 2,
            j + 1 < l.length ? curveBase + l[j + 1] * 2 : 0,
            j + 2 < l.length ? curveBase + l[j + 2] * 2 : 0,
            j + 3 < l.length ? curveBase + l[j + 3] * 2 : 0);
        }
      }
    };
    writeBands(hBands, base + 2);
    writeBands(vBands, base + 2 + BANDS);

    curves.forEach((q, i) => {
      put(curveBase + i * 2, q.x0, q.y0, q.cx, q.cy);
      put(curveBase + i * 2 + 1, q.x1, q.y1, 0, 0);
    });

    rec = { base, advance };
    this.glyphs.set(cp, rec);
    return rec;
  }

  bboxOf(rec: GlyphRec): [number, number, number, number] {
    const t = rec.base * 4;
    return [this.data[t], this.data[t + 1], this.data[t + 2], this.data[t + 3]];
  }

  private alloc(texels: number): number {
    const need = (this.used + texels) * 4;
    if (need > this.data.length) {
      let cap = this.data.length;
      while (cap < need) cap *= 2;
      const next = new Float32Array(cap);
      next.set(this.data);
      this.data = next;
    }
    const at = this.used;
    this.used += texels;
    return at;
  }

  /** Data texture with all glyph data uploaded (recreated when it grew). */
  texture(): THREE.DataTexture {
    const rows = Math.max(1, Math.ceil(this.used / TEX_W));
    if (!this._texture || this._texelsUploaded < this.used) {
      this._texture?.dispose();
      const buf = new Float32Array(TEX_W * rows * 4);
      buf.set(this.data.subarray(0, Math.min(this.data.length, TEX_W * rows * 4)));
      const tex = new THREE.DataTexture(buf, TEX_W, rows, THREE.RGBAFormat, THREE.FloatType);
      tex.minFilter = THREE.NearestFilter;
      tex.magFilter = THREE.NearestFilter;
      tex.generateMipmaps = false;
      tex.needsUpdate = true;
      this._texture = tex;
      this._texelsUploaded = this.used;
    }
    return this._texture;
  }
}

function lineCurve(x0: number, y0: number, x1: number, y1: number): Curve {
  return { x0, y0, cx: (x0 + x1) / 2, cy: (y0 + y1) / 2, x1, y1 };
}

function clampBand(f: number): number {
  return Math.min(BANDS - 1, Math.max(0, Math.floor(f * BANDS)));
}

// ── Shader (classic syntax; three's GLSL3 prefix maps attribute/varying/gl_FragColor) ──

const VERT = /* glsl */`
  attribute vec2 emuv;
  attribute float gbase;
  attribute vec3 tcolor;
  varying vec2 vEm;
  flat varying int vBase;
  varying vec3 vColor;
  void main() {
    vEm = emuv;
    vBase = int(gbase);
    vColor = tcolor;
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
  }
`;

const FRAG = /* glsl */`
  precision highp float;
  uniform sampler2D dataTex;
  varying vec2 vEm;
  flat varying int vBase;
  varying vec3 vColor;
  layout(location = 0) out vec4 fragOut;

  vec4 T(int i) { return texelFetch(dataTex, ivec2(i % ${TEX_W}, i / ${TEX_W}), 0); }

  // Lengyel's banded winding-number coverage for one ray along +x from p.
  // Curves are fetched via the band's index list; 'swap' mirrors x/y for the
  // vertical (+y) ray, whose winding sign flips (handedness reversal).
  float rayCoverage(vec2 p, float ppem, int bandTexel, bool swap) {
    vec4 band = T(bandTexel);
    int count = int(band.y);
    float cov = 0.0;
    for (int j = 0; j < 256; j++) {
      if (j >= count) break;
      vec4 idx4 = T(int(band.x) + (j >> 2));
      int cb = int(j % 4 == 0 ? idx4.x : j % 4 == 1 ? idx4.y : j % 4 == 2 ? idx4.z : idx4.w);
      vec4 A = T(cb);
      vec2 e = T(cb + 1).xy;
      // Swap the CURVE points before subtracting: p is already in swapped
      // coordinates for the vertical ray ((A.xy - p).yx would be wrong).
      vec2 p1 = (swap ? A.yx : A.xy) - p;
      vec2 p2 = (swap ? A.wz : A.zw) - p;
      vec2 p3 = (swap ? e.yx  : e.xy) - p;

      uint code = (0x2E74u >> ((p1.y > 0.0 ? 2u : 0u) + (p2.y > 0.0 ? 4u : 0u) + (p3.y > 0.0 ? 8u : 0u))) & 3u;
      if (code != 0u) {
        vec2 a = p1 - p2 * 2.0 + p3;
        vec2 b = p1 - p2;
        float t1, t2;
        if (abs(a.y) < 1e-6) {
          float t = p1.y / (2.0 * b.y);
          t1 = t; t2 = t;
        } else {
          float d = sqrt(max(b.y * b.y - a.y * p1.y, 0.0));
          t1 = (b.y - d) / a.y;
          t2 = (b.y + d) / a.y;
        }
        if ((code & 1u) != 0u) {
          float x = (a.x * t1 - b.x * 2.0) * t1 + p1.x;
          cov += clamp(x * ppem + 0.5, 0.0, 1.0);
        }
        if (code > 1u) {
          float x = (a.x * t2 - b.x * 2.0) * t2 + p1.x;
          cov -= clamp(x * ppem + 0.5, 0.0, 1.0);
        }
      }
    }
    return cov;
  }

  void main() {
    vec4 bbox = T(vBase);
    vec4 bases = T(vBase + 1);
    vec2 fw = fwidth(vEm);

    vec2 span = max(bbox.zw - bbox.xy, vec2(1e-6));
    int hb = clamp(int((vEm.y - bbox.y) / span.y * float(${BANDS})), 0, ${BANDS - 1});
    int vb = clamp(int((vEm.x - bbox.x) / span.x * float(${BANDS})), 0, ${BANDS - 1});

    float covH =  rayCoverage(vEm,    1.0 / max(fw.x, 1e-9), int(bases.x) + hb, false);
    float covV = -rayCoverage(vEm.yx, 1.0 / max(fw.y, 1e-9), int(bases.y) + vb, true);
    float alpha = clamp((covH + covV) * 0.5, 0.0, 1.0);
    if (alpha < 0.004) discard;
    fragOut = vec4(vColor, alpha);
  }
`;

/** Accumulates positioned strings and builds one merged mesh (single draw call). */
export class SlugTextBatch {
  private pos: number[] = [];
  private emuv: number[] = [];
  private gbase: number[] = [];
  private color: number[] = [];
  private index: number[] = [];

  constructor(private engine: SlugTextEngine) {}

  /**
   * alignH: 0/3/5=left datum, 1/4=center, 2=right.
   * alignV: 0=baseline, 1=bottom, 2=middle, 3=top (cap-height datum, matching
   * the previous canvas-sprite behaviour). Multi-line via '\n'.
   */
  add(text: string, pos: { x: number; y: number }, height: number, rotation: number,
      colorInt: number, alignH: number, alignV: number): void {
    const eng = this.engine;
    const lines = String(text).split('\n');
    const scale = height / eng.capHeightEm;
    const lineStep = height * 5 / 3; // CAD default MTEXT line spacing
    const blockH = (lines.length - 1) * lineStep + height;

    // DXF group 72 = 4 ("Middle") centers both axes on the alignment point;
    // group 73 (vertical) is ignored. alignH already centers horizontally (below);
    // force vertical to middle here too, else vA=0 text (block-internal plain
    // TEXT, e.g. titleblock labels) renders half a line too high.
    if (alignH === 4) alignV = 2;

    let baseline0: number;
    if (alignV === 3)      baseline0 = -height;               // top datum
    else if (alignV === 2) baseline0 = blockH / 2 - height;   // middle
    else                   baseline0 = (lines.length - 1) * lineStep; // baseline/bottom

    const cos = Math.cos(rotation), sin = Math.sin(rotation);
    const r = ((colorInt >> 16) & 0xFF) / 255, g = ((colorInt >> 8) & 0xFF) / 255, b = (colorInt & 0xFF) / 255;

    for (let li = 0; li < lines.length; li++) {
      const line = lines[li];
      if (!line) continue;
      const wEm = eng.measureEm(line);
      const ox = (alignH === 1 || alignH === 4) ? -wEm * scale / 2 : alignH === 2 ? -wEm * scale : 0;
      const oy = baseline0 - li * lineStep;
      let pen = 0;
      for (const ch of line) {
        const rec = eng.ensureGlyph(ch.codePointAt(0)!);
        if (rec.base >= 0) {
          const [x0, y0, x1, y1] = this.engine.bboxOf(rec);
          const qx0 = x0 - PAD, qy0 = y0 - PAD, qx1 = x1 + PAD, qy1 = y1 + PAD;
          const v = this.pos.length / 3;
          for (const [ex, ey] of [[qx0, qy0], [qx1, qy0], [qx1, qy1], [qx0, qy1]] as const) {
            const lx = ox + (pen + ex) * scale;
            const ly = oy + ey * scale;
            this.pos.push(pos.x + lx * cos - ly * sin, pos.y + lx * sin + ly * cos, 1);
            this.emuv.push(ex, ey);
            this.gbase.push(rec.base);
            this.color.push(r, g, b);
          }
          this.index.push(v, v + 1, v + 2, v, v + 2, v + 3);
        }
        pen += rec.advance;
      }
    }
  }

  /** Build the merged mesh; null when nothing was added. */
  build(): THREE.Mesh | null {
    if (!this.index.length) return null;
    const geo = new THREE.BufferGeometry();
    geo.setAttribute('position', new THREE.Float32BufferAttribute(this.pos, 3));
    geo.setAttribute('emuv', new THREE.Float32BufferAttribute(this.emuv, 2));
    geo.setAttribute('gbase', new THREE.Float32BufferAttribute(this.gbase, 1));
    geo.setAttribute('tcolor', new THREE.Float32BufferAttribute(this.color, 3));
    geo.setIndex(this.index);
    const mat = new THREE.ShaderMaterial({
      glslVersion: THREE.GLSL3,
      vertexShader: VERT,
      fragmentShader: FRAG,
      uniforms: { dataTex: { value: this.engine.texture() } },
      transparent: true,
      depthTest: false,
      depthWrite: false,
      side: THREE.DoubleSide,
    });
    const mesh = new THREE.Mesh(geo, mat);
    mesh.frustumCulled = true;
    return mesh;
  }
}
