// Copyright 2025 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import * as Common from '../../../core/common/common.js';
import * as Host from '../../../core/host/host.js';
import * as i18n from '../../../core/i18n/i18n.js';
import * as Root from '../../../core/root/root.js';
import type * as AiCodeCompletion from '../../../models/ai_code_completion/ai_code_completion.js';
import * as AiCodeGeneration from '../../../models/ai_code_generation/ai_code_generation.js';
import * as PanelCommon from '../../../panels/common/common.js';
import * as CodeMirror from '../../../third_party/codemirror.next/codemirror.next.js';
import * as UI from '../../../ui/legacy/legacy.js';
import * as VisualLogging from '../../visual_logging/visual_logging.js';

import {AccessiblePlaceholder} from './AccessiblePlaceholder.js';
import {AiCodeGenerationParser} from './AiCodeGenerationParser.js';
import {
  acceptAiAutoCompleteSuggestion,
  aiAutoCompleteSuggestion,
  aiAutoCompleteSuggestionState,
  AiSuggestionSource,
  hasActiveAiSuggestion,
  setAiAutoCompleteSuggestion,
} from './config.js';
import type {TextEditor} from './TextEditor.js';

export enum AiCodeGenerationTeaserMode {
  ACTIVE = 'active',
  DISMISSED = 'dismissed',
}

export const setAiCodeGenerationTeaserMode = CodeMirror.StateEffect.define<AiCodeGenerationTeaserMode>();

const aiCodeGenerationTeaserModeState = CodeMirror.StateField.define<AiCodeGenerationTeaserMode>({
  create: () => AiCodeGenerationTeaserMode.ACTIVE,
  update(value, tr) {
    return tr.effects.find(effect => effect.is(setAiCodeGenerationTeaserMode))?.value ?? value;
  },
});

export interface AiCodeGenerationConfig {
  generationContext: {
    additionalPreambleContext?: string,
    inferenceLanguage?: Host.AidaClient.AidaInferenceLanguage,
  };
  onSuggestionAccepted: (citations: Host.AidaClient.Citation[]) => void;
  onRequestTriggered: () => void;
  onResponseReceived: () => void;
  panel: AiCodeCompletion.AiCodeCompletion.ContextFlavor;
}

export class AiCodeGenerationProvider {
  #devtoolsLocale: string;
  // 'ai-code-completion-enabled' setting controls both AI code completion and AI code generation.
  // Since this provider deals with code generation, the field has been named `#aiCodeGenerationEnabledSetting`.
  #aiCodeGenerationEnabledSetting =
      Common.Settings.Settings.instance().createSetting('ai-code-completion-enabled', false);
  #aiCodeGenerationSettingEnabled = this.#aiCodeGenerationEnabledSetting.get();
  #aiCodeGenerationOnboardingCompletedSetting =
      Common.Settings.Settings.instance().createSetting('ai-code-generation-onboarding-completed', false);
  #aiCodeGenerationUsedSetting = Common.Settings.Settings.instance().createSetting('ai-code-generation-used', false);
  #generationTeaserCompartment = new CodeMirror.Compartment();
  #generationTeaser: PanelCommon.AiCodeGenerationTeaser.AiCodeGenerationTeaser;
  #editor?: TextEditor;
  #aiCodeGenerationConfig: AiCodeGenerationConfig;
  #aiCodeGeneration?: AiCodeGeneration.AiCodeGeneration.AiCodeGeneration;
  #aiCodeGenerationCitations: Host.AidaClient.Citation[] = [];

  #aidaClient: Host.AidaClient.AidaClient = new Host.AidaClient.AidaClient();
  #boundOnUpdateAiCodeGenerationState = this.#updateAiCodeGenerationState.bind(this);
  #controller = new AbortController();

