"use client";

/* Client-side OMR sheet scanner. Given a photo/scan of one of OUR printed
   sheets (see lib/omr-sheet.ts for the shared geometry):

   1. decode the QR (top-right) → paper + student roll + question set
   2. find the four corner registration squares (fiducials)
   3. least-squares affine fit mm → image pixels using the four fiducials
   4. sample each bubble patch, darkest-of-four per question wins
      (blank when nothing is clearly dark) → "AB-D…" response string

   Works on an ordinary photo: perspective is absorbed by the affine fit as
   long as all four corners are visible and the sheet is reasonably flat. */

import { OMR_PAGE, OMR_FIDUCIAL, omrBubbleCenter, omrFiducialCenters } from "@/lib/omr-sheet";

export interface OmrScanResult {
  qr: string;
  roll: string;
  setLabel: string | null;
  scheduleId: number | null;
  responses: string;
  /** average confidence of the read (0–1, higher = cleaner bubbles) */
  quality: number;
}

interface Gray {
  data: Uint8ClampedArray;
  w: number;
  h: number;
}

function toGray(canvas: HTMLCanvasElement): Gray {
  const ctx = canvas.getContext("2d", { willReadFrequently: true })!;
  const { width: w, height: h } = canvas;
  const px = ctx.getImageData(0, 0, w, h).data;
  const out = new Uint8ClampedArray(w * h);
  for (let i = 0; i < w * h; i++) {
    out[i] = (px[i * 4] * 299 + px[i * 4 + 1] * 587 + px[i * 4 + 2] * 114) / 1000;
  }
  return { data: out, w, h };
}

/** find a corner fiducial square via connected components: threshold the
 *  corner window, flood-fill dark blobs, keep the one that is a solid
 *  square of roughly the expected fiducial size (the QR block is excluded
 *  by its known position, text/bubbles are too thin or too small). */
function findFiducial(g: Gray, corner: "tl" | "tr" | "bl" | "br"): { x: number; y: number } | null {
  const win = 0.26;
  const x0 = corner === "tr" || corner === "br" ? Math.floor(g.w * (1 - win)) : 0;
  const x1 = corner === "tr" || corner === "br" ? g.w : Math.floor(g.w * win);
  const y0 = corner === "bl" || corner === "br" ? Math.floor(g.h * (1 - win)) : 0;
  const y1 = corner === "bl" || corner === "br" ? g.h : Math.floor(g.h * win);

  // threshold: darkest 2% of the window
  const vals: number[] = [];
  for (let y = y0; y < y1; y += 2) {
    for (let x = x0; x < x1; x += 2) vals.push(g.data[y * g.w + x]);
  }
  if (vals.length < 50) return null;
  vals.sort((a, b) => a - b);
  // percentile of the window, but never lighter than a mid-gray: a nearly
  // blank window must not promote paper noise into "dark" pixels
  const thr = Math.min(vals[Math.floor(vals.length * 0.02)], 110);

  // connected components over the dark mask (full resolution, 4-neighbour)
  const seen = new Uint8Array(g.w * g.h);
  const pxPerMmEst = g.h / 297;
  const expArea = Math.pow(8 * pxPerMmEst, 2); // fiducial 8mm square
  // box around the QR only (x 136–158mm, y 10–32mm) — must not cover the corner fiducials
  const qrZone = { x0: g.w * 0.58, y0: 0, x1: g.w * 0.8, y1: g.h * 0.16 };

  const stack: number[] = [];
  let best: { cx: number; cy: number; score: number } | null = null;

  for (let y = y0; y < y1; y++) {
    for (let x = x0; x < x1; x++) {
      const idx = y * g.w + x;
      if (seen[idx] || g.data[idx] > thr) continue;
      // BFS this component
      stack.length = 0;
      stack.push(idx);
      seen[idx] = 1;
      let area = 0;
      let sx = 0;
      let sy = 0;
      let minX = x;
      let maxX = x;
      let minY = y;
      let maxY = y;
      while (stack.length) {
        const cur = stack.pop()!;
        const cyx = Math.floor(cur / g.w);
        const cxx = cur % g.w;
        area++;
        sx += cxx;
        sy += cyx;
        if (cxx < minX) minX = cxx;
        if (cxx > maxX) maxX = cxx;
        if (cyx < minY) minY = cyx;
        if (cyx > maxY) maxY = cyx;
        if (area > expArea * 8) break; // runaway blob (shadows) — abandon
        const nb = [cur - 1, cur + 1, cur - g.w, cur + g.w];
        for (const n of nb) {
          const ny = Math.floor(n / g.w);
          const nx = n % g.w;
          if (nx < x0 || nx >= x1 || ny < y0 || ny >= y1) continue;
          if (n < 0 || n >= g.w * g.h || seen[n] || g.data[n] > thr) continue;
          seen[n] = 1;
          stack.push(n);
        }
      }
      if (area < expArea * 0.25 || area > expArea * 6) continue;
      const bw = maxX - minX + 1;
      const bh = maxY - minY + 1;
      const fill = area / (bw * bh);
      const aspect = bw / bh;
      // a solid square: high fill, near-1 aspect
      if (fill < 0.6 || aspect < 0.6 || aspect > 1.66) continue;
      const cx = sx / area;
      const cy = sy / area;
      // exclude the QR block (top-right zone)
      if (cx > qrZone.x0 && cx < qrZone.x1 && cy > qrZone.y0 && cy < qrZone.y1) continue;
      // prefer the component closest to the actual corner, area as tie-break
      const tx = corner === "tl" || corner === "bl" ? 0 : g.w;
      const ty = corner === "tl" || corner === "tr" ? 0 : g.h;
      const dist = Math.hypot(cx - tx, cy - ty) / g.w; // normalised 0..~0.3
      const score = dist * 6 - Math.min(1, area / expArea); // lower is better
      if (!best || score < best.score) best = { cx, cy, score };
    }
  }
  return best ? { x: best.cx, y: best.cy } : null;
}

