// Copyright (c) 2022, 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:io';

import 'package:args/args.dart';
import 'package:browser_launcher/browser_launcher.dart';
import 'package:devtools_shared/devtools_extensions_io.dart';
import 'package:devtools_shared/devtools_shared.dart';
import 'package:http_multi_server/http_multi_server.dart';
import 'package:path/path.dart' as path;
import 'package:shelf/shelf.dart' as shelf;
import 'package:shelf/shelf_io.dart' as shelf;

import 'src/devtools/client.dart';
import 'src/devtools/dtd.dart';
import 'src/devtools/handler.dart';
import 'src/devtools/machine_mode_command_handler.dart';
import 'src/devtools/memory_profile.dart';
import 'src/devtools/utils.dart';
import 'src/utils/console.dart';

class DevToolsServer {
  static const protocolVersion = '1.2.0';
  static const defaultTryPorts = 10;
  static const defaultDdsHost = '127.0.0.1';
  static const defaultDdsPort = 0;
  static const commandDescription =
      'Open DevTools (optionally connecting to an existing application).';

  static const argHelp = 'help';
  static const argVmUri = 'vm-uri';
  static const argEnableNotifications = 'enable-notifications';
  static const argAllowEmbedding = 'allow-embedding';
  static const argAppSizeBase = 'app-size-base';
  static const argAppSizeTest = 'app-size-test';
  static const argHeadlessMode = 'headless';
  static const argDdsHost = 'dds-host';
  static const argDdsPort = 'dds-port';
  static const argDebugMode = 'debug';
  static const argDisableCors = 'disable-cors';
  static const argDtdUri = 'dtd-uri';
  static const argDtdExposedUri = 'dtd-exposed-uri';
  static const argPrintDtd = 'print-dtd';
  static const argLaunchBrowser = 'launch-browser';
  static const argMachine = 'machine';
  static const argHost = 'host';
  static const argPort = 'port';
  static const argProfileMemory = 'record-memory-profile';
  static const argTryPorts = 'try-ports';
  static const argVerbose = 'verbose';
  static const argVersion = 'version';
  static const launchDevToolsService = 'launchDevTools';

  MachineModeCommandHandler? _machineModeCommandHandler;
  late ClientManager clientManager;
  final bool _isChromeOS = File('/dev/.cros_milestone').existsSync();

