/**
 * Parity harness — recast writer (gsapParser.ts) vs acorn writer
 * (gsapWriterAcorn.ts). Both must produce scripts that REPARSE to the same
 * animation model. Byte-equality is not expected (recast pretty-prints, acorn
 * splices), so parity is asserted on the parsed GsapAnimation, not raw text.
 *
 * This is the safety net for porting WS-3 ops one at a time: each ported op
 * gets a fixture row here proving it matches the battle-tested original.
 *
 * The server switches between writers via STUDIO_SDK_CUTOVER_ENABLED (WS-3.F).
 * Recast remains the default; acorn runs only when the flag is enabled.
 */
import { describe, expect, it } from "vitest";
import {
  parseGsapScript,
  removeAllKeyframesFromScript as removeAllRecast,
  convertToKeyframesInScript as convertRecast,
  materializeKeyframesInScript as materializeRecast,
  splitIntoPropertyGroups as splitGroupsRecast,
  splitAnimationsInScript as splitAnimsRecast,
  setArcPathInScript as setArcRecast,
  updateArcSegmentInScript as updateArcSegmentRecast,
  removeArcPathFromScript as removeArcRecast,
  unrollDynamicAnimations as unrollRecast,
  addKeyframeToScript as addKeyframeRecast,
  removeKeyframeFromScript as removeKeyframeRecast,
  addAnimationWithKeyframesToScript as addWithKfRecast,
  shiftPositionsInScript as shiftRecast,
  scalePositionsInScript as scaleRecast,
  type SplitAnimationsOptions,
} from "./gsapParser.js";
import {
  parseGsapScriptAcorn,
  parseGsapScriptAcornForWrite,
  type ParsedGsapAcornForWrite,
} from "./gsapParserAcorn.js";
import {
  removeAllKeyframesFromScript as removeAllAcorn,
  convertToKeyframesFromScript as convertAcorn,
  materializeKeyframesFromScript as materializeAcorn,
  splitIntoPropertyGroupsFromScript as splitGroupsAcorn,
  splitAnimationsInScript as splitAnimsAcorn,
  setArcPathInScript as setArcAcorn,
  updateArcSegmentInScript as updateArcSegmentAcorn,
  removeArcPathFromScript as removeArcAcorn,
  unrollDynamicAnimations as unrollAcorn,
  addKeyframeToScript as addKeyframeAcorn,
  removeKeyframeFromScript as removeKeyframeAcorn,
  addAnimationWithKeyframesToScript as addWithKfAcorn,
  removeAnimationFromScript as removeAnimAcorn,
  shiftPositionsInScript as shiftAcorn,
  scalePositionsInScript as scaleAcorn,
} from "./gsapWriterAcorn.js";

function acornId(script: string): string {
  const parsed = parseGsapScriptAcornForWrite(script) as ParsedGsapAcornForWrite;
  return parsed.located[0]!.id;
}

/**
 * True recast-vs-acorn differential: parse a written script with the acorn
 * parser and strip per-parse metadata, leaving only the AUTHORED animation
 * shape. Both writers must produce scripts that reparse to the same model
 * (raw text differs — recast pretty-prints, acorn splices in place).
 */
function modelOf(script: string) {
  return parseGsapScriptAcorn(script).animations.map((a) => {
    // Drop per-parse metadata; compare AUTHORED shape only.
    const {
      id: _id,
      resolvedStart: _resolvedStart,
      implicitPosition: _implicitPosition,
      propertyGroup: _propertyGroup,
      provenance: _provenance,
      ...rest
    } = a;
    return rest;
  });
}

function arcShapeOf(script: string) {
  const anim = parseGsapScript(script).animations[0]!;
  return { arcPath: anim.arcPath, properties: anim.properties };
}

/** Reparse a written script and return the first animation's editable shape. */
function shapeOf(script: string) {
  const anim = parseGsapScript(script).animations[0]!;
  return {
    method: anim.method,
    properties: anim.properties,
    keyframes: anim.keyframes,
    duration: anim.duration,
    ease: anim.ease,
  };
}

const REMOVE_ALL_FIXTURES: Array<{ name: string; script: string }> = [
  {
    name: "to() — collapses to last keyframe",
    script: `
      const tl = gsap.timeline({ paused: true });
      tl.to("#hero", {
        keyframes: { "0%": { x: 0 }, "50%": { x: 100 }, "100%": { x: 200, opacity: 1 } },
        duration: 2
      }, 0);
    `,
  },
  {
    name: "to() — single keyframe + ease",
    script: `
      const tl = gsap.timeline({ paused: true });
      tl.to("#box", {
        keyframes: { "0%": { opacity: 0 }, "100%": { opacity: 1 } },
        duration: 1,
        ease: "none"
      }, 0.5);
    `,
  },
  {
    name: "to() — easeEach dropped on collapse",
    script: `
      const tl = gsap.timeline({ paused: true });
      tl.to("#card", {
        keyframes: { "0%": { y: 0 }, "100%": { y: -40 }, easeEach: "power2.inOut" },
        duration: 1.5
      }, 0);
    `,
  },
];

describe("parity: removeAllKeyframesFromScript (recast vs acorn)", () => {
  for (const { name, script } of REMOVE_ALL_FIXTURES) {
    it(name, () => {
      const id = acornId(script);
      // Sanity: recast and acorn agree on the id for this tween.
      expect(parseGsapScript(script).animations[0]!.id).toBe(id);

      const recastOut = removeAllRecast(script, id);
      const acornOut = removeAllAcorn(script, id);

      const recastShape = shapeOf(recastOut);
      const acornShape = shapeOf(acornOut);

      expect(acornShape.keyframes).toBeUndefined();
      expect(acornShape).toEqual(recastShape);
    });
  }

  it("no-op when id not found", () => {
    const script = REMOVE_ALL_FIXTURES[0]!.script;
    expect(removeAllAcorn(script, "nonexistent-id")).toBe(script);
  });

  it("no-op when tween has no keyframes", () => {
    const script = `
      const tl = gsap.timeline({ paused: true });
      tl.to("#flat", { x: 100, duration: 1 }, 0);
    `;
    const id = acornId(script);
    expect(removeAllAcorn(script, id)).toBe(script);
  });
});

