// Copyright (c) 2023, 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:analyzer_plugin/protocol/protocol_common.dart';
import 'package:collection/collection.dart';

/// A helper to merge [SourceFileEdit]s that may have been made in multiple
/// steps.
///
/// Edits to be merged must meet some criteria:
///
/// - Multiple [SourceFileEdit]s for the same file are assumed to be in order
///   and each one assumes any earlier [SourceFileEdit] has been applied.
/// - All edits within a [SourceFileEdit] are sorted from highest to lowest
///   offsets so that there is no ambiguity about how one edit may affect the
///   range of another.
class SourceChangeMerger {
  /// A buffer that contains debug information about the re-ordering and merging
  /// of edits.
  ///
  /// This can be used in tests to provide more details about failures.
  final StringBuffer? debugBuffer;

  new({this.debugBuffer});

  /// Merges a set of edits in-place.
  List<SourceFileEdit> merge(List<SourceFileEdit> edits) {
    var results = <SourceFileEdit>[];

    for (var entry in edits.groupListsBy((edit) => edit.file).entries) {
      var file = entry.key;
      var editLists = entry.value;

      // If this file only had a single set of edits, we don't need to do
      // anything.
      if (editLists.length == 1) {
        results.add(editLists.single);
        continue;
      }

      // Flatten all sets into a single set of edits. Because we know all
      // lists and edits can be applied sequentially this is safe, however it
      // can lose the property of all edits being ordered last-to-first which is
      // something we will fix as part of sorting/merging.
      var edits = editLists.expand((edits) => edits.edits).toList();

      debugBuffer?.writeln(file);
      _debugEdits('Original', edits);

      _reorder(edits);
      _debugEdits('Reordered', edits);

      _merge(edits);
      _debugEdits('Merged', edits);

      results.add(
        SourceFileEdit(file, editLists.first.fileStamp, edits: edits),
      );
    }

    return results;
  }

  /// Writes [edits] into [debugBuffer] for debugging.
  void _debugEdits(String editKind, List<SourceEdit> edits) {
    var debugBuffer = this.debugBuffer;
    if (debugBuffer == null) {
      return;
    }

    debugBuffer.writeln('$editKind edits:');
    for (var edit in edits) {
      debugBuffer.writeln('   $edit');
    }
    debugBuffer.writeln();
  }

  /// Merges (in-place) any sequential edits that are overlapping or touching.
  ///
  /// Overlapping/touching edits will be replaced with new edits that have the
  /// same effect as applying the original edits sequentially to the source
  /// string.
  void _merge(List<SourceEdit> edits) {
    for (var i = 0; i < edits.length - 1; i++) {
      // "first" refers to position in the list (and order they were intended to
      // be sequentially applied) and not necessarily offset/source order. Most
      // edits will be in reverse order in the list.
      var first = edits[i];
      var second = edits[i + 1];

      if (second.end < first.offset) {
        // Since we know non-intersecting/touching edits are ordered correctly,
        // the second one ending before the start of the first one means it does
        // not require merging.
        continue;
      }

      // Replace the first edit with a merged version and remove the second.
      edits[i] = _mergeEdits(first, second);
      edits.removeAt(i + 1);
      i--; // Process this one again in case it also overlaps the next one.
    }
  }

  /// Merges [first] and [second] into a new [SourceEdit] that has the same
  /// effect as applying [first] then [second] sequentially to the source
  /// string.
  SourceEdit _mergeEdits(SourceEdit first, SourceEdit second) {
    // "first" refers to position in the list (and order they were intended to
    // be sequentially applied) and not necessarily offset/source order. Most
    // edits will be in reverse order in the list.

    var actualStart = math.min(first.offset, second.offset);
    var actualEnd = math.max(first.end, second.end - first.delta);
    var length = actualEnd - actualStart;

    // The new replacement text is made up of three possible parts:
    // 1. The start of first that is not replaced by second (prefix)
    // 2. The text from second (middle)
    // 3. The end of first that is not replaced by second (suffix)
    var prefix = second.offset > first.offset
        ? first.replacement.substring(0, second.offset - first.offset)
        : '';
    var middle = second.replacement;
    var suffix = second.end < first.offset + first.replacement.length
        ? first.replacement.substring(second.end - first.offset)
        : '';

    return SourceEdit(actualStart, length, '$prefix$middle$suffix');
  }

  /// Re-orders edits (in-place) so that they are from latest offset to earliest
  /// offset except in the case where they touch or intersect.
  ///
  /// Edits that are moved will be replaced by new edits with updated offsets
  /// to preserve the same behaviour when applying edits sequentially.
  ///
  /// Edits that touch or intersect will not be reordered, but will end up
  /// adjacent because other edits will be moved around them.
  void _reorder(List<SourceEdit> edits) {
    // This is essentially an in-place insertion sort, but the edits are mutated
    // as they are swapped to preserve behaviour.
    for (var i = 1; i < edits.length; i++) {
      var current = edits[i];
      var j = i - 1;
      // If the current edit starts after the j edit, we should swap
      // it to bring it earlier.
      while (j >= 0 && current.offset >= edits[j].end + edits[j].delta) {
        // Because edits[j] will no longer be applied before us and we know it
        // would have been applied earlier in the file, we need to adjust our
        // offset by its delta.
        // If we need to change the offset, we must create a new edit and not
        // mutate the original.
        current = SourceEdit(
          current.offset - edits[j].delta,
          current.length,
          current.replacement,
        );
        edits[j + 1] = edits[j];
        j--;
      }
      edits[j + 1] = current;
    }
  }
}

extension on SourceEdit {
  int get delta => replacement.length - length;
}
