/*
 * Copyright 2020 Adobe. All rights reserved.
 * This file is licensed to you 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 REPRESENTATIONS
 * OF ANY KIND, either express or implied. See the License for the specific language
 * governing permissions and limitations under the License.
 */

import {clamp, snapValueToStep, useControlledState} from '@react-stately/utils';
import {Orientation} from '@react-types/shared';
import {SliderProps} from '@react-types/slider';
import {useCallback, useMemo, useRef, useState} from 'react';

export interface SliderState {
  /**
   * Values managed by the slider by thumb index.
   */
  readonly values: number[],
  /**
   * The default values for each thumb.
   */
  readonly defaultValues: number[],
  /**
   * Get the value for the specified thumb.
   * @param index
   */
  getThumbValue(index: number): number,

  /**
   * Sets the value for the specified thumb.
   * The actual value set will be clamped and rounded according to min/max/step.
   * @param index
   * @param value
   */
  setThumbValue(index: number, value: number): void,

  /**
   * Sets value for the specified thumb by percent offset (between 0 and 1).
   * @param index
   * @param percent
   */
  setThumbPercent(index: number, percent: number): void,

  /**
   * Whether the specific thumb is being dragged.
   * @param index
   */
  isThumbDragging(index: number): boolean,
  /**
   * Set is dragging on the specified thumb.
   * @param index
   * @param dragging
   */
  setThumbDragging(index: number, dragging: boolean): void,

  /**
   * Currently-focused thumb index.
   */
  readonly focusedThumb: number | undefined,
  /**
   * Set focused true on specified thumb. This will remove focus from
   * any thumb that had it before.
   * @param index
   */
  setFocusedThumb(index: number | undefined): void,

  /**
   * Returns the specified thumb's value as a percentage from 0 to 1.
   * @param index
   */
  getThumbPercent(index: number): number,

  /**
   * Returns the value as a percent between the min and max of the slider.
   * @param index
   */
  getValuePercent(value: number): number,

  /**
   * Returns the string label for the specified thumb's value, per props.formatOptions.
   * @param index
   */
  getThumbValueLabel(index: number): string,

  /**
   * Returns the string label for the value, per props.formatOptions.
   * @param index
   */
  getFormattedValue(value: number): string,

  /**
   * Returns the min allowed value for the specified thumb.
   * @param index
   */
  getThumbMinValue(index: number): number,

  /**
   * Returns the max allowed value for the specified thumb.
   * @param index
   */
  getThumbMaxValue(index: number): number,

  /**
   * Converts a percent along track (between 0 and 1) to the corresponding value.
   * @param percent
   */
  getPercentValue(percent: number): number,

  /**
   * Returns if the specified thumb is editable.
   * @param index
   */
  isThumbEditable(index: number): boolean,

  /**
   * Set the specified thumb's editable state.
   * @param index
   * @param editable
   */
  setThumbEditable(index: number, editable: boolean): void,

  /**
   * Increments the value of the thumb by the step or page amount.
   */
  incrementThumb(index: number, stepSize?: number): void,
  /**
   * Decrements the value of the thumb by the step or page amount.
   */
  decrementThumb(index: number, stepSize?: number): void,

  /**
   * The step amount for the slider.
   */
  readonly step: number,

  /**
   * The page size for the slider, used to do a bigger step.
   */
  readonly pageSize: number,

  /** The orientation of the slider. */
  readonly orientation: Orientation,

  /** Whether the slider is disabled. */
  readonly isDisabled: boolean
}

const DEFAULT_MIN_VALUE = 0;
const DEFAULT_MAX_VALUE = 100;
const DEFAULT_STEP_VALUE = 1;

export interface SliderStateOptions<T> extends SliderProps<T> {
  numberFormatter: Intl.NumberFormat
}

/**
 * Provides state management for a slider component. Stores values for all thumbs,
 * formats values for localization, and provides methods to update the position
 * of any thumbs.
 * @param props
 */
