/**
 * lineLayout — 자유배치 좌표에서 variable line grid 를 계산한다.
 * ============================================================================
 *
 * 설계: notes/gbpark/2026-04-20-line-based-layout.md
 *
 * 원칙
 *   - 블록의 xPct/yPct/wPct/hPct 만 사용. role/bandId 로 재조립하지 않는다.
 *   - 가로/세로 경계선에서 xLines/yLines 를 추출 → 인접 선 간 거리 비율을
 *     그대로 CSS Grid track weight 로 사용.
 *   - 각 블록은 1-based colStart/colEnd, rowStart/rowEnd span 으로 렌더.
 *   - overlay 는 예외 경로(forced, snap-error, invalid-span, overlap≥15%).
 *   - 편집기 / 미리보기 / 런타임이 이 함수 하나를 공유한다.
 *
 * 이 모듈은 순수함수다. DOM/React 의존성 없음.
 * ============================================================================
 */

// ─────────────────────────────────────────────────────────────────────────────
// 상수
// ─────────────────────────────────────────────────────────────────────────────

export const LAYOUT_CONSTANTS = {
  /** x 축 선 병합 기본 허용 오차 (캔버스 폭 대비 비율, 0~1) */
  BASE_X_TOLERANCE: 0.004,
  /** y 축 선 병합 기본 허용 오차 */
  BASE_Y_TOLERANCE: 0.004,
  /** 과분할 재병합 상한 */
  MAX_TOLERANCE: 0.02,
  /** 재시도마다 tolerance 에 곱할 성장률 */
  TOLERANCE_GROWTH: 1.35,
  /** 전체 폭/높이 대비 이 비율 미만 트랙은 인접 트랙과 병합 */
  THIN_TRACK_THRESHOLD: 0.0025,
  /** 작은 블록 면적 대비 이 비율 이상 겹치면 overlay 로 강등 */
  OVERLAP_THRESHOLD: 0.15,
  /** snap 오차 > tolerance * 이 값 이면 overlay */
  SNAP_ERROR_MULT: 2,
  /** 같은 윗선(row band)으로 볼 bottom 차이 허용치 */
  ROW_BAND_TOLERANCE: 0.012,
  /** row band 자동 정렬 대상으로 보는 최대 높이 비율 */
  ROW_LIKE_MAX_HEIGHT: 0.12,
} as const;

// ─────────────────────────────────────────────────────────────────────────────
// 입출력 타입
// ─────────────────────────────────────────────────────────────────────────────

export interface LayoutBlockInput {
  id: string;
  componentId?: string;
  xPct: number;
  yPct: number;
  wPct: number;
  hPct: number;
  /** 좌표 무관하게 absolute 로 강제 렌더 (BlockV2.role==='overlay' 등) */
  forceOverlay?: boolean;
  /** z-order hint. 생략 시 입력 배열 순서 */
  z?: number;
}

export interface LayoutOptions {
  baseXTolerance?: number;
  baseYTolerance?: number;
  maxTolerance?: number;
  toleranceGrowth?: number;
  thinTrackThreshold?: number;
  overlapThreshold?: number;
  snapErrorMult?: number;
  /** 세로 축 사이징 — 'fr' 는 비율 grid, 'auto' 는 content-size */
  rowSizing?: 'fr' | 'auto';
  /** 한 줄 control 은 세로 span 을 1행으로 축소할 componentId 목록 */
  intrinsicRowSpanIds?: string[];
}

export interface GridTrack {
  index: number;
  startPct: number;
  endPct: number;
  /** 0~1 — 전체 대비 폭 비율이자 fr weight */
  weight: number;
}

export type OverlayReason = 'forced' | 'overlap' | 'invalid-span' | 'snap-error';

export interface BlockLayout {
  blockId: string;
  mode: 'grid' | 'overlay';
  // grid 모드
  colStart?: number;
  colEnd?: number;
  rowStart?: number;
  rowEnd?: number;
  // overlay 모드 (원본 좌표 그대로 복제)
  leftPct?: number;
  topPct?: number;
  wPct?: number;
  hPct?: number;
  // 디버그
  snapError?: number;
  overlayReason?: OverlayReason;
}

