// 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 WebVitals from '../../../third_party/web-vitals/web-vitals.js';
import type * as Trace from '../../trace/trace.js';

import * as OnEachLayoutShift from './OnEachLayoutShift.js';
import * as Spec from './spec/spec.js';

const {onLCP, onCLS, onINP} = WebVitals.Attribution;
const {onEachLayoutShift} = OnEachLayoutShift;

declare const window: Window&{
  getNodeForIndex: (index: number) => Node | undefined,
  [Spec.INTERNAL_KILL_SWITCH]: () => void,
  [Spec.EVENT_BINDING_NAME]: (payload: string) => void,
  devToolsReportSoftNavs?: boolean,
};

const eventListenerCleanupController = new AbortController();

const patchAddListener = (proto: typeof Window.prototype|typeof Document.prototype): void => {
  const original = proto.addEventListener;

  proto.addEventListener = function(
      type: string, listener: EventListenerOrEventListenerObject, options?: boolean|AddEventListenerOptions): void {
    // Standardize options into an object
    const navOptions = typeof options === 'boolean' ? {capture: options} : {...options};

    // If we already have a signal, we should respect it,
    // but also link it to our global cleanup signal.
    if (navOptions.signal) {
      navOptions.signal = AbortSignal.any([navOptions.signal, eventListenerCleanupController.signal]);
    } else {
      navOptions.signal = eventListenerCleanupController.signal;
    }

    return original.call(this, type, listener, navOptions);
  };
};

// Patch the core targets
patchAddListener(Window.prototype);
patchAddListener(Document.prototype);

// Use a class wrapper that auto-registers and auto-unregisters
const activeObservers = new Set<PerformanceObserver>();
class TrackedPerformanceObserver extends globalThis.PerformanceObserver {
  constructor(callback: PerformanceObserverCallback) {
    super(callback);
    activeObservers.add(this);
  }

  // Override disconnect to remove it from our tracking set
  override disconnect(): void {
    super.disconnect();
    activeObservers.delete(this);
  }
}

const nodeList: Array<WeakRef<Node>> = [];
const nodeToIdMap = new WeakMap<Node, number>();

function establishNodeIndex(node: Node): number {
  let index = nodeToIdMap.get(node);
  if (index !== undefined) {
    return index;
  }

  index = nodeList.length;
  nodeList.push(new WeakRef(node));
  nodeToIdMap.set(node, index);
  return index;
}

// Replace the global constructor
globalThis.PerformanceObserver = TrackedPerformanceObserver;

/**
 * This is a hack solution to remove any listeners that were added by web-vitals.js
 * or additional services in this bundle. Once this function is called, the execution
 * context should be considered dead and a new one will need to be created for live metrics
 * to be served again.
 */
let killed = false;
window[Spec.INTERNAL_KILL_SWITCH] = () => {
  if (killed) {
    return;
  }

  for (const observer of activeObservers) {
    // This calls the overridden disconnect above,
    // cleaning up BOTH the browser resource and our Set.
    observer.disconnect();
  }
  activeObservers.clear();

  eventListenerCleanupController.abort();

  // Explicitly clear the Node List to help GC
  nodeList.length = 0;

  killed = true;
};

function sendEventToDevTools(event: Spec.WebVitalsEvent): void {
  const payload = JSON.stringify(event);
  window[Spec.EVENT_BINDING_NAME]?.(payload);
}

/**
 * The data sent over the event binding needs to be JSON serializable, so we
 * can't send DOM nodes directly. Instead we create an ID for each node (see
 * `establishNodeIndex`) that we can later use to retrieve a remote object
 * for that node.
 *
 * This function is used by `Runtime.evaluate` calls to get a remote object
 * for the specified index.
 */
window.getNodeForIndex = (index: number): Node|undefined => {
  return nodeList[index].deref();
};

function limitScripts(loafs: Spec.PerformanceLongAnimationFrameTimingJSON[]):
    Spec.PerformanceLongAnimationFrameTimingJSON[] {
  return loafs.map(loaf => {
    const longestScripts: Spec.PerformanceScriptTimingJSON[] = [];
    for (const script of loaf.scripts) {
      if (longestScripts.length < Spec.SCRIPTS_PER_LOAF_LIMIT) {
        longestScripts.push(script);
        continue;
      }

      const shorterIndex = longestScripts.findIndex(s => s.duration < script.duration);
      if (shorterIndex === -1) {
        continue;
      }

      longestScripts[shorterIndex] = script;
    }
    longestScripts.sort((a, b) => a.startTime - b.startTime);
    loaf.scripts = longestScripts;
    return loaf;
  });
}

function isPrerendered(): boolean {
  if (document.prerendering) {
    return true;
  }

  const firstNavStart = self.performance.getEntriesByType?.('navigation')[0]?.activationStart;
  return firstNavStart !== undefined && firstNavStart > 0;
}

let startedHidden: boolean|null = null;

