// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only

const crypto = require('crypto');
const icuParser = require('@formatjs/icu-messageformat-parser');

const globalMessages = require('../../_locales/en/messages.json');
const messageKeys = Object.keys(globalMessages).sort((a, b) => {
  return a.localeCompare(b);
});

const allIcuParams = messageKeys
  .filter(key => {
    return isIcuMessageKey(globalMessages, key);
  })
  .map(key => {
    return Array.from(
      getIcuMessageParams(globalMessages[key].messageformat)
    ).join('\n');
  });

const DEFAULT_RICH_TEXT_ELEMENT_NAMES = ['emojify'];

const hashSum = crypto.createHash('sha256');
hashSum.update(messageKeys.join('\n'));
hashSum.update(allIcuParams.join('\n'));
hashSum.update(DEFAULT_RICH_TEXT_ELEMENT_NAMES.join('\n'));
const messagesCacheKey = hashSum.digest('hex');

function isI18nCall(node) {
  return (
    (node.type === 'CallExpression' &&
      node.callee.type === 'Identifier' &&
      node.callee.name === 'i18n') ||
    (node.callee.type === 'MemberExpression' &&
      node.callee.property.name === 'i18n')
  );
}

function isIntlElement(node) {
  return (
    node.type === 'JSXOpeningElement' &&
    node.name.type === 'JSXIdentifier' &&
    node.name.name === 'Intl'
  );
}

function isStringLiteral(node) {
  return node.type === 'Literal' && typeof node.value === 'string';
}

function valueToMessageKey(node) {
  if (isStringLiteral(node)) {
    return node.value;
  }
  return null;
}

function getI18nCallMessageKey(node) {
  if (node.arguments.length < 1) {
    return null;
  }

  let arg1 = node.arguments[0];
  if (arg1 == null) {
    return null;
  }

  return valueToMessageKey(arg1);
}

function getI18nCallValues(node) {
  // babel-eslint messes with elements arrays in some cases because of TS
  if (node.arguments.length < 2) {
    return null;
  }
  return node.arguments[1];
}

function getIntlElementMessageKey(node) {
  let idAttribute = node.attributes.find(attribute => {
    return (
      attribute.type === 'JSXAttribute' &&
      attribute.name.type === 'JSXIdentifier' &&
      attribute.name.name === 'id'
    );
  });

  if (idAttribute == null) {
    return null;
  }

  let value = idAttribute.value;

  return valueToMessageKey(value);
}

function getIntlElementComponents(node) {
  let componentsAttribute = node.attributes.find(attribute => {
    return (
      attribute.type === 'JSXAttribute' &&
      attribute.name.type === 'JSXIdentifier' &&
      attribute.name.name === 'components'
    );
  });

  if (componentsAttribute == null) {
    return null;
  }

  let value = componentsAttribute.value;
  if (value?.type !== 'JSXExpressionContainer') {
    return null;
  }

  return value.expression;
}

function isValidMessageKey(messages, key) {
  return Object.hasOwn(messages, key);
}

function isIcuMessageKey(messages, key) {
  if (!key.startsWith('icu:')) {
    return false;
  }
  const message = messages[key];
  return message?.messageformat != null;
}

function isDeletedMessageKey(messages, key) {
  const description = messages[key]?.description;
  return description?.toLowerCase().startsWith('(deleted ');
}

function getIcuMessageParams(message, defaultRichTextElementNames = []) {
  const params = new Set();

  function visitOptions(options) {
    for (const option of Object.values(options)) {
      visit(option.value);
    }
  }

  function visit(elements) {
    for (const element of elements) {
      switch (element.type) {
        case icuParser.TYPE.argument:
          params.add(element.value);
          break;
        case icuParser.TYPE.date:
          params.add(element.value);
          break;
        case icuParser.TYPE.literal:
          break;
        case icuParser.TYPE.number:
          params.add(element.value);
          break;
        case icuParser.TYPE.plural:
          params.add(element.value);
          visitOptions(element.options);
          break;
        case icuParser.TYPE.pound:
          break;
        case icuParser.TYPE.select:
          params.add(element.value);
          visitOptions(element.options);
          break;
        case icuParser.TYPE.tag:
          params.add(element.value);
          visit(element.children);
          break;
        case icuParser.TYPE.time:
          params.add(element.value);
          break;
        default:
          throw new Error(`Unknown element type: ${element.type}`);
      }
    }
  }

  visit(icuParser.parse(message));

  for (const defaultRichTextElementName of defaultRichTextElementNames) {
    params.delete(defaultRichTextElementName);
  }

  return params;
}

function getMissingFromSet(expected, actual) {
  const result = new Set();
  for (const item of expected) {
    if (!actual.has(item)) {
      result.add(item);
    }
  }
  return result;
}

