// Copyright 2017 Google Inc. Use of this source code is governed by an
// MIT-style license that can be found in the LICENSE file or at
// https://opensource.org/licenses/MIT.

import 'dart:js_util';
import 'dart:typed_data';

import 'package:node_interop/node.dart' hide module;
import 'package:js/js.dart';
import 'package:js/js_util.dart';

import '../syntax.dart';
import '../utils.dart';
import '../util/map.dart';
import '../value.dart';
import 'array.dart';
import 'function.dart';
import 'module.dart';
import 'reflection.dart';
import 'url.dart';

/// Throws [error] like JS would, without any Dart wrappers.
Never jsThrow(Object error) => _jsThrow.call(error) as Never;

final _jsThrow = JSFunction("error", "throw error;");

/// Returns whether or not [value] is the JS `undefined` value.
bool isUndefined(Object? value) => _isUndefined.call(value) as bool;

final _isUndefined = JSFunction("value", "return value === undefined;");

/// Returns whether or not [value] is the JS `null` value.
bool isNull(Object? value) => _isNull.call(value) as bool;

final _isNull = JSFunction("value", "return value === null;");

@JS("Error")
external JSClass get jsErrorClass;

/// Returns whether [value] is a JS Error object.
bool isJSError(Object value) => instanceof(value, jsErrorClass);

/// Attaches [trace] to [error] as its stack trace.
void attachJsStack(JsError error, StackTrace trace) {
  // Stack traces in v8 contain the error message itself as well as the stack
  // information, so we trim that out if it exists so we don't double-print it.
  var traceString = trace.toString();
  var firstRealLine = traceString.indexOf('\n    at');
  if (firstRealLine != -1) {
    // +1 to account for the newline before the first line.
    traceString = traceString.substring(firstRealLine + 1);
  }

  setProperty(error, 'stack', "Error: ${error.message}\n$traceString");
}

/// Invokes [function] with [thisArg] as `this`.
Object? call2(JSFunction function, Object thisArg, Object arg1, Object arg2) =>
    function.apply(thisArg, [arg1, arg2]);

/// Invokes [function] with [thisArg] as `this`.
Object? call3(
  JSFunction function,
  Object thisArg,
  Object arg1,
  Object arg2,
  Object arg3,
) =>
    function.apply(thisArg, [arg1, arg2, arg3]);

@JS("Object.keys")
external List<String> _keys(Object? object);

/// Invokes [callback] for each key/value pair in [object].
void jsForEach(Object object, void callback(String key, Object? value)) {
  for (var key in _keys(object)) {
    callback(key, getProperty(object, key));
  }
}

/// Evaluates [js] in a function context.
///
/// If [js] includes a `return` statement, returns that result. Otherwise
/// returns `null`.
Object? jsEval(String js) => JSFunction('', js).call();

/// Returns whether the [object] is a JS `string`.
bool isJsString(Object? object) => _jsTypeOf(object) == 'string';

/// Returns the [object]'s `typeof` according to the JS engine.
String _jsTypeOf(Object? object) =>
    JSFunction("value", "return typeof value").call(object) as String;

/// Returns `typeof value` if [value] is a native type, otherwise returns the
/// [value]'s JS class name.
String jsType(Object? value) {
  var typeOf = _jsTypeOf(value);
  return typeOf != 'object' ? typeOf : JSFunction('value', '''
    if (value && value.constructor && value.constructor.name) {
      return value.constructor.name;
    }
    return "object";
  ''').call(value) as String;
}

@JS("Object.defineProperty")
external void _defineProperty(
  Object object,
  String name,
  _PropertyDescriptor prototype,
);

@JS()
@anonymous
class _PropertyDescriptor {
  external Object get value;
  external Function get get;
  external bool get enumerable;

  external factory _PropertyDescriptor({
    Object? value,
    Function? get,
    bool? enumerable,
  });
}

/// Defines a JS getter on [object] named [name].
///
/// If [get] is passed, the getter invokes it with a `self` argument. Otherwise,
/// the getter just returns [value].
void defineGetter(Object object, String name, {Object? value, Function? get}) {
  _defineProperty(
    object,
    name,
    get == null
        ? _PropertyDescriptor(value: value, enumerable: false)
        : _PropertyDescriptor(
            get: allowInteropCaptureThis(get),
            enumerable: false,
          ),
  );
}

/// Like [allowInterop], but gives the function a [name] so it's more ergonomic
/// when debugging.
T allowInteropNamed<T extends Function>(String name, T function) {
  function = allowInterop(function);
  defineGetter(function, 'name', value: name);
  _hideDartProperties(function);
  return function;
}

