// Copyright 2016 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:convert';

import 'package:path/path.dart' as p;
import 'package:source_maps/source_maps.dart';
import 'package:stack_trace/stack_trace.dart';

import '../async_import_cache.dart';
import '../compile.dart';
import '../compile_result.dart';
import '../exception.dart';
import '../importer/filesystem.dart';
import '../io.dart';
import '../stylesheet_graph.dart';
import '../syntax.dart';
import '../utils.dart';
import '../visitor/serialize.dart';
import 'options.dart';

/// Compiles the stylesheet at [source] to [destination].
///
/// Loads files using `graph.importCache` when possible.
///
/// If [source] is `null`, that indicates that the stylesheet should be read
/// from stdin. If [destination] is `null`, that indicates that the stylesheet
/// should be emitted to stdout.
///
/// If [ifModified] is `true`, only recompiles if [source]'s modification time
/// or that of a file it imports is more recent than [destination]'s
/// modification time. Note that these modification times are cached by [graph].
///
/// Returns `(exitCode, error, stackTrace)` when an error occurs.
Future<(int, String, String?)?> compileStylesheet(
  ExecutableOptions options,
  StylesheetGraph graph,
  String? source,
  String? destination, {
  bool ifModified = false,
}) async {
  try {
    await _compileStylesheetWithoutErrorHandling(
      options,
      graph,
      source,
      destination,
      ifModified: ifModified,
    );
  } on SassException catch (error, stackTrace) {
    if (destination != null && !options.emitErrorCss) {
      _tryDelete(destination);
    }
    var message = error.toString(color: options.color);

    // Exit code 65 indicates invalid data per
    // https://www.freebsd.org/cgi/man.cgi?query=sysexits.
    return _getErrorWithStackTrace(
      65,
      message,
      options.trace ? getTrace(error) ?? stackTrace : null,
    );
  } on FileSystemException catch (error, stackTrace) {
    var path = error.path;
    var message = path == null
        ? error.message
        : "Error reading ${p.relative(path)}: ${error.message}.";

    // Exit code 66 indicates no input.
    return _getErrorWithStackTrace(
      66,
      message,
      options.trace ? getTrace(error) ?? stackTrace : null,
    );
  }
  return null;
}

/// Like [compileStylesheet], but throws errors instead of handling them
/// internally.
Future<void> _compileStylesheetWithoutErrorHandling(
  ExecutableOptions options,
  StylesheetGraph graph,
  String? source,
  String? destination, {
  bool ifModified = false,
}) async {
  var importer = FilesystemImporter.cwd;
  if (ifModified) {
    try {
      if (source != null &&
          destination != null &&
          !graph.modifiedSince(
            p.toUri(p.absolute(source)),
            modificationTime(destination),
            importer,
          )) {
        return;
      }
    } on FileSystemException catch (_) {
      // Compile as normal if the destination file doesn't exist.
    }
  }

  Syntax syntax;
  if (options.indented == true) {
    syntax = Syntax.sass;
  } else if (source != null) {
    syntax = Syntax.forPath(source);
  } else {
    syntax = Syntax.scss;
  }

  CompileResult result;
  try {
    if (options.asynchronous) {
      var importCache = AsyncImportCache(
        importers: options.pkgImporters,
        loadPaths: options.loadPaths,
      );

      result = source == null
          ? await compileStringAsync(
              await readStdin(),
              syntax: syntax,
              logger: options.logger,
              importCache: importCache,
              importer: FilesystemImporter.cwd,
              style: options.style,
              quietDeps: options.quietDeps,
              verbose: options.verbose,
              sourceMap: options.emitSourceMap,
              charset: options.charset,
              silenceDeprecations: options.silenceDeprecations,
              fatalDeprecations: options.fatalDeprecations,
              futureDeprecations: options.futureDeprecations,
            )
          : await compileAsync(
              source,
              syntax: syntax,
              logger: options.logger,
              importCache: importCache,
              style: options.style,
              quietDeps: options.quietDeps,
              verbose: options.verbose,
              sourceMap: options.emitSourceMap,
              charset: options.charset,
              silenceDeprecations: options.silenceDeprecations,
              fatalDeprecations: options.fatalDeprecations,
              futureDeprecations: options.futureDeprecations,
            );
    } else {
      // Double-check that all modified files (according to mtime) are actually
      // reloaded in the graph so we don't end up with stale ASTs.
      graph.reloadAllModified();

      result = source == null
          ? compileString(
              await readStdin(),
              syntax: syntax,
              logger: options.logger,
              importCache: graph.importCache,
              importer: FilesystemImporter.cwd,
              style: options.style,
              quietDeps: options.quietDeps,
              verbose: options.verbose,
              sourceMap: options.emitSourceMap,
              charset: options.charset,
              silenceDeprecations: options.silenceDeprecations,
              fatalDeprecations: options.fatalDeprecations,
              futureDeprecations: options.futureDeprecations,
            )
          : compile(
              source,
              syntax: syntax,
              logger: options.logger,
              importCache: graph.importCache,
              style: options.style,
              quietDeps: options.quietDeps,
              verbose: options.verbose,
              sourceMap: options.emitSourceMap,
              charset: options.charset,
              silenceDeprecations: options.silenceDeprecations,
              fatalDeprecations: options.fatalDeprecations,
              futureDeprecations: options.futureDeprecations,
            );
    }
  } on SassException catch (error) {
    if (options.emitErrorCss) {
      if (destination == null) {
        print(error.toCssString());
      } else {
        ensureDir(p.dirname(destination));
        writeFile(destination, error.toCssString() + "\n");
      }
    }
    rethrow;
  }

  var css = result.css;
  css += _writeSourceMap(options, result.sourceMap, destination);
  if (destination == null) {
    if (css.isNotEmpty) print(css);
  } else {
    ensureDir(p.dirname(destination));
    writeFile(destination, css + "\n");
  }

  if (options.quiet || (!options.update && !options.watch)) return;
  var buffer = StringBuffer();

  var sourceName = source == null ? 'stdin' : p.prettyUri(p.toUri(source));
  // `destination` is guaranteed to be non-null in update and watch mode.
  var destinationName = p.prettyUri(p.toUri(destination!));

  var nowStr = DateTime.now().toString();
  // Remove fractional seconds from printed timestamp
  var timestamp = nowStr.substring(0, nowStr.length - 7);

  if (options.color) buffer.write('\u001b[90m');
  buffer.write('[$timestamp] ');
  if (options.color) buffer.write('\u001b[32m');
  buffer.write('Compiled $sourceName to $destinationName.');
  if (options.color) buffer.write('\u001b[0m');

  safePrint(buffer);
}