  private constructor(aiCodeGenerationConfig: AiCodeGenerationConfig) {
    this.#devtoolsLocale = i18n.DevToolsLocale.DevToolsLocale.instance().locale;
    if (!AiCodeGeneration.AiCodeGeneration.AiCodeGeneration.isAiCodeGenerationEnabled(this.#devtoolsLocale)) {
      throw new Error('AI code generation feature is not enabled.');
    }
    this.#generationTeaser = new PanelCommon.AiCodeGenerationTeaser.AiCodeGenerationTeaser();
    this.#generationTeaser.disclaimerTooltipId =
        aiCodeGenerationConfig.panel + '-ai-code-generation-disclaimer-tooltip';
    this.#generationTeaser.panel = aiCodeGenerationConfig.panel;
    this.#aiCodeGenerationConfig = aiCodeGenerationConfig;
  }

  static createInstance(aiCodeGenerationConfig: AiCodeGenerationConfig): AiCodeGenerationProvider {
    return new AiCodeGenerationProvider(aiCodeGenerationConfig);
  }

  extension(): CodeMirror.Extension[] {
    return [
      CodeMirror.EditorView.updateListener.of(update => this.#activateTeaser(update)),
      CodeMirror.EditorView.updateListener.of(update => this.#abortOrDismissGenerationDuringUpdate(update)),
      aiAutoCompleteSuggestion,
      aiAutoCompleteSuggestionState,
      aiCodeGenerationTeaserModeState,
      CodeMirror.Prec.highest(this.#generationTeaserCompartment.of([])),
      CodeMirror.Prec.highest(CodeMirror.keymap.of(this.#editorKeymap())),
    ];
  }

  dispose(): void {
    this.#controller.abort();
    this.#cleanupAiCodeGeneration();
    this.#aiCodeGenerationEnabledSetting.removeChangeListener(this.#boundOnUpdateAiCodeGenerationState);
    Host.AidaClient.HostConfigTracker.instance().removeEventListener(
        Host.AidaClient.Events.AIDA_AVAILABILITY_CHANGED, this.#boundOnUpdateAiCodeGenerationState);
  }

  editorInitialized(editor: TextEditor): void {
    this.#editor = editor;
    Host.AidaClient.HostConfigTracker.instance().addEventListener(
        Host.AidaClient.Events.AIDA_AVAILABILITY_CHANGED, this.#boundOnUpdateAiCodeGenerationState);
    this.#aiCodeGenerationEnabledSetting.addChangeListener(this.#boundOnUpdateAiCodeGenerationState);
    void this.#updateAiCodeGenerationState();
  }

  async #setupAiCodeGeneration(): Promise<void> {
    if (this.#aiCodeGeneration) {
      return;
    }
    this.#aiCodeGeneration = new AiCodeGeneration.AiCodeGeneration.AiCodeGeneration({aidaClient: this.#aidaClient});
    this.#editor?.dispatch({
      effects:
          [this.#generationTeaserCompartment.reconfigure([aiCodeGenerationTeaserExtension(this.#generationTeaser)])],
    });
  }

  #cleanupAiCodeGeneration(): void {
    if (!this.#aiCodeGeneration) {
      return;
    }
    this.#aiCodeGeneration = undefined;
    this.#editor?.dispatch({
      effects: [this.#generationTeaserCompartment.reconfigure([])],
    });
  }

  async #updateAiCodeGenerationState(): Promise<void> {
    const aidaAvailability = await Host.AidaClient.AidaClient.checkAccessPreconditions();
    const isAvailable = aidaAvailability === Host.AidaClient.AidaAccessPreconditions.AVAILABLE;
    const isEnabled = this.#aiCodeGenerationEnabledSetting.get();
    if (isAvailable && isEnabled) {
      if (!this.#aiCodeGenerationSettingEnabled) {
        // If the user enabled setting when code generation feature is already available,
        // we do not need to show the upgrade dialog.
        this.#aiCodeGenerationOnboardingCompletedSetting.set(true);
      }
      await this.#setupAiCodeGeneration();
    } else {
      this.#cleanupAiCodeGeneration();
    }
    this.#aiCodeGenerationSettingEnabled = isEnabled;
  }

  #editorKeymap(): readonly CodeMirror.KeyBinding[] {
    return [
      {
        key: 'Escape',
        run: (): boolean => {
          if (!this.#editor || !this.#aiCodeGeneration) {
            return false;
          }
          if (hasActiveAiSuggestion(this.#editor.state)) {
            if (this.#editor.state.field(aiAutoCompleteSuggestionState)?.source === AiSuggestionSource.COMPLETION) {
              // If the suggestion is from code completion, we don't want to
              // dismiss it here. The user should use the code completion
              // provider's keymap to dismiss the suggestion.
              return false;
            }
            this.#dismissTeaserAndSuggestion();
            return true;
          }
          const generationTeaserIsLoading = this.#generationTeaser.displayState ===
              PanelCommon.AiCodeGenerationTeaser.AiCodeGenerationTeaserDisplayState.LOADING;
          if (this.#generationTeaser.isShowing() && generationTeaserIsLoading) {
            this.#controller.abort();
            this.#controller = new AbortController();
            this.#dismissTeaserAndSuggestion();
            return true;
          }
          return false;
        },
      },
      {
        key: 'Tab',
        run: this.#acceptAiSuggestion.bind(this),
      },
      {
        key: 'Enter',
        run: this.#acceptAiSuggestion.bind(this),
      },
      {
        any: (_view: unknown, event: KeyboardEvent) => {
          if (!this.#editor || !this.#aiCodeGeneration || !this.#generationTeaser.isShowing()) {
            return false;
          }
          if (UI.KeyboardShortcut.KeyboardShortcut.eventHasCtrlEquivalentKey(event)) {
            if (event.key === 'i') {
              void this.#triggerAiCodeGenerationFlow(event);
              return true;
            }
          }
          return false;
        }
      }
    ];
  }

  async #triggerAiCodeGenerationFlow(event: KeyboardEvent): Promise<void> {
    event.consume(true);

    const isOnboarded = await this.#onboardUser();
    if (!isOnboarded) {
      return;
    }

    void VisualLogging.logKeyDown(event.currentTarget, event, 'ai-code-generation.triggered');
    void this.#triggerAiCodeGeneration({signal: this.#controller.signal});
  }

  async #onboardUser(): Promise<boolean> {
    if (this.#aiCodeGenerationOnboardingCompletedSetting.get()) {
      return true;
    }

    const noLogging = Root.Runtime.hostConfig.aidaAvailability?.enterprisePolicyValue ===
        Root.Runtime.GenAiEnterprisePolicyValue.ALLOW_WITHOUT_LOGGING;
    const resolved = await PanelCommon.AiCodeGenerationUpgradeDialog.show({noLogging});
    this.#aiCodeGenerationOnboardingCompletedSetting.set(resolved);
    return resolved;
  }

  #dismissTeaserAndSuggestion(): void {
    this.#generationTeaser.displayState = PanelCommon.AiCodeGenerationTeaser.AiCodeGenerationTeaserDisplayState.TRIGGER;
    this.#editor?.dispatch({
      effects: [
        setAiCodeGenerationTeaserMode.of(AiCodeGenerationTeaserMode.DISMISSED),
        setAiAutoCompleteSuggestion.of(null),
      ]
    });
  }

  #acceptAiSuggestion(): boolean {
    if (!this.#aiCodeGeneration || !this.#editor || !hasActiveAiSuggestion(this.#editor.state)) {
      return false;
    }
    const {accepted, suggestion} = acceptAiAutoCompleteSuggestion(this.#editor.editor);
    if (!accepted) {
      return false;
    }
    if (suggestion?.rpcGlobalId) {
      this.#aiCodeGeneration.registerUserAcceptance(suggestion.rpcGlobalId, suggestion.sampleId);
    }
    this.#aiCodeGenerationConfig?.onSuggestionAccepted(this.#aiCodeGenerationCitations);
    return true;
  }

  #activateTeaser(update: CodeMirror.ViewUpdate): void {
    const currentTeaserMode = update.state.field(aiCodeGenerationTeaserModeState);
    if (currentTeaserMode === AiCodeGenerationTeaserMode.ACTIVE) {
      return;
    }
    if (!update.docChanged && update.state.selection.main.head === update.startState.selection.main.head) {
      return;
    }
    update.view.dispatch({effects: setAiCodeGenerationTeaserMode.of(AiCodeGenerationTeaserMode.ACTIVE)});
  }

  /**
   * Monitors editor changes to cancel an ongoing AI generation or dismiss one
   * if it already exists.
   * We abort the request (or dismiss suggestion) and dismiss the teaser if the
   * user modifies the document or moves their cursor/selection. These actions
   * indicate the user is no longer focused on the current generation point or
   * has manually resumed editing, making the suggestion irrelevant.
   */
  #abortOrDismissGenerationDuringUpdate(update: CodeMirror.ViewUpdate): void {
    if (!update.docChanged && update.state.selection.main.head === update.startState.selection.main.head) {
      return;
    }
    const currentTeaserMode = update.state.field(aiCodeGenerationTeaserModeState);
    if (currentTeaserMode === AiCodeGenerationTeaserMode.DISMISSED) {
      return;
    }
    if (this.#generationTeaser.displayState ===
        PanelCommon.AiCodeGenerationTeaser.AiCodeGenerationTeaserDisplayState.LOADING) {
      this.#controller.abort();
      this.#controller = new AbortController();
      this.#dismissTeaserAndSuggestion();
      return;
    }
    if (this.#generationTeaser.displayState ===
        PanelCommon.AiCodeGenerationTeaser.AiCodeGenerationTeaserDisplayState.GENERATED) {
      update.view.dispatch({effects: setAiAutoCompleteSuggestion.of(null)});
      this.#generationTeaser.displayState =
          PanelCommon.AiCodeGenerationTeaser.AiCodeGenerationTeaserDisplayState.DISCOVERY;
      return;
    }
  }

  async #triggerAiCodeGeneration(options?: {signal?: AbortSignal}): Promise<void> {
    if (!this.#editor || !this.#aiCodeGeneration) {
      return;
    }

    this.#aiCodeGenerationUsedSetting.set(true);

    this.#aiCodeGenerationCitations = [];
    const cursor = this.#editor.state.selection.main.head;
    const commentNodeInfo = AiCodeGenerationParser.extractCommentNodeInfo(this.#editor.state, cursor);
    if (!commentNodeInfo) {
      return;
    }
    // Move cursor to end of comment node before triggering generation.
    this.#editor.dispatch({selection: {anchor: commentNodeInfo.to}});

    const query = commentNodeInfo.text;
    if (!query || query.trim().length === 0) {
      return;
    }

    this.#generationTeaser.displayState = PanelCommon.AiCodeGenerationTeaser.AiCodeGenerationTeaserDisplayState.LOADING;
    try {
      const startTime = performance.now();
      this.#aiCodeGenerationConfig.onRequestTriggered();
      Host.userMetrics.actionTaken(Host.UserMetrics.Action.AiCodeGenerationRequestTriggered);

      const preamble = AiCodeGeneration.AiCodeGeneration.basePreamble +
          this.#aiCodeGenerationConfig.generationContext.additionalPreambleContext;
      const generationResponse = await this.#aiCodeGeneration.generateCode(
          query, preamble, this.#aiCodeGenerationConfig.generationContext.inferenceLanguage, options);

      if (this.#generationTeaser) {
        this.#dismissTeaserAndSuggestion();
      }

      if (!generationResponse || generationResponse.samples.length === 0) {
        this.#aiCodeGenerationConfig.onResponseReceived();
        return;
      }
      const topSample = generationResponse.samples[0];

      const shouldBlock = topSample.attributionMetadata?.attributionAction === Host.AidaClient.RecitationAction.BLOCK;
      if (shouldBlock) {
        return;
      }

      const backtickRegex = /^```(?:\w+)?\n([\s\S]*?)\n```$/;
      const matchArray = topSample.generationString.match(backtickRegex);
      const suggestionText = matchArray ? matchArray[1].trim() : topSample.generationString;

      this.#editor.dispatch({
        effects: [
          setAiAutoCompleteSuggestion.of({
            text: '\n' + suggestionText + '\n',
            from: commentNodeInfo.to,
            rpcGlobalId: generationResponse.metadata.rpcGlobalId,
            sampleId: topSample.sampleId,
            startTime,
            onImpression: this.#aiCodeGeneration?.registerUserImpression.bind(this.#aiCodeGeneration),
            source: AiSuggestionSource.GENERATION,
          }),
          setAiCodeGenerationTeaserMode.of(AiCodeGenerationTeaserMode.ACTIVE)
        ]
      });
      this.#generationTeaser.displayState =
          PanelCommon.AiCodeGenerationTeaser.AiCodeGenerationTeaserDisplayState.GENERATED;

      AiCodeGeneration.debugLog('Suggestion dispatched to the editor', suggestionText);
      const citations = topSample.attributionMetadata?.citations ?? [];
      this.#aiCodeGenerationCitations = citations;
      this.#aiCodeGenerationConfig.onResponseReceived();
      return;
    } catch (e) {
      if (e instanceof Host.DispatchHttpRequestClient.DispatchHttpRequestError &&
          e.type === Host.DispatchHttpRequestClient.ErrorType.ABORT) {
        return;
      }
      AiCodeGeneration.debugLog('Error while fetching code generation suggestions from AIDA', e);
      this.#aiCodeGenerationConfig.onResponseReceived();
      Host.userMetrics.actionTaken(Host.UserMetrics.Action.AiCodeGenerationError);
    }

    if (this.#generationTeaser) {
      this.#dismissTeaserAndSuggestion();
    }
  }
}

