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

import 'dart:async';
import 'dart:convert';

import 'package:dwds/src/config/tool_configuration.dart';
import 'package:dwds/src/events.dart';
import 'package:dwds/src/loaders/ddc_library_bundle.dart';
import 'package:dwds/src/services/chrome/chrome_debug_exception.dart';
import 'package:dwds/src/services/chrome/chrome_debug_service.dart';
import 'package:dwds/src/services/chrome/chrome_proxy_service.dart';
import 'package:dwds/src/services/debug_service.dart';
import 'package:dwds/src/services/proxy_service.dart';
import 'package:dwds/src/services/web_socket/web_socket_debug_service.dart';
import 'package:dwds/src/services/web_socket/web_socket_proxy_service.dart';
import 'package:dwds/src/utilities/synchronized.dart';
import 'package:logging/logging.dart';
import 'package:meta/meta.dart';
import 'package:uuid/uuid.dart';
import 'package:vm_service/vm_service.dart';
import 'package:vm_service/vm_service_io.dart';
import 'package:vm_service_interface/vm_service_interface.dart';
import 'package:webkit_inspection_protocol/webkit_inspection_protocol.dart';

/// Type of requests added to the request controller.
typedef VmRequest = Map<String, Object>;

/// Type of responses added to the response controller.
typedef VmResponse = Map<String, Object?>;

enum _NamespacedServiceExtension {
  extDwdsEmitEvent(method: 'ext.dwds.emitEvent'),
  extDwdsReload(method: 'ext.dwds.reload'),
  extDwdsRestart(method: 'ext.dwds.restart'),
  extDwdsScreenshot(method: 'ext.dwds.screenshot'),
  extDwdsSendEvent(method: 'ext.dwds.sendEvent'),
  flutterListViews(method: '_flutter.listViews');

  const _NamespacedServiceExtension({required this.method});

  final String method;
}

/// Common interface for DWDS VM clients.
abstract base class DwdsVmClient<
  T extends ProxyService,
  U extends DebugService<T>
