// Copyright (C) 2025 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//      http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import {DisposableStack} from './disposable_stack';
import {bindEventListener} from './dom_utils';
import {assertTrue} from './assert';

export interface TouchArgs {
  clientX: number;
  clientY: number;
  deltaX: number;
  deltaY: number;
}

interface TouchHandlerCallbacks {
  onPinchZoom?: (args: TouchArgs) => void;
  onPan?: (args: TouchArgs) => void;
  onTapDown?: (args: TouchArgs) => void;
  onTapMove?: (args: TouchArgs) => void;
  onTapUp?: (args: TouchArgs) => void;
}

const PAN_TIMEOUT_MS = 200;

/**
 * TouchscreenHandler handles 3 types of gestures using Touch APIs on devices
 * that support touch. Note this has nothing to do with "touchpad" (that is
 * handled using wheel event). This has to do with mouse-less devices.
 * This class supports 3 types of gestures:
 * 1. Two finger pinch zoom (it always takes the precedence).
 * 2. Single finger tap.
 * 3. Single finger mouse emulation.
 *
 * 2 and 3 are disamgiguated by time: a touch down followed by a quick move is
 * interpreted like a pan. Instead a touch down followed by a PAN_TIMEOUT_MS
 * pause is interpreted as a mouse-like event.
 */
export class TouchscreenHandler {
  private readonly trash = new DisposableStack();

  private callbacks: TouchHandlerCallbacks;
  private tapStart?: Touch;
  private tapStartTime = 0;
  private tapGeneration = 0;
  private lastTouch?: Touch;
  private lastPinch?: Touch[];
  private mode?: 'deciding' | 'pinch' | 'pan' | 'mouse_emulation';

  constructor(target: HTMLElement, callbacks: TouchHandlerCallbacks) {
    this.callbacks = callbacks;

    this.trash.use(
      bindEventListener(target, 'touchstart', this.onTouchStart.bind(this)),
    );
    this.trash.use(
      bindEventListener(target, 'touchmove', this.onTouchMove.bind(this), {
        passive: false,
      }),
    );
    this.trash.use(
      bindEventListener(target, 'touchend', this.onTouchEnd.bind(this)),
    );
  }

  [Symbol.dispose](): void {
    this.trash.dispose();
  }

  // NOTE: touch events can be overlapped. If we start touching with a single
  // finger and then we see a second one (which is very common by accident
  // because humans rarely manage to put both fingers on screen at the same
  // time), we'll see something like:
  // onTouchStart(1 touch)
  // onTouchStart(2 touches)
  // ...
  // onTouchEnd(2 touches)
  // onTouchEnd(1 touch)
  // We handle this by using the following logic:
  // - pan and mouse_emulation are mutually exclusive, and they are determined
  //   based on what you do within the first 200ms.
  // - you can "upgrade" from a pan or mouse_emulation to pinch if you add a
  //   second finger.
  // - However, once in pinch mode, you can'd downgrade.

  private onTouchStart = (e: TouchEvent) => {
    if (e.touches.length === 2) {
      this.mode = 'pinch';
      return;
    }

    if (e.touches.length !== 1) return;

    const t = e.touches[0];
    this.tapStart = t;
    this.tapStartTime = performance.now();
    this.lastTouch = t;
    this.mode = 'deciding';
    const tapGeneration = ++this.tapGeneration;
    self.setTimeout(() => {
      if (this.tapGeneration === tapGeneration && this.mode === 'deciding') {
        this.initiateMouseEmulation(t);
      }
    }, PAN_TIMEOUT_MS);
  };

  private initiateMouseEmulation(t: {clientX: number; clientY: number}) {
    assertTrue(this.mode === 'deciding');
    this.mode = 'mouse_emulation';
    this.callbacks.onTapDown?.({
      deltaX: 0,
      deltaY: 0,
      clientX: t.clientX,
      clientY: t.clientY,
    });
  }

  private onTouchMove = (e: TouchEvent) => {
    if (e.touches.length < 1 || e.touches.length > 2 || !this.tapStart) {
      return;
    }

    if (e.touches.length === 2) {
      e.preventDefault(); // Disable browser panning.
      if (this.mode === 'mouse_emulation' && this.tapStart !== undefined) {
        this.callbacks.onTapUp?.({
          deltaX: 0,
          deltaY: 0,
          ...this.tapStart,
        });
      }
      this.mode = 'pinch';
      this.handlePinch(e);
      return;
    }

    // When pinch-zooming we can get a mixture of interleaved touchmove(1) and
    // touchmove(2) depending on how many fingers we started with. Suppress the
    // former as it creates jittery vertical scrolling while panning.
    if (this.mode === 'pinch') {
      e.preventDefault();
      return;
    }

    // Single touch case. Here we need to disambiguate between:
    // 1. A pan, which is a tapdown followed by a rapid movement.
    // 2. A mouse-like touchmouse_emulation event, which we trigger when the user
    //    taps down and then doesn't move for TAP_TIMEOUT_MS.
    const t = e.touches[0];
    const dXInit = t.clientX - this.tapStart.clientX;
    const dYInit = t.clientY - this.tapStart.clientY;
    const distSq = dXInit * dXInit + dYInit * dYInit;

    this.lastTouch ||= this.tapStart;
    const deltaX = this.lastTouch.clientX - t.clientX;
    const deltaY = this.lastTouch.clientY - t.clientY;
    const args = {
      clientX: t.clientX,
      clientY: t.clientY,
      deltaX,
      deltaY,
    };
    this.lastTouch = t;
    const elapsed = performance.now() - this.tapStartTime;

    if (this.mode === 'deciding') {
      if (distSq > 25 && elapsed < PAN_TIMEOUT_MS) {
        this.mode = 'pan';
      } else if (elapsed >= PAN_TIMEOUT_MS) {
        this.initiateMouseEmulation(this.tapStart);
      } else {
        return;
      }
    }

    // Not an "else" because we want to fall through after we change the
    // singleTapType above.
    if (this.mode === 'mouse_emulation') {
      e.preventDefault();
      this.callbacks.onTapMove?.({...args});
    } else if (this.mode === 'pan') {
      this.callbacks.onPan?.({...args});
    }
  };

