import { ACTION, ACTION_SUBJECT, EVENT_TYPE } from '@atlaskit/editor-common/analytics';
import { logException } from '@atlaskit/editor-common/monitoring';
import { SafePlugin } from '@atlaskit/editor-common/safe-plugin';
import { keydownHandler } from '@atlaskit/editor-prosemirror/keymap';
import { PluginKey } from '@atlaskit/editor-prosemirror/state';
import { DecorationSet } from '@atlaskit/editor-prosemirror/view';
import { isAutocompleteDebugEnabled } from './debug-mode';
import { createGhostTextDecorationSet } from './ghost-text-decoration';
import { createLocalSlowLaneClient } from './local-slow-lane-client';
import { createSlowLaneClient, setDefaultSlowLaneClient, isWordBoundary } from './slow-lane-client';
import { predict, loadDefaultVocabulary, loadVectorsAsync, incrementSessionFreq, ingestDocumentPage } from './text-predictor';
export const autocompletePluginKey = new PluginKey('autocomplete');
const DEBOUNCE_MS = 150;
const NETWORK_SLOW_LANE_DEBOUNCE_MS = 300;
const LOCAL_SLOW_LANE_DEBOUNCE_MS = 100;
const CONTEXT_REFRESH_THROTTLE_MS = 1000;
// Caps the dedup Set so long editing sessions don't retain every distinct
// version of the (potentially hundreds-of-KB) page content for the plugin's
// lifetime. Eviction is FIFO; a re-ingest of an evicted text is harmless.
const MAX_INGESTED_CONTEXT_TEXTS = 50;
// Caps how many times the word-boundary path will retry getContext() while the
// parent comment is still missing. Combined with the 1s throttle this gives a
// ~5s window to cover a still-loading comment thread, then stops permanently so
// non-comment editors (where parentCommentContent never arrives) don't refetch
// on every word boundary for the plugin's lifetime.
const MAX_CONTEXT_REFRESH_ATTEMPTS = 5;

// eslint-disable-next-line require-unicode-regexp
const TRAILING_NON_BOUNDARY_TOKEN_REGEX = /([^\s.,;:!?]*)$/;
// eslint-disable-next-line require-unicode-regexp
const WORD_BOUNDARY_CHARS_REGEX = /[\s.,;:!?]/;
const hasDestroy = client => 'destroy' in client && typeof client.destroy === 'function';
const createInitialState = () => ({
  ghostText: '',
  ghostPosition: -1,
  decorationSet: DecorationSet.empty
});

/**
 * Extract text content before the cursor from the current document.
 * Returns the last ~200 characters for context.
 */
const getTextBeforeCursor = state => {
  const {
    $from
  } = state.selection;
  const maxChars = 200;
  const blockNode = $from.parent;
  const offsetInBlock = $from.parentOffset;
  const blockText = blockNode.textContent.slice(0, offsetInBlock);
  if (blockText.length >= maxChars) {
    return blockText.slice(-maxChars);
  }
  let fullText = blockText;

  // Walk backwards through previous blocks until we have enough context.
  let depth = $from.depth - 1;
  while (fullText.length < maxChars && depth >= 0) {
    const parentNode = $from.node(depth);
    const indexInParent = $from.index(depth);
    for (let i = indexInParent - 1; i >= 0 && fullText.length < maxChars; i--) {
      const sibling = parentNode.child(i);
      const siblingText = sibling.textContent;
      fullText = siblingText + '\n' + fullText;
    }
    depth--;
  }
  return fullText.slice(-maxChars);
};

/**
 * Set the autocomplete state via a transaction metadata.
 */
const setAutocompleteMeta = (tr, meta) => {
  return tr.setMeta(autocompletePluginKey, meta);
};

/**
 * Apply a ghost text suggestion to the editor state.
 */
let lastShownGhostText = '';
const showGhostText = (view, text, position) => {
  try {
    const {
      state,
      dispatch
    } = view;
    const decorationSet = createGhostTextDecorationSet(state, position, text);
    const tr = setAutocompleteMeta(state.tr, {
      ghostText: text,
      ghostPosition: position,
      decorationSet
    });
    dispatch(tr);
  } catch (error) {
    logException(error, {
      location: 'editor-plugin-autocomplete/showGhostText'
    });
  }
};

