// Finished-game PNG export: a light-theme snapshot of the final board (classic A..O / 1..15
// axes) with a compact per-seat scoresheet beside it. Everything is drawn by hand on a
// Canvas 2D — no dependencies. The module is the SHARED source of truth for the export
// image: the render sidecar bundles it (renderer/src/entry.ts) and rasterizes on
// skia-canvas, while this ui project keeps the pure parts unit-tested (the browser app
// itself no longer imports it — delivery is the server's signed URL).
//
// buildScoresheet and layoutImage are pure (vitest-covered); drawGameImage is the thin
// canvas pass exercised by the sidecar's node test.

import type { GameView, MoveRecord, Variant } from './model';
import { replay } from './board';
import { historyGrid, type HistoryCell } from './history';
import { premiumGrid, centre, BOARD_SIZE } from './premiums';
import { valueForLetter } from './alphabet';

// The light-theme palette, mirrored from ui/src/app.css :root — the export is always light
// regardless of the app theme, and a canvas cannot resolve CSS custom properties, so the
// values are pinned here. Keep in sync with app.css.
const C = {
  paper: '#ffffff',
  text: '#14181f',
  muted: '#6b7280',
  border: '#d8dce2',
  cell: '#e7ece8',
  cellDark: '#d9deda', // color-mix(in srgb, --cell-bg 94%, #000)
  tile: '#f4e2b8',
  tileEdge: '#d8c190',
  tileText: '#2a2113',
  premText: '#2a2113',
  prem: { TW: '#e06a5b', DW: '#efa6a0', TL: '#4f8fd6', DL: '#a8cdec' } as Record<string, string>,
};

const FONT = 'system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Liberation Sans", sans-serif';

/** One scoresheet cell: a play (classic coordinate + words + points), a localized non-play
 *  action, or an empty filler keeping the seat columns aligned. */
export type SheetCell =
  | { kind: 'play'; coord: string; words: string[]; score: number }
  | { kind: 'action'; label: string }
  | { kind: 'empty' };

/** Scoresheet is the pure content of the image's right column: one column per seat. */
export interface Scoresheet {
  /** Per seat: display name, final (adjusted) score, winner flag. */
  header: { name: string; score: number; winner: boolean }[];
  /** Row-major move grid, one column per seat (row k = each seat's k-th move). */
  rows: SheetCell[][];
  /** Per-seat endgame rack settlement: final Seat.score minus the seat's last running
   *  total. The journal carries no adjustment move (engine applyEndAdjustment folds it
   *  into the final score only), so the sheet shows it as a closing ± row. */
  adjustments: number[];
  hasAdjustments: boolean;
  /** Bottom line: "<hostname> · <finish date>". */
  footer: string;
}

/** formatFinishedDate renders the game's finish time for the footer in the DEVICE locale
 *  and time zone (regional conventions, not the UI language) — e.g. "3 июля 2026 г., 16:23".
 *  Both ride in from the client on a server render; an invalid zone falls back to the
 *  process default rather than failing the whole image. */
export function formatFinishedDate(unixSec: number, dateLocale?: string, timeZone?: string): string {
  const opts: Intl.DateTimeFormatOptions = {
    year: 'numeric',
    month: 'long',
    day: 'numeric',
    hour: '2-digit',
    minute: '2-digit',
  };
  try {
    return new Intl.DateTimeFormat(dateLocale, { ...opts, timeZone }).format(new Date(unixSec * 1000));
  } catch {
    return new Intl.DateTimeFormat(dateLocale, opts).format(new Date(unixSec * 1000));
  }
}

/**
 * buildScoresheet lays the finished game's journal out as the export scoresheet.
 * actionLabel localizes a non-play move (the caller passes the same mapping the in-game
 * history uses); hostname/dateLocale feed the footer (tests pin them, production uses
 * location.hostname and the device locale).
 */