  /// Builds an arg parser for the DevTools server.
  ///
  /// [includeHelpOption] should be set to false if this arg parser will be used
  /// in a Command subclass.
  static ArgParser buildArgParser({
    bool verbose = false,
    bool includeHelpOption = true,
    int? usageLineLength,
  }) {
    final argParser = ArgParser(
      usageLineLength: usageLineLength,
    );

    if (includeHelpOption) {
      argParser.addFlag(
        argHelp,
        negatable: false,
        abbr: 'h',
        help: 'Prints help output.',
      );
    }
    argParser
      ..addFlag(
        argVersion,
        negatable: false,
        help: 'Prints the DevTools version.',
      )
      ..addFlag(
        argVerbose,
        negatable: false,
        abbr: 'v',
        help: 'Output more informational messages.',
      )
      ..addOption(
        argHost,
        valueHelp: 'host',
        help: 'Hostname to serve DevTools on (defaults to localhost).',
      )
      ..addOption(
        argPort,
        defaultsTo: '9100',
        valueHelp: 'port',
        help: 'Port to serve DevTools on; specify 0 to automatically use any '
            'available port.',
      )
      ..addOption(
        argDtdUri,
        valueHelp: 'uri',
        help: 'A URI pointing to a Dart Tooling Daemon that DevTools should '
            'interface with.',
      )
      ..addOption(
        argDtdExposedUri,
        valueHelp: 'uri',
        help: 'An optional URI for the DartTooling Daemon (--dtd-uri) that has '
            'been exposed to the front-end to support environments split across '
            'machines such as a web-based editor.',
      )
      ..addFlag(
        argLaunchBrowser,
        help:
            'Launches DevTools in a browser immediately at start.\n(defaults to on unless in --machine mode)',
      )
      ..addFlag(
        argMachine,
        negatable: false,
        help: 'Sets output format to JSON for consumption in tools.',
      )
      ..addSeparator('Memory profiling options:')
      ..addOption(
        argProfileMemory,
        valueHelp: 'file',
        defaultsTo: 'memory_samples.json',
        help:
            'Start devtools headlessly and write memory profiling samples to the '
            'indicated file.',
      );

    argParser.addSeparator('App size options:');

    argParser
      ..addOption(
        argAppSizeBase,
        aliases: ['appSizeBase'],
        valueHelp: 'file',
        help: 'Path to the base app size file used for app size debugging.',
      )
      ..addOption(
        argAppSizeTest,
        aliases: ['appSizeTest'],
        valueHelp: 'file',
        help:
            'Path to the test app size file used for app size debugging.\nThis '
            'file should only be specified if --$argAppSizeBase is also '
            'specified.',
        hide: !verbose,
      );

    if (verbose) {
      argParser.addSeparator('Advanced options:');
    }

    // Args to show for verbose mode.
    argParser
      ..addOption(
        argTryPorts,
        defaultsTo: DevToolsServer.defaultTryPorts.toString(),
        valueHelp: 'count',
        help: 'The number of ascending ports to try binding to before failing '
            'with an error.',
        hide: !verbose,
      )
      ..addOption(
        argDdsHost,
        defaultsTo: DevToolsServer.defaultDdsHost,
        valueHelp: 'bind-address',
        help:
            "The address the Dart Development Service (DDS) should attempt to "
            "bind to if a DDS instance isn't active and a VM service URI is "
            "provided.",
        hide: !verbose,
      )
      ..addOption(
        argDdsPort,
        defaultsTo: DevToolsServer.defaultDdsPort.toString(),
        valueHelp: 'port',
        help:
            "The address the Dart Development Service (DDS) should attempt to "
            "bind to if a DDS instance isn't active and a VM service URI is "
            "provided.",
        hide: !verbose,
      )
      ..addFlag(
        argEnableNotifications,
        negatable: false,
        help: 'Requests notification permissions immediately when a client '
            'connects back to the server.',
        hide: !verbose,
      )
      ..addFlag(
        argAllowEmbedding,
        help: 'Allow embedding DevTools inside an iframe.',
        hide: !verbose,
      )
      ..addFlag(
        argDisableCors,
        help:
            'Disable CORS so that the DevTools server can communicate with the '
            'DevTools front end served on a different origin. Use caution when '
            'passing this flag since allowing access to the DevTools server '
            'from other origins may have security implications.',
        defaultsTo: false,
        hide: verbose,
      )
      ..addFlag(
        argHeadlessMode,
        negatable: false,
        help: 'Causes the server to spawn Chrome in headless mode for use in '
            'automated testing.',
        hide: !verbose,
      )
      ..addFlag(
        argPrintDtd,
        negatable: false,
        help: 'Print the address of the Dart Tooling Daemon, if one is hosted '
            'by the DevTools server.',
        hide: !verbose,
      );

    // Deprecated and hidden args.
    // TODO: Remove this - prefer that clients use the rest arg.
    argParser
      ..addOption(
        argVmUri,
        defaultsTo: '',
        help: 'VM Service protocol URI.',
        hide: true,
      )

      // Development only args.
      ..addFlag(
        argDebugMode,
        negatable: false,
        help: 'Run a debug build of the DevTools web frontend.',
        hide: true,
      );

    return argParser;
  }

