// 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:meta/meta.dart';

import '../../src/base/process.dart';
import '../../src/convert.dart' show json;
import '../../src/macos/xcode.dart';
import '../convert.dart';

/// The generator of xcresults.
///
/// Call [generate] after an iOS/MacOS build will generate a [XCResult].
/// This only works when the `-resultBundleVersion` is set to 3.
/// * See also: [XCResult].
class XCResultGenerator {
  /// Construct the [XCResultGenerator].
  XCResultGenerator({
    required this.resultPath,
    required this.xcode,
    required this.processUtils,
  });

  /// The file path that used to store the xcrun result.
  ///
  /// There's usually a `resultPath.xcresult` file in the same folder.
  final String resultPath;

  /// The [ProcessUtils] to run commands.
  final ProcessUtils processUtils;

  /// [Xcode] object used to run xcode command.
  final Xcode xcode;

  /// Generates the XCResult.
  ///
  /// Calls `xcrun xcresulttool get --path <resultPath> --format json`,
  /// then stores the useful information the json into an [XCResult] object.
  ///
  /// A`issueDiscarders` can be passed to discard any issues that matches the description of any [XCResultIssueDiscarder] in the list.
  Future<XCResult> generate(
      {List<XCResultIssueDiscarder> issueDiscarders =
          const <XCResultIssueDiscarder>[]}) async {
    final RunResult result = await processUtils.run(
      <String>[
        ...xcode.xcrunCommand(),
        'xcresulttool',
        'get',
        '--path',
        resultPath,
        '--format',
        'json',
      ],
    );
    if (result.exitCode != 0) {
      return XCResult.failed(errorMessage: result.stderr);
    }
    if (result.stdout.isEmpty) {
      return XCResult.failed(
          errorMessage: 'xcresult parser: Unrecognized top level json format.');
    }
    final Object? resultJson = json.decode(result.stdout);
    if (resultJson == null || resultJson is! Map<String, Object?>) {
      // If json parsing failed, indicate such error.
      // This also includes the top level json object is an array, which indicates
      // the structure of the json is changed and this parser class possibly needs to update for this change.
      return XCResult.failed(
          errorMessage: 'xcresult parser: Unrecognized top level json format.');
    }
    return XCResult(resultJson: resultJson, issueDiscarders: issueDiscarders);
  }
}

/// The xcresult of an `xcodebuild` command.
///
/// This is the result from an `xcrun xcresulttool get --path <resultPath> --format json` run.
/// The result contains useful information such as build errors and warnings.
class XCResult {
  /// Parse the `resultJson` and stores useful informations in the returned `XCResult`.
  factory XCResult({required Map<String, Object?> resultJson, List<XCResultIssueDiscarder> issueDiscarders = const <XCResultIssueDiscarder>[]}) {
    final List<XCResultIssue> issues = <XCResultIssue>[];

    final Object? issuesMap = resultJson['issues'];
    if (issuesMap == null || issuesMap is! Map<String, Object?>) {
      return XCResult.failed(
          errorMessage: 'xcresult parser: Failed to parse the issues map.');
    }

    final Object? errorSummaries = issuesMap['errorSummaries'];
    if (errorSummaries is Map<String, Object?>) {
      issues.addAll(_parseIssuesFromIssueSummariesJson(
        type: XCResultIssueType.error,
        issueSummariesJson: errorSummaries,
        issueDiscarder: issueDiscarders,
      ));
    }

    final Object? warningSummaries = issuesMap['warningSummaries'];
    if (warningSummaries is Map<String, Object?>) {
      issues.addAll(_parseIssuesFromIssueSummariesJson(
        type: XCResultIssueType.warning,
        issueSummariesJson: warningSummaries,
        issueDiscarder: issueDiscarders,
      ));
    }
    return XCResult._(issues: issues);
  }

  factory XCResult.failed({required String errorMessage}) {
    return XCResult._(
      parseSuccess: false,
      parsingErrorMessage: errorMessage,
    );
  }