function aiCodeGenerationTeaserExtension(teaser: PanelCommon.AiCodeGenerationTeaser.AiCodeGenerationTeaser):
    CodeMirror.Extension {
  return CodeMirror.ViewPlugin.fromClass(class {
    #view: CodeMirror.EditorView;

    constructor(view: CodeMirror.EditorView) {
      this.#view = view;
      this.#updateTeaserState(view.state);
    }

    update(update: CodeMirror.ViewUpdate): void {
      if (!update.docChanged && update.state.selection.main.head === update.startState.selection.main.head) {
        return;
      }
      this.#updateTeaserState(update.state);
    }

    get decorations(): CodeMirror.DecorationSet {
      const teaserMode = this.#view.state.field(aiCodeGenerationTeaserModeState);
      if (teaserMode === AiCodeGenerationTeaserMode.DISMISSED) {
        return CodeMirror.Decoration.none;
      }

      const cursorPosition = this.#view.state.selection.main.head;
      const line = this.#view.state.doc.lineAt(cursorPosition);

      const isEmptyLine = line.length === 0;
      const canShowDiscoveryState =
          UI.UIUtils.PromotionManager.instance().canShowPromotion(PanelCommon.AiCodeGenerationTeaser.PROMOTION_ID);

      if ((isEmptyLine && canShowDiscoveryState)) {
        return CodeMirror.Decoration.set([
          CodeMirror.Decoration.widget({widget: new AccessiblePlaceholder(teaser), side: 1}).range(cursorPosition),
        ]);
      }

      const commentInfo = AiCodeGenerationParser.extractCommentNodeInfo(this.#view.state, cursorPosition);
      if (commentInfo) {
        // If cursor is inside the comment, show at the end of the comment node.
        // If cursor is after the comment (but on same line), show at the cursor.
        const decorationPos = Math.max(cursorPosition, commentInfo.to);
        return CodeMirror.Decoration.set([
          CodeMirror.Decoration.widget({widget: new AccessiblePlaceholder(teaser), side: 1}).range(decorationPos),
        ]);
      }

      return CodeMirror.Decoration.none;
    }

    #updateTeaserState(state: CodeMirror.EditorState): void {
      // Only handle non loading and non generated states, as updates during and after generation are handled by
      // #abortOrDismissGenerationDuringUpdate in AiCodeGenerationProvider
      if (teaser.displayState === PanelCommon.AiCodeGenerationTeaser.AiCodeGenerationTeaserDisplayState.LOADING ||
          teaser.displayState === PanelCommon.AiCodeGenerationTeaser.AiCodeGenerationTeaserDisplayState.GENERATED) {
        return;
      }
      const cursorPosition = state.selection.main.head;
      const line = state.doc.lineAt(cursorPosition);
      const isEmptyLine = line.length === 0;
      if (isEmptyLine) {
        teaser.displayState = PanelCommon.AiCodeGenerationTeaser.AiCodeGenerationTeaserDisplayState.DISCOVERY;
      } else {
        teaser.displayState = PanelCommon.AiCodeGenerationTeaser.AiCodeGenerationTeaserDisplayState.TRIGGER;
      }
    }
  }, {
    decorations: v => v.decorations,
    eventHandlers: {
      mousemove(event: MouseEvent): boolean {
        // Required for mouse hover to propagate to the info button in teaser.
        return (event.target instanceof Node && teaser.contentElement.contains(event.target));
      },
      mousedown(event: MouseEvent, view: CodeMirror.EditorView): boolean {
        if (!(event.target instanceof Node) || !teaser.contentElement.contains(event.target)) {
          return false;
        }
        // On mouse click, move the cursor position to the end of the line.
        const cursorPosition = view.state.selection.main.head;
        const line = view.state.doc.lineAt(cursorPosition);
        if (cursorPosition !== line.to) {
          view.dispatch({selection: {anchor: line.to, head: line.to}});
        }
        // Explicitly focus the editor.
        view.focus();
        return true;
      },
      keydown(event: KeyboardEvent): boolean {
        if (!UI.KeyboardShortcut.KeyboardShortcut.eventHasCtrlEquivalentKey(event) ||
            teaser.displayState !== PanelCommon.AiCodeGenerationTeaser.AiCodeGenerationTeaserDisplayState.TRIGGER) {
          return false;
        }
        if (event.key === '.') {
          event.consume(true);
          void VisualLogging.logKeyDown(
              event.currentTarget, event, 'ai-code-generation-teaser.show-disclaimer-info-tooltip');
          teaser.showTooltip();
          return true;
        }
        return false;
      }
    },
  });
}
