import type { SpreadsheetTheme } from './theme';
import { getSpreadsheetTheme, resolveHeaderColor } from './theme';
import type {
  CellRange,
  CellType,
  SpreadsheetColumnConfig,
  SpreadsheetHeaderConfig,
  SpreadsheetTextAlign,
} from '../types';
import {
  COLUMN_LETTER_HEIGHT,
  DEFAULT_COL_WIDTH,
  DEFAULT_ROW_HEIGHT,
  HEADER_HEIGHT,
  HEADER_TITLE_HEIGHT,
  ROW_NUMBER_WIDTH,
  SCROLL_BUFFER_ROWS,
} from '../types';
import { getRowTop } from '../composables/useAutoMeasure';
import { formatCellWithSuffix } from '../composables/cellDisplay';
import { resolveColumnTextAlign } from '../composables/columnTextAlign';
import { colIndexToLetters } from '../formula/evaluator';
import { iterateRanges, normalizeRange, rangesContainCell } from '../commands/FillRangeCommand';
import {
  computeFixedWidth,
  getDataColX,
  getFixedColIndices,
  isFixedCol,
} from './columnLayout';

export {
  computeFixedWidth,
  getDataColX,
  getFixedColIndices,
} from './columnLayout';

export interface RenderOptions {
  headers: SpreadsheetHeaderConfig[];
  columns: SpreadsheetColumnConfig[];
  rows: Record<string, unknown>[];
  colWidths: number[];
  rowHeight: number;
  scrollTop: number;
  scrollLeft: number;
  viewportWidth: number;
  viewportHeight: number;
  displayRowCount: number;
  colCount: number;
  displayColCount: number;
  defaultColWidth: number;
  defaultTextAlign: SpreadsheetTextAlign;
  isDark: boolean;
  fixedCols: number[];
  fixedWidth: number;
  selectionRanges: CellRange[];
  activeCell: { row: number; col: number } | null;
  dirtyKeys: Set<string>;
  filterValues: Record<number, string>;
  filteredRowIndices: number[];
  showFillHandle: boolean;
  theme: SpreadsheetTheme;
  findMatches?: Array<{ row: number; col: number }>;
  findActiveIndex?: number;
  resolveDisplayValue?: (
    viewRow: number,
    colCfg: SpreadsheetColumnConfig,
    row?: Record<string, unknown>,
  ) => unknown;
  resolveCellStyle?: (
    viewRow: number,
    colCfg: SpreadsheetColumnConfig,
    row?: Record<string, unknown>,
  ) => { bgColor?: string; textColor?: string } | undefined;
  referenceCell?: { row: number; col: number } | null;
}

function colByIndex(columns: SpreadsheetColumnConfig[], col: number) {
  return columns.find((c) => c.col === col);
}

function isInSelection(
  row: number,
  col: number,
  ranges: CellRange[],
): boolean {
  return rangesContainCell(ranges, row, col);
}

function isFindMatch(
  row: number,
  col: number,
  matches?: Array<{ row: number; col: number }>,
): boolean {
  if (!matches?.length) return false;
  return matches.some((m) => m.row === row && m.col === col);
}

function isActiveFindMatch(
  row: number,
  col: number,
  matches?: Array<{ row: number; col: number }>,
  activeIndex?: number,
): boolean {
  if (activeIndex === undefined || activeIndex < 0 || !matches?.length) {
    return false;
  }
  const m = matches[activeIndex];
  return !!m && m.row === row && m.col === col;
}

function isRowInSelection(
  row: number,
  ranges: CellRange[],
  maxCol: number,
): boolean {
  for (const range of ranges) {
    const r = normalizeRange(range);
    if (r.startCol === 0 && r.endCol === maxCol && row >= r.startRow && row <= r.endRow) {
      return true;
    }
  }
  return false;
}

function isColInSelection(
  col: number,
  ranges: CellRange[],
  maxRow: number,
): boolean {
  for (const range of ranges) {
    const r = normalizeRange(range);
    if (r.startRow === 0 && r.endRow === maxRow && col >= r.startCol && col <= r.endCol) {
      return true;
    }
  }
  return false;
}

