// Copyright (c) 2017, the Dart project authors. Please see the AUTHORS file
// for details. 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 'package:analyzer/dart/analysis/features.dart';
import 'package:analyzer/dart/ast/token.dart';
import 'package:analyzer/error/error.dart';
import 'package:analyzer/src/dart/ast/ast.dart';
import 'package:test/test.dart';

import '../../../generated/parser_test_base.dart';
import '../../../generated/test_support.dart';

/// The base class for tests that test how well the parser recovers from various
/// syntactic errors.
abstract class AbstractRecoveryTest extends FastaParserTestCase {
  void testRecovery(
      String invalidCode, List<ErrorCode>? errorCodes, String validCode,
      {CompilationUnitImpl Function(CompilationUnitImpl unit)?
          adjustValidUnitBeforeComparison,
      List<ErrorCode>? expectedErrorsInValidCode,
      FeatureSet? featureSet}) {
    CompilationUnitImpl validUnit;

    // Assert that the valid code is indeed valid.
    try {
      validUnit = parseCompilationUnit(validCode,
          codes: expectedErrorsInValidCode, featureSet: featureSet);
      validateTokenStream(validUnit.beginToken);
    } catch (e) {
//      print('');
//      print('  Errors in valid code.');
//      print('    Error: $e');
//      print('    Code: $validCode');
//      print('');
      rethrow;
    }

    // Compare the structures before asserting valid errors.
    GatheringErrorListener listener = GatheringErrorListener();
    var invalidUnit =
        parseCompilationUnit2(invalidCode, listener, featureSet: featureSet);
    validateTokenStream(invalidUnit.beginToken);
    if (adjustValidUnitBeforeComparison != null) {
      validUnit = adjustValidUnitBeforeComparison(validUnit);
    }
    ResultComparator.compare(invalidUnit, validUnit);

    // Assert valid errors.
    if (errorCodes != null) {
      listener.assertErrorsWithCodes(errorCodes);
    } else {
      listener.assertNoErrors();
    }
  }

  void validateTokenStream(Token token) {
    while (!token.isEof) {
      Token next = token.next!;
      expect(token.end, lessThanOrEqualTo(next.offset));
      if (next.isSynthetic) {
        if (const [')', ']', '}'].contains(next.lexeme)) {
          expect(next.beforeSynthetic, token);
        }
      }
      token = next;
    }
  }
}

/// An object used to compare to AST structures and cause the test to fail if
/// they differ in any important ways.
class ResultComparator {
  /// Compares two lists in a syntax tree, and if they don't match, returns a
  /// description of how they fail to match.
  ///
  /// The two lists being compared might be lists of AST nodes or lists of
  /// tokens.
  _Mismatch? _compareLists(
      {required List<Object?> actual, required List<Object?> expected}) {
    int length = actual.length;
    if (expected.length != length) {
      return _failDifferentLength(actual: actual, expected: expected);
    }
    for (int i = 0; i < length; i++) {
      if (_compareTreeParts(actual: actual[i], expected: expected[i])
          case var mismatch?) {
        mismatch.reversePath.add(_IndexPathSegment(i));
        return mismatch;
      }
    }
    return null;
  }

  /// Compares two [AstNode]s in a syntax tree, and if they don't match, returns
  /// a description of how they fail to match. The caller should have already
  /// checked that the two nodes have the same [runtimeType].
  ///
  /// The nodes are compared by using [AstNodeImpl.namedChildEntities] to
  /// convert them each into a `Map<String, Object>`, whose keys typically match
  /// the names of syntactic getters, and whose values typically match the
  /// values returned by those getters. These maps are then compared using
  /// [_compareTreeParts].
  _Mismatch? _compareNodesWithMatchingTypes(
      {required AstNodeImpl actual, required AstNodeImpl expected}) {
    var allKeys = <String>{};
    var actualChildEntities =
        _computeChildEntitiesMap(actual, keyAccumulator: allKeys);
    var expectedChildEntities =
        _computeChildEntitiesMap(expected, keyAccumulator: allKeys);
    for (var key in allKeys) {
      var actualChild = actualChildEntities[key];
      var expectedChild = expectedChildEntities[key];
      if (_compareTreeParts(actual: actualChild, expected: expectedChild)
          case var mismatch?) {
        mismatch.reversePath.add(_GetterPathSegment(key));
        return mismatch;
      }
    }
    return null;
  }

