const { nodes: n } = require('nunjucks');
const babel = require('@babel/parser');
const { default: generate } = require('@babel/generator');
const { default: traverse } = require('@babel/traverse');
const t = require('@babel/types');
const template = require('@babel/template').default;
const { Parser } = require('./lit-nunjucks');
const { get, snakeCase, last, mapKeys, has } = require('lodash');
const path = require('path');
const i18nCurrency = require('@ordergroove/i18n-data/i18n_currency_data.json');
const replaceLiteralValuesInTemplate = require('./optimize');
const smiLess = require('./less');
const { OG_INTERNAL_VAR_NAME, FOR_LOOP_HELPER_NAME } = require('./constants');

const validCurrencies = new Set(Object.values(i18nCurrency));
const validCurrencyDisplays = new Set(['symbol', 'narrowSymbol', 'code', 'name']);

class SmiParser extends Parser {
  handleFunCall(node) {
    if (node.name instanceof n.Symbol) {
      switch (node.name.value) {
        case 't':
          return this.handleTranslation(node);
        case 'setting':
          return this.handleSetting(node);
        case 'js':
          return this.handleJs(node);
        case 'currency':
          return this.handleCurrency(node);
        default:
          return super.handleFunCall(node);
      }
    }
  }

  handleTranslation(node) {
    return t.callExpression(t.identifier(`_F.t`), [].concat(this.wrap(node.args)));
  }
  handleSetting(node) {
    return t.callExpression(t.identifier(`_F.setting`), [].concat(this.wrap(node.args)));
  }
  handleJs(node) {
    return t.callExpression(t.identifier(`_F.js`), [].concat(this.wrap(node.args)));
  }
  handleCurrency(node) {
    return t.callExpression(t.identifier(`_F.currency`), [].concat(this.wrap(node.args)));
  }
}

function parse(source, opts) {
  return new SmiParser(opts).parse(source);
}

const filenameWithoutExt = (content, file) => {
  const ext = path.extname(file);
  const basename = path.basename(file);
  return ext && basename ? basename.substr(0, basename.length - ext.length) : file;
};

const parseTranslation = (source, key, locale) => {
  try {
    if (typeof source === 'object') {
      return t.objectExpression(
        Object.entries(source).map(([key, val]) =>
          t.objectProperty(t.stringLiteral(key), parseTranslation(val, key, locale))
        )
      );
    }
    const ast = parse(source);
    const translationNode = last(ast.body.body).argument;
    // if translation compilation return no expression replace it with plain literal
    if (t.isTaggedTemplateExpression(translationNode) && translationNode.quasi.quasis.length === 1) {
      return t.stringLiteral(translationNode.quasi.quasis[0].value.raw);
    }
    return translationNode;
  } catch (err) {
    throw new Error(`Can't parse translation ${key} in ${locale}.`);
  }
};

const isTranslation = path => t.isIdentifier(path) && path.node.name === path.node.name.toUpperCase();

const propertyIsInTemplateArg = path =>
  t.isProperty(path.parent) &&
  t.isObjectPattern(path.parentPath.parent) &&
  t.isFunctionExpression(path.parentPath.parentPath.parent) &&
  path.parentPath.parentPath.parent.id.name === 'template';

const isFilterCallExpression = filterName => path => {
  return (
    (t.isIdentifier(path.node.callee) && path.node.callee.name === `_F.${filterName}`) ||
    (t.isMemberExpression(path.node.callee) &&
      t.isIdentifier(path.node.callee.object) &&
      t.isIdentifier(path.node.callee.object) &&
      t.isIdentifier(path.node.callee.property) &&
      path.node.callee.object.name === '_F' &&
      path.node.callee.property.name === filterName)
  );
};

const isTranslationCallExpression = isFilterCallExpression('t');
const isJsCallExpression = isFilterCallExpression('js');
const isSettingCallExpression = isFilterCallExpression('setting');
const isCurrencyCallExpression = isFilterCallExpression('currency');

