/**
 * Tap-gesture interpretation for the video player surface.
 *
 * Every tap acts IMMEDIATELY — there are only first and second taps, and no
 * deferral:
 *
 *   1st tap: toggle play/pause
 *   2nd tap (within the window): 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 started — playing stays playing, paused stays paused.
 *
 * This replaced a design that deferred the first tap behind a 300ms timer so it
 * could be cancelled if a second tap arrived. That deferral raced the
 * compatibility `click` Android's WebView synthesizes after a touch tap: the
 * timer cleared its own handle *before* running the toggle, reopening the guard
 * that was meant to suppress the late click, which then toggled a second time.
 * The result was a play/pause loop about a second apart. Acting immediately
 * removes the timer, the window race, and the loop.
 *
 * TRACES: UR-005, UR-061 | DR-092, DR-095, DR-098 | UT-085, UT-086, UT-087, UT-088
 */

/** A second tap within this window pairs with the previous one (seek + re-toggle). */
export const DOUBLE_TAP_WINDOW_MS = 300;

/**
 * How long after a touch tap a mouse `click` is assumed to be the compatibility
 * event the browser synthesizes from that touch. Android's WebView can deliver it
 * noticeably late, so this is generous.
 */
export const TOUCH_CLICK_SUPPRESS_MS = 700;

/**
 * Whether a touch landed on an interactive control rather than the bare video
 * surface, and so must NOT be interpreted as a play/pause or seek gesture.
 *
 * The gesture listener sits on the outer container, and touch events bubble, so
 * without this a tap on the bottom control bar runs the gesture handler (toggle
 * #1) *and* the button's own click handler (toggle #2) — the two cancel out and
 * the button appears dead. Buttons, links, inputs (the seek bar), and anything
 * inside an element marked `data-player-controls` are treated as controls.
 *
 * Takes the ancestor chain as plain tag/attribute pairs so the rule is unit
 * testable without a DOM.
 */
export function isControlSurfaceTouch(
  ancestors: Array<{ tag: string; isPlayerControls?: boolean; isPlayerSurface?: boolean }>
): boolean {
  const INTERACTIVE = new Set(["button", "a", "input", "select", "textarea", "label"]);
  for (const node of ancestors) {
    // `data-player-surface` wins over the tag check: the full-screen play overlay
    // is a <button> but is visually the video itself, and must keep taking tap
    // gestures — otherwise the second tap of a double tap (which lands on it,
    // because the first tap paused and raised it) is discarded and seeking dies.
    if (node.isPlayerSurface === true) return false;
    if (node.isPlayerControls === true) return true;
    if (INTERACTIVE.has(node.tag.toLowerCase())) return true;
  }
  return false;
}

/**
 * Whether a `click` should be ignored because a touch tap already handled it.
 *
 * EVERY click target layered over the video must consult this — not just the
 * `<video>` element. Pausing swaps in a full-screen play-overlay button, so the
 * synthesized click lands on *that* button rather than the video, and an
 * unguarded handler there re-toggles and undoes the pause (pause appeared
 * impossible while unpause worked, because unpausing removes the overlay).
 *
 * `detail === 0` catches the synthesized click on engines that report it; the
 * recency check covers engines that report a real `detail`.
 */
export function isSynthesizedTouchClick(
  detail: number,
  now: number,
  lastTouchTapAt: number
): boolean {
  if (detail === 0) return true;
  return now - lastTouchTapAt < TOUCH_CLICK_SUPPRESS_MS;
}

/** Double tap on the right half: skip forward. */
export const SEEK_FORWARD_SECONDS = 30;

/** Double tap on the left half: skip back. */
export const SEEK_BACKWARD_SECONDS = -10;

export type TapFeedback = "left" | "right";

export type TapOutcome =
  /** First tap: toggle play/pause right now. */
  | { action: "togglePlayPause" }
  /**
   * Second tap: seek, and toggle play/pause again so the first tap's toggle is
   * undone and the play state survives the double tap unchanged.
   */
  | {
      action: "seek";
      seekSeconds: number;
      feedback: TapFeedback;
      togglePlayPause: true;
    };

export interface TapInput {
  /** Tap x position, viewport pixels. */
  x: number;
  screenWidth: number;
  now: number;
}

