// 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:async';
import 'dart:convert' show Encoding, json;
import 'dart:io';

import 'package:file/file.dart';
import 'package:file/local.dart';
import 'package:http/http.dart';
import 'package:logging/logging.dart';
import 'package:meta/meta.dart';

import 'task_result.dart';
import 'utils.dart';

typedef ProcessRunSync = ProcessResult Function(
  String,
  List<String>, {
  Map<String, String>? environment,
  bool includeParentEnvironment,
  bool runInShell,
  Encoding? stderrEncoding,
  Encoding? stdoutEncoding,
  String? workingDirectory,
});

/// Class for test runner to interact with Flutter's infrastructure service, Cocoon.
///
/// Cocoon assigns bots to run these devicelab tasks on real devices.
/// To retrieve these results, the test runner needs to send results back so the database can be updated.
class Cocoon {
  Cocoon({
    String? serviceAccountTokenPath,
    @visibleForTesting Client? httpClient,
    @visibleForTesting this.fs = const LocalFileSystem(),
    @visibleForTesting this.processRunSync = Process.runSync,
    @visibleForTesting this.requestRetryLimit = 5,
    @visibleForTesting this.requestTimeoutLimit = 30,
  }) : _httpClient = AuthenticatedCocoonClient(serviceAccountTokenPath, httpClient: httpClient, filesystem: fs);

  /// Client to make http requests to Cocoon.
  final AuthenticatedCocoonClient _httpClient;

  final ProcessRunSync processRunSync;

  /// Url used to send results to.
  static const String baseCocoonApiUrl = 'https://flutter-dashboard.appspot.com/api';

  /// Threshold to auto retry a failed test.
  static const int retryNumber = 2;

  /// Underlying [FileSystem] to use.
  final FileSystem fs;

  static final Logger logger = Logger('CocoonClient');

  @visibleForTesting
  final int requestRetryLimit;

  @visibleForTesting
  final int requestTimeoutLimit;

  String get commitSha => _commitSha ?? _readCommitSha();
  String? _commitSha;

  /// Parse the local repo for the current running commit.
  String _readCommitSha() {
    final ProcessResult result = processRunSync('git', <String>['rev-parse', 'HEAD']);
    if (result.exitCode != 0) {
      throw CocoonException(result.stderr as String);
    }

    return _commitSha = result.stdout as String;
  }

  /// Update test status to Cocoon.
  ///
  /// Flutter infrastructure's workflow is:
  /// 1. Run DeviceLab test
  /// 2. Request service account token from luci auth (valid for at least 3 minutes)
  /// 3. Update test status from (1) to Cocoon
  ///
  /// The `resultsPath` is not available for all tests. When it doesn't show up, we
  /// need to append `CommitBranch`, `CommitSha`, and `BuilderName`.
  Future<void> sendTaskStatus({
    String? resultsPath,
    bool? isTestFlaky,
    String? gitBranch,
    String? builderName,
    String? testStatus,
    String? builderBucket,
  }) async {
    Map<String, dynamic> resultsJson = <String, dynamic>{};
    if (resultsPath != null) {
      final File resultFile = fs.file(resultsPath);
      resultsJson = json.decode(await resultFile.readAsString()) as Map<String, dynamic>;
    } else {
      resultsJson['CommitBranch'] = gitBranch;
      resultsJson['CommitSha'] = commitSha;
      resultsJson['BuilderName'] = builderName;
      resultsJson['NewStatus'] = testStatus;
    }
    resultsJson['TestFlaky'] = isTestFlaky ?? false;
    if (_shouldUpdateCocoon(resultsJson, builderBucket ?? 'prod')) {
      await retry(
        () async => _sendUpdateTaskRequest(resultsJson).timeout(Duration(seconds: requestTimeoutLimit)),
        retryIf: (Exception e) => e is SocketException || e is TimeoutException || e is ClientException,
        maxAttempts: requestRetryLimit,
      );
    }
  }

  /// Only post-submit tests on `master` are allowed to update in cocoon.
  bool _shouldUpdateCocoon(Map<String, dynamic> resultJson, String builderBucket) {
    const List<String> supportedBranches = <String>['master'];
    return supportedBranches.contains(resultJson['CommitBranch']) && builderBucket == 'prod';
  }

