"use strict";

const { isBlockComment, hasLeadingComment } = require("./comments");

const {
  builders: {
    indent,
    join,
    hardline,
    softline,
    literalline,
    concat,
    group,
    dedentToRoot
  },
  utils: { mapDoc, stripTrailingHardline }
} = require("../doc");

function embed(path, print, textToDoc, options) {
  const node = path.getValue();
  const parent = path.getParentNode();
  const parentParent = path.getParentNode(1);

  switch (node.type) {
    case "TemplateLiteral": {
      const isCss = [
        isStyledJsx,
        isStyledComponents,
        isCssProp,
        isAngularComponentStyles
      ].some(isIt => isIt(path));

      if (isCss) {
        // Get full template literal with expressions replaced by placeholders
        const rawQuasis = node.quasis.map(q => q.value.raw);
        let placeholderID = 0;
        const text = rawQuasis.reduce((prevVal, currVal, idx) => {
          return idx == 0
            ? currVal
            : prevVal +
                "@prettier-placeholder-" +
                placeholderID++ +
                "-id" +
                currVal;
        }, "");
        const doc = textToDoc(text, { parser: "css" });
        return transformCssDoc(doc, path, print);
      }

      /*
       * react-relay and graphql-tag
       * graphql`...`
       * graphql.experimental`...`
       * gql`...`
       *
       * This intentionally excludes Relay Classic tags, as Prettier does not
       * support Relay Classic formatting.
       */
      if (isGraphQL(path)) {
        const expressionDocs = node.expressions
          ? path.map(print, "expressions")
          : [];

        const numQuasis = node.quasis.length;

        if (numQuasis === 1 && node.quasis[0].value.raw.trim() === "") {
          return "``";
        }

        const parts = [];

        for (let i = 0; i < numQuasis; i++) {
          const templateElement = node.quasis[i];
          const isFirst = i === 0;
          const isLast = i === numQuasis - 1;
          const text = templateElement.value.cooked;

          // Bail out if any of the quasis have an invalid escape sequence
          // (which would make the `cooked` value be `null` or `undefined`)
          if (typeof text !== "string") {
            return null;
          }

          const lines = text.split("\n");
          const numLines = lines.length;
          const expressionDoc = expressionDocs[i];

          const startsWithBlankLine =
            numLines > 2 && lines[0].trim() === "" && lines[1].trim() === "";
          const endsWithBlankLine =
            numLines > 2 &&
            lines[numLines - 1].trim() === "" &&
            lines[numLines - 2].trim() === "";

          const commentsAndWhitespaceOnly = lines.every(line =>
            /^\s*(?:#[^\r\n]*)?$/.test(line)
          );

          // Bail out if an interpolation occurs within a comment.
          if (!isLast && /#[^\r\n]*$/.test(lines[numLines - 1])) {
            return null;
          }

          let doc = null;

          if (commentsAndWhitespaceOnly) {
            doc = printGraphqlComments(lines);
          } else {
            doc = stripTrailingHardline(textToDoc(text, { parser: "graphql" }));
          }

          if (doc) {
            doc = escapeTemplateCharacters(doc, false);
            if (!isFirst && startsWithBlankLine) {
              parts.push("");
            }
            parts.push(doc);
            if (!isLast && endsWithBlankLine) {
              parts.push("");
            }
          } else if (!isFirst && !isLast && startsWithBlankLine) {
            parts.push("");
          }

          if (expressionDoc) {
            parts.push(concat(["${", expressionDoc, "}"]));
          }
        }

        return concat([
          "`",
          indent(concat([hardline, join(hardline, parts)])),
          hardline,
          "`"
        ]);
      }

      const htmlParser = isHtml(path)
        ? "html"
        : isAngularComponentTemplate(path)
        ? "angular"
        : undefined;

      if (htmlParser) {
        return printHtmlTemplateLiteral(
          path,
          print,
          textToDoc,
          htmlParser,
          options.embeddedInHtml
        );
      }

      break;
    }

    case "TemplateElement": {
      /**
       * md`...`
       * markdown`...`
       */
      if (
        parentParent &&
        (parentParent.type === "TaggedTemplateExpression" &&
          parent.quasis.length === 1 &&
          (parentParent.tag.type === "Identifier" &&
            (parentParent.tag.name === "md" ||
              parentParent.tag.name === "markdown")))
      ) {
        const text = parent.quasis[0].value.raw.replace(
          /((?:\\\\)*)\\`/g,
          (_, backslashes) => "\\".repeat(backslashes.length / 2) + "`"
        );
        const indentation = getIndentation(text);
        const hasIndent = indentation !== "";
        return concat([
          hasIndent
            ? indent(
                concat([
                  softline,
                  printMarkdown(
                    text.replace(new RegExp(`^${indentation}`, "gm"), "")
                  )
                ])
              )
            : concat([literalline, dedentToRoot(printMarkdown(text))]),
          softline
        ]);
      }

      break;
    }
  }

  function printMarkdown(text) {
    const doc = textToDoc(text, { parser: "markdown", __inJsTemplate: true });
    return stripTrailingHardline(escapeTemplateCharacters(doc, true));
  }
}

function getIndentation(str) {
  const firstMatchedIndent = str.match(/^([^\S\n]*)\S/m);
  return firstMatchedIndent === null ? "" : firstMatchedIndent[1];
}

function uncook(cookedValue) {
  return cookedValue.replace(/([\\`]|\$\{)/g, "\\$1");
}

function escapeTemplateCharacters(doc, raw) {
  return mapDoc(doc, currentDoc => {
    if (!currentDoc.parts) {
      return currentDoc;
    }

    const parts = [];

    currentDoc.parts.forEach(part => {
      if (typeof part === "string") {
        parts.push(raw ? part.replace(/(\\*)`/g, "$1$1\\`") : uncook(part));
      } else {
        parts.push(part);
      }
    });

    return Object.assign({}, currentDoc, { parts });
  });
}

