import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
  computeScaleRatio,
  drawLayout,
  mapBodyAspectRatio,
  pageMm,
  pagePx,
  resolvePageSize,
  type LayoutOptions,
  type LegendEntry,
} from "../apps/geolibre-desktop/src/lib/print-layout";

/**
 * A minimal recording stand-in for a 2D canvas context. Every drawing method is
 * a no-op except `measureText` (returns a fixed width) and `fillText`, which
 * records the alignment in effect at the moment of the draw so tests can assert
 * how text was anchored.
 */
function recordingCanvas(): {
  canvas: HTMLCanvasElement;
  fills: { text: string; textAlign: string; textBaseline: string }[];
  fillRects: { w: number; h: number; fillStyle: string }[];
  imageBoxes: { w: number; h: number }[];
  arcs: number;
  polylines: number[];
} {
  const fills: { text: string; textAlign: string; textBaseline: string }[] = [];
  // Filled rectangles (swatches, chart bars), with the fill colour in effect.
  const fillRects: { w: number; h: number; fillStyle: string }[] = [];
  // Drawn images (custom SVG marker icons), with the box each was drawn into,
  // so a proportional marker ramp's graduated sizes can be asserted.
  const imageBoxes: { w: number; h: number }[] = [];
  // Stroked polylines: one entry per beginPath..stroke sequence that used
  // lineTo, holding the segment count (the line chart's path).
  const polylines: number[] = [];
  const counters = { arcs: 0, pendingLines: 0, drawImages: 0 };
  const state: Record<string, unknown> = {
    textAlign: "start",
    textBaseline: "alphabetic",
  };
  const ctx = new Proxy(state, {
    get(target, prop) {
      if (prop === "measureText") return () => ({ width: 10 });
      if (prop === "fillText") {
        return (text: string) =>
          fills.push({
            text,
            textAlign: String(target.textAlign),
            textBaseline: String(target.textBaseline),
          });
      }
      if (prop === "fillRect") {
        return (_x: number, _y: number, w: number, h: number) =>
          fillRects.push({ w, h, fillStyle: String(target.fillStyle) });
      }
      if (prop === "arc") {
        return () => {
          counters.arcs += 1;
        };
      }
      if (prop === "drawImage") {
        return (_img: unknown, _x: number, _y: number, w: number, h: number) => {
          counters.drawImages += 1;
          imageBoxes.push({ w, h });
        };
      }
      if (prop === "beginPath") {
        return () => {
          counters.pendingLines = 0;
        };
      }
      if (prop === "lineTo") {
        return () => {
          counters.pendingLines += 1;
        };
      }
      if (prop === "stroke") {
        return () => {
          if (counters.pendingLines > 0) polylines.push(counters.pendingLines);
          counters.pendingLines = 0;
        };
      }
      if (prop in target) return target[prop as string];
      // Any other method (save, restore, clip, ...) is a no-op.
      return () => {};
    },
    set(target, prop, value) {
      target[prop as string] = value;
      return true;
    },
  });
  const canvas = {
    width: 400,
    height: 400,
    getContext: () => ctx,
  } as unknown as HTMLCanvasElement;
  return {
    canvas,
    fills,
    fillRects,
    imageBoxes,
    get arcs() {
      return counters.arcs;
    },
    get drawImages() {
      return counters.drawImages;
    },
    polylines,
  };
}

function baseOptions(overrides: Partial<LayoutOptions> = {}): LayoutOptions {
  const legend: LegendEntry[] = [{ id: "a", name: "Roads", swatches: [{ color: "#ff0000" }] }];
  return {
    title: "Map",
    subtitle: "",
    paperSize: "a4",
    orientation: "landscape",
    showTitle: true,
    showLegend: true,
    showScaleBar: false,
    showNorthArrow: false,
    showFooter: false,
    footerText: "",
    legend,
    legendTitle: "Legend",
    legendGroupByLayer: true,
    metersPerPixel: 0,
    bearingDeg: 0,
    mapImage: null,
    mapImageWidth: 0,
    mapImageHeight: 0,
    ...overrides,
  };
}

