import { beforeEach, describe, expect, it, vi } from "vitest";
import {
  captureLivePreviewTargets,
  clearLivePreview,
  datesMatch,
  joinTransforms,
  preserveLivePreview,
  scheduleLivePreview,
  toPxValue,
  type LivePreviewSession,
} from "./timelineLivePreview.js";

function createElement(style?: Partial<CSSStyleDeclaration>): HTMLElement {
  return {
    style: {
      left: "",
      width: "",
      transform: "",
      ...style,
    },
  } as HTMLElement;
}

function createSession(element: HTMLElement): LivePreviewSession {
  return {
    mode: "move",
    cellWidth: 32,
    targets: [
      {
        element,
        baseLeft: 12,
        baseWidth: 48,
        baseTransform: "scale(1)",
      },
    ],
    pointerDeltaX: 0,
    daysDelta: 0,
    frame: null,
  };
}

function renderScheduledPreview(session: LivePreviewSession) {
  const frameCallbacks: unknown[] = [];
  vi.stubGlobal(
    "requestAnimationFrame",
    vi.fn((callback: unknown) => {
      frameCallbacks.push(callback);
      return 1;
    }),
  );

  scheduleLivePreview(session);
  const frameCallback = frameCallbacks[0] as ((timestamp: number) => void) | undefined;
  frameCallback?.(0);
}

describe("timelineLivePreview", () => {
  beforeEach(() => {
    vi.unstubAllGlobals();
    vi.restoreAllMocks();
  });

  it("parses invalid pixel values as zero", () => {
    expect(toPxValue("")).toBe(0);
    expect(toPxValue("auto")).toBe(0);
    expect(toPxValue("12.5px")).toBe(12.5);
  });

  it("joins transforms while ignoring empty and none entries", () => {
    expect(joinTransforms(" translateX(2px) ", "", "none", undefined, "scale(1)")).toBe(
      "translateX(2px) scale(1)",
    );
  });

  it("deduplicates live preview targets while preserving base styles", () => {
    const element = createElement({
      left: "20px",
      width: "64px",
      transform: "translateX(4px)",
    });

    const targets = captureLivePreviewTargets([element, element]);

    expect(targets).toHaveLength(1);
    expect(targets[0]).toMatchObject({
      baseLeft: 20,
      baseWidth: 64,
      baseTransform: "translateX(4px)",
    });
  });

  it("cancels a pending frame and restores base preview styles when clearing", () => {
    const element = createElement({
      left: "40px",
      width: "80px",
      transform: "translateX(10px)",
    });

    const session = createSession(element);
    const cancelAnimationFrameSpy = vi.fn();
    vi.stubGlobal("cancelAnimationFrame", cancelAnimationFrameSpy);

    session.frame = 23;
    session.targets[0]!.element.style.left = "99px";
    session.targets[0]!.element.style.width = "120px";
    session.targets[0]!.element.style.transform = "translateX(99px)";

    clearLivePreview(session);

    expect(cancelAnimationFrameSpy).toHaveBeenCalledWith(23);
    expect(session.frame).toBeNull();
    expect(element.style.left).toBe("12px");
    expect(element.style.width).toBe("48px");
    expect(element.style.transform).toBe("scale(1)");
  });

  it("snapshots current styles and clears pending frames when preserving", () => {
    const element = createElement({
      left: "75px",
      width: "112px",
      transform: "translateX(6px)",
    });

    const session = createSession(element);
    const cancelAnimationFrameSpy = vi.fn();
    vi.stubGlobal("cancelAnimationFrame", cancelAnimationFrameSpy);

    session.frame = 11;

    preserveLivePreview(session);

    expect(cancelAnimationFrameSpy).toHaveBeenCalledWith(11);
    expect(session.frame).toBeNull();
    expect(session.targets[0]).toMatchObject({
      baseLeft: 75,
      baseWidth: 112,
      baseTransform: "translateX(6px)",
    });
  });

  it("matches dates only when both timestamps are present and equal", () => {
    const date = new Date("2025-05-10T00:00:00.000Z");

    expect(datesMatch(date, new Date(date))).toBe(true);
    expect(datesMatch(date, new Date("2025-05-11T00:00:00.000Z"))).toBe(false);
    expect(datesMatch(date, null)).toBe(false);
    expect(datesMatch(null, null)).toBe(false);
  });

  it("renders move previews with snapped left offsets and residual transforms", () => {
    const element = createElement();
    const session = createSession(element);

    session.pointerDeltaX = 50;
    session.daysDelta = 1;

    renderScheduledPreview(session);

    expect(session.frame).toBeNull();
    expect(element.style.left).toBe("44px");
    expect(element.style.width).toBe("48px");
    expect(element.style.transform).toBe("scale(1) translateX(18px)");
  });

  it("clamps resize-start previews to the minimum cell width", () => {
    const element = createElement();
    const session = createSession(element);

    session.mode = "resize-start";
    session.cellWidth = 20;
    session.targets[0]!.baseLeft = 10;
    session.targets[0]!.baseWidth = 60;
    session.pointerDeltaX = 80;
    session.daysDelta = 1;

    renderScheduledPreview(session);

    expect(element.style.left).toBe("50px");
    expect(element.style.width).toBe("20px");
    expect(element.style.transform).toBe("scale(1)");
  });

  it("clamps resize-end previews to the minimum cell width", () => {
    const element = createElement();
    const session = createSession(element);

    session.mode = "resize-end";
    session.cellWidth = 20;
    session.targets[0]!.baseLeft = 10;
    session.targets[0]!.baseWidth = 30;
    session.pointerDeltaX = -40;
    session.daysDelta = 0;

    renderScheduledPreview(session);

    expect(element.style.left).toBe("10px");
    expect(element.style.width).toBe("20px");
    expect(element.style.transform).toBe("scale(1)");
  });

  it("schedules at most one animation frame for a preview session", () => {
    const element = createElement();
    const session = createSession(element);
    const requestAnimationFrameSpy = vi.fn(() => 7);
    vi.stubGlobal("requestAnimationFrame", requestAnimationFrameSpy);

    scheduleLivePreview(session);
    scheduleLivePreview(session);

    expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1);
    expect(session.frame).toBe(7);
  });
});
