// 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 'dart:convert';
import 'dart:io' as io;

import 'package:process/process.dart';

import 'git.dart';
import 'globals.dart';
import 'repository.dart';
import 'stdio.dart';

/// A service for rolling the SDK's pub packages to latest and open a PR upstream.
class PackageAutoroller {
  PackageAutoroller({
    required this.githubClient,
    required this.token,
    required this.framework,
    required this.orgName,
    required this.processManager,
    required this.githubUsername,
    Stdio? stdio,
  }) {
    this.stdio = stdio ?? VerboseStdio.local();
    if (token.trim().isEmpty) {
      throw Exception('empty token!');
    }
    if (githubClient.trim().isEmpty) {
      throw Exception('Must provide path to GitHub client!');
    }
    if (orgName.trim().isEmpty) {
      throw Exception('Must provide an orgName!');
    }
  }

  late final Stdio stdio;

  final FrameworkRepository framework;
  final ProcessManager processManager;

  /// Path to GitHub CLI client.
  final String githubClient;

  final String githubUsername;

  /// GitHub API access token.
  final String token;

  static const String hostname = 'github.com';

  String get prBody {
    return '''
This PR was generated by the automated
[Pub packages autoroller](https://github.com/flutter/flutter/blob/main/dev/conductor/core/bin/packages_autoroller.dart).''';
  }

  /// Name of the feature branch to be opened on against the mirror repo.
  ///
  /// We never re-use a previous branch, so the branch name ends in an index
  /// number, which gets incremented for each roll.
  late final Future<String> featureBranchName = (() async {
    final List<String> remoteBranches = await framework.listRemoteBranches(framework.mirrorRemote!.name);

    int x = 1;
    String name(int index) => 'packages-autoroller-branch-$index';

    while (remoteBranches.contains(name(x))) {
      x += 1;
    }

    return name(x);
  })();

  void log(String message) {
    stdio.printStatus(_redactToken(message));
  }

  /// Name of the GitHub organization to push the feature branch to.
  final String orgName;

  Future<void> roll() async {
    try {
      await authLogin();
      final bool openPrAlready = await hasOpenPrs();
      if (openPrAlready) {
        // Don't open multiple roll PRs.
        return;
      }
      final bool didUpdate = await updatePackages();
      if (!didUpdate) {
        log('Packages are already at latest.');
        return;
      }
      await pushBranch();
      await createPr(repository: await framework.checkoutDirectory);
      await authLogout();
    } on Exception catch (exception) {
      final String message = _redactToken(exception.toString());
      throw Exception('${exception.runtimeType}: $message');
    }
  }

  // Ensure we don't leak the GitHub token in exception messages
  String _redactToken(String message) => message.replaceAll(token, '[GitHub TOKEN]');

  /// Attempt to update all pub packages.
  ///
  /// Will return whether or not any changes were made.
  Future<bool> updatePackages({
    bool verbose = true,
  }) async {
    final String author = '$githubUsername <$githubUsername@google.com>';

    await framework.newBranch(await featureBranchName);
    final io.Process flutterProcess = await framework.streamFlutter(<String>[
      if (verbose) '--verbose',
      'update-packages',
      '--force-upgrade',
    ]);
    final int exitCode = await flutterProcess.exitCode;
    if (exitCode != 0) {
      throw ConductorException('Failed to update packages with exit code $exitCode');
    }
    // If the git checkout is clean, then pub packages are already at latest that cleanly resolve.
    if (await framework.gitCheckoutClean()) {
      return false;
    }
    await framework.commit(
      'roll packages',
      addFirst: true,
      author: author,
    );
    return true;
  }

  Future<void> pushBranch() async {
    final String projectName = framework.mirrorRemote!.url.split(r'/').last;
    // Encode the token into the remote URL for authentication to work
    final String remote = 'https://$token@$hostname/$orgName/$projectName';
    await framework.pushRef(
      fromRef: await featureBranchName,
      toRef: await featureBranchName,
      remote: remote,
    );
  }

  Future<void> authLogout() {
    return cli(
      <String>['auth', 'logout', '--hostname', hostname],
      allowFailure: true,
    );
  }

  Future<void> authLogin() {
    return cli(
      <String>[
        'auth',
        'login',
        '--hostname',
        hostname,
        '--git-protocol',
        'https',
        '--with-token',
      ],
      stdin: '$token\n',
    );
  }

  static const String _prTitle = 'Roll pub packages';

  /// Create a pull request on GitHub.
  ///
  /// Depends on the gh cli tool.
  Future<void> createPr({
    required io.Directory repository,
    String body = 'This PR was generated by `flutter update-packages --force-upgrade`.',
    String base = FrameworkRepository.defaultBranch,
    bool draft = false,
  }) async {
    const List<String> labels = <String>['tool', 'autosubmit'];

    // We will wrap title and body in double quotes before delegating to gh
    // binary
    await cli(
      <String>[
        'pr',
        'create',
        '--title',
        _prTitle,
        '--body',
        body.trim(),
        '--head',
        '$orgName:${await featureBranchName}',
        '--base',
        base,
        for (final String label in labels) ...<String>['--label', label],
        if (draft)
          '--draft',
      ],
      workingDirectory: repository.path,
    );
  }

  Future<void> help([List<String>? args]) {
    return cli(<String>[
      'help',
      ...?args,
    ]);
  }

  /// Run a sub-process with the GitHub CLI client.
  ///
  /// Will return STDOUT of the sub-process.
  Future<String> cli(
    List<String> args, {
    bool allowFailure = false,
    String? stdin,
    String? workingDirectory,
  }) async {
    log('Executing "$githubClient ${args.join(' ')}" in $workingDirectory');
    final io.Process process = await processManager.start(
      <String>[githubClient, ...args],
      workingDirectory: workingDirectory,
      environment: <String, String>{},
    );
    final List<String> stderrStrings = <String>[];
    final List<String> stdoutStrings = <String>[];
    final Future<void> stdoutFuture = process.stdout
        .transform(utf8.decoder)
        .forEach(stdoutStrings.add);
    final Future<void> stderrFuture = process.stderr
        .transform(utf8.decoder)
        .forEach(stderrStrings.add);
    if (stdin != null) {
      process.stdin.write(stdin);
      await process.stdin.flush();
      await process.stdin.close();
    }
    final int exitCode = await process.exitCode;
    await Future.wait(<Future<Object?>>[
      stdoutFuture,
      stderrFuture,
    ]);
    final String stderr = stderrStrings.join();
    final String stdout = stdoutStrings.join();
    if (!allowFailure && exitCode != 0) {
      throw GitException(
        '$stderr\n$stdout',
        args,
      );
    }
    log(stdout);
    return stdout;
  }

  Future<bool> hasOpenPrs() async {
    // gh pr list --author christopherfujino --repo flutter/flutter --state open --json number
    final String openPrString = await cli(<String>[
      'pr',
      'list',

      '--author',
      githubUsername,

      '--repo',
      'flutter/flutter',

      '--state',
      'open',

      '--search',
      _prTitle,

      // Return structured JSON with the PR numbers of open PRs
      '--json',
      'number',
    ]);

    // This will be an array of objects, one for each open PR.
    final List<Object?> openPrs = json.decode(openPrString) as List<Object?>;

    // We are only interested in pub rolls, not devicelab flaky PRs
    if (openPrs.isNotEmpty) {
      log('$githubUsername already has open tool PRs:\n$openPrs');
      return true;
    }
    return false;
  }
}
