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

@Tags(['daily'])
@TestOn('vm')
@Timeout(Duration(minutes: 5))
library;

import 'dart:async';

import 'package:dwds/expression_compiler.dart';
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 'package:webkit_inspection_protocol/webkit_inspection_protocol.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,
    canaryFeatures: true,
    ddcModuleFormat: ModuleFormat.ddc,
  );
  final project = TestProject.testHotRestartBreakpoints;
  final context = TestContext(project, provider);
  final mainFile = project.dartEntryFileName;
  final callLogMarker = 'callLog';

  tearDownAll(provider.dispose);

  Future<void> makeEditsAndRecompile(List<Edit> edits) async {
    await context.makeEdits(edits);
    await context.recompile(fullRestart: true);
  }

  group('when pause_isolates_on_start is true', () {
    late VmService client;
    late VmServiceInterface service;
    late Stream<Event> stream;
    // Fetch the log statements that are sent to console.
    final consoleLogs = <String>[];
    late StreamSubscription<ConsoleAPIEvent> consoleSubscription;

    setUp(() async {
      setCurrentLogWriter(debug: debug);
      await context.setUp(
        testSettings: TestSettings(
          enableExpressionEvaluation: true,
          compilationMode: CompilationMode.frontendServer,
          moduleFormat: provider.ddcModuleFormat,
          canaryFeatures: provider.canaryFeatures,
        ),
      );
      client = await context.connectFakeClient();
      service = context.service;
      await client.setFlag('pause_isolates_on_start', 'true');
      await client.streamListen(EventStreams.kIsolate);
      await client.streamListen(EventStreams.kDebug);
      stream = client.onDebugEvent;
      consoleSubscription = context.webkitDebugger.onConsoleAPICalled.listen(
        (e) => consoleLogs.add(e.args.first.value as String),
      );
    });

    tearDown(() async {
      await consoleSubscription.cancel();
      consoleLogs.clear();
      await context.tearDown();
    });

    Future<Breakpoint> addBreakpoint({
      required String file,
      required String breakpointMarker,
    }) async {
      final vm = await client.getVM();
      final isolateId = vm.isolates!.first.id!;
      final scriptList = await client.getScripts(isolateId);
      final scriptRef = scriptList.scripts!.firstWhere(
        (script) => script.uri!.contains(file),
      );
      final bpLine = await context.findBreakpointLine(
        breakpointMarker,
        isolateId,
        scriptRef,
      );
      final breakpointAdded = expectLater(
        stream,
        emits(_hasKind(EventKind.kBreakpointAdded)),
      );
      final breakpoint = await client.addBreakpointWithScriptUri(
        isolateId,
        scriptRef.uri!,
        bpLine,
      );
      await breakpointAdded;
      return breakpoint;
    }

    Future<void> resume() async {
      final vm = await client.getVM();
      final isolate = await client.getIsolate(vm.isolates!.first.id!);
      await client.resume(isolate.id!);
    }

    // Resume the program, and check that at some point it will execute code
    // that will print `expectedString` to the console.
    Future<void> resumeAndWaitForLog(String expectedString) async {
      final completer = Completer<void>();
      final subscription = context.webkitDebugger.onConsoleAPICalled.listen((
        e,
      ) {
        if (e.args.first.value == expectedString) {
          completer.complete();
        }
      });
      await resume();
      await completer.future.timeout(
        const Duration(minutes: 1),
        onTimeout: () {
          throw TimeoutException(
            "Failed to find log: '$expectedString' in console.",
          );
        },
      );
      await subscription.cancel();
    }

    Future<void> hotRestartAndHandlePausePost(
      List<({String file, String breakpointMarker, bool exists})> breakpoints,
    ) async {
      final isolateEvents = expectLater(
        client.onIsolateEvent,
        emitsInOrder([
          _hasKind(EventKind.kIsolateExit),
          _hasKind(EventKind.kIsolateStart),
          _hasKind(EventKind.kIsolateRunnable),
        ]),
      );
      final breakpointEvents = expectLater(
        stream,
        emitsInOrder([
          for (final (:exists, breakpointMarker: _, file: _)
              in breakpoints) ...[
            if (exists) _hasKind(EventKind.kBreakpointRemoved),
          ],
          _hasKind(EventKind.kResume),
          _hasKind(EventKind.kPausePostRequest),
          for (final _ in breakpoints) ...[
            _hasKind(EventKind.kBreakpointAdded),
          ],
        ]),
      );

      final waitForPausePost = expectLater(
        stream,
        emitsThrough(_hasKind(EventKind.kPausePostRequest)),
      );

      final hotRestart = context.getRegisteredServiceExtension('hotRestart');
      expect(
        await client.callServiceExtension(hotRestart!),
        const TypeMatcher<Success>(),
      );

      await isolateEvents;

      // DWDS defers running main after a hot restart until the client (e.g.
      // DAP) resumes. Client should listen for this event, remove breakpoints
      // (we don't remove them here as DWDS already removes them), and
      // reregister breakpoints (which will be registered in the new files), and
      // resume.
      await waitForPausePost;
      // Verify DWDS has already removed the breakpoints at this point.
      final vm = await client.getVM();
      final isolate = await service.getIsolate(vm.isolates!.first.id!);
      expect(isolate.breakpoints, isEmpty);
      for (final (exists: _, :breakpointMarker, :file) in breakpoints) {
        await addBreakpoint(file: file, breakpointMarker: breakpointMarker);
      }
      await resume();
      await breakpointEvents;
    }

    Future<Event> waitForBreakpoint() =>
        stream.firstWhere((event) => event.kind == EventKind.kPauseBreakpoint);

    test('empty hot restart keeps breakpoints', () async {
      final genString = 'main gen0';

      await addBreakpoint(file: mainFile, breakpointMarker: callLogMarker);

      final breakpointFuture = waitForBreakpoint();

      await context.recompile(fullRestart: false);

      await hotRestartAndHandlePausePost([
        (exists: true, file: mainFile, breakpointMarker: callLogMarker),
      ]);

      // Should break at `callLog`.
      await breakpointFuture;
      await resumeAndWaitForLog(genString);
    });

    test('after edit and hot restart, breakpoint is in new file', () async {
      final oldLog = 'main gen0';
      final newLog = 'main gen1';

      await addBreakpoint(file: mainFile, breakpointMarker: callLogMarker);

      await makeEditsAndRecompile([
        (file: mainFile, originalString: oldLog, newString: newLog),
      ]);

      final breakpointFuture = waitForBreakpoint();

      await hotRestartAndHandlePausePost([
        (exists: true, file: mainFile, breakpointMarker: callLogMarker),
      ]);

      // Should break at `callLog`.
      await breakpointFuture;
      expect(consoleLogs.contains(newLog), false);
      await resumeAndWaitForLog(newLog);
    });

    test('after adding line, hot restart, removing line, and hot restart, '
        'breakpoint is correct across both hot restarts', () async {
      final genLog = 'main gen0';

      await addBreakpoint(file: mainFile, breakpointMarker: callLogMarker);

      // Add an extra log before the existing log.
      final extraLog = 'hot reload';
      final oldString = "log('";
      final newString = "log('$extraLog');\n$oldString";
      await makeEditsAndRecompile([
        (file: mainFile, originalString: oldString, newString: newString),
      ]);

      var breakpointFuture = waitForBreakpoint();

      await hotRestartAndHandlePausePost([
        (exists: true, file: mainFile, breakpointMarker: callLogMarker),
      ]);

      // Should break at `callLog`.
      await breakpointFuture;
      expect(consoleLogs.contains(extraLog), true);
      expect(consoleLogs.contains(genLog), false);
      await resumeAndWaitForLog(genLog);

      consoleLogs.clear();

      // Remove the line we just added.
      await makeEditsAndRecompile([
        (file: mainFile, originalString: newString, newString: oldString),
      ]);

      breakpointFuture = waitForBreakpoint();

      await hotRestartAndHandlePausePost([
        (exists: true, file: mainFile, breakpointMarker: callLogMarker),
      ]);

      // Should break at `callLog`.
      await breakpointFuture;
      expect(consoleLogs.contains(extraLog), false);
      expect(consoleLogs.contains(genLog), false);
      await resumeAndWaitForLog(genLog);
    });

    test(
      'after adding file and putting breakpoint in it, breakpoint is correctly '
      'registered',
      () async {
        final genLog = 'main gen0';

        await addBreakpoint(file: mainFile, breakpointMarker: callLogMarker);

        // Add a library file, import it, and then refer to it in the log.
        final libFile = 'library.dart';
        final libGenLog = 'library gen0';
        final libValueMarker = 'libValue';
        context.addLibraryFile(
          libFileName: libFile,
          contents: '''String get libraryValue {
            return '$libGenLog'; // Breakpoint: $libValueMarker
          }''',
        );
        final oldImports = "import 'dart:js_interop';";
        final newImports =
            '$oldImports\n'
            "import 'package:_test_hot_restart_breakpoints/library.dart';";
        final edits = [
          (file: mainFile, originalString: oldImports, newString: newImports),
        ];
        final oldLog = "log('$genLog');";
        final newLog = "log('\$libraryValue');";
        edits.add((file: mainFile, originalString: oldLog, newString: newLog));
        await makeEditsAndRecompile(edits);

        var breakpointFuture = waitForBreakpoint();

        await hotRestartAndHandlePausePost([
          (exists: true, file: mainFile, breakpointMarker: callLogMarker),
          (exists: false, file: libFile, breakpointMarker: libValueMarker),
        ]);

        // Should break at `callLog`.
        await breakpointFuture;
        expect(consoleLogs.contains(libGenLog), false);

        breakpointFuture = waitForBreakpoint();

        await resume();
        // Should break at `libValue`.
        await breakpointFuture;
        expect(consoleLogs.contains(libGenLog), false);
        await resumeAndWaitForLog(libGenLog);
      },
    );

    // Test that we wait for all scripts to be parsed first before computing
    // location metadata.
    test('after adding many files and putting breakpoint in the last one,'
        'breakpoint is correctly registered', () async {
      final genLog = 'main gen0';

      await addBreakpoint(file: mainFile, breakpointMarker: callLogMarker);

      // Add library files, import them, but only refer to the last one in main.
      final numFiles = 50;
      final edits = <Edit>[];
      for (var i = 1; i <= numFiles; i++) {
        final libFile = 'library$i.dart';
        context.addLibraryFile(
          libFileName: libFile,
          contents: '''String get libraryValue$i {
            return 'library$i gen1'; // Breakpoint: libValue$i
          }''',
        );
        final oldImports = "import 'dart:js_interop';";
        final newImports =
            '$oldImports\n'
            "import 'package:_test_hot_restart_breakpoints/$libFile';";
        edits.add((
          file: mainFile,
          originalString: oldImports,
          newString: newImports,
        ));
      }
      final oldLog = "log('$genLog');";
      final newLog = "log('\$libraryValue$numFiles');";
      edits.add((file: mainFile, originalString: oldLog, newString: newLog));
      await makeEditsAndRecompile(edits);

      var breakpointFuture = waitForBreakpoint();

      await hotRestartAndHandlePausePost([
        (exists: true, file: mainFile, breakpointMarker: callLogMarker),
        (
          exists: false,
          file: 'library$numFiles.dart',
          breakpointMarker: 'libValue$numFiles',
        ),
      ]);

      final newGenLog = 'library$numFiles gen1';

      // Should break at `callLog`.
      await breakpointFuture;
      expect(consoleLogs.contains(newGenLog), false);

      breakpointFuture = waitForBreakpoint();

      await resume();
      // Should break at the breakpoint in the last file.
      await breakpointFuture;
      expect(consoleLogs.contains(newGenLog), false);
      await resumeAndWaitForLog(newGenLog);
    });
  });
}

TypeMatcher<Event> _hasKind(String kind) =>
    isA<Event>().having((e) => e.kind, 'kind', kind);
