/**
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 *
 * @flow
 */

import type {Size, IntrinsicSize, Rect} from './geometry';
import type {
  Interaction,
  MouseDownInteraction,
  MouseMoveInteraction,
  MouseUpInteraction,
  WheelPlainInteraction,
  WheelWithShiftInteraction,
} from './useCanvasInteraction';
import type {ScrollState} from './utils/scrollState';
import type {ViewRefs} from './Surface';
import type {ViewState} from '../types';

import {Surface} from './Surface';
import {View} from './View';
import {rectContainsPoint} from './geometry';
import {
  clampState,
  moveStateToRange,
  areScrollStatesEqual,
  translateState,
  zoomState,
} from './utils/scrollState';
import {
  MAX_ZOOM_LEVEL,
  MIN_ZOOM_LEVEL,
  MOVE_WHEEL_DELTA_THRESHOLD,
} from './constants';

export class HorizontalPanAndZoomView extends View {
  _contentView: View;
  _intrinsicContentWidth: number;
  _isPanning: boolean = false;
  _viewState: ViewState;

  constructor(
    surface: Surface,
    frame: Rect,
    contentView: View,
    intrinsicContentWidth: number,
    viewState: ViewState,
  ) {
    super(surface, frame);

    this._contentView = contentView;
    this._intrinsicContentWidth = intrinsicContentWidth;
    this._viewState = viewState;

    viewState.onHorizontalScrollStateChange(scrollState => {
      this.zoomToRange(scrollState.offset, scrollState.length);
    });

    this.addSubview(contentView);
  }

  /**
   * Just sets scroll state.
   * Use `_setStateAndInformCallbacksIfChanged` if this view's callbacks should also be called.
   *
   * @returns Whether state was changed
   * @private
   */
  setScrollState(proposedState: ScrollState) {
    const clampedState = clampState({
      state: proposedState,
      minContentLength: this._intrinsicContentWidth * MIN_ZOOM_LEVEL,
      maxContentLength: this._intrinsicContentWidth * MAX_ZOOM_LEVEL,
      containerLength: this.frame.size.width,
    });
    if (
      !areScrollStatesEqual(clampedState, this._viewState.horizontalScrollState)
    ) {
      this.setNeedsDisplay();
    }
  }

  /**
   * Zoom to a specific range of the content specified as a range of the
   * content view's intrinsic content size.
   *
   * Does not inform callbacks of state change since this is a public API.
   */
  zoomToRange(rangeStart: number, rangeEnd: number) {
    const newState = moveStateToRange({
      state: this._viewState.horizontalScrollState,
      rangeStart,
      rangeEnd,
      contentLength: this._intrinsicContentWidth,

      minContentLength: this._intrinsicContentWidth * MIN_ZOOM_LEVEL,
      maxContentLength: this._intrinsicContentWidth * MAX_ZOOM_LEVEL,
      containerLength: this.frame.size.width,
    });
    this.setScrollState(newState);
  }

  desiredSize(): Size | IntrinsicSize {
    return this._contentView.desiredSize();
  }

  layoutSubviews() {
    const {offset, length} = this._viewState.horizontalScrollState;
    const proposedFrame = {
      origin: {
        x: this.frame.origin.x + offset,
        y: this.frame.origin.y,
      },
      size: {
        width: length,
        height: this.frame.size.height,
      },
    };
    this._contentView.setFrame(proposedFrame);
    super.layoutSubviews();
  }

  handleInteraction(interaction: Interaction, viewRefs: ViewRefs) {
    switch (interaction.type) {
      case 'mousedown':
        this._handleMouseDown(interaction, viewRefs);
        break;
      case 'mousemove':
        this._handleMouseMove(interaction, viewRefs);
        break;
      case 'mouseup':
        this._handleMouseUp(interaction, viewRefs);
        break;
      case 'wheel-plain':
      case 'wheel-shift':
        this._handleWheel(interaction);
        break;
    }
  }

  _handleMouseDown(interaction: MouseDownInteraction, viewRefs: ViewRefs) {
    if (rectContainsPoint(interaction.payload.location, this.frame)) {
      this._isPanning = true;

      viewRefs.activeView = this;

      this.currentCursor = 'grabbing';
    }
  }

  _handleMouseMove(interaction: MouseMoveInteraction, viewRefs: ViewRefs) {
    const isHovered = rectContainsPoint(
      interaction.payload.location,
      this.frame,
    );
    if (isHovered && viewRefs.hoveredView === null) {
      viewRefs.hoveredView = this;
    }

    if (viewRefs.activeView === this) {
      this.currentCursor = 'grabbing';
    } else if (isHovered) {
      this.currentCursor = 'grab';
    }

    if (!this._isPanning) {
      return;
    }

    // Don't prevent mouse-move events from bubbling if they are vertical drags.
    const {movementX, movementY} = interaction.payload.event;
    if (Math.abs(movementX) < Math.abs(movementY)) {
      return;
    }

    const newState = translateState({
      state: this._viewState.horizontalScrollState,
      delta: movementX,
      containerLength: this.frame.size.width,
    });
    this._viewState.updateHorizontalScrollState(newState);
  }

  _handleMouseUp(interaction: MouseUpInteraction, viewRefs: ViewRefs) {
    if (this._isPanning) {
      this._isPanning = false;
    }

    if (viewRefs.activeView === this) {
      viewRefs.activeView = null;
    }
  }

  _handleWheel(interaction: WheelPlainInteraction | WheelWithShiftInteraction) {
    const {
      location,
      delta: {deltaX, deltaY},
    } = interaction.payload;

    if (!rectContainsPoint(location, this.frame)) {
      return; // Not scrolling on view
    }

    const absDeltaX = Math.abs(deltaX);
    const absDeltaY = Math.abs(deltaY);

    // Vertical scrolling zooms in and out (unless the SHIFT modifier is used).
    // Horizontal scrolling pans.
    if (absDeltaY > absDeltaX) {
      if (absDeltaY < MOVE_WHEEL_DELTA_THRESHOLD) {
        return;
      }

      if (interaction.type === 'wheel-shift') {
        // Shift modifier is for scrolling, not zooming.
        return;
      }

      const newState = zoomState({
        state: this._viewState.horizontalScrollState,
        multiplier: 1 + 0.005 * -deltaY,
        fixedPoint: location.x - this._viewState.horizontalScrollState.offset,

        minContentLength: this._intrinsicContentWidth * MIN_ZOOM_LEVEL,
        maxContentLength: this._intrinsicContentWidth * MAX_ZOOM_LEVEL,
        containerLength: this.frame.size.width,
      });
      this._viewState.updateHorizontalScrollState(newState);
    } else {
      if (absDeltaX < MOVE_WHEEL_DELTA_THRESHOLD) {
        return;
      }

      const newState = translateState({
        state: this._viewState.horizontalScrollState,
        delta: -deltaX,
        containerLength: this.frame.size.width,
      });
      this._viewState.updateHorizontalScrollState(newState);
    }
  }
}