// 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 'package:meta/meta.dart';

import 'ast/selector.dart';
import 'deprecation.dart';
import 'evaluation_context.dart';
import 'exception.dart';
import 'utils.dart';
import 'value/boolean.dart';
import 'value/calculation.dart';
import 'value/color.dart';
import 'value/function.dart';
import 'value/list.dart';
import 'value/map.dart';
import 'value/mixin.dart';
import 'value/number.dart';
import 'value/string.dart';
import 'visitor/interface/value.dart';
import 'visitor/serialize.dart';

export 'value/argument_list.dart';
export 'value/boolean.dart';
export 'value/calculation.dart';
export 'value/color.dart';
export 'value/function.dart';
export 'value/list.dart';
export 'value/map.dart';
export 'value/mixin.dart';
export 'value/null.dart';
export 'value/number.dart' hide conversionFactor;
export 'value/string.dart';

/// A SassScript value.
///
/// All SassScript values are unmodifiable. New values can be constructed using
/// subclass constructors like [SassString.new]. Untyped values can be cast to
/// particular types using `assert*()` functions like [assertString], which
/// throw user-friendly error messages if they fail.
///
/// {@category Value}
@sealed
abstract class Value {
  /// Whether the value counts as `true` in an `@if` statement and other
  /// contexts.
  bool get isTruthy => true;

  /// The separator for this value as a list.
  ///
  /// All SassScript values can be used as lists. Maps count as lists of pairs,
  /// and all other values count as single-value lists.
  ListSeparator get separator => ListSeparator.undecided;

  /// Whether this value as a list has brackets.
  ///
  /// All SassScript values can be used as lists. Maps count as lists of pairs,
  /// and all other values count as single-value lists.
  bool get hasBrackets => false;

  /// This value as a list.
  ///
  /// All SassScript values can be used as lists. Maps count as lists of pairs,
  /// and all other values count as single-value lists.
  List<Value> get asList => [this];

  /// The length of [asList].
  ///
  /// This is used to compute [sassIndexToListIndex] without allocating a new
  /// list.
  ///
  /// @nodoc
  @protected
  int get lengthAsList => 1;

  /// Whether the value will be represented in CSS as the empty string.
  ///
  /// @nodoc
  @internal
  bool get isBlank => false;

  /// Whether this is a value that CSS may treat as a number, such as `calc()`
  /// or `var()`.
  ///
  /// Functions that shadow plain CSS functions need to gracefully handle when
  /// these arguments are passed in.
  ///
  /// @nodoc
  @internal
  bool get isSpecialNumber => false;

  /// Whether this is a call to `var()`, which may be substituted in CSS for a
  /// custom property value.
  ///
  /// Functions that shadow plain CSS functions need to gracefully handle when
  /// these arguments are passed in.
  ///
  /// @nodoc
  @internal
  bool get isSpecialVariable => false;

  /// Returns Dart's `null` value if this is [sassNull], and returns `this`
  /// otherwise.
  Value? get realNull => this;

  /// @nodoc
  const Value();

  /// Calls the appropriate visit method on [visitor].
  ///
  /// @nodoc
  @internal
  T accept<T>(ValueVisitor<T> visitor);

  /// Converts [sassIndex] into a Dart-style index into the list returned by
  /// [asList].
  ///
  /// Sass indexes are one-based, while Dart indexes are zero-based. Sass
  /// indexes may also be negative in order to index from the end of the list.
  ///
  /// Throws a [SassScriptException] if [sassIndex] isn't a number, if that
  /// number isn't an integer, or if that integer isn't a valid index for
  /// [asList]. If [sassIndex] came from a function argument, [name] is the
  /// argument name (without the `$`). It's used for error reporting.
  int sassIndexToListIndex(Value sassIndex, [String? name]) {
    var indexValue = sassIndex.assertNumber(name);
    if (indexValue.hasUnits) {
      warnForDeprecation(
        "\$$name: Passing a number with unit ${indexValue.unitString} is "
        "deprecated.\n"
        "\n"
        "To preserve current behavior: "
        "${indexValue.unitSuggestion(name ?? 'index')}\n"
        "\n"
        "More info: https://sass-lang.com/d/function-units",
        Deprecation.functionUnits,
      );
    }

    var index = indexValue.assertInt(name);
    if (index == 0) throw SassScriptException("List index may not be 0.", name);
    if (index.abs() > lengthAsList) {
      throw SassScriptException(
        "Invalid index $sassIndex for a list with $lengthAsList elements.",
        name,
      );
    }

    return index < 0 ? lengthAsList + index : index - 1;
  }

