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

import '../../../../ui/components/markdown_view/markdown_view.js';

import * as i18n from '../../../../core/i18n/i18n.js';
import * as Root from '../../../../core/root/root.js';
import * as AIAssistance from '../../../../models/ai_assistance/ai_assistance.js';
import * as Badges from '../../../../models/badges/badges.js';
import type {InsightModel} from '../../../../models/trace/insights/types.js';
import type * as Trace from '../../../../models/trace/trace.js';
import * as Buttons from '../../../../ui/components/buttons/buttons.js';
import * as UI from '../../../../ui/legacy/legacy.js';
import * as Lit from '../../../../ui/lit/lit.js';
import * as VisualLogging from '../../../../ui/visual_logging/visual_logging.js';
import type * as Overlays from '../../overlays/overlays.js';

import baseInsightComponentStyles from './baseInsightComponent.css.js';
import {md} from './Helpers.js';
import * as SidebarInsight from './SidebarInsight.js';
import type {TableState} from './Table.js';

const {html} = Lit;

const UIStrings = {
  /**
   * @description Text to tell the user the estimated time or size savings for this insight. "&" means "and" - space is limited to prefer abbreviated terms if possible. Text will still fit if not short, it just won't look very good, so using no abbreviations is fine if necessary.
   * @example {401 ms} PH1
   * @example {112 kB} PH1
   */
  estimatedSavings: 'Est savings: {PH1}',
  /**
   * @description Text to tell the user the estimated time and size savings for this insight. "&" means "and", "Est" means "Estimated" - space is limited to prefer abbreviated terms if possible. Text will still fit if not short, it just won't look very good, so using no abbreviations is fine if necessary.
   * @example {401 ms} PH1
   * @example {112 kB} PH2
   */
  estimatedSavingsTimingAndBytes: 'Est savings: {PH1} & {PH2}',
  /**
   * @description Text to tell the user the estimated time savings for this insight that is used for screen readers.
   * @example {401 ms} PH1
   * @example {112 kB} PH1
   */
  estimatedSavingsAriaTiming: 'Estimated savings for this insight: {PH1}',
  /**
   * @description Text to tell the user the estimated size savings for this insight that is used for screen readers. Value is in terms of "transfer size", aka encoded/compressed data length.
   * @example {401 ms} PH1
   * @example {112 kB} PH1
   */
  estimatedSavingsAriaBytes: 'Estimated savings for this insight: {PH1} transfer size',
  /**
   * @description Text to tell the user the estimated time and size savings for this insight that is used for screen readers.
   * @example {401 ms} PH1
   * @example {112 kB} PH2
   */
  estimatedSavingsTimingAndBytesAria: 'Estimated savings for this insight: {PH1} and {PH2} transfer size',
  /**
   * @description Used for screen-readers as a label on the button to expand an insight to view details
   * @example {LCP breakdown} PH1
   */
  viewDetails: 'View details for {PH1} insight.',
} as const;

