// 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:collection';
import 'dart:convert';
import 'dart:js_interop';

import 'package:dwds/src/utilities/shared.dart';
import 'package:graphs/graphs.dart' as graphs;
import 'package:http/browser_client.dart';
import 'package:web/web.dart';

import '../run_main.dart';
import '../web_utils.dart';
import 'restarter.dart';

@JS(r'$dartRunMain')
external set dartRunMain(JSFunction func);

@JS(r'$dartRunMain')
external JSFunction get dartRunMain;

@JS(r'$requireLoader')
external RequireLoader get requireLoader;

@anonymous
@JS()
@staticInterop
class RequireLoader {}

extension RequireLoaderExtension on RequireLoader {
  external String get digestsPath;

  external JsMap<JSString, JSArray> get moduleParentsGraph;

  external void forceLoadModule(
    JSString moduleId,
    JSFunction callback,
    JSFunction onError,
  );
}

@anonymous
@JS()
@staticInterop
class Sdk {}

@anonymous
@JS()
@staticInterop
class SdkDeveloper {}

@anonymous
@JS()
@staticInterop
class SdkDart {}

@anonymous
@JS()
@staticInterop
class SdkExt {}

@anonymous
@JS()
@staticInterop
class MainLibrary {}

@JS(r'$loadModuleConfig')
external Sdk require(String value);

Sdk get sdk => require('dart_sdk');

extension SdkExtension on Sdk {
  external SdkDart get dart;
  external SdkDeveloper get developer;
}

extension SdkDeveloperExtension on SdkDeveloper {
  external JSPromise<JSString> invokeExtension(String key, String params);
  external SdkExt get _extensions;

  Future<void> maybeInvokeFlutterDisassemble() async {
    final method = 'ext.flutter.disassemble';
    if (_extensions.containsKey(method)) {
      await invokeExtension(method, '{}').toDart;
    }
  }
}

extension SdkDartExtension on SdkDart {
  external void hotRestart();
  external JSObject getModuleLibraries(String? moduleId);

  MainLibrary getMainLibrary(String? moduleId) {
    final libraries = getModuleLibraries(moduleId).values;
    return libraries.first! as MainLibrary;
  }
}

extension SdkExtExtension on SdkExt {
  external bool containsKey(String key);
}

extension MainLibraryExtension on MainLibrary {
  external void main();
}

class HotReloadFailedException implements Exception {
  final String _s;

  HotReloadFailedException(this._s);
  @override
  String toString() => "HotReloadFailedException: '$_s'";
}

/// Handles hot restart reloading for use with the require module system.
class RequireRestarter implements Restarter {
  /// The last known digests of all the modules in the application.
  ///
  /// This is updated in place during calls to hotRestart.
  static late Map<String, String> _lastKnownDigests;

  final _moduleOrdering = HashMap<String, int>();
  late SplayTreeSet<String> _dirtyModules;
  var _running = Completer<bool>()..complete(true);

  var count = 0;

  RequireRestarter._() {
    _dirtyModules = SplayTreeSet(_moduleTopologicalCompare);
  }

  @override
  Future<(bool, JSArray<JSObject>?)> restart({
    String? runId,
    Future? readyToRunMain,
    String? reloadedSourcesPath,
  }) async {
    assert(
      reloadedSourcesPath == null,
      "'reloadedSourcesPath' should not be used for the AMD module format.",
    );
    await sdk.developer.maybeInvokeFlutterDisassemble();

    final newDigests = await _getDigests();
    final modulesToLoad = <String>[];
    for (final moduleId in newDigests.keys) {
      if (!_lastKnownDigests.containsKey(moduleId)) {
        print(
          'Error during script reloading, refreshing the page. \n'
          'Unable to find an existing digest for module: $moduleId.',
        );
        _reloadPage();
      } else if (_lastKnownDigests[moduleId] != newDigests[moduleId]) {
        _lastKnownDigests[moduleId] = newDigests[moduleId]!;
        modulesToLoad.add(moduleId);
      }
    }

    var result = true;
    if (modulesToLoad.isNotEmpty) {
      _updateGraph();
      result = await _reload(modulesToLoad);
    }
    sdk.dart.hotRestart();
    safeUnawaited(_runMainWhenReady(readyToRunMain));
    return (result, null);
  }

  @override
  Future<void> hotReloadEnd() =>
      throw UnimplementedError(
        'Hot reload is not supported for the AMD module format.',
      );

