// 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 * as i18n from '../../../core/i18n/i18n.js';
import * as Platform from '../../../core/platform/platform.js';
import * as AIAssistance from '../../../models/ai_assistance/ai_assistance.js';
import * as CrUXManager from '../../../models/crux-manager/crux-manager.js';
import * 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 {md, shouldRenderForCategory} from './insights/Helpers.js';
import * as Insights from './insights/insights.js';
import type {ActiveInsight} from './Sidebar.js';
import sidebarSingleInsightSetStyles from './sidebarSingleInsightSet.css.js';
import {isFieldWorseThanLocal, NumberWithUnit} from './Utils.js';

const {html} = Lit.StaticHtml;

const UIStrings = {
  /**
   * @description title used for a metric value to tell the user about its score classification
   * @example {INP} PH1
   * @example {1.2s} PH2
   * @example {poor} PH3
   */
  metricScore: '{PH1}: {PH2} {PH3} score',
  /**
   * @description title used for a metric value to tell the user that the data is unavailable
   * @example {INP} PH1
   */
  metricScoreUnavailable: '{PH1}: unavailable',
  /**
   * @description Summary text for an expandable dropdown that contains all insights in a passing state.
   * @example {4} PH1
   */
  passedInsights: 'Passed insights ({PH1})',
  /**
   * @description Label denoting that metrics were observed in the field, from real use data (CrUX). Also denotes if from URL or Origin dataset.
   * @example {URL} PH1
   */
  fieldScoreLabel: 'Field ({PH1})',
  /**
   * @description Label for an option that selects the page's specific URL as opposed to it's entire origin/domain.
   */
  urlOption: 'URL',
  /**
   * @description Label for an option that selects the page's entire origin/domain as opposed to it's specific URL.
   */
  originOption: 'Origin',
  /**
   * @description Title for button that closes a warning popup.
   */
  dismissTitle: 'Dismiss',
  /**
   * @description Title shown in a warning dialog when field metrics (collected from real users) is worse than the locally observed metrics.
   */
  fieldMismatchTitle: 'Field & local metrics mismatch',
  /**
   * @description Text shown in a warning dialog when field metrics (collected from real users) is worse than the locally observed metrics.
   * Asks user to use features such as throttling and device emulation.
   */
  fieldMismatchNotice:
      'There are many reasons why local and field metrics [may not match](https://web.dev/articles/lab-and-field-data-differences). ' +
      'Adjust [throttling settings and device emulation](https://developer.chrome.com/docs/devtools/device-mode) to analyze traces more similar to the average user\'s environment.',
} as const;

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

export interface SidebarSingleInsightSetData {
  insightSetKey: Trace.Types.Events.NavigationId|null;
  activeCategory: Trace.Insights.Types.InsightCategory;
  activeInsight: ActiveInsight|null;
  parsedTrace: Trace.TraceModel.ParsedTrace|null;
}

interface InsightData {
  insightName: string;
  model: Trace.Insights.Types.InsightModel;
}

interface LocalMetrics {
  lcp: {value: Trace.Types.Timing.Micro, event: Trace.Types.Events.AnyLargestContentfulPaintCandidate}|null;
  cls: {value: number, worstClusterEvent: Trace.Types.Events.Event|null};
  inp: {value: Trace.Types.Timing.Micro, event: Trace.Types.Events.SyntheticInteractionPair}|null;
}

interface ViewInput {
  shownInsights: InsightData[];
  passedInsights: InsightData[];
  local: LocalMetrics|null;
  field: Trace.Insights.Common.CrUXFieldMetricResults|null;
  activeCategory: Trace.Insights.Types.InsightCategory;
  showFieldMismatchNotice: boolean;
  onDismisFieldMismatchNotice: () => void;
  onClickMetric: (traceEvent: Trace.Types.Events.Event) => void;
  renderInsightComponent: (insightData: InsightData) => Lit.LitTemplate;
}

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