const CONVERT_FIXTURES: Array<{
  name: string;
  script: string;
  resolvedFromValues?: Record<string, number | string>;
}> = [
  {
    name: "to() — builds 0%/100% keyframes with identity from",
    script: `
      const tl = gsap.timeline({ paused: true });
      tl.to("#hero", { x: 200, opacity: 0.5, duration: 1.5 }, 0);
    `,
  },
  {
    name: "to() — with ease becomes easeEach + ease: none",
    script: `
      const tl = gsap.timeline({ paused: true });
      tl.to("#box", { x: 100, duration: 1, ease: "power2.out" }, 0);
    `,
  },
  {
    name: "from() — method renamed to to()",
    script: `
      const tl = gsap.timeline({ paused: true });
      tl.from("#card", { y: -50, opacity: 0, duration: 0.8 }, 0);
    `,
  },
  {
    name: "fromTo() — method renamed, fromArg removed",
    script: `
      const tl = gsap.timeline({ paused: true });
      tl.fromTo("#text", { x: 0 }, { x: 300, duration: 2 }, 0);
    `,
  },
  {
    name: "to() — with resolvedFromValues overrides 0%",
    script: `
      const tl = gsap.timeline({ paused: true });
      tl.to("#el", { x: 100, duration: 1 }, 0);
    `,
    resolvedFromValues: { x: 42 },
  },
];

describe("parity: convertToKeyframesFromScript (recast vs acorn)", () => {
  for (const { name, script, resolvedFromValues } of CONVERT_FIXTURES) {
    it(name, () => {
      const id = acornId(script);
      const recastOut = convertRecast(script, id, resolvedFromValues);
      const acornOut = convertAcorn(script, id, resolvedFromValues);

      const recastShape = shapeOf(recastOut);
      const acornShape = shapeOf(acornOut);

      expect(acornShape.keyframes).toBeDefined();
      expect(acornShape.method).toBe("to");
      expect(acornShape).toEqual(recastShape);
    });
  }

  it("no-op when id not found", () => {
    const script = CONVERT_FIXTURES[0]!.script;
    expect(convertAcorn(script, "nonexistent-id")).toBe(script);
  });

  it("no-op when tween already has keyframes", () => {
    const script = `
      const tl = gsap.timeline({ paused: true });
      tl.to("#el", { keyframes: { "0%": { x: 0 }, "100%": { x: 100 } }, duration: 1 }, 0);
    `;
    const id = acornId(script);
    expect(convertAcorn(script, id)).toBe(script);
  });
});

// ── materializeKeyframes parity ───────────────────────────────────────────────

const MATERIALIZE_KFS = [
  { percentage: 0, properties: { x: 0, opacity: 1 } },
  { percentage: 50, properties: { x: 150, opacity: 0.5 } },
  { percentage: 100, properties: { x: 300, opacity: 0 } },
];

const MATERIALIZE_FIXTURES: Array<{
  name: string;
  script: string;
  kfs: typeof MATERIALIZE_KFS;
  easeEach?: string;
}> = [
  {
    name: "flat tween — adds keyframes property",
    script: `
      const tl = gsap.timeline({ paused: true });
      tl.to("#hero", { x: 300, duration: 2 }, 0);
    `,
    kfs: MATERIALIZE_KFS,
  },
  {
    name: "with easeEach",
    script: `
      const tl = gsap.timeline({ paused: true });
      tl.to("#box", { opacity: 0, duration: 1 }, 0);
    `,
    kfs: [
      { percentage: 0, properties: { opacity: 1 } },
      { percentage: 100, properties: { opacity: 0 } },
    ],
    easeEach: "power2.inOut",
  },
];

describe("parity: materializeKeyframesFromScript (recast vs acorn)", () => {
  for (const { name, script, kfs, easeEach } of MATERIALIZE_FIXTURES) {
    it(name, () => {
      const id = acornId(script);
      const recastOut = materializeRecast(script, id, kfs, easeEach);
      const acornOut = materializeAcorn(script, id, kfs, easeEach);
      const recastShape = shapeOf(recastOut);
      const acornShape = shapeOf(acornOut);
      expect(acornShape.keyframes).toBeDefined();
      expect(acornShape).toEqual(recastShape);
    });
  }

  it("no-op when id not found", () => {
    const script = MATERIALIZE_FIXTURES[0]!.script;
    expect(materializeAcorn(script, "nope", MATERIALIZE_KFS)).toBe(script);
  });
});

// ── splitIntoPropertyGroups parity ────────────────────────────────────────────

function shapesOf(script: string) {
  return parseGsapScript(script).animations.map((a) => ({
    method: a.method,
    properties: a.properties,
    keyframes: a.keyframes,
    duration: a.duration,
    ease: a.ease,
    selector: a.targetSelector,
    propertyGroup: a.propertyGroup,
  }));
}

const SPLIT_FIXTURES: Array<{ name: string; script: string }> = [
  {
    name: "flat mixed tween — splits into position + visual groups",
    script: `
      const tl = gsap.timeline({ paused: true });
      tl.to("#hero", { x: 100, y: 50, opacity: 0.5, duration: 1 }, 0);
    `,
  },
  {
    name: "keyframed mixed tween — splits per group",
    script: `
      const tl = gsap.timeline({ paused: true });
      tl.to("#box", { keyframes: { "0%": { x: 0, opacity: 1 }, "100%": { x: 200, opacity: 0 } }, duration: 1 }, 0);
    `,
  },
];

describe("parity: splitIntoPropertyGroupsFromScript (recast vs acorn)", () => {
  for (const { name, script } of SPLIT_FIXTURES) {
    it(name, () => {
      const id = acornId(script);
      const { script: recastOut } = splitGroupsRecast(script, id);
      const { script: acornOut } = splitGroupsAcorn(script, id);
      const recastShapes = shapesOf(recastOut);
      const acornShapes = shapesOf(acornOut);
      expect(acornShapes).toHaveLength(recastShapes.length);
      expect(acornShapes.length).toBeGreaterThan(1);
      // Each produced group should match its counterpart by propertyGroup
      const sortByGroup = (arr: typeof recastShapes) =>
        arr.slice().sort((a, b) => (a.propertyGroup ?? "").localeCompare(b.propertyGroup ?? ""));
      expect(sortByGroup(acornShapes)).toEqual(sortByGroup(recastShapes));
    });
  }

  it("no-op when id not found", () => {
    const script = SPLIT_FIXTURES[0]!.script;
    const { script: out, ids } = splitGroupsAcorn(script, "nope");
    expect(out).toBe(script);
    expect(ids).toEqual(["nope"]);
  });

  it("no-op when single-group tween", () => {
    const script = `
      const tl = gsap.timeline({ paused: true });
      tl.to("#el", { x: 100, y: 50, duration: 1 }, 0);
    `;
    const id = acornId(script);
    const { script: out } = splitGroupsAcorn(script, id);
    expect(out).toBe(script);
  });
});

