// Copyright 2014 The Flutter Authors. 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:flutter/painting.dart';
import 'package:flutter/services.dart'
    show SpellCheckResults, SpellCheckService, SuggestionSpan, TextEditingValue;

import 'editable_text.dart' show EditableTextContextMenuBuilder;
import 'framework.dart' show immutable;

/// Controls how spell check is performed for text input.
///
/// This configuration determines the [SpellCheckService] used to fetch the
/// [List<SuggestionSpan>] spell check results and the [TextStyle] used to
/// mark misspelled words within text input.
@immutable
class SpellCheckConfiguration {
  /// Creates a configuration that specifies the service and suggestions handler
  /// for spell check.
  const SpellCheckConfiguration({
    this.spellCheckService,
    this.misspelledTextStyle,
    this.spellCheckSuggestionsToolbarBuilder,
  }) : _spellCheckEnabled = true;

  /// Creates a configuration that disables spell check.
  const SpellCheckConfiguration.disabled()
    :  _spellCheckEnabled = false,
       spellCheckService = null,
       spellCheckSuggestionsToolbarBuilder = null,
       misspelledTextStyle = null;

  /// The service used to fetch spell check results for text input.
  final SpellCheckService? spellCheckService;

  /// Style used to indicate misspelled words.
  ///
  /// This is nullable to allow style-specific wrappers of [EditableText]
  /// to infer this, but this must be specified if this configuration is
  /// provided directly to [EditableText] or its construction will fail with an
  /// assertion error.
  final TextStyle? misspelledTextStyle;

  /// Builds the toolbar used to display spell check suggestions for misspelled
  /// words.
  final EditableTextContextMenuBuilder? spellCheckSuggestionsToolbarBuilder;

  final bool _spellCheckEnabled;

  /// Whether or not the configuration should enable or disable spell check.
  bool get spellCheckEnabled => _spellCheckEnabled;

  /// Returns a copy of the current [SpellCheckConfiguration] instance with
  /// specified overrides.
  SpellCheckConfiguration copyWith({
    SpellCheckService? spellCheckService,
    TextStyle? misspelledTextStyle,
    EditableTextContextMenuBuilder? spellCheckSuggestionsToolbarBuilder}) {
    if (!_spellCheckEnabled) {
      // A new configuration should be constructed to enable spell check.
      return const SpellCheckConfiguration.disabled();
    }

    return SpellCheckConfiguration(
      spellCheckService: spellCheckService ?? this.spellCheckService,
      misspelledTextStyle: misspelledTextStyle ?? this.misspelledTextStyle,
      spellCheckSuggestionsToolbarBuilder : spellCheckSuggestionsToolbarBuilder ?? this.spellCheckSuggestionsToolbarBuilder,
    );
  }

  @override
  String toString() {
    return '''
  spell check enabled   : $_spellCheckEnabled
  spell check service   : $spellCheckService
  misspelled text style : $misspelledTextStyle
  spell check suggestions toolbar builder: $spellCheckSuggestionsToolbarBuilder
'''
        .trim();
  }

  @override
  bool operator ==(Object other) {
    if (identical(this, other)) {
        return true;
    }

    return other is SpellCheckConfiguration
      && other.spellCheckService == spellCheckService
      && other.misspelledTextStyle == misspelledTextStyle
      && other.spellCheckSuggestionsToolbarBuilder == spellCheckSuggestionsToolbarBuilder
      && other._spellCheckEnabled == _spellCheckEnabled;
  }

  @override
  int get hashCode => Object.hash(spellCheckService, misspelledTextStyle, spellCheckSuggestionsToolbarBuilder, _spellCheckEnabled);
}

// Methods for displaying spell check results:

