// Copyright 2023 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import * as CodeMirror from '../../../../third_party/codemirror.next/codemirror.next.js';

const cssParser = CodeMirror.css.cssLanguage.parser;

export interface Point {
  input: number;
  output: number;
}

interface LinearStop {
  number: number;
  lengthA?: number;
  lengthB?: number;
}

const numberFormatter = new Intl.NumberFormat('en', {
  maximumFractionDigits: 2,
});

function findNextDefinedInputIndex(points: Point[], currentIndex: number): number {
  for (let i = currentIndex; i < points.length; i++) {
    if (!isNaN(points[i].input)) {
      return i;
    }
  }

  return -1;
}

function consumeLinearStop(cursor: CodeMirror.TreeCursor, referenceText: string): LinearStop|null {
  const tokens = [];
  while (cursor.type.name !== ',' && cursor.type.name !== ')') {
    const token = referenceText.substring(cursor.from, cursor.to);
    if (cursor.type.name !== 'NumberLiteral') {
      // There is something that is not a number inside the argument.
      return null;
    }

    tokens.push(token);
    cursor.next(false);
  }

  // Invalid syntax `linear(0 50% 60% 40%, 1)`.
  if (tokens.length > 3) {
    return null;
  }

  const percentages = tokens.filter(token => token.includes('%'));
  // There can't be more than 2 percentages.
  if (percentages.length > 2) {
    return null;
  }

  const numbers = tokens.filter(token => !token.includes('%'));
  // There must only be 1 number.
  if (numbers.length !== 1) {
    return null;
  }

  return {
    number: Number(numbers[0]),
    lengthA: percentages[0] ? Number(percentages[0].substring(0, percentages[0].length - 1)) : undefined,
    lengthB: percentages[1] ? Number(percentages[1].substring(0, percentages[1].length - 1)) : undefined,
  };
}

function consumeLinearFunction(text: string): LinearStop[]|null {
  const textToParse = `*{--a: ${text}}`;
  const parsed = cssParser.parse(textToParse);
  // Take the cursor from declaration
  const cursor = parsed.cursorAt(textToParse.indexOf(':') + 1);

  // Move until the `ArgList`
  while (cursor.name !== 'ArgList' && cursor.next(true)) {
    // If the callee is not the `linear` function, return null
    if (cursor.name === 'Callee' && textToParse.substring(cursor.from, cursor.to) !== 'linear') {
      return null;
    }
  }

  if (cursor.name !== 'ArgList') {
    return null;
  }

  // We're on the `ArgList`, enter into it
  cursor.firstChild();
  const stops = [];
  while (cursor.type.name !== ')' && cursor.next(false)) {
    const linearStop = consumeLinearStop(cursor, textToParse);
    if (!linearStop) {
      // Parsing a `linearStop` was invalid; abort.
      return null;
    }

    stops.push(linearStop);
  }
  return stops;
}

const KeywordToValue: Record<string, string> = {
  linear: 'linear(0 0%, 1 100%)',
};

export class CSSLinearEasingModel {
  #points: Point[];

  constructor(points: Point[]) {
    this.#points = points;
  }

  // https://w3c.github.io/csswg-drafts/css-easing/#linear-easing-function-parsing
  static parse(text: string): CSSLinearEasingModel|null {
    // Parse `linear` keyword as `linear(0 0%, 1 100%)` function.
    if (KeywordToValue[text]) {
      return CSSLinearEasingModel.parse(KeywordToValue[text]);
    }

    const stops = consumeLinearFunction(text);
    // 1. Let function be a new linear easing function.
    // 2. Let largestInput be negative infinity.
    // 3. If there are less than two items in stopList, then return failure.
    if (!stops || stops.length < 2) {
      return null;
    }

    // 4. For each stop in stopList:
    let largestInput = -Infinity;
    const points: Point[] = [];
    for (let i = 0; i < stops.length; i++) {
      const stop = stops[i];
      // 4.1. Let point be a new linear easing point with its output set
      // to stop’s <number> as a number.
      const point: Point = {input: NaN, output: stop.number};
      // 4.2. Append point to function’s points.
      points.push(point);

      // 4.3. If stop has a <linear-stop-length>, then:
      if (stop.lengthA !== undefined) {
        // 4.3.1. Set point’s input to whichever is greater:
        // stop’s <linear-stop-length>'s first <percentage> as a number,
        // or largestInput.
        point.input = Math.max(stop.lengthA, largestInput);
        // 4.3.2. Set largestInput to point’s input.
        largestInput = point.input;

        // 4.3.3. If stop’s <linear-stop-length> has a second <percentage>, then:
        if (stop.lengthB !== undefined) {
          // 4.3.3.1. Let extraPoint be a new linear easing point with its output
          // set to stop’s <number> as a number.
          const extraPoint: Point = {input: NaN, output: point.output};
          // 4.3.3.2. Append extraPoint to function’s points.
          points.push(extraPoint);
          // 4.3.3.3. Set extraPoint’s input to whichever is greater:
          // stop’s <linear-stop-length>'s second <percentage>
          // as a number, or largestInput.
          extraPoint.input = Math.max(stop.lengthB, largestInput);
          // 4.3.3.4. Set largestInput to extraPoint’s input.
          largestInput = extraPoint.input;
        }
        // 4.4. Otherwise, if stop is the first item in stopList, then:
      } else if (i === 0) {
        // 4.4.1. Set point’s input to 0.
        point.input = 0;
        // 4.4.2. Set largestInput to 0.
        largestInput = 0;
        // 4.5. Otherwise, if stop is the last item in stopList,
        // then set point’s input to whichever is greater: 1 or largestInput.
      } else if (i === stops.length - 1) {
        point.input = Math.max(100, largestInput);
      }
    }

    // 5. For runs of items in function’s points that have a null input, assign a
    // number to the input by linearly interpolating between the closest previous
    // and next points that have a non-null input.
    let upperIndex = 0;
    for (let i = 1; i < points.length; i++) {
      if (isNaN(points[i].input)) {
        if (i > upperIndex) {
          // Since the last point's input is always defined
          // we know that `upperIndex` cannot be `-1`.
          upperIndex = findNextDefinedInputIndex(points, i);
        }

        points[i].input =
            points[i - 1].input + (points[upperIndex].input - points[i - 1].input) / (upperIndex - (i - 1));
      }
    }

    return new CSSLinearEasingModel(points);
  }

  addPoint(point: Point, index?: number): void {
    if (index !== undefined) {
      this.#points.splice(index, 0, point);
      return;
    }

    this.#points.push(point);
  }

  removePoint(index: number): void {
    this.#points.splice(index, 1);
  }

  setPoint(index: number, point: Point): void {
    this.#points[index] = point;
  }

  points(): Point[] {
    return this.#points;
  }

  asCSSText(): string {
    const args =
        this.#points.map(point => `${numberFormatter.format(point.output)} ${numberFormatter.format(point.input)}%`)
            .join(', ');
    const text = `linear(${args})`;

    // If a keyword matches to this function, return the keyword value of it.
    for (const [keyword, value] of Object.entries(KeywordToValue)) {
      if (value === text) {
        return keyword;
      }
    }

    return text;
  }
}
