// Copyright 2021 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:async';
import 'dart:convert';
import 'dart:js_util';
import 'dart:typed_data';

import 'package:cli_pkg/js.dart';
import 'package:node_interop/js.dart';
import 'package:path/path.dart' as p;
import '../async_import_cache.dart';
import '../import_cache.dart';
import '../importer/node_package.dart';

import '../callable.dart';
import '../compile.dart';
import '../compile_result.dart';
import '../exception.dart';
import '../importer/legacy_node.dart';
import '../io.dart';
import '../logger.dart';
import '../logger/js_to_dart.dart';
import '../syntax.dart';
import '../util/nullable.dart';
import '../utils.dart';
import '../value.dart';
import '../visitor/serialize.dart';
import 'deprecations.dart';
import 'function.dart';
import 'legacy/render_context.dart';
import 'legacy/render_options.dart';
import 'legacy/render_result.dart';
import 'legacy/value.dart';
import 'utils.dart';

/// Converts Sass to CSS.
///
/// This attempts to match the [node-sass `render()` API][render] as closely as
/// possible.
///
/// [render]: https://github.com/sass/node-sass#options
void render(
  RenderOptions options,
  void callback(Object? error, RenderResult? result),
) {
  if (!isNodeJs) {
    jsThrow(JsError("The render() method is only available in Node.js."));
  }
  if (options.fiber case var fiber?) {
    fiber.call(
      allowInterop(() {
        try {
          callback(null, renderSync(options));
        } catch (error) {
          callback(error, null);
        }
        return null;
      }),
    ).run();
  } else {
    _renderAsync(options).then(
      (result) {
        callback(null, result);
      },
      onError: (Object error, StackTrace stackTrace) {
        if (error is SassException) {
          callback(_wrapException(error, stackTrace), null);
        } else {
          callback(
            _newRenderError(
              error.toString(),
              getTrace(error) ?? stackTrace,
              status: 3,
            ),
            null,
          );
        }
      },
    );
  }
}

/// Converts Sass to CSS asynchronously.
Future<RenderResult> _renderAsync(RenderOptions options) async {
  var start = DateTime.now();
  CompileResult result;

  var file = options.file.andThen(p.absolute);
  var logger = JSToDartLogger(
    options.logger,
    Logger.stderr(color: hasTerminal),
  );
  if (options.data case var data?) {
    result = await compileStringAsync(
      data,
      nodeImporter: _parseImporter(options, start),
      importCache: _parsePackageImportersAsync(options, start),
      functions: _parseFunctions(options, start, asynch: true),
      syntax: isTruthy(options.indentedSyntax) ? Syntax.sass : null,
      style: _parseOutputStyle(options.outputStyle),
      useSpaces: options.indentType != 'tab',
      indentWidth: _parseIndentWidth(options.indentWidth),
      lineFeed: _parseLineFeed(options.linefeed),
      url: file == null ? 'stdin' : p.toUri(file).toString(),
      quietDeps: options.quietDeps ?? false,
      fatalDeprecations: parseDeprecations(
        logger,
        options.fatalDeprecations,
        supportVersions: true,
      ),
      futureDeprecations: parseDeprecations(logger, options.futureDeprecations),
      silenceDeprecations: parseDeprecations(
        logger,
        options.silenceDeprecations,
      ),
      verbose: options.verbose ?? false,
      charset: options.charset ?? true,
      sourceMap: _enableSourceMaps(options),
      logger: logger,
    );
  } else if (file != null) {
    result = await compileAsync(
      file,
      nodeImporter: _parseImporter(options, start),
      importCache: _parsePackageImportersAsync(options, start),
      functions: _parseFunctions(options, start, asynch: true),
      syntax: isTruthy(options.indentedSyntax) ? Syntax.sass : null,
      style: _parseOutputStyle(options.outputStyle),
      useSpaces: options.indentType != 'tab',
      indentWidth: _parseIndentWidth(options.indentWidth),
      lineFeed: _parseLineFeed(options.linefeed),
      quietDeps: options.quietDeps ?? false,
      fatalDeprecations: parseDeprecations(
        logger,
        options.fatalDeprecations,
        supportVersions: true,
      ),
      futureDeprecations: parseDeprecations(logger, options.futureDeprecations),
      silenceDeprecations: parseDeprecations(
        logger,
        options.silenceDeprecations,
      ),
      verbose: options.verbose ?? false,
      charset: options.charset ?? true,
      sourceMap: _enableSourceMaps(options),
      logger: logger,
    );
  } else {
    throw ArgumentError("Either options.data or options.file must be set.");
  }

  return _newRenderResult(options, result, start);
}

