/**
 * Receipt photo cleanup: contrast detection, deskew, and content crop.
 */

import sharp from "sharp";

export {
  AI_IMAGE_LONG_EDGE_PX,
  AI_IMAGE_MAX_LONG_EDGE_PX,
  AI_IMAGE_MIN_WIDTH_PX,
  computeReceiptResize,
} from "./resize";
export type { ReceiptResize, ReceiptResizeTargets } from "./resize";

export interface ContrastChannel {
  mean: number;
  stdev: number;
  min: number;
  max: number;
}

export interface ContrastStats {
  channels: ContrastChannel[];
}

/** Low-contrast (faded thermal) vs a well-lit color photo. */
export const CONTRAST_STDEV_THRESHOLD = 32;
export const CONTRAST_RANGE_THRESHOLD = 90;

export function needsContrastBoost(stats: ContrastStats | null | undefined): boolean {
  const channels = stats?.channels;
  if (!channels || channels.length === 0) return false;

  const luma =
    channels.length >= 3
      ? {
          stdev:
            0.2126 * channels[0].stdev +
            0.7152 * channels[1].stdev +
            0.0722 * channels[2].stdev,
          min: 0.2126 * channels[0].min + 0.7152 * channels[1].min + 0.0722 * channels[2].min,
          max: 0.2126 * channels[0].max + 0.7152 * channels[1].max + 0.0722 * channels[2].max,
        }
      : { stdev: channels[0].stdev, min: channels[0].min, max: channels[0].max };

  const range = luma.max - luma.min;
  return luma.stdev < CONTRAST_STDEV_THRESHOLD || range < CONTRAST_RANGE_THRESHOLD;
}

export interface PixelBox {
  left: number;
  top: number;
  width: number;
  height: number;
}

export interface PaperQuadrilateral {
  topLeft: { x: number; y: number };
  topRight: { x: number; y: number };
  bottomRight: { x: number; y: number };
  bottomLeft: { x: number; y: number };
  confidence: number;
}

function percentile(values: number[], ratio: number): number {
  if (values.length === 0) return 0;
  const sorted = [...values].sort((a, b) => a - b);
  return sorted[Math.min(sorted.length - 1, Math.max(0, Math.round((sorted.length - 1) * ratio)))];
}

/**
 * Detects a high-confidence paper trapezoid on a small RGB probe. This is
 * deliberately conservative: uncertain scenes return null and keep the
 * original pixels, avoiding the false rotations caused by table texture.
 */
