// Copyright 2018 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 'package:args/args.dart';
import 'package:charcode/charcode.dart';
import 'package:collection/collection.dart';
import 'package:path/path.dart' as p;
import 'package:pub_semver/pub_semver.dart';
import 'package:term_glyph/term_glyph.dart' as term_glyph;

import '../../sass.dart';
import '../importer/node_package.dart';
import '../io.dart';
import '../util/character.dart';

/// The parsed and processed command-line options for the Sass executable.
///
/// The constructor and any members may throw [UsageException]s indicating that
/// invalid arguments were passed.
final class ExecutableOptions {
  /// The bar character to use in help separators.
  static final _separatorBar = isWindows ? '=' : '━';

  /// The total length of help separators, including text.
  static final _separatorLength = 40;

  /// The parser that defines the arguments the executable allows.
  static final ArgParser _parser = () {
    var parser = ArgParser(allowTrailingOptions: true)
      // This is used for compatibility with sass-spec, even though we don't
      // support setting the precision.
      ..addOption('precision', hide: true)
      // This is used when testing to ensure that the asynchronous evaluator path
      // works the same as the synchronous one.
      ..addFlag('async', hide: true);

    parser
      ..addSeparator(_separator('Input and Output'))
      ..addFlag('stdin', help: 'Read the stylesheet from stdin.')
      ..addFlag(
        'indented',
        help: 'Use the indented syntax for input from stdin.',
      )
      ..addMultiOption(
        'load-path',
        abbr: 'I',
        valueHelp: 'PATH',
        help: 'A path to use when resolving imports.\n'
            'May be passed multiple times.',
        splitCommas: false,
      )
      ..addMultiOption(
        'pkg-importer',
        abbr: 'p',
        valueHelp: 'TYPE',
        allowed: ['node'],
        help: 'Built-in importer(s) to use for pkg: URLs.',
        allowedHelp: {'node': 'Load files like Node.js package resolution.'},
      )
      ..addOption(
        'style',
        abbr: 's',
        valueHelp: 'NAME',
        help: 'Output style.',
        allowed: ['expanded', 'compressed'],
        defaultsTo: 'expanded',
      )
      ..addFlag(
        'charset',
        help: 'Emit a @charset or BOM for CSS with non-ASCII characters.',
        defaultsTo: true,
      )
      ..addFlag(
        'error-css',
        help: 'When an error occurs, emit a stylesheet describing it.\n'
            'Defaults to true when compiling to a file.',
        defaultsTo: null,
      )
      ..addFlag(
        'update',
        help: 'Only compile out-of-date stylesheets.',
        negatable: false,
      );

    parser
      ..addSeparator(_separator('Source Maps'))
      ..addFlag(
        'source-map',
        help: 'Whether to generate source maps.',
        defaultsTo: true,
      )
      ..addOption(
        'source-map-urls',
        defaultsTo: 'relative',
        help: 'How to link from source maps to source files.',
        allowed: ['relative', 'absolute'],
      )
      ..addFlag(
        'embed-sources',
        help: 'Embed source file contents in source maps.',
        defaultsTo: false,
      )
      ..addFlag(
        'embed-source-map',
        help: 'Embed source map contents in CSS.',
        defaultsTo: false,
      );

    parser
      ..addSeparator(_separator('Warnings'))
      ..addFlag('quiet', abbr: 'q', help: "Don't print warnings.")
      ..addFlag(
        'quiet-deps',
        help: "Don't print compiler warnings from dependencies.\n"
            "Stylesheets imported through load paths count as dependencies.",
      )
      ..addFlag(
        'verbose',
        help: "Print all deprecation warnings even when they're repetitive.",
      )
      ..addMultiOption(
        'fatal-deprecation',
        help: 'Deprecations to treat as errors. You may also pass a Sass\n'
            'version to include any behavior deprecated in or before it.\n'
            'See https://sass-lang.com/documentation/breaking-changes for \n'
            'a complete list.',
      )
      ..addMultiOption('silence-deprecation', help: 'Deprecations to ignore.')
      ..addMultiOption(
        'future-deprecation',
        help: 'Opt in to a deprecation early.',
      );

    parser
      ..addSeparator(_separator('Other'))
      ..addFlag(
        'watch',
        abbr: 'w',
        help: 'Watch stylesheets and recompile when they change.',
        negatable: false,
      )
      ..addFlag(
        'poll',
        help: 'Manually check for changes rather than using a native '
            'watcher.\n'
            'Only valid with --watch.',
      )
      ..addFlag(
        'stop-on-error',
        help: "Don't compile more files once an error is encountered.",
      )
      ..addFlag(
        'interactive',
        abbr: 'i',
        help: 'Run an interactive SassScript shell.',
        negatable: false,
      )
      ..addFlag(
        'color',
        abbr: 'c',
        help: 'Whether to use terminal colors for messages.',
      )
      ..addFlag(
        'unicode',
        help: 'Whether to use Unicode characters for messages.',
      )
      ..addFlag('trace', help: 'Print full Dart stack traces for exceptions.')
      ..addFlag(
        'help',
        abbr: 'h',
        help: 'Print this usage information.',
        negatable: false,
      )
      ..addFlag(
        'version',
        help: 'Print the version of Dart Sass.',
        negatable: false,
      );

    return parser;
  }();