function isAllSelected(
  ranges: CellRange[],
  maxRow: number,
  maxCol: number,
): boolean {
  if (ranges.length !== 1) return false;
  const r = normalizeRange(ranges[0]!);
  return r.startRow === 0 && r.endRow === maxRow && r.startCol === 0 && r.endCol === maxCol;
}

function formatCellDisplay(value: unknown, cellType: CellType): string {
  if (value === null || value === undefined) return '';
  if (cellType === 'number') {
    const n = Number(value);
    return Number.isFinite(n) ? String(n) : String(value);
  }
  if (typeof value === 'object') return JSON.stringify(value);
  return String(value);
}

function drawAlignedText(
  ctx: CanvasRenderingContext2D,
  text: string,
  x: number,
  y: number,
  w: number,
  align: SpreadsheetTextAlign,
) {
  ctx.textBaseline = 'middle';
  if (align === 'center') {
    ctx.textAlign = 'center';
    ctx.fillText(text, x + w / 2, y);
  } else if (align === 'right') {
    ctx.textAlign = 'right';
    ctx.fillText(text, x + w - 8, y);
  } else {
    ctx.textAlign = 'left';
    ctx.fillText(text, x + 8, y, w - 16);
  }
  ctx.textAlign = 'left';
}

function drawCellContent(
  ctx: CanvasRenderingContext2D,
  value: unknown,
  cellType: CellType,
  colCfg: SpreadsheetColumnConfig | undefined,
  x: number,
  y: number,
  w: number,
  h: number,
  theme: SpreadsheetTheme,
  textColor: string | undefined,
  defaultTextAlign: SpreadsheetTextAlign,
) {
  ctx.save();
  ctx.beginPath();
  ctx.rect(x, y, w, h);
  ctx.clip();

  const align = resolveColumnTextAlign(colCfg, defaultTextAlign);

  if (cellType === 'image' && value) {
    ctx.fillStyle = theme.imagePlaceholder;
    ctx.fillRect(x + 4, y + 4, h - 8, h - 8);
    ctx.fillStyle = theme.textMuted;
    ctx.font = '11px sans-serif';
    drawAlignedText(ctx, 'IMG', x, y + h / 2, w, align);
  } else {
    ctx.fillStyle = textColor ?? theme.text;
    ctx.font = '13px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
    const text = colCfg
      ? formatCellWithSuffix(value, colCfg)
      : formatCellDisplay(value, cellType);
    drawAlignedText(ctx, text, x, y + h / 2, w, align);
  }
  ctx.restore();
}

function drawRowNumber(
  ctx: CanvasRenderingContext2D,
  y: number,
  rowHeight: number,
  label: string,
  theme: SpreadsheetTheme,
  selected = false,
) {
  ctx.fillStyle = selected ? theme.selectionBg : theme.rowNumBg;
  ctx.fillRect(0, y, ROW_NUMBER_WIDTH, rowHeight);
  ctx.strokeStyle = theme.border;
  ctx.strokeRect(0, y, ROW_NUMBER_WIDTH, rowHeight);
  ctx.fillStyle = theme.textMuted;
  ctx.font = '12px sans-serif';
  ctx.textBaseline = 'middle';
  ctx.fillText(label, ROW_NUMBER_WIDTH / 2 - 4, y + rowHeight / 2);
}

function drawHeaderCorner(
  ctx: CanvasRenderingContext2D,
  theme: SpreadsheetTheme,
  allSelected = false,
) {
  ctx.fillStyle = allSelected ? theme.selectionBg : theme.rowNumBg;
  ctx.fillRect(0, 0, ROW_NUMBER_WIDTH, HEADER_HEIGHT);
  ctx.strokeStyle = theme.border;
  ctx.strokeRect(0, 0, ROW_NUMBER_WIDTH, HEADER_HEIGHT);
  ctx.fillStyle = theme.textMuted;
  ctx.font = '12px sans-serif';
  ctx.textBaseline = 'middle';
  ctx.fillText(
    '#',
    ROW_NUMBER_WIDTH / 2 - 4,
    COLUMN_LETTER_HEIGHT + HEADER_TITLE_HEIGHT / 2,
  );
}

