"use client";

import { ChangeEvent, useEffect, useMemo, useRef, useState } from "react";
import { MARD_221 } from "./mard-palette";

type BeadColor = {
  code: string;
  name: string;
  hex: string;
  rgb: [number, number, number];
  lab: Oklab;
};

type Oklab = [number, number, number];

type ColorBin = {
  key: number;
  count: number;
  rgb: [number, number, number];
  lab: Oklab;
};

type ColorCluster = {
  count: number;
  rgb: [number, number, number];
  lab: Oklab;
};

type SamplingStrategy = "smooth" | "dominant";

type CropRect = {
  x: number;
  y: number;
  width: number;
  height: number;
};

type CropSelectionMode = "free" | "square";
type CropDialogPurpose = "source" | "pattern-import";

type CropResizeHandle = "n" | "s" | "e" | "w" | "nw" | "ne" | "sw" | "se";

type CropSelectionDrag = {
  pointerId: number;
  mode: "create" | "move" | "resize";
  startX: number;
  startY: number;
  origin: CropRect;
  handle?: CropResizeHandle;
};

const FULL_CROP: CropRect = { x: 0, y: 0, width: 1, height: 1 };
// 现成图纸识别准确率仍需继续优化，暂时保留实现但关闭用户入口。
const ENABLE_PATTERN_IMPORT = false;

function logUsage(event: Record<string, unknown>) {
  void fetch("/api/usage", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(event),
    keepalive: true,
  }).catch(() => undefined);
}

type Pixel = {
  color: BeadColor | null;
};

type HoveredPixel = {
  row: number;
  column: number;
  color: BeadColor;
  left: number;
  top: number;
};

type SavedPattern = {
  id: string;
  name: string;
  savedAt: string;
  width: number;
  height: number;
  colorLimit: number;
  codes: Array<string | null>;
};

const TRANSPARENT_ALPHA_THRESHOLD = 128;
const HISTORY_LIMIT = 30;
const HISTORY_STORAGE_KEY = "bead-pattern-history";
const HISTORY_DB_NAME = "pixel-bead-pattern-history";
const HISTORY_STORE_NAME = "patterns";

function createHistoryId() {
  if (typeof globalThis.crypto?.randomUUID === "function") return globalThis.crypto.randomUUID();
  return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}

function rowNumberToLetters(rowNumber: number) {
  let value = Math.max(1, Math.floor(rowNumber));
  let letters = "";
  while (value > 0) {
    value -= 1;
    letters = String.fromCharCode(65 + (value % 26)) + letters;
    value = Math.floor(value / 26);
  }
  return letters;
}

