// 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';

import 'tool_subsharding.dart';

/// Reads and decodes the `LUCI_CONTEXT` JSON file, or returns null if it is not
/// set or cannot be read.
///
/// `LUCI_CONTEXT` is a file path (in the `LUCI_CONTEXT` environment variable)
/// pointing at a JSON document that LUCI populates for the running build.
Map<String, Object?>? readLuciContext([Map<String, String>? environment]) {
  final Map<String, String> env = environment ?? Platform.environment;
  final String? luciContextPath = env['LUCI_CONTEXT'];
  if (luciContextPath == null || luciContextPath.isEmpty) {
    return null;
  }
  final file = File(luciContextPath);
  if (!file.existsSync()) {
    return null;
  }
  // Be defensive: an unreadable file or malformed JSON must never crash the
  // test run, since this is called outside the reporting try/catch.
  try {
    if (json.decode(file.readAsStringSync()) case final Map<String, Object?> decoded) {
      return decoded;
    }
    return null;
  } catch (_) {
    return null;
  }
}

/// A client for the [ResultDB Recorder][recorder] `BatchCreateTestResults` API.
///
/// This uploads test results directly to the build's ResultDB invocation using
/// the `update_token` from `LUCI_CONTEXT["resultdb"]["current_invocation"]`.
///
/// This works even when the build is not running under `rdb stream` (which is
/// the case for the Flutter recipe): bbagent creates the invocation and exposes
/// `current_invocation` in `LUCI_CONTEXT`, without needing a separate result
/// streaming sidecar.
///
/// [recorder]: https://pkg.go.dev/go.chromium.org/luci/resultdb/proto/v1
class ResultDbRecorder {
  ResultDbRecorder({
    required this.host,
    required this.invocation,
    required this.updateToken,
    HttpClient? httpClient,
  }) : _client = httpClient ?? HttpClient();

  /// The ResultDB hostname (for example, `results.api.luci.app`).
  final String host;

  /// The invocation name (for example, `invocations/build-123`).
  final String invocation;

  /// The token that authorizes writes to [invocation].
  final String updateToken;

  final HttpClient _client;

  /// Creates a [ResultDbRecorder] from the `LUCI_CONTEXT`, or returns null if
  /// ResultDB is not configured for the current invocation.
  static ResultDbRecorder? fromEnvironment([Map<String, String>? environment]) {
    final Map<String, Object?>? luciContext = readLuciContext(environment);
    if (luciContext case {
      'resultdb': {
        'hostname': final String host,
        'current_invocation': {'name': final String name, 'update_token': final String updateToken},
      },
    }) {
      return ResultDbRecorder(host: host, invocation: name, updateToken: updateToken);
    }
    return null;
  }

  /// Reports the given [testResults] to the invocation via
  /// `BatchCreateTestResults`.
  ///
  /// Throws an [HttpException] if the server responds with a non-200 status.
  Future<void> reportTestResults(List<LuciTestResult> testResults) async {
    if (testResults.isEmpty) {
      return;
    }
    final Uri url = Uri.parse(
      'https://$host/prpc/luci.resultdb.v1.Recorder/BatchCreateTestResults',
    );
    for (final List<LuciTestResult> batch in _batches(testResults)) {
      final HttpClientRequest request = await _client.postUrl(url);
      request.headers.set(HttpHeaders.contentTypeHeader, 'application/json');
      request.headers.set(HttpHeaders.acceptHeader, 'application/json');
      // The Recorder authorizes writes to the invocation via this header.
      request.headers.set('update-token', updateToken);
      request.add(
        utf8.encode(
          json.encode(<String, Object?>{
            'invocation': invocation,
            'requests': <Map<String, Object?>>[
              for (final LuciTestResult result in batch)
                <String, Object?>{'testResult': result.toJson()},
            ],
          }),
        ),
      );
      final HttpClientResponse response = await request.close();
      final String body = await response.transform(utf8.decoder).join();
      if (response.statusCode != HttpStatus.ok) {
        throw HttpException(
          'ResultDB BatchCreateTestResults failed with status ${response.statusCode}: $body',
        );
      }
    }
  }

  /// Closes the underlying HTTP client.
  void close() {
    _client.close(force: true);
  }
}

/// The maximum number of test results to send in a single request.
const int _kBatchSize = 500;

/// Splits [testResults] into batches of at most [_kBatchSize].
Iterable<List<LuciTestResult>> _batches(List<LuciTestResult> testResults) sync* {
  for (var i = 0; i < testResults.length; i += _kBatchSize) {
    final int end = (i + _kBatchSize < testResults.length) ? i + _kBatchSize : testResults.length;
    yield testResults.sublist(i, end);
  }
}

/// A ResultDB [structured test id][id] (`testIdStructured`).
///
/// Using a structured id lets the "Test Results" UI present the test file and
/// the individual test name as separate columns.
///
/// [id]: https://pkg.go.dev/go.chromium.org/luci/resultdb/proto/v1#TestIdentifier
class LuciStructuredTestId {
  const LuciStructuredTestId({
    required this.moduleName,
    required this.caseName,
    this.moduleScheme = 'flat',
    this.moduleVariant = const <String, String>{},
  });