/// Adjusts spell check results to correspond to [newText] if the only results
/// that the handler has access to are the [results] corresponding to
/// [resultsText].
///
/// Used in the case where the request for the spell check results of the
/// [newText] is lagging in order to avoid display of incorrect results.
List<SuggestionSpan> _correctSpellCheckResults(
    String newText, String resultsText, List<SuggestionSpan> results) {
  final List<SuggestionSpan> correctedSpellCheckResults = <SuggestionSpan>[];

  int spanPointer = 0;
  int offset = 0;
  int foundIndex;
  int spanLength;
  SuggestionSpan currentSpan;
  SuggestionSpan adjustedSpan;
  String currentSpanText;
  String newSpanText = '';
  bool currentSpanValid = false;
  RegExp regex;

  // Assumes that the order of spans has not been jumbled for optimization
  // purposes, and will only search since the previously found span.
  int searchStart = 0;

  while (spanPointer < results.length) {
    // Try finding SuggestionSpan from old results (currentSpan) in new text.
    currentSpan = results[spanPointer];
    currentSpanText =
        resultsText.substring(currentSpan.range.start, currentSpan.range.end);

    try {
      // currentSpan was found and can be applied to new text.
      newSpanText = newText.substring(
          currentSpan.range.start + offset, currentSpan.range.end + offset);
      currentSpanValid = true;
    } catch (e) {
      // currentSpan is invalid and needs to be searched for in newText.
      currentSpanValid = false;
    }

    if (currentSpanValid && newSpanText == currentSpanText) {
      // currentSpan was found at the same index in new text and old text
      // (resultsText), so apply it to new text by adding it to the list of
      // corrected results.
      searchStart = currentSpan.range.end + offset;
      adjustedSpan = SuggestionSpan(
          TextRange(
              start: currentSpan.range.start + offset, end: searchStart),
          currentSpan.suggestions
      );
      correctedSpellCheckResults.add(adjustedSpan);
    } else {
      // Search for currentSpan in new text and if found, apply it to new text
      // by adding it to the list of corrected results.
      regex = RegExp('\\b$currentSpanText\\b');
      foundIndex = newText.substring(searchStart).indexOf(regex);

      if (foundIndex >= 0) {
        foundIndex += searchStart;
        spanLength = currentSpan.range.end - currentSpan.range.start;
        searchStart = foundIndex + spanLength;
        adjustedSpan = SuggestionSpan(
            TextRange(start: foundIndex, end: searchStart),
            currentSpan.suggestions
        );
        offset = foundIndex - currentSpan.range.start;

        correctedSpellCheckResults.add(adjustedSpan);
      }
    }
    spanPointer++;
  }

  return correctedSpellCheckResults;
}

/// Builds the [TextSpan] tree given the current state of the text input and
/// spell check results.
///
/// The [value] is the current [TextEditingValue] requested to be rendered
/// by a text input widget. The [composingWithinCurrentTextRange] value
/// represents whether or not there is a valid composing region in the
/// [value]. The [style] is the [TextStyle] to render the [value]'s text with,
/// and the [misspelledTextStyle] is the [TextStyle] to render misspelled
/// words within the [value]'s text with. The [spellCheckResults] are the
/// results of spell checking the [value]'s text.
TextSpan buildTextSpanWithSpellCheckSuggestions(
    TextEditingValue value,
    bool composingWithinCurrentTextRange,
    TextStyle? style,
    TextStyle misspelledTextStyle,
    SpellCheckResults spellCheckResults) {
  List<SuggestionSpan> spellCheckResultsSpans =
      spellCheckResults.suggestionSpans;
  final String spellCheckResultsText = spellCheckResults.spellCheckedText;

  if (spellCheckResultsText != value.text) {
    spellCheckResultsSpans = _correctSpellCheckResults(
        value.text, spellCheckResultsText, spellCheckResultsSpans);
  }

  return TextSpan(
      style: style,
      children: _buildSubtreesWithMisspelledWordsIndicated(
          spellCheckResultsSpans,
          value,
          style,
          misspelledTextStyle,
          composingWithinCurrentTextRange
      )
    );
}