module.exports = {
  messagesCacheKey,
  meta: {
    type: 'problem',
    hasSuggestions: false,
    fixable: false,
    schema: [
      {
        type: 'object',
        properties: {
          messagesCacheKey: {
            type: 'string',
          },
          __mockMessages__: {
            type: 'object',
            patternProperties: {
              '.*': {
                oneOf: [
                  {
                    type: 'object',
                    properties: {
                      message: { type: 'string' },
                      description: { type: 'string' },
                    },
                    required: ['message'],
                  },
                  {
                    type: 'object',
                    properties: {
                      messageformat: { type: 'string' },
                      description: { type: 'string' },
                    },
                    required: ['messageformat'],
                  },
                ],
              },
            },
          },
        },
        required: ['messagesCacheKey'],
        additionalProperties: false,
      },
    ],
  },
  create(context) {
    const messagesCacheKeyOption = context.options[0].messagesCacheKey;
    if (messagesCacheKeyOption !== messagesCacheKey) {
      throw new Error(
        `The cache key for the i18n rule does not match the current messages.json file (expected: ${messagesCacheKey}, received: ${messagesCacheKeyOption})`
      );
    }

    const mockMessages = context.options[0].__mockMessages__;
    const messages = mockMessages ?? globalMessages;

    return {
      JSXOpeningElement(node) {
        if (!isIntlElement(node)) {
          return;
        }

        const key = getIntlElementMessageKey(node);

        if (key == null) {
          context.report({
            node,
            message:
              "<Intl> must always be provided an 'id' attribute with a literal string",
          });
          return;
        }

        if (!isValidMessageKey(messages, key)) {
          context.report({
            node,
            message: `<Intl> id "${key}" not found in _locales/en/messages.json`,
          });
          return;
        }

        if (!isIcuMessageKey(messages, key)) {
          context.report({
            node,
            message: `<Intl> id "${key}" is not an ICU message in _locales/en/messages.json`,
          });
          return;
        }

        if (isDeletedMessageKey(messages, key)) {
          context.report({
            node,
            message: `<Intl> id "${key}" is marked as deleted in _locales/en/messages.json`,
          });
          return;
        }

        const params = getIcuMessageParams(
          messages[key].messageformat,
          DEFAULT_RICH_TEXT_ELEMENT_NAMES
        );
        const components = getIntlElementComponents(node);

        if (params.size === 0) {
          if (components != null) {
            context.report({
              node,
              message: `<Intl> message "${key}" does not have any params, but has a "components" attribute`,
            });
          }
          return;
        }

        if (components == null) {
          context.report({
            node,
            message: `<Intl> message "${key}" has params, but is missing a "components" attribute`,
          });
          return;
        }

        if (components.type !== 'ObjectExpression') {
          context.report({
            node: components,
            message: `<Intl> "components" attribute must be an object literal`,
          });
          return;
        }

        const props = new Set();
        for (const property of components.properties) {
          if (property.type !== 'Property' || property.computed) {
            context.report({
              node: property,
              message: `<Intl> "components" attribute must only contain literal keys`,
            });
            return;
          }
          props.add(property.key.name);
        }

        const missingParams = getMissingFromSet(params, props);
        if (missingParams.size > 0) {
          for (const param of missingParams) {
            context.report({
              node: components,
              message: `<Intl> message "${key}" has a param "${param}", but no corresponding component`,
            });
          }
          return;
        }

        const extraComponents = getMissingFromSet(props, params);
        if (extraComponents.size > 0) {
          for (const prop of extraComponents) {
            context.report({
              node: components,
              message: `<Intl> message "${key}" has a component "${prop}", but no corresponding param`,
            });
          }
          return;
        }
      },
      CallExpression(node) {
        if (!isI18nCall(node)) {
          return;
        }

        const key = getI18nCallMessageKey(node);

        if (key == null) {
          context.report({
            node,
            message:
              "i18n()'s first argument should always be a literal string",
          });
          return;
        }

        if (!isValidMessageKey(messages, key)) {
          context.report({
            node,
            message: `i18n() key "${key}" not found in _locales/en/messages.json`,
          });
          return;
        }

        if (!isIcuMessageKey(messages, key)) {
          context.report({
            node,
            message: `i18n() key "${key}" is not an ICU message in _locales/en/messages.json`,
          });
          return;
        }

        if (isDeletedMessageKey(messages, key)) {
          context.report({
            node,
            message: `i18n() key "${key}" is marked as deleted in _locales/en/messages.json`,
          });
          return;
        }

        const params = getIcuMessageParams(
          messages[key].messageformat,
          DEFAULT_RICH_TEXT_ELEMENT_NAMES
        );
        const values = getI18nCallValues(node);

        if (params.size === 0) {
          if (values != null) {
            context.report({
              node,
              message: `i18n() message "${key}" does not have any params, but has a "values" argument`,
            });
          }
          return;
        }

        if (values == null) {
          context.report({
            node,
            message: `i18n() message "${key}" has params, but is missing a "values" argument`,
          });
          return;
        }

        if (values.type !== 'ObjectExpression') {
          context.report({
            node: values,
            message: `i18n() "values" argument must be an object literal`,
          });
          return;
        }

        const props = new Set();
        for (const property of values.properties) {
          if (property.type !== 'Property' || property.computed) {
            context.report({
              node: property,
              message: `i18n() "values" argument must only contain literal keys`,
            });
            return;
          }
          props.add(property.key.name);
        }

        const missingParams = getMissingFromSet(params, props);
        if (missingParams.size > 0) {
          for (const param of missingParams) {
            context.report({
              node: values,
              message: `i18n() message "${key}" has a param "${param}", but no corresponding value`,
            });
          }
          return;
        }

        const extraProps = getMissingFromSet(props, params);
        if (extraProps.size > 0) {
          for (const prop of extraProps) {
            context.report({
              node: values,
              message: `i18n() message "${key}" has a value "${prop}", but no corresponding param`,
            });
          }
          return;
        }
      },
    };
  },
};
