// 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 'dart:io';

import 'package:build_daemon/client.dart';
import 'package:build_daemon/data/build_status.dart';
import 'package:build_daemon/data/build_target.dart';
import 'package:dwds/asset_reader.dart';
import 'package:dwds/dart_web_debug_service.dart';
import 'package:dwds/src/connections/app_connection.dart';
import 'package:dwds/src/connections/debug_connection.dart';
import 'package:dwds/src/debugging/webkit_debugger.dart';
import 'package:dwds/src/loaders/build_runner_require.dart';
import 'package:dwds/src/loaders/frontend_server_strategy_provider.dart';
import 'package:dwds/src/loaders/strategy.dart';
import 'package:dwds/src/readers/proxy_server_asset_reader.dart';
import 'package:dwds/src/services/chrome/chrome_proxy_service.dart';
import 'package:dwds/src/services/expression_compiler.dart';
import 'package:dwds/src/services/expression_compiler_service.dart';
import 'package:dwds/src/utilities/dart_uri.dart';
import 'package:dwds/src/utilities/server.dart';
import 'package:file/local.dart';
import 'package:frontend_server_common/src/devfs.dart';
import 'package:frontend_server_common/src/resident_runner.dart';
import 'package:http/http.dart';
import 'package:http/io_client.dart';
import 'package:logging/logging.dart' as logging;
import 'package:path/path.dart' as p;
import 'package:shelf/shelf.dart';
import 'package:shelf_proxy/shelf_proxy.dart';
import 'package:test/test.dart';
import 'package:test_common/logging.dart';
import 'package:test_common/test_sdk_configuration.dart';
import 'package:test_common/utilities.dart';
import 'package:vm_service/vm_service.dart';
import 'package:vm_service/vm_service_io.dart';
import 'package:webdriver/async_io.dart';
import 'package:webkit_inspection_protocol/webkit_inspection_protocol.dart';

import 'project.dart';
import 'server.dart';
import 'utilities.dart';

final _exeExt = Platform.isWindows ? '.exe' : '';

const isRPCError = TypeMatcher<RPCError>();
const isSentinelException = TypeMatcher<SentinelException>();

final Matcher throwsRPCError = throwsA(isRPCError);
final Matcher throwsSentinelException = throwsA(isSentinelException);

Matcher isRPCErrorWithMessage(String message) =>
    isA<RPCError>().having((e) => e.message, 'message', contains(message));
Matcher throwsRPCErrorWithMessage(String message) =>
    throwsA(isRPCErrorWithMessage(message));

Matcher isRPCErrorWithCode(int code) =>
    isA<RPCError>().having((e) => e.code, 'code', equals(code));
Matcher throwsRPCErrorWithCode(int code) => throwsA(isRPCErrorWithCode(code));

enum CompilationMode { buildDaemon, frontendServer }

class TestContext {
  final TestProject project;
  final TestSdkConfigurationProvider sdkConfigurationProvider;

  String get appUrl => _appUrl!;
  late String? _appUrl;

  WipConnection get tabConnection => _tabConnection!;
  late WipConnection? _tabConnection;

  TestServer get testServer => _testServer!;
  TestServer? _testServer;

  Dwds? get dwds => _testServer?.dwds;

  BuildDaemonClient get daemonClient => _daemonClient!;
  BuildDaemonClient? _daemonClient;

  ResidentWebRunner get webRunner => _webRunner!;
  ResidentWebRunner? _webRunner;

  WebDriver get webDriver => _webDriver!;
  WebDriver? _webDriver;

  Process get chromeDriver => _chromeDriver!;
  Process? _chromeDriver;

  WebkitDebugger get webkitDebugger => _webkitDebugger!;
  late WebkitDebugger? _webkitDebugger;

  Handler get assetHandler => _assetHandler!;
  late Handler? _assetHandler;

  Client get client => _client!;
  Client? _client;

