// 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:test/test.dart';
import 'package:test_reflective_loader/test_reflective_loader.dart';

class TextExpectationsCollector {
  /// 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(
      className: 'AbstractCompletionDriverTest',
      methodName: 'assertResponse',
      argument: _ArgumentIndex(0),
    ),
    _AssertMethod(
      className: '_AnalysisDomainTest',
      methodName: 'assertNotificationsText',
      argument: _ArgumentIndex(0),
    ),
    _AssertMethod(
      className: 'SelectionTest',
      methodName: '_assertSelection',
      argument: _ArgumentIndex(1),
    ),
  ];

  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) {
      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[1]!).toFilePath();
        var line = int.parse(locationMatch[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',
          );
        }

        if (invocation.isInFailingTest) {
          return;
        }

        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 UpdateTextExpectations {
  test_applyReplacements() {
    TextExpectationsCollector.apply();
  }
}

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

final class _ArgumentIndex extends _Argument {
  final int index;

  new(this.index);

  @override
  Expression get(ArgumentList argumentList) {
    return argumentList.arguments
        .where((argument) => argument is! NamedArgument)
        .map((argument) => argument.argumentExpression)
        .elementAt(index);
  }
}

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

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

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

  factory(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,
    );
  }

  new _({
    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;

  new({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;

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

extension on AstNode {
  bool get isInFailingTest {
    var testMethod = thisOrAncestorOfType<MethodDeclaration>();
    if (testMethod != null && testMethod.name.lexeme.startsWith('test_')) {
      for (var annotation in testMethod.metadata) {
        var name = annotation.name.name;
        if (const {'FailingTest', 'failingTest'}.contains(name)) {
          return true;
        }
      }
    }
    return false;
  }
}