// ── splitAnimationsInScript parity ────────────────────────────────────────────

function animShapesOf(script: string) {
  return parseGsapScript(script).animations.map((a) => ({
    method: a.method,
    selector: a.targetSelector,
    properties: a.properties,
    fromProperties: a.fromProperties,
    duration: a.duration,
    position: a.position,
  }));
}

const SPLIT_ANIM_CASES: Array<{ name: string; script: string; opts: SplitAnimationsOptions }> = [
  {
    name: "all tweens before split — retargets none",
    script: `
      const tl = gsap.timeline({ paused: true });
      tl.to("#hero", { x: 100, duration: 1 }, 0);
    `,
    opts: {
      originalId: "hero",
      newId: "hero-2",
      splitTime: 2,
      elementStart: 0,
      elementDuration: 4,
    },
  },
  {
    name: "tween entirely after split — retargeted to newId",
    script: `
      const tl = gsap.timeline({ paused: true });
      tl.to("#hero", { opacity: 0, duration: 0.5 }, 3);
    `,
    opts: {
      originalId: "hero",
      newId: "hero-2",
      splitTime: 2,
      elementStart: 0,
      elementDuration: 4,
    },
  },
  {
    name: "tween spanning split — truncated first half + fromTo second half",
    script: `
      const tl = gsap.timeline({ paused: true });
      tl.to("#hero", { x: 200, duration: 4 }, 0);
    `,
    opts: {
      originalId: "hero",
      newId: "hero-2",
      splitTime: 2,
      elementStart: 0,
      elementDuration: 4,
    },
  },
];

describe("parity: splitAnimationsInScript (recast vs acorn)", () => {
  for (const { name, script, opts } of SPLIT_ANIM_CASES) {
    it(name, () => {
      const { script: recastOut } = splitAnimsRecast(script, opts);
      const { script: acornOut } = splitAnimsAcorn(script, opts);
      const sortByPos = (arr: ReturnType<typeof animShapesOf>) =>
        arr.slice().sort((a, b) => {
          const pa = typeof a.position === "number" ? a.position : 0;
          const pb = typeof b.position === "number" ? b.position : 0;
          return pa - pb || (a.selector ?? "").localeCompare(b.selector ?? "");
        });
      expect(sortByPos(animShapesOf(acornOut))).toEqual(sortByPos(animShapesOf(recastOut)));
    });
  }

  it("no-op when originalId not found in script", () => {
    const script = SPLIT_ANIM_CASES[0]!.script;
    const opts: SplitAnimationsOptions = {
      originalId: "nonexistent",
      newId: "x",
      splitTime: 2,
      elementStart: 0,
      elementDuration: 4,
    };
    expect(splitAnimsAcorn(script, opts).script).toBe(script);
  });
});

// ─── arc path parity ──────────────────────────────────────────────────────────

const ARC_FLAT_SCRIPT = `
  const tl = gsap.timeline({ paused: true });
  tl.to("#hero", { x: 100, y: 50, duration: 2 }, 0);
`;
const ARC_CFG = {
  enabled: true as const,
  autoRotate: false as const,
  segments: [{ curviness: 1 }],
};
const DISABLE_CFG = {
  enabled: false as const,
  autoRotate: false as const,
  segments: [] as never[],
};

function arcFixture() {
  const id = acornId(ARC_FLAT_SCRIPT);
  const enabled = setArcAcorn(ARC_FLAT_SCRIPT, id, ARC_CFG);
  return { id, enabled };
}

// Multi-waypoint fixture: keyframes drive >2 path waypoints and >1 segment, and
// autoRotate is on — exercises the multi-segment branch of buildMotionPathObjectCode.
const ARC_KEYFRAME_SCRIPT = `
  const tl = gsap.timeline({ paused: true });
  tl.to("#hero", {
    keyframes: { "0%": { x: 0, y: 0 }, "50%": { x: 50, y: 100 }, "100%": { x: 200, y: 0 } },
    duration: 2
  }, 0);
`;
const ARC_KEYFRAME_CFG = {
  enabled: true as const,
  autoRotate: true as const,
  segments: [{ curviness: 1.5 }, { curviness: 0.5 }],
};

describe("setArcPathInScript: acorn output correctness", () => {
  it("enable: arcPath.enabled=true, segments preserved", () => {
    const id = acornId(ARC_FLAT_SCRIPT);
    const shape = arcShapeOf(setArcAcorn(ARC_FLAT_SCRIPT, id, ARC_CFG));
    expect(shape.arcPath?.enabled).toBe(true);
    expect(shape.arcPath?.segments).toHaveLength(1);
  });

  it("disable: arcPath=undefined, x/y restored", () => {
    const { id, enabled } = arcFixture();
    const shape = arcShapeOf(setArcAcorn(enabled, id, DISABLE_CFG));
    expect(shape.arcPath).toBeUndefined();
    expect(typeof shape.properties.x).toBe("number");
  });

  it("no-op when animation not found", () => {
    expect(setArcAcorn(ARC_FLAT_SCRIPT, "nope", ARC_CFG)).toBe(ARC_FLAT_SCRIPT);
  });
});

describe("updateArcSegmentInScript: acorn output correctness", () => {
  it("curviness update reflected in parsed shape", () => {
    const { id, enabled } = arcFixture();
    const shape = arcShapeOf(updateArcSegmentAcorn(enabled, id, 0, { curviness: 2 }));
    expect(shape.arcPath?.segments[0]?.curviness).toBe(2);
  });

  it("no-op when index out of range", () => {
    const { id, enabled } = arcFixture();
    expect(updateArcSegmentAcorn(enabled, id, 99, { curviness: 2 })).toBe(enabled);
  });
});