  ExpressionCompilerService? ddcService;

  int get port => _port!;
  late int? _port;

  Directory get outputDir => _outputDir!;
  Directory? _outputDir;

  late WipConnection extensionConnection;
  late AppConnection appConnection;
  late DebugConnection debugConnection;

  final _logger = logging.Logger('Context');

  final _serviceNameToMethod = <String, String?>{};

  late LocalFileSystem frontendServerFileSystem;

  late String _hostname;

  /// Internal VM service.
  ///
  /// Prefer using [vmService] instead in tests when possible, to include testing
  /// of the VmServerConnection (bypassed when using [service]).
  ChromeProxyService get service => fetchChromeProxyService(debugConnection);

  /// External VM service.
  VmService get vmService => debugConnection.vmService;

  TestContext(this.project, this.sdkConfigurationProvider);

  Future<void> setUp({
    TestSettings testSettings = const TestSettings(),
    TestAppMetadata appMetadata = const TestAppMetadata.externalApp(),
    TestDebugSettings debugSettings =
        const TestDebugSettings.noDevToolsLaunch(),
  }) async {
    try {
      // Build settings to return from load strategy.
      final buildSettings = TestBuildSettings(
        appEntrypoint: project.dartEntryFilePackageUri,
        canaryFeatures: testSettings.canaryFeatures,
        isFlutterApp: testSettings.isFlutterApp,
        experiments: testSettings.experiments,
      );

      // Make sure configuration was created correctly.
      final sdkLayout = sdkConfigurationProvider.sdkLayout;
      final configuration = await sdkConfigurationProvider.configuration;
      configuration.validate();
      await project.setUp();

      DartUri.currentDirectory = project.absolutePackageDirectory;

      _logger.info(
        'Serving: ${project.directoryToServe}/${project.filePathToServe}',
      );
      _logger.info('Project: ${project.absolutePackageDirectory}');
      _logger.info('Packages: ${project.packageConfigFile}');
      _logger.info('Entry: ${project.dartEntryFilePath}');

      configureLogWriter();

      _client = IOClient(
        HttpClient()
          ..maxConnectionsPerHost = 200
          ..idleTimeout = const Duration(seconds: 30)
          ..connectionTimeout = const Duration(seconds: 30),
      );

      final systemTempDir = Directory.systemTemp;
      _outputDir = systemTempDir.createTempSync('foo bar');

      final chromeDriverPort = await findUnusedPort();
      final chromeDriverUrlBase = 'wd/hub';
      try {
        _chromeDriver = await Process.start('chromedriver$_exeExt', [
          '--port=$chromeDriverPort',
          '--url-base=$chromeDriverUrlBase',
        ]);
        final stdOutLines = chromeDriver.stdout
            .transform(utf8.decoder)
            .transform(const LineSplitter())
            .asBroadcastStream();

        final stdErrLines = chromeDriver.stderr
            .transform(utf8.decoder)
            .transform(const LineSplitter())
            .asBroadcastStream();

        // Sometimes ChromeDriver can be slow to startup.
        // This was seen on a github actions run:
        // > 11:22:59.924700: ChromeDriver stdout: Starting ChromeDriver
        // >                  139.0.7258.154 ([...]) on port 38107
        // > [...]
        // > 11:23:00.237350: ChromeDriver stdout: ChromeDriver was started
        // >                  successfully on port 38107.
        // Where in the 300+ ms it took before it was actually ready to accept
        // a connection we had tried - and failed - to connect.
        // We therefore wait until ChromeDriver reports that it has started
        // successfully.

        final chromeDriverStartup = Completer();
        stdOutLines.listen((line) {
          if (!chromeDriverStartup.isCompleted &&
              line.contains('was started successfully')) {
            chromeDriverStartup.complete();
          }
          _logger.finest('ChromeDriver stdout: $line');
        });
        stdErrLines.listen(
          (line) => _logger.warning('ChromeDriver stderr: $line'),
        );

        await chromeDriverStartup.future;
      } catch (e) {
        throw StateError(
          'Could not start ChromeDriver. Is it installed?\nError: $e',
        );
      }

      await Process.run(sdkLayout.dartPath, [
        'pub',
        'upgrade',
      ], workingDirectory: project.absolutePackageDirectory);

      ExpressionCompiler? expressionCompiler;
      AssetReader assetReader;
      Stream<BuildResults> buildResults;
      LoadStrategy loadStrategy;
      var basePath = '';
      var filePathToServe = project.filePathToServe;

      _port = await findUnusedPort();
      switch (testSettings.compilationMode) {
        case CompilationMode.buildDaemon:
          {
            final options = [
              if (testSettings.enableExpressionEvaluation) ...[
                '--define',
                'build_web_compilers|ddc=generate-full-dill=true',
              ],
              for (final experiment in buildSettings.experiments)
                '--enable-experiment=$experiment',
              if (buildSettings.canaryFeatures) ...[
                '--define',
                'build_web_compilers|ddc=canary=true',
                '--define',
                'build_web_compilers|sdk_js=canary=true',
              ],
              '--verbose',
            ];
            _daemonClient = await connectClient(
              sdkLayout.dartPath,
              project.absolutePackageDirectory,
              options,
              (log) {
                final record = log.toLogRecord();
                final name = record.loggerName == ''
                    ? ''
                    : '${record.loggerName}: ';
                _logger.log(
                  record.level,
                  '$name${record.message}',
                  record.error,
                  record.stackTrace,
                );
              },
            );
            daemonClient.registerBuildTarget(
              DefaultBuildTarget((b) => b..target = project.directoryToServe),
            );
            daemonClient.startBuild();

            await waitForSuccessfulBuild();

            final assetServerPort = daemonPort(
              project.absolutePackageDirectory,
            );
            _assetHandler = proxyHandler(
              'http://localhost:$assetServerPort/${project.directoryToServe}/',
              client: client,
            );
            assetReader = ProxyServerAssetReader(
              assetServerPort,
              root: project.directoryToServe,
            );

            if (testSettings.enableExpressionEvaluation) {
              ddcService = ExpressionCompilerService(
                'localhost',
                port,
                verbose: testSettings.verboseCompiler,
                sdkConfigurationProvider: sdkConfigurationProvider,
              );
              expressionCompiler = ddcService;
            }

            loadStrategy = BuildRunnerRequireStrategyProvider(
              assetHandler,
              testSettings.reloadConfiguration,
              assetReader,
              buildSettings,
            ).strategy;

            buildResults = daemonClient.buildResults;
          }
          break;
        case CompilationMode.frontendServer:
          {
            filePathToServe = webCompatiblePath([
              project.directoryToServe,
              project.filePathToServe,
            ]);

            _logger.info('Serving: $filePathToServe');

            final entry = p.toUri(
              p.join(project.webAssetsPath, project.dartEntryFileName),
            );
            frontendServerFileSystem = LocalFileSystem();
            final packageUriMapper = await PackageUriMapper.create(
              frontendServerFileSystem,
              project.packageConfigFile,
              useDebuggerModuleNames: testSettings.useDebuggerModuleNames,
            );

            final compilerOptions = TestCompilerOptions(
              experiments: buildSettings.experiments,
              canaryFeatures: buildSettings.canaryFeatures,
              moduleFormat: testSettings.moduleFormat,
            );

            _webRunner = ResidentWebRunner(
              mainUri: entry,
              urlTunneler: debugSettings.urlEncoder,
              projectDirectory: Directory(project.absolutePackageDirectory).uri,
              packageConfigFile: project.packageConfigFile,
              packageUriMapper: packageUriMapper,
              fileSystemRoots: [
                Directory(project.absolutePackageDirectory).uri,
              ],
              fileSystemScheme: 'org-dartlang-app',
              outputPath: outputDir.path,
              compilerOptions: compilerOptions,
              sdkLayout: sdkLayout,
              verbose: testSettings.verboseCompiler,
            );

            final assetServerPort = await findUnusedPort();
            _hostname = appMetadata.hostname;
            await webRunner.run(
              frontendServerFileSystem,
              hostname: _hostname,
              port: assetServerPort,
              index: filePathToServe,
            );

            if (testSettings.enableExpressionEvaluation) {
              expressionCompiler = webRunner.expressionCompiler;
            }

            basePath = webRunner.devFS!.assetServer.basePath;
            assetReader = webRunner.devFS!.assetServer;
            _assetHandler = webRunner.devFS!.assetServer.handleRequest;
            loadStrategy = switch (testSettings.moduleFormat) {
              ModuleFormat.amd => FrontendServerRequireStrategyProvider(
                testSettings.reloadConfiguration,
                assetReader,
                packageUriMapper,
                () async => {},
                buildSettings,
              ).strategy,
              ModuleFormat.ddc =>
                buildSettings.canaryFeatures
                    ? FrontendServerDdcLibraryBundleStrategyProvider(
                        testSettings.reloadConfiguration,
                        assetReader,
                        packageUriMapper,
                        () async => {},
                        buildSettings,
                        reloadedSourcesUri: Uri.parse(
                          'http://localhost:$port/${WebDevFS.reloadedSourcesFileName}',
                        ),
                      ).strategy
                    : FrontendServerDdcStrategyProvider(
                        testSettings.reloadConfiguration,
                        assetReader,
                        packageUriMapper,
                        () async => {},
                        buildSettings,
                      ).strategy,
              _ => throw Exception(
                'Unsupported DDC module format ${testSettings.moduleFormat.name}.',
              ),
            };
            buildResults = const Stream<BuildResults>.empty();
          }
          break;
      }

      final debugPort = await findUnusedPort();
      if (testSettings.launchChrome) {
        // If the environment variable DWDS_DEBUG_CHROME is set to the string
        // true then Chrome will be launched with a UI rather than headless.
        // If the extension is enabled, then Chrome will be launched with a UI
        // since headless Chrome does not support extensions.
        final enableDebugExtension = debugSettings.enableDebugExtension;
        final headless =
            Platform.environment['DWDS_DEBUG_CHROME'] != 'true' &&
            !enableDebugExtension;
        if (enableDebugExtension) {
          await _buildDebugExtension();
        }
        final capabilities = Capabilities.chrome
          ..addAll({
            Capabilities.chromeOptions: {
              'args': [
                // --disable-gpu speeds up the tests that use ChromeDriver when
                // they are run on GitHub Actions.
                '--disable-gpu',
                'remote-debugging-port=$debugPort',
                if (enableDebugExtension)
                  '--load-extension=debug_extension/prod_build',
                if (headless) '--headless',
              ],
            },
          });
        _webDriver = await createDriver(
          spec: WebDriverSpec.JsonWire,
          desired: capabilities,
          uri: Uri.parse(
            'http://127.0.0.1:$chromeDriverPort/$chromeDriverUrlBase/',
          ),
        );
      }

      // The debugger tab must be enabled and connected before certain
      // listeners in DWDS or `main` is run.
      final tabConnectionCompleter = Completer();
      final appConnectionCompleter = Completer();
      final connection = ChromeConnection('localhost', debugPort);

      // TODO(srujzs): In the case of the frontend server, it doesn't make sense
      // that we initialize a new HTTP server instead of reusing the one in
      // `TestAssetServer`. We should instead use that one to align with Flutter
      // tools.
      _testServer = await TestServer.start(
        debugSettings: debugSettings.copyWith(
          expressionCompiler: expressionCompiler,
        ),
        appMetadata: appMetadata,
        port: port,
        assetHandler: assetHandler,
        assetReader: assetReader,
        strategy: loadStrategy,
        target: project.directoryToServe,
        buildResults: buildResults,
        chromeConnection: () async => connection,
      );

      _testServer!.dwds.connectedApps.listen((connection) async {
        // Ensure that we've established a tab connection before running main.
        await tabConnectionCompleter.future;
        if (testSettings.autoRun) {
          connection.runMain();
        }

        // We may reuse the app connection, so only save it the first time
        // it's encountered.
        if (!appConnectionCompleter.isCompleted) {
          appConnection = connection;
          appConnectionCompleter.complete();
        }
      });

      _appUrl = basePath.isEmpty
          ? 'http://localhost:$port/$filePathToServe'
          : 'http://localhost:$port/$basePath/$filePathToServe';

      if (testSettings.launchChrome) {
        await _webDriver?.get(appUrl);
        final tab = await connection.getTab((t) => t.url == appUrl);
        if (tab != null) {
          _tabConnection = await tab.connect();
          await tabConnection.runtime.enable();
          await tabConnection.debugger.enable().then(
            (_) => tabConnectionCompleter.complete(),
          );
        } else {
          throw StateError('Unable to connect to tab.');
        }

        if (debugSettings.enableDebugExtension) {
          final extensionTab = await _fetchDartDebugExtensionTab(connection);
          extensionConnection = await extensionTab.connect();
          await extensionConnection.runtime.enable();
        }

        await appConnectionCompleter.future;
        if (debugSettings.enableDebugging && !testSettings.waitToDebug) {
          await startDebugging();
        }
        _webkitDebugger = WebkitDebugger(WipDebugger(tabConnection));
      } else {
        // No tab needs to be discovered, so fulfill the relevant completer.
        tabConnectionCompleter.complete();
      }
    } catch (e, s) {
      _logger.severe('Failed to setup the service, $e:$s');
      await tearDown();
      rethrow;
    }
  }

