// dashboard-grid — pure layout math for the dashboard widget grid.
//
// Lives outside dashboard.ts so the placement logic is importable from
// tests without dragging in the DOM-side rendering code. The grid is a
// 12-column CSS Grid matching internal/services/dashboard_layout_spec.go;
// rows grow vertically as widgets are placed.
//
// The core invariant is no-overlap: after placeWidgets() returns, every
// pair of widgets occupies disjoint cells. Pre-overhaul callers wrote
// computePlacements() to trust explicit (x, y) without checking — that
// produced visual overlap whenever a drag or resize landed a widget on
// cells another widget already covered (m/paliad#70). The collision-
// aware placer below shifts colliding widgets to the next free row so
// the rendered grid never overlaps regardless of the input spec.

export const GRID_COLUMNS = 12;
export const MAX_ROW_SPAN = 5;

// Hard cap on the row-scan depth in findFreeSlot. The widget cap on a
// single layout is 32 (LayoutWidgetCap on the Go side); each row holds
// at least one widget, so 256 rows is an order-of-magnitude buffer
// against runaway loops on pathological inputs.
const MAX_SCAN_ROWS = 256;

export interface PlacedRect {
  x: number;
  y: number;
  w: number;
  h: number;
}

// WidgetSizeBound captures the per-widget min/max/default clamps the
// catalog publishes. Optional fields keep callers from having to
// synthesize zeroes when the catalog entry is missing.
export interface WidgetSizeBound {
  default_w?: number;
  default_h?: number;
  min_w?: number;
  max_w?: number;
  min_h?: number;
  max_h?: number;
}

// WidgetPlacementInput is the per-widget data the placer consumes. The
// catalog bound is optional — when missing, defaults fall back to a
// full-width 1-row widget.
export interface WidgetPlacementInput {
  key: string;
  visible: boolean;
  x?: number;
  y?: number;
  w?: number;
  h?: number;
  bound?: WidgetSizeBound;
}

export function clampW(w: number, bound: WidgetSizeBound | undefined): number {
  let v = Math.round(w);
  if (!Number.isFinite(v) || v <= 0) v = bound?.default_w ?? GRID_COLUMNS;
  v = Math.max(1, Math.min(GRID_COLUMNS, v));
  if (bound?.min_w && v < bound.min_w) v = bound.min_w;
  if (bound?.max_w && v > bound.max_w) v = bound.max_w;
  return v;
}

export function clampH(h: number, bound: WidgetSizeBound | undefined): number {
  let v = Math.round(h);
  if (!Number.isFinite(v) || v <= 0) v = bound?.default_h ?? 1;
  v = Math.max(1, Math.min(MAX_ROW_SPAN, v));
  if (bound?.min_h && v < bound.min_h) v = bound.min_h;
  if (bound?.max_h && v > bound.max_h) v = bound.max_h;
  return v;
}

// Occupancy bitmap: one row → Uint8Array of GRID_COLUMNS bits. Rows are
// created lazily so the map only stores rows the layout actually
// reaches. Cell value 1 = occupied.
class Occupancy {
  private rows = new Map<number, Uint8Array>();

  row(y: number): Uint8Array {
    let r = this.rows.get(y);
    if (!r) {
      r = new Uint8Array(GRID_COLUMNS);
      this.rows.set(y, r);
    }
    return r;
  }

  free(x: number, y: number, w: number, h: number): boolean {
    if (x < 0 || y < 0 || x + w > GRID_COLUMNS) return false;
    for (let yy = y; yy < y + h; yy++) {
      const row = this.row(yy);
      for (let xx = x; xx < x + w; xx++) {
        if (row[xx]) return false;
      }
    }
    return true;
  }

  mark(x: number, y: number, w: number, h: number): void {
    for (let yy = y; yy < y + h; yy++) {
      const row = this.row(yy);
      for (let xx = x; xx < x + w; xx++) row[xx] = 1;
    }
  }
}

// findFreeSlot scans for the first (x, y) where a w×h block fits without
// collision, starting at row startY. At each row preferX is tried first
// — that keeps a widget close to its requested column when only the row
// is blocked. Falls back to left-to-right scan within the row, then to
// the next row. Caller guarantees w ≤ GRID_COLUMNS.
function findFreeSlot(
  occ: Occupancy,
  startY: number,
  w: number,
  h: number,
  preferX: number,
): { x: number; y: number } {
  for (let y = startY; y < startY + MAX_SCAN_ROWS; y++) {
    if (preferX >= 0 && preferX + w <= GRID_COLUMNS && occ.free(preferX, y, w, h)) {
      return { x: preferX, y };
    }
    for (let x = 0; x + w <= GRID_COLUMNS; x++) {
      if (x === preferX) continue;
      if (occ.free(x, y, w, h)) return { x, y };
    }
  }
  // Pathological fallback — caller's widget cap (32) makes this
  // unreachable in practice. Snap to the bottom-left so the widget at
  // least renders somewhere visible instead of vanishing.
  return { x: 0, y: startY + MAX_SCAN_ROWS };
}

