/**
 * Copyright (c) Nicolas Gallagher.
 *
 * 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
 */

import createReactDOMStyle from './createReactDOMStyle';
import hash from '../../vendor/hash';
import hyphenateStyleName from 'hyphenate-style-name';
import normalizeValueWithProperty from './normalizeValueWithProperty';
import prefixStyles, { prefixInlineStyles } from '../../modules/prefixStyles';

type Value = Object | Array<any> | string | number;
type Style = { [key: string]: Value };
type Rule = string;
type Rules = Array<Rule>;
type RulesData = {|
  property?: string,
  value?: string,
  identifier: string,
  rules: Rules
|};
type CompilerOutput = { [key: string]: RulesData };

const cache = {
  get(property, value) {
    if (
      cache[property] != null &&
      cache[property].hasOwnProperty(value) &&
      cache[property][value] != null
    ) {
      return cache[property][value];
    }
  },
  set(property, value, object) {
    if (cache[property] == null) {
      cache[property] = {};
    }
    return (cache[property][value] = object);
  }
};

/**
 * Compile style to atomic CSS rules.
 */
export function atomic(style: Style): CompilerOutput {
  return Object.keys(style)
    .sort()
    .reduce((acc, property) => {
      const value = style[property];
      if (value != null) {
        const valueString = stringifyValueWithProperty(value, property);
        const cachedResult = cache.get(property, valueString);
        if (cachedResult != null) {
          const { identifier } = cachedResult;
          acc[identifier] = cachedResult;
        } else {
          const identifier = createIdentifier('r', property, value);
          const rules = createAtomicRules(identifier, property, value);
          const cachedResult = cache.set(property, valueString, {
            property,
            value: stringifyValueWithProperty(value, property),
            identifier,
            rules
          });
          acc[identifier] = cachedResult;
        }
      }
      return acc;
    }, {});
}

/**
 * Compile simple style object to classic CSS rules.
 * No support for 'placeholderTextColor', 'scrollbarWidth', or 'pointerEvents'.
 */
export function classic(style: Style, name: string): CompilerOutput {
  const identifier = createIdentifier('css', name, style);
  const { animationKeyframes, ...rest } = style;

  const rules = [];
  const selector = `.${identifier}`;
  let animationName;
  if (animationKeyframes != null) {
    const { animationNames, rules: keyframesRules } = processKeyframesValue(animationKeyframes);
    animationName = animationNames.join(',');
    rules.push(...keyframesRules);
  }
  const block = createDeclarationBlock({ ...rest, animationName });
  rules.push(`${selector}${block}`);

  return { [identifier]: { identifier, rules } };
}

/**
 * Compile simple style object to inline DOM styles.
 * No support for 'animationKeyframes', 'placeholderTextColor', 'scrollbarWidth', or 'pointerEvents'.
 */
export function inline(style: Style) {
  return prefixInlineStyles(createReactDOMStyle(style));
}

/**
 * Create a value string that normalizes different input values with a common
 * output.
 */
export function stringifyValueWithProperty(value: Value, property: ?string) {
  // e.g., 0 => '0px', 'black' => 'rgba(0,0,0,1)'
  const normalizedValue = normalizeValueWithProperty(value, property);
  return typeof normalizedValue !== 'string'
    ? JSON.stringify(normalizedValue || '')
    : normalizedValue;
}

/**
 * Create the Atomic CSS rules needed for a given StyleSheet rule.
 * Translates StyleSheet declarations to CSS.
 */
