/**
 * 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 strict-local
 * @format
 */

'use strict';

import type {ProcessedColorValue} from './processColor';
import type {
  BackgroundImageValue,
  RadialGradientPosition,
  RadialGradientShape,
  RadialGradientSize,
} from './StyleSheetTypes';

const processColor = require('./processColor').default;

// Linear Gradient
const LINEAR_GRADIENT_DIRECTION_REGEX =
  /^to\s+(?:top|bottom|left|right)(?:\s+(?:top|bottom|left|right))?/i;
const LINEAR_GRADIENT_ANGLE_UNIT_REGEX =
  /^([+-]?\d*\.?\d+)(deg|grad|rad|turn)$/i;
const LINEAR_GRADIENT_DEFAULT_DIRECTION: LinearGradientDirection = {
  type: 'angle',
  value: 180,
};

type LinearGradientDirection =
  | {type: 'angle', value: number}
  | {type: 'keyword', value: string};

type LinearGradientBackgroundImage = {
  type: 'linear-gradient',
  direction: LinearGradientDirection,
  colorStops: $ReadOnlyArray<{
    color: ColorStopColor,
    position: ColorStopPosition,
  }>,
};

// Radial Gradient
const DEFAULT_RADIAL_SHAPE = 'ellipse';
const DEFAULT_RADIAL_SIZE = 'farthest-corner';
// center
const DEFAULT_RADIAL_POSITION: RadialGradientPosition = {
  top: '50%',
  left: '50%',
};

type RadialGradientBackgroundImage = {
  type: 'radial-gradient',
  shape: RadialGradientShape,
  size: RadialGradientSize,
  position: RadialGradientPosition,
  colorStops: $ReadOnlyArray<{
    color: ColorStopColor,
    position: ColorStopPosition,
  }>,
};

// null color indicate that the transition hint syntax is used. e.g. red, 20%, blue
type ColorStopColor = ProcessedColorValue | null;
// percentage or pixel value
type ColorStopPosition = number | string | null;

type ParsedBackgroundImageValue =
  | LinearGradientBackgroundImage
  | RadialGradientBackgroundImage;

export default function processBackgroundImage(
  backgroundImage: ?($ReadOnlyArray<BackgroundImageValue> | string),
): $ReadOnlyArray<ParsedBackgroundImageValue> {
  let result: $ReadOnlyArray<ParsedBackgroundImageValue> = [];
  if (backgroundImage == null) {
    return result;
  }

  if (typeof backgroundImage === 'string') {
    result = parseBackgroundImageCSSString(backgroundImage.replace(/\n/g, ' '));
  } else if (Array.isArray(backgroundImage)) {
    for (const bgImage of backgroundImage) {
      const processedColorStops = processColorStops(bgImage);
      if (processedColorStops == null) {
        // If a color stop is invalid, return an empty array and do not apply any gradient. Same as web.
        return [];
      }

      if (bgImage.type === 'linear-gradient') {
        let direction: LinearGradientDirection =
          LINEAR_GRADIENT_DEFAULT_DIRECTION;
        const bgDirection =
          bgImage.direction != null ? bgImage.direction.toLowerCase() : null;

        if (bgDirection != null) {
          if (LINEAR_GRADIENT_ANGLE_UNIT_REGEX.test(bgDirection)) {
            const parsedAngle = getAngleInDegrees(bgDirection);
            if (parsedAngle != null) {
              direction = {
                type: 'angle',
                value: parsedAngle,
              };
            } else {
              // If an angle is invalid, return an empty array and do not apply any gradient. Same as web.
              return [];
            }
          } else if (LINEAR_GRADIENT_DIRECTION_REGEX.test(bgDirection)) {
            const parsedDirection = getDirectionForKeyword(bgDirection);
            if (parsedDirection != null) {
              direction = parsedDirection;
            } else {
              // If a direction is invalid, return an empty array and do not apply any gradient. Same as web.
              return [];
            }
          } else {
            // If a direction is invalid, return an empty array and do not apply any gradient. Same as web.
            return [];
          }
        }

        result = result.concat({
          type: 'linear-gradient',
          direction,
          colorStops: processedColorStops,
        });
      } else if (bgImage.type === 'radial-gradient') {
        let shape: RadialGradientShape = DEFAULT_RADIAL_SHAPE;
        let size: RadialGradientSize = DEFAULT_RADIAL_SIZE;
        let position: RadialGradientPosition = {...DEFAULT_RADIAL_POSITION};

        if (bgImage.shape != null) {
          if (bgImage.shape === 'circle' || bgImage.shape === 'ellipse') {
            shape = bgImage.shape;
          } else {
            // If the shape is invalid, return an empty array and do not apply any gradient. Same as web.
            return [];
          }
        }

        if (bgImage.size != null) {
          if (
            typeof bgImage.size === 'string' &&
            (bgImage.size === 'closest-side' ||
              bgImage.size === 'closest-corner' ||
              bgImage.size === 'farthest-side' ||
              bgImage.size === 'farthest-corner')
          ) {
            size = bgImage.size;
          } else if (
            typeof bgImage.size === 'object' &&
            bgImage.size.x != null &&
            bgImage.size.y != null
          ) {
            size = {
              x: bgImage.size.x,
              y: bgImage.size.y,
            };
          } else {
            // If the size is invalid, return an empty array and do not apply any gradient. Same as web.
            return [];
          }
        }

        if (bgImage.position != null) {
          position = bgImage.position;
        }

        result = result.concat({
          type: 'radial-gradient',
          shape,
          size,
          position,
          colorStops: processedColorStops,
        });
      }
    }
  }

  return result;
}

