import { describe, it, expect } from "vitest";
import { lintHyperframeHtml } from "../hyperframeLinter.js";

describe("composition rules", () => {
  describe("subcomposition guidance", () => {
    it("warns when any HTML composition file is over 300 lines", async () => {
      const html = Array.from({ length: 301 }, (_, i) =>
        i === 0 ? "<html><body>" : `<!-- filler ${i} -->`,
      ).join("\n");

      const result = await lintHyperframeHtml(html, {
        filePath: "/project/compositions/scene.html",
      });
      const finding = result.findings.find((f) => f.code === "composition_file_too_large");
      expect(finding).toBeDefined();
      expect(finding?.severity).toBe("warning");
    });

    it("does not warn when an HTML composition file is exactly 300 lines", async () => {
      const html = Array.from({ length: 300 }, (_, i) =>
        i === 0 ? "<html><body>" : `<!-- filler ${i} -->`,
      ).join("\n");

      const result = await lintHyperframeHtml(html, { filePath: "/project/index.html" });
      const finding = result.findings.find((f) => f.code === "composition_file_too_large");
      expect(finding).toBeUndefined();
    });

    it("does not count a final trailing newline as an extra physical line", async () => {
      const html =
        Array.from({ length: 300 }, (_, i) =>
          i === 0 ? "<html><body>" : `<!-- filler ${i} -->`,
        ).join("\n") + "\n";

      const result = await lintHyperframeHtml(html, { filePath: "/project/index.html" });
      const finding = result.findings.find((f) => f.code === "composition_file_too_large");
      expect(finding).toBeUndefined();
    });

    it("does not count inline style block internals as structural lines", async () => {
      const style = `<style>\n${Array.from({ length: 320 }, (_, i) => `.rule-${i} { color: red; }`).join("\n")}\n</style>`;
      const html = `<!doctype html>
<html>
  <head>${style}</head>
  <body>
    <div data-composition-id="main" data-start="0" data-duration="1">TEXT</div>
  </body>
</html>`;

      const result = await lintHyperframeHtml(html, { filePath: "/project/index.html" });
      const finding = result.findings.find((f) => f.code === "composition_file_too_large");
      expect(finding).toBeUndefined();
    });

    it("does not warn for large registry source block files", async () => {
      const html = Array.from({ length: 301 }, (_, i) =>
        i === 0 ? "<html><body>" : `<!-- filler ${i} -->`,
      ).join("\n");

      const result = await lintHyperframeHtml(html, {
        filePath: "/project/registry/blocks/data-chart/data-chart.html",
      });
      const finding = result.findings.find((f) => f.code === "composition_file_too_large");
      expect(finding).toBeUndefined();
    });

    it("warns for large installed block composition files", async () => {
      const html = Array.from({ length: 301 }, (_, i) =>
        i === 0 ? "<html><body>" : `<!-- filler ${i} -->`,
      ).join("\n");

      const result = await lintHyperframeHtml(html, {
        filePath: "/project/compositions/data-chart.html",
      });
      const finding = result.findings.find((f) => f.code === "composition_file_too_large");
      expect(finding).toBeDefined();
      expect(finding?.severity).toBe("warning");
    });

    it("does not warn for large registry-installed block composition files", async () => {
      const html =
        "<!-- hyperframes-registry-item: data-chart -->\n" +
        Array.from({ length: 300 }, (_, i) =>
          i === 0 ? "<html><body>" : `<!-- filler ${i} -->`,
        ).join("\n");

      const result = await lintHyperframeHtml(html, {
        filePath: "/project/compositions/data-chart.html",
      });
      const finding = result.findings.find((f) => f.code === "composition_file_too_large");
      expect(finding).toBeUndefined();
    });

    it("uses nested split copy for large sub-composition files", async () => {
      const html = Array.from({ length: 301 }, (_, i) =>
        i === 0 ? "<html><body>" : `<!-- filler ${i} -->`,
      ).join("\n");

      const result = await lintHyperframeHtml(html, {
        filePath: "/project/compositions/scene.html",
        isSubComposition: true,
      });
      const finding = result.findings.find((f) => f.code === "composition_file_too_large");
      expect(finding?.fixHint).toContain("Split this sub-composition further");
    });

    it("warns when more than 3 timed elements share the same track", async () => {
      const html = `<!DOCTYPE html>
<html><body>
  <div data-composition-id="main" data-width="1920" data-height="1080" data-start="0">
    <div class="clip" data-start="0" data-duration="1" data-track-index="0">A</div>
    <div class="clip" data-start="1" data-duration="1" data-track-index="0">B</div>
    <div class="clip" data-start="2" data-duration="1" data-track-index="0">C</div>
    <div class="clip" data-start="3" data-duration="1" data-track-index="0">D</div>
  </div>
</body></html>`;

      const result = await lintHyperframeHtml(html, {
        filePath: "/project/compositions/scene.html",
      });
      const finding = result.findings.find((f) => f.code === "timeline_track_too_dense");
      expect(finding).toBeDefined();
      expect(finding?.severity).toBe("warning");
      expect(finding?.message).toContain("Track 0 has 4 timed elements");
    });

    it("does not warn when 3 timed elements share the same track", async () => {
      const html = `<!DOCTYPE html>
<html><body>
  <div data-composition-id="main" data-width="1920" data-height="1080" data-start="0">
    <div class="clip" data-start="0" data-duration="1" data-track-index="0">A</div>
    <div class="clip" data-start="1" data-duration="1" data-track-index="0">B</div>
    <div class="clip" data-start="2" data-duration="1" data-track-index="0">C</div>
  </div>
</body></html>`;

      const result = await lintHyperframeHtml(html, { filePath: "/project/index.html" });
      const finding = result.findings.find((f) => f.code === "timeline_track_too_dense");
      expect(finding).toBeUndefined();
    });

    it("does not warn when timed elements are split across tracks", async () => {
      const html = `<!DOCTYPE html>
<html><body>
  <div data-composition-id="main" data-width="1920" data-height="1080" data-start="0">
    <div class="clip" data-start="0" data-duration="1" data-track-index="0">A</div>
    <div class="clip" data-start="1" data-duration="1" data-track-index="0">B</div>
    <div class="clip" data-start="2" data-duration="1" data-track-index="1">C</div>
    <div class="clip" data-start="3" data-duration="1" data-track-index="1">D</div>
  </div>
</body></html>`;

      const result = await lintHyperframeHtml(html, { filePath: "/project/index.html" });
      const finding = result.findings.find((f) => f.code === "timeline_track_too_dense");
      expect(finding).toBeUndefined();
    });

    it("does not count timed media or script/style tags as dense track elements", async () => {
      const html = `<!DOCTYPE html>
<html><body>
  <div data-composition-id="main" data-width="1920" data-height="1080" data-start="0">
    <audio data-start="0" data-duration="1" data-track-index="0"></audio>
    <audio data-start="1" data-duration="1" data-track-index="0"></audio>
    <video muted data-start="2" data-duration="1" data-track-index="0"></video>
    <script data-start="3" data-duration="1" data-track-index="0"></script>
    <style data-start="4" data-duration="1" data-track-index="0"></style>
  </div>
</body></html>`;

      const result = await lintHyperframeHtml(html, { filePath: "/project/index.html" });
      const finding = result.findings.find((f) => f.code === "timeline_track_too_dense");
      expect(finding).toBeUndefined();
    });

    it("does not count root composition or mounted sub-compositions as dense elements", async () => {
      const html = `<!DOCTYPE html>
<html><body>
  <div data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-track-index="0">
    <div data-composition-id="a" data-composition-src="compositions/a.html" data-start="0" data-duration="1" data-track-index="0"></div>
    <div data-composition-id="b" data-composition-src="compositions/b.html" data-start="1" data-duration="1" data-track-index="0"></div>
    <div data-composition-id="c" data-composition-src="compositions/c.html" data-start="2" data-duration="1" data-track-index="0"></div>
    <div data-composition-id="d" data-composition-src="compositions/d.html" data-start="3" data-duration="1" data-track-index="0"></div>
  </div>
</body></html>`;

      const result = await lintHyperframeHtml(html, { filePath: "/project/index.html" });
      const finding = result.findings.find((f) => f.code === "timeline_track_too_dense");
      expect(finding).toBeUndefined();
    });
  });

  it("reports error when querySelector uses template literal variable", async () => {
    const html = `
<html><body>
  <div data-composition-id="main" data-width="1920" data-height="1080">
    <div class="chart"></div>
  </div>
  <script>
    window.__timelines = window.__timelines || {};
    const compId = "main";
    const el = document.querySelector(\`[data-composition-id="\${compId}"] .chart\`);
    const tl = gsap.timeline({ paused: true });
    window.__timelines["main"] = tl;
  </script>
</body></html>`;
    const result = await lintHyperframeHtml(html);
    const finding = result.findings.find((f) => f.code === "template_literal_selector");
    expect(finding).toBeDefined();
    expect(finding?.severity).toBe("error");
  });

  it("reports error for querySelectorAll with template literal variable", async () => {
    const html = `
<html><body>
  <div data-composition-id="main" data-width="1920" data-height="1080"></div>
  <script>
    window.__timelines = window.__timelines || {};
    const id = "main";
    document.querySelectorAll(\`[data-composition-id="\${id}"] .item\`);
    const tl = gsap.timeline({ paused: true });
    window.__timelines["main"] = tl;
  </script>
</body></html>`;
    const result = await lintHyperframeHtml(html);
    const finding = result.findings.find((f) => f.code === "template_literal_selector");
    expect(finding).toBeDefined();
  });

  it("does not report error for hardcoded querySelector strings", async () => {
    const html = `
<html><body>
  <div data-composition-id="main" data-width="1920" data-height="1080">
    <div class="chart"></div>
  </div>
  <script>
    window.__timelines = window.__timelines || {};
    const el = document.querySelector('[data-composition-id="main"] .chart');
    const tl = gsap.timeline({ paused: true });
    window.__timelines["main"] = tl;
  </script>
</body></html>`;
    const result = await lintHyperframeHtml(html);
    const finding = result.findings.find((f) => f.code === "template_literal_selector");
    expect(finding).toBeUndefined();
  });

  it("reports error when a selector combines data attributes in one bracket", async () => {
    const html = `
<template id="scene-template">
  <div data-composition-id="scene" data-start="0" data-width="1920" data-height="1080">
    <style>
      [data-composition-id="scene" data-start="0"] .title { opacity: 0; }
    </style>
    <script>
      window.__timelines = window.__timelines || {};
      const title = document.querySelector('[data-composition-id="scene" data-start="0"] .title');
      const tl = gsap.timeline({ paused: true });
      tl.to('[data-composition-id="scene" data-start="0"]', { opacity: 0, duration: 0.5 }, 4);
      window.__timelines["scene"] = tl;
    </script>
  </div>
</template>`;
    const result = await lintHyperframeHtml(html, { filePath: "compositions/scene.html" });
    const findings = result.findings.filter((f) => f.code === "split_data_attribute_selector");
    expect(findings.length).toBe(1);
    expect(findings[0]?.severity).toBe("error");
    expect(findings[0]?.fixHint).toContain('[data-composition-id="scene"][data-start="0"]');
  });

  describe("timed_element_missing_clip_class", () => {
    it("flags element with data-start but no class='clip'", async () => {
      const html = `
<html><body>
  <div data-composition-id="c1" data-width="1920" data-height="1080">
    <div id="box" data-start="0" data-duration="2">Hello</div>
  </div>
  <script>
    window.__timelines = window.__timelines || {};
    window.__timelines["c1"] = gsap.timeline({ paused: true });
  </script>
</body></html>`;
      const result = await lintHyperframeHtml(html);
      const finding = result.findings.find((f) => f.code === "timed_element_missing_clip_class");
      expect(finding).toBeDefined();
      expect(finding?.severity).toBe("error");
    });

    it("does not flag element that has class='clip'", async () => {
      const html = `
<html><body>
  <div data-composition-id="c1" data-width="1920" data-height="1080">
    <div id="box" class="clip" data-start="0" data-duration="2">Hello</div>
  </div>
  <script>
    window.__timelines = window.__timelines || {};
    window.__timelines["c1"] = gsap.timeline({ paused: true });
  </script>
</body></html>`;
      const result = await lintHyperframeHtml(html);
      const finding = result.findings.find((f) => f.code === "timed_element_missing_clip_class");
      expect(finding).toBeUndefined();
    });

    it("does not flag audio or video elements", async () => {
      const html = `
<html><body>
  <div data-composition-id="c1" data-width="1920" data-height="1080">
    <audio data-start="0" data-duration="5" src="music.mp3"></audio>
    <video data-start="0" data-duration="5" src="clip.mp4"></video>
  </div>
  <script>
    window.__timelines = window.__timelines || {};
    window.__timelines["c1"] = gsap.timeline({ paused: true });
  </script>
</body></html>`;
      const result = await lintHyperframeHtml(html);
      const finding = result.findings.find((f) => f.code === "timed_element_missing_clip_class");
      expect(finding).toBeUndefined();
    });

    it("does not flag element with only data-track-index (layer container, no timing)", async () => {
      const html = `
<html><body>
  <div data-composition-id="c1" data-width="1920" data-height="1080">
    <div id="layer" data-track-index="0">
      <div id="box" class="clip" data-start="0" data-duration="2">Hello</div>
    </div>
  </div>
  <script>
    window.__timelines = window.__timelines || {};
    window.__timelines["c1"] = gsap.timeline({ paused: true });
  </script>
</body></html>`;
      const result = await lintHyperframeHtml(html);
      const finding = result.findings.find((f) => f.code === "timed_element_missing_clip_class");
      expect(finding).toBeUndefined();
    });
  });

  describe("overlapping_clips_same_track", () => {
    it("flags overlapping clips on the same track", async () => {
      const html = `
<html><body>
  <div data-composition-id="c1" data-width="1920" data-height="1080">
    <div class="clip" data-start="0" data-duration="3" data-track-index="0">A</div>
    <div class="clip" data-start="2" data-duration="3" data-track-index="0">B</div>
  </div>
  <script>
    window.__timelines = window.__timelines || {};
    window.__timelines["c1"] = gsap.timeline({ paused: true });
  </script>
</body></html>`;
      const result = await lintHyperframeHtml(html);
      const finding = result.findings.find((f) => f.code === "overlapping_clips_same_track");
      expect(finding).toBeDefined();
      expect(finding?.severity).toBe("error");
    });

    it("does not flag clips on different tracks", async () => {
      const html = `
<html><body>
  <div data-composition-id="c1" data-width="1920" data-height="1080">
    <div class="clip" data-start="0" data-duration="3" data-track-index="0">A</div>
    <div class="clip" data-start="1" data-duration="3" data-track-index="1">B</div>
  </div>
  <script>
    window.__timelines = window.__timelines || {};
    window.__timelines["c1"] = gsap.timeline({ paused: true });
  </script>
</body></html>`;
      const result = await lintHyperframeHtml(html);
      const finding = result.findings.find((f) => f.code === "overlapping_clips_same_track");
      expect(finding).toBeUndefined();
    });

    it("does not flag sequential clips on the same track", async () => {
      const html = `
<html><body>
  <div data-composition-id="c1" data-width="1920" data-height="1080">
    <div class="clip" data-start="0" data-duration="2" data-track-index="0">A</div>
    <div class="clip" data-start="2" data-duration="2" data-track-index="0">B</div>
  </div>
  <script>
    window.__timelines = window.__timelines || {};
    window.__timelines["c1"] = gsap.timeline({ paused: true });
  </script>
</body></html>`;
      const result = await lintHyperframeHtml(html);
      const finding = result.findings.find((f) => f.code === "overlapping_clips_same_track");
      expect(finding).toBeUndefined();
    });
  });

  describe("root_composition_missing_html_wrapper", () => {
    it("flags bare composition div as error", async () => {
      // Exact scenario from the screenshot — bare div with composition attributes, no HTML wrapper
      const html = `<div
  id="comp-main"
  data-composition-id="no-limits"
  data-start="0"
  data-duration="15"
  data-width="1920"
  data-height="1080"
>
  <!-- Sub-composition: the visual spectacle -->
  <div
    id="el-visuals"
    data-composition-id="visuals"
    data-composition-src="compositions/visuals.html"
    data-duration="15"
    data-track-index="0"
  ></div>

  <script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
  <script>
    window.__timelines = window.__timelines || {};
    const tl = gsap.timeline({ paused: true });
    window.__timelines["no-limits"] = tl;
  </script>
</div>`;
      const result = await lintHyperframeHtml(html, { filePath: "index.html" });
      const finding = result.findings.find(
        (f) => f.code === "root_composition_missing_html_wrapper",
      );
      expect(finding).toBeDefined();
      expect(finding?.severity).toBe("error");
      expect(result.ok).toBe(false);
    });

    it("does not flag properly wrapped HTML composition", async () => {
      const html = `<!DOCTYPE html>
<html><head><meta charset="UTF-8"></head><body>
  <div data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="10">
    <div class="clip" data-start="0" data-duration="5">Hello</div>
  </div>
  <script>
    window.__timelines = window.__timelines || {};
    window.__timelines["main"] = gsap.timeline({ paused: true });
  </script>
</body></html>`;
      const result = await lintHyperframeHtml(html);
      const finding = result.findings.find(
        (f) => f.code === "root_composition_missing_html_wrapper",
      );
      expect(finding).toBeUndefined();
    });

    it("does not flag composition starting with <html> (no doctype)", async () => {
      const html = `<html><body>
  <div data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="5"></div>
  <script>
    window.__timelines = window.__timelines || {};
    window.__timelines["main"] = gsap.timeline({ paused: true });
  </script>
</body></html>`;
      const result = await lintHyperframeHtml(html);
      const finding = result.findings.find(
        (f) => f.code === "root_composition_missing_html_wrapper",
      );
      expect(finding).toBeUndefined();
    });

    it("does not flag sub-compositions", async () => {
      const html = `<div data-composition-id="sub" data-width="1920" data-height="1080">
  <script>
    window.__timelines = window.__timelines || {};
    window.__timelines["sub"] = gsap.timeline({ paused: true });
  </script>
</div>`;
      const result = await lintHyperframeHtml(html, { isSubComposition: true });
      const finding = result.findings.find(
        (f) => f.code === "root_composition_missing_html_wrapper",
      );
      expect(finding).toBeUndefined();
    });

    it("does not flag HTML without composition attributes", async () => {
      const html = `<div id="hello"><p>Not a composition</p></div>`;
      const result = await lintHyperframeHtml(html);
      const finding = result.findings.find(
        (f) => f.code === "root_composition_missing_html_wrapper",
      );
      expect(finding).toBeUndefined();
    });

    it("includes root tag snippet in finding", async () => {
      const html = `<div data-composition-id="bare" data-width="1920" data-height="1080">
  <script>
    window.__timelines = window.__timelines || {};
    window.__timelines["bare"] = gsap.timeline({ paused: true });
  </script>
</div>`;
      const result = await lintHyperframeHtml(html);
      const finding = result.findings.find(
        (f) => f.code === "root_composition_missing_html_wrapper",
      );
      expect(finding).toBeDefined();
      expect(finding?.snippet).toContain("data-composition-id");
    });
  });

  describe("standalone_composition_wrapped_in_template", () => {
    it("flags root index.html wrapped in template", async () => {
      const html = `<template id="main-template">
  <div data-composition-id="main" data-width="1920" data-height="1080">
    <script>
      window.__timelines = window.__timelines || {};
      window.__timelines["main"] = gsap.timeline({ paused: true });
    </script>
  </div>
</template>`;
      const result = await lintHyperframeHtml(html);
      const finding = result.findings.find(
        (f) => f.code === "standalone_composition_wrapped_in_template",
      );
      expect(finding).toBeDefined();
      expect(finding?.severity).toBe("error");
    });

    it("does not flag sub-compositions in template", async () => {
      const html = `<template id="sub-template">
  <div data-composition-id="sub" data-width="1920" data-height="1080">
    <script>
      window.__timelines = window.__timelines || {};
      window.__timelines["sub"] = gsap.timeline({ paused: true });
    </script>
  </div>
</template>`;
      const result = await lintHyperframeHtml(html, { isSubComposition: true });
      const finding = result.findings.find(
        (f) => f.code === "standalone_composition_wrapped_in_template",
      );
      expect(finding).toBeUndefined();
    });
  });

  describe("requestanimationframe_in_composition", () => {
    it("flags requestAnimationFrame usage in script content", async () => {
      const html = `
<html><body>
  <div data-composition-id="c1" data-width="1920" data-height="1080"></div>
  <script>
    window.__timelines = window.__timelines || {};
    requestAnimationFrame(() => { console.log("tick"); });
    window.__timelines["c1"] = gsap.timeline({ paused: true });
  </script>
</body></html>`;
      const result = await lintHyperframeHtml(html);
      const finding = result.findings.find(
        (f) => f.code === "requestanimationframe_in_composition",
      );
      expect(finding).toBeDefined();
      expect(finding?.severity).toBe("error");
    });

    it("does not flag requestAnimationFrame in comments", async () => {
      const html = `
<html><body>
  <div data-composition-id="c1" data-width="1920" data-height="1080"></div>
  <script>
    window.__timelines = window.__timelines || {};
    // requestAnimationFrame(() => { });
    window.__timelines["c1"] = gsap.timeline({ paused: true });
  </script>
</body></html>`;
      const result = await lintHyperframeHtml(html);
      const finding = result.findings.find(
        (f) => f.code === "requestanimationframe_in_composition",
      );
      expect(finding).toBeUndefined();
    });

    it("does not flag installed registry blocks that use rAF (e.g. particle effects)", async () => {
      const html =
        `<!-- hyperframes-registry-item: particles -->\n` +
        `<html><body>
  <div data-composition-id="c1" data-width="1920" data-height="1080"></div>
  <script>
    window.__timelines = window.__timelines || {};
    requestAnimationFrame(function loop() { requestAnimationFrame(loop); });
    window.__timelines["c1"] = gsap.timeline({ paused: true });
  </script>
</body></html>`;
      const result = await lintHyperframeHtml(html);
      const finding = result.findings.find(
        (f) => f.code === "requestanimationframe_in_composition",
      );
      expect(finding).toBeUndefined();
    });
  });

  describe("root_composition_missing_data_duration (removed)", () => {
    // The rule was a static proxy for the runtime's loop-inflation Infinity
    // emission, but lint cannot observe GSAP timeline duration statically and
    // the looping shapes that drive it are already covered by
    // `gsap_infinite_repeat` and `gsap_repeat_ceil_overshoot`. The rule has
    // been removed (#243's Infinity-emission concern is now carried by those
    // GSAP rules); these tests pin the removal so the rule does not silently
    // come back.

    it("does not warn on a docs-compliant root with no data-duration", async () => {
      // The documented authoring model: root composition without
      // data-duration, runtime derives it from the GSAP timeline.
      const html = `<!DOCTYPE html><html><body>
  <div data-composition-id="docs" data-width="1920" data-height="1080" data-start="0">
    <video src="clip.mp4" data-start="0" data-track-index="0" muted playsinline></video>
  </div>
  <script>
    window.__timelines = window.__timelines || {};
    window.__timelines["docs"] = gsap.timeline({ paused: true });
  </script>
</body></html>`;
      const result = await lintHyperframeHtml(html);
      const finding = result.findings.find(
        (f) => f.code === "root_composition_missing_data_duration",
      );
      expect(finding).toBeUndefined();
    });

    it("does not warn even on the original Infinity-risk shape (no media, looping timeline)", async () => {
      // This was the canonical "warn" case under the old rule — root with no
      // data-duration, no media, GSAP timeline driven by repeat: -1. The
      // looping shape itself is now flagged by `gsap_infinite_repeat`; the
      // duplicate `root_composition_missing_data_duration` warning is gone.
      const html = `<!DOCTYPE html><html><body>
  <div data-composition-id="loopy" data-width="1920" data-height="1080" data-start="0">
    <div class="caption" data-start="1" data-duration="2">hello</div>
  </div>
  <script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
  <script>
    window.__timelines = window.__timelines || {};
    const tl = gsap.timeline({ paused: true });
    tl.to(".caption", { x: 100, duration: 1, repeat: -1 });
    window.__timelines["loopy"] = tl;
  </script>
</body></html>`;
      const result = await lintHyperframeHtml(html);
      // The deprecated rule must not fire.
      const removedFinding = result.findings.find(
        (f) => f.code === "root_composition_missing_data_duration",
      );
      expect(removedFinding).toBeUndefined();
      // The looping shape is still surfaced — by `gsap_infinite_repeat`,
      // which is the more actionable signal pointing at the real authoring
      // mistake.
      const gsapFinding = result.findings.find((f) => f.code === "gsap_infinite_repeat");
      expect(gsapFinding).toBeDefined();
    });
  });

  describe("root_composition_missing_data_start", () => {
    it("does not warn for template-wrapped sub-composition files", async () => {
      const html = `
<template id="foo-template">
  <div data-composition-id="foo" data-width="1920" data-height="1080">
    <div class="clip" data-start="0" data-duration="1"></div>
  </div>
</template>`;
      const result = await lintHyperframeHtml(html, { isSubComposition: true });
      const finding = result.findings.find((f) => f.code === "root_composition_missing_data_start");
      expect(finding).toBeUndefined();
    });
  });

  describe("invalid_variable_values_json", () => {
    it("warns when data-variable-values is unparseable JSON", async () => {
      const html = `<html><body>
<div data-composition-id="card-1" data-composition-src="card.html" data-variable-values='{not json'></div>
</body></html>`;
      const result = await lintHyperframeHtml(html);
      const finding = result.findings.find((f) => f.code === "invalid_variable_values_json");
      expect(finding).toBeDefined();
      expect(finding?.severity).toBe("error");
    });

    it("warns when data-variable-values is a JSON array (must be an object)", async () => {
      const html = `<html><body>
<div data-composition-src="card.html" data-variable-values='[1,2,3]'></div>
</body></html>`;
      const result = await lintHyperframeHtml(html);
      const finding = result.findings.find((f) => f.code === "invalid_variable_values_json");
      expect(finding).toBeDefined();
      expect(finding?.message).toMatch(/must be a JSON object/);
    });

    it("warns when data-variable-values is a JSON string (must be an object)", async () => {
      const html = `<html><body>
<div data-composition-src="card.html" data-variable-values='"hello"'></div>
</body></html>`;
      const result = await lintHyperframeHtml(html);
      const finding = result.findings.find((f) => f.code === "invalid_variable_values_json");
      expect(finding).toBeDefined();
    });

    it("does not warn for a valid JSON object", async () => {
      const html = `<html><body>
<div data-composition-src="card.html" data-variable-values='{"title":"Hello","count":3}'></div>
</body></html>`;
      const result = await lintHyperframeHtml(html);
      const finding = result.findings.find((f) => f.code === "invalid_variable_values_json");
      expect(finding).toBeUndefined();
    });

    it("does not warn when data-variable-values is absent", async () => {
      const html = `<html><body>
<div data-composition-src="card.html"></div>
</body></html>`;
      const result = await lintHyperframeHtml(html);
      const finding = result.findings.find((f) => f.code === "invalid_variable_values_json");
      expect(finding).toBeUndefined();
    });
  });

  describe("invalid_composition_variables_declaration", () => {
    it("warns when data-composition-variables is unparseable JSON", async () => {
      const html = `<html data-composition-variables='[{not json'><body><div data-composition-id="x"></div></body></html>`;
      const result = await lintHyperframeHtml(html);
      const finding = result.findings.find(
        (f) => f.code === "invalid_composition_variables_declaration",
      );
      expect(finding).toBeDefined();
    });

    it("warns when data-composition-variables is not an array", async () => {
      const html = `<html data-composition-variables='{"title":"Hello"}'><body><div data-composition-id="x"></div></body></html>`;
      const result = await lintHyperframeHtml(html);
      const finding = result.findings.find(
        (f) => f.code === "invalid_composition_variables_declaration",
      );
      expect(finding).toBeDefined();
      expect(finding?.message).toMatch(/array of variable declarations/);
    });

    it("warns per-entry when an entry is missing required fields", async () => {
      const html = `<html data-composition-variables='[{"id":"ok","type":"string","label":"Ok","default":"x"},{"id":"bad"}]'><body><div data-composition-id="x"></div></body></html>`;
      const result = await lintHyperframeHtml(html);
      const findings = result.findings.filter(
        (f) => f.code === "invalid_composition_variables_declaration",
      );
      expect(findings.length).toBe(1);
      expect(findings[0]?.message).toMatch(/\[1\]/);
      expect(findings[0]?.message).toMatch(/type|label|default/);
    });

    it("warns when a declaration uses an unknown type", async () => {
      const html = `<html data-composition-variables='[{"id":"x","type":"date","label":"X","default":"y"}]'><body><div data-composition-id="x"></div></body></html>`;
      const result = await lintHyperframeHtml(html);
      const finding = result.findings.find(
        (f) => f.code === "invalid_composition_variables_declaration",
      );
      expect(finding).toBeDefined();
      expect(finding?.message).toMatch(/type/);
    });

    it("does not warn for a fully valid declarations array", async () => {
      const html = `<html data-composition-variables='[
        {"id":"title","type":"string","label":"Title","default":"Hello"},
        {"id":"count","type":"number","label":"Count","default":3},
        {"id":"theme","type":"enum","label":"Theme","default":"light","options":[{"value":"light","label":"Light"}]}
      ]'><body><div data-composition-id="x"></div></body></html>`;
      const result = await lintHyperframeHtml(html);
      const finding = result.findings.find(
        (f) => f.code === "invalid_composition_variables_declaration",
      );
      expect(finding).toBeUndefined();
    });

    it("does not warn when data-composition-variables is absent", async () => {
      const html = `<html><body><div data-composition-id="x"></div></body></html>`;
      const result = await lintHyperframeHtml(html);
      const finding = result.findings.find(
        (f) => f.code === "invalid_composition_variables_declaration",
      );
      expect(finding).toBeUndefined();
    });
  });

  describe("invalid_capture_path", () => {
    it("errors when an <img> src uses ../capture/", async () => {
      const html = `<html><body>
        <div data-composition-id="x">
          <img src="../capture/assets/logo.svg" alt="logo">
        </div>
      </body></html>`;
      const result = await lintHyperframeHtml(html, {
        filePath: "/project/compositions/scene.html",
      });
      const finding = result.findings.find((f) => f.code === "invalid_capture_path");
      expect(finding).toBeDefined();
      expect(finding?.severity).toBe("error");
    });

    it("errors when a CSS url() uses ../capture/ (counts all occurrences)", async () => {
      const html = `<html><body>
        <style>
          @font-face { font-family: 'Brand'; src: url('../capture/assets/fonts/Brand.woff2'); }
          .hero { background-image: url('../capture/assets/hero.png'); }
        </style>
        <div data-composition-id="x"></div>
      </body></html>`;
      const result = await lintHyperframeHtml(html, {
        filePath: "/project/compositions/scene.html",
      });
      const finding = result.findings.find((f) => f.code === "invalid_capture_path");
      expect(finding).toBeDefined();
      expect(finding?.message).toContain("2 asset path(s)");
    });

    it("does not flag root-relative capture/ paths", async () => {
      const html = `<html><body>
        <div data-composition-id="x">
          <img src="capture/assets/logo.svg" alt="logo">
        </div>
        <style>.hero { background-image: url('capture/assets/hero.png'); }</style>
      </body></html>`;
      const result = await lintHyperframeHtml(html, {
        filePath: "/project/compositions/scene.html",
      });
      const finding = result.findings.find((f) => f.code === "invalid_capture_path");
      expect(finding).toBeUndefined();
    });
  });

  describe("subcomposition_blanks_before_host", () => {
    const find = (findings: Array<{ code: string }>) =>
      findings.find((f) => f.code === "subcomposition_blanks_before_host");

    it("fires on the issue #1540 shape (child shorter than host)", async () => {
      const html = `<html><body>
        <div id="root" data-composition-id="main" data-start="0" data-duration="331.224" data-width="1920" data-height="1080">
          <div id="decision-tree-comp" data-composition-id="decision-tree" data-composition-src="compositions/decision_tree.html" data-start="0" data-duration="15"></div>
        </div>
      </body></html>`;
      const result = await lintHyperframeHtml(html, { filePath: "/project/index.html" });
      const finding = find(result.findings);
      expect(finding).toBeDefined();
      expect(finding?.severity).toBe("warning");
      expect(finding?.message).toContain("blank");
      expect(finding?.message).toContain("331.224");
    });

    it("fires when the mount starts within the start tolerance", async () => {
      const html = `<html><body>
        <div id="root" data-composition-id="main" data-start="0" data-duration="60">
          <div data-composition-id="sub" data-composition-src="compositions/sub.html" data-start="0.3" data-duration="15"></div>
        </div>
      </body></html>`;
      const result = await lintHyperframeHtml(html, { filePath: "/project/index.html" });
      expect(find(result.findings)).toBeDefined();
    });

    it("fires at exactly the start tolerance boundary (start=0.5)", async () => {
      const html = `<html><body>
        <div id="root" data-composition-id="main" data-start="0" data-duration="60">
          <div data-composition-id="sub" data-composition-src="compositions/sub.html" data-start="0.5" data-duration="15"></div>
        </div>
      </body></html>`;
      const result = await lintHyperframeHtml(html, { filePath: "/project/index.html" });
      expect(find(result.findings)).toBeDefined();
    });

    it("stays silent on an intentional short intro followed by another clip", async () => {
      const html = `<html><body>
        <div id="root" data-composition-id="main" data-start="0" data-duration="60">
          <div data-composition-id="intro" data-composition-src="compositions/intro.html" data-start="0" data-duration="15"></div>
          <div data-composition-id="body" data-composition-src="compositions/body.html" data-start="15" data-duration="45"></div>
        </div>
      </body></html>`;
      const result = await lintHyperframeHtml(html, { filePath: "/project/index.html" });
      expect(find(result.findings)).toBeUndefined();
    });

    it("stays silent when the child matches the host window", async () => {
      const html = `<html><body>
        <div id="root" data-composition-id="main" data-start="0" data-duration="15">
          <div data-composition-id="sub" data-composition-src="compositions/sub.html" data-start="0" data-duration="15"></div>
        </div>
      </body></html>`;
      const result = await lintHyperframeHtml(html, { filePath: "/project/index.html" });
      expect(find(result.findings)).toBeUndefined();
    });

    it("stays silent when the child is longer than the host", async () => {
      const html = `<html><body>
        <div id="root" data-composition-id="main" data-start="0" data-duration="15">
          <div data-composition-id="sub" data-composition-src="compositions/sub.html" data-start="0" data-duration="30"></div>
        </div>
      </body></html>`;
      const result = await lintHyperframeHtml(html, { filePath: "/project/index.html" });
      expect(find(result.findings)).toBeUndefined();
    });

    it("stays silent for a non-sub-composition timed element", async () => {
      const html = `<html><body>
        <div id="root" data-composition-id="main" data-start="0" data-duration="60">
          <div class="clip" data-start="0" data-duration="15"></div>
        </div>
      </body></html>`;
      const result = await lintHyperframeHtml(html, { filePath: "/project/index.html" });
      expect(find(result.findings)).toBeUndefined();
    });

    it("stays silent when the root has no numeric data-duration", async () => {
      const html = `<html><body>
        <div id="root" data-composition-id="main" data-start="0">
          <div data-composition-id="sub" data-composition-src="compositions/sub.html" data-start="0" data-duration="15"></div>
        </div>
      </body></html>`;
      const result = await lintHyperframeHtml(html, { filePath: "/project/index.html" });
      expect(find(result.findings)).toBeUndefined();
    });

    it("stays silent for a late-starting clip", async () => {
      const html = `<html><body>
        <div id="root" data-composition-id="main" data-start="0" data-duration="60">
          <div data-composition-id="sub" data-composition-src="compositions/sub.html" data-start="40" data-duration="5"></div>
        </div>
      </body></html>`;
      const result = await lintHyperframeHtml(html, { filePath: "/project/index.html" });
      expect(find(result.findings)).toBeUndefined();
    });

    it("stays silent when an unknown-duration sibling covers the tail", async () => {
      const html = `<html><body>
        <div id="root" data-composition-id="main" data-start="0" data-duration="60">
          <div data-composition-id="sub" data-composition-src="compositions/sub.html" data-start="0" data-duration="15"></div>
          <div class="clip" data-start="0"></div>
        </div>
      </body></html>`;
      const result = await lintHyperframeHtml(html, { filePath: "/project/index.html" });
      expect(find(result.findings)).toBeUndefined();
    });
  });
});