export function detectPaperQuadrilateral(
  data: Uint8Array,
  width: number,
  height: number,
  channels: number = 3
): PaperQuadrilateral | null {
  if (width < 48 || height < 80 || channels < 3 || data.length < width * height * channels) {
    return null;
  }

  const rows: Array<{ y: number; left: number; right: number }> = [];
  for (let y = 0; y < height; y++) {
    const paperXs: number[] = [];
    for (let x = 0; x < width; x++) {
      const offset = (y * width + x) * channels;
      const r = data[offset];
      const g = data[offset + 1];
      const b = data[offset + 2];
      const max = Math.max(r, g, b);
      const min = Math.min(r, g, b);
      const luma = 0.2126 * r + 0.7152 * g + 0.0722 * b;
      if (luma >= 145 && max - min <= 38) paperXs.push(x);
    }
    if (paperXs.length >= width * 0.18) {
      rows.push({
        y,
        // Ignore isolated bright background pixels at either side.
        left: percentile(paperXs, 0.03),
        right: percentile(paperXs, 0.97),
      });
    }
  }
  if (rows.length < height * 0.55) return null;

  const topY = percentile(rows.map((row) => row.y), 0.03);
  const bottomY = percentile(rows.map((row) => row.y), 0.97);
  const span = bottomY - topY;
  if (span < height * 0.55) return null;
  const band = Math.max(3, Math.round(span * 0.08));
  const topRows = rows.filter((row) => row.y >= topY && row.y <= topY + band);
  const bottomRows = rows.filter((row) => row.y >= bottomY - band && row.y <= bottomY);
  if (topRows.length < 3 || bottomRows.length < 3) return null;

  const topLeft = percentile(topRows.map((row) => row.left), 0.5);
  const topRight = percentile(topRows.map((row) => row.right), 0.5);
  const bottomLeft = percentile(bottomRows.map((row) => row.left), 0.5);
  const bottomRight = percentile(bottomRows.map((row) => row.right), 0.5);
  const topWidth = topRight - topLeft;
  const bottomWidth = bottomRight - bottomLeft;
  const averageWidth = (topWidth + bottomWidth) / 2;
  if (topWidth < width * 0.35 || bottomWidth < width * 0.35 || averageWidth <= 0) return null;

  const widthChange = Math.abs(topWidth - bottomWidth) / averageWidth;
  const sideShift =
    (Math.abs(topLeft - bottomLeft) + Math.abs(topRight - bottomRight)) / (2 * width);
  const meaningful = widthChange >= 0.045 || sideShift >= 0.045;
  const plausible = widthChange <= 0.38 && sideShift <= 0.30;
  if (!meaningful || !plausible) return null;

  const rowCoverage = rows.length / Math.max(1, span + 1);
  const confidence = Math.min(1, 0.55 * rowCoverage + 0.45 * Math.min(1, averageWidth / width));
  if (confidence < 0.72) return null;
  return {
    topLeft: { x: topLeft, y: topY },
    topRight: { x: topRight, y: topY },
    bottomRight: { x: bottomRight, y: bottomY },
    bottomLeft: { x: bottomLeft, y: bottomY },
    confidence,
  };
}

function distance(a: { x: number; y: number }, b: { x: number; y: number }): number {
  return Math.hypot(a.x - b.x, a.y - b.y);
}