export function useSliderState<T extends number | number[]>(props: SliderStateOptions<T>): SliderState {
  const {
    isDisabled = false,
    minValue = DEFAULT_MIN_VALUE,
    maxValue = DEFAULT_MAX_VALUE,
    numberFormatter: formatter,
    step = DEFAULT_STEP_VALUE,
    orientation = 'horizontal'
  } = props;

  // Page step should be at least equal to step and always a multiple of the step.
  let pageSize = useMemo(() => {
    let calcPageSize = (maxValue - minValue) / 10;
    calcPageSize = snapValueToStep(calcPageSize, 0, calcPageSize + step, step);
    return Math.max(calcPageSize, step);
  }, [step, maxValue, minValue]);

  let restrictValues = useCallback((values: number[] | undefined) => values?.map((val, idx) => {
    let min = idx === 0 ? minValue : values[idx - 1];
    let max = idx === values.length - 1 ? maxValue : values[idx + 1];
    return snapValueToStep(val, min, max, step);
  }), [minValue, maxValue, step]);

  let value = useMemo(() => restrictValues(convertValue(props.value)), [props.value, restrictValues]);
  let defaultValue = useMemo(() => restrictValues(convertValue(props.defaultValue) ?? [minValue])!, [props.defaultValue, minValue, restrictValues]);
  let onChange = createOnChange(props.value, props.defaultValue, props.onChange);
  let onChangeEnd = createOnChange(props.value, props.defaultValue, props.onChangeEnd);

  const [values, setValuesState] = useControlledState<number[]>(
    value,
    defaultValue,
    onChange
  );
  let [initialValues] = useState(values);
  const [isDraggings, setDraggingsState] = useState<boolean[]>(new Array(values.length).fill(false));
  const isEditablesRef = useRef<boolean[]>(new Array(values.length).fill(true));
  const [focusedIndex, setFocusedIndex] = useState<number | undefined>(undefined);

  const valuesRef = useRef<number[]>(values);
  const isDraggingsRef = useRef<boolean[]>(isDraggings);

  let setValues = (values: number[]) => {
    valuesRef.current = values;
    setValuesState(values);
  };

  let setDraggings = (draggings: boolean[]) => {
    isDraggingsRef.current = draggings;
    setDraggingsState(draggings);
  };

  function getValuePercent(value: number) {
    return (value - minValue) / (maxValue - minValue);
  }

  function getThumbMinValue(index: number) {
    return index === 0 ? minValue : values[index - 1];
  }
  function getThumbMaxValue(index: number) {
    return index === values.length - 1 ? maxValue : values[index + 1];
  }

  function isThumbEditable(index: number) {
    return isEditablesRef.current[index];
  }

  function setThumbEditable(index: number, editable: boolean) {
    isEditablesRef.current[index] = editable;
  }

  function updateValue(index: number, value: number) {
    if (isDisabled || !isThumbEditable(index)) {
      return;
    }
    const thisMin = getThumbMinValue(index);
    const thisMax = getThumbMaxValue(index);

    // Round value to multiple of step, clamp value between min and max
    value = snapValueToStep(value, thisMin, thisMax, step);
    let newValues = replaceIndex(valuesRef.current, index, value);
    setValues(newValues);
  }

  function updateDragging(index: number, dragging: boolean) {
    if (isDisabled || !isThumbEditable(index)) {
      return;
    }
    if (dragging) {
      valuesRef.current = values;
    }

    const wasDragging = isDraggingsRef.current[index];
    isDraggingsRef.current = replaceIndex(isDraggingsRef.current, index, dragging);
    setDraggings(isDraggingsRef.current);

    // Call onChangeEnd if no handles are dragging.
    if (onChangeEnd && wasDragging && !isDraggingsRef.current.some(Boolean)) {
      onChangeEnd(valuesRef.current);
    }
  }

  function getFormattedValue(value: number) {
    return formatter.format(value);
  }

  function setThumbPercent(index: number, percent: number) {
    updateValue(index, getPercentValue(percent));
  }

  function getRoundedValue(value: number) {
    return Math.round((value - minValue) / step) * step + minValue;
  }

  function getPercentValue(percent: number) {
    const val = percent * (maxValue - minValue) + minValue;
    return clamp(getRoundedValue(val), minValue, maxValue);
  }

  function incrementThumb(index: number, stepSize: number = 1) {
    let s = Math.max(stepSize, step);
    updateValue(index, snapValueToStep(values[index] + s, minValue, maxValue, step));
  }

  function decrementThumb(index: number, stepSize: number = 1) {
    let s = Math.max(stepSize, step);
    updateValue(index, snapValueToStep(values[index] - s, minValue, maxValue, step));
  }

  return {
    values: values,
    defaultValues: props.defaultValue !== undefined ? defaultValue : initialValues,
    getThumbValue: (index: number) => values[index],
    setThumbValue: updateValue,
    setThumbPercent,
    isThumbDragging: (index: number) => isDraggings[index],
    setThumbDragging: updateDragging,
    focusedThumb: focusedIndex,
    setFocusedThumb: setFocusedIndex,
    getThumbPercent: (index: number) => getValuePercent(values[index]),
    getValuePercent,
    getThumbValueLabel: (index: number) => getFormattedValue(values[index]),
    getFormattedValue,
    getThumbMinValue,
    getThumbMaxValue,
    getPercentValue,
    isThumbEditable,
    setThumbEditable,
    incrementThumb,
    decrementThumb,
    step,
    pageSize,
    orientation,
    isDisabled
  };
}

function replaceIndex<T>(array: T[], index: number, value: T) {
  if (array[index] === value) {
    return array;
  }

  return [...array.slice(0, index), value, ...array.slice(index + 1)];
}

function convertValue(value?: number | number[]) {
  if (value == null) {
    return undefined;
  }

  return Array.isArray(value) ? value : [value];
}

function createOnChange(value, defaultValue, onChange) {
  return (newValue: number[]) => {
    if (typeof value === 'number' || typeof defaultValue === 'number') {
      onChange?.(newValue[0]);
    } else {
      onChange?.(newValue);
    }
  };
}