export function buildScoresheet(
  game: GameView,
  moves: MoveRecord[],
  actionLabel: (action: string) => string,
  opts?: { hostname?: string; dateLocale?: string; timeZone?: string },
): Scoresheet {
  const seats = game.seats;
  const grid = historyGrid(moves, seats.length, null);
  const rows = grid.map((row) =>
    row.map((c: HistoryCell): SheetCell => {
      if (c.kind === 'play') {
        return { kind: 'play', coord: c.coord ?? '', words: c.words ?? [], score: c.score ?? 0 };
      }
      if (c.kind === 'action') return { kind: 'action', label: actionLabel(c.action ?? '') };
      return { kind: 'empty' };
    }),
  );
  // The last running total each seat reached in the journal; the final Seat.score differs
  // from it exactly by the endgame rack settlement (resignations freeze scores — zero diff).
  const lastTotal = seats.map((s) => {
    for (let i = moves.length - 1; i >= 0; i--) {
      if (moves[i].player === s.seat) return moves[i].total;
    }
    return 0;
  });
  const adjustments = seats.map((s, i) => s.score - lastTotal[i]);
  const host = opts?.hostname ?? (typeof location !== 'undefined' ? location.hostname : '');
  return {
    header: seats.map((s) => ({ name: s.displayName, score: s.score, winner: s.isWinner })),
    rows,
    adjustments,
    hasAdjustments: adjustments.some((a) => a !== 0),
    footer: `${host} · ${formatFinishedDate(game.lastActivityUnix, opts?.dateLocale, opts?.timeZone)}`,
  };
}

// Fixed scoresheet typography (@1x logical px). The column format never changes with game
// length — the board stretches instead (layoutImage).
const MARGIN = 22;
const AXIS = 20; // top letters / left numbers gutter
const GAP = 22; // board ↔ scoresheet
const COL_W = 158; // one seat column
const COL_GAP = 14; // between seat columns
const HEAD_NAME_H = 20;
const HEAD_SCORE_H = 24;
const HEAD_RULE_H = 12;
const ROW_H = 17;
const SUB_ROW_H = 13; // extra-words line under a multi-word play
const ADJ_ROW_H = 20;
const FOOTER_H = 30;
/** The board never shrinks below this side (@1x) even for a two-move game. */
export const MIN_SIDE = 620;

/** ImageLayout is the pure geometry of the export image (@1x; the canvas scales it). */
export interface ImageLayout {
  w: number;
  h: number;
  board: { x: number; y: number; side: number; cell: number };
  hist: { x: number; y: number; w: number; colW: number; colGap: number };
  /** Per move-row height (a row with any multi-word play gets the extra sub-line). */
  rowHeights: number[];
  headNameH: number;
  headScoreH: number;
  headRuleH: number;
  rowH: number;
  subRowH: number;
  adjRowH: number;
  footerH: number;
  axis: number;
  margin: number;
}

/**
 * layoutImage computes the export geometry from the scoresheet: the fixed-typography
 * history column height decides the board side (never below MIN_SIDE), so the image
 * carries no dead space under the board and a long game simply grows both.
 */
export function layoutImage(sheet: Scoresheet): ImageLayout {
  const seats = sheet.header.length;
  const rowHeights = sheet.rows.map(
    (row) => ROW_H + (row.some((c) => c.kind === 'play' && c.words.length > 1) ? SUB_ROW_H : 0),
  );
  const histContentH =
    HEAD_NAME_H +
    HEAD_SCORE_H +
    HEAD_RULE_H +
    rowHeights.reduce((a, b) => a + b, 0) +
    (sheet.hasAdjustments ? ADJ_ROW_H : 0);
  const side = Math.max(MIN_SIDE, Math.ceil(histContentH));
  const histW = seats * COL_W + (seats - 1) * COL_GAP;
  return {
    w: MARGIN + AXIS + side + GAP + histW + MARGIN,
    h: MARGIN + AXIS + side + FOOTER_H + MARGIN,
    board: { x: MARGIN + AXIS, y: MARGIN + AXIS, side, cell: side / BOARD_SIZE },
    hist: { x: MARGIN + AXIS + side + GAP, y: MARGIN + AXIS, w: histW, colW: COL_W, colGap: COL_GAP },
    rowHeights,
    headNameH: HEAD_NAME_H,
    headScoreH: HEAD_SCORE_H,
    headRuleH: HEAD_RULE_H,
    rowH: ROW_H,
    subRowH: SUB_ROW_H,
    adjRowH: ADJ_ROW_H,
    footerH: FOOTER_H,
    axis: AXIS,
    margin: MARGIN,
  };
}

