// @vitest-environment happy-dom

import { describe, expect, it } from "vitest";
import {
  DEFAULT_PREVIEW_ZOOM,
  MAX_PREVIEW_ZOOM_PERCENT,
  MIN_PREVIEW_ZOOM_PERCENT,
  canStartPreviewPan,
  clampPreviewPan,
  clampPreviewZoomPercent,
  getNextPreviewZoomPercent,
  getPreviewWheelZoomPercent,
  ownsPreviewPanTarget,
  PREVIEW_PAN_OVERSCROLL_PX,
  PREVIEW_PAN_SURFACE_SELECTOR,
  resolvePreviewWheelPan,
  resolvePreviewWheelZoom,
  toDomPrecision,
} from "./previewZoom";

describe("toDomPrecision", () => {
  it("rounds to 4 decimal places", () => {
    expect(toDomPrecision(1.23456789)).toBe(1.2346);
  });

  it("preserves zero", () => {
    expect(toDomPrecision(0)).toBe(0);
  });

  it("handles negative values", () => {
    expect(toDomPrecision(-3.14159)).toBe(-3.1416);
  });
});

describe("clampPreviewZoomPercent", () => {
  it("falls back to fit zoom for invalid input", () => {
    expect(clampPreviewZoomPercent(Number.NaN)).toBe(100);
  });

  it("clamps to supported preview zoom bounds", () => {
    expect(clampPreviewZoomPercent(1)).toBe(MIN_PREVIEW_ZOOM_PERCENT);
    expect(clampPreviewZoomPercent(5000)).toBe(MAX_PREVIEW_ZOOM_PERCENT);
  });
});

describe("getPreviewWheelZoomPercent", () => {
  it("zooms in on negative deltaY (scroll up / pinch out)", () => {
    expect(getPreviewWheelZoomPercent(-5, 100)).toBeGreaterThan(100);
  });

  it("zooms out on positive deltaY (scroll down / pinch in)", () => {
    expect(getPreviewWheelZoomPercent(5, 200)).toBeLessThan(200);
  });

  it("clamps large deltas to prevent overshoot", () => {
    const small = getPreviewWheelZoomPercent(-5, 100);
    const large = getPreviewWheelZoomPercent(-50, 100);
    expect(large).toBeLessThan(small * 2);
  });

  it("preserves the current zoom for invalid input", () => {
    expect(getPreviewWheelZoomPercent(Number.NaN, 180)).toBe(180);
  });
});

describe("getNextPreviewZoomPercent", () => {
  it("steps preview zoom in and out", () => {
    expect(getNextPreviewZoomPercent("in", 100)).toBe(125);
    expect(getNextPreviewZoomPercent("out", 125)).toBe(100);
  });
});

describe("clampPreviewPan", () => {
  it("allows a small overscroll margin at fit zoom", () => {
    const next = clampPreviewPan({
      panX: 900,
      panY: -900,
      zoomPercent: 100,
      viewportWidth: 800,
      viewportHeight: 600,
    });

    expect(next.panX).toBe(PREVIEW_PAN_OVERSCROLL_PX);
    expect(next.panY).toBe(-PREVIEW_PAN_OVERSCROLL_PX);
  });

  it("keeps pan within the zoomed preview bounds", () => {
    expect(
      clampPreviewPan({
        panX: 900,
        panY: -900,
        zoomPercent: 200,
        viewportWidth: 800,
        viewportHeight: 600,
      }),
    ).toEqual({
      panX: 400 + PREVIEW_PAN_OVERSCROLL_PX,
      panY: -(300 + PREVIEW_PAN_OVERSCROLL_PX),
    });
  });

  it("allows overscroll even when only one axis overflows", () => {
    expect(
      clampPreviewPan({
        panX: 120,
        panY: -90,
        zoomPercent: 107.25,
        viewportWidth: 1352,
        viewportHeight: 682,
        contentWidth: 1184,
        contentHeight: 666,
      }),
    ).toEqual({
      panX: PREVIEW_PAN_OVERSCROLL_PX,
      panY: -(16.142499999999984 + PREVIEW_PAN_OVERSCROLL_PX),
    });
  });
});

describe("canStartPreviewPan", () => {
  it("allows middle mouse pan at fit zoom", () => {
    expect(canStartPreviewPan(1)).toBe(true);
  });

  it("allows middle mouse pan when zoomed in", () => {
    expect(canStartPreviewPan(1)).toBe(true);
  });

  it("rejects other mouse buttons", () => {
    expect(canStartPreviewPan(0)).toBe(false);
    expect(canStartPreviewPan(2)).toBe(false);
  });
});

describe("ownsPreviewPanTarget", () => {
  it("accepts targets inside the preview stage", () => {
    const stage = document.createElement("div");
    const child = document.createElement("div");
    stage.appendChild(child);

    expect(ownsPreviewPanTarget(child, stage)).toBe(true);
  });

  it("accepts targets inside the shared preview pan surface", () => {
    const surface = document.createElement("div");
    surface.setAttribute("data-preview-pan-surface", "true");
    const overlay = document.createElement("div");
    surface.appendChild(overlay);

    expect(ownsPreviewPanTarget(overlay, null)).toBe(true);
  });

  it("rejects targets outside the preview stage and preview pan surface", () => {
    const outside = document.createElement("div");

    expect(ownsPreviewPanTarget(outside, null)).toBe(false);
  });

  it("uses the shared preview pan surface selector contract", () => {
    expect(PREVIEW_PAN_SURFACE_SELECTOR).toBe('[data-preview-pan-surface="true"]');
  });
});

