interface LintParsedGsap {
  animations: Array<{
    targetSelector: string;
    method: string;
    position: number | string;
    properties: Record<string, number | string>;
    // fromTo() exposes its first ("from") vars object separately; a layout/reflow prop
    // that appears only here still animates and must be checked.
    fromProperties?: Record<string, number | string>;
    duration?: number;
    ease?: string;
    extras?: Record<string, unknown>;
  }>;
  timelineVar: string;
}

// Use the acorn read parser: it resolves computed timelines (helpers, bounded
// loops) so lint findings like overlapping_gsap_tweens reflect true positions
// instead of all-collapsed-at-0. It's also browser-safe, so this keeps recast
// out of the lint graph entirely. Dynamic import preserves the lazy load.
async function loadParseGsapScript(): Promise<(script: string) => LintParsedGsap> {
  const mod = await import("@hyperframes/parsers/gsap-parser-acorn");
  return mod.parseGsapScriptAcorn as unknown as (script: string) => LintParsedGsap;
}
import type { LintContext } from "../context";
import type { HyperframeLintFinding, LintRule } from "../types";
import type { OpenTag } from "../utils";
import {
  readAttr,
  truncateSnippet,
  stripJsComments,
  hasCaptionStyles,
  WINDOW_TIMELINE_ASSIGN_PATTERN,
  TIMELINE_REGISTRY_OBJECT_LITERAL_PATTERN,
} from "../utils";

// ── GSAP-specific types ────────────────────────────────────────────────────

type GsapWindow = {
  targetSelector: string;
  position: number;
  end: number;
  properties: string[];
  propertyValues: Record<string, string | number>;
  overwriteAuto: boolean;
  method: string;
  raw: string;
};

type CompositionRange = {
  id: string;
  start: number;
  end: number;
};

const SCENE_BOUNDARY_EPSILON_SECONDS = 0.05;

// Sentinel the GSAP parser assigns to a tween whose target it cannot statically
// resolve to a concrete element (a computed variable, a helper call, etc.). It is
// NOT an identity: two distinct unresolved selectors are not the same element, so
// overlap analysis must never treat them as one.
const UNRESOLVED_TARGET = "__unresolved__";

// ── GSAP parsing utilities ─────────────────────────────────────────────────

function countClassUsage(tags: OpenTag[]): Map<string, number> {
  const counts = new Map<string, number>();
  for (const tag of tags) {
    const classAttr = readAttr(tag.raw, "class");
    if (!classAttr) continue;
    for (const className of classAttr.split(/\s+/).filter(Boolean)) {
      counts.set(className, (counts.get(className) || 0) + 1);
    }
  }
  return counts;
}

function readRegisteredTimelineCompositionId(script: string): string | null {
  const match = script.match(WINDOW_TIMELINE_ASSIGN_PATTERN);
  return match?.[1] || match?.[2] || null;
}

