// Justified ("mosaic") tile layout — pure geometry, no DOM.
//
// The library overview and the home "Your Libraries" strip both show artwork of
// mixed shapes: square music covers next to 16:9 library backdrops next to 2:3
// posters. A CSS grid forces one box shape on all of them, so every tile that
// isn't that shape is cropped or letterboxed. This packs tiles into rows of a
// *shared height* and lets each keep its own width, so each tile is displayed at
// its own aspect ratio and nothing is distorted.
//
// Presentation only — nothing here knows what a library or a media item is.
//
// TRACES: UR-075 | DR-174 | UT-158, UT-159, UT-160

/** A tile to place: an opaque key and the aspect ratio (width / height) to honour. */
export interface MosaicInput {
  key: string;
  /** width / height. 1 = square, 16/9 ≈ 1.78, 2/3 ≈ 0.67. */
  ratio: number;
}

/**
 * A tile with its resolved pixel box. Generic so callers can hang whatever they
 * need to render (the library, the label, a route) off the same object.
 */
export type MosaicTile<T extends MosaicInput = MosaicInput> = T & {
  width: number;
  height: number;
};

/** One row of tiles, all sharing `height`. */
export interface MosaicRow<T extends MosaicInput = MosaicInput> {
  height: number;
  tiles: MosaicTile<T>[];
}

export interface MosaicOptions {
  /** Usable width in px (already net of the container's own padding). */
  containerWidth: number;
  /** The height rows aim for. Rows land at or below it; see `layoutMosaic`. */
  targetHeight: number;
  /** Gap between tiles in a row, in px. Rows are justified around it. */
  gap?: number;
  /**
   * Ratios outside this band are clamped. An extreme tile would otherwise take a
   * whole row to itself (very wide) or shrink to a sliver (very tall); clamping
   * costs a little crop on the outliers and keeps the mosaic readable.
   */
  minRatio?: number;
  maxRatio?: number;
}

const DEFAULTS = {
  gap: 8,
  minRatio: 0.5,
  maxRatio: 2.5,
} as const;

function clamp(value: number, min: number, max: number): number {
  return Math.min(max, Math.max(min, value));
}

/** A ratio we can lay out: finite and positive, clamped into the band. */
function usableRatio(ratio: number, min: number, max: number): number {
  if (!Number.isFinite(ratio) || ratio <= 0) return 1;
  return clamp(ratio, min, max);
}

/**
 * Give a row its pixel boxes.
 *
 * `justifyTo` is the width the row must fill *exactly* — rounding each tile
 * independently leaves the row a pixel or two short or long, which reads as a
 * ragged right edge, so the remainder is absorbed by the widest tile (where one
 * pixel is least visible). A `null` justifies nothing: the last row keeps its
 * natural width and is left-aligned.
 */
function buildRow<T extends MosaicInput>(
  items: T[],
  height: number,
  gap: number,
  justifyTo: number | null,
): MosaicRow<T> {
  const h = Math.max(1, Math.round(height));
  const tiles: MosaicTile<T>[] = items.map((item) => ({
    ...item,
    height: h,
    width: Math.max(1, Math.round(item.ratio * h)),
  }));

  if (justifyTo !== null && tiles.length > 0) {
    const used = tiles.reduce((sum, t) => sum + t.width, 0) + gap * (tiles.length - 1);
    const delta = justifyTo - used;
    if (delta !== 0) {
      let widest = 0;
      for (let i = 1; i < tiles.length; i++) {
        if (tiles[i].width > tiles[widest].width) widest = i;
      }
      tiles[widest].width = Math.max(1, tiles[widest].width + delta);
    }
  }

  return { height: h, tiles };
}

/**
 * Pack `items` into justified rows.
 *
 * Tiles are added to a row until the height needed to fill `containerWidth` has
 * fallen to `targetHeight` — at which point the row is closed at that height, so
 * rows come out at or slightly below the target rather than above it. The final
 * row is never stretched to fill the width: with one tile left over, justifying
 * would blow it up to the full container width. It sits at `targetHeight`
 * instead (or lower, if its natural fit is already shorter), left-aligned.
 *
 * Returns `[]` for a container with no width — a first paint before the element
 * has been measured, which must render nothing rather than a row of 1px tiles.
 */
export function layoutMosaic<T extends MosaicInput>(
  items: T[],
  options: MosaicOptions,
): MosaicRow<T>[] {
  const { containerWidth, targetHeight } = options;
  const gap = options.gap ?? DEFAULTS.gap;
  const minRatio = options.minRatio ?? DEFAULTS.minRatio;
  const maxRatio = options.maxRatio ?? DEFAULTS.maxRatio;

  if (containerWidth <= 0 || targetHeight <= 0 || items.length === 0) return [];

  const normalized = items.map((item) => ({
    ...item,
    ratio: usableRatio(item.ratio, minRatio, maxRatio),
  }));

  const rows: MosaicRow<T>[] = [];
  let current: T[] = [];
  let ratioSum = 0;

  for (const item of normalized) {
    current.push(item);
    ratioSum += item.ratio;

    // Width left for artwork once this row's gaps are paid for.
    const available = containerWidth - gap * (current.length - 1);
    const height = available / ratioSum;

    if (height <= targetHeight) {
      rows.push(buildRow(current, height, gap, containerWidth));
      current = [];
      ratioSum = 0;
    }
  }

  if (current.length > 0) {
    const available = containerWidth - gap * (current.length - 1);
    const natural = available / ratioSum;
    rows.push(buildRow(current, Math.min(natural, targetHeight), gap, null));
  }

  return rows;
}

/** Row height bounds — a phone must still fit two tiles, a desktop must not
 *  turn each library into a billboard. */
const MIN_TARGET_HEIGHT = 96;
const MAX_TARGET_HEIGHT = 190;
/** Roughly this many tiles per row, before ratios pull the count around. */
const TILES_PER_ROW = 4;
const MIN_TILE_WIDTH = 150;
const MAX_TILE_WIDTH = 300;
/** The width/height a "typical" tile is sized against. */
const NOMINAL_RATIO = 1.6;

/**
 * A row height that suits the container it is drawn in: tall enough on a desktop
 * to be worth looking at, short enough on a phone that two tiles still fit side
 * by side. Callers may override it; this is what the grid picks unasked.
 */
export function mosaicTargetHeight(containerWidth: number): number {
  if (containerWidth <= 0) return MIN_TARGET_HEIGHT;
  const tileWidth = clamp(containerWidth / TILES_PER_ROW, MIN_TILE_WIDTH, MAX_TILE_WIDTH);
  return Math.round(clamp(tileWidth / NOMINAL_RATIO, MIN_TARGET_HEIGHT, MAX_TARGET_HEIGHT));
}

/**
 * Lay tiles out as a single fixed-height row — the shape a horizontally
 * scrolling strip wants. Same principle as `layoutMosaic`: one height, natural
 * widths, no distortion.
 */
export function layoutMosaicStrip<T extends MosaicInput>(
  items: T[],
  height: number,
  options: Pick<MosaicOptions, "minRatio" | "maxRatio"> = {},
): MosaicTile<T>[] {
  const minRatio = options.minRatio ?? DEFAULTS.minRatio;
  const maxRatio = options.maxRatio ?? DEFAULTS.maxRatio;
  if (height <= 0) return [];

  const h = Math.max(1, Math.round(height));
  return items.map((item) => {
    const ratio = usableRatio(item.ratio, minRatio, maxRatio);
    return { ...item, ratio, height: h, width: Math.max(1, Math.round(ratio * h)) };
  });
}