  /// Compares two [Token]s in a syntax tree, and if they don't match, returns
  /// a description of how they fail to match.
  _Mismatch? _compareTokens({required Token actual, required Token expected}) {
    StringBuffer buffer;
    if (expected.lexeme == '_k_') {
      // '_k_' matches a keyword.
      if (actual.isKeyword) {
        return null;
      } else {
        buffer = StringBuffer();
        buffer.writeln('Expected a keyword');
      }
    } else if (expected.lexeme == '_s_') {
      // '_s_' matches a synthetic identifier.
      if (actual.isSynthetic && actual.type == expected.type) {
        return null;
      } else {
        buffer = StringBuffer();
        buffer.writeln('Expected a synthetic identifier');
      }
    } else {
      if (actual.type == expected.type) {
        if (actual.isSynthetic) {
          if (expected.isSynthetic) {
            // Synthetic tokens match each other regardless of their lexeme and
            // length
            return null;
          } else {
            // An actual synthetic token matches an expected non-synthetic token
            // if the lexemes match. The lengths do not need to match because
            // the length of a synthetic token is zero.
            assert(actual.length == 0,
                'Length is expected to be zero because token is synthetic');
            if (actual.lexeme == expected.lexeme) return null;
            // Also, an actual synthetic empty string matches an expected empty
            // string, even if the strings are quotated differently.
            if (_isEmptyStringToken(actual) && _isEmptyStringToken(expected)) {
              return null;
            }
          }
        } else {
          // An actual non-synthetic token only matches an expected token with
          // the same length and lexeme.
          if (actual.length == expected.length &&
              actual.lexeme == expected.lexeme) {
            return null;
          }
        }
      }
      buffer = StringBuffer();
      buffer.write('Expected ');
      _describeToken(expected, buffer);
    }
    buffer.write('But found ');
    _describeToken(actual, buffer);
    return _Mismatch(buffer.toString());
  }

  /// Compares two parts of a syntax tree, and if they don't match, returns a
  /// description of how they fail to match.
  ///
  /// The two parts being compared might be AST nodes, AST node lists, tokens,
  /// or token lists.
  _Mismatch? _compareTreeParts(
      {required Object? actual, required Object? expected}) {
    if (identical(actual, expected)) {
      return null;
    }
    if (actual == null) {
      return _failIfExpectingNonNull(expected: expected);
    }
    if (expected == null) {
      return _failBecauseUnexpected(actual: actual);
    }
    if (actual is List<Object?> && expected is List<Object?>) {
      return _compareLists(actual: actual, expected: expected);
    }
    if (actual is Token && expected is Token) {
      return _compareTokens(actual: actual, expected: expected);
    }
    if (actual.runtimeType != expected.runtimeType) {
      return _failBecauseUnexpectedType(actual: actual, expected: expected);
    }
    if (_compareNodesWithMatchingTypes(
            actual: actual as AstNodeImpl, expected: expected as AstNodeImpl)
        case var mismatch?) {
      var typeName = actual.runtimeType.toString();
      if (typeName.endsWith('Impl')) {
        typeName = typeName.substring(0, typeName.length - 4);
      }
      mismatch.reversePath.add(_CastPathSegment(typeName));
      return mismatch;
    }
    return null;
  }

  /// Computes a map whose values are the [AstNode]s, [Token]s, and
  /// [NodeList]s pointed to by [node], and whose keys are the names of the
  /// getters that return those values.
  ///
  /// This information is derived from the [ChildEntity] objects returned by
  /// [AstNodeImpl.namedChildEntities].
  Map<String, Object> _computeChildEntitiesMap(AstNodeImpl node,
      {required Set<String> keyAccumulator}) {
    var result = <String, Object>{};
    for (var ChildEntity(:name, :value) in node.namedChildEntities) {
      if (node is FormalParameterListImpl && name == 'parameter') {
        // FormalParameterListImpl.namedChildEntities splits up the parameter
        // list into individual `parameter` entitites so that it can insert
        // `leftDelimiter` in the middle of the list (see
        // https://github.com/dart-lang/sdk/issues/60445 for details). To
        // compensate for this, we need to reassemble the individual `parameter`
        // entities back into a single list.
        keyAccumulator.add('parameters');
        ((result['parameters'] ??= <AstNode>[]) as List<AstNode>)
            .add(value as AstNode);
      } else {
        assert(!result.containsKey(name),
            'Unexpected duplicate name in $runtimeType.childEntities: $name');
        keyAccumulator.add(name);
        result[name] = value;
      }
    }
    return result;
  }

  /// Writes a human-readable description of [token] to [buffer].
  void _describeToken(Token token, StringBuffer buffer) {
    if (token.isSynthetic) buffer.write('synthetic ');
    buffer.write('token ');
    buffer.writeln(json.encode(token.lexeme));
    buffer.write('  type=');
    buffer.write(token.type.name);
    buffer.write(', length=');
    buffer.writeln(token.length);
  }