describe("drawLayout legend rendering", () => {
  it("left-aligns legend row labels even when the legend title is empty", () => {
    // The title block draws centered text first; the legend must reset the
    // alignment for its rows regardless of whether it draws its own title.
    const { canvas, fills } = recordingCanvas();
    drawLayout(canvas, baseOptions({ legendTitle: "" }));

    const label = fills.find((f) => f.text === "Roads");
    assert.ok(label, "expected the legend row label to be drawn");
    assert.equal(label.textAlign, "left");
    assert.equal(label.textBaseline, "alphabetic");
  });

  it("renders the swatch label for an entry collapsed to one visible class", () => {
    // applyLegendConfig yields a single-swatch entry (layer name + the one
    // un-hidden class label) when the other classes are hidden; the label, not
    // the layer name, must be drawn.
    const { canvas, fills } = recordingCanvas();
    drawLayout(
      canvas,
      baseOptions({
        legend: [
          {
            id: "pop",
            name: "Population",
            swatches: [{ color: "#00aa00", label: "High" }],
          },
        ],
      }),
    );
    assert.ok(
      fills.some((f) => f.text === "High"),
      "expected the surviving class label to be drawn",
    );
    assert.ok(
      !fills.some((f) => f.text === "Population"),
      "expected the layer name not to replace the class label",
    );
  });

  it("elides class rows that do not fit the map body and says how many (GH #1608)", () => {
    // A categorized layer can carry more classes than the body is tall; the
    // legend is clipped to the body, so the overflow must be reported rather
    // than disappearing at the edge.
    const { canvas, fills } = recordingCanvas();
    drawLayout(
      canvas,
      baseOptions({
        legend: [
          {
            id: "otex",
            name: "Farms",
            swatches: Array.from({ length: 60 }, (_, i) => ({
              color: "#00aa00",
              label: `class-${i.toString()}`,
            })),
          },
        ],
        legendFormatNote: (count) => `+${count.toString()} more`,
      }),
    );
    const drawn = fills.filter((f) => f.text.startsWith("class-"));
    assert.ok(drawn.length > 0, "expected some class rows to be drawn");
    assert.ok(drawn.length < 60, "expected the overflowing rows to be elided");
    const note = fills.find((f) => f.text.startsWith("+") && f.text.endsWith("more"));
    assert.ok(note, "expected a truncation note");
    assert.equal(note.text, `+${(60 - drawn.length).toString()} more`);
  });

  it("counts only class rows in the note and drops a dangling group heading", () => {
    // With groupByLayer on, the flattened rows interleave layer headings. A
    // heading is scaffolding: it must not inflate the "+N more" count, and one
    // left with no classes under it describes nothing.
    const { canvas, fills } = recordingCanvas();
    const entry = (id: string, name: string, n: number): LegendEntry => ({
      id,
      name,
      swatches: Array.from({ length: n }, (_, i) => ({
        color: "#00aa00",
        label: `${id}-${i.toString()}`,
      })),
    });
    // Sized so the cut falls on Layer B's heading: at this page size the body
    // fits 25 legend rows, and Layer A occupies exactly the first 24.
    drawLayout(
      canvas,
      baseOptions({
        legendGroupByLayer: true,
        legend: [entry("a", "Layer A", 23), entry("b", "Layer B", 30)],
        legendFormatNote: (count) => `+${count.toString()} more`,
      }),
    );
    const texts = fills.map((f) => f.text);
    const classRows = texts.filter((t) => /^[ab]-\d+$/.test(t));
    const note = texts.find((t) => t.startsWith("+") && t.endsWith("more"));
    assert.ok(note, "expected a truncation note");
    // 53 class rows across the two layers; the headings must not be counted.
    assert.equal(note, `+${(53 - classRows.length).toString()} more`);
    // A heading is only drawn when at least one of its classes survives the cut.
    assert.ok(
      !texts.includes("Layer B") || classRows.some((t) => t.startsWith("b-")),
      "expected no orphan layer heading",
    );
    assert.ok(texts.includes("Layer A"), "expected the surviving layer's heading");
  });

  it("draws no truncation note when every class row fits", () => {
    const { canvas, fills } = recordingCanvas();
    drawLayout(
      canvas,
      baseOptions({
        legend: [
          {
            id: "otex",
            name: "Farms",
            swatches: Array.from({ length: 4 }, (_, i) => ({
              color: "#00aa00",
              label: `class-${i.toString()}`,
            })),
          },
        ],
        legendFormatNote: (count) => `+${count.toString()} more`,
      }),
    );
    assert.equal(fills.filter((f) => f.text.startsWith("class-")).length, 4);
    assert.ok(!fills.some((f) => f.text.endsWith("more")));
  });

  it("renders the layer name for a genuine single-symbol entry", () => {
    const { canvas, fills } = recordingCanvas();
    drawLayout(
      canvas,
      baseOptions({
        legend: [{ id: "a", name: "Roads", swatches: [{ color: "#ff0000" }] }],
      }),
    );
    assert.ok(fills.some((f) => f.text === "Roads"));
  });

  it("draws a built-in point marker as its shape (not a color square)", () => {
    const { canvas, polylines, fillRects } = recordingCanvas();
    drawLayout(
      canvas,
      baseOptions({
        legend: [
          {
            id: "pts",
            name: "Sites",
            swatches: [
              {
                color: "#00aa55",
                marker: { shape: "triangle", color: "#00aa55" },
              },
            ],
          },
        ],
      }),
    );
    // The triangle is a stroked/filled path, so a polyline is recorded and no
    // swatch-sized color square is filled for the marker.
    assert.ok(polylines.length > 0, "expected the marker shape path to be drawn");
    assert.ok(
      !fillRects.some((r) => r.fillStyle === "#00aa55" && r.w === r.h),
      "expected no plain color square for a shape marker",
    );
  });

  it("draws a preloaded custom SVG marker as an image", () => {
    const svg = "<svg/>";
    const image = {} as unknown as CanvasImageSource;
    // `drawImages` is a live getter, so read it off `rec` after the draw rather
    // than destructuring (which would capture its pre-draw value of 0).
    const rec = recordingCanvas();
    drawLayout(
      rec.canvas,
      baseOptions({
        legend: [
          {
            id: "bees",
            name: "Bees",
            swatches: [
              {
                color: "#3b82f6",
                marker: { shape: "custom", color: "#3b82f6", svg },
              },
            ],
          },
        ],
        markerIcons: new Map([[svg, image]]),
      }),
    );
    assert.equal(rec.drawImages, 1, "expected the SVG icon to be drawn as an image");
    assert.ok(
      !rec.fillRects.some((r) => r.fillStyle === "#3b82f6" && r.w === r.h),
      "expected no fallback color square when the icon is available",
    );
  });

  it("scales a proportional size ramp's marker icons instead of drawing one fixed box", () => {
    const svg = "<svg/>";
    const image = {} as unknown as CanvasImageSource;
    const marker = { shape: "custom", color: "#3b82f6", svg } as const;
    const rec = recordingCanvas();
    drawLayout(
      rec.canvas,
      baseOptions({
        legend: [
          {
            id: "ruchers",
            name: "Ruchers",
            swatches: [
              { color: "#3b82f6", label: "1", size: 4, marker },
              { color: "#3b82f6", label: "43.5", size: 14, marker },
              { color: "#3b82f6", label: "86", size: 24, marker },
            ],
          },
        ],
        markerIcons: new Map([[svg, image]]),
      }),
    );
    assert.equal(rec.drawImages, 3);
    const widths = rec.imageBoxes.map((box) => box.w);
    // Strictly growing, at the map's size ratios, so the ramp reads as one
    // symbol growing rather than three identical icons (GH discussion #1711).
    // The smallest row sits on the shared visible-symbol floor (like the circle
    // branch), so the ratio is asserted between the two unclamped rows.
    assert.ok(widths[0] < widths[1] && widths[1] < widths[2], `not growing: ${widths.join()}`);
    assert.ok(
      Math.abs(widths[1] / widths[2] - 14 / 24) < 0.02,
      `middle/max ratio off: ${widths.join()}`,
    );
    assert.ok(
      rec.imageBoxes.every((box) => box.w === box.h),
      "expected square marker boxes",
    );
  });

  it("matches proportional legend markers to the fitted map image scale", () => {
    const svg = "<svg/>";
    const image = {} as unknown as CanvasImageSource;
    const marker = { shape: "custom", color: "#3b82f6", svg } as const;
    const legend: LegendEntry[] = [
      {
        id: "ruchers",
        name: "Ruchers",
        swatches: [{ color: "#3b82f6", label: "86", size: 16, marker }],
      },
    ];
    const render = (mapImageWidth: number) => {
      const rec = recordingCanvas();
      drawLayout(
        rec.canvas,
        baseOptions({
          legend,
          markerIcons: new Map([[svg, image]]),
          mapImage: {} as CanvasImageSource,
          mapImageWidth,
          mapImageHeight: mapImageWidth,
          mapPixelRatio: 2,
        }),
      );
      return rec.imageBoxes.at(-1)!.w;
    };

    // A 400 px square page with normal margins has a 360 px map body. At DPR 2,
    // a 16 CSS-px radius is a 32 device-px radius before the map fit is applied.
    // (Kept under the swatch cap below so this asserts the fit scaling alone.)
    assert.equal(render(400), 57.6);
    assert.equal(render(800), 28.8);
  });

  it("caps an outsized proportional symbol instead of blanking the legend box", () => {
    // A live-map radius far larger than the page: sized 1:1 it would make one
    // row taller than the whole map body, and the truncation rule would drop
    // the entire box — including the unrelated entry below it.
    const legend: LegendEntry[] = [
      {
        id: "hives",
        name: "Hives",
        swatches: [
          { color: "#3b82f6", label: "1", size: 4 },
          { color: "#3b82f6", label: "9000", size: 900 },
        ],
      },
    ];
    const rec = recordingCanvas();
    drawLayout(
      rec.canvas,
      baseOptions({
        legend,
        mapImage: {} as CanvasImageSource,
        mapImageWidth: 400,
        mapImageHeight: 400,
        mapPixelRatio: 2,
      }),
    );
    const texts = rec.fills.map((f) => f.text);
    assert.ok(texts.includes("Hives"), `legend was blanked: ${texts.join()}`);
    assert.ok(texts.includes("1") && texts.includes("9000"), `rows elided: ${texts.join()}`);
  });

  it("scopes the outsized-symbol shrink to the entry that overflowed", () => {
    const svg = "<svg/>";
    const image = {} as unknown as CanvasImageSource;
    const marker = { shape: "custom", color: "#3b82f6", svg } as const;
    // Two proportional layers sharing one legend: only the first overflows the
    // swatch column, so the second must still draw 1:1 with the map.
    const legend: LegendEntry[] = [
      {
        id: "hives",
        name: "Hives",
        swatches: [{ color: "#3b82f6", label: "9000", size: 900, marker }],
      },
      {
        id: "wells",
        name: "Wells",
        swatches: [{ color: "#3b82f6", label: "3", size: 8, marker }],
      },
    ];
    const rec = recordingCanvas();
    drawLayout(
      rec.canvas,
      baseOptions({
        legend,
        markerIcons: new Map([[svg, image]]),
        mapImage: {} as CanvasImageSource,
        mapImageWidth: 400,
        mapImageHeight: 400,
        mapPixelRatio: 2,
      }),
    );
    // mapSymbolScale is 2 (DPR) × 0.9 (map fit), so the modest 8 px radius is a
    // 14.4 px radius, i.e. a 28.8 px box — untouched by the outlier's shrink.
    assert.equal(rec.imageBoxes.at(-1)!.w, 28.8);
  });

  it("falls back to a color square when a custom SVG marker icon is not preloaded", () => {
    const svg = "<svg/>";
    const rec = recordingCanvas();
    drawLayout(
      rec.canvas,
      baseOptions({
        legend: [
          {
            id: "bees",
            name: "Bees",
            swatches: [
              {
                color: "#3b82f6",
                marker: { shape: "custom", color: "#3b82f6", svg },
              },
            ],
          },
        ],
        // No markerIcons: the icon is unavailable.
      }),
    );
    assert.equal(rec.drawImages, 0);
    assert.ok(
      rec.fillRects.some((r) => r.fillStyle === "#3b82f6" && r.w === r.h),
      "expected a fallback color square when the icon is missing",
    );
  });

  it("falls back to sized circles, not framed squares, for an unloaded ramp icon", () => {
    const svg = "<svg/>";
    const marker = { shape: "custom", color: "#3b82f6", svg } as const;
    const rec = recordingCanvas();
    drawLayout(
      rec.canvas,
      baseOptions({
        legend: [
          {
            id: "ruchers",
            name: "Ruchers",
            swatches: [
              { color: "#3b82f6", label: "1", size: 4, marker },
              { color: "#3b82f6", label: "43.5", size: 14, marker },
              { color: "#3b82f6", label: "86", size: 24, marker },
            ],
          },
        ],
        // No markerIcons: a remote icon still fetching, or one that failed.
      }),
    );
    assert.equal(rec.drawImages, 0);
    // Three filled circles, so the ramp still reads as one growing symbol
    // during the load window rather than a column of identical framed boxes.
    assert.ok(rec.arcs >= 3, `expected fallback circles, got ${rec.arcs} arcs`);
    assert.ok(
      !rec.fillRects.some((r) => r.fillStyle === "#3b82f6" && r.w === r.h),
      "expected no square fallback swatch for a proportional row",
    );
  });

  it("draws the marker of a multi-swatch entry's primary swatch as a shape", () => {
    const { canvas, polylines, fillRects } = recordingCanvas();
    drawLayout(
      canvas,
      baseOptions({
        legend: [
          {
            id: "md",
            name: "Marker+Diagram",
            swatches: [
              {
                color: "#00aa55",
                marker: { shape: "triangle", color: "#00aa55" },
              },
              { color: "#111111", label: "votes" },
            ],
          },
        ],
      }),
    );
    // The primary swatch's marker is a triangle path; the diagram swatch is a
    // plain color square.
    assert.ok(polylines.length > 0, "expected the primary marker shape to be drawn");
    assert.ok(
      fillRects.some((r) => r.fillStyle === "#111111" && r.w === r.h),
      "expected the diagram class swatch to still be a color square",
    );
  });

  it("left-aligns legend rows when a legend title is present", () => {
    const { canvas, fills } = recordingCanvas();
    drawLayout(canvas, baseOptions({ legendTitle: "Key" }));

    assert.ok(
      fills.some((f) => f.text === "Key" && f.textAlign === "left"),
      "expected the legend title to be drawn left-aligned",
    );
    const label = fills.find((f) => f.text === "Roads");
    assert.ok(label, "expected the legend row label to be drawn");
    assert.equal(label.textAlign, "left");
  });
});

