// 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:path/path.dart';

import 'base/common.dart';
import 'base/file_system.dart';
import 'base/logger.dart';
import 'base/project.dart';
import 'custom_merge.dart';
import 'environment.dart';
import 'flutter_project_metadata.dart';
import 'migrate_logger.dart';
import 'result.dart';
import 'utils.dart';

// This defines paths of files and directories relative to the project root
// that should be ignored by the migrate tool regardless of .gitignore and
// config settings.
// Paths use `/` as a stand-in for path separator.
const List<String> _skippedFiles = <String>[
  'ios/Runner.xcodeproj/project.pbxproj', // Xcode managed configs that may not merge cleanly.
  'README.md', // changes to this shouldn't be overwritten since is is user owned.
];

const List<String> _skippedDirectories = <String>[
  '.dart_tool', // The .dart_tool generated dir.
  '.git', // Git metadata.
  'assets', // Common directory for user assets.
  'build', // Build artifacts.
  'lib', // Always user owned and we don't want to overwrite their apps.
  'test', // Typically user owned and flutter-side changes are not relevant.
];

final Iterable<String> canonicalizedSkippedFiles = _skippedFiles.map<String>(
  (String path) => canonicalize(path),
);

// Returns true for paths relative to the project root that should be skipped
// completely by the migrate tool.
bool _skipped(String localPath, FileSystem fileSystem,
    {Set<String>? skippedPrefixes}) {
  final String canonicalizedLocalPath = canonicalize(localPath);
  final Iterable<String> canonicalizedSkippedFiles =
      _skippedFiles.map<String>((String path) => canonicalize(path));
  if (canonicalizedSkippedFiles.contains(canonicalizedLocalPath)) {
    return true;
  }
  final Iterable<String> canonicalizedSkippedDirectories =
      _skippedDirectories.map<String>((String path) => canonicalize(path));
  for (final String dir in canonicalizedSkippedDirectories) {
    if (canonicalizedLocalPath.startsWith('$dir${fileSystem.path.separator}')) {
      return true;
    }
  }
  if (skippedPrefixes != null) {
    return skippedPrefixes.any((String prefix) => localPath.startsWith(
        '${normalize(prefix.replaceAll(r'\', fileSystem.path.separator))}${fileSystem.path.separator}'));
  }
  return false;
}

// File extensions that the tool will not attempt to merge. Changes
// in files with these extensions will be accepted wholesale.
//
// The executables and binaries in this list are not meant to be
// comprehensive and need only cover the files that are generated
// in `flutter create` as only files generated by the template
// will be attempted to be merged.
const List<String> _doNotMergeFileExtensions = <String>[
  // Don't merge image files
  '.bmp',
  '.gif',
  '.jpg',
  '.jpeg',
  '.png',
  '.svg',
  // Don't merge compiled artifacts and executables
  '.dll',
  '.exe',
  '.jar',
  '.so',
];

// These files should always go through the migrate process as
// they are either integral to the migrate process or we expect
// new versions of this file to always be desired.
const Set<String> _alwaysMigrateFiles = <String>{
  '.metadata', // .metadata tracks key migration information.
  'android/gradle/wrapper/gradle-wrapper.jar',
  // Always add .gitignore back in even if user-deleted as it makes it
  // difficult to migrate in the future and the migrate tool enforces git
  // usage.
  '.gitignore',
};

/// False for files that should not be merged. Typically, images and binary files.
bool _mergable(String localPath) {
  return _alwaysMigrateFiles.contains(localPath) ||
      !_doNotMergeFileExtensions.any((String ext) => localPath.endsWith(ext));
}

// Compile the set of path prefixes that should be ignored as configured
// in the command arguments.
Set<String> _getSkippedPrefixes(List<SupportedPlatform> platforms) {
  final Set<String> skippedPrefixes = <String>{};
  for (final SupportedPlatform platform in SupportedPlatform.values) {
    skippedPrefixes.add(platformToSubdirectoryPrefix(platform));
  }
  for (final SupportedPlatform platform in platforms) {
    skippedPrefixes.remove(platformToSubdirectoryPrefix(platform));
  }
  return skippedPrefixes;
}

/// Data class holds the common context that is used throughout the steps of a migrate computation.
class MigrateContext {
  MigrateContext({
    required this.flutterProject,
    required this.skippedPrefixes,
    required this.fileSystem,
    required this.migrateLogger,
    required this.migrateUtils,
    required this.environment,
    this.baseProject,
    this.targetProject,
  });

  final FlutterProject flutterProject;
  final Set<String> skippedPrefixes;
  final FileSystem fileSystem;
  final MigrateLogger migrateLogger;
  final MigrateUtils migrateUtils;
  final FlutterToolsEnvironment environment;

