#!/usr/bin/env node
/**
 * Scroll region inventory — annotation-free scroll existence detection.
 *
 * `contract introspect` only sees scrollports that carry a
 * `data-scrollport` attribute. This tool walks every element's computed
 * overflow and scroll metrics and reports what *actually* scrolls (or
 * clips) on the page, with no markup cooperation required:
 *
 *   - real scroll containers: selector, axis, overflow amount, bbox —
 *     JSON output includes an `expectedScrollports` block that pastes
 *     straight into a UI Contract
 *   - unintended page-level horizontal scroll (the classic mobile
 *     regression), with the offending elements that stick out
 *   - `overflow: hidden` elements whose content overflows well past the
 *     box — cut-off-content suspects
 *   - declared-but-dead scrollports (overflow: auto/scroll, content fits)
 *   - nested same-axis scroll containers (scroll-inside-scroll)
 *
 * Deterministic: Playwright + computed style + scroll metrics. No VLM.
 *
 * Usage:
 *   vlmkit scan scroll <html-or-url>
 *   vlmkit scan scroll <html-or-url> --json
 */
import { resolve } from "node:path";
import { STABLE_SELECTOR_JS } from "../stable-selector.ts";
import { withAuthState } from "@mizchi/vlmkit-core/auth-state.ts";
import { describeRedirect } from "@mizchi/vlmkit-core/navigation-redirect.ts";
import { type PageLoadOptions, navigatePage, navigationOptions } from "@mizchi/vlmkit-core/page-load.ts";
import { BOLD, CYAN, DIM, GREEN, RED, RESET, YELLOW } from "@mizchi/vlmkit-core/terminal-colors.ts";
import type { RuleView } from "@mizchi/vlmkit-core/plugin/contract.ts";
import { retuneNote, tierIssues } from "../rule-prose.ts";
import type { UiExpectedScrollportContract } from "../contract/ui-contract.ts";
import { withBrowser } from "@mizchi/vlmkit-core/browser-launch.ts";
import { isUrlSource, sourceToUrl } from "@mizchi/vlmkit-core/page-open.ts";

export type ScrollAxis = "x" | "y" | "both";

export interface ScrollElementSample {
  selector: string;
  tagName: string;
  /** Computed overflow-x / overflow-y. */
  overflowX: string;
  overflowY: string;
  /** scrollWidth - clientWidth / scrollHeight - clientHeight (px, >= 0). */
  overflowAmountX: number;
  overflowAmountY: number;
  clientWidth: number;
  clientHeight: number;
  bbox: { x: number; y: number; width: number; height: number };
  /** Value of a data-scrollport-ish annotation when present. */
  scrollportAttr?: string;
  /** Selector of the nearest ancestor scroll container, when any. */
  ancestorScroller?: string;
}

export interface ScrollPageSample {
  viewportWidth: number;
  viewportHeight: number;
  scrollWidth: number;
  scrollHeight: number;
  /** Elements whose border box sticks out past the right viewport edge. */
  overflowOffenders: {
    selector: string;
    right: number;
    width: number;
    /**
     * Page overflow (px) that disappears when this element's own width /
     * min-width rigidity is neutralized. High = this element is a cause;
     * 0 = it was merely stretched by something else. Absent when the
     * measurement was not run (only the top candidates are probed).
     */
    relieves?: number;
  }[];
}

export interface ScrollScanInput {
  source: string;
  page: ScrollPageSample;
  elements: ScrollElementSample[];
}

export interface ScrollContainer {
  selector: string;
  axis: ScrollAxis;
  overflowAmountX: number;
  overflowAmountY: number;
  clientWidth: number;
  clientHeight: number;
  bbox: { x: number; y: number; width: number; height: number };
  scrollportAttr?: string;
  nestedIn?: string;
}

export type ScrollScanIssueKind =
  /**
   * A URL that redirected somewhere meaningful — almost always a login wall.
   * Measured 2026-08-02: pointed at an auth-walled route with no session, this
   * gate reported `status: ok` for the login page while naming the requested
   * URL as its source. Reported as a suspect issue so the pass cannot be silent.
   */
  | "redirected"
  | "page-overflow-x"
  | "clipped-content"
  | "nested-scroll";