export interface LayoutResult {
  xLines: number[];
  yLines: number[];
  cols: GridTrack[];
  rows: GridTrack[];
  /** 입력 순서 보존 */
  blocks: BlockLayout[];
  xToleranceUsed: number;
  yToleranceUsed: number;
}

// ─────────────────────────────────────────────────────────────────────────────
// 선 추출 / 병합
// ─────────────────────────────────────────────────────────────────────────────

/**
 * 경계값 배열을 tolerance 로 클러스터링 해 정렬된 선 배열을 반환한다.
 * 결과는 항상 `[0, ..., 1]` 형태 — 양쪽 경계 강제 포함.
 *
 * tolerance 이내의 클러스터는 평균값으로 합쳐지며, 0/1 과 가까운 클러스터
 * (<= tolerance 또는 >= 1 - tolerance) 는 0/1 로 흡수된다.
 */
export function extractLines(values: number[], tolerance: number): number[] {
  const sorted = values
    .filter((v) => Number.isFinite(v) && v >= 0 && v <= 1)
    .slice()
    .sort((a, b) => a - b);

  const clusters: number[] = [];
  let bucket: number[] = [];
  for (const v of sorted) {
    if (bucket.length === 0 || v - bucket[bucket.length - 1] <= tolerance) {
      bucket.push(v);
    } else {
      clusters.push(avg(bucket));
      bucket = [v];
    }
  }
  if (bucket.length) clusters.push(avg(bucket));

  const lines: number[] = [0];
  for (const c of clusters) {
    if (c <= tolerance) continue;          // 0 에 흡수
    if (c >= 1 - tolerance) continue;      // 1 에 흡수 (아래 push)
    lines.push(c);
  }
  lines.push(1);
  return lines;
}

/**
 * 전체 폭/높이 대비 `threshold` 미만의 얇은 트랙을 인접 트랙과 병합한다.
 * 첫 선(0) 과 마지막 선(1) 은 절대 제거하지 않는다.
 */
export function mergeThinTracks(lines: number[], threshold: number): number[] {
  if (lines.length <= 2) return [0, 1];
  const cur = lines.slice();

  let changed = true;
  let guard = 0;
  while (changed && cur.length > 2 && guard++ < 64) {
    changed = false;
    for (let i = 0; i < cur.length - 1; i++) {
      const gap = cur[i + 1] - cur[i];
      if (gap >= threshold) continue;

      if (i === 0) {
        // 첫 트랙이 얇음 → 두 번째 선(idx 1) 제거, 0 은 유지
        cur.splice(1, 1);
      } else if (i === cur.length - 2) {
        // 마지막 트랙이 얇음 → 뒤에서 두 번째 선 제거, 1 은 유지
        cur.splice(cur.length - 2, 1);
      } else {
        // 내부: 인접 두 선을 평균으로 합치고 하나 제거
        cur[i] = (cur[i] + cur[i + 1]) / 2;
        cur.splice(i + 1, 1);
      }
      changed = true;
      break;
    }
  }

  cur[0] = 0;
  cur[cur.length - 1] = 1;
  return cur;
}

/**
 * 선 개수 상한(`blockCount*2 + 2`) 을 넘지 않을 때까지 tolerance 를
 * `growth` 배씩 키우며 재추출한다. 각 반복 결과에 thin-track merge 도 적용.
 */
export function fitLines(
  values: number[],
  blockCount: number,
  baseTol: number,
  maxTol: number,
  growth: number,
  thinThreshold: number,
): { lines: number[]; tolerance: number } {
  const target = Math.max(4, blockCount * 2 + 2);
  let tol = baseTol;
  let lines = mergeThinTracks(extractLines(values, tol), thinThreshold);
  let guard = 0;
  while (lines.length > target && tol < maxTol && guard++ < 32) {
    tol = Math.min(maxTol, tol * growth);
    lines = mergeThinTracks(extractLines(values, tol), thinThreshold);
  }
  return { lines, tolerance: tol };
}