  MigrateBaseFlutterProject? baseProject;
  MigrateTargetFlutterProject? targetProject;
}

/// Returns the path relative to the flutter project's root.
String getLocalPath(String path, String basePath, FileSystem fileSystem) {
  return path.replaceFirst(basePath + fileSystem.path.separator, '');
}

String platformToSubdirectoryPrefix(SupportedPlatform platform) {
  switch (platform) {
    case SupportedPlatform.android:
      return 'android';
    case SupportedPlatform.ios:
      return 'ios';
    case SupportedPlatform.linux:
      return 'linux';
    case SupportedPlatform.macos:
      return 'macos';
    case SupportedPlatform.web:
      return 'web';
    case SupportedPlatform.windows:
      return 'windows';
    case SupportedPlatform.fuchsia:
      return 'fuchsia';
  }
}

/// Data class that contains the command line arguments passed by the user.
class MigrateCommandParameters {
  MigrateCommandParameters({
    this.baseAppPath,
    this.targetAppPath,
    this.baseRevision,
    this.targetRevision,
    this.preferTwoWayMerge = false,
    this.verbose = false,
    this.allowFallbackBaseRevision = false,
    this.deleteTempDirectories = true,
    this.platforms,
  });
  final String? baseAppPath;
  final String? targetAppPath;
  final String? baseRevision;
  final String? targetRevision;
  final bool preferTwoWayMerge;
  final bool verbose;
  final bool allowFallbackBaseRevision;
  final bool deleteTempDirectories;
  final List<SupportedPlatform>? platforms;
}

/// Computes the changes that migrates the current flutter project to the target revision.
///
/// This is the entry point to the core migration computations and drives the migration process.
///
/// This method attempts to find a base revision, which is the revision of the Flutter SDK
/// the app was generated with or the last revision the app was migrated to. The base revision
/// typically comes from the .metadata, but for legacy apps, the config may not exist. In
/// this case, we fallback to using the revision in .metadata, and if that does not exist, we
/// use the target revision as the base revision. In the final fallback case, the migration should
/// still work, but will likely generate slightly less accurate merges.
///
/// Operations the computation performs:
///
///  - Parse .metadata file
///  - Collect revisions to use for each platform
///  - Download each flutter revision and call `flutter create` for each.
///  - Call `flutter create` with target revision (target is typically current flutter version)
///  - Diff base revision generated app with target revision generated app
///  - Compute all newly added files between base and target revisions
///  - Compute merge of all files that are modified by user and flutter
///  - Track temp dirs to be deleted
///
/// Structure: This method builds upon a MigrateResult instance
Future<MigrateResult?> computeMigration({
  FlutterProject? flutterProject,
  required MigrateCommandParameters commandParameters,
  required FileSystem fileSystem,
  required Logger logger,
  required MigrateUtils migrateUtils,
  required FlutterToolsEnvironment environment,
}) async {
  flutterProject ??= FlutterProject.current(fileSystem);

  final MigrateLogger migrateLogger =
      MigrateLogger(logger: logger, verbose: commandParameters.verbose);
  migrateLogger.logStep('start');
  // Find the path prefixes to ignore. This allows subdirectories of platforms
  // not part of the migration to be skipped.
  final List<SupportedPlatform> platforms =
      commandParameters.platforms ?? flutterProject.getSupportedPlatforms();
  final Set<String> skippedPrefixes = _getSkippedPrefixes(platforms);

  final MigrateResult result = MigrateResult.empty();
  final MigrateContext context = MigrateContext(
    flutterProject: flutterProject,
    skippedPrefixes: skippedPrefixes,
    migrateLogger: migrateLogger,
    fileSystem: fileSystem,
    migrateUtils: migrateUtils,
    environment: environment,
  );

  migrateLogger.logStep('revisions');
  final MigrateRevisions revisionConfig = MigrateRevisions(
    context: context,
    baseRevision: commandParameters.baseRevision,
    allowFallbackBaseRevision: commandParameters.allowFallbackBaseRevision,
    platforms: platforms,
    environment: environment,
  );

  // Extract the unamanged files/paths that should be ignored by the migrate tool.
  // These paths are absolute paths.
  migrateLogger.logStep('unmanaged');
  final List<String> unmanagedFiles = <String>[];
  final List<String> unmanagedDirectories = <String>[];
  final String basePath = flutterProject.directory.path;
  for (final String localPath in revisionConfig.config.unmanagedFiles) {
    if (localPath.endsWith(fileSystem.path.separator)) {
      unmanagedDirectories.add(fileSystem.path.join(basePath, localPath));
    } else {
      unmanagedFiles.add(fileSystem.path.join(basePath, localPath));
    }
  }

  migrateLogger.logStep('generating_base');
  // Generate the base templates
  final ReferenceProjects referenceProjects =
      await _generateBaseAndTargetReferenceProjects(
    context: context,
    result: result,
    revisionConfig: revisionConfig,
    platforms: platforms,
    commandParameters: commandParameters,
  );

  // Generate diffs. These diffs are used to determine if a file is newly added, needs merging,
  // or deleted (rare). Only files with diffs between the base and target revisions need to be
  // migrated. If files are unchanged between base and target, then there are no changes to merge.
  migrateLogger.logStep('diff');
  result.diffMap.addAll(await referenceProjects.baseProject
      .diff(context, referenceProjects.targetProject));

  // Check for any new files that were added in the target reference app that did not
  // exist in the base reference app.
  migrateLogger.logStep('new_files');
  result.addedFiles.addAll(await referenceProjects.baseProject
      .computeNewlyAddedFiles(
          context, result, referenceProjects.targetProject));

  // Merge any base->target changed files with the version in the developer's project.
  // Files that the developer left unchanged are fully updated to match the target reference.
  // Files that the developer changed and were changed from base->target are merged.
  migrateLogger.logStep('merging');
  await MigrateFlutterProject.merge(
    context,
    result,
    referenceProjects.baseProject,
    referenceProjects.targetProject,
    unmanagedFiles,
    unmanagedDirectories,
    commandParameters.preferTwoWayMerge,
  );

  // Clean up any temp directories generated by this tool.
  migrateLogger.logStep('cleaning');
  _registerTempDirectoriesForCleaning(
      commandParameters: commandParameters,
      result: result,
      referenceProjects: referenceProjects);
  migrateLogger.stop();
  return result;
}