  /// Throws a [SassScriptException] if `this` isn't a boolean.
  ///
  /// Note that generally, functions should use [isTruthy] rather than requiring
  /// a literal boolean.
  ///
  /// If this came from a function argument, [name] is the argument name
  /// (without the `$`). It's used for error reporting.
  SassBoolean assertBoolean([String? name]) =>
      throw SassScriptException("$this is not a boolean.", name);

  /// Throws a [SassScriptException] if `this` isn't a calculation.
  ///
  /// If this came from a function argument, [name] is the argument name
  /// (without the `$`). It's used for error reporting.
  SassCalculation assertCalculation([String? name]) =>
      throw SassScriptException("$this is not a calculation.", name);

  /// Throws a [SassScriptException] if `this` isn't a color.
  ///
  /// If this came from a function argument, [name] is the argument name
  /// (without the `$`). It's used for error reporting.
  SassColor assertColor([String? name]) =>
      throw SassScriptException("$this is not a color.", name);

  /// Throws a [SassScriptException] if `this` isn't a function reference.
  ///
  /// If this came from a function argument, [name] is the argument name
  /// (without the `$`). It's used for error reporting.
  SassFunction assertFunction([String? name]) =>
      throw SassScriptException("$this is not a function reference.", name);

  /// Throws a [SassScriptException] if `this` isn't a mixin reference.
  ///
  /// If this came from a function argument, [name] is the argument name
  /// (without the `$`). It's used for error reporting.
  SassMixin assertMixin([String? name]) =>
      throw SassScriptException("$this is not a mixin reference.", name);

  /// Throws a [SassScriptException] if `this` isn't a map.
  ///
  /// If this came from a function argument, [name] is the argument name
  /// (without the `$`). It's used for error reporting.
  SassMap assertMap([String? name]) =>
      throw SassScriptException("$this is not a map.", name);

  /// Returns `this` as a [SassMap] if it is one (including empty lists, which
  /// count as empty maps) or returns `null` if it's not.
  SassMap? tryMap() => null;

  /// Throws a [SassScriptException] if `this` isn't a number.
  ///
  /// If this came from a function argument, [name] is the argument name
  /// (without the `$`). It's used for error reporting.
  SassNumber assertNumber([String? name]) =>
      throw SassScriptException("$this is not a number.", name);

  /// Throws a [SassScriptException] if `this` isn't a string.
  ///
  /// If this came from a function argument, [name] is the argument name
  /// (without the `$`). It's used for error reporting.
  SassString assertString([String? name]) =>
      throw SassScriptException("$this is not a string.", name);

  /// Throws a [SassScriptException] if `this` isn't a list of the sort commonly
  /// used in plain CSS expression syntax: space-separated and unbracketed.
  ///
  /// If [allowSlash] is `true`, this allows slash-separated lists as well.
  ///
  /// If this came from a function argument, [name] is the argument name
  /// (without the `$`). It's used for error reporting.
  ///
  /// @nodoc
  @internal
  List<Value> assertCommonListStyle(String? name, {required bool allowSlash}) {
    var invalidSeparator = separator == ListSeparator.comma ||
        (!allowSlash && separator == ListSeparator.slash);
    if (!invalidSeparator && !hasBrackets) return asList;

    var buffer = StringBuffer(r"Expected");
    if (hasBrackets) buffer.write(" an unbracketed");
    if (invalidSeparator) {
      buffer.write(hasBrackets ? "," : " a");
      buffer.write(" space-");
      if (allowSlash) buffer.write(" or slash-");
      buffer.write("separated");
    }
    buffer.write(" list, was $this");
    throw SassScriptException(buffer.toString(), name);
  }

  /// Converts a `selector-parse()`-style input into a string that can be
  /// parsed.
  ///
  /// Throws a [SassScriptException] if `this` isn't a type or a structure that
  /// can be parsed as a selector.
  String _selectorString([String? name]) {
    if (_selectorStringOrNull() case var string?) return string;

    throw SassScriptException(
      "$this is not a valid selector: it must be a string,\n"
      "a list of strings, or a list of lists of strings.",
      name,
    );
  }