const str_ = i18n.i18n.registerUIStrings('panels/timeline/components/insights/BaseInsightComponent.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);

interface ViewInput {
  internalName: string;
  model: InsightModel;
  selected: boolean;
  showAskAI: boolean;
  estimatedSavingsString: string|null;
  estimatedSavingsAriaLabel: string|null;
  renderContent: () => Lit.LitTemplate;
  dispatchInsightToggle: () => void;
  onHeaderKeyDown: (event: KeyboardEvent) => void;
  onAskAIButtonClick: () => void;
  /**
   * Minimal mode hides the component's header and AI buttons, and ensures that the
   * component is rendered as expanded (not closed).
   *
   * It is used when rendering an insight in a widget within the AI assistance panel.
   */
  minimal?: boolean;
}

type View = (input: ViewInput, output: undefined, target: HTMLElement) => void;

const DEFAULT_VIEW: View = (input, _output, target) => {
  const {
    internalName,
    model,
    selected,
    estimatedSavingsString,
    estimatedSavingsAriaLabel,
    showAskAI,
    dispatchInsightToggle,
    renderContent,
    onHeaderKeyDown,
    onAskAIButtonClick,
    minimal,
  } = input;

  const containerClasses = Lit.Directives.classMap({
    insight: true,
    closed: !selected && !minimal,
    minimal: Boolean(minimal),
  });

  let ariaLabel = `${i18nString(UIStrings.viewDetails, {PH1: model.title})}`;
  if (estimatedSavingsAriaLabel) {
    // space prefix is deliberate to add a gap after the view details text
    ariaLabel += ` ${estimatedSavingsAriaLabel}`;
  }

  function renderInsightContent(): Lit.LitTemplate {
    if (!selected && !minimal) {
      return Lit.nothing;
    }

    const aiLabel = AIAssistance.AiUtils.isGeminiBranding() ? 'Ask Gemini' : 'Ask AI';
    const ariaLabel = `${aiLabel} about ${model.title} insight`;
    const content = renderContent();
    const iconName = AIAssistance.AiUtils.getIconName();

    // clang-format off
    return html`
      <div class="insight-body">
        ${minimal ? Lit.nothing : html`<div class="insight-description">${md(model.description)}</div>`}
        <div class="insight-content">${content}</div>
        ${showAskAI && !minimal ? html`
          <div class="ask-ai-btn-wrap">
            <devtools-button class="ask-ai"
              .variant=${Buttons.Button.Variant.OUTLINED}
              .iconName=${iconName}
              data-insights-ask-ai
              jslog=${VisualLogging.action(`timeline.insight-ask-ai.${internalName}`).track({click: true})}
              @click=${onAskAIButtonClick}
              aria-label=${ariaLabel}
            >${aiLabel}</devtools-button>
          </div>
        `: Lit.nothing}
      </div>`;
    // clang-format on
  }

  function renderHoverIcon(): Lit.LitTemplate {
    const containerClasses = Lit.Directives.classMap({
      'insight-hover-icon': true,
      active: selected,
    });

    // clang-format off
    return html`
      <div class=${containerClasses} inert>
        <devtools-button .data=${{
          variant: Buttons.Button.Variant.ICON,
          iconName: 'chevron-down',
          size: Buttons.Button.Size.SMALL,
        } as Buttons.Button.ButtonData}
      ></devtools-button>
      </div>
    `;
    // clang-format on
  }

  // clang-format off
  Lit.render(html`
    <style>${baseInsightComponentStyles}</style>
    <div class=${containerClasses}>
      ${minimal ? Lit.nothing : html`
        <header @click=${dispatchInsightToggle}
          @keydown=${onHeaderKeyDown}
          jslog=${VisualLogging.action(`timeline.toggle-insight.${internalName}`).track({click: true})}
          data-insight-header-title=${model?.title}
          tabIndex="0"
          role="button"
          aria-expanded=${selected}
          aria-label=${ariaLabel}
        >
          ${renderHoverIcon()}
          <h3 class="insight-title">${model?.title}</h3>
          ${estimatedSavingsString ?
            html`
            <slot name="insight-savings" class="insight-savings">
              <span title=${estimatedSavingsAriaLabel ?? ''}>${estimatedSavingsString}</span>
            </slot>`
          : Lit.nothing}
        </header>
      `}
      ${renderInsightContent()}
    </div>
  `, target);
  // clang-format on

  if (selected) {
    requestAnimationFrame(() => requestAnimationFrame(() => target.scrollIntoViewIfNeeded()));
  }
};

export interface BaseInsightData {
  /** The trace bounds for the insight set that contains this insight. */
  bounds: Trace.Types.Timing.TraceWindowMicro|null;
  /** The key into `insights` that contains this particular insight. */
  insightSetKey: string|null;
}

export abstract class BaseInsightComponent<T extends InsightModel> extends UI.Widget.Widget {
  #view: View;
  abstract internalName: string;
  #selected = false;
  #minimal = false;
  #model: T|null = null;
  #agentFocus: AIAssistance.AIContext.AgentFocus|null = null;
  #fieldMetrics: Trace.Insights.Common.CrUXFieldMetricResults|null = null;
  #initialOverlays: Trace.Types.Overlays.Overlay[]|null = null;

  constructor(element?: HTMLElement, view: View = DEFAULT_VIEW) {
    super(element, {useShadowDom: true});
    this.#view = view;
  }

  get model(): T|null {
    return this.#model;
  }

  protected data: BaseInsightData = {
    bounds: null,
    insightSetKey: null,
  };

  readonly sharedTableState: TableState = {
    selectedRowEl: null,
    selectionIsSticky: false,
  };

  // Insights that do support the AI feature can override this to return true.
  // The "Ask AI" button will only be shown for an Insight if this
  // is true and if the feature has been enabled by the user and they meet the
  // requirements to use AI.
  protected hasAskAiSupport(): boolean {
    return false;
  }

  set selected(selected: boolean) {
    if (!this.#selected && selected) {
      if (!this.#minimal) {
        const options = this.getOverlayOptionsForInitialOverlays();
        this.element.dispatchEvent(new SidebarInsight.InsightProvideOverlays(this.getInitialOverlays(), options));
      }
    }

    if (this.#selected !== selected) {
      this.#selected = selected;
      this.requestUpdate();
    }
  }

  get selected(): boolean {
    return this.#selected;
  }

  set minimal(minimal: boolean) {
    this.#minimal = minimal;
    this.#selected = this.#selected || minimal;
    this.requestUpdate();
  }

  get minimal(): boolean {
    return this.#minimal;
  }

  set model(model: T) {
    this.#model = model;
    this.requestUpdate();
  }

  set insightSetKey(insightSetKey: string|null) {
    this.data.insightSetKey = insightSetKey;
    this.requestUpdate();
  }

  get bounds(): Trace.Types.Timing.TraceWindowMicro|null {
    return this.data.bounds;
  }

  set bounds(bounds: Trace.Types.Timing.TraceWindowMicro|null) {
    this.data.bounds = bounds;
    this.requestUpdate();
  }

  set agentFocus(agentFocus: AIAssistance.AIContext.AgentFocus|null) {
    this.#agentFocus = agentFocus;
    this.requestUpdate();
  }

  set fieldMetrics(fieldMetrics: Trace.Insights.Common.CrUXFieldMetricResults|null) {
    this.#fieldMetrics = fieldMetrics;
    this.requestUpdate();
  }

  get fieldMetrics(): Trace.Insights.Common.CrUXFieldMetricResults|null {
    return this.#fieldMetrics;
  }

  getOverlayOptionsForInitialOverlays(): Overlays.Overlays.TimelineOverlaySetOptions {
    return {updateTraceWindow: true};
  }

  #dispatchInsightToggle(): void {
    if (!this.data.insightSetKey || !this.#model) {
      // Shouldn't happen, but needed to satisfy TS.
      return;
    }

    const focus = UI.Context.Context.instance().flavor(AIAssistance.AIContext.AgentFocus);
    if (this.#selected) {
      this.element.dispatchEvent(new SidebarInsight.InsightDeactivated());

      // Clear agent (but only if currently focused on an insight).
      if (focus) {
        UI.Context.Context.instance().setFlavor(AIAssistance.AIContext.AgentFocus, focus.withInsight(null));
      }
      return;
    }

    if (focus) {
      UI.Context.Context.instance().setFlavor(AIAssistance.AIContext.AgentFocus, focus.withInsight(this.#model));
    }

    Badges.UserBadges.instance().recordAction(Badges.BadgeAction.PERFORMANCE_INSIGHT_CLICKED);

    this.sharedTableState.selectedRowEl?.classList.remove('selected');
    this.sharedTableState.selectedRowEl = null;
    this.sharedTableState.selectionIsSticky = false;

    this.element.dispatchEvent(new SidebarInsight.InsightActivated(this.#model, this.data.insightSetKey));
  }

  /**
   * Ensure that if the user presses enter or space on a header, we treat it
   * like a click and toggle the insight.
   */
  #onHeaderKeyDown(event: KeyboardEvent): void {
    if (event.key === 'Enter' || event.key === ' ') {
      event.preventDefault();
      event.stopPropagation();
      this.#dispatchInsightToggle();
    }
  }

  /**
   * Replaces the initial insight overlays with the ones provided.
   *
   * If `overlays` is null, reverts back to the initial overlays.
   *
   * This allows insights to provide an initial set of overlays,
   * and later temporarily replace all of those insights with a different set.
   * This enables the hover/click table interactions.
   */
  toggleTemporaryOverlays(
      overlays: Trace.Types.Overlays.Overlay[]|null, options: Overlays.Overlays.TimelineOverlaySetOptions): void {
    if (!this.#selected && !this.#minimal) {
      return;
    }

    if (!overlays) {
      const initialOverlays = this.#minimal ? [] : this.getInitialOverlays();
      this.element.dispatchEvent(
          new SidebarInsight.InsightProvideOverlays(initialOverlays, this.getOverlayOptionsForInitialOverlays()));
      return;
    }

    this.element.dispatchEvent(new SidebarInsight.InsightProvideOverlays(overlays, options));
  }

  getInitialOverlays(): Trace.Types.Overlays.Overlay[] {
    if (this.#initialOverlays) {
      return this.#initialOverlays;
    }

    this.#initialOverlays = this.createOverlays();
    return this.#initialOverlays;
  }

  protected createOverlays(): Trace.Types.Overlays.Overlay[] {
    return this.#model?.createOverlays?.() ?? [];
  }

  protected abstract renderContent(): Lit.LitTemplate;

  override performUpdate(): void {
    if (!this.#model) {
      return;
    }

    const input: ViewInput = {
      internalName: this.internalName,
      model: this.#model,
      selected: this.#selected,
      estimatedSavingsString: this.getEstimatedSavingsString(),
      estimatedSavingsAriaLabel: this.#getEstimatedSavingsAriaLabel(),
      showAskAI: this.#canShowAskAI(),
      dispatchInsightToggle: () => this.#dispatchInsightToggle(),
      renderContent: () => this.renderContent(),
      onHeaderKeyDown: this.#onHeaderKeyDown.bind(this),
      onAskAIButtonClick: () => this.#onAskAIButtonClick(),
      minimal: this.#minimal,
    };
    this.#view(input, undefined, this.contentElement);
  }

  getEstimatedSavingsTime(): Trace.Types.Timing.Milli|null {
    return null;
  }

  getEstimatedSavingsBytes(): number|null {
    return this.#model?.wastedBytes ?? null;
  }

  #getEstimatedSavingsTextParts(): {bytesString?: string, timeString?: string} {
    const savingsTime = this.getEstimatedSavingsTime();
    const savingsBytes = this.getEstimatedSavingsBytes();

    let timeString, bytesString;
    if (savingsTime) {
      timeString = i18n.TimeUtilities.millisToString(savingsTime);
    }
    if (savingsBytes) {
      bytesString = i18n.ByteUtilities.bytesToString(savingsBytes);
    }
    return {
      timeString,
      bytesString,
    };
  }

  #getEstimatedSavingsAriaLabel(): string|null {
    const {bytesString, timeString} = this.#getEstimatedSavingsTextParts();

    if (timeString && bytesString) {
      return i18nString(UIStrings.estimatedSavingsTimingAndBytesAria, {
        PH1: timeString,
        PH2: bytesString,
      });
    }
    if (timeString) {
      return i18nString(UIStrings.estimatedSavingsAriaTiming, {
        PH1: timeString,
      });
    }
    if (bytesString) {
      return i18nString(UIStrings.estimatedSavingsAriaBytes, {
        PH1: bytesString,
      });
    }

    return null;
  }

  getEstimatedSavingsString(): string|null {
    const {bytesString, timeString} = this.#getEstimatedSavingsTextParts();

    if (timeString && bytesString) {
      return i18nString(UIStrings.estimatedSavingsTimingAndBytes, {
        PH1: timeString,
        PH2: bytesString,
      });
    }
    if (timeString) {
      return i18nString(UIStrings.estimatedSavings, {
        PH1: timeString,
      });
    }
    if (bytesString) {
      return i18nString(UIStrings.estimatedSavings, {
        PH1: bytesString,
      });
    }

    return null;
  }

  #onAskAIButtonClick(): void {
    if (!this.#agentFocus) {
      return;
    }

    // matches the one in ai_assistance-meta.ts
    const actionId = 'drjones.performance-panel-context';
    if (!UI.ActionRegistry.ActionRegistry.instance().hasAction(actionId)) {
      return;
    }

    let focus = UI.Context.Context.instance().flavor(AIAssistance.AIContext.AgentFocus);
    if (focus) {
      focus = focus.withInsight(this.#model);
    } else {
      focus = this.#agentFocus;
    }
    UI.Context.Context.instance().setFlavor(AIAssistance.AIContext.AgentFocus, focus);

    // Trigger the AI Assistance panel to open.
    const action = UI.ActionRegistry.ActionRegistry.instance().getAction(actionId);
    void action.execute();
  }

  #canShowAskAI(): boolean {
    if (!this.hasAskAiSupport()) {
      return false;
    }

    // Check if the Insights AI feature enabled within Chrome for the active user.
    const {devToolsAiAssistancePerformanceAgent} = Root.Runtime.hostConfig;
    const askAiEnabled = Boolean(devToolsAiAssistancePerformanceAgent?.enabled);
    if (!askAiEnabled) {
      return false;
    }

    const {aidaAvailability} = Root.Runtime.hostConfig;
    return aidaAvailability?.enterprisePolicyValue !== Root.Runtime.GenAiEnterprisePolicyValue.DISABLE &&
        aidaAvailability?.enabled === true;
  }
}
