/* =========================================================================
 *  CTWCAD — File preview
 *  ─────────────────────
 *  • CAD kinds (step/stp/stl/iges/3mf/ctws) → procedural 3D mesh, rotating,
 *    rendered with painter's-algorithm SVG. Deterministic per file id.
 *  • PDF kind → simulated drawing-sheet viewer with page chrome.
 *  • Other kinds → tasteful fallback card.
 * =======================================================================*/

const CAD_KINDS = new Set(["step", "stp", "stl", "iges", "3mf", "ctwcad", "ctws", "obj", "gltf", "glb"]);
// online-3d-viewer can load these directly. STEP support requires the wasm
// loader bundled with o3dv@0.13+ and is enabled at runtime by OV.SetExternalLibLocation.
// .ctwcad files aren't directly viewable — the desktop app uploads a STEP
// alongside, exposed at file.previewDownloadURL, which we route through here.
const REAL_VIEWER_KINDS = new Set(["step", "stp", "stl", "3mf", "obj", "gltf", "glb", "iges"]);
// Raster + vector image kinds the browser renders natively via <img>.
const IMAGE_KINDS = new Set(["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp"]);

function FilePreview({ file, height = 280, allowFullscreen = true }) {
  if (!file) return null;
  if (IMAGE_KINDS.has(file.kind) && file.downloadURL) {
    return <ImagePreview file={file} height={height} allowFullscreen={allowFullscreen} />;
  }
  if (file.kind === "pdf") {
    // Real uploaded PDFs come back with a downloadURL — embed the actual
    // file so the user sees real content, not the procedural placeholder.
    if (file.downloadURL) return <RealPDFPreview file={file} height={height} allowFullscreen={allowFullscreen} />;
    return <PDFPreview file={file} height={height} allowFullscreen={allowFullscreen} />;
  }
  if (CAD_KINDS.has(file.kind)) {
    // .ctwcad is the desktop app's native parametric format — the browser
    // can't read it. The desktop app uploads a STEP preview on every save;
    // route through that here. The 3D viewer lazy-loads on first use.
    if (file.kind === "ctwcad" && file.previewDownloadURL) {
      const previewFile = {
        ...file,
        downloadURL: file.previewDownloadURL,
        kind: file.previewKind || "step",
      };
      return <RealCADViewer file={previewFile} height={height} allowFullscreen={allowFullscreen} />;
    }
    // Use the real online-3d-viewer for kinds it can read AND a real
    // download URL. Mock-data files have no URL → procedural placeholder.
    if (file.downloadURL && REAL_VIEWER_KINDS.has(file.kind)) {
      return <RealCADViewer file={file} height={height} allowFullscreen={allowFullscreen} />;
    }
    return <CADViewer file={file} height={height} allowFullscreen={allowFullscreen} />;
  }
  return <GenericPreview file={file} height={height} />;
}

/* =========================================================================
 *  Image preview — native <img> with object-fit:contain so portraits and
 *  landscapes both fit the container without distortion.
 * =======================================================================*/
function ImagePreview({ file, height }) {
  return (
    <div className="ct-preview ct-preview-image"
         style={{ height, background: "var(--ct-bg-elev-2)",
                  display: "flex", alignItems: "center", justifyContent: "center" }}>
      <img
        src={file.downloadURL}
        alt={file.name}
        loading="lazy"
        style={{ maxWidth: "100%", maxHeight: "100%", display: "block" }}
      />
    </div>
  );
}

/* =========================================================================
 *  Real PDF preview — let the browser render the actual document via an
 *  iframe pointed at the Firebase Storage URL. Falls back to a download
 *  link on mobile Safari, which doesn't render PDFs inline reliably.
 * =======================================================================*/
function RealPDFPreview({ file, height }) {
  // Detect mobile Safari — iframe PDF rendering is unreliable there.
  const isMobile = !!window.CTWCAD_IS_MOBILE;
  const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent || "");

  if (isMobile && isIOS) {
    return (
      <div className="ct-preview ct-preview-pdf-mobile"
           style={{ height, background: "var(--ct-bg-elev-2)",
                    display: "flex", flexDirection: "column",
                    alignItems: "center", justifyContent: "center", gap: 12, padding: 16 }}>
        <Icon name="file-text" size={36}/>
        <div style={{ textAlign: "center" }}>
          <div style={{ fontWeight: 600 }}>{file.name}</div>
          <div className="ct-mono ct-dim" style={{ fontSize: 11, marginTop: 4 }}>
            {fmtBytes(file.sizeBytes)} · PDF
          </div>
        </div>
        <a href={file.downloadURL} target="_blank" rel="noopener" className="ct-btn ct-btn-primary">
          <Icon name="external-link" size={13}/>Open PDF
        </a>
      </div>
    );
  }

  return (
    <div className="ct-preview ct-preview-pdf-real"
         style={{ height, background: "var(--ct-bg-elev-2)" }}>
      <iframe
        src={file.downloadURL}
        title={file.name}
        loading="lazy"
        style={{ width: "100%", height: "100%", border: 0, display: "block" }}
      />
    </div>
  );
}

/* =========================================================================
 *  Real CAD viewer — online-3d-viewer
 *  Loads STEP / STL / 3MF / OBJ / GLTF / IGES from a download URL and
 *  renders a real WebGL scene. Drag to orbit, scroll to zoom — all
 *  handled by the embedded viewer.
 * =======================================================================*/