  /// Creates a VM service connection connected to the debug URI.
  ///
  /// This can be used to test behavior that should be available to a client
  /// connected to DWDS.
  Future<VmService> connectFakeClient() async {
    final fakeClient = await vmServiceConnectUri(debugConnection.uri);

    fakeClient.onEvent(EventStreams.kService).listen(_handleServiceEvent);
    await fakeClient.streamListen(EventStreams.kService);

    return fakeClient;
  }

  /// Returns the service extension method given the [extensionName].
  ///
  /// The extension be called by a client created with [connectFakeClient].
  String? getRegisteredServiceExtension(String extensionName) {
    if (_serviceNameToMethod.isEmpty) {
      throw StateError('''
        No registered service extensions. Did you call connectFakeClient?
      ''');
    }

    return _serviceNameToMethod[extensionName];
  }

  void _handleServiceEvent(Event e) {
    if (e.kind == EventKind.kServiceRegistered) {
      final serviceName = e.service!;
      _serviceNameToMethod[serviceName] = e.method;
    }
  }

  Future<void> startDebugging() async {
    debugConnection = await testServer.dwds.debugConnection(appConnection);
  }

  Future<void> tearDown() async {
    await _webRunner?.stop();
    await _webDriver?.quit(closeSession: true);
    _chromeDriver?.kill();
    DartUri.currentDirectory = p.current;
    await _daemonClient?.close();
    await ddcService?.stop();
    await _testServer?.stop();
    _client?.close();
    await _outputDir?.delete(recursive: true);
    stopLogWriter();
    await project.tearDown();

    // clear the state for next setup
    _webDriver = null;
    _chromeDriver = null;
    _daemonClient = null;
    ddcService = null;
    _webRunner = null;
    _testServer = null;
    _client = null;
    _outputDir = null;
  }

