export interface LayoutRect {
  left: number;
  top: number;
  right: number;
  bottom: number;
  width: number;
  height: number;
}

export type LayoutOverflow = Partial<Record<"left" | "right" | "top" | "bottom", number>>;

export type LayoutIssueCode =
  | "text_box_overflow"
  | "clipped_text"
  | "canvas_overflow"
  | "container_overflow"
  | "content_overlap"
  | "text_occluded"
  // Motion-verification findings (#1437) — evaluated against the seeked timeline.
  | "motion_appears_late"
  | "motion_out_of_order"
  | "motion_off_frame"
  | "motion_frozen"
  | "motion_selector_missing"
  | "motion_selector_ambiguous";

export type LayoutIssueSeverity = "error" | "warning" | "info";

export interface LayoutIssue {
  code: LayoutIssueCode;
  severity: LayoutIssueSeverity;
  time: number;
  firstSeen?: number;
  lastSeen?: number;
  occurrences?: number;
  selector: string;
  containerSelector?: string;
  text?: string;
  message: string;
  rect: LayoutRect;
  containerRect?: LayoutRect;
  overflow?: LayoutOverflow;
  fixHint?: string;
}

export interface LayoutSummary {
  ok: boolean;
  errorCount: number;
  warningCount: number;
  infoCount: number;
  issueCount: number;
}

export interface LayoutSampleOptions {
  duration: number;
  samples: number;
  at?: number[];
}

export function buildLayoutSampleTimes({ duration, samples, at }: LayoutSampleOptions): number[] {
  if (at?.length) {
    return uniqueSortedTimes(
      at.filter(
        (time) => Number.isFinite(time) && time >= 0 && (duration <= 0 || time <= duration),
      ),
    );
  }

  if (!Number.isFinite(duration) || duration <= 0 || samples <= 0) return [];

  const count = Math.max(1, Math.floor(samples));
  return Array.from({ length: count }, (_, index) => roundTime(((index + 0.5) / count) * duration));
}

export function computeOverflow(
  subject: LayoutRect,
  container: LayoutRect,
  tolerance: number,
): LayoutOverflow | null {
  const overflow: LayoutOverflow = {};

  if (subject.left < container.left - tolerance) {
    overflow.left = roundPx(container.left - subject.left);
  }
  if (subject.right > container.right + tolerance) {
    overflow.right = roundPx(subject.right - container.right);
  }
  if (subject.top < container.top - tolerance) {
    overflow.top = roundPx(container.top - subject.top);
  }
  if (subject.bottom > container.bottom + tolerance) {
    overflow.bottom = roundPx(subject.bottom - container.bottom);
  }

  return Object.keys(overflow).length > 0 ? overflow : null;
}

/**
 * Whether a computed `overflow*` value clips its box. Mirrors the rule the
 * browser audit (layout-audit.browser.js) uses to decide that text spilling
 * past such an ancestor is intentionally masked (odometer/ticker reels) rather
 * than a `text_box_overflow` defect. Kept here as the one unit-testable seam of
 * that suppression: only `visible` (and the `clip visible` no-op) must NOT clip
 * — every clipping value must, or real masked overflow gets reported as a bug.
 */
export function overflowValueClips(value: string | null | undefined): boolean {
  return !!value && value !== "visible" && value !== "clip visible";
}

export function summarizeLayoutIssues(issues: LayoutIssue[]): LayoutSummary {
  const errorCount = issues.filter((issue) => issue.severity === "error").length;
  const warningCount = issues.filter((issue) => issue.severity === "warning").length;
  const infoCount = issues.filter((issue) => issue.severity === "info").length;

  return {
    ok: errorCount === 0,
    errorCount,
    warningCount,
    infoCount,
    issueCount: issues.length,
  };
}

export function formatLayoutIssue(issue: LayoutIssue): string {
  const timeLabel =
    issue.occurrences && issue.occurrences > 1
      ? `t=${formatNumber(issue.firstSeen ?? issue.time)}-${formatNumber(issue.lastSeen ?? issue.time)}s (${issue.occurrences} samples)`
      : `t=${formatNumber(issue.time)}s`;
  const parts = [
    timeLabel,
    issue.code,
    issue.selector,
    issue.containerSelector ? `inside ${issue.containerSelector}` : "",
    issue.overflow ? `overflowed ${formatOverflow(issue.overflow)}` : "",
    issue.text ? quoteText(issue.text) : "",
  ].filter(Boolean);

  const line = `${parts.join(" ")} — ${issue.message}`;
  return issue.fixHint ? `${line}\n    Fix: ${issue.fixHint}` : line;
}

export function dedupeLayoutIssues(issues: LayoutIssue[]): LayoutIssue[] {
  const seen = new Set<string>();
  const result: LayoutIssue[] = [];

  for (const issue of issues) {
    const key = [
      issue.code,
      issue.severity,
      issue.time.toFixed(3),
      issue.selector,
      issue.containerSelector ?? "",
      issue.text ?? "",
      issue.overflow ? formatOverflow(issue.overflow) : "",
    ].join("|");
    if (seen.has(key)) continue;
    seen.add(key);
    result.push(issue);
  }

  return result;
}