/**
 * Clear the current ghost text from the editor.
 */
const clearGhostText = (state, dispatch) => {
  const pluginState = autocompletePluginKey.getState(state);
  if (!pluginState || !pluginState.ghostText) {
    return false;
  }
  if (dispatch) {
    try {
      const tr = setAutocompleteMeta(state.tr, {
        ghostText: '',
        ghostPosition: -1,
        decorationSet: DecorationSet.empty
      });
      dispatch(tr);
    } catch (error) {
      logException(error, {
        location: 'editor-plugin-autocomplete/clearGhostText'
      });
      return false;
    }
  }
  return true;
};

/**
 * Accept the current ghost text suggestion and insert it into the document.
 */
const acceptGhostText = (state, dispatch) => {
  const pluginState = autocompletePluginKey.getState(state);
  if (!pluginState || !pluginState.ghostText) {
    return false;
  }
  if (dispatch) {
    try {
      const {
        ghostText,
        ghostPosition
      } = pluginState;
      let tr = state.tr.insertText(ghostText, ghostPosition);
      tr = setAutocompleteMeta(tr, {
        ghostText: '',
        ghostPosition: -1,
        decorationSet: DecorationSet.empty
      });
      dispatch(tr);
    } catch (error) {
      logException(error, {
        location: 'editor-plugin-autocomplete/acceptGhostText'
      });
      return false;
    }
  }
  return true;
};

/**
 * Context provided to the autocomplete plugin on first editor focus.
 * Text fields are selectively ingested to boost word-frequency scoring for
 * predictions, giving words already present in the document/thread an L1
 * priority boost.
 */

/**
 * Build the text payload for the slow-lane request by prepending any available
 * comment context ahead of the live document text. This gives the backend
 * model richer context about the thread the user is writing in.
 */
