// fallow-ignore-file code-duplication
import { describe, it, expect, vi } from "vitest";
import { SlideshowController } from "./SlideshowController";
import type { ResolvedSlideshow } from "@hyperframes/core/slideshow";

function fakePlayer() {
  let cb: ((t: number) => void) | null = null;
  const player = {
    currentTime: 0,
    seek: vi.fn((t: number) => {
      player.currentTime = t;
    }),
    play: vi.fn(() => {}),
    pause: vi.fn(() => {}),
    stopMedia: vi.fn(() => {}),
    playSceneMedia: vi.fn((_sceneId: string) => {}),
    onTimeUpdate: (fn: (t: number) => void) => {
      cb = fn;
      return () => {
        cb = null;
      };
    },
    emit: (t: number) => {
      player.currentTime = t;
      cb?.(t);
    },
  };
  return player;
}

const SHOW: ResolvedSlideshow = {
  slides: [
    { sceneId: "a", start: 0, end: 5, fragments: [2, 4], hotspots: [] },
    { sceneId: "b", start: 5, end: 10, fragments: [], hotspots: [] },
  ],
  sequences: {
    deep: {
      id: "deep",
      label: "Deep dive",
      slides: [{ sceneId: "c", start: 10, end: 13, fragments: [], hotspots: [] }],
    },
  },
};

/**
 * Factory: controller on SHOW, advanced to fragmentIndex=1. Construction enters
 * slide a at fragmentIndex 0 (its first fragment); one next() reveals fragment 1.
 * Navigation is synchronous (seek-driven) — no playback emit needed.
 */
function showAtFrag1() {
  const p = fakePlayer();
  const c = new SlideshowController(p, SHOW);
  c.next(); // fragmentIndex 0 → 1
  return { p, c };
}

/**
 * Factory: controller on SHOW, at slide 1, inside the "deep" branch.
 * Used across branching + backToMain tests that share goToSlide(1)+enterBranch setup.
 */
function showAtSlide1InDeep() {
  const p = fakePlayer();
  const c = new SlideshowController(p, SHOW);
  c.goToSlide(1);
  c.enterBranch("deep");
  return { p, c };
}