  /// Creates a styled separator with the given [text].
  static String _separator(String text) =>
      _separatorBar * 3 +
      " " +
      (hasTerminal ? '\u001b[1m' : '') +
      text +
      (hasTerminal ? '\u001b[0m' : '') +
      ' ' +
      // Three separators + two spaces = 5
      _separatorBar * (_separatorLength - 5 - text.length);

  /// A human-readable description of how to invoke the Sass executable.
  static String get usage => _parser.usage;

  /// Shorthand for throwing a [UsageException] with the given [message].
  static Never _fail(String message) => throw UsageException(message);

  /// The parsed options passed by the user to the executable.
  final ArgResults _options;

  /// Whether to print the version of Sass and exit.
  bool get version => _options['version'] as bool;

  /// Whether to run an interactive shell.
  late final bool interactive = () {
    if (!(_options['interactive'] as bool)) return false;

    var invalidOptions = [
      'stdin', 'indented', 'style', 'source-map', 'source-map-urls', //
      'embed-sources', 'embed-source-map', 'update', 'watch',
    ];
    if (invalidOptions.firstWhereOrNull(_options.wasParsed) case var option?) {
      throw UsageException("--$option isn't allowed with --interactive.");
    }
    return true;
  }();

  /// Whether to parse the source file with the indented syntax.
  ///
  /// This may be `null`, indicating that this should be determined by each
  /// stylesheet's extension.
  bool? get indented => _ifParsed('indented') as bool?;

  /// Whether to use ANSI terminal colors.
  bool get color => _options.wasParsed('color')
      ? _options['color'] as bool
      : supportsAnsiEscapes;

  /// Whether to use non-ASCII Unicode glyphs.
  bool get unicode => _options.wasParsed('unicode')
      ? _options['unicode'] as bool
      : !term_glyph.ascii;

  /// Whether to silence all warnings.
  bool get quiet => _options['quiet'] as bool;

  /// Whether to silence warnings in dependencies.
  bool get quietDeps => _options['quiet-deps'] as bool;

  /// Whether to emit all repetitive deprecation warnings.
  bool get verbose => _options['verbose'] as bool;

  /// The logger to use to emit messages from Sass.
  Logger get logger => quiet ? Logger.quiet : Logger.stderr(color: color);

  /// The style to use for the generated CSS.
  OutputStyle get style => _options['style'] == 'compressed'
      ? OutputStyle.compressed
      : OutputStyle.expanded;

  /// Whether to include a `@charset` declaration or a BOM if the stylesheet
  /// contains any non-ASCII characters.
  bool get charset => _options['charset'] as bool;

  /// The set of paths Sass in which should look for imported files.
  List<String> get loadPaths => _options['load-path'] as List<String>;

  /// The list of built-in importers to use to load `pkg:` URLs.
  List<Importer> get pkgImporters => [
        for (var _ in _options['pkg-importer'] as List<String>)
          NodePackageImporter('.'),
      ];

  /// Whether to run the evaluator in asynchronous mode, for debugging purposes.
  bool get asynchronous => _options['async'] as bool;