describe("resolvePreviewWheelZoom", () => {
  it("zooms in from center without shifting pan", () => {
    const next = resolvePreviewWheelZoom({
      state: DEFAULT_PREVIEW_ZOOM,
      deltaY: -5,
      viewportWidth: 800,
      viewportHeight: 600,
    });

    expect(next.zoomPercent).toBeGreaterThan(100);
    expect(next.panX).toBe(0);
    expect(next.panY).toBe(0);
  });

  it("preserves small pan inside the overscroll margin when zooming out past minimum", () => {
    const next = resolvePreviewWheelZoom({
      state: { zoomPercent: 26, panX: 20, panY: 20 },
      deltaY: 500,
      viewportWidth: 800,
      viewportHeight: 600,
    });

    expect(next.zoomPercent).toBeCloseTo(MIN_PREVIEW_ZOOM_PERCENT, 0);
    expect(next.panX).toBe(20);
    expect(next.panY).toBe(20);
  });

  it("zooms toward the cursor when cursorX/cursorY are provided", () => {
    const next = resolvePreviewWheelZoom({
      state: DEFAULT_PREVIEW_ZOOM,
      deltaY: -5,
      viewportWidth: 800,
      viewportHeight: 600,
      cursorX: 200,
      cursorY: 100,
    });

    expect(next.zoomPercent).toBeGreaterThan(100);
    expect(next.panX).toBeLessThan(0);
    expect(next.panY).toBeLessThan(0);
  });

  it("keeps pan at zero when cursor is at viewport center", () => {
    const next = resolvePreviewWheelZoom({
      state: DEFAULT_PREVIEW_ZOOM,
      deltaY: -5,
      viewportWidth: 800,
      viewportHeight: 600,
      cursorX: 0,
      cursorY: 0,
    });

    expect(next.zoomPercent).toBeGreaterThan(100);
    expect(next.panX).toBe(0);
    expect(next.panY).toBe(0);
  });

  it("scales pan proportionally when cursor is at center", () => {
    const next = resolvePreviewWheelZoom({
      state: { zoomPercent: 200, panX: 50, panY: 30 },
      deltaY: -5,
      viewportWidth: 800,
      viewportHeight: 600,
      contentWidth: 800,
      contentHeight: 450,
      cursorX: 0,
      cursorY: 0,
    });

    const ratio = next.zoomPercent / 200;
    expect(next.panX).toBeCloseTo(50 * ratio, 1);
    expect(next.panY).toBeCloseTo(30 * ratio, 1);
  });

  it("keeps the content point under a non-center cursor fixed after zoom", () => {
    const cursorX = 150;
    const cursorY = -80;
    const state: PreviewZoomState = { zoomPercent: 150, panX: 20, panY: -10 };
    const oldScale = state.zoomPercent / 100;

    const next = resolvePreviewWheelZoom({
      state,
      deltaY: -5,
      viewportWidth: 800,
      viewportHeight: 600,
      contentWidth: 800,
      contentHeight: 450,
      cursorX,
      cursorY,
    });

    const newScale = next.zoomPercent / 100;
    const contentXBefore = (cursorX - state.panX) / oldScale;
    const contentXAfter = (cursorX - next.panX) / newScale;
    const contentYBefore = (cursorY - state.panY) / oldScale;
    const contentYAfter = (cursorY - next.panY) / newScale;

    expect(contentXAfter).toBeCloseTo(contentXBefore, 6);
    expect(contentYAfter).toBeCloseTo(contentYBefore, 6);
  });

  it("uses wider pan range for cursor zoom than manual drag", () => {
    let state: PreviewZoomState = { zoomPercent: 100, panX: 0, panY: 0 };
    for (let i = 0; i < 40; i++) {
      state = resolvePreviewWheelZoom({
        state,
        deltaY: 5,
        viewportWidth: 800,
        viewportHeight: 600,
        contentWidth: 800,
        contentHeight: 450,
        cursorX: -300,
        cursorY: 0,
      });
    }

    expect(state.zoomPercent).toBeLessThan(100);

    const dragClamped = clampPreviewPan({
      panX: state.panX,
      panY: state.panY,
      zoomPercent: state.zoomPercent,
      viewportWidth: 800,
      viewportHeight: 600,
      contentWidth: 800,
      contentHeight: 450,
    });

    expect(Math.abs(state.panX)).toBeGreaterThan(Math.abs(dragClamped.panX));
  });
});

describe("resolvePreviewWheelPan", () => {
  it("moves preview pan from wheel deltas", () => {
    const next = resolvePreviewWheelPan({
      state: DEFAULT_PREVIEW_ZOOM,
      deltaX: 18,
      deltaY: -12,
      viewportWidth: 800,
      viewportHeight: 600,
    });

    expect(next.zoomPercent).toBe(100);
    expect(next.panX).toBe(-18);
    expect(next.panY).toBe(12);
  });

  it("keeps wheel pan inside overscroll bounds", () => {
    const next = resolvePreviewWheelPan({
      state: DEFAULT_PREVIEW_ZOOM,
      deltaX: -900,
      deltaY: 900,
      viewportWidth: 800,
      viewportHeight: 600,
    });

    expect(next.panX).toBe(PREVIEW_PAN_OVERSCROLL_PX);
    expect(next.panY).toBe(-PREVIEW_PAN_OVERSCROLL_PX);
  });
});
