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

/// A library used to spawn the Dart Developer Service, used to communicate
/// with a Dart VM Service instance.
library;

import 'dart:async';
import 'dart:io';

import 'package:devtools_shared/devtools_shared.dart' show DtdInfo;

import 'src/dds_impl.dart';

typedef UriConverter = String? Function(String uri);

/// An intermediary between a Dart VM service and its clients that offers
/// additional functionality on top of the standard VM service protocol.
///
/// See the [Dart Development Service Protocol](https://github.com/dart-lang/sdk/blob/master/pkg/dds/dds_protocol.md)
/// for details.
abstract class DartDevelopmentService {
  /// Creates a [DartDevelopmentService] instance which will communicate with a
  /// VM service. Requires the target VM service to have no other connected
  /// clients.
  ///
  /// [remoteVmServiceUri] is the address of the VM service that this
  /// development service will communicate with.
  ///
  /// If provided, [serviceUri] will determine the address and port of the
  /// spawned Dart Development Service. The format of [serviceUri] must be
  /// consistent with the protocol determined by [ipv6].
  ///
  /// [enableAuthCodes] controls whether or not an authentication code must
  /// be provided by clients when communicating with this instance of
  /// [DartDevelopmentService]. Authentication codes take the form of a base64
  /// encoded string provided as the first element of the DDS path and is meant
  /// to make it more difficult for unintended clients to connect to this
  /// service. Authentication codes are enabled by default.
  ///
  /// [ipv6] controls whether or not DDS is served via IPv6. IPv4 is enabled by
  /// default.
  ///
  /// If [enablesServicePortFallback] is enabled, DDS will attempt to bind to any
  /// available port if the specified port is unavailable.
  static Future<DartDevelopmentService> startDartDevelopmentService(
    Uri remoteVmServiceUri, {
    Uri? serviceUri,
    bool enableAuthCodes = true,
    bool ipv6 = false,
    bool enableServicePortFallback = false,
    List<String> cachedUserTags = const [],
    DevToolsConfiguration? devToolsConfiguration,
    bool logRequests = false,
    UriConverter? uriConverter,
  }) async {
    if (!remoteVmServiceUri.isScheme('http')) {
      throw ArgumentError(
        'remoteVmServiceUri must have an HTTP scheme. Actual: ${remoteVmServiceUri.scheme}',
      );
    }
    if (serviceUri != null) {
      if (!serviceUri.isScheme('http')) {
        throw ArgumentError(
          'serviceUri must have an HTTP scheme. Actual: ${serviceUri.scheme}',
        );
      }

      // If provided an address to bind to, ensure it uses a protocol consistent
      // with that used to spawn DDS.
      final addresses = await InternetAddress.lookup(serviceUri.host);

      try {
        // Check to see if there's a valid address.
        addresses.firstWhere(
          (a) => (a.type ==
              (ipv6 ? InternetAddressType.IPv6 : InternetAddressType.IPv4)),
        );
      } on StateError {
        // Could not find a valid address.
        throw ArgumentError(
          "serviceUri '$serviceUri' is not an IPv${ipv6 ? "6" : "4"} address.",
        );
      }
    }

    final service = DartDevelopmentServiceImpl(
      remoteVmServiceUri,
      serviceUri,
      enableAuthCodes,
      cachedUserTags,
      ipv6,
      devToolsConfiguration,
      logRequests,
      enableServicePortFallback,
      uriConverter,
    );
    await service.startService();
    return service;
  }

  DartDevelopmentService._();

  /// Stop accepting requests after gracefully handling existing requests.
  Future<void> shutdown();

  /// Registers an external DevTools server with this instance of
  /// [DartDevelopmentService] to allow for DDS to redirect DevTools requests
  /// to the DevTools server.
  ///
  /// Throws a [StateError] if DevTools is already being served by DDS.
  void setExternalDevToolsUri(Uri uri);

  /// Set to `true` if this instance of [DartDevelopmentService] requires an
  /// authentication code to connect.
  bool get authCodesEnabled;

  /// Completes when this [DartDevelopmentService] has shut down.
  Future<void> get done;

  /// The HTTP [Uri] of the remote VM service instance that this service will
  /// forward requests to.
  Uri get remoteVmServiceUri;

  /// The web socket [Uri] of the remote VM service instance that this service
  /// will forward requests to.
  ///
  /// Can be used with [WebSocket] to communicate directly with the VM service.
  Uri get remoteVmServiceWsUri;

  /// The [Uri] VM service clients can use to communicate with this
  /// [DartDevelopmentService] via HTTP.
  ///
  /// Returns `null` if the service is not running.
  Uri? get uri;