/// Writes the source map given by [mapping] to disk (if necessary) according to
/// [options].
///
/// The [destination] is the path where the CSS file associated with this source
/// map will be written. If it's `null`, that indicates that the CSS will be
/// printed to stdout.
///
/// Returns the source map comment to add to the end of the CSS file.
String _writeSourceMap(
  ExecutableOptions options,
  SingleMapping? sourceMap,
  String? destination,
) {
  if (sourceMap == null) return "";

  if (destination != null) {
    sourceMap.targetUrl = p.toUri(p.basename(destination)).toString();
  }

  // TODO(nweiz): Don't explicitly use a type parameter when dart-lang/sdk#25490
  // is fixed.
  mapInPlace<String>(
    sourceMap.urls,
    (url) => options.sourceMapUrl(Uri.parse(url), destination).toString(),
  );
  var sourceMapText = jsonEncode(
    sourceMap.toJson(includeSourceContents: options.embedSources),
  );

  Uri url;
  if (options.embedSourceMap) {
    url = Uri.dataFromString(
      sourceMapText,
      mimeType: 'application/json',
      encoding: utf8,
    );
  } else {
    // [destination] can't be null here because --embed-source-map is
    // incompatible with writing to stdout.
    var sourceMapPath = destination! + '.map';
    ensureDir(p.dirname(sourceMapPath));
    writeFile(sourceMapPath, sourceMapText);

    url = p.toUri(p.relative(sourceMapPath, from: p.dirname(destination)));
  }

  var escapedUrl = url.toString().replaceAll("*/", '%2A/');

  return (options.style == OutputStyle.compressed ? '' : '\n\n') +
      '/*# sourceMappingURL=$escapedUrl */';
}

/// Delete [path] if it exists and do nothing otherwise.
///
/// This is a separate function to work around dart-lang/sdk#53082.
void _tryDelete(String path) {
  try {
    deleteFile(path);
  } on FileSystemException {
    // If the file doesn't exist, that's fine.
  }
}

/// Return a Record of `(exitCode, error, stackTrace)` for the given error.
(int, String, String?) _getErrorWithStackTrace(
  int exitCode,
  String error,
  StackTrace? stackTrace,
) {
  return (
    exitCode,
    error,
    stackTrace != null
        ? Trace.from(stackTrace).terse.toString().trimRight()
        : null,
  );
}