/** RenderOptions carries the presentation the caller resolves from app state. */
export interface RenderOptions {
  /** Localizes a non-play move for the scoresheet (pass/exchange/resign/timeout). */
  actionLabel: (action: string) => string;
  /** Footer host override (defaults to location.hostname) — for tests/harnesses. */
  hostname?: string;
  /** Footer date locale override (defaults to the device locale) — for tests. */
  dateLocale?: string;
  /** Footer IANA time zone (the device zone on a server render; defaults to local). */
  timeZone?: string;
  /** Canvas pixel scale (default 2 — crisp on hi-dpi screens and in chats). */
  scale?: number;
}

/** CanvasLike is the minimal canvas surface drawGameImage needs — satisfied by both the
 *  browser HTMLCanvasElement and the render sidecar's skia-canvas Canvas, so the drawing
 *  code is shared verbatim between the two runtimes. */
export interface CanvasLike {
  width: number;
  height: number;
  getContext(contextId: '2d'): CanvasRenderingContext2D | null;
}

/**
 * drawGameImage sizes the canvas and draws the finished game onto it: final board with
 * classic axes on the left, the scoresheet on the right, the site + finish date at the
 * bottom. Light theme always; no last-move highlight (an archival artifact, not a live
 * frame). The caller owns the canvas and its PNG encoding (toBlob in the browser,
 * toBuffer in the sidecar).
 */
export function drawGameImage(canvas: CanvasLike, game: GameView, moves: MoveRecord[], opts: RenderOptions): void {
  const sheet = buildScoresheet(game, moves, opts.actionLabel, {
    hostname: opts.hostname,
    dateLocale: opts.dateLocale,
    timeZone: opts.timeZone,
  });
  const layout = layoutImage(sheet);
  const scale = opts.scale ?? 2;
  canvas.width = Math.round(layout.w * scale);
  canvas.height = Math.round(layout.h * scale);
  const ctx = canvas.getContext('2d');
  if (!ctx) throw new Error('gameimage: no 2d context');
  ctx.scale(scale, scale);

  ctx.fillStyle = C.paper;
  ctx.fillRect(0, 0, layout.w, layout.h);
  drawAxes(ctx, layout);
  drawBoard(ctx, layout, game.variant, moves);
  drawSheet(ctx, layout, sheet);

  ctx.fillStyle = C.muted;
  ctx.font = `11px ${FONT}`;
  ctx.textAlign = 'left';
  ctx.textBaseline = 'alphabetic';
  ctx.fillText(sheet.footer, layout.margin, layout.h - layout.margin - 6);
}


function drawAxes(ctx: CanvasRenderingContext2D, l: ImageLayout): void {
  const { x, y, cell } = l.board;
  ctx.fillStyle = C.muted;
  ctx.font = `${Math.max(10, cell * 0.28)}px ${FONT}`;
  ctx.textAlign = 'center';
  ctx.textBaseline = 'middle';
  for (let c = 0; c < BOARD_SIZE; c++) {
    ctx.fillText(String.fromCharCode(65 + c), x + c * cell + cell / 2, y - l.axis / 2);
  }
  for (let r = 0; r < BOARD_SIZE; r++) {
    ctx.fillText(String(r + 1), x - l.axis / 2, y + r * cell + cell / 2);
  }
}

function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number): void {
  ctx.beginPath();
  ctx.moveTo(x + r, y);
  ctx.arcTo(x + w, y, x + w, y + h, r);
  ctx.arcTo(x + w, y + h, x, y + h, r);
  ctx.arcTo(x, y + h, x, y, r);
  ctx.arcTo(x, y, x + w, y, r);
  ctx.closePath();
}

