import { stringHandlers as defaultStringHandlers } from './inject';
import OrderedElements from './ordered-elements';
import staticData from './staticPrefixData';
import { SelectorHandler, SheetDefinition, StringHandlers } from './type';
import { kebabifyStyleName, stringifyValue } from './util';
// eslint-disable-next-line @typescript-eslint/ban-types
type GenerateSubtreeStyles = (value: string) => string[];
const createPrefixer = require('inline-style-prefixer/lib/createPrefixer').default;
const prefixAll = createPrefixer(staticData);
/**
 * `selectorHandlers` are functions which handle special selectors which act
 * differently than normal style definitions. These functions look at the
 * current selector and can generate CSS for the styles in their subtree by
 * calling the callback with a new selector.
 *
 * For example, when generating styles with a base selector of '.foo' and the
 * following styles object:
 *
 *   {
 *     ':nth-child(2n)': {
 *       ':hover': {
 *         color: 'red'
 *       }
 *     }
 *   }
 *
 * when we reach the ':hover' style, we would call our selector handlers like
 *
 *   handler(':hover', '.foo:nth-child(2n)', callback)
 *
 * Since our `pseudoSelectors` handles ':hover' styles, that handler would call
 * the callback like
 *
 *   callback('.foo:nth-child(2n):hover')
 *
 * to generate its subtree `{ color: 'red' }` styles with a
 * '.foo:nth-child(2n):hover' selector. The callback would return an array of CSS
 * rules like
 *
 *   ['.foo:nth-child(2n):hover{color:red !important;}']
 *
 * and the handler would then return that resulting CSS.
 *
 * `defaultSelectorHandlers` is the list of default handlers used in a call to
 * `generateCSS`.
 *
 * @name SelectorHandler
 * @function
 * @param {string} selector: The currently inspected selector. ':hover' in the
 *     example above.
 * @param {string} baseSelector: The selector of the parent styles.
 *     '.foo:nth-child(2n)' in the example above.
 * @param {function} generateSubtreeStyles: A function which can be called to
 *     generate CSS for the subtree of styles corresponding to the selector.
 *     Accepts a new baseSelector to use for generating those styles.
 * @returns {string[] | string | null} The generated CSS for this selector, or
 *     null if we don't handle this selector.
 */
export const defaultSelectorHandlers: SelectorHandler[] = [
  // Handle pseudo-selectors, like :hover and :nth-child(3n)
  function pseudoSelectors(selector: string, baseSelector: string, generateSubtreeStyles: GenerateSubtreeStyles) {
    if (selector[0] !== ":") {
      return null;
    }
    return generateSubtreeStyles(baseSelector + selector);
  },

  // Handle media queries (or font-faces)
  function mediaQueries(selector: string, baseSelector: string, generateSubtreeStyles: GenerateSubtreeStyles) {
    if (selector[0] !== "@") {
      return null;
    }
    // Generate the styles normally, and then wrap them in the media query.
    const generated = generateSubtreeStyles(baseSelector);
    return [`${selector}{${generated.join('')}}`];
  }
];

/**
 * Generate CSS for a selector and some styles.
 *
 * This function handles the media queries and pseudo selectors that can be used
 * in aphrodite styles.
 *
 * @param {string} selector: A base CSS selector for the styles to be generated
 *     with.
 * @param {Object} styleTypes: A list of properties of the return type of
 *     StyleSheet.create, e.g. [styles.red, styles.blue].
 * @param {Array.<SelectorHandler>} selectorHandlers: A list of selector
 *     handlers to use for handling special selectors. See
 *     `defaultSelectorHandlers`.
 * @param stringHandlers: See `generateCSSRuleset`
 * @param useImportant: See `generateCSSRuleset`
 *
 * To actually generate the CSS special-construct-less styles are passed to
 * `generateCSSRuleset`.
 *
 * For instance, a call to
 *
 *     generateCSS(".foo", [{
 *       color: "red",
 *       "@media screen": {
 *         height: 20,
 *         ":hover": {
 *           backgroundColor: "black"
 *         }
 *       },
 *       ":active": {
 *         fontWeight: "bold"
 *       }
 *     }], defaultSelectorHandlers);
 *
 * with the default `selectorHandlers` will make 5 calls to
 * `generateCSSRuleset`:
 *
 *     generateCSSRuleset(".foo", { color: "red" }, ...)
 *     generateCSSRuleset(".foo:active", { fontWeight: "bold" }, ...)
 *     // These 2 will be wrapped in @media screen {}
 *     generateCSSRuleset(".foo", { height: 20 }, ...)
 *     generateCSSRuleset(".foo:hover", { backgroundColor: "black" }, ...)
 */
