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