// PlaceOptions tunes the placer for the caller's render-vs-persist
// needs.
export interface PlaceOptions {
  // When true, hidden widgets are placed too — for edit-mode rendering
  // where the user can see + un-hide them inline. The two-pass order
  // (visible first, then hidden) guarantees hidden widgets never
  // displace visible ones: they get whatever cells are left below the
  // active layout. Default false matches view-mode behaviour and the
  // persistence path (materializePositions) where hidden widgets
  // retain their stored coordinates instead of being repacked.
  //
  // Without this option, hidden widgets in edit mode were left without
  // an explicit grid-column inline style by applyLayout(), so CSS Grid
  // auto-flowed them into the next free cell at 1×1 — the "super slim
  // greyed-out column" symptom of m/paliad#73 / t-paliad-238.
  includeHidden?: boolean;
}

// placeWidgets assigns no-overlap grid coordinates to widgets. By
// default only visible widgets receive placements; pass
// {includeHidden:true} to also place hidden widgets after the visible
// pass (used by applyLayout in edit mode).
//
// Algorithm — per pass:
//   1. Clamp w/h against catalog bounds.
//   2. If the spec carries explicit x and y, try that slot. On a
//      collision, search downward starting at the requested y for the
//      first free w×h block (preferring the requested x).
//   3. If only x is explicit, search from y=0 at that x.
//   4. Otherwise auto-flow: pack left-to-right under a running cursor;
//      when the row doesn't fit or is blocked by an explicitly-placed
//      widget, wrap to the next free row.
//
// The mixed-spec case (some widgets explicit, others auto-flow) is the
// real-world layout — placing the explicit widgets first would change
// the visual order, so we keep input order and let auto-flow widgets
// step around any explicit blockers via the same collision search.
//
// Two-pass behaviour for hidden widgets: the visible pass owns its
// own auto-flow cursor; the hidden pass continues from where the
// visible pass left off so the hidden widgets stack right under the
// active layout. The shared Occupancy bitmap guarantees the second
// pass can never overlap a placed visible widget.
export function placeWidgets(
  widgets: WidgetPlacementInput[],
  options: PlaceOptions = {},
): Map<string, PlacedRect> {
  const out = new Map<string, PlacedRect>();
  const occ = new Occupancy();

  // Auto-flow cursor — advances as we place flowed widgets. cursorY
  // tracks the row currently being filled; rowMaxH is the tallest
  // widget in that row so wrapping advances past it (not just past the
  // new widget's height — that would let taller previous neighbours
  // overlap into the wrap row).
  let cursorX = 0;
  let cursorY = 0;
  let rowMaxH = 0;

  const placeOne = (w: WidgetPlacementInput): void => {
    const dw = clampW(w.w ?? w.bound?.default_w ?? GRID_COLUMNS, w.bound);
    const dh = clampH(w.h ?? w.bound?.default_h ?? 1, w.bound);

    const hasX = typeof w.x === "number";
    const hasY = typeof w.y === "number";
    let placed: { x: number; y: number };

    if (hasX && hasY) {
      // Clamp x so the widget never overflows the right edge — drag/
      // resize gestures can produce x+w > GRID_COLUMNS otherwise.
      const prefX = Math.max(0, Math.min(GRID_COLUMNS - dw, w.x as number));
      const prefY = Math.max(0, w.y as number);
      if (occ.free(prefX, prefY, dw, dh)) {
        placed = { x: prefX, y: prefY };
      } else {
        placed = findFreeSlot(occ, prefY, dw, dh, prefX);
      }
    } else if (hasX) {
      const prefX = Math.max(0, Math.min(GRID_COLUMNS - dw, w.x as number));
      placed = findFreeSlot(occ, 0, dw, dh, prefX);
    } else {
      // Auto-flow. Wrap the cursor when the widget wouldn't fit in the
      // remaining columns of the current row, then ask findFreeSlot to
      // honour the cursor's preferred (x, y) — that lets it step past
      // any explicit widget that already claimed cells under the
      // cursor.
      if (cursorX + dw > GRID_COLUMNS) {
        cursorY += rowMaxH || 1;
        cursorX = 0;
        rowMaxH = 0;
      }
      placed = findFreeSlot(occ, cursorY, dw, dh, cursorX);
      if (placed.y > cursorY) {
        // Wrap was forced by a collision deeper than the current row.
        cursorY = placed.y;
        rowMaxH = 0;
      }
      cursorX = placed.x + dw;
      if (dh > rowMaxH) rowMaxH = dh;
    }

    occ.mark(placed.x, placed.y, dw, dh);
    out.set(w.key, { x: placed.x, y: placed.y, w: dw, h: dh });
  };

  // Pass 1: visible widgets. They own the active layout.
  for (const w of widgets) {
    if (!w.visible) continue;
    placeOne(w);
  }

  // Pass 2: hidden widgets (edit-mode only). Wrap the cursor to the
  // start of the next row before the second pass so the hidden tray
  // visually separates from the active layout — even if the last
  // visible widget left half a row open.
  if (options.includeHidden) {
    if (cursorX > 0) {
      cursorY += rowMaxH || 1;
      cursorX = 0;
      rowMaxH = 0;
    }
    for (const w of widgets) {
      if (w.visible) continue;
      placeOne(w);
    }
  }

  return out;
}
