// Copyright (c) 2023, 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:async';
import 'dart:io';

import 'package:path/path.dart' as path;
import 'package:expect/expect.dart';
import 'package:native_stack_traces/native_stack_traces.dart';

//
// Test framework
//

class _ParsedFrame {
  const _ParsedFrame();

  static _ParsedFrame parse(String frame) {
    if (frame == '<asynchronous suspension>') {
      return const _AsynchronousGap();
    } else {
      return _DartFrame.parse(frame);
    }
  }
}

class _DartFrame extends _ParsedFrame {
  final int no;
  final String symbol;
  final String location;
  final int? lineNo;

  _DartFrame({
    required this.no,
    required this.symbol,
    required this.location,
    required this.lineNo,
  });

  static final _pattern = RegExp(
    r'^#(?<no>\d+)\s+(?<symbol>[^(]+)(\((?<location>((\w+://)?[/\w]+:)?[^:]+)(:(?<line>\d+)(:(?<column>\d+))?)?\))?$',
  );

  static _DartFrame parse(String frame) {
    final match = _pattern.firstMatch(frame);
    if (match == null) {
      throw 'Failed to parse: $frame';
    }

    final no = int.parse(match.namedGroup('no')!);
    final symbol = match.namedGroup('symbol')!.trim();
    var location = match.namedGroup('location')!;
    if (location.endsWith('_test.dart')) {
      location = '%test%';
    }
    final lineNo =
        location.endsWith('utils.dart') || location.endsWith('tests.dart')
        ? match.namedGroup('line')
        : null;

    return _DartFrame(
      no: no,
      symbol: symbol,
      location: location.split('/').last,
      lineNo: lineNo != null ? int.parse(lineNo) : null,
    );
  }

  @override
  String toString() =>
      '#$no    $symbol ($location${lineNo != null ? ':$lineNo' : ''})';

  @override
  bool operator ==(Object other) {
    if (other is! _DartFrame) {
      return false;
    }

    return no == other.no &&
        symbol == other.symbol &&
        location == other.location &&
        lineNo == other.lineNo;
  }
}

class _AsynchronousGap extends _ParsedFrame {
  const _AsynchronousGap();

  @override
  String toString() => '<asynchronous suspension>';
}

final _lineRE = RegExp(r'^(?:#(?<number>\d+)|<asynchronous suspension>)');

Future<List<_ParsedFrame>> _parseStack(String text) async {
  if (text.contains('*** *** ***')) {
    // Looks like DWARF stack traces mode.
    text = await Stream.fromIterable(text.split('\n'))
        .transform(DwarfStackTraceDecoder(_dwarf!))
        .where(_lineRE.hasMatch)
        .join('\n');
  }

  return text
      .split('\n')
      .map((l) => l.trim())
      .where((l) => l.isNotEmpty)
      .map(_ParsedFrame.parse)
      .toList();
}

const _updatingExpectations = bool.fromEnvironment('update.expectations');

final _updatedExpectations = <String>[];
late final List<String> _currentExpectations;

var _testIndex = 0;

late final Dwarf? _dwarf;

void configure(
  List<String> currentExpectations, {
  String debugInfoFilename = 'debug.so',
}) {
  try {
    final testCompilationDir = Platform.environment['TEST_COMPILATION_DIR'];
    if (testCompilationDir != null) {
      debugInfoFilename = path.join(testCompilationDir, debugInfoFilename);
    }
    _dwarf = Dwarf.fromFile(debugInfoFilename)!;
  } on FileSystemException {
    // We're not running in precompiled mode, so the file doesn't exist and
    // we can continue normally.
  }
  _currentExpectations = currentExpectations;
}

Future<void> runTest(Future<void> Function() body) async {
  try {
    await body();
  } catch (e, st) {
    await checkExpectedStack(st);
  }
}

Future<void> checkExpectedStack(StackTrace st) async {
  final expectedFramesString = _testIndex < _currentExpectations.length
      ? _currentExpectations[_testIndex]
      : '';
  final stackTraceString = st.toString();
  final gotFrames = await _parseStack(stackTraceString);
  final normalizedStack = gotFrames.join('\n');
  if (_updatingExpectations) {
    _updatedExpectations.add(normalizedStack);
  } else {
    if (normalizedStack != expectedFramesString) {
      final expectedFrames = await _parseStack(expectedFramesString);
      final isDwarfMode = stackTraceString.contains('*** *** ***');
      print('''
STACK TRACE MISMATCH -----------------
GOT:
$normalizedStack
EXPECTED:
$expectedFramesString
--------------------------------------
To regenate expectations run:
\$ ${Platform.executable} -Dupdate.expectations=true ${Platform.script}
--------------------------------------
''');
      if (isDwarfMode) {
        print('''
--------------------------------------
RAW STACK:
$st
--------------------------------------
''');
      }

      Expect.equals(
        expectedFrames.length,
        gotFrames.length,
        'wrong number of frames',
      );
      for (var i = 0; i < expectedFrames.length; i++) {
        final expectedFrame = expectedFrames[i];
        final gotFrame = gotFrames[i];
        if (expectedFrame == gotFrame) {
          continue;
        }

        if (expectedFrame is _DartFrame && gotFrame is _DartFrame) {
          Expect.equals(
            expectedFrame.symbol,
            gotFrame.symbol,
            'at frame #$i mismatched function name',
          );
          Expect.equals(
            expectedFrame.location,
            gotFrame.location,
            'at frame #$i mismatched location',
          );
          Expect.equals(
            expectedFrame.lineNo,
            gotFrame.lineNo,
            'at frame #$i mismatched line location',
          );
        }

        Expect.equals(expectedFrame, gotFrame);
      }
    }
  }
  _testIndex++;
}

void updateExpectations([String? expectationsFile]) {
  if (!_updatingExpectations) {
    return;
  }

  final sourceFilePath = expectationsFile != null
      ? path.join(path.dirname(Platform.script.toFilePath()), expectationsFile)
      : Platform.script.toFilePath();
  final sourceFile = File(sourceFilePath);

  final source = sourceFile.readAsStringSync();

  final expectationsStart = source.lastIndexOf('// CURRENT EXPECTATIONS BEGIN');
  final updatedExpectationsString = [
    for (var s in _updatedExpectations) '"""\n$s"""',
  ].join(",\n");

  final newSource =
      source.substring(0, expectationsStart) +
      """
// CURRENT EXPECTATIONS BEGIN
final currentExpectations = [${updatedExpectationsString}];
// CURRENT EXPECTATIONS END
""";

  sourceFile.writeAsStringSync(newSource);
  print('updated expectations in ${sourceFile}!');
}

// Check if we are running with obfuscation but without DWARF stack traces
// then we don't have a way to deobfuscate the stack trace.
bool shouldSkip() {
  final stack = StackTrace.current.toString();
  final isObfuscateMode = !stack.contains('shouldSkip');
  final isDwarfStackTracesMode = stack.contains('*** ***');
  final isDLL = Platform.script.toString().endsWith(".dll");

  // We should skip the test if we are running without DWARF stack
  // traces enabled but with obfuscation. Or if running from a DLL,
  // which lacks DWARF.
  return isDLL || (!isDwarfStackTracesMode && isObfuscateMode);
}