describe("removeArcPathFromScript: acorn output correctness", () => {
  it("arcPath=undefined after removal", () => {
    const { id, enabled } = arcFixture();
    expect(arcShapeOf(removeArcAcorn(enabled, id)).arcPath).toBeUndefined();
  });
});

// ─── unrollDynamicAnimations correctness ──────────────────────────────────────

const UNROLL_LOOP_SCRIPT = `
  const tl = gsap.timeline({ paused: true });
  const items = ["#a", "#b"];
  for (let i = 0; i < items.length; i++) {
    tl.to(items[i], { opacity: 1, duration: 1 }, 0);
  }
`;

const UNROLL_FOREACH_SCRIPT = `
  const tl = gsap.timeline({ paused: true });
  ["#a", "#b"].forEach(function(sel) {
    tl.to(sel, { opacity: 1, duration: 2 }, 1);
  });
`;

const UNROLL_ELEMENTS = [
  {
    selector: "#hero",
    keyframes: [
      { percentage: 0, properties: { opacity: 0 } },
      { percentage: 100, properties: { opacity: 1 } },
    ],
  },
  {
    selector: "#sub",
    keyframes: [
      { percentage: 0, properties: { x: 0 } },
      { percentage: 100, properties: { x: 200 } },
    ],
  },
];

function unrollId(script: string): string {
  const p = acornId(script);
  return p;
}

describe("unrollDynamicAnimations: acorn output correctness", () => {
  it("for-loop: loop replaced with individual tl.to() calls", () => {
    const id = unrollId(UNROLL_LOOP_SCRIPT);
    const out = unrollAcorn(UNROLL_LOOP_SCRIPT, id, UNROLL_ELEMENTS);
    expect(out).not.toBe(UNROLL_LOOP_SCRIPT);
    expect(out).toContain('tl.to("#hero"');
    expect(out).toContain('tl.to("#sub"');
    expect(out).not.toContain("for (");
  });

  it("forEach: loop replaced with individual tl.to() calls", () => {
    const id = unrollId(UNROLL_FOREACH_SCRIPT);
    const out = unrollAcorn(UNROLL_FOREACH_SCRIPT, id, UNROLL_ELEMENTS);
    expect(out).toContain('tl.to("#hero"');
    expect(out).not.toContain("forEach");
  });

  it("preserves duration and position from original tween", () => {
    const id = unrollId(UNROLL_LOOP_SCRIPT);
    const out = unrollAcorn(UNROLL_LOOP_SCRIPT, id, UNROLL_ELEMENTS);
    expect(out).toContain("duration: 1");
    expect(out).toContain("}, 0)");
  });

  it("no-op when animationId not found", () => {
    expect(unrollAcorn(UNROLL_LOOP_SCRIPT, "nope", UNROLL_ELEMENTS)).toBe(UNROLL_LOOP_SCRIPT);
  });
});

// ── True recast-vs-acorn differential for the arc trio ──────────────────────
// For each representative input, apply the op via BOTH the recast writer
// (gsapParser.ts) and the acorn writer (gsapWriterAcorn.ts), then assert the
// reparsed authored model is identical. This is the WS-3.F parity safety net:
// acorn cannot drop or mis-serialize a path/segment/restored-xy that recast keeps.
describe("parity: arc path trio (recast vs acorn)", () => {
  for (const { name, script, cfg } of [
    { name: "flat x/y — single segment", script: ARC_FLAT_SCRIPT, cfg: ARC_CFG },
    {
      name: "keyframes — multi-segment + autoRotate",
      script: ARC_KEYFRAME_SCRIPT,
      cfg: ARC_KEYFRAME_CFG,
    },
  ]) {
    describe(name, () => {
      it("setArcPath enable: models match", () => {
        const id = acornId(script);
        expect(parseGsapScript(script).animations[0]!.id).toBe(id);
        expect(modelOf(setArcAcorn(script, id, cfg))).toEqual(
          modelOf(setArcRecast(script, id, cfg)),
        );
      });

      it("updateArcSegment: models match", () => {
        const recastEnabled = setArcRecast(script, acornId(script), cfg);
        const acornEnabled = setArcAcorn(script, acornId(script), cfg);
        const idx = cfg.segments.length - 1;
        expect(
          modelOf(
            updateArcSegmentAcorn(acornEnabled, acornId(acornEnabled), idx, { curviness: 3 }),
          ),
        ).toEqual(
          modelOf(
            updateArcSegmentRecast(recastEnabled, acornId(recastEnabled), idx, { curviness: 3 }),
          ),
        );
      });

      it("removeArcPath: models match (x/y restored, motionPath gone)", () => {
        const recastEnabled = setArcRecast(script, acornId(script), cfg);
        const acornEnabled = setArcAcorn(script, acornId(script), cfg);
        expect(modelOf(removeArcAcorn(acornEnabled, acornId(acornEnabled)))).toEqual(
          modelOf(removeArcRecast(recastEnabled, acornId(recastEnabled))),
        );
      });
    });
  }
});

// ── forEach with explicit ease + nonzero position — the acorn writer reads
// duration/ease/position from the parsed animation model, recast reads them
// straight from the original tween's AST var/position args. ────────────────────
const UNROLL_FOREACH_EASE_SCRIPT = `
  const tl = gsap.timeline({ paused: true });
  ["#a", "#b"].forEach(function(sel) {
    tl.to(sel, { opacity: 1, duration: 2, ease: "power2.out" }, 1);
  });
`;

// Dynamic tween NOT inside a loop — both writers fall back to replacing the
// enclosing expression statement. String (label) position, no duration/ease,
// so the default duration: 8 / ease: "none" path is exercised on both sides.
const UNROLL_FALLBACK_SCRIPT = `
  const tl = gsap.timeline({ paused: true });
  tl.to("#dyn", { opacity: 1 }, "intro");
`;

const UNROLL_ELEMENTS_EASE = [
  {
    selector: "#hero",
    keyframes: [
      { percentage: 0, properties: { opacity: 0 } },
      { percentage: 100, properties: { opacity: 1 } },
    ],
    easeEach: "power1.in",
  },
  {
    selector: "#sub",
    keyframes: [
      { percentage: 0, properties: { x: 0 } },
      { percentage: 100, properties: { x: 200 } },
    ],
  },
];