function processColorStops(bgImage: BackgroundImageValue): $ReadOnlyArray<{
  color: ColorStopColor,
  position: ColorStopPosition,
}> | null {
  const processedColorStops: Array<{
    color: ColorStopColor,
    position: ColorStopPosition,
  }> = [];

  for (let index = 0; index < bgImage.colorStops.length; index++) {
    const colorStop = bgImage.colorStops[index];
    const positions = colorStop.positions;
    // Color transition hint syntax (red, 20%, blue)
    if (
      colorStop.color == null &&
      Array.isArray(positions) &&
      positions.length === 1
    ) {
      const position = positions[0];
      if (
        typeof position === 'number' ||
        (typeof position === 'string' && position.endsWith('%'))
      ) {
        processedColorStops.push({
          color: null,
          position,
        });
      } else {
        // If a position is invalid, return null and do not apply gradient. Same as web.
        return null;
      }
    } else {
      const processedColor = processColor(colorStop.color);
      if (processedColor == null) {
        // If a color is invalid, return null and do not apply gradient. Same as web.
        return null;
      }
      if (positions != null && positions.length > 0) {
        for (const position of positions) {
          if (
            typeof position === 'number' ||
            (typeof position === 'string' && position.endsWith('%'))
          ) {
            processedColorStops.push({
              color: processedColor,
              position,
            });
          } else {
            // If a position is invalid, return null and do not apply gradient. Same as web.
            return null;
          }
        }
      } else {
        processedColorStops.push({
          color: processedColor,
          position: null,
        });
      }
    }
  }

  return processedColorStops;
}

function parseBackgroundImageCSSString(
  cssString: string,
): $ReadOnlyArray<ParsedBackgroundImageValue> {
  const gradients = [];
  const bgImageStrings = splitGradients(cssString);

  for (const bgImageString of bgImageStrings) {
    const bgImage = bgImageString.toLowerCase();
    const gradientRegex = /^(linear|radial)-gradient\(((?:\([^)]*\)|[^()])*)\)/;

    const match = gradientRegex.exec(bgImage);
    if (match) {
      const [, type, gradientContent] = match;
      const isRadial = type.toLowerCase() === 'radial';
      const gradient = isRadial
        ? parseRadialGradientCSSString(gradientContent)
        : parseLinearGradientCSSString(gradientContent);

      if (gradient != null) {
        gradients.push(gradient);
      }
    }
  }
  return gradients;
}