  /// Converts a `selector-parse()`-style input into a string that can be
  /// parsed.
  ///
  /// Returns `null` if `this` isn't a type or a structure that can be parsed as
  /// a selector.
  String? _selectorStringOrNull() {
    var self = this;
    if (self is SassString) return self.text;
    if (self is! SassList) return null;
    if (self.asList.isEmpty) return null;

    var result = <String>[];
    switch (self.separator) {
      case ListSeparator.comma:
        for (var complex in self.asList) {
          switch (complex) {
            case SassString():
              result.add(complex.text);
            case SassList(separator: ListSeparator.space):
              var string = complex._selectorStringOrNull();
              if (string == null) return null;
              result.add(string);
            case _:
              return null;
          }
        }
      case ListSeparator.slash:
        return null;
      case _:
        for (var compound in self.asList) {
          if (compound is! SassString) return null;
          result.add(compound.text);
        }
    }
    return result.join(self.separator == ListSeparator.comma ? ', ' : ' ');
  }

  /// Returns a new list containing [contents] that defaults to this value's
  /// separator and brackets.
  SassList withListContents(
    Iterable<Value> contents, {
    ListSeparator? separator,
    bool? brackets,
  }) {
    return SassList(
      contents,
      separator ?? this.separator,
      brackets: brackets ?? hasBrackets,
    );
  }

  /// The SassScript `=` operation.
  ///
  /// @nodoc
  @internal
  Value singleEquals(Value other) =>
      SassString("${toCssString()}=${other.toCssString()}", quotes: false);

  /// The SassScript `>` operation.
  ///
  /// @nodoc
  @internal
  SassBoolean greaterThan(Value other) =>
      throw SassScriptException('Undefined operation "$this > $other".');

  /// The SassScript `>=` operation.
  ///
  /// @nodoc
  @internal
  SassBoolean greaterThanOrEquals(Value other) =>
      throw SassScriptException('Undefined operation "$this >= $other".');

  /// The SassScript `<` operation.
  ///
  /// @nodoc
  @internal
  SassBoolean lessThan(Value other) =>
      throw SassScriptException('Undefined operation "$this < $other".');

  /// The SassScript `<=` operation.
  ///
  /// @nodoc
  @internal
  SassBoolean lessThanOrEquals(Value other) =>
      throw SassScriptException('Undefined operation "$this <= $other".');

  /// The SassScript `*` operation.
  ///
  /// @nodoc
  @internal
  Value times(Value other) =>
      throw SassScriptException('Undefined operation "$this * $other".');

  /// The SassScript `%` operation.
  ///
  /// @nodoc
  @internal
  Value modulo(Value other) =>
      throw SassScriptException('Undefined operation "$this % $other".');

  /// The SassScript `+` operation.
  ///
  /// @nodoc
  @internal
  Value plus(Value other) => switch (other) {
        SassString() => SassString(
            toCssString() + other.text,
            quotes: other.hasQuotes,
          ),
        SassCalculation() =>
          throw SassScriptException('Undefined operation "$this + $other".'),
        _ => SassString(toCssString() + other.toCssString(), quotes: false),
      };

  /// The SassScript `-` operation.
  ///
  /// @nodoc
  @internal
  Value minus(Value other) => other is SassCalculation
      ? throw SassScriptException('Undefined operation "$this - $other".')
      : SassString(
          "${toCssString()}-${other.toCssString()}",
          quotes: false,
        );

  /// The SassScript `/` operation.
  ///
  /// @nodoc
  @internal
  Value dividedBy(Value other) =>
      SassString("${toCssString()}/${other.toCssString()}", quotes: false);

  /// The SassScript unary `+` operation.
  ///
  /// @nodoc
  @internal
  Value unaryPlus() => SassString("+${toCssString()}", quotes: false);

  /// The SassScript unary `-` operation.
  ///
  /// @nodoc
  @internal
  Value unaryMinus() => SassString("-${toCssString()}", quotes: false);

  /// The SassScript unary `/` operation.
  ///
  /// @nodoc
  @internal
  Value unaryDivide() => SassString("/${toCssString()}", quotes: false);

  /// The SassScript unary `not` operation.
  ///
  /// @nodoc
  @internal
  Value unaryNot() => sassFalse;

  /// Returns a copy of `this` without [SassNumber.asSlash] set.
  ///
  /// If this isn't a [SassNumber], returns it as-is.
  ///
  /// @nodoc
  @internal
  Value withoutSlash() => this;