describe("resolvePageSize", () => {
  it("swaps width/height for landscape preset paper", () => {
    const portrait = resolvePageSize({
      paperSize: "a4",
      orientation: "portrait",
    });
    assert.deepEqual(portrait, { width: 210, height: 297, unit: "mm" });
    const landscape = resolvePageSize({
      paperSize: "a4",
      orientation: "landscape",
    });
    assert.deepEqual(landscape, { width: 297, height: 210, unit: "mm" });
  });

  it("resolves a pixel screen preset to its oriented pixel dimensions", () => {
    const landscape = resolvePageSize({
      paperSize: "fullhd",
      orientation: "landscape",
    });
    assert.deepEqual(landscape, { width: 1920, height: 1080, unit: "px" });
  });

  it("takes custom dimensions verbatim, ignoring orientation", () => {
    const size = resolvePageSize({
      paperSize: "custom",
      orientation: "landscape",
      customSize: { width: 800, height: 600, unit: "px" },
    });
    assert.deepEqual(size, { width: 800, height: 600, unit: "px" });
  });

  it("falls back to a sane default for an incomplete custom size", () => {
    const size = resolvePageSize({
      paperSize: "custom",
      orientation: "portrait",
      customSize: { width: 0, height: 0, unit: "px" },
    });
    assert.deepEqual(size, { width: 1280, height: 720, unit: "px" });
  });
});