function transformCssDoc(quasisDoc, path, print) {
  const parentNode = path.getValue();

  const isEmpty =
    parentNode.quasis.length === 1 && !parentNode.quasis[0].value.raw.trim();
  if (isEmpty) {
    return "``";
  }

  const expressionDocs = parentNode.expressions
    ? path.map(print, "expressions")
    : [];
  const newDoc = replacePlaceholders(quasisDoc, expressionDocs);
  /* istanbul ignore if */
  if (!newDoc) {
    throw new Error("Couldn't insert all the expressions");
  }
  return concat([
    "`",
    indent(concat([hardline, stripTrailingHardline(newDoc)])),
    softline,
    "`"
  ]);
}

// Search all the placeholders in the quasisDoc tree
// and replace them with the expression docs one by one
// returns a new doc with all the placeholders replaced,
// or null if it couldn't replace any expression
function replacePlaceholders(quasisDoc, expressionDocs) {
  if (!expressionDocs || !expressionDocs.length) {
    return quasisDoc;
  }

  const expressions = expressionDocs.slice();
  let replaceCounter = 0;
  const newDoc = mapDoc(quasisDoc, doc => {
    if (!doc || !doc.parts || !doc.parts.length) {
      return doc;
    }
    let parts = doc.parts;
    const atIndex = parts.indexOf("@");
    const placeholderIndex = atIndex + 1;
    if (
      atIndex > -1 &&
      typeof parts[placeholderIndex] === "string" &&
      parts[placeholderIndex].startsWith("prettier-placeholder")
    ) {
      // If placeholder is split, join it
      const at = parts[atIndex];
      const placeholder = parts[placeholderIndex];
      const rest = parts.slice(placeholderIndex + 1);
      parts = parts
        .slice(0, atIndex)
        .concat([at + placeholder])
        .concat(rest);
    }
    const atPlaceholderIndex = parts.findIndex(
      part =>
        typeof part === "string" && part.startsWith("@prettier-placeholder")
    );
    if (atPlaceholderIndex > -1) {
      const placeholder = parts[atPlaceholderIndex];
      const rest = parts.slice(atPlaceholderIndex + 1);
      const placeholderMatch = placeholder.match(
        /@prettier-placeholder-(.+)-id([\s\S]*)/
      );
      const placeholderID = placeholderMatch[1];
      // When the expression has a suffix appended, like:
      // animation: linear ${time}s ease-out;
      const suffix = placeholderMatch[2];
      const expression = expressions[placeholderID];

      replaceCounter++;
      parts = parts
        .slice(0, atPlaceholderIndex)
        .concat(["${", expression, "}" + suffix])
        .concat(rest);
    }
    return Object.assign({}, doc, {
      parts: parts
    });
  });

  return expressions.length === replaceCounter ? newDoc : null;
}

