// Copyright (c) 2021, 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 'package:dap/dap.dart';
import 'package:dds/src/dap/adapters/dart.dart';
import 'package:test/test.dart';

import 'test_client.dart';
import 'test_scripts.dart';
import 'test_support.dart';

main() {
  late DapTestSession dap;
  setUp(() async {
    dap = await DapTestSession.setUp();
  });
  tearDown(() => dap.tearDown());

  group('debug mode evaluation', () {
    test('evaluates expressions with simple results', () async {
      final client = dap.client;
      final testFile = dap.createTestFile('''
void main(List<String> args) {
  var a = 1;
  var b = 2;
  var c = 'test';
  print('Hello!'); $breakpointMarker
}''');
      final breakpointLine = lineWith(testFile, breakpointMarker);

      final stop = await client.hitBreakpoint(testFile, breakpointLine);
      final topFrameId = await client.getTopFrameId(stop.threadId!);
      await client.expectEvalResult(topFrameId, 'a', '1');
      await client.expectEvalResult(topFrameId, 'a * b', '2');
      await client.expectEvalResult(topFrameId, 'c', '"test"');
    });

    test('evaluates expressions with complex results', () async {
      final client = dap.client;
      final testFile = dap.createTestFile(simpleBreakpointProgram);
      final breakpointLine = lineWith(testFile, breakpointMarker);

      final stop = await client.hitBreakpoint(testFile, breakpointLine);
      final topFrameId = await client.getTopFrameId(stop.threadId!);
      final result = await client.expectEvalResult(
        topFrameId,
        'DateTime(2000, 1, 1)',
        'DateTime',
      );

      // Check we got a variablesReference that maps on to the fields.
      expect(result.variablesReference, isPositive);
      await client.expectVariables(
        result.variablesReference,
        '''
            isUtc: false, eval: DateTime(2000, 1, 1).isUtc
        ''',
      );
    });

    test('evaluates expressions ending with semicolons', () async {
      final client = dap.client;
      final testFile = dap.createTestFile('''
void main(List<String> args) {
  var a = 1;
  var b = 2;
  print('Hello!'); $breakpointMarker
}''');
      final breakpointLine = lineWith(testFile, breakpointMarker);

      final stop = await client.hitBreakpoint(testFile, breakpointLine);
      final topFrameId = await client.getTopFrameId(stop.threadId!);
      await client.expectEvalResult(topFrameId, 'a + b;', '3');
    });

    test('evaluates complex expressions with evaluateToStringInDebugViews=true',
        () async {
      final client = dap.client;
      final testFile = dap.createTestFile(simpleBreakpointProgram);
      final breakpointLine = lineWith(testFile, breakpointMarker);

      final stop = await client.hitBreakpoint(
        testFile,
        breakpointLine,
        launch: () =>
            client.launch(testFile.path, evaluateToStringInDebugViews: true),
      );

      final topFrameId = await client.getTopFrameId(stop.threadId!);
      await client.expectEvalResult(
        topFrameId,
        'DateTime(2000, 1, 1)',
        'DateTime (2000-01-01 00:00:00.000)',
      );
    });

    test(
        'evaluates $threadExceptionExpression to the threads exception (simple type)',
        () async {
      final client = dap.client;
      final testFile = dap.createTestFile(r'''
void main(List<String> args) {
  throw 'my error';
}''');

      final stop = await client.hitException(testFile);
      final topFrameId = await client.getTopFrameId(stop.threadId!);
      final result = await client.expectEvalResult(
        topFrameId,
        threadExceptionExpression,
        '"my error"',
      );
      expect(result.variablesReference, equals(0));
    });

    test(
        'evaluates $threadExceptionExpression to the threads exception (complex type)',
        () async {
      final client = dap.client;
      final testFile = dap.createTestFile(r'''
void main(List<String> args) {
  throw Exception('my error');
}''');

      final stop = await client.hitException(testFile);
      final topFrameId = await client.getTopFrameId(stop.threadId!);
      final result = await client.expectEvalResult(
        topFrameId,
        threadExceptionExpression,
        '_Exception',
      );
      expect(result.variablesReference, isPositive);
    });

    test(
        'evaluates $threadExceptionExpression.x.y to x.y on the threads exception',
        () async {
      final client = dap.client;
      final testFile = dap.createTestFile(r'''
void main(List<String> args) {
  throw Exception('12345');
}
    ''');

      final stop = await client.hitException(testFile);
      final topFrameId = await client.getTopFrameId(stop.threadId!);
      await client.expectEvalResult(
        topFrameId,
        '$threadExceptionExpression.message.length',
        '5',
      );
    });

    test('can evaluate expressions in non-top frames', () async {
      final client = dap.client;
      final testFile = dap.createTestFile('''
void main(List<String> args) {
  var a = 999;
  foo();
}

void foo() {
  var a = 111; $breakpointMarker
}''');
      final breakpointLine = lineWith(testFile, breakpointMarker);

      final stop = await client.hitBreakpoint(testFile, breakpointLine);
      final stack = await client.getValidStack(stop.threadId!,
          startFrame: 0, numFrames: 2);
      final secondFrameId = stack.stackFrames[1].id;

      await client.expectEvalResult(secondFrameId, 'a', '999');
    });

    test('returns the full message for evaluation errors', () async {
      final client = dap.client;
      final testFile = dap.createTestFile(simpleBreakpointProgram);
      final breakpointLine = lineWith(testFile, breakpointMarker);

      final stop = await client.hitBreakpoint(testFile, breakpointLine);
      final topFrameId = await client.getTopFrameId(stop.threadId!);
      expectResponseError(
        client.evaluate(
          '1 + "a"',
          frameId: topFrameId,
        ),
        allOf([
          contains('evaluateInFrame: (113) Expression compilation error'),
          contains("'String' can't be assigned to a variable of type 'num'."),
          contains(
            '1 + "a"\n'
            '    ^',
          )
        ]),
      );
    });

    test('returns short errors for evaluation in "watch" context', () async {
      final client = dap.client;
      final testFile = dap.createTestFile(simpleBreakpointProgram);
      final breakpointLine = lineWith(testFile, breakpointMarker);

      final stop = await client.hitBreakpoint(testFile, breakpointLine);
      final topFrameId = await client.getTopFrameId(stop.threadId!);
      expectResponseError(
        client.evaluate(
          '1 + "a"',
          frameId: topFrameId,
          context: 'watch',
        ),
        equals(
          "A value of type 'String' can't be assigned "
          "to a variable of type 'num'.",
        ),
      );
    });

    test('returns truncated strings by default', () async {
      final client = dap.client;
      final testFile = dap.createTestFile(simpleBreakpointProgram);
      final breakpointLine = lineWith(testFile, breakpointMarker);

      final stop = await client.hitBreakpoint(testFile, breakpointLine);
      final topFrameId = await client.getTopFrameId(stop.threadId!);

      final expectedTruncatedString = 'a' * 128;
      await client.expectEvalResult(
        topFrameId,
        '"a" * 200',
        '"$expectedTruncatedString…"',
      );
    });

    test('returns whole string without quotes when context is "clipboard"',
        () async {
      final client = dap.client;
      final testFile = dap.createTestFile(simpleBreakpointProgram);
      final breakpointLine = lineWith(testFile, breakpointMarker);

      final stop = await client.hitBreakpoint(testFile, breakpointLine);
      final topFrameId = await client.getTopFrameId(stop.threadId!);

      final expectedStringValue = 'a' * 200;
      await client.expectEvalResult(
        topFrameId,
        '"a" * 200',
        expectedStringValue,
        context: 'clipboard',
      );
    });

    test('returns whole string with quotes when context is "repl"', () async {
      final client = dap.client;
      final testFile = dap.createTestFile(simpleBreakpointProgram);
      final breakpointLine = lineWith(testFile, breakpointMarker);

      final stop = await client.hitBreakpoint(testFile, breakpointLine);
      final topFrameId = await client.getTopFrameId(stop.threadId!);

      final expectedStringValue = 'a' * 200;
      await client.expectEvalResult(
        topFrameId,
        '"a" * 200',
        '"$expectedStringValue"',
        context: 'repl',
      );
    });

    test('returns whole string with quotes when context is a script', () async {
      final client = dap.client;
      final testFile = dap.createTestFile(simpleBreakpointProgram);
      final breakpointLine = lineWith(testFile, breakpointMarker);

      final stop = await client.hitBreakpoint(testFile, breakpointLine);
      final topFrameId = await client.getTopFrameId(stop.threadId!);

      final expectedStringValue = 'a' * 200;
      await client.expectEvalResult(
        topFrameId,
        '"a" * 200',
        '"$expectedStringValue"',
        context: Uri.file(testFile.path).toString(),
      );
    });

    test('variableReferences remain valid while an isolate is paused',
        () async {
      final client = dap.client;
      final testFile =
          dap.createTestFile(simpleBreakpointProgramWith50ExtraLines);
      final breakpointLine = lineWith(testFile, breakpointMarker);
      final stop = await client.hitBreakpoint(testFile, breakpointLine);

      // We're paused at a breakpoint. Our evaluate results should continue to
      // work even after GCs.
      final threadId = stop.threadId!;
      final topFrameId = await client.getTopFrameId(threadId);

      // Evaluate something and get back a variablesReference.
      final result = await client.expectEvalResult(
        topFrameId,
        'DateTime(2000, 1, 1)',
        'DateTime',
      );

      // Ensure it remains valid even after GCs. This ensures both DAP preserves
      // the variablesReference, and also that the VM preserved the instance
      // reference.
      for (var i = 0; i < 5; i++) {
        await client.expectVariables(
          result.variablesReference,
          'isUtc: false, eval: DateTime(2000, 1, 1).isUtc',
        );
        // Force GC.
        await client.forceGc(threadId);
      }
    });

    test('variableReferences become invalid after a resume', () async {
      final client = dap.client;
      final testFile =
          dap.createTestFile(simpleBreakpointProgramWith50ExtraLines);
      final breakpointLine = lineWith(testFile, breakpointMarker);
      final stop = await client.hitBreakpoint(
        testFile,
        breakpointLine,
        additionalBreakpoints: [breakpointLine + 1],
      );

      // We're paused at a breakpoint. Our evaluate results should only continue
      // to work until we resume.
      final threadId = stop.threadId!;
      final topFrameId = await client.getTopFrameId(threadId);

      // Evaluate something and get back a variablesReference.
      final evalResult = await client.expectEvalResult(
        topFrameId,
        'DateTime(2000, 1, 1)',
        'DateTime',
      );

      // Resume, which should invalidate the variablesReference.
      await client.continue_(threadId);

      // Verify the reference is no longer valid. This is because we clear
      // the threads variable data on resume, and not because of VM Service
      // Zone IDs. We have no way to validate the VM behaviour because we
      // don't have the reference (it is abstracted from the DAP client) to
      // verify.
      expectResponseError(
        client.variables(evalResult.variablesReference),
        equals('Bad state: variablesReference is no longer valid'),
      );
    });

    test('variableReferences become invalid after a step', () async {
      final client = dap.client;
      final testFile =
          dap.createTestFile(simpleBreakpointProgramWith50ExtraLines);
      final breakpointLine = lineWith(testFile, breakpointMarker);
      final stop = await client.hitBreakpoint(testFile, breakpointLine);

      // We're paused at a breakpoint. Our evaluate results should only continue
      // to work until we resume.
      final threadId = stop.threadId!;
      final topFrameId = await client.getTopFrameId(threadId);

      // Evaluate something and get back a variablesReference.
      final evalResult = await client.expectEvalResult(
        topFrameId,
        'DateTime(2000, 1, 1)',
        'DateTime',
      );

      // Step, which should also invalidate because we're basically unpausing
      // and then pausing again.
      await client.next(threadId);

      // Verify the reference is no longer valid. This is because we clear
      // the threads variable data on resume/step, and not because of VM Service
      // Zone IDs. We have no way to validate the VM behaviour because we
      // don't have the reference (it is abstracted from the DAP client) to
      // verify.
      expectResponseError(
        client.variables(evalResult.variablesReference),
        equals('Bad state: variablesReference is no longer valid'),
      );
    });

    test('evaluation service zones are invalidated on resume', () async {
      final client = dap.client;
      final testFile = dap.createTestFile(simpleBreakpointProgram);
      final breakpointLine = lineWith(testFile, breakpointMarker);
      final stop = await client.hitBreakpoint(testFile, breakpointLine);

      // Enable logging and capture any requests to the VM that are calling
      // invalidateIdZone.
      final loggedEvaluateInFrameRequests = client
          .events('dart.log')
          .map((event) => event.body as Map<String, Object?>)
          .map((body) => body['message'] as String)
          .where((message) => message.contains('"method":"invalidateIdZone"'))
          .toList();
      await client.custom('updateSendLogsToClient', {'enabled': true});

      // Trigger an evaluation because evaluation zones are created lazily
      // and we won't invalidate anything if we haven't created one.
      await client.evaluate('0', frameId: stop.threadId!);

      // Resume and wait for the app to terminate.
      await Future.wait([
        client.continue_(stop.threadId!),
        client.event('terminated'),
      ]);

      // Verify that invalidate had been called.
      expect(
        await loggedEvaluateInFrameRequests,
        isNotEmpty,
      );
    });

    test('evaluation service zones are invalidated on step', () async {
      final client = dap.client;
      final testFile = dap.createTestFile(simpleBreakpointProgram);
      final breakpointLine = lineWith(testFile, breakpointMarker);
      final stop = await client.hitBreakpoint(testFile, breakpointLine);

      // Enable logging and capture any requests to the VM that are calling
      // invalidateIdZone.
      final loggedEvaluateInFrameRequests = client
          .events('dart.log')
          .map((event) => event.body as Map<String, Object?>)
          .map((body) => body['message'] as String)
          .where((message) => message.contains('"method":"invalidateIdZone"'))
          .toList();
      await client.custom('updateSendLogsToClient', {'enabled': true});

      // Trigger an evaluation because evaluation zones are created lazily
      // and we won't invalidate anything if we haven't created one.
      await client.evaluate('0', frameId: stop.threadId!);

      // Step, which should also invalidate because we're basically unpausing
      // and then pausing again.
      await client.next(stop.threadId!);

      // Then disable logging (so we don't get false positives from shutdown)
      // and terminate.
      await client.custom('updateSendLogsToClient', {'enabled': false});
      await Future.wait([
        client.terminate(),
        client.event('terminated'),
      ]);

      // Verify that invalidate had been called.
      expect(
        await loggedEvaluateInFrameRequests,
        isNotEmpty,
      );
    });

    group('global evaluation', () {
      test('can evaluate in a bin/ file when not paused given a bin/ URI',
          () async {
        final client = dap.client;
        await dap.createFooPackage();
        final testFile = dap.createTestFile(globalEvaluationProgram);

        await Future.wait([
          client.initialize(),
          client.launch(testFile.path),
        ], eagerError: true);

        // Wait for a '.' to be printed to know the script is up and running.
        await dap.client.outputEvents
            .firstWhere((event) => event.output.trim() == '.');

        await client.expectGlobalEvalResult(
          'myGlobal',
          '"Hello, world!"',
          context: Uri.file(testFile.path).toString(),
        );
      });

      test('can evaluate when not paused given a lib/ URI', () async {
        final client = dap.client;
        final (_, libFile) = await dap.createFooPackage();
        final binFile = dap.createTestFile(globalEvaluationProgram);

        await Future.wait([
          client.initialize(),
          client.launch(binFile.path),
        ], eagerError: true);

        // Wait for a '.' to be printed to know the script is up and running.
        await dap.client.outputEvents
            .firstWhere((event) => event.output.trim() == '.');

        await client.expectGlobalEvalResult(
          'fooGlobal',
          '"Hello, foo!"',
          context: Uri.file(libFile.path).toString(),
        );
      });

      test('returns a suitable error with no context', () async {
        const expectedErrorMessage = 'Evaluation is only supported when the '
            'debugger is paused unless you have a Dart file active in the '
            'editor';

        final client = dap.client;
        await dap.createFooPackage();
        final testFile = dap.createTestFile(globalEvaluationProgram);

        await Future.wait([
          client.initialize(),
          client.launch(testFile.path),
        ], eagerError: true);

        // Wait for a '.' to be printed to know the script is up and running.
        await dap.client.outputEvents
            .firstWhere((event) => event.output.trim() == '.');

        final response = await client.sendRequest(
          EvaluateArguments(
            expression: 'myGlobal',
          ),
          allowFailure: true,
        );
        expect(response.success, isFalse);
        expect(response.message, expectedErrorMessage);

        // Also verify the structured error body.
        final body = response.body as Map<String, Object?>;
        final error = body['error'] as Map<String, Object?>;
        final variables = error['variables'] as Map<String, Object?>;
        expect(error['format'], '{message}');
        expect(error['showUser'], false);
        expect(variables['message'], expectedErrorMessage);
        expect(variables['stack'], isNotNull);
      });

      test('returns a suitable error with an unknown script context', () async {
        final client = dap.client;
        await dap.createFooPackage();
        final testFile = dap.createTestFile(globalEvaluationProgram);

        await Future.wait([
          client.initialize(),
          client.launch(testFile.path),
        ], eagerError: true);

        // Wait for a '.' to be printed to know the script is up and running.
        await dap.client.outputEvents
            .firstWhere((event) => event.output.trim() == '.');

        final context =
            Uri.file(testFile.path.replaceAll('.dart', '_invalid.dart'))
                .toString();
        final response = await client.sendRequest(
          EvaluateArguments(
            expression: 'myGlobal',
            context: context,
          ),
          allowFailure: true,
        );
        expect(response.success, isFalse);
        expect(
          response.message,
          contains('Unable to find the library for file:'),
        );
      });
    });

    group('format specifiers', () {
      test('",nq" suppresses quotes on strings', () async {
        final client = dap.client;
        final testFile = dap.createTestFile('''
  void main(List<String> args) {
    var myString = 'test';
    print('Hello!'); $breakpointMarker
  }''');
        final breakpointLine = lineWith(testFile, breakpointMarker);

        final stop = await client.hitBreakpoint(testFile, breakpointLine);
        final topFrameId = await client.getTopFrameId(stop.threadId!);
        await client.expectEvalResult(topFrameId, 'myString', '"test"');
        await client.expectEvalResult(topFrameId, 'myString,nq', 'test');
      });

      test('",h" renders numbers in hex', () async {
        final client = dap.client;
        final testFile = dap.createTestFile('''
  void main(List<String> args) {
    var i = 12345;
    print('Hello!'); $breakpointMarker
  }''');
        final breakpointLine = lineWith(testFile, breakpointMarker);

        final stop = await client.hitBreakpoint(testFile, breakpointLine);
        final topFrameId = await client.getTopFrameId(stop.threadId!);
        await client.expectEvalResult(topFrameId, 'i', '12345');
        await client.expectEvalResult(topFrameId, 'i,h', '0x3039');
      });

      test('",d" renders numbers in decimal', () async {
        final client = dap.client;
        final testFile = dap.createTestFile('''
  void main(List<String> args) {
    var i = 12345;
    print('Hello!'); $breakpointMarker
  }''');
        final breakpointLine = lineWith(testFile, breakpointMarker);

        final stop = await client.hitBreakpoint(testFile, breakpointLine);
        final topFrameId = await client.getTopFrameId(stop.threadId!);
        await client.expectEvalResult(topFrameId, 'i', '12345');
        await client.expectEvalResult(topFrameId, 'i,d', '12345');
      });

      test('apply to child values', () async {
        final client = dap.client;
        final testFile = dap.createTestFile('''
  void main(List<String> args) {
    var myItems = [12345, 34567];
    print('Hello!'); $breakpointMarker
  }''');
        final breakpointLine = lineWith(testFile, breakpointMarker);

        final stop = await client.hitBreakpoint(testFile, breakpointLine);
        final topFrameId = await client.getTopFrameId(stop.threadId!);
        final result = await client.expectEvalResult(
            topFrameId, 'myItems,h', 'List (2 items)');

        // Check we got a variablesReference and fetching the child items uses
        // the requested formatting.
        expect(result.variablesReference, isPositive);
        await client.expectVariables(
          result.variablesReference,
          '''
            [0]: 0x3039, eval: myItems[0]
            [1]: 0x8707, eval: myItems[1]
        ''',
        );
      });

      test('multiple can be applied in any order', () async {
        final client = dap.client;
        final testFile = dap.createTestFile('''
  void main(List<String> args) {
    var myItems = [12345, 'test'];
    print('Hello!'); $breakpointMarker
  }''');
        final breakpointLine = lineWith(testFile, breakpointMarker);

        final stop = await client.hitBreakpoint(testFile, breakpointLine);
        final topFrameId = await client.getTopFrameId(stop.threadId!);
        for (final expression in ['myItems,h,nq', 'myItems,nq,h']) {
          final result = await client.expectEvalResult(
              topFrameId, expression, 'List (2 items)');

          // Check we got a variablesReference and fetching the child items uses
          // the requested formatting.
          expect(result.variablesReference, isPositive);
          await client.expectVariables(
            result.variablesReference,
            '''
            [0]: 0x3039, eval: myItems[0]
            [1]: test, eval: myItems[1]
        ''',
          );
        }
      });
    });

    group('value formats', () {
      test('supports format.hex in evaluation arguments', () async {
        final client = dap.client;
        final testFile = dap.createTestFile('''
  void main(List<String> args) {
    var i = 12345;
    print('Hello!'); $breakpointMarker
  }''');
        final breakpointLine = lineWith(testFile, breakpointMarker);

        final stop = await client.hitBreakpoint(testFile, breakpointLine);
        final topFrameId = await client.getTopFrameId(stop.threadId!);
        await client.expectEvalResult(
          topFrameId,
          'i',
          '0x3039',
          format: ValueFormat(hex: true),
        );
      });
    });
    // These tests can be slow due to starting up the external server process.
  }, timeout: Timeout.none);
}