  @override
  Future<JSArray<JSObject>> hotReloadStart(String reloadedSourcesPath) =>
      throw UnimplementedError(
        'Hot reload is not supported for the AMD module format.',
      );

  Future<void> _runMainWhenReady(Future? readyToRunMain) async {
    if (readyToRunMain != null) {
      await readyToRunMain;
    }
    runMain();
  }

  Iterable<String> _allModules() => requireLoader.moduleParentsGraph.modules;

  Future<Map<String, String>> _getDigests() async {
    final client = BrowserClient();
    final response = await client.get(Uri.parse(requireLoader.digestsPath));
    return (jsonDecode(response.body) as Map).cast<String, String>();
  }

  Future<void> _initialize() async {
    _lastKnownDigests = await _getDigests();
  }

  List<String> _moduleParents(String module) =>
      requireLoader.moduleParentsGraph.parents(module);

  int _moduleTopologicalCompare(String module1, String module2) {
    var topological = 0;

    final order1 = _moduleOrdering[module1];
    final order2 = _moduleOrdering[module2];

    if (order1 == null || order2 == null) {
      final missing = order1 == null ? module1 : module2;
      throw HotReloadFailedException(
        'Unable to fetch ordering info for module: $missing',
      );
    }

    topological = Comparable.compare(
      _moduleOrdering[module2]!,
      _moduleOrdering[module1]!,
    );

    if (topological == 0) {
      // If modules are in cycle (same strongly connected component) compare
      // their string id, to ensure total ordering for SplayTreeSet uniqueness.
      topological = module1.compareTo(module2);
    }

    return topological;
  }

  /// Returns `true` if the reload was fully handled, `false` if it failed
  /// explicitly, or `null` for an unhandled reload.
  Future<bool> _reload(List<String> modules) async {
    final dart = sdk.dart;

    // As function is async, it can potentially be called second time while
    // first invocation is still running. In this case just mark as dirty and
    // wait until loop from the first call will do the work
    if (!_running.isCompleted) return await _running.future;
    _running = Completer();

    var reloadedModules = 0;
    try {
      _dirtyModules.addAll(modules);
      String? previousModuleId;
      while (_dirtyModules.isNotEmpty) {
        final moduleId = _dirtyModules.first;
        _dirtyModules.remove(moduleId);
        final parentIds = _moduleParents(moduleId);
        // Check if this is the root / bootstrap module.
        if (parentIds.isEmpty) {
          // The bootstrap module is not reloaded but we need to update the
          // $dartRunMain reference to the newly loaded child module.
          // ignore: unnecessary_lambdas
          dartRunMain =
              () {
                dart.getMainLibrary(previousModuleId).main();
              }.toJS;
        } else {
          ++reloadedModules;
          await _reloadModule(moduleId);
          parentIds.sort(_moduleTopologicalCompare);
          _dirtyModules.addAll(parentIds);
          previousModuleId = moduleId;
        }
      }
      print('$reloadedModules module(s) were hot-reloaded.');
      _running.complete(true);
    } on HotReloadFailedException catch (e) {
      print('Error during script reloading. Firing full page reload. $e');
      _reloadPage();
      _running.complete(false);
    }
    return _running.future;
  }

  Future<void> _reloadModule(String moduleId) {
    final completer = Completer();
    final stackTrace = StackTrace.current;
    requireLoader.forceLoadModule(
      moduleId.toJS,
      // Removing the argument type in complete()
      // ignore: unnecessary_lambdas
      () {
        completer.complete();
      }.toJS,
      (JsError e) {
        completer.completeError(
          HotReloadFailedException(e.message),
          stackTrace,
        );
      }.toJS,
    );
    return completer.future;
  }

  void _reloadPage() {
    window.location.reload();
  }

  void _updateGraph() {
    final allModules = _allModules();

    final stronglyConnectedComponents = graphs.stronglyConnectedComponents(
      allModules,
      _moduleParents,
    );
    _moduleOrdering.clear();
    for (var i = 0; i < stronglyConnectedComponents.length; i++) {
      for (final module in stronglyConnectedComponents[i]) {
        _moduleOrdering[module] = i;
      }
    }
  }

  static Future<RequireRestarter> create() async {
    final reloader = RequireRestarter._();
    await reloader._initialize();
    return reloader;
  }
}
