/*
 * Copyright 2022 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 {ColumnSize} from '@react-types/table';
import {Key} from '@react-types/shared';

// numbers and percents are considered static. *fr units or a lack of units are considered dynamic.
export function isStatic(width?: ColumnSize | null): boolean {
  return width != null && (!isNaN(width as number) || (String(width)).match(/^(\d+)(?=%$)/) !== null);
}

export function parseFractionalUnit(width?: ColumnSize | null): number {
  if (!width || typeof width === 'number') {
    return 1;
  }
  let match = width.match(/^(.+)(?=fr$)/);
  // if width is the incorrect format, just default it to a 1fr
  if (!match) {
    if (process.env.NODE_ENV !== 'production') {
      console.warn(`width: ${width} is not a supported format, width should be a number (ex. 150), percentage (ex. '50%') or fr unit (ex. '2fr')`,
        'defaulting to \'1fr\'');
    }
    return 1;
  }
  return parseFloat(match[0]);
}

export function parseStaticWidth(width: number | string, tableWidth: number): number {
  if (typeof width === 'string') {
    let match = width.match(/^(\d+)(?=%$)/);
    if (!match) {
      throw new Error('Only percentages or numbers are supported for static column widths');
    }
    return tableWidth * (parseFloat(match[0]) / 100);
  }
  return width;
}


export function getMaxWidth(maxWidth: number | string | null | undefined, tableWidth: number): number {
  return maxWidth != null
    ? parseStaticWidth(maxWidth, tableWidth)
    : Number.MAX_SAFE_INTEGER;
}

// cannot support FR units, we'd need to know everything else in the table to do that
export function getMinWidth(minWidth: number | string, tableWidth: number): number {
  return minWidth != null
    ? parseStaticWidth(minWidth, tableWidth)
    : 0;
}


export interface IColumn {
  minWidth?: number | string,
  maxWidth?: number | string,
  width?: number | string,
  defaultWidth?: number | string,
  key: Key
}

interface FlexItem {
  frozen: boolean,
  baseSize: number,
  hypotheticalMainSize: number,
  min: number,
  max: number,
  flex: number,
  targetMainSize: number,
  violation: number
}

/**
 * Implements the flex algorithm described in https://www.w3.org/TR/css-flexbox-1/#layout-algorithm
 * It makes a few constraint/assumptions:
 * 1. All basis values are 0 unless it is a static width, then the basis is the static width
 * 2. All flex grow and shrink values are equal to the FR specified on the column, grow and shrink for the same column are equal
 * 3. We only have one row
 * An example of the setup can be seen here https://jsfiddle.net/snowystinger/wv0ymjaf/61/ where I let the browser figure out the
 * flex of the columns.
 * Note: We differ in one key aspect, all of our column widths must be whole numbers, so we avoid browser
 * sub pixel rounding errors. To do this, we use a cascading rounding algorithm to ensure that the sum of the widths is maintained
 * while distributing the rounding remainder across the columns.
 *
 * As noted in the chrome source code, this algorithm is very accurate, but has the potential to be quadratic.
 * They have deemed this to be acceptable because the number of elements is usually small and the flex factors
 * are usually not high variance. I believe we can make the same assumptions. Particularly once resizing is
 * started, it will convert all columns to the left to static widths, so it will cut down on the number of FR columns.
 *
 * There are likely faster ways to do this, I've chosen to stick to the spec as closely as possible for readability, accuracy, and for the
 * note that this behaving quadratically is unlikely to be a problem.
 * @param availableWidth - The visible width of the table.
 * @param columns - The table defined columns.
 * @param changedColumns - Any columns we want to override, for example, during resizing.
 * @param getDefaultWidth - A function that returns the default width of a column by its index.
 * @param getDefaultMinWidth - A function that returns the default min width of a column by its index.
 */
