// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'dart:convert';
import 'dart:io';

import 'package:file/file.dart' as file;
import 'package:meta/meta.dart';
import 'package:path/path.dart' as path;

import 'gen_l10n_templates.dart';
import 'gen_l10n_types.dart';
import 'localizations_utils.dart';

List<String> generateMethodParameters(Message message) {
  assert(message.placeholders.isNotEmpty);
  final Placeholder countPlaceholder =
      message.isPlural ? message.getCountPlaceholder() : null;
  return message.placeholders.map((Placeholder placeholder) {
    final String type =
        placeholder == countPlaceholder ? 'int' : placeholder.type;
    return '$type ${placeholder.name}';
  }).toList();
}

String generateDateFormattingLogic(Message message) {
  if (message.placeholders.isEmpty || !message.placeholdersRequireFormatting) {
    return '@(none)';
  }

  final Iterable<String> formatStatements = message.placeholders
      .where((Placeholder placeholder) => placeholder.isDate)
      .map((Placeholder placeholder) {
    if (placeholder.format == null) {
      throw L10nException(
          'The placeholder, ${placeholder.name}, has its "type" resource attribute set to '
          'the "${placeholder.type}" type. To properly resolve for the right '
          '${placeholder.type} format, the "format" attribute needs to be set '
          'to determine which DateFormat to use. \n'
          'Check the intl library\'s DateFormat class constructors for allowed '
          'date formats.');
    }
    if (!placeholder.hasValidDateFormat) {
      throw L10nException('Date format "${placeholder.format}" for placeholder '
          '${placeholder.name} does not have a corresponding DateFormat '
          'constructor\n. Check the intl library\'s DateFormat class '
          'constructors for allowed date formats.');
    }
    return dateFormatTemplate
        .replaceAll('@(placeholder)', placeholder.name)
        .replaceAll('@(format)', placeholder.format);
  });

  return formatStatements.isEmpty ? '@(none)' : formatStatements.join('');
}

String generateNumberFormattingLogic(Message message) {
  if (message.placeholders.isEmpty || !message.placeholdersRequireFormatting) {
    return '@(none)';
  }

  final Iterable<String> formatStatements = message.placeholders
      .where((Placeholder placeholder) => placeholder.isNumber)
      .map((Placeholder placeholder) {
    if (!placeholder.hasValidNumberFormat) {
      throw L10nException(
          'Number format ${placeholder.format} for the ${placeholder.name} '
          'placeholder does not have a corresponding NumberFormat constructor.\n'
          'Check the intl library\'s NumberFormat class constructors for allowed '
          'number formats.');
    }
    final Iterable<String> parameters =
        placeholder.optionalParameters.map<String>(
      (OptionalParameter parameter) {
        return '${parameter.name}: ${parameter.value}';
      },
    );
    return numberFormatTemplate
        .replaceAll('@(placeholder)', placeholder.name)
        .replaceAll('@(format)', placeholder.format)
        .replaceAll('@(parameters)', parameters.join(',    \n'));
  });

  return formatStatements.isEmpty ? '@(none)' : formatStatements.join('');
}