export interface ScrollScanIssue {
  kind: ScrollScanIssueKind;
  severity: "warn" | "suspect";
  message: string;
  selector?: string;
}

export interface ScrollScanReport {
  source: string;
  page: {
    viewportWidth: number;
    viewportHeight: number;
    scrollWidth: number;
    scrollHeight: number;
    horizontalOverflow: number;
    verticalScroll: number;
  };
  containers: ScrollContainer[];
  /** overflow: auto/scroll declared, but content fits — nothing to scroll. */
  deadScrollports: { selector: string; overflowX: string; overflowY: string }[];
  /** overflow: hidden/clip with content overflowing past the threshold. */
  clipped: { selector: string; hiddenX: number; hiddenY: number }[];
  /** Ready-to-paste UI Contract scrollport expectations for the real containers. */
  expectedScrollports: UiExpectedScrollportContract[];
  issues: ScrollScanIssue[];
}

export interface ScrollScanOptions extends PageLoadOptions {
  /**
   * Playwright storage-state file so gates can measure pages behind a
   * login. Falls back to VLMKIT_STORAGE_STATE. See auth-state.ts.
   */
  storageState?: string;
  source: string;
  html?: string;
  viewport?: { width: number; height: number };
  /** Hidden-content px below which overflow:hidden clipping is ignored (default 16). */
  clipThreshold?: number;
  /** Scrollable px below which a container is not counted as real (default 2). */
  minOverflow?: number;
  /** Cap for reported clipped elements / offenders (default 20). */
  maxFindings?: number;
}


function scrollable(overflow: string): boolean {
  return overflow === "auto" || overflow === "scroll";
}

function clipping(overflow: string): boolean {
  return overflow === "hidden" || overflow === "clip";
}

function slug(value: string): string {
  return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
}

