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

import 'package:args/command_runner.dart';
import 'package:file/file.dart' show File;
import 'package:meta/meta.dart' show visibleForTesting;

import 'context.dart';
import 'git.dart';
import 'globals.dart';
import 'proto/conductor_state.pb.dart' as pb;
import 'proto/conductor_state.pbenum.dart';
import 'repository.dart';
import 'state.dart' as state_import;

const String kStateOption = 'state-file';
const String kYesFlag = 'yes';

/// Command to proceed from one [pb.ReleasePhase] to the next.
///
/// After `conductor start`, the rest of the release steps are initiated by the
/// user via `conductor next`. Thus this command's behavior is conditional upon
/// which phase of the release the user is currently in. This is implemented
/// with a switch case statement.
class NextCommand extends Command<void> {
  NextCommand({
    required this.checkouts,
  }) {
    final String defaultPath = state_import.defaultStateFilePath(checkouts.platform);
    argParser.addOption(
      kStateOption,
      defaultsTo: defaultPath,
      help: 'Path to persistent state file. Defaults to $defaultPath',
    );
    argParser.addFlag(
      kYesFlag,
      help: 'Auto-accept any confirmation prompts.',
      hide: true, // primarily for integration testing
    );
    argParser.addFlag(
      kForceFlag,
      help: 'Force push when updating remote git branches.',
    );
  }

  final Checkouts checkouts;

  @override
  String get name => 'next';

  @override
  String get description => 'Proceed to the next release phase.';

  @override
  Future<void> run() async {
    final File stateFile = checkouts.fileSystem.file(argResults![kStateOption]);
    if (!stateFile.existsSync()) {
      throw ConductorException(
          'No persistent state file found at ${stateFile.path}.',
      );
    }
    final pb.ConductorState state = state_import.readStateFromFile(stateFile);

    await NextContext(
      autoAccept: argResults![kYesFlag] as bool,
      checkouts: checkouts,
      force: argResults![kForceFlag] as bool,
      stateFile: stateFile,
    ).run(state);
  }
}

/// Utility class for proceeding to the next step in a release.
///
/// Any calls to functions that cause side effects are wrapped in methods to
/// allow overriding in unit tests.
class NextContext extends Context {
  const NextContext({
    required this.autoAccept,
    required this.force,
    required super.checkouts,
    required super.stateFile,
  });

  final bool autoAccept;
  final bool force;