// True recast-vs-acorn differential: apply unroll via BOTH writers, then assert
// the reparsed (authored-shape) models are identical. Covers a for-loop, a
// forEach with explicit ease + nonzero position, and a non-loop (fallback)
// tween with a label position + defaulted duration/ease.
const UNROLL_PARITY_CASES: Array<{
  name: string;
  script: string;
  elements: typeof UNROLL_ELEMENTS;
}> = [
  { name: "for-loop", script: UNROLL_LOOP_SCRIPT, elements: UNROLL_ELEMENTS },
  { name: "forEach", script: UNROLL_FOREACH_SCRIPT, elements: UNROLL_ELEMENTS },
  {
    name: "forEach with ease + nonzero position",
    script: UNROLL_FOREACH_EASE_SCRIPT,
    elements: UNROLL_ELEMENTS_EASE,
  },
  {
    name: "non-loop fallback — label position, defaulted duration/ease",
    script: UNROLL_FALLBACK_SCRIPT,
    elements: UNROLL_ELEMENTS_EASE,
  },
];

describe("parity: unrollDynamicAnimations (recast vs acorn)", () => {
  for (const { name, script, elements } of UNROLL_PARITY_CASES) {
    it(name, () => {
      const id = unrollId(script);
      // Sanity: recast and acorn agree on the id for the dynamic tween.
      expect(parseGsapScript(script).animations[0]!.id).toBe(id);

      const recastOut = unrollRecast(script, id, elements);
      const acornOut = unrollAcorn(script, id, elements);

      // Both writers must actually unroll (not no-op).
      expect(recastOut).not.toBe(script);
      expect(acornOut).not.toBe(script);

      // Reparsed authored models must be identical.
      expect(modelOf(acornOut)).toEqual(modelOf(recastOut));
    });
  }

  it("no-op parity when animationId not found", () => {
    expect(unrollAcorn(UNROLL_LOOP_SCRIPT, "nope", UNROLL_ELEMENTS)).toBe(UNROLL_LOOP_SCRIPT);
    expect(unrollRecast(UNROLL_LOOP_SCRIPT, "nope", UNROLL_ELEMENTS)).toBe(UNROLL_LOOP_SCRIPT);
  });
});

// ── addKeyframeToScript parity (recast vs acorn) ────────────────────────────
// PR #1470 routes Studio's GSAP keyframe-add through the acorn writer. Each
// case applies the op via BOTH writers and asserts the parsed authored models
// are equal — closing the parity gap the keyframe-add fix enforces (the acorn
// whole-value overwrite branch must emit recordToCode, not a stale valueCode).

// Two distinct percentages so adding/merging exercises the insert + merge paths.
const KF_ADD_SCRIPT = `
  const tl = gsap.timeline({ paused: true });
  tl.to("#box", { keyframes: { "0%": { opacity: 0 }, "100%": { opacity: 1 } }, duration: 0.5 }, 0.2);
`;

// `_auto`-marked endpoints exercise the adjacent-endpoint sync branch.
const KF_ADD_AUTO_SCRIPT = `
  const tl = gsap.timeline({ paused: true });
  tl.to("#box", { keyframes: { "0%": { opacity: 0, _auto: 1 }, "100%": { opacity: 1, _auto: 1 } }, duration: 0.5 }, 0.2);
`;

// Flat tween — adding a keyframe runs the convert-to-keyframes path first.
const KF_ADD_FLAT_SCRIPT = `
  const tl = gsap.timeline({ paused: true });
  tl.to("#hero", { opacity: 1, duration: 0.5, ease: "power3.out" }, 0.2);
`;

// A percentage entry whose value is NOT an object literal — exercises the
// whole-value overwrite branch (the acorn path here once referenced an
// undefined `valueCode`; recast emits the new value node).
const KF_ADD_NON_OBJECT_SCRIPT = `
  const tl = gsap.timeline({ paused: true });
  tl.to("#box", { keyframes: { "0%": { opacity: 0 }, "50%": 0.7, "100%": { opacity: 1 } }, duration: 0.5 }, 0.2);
`;

describe("parity: addKeyframeToScript (recast vs acorn)", () => {
  function expectParity(
    script: string,
    percentage: number,
    properties: Record<string, number | string>,
    ease?: string,
    backfillDefaults?: Record<string, number | string>,
  ) {
    const id = acornId(script);
    expect(parseGsapScript(script).animations[0]!.id).toBe(id);
    const acorn = addKeyframeAcorn(script, id, percentage, properties, ease, backfillDefaults);
    const recast = addKeyframeRecast(script, id, percentage, properties, ease, backfillDefaults);
    expect(modelOf(acorn)).toEqual(modelOf(recast));
  }

  it("inserts a new percentage in sorted order", () => {
    expectParity(KF_ADD_SCRIPT, 25, { opacity: 0.3 });
  });

  it("replaces the value when the percentage already exists", () => {
    expectParity(KF_ADD_SCRIPT, 100, { opacity: 0.99 });
  });

  it("merges a new property into an existing keyframe, preserving siblings", () => {
    expectParity(KF_ADD_SCRIPT, 100, { x: 100 });
  });

  it("carries an ease onto the new keyframe", () => {
    expectParity(KF_ADD_SCRIPT, 30, { opacity: 0.4 }, "power2.out");
  });

  it("backfills a new property across sibling keyframes", () => {
    expectParity(KF_ADD_SCRIPT, 25, { x: 50 }, undefined, { x: 0 });
  });

  it("syncs an adjacent _auto 0% endpoint", () => {
    expectParity(KF_ADD_AUTO_SCRIPT, 10, { opacity: 0.2 });
  });

  it("syncs an adjacent _auto 100% endpoint", () => {
    expectParity(KF_ADD_AUTO_SCRIPT, 90, { opacity: 0.8 });
  });

  it("converts a flat tween to keyframes before inserting", () => {
    expectParity(KF_ADD_FLAT_SCRIPT, 50, { opacity: 0.5 });
  });

  it("overwrites a non-object keyframe value with the new properties", () => {
    expectParity(KF_ADD_NON_OBJECT_SCRIPT, 50, { opacity: 0.5 });
  });

  it("no-op on unknown id agrees between writers", () => {
    expect(addKeyframeAcorn(KF_ADD_SCRIPT, "bad-id", 50, { opacity: 0.5 })).toBe(KF_ADD_SCRIPT);
    expect(addKeyframeRecast(KF_ADD_SCRIPT, "bad-id", 50, { opacity: 0.5 })).toBe(KF_ADD_SCRIPT);
  });
});