function addCallExpressions(ast, locales, file, settings) {
  traverse(ast, {
    CallExpression(path) {
      const { arguments: args } = path.node;
      const [firstArg] = args;

      if (isTranslationCallExpression(path) && t.isStringLiteral(firstArg)) {
        processTranslationCallExpression(path, firstArg, locales, file);
      } else if (isJsCallExpression(path) && t.isStringLiteral(firstArg)) {
        processJsCallExpression(path, firstArg);
      } else if (isSettingCallExpression(path) && t.isStringLiteral(firstArg)) {
        processSettingCallExpression(path, firstArg, settings);
      } else if (isCurrencyCallExpression(path)) {
        processCurrencyCallExpression(path, locales, validCurrencies, validCurrencyDisplays);
      }
    },
    Identifier(path) {
      const translationsKey = path.node.name;
      if (translationsKey in locales) {
        processIdentifier(path, translationsKey, locales, file);
      }
    }
  });
}

// Replace | t expressions
function processTranslationCallExpression(path, firstArg, locales, file) {
  const translationsKey = firstArg.value.toLowerCase();
  const translationArguments = path.node.arguments[1];

  if (!(translationsKey in locales) || locales[translationsKey] === '') {
    path.replaceWith(t.stringLiteral(''));
  } else if (!translationArguments) {
    path.replaceWith(parseTranslation(locales[translationsKey], translationsKey, file));
  } else if (t.isObjectExpression(translationArguments)) {
    replaceTranslationWithArguments(path, translationArguments, locales[translationsKey], translationsKey, file);
  } else {
    throw new Error('Translation expression not supported');
  }
}

// Replace | js expressions
function processJsCallExpression(path, firstArg) {
  try {
    const subprogram = babel.parse(firstArg.value);
    path.replaceWith(subprogram.program.body[0]);
  } catch (err) {
    throw new Error(`Can't parse JS expression: ${firstArg.value}`);
  }
}

// Replace | setting expressions
function processSettingCallExpression(path, firstArg, settings) {
  const settingKey = firstArg.value.toLowerCase();
  const defaultValueNode = path.node.arguments[1];

  if (has(settings, settingKey)) {
    const value = get(settings, settingKey);
    path.replaceWithSourceString(JSON.stringify(value));
  } else if (defaultValueNode) {
    path.replaceWith(defaultValueNode);
  } else {
    path.replaceWithSourceString('undefined');
  }
}

// Replace | currency expressions
function processCurrencyCallExpression(path, locales, validCurrencies, validCurrencyDisplays) {
  // there should always be a value being piped into the filter
  if (path.node.arguments.length === 1) {
    path.node.arguments.push(
      validCurrencies.has(locales['currency_code'])
        ? t.stringLiteral(locales['currency_code'])
        : t.identifier('undefined')
    );
    path.node.arguments.push(
      validCurrencyDisplays.has(locales['currency_display'])
        ? t.stringLiteral(locales['currency_display'])
        : t.identifier('undefined')
    );
  }
  if (path.node.arguments.length === 2) {
    path.node.arguments.push(
      validCurrencyDisplays.has(locales['currency_display'])
        ? t.stringLiteral(locales['currency_display'])
        : t.identifier('undefined')
    );
  }
}

function processIdentifier(path, translationsKey, locales, file) {
  if (propertyIsInTemplateArg(path)) {
    path.parentPath.remove();
  } else if (t.isTemplateLiteral(path.parent) || isTranslation(path)) {
    if (locales[translationsKey] === '') {
      path.replaceWith(t.stringLiteral(''));
    } else {
      path.replaceWith(parseTranslation(locales[translationsKey], translationsKey, file));
    }
  }
}

function replaceTranslationWithArguments(path, argumentReplacement, translation, translationsKey, file) {
  // Support translations arguments t(every=subscription.every, period=subscription.period)
  if (!argumentReplacement.properties.every(prop => t.isStringLiteral(prop.key))) {
    throw new Error('Translation argument keys should be strings');
  }

  const [argumentNames, callValues] = argumentReplacement.properties.reduce(
    ([names, values], prop) => [
      [...names, t.identifier(prop.key.value)],
      [...values, prop.value]
    ],
    [[], []]
  );

  const body = parseTranslation(translation, translationsKey, file);
  const translationWithValues = t.callExpression(t.arrowFunctionExpression(argumentNames, body), callValues);
  path.replaceWith(translationWithValues);
}