describe("SlideshowController linear nav", () => {
  it("enters the first slide on construction: seeks to the first fragment (no auto-play)", () => {
    const p = fakePlayer();
    const c = new SlideshowController(p, SHOW);
    // Synchronous seek-only hold: jump to fragments[0]=2, fragmentIndex 0, never play.
    expect(p.seek).toHaveBeenCalledWith(2);
    expect(p.play).not.toHaveBeenCalled();
    expect(c.position.slideIndex).toBe(0);
    expect(c.position.fragmentIndex).toBe(0);
  });

  it("never auto-plays — a single seek both repaints and holds", () => {
    const p = fakePlayer();
    new SlideshowController(p, SHOW);
    // Determinism: navigation is a pure seek; the player is never put into a
    // playing state that could run on into the next fragment/scene.
    expect(p.play).not.toHaveBeenCalled();
  });

  it("does not stop media on construction or same-slide fragment navigation", () => {
    const p = fakePlayer();
    const c = new SlideshowController(p, SHOW);
    expect(p.stopMedia).not.toHaveBeenCalled();

    c.next(); // slide a fragment 0 -> fragment 1, same slide
    expect(p.stopMedia).not.toHaveBeenCalled();
  });

  it("stops media before changing to another slide", () => {
    const p = fakePlayer();
    const c = new SlideshowController(p, SHOW);

    c.next(); // fragment 0 -> fragment 1, same slide
    c.next(); // slide a -> slide b

    expect(p.stopMedia).toHaveBeenCalledOnce();
    expect(p.seek).toHaveBeenLastCalledWith(7.5);
  });

  it("next stops at the first fragment, not the next slide", () => {
    const p = fakePlayer();
    const c = new SlideshowController(p, SHOW);
    // Construction already lands on fragment 0 of slide a.
    expect(c.position.slideIndex).toBe(0);
    expect(c.position.fragmentIndex).toBe(0);
  });

  it("next past the last fragment advances to the next slide immediately", () => {
    const p = fakePlayer();
    const c = new SlideshowController(p, SHOW);
    // construction → fragment 0; next → fragment 1; next → slide b (no fragments)
    c.next(); // fragmentIndex 0 → 1 (seek 4)
    c.next(); // no more fragments — advance to slide b immediately
    expect(c.position.slideIndex).toBe(1);
    expect(p.seek).toHaveBeenLastCalledWith(7.5); // slide b midpoint
  });

  it("next() on a slide with NO fragments advances to the next slide immediately", () => {
    const p = fakePlayer();
    const c = new SlideshowController(p, SHOW);
    // Go to slide b (index 1, no fragments, not at end yet)
    c.goToSlide(1); // slide b: start=5, end=10, fragments=[]
    expect(c.position.slideIndex).toBe(1);
    // The demo SHOW only has 2 slides, so next on slide 1 is a no-op.
    // Use a show with a third slide to verify advancement.
    const show3: ResolvedSlideshow = {
      slides: [
        { sceneId: "a", start: 0, end: 5, fragments: [], hotspots: [] },
        { sceneId: "b", start: 5, end: 10, fragments: [], hotspots: [] },
        { sceneId: "c", start: 10, end: 15, fragments: [], hotspots: [] },
      ],
      sequences: {},
    };
    const p2 = fakePlayer();
    const c2 = new SlideshowController(p2, show3);
    // slide 0 has no fragments; one next() should advance immediately to slide 1
    c2.next();
    expect(c2.position.slideIndex).toBe(1);
    expect(p2.seek).toHaveBeenLastCalledWith(7.5); // slide b midpoint
  });

  it("next() on the last slide is a no-op", () => {
    const p = fakePlayer();
    const c = new SlideshowController(p, SHOW);
    c.goToSlide(1); // slide b is last
    c.next();
    expect(c.position.slideIndex).toBe(1); // no change
  });

  it("prev returns to the previous slide start", () => {
    const p = fakePlayer();
    const c = new SlideshowController(p, SHOW);
    c.goToSlide(1);
    c.prev();
    expect(c.position.slideIndex).toBe(0);
  });

  it("at a fragment, next advances to the FOLLOWING fragment (not the end)", () => {
    const p = fakePlayer();
    const c = new SlideshowController(p, SHOW);
    expect(c.position.fragmentIndex).toBe(0); // construction → fragment 0
    c.next(); // should target fragments[1]=4, NOT slide.end=5
    expect(c.position.fragmentIndex).toBe(1);
    expect(p.seek).toHaveBeenLastCalledWith(4);
  });
});

describe("SlideshowController nextSlide", () => {
  it("returns the next slide when not at the end", () => {
    const p = fakePlayer();
    const c = new SlideshowController(p, SHOW);
    // At slide 0, next should be slide 1 (sceneId "b")
    expect(c.nextSlide).not.toBeNull();
    expect(c.nextSlide?.sceneId).toBe("b");
  });

  it("returns null when at the last slide in the sequence", () => {
    const p = fakePlayer();
    const c = new SlideshowController(p, SHOW);
    c.goToSlide(1); // slide "b" is the last in main
    expect(c.nextSlide).toBeNull();
  });

  it("nextSlide is scoped to the current sequence", () => {
    const p = fakePlayer();
    const c = new SlideshowController(p, SHOW);
    c.enterBranch("deep"); // "deep" has only one slide
    expect(c.nextSlide).toBeNull();
  });
});

describe("SlideshowController branching", () => {
  it("enterBranch pushes onto the stack and enters the branch's first slide", () => {
    const p = fakePlayer();
    const c = new SlideshowController(p, SHOW);
    c.enterBranch("deep");
    expect(c.position.sequenceId).toBe("deep");
    expect(c.currentSlide?.sceneId).toBe("c");
    expect(p.seek).toHaveBeenLastCalledWith(11.5); // slide c midpoint
  });

  it("stops media when entering and leaving a branch", () => {
    const p = fakePlayer();
    const c = new SlideshowController(p, SHOW);

    c.enterBranch("deep");
    expect(p.stopMedia).toHaveBeenCalledTimes(1);

    c.back();
    expect(p.stopMedia).toHaveBeenCalledTimes(2);
  });

  it("counter is scoped to the current sequence", () => {
    const p = fakePlayer();
    const c = new SlideshowController(p, SHOW);
    c.enterBranch("deep");
    expect(c.counter).toEqual({ index: 1, total: 1 });
  });

  it("breadcrumb reflects the stack", () => {
    const { c } = showAtSlide1InDeep();
    expect(c.breadcrumb.map((b) => b.label)).toEqual(["Main deck", "Deep dive"]);
  });

  it("back returns to the exact parent slide", () => {
    const { c } = showAtSlide1InDeep();
    c.back();
    expect(c.position.sequenceId).toBe("main");
    expect(c.position.slideIndex).toBe(1);
  });

  it("backToMain clears nested branches to the root", () => {
    const { c } = showAtSlide1InDeep();
    c.backToMain();
    expect(c.breadcrumb.length).toBe(1);
    expect(c.position.slideIndex).toBe(1);
  });
});

