// 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 'dart:async';
import 'dart:io';
import 'dart:typed_data';

import 'package:process/process.dart';

import 'base/common.dart';
import 'base/file_system.dart';
import 'base/logger.dart';
import 'base/terminal.dart';

/// The default name of the migrate working directory used to stage proposed changes.
const String kDefaultMigrateStagingDirectoryName = 'migrate_staging_dir';

/// Utility class that contains methods that wrap git and other shell commands.
class MigrateUtils {
  MigrateUtils({
    required Logger logger,
    required FileSystem fileSystem,
    required ProcessManager processManager,
  })  : _processManager = processManager,
        _logger = logger,
        _fileSystem = fileSystem;

  final Logger _logger;
  final FileSystem _fileSystem;
  final ProcessManager _processManager;

  Future<ProcessResult> _runCommand(List<String> command,
      {String? workingDirectory, bool runInShell = false}) {
    return _processManager.run(command,
        workingDirectory: workingDirectory, runInShell: runInShell);
  }

  /// Calls `git diff` on two files and returns the diff as a DiffResult.
  Future<DiffResult> diffFiles(File one, File two) async {
    if (one.existsSync() && !two.existsSync()) {
      return DiffResult(diffType: DiffType.deletion);
    }
    if (!one.existsSync() && two.existsSync()) {
      return DiffResult(diffType: DiffType.addition);
    }
    final List<String> cmdArgs = <String>[
      'git',
      'diff',
      '--no-index',
      one.absolute.path,
      two.absolute.path
    ];
    final ProcessResult result = await _runCommand(cmdArgs);

    // diff exits with 1 if diffs are found.
    checkForErrors(result,
        allowedExitCodes: <int>[0, 1],
        commandDescription: 'git ${cmdArgs.join(' ')}');
    return DiffResult(
        diffType: DiffType.command,
        diff: result.stdout as String,
        exitCode: result.exitCode);
  }

  /// Clones a copy of the flutter repo into the destination directory. Returns false if unsuccessful.
  Future<bool> cloneFlutter(String revision, String destination) async {
    // Use https url instead of ssh to avoid need to setup ssh on git.
    List<String> cmdArgs = <String>[
      'git',
      'clone',
      '--filter=blob:none',
      'https://github.com/flutter/flutter.git',
      destination
    ];
    ProcessResult result = await _runCommand(cmdArgs);
    checkForErrors(result, commandDescription: cmdArgs.join(' '));

    cmdArgs.clear();
    cmdArgs = <String>['git', 'reset', '--hard', revision];
    result = await _runCommand(cmdArgs, workingDirectory: destination);
    if (!checkForErrors(result,
        commandDescription: cmdArgs.join(' '), exit: false)) {
      return false;
    }
    return true;
  }

  /// Calls `flutter create` as a re-entrant command.
  Future<String> createFromTemplates(
    String flutterBinPath, {
    required String name,
    bool legacyNameParameter = false,
    required String androidLanguage,
    required String iosLanguage,
    required String outputDirectory,
    String? createVersion,
    List<String> platforms = const <String>[],
    int iterationsAllowed = 5,
  }) async {
    // Limit the number of iterations this command is allowed to attempt to prevent infinite looping.
    if (iterationsAllowed <= 0) {
      _logger.printError(
          'Unable to `flutter create` with the version of flutter at $flutterBinPath');
      return outputDirectory;
    }

    final List<String> cmdArgs = <String>['$flutterBinPath/flutter', 'create'];
    if (!legacyNameParameter) {
      cmdArgs.add('--project-name=$name');
    }
    cmdArgs.add('--android-language=$androidLanguage');
    cmdArgs.add('--ios-language=$iosLanguage');
    if (platforms.isNotEmpty) {
      String platformsArg = '--platforms=';
      for (int i = 0; i < platforms.length; i++) {
        if (i > 0) {
          platformsArg += ',';
        }
        platformsArg += platforms[i];
      }
      cmdArgs.add(platformsArg);
    }
    cmdArgs.add('--no-pub');
    if (legacyNameParameter) {
      cmdArgs.add(name);
    } else {
      cmdArgs.add(outputDirectory);
    }
    final ProcessResult result =
        await _runCommand(cmdArgs, workingDirectory: outputDirectory);
    final String error = result.stderr as String;

    // Catch errors due to parameters not existing.

    // Old versions of the tool does not include the platforms option.
    if (error.contains('Could not find an option named "platforms".')) {
      return createFromTemplates(
        flutterBinPath,
        name: name,
        legacyNameParameter: legacyNameParameter,
        androidLanguage: androidLanguage,
        iosLanguage: iosLanguage,
        outputDirectory: outputDirectory,
        iterationsAllowed: iterationsAllowed--,
      );
    }
    // Old versions of the tool does not include the project-name option.
    if ((result.stderr as String)
        .contains('Could not find an option named "project-name".')) {
      return createFromTemplates(
        flutterBinPath,
        name: name,
        legacyNameParameter: true,
        androidLanguage: androidLanguage,
        iosLanguage: iosLanguage,
        outputDirectory: outputDirectory,
        platforms: platforms,
        iterationsAllowed: iterationsAllowed--,
      );
    }
    if (error.contains('Multiple output directories specified.')) {
      if (error.contains('Try moving --platforms')) {
        return createFromTemplates(
          flutterBinPath,
          name: name,
          legacyNameParameter: legacyNameParameter,
          androidLanguage: androidLanguage,
          iosLanguage: iosLanguage,
          outputDirectory: outputDirectory,
          iterationsAllowed: iterationsAllowed--,
        );
      }
    }
    checkForErrors(result, commandDescription: cmdArgs.join(' '), silent: true);

    if (legacyNameParameter) {
      return _fileSystem.path.join(outputDirectory, name);
    }
    return outputDirectory;
  }

