// Copyright (c) 2022, 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:io' as io;

import 'package:analyzer/dart/analysis/analysis_context_collection.dart';
import 'package:analyzer/dart/analysis/results.dart';
import 'package:analyzer/dart/ast/visitor.dart';
import 'package:analyzer/file_system/physical_file_system.dart';
import 'package:analyzer/source/line_info.dart';
import 'package:analyzer/src/dart/ast/ast.dart';
import 'package:analyzer/src/utilities/extensions/collection.dart';
import 'package:test/test.dart';
import 'package:test_reflective_loader/test_reflective_loader.dart';

class NodeTextExpectationsCollector {
  /// If this flag is `true`, we accumulate updates to expectations.
  /// This should only happen locally, to update tests or implementation.
  ///
  /// This flag should be `false` during code review.
  static const updatingIsEnabled = false;

  static final assertMethods = [
    _AssertMethod.forFunction(
      methodName: 'assertEdits',
      argument: _ArgumentNamed('expected'),
    ),
    _AssertMethod(
      className: 'AnalysisContextCollectionTest',
      methodName: '_assertWorkspaceCollectionText',
      argument: _ArgumentIndex(1),
    ),
    _AssertMethod(
      className: 'AnalysisSessionImplTest',
      methodName: '_assertFileUnitElementResultText',
      argument: _ArgumentIndex(1),
    ),
    _AssertMethod(
      className: 'ContextResolutionTest',
      methodName: 'assertDriverStateString',
      argument: _ArgumentIndex(1),
    ),
    _AssertMethod(
      className: 'ElementsBaseTest',
      methodName: 'checkElementText',
      argument: _ArgumentIndex(1),
    ),
    _AssertMethod(
      className: 'ElementLocatorTest',
      methodName: '_assertElement',
      argument: _ArgumentIndex(1),
    ),
    _AssertMethod(
      className: 'ElementLocatorTest2',
      methodName: '_assertElement',
      argument: _ArgumentIndex(1),
    ),
    _AssertMethod(
      className: 'FileResolutionTest',
      methodName: 'assertStateString',
      argument: _ArgumentIndex(0),
    ),
    _AssertMethod(
      className: 'FineAnalysisDriverTest',
      methodName: '_runChangeScenarioTA',
      argument: _ArgumentNamed(
        'expectedInitialEvents',
        shouldCheckIntraInvocationId: true,
      ),
    ),
    _AssertMethod(
      className: 'FineAnalysisDriverTest',
      methodName: '_runChangeScenarioTA',
      argument: _ArgumentNamed(
        'expectedUpdatedEvents',
        shouldCheckIntraInvocationId: true,
      ),
    ),
    _AssertMethod(
      className: 'FineAnalysisDriverTest',
      methodName: '_runChangeScenarioUA',
      argument: _ArgumentNamed(
        'expectedUpdatedEvents',
        shouldCheckIntraInvocationId: true,
      ),
    ),
    _AssertMethod(
      className: 'FineAnalysisDriverTest',
      methodName: '_runChangeScenario',
      argument: _ArgumentNamed(
        'expectedInitialEvents',
        shouldCheckIntraInvocationId: true,
      ),
    ),
    _AssertMethod(
      className: 'FineAnalysisDriverTest',
      methodName: '_runChangeScenario',
      argument: _ArgumentNamed(
        'expectedUpdatedEvents',
        shouldCheckIntraInvocationId: true,
      ),
    ),
    _AssertMethod(
      className: 'FineAnalysisDriverTest',
      methodName: '_runLibraryManifestScenario',
      argument: _ArgumentNamed(
        'expectedInitialEvents',
        shouldCheckIntraInvocationId: true,
      ),
    ),
    _AssertMethod(
      className: 'FineAnalysisDriverTest',
      methodName: '_runLibraryManifestScenario',
      argument: _ArgumentNamed(
        'expectedUpdatedEvents',
        shouldCheckIntraInvocationId: true,
      ),
    ),
    _AssertMethod(
      className: 'IndexTest',
      methodName: 'assertElementIndexText',
      argument: _ArgumentIndex(1),
    ),
    _AssertMethod(
      className: 'IndexTest',
      methodName: 'assertLibraryFragmentIndexText',
      argument: _ArgumentIndex(1),
    ),
    _AssertMethod(
      className: '_InheritanceManager3Base2',
      methodName: 'assertInterfaceText',
      argument: _ArgumentIndex(1),
    ),
    _AssertMethod(
      className: 'LibraryElementTest_scope',
      methodName: '_assertLibraryExtensions',
      argument: _ArgumentIndex(1),
    ),
    _AssertMethod(
      className: 'LibraryFragmentElementTest',
      methodName: '_assertScopeLookups',
      argument: _ArgumentIndex(3),
    ),
    _AssertMethod(
      className: 'MetadataResolutionTest',
      methodName: '_assertAnnotationValueText',
      argument: _ArgumentIndex(1),
    ),
    _AssertMethod(
      className: 'ParserDiagnosticsTest',
      methodName: 'assertParsedNodeText',
      argument: _ArgumentIndex(1),
    ),
    _AssertMethod(
      className: 'ResolutionTest',
      methodName: 'assertDartObjectText',
      argument: _ArgumentIndex(1),
    ),
    _AssertMethod(
      className: 'ResolutionTest',
      methodName: 'assertParsedNodeText',
      argument: _ArgumentIndex(1),
    ),
    _AssertMethod(
      className: 'ResolutionTest',
      methodName: 'assertResolvedLibraryResultText',
      argument: _ArgumentIndex(1),
    ),
    _AssertMethod(
      className: 'ResolutionTest',
      methodName: 'assertResolvedNodeText',
      argument: _ArgumentIndex(1),
    ),
    _AssertMethod(
      className: 'SearchTest',
      methodName: 'assertDeclarationsText',
      argument: _ArgumentIndex(2),
    ),
    _AssertMethod(
      className: 'SearchTest',
      methodName: 'assertElementReferencesText',
      argument: _ArgumentIndex(1),
    ),
    _AssertMethod(
      className: 'SearchTest',
      methodName: 'assertLibraryFragmentReferencesText',
      argument: _ArgumentIndex(1),
    ),
    _AssertMethod(
      className: 'SearchTest',
      methodName: 'assertLibraryImportReferencesText',
      argument: _ArgumentIndex(1),
    ),
    _AssertMethod(
      className: 'SearchTest',
      methodName: 'assertUnresolvedMemberReferencesText',
      argument: _ArgumentIndex(1),
    ),
    _AssertMethod(
      className: '_EventsMixin',
      methodName: 'assertEventsText',
      argument: _ArgumentIndex(1),
    ),
  ];