function drawBoard(
  ctx: CanvasRenderingContext2D,
  l: ImageLayout,
  variant: Variant,
  moves: MoveRecord[],
): void {
  const { x, y, cell, side } = l.board;
  const board = replay(moves);
  const prem = premiumGrid(variant);
  const ctr = centre(variant);

  // Squares first (premium colours + the alternating plain-cell tint, as in game). Bonus
  // squares carry NO text labels on the export — the fill alone reads universally, with the
  // coordinate axes carrying the location.
  for (let r = 0; r < BOARD_SIZE; r++) {
    for (let c = 0; c < BOARD_SIZE; c++) {
      const p = prem[r][c];
      ctx.fillStyle = p ? C.prem[p] : (r + c) % 2 === 1 && !board[r][c] ? C.cellDark : C.cell;
      ctx.fillRect(x + c * cell, y + r * cell, cell + 0.5, cell + 0.5);
    }
  }

  // The centre star on an uncovered centre square (rare in a finished game).
  if (!board[ctr.row][ctr.col]) {
    ctx.textAlign = 'center';
    ctx.textBaseline = 'middle';
    ctx.fillStyle = C.premText;
    ctx.globalAlpha = 0.7;
    ctx.font = `${cell * 0.54}px ${FONT}`;
    ctx.fillText('★', x + ctr.col * cell + cell / 2, y + ctr.row * cell + cell / 2);
    ctx.globalAlpha = 1;
  }

  // Tiles: rounded face, inset bottom bevel and a soft right-side shadow, letter top-left,
  // value bottom-right — the in-game look (Board.svelte), light palette.
  const rad = cell * 0.1;
  for (let r = 0; r < BOARD_SIZE; r++) {
    for (let c = 0; c < BOARD_SIZE; c++) {
      const t = board[r][c];
      if (!t) continue;
      const tx = x + c * cell;
      const ty = y + r * cell;
      ctx.save();
      ctx.shadowColor = 'rgba(0,0,0,0.4)';
      ctx.shadowOffsetX = cell * 0.05;
      ctx.shadowBlur = cell * 0.07;
      ctx.fillStyle = C.tile;
      roundRect(ctx, tx, ty, cell, cell, rad);
      ctx.fill();
      ctx.restore();
      // Inset bottom bevel: a tile-edge strip clipped to the rounded face.
      ctx.save();
      roundRect(ctx, tx, ty, cell, cell, rad);
      ctx.clip();
      ctx.fillStyle = C.tileEdge;
      ctx.fillRect(tx, ty + cell - cell * 0.05, cell, cell * 0.05);
      ctx.restore();

      ctx.fillStyle = C.tileText;
      ctx.textAlign = 'left';
      ctx.textBaseline = 'top';
      ctx.font = `700 ${cell * 0.63}px ${FONT}`;
      ctx.fillText(t.letter, tx + cell * 0.08, ty + cell * 0.07);
      const isErudit = variant === 'erudit_ru';
      ctx.textAlign = 'right';
      ctx.textBaseline = 'alphabetic';
      if (t.blank && isErudit) {
        // The Erudit blank marker (✻) in the value corner, as in game (usesStarBlank).
        ctx.font = `${cell * 0.32}px ${FONT}`;
        ctx.fillText('✻', tx + cell * 0.9, ty + cell * 0.9);
      } else if (!t.blank) {
        ctx.font = `600 ${cell * 0.28}px ${FONT}`;
        ctx.fillText(String(valueForLetter(variant, t.letter)), tx + cell * 0.9, ty + cell * 0.9);
      }
    }
  }

  // A hairline frame so the pale board edge reads against the paper.
  ctx.strokeStyle = C.border;
  ctx.lineWidth = 1;
  ctx.strokeRect(x - 0.5, y - 0.5, side + 1, side + 1);
}

/** fitText shrinks the font size until text fits maxW (floor 8.5px), returning the size. */
function fitText(ctx: CanvasRenderingContext2D, text: string, weight: string, size: number, maxW: number): number {
  let s = size;
  ctx.font = `${weight} ${s}px ${FONT}`;
  while (s > 8.5 && ctx.measureText(text).width > maxW) {
    s -= 0.5;
    ctx.font = `${weight} ${s}px ${FONT}`;
  }
  return s;
}

function ellipsize(ctx: CanvasRenderingContext2D, text: string, maxW: number): string {
  if (ctx.measureText(text).width <= maxW) return text;
  let t = text;
  while (t.length > 1 && ctx.measureText(t + '…').width > maxW) t = t.slice(0, -1);
  return t + '…';
}

