// 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/events.dart';
import 'package:dwds/src/services/chrome_debug_exception.dart';
import 'package:dwds/src/services/chrome_proxy_service.dart';
import 'package:dwds/src/services/debug_service.dart';
import 'package:dwds/src/utilities/shared.dart';
import 'package:dwds/src/utilities/synchronized.dart';
import 'package:logging/logging.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';

final _logger = Logger('DwdsVmClient');

/// 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;
}

// A client of the vm service that registers some custom extensions like
// hotRestart.
class DwdsVmClient {
  final VmService client;
  final StreamController<Map<String, Object>> _requestController;
  final StreamController<Map<String, Object?>> _responseController;

  /// Null until [close] is called.
  ///
  /// All subsequent calls to [close] will return this future.
  Future<void>? _closed;

  /// Synchronizes hot restarts to avoid races.
  final _hotRestartQueue = AtomicQueue();

  DwdsVmClient(this.client, this._requestController, this._responseController);

  Future<void> close() => _closed ??= () async {
        await _requestController.close();
        await _responseController.close();
        await client.dispose();
      }();

  static Future<DwdsVmClient> create(
    DebugService debugService,
    DwdsStats dwdsStats,
    Uri? ddsUri,
  ) async {
    final chromeProxyService =
        debugService.chromeProxyService as ChromeProxyService;
    final responseController = StreamController<VmResponse>();
    final responseSink = responseController.sink;
    // Response stream must be a broadcast stream so that it can have multiple
    // listeners:
    final responseStream = responseController.stream.asBroadcastStream();
    final requestController = StreamController<VmRequest>();
    final requestSink = requestController.sink;
    final requestStream = requestController.stream;

    final clientCompleter = Completer<VmService>();

    _setUpVmServerConnection(
      chromeProxyService: chromeProxyService,
      debugService: debugService,
      responseStream: responseStream,
      responseSink: responseSink,
      requestStream: requestStream,
      requestSink: requestSink,
      dwdsStats: dwdsStats,
      clientFuture: clientCompleter.future,
    );

    final client = ddsUri == null
        ? _setUpVmClient(
            responseStream: responseStream,
            requestController: requestController,
            requestSink: requestSink,
          )
        : await _setUpDdsClient(
            ddsUri: ddsUri,
          );

    if (!clientCompleter.isCompleted) {
      clientCompleter.complete(client);
    }

    final dwdsVmClient =
        DwdsVmClient(client, requestController, responseController);

    await _registerServiceExtensions(
      client: client,
      chromeProxyService: chromeProxyService,
      dwdsVmClient: dwdsVmClient,
    );

    return dwdsVmClient;
  }

  /// Establishes a VM service client that is connected via DDS and registers
  /// the service extensions on that client.
  static 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.
  static VmService _setUpVmClient({
    required Stream<VmResponse> responseStream,
    required StreamSink<VmRequest> requestSink,
    required StreamController<VmRequest> requestController,
  }) {
    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.
  static void _setUpVmServerConnection({
    required ChromeProxyService chromeProxyService,
    required DwdsStats dwdsStats,
    required DebugService debugService,
    required Stream<VmResponse> responseStream,
    required StreamSink<VmResponse> responseSink,
    required Stream<VmRequest> requestStream,
    required StreamSink<VmRequest> requestSink,
    required Future<VmService> clientFuture,
  }) {
    responseStream.listen((request) async {
      final response = await _maybeHandleServiceExtensionRequest(
        request,
        chromeProxyService: chromeProxyService,
        dwdsStats: dwdsStats,
        clientFuture: clientFuture,
      );
      if (response != null) {
        requestSink.add(response);
      }
    });

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

    for (final extension in _NamespacedServiceExtension.values) {
      debugService.serviceExtensionRegistry
          .registerExtension(extension.method, vmServerConnection);
    }
  }

  static Future<VmRequest?> _maybeHandleServiceExtensionRequest(
    VmResponse request, {
    required ChromeProxyService chromeProxyService,
    required DwdsStats dwdsStats,
    required Future<VmService> clientFuture,
  }) async {
    VmRequest? response;
    final method = request['method'];
    if (method == _NamespacedServiceExtension.flutterListViews.method) {
      response = await _flutterListViewsHandler(chromeProxyService);
    } else if (method == _NamespacedServiceExtension.extDwdsEmitEvent.method) {
      response = _extDwdsEmitEventHandler(request);
    } else if (method == _NamespacedServiceExtension.extDwdsReload.method) {
      response = await _extDwdsReloadHandler(chromeProxyService);
    } else if (method == _NamespacedServiceExtension.extDwdsRestart.method) {
      final client = await clientFuture;
      response = await _extDwdsRestartHandler(chromeProxyService, client);
    } else if (method == _NamespacedServiceExtension.extDwdsSendEvent.method) {
      response = await _extDwdsSendEventHandler(request, dwdsStats);
    } else if (method == _NamespacedServiceExtension.extDwdsScreenshot.method) {
      response = await _extDwdsScreenshotHandler(chromeProxyService);
    }

    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;
  }

  static Future<Map<String, Object>> _flutterListViewsHandler(
    ChromeProxyService chromeProxyService,
  ) async {
    final vm = await chromeProxyService.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(),
            },
        ],
      },
    };
  }

  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};
  }

  static Future<Map<String, Object>> _extDwdsSendEventHandler(
    VmResponse request,
    DwdsStats dwdsStats,
  ) async {
    _processSendEvent(request, dwdsStats);
    return {'result': Success().toJson()};
  }

  static Map<String, Object> _extDwdsEmitEventHandler(
    VmResponse request,
  ) {
    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) {
        emitEvent(
          DwdsEvent(type, payload),
        );
      }
    }

    return {'result': Success().toJson()};
  }

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

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

  static Future<void> _registerServiceExtensions({
    required VmService client,
    required ChromeProxyService chromeProxyService,
    required DwdsVmClient dwdsVmClient,
  }) async {
    client.registerServiceCallback(
      'hotRestart',
      (request) => captureElapsedTime(
        () => dwdsVmClient.hotRestart(chromeProxyService, client),
        (_) => DwdsEvent.hotRestart(),
      ),
    );
    await client.registerService('hotRestart', 'DWDS');

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

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

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.');
  }
}

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');
  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();
    _logger.info('Issuing \$dartHotRestartDwds request');
    await chromeProxyService.inspector.jsEvaluate(
      '\$dartHotRestartDwds(\'$runId\', $pauseIsolatesOnStart);',
      awaitPromise: true,
    );
    _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 stream.firstWhere((event) => event.kind == EventKind.kIsolateStart);
  chromeProxyService.terminatingIsolates = false;

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

void _waitForResumeEventToRunMain(
  ChromeProxyService chromeProxyService,
) {
  final issuedReadyToRunMainCompleter = Completer<void>();

  final resumeEventsSubscription =
      chromeProxyService.resumeAfterRestartEventsStream.listen((_) async {
    await chromeProxyService.inspector.jsEvaluate('\$dartReadyToRunMain();');
    if (!issuedReadyToRunMainCompleter.isCompleted) {
      issuedReadyToRunMainCompleter.complete();
    }
  });

  safeUnawaited(
    issuedReadyToRunMainCompleter.future.then((_) {
      resumeEventsSubscription.cancel();
    }),
  );
}

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');
}
