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:file/file.dart';
  import 'package:git/git.dart';
  import 'package:path/path.dart' as p;
  import 'package:pub_semver/pub_semver.dart';
  
  import 'common/core.dart';
  import 'common/git_version_finder.dart';
  import 'common/plugin_command.dart';
  import 'common/repository_package.dart';
  
  const int _exitPackageNotFound = 3;
  const int _exitCannotUpdatePubspec = 4;
  
  enum _RewriteOutcome { changed, noChangesNeeded, alreadyChanged }
  
  /// Converts all dependencies on target packages to path-based dependencies.
  ///
  /// This is to allow for pre-publish testing of changes that could affect other
  /// packages in the repository. For instance, this allows for catching cases
  /// where a non-breaking change to a platform interface package of a federated
  /// plugin would cause post-publish analyzer failures in another package of that
  /// plugin.
  class MakeDepsPathBasedCommand extends PluginCommand {
    /// Creates an instance of the command to convert selected dependencies to
    /// path-based.
    MakeDepsPathBasedCommand(
      Directory packagesDir, {
      GitDir? gitDir,
    }) : super(packagesDir, gitDir: gitDir) {
      argParser.addMultiOption(_targetDependenciesArg,
          help:
              'The names of the packages to convert to path-based dependencies.\n'
              'Ignored if --$_targetDependenciesWithNonBreakingUpdatesArg is '
              'passed.',
          valueHelp: 'some_package');
      argParser.addFlag(
        _targetDependenciesWithNonBreakingUpdatesArg,
        help: 'Causes all packages that have non-breaking version changes '
            'when compared against the git base to be treated as target '
            'packages.',
      );
    }
  
    static const String _targetDependenciesArg = 'target-dependencies';
    static const String _targetDependenciesWithNonBreakingUpdatesArg =
        'target-dependencies-with-non-breaking-updates';
  
    // The comment to add to temporary dependency overrides.
    static const String _dependencyOverrideWarningComment =
        '# FOR TESTING ONLY. DO NOT MERGE.';
  
    @override
    final String name = 'make-deps-path-based';
  
    @override
    final String description =
        'Converts package dependencies to path-based references.';
  
    @override
    Future<void> run() async {
      final Set<String> targetDependencies =
          getBoolArg(_targetDependenciesWithNonBreakingUpdatesArg)
              ? await _getNonBreakingUpdatePackages()
              : getStringListArg(_targetDependenciesArg).toSet();
  
      if (targetDependencies.isEmpty) {
        print('No target dependencies; nothing to do.');
        return;
      }
      print('Rewriting references to: ${targetDependencies.join(', ')}...');
  
      final Map<String, RepositoryPackage> localDependencyPackages =
          _findLocalPackages(targetDependencies);
  
      final String repoRootPath = (await gitDir).path;
      for (final File pubspec in await _getAllPubspecs()) {
        final String displayPath = p.posix.joinAll(
            path.split(path.relative(pubspec.absolute.path, from: repoRootPath)));
        final _RewriteOutcome outcome = await _addDependencyOverridesIfNecessary(
            pubspec, localDependencyPackages);
        switch (outcome) {
          case _RewriteOutcome.changed:
            print('  Modified $displayPath');
            break;
          case _RewriteOutcome.alreadyChanged:
            print('  Skipped $displayPath - Already rewritten');
            break;
          case _RewriteOutcome.noChangesNeeded:
            break;
        }
      }
    }
  
    Map<String, RepositoryPackage> _findLocalPackages(Set<String> packageNames) {
      final Map<String, RepositoryPackage> targets =
          <String, RepositoryPackage>{};
      for (final String packageName in packageNames) {
        final Directory topLevelCandidate =
            packagesDir.childDirectory(packageName);
        // If packages/<packageName>/ exists, then either that directory is the
        // package, or packages/<packageName>/<packageName>/ exists and is the
        // package (in the case of a federated plugin).
        if (topLevelCandidate.existsSync()) {
          final Directory appFacingCandidate =
              topLevelCandidate.childDirectory(packageName);
          targets[packageName] = RepositoryPackage(appFacingCandidate.existsSync()
              ? appFacingCandidate
              : topLevelCandidate);
          continue;
        }
        // If there is no packages/<packageName> directory, then either the
        // packages doesn't exist, or it is a sub-package of a federated plugin.
        // If it's the latter, it will be a directory whose name is a prefix.
        for (final FileSystemEntity entity in packagesDir.listSync()) {
          if (entity is Directory && packageName.startsWith(entity.basename)) {
            final Directory subPackageCandidate =
                entity.childDirectory(packageName);
            if (subPackageCandidate.existsSync()) {
              targets[packageName] = RepositoryPackage(subPackageCandidate);
              break;
            }
          }
        }
  
        if (!targets.containsKey(packageName)) {
          printError('Unable to find package "$packageName"');
          throw ToolExit(_exitPackageNotFound);
        }
      }
      return targets;
    }
  
    /// If [pubspecFile] has any dependencies on packages in [localDependencies],
    /// adds dependency_overrides entries to redirect them to the local version
    /// using path-based dependencies.
    Future<_RewriteOutcome> _addDependencyOverridesIfNecessary(File pubspecFile,
        Map<String, RepositoryPackage> localDependencies) async {
      final String pubspecContents = pubspecFile.readAsStringSync();
      final Pubspec pubspec = Pubspec.parse(pubspecContents);
      // Fail if there are any dependency overrides already, other than ones
      // created by this script. If support for that is needed at some point, it
      // can be added, but currently it's not and relying on that makes the logic
      // here much simpler.
      if (pubspec.dependencyOverrides.isNotEmpty) {
        if (pubspecContents.contains(_dependencyOverrideWarningComment)) {
          return _RewriteOutcome.alreadyChanged;
        }
        printError(
            'Plugins with dependency overrides are not currently supported.');
        throw ToolExit(_exitCannotUpdatePubspec);
      }
  
      final Iterable<String> packagesToOverride = pubspec.dependencies.keys.where(
          (String packageName) => localDependencies.containsKey(packageName));
      if (packagesToOverride.isNotEmpty) {
        final String commonBasePath = packagesDir.path;
        // Find the relative path to the common base.
        final int packageDepth = path
            .split(path.relative(pubspecFile.parent.absolute.path,
                from: commonBasePath))
            .length;
        final List<String> relativeBasePathComponents =
            List<String>.filled(packageDepth, '..');
        // This is done via strings rather than by manipulating the Pubspec and
        // then re-serialiazing so that it's a localized change, rather than
        // rewriting the whole file (e.g., destroying comments), which could be
        // more disruptive for local use.
        String newPubspecContents = '''
  $pubspecContents
  
  $_dependencyOverrideWarningComment
  dependency_overrides:
  ''';
        for (final String packageName in packagesToOverride) {
          // Find the relative path from the common base to the local package.
          final List<String> repoRelativePathComponents = path.split(
              path.relative(localDependencies[packageName]!.path,
                  from: commonBasePath));
          newPubspecContents += '''
    $packageName:
      path: ${p.posix.joinAll(<String>[
                ...relativeBasePathComponents,
                ...repoRelativePathComponents,
              ])}
  ''';
        }
        pubspecFile.writeAsStringSync(newPubspecContents);
        return _RewriteOutcome.changed;
      }
      return _RewriteOutcome.noChangesNeeded;
    }
  
    /// Returns all pubspecs anywhere under the packages directory.
    Future<List<File>> _getAllPubspecs() => packagesDir.parent
        .list(recursive: true, followLinks: false)
        .where((FileSystemEntity entity) =>
            entity is File && p.basename(entity.path) == 'pubspec.yaml')
        .map((FileSystemEntity file) => file as File)
        .toList();
  
    /// Returns all packages that have non-breaking published changes (i.e., a
    /// minor or bugfix version change) relative to the git comparison base.
    ///
    /// Prints status information about what was checked for ease of auditing logs
    /// in CI.
    Future<Set<String>> _getNonBreakingUpdatePackages() async {
      final GitVersionFinder gitVersionFinder = await retrieveVersionFinder();
      final String baseSha = await gitVersionFinder.getBaseSha();
      print('Finding changed packages relative to "$baseSha"...');
  
      final Set<String> changedPackages = <String>{};
      for (final String changedPath in await gitVersionFinder.getChangedFiles()) {
        // Git output always uses Posix paths.
        final List<String> allComponents = p.posix.split(changedPath);
        // Only pubspec changes are potential publishing events.
        if (allComponents.last != 'pubspec.yaml' ||
            allComponents.contains('example')) {
          continue;
        }
        if (!allComponents.contains(packagesDir.basename)) {
          print('  Skipping $changedPath; not in packages directory.');
          continue;
        }
        final RepositoryPackage package =
            RepositoryPackage(packagesDir.fileSystem.file(changedPath).parent);
        // Ignored deleted packages, as they won't be published.
        if (!package.pubspecFile.existsSync()) {
          final String directoryName = p.posix.joinAll(path.split(path.relative(
              package.directory.absolute.path,
              from: packagesDir.path)));
          print('  Skipping $directoryName; deleted.');
          continue;
        }
        final String packageName = package.parsePubspec().name;
        if (!await _hasNonBreakingVersionChange(package)) {
          // Log packages that had pubspec changes but weren't included for ease
          // of auditing CI.
          print('  Skipping $packageName; no non-breaking version change.');
          continue;
        }
        changedPackages.add(packageName);
      }
      return changedPackages;
    }
  
    Future<bool> _hasNonBreakingVersionChange(RepositoryPackage package) async {
      final Pubspec pubspec = package.parsePubspec();
      if (pubspec.publishTo == 'none') {
        return false;
      }
  
      final String pubspecGitPath = p.posix.joinAll(path.split(path.relative(
          package.pubspecFile.absolute.path,
          from: (await gitDir).path)));
      final GitVersionFinder gitVersionFinder = await retrieveVersionFinder();
      final Version? previousVersion =
          await gitVersionFinder.getPackageVersion(pubspecGitPath);
      if (previousVersion == null) {
        // The plugin is new, so nothing can be depending on it yet.
        return false;
      }
      final Version newVersion = pubspec.version!;
      if ((newVersion.major > 0 && newVersion.major != previousVersion.major) ||
          (newVersion.major == 0 && newVersion.minor != previousVersion.minor)) {
        // Breaking changes aren't targetted since they won't be picked up
        // automatically.
        return false;
      }
      return newVersion != previousVersion;
    }
  }
  