/// Returns a base revision to fallback to in case a true base revision is unknown.
String _getFallbackBaseRevision(
    bool allowFallbackBaseRevision, MigrateLogger migrateLogger) {
  if (!allowFallbackBaseRevision) {
    migrateLogger.stop();
    migrateLogger.printError(
        'Could not determine base revision this app was created with:');
    migrateLogger.printError(
        '.metadata file did not exist or did not contain a valid revision.',
        indent: 2);
    migrateLogger.printError(
        'Run this command again with the `--allow-fallback-base-revision` flag to use Flutter v1.0.0 as the base revision or manually pass a revision with `--base-revision=<revision>`',
        indent: 2);
    throwToolExit('Failed to resolve base revision');
  }
  // Earliest version of flutter with .metadata: c17099f474675d8066fec6984c242d8b409ae985 (2017)
  // Flutter 2.0.0: 60bd88df915880d23877bfc1602e8ddcf4c4dd2a
  // Flutter v1.0.0: 5391447fae6209bb21a89e6a5a6583cac1af9b4b
  //
  // TODO(garyq): Use things like dart sdk version and other hints to better fine-tune this fallback.
  //
  // We fall back on flutter v1.0.0 if .metadata doesn't exist.
  migrateLogger.printIfVerbose(
      'Could not determine base revision, falling back on `v1.0.0`, revision 5391447fae6209bb21a89e6a5a6583cac1af9b4b');
  return '5391447fae6209bb21a89e6a5a6583cac1af9b4b';
}

/// Simple data class that holds the base and target reference
/// projects.
class ReferenceProjects {
  ReferenceProjects({
    required this.baseProject,
    required this.targetProject,
    required this.customBaseProjectDir,
    required this.customTargetProjectDir,
  });

  MigrateBaseFlutterProject baseProject;
  MigrateTargetFlutterProject targetProject;

  // Whether a user provided base and target projects were provided.
  bool customBaseProjectDir;
  bool customTargetProjectDir;
}