describe("SlideshowController — fragmentIndex advances synchronously on next()", () => {
  it("construction lands on fragment 0; next() reveals fragment 1 immediately", () => {
    const p = fakePlayer();
    const c = new SlideshowController(p, SHOW);
    // Seek-only model: entering a fragmented slide shows its first fragment.
    expect(c.position.fragmentIndex).toBe(0);
    expect(p.seek).toHaveBeenLastCalledWith(2); // fragments[0]
    c.next();
    expect(c.position.fragmentIndex).toBe(1); // synchronous, no played tick
    expect(p.seek).toHaveBeenLastCalledWith(4); // fragments[1]
  });

  it("next() targets the FOLLOWING fragment (not slide end) while fragments remain", () => {
    const p = fakePlayer();
    const c = new SlideshowController(p, SHOW);
    c.next(); // fragment 0 → 1 (fragments[1]=4, NOT slide.end=5)
    expect(c.position.fragmentIndex).toBe(1);
    expect(p.seek).toHaveBeenLastCalledWith(4);
  });
});

describe("SlideshowController Fix 8b — back() restores parent fragmentIndex", () => {
  it("back() restores the saved fragmentIndex and seeks to the fragment time", () => {
    const { p, c } = showAtFrag1();
    expect(c.position.fragmentIndex).toBe(1);
    // Enter branch — saves frame {main, slideIndex:0, fragmentIndex:1}
    c.enterBranch("deep");
    expect(c.position.sequenceId).toBe("deep");
    // Back should restore main, slideIndex=0, fragmentIndex=1, seek to fragments[1]=4
    c.back();
    expect(c.position.sequenceId).toBe("main");
    expect(c.position.slideIndex).toBe(0);
    expect(c.position.fragmentIndex).toBe(1);
    expect(p.seek).toHaveBeenLastCalledWith(4); // fragments[1] = 4
  });

  it("resuming a fragmented slide at fragmentIndex -1 seeks to slide start", () => {
    const p = fakePlayer();
    const c = new SlideshowController(p, SHOW);
    // fragmentIndex -1 on a fragmented slide = before the first reveal. This state
    // is reachable via syncTo (audience mirror); resume should seek to slide.start.
    c.syncTo("main", 0, -1);
    expect(c.position.fragmentIndex).toBe(-1);
    expect(p.seek).toHaveBeenLastCalledWith(0); // slide a start
  });

  it("back() to a NO-fragment parent slide resumes at its midpoint, not frame 0", () => {
    const p = fakePlayer();
    const c = new SlideshowController(p, SHOW);
    c.goToSlide(1); // slide b: [5,10], no fragments
    c.enterBranch("deep");
    c.back();
    expect(c.position.slideIndex).toBe(1);
    // Mirrors enterSlide's no-fragment rest frame (midpoint) so the slide is
    // visible at rest instead of frozen at its pre-entrance frame-0.
    expect(p.seek).toHaveBeenLastCalledWith(7.5); // slide b midpoint (5 + 5*0.5)
  });
});