describe("mapBodyAspectRatio", () => {
  it("accounts for an outside title when fitting the atlas camera", () => {
    const inside = mapBodyAspectRatio(
      baseOptions({
        titlePlacement: "inside",
        showAttribution: false,
        showDate: false,
      }),
    );
    const outside = mapBodyAspectRatio(
      baseOptions({
        titlePlacement: "outside",
        showAttribution: false,
        showDate: false,
      }),
    );
    assert.ok(outside > inside);
  });
});

describe("pageMm / pagePx", () => {
  it("passes millimetre sizes through and scales by dpi for pixels", () => {
    assert.deepEqual(pageMm({ width: 210, height: 297, unit: "mm" }), {
      widthMm: 210,
      heightMm: 297,
    });
    assert.deepEqual(pagePx({ width: 1920, height: 1080, unit: "px" }, 150), {
      width: 1920,
      height: 1080,
    });
    // A4 portrait at 150 dpi: 210 mm * 150 / 25.4 ≈ 1240 px.
    assert.deepEqual(pagePx({ width: 210, height: 297, unit: "mm" }, 150), {
      width: 1240,
      height: 1754,
    });
  });

  it("treats screen pixels as 96-dpi millimetres", () => {
    const { widthMm } = pageMm({ width: 96, height: 96, unit: "px" });
    assert.ok(Math.abs(widthMm - 25.4) < 1e-6, "96 px at 96 dpi is one inch");
  });
});

