/**
 * 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;
}

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 probe = await sharp(input, { failOnError: false })
    .grayscale()
    .resize({
      width: Math.min(400, fullW),
      withoutEnlargement: true,
      fastShrinkOnLoad: true,
    })
    .raw()
    .toBuffer({ resolveWithObject: true });

  const box = contentBoundingBox(probe.data, probe.info.width, probe.info.height);
  if (!box) return input;
  const region = scalePixelBox(box, probe.info.width, probe.info.height, fullW, fullH);
  if (region.width < 16 || region.height < 16) return input;
  return sharp(input, { failOnError: false }).extract(region).toBuffer();
}