/// Converts Sass to CSS.
///
/// This attempts to match the [node-sass `renderSync()` API][render] as closely
/// as possible.
///
/// [render]: https://github.com/sass/node-sass#options
RenderResult renderSync(RenderOptions options) {
  if (!isNodeJs) {
    jsThrow(JsError("The renderSync() method is only available in Node.js."));
  }
  try {
    var start = DateTime.now();
    CompileResult result;

    var file = options.file.andThen(p.absolute);
    var logger = JSToDartLogger(
      options.logger,
      Logger.stderr(color: hasTerminal),
    );
    if (options.data case var data?) {
      result = compileString(
        data,
        nodeImporter: _parseImporter(options, start),
        importCache: _parsePackageImporters(options, start),
        functions: _parseFunctions(options, start).cast(),
        syntax: isTruthy(options.indentedSyntax) ? Syntax.sass : null,
        style: _parseOutputStyle(options.outputStyle),
        useSpaces: options.indentType != 'tab',
        indentWidth: _parseIndentWidth(options.indentWidth),
        lineFeed: _parseLineFeed(options.linefeed),
        url: file == null ? 'stdin' : p.toUri(file).toString(),
        quietDeps: options.quietDeps ?? false,
        fatalDeprecations: parseDeprecations(
          logger,
          options.fatalDeprecations,
          supportVersions: true,
        ),
        futureDeprecations: parseDeprecations(
          logger,
          options.futureDeprecations,
        ),
        silenceDeprecations: parseDeprecations(
          logger,
          options.silenceDeprecations,
        ),
        verbose: options.verbose ?? false,
        charset: options.charset ?? true,
        sourceMap: _enableSourceMaps(options),
        logger: logger,
      );
    } else if (file != null) {
      result = compile(
        file,
        nodeImporter: _parseImporter(options, start),
        importCache: _parsePackageImporters(options, start),
        functions: _parseFunctions(options, start).cast(),
        syntax: isTruthy(options.indentedSyntax) ? Syntax.sass : null,
        style: _parseOutputStyle(options.outputStyle),
        useSpaces: options.indentType != 'tab',
        indentWidth: _parseIndentWidth(options.indentWidth),
        lineFeed: _parseLineFeed(options.linefeed),
        quietDeps: options.quietDeps ?? false,
        fatalDeprecations: parseDeprecations(
          logger,
          options.fatalDeprecations,
          supportVersions: true,
        ),
        futureDeprecations: parseDeprecations(
          logger,
          options.futureDeprecations,
        ),
        silenceDeprecations: parseDeprecations(
          logger,
          options.silenceDeprecations,
        ),
        verbose: options.verbose ?? false,
        charset: options.charset ?? true,
        sourceMap: _enableSourceMaps(options),
        logger: logger,
      );
    } else {
      throw ArgumentError("Either options.data or options.file must be set.");
    }

    return _newRenderResult(options, result, start);
  } on SassException catch (error, stackTrace) {
    jsThrow(_wrapException(error, stackTrace));
  } catch (error, stackTrace) {
    jsThrow(
      _newRenderError(
        error.toString(),
        getTrace(error) ?? stackTrace,
        status: 3,
      ),
    );
  }
}

/// Converts an exception to a [JsError].
JsError _wrapException(Object exception, StackTrace stackTrace) {
  if (exception is SassException) {
    var file = switch (exception.span.sourceUrl) {
      null => 'stdin',
      Uri(scheme: 'file') && var url => p.fromUri(url),
      var url => url.toString(),
    };

    return _newRenderError(
      exception.toString().replaceFirst("Error: ", ""),
      getTrace(exception) ?? stackTrace,
      line: exception.span.start.line + 1,
      column: exception.span.start.column + 1,
      file: file,
      status: 1,
    );
  } else {
    var error = JsError(exception.toString());
    attachJsStack(error, getTrace(exception) ?? stackTrace);
    return error;
  }
}