  private onTouchEnd = (e: TouchEvent) => {
    this.tapStart = undefined;
    const mode = this.mode;
    this.mode = undefined;
    this.lastTouch = undefined;
    this.lastPinch = undefined;

    if (mode === 'mouse_emulation') {
      const t = e.changedTouches[0];
      this.callbacks.onTapUp?.({
        deltaX: 0,
        deltaY: 0,
        clientX: t.clientX,
        clientY: t.clientY,
      });
    }
  };

  private handlePinch(e: TouchEvent) {
    if (e.touches.length !== 2) return;

    if (this.lastPinch === undefined) {
      this.lastPinch = [e.touches[0], e.touches[1]];
    }

    function distance(t: ArrayLike<Touch>) {
      const dX = t[0].clientX - t[1].clientX;
      const dY = t[0].clientY - t[1].clientY;
      return Math.sqrt(dX * dX + dY * dY);
    }

    const delta = Math.round(distance(this.lastPinch) - distance(e.touches));
    this.lastPinch = [e.touches[0], e.touches[1]];

    const midX = (e.touches[0].clientX + e.touches[1].clientX) / 2;
    const midY = (e.touches[0].clientY + e.touches[1].clientY) / 2;

    this.callbacks.onPinchZoom?.({
      clientX: Math.round(midX),
      clientY: Math.round(midY),
      deltaX: delta,
      deltaY: delta,
    });
  }
}

export type TouchEventTranslation =
  | 'down-up-move'
  | 'pan-x'
  | 'pinch-zoom-as-ctrl-wheel';
export function convertTouchIntoMouseEvents(
  target: HTMLElement,
  events: TouchEventTranslation[],
): Disposable {
  return new TouchscreenHandler(target, {
    onTapDown(args: TouchArgs) {
      if (!events.includes('down-up-move')) return;
      target.dispatchEvent(
        new MouseEvent('mousedown', {
          bubbles: true,
          buttons: 1,
          ...args,
        }),
      );
    },
    onTapUp(args: TouchArgs) {
      if (!events.includes('down-up-move')) return;
      target.dispatchEvent(
        new MouseEvent('mouseup', {
          bubbles: true,
          buttons: 0,
          ...args,
        }),
      );
    },
    onTapMove(args: TouchArgs) {
      if (!events.includes('down-up-move')) return;
      target.dispatchEvent(
        new MouseEvent('mousemove', {
          bubbles: true,
          buttons: 1,
          movementX: args.deltaX,
          movementY: args.deltaY,
          ...args,
        }),
      );
    },
    onPan(args: TouchArgs) {
      if (
        Math.abs(args.deltaX) > Math.abs(args.deltaY) &&
        events.includes('pan-x')
      ) {
        withInertia(args.deltaX, 0, (dx) =>
          target.dispatchEvent(
            new WheelEvent('wheel', {
              ...args,
              deltaX: dx,
              deltaY: 0,
              ctrlKey: false,
            }),
          ),
        );
      }
    },
    onPinchZoom(args: TouchArgs) {
      if (!events.includes('pinch-zoom-as-ctrl-wheel')) return;
      // We translate pinch zoom into Ctrl+vertical wheel. This is consistent
      // with what most laptops seem to do when pinching on the touchpad.
      target.dispatchEvent(
        new WheelEvent('wheel', {
          ...args,
          deltaX: 0,
          deltaY: args.deltaY,
          ctrlKey: true,
        }),
      );
    },
  });
}

function withInertia(
  vx: number,
  vy: number,
  callback: (dx: number, dy: number) => void,
  options?: {
    friction?: number;
    minVelocity?: number;
  },
): () => void {
  const friction = options?.friction ?? 0.8;
  const minVelocity = options?.minVelocity ?? 0.05;

  let frame: number | null = null;

  function step() {
    vx *= friction;
    vy *= friction;

    if (Math.abs(vx) < minVelocity && Math.abs(vy) < minVelocity) {
      if (frame !== null) cancelAnimationFrame(frame);
      return;
    }

    callback(vx, vy);
    frame = requestAnimationFrame(step);
  }

  frame = requestAnimationFrame(step);

  return () => {
    if (frame !== null) {
      cancelAnimationFrame(frame);
      frame = null;
    }
  };
}
