// Copyright (c) 2022, 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 'dart:math' as math;

import 'package:analysis_server/src/protocol_server.dart'
    as server
    hide AnalysisError;
import 'package:collection/collection.dart';

/// Builds an LSP snippet string using the supplied edit groups.
///
/// [editGroups] are provided as absolute positions, where the edit will be
/// made starting at [editOffset].
///
/// [selectionOffset] is also absolute and assumes [text] will be
/// inserted at [editOffset].
String buildSnippetStringForEditGroups(
  String text, {
  required String filePath,
  required List<server.LinkedEditGroup> editGroups,
  required int editOffset,
  int? selectionOffset,
  int? selectionLength,
}) => _buildSnippetString(
  text,
  filePath: filePath,
  editGroups: editGroups,
  editGroupsOffset: editOffset,
  selectionOffset: selectionOffset != null
      ? selectionOffset - editOffset
      : null,
  selectionLength: selectionLength,
);

/// Builds an LSP snippet string with supplied ranges as tab stops.
///
/// [tabStopOffsetLengthPairs] are relative to the supplied text.
String buildSnippetStringWithTabStops(
  String text,
  List<int>? tabStopOffsetLengthPairs,
) => _buildSnippetString(
  text,
  filePath: null,
  tabStopOffsetLengthPairs: tabStopOffsetLengthPairs,
);

/// Builds an LSP snippet string with supplied ranges as tab stops.
///
/// [tabStopOffsetLengthPairs] are relative to the supplied text.
///
/// [selectionOffset]/[selectionLength] form a tab stop that is always "number 0"
/// which is the final tab stop.
///
/// [editGroups] are provided as absolute positions, where [text] is known to
/// start at [editGroupsOffset] in the final document.
String _buildSnippetString(
  String text, {
  required String? filePath,
  List<int>? tabStopOffsetLengthPairs,
  int? selectionOffset,
  int? selectionLength,
  List<server.LinkedEditGroup>? editGroups,
  int editGroupsOffset = 0,
}) {
  tabStopOffsetLengthPairs ??= const [];
  editGroups ??= const [];
  assert(tabStopOffsetLengthPairs.length % 2 == 0);

  /// Helper to create a [SnippetPlaceholder] for each position in a linked
  /// edit group.
  Iterable<SnippetPlaceholder> convertEditGroup(
    int index,
    server.LinkedEditGroup editGroup,
  ) {
    var validPositions = editGroup.positions.where((p) => p.file == filePath);
    // Create a placeholder for each position in the group.
    return validPositions.map(
      (position) => SnippetPlaceholder(
        // Make the position relative to the supplied text.
        position.offset - editGroupsOffset,
        editGroup.length,
        suggestions: editGroup.suggestions
            .map((suggestion) => suggestion.value)
            .toList(),
        // Use the index as an ID to keep all related positions together (so
        // the remain "linked").
        linkedGroupId: index,
        // If there is no selection, no tabstops, only a single edit group and
        // not multiple suggestions (which map to a choice), allow this to be
        // the final tabstop.
        isFinal:
            selectionOffset == null &&
            (tabStopOffsetLengthPairs?.isEmpty ?? false) &&
            editGroups?.length == 1 &&
            editGroup.suggestions.length <= 1,
      ),
    );
  }

  // Convert selection/tab stops/edit groups all into the same format
  // (`_SnippetPlaceholder`) so they can be handled in a single pass through
  // the text.
  var placeholders = [
    // Selection.
    if (selectionOffset != null)
      SnippetPlaceholder(selectionOffset, selectionLength ?? 0, isFinal: true),

    // Tab stops.
    for (var i = 0; i < tabStopOffsetLengthPairs.length - 1; i += 2)
      SnippetPlaceholder(
        tabStopOffsetLengthPairs[i],
        tabStopOffsetLengthPairs[i + 1],
        // If there's only a single tab stop (and no selection/editGroups), mark
        // it as the final stop so it exit "snippet mode" when tabbed to.
        isFinal:
            selectionOffset == null &&
            editGroups.isEmpty &&
            tabStopOffsetLengthPairs.length == 2,
      ),

    // Linked edit groups.
    ...editGroups.expandIndexed(convertEditGroup),
  ];

  // Remove any groups outside of the range (it's possible the edit groups apply
  // to a different edit in the collection).
  placeholders.removeWhere(
    (placeholder) =>
        placeholder.offset < 0 ||
        placeholder.offset + placeholder.length > text.length,
  );

  /// If there are no edit groups, then placeholders are all simple and
  /// guaranteed to be in the correct order.
  var isPreSorted = editGroups.isEmpty;
  var builder = SnippetBuilder()
    ..appendPlaceholders(text, placeholders, isPreSorted: isPreSorted);
  return builder.value;
}