  /// Serves DevTools.
  ///
  /// `handler` is the [shelf.Handler] that the server will use for all requests.
  /// If null, [defaultHandler] will be used. Defaults to null.
  ///
  /// `customDevToolsPath` is a path to a directory containing a pre-built
  /// DevTools application.
  ///
  // Note: this method is used by the Dart CLI and by package:dwds.
  Future<HttpServer?> serveDevTools({
    bool enableStdinCommands = true,
    bool machineMode = false,
    bool debugMode = false,
    bool launchBrowser = false,
    bool enableNotifications = false,
    bool allowEmbedding = true,
    bool disableCors = false,
    bool headlessMode = false,
    bool verboseMode = false,
    bool printDtdUri = false,
    String? hostname,
    String? customDevToolsPath,
    int port = 0,
    int numPortsToTry = defaultTryPorts,
    shelf.Handler? handler,
    String? serviceProtocolUri,
    String? profileFilename,
    String? appSizeBase,
    String? appSizeTest,
    DtdInfo? dtdInfo,
  }) async {
    hostname ??= 'localhost';

    // Collect profiling information.
    if (profileFilename != null && serviceProtocolUri != null) {
      final Uri? vmServiceUri = Uri.tryParse(serviceProtocolUri);
      if (vmServiceUri != null) {
        await _hookupMemoryProfiling(
          vmServiceUri,
          profileFilename,
          verboseMode,
        );
      }
      return null;
    }

    if (machineMode) {
      assert(
        enableStdinCommands,
        'machineMode only works with enableStdinCommands.',
      );
    }

    clientManager = ClientManager(
      requestNotificationPermissions: enableNotifications,
    );

    dtdInfo ??= await startDtd(
      machineMode: machineMode,
      printDtdUri: printDtdUri,
    );

    handler ??= await defaultHandler(
      buildDir: customDevToolsPath,
      clientManager: clientManager,
      dtd: dtdInfo,
      devtoolsExtensionsManager: ExtensionsManager(),
    );

    HttpServer? server;
    SocketException? ex;
    while (server == null && numPortsToTry >= 0) {
      // If we have tried [numPortsToTry] ports and still have not been able to
      // connect, try port 0 to find a random available port.
      if (numPortsToTry == 0) port = 0;

      try {
        server = await HttpMultiServer.bind(hostname, port);
      } on SocketException catch (e) {
        ex = e;
        numPortsToTry--;
        port++;
      }
    }

    // Re-throw the last exception if we failed to bind.
    if (server == null && ex != null) {
      throw ex;
    }

    // Type promote server.
    server!;

    if (allowEmbedding) {
      server.defaultResponseHeaders.remove('x-frame-options', 'SAMEORIGIN');
      // The origin-agent-cluster header is required to support the embedding of
      // Dart DevTools in Chrome DevTools.
      server.defaultResponseHeaders.add('origin-agent-cluster', '?1');
    }

    // CORS restrictions may be disabled for the purposes of debugging or
    // testing when it is useful to allow connecting a debug instance of
    // DevTools app to a running DevTools server.
    if (disableCors) {
      server.defaultResponseHeaders.add(
        HttpHeaders.accessControlAllowOriginHeader,
        '*',
      );
    }

    // Ensure browsers don't cache older versions of the app.
    server.defaultResponseHeaders.add(
      HttpHeaders.cacheControlHeader,
      'no-store',
    );

    // Add the headers required to serve with wasm.
    server.defaultResponseHeaders
      ..add('Cross-Origin-Embedder-Policy', 'credentialless')
      ..add('Cross-Origin-Opener-Policy', 'same-origin')
      ..add('Cross-Origin-Resource-Policy', 'cross-origin');

    // Serve requests in an error zone to prevent failures
    // when running from another error zone.
    runZonedGuarded(
      () => shelf.serveRequests(server!, handler!),
      (e, _) => print('Error serving requests: $e'),
    );

    final devToolsUrl = 'http://${server.address.host}:${server.port}';

    if (launchBrowser) {
      if (serviceProtocolUri != null) {
        serviceProtocolUri =
            normalizeVmServiceUri(serviceProtocolUri).toString();
      }

      final queryParameters = {
        if (serviceProtocolUri != null) 'uri': serviceProtocolUri,
        if (appSizeBase != null) 'appSizeBase': appSizeBase,
        if (appSizeTest != null) 'appSizeTest': appSizeTest,
      };
      String url = Uri.parse(devToolsUrl)
          .replace(queryParameters: queryParameters)
          .toString();

      // If app size parameters are present, open to the standalone `app-size`
      // page, regardless if there is a vm service uri specified. We only check
      // for the presence of [appSizeBase] here because [appSizeTest] may or may
      // not be specified (it should only be present for diffs). If [appSizeTest]
      // is present without [appSizeBase], we will ignore the parameter.
      if (appSizeBase != null) {
        final startQueryParamIndex = url.indexOf('?');
        if (startQueryParamIndex != -1) {
          url = '${url.substring(0, startQueryParamIndex)}'
              '/#/app-size'
              '${url.substring(startQueryParamIndex)}';
        }
      }

      try {
        await Chrome.start([url]);
      } catch (e) {
        print('Unable to launch Chrome: $e\n');
      }
    }

    if (enableStdinCommands) {
      String message = '''Serving DevTools at $devToolsUrl.

          Hit ctrl-c to terminate the server.''';
      if (!machineMode && debugMode) {
        // Add bold to help find the correct url to open.
        message = ConsoleUtils.bold('$message\n');
      }

      DevToolsUtils.printOutput(
        message,
        {
          'event': 'server.started',
          // TODO(dantup): Remove this `method` field when we're sure VS Code
          // users are all on a newer version that uses `event`. We incorrectly
          // used `method` for the original releases.
          'method': 'server.started',
          'params': {
            'host': server.address.host,
            'port': server.port,
            'pid': pid,
            'protocolVersion': protocolVersion,
          }
        },
        machineMode: machineMode,
      );

      if (machineMode) {
        _machineModeCommandHandler = MachineModeCommandHandler(server: this);
        await _machineModeCommandHandler!.initialize(
          devToolsUrl: devToolsUrl,
          headlessMode: headlessMode,
        );
      }
    }

    return server;
  }