/** High-confidence projective normalization; returns the input on uncertainty. */
export async function perspectiveCorrectBuffer(input: Buffer): Promise<Buffer> {
  let sourceInput = input;
  let metadata = await sharp(sourceInput, { failOnError: false }).metadata();
  const initialWidth = metadata.width ?? 0;
  const initialHeight = metadata.height ?? 0;
  const initialLongEdge = Math.max(initialWidth, initialHeight);
  // HEIC can bypass browser preprocessing. Cap the pure-JS projective warp at
  // a size already above the downstream 4096px vision ceiling.
  if (initialLongEdge > 5000) {
    sourceInput = await sharp(sourceInput, { failOnError: false })
      .resize({
        width: initialWidth >= initialHeight ? 5000 : undefined,
        height: initialHeight > initialWidth ? 5000 : undefined,
        fit: "inside",
        withoutEnlargement: true,
      })
      .toBuffer();
    metadata = await sharp(sourceInput, { failOnError: false }).metadata();
  }
  const fullWidth = metadata.width ?? 0;
  const fullHeight = metadata.height ?? 0;
  if (fullWidth < 48 || fullHeight < 80) return input;

  const probe = await sharp(sourceInput, { failOnError: false })
    .resize({ width: Math.min(360, fullWidth), withoutEnlargement: true, fastShrinkOnLoad: true })
    .removeAlpha()
    .raw()
    .toBuffer({ resolveWithObject: true });
  const quad = detectPaperQuadrilateral(
    probe.data,
    probe.info.width,
    probe.info.height,
    probe.info.channels
  );
  if (!quad) return input;

  const sx = fullWidth / probe.info.width;
  const sy = fullHeight / probe.info.height;
  const tl = { x: quad.topLeft.x * sx, y: quad.topLeft.y * sy };
  const tr = { x: quad.topRight.x * sx, y: quad.topRight.y * sy };
  const br = { x: quad.bottomRight.x * sx, y: quad.bottomRight.y * sy };
  const bl = { x: quad.bottomLeft.x * sx, y: quad.bottomLeft.y * sy };
  const outWidth = Math.max(32, Math.round((distance(tl, tr) + distance(bl, br)) / 2));
  const outHeight = Math.max(64, Math.round((distance(tl, bl) + distance(tr, br)) / 2));

  const source = await sharp(sourceInput, { failOnError: false })
    .removeAlpha()
    .raw()
    .toBuffer({ resolveWithObject: true });
  const channels = source.info.channels;
  const output = Buffer.alloc(outWidth * outHeight * channels, 255);

  const dx1 = tr.x - br.x;
  const dx2 = bl.x - br.x;
  const dx3 = tl.x - tr.x + br.x - bl.x;
  const dy1 = tr.y - br.y;
  const dy2 = bl.y - br.y;
  const dy3 = tl.y - tr.y + br.y - bl.y;
  const denominator = dx1 * dy2 - dx2 * dy1;
  if (Math.abs(denominator) < 1e-6) return input;
  const g = (dx3 * dy2 - dx2 * dy3) / denominator;
  const h = (dx1 * dy3 - dx3 * dy1) / denominator;
  const a = tr.x - tl.x + g * tr.x;
  const b = bl.x - tl.x + h * bl.x;
  const c = tl.x;
  const d = tr.y - tl.y + g * tr.y;
  const e = bl.y - tl.y + h * bl.y;
  const f = tl.y;

  for (let y = 0; y < outHeight; y++) {
    const v = outHeight === 1 ? 0 : y / (outHeight - 1);
    for (let x = 0; x < outWidth; x++) {
      const u = outWidth === 1 ? 0 : x / (outWidth - 1);
      const divisor = g * u + h * v + 1;
      const sourceX = Math.max(0, Math.min(fullWidth - 1, (a * u + b * v + c) / divisor));
      const sourceY = Math.max(0, Math.min(fullHeight - 1, (d * u + e * v + f) / divisor));
      const x0 = Math.floor(sourceX);
      const y0 = Math.floor(sourceY);
      const x1 = Math.min(fullWidth - 1, x0 + 1);
      const y1 = Math.min(fullHeight - 1, y0 + 1);
      const fx = sourceX - x0;
      const fy = sourceY - y0;
      const targetOffset = (y * outWidth + x) * channels;
      for (let channel = 0; channel < channels; channel++) {
        const p00 = source.data[(y0 * fullWidth + x0) * channels + channel];
        const p10 = source.data[(y0 * fullWidth + x1) * channels + channel];
        const p01 = source.data[(y1 * fullWidth + x0) * channels + channel];
        const p11 = source.data[(y1 * fullWidth + x1) * channels + channel];
        output[targetOffset + channel] = Math.round(
          p00 * (1 - fx) * (1 - fy) +
            p10 * fx * (1 - fy) +
            p01 * (1 - fx) * fy +
            p11 * fx * fy
        );
      }
    }
  }
  return sharp(output, { raw: { width: outWidth, height: outHeight, channels } })
    .jpeg({ quality: 94, progressive: false, mozjpeg: false })
    .toBuffer();
}

/**
 * Finds a light, low-chroma paper strip on a coloured background.  The older
 * corner-difference detector is deliberately generic, but it treats wood grain
 * (and similarly textured tables) as foreground.  This detector is a safe
 * fallback for the common "white thermal receipt on a table" photo: it works
 * on a tiny RGB probe, joins text-sized holes into cells and selects an
 * elongated connected component.  If the scene does not contain a convincing
 * paper strip it returns null and the original image is kept.
 */
