// Copyright (c) 2019, 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.

@TestOn('vm')
@Timeout(Duration(minutes: 2))
library;

import 'package:test/test.dart';
import 'package:test_common/logging.dart';
import 'package:test_common/test_sdk_configuration.dart';
import 'package:vm_service/vm_service.dart';
import 'package:vm_service_interface/vm_service_interface.dart';

import 'fixtures/context.dart';
import 'fixtures/project.dart';
import 'fixtures/utilities.dart';

void main() {
  // Enable verbose logging for debugging.
  const debug = false;

  final provider = TestSdkConfigurationProvider(verbose: debug);
  tearDownAll(provider.dispose);

  group('shared context |', () {
    final project = TestProject.testPackage();
    final context = TestContext(project, provider);

    setUpAll(() async {
      setCurrentLogWriter(debug: debug);
      await context.setUp(
        testSettings: TestSettings(
          compilationMode: CompilationMode.frontendServer,
          enableExpressionEvaluation: true,
          verboseCompiler: debug,
        ),
      );
    });

    tearDownAll(() async {
      await context.tearDown();
    });

    group('callStack |', () {
      late VmServiceInterface service;
      VM vm;
      late Isolate isolate;
      late String isolateId;
      ScriptList scripts;
      late ScriptRef mainScript;
      late ScriptRef testLibraryScript;
      late Stream<Event> stream;

      setUp(() async {
        setCurrentLogWriter(debug: debug);
        service = context.service;
        vm = await service.getVM();
        isolate = await service.getIsolate(vm.isolates!.first.id!);
        isolateId = isolate.id!;
        scripts = await service.getScripts(isolateId);

        await service.streamListen('Debug');
        stream = service.onEvent('Debug');

        final testPackage = project.packageName;
        mainScript = scripts.scripts!.firstWhere(
          (each) => each.uri!.contains('main.dart'),
        );
        testLibraryScript = scripts.scripts!.firstWhere(
          (each) =>
              each.uri!.contains('package:$testPackage/test_library.dart'),
        );
      });

      tearDown(() async {
        await service.resume(isolateId);
      });

      Future<void> onBreakPoint(
        BreakpointTestData breakpoint,
        Future<void> Function() body,
      ) async {
        Breakpoint? bp;
        try {
          final bpId = breakpoint.bpId;
          final script = breakpoint.script;
          final line = await context.findBreakpointLine(
            bpId,
            isolateId,
            script,
          );
          bp = await context.service.addBreakpointWithScriptUri(
            isolateId,
            script.uri!,
            line,
          );

          expect(bp, isNotNull);
          expect(bp.location, _matchBpLocation(script, line, 0));

          await stream.firstWhere(
            (Event event) => event.kind == EventKind.kPauseBreakpoint,
          );

          await body();
        } finally {
          // Remove breakpoint so it doesn't impact other tests or retries.
          if (bp != null) {
            await context.service.removeBreakpoint(isolateId, bp.id!);
          }
        }
      }

      Future<void> testCallStack(
        List<BreakpointTestData> breakpoints, {
        int frameIndex = 1,
      }) async {
        // Find lines the breakpoints are located on.
        final lines = await Future.wait(
          breakpoints.map(
            (frame) =>
                context.findBreakpointLine(frame.bpId, isolateId, frame.script),
          ),
        );

        // Get current stack.
        final stack = await service.getStack(isolateId);

        // Verify the stack is correct.
        expect(stack.frames!.length, greaterThanOrEqualTo(lines.length));
        final expected = [
          for (var i = 0; i < lines.length; i++)
            _matchFrame(
              breakpoints[i].script,
              breakpoints[i].function,
              lines[i],
            ),
        ];
        expect(stack.frames, containsAll(expected));

        // Verify that expression evaluation is not failing.
        final instance = await service.evaluateInFrame(
          isolateId,
          frameIndex,
          'true',
        );
        expect(instance, isA<InstanceRef>());
      }

      test('breakpoint succeeds with correct callstack', () async {
        // Expected breakpoints on the stack
        final breakpoints = [
          BreakpointTestData(
            'printEnclosingObject',
            'printEnclosingObject',
            mainScript,
          ),
          BreakpointTestData(
            'printEnclosingFunctionMultiLine',
            'printNestedObjectsMultiLine',
            mainScript,
          ),
          BreakpointTestData(
            'callPrintEnclosingFunctionMultiLine',
            '<closure>',
            mainScript,
          ),
        ];
        await onBreakPoint(breakpoints[0], () => testCallStack(breakpoints));
      });

      test('expression evaluation succeeds on parent frame', () async {
        // Expected breakpoints on the stack
        final breakpoints = [
          BreakpointTestData(
            'testLibraryClassConstructor',
            'new',
            testLibraryScript,
          ),
          BreakpointTestData(
            'createLibraryObject',
            'printFieldFromLibraryClass',
            mainScript,
          ),
          BreakpointTestData(
            'callPrintFieldFromLibraryClass',
            '<closure>',
            mainScript,
          ),
        ];
        await onBreakPoint(
          breakpoints[0],
          () => testCallStack(breakpoints, frameIndex: 2),
        );
      });

      test('breakpoint inside a line gives correct callstack', () async {
        // Expected breakpoints on the stack
        final breakpoints = [
          BreakpointTestData('newEnclosedClass', 'new', mainScript),
          BreakpointTestData(
            'printNestedObjectMultiLine',
            'printNestedObjectsMultiLine',
            mainScript,
          ),
          BreakpointTestData(
            'callPrintEnclosingFunctionMultiLine',
            '<closure>',
            mainScript,
          ),
        ];
        await onBreakPoint(breakpoints[0], () => testCallStack(breakpoints));
      });

      test('breakpoint gives correct callstack after step out', () async {
        // Expected breakpoints on the stack
        final breakpoints = [
          BreakpointTestData('newEnclosedClass', 'new', mainScript),
          BreakpointTestData(
            'printEnclosingObjectMultiLine',
            'printNestedObjectsMultiLine',
            mainScript,
          ),
          BreakpointTestData(
            'callPrintEnclosingFunctionMultiLine',
            '<closure>',
            mainScript,
          ),
        ];
        await onBreakPoint(breakpoints[0], () async {
          await service.resume(isolateId, step: 'Out');
          await stream.firstWhere(
            (Event event) => event.kind == EventKind.kPauseInterrupted,
          );
          return testCallStack([breakpoints[1], breakpoints[2]]);
        });
      });

      test('breakpoint gives correct callstack after step in', () async {
        // Expected breakpoints on the stack
        final breakpoints = [
          BreakpointTestData('newEnclosedClass', 'new', mainScript),
          BreakpointTestData(
            'printNestedObjectMultiLine',
            'printNestedObjectsMultiLine',
            mainScript,
          ),
          BreakpointTestData(
            'callPrintEnclosingFunctionMultiLine',
            '<closure>',
            mainScript,
          ),
        ];
        await onBreakPoint(breakpoints[1], () async {
          await service.resume(isolateId, step: 'Into');
          await stream.firstWhere(
            (Event event) => event.kind == EventKind.kPauseInterrupted,
          );
          return testCallStack(breakpoints);
        });
      });

      test(
        'breakpoint gives correct callstack after step into chain calls',
        () async {
          // Expected breakpoints on the stack
          final breakpoints = [
            BreakpointTestData(
              'createObjectWithMethod',
              'createObject',
              mainScript,
            ),
            BreakpointTestData(
              // This is currently incorrect, should be printObjectMultiLine.
              // See issue: https://github.com/dart-lang/sdk/issues/48874
              'printMultiLine',
              'printObjectMultiLine',
              mainScript,
            ),
            BreakpointTestData(
              'callPrintObjectMultiLine',
              '<closure>',
              mainScript,
            ),
          ];
          final bp = BreakpointTestData(
            'printMultiLine',
            'printObjectMultiLine',
            mainScript,
          );
          await onBreakPoint(bp, () async {
            await service.resume(isolateId, step: 'Into');
            await stream.firstWhere(
              (Event event) => event.kind == EventKind.kPauseInterrupted,
            );
            return testCallStack(breakpoints);
          });
        },
      );
    });
  });
}

Matcher _matchFrame(ScriptRef script, String function, int line) => isA<Frame>()
    .having((frame) => frame.code!.name, 'function', function)
    .having(
      (frame) => frame.location,
      'location',
      _matchFrameLocation(script, line),
    );

Matcher _matchBpLocation(ScriptRef script, int line, int column) =>
    isA<SourceLocation>()
        .having((loc) => loc.script, 'script', equals(script))
        .having((loc) => loc.line, 'line', equals(line))
        .having((loc) => loc.column, 'column', greaterThanOrEqualTo(column));

Matcher _matchFrameLocation(ScriptRef script, int line) => isA<SourceLocation>()
    .having((loc) => loc.script, 'script', equals(script))
    .having((loc) => loc.line, 'line', equals(line));

class BreakpointTestData {
  String bpId;
  String function;
  ScriptRef script;

  BreakpointTestData(this.bpId, this.function, this.script);
}
