// 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.
/* eslint-disable @devtools/no-lit-render-outside-of-view */

import * as i18n from '../../../core/i18n/i18n.js';
import * as Platform from '../../../core/platform/platform.js';
import * as CrUXManager from '../../../models/crux-manager/crux-manager.js';
import type * as Trace from '../../../models/trace/trace.js';
import * as Buttons from '../../../ui/components/buttons/buttons.js';
import * as ComponentHelpers from '../../../ui/components/helpers/helpers.js';
import * as UIHelpers from '../../../ui/helpers/helpers.js';
import * as Lit from '../../../ui/lit/lit.js';
import * as VisualLogging from '../../../ui/visual_logging/visual_logging.js';

import metricCardStyles from './metricCard.css.js';
import {type CompareRating, renderCompareText, renderDetailedCompareText} from './MetricCompareStrings.js';
import metricValueStyles from './metricValueStyles.css.js';
import {
  CLS_THRESHOLDS,
  determineCompareRating,
  INP_THRESHOLDS,
  LCP_THRESHOLDS,
  type MetricRating,
  type MetricThresholds,
  rateMetric,
  renderMetricValue,
} from './Utils.js';

const {html, nothing} = Lit;

const UIStrings = {
  /**
   * @description Label for a metric value measured in the local environment in the live metrics view of the Performance panel.
   */
  localValue: 'Local',
  /**
   * @description Label for the 75th percentile of real user field metrics in the live metrics view of the Performance panel.
   */
  field75thPercentile: 'Field 75th percentile',
  /**
   * @description Column header for the 75th percentile field metrics in the live metrics view of the Performance panel.
   */
  fieldP75: 'Field p75',
  /**
   * @description Label for metric values classified as good in the live metrics view of the Performance panel.
   */
  good: 'Good',
  /**
   * @description Label for metric values classified as needs improvement in the live metrics view of the Performance panel.
   */
  needsImprovement: 'Needs improvement',
  /**
   * @description Label for metric values classified as poor in the live metrics view of the Performance panel.
   */
  poor: 'Poor',
  /**
   * @description Label for a range of values that are less than or equal to a threshold in the live metrics view of the Performance panel.
   * @example {500 ms} PH1
   */
  leqRange: '(≤{PH1})',
  /**
   * @description Label for a range of values between two thresholds in the live metrics view of the Performance panel.
   * @example {500 ms} PH1
   * @example {800 ms} PH2
   */
  betweenRange: '({PH1}-{PH2})',
  /**
   * @description Label for a range of values greater than a threshold in the live metrics view of the Performance panel.
   * @example {500 ms} PH1
   */
  gtRange: '(>{PH1})',
  /**
   * @description Percentage value format string in the live metrics view of the Performance panel.
   * @example {13} PH1
   */
  percentage: '{PH1}%',
  /**
   * @description Prompt instructing the user to interact with the page to measure INP in the live metrics view of the Performance panel.
   */
  interactToMeasure: 'Interact with the page to measure INP.',
  /**
   * @description Tooltip label to expand more details in the metric card of the Performance panel.
   */
  viewCardDetails: 'View card details',
  /**
   * @description Header recommending the user inspect their local test environment in the live metrics view of the Performance panel.
   */
  considerTesting: 'Consider your local test conditions',
  /**
   * @description Recommendation explaining how network throttling affects LCP page loads in the Performance panel.
   */
  recThrottlingLCP:
      'Real users may experience longer page loads due to slower network conditions. Increasing network throttling will simulate slower network conditions.',
  /**
   * @description Recommendation explaining how CPU throttling affects INP interaction delays in the Performance panel.
   */
  recThrottlingINP:
      'Real users may experience longer interactions due to slower CPU speeds. Increasing CPU throttling will simulate a slower device.',
  /**
   * @description Recommendation explaining how viewport size affects the LCP element in the Performance panel.
   */
  recViewportLCP: 'Screen size can influence what the LCP element is. Ensure you are testing common viewport sizes.',
  /**
   * @description Recommendation explaining how viewport size affects layout shifts in the Performance panel.
   */
  recViewportCLS: 'Screen size can influence what layout shifts happen. Ensure you are testing common viewport sizes.',
  /**
   * @description Recommendation explaining how user interaction journeys affect layout shifts in the Performance panel.
   */
  recJourneyCLS:
      'How a user interacts with the page can influence layout shifts. Ensure you are testing common interactions like scrolling the page.',
  /**
   * @description Recommendation explaining how user interaction journeys affect interaction delays in the Performance panel.
   */
  recJourneyINP:
      'How a user interacts with the page influences interaction delays. Ensure you are testing common interactions.',
  /**
   * @description Recommendation explaining how dynamic content affects LCP in the Performance panel.
   */
  recDynamicContentLCP: 'The LCP element can vary between page loads if content is dynamic.',
  /**
   * @description Recommendation explaining how dynamic content affects layout shifts in the Performance panel.
   */
  recDynamicContentCLS: 'Dynamic content can influence what layout shifts happen.',
  /**
   * @description Table column header for subpart stage names in the live metrics view of the Performance panel.
   */
  subpart: 'Subpart',
  /**
   * @description Tooltip text explaining the Largest Contentful Paint (LCP) metric in the live metrics view of the Performance panel.
   */
  lcpHelpTooltip:
      'LCP reports the render time of the largest image, text block, or video visible in the viewport. Click here to learn more about LCP.',
  /**
   * @description Tooltip text explaining the Cumulative Layout Shift (CLS) metric in the live metrics view of the Performance panel.
   */
  clsHelpTooltip: 'CLS measures the amount of unexpected shifted content. Click here to learn more about CLS.',
  /**
   * @description Tooltip text explaining the Interaction to Next Paint (INP) metric in the live metrics view of the Performance panel.
   */
  inpHelpTooltip:
      'INP measures the overall responsiveness to all click, tap, and keyboard interactions. Click here to learn more about INP.',
} as const;

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