export function paperReceiptBoundingBox(
  data: Uint8Array,
  width: number,
  height: number,
  channels: number = 3
): PixelBox | null {
  if (width < 32 || height < 32 || channels < 3 || data.length < width * height * channels) {
    return null;
  }

  const cell = 4;
  const gridW = Math.ceil(width / cell);
  const gridH = Math.ceil(height / cell);
  const mask = new Uint8Array(gridW * gridH);

  for (let gy = 0; gy < gridH; gy++) {
    for (let gx = 0; gx < gridW; gx++) {
      let paperPixels = 0;
      let samples = 0;
      const x1 = Math.min(width, (gx + 1) * cell);
      const y1 = Math.min(height, (gy + 1) * cell);
      for (let y = gy * cell; y < y1; y++) {
        for (let x = gx * cell; x < x1; x++) {
          const offset = (y * width + x) * channels;
          const r = data[offset];
          const g = data[offset + 1];
          const b = data[offset + 2];
          const max = Math.max(r, g, b);
          const min = Math.min(r, g, b);
          const luma = 0.2126 * r + 0.7152 * g + 0.0722 * b;
          // Paper may be grey due to shadows, while wood and coloured desks
          // retain noticeably more chroma. Dark print is bridged at cell level.
          if (luma >= 150 && max - min <= 30) paperPixels++;
          samples++;
        }
      }
      if (samples > 0 && paperPixels / samples >= 0.28) mask[gy * gridW + gx] = 1;
    }
  }

  // One-cell close: joins the small gaps created by printed characters and
  // folds without merging distant objects in the scene.
  const closed = new Uint8Array(mask.length);
  for (let gy = 0; gy < gridH; gy++) {
    for (let gx = 0; gx < gridW; gx++) {
      let neighbours = 0;
      for (let yy = Math.max(0, gy - 1); yy <= Math.min(gridH - 1, gy + 1); yy++) {
        for (let xx = Math.max(0, gx - 1); xx <= Math.min(gridW - 1, gx + 1); xx++) {
          neighbours += mask[yy * gridW + xx];
        }
      }
      if (mask[gy * gridW + gx] || neighbours >= 5) closed[gy * gridW + gx] = 1;
    }
  }

  const seen = new Uint8Array(closed.length);
  let best: { minX: number; minY: number; maxX: number; maxY: number; score: number } | null = null;
  const queue: number[] = [];

  for (let start = 0; start < closed.length; start++) {
    if (!closed[start] || seen[start]) continue;
    queue.length = 0;
    queue.push(start);
    seen[start] = 1;
    let head = 0;
    let count = 0;
    let minX = gridW;
    let minY = gridH;
    let maxX = 0;
    let maxY = 0;

    while (head < queue.length) {
      const index = queue[head++];
      const x = index % gridW;
      const y = Math.floor(index / gridW);
      count++;
      minX = Math.min(minX, x);
      minY = Math.min(minY, y);
      maxX = Math.max(maxX, x);
      maxY = Math.max(maxY, y);
      for (let yy = Math.max(0, y - 1); yy <= Math.min(gridH - 1, y + 1); yy++) {
        for (let xx = Math.max(0, x - 1); xx <= Math.min(gridW - 1, x + 1); xx++) {
          const next = yy * gridW + xx;
          if (!closed[next] || seen[next]) continue;
          seen[next] = 1;
          queue.push(next);
        }
      }
    }

    const componentW = maxX - minX + 1;
    const componentH = maxY - minY + 1;
    const areaRatio = (componentW * componentH) / (gridW * gridH);
    const fill = count / (componentW * componentH);
    const long = Math.max(componentW, componentH);
    const short = Math.max(1, Math.min(componentW, componentH));
    const elongation = long / short;
    const coverage = long / Math.max(gridW, gridH);

    if (areaRatio < 0.035 || areaRatio > 0.72 || fill < 0.22 || elongation < 1.8 || coverage < 0.48) {
      continue;
    }
    const score = count * Math.min(elongation, 6) * coverage;
    if (!best || score > best.score) best = { minX, minY, maxX, maxY, score };
  }

  if (!best) return null;
  const padX = Math.max(6, Math.round(width * 0.025));
  const padY = Math.max(6, Math.round(height * 0.015));
  const left = Math.max(0, best.minX * cell - padX);
  const top = Math.max(0, best.minY * cell - padY);
  const right = Math.min(width, (best.maxX + 1) * cell + padX);
  const bottom = Math.min(height, (best.maxY + 1) * cell + padY);
  return { left, top, width: right - left, height: bottom - top };
}