export function collapseStaticLayoutIssues(issues: LayoutIssue[]): LayoutIssue[] {
  const groups = new Map<
    string,
    {
      issue: LayoutIssue;
      firstSeen: number;
      lastSeen: number;
      occurrences: number;
    }
  >();

  for (const issue of issues) {
    const key = staticIssueKey(issue);
    const existing = groups.get(key);
    if (!existing) {
      groups.set(key, {
        issue,
        firstSeen: issue.time,
        lastSeen: issue.time,
        occurrences: 1,
      });
      continue;
    }

    existing.firstSeen = Math.min(existing.firstSeen, issue.time);
    existing.lastSeen = Math.max(existing.lastSeen, issue.time);
    existing.occurrences += 1;
  }

  return [...groups.values()].map(({ issue, firstSeen, lastSeen, occurrences }) => ({
    ...issue,
    time: firstSeen,
    firstSeen,
    lastSeen,
    occurrences,
  }));
}

export function limitLayoutIssues(
  issues: LayoutIssue[],
  maxIssues: number,
): { issues: LayoutIssue[]; totalIssueCount: number; truncated: boolean } {
  const limit = Math.max(1, Math.floor(maxIssues));
  const sortedIssues = [...issues].sort((a, b) => {
    const severityDelta = severityRank(a.severity) - severityRank(b.severity);
    if (severityDelta !== 0) return severityDelta;
    return a.time - b.time;
  });
  return {
    issues: sortedIssues.slice(0, limit),
    totalIssueCount: issues.length,
    truncated: issues.length > limit,
  };
}

function severityRank(severity: LayoutIssueSeverity): number {
  if (severity === "error") return 0;
  if (severity === "warning") return 1;
  return 2;
}

function staticIssueKey(issue: LayoutIssue): string {
  return [
    issue.code,
    issue.severity,
    issue.selector,
    issue.containerSelector ?? "",
    issue.text ?? "",
    issue.overflow ? formatOverflow(issue.overflow) : "",
  ].join("|");
}

function uniqueSortedTimes(times: number[]): number[] {
  const rounded = times.map(roundTime);
  return [...new Set(rounded)].sort((a, b) => a - b);
}

export interface TransitionSampleOptions {
  duration: number;
  boundaries: number[];
  /** Optional hard limit on the returned sample count. No limit when absent. */
  cap?: number;
}

export interface TransitionSamples {
  times: number[];
  /** Sample times omitted because of `cap`. Always 0 when no cap is given. */
  dropped: number;
}

/**
 * Build sample times from tween start/end boundaries: the boundaries
 * themselves plus the midpoint of every segment between consecutive
 * boundaries. Boundary frames are where transient overlaps live (#1380), but
 * sampling exactly at a boundary can land on an element at opacity 0 — the
 * segment midpoints catch the window where both sides of a transition are
 * partially visible. Every collected boundary is sampled unless the caller
 * passes an explicit `cap`, in which case the result is an evenly-strided
 * subset and `dropped` reports how many sample times were omitted.
 */
export function buildTransitionSampleTimes({
  duration,
  boundaries,
  cap,
}: TransitionSampleOptions): TransitionSamples {
  if (!Number.isFinite(duration) || duration <= 0) return { times: [], dropped: 0 };
  const inRange = uniqueSortedTimes(
    boundaries.filter((time) => Number.isFinite(time) && time >= 0 && time <= duration),
  );
  const withMidpoints = [...inRange];
  for (let i = 0; i < inRange.length - 1; i++) {
    const current = inRange[i];
    const next = inRange[i + 1];
    if (current === undefined || next === undefined) continue;
    withMidpoints.push(roundTime((current + next) / 2));
  }
  const merged = uniqueSortedTimes(withMidpoints);
  if (cap === undefined || merged.length <= Math.max(2, cap)) {
    return { times: merged, dropped: 0 };
  }
  const limit = Math.max(2, cap);
  const strided: number[] = [];
  for (let i = 0; i < limit; i++) {
    const pick = merged[Math.floor((i * (merged.length - 1)) / (limit - 1))];
    if (pick !== undefined) strided.push(pick);
  }
  const times = uniqueSortedTimes(strided);
  return { times, dropped: merged.length - times.length };
}

/** Merge sample-time lists into one deduplicated ascending list. */
export function mergeSampleTimes(...lists: number[][]): number[] {
  return uniqueSortedTimes(lists.flat());
}

function formatOverflow(overflow: LayoutOverflow): string {
  return (["left", "right", "top", "bottom"] as const)
    .flatMap((side) => {
      const value = overflow[side];
      return value == null ? [] : `${side} ${formatNumber(value)}px`;
    })
    .join(", ");
}

function quoteText(text: string): string {
  const normalized = text.replace(/\s+/g, " ").trim();
  const truncated = normalized.length > 80 ? `${normalized.slice(0, 77)}...` : normalized;
  return `"${truncated}"`;
}

function formatNumber(value: number): string {
  return Number.isInteger(value)
    ? String(value)
    : value.toFixed(2).replace(/0+$/, "").replace(/\.$/, "");
}

function roundTime(value: number): number {
  return Math.round(value * 1000) / 1000;
}

function roundPx(value: number): number {
  return Math.round(value * 100) / 100;
}