  void _printUsage(ArgParser argParser) {
    print(commandDescription);
    print('\nUsage: devtools [arguments] [service protocol uri]');
    print(argParser.usage);
  }

  /// Wraps [serveDevTools] `arguments` parsed, as from the command line.
  ///
  /// For more information on `handler`, see [serveDevTools].
  // Note: this method is used in google3 as well as by DevTools' main method.
  Future<HttpServer?> serveDevToolsWithArgs(
    List<String> arguments, {
    shelf.Handler? handler,
    String? customDevToolsPath,
  }) async {
    ArgResults args;
    final verbose = arguments.contains('-v') || arguments.contains('--verbose');
    final argParser = buildArgParser(verbose: verbose);
    try {
      args = argParser.parse(arguments);
    } on FormatException catch (e) {
      print(e.message);
      print('');
      _printUsage(argParser);
      return null;
    }

    return await _serveDevToolsWithArgs(
      args,
      verbose,
      handler: handler,
      customDevToolsPath: customDevToolsPath,
    );
  }

  Future<HttpServer?> _serveDevToolsWithArgs(
    ArgResults args,
    bool verbose, {
    shelf.Handler? handler,
    String? customDevToolsPath,
  }) async {
    final help = args[argHelp];
    final bool version = args[argVersion];
    final bool machineMode = args[argMachine];
    // launchBrowser defaults based on machine-mode if not explicitly supplied.
    final bool launchBrowser = args.wasParsed(argLaunchBrowser)
        ? args[argLaunchBrowser]
        : !machineMode;
    final bool enableNotifications = args[argEnableNotifications];
    final bool allowEmbedding =
        args.wasParsed(argAllowEmbedding) ? args[argAllowEmbedding] : true;
    final bool disableCors =
        args.wasParsed(argDisableCors) ? args[argDisableCors] : false;

    final port = args[argPort] != null ? int.tryParse(args[argPort]) ?? 0 : 0;

    final bool headlessMode = args[argHeadlessMode];
    final bool debugMode = args[argDebugMode];

    final numPortsToTry = args[argTryPorts] != null
        ? int.tryParse(args[argTryPorts]) ?? 0
        : defaultTryPorts;

    final bool verboseMode = args[argVerbose];
    final String? hostname = args[argHost];

    // A helper to print a message and usage information that can be used in
    // a return statement.
    Null printUsage(String message) {
      print(message);
      print('');
      _printUsage(buildArgParser(verbose: verbose));
    }

    Uri? dtdUri;
    if (args.wasParsed(argDtdUri)) {
      dtdUri = Uri.tryParse(args[argDtdUri]);
      if (dtdUri == null || !dtdUri.hasScheme) {
        return printUsage('--dtd-uri must be a valid URI');
      }
    }

    Uri? dtdExposedUri;
    if (args.wasParsed(argDtdExposedUri)) {
      if (dtdUri == null) {
        return printUsage(
            '--dtd-exposed-uri can only be supplied with --dtd-uri');
      }

      dtdExposedUri = Uri.tryParse(args[argDtdExposedUri]);
      if (dtdExposedUri == null || !dtdExposedUri.hasScheme) {
        return printUsage('--dtd-exposed-uri must be a valid URI');
      }
    }

    final printDtdUri = args.wasParsed(argPrintDtd);

    if (help) {
      return printUsage(
          'Dart DevTools version ${await DevToolsUtils.getVersion(customDevToolsPath ?? "")}');
    }

    if (version) {
      final versionStr =
          await DevToolsUtils.getVersion(customDevToolsPath ?? '');
      DevToolsUtils.printOutput(
        'Dart DevTools version $versionStr',
        {
          'version': versionStr,
        },
        machineMode: machineMode,
      );
      return null;
    }

    // Prefer getting the VM URI from the rest args; fall back on the 'vm-url'
    // option otherwise.
    String? serviceProtocolUri;
    if (args.rest.isNotEmpty) {
      serviceProtocolUri = args.rest.first;
    } else if (args.wasParsed(argVmUri)) {
      serviceProtocolUri = args[argVmUri];
    }

    // Support collecting profile data.
    String? profileFilename;
    if (args.wasParsed(argProfileMemory)) {
      profileFilename = args[argProfileMemory];
    }
    if (profileFilename != null && !path.isAbsolute(profileFilename)) {
      profileFilename = path.absolute(profileFilename);
    }

    // App size info.
    String? appSizeBase = args[argAppSizeBase];
    if (appSizeBase != null && !path.isAbsolute(appSizeBase)) {
      appSizeBase = path.absolute(appSizeBase);
    }
    String? appSizeTest = args[argAppSizeTest];
    if (appSizeTest != null && !path.isAbsolute(appSizeTest)) {
      appSizeTest = path.absolute(appSizeTest);
    }

    return serveDevTools(
      machineMode: machineMode,
      debugMode: debugMode,
      launchBrowser: launchBrowser,
      enableNotifications: enableNotifications,
      allowEmbedding: allowEmbedding,
      disableCors: disableCors,
      port: port,
      headlessMode: headlessMode,
      numPortsToTry: numPortsToTry,
      handler: handler,
      customDevToolsPath: customDevToolsPath,
      serviceProtocolUri: serviceProtocolUri,
      profileFilename: profileFilename,
      verboseMode: verboseMode,
      hostname: hostname,
      appSizeBase: appSizeBase,
      appSizeTest: appSizeTest,
      dtdInfo:
          dtdUri != null ? DtdInfo(dtdUri, exposedUri: dtdExposedUri) : null,
      printDtdUri: printDtdUri,
    );
  }