function RealCADViewer({ file, height, allowFullscreen }) {
  const hostRef = useRef(null);
  const [fullscreen, setFullscreen] = useState(false);
  const [err, setErr] = useState(null);
  const [ovReady, setOvReady] = useState(!!window.OV);
  const [loading, setLoading] = useState(true);
  // Loop the brandmark animation while we're still waiting on the model.
  // Each ~3.5s tick re-keys AnimatedBrandmark, which remounts and replays
  // its draw-in choreography. Elapsed counter feeds the ETA line.
  const [loopKey, setLoopKey] = useState(0);
  const [elapsedSec, setElapsedSec] = useState(0);
  const viewerRef = useRef(null);

  // Lazy-load the o3dv library on first mount. If it's already in
  // window.OV (pre-warmed by App after sign-in, or from a prior preview
  // this session), skip straight to setup.
  useEffect(() => {
    if (window.OV) { setOvReady(true); return; }
    let cancelled = false;
    if (typeof window.ensureO3DV === "function") {
      window.ensureO3DV()
        .then(() => { if (!cancelled) setOvReady(true); })
        .catch((e) => { if (!cancelled) setErr("Couldn't load 3D viewer (" + (e?.message || "network") + ")"); });
    } else {
      setErr("3D viewer loader missing");
    }
    return () => { cancelled = true; };
  }, []);

  useEffect(() => {
    if (!ovReady || !hostRef.current || !window.OV) return;
    let cancelled = false;
    setErr(null);
    setLoading(true);
    try {
      window.OV.SetExternalLibLocation("/vendor/online-3d-viewer/0.13.0/libs/");
    } catch {}
    hostRef.current.innerHTML = "";
    // Tell the global LoadingIndicator (top-left brandmark) a part is on
    // its way. The bigger centered brandmark inside this viewer is
    // controlled by the local `loading` state instead.
    const fire = (detail) => window.dispatchEvent(
      new CustomEvent("ctwcad:part-loading", { detail })
    );
    fire({ progress: null });

    // Hold the centered brandmark on screen for at least one full
    // choreography cycle (~3.3s) so it never gets cut off mid-draw on a
    // cached / fast model load. If the model takes longer, we hide the
    // brandmark immediately when onModelLoaded fires. The timing matches
    // AnimatedBrandmark's choreography: outer 2.0s + L delay 1.45 +
    // L 1.0 + dot delay 2.55 + dot 0.7 ≈ 3.25s, plus a 50ms margin.
    const MIN_LOADING_MS = 3300;
    const startedAt = Date.now();
    let minHoldTimer = null;
    const stopLoading = () => {
      if (cancelled) return;
      const elapsed = Date.now() - startedAt;
      const remaining = Math.max(0, MIN_LOADING_MS - elapsed);
      if (remaining > 0) {
        minHoldTimer = setTimeout(() => { if (!cancelled) setLoading(false); }, remaining);
      } else {
        setLoading(false);
      }
    };

    let endTimer = setTimeout(() => {
      fire({ done: true });
      stopLoading();
    }, 30000); // safety cap
    try {
      const viewer = new window.OV.EmbeddedViewer(hostRef.current, {
        backgroundColor: new window.OV.RGBAColor(20, 25, 35, 0),
        defaultColor: new window.OV.RGBColor(180, 195, 215),
        edgeSettings: new window.OV.EdgeSettings(true, new window.OV.RGBColor(40, 50, 60), 1),
        onModelLoaded: () => {
          clearTimeout(endTimer);
          fire({ done: true });
          stopLoading();
        },
      });
      viewerRef.current = viewer;
      viewer.LoadModelFromUrlList([file.downloadURL]);
    } catch (e) {
      clearTimeout(endTimer);
      fire({ done: true });
      if (!cancelled) {
        setErr(e?.message || "Viewer failed to load");
        // Errors don't need to hold the animation — surface immediately
        // so the cover-snapshot fallback shows without delay.
        setLoading(false);
      }
    }
    return () => {
      cancelled = true;
      clearTimeout(endTimer);
      clearTimeout(minHoldTimer);
      fire({ done: true });
      if (viewerRef.current && hostRef.current) hostRef.current.innerHTML = "";
      viewerRef.current = null;
    };
  }, [ovReady, file.downloadURL, file.id]);

  // When the viewer can't render (lib failed to load, CORS-blocked file
  // URL, malformed model, …), fall back to the desktop-app cover snapshot
  // if there is one, then to the procedural CADThumb. Either is more
  // useful than a red wall of text.
  const renderError = () => {
    if (file.coverDownloadURL) {
      return (
        <div className="ct-preview-fallback">
          <img src={file.coverDownloadURL} alt={file.name}
               className="ct-preview-fallback-img" loading="lazy"/>
          <div className="ct-preview-fallback-note ct-mono">
            <Icon name="alert-triangle" size={11}/>
            3D viewer couldn't load — showing the desktop-app snapshot
          </div>
        </div>
      );
    }
    return (
      <div className="ct-preview-fallback">
        <div className="ct-preview-fallback-thumb"><CADThumb file={file} /></div>
        <div className="ct-preview-fallback-note ct-mono">
          <Icon name="alert-triangle" size={11}/>
          3D viewer couldn't load this file — showing a placeholder
        </div>
      </div>
    );
  };

  // Loading overlay sits absolute-positioned ON TOP of the viewer host
  // div, with an OPAQUE background so the o3dv canvas can't peek through
  // while the brandmark draws. When loading ends, AnimatePresence fades
  // the overlay out cleanly — the 3D model reveals only after the
  // brandmark is fully gone, no pop-in.
  const M = window.FramerMotion?.motion;
  const A = window.FramerMotion?.AnimatePresence;
  const showLoading = (loading || !ovReady) && !err;

  // Loop the brandmark + tick the elapsed counter while loading. Both
  // intervals stop the moment loading flips false (so the brandmark
  // doesn't re-render after the model is in).
  useEffect(() => {
    if (!showLoading) { setLoopKey(0); setElapsedSec(0); return; }
    const start = Date.now();
    const elapsedTimer = setInterval(() => {
      setElapsedSec(Math.floor((Date.now() - start) / 1000));
    }, 250);
    // 3500ms ≈ AnimatedBrandmark choreography (3.25s) + a small breath.
    const loopTimer = setInterval(() => setLoopKey(k => k + 1), 3500);
    return () => { clearInterval(elapsedTimer); clearInterval(loopTimer); };
  }, [showLoading]);

  // Rough ETA based on file size. occt-import-js parses STEP at roughly
  // 500 KB/s on a typical desktop, plus ~2s of WASM startup on the
  // first-ever load. Conservative — if we underestimate it just keeps
  // saying "still loading…" past the estimate. Floor at 3s so the
  // estimate isn't lower than the brandmark's own choreography.
  const sizeBytes = file?.sizeBytes || 0;
  const estTotalSec = Math.max(3, Math.round(2 + sizeBytes / (500 * 1024)));
  return (
    <div className={"ct-preview ct-preview-cad" + (fullscreen ? " is-fullscreen" : "")}
         style={{ position: "relative", height: fullscreen ? "100vh" : height, background: "var(--ct-bg)" }}>
      <div ref={hostRef}
           className="ct-preview-cad-host"
           style={{ position: "absolute", inset: 0 }}/>
      {M && A && (
        <A>
          {showLoading && (
            <M.div className="ct-preview-cad-loading"
                   initial={{ opacity: 1 }}
                   animate={{ opacity: 1 }}
                   exit={{ opacity: 0 }}
                   transition={{ duration: 0.45, ease: [0.22, 1, 0.36, 1] }}>
              <div className="ct-preview-cad-loading-stack">
                <AnimatedBrandmark key={loopKey} size={84} />
                <div className="ct-preview-cad-eta ct-mono">
                  <div>Preparing 3D viewer{sizeBytes ? ` · ${fmtBytes(sizeBytes)} part` : ""}</div>
                  <div className="ct-dim">
                    {elapsedSec < estTotalSec
                      ? `${elapsedSec}s elapsed · ~${estTotalSec}s estimated`
                      : `${elapsedSec}s elapsed · still loading…`}
                  </div>
                </div>
              </div>
            </M.div>
          )}
        </A>
      )}
      {err && renderError()}
    </div>
  );
}
window.RealCADViewer = RealCADViewer;