function addLocaleFunctions(ast, localeName, result, localizedTemplates) {
  traverse(ast, {
    Identifier(path) {
      if (t.isFunctionDeclaration(path.parent) && path.node.name === 'template') {
        const name = `template_${snakeCase(localeName)}`;
        localizedTemplates.push(t.objectProperty(t.stringLiteral(localeName), t.identifier(name)));
        result.push(
          t.functionDeclaration(
            t.identifier(name),
            path.parent.params.map(p => t.cloneNode(p, true)),
            t.cloneNode(path.parent.body, true)
          )
        );
      }
    }
  });
}

/**
 * @param source string representing the main template liquid file
 * @param partials string object where keys refer to the template partials name and values are the template content
 * @param locales array locale objects { locale, translations, file }
 * @param defaultLocale string default to en
 */
function precompileCompat(source, partials = {}, locales = [], settings = {}, defaultLocale = 'en', script, css) {
  const ast = t.file(t.program([parse(source, { partials: mapKeys(partials, filenameWithoutExt) })]));
  const { result, localizedTemplates } = compileLocales(locales, defaultLocale, settings, ast);
  const extras = generateExtras(settings, script, css);
  const program = constructTemplateFunction(localizedTemplates, result, extras);
  replaceLiteralValuesInTemplate(program);
  return {
    template: generate(program).code,
    css
  };
}

const getLocaleName = name => {
  const matches = name.match(/(^|\/)([A-Za-z]{2,4})[_-]?([A-Za-z]{2,4})?([_-]([A-Za-z]{2}|[0-9]{3}))?\.json$/);
  if (matches) {
    const localeName = matches
      .slice(2, 4)
      .concat(matches[5])
      .filter(i => i)
      .join('-');

    return localeName;
  }
  return undefined;
};

function reduceFilesToConfig(state, { name, content }) {
  const basename = (str, sep = '/') => str.substr(str.lastIndexOf(sep) + 1);

  if (basename(name) === 'theme-settings.json') {
    const { settings, ...extra } = state;
    return {
      ...extra,
      ...(content !== null && { settings: JSON.parse(content) })
    };
  }

  if (basename(name) === 'styles.css') {
    const { css, ...extra } = state;
    return {
      ...extra,
      ...(content !== null && { css: content })
    };
  }
  if (basename(name) === 'script.js') {
    const { script, ...extra } = state;
    return {
      ...extra,
      ...(content !== null && { script: content })
    };
  }

  const localeName = getLocaleName(name);
  if (localeName) {
    const { locales, ...extra } = state;
    const translationsJson = JSON.parse(content || '{}');
    return {
      ...extra,
      locales:
        content === null
          ? (locales || []).filter(it => it.locale !== localeName)
          : Object.entries(
              (locales || [])
                .concat({
                  locale: localeName,
                  translations: translationsJson
                })
                .reduce((p, c) => ({ ...p, [c.locale]: c.translations }), {})
            ).map(([locale, translations]) => ({ locale, translations }))
    };
  }
  const isTemplate = name.match(/(?:\/?views\/)([-\w/]+)\.liquid/);
  if (isTemplate) {
    const name = isTemplate[1];
    const partials = {
      ...state.partials,
      [name]: content
    };
    if (content === null) delete partials[name];
    return {
      ...state,
      partials: partials
    };
  }
  return state;
}

async function preprocessFiles(files) {
  const { css } = await smiLess(files);
  const smi = files.reduce(reduceFilesToConfig, {});
  return { css, smi };
}

function compileLocales(locales, defaultLocale, settings, ast) {
  const result = [];
  const localizedTemplates = [];

  if (!locales.length) locales.push({ locale: defaultLocale, translations: {} });
  let defaultLocaleIx = locales.findIndex(({ locale }) => locale.startsWith(defaultLocale));
  if (defaultLocaleIx < 0) defaultLocaleIx = 0;

  locales.forEach(({ locale, translations, file }, ix, arr) => {
    const localeName = locale;
    const mergedLocales =
      ix !== defaultLocaleIx ? { ...arr[defaultLocaleIx].translations, ...translations } : translations || {};
    const clonedAst = t.cloneNode(ast, true);

    addCallExpressions(clonedAst, mergedLocales, file, settings);
    addLocaleFunctions(clonedAst, localeName, result, localizedTemplates);
  });

  return {
    result,
    localizedTemplates
  };
}