function printGraphqlComments(lines) {
  const parts = [];
  let seenComment = false;

  lines
    .map(textLine => textLine.trim())
    .forEach((textLine, i, array) => {
      // Lines are either whitespace only, or a comment (with potential whitespace
      // around it). Drop whitespace-only lines.
      if (textLine === "") {
        return;
      }

      if (array[i - 1] === "" && seenComment) {
        // If a non-first comment is preceded by a blank (whitespace only) line,
        // add in a blank line.
        parts.push(concat([hardline, textLine]));
      } else {
        parts.push(textLine);
      }

      seenComment = true;
    });

  // If `lines` was whitespace only, return `null`.
  return parts.length === 0 ? null : join(hardline, parts);
}

/**
 * Template literal in these contexts:
 * <style jsx>{`div{color:red}`}</style>
 * css``
 * css.global``
 * css.resolve``
 */
function isStyledJsx(path) {
  const node = path.getValue();
  const parent = path.getParentNode();
  const parentParent = path.getParentNode(1);
  return (
    (parentParent &&
      node.quasis &&
      parent.type === "JSXExpressionContainer" &&
      parentParent.type === "JSXElement" &&
      parentParent.openingElement.name.name === "style" &&
      parentParent.openingElement.attributes.some(
        attribute => attribute.name.name === "jsx"
      )) ||
    (parent &&
      parent.type === "TaggedTemplateExpression" &&
      parent.tag.type === "Identifier" &&
      parent.tag.name === "css") ||
    (parent &&
      parent.type === "TaggedTemplateExpression" &&
      parent.tag.type === "MemberExpression" &&
      parent.tag.object.name === "css" &&
      (parent.tag.property.name === "global" ||
        parent.tag.property.name === "resolve"))
  );
}

/**
 * Angular Components can have:
 * - Inline HTML template
 * - Inline CSS styles
 *
 * ...which are both within template literals somewhere
 * inside of the Component decorator factory.
 *
 * E.g.
 * @Component({
 *  template: `<div>...</div>`,
 *  styles: [`h1 { color: blue; }`]
 * })
 */
function isAngularComponentStyles(path) {
  return isPathMatch(
    path,
    [
      node => node.type === "TemplateLiteral",
      (node, name) => node.type === "ArrayExpression" && name === "elements",
      (node, name) =>
        node.type === "Property" &&
        node.key.type === "Identifier" &&
        node.key.name === "styles" &&
        name === "value"
    ].concat(getAngularComponentObjectExpressionPredicates())
  );
}
function isAngularComponentTemplate(path) {
  return isPathMatch(
    path,
    [
      node => node.type === "TemplateLiteral",
      (node, name) =>
        node.type === "Property" &&
        node.key.type === "Identifier" &&
        node.key.name === "template" &&
        name === "value"
    ].concat(getAngularComponentObjectExpressionPredicates())
  );
}
function getAngularComponentObjectExpressionPredicates() {
  return [
    (node, name) => node.type === "ObjectExpression" && name === "properties",
    (node, name) =>
      node.type === "CallExpression" &&
      node.callee.type === "Identifier" &&
      node.callee.name === "Component" &&
      name === "arguments",
    (node, name) => node.type === "Decorator" && name === "expression"
  ];
}

/**
 * styled-components template literals
 */
function isStyledComponents(path) {
  const parent = path.getParentNode();

  if (!parent || parent.type !== "TaggedTemplateExpression") {
    return false;
  }

  const tag = parent.tag;

  switch (tag.type) {
    case "MemberExpression":
      return (
        // styled.foo``
        isStyledIdentifier(tag.object) ||
        // Component.extend``
        isStyledExtend(tag)
      );

    case "CallExpression":
      return (
        // styled(Component)``
        isStyledIdentifier(tag.callee) ||
        (tag.callee.type === "MemberExpression" &&
          ((tag.callee.object.type === "MemberExpression" &&
            // styled.foo.attrs({})``
            (isStyledIdentifier(tag.callee.object.object) ||
              // Component.extend.attrs({})``
              isStyledExtend(tag.callee.object))) ||
            // styled(Component).attrs({})``
            (tag.callee.object.type === "CallExpression" &&
              isStyledIdentifier(tag.callee.object.callee))))
      );

    case "Identifier":
      // css``
      return tag.name === "css";

    default:
      return false;
  }
}

/**
 * JSX element with CSS prop
 */
function isCssProp(path) {
  const parent = path.getParentNode();
  const parentParent = path.getParentNode(1);
  return (
    parentParent &&
    parent.type === "JSXExpressionContainer" &&
    parentParent.type === "JSXAttribute" &&
    parentParent.name.type === "JSXIdentifier" &&
    parentParent.name.name === "css"
  );
}