function initialize(): void {
  sendEventToDevTools({name: 'reset'});

  new PerformanceObserver(list => {
    for (const entry of list.getEntries()) {
      if (startedHidden === null && !isPrerendered()) {
        startedHidden = entry.name === 'hidden';
      }
    }
  }).observe({type: 'visibility-state', buffered: true});

  // We want to treat bfcache navigations like a standard navigations, so emit
  // a reset event when bfcache is restored.
  //
  // Metric functions will also re-emit their values using this listener's callback.
  // To ensure this event is fired before those values are emitted, register this
  // callback before any others.
  WebVitals.onBFCacheRestore(() => {
    startedHidden = false;
    sendEventToDevTools({name: 'reset', navigationType: 'back-forward-cache'});
  });

  let lastLcpNavigationId: number|undefined;
  onLCP(metric => {
    if (lastLcpNavigationId && metric.navigationId && metric.navigationId !== lastLcpNavigationId) {
      sendEventToDevTools({name: 'reset', url: window.location.href, navigationType: metric.navigationType});
    }
    lastLcpNavigationId = metric.navigationId;
    const event: Spec.LcpChangeEvent = {
      name: 'LCP',
      value: metric.value as Trace.Types.Timing.Milli,
      startedHidden: Boolean(startedHidden),
      subparts: {
        timeToFirstByte: metric.attribution.timeToFirstByte as Trace.Types.Timing.Milli,
        resourceLoadDelay: metric.attribution.resourceLoadDelay as Trace.Types.Timing.Milli,
        resourceLoadTime: metric.attribution.resourceLoadDuration as Trace.Types.Timing.Milli,
        elementRenderDelay: metric.attribution.elementRenderDelay as Trace.Types.Timing.Milli,
      },
    };

    const element = metric.attribution.lcpEntry?.element;
    if (element) {
      event.nodeIndex = establishNodeIndex(element);
    }
    sendEventToDevTools(event);
  }, {reportAllChanges: true, reportSoftNavs: window.devToolsReportSoftNavs});

  onCLS(metric => {
    const event: Spec.ClsChangeEvent = {
      name: 'CLS',
      value: metric.value,
      clusterShiftIds: metric.entries.map(Spec.getUniqueLayoutShiftId),
    };
    sendEventToDevTools(event);
  }, {reportAllChanges: true, reportSoftNavs: window.devToolsReportSoftNavs});

  function onEachInteraction(interaction: WebVitals.INPMetricWithAttribution): void {
    // Multiple `InteractionEntry` events can be emitted for the same `uniqueInteractionId`
    // However, it is easier to combine these entries in the DevTools client rather than in
    // this injected code.
    const event: Spec.InteractionEntryEvent = {
      name: 'InteractionEntry',
      duration: interaction.value as Trace.Types.Timing.Milli,
      subparts: {
        inputDelay: interaction.attribution.inputDelay as Trace.Types.Timing.Milli,
        processingDuration: interaction.attribution.processingDuration as Trace.Types.Timing.Milli,
        presentationDelay: interaction.attribution.presentationDelay as Trace.Types.Timing.Milli,
      },
      startTime: interaction.entries[0].startTime,
      navigationId: interaction.navigationId,
      entryGroupId: interaction.entries[0].interactionId as Spec.InteractionEntryGroupId,
      nextPaintTime: interaction.attribution.nextPaintTime,
      interactionType: interaction.attribution.interactionType,
      eventName: interaction.entries[0].name,
      // To limit the amount of events, just get the last 5 LoAFs
      longAnimationFrameEntries: limitScripts(
          interaction.attribution.longAnimationFrameEntries.slice(-Spec.LOAF_LIMIT).map(loaf => loaf.toJSON())),
    };
    const target = interaction.attribution.interactionTarget;
    if (target) {
      event.nodeIndex = Number(target);
    }
    sendEventToDevTools(event);
  }

  onINP(metric => {
    const event: Spec.InpChangeEvent = {
      name: 'INP',
      value: metric.value as Trace.Types.Timing.Milli,
      subparts: {
        inputDelay: metric.attribution.inputDelay as Trace.Types.Timing.Milli,
        processingDuration: metric.attribution.processingDuration as Trace.Types.Timing.Milli,
        presentationDelay: metric.attribution.presentationDelay as Trace.Types.Timing.Milli,
      },
      startTime: metric.entries[0].startTime,
      entryGroupId: metric.entries[0].interactionId as Spec.InteractionEntryGroupId,
      interactionType: metric.attribution.interactionType,
    };
    sendEventToDevTools(event);
  }, {
    reportAllChanges: true,
    durationThreshold: 0,
    includeProcessedEventEntries: false,
    reportSoftNavs: window.devToolsReportSoftNavs,
    onEachInteraction,
    generateTarget(el) {
      if (el) {
        return String(establishNodeIndex(el));
      }

      return undefined;
    },
  });

  onEachLayoutShift(layoutShift => {
    const event: Spec.LayoutShiftEvent = {
      name: 'LayoutShift',
      score: layoutShift.value,
      uniqueLayoutShiftId: Spec.getUniqueLayoutShiftId(layoutShift.entry),
      affectedNodeIndices: layoutShift.attribution.affectedNodes.map(establishNodeIndex),
    };

    sendEventToDevTools(event);
  });
}
initialize();