  /// Given a list of edits, use file IO to write them to the file system.
  ///
  /// If `file` has the same name as the project's entry file name, that file
  /// will be edited. Otherwise, it's assumed to be a library file.
  // TODO(srujzs): It's possible we may want a library file with the same name
  // as the entry file, but this function doesn't allow that. Potentially
  // support that.
  Future<void> makeEdits(List<Edit> edits) async {
    // `dart:io`'s `stat` on Windows does not have millisecond precision so we
    // need to make sure we wait long enough that modifications result in a
    // timestamp that is guaranteed to be after the previous compile.
    // TODO(https://github.com/dart-lang/sdk/issues/51937): Remove once this bug
    // is fixed.
    if (Platform.isWindows) await Future.delayed(Duration(seconds: 1));
    for (var (:file, :originalString, :newString) in edits) {
      if (file == project.dartEntryFileName) {
        file = project.dartEntryFilePath;
      } else {
        file = project.dartLibFilePath(file);
      }
      final f = File(project.dartLibFilePath(file));
      final fileContents = f.readAsStringSync();
      f.writeAsStringSync(fileContents.replaceAll(originalString, newString));
    }
  }

  void addLibraryFile({required String libFileName, required String contents}) {
    final file = File(project.dartLibFilePath(libFileName));
    // Library folder may not exist yet, so create it.
    file.createSync(recursive: true);
    file.writeAsStringSync(contents);
  }