describe("drawLayout cartographic furniture", () => {
  it("right-aligns the title when titleAlign is right", () => {
    const { canvas, fills } = recordingCanvas();
    drawLayout(canvas, baseOptions({ title: "Atlas", titleAlign: "right" }));
    const title = fills.find((f) => f.text === "Atlas");
    assert.ok(title, "expected the title to be drawn");
    assert.equal(title.textAlign, "right");
  });

  it("draws the attribution and date in the footer row independently", () => {
    const { canvas, fills } = recordingCanvas();
    drawLayout(
      canvas,
      baseOptions({
        showAttribution: true,
        showDate: true,
        dateText: "2026-06-19",
        showFooter: true,
        footerText: "My Org",
      }),
    );
    const attribution = fills.find((f) => f.text === "Created with GeoLibre");
    assert.ok(attribution, "expected the attribution to be drawn");
    assert.equal(attribution.textAlign, "left");
    const date = fills.find((f) => f.text === "2026-06-19");
    assert.ok(date, "expected the date to be drawn");
    assert.equal(date.textAlign, "right");
    const footer = fills.find((f) => f.text === "My Org");
    assert.ok(footer, "expected the custom footer text to be drawn");
    assert.equal(footer.textAlign, "center");
  });

  it("omits the attribution when showAttribution is false", () => {
    const { canvas, fills } = recordingCanvas();
    drawLayout(canvas, baseOptions({ showAttribution: false }));
    assert.ok(!fills.some((f) => f.text === "Created with GeoLibre"));
  });

  it("draws a 1:N scale ratio for a millimetre paper size", () => {
    const { canvas, fills } = recordingCanvas();
    drawLayout(
      canvas,
      baseOptions({
        showScaleBar: true,
        metersPerPixel: 10,
        mapImage: {} as CanvasImageSource,
        mapImageWidth: 400,
        mapImageHeight: 400,
      }),
    );
    assert.ok(
      fills.some((f) => /^1:/.test(f.text)),
      "expected a representative-fraction label",
    );
  });

  it("labels the scale bar in metric by default", () => {
    const { canvas, fills } = recordingCanvas();
    drawLayout(
      canvas,
      baseOptions({
        showScaleBar: true,
        metersPerPixel: 10,
        mapImage: {} as CanvasImageSource,
        mapImageWidth: 400,
        mapImageHeight: 400,
      }),
    );
    assert.ok(
      fills.some((f) => /^\d[\d.]* (km|m|cm)$/.test(f.text)),
      "expected a metric distance label (km/m/cm)",
    );
    assert.ok(
      !fills.some((f) => /(mi|ft|nmi)$/.test(f.text)),
      "did not expect an imperial/nautical label under metric",
    );
  });

  it("labels the scale bar in imperial when scaleUnit is imperial", () => {
    const { canvas, fills } = recordingCanvas();
    drawLayout(
      canvas,
      baseOptions({
        showScaleBar: true,
        scaleUnit: "imperial",
        metersPerPixel: 10,
        mapImage: {} as CanvasImageSource,
        mapImageWidth: 400,
        mapImageHeight: 400,
      }),
    );
    assert.ok(
      fills.some((f) => /^\d[\d.]* (mi|ft)$/.test(f.text)),
      "expected an imperial distance label (mi/ft)",
    );
    assert.ok(
      !fills.some((f) => /(km|nmi)$/.test(f.text) || / m$| cm$/.test(f.text)),
      "did not expect a metric/nautical label under imperial",
    );
  });

  it("labels the scale bar in nautical miles when scaleUnit is nautical", () => {
    const { canvas, fills } = recordingCanvas();
    drawLayout(
      canvas,
      baseOptions({
        showScaleBar: true,
        scaleUnit: "nautical",
        metersPerPixel: 10,
        mapImage: {} as CanvasImageSource,
        mapImageWidth: 400,
        mapImageHeight: 400,
      }),
    );
    assert.ok(
      fills.some((f) => /^\d[\d.]* nmi$/.test(f.text)),
      "expected a nautical-mile distance label (nmi)",
    );
  });

  it("does not draw a scale ratio for a pixel screen size", () => {
    const { canvas, fills } = recordingCanvas();
    drawLayout(
      canvas,
      baseOptions({
        paperSize: "fullhd",
        showScaleBar: true,
        metersPerPixel: 10,
        mapImage: {} as CanvasImageSource,
        mapImageWidth: 400,
        mapImageHeight: 400,
      }),
    );
    assert.ok(!fills.some((f) => /^1:/.test(f.text)));
  });

  it("draws the info block rows when showInfoBlock and fields are set", () => {
    const { canvas, fills } = recordingCanvas();
    drawLayout(
      canvas,
      baseOptions({
        showInfoBlock: true,
        author: "Jane Cartographer",
        projectNumber: "PRJ-42",
        crs: "EPSG:28992",
        revision: "Rev 01",
        infoLabels: {
          author: "Author",
          project: "Project",
          crs: "CRS",
          revision: "Rev",
        },
      }),
    );
    for (const v of ["Jane Cartographer", "PRJ-42", "EPSG:28992", "Rev 01"]) {
      assert.ok(
        fills.some((f) => f.text === v),
        `expected the info block to draw "${v}"`,
      );
    }
  });

  it("omits the info block when showInfoBlock is false", () => {
    const { canvas, fills } = recordingCanvas();
    drawLayout(canvas, baseOptions({ author: "Hidden Author" }));
    assert.ok(!fills.some((f) => f.text === "Hidden Author"));
  });
});

