// Copyright (c) 2020, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

import 'package:collection/collection.dart';
import 'package:yaml/yaml.dart';

import 'utils.dart';

/// Given [value], tries to format it into a plain string recognizable by YAML.
///
/// Not all values can be formatted into a plain string. If the string contains
/// an escape sequence, it can only be detected when in a double-quoted
/// sequence. Plain strings may also be misinterpreted by the YAML parser (e.g.
/// ' null').
///
/// Returns `null` if [value] cannot be encoded as a plain string.
String? _tryYamlEncodePlain(String value) {
  /// If it contains a dangerous character we want to wrap the result with
  /// double quotes because the double quoted style allows for arbitrary
  /// strings with "\" escape sequences.
  ///
  /// See 7.3.1 Double-Quoted Style
  /// https://yaml.org/spec/1.2/spec.html#id2787109
  return isDangerousString(value) ? null : value;
}

/// Checks if [string] has unprintable characters according to
/// [unprintableCharCodes].
bool _hasUnprintableCharacters(String string) {
  final codeUnits = string.codeUnits;

  for (final key in unprintableCharCodes.keys) {
    if (codeUnits.contains(key)) return true;
  }

  return false;
}

/// Generates a YAML-safe double-quoted string based on [string], escaping the
/// list of characters as defined by the YAML 1.2 spec.
///
/// See 5.7 Escaped Characters https://yaml.org/spec/1.2/spec.html#id2776092
String _yamlEncodeDoubleQuoted(String string) {
  final buffer = StringBuffer();
  for (final codeUnit in string.codeUnits) {
    if (doubleQuoteEscapeChars[codeUnit] != null) {
      buffer.write(doubleQuoteEscapeChars[codeUnit]);
    } else {
      buffer.writeCharCode(codeUnit);
    }
  }

  return '"$buffer"';
}

/// Encodes [string] as YAML single quoted string.
///
/// Returns `null`, if the [string] can't be encoded as single-quoted string.
/// This might happen if it contains line-breaks or [_hasUnprintableCharacters].
///
/// See: https://yaml.org/spec/1.2.2/#732-single-quoted-style
String? _tryYamlEncodeSingleQuoted(String string) {
  // If [string] contains a newline we'll use double quoted strings instead.
  // Single quoted strings can represent newlines, but then we have to use an
  // empty line (replace \n with \n\n). But since leading spaces following
  // line breaks are ignored, we can't represent "\n ".
  // Thus, if the string contains `\n` and we're asked to do single quoted,
  // we'll fallback to a double quoted string.
  if (_hasUnprintableCharacters(string) || string.contains('\n')) return null;

  final result = string.replaceAll('\'', '\'\'');
  return '\'$result\'';
}

/// Attempts to encode a [string] as a _YAML folded string_ and apply the
/// appropriate _chomping indicator_.
///
/// Returns `null`, if the [string] cannot be encoded as a _YAML folded
/// string_.
///
/// **Examples** of folded strings.
/// ```yaml
/// # With the "strip" chomping indicator
/// key: >-
///   my folded
///   string
///
/// # With the "keep" chomping indicator
/// key: >+
///   my folded
///   string
/// ```
///
/// See: https://yaml.org/spec/1.2.2/#813-folded-style
String? _tryYamlEncodeFolded(String string, int indentSize, String lineEnding) {
  // A string that starts with space or newline followed by space can't be
  // encoded in folded mode.
  if (string.isEmpty || string.trim().length != string.length) return null;

  if (_hasUnprintableCharacters(string)) return null;

  // TODO: Are there other strings we can't encode in folded mode?

  final indent = ' ' * indentSize;

  /// Remove trailing `\n` & white-space to ease string folding
  var trimmed = string.trimRight();
  final stripped = string.substring(trimmed.length);

  final trimmedSplit =
      trimmed.replaceAll('\n', lineEnding + indent).split(lineEnding);

  /// Try folding to match specification:
  /// * https://yaml.org/spec/1.2.2/#65-line-folding
  trimmed = trimmedSplit.reduceIndexed((index, previous, current) {
    var updated = current;

    /// If initially empty, this line holds only `\n` or white-space. This
    /// tells us we don't need to apply an additional `\n`.
    ///
    /// See https://yaml.org/spec/1.2.2/#64-empty-lines
    ///
    /// If this line is not empty, we need to apply an additional `\n` if and
    /// only if:
    ///   1. The preceding line was non-empty too
    ///   2. If the current line doesn't begin with white-space
    ///
    /// Such that we apply `\n` for `foo\nbar` but not `foo\n bar`.
    if (current.trim().isNotEmpty &&
        trimmedSplit[index - 1].trim().isNotEmpty &&
        !current.replaceFirst(indent, '').startsWith(' ')) {
      updated = lineEnding + updated;
    }

    /// Apply a `\n` by default.
    return previous + lineEnding + updated;
  });

  return '>-\n'
      '$indent$trimmed'
      '${stripped.replaceAll('\n', lineEnding + indent)}';
}