describe("SlideshowController unknown-sequence degradation", () => {
  it("enterBranch with an unknown id does not throw and leaves nav state unchanged", () => {
    const p = fakePlayer();
    const c = new SlideshowController(p, SHOW);
    c.goToSlide(1);
    expect(() => c.enterBranch("no-such-seq")).not.toThrow();
    expect(c.position.sequenceId).toBe("main");
    expect(c.position.slideIndex).toBe(1);
  });

  it("counter and currentSlide degrade gracefully when sequence is missing from show", () => {
    // Construct a show where sequences has no entries, then verify slidesOf([missing])
    // returns [] and counter/currentSlide do not throw.
    const showNoSeq: ResolvedSlideshow = {
      slides: [{ sceneId: "x", start: 0, end: 5, fragments: [], hotspots: [] }],
      sequences: {},
    };
    const p = fakePlayer();
    const c = new SlideshowController(p, showNoSeq);
    // enterBranch guards — no bogus frame gets pushed. counter on main is safe.
    expect(() => c.counter).not.toThrow();
    expect(c.counter).toEqual({ index: 1, total: 1 });
    // enterBranch with an unknown id: guard fires, state stays on main
    expect(() => c.enterBranch("ghost")).not.toThrow();
    expect(c.position.sequenceId).toBe("main");
    // breadcrumb does not throw for unknown sequence in stack (regression guard)
    expect(() => c.breadcrumb).not.toThrow();
    expect(c.breadcrumb[0]?.id).toBe("main");
  });
});

// ---------------------------------------------------------------------------
// Bug fix tests: #5-ctrl — enterSlide clears holdAt on empty-slide early return
// ---------------------------------------------------------------------------
describe("SlideshowController Fix #5-ctrl — enterBranch ignores empty branch", () => {
  it("enterBranch into an empty sequence is a no-op (does not enter the branch)", () => {
    // Build a show where "empty" sequence has no slides
    const show: ResolvedSlideshow = {
      slides: [{ sceneId: "a", start: 0, end: 5, fragments: [2], hotspots: [] }],
      sequences: {
        empty: { id: "empty", label: "Empty", slides: [] },
      },
    };
    const p = fakePlayer();
    const c = new SlideshowController(p, show);

    // Advance to a holdAt state by calling next() (sets holdAt to fragment 2)
    c.next();
    // Entering a branch that has no slides must be ignored — nav state unchanged.
    c.enterBranch("empty");
    expect(c.position.sequenceId).toBe("main");
    expect(c.position.slideIndex).toBe(0);
  });

  it("enterSlide(0) on an empty main sequence does not throw", () => {
    // This verifies the early-return path doesn't leave holdAt dirty
    const show: ResolvedSlideshow = {
      slides: [],
      sequences: {},
    };
    const p = fakePlayer();
    // Constructor calls enterSlide(0) — must not throw with empty slides
    expect(() => new SlideshowController(p, show)).not.toThrow();
  });
});

// ---------------------------------------------------------------------------
// Bug fix tests: #backToMain — uses resumeSlide to preserve fragment position
// ---------------------------------------------------------------------------
describe("SlideshowController Fix #backToMain — restores fragment position like back()", () => {
  it("backToMain restores the root frame's fragmentIndex (not reset to -1)", () => {
    const { p, c } = showAtFrag1();
    expect(c.position.fragmentIndex).toBe(1);

    // Enter branch — saves root frame with fragmentIndex=1
    c.enterBranch("deep");
    expect(c.position.sequenceId).toBe("deep");

    // backToMain should restore to main slideIndex=0, fragmentIndex=1 (not -1)
    c.backToMain();
    expect(c.position.sequenceId).toBe("main");
    expect(c.position.slideIndex).toBe(0);
    expect(c.position.fragmentIndex).toBe(1);
    // resumeSlide seeks to the fragment time (fragments[1]=4)
    expect(p.seek).toHaveBeenLastCalledWith(4);
  });

  it("backToMain restores the root fragment the branch was entered from", () => {
    const p = fakePlayer();
    const c = new SlideshowController(p, SHOW);

    // Construction lands on slide a fragment 0; enter a branch, then return.
    c.enterBranch("deep");
    c.backToMain();

    expect(c.position.slideIndex).toBe(0);
    expect(c.position.fragmentIndex).toBe(0);
    expect(p.seek).toHaveBeenLastCalledWith(2); // fragments[0]
  });

  it("backToMain with multiple nested branches restores root slide position", () => {
    const show: ResolvedSlideshow = {
      slides: [
        { sceneId: "a", start: 0, end: 5, fragments: [2], hotspots: [] },
        { sceneId: "b", start: 5, end: 10, fragments: [], hotspots: [] },
      ],
      sequences: {
        lvl1: {
          id: "lvl1",
          label: "Level 1",
          slides: [{ sceneId: "c", start: 10, end: 13, fragments: [], hotspots: [] }],
        },
      },
    };
    const p = fakePlayer();
    const c = new SlideshowController(p, show);
    c.goToSlide(1); // root at slide 1
    c.enterBranch("lvl1");

    // backToMain must pop all frames back to root
    c.backToMain();
    expect(c.breadcrumb.length).toBe(1);
    expect(c.position.sequenceId).toBe("main");
    expect(c.position.slideIndex).toBe(1);
  });
});