function drawDataCell(
  ctx: CanvasRenderingContext2D,
  options: {
    x: number;
    y: number;
    cw: number;
    rowHeight: number;
    bg: string;
    theme: SpreadsheetTheme;
    value?: unknown;
    cellType?: CellType;
    colCfg?: SpreadsheetColumnConfig;
    textColor?: string;
    empty?: boolean;
    defaultTextAlign: SpreadsheetTextAlign;
  },
) {
  const {
    x,
    y,
    cw,
    rowHeight,
    bg,
    theme,
    value,
    cellType,
    colCfg,
    textColor,
    empty,
    defaultTextAlign,
  } = options;
  ctx.fillStyle = bg;
  ctx.fillRect(x, y, cw, rowHeight);
  ctx.strokeStyle = theme.border;
  ctx.strokeRect(x, y, cw, rowHeight);
  if (!empty && cellType && value !== undefined) {
    drawCellContent(
      ctx,
      value,
      cellType,
      colCfg,
      x,
      y,
      cw,
      rowHeight,
      theme,
      textColor,
      defaultTextAlign,
    );
  }
}

function resolveCellBg(
  options: RenderOptions,
  vi: number,
  col: number,
  colCfg: SpreadsheetColumnConfig | undefined,
  row: Record<string, unknown> | undefined,
  isEmpty: boolean,
  dataRow: number | undefined,
): string {
  const { theme } = options;
  const selected = isInSelection(vi, col, options.selectionRanges);
  const findActive = isActiveFindMatch(
    vi,
    col,
    options.findMatches,
    options.findActiveIndex,
  );
  const findMatch = isFindMatch(vi, col, options.findMatches);

  if (isEmpty || !colCfg) {
    if (findActive) return theme.findActiveMatchBg;
    if (findMatch) return theme.findMatchBg;
    return selected ? theme.selectionBg : theme.cellBg;
  }
  const dirtyKey =
    dataRow !== undefined ? `${dataRow}:${colCfg.fieldId}` : '';
  const cellStyle = options.resolveCellStyle?.(vi, colCfg, row);
  if (options.dirtyKeys.has(dirtyKey)) return theme.dirtyBg;
  if (findActive) return theme.findActiveMatchBg;
  if (findMatch) return theme.findMatchBg;
  if (cellStyle?.bgColor) return cellStyle.bgColor;
  if (selected) return theme.selectionBg;
  return theme.cellBg;
}

function drawHeaderCell(
  ctx: CanvasRenderingContext2D,
  options: RenderOptions,
  col: number,
  cw: number,
  x: number,
  title: string,
  headerColor?: string,
) {
  const { theme } = options;
  const letterY = 0;
  const titleY = COLUMN_LETTER_HEIGHT;
  const maxRow = Math.max(0, options.filteredRowIndices.length - 1);
  const colSelected = isColInSelection(col, options.selectionRanges, maxRow);

  ctx.fillStyle = colSelected ? theme.selectionBg : theme.headerBg;
  ctx.fillRect(x, letterY, cw, COLUMN_LETTER_HEIGHT);
  ctx.strokeStyle = theme.border;
  ctx.strokeRect(x, letterY, cw, COLUMN_LETTER_HEIGHT);
  ctx.fillStyle = theme.textMuted;
  ctx.font = '11px sans-serif';
  ctx.textBaseline = 'middle';
  ctx.textAlign = 'center';
  ctx.fillText(colIndexToLetters(col), x + cw / 2, letterY + COLUMN_LETTER_HEIGHT / 2);
  ctx.textAlign = 'left';

  const bg = resolveHeaderColor(
    headerColor,
    options.isDark,
    theme.headerBg,
  );
  ctx.fillStyle = bg;
  ctx.fillRect(x, titleY, cw, HEADER_TITLE_HEIGHT);
  ctx.strokeStyle = theme.border;
  ctx.strokeRect(x, titleY, cw, HEADER_TITLE_HEIGHT);
  ctx.fillStyle = theme.headerText;
  ctx.font = '600 13px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
  const colCfg = colByIndex(options.columns, col);
  const align = resolveColumnTextAlign(colCfg, options.defaultTextAlign);
  drawAlignedText(
    ctx,
    title,
    x,
    titleY + HEADER_TITLE_HEIGHT / 2,
    cw,
    align,
  );
}

