/**
 * 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,
  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,
  areScrollStatesEqual,
  translateState,
} from './utils/scrollState';
import {MOVE_WHEEL_DELTA_THRESHOLD} from './constants';
import {COLORS} from '../content-views/constants';

const CARET_MARGIN = 3;
const CARET_WIDTH = 5;
const CARET_HEIGHT = 3;

type OnChangeCallback = (
  scrollState: ScrollState,
  containerLength: number,
) => void;

export class VerticalScrollView extends View {
  _contentView: View;
  _isPanning: boolean;
  _mutableViewStateKey: string;
  _onChangeCallback: OnChangeCallback | null;
  _scrollState: ScrollState;
  _viewState: ViewState;

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

    this._contentView = contentView;
    this._isPanning = false;
    this._mutableViewStateKey = label + ':VerticalScrollView';
    this._onChangeCallback = null;
    this._scrollState = {
      offset: 0,
      length: 0,
    };
    this._viewState = viewState;

    this.addSubview(contentView);

    this._restoreMutableViewState();
  }

  setFrame(newFrame: Rect) {
    super.setFrame(newFrame);

    // Revalidate scrollState
    this._setScrollState(this._scrollState);
  }

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

  draw(context: CanvasRenderingContext2D, viewRefs: ViewRefs) {
    super.draw(context, viewRefs);

    // Show carets if there's scroll overflow above or below the viewable area.
    if (this.frame.size.height > CARET_HEIGHT * 2 + CARET_MARGIN * 3) {
      const offset = this._scrollState.offset;
      const desiredSize = this._contentView.desiredSize();

      const above = offset;
      const below = this.frame.size.height - desiredSize.height - offset;

      if (above < 0 || below < 0) {
        const {visibleArea} = this;
        const {x, y} = visibleArea.origin;
        const {width, height} = visibleArea.size;
        const horizontalCenter = x + width / 2;

        const halfWidth = CARET_WIDTH;
        const left = horizontalCenter + halfWidth;
        const right = horizontalCenter - halfWidth;

        if (above < 0) {
          const topY = y + CARET_MARGIN;

          context.beginPath();
          context.moveTo(horizontalCenter, topY);
          context.lineTo(left, topY + CARET_HEIGHT);
          context.lineTo(right, topY + CARET_HEIGHT);
          context.closePath();
          context.fillStyle = COLORS.SCROLL_CARET;
          context.fill();
        }

        if (below < 0) {
          const bottomY = y + height - CARET_MARGIN;

          context.beginPath();
          context.moveTo(horizontalCenter, bottomY);
          context.lineTo(left, bottomY - CARET_HEIGHT);
          context.lineTo(right, bottomY - CARET_HEIGHT);
          context.closePath();
          context.fillStyle = COLORS.SCROLL_CARET;
          context.fill();
        }
      }
    }
  }

  layoutSubviews() {
    const {offset} = this._scrollState;
    const desiredSize = this._contentView.desiredSize();

    const minimumHeight = this.frame.size.height;
    const desiredHeight = desiredSize ? desiredSize.height : 0;
    // Force view to take up at least all remaining vertical space.
    const height = Math.max(desiredHeight, minimumHeight);

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

  handleInteraction(interaction: Interaction): ?boolean {
    switch (interaction.type) {
      case 'mousedown':
        return this._handleMouseDown(interaction);
      case 'mousemove':
        return this._handleMouseMove(interaction);
      case 'mouseup':
        return this._handleMouseUp(interaction);
      case 'wheel-shift':
        return this._handleWheelShift(interaction);
    }
  }

  onChange(callback: OnChangeCallback) {
    this._onChangeCallback = callback;
  }

  scrollBy(deltaY: number): boolean {
    const newState = translateState({
      state: this._scrollState,
      delta: -deltaY,
      containerLength: this.frame.size.height,
    });

    // If the state is updated by this wheel scroll,
    // return true to prevent the interaction from bubbling.
    // For instance, this prevents the outermost container from also scrolling.
    return this._setScrollState(newState);
  }

  _handleMouseDown(interaction: MouseDownInteraction) {
    if (rectContainsPoint(interaction.payload.location, this.frame)) {
      const frameHeight = this.frame.size.height;
      const contentHeight = this._contentView.desiredSize().height;
      // Don't claim drag operations if the content is not tall enough to be scrollable.
      // This would block any outer scroll views from working.
      if (frameHeight < contentHeight) {
        this._isPanning = true;
      }
    }
  }

  _handleMouseMove(interaction: MouseMoveInteraction): void | boolean {
    if (!this._isPanning) {
      return;
    }

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

    const newState = translateState({
      state: this._scrollState,
      delta: interaction.payload.event.movementY,
      containerLength: this.frame.size.height,
    });
    this._setScrollState(newState);

    return true;
  }

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

  _handleWheelShift(interaction: WheelWithShiftInteraction): boolean {
    const {
      location,
      delta: {deltaX, deltaY},
    } = interaction.payload;

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

    const absDeltaX = Math.abs(deltaX);
    const absDeltaY = Math.abs(deltaY);
    if (absDeltaX > absDeltaY) {
      return false; // Scrolling horizontally
    }

    if (absDeltaY < MOVE_WHEEL_DELTA_THRESHOLD) {
      return false; // Movement was too small and should be ignored.
    }

    return this.scrollBy(deltaY);
  }

  _restoreMutableViewState() {
    if (
      this._viewState.viewToMutableViewStateMap.has(this._mutableViewStateKey)
    ) {
      this._scrollState = ((this._viewState.viewToMutableViewStateMap.get(
        this._mutableViewStateKey,
      ): any): ScrollState);
    } else {
      this._viewState.viewToMutableViewStateMap.set(
        this._mutableViewStateKey,
        this._scrollState,
      );
    }

    this.setNeedsDisplay();
  }

  _setScrollState(proposedState: ScrollState): boolean {
    const contentHeight = this._contentView.frame.size.height;
    const containerHeight = this.frame.size.height;

    const clampedState = clampState({
      state: proposedState,
      minContentLength: contentHeight,
      maxContentLength: contentHeight,
      containerLength: containerHeight,
    });
    if (!areScrollStatesEqual(clampedState, this._scrollState)) {
      this._scrollState.offset = clampedState.offset;
      this._scrollState.length = clampedState.length;

      this.setNeedsDisplay();

      if (this._onChangeCallback !== null) {
        this._onChangeCallback(clampedState, this.frame.size.height);
      }

      return true;
    }

    // Don't allow wheel events to bubble past this view even if we've scrolled to the edge.
    // It just feels bad to have the scrolling jump unexpectedly from in a container to the outer page.
    // The only exception is when the container fits the content (no scrolling).
    if (contentHeight === containerHeight) {
      return false;
    }

    return true;
  }
}