String generatePluralMethod(Message message, AppResourceBundle bundle) {
  if (message.placeholders.isEmpty) {
    throw L10nException(
        'Unable to find placeholders for the plural message: ${message.resourceId}.\n'
        'Check to see if the plural message is in the proper ICU syntax format '
        'and ensure that placeholders are properly specified.');
  }

  // To make it easier to parse the plurals message, temporarily replace each
  // "{placeholder}" parameter with "#placeholder#".
  String easyMessage = bundle.translationFor(message);
  for (final Placeholder placeholder in message.placeholders) {
    easyMessage = easyMessage.replaceAll(
        '{${placeholder.name}}', '#${placeholder.name}#');
  }

  final Placeholder countPlaceholder = message.getCountPlaceholder();
  if (countPlaceholder == null) {
    throw L10nException(
        'Unable to find the count placeholder for the plural message: ${message.resourceId}.\n'
        'Check to see if the plural message is in the proper ICU syntax format '
        'and ensure that placeholders are properly specified.');
  }

  const Map<String, String> pluralIds = <String, String>{
    '=0': 'zero',
    '=1': 'one',
    '=2': 'two',
    'few': 'few',
    'many': 'many',
    'other': 'other'
  };

  final List<String> pluralLogicArgs = <String>[];
  for (final String pluralKey in pluralIds.keys) {
    final RegExp expRE = RegExp('($pluralKey)\\s*{([^}]+)}');
    final RegExpMatch match = expRE.firstMatch(easyMessage);
    if (match != null && match.groupCount == 2) {
      String argValue = generateString(match.group(2));
      for (final Placeholder placeholder in message.placeholders) {
        if (placeholder != countPlaceholder && placeholder.requiresFormatting) {
          argValue = argValue.replaceAll(
              '#${placeholder.name}#', '\${${placeholder.name}String}');
        } else {
          argValue = argValue.replaceAll(
              '#${placeholder.name}#', '\${${placeholder.name}}');
        }
      }
      pluralLogicArgs.add('      ${pluralIds[pluralKey]}: $argValue');
    }
  }

  final List<String> parameters =
      message.placeholders.map((Placeholder placeholder) {
    final String placeholderType =
        placeholder == countPlaceholder ? 'int' : placeholder.type;
    return '$placeholderType ${placeholder.name}';
  }).toList();

  final String comment = message.description ??
      'No description provided in @${message.resourceId}';

  return pluralMethodTemplate
      .replaceAll('@(comment)', comment)
      .replaceAll('@(name)', message.resourceId)
      .replaceAll('@(parameters)', parameters.join(', '))
      .replaceAll('@(dateFormatting)', generateDateFormattingLogic(message))
      .replaceAll('@(numberFormatting)', generateNumberFormattingLogic(message))
      .replaceAll('@(count)', countPlaceholder.name)
      .replaceAll('@(pluralLogicArgs)', pluralLogicArgs.join(',\n'))
      .replaceAll('@(none)\n', '');
}

String generateMethod(Message message, AppResourceBundle bundle) {
  String generateMessage() {
    String messageValue = generateString(bundle.translationFor(message));
    for (final Placeholder placeholder in message.placeholders) {
      if (placeholder.requiresFormatting) {
        messageValue = messageValue.replaceAll(
            '{${placeholder.name}}', '\${${placeholder.name}String}');
      } else {
        messageValue = messageValue.replaceAll(
            '{${placeholder.name}}', '\${${placeholder.name}}');
      }
    }

    return messageValue;
  }

  if (message.isPlural) {
    return generatePluralMethod(message, bundle);
  }

  if (message.placeholdersRequireFormatting) {
    return formatMethodTemplate
        .replaceAll('@(name)', message.resourceId)
        .replaceAll(
            '@(parameters)', generateMethodParameters(message).join(', '))
        .replaceAll('@(dateFormatting)', generateDateFormattingLogic(message))
        .replaceAll(
            '@(numberFormatting)', generateNumberFormattingLogic(message))
        .replaceAll('@(message)', generateMessage())
        .replaceAll('@(none)\n', '');
  }

  if (message.placeholders.isNotEmpty) {
    return methodTemplate
        .replaceAll('@(name)', message.resourceId)
        .replaceAll(
            '@(parameters)', generateMethodParameters(message).join(', '))
        .replaceAll('@(message)', generateMessage());
  }

  return getterTemplate
      .replaceAll('@(name)', message.resourceId)
      .replaceAll('@(message)', generateMessage());
}

String generateBaseClassMethod(Message message) {
  final String comment = message.description ??
      'No description provided in @${message.resourceId}';
  if (message.placeholders.isNotEmpty) {
    return baseClassMethodTemplate
        .replaceAll('@(comment)', comment)
        .replaceAll('@(name)', message.resourceId)
        .replaceAll(
            '@(parameters)', generateMethodParameters(message).join(', '));
  }
  return baseClassGetterTemplate
      .replaceAll('@(comment)', comment)
      .replaceAll('@(name)', message.resourceId);
}

String _generateLookupByAllCodes(
  AppResourceBundleCollection allBundles,
  String Function(LocaleInfo) generateSwitchClauseTemplate,
) {
  final Iterable<LocaleInfo> localesWithAllCodes =
      allBundles.locales.where((LocaleInfo locale) {
    return locale.scriptCode != null && locale.countryCode != null;
  });

  if (localesWithAllCodes.isEmpty) {
    return '';
  }

  final Iterable<String> switchClauses =
      localesWithAllCodes.map<String>((LocaleInfo locale) {
    return generateSwitchClauseTemplate(locale)
        .replaceAll('@(case)', locale.toString());
  });

  return allCodesLookupTemplate.replaceAll(
    '@(allCodesSwitchClauses)',
    switchClauses.join('\n    '),
  );
}

