/*
Copyright (c) 2014, Yahoo! Inc. All rights reserved.
Copyrights licensed under the New BSD License.
See the accompanying LICENSE file for terms.
*/

/* jslint esnext: true */

import parser, { MessageFormatPattern } from 'intl-messageformat-parser';

import Compiler, {
  Formats,
  Formatters,
  isSelectOrPluralFormat,
  Pattern} from './compiler';

// -- MessageFormat --------------------------------------------------------

function resolveLocale(locales: string | string[]): string {
  if (typeof locales === 'string') {
    locales = [locales];
  }
  try {
    return Intl.NumberFormat.supportedLocalesOf(locales, {
      // IE11 localeMatcher `lookup` seems to convert `en` -> `en-US`
      // but not other browsers,
      localeMatcher: 'best fit'
    })[0];
  } catch (e) {
    return IntlMessageFormat.defaultLocale;
  }
}

function formatPatterns(
  pattern: Pattern[],
  values?: Record<string, string | number | boolean | null | undefined>
) {
  let result = '';
  for (const part of pattern) {
    // Exist early for string parts.
    if (typeof part === 'string') {
      result += part;
      continue;
    }

    const { id } = part;

    // Enforce that all required values are provided by the caller.
    if (!(values && id in values)) {
      throw new FormatError(`A value must be provided for: ${id}`, id);
    }

    const value = values[id];

    // Recursively format plural and select parts' option — which can be a
    // nested pattern structure. The choosing of the option to use is
    // abstracted-by and delegated-to the part helper object.
    if (isSelectOrPluralFormat(part)) {
      result += formatPatterns(part.getOption(value as any), values);
    } else {
      result += part.format(value as any);
    }
  }

  return result;
}

function mergeConfig(c1: Record<string, object>, c2?: Record<string, object>) {
  if (!c2) {
    return c1;
  }
  return {
    ...(c1 || {}),
    ...(c2 || {}),
    ...Object.keys(c1).reduce((all: Record<string, object>, k) => {
      all[k] = {
        ...c1[k],
        ...(c2[k] || {})
      };
      return all;
    }, {})
  };
}

function mergeConfigs(
  defaultConfig: Formats,
  configs?: Partial<Formats>
): Formats {
  if (!configs) {
    return defaultConfig;
  }

  return (Object.keys(defaultConfig) as Array<keyof Formats>).reduce(
    (all: Formats, k: keyof Formats) => {
      all[k] = mergeConfig(defaultConfig[k], configs[k]);
      return all;
    },
    { ...defaultConfig }
  );
}

class FormatError extends Error {
  public readonly variableId?: string;
  constructor(msg?: string, variableId?: string) {
    super(msg);
    this.variableId = variableId;
  }
}

export interface Options {
  formatters?: Formatters;
}

export function createDefaultFormatters(): Formatters {
  return {
    getNumberFormat(...args) {
      return new Intl.NumberFormat(...args);
    },
    getDateTimeFormat(...args) {
      return new Intl.DateTimeFormat(...args);
    },
    getPluralRules(...args) {
      return new Intl.PluralRules(...args);
    }
  };
}

export class IntlMessageFormat {
  private ast: MessageFormatPattern;
  private locale: string;
  private pattern: Pattern[];
  private message: string | MessageFormatPattern;
  constructor(
    message: string | MessageFormatPattern,
    locales: string | string[] = IntlMessageFormat.defaultLocale,
    overrideFormats?: Partial<Formats>,
    opts?: Options
  ) {
    if (typeof message === 'string') {
      if (!IntlMessageFormat.__parse) {
        throw new TypeError(
          'IntlMessageFormat.__parse must be set to process `message` of type `string`'
        );
      }
      // Parse string messages into an AST.
      this.ast = IntlMessageFormat.__parse(message);
    } else {
      this.ast = message;
    }

    this.message = message;

    if (!(this.ast && this.ast.type === 'messageFormatPattern')) {
      throw new TypeError('A message must be provided as a String or AST.');
    }

    // Creates a new object with the specified `formats` merged with the default
    // formats.
    const formats = mergeConfigs(IntlMessageFormat.formats, overrideFormats);

    // Defined first because it's used to build the format pattern.
    this.locale = resolveLocale(locales || []);

    let formatters = (opts && opts.formatters) || createDefaultFormatters();

    // Compile the `ast` to a pattern that is highly optimized for repeated
    // `format()` invocations. **Note:** This passes the `locales` set provided
    // to the constructor instead of just the resolved locale.
    this.pattern = new Compiler(locales, formats, formatters).compile(this.ast);

    // "Bind" `format()` method to `this` so it can be passed by reference like
    // the other `Intl` APIs.
  }

  format = (
    values?: Record<string, string | number | boolean | null | undefined>
  ) => {
    try {
      return formatPatterns(this.pattern, values);
    } catch (e) {
      if (e.variableId) {
        throw new Error(
          `The intl string context variable '${e.variableId}' was not provided to the string '${this.message}'`
        );
      } else {
        throw e;
      }
    }
  };
  resolvedOptions() {
    return { locale: this.locale };
  }
  getAst() {
    return this.ast;
  }
  static defaultLocale = 'en';
  static __parse: typeof parser['parse'] | undefined = undefined;
  // Default format options used as the prototype of the `formats` provided to the
  // constructor. These are used when constructing the internal Intl.NumberFormat
  // and Intl.DateTimeFormat instances.
  static formats = {
    number: {
      currency: {
        style: 'currency'
      },

      percent: {
        style: 'percent'
      }
    },

    date: {
      short: {
        month: 'numeric',
        day: 'numeric',
        year: '2-digit'
      },

      medium: {
        month: 'short',
        day: 'numeric',
        year: 'numeric'
      },

      long: {
        month: 'long',
        day: 'numeric',
        year: 'numeric'
      },

      full: {
        weekday: 'long',
        month: 'long',
        day: 'numeric',
        year: 'numeric'
      }
    },

    time: {
      short: {
        hour: 'numeric',
        minute: 'numeric'
      },

      medium: {
        hour: 'numeric',
        minute: 'numeric',
        second: 'numeric'
      },

      long: {
        hour: 'numeric',
        minute: 'numeric',
        second: 'numeric',
        timeZoneName: 'short'
      },

      full: {
        hour: 'numeric',
        minute: 'numeric',
        second: 'numeric',
        timeZoneName: 'short'
      }
    }
  };
}

export { Formats, Pattern } from './compiler';
export default IntlMessageFormat;