function medianOf(values: number[]): number {
  if (values.length === 0) return 0;
  const sorted = [...values].sort((a, b) => a - b);
  const mid = Math.floor(sorted.length / 2);
  return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
}

function sampleCornerLuma(
  data: Uint8Array,
  width: number,
  height: number,
  x0: number,
  y0: number,
  size: number
): number[] {
  const values: number[] = [];
  const x1 = Math.min(width, x0 + size);
  const y1 = Math.min(height, y0 + size);
  for (let y = y0; y < y1; y++) {
    const row = y * width;
    for (let x = x0; x < x1; x++) {
      values.push(data[row + x]);
    }
  }
  return values;
}

/**
 * Bounding box of the receipt on a grayscale buffer. Corners vote for the
 * background; content is whatever differs enough from that vote.
 */
export function contentBoundingBox(
  data: Uint8Array,
  width: number,
  height: number
): PixelBox | null {
  if (width < 8 || height < 8 || data.length < width * height) return null;

  const corner = 8;
  const samples = [
    ...sampleCornerLuma(data, width, height, 0, 0, corner),
    ...sampleCornerLuma(data, width, height, Math.max(0, width - corner), 0, corner),
    ...sampleCornerLuma(data, width, height, 0, Math.max(0, height - corner), corner),
    ...sampleCornerLuma(
      data,
      width,
      height,
      Math.max(0, width - corner),
      Math.max(0, height - corner),
      corner
    ),
  ];
  const background = medianOf(samples);
  const delta = 28;

  let minX = width;
  let minY = height;
  let maxX = 0;
  let maxY = 0;
  let hits = 0;

  for (let y = 0; y < height; y++) {
    const row = y * width;
    for (let x = 0; x < width; x++) {
      if (Math.abs(data[row + x] - background) <= delta) continue;
      hits += 1;
      if (x < minX) minX = x;
      if (y < minY) minY = y;
      if (x > maxX) maxX = x;
      if (y > maxY) maxY = y;
    }
  }

  if (hits < width * height * 0.004) return null;

  const padX = Math.max(4, Math.round(width * 0.025));
  const padY = Math.max(4, Math.round(height * 0.025));
  const left = Math.max(0, minX - padX);
  const top = Math.max(0, minY - padY);
  const right = Math.min(width, maxX + 1 + padX);
  const bottom = Math.min(height, maxY + 1 + padY);
  const boxW = right - left;
  const boxH = bottom - top;
  const area = boxW * boxH;
  const full = width * height;
  if (area < full * 0.22 || area > full * 0.97) return null;

  return { left, top, width: boxW, height: boxH };
}

function projectionVariance(
  data: Uint8Array,
  width: number,
  height: number,
  angleDeg: number
): number {
  const tan = Math.tan((angleDeg * Math.PI) / 180);
  const sums = new Float64Array(height);
  const mid = (height - 1) / 2;
  for (let y = 0; y < height; y++) {
    const row = y * width;
    const shift = (y - mid) * tan;
    let sum = 0;
    for (let x = 0; x < width; x++) {
      const srcX = Math.round(x + shift);
      if (srcX < 0 || srcX >= width) continue;
      sum += data[row + srcX];
    }
    sums[y] = sum;
  }

  let mean = 0;
  for (let i = 0; i < height; i++) mean += sums[i];
  mean /= height;
  let varSum = 0;
  for (let i = 0; i < height; i++) {
    const d = sums[i] - mean;
    varSum += d * d;
  }
  return varSum / height;
}

export const SKEW_MIN_DEGREES = 0.5;
export const SKEW_MAX_DEGREES = 8;

/**
 * Estimate rotation (degrees, clockwise) that lines up horizontal text.
 * 0 means "leave it". Operates on a small grayscale buffer.
 */