  /// The module name; for Flutter this is the test file relative to the repo
  /// root.
  final String moduleName;

  /// The scheme the module belongs to. `flat` has no intermediate (coarse/fine)
  /// hierarchy levels.
  final String moduleScheme;

  /// The module variant definition (empty for Flutter's tests).
  final Map<String, String> moduleVariant;

  /// The case name; for Flutter this is the individual test name.
  final String caseName;

  /// The JSON representation used by the ResultDB Recorder API.
  Map<String, Object?> toJson() => <String, Object?>{
    'moduleName': moduleName,
    'moduleScheme': moduleScheme,
    'moduleVariant': moduleVariant,
    'caseName': caseName,
  };
}

/// A single ResultDB [`TestResult`][result], describing the outcome of one
/// individual test case.
///
/// [result]: https://pkg.go.dev/go.chromium.org/luci/resultdb/proto/v1#TestResult
class LuciTestResult {
  const LuciTestResult({
    required this.testId,
    required this.resultId,
    required this.expected,
    required this.status,
    required this.duration,
  });

  /// The structured id identifying the test file and case.
  final LuciStructuredTestId testId;

  /// An id that is unique within the invocation for a given [testId].
  final String resultId;

  /// Whether this result is expected (`true`) rather than an unexpected
  /// regression (`false`).
  final bool expected;

  /// The ResultDB `TestStatus` enum value (`PASS`, `FAIL`, or `SKIP`).
  final String status;

  /// The test duration as a proto `Duration` string (for example, `0.100000s`).
  final String duration;

  /// The JSON representation used by the ResultDB Recorder API.
  Map<String, Object?> toJson() => <String, Object?>{
    'testIdStructured': testId.toJson(),
    'resultId': resultId,
    'expected': expected,
    'status': status,
    'duration': duration,
  };
}

/// Converts the parsed [results] into a list of [LuciTestResult]s, one per
/// (non-hidden) individual test case.
///
/// Each result uses a *structured* test id (`testIdStructured`) so the "Test
/// Results" tab can separate the file from the test name: the module name is the
/// test file (relative to the repo root) and the case name is the individual
/// test name. The `flat` scheme is used because it has no intermediate
/// (coarse/fine) hierarchy levels.
///
/// [workingDirectory] is the directory the shard ran `flutter test`/`dart test`
/// in; it is used to resolve relative suite paths. [rootDirectory], when
/// provided, is stripped from the front of the (resolved) suite path so that the
/// module name is reported relative to the repository root (for example,
/// `packages/flutter/test/foo_test.dart` instead of an absolute path or a bare
/// `test/foo_test.dart`).
///
/// When [expectFailure] is true, the entire `flutter test` invocation was
/// expected to fail (for example, the `test_smoke_test` negative tests run with
/// `expectFailure: true`). In that case every reported result is marked as
/// `expected`, so ResultDB records them as expected failures rather than
/// surfacing them as red regressions in the "Test Results" tab.
List<LuciTestResult> convertToLuciTestResultsFormat(
  TestFileReporterResults results, {
  bool expectFailure = false,
  String? workingDirectory,
  String? rootDirectory,
}) {
  var counter = 0;
  return <LuciTestResult>[
    for (final testResult in results.testResults.values)
      if (!testResult.hidden)
        LuciTestResult(
          // A structured test id: the module is the test file and the case is the
          // individual test name, so ResultDB presents them as separate columns.
          testId: LuciStructuredTestId(
            moduleName: _sanitizeModuleName(
              switch (results.allTestSpecs[testResult.suiteID]?.path) {
                final String path when path.isNotEmpty => _repoRelativeSuitePath(
                  path,
                  workingDirectory,
                  rootDirectory,
                ),
                _ => testResult.name,
              },
            ),
            caseName: _sanitizeCaseName(testResult.name),
          ),
          // Result ids must be unique within the invocation for a given test id.
          resultId: '${counter++}',
          expected: expectFailure || testResult.actual == testResult.expected,
          status: _sinkStatus(testResult),
          duration: '${testResult.seconds.toStringAsFixed(6)}s',
        ),
  ];
}