String _generateLookupByScriptCode(
  AppResourceBundleCollection allBundles,
  String Function(LocaleInfo) generateSwitchClauseTemplate,
) {
  final Iterable<String> switchClauses =
      allBundles.languages.map((String language) {
    final Iterable<LocaleInfo> locales =
        allBundles.localesForLanguage(language);
    final Iterable<LocaleInfo> localesWithScriptCodes =
        locales.where((LocaleInfo locale) {
      return locale.scriptCode != null && locale.countryCode == null;
    });

    if (localesWithScriptCodes.isEmpty) return null;

    return nestedSwitchTemplate
        .replaceAll('@(languageCode)', language)
        .replaceAll('@(code)', 'scriptCode')
        .replaceAll(
            '@(switchClauses)',
            localesWithScriptCodes.map((LocaleInfo locale) {
              return generateSwitchClauseTemplate(locale)
                  .replaceAll('@(case)', locale.scriptCode);
            }).join('\n        '));
  }).where((String switchClause) => switchClause != null);

  if (switchClauses.isEmpty) {
    return '';
  }

  return languageCodeSwitchTemplate
      .replaceAll('@(comment)',
          '// Lookup logic when language+script codes are specified.')
      .replaceAll(
        '@(switchClauses)',
        switchClauses.join('\n    '),
      );
}

String _generateLookupByCountryCode(
  AppResourceBundleCollection allBundles,
  String Function(LocaleInfo) generateSwitchClauseTemplate,
) {
  final Iterable<String> switchClauses =
      allBundles.languages.map((String language) {
    final Iterable<LocaleInfo> locales =
        allBundles.localesForLanguage(language);
    final Iterable<LocaleInfo> localesWithCountryCodes =
        locales.where((LocaleInfo locale) {
      return locale.countryCode != null && locale.scriptCode == null;
    });

    if (localesWithCountryCodes.isEmpty) return null;

    return nestedSwitchTemplate
        .replaceAll('@(languageCode)', language)
        .replaceAll('@(code)', 'countryCode')
        .replaceAll(
            '@(switchClauses)',
            localesWithCountryCodes.map((LocaleInfo locale) {
              return generateSwitchClauseTemplate(locale)
                  .replaceAll('@(case)', locale.countryCode);
            }).join('\n        '));
  }).where((String switchClause) => switchClause != null);

  if (switchClauses.isEmpty) {
    return '';
  }

  return languageCodeSwitchTemplate
      .replaceAll('@(comment)',
          '// Lookup logic when language+country codes are specified.')
      .replaceAll('@(switchClauses)', switchClauses.join('\n    '));
}

String _generateLookupByLanguageCode(
  AppResourceBundleCollection allBundles,
  String Function(LocaleInfo) generateSwitchClauseTemplate,
) {
  final Iterable<String> switchClauses =
      allBundles.languages.map((String language) {
    final Iterable<LocaleInfo> locales =
        allBundles.localesForLanguage(language);
    final Iterable<LocaleInfo> localesWithLanguageCode =
        locales.where((LocaleInfo locale) {
      return locale.countryCode == null && locale.scriptCode == null;
    });

    if (localesWithLanguageCode.isEmpty) return null;

    return localesWithLanguageCode.map((LocaleInfo locale) {
      return generateSwitchClauseTemplate(locale)
          .replaceAll('@(case)', locale.languageCode);
    }).join('\n        ');
  }).where((String switchClause) => switchClause != null);

  if (switchClauses.isEmpty) {
    return '';
  }

  return languageCodeSwitchTemplate
      .replaceAll(
          '@(comment)', '// Lookup logic when only language code is specified.')
      .replaceAll('@(switchClauses)', switchClauses.join('\n    '));
}