/// Parses `functions` from [RenderOptions] into a list of [Callable]s or
/// [AsyncCallable]s.
///
/// This is typed to always return [AsyncCallable], but in practice it will
/// return a `List<Callable>` if [asynch] is `false`.
List<AsyncCallable> _parseFunctions(
  RenderOptions options,
  DateTime start, {
  bool asynch = false,
}) {
  var functions = options.functions;
  if (functions == null) return const [];

  var result = <AsyncCallable>[];
  jsForEach(functions, (signature, callback) {
    var context = RenderContext(options: _contextOptions(options, start));
    context.options.context = context;

    if (options.fiber case var fiber?) {
      result.add(
        Callable.fromSignature(signature.trimLeft(), (arguments) {
          var currentFiber = fiber.current;
          var jsArguments = [
            ...arguments.map(wrapValue),
            allowInterop(([Object? result]) {
              // Schedule a microtask so we don't try to resume the running fiber
              // if [importer] calls `done()` synchronously.
              scheduleMicrotask(() => currentFiber.run(result));
            }),
          ];
          var result = wrapJSExceptions(
            () => (callback as JSFunction).apply(context, jsArguments),
          );
          return unwrapValue(
            isUndefined(result)
                // Run `fiber.yield()` in runZoned() so that Dart resets the current
                // zone once it's done. Otherwise, interweaving fibers can leave
                // `Zone.current` in an inconsistent state.
                ? runZoned(() => fiber.yield())
                : result,
          );
        }, requireParens: false),
      );
    } else if (!asynch) {
      result.add(
        Callable.fromSignature(
          signature.trimLeft(),
          (arguments) => unwrapValue(
            wrapJSExceptions(
              () => (callback as JSFunction).apply(
                context,
                arguments.map(wrapValue).toList(),
              ),
            ),
          ),
          requireParens: false,
        ),
      );
    } else {
      result.add(
        AsyncCallable.fromSignature(signature.trimLeft(), (arguments) async {
          var completer = Completer<Object?>();
          var jsArguments = [
            ...arguments.map(wrapValue),
            allowInterop(([Object? result]) => completer.complete(result)),
          ];
          var result = wrapJSExceptions(
            () => (callback as JSFunction).apply(context, jsArguments),
          );
          return unwrapValue(
            isUndefined(result) ? await completer.future : result,
          );
        }, requireParens: false),
      );
    }
  });
  return result;
}

/// Parses [importer] and [includePaths] from [RenderOptions] into a
/// [NodeImporter].
NodeImporter _parseImporter(RenderOptions options, DateTime start) {
  var importers = switch (options.importer) {
    null => <JSFunction>[],
    List<Object?> importers => importers.cast<JSFunction>(),
    var importer => [importer as JSFunction],
  };

  var contextOptions =
      importers.isNotEmpty ? _contextOptions(options, start) : Object();

  if (options.fiber case var fiber?) {
    importers = importers.map((importer) {
      return allowInteropCaptureThis((
        Object thisArg,
        String url,
        String previous, [
        Object? _,
      ]) {
        var currentFiber = fiber.current;
        var result = call3(
          importer,
          thisArg,
          url,
          previous,
          allowInterop((Object result) {
            // Schedule a microtask so we don't try to resume the running fiber if
            // [importer] calls `done()` synchronously.
            scheduleMicrotask(() => currentFiber.run(result));
          }),
        );

        // Run `fiber.yield()` in runZoned() so that Dart resets the current
        // zone once it's done. Otherwise, interweaving fibers can leave
        // `Zone.current` in an inconsistent state.
        if (isUndefined(result)) return runZoned(() => fiber.yield());
        return result;
      }) as JSFunction;
    }).toList();
  }

  var includePaths = List<String>.from(options.includePaths ?? []);
  return NodeImporter(contextOptions, includePaths, importers);
}

/// Creates an [AsyncImportCache] for Package Importers.
AsyncImportCache? _parsePackageImportersAsync(
  RenderOptions options,
  DateTime start,
) {
  if (options.pkgImporter is NodePackageImporter) {
    return AsyncImportCache.only([options.pkgImporter!]);
  }
  return null;
}

/// Creates an [ImportCache] for Package Importers.
ImportCache? _parsePackageImporters(RenderOptions options, DateTime start) {
  if (options.pkgImporter is NodePackageImporter) {
    return ImportCache.only([options.pkgImporter!]);
  }
  return null;
}

/// Creates the [RenderContextOptions] for the `this` context in which custom
/// functions and importers will be evaluated.
RenderContextOptions _contextOptions(RenderOptions options, DateTime start) {
  var includePaths = List<String>.from(options.includePaths ?? []);
  return RenderContextOptions(
    file: options.file,
    data: options.data,
    includePaths: ([p.current, ...includePaths]).join(isWindows ? ';' : ':'),
    precision: SassNumber.precision,
    style: 1,
    indentType: options.indentType == 'tab' ? 1 : 0,
    indentWidth: _parseIndentWidth(options.indentWidth) ?? 2,
    linefeed: _parseLineFeed(options.linefeed).text,
    result: RenderContextResult(
      stats: RenderContextResultStats(
        start: start.millisecondsSinceEpoch,
        entry: options.file ?? 'data',
      ),
    ),
  );
}