function drawHeaderTitleOnly(
  ctx: CanvasRenderingContext2D,
  x: number,
  y: number,
  cw: number,
  title: string,
  headerColor: string | undefined,
  col: number,
  columns: SpreadsheetColumnConfig[],
  defaultTextAlign: SpreadsheetTextAlign,
  isDark: boolean,
  theme: SpreadsheetTheme,
) {
  const bg = resolveHeaderColor(headerColor, isDark, theme.headerBg);
  ctx.fillStyle = bg;
  ctx.fillRect(x, y, cw, HEADER_TITLE_HEIGHT);
  ctx.strokeStyle = theme.border;
  ctx.strokeRect(x, y, cw, HEADER_TITLE_HEIGHT);
  ctx.fillStyle = theme.headerText;
  ctx.font = '600 13px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
  const colCfg = colByIndex(columns, col);
  const align = resolveColumnTextAlign(colCfg, defaultTextAlign);
  drawAlignedText(ctx, title, x, y + HEADER_TITLE_HEIGHT / 2, cw, align);
}

function resolveExportCellBg(
  theme: SpreadsheetTheme,
  viewRow: number,
  col: number,
  colCfg: SpreadsheetColumnConfig | undefined,
  row: Record<string, unknown> | undefined,
  dataRow: number | undefined,
  dirtyKeys: Set<string>,
  resolveCellStyle?: RenderOptions['resolveCellStyle'],
): string {
  if (!colCfg) return theme.cellBg;
  const dirtyKey =
    dataRow !== undefined ? `${dataRow}:${colCfg.fieldId}` : '';
  const cellStyle = resolveCellStyle?.(viewRow, colCfg, row);
  if (dirtyKeys.has(dirtyKey)) return theme.dirtyBg;
  if (cellStyle?.bgColor) return cellStyle.bgColor;
  return theme.cellBg;
}

export interface SelectionImageParams {
  selectionRanges: CellRange[];
  headers: SpreadsheetHeaderConfig[];
  columns: SpreadsheetColumnConfig[];
  colWidths: number[];
  rowHeight: number;
  defaultColWidth: number;
  defaultTextAlign: SpreadsheetTextAlign;
  isDark: boolean;
  filteredRowIndices: number[];
  rows: Record<string, unknown>[];
  dirtyKeys: Set<string>;
  resolveDisplayValue?: RenderOptions['resolveDisplayValue'];
  resolveCellStyle?: RenderOptions['resolveCellStyle'];
}