// Generate reference base and target flutter projects.
//
// This function generates reference vaniilla projects by using `flutter create` with
// the base revision Flutter SDK as well as the target revision SDK.
Future<ReferenceProjects> _generateBaseAndTargetReferenceProjects({
  required MigrateContext context,
  required MigrateResult result,
  required MigrateRevisions revisionConfig,
  required List<SupportedPlatform> platforms,
  required MigrateCommandParameters commandParameters,
}) async {
  // Use user-provided projects if provided, if not, generate them internally.
  final bool customBaseProjectDir = commandParameters.baseAppPath != null;
  final bool customTargetProjectDir = commandParameters.targetAppPath != null;
  Directory baseProjectDir =
      context.fileSystem.systemTempDirectory.createTempSync('baseProject');
  Directory targetProjectDir =
      context.fileSystem.systemTempDirectory.createTempSync('targetProject');
  if (customBaseProjectDir) {
    baseProjectDir =
        context.fileSystem.directory(commandParameters.baseAppPath);
  } else {
    baseProjectDir =
        context.fileSystem.systemTempDirectory.createTempSync('baseProject');
    context.migrateLogger
        .printIfVerbose('Created temporary directory: ${baseProjectDir.path}');
  }
  if (customTargetProjectDir) {
    targetProjectDir =
        context.fileSystem.directory(commandParameters.targetAppPath);
  } else {
    targetProjectDir =
        context.fileSystem.systemTempDirectory.createTempSync('targetProject');
    context.migrateLogger.printIfVerbose(
        'Created temporary directory: ${targetProjectDir.path}');
  }

  // Git init to enable running further git commands on the reference projects.
  await context.migrateUtils.gitInit(baseProjectDir.absolute.path);
  await context.migrateUtils.gitInit(targetProjectDir.absolute.path);

  result.generatedBaseTemplateDirectory = baseProjectDir;
  result.generatedTargetTemplateDirectory = targetProjectDir;

  final String name =
      context.environment['FlutterProject.manifest.appname']! as String;
  final String androidLanguage =
      context.environment['FlutterProject.android.isKotlin']! as bool
          ? 'kotlin'
          : 'java';
  final String iosLanguage =
      context.environment['FlutterProject.ios.isSwift']! as bool
          ? 'swift'
          : 'objc';

  final Directory targetFlutterDirectory = context.fileSystem
      .directory(context.environment.getString('Cache.flutterRoot'));

  // Create the base reference vanilla app.
  //
  // This step clones the base flutter sdk, and uses it to create a new vanilla app.
  // The vanilla base app is used as part of a 3 way merge between the base app, target
  // app, and the current user-owned app.
  final MigrateBaseFlutterProject baseProject = MigrateBaseFlutterProject(
    path: commandParameters.baseAppPath,
    directory: baseProjectDir,
    name: name,
    androidLanguage: androidLanguage,
    iosLanguage: iosLanguage,
    platformWhitelist: platforms,
  );
  context.baseProject = baseProject;
  await baseProject.createProject(
    context,
    result,
    revisionConfig.revisionsList,
    revisionConfig.revisionToConfigs,
    commandParameters.baseRevision ??
        revisionConfig.metadataRevision ??
        _getFallbackBaseRevision(
            commandParameters.allowFallbackBaseRevision, context.migrateLogger),
    revisionConfig.targetRevision,
    targetFlutterDirectory,
  );

  // Create target reference app when not provided.
  //
  // This step directly calls flutter create with the target (the current installed revision)
  // flutter sdk.
  final MigrateTargetFlutterProject targetProject = MigrateTargetFlutterProject(
    path: commandParameters.targetAppPath,
    directory: targetProjectDir,
    name: name,
    androidLanguage: androidLanguage,
    iosLanguage: iosLanguage,
    platformWhitelist: platforms,
  );
  context.targetProject = targetProject;
  await targetProject.createProject(
    context,
    result,
    revisionConfig.targetRevision,
    targetFlutterDirectory,
  );

  return ReferenceProjects(
    baseProject: baseProject,
    targetProject: targetProject,
    customBaseProjectDir: customBaseProjectDir,
    customTargetProjectDir: customTargetProjectDir,
  );
}

// Registers any generated temporary directories for optional deletion upon tool exit.
void _registerTempDirectoriesForCleaning({
  required MigrateCommandParameters commandParameters,
  required MigrateResult result,
  required ReferenceProjects referenceProjects,
}) {
  if (commandParameters.deleteTempDirectories) {
    // Don't delete user-provided directories
    if (!referenceProjects.customBaseProjectDir) {
      result.tempDirectories.add(result.generatedBaseTemplateDirectory!);
    }
    if (!referenceProjects.customTargetProjectDir) {
      result.tempDirectories.add(result.generatedTargetTemplateDirectory!);
    }
    result.tempDirectories.addAll(result.sdkDirs.values);
  }
}

/// A reference flutter project.
///
/// A MigrateFlutterProject is a project that is generated internally within the tool
/// to see what changes need to be made to the user's project. This class
/// provides methods to merge, diff, and otherwise compare multiple MigrateFlutterProject
/// instances.
abstract class MigrateFlutterProject {
  MigrateFlutterProject({
    required this.path,
    required this.directory,
    required this.name,
    required this.androidLanguage,
    required this.iosLanguage,
    this.platformWhitelist,
  });

  final String? path;
  final Directory directory;
  final String name;
  final String androidLanguage;
  final String iosLanguage;
  final List<SupportedPlatform>? platformWhitelist;