describe("computeScaleRatio", () => {
  const withMap = (overrides: Partial<LayoutOptions> = {}): LayoutOptions =>
    baseOptions({
      metersPerPixel: 10,
      mapImage: {} as CanvasImageSource,
      mapImageWidth: 400,
      mapImageHeight: 400,
      ...overrides,
    });

  it("returns 0 for a pixel/screen page (no physical scale)", () => {
    assert.equal(computeScaleRatio(withMap({ paperSize: "fullhd" })), 0);
  });

  it("returns 0 when there is no captured map", () => {
    assert.equal(computeScaleRatio(baseOptions({ metersPerPixel: 10, mapImage: null })), 0);
  });

  it("returns a positive representative fraction for a millimetre page", () => {
    const ratio = computeScaleRatio(withMap());
    assert.ok(ratio > 0 && Number.isFinite(ratio));
  });

  it("scales linearly with metres-per-pixel (the basis of the zoom math)", () => {
    // The scale denominator is linear in metres-per-pixel, which halves per zoom
    // level, so applyScale derives its zoom delta as log2(current / target).
    const a = computeScaleRatio(withMap({ metersPerPixel: 10 }));
    const b = computeScaleRatio(withMap({ metersPerPixel: 20 }));
    assert.ok(Math.abs(b / a - 2) < 1e-9, "doubling mpp doubles the ratio");
  });
});