// ─────────────────────────────────────────────────────────────────────────────
// span 매핑 헬퍼
// ─────────────────────────────────────────────────────────────────────────────

/** 선 배열 중 value 에 가장 가까운 선의 인덱스 + 오차. */
export function nearestLine(value: number, lines: number[]): { idx: number; err: number } {
  let best = 0;
  let bestErr = Infinity;
  for (let i = 0; i < lines.length; i++) {
    const e = Math.abs(lines[i] - value);
    if (e < bestErr) {
      best = i;
      bestErr = e;
    }
  }
  return { idx: best, err: bestErr };
}

// ─────────────────────────────────────────────────────────────────────────────
// 트랙 / 템플릿 생성
// ─────────────────────────────────────────────────────────────────────────────

/** 선 배열에서 인접 간격별 트랙 객체 배열 생성. weight = 폭 비율. */
export function toTracks(lines: number[]): GridTrack[] {
  const out: GridTrack[] = [];
  for (let i = 0; i < lines.length - 1; i++) {
    const start = lines[i];
    const end = lines[i + 1];
    out.push({
      index: i,
      startPct: start,
      endPct: end,
      weight: Math.max(0.0001, end - start),
    });
  }
  return out;
}

/**
 * grid-template-columns/rows 문자열 생성.
 * - 'fr'   → `minmax(0, <weight>fr)` — 카드 폭이 줄어도 0 까지 축소 허용
 * - 'auto' → `auto auto ...` — content 세로 확장 허용
 */
export function toTrackTemplate(tracks: GridTrack[], mode: 'fr' | 'auto'): string {
  if (mode === 'auto') return tracks.map(() => 'auto').join(' ');
  return tracks.map((t) => `minmax(0, ${formatFr(t.weight)}fr)`).join(' ');
}

// ─────────────────────────────────────────────────────────────────────────────
// overlay 헬퍼
// ─────────────────────────────────────────────────────────────────────────────

function overlapRatio(a: LayoutBlockInput, b: LayoutBlockInput): number {
  const xOv = Math.max(
    0,
    Math.min(a.xPct + a.wPct, b.xPct + b.wPct) - Math.max(a.xPct, b.xPct),
  );
  const yOv = Math.max(
    0,
    Math.min(a.yPct + a.hPct, b.yPct + b.hPct) - Math.max(a.yPct, b.yPct),
  );
  const inter = xOv * yOv;
  const areaA = Math.max(0, a.wPct) * Math.max(0, a.hPct);
  const areaB = Math.max(0, b.wPct) * Math.max(0, b.hPct);
  const minArea = Math.max(1e-9, Math.min(areaA, areaB));
  return inter / minArea;
}

function makeOverlay(
  b: LayoutBlockInput,
  snapError: number,
  reason: OverlayReason,
): BlockLayout {
  return {
    blockId: b.id,
    mode: 'overlay',
    leftPct: b.xPct,
    topPct: b.yPct,
    wPct: b.wPct,
    hPct: b.hPct,
    snapError,
    overlayReason: reason,
  };
}

function demoteToOverlay(layout: BlockLayout, src: LayoutBlockInput, reason: OverlayReason) {
  layout.mode = 'overlay';
  layout.overlayReason = reason;
  layout.leftPct = src.xPct;
  layout.topPct = src.yPct;
  layout.wPct = src.wPct;
  layout.hPct = src.hPct;
  delete layout.colStart;
  delete layout.colEnd;
  delete layout.rowStart;
  delete layout.rowEnd;
}

// ─────────────────────────────────────────────────────────────────────────────
// 메인
// ─────────────────────────────────────────────────────────────────────────────

/**
 * 블록 배치로부터 variable line grid 를 계산한다.
 *
 * 반환된 `blocks` 는 입력 순서를 그대로 유지한다 (overlay 포함).
 */