/// Attempts to encode a [string] as a _YAML literal string_ and apply the
/// appropriate _chomping indicator_.
///
/// Returns `null`, if the [string] cannot be encoded as a _YAML literal
/// string_.
///
/// **Examples** of literal strings.
/// ```yaml
/// # With the "strip" chomping indicator
/// key: |-
///   my literal
///   string
///
/// # Without chomping indicator
/// key: |
///   my literal
///   string
/// ```
///
/// See: https://yaml.org/spec/1.2.2/#812-literal-style
String? _tryYamlEncodeLiteral(
    String string, int indentSize, String lineEnding) {
  if (string.isEmpty || string.trim().length != string.length) return null;

  // A string that starts with space or newline followed by space can't be
  // encoded in literal mode.
  if (_hasUnprintableCharacters(string)) return null;

  // TODO: Are there other strings we can't encode in literal mode?

  final indent = ' ' * indentSize;

  /// Simplest block style.
  /// * https://yaml.org/spec/1.2.2/#812-literal-style
  return '|-\n$indent${string.replaceAll('\n', lineEnding + indent)}';
}

/// Encodes a flow [YamlScalar] based on the provided [YamlScalar.style].
///
/// Falls back to [ScalarStyle.DOUBLE_QUOTED] if the [yamlScalar] cannot be
/// encoded with the [YamlScalar.style] or with [ScalarStyle.PLAIN] when the
/// [yamlScalar] is not a [String].
String _yamlEncodeFlowScalar(YamlScalar yamlScalar) {
  final YamlScalar(:value, :style) = yamlScalar;

  if (value is! String) {
    return value.toString();
  }

  switch (style) {
    /// Only encode as double-quoted if it's a string.
    case ScalarStyle.DOUBLE_QUOTED:
      return _yamlEncodeDoubleQuoted(value);

    case ScalarStyle.SINGLE_QUOTED:
      return _tryYamlEncodeSingleQuoted(value) ??
          _yamlEncodeDoubleQuoted(value);

    /// Cast into [String] if [null] as this condition only returns [null]
    /// for a [String] that can't be encoded.
    default:
      return _tryYamlEncodePlain(value) ?? _yamlEncodeDoubleQuoted(value);
  }
}

/// Encodes a block [YamlScalar] based on the provided [YamlScalar.style].
///
/// Falls back to [ScalarStyle.DOUBLE_QUOTED] if the [yamlScalar] cannot be
/// encoded with the [YamlScalar.style] provided.
String _yamlEncodeBlockScalar(
  YamlScalar yamlScalar,
  int indentation,
  String lineEnding,
) {
  final YamlScalar(:value, :style) = yamlScalar;
  assertValidScalar(value);

  if (value is! String) {
    return value.toString();
  }

  switch (style) {
    /// Prefer 'plain', fallback to "double quoted"
    case ScalarStyle.PLAIN:
      return _tryYamlEncodePlain(value) ?? _yamlEncodeDoubleQuoted(value);

    // Prefer 'single quoted', fallback to "double quoted"
    case ScalarStyle.SINGLE_QUOTED:
      return _tryYamlEncodeSingleQuoted(value) ??
          _yamlEncodeDoubleQuoted(value);

    /// Prefer folded string, fallback to "double quoted"
    case ScalarStyle.FOLDED:
      return _tryYamlEncodeFolded(value, indentation, lineEnding) ??
          _yamlEncodeDoubleQuoted(value);

    /// Prefer literal string, fallback to "double quoted"
    case ScalarStyle.LITERAL:
      return _tryYamlEncodeLiteral(value, indentation, lineEnding) ??
          _yamlEncodeDoubleQuoted(value);

    /// Prefer plain, fallback to "double quoted"
    default:
      return _tryYamlEncodePlain(value) ?? _yamlEncodeDoubleQuoted(value);
  }
}