export function analyzeScrollSamples(
  input: ScrollScanInput,
  options: { clipThreshold?: number; minOverflow?: number; maxFindings?: number } = {},
): ScrollScanReport {
  const clipThreshold = options.clipThreshold ?? 16;
  const minOverflow = options.minOverflow ?? 2;
  const maxFindings = options.maxFindings ?? 20;

  const containers: ScrollContainer[] = [];
  const deadScrollports: ScrollScanReport["deadScrollports"] = [];
  const clipped: ScrollScanReport["clipped"] = [];

  for (const el of input.elements) {
    const scrollX = scrollable(el.overflowX) && el.overflowAmountX >= minOverflow;
    const scrollY = scrollable(el.overflowY) && el.overflowAmountY >= minOverflow;
    if (scrollX || scrollY) {
      containers.push({
        selector: el.selector,
        axis: scrollX && scrollY ? "both" : scrollX ? "x" : "y",
        overflowAmountX: el.overflowAmountX,
        overflowAmountY: el.overflowAmountY,
        clientWidth: el.clientWidth,
        clientHeight: el.clientHeight,
        bbox: el.bbox,
        ...(el.scrollportAttr !== undefined ? { scrollportAttr: el.scrollportAttr } : {}),
        ...(el.ancestorScroller ? { nestedIn: el.ancestorScroller } : {}),
      });
      continue;
    }
    if ((scrollable(el.overflowX) || scrollable(el.overflowY))
      && el.overflowAmountX < minOverflow && el.overflowAmountY < minOverflow) {
      deadScrollports.push({ selector: el.selector, overflowX: el.overflowX, overflowY: el.overflowY });
      continue;
    }
    const hiddenX = clipping(el.overflowX) ? el.overflowAmountX : 0;
    const hiddenY = clipping(el.overflowY) ? el.overflowAmountY : 0;
    if (hiddenX >= clipThreshold || hiddenY >= clipThreshold) {
      clipped.push({ selector: el.selector, hiddenX, hiddenY });
    }
  }
  clipped.sort((a, b) => Math.max(b.hiddenX, b.hiddenY) - Math.max(a.hiddenX, a.hiddenY));

  const horizontalOverflow = Math.max(0, input.page.scrollWidth - input.page.viewportWidth);
  const verticalScroll = Math.max(0, input.page.scrollHeight - input.page.viewportHeight);

  const issues: ScrollScanIssue[] = [];
  if (horizontalOverflow >= minOverflow) {
    // Lead with measured causes when we have them: constraining these is
    // what actually removes the overflow. Fall back to the widest boxes
    // when nothing was probed or nothing relieved anything.
    const causes = input.page.overflowOffenders
      // 10% keeps co-causes visible (two rigid siblings splitting the
      // blame) without promoting rounding noise into a "cause".
      .filter((o) => (o.relieves ?? 0) >= Math.max(minOverflow, horizontalOverflow * 0.1))
      .sort((a, b) => (b.relieves ?? 0) - (a.relieves ?? 0))
      .slice(0, 3);
    const widest = input.page.overflowOffenders
      .slice(0, 3)
      .map((o) => `${o.selector} (right edge ${o.right}px)`)
      .join(", ");
    const detail = causes.length > 0
      // Where the element ENDS, and both terms that put it there. The message used to
      // read "(130px wide; constraining it removes 46px of the overflow)", which is
      // arithmetically true and diagnostically wrong: a dogfood agent noted "The cause
      // was `left: 660px`, not the width. Shrinking the button as instructed would have
      // 'fixed' integrity and left the tab order broken." Naming only the width
      // prescribes the one change that is usually not the fix.
      ? ` — caused by: ${causes.map((o) =>
        `${o.selector} (extends to x=${o.right}px: starts at ${Math.round(o.right - o.width)}px, ${o.width}px wide;`
        + ` shrinking or moving it removes ${o.relieves}px of the overflow)`).join(", ")}`
      : (widest ? ` — sticking out: ${widest}` : "");
    // Say how many other candidates were probed and cleared. The measurement already
    // neutralizes up to 40 elements past the edge, but the report named only the
    // survivors, so a reader could not tell an exhaustive probe from a first-match
    // guess. v4's repair agent, on a card row that was wider than its container and
    // went unmentioned because flex shrank it: "I could not tell from the output
    // whether integrity had *analysed and cleared* the cards row or simply attributed
    // all overflow to the single worst offender and stopped. I had to reason about
    // flex-shrink myself to trust the fix."
    const cleared = input.page.overflowOffenders.filter((o) =>
      o.relieves !== undefined && !causes.some((c) => c.selector === o.selector));
    // The best single fix, not the sum: `relieves` is measured by neutralizing one
    // element at a time, so two elements can each report the same overflow and adding
    // them up would claim more than exists.
    const accounted = Math.max(0, ...causes.map((o) => o.relieves ?? 0));
    const unaccounted = horizontalOverflow - accounted;
    const clearedNote = causes.length === 0
      ? ""
      // A shortfall is the more useful half of the answer, and this is how it showed
      // up: on the scenario at 375px the gate reports 439px of overflow and the named
      // cause relieves 77px. The other elements past the edge are rigid siblings in one
      // row, and since each is probed alone, neutralizing either leaves the other
      // overflowing — so both measure 0 and no single element can be blamed. Stating
      // the remainder is what stops the named cause reading as the whole story.
      : (unaccounted >= minOverflow
        ? `; fixing that leaves ${unaccounted}px, which no single element relieves`
          + ` — look for siblings that are each rigid (a fixed-width row, a min-width grid track).`
          + ` ${cleared.length} other element(s) past the edge were probed individually`
        : cleared.length > 0
          ? `; ${cleared.length} other element(s) past the edge were probed and each accounts for`
            + ` ${Math.max(0, ...cleared.map((o) => o.relieves ?? 0))}px or less`
          : "");
    issues.push({
      kind: "page-overflow-x",
      severity: "suspect",
      // The blamed element travels as the SELECTOR, not only inside the prose.
      //
      // v7's agent-l tried to scope an exemption to the one element the message
      // named and could not: "`--allow` can't scope `page-overflow-x`. `@table.orders`
      // → `1 --allow rule(s) matched nothing` though the message says *"caused by:
      // table.orders"*; there's no `selector` in the JSON. […] So the only working
      // form is page-wide — I added a 1400px panel and got `NO DEFECTS … EXIT=0`.
      // CI is now blind to new overflow."
      //
      // That is the worst shape a suppression can have: the only expressible form
      // silenced the whole rule, so accepting one known overflow meant accepting
      // every future one. With the selector attached, an exemption names one element
      // and a NEW offender has a different selector, so it still reports.
      //
      // Only when a single element was actually blamed. When no element relieves the
      // overflow (rigid siblings in one row), there is nothing honest to put here and
      // the `unaccounted` note already says so.
      ...(causes[0]?.selector ? { selector: causes[0].selector } : {}),
      message: `The page scrolls horizontally by ${horizontalOverflow}px at ${input.page.viewportWidth}px viewport width` +
        detail + clearedNote + ".",
    });
  }
  for (const clip of clipped.slice(0, maxFindings)) {
    issues.push({
      kind: "clipped-content",
      severity: "warn",
      selector: clip.selector,
      message: `${clip.selector} clips ${Math.max(clip.hiddenX, clip.hiddenY)}px of its content behind overflow: hidden — cut-off content unless the oversize is decorative.`,
    });
  }
  for (const container of containers) {
    if (container.nestedIn) {
      issues.push({
        kind: "nested-scroll",
        severity: "warn",
        selector: container.selector,
        message: `${container.selector} scrolls inside another scroll container (${container.nestedIn}) — nested scrolling is easy to trigger accidentally and hard to use.`,
      });
    }
  }

  const idCounts = new Map<string, number>();
  const expectedScrollports: UiExpectedScrollportContract[] = containers.map((c, i) => {
    const base = slug(c.scrollportAttr ?? c.selector) || `scrollport-${i}`;
    const n = (idCounts.get(base) ?? 0) + 1;
    idCounts.set(base, n);
    const id = n === 1 ? base : `${base}-${n}`;
    const minOverflowPx = c.axis === "x"
      ? c.overflowAmountX
      : c.axis === "both" ? Math.max(c.overflowAmountX, c.overflowAmountY) : c.overflowAmountY;
    return {
      id,
      selector: c.selector,
      axis: c.axis,
      required: true,
      ...(minOverflowPx > 0 ? { minOverflow: minOverflowPx } : {}),
    };
  });

  return {
    source: input.source,
    page: {
      viewportWidth: input.page.viewportWidth,
      viewportHeight: input.page.viewportHeight,
      scrollWidth: input.page.scrollWidth,
      scrollHeight: input.page.scrollHeight,
      horizontalOverflow,
      verticalScroll,
    },
    containers,
    deadScrollports,
    clipped: clipped.slice(0, maxFindings),
    expectedScrollports,
    issues,
  };
}