  /// Returns a valid CSS representation of `this`.
  ///
  /// Throws a [SassScriptException] if `this` can't be represented in plain
  /// CSS. Use [toString] instead to get a string representation even if this
  /// isn't valid CSS.
  //
  // Internal-only: If [quote] is `false`, quoted strings are emitted without
  // quotes.
  String toCssString({@internal bool quote = true}) =>
      serializeValue(this, quote: quote);

  /// Returns a string representation of `this`.
  ///
  /// Note that this is equivalent to calling `inspect()` on the value, and thus
  /// won't reflect the user's output settings. [toCssString] should be used
  /// instead to convert `this` to CSS.
  String toString() => serializeValue(this, inspect: true);
}

/// Extension methods that are only visible through the `sass_api` package.
///
/// These methods are considered less general-purpose and more liable to change
/// than the main [Value] interface.
///
/// {@category Value}
extension SassApiValue on Value {
  /// Parses `this` as a selector list, in the same manner as the
  /// `selector-parse()` function.
  ///
  /// Throws a [SassScriptException] if this isn't a type that can be parsed as a
  /// selector, or if parsing fails. If [allowParent] is `true`, this allows
  /// [ParentSelector]s. Otherwise, they're considered parse errors.
  ///
  /// If this came from a function argument, [name] is the argument name
  /// (without the `$`). It's used for error reporting.
  SelectorList assertSelector({String? name, bool allowParent = false}) {
    var string = _selectorString(name);
    try {
      return SelectorList.parse(string, allowParent: allowParent);
    } on SassFormatException catch (error, stackTrace) {
      // TODO(nweiz): colorize this if we're running in an environment where
      // that works.
      throwWithTrace(
        SassScriptException(error.toString().replaceFirst("Error: ", ""), name),
        error,
        stackTrace,
      );
    }
  }

  /// Parses `this` as a simple selector, in the same manner as the
  /// `selector-parse()` function.
  ///
  /// Throws a [SassScriptException] if this isn't a type that can be parsed as a
  /// selector, or if parsing fails. If [allowParent] is `true`, this allows
  /// [ParentSelector]s. Otherwise, they're considered parse errors.
  ///
  /// If this came from a function argument, [name] is the argument name
  /// (without the `$`). It's used for error reporting.
  SimpleSelector assertSimpleSelector({
    String? name,
    bool allowParent = false,
  }) {
    var string = _selectorString(name);
    try {
      return SimpleSelector.parse(string, allowParent: allowParent);
    } on SassFormatException catch (error, stackTrace) {
      // TODO(nweiz): colorize this if we're running in an environment where
      // that works.
      throwWithTrace(
        SassScriptException(error.toString().replaceFirst("Error: ", ""), name),
        error,
        stackTrace,
      );
    }
  }

  /// Parses `this` as a compound selector, in the same manner as the
  /// `selector-parse()` function.
  ///
  /// Throws a [SassScriptException] if this isn't a type that can be parsed as a
  /// selector, or if parsing fails. If [allowParent] is `true`, this allows
  /// [ParentSelector]s. Otherwise, they're considered parse errors.
  ///
  /// If this came from a function argument, [name] is the argument name
  /// (without the `$`). It's used for error reporting.
  CompoundSelector assertCompoundSelector({
    String? name,
    bool allowParent = false,
  }) {
    var string = _selectorString(name);
    try {
      return CompoundSelector.parse(string, allowParent: allowParent);
    } on SassFormatException catch (error, stackTrace) {
      // TODO(nweiz): colorize this if we're running in an environment where
      // that works.
      throwWithTrace(
        SassScriptException(error.toString().replaceFirst("Error: ", ""), name),
        error,
        stackTrace,
      );
    }
  }

  /// Parses `this` as a complex selector, in the same manner as the
  /// `selector-parse()` function.
  ///
  /// Throws a [SassScriptException] if this isn't a type that can be parsed as a
  /// selector, or if parsing fails. If [allowParent] is `true`, this allows
  /// [ParentSelector]s. Otherwise, they're considered parse errors.
  ///
  /// If this came from a function argument, [name] is the argument name
  /// (without the `$`). It's used for error reporting.
  ComplexSelector assertComplexSelector({
    String? name,
    bool allowParent = false,
  }) {
    var string = _selectorString(name);
    try {
      return ComplexSelector.parse(string, allowParent: allowParent);
    } on SassFormatException catch (error, stackTrace) {
      // TODO(nweiz): colorize this if we're running in an environment where
      // that works.
      throwWithTrace(
        SassScriptException(error.toString().replaceFirst("Error: ", ""), name),
        error,
        stackTrace,
      );
    }
  }
}