export async function renderSelectionImage(
  params: SelectionImageParams,
): Promise<Blob> {
  const selRanges = params.selectionRanges.map((r) => normalizeRange(r));
  if (!selRanges.length) throw new Error('No selection');

  const rowSet = new Set<number>();
  const colSet = new Set<number>();
  iterateRanges(selRanges, (viewRow, col) => {
    rowSet.add(viewRow);
    colSet.add(col);
  });

  const uniqueRows = [...rowSet].sort((a, b) => a - b);
  const uniqueCols = [...colSet].sort((a, b) => a - b);
  if (!uniqueRows.length || !uniqueCols.length) {
    throw new Error('No selection');
  }

  const theme = getSpreadsheetTheme(params.isDark);

  let width = 0;
  for (const col of uniqueCols) {
    width += params.colWidths[col] ?? params.defaultColWidth;
  }
  const height =
    HEADER_TITLE_HEIGHT + uniqueRows.length * params.rowHeight;

  const canvas = document.createElement('canvas');
  const dpr = window.devicePixelRatio || 1;
  canvas.width = Math.ceil(width * dpr);
  canvas.height = Math.ceil(height * dpr);
  const ctx = canvas.getContext('2d');
  if (!ctx) throw new Error('Canvas not supported');

  ctx.scale(dpr, dpr);
  ctx.fillStyle = theme.cellBg;
  ctx.fillRect(0, 0, width, height);

  let headerX = 0;
  for (const col of uniqueCols) {
    const cw = params.colWidths[col] ?? params.defaultColWidth;
    const header = params.headers.find((h) => h.col === col);
    const colCfg = colByIndex(params.columns, col);
    const title = header?.title ?? colCfg?.fieldName ?? '';
    drawHeaderTitleOnly(
      ctx,
      headerX,
      0,
      cw,
      title,
      header?.headerColor,
      col,
      params.columns,
      params.defaultTextAlign,
      params.isDark,
      theme,
    );
    headerX += cw;
  }

  uniqueRows.forEach((viewRow, rowIndex) => {
    const dataRow = params.filteredRowIndices[viewRow];
    const row =
      dataRow !== undefined ? params.rows[dataRow] : undefined;
    const y = HEADER_TITLE_HEIGHT + rowIndex * params.rowHeight;
    let cellX = 0;
    for (const col of uniqueCols) {
      const cw = params.colWidths[col] ?? params.defaultColWidth;
      const colCfg = colByIndex(params.columns, col);
      const inSel = rangesContainCell(selRanges, viewRow, col);
      const isEmpty =
        !inSel || dataRow === undefined || !row || !colCfg;
      const bg = inSel
        ? resolveExportCellBg(
            theme,
            viewRow,
            col,
            colCfg,
            row,
            dataRow,
            params.dirtyKeys,
            params.resolveCellStyle,
          )
        : theme.cellBg;
      const cellStyle =
        inSel && !isEmpty && colCfg
          ? params.resolveCellStyle?.(viewRow, colCfg, row)
          : undefined;
      const displayCellType = colCfg?.cellType === 'formula'
        ? 'number'
        : colCfg?.cellType;
      drawDataCell(ctx, {
        x: cellX,
        y,
        cw,
        rowHeight: params.rowHeight,
        bg,
        theme,
        empty: isEmpty,
        value:
          inSel && !isEmpty && colCfg
            ? params.resolveDisplayValue?.(viewRow, colCfg, row)
            : undefined,
        cellType: displayCellType,
        colCfg: inSel ? colCfg : undefined,
        textColor: cellStyle?.textColor,
        defaultTextAlign: params.defaultTextAlign,
      });
      cellX += cw;
    }
  });

  return new Promise((resolve, reject) => {
    canvas.toBlob((blob) => {
      if (blob) resolve(blob);
      else reject(new Error('Failed to create image'));
    }, 'image/png');
  });
}

function drawHeaderFillerCell(
  ctx: CanvasRenderingContext2D,
  options: RenderOptions,
  col: number,
  cw: number,
  x: number,
) {
  const { theme } = options;
  const maxRow = Math.max(0, options.filteredRowIndices.length - 1);
  const colSelected = isColInSelection(col, options.selectionRanges, maxRow);
  const letterBg = colSelected ? theme.selectionBg : theme.headerBg;
  ctx.fillStyle = letterBg;
  ctx.fillRect(x, 0, cw, COLUMN_LETTER_HEIGHT);
  ctx.strokeStyle = theme.border;
  ctx.strokeRect(x, 0, cw, COLUMN_LETTER_HEIGHT);
  ctx.fillStyle = theme.textMuted;
  ctx.font = '11px sans-serif';
  ctx.textBaseline = 'middle';
  ctx.textAlign = 'center';
  ctx.fillText(colIndexToLetters(col), x + cw / 2, COLUMN_LETTER_HEIGHT / 2);
  ctx.textAlign = 'left';
  ctx.fillRect(x, COLUMN_LETTER_HEIGHT, cw, HEADER_TITLE_HEIGHT);
  ctx.strokeRect(x, COLUMN_LETTER_HEIGHT, cw, HEADER_TITLE_HEIGHT);
}