export interface TapGestureState {
  /**
   * Forget the previous tap, so the next one is treated as a first tap. Used
   * when the gesture turns out to be a swipe.
   */
  cancel(): void;
}

interface InternalState extends TapGestureState {
  lastTapTime: number;
}

export function createTapGestureState(): TapGestureState {
  const state: InternalState = {
    lastTapTime: 0,
    cancel() {
      state.lastTapTime = 0;
    },
  };
  return state;
}

/**
 * Classify a tap and return the action to perform *now*.
 *
 * A tap that closely follows another is the second of a pair: it seeks and
 * re-toggles play/pause (undoing the first tap's toggle). Any other tap is a
 * first tap and simply toggles. Nothing is deferred, so there is no window to
 * race and no third-tap case — a consumed pair resets the state.
 */
export function registerTap(state: TapGestureState, input: TapInput): TapOutcome {
  const s = state as InternalState;
  const sinceLastTap = input.now - s.lastTapTime;

  if (s.lastTapTime > 0 && sinceLastTap > 0 && sinceLastTap < DOUBLE_TAP_WINDOW_MS) {
    s.lastTapTime = 0; // pair consumed; the next tap is a first tap again
    const isLeftSide = input.x < input.screenWidth / 2;
    return isLeftSide
      ? {
          action: "seek",
          seekSeconds: SEEK_BACKWARD_SECONDS,
          feedback: "left",
          togglePlayPause: true,
        }
      : {
          action: "seek",
          seekSeconds: SEEK_FORWARD_SECONDS,
          feedback: "right",
          togglePlayPause: true,
        };
  }

  s.lastTapTime = input.now;
  return { action: "togglePlayPause" };
}

/**
 * Safety margin (seconds) kept between a clamped seek target and the media end.
 *
 * Landing *exactly* on `duration` makes hls.js request the segment whose start
 * time is at/after the end of the media. The server never produces that segment,
 * so the fetch times out and hls.js' gap-controller stalls forever at the last
 * buffered position — surfacing as "unpausing bounces straight back to paused".
 * One segment length (~6s for Jellyfin's ts segments) is comfortably clear of
 * the final segment boundary.
 */
export const END_SEEK_MARGIN_SECONDS = 6;

/**
 * Clamp an absolute seek target into the safely-playable range.
 *
 * Shared by the relative-skip path ({@link resolveSeekTarget}) and the seek-bar
 * drag path, which can otherwise land exactly on `duration` because the range
 * input's `max` is the duration itself.
 */
export function clampSeekTarget(target: number, duration: number): number {
  if (!Number.isFinite(target) || target < 0) return 0;
  if (duration > 0 && target > duration - END_SEEK_MARGIN_SECONDS) {
    return Math.max(0, duration - END_SEEK_MARGIN_SECONDS);
  }
  return target;
}

export interface SeekTargetInput {
  /** Relative offset in seconds (negative rewinds). */
  delta: number;
  /** Latest position reported by the player — the authoritative source. */
  reportedPosition: number;
  /** Media duration; 0/unknown disables the upper clamp. */
  duration: number;
  /**
   * Target of a seek already requested but not yet reflected in
   * `reportedPosition`. Consecutive double taps chain off this so they add up
   * instead of all resolving against the same stale position.
   */
  pendingTarget?: number | null;
}

/**
 * Resolve a relative skip to the absolute position the facade expects.
 *
 * The player facade seeks by absolute position only (the backend picks the seek
 * strategy), so the delta is applied here — against the pending target when one
 * is still in flight and still ahead of what the player has reported.
 */
export function resolveSeekTarget(input: SeekTargetInput): number {
  const { delta, reportedPosition, duration, pendingTarget } = input;

  const base =
    pendingTarget != null && Math.abs(pendingTarget - reportedPosition) > 0.5 && pendingTarget > reportedPosition
      ? pendingTarget
      : reportedPosition;

  const target = base + delta;
  if (target < 0) return 0;
  // Clamp strictly inside the media — see END_SEEK_MARGIN_SECONDS. Guard against
  // going negative on media shorter than the margin itself.
  if (duration > 0 && target > duration) {
    return Math.max(0, duration - END_SEEK_MARGIN_SECONDS);
  }
  return target;
}