export const DEFAULT_VIEW: View = (input, output, target) => {
  const {
    shownInsights,
    passedInsights,
    local,
    field,
    activeCategory,
    showFieldMismatchNotice,
    onDismisFieldMismatchNotice,
    onClickMetric,
    renderInsightComponent,
  } = input;

  function renderMetrics(): Lit.TemplateResult {
    const lcpEl = renderMetricValue('LCP', local?.lcp?.value ?? null, local?.lcp?.event ?? null);
    const inpEl = renderMetricValue('INP', local?.inp?.value ?? null, local?.inp?.event ?? null);
    const clsEl = renderMetricValue('CLS', local?.cls?.value ?? null, local?.cls?.worstClusterEvent ?? null);

    const localMetricsTemplateResult = html`
      <div class="metrics-row">
        <span>${lcpEl}</span>
        <span>${inpEl}</span>
        <span>${clsEl}</span>
        <span class="row-label">Local</span>
      </div>
      <span class="row-border"></span>
    `;

    let fieldMetricsTemplateResult;
    if (field) {
      const {lcp, inp, cls} = field;

      const lcpEl = renderMetricValue('LCP', lcp?.value ?? null, null);
      const inpEl = renderMetricValue('INP', inp?.value ?? null, null);
      const clsEl = renderMetricValue('CLS', cls?.value ?? null, null);

      let scope = i18nString(UIStrings.originOption);
      if (lcp?.pageScope === 'url' || inp?.pageScope === 'url') {
        scope = i18nString(UIStrings.urlOption);
      }

      // clang-format off
      fieldMetricsTemplateResult = html`
        <div class="metrics-row">
          <span>${lcpEl}</span>
          <span>${inpEl}</span>
          <span>${clsEl}</span>
          <span class="row-label">${i18nString(UIStrings.fieldScoreLabel, {PH1: scope})}</span>
        </div>
        <span class="row-border"></span>
      `;
      // clang-format on
    }

    let fieldIsDifferentEl;
    if (showFieldMismatchNotice) {
      // clang-format off
      fieldIsDifferentEl = html`
        <div class="field-mismatch-notice" jslog=${VisualLogging.section('timeline.insights.field-mismatch')}>
          <h3>${i18nString(UIStrings.fieldMismatchTitle)}</h3>
          <devtools-button
            title=${i18nString(UIStrings.dismissTitle)}
            .iconName=${'cross'}
            .variant=${Buttons.Button.Variant.ICON}
            .jslogContext=${'timeline.insights.dismiss-field-mismatch'}
            @click=${onDismisFieldMismatchNotice}
          ></devtools-button>
          <div class="field-mismatch-notice__body">${md(i18nString(UIStrings.fieldMismatchNotice))}</div>
        </div>
      `;
      // clang-format on
    }

    const classes = {metrics: true, 'metrics--field': Boolean(fieldMetricsTemplateResult)};
    const metricsTableEl = html`<div class=${Lit.Directives.classMap(classes)}>
      <div class="metrics-row">
        <span class="metric-label">LCP</span>
        <span class="metric-label">INP</span>
        <span class="metric-label">CLS</span>
        <span class="row-label"></span>
      </div>
      ${localMetricsTemplateResult}
      ${fieldMetricsTemplateResult}
    </div>`;

    return html`
      ${metricsTableEl}
      ${fieldIsDifferentEl}
    `;
  }

  function renderMetricValue(
      metric: 'LCP'|'CLS'|'INP', value: number|null, relevantEvent: Trace.Types.Events.Event|null): Lit.LitTemplate {
    let valueText: string;
    let valueDisplay: HTMLElement|string;
    let classification;
    if (value === null) {
      valueText = valueDisplay = '-';
      classification = Trace.Handlers.ModelHandlers.PageLoadMetrics.ScoreClassification.UNCLASSIFIED;
    } else if (metric === 'LCP') {
      const micros = value as Trace.Types.Timing.Micro;
      const {text, element} = NumberWithUnit.formatMicroSecondsAsSeconds(micros);
      valueText = text;
      valueDisplay = element;
      classification =
          Trace.Handlers.ModelHandlers.PageLoadMetrics.scoreClassificationForLargestContentfulPaint(micros);
    } else if (metric === 'CLS') {
      valueText = valueDisplay = value ? value.toFixed(2) : '0';
      classification = Trace.Handlers.ModelHandlers.LayoutShifts.scoreClassificationForLayoutShift(value);
    } else if (metric === 'INP') {
      const micros = value as Trace.Types.Timing.Micro;
      const {text, element} = NumberWithUnit.formatMicroSecondsAsMillisFixed(micros);
      valueText = text;
      valueDisplay = element;
      classification =
          Trace.Handlers.ModelHandlers.UserInteractions.scoreClassificationForInteractionToNextPaint(micros);
    } else {
      Platform.TypeScriptUtilities.assertNever(metric, `Unexpected metric ${metric}`);
    }

    // NOTE: it is deliberate to use the same value for the title and
    // aria-label; the aria-label is used to give more context to
    // screen-readers, and the title is to aid users who may not know what
    // the red/orange/green classification is, or those who are unable to
    // easily distinguish the visual colour differences.
    // clang-format off
    const title = value !== null ?
      i18nString(UIStrings.metricScore, {PH1: metric, PH2: valueText, PH3: classification}) :
      i18nString(UIStrings.metricScoreUnavailable, {PH1: metric});

    return metricIsVisible(activeCategory, metric) ? html`
      <button class="metric"
        @click=${relevantEvent ? onClickMetric.bind(relevantEvent) : null}
        title=${title}
        aria-label=${title}
      >
        <div class="metric-value metric-value-${classification}">${valueDisplay}</div>
      </button>
    ` : Lit.nothing;
    // clang-format on
  }

  function renderInsights(): Lit.LitTemplate {
    const shownInsightTemplates = shownInsights.map(renderInsightComponent);
    const passedInsightsTemplates = passedInsights.map(renderInsightComponent);

    // clang-format off
    return html`
      ${shownInsightTemplates}
      ${passedInsightsTemplates.length ? html`
        <details class="passed-insights-section">
          <summary>${i18nString(UIStrings.passedInsights, {
            PH1: passedInsightsTemplates.length,
          })}</summary>
          ${passedInsightsTemplates}
        </details>
      ` : Lit.nothing}
    `;
    // clang-format on
  }

  // clang-format off
  Lit.render(html`
    <style>${sidebarSingleInsightSetStyles}</style>
    <div class="navigation">
      ${renderMetrics()}
      ${renderInsights()}
    </div>
  `, target);
  // clang-format on
};

