// 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.

// Regenerates a Dart file with a class containing IconData constants.
// See https://github.com/flutter/flutter/wiki/Updating-Material-Design-Fonts-&-Icons
// Should be idempotent with:
// dart dev/tools/update_icons.dart --new-codepoints bin/cache/artifacts/material_fonts/codepoints

import 'dart:collection';
import 'dart:convert' show LineSplitter;
import 'dart:io';

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

const String _iconsPathOption = 'icons';
const String _iconsTemplatePathOption = 'icons-template';
const String _newCodepointsPathOption = 'new-codepoints';
const String _oldCodepointsPathOption = 'old-codepoints';
const String _fontFamilyOption = 'font-family';
const String _possibleStyleSuffixesOption = 'style-suffixes';
const String _classNameOption = 'class-name';
const String _enforceSafetyChecks = 'enforce-safety-checks';
const String _dryRunOption = 'dry-run';

const String _defaultIconsPath = 'packages/flutter/lib/src/material/icons.dart';
const String _defaultNewCodepointsPath = 'codepoints';
const String _defaultOldCodepointsPath = 'bin/cache/artifacts/material_fonts/codepoints';
const String _defaultFontFamily = 'MaterialIcons';
const List<String> _defaultPossibleStyleSuffixes = <String>[
  '_outlined',
  '_rounded',
  '_sharp',
];
const String _defaultClassName = 'Icons';
const String _defaultDemoFilePath = '/tmp/new_icons_demo.dart';

const String _beginGeneratedMark = '// BEGIN GENERATED ICONS';
const String _endGeneratedMark = '// END GENERATED ICONS';
const String _beginPlatformAdaptiveGeneratedMark = '// BEGIN GENERATED PLATFORM ADAPTIVE ICONS';
const String _endPlatformAdaptiveGeneratedMark = '// END GENERATED PLATFORM ADAPTIVE ICONS';

const Map<String, List<String>> _platformAdaptiveIdentifiers = <String, List<String>>{
  // Mapping of Flutter IDs to an Android/agnostic ID and an iOS ID.
  // Flutter IDs can be anything, but should be chosen to be agnostic.
  'arrow_back': <String>['arrow_back', 'arrow_back_ios'],
  'arrow_forward': <String>['arrow_forward', 'arrow_forward_ios'],
  'flip_camera': <String>['flip_camera_android', 'flip_camera_ios'],
  'more': <String>['more_vert', 'more_horiz'],
  'share': <String>['share', 'ios_share'],
};

// Rewrite certain Flutter IDs (numbers) using prefix matching.
const Map<String, String> _identifierPrefixRewrites = <String, String>{
  '1': 'one_',
  '2': 'two_',
  '3': 'three_',
  '4': 'four_',
  '5': 'five_',
  '6': 'six_',
  '7': 'seven_',
  '8': 'eight_',
  '9': 'nine_',
  '10': 'ten_',
  '11': 'eleven_',
  '12': 'twelve_',
  '13': 'thirteen_',
  '14': 'fourteen_',
  '15': 'fifteen_',
  '16': 'sixteen_',
  '17': 'seventeen_',
  '18': 'eighteen_',
  '19': 'nineteen_',
  '20': 'twenty_',
  '21': 'twenty_one_',
  '22': 'twenty_two_',
  '23': 'twenty_three_',
  '24': 'twenty_four_',
  '30': 'thirty_',
  '60': 'sixty_',
  '123': 'onetwothree',
  '360': 'threesixty',
  '2d': 'twod',
  '3d': 'threed',
  '3d_rotation': 'threed_rotation',
};

// Rewrite certain Flutter IDs (reserved keywords) using exact matching.
const Map<String, String> _identifierExactRewrites = <String, String>{
  'class': 'class_',
  'new': 'new_',
  'switch': 'switch_',
  'try': 'try_sms_star',
  'door_back': 'door_back_door',
  'door_front': 'door_front_door',
};