/// Like [allowInteropCaptureThis], but gives the function a [name] so it's more
/// ergonomic when debugging.
Function allowInteropCaptureThisNamed(String name, Function function) {
  function = allowInteropCaptureThis(function);
  defineGetter(function, 'name', value: name);
  _hideDartProperties(function);
  return function;
}

@JS("Object.getOwnPropertyNames")
external List<Object?> _getOwnPropertyNames(Object object);

/// Hide Dart-internal properties on [object].
///
/// Dart sometimes adds weird properties to objects that show up in
/// `utils.inspect()` or `console.log()`. This hides them by marking them as
/// non-enumerable.
void _hideDartProperties(Object object) {
  for (var name in _getOwnPropertyNames(object).cast<String>()) {
    if (name.startsWith('_')) {
      defineGetter(object, name, value: getProperty(object, name));
    }
  }
}

/// Returns whether [value] is truthy according to JavaScript.
bool isTruthy(Object? value) => value != false && value != null;

@JS('Promise')
external JSClass get _promiseClass;

/// Returns whether [object] is a `Promise`.
bool isPromise(Object? object) =>
    object != null && instanceof(object, _promiseClass);

/// Like [futureToPromise] from `node_interop`, but stores the stack trace for
/// errors using [throwWithTrace].
Promise futureToPromise(Future<Object?> future) => Promise(
      allowInterop(
          (void Function(Object?) resolve, void Function(Object?) reject) {
        future.then(
          (result) => resolve(result),
          onError: (Object error, StackTrace stackTrace) {
            attachTrace(error, stackTrace);
            reject(error);
          },
        );
      }),
    );

@JS('URL')
external JSClass get _urlClass;

/// Returns whether [object] is a JavaScript `URL`.
bool isJSUrl(Object? object) => object != null && instanceof(object, _urlClass);

@JS('Buffer.from')
external Uint8List _buffer(String text, String encoding);

/// Encodes [text] as a UTF-8 byte buffer.
///
/// We could do this using Dart's native UTF-8 support, but it's much less
/// efficient in Node.
Uint8List utf8Encode(String text) => _buffer(text, 'utf8');

/// Converts a standard JS `URL` object to a Dart [Uri] object.
Uri jsToDartUrl(JSUrl url) => Uri.parse(url.toString());

/// Converts a Dart [Uri] object to a standard JS `URL` object.
JSUrl dartToJSUrl(Uri url) => JSUrl(url.toString());

/// Creates a JavaScript array containing [iterable].
///
/// While Dart arrays are notionally compatible with JS arrays, they still have
/// some non-enumerable properties that can cause problems (for example, they
/// don't compare as "equal" for Jest's matchers) so it's preferable to use this
/// when exposing them.
JSArray toJSArray(Iterable<Object?> iterable) {
  var array = JSArray();
  for (var element in iterable) {
    array.push(element);
  }
  return array;
}

/// Converts a JavaScript record into a map from property names to their values.
Map<String, Object?> objectToMap(Object object) {
  var map = <String, Object?>{};
  jsForEach(object, (key, value) => map[key] = value);
  return map;
}

@JS("Object")
external JSClass get _jsObjectClass;

/// Converts a JavaScript record into a map from property names to their values.
Object mapToObject(Map<String, Object?> map) {
  var result = callConstructor<Object>(_jsObjectClass, const []);
  for (var (key, value) in map.pairs) {
    setProperty(result, key, value);
  }
  return result;
}

/// Converts a JavaScript separator string into a [ListSeparator].
ListSeparator jsToDartSeparator(String? separator) => switch (separator) {
      ' ' => ListSeparator.space,
      ',' => ListSeparator.comma,
      '/' => ListSeparator.slash,
      null => ListSeparator.undecided,
      _ => jsThrow(JsError('Unknown separator "$separator".')),
    };

/// Converts a syntax string to an instance of [Syntax].
Syntax parseSyntax(String? syntax) => switch (syntax) {
      null || 'scss' => Syntax.scss,
      'indented' => Syntax.sass,
      'css' => Syntax.css,
      _ => jsThrow(JsError('Unknown syntax "$syntax".')),
    };

/// The path to the Node.js entrypoint, if one can be located.
String? get entrypointFilename {
  if (_requireMain?.filename case var filename?) {
    return filename;
  } else if (process.argv case [_, String path, ...]) {
    return module.createRequire(path).resolve(path);
  } else {
    return null;
  }
}

@JS("require.main")
external _RequireMain? get _requireMain;

@JS()
@anonymous
class _RequireMain {
  external String? get filename;
}