export const generateCSS = (
  selector: string,
  styleTypes: SheetDefinition[],
  selectorHandlers: SelectorHandler[],
  stringHandlers = defaultStringHandlers,
): string[] => {
  const merged = new OrderedElements();
  for (let i = 0; i < styleTypes.length; i++) {
    merged.addStyleType(styleTypes[i]);
  }

  const plainDeclarations = new OrderedElements();
  const generatedStyles = [];
 
  // TODO(emily): benchmark this to see if a plain for loop would be faster.
  merged.forEach((val: any, key: string) => {

    // For each key, see if one of the selector handlers will handle these
    // styles.
    const foundHandler = selectorHandlers.some(handler => {
      const result = handler(key, selector, (newSelector) => {
        return generateCSS(newSelector, [val], selectorHandlers, stringHandlers);
      });
      if (result !== null) {
        // If the handler returned something, add it to the generated
        // CSS and stop looking for another handler.
        if (Array.isArray(result)) {
          generatedStyles.push(...result);
        } else {
          // eslint-disable-next-line
                    console.warn(
            'WARNING: Selector handlers should return an array of rules.' +
                        'Returning a string containing multiple rules is deprecated.',
            handler
          );
          generatedStyles.push(`@media all {${result}}`);
        }
        return true;
      }
    });
    // If none of the handlers handled it, add it to the list of plain
    // style declarations.
    if (!foundHandler) {
      plainDeclarations.set(key, val, true);
    }
  });
  const generatedRuleset = generateCSSRuleset(
    selector,
    plainDeclarations,
    stringHandlers,
    selectorHandlers
  );

  if (generatedRuleset) {
    generatedStyles.unshift(generatedRuleset);
  }

  return generatedStyles;
};

/**
 * Helper method of generateCSSRuleset to facilitate custom handling of certain
 * CSS properties. Used for e.g. font families.
 *
 * See generateCSSRuleset for usage and documentation of paramater types.
 */
const runStringHandlers = (
  declarations: OrderedElements,
  stringHandlers: StringHandlers,
  selectorHandlers: SelectorHandler[]
) /* : void */ => {
  if (!stringHandlers) {
    return;
  }

  const stringHandlerKeys = Object.keys(stringHandlers);
  for (let i = 0; i < stringHandlerKeys.length; i++) {
    const key = stringHandlerKeys[i];
    if (declarations.has(key)) {
      // A declaration exists for this particular string handler, so we
      // need to let the string handler interpret the declaration first
      // before proceeding.
      //
      // TODO(emily): Pass in a callback which generates CSS, similar to
      // how our selector handlers work, instead of passing in
      // `selectorHandlers` and have them make calls to `generateCSS`
      // themselves. Right now, this is impractical because our string
      // handlers are very specialized and do complex things.
      declarations.set(
        key,
        stringHandlers[key](declarations.get(key), selectorHandlers),

        // Preserve order here, since we are really replacing an
        // unprocessed style with a processed style, not overriding an
        // earlier style
        false
      );
    }
  }
};


const transformRule = (
  key: string,
  value: string,
  transformValue: (key: string, value: string) => string
) /* : string */ => (
  `${kebabifyStyleName(key)}:${transformValue(key, value)};`
);


const arrayToObjectKeysReducer = (acc: Record<string, unknown>, val: string) => {
  acc[val] = true;
  return acc;
};