/// A helper for building for snippets using LSP/TextMate syntax.
///
/// https://microsoft.github.io/language-server-protocol/specifications/specification-current/#snippet_syntax
///
///  - $1, $2, etc. are used for tab stops
///  - ${1:foo} inserts a placeholder of foo
///  - ${1|foo,bar|} inserts a placeholder of foo with a selection list
///      containing "foo" and "bar"
class SnippetBuilder {
  /// The constant `$0` used do indicate a final tab stop in the snippet syntax.
  static const finalTabStop = r'$0';

  /// Regex used by [escapeSnippetChoiceText].
  static final _escapeSnippetChoiceTextRegex = RegExp(
    r'[\\\|,]',
  ); // Replace any of \ | ,

  /// Regex used by [escapeSnippetPlainText].
  static final _escapeSnippetPlainTextRegex = RegExp(
    r'[$\\]',
  ); // Replace any of $ \

  /// Regex used by [escapeSnippetVariableText].
  static final _escapeSnippetVariableTextRegex = RegExp(
    r'[$}\\]',
  ); // Replace any of $ } \

  final _buffer = StringBuffer();

  var _nextPlaceholder = 1;

  /// The built snippet text using the LSP snippet syntax.
  String get value => _buffer.toString();

  /// Appends a placeholder with a set of choices to choose from.
  ///
  /// If there are 0 or 1 choices, a placeholder will be inserted instead.
  /// If there are multiple choices, [placeholderNumber] must not be 0.
  ///
  /// Returns the placeholder number used.
  int appendChoice(Set<String> uniqueChoices, {int? placeholderNumber}) {
    // If there's only 0/1 items, we can downgrade this to a placeholder.
    if (uniqueChoices.length <= 1) {
      return appendPlaceholder(
        uniqueChoices.firstOrNull ?? '',
        placeholderNumber: placeholderNumber,
      );
    }

    // Otherwise, we will use a choice. In a choice it's not valid to be the
    // final (0th) tabstop.
    assert(placeholderNumber == null || placeholderNumber > 0);

    // To avoid producing broken choice snippets in release builds (if the
    // assert above didn't catch issues at dev time), map any final tabstops to
    // use the next available tabstop to produce a valid snippet.
    if (placeholderNumber == 0) {
      placeholderNumber = null;
    }

    placeholderNumber = _usePlaceholderNumber(placeholderNumber);

    var escapedChoices = uniqueChoices.map(escapeSnippetChoiceText).join(',');
    _buffer.write('\${$placeholderNumber|$escapedChoices|}');

    return placeholderNumber;
  }

  /// Appends a placeholder with the given text.
  ///
  /// If the text is empty, inserts a tab stop instead.
  ///
  /// Returns the placeholder number used.
  int appendPlaceholder(String text, {int? placeholderNumber}) {
    // If there's no text, we can downgrade this to a tab stop.
    if (text.isEmpty) {
      return appendTabStop(placeholderNumber: placeholderNumber);
    }

    placeholderNumber = _usePlaceholderNumber(placeholderNumber);

    var escapedText = escapeSnippetVariableText(text);
    _buffer.write(r'${');
    _buffer.write(placeholderNumber);
    _buffer.write(':');
    _buffer.write(escapedText);
    _buffer.write('}');

    return placeholderNumber;
  }

  /// Appends a tab stop.
  ///
  /// Returns the placeholder number used.
  int appendTabStop({int? placeholderNumber}) {
    placeholderNumber = _usePlaceholderNumber(placeholderNumber);

    _buffer.write(r'$');
    _buffer.write(placeholderNumber);

    return placeholderNumber;
  }

  /// Appends normal text (escaping it as required).
  void appendText(String text) {
    _buffer.write(escapeSnippetPlainText(text));
  }

  /// Generates the current and next placeholder numbers.
  int _usePlaceholderNumber(int? placeholderNumber) {
    // If a number was not supplied, use the next available one.
    placeholderNumber ??= _nextPlaceholder;
    // If the number we used was the highest seen, set the next one after it.
    _nextPlaceholder = math.max(_nextPlaceholder, placeholderNumber + 1);

    return placeholderNumber;
  }