> {
  late final VmService client;
  final _requestController = StreamController<VmRequest>();
  final _responseController = StreamController<VmResponse>();
  late final _responseSink = _responseController.sink;
  late final _responseStream = _responseController.stream.asBroadcastStream();
  late final _requestSink = _requestController.sink;
  late final _requestStream = _requestController.stream;

  Logger get logger;

  DwdsVmClient({required this.debugService});

  final U debugService;

  Future<void>? _closed;

  /// Closes the VM client and releases resources.
  Future<void> close() => _closed ??= () async {
    await _requestController.close();
    await _responseController.close();
    await client.dispose();
  }();

  @mustCallSuper
  Future<void> initialize({required Uri? ddsUri}) async {
    setUpVmServerConnection(
      proxyService: debugService.proxyService,
      debugService: debugService,
      responseStream: _responseStream,
      responseSink: _responseSink,
      requestStream: _requestStream,
      requestSink: _requestSink,
    );

    client = ddsUri == null
        ? _setUpVmClient()
        : await _setUpDdsClient(ddsUri: ddsUri);

    await registerServiceExtensions(
      client: client,
      proxyService: debugService.proxyService,
    );
  }

  /// Establishes a VM service client that is connected via DDS and registers
  /// the service extensions on that client.
  Future<VmService> _setUpDdsClient({required Uri ddsUri}) async {
    final client = await vmServiceConnectUri(ddsUri.toString());
    return client;
  }

  /// Establishes a VM service client that bypasses DDS and registers service
  /// extensions on that client.
  ///
  /// Note: This is only used in the rare cases where DDS is disabled.
  VmService _setUpVmClient() {
    final client = VmService(_responseStream.map(jsonEncode), (request) {
      if (_requestController.isClosed) {
        logger.warning(
          'Attempted to send a request but the connection is closed:\n\n$request',
        );
        return;
      }
      _requestSink.add(Map<String, Object>.from(jsonDecode(request)));
    });
    return client;
  }

  /// Establishes a direct connection with the VM Server.
  ///
  /// This is used to register the [_NamespacedServiceExtension]s. Because
  /// namespaced service extensions are supposed to be registered by the engine,
  /// we need to register them on the VM server connection instead of via DDS.
  ///
  /// TODO(https://github.com/dart-lang/webdev/issues/1315): Ideally the engine
  /// should register all Flutter service extensions. However, to do so we will
  /// need to implement the missing isolate-related dart:developer APIs so that
  /// the engine has access to this information.
  void setUpVmServerConnection({
    required T proxyService,
    required DebugService<T> debugService,
    required Stream<VmResponse> responseStream,
    required StreamSink<VmResponse> responseSink,
    required Stream<VmRequest> requestStream,
    required StreamSink<VmRequest> requestSink,
    DwdsStats? dwdsStats,
  }) {
    responseStream.listen((request) async {
      final response = await _maybeHandleServiceExtensionRequest(
        request,
        proxyService: proxyService,
        dwdsStats: dwdsStats,
      );
      if (response != null) {
        requestSink.add(response);
      }
    });

    final vmServerConnection = VmServerConnection(
      requestStream,
      responseSink,
      debugService.serviceExtensionRegistry,
      proxyService,
    );

    // Register service extensions
    for (final extension in _NamespacedServiceExtension.values) {
      logger.finest('Registering service extension: ${extension.method}');
      debugService.serviceExtensionRegistry.registerExtension(
        extension.method,
        vmServerConnection,
      );
    }
  }

  Future<VmRequest?> _maybeHandleServiceExtensionRequest(
    VmResponse request, {
    required T proxyService,
    required DwdsStats? dwdsStats,
  }) async {
    final response = await maybeHandleServiceExtensionRequestImpl(
      request,
      proxyService: proxyService,
      dwdsStats: dwdsStats,
    );
    if (response != null) {
      response['id'] = request['id'] as String;
      // This is necessary even though DWDS doesn't use package:json_rpc_2.
      // Without it, the response will be treated as invalid:
      // https://github.com/dart-lang/json_rpc_2/blob/639857be892050159f5164c749d7947694976a4a/lib/src/server.dart#L252
      response['jsonrpc'] = '2.0';
    }
    return response;
  }

  @mustBeOverridden
  Future<VmRequest?> maybeHandleServiceExtensionRequestImpl(
    VmResponse request, {
    required T proxyService,
    DwdsStats? dwdsStats,
  });

  @mustBeOverridden
  Future<void> registerServiceExtensions({
    required VmService client,
    required T proxyService,
  });

  /// Shared handler for DWDS send event service extension.
  Future<Map<String, Object>> extDwdsSendEventHandler(
    VmResponse request,
    DwdsStats? dwdsStats,
    Logger logger,
  ) async {
    logger.fine('SendEvent: $request');
    if (dwdsStats != null) {
      _processSendEvent(request, dwdsStats);
    }
    return {'result': Success().toJson()};
  }

  void _processSendEvent(Map<String, dynamic> request, DwdsStats dwdsStats) {
    final event = request['params'] as Map<String, dynamic>?;
    if (event == null) return;
    final type = event['type'] as String?;
    final payload = event['payload'] as Map<String, dynamic>?;
    switch (type) {
      case 'DevtoolsEvent':
        {
          logger.finest('Received DevTools event: $event');
          final action = payload?['action'] as String?;
          final screen = payload?['screen'] as String?;
          if (screen != null && action == 'pageReady') {
            _recordDwdsStats(dwdsStats, screen);
          } else {
            logger.finest('Ignoring unknown event: $event');
          }
        }
    }
  }

  void _recordDwdsStats(DwdsStats dwdsStats, String screen) {
    if (dwdsStats.isFirstDebuggerReady) {
      final devToolsStart = dwdsStats.devToolsStart;
      final debuggerStart = dwdsStats.debuggerStart;
      if (devToolsStart != null) {
        final devToolLoadTime = DateTime.now()
            .difference(devToolsStart)
            .inMilliseconds;
        emitEvent(DwdsEvent.devToolsLoad(devToolLoadTime, screen));
        logger.fine('DevTools load time: $devToolLoadTime ms');
      }
      if (debuggerStart != null) {
        final debuggerReadyTime = DateTime.now()
            .difference(debuggerStart)
            .inMilliseconds;
        emitEvent(DwdsEvent.debuggerReady(debuggerReadyTime, screen));
        logger.fine('Debugger ready time: $debuggerReadyTime ms');
      }
    } else {
      logger.finest('Debugger and DevTools stats are already recorded.');
    }
  }

  /// Shared handler for DWDS emit event service extension.
  Map<String, Object> extDwdsEmitEventHandler(
    VmResponse request,
    Logger logger,
  ) {
    final event = request['params'] as Map<String, dynamic>?;
    if (event != null) {
      final type = event['type'] as String?;
      final payload = event['payload'] as Map<String, dynamic>?;
      if (type != null && payload != null) {
        logger.fine('EmitEvent: $type $payload');
        emitEvent(DwdsEvent(type, payload));
      }
    }
    return {'result': Success().toJson()};
  }

  /// Shared handler for Flutter list views service extension.
  Future<Map<String, Object>> flutterListViewsHandler(
    ProxyService proxyService,
  ) async {
    final vm = await proxyService.getVM();
    final isolates = vm.isolates;
    return <String, Object>{
      'result': <String, Object>{
        'views': <Object>[
          for (final isolate in isolates ?? [])
            <String, Object>{'id': isolate.id, 'isolate': isolate.toJson()},
        ],
      },
    };
  }
}