function openHistoryDatabase() {
  return new Promise<IDBDatabase>((resolve, reject) => {
    const request = indexedDB.open(HISTORY_DB_NAME, 1);
    request.onupgradeneeded = () => {
      if (!request.result.objectStoreNames.contains(HISTORY_STORE_NAME)) {
        request.result.createObjectStore(HISTORY_STORE_NAME);
      }
    };
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

async function readPatternHistory(): Promise<SavedPattern[]> {
  const database = await openHistoryDatabase();
  return new Promise((resolve, reject) => {
    const transaction = database.transaction(HISTORY_STORE_NAME, "readonly");
    const request = transaction.objectStore(HISTORY_STORE_NAME).get(HISTORY_STORAGE_KEY);
    request.onsuccess = () => resolve(Array.isArray(request.result) ? request.result : []);
    request.onerror = () => reject(request.error);
    transaction.oncomplete = () => database.close();
  });
}

async function writePatternHistory(value: SavedPattern[]) {
  const database = await openHistoryDatabase();
  return new Promise<void>((resolve, reject) => {
    const transaction = database.transaction(HISTORY_STORE_NAME, "readwrite");
    transaction.objectStore(HISTORY_STORE_NAME).put(value, HISTORY_STORAGE_KEY);
    transaction.oncomplete = () => { database.close(); resolve(); };
    transaction.onerror = () => { database.close(); reject(transaction.error); };
    transaction.onabort = () => { database.close(); reject(transaction.error); };
  });
}

const MARD_SERIES_NAMES: Record<string, string> = {
  A: "黄橙系", B: "绿色系", C: "蓝青系", D: "紫蓝系", E: "粉红系",
  F: "红色系", G: "肤棕系", H: "黑白灰系", M: "莫兰迪系",
};

function rgbToOklab([red, green, blue]: [number, number, number]): Oklab {
  const linear = (value: number) => {
    const channel = value / 255;
    return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4;
  };
  const r = linear(red);
  const g = linear(green);
  const b = linear(blue);
  const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
  const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
  const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
  return [
    0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
    1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
    0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
  ];
}

function oklabDistance(a: Oklab, b: Oklab) {
  return Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
}

function isPerceptualBridge(color: Oklab, first: Oklab, second: Oklab) {
  const vector = [second[0] - first[0], second[1] - first[1], second[2] - first[2]] as Oklab;
  const lengthSquared = vector[0] ** 2 + vector[1] ** 2 + vector[2] ** 2;
  if (lengthSquared < 0.07 ** 2) return false;
  const offset = [color[0] - first[0], color[1] - first[1], color[2] - first[2]] as Oklab;
  const position = (offset[0] * vector[0] + offset[1] * vector[1] + offset[2] * vector[2]) / lengthSquared;
  if (position < 0.12 || position > 0.88) return false;
  const projection: Oklab = [
    first[0] + vector[0] * position,
    first[1] + vector[1] * position,
    first[2] + vector[2] * position,
  ];
  return oklabDistance(color, projection) <= 0.025;
}

const PALETTE: BeadColor[] = MARD_221.map(([code, hex]) => {
  const rgb: [number, number, number] = [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)];
  return { code, name: `MARD ${MARD_SERIES_NAMES[code[0]]}`, hex, rgb, lab: rgbToOklab(rgb) };
});
const PALETTE_BY_CODE = new Map(PALETTE.map((color) => [color.code, color]));

const hexToRgb = (hex: string): [number, number, number] => [
  parseInt(hex.slice(1, 3), 16),
  parseInt(hex.slice(3, 5), 16),
  parseInt(hex.slice(5, 7), 16),
];

function HistoryThumbnail({ entry }: { entry: SavedPattern }) {
  const thumbnailRef = useRef<HTMLCanvasElement | null>(null);
  useEffect(() => {
    const canvas = thumbnailRef.current;
    if (!canvas) return;
    canvas.width = Math.max(1, entry.width);
    canvas.height = Math.max(1, entry.height);
    const ctx = canvas.getContext("2d");
    if (!ctx) return;
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    entry.codes.forEach((code, index) => {
      if (!code) return;
      const color = PALETTE_BY_CODE.get(code);
      if (!color) return;
      ctx.fillStyle = color.hex;
      ctx.fillRect(index % entry.width, Math.floor(index / entry.width), 1, 1);
    });
  }, [entry]);
  return <div className="history-thumbnail" aria-label={`${entry.name} 的完整格子图缩略图`}>
    <canvas ref={thumbnailRef} />
  </div>;
}

function nearestColor(lab: Oklab, palette: BeadColor[]) {
  let closest = palette[0];
  let smallest = Number.POSITIVE_INFINITY;
  for (const color of palette) {
    const distance = oklabDistance(lab, color.lab);
    if (distance < smallest) {
      smallest = distance;
      closest = color;
    }
  }
  return closest;
}

type NormalizedGlyph = {
  pixels: Uint8Array;
  count: number;
};

let mardCodeTemplates: Array<{ code: string; glyph: NormalizedGlyph }> | null = null;

function normalizeGlyph(mask: Uint8Array, width: number, height: number, targetWidth = 32, targetHeight = 16): NormalizedGlyph | null {
  let left = width;
  let right = -1;
  let top = height;
  let bottom = -1;
  for (let y = 0; y < height; y++) {
    for (let x = 0; x < width; x++) {
      if (!mask[y * width + x]) continue;
      left = Math.min(left, x);
      right = Math.max(right, x);
      top = Math.min(top, y);
      bottom = Math.max(bottom, y);
    }
  }
  if (right < left || bottom < top) return null;
  const sourceWidth = right - left + 1;
  const sourceHeight = bottom - top + 1;
  const scale = Math.min((targetWidth - 2) / sourceWidth, (targetHeight - 2) / sourceHeight);
  const drawWidth = Math.max(1, Math.round(sourceWidth * scale));
  const drawHeight = Math.max(1, Math.round(sourceHeight * scale));
  const offsetX = Math.floor((targetWidth - drawWidth) / 2);
  const offsetY = Math.floor((targetHeight - drawHeight) / 2);
  const pixels = new Uint8Array(targetWidth * targetHeight);
  let count = 0;
  for (let y = 0; y < drawHeight; y++) {
    for (let x = 0; x < drawWidth; x++) {
      const sourceX = left + Math.min(sourceWidth - 1, Math.floor(x / scale));
      const sourceY = top + Math.min(sourceHeight - 1, Math.floor(y / scale));
      if (!mask[sourceY * width + sourceX]) continue;
      pixels[(offsetY + y) * targetWidth + offsetX + x] = 1;
      count += 1;
    }
  }
  return count >= 3 ? { pixels, count } : null;
}

function buildMardCodeTemplates() {
  if (mardCodeTemplates) return mardCodeTemplates;
  const canvas = document.createElement("canvas");
  canvas.width = 96;
  canvas.height = 48;
  const ctx = canvas.getContext("2d", { willReadFrequently: true })!;
  mardCodeTemplates = PALETTE.map((color) => {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    ctx.fillStyle = "#ffffff";
    ctx.fillRect(0, 0, canvas.width, canvas.height);
    ctx.fillStyle = "#000000";
    ctx.font = "700 28px Arial";
    ctx.textAlign = "center";
    ctx.textBaseline = "middle";
    ctx.fillText(color.code, canvas.width / 2, canvas.height / 2);
    const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
    const mask = new Uint8Array(canvas.width * canvas.height);
    for (let index = 0; index < mask.length; index++) {
      const offset = index * 4;
      const luminance = data[offset] * 0.299 + data[offset + 1] * 0.587 + data[offset + 2] * 0.114;
      if (luminance < 150) mask[index] = 1;
    }
    return { code: color.code, glyph: normalizeGlyph(mask, canvas.width, canvas.height)! };
  });
  return mardCodeTemplates;
}

function glyphSimilarity(first: NormalizedGlyph, second: NormalizedGlyph) {
  let intersection = 0;
  for (let index = 0; index < first.pixels.length; index++) {
    if (first.pixels[index] && second.pixels[index]) intersection += 1;
  }
  return (2 * intersection) / Math.max(1, first.count + second.count);
}

function dominantCellBackground(data: Uint8ClampedArray, canvasWidth: number, startX: number, startY: number, cellSize: number) {
  const bins = new Map<number, { count: number; red: number; green: number; blue: number; alpha: number }>();
  const inset = Math.max(2, Math.floor(cellSize * 0.12));
  for (let y = inset; y < cellSize - inset; y++) {
    for (let x = inset; x < cellSize - inset; x++) {
      const nx = x / cellSize;
      const ny = y / cellSize;
      if (nx > 0.34 && nx < 0.66 && ny > 0.27 && ny < 0.73) continue;
      const index = ((startY + y) * canvasWidth + startX + x) * 4;
      const alpha = data[index + 3];
      const key = alpha < TRANSPARENT_ALPHA_THRESHOLD
        ? -1
        : ((data[index] >> 4) << 8) | ((data[index + 1] >> 4) << 4) | (data[index + 2] >> 4);
      const bin = bins.get(key) ?? { count: 0, red: 0, green: 0, blue: 0, alpha: 0 };
      bin.count += 1;
      bin.red += data[index];
      bin.green += data[index + 1];
      bin.blue += data[index + 2];
      bin.alpha += alpha;
      bins.set(key, bin);
    }
  }
  const dominant = [...bins.values()].sort((a, b) => b.count - a.count)[0];
  if (!dominant) return { rgb: [255, 255, 255] as [number, number, number], alpha: 0 };
  return {
    rgb: [
      Math.round(dominant.red / dominant.count),
      Math.round(dominant.green / dominant.count),
      Math.round(dominant.blue / dominant.count),
    ] as [number, number, number],
    alpha: Math.round(dominant.alpha / dominant.count),
  };
}

function extractCellGlyph(
  data: Uint8ClampedArray,
  canvasWidth: number,
  startX: number,
  startY: number,
  cellSize: number,
  background: [number, number, number],
) {
  const mask = new Uint8Array(cellSize * cellSize);
  const marginX = Math.max(2, Math.floor(cellSize * 0.12));
  const marginY = Math.max(2, Math.floor(cellSize * 0.16));
  for (let y = marginY; y < cellSize - marginY; y++) {
    for (let x = marginX; x < cellSize - marginX; x++) {
      const index = ((startY + y) * canvasWidth + startX + x) * 4;
      if (data[index + 3] < TRANSPARENT_ALPHA_THRESHOLD) continue;
      const difference = Math.hypot(
        data[index] - background[0],
        data[index + 1] - background[1],
        data[index + 2] - background[2],
      );
      if (difference >= 72) mask[y * cellSize + x] = 1;
    }
  }
  return normalizeGlyph(mask, cellSize, cellSize);
}

function recognizeMardCode(glyph: NormalizedGlyph, backgroundLab: Oklab) {
  let bestCode = "";
  let bestScore = 0;
  const candidates = buildMardCodeTemplates()
    .map((template) => ({ template, distance: oklabDistance(backgroundLab, PALETTE_BY_CODE.get(template.code)!.lab) }))
    .sort((first, second) => first.distance - second.distance)
    .slice(0, 24);
  for (const { template, distance } of candidates) {
    const rawScore = glyphSimilarity(glyph, template.glyph);
    const colorPenalty = Math.min(0.16, distance * 0.35);
    const score = rawScore - colorPenalty;
    if (score > bestScore) {
      bestScore = score;
      bestCode = template.code;
    }
  }
  return { code: bestCode, score: bestScore };
}

function importMardPatternImage(
  image: HTMLImageElement,
  crop: CropRect,
  width: number,
  height: number,
  missingCodeAsEmpty: boolean,
) {
  const maximumSide = 4096;
  const cellSize = Math.max(10, Math.min(36, Math.floor(maximumSide / Math.max(width, height))));
  const canvas = document.createElement("canvas");
  canvas.width = width * cellSize;
  canvas.height = height * cellSize;
  const ctx = canvas.getContext("2d", { willReadFrequently: true })!;
  ctx.imageSmoothingEnabled = true;
  ctx.drawImage(
    image,
    crop.x * image.naturalWidth,
    crop.y * image.naturalHeight,
    crop.width * image.naturalWidth,
    crop.height * image.naturalHeight,
    0,
    0,
    canvas.width,
    canvas.height,
  );
  const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
  const pixels: Pixel[] = [];
  let recognizedCodes = 0;
  let colorMatches = 0;
  let emptyCells = 0;
  const recognitionCache = new Map<string, { code: string; score: number }>();
  for (let row = 0; row < height; row++) {
    for (let column = 0; column < width; column++) {
      const startX = column * cellSize;
      const startY = row * cellSize;
      const background = dominantCellBackground(data, canvas.width, startX, startY, cellSize);
      const backgroundLab = rgbToOklab(background.rgb);
      const glyph = extractCellGlyph(data, canvas.width, startX, startY, cellSize, background.rgb);
      let recognized: { code: string; score: number } | null = null;
      if (glyph) {
        let key = "";
        for (let index = 0; index < glyph.pixels.length; index++) key += glyph.pixels[index] ? "1" : "0";
        recognized = recognitionCache.get(key) ?? recognizeMardCode(glyph, backgroundLab);
        recognitionCache.set(key, recognized);
      }
      if (recognized && recognized.score >= 0.58 && PALETTE_BY_CODE.has(recognized.code)) {
        pixels.push({ color: PALETTE_BY_CODE.get(recognized.code)! });
        recognizedCodes += 1;
        continue;
      }
      if (missingCodeAsEmpty) {
        pixels.push({ color: null });
        emptyCells += 1;
        continue;
      }
      const chroma = Math.hypot(backgroundLab[1], backgroundLab[2]);
      if (background.alpha < TRANSPARENT_ALPHA_THRESHOLD || (!glyph && backgroundLab[0] > 0.93 && chroma < 0.025)) {
        pixels.push({ color: null });
        emptyCells += 1;
        continue;
      }
      pixels.push({ color: nearestColor(backgroundLab, PALETTE) });
      colorMatches += 1;
    }
  }
  return { pixels, recognizedCodes, colorMatches, emptyCells };
}

function mergePerceptualColors(data: Uint8ClampedArray) {
  const pixelKeys: Array<number | null> = [];
  const histogram = new Map<number, { count: number; red: number; green: number; blue: number }>();
  for (let index = 0; index < data.length; index += 4) {
    if (data[index + 3] < TRANSPARENT_ALPHA_THRESHOLD) {
      pixelKeys.push(null);
      continue;
    }
    const red = data[index];
    const green = data[index + 1];
    const blue = data[index + 2];
    const key = (red >> 3) << 10 | (green >> 3) << 5 | (blue >> 3);
    pixelKeys.push(key);
    const bin = histogram.get(key);
    if (bin) {
      bin.count += 1;
      bin.red += red;
      bin.green += green;
      bin.blue += blue;
    } else {
      histogram.set(key, { count: 1, red, green, blue });
    }
  }

  const bins: ColorBin[] = [...histogram.entries()].map(([key, value]) => {
    const rgb: [number, number, number] = [value.red / value.count, value.green / value.count, value.blue / value.count];
    return { key, count: value.count, rgb, lab: rgbToOklab(rgb) };
  }).sort((a, b) => b.count - a.count);

  const clusters: ColorCluster[] = [];
  // Keep enough source-color resolution for broad gradients. The later MARD
  // selection still merges close colors globally and rejects tiny edge noise.
  const mergeThreshold = 0.015;
  for (const bin of bins) {
    let nearestIndex = -1;
    let nearestDistance = Number.POSITIVE_INFINITY;
    for (let index = 0; index < clusters.length; index++) {
      const distance = oklabDistance(bin.lab, clusters[index].lab);
      if (distance < nearestDistance) {
        nearestDistance = distance;
        nearestIndex = index;
      }
    }
    if (nearestIndex >= 0 && nearestDistance <= mergeThreshold) {
      const cluster = clusters[nearestIndex];
      const total = cluster.count + bin.count;
      cluster.rgb = cluster.rgb.map((value, channel) => (value * cluster.count + bin.rgb[channel] * bin.count) / total) as [number, number, number];
      cluster.lab = cluster.lab.map((value, channel) => (value * cluster.count + bin.lab[channel] * bin.count) / total) as Oklab;
      cluster.count = total;
    } else {
      clusters.push({ count: bin.count, rgb: [...bin.rgb], lab: [...bin.lab] });
    }
  }

  const binClusters = new Map<number, number>();
  for (const bin of bins) {
    let nearestIndex = 0;
    let nearestDistance = Number.POSITIVE_INFINITY;
    clusters.forEach((cluster, index) => {
      const distance = oklabDistance(bin.lab, cluster.lab);
      if (distance < nearestDistance) {
        nearestDistance = distance;
        nearestIndex = index;
      }
    });
    binClusters.set(bin.key, nearestIndex);
  }
  return { pixelKeys, clusters, binClusters };
}

function chooseDistinctMardColors(clusters: ColorCluster[], maximum: number) {
  const candidates = new Map<string, { color: BeadColor; count: number; error: number; score: number }>();
  for (const cluster of clusters) {
    const color = nearestColor(cluster.lab, PALETTE);
    const error = oklabDistance(cluster.lab, color.lab);
    const chroma = Math.hypot(cluster.lab[1], cluster.lab[2]);
    const detailBonus = 1 + Math.min(0.65, chroma * 2.2) + (cluster.lab[0] < 0.28 ? 0.35 : 0);
    const current = candidates.get(color.code) ?? { color, count: 0, error: 0, score: 0 };
    current.count += cluster.count;
    current.error += error * cluster.count;
    current.score += cluster.count * detailBonus / (1 + error * 5);
    candidates.set(color.code, current);
  }

  const separated: BeadColor[] = [];
  const minimumMardDistance = 0.035;
  const ranked = [...candidates.values()].sort((a, b) => b.score - a.score || a.error / a.count - b.error / b.count);
  for (const candidate of ranked) {
    if (separated.every((selected) => oklabDistance(candidate.color.lab, selected.lab) >= minimumMardDistance)) {
      separated.push(candidate.color);
    }
  }
  return separated.slice(0, Math.max(0, Math.min(maximum, separated.length)));
}

function matchClustersToPalette(clusters: ColorCluster[], limitedPalette: BeadColor[], colorLimit: number) {
  const matches = clusters.map((cluster) => nearestColor(cluster.lab, limitedPalette));
  if (colorLimit < 12) return matches;

  // A large, smooth gradient may contain several useful MARD steps which the
  // global minimum-distance filter deliberately merges. Let only substantial
  // source clusters recover their accurate shade; tiny edge colors stay out.
  const totalPixels = clusters.reduce((total, cluster) => total + cluster.count, 0);
  const broadClusterThreshold = Math.max(24, totalPixels * 0.012);
  const maximumExtraShades = Math.min(8, Math.max(0, colorLimit - limitedPalette.length));
  const extraShades: BeadColor[] = [];
  const rankedClusters = clusters
    .map((cluster, index) => ({ cluster, index, exact: nearestColor(cluster.lab, PALETTE) }))
    .filter(({ cluster, exact, index }) => cluster.count >= broadClusterThreshold && exact.code !== matches[index].code)
    .sort((a, b) => b.cluster.count - a.cluster.count);

  for (const candidate of rankedClusters) {
    if (extraShades.length >= maximumExtraShades) break;
    if (extraShades.some((color) => color.code === candidate.exact.code)) {
      matches[candidate.index] = candidate.exact;
      continue;
    }
    const nearestSelectedDistance = Math.min(...limitedPalette.map((color) => oklabDistance(color.lab, candidate.exact.lab)));
    if (nearestSelectedDistance < 0.018) continue;
    extraShades.push(candidate.exact);
    matches[candidate.index] = candidate.exact;
  }
  return matches;
}

function preserveSmoothGradientSteps(
  colors: Array<BeadColor | null>,
  sourceData: Uint8ClampedArray,
  width: number,
  height: number,
  colorLimit: number,
) {
  if (colorLimit < 12) return colors;
  const result = [...colors];
  const sourceLabs: Array<Oklab | null> = [];
  for (let index = 0; index < sourceData.length; index += 4) {
    sourceLabs.push(sourceData[index + 3] < TRANSPARENT_ALPHA_THRESHOLD
      ? null
      : rgbToOklab([sourceData[index], sourceData[index + 1], sourceData[index + 2]]));
  }

  const smoothMask = new Uint8Array(width * height);
  const directions = [[-1, 0], [1, 0], [0, -1], [0, 1]];
  for (let row = 1; row < height - 1; row++) {
    for (let column = 1; column < width - 1; column++) {
      const index = row * width + column;
      const current = sourceLabs[index];
      if (!current) continue;
      let maximumNeighborDistance = 0;
      for (const [rowOffset, columnOffset] of directions) {
        const neighbor = sourceLabs[(row + rowOffset) * width + column + columnOffset];
        if (!neighbor) { maximumNeighborDistance = Number.POSITIVE_INFINITY; break; }
        maximumNeighborDistance = Math.max(maximumNeighborDistance, oklabDistance(current, neighbor));
      }
      if (maximumNeighborDistance <= 0.018) smoothMask[index] = 1;
    }
  }

  // Require a real two-dimensional smooth patch, so a one-cell antialiased
  // outline can never qualify as a gradient.
  const gradientPalette = new Set<string>();
  for (let row = 2; row < height - 2; row++) {
    for (let column = 2; column < width - 2; column++) {
      const index = row * width + column;
      if (!smoothMask[index]) continue;
      let smoothNeighbors = 0;
      for (let rowOffset = -1; rowOffset <= 1; rowOffset++) {
        for (let columnOffset = -1; columnOffset <= 1; columnOffset++) {
          if (smoothMask[(row + rowOffset) * width + column + columnOffset]) smoothNeighbors += 1;
        }
      }
      if (smoothNeighbors < 7) continue;
      const sourceLab = sourceLabs[index]!;
      const sourceChroma = Math.hypot(sourceLab[1], sourceLab[2]);
      // Near-neutral pixels should use the neutral MARD series. Otherwise a
      // barely visible numerical hue can create a conspicuous lavender/pink
      // stripe inside an otherwise blue-to-cream gradient.
      const gradientCandidates = sourceChroma < 0.035
        ? PALETTE.filter((color) => color.code.startsWith("H"))
        : PALETTE;
      const exact = nearestColor(sourceLab, gradientCandidates);
      if (!gradientPalette.has(exact.code) && gradientPalette.size >= 8) continue;
      gradientPalette.add(exact.code);
      result[index] = exact;
    }
  }
  return result;
}

function cleanGloballyRareIsolatedColors(colors: Array<BeadColor | null>, width: number, height: number) {
  const usage = new Map<string, number>();
  colors.forEach((color) => { if (color) usage.set(color.code, (usage.get(color.code) ?? 0) + 1); });
  const cleaned = [...colors];
  let changed = 0;
  for (let row = 0; row < height; row++) {
    for (let column = 0; column < width; column++) {
      const index = row * width + column;
      const current = colors[index];
      if (!current || (usage.get(current.code) ?? 0) > 2) continue;
      const neighbors = new Map<string, { color: BeadColor; count: number }>();
      for (let rowOffset = -1; rowOffset <= 1; rowOffset++) {
        for (let columnOffset = -1; columnOffset <= 1; columnOffset++) {
          if (rowOffset === 0 && columnOffset === 0) continue;
          const neighborRow = row + rowOffset;
          const neighborColumn = column + columnOffset;
          if (neighborRow < 0 || neighborColumn < 0 || neighborRow >= height || neighborColumn >= width) continue;
          const neighbor = colors[neighborRow * width + neighborColumn];
          if (!neighbor || neighbor.code === current.code) continue;
          const entry = neighbors.get(neighbor.code) ?? { color: neighbor, count: 0 };
          entry.count += 1;
          neighbors.set(neighbor.code, entry);
        }
      }
      const majority = [...neighbors.values()].sort((a, b) => b.count - a.count)[0];
      if (!majority) continue;
      const requiredSupport = (usage.get(current.code) ?? 0) === 1 ? 3 : 5;
      if (majority.count < requiredSupport) continue;
      cleaned[index] = majority.color;
      changed += 1;
    }
  }
  return { colors: cleaned, changed };
}

function sampleDominantRegions(
  image: HTMLImageElement,
  width: number,
  height: number,
  fitMode: "cover" | "contain",
  crop: CropRect,
) {
  // A mildly center-weighted 3x3 vote keeps hard object boundaries clean.
  // Broad gradients are preserved later when the MARD palette is selected,
  // rather than by blending pixels across object edges here.
  const scale = 3;
  const sampleWeights = [
    [1, 1, 1],
    [1, 2, 1],
    [1, 1, 1],
  ];
  const totalSampleWeight = 10;
  const sample = document.createElement("canvas");
  sample.width = width * scale;
  sample.height = height * scale;
  const ctx = sample.getContext("2d", { willReadFrequently: true })!;
  ctx.clearRect(0, 0, sample.width, sample.height);
  ctx.imageSmoothingEnabled = false;
  drawFittedImage(ctx, image, sample.width, sample.height, fitMode, crop);
  const source = ctx.getImageData(0, 0, sample.width, sample.height).data;
  const result = new Uint8ClampedArray(width * height * 4);
  const confidence = new Float32Array(width * height);
  const localMergeThreshold = 0.04;

  for (let row = 0; row < height; row++) {
    for (let column = 0; column < width; column++) {
      const groups: Array<{ count: number; weight: number; lab: Oklab; samples: Array<{ rgb: [number, number, number]; lab: Oklab }>; containsCenter: boolean }> = [];
      let transparentWeight = 0;
      for (let offsetY = 0; offsetY < scale; offsetY++) {
        for (let offsetX = 0; offsetX < scale; offsetX++) {
          const sourceIndex = ((row * scale + offsetY) * sample.width + column * scale + offsetX) * 4;
          const sampleWeight = sampleWeights[offsetY][offsetX];
          if (source[sourceIndex + 3] < TRANSPARENT_ALPHA_THRESHOLD) {
            transparentWeight += sampleWeight;
            continue;
          }
          const rgb: [number, number, number] = [source[sourceIndex], source[sourceIndex + 1], source[sourceIndex + 2]];
          const lab = rgbToOklab(rgb);
          const isCenter = offsetX === Math.floor(scale / 2) && offsetY === Math.floor(scale / 2);
          let closest: (typeof groups)[number] | undefined;
          let closestDistance = Number.POSITIVE_INFINITY;
          for (const group of groups) {
            const distance = oklabDistance(lab, group.lab);
            if (distance < closestDistance) {
              closest = group;
              closestDistance = distance;
            }
          }
          if (closest && closestDistance <= localMergeThreshold) {
            const totalWeight = closest.weight + sampleWeight;
            closest.lab = closest.lab.map((value, channel) => (value * closest.weight + lab[channel] * sampleWeight) / totalWeight) as Oklab;
            closest.weight = totalWeight;
            closest.count += 1;
            closest.samples.push({ rgb, lab });
            closest.containsCenter ||= isCenter;
          } else {
            groups.push({ count: 1, weight: sampleWeight, lab: [...lab], samples: [{ rgb, lab }], containsCenter: isCenter });
          }
        }
      }
      const targetIndex = (row * width + column) * 4;
      if (transparentWeight >= totalSampleWeight / 2 || groups.length === 0) {
        result[targetIndex + 3] = 0;
        confidence[row * width + column] = transparentWeight / totalSampleWeight;
        continue;
      }
      const rankedGroups = groups.map((group) => {
        let effectiveWeight = group.weight;
        if (group.count <= 2) {
          for (let first = 0; first < groups.length; first++) {
            for (let second = first + 1; second < groups.length; second++) {
              if (groups[first] === group || groups[second] === group) continue;
              if (groups[first].weight + groups[second].weight < group.weight) continue;
              if (isPerceptualBridge(group.lab, groups[first].lab, groups[second].lab)) effectiveWeight = 0;
            }
          }
        }
        return { group, effectiveWeight };
      });
      const dominant = rankedGroups.sort((a, b) => b.effectiveWeight - a.effectiveWeight || b.group.weight - a.group.weight || Number(b.group.containsCenter) - Number(a.group.containsCenter))[0].group;
      const representative = dominant.samples.reduce((best, current) =>
        oklabDistance(current.lab, dominant.lab) < oklabDistance(best.lab, dominant.lab) ? current : best,
      );
      result[targetIndex] = representative.rgb[0];
      result[targetIndex + 1] = representative.rgb[1];
      result[targetIndex + 2] = representative.rgb[2];
      result[targetIndex + 3] = 255;
      confidence[row * width + column] = dominant.weight / Math.max(1, totalSampleWeight - transparentWeight);
    }
  }
  return { data: result, confidence };
}

function cleanThinTransitionBands(colors: Array<BeadColor | null>, width: number, height: number) {
  const cleaned = [...colors];
  let changed = 0;

  for (let row = 0; row < height; row++) {
    for (let column = 0; column < width; column++) {
      const index = row * width + column;
      const current = colors[index];
      if (!current) continue;
      const neighbors = new Map<string, { color: BeadColor; count: number }>();
      let sameColorNeighbors = 0;
      for (let rowOffset = -1; rowOffset <= 1; rowOffset++) {
        for (let columnOffset = -1; columnOffset <= 1; columnOffset++) {
          if (rowOffset === 0 && columnOffset === 0) continue;
          const neighborRow = row + rowOffset;
          const neighborColumn = column + columnOffset;
          if (neighborRow < 0 || neighborColumn < 0 || neighborRow >= height || neighborColumn >= width) continue;
          const neighbor = colors[neighborRow * width + neighborColumn];
          if (!neighbor) continue;
          if (neighbor.code === current.code) {
            sameColorNeighbors += 1;
            continue;
          }
          const entry = neighbors.get(neighbor.code) ?? { color: neighbor, count: 0 };
          entry.count += 1;
          neighbors.set(neighbor.code, entry);
        }
      }
      // A true gradient band has broad same-color support. Anti-aliased edge
      // colors form a one-cell-wide chain and have at most two same-color
      // neighbors, regardless of how often that edge color occurs globally.
      if (sameColorNeighbors > 2) continue;
      const candidates = [...neighbors.values()].sort((a, b) => b.count - a.count).slice(0, 3);
      let replacement: BeadColor | null = null;
      let replacementSupport = 0;
      for (let first = 0; first < candidates.length; first++) {
        for (let second = first + 1; second < candidates.length; second++) {
          if (candidates[first].count + candidates[second].count < 4) continue;
          if (!isPerceptualBridge(current.lab, candidates[first].color.lab, candidates[second].color.lab)) continue;
          const preferred = candidates[first].count >= candidates[second].count ? candidates[first] : candidates[second];
          if (preferred.count > replacementSupport) {
            replacement = preferred.color;
            replacementSupport = preferred.count;
          }
        }
      }
      if (replacement) {
        cleaned[index] = replacement;
        changed += 1;
      }
    }
  }
  return { colors: cleaned, changed };
}

function cleanLowConfidenceIsolatedColors(
  colors: Array<BeadColor | null>,
  confidence: Float32Array,
  width: number,
  height: number,
) {
  const cleaned = [...colors];
  let changed = 0;
  const offsets = [-1, 0, 1];
  for (let row = 0; row < height; row++) {
    for (let column = 0; column < width; column++) {
      const index = row * width + column;
      const current = colors[index];
      if (!current) continue;
      if (confidence[index] >= 0.625) continue;
      const chroma = Math.hypot(current.lab[1], current.lab[2]);
      const protectedDetail = current.lab[0] < 0.32 || current.lab[0] > 0.96 || chroma > 0.14;
      if (protectedDetail) continue;

      const neighborCounts = new Map<string, { color: BeadColor; count: number; confidentCount: number }>();
      let neighborTotal = 0;
      let sameColorNeighbors = 0;
      for (const rowOffset of offsets) {
        for (const columnOffset of offsets) {
          if (rowOffset === 0 && columnOffset === 0) continue;
          const neighborRow = row + rowOffset;
          const neighborColumn = column + columnOffset;
          if (neighborRow < 0 || neighborColumn < 0 || neighborRow >= height || neighborColumn >= width) continue;
          const neighborIndex = neighborRow * width + neighborColumn;
          const neighbor = colors[neighborIndex];
          if (!neighbor) continue;
          if (neighbor.code === current.code) sameColorNeighbors += 1;
          const entry = neighborCounts.get(neighbor.code) ?? { color: neighbor, count: 0, confidentCount: 0 };
          entry.count += 1;
          if (confidence[neighborIndex] >= 0.625) entry.confidentCount += 1;
          neighborCounts.set(neighbor.code, entry);
          neighborTotal += 1;
        }
      }
      // A boundary corner is still part of a continuous object. Only a color with
      // no same-color support in all eight neighboring cells counts as isolated.
      if (sameColorNeighbors > 0) continue;
      const majority = [...neighborCounts.values()].sort((a, b) => b.count - a.count)[0];
      if (!majority || majority.color.code === current.code) continue;
      const requiredCount = neighborTotal >= 7 ? 5 : Math.max(2, Math.ceil(neighborTotal * 0.67));
      if (majority.count < requiredCount) continue;
      if (majority.confidentCount < Math.min(3, majority.count)) continue;
      // Large color jumps describe a real object boundary rather than an
      // anti-aliased transition color, so never let cleanup cross that edge.
      if (oklabDistance(current.lab, majority.color.lab) > 0.075) continue;
      cleaned[index] = majority.color;
      changed += 1;
    }
  }
  return { colors: cleaned, changed };
}

function drawFittedImage(
  ctx: CanvasRenderingContext2D,
  image: HTMLImageElement,
  width: number,
  height: number,
  fitMode: "cover" | "contain",
  crop: CropRect,
) {
  const sourceX = crop.x * image.naturalWidth;
  const sourceY = crop.y * image.naturalHeight;
  const sourceWidth = crop.width * image.naturalWidth;
  const sourceHeight = crop.height * image.naturalHeight;
  const imageRatio = sourceWidth / sourceHeight;
  const boxRatio = width / height;
  const sourceCenterX = sourceX + sourceWidth / 2;
  const sourceCenterY = sourceY + sourceHeight / 2;
  if (fitMode === "cover") {
    let centeredSourceWidth = sourceWidth;
    let centeredSourceHeight = sourceHeight;
    if (imageRatio > boxRatio) centeredSourceWidth = sourceHeight * boxRatio;
    else centeredSourceHeight = sourceWidth / boxRatio;
    ctx.drawImage(
      image,
      sourceCenterX - centeredSourceWidth / 2,
      sourceCenterY - centeredSourceHeight / 2,
      centeredSourceWidth,
      centeredSourceHeight,
      0,
      0,
      width,
      height,
    );
    return;
  }
  let drawWidth = width;
  let drawHeight = height;
  if (imageRatio < boxRatio) {
    drawHeight = height;
    drawWidth = height * imageRatio;
  } else {
    drawWidth = width;
    drawHeight = width / imageRatio;
  }
  const drawX = (width - drawWidth) / 2;
  const drawY = (height - drawHeight) / 2;
  // The selected area's center is always the grid's center. With odd grid
  // sizes it lands on the middle cell; with even sizes it lands on the
  // intersection of the four middle cells.
  ctx.drawImage(image, sourceX, sourceY, sourceWidth, sourceHeight, drawX, drawY, drawWidth, drawHeight);
}

function makeDemoImage() {
  const canvas = document.createElement("canvas");
  canvas.width = 640;
  canvas.height = 640;
  const ctx = canvas.getContext("2d")!;
  const sky = ctx.createLinearGradient(0, 0, 0, 640);
  sky.addColorStop(0, "#bfe8f1");
  sky.addColorStop(0.65, "#fff2ce");
  sky.addColorStop(1, "#f6c783");
  ctx.fillStyle = sky;
  ctx.fillRect(0, 0, 640, 640);
  ctx.fillStyle = "#f6c742";
  ctx.beginPath(); ctx.arc(488, 134, 70, 0, Math.PI * 2); ctx.fill();
  ctx.fillStyle = "#58a95b";
  ctx.beginPath(); ctx.moveTo(0, 455); ctx.quadraticCurveTo(135, 310, 300, 455); ctx.quadraticCurveTo(480, 285, 640, 435); ctx.lineTo(640, 640); ctx.lineTo(0, 640); ctx.fill();
  ctx.fillStyle = "#27865a";
  ctx.beginPath(); ctx.moveTo(0, 535); ctx.quadraticCurveTo(180, 390, 350, 520); ctx.quadraticCurveTo(505, 410, 640, 505); ctx.lineTo(640, 640); ctx.lineTo(0, 640); ctx.fill();
  ctx.fillStyle = "#f7f5ed";
  ctx.fillRect(245, 390, 152, 126);
  ctx.fillStyle = "#d9383a";
  ctx.beginPath(); ctx.moveTo(218, 405); ctx.lineTo(321, 323); ctx.lineTo(424, 405); ctx.closePath(); ctx.fill();
  ctx.fillStyle = "#754a32";
  ctx.fillRect(300, 447, 43, 69);
  ctx.fillStyle = "#79cbe1";
  ctx.fillRect(258, 415, 39, 37); ctx.fillRect(350, 415, 34, 37);
  return canvas.toDataURL("image/png");
}

export default function Home() {
  const [sourceUrl, setSourceUrl] = useState("");
  const [sourceName, setSourceName] = useState("示例：田野小屋");
  const [requestedWidth, setRequestedWidth] = useState(100);
  const [requestedHeight, setRequestedHeight] = useState(100);
  const [gridWidth, setGridWidth] = useState(100);
  const [gridHeight, setGridHeight] = useState(100);
  const [colorLimit, setColorLimit] = useState(64);
  const [fitMode, setFitMode] = useState<"cover" | "contain">("contain");
  const [samplingStrategy, setSamplingStrategy] = useState<SamplingStrategy>("dominant");
  const [cropRect, setCropRect] = useState<CropRect>(FULL_CROP);
  const [draftCropRect, setDraftCropRect] = useState<CropRect>(FULL_CROP);
  const [cropSelectionMode, setCropSelectionMode] = useState<CropSelectionMode>("free");
  const [cropDialogOpen, setCropDialogOpen] = useState(false);
  const [cropDialogPurpose, setCropDialogPurpose] = useState<CropDialogPurpose>("source");
  const [patternImportWidth, setPatternImportWidth] = useState(100);
  const [patternImportHeight, setPatternImportHeight] = useState(100);
  const [missingCodeAsEmpty, setMissingCodeAsEmpty] = useState(true);
  const [patternImportName, setPatternImportName] = useState("");
  const [patternImportUrl, setPatternImportUrl] = useState("");
  const [patternImportBusy, setPatternImportBusy] = useState(false);
  const [pixels, setPixels] = useState<Pixel[]>([]);
  const [selectedCodes, setSelectedCodes] = useState<Set<string>>(new Set());
  const [onlySelected, setOnlySelected] = useState(false);
  const [zoom, setZoom] = useState(8);
  const [showGrid, setShowGrid] = useState(true);
  const [showCodes, setShowCodes] = useState(true);
  const [query, setQuery] = useState("");
  const [hoveredPixel, setHoveredPixel] = useState<HoveredPixel | null>(null);
  const [history, setHistory] = useState<SavedPattern[]>([]);
  const [historyQuery, setHistoryQuery] = useState("");
  const [status, setStatus] = useState("示例图已准备好，可以直接转换");
  const sourceImageRef = useRef<HTMLImageElement | null>(null);
  const fileInputRef = useRef<HTMLInputElement | null>(null);
  const patternImportInputRef = useRef<HTMLInputElement | null>(null);
  const patternImportImageRef = useRef<HTMLImageElement | null>(null);
  const cropCanvasRef = useRef<HTMLCanvasElement | null>(null);
  const cropDialogCanvasRef = useRef<HTMLCanvasElement | null>(null);
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
  const patternStageRef = useRef<HTMLDivElement | null>(null);
  const zoomRef = useRef(zoom);
  const pendingWheelZoomRef = useRef<{
    stage: HTMLDivElement;
    cursorX: number;
    cursorY: number;
    canvasX: number;
    canvasY: number;
  } | null>(null);
  const cropDialogStageRef = useRef<HTMLDivElement | null>(null);
  const cropSelectionDragRef = useRef<CropSelectionDrag | null>(null);
  const patternDragRef = useRef<{
    pointerId: number;
    x: number;
    y: number;
    scrollLeft: number;
    scrollTop: number;
    moved: boolean;
  } | null>(null);
  const suppressCanvasClickRef = useRef(false);

  useEffect(() => {
    logUsage({ event: "page_view" });
    let cancelled = false;
    const loadHistory = async () => {
      try {
        let saved = await readPatternHistory();
        const legacy = localStorage.getItem(HISTORY_STORAGE_KEY);
        if (saved.length === 0 && legacy) {
          const parsed = JSON.parse(legacy);
          if (Array.isArray(parsed)) {
            saved = parsed.slice(0, HISTORY_LIMIT);
            await writePatternHistory(saved);
          }
          localStorage.removeItem(HISTORY_STORAGE_KEY);
        }
        if (!cancelled) setHistory(saved.slice(0, HISTORY_LIMIT));
      } catch {
        if (!cancelled) setStatus("历史记录读取失败，请检查浏览器是否允许本地存储");
      }
    };
    void loadHistory();
    return () => { cancelled = true; };
  }, []);

  const convertImage = (
    image = sourceImageRef.current,
    crop = cropRect,
    recordUsage = true,
  ) => {
    if (!image) return;
    const width = Math.max(8, Math.min(256, requestedWidth));
    const height = Math.max(8, Math.min(256, requestedHeight));
    let data: Uint8ClampedArray;
    let dominantConfidence: Float32Array | null = null;
    if (samplingStrategy === "dominant") {
      const sampled = sampleDominantRegions(image, width, height, fitMode, crop);
      data = sampled.data;
      dominantConfidence = sampled.confidence;
    } else {
      const sample = document.createElement("canvas");
      sample.width = width;
      sample.height = height;
      const ctx = sample.getContext("2d", { willReadFrequently: true })!;
      ctx.clearRect(0, 0, width, height);
      drawFittedImage(ctx, image, width, height, fitMode, crop);
      data = ctx.getImageData(0, 0, width, height).data;
    }
    const { pixelKeys, clusters, binClusters } = mergePerceptualColors(data);
    const limitedPalette = chooseDistinctMardColors(clusters, Math.max(2, Math.min(colorLimit, PALETTE.length)));
    const clusterMatches = matchClustersToPalette(clusters, limitedPalette, colorLimit);
    let matchedColors: Array<BeadColor | null> = pixelKeys.map((key) => key === null ? null : clusterMatches[binClusters.get(key) ?? 0]);
    let cleanedCount = 0;
    if (dominantConfidence) {
      const cleaned = cleanLowConfidenceIsolatedColors(matchedColors, dominantConfidence, width, height);
      matchedColors = cleaned.colors;
      cleanedCount = cleaned.changed;
      const transitionCleaned = cleanThinTransitionBands(matchedColors, width, height);
      matchedColors = transitionCleaned.colors;
      cleanedCount += transitionCleaned.changed;
      matchedColors = preserveSmoothGradientSteps(matchedColors, data, width, height, colorLimit);
      const rareCleaned = cleanGloballyRareIsolatedColors(matchedColors, width, height);
      matchedColors = rareCleaned.colors;
      cleanedCount += rareCleaned.changed;
    }
    const converted = matchedColors.map((color) => ({ color }));
    setGridWidth(width);
    setGridHeight(height);
    setRequestedWidth(width);
    setRequestedHeight(height);
    setPixels(converted);
    setSelectedCodes(new Set());
    const strategyName = samplingStrategy === "dominant" ? `区域主色 · 清理 ${cleanedCount} 个低可信孤立格` : "平滑取色";
    const actualColorCount = new Set(matchedColors.filter((color): color is BeadColor => color !== null).map((color) => color.code)).size;
    const actualBeadCount = matchedColors.filter((color) => color !== null).length;
    if (recordUsage) {
      logUsage({
        event: "conversion",
        width,
        height,
        sampling_strategy: samplingStrategy,
        color_limit: colorLimit,
        actual_colors: actualColorCount,
        bead_count: actualBeadCount,
        transparent_cells: width * height - actualBeadCount,
      });
    }
    setStatus(`已转换为 ${width} × ${height} · ${actualBeadCount} 颗拼豆 · ${strategyName} · ${actualColorCount} 种差异色`);
  };

  useEffect(() => {
    const demo = makeDemoImage();
    const image = new Image();
    image.onload = () => {
      sourceImageRef.current = image;
      setSourceUrl(demo);
      convertImage(image, FULL_CROP, false);
    };
    image.src = demo;
    // This intentionally runs once to create the initial example.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  useEffect(() => {
    const canvas = cropCanvasRef.current;
    const image = sourceImageRef.current;
    if (!canvas || !image) return;
    const targetWidth = Math.max(8, Math.min(256, requestedWidth || 8));
    const targetHeight = Math.max(8, Math.min(256, requestedHeight || 8));
    const ratio = targetWidth / targetHeight;
    const previewWidth = ratio >= 1 ? 600 : Math.max(120, Math.round(600 * ratio));
    const previewHeight = ratio >= 1 ? Math.max(120, Math.round(600 / ratio)) : 600;
    canvas.width = previewWidth;
    canvas.height = previewHeight;
    const ctx = canvas.getContext("2d")!;
    ctx.fillStyle = "#f7f5ed";
    ctx.fillRect(0, 0, previewWidth, previewHeight);
    drawFittedImage(ctx, image, previewWidth, previewHeight, fitMode, cropRect);
  }, [sourceUrl, requestedWidth, requestedHeight, fitMode, cropRect]);

  useEffect(() => {
    if (!cropDialogOpen) return;
    const canvas = cropDialogCanvasRef.current;
    const image = cropDialogPurpose === "pattern-import"
      ? patternImportImageRef.current
      : sourceImageRef.current;
    if (!canvas || !image) return;
    const maximumWidth = 1200;
    const maximumHeight = 760;
    const scale = Math.min(maximumWidth / image.naturalWidth, maximumHeight / image.naturalHeight, 1);
    canvas.width = Math.max(1, Math.round(image.naturalWidth * scale));
    canvas.height = Math.max(1, Math.round(image.naturalHeight * scale));
    const ctx = canvas.getContext("2d")!;
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
  }, [cropDialogOpen, cropDialogPurpose, sourceUrl, patternImportUrl]);

  useEffect(() => {
    if (!cropDialogOpen) return;
    const previousOverflow = document.body.style.overflow;
    document.body.style.overflow = "hidden";
    const closeOnEscape = (event: KeyboardEvent) => {
      if (event.key === "Escape") setCropDialogOpen(false);
    };
    window.addEventListener("keydown", closeOnEscape);
    return () => {
      document.body.style.overflow = previousOverflow;
      window.removeEventListener("keydown", closeOnEscape);
    };
  }, [cropDialogOpen]);

  const usedColors = useMemo(() => {
    const counts = new Map<string, number>();
    pixels.forEach(({ color }) => { if (color) counts.set(color.code, (counts.get(color.code) ?? 0) + 1); });
    return PALETTE.filter((color) => counts.has(color.code))
      .map((color) => ({ ...color, count: counts.get(color.code)! }))
      .sort((a, b) => b.count - a.count);
  }, [pixels]);

  const beadCount = useMemo(() => pixels.reduce((total, pixel) => total + (pixel.color ? 1 : 0), 0), [pixels]);

  const filteredColors = useMemo(() => {
    const key = query.trim().toLowerCase();
    return usedColors.filter((color) => !key || color.code.toLowerCase().includes(key) || color.name.includes(key) || color.hex.toLowerCase().includes(key));
  }, [query, usedColors]);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas || pixels.length === 0) return;
    const cell = zoom;
    const ruler = Math.max(22, cell);
    const ratio = Math.min(window.devicePixelRatio || 1, 2);
    canvas.width = (gridWidth * cell + ruler) * ratio;
    canvas.height = (gridHeight * cell + ruler) * ratio;
    canvas.style.width = `${gridWidth * cell + ruler}px`;
    canvas.style.height = `${gridHeight * cell + ruler}px`;
    const ctx = canvas.getContext("2d")!;
    ctx.scale(ratio, ratio);
    ctx.fillStyle = "#ffffff";
    ctx.fillRect(0, 0, gridWidth * cell + ruler, gridHeight * cell + ruler);
    ctx.fillStyle = "#f0eee7";
    ctx.fillRect(ruler, 0, gridWidth * cell, ruler);
    ctx.fillRect(0, ruler, ruler, gridHeight * cell);
    ctx.strokeStyle = "rgba(32,38,36,.25)";
    ctx.lineWidth = 0.6;
    ctx.font = `600 ${Math.max(7, Math.min(10, cell * 0.35))}px Arial`;
    ctx.fillStyle = "#56605b";
    ctx.textAlign = "center";
    ctx.textBaseline = "middle";
    const labelEvery = cell < 10 ? 10 : cell < 16 ? 5 : 1;
    for (let column = 0; column < gridWidth; column++) {
      const x = ruler + column * cell;
      ctx.strokeRect(x + 0.3, 0.3, cell - 0.6, ruler - 0.6);
      if (column === 0 || (column + 1) % labelEvery === 0) ctx.fillText(String(column + 1), x + cell / 2, ruler / 2);
    }
    for (let row = 0; row < gridHeight; row++) {
      const y = ruler + row * cell;
      ctx.strokeRect(0.3, y + 0.3, ruler - 0.6, cell - 0.6);
      if (row === 0 || (row + 1) % labelEvery === 0) ctx.fillText(String(row + 1), ruler / 2, y + cell / 2);
    }
    pixels.forEach(({ color }, index) => {
      const x = ruler + (index % gridWidth) * cell;
      const y = ruler + Math.floor(index / gridWidth) * cell;
      if (!color) {
        ctx.clearRect(x, y, cell, cell);
        if (showGrid) {
          ctx.strokeStyle = "rgba(32,38,36,.12)";
          ctx.lineWidth = 0.6;
          ctx.strokeRect(x + 0.3, y + 0.3, cell - 0.6, cell - 0.6);
        }
        return;
      }
      const hasSelection = selectedCodes.size > 0;
      const isSelected = selectedCodes.has(color.code);
      if (onlySelected && hasSelection && !isSelected) {
        ctx.fillStyle = "#ffffff";
      } else {
        ctx.fillStyle = color.hex;
        ctx.globalAlpha = hasSelection && !isSelected ? 0.12 : 1;
        ctx.fillRect(x, y, cell, cell);
        ctx.globalAlpha = 1;
      }
      if (showGrid) {
        ctx.strokeStyle = hasSelection && !isSelected ? "rgba(32,38,36,.07)" : "rgba(32,38,36,.22)";
        ctx.lineWidth = 0.6;
        ctx.strokeRect(x + 0.3, y + 0.3, cell - 0.6, cell - 0.6);
      }
      if (showCodes && cell >= 22 && (!hasSelection || isSelected)) {
        const [r, g, b] = hexToRgb(color.hex);
        ctx.fillStyle = r * 0.299 + g * 0.587 + b * 0.114 > 160 ? "#1f2925" : "#ffffff";
        ctx.font = `600 ${Math.max(7, cell * 0.28)}px Arial`;
        ctx.textAlign = "center";
        ctx.textBaseline = "middle";
        ctx.fillText(color.code, x + cell / 2, y + cell / 2);
      }
    });
    if (showGrid) {
      ctx.strokeStyle = "rgba(32,38,36,.48)";
      ctx.lineWidth = 1.5;
      for (let column = 5; column < gridWidth; column += 5) {
        const x = ruler + column * cell;
        ctx.beginPath(); ctx.moveTo(x, ruler); ctx.lineTo(x, ruler + gridHeight * cell); ctx.stroke();
      }
      for (let row = 5; row < gridHeight; row += 5) {
        const y = ruler + row * cell;
        ctx.beginPath(); ctx.moveTo(ruler, y); ctx.lineTo(ruler + gridWidth * cell, y); ctx.stroke();
      }
    }
  }, [pixels, gridWidth, gridHeight, zoom, selectedCodes, onlySelected, showGrid, showCodes]);

  useEffect(() => {
    zoomRef.current = zoom;
    const pending = pendingWheelZoomRef.current;
    if (!pending) return;
    pendingWheelZoomRef.current = null;
    const frame = window.requestAnimationFrame(() => {
      const { stage } = pending;
      const canvas = canvasRef.current;
      if (!canvas) return;
      const stageRect = stage.getBoundingClientRect();
      const canvasRect = canvas.getBoundingClientRect();
      const anchorX = canvasRect.left + canvasRect.width * pending.canvasX;
      const anchorY = canvasRect.top + canvasRect.height * pending.canvasY;
      stage.scrollLeft += anchorX - (stageRect.left + pending.cursorX);
      stage.scrollTop += anchorY - (stageRect.top + pending.cursorY);
    });
    return () => window.cancelAnimationFrame(frame);
  }, [zoom]);

  useEffect(() => {
    const stage = patternStageRef.current;
    if (!stage) return;
    const handleWheel = (event: WheelEvent) => {
      if (pixels.length === 0 || event.deltaY === 0) return;
      event.preventDefault();
      const currentZoom = zoomRef.current;
      const nextZoom = Math.max(3, Math.min(42, currentZoom + (event.deltaY < 0 ? 1 : -1)));
      if (nextZoom === currentZoom) return;
      const rect = stage.getBoundingClientRect();
      const canvasRect = canvasRef.current?.getBoundingClientRect();
      const cursorX = event.clientX - rect.left;
      const cursorY = event.clientY - rect.top;
      pendingWheelZoomRef.current = {
        stage,
        cursorX,
        cursorY,
        canvasX: canvasRect ? (event.clientX - canvasRect.left) / Math.max(1, canvasRect.width) : 0.5,
        canvasY: canvasRect ? (event.clientY - canvasRect.top) / Math.max(1, canvasRect.height) : 0.5,
      };
      zoomRef.current = nextZoom;
      setZoom(nextZoom);
    };
    stage.addEventListener("wheel", handleWheel, { passive: false });
    return () => stage.removeEventListener("wheel", handleWheel);
  }, [pixels.length]);

  const handleUpload = (event: ChangeEvent<HTMLInputElement>) => {
    const file = event.target.files?.[0];
    if (!file) return;
    if (!file.type.startsWith("image/")) {
      setStatus("请选择 JPG、PNG 或 WebP 图片");
      return;
    }
    const url = URL.createObjectURL(file);
    const image = new Image();
    image.onload = () => {
      if (sourceUrl.startsWith("blob:")) URL.revokeObjectURL(sourceUrl);
      sourceImageRef.current = image;
      setSourceUrl(url);
      setSourceName(file.name);
      setCropRect(FULL_CROP);
      setDraftCropRect(FULL_CROP);
      setStatus("图片已载入，点击“重新转换”生成图纸");
      convertImage(image, FULL_CROP);
    };
    image.src = url;
  };

  const handlePatternImportUpload = (event: ChangeEvent<HTMLInputElement>) => {
    const file = event.target.files?.[0];
    event.target.value = "";
    if (!file) return;
    if (!file.type.startsWith("image/")) {
      setStatus("请选择 JPG、PNG 或 WebP 图纸图片");
      return;
    }
    const url = URL.createObjectURL(file);
    const image = new Image();
    image.onload = () => {
      if (patternImportUrl.startsWith("blob:") && patternImportUrl !== sourceUrl) URL.revokeObjectURL(patternImportUrl);
      patternImportImageRef.current = image;
      setPatternImportUrl(url);
      setPatternImportName(file.name);
      setPatternImportWidth(gridWidth);
      setPatternImportHeight(gridHeight);
      setCropDialogPurpose("pattern-import");
      setCropSelectionMode("free");
      setDraftCropRect(FULL_CROP);
      setCropDialogOpen(true);
      setStatus("图纸图片已载入，请框选实际网格并填写行列数");
    };
    image.onerror = () => {
      URL.revokeObjectURL(url);
      setStatus("图纸图片读取失败，请换一张图片重试");
    };
    image.src = url;
  };

  const openCropDialog = () => {
    setCropDialogPurpose("source");
    setDraftCropRect(cropRect);
    setCropDialogOpen(true);
  };

  const cropPoint = (event: React.PointerEvent<HTMLDivElement>) => {
    const stage = cropDialogStageRef.current;
    if (!stage) return { x: 0, y: 0 };
    const rect = stage.getBoundingClientRect();
    return {
      x: Math.max(0, Math.min(1, (event.clientX - rect.left) / Math.max(1, rect.width))),
      y: Math.max(0, Math.min(1, (event.clientY - rect.top) / Math.max(1, rect.height))),
    };
  };

  const updateDraftCrop = (startX: number, startY: number, endX: number, endY: number) => {
    const directionX = endX >= startX ? 1 : -1;
    const directionY = endY >= startY ? 1 : -1;
    let width = Math.abs(endX - startX);
    let height = Math.abs(endY - startY);
    if (cropSelectionMode === "square") {
      const image = cropDialogPurpose === "pattern-import"
        ? patternImportImageRef.current
        : sourceImageRef.current;
      if (!image) return;
      const normalizedSquareRatio = image.naturalHeight / image.naturalWidth;
      if (width / Math.max(height, 0.0001) > normalizedSquareRatio) width = height * normalizedSquareRatio;
      else height = width / Math.max(normalizedSquareRatio, 0.0001);
      const availableWidth = directionX > 0 ? 1 - startX : startX;
      const availableHeight = directionY > 0 ? 1 - startY : startY;
      const scale = Math.min(1, availableWidth / Math.max(width, 0.0001), availableHeight / Math.max(height, 0.0001));
      width *= scale;
      height *= scale;
    }
    width = Math.max(0.01, Math.min(1, width));
    height = Math.max(0.01, Math.min(1, height));
    setDraftCropRect({
      x: Math.max(0, Math.min(1 - width, directionX > 0 ? startX : startX - width)),
      y: Math.max(0, Math.min(1 - height, directionY > 0 ? startY : startY - height)),
      width,
      height,
    });
  };

  const pointInsideCrop = (point: { x: number; y: number }) => (
    (draftCropRect.width < 0.995 || draftCropRect.height < 0.995)
    &&
    point.x >= draftCropRect.x
    && point.x <= draftCropRect.x + draftCropRect.width
    && point.y >= draftCropRect.y
    && point.y <= draftCropRect.y + draftCropRect.height
  );

  const chooseCropSelectionMode = (mode: CropSelectionMode) => {
    setCropSelectionMode(mode);
    if (mode !== "square") return;
    const image = cropDialogPurpose === "pattern-import"
      ? patternImportImageRef.current
      : sourceImageRef.current;
    if (!image) return;
    const currentCenterX = draftCropRect.x + draftCropRect.width / 2;
    const currentCenterY = draftCropRect.y + draftCropRect.height / 2;
    const sideInPixels = Math.min(
      draftCropRect.width * image.naturalWidth,
      draftCropRect.height * image.naturalHeight,
    );
    const width = sideInPixels / image.naturalWidth;
    const height = sideInPixels / image.naturalHeight;
    setDraftCropRect({
      x: Math.max(0, Math.min(1 - width, currentCenterX - width / 2)),
      y: Math.max(0, Math.min(1 - height, currentCenterY - height / 2)),
      width,
      height,
    });
  };

  const resizeDraftCrop = (drag: CropSelectionDrag, point: { x: number; y: number }) => {
    const handle = drag.handle;
    if (!handle) return;
    const origin = drag.origin;
    if (cropSelectionMode === "square") {
      const anchorX = handle.includes("w") ? origin.x + origin.width : origin.x;
      const anchorY = handle.includes("n") ? origin.y + origin.height : origin.y;
      updateDraftCrop(anchorX, anchorY, point.x, point.y);
      return;
    }
    const minimumSize = 0.03;
    let left = origin.x;
    let top = origin.y;
    let right = origin.x + origin.width;
    let bottom = origin.y + origin.height;
    if (handle.includes("w")) left = Math.max(0, Math.min(right - minimumSize, point.x));
    if (handle.includes("e")) right = Math.min(1, Math.max(left + minimumSize, point.x));
    if (handle.includes("n")) top = Math.max(0, Math.min(bottom - minimumSize, point.y));
    if (handle.includes("s")) bottom = Math.min(1, Math.max(top + minimumSize, point.y));
    setDraftCropRect({ x: left, y: top, width: right - left, height: bottom - top });
  };

  const handleCropSelectionDown = (event: React.PointerEvent<HTMLDivElement>) => {
    if (event.button !== 0) return;
    const point = cropPoint(event);
    event.currentTarget.setPointerCapture(event.pointerId);
    const resizeHandle = (event.target as HTMLElement).dataset.cropHandle as CropResizeHandle | undefined;
    if (resizeHandle) {
      cropSelectionDragRef.current = {
        pointerId: event.pointerId,
        mode: "resize",
        startX: point.x,
        startY: point.y,
        origin: draftCropRect,
        handle: resizeHandle,
      };
      event.currentTarget.classList.add("is-resizing-selection");
      return;
    }
    if (pointInsideCrop(point) && draftCropRect.width >= 0.03 && draftCropRect.height >= 0.03) {
      cropSelectionDragRef.current = {
        pointerId: event.pointerId,
        mode: "move",
        startX: point.x,
        startY: point.y,
        origin: draftCropRect,
      };
      event.currentTarget.classList.add("is-moving-selection");
      return;
    }
    cropSelectionDragRef.current = {
      pointerId: event.pointerId,
      mode: "create",
      startX: point.x,
      startY: point.y,
      origin: draftCropRect,
    };
    setDraftCropRect({ x: Math.min(0.99, point.x), y: Math.min(0.99, point.y), width: 0.01, height: 0.01 });
  };

  const handleCropSelectionMove = (event: React.PointerEvent<HTMLDivElement>) => {
    const drag = cropSelectionDragRef.current;
    const point = cropPoint(event);
    if (!drag) {
      event.currentTarget.classList.toggle("can-move-selection", pointInsideCrop(point));
      return;
    }
    if (drag.pointerId !== event.pointerId) return;
    if (drag.mode === "resize") {
      resizeDraftCrop(drag, point);
      return;
    }
    if (drag.mode === "move") {
      const x = Math.max(0, Math.min(1 - drag.origin.width, drag.origin.x + point.x - drag.startX));
      const y = Math.max(0, Math.min(1 - drag.origin.height, drag.origin.y + point.y - drag.startY));
      setDraftCropRect({ ...drag.origin, x, y });
      return;
    }
    updateDraftCrop(drag.startX, drag.startY, point.x, point.y);
  };

  const handleCropSelectionEnd = (event: React.PointerEvent<HTMLDivElement>) => {
    if (cropSelectionDragRef.current?.pointerId === event.pointerId) cropSelectionDragRef.current = null;
    event.currentTarget.classList.remove("is-moving-selection");
    event.currentTarget.classList.remove("is-resizing-selection");
    if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
  };

  const confirmPatternImport = async () => {
    const image = patternImportImageRef.current;
    const width = Math.max(1, Math.min(256, Math.round(patternImportWidth)));
    const height = Math.max(1, Math.min(256, Math.round(patternImportHeight)));
    if (!image) {
      setStatus("请先选择要导入的图纸图片");
      return;
    }
    if (draftCropRect.width < 0.03 || draftCropRect.height < 0.03) {
      setStatus("网格区域太小，请重新框选");
      return;
    }
    setPatternImportBusy(true);
    setStatus("正在读取 MARD 编码并匹配格子底色…");
    await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()));
    try {
      const result = importMardPatternImage(image, draftCropRect, width, height, missingCodeAsEmpty);
      const actualColors = new Set(result.pixels.flatMap(({ color }) => color ? [color.code] : [])).size;
      const beadCount = result.pixels.reduce((total, { color }) => total + (color ? 1 : 0), 0);
      setPixels(result.pixels);
      setGridWidth(width);
      setGridHeight(height);
      setRequestedWidth(width);
      setRequestedHeight(height);
      setSelectedCodes(new Set());
      setCropRect(draftCropRect);
      sourceImageRef.current = image;
      setSourceUrl(patternImportUrl);
      setSourceName(`导入图纸：${patternImportName}`);
      setCropDialogOpen(false);
      setStatus(`已导入 ${width} × ${height} · 读取编码 ${result.recognizedCodes} 格 · 底色匹配 ${result.colorMatches} 格 · 空白 ${result.emptyCells} 格`);
      logUsage({
        event: "pattern_import",
        width,
        height,
        recognized_codes: result.recognizedCodes,
        color_matches: result.colorMatches,
        transparent_cells: result.emptyCells,
        actual_colors: actualColors,
        bead_count: beadCount,
        missing_code_as_empty: missingCodeAsEmpty,
      });
      window.requestAnimationFrame(() => document.getElementById("pattern-preview")?.scrollIntoView({ behavior: "smooth", block: "start" }));
    } catch {
      setStatus("图纸识别失败，请确认选框与网格行列准确后重试");
    } finally {
      setPatternImportBusy(false);
    }
  };

  const confirmCrop = () => {
    if (draftCropRect.width < 0.03 || draftCropRect.height < 0.03) {
      setStatus("框选区域太小，请在大图上拖出更大的选框");
      return;
    }
    if (cropDialogPurpose === "pattern-import") {
      void confirmPatternImport();
      return;
    }
    setCropRect(draftCropRect);
    setCropDialogOpen(false);
    convertImage(sourceImageRef.current, draftCropRect);
  };

  const toggleColor = (code: string) => {
    setSelectedCodes((current) => {
      const next = new Set(current);
      if (next.has(code)) next.delete(code); else next.add(code);
      return next;
    });
  };

  const handlePatternPointerDown = (event: React.PointerEvent<HTMLCanvasElement>) => {
    if (event.button !== 0) return;
    const stage = patternStageRef.current;
    if (!stage) return;
    suppressCanvasClickRef.current = false;
    event.currentTarget.setPointerCapture(event.pointerId);
    patternDragRef.current = {
      pointerId: event.pointerId,
      x: event.clientX,
      y: event.clientY,
      scrollLeft: stage.scrollLeft,
      scrollTop: stage.scrollTop,
      moved: false,
    };
  };

  const handlePatternPointerMove = (event: React.PointerEvent<HTMLCanvasElement>) => {
    const stage = patternStageRef.current;
    const drag = patternDragRef.current;
    if (!stage || !drag || drag.pointerId !== event.pointerId) return;
    const deltaX = event.clientX - drag.x;
    const deltaY = event.clientY - drag.y;
    if (!drag.moved && Math.hypot(deltaX, deltaY) < 5) return;
    drag.moved = true;
    stage.classList.add("is-dragging");
    stage.scrollLeft = drag.scrollLeft - deltaX;
    stage.scrollTop = drag.scrollTop - deltaY;
    setHoveredPixel(null);
  };

  const handlePatternPointerUp = (event: React.PointerEvent<HTMLCanvasElement>) => {
    const stage = patternStageRef.current;
    const drag = patternDragRef.current;
    if (!drag || drag.pointerId !== event.pointerId) return;
    suppressCanvasClickRef.current = drag.moved;
    patternDragRef.current = null;
    stage?.classList.remove("is-dragging");
    if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
  };

  const handlePatternPointerCancel = (event: React.PointerEvent<HTMLCanvasElement>) => {
    const drag = patternDragRef.current;
    if (drag?.pointerId !== event.pointerId) return;
    patternDragRef.current = null;
    suppressCanvasClickRef.current = false;
    patternStageRef.current?.classList.remove("is-dragging");
    if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
  };

  const handlePatternLostPointerCapture = (event: React.PointerEvent<HTMLCanvasElement>) => {
    if (patternDragRef.current?.pointerId !== event.pointerId) return;
    patternDragRef.current = null;
    suppressCanvasClickRef.current = false;
    patternStageRef.current?.classList.remove("is-dragging");
  };

  const handleCanvasClick = (event: React.MouseEvent<HTMLCanvasElement>) => {
    if (suppressCanvasClickRef.current) {
      suppressCanvasClickRef.current = false;
      return;
    }
    const canvas = canvasRef.current;
    if (!canvas) return;
    const rect = canvas.getBoundingClientRect();
    const ruler = Math.max(22, zoom);
    const x = Math.floor((event.clientX - rect.left - ruler) / zoom);
    const y = Math.floor((event.clientY - rect.top - ruler) / zoom);
    const pixel = pixels[y * gridWidth + x];
    if (x >= 0 && y >= 0 && x < gridWidth && y < gridHeight && pixel?.color) toggleColor(pixel.color.code);
  };

  const handleCanvasMove = (event: React.MouseEvent<HTMLCanvasElement>) => {
    if (patternDragRef.current?.moved) {
      setHoveredPixel(null);
      return;
    }
    const canvas = canvasRef.current;
    if (!canvas) return;
    const rect = canvas.getBoundingClientRect();
    const ruler = Math.max(22, zoom);
    const column = Math.floor((event.clientX - rect.left - ruler) / zoom);
    const row = Math.floor((event.clientY - rect.top - ruler) / zoom);
    const pixel = pixels[row * gridWidth + column];
    if (column < 0 || row < 0 || column >= gridWidth || row >= gridHeight || !pixel?.color) {
      setHoveredPixel(null);
      return;
    }
    setHoveredPixel({ row: row + 1, column: column + 1, color: pixel.color, left: event.clientX - rect.left + 14, top: event.clientY - rect.top + 14 });
  };

  const persistHistory = async (next: SavedPattern[]) => {
    await writePatternHistory(next);
    setHistory(next);
  };

  const savePattern = async () => {
    if (!pixels.length) return;
    try {
      const entry: SavedPattern = {
        id: createHistoryId(),
        name: sourceName.replace(/\.[^.]+$/, "") || "未命名图纸",
        savedAt: new Date().toISOString(),
        width: gridWidth,
        height: gridHeight,
        colorLimit,
        codes: pixels.map((pixel) => pixel.color?.code ?? null),
      };
      await persistHistory([entry, ...history].slice(0, HISTORY_LIMIT));
      setHistoryQuery("");
      setStatus("图纸已保存到本机历史记录");
      requestAnimationFrame(() => document.getElementById("history-title")?.scrollIntoView({ behavior: "smooth", block: "start" }));
    } catch {
      setStatus("保存历史失败，请检查浏览器是否允许本地存储或剩余空间是否充足");
    }
  };

  const restorePattern = (entry: SavedPattern) => {
    const colorMap = new Map(PALETTE.map((color) => [color.code, color]));
    setPixels(entry.codes.map((code) => ({ color: code === null ? null : colorMap.get(code) ?? PALETTE[0] })));
    setGridWidth(entry.width);
    setGridHeight(entry.height);
    setRequestedWidth(entry.width);
    setRequestedHeight(entry.height);
    setColorLimit(entry.colorLimit);
    setSourceName(entry.name);
    setSelectedCodes(new Set());
    setStatus(`已恢复历史图纸：${entry.name}`);
    document.getElementById("pattern-preview")?.scrollIntoView({ behavior: "smooth" });
  };

  const removePattern = async (id: string) => {
    try {
      await persistHistory(history.filter((entry) => entry.id !== id));
    } catch {
      setStatus("删除历史失败，请稍后重试");
    }
  };

  const filteredHistory = useMemo(() => {
    const key = historyQuery.trim().toLowerCase();
    return history.filter((entry) => !key || entry.name.toLowerCase().includes(key) || `${entry.width}x${entry.height}`.includes(key));
  }, [history, historyQuery]);

  const exportPng = () => {
    if (!pixels.length) return;
    const longestSide = Math.max(gridWidth, gridHeight);
    const maximumExportSide = 8192;
    const preferredCell = longestSide <= 120 ? 56 : longestSide <= 180 ? 40 : 32;
    const cell = Math.max(24, Math.min(preferredCell, Math.floor((maximumExportSide - 72) / longestSide)));
    const ruler = Math.max(48, cell + 16);
    const gridPixelWidth = gridWidth * cell;
    const gridPixelHeight = gridHeight * cell;
    const canvasWidth = ruler + gridPixelWidth;
    const legendPadding = 18;
    const legendItemWidth = 128;
    const legendItemHeight = 42;
    const legendColumns = Math.max(1, Math.floor((canvasWidth - legendPadding * 2) / legendItemWidth));
    const legendRows = Math.ceil(usedColors.length / legendColumns);
    const legendHeight = usedColors.length > 0 ? legendPadding * 2 + legendRows * legendItemHeight : 0;
    const canvas = document.createElement("canvas");
    canvas.width = canvasWidth;
    canvas.height = ruler + gridPixelHeight + legendHeight;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    ctx.fillStyle = "#ffffff";
    ctx.fillRect(0, 0, canvas.width, canvas.height);
    ctx.fillStyle = "#f0eee7";
    ctx.fillRect(ruler, 0, gridPixelWidth, ruler);
    ctx.fillRect(0, ruler, ruler, gridPixelHeight);
    ctx.strokeStyle = "rgba(32,38,36,.34)";
    ctx.lineWidth = 1;
    ctx.font = `700 ${Math.max(9, Math.floor(cell * 0.32))}px Arial`;
    ctx.fillStyle = "#45504b";
    ctx.textAlign = "center";
    ctx.textBaseline = "middle";

    for (let column = 0; column < gridWidth; column += 1) {
      const x = ruler + column * cell;
      ctx.strokeRect(x + 0.5, 0.5, cell - 1, ruler - 1);
      ctx.fillText(String(column + 1), x + cell / 2, ruler / 2);
    }
    for (let row = 0; row < gridHeight; row += 1) {
      const y = ruler + row * cell;
      ctx.strokeRect(0.5, y + 0.5, ruler - 1, cell - 1);
      ctx.fillText(rowNumberToLetters(row + 1), ruler / 2, y + cell / 2);
    }

    pixels.forEach(({ color }, index) => {
      const column = index % gridWidth;
      const row = Math.floor(index / gridWidth);
      const x = ruler + column * cell;
      const y = ruler + row * cell;
      if (color) {
        ctx.fillStyle = color.hex;
        ctx.fillRect(x, y, cell, cell);
        const label = color.code;
        const [red, green, blue] = hexToRgb(color.hex);
        ctx.fillStyle = red * 0.299 + green * 0.587 + blue * 0.114 > 160 ? "#18211d" : "#ffffff";
        let fontSize = Math.max(8, Math.floor(cell * 0.31));
        ctx.font = `700 ${fontSize}px Arial`;
        while (fontSize > 8 && ctx.measureText(label).width > cell - 5) {
          fontSize -= 1;
          ctx.font = `700 ${fontSize}px Arial`;
        }
        ctx.textAlign = "center";
        ctx.textBaseline = "middle";
        ctx.fillText(label, x + cell / 2, y + cell / 2);
      }
      ctx.strokeStyle = "rgba(32,38,36,.24)";
      ctx.lineWidth = 1;
      ctx.strokeRect(x + 0.5, y + 0.5, cell - 1, cell - 1);
    });

    ctx.strokeStyle = "rgba(32,38,36,.58)";
    ctx.lineWidth = 2;
    for (let column = 5; column < gridWidth; column += 5) {
      const x = ruler + column * cell;
      ctx.beginPath();
      ctx.moveTo(x, ruler);
      ctx.lineTo(x, ruler + gridPixelHeight);
      ctx.stroke();
    }
    for (let row = 5; row < gridHeight; row += 5) {
      const y = ruler + row * cell;
      ctx.beginPath();
      ctx.moveTo(ruler, y);
      ctx.lineTo(ruler + gridPixelWidth, y);
      ctx.stroke();
    }
    ctx.strokeStyle = "#202624";
    ctx.lineWidth = 2;
    ctx.strokeRect(ruler, ruler, gridPixelWidth, gridPixelHeight);

    const legendTop = ruler + gridPixelHeight;
    if (usedColors.length > 0) {
      ctx.strokeStyle = "#d7d2c7";
      ctx.lineWidth = 1;
      ctx.beginPath();
      ctx.moveTo(0, legendTop + 0.5);
      ctx.lineTo(canvasWidth, legendTop + 0.5);
      ctx.stroke();
      usedColors.forEach((color, index) => {
        const column = index % legendColumns;
        const row = Math.floor(index / legendColumns);
        const x = legendPadding + column * legendItemWidth;
        const y = legendTop + legendPadding + row * legendItemHeight;
        ctx.fillStyle = color.hex;
        ctx.beginPath();
        ctx.arc(x + 12, y + 13, 10, 0, Math.PI * 2);
        ctx.fill();
        ctx.strokeStyle = "rgba(32,38,36,.28)";
        ctx.lineWidth = 1;
        ctx.stroke();
        ctx.fillStyle = "#202624";
        ctx.font = "700 13px Arial";
        ctx.textAlign = "left";
        ctx.textBaseline = "middle";
        ctx.fillText(`${color.code} · ${color.count}颗`, x + 28, y + 13);
      });
    }

    canvas.toBlob((blob) => {
      if (!blob) {
        setStatus("PNG 生成失败，请减少图纸格数后重试");
        return;
      }
      const url = URL.createObjectURL(blob);
      const link = document.createElement("a");
      link.download = `拼豆图纸-${gridWidth}x${gridHeight}.png`;
      link.href = url;
      link.click();
      window.setTimeout(() => URL.revokeObjectURL(url), 1000);
      setStatus(`高清 PNG 已生成：${canvas.width} × ${canvas.height}px，底部图例包含色号和用量`);
      logUsage({ event: "download_png", width: gridWidth, height: gridHeight, colors: usedColors.length, export_width: canvas.width, export_height: canvas.height });
    }, "image/png");
  };

  return (
    <main>
      <header className="topbar">
        <a className="brand" href="#top" aria-label="豆格工坊首页">
          <span className="brand-mark" aria-hidden="true"><i /><i /><i /><i /></span>
          <span>豆格工坊</span>
        </a>
        <span className="top-note">图片转拼豆图纸 · 本地处理</span>
      </header>

      <section className="hero" id="top">
        <div>
          <p className="eyebrow">PIXEL BEAD STUDIO</p>
          <h1>把喜欢的图片，<br /><em>变成一格一格的快乐。</em></h1>
          <p className="hero-copy">上传图片，选择图纸大小和颜色数量；点击任意像素格，就能单独查看该颜色需要摆放的区域。</p>
        </div>
        <div className="hero-badge"><strong>{gridWidth} × {gridHeight}</strong><span>当前图纸格数</span></div>
      </section>

      <section className="workspace" aria-label="拼豆图纸转换工具">
        <aside className="control-panel">
          <div className="panel-heading"><span>01</span><div><h2>图片与规格</h2><p>先决定图纸需要多少格</p></div></div>
          <div className="upload-card crop-preview" style={{ aspectRatio: `${Math.max(8, requestedWidth || 8)} / ${Math.max(8, requestedHeight || 8)}` }}>
            <canvas ref={cropCanvasRef} aria-label="当前取景区域预览" />
            <button
              type="button"
              className="upload-overlay"
              onClick={() => fileInputRef.current?.click()}
            >更换图片</button>
            <input ref={fileInputRef} className="file-input" type="file" accept="image/png,image/jpeg,image/webp" onChange={handleUpload} />
          </div>
          <p className="file-name" title={sourceName}>{sourceName}</p>

          <button className="crop-select-button" onClick={openCropDialog}>选择取景区域</button>
          <button className="reset-crop" onClick={() => { setCropRect(FULL_CROP); setDraftCropRect(FULL_CROP); setStatus("已恢复使用整张图片"); }}>使用整张图片</button>
          {ENABLE_PATTERN_IMPORT && <>
            <button className="pattern-import-button" onClick={() => patternImportInputRef.current?.click()}>导入现成图纸</button>
            <input ref={patternImportInputRef} className="file-input pattern-import-file" type="file" accept="image/png,image/jpeg,image/webp" onChange={handlePatternImportUpload} />
          </>}

          <div className="field-row">
            <label><span>横向格数</span><input type="number" min="8" max="256" value={requestedWidth} onChange={(e) => setRequestedWidth(Number(e.target.value))} /></label>
            <span className="times">×</span>
            <label><span>纵向格数</span><input type="number" min="8" max="256" value={requestedHeight} onChange={(e) => setRequestedHeight(Number(e.target.value))} /></label>
          </div>

          <label className="field"><span>最多使用颜色 <b>{colorLimit}</b></span><input type="range" min="2" max="64" value={colorLimit} onChange={(e) => setColorLimit(Number(e.target.value))} /></label>
          <label className="field"><span>取色策略</span><select value={samplingStrategy} onChange={(e) => setSamplingStrategy(e.target.value as SamplingStrategy)}><option value="dominant">区域主色（减少杂色）</option><option value="smooth">平滑取色（细节丰富）</option></select></label>
          <label className="field"><span>图片适应方式</span><select value={fitMode} onChange={(e) => setFitMode(e.target.value as "cover" | "contain")}><option value="contain">完整显示</option><option value="cover">裁剪铺满</option></select></label>
          <button className="primary-button" onClick={() => convertImage()}>重新转换</button>
          <p className="privacy-note">图片只在当前浏览器中处理，不会上传到服务器。</p>
        </aside>

        <section className="pattern-panel" id="pattern-preview">
          <div className="pattern-toolbar">
            <div><p className="section-kicker">02 / 图纸预览</p><h2>点击格子，筛选颜色</h2></div>
            <div className="zoom-control" aria-label="图纸缩放">
              <button onClick={() => setZoom((z) => Math.max(3, z - 1))} aria-label="缩小">−</button>
              <input aria-label="缩放比例" type="range" min="3" max="42" step="1" value={zoom} onChange={(e) => setZoom(Number(e.target.value))} />
              <button onClick={() => setZoom((z) => Math.min(42, z + 1))} aria-label="放大">＋</button>
              <output>{zoom}px</output>
            </div>
          </div>

          <div className="canvas-viewport">
            <div
              className="canvas-stage"
              ref={patternStageRef}
              title="鼠标位于预览区时，可滚动滚轮缩放图纸"
            >
              <div className="canvas-wrap">
                <canvas
                  ref={canvasRef}
                  onPointerDown={handlePatternPointerDown}
                  onPointerMove={handlePatternPointerMove}
                  onPointerUp={handlePatternPointerUp}
                  onPointerCancel={handlePatternPointerCancel}
                  onLostPointerCapture={handlePatternLostPointerCapture}
                  onContextMenu={(event) => event.preventDefault()}
                  onClick={handleCanvasClick}
                  onMouseMove={handleCanvasMove}
                  onMouseLeave={() => setHoveredPixel(null)}
                  aria-label="转换后的拼豆像素图，可拖动查看，轻点格子选择颜色"
                />
                {hoveredPixel && <div className="pixel-tooltip" style={{ left: hoveredPixel.left, top: hoveredPixel.top }}>
                  <span className="tooltip-swatch" style={{ backgroundColor: hoveredPixel.color.hex }} />
                  <strong>第 {hoveredPixel.row} 行 · 第 {hoveredPixel.column} 列</strong>
                  <small>{hoveredPixel.color.code} · {hoveredPixel.color.name} · {hoveredPixel.color.hex}</small>
                </div>}
              </div>
            </div>
          </div>

          <div className="pattern-footer">
            <span className="status-dot" /> <span>{status}</span>
            <div className="view-options">
              <label><input type="checkbox" checked={showGrid} onChange={(e) => setShowGrid(e.target.checked)} /> 网格</label>
              <label><input type="checkbox" checked={showCodes} onChange={(e) => setShowCodes(e.target.checked)} /> 格内编号</label>
              <button className="save-button" onClick={savePattern}>保存历史</button>
              <button onClick={exportPng}>下载高清 PNG</button>
            </div>
          </div>
          <div className="pattern-legend">
            <div className="legend-heading"><strong>本图所需颜色与用量</strong><span>按数量从多到少，共 {usedColors.length} 色</span></div>
            <div className="legend-list">
              {usedColors.map((color) => <button key={color.code} className={selectedCodes.has(color.code) ? "active" : ""} onClick={() => toggleColor(color.code)}>
                <i style={{ backgroundColor: color.hex }} /><strong>{color.code}</strong><span>{color.name}</span><b>{color.count} 颗</b>
              </button>)}
            </div>
          </div>
        </section>

        <aside className="color-panel">
          <div className="panel-heading"><span>03</span><div><h2>分色查看</h2><p>{usedColors.length} 种颜色 · {beadCount} 颗</p></div></div>
          <div className="search-box"><span>⌕</span><input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="输入 A6、MARD A 或 #FEAC4C" /></div>
          <div className="selection-tools">
            <label className="switch-row"><input type="checkbox" checked={onlySelected} onChange={(e) => setOnlySelected(e.target.checked)} /><span className="switch" /><span>只显示选中颜色</span></label>
            {selectedCodes.size > 0 && <button onClick={() => setSelectedCodes(new Set())}>清除 {selectedCodes.size} 项</button>}
          </div>
          <div className="color-list">
            {filteredColors.map((color) => {
              const active = selectedCodes.has(color.code);
              return <button key={color.code} className={`color-item ${active ? "active" : ""}`} onClick={() => toggleColor(color.code)} aria-pressed={active}>
                <span className="swatch" style={{ backgroundColor: color.hex }} />
                <span className="color-meta"><strong>{color.code} · {color.name}</strong><small>{color.hex}</small></span>
                <b>{color.count}<small>颗</small></b>
              </button>;
            })}
          </div>
          <p className="palette-note"><strong>MARD 221 基础色卡</strong>：默认使用 A/B/C/D/E/F/G/H/M 九个系列。屏幕色值仅用于近似匹配，实体豆颜色会受批次、光线和屏幕影响。</p>
        </aside>
      </section>

      {cropDialogOpen && <div className="crop-dialog-backdrop" role="presentation" onMouseDown={(event) => { if (event.target === event.currentTarget) setCropDialogOpen(false); }}>
        <section className="crop-dialog" role="dialog" aria-modal="true" aria-labelledby="crop-dialog-title">
          <div className="crop-dialog-header">
            {cropDialogPurpose === "pattern-import"
              ? <div><p className="section-kicker">MARD PATTERN IMPORT</p><h2 id="crop-dialog-title">导入现成 MARD 图纸</h2><p>选框边缘请贴合实际格子区域，不要包含外侧行列标尺；再填写网格的列数和行数。</p></div>
              : <div><p className="section-kicker">IMAGE CROP</p><h2 id="crop-dialog-title">框选需要转换的区域</h2><p>框内拖动可移动，控制点可调整大小；生成格子时以所选区域正中心为采样基准。</p></div>}
            <button aria-label="关闭取景窗口" onClick={() => setCropDialogOpen(false)}>×</button>
          </div>
          <div className="crop-mode-bar">
            {cropDialogPurpose === "pattern-import" ? <>
              <div className="pattern-import-dimensions">
                <label><span>横向列数</span><input aria-label="导入图纸横向列数" type="number" min="1" max="256" value={patternImportWidth} onChange={(event) => setPatternImportWidth(Number(event.target.value))} /></label>
                <b>×</b>
                <label><span>纵向行数</span><input aria-label="导入图纸纵向行数" type="number" min="1" max="256" value={patternImportHeight} onChange={(event) => setPatternImportHeight(Number(event.target.value))} /></label>
              </div>
              <label className="pattern-import-empty-option"><input type="checkbox" checked={missingCodeAsEmpty} onChange={(event) => setMissingCodeAsEmpty(event.target.checked)} /> 没有识别到 MARD 编码的格子视为空白</label>
            </> : <>
              <span>选框形状</span>
              <div className="crop-mode-switch" role="group" aria-label="选框形状">
                <button className={cropSelectionMode === "free" ? "active" : ""} aria-pressed={cropSelectionMode === "free"} onClick={() => chooseCropSelectionMode("free")}>自由矩形</button>
                <button className={cropSelectionMode === "square" ? "active" : ""} aria-pressed={cropSelectionMode === "square"} onClick={() => chooseCropSelectionMode("square")}>正方形</button>
              </div>
            </>}
            <span className="crop-size-readout">当前选框：{Math.round(draftCropRect.width * 100)}% × {Math.round(draftCropRect.height * 100)}%</span>
          </div>
          <div className="crop-dialog-scroll">
            <div
              className="crop-dialog-stage"
              ref={cropDialogStageRef}
              onPointerDown={handleCropSelectionDown}
              onPointerMove={handleCropSelectionMove}
              onPointerUp={handleCropSelectionEnd}
              onPointerCancel={handleCropSelectionEnd}
              onPointerLeave={(event) => { if (!cropSelectionDragRef.current) event.currentTarget.classList.remove("can-move-selection"); }}
            >
              <canvas ref={cropDialogCanvasRef} aria-label="可框选的原始大图" />
              <div className="crop-selection-mask crop-selection-top" style={{ height: `${draftCropRect.y * 100}%` }} />
              <div className="crop-selection-mask crop-selection-left" style={{ top: `${draftCropRect.y * 100}%`, width: `${draftCropRect.x * 100}%`, height: `${draftCropRect.height * 100}%` }} />
              <div className="crop-selection-mask crop-selection-right" style={{ top: `${draftCropRect.y * 100}%`, left: `${(draftCropRect.x + draftCropRect.width) * 100}%`, right: 0, height: `${draftCropRect.height * 100}%` }} />
              <div className="crop-selection-mask crop-selection-bottom" style={{ top: `${(draftCropRect.y + draftCropRect.height) * 100}%` }} />
              <div className="crop-selection-box" style={{ left: `${draftCropRect.x * 100}%`, top: `${draftCropRect.y * 100}%`, width: `${draftCropRect.width * 100}%`, height: `${draftCropRect.height * 100}%` }}>
                {cropDialogPurpose === "pattern-import" && patternImportWidth > 0 && patternImportHeight > 0 && <i
                  className="pattern-import-grid-overlay"
                  style={{ backgroundSize: `${100 / patternImportWidth}% 100%, 100% ${100 / patternImportHeight}%` }}
                  aria-hidden="true"
                />}
                {(["nw", "ne", "sw", "se"] as CropResizeHandle[]).map((handle) => <i key={handle} className={`crop-resize-handle handle-${handle}`} data-crop-handle={handle} aria-hidden="true" />)}
                {cropSelectionMode === "free" && (["n", "s", "e", "w"] as CropResizeHandle[]).map((handle) => <i key={handle} className={`crop-resize-handle crop-edge-handle handle-${handle}`} data-crop-handle={handle} aria-hidden="true" />)}
              </div>
            </div>
          </div>
          <div className="crop-dialog-actions">
            <button onClick={() => { setCropSelectionMode("free"); setDraftCropRect(FULL_CROP); }}>选择整张图</button>
            <span>{cropDialogPurpose === "pattern-import" ? "对齐越准确，格内编码与底色识别越可靠" : "框外拖动可重选，框内拖动可移动，控制点可缩放"}</span>
            <button onClick={() => setCropDialogOpen(false)}>取消</button>
            <button
              className="confirm"
              disabled={patternImportBusy || draftCropRect.width < 0.03 || draftCropRect.height < 0.03 || (cropDialogPurpose === "pattern-import" && (patternImportWidth < 1 || patternImportWidth > 256 || patternImportHeight < 1 || patternImportHeight > 256))}
              onClick={confirmCrop}
            >{cropDialogPurpose === "pattern-import" ? (patternImportBusy ? "正在识别…" : "识别并导入") : "使用此区域"}</button>
          </div>
        </section>
      </div>}

      <section className="history-section" aria-labelledby="history-title">
        <div className="history-heading"><div><p className="section-kicker">LOCAL PATTERN ARCHIVE</p><h2 id="history-title">历史图纸</h2><p>历史保存在当前浏览器，最多保留 30 份。</p></div>
          <div className="search-box"><span>⌕</span><input value={historyQuery} onChange={(e) => setHistoryQuery(e.target.value)} placeholder="按名称或 32x32 查询" /></div>
        </div>
        {filteredHistory.length ? <div className="history-grid">{filteredHistory.map((entry) => {
          return <article className="history-card" key={entry.id}>
            <HistoryThumbnail entry={entry} />
            <div><h3>{entry.name}</h3><p>{entry.width} × {entry.height} · {new Date(entry.savedAt).toLocaleString("zh-CN", { hour12: false })}</p></div>
            <div className="history-actions"><button onClick={() => restorePattern(entry)}>打开</button><button onClick={() => removePattern(entry.id)}>删除</button></div>
          </article>;
        })}</div> : <div className="history-empty">还没有符合条件的历史图纸。转换完成后点击“保存历史”即可在这里查询。</div>}
      </section>
    </main>
  );
}