  /// Run git diff over each matching pair of files in the this project and the provided target project.
  Future<Map<String, DiffResult>> diff(
    MigrateContext context,
    MigrateFlutterProject other,
  ) async {
    final Map<String, DiffResult> diffMap = <String, DiffResult>{};
    final List<FileSystemEntity> thisFiles =
        directory.listSync(recursive: true);
    int modifiedFilesCount = 0;
    for (final FileSystemEntity entity in thisFiles) {
      if (entity is! File) {
        continue;
      }
      final File thisFile = entity.absolute;
      final String localPath = getLocalPath(
          thisFile.path, directory.absolute.path, context.fileSystem);
      if (_skipped(localPath, context.fileSystem,
          skippedPrefixes: context.skippedPrefixes)) {
        continue;
      }
      if (await context.migrateUtils
          .isGitIgnored(thisFile.absolute.path, directory.absolute.path)) {
        diffMap[localPath] = DiffResult(diffType: DiffType.ignored);
      }
      final File otherFile = other.directory.childFile(localPath);
      if (otherFile.existsSync()) {
        final DiffResult diff =
            await context.migrateUtils.diffFiles(thisFile, otherFile);
        diffMap[localPath] = diff;
        if (diff.diff != '') {
          context.migrateLogger.printIfVerbose(
              'Found ${diff.exitCode} changes in $localPath',
              indent: 4);
          modifiedFilesCount++;
        }
      } else {
        // Current file has no new template counterpart, which is equivalent to a deletion.
        // This could also indicate a renaming if there is an addition with equivalent contents.
        diffMap[localPath] = DiffResult(diffType: DiffType.deletion);
      }
    }
    context.migrateLogger.printIfVerbose(
        '$modifiedFilesCount files were modified between base and target apps.');
    return diffMap;
  }

  /// Find all files that exist in the target reference app but not in the base reference app.
  Future<List<FilePendingMigration>> computeNewlyAddedFiles(
    MigrateContext context,
    MigrateResult result,
    MigrateFlutterProject other,
  ) async {
    final List<FilePendingMigration> addedFiles = <FilePendingMigration>[];
    final List<FileSystemEntity> otherFiles =
        other.directory.listSync(recursive: true);
    for (final FileSystemEntity entity in otherFiles) {
      if (entity is! File) {
        continue;
      }
      final File otherFile = entity.absolute;
      final String localPath = getLocalPath(
          otherFile.path, other.directory.absolute.path, context.fileSystem);
      if (directory.childFile(localPath).existsSync() ||
          _skipped(localPath, context.fileSystem,
              skippedPrefixes: context.skippedPrefixes)) {
        continue;
      }
      if (await context.migrateUtils.isGitIgnored(
          otherFile.absolute.path, other.directory.absolute.path)) {
        result.diffMap[localPath] = DiffResult(diffType: DiffType.ignored);
      }
      result.diffMap[localPath] = DiffResult(diffType: DiffType.addition);
      if (context.flutterProject.directory.childFile(localPath).existsSync()) {
        // Don't store as added file if file already exists in the project.
        continue;
      }
      addedFiles.add(FilePendingMigration(localPath, otherFile));
    }
    context.migrateLogger.printIfVerbose(
        '${addedFiles.length} files were newly added in the target app.');
    return addedFiles;
  }

