import { describe, it, expect } from "vitest";
import {
  DOUBLE_TAP_WINDOW_MS,
  SEEK_FORWARD_SECONDS,
  SEEK_BACKWARD_SECONDS,
  createTapGestureState,
  registerTap,
  resolveSeekTarget,
  clampSeekTarget,
  END_SEEK_MARGIN_SECONDS,
  isSynthesizedTouchClick,
  isControlSurfaceTouch,
  TOUCH_CLICK_SUPPRESS_MS,
} from "./tapGestures";

const SCREEN_WIDTH = 1000;
const LEFT = 100;
const RIGHT = 900;

function tap(state: ReturnType<typeof createTapGestureState>, x: number, at: number) {
  return registerTap(state, { x, screenWidth: SCREEN_WIDTH, now: at });
}

/** Narrow a tap outcome to the seek variant, failing the test if it is not one. */
function asSeek(outcome: ReturnType<typeof tap>) {
  if (outcome.action !== "seek") {
    throw new Error(`expected a seek outcome, got "${outcome.action}"`);
  }
  return outcome;
}

describe("tap gesture resolution", () => {
  // Every tap acts IMMEDIATELY — there is no deferral and no timer.
  //
  //   1st tap: toggle play/pause
  //   2nd tap: seek, then toggle play/pause AGAIN
  //
  // The second toggle undoes the first, so a double tap seeks while leaving the
  // play state exactly as it was: playing -> jump and keep playing; paused ->
  // jump and stay paused. The old design deferred the first tap behind a 300ms
  // timer, which raced the synthesized click and produced a pause/unpause loop.

  it("toggles play/pause immediately on the first tap", () => {
    const state = createTapGestureState();
    expect(tap(state, RIGHT, 1000)).toEqual({ action: "togglePlayPause" });
  });

  it("seeks forward 30s AND toggles again on a second right-side tap", () => {
    const state = createTapGestureState();
    tap(state, RIGHT, 1000);
    const second = asSeek(tap(state, RIGHT, 1150));

    expect(second.seekSeconds).toBe(SEEK_FORWARD_SECONDS);
    expect(second.seekSeconds).toBe(30);
    expect(second.feedback).toBe("right");
    // The re-toggle is what preserves the play state across a double tap.
    expect(second.togglePlayPause).toBe(true);
  });

  it("seeks back 10s AND toggles again on a second left-side tap", () => {
    const state = createTapGestureState();
    tap(state, LEFT, 1000);
    const second = asSeek(tap(state, LEFT, 1100));

    expect(second.seekSeconds).toBe(SEEK_BACKWARD_SECONDS);
    expect(second.seekSeconds).toBe(-10);
    expect(second.feedback).toBe("left");
    expect(second.togglePlayPause).toBe(true);
  });

  it("net play state is unchanged by a double tap (two toggles cancel out)", () => {
    const state = createTapGestureState();
    let playing = true;
    const apply = (outcome: ReturnType<typeof tap>) => {
      if (outcome.action === "togglePlayPause") playing = !playing;
      else if (outcome.action === "seek" && outcome.togglePlayPause) playing = !playing;
    };

    apply(tap(state, RIGHT, 1000)); // toggle -> paused
    apply(tap(state, RIGHT, 1100)); // seek + toggle -> playing again
    expect(playing).toBe(true);

    // And from paused, a double tap leaves it paused.
    playing = false;
    apply(tap(state, RIGHT, 2000));
    apply(tap(state, RIGHT, 2100));
    expect(playing).toBe(false);
  });

  it("treats a tap after the window as a fresh first tap", () => {
    const state = createTapGestureState();
    tap(state, RIGHT, 1000);
    const late = tap(state, RIGHT, 1000 + DOUBLE_TAP_WINDOW_MS + 1);

    expect(late.action).toBe("togglePlayPause");
  });

  it("only ever has first and second taps — the tap after a pair is a fresh toggle", () => {
    const state = createTapGestureState();
    tap(state, RIGHT, 1000);
    expect(tap(state, RIGHT, 1100).action).toBe("seek");

    // The pair is consumed. The next tap is a FIRST tap again, so it toggles
    // play/pause — there is no "third tap" concept.
    expect(tap(state, RIGHT, 1200).action).toBe("togglePlayPause");
  });

  it("accumulates repeated double taps on the same side", () => {
    const state = createTapGestureState();
    tap(state, RIGHT, 1000);
    const a = asSeek(tap(state, RIGHT, 1100));
    tap(state, RIGHT, 1200);
    const b = asSeek(tap(state, RIGHT, 1300));

    expect(a.seekSeconds).toBe(30);
    expect(b.seekSeconds).toBe(30);
  });

  it("uses the tap side, so a double tap split across halves follows the second tap", () => {
    const state = createTapGestureState();
    tap(state, LEFT, 1000);
    const second = asSeek(tap(state, RIGHT, 1100));

    expect(second.seekSeconds).toBe(SEEK_FORWARD_SECONDS);
    expect(second.feedback).toBe("right");
  });

  it("cancel() makes the next tap a fresh first tap (swipe interrupted the pair)", () => {
    const state = createTapGestureState();
    tap(state, RIGHT, 1000);
    state.cancel();

    // Without cancel() this would have been the seeking second tap.
    expect(tap(state, RIGHT, 1100).action).toBe("togglePlayPause");
  });
});