  /// Runs the git 3-way merge on three files and returns the results as a MergeResult.
  ///
  /// Passing the same path for base and current will perform a two-way fast forward merge.
  Future<MergeResult> gitMergeFile({
    required String base,
    required String current,
    required String target,
    required String localPath,
  }) async {
    final List<String> cmdArgs = <String>[
      'git',
      'merge-file',
      '-p',
      current,
      base,
      target
    ];
    final ProcessResult result = await _runCommand(cmdArgs);
    checkForErrors(result,
        allowedExitCodes: <int>[-1], commandDescription: cmdArgs.join(' '));
    return StringMergeResult(result, localPath);
  }

  /// Calls `git init` on the workingDirectory.
  Future<void> gitInit(String workingDirectory) async {
    final List<String> cmdArgs = <String>['git', 'init'];
    final ProcessResult result =
        await _runCommand(cmdArgs, workingDirectory: workingDirectory);
    checkForErrors(result, commandDescription: cmdArgs.join(' '));
  }

  /// Returns true if the workingDirectory git repo has any uncommited changes.
  Future<bool> hasUncommittedChanges(String workingDirectory,
      {String? migrateStagingDir}) async {
    final List<String> cmdArgs = <String>[
      'git',
      'ls-files',
      '--deleted',
      '--modified',
      '--others',
      '--exclude-standard',
      '--exclude=${migrateStagingDir ?? kDefaultMigrateStagingDirectoryName}'
    ];
    final ProcessResult result =
        await _runCommand(cmdArgs, workingDirectory: workingDirectory);
    checkForErrors(result,
        allowedExitCodes: <int>[-1], commandDescription: cmdArgs.join(' '));
    if ((result.stdout as String).isEmpty) {
      return false;
    }
    return true;
  }

  /// Returns true if the workingDirectory is a git repo.
  Future<bool> isGitRepo(String workingDirectory) async {
    final List<String> cmdArgs = <String>[
      'git',
      'rev-parse',
      '--is-inside-work-tree'
    ];
    final ProcessResult result =
        await _runCommand(cmdArgs, workingDirectory: workingDirectory);
    checkForErrors(result,
        allowedExitCodes: <int>[-1], commandDescription: cmdArgs.join(' '));
    if (result.exitCode == 0) {
      return true;
    }
    return false;
  }

  /// Returns true if the file at `filePath` is covered by the `.gitignore`
  Future<bool> isGitIgnored(String filePath, String workingDirectory) async {
    final List<String> cmdArgs = <String>['git', 'check-ignore', filePath];
    final ProcessResult result =
        await _runCommand(cmdArgs, workingDirectory: workingDirectory);
    checkForErrors(result,
        allowedExitCodes: <int>[0, 1, 128],
        commandDescription: cmdArgs.join(' '));
    return result.exitCode == 0;
  }

  /// Runs `flutter pub upgrade --major-revisions`.
  Future<void> flutterPubUpgrade(String workingDirectory) async {
    final List<String> cmdArgs = <String>[
      'flutter',
      'pub',
      'upgrade',
      '--major-versions'
    ];
    final ProcessResult result =
        await _runCommand(cmdArgs, workingDirectory: workingDirectory);
    checkForErrors(result, commandDescription: cmdArgs.join(' '));
  }

  /// Runs `./gradlew tasks` in the android directory of a flutter project.
  Future<void> gradlewTasks(String workingDirectory) async {
    final String baseCommand = isWindows ? 'gradlew.bat' : './gradlew';
    final List<String> cmdArgs = <String>[baseCommand, 'tasks'];
    final ProcessResult result = await _runCommand(cmdArgs,
        workingDirectory: workingDirectory, runInShell: isWindows);
    checkForErrors(result, commandDescription: cmdArgs.join(' '));
  }

