// 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 type * as Platform from '../../../core/platform/platform.js';
import * as SDK from '../../../core/sdk/sdk.js';
import type * as Protocol from '../../../generated/protocol.js';
import * as Helpers from '../../../models/trace/helpers/helpers.js';
import * as Trace from '../../../models/trace/trace.js';
import * as Buttons from '../../../ui/components/buttons/buttons.js';
import * as LegacyComponents from '../../../ui/legacy/components/utils/utils.js';
import * as UI from '../../../ui/legacy/legacy.js';
import * as Lit from '../../../ui/lit/lit.js';

import * as Insights from './insights/insights.js';
import {nodeLink} from './insights/NodeLink.js';
import layoutShiftDetailsStyles from './layoutShiftDetails.css.js';

const {html, render} = Lit;

const MAX_URL_LENGTH = 20;

const UIStrings = {
  /**
   * @description Text referring to the start time of a given event.
   */
  startTime: 'Start time',
  /**
   * @description Text for a table header referring to the score of a Layout Shift event.
   */
  shiftScore: 'Shift score',
  /**
   * @description Text for a table header referring to the elements shifted for a Layout Shift event.
   */
  elementsShifted: 'Elements shifted',
  /**
   * @description Text for a table header referring to the culprit of a Layout Shift event.
   */
  culprit: 'Culprit',
  /**
   * @description Text for a culprit type of Injected iframe.
   */
  injectedIframe: 'Injected iframe',
  /**
   * @description Text for a culprit type of Font request.
   */
  fontRequest: 'Font request',
  /**
   * @description Text for a culprit type of non-composited animation.
   */
  nonCompositedAnimation: 'Non-composited animation',
  /**
   * @description Text referring to an animation.
   */
  animation: 'Animation',
  /**
   * @description Text referring to a parent cluster.
   */
  parentCluster: 'Parent cluster',
  /**
   * @description Text referring to a layout shift cluster and its start time.
   * @example {32 ms} PH1
   */
  cluster: 'Layout shift cluster @ {PH1}',
  /**
   * @description Text referring to a layout shift and its start time.
   * @example {32 ms} PH1
   */
  layoutShift: 'Layout shift @ {PH1}',
  /**
   * @description Text referring to the total cumulative score of a layout shift cluster.
   */
  total: 'Total',
  /**
   * @description Text for a culprit type of Unsized image.
   */
  unsizedImage: 'Unsized image',
} as const;

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

export interface ViewInput {
  event: Trace.Types.Events.SyntheticLayoutShift|Trace.Types.Events.SyntheticLayoutShiftCluster|null;
  parsedTrace: Trace.TraceModel.ParsedTrace|null;
  isFreshRecording: boolean;
  togglePopover: (e: MouseEvent) => void;
  onEventClick: (event: Trace.Types.Events.Event) => void;
}

export class LayoutShiftDetails extends UI.Widget.Widget {
  #view: typeof DEFAULT_VIEW;
  #event: Trace.Types.Events.SyntheticLayoutShift|Trace.Types.Events.SyntheticLayoutShiftCluster|null = null;
  #parsedTrace: Trace.TraceModel.ParsedTrace|null = null;
  #isFreshRecording = false;

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

  set event(event: Trace.Types.Events.SyntheticLayoutShift|Trace.Types.Events.SyntheticLayoutShiftCluster) {
    this.#event = event;
    void this.requestUpdate();
  }

  set parsedTrace(parsedTrace: Trace.TraceModel.ParsedTrace|null) {
    this.#parsedTrace = parsedTrace;
    void this.requestUpdate();
  }

  set isFreshRecording(isFreshRecording: boolean) {
    this.#isFreshRecording = isFreshRecording;
    void this.requestUpdate();
  }

  // TODO(crbug.com/368170718): use eventRef instead
  #handleTraceEventClick(event: Trace.Types.Events.Event): void {
    this.contentElement.dispatchEvent(new Insights.EventRef.EventReferenceClick(event));
  }

  #togglePopover(e: MouseEvent): void {
    const show = e.type === 'mouseover';
    if (e.type === 'mouseleave') {
      this.contentElement.dispatchEvent(
          new CustomEvent('toggle-popover', {detail: {show}, bubbles: true, composed: true}));
    }

    if (!(e.target instanceof HTMLElement) || !this.#event) {
      return;
    }
    const rowEl = e.target.closest('tbody tr');
    if (!rowEl?.parentElement) {
      return;
    }

    // Grab the associated trace event of this row.
    const event = Trace.Types.Events.isSyntheticLayoutShift(this.#event) ?
        this.#event :
        this.#event.events.find(e => e.ts === parseInt(rowEl.getAttribute('data-ts') ?? '', 10));
    this.contentElement.dispatchEvent(
        new CustomEvent('toggle-popover', {detail: {event, show}, bubbles: true, composed: true}));
  }

  override performUpdate(): Promise<void>|void {
    this.#view(
        {
          event: this.#event,
          parsedTrace: this.#parsedTrace,
          isFreshRecording: this.#isFreshRecording,
          togglePopover: e => this.#togglePopover(e),
          onEventClick: e => this.#handleTraceEventClick(e)
        },
        {}, this.contentElement);
  }
}