// ── removeKeyframeFromScript parity (recast vs acorn) ───────────────────────
// When removal drops a keyframes block below two stops it must collapse back to
// a flat tween (recast via collapseKeyframesToFlat). The acorn writer must
// mirror this — folding the survivor (incl. `_auto`), dropping per-keyframe
// `ease` and the sibling `easeEach` — or the SDK/server paths diverge.

// Three plain keyframes — removing the interior one stays a keyframes block.
const RM_PLAIN_SCRIPT = `
  const tl = gsap.timeline({ paused: true });
  tl.to("#box", { keyframes: { "0%": { opacity: 0 }, "50%": { opacity: 0.5 }, "100%": { opacity: 1 } }, duration: 0.5 }, 0.2);
`;

// Two plain keyframes — removing one drops below 2 → collapse to flat tween.
const RM_TWO_KF_SCRIPT = `
  const tl = gsap.timeline({ paused: true });
  tl.to("#box", { keyframes: { "0%": { opacity: 0 }, "100%": { opacity: 1 } }, duration: 0.5 }, 0.2);
`;

// Two _auto endpoints — collapse must carry the surviving `_auto` marker.
const RM_TWO_KF_AUTO_SCRIPT = `
  const tl = gsap.timeline({ paused: true });
  tl.to("#box", { keyframes: { "0%": { opacity: 1, _auto: 1 }, "100%": { opacity: 0, _auto: 1 } }, duration: 0.5 }, 0.2);
`;

// Survivor carries a per-keyframe `ease`, plus a sibling `easeEach`. Collapse
// must drop both `ease` (per-keyframe) and `easeEach` (keyframes-only).
const RM_TWO_KF_EASE_SCRIPT = `
  const tl = gsap.timeline({ paused: true });
  tl.to("#box", { keyframes: { "0%": { opacity: 0 }, "100%": { opacity: 1, ease: "power2.in" }, easeEach: "none" }, duration: 0.5 }, 0.2);
`;

// Survivor is empty — collapse yields a tween with NO authored props.
const RM_TWO_KF_EMPTY_SCRIPT = `
  const tl = gsap.timeline({ paused: true });
  tl.to("#box", { keyframes: { "0%": {}, "100%": { opacity: 1 } }, duration: 0.5 }, 0.2);
`;

describe("parity: removeKeyframeFromScript (recast vs acorn)", () => {
  function expectParity(script: string, percentage: number) {
    const id = acornId(script);
    expect(parseGsapScript(script).animations[0]!.id).toBe(id);
    expect(modelOf(removeKeyframeAcorn(script, id, percentage))).toEqual(
      modelOf(removeKeyframeRecast(script, id, percentage)),
    );
  }

  it("removes an interior keyframe and stays a keyframes block (3 → 2)", () => {
    expectParity(RM_PLAIN_SCRIPT, 50);
  });

  it("targets a near-coincident percentage (51 → the 50% keyframe) at parity", () => {
    expectParity(RM_PLAIN_SCRIPT, 51);
  });

  it("collapses to a flat tween when only one keyframe would remain (2 → 1)", () => {
    expectParity(RM_TWO_KF_SCRIPT, 0);
  });

  it("collapses an _auto endpoint pair, carrying the surviving _auto marker", () => {
    expectParity(RM_TWO_KF_AUTO_SCRIPT, 0);
  });

  it("collapses and drops per-keyframe ease + easeEach", () => {
    expectParity(RM_TWO_KF_EASE_SCRIPT, 0);
  });

  it("collapses to a propless flat tween when the surviving keyframe is empty", () => {
    expectParity(RM_TWO_KF_EMPTY_SCRIPT, 100);
  });

  it("no-op on unknown id agrees between writers", () => {
    expect(removeKeyframeAcorn(RM_TWO_KF_SCRIPT, "bad-id", 0)).toBe(RM_TWO_KF_SCRIPT);
    expect(removeKeyframeRecast(RM_TWO_KF_SCRIPT, "bad-id", 0)).toBe(RM_TWO_KF_SCRIPT);
  });
});

// ── addAnimationWithKeyframesToScript parity (recast vs acorn) ───────────────
// WS-3.C add path: both writers insert a new keyframed tl.to() call. The
// inserted statement's authored model (selector, keyframes, duration, ease,
// position) must match — comparing the LAST animation each writer produced.
const ADD_WITH_KF_BASE = `
  const tl = gsap.timeline({ paused: true });
  tl.to("#existing", { opacity: 1, duration: 1 }, 0);
`;

function lastModelOf(script: string) {
  const arr = modelOf(script);
  return arr[arr.length - 1];
}