  Future<void> recompile({required bool fullRestart}) async {
    await webRunner.rerun(
      fullRestart: fullRestart,
      fileServerUri: Uri.parse('http://${testServer.host}:${testServer.port}'),
    );
    return;
  }

  Future<void> waitForSuccessfulBuild({
    Duration? timeout,
    bool propagateToBrowser = false,
  }) async {
    // Wait for the build until the timeout is reached:
    await daemonClient.buildResults
        .firstWhere(
          (results) => results.results.any(
            (result) => result.status == BuildStatus.succeeded,
          ),
        )
        .timeout(timeout ?? const Duration(seconds: 60));

    if (propagateToBrowser) {
      // Allow change to propagate to the browser.
      // Windows, or at least Travis on Windows, seems to need more time.
      final delay = Platform.isWindows
          ? const Duration(seconds: 5)
          : const Duration(seconds: 2);
      await Future.delayed(delay);
    }
  }

  Future<void> _buildDebugExtension() async {
    final process = await Process.run(
      'tool/build_extension.sh',
      ['prod'],
      workingDirectory: absolutePath(pathFromDwds: 'debug_extension'),
    );
    print(process.stdout);
  }

  Future<ChromeTab> _fetchDartDebugExtensionTab(
    ChromeConnection connection,
  ) async {
    final extensionTabs = (await connection.getTabs()).where((tab) {
      return tab.isChromeExtension;
    });
    for (final tab in extensionTabs) {
      final tabConnection = await tab.connect();
      final response = await tabConnection.runtime.evaluate(
        'window.isDartDebugExtension',
      );
      if (response.value == true) {
        return tab;
      }
    }
    throw StateError('No extension installed.');
  }

  /// Finds the line number in [scriptRef] matching [breakpointId].
  ///
  /// A breakpoint ID is found by looking for a line that ends with a comment
  /// of exactly this form: `// Breakpoint: <id>`.
  ///
  /// Throws if it can't find the matching line.
  Future<int> findBreakpointLine(
    String breakpointId,
    String isolateId,
    ScriptRef scriptRef,
  ) async {
    final script =
        await debugConnection.vmService.getObject(isolateId, scriptRef.id!)
            as Script;
    final lines = LineSplitter.split(script.source!).toList();
    final lineNumber = lines.indexWhere(
      (l) => l.endsWith('// Breakpoint: $breakpointId'),
    );
    if (lineNumber == -1) {
      throw StateError(
        'Unable to find breakpoint in ${scriptRef.uri} with id '
        '$breakpointId',
      );
    }
    return lineNumber + 1;
  }
}

typedef Edit = ({String file, String originalString, String newString});