/** Strip a `__raw:` prefix the parser adds to unresolvable values. */
function unwrapRaw(value: unknown): string | number | undefined {
  if (typeof value === "number") return value;
  if (typeof value !== "string") return undefined;
  const code = value.startsWith("__raw:") ? value.slice(6) : value;
  return code.replace(/^\s*["']|["']\s*$/g, "");
}

function extrasNumber(value: unknown): number {
  const unwrapped = unwrapRaw(value);
  const numeric = typeof unwrapped === "number" ? unwrapped : Number(unwrapped);
  return Number.isFinite(numeric) ? numeric : 0;
}

/** A readable single-line snippet of a tween for finding messages. */
function synthesizeWindowRaw(
  timelineVar: string,
  anim: LintParsedGsap["animations"][number],
): string {
  const entries = Object.entries(anim.properties).map(([k, v]) => {
    if (typeof v === "string" && v.startsWith("__raw:")) return `${k}: ${v.slice(6)}`;
    return `${k}: ${typeof v === "string" ? JSON.stringify(v) : v}`;
  });
  if (anim.duration !== undefined) entries.push(`duration: ${anim.duration}`);
  if (anim.ease) entries.push(`ease: ${JSON.stringify(anim.ease)}`);
  const pos = typeof anim.position === "number" ? anim.position : JSON.stringify(anim.position);
  return `${timelineVar}.${anim.method}("${anim.targetSelector}", { ${entries.join(", ")} }, ${pos})`;
}

const gsapWindowsCache = new Map<string, GsapWindow[]>();

async function cachedExtractGsapWindows(scriptContent: string): Promise<GsapWindow[]> {
  const cached = gsapWindowsCache.get(scriptContent);
  if (cached) return cached;
  const windows = await extractGsapWindows(scriptContent);
  gsapWindowsCache.set(scriptContent, windows);
  return windows;
}

// fallow-ignore-next-line complexity
async function extractGsapWindows(script: string): Promise<GsapWindow[]> {
  if (!/gsap\.timeline/.test(script)) return [];
  const parseGsapScript = await loadParseGsapScript();
  const parsed = parseGsapScript(script);
  if (parsed.animations.length === 0) return [];

  const windows: GsapWindow[] = [];
  for (const animation of parsed.animations) {
    // Skip animations with string positions (e.g. "+=1", "<") — their absolute
    // timing depends on runtime evaluation and can't be statically linted.
    if (typeof animation.position !== "number") continue;
    const repeat = extrasNumber(animation.extras?.repeat);
    const cycleCount = repeat > 0 ? repeat + 1 : 1;
    const effectiveDuration =
      animation.method === "set" ? 0 : (animation.duration ?? 0) * cycleCount;
    windows.push({
      targetSelector: animation.targetSelector,
      position: animation.position,
      end: animation.position + effectiveDuration,
      properties: Object.keys(animation.properties),
      propertyValues: animation.properties,
      overwriteAuto: unwrapRaw(animation.extras?.overwrite) === "auto",
      method: animation.method,
      raw: synthesizeWindowRaw(parsed.timelineVar, animation),
    });
  }
  return windows;
}

function numberValue(value: string | number | undefined): number | null {
  if (typeof value === "number") return value;
  if (typeof value === "string" && value.trim()) {
    const numeric = Number(value);
    return Number.isFinite(numeric) ? numeric : null;
  }
  return null;
}

function stringValue(value: string | number | undefined): string | null {
  if (typeof value === "string") return value;
  if (typeof value === "number") return String(value);
  return null;
}

function zeroValue(value: string | number | undefined): boolean {
  if (typeof value === "number") return value === 0;
  if (typeof value !== "string") return false;
  return Number(value.trim()) === 0;
}

function isHiddenGsapState(values: Record<string, string | number>): boolean {
  const visibility = stringValue(values.visibility)?.toLowerCase();
  const display = stringValue(values.display)?.toLowerCase();
  return (
    zeroValue(values.opacity) ||
    zeroValue(values.autoAlpha) ||
    visibility === "hidden" ||
    display === "none"
  );
}

function oneValue(
  values: Record<string, string | number>,
  keys: string[],
): string | number | undefined {
  for (const key of keys) {
    const value = values[key];
    if (value !== undefined) return value;
  }
  return undefined;
}

function isVisibleGsapState(values: Record<string, string | number>): boolean {
  const opacity = oneValue(values, ["opacity", "autoAlpha"]);
  if (typeof opacity === "number") return opacity > 0;
  if (typeof opacity === "string" && opacity.trim()) {
    const numeric = Number(opacity);
    if (Number.isFinite(numeric)) return numeric > 0;
  }

  const visibility = stringValue(values.visibility)?.toLowerCase();
  if (visibility === "visible" || visibility === "inherit") return true;

  const display = stringValue(values.display)?.toLowerCase();
  if (display && display !== "none") return true;

  return false;
}

function makesOverlayVisible(win: GsapWindow): boolean {
  if (win.method === "from" && isHiddenGsapState(win.propertyValues)) return true;
  return isVisibleGsapState(win.propertyValues);
}

function isSceneBoundaryExit(win: GsapWindow): boolean {
  if (win.end <= win.position) return false;
  if (win.method !== "to" && win.method !== "fromTo") return false;
  return isHiddenGsapState(win.propertyValues);
}

function isHardKillSet(win: GsapWindow, selector: string, boundary: number): boolean {
  return (
    win.method === "set" &&
    win.targetSelector === selector &&
    Math.abs(win.position - boundary) <= SCENE_BOUNDARY_EPSILON_SECONDS &&
    isHiddenGsapState(win.propertyValues)
  );
}

function hiddenStateLiteral(values: Record<string, string | number>): string {
  if (zeroValue(values.autoAlpha)) return "{ autoAlpha: 0 }";
  if (zeroValue(values.opacity)) return "{ opacity: 0 }";
  if (stringValue(values.visibility)?.toLowerCase() === "hidden") return '{ visibility: "hidden" }';
  if (stringValue(values.display)?.toLowerCase() === "none") return '{ display: "none" }';
  return "{ opacity: 0 }";
}

function findTagEnd(source: string, tag: OpenTag): number {
  const escapedTagName = tag.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
  const pattern = new RegExp(`<\\/?${escapedTagName}\\b[^>]*>`, "gi");
  pattern.lastIndex = tag.index;

  let depth = 0;
  let match: RegExpExecArray | null;
  while ((match = pattern.exec(source)) !== null) {
    const raw = match[0];
    const isClosing = /^<\s*\//.test(raw);
    const isSelfClosing = /\/\s*>$/.test(raw);
    if (!isClosing && !isSelfClosing) depth += 1;
    if (isClosing) depth -= 1;
    if (depth === 0) return pattern.lastIndex;
  }

  return source.length;
}

function collectCompositionRanges(source: string, tags: OpenTag[]): CompositionRange[] {
  return tags
    .map((tag) => {
      const id = readAttr(tag.raw, "data-composition-id");
      if (!id) return null;
      return {
        id,
        start: tag.index,
        end: findTagEnd(source, tag),
      };
    })
    .filter((range) => range !== null);
}

function findContainingCompositionId(tag: OpenTag, ranges: CompositionRange[]): string | null {
  let match: CompositionRange | null = null;
  for (const range of ranges) {
    if (tag.index < range.start || tag.index >= range.end) continue;
    if (!match || range.start >= match.start) match = range;
  }
  return match?.id || null;
}

// A tag's `class` attribute, split into tokens, but only when it carries the
// `clip` marker class — the common "is this a clip element?" filter used by
// several rules that walk every tag looking for clips.
type ClipTagClasses = { classAttr: string; classes: string[] };

function getClipTagClasses(tag: OpenTag): ClipTagClasses | null {
  const classAttr = readAttr(tag.raw, "class") || "";
  const classes = classAttr.split(/\s+/).filter(Boolean);
  return classes.includes("clip") ? { classAttr, classes } : null;
}

function collectClipStartBoundariesByComposition(
  source: string,
  tags: OpenTag[],
): Map<string, number[]> {
  const ranges = collectCompositionRanges(source, tags);
  const boundaries = new Map<string, Set<number>>();

  for (const tag of tags) {
    if (!getClipTagClasses(tag)) continue;
    const compositionId = findContainingCompositionId(tag, ranges);
    if (!compositionId) continue;
    const start = numberValue(readAttr(tag.raw, "data-start") ?? undefined);
    if (start == null || start <= 0) continue;
    const compositionBoundaries = boundaries.get(compositionId) ?? new Set<number>();
    compositionBoundaries.add(start);
    boundaries.set(compositionId, compositionBoundaries);
  }

  return new Map(
    [...boundaries.entries()].map(([compositionId, values]) => [
      compositionId,
      [...values].sort((a, b) => a - b),
    ]),
  );
}

function findMatchingSceneBoundary(time: number, boundaries: number[]): number | null {
  for (const boundary of boundaries) {
    if (Math.abs(time - boundary) <= SCENE_BOUNDARY_EPSILON_SECONDS) return boundary;
  }
  return null;
}

function isSuspiciousGlobalSelector(selector: string): boolean {
  if (!selector) return false;
  if (selector.includes("[data-composition-id=")) return false;
  if (selector.startsWith("#")) return false;
  return selector.startsWith(".") || /^[a-z]/i.test(selector);
}

function getSingleClassSelector(selector: string): string | null {
  const match = selector.trim().match(/^\.(?<name>[A-Za-z0-9_-]+)$/);
  return match?.groups?.name || null;
}

function readStyleProperty(style: string, property: string): string | null {
  const escapedProperty = property.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
  const match = style.match(new RegExp(`(?:^|;)\\s*${escapedProperty}\\s*:\\s*([^;]+)`, "i"));
  return match?.[1]?.trim() || null;
}

function cssZero(value: string | null): boolean {
  if (!value) return false;
  return /^0(?:\.0+)?(?:px|%|vw|vh|rem|em)?$/i.test(value.trim());
}

function styleHasHiddenInitialState(style: string): boolean {
  const opacity = readStyleProperty(style, "opacity");
  if (opacity && Number(opacity) === 0) return true;
  if (readStyleProperty(style, "visibility")?.toLowerCase() === "hidden") return true;
  if (readStyleProperty(style, "display")?.toLowerCase() === "none") return true;
  return false;
}

function styleHasOpaqueBackground(style: string): boolean {
  const background =
    readStyleProperty(style, "background") || readStyleProperty(style, "background-color");
  if (!background) return false;
  const normalized = background.toLowerCase().replace(/\s+/g, "");
  if (normalized === "transparent" || normalized === "none") return false;
  if (/rgba?\([^)]*,0(?:\.0+)?\)$/.test(normalized)) return false;
  if (/hsla?\([^)]*,0(?:\.0+)?\)$/.test(normalized)) return false;
  return true;
}

function styleLooksFullFrameOverlay(style: string): boolean {
  const position = readStyleProperty(style, "position")?.toLowerCase();
  if (position !== "fixed" && position !== "absolute") return false;
  const coversFrame =
    cssZero(readStyleProperty(style, "inset")) ||
    (cssZero(readStyleProperty(style, "top")) &&
      cssZero(readStyleProperty(style, "right")) &&
      cssZero(readStyleProperty(style, "bottom")) &&
      cssZero(readStyleProperty(style, "left")));
  return coversFrame && styleHasOpaqueBackground(style);
}