// ---------------------------------------------------------------------------
// Branch-edge navigation: prev/next at branch boundaries return to parent
// ---------------------------------------------------------------------------

// Show used only for branch-edge tests: 2 main slides + single- and multi-slide branches.
const SHOW_BRANCH_EDGE: ResolvedSlideshow = {
  slides: [
    { sceneId: "a", start: 0, end: 5, fragments: [], hotspots: [] },
    { sceneId: "b", start: 5, end: 10, fragments: [], hotspots: [] },
  ],
  sequences: {
    single: {
      id: "single",
      label: "Single slide branch",
      slides: [{ sceneId: "x", start: 10, end: 13, fragments: [], hotspots: [] }],
    },
    multi: {
      id: "multi",
      label: "Multi slide branch",
      slides: [
        { sceneId: "y", start: 13, end: 16, fragments: [], hotspots: [] },
        { sceneId: "z", start: 16, end: 20, fragments: [], hotspots: [] },
      ],
    },
  },
};

/** Factory: controller on SHOW_BRANCH_EDGE, already inside the given branch. */
function inBranch(branchId: string): { c: SlideshowController } {
  const p = fakePlayer();
  const c = new SlideshowController(p, SHOW_BRANCH_EDGE);
  c.enterBranch(branchId);
  return { c };
}

describe("SlideshowController branch-edge nav — prev/next return to parent", () => {
  it("single-slide branch: prev() returns to parent", () => {
    const { c } = inBranch("single");
    expect(c.breadcrumb.length).toBe(2);
    c.prev();
    expect(c.position.sequenceId).toBe("main");
    expect(c.breadcrumb.length).toBe(1);
  });

  it("single-slide branch: next() (no fragments, last slide) returns to parent", () => {
    const { c } = inBranch("single");
    expect(c.breadcrumb.length).toBe(2);
    c.next();
    expect(c.position.sequenceId).toBe("main");
    expect(c.breadcrumb.length).toBe(1);
  });

  it("multi-slide branch: prev() from slide 1 → slide 0, NOT popped", () => {
    const { c } = inBranch("multi");
    c.goToSlide(1);
    c.prev();
    expect(c.position.sequenceId).toBe("multi");
    expect(c.position.slideIndex).toBe(0);
    expect(c.breadcrumb.length).toBe(2);
  });

  it("multi-slide branch: prev() from slide 0 → parent", () => {
    const { c } = inBranch("multi");
    c.prev();
    expect(c.position.sequenceId).toBe("main");
    expect(c.breadcrumb.length).toBe(1);
  });

  it("multi-slide branch: next() from slide 0 → slide 1, NOT popped", () => {
    const { c } = inBranch("multi");
    c.next();
    expect(c.position.sequenceId).toBe("multi");
    expect(c.position.slideIndex).toBe(1);
    expect(c.breadcrumb.length).toBe(2);
  });

  it("multi-slide branch: next() from slide 1 (last) → parent", () => {
    const { c } = inBranch("multi");
    c.goToSlide(1);
    c.next();
    expect(c.position.sequenceId).toBe("main");
    expect(c.breadcrumb.length).toBe(1);
  });

  it("main line: prev() at slide 0 is a no-op (stack.length === 1)", () => {
    const p = fakePlayer();
    const c = new SlideshowController(p, SHOW_BRANCH_EDGE);
    c.prev();
    expect(c.position.sequenceId).toBe("main");
    expect(c.position.slideIndex).toBe(0);
    expect(c.breadcrumb.length).toBe(1);
  });

  it("main line: next() at last slide is a no-op (does NOT call back)", () => {
    const p = fakePlayer();
    const c = new SlideshowController(p, SHOW_BRANCH_EDGE);
    c.goToSlide(1);
    c.next();
    expect(c.position.sequenceId).toBe("main");
    expect(c.position.slideIndex).toBe(1);
    expect(c.breadcrumb.length).toBe(1);
  });
});