/* =========================================================================
 *  Tiny 3D engine (no deps)
 * =======================================================================*/
const V = {
  sub: (a, b) => [a[0]-b[0], a[1]-b[1], a[2]-b[2]],
  cross: (a, b) => [a[1]*b[2]-a[2]*b[1], a[2]*b[0]-a[0]*b[2], a[0]*b[1]-a[1]*b[0]],
  norm: (a) => { const l = Math.hypot(a[0],a[1],a[2]) || 1; return [a[0]/l,a[1]/l,a[2]/l]; },
  dot: (a, b) => a[0]*b[0]+a[1]*b[1]+a[2]*b[2],
  add: (a, b) => [a[0]+b[0], a[1]+b[1], a[2]+b[2]],
  scale: (a, s) => [a[0]*s, a[1]*s, a[2]*s],
};
function rotY(p, a) { const c=Math.cos(a), s=Math.sin(a); return [c*p[0]+s*p[2], p[1], -s*p[0]+c*p[2]]; }
function rotX(p, a) { const c=Math.cos(a), s=Math.sin(a); return [p[0], c*p[1]-s*p[2], s*p[1]+c*p[2]]; }
function project(p, W, H, scale) {
  // simple perspective
  const z = p[2] + 5;
  const f = scale / z;
  return [W/2 + p[0]*f, H/2 - p[1]*f, p[2]];
}

/* ---------- procedural solids ----------------------------------------- */
// Each builder returns { verts: [[x,y,z]], faces: [[i,j,k,...], ...] }
// Coordinates roughly in -1..1 range. The painter sorts faces by avg z.

function makeSolid(file) {
  const seed = (file.thumbSeed || 1) + file.id.charCodeAt(file.id.length - 1) * 7;
  const rng = mulberry32(seed);
  const kind = file.kind;
  // route by kind + a hash so files of same kind can differ
  const variant = Math.floor(rng() * 4);
  if (kind === "stl" || kind === "3mf") return solidOrganic(rng, variant);
  if (kind === "iges") return solidLofted(rng, variant);
  if (kind === "ctws") return solidPlate(rng, variant);
  // step/stp: chunky mechanical part
  if (variant === 0) return solidBracket(rng);
  if (variant === 1) return solidFlange(rng);
  if (variant === 2) return solidHousing(rng);
  return solidGearLike(rng);
}

/* Bracket: L-shape with mounting holes — concave profile, needs ear-clip */
function solidBracket(rng) {
  const w = 1.4, h = 1.0, d = 0.45, t = 0.18;
  // L profile in xy, extruded along z (CCW winding when viewed from +Z)
  const profile = [
    [-w/2, -h/2], [w/2, -h/2], [w/2, -h/2 + t], [-w/2 + t, -h/2 + t],
    [-w/2 + t, h/2], [-w/2, h/2],
  ];
  return extrudeProfile(profile, d, { bevel: 0.04 });
}

/* Flange: hex prism with center hole */
function solidFlange(rng) {
  const r = 0.85, ri = 0.32, d = 0.32;
  const sides = 6;
  const verts = [], faces = [];
  for (let layer = 0; layer < 2; layer++) {
    const z = layer === 0 ? -d/2 : d/2;
    for (let i = 0; i < sides; i++) {
      const a = (i/sides) * Math.PI*2;
      verts.push([Math.cos(a)*r, Math.sin(a)*r, z]);
    }
  }
  // outer side faces
  for (let i = 0; i < sides; i++) {
    const j = (i+1) % sides;
    faces.push([i, j, sides+j, sides+i]);
  }
  // top/bot rings: triangulate to center, but skip center hole — use ring quads to inner ring
  const innerStart = verts.length;
  for (let layer = 0; layer < 2; layer++) {
    const z = layer === 0 ? -d/2 : d/2;
    for (let i = 0; i < sides; i++) {
      const a = (i/sides) * Math.PI*2;
      verts.push([Math.cos(a)*ri, Math.sin(a)*ri, z]);
    }
  }
  // top ring (outer top -> inner top)
  for (let i = 0; i < sides; i++) {
    const j = (i+1) % sides;
    faces.push([sides+i, sides+j, innerStart+sides+j, innerStart+sides+i]);
    // bottom ring (reverse winding so normals face out)
    faces.push([innerStart+j, innerStart+i, i, j]);
  }
  // inner cylinder (faces inward — render anyway)
  for (let i = 0; i < sides; i++) {
    const j = (i+1) % sides;
    faces.push([innerStart+i, innerStart+j, innerStart+sides+j, innerStart+sides+i]);
  }
  return { verts, faces };
}