  /// Some top-level "assert" have multiple expectations.
  /// They set this field, so that [_ArgumentNamed] can disambiguate.
  static String? intraInvocationId;

  static final Map<String, _File> _files = {};

  static void add(String actual) {
    if (!updatingIsEnabled) {
      return;
    }

    var traceLines = '${StackTrace.current}'.split('\n');
    for (var assertMethod in assertMethods) {
      // Disambiguate multi-expectation arguments.
      if (assertMethod.argument case _ArgumentNamed namedArgument) {
        if (namedArgument.shouldCheckIntraInvocationId) {
          var id = NodeTextExpectationsCollector.intraInvocationId;
          if (namedArgument.name != id) {
            continue;
          }
        }
      }

      for (var traceIndex = 0; traceIndex < traceLines.length; traceIndex++) {
        var traceLine = traceLines[traceIndex];
        if (!traceLine.contains(' ${assertMethod.stackTracePattern} ')) {
          continue;
        }

        // Find the invocation of the assert method in the stack trace.
        var invocationTraceIndex = traceIndex + 1;
        if (traceLines[invocationTraceIndex] == '<asynchronous suspension>') {
          invocationTraceIndex++;
        }
        var invocationTraceLine = traceLines[invocationTraceIndex];

        // Parse the invocation stack trace line.
        var locationMatch = RegExp(
          r'(file://.+_test.dart):(\d+):',
        ).firstMatch(invocationTraceLine);
        if (locationMatch == null) {
          fail('Cannot parse: $invocationTraceLine');
        }

        var path = Uri.parse(locationMatch.group(1)!).toFilePath();
        var line = int.parse(locationMatch.group(2)!);
        var file = _getFile(path);

        var invocation = file.findInvocation(
          invocationLine: line,
        );
        if (invocation == null) {
          fail('Cannot find MethodInvocation.');
        }

        if (invocation.methodName.name != assertMethod.methodName) {
          fail(
            'Expected: ${assertMethod.methodName}\n'
            'Actual: ${invocation.methodName.name}\n',
          );
        }

        var argumentList = invocation.argumentList;
        var argument = assertMethod.argument.get(argumentList);
        if (argument is! SimpleStringLiteral) {
          fail('Not a literal: ${argument.runtimeType}');
        }

        file.addReplacement(
          _Replacement(
            argument.contentsOffset,
            argument.contentsEnd,
            actual,
          ),
        );

        // Stop after the first (most specific) assert method.
        return;
      }
    }
  }