export const DEFAULT_VIEW: (input: ViewInput, output: object, target: HTMLElement) => void =
    (input, _output, target) => {
      if (!input.event || !input.parsedTrace) {
        render(Lit.nothing, target);
        return;
      }

      const title = Trace.Name.forEntry(input.event);
      // clang-format off
      render(html`
        <style>${layoutShiftDetailsStyles}</style>
        <style>${Buttons.textButtonStyles}</style>

      <div class="layout-shift-summary-details">
        <div
          class="event-details"
          @mouseover=${input.togglePopover}
          @mouseleave=${input.togglePopover}
        >
        <div class="layout-shift-details-title">
          <div class="layout-shift-event-title"></div>
          ${title}
        </div>
        ${Trace.Types.Events.isSyntheticLayoutShift(input.event) ?
          renderLayoutShiftDetails(
            input.event, input.parsedTrace.insights, input.parsedTrace, input.isFreshRecording, input.onEventClick,
         ) : renderLayoutShiftClusterDetails(
           input.event, input.parsedTrace.insights, input.parsedTrace, input.onEventClick,
         )}
        </div>
      </div>
      `, target);
      // clang-format on
    };

function findInsightSet(insightSets: Trace.Insights.Types.TraceInsightSets|null, navigationId: string|undefined):
    Trace.Insights.Types.InsightSet|undefined {
  return insightSets?.values().find(
      insightSet =>
          navigationId ? navigationId === insightSet.navigation?.args.data?.navigationId : !insightSet.navigation);
}

function renderLayoutShiftDetails(
    layoutShift: Trace.Types.Events.SyntheticLayoutShift, insightSets: Trace.Insights.Types.TraceInsightSets|null,
    parsedTrace: Trace.TraceModel.ParsedTrace, isFreshRecording: boolean,
    onEventClick: (e: Trace.Types.Events.Event) => void): Lit.LitTemplate {
  if (!insightSets) {
    return Lit.nothing;
  }

  const clsInsight = findInsightSet(insightSets, layoutShift.args.data?.navigationId)?.model.CLSCulprits;
  if (!clsInsight) {
    return Lit.nothing;
  }

  const rootCauses = clsInsight.shifts.get(layoutShift);

  let elementsShifted = layoutShift.args.data?.impacted_nodes ?? [];
  if (!isFreshRecording) {
    elementsShifted = elementsShifted?.filter(el => el.debug_name);
  }

  const hasCulprits = rootCauses &&
      (rootCauses.webFonts.length || rootCauses.iframes.length || rootCauses.nonCompositedAnimations.length ||
       rootCauses.unsizedImages.length);
  const hasShiftedElements = elementsShifted?.length;

  const parentCluster = clsInsight.clusters.find(cluster => {
    return cluster.events.find(event => event === layoutShift);
  });

  // clang-format off
    return html`
      <table class="layout-shift-details-table">
        <thead class="table-title">
          <tr>
            <th>${i18nString(UIStrings.startTime)}</th>
            <th>${i18nString(UIStrings.shiftScore)}</th>
            ${hasShiftedElements ? html`
              <th>${i18nString(UIStrings.elementsShifted)}</th>` : Lit.nothing}
            ${hasCulprits ? html`
              <th>${i18nString(UIStrings.culprit)}</th> ` : Lit.nothing}
          </tr>
        </thead>
        <tbody>
          ${renderShiftRow(layoutShift, true, parsedTrace, elementsShifted, onEventClick, rootCauses)}
        </tbody>
      </table>
      ${renderParentCluster(parentCluster, onEventClick, parsedTrace)}
    `;
  // clang-format on
}