/** least-squares affine mm → px from ≥3 point pairs (uses normal equations) */
function fitAffine(
  mm: { x: number; y: number }[],
  px: { x: number; y: number }[]
): (p: { x: number; y: number }) => { x: number; y: number } {
  // A · [a b c d e f]^T solves  x' = a·x + b·y + c,  y' = d·x + e·y + f
  const n = mm.length;
  const S = (i: number, j: number) => mm.reduce((s, p, k) => s + coord(i, p) * coord(j, p), 0);
  const coord = (i: number, p: { x: number; y: number }) => (i === 0 ? p.x : i === 1 ? p.y : 1);
  const rhs = (i: number, out: 0 | 1) => mm.reduce((s, p, k) => s + coord(i, p) * (out === 0 ? px[k].x : px[k].y), 0);

  const A = [
    [S(0, 0), S(0, 1), S(0, 2)],
    [S(1, 0), S(1, 1), S(1, 2)],
    [S(2, 0), S(2, 1), S(2, 2)],
  ];
  const solve = (b: number[]) => {
    const m = A.map((row, i) => [...row, b[i]]);
    for (let col = 0; col < 3; col++) {
      let piv = col;
      for (let r = col + 1; r < 3; r++) if (Math.abs(m[r][col]) > Math.abs(m[piv][col])) piv = r;
      [m[col], m[piv]] = [m[piv], m[col]];
      const d = m[col][col] || 1e-9;
      for (let c = col; c < 4; c++) m[col][c] /= d;
      for (let r = 0; r < 3; r++) {
        if (r === col) continue;
        const f = m[r][col];
        for (let c = col; c < 4; c++) m[r][c] -= f * m[col][c];
      }
    }
    return [m[0][3], m[1][3], m[2][3]];
  };
  const [a, b, c] = solve([0, 1, 2].map((i) => rhs(i, 0)));
  const [d, e, f] = solve([0, 1, 2].map((i) => rhs(i, 1)));
  return (p) => ({ x: a * p.x + b * p.y + c, y: d * p.x + e * p.y + f });
}

/** mean luminance of a disc around a mapped point */
function patchDarkness(g: Gray, cx: number, cy: number, r: number): number {
  let sum = 0;
  let n = 0;
  for (let dy = -r; dy <= r; dy++) {
    for (let dx = -r; dx <= r; dx++) {
      if (dx * dx + dy * dy > r * r) continue;
      const x = Math.round(cx + dx);
      const y = Math.round(cy + dy);
      if (x < 0 || y < 0 || x >= g.w || y >= g.h) continue;
      sum += g.data[y * g.w + x];
      n++;
    }
  }
  return n ? sum / n : 255;
}

