import { describe, it, expect } from "vitest";
import {
  layoutMosaic,
  layoutMosaicStrip,
  mosaicTargetHeight,
  type MosaicInput,
  type MosaicRow,
} from "./mosaic";

const VIDEO = 16 / 9;
const SQUARE = 1;
const POSTER = 2 / 3;

function tiles(...ratios: number[]): MosaicInput[] {
  return ratios.map((ratio, i) => ({ key: `t${i}`, ratio }));
}

function rowWidth(row: MosaicRow, gap: number): number {
  return row.tiles.reduce((sum, t) => sum + t.width, 0) + gap * (row.tiles.length - 1);
}

/** Every tile bar the one that absorbs the rounding remainder keeps its ratio. */
function offRatioTiles(row: MosaicRow, tolerancePx = 1): number {
  return row.tiles.filter((t) => Math.abs(t.width - t.ratio * t.height) > tolerancePx).length;
}

describe("layoutMosaic", () => {
  const opts = { containerWidth: 1000, targetHeight: 160, gap: 8 };

  it("fills the container width exactly on every row but the last", () => {
    const rows = layoutMosaic(tiles(VIDEO, SQUARE, POSTER, VIDEO, SQUARE, VIDEO, POSTER), opts);

    expect(rows.length).toBeGreaterThan(1);
    for (const row of rows.slice(0, -1)) {
      expect(rowWidth(row, opts.gap)).toBe(opts.containerWidth);
    }
  });

  it("never overflows the container, last row included", () => {
    const rows = layoutMosaic(tiles(VIDEO, SQUARE, POSTER, VIDEO, SQUARE, VIDEO, POSTER), opts);
    for (const row of rows) {
      expect(rowWidth(row, opts.gap)).toBeLessThanOrEqual(opts.containerWidth);
    }
  });

  it("gives every tile in a row the same height", () => {
    const rows = layoutMosaic(tiles(VIDEO, SQUARE, POSTER, VIDEO, SQUARE, VIDEO), opts);
    for (const row of rows) {
      for (const tile of row.tiles) {
        expect(tile.height).toBe(row.height);
      }
    }
  });

  it("honours each tile's aspect ratio — widths vary, nothing is squashed", () => {
    const rows = layoutMosaic(tiles(VIDEO, SQUARE, POSTER, VIDEO, SQUARE, VIDEO), opts);

    for (const row of rows) {
      // At most the single remainder-absorbing tile may be off, and only by the
      // few pixels the row was short of the container width.
      expect(offRatioTiles(row)).toBeLessThanOrEqual(1);
    }

    // A 16:9 tile is meaningfully wider than a 2:3 tile at the same height.
    const all = rows.flatMap((r) => r.tiles);
    const video = all.find((t) => t.key === "t0")!;
    const poster = all.find((t) => t.key === "t2")!;
    expect(video.width).toBeGreaterThan(poster.width * 2);
  });

  it("keeps rows at or below the target height", () => {
    const rows = layoutMosaic(tiles(...Array(12).fill(VIDEO)), opts);
    for (const row of rows) {
      expect(row.height).toBeLessThanOrEqual(opts.targetHeight);
    }
  });

  it("does not stretch a short last row across the whole container", () => {
    // Two 16:9 tiles cannot fill 1000px at 160px tall (they want ~569px), so the
    // last row must stay at the target height rather than blow up to fill.
    const rows = layoutMosaic(tiles(VIDEO, VIDEO), opts);

    expect(rows).toHaveLength(1);
    expect(rows[0].height).toBe(opts.targetHeight);
    expect(rowWidth(rows[0], opts.gap)).toBeLessThan(opts.containerWidth);
  });

  it("shrinks a last row that would otherwise overflow", () => {
    // Five 16:9 tiles at 160px tall want ~1454px; the row has to come down.
    const rows = layoutMosaic(tiles(VIDEO, VIDEO, VIDEO, VIDEO, VIDEO), {
      ...opts,
      targetHeight: 400,
    });

    for (const row of rows) {
      expect(rowWidth(row, opts.gap)).toBeLessThanOrEqual(opts.containerWidth);
    }
  });

  it("clamps an extreme ratio instead of letting it own a row", () => {
    const rows = layoutMosaic(tiles(20, SQUARE, SQUARE), { ...opts, maxRatio: 2.5 });
    const panorama = rows.flatMap((r) => r.tiles).find((t) => t.key === "t0")!;
    expect(panorama.width / panorama.height).toBeLessThanOrEqual(2.6);
  });

  it("treats a missing or nonsensical ratio as square rather than collapsing", () => {
    const rows = layoutMosaic(
      [
        { key: "nan", ratio: Number.NaN },
        { key: "zero", ratio: 0 },
        { key: "neg", ratio: -2 },
      ],
      opts,
    );
    for (const tile of rows.flatMap((r) => r.tiles)) {
      expect(tile.width).toBeCloseTo(tile.height, -1);
    }
  });

  it("renders nothing before the container has been measured", () => {
    expect(layoutMosaic(tiles(VIDEO, SQUARE), { ...opts, containerWidth: 0 })).toEqual([]);
    expect(layoutMosaic(tiles(VIDEO, SQUARE), { ...opts, targetHeight: 0 })).toEqual([]);
    expect(layoutMosaic([], opts)).toEqual([]);
  });

  it("places every tile exactly once, in order", () => {
    const input = tiles(VIDEO, SQUARE, POSTER, VIDEO, SQUARE, VIDEO, POSTER, SQUARE);
    const placed = layoutMosaic(input, opts).flatMap((r) => r.tiles.map((t) => t.key));
    expect(placed).toEqual(input.map((t) => t.key));
  });

  it("re-packs when the container narrows", () => {
    const input = tiles(VIDEO, SQUARE, POSTER, VIDEO, SQUARE, VIDEO);
    const wide = layoutMosaic(input, { ...opts, containerWidth: 1400 });
    const narrow = layoutMosaic(input, { ...opts, containerWidth: 420 });
    expect(narrow.length).toBeGreaterThan(wide.length);
  });
});

describe("mosaicTargetHeight", () => {
  it("still fits two 16:9 tiles across a phone", () => {
    const width = 360;
    const height = mosaicTargetHeight(width);
    const rows = layoutMosaic(tiles(VIDEO, VIDEO, VIDEO), {
      containerWidth: width,
      targetHeight: height,
      gap: 8,
    });
    expect(rows[0].tiles.length).toBeGreaterThanOrEqual(2);
  });

  it("grows with the container but stays within bounds", () => {
    const widths = [0, 320, 600, 900, 1400, 3000];
    const heights = widths.map(mosaicTargetHeight);

    for (const h of heights) {
      expect(h).toBeGreaterThanOrEqual(96);
      expect(h).toBeLessThanOrEqual(190);
    }
    for (let i = 1; i < heights.length; i++) {
      expect(heights[i]).toBeGreaterThanOrEqual(heights[i - 1]);
    }
  });
});

describe("layoutMosaicStrip", () => {
  it("gives one height and ratio-derived widths", () => {
    const strip = layoutMosaicStrip(tiles(VIDEO, SQUARE, POSTER), 140);

    expect(strip.map((t) => t.height)).toEqual([140, 140, 140]);
    expect(strip[0].width).toBe(Math.round(VIDEO * 140));
    expect(strip[1].width).toBe(140);
    expect(strip[2].width).toBe(Math.round(POSTER * 140));
  });

  it("returns nothing for a height it cannot draw", () => {
    expect(layoutMosaicStrip(tiles(VIDEO), 0)).toEqual([]);
  });
});
