/**
 * This file handles transforming template literals to class names or styled components and generates CSS content.
 * It uses CSS code from template literals and evaluated values of lazy dependencies stored in ValueCache.
 */

import type { Expression } from '@babel/types';
import generator from '@babel/generator';

import type { StyledMeta } from '@linaria/core';
import { debug } from '@linaria/logger';
import { units } from '../units';
import type {
  State,
  StrictOptions,
  TemplateExpression,
  ValueCache,
} from '../types';

import isSerializable from '../utils/isSerializable';
import throwIfInvalid from '../utils/throwIfInvalid';
import stripLines from '../utils/stripLines';
import toCSS from '../utils/toCSS';
import getLinariaComment from '../utils/getLinariaComment';
import { Core } from '../babel';

// Match any valid CSS units followed by a separator such as ;, newline etc.
const unitRegex = new RegExp(`^(${units.join('|')})(;|,|\n| |\\))`);

type Interpolation = {
  id: string;
  node: Expression;
  source: string;
  unit: string;
};

function hasMeta(value: any): value is StyledMeta {
  return value && typeof value === 'object' && (value as any).__linaria;
}

const processedPaths = new WeakSet();

export default function getTemplateProcessor(
  { types: t }: Core,
  options: StrictOptions
) {
  return function process(
    { styled, path }: TemplateExpression,
    state: State,
    valueCache: ValueCache
  ) {
    if (processedPaths.has(path)) {
      // Do not process an expression
      // if it is referenced in one template more than once
      return;
    }

    processedPaths.add(path);

    const { quasi } = path.node;

    const interpolations: Interpolation[] = [];

    // Check if the variable is referenced anywhere for basic DCE
    // Only works when it's assigned to a variable
    let isReferenced = true;

    const [, slug, displayName, className] = getLinariaComment(path);

    const parent = path.findParent(
      (p) =>
        t.isObjectProperty(p) ||
        t.isJSXOpeningElement(p) ||
        t.isVariableDeclarator(p)
    );

    if (parent) {
      const parentNode = parent.node;
      if (t.isVariableDeclarator(parentNode) && t.isIdentifier(parentNode.id)) {
        const { referencePaths } = path.scope.getBinding(
          parentNode.id.name
        ) || { referencePaths: [] };

        isReferenced = referencePaths.length !== 0;
      }
    }

    // Serialize the tagged template literal to a string
    let cssText = '';

    const expressions = path.get('quasi').get('expressions');

    quasi.quasis.forEach((el, i, self) => {
      let appended = false;

      if (i !== 0 && el.value.cooked) {
        // Check if previous expression was a CSS variable that we replaced
        // If it has a unit after it, we need to move the unit into the interpolation
        // e.g. `var(--size)px` should actually be `var(--size)`
        // So we check if the current text starts with a unit, and add the unit to the previous interpolation
        // Another approach would be `calc(var(--size) * 1px), but some browsers don't support all units
        // https://bugzilla.mozilla.org/show_bug.cgi?id=956573
        const matches = el.value.cooked.match(unitRegex);

        if (matches) {
          const last = interpolations[interpolations.length - 1];
          const [, unit] = matches;

          if (last && cssText.endsWith(`var(--${last.id})`)) {
            last.unit = unit;
            cssText += el.value.cooked.replace(unitRegex, '$2');
            appended = true;
          }
        }
      }

      if (!appended) {
        cssText += el.value.cooked;
      }

      const ex = expressions[i];

      if (ex && !ex.isExpression()) {
        throw ex.buildCodeFrameError(
          `The expression '${generator(ex.node).code}' is not supported.`
        );
      }

      if (ex) {
        const { end } = ex.node.loc!;
        const result = ex.evaluate();
        const beforeLength = cssText.length;

        // The location will be end of the current string to start of next string
        const next = self[i + 1];
        const loc = {
          // +1 because the expressions location always shows 1 column before
          start: { line: el.loc!.end.line, column: el.loc!.end.column + 1 },
          end: next
            ? { line: next.loc!.start.line, column: next.loc!.start.column }
            : { line: end.line, column: end.column + 1 },
        };

        if (result.confident) {
          throwIfInvalid(result.value, ex);

          if (isSerializable(result.value)) {
            // If it's a plain object or an array, convert it to a CSS string
            cssText += stripLines(loc, toCSS(result.value));
          } else {
            cssText += stripLines(loc, result.value);
          }

          state.replacements.push({
            original: loc,
            length: cssText.length - beforeLength,
          });
        } else {
          // Try to preval the value
          if (
            options.evaluate &&
            !(t.isFunctionExpression(ex) || t.isArrowFunctionExpression(ex))
          ) {
            const value = valueCache.get(ex.node);
            throwIfInvalid(value, ex);

            // Skip the blank string instead of throw ing an error
            if (value === '') {
              return;
            }

            if (value && typeof value !== 'function') {
              // Only insert text for non functions
              // We don't touch functions because they'll be interpolated at runtime

              if (hasMeta(value)) {
                // If it's an React component wrapped in styled, get the class name
                // Useful for interpolating components
                cssText += `.${value.__linaria.className}`;
              } else if (isSerializable(value)) {
                cssText += stripLines(loc, toCSS(value));
              } else {
                // For anything else, assume it'll be stringified
                cssText += stripLines(loc, value);
              }

              state.replacements.push({
                original: loc,
                length: cssText.length - beforeLength,
              });

              return;
            }
          }

          if (styled) {
            const id = `${slug}-${i}`;

            interpolations.push({
              id,
              node: ex.node,
              source: ex.getSource() || generator(ex.node).code,
              unit: '',
            });

            cssText += `var(--${id})`;
          } else {
            // CSS custom properties can't be used outside components
            throw ex.buildCodeFrameError(
              `The CSS cannot contain JavaScript expressions when using the 'css' tag. To evaluate the expressions at build time, pass 'evaluate: true' to the babel plugin.`
            );
          }
        }
      }
    });

    let selector = `.${className}`;

    if (styled) {
      // If `styled` wraps another component and not a primitive,
      // get its class name to create a more specific selector
      // it'll ensure that styles are overridden properly
      if (options.evaluate && t.isIdentifier(styled.component.node)) {
        let value = valueCache.get(styled.component.node.name);
        while (hasMeta(value)) {
          selector += `.${value.__linaria.className}`;
          value = value.__linaria.extends;
        }
      }

      const props = [];

      props.push(
        t.objectProperty(t.identifier('name'), t.stringLiteral(displayName!))
      );

      props.push(
        t.objectProperty(t.identifier('class'), t.stringLiteral(className!))
      );

      // If we found any interpolations, also pass them so they can be applied
      if (interpolations.length) {
        // De-duplicate interpolations based on the source and unit
        // If two interpolations have the same source code and same unit,
        // we don't need to use 2 custom properties for them, we can use a single one
        const result: { [key: string]: Interpolation } = {};

        interpolations.forEach((it) => {
          const key = it.source + it.unit;

          if (key in result) {
            cssText = cssText.replace(
              `var(--${it.id})`,
              `var(--${result[key].id})`
            );
          } else {
            result[key] = it;
          }
        });

        props.push(
          t.objectProperty(
            t.identifier('vars'),
            t.objectExpression(
              Object.keys(result).map((key) => {
                const { id, node, unit } = result[key];
                const items = [node];

                if (unit) {
                  items.push(t.stringLiteral(unit));
                }

                return t.objectProperty(
                  t.stringLiteral(id),
                  t.arrayExpression(items)
                );
              })
            )
          )
        );
      }

      path.replaceWith(
        t.callExpression(
          t.callExpression(
            t.identifier(state.file.metadata.localName || 'styled'),
            [styled.component.node]
          ),
          [t.objectExpression(props)]
        )
      );

      path.addComment('leading', '#__PURE__');
    } else {
      path.replaceWith(t.stringLiteral(className!));
    }

    if (!isReferenced && !cssText.includes(':global')) {
      return;
    }

    debug(
      'evaluator:template-processor:extracted-rule',
      `\n${selector} {${cssText}\n}`
    );

    state.rules[selector] = {
      cssText,
      className: className!,
      displayName: displayName!,
      start: path.parent?.loc?.start ?? null,
    };
  };
}