  static void apply() {
    for (var file in _files.values) {
      file.applyReplacements();
    }
    _files.clear();
  }

  static _File _getFile(String path) {
    return _files[path] ??= _File(path);
  }
}

@reflectiveTest
class UpdateNodeTextExpectations {
  test_applyReplacements() {
    NodeTextExpectationsCollector.apply();
  }
}

sealed class _Argument {
  Expression get(ArgumentList argumentList);
}

final class _ArgumentIndex extends _Argument {
  final int index;

  _ArgumentIndex(this.index);

  @override
  Expression get(ArgumentList argumentList) {
    return argumentList.arguments
        .whereNotType<NamedExpression>()
        .elementAt(index);
  }
}

final class _ArgumentNamed extends _Argument {
  final String name;
  final bool shouldCheckIntraInvocationId;

  _ArgumentNamed(
    this.name, {
    this.shouldCheckIntraInvocationId = false,
  });

  @override
  Expression get(ArgumentList argumentList) {
    return argumentList.arguments
        .whereType<NamedExpression>()
        .where((argument) => argument.name.label.name == name)
        .single
        .expression;
  }
}

class _AssertMethod {
  final String methodName;
  final String stackTracePattern;
  final _Argument argument;

  const _AssertMethod({
    required String className,
    required this.methodName,
    required this.argument,
  }) : stackTracePattern = '$className.$methodName';

  const _AssertMethod.forFunction({
    required this.methodName,
    required this.argument,
  }) : stackTracePattern = ' $methodName';
}

class _File {
  final String path;
  final String content;
  final LineInfo lineInfo;
  final CompilationUnit unit;
  final List<_Replacement> replacements = [];

  factory _File(String path) {
    var content = io.File(path).readAsStringSync();

    var collection = AnalysisContextCollection(
      resourceProvider: PhysicalResourceProvider.INSTANCE,
      includedPaths: [path],
    );
    var analysisContext = collection.contextFor(path);
    var analysisSession = analysisContext.currentSession;
    var parseResult = analysisSession.getParsedUnit(path);
    parseResult as ParsedUnitResult;

    return _File._(
      path: path,
      content: content,
      lineInfo: LineInfo.fromContent(content),
      unit: parseResult.unit,
    );
  }

  _File._({
    required this.path,
    required this.content,
    required this.lineInfo,
    required this.unit,
  });

  void addReplacement(_Replacement replacement) {
    // Check if there is the same replacement.
    for (var existing in replacements) {
      if (existing.offset == replacement.offset) {
        // Sanity check.
        if (existing.end != replacement.end) {
          fail(
            'At offset: ${existing.offset}\n'
            'Existing end: ${existing.end}\n'
            'New end: ${replacement.end}\n',
          );
        }
        if (existing.text != replacement.text) {
          fail(
            'At offset: ${existing.offset}\n'
            'Existing text:\n${existing.text}\n'
            'New text:\n${replacement.end}\n',
          );
        }
        // We already have the same replacement, exit.
        return;
      }
    }

    // This is a new replacement, add it.
    replacements.add(replacement);
  }

  void applyReplacements() {
    replacements.sort((a, b) => b.offset - a.offset);
    var newCode = content;
    for (var replacement in replacements) {
      newCode = newCode.substring(0, replacement.offset) +
          replacement.text +
          newCode.substring(replacement.end);
    }
    io.File(path).writeAsStringSync(newCode);
  }

  MethodInvocation? findInvocation({
    required int invocationLine,
  }) {
    var visitor = _InvocationVisitor(
      lineInfo: lineInfo,
      requestedLine: invocationLine,
    );
    unit.accept(visitor);
    return visitor.result;
  }
}

class _InvocationVisitor extends RecursiveAstVisitor<void> {
  final LineInfo lineInfo;
  final int requestedLine;
  MethodInvocation? result;

  _InvocationVisitor({
    required this.lineInfo,
    required this.requestedLine,
  });

  @override
  void visitMethodInvocation(MethodInvocation node) {
    if (result != null) {
      return;
    }

    var nodeLine = lineInfo.getLocation(node.offset).lineNumber;
    if (nodeLine == requestedLine) {
      result = node;
    }

    super.visitMethodInvocation(node);
  }
}

class _Replacement {
  final int offset;
  final int end;
  final String text;

  _Replacement(this.offset, this.end, this.text);
}