  /// Loops through each existing file and intelligently merges it with the base->target changes.
  static Future<void> merge(
    MigrateContext context,
    MigrateResult result,
    MigrateFlutterProject baseProject,
    MigrateFlutterProject targetProject,
    List<String> unmanagedFiles,
    List<String> unmanagedDirectories,
    bool preferTwoWayMerge,
  ) async {
    final List<CustomMerge> customMerges = <CustomMerge>[
      MetadataCustomMerge(logger: context.migrateLogger.logger),
    ];
    // For each existing file in the project, we attempt to 3 way merge if it is changed by the user.
    final List<FileSystemEntity> currentFiles =
        context.flutterProject.directory.listSync(recursive: true);
    final String projectRootPath =
        context.flutterProject.directory.absolute.path;
    final Set<String> missingAlwaysMigrateFiles =
        Set<String>.of(_alwaysMigrateFiles);
    for (final FileSystemEntity entity in currentFiles) {
      if (entity is! File) {
        continue;
      }
      // check if the file is unmanaged/ignored by the migration tool.
      bool ignored = false;
      ignored = unmanagedFiles.contains(entity.absolute.path);
      for (final String path in unmanagedDirectories) {
        if (entity.absolute.path.startsWith(path)) {
          ignored = true;
          break;
        }
      }
      if (ignored) {
        continue; // Skip if marked as unmanaged
      }

      final File currentFile = entity.absolute;
      // Diff the current file against the old generated template
      final String localPath =
          getLocalPath(currentFile.path, projectRootPath, context.fileSystem);
      missingAlwaysMigrateFiles.remove(localPath);
      if (result.diffMap.containsKey(localPath) &&
              result.diffMap[localPath]!.diffType == DiffType.ignored ||
          await context.migrateUtils.isGitIgnored(currentFile.path,
              context.flutterProject.directory.absolute.path) ||
          _skipped(localPath, context.fileSystem,
              skippedPrefixes: context.skippedPrefixes) ||
          !_mergable(localPath)) {
        continue;
      }
      final File baseTemplateFile = baseProject.directory.childFile(localPath);
      final File targetTemplateFile =
          targetProject.directory.childFile(localPath);
      final DiffResult userDiff =
          await context.migrateUtils.diffFiles(currentFile, baseTemplateFile);
      final DiffResult targetDiff =
          await context.migrateUtils.diffFiles(currentFile, targetTemplateFile);
      if (targetDiff.exitCode == 0) {
        // current file is already the same as the target file.
        continue;
      }

      final bool alwaysMigrate = _alwaysMigrateFiles.contains(localPath);

      // Current file unchanged by user, thus we consider it owned by the tool.
      if (userDiff.exitCode == 0 || alwaysMigrate) {
        if ((result.diffMap.containsKey(localPath) || alwaysMigrate) &&
            result.diffMap[localPath] != null) {
          // File changed between base and target
          if (result.diffMap[localPath]!.diffType == DiffType.deletion) {
            // File is deleted in new template
            result.deletedFiles
                .add(FilePendingMigration(localPath, currentFile));
            continue;
          }
          if (result.diffMap[localPath]!.exitCode != 0 || alwaysMigrate) {
            // Accept the target version wholesale
            MergeResult mergeResult;
            try {
              mergeResult = StringMergeResult.explicit(
                mergedString: targetTemplateFile.readAsStringSync(),
                hasConflict: false,
                exitCode: 0,
                localPath: localPath,
              );
            } on FileSystemException {
              mergeResult = BinaryMergeResult.explicit(
                mergedBytes: targetTemplateFile.readAsBytesSync(),
                hasConflict: false,
                exitCode: 0,
                localPath: localPath,
              );
            }
            result.mergeResults.add(mergeResult);
            continue;
          }
        }
        continue;
      }

      // File changed by user
      if (result.diffMap.containsKey(localPath)) {
        MergeResult? mergeResult;
        // Default to two way merge as it does not require the base file to exist.
        MergeType mergeType =
            result.mergeTypeMap[localPath] ?? MergeType.twoWay;
        for (final CustomMerge customMerge in customMerges) {
          if (customMerge.localPath == localPath) {
            mergeResult = customMerge.merge(
                currentFile, baseTemplateFile, targetTemplateFile);
            mergeType = MergeType.custom;
            break;
          }
        }
        if (mergeResult == null) {
          late String basePath;
          late String currentPath;
          late String targetPath;

          // Use two way merge if diff between base and target are the same.
          // This prevents the three way merge re-deleting the base->target changes.
          if (preferTwoWayMerge) {
            mergeType = MergeType.twoWay;
          }
          switch (mergeType) {
            case MergeType.twoWay:
              {
                basePath = currentFile.path;
                currentPath = currentFile.path;
                targetPath = context.fileSystem.path.join(
                    result.generatedTargetTemplateDirectory!.path, localPath);
                break;
              }
            case MergeType.threeWay:
              {
                basePath = context.fileSystem.path.join(
                    result.generatedBaseTemplateDirectory!.path, localPath);
                currentPath = currentFile.path;
                targetPath = context.fileSystem.path.join(
                    result.generatedTargetTemplateDirectory!.path, localPath);
                break;
              }
            case MergeType.custom:
              {
                break; // handled above
              }
          }
          if (mergeType != MergeType.custom) {
            mergeResult = await context.migrateUtils.gitMergeFile(
              base: basePath,
              current: currentPath,
              target: targetPath,
              localPath: localPath,
            );
          }
        }
        if (mergeResult != null) {
          // Don't include if result is identical to the current file.
          if (mergeResult is StringMergeResult) {
            if (mergeResult.mergedString == currentFile.readAsStringSync()) {
              context.migrateLogger
                  .printIfVerbose('$localPath was merged with a $mergeType.');
              continue;
            }
          } else {
            if ((mergeResult as BinaryMergeResult).mergedBytes ==
                currentFile.readAsBytesSync()) {
              continue;
            }
          }
          result.mergeResults.add(mergeResult);
        }
        context.migrateLogger
            .printStatus('$localPath was merged with a $mergeType.');
        continue;
      }
    }

    // Add files that are in the target, marked as always migrate, and missing in the current project.
    for (final String localPath in missingAlwaysMigrateFiles) {
      final File targetTemplateFile =
          result.generatedTargetTemplateDirectory!.childFile(localPath);
      if (targetTemplateFile.existsSync() &&
          !_skipped(localPath, context.fileSystem,
              skippedPrefixes: context.skippedPrefixes)) {
        result.addedFiles
            .add(FilePendingMigration(localPath, targetTemplateFile));
      }
    }
  }
}

