// 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.

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

import 'package:dwds/dwds.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 'fixtures/context.dart';
import 'fixtures/project.dart';
import 'fixtures/utilities.dart';

const originalString = 'Hello World!';
const newString = 'Bonjour le monde!';

void main() {
  // set to true for debug logging.
  final debug = false;

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

  final context = TestContext(TestProject.testAppendBody, provider);

  Future<void> makeEditAndWaitForRebuild() async {
    context.makeEditToDartEntryFile(
      toReplace: originalString,
      replaceWith: newString,
    );
    await context.waitForSuccessfulBuild(propagateToBrowser: true);
  }

  void undoEdit() {
    context.makeEditToDartEntryFile(
      toReplace: newString,
      replaceWith: originalString,
    );
  }

  group(
    'Injected client with live reload',
    () {
      group('and with debugging', () {
        setUp(() async {
          setCurrentLogWriter(debug: debug);
          await context.setUp(
            testSettings: TestSettings(
              reloadConfiguration: ReloadConfiguration.liveReload,
            ),
          );
        });

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

        test('can live reload changes ', () async {
          await makeEditAndWaitForRebuild();
          final source = await context.webDriver.pageSource;

          // A full reload should clear the state.
          expect(source.contains(originalString), isFalse);
          expect(source.contains(newString), isTrue);
        });
      });

      group('and without debugging', () {
        setUp(() async {
          setCurrentLogWriter(debug: debug);
          await context.setUp(
            testSettings: TestSettings(
              reloadConfiguration: ReloadConfiguration.liveReload,
            ),
            debugSettings: TestDebugSettings.noDevTools().copyWith(
              enableDebugging: false,
            ),
          );
        });

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

        test('can live reload changes ', () async {
          await makeEditAndWaitForRebuild();

          final source = await context.webDriver.pageSource;

          // A full reload should clear the state.
          expect(source.contains(originalString), isFalse);
          expect(source.contains(newString), isTrue);
        });
      });

      group('and without debugging using WebSockets', () {
        setUp(() async {
          setCurrentLogWriter(debug: debug);
          await context.setUp(
            testSettings: TestSettings(
              reloadConfiguration: ReloadConfiguration.liveReload,
            ),
            debugSettings: TestDebugSettings.noDevTools().copyWith(
              enableDebugging: false,
              useSse: false,
            ),
          );
        });

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

        test('can live reload changes ', () async {
          await makeEditAndWaitForRebuild();

          final source = await context.webDriver.pageSource;

          // A full reload should clear the state.
          expect(source.contains(originalString), isFalse);
          expect(source.contains(newString), isTrue);
        });
      });
    },
    timeout: Timeout.factor(2),
  );

  group(
    'Injected client',
    () {
      late VmService fakeClient;

      setUp(() async {
        setCurrentLogWriter(debug: debug);
        await context.setUp(
          testSettings: TestSettings(
            enableExpressionEvaluation: true,
          ),
        );
        fakeClient = await context.connectFakeClient();
      });

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

      test('destroys and recreates the isolate during a hot restart', () async {
        final client = context.debugConnection.vmService;
        await client.streamListen('Isolate');
        await makeEditAndWaitForRebuild();

        final eventsDone = expectLater(
          client.onIsolateEvent,
          emitsThrough(
            emitsInOrder([
              _hasKind(EventKind.kIsolateExit),
              _hasKind(EventKind.kIsolateStart),
              _hasKind(EventKind.kIsolateRunnable),
            ]),
          ),
        );

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

        await eventsDone;
      });

      test('can execute simultaneous hot restarts', () async {
        final client = context.debugConnection.vmService;
        await client.streamListen('Isolate');
        await makeEditAndWaitForRebuild();

        final eventsDone = expectLater(
          client.onIsolateEvent,
          emitsThrough(
            emitsInOrder([
              _hasKind(EventKind.kIsolateExit),
              _hasKind(EventKind.kIsolateStart),
              _hasKind(EventKind.kIsolateRunnable),
            ]),
          ),
        );

        // Execute two hot restart calls in parallel.
        final hotRestart = context.getRegisteredServiceExtension('hotRestart');
        final done = Future.wait([
          fakeClient.callServiceExtension(hotRestart!),
          fakeClient.callServiceExtension(hotRestart),
        ]);
        expect(
          await done,
          [const TypeMatcher<Success>(), const TypeMatcher<Success>()],
        );

        // The debugger is still working.
        final vm = await client.getVM();
        final isolateId = vm.isolates!.first.id!;
        final isolate = await client.getIsolate(isolateId);
        final library = isolate.rootLib!.uri!;

        final result = await client.evaluate(isolateId, library, 'true');
        expect(
          result,
          isA<InstanceRef>().having(
            (instance) => instance.valueAsString,
            'valueAsString',
            'true',
          ),
        );

        await eventsDone;
      });

      test('destroys and recreates the isolate during a page refresh',
          () async {
        final client = context.debugConnection.vmService;
        await client.streamListen('Isolate');
        await makeEditAndWaitForRebuild();

        final eventsDone = expectLater(
          client.onIsolateEvent,
          emitsThrough(
            emitsInOrder([
              _hasKind(EventKind.kIsolateExit),
              _hasKind(EventKind.kIsolateStart),
              _hasKind(EventKind.kIsolateRunnable),
            ]),
          ),
        );

        await context.webDriver.driver.refresh();

        await eventsDone;
      });

      test('can hot restart via the service extension', () async {
        final client = context.debugConnection.vmService;
        await client.streamListen('Isolate');
        await makeEditAndWaitForRebuild();

        final eventsDone = expectLater(
          client.onIsolateEvent,
          emitsThrough(
            emitsInOrder([
              _hasKind(EventKind.kIsolateExit),
              _hasKind(EventKind.kIsolateStart),
              _hasKind(EventKind.kIsolateRunnable),
            ]),
          ),
        );
        final hotRestart = context.getRegisteredServiceExtension('hotRestart');
        expect(
          await fakeClient.callServiceExtension(hotRestart!),
          const TypeMatcher<Success>(),
        );

        await eventsDone;

        final source = await context.webDriver.pageSource;
        // Main is re-invoked which shouldn't clear the state.
        expect(source, contains(originalString));
        expect(source, contains(newString));
      });

      test('can send events before and after hot restart', () async {
        final client = context.debugConnection.vmService;
        await client.streamListen('Isolate');

        // The event just before hot restart might never be received,
        // but the injected client continues to work and send events
        // after hot restart.
        final eventsDone = expectLater(
          client.onIsolateEvent,
          emitsThrough(
            _hasKind(EventKind.kServiceExtensionAdded)
                .having((e) => e.extensionRPC, 'service', 'ext.bar'),
          ),
        );

        var vm = await client.getVM();
        var isolateId = vm.isolates!.first.id!;
        var isolate = await client.getIsolate(isolateId);
        var library = isolate.rootLib!.uri!;

        final callback = '(_, __) async => ServiceExtensionResponse.result("")';

        await client.evaluate(
          isolateId,
          library,
          "registerExtension('ext.foo', $callback)",
        );

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

        vm = await client.getVM();
        isolateId = vm.isolates!.first.id!;
        isolate = await client.getIsolate(isolateId);
        library = isolate.rootLib!.uri!;

        await client.evaluate(
          isolateId,
          library,
          "registerExtension('ext.bar', $callback)",
        );

        await eventsDone;

        final source = await context.webDriver.pageSource;
        // Main is re-invoked which shouldn't clear the state.
        expect(source, contains('Hello World!'));
      });

      test('can refresh the page via the fullReload service extension',
          () async {
        final client = context.debugConnection.vmService;
        await client.streamListen('Isolate');
        await makeEditAndWaitForRebuild();

        final eventsDone = expectLater(
          client.onIsolateEvent,
          emitsThrough(
            emitsInOrder([
              _hasKind(EventKind.kIsolateExit),
              _hasKind(EventKind.kIsolateStart),
              _hasKind(EventKind.kIsolateRunnable),
            ]),
          ),
        );

        final fullReload = context.getRegisteredServiceExtension('fullReload');
        expect(
          await fakeClient.callServiceExtension(fullReload!),
          isA<Success>(),
        );

        await eventsDone;

        final source = await context.webDriver.pageSource;
        // Should see only the new text
        expect(source.contains(originalString), isFalse);
        expect(source.contains(newString), isTrue);
      });

      test('can hot restart while paused', () async {
        final client = context.debugConnection.vmService;
        var vm = await client.getVM();
        var isolateId = vm.isolates!.first.id!;
        await client.streamListen('Debug');
        final stream = client.onEvent('Debug');
        final scriptList = await client.getScripts(isolateId);
        final main = scriptList.scripts!
            .firstWhere((script) => script.uri!.contains('main.dart'));
        final bpLine =
            await context.findBreakpointLine('printCount', isolateId, main);
        await client.addBreakpoint(isolateId, main.id!, bpLine);
        await stream
            .firstWhere((event) => event.kind == EventKind.kPauseBreakpoint);

        await makeEditAndWaitForRebuild();
        final hotRestart = context.getRegisteredServiceExtension('hotRestart');
        await fakeClient.callServiceExtension(hotRestart!);
        final source = await context.webDriver.pageSource;

        // Main is re-invoked which shouldn't clear the state.
        expect(source.contains(originalString), isTrue);
        expect(source.contains(newString), isTrue);

        vm = await client.getVM();
        isolateId = vm.isolates!.first.id!;
        final isolate = await client.getIsolate(isolateId);

        // Previous breakpoint should be cleared.
        expect(isolate.breakpoints!.isEmpty, isTrue);
      });

      test('can evaluate expressions after hot restart', () async {
        final client = context.debugConnection.vmService;

        final hotRestart = context.getRegisteredServiceExtension('hotRestart');
        await fakeClient.callServiceExtension(hotRestart!);

        final vm = await client.getVM();
        final isolateId = vm.isolates!.first.id!;
        final isolate = await client.getIsolate(isolateId);
        final library = isolate.rootLib!.uri!;

        // Expression evaluation while running should work.
        final result = await client.evaluate(isolateId, library, 'true');
        expect(
          result,
          isA<InstanceRef>().having(
            (instance) => instance.valueAsString,
            'valueAsString',
            'true',
          ),
        );
      });
    },
    timeout: Timeout.factor(2),
  );

  group(
    'Injected client with hot restart',
    () {
      group('and with debugging', () {
        setUp(() async {
          setCurrentLogWriter(debug: debug);
          await context.setUp(
            testSettings: TestSettings(
              reloadConfiguration: ReloadConfiguration.hotRestart,
            ),
          );
        });

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

        test('can hot restart changes ', () async {
          await makeEditAndWaitForRebuild();

          final source = await context.webDriver.pageSource;

          // Main is re-invoked which shouldn't clear the state.
          expect(source.contains(originalString), isTrue);
          expect(source.contains(newString), isTrue);
          // The ext.flutter.disassemble callback is invoked and waited for.
          expect(
            source,
            contains('start disassemble end disassemble $newString'),
          );
        });

        test('fires isolate create/destroy events during hot restart',
            () async {
          final client = context.debugConnection.vmService;
          await client.streamListen('Isolate');

          final eventsDone = expectLater(
            client.onIsolateEvent,
            emitsThrough(
              emitsInOrder([
                _hasKind(EventKind.kIsolateExit),
                _hasKind(EventKind.kIsolateStart),
                _hasKind(EventKind.kIsolateRunnable),
              ]),
            ),
          );

          await makeEditAndWaitForRebuild();

          await eventsDone;
        });
      });

      group('and without debugging', () {
        setUp(() async {
          setCurrentLogWriter(debug: debug);
          await context.setUp(
            testSettings: TestSettings(
              reloadConfiguration: ReloadConfiguration.hotRestart,
            ),
            debugSettings:
                TestDebugSettings.noDevTools().copyWith(enableDebugging: false),
          );
        });

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

        test('can hot restart changes ', () async {
          await makeEditAndWaitForRebuild();

          final source = await context.webDriver.pageSource;

          // Main is re-invoked which shouldn't clear the state.
          expect(source.contains(originalString), isTrue);
          expect(source.contains(newString), isTrue);
          // The ext.flutter.disassemble callback is invoked and waited for.
          expect(
            source,
            contains('start disassemble end disassemble $newString'),
          );
        });
      });
    },
    timeout: Timeout.factor(2),
  );

  // TODO(https://github.com/dart-lang/webdev/issues/2380): Run these tests with
  // the FrontendServer as well.
  group('when isolates_paused_on_start is true', () {
    late VmService client;
    late VmService fakeClient;

    setUp(() async {
      setCurrentLogWriter(debug: debug);
      await context.setUp(
        testSettings: TestSettings(
          enableExpressionEvaluation: true,
        ),
      );
      client = context.debugConnection.vmService;
      fakeClient = await context.connectFakeClient();
      await client.setFlag('pause_isolates_on_start', 'true');
      await client.streamListen('Isolate');
    });

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

    test('after hot-restart, does not run app until there is a resume event',
        () async {
      await makeEditAndWaitForRebuild();

      final eventsDone = expectLater(
        client.onIsolateEvent,
        emitsThrough(
          emitsInOrder([
            _hasKind(EventKind.kIsolateExit),
            _hasKind(EventKind.kIsolateStart),
            _hasKind(EventKind.kIsolateRunnable),
          ]),
        ),
      );

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

      await eventsDone;

      final sourceBeforeResume = await context.webDriver.pageSource;
      expect(sourceBeforeResume.contains(newString), isFalse);

      final vm = await client.getVM();
      final isolateId = vm.isolates!.first.id!;
      await client.resume(isolateId);

      final sourceAfterResume = await context.webDriver.pageSource;
      expect(sourceAfterResume.contains(newString), isTrue);
    });

    test('after page refresh, does not run app until there is a resume event',
        () async {
      await makeEditAndWaitForRebuild();

      await context.webDriver.driver.refresh();

      final eventsDone = expectLater(
        client.onIsolateEvent,
        emitsThrough(
          emitsInOrder([
            _hasKind(EventKind.kIsolateExit),
            _hasKind(EventKind.kIsolateStart),
            _hasKind(EventKind.kIsolateRunnable),
          ]),
        ),
      );

      await eventsDone;

      final sourceBeforeResume = await context.webDriver.pageSource;
      expect(sourceBeforeResume.contains(newString), isFalse);

      final vm = await client.getVM();
      final isolateId = vm.isolates!.first.id!;
      await client.resume(isolateId);

      final sourceAfterResume = await context.webDriver.pageSource;
      expect(sourceAfterResume.contains(newString), isTrue);
    });
  });
}

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