/**
 * Generate a CSS ruleset with the selector and containing the declarations.
 *
 * This function assumes that the given declarations don't contain any special
 * children (such as media queries, pseudo-selectors, or descendant styles).
 *
 * Note that this method does not deal with nesting used for e.g.
 * psuedo-selectors or media queries. That responsibility is left to  the
 * `generateCSS` function.
 *
 * @param {string} selector: the selector associated with the ruleset
 * @param {Object} declarations: a map from camelCased CSS property name to CSS
 *     property value.
 * @param {Object.<string, function>} stringHandlers: a map from camelCased CSS
 *     property name to a function which will map the given value to the value
 *     that is output.
 * @param {bool} useImportant: A boolean saying whether to append "!important"
 *     to each of the CSS declarations.
 * @returns {string} A string of raw CSS.
 *
 * Examples:
 *
 *    generateCSSRuleset(".blah", { color: "red" })
 *    -> ".blah{color: red !important;}"
 *    generateCSSRuleset(".blah", { color: "red" }, {}, false)
 *    -> ".blah{color: red}"
 *    generateCSSRuleset(".blah", { color: "red" }, {color: c => c.toUpperCase})
 *    -> ".blah{color: RED}"
 *    generateCSSRuleset(".blah:hover", { color: "red" })
 *    -> ".blah:hover{color: red}"
 */
export const generateCSSRuleset = (
  selector: string,
  declarations: OrderedElements,
  stringHandlers: StringHandlers,
  selectorHandlers: SelectorHandler[]
) /* : string */ => {
  // Mutates declarations
  runStringHandlers(declarations, stringHandlers, selectorHandlers);

  const originalElements = Object.keys(declarations.elements)
    .reduce(arrayToObjectKeysReducer, Object.create(null));

  // NOTE(emily): This mutates handledDeclarations.elements.
  const prefixedElements = prefixAll(declarations.elements);

  const elementNames = Object.keys(prefixedElements);
  if (elementNames.length !== declarations.keyOrder.length) {
    // There are some prefixed values, so we need to figure out how to sort
    // them.
    //
    // Loop through prefixedElements, looking for anything that is not in
    // sortOrder, which means it was added by prefixAll. This means that we
    // need to figure out where it should appear in the sortOrder.
    for (let i = 0; i < elementNames.length; i++) {
      if (!originalElements[elementNames[i]]) {
        // This element is not in the sortOrder, which means it is a prefixed
        // value that was added by prefixAll. Let's try to figure out where it
        // goes.
        let originalStyle;
        if (elementNames[i][0] === 'W') {
          // This is a Webkit-prefixed style, like "WebkitTransition". Let's
          // find its original style's sort order.
          originalStyle = elementNames[i][6].toLowerCase() + elementNames[i].slice(7);
        } else if (elementNames[i][1] === 'o') {
          // This is a Moz-prefixed style, like "MozTransition". We check
          // the second character to avoid colliding with Ms-prefixed
          // styles. Let's find its original style's sort order.
          originalStyle = elementNames[i][3].toLowerCase() + elementNames[i].slice(4);
        } else { // if (elementNames[i][1] === 's') {
          // This is a Ms-prefixed style, like "MsTransition".
          originalStyle = elementNames[i][2].toLowerCase() + elementNames[i].slice(3);
        }
       
        if (originalStyle && originalElements[originalStyle]) {
          const originalIndex = declarations.keyOrder.indexOf(originalStyle);
          declarations.keyOrder.splice(originalIndex, 0, elementNames[i]);
        } else {
          // We don't know what the original style was, so sort it to
          // top. This can happen for styles that are added that don't
          // have the same base name as the original style.
          declarations.keyOrder.unshift(elementNames[i]);
        }
      }
    }
  }

  const transformValue = stringifyValue;
  const rules = [];
  for (let i = 0; i < declarations.keyOrder.length; i ++) {
    const key = declarations.keyOrder[i];
    const value = prefixedElements[key];
    if (Array.isArray(value)) {
      // inline-style-prefixer returns an array when there should be
      // multiple rules for the same key. Here we flatten to multiple
      // pairs with the same key.
      for (let j = 0; j < value.length; j++) {
        rules.push(transformRule(key, value[j], transformValue as any));
      }
    } else {
      rules.push(transformRule(key, value, transformValue as any));
    }
  }

  if (rules.length) {
    return `${selector}{${rules.join("")}}`;
  } else {
    return "";
  }
};