/// The base reference project used in a migration computation.
///
/// This project is a clean re-generation of the version the user's project
/// was 1. originally generated with, or 2. the last successful migrated to.
class MigrateBaseFlutterProject extends MigrateFlutterProject {
  MigrateBaseFlutterProject({
    required super.path,
    required super.directory,
    required super.name,
    required super.androidLanguage,
    required super.iosLanguage,
    super.platformWhitelist,
  });

  /// Creates the base reference app based off of the migrate config in the .metadata file.
  Future<void> createProject(
    MigrateContext context,
    MigrateResult result,
    List<String> revisionsList,
    Map<String, List<MigratePlatformConfig>> revisionToConfigs,
    String fallbackRevision,
    String targetRevision,
    Directory targetFlutterDirectory,
  ) async {
    // Create base
    // Clone base flutter
    if (path == null) {
      final Map<String, Directory> revisionToFlutterSdkDir =
          <String, Directory>{};
      for (final String revision in revisionsList) {
        final List<String> platforms = <String>[];
        for (final MigratePlatformConfig config
            in revisionToConfigs[revision]!) {
          if (config.component == FlutterProjectComponent.root) {
            continue;
          }
          platforms.add(config.component.toString().split('.').last);
        }

        // In the case of the revision being invalid or not a hash of the master branch,
        // we want to fallback in the following order:
        //   - parsed revision
        //   - fallback revision
        //   - target revision (currently installed flutter)
        late Directory sdkDir;
        final List<String> revisionsToTry = <String>[revision];
        if (revision != fallbackRevision) {
          revisionsToTry.add(fallbackRevision);
        }
        bool sdkAvailable = false;
        int index = 0;
        do {
          if (index < revisionsToTry.length) {
            final String activeRevision = revisionsToTry[index++];
            if (activeRevision != revision &&
                revisionToFlutterSdkDir.containsKey(activeRevision)) {
              sdkDir = revisionToFlutterSdkDir[activeRevision]!;
              revisionToFlutterSdkDir[revision] = sdkDir;
              sdkAvailable = true;
            } else {
              sdkDir = context.fileSystem.systemTempDirectory
                  .createTempSync('flutter_$activeRevision');
              result.sdkDirs[activeRevision] = sdkDir;
              context.migrateLogger.printStatus('Cloning SDK $activeRevision');
              sdkAvailable = await context.migrateUtils
                  .cloneFlutter(activeRevision, sdkDir.absolute.path);
              revisionToFlutterSdkDir[revision] = sdkDir;
            }
          } else {
            // fallback to just using the modern target version of flutter.
            sdkDir = targetFlutterDirectory;
            revisionToFlutterSdkDir[revision] = sdkDir;
            sdkAvailable = true;
          }
        } while (!sdkAvailable);
        context.migrateLogger.printStatus(
            'Creating base app for $platforms with revision $revision.');
        final String newDirectoryPath =
            await context.migrateUtils.createFromTemplates(
          sdkDir.childDirectory('bin').absolute.path,
          name: name,
          androidLanguage: androidLanguage,
          iosLanguage: iosLanguage,
          outputDirectory: result.generatedBaseTemplateDirectory!.absolute.path,
          platforms: platforms,
        );
        if (newDirectoryPath != result.generatedBaseTemplateDirectory?.path) {
          result.generatedBaseTemplateDirectory =
              context.fileSystem.directory(newDirectoryPath);
        }
        // Determine merge type for each newly generated file.
        final List<FileSystemEntity> generatedBaseFiles =
            result.generatedBaseTemplateDirectory!.listSync(recursive: true);
        for (final FileSystemEntity entity in generatedBaseFiles) {
          if (entity is! File) {
            continue;
          }
          final File baseTemplateFile = entity.absolute;
          final String localPath = getLocalPath(
              baseTemplateFile.path,
              result.generatedBaseTemplateDirectory!.absolute.path,
              context.fileSystem);
          if (!result.mergeTypeMap.containsKey(localPath)) {
            // Use two way merge when the base revision is the same as the target revision.
            result.mergeTypeMap[localPath] = revision == targetRevision
                ? MergeType.twoWay
                : MergeType.threeWay;
          }
        }
        if (newDirectoryPath != result.generatedBaseTemplateDirectory?.path) {
          result.generatedBaseTemplateDirectory =
              context.fileSystem.directory(newDirectoryPath);
          break; // The create command is old and does not distinguish between platforms so it only needs to be called once.
        }
      }
    }
  }
}

/// Represents a manifested flutter project that is the migration target.
///
/// The files in this project are the version the migrate tool will try
/// to transform the existing files into.
class MigrateTargetFlutterProject extends MigrateFlutterProject {
  MigrateTargetFlutterProject({
    required super.path,
    required super.directory,
    required super.name,
    required super.androidLanguage,
    required super.iosLanguage,
    super.platformWhitelist,
  });