export function computeLayout(
  blocks: LayoutBlockInput[],
  opts: LayoutOptions = {},
): LayoutResult {
  const baseXTol = opts.baseXTolerance ?? LAYOUT_CONSTANTS.BASE_X_TOLERANCE;
  const baseYTol = opts.baseYTolerance ?? LAYOUT_CONSTANTS.BASE_Y_TOLERANCE;
  const maxTol = opts.maxTolerance ?? LAYOUT_CONSTANTS.MAX_TOLERANCE;
  const growth = opts.toleranceGrowth ?? LAYOUT_CONSTANTS.TOLERANCE_GROWTH;
  const thinTh = opts.thinTrackThreshold ?? LAYOUT_CONSTANTS.THIN_TRACK_THRESHOLD;
  const overlapTh = opts.overlapThreshold ?? LAYOUT_CONSTANTS.OVERLAP_THRESHOLD;
  const snapMult = opts.snapErrorMult ?? LAYOUT_CONSTANTS.SNAP_ERROR_MULT;
  const intrinsicRowSpanIds = new Set(
    opts.intrinsicRowSpanIds ?? ['search', 'button', 'button-bar', 'pagination'],
  );
  const normalizedBlocks = normalizeRowBands(blocks);

  // 0. 빈 입력 처리
  if (!Array.isArray(normalizedBlocks) || normalizedBlocks.length === 0) {
    return {
      xLines: [0, 1],
      yLines: [0, 1],
      cols: [{ index: 0, startPct: 0, endPct: 1, weight: 1 }],
      rows: [{ index: 0, startPct: 0, endPct: 1, weight: 1 }],
      blocks: [],
      xToleranceUsed: baseXTol,
      yToleranceUsed: baseYTol,
    };
  }

  // 1. forced overlay 분리 (grid 선 추출에서 제외)
  const candidates: LayoutBlockInput[] = [];
  const forced: LayoutBlockInput[] = [];
  for (const b of normalizedBlocks) {
    if (b.forceOverlay) forced.push(b);
    else candidates.push(b);
  }

  // 2. 경계값 수집
  const xs: number[] = [];
  const ys: number[] = [];
  for (const b of candidates) {
    xs.push(b.xPct, b.xPct + b.wPct);
    ys.push(b.yPct, b.yPct + b.hPct);
  }

  // 3. x / y 선 추출 (각자 tolerance)
  const fx = fitLines(xs, candidates.length, baseXTol, maxTol, growth, thinTh);
  const fy = fitLines(ys, candidates.length, baseYTol, maxTol, growth, thinTh);
  const xLines = fx.lines;
  const yLines = fy.lines;
  const tolX = fx.tolerance;
  const tolY = fy.tolerance;
  const effTol = Math.max(tolX, tolY);

  const cols = toTracks(xLines);
  const rows = toTracks(yLines);

  // 4. 입력 순서를 그대로 유지하며 span 매핑
  //    grid 확정된 블록은 gridOk 에 따로 쌓아두고, overlap 판정에 사용.
  const byId = new Map<string, BlockLayout>();
  const ordered: BlockLayout[] = new Array(normalizedBlocks.length);
  const gridOk: { input: LayoutBlockInput; layout: BlockLayout; inputIdx: number }[] = [];

  for (let i = 0; i < normalizedBlocks.length; i++) {
    const b = normalizedBlocks[i];

    if (b.forceOverlay) {
      const layout = makeOverlay(b, 0, 'forced');
      ordered[i] = layout;
      byId.set(b.id, layout);
      continue;
    }

    const l = nearestLine(b.xPct, xLines);
    const r = nearestLine(b.xPct + b.wPct, xLines);
    const t = nearestLine(b.yPct, yLines);
    const bt = nearestLine(b.yPct + b.hPct, yLines);
    const snapError = Math.max(l.err, r.err, t.err, bt.err);

    if (snapError > effTol * snapMult) {
      const layout = makeOverlay(b, snapError, 'snap-error');
      ordered[i] = layout;
      byId.set(b.id, layout);
      continue;
    }

    const colStart = l.idx + 1;
    const colEnd = r.idx + 1;
    let rowStart = t.idx + 1;
    let rowEnd = bt.idx + 1;

    // 한 줄 control 은 저장된 큰 bounding box 때문에 여러 row 를 점유해도,
    // 실제 렌더는 한 줄 높이로 보이는 경우가 많다. 이런 컴포넌트는 centerY
    // 기준 단일 row span 으로 축소해 중간 공백 row 가 커지는 문제를 막는다.
    const componentId = b.componentId;
    if (componentId && intrinsicRowSpanIds.has(componentId) && rowEnd > rowStart + 1) {
      const centerY = b.yPct + b.hPct / 2;
      let centerIdx = nearestLine(centerY, yLines).idx;
      centerIdx = Math.max(0, Math.min(centerIdx, yLines.length - 2));
      rowStart = centerIdx + 1;
      rowEnd = rowStart + 1;
    }

    if (colEnd <= colStart || rowEnd <= rowStart) {
      const layout = makeOverlay(b, snapError, 'invalid-span');
      ordered[i] = layout;
      byId.set(b.id, layout);
      continue;
    }

    const layout: BlockLayout = {
      blockId: b.id,
      mode: 'grid',
      colStart,
      colEnd,
      rowStart,
      rowEnd,
      snapError,
    };
    ordered[i] = layout;
    byId.set(b.id, layout);
    gridOk.push({ input: b, layout, inputIdx: i });
  }

  // 5. overlap 판정 — grid 확정된 블록 쌍 중 overlap >= 임계면, z 또는 입력
  //    순서상 뒤에 오는 블록을 overlay 로 강등.
  for (let i = 0; i < gridOk.length; i++) {
    for (let j = i + 1; j < gridOk.length; j++) {
      const A = gridOk[i];
      const B = gridOk[j];
      if (A.layout.mode !== 'grid' || B.layout.mode !== 'grid') continue;
      if (overlapRatio(A.input, B.input) < overlapTh) continue;

      // 강등 대상 선정: z 큰쪽 우선, 같으면 입력 순서상 뒤(=inputIdx 큰쪽)
      const zA = A.input.z ?? A.inputIdx;
      const zB = B.input.z ?? B.inputIdx;
      const demote = zA === zB ? (A.inputIdx > B.inputIdx ? A : B) : zA > zB ? A : B;

      demoteToOverlay(demote.layout, demote.input, 'overlap');
    }
  }

  return {
    xLines,
    yLines,
    cols,
    rows,
    blocks: ordered,
    xToleranceUsed: tolX,
    yToleranceUsed: tolY,
  };
}