  /// Whether to print the full Dart stack trace on exceptions.
  bool get trace => _options['trace'] as bool;

  /// Whether to update only files that have changed since the last compilation.
  bool get update => _options['update'] as bool;

  /// Whether to continuously watch the filesystem for changes.
  bool get watch => _options['watch'] as bool;

  /// Whether to manually poll for changes when watching.
  bool get poll => _options['poll'] as bool;

  /// Whether to stop compiling additional files once one file produces an
  /// error.
  bool get stopOnError => _options['stop-on-error'] as bool;

  /// Whether to emit error messages as CSS stylesheets
  bool get emitErrorCss =>
      _options['error-css'] as bool? ??
      sourcesToDestinations.values.any((destination) => destination != null);

  /// A map from source paths to the destination paths where the compiled CSS
  /// should be written.
  ///
  /// Considers keys to be the same if they represent the same path on disk.
  ///
  /// A `null` source indicates that a stylesheet should be read from standard
  /// input. A `null` destination indicates that a stylesheet should be written
  /// to standard output.
  Map<String?, String?> get sourcesToDestinations {
    _ensureSources();
    return _sourcesToDestinations!;
  }

  Map<String?, String?>? _sourcesToDestinations;

  /// A map from source directories to the destination directories where the
  /// compiled CSS for stylesheets in the source directories should be written.
  ///
  /// Considers keys to be the same if they represent the same path on disk.
  Map<String?, String> get sourceDirectoriesToDestinations {
    _ensureSources();
    return _sourceDirectoriesToDestinations;
  }

  late final Map<String?, String> _sourceDirectoriesToDestinations;

  /// Ensure that both [sourcesToDestinations] and [sourceDirectories] have been
  /// computed.
  void _ensureSources() {
    if (_sourcesToDestinations != null) return;

    var stdin = _options['stdin'] as bool;
    if (_options.rest.isEmpty && !stdin) _fail("Compile Sass to CSS.");

    var directories = <String>{};
    var colonArgs = false;
    var positionalArgs = false;
    for (var argument in _options.rest) {
      if (argument.isEmpty) _fail('Invalid argument "".');

      if (argument.contains(":") &&
          (!_isWindowsPath(argument, 0) ||
              // Look for colons after index 1, since that's where the drive
              // letter is on Windows paths.
              argument.contains(":", 2))) {
        colonArgs = true;
      } else if (dirExists(argument)) {
        directories.add(argument);
      } else {
        positionalArgs = true;
      }
    }

    if (positionalArgs || _options.rest.isEmpty) {
      if (colonArgs) {
        _fail('Positional and ":" arguments may not both be used.');
      } else if (stdin) {
        if (_options.rest.length > 1) {
          _fail("Only one argument is allowed with --stdin.");
        } else if (update) {
          _fail("--update is not allowed with --stdin.");
        } else if (watch) {
          _fail("--watch is not allowed with --stdin.");
        }
        _sourcesToDestinations = Map.unmodifiable({
          null: _options.rest.isEmpty ? null : _options.rest.first,
        });
      } else if (_options.rest.length > 2) {
        _fail("Only two positional args may be passed.");
      } else if (directories.isNotEmpty) {
        var message =
            'Directory "${directories.first}" may not be a positional arg.';

        // If it looks like the user called `sass in-dir out-dir`, suggest they
        // call "sass in-dir:out-dir` instead. Don't do this if they wrote
        // `sass dir file.scss` or `sass something dir`.
        var target = _options.rest.last;
        if (directories.first == _options.rest.first && !fileExists(target)) {
          message += '\n'
              'To compile all CSS in "${directories.first}" to "$target", use '
              '`sass ${directories.first}:$target`.';
        }

        _fail(message);
      } else {
        var source = _options.rest.first == '-' ? null : _options.rest.first;
        var destination = _options.rest.length == 1 ? null : _options.rest.last;
        if (destination == null) {
          if (update) {
            _fail("--update is not allowed when printing to stdout.");
          } else if (watch) {
            _fail("--watch is not allowed when printing to stdout.");
          }
        }

        var map =
            p.PathMap<String?>(); // p.PathMap.of() doesn't support null keys.
        map[source] = destination;
        _sourcesToDestinations = UnmodifiableMapView(map);
      }
      _sourceDirectoriesToDestinations = const {};
      return;
    }

    if (stdin) _fail('--stdin may not be used with ":" arguments.');

    // Track [seen] separately from `sourcesToDestinations.keys` because we want
    // to report errors for sources as users entered them, rather than after
    // directories have been resolved.
    var seen = <String>{};
    var sourcesToDestinations = p.PathMap<String>();
    var sourceDirectoriesToDestinations = p.PathMap<String>();
    for (var argument in _options.rest) {
      if (directories.contains(argument)) {
        if (!seen.add(argument)) _fail('Duplicate source "$argument".');

        sourceDirectoriesToDestinations[argument] = argument;
        sourcesToDestinations.addAll(_listSourceDirectory(argument, argument));
        continue;
      }

      var (source, destination) = _splitSourceAndDestination(argument);
      if (!seen.add(source)) _fail('Duplicate source "$source".');

      if (source == '-') {
        sourcesToDestinations[null] = destination;
      } else if (dirExists(source)) {
        sourceDirectoriesToDestinations[source] = destination;
        sourcesToDestinations.addAll(_listSourceDirectory(source, destination));
      } else {
        sourcesToDestinations[source] = destination;
      }
    }
    _sourcesToDestinations = UnmodifiableMapView(sourcesToDestinations);
    _sourceDirectoriesToDestinations = UnmodifiableMapView(
      sourceDirectoriesToDestinations,
    );
  }