  /// Creates the base reference app based off of the migrate config in the .metadata file.
  Future<void> createProject(
    MigrateContext context,
    MigrateResult result,
    String targetRevision,
    Directory targetFlutterDirectory,
  ) async {
    if (path == null) {
      // Create target
      context.migrateLogger
          .printStatus('Creating target app with revision $targetRevision.');
      context.migrateLogger.printIfVerbose('Creating target app.');
      await context.migrateUtils.createFromTemplates(
        targetFlutterDirectory.childDirectory('bin').absolute.path,
        name: name,
        androidLanguage: androidLanguage,
        iosLanguage: iosLanguage,
        outputDirectory: result.generatedTargetTemplateDirectory!.absolute.path,
      );
    }
  }
}

/// Parses the metadata of the flutter project, extracts, computes, and stores the
/// revisions that the migration should use to migrate between.
class MigrateRevisions {
  MigrateRevisions({
    required MigrateContext context,
    required String? baseRevision,
    required bool allowFallbackBaseRevision,
    required List<SupportedPlatform> platforms,
    required FlutterToolsEnvironment environment,
  }) {
    _computeRevisions(context, baseRevision, allowFallbackBaseRevision,
        platforms, environment);
  }

  late List<String> revisionsList;
  late Map<String, List<MigratePlatformConfig>> revisionToConfigs;
  late String fallbackRevision;
  late String targetRevision;
  late String? metadataRevision;
  late MigrateConfig config;

  void _computeRevisions(
    MigrateContext context,
    String? baseRevision,
    bool allowFallbackBaseRevision,
    List<SupportedPlatform> platforms,
    FlutterToolsEnvironment environment,
  ) {
    final List<FlutterProjectComponent> components =
        <FlutterProjectComponent>[];
    for (final SupportedPlatform platform in platforms) {
      components.add(platform.toFlutterProjectComponent());
    }
    components.add(FlutterProjectComponent.root);
    final FlutterProjectMetadata metadata = FlutterProjectMetadata(
        context.flutterProject.directory.childFile('.metadata'),
        context.migrateLogger.logger);
    config = metadata.migrateConfig;

    // We call populate in case MigrateConfig is empty. If it is filled, populate should not do anything.
    config.populate(
      projectDirectory: context.flutterProject.directory,
      update: false,
      logger: context.migrateLogger.logger,
    );

    metadataRevision = metadata.versionRevision;
    if (environment.getString('FlutterVersion.frameworkRevision') == null) {
      throwToolExit('Flutter framework revision was null');
    }
    targetRevision = environment.getString('FlutterVersion.frameworkRevision')!;
    String rootBaseRevision = '';
    revisionToConfigs = <String, List<MigratePlatformConfig>>{};
    final Set<String> revisions = <String>{};
    if (baseRevision == null) {
      for (final MigratePlatformConfig platform
          in config.platformConfigs.values) {
        final String effectiveRevision = platform.baseRevision == null
            ? metadataRevision ??
                _getFallbackBaseRevision(
                    allowFallbackBaseRevision, context.migrateLogger)
            : platform.baseRevision!;
        if (!components.contains(platform.component)) {
          continue;
        }
        if (platform.component == FlutterProjectComponent.root) {
          rootBaseRevision = effectiveRevision;
        }
        revisions.add(effectiveRevision);
        if (revisionToConfigs[effectiveRevision] == null) {
          revisionToConfigs[effectiveRevision] = <MigratePlatformConfig>[];
        }
        revisionToConfigs[effectiveRevision]!.add(platform);
      }
    } else {
      rootBaseRevision = baseRevision;
      revisionToConfigs[baseRevision] = <MigratePlatformConfig>[];
      for (final FlutterProjectComponent component in components) {
        revisionToConfigs[baseRevision]!.add(MigratePlatformConfig(
            component: component, baseRevision: baseRevision));
      }
      // revisionToConfigs[baseRevision]!.add(
      //     MigratePlatformConfig(platform: null, baseRevision: baseRevision));
    }
    // Reorder such that the root revision is created first.
    revisions.remove(rootBaseRevision);
    revisionsList = List<String>.from(revisions);
    if (rootBaseRevision != '') {
      revisionsList.insert(0, rootBaseRevision);
    }
    context.migrateLogger
        .printIfVerbose('Potential base revisions: $revisionsList');
    fallbackRevision = _getFallbackBaseRevision(true, context.migrateLogger);
    if (revisionsList.contains(fallbackRevision) &&
        baseRevision != fallbackRevision &&
        metadataRevision != fallbackRevision) {
      context.migrateLogger.printStatus(
          'Using Flutter v1.0.0 ($fallbackRevision) as the base revision since a valid base revision could not be found in the .metadata file. This may result in more merge conflicts than normally expected.',
          indent: 4);
    }
  }
}