function parseRadialGradientCSSString(
  gradientContent: string,
): RadialGradientBackgroundImage | null {
  let shape: RadialGradientShape = DEFAULT_RADIAL_SHAPE;
  let size: RadialGradientSize = DEFAULT_RADIAL_SIZE;
  let position: RadialGradientPosition = {...DEFAULT_RADIAL_POSITION};

  // split the content by commas, but not if inside parentheses (for color values)
  const parts = gradientContent.split(/,(?![^(]*\))/);
  // first part may contain shape, size, and position
  // [ <radial-shape> || <radial-size> ]? [ at <position> ]?
  const firstPartStr = parts[0].trim();
  const remainingParts = [...parts];
  let hasShapeSizeOrPositionString = false;
  let hasExplicitSingleSize = false;
  let hasExplicitShape = false;
  const firstPartTokens = firstPartStr.split(/\s+/);

  // firstPartTokens is the shape, size, and position
  while (firstPartTokens.length > 0) {
    let token = firstPartTokens.shift();
    if (token == null) {
      continue;
    }
    let tokenTrimmed = token.toLowerCase().trim();

    if (tokenTrimmed === 'circle' || tokenTrimmed === 'ellipse') {
      shape = tokenTrimmed === 'circle' ? 'circle' : 'ellipse';
      hasShapeSizeOrPositionString = true;
      hasExplicitShape = true;
    } else if (
      tokenTrimmed === 'closest-corner' ||
      tokenTrimmed === 'farthest-corner' ||
      tokenTrimmed === 'closest-side' ||
      tokenTrimmed === 'farthest-side'
    ) {
      size = tokenTrimmed;
      hasShapeSizeOrPositionString = true;
    } else if (tokenTrimmed.endsWith('px') || tokenTrimmed.endsWith('%')) {
      let sizeX = getPositionFromCSSValue(tokenTrimmed);
      if (sizeX == null) {
        // If a size is invalid, return null and do not apply any gradient. Same as web.
        return null;
      }
      if (typeof sizeX === 'number' && sizeX < 0) {
        // If a size is invalid, return null and do not apply any gradient. Same as web.
        return null;
      }
      hasShapeSizeOrPositionString = true;
      size = {x: sizeX, y: sizeX};
      token = firstPartTokens.shift();
      if (token == null) {
        hasExplicitSingleSize = true;
        continue;
      }
      tokenTrimmed = token.toLowerCase().trim();
      if (tokenTrimmed.endsWith('px') || tokenTrimmed.endsWith('%')) {
        const sizeY = getPositionFromCSSValue(tokenTrimmed);
        if (sizeY == null) {
          // If a size is invalid, return null and do not apply any gradient. Same as web.
          return null;
        }
        if (typeof sizeY === 'number' && sizeY < 0) {
          // If a size is invalid, return null and do not apply any gradient. Same as web.
          return null;
        }
        size = {x: sizeX, y: sizeY};
      } else {
        hasExplicitSingleSize = true;
      }
    } else if (tokenTrimmed === 'at') {
      let top: string | number;
      let left: string | number;
      let right: string | number;
      let bottom: string | number;
      hasShapeSizeOrPositionString = true;

      if (firstPartTokens.length === 0) {
        // If 'at' is not followed by a position, return null and do not apply any gradient. Same as web.
        return null;
      }

      // 1. [ left | center | right | top | bottom | <length-percentage> ]
      if (firstPartTokens.length === 1) {
        token = firstPartTokens.shift();
        if (token == null) {
          // If 'at' is not followed by a position, return null and do not apply any gradient. Same as web.
          return null;
        }
        tokenTrimmed = token.toLowerCase().trim();
        if (tokenTrimmed === 'left') {
          left = '0%';
          top = '50%';
        } else if (tokenTrimmed === 'center') {
          left = '50%';
          top = '50%';
        } else if (tokenTrimmed === 'right') {
          left = '100%';
          top = '50%';
        } else if (tokenTrimmed === 'top') {
          left = '50%';
          top = '0%';
        } else if (tokenTrimmed === 'bottom') {
          left = '50%';
          top = '100%';
        } else if (tokenTrimmed.endsWith('px') || tokenTrimmed.endsWith('%')) {
          const value = getPositionFromCSSValue(tokenTrimmed);
          if (value == null) {
            // If a position is invalid, return null and do not apply any gradient. Same as web.
            return null;
          }
          left = value;
          top = '50%';
        }
      }

      if (firstPartTokens.length === 2) {
        const t1 = firstPartTokens.shift();
        const t2 = firstPartTokens.shift();
        if (t1 == null || t2 == null) {
          // If a position is invalid, return null and do not apply any gradient. Same as web.
          return null;
        }

        const token1 = t1.toLowerCase().trim();
        const token2 = t2.toLowerCase().trim();

        // 2. [ left | center | right ] && [ top | center | bottom ]
        const horizontalPositions = ['left', 'center', 'right'];
        const verticalPositions = ['top', 'center', 'bottom'];

        if (
          horizontalPositions.includes(token1) &&
          verticalPositions.includes(token2)
        ) {
          left =
            token1 === 'left' ? '0%' : token1 === 'center' ? '50%' : '100%';
          top = token2 === 'top' ? '0%' : token2 === 'center' ? '50%' : '100%';
        } else if (
          verticalPositions.includes(token1) &&
          horizontalPositions.includes(token2)
        ) {
          left =
            token2 === 'left' ? '0%' : token2 === 'center' ? '50%' : '100%';
          top = token1 === 'top' ? '0%' : token1 === 'center' ? '50%' : '100%';
        }
        // 3. [ left | center | right | <length-percentage> ] [ top | center | bottom | <length-percentage> ]
        else {
          if (token1 === 'left') {
            left = '0%';
          } else if (token1 === 'center') {
            left = '50%';
          } else if (token1 === 'right') {
            left = '100%';
          } else if (token1.endsWith('px') || token1.endsWith('%')) {
            const value = getPositionFromCSSValue(token1);
            if (value == null) {
              // If a position is invalid, return null and do not apply any gradient. Same as web.
              return null;
            }
            left = value;
          } else {
            // If a position is invalid, return null and do not apply any gradient. Same as web.
            return null;
          }

          if (token2 === 'top') {
            top = '0%';
          } else if (token2 === 'center') {
            top = '50%';
          } else if (token2 === 'bottom') {
            top = '100%';
          } else if (token2.endsWith('px') || token2.endsWith('%')) {
            const value = getPositionFromCSSValue(token2);
            if (value == null) {
              // If a position is invalid, return null and do not apply any gradient. Same as web.
              return null;
            }
            top = value;
          } else {
            // If a position is invalid, return null and do not apply any gradient. Same as web.
            return null;
          }
        }
      }

      // 4. [ [ left | right ] <length-percentage> ] && [ [ top | bottom ] <length-percentage> ]
      if (firstPartTokens.length === 4) {
        const t1 = firstPartTokens.shift();
        const t2 = firstPartTokens.shift();
        const t3 = firstPartTokens.shift();
        const t4 = firstPartTokens.shift();

        if (t1 == null || t2 == null || t3 == null || t4 == null) {
          // If a position is invalid, return null and do not apply any gradient. Same as web.
          return null;
        }
        const token1 = t1.toLowerCase().trim();
        const token2 = t2.toLowerCase().trim();
        const token3 = t3.toLowerCase().trim();
        const token4 = t4.toLowerCase().trim();
        const keyword1 = token1;
        const value1 = getPositionFromCSSValue(token2);
        const keyword2 = token3;
        const value2 = getPositionFromCSSValue(token4);
        if (value1 == null || value2 == null) {
          // If a position is invalid, return null and do not apply any gradient. Same as web.
          return null;
        }

        if (keyword1 === 'left') {
          left = value1;
        } else if (keyword1 === 'right') {
          right = value1;
        } else if (keyword1 === 'top') {
          top = value1;
        } else if (keyword1 === 'bottom') {
          bottom = value1;
        } else {
          // If a position is invalid, return null and do not apply any gradient. Same as web.
          return null;
        }

        if (keyword2 === 'left') {
          left = value2;
        } else if (keyword2 === 'right') {
          right = value2;
        } else if (keyword2 === 'top') {
          top = value2;
        } else if (keyword2 === 'bottom') {
          bottom = value2;
        } else {
          // If a position is invalid, return null and do not apply any gradient. Same as web.
          return null;
        }
      }

      if (top != null && left != null) {
        position = {
          top,
          left,
        };
      } else if (bottom != null && right != null) {
        position = {
          bottom,
          right,
        };
      } else if (top != null && right != null) {
        position = {
          top,
          right,
        };
      } else if (bottom != null && left != null) {
        position = {
          bottom,
          left,
        };
      } else {
        // If a position is invalid, return null and do not apply any gradient. Same as web.
        return null;
      }
      // 'at' comes at the end of first part of radial gradient syntax;
      break;
    }

    // if there is no shape, size, or position string found in first token, break
    // if might be a color stop
    if (!hasShapeSizeOrPositionString) {
      break;
    }
  }

  if (hasShapeSizeOrPositionString) {
    remainingParts.shift();

    if (!hasExplicitShape && hasExplicitSingleSize) {
      shape = 'circle';
    }

    if (hasExplicitSingleSize && hasExplicitShape && shape === 'ellipse') {
      // If a single size is explicitly set and the shape is an ellipse, return null and do not apply any gradient. Same as web.
      return null;
    }
  }

  const colorStops = parseColorStopsCSSString(remainingParts);
  if (colorStops == null) {
    // If color stops are invalid, return null and do not apply any gradient. Same as web.
    return null;
  }

  return {
    type: 'radial-gradient',
    shape,
    size,
    position,
    colorStops,
  };
}