/* Housing: rounded box with cutout slot on top */
function solidHousing(rng) {
  const w = 1.2, h = 0.7, d = 0.8;
  const profile = roundedRect(-w/2, -h/2, w, h, 0.12, 6);
  return extrudeProfile(profile, d, { bevel: 0.03 });
}

/* Gear-like: cylinder with notches */
function solidGearLike(rng) {
  const teeth = 12, r1 = 0.85, r2 = 1.0, d = 0.28;
  const profile = [];
  for (let i = 0; i < teeth*2; i++) {
    const a = (i/(teeth*2))*Math.PI*2;
    const r = i%2===0 ? r1 : r2;
    profile.push([Math.cos(a)*r, Math.sin(a)*r]);
  }
  return extrudeProfile(profile, d, { bevel: 0.02 });
}

/* Plate: flat rectangle with bevel + corner holes (procedural) */
function solidPlate(rng, variant) {
  const w = 1.5, h = 1.0, d = 0.12;
  const profile = roundedRect(-w/2, -h/2, w, h, 0.1, 4);
  return extrudeProfile(profile, d, { bevel: 0.025 });
}

/* Lofted curve solid — closed swept ellipse with end caps */
function solidLofted(rng, variant) {
  const segs = 16, sides = 10;
  const verts = [], faces = [];
  for (let s = 0; s <= segs; s++) {
    const t = s/segs;
    const cx = (t-0.5)*1.6;
    const cy = Math.sin(t*Math.PI)*0.5 - 0.1;
    const cz = Math.cos(t*Math.PI*1.2)*0.2;
    const r = 0.15 + 0.18*Math.sin(t*Math.PI);
    for (let k = 0; k < sides; k++) {
      const a = (k/sides)*Math.PI*2;
      verts.push([cx + Math.cos(a)*r, cy + Math.sin(a)*r*0.7, cz + Math.sin(a)*r]);
    }
  }
  for (let s = 0; s < segs; s++) {
    for (let k = 0; k < sides; k++) {
      const a = s*sides + k;
      const b = s*sides + ((k+1)%sides);
      const c = (s+1)*sides + ((k+1)%sides);
      const d = (s+1)*sides + k;
      faces.push([a, b, c, d]);
    }
  }
  // end caps — fan triangulation around the centroid of each end ring
  const startRing = []; for (let k = 0; k < sides; k++) startRing.push(k);
  const endBase = segs * sides;
  const endRing = []; for (let k = 0; k < sides; k++) endRing.push(endBase + k);
  // closing fans (the rings are circular, so a simple fan around index 0 is safe)
  for (let k = 1; k < sides - 1; k++) {
    faces.push([startRing[0], startRing[k+1], startRing[k]]); // reverse winding for start cap
    faces.push([endRing[0], endRing[k], endRing[k+1]]);
  }
  return { verts, faces };
}

/* Organic blob (for STL/3MF) — low-poly faceted egg/dome */
function solidOrganic(rng, variant) {
  const lat = 9, lon = 14;
  const verts = [], faces = [];
  for (let i = 0; i <= lat; i++) {
    const phi = (i/lat)*Math.PI;
    for (let j = 0; j < lon; j++) {
      const th = (j/lon)*Math.PI*2;
      const r = 0.85 + 0.07*Math.sin(phi*3 + th*2);
      const stretch = 1.0 + 0.15*Math.cos(phi*2);
      verts.push([
        Math.sin(phi)*Math.cos(th)*r,
        Math.cos(phi)*r*stretch - 0.1,
        Math.sin(phi)*Math.sin(th)*r,
      ]);
    }
  }
  for (let i = 0; i < lat; i++) {
    for (let j = 0; j < lon; j++) {
      const a = i*lon + j;
      const b = i*lon + ((j+1)%lon);
      const c = (i+1)*lon + ((j+1)%lon);
      const d = (i+1)*lon + j;
      faces.push([a, b, c, d]);
    }
  }
  return { verts, faces };
}

/* ---------- 2D profile helpers --------------------------------------- */
function roundedRect(x, y, w, h, r, segs) {
  const pts = [];
  const add = (cx, cy, a0, a1) => {
    for (let i = 0; i <= segs; i++) {
      const t = i/segs;
      const a = a0 + (a1-a0)*t;
      pts.push([cx + Math.cos(a)*r, cy + Math.sin(a)*r]);
    }
  };
  add(x+w-r, y+h-r, 0, Math.PI/2);
  add(x+r, y+h-r, Math.PI/2, Math.PI);
  add(x+r, y+r, Math.PI, 1.5*Math.PI);
  add(x+w-r, y+r, 1.5*Math.PI, 2*Math.PI);
  return pts;
}

/* extrude a closed 2D profile along z. Caps are ear-clipped so concave
 * profiles (L-shapes etc) tessellate correctly. */