String _generateLookupBody(
  AppResourceBundleCollection allBundles,
  String className,
  bool useDeferredLoading,
  String fileName,
) {
  final String Function(LocaleInfo) generateSwitchClauseTemplate =
      (LocaleInfo locale) {
    return (useDeferredLoading
            ? switchClauseDeferredLoadingTemplate
            : switchClauseTemplate)
        .replaceAll('@(localeClass)', '$className${locale.camelCase()}')
        .replaceAll('@(appClass)', className)
        .replaceAll('@(library)', '${fileName}_${locale.languageCode}');
  };
  return lookupBodyTemplate
      .replaceAll(
          '@(lookupAllCodesSpecified)',
          _generateLookupByAllCodes(
            allBundles,
            generateSwitchClauseTemplate,
          ))
      .replaceAll(
          '@(lookupScriptCodeSpecified)',
          _generateLookupByScriptCode(
            allBundles,
            generateSwitchClauseTemplate,
          ))
      .replaceAll(
          '@(lookupCountryCodeSpecified)',
          _generateLookupByCountryCode(
            allBundles,
            generateSwitchClauseTemplate,
          ))
      .replaceAll(
          '@(lookupLanguageCodeSpecified)',
          _generateLookupByLanguageCode(
            allBundles,
            generateSwitchClauseTemplate,
          ));
}

String _generateDelegateClass({
  AppResourceBundleCollection allBundles,
  String className,
  Set<String> supportedLanguageCodes,
  bool useDeferredLoading,
  String fileName,
}) {
  final String lookupBody = _generateLookupBody(
    allBundles,
    className,
    useDeferredLoading,
    fileName,
  );
  final String loadBody =
      (useDeferredLoading ? loadBodyDeferredLoadingTemplate : loadBodyTemplate)
          .replaceAll('@(class)', className)
          .replaceAll('@(lookupName)', '_lookup$className');
  final String lookupFunction = (useDeferredLoading
          ? lookupFunctionDeferredLoadingTemplate
          : lookupFunctionTemplate)
      .replaceAll('@(class)', className)
      .replaceAll('@(lookupName)', '_lookup$className')
      .replaceAll('@(lookupBody)', lookupBody);
  return delegateClassTemplate
      .replaceAll('@(class)', className)
      .replaceAll('@(loadBody)', loadBody)
      .replaceAll(
          '@(supportedLanguageCodes)', supportedLanguageCodes.join(', '))
      .replaceAll('@(lookupFunction)', lookupFunction);
}

class LocalizationsGenerator {
  /// Creates an instance of the localizations generator class.
  ///
  /// It takes in a [FileSystem] representation that the class will act upon.
  LocalizationsGenerator(this._fs);

  final file.FileSystem _fs;
  Iterable<Message> _allMessages;
  AppResourceBundleCollection _allBundles;
  LocaleInfo _templateArbLocale;

  /// The reference to the project's l10n directory.
  ///
  /// It is assumed that all input files (e.g. [templateArbFile], arb files
  /// for translated messages) and output files (e.g. The localizations
  /// [outputFile], `messages_<locale>.dart` and `messages_all.dart`)
  /// will reside here.
  ///
  /// This directory is specified with the [initialize] method.
  Directory l10nDirectory;

  /// The input arb file which defines all of the messages that will be
  /// exported by the generated class that's written to [outputFile].
  ///
  /// This file is specified with the [initialize] method.
  File templateArbFile;

  /// The file to write the generated localizations and localizations delegate
  /// classes to.
  ///
  /// This file is specified with the [initialize] method.
  File outputFile;

  /// The class name to be used for the localizations class in [outputFile].
  ///
  /// For example, if 'AppLocalizations' is passed in, a class named
  /// AppLocalizations will be used for localized message lookups.
  ///
  /// The class name is specified with the [initialize] method.
  String get className => _className;
  String _className;

  /// The list of preferred supported locales.
  ///
  /// By default, the list of supported locales in the localizations class
  /// will be sorted in alphabetical order. However, this option
  /// allows for a set of preferred locales to appear at the top of the
  /// list.
  ///
  /// The order of locales in this list will also be the order of locale
  /// priority. For example, if a device supports 'en' and 'es' and
  /// ['es', 'en'] is passed in, the 'es' locale will take priority over 'en'.
  ///
  /// The list of preferred locales is specified with the [initialize] method.
  List<LocaleInfo> get preferredSupportedLocales => _preferredSupportedLocales;
  List<LocaleInfo> _preferredSupportedLocales;

  /// The list of all arb path strings in [l10nDirectory].
  List<String> get arbPathStrings {
    return _allBundles.bundles
        .map((AppResourceBundle bundle) => bundle.file.path)
        .toList();
  }

  /// The supported language codes as found in the arb files located in
  /// [l10nDirectory].
  final Set<String> supportedLanguageCodes = <String>{};

  /// The supported locales as found in the arb files located in
  /// [l10nDirectory].
  final Set<LocaleInfo> supportedLocales = <LocaleInfo>{};

  /// The header to be prepended to the generated Dart localization file.
  String header = '';