  /// Escapes a string use inside a "choice" in a snippet.
  ///
  /// Similar to [escapeSnippetPlainText], but choices are delimited/separated
  /// by pipes and commas (`${1:|a,b,c|}`).
  static String escapeSnippetChoiceText(String input) =>
      _escapeCharacters(input, _escapeSnippetChoiceTextRegex);

  /// Escapes a string to be used in an LSP edit that uses Snippet mode where the
  /// text is outside of a snippet token.
  ///
  /// Snippets can contain special markup like `${a:b}` so `$` needs escaping
  /// as does `\` so it's not interpreted as an escape.
  static String escapeSnippetPlainText(String input) =>
      _escapeCharacters(input, _escapeSnippetPlainTextRegex);

  /// Escapes a string to be used inside a snippet token.
  ///
  /// Similar to [escapeSnippetPlainText] but additionally escapes `}` so that the
  /// token is not ended early if the included text contains braces.
  static String escapeSnippetVariableText(String input) =>
      _escapeCharacters(input, _escapeSnippetVariableTextRegex);

  /// Escapes [pattern] in [input] with backslashes.
  static String _escapeCharacters(String input, Pattern pattern) =>
      input.replaceAllMapped(pattern, (c) => '\\${c[0]}');
}

/// Information about an individual placeholder/tab stop in a piece of code.
///
/// Each placeholder represents a single position into the code, so a linked
/// edit group with 2 positions will be represented as two instances of this
/// class (with the same [linkedGroupId]).
class SnippetPlaceholder {
  final int offset;
  final int length;
  final List<String>? suggestions;
  final int? linkedGroupId;
  final bool isFinal;

  new(
    this.offset,
    this.length, {
    this.suggestions,
    this.linkedGroupId,
    this.isFinal = false,
  });
}

/// Helpers for [SnippetBuilder] that do not relate to building the main snippet
/// syntax (for example, converting from intermediate structures).
///
/// `isPreSorted` is a performance optimisation that allows skipping some
/// sorting if it's guaranteed that placeholders are already in source-order.
extension SnippetBuilderExtensions on SnippetBuilder {
  void appendPlaceholders(
    String text,
    List<SnippetPlaceholder> placeholders, {
    required bool isPreSorted,
  }) {
    // Ensure placeholders are in the order they're visible in the source so
    // tabbing through them doesn't appear to jump around.
    if (!isPreSorted) {
      placeholders.sortBy<num>((placeholder) => placeholder.offset);
    }

    // We need to use the same placeholder number for all placeholders in the
    // same linked group, so the first time we see a linked item, store its
    // placeholder number here, so subsequent placeholders for the same linked
    // group can reuse it.
    var placeholderIdForLinkedGroupId = <int, int>{};

    var offset = 0;
    for (var placeholder in placeholders) {
      // Add any text that came before this placeholder to the result.
      appendText(text.substring(offset, placeholder.offset));

      var linkedGroupId = placeholder.linkedGroupId;
      int? thisPlaceholderNumber;
      // Override the placeholder number if it's the final one (0) or needs to
      // re-use an existing one for a linked group.
      if (placeholder.isFinal) {
        thisPlaceholderNumber = 0;
      } else if (linkedGroupId != null) {
        thisPlaceholderNumber = placeholderIdForLinkedGroupId[linkedGroupId];
      }

      // Append the placeholder/choices.
      var placeholderText = text.substring(
        placeholder.offset,
        placeholder.offset + placeholder.length,
      );
      // appendChoice handles mapping empty/single suggestions to a normal
      // placeholder but it's faster if we can avoid putting a single item into
      // a set and then detecting it.
      if (placeholder.suggestions == null) {
        thisPlaceholderNumber = appendPlaceholder(
          placeholderText,
          placeholderNumber: thisPlaceholderNumber,
        );
      } else {
        var choices = <String>{
          if (placeholderText.isNotEmpty) placeholderText,
          ...?placeholder.suggestions,
        };
        thisPlaceholderNumber = appendChoice(
          choices,
          placeholderNumber: thisPlaceholderNumber,
        );
      }

      // Track where we're up to.
      offset = placeholder.offset + placeholder.length;

      // Store the placeholder number used for linked groups so it can be reused
      // by subsequent references to it.
      if (linkedGroupId != null) {
        placeholderIdForLinkedGroupId[linkedGroupId] = thisPlaceholderNumber;
      }
    }

    // Add any remaining text that was after the last placeholder.
    appendText(text.substring(offset));
  }
}