function parseLinearGradientCSSString(
  gradientContent: string,
): LinearGradientBackgroundImage | null {
  const parts = gradientContent.split(',');
  let direction: LinearGradientDirection = LINEAR_GRADIENT_DEFAULT_DIRECTION;
  const trimmedDirection = parts[0].trim().toLowerCase();

  if (LINEAR_GRADIENT_ANGLE_UNIT_REGEX.test(trimmedDirection)) {
    const parsedAngle = getAngleInDegrees(trimmedDirection);
    if (parsedAngle != null) {
      direction = {
        type: 'angle',
        value: parsedAngle,
      };
      parts.shift();
    } else {
      // If an angle is invalid, return null and do not apply any gradient. Same as web.
      return null;
    }
  } else if (LINEAR_GRADIENT_DIRECTION_REGEX.test(trimmedDirection)) {
    const parsedDirection = getDirectionForKeyword(trimmedDirection);
    if (parsedDirection != null) {
      direction = parsedDirection;
      parts.shift();
    } else {
      // If a direction is invalid, return null and do not apply any gradient. Same as web.
      return null;
    }
  }

  const colorStops = parseColorStopsCSSString(parts);
  if (colorStops == null) {
    // If a color stop is invalid, return null and do not apply any gradient. Same as web.
    return null;
  }

  return {
    type: 'linear-gradient',
    direction,
    colorStops,
  };
}