  final Map<LocaleInfo, List<String>> _unimplementedMessages =
      <LocaleInfo, List<String>>{};

  /// Whether to generate the Dart localization file with locales imported as
  /// deferred, allowing for lazy loading of each locale in Flutter web.
  ///
  /// This can reduce a web app’s initial startup time by decreasing the size of
  /// the JavaScript bundle. When [_useDeferredLoading] is set to true, the
  /// messages for a particular locale are only downloaded and loaded by the
  /// Flutter app as they are needed. For projects with a lot of different
  /// locales and many localization strings, it can be an performance
  /// improvement to have deferred loading. For projects with a small number of
  /// locales, the difference is negligible, and might slow down the start up
  /// compared to bundling the localizations with the rest of the application.
  ///
  /// Note that this flag does not affect other platforms such as mobile or
  /// desktop.
  bool get useDeferredLoading => _useDeferredLoading;
  bool _useDeferredLoading;

  /// Initializes [l10nDirectory], [templateArbFile], [outputFile] and [className].
  ///
  /// Throws an [L10nException] when a provided configuration is not allowed
  /// by [LocalizationsGenerator].
  ///
  /// Throws a [FileSystemException] when a file operation necessary for setting
  /// up the [LocalizationsGenerator] cannot be completed.
  void initialize({
    String l10nDirectoryPath,
    String templateArbFileName,
    String outputFileString,
    String classNameString,
    String preferredSupportedLocaleString,
    String headerString,
    String headerFile,
    bool useDeferredLoading = false,
  }) {
    setL10nDirectory(l10nDirectoryPath);
    setTemplateArbFile(templateArbFileName);
    setOutputFile(outputFileString);
    setPreferredSupportedLocales(preferredSupportedLocaleString);
    _setHeader(headerString, headerFile);
    _setUseDeferredLoading(useDeferredLoading);
    className = classNameString;
  }

  static bool _isNotReadable(FileStat fileStat) {
    final String rawStatString = fileStat.modeString();
    // Removes potential prepended permission bits, such as '(suid)' and '(guid)'.
    final String statString = rawStatString.substring(rawStatString.length - 9);
    return !(statString[0] == 'r' ||
        statString[3] == 'r' ||
        statString[6] == 'r');
  }

  static bool _isNotWritable(FileStat fileStat) {
    final String rawStatString = fileStat.modeString();
    // Removes potential prepended permission bits, such as '(suid)' and '(guid)'.
    final String statString = rawStatString.substring(rawStatString.length - 9);
    return !(statString[1] == 'w' ||
        statString[4] == 'w' ||
        statString[7] == 'w');
  }

  /// Sets the reference [Directory] for [l10nDirectory].
  @visibleForTesting
  void setL10nDirectory(String arbPathString) {
    if (arbPathString == null) {
      throw L10nException('arbPathString argument cannot be null');
    }

    l10nDirectory = _fs.directory(arbPathString);
    if (!l10nDirectory.existsSync()) {
      throw FileSystemException(
          "The 'arb-dir' directory, $l10nDirectory, does not exist.\n"
          'Make sure that the correct path was provided.');
    }

    final FileStat fileStat = l10nDirectory.statSync();
    if (_isNotReadable(fileStat) || _isNotWritable(fileStat)) {
      throw FileSystemException(
          "The 'arb-dir' directory, $l10nDirectory, doesn't allow reading and writing.\n"
          'Please ensure that the user has read and write permissions.');
    }
  }

  /// Sets the reference [File] for [templateArbFile].
  @visibleForTesting
  void setTemplateArbFile(String templateArbFileName) {
    if (templateArbFileName == null) {
      throw L10nException('templateArbFileName argument cannot be null');
    }
    if (l10nDirectory == null) {
      throw L10nException(
          'l10nDirectory cannot be null when setting template arb file');
    }

    templateArbFile =
        _fs.file(path.join(l10nDirectory.path, templateArbFileName));
    final String templateArbFileStatModeString =
        templateArbFile.statSync().modeString();
    if (templateArbFileStatModeString[0] == '-' &&
        templateArbFileStatModeString[3] == '-') {
      throw FileSystemException(
          "The 'template-arb-file', $templateArbFile, is not readable.\n"
          'Please ensure that the user has read permissions.');
    }
  }