  /// The [Uri] VM service clients can use to communicate with this
  /// [DartDevelopmentService] via server-sent events (SSE).
  ///
  /// Returns `null` if the service is not running.
  Uri? get sseUri;

  /// The [Uri] VM service clients can use to communicate with this
  /// [DartDevelopmentService] via a [WebSocket].
  ///
  /// Returns `null` if the service is not running.
  Uri? get wsUri;

  /// The HTTP [Uri] of the hosted DevTools instance.
  ///
  /// Returns `null` if DevTools is not running.
  Uri? get devToolsUri;

  /// Metadata for the Dart Tooling Daemon instance that is hosted by DevTools.
  ///
  /// This will be null if DTD was not started by the DevTools server. For
  /// example, it may have been started by an IDE.
  DtdInfo? get hostedDartToolingDaemon;

  /// Set to `true` if this instance of [DartDevelopmentService] is accepting
  /// requests.
  bool get isRunning;

  /// The list of [UserTag]s used to determine which CPU samples are cached by
  /// DDS.
  List<String> get cachedUserTags;

  /// The version of the DDS protocol supported by this [DartDevelopmentService]
  /// instance.
  static const String protocolVersion = '2.0';
}

/// Thrown by DDS during initialization failures, unexpected connection issues,
/// and when attempting to spawn DDS when an existing DDS instance exists.
class DartDevelopmentServiceException implements Exception {
  /// Set when `DartDeveloperService.startDartDevelopmentService` is called and
  /// the target VM service already has a Dart Developer Service instance
  /// connected.
  static const int existingDdsInstanceError = 1;

  /// Set when the connection to the remote VM service terminates unexpectedly
  /// during Dart Development Service startup.
  static const int failedToStartError = 2;

  /// Set when a connection error has occurred after startup.
  static const int connectionError = 3;

  factory DartDevelopmentServiceException.fromJson(Map<String, Object?> json) {
    if (json
        case {
          'error_code': final int errorCode,
          'message': final String message,
        }) {
      return switch (errorCode) {
        existingDdsInstanceError =>
          DartDevelopmentServiceException.existingDdsInstance(
            message,
            ddsUri: Uri.parse(json['uri']! as String),
          ),
        failedToStartError => DartDevelopmentServiceException.failedToStart(),
        connectionError =>
          DartDevelopmentServiceException.connectionIssue(message),
        _ => throw StateError(
            'Invalid DartDevelopmentServiceException error_code: $errorCode',
          ),
      };
    }
    throw StateError('Invalid DartDevelopmentServiceException JSON: $json');
  }

  /// Thrown when `DartDeveloperService.startDartDevelopmentService` is called
  /// and the target VM service already has a Dart Developer Service instance
  /// connected.
  factory DartDevelopmentServiceException.existingDdsInstance(
    String message, {
    Uri? ddsUri,
  }) {
    return ExistingDartDevelopmentServiceException._(
      message,
      ddsUri: ddsUri,
    );
  }

  /// Thrown when the connection to the remote VM service terminates unexpectedly
  /// during Dart Development Service startup.
  factory DartDevelopmentServiceException.failedToStart() {
    return DartDevelopmentServiceException._(
        failedToStartError, 'Failed to start Dart Development Service');
  }

  /// Thrown when a connection error has occurred after startup.
  factory DartDevelopmentServiceException.connectionIssue(String message) {
    return DartDevelopmentServiceException._(connectionError, message);
  }

  DartDevelopmentServiceException._(this.errorCode, this.message);

  @override
  String toString() => 'DartDevelopmentServiceException: $message';

  Map<String, Object?> toJson() => {
        'error_code': errorCode,
        'message': message,
      };

  final int errorCode;
  final String message;
}

/// Thrown when attempting to start a new DDS instance when one already exists.
class ExistingDartDevelopmentServiceException
    extends DartDevelopmentServiceException {
  ExistingDartDevelopmentServiceException._(
    String message, {
    this.ddsUri,
  }) : super._(
          DartDevelopmentServiceException.existingDdsInstanceError,
          message,
        );

  /// The URI of the existing DDS instance, if available.
  ///
  /// This URL is the base HTTP URI such as `http://127.0.0.1:1234/AbcDefg=/`,
  /// not the WebSocket URI (which can be obtained by mapping the scheme to
  /// `ws` (or `wss`) and appending `ws` to the path segments).
  final Uri? ddsUri;

  @override
  Map<String, Object?> toJson() => {
        ...super.toJson(),
        'uri': ddsUri.toString(),
      };
}

class DevToolsConfiguration {
  const DevToolsConfiguration({
    required this.customBuildDirectoryPath,
    this.devToolsServerAddress,
    this.enable = false,
  });

  final bool enable;
  final Uri? devToolsServerAddress;
  final Uri customBuildDirectoryPath;
}