const Set<String> _iconsMirroredWhenRTL = <String>{
  // This list is obtained from:
  // https://developers.google.com/fonts/docs/material_icons#which_icons_should_be_mirrored_for_rtl
  'arrow_back',
  'arrow_back_ios',
  'arrow_forward',
  'arrow_forward_ios',
  'arrow_left',
  'arrow_right',
  'assignment',
  'assignment_return',
  'backspace',
  'battery_unknown',
  'call_made',
  'call_merge',
  'call_missed',
  'call_missed_outgoing',
  'call_received',
  'call_split',
  'chevron_left',
  'chevron_right',
  'chrome_reader_mode',
  'device_unknown',
  'dvr',
  'event_note',
  'featured_play_list',
  'featured_video',
  'first_page',
  'flight_land',
  'flight_takeoff',
  'format_indent_decrease',
  'format_indent_increase',
  'format_list_bulleted',
  'forward',
  'functions',
  'help',
  'help_outline',
  'input',
  'keyboard_backspace',
  'keyboard_tab',
  'label',
  'label_important',
  'label_outline',
  'last_page',
  'launch',
  'list',
  'live_help',
  'mobile_screen_share',
  'multiline_chart',
  'navigate_before',
  'navigate_next',
  'next_week',
  'note',
  'open_in',
  'playlist_add',
  'queue_music',
  'redo',
  'reply',
  'reply_all',
  'screen_share',
  'send',
  'short_text',
  'show_chart',
  'sort',
  'star_half',
  'subject',
  'trending_flat',
  'toc',
  'trending_down',
  'trending_up',
  'undo',
  'view_list',
  'view_quilt',
  'wrap_text',
};

void main(List<String> args) {
  // If we're run from the `tools` dir, set the cwd to the repo root.
  if (path.basename(Directory.current.path) == 'tools') {
    Directory.current = Directory.current.parent.parent;
  }

  final ArgResults argResults = _handleArguments(args);

  final File iconsFile = File(path.normalize(path.absolute(argResults[_iconsPathOption] as String)));
  if (!iconsFile.existsSync()) {
    stderr.writeln('Error: Icons file not found: ${iconsFile.path}');
    exit(1);
  }
  final File iconsTemplateFile = File(path.normalize(path.absolute(argResults[_iconsTemplatePathOption] as String)));
  if (!iconsTemplateFile.existsSync()) {
    stderr.writeln('Error: Icons template file not found: ${iconsTemplateFile.path}');
    exit(1);
  }
  final File newCodepointsFile = File(argResults[_newCodepointsPathOption] as String);
  if (!newCodepointsFile.existsSync()) {
    stderr.writeln('Error: New codepoints file not found: ${newCodepointsFile.path}');
    exit(1);
  }
  final File oldCodepointsFile = File(argResults[_oldCodepointsPathOption] as String);
  if (!oldCodepointsFile.existsSync()) {
    stderr.writeln('Error: Old codepoints file not found: ${oldCodepointsFile.path}');
    exit(1);
  }

  final String newCodepointsString = newCodepointsFile.readAsStringSync();
  final Map<String, String> newTokenPairMap = stringToTokenPairMap(newCodepointsString);

  final String oldCodepointsString = oldCodepointsFile.readAsStringSync();
  final Map<String, String> oldTokenPairMap = stringToTokenPairMap(oldCodepointsString);

  stderr.writeln('Performing safety checks');
  final bool isSuperset = testIsSuperset(newTokenPairMap, oldTokenPairMap);
  final bool isStable = testIsStable(newTokenPairMap, oldTokenPairMap);
  if ((!isSuperset || !isStable) && argResults[_enforceSafetyChecks] as bool) {
    exit(1);
  }
  final String iconsTemplateContents = iconsTemplateFile.readAsStringSync();

  stderr.writeln("Generating icons ${argResults[_dryRunOption] as bool ? '' : 'to ${iconsFile.path}'}");
  final String newIconsContents = _regenerateIconsFile(
    iconsTemplateContents,
    newTokenPairMap,
    argResults[_fontFamilyOption] as String,
    argResults[_classNameOption] as String,
    argResults[_enforceSafetyChecks] as bool,
  );

  if (argResults[_dryRunOption] as bool) {
    stdout.write(newIconsContents);
  } else {
    iconsFile.writeAsStringSync(newIconsContents);

    final SplayTreeMap<String, String> sortedNewTokenPairMap = SplayTreeMap<String, String>.of(newTokenPairMap);
    _regenerateCodepointsFile(oldCodepointsFile, sortedNewTokenPairMap);

    sortedNewTokenPairMap.removeWhere((String key, String value) => oldTokenPairMap.containsKey(key));
    _generateIconDemo(File(_defaultDemoFilePath), sortedNewTokenPairMap);
  }
}