const buildSlowLaneText = (docText, context) => {
  var _context$siblingComme, _context$siblingComme2, _context$siblingComme3;
  const lines = [];
  if (context !== null && context !== void 0 && context.parentCommentContent) {
    lines.push(`comment: ${context.parentCommentContent}`);
  }
  context === null || context === void 0 ? void 0 : (_context$siblingComme = context.siblingCommentsContents) === null || _context$siblingComme === void 0 ? void 0 : _context$siblingComme.forEach((sibling, index) => {
    lines.push(`reply ${index + 1}: ${sibling}`);
  });
  const nextReplyNumber = ((_context$siblingComme2 = context === null || context === void 0 ? void 0 : (_context$siblingComme3 = context.siblingCommentsContents) === null || _context$siblingComme3 === void 0 ? void 0 : _context$siblingComme3.length) !== null && _context$siblingComme2 !== void 0 ? _context$siblingComme2 : 0) + 1;
  lines.push(`reply ${nextReplyNumber}: ${docText}`);
  return lines.join('\n');
};
const createMidWordCharacterRegex = () => {
  try {
    // string-built regex avoids TS1501 under the package's ES5 build target
    // Ignored via go/ees019
    // eslint-disable-next-line e18e/prefer-static-regex
    return new RegExp('[\\p{L}\\p{N}_]', 'u');
  } catch {
    return null;
  }
};
const midWordCharacterRegex = createMidWordCharacterRegex();
const isAsciiWordCharacter = char => {
  if (char === '_') {
    return true;
  }
  const charCode = char.charCodeAt(0);
  return charCode >= 48 && charCode <= 57 || charCode >= 65 && charCode <= 90 || charCode >= 97 && charCode <= 122;
};
const isMidWordCharacter = char => {
  if (midWordCharacterRegex) {
    return midWordCharacterRegex.test(char);
  }
  if (isAsciiWordCharacter(char)) {
    return true;
  }
  return char.toLocaleLowerCase() !== char.toLocaleUpperCase();
};
const getLeadingTextCharacter = text => {
  const firstCodePoint = text === null || text === void 0 ? void 0 : text.codePointAt(0);
  if (firstCodePoint === undefined) {
    return undefined;
  }
  return String.fromCodePoint(firstCodePoint);
};
const getTypedLengthForPrediction = textBefore => {
  var _textBefore$match$, _textBefore$match;
  if (isWordBoundary(textBefore)) {
    return 0;
  }
  const trailingToken = (_textBefore$match$ = (_textBefore$match = textBefore.match(TRAILING_NON_BOUNDARY_TOKEN_REGEX)) === null || _textBefore$match === void 0 ? void 0 : _textBefore$match[1]) !== null && _textBefore$match$ !== void 0 ? _textBefore$match$ : '';
  return trailingToken.length;
};
const isEnglishLocale = locale => {
  if (!locale) {
    return false;
  }
  return locale.toLowerCase().startsWith('en');
};
export const createAutocompletePlugin = (options, api) => {
  var _options$locale;
  const locale = (_options$locale = options === null || options === void 0 ? void 0 : options.locale) !== null && _options$locale !== void 0 ? _options$locale : typeof navigator !== 'undefined' ? navigator.language : undefined;
  const isAutocompleteEnabled = isEnglishLocale(locale);
  let debounceTimer = null;
  let hasIngestedPage = false;
  let resolvedContext;
  /**
   * Kept in sync with the live EditorView so the async getContext() promise
   * can re-trigger a slow-lane update the moment context arrives, even if the
   * user has already typed several words before the promise resolved.
   */
  let currentView = null;
  let unsubscribeFromContextUpdates;
  /**
   * Set after accepting a suggestion so the next doc-change update
   * skips scheduling a new prediction for the just-inserted text.
   * Scoped to the factory so multiple editor instances don't share state.
   */
  let justAccepted = false;

  /**
   * Stores the text-before-cursor snapshot at the moment the user dismissed
   * a suggestion via Escape. While the context remains identical, we suppress
   * re-showing the same suggestion. Resets to null as soon as the text changes.
   */
  let dismissedContext = null;
  let lastSuggestionTypedLength = 0;
  let lastSuggestionLength = 0;

  /**
   * cold  → no slow-lane vector received yet; frequency-only trie scoring
   * server → server slow-lane API returned the context vector
   * localLlm → on-device WebGPU/MLC model returned the context vector
   */
  const getCompletionSource = () => {
    if (!slowLaneClient.getContextVector()) {
      return 'cold';
    }
    return options !== null && options !== void 0 && options.useLocalModel ? 'localLlm' : 'server';
  };
  const fireSuggestionDismissedAnalytics = reason => {
    var _api$analytics;
    api === null || api === void 0 ? void 0 : (_api$analytics = api.analytics) === null || _api$analytics === void 0 ? void 0 : _api$analytics.actions.fireAnalyticsEvent({
      action: ACTION.SUGGESTION_DISMISSED,
      actionSubject: ACTION_SUBJECT.CONTEXTUAL_TYPEAHEAD,
      eventType: EVENT_TYPE.TRACK,
      attributes: {
        completionSource: getCompletionSource(),
        reason
      }
    });
  };
  const fireLocalModelLoadedAnalytics = info => {
    var _api$analytics2;
    api === null || api === void 0 ? void 0 : (_api$analytics2 = api.analytics) === null || _api$analytics2 === void 0 ? void 0 : _api$analytics2.actions.fireAnalyticsEvent({
      action: ACTION.LOCAL_MODEL_LOADED,
      actionSubject: ACTION_SUBJECT.CONTEXTUAL_TYPEAHEAD,
      eventType: EVENT_TYPE.TRACK,
      attributes: {
        modelId: info.modelId,
        embeddingModelId: info.embeddingModelId,
        loadDurationMs: info.loadDurationMs,
        gpuVendor: info.capabilities.vendor,
        gpuArchitecture: info.capabilities.architecture
      }
    });
  };
  const fireLocalModelLoadFailedAnalytics = error => {
    var _api$analytics3;
    const {
      capabilities
    } = error;
    api === null || api === void 0 ? void 0 : (_api$analytics3 = api.analytics) === null || _api$analytics3 === void 0 ? void 0 : _api$analytics3.actions.fireAnalyticsEvent({
      action: ACTION.LOCAL_MODEL_LOAD_FAILED,
      actionSubject: ACTION_SUBJECT.CONTEXTUAL_TYPEAHEAD,
      eventType: EVENT_TYPE.TRACK,
      attributes: {
        reason: error.reason,
        message: error.message,
        modelId: error.modelId,
        embeddingModelId: error.embeddingModelId,
        webgpuAvailable: capabilities.available,
        adapterAvailable: capabilities.adapterAvailable,
        shaderF16Supported: capabilities.shaderF16Supported,
        maxBufferSizeMB: capabilities.maxBufferSizeMB,
        maxStorageBufferBindingSizeMB: capabilities.maxStorageBufferBindingSizeMB,
        gpuVendor: capabilities.vendor,
        gpuArchitecture: capabilities.architecture
      }
    });
  };
  const fireSuggestionInsertedAnalytics = ghostText => {
    var _api$analytics4;
    const typedLength = lastSuggestionTypedLength;
    const suggestionLength = lastSuggestionLength || typedLength + ghostText.length;
    const kssDelta = suggestionLength - typedLength;
    api === null || api === void 0 ? void 0 : (_api$analytics4 = api.analytics) === null || _api$analytics4 === void 0 ? void 0 : _api$analytics4.actions.fireAnalyticsEvent({
      action: ACTION.SUGGESTION_INSERTED,
      actionSubject: ACTION_SUBJECT.CONTEXTUAL_TYPEAHEAD,
      eventType: EVENT_TYPE.TRACK,
      attributes: {
        completionSource: getCompletionSource(),
        suggestionLength,
        typedLength,
        kssDelta
      }
    });
  };
  const slowLaneClient = options !== null && options !== void 0 && options.useLocalModel ? createLocalSlowLaneClient({
    debounceMs: LOCAL_SLOW_LANE_DEBOUNCE_MS,
    onLoadSuccess: fireLocalModelLoadedAnalytics,
    onLoadError: fireLocalModelLoadFailedAnalytics
  }) : createSlowLaneClient({
    baseUrl: '',
    debounceMs: NETWORK_SLOW_LANE_DEBOUNCE_MS
  });
  setDefaultSlowLaneClient(slowLaneClient);
  let contextRequestInFlight = false;
  let lastContextRefreshAt = 0;
  // Bounds the word-boundary retry loop so it terminates even when the editor is
  // not in a comment thread (parentCommentContent never resolves).
  let wordBoundaryRefreshAttempts = 0;
  // Set when the plugin is torn down so in-flight getContext() resolutions don't
  // mutate the global text-predictor state after destruction.
  let destroyed = false;
  const ingestedContextTexts = new Set();
  const logContextResolved = (source, context) => {
    var _context$parentCommen, _context$siblingComme4, _context$siblingComme5;
    if (!isAutocompleteDebugEnabled()) {
      return;
    }

    // eslint-disable-next-line no-console
    console.log('%c[Autocomplete] %cgetContext resolved', 'color: #00b8d9; font-weight: bold;', 'color: inherit;', {
      source,
      hasParentComment: !!(context !== null && context !== void 0 && context.parentCommentContent),
      parentCommentPreview: context === null || context === void 0 ? void 0 : (_context$parentCommen = context.parentCommentContent) === null || _context$parentCommen === void 0 ? void 0 : _context$parentCommen.slice(0, 80),
      siblingCount: (_context$siblingComme4 = context === null || context === void 0 ? void 0 : (_context$siblingComme5 = context.siblingCommentsContents) === null || _context$siblingComme5 === void 0 ? void 0 : _context$siblingComme5.length) !== null && _context$siblingComme4 !== void 0 ? _context$siblingComme4 : 0,
      hasFullPage: !!(context !== null && context !== void 0 && context.fullPageContent)
    });
  };
  const applyContext = context => {
    // Merge rather than replace: the word-boundary retry may resolve only a
    // late-arriving field (e.g. parentCommentContent) without re-sending
    // fullPageContent, so replacing would drop previously resolved context.
    // Strip undefined values first so a field explicitly set to undefined by
    // getContext doesn't overwrite a previously resolved value.
    const definedContext = Object.fromEntries(Object.entries(context).filter(([, value]) => value !== undefined));
    resolvedContext = {
      ...resolvedContext,
      ...definedContext
    };
    const ingestContextText = text => {
      if (!text || ingestedContextTexts.has(text)) {
        return;
      }
      ingestedContextTexts.add(text);
      // Evict oldest entries (Set preserves insertion order) to bound memory.
      while (ingestedContextTexts.size > MAX_INGESTED_CONTEXT_TEXTS) {
        const oldest = ingestedContextTexts.values().next().value;
        if (oldest === undefined) {
          break;
        }
        ingestedContextTexts.delete(oldest);
      }
      ingestDocumentPage(text);
    };
    ingestContextText(context.fullPageContent);
    ingestContextText(context.parentCommentContent);
    for (const siblingCommentContent of (_context$siblingComme6 = context.siblingCommentsContents) !== null && _context$siblingComme6 !== void 0 ? _context$siblingComme6 : []) {
      var _context$siblingComme6;
      ingestContextText(siblingCommentContent);
    }

    // Context arrived after word boundaries may already have fired. Re-send
    // slow-lane context immediately so the next inference includes the thread.
    if (currentView) {
      slowLaneClient.updateContext(buildSlowLaneText(currentView.state.doc.textContent, resolvedContext));
    }
  };

  /**
   * Returns true when a fetch was actually started, false when it was skipped
   * (no getContext, a request already in flight, or throttled). Callers that
   * track a retry budget should only count attempts where this returns true.
   */
  const refreshContext = ({
    source,
    allowThrottle = true
  }) => {
    if (!(options !== null && options !== void 0 && options.getContext) || contextRequestInFlight) {
      return false;
    }
    const now = Date.now();
    if (allowThrottle && now - lastContextRefreshAt < CONTEXT_REFRESH_THROTTLE_MS) {
      return false;
    }
    contextRequestInFlight = true;
    lastContextRefreshAt = now;
    options.getContext().then(context => {
      // Bail if the plugin was destroyed while the fetch was in flight —
      // applyContext mutates global text-predictor state we must not touch
      // after teardown.
      if (destroyed) {
        return;
      }
      logContextResolved(source, context);
      if (!context) {
        return;
      }
      applyContext(context);
    }).catch(error => {
      logException(error, {
        location: 'editor-plugin-autocomplete/getContext'
      });
    }).finally(() => {
      contextRequestInFlight = false;
    });
    return true;
  };

  /**
   * Schedule a prediction after a short debounce.
   * Tier 1 predictions are synchronous (<0.1ms) but we still debounce
   * to avoid unnecessary work on rapid keystrokes.
   */
  const schedulePrediction = view => {
    if (debounceTimer) {
      clearTimeout(debounceTimer);
    }
    debounceTimer = setTimeout(() => {
      try {
        const {
          state
        } = view;
        const {
          selection
        } = state;
        if (!selection.empty) {
          return;
        }

        // Suppress suggestions when the cursor is mid-word — only offer
        // completions when the cursor is at the trailing edge of a token.
        // nodeAfter correctly handles inline atoms (mentions, emojis) where
        // parentOffset and textContent indices diverge.
        const nodeAfter = selection.$from.nodeAfter;
        const charAfterCursor = nodeAfter !== null && nodeAfter !== void 0 && nodeAfter.isText ? getLeadingTextCharacter(nodeAfter.text) : undefined;
        // Only suppress for Unicode letters, digits, and underscore — hyphens,
        // apostrophes, and similar punctuation are valid left-edge boundaries
        // and should not block suggestions (e.g. cursor before '-' in compound-word).
        if (charAfterCursor && isMidWordCharacter(charAfterCursor)) {
          return;
        }
        const textBefore = getTextBeforeCursor(state);

        // Suppress re-showing the same suggestion the user just dismissed.
        // Once the text context changes (user types or deletes), this clears automatically.
        if (textBefore === dismissedContext) {
          return;
        }
        dismissedContext = null;
        if (textBefore.trim().length < 3) {
          return;
        }
        const prediction = predict(textBefore);
        if (prediction && prediction.length > 0) {
          const typedLength = getTypedLengthForPrediction(textBefore);
          lastSuggestionTypedLength = typedLength;
          lastSuggestionLength = typedLength + prediction.length;
          showGhostText(view, prediction, selection.from);
          if (prediction !== lastShownGhostText) {
            var _api$analytics5;
            lastShownGhostText = prediction;
            api === null || api === void 0 ? void 0 : (_api$analytics5 = api.analytics) === null || _api$analytics5 === void 0 ? void 0 : _api$analytics5.actions.fireAnalyticsEvent({
              action: ACTION.SUGGESTION_VIEWED,
              actionSubject: ACTION_SUBJECT.CONTEXTUAL_TYPEAHEAD,
              eventType: EVENT_TYPE.TRACK,
              attributes: {
                completionSource: getCompletionSource()
              }
            });
          }
        }
      } catch (error) {
        logException(error, {
          location: 'editor-plugin-autocomplete/schedulePrediction'
        });
      }
    }, DEBOUNCE_MS);
  };
  const maybeUpdateSessionFrequency = (view, prevState) => {
    const newText = getTextBeforeCursor(view.state);
    const prevText = getTextBeforeCursor(prevState);
    if (newText.length <= prevText.length) {
      return;
    }
    const lastChar = newText[newText.length - 1];
    if (!WORD_BOUNDARY_CHARS_REGEX.test(lastChar)) {
      return;
    }

    // Only fire if the previous state did not already end on a boundary,
    // so we don't double-count when multiple boundary chars are inserted.
    const prevLastChar = prevText[prevText.length - 1];
    if (prevLastChar && WORD_BOUNDARY_CHARS_REGEX.test(prevLastChar)) {
      return;
    }
    const beforeBoundary = newText.slice(0, -1).trimEnd();
    const lastSpaceIdx = beforeBoundary.lastIndexOf(' ');
    const completedWord = beforeBoundary.slice(lastSpaceIdx + 1).toLowerCase();
    if (completedWord.length >= 2) {
      incrementSessionFreq(completedWord);
    }
  };
  return new SafePlugin({
    key: autocompletePluginKey,
    state: {
      init: () => createInitialState(),
      apply: (tr, pluginState) => {
        const meta = tr.getMeta(autocompletePluginKey);
        if (meta) {
          return {
            ...pluginState,
            ...meta
          };
        }

        // A new prediction is scheduled from view.update.
        if (tr.docChanged) {
          return {
            ...pluginState,
            ghostText: '',
            ghostPosition: -1,
            decorationSet: DecorationSet.empty
          };
        }
        if (tr.selectionSet && pluginState.ghostText) {
          return {
            ...pluginState,
            ghostText: '',
            ghostPosition: -1,
            decorationSet: DecorationSet.empty
          };
        }
        return pluginState;
      }
    },
    props: {
      decorations: state => {
        var _pluginState$decorati;
        const pluginState = autocompletePluginKey.getState(state);
        return (_pluginState$decorati = pluginState === null || pluginState === void 0 ? void 0 : pluginState.decorationSet) !== null && _pluginState$decorati !== void 0 ? _pluginState$decorati : DecorationSet.empty;
      },
      handleKeyDown: keydownHandler({
        Tab: (state, dispatch) => {
          var _pluginState$ghostTex;
          const pluginState = autocompletePluginKey.getState(state);
          const ghostText = (_pluginState$ghostTex = pluginState === null || pluginState === void 0 ? void 0 : pluginState.ghostText) !== null && _pluginState$ghostTex !== void 0 ? _pluginState$ghostTex : '';
          const accepted = acceptGhostText(state, dispatch);
          if (accepted) {
            justAccepted = true;
            lastShownGhostText = '';
            fireSuggestionInsertedAnalytics(ghostText);
          }
          return accepted;
        },
        ArrowRight: (state, dispatch) => {
          var _pluginState$ghostTex2;
          const pluginState = autocompletePluginKey.getState(state);
          const ghostText = (_pluginState$ghostTex2 = pluginState === null || pluginState === void 0 ? void 0 : pluginState.ghostText) !== null && _pluginState$ghostTex2 !== void 0 ? _pluginState$ghostTex2 : '';
          const accepted = acceptGhostText(state, dispatch);
          if (accepted) {
            justAccepted = true;
            lastShownGhostText = '';
            fireSuggestionInsertedAnalytics(ghostText);
          }
          return accepted;
        },
        Escape: (state, dispatch) => {
          const didClear = clearGhostText(state, dispatch);
          if (didClear) {
            dismissedContext = getTextBeforeCursor(state);
            fireSuggestionDismissedAnalytics('escape');
          }
          return didClear;
        }
      }),
      handleDOMEvents: {
        blur: view => {
          if (!isAutocompleteEnabled) {
            return false;
          }
          const pluginState = autocompletePluginKey.getState(view.state);
          if (pluginState !== null && pluginState !== void 0 && pluginState.ghostText) {
            clearGhostText(view.state, view.dispatch);
            fireSuggestionDismissedAnalytics('blur');
          }
          return false;
        },
        focus: () => {
          var _options$useLocalMode, _options$useLocalMode2;
          if (!isAutocompleteEnabled) {
            return false;
          }
          loadDefaultVocabulary({
            isLocalLLM: (_options$useLocalMode = options === null || options === void 0 ? void 0 : options.useLocalModel) !== null && _options$useLocalMode !== void 0 ? _options$useLocalMode : false
          }).catch(error => {
            logException(error, {
              location: 'editor-plugin-autocomplete/loadDefaultVocabulary'
            });
          });
          loadVectorsAsync({
            getBinaryUrl: options === null || options === void 0 ? void 0 : options.getVectorsBinaryUrl,
            isLocalLLM: (_options$useLocalMode2 = options === null || options === void 0 ? void 0 : options.useLocalModel) !== null && _options$useLocalMode2 !== void 0 ? _options$useLocalMode2 : false
          }).catch(error => {
            logException(error, {
              location: 'editor-plugin-autocomplete/loadVectorsAsync'
            });
          });
          if (!hasIngestedPage) {
            hasIngestedPage = true;
            refreshContext({
              source: 'focus',
              allowThrottle: false
            });
          }
          return false;
        }
      }
    },
    view: editorView => {
      // Capture up front so a subscription notification before the first PM
      // transaction can still drive slowLaneClient.updateContext (gated on currentView).
      currentView = editorView;

      // Push channel for hosts that keep producing context after mount (e.g. Rovo chat).
      if (isAutocompleteEnabled && options !== null && options !== void 0 && options.subscribeToContextUpdates) {
        unsubscribeFromContextUpdates = options.subscribeToContextUpdates(() => {
          // Bypass throttle for freshness; the in-flight guard prevents overlap.
          refreshContext({
            source: 'subscription',
            allowThrottle: false
          });
        });
      }
      return {
        update: (view, prevState) => {
          if (!isAutocompleteEnabled) {
            return;
          }
          currentView = view;
          if (!prevState.doc.eq(view.state.doc)) {
            if (justAccepted) {
              justAccepted = false;

              // Snapshot the post-acceptance text so follow-up transactions hit
              // the dismissedContext guard and abort until the user types again.
              dismissedContext = getTextBeforeCursor(view.state);
              if (debounceTimer) {
                clearTimeout(debounceTimer);
              }
              return;
            }
            maybeUpdateSessionFrequency(view, prevState);
            const textBefore = getTextBeforeCursor(view.state);
            if (isWordBoundary(textBefore)) {
              var _resolvedContext;
              slowLaneClient.updateContext(buildSlowLaneText(view.state.doc.textContent, resolvedContext));

              // Context may not have resolved on first focus (e.g. comment
              // thread still loading). Retry on word boundaries until we have
              // the parent comment, throttled so we don't refetch constantly
              // and capped so non-comment editors stop retrying entirely.
              // Skipped for push-channel hosts (subscribeToContextUpdates): they
              // never grow parentCommentContent, so polling only burns the budget.
              if (!(options !== null && options !== void 0 && options.subscribeToContextUpdates) && !((_resolvedContext = resolvedContext) !== null && _resolvedContext !== void 0 && _resolvedContext.parentCommentContent) && wordBoundaryRefreshAttempts < MAX_CONTEXT_REFRESH_ATTEMPTS) {
                // Only count the attempt when a fetch actually started, so an
                // in-flight or throttled no-op doesn't burn the retry budget.
                if (refreshContext({
                  source: 'word-boundary'
                })) {
                  wordBoundaryRefreshAttempts++;
                }
              }
            }
            schedulePrediction(view);
          }
        },
        destroy: () => {
          var _unsubscribeFromConte;
          destroyed = true;
          currentView = null;
          (_unsubscribeFromConte = unsubscribeFromContextUpdates) === null || _unsubscribeFromConte === void 0 ? void 0 : _unsubscribeFromConte();
          unsubscribeFromContextUpdates = undefined;
          if (debounceTimer) {
            clearTimeout(debounceTimer);
          }
          if (hasDestroy(slowLaneClient)) {
            slowLaneClient.destroy();
          }
          ingestedContextTexts.clear();
          setDefaultSlowLaneClient(null);
        }
      };
    }
  });
};