function extrudeProfile(profile, depth, opts = {}) {
  const verts = [], faces = [];
  const n = profile.length;
  const z0 = -depth/2, z1 = depth/2;
  // bottom & top rings
  for (let i = 0; i < n; i++) verts.push([profile[i][0], profile[i][1], z0]);
  for (let i = 0; i < n; i++) verts.push([profile[i][0], profile[i][1], z1]);
  // side quads (CCW so outward normal)
  for (let i = 0; i < n; i++) {
    const j = (i+1)%n;
    faces.push([i, j, n+j, n+i]);
  }
  // ear-clip caps
  const tris = earClip(profile);
  for (const [a, b, c] of tris) {
    // bottom (reverse winding so normal points -Z)
    faces.push([a, c, b]);
    // top (forward winding so normal points +Z)
    faces.push([n+a, n+b, n+c]);
  }
  return { verts, faces };
}

/* Simple ear-clipping triangulation for a 2D polygon (CCW). Handles
 * concave but simple polygons. Returns array of [i,j,k] index triplets. */
function earClip(poly) {
  const n = poly.length;
  if (n < 3) return [];
  // ensure CCW; if CW, reverse indices
  let area = 0;
  for (let i = 0; i < n; i++) {
    const a = poly[i], b = poly[(i+1)%n];
    area += (b[0]-a[0]) * (b[1]+a[1]);
  }
  // shoelace returns negative for CCW in screen coords; in math coords it's positive for CCW
  const ccw = area < 0;
  const idx = [];
  for (let i = 0; i < n; i++) idx.push(ccw ? i : n-1-i);

  const tris = [];
  let guard = 0;
  while (idx.length > 3 && guard++ < 5000) {
    let clipped = false;
    for (let i = 0; i < idx.length; i++) {
      const i0 = idx[(i-1+idx.length)%idx.length];
      const i1 = idx[i];
      const i2 = idx[(i+1)%idx.length];
      const a = poly[i0], b = poly[i1], c = poly[i2];
      // convex? cross > 0 in CCW
      const cross = (b[0]-a[0])*(c[1]-a[1]) - (b[1]-a[1])*(c[0]-a[0]);
      if (cross <= 0) continue;
      // contains any other vertex?
      let contains = false;
      for (let k = 0; k < idx.length; k++) {
        const ki = idx[k];
        if (ki === i0 || ki === i1 || ki === i2) continue;
        if (pointInTri(poly[ki], a, b, c)) { contains = true; break; }
      }
      if (contains) continue;
      tris.push([i0, i1, i2]);
      idx.splice(i, 1);
      clipped = true;
      break;
    }
    if (!clipped) break;
  }
  if (idx.length === 3) tris.push([idx[0], idx[1], idx[2]]);
  return tris;
}

function pointInTri(p, a, b, c) {
  const d = (b[1]-c[1])*(a[0]-c[0]) + (c[0]-b[0])*(a[1]-c[1]);
  if (Math.abs(d) < 1e-9) return false;
  const s = ((b[1]-c[1])*(p[0]-c[0]) + (c[0]-b[0])*(p[1]-c[1])) / d;
  const t = ((c[1]-a[1])*(p[0]-c[0]) + (a[0]-c[0])*(p[1]-c[1])) / d;
  return s > 0 && t > 0 && (1 - s - t) > 0;
}