/// Returns [value] with the necessary formatting applied in a flow context.
///
/// If [value] is a [YamlNode], we try to respect its [YamlScalar.style]
/// parameter where possible. Certain cases make this impossible (e.g. a plain
/// string scalar that starts with '>', a child having a block style
/// parameters), in which case we will produce [value] with default styling
/// options.
String yamlEncodeFlow(YamlNode value) {
  if (value is YamlList) {
    final list = value.nodes;

    final safeValues = list.map(yamlEncodeFlow);
    return '[${safeValues.join(', ')}]';
  } else if (value is YamlMap) {
    final safeEntries = value.nodes.entries.map((entry) {
      final safeKey = yamlEncodeFlow(entry.key as YamlNode);
      final safeValue = yamlEncodeFlow(entry.value);
      return '$safeKey: $safeValue';
    });

    return '{${safeEntries.join(', ')}}';
  }

  return _yamlEncodeFlowScalar(value as YamlScalar);
}

/// Returns [value] with the necessary formatting applied in a block context.
String yamlEncodeBlock(
  YamlNode value,
  int indentation,
  String lineEnding,
) {
  const additionalIndentation = 2;

  if (!isBlockNode(value)) return yamlEncodeFlow(value);

  final newIndentation = indentation + additionalIndentation;

  if (value is YamlList) {
    if (value.isEmpty) return '${' ' * indentation}[]';

    Iterable<String> safeValues;

    final children = value.nodes;

    safeValues = children.map((child) {
      var valueString = yamlEncodeBlock(child, newIndentation, lineEnding);
      if (isCollection(child) && !isFlowYamlCollectionNode(child)) {
        valueString = valueString.substring(newIndentation);
      }

      return '${' ' * indentation}- $valueString';
    });

    return safeValues.join(lineEnding);
  } else if (value is YamlMap) {
    if (value.isEmpty) return '${' ' * indentation}{}';

    return value.nodes.entries.map((entry) {
      final MapEntry(:key, :value) = entry;

      final safeKey = yamlEncodeFlow(key as YamlNode);
      final formattedKey = ' ' * indentation + safeKey;

      final formattedValue = yamlEncodeBlock(
        value,
        newIndentation,
        lineEnding,
      );

      /// Empty collections are always encoded in flow-style, so new-line must
      /// be avoided
      if (isCollection(value) && !isEmpty(value)) {
        return '$formattedKey:$lineEnding$formattedValue';
      }

      return '$formattedKey: $formattedValue';
    }).join(lineEnding);
  }

  return _yamlEncodeBlockScalar(
    value as YamlScalar,
    newIndentation,
    lineEnding,
  );
}

/// List of unprintable characters.
///
/// See 5.7 Escape Characters https://yaml.org/spec/1.2/spec.html#id2776092
final Map<int, String> unprintableCharCodes = {
  0: '\\0', //  Escaped ASCII null (#x0) character.
  7: '\\a', //  Escaped ASCII bell (#x7) character.
  8: '\\b', //  Escaped ASCII backspace (#x8) character.
  11: '\\v', // 	Escaped ASCII vertical tab (#xB) character.
  12: '\\f', //  Escaped ASCII form feed (#xC) character.
  13: '\\r', //  Escaped ASCII carriage return (#xD) character. Line Break.
  27: '\\e', //  Escaped ASCII escape (#x1B) character.
  133: '\\N', //  Escaped Unicode next line (#x85) character.
  160: '\\_', //  Escaped Unicode non-breaking space (#xA0) character.
  8232: '\\L', //  Escaped Unicode line separator (#x2028) character.
  8233: '\\P', //  Escaped Unicode paragraph separator (#x2029) character.
};

/// List of escape characters.
///
/// See 5.7 Escape Characters https://yaml.org/spec/1.2/spec.html#id2776092
final Map<int, String> doubleQuoteEscapeChars = {
  ...unprintableCharCodes,
  9: '\\t', //  Escaped ASCII horizontal tab (#x9) character. Printable
  10: '\\n', //  Escaped ASCII line feed (#xA) character. Line Break.
  34: '\\"', //  Escaped ASCII double quote (#x22).
  47: '\\/', //  Escaped ASCII slash (#x2F), for JSON compatibility.
  92: '\\\\', //  Escaped ASCII back slash (#x5C).
};