function normalizeRowBands(blocks: LayoutBlockInput[]): LayoutBlockInput[] {
  const tol = LAYOUT_CONSTANTS.ROW_BAND_TOLERANCE;
  const maxHeight = LAYOUT_CONSTANTS.ROW_LIKE_MAX_HEIGHT;
  const cloned = blocks.map((b) => ({ ...b }));
  const rowLikes = cloned
    .filter((b) => !b.forceOverlay && b.hPct <= maxHeight)
    .sort((a, b) => a.yPct - b.yPct || a.xPct - b.xPct);

  let i = 0;
  while (i < rowLikes.length) {
    const seedTop = rowLikes[i].yPct;
    const band: LayoutBlockInput[] = [rowLikes[i]];
    let j = i + 1;
    while (j < rowLikes.length && Math.abs(rowLikes[j].yPct - seedTop) <= tol) {
      band.push(rowLikes[j]);
      j++;
    }

    if (band.length >= 2) {
      const bottoms = band.map((b) => b.yPct + b.hPct);
      const minBottom = Math.min(...bottoms);
      const maxBottom = Math.max(...bottoms);

      // 같은 윗선에서 시작한 작은 높이 블록은 bottom 차이가 조금 나도
      // 하나의 row band 로 본다. 더 긴 블록 쪽으로 맞춰 새 가로선 분리를 막는다.
      if (maxBottom - minBottom <= tol) {
        for (const block of band) {
          block.hPct = Math.max(0, maxBottom - block.yPct);
        }
      }
    }

    i = j;
  }

  return cloned;
}