/// Returns [suitePath] as a forward-slash path relative to the repository root.
///
/// Suite paths reported by the test runner may be absolute (e.g.
/// `/b/s/w/.../flutter/dev/foo/bar_test.dart`) or relative to the directory the
/// shard ran in (e.g. `test/bar_test.dart` for a `packages/flutter` shard).
/// Relative paths are first resolved against [workingDirectory], then
/// [rootDirectory] (the repo root) is stripped, so every module name is a
/// consistent, repo-relative path regardless of where the shard ran.
String _repoRelativeSuitePath(String suitePath, String? workingDirectory, String? rootDirectory) {
  var p = suitePath;
  if (workingDirectory != null && workingDirectory.isNotEmpty && !_isAbsolutePath(p)) {
    p = '${_stripTrailingSeparators(workingDirectory)}/$p';
  }
  // Normalize separators so the id is stable and readable across platforms.
  p = p.replaceAll(r'\', '/');
  if (rootDirectory != null && rootDirectory.isNotEmpty) {
    final root = '${_stripTrailingSeparators(rootDirectory.replaceAll(r'\', '/'))}/';
    if (p.startsWith(root)) {
      p = p.substring(root.length);
    }
  }
  return p;
}

/// Whether [p] is an absolute path on POSIX (`/foo`) or Windows (`C:\foo`,
/// `C:/foo` or `\foo`).
bool _isAbsolutePath(String p) => switch (p) {
  String(isEmpty: true) => false,
  _ when p.startsWith('/') || p.startsWith(r'\') => true,
  // Windows drive-letter path, e.g. `C:\...` or `C:/...`.
  _ => p.length >= 3 && p[1] == ':' && (p[2] == r'\' || p[2] == '/'),
};

/// Removes any trailing `/` or `\` separators from [p].
String _stripTrailingSeparators(String p) {
  int end = p.length;
  while (end > 0 && (p[end - 1] == '/' || p[end - 1] == r'\')) {
    end--;
  }
  return p.substring(0, end);
}

/// The maximum length, in bytes, of a structured test id module name.
const int _kMaxModuleNameBytes = 300;

/// The maximum length, in bytes, of a structured test id case name.
const int _kMaxCaseNameBytes = 512;

/// Matches ASCII control characters (including newlines and tabs) and DEL.
final RegExp _controlCharacters = RegExp(r'[\x00-\x1f\x7f]');

/// Makes [moduleName] safe for a ResultDB structured test id.
///
/// The module name must be non-empty, printable UTF-8 of at most
/// [_kMaxModuleNameBytes] bytes. Colons are allowed (they commonly appear in
/// build target names), so no escaping is required.
String _sanitizeModuleName(String moduleName) {
  // Replace control characters (including newlines/tabs) with spaces.
  final String sanitized = moduleName.replaceAll(_controlCharacters, ' ');
  return _truncateToBytes(sanitized.isEmpty ? 'unknown' : sanitized, _kMaxModuleNameBytes);
}

/// Makes [caseName] safe for a ResultDB structured (non-legacy) test id case
/// name.
///
/// Unlike the legacy id format, a non-legacy scheme's case name must:
///  * escape `\` and `:` with a backslash (`:` denotes hierarchy separators),
///  * not start with a character in U+0020..U+002C (unless it is `*fixture`),
///  * be non-empty, printable UTF-8 of at most [_kMaxCaseNameBytes] bytes.
///
/// Keeping this robust prevents a single unusual test name from causing the
/// whole batch of results to be rejected.
String _sanitizeCaseName(String caseName) {
  // Replace control characters (including newlines/tabs) with spaces.
  String sanitized = caseName.replaceAll(_controlCharacters, ' ');
  // "*fixture" is a reserved value (used for setup/teardown); leave it as-is.
  if (sanitized != '*fixture') {
    // Escape backslashes first, then colons (order matters).
    sanitized = sanitized.replaceAll(r'\', r'\\').replaceAll(':', r'\:');
  }
  if (sanitized.isEmpty) {
    sanitized = 'unnamed test';
  }
  // The first character must not be in U+0020..U+002C (space and !"#$%&'()*+,).
  final int first = sanitized.codeUnitAt(0);
  if (sanitized != '*fixture' && first >= 0x20 && first <= 0x2c) {
    sanitized = '_$sanitized';
  }
  sanitized = _truncateToBytes(sanitized, _kMaxCaseNameBytes);
  // Truncation must not leave a dangling (unpaired) trailing backslash, which
  // would be an invalid escape sequence.
  return _stripDanglingBackslash(sanitized);
}

/// Truncates [value] so its UTF-8 encoding is at most [maxBytes] bytes, without
/// splitting a UTF-16 code unit.
String _truncateToBytes(String value, int maxBytes) {
  if (utf8.encode(value).length <= maxBytes) {
    return value;
  }
  // A UTF-8 encoding is always at least as long (in bytes) as the string's
  // UTF-16 code unit length, so truncating to [maxBytes] code units first keeps
  // the byte-length loop below to at most [maxBytes] iterations (avoiding O(N^2)
  // behavior on very long names).
  if (value.length > maxBytes) {
    value = value.substring(0, maxBytes);
  }
  while (utf8.encode(value).length > maxBytes) {
    value = value.substring(0, value.length - 1);
  }
  return value;
}

/// Removes a trailing backslash if it would be left unpaired (for example after
/// truncation), so the value remains a valid escaped string.
String _stripDanglingBackslash(String value) {
  var trailing = 0;
  for (int i = value.length - 1; i >= 0 && value[i] == r'\'; i--) {
    trailing++;
  }
  if (trailing.isOdd) {
    return value.substring(0, value.length - 1);
  }
  return value;
}

/// Maps a parsed [testResult] to a ResultDB `TestStatus` enum value.
String _sinkStatus(TestResult testResult) => switch (testResult) {
  TestResult(skipped: true) => 'SKIP',
  TestResult(actual: 'PASS') => 'PASS',
  _ => 'FAIL',
};