  /// Splits an argument that contains a colon and returns its source and its
  /// destination component.
  (String, String) _splitSourceAndDestination(String argument) {
    for (var i = 0; i < argument.length; i++) {
      // A colon at position 1 may be a Windows drive letter and not a
      // separator.
      if (i == 1 && _isWindowsPath(argument, i - 1)) continue;

      if (argument.codeUnitAt(i) == $colon) {
        var nextColon = argument.indexOf(':', i + 1);
        // A colon 2 characters after the separator may also be a Windows
        // drive letter.
        if (nextColon == i + 2 && _isWindowsPath(argument, i + 1)) {
          nextColon = argument.indexOf(':', nextColon + 1);
        }
        if (nextColon != -1) _fail('"$argument" may only contain one ":".');

        return (argument.substring(0, i), argument.substring(i + 1));
      }
    }

    throw ArgumentError('Expected "$argument" to contain a colon.');
  }

  /// Returns whether [string] contains an absolute Windows path at [index].
  bool _isWindowsPath(String string, int index) =>
      string.length > index + 2 &&
      string.codeUnitAt(index).isAlphabetic &&
      string.codeUnitAt(index + 1) == $colon;

  /// Returns the sub-map of [sourcesToDestinations] for the given [source] and
  /// [destination] directories.
  Map<String, String> _listSourceDirectory(String source, String destination) {
    return {
      for (var path in listDir(source, recursive: true))
        if (_isEntrypoint(path) &&
            // Don't compile a CSS file to its own location.
            !(source == destination && p.extension(path) == '.css'))
          path: p.join(
            destination,
            p.setExtension(p.relative(path, from: source), '.css'),
          ),
    };
  }

  /// Returns whether [path] is a Sass entrypoint (that is, not a partial).
  bool _isEntrypoint(String path) {
    if (p.basename(path).startsWith("_")) return false;
    var extension = p.extension(path);
    return extension == ".scss" || extension == ".sass" || extension == ".css";
  }

  /// Returns whether we're writing to stdout instead of a file or files.
  bool get _writeToStdout =>
      sourcesToDestinations.length == 1 &&
      sourcesToDestinations.values.single == null;