/** Draw any image onto a bounded canvas (max dimension ~1700 px). */
export function imageToCanvas(img: HTMLImageElement | HTMLCanvasElement, maxDim = 1700): HTMLCanvasElement {
  const iw = "naturalWidth" in img ? img.naturalWidth || img.width : img.width;
  const ih = "naturalHeight" in img ? img.naturalHeight || img.height : img.height;
  const scale = Math.min(1, maxDim / Math.max(iw, ih));
  const canvas = document.createElement("canvas");
  canvas.width = Math.round(iw * scale);
  canvas.height = Math.round(ih * scale);
  const ctx = canvas.getContext("2d", { willReadFrequently: true })!;
  // white background so JPEG-transparent areas read as paper
  ctx.fillStyle = "#fff";
  ctx.fillRect(0, 0, canvas.width, canvas.height);
  ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
  return canvas;
}

/** decode the QR of a canvas via html5-qrcode (dynamic import, browser only) */
export async function decodeQrFromCanvas(canvas: HTMLCanvasElement): Promise<string | null> {
  try {
    const blob = await new Promise<Blob | null>((res) => canvas.toBlob(res, "image/png"));
    if (!blob) return null;
    const file = new File([blob], "omr.png", { type: "image/png" });
    const { Html5Qrcode } = await import("html5-qrcode");
    const holderId = "omr-qr-holder-" + Math.random().toString(36).slice(2);
    const holder = document.createElement("div");
    holder.id = holderId;
    holder.style.display = "none";
    document.body.appendChild(holder);
    const scanner = new Html5Qrcode(holderId);
    try {
      const text: string | null = await scanner.scanFile(file, false);
      return text ?? null;
    } finally {
      await Promise.resolve(scanner.clear()).catch(() => undefined);
      holder.remove();
    }
  } catch {
    return null;
  }
}

/** full pipeline: QR + fiducials + bubbles → one scanned sheet */
export async function scanOmrCanvas(
  canvas: HTMLCanvasElement,
  questions: number
): Promise<OmrScanResult | null> {
  const qr = await decodeQrFromCanvas(canvas);
  const g = toGray(canvas);

  // roll/set/paper from the QR, whatever happens to the marks
  let roll = "";
  let setLabel: string | null = null;
  let scheduleId: number | null = null;
  if (qr) {
    const parts = qr.trim().split("|");
    if (parts.length === 4 && parts[0] === "OMR") {
      scheduleId = Number(parts[1]) || null;
      roll = parts[2];
      setLabel = parts[3] || null;
    }
  }

  const corners = (["tl", "tr", "bl", "br"] as const).map((c) => findFiducial(g, c));
  if (corners.some((c) => !c)) {
    // sheet identified but alignment failed → empty responses, quality 0
    return { qr: qr ?? "", roll, setLabel, scheduleId, responses: "", quality: 0 };
  }
  const mmPts = omrFiducialCenters();
  const toPx = fitAffine(mmPts, corners as { x: number; y: number }[]);

  // bubble sample radius in pixels (affine scale of the mm radius)
  const o = omrBubbleCenter(0, 0);
  const p0 = toPx(o);
  const p1 = toPx({ x: o.x + 1, y: o.y });
  const pxPerMm = Math.hypot(p1.x - p0.x, p1.y - p0.y);
  const r = Math.max(2, OMR_FIDUCIAL.size * 0 + 2.7 * pxPerMm * 0.62);

  const OPT = ["A", "B", "C", "D"];
  let out = "";
  let confSum = 0;
  for (let q = 0; q < questions; q++) {
    const darkness = [0, 1, 2, 3].map((opt) => {
      const c = omrBubbleCenter(q, opt);
      const p = toPx(c);
      return patchDarkness(g, p.x, p.y, r);
    });
    const sorted = [...darkness].sort((a, b) => a - b);
    const darkest = sorted[0];
    const rest = (sorted[1] + sorted[2] + sorted[3]) / 3;
    // filled bubble: clearly darker than its empty siblings
    if (darkest < rest * 0.72) {
      const pick = darkness.indexOf(darkest);
      out += OPT[pick];
      confSum += 1 - darkest / 255;
    } else {
      out += "-";
      confSum += 0.5;
    }
  }
  const quality = Math.max(0, Math.min(1, confSum / questions));

  return { qr: qr ?? "", roll, setLabel, scheduleId, responses: out, quality };
}