export type SubpartTable = Array<[string, Trace.Types.Timing.Milli, Trace.Types.Timing.Milli?]>;

export interface MetricCardData {
  metric: 'LCP'|'CLS'|'INP';
  localValue?: number;
  fieldValue?: number|string;
  histogram?: CrUXManager.MetricResponse['histogram'];
  tooltipContainer?: HTMLElement;
  subparts?: SubpartTable;
  warnings?: string[];
}

export class MetricCard extends HTMLElement {
  readonly #shadow = this.attachShadow({mode: 'open'});

  constructor() {
    super();

    this.#render();
  }

  #tooltipEl?: HTMLElement;

  #data: MetricCardData = {
    metric: 'LCP',
  };

  set data(data: MetricCardData) {
    this.#data = data;
    void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render);
  }

  connectedCallback(): void {
    void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render);
  }

  #hideTooltipOnEsc = (event: KeyboardEvent): void => {
    if (Platform.KeyboardUtilities.isEscKey(event)) {
      event.stopPropagation();
      this.#hideTooltip();
    }
  };

  #hideTooltipOnMouseLeave(event: Event): void {
    const target = event.target as HTMLElement;
    if (target?.hasFocus()) {
      return;
    }

    this.#hideTooltip();
  }

  #hideTooltipOnFocusOut(event: FocusEvent): void {
    const target = event.target as HTMLElement;
    if (target?.hasFocus()) {
      return;
    }

    const relatedTarget = event.relatedTarget;
    if (relatedTarget instanceof Node && target.contains(relatedTarget)) {
      // `focusout` bubbles so we should get another event once focus leaves `relatedTarget`
      return;
    }

    this.#hideTooltip();
  }

  #hideTooltip(): void {
    const tooltipEl = this.#tooltipEl;
    if (!tooltipEl) {
      return;
    }

    document.body.removeEventListener('keydown', this.#hideTooltipOnEsc);

    tooltipEl.style.removeProperty('left');
    tooltipEl.style.removeProperty('visibility');
    tooltipEl.style.removeProperty('display');
    tooltipEl.style.removeProperty('transition-delay');
  }

  #showTooltip(delayMs = 0): void {
    const tooltipEl = this.#tooltipEl;
    if (!tooltipEl || tooltipEl.style.visibility || tooltipEl.style.display) {
      return;
    }

    document.body.addEventListener('keydown', this.#hideTooltipOnEsc);

    tooltipEl.style.display = 'block';
    tooltipEl.style.transitionDelay = `${Math.round(delayMs)}ms`;

    const container = this.#data.tooltipContainer;
    if (!container) {
      return;
    }

    const containerBox = container.getBoundingClientRect();
    tooltipEl.style.setProperty('--tooltip-container-width', `${Math.round(containerBox.width)}px`);

    requestAnimationFrame(() => {
      let offset = 0;

      const tooltipBox = tooltipEl.getBoundingClientRect();

      const rightDiff = tooltipBox.right - containerBox.right;
      const leftDiff = tooltipBox.left - containerBox.left;

      if (leftDiff < 0) {
        offset = Math.round(leftDiff);
      } else if (rightDiff > 0) {
        offset = Math.round(rightDiff);
      }

      tooltipEl.style.left = `calc(50% - ${offset}px)`;
      tooltipEl.style.visibility = 'visible';
    });
  }

  #getTitle(): string {
    switch (this.#data.metric) {
      case 'LCP':
        return i18n.i18n.lockedString('Largest Contentful Paint (LCP)');
      case 'CLS':
        return i18n.i18n.lockedString('Cumulative Layout Shift (CLS)');
      case 'INP':
        return i18n.i18n.lockedString('Interaction to Next Paint (INP)');
    }
  }

  #getThresholds(): MetricThresholds {
    switch (this.#data.metric) {
      case 'LCP':
        return LCP_THRESHOLDS;
      case 'CLS':
        return CLS_THRESHOLDS;
      case 'INP':
        return INP_THRESHOLDS;
    }
  }

  #getFormatFn(): (value: number) => string {
    switch (this.#data.metric) {
      case 'LCP':
        return v => {
          const micro = (v * 1000) as Platform.Timing.MicroSeconds;
          return i18n.TimeUtilities.formatMicroSecondsAsSeconds(micro);
        };
      case 'CLS':
        return v => v === 0 ? '0' : v.toFixed(2);
      case 'INP':
        return v => i18n.TimeUtilities.preciseMillisToString(v);
    }
  }

  #getHelpLink(): Platform.DevToolsPath.UrlString {
    switch (this.#data.metric) {
      case 'LCP':
        return 'https://web.dev/articles/lcp' as Platform.DevToolsPath.UrlString;
      case 'CLS':
        return 'https://web.dev/articles/cls' as Platform.DevToolsPath.UrlString;
      case 'INP':
        return 'https://web.dev/articles/inp' as Platform.DevToolsPath.UrlString;
    }
  }

  #getHelpTooltip(): string {
    switch (this.#data.metric) {
      case 'LCP':
        return i18nString(UIStrings.lcpHelpTooltip);
      case 'CLS':
        return i18nString(UIStrings.clsHelpTooltip);
      case 'INP':
        return i18nString(UIStrings.inpHelpTooltip);
    }
  }

  #getLocalValue(): number|undefined {
    const {localValue} = this.#data;
    if (localValue === undefined) {
      return;
    }

    return localValue;
  }

  #getFieldValue(): number|undefined {
    let {fieldValue} = this.#data;
    if (fieldValue === undefined) {
      return;
    }

    if (typeof fieldValue === 'string') {
      fieldValue = Number(fieldValue);
    }

    if (!Number.isFinite(fieldValue)) {
      return;
    }

    return fieldValue;
  }

  /**
   * Returns if the local value is better/worse/similar compared to field.
   */
  #getCompareRating(): CompareRating|undefined {
    const localValue = this.#getLocalValue();
    const fieldValue = this.#getFieldValue();
    if (localValue === undefined || fieldValue === undefined) {
      return;
    }

    return determineCompareRating(this.#data.metric, localValue, fieldValue);
  }

  #renderCompareString(): Lit.LitTemplate {
    const localValue = this.#getLocalValue();
    if (localValue === undefined) {
      if (this.#data.metric === 'INP') {
        return html`
          <div class="compare-text">${i18nString(UIStrings.interactToMeasure)}</div>
        `;
      }
      return Lit.nothing;
    }

    const compare = this.#getCompareRating();
    const rating = rateMetric(localValue, this.#getThresholds());

    const valueEl = renderMetricValue(
        this.#getMetricValueLogContext(true), localValue, this.#getThresholds(), this.#getFormatFn(), {dim: true});

    // clang-format off
    return html`
      <div class="compare-text">
        ${renderCompareText({
          metric: i18n.i18n.lockedString(this.#data.metric),
          rating,
          compare,
          localValue: valueEl,
        })}
      </div>
    `;
    // clang-format on
  }

  #renderEnvironmentRecommendations(): Lit.LitTemplate {
    const compare = this.#getCompareRating();
    if (!compare || compare === 'similar') {
      return Lit.nothing;
    }

    const recs: string[] = [];
    const metric = this.#data.metric;

    // Recommend using throttling
    if (metric === 'LCP' && compare === 'better') {
      recs.push(i18nString(UIStrings.recThrottlingLCP));
    } else if (metric === 'INP' && compare === 'better') {
      recs.push(i18nString(UIStrings.recThrottlingINP));
    }

    // Recommend trying new viewport sizes
    if (metric === 'LCP') {
      recs.push(i18nString(UIStrings.recViewportLCP));
    } else if (metric === 'CLS') {
      recs.push(i18nString(UIStrings.recViewportCLS));
    }

    // Recommend trying new user journeys
    if (metric === 'CLS') {
      recs.push(i18nString(UIStrings.recJourneyCLS));
    } else if (metric === 'INP') {
      recs.push(i18nString(UIStrings.recJourneyINP));
    }

    // Recommend accounting for dynamic content
    if (metric === 'LCP') {
      recs.push(i18nString(UIStrings.recDynamicContentLCP));
    } else if (metric === 'CLS') {
      recs.push(i18nString(UIStrings.recDynamicContentCLS));
    }

    if (!recs.length) {
      return Lit.nothing;
    }

    return html`
      <details class="environment-recs">
        <summary>${i18nString(UIStrings.considerTesting)}</summary>
        <ul class="environment-recs-list">${recs.map(rec => html`<li>${rec}</li>`)}</ul>
      </details>
    `;
  }

  #getMetricValueLogContext(isLocal: boolean): string {
    return `timeline.landing.${isLocal ? 'local' : 'field'}-${this.#data.metric.toLowerCase()}`;
  }

  #renderDetailedCompareString(): Lit.LitTemplate {
    const localValue = this.#getLocalValue();
    if (localValue === undefined) {
      if (this.#data.metric === 'INP') {
        return html`
          <div class="detailed-compare-text">${i18nString(UIStrings.interactToMeasure)}</div>
        `;
      }
      return Lit.nothing;
    }

    const localRating = rateMetric(localValue, this.#getThresholds());

    const fieldValue = this.#getFieldValue();
    const fieldRating = fieldValue !== undefined ? rateMetric(fieldValue, this.#getThresholds()) : undefined;

    const localValueEl = renderMetricValue(
        this.#getMetricValueLogContext(true), localValue, this.#getThresholds(), this.#getFormatFn(), {dim: true});
    const fieldValueEl = renderMetricValue(
        this.#getMetricValueLogContext(false), fieldValue, this.#getThresholds(), this.#getFormatFn(), {dim: true});

    // clang-format off
    return html`
      <div class="detailed-compare-text">${renderDetailedCompareText({
        metric: i18n.i18n.lockedString(this.#data.metric),
        localRating,
        fieldRating,
        localValue: localValueEl,
        fieldValue: fieldValueEl,
        percent: this.#getPercentLabelForRating(localRating),
      })}</div>
    `;
    // clang-format on
  }

  #bucketIndexForRating(rating: MetricRating): number {
    switch (rating) {
      case 'good':
        return 0;
      case 'needs-improvement':
        return 1;
      case 'poor':
        return 2;
    }
  }

  #getBarWidthForRating(rating: MetricRating): string {
    const histogram = this.#data.histogram;
    const density = histogram?.[this.#bucketIndexForRating(rating)].density || 0;
    const percent = Math.round(density * 100);
    return `${percent}%`;
  }

  #getPercentLabelForRating(rating: MetricRating): string {
    const histogram = this.#data.histogram;
    if (histogram === undefined) {
      return '-';
    }

    // A missing density value should be interpreted as 0%
    const density = histogram[this.#bucketIndexForRating(rating)].density || 0;
    const percent = Math.round(density * 100);
    return i18nString(UIStrings.percentage, {PH1: percent});
  }

  #renderFieldHistogram(): Lit.LitTemplate {
    const fieldEnabled = CrUXManager.CrUXManager.instance().getConfigSetting().get().enabled;

    const format = this.#getFormatFn();
    const thresholds = this.#getThresholds();

    // clang-format off
    const goodLabel = html`
      <div class="bucket-label">
        <span>${i18nString(UIStrings.good)}</span>
        <span class="bucket-range"> ${i18nString(UIStrings.leqRange, {PH1: format(thresholds[0])})}</span>
      </div>
    `;

    const needsImprovementLabel = html`
      <div class="bucket-label">
        <span>${i18nString(UIStrings.needsImprovement)}</span>
        <span class="bucket-range"> ${i18nString(UIStrings.betweenRange, {PH1: format(thresholds[0]), PH2: format(thresholds[1])})}</span>
      </div>
    `;

    const poorLabel = html`
      <div class="bucket-label">
        <span>${i18nString(UIStrings.poor)}</span>
        <span class="bucket-range"> ${i18nString(UIStrings.gtRange, {PH1: format(thresholds[1])})}</span>
      </div>
    `;
    // clang-format on

    if (!fieldEnabled) {
      return html`
        <div class="bucket-summaries">
          ${goodLabel}
          ${needsImprovementLabel}
          ${poorLabel}
        </div>
      `;
    }

    // clang-format off
    return html`
      <div class="bucket-summaries histogram" jslog=${VisualLogging.canvas('metric-histogram')}>
        ${goodLabel}
        <div class="histogram-bar good-bg" style="width: ${this.#getBarWidthForRating('good')}"></div>
        <div class="histogram-percent">${this.#getPercentLabelForRating('good')}</div>
        ${needsImprovementLabel}
        <div class="histogram-bar needs-improvement-bg" style="width: ${this.#getBarWidthForRating('needs-improvement')}"></div>
        <div class="histogram-percent">${this.#getPercentLabelForRating('needs-improvement')}</div>
        ${poorLabel}
        <div class="histogram-bar poor-bg" style="width: ${this.#getBarWidthForRating('poor')}"></div>
        <div class="histogram-percent">${this.#getPercentLabelForRating('poor')}</div>
      </div>
    `;
    // clang-format on
  }

  #renderSubpartTable(subparts: SubpartTable): Lit.LitTemplate {
    const hasFieldData = subparts.every(subpart => subpart[2] !== undefined);

    // clang-format off
    return html`
      <hr class="divider">
      <div class="subpart-table" role="table">
        <div class="subpart-table-row subpart-table-header-row" role="row">
          <div role="columnheader" style="grid-column: 1">${i18nString(UIStrings.subpart)}</div>
          <div role="columnheader" class="subpart-table-value" style="grid-column: 2">${i18nString(UIStrings.localValue)}</div>
          ${hasFieldData ? html`
            <div
              role="columnheader"
              class="subpart-table-value"
              style="grid-column: 3"
              title=${i18nString(UIStrings.field75thPercentile)}>${i18nString(UIStrings.fieldP75)}</div>
          ` : nothing}
        </div>
        ${subparts.map(subpart => html`
          <div class="subpart-table-row" role="row" jslog=${VisualLogging.tableRow('metric-subpart')}>
            <div role="cell">${subpart[0]}</div>
            <div role="cell" class="subpart-table-value">${i18n.TimeUtilities.preciseMillisToString(subpart[1])}</div>
            ${subpart[2] !== undefined ? html`
              <div role="cell" class="subpart-table-value">${i18n.TimeUtilities.preciseMillisToString(subpart[2])}</div>
            ` : nothing}
          </div>
        `)}
      </div>
    `;
    // clang-format on
  }

  #render = (): void => {
    const fieldEnabled = CrUXManager.CrUXManager.instance().getConfigSetting().get().enabled;
    const helpLink = this.#getHelpLink();

    const localValue = this.#getLocalValue();
    const fieldValue = this.#getFieldValue();
    const thresholds = this.#getThresholds();
    const formatFn = this.#getFormatFn();

    const localValueEl = renderMetricValue(this.#getMetricValueLogContext(true), localValue, thresholds, formatFn);
    const fieldValueEl = renderMetricValue(this.#getMetricValueLogContext(false), fieldValue, thresholds, formatFn);

    // clang-format off
    const output = html`
      <style>${metricCardStyles}</style>
      <style>${metricValueStyles}</style>
      <div class="metric-card" jslog=${VisualLogging.section(Platform.StringUtilities.toKebabCase(this.#data.metric))}>
        <h3 class="title">
          ${this.#getTitle()}
          <devtools-button
            class="title-help"
            title=${this.#getHelpTooltip()}
            .iconName=${'help'}
            .variant=${Buttons.Button.Variant.ICON}
            @click=${() => UIHelpers.openInNewTab(helpLink)}
          ></devtools-button>
        </h3>
        <div tabindex="0" class="metric-values-section"
          @mouseenter=${() => this.#showTooltip(500)}
          @mouseleave=${this.#hideTooltipOnMouseLeave}
          @focusin=${this.#showTooltip}
          @focusout=${this.#hideTooltipOnFocusOut}
          aria-describedby="tooltip"
        >
          <div class="metric-source-block">
            <div class="metric-source-value" id="local-value">${localValueEl}</div>
            ${fieldEnabled ? html`<div class="metric-source-label">${i18nString(UIStrings.localValue)}</div>` : nothing}
          </div>
          ${fieldEnabled ? html`
            <div class="metric-source-block">
              <div class="metric-source-value" id="field-value">${fieldValueEl}</div>
              <div class="metric-source-label">${i18nString(UIStrings.field75thPercentile)}</div>
            </div>
          `: nothing}
          <div
            id="tooltip"
            class="tooltip"
            role="tooltip"
            aria-label=${i18nString(UIStrings.viewCardDetails)}
            ${Lit.Directives.ref(el => {
              if (el instanceof HTMLElement) {
                this.#tooltipEl = el as HTMLElement;
              }
            })}
          >
            <div class="tooltip-scroll">
              <div class="tooltip-contents">
                <div>
                  ${this.#renderDetailedCompareString()}
                  <hr class="divider">
                  ${this.#renderFieldHistogram()}
                  ${localValue && this.#data.subparts ? this.#renderSubpartTable(this.#data.subparts) : nothing}
                </div>
              </div>
            </div>
          </div>
        </div>
        ${fieldEnabled ? html`<hr class="divider">` : nothing}
        ${this.#renderCompareString()}
        ${this.#data.warnings?.map(warning => html`
          <div class="warning">${warning}</div>
        `)}
        ${this.#renderEnvironmentRecommendations()}
        <slot name="extra-info"></slot>
      </div>
    `;
    Lit.render(output, this.#shadow, {host: this});
  };
  // clang-format on
}

customElements.define('devtools-metric-card', MetricCard);

declare global {
  interface HTMLElementTagNameMap {
    'devtools-metric-card': MetricCard;
  }
}