  Future<void> run(pb.ConductorState state) async {
    const List<CherrypickState> finishedStates = <CherrypickState>[
      CherrypickState.COMPLETED,
      CherrypickState.ABANDONED,
    ];
    switch (state.currentPhase) {
      case pb.ReleasePhase.APPLY_ENGINE_CHERRYPICKS:
        final Remote upstream = Remote(
            name: RemoteName.upstream,
            url: state.engine.upstream.url,
        );
        final EngineRepository engine = EngineRepository(
            checkouts,
            initialRef: state.engine.workingBranch,
            upstreamRemote: upstream,
            previousCheckoutLocation: state.engine.checkoutPath,
        );
        if (!state_import.requiresEnginePR(state)) {
          stdio.printStatus(
              'This release has no engine cherrypicks. No Engine PR is necessary.\n',
          );
          break;
        }

        final List<pb.Cherrypick> unappliedCherrypicks = <pb.Cherrypick>[];
        for (final pb.Cherrypick cherrypick in state.engine.cherrypicks) {
          if (!finishedStates.contains(cherrypick.state)) {
            unappliedCherrypicks.add(cherrypick);
          }
        }

        if (unappliedCherrypicks.isEmpty) {
          stdio.printStatus('All engine cherrypicks have been auto-applied by the conductor.\n');
        } else {
          if (unappliedCherrypicks.length == 1) {
            stdio.printStatus('There was ${unappliedCherrypicks.length} cherrypick that was not auto-applied.');
          } else {
            stdio.printStatus('There were ${unappliedCherrypicks.length} cherrypicks that were not auto-applied.');
          }
          stdio.printStatus('These must be applied manually in the directory '
              '${state.engine.checkoutPath} before proceeding.\n');
        }
        if (autoAccept == false) {
          final bool response = await prompt(
            'Are you ready to push your engine branch to the repository '
            '${state.engine.mirror.url}?',
          );
          if (!response) {
            stdio.printError('Aborting command.');
            updateState(state, stdio.logs);
            return;
          }
        }

        await pushWorkingBranch(engine, state.engine);
        break;
      case pb.ReleasePhase.CODESIGN_ENGINE_BINARIES:
        stdio.printStatus(<String>[
          'You must validate pre-submit CI for your engine PR, merge it, and codesign',
          'binaries before proceeding.\n',
        ].join('\n'));
        if (autoAccept == false) {
          // TODO(fujino): actually test if binaries have been codesigned on macOS
          final bool response = await prompt(
            'Has CI passed for the engine PR and binaries been codesigned?',
          );
          if (!response) {
            stdio.printError('Aborting command.');
            updateState(state, stdio.logs);
            return;
          }
        }
        break;
      case pb.ReleasePhase.APPLY_FRAMEWORK_CHERRYPICKS:
        final Remote engineUpstreamRemote = Remote(
            name: RemoteName.upstream,
            url: state.engine.upstream.url,
        );
        final EngineRepository engine = EngineRepository(
            checkouts,
            // We explicitly want to check out the merged version from upstream
            initialRef: '${engineUpstreamRemote.name}/${state.engine.candidateBranch}',
            upstreamRemote: engineUpstreamRemote,
            previousCheckoutLocation: state.engine.checkoutPath,
        );

        final String engineRevision = await engine.reverseParse('HEAD');

        final Remote upstream = Remote(
          name: RemoteName.upstream,
          url: state.framework.upstream.url,
        );
        final FrameworkRepository framework = FrameworkRepository(
          checkouts,
          initialRef: state.framework.workingBranch,
          upstreamRemote: upstream,
          previousCheckoutLocation: state.framework.checkoutPath,
        );
        stdio.printStatus('Writing candidate branch...');
        bool needsCommit = await framework.updateCandidateBranchVersion(state.framework.candidateBranch);
        if (needsCommit) {
          final String revision = await framework.commit(
              'Create candidate branch version ${state.framework.candidateBranch} for ${state.releaseChannel}',
              addFirst: true,
          );
          // append to list of cherrypicks so we know a PR is required
          state.framework.cherrypicks.add(pb.Cherrypick(
                  appliedRevision: revision,
                  state: pb.CherrypickState.COMPLETED,
          ));
        }
        stdio.printStatus('Rolling new engine hash $engineRevision to framework checkout...');
        needsCommit = await framework.updateEngineRevision(engineRevision);
        if (needsCommit) {
          final String revision = await framework.commit(
              'Update Engine revision to $engineRevision for ${state.releaseChannel} release ${state.releaseVersion}',
              addFirst: true,
          );
          // append to list of cherrypicks so we know a PR is required
          state.framework.cherrypicks.add(pb.Cherrypick(
                  appliedRevision: revision,
                  state: pb.CherrypickState.COMPLETED,
          ));
        }

        final List<pb.Cherrypick> unappliedCherrypicks = <pb.Cherrypick>[];
        for (final pb.Cherrypick cherrypick in state.framework.cherrypicks) {
          if (!finishedStates.contains(cherrypick.state)) {
            unappliedCherrypicks.add(cherrypick);
          }
        }

        if (state.framework.cherrypicks.isEmpty) {
          stdio.printStatus(
              'This release has no framework cherrypicks. However, a framework PR is still\n'
              'required to roll engine cherrypicks.',
          );
        } else if (unappliedCherrypicks.isEmpty) {
          stdio.printStatus('All framework cherrypicks were auto-applied by the conductor.');
        } else {
          if (unappliedCherrypicks.length == 1) {
            stdio.printStatus('There was ${unappliedCherrypicks.length} cherrypick that was not auto-applied.',);
          }
          else {
            stdio.printStatus('There were ${unappliedCherrypicks.length} cherrypicks that were not auto-applied.',);
          }
          stdio.printStatus(
              'These must be applied manually in the directory '
              '${state.framework.checkoutPath} before proceeding.\n',
          );
        }

        if (autoAccept == false) {
          final bool response = await prompt(
            'Are you ready to push your framework branch to the repository '
            '${state.framework.mirror.url}?',
          );
          if (!response) {
            stdio.printError('Aborting command.');
            updateState(state, stdio.logs);
            return;
          }
        }

        await pushWorkingBranch(framework, state.framework);
        break;
      case pb.ReleasePhase.PUBLISH_VERSION:
        stdio.printStatus('Please ensure that you have merged your framework PR and that');
        stdio.printStatus('post-submit CI has finished successfully.\n');
        final Remote frameworkUpstream = Remote(
            name: RemoteName.upstream,
            url: state.framework.upstream.url,
        );
        final FrameworkRepository framework = FrameworkRepository(
            checkouts,
            // We explicitly want to check out the merged version from upstream
            initialRef: '${frameworkUpstream.name}/${state.framework.candidateBranch}',
            upstreamRemote: frameworkUpstream,
            previousCheckoutLocation: state.framework.checkoutPath,
        );
        final String frameworkHead = await framework.reverseParse('HEAD');
        final Remote engineUpstream = Remote(
            name: RemoteName.upstream,
            url: state.engine.upstream.url,
        );
        final EngineRepository engine = EngineRepository(
            checkouts,
            // We explicitly want to check out the merged version from upstream
            initialRef: '${engineUpstream.name}/${state.engine.candidateBranch}',
            upstreamRemote: engineUpstream,
            previousCheckoutLocation: state.engine.checkoutPath,
        );
        final String engineHead = await engine.reverseParse('HEAD');
        if (autoAccept == false) {
          final bool response = await prompt(
            'Are you ready to tag commit $frameworkHead as ${state.releaseVersion}\n'
            'and push to remote ${state.framework.upstream.url}?',
          );
          if (!response) {
            stdio.printError('Aborting command.');
            updateState(state, stdio.logs);
            return;
          }
        }
        await framework.tag(frameworkHead, state.releaseVersion, frameworkUpstream.name);
        await engine.tag(engineHead, state.releaseVersion, engineUpstream.name);
        break;
      case pb.ReleasePhase.PUBLISH_CHANNEL:
        final Remote upstream = Remote(
            name: RemoteName.upstream,
            url: state.framework.upstream.url,
        );
        final FrameworkRepository framework = FrameworkRepository(
            checkouts,
            // We explicitly want to check out the merged version from upstream
            initialRef: '${upstream.name}/${state.framework.candidateBranch}',
            upstreamRemote: upstream,
            previousCheckoutLocation: state.framework.checkoutPath,
        );
        final String headRevision = await framework.reverseParse('HEAD');
        if (autoAccept == false) {
          // dryRun: true means print out git command
          await framework.pushRef(
              fromRef: headRevision,
              toRef: state.releaseChannel,
              remote: state.framework.upstream.url,
              force: force,
              dryRun: true,
          );

          final bool response = await prompt(
            'Are you ready to publish version ${state.releaseVersion} to ${state.releaseChannel}?',
          );
          if (!response) {
            stdio.printError('Aborting command.');
            updateState(state, stdio.logs);
            return;
          }
        }
        await framework.pushRef(
            fromRef: headRevision,
            toRef: state.releaseChannel,
            remote: state.framework.upstream.url,
            force: force,
        );
        break;
      case pb.ReleasePhase.VERIFY_RELEASE:
        stdio.printStatus(
            'The current status of packaging builds can be seen at:\n'
            '\t$kLuciPackagingConsoleLink',
        );
        if (autoAccept == false) {
          final bool response = await prompt(
              'Have all packaging builds finished successfully and post release announcements been completed?');
          if (!response) {
            stdio.printError('Aborting command.');
            updateState(state, stdio.logs);
            return;
          }
        }
        break;
      case pb.ReleasePhase.RELEASE_COMPLETED:
        throw ConductorException('This release is finished.');
    }
    final ReleasePhase nextPhase = state_import.getNextPhase(state.currentPhase);
    stdio.printStatus('\nUpdating phase from ${state.currentPhase} to $nextPhase...\n');
    state.currentPhase = nextPhase;
    stdio.printStatus(state_import.phaseInstructions(state));

    updateState(state, stdio.logs);
  }

  /// Push the working branch to the user's mirror.
  ///
  /// [repository] represents the actual Git repository on disk, and is used to
  /// call `git push`, while [pbRepository] represents the user-specified
  /// configuration for the repository, and is used to read the name of the
  /// working branch and the mirror's remote name.
  ///
  /// May throw either a [ConductorException] if the user already has a branch
  /// of the same name on their mirror, or a [GitException] for any other
  /// failures from the underlying git process call.
  @visibleForTesting
  Future<void> pushWorkingBranch(Repository repository, pb.Repository pbRepository) async {
    try {
      await repository.pushRef(
          fromRef: 'HEAD',
          // Explicitly create new branch
          toRef: 'refs/heads/${pbRepository.workingBranch}',
          remote: pbRepository.mirror.name,
          force: force,
      );
    } on GitException catch (exception) {
      if (exception.type == GitExceptionType.PushRejected && force == false) {
        throw ConductorException(
          'Push failed because the working branch named '
          '${pbRepository.workingBranch} already exists on your mirror. '
          'Re-run this command with --force to overwrite the remote branch.\n'
          '${exception.message}',
        );
      }
      rethrow;
    }
  }
}