export function estimateSkewDegrees(
  data: Uint8Array,
  width: number,
  height: number
): number {
  if (width < 24 || height < 24) return 0;

  let bestAngle = 0;
  let bestScore = -Infinity;
  for (let angle = -SKEW_MAX_DEGREES; angle <= SKEW_MAX_DEGREES + 1e-9; angle += 0.5) {
    const score = projectionVariance(data, width, height, angle);
    if (score > bestScore) {
      bestScore = score;
      bestAngle = angle;
    }
  }
  return Math.abs(bestAngle) < SKEW_MIN_DEGREES ? 0 : bestAngle;
}

export function scalePixelBox(
  box: PixelBox,
  fromWidth: number,
  fromHeight: number,
  toWidth: number,
  toHeight: number
): PixelBox {
  const sx = toWidth / fromWidth;
  const sy = toHeight / fromHeight;
  let left = Math.floor(box.left * sx);
  let top = Math.floor(box.top * sy);
  let width = Math.ceil(box.width * sx);
  let height = Math.ceil(box.height * sy);
  if (left < 0) left = 0;
  if (top < 0) top = 0;
  if (left + width > toWidth) width = toWidth - left;
  if (top + height > toHeight) height = toHeight - top;
  return {
    left,
    top,
    width: Math.max(1, width),
    height: Math.max(1, height),
  };
}

/** Small grayscale probe + optional rotate. No-op when the page is already upright. */
export async function deskewBuffer(input: Buffer): Promise<Buffer> {
  const probe = await sharp(input, { failOnError: false })
    .grayscale()
    .resize({ width: 280, withoutEnlargement: true, fastShrinkOnLoad: true })
    .raw()
    .toBuffer({ resolveWithObject: true });
  const angle = estimateSkewDegrees(probe.data, probe.info.width, probe.info.height);
  if (angle === 0) return input;
  return sharp(input, { failOnError: false })
    .rotate(angle, { background: "#ffffff" })
    .toBuffer();
}

/** Crops table / margin around the slip. Skips when the detector is unsure. */
export async function cropBuffer(input: Buffer): Promise<Buffer> {
  const meta = await sharp(input, { failOnError: false }).metadata();
  const fullW = meta.width ?? 0;
  const fullH = meta.height ?? 0;
  if (fullW < 16 || fullH < 16) return input;

  const rgbProbe = await sharp(input, { failOnError: false })
    .resize({
      width: Math.min(400, fullW),
      withoutEnlargement: true,
      fastShrinkOnLoad: true,
    })
    .raw()
    .toBuffer({ resolveWithObject: true });

  const grayscaleProbe = await sharp(rgbProbe.data, {
    raw: {
      width: rgbProbe.info.width,
      height: rgbProbe.info.height,
      channels: rgbProbe.info.channels,
    },
  })
    .grayscale()
    .raw()
    .toBuffer({ resolveWithObject: true });

  const genericBox = contentBoundingBox(
    grayscaleProbe.data,
    grayscaleProbe.info.width,
    grayscaleProbe.info.height
  );
  const paperBox = paperReceiptBoundingBox(
    rgbProbe.data,
    rgbProbe.info.width,
    rgbProbe.info.height,
    rgbProbe.info.channels
  );
  const elongation = (candidate: PixelBox) =>
    Math.max(candidate.width, candidate.height) / Math.max(1, Math.min(candidate.width, candidate.height));
  const box =
    paperBox &&
    (!genericBox ||
      (elongation(paperBox) >= 1.6 &&
        (paperBox.width * paperBox.height < genericBox.width * genericBox.height * 0.9 ||
          elongation(paperBox) > elongation(genericBox) * 1.2)))
      ? paperBox
      : genericBox;
  if (!box) return input;
  const region = scalePixelBox(box, rgbProbe.info.width, rgbProbe.info.height, fullW, fullH);
  if (region.width < 16 || region.height < 16) return input;
  return sharp(input, { failOnError: false }).extract(region).toBuffer();
}