  /// Sets the reference [File] for the localizations delegate [outputFile].
  @visibleForTesting
  void setOutputFile(String outputFileString) {
    if (outputFileString == null) {
      throw L10nException('outputFileString argument cannot be null');
    }
    outputFile = _fs.file(path.join(l10nDirectory.path, outputFileString));
  }

  static bool _isValidClassName(String className) {
    // Public Dart class name cannot begin with an underscore
    if (className[0] == '_') return false;
    // Dart class name cannot contain non-alphanumeric symbols
    if (className.contains(RegExp(r'[^a-zA-Z_\d]'))) return false;
    // Dart class name must start with upper case character
    if (className[0].contains(RegExp(r'[a-z]'))) return false;
    // Dart class name cannot start with a number
    if (className[0].contains(RegExp(r'\d'))) return false;
    return true;
  }

  /// Sets the [className] for the localizations and localizations delegate
  /// classes.
  @visibleForTesting
  set className(String classNameString) {
    if (classNameString == null || classNameString.isEmpty) {
      throw L10nException('classNameString argument cannot be null or empty');
    }
    if (!_isValidClassName(classNameString)) {
      throw L10nException(
          "The 'output-class', $classNameString, is not a valid public Dart class name.\n");
    }
    _className = classNameString;
  }

  /// Sets [preferredSupportedLocales] so that this particular list of locales
  /// will take priority over the other locales.
  @visibleForTesting
  void setPreferredSupportedLocales(String inputLocales) {
    if (inputLocales == null || inputLocales.trim().isEmpty) {
      _preferredSupportedLocales = const <LocaleInfo>[];
    } else {
      final List<dynamic> preferredLocalesStringList =
          json.decode(inputLocales) as List<dynamic>;
      _preferredSupportedLocales =
          preferredLocalesStringList.map((dynamic localeString) {
        if (localeString.runtimeType != String) {
          throw L10nException('Incorrect runtime type for $localeString');
        }
        return LocaleInfo.fromString(
          localeString.toString(),
        );
      }).toList();
    }
  }

  void _setHeader(String headerString, String headerFile) {
    if (headerString != null && headerFile != null) {
      throw L10nException(
          'Cannot accept both header and header file arguments. \n'
          'Please make sure to define only one or the other. ');
    }

    if (headerString != null) {
      header = headerString;
    } else if (headerFile != null) {
      try {
        header = _fs
            .file(path.join(l10nDirectory.path, headerFile))
            .readAsStringSync();
      } on FileSystemException catch (error) {
        throw L10nException('Failed to read header file: "$headerFile". \n'
            'FileSystemException: ${error.message}');
      }
    }
  }

  void _setUseDeferredLoading(bool useDeferredLoading) {
    if (useDeferredLoading == null) {
      throw L10nException('useDeferredLoading argument cannot be null.');
    }
    _useDeferredLoading = useDeferredLoading;
  }

  static bool _isValidGetterAndMethodName(String name) {
    // Public Dart method name must not start with an underscore
    if (name[0] == '_') return false;
    // Dart getter and method name cannot contain non-alphanumeric symbols
    if (name.contains(RegExp(r'[^a-zA-Z_\d]'))) return false;
    // Dart method name must start with lower case character
    if (name[0].contains(RegExp(r'[A-Z]'))) return false;
    // Dart class name cannot start with a number
    if (name[0].contains(RegExp(r'\d'))) return false;
    return true;
  }

  // Load _allMessages from templateArbFile and _allBundles from all of the ARB
  // files in l10nDirectory. Also initialized: supportedLocales.
  void loadResources() {
    final AppResourceBundle templateBundle = AppResourceBundle(templateArbFile);
    _templateArbLocale = templateBundle.locale;
    _allMessages = templateBundle.resourceIds
        .map((String id) => Message(templateBundle.resources, id));
    for (final String resourceId in templateBundle.resourceIds) {
      if (!_isValidGetterAndMethodName(resourceId)) {
        throw L10nException(
            'Invalid ARB resource name "$resourceId" in $templateArbFile.\n'
            'Resources names must be valid Dart method names: they have to be '
            'camel case, cannot start with a number or underscore, and cannot '
            'contain non-alphanumeric characters.');
      }
    }

    _allBundles = AppResourceBundleCollection(l10nDirectory);

    final List<LocaleInfo> allLocales =
        List<LocaleInfo>.from(_allBundles.locales);
    for (final LocaleInfo preferredLocale in preferredSupportedLocales) {
      final int index = allLocales.indexOf(preferredLocale);
      if (index == -1) {
        throw L10nException(
            "The preferred supported locale, '$preferredLocale', cannot be "
            'added. Please make sure that there is a corresponding ARB file '
            'with translations for the locale, or remove the locale from the '
            'preferred supported locale list.');
      }
      allLocales.removeAt(index);
      allLocales.insertAll(0, preferredSupportedLocales);
    }
    supportedLocales.addAll(allLocales);
  }