function drawScrollableHeaders(
  ctx: CanvasRenderingContext2D,
  options: RenderOptions,
  w: number,
) {
  const { fixedCols, fixedWidth } = options;
  ctx.save();
  ctx.beginPath();
  ctx.rect(fixedWidth, 0, w - fixedWidth, HEADER_HEIGHT);
  ctx.clip();

  for (const header of options.headers) {
    if (isFixedCol(header.col, fixedCols)) continue;
    const x = getDataColX(
      header.col,
      options.colWidths,
      options.scrollLeft,
      fixedCols,
      fixedWidth,
    );
    const cw = options.colWidths[header.col] ?? options.defaultColWidth;
    if (x + cw <= fixedWidth || x > w) continue;
    drawHeaderCell(ctx, options, header.col, cw, x, header.title, header.headerColor);
  }

  for (let col = options.colCount; col < options.displayColCount; col += 1) {
    if (isFixedCol(col, fixedCols)) continue;
    const x = getDataColX(
      col,
      options.colWidths,
      options.scrollLeft,
      fixedCols,
      fixedWidth,
    );
    const cw = options.defaultColWidth;
    if (x + cw <= fixedWidth || x > w) continue;
    drawHeaderFillerCell(ctx, options, col, cw, x);
  }
  ctx.restore();
}

function drawFrozenHeaders(
  ctx: CanvasRenderingContext2D,
  options: RenderOptions,
  w: number,
) {
  const { fixedCols, fixedWidth, theme } = options;
  const maxRow = Math.max(0, options.filteredRowIndices.length - 1);
  const maxCol = options.colCount - 1;
  drawHeaderCorner(
    ctx,
    theme,
    isAllSelected(options.selectionRanges, maxRow, maxCol),
  );

  for (const header of options.headers) {
    if (!isFixedCol(header.col, fixedCols)) continue;
    const x = getDataColX(
      header.col,
      options.colWidths,
      options.scrollLeft,
      fixedCols,
      fixedWidth,
    );
    const cw = options.colWidths[header.col] ?? options.defaultColWidth;
    if (x + cw <= ROW_NUMBER_WIDTH) continue;
    drawHeaderCell(ctx, options, header.col, cw, x, header.title, header.headerColor);
  }

  if (fixedCols.length > 0 && fixedWidth < w) {
    ctx.fillStyle = theme.fixedShadow;
    ctx.fillRect(fixedWidth, 0, 1, HEADER_HEIGHT);
  }
}

export function renderHeader(canvas: HTMLCanvasElement, options: RenderOptions) {
  const ctx = canvas.getContext('2d');
  if (!ctx) return;

  const dpr = window.devicePixelRatio || 1;
  const w = canvas.clientWidth;
  const h = HEADER_HEIGHT;
  canvas.width = w * dpr;
  canvas.height = h * dpr;
  ctx.scale(dpr, dpr);
  ctx.clearRect(0, 0, w, h);

  ctx.fillStyle = options.theme.cellBg;
  ctx.fillRect(0, 0, w, h);

  drawScrollableHeaders(ctx, options, w);
  drawFrozenHeaders(ctx, options, w);
}