// ---------------------------------------------------------------------------
// canPrev / canNext getters
// ---------------------------------------------------------------------------
describe("SlideshowController canPrev / canNext", () => {
  it("main first slide: canPrev=false, canNext=true", () => {
    const p = fakePlayer();
    const c = new SlideshowController(p, SHOW_BRANCH_EDGE);
    expect(c.canPrev).toBe(false);
    expect(c.canNext).toBe(true);
  });

  it("main last slide: canPrev=true, canNext=false", () => {
    const p = fakePlayer();
    const c = new SlideshowController(p, SHOW_BRANCH_EDGE);
    c.goToSlide(1); // last slide (total=2)
    expect(c.canPrev).toBe(true);
    expect(c.canNext).toBe(false);
  });

  it("main middle slide: canPrev=true, canNext=true", () => {
    // Use SHOW (3+ slides via SHOW_BRANCH_EDGE is only 2; use a 3-slide show)
    const threeSlideShow: ResolvedSlideshow = {
      slides: [
        { sceneId: "a", start: 0, end: 5, fragments: [], hotspots: [] },
        { sceneId: "b", start: 5, end: 10, fragments: [], hotspots: [] },
        { sceneId: "c", start: 10, end: 15, fragments: [], hotspots: [] },
      ],
      sequences: {},
    };
    const p = fakePlayer();
    const c = new SlideshowController(p, threeSlideShow);
    c.goToSlide(1);
    expect(c.canPrev).toBe(true);
    expect(c.canNext).toBe(true);
  });

  it("single-slide main: canPrev=false, canNext=false", () => {
    const oneSlide: ResolvedSlideshow = {
      slides: [{ sceneId: "only", start: 0, end: 5, fragments: [], hotspots: [] }],
      sequences: {},
    };
    const p = fakePlayer();
    const c = new SlideshowController(p, oneSlide);
    expect(c.canPrev).toBe(false);
    expect(c.canNext).toBe(false);
  });

  it("inside a branch (first slide): canPrev=true (parent is prev), canNext=true (next-within or parent)", () => {
    const { c } = inBranch("single");
    // single-slide branch, slideIndex=0, stack.length=2
    expect(c.canPrev).toBe(true);
    expect(c.canNext).toBe(true);
  });

  it("inside a multi-slide branch (first slide): canPrev=true, canNext=true", () => {
    const { c } = inBranch("multi");
    // slideIndex=0, next slide exists within branch
    expect(c.canPrev).toBe(true);
    expect(c.canNext).toBe(true);
  });

  it("inside a multi-slide branch (last slide): canPrev=true, canNext=true (parent is next)", () => {
    const { c } = inBranch("multi");
    c.goToSlide(1); // last slide in branch
    expect(c.canPrev).toBe(true);
    expect(c.canNext).toBe(true);
  });
});

// ---------------------------------------------------------------------------
// next() reveals remaining fragments even when playback is already at slide end
// (the atEnd gate was removed so a no-animation jump to slide end still steps
// through pending fragments rather than skipping straight to the next slide).
// ---------------------------------------------------------------------------
describe("SlideshowController next() — reveals remaining fragments at slide end", () => {
  it("reveals the next fragment even when currentTime is already at slide end", () => {
    const p = fakePlayer();
    const c = new SlideshowController(p, SHOW);
    // Static jump to slide end; pending fragments should still be revealed in order
    // (the playhead position doesn't gate fragment stepping).
    p.currentTime = 5; // slide a end
    c.next(); // fragment 0 → 1, stays on slide a
    expect(c.position.slideIndex).toBe(0);
    expect(c.position.fragmentIndex).toBe(1);
    expect(p.seek).toHaveBeenLastCalledWith(4); // fragments[1], not slide b
  });
});