function renderLayoutShiftClusterDetails(
    cluster: Trace.Types.Events.SyntheticLayoutShiftCluster, insightSets: Trace.Insights.Types.TraceInsightSets|null,
    parsedTrace: Trace.TraceModel.ParsedTrace, onEventClick: (e: Trace.Types.Events.Event) => void): Lit.LitTemplate {
  if (!insightSets) {
    return Lit.nothing;
  }

  const clsInsight = findInsightSet(insightSets, cluster.navigationId)?.model.CLSCulprits;
  if (!clsInsight) {
    return Lit.nothing;
  }

  // This finds the culprits of the cluster and returns an array of the culprits.
  const clusterCulprits = Array.from(clsInsight.shifts.entries())
                              .filter(([key]) => cluster.events.includes(key))
                              .map(([, value]) => value)
                              .flatMap(x => Object.values(x))
                              .flat();

  const hasCulprits = Boolean(clusterCulprits.length);

  // clang-format off
  return html`
    <table class="layout-shift-details-table">
      <thead class="table-title">
        <tr>
          <th>${i18nString(UIStrings.startTime)}</th>
          <th>${i18nString(UIStrings.shiftScore)}</th>
          <th>${i18nString(UIStrings.elementsShifted)}</th>
          ${hasCulprits ? html`
            <th>${i18nString(UIStrings.culprit)}</th> ` : Lit.nothing}
        </tr>
      </thead>
      <tbody>
        ${cluster.events.map(shift => {
          const rootCauses = clsInsight.shifts.get(shift);
          const elementsShifted = shift.args.data?.impacted_nodes ?? [];
          return renderShiftRow(shift, false, parsedTrace, elementsShifted, onEventClick, rootCauses);
        })}

        <tr>
          <td class="total-row">${i18nString(UIStrings.total)}</td>
          <td class="total-row">${(cluster.clusterCumulativeScore.toFixed(4))}</td>
        </tr>
      </tbody>
    </table>
  `;
  // clang-format on
}

function renderShiftRow(
    currentShift: Trace.Types.Events.SyntheticLayoutShift, userHasSingleShiftSelected: boolean,
    parsedTrace: Trace.TraceModel.ParsedTrace, elementsShifted: Trace.Types.Events.TraceImpactedNode[],
    onEventClick: (e: Trace.Types.Events.Event) => void,
    rootCauses: Trace.Insights.Models.CLSCulprits.LayoutShiftRootCausesData|undefined): Lit.LitTemplate {
  const score = currentShift.args.data?.weighted_score_delta;
  if (!score) {
    return Lit.nothing;
  }
  const hasCulprits = Boolean(
      rootCauses &&
      (rootCauses.webFonts.length || rootCauses.iframes.length || rootCauses.nonCompositedAnimations.length ||
       rootCauses.unsizedImages.length));

  // clang-format off
    return html`
      <tr class="shift-row" data-ts=${currentShift.ts}>
        <td>${renderStartTime(currentShift, userHasSingleShiftSelected, parsedTrace, onEventClick)}</td>
        <td>${(score.toFixed(4))}</td>
        ${elementsShifted.length ? html`
          <td>
            <div class="elements-shifted">
              ${renderShiftedElements(currentShift, elementsShifted)}
            </div>
          </td>` : Lit.nothing}
        ${hasCulprits ? html`
          <td class="culprits">
            ${rootCauses?.webFonts.map(fontReq => renderFontRequest(fontReq))}
            ${rootCauses?.iframes.map(iframe => renderIframe(iframe))}
            ${rootCauses?.nonCompositedAnimations.map(failure => renderAnimation(failure, onEventClick))}
            ${rootCauses?.unsizedImages.map(unsizedImage => renderUnsizedImage(currentShift.args.frame, unsizedImage))}
          </td>` : Lit.nothing}
      </tr>`;
  // clang-format on
}

function renderStartTime(
    shift: Trace.Types.Events.SyntheticLayoutShift,
    userHasSingleShiftSelected: boolean,
    parsedTrace: Trace.TraceModel.ParsedTrace,
    onEventClick: (e: Trace.Types.Events.Event) => void,
    ): Lit.TemplateResult {
  const ts = Trace.Types.Timing.Micro(shift.ts - parsedTrace.data.Meta.traceBounds.min);
  if (userHasSingleShiftSelected) {
    return html`${i18n.TimeUtilities.preciseMillisToString(Helpers.Timing.microToMilli(ts))}`;
  }
  const shiftTs = i18n.TimeUtilities.formatMicroSecondsTime(ts);
  // clang-format off
    return html`
         <button type="button" class="timeline-link" @click=${() => onEventClick(shift)}>${i18nString(UIStrings.layoutShift, {PH1: shiftTs})}</button>`;
  // clang-format on
}