  /// Whether to emit a source map file.
  bool get emitSourceMap {
    if (!(_options['source-map'] as bool)) {
      if (_options.wasParsed('source-map-urls')) {
        _fail("--source-map-urls isn't allowed with --no-source-map.");
      } else if (_options.wasParsed('embed-sources')) {
        _fail("--embed-sources isn't allowed with --no-source-map.");
      } else if (_options.wasParsed('embed-source-map')) {
        _fail("--embed-source-map isn't allowed with --no-source-map.");
      }
    }
    if (!_writeToStdout) return _options['source-map'] as bool;

    if (_ifParsed('source-map-urls') == 'relative') {
      _fail(
        "--source-map-urls=relative isn't allowed when printing to stdout.",
      );
    }

    if (_options['embed-source-map'] as bool) {
      return _options['source-map'] as bool;
    } else if (_ifParsed('source-map') == true) {
      _fail(
        "When printing to stdout, --source-map requires --embed-source-map.",
      );
    } else if (_options.wasParsed('source-map-urls')) {
      _fail(
        "When printing to stdout, --source-map-urls requires "
        "--embed-source-map.",
      );
    } else if (_options['embed-sources'] as bool) {
      _fail(
        "When printing to stdout, --embed-sources requires "
        "--embed-source-map.",
      );
    } else {
      return false;
    }
  }

  /// Whether to embed the generated source map as a data URL in the output CSS.
  bool get embedSourceMap => _options['embed-source-map'] as bool;

  /// Whether to embed the source files in the generated source map.
  bool get embedSources => _options['embed-sources'] as bool;

  /// Parses options from [args].
  ///
  /// Throws a [UsageException] if parsing fails.
  factory ExecutableOptions.parse(List<String> args) {
    try {
      var options = ExecutableOptions._(_parser.parse(args));
      if (options._options['help'] as bool) _fail("Compile Sass to CSS.");
      return options;
    } on FormatException catch (error) {
      _fail(error.message);
    }
  }

  ExecutableOptions._(this._options) {
    if (_options.wasParsed('poll') && !watch) {
      _fail("--poll may not be passed without --watch.");
    }
  }

  /// Makes [url] absolute or relative (to the directory containing
  /// [destination]) according to the `source-map-urls` option.
  ///
  /// If [url] isn't a `file:` URL, returns it as-is.
  Uri sourceMapUrl(Uri url, String? destination) {
    if (url.scheme.isNotEmpty && url.scheme != 'file') return url;

    var path = p.fromUri(url);
    return p.toUri(
      _options['source-map-urls'] == 'relative' && !_writeToStdout
          // [destination] can't be null here because --source-map-urls=relative
          // is incompatible with writing to stdout.
          ? p.relative(path, from: p.dirname(destination!))
          : p.absolute(path),
    );
  }

  /// The set of deprecations whose warnings should be silenced.
  Set<Deprecation> get silenceDeprecations => {
        for (var id in _options['silence-deprecation'] as List<String>)
          Deprecation.fromId(id) ?? _fail('Invalid deprecation "$id".'),
      };

  /// The set of deprecations that cause errors.
  Set<Deprecation> get fatalDeprecations => _fatalDeprecations ??= () {
        var deprecations = <Deprecation>{};
        for (var id in _options['fatal-deprecation'] as List<String>) {
          if (Deprecation.fromId(id) case var deprecation?) {
            deprecations.add(deprecation);
            continue;
          }

          try {
            var argVersion = Version.parse(id);
            // We can't get the version synchronously when running from
            // source, so we just ignore this check by using a version higher
            // than any that will ever be used.
            var sassVersion = Version.parse(
              const bool.hasEnvironment('version')
                  ? const String.fromEnvironment('version')
                  : '1000.0.0',
            );
            if (argVersion > sassVersion) {
              _fail(
                'Invalid version $argVersion. --fatal-deprecation '
                'requires a version less than or equal to the current '
                'Dart Sass version.',
              );
            }
            deprecations.addAll(Deprecation.forVersion(argVersion));
          } on FormatException {
            _fail('Invalid deprecation "$id".');
          }
        }
        return deprecations;
      }();
  Set<Deprecation>? _fatalDeprecations;

  /// The set of future deprecations that should emit warnings anyway.
  Set<Deprecation> get futureDeprecations => {
        for (var id in _options['future-deprecation'] as List<String>)
          Deprecation.fromId(id) ?? _fail('Invalid deprecation "$id".'),
      };

  /// Returns the value of [name] in [options] if it was explicitly provided by
  /// the user, and `null` otherwise.
  Object? _ifParsed(String name) =>
      _options.wasParsed(name) ? _options[name] : null;
}

/// An exception indicating that invalid arguments were passed.
class UsageException implements Exception {
  final String message;

  UsageException(this.message);
}