function parseColorStopsCSSString(parts: Array<string>): Array<{
  color: ColorStopColor,
  position: ColorStopPosition,
}> | null {
  const colorStopsString = parts.join(',');
  const colorStops: Array<{
    color: ColorStopColor,
    position: ColorStopPosition,
  }> = [];
  // split by comma, but not if it's inside a parentheses. e.g. red, rgba(0, 0, 0, 0.5), green => ["red", "rgba(0, 0, 0, 0.5)", "green"]
  const stops = colorStopsString.split(/,(?![^(]*\))/);
  let prevStop = null;
  for (let i = 0; i < stops.length; i++) {
    const stop = stops[i];
    const trimmedStop = stop.trim().toLowerCase();
    // Match function like pattern or single words
    const colorStopParts = trimmedStop.match(/\S+\([^)]*\)|\S+/g);
    if (colorStopParts == null) {
      // If a color stop is invalid, return null and do not apply any gradient. Same as web.
      return null;
    }
    // Case 1: [color, position, position]
    if (colorStopParts.length === 3) {
      const color = colorStopParts[0];
      const position1 = getPositionFromCSSValue(colorStopParts[1]);
      const position2 = getPositionFromCSSValue(colorStopParts[2]);
      const processedColor = processColor(color);
      if (processedColor == null) {
        // If a color is invalid, return null and do not apply any gradient. Same as web.
        return null;
      }

      if (position1 == null || position2 == null) {
        // If a position is invalid, return null and do not apply any gradient. Same as web.
        return null;
      }

      colorStops.push({
        color: processedColor,
        position: position1,
      });
      colorStops.push({
        color: processedColor,
        position: position2,
      });
    }
    // Case 2: [color, position]
    else if (colorStopParts.length === 2) {
      const color = colorStopParts[0];
      const position = getPositionFromCSSValue(colorStopParts[1]);
      const processedColor = processColor(color);
      if (processedColor == null) {
        // If a color is invalid, return null and do not apply any gradient. Same as web.
        return null;
      }
      if (position == null) {
        // If a position is invalid, return null and do not apply any gradient. Same as web.
        return null;
      }
      colorStops.push({
        color: processedColor,
        position,
      });
    }
    // Case 3: [color]
    // Case 4: [position] => transition hint syntax
    else if (colorStopParts.length === 1) {
      const position = getPositionFromCSSValue(colorStopParts[0]);
      if (position != null) {
        // handle invalid transition hint syntax. transition hint syntax must have color before and after the position. e.g. red, 20%, blue
        if (
          (prevStop != null &&
            prevStop.length === 1 &&
            getPositionFromCSSValue(prevStop[0]) != null) ||
          i === stops.length - 1 ||
          i === 0
        ) {
          // If the last stop is a transition hint syntax, return null and do not apply any gradient. Same as web.
          return null;
        }
        colorStops.push({
          color: null,
          position,
        });
      } else {
        const processedColor = processColor(colorStopParts[0]);
        if (processedColor == null) {
          // If a color is invalid, return null and do not apply any gradient. Same as web.
          return null;
        }
        colorStops.push({
          color: processedColor,
          position: null,
        });
      }
    } else {
      // If a color stop is invalid, return null and do not apply any gradient. Same as web.
      return null;
    }
    prevStop = colorStopParts;
  }

  return colorStops;
}