// NOTE (WS-3.F): recast is retired, so `recast` here is an alias of the acorn
// writer and the historical `toEqual(lastModelOf(recast))` comparisons are
// tautologies. The WS-3.C ops below instead pin the acorn output as golden
// inline snapshots so they retain a real regression oracle. Converting the
// remaining (pre-WS-3.C) parity blocks to golden snapshots is follow-up work.
describe("parity: addAnimationWithKeyframesToScript (acorn golden)", () => {
  it("minimal: two-keyframe insert, no ease", () => {
    const kfs = [
      { percentage: 0, properties: { x: 0 } },
      { percentage: 100, properties: { x: 200 } },
    ];
    const acorn = addWithKfAcorn(ADD_WITH_KF_BASE, "#hero", 0, 1, kfs).script;
    expect(lastModelOf(acorn)).toMatchInlineSnapshot(`
      {
        "duration": 1,
        "ease": undefined,
        "fromProperties": undefined,
        "keyframes": {
          "format": "percentage",
          "keyframes": [
            {
              "percentage": 0,
              "properties": {
                "x": 0,
              },
            },
            {
              "percentage": 100,
              "properties": {
                "x": 200,
              },
            },
          ],
        },
        "method": "to",
        "position": 0,
        "properties": {},
        "targetSelector": "#hero",
      }
    `);
  });

  it("moderate: three keyframes, per-keyframe ease, easeEach, nonzero position", () => {
    const kfs = [
      { percentage: 0, properties: { x: 0, opacity: 0 } },
      { percentage: 50, properties: { x: 100, opacity: 0.5 }, ease: "power2.out" },
      { percentage: 100, properties: { x: 300, opacity: 1 } },
    ];
    const acorn = addWithKfAcorn(ADD_WITH_KF_BASE, "#card", 1.5, 2.25, kfs, "none").script;
    expect(lastModelOf(acorn)).toMatchInlineSnapshot(`
      {
        "duration": 2.25,
        "ease": "none",
        "fromProperties": undefined,
        "keyframes": {
          "format": "percentage",
          "keyframes": [
            {
              "percentage": 0,
              "properties": {
                "opacity": 0,
                "x": 0,
              },
            },
            {
              "ease": "power2.out",
              "percentage": 50,
              "properties": {
                "opacity": 0.5,
                "x": 100,
              },
            },
            {
              "percentage": 100,
              "properties": {
                "opacity": 1,
                "x": 300,
              },
            },
          ],
        },
        "method": "to",
        "position": 1.5,
        "properties": {},
        "targetSelector": "#card",
      }
    `);
  });

  // WS-3.C: auto-endpoint markers must round-trip through both writers.
  it("_auto endpoint: 0% and 100% carry the _auto marker", () => {
    const kfs = [
      { percentage: 0, properties: { x: 0, opacity: 1 }, auto: true },
      { percentage: 50, properties: { x: 100, opacity: 0.5 } },
      { percentage: 100, properties: { x: 200, opacity: 0 }, auto: true },
    ];
    const acorn = addWithKfAcorn(ADD_WITH_KF_BASE, "#hero", 0, 1, kfs).script;
    expect(lastModelOf(acorn)).toMatchInlineSnapshot(`
      {
        "duration": 1,
        "ease": undefined,
        "fromProperties": undefined,
        "keyframes": {
          "format": "percentage",
          "keyframes": [
            {
              "percentage": 0,
              "properties": {
                "_auto": 1,
                "opacity": 1,
                "x": 0,
              },
            },
            {
              "percentage": 50,
              "properties": {
                "opacity": 0.5,
                "x": 100,
              },
            },
            {
              "percentage": 100,
              "properties": {
                "_auto": 1,
                "opacity": 0,
                "x": 200,
              },
            },
          ],
        },
        "method": "to",
        "position": 0,
        "properties": {},
        "targetSelector": "#hero",
      }
    `);
  });

  it("_auto endpoint: only 0% carries auto marker", () => {
    const kfs = [
      { percentage: 0, properties: { opacity: 1 }, auto: true },
      { percentage: 100, properties: { opacity: 0 } },
    ];
    const acorn = addWithKfAcorn(ADD_WITH_KF_BASE, "#el", 2, 0.5, kfs).script;
    expect(lastModelOf(acorn)).toMatchInlineSnapshot(`
      {
        "duration": 0.5,
        "ease": undefined,
        "fromProperties": undefined,
        "keyframes": {
          "format": "percentage",
          "keyframes": [
            {
              "percentage": 0,
              "properties": {
                "_auto": 1,
                "opacity": 1,
              },
            },
            {
              "percentage": 100,
              "properties": {
                "opacity": 0,
              },
            },
          ],
        },
        "method": "to",
        "position": 2,
        "properties": {},
        "targetSelector": "#el",
      }
    `);
  });

  it("returns a stable new animation ID that is non-empty", () => {
    const kfs = [
      { percentage: 0, properties: { opacity: 0 } },
      { percentage: 100, properties: { opacity: 1 } },
    ];
    const acornResult = addWithKfAcorn(ADD_WITH_KF_BASE, "#box", 0, 1, kfs);
    const recastResult = addWithKfRecast(ADD_WITH_KF_BASE, "#box", 0, 1, kfs);
    expect(acornResult.id).not.toBe("");
    expect(recastResult.id).not.toBe("");
    // The IDs are position-derived and may differ between writers due to
    // formatting differences, but both must be non-empty valid strings.
    expect(typeof acornResult.id).toBe("string");
    expect(typeof recastResult.id).toBe("string");
  });
});

// ── replaceWithKeyframes parity (remove + addWithKeyframes, recast vs acorn) ──
// WS-3.C replace path: both writers remove the existing tween by animationId,
// then insert the replacement keyframed tween. The animation model of the
// resulting script's last animation must match.

const REPLACE_WITH_KF_BASE = `\
const tl = gsap.timeline({ paused: true });
tl.to("#box", { x: 100, opacity: 1, duration: 0.5 }, 1);
`;

function replaceWithKfAcorn(
  script: string,
  animId: string,
  selector: string,
  pos: number,
  dur: number,
  kfs: Array<{
    percentage: number;
    properties: Record<string, number | string>;
    ease?: string;
    auto?: boolean;
  }>,
  ease?: string,
): string {
  const removed = removeAnimAcorn(script, animId);
  return addWithKfAcorn(removed, selector, pos, dur, kfs, ease).script;
}