function drawSheet(ctx: CanvasRenderingContext2D, l: ImageLayout, sheet: Scoresheet): void {
  const { x, y, colW, colGap } = l.hist;
  const seats = sheet.header.length;
  const colX = (i: number) => x + i * (colW + colGap);

  // Header: per-seat name (ellipsized) over the final score; 🏆 marks the winner.
  for (let i = 0; i < seats; i++) {
    const h = sheet.header[i];
    const cx = colX(i) + colW / 2;
    ctx.fillStyle = C.text;
    ctx.textAlign = 'center';
    ctx.textBaseline = 'middle';
    ctx.font = `600 13px ${FONT}`;
    ctx.fillText(ellipsize(ctx, h.name, colW - 8), cx, y + l.headNameH / 2);
    ctx.font = `700 16px ${FONT}`;
    const score = h.winner ? `${h.score} 🏆` : String(h.score);
    ctx.fillText(score, cx, y + l.headNameH + l.headScoreH / 2);
  }
  // Rule under the header, spanning the sheet.
  const ruleY = y + l.headNameH + l.headScoreH + l.headRuleH / 2;
  ctx.strokeStyle = C.border;
  ctx.lineWidth = 1;
  ctx.beginPath();
  ctx.moveTo(x, ruleY);
  ctx.lineTo(x + l.hist.w, ruleY);
  ctx.stroke();

  // Move rows: "<coord> <WORD>" left, points right; extra words on a small second line.
  let rowY = y + l.headNameH + l.headScoreH + l.headRuleH;
  for (let r = 0; r < sheet.rows.length; r++) {
    const row = sheet.rows[r];
    const rh = l.rowHeights[r];
    for (let i = 0; i < seats; i++) {
      const cell = row[i];
      const bx = colX(i);
      const baseline = rowY + l.rowH / 2;
      if (cell.kind === 'play') {
        const scoreText = String(cell.score);
        ctx.textAlign = 'right';
        ctx.textBaseline = 'middle';
        ctx.font = `600 11.5px ${FONT}`;
        ctx.fillStyle = C.text;
        ctx.fillText(scoreText, bx + colW, baseline);
        const scoreW = ctx.measureText(scoreText).width + 6;

        ctx.textAlign = 'left';
        ctx.fillStyle = C.muted;
        ctx.font = `600 10.5px ${FONT}`;
        ctx.fillText(cell.coord, bx, baseline);
        const coordW = ctx.measureText('D15').width + 5; // fixed slot so words align

        const wordMax = colW - coordW - scoreW;
        const word = cell.words[0] ?? '';
        ctx.fillStyle = C.text;
        const size = fitText(ctx, word, '500', 11.5, wordMax);
        ctx.font = `500 ${size}px ${FONT}`;
        ctx.fillText(ellipsize(ctx, word, wordMax), bx + coordW, baseline);

        if (cell.words.length > 1) {
          ctx.fillStyle = C.muted;
          ctx.font = `9.5px ${FONT}`;
          ctx.fillText(
            ellipsize(ctx, `+ ${cell.words.slice(1).join(', ')}`, colW - coordW),
            bx + coordW,
            rowY + l.rowH + l.subRowH / 2 - 1,
          );
        }
      } else if (cell.kind === 'action') {
        ctx.textAlign = 'left';
        ctx.textBaseline = 'middle';
        ctx.fillStyle = C.muted;
        ctx.font = `italic 10.5px ${FONT}`;
        ctx.fillText(ellipsize(ctx, cell.label, colW), bx, baseline);
      }
    }
    rowY += rh;
  }

  // Endgame rack settlement (when any seat has one): a closing ± row per column.
  if (sheet.hasAdjustments) {
    for (let i = 0; i < seats; i++) {
      const a = sheet.adjustments[i];
      if (a === 0) continue;
      ctx.textAlign = 'right';
      ctx.textBaseline = 'middle';
      ctx.fillStyle = C.muted;
      ctx.font = `600 10.5px ${FONT}`;
      ctx.fillText(a > 0 ? `+${a}` : String(a), colX(i) + colW, rowY + l.adjRowH / 2);
    }
  }
}