  /// Create a [XCResult] with constructed [XCResultIssue]s for testing.
  @visibleForTesting
  factory XCResult.test({
    List<XCResultIssue>? issues,
    bool? parseSuccess,
    String? parsingErrorMessage,
  }) {
    return XCResult._(
      issues: issues ?? const <XCResultIssue>[],
      parseSuccess: parseSuccess ?? true,
      parsingErrorMessage: parsingErrorMessage,
    );
  }

  XCResult._({
    this.issues = const <XCResultIssue>[],
    this.parseSuccess = true,
    this.parsingErrorMessage,
  });

  /// The issues in the xcresult file.
  final List<XCResultIssue> issues;

  /// Indicate if the xcresult was successfully parsed.
  ///
  /// See also: [parsingErrorMessage] for the error message if the parsing was unsuccessful.
  final bool parseSuccess;

  /// The error message describes why the parse if unsuccessful.
  ///
  /// This is `null` if [parseSuccess] is `true`.
  final String? parsingErrorMessage;
}

/// An issue object in the XCResult
class XCResultIssue {
  /// Construct an `XCResultIssue` object from `issueJson`.
  ///
  /// `issueJson` is the object at xcresultJson[['actions']['_values'][0]['buildResult']['issues']['errorSummaries'/'warningSummaries']['_values'].
  factory XCResultIssue({
    required XCResultIssueType type,
    required Map<String, Object?> issueJson,
  }) {
    // Parse type.
    final Object? issueSubTypeMap = issueJson['issueType'];
    String? subType;
    if (issueSubTypeMap is Map<String, Object?>) {
      final Object? subTypeValue = issueSubTypeMap['_value'];
      if (subTypeValue is String) {
        subType = subTypeValue;
      }
    }

    // Parse message.
    String? message;
    final Object? messageMap = issueJson['message'];
    if (messageMap is Map<String, Object?>) {
      final Object? messageValue = messageMap['_value'];
      if (messageValue is String) {
        message = messageValue;
      }
    }

    final List<String> warnings = <String>[];
    // Parse url and convert it to a location String.
    String? location;
    final Object? documentLocationInCreatingWorkspaceMap =
        issueJson['documentLocationInCreatingWorkspace'];
    if (documentLocationInCreatingWorkspaceMap is Map<String, Object?>) {
      final Object? urlMap = documentLocationInCreatingWorkspaceMap['url'];
      if (urlMap is Map<String, Object?>) {
        final Object? urlValue = urlMap['_value'];
        if (urlValue is String) {
          location = _convertUrlToLocationString(urlValue);
          if (location == null) {
            warnings.add(
                '(XCResult) The `url` exists but it was failed to be parsed. url: $urlValue');
          }
        }
      }
    }

    return XCResultIssue._(
      type: type,
      subType: subType,
      message: message,
      location: location,
      warnings: warnings,
    );
  }

  /// Create a [XCResultIssue] without JSON parsing for testing.
  @visibleForTesting
  factory XCResultIssue.test({
    XCResultIssueType type = XCResultIssueType.error,
    String? subType,
    String? message,
    String? location,
    List<String> warnings = const <String>[],
  }) {
    return XCResultIssue._(
      type: type,
      subType: subType,
      message: message,
      location: location,
      warnings: warnings,
    );
  }

  XCResultIssue._({
    required this.type,
    required this.subType,
    required this.message,
    required this.location,
    required this.warnings,
  });

  /// The type of the issue.
  final XCResultIssueType type;

  /// The sub type of the issue.
  ///
  /// This is a more detailed category about the issue.
  /// The possible values are `Warning`, `Semantic Issue'` etc.
  final String? subType;

  /// Human readable message for the issue.
  ///
  /// This can be displayed to user for their information.
  final String? message;

  /// The location where the issue occurs.
  ///
  /// This is a re-formatted version of the "url" value in the json.
  /// The format looks like <FileLocation>:<StartingLineNumber>:<StartingColumnNumber>.
  final String? location;

  /// Warnings when constructing the issue object.
  final List<String> warnings;
}

/// The type of an `XCResultIssue`.
enum XCResultIssueType {
  /// The issue is an warning.
  ///
  /// This is for all the issues under the `warningSummaries` key in the xcresult.
  warning,