/**
 * A recording context that captures every `strokeRect` with the stroke style and
 * line width in effect at the moment of the draw, so tests can assert how the
 * map frame border (GH #749) is rendered.
 */
function strokeRecordingCanvas(): {
  canvas: HTMLCanvasElement;
  strokes: { x: number; w: number; strokeStyle: string; lineWidth: number }[];
} {
  const strokes: {
    x: number;
    w: number;
    strokeStyle: string;
    lineWidth: number;
  }[] = [];
  const state: Record<string, unknown> = { strokeStyle: "", lineWidth: 0 };
  const ctx = new Proxy(state, {
    get(target, prop) {
      if (prop === "measureText") return () => ({ width: 10 });
      if (prop === "strokeRect") {
        return (x: number, _y: number, w: number) =>
          strokes.push({
            x,
            w,
            strokeStyle: String(target.strokeStyle),
            lineWidth: Number(target.lineWidth),
          });
      }
      if (prop in target) return target[prop as string];
      return () => {};
    },
    set(target, prop, value) {
      target[prop as string] = value;
      return true;
    },
  });
  const canvas = {
    width: 400,
    height: 400,
    getContext: () => ctx,
  } as unknown as HTMLCanvasElement;
  return { canvas, strokes };
}

describe("drawLayout map frame border (GH #749)", () => {
  // These constants follow directly from computeBodyRect on a 400×400 canvas
  // with the default "normal" margin: unit = min(400,400)/100 = 4, margin =
  // unit*5 = 20, so bodyX = 20 and bodyW = 400 - 40 = 360 (> 200). If the unit
  // formula or default margin changes, update these values.
  const isBodyBorder = (s: { x: number; w: number }) => s.x === 20 && s.w > 200;

  it("draws the frame in the chosen colour and thickness", () => {
    const { canvas, strokes } = strokeRecordingCanvas();
    drawLayout(canvas, baseOptions({ mapBorderColor: "#123456", mapBorderWidth: 5 }));
    const frame = strokes.find((s) => isBodyBorder(s) && s.strokeStyle === "#123456");
    assert.ok(frame, "expected the map frame to use the custom colour");
    assert.ok(frame.lineWidth > 1, "expected a thicker frame for width 5");
  });

  it("hides the frame when the thickness is 0", () => {
    const { canvas, strokes } = strokeRecordingCanvas();
    drawLayout(canvas, baseOptions({ mapBorderWidth: 0 }));
    assert.ok(!strokes.some(isBodyBorder), "expected no map frame stroke when width is 0");
  });
});