// ---------------------------------------------------------------------------
// syncTo — absolute, animation-free position mirroring for the audience window.
// ---------------------------------------------------------------------------
describe("SlideshowController syncTo", () => {
  it("re-roots to a branch sequence and restores slide+fragment statically", () => {
    const p = fakePlayer();
    const c = new SlideshowController(p, SHOW);
    c.syncTo("deep", 0, -1);
    expect(c.position.sequenceId).toBe("deep");
    expect(c.position.slideIndex).toBe(0);
    // Slide c has no fragments, so resumeSlide lands at its midpoint (restFrame) —
    // the same visible-at-rest position enterSlide uses — not slide start. A single
    // seek both repaints and holds (no sustained playback).
    expect(p.seek).toHaveBeenLastCalledWith(11.5); // slide c midpoint (10 + 3*0.5)
    expect(p.play).not.toHaveBeenCalled();
  });

  it("syncs a main-line slide+fragment position without animating", () => {
    const p = fakePlayer();
    const c = new SlideshowController(p, SHOW);
    c.syncTo("main", 0, 1); // slide a, fragmentIndex 1 → fragments[1]=4
    expect(c.position.sequenceId).toBe("main");
    expect(c.position.slideIndex).toBe(0);
    expect(c.position.fragmentIndex).toBe(1);
    expect(p.seek).toHaveBeenLastCalledWith(4); // fragments[1] = 4
  });

  it("does not stop media when syncing only the fragment within the same slide", () => {
    const p = fakePlayer();
    const c = new SlideshowController(p, SHOW);

    c.syncTo("main", 0, 1);

    expect(p.stopMedia).not.toHaveBeenCalled();
  });

  it("stops media when audience sync moves to a different slide or sequence", () => {
    const p = fakePlayer();
    const c = new SlideshowController(p, SHOW);

    c.syncTo("main", 1, -1);
    expect(p.stopMedia).toHaveBeenCalledTimes(1);

    c.syncTo("deep", 0, -1);
    expect(p.stopMedia).toHaveBeenCalledTimes(2);
  });

  it("ignores an unknown sequence target", () => {
    const p = fakePlayer();
    const c = new SlideshowController(p, SHOW);
    c.syncTo("nope", 0, -1);
    expect(c.position.sequenceId).toBe("main");
  });

  it("ignores an out-of-range slide index", () => {
    const p = fakePlayer();
    const c = new SlideshowController(p, SHOW);
    c.syncTo("main", 99, -1);
    expect(c.position.slideIndex).toBe(0);
  });
});

describe("SlideshowController autoplay", () => {
  // "v" autoplays; "w" does not. Both sit on the main line.
  const AUTOPLAY_SHOW: ResolvedSlideshow = {
    slides: [
      { sceneId: "v", start: 0, end: 5, fragments: [], hotspots: [], autoplay: true },
      { sceneId: "w", start: 5, end: 10, fragments: [], hotspots: [] },
    ],
    sequences: {},
  };

  it("plays the slide's media on enter when autoplay is set", () => {
    const p = fakePlayer();
    new SlideshowController(p, AUTOPLAY_SHOW); // constructs on slide "v"
    expect(p.playSceneMedia).toHaveBeenCalledWith("v");
    expect(p.playSceneMedia).toHaveBeenCalledTimes(1);
  });

  it("does not play media when entering a non-autoplay slide", () => {
    const p = fakePlayer();
    const c = new SlideshowController(p, AUTOPLAY_SHOW);
    p.playSceneMedia.mockClear();
    c.next(); // v → w (w is not autoplay)
    expect(c.position.slideIndex).toBe(1);
    expect(p.playSceneMedia).not.toHaveBeenCalled();
  });

  it("stops prior media and plays again when navigating back into an autoplay slide", () => {
    const p = fakePlayer();
    const c = new SlideshowController(p, AUTOPLAY_SHOW);
    p.playSceneMedia.mockClear();
    c.next(); // → w
    expect(p.stopMedia).toHaveBeenCalled(); // leaving v stops its clip
    p.playSceneMedia.mockClear();
    c.prev(); // back into v (enterSlide) → replays
    expect(p.playSceneMedia).toHaveBeenCalledWith("v");
  });

  it("does not require autoplay support on the port (optional hook)", () => {
    // A port without playSceneMedia must not throw when entering an autoplay slide.
    const { playSceneMedia: _omitted, ...port } = fakePlayer();
    expect(() => new SlideshowController(port, AUTOPLAY_SHOW)).not.toThrow();
  });
});