function createAtomicRules(identifier: string, property, value): Rules {
  const rules = [];
  const selector = `.${identifier}`;

  // Handle non-standard properties and object values that require multiple
  // CSS rules to be created.
  switch (property) {
    case 'animationKeyframes': {
      const { animationNames, rules: keyframesRules } = processKeyframesValue(value);
      const block = createDeclarationBlock({ animationName: animationNames.join(',') });
      rules.push(`${selector}${block}`, ...keyframesRules);
      break;
    }

    // Equivalent to using '::placeholder'
    case 'placeholderTextColor': {
      const block = createDeclarationBlock({ color: value, opacity: 1 });
      rules.push(
        `${selector}::-webkit-input-placeholder${block}`,
        `${selector}::-moz-placeholder${block}`,
        `${selector}:-ms-input-placeholder${block}`,
        `${selector}::placeholder${block}`
      );
      break;
    }

    // Polyfill for additional 'pointer-events' values
    // See d13f78622b233a0afc0c7a200c0a0792c8ca9e58
    case 'pointerEvents': {
      let finalValue = value;
      if (value === 'auto' || value === 'box-only') {
        finalValue = 'auto!important';
        if (value === 'box-only') {
          const block = createDeclarationBlock({ [property]: 'none' });
          rules.push(`${selector}>*${block}`);
        }
      } else if (value === 'none' || value === 'box-none') {
        finalValue = 'none!important';
        if (value === 'box-none') {
          const block = createDeclarationBlock({ [property]: 'auto' });
          rules.push(`${selector}>*${block}`);
        }
      }
      const block = createDeclarationBlock({ [property]: finalValue });
      rules.push(`${selector}${block}`);
      break;
    }

    // Polyfill for draft spec
    // https://drafts.csswg.org/css-scrollbars-1/
    case 'scrollbarWidth': {
      if (value === 'none') {
        rules.push(
          `${selector}::-webkit-scrollbar{display:none}`,
          `${selector}{overflow:-moz-scrollbars-none;-ms-overflow-style:none;}`
        );
      }
      const block = createDeclarationBlock({ [property]: value });
      rules.push(`${selector}${block}`);
      break;
    }

    default: {
      const block = createDeclarationBlock({ [property]: value });
      rules.push(`${selector}${block}`);
      break;
    }
  }

  return rules;
}

/**
 * Creates a CSS declaration block from a StyleSheet object.
 */
function createDeclarationBlock(style: Style) {
  const domStyle = prefixStyles(createReactDOMStyle(style));
  const declarationsString = Object.keys(domStyle)
    .map(property => {
      const value = domStyle[property];
      const prop = hyphenateStyleName(property);
      // The prefixer may return an array of values:
      // { display: [ '-webkit-flex', 'flex' ] }
      // to represent "fallback" declarations
      // { display: -webkit-flex; display: flex; }
      if (Array.isArray(value)) {
        return value.map(v => `${prop}:${v}`).join(';');
      } else {
        return `${prop}:${value}`;
      }
    })
    // Once properties are hyphenated, this will put the vendor
    // prefixed and short-form properties first in the list.
    .sort()
    .join(';');

  return `{${declarationsString};}`;
}

/**
 * An identifier is associated with a unique set of styles.
 */
function createIdentifier(prefix, name, value) {
  const hashedString = hash(name + stringifyValueWithProperty(value, name));
  return process.env.NODE_ENV !== 'production'
    ? `${prefix}-${name}-${hashedString}`
    : `${prefix}-${hashedString}`;
}

/**
 * Create individual CSS keyframes rules.
 */
function createKeyframes(keyframes) {
  const prefixes = ['-webkit-', ''];
  const identifier = createIdentifier('r', 'animation', keyframes);

  const steps =
    '{' +
    Object.keys(keyframes)
      .map(stepName => {
        const rule = keyframes[stepName];
        const block = createDeclarationBlock(rule);
        return `${stepName}${block}`;
      })
      .join('') +
    '}';

  const rules = prefixes.map(prefix => {
    return `@${prefix}keyframes ${identifier}${steps}`;
  });
  return { identifier, rules };
}

/**
 * Create CSS keyframes rules and names from a StyleSheet keyframes object.
 */
function processKeyframesValue(keyframesValue) {
  if (typeof keyframesValue === 'number') {
    throw new Error(`Invalid CSS keyframes type: ${typeof keyframesValue}`);
  }

  const animationNames = [];
  const rules = [];
  const value = Array.isArray(keyframesValue) ? keyframesValue : [keyframesValue];

  value.forEach(keyframes => {
    if (typeof keyframes === 'string') {
      // Support external animation libraries (identifiers only)
      animationNames.push(keyframes);
    } else {
      // Create rules for each of the keyframes
      const { identifier, rules: keyframesRules } = createKeyframes(keyframes);
      animationNames.push(identifier);
      rules.push(...keyframesRules);
    }
  });

  return { animationNames, rules };
}