ArgResults _handleArguments(List<String> args) {
  final ArgParser argParser = ArgParser()
    ..addOption(_iconsPathOption,
        defaultsTo: _defaultIconsPath,
        help: 'Location of the material icons file')
    ..addOption(_iconsTemplatePathOption,
        defaultsTo: _defaultIconsPath,
        help:
            'Location of the material icons file template. Usually the same as --$_iconsPathOption')
    ..addOption(_newCodepointsPathOption,
        defaultsTo: _defaultNewCodepointsPath,
        help: 'Location of the new codepoints directory')
    ..addOption(_oldCodepointsPathOption,
        defaultsTo: _defaultOldCodepointsPath,
        help: 'Location of the existing codepoints directory')
    ..addOption(_fontFamilyOption,
        defaultsTo: _defaultFontFamily,
        help: 'The font family to use for the IconData constants')
    ..addMultiOption(_possibleStyleSuffixesOption,
        defaultsTo: _defaultPossibleStyleSuffixes,
        help: 'A comma-separated list of suffixes (typically an optional '
              'family + a style) e.g. _outlined, _monoline_filled')
    ..addOption(_classNameOption,
        defaultsTo: _defaultClassName,
        help: 'The containing class for all icons')
    ..addFlag(_enforceSafetyChecks,
        defaultsTo: true,
        help: 'Whether to exit if safety checks fail (e.g. codepoints are missing or unstable')
    ..addFlag(_dryRunOption);
  argParser.addFlag('help', abbr: 'h', negatable: false, callback: (bool help) {
    if (help) {
      print(argParser.usage);
      exit(1);
    }
  });
  return argParser.parse(args);
}

Map<String, String> stringToTokenPairMap(String codepointData) {
  final Iterable<String> cleanData = LineSplitter.split(codepointData)
      .map((String line) => line.trim())
      .where((String line) => line.isNotEmpty);

  final Map<String, String> pairs = <String, String>{};

  for (final String line in cleanData) {
    final List<String> tokens = line.split(' ');
    if (tokens.length != 2) {
      throw FormatException('Unexpected codepoint data: $line');
    }
    pairs.putIfAbsent(tokens[0], () => tokens[1]);
  }

  return pairs;
}

String _regenerateIconsFile(
    String templateFileContents,
    Map<String, String> tokenPairMap,
    String fontFamily,
    String className,
    bool enforceSafetyChecks,
  ) {
  final List<Icon> newIcons = tokenPairMap.entries
      .map((MapEntry<String, String> entry) =>
        Icon(entry, fontFamily: fontFamily, className: className))
      .toList();
  newIcons.sort((Icon a, Icon b) => a._compareTo(b));

  final StringBuffer buf = StringBuffer();
  bool generating = false;

  for (final String line in LineSplitter.split(templateFileContents)) {
    if (!generating) {
      buf.writeln(line);
    }

    // Generate for PlatformAdaptiveIcons
    if (line.contains(_beginPlatformAdaptiveGeneratedMark)) {
      generating = true;
      final List<String> platformAdaptiveDeclarations = <String>[];
      _platformAdaptiveIdentifiers.forEach((String flutterId, List<String> ids) {
        // Automatically finds and generates all icon declarations.
        for (final String style in <String>['', '_outlined', '_rounded', '_sharp']) {
          try {
            final Icon agnosticIcon = newIcons.firstWhere(
                (Icon icon) => icon.id == '${ids[0]}$style',
                orElse: () => throw ids[0]);
            final Icon iOSIcon = newIcons.firstWhere(
                (Icon icon) => icon.id == '${ids[1]}$style',
                orElse: () => throw ids[1]);
            platformAdaptiveDeclarations.add(
              agnosticIcon.platformAdaptiveDeclaration('$flutterId$style', iOSIcon),
            );
          } catch (e) {
            if (style == '') {
              // Throw an error for baseline icons.
              stderr.writeln("❌ Platform adaptive icon '$e' not found.");
              if (enforceSafetyChecks) {
                stderr.writeln('Safety checks failed');
                exit(1);
              }
            } else {
              // Ignore errors for styled icons since some don't exist.
            }
          }
        }
      });
      buf.write(platformAdaptiveDeclarations.join());
    } else if (line.contains(_endPlatformAdaptiveGeneratedMark)) {
      generating = false;
      buf.writeln(line);
    }

    // Generate for Icons
    if (line.contains(_beginGeneratedMark)) {
      generating = true;
      final String iconDeclarationsString = newIcons.map((Icon icon) => icon.fullDeclaration).join();
      buf.write(iconDeclarationsString);
    } else if (line.contains(_endGeneratedMark)) {
      generating = false;
      buf.writeln(line);
    }
  }
  return buf.toString();
}

