/*
 * Copyright 2000-2016 JetBrains s.r.o.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.intellij.codeInsight.template.impl;

import com.intellij.codeInsight.CodeInsightBundle;
import com.intellij.codeInsight.completion.*;
import com.intellij.codeInsight.template.*;
import com.intellij.lang.Language;
import com.intellij.openapi.Disposable;
import com.intellij.openapi.application.ApplicationManager;
import com.intellij.openapi.command.CommandProcessor;
import com.intellij.openapi.command.WriteCommandAction;
import com.intellij.openapi.editor.*;
import com.intellij.openapi.editor.event.EditorFactoryAdapter;
import com.intellij.openapi.editor.event.EditorFactoryEvent;
import com.intellij.openapi.editor.event.EditorFactoryListener;
import com.intellij.openapi.extensions.Extensions;
import com.intellij.openapi.fileEditor.FileDocumentManager;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.util.*;
import com.intellij.psi.PsiDocumentManager;
import com.intellij.psi.PsiFile;
import com.intellij.psi.util.CachedValueProvider;
import com.intellij.psi.util.CachedValuesManager;
import com.intellij.psi.util.PsiUtilBase;
import com.intellij.psi.util.PsiUtilCore;
import com.intellij.util.PairProcessor;
import com.intellij.util.containers.ConcurrentFactoryMap;
import com.intellij.util.containers.ContainerUtil;
import com.intellij.util.containers.HashMap;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.TestOnly;

import java.util.*;

public class TemplateManagerImpl extends TemplateManager implements Disposable {
  private static final TemplateContextType[] ourContextTypes = Extensions.getExtensions(TemplateContextType.EP_NAME);
  private final Project myProject;
  private boolean myTemplateTesting;

  private static final Key<TemplateState> TEMPLATE_STATE_KEY = Key.create("TEMPLATE_STATE_KEY");

  public TemplateManagerImpl(Project project) {
    myProject = project;
    final EditorFactoryListener myEditorFactoryListener = new EditorFactoryAdapter() {
      @Override
      public void editorReleased(@NotNull EditorFactoryEvent event) {
        Editor editor = event.getEditor();
        if (editor.getProject() != null && editor.getProject() != myProject) return;
        if (myProject.isDisposed() || !myProject.isOpen()) return;
        TemplateState state = getTemplateState(editor);
        if (state != null) {
          state.gotoEnd();
        }
        clearTemplateState(editor);
      }
    };
    EditorFactory.getInstance().addEditorFactoryListener(myEditorFactoryListener, myProject);
  }

  @Override
  public void dispose() {

  }

  /**
   * @deprecated Use {@link #setTemplateTesting(Project, Disposable)} instead
   */
  @TestOnly
  @Deprecated
  public void setTemplateTesting(final boolean templateTesting) {
    myTemplateTesting = templateTesting;
  }

  @TestOnly
  public static void setTemplateTesting(Project project, Disposable parentDisposable) {
    final TemplateManagerImpl instance = (TemplateManagerImpl)getInstance(project);
    instance.myTemplateTesting = true;
    Disposer.register(parentDisposable, new Disposable() {
      @Override
      public void dispose() {
        instance.myTemplateTesting = false;
      }
    });
  }

  private static void disposeState(@NotNull TemplateState state) {
    Disposer.dispose(state);
  }

  @Override
  public Template createTemplate(@NotNull String key, String group) {
    return new TemplateImpl(key, group);
  }

  @Override
  public Template createTemplate(@NotNull String key, String group, String text) {
    return new TemplateImpl(key, text, group);
  }

  @Nullable
  public static TemplateState getTemplateState(@NotNull Editor editor) {
    TemplateState templateState = editor.getUserData(TEMPLATE_STATE_KEY);
    if (templateState != null && templateState.isDisposed()) {
      editor.putUserData(TEMPLATE_STATE_KEY, null);
      return null;
    }
    return templateState;
  }

  static void clearTemplateState(@NotNull Editor editor) {
    TemplateState prevState = getTemplateState(editor);
    if (prevState != null) {
      disposeState(prevState);
    }
    editor.putUserData(TEMPLATE_STATE_KEY, null);
  }

  private TemplateState initTemplateState(@NotNull Editor editor) {
    clearTemplateState(editor);
    TemplateState state = new TemplateState(myProject, editor);
    Disposer.register(this, state);
    editor.putUserData(TEMPLATE_STATE_KEY, state);
    return state;
  }

  @Override
  public boolean startTemplate(@NotNull Editor editor, char shortcutChar) {
    Runnable runnable = prepareTemplate(editor, shortcutChar, null);
    if (runnable != null) {
      if (!FileDocumentManager.getInstance().requestWriting(editor.getDocument(), myProject)) {
        return false;
      }

      PsiDocumentManager.getInstance(myProject).commitDocument(editor.getDocument());
      WriteCommandAction.runWriteCommandAction(myProject, runnable);
    }
    return runnable != null;
  }

  @Override
  public void startTemplate(@NotNull final Editor editor, @NotNull Template template) {
    startTemplate(editor, template, null);
  }

  @Override
  public void startTemplate(@NotNull Editor editor, String selectionString, @NotNull Template template) {
    startTemplate(editor, selectionString, template, true, null, null, null);
  }

  @Override
  public void startTemplate(@NotNull Editor editor,
                            @NotNull Template template,
                            TemplateEditingListener listener,
                            final PairProcessor<String, String> processor) {
    startTemplate(editor, null, template, true, listener, processor, null);
  }

  private void startTemplate(final Editor editor,
                             final String selectionString,
                             final Template template,
                             boolean inSeparateCommand,
                             TemplateEditingListener listener,
                             final PairProcessor<String, String> processor,
                             final Map<String, String> predefinedVarValues) {
    final TemplateState templateState = initTemplateState(editor);

    //noinspection unchecked
    templateState.getProperties().put(ExpressionContext.SELECTION, selectionString);

    if (listener != null) {
      templateState.addTemplateStateListener(listener);
    }
    Runnable r = () -> {
      if (selectionString != null) {
        ApplicationManager.getApplication().runWriteAction(() -> EditorModificationUtil.deleteSelectedText(editor));
      }
      else {
        editor.getSelectionModel().removeSelection();
      }
      templateState.start((TemplateImpl)template, processor, predefinedVarValues);
    };
    if (inSeparateCommand) {
      CommandProcessor.getInstance().executeCommand(myProject, r, CodeInsightBundle.message("insert.code.template.command"), null);
    }
    else {
      r.run();
    }

    if (shouldSkipInTests()) {
      if (!templateState.isFinished()) templateState.gotoEnd(false);
    }
  }

  public boolean shouldSkipInTests() {
    return ApplicationManager.getApplication().isUnitTestMode() && !myTemplateTesting;
  }

  @Override
  public void startTemplate(@NotNull final Editor editor, @NotNull final Template template, TemplateEditingListener listener) {
    startTemplate(editor, null, template, true, listener, null, null);
  }

  @Override
  public void startTemplate(@NotNull final Editor editor,
                            @NotNull final Template template,
                            boolean inSeparateCommand,
                            Map<String, String> predefinedVarValues,
                            TemplateEditingListener listener) {
    startTemplate(editor, null, template, inSeparateCommand, listener, null, predefinedVarValues);
  }

  private static int passArgumentBack(CharSequence text, int caretOffset) {
    int i = caretOffset - 1;
    for (; i >= 0; i--) {
      char c = text.charAt(i);
      if (isDelimiter(c)) {
        break;
      }
    }
    return i + 1;
  }

  private static boolean isDelimiter(char c) {
    return !Character.isJavaIdentifierPart(c);
  }

  private static <T, U> void addToMap(@NotNull Map<T, U> map, @NotNull Collection<? extends T> keys, U value) {
    for (T key : keys) {
      map.put(key, value);
    }
  }

  private static boolean containsTemplateStartingBefore(Map<TemplateImpl, String> template2argument,
                                                        int offset,
                                                        int caretOffset,
                                                        CharSequence text) {
    for (TemplateImpl template : template2argument.keySet()) {
      String argument = template2argument.get(template);
      int templateStart = getTemplateStart(template, argument, caretOffset, text);
      if (templateStart < offset) {
        return true;
      }
    }
    return false;
  }

  @Nullable
  public Runnable prepareTemplate(final Editor editor, char shortcutChar, @Nullable final PairProcessor<String, String> processor) {
    if (editor.getSelectionModel().hasSelection()) {
      return null;
    }

    PsiFile file = PsiUtilBase.getPsiFileInEditor(editor, myProject);
    if (file == null) return null;

    Map<TemplateImpl, String> template2argument = findMatchingTemplates(file, editor, shortcutChar, TemplateSettings.getInstance());

    List<CustomLiveTemplate> customCandidates = ContainerUtil.findAll(CustomLiveTemplate.EP_NAME.getExtensions(), customLiveTemplate ->
      shortcutChar == customLiveTemplate.getShortcut() &&
      (editor.getCaretModel().getCaretCount() <= 1 || supportsMultiCaretMode(customLiveTemplate)));
    if (!customCandidates.isEmpty()) {
      int caretOffset = editor.getCaretModel().getOffset();
      PsiFile fileCopy = insertDummyIdentifierWithCache(file, caretOffset, caretOffset, "").getFile();
      Document document = editor.getDocument();

      for (final CustomLiveTemplate customLiveTemplate : customCandidates) {
        if (isApplicable(customLiveTemplate, editor, fileCopy)) {
          final String key = customLiveTemplate.computeTemplateKey(new CustomTemplateCallback(editor, fileCopy));
          if (key != null) {
            int offsetBeforeKey = caretOffset - key.length();
            CharSequence text = document.getImmutableCharSequence();
            if (template2argument == null || !containsTemplateStartingBefore(template2argument, offsetBeforeKey, caretOffset, text)) {
              return () -> customLiveTemplate.expand(key, new CustomTemplateCallback(editor, file));
            }
          }
        }
      }
    }

    return startNonCustomTemplates(template2argument, editor, processor);
  }

  private static boolean supportsMultiCaretMode(CustomLiveTemplate customLiveTemplate) {
    return !(customLiveTemplate instanceof CustomLiveTemplateBase) || ((CustomLiveTemplateBase)customLiveTemplate).supportsMultiCaret();
  }

  public static boolean isApplicable(@NotNull CustomLiveTemplate customLiveTemplate,
                                     @NotNull Editor editor,
                                     @NotNull PsiFile file) {
    return isApplicable(customLiveTemplate, editor, file, false);
  }

  public static boolean isApplicable(@NotNull CustomLiveTemplate customLiveTemplate,
                                     @NotNull Editor editor,
                                     @NotNull PsiFile file, boolean wrapping) {
    return customLiveTemplate.isApplicable(file, CustomTemplateCallback.getOffset(editor), wrapping);
  }

  private static int getArgumentOffset(int caretOffset, String argument, CharSequence text) {
    int argumentOffset = caretOffset - argument.length();
    if (argumentOffset > 0 && text.charAt(argumentOffset - 1) == ' ') {
      if (argumentOffset - 2 >= 0 && Character.isJavaIdentifierPart(text.charAt(argumentOffset - 2))) {
        argumentOffset--;
      }
    }
    return argumentOffset;
  }

  private static int getTemplateStart(TemplateImpl template, String argument, int caretOffset, CharSequence text) {
    int templateStart;
    if (argument == null) {
      templateStart = caretOffset - template.getKey().length();
    }
    else {
      int argOffset = getArgumentOffset(caretOffset, argument, text);
      templateStart = argOffset - template.getKey().length();
    }
    return templateStart;
  }

  public Map<TemplateImpl, String> findMatchingTemplates(final PsiFile file,
                                                          Editor editor,
                                                          @Nullable Character shortcutChar,
                                                          TemplateSettings templateSettings) {
    final Document document = editor.getDocument();
    CharSequence text = document.getCharsSequence();
    final int caretOffset = editor.getCaretModel().getOffset();

    List<TemplateImpl> candidatesWithoutArgument = findMatchingTemplates(text, caretOffset, shortcutChar, templateSettings, false);

    int argumentOffset = passArgumentBack(text, caretOffset);
    String argument = null;
    if (argumentOffset >= 0) {
      argument = text.subSequence(argumentOffset, caretOffset).toString();
      if (argumentOffset > 0 && text.charAt(argumentOffset - 1) == ' ') {
        if (argumentOffset - 2 >= 0 && Character.isJavaIdentifierPart(text.charAt(argumentOffset - 2))) {
          argumentOffset--;
        }
      }
    }
    List<TemplateImpl> candidatesWithArgument = findMatchingTemplates(text, argumentOffset, shortcutChar, templateSettings, true);

    if (candidatesWithArgument.isEmpty() && candidatesWithoutArgument.isEmpty()) {
      return null;
    }

    candidatesWithoutArgument = filterApplicableCandidates(file, caretOffset, candidatesWithoutArgument);
    candidatesWithArgument = filterApplicableCandidates(file, argumentOffset, candidatesWithArgument);
    Map<TemplateImpl, String> candidate2Argument = new HashMap<>();
    addToMap(candidate2Argument, candidatesWithoutArgument, null);
    addToMap(candidate2Argument, candidatesWithArgument, argument);
    return candidate2Argument;
  }

  @Nullable
  public Runnable startNonCustomTemplates(final Map<TemplateImpl, String> template2argument,
                                          final Editor editor,
                                          @Nullable final PairProcessor<String, String> processor) {
    final int caretOffset = editor.getCaretModel().getOffset();
    final Document document = editor.getDocument();
    final CharSequence text = document.getCharsSequence();

    if (template2argument == null || template2argument.isEmpty()) {
      return null;
    }

    return () -> {
      if (template2argument.size() == 1) {
        TemplateImpl template = template2argument.keySet().iterator().next();
        String argument = template2argument.get(template);
        int templateStart = getTemplateStart(template, argument, caretOffset, text);
        startTemplateWithPrefix(editor, template, templateStart, processor, argument);
      }
      else {
        ListTemplatesHandler.showTemplatesLookup(myProject, editor, template2argument);
      }
    };
  }

  public static List<TemplateImpl> findMatchingTemplates(CharSequence text,
                                                         int caretOffset,
                                                         @Nullable Character shortcutChar,
                                                         TemplateSettings settings,
                                                         boolean hasArgument) {
    List<TemplateImpl> candidates = Collections.emptyList();
    for (int i = settings.getMaxKeyLength(); i >= 1; i--) {
      int wordStart = caretOffset - i;
      if (wordStart < 0) {
        continue;
      }
      String key = text.subSequence(wordStart, caretOffset).toString();
      if (Character.isJavaIdentifierStart(key.charAt(0))) {
        if (wordStart > 0 && Character.isJavaIdentifierPart(text.charAt(wordStart - 1))) {
          continue;
        }
      }

      candidates = settings.collectMatchingCandidates(key, shortcutChar, hasArgument);
      if (!candidates.isEmpty()) break;
    }
    return candidates;
  }

  public void startTemplateWithPrefix(final Editor editor,
                                      final TemplateImpl template,
                                      @Nullable final PairProcessor<String, String> processor,
                                      @Nullable String argument) {
    final int caretOffset = editor.getCaretModel().getOffset();
    String key = template.getKey();
    int startOffset = caretOffset - key.length();
    if (argument != null) {
      if (!isDelimiter(key.charAt(key.length() - 1))) {
        // pass space
        startOffset--;
      }
      startOffset -= argument.length();
    }
    startTemplateWithPrefix(editor, template, startOffset, processor, argument);
  }

  public void startTemplateWithPrefix(final Editor editor,
                                      final TemplateImpl template,
                                      final int templateStart,
                                      @Nullable final PairProcessor<String, String> processor,
                                      @Nullable final String argument) {
    final int caretOffset = editor.getCaretModel().getOffset();
    final TemplateState templateState = initTemplateState(editor);
    CommandProcessor commandProcessor = CommandProcessor.getInstance();
    commandProcessor.executeCommand(myProject, () -> {
      editor.getDocument().deleteString(templateStart, caretOffset);
      editor.getCaretModel().moveToOffset(templateStart);
      editor.getScrollingModel().scrollToCaret(ScrollType.RELATIVE);
      editor.getSelectionModel().removeSelection();
      Map<String, String> predefinedVarValues = null;
      if (argument != null) {
        predefinedVarValues = new HashMap<>();
        predefinedVarValues.put(TemplateImpl.ARG, argument);
      }
      templateState.start(template, processor, predefinedVarValues);
    }, CodeInsightBundle.message("insert.code.template.command"), null);
  }

  private static List<TemplateImpl> filterApplicableCandidates(PsiFile file, int caretOffset, List<TemplateImpl> candidates) {
    if (candidates.isEmpty()) {
      return candidates;
    }

    PsiFile copy = insertDummyIdentifierWithCache(file, caretOffset, caretOffset, CompletionUtil.DUMMY_IDENTIFIER_TRIMMED).getFile();

    List<TemplateImpl> result = new ArrayList<>();
    for (TemplateImpl candidate : candidates) {
      if (isApplicable(copy, caretOffset - candidate.getKey().length(), candidate)) {
        result.add(candidate);
      }
    }
    return result;
  }

  private static List<TemplateContextType> getBases(TemplateContextType type) {
    ArrayList<TemplateContextType> list = new ArrayList<>();
    while (true) {
      type = type.getBaseContextType();
      if (type == null) return list;
      list.add(type);
    }
  }

  private static Set<TemplateContextType> getDirectlyApplicableContextTypes(@NotNull PsiFile file, int offset) {
    LinkedHashSet<TemplateContextType> set = new LinkedHashSet<>();
    LinkedList<TemplateContextType> contexts = buildOrderedContextTypes();
    for (TemplateContextType contextType : contexts) {
      if (contextType.isInContext(file, offset)) {
        set.add(contextType);
      }
    }

    removeBases:
    while (true) {
      for (TemplateContextType type : set) {
        if (set.removeAll(getBases(type))) {
          continue removeBases;
        }
      }

      return set;
    }
  }

  private static LinkedList<TemplateContextType> buildOrderedContextTypes() {
    final TemplateContextType[] typeCollection = getAllContextTypes();
    LinkedList<TemplateContextType> userDefinedExtensionsFirst = new LinkedList<>();
    for (TemplateContextType contextType : typeCollection) {
      if (contextType.getClass().getName().startsWith(Template.class.getPackage().getName())) {
        userDefinedExtensionsFirst.addLast(contextType);
      }
      else {
        userDefinedExtensionsFirst.addFirst(contextType);
      }
    }
    return userDefinedExtensionsFirst;
  }

  public static TemplateContextType[] getAllContextTypes() {
    return ourContextTypes;
  }

  @Override
  @Nullable
  public Template getActiveTemplate(@NotNull Editor editor) {
    final TemplateState templateState = getTemplateState(editor);
    return templateState != null ? templateState.getTemplate() : null;
  }

  @Override
  public boolean finishTemplate(@NotNull Editor editor) {
    TemplateState state = getTemplateState(editor);
    if (state != null) {
      state.gotoEnd();
      return true;
    }
    return false;
  }

  public static boolean isApplicable(PsiFile file, int offset, TemplateImpl template) {
    return isApplicable(template, getApplicableContextTypes(file, offset));
  }

  public static boolean isApplicable(TemplateImpl template, Set<TemplateContextType> contextTypes) {
    for (TemplateContextType type : contextTypes) {
      if (template.getTemplateContext().isEnabled(type)) {
        return true;
      }
    }
    return false;
  }

  public static List<TemplateImpl> listApplicableTemplates(PsiFile file, int offset, boolean selectionOnly) {
    Set<TemplateContextType> contextTypes = getApplicableContextTypes(file, offset);

    final ArrayList<TemplateImpl> result = ContainerUtil.newArrayList();
    for (final TemplateImpl template : TemplateSettings.getInstance().getTemplates()) {
      if (!template.isDeactivated() && (!selectionOnly || template.isSelectionTemplate()) && isApplicable(template, contextTypes)) {
        result.add(template);
      }
    }
    return result;
  }
  
  public static List<TemplateImpl> listApplicableTemplateWithInsertingDummyIdentifier(Editor editor, PsiFile file, boolean selectionOnly) {
    int startOffset = editor.getSelectionModel().getSelectionStart();
    int endOffset = editor.getSelectionModel().getSelectionEnd();
    OffsetsInFile offsets = insertDummyIdentifierWithCache(file, startOffset, endOffset, CompletionUtil.DUMMY_IDENTIFIER_TRIMMED);
    return listApplicableTemplates(offsets.getFile(), getStartOffset(offsets), selectionOnly);
  }

  public static List<CustomLiveTemplate> listApplicableCustomTemplates(@NotNull Editor editor, @NotNull PsiFile file, boolean selectionOnly) {
    List<CustomLiveTemplate> result = new ArrayList<>();
    for (CustomLiveTemplate template : CustomLiveTemplate.EP_NAME.getExtensions()) {
      if ((!selectionOnly || template.supportsWrapping()) && isApplicable(template, editor, file, selectionOnly)) {
        result.add(template);
      }
    }
    return result;
  }

  public static Set<TemplateContextType> getApplicableContextTypes(PsiFile file, int offset) {
    Set<TemplateContextType> result = getDirectlyApplicableContextTypes(file, offset);

    Language baseLanguage = file.getViewProvider().getBaseLanguage();
    if (baseLanguage != file.getLanguage()) {
      PsiFile basePsi = file.getViewProvider().getPsi(baseLanguage);
      if (basePsi != null) {
        result.addAll(getDirectlyApplicableContextTypes(basePsi, offset));
      }
    }

    // if we have, for example, a Ruby fragment in RHTML selected with its exact bounds, the file language and the base
    // language will be ERb, so we won't match HTML templates for it. but they're actually valid
    Language languageAtOffset = PsiUtilCore.getLanguageAtOffset(file, offset);
    if (languageAtOffset != file.getLanguage() && languageAtOffset != baseLanguage) {
      PsiFile basePsi = file.getViewProvider().getPsi(languageAtOffset);
      if (basePsi != null) {
        result.addAll(getDirectlyApplicableContextTypes(basePsi, offset));
      }
    }

    return result;
  }

  private static final OffsetKey START_OFFSET = OffsetKey.create("start", false);
  private static final OffsetKey END_OFFSET = OffsetKey.create("end", true);

  private static int getStartOffset(OffsetsInFile offsets) {
    return offsets.getOffsets().getOffset(START_OFFSET);
  }

  private static int getEndOffset(OffsetsInFile offsets) {
    return offsets.getOffsets().getOffset(END_OFFSET);
  }

  private static OffsetsInFile insertDummyIdentifierWithCache(PsiFile file, int startOffset, int endOffset, String replacement) {
    ProperTextRange editRange = ProperTextRange.create(startOffset, endOffset);
    assertRangeWithinDocument(editRange, file.getViewProvider().getDocument());

    ConcurrentFactoryMap<Pair<ProperTextRange, String>, OffsetsInFile> map = CachedValuesManager.getCachedValue(file, () ->
      CachedValueProvider.Result.create(
        ConcurrentFactoryMap.createConcurrentMap(
          key -> copyWithDummyIdentifier(new OffsetsInFile(file), key.first.getStartOffset(), key.first.getEndOffset(), key.second)),
        file, file.getViewProvider().getDocument()));
    return map.get(Pair.create(editRange, replacement));
  }

  private static void assertRangeWithinDocument(ProperTextRange editRange, Document document) {
    TextRange docRange = TextRange.from(0, document.getTextLength());
    assert docRange.contains(editRange) : docRange + " doesn't contain " + editRange;
  }

  @NotNull
  public static OffsetsInFile copyWithDummyIdentifier(OffsetsInFile offsetMap, int startOffset, int endOffset, String replacement) {
    offsetMap.getOffsets().addOffset(START_OFFSET, startOffset);
    offsetMap.getOffsets().addOffset(END_OFFSET, endOffset);

    Document document = offsetMap.getFile().getViewProvider().getDocument();
    assert document != null;
    if (replacement.isEmpty() && startOffset == endOffset &&
        PsiDocumentManager.getInstance(offsetMap.getFile().getProject()).isCommitted(document)) {
      return offsetMap;
    }

    OffsetsInFile hostOffsets = offsetMap.toTopLevelFile();
    OffsetsInFile hostCopy = hostOffsets.copyWithReplacement(getStartOffset(hostOffsets), getEndOffset(hostOffsets), replacement);
    return hostCopy.toInjectedIfAny(getStartOffset(hostCopy));
  }
}
