// @vitest-environment happy-dom

import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import type { GsapAnimation, PropertyGroupName } from "@hyperframes/core/gsap-parser";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TimelinePropertyLanes } from "./TimelinePropertyLanes";
import { TimelineTrackHeader } from "./TimelineTrackHeader";
import { defaultTimelineTheme } from "./timelineTheme";
import { type TimelineElement } from "../store/playerStore";
import type { TimelineEditCallbacks } from "./timelineCallbacks";
import { getTimelineLaneTop, LABEL_COL_W, TRACK_H } from "./timelineLayout";
import { AUTOMATION_LANE_H } from "./automationLaneHeight";

(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;

/** Enrolled canaries for the render under test. Both audio canaries sit at 0%,
 *  so the default here is "enrolled in nothing" — the state a real user is in. */
const enabledCanaries = new Set<string>();
vi.mock("../../telemetry/canary", () => ({
  isCanaryEnabled: (name: string) => enabledCanaries.has(name),
}));

beforeEach(() => {
  enabledCanaries.clear();
});

afterEach(() => {
  document.body.innerHTML = "";
});

const ELEMENT: TimelineElement = {
  id: "clip-1",
  label: "Hero card",
  tag: "div",
  start: 0,
  duration: 2,
  track: 0,
};

function animation(
  id: string,
  propertyGroup: PropertyGroupName,
  keyframes: Array<{
    percentage: number;
    properties: Record<string, number | string>;
    ease?: string;
  }>,
): GsapAnimation {
  return {
    id,
    targetSelector: "#clip-1",
    method: "to",
    position: 0,
    duration: 2,
    properties: {},
    propertyGroup,
    keyframes: { format: "percentage", keyframes },
  };
}

const POSITION = animation("position-tween", "position", [
  { percentage: 0, properties: { x: 0, y: 0 } },
  { percentage: 50, properties: { x: 100, y: 50 } },
  { percentage: 100, properties: { x: 200, y: 100 } },
]);

const OPACITY = animation("opacity-tween", "visual", [
  { percentage: 0, properties: { opacity: 0 } },
  { percentage: 50, properties: { opacity: 0.5 } },
  { percentage: 100, properties: { opacity: 1 } },
]);

interface RenderHeaderOptions {
  keyframeClip?: TimelineElement;
  /** Every clip on the track; defaults to just the keyframe clip. */
  trackElements?: readonly TimelineElement[];
  animations?: GsapAnimation[];
  clipCount?: number;
  currentTime?: number;
  expanded?: boolean;
  onSeek?: (time: number) => void;
  onTogglePropertyGroupKeyframe?: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
  onToggleTrackHidden?: TimelineEditCallbacks["onToggleTrackHidden"];
  onRemoveAutomationLane?: (target: string) => void;
  isAudioTrack?: boolean;
  isGroupMember?: boolean;
  isTrackHidden?: boolean;
}

function renderHeader(options: RenderHeaderOptions = {}): {
  host: HTMLDivElement;
  root: Root;
  rerender: (next: RenderHeaderOptions) => void;
} {
  const host = document.createElement("div");
  document.body.append(host);
  const root = createRoot(host);
  const render = (raw: RenderHeaderOptions) => {
    // Defaults resolved once, up front, rather than as a `??` per prop in the
    // JSX — a dozen of those is a dozen branches through one arrow.
    const next = {
      keyframeClip: ELEMENT,
      clipCount: 1,
      animations: [POSITION, OPACITY],
      currentTime: 0,
      isAudioTrack: false,
      isGroupMember: false,
      isTrackHidden: false,
      onToggleTrackHidden: vi.fn(),
      ...raw,
    };
    act(() => {
      root.render(
        <TimelineTrackHeader
          // A real fractional z-order sort key, so a label built from it would
          // read out "track 0.16666666666666666".
          trackNumber={1 / 6}
          trackDisplayNumber={1}
          trackLabel="Hero card"
          lanesId="timeline-lanes-track-0"
          contentOrigin={LABEL_COL_W}
          keyframeClip={next.keyframeClip}
          trackElements={next.trackElements ?? [next.keyframeClip]}
          clipCount={next.clipCount}
          isExpanded={next.expanded !== false}
          animations={next.animations}
          currentTime={next.currentTime}
          isTrackHidden={next.isTrackHidden}
          isAudioTrack={next.isAudioTrack}
          isGroupMember={next.isGroupMember}
          theme={defaultTimelineTheme}
          onToggleClipExpanded={vi.fn()}
          onToggleTrackHidden={next.onToggleTrackHidden}
          onTogglePropertyGroupKeyframe={next.onTogglePropertyGroupKeyframe}
          onRemoveAutomationLane={next.onRemoveAutomationLane}
          onSeek={next.onSeek}
        />,
      );
    });
  };
  render(options);
  return { host, root, rerender: render };
}

function click(host: HTMLElement, label: string) {
  const button = host.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`);
  expect(button).not.toBeNull();
  act(() => button?.click());
}

describe("TimelineTrackHeader", () => {
  // §5: gain stages multiply. A group fading to 0.42 under a clip fading to
  // 0.80 plays at 0.34, and an author who drew both hears something quieter
  // than either with nothing on screen to say why. Not a warning; an
  // explanation.
  it("says so when the clip's group is fading the same parameter", () => {
    const automation = JSON.stringify({
      version: 1,
      lanes: [
        {
          target: "volume",
          points: [
            { t: 0, v: 1 },
            { t: 2, v: 0.4 },
          ],
        },
      ],
    });
    const clip: TimelineElement = {
      ...ELEMENT,
      tag: "audio",
      automation,
      audioGroup: "voiceover",
      audioGroupLabel: "Voiceover",
      audioGroupAutomation: automation,
    };
    const view = renderHeader({ keyframeClip: clip, trackElements: [clip], animations: [] });
    expect(view.host.textContent).toContain("Voiceover is also fading this.");
    act(() => view.root.unmount());
  });

  // The same clip with an un-automated group must stay quiet — the note is
  // only honest when the two curves actually multiply.
  it("stays quiet when the group automates nothing", () => {
    const automation = JSON.stringify({
      version: 1,
      lanes: [
        {
          target: "volume",
          points: [
            { t: 0, v: 1 },
            { t: 2, v: 0.4 },
          ],
        },
      ],
    });
    const clip: TimelineElement = {
      ...ELEMENT,
      tag: "audio",
      automation,
      audioGroup: "voiceover",
      audioGroupLabel: "Voiceover",
    };
    const view = renderHeader({ keyframeClip: clip, trackElements: [clip], animations: [] });
    expect(view.host.textContent).not.toContain("is also fading this");
    act(() => view.root.unmount());
  });

  // An expanded sub-composition child sits on the MASTER timeline at a
  // host-absolute start, but its tweens are parsed from its own file and are
  // local to it. Feeding the raw start straight into the clip-% math put every
  // lane keyframe far outside the clip.
  it("keeps an expanded sub-comp child's lane percentages inside the clip", () => {
    const child: TimelineElement = {
      id: "pill",
      tag: "div",
      start: 16.5,
      duration: 2,
      track: 0,
      expandedParentStart: 16,
      sourceFile: "scene.html",
    };
    const local: GsapAnimation = {
      id: "pill-tween",
      targetSelector: "#pill",
      method: "to",
      position: 0.5,
      resolvedStart: 0.5,
      duration: 2,
      properties: {},
      propertyGroup: "position",
      keyframes: {
        format: "percentage",
        keyframes: [
          { percentage: 0, properties: { x: 0 } },
          { percentage: 100, properties: { x: 100 } },
        ],
      },
    };
    // Playhead at the clip's midpoint (master time), so the 100% keyframe is
    // ahead of it. On the raw host-absolute basis every keyframe rebased to a
    // large negative percentage and nothing was ever ahead of the playhead.
    const view = renderHeader({
      keyframeClip: child,
      animations: [local],
      currentTime: 17.5,
    });

    expect(
      view.host.querySelector<HTMLButtonElement>('button[aria-label="Next Position keyframe"]')
        ?.disabled,
    ).toBe(false);
    act(() => view.root.unmount());
  });

  // The header shows one clip's lanes, so how many clips the track holds is
  // otherwise invisible from the label column. A single-clip track stays silent.
  it("shows the track's clip count only once the track holds more than one clip", () => {
    const view = renderHeader({ clipCount: 1 });
    expect(view.host.querySelector('[aria-label="1 clips"]')).toBeNull();

    view.rerender({ clipCount: 3 });
    expect(view.host.querySelector('[aria-label="3 clips"]')?.textContent).toBe("3");
    act(() => view.root.unmount());
  });

  // The visibility control is the old hide eye. On an audio track it silences
  // rather than hides, and the row already says so with a speaker elsewhere —
  // so the eye's slot stays empty there. A non-audio track is untouched.
  it("keeps the visibility control off audio track headers", () => {
    const audio: TimelineElement = { ...ELEMENT, tag: "audio" };
    const view = renderHeader({
      keyframeClip: audio,
      trackElements: [audio],
      isAudioTrack: true,
      animations: [],
    });
    const labels = Array.from(view.host.querySelectorAll("button")).map((b) =>
      b.getAttribute("aria-label"),
    );
    expect(labels.some((l) => l && /^(Hide|Show) track/.test(l))).toBe(false);
    expect(labels).not.toContain("Mute");
    act(() => view.root.unmount());
  });

  // The escape hatch. `data-hidden` on audio silences it in preview and drops it
  // from the render; the panel's "Muted" is the unrelated HTML `muted`
  // attribute, and nothing else writes it. Withholding the eye unconditionally
  // meant a track hidden by "Hide all" (or by hand, or before that rule existed)
  // was silent with no control anywhere to bring it back.
  it("offers the eye on an audio track that is already hidden, so it can be restored", () => {
    const audio: TimelineElement = { ...ELEMENT, tag: "audio" };
    const view = renderHeader({
      keyframeClip: audio,
      trackElements: [audio],
      isAudioTrack: true,
      isTrackHidden: true,
      animations: [],
    });
    const labels = Array.from(view.host.querySelectorAll("button")).map((b) =>
      b.getAttribute("aria-label"),
    );
    expect(labels.some((l) => l && /^Show track/.test(l))).toBe(true);
    act(() => view.root.unmount());
  });

  it("keeps it on a non-audio track", () => {
    const view = renderHeader({ isAudioTrack: false });
    const labels = Array.from(view.host.querySelectorAll("button")).map((b) =>
      b.getAttribute("aria-label"),
    );
    expect(labels.some((l) => l && /^Hide track/.test(l))).toBe(true);
    act(() => view.root.unmount());
  });

  // The eye acts on the layer, so it has to be reachable without a pointer and
  // in every disclosure state — a hover-gated eye is unusable by keyboard.
  it("keeps the visibility eye mounted whether the layer is expanded or collapsed", () => {
    const view = renderHeader({ expanded: true });
    expect(view.host.querySelector('button[aria-label="Hide track 1"]')).not.toBeNull();

    view.rerender({ expanded: false });
    expect(view.host.querySelector('button[aria-label="Hide track 1"]')).not.toBeNull();
    act(() => view.root.unmount());
  });

  // trackNumber is a fractional z-order sort key, so building the label from it
  // made screen readers announce "Hide track 0.16666666666666666". The display
  // number is label-only; the toggle still routes by the real key.
  it("announces the display track number but toggles with the real fractional key", () => {
    const onToggleTrackHidden = vi.fn();
    const view = renderHeader({ onToggleTrackHidden });
    const eye = view.host.querySelector<HTMLButtonElement>('button[aria-label="Hide track 1"]');

    expect(eye).not.toBeNull();
    expect(eye?.title).toBe("Hide track 1");
    expect(view.host.innerHTML).not.toContain("0.16666666666666666");

    act(() => eye?.click());
    expect(onToggleTrackHidden).toHaveBeenCalledWith(1 / 6, true);
    act(() => view.root.unmount());
  });

  it("adds and removes a keyframe on the explicitly targeted property-group tween", () => {
    const onTogglePropertyGroupKeyframe = vi.fn();
    const view = renderHeader({ currentTime: 0.5, onTogglePropertyGroupKeyframe });

    click(view.host, "Add Opacity keyframe");
    expect(onTogglePropertyGroupKeyframe).toHaveBeenLastCalledWith(
      ELEMENT,
      expect.objectContaining({
        animationId: "opacity-tween",
        propertyGroup: "visual",
        tweenPercentage: 25,
        properties: { opacity: 0.25 },
        remove: false,
      }),
    );

    view.rerender({ currentTime: 1, onTogglePropertyGroupKeyframe });
    click(view.host, "Remove Opacity keyframe");
    expect(onTogglePropertyGroupKeyframe).toHaveBeenLastCalledWith(
      ELEMENT,
      expect.objectContaining({
        animationId: "opacity-tween",
        propertyGroup: "visual",
        tweenPercentage: 50,
        properties: { opacity: 0.5 },
        remove: true,
      }),
    );
    expect(onTogglePropertyGroupKeyframe).not.toHaveBeenCalledWith(
      ELEMENT,
      expect.objectContaining({ animationId: "position-tween" }),
    );
    act(() => view.root.unmount());
  });

  it("seeks only to the selected group's adjacent keyframes", () => {
    const onSeek = vi.fn();
    const view = renderHeader({
      currentTime: 1,
      animations: [
        POSITION,
        animation("opacity-tween", "visual", [
          { percentage: 25, properties: { opacity: 0.25 } },
          { percentage: 50, properties: { opacity: 0.5 } },
          { percentage: 75, properties: { opacity: 0.75 } },
        ]),
      ],
      onSeek,
    });

    click(view.host, "Next Position keyframe");
    expect(onSeek).toHaveBeenLastCalledWith(2);
    click(view.host, "Previous Position keyframe");
    expect(onSeek).toHaveBeenLastCalledWith(0);
    expect(onSeek).not.toHaveBeenCalledWith(1.5);
    act(() => view.root.unmount());
  });

  // The lane header sits inside the track row, whose own click handler selects
  // the track. Every control in the label column has to own its click, or
  // seeking to a keyframe also reselects whatever is behind the header.
  it("keeps lane-header control clicks off the ancestor track row", () => {
    const onAncestorClick = vi.fn();
    const view = renderHeader({
      currentTime: 1,
      onSeek: vi.fn(),
      onTogglePropertyGroupKeyframe: vi.fn(),
    });
    // React 18 delegates from the root container, so an ancestor of it is where
    // a leaked click actually shows up.
    document.body.addEventListener("click", onAncestorClick);

    // Every control in the lane's label column, found by row rather than by
    // label, so a wording change to one button can't silently drop it here.
    const controls = view.host.querySelectorAll<HTMLButtonElement>(
      '[data-property-group="position"] button',
    );
    expect(controls.length).toBeGreaterThanOrEqual(3);
    for (const button of controls) {
      act(() => {
        button.dispatchEvent(new MouseEvent("click", { bubbles: true }));
      });
    }

    document.body.removeEventListener("click", onAncestorClick);
    expect(onAncestorClick).not.toHaveBeenCalled();
    act(() => view.root.unmount());
  });

  it("fills the toggle diamond exactly at that group's keyframe", () => {
    const view = renderHeader({ currentTime: 0.5 });
    const positionToggle = view.host.querySelector<HTMLButtonElement>(
      'button[aria-label="Add Position keyframe"]',
    );
    expect(positionToggle?.textContent).toBe("◇");

    view.rerender({ currentTime: 1 });
    expect(
      view.host.querySelector<HTMLButtonElement>('button[aria-label="Remove Position keyframe"]')
        ?.textContent,
    ).toBe("◆");
    act(() => view.root.unmount());
  });

  it("updates formatted group values when the playhead moves", () => {
    const view = renderHeader({ currentTime: 0.5 });
    expect(view.host.querySelector('[data-property-group="position"]')?.textContent).toContain(
      "50, 25",
    );
    expect(view.host.querySelector('[data-property-group="visual"]')?.textContent).toContain("25%");

    view.rerender({ currentTime: 1.5 });
    expect(view.host.querySelector('[data-property-group="position"]')?.textContent).toContain(
      "150, 75",
    );
    expect(view.host.querySelector('[data-property-group="visual"]')?.textContent).toContain("75%");
    act(() => view.root.unmount());
  });

  it("samples mid-segment values along the segment's ease, not linearly", () => {
    // GSAP hangs a segment's ease on the keyframe it arrives at, so 0% -> 50%
    // runs power2.in. Half way through that segment power2.in(0.5) = 0.125, so
    // the readout is 12.5/6.25 and NOT the linear 50/25.
    const eased = animation("eased-position", "position", [
      { percentage: 0, properties: { x: 0, y: 0 } },
      { percentage: 50, properties: { x: 100, y: 50 }, ease: "power2.in" },
      { percentage: 100, properties: { x: 200, y: 100 }, ease: "power2.in" },
    ]);
    const onTogglePropertyGroupKeyframe = vi.fn();
    const view = renderHeader({
      animations: [eased],
      currentTime: 0.5,
      onTogglePropertyGroupKeyframe,
    });

    expect(view.host.querySelector('[data-property-group="position"]')?.textContent).toContain(
      "12.5, 6.25",
    );

    // The same sampled value is what an added keyframe gets stamped with, so a
    // header insert lands on the existing curve instead of deforming it.
    click(view.host, "Add Position keyframe");
    expect(onTogglePropertyGroupKeyframe).toHaveBeenCalledOnce();
    expect(onTogglePropertyGroupKeyframe.mock.calls[0][1]).toMatchObject({
      properties: { x: 12.5, y: 6.25 },
    });
    act(() => view.root.unmount());
  });

  it("disables the previous chevron at or before the group's first keyframe", () => {
    const view = renderHeader({ currentTime: 0 });
    const prevAt0 = view.host.querySelector<HTMLButtonElement>(
      'button[aria-label="Previous Position keyframe"]',
    );
    expect(prevAt0).not.toBeNull();
    expect(prevAt0?.disabled).toBe(true);

    view.rerender({ currentTime: 1 });
    const prevAt1 = view.host.querySelector<HTMLButtonElement>(
      'button[aria-label="Previous Position keyframe"]',
    );
    expect(prevAt1?.disabled).toBe(false);
    act(() => view.root.unmount());
  });

  it("uses the same lane row offsets when collapsed, expanded once, and expanded multiple times", () => {
    const view = renderHeader({ expanded: false });
    expect(view.host.querySelectorAll("[data-timeline-lane-top]")).toHaveLength(0);

    const assertAligned = (animations: GsapAnimation[]) => {
      view.rerender({ animations });
      const lanesHost = document.createElement("div");
      document.body.append(lanesHost);
      const lanesRoot = createRoot(lanesHost);
      act(() => {
        lanesRoot.render(
          <TimelinePropertyLanes
            id="timeline-property-lanes-alignment-test"
            animations={animations}
            clipStart={0}
            clipDuration={2}
            clipLeftPx={120}
            clipWidthPx={200}
            accentColor="#3CE6AC"
            isSelected
            currentPercentage={0}
            elementId="clip-1"
            selectedKeyframes={new Set()}
          />,
        );
      });
      expect(
        Array.from(view.host.querySelectorAll<HTMLElement>("[data-timeline-lane-top]")).map(
          (row) => row.style.top,
        ),
      ).toEqual(
        Array.from(lanesHost.querySelectorAll<HTMLElement>("[data-timeline-lane-top]")).map(
          (row) => row.style.top,
        ),
      );
      expect(
        Array.from(lanesHost.querySelectorAll<HTMLElement>("[data-timeline-property-lane]")).map(
          (row) => row.style.left,
        ),
      ).toEqual(animations.map(() => "120px"));
      act(() => lanesRoot.unmount());
    };

    assertAligned([POSITION]);
    assertAligned([POSITION, OPACITY]);
    act(() => view.root.unmount());
  });

  /**
   * Automation lanes are named in the label column, on the same tree as the
   * keyframe rows — not painted inside the lane, where the name sat on top of
   * the envelope it belonged to and scrolled away from its own row.
   */
  describe("audio automation rows", () => {
    const BED: TimelineElement = {
      id: "bed",
      label: "Music Bed",
      tag: "audio",
      start: 0,
      duration: 10,
      track: 0,
      fxChain: JSON.stringify({
        version: 1,
        nodes: [{ type: "peaking", id: "n1", params: { frequency: 1600, gain: -6, q: 1.4 } }],
      }),
      automation: JSON.stringify({
        version: 1,
        lanes: [
          {
            target: "volume",
            points: [
              { t: 0, v: 1 },
              { t: 5, v: 0.4 },
            ],
          },
          {
            target: "fx.n1.gain",
            points: [
              { t: 0, v: 0 },
              { t: 5, v: -6 },
            ],
          },
        ],
      }),
    } as TimelineElement;

    it("names every envelope in the label column", () => {
      const { host, root } = renderHeader({ keyframeClip: BED, animations: [] });
      const rows = Array.from(host.querySelectorAll<HTMLElement>("[data-automation-lane-label]"));
      // The attribute is the ROW's identity, which is the label: a row can hold
      // several clips' envelopes, whose lane targets differ from each other.
      expect(rows.map((r) => r.getAttribute("data-automation-lane-label"))).toEqual([
        "Peaking EQ 1.6 kHz · Gain",
        "Volume",
      ]);
      // A band is named by its frequency: with several of them, "Peaking EQ" says
      // nothing about which is which. Bands sit above the level lanes.
      // Two lines per row: what the effect is, then which knob the envelope
      // drives. One line truncated mid-word in a column this narrow.
      expect(rows.map((r) => r.querySelector("[data-automation-lane-name]")?.textContent)).toEqual([
        "Peaking EQ 1.6 kHz",
        "Volume",
      ]);
      expect(rows.map((r) => r.querySelector("[data-automation-lane-param]")?.textContent)).toEqual(
        [
          "Gain",
          // Volume has no effect behind it, so it has no second line at all.
          undefined,
        ],
      );
      act(() => root.unmount());
    });

    it("hides them when the track is collapsed", () => {
      const { host, root } = renderHeader({
        keyframeClip: BED,
        animations: [],
        expanded: false,
      });
      expect(host.querySelectorAll("[data-automation-lane-label]")).toHaveLength(0);
      act(() => root.unmount());
    });

    it("removes just that envelope from the label column", () => {
      // The panel's automate toggle can only reach a parameter it still shows; a
      // carve's own lanes are not in it at all, so without this an envelope could
      // be created and never deleted.
      const onRemoveAutomationLane = vi.fn();
      const { host, root } = renderHeader({
        keyframeClip: BED,
        animations: [],
        onRemoveAutomationLane,
      });
      const button = host.querySelector<HTMLButtonElement>(
        'button[aria-label="Remove Peaking EQ 1.6 kHz · Gain automation"]',
      );
      expect(button).not.toBeNull();
      act(() => button?.click());
      expect(onRemoveAutomationLane).toHaveBeenCalledWith("fx.n1.gain");
      act(() => root.unmount());
    });

    it("offers no remove button when the lanes are read-only", () => {
      const { host, root } = renderHeader({ keyframeClip: BED, animations: [] });
      expect(host.querySelectorAll('button[aria-label$="automation"]')).toHaveLength(0);
      act(() => root.unmount());
    });

    it("stacks each envelope's row where its lane is drawn", () => {
      // Same rhythm the canvas uses: automation begins below the keyframe lanes
      // and steps by its own taller row height.
      const { host, root } = renderHeader({ keyframeClip: BED, animations: [OPACITY] });
      const tops = Array.from(
        host.querySelectorAll<HTMLElement>("[data-automation-lane-label]"),
      ).map((r) => r.style.top);
      const base = getTimelineLaneTop(1);
      expect(tops).toEqual([`${base}px`, `${base + AUTOMATION_LANE_H}px`]);
      act(() => root.unmount());
    });
  });

  /**
   * Several clips on one row share a lane row per property, so the label column
   * has to name the row for the property and the header for the track — not for
   * whichever clip happens to be selected.
   */
  describe("a track several clips share", () => {
    const clip = (id: string, over: Partial<TimelineElement>): TimelineElement =>
      ({
        id,
        key: id,
        label: id,
        tag: "audio",
        start: 0,
        duration: 4,
        track: 0,
        ...over,
      }) as TimelineElement;
    const PEAKING = (gain: number) =>
      JSON.stringify({
        version: 1,
        nodes: [{ type: "peaking", id: "n1", params: { frequency: 1000, gain, q: 1.4 } }],
      });
    const NARRATION_1 = clip("narration-1", {
      fxChain: PEAKING(-3),
      automation: JSON.stringify({
        version: 1,
        lanes: [{ target: "fx.n1.gain", points: [{ t: 0, v: -3 }] }],
      }),
    });
    const NARRATION_2 = clip("narration-2", {
      start: 4,
      fxChain: PEAKING(-6),
      automation: JSON.stringify({
        version: 1,
        lanes: [
          { target: "fx.n1.gain", points: [{ t: 0, v: -6 }] },
          { target: "volume", points: [{ t: 0, v: 1 }] },
        ],
      }),
    });
    const ROW = { trackElements: [NARRATION_1, NARRATION_2], clipCount: 2, animations: [] };

    it("lists every clip's envelopes, whichever clip is selected", () => {
      const labels = (host: HTMLElement) =>
        Array.from(host.querySelectorAll("[data-automation-lane-label]")).map((r) =>
          r.getAttribute("data-automation-lane-label"),
        );
      const first = renderHeader({ ...ROW, keyframeClip: NARRATION_1 });
      const second = renderHeader({ ...ROW, keyframeClip: NARRATION_2 });
      // narration-1 has no volume envelope, but the row is still there — it is
      // the track's, and it was its sibling's before the selection moved.
      expect(labels(first.host)).toEqual(["Peaking EQ 1 kHz · Gain", "Volume"]);
      expect(labels(second.host)).toEqual(labels(first.host));
      act(() => first.root.unmount());
      act(() => second.root.unmount());
    });

    it("removes only from the clip it is showing, and offers nothing where it has no lane", () => {
      // A write can only reach the selected clip, so a button on a row that clip
      // is absent from could only remove nothing, or somebody else's envelope.
      const onRemoveAutomationLane = vi.fn();
      const { host, root } = renderHeader({
        ...ROW,
        keyframeClip: NARRATION_1,
        onRemoveAutomationLane,
      });
      expect(
        Array.from(host.querySelectorAll('button[aria-label$="automation"]')).map((b) =>
          b.getAttribute("aria-label"),
        ),
      ).toEqual(["Remove Peaking EQ 1 kHz · Gain automation"]);
      act(() => host.querySelector<HTMLButtonElement>('button[aria-label$="automation"]')?.click());
      expect(onRemoveAutomationLane).toHaveBeenCalledWith("fx.n1.gain");
      act(() => root.unmount());
    });

    it("names the header for the track, not for one of the clips on it", () => {
      const view = renderHeader({ ...ROW, keyframeClip: NARRATION_2 });
      expect(view.host.textContent).not.toContain("narration-2");
      expect(view.host.querySelector('[title="Track 1"]')?.textContent).toBe("Track 1");
      // Alone on the track it is still named for itself.
      view.rerender({
        ...ROW,
        keyframeClip: NARRATION_2,
        trackElements: [NARRATION_2],
        clipCount: 1,
      });
      expect(view.host.querySelector('[title="narration-2"]')?.textContent).toBe("narration-2");
      act(() => view.root.unmount());
    });
  });

  describe("audio ids and canary gates", () => {
    const VOICE: TimelineElement = {
      id: "voice-1",
      key: "index.html#voice-1",
      domId: "voice-1",
      tag: "audio",
      start: 0,
      duration: 5,
      track: 0,
    };
    const VOICE_2: TimelineElement = {
      ...VOICE,
      id: "voice-2",
      key: "index.html#voice-2",
      domId: "voice-2",
    };

    // The set is pushed straight into the runtime, which compares it against
    // `el.id`. A store key here matches nothing, `isAudibleUnderSolo` returns
    // false for every element, and soloing silences the whole preview.
    // A member row is `aria-level="2"`, and without this it looked identical to
    // every top-level row — the nesting existed for a screen reader and not for
    // an eye. B2's design called for the accent rail; only the semantics shipped.
    it("indents a group member's row and gives it the accent rail", () => {
      const view = renderHeader({
        keyframeClip: VOICE,
        animations: [],
        expanded: false,
        isAudioTrack: true,
      });
      const header = () => view.host.querySelector<HTMLElement>('[role="rowheader"]');

      expect(header()?.style.paddingLeft).toBe("");
      expect(header()?.style.borderLeft).toBe("");
      expect(header()?.style.background).not.toContain("linear-gradient");

      view.rerender({
        keyframeClip: VOICE,
        animations: [],
        expanded: false,
        isAudioTrack: true,
        isGroupMember: true,
      });
      expect(header()?.style.paddingLeft).toBe("14px");
      expect(header()?.style.borderLeft).toContain("2px");
      // And a lighter gutter, so the row reads as sitting INSIDE its group
      // rather than beside it. Overlaid on the theme's own fill rather than a
      // hard-coded colour, so it follows whatever the gutter is.
      expect(header()?.style.background).toContain("linear-gradient");
      act(() => view.root.unmount());
    });

    it("hides the FX button outside the audio-fx-rack canary", () => {
      const view = renderHeader({
        keyframeClip: VOICE,
        animations: [],
        expanded: false,
        isAudioTrack: true,
      });
      expect(view.host.querySelector('button[aria-label="Effects"]')).toBeNull();
      enabledCanaries.add("audio-fx-rack");
      view.rerender({
        keyframeClip: VOICE,
        animations: [],
        expanded: false,
        isAudioTrack: true,
      });
      expect(view.host.querySelector('button[aria-label="Effects"]')).not.toBeNull();
      act(() => view.root.unmount());
    });

    // The group-pointer variant WRITES a group, so it needs the groups canary
    // too — otherwise an unenrolled user creates a group and then has no UI to
    // manage it.
    it("hides the group pointer unless BOTH audio canaries are on", () => {
      const opts = {
        keyframeClip: VOICE,
        trackElements: [VOICE, VOICE_2],
        clipCount: 2,
        animations: [],
        expanded: false,
        isAudioTrack: true,
      };
      const pointer = (host: HTMLElement) =>
        host.querySelector('button[aria-label="Effects — group these clips first"]');
      const view = renderHeader(opts);
      expect(pointer(view.host)).toBeNull();
      enabledCanaries.add("audio-fx-rack");
      view.rerender({ ...opts });
      expect(pointer(view.host)).toBeNull();
      enabledCanaries.add("audio-groups");
      view.rerender({ ...opts });
      expect(pointer(view.host)).not.toBeNull();
      act(() => view.root.unmount());
    });

    // The header is a 48px column of exactly TWO lines — what the row is, then
    // what you can do to it. The group pointer used to render as a sibling of
    // both, making a third: 17 + 24 + 24 + gaps in a 48px box, which
    // `justify-center` then spilled evenly out of the top and bottom. The name
    // rode 10px above its own row and the pointer collided with the row below.
    // The header GROWS by AUTOMATION_LANE_H for every open lane, and the lanes
    // are absolutely positioned from its top. `justify-center` on the header
    // itself therefore centred the two lines in the FULL height, so opening a
    // lane pushed the name and its controls down on top of the lane rows.
    it("pins the two lines to the top TRACK_H, whatever the header grows to", () => {
      enabledCanaries.add("audio-fx-rack");
      const automated: TimelineElement = {
        ...VOICE,
        automation: JSON.stringify({
          version: 1,
          lanes: [{ target: "volume", points: [{ t: 0, v: 1 }] }],
        }),
      };
      const view = renderHeader({
        keyframeClip: automated,
        trackElements: [automated],
        clipCount: 1,
        animations: [],
        expanded: true,
        isAudioTrack: true,
      });
      const header = view.host.querySelector<HTMLElement>('[role="rowheader"]');
      const lines = header?.children[0] as HTMLElement | undefined;
      expect(lines?.style.height).toBe(`${TRACK_H}px`);
      // The lane row is a sibling of the wrapper, not inside it — it stacks
      // BELOW the two lines rather than sharing their box.
      expect((header?.children.length ?? 0) > 1).toBe(true);
      act(() => view.root.unmount());
    });

    // The header is ONE line: name, clip count, then every control anchored to
    // the right edge. It was two — a name line and a control line — which is
    // what let a stray third child overflow the 48px box; now there is a single
    // row and the controls share one right-aligned group.
    it("keeps the name and every control on one line, controls to the right", () => {
      enabledCanaries.add("audio-fx-rack");
      enabledCanaries.add("audio-groups");
      const view = renderHeader({
        keyframeClip: VOICE,
        trackElements: [VOICE, VOICE_2],
        clipCount: 2,
        animations: [],
        expanded: false,
        isAudioTrack: true,
      });
      const header = view.host.querySelector<HTMLElement>('[role="rowheader"]');
      // One TRACK_H-tall wrapper holding one line.
      const lines = header?.children[0];
      expect(lines?.children).toHaveLength(1);
      // The controls live in a right-anchored group at the end of that line,
      // after the name and the clip count — `ml-auto` is what holds the edge.
      const line = lines?.children[0];
      const controls = line?.lastElementChild as HTMLElement | null;
      expect(controls?.className).toContain("ml-auto");
      expect(
        controls?.querySelector('button[aria-label="Effects — group these clips first"]'),
      ).not.toBeNull();
      // And the clip count is beside the name, not out with the controls.
      expect(controls?.querySelector('[aria-label="2 clips"]')).toBeNull();
      expect(line?.querySelector('[aria-label="2 clips"]')).not.toBeNull();
      act(() => view.root.unmount());
    });
  });
});
