// Copyright (c) 2016, 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:async/async.dart';
import 'package:path/path.dart' as p;
import 'package:test_api/backend.dart' show Compiler, Runtime, SuitePlatform;
import 'package:test_core/src/runner/configuration.dart'; // ignore: implementation_imports
import 'package:test_core/src/runner/load_exception.dart'; // ignore: implementation_imports
import 'package:test_core/src/runner/platform.dart'; // ignore: implementation_imports
import 'package:test_core/src/runner/plugin/customizable_platform.dart'; // ignore: implementation_imports
import 'package:test_core/src/runner/runner_suite.dart'; // ignore: implementation_imports
import 'package:test_core/src/runner/suite.dart'; // ignore: implementation_imports
import 'package:test_core/src/util/package_config.dart'; // ignore: implementation_imports
import 'package:yaml/yaml.dart';

import '../executable_settings.dart';
import 'browser_manager.dart';
import 'compilers/compiler_support.dart';
import 'compilers/dart2js.dart';
import 'compilers/dart2wasm.dart';
import 'compilers/precompiled.dart';
import 'default_settings.dart';

class BrowserPlatform extends PlatformPlugin
    implements CustomizablePlatform<ExecutableSettings> {
  /// Starts the server.
  ///
  /// [root] is the root directory that the server should serve. It defaults to
  /// the working directory.
  static Future<BrowserPlatform> start({String? root}) async {
    var packageConfig = await currentPackageConfig;
    return BrowserPlatform._(
        Configuration.current,
        p.fromUri(packageConfig.resolve(
            Uri.parse('package:test/src/runner/browser/static/favicon.ico'))),
        p.fromUri(packageConfig.resolve(Uri.parse(
            'package:test/src/runner/browser/static/default.html.tpl'))),
        p.fromUri(packageConfig.resolve(Uri.parse(
            'package:test/src/runner/browser/static/run_wasm_chrome.js'))),
        root: root);
  }

  /// The test runner configuration.
  final Configuration _config;

  /// The cached [CompilerSupport] for each compiler.
  final _compilerSupport = <Compiler, Future<CompilerSupport>>{};

  /// The `package:test` side wrapper for the Dart2Wasm runtime.
  final String _jsRuntimeWrapper;

  /// The URL for this server and [compiler] combination.
  ///
  /// Each compiler serves its tests under a different randomly-generated
  /// secret URI to ensure that other users on the same system can't snoop
  /// on data being served through this server, as well as distinguish tests
  /// from different compilers from each other.
  Future<CompilerSupport> compilerSupport(Compiler compiler) =>
      _compilerSupport.putIfAbsent(compiler, () {
        if (_config.suiteDefaults.precompiledPath != null) {
          return PrecompiledSupport.start(
              compiler: compiler,
              config: _config,
              defaultTemplatePath: _defaultTemplatePath,
              root: _config.suiteDefaults.precompiledPath!,
              faviconPath: _faviconPath);
        }
        return switch (compiler) {
          Compiler.dart2js => Dart2JsSupport.start(
              config: _config,
              defaultTemplatePath: _defaultTemplatePath,
              root: _root,
              faviconPath: _faviconPath),
          Compiler.dart2wasm => Dart2WasmSupport.start(
              config: _config,
              defaultTemplatePath: _defaultTemplatePath,
              jsRuntimeWrapper: _jsRuntimeWrapper,
              root: _root,
              faviconPath: _faviconPath),
          _ => throw StateError('Unexpected compiler $compiler'),
        };
      });

  /// The root directory served statically by this server.
  final String _root;

  /// Whether [close] has been called.
  bool get _closed => _closeMemo.hasRun;

  /// A map from browser identifiers to futures that will complete to the
  /// [BrowserManager]s for those browsers, or `null` if they failed to load.
  ///
  /// This should only be accessed through [_browserManagerFor].
  final _browserManagers = <(Runtime, Compiler), Future<BrowserManager?>>{};

  /// Settings for invoking each browser.
  ///
  /// This starts out with the default settings, which may be overridden by user settings.
  final _browserSettings =
      Map<Runtime, ExecutableSettings>.from(defaultSettings);

  /// The default template for html tests.
  final String _defaultTemplatePath;

  final String _faviconPath;

  BrowserPlatform._(Configuration config, this._faviconPath,
      this._defaultTemplatePath, this._jsRuntimeWrapper,
      {String? root})
      : _config = config,
        _root = root ?? p.current;

  @override
  ExecutableSettings parsePlatformSettings(YamlMap settings) =>
      ExecutableSettings.parse(settings);

  @override
  ExecutableSettings mergePlatformSettings(
          ExecutableSettings settings1, ExecutableSettings settings2) =>
      settings1.merge(settings2);

  @override
  void customizePlatform(Runtime runtime, ExecutableSettings settings) {
    var oldSettings =
        _browserSettings[runtime] ?? _browserSettings[runtime.root];
    if (oldSettings != null) settings = oldSettings.merge(settings);
    _browserSettings[runtime] = settings;
  }

  /// Loads the test suite at [path] on the platform [platform].
  ///
  /// This will start a browser to load the suite if one isn't already running.
  /// Throws an [ArgumentError] if `platform.platform` isn't a browser.
  @override
  Future<RunnerSuite?> load(String path, SuitePlatform platform,
      SuiteConfiguration suiteConfig, Map<String, Object?> message) async {
    var browser = platform.runtime;
    assert(suiteConfig.runtimes.contains(browser.identifier));

    if (!browser.isBrowser) {
      throw ArgumentError('$browser is not a browser.');
    }

    var compiler = platform.compiler;
    var support = await compilerSupport(compiler);

    var htmlPathFromTestPath = '${p.withoutExtension(path)}.html';
    if (File(htmlPathFromTestPath).existsSync()) {
      if (_config.customHtmlTemplatePath != null &&
          p.basename(htmlPathFromTestPath) ==
              p.basename(_config.customHtmlTemplatePath!)) {
        throw LoadException(
            path,
            'template file "${p.basename(_config.customHtmlTemplatePath!)}" cannot be named '
            'like the test file.');
      }
      _checkHtmlCorrectness(htmlPathFromTestPath, path);
    } else if (_config.customHtmlTemplatePath != null) {
      var htmlTemplatePath = _config.customHtmlTemplatePath!;
      if (!File(htmlTemplatePath).existsSync()) {
        throw LoadException(
            path, '"$htmlTemplatePath" does not exist or is not readable');
      }

      final templateFileContents = File(htmlTemplatePath).readAsStringSync();
      if ('{{testScript}}'.allMatches(templateFileContents).length != 1) {
        throw LoadException(path,
            '"$htmlTemplatePath" must contain exactly one {{testScript}} placeholder');
      }
      _checkHtmlCorrectness(htmlTemplatePath, path);
    }

    if (_closed) return null;
    await support.compileSuite(path, suiteConfig, platform);

    var suiteUrl = support.serverUrl.resolveUri(
        p.toUri('${p.withoutExtension(p.relative(path, from: _root))}.html'));

    if (_closed) return null;

    var browserManager = await _browserManagerFor(browser, compiler);
    if (_closed || browserManager == null) return null;

    var timeout = const Duration(seconds: 30);
    if (suiteConfig.metadata.timeout.apply(timeout) case final suiteTimeout?
        when suiteTimeout > timeout) {
      timeout = suiteTimeout;
    }
    var suite = await browserManager.load(
        path, suiteUrl, suiteConfig, message, platform.compiler,
        mapper: (await compilerSupport(compiler)).stackTraceMapperForPath(path),
        timeout: timeout);
    if (_closed) return null;
    return suite;
  }

  void _checkHtmlCorrectness(String htmlPath, String path) {
    if (!File(htmlPath).readAsStringSync().contains('packages/test/dart.js')) {
      throw LoadException(
          path,
          '"$htmlPath" must contain <script src="packages/test/dart.js">'
          '</script>.');
    }
  }

  /// Returns the [BrowserManager] for [runtime], which should be a browser.
  ///
  /// If no browser manager is running yet, starts one.
  Future<BrowserManager?> _browserManagerFor(
      Runtime browser, Compiler compiler) {
    var managerFuture = _browserManagers[(browser, compiler)];
    if (managerFuture != null) return managerFuture;

    var future = _createBrowserManager(browser, compiler);
    // Store null values for browsers that error out so we know not to load them
    // again.
    _browserManagers[(browser, compiler)] =
        future.then<BrowserManager?>((value) => value).onError((_, __) => null);

    return future;
  }

  Future<BrowserManager> _createBrowserManager(
      Runtime browser, Compiler compiler) async {
    var support = await compilerSupport(compiler);
    var (webSocketUrl, socketFuture) = support.webSocket;
    var hostUrl = support.serverUrl
        .resolve('packages/test/src/runner/browser/static/index.html')
        .replace(queryParameters: {
      'managerUrl': webSocketUrl.toString(),
      'debug': _config.debug.toString()
    });

    return BrowserManager.start(
        browser, hostUrl, socketFuture, _browserSettings[browser]!, _config);
  }

  /// Close all the browsers that the server currently has open.
  ///
  /// Note that this doesn't close the server itself. Browser tests can still be
  /// loaded, they'll just spawn new browsers.
  @override
  Future<List<void>> closeEphemeral() {
    var managers = _browserManagers.values.toList();
    _browserManagers.clear();
    return Future.wait(managers.map((manager) async {
      var result = await manager;
      if (result == null) return;
      await result.close();
    }));
  }

  /// Closes the server and releases all its resources.
  ///
  /// Returns a [Future] that completes once the server is closed and its
  /// resources have been fully released.
  @override
  Future<void> close() async => _closeMemo.runOnce(() => Future.wait([
        for (var browser in _browserManagers.values)
          browser.then((b) => b?.close()),
        for (var support in _compilerSupport.values)
          support.then((s) => s.close()),
      ]));
  final _closeMemo = AsyncMemoizer<void>();
}