/** In-page collector shared with `check integrity` (A7 delegation). */
export const COLLECT_SCROLL_SCRIPT = `(() => {
  ${STABLE_SELECTOR_JS}

  const SCROLLABLE = /^(auto|scroll)$/;
  const CLIPPING = /^(hidden|clip)$/;
  const scrollers = new Set();
  const elements = [];
  for (const el of Array.from(document.querySelectorAll("body *"))) {
    const style = getComputedStyle(el);
    const interesting = SCROLLABLE.test(style.overflowX) || SCROLLABLE.test(style.overflowY)
      || CLIPPING.test(style.overflowX) || CLIPPING.test(style.overflowY);
    if (!interesting) continue;
    const overflowAmountX = Math.max(0, el.scrollWidth - el.clientWidth);
    const overflowAmountY = Math.max(0, el.scrollHeight - el.clientHeight);
    let ancestorScroller;
    for (let p = el.parentElement; p && p !== document.body; p = p.parentElement) {
      if (scrollers.has(p)) { ancestorScroller = stableSelector(p); break; }
    }
    if ((SCROLLABLE.test(style.overflowX) && overflowAmountX > 1)
      || (SCROLLABLE.test(style.overflowY) && overflowAmountY > 1)) {
      scrollers.add(el);
    }
    const rect = el.getBoundingClientRect();
    const scrollportAttr = el.getAttribute("data-scrollport")
      ?? el.getAttribute("data-vlmkit-scrollport")
      ?? el.getAttribute("data-ui-scrollport")
      ?? el.getAttribute("data-scroll-region")
      ?? undefined;
    elements.push({
      selector: stableSelector(el),
      tagName: el.tagName,
      overflowX: style.overflowX,
      overflowY: style.overflowY,
      overflowAmountX,
      overflowAmountY,
      clientWidth: el.clientWidth,
      clientHeight: el.clientHeight,
      bbox: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) },
      ...(scrollportAttr !== undefined ? { scrollportAttr } : {}),
      ...(ancestorScroller ? { ancestorScroller } : {}),
    });
  }

  const doc = document.scrollingElement || document.documentElement;
  const viewportWidth = window.innerWidth;
  const offenders = [];
  if (doc.scrollWidth > viewportWidth + 1) {
    const cands = [];
    for (const el of Array.from(document.querySelectorAll("body *"))) {
      const rect = el.getBoundingClientRect();
      if (rect.right > viewportWidth + 1 && rect.width > 0) {
        cands.push({ el, selector: stableSelector(el), right: Math.round(rect.right), width: Math.round(rect.width) });
      }
    }
    cands.sort((a, b) => b.right - a.right);
    // Ranking by right edge names symptoms, not causes: in a grid/flex
    // shell one rigid child stretches the track, so every stretched
    // ancestor and sibling reports a larger right edge than the element
    // actually at fault (2026-08-01 hard-target audit: a 760px table made
    // the gate blame the sidebar and the page shell). So measure instead
    // of ranking — neutralize each candidate's own rigidity and see how
    // much page overflow disappears; that delta is the "relieves" value.
    // Probe generously: the culprit is often ranked LOW by right edge
    // (stretched ancestors and siblings outrank it), so a small window
    // would reproduce the very bug this measurement exists to fix.
    const baseline = doc.scrollWidth;
    for (const c of cands.slice(0, 40)) {
      const el = c.el;
      const prevW = el.style.getPropertyValue("width");
      const prevWP = el.style.getPropertyPriority("width");
      const prevMin = el.style.getPropertyValue("min-width");
      const prevMinP = el.style.getPropertyPriority("min-width");
      el.style.setProperty("width", "0", "important");
      el.style.setProperty("min-width", "0", "important");
      const after = doc.scrollWidth;
      if (prevW) el.style.setProperty("width", prevW, prevWP); else el.style.removeProperty("width");
      if (prevMin) el.style.setProperty("min-width", prevMin, prevMinP); else el.style.removeProperty("min-width");
      c.relieves = Math.max(0, baseline - after);
    }
    // Measured causes must survive the report's top-N slice, so order by
    // how much overflow each element accounts for and fall back to right
    // edge (which keeps the pre-measurement ordering when nothing relieved).
    cands.sort((a, b) => ((b.relieves || 0) - (a.relieves || 0)) || (b.right - a.right));
    for (const c of cands) {
      offenders.push({
        selector: c.selector, right: c.right, width: c.width,
        ...(c.relieves !== undefined ? { relieves: c.relieves } : {}),
      });
    }
  }

  return {
    page: {
      viewportWidth,
      viewportHeight: window.innerHeight,
      scrollWidth: doc.scrollWidth,
      scrollHeight: doc.scrollHeight,
      overflowOffenders: offenders.slice(0, 10),
    },
    elements,
  };
})()`;