  Future<Map<String, dynamic>> launchDevTools(
    Map<String, dynamic> params,
    Uri vmServiceUri,
    String devToolsUrl,
    bool headlessMode,
    bool machineMode,
  ) async {
    // First see if we have an existing DevTools client open that we can
    // reuse.
    final canReuse =
        params.containsKey('reuseWindows') && params['reuseWindows'] == true;
    final shouldNotify =
        params.containsKey('notify') && params['notify'] == true;
    final page = params['page'];
    if (canReuse &&
        await _tryReuseExistingDevToolsInstance(
          vmServiceUri,
          page,
          shouldNotify,
        )) {
      _emitLaunchEvent(
        reused: true,
        notified: shouldNotify,
        pid: null,
        machineMode: machineMode,
      );
      return {
        'reused': true,
        'notified': shouldNotify,
      };
    }

    final uriParams = <String, dynamic>{};

    // Copy over queryParams passed by the client
    params['queryParams']?.forEach((key, value) => uriParams[key] = value);

    // Add the URI to the VM service
    uriParams['uri'] = vmServiceUri.toString();

    final devToolsUri = Uri.parse(devToolsUrl);
    final uriToLaunch = buildUriToLaunch(
      devToolsUri,
      page,
      uriParams,
    );

    // TODO(dantup): When ChromeOS has support for tunneling all ports we can
    // change this to always use the native browser for ChromeOS and may wish to
    // handle this inside `browser_launcher`; https://crbug.com/848063.
    final useNativeBrowser = _isChromeOS &&
        _isAccessibleToChromeOSNativeBrowser(devToolsUri) &&
        _isAccessibleToChromeOSNativeBrowser(vmServiceUri);
    int? browserPid;
    if (useNativeBrowser) {
      await Process.start('x-www-browser', [uriToLaunch.toString()]);
    } else {
      final args = headlessMode
          ? [
              '--headless',
              // When running headless, Chrome will quit immediately after
              // loading the page unless we have the debug port open.
              '--remote-debugging-port=9223',
              '--disable-gpu',
              '--no-sandbox',
              // When running on MacOS, Chrome may open system dialogs
              // requesting credentials. This uses a mock keychain to avoid that
              // dialog from blocking.
              '--use-mock-keychain',
            ]
          : <String>[];
      final proc = await Chrome.start([uriToLaunch.toString()], args: args);
      browserPid = proc.pid;
    }
    _emitLaunchEvent(
        reused: false,
        notified: false,
        pid: browserPid!,
        machineMode: machineMode);
    return {
      'reused': false,
      'notified': false,
      'pid': browserPid,
    };
  }