  /// Write the given parameters into an update task request and store the JSON in [resultsPath].
  Future<void> writeTaskResultToFile({
    String? builderName,
    String? gitBranch,
    required TaskResult result,
    required String resultsPath,
  }) async {
    final Map<String, dynamic> updateRequest = _constructUpdateRequest(
      gitBranch: gitBranch,
      builderName: builderName,
      result: result,
    );
    final File resultFile = fs.file(resultsPath);
    if (resultFile.existsSync()) {
      resultFile.deleteSync();
    }
    logger.fine('Writing results: ${json.encode(updateRequest)}');
    resultFile.createSync();
    resultFile.writeAsStringSync(json.encode(updateRequest));
  }

  Map<String, dynamic> _constructUpdateRequest({
    String? builderName,
    required TaskResult result,
    String? gitBranch,
  }) {
    final Map<String, dynamic> updateRequest = <String, dynamic>{
      'CommitBranch': gitBranch,
      'CommitSha': commitSha,
      'BuilderName': builderName,
      'NewStatus': result.succeeded ? 'Succeeded' : 'Failed',
    };
    logger.fine('Update request: $updateRequest');

    // Make a copy of result data because we may alter it for validation below.
    updateRequest['ResultData'] = result.data;

    final List<String> validScoreKeys = <String>[];
    if (result.benchmarkScoreKeys != null) {
      for (final String scoreKey in result.benchmarkScoreKeys!) {
        final Object score = result.data![scoreKey] as Object;
        if (score is num) {
          // Convert all metrics to double, which provide plenty of precision
          // without having to add support for multiple numeric types in Cocoon.
          result.data![scoreKey] = score.toDouble();
          validScoreKeys.add(scoreKey);
        }
      }
    }
    updateRequest['BenchmarkScoreKeys'] = validScoreKeys;

    return updateRequest;
  }

  Future<void> _sendUpdateTaskRequest(Map<String, dynamic> postBody) async {
    logger.info('Attempting to send update task request to Cocoon.');
    final Map<String, dynamic> response = await _sendCocoonRequest('update-task-status', postBody);
    if (response['Name'] != null) {
      logger.info('Updated Cocoon with results from this task');
    } else {
      logger.info(response);
      logger.severe('Failed to updated Cocoon with results from this task');
    }
  }

  /// Make an API request to Cocoon.
  Future<Map<String, dynamic>> _sendCocoonRequest(String apiPath, [dynamic jsonData]) async {
    final Uri url = Uri.parse('$baseCocoonApiUrl/$apiPath');

    /// Retry requests to Cocoon as sometimes there are issues with the servers, such
    /// as version changes to the backend, datastore issues, or latency issues.
    final Response response = await retry(
      () => _httpClient.post(url, body: json.encode(jsonData)),
      retryIf: (Exception e) => e is SocketException || e is TimeoutException || e is ClientException,
      maxAttempts: requestRetryLimit,
    );
    return json.decode(response.body) as Map<String, dynamic>;
  }
}

/// [HttpClient] for sending authenticated requests to Cocoon.
class AuthenticatedCocoonClient extends BaseClient {
  AuthenticatedCocoonClient(
    this._serviceAccountTokenPath, {
    @visibleForTesting Client? httpClient,
    @visibleForTesting FileSystem? filesystem,
  })  : _delegate = httpClient ?? Client(),
        _fs = filesystem ?? const LocalFileSystem();

  /// Authentication token to have the ability to upload and record test results.
  ///
  /// This is intended to only be passed on automated runs on LUCI post-submit.
  final String? _serviceAccountTokenPath;

  /// Underlying [HttpClient] to send requests to.
  final Client _delegate;

  /// Underlying [FileSystem] to use.
  final FileSystem _fs;

  /// Value contained in the service account token file that can be used in http requests.
  String get serviceAccountToken => _serviceAccountToken ?? _readServiceAccountTokenFile();
  String? _serviceAccountToken;

  /// Get [serviceAccountToken] from the given service account file.
  String _readServiceAccountTokenFile() {
    return _serviceAccountToken = _fs.file(_serviceAccountTokenPath).readAsStringSync().trim();
  }

  @override
  Future<StreamedResponse> send(BaseRequest request) async {
    request.headers['Service-Account-Token'] = serviceAccountToken;
    final StreamedResponse response = await _delegate.send(request);

    if (response.statusCode != 200) {
      throw ClientException(
          'AuthenticatedClientError:\n'
          '  URI: ${request.url}\n'
          '  HTTP Status: ${response.statusCode}\n'
          '  Response body:\n'
          '${(await Response.fromStream(response)).body}',
          request.url);
    }
    return response;
  }
}

class CocoonException implements Exception {
  CocoonException(this.message);

  /// The message to show to the issuer to explain the error.
  final String message;

  @override
  String toString() => 'CocoonException: $message';
}