@visibleForTesting
bool testIsSuperset(Map<String, String> newCodepoints, Map<String, String> oldCodepoints) {
  final Set<String> newCodepointsSet = newCodepoints.keys.toSet();
  final Set<String> oldCodepointsSet = oldCodepoints.keys.toSet();

  final int diff = newCodepointsSet.length - oldCodepointsSet.length;
  if (diff > 0) {
    stderr.writeln('🆕 $diff new codepoints: ${newCodepointsSet.difference(oldCodepointsSet)}');
  }
  if (!newCodepointsSet.containsAll(oldCodepointsSet)) {
    stderr.writeln(
        '❌ new codepoints file does not contain all ${oldCodepointsSet.length} '
        'existing codepoints. Missing: ${oldCodepointsSet.difference(newCodepointsSet)}');
    return false;
  } else {
    stderr.writeln('✅ new codepoints file contains all ${oldCodepointsSet.length} existing codepoints');
  }
  return true;
}

@visibleForTesting
bool testIsStable(Map<String, String> newCodepoints, Map<String, String> oldCodepoints) {
  final int oldCodepointsCount = oldCodepoints.length;
  final List<String> unstable = <String>[];

  oldCodepoints.forEach((String key, String value) {
    if (newCodepoints.containsKey(key)) {
      if (value != newCodepoints[key]) {
        unstable.add(key);
      }
    }
  });

  if (unstable.isNotEmpty) {
    stderr.writeln('❌ out of $oldCodepointsCount existing codepoints, ${unstable.length} were unstable: $unstable');
    return false;
  } else {
    stderr.writeln('✅ all existing $oldCodepointsCount codepoints are stable');
    return true;
  }
}

void _regenerateCodepointsFile(File oldCodepointsFile, Map<String, String> tokenPairMap) {
  stderr.writeln('Regenerating old codepoints file ${oldCodepointsFile.path}');

  final StringBuffer buf = StringBuffer();
  tokenPairMap.forEach((String key, String value) => buf.writeln('$key $value'));
  oldCodepointsFile.writeAsStringSync(buf.toString());
}

void _generateIconDemo(File demoFilePath, Map<String, String> tokenPairMap) {
  if (tokenPairMap.isEmpty) {
    stderr.writeln('No new icons, skipping generating icon demo');
    return;
  }
  stderr.writeln('Generating icon demo at $_defaultDemoFilePath');

  final StringBuffer newIconUsages = StringBuffer();
  for (final MapEntry<String, String> entry in tokenPairMap.entries) {
    newIconUsages.writeln(Icon(entry).usage);
  }
  final String demoFileContents = '''
    import 'package:flutter/material.dart';

    void main() => runApp(const IconDemo());

    class IconDemo extends StatelessWidget {
      const IconDemo({ Key? key }) : super(key: key);

      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          debugShowCheckedModeBanner: false,
          home: Scaffold(
            body: Wrap(
              children: const [
                $newIconUsages
              ],
            ),
          ),
        );
      }
    }
    ''';
  demoFilePath.writeAsStringSync(demoFileContents);
}

class Icon {
  // Parse tokenPair (e.g. {"6_ft_apart_outlined": "e004"}).
  Icon(MapEntry<String, String> tokenPair, {
    this.fontFamily = _defaultFontFamily,
    this.possibleStyleSuffixes = _defaultPossibleStyleSuffixes,
    this.className = _defaultClassName,
  }) {
    id = tokenPair.key;
    hexCodepoint = tokenPair.value;

    // Determine family and HTML class suffix for Dartdoc.
    if (id.endsWith('_gm_outlined')) {
      dartdocFamily = 'GM';
      dartdocHtmlSuffix = '-outlined';
    } else if (id.endsWith('_gm_filled')) {
      dartdocFamily = 'GM';
      dartdocHtmlSuffix = '-filled';
    } else if (id.endsWith('_monoline_outlined')) {
      dartdocFamily = 'Monoline';
      dartdocHtmlSuffix = '-outlined';
    } else if (id.endsWith('_monoline_filled')) {
      dartdocFamily = 'Monoline';
      dartdocHtmlSuffix = '-filled';
    } else {
      dartdocFamily = 'material';
      if (id.endsWith('_baseline')) {
        id = _removeLast(id, '_baseline');
        dartdocHtmlSuffix = '';
      } else if (id.endsWith('_outlined')) {
        dartdocHtmlSuffix = '-outlined';
      } else if (id.endsWith('_rounded')) {
        dartdocHtmlSuffix = '-round';
      } else if (id.endsWith('_sharp')) {
        dartdocHtmlSuffix = '-sharp';
      }
    }

    _generateShortId();
    _generateFlutterId();
  }