  Future<void> _hookupMemoryProfiling(
    Uri observatoryUri,
    String profileFile, [
    bool verboseMode = false,
  ]) async {
    final service = await DevToolsUtils.connectToVmService(observatoryUri);
    if (service == null) {
      return;
    }

    final memoryProfiler = MemoryProfile(service, profileFile, verboseMode);
    memoryProfiler.startPolling();

    print('Writing memory profile samples to $profileFile...');
  }

  /// Tries to reuse an existing DevTools instance.
  ///
  /// Because SSE connections have timeouts that prevent us knowing if a client
  /// has really gone away for up to 30s, this method will attempt to ping
  /// candidate first to see if it is still responsive.
  Future<bool> _tryReuseExistingDevToolsInstance(
    Uri vmServiceUri,
    String? page,
    bool notifyUser,
  ) async {
    // First try to find a client that's already connected to this VM service,
    // and just send the user a notification for that one.
    final existingClient =
        await clientManager.findExistingConnectedReusableClient(vmServiceUri);
    if (existingClient != null) {
      try {
        if (page != null) {
          existingClient.showPage(page);
        }
        if (notifyUser) {
          existingClient.notify();
        }
        return true;
      } catch (e) {
        print('Failed to reuse existing connected DevTools client');
        print(e);
      }
    }

    final reusableClient = await clientManager.findReusableClient();
    if (reusableClient != null) {
      try {
        reusableClient.connectToVmService(vmServiceUri, notifyUser);
        return true;
      } catch (e) {
        print('Failed to reuse existing DevTools client');
        print(e);
      }
    }
    return false;
  }

  static String buildUriToLaunch(
    Uri devToolsUri,
    String? page,
    Map<String, dynamic>? params,
  ) {
    page ??= '';
    var pathSep = devToolsUri.path.endsWith('/') ? '' : '/';
    var newPath = '${devToolsUri.path}$pathSep$page';
    var newParams = {
      ...devToolsUri.queryParameters,
      ...?params,
    };
    return devToolsUri
        .replace(
            path: newPath,
            queryParameters: newParams.isNotEmpty ? newParams : null)
        .toString();
  }

  /// Prints a launch event to stdout so consumers of the DevTools server
  /// can see when clients are being launched/reused.
  void _emitLaunchEvent(
      {required bool reused,
      required bool notified,
      required int? pid,
      required bool machineMode}) {
    DevToolsUtils.printOutput(
      null,
      {
        'event': 'client.launch',
        'params': {
          'reused': reused,
          'notified': notified,
          'pid': pid,
        },
      },
      machineMode: machineMode,
    );
  }

  bool _isAccessibleToChromeOSNativeBrowser(Uri uri) {
    const tunneledPorts = {
      8000,
      8008,
      8080,
      8085,
      8888,
      9005,
      3000,
      4200,
      5000
    };
    return uri.hasPort && tunneledPorts.contains(uri.port);
  }
}