function collectSimpleStyleRules(styles: LintContext["styles"]): Map<string, string> {
  const rules = new Map<string, string>();
  for (const style of styles) {
    for (const [, selectorList, body] of style.content.matchAll(/([^{}]+)\{([^}]+)\}/g)) {
      if (!selectorList || !body) continue;
      for (const selector of selectorList.split(",")) {
        const token = selector.trim();
        if (!/^[#.][A-Za-z0-9_-]+$/.test(token)) continue;
        rules.set(token, `${rules.get(token) || ""};${body}`);
      }
    }
  }
  return rules;
}

function tagSimpleSelectors(tag: OpenTag): string[] {
  const selectors: string[] = [];
  const id = readAttr(tag.raw, "id");
  if (id) selectors.push(`#${id}`);
  const classes = readAttr(tag.raw, "class")?.split(/\s+/).filter(Boolean) ?? [];
  for (const className of classes) selectors.push(`.${className}`);
  return selectors;
}

function combinedTagStyle(tag: OpenTag, styleRules: Map<string, string>): string {
  const styles = [readAttr(tag.raw, "style") || ""];
  for (const selector of tagSimpleSelectors(tag)) {
    const ruleStyle = styleRules.get(selector);
    if (ruleStyle) styles.push(ruleStyle);
  }
  return styles.filter(Boolean).join(";");
}

// fallow-ignore-next-line complexity
function cssTransformToGsapProps(cssTransform: string): string | null {
  const parts: string[] = [];

  // translate(-50%, -50%) or translate(X, Y)
  const translateMatch = cssTransform.match(
    /translate\(\s*(-?[\d.]+)(%|px)?\s*,\s*(-?[\d.]+)(%|px)?\s*\)/,
  );
  if (translateMatch) {
    const [, xVal, xUnit, yVal, yUnit] = translateMatch;
    if (xUnit === "%") parts.push(`xPercent: ${xVal}`);
    else parts.push(`x: ${xVal}`);
    if (yUnit === "%") parts.push(`yPercent: ${yVal}`);
    else parts.push(`y: ${yVal}`);
  }

  // translateX(-50%) or translateX(px)
  const txMatch = cssTransform.match(/translateX\(\s*(-?[\d.]+)(%|px)?\s*\)/);
  if (txMatch) {
    const [, val, unit] = txMatch;
    parts.push(unit === "%" ? `xPercent: ${val}` : `x: ${val}`);
  }

  // translateY(-50%) or translateY(px)
  const tyMatch = cssTransform.match(/translateY\(\s*(-?[\d.]+)(%|px)?\s*\)/);
  if (tyMatch) {
    const [, val, unit] = tyMatch;
    parts.push(unit === "%" ? `yPercent: ${val}` : `y: ${val}`);
  }

  // scale(N)
  const scaleMatch = cssTransform.match(/scale\(\s*([\d.]+)\s*\)/);
  if (scaleMatch) {
    parts.push(`scale: ${scaleMatch[1]}`);
  }

  return parts.length > 0 ? parts.join(", ") : null;
}

// ── CSS-transform ↔ GSAP-transform conflict matching ─────────────────────────

// Transform components that COMBINE with a CSS translate/scale on the same
// element. GSAP bakes the element's existing CSS transform in when it seeks, so
// these stack rather than override in the capture path (e.g. CSS translateX(-50%)
// + xPercent:-50 renders as -100% — off-centre). `rotation` is excluded: it maps
// to CSS rotate(), which this rule treats separately (no false positive on spin).
const CONFLICTING_TRANSLATE_PROPS = ["x", "y", "xPercent", "yPercent"];
const CONFLICTING_SCALE_PROPS = ["scale", "scaleX", "scaleY"];

type GsapTransformCall = {
  method: string;
  selector: string;
  properties: string[];
  raw: string;
};

// Decompose a (possibly grouped / descendant / compound) GSAP target selector
// into the simple `#id` / `.class` tokens of the elements it actually targets —
// the RIGHTMOST compound of each comma group is the targeted element. This lets a
// CSS rule keyed by a simple selector (`.m04-label`) match a scoped GSAP selector
// (`"#root .m04-label, #root .m04-sub"`), which the prior exact-string lookup
// missed — so every scoped/grouped selector slipped past the rule entirely.
function targetedSelectorTokens(selector: string): Set<string> {
  const tokens = new Set<string>();
  for (const group of selector.split(",")) {
    const compounds = group
      .trim()
      .split(/[\s>+~]+/)
      .filter(Boolean);
    const last = compounds[compounds.length - 1];
    if (!last) continue;
    const simple = last.match(/[#.][A-Za-z0-9_-]+/g);
    if (simple) for (const token of simple) tokens.add(token);
  }
  return tokens;
}

// Find a CSS transform conflicting with a GSAP target selector: exact-string
// match first (fast path + back-compat with the original behaviour), then a
// token match so scoped/grouped/descendant selectors resolve to their class/id.
function matchCssTransform(gsapSelector: string, cssMap: Map<string, string>): string | undefined {
  if (cssMap.size === 0) return undefined;
  const direct = cssMap.get(gsapSelector);
  if (direct) return direct;
  const tokens = targetedSelectorTokens(gsapSelector);
  for (const [cssSelector, value] of cssMap) {
    if (tokens.has(cssSelector)) return value;
  }
  return undefined;
}

// Scan for STANDALONE `gsap.set/to/from/fromTo("selector", { ...props })` calls.
// The acorn timeline parser only captures calls rooted on the timeline var
// (`tl.to`, `tl.set`, …); a top-level `gsap.set("#root .label", { xPercent: -50 })`
// — a common way to seat shared base transforms before the timeline runs — is
// invisible to it, so the conflict rule never saw it. Variable selectors
// (`gsap.set(kicker, …)`) can't be resolved statically and are skipped.
function extractStandaloneGsapTransformCalls(script: string): GsapTransformCall[] {
  const calls: GsapTransformCall[] = [];
  const pattern = /gsap\.(set|to|from|fromTo)\s*\(\s*(["'])([^"']+)\2\s*,\s*\{([^{}]*)\}/g;
  let match: RegExpExecArray | null;
  while ((match = pattern.exec(script)) !== null) {
    const method = match[1] ?? "set";
    const selector = match[3] ?? "";
    const propsBody = match[4] ?? "";
    const properties = [...propsBody.matchAll(/([A-Za-z_$][\w$]*)\s*:/g)].map((m) => m[1] ?? "");
    calls.push({ method, selector, properties, raw: truncateSnippet(match[0]) ?? match[0] });
  }
  return calls;
}

// Run a global regex over every script's content, yielding each match plus a
// context-padded snippet around it. Shared by the repeat-count and
// group-selector-keyframes rules below, which differ only in the pattern,
// whether comments are stripped first, and the context window size.
function scanScriptsForRegexMatches(
  scripts: LintContext["scripts"],
  pattern: RegExp,
  options: { stripComments: boolean; contextBefore: number; contextAfter: number },
): Array<{ match: RegExpExecArray; snippet: string }> {
  const hits: Array<{ match: RegExpExecArray; snippet: string }> = [];
  for (const script of scripts) {
    const content = options.stripComments ? stripJsComments(script.content) : script.content;
    const regex = new RegExp(pattern.source, pattern.flags);
    let match: RegExpExecArray | null;
    while ((match = regex.exec(content)) !== null) {
      const contextStart = Math.max(0, match.index - options.contextBefore);
      const contextEnd = Math.min(
        content.length,
        match.index + match[0].length + options.contextAfter,
      );
      hits.push({ match, snippet: content.slice(contextStart, contextEnd) });
    }
  }
  return hits;
}

// ── GSAP rules ─────────────────────────────────────────────────────────────

// fallow-ignore-next-line complexity
export const gsapRules: LintRule<LintContext>[] = [
  // overlapping_gsap_tweens + gsap_animates_clip_element + unscoped_gsap_selector
  // fallow-ignore-next-line complexity
  async ({ source, tags, scripts, styles, rootCompositionId }) => {
    const findings: HyperframeLintFinding[] = [];

    // Build clip element selector map
    type ClipInfo = { tag: string; id: string; classes: string };
    const clipIds = new Map<string, ClipInfo>();
    const clipClasses = new Map<string, ClipInfo>();
    for (const tag of tags) {
      const clipTag = getClipTagClasses(tag);
      if (!clipTag) continue;
      const id = readAttr(tag.raw, "id");
      const info: ClipInfo = {
        tag: tag.name,
        id: id || "",
        classes: clipTag.classAttr,
      };
      if (id) clipIds.set(`#${id}`, info);
      for (const cls of clipTag.classes) {
        if (cls !== "clip") clipClasses.set(`.${cls}`, info);
      }
    }

    const classUsage = countClassUsage(tags);
    const clipStartBoundariesByComposition = collectClipStartBoundariesByComposition(source, tags);
    const styleRules = collectSimpleStyleRules(styles);
    const reportedVisibleOverlayKeys = new Set<string>();

    for (const script of scripts) {
      const localTimelineCompId = readRegisteredTimelineCompositionId(script.content);
      const gsapWindows = await cachedExtractGsapWindows(script.content);
      const clipStartBoundaries =
        clipStartBoundariesByComposition.get(localTimelineCompId || rootCompositionId || "") ?? [];

      // overlapping_gsap_tweens
      for (let i = 0; i < gsapWindows.length; i++) {
        const left = gsapWindows[i];
        if (!left) continue;
        if (left.end <= left.position) continue;
        // Unresolved targets are unknown elements: two of them are not provably
        // the same element, so an overlap between them cannot be asserted.
        if (left.targetSelector === UNRESOLVED_TARGET) continue;
        for (let j = i + 1; j < gsapWindows.length; j++) {
          const right = gsapWindows[j];
          if (!right) continue;
          if (right.end <= right.position) continue;
          if (left.targetSelector !== right.targetSelector) continue;
          const overlapStart = Math.max(left.position, right.position);
          const overlapEnd = Math.min(left.end, right.end);
          if (overlapEnd <= overlapStart) continue;
          if (left.overwriteAuto || right.overwriteAuto) continue;
          const sharedProperties = left.properties.filter((prop) =>
            right.properties.includes(prop),
          );
          if (sharedProperties.length === 0) continue;
          findings.push({
            code: "overlapping_gsap_tweens",
            severity: "warning",
            message: `GSAP tweens overlap on "${left.targetSelector}" for ${sharedProperties.join(", ")} between ${overlapStart.toFixed(2)}s and ${overlapEnd.toFixed(2)}s.`,
            selector: left.targetSelector,
            fixHint: 'Shorten the earlier tween, move the later tween, or add `overwrite: "auto"`.',
            snippet: truncateSnippet(`${left.raw}\n${right.raw}`),
          });
        }
      }

      // gsap_exit_missing_hard_kill
      if (clipStartBoundaries.length > 0) {
        for (const win of gsapWindows) {
          // Unresolved targets are unknown elements: you cannot assert a missing
          // hard kill on one, and a `tl.set("__unresolved__", ...)` hint is meaningless.
          if (win.targetSelector === UNRESOLVED_TARGET) continue;
          if (!isSceneBoundaryExit(win)) continue;
          const boundary = findMatchingSceneBoundary(win.end, clipStartBoundaries);
          if (boundary == null) continue;
          const hasHardKill = gsapWindows.some((candidate) =>
            isHardKillSet(candidate, win.targetSelector, boundary),
          );
          if (hasHardKill) continue;

          // A tl.set hard kill on the exiting selector itself is the fix — unless
          // that selector IS a clip element, in which case gsap_animates_clip_element
          // (below) errors on that exact tl.set: the framework already owns
          // visibility/display on clip elements. Point at the inner-wrapper
          // pattern instead so the two rules' advice doesn't contradict.
          const exitClipInfo =
            clipIds.get(win.targetSelector) || clipClasses.get(win.targetSelector);
          const fixHint = exitClipInfo
            ? `"${win.targetSelector}" is a clip element — the framework already manages its visibility. ` +
              "Wrap the scene's content in an inner non-clip <div>, move the exit tween and the hard kill " +
              `(\`tl.set("<inner-selector>", ${hiddenStateLiteral(win.propertyValues)}, ${boundary.toFixed(2)})\`) onto that wrapper instead.`
            : `Add \`tl.set("${win.targetSelector}", ${hiddenStateLiteral(win.propertyValues)}, ${boundary.toFixed(2)})\` ` +
              "after the exit tween.";

          findings.push({
            code: "gsap_exit_missing_hard_kill",
            severity: "error",
            message:
              `GSAP exit on "${win.targetSelector}" ends at the ${boundary.toFixed(2)}s clip start boundary ` +
              "without a matching tl.set hard kill. Non-linear seeking can land after the fade and leave stale visibility state.",
            selector: win.targetSelector,
            fixHint,
            snippet: truncateSnippet(win.raw),
          });
        }
      }

      // gsap_fullscreen_overlay_starts_visible
      for (const tag of tags) {
        const selectors = tagSimpleSelectors(tag);
        if (selectors.length === 0) continue;
        const overlayKey = readAttr(tag.raw, "id") || String(tag.index);
        if (reportedVisibleOverlayKeys.has(overlayKey)) continue;
        const authoredStyle = combinedTagStyle(tag, styleRules);
        if (!authoredStyle || !styleLooksFullFrameOverlay(authoredStyle)) continue;
        if (styleHasHiddenInitialState(authoredStyle)) continue;

        const visibilityWindows = gsapWindows
          .filter((win) => {
            const tokens = targetedSelectorTokens(win.targetSelector);
            if (!selectors.some((selector) => tokens.has(selector))) return false;
            return win.properties.some((prop) =>
              ["opacity", "autoAlpha", "visibility", "display"].includes(prop),
            );
          })
          .sort((a, b) => a.position - b.position);
        const startsHiddenAtZero = visibilityWindows.some(
          (win) =>
            win.position <= SCENE_BOUNDARY_EPSILON_SECONDS && isHiddenGsapState(win.propertyValues),
        );
        if (startsHiddenAtZero) continue;
        const firstVisible = visibilityWindows.find((win) => makesOverlayVisible(win));
        if (!firstVisible) continue;
        const selector =
          selectors.find((candidate) =>
            targetedSelectorTokens(firstVisible.targetSelector).has(candidate),
          ) ||
          selectors[0] ||
          tag.name;
        const laterHidden = visibilityWindows.some(
          (win) => win.position >= firstVisible.position && isHiddenGsapState(win.propertyValues),
        );
        if (firstVisible.method !== "from" && !laterHidden) continue;

        reportedVisibleOverlayKeys.add(overlayKey);
        findings.push({
          code: "gsap_fullscreen_overlay_starts_visible",
          severity: "error",
          message:
            `Full-frame overlay "${selector}" starts visible before its first GSAP opacity tween at ` +
            `${firstVisible.position.toFixed(2)}s. It will cover earlier render frames, often as a blank/white video.`,
          selector,
          elementId: readAttr(tag.raw, "id") || undefined,
          fixHint:
            `Add \`opacity: 0\` to "${selector}" in CSS/inline styles, or add ` +
            `\`tl.set("${selector}", { opacity: 0 }, 0)\` before the reveal tween.`,
          snippet: truncateSnippet(firstVisible.raw),
        });
      }

      // gsap_animates_clip_element — only error when GSAP animates visibility/display
      for (const win of gsapWindows) {
        const sel = win.targetSelector;
        const clipInfo = clipIds.get(sel) || clipClasses.get(sel);
        if (!clipInfo) continue;
        const conflictingProps = win.properties.filter(
          (p) => p === "visibility" || p === "display",
        );
        if (conflictingProps.length === 0) continue;
        const elDesc = `<${clipInfo.tag}${clipInfo.id ? ` id="${clipInfo.id}"` : ""} class="${clipInfo.classes}">`;
        findings.push({
          code: "gsap_animates_clip_element",
          severity: "error",
          message: `GSAP animation sets ${conflictingProps.join(", ")} on a clip element. Selector "${sel}" resolves to element ${elDesc}. The framework manages clip visibility via ${conflictingProps.join("/")} — do not animate these properties on clip elements.`,
          selector: sel,
          elementId: clipInfo.id || undefined,
          fixHint:
            "Remove the visibility/display tween, or move the content into a child <div> and target that instead.",
          snippet: truncateSnippet(win.raw),
        });
      }

      // unscoped_gsap_selector
      if (!localTimelineCompId || localTimelineCompId === rootCompositionId) continue;
      for (const win of gsapWindows) {
        if (!isSuspiciousGlobalSelector(win.targetSelector)) continue;
        const className = getSingleClassSelector(win.targetSelector);
        if (className && (classUsage.get(className) || 0) < 2) continue;
        findings.push({
          code: "unscoped_gsap_selector",
          severity: "error",
          message: `Timeline "${localTimelineCompId}" uses unscoped selector "${win.targetSelector}" that will target elements in ALL compositions when bundled, causing data loss (opacity, transforms, etc.).`,
          selector: win.targetSelector,
          fixHint: `Scope the selector: \`[data-composition-id="${localTimelineCompId}"] ${win.targetSelector}\` or use a unique id.`,
          snippet: truncateSnippet(win.raw),
        });
      }
    }
    return findings;
  },

  // gsap_css_transform_conflict
  // fallow-ignore-next-line complexity
  async ({ styles, scripts, tags }) => {
    const findings: HyperframeLintFinding[] = [];
    const cssTranslateSelectors = new Map<string, string>();
    const cssScaleSelectors = new Map<string, string>();

    // Check <style> blocks for transform rules
    for (const style of styles) {
      for (const [, selector, body] of style.content.matchAll(
        /([#.][a-zA-Z0-9_-]+)\s*\{([^}]+)\}/g,
      )) {
        const tMatch = body?.match(/transform\s*:\s*([^;]+)/);
        if (!tMatch || !tMatch[1]) continue;
        const transformVal = tMatch[1].trim();
        if (/translate/i.test(transformVal))
          cssTranslateSelectors.set((selector ?? "").trim(), transformVal);
        if (/scale/i.test(transformVal))
          cssScaleSelectors.set((selector ?? "").trim(), transformVal);
      }
    }

    // Also check inline style="..." attributes on tags
    for (const tag of tags) {
      const inlineStyle = readAttr(tag.raw, "style");
      if (!inlineStyle) continue;
      const tMatch = inlineStyle.match(/transform\s*:\s*([^;]+)/);
      if (!tMatch || !tMatch[1]) continue;
      const transformVal = tMatch[1].trim();
      // Derive selectors from the tag's id and all classes
      const id = readAttr(tag.raw, "id");
      const classes = readAttr(tag.raw, "class")?.split(/\s+/).filter(Boolean) ?? [];
      const selectors: string[] = [];
      if (id) selectors.push(`#${id}`);
      for (const cls of classes) selectors.push(`.${cls}`);
      if (selectors.length === 0) continue;
      for (const sel of selectors) {
        if (/translate/i.test(transformVal) && !cssTranslateSelectors.has(sel))
          cssTranslateSelectors.set(sel, transformVal);
        if (/scale/i.test(transformVal) && !cssScaleSelectors.has(sel))
          cssScaleSelectors.set(sel, transformVal);
      }
    }

    if (cssTranslateSelectors.size === 0 && cssScaleSelectors.size === 0) return findings;

    for (const script of scripts) {
      if (!/gsap\.timeline/.test(script.content)) continue;
      const windows = await cachedExtractGsapWindows(script.content);

      // Two sources of transform-setting calls: timeline-rooted tweens (from the
      // acorn parser) and standalone gsap.* calls (regex — the parser ignores
      // these). Normalize both into one shape and run the same conflict check.
      const calls: GsapTransformCall[] = [
        ...windows.map((win) => ({
          method: win.method,
          selector: win.targetSelector,
          properties: win.properties,
          raw: win.raw,
        })),
        ...extractStandaloneGsapTransformCalls(stripJsComments(script.content)),
      ];

      type Conflict = { cssTransform: string; props: Set<string>; raw: string };
      const conflicts = new Map<string, Conflict>();

      for (const call of calls) {
        // from() and fromTo() both supply explicit start values so GSAP owns
        // the full transform from t=0, making the CSS conflict moot
        if (call.method === "fromTo" || call.method === "from") continue;
        const sel = call.selector;
        const translateProps = call.properties.filter((p) =>
          CONFLICTING_TRANSLATE_PROPS.includes(p),
        );
        const scaleProps = call.properties.filter((p) => CONFLICTING_SCALE_PROPS.includes(p));
        const cssFromTranslate =
          translateProps.length > 0 ? matchCssTransform(sel, cssTranslateSelectors) : undefined;
        const cssFromScale =
          scaleProps.length > 0 ? matchCssTransform(sel, cssScaleSelectors) : undefined;
        if (!cssFromTranslate && !cssFromScale) continue;
        const existing = conflicts.get(sel) ?? {
          cssTransform: [cssFromTranslate, cssFromScale].filter(Boolean).join(" "),
          props: new Set<string>(),
          raw: call.raw,
        };
        for (const p of [...translateProps, ...scaleProps]) existing.props.add(p);
        conflicts.set(sel, existing);
      }

      for (const [sel, { cssTransform, props, raw }] of conflicts) {
        const propList = [...props].join("/");
        const gsapEquivalent = cssTransformToGsapProps(cssTransform);
        const fixHint = gsapEquivalent
          ? `Remove \`transform: ${cssTransform}\` from CSS and replace with GSAP properties: ${gsapEquivalent}. ` +
            `Example: tl.fromTo('${sel}', { ${gsapEquivalent} }, { ${gsapEquivalent}, ...yourAnimation }). ` +
            `tl.fromTo is exempt from this rule.`
          : `Remove the transform from CSS and use tl.fromTo('${sel}', ` +
            `{ xPercent: -50, x: -1000 }, { xPercent: -50, x: 0 }) so GSAP owns ` +
            `the full transform state. tl.fromTo is exempt from this rule.`;
        findings.push({
          code: "gsap_css_transform_conflict",
          severity: "error",
          message:
            `"${sel}" has CSS \`transform: ${cssTransform}\` and a GSAP tween animates ` +
            `${propList}. GSAP will overwrite the full CSS transform, discarding any ` +
            `translateX(-50%) centering or CSS scale value.`,
          selector: sel,
          fixHint,
          snippet: truncateSnippet(raw),
        });
      }
    }
    return findings;
  },

  // missing_gsap_script
  ({ scripts, rawSource, options }) => {
    const allScriptTexts = scripts.filter((s) => !/\bsrc\s*=/.test(s.attrs)).map((s) => s.content);
    const allScriptSrcs = scripts
      .map((s) => readAttr(`<script ${s.attrs}>`, "src") || "")
      .filter(Boolean);
    const canInheritGsapFromHost =
      options.isSubComposition || rawSource.trimStart().toLowerCase().startsWith("<template");

    const usesGsap = allScriptTexts.some((t) =>
      /gsap\.(to|from|fromTo|timeline|set|registerPlugin)\b/.test(t),
    );
    const hasGsapScript = allScriptSrcs.some((src) => /gsap/i.test(src));
    // Detect GSAP bundled inline (no src attribute). Match:
    // - Producer's CDN-inlining comment: /* inlined: ...gsap... */
    // - GSAP library internals: _gsScope, GreenSock, gsap.config
    // - Large inline scripts (>5KB) that reference gsap (likely bundled library)
    const hasInlineGsap = allScriptTexts.some(
      (t) =>
        /\/\*\s*inlined:.*gsap/i.test(t) ||
        /\b_gsScope\b/.test(t) ||
        /\bGreenSock\b/.test(t) ||
        /\bgsap\.(config|defaults|version)\b/.test(t) ||
        (t.length > 5000 && /\bgsap\b/i.test(t)),
    );

    if (!usesGsap || hasGsapScript || hasInlineGsap || canInheritGsapFromHost) return [];
    return [
      {
        code: "missing_gsap_script",
        severity: "error",
        message: "Composition uses GSAP but no GSAP script is loaded. The animation will not run.",
        fixHint:
          'Add <script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script> before your animation script.',
      },
    ];
  },

  // audio_reactive_single_tween_per_group
  // fallow-ignore-next-line complexity
  ({ scripts, styles }) => {
    const findings: HyperframeLintFinding[] = [];
    if (!hasCaptionStyles(styles)) return findings;

    for (const script of scripts) {
      const content = script.content;
      // Detect audio data loading
      const hasAudioData = /AUDIO|audio[-_]?data|bands\[/.test(content);
      if (!hasAudioData) continue;

      // Detect caption group loop
      const hasCaptionLoop = /forEach/.test(content) && /caption|group|cg-/.test(content);
      if (!hasCaptionLoop) continue;

      // Check if audio-reactive tweens are created at intervals (loop inside the group loop)
      // vs a single tween per group (no inner time-sampling loop)
      const hasInnerSamplingLoop =
        /for\s*\(\s*var\s+\w+\s*=\s*group\.start/.test(content) ||
        /for\s*\(\s*var\s+at\s*=/.test(content) ||
        /while\s*\(\s*\w+\s*<\s*group\.end/.test(content);

      if (!hasInnerSamplingLoop) {
        // Check if there's at least a peak-based single tween (the minimal pattern)
        const hasPeakTween =
          /peak(?:Bass|Treble|Energy)/.test(content) && /group\.start/.test(content);
        if (hasPeakTween) {
          findings.push({
            code: "audio_reactive_single_tween_per_group",
            severity: "warning",
            message:
              "Audio-reactive captions use a single tween per group based on peak values. " +
              "This sets one static value at group.start — not perceptible as audio reactivity.",
            fixHint:
              "Sample audio data at 100-200ms intervals throughout each group's lifetime " +
              "(for loop from group.start to group.end) and create a tween at each sample " +
              "point for visible pulsing.",
          });
        }
      }
    }
    return findings;
  },

  // gsap_infinite_repeat
  ({ scripts }) => {
    const findings: HyperframeLintFinding[] = [];
    // Match repeat: -1 in GSAP tweens or timeline configs
    const pattern = /repeat\s*:\s*-1(?!\d)/g;
    for (const { snippet } of scanScriptsForRegexMatches(scripts, pattern, {
      stripComments: true,
      contextBefore: 60,
      contextAfter: 60,
    })) {
      findings.push({
        code: "gsap_infinite_repeat",
        severity: "error",
        message:
          "GSAP tween uses `repeat: -1` (infinite). Infinite repeats break the deterministic " +
          "capture engine which seeks to exact frame times. Use a finite repeat count calculated " +
          "from the composition duration: `repeat: Math.floor(duration / cycleDuration) - 1`.",
        fixHint:
          "Replace `repeat: -1` with a finite count, e.g. `repeat: Math.floor(totalDuration / singleCycleDuration) - 1`. " +
          "Use Math.floor (not Math.ceil) to ensure the animation fits within the total duration.",
        snippet: truncateSnippet(snippet),
      });
    }
    return findings;
  },

  // gsap_repeat_ceil_overshoot
  ({ scripts }) => {
    const findings: HyperframeLintFinding[] = [];
    // Match patterns like: repeat: Math.ceil(duration / X) - 1
    // or repeat: Math.ceil(totalDuration / cycleDuration) - 1
    const pattern = /repeat\s*:\s*Math\.ceil\s*\([^)]+\)\s*-\s*1/g;
    for (const { snippet } of scanScriptsForRegexMatches(scripts, pattern, {
      stripComments: false,
      contextBefore: 40,
      contextAfter: 40,
    })) {
      findings.push({
        code: "gsap_repeat_ceil_overshoot",
        severity: "warning",
        message:
          "GSAP repeat calculation uses `Math.ceil` which can overshoot the composition duration. " +
          "For example, Math.ceil(10.5 / 2) - 1 = 5 repeats → 6 cycles × 2s = 12s, exceeding 10.5s.",
        fixHint:
          "Use `Math.floor` instead of `Math.ceil` to ensure the animation fits within the duration: " +
          "`repeat: Math.floor(totalDuration / cycleDuration) - 1`. " +
          "Math.floor(10.5 / 2) - 1 = 4 repeats → 5 cycles × 2s = 10s ✓",
        snippet: truncateSnippet(snippet),
      });
    }
    return findings;
  },

  // scene_layer_missing_visibility_kill
  ({ scripts, tags }) => {
    const findings: HyperframeLintFinding[] = [];

    // Detect multi-scene compositions: multiple elements with "scene" in their id
    const sceneElements = tags.filter((t) => {
      const id = readAttr(t.raw, "id") || "";
      return /^scene\d+$/i.test(id);
    });
    if (sceneElements.length < 2) return findings;

    for (const script of scripts) {
      const content = stripJsComments(script.content);
      // For each scene, check if there's a visibility:hidden set after exit tweens
      for (const tag of sceneElements) {
        const id = readAttr(tag.raw, "id") || "";
        // Check if this scene has exit tweens (opacity: 0)
        const exitPattern = new RegExp(`["']#${id}["'][^)]*opacity\\s*:\\s*0`);
        const hasExit = exitPattern.test(content);
        if (!hasExit) continue;

        // Check if there's a hard visibility kill
        const killPattern = new RegExp(`["']#${id}["'][^)]*visibility\\s*:\\s*["']hidden["']`);
        const hasKill = killPattern.test(content);
        if (!hasKill) {
          // A tl.set on "#id" is only safe advice when the scene element isn't
          // itself a clip — otherwise gsap_animates_clip_element errors on that
          // exact tl.set, since the framework already owns visibility/display on
          // clip elements. Point at the inner-wrapper pattern instead.
          const classes = (readAttr(tag.raw, "class") || "").split(/\s+/).filter(Boolean);
          const isClip = classes.includes("clip");
          const fixHint = isClip
            ? `"#${id}" is a clip element — the framework already manages its visibility. ` +
              "Wrap the scene's content in an inner non-clip <div>, move the exit tween and the hard kill " +
              '(`tl.set("<inner-selector>", { visibility: "hidden" }, <exit-end-time>)`) onto that wrapper instead.'
            : `Add \`tl.set("#${id}", { visibility: "hidden" }, <exit-end-time>)\` after the scene's exit tweens.`;

          findings.push({
            code: "scene_layer_missing_visibility_kill",
            severity: "error",
            elementId: id,
            message:
              `Scene layer "#${id}" exits via opacity tween but has no visibility: hidden hard kill. ` +
              "When scrubbing or when tweens conflict, the scene may remain partially visible and overlap the next scene.",
            fixHint,
          });
        }
      }
    }
    return findings;
  },

  // gsap_timeline_not_registered
  ({ scripts, rawSource, options }) => {
    const findings: HyperframeLintFinding[] = [];
    const canInheritFromHost =
      options.isSubComposition || rawSource.trimStart().toLowerCase().startsWith("<template");

    for (const script of scripts) {
      const content = script.content;
      if (!/gsap\.timeline/.test(content)) continue;
      const hasRegistration =
        WINDOW_TIMELINE_ASSIGN_PATTERN.test(content) ||
        TIMELINE_REGISTRY_OBJECT_LITERAL_PATTERN.test(content);
      if (hasRegistration || canInheritFromHost) continue;
      findings.push({
        code: "gsap_timeline_not_registered",
        severity: "error",
        message:
          "GSAP timeline is created but never registered in window.__timelines. " +
          "The runtime discovers timelines from this registry — without registration, " +
          "animations will not play during preview or render.",
        fixHint:
          "Add `window.__timelines = window.__timelines || {};` and " +
          '`window.__timelines["root"] = tl;` after creating the timeline (use the ' +
          "composition's data-composition-id as the key).",
      });
    }
    return findings;
  },

  // gsap_timeline_registered_before_async_build — registering window.__timelines[id]
  // BEFORE the timeline is built inside document.fonts.ready (or any async callback)
  // leaves an EMPTY timeline registered. The runtime's sub-composition readiness gate
  // treats "key present" as "ready" and nests the child ONCE, while still empty — so the
  // animation never renders when this composition is mounted as a sub-composition.
  // Register only AFTER the build completes (the documented async-setup contract).
  ({ scripts }) => {
    const findings: HyperframeLintFinding[] = [];
    for (const script of scripts) {
      const content = stripJsComments(script.content);
      const regIdx = content.search(/window\s*\.\s*__timelines\s*\[/);
      if (regIdx < 0) continue;
      const fontsReadyIdx = content.search(/document\s*\.\s*fonts\s*\.\s*ready/);
      if (fontsReadyIdx < 0) continue;
      // Registering after the async boundary is the correct pattern — skip it.
      if (regIdx >= fontsReadyIdx) continue;
      // Confirm the build is actually deferred past the boundary (a tween/build call
      // appears after document.fonts.ready), i.e. the registered timeline starts empty.
      const tail = content.slice(fontsReadyIdx);
      if (!/\.(?:to|from|fromTo)\s*\(|buildEffect\s*\(/.test(tail)) continue;
      findings.push({
        code: "gsap_timeline_registered_before_async_build",
        severity: "error",
        message:
          "window.__timelines is assigned BEFORE the timeline is built inside " +
          "document.fonts.ready. An empty timeline registered early gets nested empty " +
          "when this composition is used as a sub-composition (the readiness gate treats " +
          '"key present" as "ready" and never re-nests), so the animation renders blank.',
        fixHint:
          "Move the `window.__timelines[id] = tl;` assignment to the END of the " +
          "document.fonts.ready callback, after the tweens are added. Optionally call " +
          "window.__hfForceTimelineRebind() right after, to re-nest the populated timeline.",
      });
    }
    return findings;
  },

  // gsap_from_opacity_noop — CSS opacity:0 + gsap.from({opacity:0}) = invisible forever
  // fallow-ignore-next-line complexity
  async ({ styles, scripts, tags }) => {
    const findings: HyperframeLintFinding[] = [];
    const cssOpacityZeroSelectors = new Set<string>();

    // Single owner of "this declaration list sets opacity to EXACTLY zero" —
    // boundary-anchored so `opacity: 0.98` never matches. Works for both a CSS
    // block body (brace already stripped by the block regex) and an inline
    // style attribute: the declaration ends at `;` or at end of input, which
    // also catches a final declaration without a trailing semicolon.
    const opacityExactlyZero = /opacity\s*:\s*0(?:\.0+)?\s*(?:;|$)/;

    for (const style of styles) {
      for (const [, selector, body] of style.content.matchAll(
        /([#.][a-zA-Z0-9_-]+)\s*\{([^}]+)\}/g,
      )) {
        if (body && opacityExactlyZero.test(body)) {
          cssOpacityZeroSelectors.add((selector ?? "").trim());
        }
      }
    }

    for (const tag of tags) {
      const inlineStyle = readAttr(tag.raw, "style");
      if (!inlineStyle || !opacityExactlyZero.test(inlineStyle)) continue;
      const id = readAttr(tag.raw, "id");
      const classes = readAttr(tag.raw, "class")?.split(/\s+/).filter(Boolean) ?? [];
      if (id) cssOpacityZeroSelectors.add(`#${id}`);
      for (const cls of classes) cssOpacityZeroSelectors.add(`.${cls}`);
    }

    if (cssOpacityZeroSelectors.size === 0) return findings;

    for (const script of scripts) {
      if (!/gsap\.timeline/.test(script.content)) continue;
      const windows = await cachedExtractGsapWindows(script.content);

      for (const win of windows) {
        if (win.method !== "from") continue;
        if (!win.properties.includes("opacity")) continue;
        // Only a noop when the tween animates FROM 0 (same as the CSS value)
        if (win.propertyValues["opacity"] !== 0) continue;
        const sel = win.targetSelector;
        const cssKey = sel.startsWith("#") || sel.startsWith(".") ? sel : `#${sel}`;
        if (!cssOpacityZeroSelectors.has(cssKey)) continue;

        findings.push({
          code: "gsap_from_opacity_noop",
          severity: "error",
          message:
            `"${sel}" has CSS \`opacity: 0\` and a gsap.${win.method}() that also sets opacity to 0. ` +
            `gsap.from() animates FROM the specified value TO the current CSS value — ` +
            `since CSS is already 0, the element animates from 0→0 and never becomes visible.`,
          selector: sel,
          fixHint:
            `Remove \`opacity: 0\` from the CSS/inline style on "${sel}". ` +
            `Let gsap.from({opacity: 0}) handle the initial hidden state — ` +
            `it will animate FROM 0 TO the CSS value (1 by default).`,
          snippet: truncateSnippet(win.raw),
        });
      }
    }
    return findings;
  },

  // gsap_non_transform_motion — animating layout props (left/top/right/bottom/margin*)
  // or using roundProps snaps motion to integer device pixels. On the seek-by-frame
  // capture engine this looks smooth at high per-frame deltas (fast tweens) but visibly
  // stutters at low deltas (slow tweens / ease-out tails): sub-pixel movement rounds to
  // the same pixel for several frames, then jumps a whole pixel. Transforms (x/y/scale)
  // interpolate sub-pixel and stay smooth.
  //
  // EXEMPTION: elements rasterized via the html-in-canvas API — those under a
  // `<canvas layoutsubtree>` ancestor (e.g. the liquid-glass blocks) — are NOT laid out
  // by the browser compositor. The canvas lib reads getComputedStyle().left/top (a
  // sub-pixel value) and draws the element to a bitmap, so animating a layout prop on
  // them does not integer-snap and does not stutter. We resolve each tween's target to
  // its element(s) and skip the finding only when EVERY target is html-in-canvas; a
  // grouped tween that also touches a plain-DOM element (which does stutter) still fires.
  //
  // No suppression by design: there is intentionally no per-line/per-file opt-out (unlike
  // eslint-disable). The stance is fix-the-motion, not silence-the-rule — a plain-DOM
  // layout-prop animation always has a faithful transform equivalent (per-glyph x for
  // spacing, scale for size, x/y for position). An author who has consciously accepted a
  // stutter still has no flag to flip; that is deliberate, not a missing feature.
  async ({ scripts, tags, source }) => {
    const findings: HyperframeLintFinding[] = [];

    // Byte-ranges of every <canvas layoutsubtree>. An element whose open-tag index falls
    // inside one of these ranges is html-in-canvas composited.
    const layoutSubtreeRanges = tags
      .filter((t) => t.name.toLowerCase() === "canvas" && /\blayoutsubtree\b/i.test(t.raw))
      .map((t) => ({ start: t.index, end: findTagEnd(source, t) }));
    const isHtmlInCanvas = (tag: OpenTag): boolean =>
      layoutSubtreeRanges.some((r) => tag.index > r.start && tag.index < r.end);

    // Resolve a simple #id / .class token to the element tag(s) it matches.
    const tagsByToken = new Map<string, OpenTag[]>();
    const addToken = (token: string, tag: OpenTag): void => {
      const list = tagsByToken.get(token);
      if (list) list.push(tag);
      else tagsByToken.set(token, [tag]);
    };
    for (const tag of tags) {
      const id = readAttr(tag.raw, "id");
      if (id) addToken(`#${id}`, tag);
      for (const cls of readAttr(tag.raw, "class")?.split(/\s+/).filter(Boolean) ?? [])
        addToken(`.${cls}`, tag);
    }

    // True only when the selector resolves to at least one element AND every resolved
    // element is html-in-canvas. Unresolvable selectors (no match) are NOT exempt — we
    // stay conservative and let the finding fire rather than risk a false negative.
    const allTargetsHtmlInCanvas = (selector: string): boolean => {
      if (layoutSubtreeRanges.length === 0) return false;
      const matched = [...targetedSelectorTokens(selector)].flatMap(
        (token) => tagsByToken.get(token) ?? [],
      );
      return matched.length > 0 && matched.every(isHtmlInCanvas);
    };
    // Positional layout props → each maps to its transform replacement axis (x/y).
    const LAYOUT_FIX: Record<string, string[]> = {
      left: ["x"],
      right: ["x"],
      top: ["y"],
      bottom: ["y"],
      margin: ["x", "y"],
      marginLeft: ["x"],
      marginRight: ["x"],
      marginTop: ["y"],
      marginBottom: ["y"],
    };
    // Text-reflow props: animating them reflows text and snaps glyph positions to the
    // pixel grid, stuttering on slow motion exactly like positional props. They have no
    // transform replacement (the fix is to not animate them — settle via scale or hold the
    // value), and the snap happens during browser layout, UPSTREAM of any canvas raster, so
    // they are never html-in-canvas-exempt. (width/height are deliberately omitted: they
    // have legitimate animated uses — progress bars, reveals — and would over-report.)
    const REFLOW_PROPS = ["letterSpacing", "wordSpacing", "fontSize"];
    // Resolve the parser once, above the loop (the other async rules in this file do the
    // same); the dynamic-import cache makes per-iteration calls equivalent, but hoisting
    // keeps the placement from reading as load-bearing.
    const parseGsapScript = await loadParseGsapScript();
    for (const script of scripts) {
      if (!/gsap\.timeline/.test(script.content)) continue;

      // Two sources: timeline-rooted tweens (tl.to/from/fromTo) and standalone
      // gsap.to/from/fromTo calls the acorn parser ignores.
      //
      // Timeline tweens come straight from the acorn parser's animation list — NOT
      // cachedExtractGsapWindows, which drops every tween with a non-numeric timeline
      // position (a string label or `+=`/`-=` offset, e.g. `tl.to("#x",{left:9},"hold6")`).
      // Position is irrelevant to whether a tween animates a layout prop, so dropping
      // those would let real stutter-prone tweens escape. The parser also gives real AST
      // keys, so a nested `{}` value (an onComplete body, modifiers) and a layout-prop
      // name appearing inside a string value can't be misread — both hazards of a raw scan.
      const parsed = parseGsapScript(script.content);
      const calls: GsapTransformCall[] = [
        ...parsed.animations.map((anim) => ({
          method: anim.method,
          selector: anim.targetSelector,
          // Union the from-vars: a fromTo() can animate a layout/reflow prop that appears
          // only in its first ("from") object, which is just as stutter-prone as the to-vars.
          properties: [
            ...new Set([
              ...Object.keys(anim.properties),
              ...Object.keys(anim.fromProperties ?? {}),
            ]),
          ],
          raw: synthesizeWindowRaw(parsed.timelineVar, anim),
        })),
        ...extractStandaloneGsapTransformCalls(stripJsComments(script.content)),
      ];

      for (const call of calls) {
        // set() is instantaneous — it never animates, so it cannot stutter. A set() that
        // seats an integer-snapped layout position (e.g. tl.set("#x",{left:100})) before a
        // later transform tween is a single from-state frame, not motion; intentionally skipped.
        if (call.method === "set") continue;
        // Object.hasOwn, not `in`: a tween property named `toString`/`constructor` would
        // match the prototype chain and resolve LAYOUT_FIX[p] to an inherited function.
        let layoutProps = call.properties.filter((p) => Object.hasOwn(LAYOUT_FIX, p));
        const reflowProps = call.properties.filter((p) => REFLOW_PROPS.includes(p));
        const usesRoundProps = call.properties.includes("roundProps");
        // Only positional props are html-in-canvas-exempt: the canvas positions the draw
        // from sub-pixel computed left/top. Reflow props (glyph layout) and roundProps
        // (value rounding) snap upstream of the raster, so they always fire.
        if (layoutProps.length > 0 && allTargetsHtmlInCanvas(call.selector)) layoutProps = [];
        if (layoutProps.length === 0 && reflowProps.length === 0 && !usesRoundProps) continue;

        const flagged = [...layoutProps, ...reflowProps, ...(usesRoundProps ? ["roundProps"] : [])];
        const message =
          `GSAP tween on "${call.selector}" uses motion that snaps to integer device pixels: ` +
          `${flagged.join(", ")}. Layout and text-reflow properties snap during browser layout; ` +
          "roundProps rounds the tween value. Slow motion or an ease-out tail then stutters under " +
          "the seek-by-frame capture engine — animate transforms (x/y/scale/opacity) instead.";

        const fixes: string[] = [];
        if (layoutProps.length > 0) {
          const tokens = [...new Set(layoutProps.flatMap((p) => LAYOUT_FIX[p] ?? []))];
          fixes.push(
            `replace ${layoutProps.join("/")} with the transform equivalent (${tokens.join(", ")}) — ` +
              `e.g. tl.fromTo("${call.selector}", { x: -1300 }, { x: 0, ...yourAnimation })`,
          );
        }
        if (reflowProps.length > 0) {
          // Faithful fix differs by property: fontSize maps to scale (same visual), but
          // letterSpacing/wordSpacing do NOT — uniform scale resizes glyphs, it does not
          // change the gaps between them. The smooth equivalent of a spacing tween is a
          // per-glyph split with an x transform per character.
          const sizing = reflowProps.filter((p) => p === "fontSize");
          const spacing = reflowProps.filter((p) => p !== "fontSize");
          const parts: string[] = [];
          if (sizing.length > 0) {
            parts.push(`replace ${sizing.join("/")} with scale (same visual, no reflow)`);
          }
          if (spacing.length > 0) {
            parts.push(
              `for ${spacing.join("/")}, split the text into per-character elements and animate ` +
                "each glyph's x (the spread) — uniform scale is NOT equivalent — or hold the final value statically",
            );
          }
          fixes.push(
            `do not animate ${reflowProps.join("/")} (they reflow text and snap glyph positions): ` +
              parts.join("; "),
          );
        }
        if (usesRoundProps) fixes.push("remove roundProps");
        const fixHint = `${fixes.join("; ")}. Transforms interpolate sub-pixel and stay smooth at any speed.`;

        findings.push({
          code: "gsap_non_transform_motion",
          severity: "error",
          message,
          selector: call.selector,
          fixHint,
          snippet: truncateSnippet(call.raw),
        });
      }
    }
    return findings;
  },

  // gsap_group_selector_keyframes
  ({ scripts }) => {
    const findings: HyperframeLintFinding[] = [];
    const pattern = /\.(?:to|from|fromTo)\(\s*["']([^"']+,\s*[^"']+)["']\s*,\s*\{[^}]*keyframes/g;
    for (const { match, snippet } of scanScriptsForRegexMatches(scripts, pattern, {
      stripComments: true,
      contextBefore: 20,
      contextAfter: 40,
    })) {
      const selector = match[1]!;
      const count = selector.split(",").length;
      findings.push({
        code: "gsap_group_selector_keyframes",
        severity: "warning",
        message:
          `GSAP tween targets ${count} elements with shared keyframes ("${truncateSnippet(selector, 60)}"). ` +
          `Editing one element's keyframes in Studio will affect all ${count} elements. ` +
          `Split into individual tweens for per-element keyframe control.`,
        fixHint:
          `Replace the group selector with individual tl.to() calls per element, ` +
          `each with their own keyframes object.`,
        snippet: truncateSnippet(snippet),
      });
    }
    return findings;
  },
];