function generateExtras(settings, script, css) {
  let extras = [];
  if (script) {
    const scriptAst = babel.parse(script);
    extras.push(...scriptAst.program.body);
  }
  if (css) {
    const cssChunkId = 'css_element_id' in settings ? settings.css_element_id : 'og_smi_styles';
    const cssAst = babel.parse(`
      const css = document.createElement('style');
      css.type = 'text/css';
      css.id = ${JSON.stringify(cssChunkId)};
      css.appendChild(document.createTextNode(${JSON.stringify(css.toString())}));
      document.head.appendChild(css);
    `);
    extras.push(...cssAst.program.body);
  }

  return extras;
}

function constructTemplateFunction(localizedTemplates, result, extras) {
  // when the | key filter is used in the template, the resulting array will have the __og_should_key property set
  // __og_key is set by the | key filter - we use it as a unique key to allow Lit to update lists correctly when the order changes
  const forLoopHelper = `
      function(arr, renderFn) {
        return arr.__og_should_key
          ? repeat(arr, item => item.__og_key, renderFn)
          : arr.map(renderFn)
      }
  `;

  // Internal object to hold helper functions
  // This is to avoid conflicting with user defined variables/functions
  const ogInternalDeclaration = template.ast(`
    const ${OG_INTERNAL_VAR_NAME} = {
      ${FOR_LOOP_HELPER_NAME}: ${forLoopHelper}
    };
  `);

  return t.file(
    t.program([
      t.functionDeclaration(
        t.identifier('template'),
        [t.identifier('html'), t.identifier('repeat'), t.identifier('unsafeHTML')],
        // Custom script should be before templates_en_us but inside template(){} generator.
        t.blockStatement([
          ogInternalDeclaration,
          ...extras,
          ...result,
          t.returnStatement(t.objectExpression(localizedTemplates))
        ])
      )
    ])
  );
}

async function processArrayInput(files, settings, defaultLocale) {
  const { css, smi } = await preprocessFiles(files);

  const combinedSettings = { ...settings, ...smi.settings }; // Merge settings from theme-settings.json and settings from SSPC
  return precompileCompat(
    smi.partials.main,
    smi.partials,
    smi.locales,
    combinedSettings,
    defaultLocale,
    smi.script,
    css
  );
}

function processLegacyInput(source, partials, locales, settings, defaultLocale, script, css) {
  return precompileCompat(source, partials, locales, settings, defaultLocale, script, css).template;
}

/**
 * Precompiles the given SMI files into a template function
 *
 * New signature:
 * precompile(files, settings = {}, defaultLocale = 'en', flags = {})
 *
 * if `flags.objectReturn` is true, returns an object instead of a string.
 * The `template` property of this object is equivalent to the string returned when `objectReturn` is false.
 *
 * Legacy signature:
 * precompile(source, partials = {}, locales = [], settings = {}, defaultLocale = 'en', script, css)
 *
 * @returns {Promise<string|{template: string; css: string}>|string}
 */
function precompile() {
  if (!Array.isArray(arguments[0])) {
    // Legacy usage
    const source = arguments[0];
    const partials = arguments[1] || {};
    const locales = arguments[2];
    const settings = arguments[3] || {};
    const defaultLocale = arguments[4] || 'en';
    const script = arguments[5];
    const css = arguments[6];
    return processLegacyInput(source, partials, locales, settings, defaultLocale, script, css);
  }

  // Newer usage
  const files = arguments[0];
  const settings = arguments[1] || {};
  const defaultLocale = arguments[2] || 'en';
  const compileFlags = arguments[3] || {};
  return processArrayInput(files, settings, defaultLocale).then(result =>
    compileFlags.objectReturn ? result : result.template
  );
}

module.exports = precompile;