// ─────────────────────────────────────────────────────────────────────────────
// 내부 유틸
// ─────────────────────────────────────────────────────────────────────────────

function avg(arr: number[]): number {
  let sum = 0;
  for (const v of arr) sum += v;
  return sum / arr.length;
}

function round3(v: number): number {
  return Math.round(v * 1000) / 1000;
}

// ─────────────────────────────────────────────────────────────────────────────
// Row classification + fit-to-height allocator (Step 2.7, 2026-04-20)
//
// preferred row heights 를 산출하고, 사용 가능한 container height 에 정확히
// 맞도록 재분배한다. outer scroll 생성 금지 — row 가 바깥 스크롤을 유발하지
// 않도록, 공간이 부족하면 elastic → semi-rigid → rigid 순으로 압축한다.
// rigid 는 floor 까지만 줄이고, 그래도 부족하면 그대로 clip (wrapper overflow
// hidden 이 외부로의 노출을 차단).
// ─────────────────────────────────────────────────────────────────────────────

export type RowType = 'gap' | 'rigid' | 'semi-rigid' | 'elastic';

export interface RowClassifyBlockInfo {
  id: string;
  hPct: number;
  role?: string;
  componentId?: string;
}

export interface RowClassifyOptions {
  /** rigid 판정 role (기본 ['action']) */
  rigidRoles?: string[];
  /** rigid 판정 componentId (기본 ['button','button-bar','pagination']) */
  rigidComponentIds?: string[];
  /** elastic 판정 componentId (기본 table/container/form/tabs/split-panel/accordion) */
  elasticComponentIds?: string[];
  /** floor (px) */
  rigidFloor?: number;   // default 40
  semiFloor?: number;    // default 56
  elasticFloor?: number; // default 80
  /** gap row 의 floor (px). 기본 0 (거의 0 까지 줄일 수 있다). */
  gapFloor?: number;
  /** gap row 의 preferred 상한 (px). 큰 공백 행이 카드 내부를 과도하게 먹지 않게 한다. */
  gapPreferredCap?: number;
  /** 단일 제어 컴포넌트의 preferred share 상한 (px) */
  intrinsicPreferredCaps?: Partial<Record<string, number>>;
}

export interface RowPlan {
  type: RowType;
  /** design 기준 권장 px (max share within row, floor 적용) */
  preferredPx: number;
  /** 해당 type 의 최소 허용 px */
  floorPx: number;
  /** 이 row 에 걸친 grid 블록의 수 (debug/디버그용). gap row 는 0. */
  occupiedBlockCount: number;
}

/**
 * 각 row 의 type 과 preferred/floor px 를 산출한다.
 * row 에 걸친 블록들의 가장 elastic 한 성향을 row type 으로 승격 (elastic >
 * semi-rigid > rigid). preferred = rowSpan 분배 후 blocks max share, floor 적용.
 */