/* ---------- core viewer ---------------------------------------------- */
function CADViewer({ file, height, allowFullscreen }) {
  const { theme } = useTheme();
  const M = window.FramerMotion?.motion || {};
  const wrapRef = useRef(null);
  const [size, setSize] = useState({ w: 460, h: height });
  const [playing, setPlaying] = useState(true);
  const [fullscreen, setFullscreen] = useState(false);
  const [mode, setMode] = useState("solid"); // solid | wire | xray
  const yawRef = useRef(0.6);
  const pitchRef = useRef(-0.35);
  const dragRef = useRef(null);
  const lastTRef = useRef(performance.now());
  const [, force] = useState(0);
  const tick = useCallback(() => force(n => (n+1) & 0xfff), []);

  const solid = useMemo(() => makeSolid(file), [file.id, file.kind]);

  // resize observer
  useLayoutEffect(() => {
    if (!wrapRef.current) return;
    const ro = new ResizeObserver(() => {
      const r = wrapRef.current.getBoundingClientRect();
      setSize({ w: Math.max(80, r.width), h: Math.max(80, r.height) });
    });
    ro.observe(wrapRef.current);
    return () => ro.disconnect();
  }, [fullscreen]);

  // animation loop
  useEffect(() => {
    let raf = 0;
    const step = (t) => {
      const dt = Math.min(0.05, (t - lastTRef.current)/1000);
      lastTRef.current = t;
      if (playing && !dragRef.current) {
        yawRef.current += dt * 0.5;
      }
      tick();
      raf = requestAnimationFrame(step);
    };
    raf = requestAnimationFrame(step);
    return () => cancelAnimationFrame(raf);
  }, [playing, tick]);

  // drag to orbit
  function onPointerDown(e) {
    e.currentTarget.setPointerCapture(e.pointerId);
    dragRef.current = { x: e.clientX, y: e.clientY, yaw: yawRef.current, pitch: pitchRef.current };
  }
  function onPointerMove(e) {
    if (!dragRef.current) return;
    const dx = e.clientX - dragRef.current.x;
    const dy = e.clientY - dragRef.current.y;
    yawRef.current = dragRef.current.yaw + dx * 0.01;
    pitchRef.current = Math.max(-Math.PI/2 + 0.1, Math.min(Math.PI/2 - 0.1, dragRef.current.pitch + dy * 0.01));
    tick();
  }
  function onPointerUp(e) {
    e.currentTarget.releasePointerCapture(e.pointerId);
    dragRef.current = null;
  }

  // render
  const W = size.w, H = size.h;
  const yaw = yawRef.current, pitch = pitchRef.current;
  const scale = Math.min(W, H) * 1.6;

  // transform verts
  const tverts = solid.verts.map(p => {
    let q = rotY(p, yaw);
    q = rotX(q, pitch);
    return q;
  });
  const projected = tverts.map(p => project(p, W, H, scale));

  // light direction (camera space)
  const L = V.norm([0.4, 0.8, 0.5]);
  const isDark = theme === "dark";
  const baseHue = file.id.charCodeAt(file.id.length-2) % 360;

  const polys = solid.faces.map((face, fi) => {
    const ps = face.map(i => tverts[i]);
    const pp = face.map(i => projected[i]);
    // normal (Newell's method for robustness with N-gons)
    let nx=0, ny=0, nz=0;
    for (let i = 0; i < ps.length; i++) {
      const a = ps[i], b = ps[(i+1)%ps.length];
      nx += (a[1]-b[1])*(a[2]+b[2]);
      ny += (a[2]-b[2])*(a[0]+b[0]);
      nz += (a[0]-b[0])*(a[1]+b[1]);
    }
    const n = V.norm([nx, ny, nz]);
    const facing = n[2]; // toward camera
    const lit = Math.max(0, V.dot(n, L));
    const ambient = 0.28;
    const intensity = ambient + 0.72 * lit;
    const avgZ = ps.reduce((s, p) => s + p[2], 0) / ps.length;
    return { face, pp, n, facing, intensity, avgZ };
  });

  // sort back-to-front (painter's): smaller avgZ (further from camera) drawn first
  // After rotation, +Z points TOWARD the camera, so smaller avgZ = farther.
  polys.sort((a, b) => a.avgZ - b.avgZ);

  // colors
  const accentL = isDark ? 0.62 : 0.55;
  const fillBase = (i) => `oklch(${(0.32 + i*0.55).toFixed(3)} ${(0.04 + i*0.06).toFixed(3)} 230)`;
  const stroke = isDark ? "rgba(220,235,250,0.55)" : "rgba(20,30,45,0.55)";
  const wireStroke = isDark ? "rgba(180,210,240,0.85)" : "rgba(20,30,45,0.85)";
  const bgGrad = isDark
    ? "radial-gradient(ellipse at 30% 20%, oklch(0.22 0.02 240), oklch(0.13 0.01 240) 70%)"
    : "radial-gradient(ellipse at 30% 20%, oklch(0.97 0.005 240), oklch(0.90 0.01 240) 70%)";

  // edge set for clean silhouette in solid mode
  const edges = useMemo(() => extractEdges(solid), [solid]);
  const edgeIsSilhouette = (e) => {
    // an edge is silhouette if its two adjacent faces have opposite signs on normal.z
    if (!e.faces || e.faces.length < 2) return true;
    const f0 = polys.find(p => p.face === solid.faces[e.faces[0]]);
    const f1 = polys.find(p => p.face === solid.faces[e.faces[1]]);
    if (!f0 || !f1) return true;
    return (f0.n[2] >= 0) !== (f1.n[2] >= 0);
  };

  return (
    <div
      ref={wrapRef}
      className={"ct-preview ct-preview-cad " + (fullscreen ? "is-fullscreen" : "")}
      style={{ height: fullscreen ? undefined : height, background: bgGrad }}
      onPointerDown={onPointerDown}
      onPointerMove={onPointerMove}
      onPointerUp={onPointerUp}
      onPointerCancel={onPointerUp}
    >
      {/* grid floor */}
      <svg className="ct-preview-svg" viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none">
        <GridFloor W={W} H={H} yaw={yaw} pitch={pitch} scale={scale} dim={isDark ? "rgba(150,180,210,0.16)" : "rgba(30,50,80,0.18)"} />
        {mode === "solid" && polys.filter(p => p.facing > 0).map((p, i) => (
          <polygon
            key={i}
            points={p.pp.map(q => `${q[0].toFixed(1)},${q[1].toFixed(1)}`).join(" ")}
            fill={fillBase(p.intensity)}
            stroke="none"
            strokeLinejoin="round"
          />
        ))}
        {mode === "xray" && polys.map((p, i) => (
          <polygon
            key={i}
            points={p.pp.map(q => `${q[0].toFixed(1)},${q[1].toFixed(1)}`).join(" ")}
            fill={`oklch(0.6 0.12 ${baseHue} / 0.12)`}
            stroke={wireStroke}
            strokeWidth="0.5"
            strokeLinejoin="round"
          />
        ))}
        {mode === "wire" && edges.map((e, i) => {
          const a = projected[e.a], b = projected[e.b];
          const sil = edgeIsSilhouette(e);
          return (
            <line
              key={i}
              x1={a[0].toFixed(1)} y1={a[1].toFixed(1)}
              x2={b[0].toFixed(1)} y2={b[1].toFixed(1)}
              stroke={wireStroke}
              strokeWidth={sil ? 1.4 : 0.6}
              opacity={sil ? 1 : 0.55}
            />
          );
        })}
        {/* axis gizmo */}
        <AxisGizmo W={W} H={H} yaw={yaw} pitch={pitch} />
      </svg>

      {/* HUD removed — viewer is view-only with auto-rotate + drag-to-orbit */}

      {fullscreen && (
        <button className="ct-preview-close" onClick={() => setFullscreen(false)} aria-label="Close">
          <Icon name="x" size={16}/>
        </button>
      )}
    </div>
  );
}

/* ---------- helpers --------------------------------------------------- */
function extractEdges(solid) {
  const map = new Map();
  solid.faces.forEach((face, fi) => {
    for (let i = 0; i < face.length; i++) {
      const a = face[i], b = face[(i+1)%face.length];
      const key = a < b ? `${a}-${b}` : `${b}-${a}`;
      const ent = map.get(key) || { a: Math.min(a,b), b: Math.max(a,b), faces: [] };
      ent.faces.push(fi);
      map.set(key, ent);
    }
  });
  return Array.from(map.values());
}