function getDirectionForKeyword(direction?: string): ?LinearGradientDirection {
  if (direction == null) {
    return null;
  }
  // Remove extra whitespace
  const normalized = direction.replace(/\s+/g, ' ').toLowerCase();

  switch (normalized) {
    case 'to top':
      return {type: 'angle', value: 0};
    case 'to right':
      return {type: 'angle', value: 90};
    case 'to bottom':
      return {type: 'angle', value: 180};
    case 'to left':
      return {type: 'angle', value: 270};
    case 'to top right':
    case 'to right top':
      return {type: 'keyword', value: 'to top right'};
    case 'to bottom right':
    case 'to right bottom':
      return {type: 'keyword', value: 'to bottom right'};
    case 'to top left':
    case 'to left top':
      return {type: 'keyword', value: 'to top left'};
    case 'to bottom left':
    case 'to left bottom':
      return {type: 'keyword', value: 'to bottom left'};
    default:
      return null;
  }
}

function getAngleInDegrees(angle?: string): ?number {
  if (angle == null) {
    return null;
  }
  const match = angle.match(LINEAR_GRADIENT_ANGLE_UNIT_REGEX);
  if (!match) {
    return null;
  }

  const [, value, unit] = match;

  const numericValue = parseFloat(value);
  switch (unit) {
    case 'deg':
      return numericValue;
    case 'grad':
      return numericValue * 0.9; // 1 grad = 0.9 degrees
    case 'rad':
      return (numericValue * 180) / Math.PI;
    case 'turn':
      return numericValue * 360; // 1 turn = 360 degrees
    default:
      return null;
  }
}

function getPositionFromCSSValue(position: string) {
  if (position.endsWith('px')) {
    return parseFloat(position);
  }

  if (position.endsWith('%')) {
    return position;
  }
}

function splitGradients(input: string) {
  const result = [];
  let current = '';
  let depth = 0;

  for (let i = 0; i < input.length; i++) {
    const char = input[i];

    if (char === '(') {
      depth++;
    } else if (char === ')') {
      depth--;
    } else if (char === ',' && depth === 0) {
      result.push(current.trim());
      current = '';
      continue;
    }

    current += char;
  }

  if (current.trim() !== '') {
    result.push(current.trim());
  }

  return result;
}