  /// Creates a [_Mismatch] representing a piece of syntax that was unexpected
  /// (the corresponding part of the `expected` AST is `null`).
  _Mismatch _failBecauseUnexpected({required Object actual}) {
    StringBuffer buffer = StringBuffer();
    buffer.write('Unexpected ');
    buffer.writeln(actual.runtimeType);
    return _Mismatch(buffer.toString());
  }

  /// Creates a [_Mismatch] representing a piece of syntax that has the wrong
  /// runtime type.
  _Mismatch _failBecauseUnexpectedType(
      {required Object actual, required Object expected}) {
    StringBuffer buffer = StringBuffer();
    buffer.write('Expected a ');
    buffer.write(expected.runtimeType);
    buffer.write('; found ');
    buffer.writeln(actual.runtimeType);
    return _Mismatch(buffer.toString());
  }

  /// Creates a [_Mismatch] representing a piece of syntax that is a list with
  /// an unexpected length.
  _Mismatch _failDifferentLength(
      {required List<Object?> actual, required List<Object?> expected}) {
    StringBuffer buffer = StringBuffer();
    buffer.writeln('Expected a list of length ${expected.length}');
    buffer.writeln('  $expected');
    buffer.writeln('But found a list of length ${actual.length}');
    buffer.writeln('  $actual');
    return _Mismatch(buffer.toString());
  }

  /// Handles the situation where a slot in the `actual` AST is `null`.
  ///
  /// If the corresponding slot in the `expected` AST is also `null`, then there
  /// is no mismatch. Otherwise, a [_Mismatch] is created to describe the
  /// problem.
  _Mismatch? _failIfExpectingNonNull({required Object? expected}) {
    if (expected != null) {
      StringBuffer buffer = StringBuffer();
      buffer.write('Expected a ');
      buffer.write(expected.runtimeType);
      buffer.writeln('; found nothing');
      return _Mismatch(buffer.toString());
    }
    return null;
  }

  bool _isEmptyStringToken(Token token) => const [
        '""',
        "''",
        '""""""',
        "''''''",
        'r""',
        "r''",
        'r""""""',
        "r''''''"
      ].contains(token.lexeme);

  /// Compares the [actual] and [expected] nodes, failing the test if they are
  /// different.
  static void compare(AstNode actual, AstNode expected) {
    ResultComparator comparator = ResultComparator();
    if (comparator._compareTreeParts(actual: actual, expected: expected)
        case _Mismatch(:var message, :var reversePath)) {
      if (reversePath.isNotEmpty) {
        var path =
            reversePath.reversed.fold('root', (p, seg) => seg.combinePath(p));
        fail('$message  path: $path\n');
      } else {
        fail(message);
      }
    }
  }
}

/// A [_PathSegment] representing a cast to a specific AST node type.
class _CastPathSegment extends _PathSegment {
  final String typeName;

  _CastPathSegment(this.typeName);

  @override
  String combinePath(String path) => '($path as $typeName)';
}

/// A [_PathSegment] representing a getter invocation.
class _GetterPathSegment extends _PathSegment {
  final String getterName;

  _GetterPathSegment(this.getterName);

  @override
  String combinePath(String path) => '$path.$getterName';
}

/// A [_PathSegment] representing the operation of indexing into a list.
class _IndexPathSegment extends _PathSegment {
  final int index;

  _IndexPathSegment(this.index);

  @override
  String combinePath(String path) => '$path[$index]';
}

/// A description of a mismatch between two ASTs.
///
/// Part of the implementation of [ResultComparator.compare].
class _Mismatch {
  /// Description of the mismatch, followed by `\n`.
  final String message;

  /// A list of [_PathSegment] objects which, when taken in revers order,
  /// describe the path from the AST nodes passed to [ResultComparator.compare]
  /// to the point where the mismatch occurred.
  final reversePath = <_PathSegment>[];

  _Mismatch(this.message) : assert(message.endsWith('\n'));
}

/// A single step in the path from the root of an AST to a particular point in
/// the AST.
sealed class _PathSegment {
  /// Combines this path segment with [path].
  ///
  /// If [path] represents a particular AST (or a part of an AST), then the
  /// returned string represents the particular substructure of that AST
  /// selected by this path segment. For example, if `path` is `foo`, and `this`
  /// is a [_GetterPathSegment] representing invocation of the getter
  /// `.operand`, then the resulting string will be `foo.operand`.
  String combinePath(String path);
}