function renderParentCluster(
    cluster: Trace.Types.Events.SyntheticLayoutShiftCluster|undefined,
    onEventClick: (e: Trace.Types.Events.Event) => void, parsedTrace: Trace.TraceModel.ParsedTrace): Lit.LitTemplate {
  if (!cluster) {
    return Lit.nothing;
  }
  const ts = Trace.Types.Timing.Micro(cluster.ts - (parsedTrace.data.Meta.traceBounds.min ?? 0));
  const clusterTs = i18n.TimeUtilities.formatMicroSecondsTime(ts);

  // clang-format off
    return html`
      <span class="parent-cluster">${i18nString(UIStrings.parentCluster)}:<button type="button" class="timeline-link parent-cluster-link" @click=${() => onEventClick(cluster)}>${i18nString(UIStrings.cluster, {PH1: clusterTs})}</button>
      </span>`;
  // clang-format on
}

function renderShiftedElements(
    shift: Trace.Types.Events.SyntheticLayoutShift,
    elementsShifted: Trace.Types.Events.TraceImpactedNode[]|undefined): Lit.TemplateResult {
  // clang-format off
    return html`
      ${elementsShifted?.map(el => {
        if (el.node_id !== undefined) {
          return nodeLink({
            backendNodeId: el.node_id,
            frame: shift.args.frame,
            fallbackHtmlSnippet: el.debug_name,
          });
        }
          return Lit.nothing;
      })}`;
  // clang-format on
}

function renderAnimation(
    failure: Trace.Insights.Models.CLSCulprits.NoncompositedAnimationFailure,
    onEventClick: (e: Trace.Types.Events.Event) => void,

    ): Lit.LitTemplate {
  const event = failure.animation;
  if (!event) {
    return Lit.nothing;
  }
  // clang-format off
    return html`
        <span class="culprit">
        <span class="culprit-type">${i18nString(UIStrings.nonCompositedAnimation)}: </span>
        <button type="button" class="culprit-value timeline-link" @click=${() => onEventClick(event)}>${i18nString(UIStrings.animation)}</button>
      </span>`;
  // clang-format on
}

function renderUnsizedImage(
    frame: string, unsizedImage: Trace.Insights.Models.CLSCulprits.UnsizedImage): Lit.LitTemplate {
  const nodeLinkEl = nodeLink({
    backendNodeId: unsizedImage.backendNodeId,
    frame,
    fallbackUrl: unsizedImage.paintImageEvent.args.data.url as Platform.DevToolsPath.UrlString | undefined,
  });
  // clang-format off
  return html`
    <span class="culprit">
      <span class="culprit-type">${i18nString(UIStrings.unsizedImage)}: </span>
      <span class="culprit-value">${nodeLinkEl}</span>
    </span>`;
  // clang-format on
}

function renderFontRequest(request: Trace.Types.Events.SyntheticNetworkRequest): Lit.LitTemplate {
  const linkifiedURL = linkifyURL(request.args.data.url as Platform.DevToolsPath.UrlString);

  // clang-format off
    return html`
      <span class="culprit">
        <span class="culprit-type">${i18nString(UIStrings.fontRequest)}: </span>
        <span class="culprit-value">${linkifiedURL}</span>
      </span>`;
  // clang-format on
}

function linkifyURL(url: Platform.DevToolsPath.UrlString): HTMLElement {
  return LegacyComponents.Linkifier.Linkifier.linkifyURL(url, {
    tabStop: true,
    showColumnNumber: false,
    inlineFrameIndex: 0,
    maxLength: MAX_URL_LENGTH,
  });
}

function renderIframe(iframeRootCause: Trace.Insights.Models.CLSCulprits.IframeRootCause): Lit.LitTemplate {
  const domLoadingId = iframeRootCause.frame as Protocol.Page.FrameId;
  const domLoadingFrame = SDK.FrameManager.FrameManager.instance().getFrame(domLoadingId);

  let el;
  if (domLoadingFrame) {
    el = LegacyComponents.Linkifier.Linkifier.linkifyRevealable(domLoadingFrame, domLoadingFrame.displayName());
  } else {
    el = linkifyURL(iframeRootCause.url as Platform.DevToolsPath.UrlString);
  }

  // clang-format off
    return html`
      <span class="culprit">
        <span class="culprit-type"> ${i18nString(UIStrings.injectedIframe)}: </span>
        <span class="culprit-value">${el}</span>
      </span>`;
  // clang-format on
}