describe("seek target resolution", () => {
  const DURATION = 600;

  it("adds the delta to the reported position", () => {
    expect(resolveSeekTarget({ delta: 30, reportedPosition: 100, duration: DURATION })).toBe(130);
  });

  it("clamps to zero when rewinding past the start", () => {
    expect(resolveSeekTarget({ delta: -10, reportedPosition: 4, duration: DURATION })).toBe(0);
  });

  it("clamps short of the duration when skipping past the end", () => {
    // Never land exactly on `duration`: hls.js would then request the segment
    // that starts at/after the media end, which the server never produces —
    // the fetch times out and the gap-controller stalls in a pause loop.
    expect(resolveSeekTarget({ delta: 30, reportedPosition: 590, duration: DURATION })).toBe(
      DURATION - END_SEEK_MARGIN_SECONDS
    );
  });

  it("keeps the end clamp strictly inside the media for a long transcoded item", () => {
    // Regression: seeking near the end of a ~105min transcoded item clamped to
    // the exact runtime (6330.324s), making hls.js fetch segment 1055 which
    // starts at 6336.33s — past the end. That segment 404s/times out forever.
    const runtime = 6330.324;
    const target = resolveSeekTarget({ delta: 30, reportedPosition: 6320, duration: runtime });

    expect(target).toBeLessThan(runtime);
    expect(target).toBeCloseTo(runtime - END_SEEK_MARGIN_SECONDS, 5);
  });

  it("does not clamp below zero for media shorter than the end margin", () => {
    expect(resolveSeekTarget({ delta: 30, reportedPosition: 1, duration: 1 })).toBe(0);
  });

  it("chains off a pending target so rapid taps do not compound off a stale position", () => {
    // The player has not yet reported the first seek's result, so the
    // reported position is still the pre-seek value.
    const first = resolveSeekTarget({ delta: 30, reportedPosition: 100, duration: DURATION });
    const second = resolveSeekTarget({
      delta: 30,
      reportedPosition: 100,
      duration: DURATION,
      pendingTarget: first,
    });

    expect(second).toBe(160);
  });

  it("ignores a pending target once the player has caught up past it", () => {
    const target = resolveSeekTarget({
      delta: 30,
      reportedPosition: 200,
      duration: DURATION,
      pendingTarget: 130,
    });

    expect(target).toBe(230);
  });

  it("falls back to the delta alone when duration is unknown", () => {
    expect(resolveSeekTarget({ delta: 30, reportedPosition: 100, duration: 0 })).toBe(130);
  });
});

describe("control-surface touches are not gestures", () => {
  // Regression: the gesture listener is on the outer container and touch events
  // bubble, so tapping the bottom play/pause button ran the gesture handler
  // (toggle #1) AND the button's own click handler (toggle #2). The two
  // cancelled out and the control appeared dead.
  it("treats a tap on a button as a control, not a gesture", () => {
    expect(isControlSurfaceTouch([{ tag: "svg" }, { tag: "button" }, { tag: "div" }])).toBe(true);
  });

  it("treats the seek bar input as a control", () => {
    expect(isControlSurfaceTouch([{ tag: "input" }, { tag: "div" }])).toBe(true);
  });

  it("treats anything inside the controls bar as a control", () => {
    expect(
      isControlSurfaceTouch([{ tag: "span" }, { tag: "div", isPlayerControls: true }])
    ).toBe(true);
  });

  it("lets a tap on the bare video surface through as a gesture", () => {
    expect(isControlSurfaceTouch([{ tag: "video" }, { tag: "div" }, { tag: "div" }])).toBe(false);
  });

  it("is case-insensitive about tag names", () => {
    expect(isControlSurfaceTouch([{ tag: "BUTTON" }])).toBe(true);
  });
});

describe("synthesized touch-click suppression", () => {
  // Regression: pausing renders a full-screen play-overlay button over the
  // video, so the compatibility click Android synthesizes from the tap lands on
  // the OVERLAY, not the <video>. With no guard there it re-toggled and undid
  // the pause — pausing looked impossible while unpausing worked fine (the
  // overlay is removed when playing, so nothing intercepted that direction).
  it("suppresses a click with detail 0 (clearly synthesized)", () => {
    expect(isSynthesizedTouchClick(0, 10_000, 0)).toBe(true);
  });

  it("suppresses a real-detail click that closely follows a touch tap", () => {
    const tapAt = 10_000;
    expect(isSynthesizedTouchClick(1, tapAt + 120, tapAt)).toBe(true);
    expect(isSynthesizedTouchClick(1, tapAt + TOUCH_CLICK_SUPPRESS_MS - 1, tapAt)).toBe(true);
  });

  it("allows a genuine mouse click well after any touch", () => {
    const tapAt = 10_000;
    expect(isSynthesizedTouchClick(1, tapAt + TOUCH_CLICK_SUPPRESS_MS + 1, tapAt)).toBe(false);
  });

  it("allows a genuine mouse click when no touch has ever happened", () => {
    expect(isSynthesizedTouchClick(1, 10_000, 0)).toBe(false);
  });
});

describe("seek target clamping (shared by skip and seek-bar drag)", () => {
  it("keeps a mid-stream target untouched", () => {
    expect(clampSeekTarget(100, 600)).toBe(100);
  });

  it("pulls a drag to the very end back inside the media", () => {
    // The seek bar's max IS the duration, so dragging fully right yields
    // exactly `duration` — the value that triggers the dead-segment stall.
    expect(clampSeekTarget(6330.324, 6330.324)).toBeCloseTo(6330.324 - END_SEEK_MARGIN_SECONDS, 5);
  });

  it("clamps negative and non-finite targets to zero", () => {
    expect(clampSeekTarget(-5, 600)).toBe(0);
    expect(clampSeekTarget(NaN, 600)).toBe(0);
  });

  it("leaves the target alone when the duration is unknown", () => {
    expect(clampSeekTarget(500, 0)).toBe(500);
  });
});