function GridFloor({ W, H, yaw, pitch, scale, dim }) {
  // render a grid on y = -1 plane
  const lines = [];
  const N = 8, step = 0.3;
  for (let i = -N; i <= N; i++) {
    const a1 = [i*step, -1, -N*step];
    const a2 = [i*step, -1,  N*step];
    const b1 = [-N*step, -1, i*step];
    const b2 = [ N*step, -1, i*step];
    lines.push([a1, a2]);
    lines.push([b1, b2]);
  }
  return (
    <g opacity="0.65">
      {lines.map(([a,b], i) => {
        const ta = project(rotX(rotY(a, yaw), pitch), W, H, scale);
        const tb = project(rotX(rotY(b, yaw), pitch), W, H, scale);
        return <line key={i} x1={ta[0]} y1={ta[1]} x2={tb[0]} y2={tb[1]} stroke={dim} strokeWidth="0.5" />;
      })}
    </g>
  );
}

function AxisGizmo({ W, H, yaw, pitch }) {
  const o = [W - 38, H - 38];
  const len = 18;
  const axes = [
    { v: [1,0,0], color: "#e35d6a", label: "X" },
    { v: [0,1,0], color: "#5fb763", label: "Y" },
    { v: [0,0,1], color: "#5b8def", label: "Z" },
  ].map(ax => {
    const r = rotX(rotY(ax.v, yaw), pitch);
    return { ...ax, end: [o[0] + r[0]*len, o[1] - r[1]*len], depth: r[2] };
  });
  // draw negatives first
  axes.sort((a,b) => a.depth - b.depth);
  return (
    <g>
      <circle cx={o[0]} cy={o[1]} r="22" fill="rgba(0,0,0,0.18)" stroke="rgba(255,255,255,0.12)" />
      {axes.map((ax, i) => (
        <g key={i}>
          <line x1={o[0]} y1={o[1]} x2={ax.end[0]} y2={ax.end[1]} stroke={ax.color} strokeWidth="1.5" strokeLinecap="round" />
          <circle cx={ax.end[0]} cy={ax.end[1]} r="4" fill={ax.color} />
          <text x={ax.end[0]} y={ax.end[1] + 1} textAnchor="middle" fontSize="6" fontFamily="var(--ct-mono)" fill="white" dominantBaseline="middle">{ax.label}</text>
        </g>
      ))}
    </g>
  );
}

/* =========================================================================
 *  PDF preview — procedural drawing sheet
 * =======================================================================*/
function PDFPreview({ file, height, allowFullscreen }) {
  const { theme } = useTheme();
  const [page, setPage] = useState(1);
  const [fullscreen, setFullscreen] = useState(false);
  const totalPages = 1 + (file.id.charCodeAt(file.id.length-1) % 3); // 1..3

  const dim = theme === "dark" ? "rgba(20,30,45,0.6)" : "rgba(20,30,45,0.6)";
  const ink = "#1a2230";

  return (
    <div
      className={"ct-preview ct-preview-pdf " + (fullscreen ? "is-fullscreen" : "")}
      style={{ height: fullscreen ? undefined : height }}
    >
      <div className="ct-pdf-page-shell">
        <div className="ct-pdf-page" key={page}>
          <DrawingSheet file={file} pageNum={page} ink={ink} dim={dim} />
        </div>
      </div>

      <div className="ct-preview-hud">
        <div className="ct-preview-hud-l">
          <span className="ct-mono ct-dim">PDF · page {page} / {totalPages}</span>
        </div>
      </div>

      {fullscreen && (
        <button className="ct-preview-close" onClick={() => setFullscreen(false)} aria-label="Close">
          <Icon name="x" size={16}/>
        </button>
      )}
    </div>
  );
}

function DrawingSheet({ file, pageNum, ink, dim }) {
  const W = 800, H = 560;
  const seed = (file.thumbSeed || 1) + file.id.charCodeAt(file.id.length - 1) + pageNum * 17;
  const rng = mulberry32(seed);

  // a chunk of orthographic views: top, front, side
  const pad = 36;
  return (
    <svg viewBox={`0 0 ${W} ${H}`} className="ct-pdf-svg" preserveAspectRatio="xMidYMid meet">
      {/* sheet border */}
      <rect x={pad/2} y={pad/2} width={W-pad} height={H-pad} fill="none" stroke={ink} strokeWidth="1.2" />
      <rect x={pad/2 + 8} y={pad/2 + 8} width={W-pad - 16} height={H-pad - 16} fill="none" stroke={dim} strokeWidth="0.5" />

      {/* zone labels */}
      <g fontFamily="var(--ct-mono)" fontSize="8" fill={dim}>
        {[1,2,3,4,5,6].map(i => (
          <text key={"zt"+i} x={pad + (i-1)*((W-pad*2)/6) + ((W-pad*2)/12)} y={pad/2 + 6} textAnchor="middle">{i}</text>
        ))}
        {["A","B","C","D"].map((l, i) => (
          <text key={"zl"+l} x={pad/2 + 6} y={pad + i*((H-pad*2)/4) + ((H-pad*2)/8)} dominantBaseline="middle">{l}</text>
        ))}
      </g>

      {/* three views */}
      <g transform={`translate(${pad+30}, ${pad+10})`}>
        <OrthoView label="TOP" W={220} H={140} ink={ink} dim={dim} kind="top" rng={rng} />
      </g>
      <g transform={`translate(${pad+280}, ${pad+10})`}>
        <OrthoView label="FRONT" W={240} H={140} ink={ink} dim={dim} kind="front" rng={rng} />
      </g>
      <g transform={`translate(${pad+30}, ${pad+180})`}>
        <OrthoView label="SECTION A-A" W={220} H={150} ink={ink} dim={dim} kind="section" rng={rng} />
      </g>

      {/* iso preview */}
      <g transform={`translate(${pad+280}, ${pad+180})`}>
        <rect width="240" height="150" fill="none" stroke={dim} strokeWidth="0.4" />
        <text x="6" y="12" fontFamily="var(--ct-mono)" fontSize="9" fill={dim}>ISO</text>
        <IsoSketch W={240} H={150} ink={ink} dim={dim} rng={rng} />
      </g>

      {/* title block */}
      <g transform={`translate(${W - 250}, ${H - 110})`}>
        <rect width="220" height="80" fill="none" stroke={ink} strokeWidth="1" />
        <line x1="0" y1="20" x2="220" y2="20" stroke={ink} strokeWidth="0.5" />
        <line x1="0" y1="40" x2="220" y2="40" stroke={ink} strokeWidth="0.5" />
        <line x1="0" y1="60" x2="220" y2="60" stroke={ink} strokeWidth="0.5" />
        <line x1="110" y1="20" x2="110" y2="80" stroke={ink} strokeWidth="0.5" />

        <g fontFamily="var(--ct-mono)" fontSize="7" fill={ink}>
          <text x="6" y="13">TITLE</text>
          <text x="6" y="33" fontSize="9">{shortName(file.name)}</text>
          <text x="6" y="53">DRAWN  S. RICKS</text>
          <text x="116" y="53">CHK  —</text>
          <text x="6" y="73">SCALE 1:1</text>
          <text x="116" y="73">REV  {String(pageNum).padStart(2,"0")}</text>
        </g>
      </g>

      {/* page number */}
      <text x={W - pad/2 - 8} y={H - pad/2 - 4} textAnchor="end" fontFamily="var(--ct-mono)" fontSize="8" fill={dim}>
        SHEET {pageNum} OF {pageNum >= 3 ? 3 : 3}
      </text>
    </svg>
  );
}
function shortName(n) { return n.length > 28 ? n.slice(0, 26) + "…" : n; }