// A client of the vm service that registers some custom extensions like
// hotRestart.
final class ChromeDwdsVmClient
    extends DwdsVmClient<ChromeProxyService, ChromeDebugService> {
  /// Synchronizes hot restarts to avoid races.
  final _hotRestartQueue = AtomicQueue();

  final _clientCompleter = Completer<VmService>();

  @override
  final logger = Logger('DwdsVmClient');

  ChromeDwdsVmClient({required super.debugService});

  static Future<ChromeDwdsVmClient> create(
    ChromeDebugService debugService,
    Uri? ddsUri,
  ) async {
    final dwdsVmClient = ChromeDwdsVmClient(debugService: debugService);
    await dwdsVmClient.initialize(ddsUri: ddsUri);
    return dwdsVmClient;
  }

  @override
  Future<void> initialize({required Uri? ddsUri}) async {
    await super.initialize(ddsUri: ddsUri);
    if (!_clientCompleter.isCompleted) {
      _clientCompleter.complete(client);
    }
  }

  @override
  Future<void> registerServiceExtensions({
    required VmService client,
    required ChromeProxyService proxyService,
  }) async {
    client.registerServiceCallback(
      'hotRestart',
      (request) => captureElapsedTime(
        () => hotRestart(proxyService, client),
        (_) => DwdsEvent.hotRestart(),
      ),
    );
    await client.registerService('hotRestart', 'DWDS');

    client.registerServiceCallback(
      'fullReload',
      (request) => captureElapsedTime(
        () => _fullReload(proxyService),
        (_) => DwdsEvent.fullReload(),
      ),
    );
    await client.registerService('fullReload', 'DWDS');
  }

  @override
  Future<VmRequest?> maybeHandleServiceExtensionRequestImpl(
    VmResponse request, {
    required ChromeProxyService proxyService,
    DwdsStats? dwdsStats,
  }) async {
    VmRequest? response;
    final method = request['method'];
    if (method == _NamespacedServiceExtension.flutterListViews.method) {
      response = await flutterListViewsHandler(proxyService);
    } else if (method == _NamespacedServiceExtension.extDwdsEmitEvent.method) {
      response = extDwdsEmitEventHandler(request, logger);
    } else if (method == _NamespacedServiceExtension.extDwdsReload.method) {
      response = await _extDwdsReloadHandler(proxyService);
    } else if (method == _NamespacedServiceExtension.extDwdsRestart.method) {
      response = await _extDwdsRestartHandler(proxyService, client);
    } else if (method == _NamespacedServiceExtension.extDwdsSendEvent.method) {
      response = await extDwdsSendEventHandler(request, dwdsStats, logger);
    } else if (method == _NamespacedServiceExtension.extDwdsScreenshot.method) {
      response = await _extDwdsScreenshotHandler(proxyService);
    }
    return response;
  }

  static Future<Map<String, Object>> _extDwdsScreenshotHandler(
    ChromeProxyService chromeProxyService,
  ) async {
    await chromeProxyService.remoteDebugger.enablePage();
    final response = await chromeProxyService.remoteDebugger.sendCommand(
      'Page.captureScreenshot',
    );
    return {'result': response.result as Object};
  }

  Future<Map<String, Object>> _extDwdsReloadHandler(
    ChromeProxyService chromeProxyService,
  ) async {
    await _fullReload(chromeProxyService);
    return {'result': Success().toJson()};
  }

  Future<Map<String, Object>> _extDwdsRestartHandler(
    ChromeProxyService chromeProxyService,
    VmService client,
  ) async {
    await _hotRestart(chromeProxyService, client);
    return {'result': Success().toJson()};
  }

  Future<Map<String, dynamic>> hotRestart(
    ChromeProxyService chromeProxyService,
    VmService client,
  ) {
    return _hotRestartQueue.run(() => _hotRestart(chromeProxyService, client));
  }

  Future<int> tryGetContextId(
    ChromeProxyService chromeProxyService, {
    int retries = 3,
  }) async {
    const waitInMs = 50;
    for (var retry = 0; retry < retries; retry++) {
      final tryId = await chromeProxyService.executionContext.id;
      if (tryId != null) return tryId;
      await Future.delayed(const Duration(milliseconds: waitInMs));
    }
    throw StateError('No context with the running Dart application.');
  }

  Future<Map<String, dynamic>> _hotRestart(
    ChromeProxyService chromeProxyService,
    VmService client,
  ) async {
    logger.info('Attempting a hot restart');

    chromeProxyService.terminatingIsolates = true;
    await _disableBreakpointsAndResume(client, chromeProxyService);
    try {
      logger.info('Attempting to get execution context ID.');
      await tryGetContextId(chromeProxyService);
      logger.info('Got execution context ID.');
    } on StateError catch (e) {
      // We couldn't find the execution context. `hotRestart` may have been
      // triggered in the middle of a full reload.
      return {
        'error': {
          'code': RPCErrorKind.kInternalError.code,
          'message': e.message,
        },
      };
    }
    // Start listening for isolate create events before issuing a hot
    // restart. Only return success after the isolate has fully started.
    final stream = chromeProxyService.onEvent('Isolate');
    final waitForIsolateStarted = stream.firstWhere(
      (event) => event.kind == EventKind.kIsolateStart,
    );
    try {
      // If we should pause isolates on start, then only run main once we get a
      // resume event.
      final pauseIsolatesOnStart = chromeProxyService.pauseIsolatesOnStart;
      if (pauseIsolatesOnStart) {
        _waitForResumeEventToRunMain(chromeProxyService);
      }
      // Generate run id to hot restart all apps loaded into the tab.
      final runId = const Uuid().v4().toString();

      // When using the DDC library bundle format, we determine the sources that
      // were reloaded during a hot restart to then wait until all the sources are
      // parsed before finishing hot restart. This is necessary before we can
      // recompute any source location metadata in the `ChromeProxyService`.
      // TODO(srujzs): We don't do this for the AMD module format, should we? It
      // would require adding an extra parameter in the AMD strategy. As we're
      // planning to deprecate it, for now, do nothing.
      final isDdcLibraryBundle =
          globalToolConfiguration.loadStrategy is DdcLibraryBundleStrategy;
      final computedReloadedSrcs = Completer<void>();
      final reloadedSrcs = <String>{};
      late StreamSubscription<String> parsedScriptsSubscription;
      if (isDdcLibraryBundle) {
        // Injected client should send a request to recreate the isolate after the
        // hot restart. The creation of the isolate should in turn wait until all
        // scripts are parsed.
        chromeProxyService.allowedToCreateIsolate = Completer<void>();
        final debugger = await chromeProxyService.debuggerFuture;
        parsedScriptsSubscription = debugger.parsedScriptsController.stream
            .listen((url) {
              computedReloadedSrcs.future.then((_) async {
                reloadedSrcs.remove(Uri.parse(url).normalizePath().path);
                if (reloadedSrcs.isEmpty &&
                    !chromeProxyService.allowedToCreateIsolate.isCompleted) {
                  chromeProxyService.allowedToCreateIsolate.complete();
                }
              });
            });
      }
      logger.info('Issuing \$dartHotRestartDwds request');
      final remoteObject = await chromeProxyService.inspector.jsEvaluate(
        '\$dartHotRestartDwds(\'$runId\', $pauseIsolatesOnStart);',
        awaitPromise: true,
        returnByValue: true,
      );
      if (isDdcLibraryBundle) {
        final reloadedSrcModuleLibraries = (remoteObject.value as List)
            .cast<Map>();
        for (final srcModuleLibrary in reloadedSrcModuleLibraries) {
          final srcModuleLibraryCast = srcModuleLibrary.cast<String, Object>();
          reloadedSrcs.add(
            Uri.parse(
              srcModuleLibraryCast['src'] as String,
            ).normalizePath().path,
          );
        }
        if (reloadedSrcs.isEmpty) {
          chromeProxyService.allowedToCreateIsolate.complete();
        }
        computedReloadedSrcs.complete();
        await chromeProxyService.allowedToCreateIsolate.future;
        await parsedScriptsSubscription.cancel();
      } else {
        assert(remoteObject.value == null);
      }
      logger.info('\$dartHotRestartDwds request complete.');
    } on WipError catch (exception) {
      final code = exception.error?['code'];
      final message = exception.error?['message'];
      // This corresponds to `Execution context was destroyed` which can
      // occur during a hot restart that must fall back to a full reload.
      if (code != RPCErrorKind.kServerError.code) {
        return {
          'error': {'code': code, 'message': message, 'data': exception},
        };
      }
    } on ChromeDebugException catch (exception) {
      // Exceptions thrown by the injected client during hot restart.
      return {
        'error': {
          'code': RPCErrorKind.kInternalError.code,
          'message': '$exception',
        },
      };
    }
    logger.info('Waiting for Isolate Start event.');
    await waitForIsolateStarted;
    chromeProxyService.terminatingIsolates = false;

    logger.info('Successful hot restart');
    return {'result': Success().toJson()};
  }

  void _waitForResumeEventToRunMain(ChromeProxyService chromeProxyService) {
    StreamSubscription<String>? resumeEventsSubscription;
    resumeEventsSubscription = chromeProxyService.resumeAfterRestartEventsStream
        .listen((_) async {
          await resumeEventsSubscription!.cancel();
          await chromeProxyService.inspector.jsEvaluate(
            '\$dartReadyToRunMain();',
          );
        });
  }

  Future<Map<String, dynamic>> _fullReload(
    ChromeProxyService chromeProxyService,
  ) async {
    logger.info('Attempting a full reload');
    await chromeProxyService.remoteDebugger.enablePage();
    await chromeProxyService.remoteDebugger.pageReload();
    logger.info('Successful full reload');
    return {'result': Success().toJson()};
  }

  Future<void> _disableBreakpointsAndResume(
    VmService client,
    ChromeProxyService chromeProxyService,
  ) async {
    logger.info('Attempting to disable breakpoints and resume the isolate');
    final vm = await client.getVM();
    final isolates = vm.isolates;
    if (isolates == null || isolates.isEmpty) {
      throw StateError('No active isolate to resume.');
    }
    final isolateId = isolates.first.id;
    if (isolateId == null) {
      throw StateError('No active isolate to resume.');
    }
    await chromeProxyService.disableBreakpoints();
    try {
      // Any checks for paused status result in race conditions or hangs
      // at this point:
      //
      // - `getIsolate()` and check for status:
      //    the app might still pause on existing breakpoint.
      //
      // - `pause()` and wait for `Debug.paused` event:
      //   chrome does not send the `Debug.Paused `notification
      //   without shifting focus to chrome.
      //
      // Instead, just try resuming and
      // ignore failures indicating that the app is already running:
      //
      // WipError -32000 Can only perform operation while paused.
      await client.resume(isolateId);
    } on RPCError catch (e, s) {
      if (!e.message.contains('Can only perform operation while paused')) {
        logger.severe('Hot restart failed to resume exiting isolate', e, s);
        rethrow;
      }
    }
    logger.info('Successfully disabled breakpoints and resumed the isolate');
  }
}