export function calculateColumnSizes(availableWidth: number, columns: IColumn[], changedColumns: Map<Key, ColumnSize>, getDefaultWidth?: (index: number) => ColumnSize | null | undefined, getDefaultMinWidth?: (index: number) => ColumnSize | null | undefined): number[] {
  let hasNonFrozenItems = false;
  let flexItems: FlexItem[] = columns.map((column, index) => {
    let width: ColumnSize = (changedColumns.get(column.key) != null ? changedColumns.get(column.key) ?? '1fr' : column.width ?? column.defaultWidth ?? getDefaultWidth?.(index) ?? '1fr') as ColumnSize;
    let frozen = false;
    let baseSize = 0;
    let flex = 0;
    let targetMainSize = 0;
    if (isStatic(width)) {
      baseSize = parseStaticWidth(width, availableWidth);
      frozen = true;
    } else {
      flex = parseFractionalUnit(width);
      if (flex <= 0) {
        frozen = true;
      }
    }

    let min = getMinWidth(column.minWidth ?? getDefaultMinWidth?.(index) ?? 0, availableWidth);
    let max = getMaxWidth(column.maxWidth, availableWidth);
    let hypotheticalMainSize = Math.max(min, Math.min(baseSize, max));

    // 9.7.1
    // We don't make use of flex basis, it's always 0, so we are always in 'grow' mode.
    // 9.7.2
    if (frozen) {
      targetMainSize = hypotheticalMainSize;
    } else if (baseSize > hypotheticalMainSize) {
      frozen = true;
      targetMainSize = hypotheticalMainSize;
    }

    // 9.7.3
    if (!frozen) {
      hasNonFrozenItems = true;
    }
    return {
      frozen,
      baseSize,
      hypotheticalMainSize,
      min,
      max,
      flex,
      targetMainSize,
      violation: 0
    };
  });

  // 9.7.4
  // 9.7.4.a
  while (hasNonFrozenItems) {
    // 9.7.4.b
    /**
     * Calculate the remaining free space as for initial free space,
     * above (9.7.3). If the sum of the unfrozen flex items’ flex factors is
     * less than one, multiply the initial free space by this sum (of flex factors).
     * If the magnitude of this value is less than the magnitude of
     * the remaining free space, use this as the remaining free space.
     */
    let usedWidth = 0;
    let flexFactors = 0;
    flexItems.forEach(item => {
      if (item.frozen) {
        usedWidth += item.targetMainSize;
      } else {
        usedWidth += item.baseSize;
        flexFactors += item.flex;
      }
    });

    let remainingFreeSpace = availableWidth - usedWidth;
    // we only support integer FR's, and because of hasNonFrozenItems, we know that flexFactors > 0
    // so no need to check for flexFactors < 1
    // 9.7.4.c
    /**
     * If the remaining free space is zero
     * - Do nothing.
     * Else // remember, we're always in grow mode
     * - Find the ratio of the item’s flex grow factor to the
     * sum of the flex grow factors of all unfrozen items on
     * the line. Set the item’s target main size to its flex
     * base size plus a fraction of the remaining free space
     * proportional to the ratio.
     */
    if (remainingFreeSpace > 0) {
      flexItems.forEach((item) => {
        if (!item.frozen) {
          let ratio = item.flex / flexFactors;
          item.targetMainSize = item.baseSize + (ratio * remainingFreeSpace);
        }
      });
    }

    // 9.7.4.d
    /**
     * Fix min/max violations. Clamp each non-frozen item’s
     * target main size by its used min and max main sizes
     * and floor its content-box size at zero. If the item’s
     * target main size was made smaller by this, it’s a max
     * violation. If the item’s target main size was made
     * larger by this, it’s a min violation.
     */
    let totalViolation = 0;
    flexItems.forEach(item => {
      item.violation = 0;
      if (!item.frozen) {
        let {min, max, targetMainSize} = item;
        item.targetMainSize = Math.max(min, Math.min(targetMainSize, max));

        item.violation = item.targetMainSize - targetMainSize;
        totalViolation += item.violation;
      }
    });

    // 9.7.4.e
    /**
     * Freeze over-flexed items. The total violation is the
     * sum of the adjustments from the previous step
     * ∑(clamped size - unclamped size). If the total violation is:
     * Zero
     * - Freeze all items.
     *
     * Positive
     * - Freeze all the items with min violations.
     *
     * Negative
     * - Freeze all the items with max violations.
     */
    hasNonFrozenItems = false;
    flexItems.forEach(item => {
      if (totalViolation === 0 || Math.sign(totalViolation) === Math.sign(item.violation)) {
        item.frozen = true;
      } else if (!item.frozen) {
        hasNonFrozenItems = true;
      }
    });
  }

  return cascadeRounding(flexItems);
}

function cascadeRounding(flexItems: FlexItem[]): number[] {
  /*
  Given an array of floats that sum to an integer, this rounds the floats
  and returns an array of integers with the same sum.
  */

  let fpTotal = 0;
  let intTotal = 0;
  let roundedArray: number[] = [];
  flexItems.forEach(function (item) {
    let float = item.targetMainSize;
    let integer = Math.round(float + fpTotal) - intTotal;
    fpTotal += float;
    intTotal += integer;
    roundedArray.push(integer);
  });

  return roundedArray;
}