function OrthoView({ label, W, H, ink, dim, kind, rng }) {
  const w = W - 50, h = H - 40;
  const cx = W/2, cy = H/2 + 6;
  const sw = w*0.6, sh = h*0.6;
  return (
    <g>
      <rect width={W} height={H} fill="none" stroke={dim} strokeWidth="0.4" />
      <text x="6" y="12" fontFamily="var(--ct-mono)" fontSize="9" fill={dim}>{label}</text>
      <g stroke={ink} strokeWidth="1" fill="none">
        <rect x={cx-sw/2} y={cy-sh/2} width={sw} height={sh} />
        {kind === "top" && (
          <>
            <circle cx={cx-sw/2+12} cy={cy-sh/2+12} r="4" />
            <circle cx={cx+sw/2-12} cy={cy-sh/2+12} r="4" />
            <circle cx={cx-sw/2+12} cy={cy+sh/2-12} r="4" />
            <circle cx={cx+sw/2-12} cy={cy+sh/2-12} r="4" />
          </>
        )}
        {kind === "front" && (
          <>
            <line x1={cx-sw/2} y1={cy} x2={cx+sw/2} y2={cy} strokeDasharray="4 3" />
            <rect x={cx-sw/4} y={cy-sh/4} width={sw/2} height={sh/2} />
          </>
        )}
        {kind === "section" && (
          <>
            <line x1={cx-sw/2} y1={cy-sh/2} x2={cx+sw/2} y2={cy+sh/2} strokeDasharray="2 3" stroke={dim} />
            <line x1={cx-sw/2} y1={cy+sh/2} x2={cx+sw/2} y2={cy-sh/2} strokeDasharray="2 3" stroke={dim} />
            <rect x={cx-sw/3} y={cy-sh/3} width={sw*2/3} height={sh*2/3} />
          </>
        )}
      </g>
      {/* dimension lines */}
      <g stroke={dim} strokeWidth="0.4">
        <line x1={cx-sw/2} y1={cy+sh/2 + 14} x2={cx+sw/2} y2={cy+sh/2 + 14} />
        <line x1={cx-sw/2} y1={cy+sh/2 + 10} x2={cx-sw/2} y2={cy+sh/2 + 18} />
        <line x1={cx+sw/2} y1={cy+sh/2 + 10} x2={cx+sw/2} y2={cy+sh/2 + 18} />
      </g>
      <text x={cx} y={cy+sh/2+12} textAnchor="middle" fontFamily="var(--ct-mono)" fontSize="7" fill={dim}>
        {(60 + Math.floor(rng()*120))}.{Math.floor(rng()*9)} mm
      </text>
    </g>
  );
}
function IsoSketch({ W, H, ink, dim, rng }) {
  const u = 36;
  const sx = W/2 - u, sy = H/2 + 8;
  const k = 0.5;
  return (
    <g stroke={ink} strokeWidth="1" fill="none">
      <path d={`M${sx} ${sy} L${sx+u} ${sy-u*k} L${sx+u*2} ${sy} L${sx+u} ${sy+u*k} z`} />
      <path d={`M${sx} ${sy} L${sx} ${sy+u*0.7} L${sx+u} ${sy+u*k+u*0.7} L${sx+u} ${sy+u*k} z`} />
      <path d={`M${sx+u*2} ${sy} L${sx+u*2} ${sy+u*0.7} L${sx+u} ${sy+u*k+u*0.7} L${sx+u} ${sy+u*k} z`} />
      <ellipse cx={sx+u} cy={sy} rx="10" ry="5" stroke={ink} />
    </g>
  );
}

/* =========================================================================
 *  Generic fallback
 * =======================================================================*/
function GenericPreview({ file, height }) {
  return (
    <div className="ct-preview ct-preview-generic" style={{ height }}>
      <Icon name="file" size={36} />
      <div className="ct-preview-generic-name">{file.name}</div>
      <div className="ct-mono ct-dim">No preview available · download to view</div>
    </div>
  );
}

Object.assign(window, { FilePreview, CADViewer, PDFPreview, ImagePreview, RealPDFPreview });