function drawScrollableBodyRow(
  ctx: CanvasRenderingContext2D,
  options: RenderOptions,
  vi: number,
  y: number,
  dataRow: number | undefined,
  row: Record<string, unknown> | undefined,
  w: number,
) {
  const { theme, rowHeight, fixedCols, fixedWidth } = options;
  const isEmpty = dataRow === undefined || !row;

  ctx.save();
  ctx.beginPath();
  ctx.rect(fixedWidth, y, w - fixedWidth, rowHeight);
  ctx.clip();

  for (const colCfg of options.columns) {
    const col = colCfg.col;
    if (isFixedCol(col, fixedCols)) continue;
    const x = getDataColX(
      col,
      options.colWidths,
      options.scrollLeft,
      fixedCols,
      fixedWidth,
    );
    const cw = options.colWidths[col] ?? options.defaultColWidth;
    if (x + cw <= fixedWidth || x > options.viewportWidth) continue;

    const bg = resolveCellBg(options, vi, col, colCfg, row, isEmpty, dataRow);
    const cellStyle = !isEmpty
      ? options.resolveCellStyle?.(vi, colCfg, row)
      : undefined;
    const displayCellType =
      colCfg.cellType === 'formula' ? 'number' : colCfg.cellType;

    drawDataCell(ctx, {
      x,
      y,
      cw,
      rowHeight,
      bg,
      theme,
      empty: isEmpty,
      value: options.resolveDisplayValue
        ? options.resolveDisplayValue(vi, colCfg, row)
        : row?.[colCfg.fieldId],
      cellType: displayCellType,
      colCfg,
      textColor: cellStyle?.textColor,
      defaultTextAlign: options.defaultTextAlign,
    });
  }

  for (let col = options.colCount; col < options.displayColCount; col += 1) {
    if (isFixedCol(col, fixedCols)) continue;
    const x = getDataColX(
      col,
      options.colWidths,
      options.scrollLeft,
      fixedCols,
      fixedWidth,
    );
    const cw = options.defaultColWidth;
    if (x + cw <= fixedWidth || x > options.viewportWidth) continue;
    const bg = resolveCellBg(options, vi, col, undefined, undefined, true, dataRow);
    drawDataCell(ctx, {
      x,
      y,
      cw,
      rowHeight,
      bg,
      theme,
      empty: true,
      defaultTextAlign: options.defaultTextAlign,
    });
  }
  ctx.restore();
}

function drawFrozenBodyRow(
  ctx: CanvasRenderingContext2D,
  options: RenderOptions,
  vi: number,
  y: number,
  dataRow: number | undefined,
  row: Record<string, unknown> | undefined,
) {
  const { theme, rowHeight, fixedCols, fixedWidth } = options;
  const isEmpty = dataRow === undefined || !row;
  const maxCol = options.colCount - 1;
  const rowSelected = isRowInSelection(vi, options.selectionRanges, maxCol);

  drawRowNumber(ctx, y, rowHeight, String(vi + 1), theme, rowSelected);

  for (const colCfg of options.columns) {
    const col = colCfg.col;
    if (!isFixedCol(col, fixedCols)) continue;
    const x = getDataColX(
      col,
      options.colWidths,
      options.scrollLeft,
      fixedCols,
      fixedWidth,
    );
    const cw = options.colWidths[col] ?? options.defaultColWidth;

    const bg = resolveCellBg(options, vi, col, colCfg, row, isEmpty, dataRow);
    const cellStyle = !isEmpty
      ? options.resolveCellStyle?.(vi, colCfg, row)
      : undefined;
    const displayCellType =
      colCfg.cellType === 'formula' ? 'number' : colCfg.cellType;

    drawDataCell(ctx, {
      x,
      y,
      cw,
      rowHeight,
      bg,
      theme,
      empty: isEmpty,
      value: options.resolveDisplayValue
        ? options.resolveDisplayValue(vi, colCfg, row)
        : row?.[colCfg.fieldId],
      cellType: displayCellType,
      colCfg,
      textColor: cellStyle?.textColor,
      defaultTextAlign: options.defaultTextAlign,
    });
  }
}