export function classifyRows(
  layout: Pick<LayoutResult, 'blocks' | 'rows'>,
  blocks: RowClassifyBlockInfo[],
  baseHeight: number,
  opts: RowClassifyOptions = {},
): RowPlan[] {
  const rowCount = layout.rows.length;
  if (rowCount === 0) return [];

  const rigidRoles = new Set(opts.rigidRoles ?? ['action']);
  const rigidIds = new Set(
    opts.rigidComponentIds ?? ['button', 'button-bar', 'pagination'],
  );
  const elasticIds = new Set(
    opts.elasticComponentIds ?? [
      'table',
      'container',
      'form',
      'tabs',
      'split-panel',
      'accordion',
    ],
  );
  const rigidFloor = opts.rigidFloor ?? 40;
  const semiFloor = opts.semiFloor ?? 56;
  const elasticFloor = opts.elasticFloor ?? 80;
  const gapFloor = opts.gapFloor ?? 0;
  const gapPreferredCap = opts.gapPreferredCap ?? 24;
  const intrinsicPreferredCaps = {
    search: 44,
    button: 40,
    'button-bar': 40,
    pagination: 40,
    ...(opts.intrinsicPreferredCaps ?? {}),
  };

  const byId = new Map<string, RowClassifyBlockInfo>();
  for (const b of blocks) byId.set(b.id, b);

  // block kind 는 elastic / rigid / semi-rigid 중 하나 (gap 은 block 이 없는
  // row 에만 해당). 그래서 kinds Set 에는 gap 이 들어가지 않는다.
  type BlockKind = Exclude<RowType, 'gap'>;
  const rowInfo: {
    maxShare: number;
    kinds: Set<BlockKind>;
    occupiedCount: number;
  }[] = [];
  for (let i = 0; i < rowCount; i++) {
    rowInfo.push({ maxShare: 0, kinds: new Set<BlockKind>(), occupiedCount: 0 });
  }

  for (const bl of layout.blocks) {
    if (bl.mode === 'overlay') continue;
    const b = byId.get(bl.blockId);
    if (!b) continue;
    const rs = (bl.rowStart ?? 1) - 1;
    const re = (bl.rowEnd ?? 1) - 1;
    const span = re - rs;
    if (span <= 0) continue;
    const designH = Math.max(0, b.hPct * baseHeight);
    const intrinsicCap =
      b.componentId != null ? intrinsicPreferredCaps[b.componentId] : undefined;
    const share =
      intrinsicCap != null
        ? Math.min(designH / span, intrinsicCap)
        : designH / span;

    let kind: BlockKind;
    if (b.componentId && elasticIds.has(b.componentId)) kind = 'elastic';
    else if (
      (b.role && rigidRoles.has(b.role)) ||
      (b.componentId && rigidIds.has(b.componentId))
    )
      kind = 'rigid';
    else kind = 'semi-rigid';

    for (let i = rs; i < re; i++) {
      if (i < 0 || i >= rowCount) continue;
      if (share > rowInfo[i].maxShare) rowInfo[i].maxShare = share;
      rowInfo[i].kinds.add(kind);
      rowInfo[i].occupiedCount++;
    }
  }

  const plans: RowPlan[] = [];
  for (let i = 0; i < rowCount; i++) {
    const { maxShare, kinds, occupiedCount } = rowInfo[i];
    let type: RowType;
    if (occupiedCount === 0 || kinds.size === 0) type = 'gap';
    else if (kinds.has('elastic')) type = 'elastic';
    else if (kinds.has('semi-rigid')) type = 'semi-rigid';
    else type = 'rigid';

    let floorPx: number;
    let preferredPx: number;
    if (type === 'gap') {
      floorPx = gapFloor;
      // gap 의 preferred 는 디자인 원본 yLines 간격 (= weight * baseHeight).
      // 블록이 없으니 maxShare 는 0 이다.
      const designH = Math.max(0, layout.rows[i].weight * baseHeight);
      preferredPx = Math.max(
        floorPx,
        Math.min(Math.round(designH), gapPreferredCap),
      );
    } else {
      floorPx =
        type === 'rigid'
          ? rigidFloor
          : type === 'semi-rigid'
            ? semiFloor
            : elasticFloor;
      preferredPx = Math.max(floorPx, Math.round(maxShare));
    }
    plans.push({ type, preferredPx, floorPx, occupiedBlockCount: occupiedCount });
  }
  return plans;
}

/**
 * preferred row heights 를 availableHeight 에 정확히 맞도록 조정.
 *
 *   total === available : 그대로
 *   total <  available : surplus 를 elastic 우선 비율 분배 (없으면 semi-rigid,
 *                        그것도 없으면 rigid)
 *   total >  available : deficit 를 elastic 먼저 floor 까지 압축 → semi-rigid
 *                        → rigid 순으로. floor 합이 available 을 넘으면 그대로
 *                        반환 (wrapper hidden 이 clip).
 *
 * 반환 배열 합은 가능하면 availableHeight 와 일치 (부동소수 오차는 마지막
 * row 에 반영).
 */
