content: // Copyright 2013 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 'package:args/command_runner.dart';
  import 'package:file/file.dart';
  import 'package:flutter_plugin_tools/src/common/core.dart';
  import 'package:git/git.dart';
  import 'package:pub_semver/pub_semver.dart';
  import 'package:yaml_edit/yaml_edit.dart';
  
  import 'common/git_version_finder.dart';
  import 'common/package_looping_command.dart';
  import 'common/package_state_utils.dart';
  import 'common/repository_package.dart';
  
  /// Supported version change types, from smallest to largest component.
  enum _VersionIncrementType { build, bugfix, minor }
  
  /// Possible results of attempting to update a CHANGELOG.md file.
  enum _ChangelogUpdateOutcome { addedSection, updatedSection, failed }
  
  /// A state machine for the process of updating a CHANGELOG.md.
  enum _ChangelogUpdateState {
    /// Looking for the first version section.
    findingFirstSection,
  
    /// Looking for the first list entry in an existing section.
    findingFirstListItem,
  
    /// Finished with updates.
    finishedUpdating,
  }
  
  /// A command to update the changelog, and optionally version, of packages.
  class UpdateReleaseInfoCommand extends PackageLoopingCommand {
    /// Creates a publish metadata updater command instance.
    UpdateReleaseInfoCommand(
      Directory packagesDir, {
      GitDir? gitDir,
    }) : super(packagesDir, gitDir: gitDir) {
      argParser.addOption(_changelogFlag,
          mandatory: true,
          help: 'The changelog entry to add. '
              'Each line will be a separate list entry.');
      argParser.addOption(_versionTypeFlag,
          mandatory: true,
          help: 'The version change level',
          allowed: <String>[
            _versionNext,
            _versionMinimal,
            _versionBugfix,
            _versionMinor,
          ],
          allowedHelp: <String, String>{
            _versionNext:
                'No version change; just adds a NEXT entry to the changelog.',
            _versionBugfix: 'Increments the bugfix version.',
            _versionMinor: 'Increments the minor version.',
            _versionMinimal: 'Depending on the changes to each package: '
                'increments the bugfix version (for publishable changes), '
                "uses NEXT (for changes that don't need to be published), "
                'or skips (if no changes).',
          });
    }
  
    static const String _changelogFlag = 'changelog';
    static const String _versionTypeFlag = 'version';
  
    static const String _versionNext = 'next';
    static const String _versionBugfix = 'bugfix';
    static const String _versionMinor = 'minor';
    static const String _versionMinimal = 'minimal';
  
    // The version change type, if there is a set type for all platforms.
    //
    // If null, either there is no version change, or it is dynamic (`minimal`).
    _VersionIncrementType? _versionChange;
  
    // The cache of changed files, for dynamic version change determination.
    //
    // Only set for `minimal` version change.
    late final List<String> _changedFiles;
  
    @override
    final String name = 'update-release-info';
  
    @override
    final String description = 'Updates CHANGELOG.md files, and optionally the '
        'version in pubspec.yaml, in a way that is consistent with version-check '
        'enforcement.';
  
    @override
    bool get hasLongOutput => false;
  
    @override
    Future<void> initializeRun() async {
      if (getStringArg(_changelogFlag).trim().isEmpty) {
        throw UsageException('Changelog message must not be empty.', usage);
      }
      switch (getStringArg(_versionTypeFlag)) {
        case _versionMinor:
          _versionChange = _VersionIncrementType.minor;
          break;
        case _versionBugfix:
          _versionChange = _VersionIncrementType.bugfix;
          break;
        case _versionMinimal:
          final GitVersionFinder gitVersionFinder = await retrieveVersionFinder();
          _changedFiles = await gitVersionFinder.getChangedFiles();
          // Anothing other than a fixed change is null.
          _versionChange = null;
          break;
        case _versionNext:
          _versionChange = null;
          break;
        default:
          throw UnimplementedError('Unimplemented version change type');
      }
    }
  
    @override
    Future<PackageResult> runForPackage(RepositoryPackage package) async {
      String nextVersionString;
  
      _VersionIncrementType? versionChange = _versionChange;
  
      // If the change type is `minimal` determine what changes, if any, are
      // needed.
      if (versionChange == null &&
          getStringArg(_versionTypeFlag) == _versionMinimal) {
        final Directory gitRoot =
            packagesDir.fileSystem.directory((await gitDir).path);
        final String relativePackagePath =
            getRelativePosixPath(package.directory, from: gitRoot);
        final PackageChangeState state = checkPackageChangeState(package,
            changedPaths: _changedFiles,
            relativePackagePath: relativePackagePath);
  
        if (!state.hasChanges) {
          return PackageResult.skip('No changes to package');
        }
        if (state.needsVersionChange) {
          versionChange = _VersionIncrementType.bugfix;
        }
      }
  
      if (versionChange != null) {
        final Version? updatedVersion =
            _updatePubspecVersion(package, versionChange);
        if (updatedVersion == null) {
          return PackageResult.fail(
              <String>['Could not determine current version.']);
        }
        nextVersionString = updatedVersion.toString();
        print('${indentation}Incremented version to $nextVersionString.');
      } else {
        nextVersionString = 'NEXT';
      }
  
      final _ChangelogUpdateOutcome updateOutcome =
          _updateChangelog(package, nextVersionString);
      switch (updateOutcome) {
        case _ChangelogUpdateOutcome.addedSection:
          print('${indentation}Added a $nextVersionString section.');
          break;
        case _ChangelogUpdateOutcome.updatedSection:
          print('${indentation}Updated NEXT section.');
          break;
        case _ChangelogUpdateOutcome.failed:
          return PackageResult.fail(<String>['Could not update CHANGELOG.md.']);
      }
  
      return PackageResult.success();
    }
  
    _ChangelogUpdateOutcome _updateChangelog(
        RepositoryPackage package, String version) {
      if (!package.changelogFile.existsSync()) {
        printError('${indentation}Missing CHANGELOG.md.');
        return _ChangelogUpdateOutcome.failed;
      }
  
      final String newHeader = '## $version';
      final RegExp listItemPattern = RegExp(r'^(\s*[-*])');
  
      final StringBuffer newChangelog = StringBuffer();
      _ChangelogUpdateState state = _ChangelogUpdateState.findingFirstSection;
      bool updatedExistingSection = false;
  
      for (final String line in package.changelogFile.readAsLinesSync()) {
        switch (state) {
          case _ChangelogUpdateState.findingFirstSection:
            final String trimmedLine = line.trim();
            if (trimmedLine.isEmpty) {
              // Discard any whitespace at the top of the file.
            } else if (trimmedLine == '## NEXT') {
              // Replace the header with the new version (which may also be NEXT).
              newChangelog.writeln(newHeader);
              // Find the existing list to add to.
              state = _ChangelogUpdateState.findingFirstListItem;
            } else {
              // The first content in the file isn't a NEXT section, so just add
              // the new section.
              <String>[
                newHeader,
                '',
                ..._changelogAdditionsAsList(),
                '',
                line, // Don't drop the current line.
              ].forEach(newChangelog.writeln);
              state = _ChangelogUpdateState.finishedUpdating;
            }
            break;
          case _ChangelogUpdateState.findingFirstListItem:
            final RegExpMatch? match = listItemPattern.firstMatch(line);
            if (match != null) {
              final String listMarker = match[1]!;
              // Add the new items on top. If the new change is changing the
              // version, then the new item should be more relevant to package
              // clients than anything that was already there. If it's still
              // NEXT, the order doesn't matter.
              <String>[
                ..._changelogAdditionsAsList(listMarker: listMarker),
                line, // Don't drop the current line.
              ].forEach(newChangelog.writeln);
              state = _ChangelogUpdateState.finishedUpdating;
              updatedExistingSection = true;
            } else if (line.trim().isEmpty) {
              // Scan past empty lines, but keep them.
              newChangelog.writeln(line);
            } else {
              printError('  Existing NEXT section has unrecognized format.');
              return _ChangelogUpdateOutcome.failed;
            }
            break;
          case _ChangelogUpdateState.finishedUpdating:
            // Once changes are done, add the rest of the lines as-is.
            newChangelog.writeln(line);
            break;
        }
      }
  
      package.changelogFile.writeAsStringSync(newChangelog.toString());
  
      return updatedExistingSection
          ? _ChangelogUpdateOutcome.updatedSection
          : _ChangelogUpdateOutcome.addedSection;
    }
  
    /// Returns the changelog to add as a Markdown list, using the given list
    /// bullet style (default to the repository standard of '*'), and adding
    /// any missing periods.
    ///
    /// E.g., 'A line\nAnother line.' will become:
    /// ```
    /// [ '* A line.', '* Another line.' ]
    /// ```
    Iterable<String> _changelogAdditionsAsList({String listMarker = '*'}) =>
        getStringArg(_changelogFlag).split('\n').map((String entry) {
          String standardizedEntry = entry.trim();
          if (!standardizedEntry.endsWith('.')) {
            standardizedEntry = '$standardizedEntry.';
          }
          return '$listMarker $standardizedEntry';
        });
  
    /// Updates the version in [package]'s pubspec according to [type], returning
    /// the new version, or null if there was an error updating the version.
    Version? _updatePubspecVersion(
        RepositoryPackage package, _VersionIncrementType type) {
      final Pubspec pubspec = package.parsePubspec();
      final Version? currentVersion = pubspec.version;
      if (currentVersion == null) {
        printError('${indentation}No version in pubspec.yaml');
        return null;
      }
  
      // For versions less than 1.0, shift the change down one component per
      // Dart versioning conventions.
      final _VersionIncrementType adjustedType = currentVersion.major > 0
          ? type
          : _VersionIncrementType.values[type.index - 1];
  
      final Version newVersion = _nextVersion(currentVersion, adjustedType);
  
      // Write the new version to the pubspec.
      final YamlEditor editablePubspec =
          YamlEditor(package.pubspecFile.readAsStringSync());
      editablePubspec.update(<String>['version'], newVersion.toString());
      package.pubspecFile.writeAsStringSync(editablePubspec.toString());
  
      return newVersion;
    }
  
    Version _nextVersion(Version version, _VersionIncrementType type) {
      switch (type) {
        case _VersionIncrementType.minor:
          return version.nextMinor;
        case _VersionIncrementType.bugfix:
          return version.nextPatch;
        case _VersionIncrementType.build:
          final int buildNumber =
              version.build.isEmpty ? 0 : version.build.first as int;
          return Version(version.major, version.minor, version.patch,
              build: '${buildNumber + 1}');
      }
    }
  }
  