export async function runScrollScan(options: ScrollScanOptions): Promise<ScrollScanReport> {
  const viewport = options.viewport ?? { width: 1280, height: 720 };
  return await withBrowser(async (browser) => {
    const page = await browser.newPage(withAuthState({ viewport }, options.storageState));
    if (options.html !== undefined) {
      await page.setContent(options.html, navigationOptions(options));
    } else {
      // file: URL navigation so relative stylesheets/scripts/images resolve —
      // setContent gives the document an about:blank base URL.
      const url = sourceToUrl(options.source);
      await navigatePage(page, url, options);
    }
    // A redirect here is almost always a login wall. Without this the gate
    // measured the login page and reported `status: ok` while naming the
    // requested URL as its source (measured 2026-08-02).
    const redirectNote = isUrlSource(options.source) ? describeRedirect(options.source, page.url()) : null;
    const collected = await page.evaluate(COLLECT_SCROLL_SCRIPT) as Omit<ScrollScanInput, "source">;
    await page.close();
    const report = analyzeScrollSamples(
      { source: options.source, ...collected },
      options,
    );
    // Pushed as a suspect ISSUE, not just printed: the status line is derived
    // from the issue list, so a note alone would have left `status: ok`.
    if (redirectNote) {
      report.issues.unshift({ kind: "redirected", severity: "suspect",  message: redirectNote });
    }
    return report;
  });
}