  late String id; // e.g. 5g, 5g_outlined, 5g_rounded, 5g_sharp
  late String shortId; // e.g. 5g
  late String flutterId; // e.g. five_g, five_g_outlined, five_g_rounded, five_g_sharp
  late String hexCodepoint; // e.g. e547
  late String dartdocFamily; // e.g. material
  late String dartdocHtmlSuffix = ''; // The suffix for the 'material-icons' HTML class.
  String fontFamily; // The IconData font family.
  List<String> possibleStyleSuffixes; // A list of possible suffixes e.g. _outlined, _monoline_filled.
  String className; // The containing class.

  String get name => shortId.replaceAll('_', ' ').trim();

  String get style => dartdocHtmlSuffix == '' ? '' : ' (${dartdocHtmlSuffix.replaceFirst('-', '')})';

  String get dartDoc =>
      '<i class="material-icons$dartdocHtmlSuffix md-36">$shortId</i> &#x2014; $dartdocFamily icon named "$name"$style';

  String get usage => 'Icon($className.$flutterId),';

  bool get isMirroredInRTL {
    // Remove common suffixes (e.g. "_new" or "_alt") from the shortId.
    final String normalizedShortId = shortId.replaceAll(RegExp(r'_(new|alt|off|on)$'), '');
    return _iconsMirroredWhenRTL.any((String shortIdMirroredWhenRTL) => normalizedShortId == shortIdMirroredWhenRTL);
  }

  String get declaration =>
      "static const IconData $flutterId = IconData(0x$hexCodepoint, fontFamily: '$fontFamily'${isMirroredInRTL ? ', matchTextDirection: true' : ''});";

  String get fullDeclaration => '''

  /// $dartDoc.
  $declaration
''';

  String platformAdaptiveDeclaration(String fullFlutterId, Icon iOSIcon) => '''

  /// Platform-adaptive icon for $dartDoc and ${iOSIcon.dartDoc}.;
  IconData get $fullFlutterId => !_isCupertino() ? $className.$flutterId : $className.${iOSIcon.flutterId};
''';

  @override
  String toString() => id;

  /// Analogous to [String.compareTo]
  int _compareTo(Icon b) {
    if (shortId == b.shortId) {
      // Sort a regular icon before its variants.
      return id.length - b.id.length;
    }
    return shortId.compareTo(b.shortId);
  }

  static String _removeLast(String string, String toReplace) {
    return string.replaceAll(RegExp('$toReplace\$'), '');
  }

  /// See [shortId].
  void _generateShortId() {
    shortId = id;
    for (final String styleSuffix in possibleStyleSuffixes) {
      shortId = _removeLast(shortId, styleSuffix);
      if (shortId != id) {
        break;
      }
    }
  }

  /// See [flutterId].
  void _generateFlutterId() {
    flutterId = id;
    // Exact identifier rewrites.
    for (final MapEntry<String, String> rewritePair in _identifierExactRewrites.entries) {
      if (shortId == rewritePair.key) {
        flutterId = id.replaceFirst(
          rewritePair.key,
          _identifierExactRewrites[rewritePair.key]!,
        );
      }
    }
    // Prefix identifier rewrites.
    for (final MapEntry<String, String> rewritePair in _identifierPrefixRewrites.entries) {
      if (id.startsWith(rewritePair.key)) {
        flutterId = id.replaceFirst(
          rewritePair.key,
          _identifierPrefixRewrites[rewritePair.key]!,
        );
      }
    }

    // Prevent double underscores.
    flutterId = flutterId.replaceAll('__', '_');
  }
}