function isStyledIdentifier(node) {
  return node.type === "Identifier" && node.name === "styled";
}

function isStyledExtend(node) {
  return /^[A-Z]/.test(node.object.name) && node.property.name === "extend";
}

/*
 * react-relay and graphql-tag
 * graphql`...`
 * graphql.experimental`...`
 * gql`...`
 * GraphQL comment block
 *
 * This intentionally excludes Relay Classic tags, as Prettier does not
 * support Relay Classic formatting.
 */
function isGraphQL(path) {
  const node = path.getValue();
  const parent = path.getParentNode();

  return (
    hasLanguageComment(node, "GraphQL") ||
    (parent &&
      ((parent.type === "TaggedTemplateExpression" &&
        ((parent.tag.type === "MemberExpression" &&
          parent.tag.object.name === "graphql" &&
          parent.tag.property.name === "experimental") ||
          (parent.tag.type === "Identifier" &&
            (parent.tag.name === "gql" || parent.tag.name === "graphql")))) ||
        (parent.type === "CallExpression" &&
          parent.callee.type === "Identifier" &&
          parent.callee.name === "graphql")))
  );
}

function hasLanguageComment(node, languageName) {
  // This checks for a leading comment that is exactly `/* GraphQL */`
  // In order to be in line with other implementations of this comment tag
  // we will not trim the comment value and we will expect exactly one space on
  // either side of the GraphQL string
  // Also see ./clean.js
  return hasLeadingComment(
    node,
    comment => isBlockComment(comment) && comment.value === ` ${languageName} `
  );
}

function isPathMatch(path, predicateStack) {
  const stack = path.stack.slice();

  let name = null;
  let node = stack.pop();

  for (const predicate of predicateStack) {
    if (node === undefined) {
      return false;
    }

    // skip index/array
    if (typeof name === "number") {
      name = stack.pop();
      node = stack.pop();
    }

    if (!predicate(node, name)) {
      return false;
    }

    name = stack.pop();
    node = stack.pop();
  }

  return true;
}

/**
 *     - html`...`
 *     - HTML comment block
 */
function isHtml(path) {
  const node = path.getValue();
  return (
    hasLanguageComment(node, "HTML") ||
    isPathMatch(path, [
      node => node.type === "TemplateLiteral",
      (node, name) =>
        node.type === "TaggedTemplateExpression" &&
        node.tag.type === "Identifier" &&
        node.tag.name === "html" &&
        name === "quasi"
    ])
  );
}

// The counter is needed to distinguish nested embeds.
let htmlTemplateLiteralCounter = 0;

function printHtmlTemplateLiteral(
  path,
  print,
  textToDoc,
  parser,
  escapeClosingScriptTag
) {
  const node = path.getValue();

  const counter = htmlTemplateLiteralCounter;
  htmlTemplateLiteralCounter = (htmlTemplateLiteralCounter + 1) >>> 0;

  const composePlaceholder = index =>
    `PRETTIER_HTML_PLACEHOLDER_${index}_${counter}_IN_JS`;

  const text = node.quasis
    .map((quasi, index, quasis) =>
      index === quasis.length - 1
        ? quasi.value.cooked
        : quasi.value.cooked + composePlaceholder(index)
    )
    .join("");

  const expressionDocs = path.map(print, "expressions");

  if (expressionDocs.length === 0 && text.trim().length === 0) {
    return "``";
  }

  const placeholderRegex = RegExp(composePlaceholder("(\\d+)"), "g");

  const contentDoc = mapDoc(
    stripTrailingHardline(textToDoc(text, { parser })),
    doc => {
      if (typeof doc !== "string") {
        return doc;
      }

      const parts = [];

      const components = doc.split(placeholderRegex);
      for (let i = 0; i < components.length; i++) {
        let component = components[i];

        if (i % 2 === 0) {
          if (component) {
            component = uncook(component);
            if (escapeClosingScriptTag) {
              component = component.replace(/<\/(script)\b/gi, "<\\/$1");
            }
            parts.push(component);
          }
          continue;
        }

        const placeholderIndex = +component;
        parts.push(
          concat(["${", group(expressionDocs[placeholderIndex]), "}"])
        );
      }

      return concat(parts);
    }
  );

  return group(
    concat(["`", indent(concat([hardline, group(contentDoc)])), softline, "`"])
  );
}

module.exports = embed;