  void _addUnimplementedMessage(LocaleInfo locale, String message) {
    if (_unimplementedMessages.containsKey(locale)) {
      _unimplementedMessages[locale].add(message);
    } else {
      _unimplementedMessages.putIfAbsent(locale, () => <String>[message]);
    }
  }

  String _generateBaseClassFile(
    String className,
    String fileName,
    String header,
    AppResourceBundle bundle,
    AppResourceBundle templateBundle,
    Iterable<Message> messages,
  ) {
    final LocaleInfo locale = bundle.locale;

    final Iterable<String> methods = messages.map((Message message) {
      if (bundle.translationFor(message) == null) {
        _addUnimplementedMessage(locale, message.resourceId);
      }

      return generateMethod(
        message,
        bundle.translationFor(message) == null ? templateBundle : bundle,
      );
    });

    return classFileTemplate
        .replaceAll('@(header)', header)
        .replaceAll('@(language)', describeLocale(locale.toString()))
        .replaceAll('@(baseClass)', className)
        .replaceAll('@(fileName)', fileName)
        .replaceAll('@(class)', '$className${locale.camelCase()}')
        .replaceAll('@(localeName)', locale.toString())
        .replaceAll('@(methods)', methods.join('\n\n'));
  }

  String _generateSubclass(
    String className,
    AppResourceBundle bundle,
    Iterable<Message> messages,
  ) {
    final LocaleInfo locale = bundle.locale;
    final String baseClassName =
        '$className${LocaleInfo.fromString(locale.languageCode).camelCase()}';

    messages
        .where((Message message) => bundle.translationFor(message) == null)
        .forEach((Message message) {
      _addUnimplementedMessage(locale, message.resourceId);
    });

    final Iterable<String> methods = messages
        .where((Message message) => bundle.translationFor(message) != null)
        .map((Message message) => generateMethod(message, bundle));

    return subclassTemplate
        .replaceAll('@(language)', describeLocale(locale.toString()))
        .replaceAll('@(baseLanguageClassName)', baseClassName)
        .replaceAll('@(class)', '$className${locale.camelCase()}')
        .replaceAll('@(localeName)', locale.toString())
        .replaceAll('@(methods)', methods.join('\n\n'));
  }