  /// Verifies that the ProcessResult does not contain an error.
  ///
  /// If an error is detected, the error can be optionally logged or exit the tool.
  ///
  /// Passing -1 in allowedExitCodes means all exit codes are valid.
  bool checkForErrors(ProcessResult result,
      {List<int> allowedExitCodes = const <int>[0],
      String? commandDescription,
      bool exit = true,
      bool silent = false}) {
    if (allowedExitCodes.contains(result.exitCode) ||
        allowedExitCodes.contains(-1)) {
      return true;
    }
    if (!silent) {
      _logger.printError(
          'Command encountered an error with exit code ${result.exitCode}.');
      if (commandDescription != null) {
        _logger.printError('Command:');
        _logger.printError(commandDescription, indent: 2);
      }
      _logger.printError('Stdout:');
      _logger.printError(result.stdout as String, indent: 2);
      _logger.printError('Stderr:');
      _logger.printError(result.stderr as String, indent: 2);
    }
    if (exit) {
      throwToolExit(
          'Command failed with exit code ${result.exitCode}: ${result.stderr}\n${result.stdout}',
          exitCode: result.exitCode);
    }
    return false;
  }

  /// Returns true if the file does not contain any git conflit markers.
  bool conflictsResolved(String contents) {
    final bool hasMarker = contents.contains('>>>>>>>') ||
        contents.contains('=======') ||
        contents.contains('<<<<<<<');
    return !hasMarker;
  }
}

Future<bool> gitRepoExists(
    String projectDirectory, Logger logger, MigrateUtils migrateUtils) async {
  if (await migrateUtils.isGitRepo(projectDirectory)) {
    return true;
  }
  logger.printStatus(
      'Project is not a git repo. Please initialize a git repo and try again.');
  printCommand('git init', logger);
  return false;
}

Future<bool> hasUncommittedChanges(
    String projectDirectory, Logger logger, MigrateUtils migrateUtils) async {
  if (await migrateUtils.hasUncommittedChanges(projectDirectory)) {
    logger.printStatus(
        'There are uncommitted changes in your project. Please git commit, abandon, or stash your changes before trying again.');
    logger.printStatus('You may commit your changes using');
    printCommand('git add .', logger, newlineAfter: false);
    printCommand('git commit -m "<message>"', logger);
    return true;
  }
  return false;
}

void printCommand(String command, Logger logger, {bool newlineAfter = true}) {
  logger.printStatus(
    '\n\$ $command${newlineAfter ? '\n' : ''}',
    color: TerminalColor.grey,
    indent: 4,
    newline: false,
  );
}

/// Prints a command to logger with appropriate formatting.
void printCommandText(String command, Logger logger,
    {bool? standalone = true, bool newlineAfter = true}) {
  final String prefix = standalone == null
      ? ''
      : (standalone
          ? 'dart run <flutter_migrate_dir>${Platform.pathSeparator}bin${Platform.pathSeparator}flutter_migrate.dart '
          : 'flutter migrate ');
  printCommand('$prefix$command', logger, newlineAfter: newlineAfter);
}

/// Defines the classification of difference between files.
enum DiffType {
  command,
  addition,
  deletion,
  ignored,
  none,
}

/// Tracks the output of a git diff command or any special cases such as addition of a new
/// file or deletion of an existing file.
class DiffResult {
  DiffResult({
    required this.diffType,
    this.diff,
    this.exitCode,
  }) : assert(diffType == DiffType.command && exitCode != null ||
            diffType != DiffType.command && exitCode == null);

  /// The diff string output by git.
  final String? diff;

  final DiffType diffType;

  /// The exit code of the command. This is zero when no diffs are found.
  ///
  /// The exitCode is null when the diffType is not `command`.
  final int? exitCode;
}

/// Data class to hold the results of a merge.
abstract class MergeResult {
  /// Initializes a MergeResult based off of a ProcessResult.
  MergeResult(ProcessResult result, this.localPath)
      : hasConflict = result.exitCode != 0,
        exitCode = result.exitCode;

  /// Manually initializes a MergeResult with explicit values.
  MergeResult.explicit({
    required this.hasConflict,
    required this.exitCode,
    required this.localPath,
  });

  /// True when there is a merge conflict.
  bool hasConflict;

  /// The exitcode of the merge command.
  int exitCode;

  /// The local path relative to the project root of the file.
  String localPath;
}

/// The results of a string merge.
class StringMergeResult extends MergeResult {
  /// Initializes a BinaryMergeResult based off of a ProcessResult.
  StringMergeResult(super.result, super.localPath)
      : mergedString = result.stdout as String;

  /// Manually initializes a StringMergeResult with explicit values.
  StringMergeResult.explicit({
    required this.mergedString,
    required super.hasConflict,
    required super.exitCode,
    required super.localPath,
  }) : super.explicit();

  /// The final merged string.
  String mergedString;
}

/// The results of a binary merge.
class BinaryMergeResult extends MergeResult {
  /// Initializes a BinaryMergeResult based off of a ProcessResult.
  BinaryMergeResult(super.result, super.localPath)
      : mergedBytes = result.stdout as Uint8List;

  /// Manually initializes a BinaryMergeResult with explicit values.
  BinaryMergeResult.explicit({
    required this.mergedBytes,
    required super.hasConflict,
    required super.exitCode,
    required super.localPath,
  }) : super.explicit();

  /// The final merged bytes.
  Uint8List mergedBytes;
}