/// Builds [TextSpan] subtree for text with misspelled words.
List<TextSpan> _buildSubtreesWithMisspelledWordsIndicated(
    List<SuggestionSpan>? spellCheckSuggestions,
    TextEditingValue value,
    TextStyle? style,
    TextStyle misspelledStyle,
    bool composingWithinCurrentTextRange) {
  final List<TextSpan> tsTreeChildren = <TextSpan>[];

  int textPointer = 0;
  int currSpanPointer = 0;
  int endIndex;
  SuggestionSpan currSpan;
  final String text = value.text;
  final TextRange composingRegion = value.composing;
  final TextStyle composingTextStyle =
      style?.merge(const TextStyle(decoration: TextDecoration.underline)) ??
          const TextStyle(decoration: TextDecoration.underline);
  final TextStyle misspelledJointStyle =
      style?.merge(misspelledStyle) ?? misspelledStyle;
  bool textPointerWithinComposingRegion = false;
  bool currSpanIsComposingRegion = false;

  // Add text interwoven with any misspelled words to the tree.
  if (spellCheckSuggestions != null) {
    while (textPointer < text.length &&
      currSpanPointer < spellCheckSuggestions.length) {
      currSpan = spellCheckSuggestions[currSpanPointer];

      if (currSpan.range.start > textPointer) {
        endIndex = currSpan.range.start < text.length
            ? currSpan.range.start
            : text.length;
        textPointerWithinComposingRegion =
            composingRegion.start >= textPointer &&
                composingRegion.end <= endIndex &&
                !composingWithinCurrentTextRange;

        if (textPointerWithinComposingRegion) {
          _addComposingRegionTextSpans(tsTreeChildren, text, textPointer,
              composingRegion, style, composingTextStyle);
          tsTreeChildren.add(
            TextSpan(
              style: style,
              text: text.substring(composingRegion.end, endIndex)
            )
          );
        } else {
          tsTreeChildren.add(
            TextSpan(
              style: style,
              text: text.substring(textPointer, endIndex)
            )
          );
        }

        textPointer = endIndex;
      } else {
        endIndex =
            currSpan.range.end < text.length ? currSpan.range.end : text.length;
        currSpanIsComposingRegion = textPointer >= composingRegion.start &&
            endIndex <= composingRegion.end &&
            !composingWithinCurrentTextRange;
        tsTreeChildren.add(
          TextSpan(
            style: currSpanIsComposingRegion
                ? composingTextStyle
                : misspelledJointStyle,
            text: text.substring(currSpan.range.start, endIndex)
          )
        );

        textPointer = endIndex;
        currSpanPointer++;
      }
    }
  }

  // Add any remaining text to the tree if applicable.
  if (textPointer < text.length) {
    if (textPointer < composingRegion.start &&
        !composingWithinCurrentTextRange) {
      _addComposingRegionTextSpans(tsTreeChildren, text, textPointer,
          composingRegion, style, composingTextStyle);

      if (composingRegion.end != text.length) {
        tsTreeChildren.add(
          TextSpan(
            style: style,
            text: text.substring(composingRegion.end, text.length)
          )
        );
      }
    } else {
      tsTreeChildren.add(
        TextSpan(
          style: style, text: text.substring(textPointer, text.length)
        )
      );
    }
  }

  return tsTreeChildren;
}

/// Helper method to create [TextSpan] tree children for specified range of
/// text up to and including the composing region.
void _addComposingRegionTextSpans(
    List<TextSpan> treeChildren,
    String text,
    int start,
    TextRange composingRegion,
    TextStyle? style,
    TextStyle composingTextStyle) {
  treeChildren.add(
    TextSpan(
      style: style,
      text: text.substring(start, composingRegion.start)
    )
  );
  treeChildren.add(
    TextSpan(
      style: composingTextStyle,
      text: text.substring(composingRegion.start, composingRegion.end)
    )
  );
}