describe("parity: replaceWithKeyframes (remove + addWithKeyframes, acorn golden)", () => {
  it("replaces the only tween: resulting animation model matches", () => {
    const id = acornId(REPLACE_WITH_KF_BASE);
    const kfs = [
      { percentage: 0, properties: { x: 0, opacity: 0 } },
      { percentage: 100, properties: { x: 200, opacity: 1 } },
    ];
    const acorn = replaceWithKfAcorn(REPLACE_WITH_KF_BASE, id, "#box", 0.5, 1.5, kfs);
    expect(lastModelOf(acorn)).toMatchInlineSnapshot(`
      {
        "duration": 1.5,
        "ease": undefined,
        "fromProperties": undefined,
        "keyframes": {
          "format": "percentage",
          "keyframes": [
            {
              "percentage": 0,
              "properties": {
                "opacity": 0,
                "x": 0,
              },
            },
            {
              "percentage": 100,
              "properties": {
                "opacity": 1,
                "x": 200,
              },
            },
          ],
        },
        "method": "to",
        "position": 0.5,
        "properties": {},
        "targetSelector": "#box",
      }
    `);
  });

  it("replaces the first tween in a two-tween script, preserving the other", () => {
    const TWO_TWEEN = `\
const tl = gsap.timeline({ paused: true });
tl.to("#box", { x: 100, duration: 0.5 }, 0);
tl.to("#circle", { y: 200, duration: 1 }, 1);
`;
    const id = acornId(TWO_TWEEN);
    const kfs = [
      { percentage: 0, properties: { x: 0 } },
      { percentage: 100, properties: { x: 300 } },
    ];
    const acorn = replaceWithKfAcorn(TWO_TWEEN, id, "#box", 0, 0.75, kfs);
    // The second tween (#circle) must survive unchanged.
    expect(modelOf(acorn)).toHaveLength(2);
    expect(lastModelOf(acorn)).toMatchInlineSnapshot(`
      {
        "duration": 0.75,
        "ease": undefined,
        "fromProperties": undefined,
        "keyframes": {
          "format": "percentage",
          "keyframes": [
            {
              "percentage": 0,
              "properties": {
                "x": 0,
              },
            },
            {
              "percentage": 100,
              "properties": {
                "x": 300,
              },
            },
          ],
        },
        "method": "to",
        "position": 0,
        "properties": {},
        "targetSelector": "#box",
      }
    `);
  });

  it("replaces with _auto endpoint markers", () => {
    const id = acornId(REPLACE_WITH_KF_BASE);
    const kfs = [
      { percentage: 0, properties: { opacity: 1 }, auto: true },
      { percentage: 100, properties: { opacity: 0 }, auto: true },
    ];
    const acorn = replaceWithKfAcorn(REPLACE_WITH_KF_BASE, id, "#box", 1, 2, kfs);
    expect(lastModelOf(acorn)).toMatchInlineSnapshot(`
      {
        "duration": 2,
        "ease": undefined,
        "fromProperties": undefined,
        "keyframes": {
          "format": "percentage",
          "keyframes": [
            {
              "percentage": 0,
              "properties": {
                "_auto": 1,
                "opacity": 1,
              },
            },
            {
              "percentage": 100,
              "properties": {
                "_auto": 1,
                "opacity": 0,
              },
            },
          ],
        },
        "method": "to",
        "position": 1,
        "properties": {},
        "targetSelector": "#box",
      }
    `);
  });
});

// ── shiftPositionsInScript / scalePositionsInScript (timeline clip move/resize) ──

const POSITIONS_MULTI = `const tl = gsap.timeline({ paused: true });
tl.from("#hero", { opacity: 0, duration: 1 }, 0);
tl.to("#hero", { opacity: 0, duration: 0.5 }, 2.5);
tl.from("#bg", { scale: 0, duration: 1 }, 1);`;

describe("parity: shiftPositionsInScript (recast vs acorn)", () => {
  it("shifts only the target selector's numeric positions", () => {
    const a = shiftAcorn(POSITIONS_MULTI, "#hero", 3);
    const r = shiftRecast(POSITIONS_MULTI, "#hero", 3);
    expect(modelOf(a)).toEqual(modelOf(r));
  });

  it("clamps negative-going positions to zero", () => {
    const s = `const tl = gsap.timeline({ paused: true });
tl.to("#el", { x: 100, duration: 1 }, 0.3);
tl.to("#el", { y: 50, duration: 1 }, 1.5);`;
    expect(modelOf(shiftAcorn(s, "#el", -1))).toEqual(modelOf(shiftRecast(s, "#el", -1)));
  });

  it("skips string (relative) positions", () => {
    const s = `const tl = gsap.timeline({ paused: true });
tl.to("#el", { x: 100, duration: 1 }, 2);
tl.to("#el", { y: 50, duration: 1 }, "+=0.5");`;
    expect(modelOf(shiftAcorn(s, "#el", 1))).toEqual(modelOf(shiftRecast(s, "#el", 1)));
  });

  it("adjacent positions do not collide", () => {
    const s = `const tl = gsap.timeline({ paused: true });
tl.to("#burst", { opacity: 1, duration: 0.5 }, 1.0);
tl.to("#burst", { opacity: 0, duration: 0.5 }, 1.5);`;
    expect(modelOf(shiftAcorn(s, "#burst", 0.5))).toEqual(modelOf(shiftRecast(s, "#burst", 0.5)));
  });

  it("implicit-position tween gains an explicit shifted position", () => {
    const s = `const tl = gsap.timeline({ paused: true });
tl.to("#el", { x: 1, duration: 1 });`;
    expect(modelOf(shiftAcorn(s, "#el", 2))).toEqual(modelOf(shiftRecast(s, "#el", 2)));
  });

  it("no matching selector is a no-op", () => {
    expect(shiftAcorn(POSITIONS_MULTI, "#nope", 3)).toBe(POSITIONS_MULTI);
  });
});

describe("parity: scalePositionsInScript (recast vs acorn)", () => {
  it("scales positions and durations proportionally for the target", () => {
    const a = scaleAcorn(POSITIONS_MULTI, "#hero", 0, 1, 2, 2);
    const r = scaleRecast(POSITIONS_MULTI, "#hero", 0, 1, 2, 2);
    expect(modelOf(a)).toEqual(modelOf(r));
  });

  it("skips string (relative) positions", () => {
    const s = `const tl = gsap.timeline({ paused: true });
tl.to("#el", { x: 100, duration: 1 }, 2);
tl.to("#el", { y: 50, duration: 1 }, "+=0.5");`;
    expect(modelOf(scaleAcorn(s, "#el", 0, 1, 1, 2))).toEqual(
      modelOf(scaleRecast(s, "#el", 0, 1, 1, 2)),
    );
  });

  it("no-op when oldDuration <= 0", () => {
    expect(scaleAcorn(POSITIONS_MULTI, "#hero", 0, 0, 2, 2)).toBe(POSITIONS_MULTI);
  });

  it("no-op when newDuration <= 0", () => {
    expect(scaleAcorn(POSITIONS_MULTI, "#hero", 0, 1, 2, 0)).toBe(POSITIONS_MULTI);
  });
});