export function renderBody(canvas: HTMLCanvasElement, options: RenderOptions) {
  const ctx = canvas.getContext('2d');
  if (!ctx) return;

  const { theme, rowHeight, scrollTop, fixedWidth, fixedCols } = options;
  const dpr = window.devicePixelRatio || 1;
  const w = canvas.clientWidth;
  const h = canvas.clientHeight;
  canvas.width = w * dpr;
  canvas.height = h * dpr;
  ctx.scale(dpr, dpr);
  ctx.clearRect(0, 0, w, h);

  const visibleCount = Math.ceil(h / rowHeight) + SCROLL_BUFFER_ROWS;
  const startRow = Math.max(0, Math.floor(scrollTop / rowHeight) - 2);
  const endRow = Math.min(options.displayRowCount - 1, startRow + visibleCount);

  for (let vi = startRow; vi <= endRow; vi += 1) {
    const dataRow = options.filteredRowIndices[vi];
    const row =
      dataRow !== undefined ? options.rows[dataRow] : undefined;
    const y = getRowTop(vi, rowHeight) - scrollTop;
    drawScrollableBodyRow(ctx, options, vi, y, dataRow, row, w);
  }

  for (let vi = startRow; vi <= endRow; vi += 1) {
    const dataRow = options.filteredRowIndices[vi];
    const row =
      dataRow !== undefined ? options.rows[dataRow] : undefined;
    const y = getRowTop(vi, rowHeight) - scrollTop;
    drawFrozenBodyRow(ctx, options, vi, y, dataRow, row);
  }

  if (fixedCols.length > 0 && fixedWidth < w) {
    const bodyH = Math.min(h, options.displayRowCount * rowHeight - scrollTop);
    ctx.fillStyle = theme.fixedShadow;
    ctx.fillRect(fixedWidth, 0, 1, Math.max(0, bodyH));
  }

  if (options.activeCell) {
    const { activeCell } = options;
    const x = getDataColX(
      activeCell.col,
      options.colWidths,
      options.scrollLeft,
      fixedCols,
      fixedWidth,
    );
    const y = getRowTop(activeCell.row, rowHeight) - scrollTop;
    const cw = options.colWidths[activeCell.col] ?? options.defaultColWidth;

    const findActive = isActiveFindMatch(
      activeCell.row,
      activeCell.col,
      options.findMatches,
      options.findActiveIndex,
    );

    ctx.strokeStyle = findActive ? theme.findActiveBorder : theme.selectionBorder;
    ctx.lineWidth = 2;
    ctx.setLineDash([]);
    ctx.strokeRect(x + 1, y + 1, cw - 2, rowHeight - 2);

    if (options.showFillHandle) {
      const fh = 6;
      ctx.fillStyle = theme.fillHandle;
      ctx.fillRect(x + cw - fh - 2, y + rowHeight - fh - 2, fh, fh);
    }
  }

  if (options.referenceCell) {
    const { row, col } = options.referenceCell;
    const x = getDataColX(
      col,
      options.colWidths,
      options.scrollLeft,
      fixedCols,
      fixedWidth,
    );
    const y = getRowTop(row, rowHeight) - scrollTop;
    const cw = options.colWidths[col] ?? options.defaultColWidth;

    ctx.strokeStyle = theme.selectionBorder;
    ctx.lineWidth = 2;
    ctx.setLineDash([4, 4]);
    ctx.strokeRect(x + 1, y + 1, cw - 2, rowHeight - 2);
    ctx.setLineDash([]);
  }
}

export function getVisibleRowIndices(
  rows: Record<string, unknown>[],
  columns: SpreadsheetColumnConfig[],
  filterValues: Record<number, string>,
): number[] {
  const activeFilters = Object.entries(filterValues).filter(([, v]) => v.trim());
  if (!activeFilters.length) {
    return rows.map((_, i) => i);
  }

  return rows
    .map((row, index) => ({ row, index }))
    .filter(({ row }) =>
      activeFilters.every(([colStr, keyword]) => {
        const col = Number(colStr);
        const colCfg = colByIndex(columns, col);
        if (!colCfg) return true;
        const val = formatCellDisplay(row[colCfg.fieldId], colCfg.cellType);
        return val.toLowerCase().includes(keyword.trim().toLowerCase());
      }),
    )
    .map(({ index }) => index);
}

export function getContentSize(
  colWidths: number[],
  displayRowCount: number,
  rowHeight: number,
  displayContentWidth?: number,
): { width: number; height: number } {
  const dataWidth = ROW_NUMBER_WIDTH + colWidths.reduce((s, w) => s + w, 0);
  const width = displayContentWidth ?? dataWidth;
  return { width, height: displayRowCount * rowHeight };
}

export { ROW_NUMBER_WIDTH, HEADER_HEIGHT, DEFAULT_ROW_HEIGHT };