/// Parse [style] into an [OutputStyle].
OutputStyle _parseOutputStyle(String? style) => switch (style) {
      null || 'expanded' => OutputStyle.expanded,
      'compressed' => OutputStyle.compressed,
      _ => jsThrow(JsError('Unknown output style "$style".')),
    };

/// Parses the indentation width into an [int].
int? _parseIndentWidth(Object? width) => switch (width) {
      null => null,
      int() => width,
      _ => int.parse(width.toString()),
    };

/// Parses the name of a line feed type into a [LineFeed].
LineFeed _parseLineFeed(String? str) => switch (str) {
      'cr' => LineFeed.cr,
      'crlf' => LineFeed.crlf,
      'lfcr' => LineFeed.lfcr,
      _ => LineFeed.lf,
    };

/// Creates a [RenderResult] that exposes [result] in the Node Sass API format.
RenderResult _newRenderResult(
  RenderOptions options,
  CompileResult result,
  DateTime start,
) {
  var end = DateTime.now();

  var css = result.css;
  Uint8List? sourceMapBytes = undefined;
  if (_enableSourceMaps(options)) {
    var sourceMapOption = options.sourceMap;
    var sourceMapPath =
        sourceMapOption is String ? sourceMapOption : options.outFile! + '.map';
    var sourceMapDir = p.dirname(sourceMapPath);

    var sourceMap = result.sourceMap!;
    sourceMap.sourceRoot = options.sourceMapRoot;
    var outFile = options.outFile;
    if (outFile == null) {
      sourceMap.targetUrl = switch (options.file) {
        var file? => p.toUri(p.setExtension(file, '.css')).toString(),
        _ => sourceMap.targetUrl = 'stdin.css',
      };
    } else {
      sourceMap.targetUrl =
          p.toUri(p.relative(outFile, from: sourceMapDir)).toString();
    }

    var sourceMapDirUrl = p.toUri(sourceMapDir).toString();
    for (var i = 0; i < sourceMap.urls.length; i++) {
      var source = sourceMap.urls[i];
      if (source == "stdin") continue;

      // URLs handled by Node importers that directly return file contents are
      // preserved in their original (usually relative) form. They may or may
      // not be intended as `file:` URLs, but there's nothing we can do about it
      // either way so we keep them as-is.
      if (p.url.isRelative(source) || p.url.isRootRelative(source)) continue;
      sourceMap.urls[i] = p.url.relative(source, from: sourceMapDirUrl);
    }

    var json = sourceMap.toJson(
      includeSourceContents: isTruthy(options.sourceMapContents),
    );
    sourceMapBytes = utf8Encode(jsonEncode(json));

    if (!isTruthy(options.omitSourceMapUrl)) {
      var url = isTruthy(options.sourceMapEmbed)
          ? Uri.dataFromBytes(sourceMapBytes, mimeType: "application/json")
          : p.toUri(
              outFile == null
                  ? sourceMapPath
                  : p.relative(sourceMapPath, from: p.dirname(outFile)),
            );
      var escapedUrl = url.toString().replaceAll("*/", '%2A/');
      css += "\n\n/*# sourceMappingURL=$escapedUrl */";
    }
  }

  return RenderResult(
    css: utf8Encode(css),
    map: sourceMapBytes,
    stats: RenderResultStats(
      entry: options.file ?? 'data',
      start: start.millisecondsSinceEpoch,
      end: end.millisecondsSinceEpoch,
      duration: end.difference(start).inMilliseconds,
      includedFiles: [
        for (var url in result.loadedUrls)
          url.scheme == 'file' ? p.fromUri(url) : url.toString(),
      ],
    ),
  );
}

/// Returns whether source maps are enabled by [options].
bool _enableSourceMaps(RenderOptions options) =>
    options.sourceMap is String ||
    (isTruthy(options.sourceMap) && options.outFile != null);

/// Creates a [JsError] with the given fields added to it so it acts like a Node
/// Sass error.
JsError _newRenderError(
  String message,
  StackTrace stackTrace, {
  int? line,
  int? column,
  String? file,
  int? status,
}) {
  var error = JsError(message);
  setProperty(error, 'formatted', 'Error: $message');
  if (line != null) setProperty(error, 'line', line);
  if (column != null) setProperty(error, 'column', column);
  if (file != null) setProperty(error, 'file', file);
  if (status != null) setProperty(error, 'status', status);
  attachJsStack(error, stackTrace);
  return error;
}