describe("drawLayout data blocks (GH #1324)", () => {
  it("renders the table title, headers, cells, and truncation note", () => {
    const { canvas, fills } = recordingCanvas();
    drawLayout(
      canvas,
      baseOptions({
        dataTable: {
          title: "Cities",
          columns: ["name", "pop"],
          rows: [
            ["Springfield", "42000"],
            ["Shelbyville", "39000"],
          ],
          truncated: 3,
          formatNote: (count) => `+${count} more`,
          position: "bottom-left",
        },
      }),
    );
    for (const text of ["Cities", "name", "pop", "Springfield", "42000", "+3 more"]) {
      const fill = fills.find((f) => f.text === text);
      assert.ok(fill, `expected "${text}" to be drawn`);
      assert.equal(fill.textAlign, "left");
    }
  });

  it("drops rows that cannot fit the body height and folds them into the note", () => {
    const rows = Array.from({ length: 50 }, (_, i) => [`row${i}`]);
    const rec = recordingCanvas();
    drawLayout(
      rec.canvas,
      baseOptions({
        dataTable: {
          columns: ["name"],
          rows,
          truncated: 5,
          position: "bottom-left",
        },
      }),
    );
    // 50 rows cannot fit a 400px page body; the tail rows must be dropped...
    assert.ok(rec.fills.some((f) => f.text === "row0"));
    assert.ok(!rec.fills.some((f) => f.text === "row49"));
    // ...and the note must count both the dialog-truncated and height-hidden
    // rows (so it reads "+N more" with N > the dialog's own 5).
    const note = rec.fills.find((f) => /^\+\d+ more$/.test(f.text));
    assert.ok(note, "expected a truncation note to be drawn");
    assert.ok(Number(note.text.match(/\d+/)?.[0]) > 5);
  });

  it("skips a table with no rows or no columns", () => {
    const { canvas, fills } = recordingCanvas();
    drawLayout(
      canvas,
      baseOptions({
        dataTable: {
          columns: ["name"],
          rows: [],
          position: "bottom-left",
        },
      }),
    );
    assert.ok(!fills.some((f) => f.text === "name"));
  });

  it("renders bar chart labels, values, and one bar rect per category", () => {
    const rec = recordingCanvas();
    drawLayout(
      rec.canvas,
      baseOptions({
        dataChart: {
          title: "Population",
          position: "top-right",
          data: {
            kind: "bar",
            bars: [
              { label: "CA", value: 12, color: "#111111" },
              { label: "OR", value: 4, color: "#222222" },
            ],
            maxValue: 12,
            minValue: 0,
          },
        },
      }),
    );
    for (const text of ["Population", "CA", "OR", "12", "4"]) {
      assert.ok(
        rec.fills.some((f) => f.text === text),
        `expected "${text}" to be drawn`,
      );
    }
    // The bars themselves must be filled in each category's colour, with the
    // larger value producing the wider rect.
    const ca = rec.fillRects.find((r) => r.fillStyle === "#111111");
    const or = rec.fillRects.find((r) => r.fillStyle === "#222222");
    assert.ok(ca && or, "expected one filled bar rect per category");
    assert.ok(ca.w > or.w, "expected the larger value to draw a wider bar");
  });

  it("draws a truncation note for bar categories past the top-N cap", () => {
    const rec = recordingCanvas();
    drawLayout(
      rec.canvas,
      baseOptions({
        dataChart: {
          position: "top-right",
          data: {
            kind: "bar",
            bars: [{ label: "CA", value: 12, color: "#111111" }],
            maxValue: 12,
            minValue: 0,
            truncated: 7,
          },
          formatNote: (count) => `+${count} more`,
        },
      }),
    );
    assert.ok(
      rec.fills.some((f) => f.text === "+7 more"),
      "expected the dropped-category note to be drawn",
    );
  });

  it("renders pie slice labels, percentages, and slice arcs", () => {
    const rec = recordingCanvas();
    const before = rec.arcs;
    drawLayout(
      rec.canvas,
      baseOptions({
        dataChart: {
          position: "bottom-right",
          data: {
            kind: "pie",
            slices: [
              { label: "a", value: 3, color: "#111111" },
              { label: "b", value: 1, color: "#222222" },
            ],
            total: 4,
          },
        },
      }),
    );
    assert.ok(rec.fills.some((f) => f.text === "a (75%)"));
    assert.ok(rec.fills.some((f) => f.text === "b (25%)"));
    // One arc per slice plus the outline circle (the north arrow's backing
    // disc also uses arc, so require at least that many new arcs).
    assert.ok(rec.arcs - before >= 3, "expected slice arcs and the outline circle to be drawn");
    // Slice colours are also used for the legend swatch rects.
    assert.ok(rec.fillRects.some((r) => r.fillStyle === "#111111"));
    assert.ok(rec.fillRects.some((r) => r.fillStyle === "#222222"));
  });

  it("renders the line chart's axis labels and polyline path", () => {
    const rec = recordingCanvas();
    drawLayout(
      rec.canvas,
      baseOptions({
        dataChart: {
          position: "top-left",
          data: {
            kind: "line",
            points: [
              { index: 0, value: 5 },
              { index: 1, value: 7 },
              { index: 2, value: 9 },
            ],
            min: 5,
            max: 9,
            length: 3,
            color: "#111111",
          },
        },
      }),
    );
    assert.ok(rec.fills.some((f) => f.text === "9" && f.textAlign === "right"));
    assert.ok(rec.fills.some((f) => f.text === "5" && f.textAlign === "right"));
    // The path itself: one stroked polyline with a segment per point pair.
    assert.ok(rec.polylines.includes(2), "expected a stroked 2-segment polyline for the 3 points");
  });
});