  // Generate the AppLocalizations class, its LocalizationsDelegate subclass,
  // and all AppLocalizations subclasses for every locale.
  String generateCode() {
    bool isBaseClassLocale(LocaleInfo locale, String language) {
      return locale.languageCode == language &&
          locale.countryCode == null &&
          locale.scriptCode == null;
    }

    List<LocaleInfo> getLocalesForLanguage(String language) {
      return _allBundles.bundles
          // Return locales for the language specified, except for the base locale itself
          .where((AppResourceBundle bundle) {
            final LocaleInfo locale = bundle.locale;
            return !isBaseClassLocale(locale, language) &&
                locale.languageCode == language;
          })
          .map((AppResourceBundle bundle) => bundle.locale)
          .toList();
    }

    final String directory = path.basename(l10nDirectory.path);
    final String outputFileName = path.basename(outputFile.path);

    final Iterable<String> supportedLocalesCode =
        supportedLocales.map((LocaleInfo locale) {
      final String languageCode = locale.languageCode;
      final String countryCode = locale.countryCode;
      final String scriptCode = locale.scriptCode;

      if (countryCode == null && scriptCode == null) {
        return 'Locale(\'$languageCode\')';
      } else if (countryCode != null && scriptCode == null) {
        return 'Locale(\'$languageCode\', \'$countryCode\')';
      } else if (countryCode != null && scriptCode != null) {
        return 'Locale.fromSubtags(languageCode: \'$languageCode\', countryCode: \'$countryCode\', scriptCode: \'$scriptCode\')';
      } else {
        return 'Locale.fromSubtags(languageCode: \'$languageCode\', scriptCode: \'$scriptCode\')';
      }
    });

    final Set<String> supportedLanguageCodes = Set<String>.from(_allBundles
        .locales
        .map<String>((LocaleInfo locale) => '\'${locale.languageCode}\''));

    final List<LocaleInfo> allLocales = _allBundles.locales.toList()..sort();
    final String fileName = outputFileName.split('.')[0];
    for (final LocaleInfo locale in allLocales) {
      if (isBaseClassLocale(locale, locale.languageCode)) {
        final File localeMessageFile = _fs.file(
          path.join(l10nDirectory.path, '${fileName}_$locale.dart'),
        );

        // Generate the template for the base class file. Further string
        // interpolation will be done to determine if there are
        // subclasses that extend the base class.
        final String languageBaseClassFile = _generateBaseClassFile(
          className,
          outputFileName,
          header,
          _allBundles.bundleFor(locale),
          _allBundles.bundleFor(_templateArbLocale),
          _allMessages,
        );

        // Every locale for the language except the base class.
        final List<LocaleInfo> localesForLanguage =
            getLocalesForLanguage(locale.languageCode);

        // Generate every subclass that is needed for the particular language
        final Iterable<String> subclasses =
            localesForLanguage.map<String>((LocaleInfo locale) {
          return _generateSubclass(
            className,
            _allBundles.bundleFor(locale),
            _allMessages,
          );
        });

        localeMessageFile.writeAsStringSync(
          languageBaseClassFile.replaceAll('@(subclasses)', subclasses.join()),
        );
      }
    }

    final List<String> sortedClassImports = supportedLocales
        .where((LocaleInfo locale) =>
            isBaseClassLocale(locale, locale.languageCode))
        .map((LocaleInfo locale) {
      final String library = '${fileName}_${locale.toString()}';
      if (useDeferredLoading) {
        return "import '$library.dart' deferred as $library;";
      } else {
        return "import '$library.dart';";
      }
    }).toList()
          ..sort();

    final String delegateClass = _generateDelegateClass(
      allBundles: _allBundles,
      className: className,
      supportedLanguageCodes: supportedLanguageCodes,
      useDeferredLoading: useDeferredLoading,
      fileName: fileName,
    );

    return fileTemplate
        .replaceAll('@(header)', header)
        .replaceAll('@(class)', className)
        .replaceAll(
            '@(methods)', _allMessages.map(generateBaseClassMethod).join('\n'))
        .replaceAll('@(importFile)', '$directory/$outputFileName')
        .replaceAll('@(supportedLocales)', supportedLocalesCode.join(',\n    '))
        .replaceAll(
            '@(supportedLanguageCodes)', supportedLanguageCodes.join(', '))
        .replaceAll('@(messageClassImports)', sortedClassImports.join('\n'))
        .replaceAll('@(delegateClass)', delegateClass);
  }

  void writeOutputFile() {
    outputFile.writeAsStringSync(generateCode());
  }

  void outputUnimplementedMessages(String untranslatedMessagesFile) {
    if (untranslatedMessagesFile == null || untranslatedMessagesFile == '') {
      _unimplementedMessages
          .forEach((LocaleInfo locale, List<String> messages) {
        stdout
            .writeln('"$locale": ${messages.length} untranslated message(s).');
      });
      stdout.writeln(
          'To see a detailed report, use the --untranslated-messages-file \n'
          'option in the tool to generate a JSON format file containing \n'
          'all messages that need to be translated.');
    } else {
      _writeUnimplementedMessagesFile(untranslatedMessagesFile);
    }
  }

  void _writeUnimplementedMessagesFile(String untranslatedMessagesFile) {
    if (_unimplementedMessages.isEmpty) {
      return;
    }

    final File unimplementedMessageTranslationsFile =
        _fs.file(untranslatedMessagesFile);

    String resultingFile = '{\n';
    int count = 0;
    final int numberOfLocales = _unimplementedMessages.length;
    _unimplementedMessages.forEach((LocaleInfo locale, List<String> messages) {
      resultingFile += '  "$locale": [\n';

      for (int i = 0; i < messages.length; i += 1) {
        resultingFile += '    "${messages[i]}"';
        if (i != messages.length - 1) {
          resultingFile += ',';
        }
        resultingFile += '\n';
      }

      resultingFile += '  ]';
      count += 1;
      if (count < numberOfLocales) {
        resultingFile += ',\n';
      }
      resultingFile += '\n';
    });

    resultingFile += '}\n';
    unimplementedMessageTranslationsFile.writeAsStringSync(resultingFile);
  }
}