export function allocateRowHeightsPx(
  plans: RowPlan[],
  availableHeight: number,
): number[] {
  const n = plans.length;
  if (n === 0) return [];
  if (!Number.isFinite(availableHeight) || availableHeight <= 0) {
    return plans.map((p) => p.preferredPx);
  }

  const final = plans.map((p) => p.preferredPx);
  const total = final.reduce((s, v) => s + v, 0);

  if (Math.abs(total - availableHeight) < 0.5) {
    return final.map((v) => round3(v));
  }

  const idxByType = (t: RowType) =>
    plans.map((p, i) => (p.type === t ? i : -1)).filter((i) => i >= 0);

  /** gap 은 preferredPx 의 110% 또는 +8px (큰 쪽) 까지만 늘어날 수 있다. */
  const capOf = (i: number): number => {
    const p = plans[i];
    if (p.type === 'gap') {
      return Math.max(p.preferredPx * 1.1, p.preferredPx + 8);
    }
    return Number.POSITIVE_INFINITY;
  };

  if (total < availableHeight) {
    let surplus = availableHeight - total;
    /**
     * cap 제약 하에서 비율 분배. cap 에 막힌 row 는 나머지 surplus 를
     * 받지 못하고 남은 몫은 호출자가 다음 type 으로 넘긴다.
     */
    const distribute = (idxs: number[]): void => {
      if (idxs.length === 0 || surplus < 0.5) return;
      let active = idxs.slice();
      let guard = 0;
      while (active.length > 0 && surplus > 0.5 && guard++ < 8) {
        const sumP = active.reduce((s, i) => s + final[i], 0);
        const isUniform = sumP === 0;
        const next: number[] = [];
        let given = 0;
        for (const i of active) {
          const want = isUniform
            ? surplus / active.length
            : (final[i] / sumP) * surplus;
          const headroom = Math.max(0, capOf(i) - final[i]);
          const take = Math.min(want, headroom);
          if (take > 0) {
            final[i] += take;
            given += take;
          }
          if (final[i] + 0.5 < capOf(i)) next.push(i);
        }
        surplus -= given;
        if (next.length === active.length && given < 0.5) break; // 정체 — 탈출
        active = next;
      }
    };
    // 우선순위: elastic → semi-rigid → rigid → gap (gap 은 cap 으로 최소한만)
    distribute(idxByType('elastic'));
    distribute(idxByType('semi-rigid'));
    distribute(idxByType('rigid'));
    distribute(idxByType('gap'));
  } else {
    let deficit = total - availableHeight;
    const floorOf = (i: number) => plans[i].floorPx;
    const compress = (idxs: number[]) => {
      if (deficit < 0.5 || idxs.length === 0) return;
      let reducible = 0;
      for (const i of idxs) reducible += Math.max(0, final[i] - floorOf(i));
      if (reducible <= 0) return;
      const take = Math.min(deficit, reducible);
      const ratio = take / reducible;
      let absorbed = 0;
      for (const i of idxs) {
        const slack = Math.max(0, final[i] - floorOf(i));
        const reduce = slack * ratio;
        final[i] -= reduce;
        absorbed += reduce;
      }
      deficit -= absorbed;
    };
    // 우선순위: gap → elastic → semi-rigid → rigid
    compress(idxByType('gap'));
    compress(idxByType('elastic'));
    compress(idxByType('semi-rigid'));
    compress(idxByType('rigid'));
  }

  const diff = availableHeight - final.reduce((s, v) => s + v, 0);
  if (Math.abs(diff) > 0.5 && final.length > 0) {
    final[final.length - 1] += diff;
  }
  return final.map((v) => Math.max(0, round3(v)));
}

function formatFr(w: number): string {
  // fr 가중치는 0 초과. 너무 작은 값은 0.0001 로 clamp.
  const v = Math.max(0.0001, w);
  // 소수점 6자리로 고정, trailing zero 는 유지 (문자열 안정성).
  return v.toFixed(6);
}