export function formatScrollScanReport(report: ScrollScanReport, rules?: RuleView): string {
  const lines: string[] = [];
  const { shown, status, note } = tierIssues(report.issues, rules);
  lines.push(`${BOLD}${CYAN}vlmkit scan scroll${RESET}`);
  lines.push(`${DIM}source: ${report.source} (${report.page.viewportWidth}x${report.page.viewportHeight})${RESET}`);
  lines.push("");
  lines.push(`status: ${status}`);
  lines.push(`page: ${report.page.scrollWidth}x${report.page.scrollHeight} — horizontal overflow ${report.page.horizontalOverflow}px, vertical scroll ${report.page.verticalScroll}px`);
  lines.push(`scroll containers: ${report.containers.length} (dead scrollports ${report.deadScrollports.length}, clipped ${report.clipped.length})`);
  if (report.containers.length > 0) {
    lines.push("");
    lines.push("Scroll containers:");
    for (const c of report.containers) {
      const amount = c.axis === "x" ? `${c.overflowAmountX}px` : c.axis === "y" ? `${c.overflowAmountY}px` : `${c.overflowAmountX}px/${c.overflowAmountY}px`;
      const attr = c.scrollportAttr !== undefined ? ` [data-scrollport="${c.scrollportAttr}"]` : "";
      const nested = c.nestedIn ? ` (nested in ${c.nestedIn})` : "";
      lines.push(`  - ${c.selector}: axis=${c.axis} overflow=${amount} box=(${c.bbox.x},${c.bbox.y}) ${c.bbox.width}x${c.bbox.height}${attr}${nested}`);
    }
  }
  if (report.deadScrollports.length > 0) {
    lines.push("");
    lines.push("Dead scrollports (declared scrollable, content fits):");
    for (const d of report.deadScrollports.slice(0, 10)) {
      lines.push(`  - ${d.selector} (overflow: ${d.overflowX} ${d.overflowY})`);
    }
  }
  if (shown.length > 0) {
    lines.push("");
    lines.push("Issues:");
    for (const entry of shown) {
      const issue = entry.row;
      const icon = entry.tier === "suspect" ? `${RED}x${RESET}` : `${YELLOW}!${RESET}`;
      const selector = issue.selector ? ` ${issue.selector}` : "";
      lines.push(`  ${icon} ${issue.kind}${selector}: ${issue.message}${retuneNote(entry)}`);
    }
  } else if (note === undefined) {
    lines.push("");
    lines.push(`${GREEN}No scroll issues detected.${RESET}`);
  }
  // Under the rows when some survived, in place of the green line when none did — a page whose
  // only findings were turned off is not a page with no findings.
  if (note) {
    lines.push("");
    lines.push(`${DIM}${note}${RESET}`);
  }
  if (report.expectedScrollports.length > 0) {
    lines.push("");
    lines.push(`Contract hint: --json emits ${report.expectedScrollports.length} expectedScrollports entr${report.expectedScrollports.length === 1 ? "y" : "ies"} ready for a UI Contract.`);
  }
  return lines.join("\n");
}

/**
 * CLI entry removed: this module is measurement code now, not a command.
 * `scan scroll` is declared in `../gates/scroll-scan.gate.ts` and driven by the core runner
 * (`@mizchi/vlmkit-core/plugin/runner.ts`), which owns argument parsing,
 * `--json`, `--advisory`, the run ledger and the exit code.
 */