function metricIsVisible(activeCategory: Trace.Insights.Types.InsightCategory, label: 'LCP'|'CLS'|'INP'): boolean {
  if (activeCategory === Trace.Insights.Types.InsightCategory.ALL) {
    return true;
  }

  return label === activeCategory;
}

export class SidebarSingleInsightSet extends UI.Widget.Widget {
  #view: View;
  #insightRenderer = new Insights.InsightRenderer.InsightRenderer();
  #activeInsightElement: HTMLElement|null = null;
  #activeHighlightTimeout = -1;

  #data: SidebarSingleInsightSetData = {
    insightSetKey: null,
    activeCategory: Trace.Insights.Types.InsightCategory.ALL,
    activeInsight: null,
    parsedTrace: null,
  };

  #didDismissFieldMismatchNotice = false;

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

  set data(data: SidebarSingleInsightSetData) {
    this.#data = data;
    this.requestUpdate();
  }

  override willHide(): void {
    super.willHide();
    window.clearTimeout(this.#activeHighlightTimeout);
  }

  highlightActiveInsight(): void {
    if (!this.#activeInsightElement) {
      return;
    }

    // First clear any existing highlight that is going on.
    this.#activeInsightElement.removeAttribute('highlight-insight');
    window.clearTimeout(this.#activeHighlightTimeout);

    requestAnimationFrame(() => {
      this.#activeInsightElement?.setAttribute('highlight-insight', 'true');
      this.#activeHighlightTimeout = window.setTimeout(() => {
        this.#activeInsightElement?.removeAttribute('highlight-insight');
      }, 2_000);
    });
  }

  #onClickMetric(traceEvent: Trace.Types.Events.Event): void {
    this.element.dispatchEvent(new Insights.EventRef.EventReferenceClick(traceEvent));
  }

  #getLocalMetrics(insightSetKey: string): LocalMetrics|null {
    if (!this.#data.parsedTrace) {
      return null;
    }

    const insightSet = this.#data.parsedTrace.insights?.get(insightSetKey);
    if (!insightSet) {
      return null;
    }

    const lcp = Trace.Insights.Common.getLCP(insightSet);
    const cls = Trace.Insights.Common.getCLS(insightSet);
    const inp = Trace.Insights.Common.getINP(insightSet);

    return {lcp, cls, inp};
  }

  #getFieldMetrics(insightSetKey: string): Trace.Insights.Common.CrUXFieldMetricResults|null {
    if (!this.#data.parsedTrace) {
      return null;
    }

    const insightSet = this.#data.parsedTrace.insights?.get(insightSetKey);
    if (!insightSet) {
      return null;
    }

    const fieldMetricsResults = Trace.Insights.Common.getFieldMetricsForInsightSet(
        insightSet, this.#data.parsedTrace.metadata, CrUXManager.CrUXManager.instance().getSelectedScope());
    if (!fieldMetricsResults) {
      return null;
    }

    return fieldMetricsResults;
  }

  #onDismisFieldMismatchNotice(): void {
    this.#didDismissFieldMismatchNotice = true;
    this.requestUpdate();
  }

  static categorizeInsights(
      insightSets: Trace.Insights.Types.TraceInsightSets|null,
      insightSetKey: string,
      activeCategory: Trace.Insights.Types.InsightCategory,
      ): {shownInsights: InsightData[], passedInsights: InsightData[]} {
    const insightSet = insightSets?.get(insightSetKey);
    if (!insightSet) {
      return {shownInsights: [], passedInsights: []};
    }

    const shownInsights: InsightData[] = [];
    const passedInsights: InsightData[] = [];

    for (const [insightName, model] of Object.entries(insightSet.model)) {
      if (!model || !shouldRenderForCategory({activeCategory, insightCategory: model.category})) {
        continue;
      }

      if (model.state === 'pass') {
        passedInsights.push({insightName, model});
      } else {
        shownInsights.push({insightName, model});
      }
    }
    return {shownInsights, passedInsights};
  }

  #renderInsightComponent(
      insightSet: Trace.Insights.Types.InsightSet, insightData: InsightData,
      fieldMetrics: Trace.Insights.Common.CrUXFieldMetricResults|null): Lit.LitTemplate {
    if (!this.#data.parsedTrace) {
      return Lit.nothing;
    }

    const {insightName, model} = insightData;
    const activeInsight = this.#data.activeInsight;
    const agentFocus = AIAssistance.AIContext.AgentFocus.fromInsight(this.#data.parsedTrace, model);

    const widgetElement =
        this.#insightRenderer.renderInsightToWidgetElement(this.#data.parsedTrace, insightSet, model, insightName, {
          selected: activeInsight?.model === model,
          agentFocus,
          fieldMetrics,
        });

    if (activeInsight?.model === model) {
      this.#activeInsightElement = widgetElement;
    }

    return html`${widgetElement}`;
  }

  override performUpdate(): void {
    const {
      parsedTrace,
      insightSetKey,
    } = this.#data;

    if (!parsedTrace?.insights || !insightSetKey) {
      return;
    }

    const insightSet = parsedTrace.insights.get(insightSetKey);
    if (!insightSet) {
      return;
    }

    const local = this.#getLocalMetrics(insightSetKey);
    const field = this.#getFieldMetrics(insightSetKey);
    const {shownInsights, passedInsights} = SidebarSingleInsightSet.categorizeInsights(
        parsedTrace.insights,
        insightSetKey,
        this.#data.activeCategory,
    );

    const localValues = {
      lcp: local?.lcp?.value !== undefined ? Trace.Helpers.Timing.microToMilli(local?.lcp.value) : undefined,
      inp: local?.inp?.value !== undefined ? Trace.Helpers.Timing.microToMilli(local?.inp.value) : undefined,
    };
    const fieldValues = field && {
      lcp: field.lcp?.value !== undefined ? Trace.Helpers.Timing.microToMilli(field.lcp.value) : undefined,
      inp: field.inp?.value !== undefined ? Trace.Helpers.Timing.microToMilli(field.inp.value) : undefined,
    };
    const showFieldMismatchNotice =
        !this.#didDismissFieldMismatchNotice && !!fieldValues && isFieldWorseThanLocal(localValues, fieldValues);

    const input: ViewInput = {
      shownInsights,
      passedInsights,
      local,
      field,
      activeCategory: this.#data.activeCategory,
      showFieldMismatchNotice,
      onDismisFieldMismatchNotice: this.#onDismisFieldMismatchNotice.bind(this),
      onClickMetric: this.#onClickMetric.bind(this),
      renderInsightComponent: insightData => this.#renderInsightComponent(insightSet, insightData, field),
    };
    this.#view(input, undefined, this.contentElement);
  }
}