/// WebSocket-based DWDS VM client.
final class WebSocketDwdsVmClient
    extends DwdsVmClient<WebSocketProxyService, WebSocketDebugService> {
  @override
  final logger = Logger('WebSocketDwdsVmClient');

  static Future<WebSocketDwdsVmClient> create(
    WebSocketDebugService debugService,
    Uri? ddsUri,
  ) async {
    final dwdsVmClient = WebSocketDwdsVmClient(debugService: debugService);
    dwdsVmClient.logger.fine('Creating WebSocket DWDS VM client');
    await dwdsVmClient.initialize(ddsUri: ddsUri);
    return dwdsVmClient;
  }

  WebSocketDwdsVmClient({required super.debugService});

  @override
  Future<void> registerServiceExtensions({
    required VmService client,
    required WebSocketProxyService proxyService,
  }) async {
    client.registerServiceCallback(
      'hotRestart',
      (request) => captureElapsedTime(
        () => proxyService.hotRestart(),
        (_) => DwdsEvent.hotRestart(),
      ),
    );
    await client.registerService('hotRestart', 'DWDS');
  }

  @override
  Future<VmRequest?> maybeHandleServiceExtensionRequestImpl(
    VmResponse request, {
    required WebSocketProxyService proxyService,
    DwdsStats? dwdsStats,
  }) async {
    VmRequest? response;
    final method = request['method'];

    logger.finest('Processing service extension method: $method');

    if (method == _NamespacedServiceExtension.flutterListViews.method) {
      response = await flutterListViewsHandler(proxyService);
    } else if (method == _NamespacedServiceExtension.extDwdsEmitEvent.method) {
      response = extDwdsEmitEventHandler(request, logger);
    } else if (method == _NamespacedServiceExtension.extDwdsReload.method) {
      response = {'result': 'Reload not implemented'};
    } else if (method == _NamespacedServiceExtension.extDwdsSendEvent.method) {
      response = await extDwdsSendEventHandler(request, null, logger);
    } else if (method == _NamespacedServiceExtension.extDwdsScreenshot.method) {
      response = {'result': 'Screenshot not implemented'};
    }
    return response;
  }
}