  /// The issue is an warning.
  ///
  /// This is for all the issues under the `errorSummaries` key in the xcresult.
  error,
}

/// Discards the [XCResultIssue] that matches any of the matchers.
class XCResultIssueDiscarder {
  XCResultIssueDiscarder(
      {this.typeMatcher,
      this.subTypeMatcher,
      this.messageMatcher,
      this.locationMatcher})
      : assert(typeMatcher != null ||
            subTypeMatcher != null ||
            messageMatcher != null ||
            locationMatcher != null);

  /// The type of the discarder.
  ///
  /// A [XCResultIssue] should be discarded if its `type` equals to this.
  final XCResultIssueType? typeMatcher;

  /// The subType of the discarder.
  ///
  /// A [XCResultIssue] should be discarded if its `subType` matches the RegExp.
  final RegExp? subTypeMatcher;

  /// The message of the discarder.
  ///
  /// A [XCResultIssue] should be discarded if its `message` matches the RegExp.
  final RegExp? messageMatcher;

  /// The location of the discarder.
  ///
  /// A [XCResultIssue] should be discarded if its `location` matches the RegExp.
  final RegExp? locationMatcher;
}

// A typical location url string looks like file:///foo.swift#CharacterRangeLen=0&EndingColumnNumber=82&EndingLineNumber=7&StartingColumnNumber=82&StartingLineNumber=7.
//
// This function converts it to something like: /foo.swift:<StartingLineNumber>:<StartingColumnNumber>.
String? _convertUrlToLocationString(String url) {
  final Uri? fragmentLocation = Uri.tryParse(url);
  if (fragmentLocation == null) {
    return null;
  }
  // Parse the fragment as a query of key-values:
  final Uri fileLocation = Uri(
    path: fragmentLocation.path,
    query: fragmentLocation.fragment,
  );
  String startingLineNumber =
      fileLocation.queryParameters['StartingLineNumber'] ?? '';
  if (startingLineNumber.isNotEmpty) {
    startingLineNumber = ':$startingLineNumber';
  }
  String startingColumnNumber =
      fileLocation.queryParameters['StartingColumnNumber'] ?? '';
  if (startingColumnNumber.isNotEmpty) {
    startingColumnNumber = ':$startingColumnNumber';
  }
  return '${fileLocation.path}$startingLineNumber$startingColumnNumber';
}

// Determine if an `issue` should be discarded based on the `discarder`.
bool _shouldDiscardIssue(
    {required XCResultIssue issue, required XCResultIssueDiscarder discarder}) {
  if (issue.type == discarder.typeMatcher) {
    return true;
  }
  if (issue.subType != null &&
      discarder.subTypeMatcher != null &&
      discarder.subTypeMatcher!.hasMatch(issue.subType!)) {
    return true;
  }
  if (issue.message != null &&
      discarder.messageMatcher != null &&
      discarder.messageMatcher!.hasMatch(issue.message!)) {
    return true;
  }
  if (issue.location != null &&
      discarder.locationMatcher != null &&
      discarder.locationMatcher!.hasMatch(issue.location!)) {
    return true;
  }

  return false;
}

List<XCResultIssue> _parseIssuesFromIssueSummariesJson({
  required XCResultIssueType type,
  required Map<String, Object?> issueSummariesJson,
  required List<XCResultIssueDiscarder> issueDiscarder,
}) {
  final List<XCResultIssue> issues = <XCResultIssue>[];
  final Object? errorsList = issueSummariesJson['_values'];
  if (errorsList is List<Object?>) {
    for (final Object? issueJson in errorsList) {
      if (issueJson == null || issueJson is! Map<String, Object?>) {
        continue;
      }
      final XCResultIssue resultIssue = XCResultIssue(
        type: type,
        issueJson: issueJson,
      );
      bool discard = false;
      for (final XCResultIssueDiscarder discarder in issueDiscarder) {
        if (_shouldDiscardIssue(issue: resultIssue, discarder: discarder)) {
          discard = true;
          break;
        }
      }
      if (discard) {
        continue;
      }
      issues.add(resultIssue);
    }
  }
  return issues;
}