/*
 * Copyright 2022 Google LLC
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

import {getLoadState} from '../lib/getLoadState.js';
import {getSelector} from '../lib/getSelector.js';
import {
  longestInteractionList,
  entryPreProcessingCallbacks,
  longestInteractionMap,
} from '../lib/interactions.js';
import {observe} from '../lib/observe.js';
import {whenIdle} from '../lib/whenIdle.js';
import {onINP as unattributedOnINP} from '../onINP.js';
import {
  INPAttribution,
  INPMetric,
  INPMetricWithAttribution,
  ReportOpts,
} from '../types.js';

interface pendingEntriesGroup {
  startTime: DOMHighResTimeStamp;
  processingStart: DOMHighResTimeStamp;
  processingEnd: DOMHighResTimeStamp;
  renderTime: DOMHighResTimeStamp;
  entries: PerformanceEventTiming[];
}

// The maximum number of previous frames for which data is kept.
// Storing data about previous frames is necessary to handle cases where event
// and LoAF entries are dispatched out of order, and so a buffer of previous
// frame data is needed to determine various bits of INP attribution once all
// the frame-related data has come in.
// In most cases this out-of-order data is only off by a frame or two, so
// keeping the most recent 50 should be more than sufficient.
const MAX_PREVIOUS_FRAMES = 50;

// A PerformanceObserver, observing new `long-animation-frame` entries.
// If this variable is defined it means the browser supports LoAF.
let loafObserver: PerformanceObserver | undefined;

// A list of LoAF entries that have been dispatched and could potentially
// intersect with the INP candidate interaction. Note that periodically this
// list is cleaned up and entries that are known to not match INP are removed.
let pendingLoAFs: PerformanceLongAnimationFrameTiming[] = [];

// An array of groups of all the event timing entries that occurred within a
// particular frame. Note that periodically this array is cleaned up and entries
// that are known to not match INP are removed.
let pendingEntriesGroups: pendingEntriesGroup[] = [];

// The `processingEnd` time of most recently-processed event, chronologically.
let latestProcessingEnd: number = 0;

// A WeakMap to look up the event-timing-entries group of a given entry.
// Note that this only maps from "important" entries: either the first input or
// those with an `interactionId`.
const entryToEntriesGroupMap: WeakMap<
  PerformanceEventTiming,
  pendingEntriesGroup
> = new WeakMap();

// A mapping of interactionIds to the target Node.
export const interactionTargetMap: Map<number, Node> = new Map();

// A reference to the idle task used to clean up entries from the above
// variables. If the value is -1 it means no task is queue, and if it's
// greater than -1 the value corresponds to the idle callback handle.
let idleHandle: number = -1;

/**
 * Adds new LoAF entries to the `pendingLoAFs` list.
 */
const handleLoAFEntries = (entries: PerformanceLongAnimationFrameTiming[]) => {
  pendingLoAFs = pendingLoAFs.concat(entries);
  queueCleanup();
};

// Get a reference to the interaction target element in case it's removed
// from the DOM later.
const saveInteractionTarget = (entry: PerformanceEventTiming) => {
  if (
    entry.interactionId &&
    entry.target &&
    !interactionTargetMap.has(entry.interactionId)
  ) {
    interactionTargetMap.set(entry.interactionId, entry.target);
  }
};

/**
 * Groups entries that were presented within the same animation frame by
 * a common `renderTime`. This function works by referencing
 * `pendingEntriesGroups` and using an existing render time if one is found
 * (otherwise creating a new one). This function also adds all interaction
 * entries to an `entryToRenderTimeMap` WeakMap so that the "grouped" entries
 * can be looked up later.
 */
const groupEntriesByRenderTime = (entry: PerformanceEventTiming) => {
  const renderTime = entry.startTime + entry.duration;
  let group;

  latestProcessingEnd = Math.max(latestProcessingEnd, entry.processingEnd);

  // Iterate over all previous render times in reverse order to find a match.
  // Go in reverse since the most likely match will be at the end.
  for (let i = pendingEntriesGroups.length - 1; i >= 0; i--) {
    const potentialGroup = pendingEntriesGroups[i];

    // If a group's render time is within 8ms of the entry's render time,
    // assume they were part of the same frame and add it to the group.
    if (Math.abs(renderTime - potentialGroup.renderTime) <= 8) {
      group = potentialGroup;
      group.startTime = Math.min(entry.startTime, group.startTime);
      group.processingStart = Math.min(
        entry.processingStart,
        group.processingStart,
      );
      group.processingEnd = Math.max(entry.processingEnd, group.processingEnd);
      group.entries.push(entry);

      break;
    }
  }

  // If there was no matching group, assume this is a new frame.
  if (!group) {
    group = {
      startTime: entry.startTime,
      processingStart: entry.processingStart,
      processingEnd: entry.processingEnd,
      renderTime,
      entries: [entry],
    };

    pendingEntriesGroups.push(group);
  }

  // Store the grouped render time for this entry for reference later.
  if (entry.interactionId || entry.entryType === 'first-input') {
    entryToEntriesGroupMap.set(entry, group);
  }

  queueCleanup();
};

const queueCleanup = () => {
  // Queue cleanup of entries that are not part of any INP candidates.
  if (idleHandle < 0) {
    idleHandle = whenIdle(cleanupEntries);
  }
};

const cleanupEntries = () => {
  // Delete any stored interaction target elements if they're not part of one
  // of the 10 longest interactions.
  if (interactionTargetMap.size > 10) {
    interactionTargetMap.forEach((_, key) => {
      if (!longestInteractionMap.has(key)) {
        interactionTargetMap.delete(key);
      }
    });
  }

  // Keep all render times that are part of a pending INP candidate or
  // that occurred within the 50 most recently-dispatched groups of events.
  const longestInteractionGroups = longestInteractionList.map((i) => {
    return entryToEntriesGroupMap.get(i.entries[0]);
  });
  const minIndex = pendingEntriesGroups.length - MAX_PREVIOUS_FRAMES;
  pendingEntriesGroups = pendingEntriesGroups.filter((group, index) => {
    if (index >= minIndex) return true;
    return longestInteractionGroups.includes(group);
  });

  // Keep all pending LoAF entries that either:
  // 1) intersect with entries in the newly cleaned up `pendingEntriesGroups`
  // 2) occur after the most recently-processed event entry (for up to MAX_PREVIOUS_FRAMES)
  const loafsToKeep: Set<PerformanceLongAnimationFrameTiming> = new Set();
  for (let i = 0; i < pendingEntriesGroups.length; i++) {
    const group = pendingEntriesGroups[i];
    getIntersectingLoAFs(group.startTime, group.processingEnd).forEach(
      (loaf) => {
        loafsToKeep.add(loaf);
      },
    );
  }
  const prevFrameIndexCutoff = pendingLoAFs.length - 1 - MAX_PREVIOUS_FRAMES;
  // Filter `pendingLoAFs` to preserve LoAF order.
  pendingLoAFs = pendingLoAFs.filter((loaf, index) => {
    if (loaf.startTime > latestProcessingEnd && index > prevFrameIndexCutoff) {
      return true;
    }

    return loafsToKeep.has(loaf);
  });

  // Reset the idle callback handle so it can be queued again.
  idleHandle = -1;
};

entryPreProcessingCallbacks.push(
  saveInteractionTarget,
  groupEntriesByRenderTime,
);

const getIntersectingLoAFs = (
  start: DOMHighResTimeStamp,
  end: DOMHighResTimeStamp,
) => {
  const intersectingLoAFs = [];

  for (let i = 0, loaf; (loaf = pendingLoAFs[i]); i++) {
    // If the LoAF ends before the given start time, ignore it.
    if (loaf.startTime + loaf.duration < start) continue;

    // If the LoAF starts after the given end time, ignore it and all
    // subsequent pending LoAFs (because they're in time order).
    if (loaf.startTime > end) break;

    // Still here? If so this LoAF intersects with the interaction.
    intersectingLoAFs.push(loaf);
  }
  return intersectingLoAFs;
};

const attributeINP = (metric: INPMetric): INPMetricWithAttribution => {
  const firstEntry = metric.entries[0];
  const group = entryToEntriesGroupMap.get(firstEntry)!;

  const processingStart = firstEntry.processingStart;
  const processingEnd = group.processingEnd;

  // Sort the entries in processing time order.
  const processedEventEntries = group.entries.sort((a, b) => {
    return a.processingStart - b.processingStart;
  });

  const longAnimationFrameEntries: PerformanceLongAnimationFrameTiming[] =
    getIntersectingLoAFs(firstEntry.startTime, processingEnd);

  // The first interaction entry may not have a target defined, so use the
  // first one found in the entry list.
  // TODO: when the following bug is fixed just use `firstInteractionEntry`.
  // https://bugs.chromium.org/p/chromium/issues/detail?id=1367329
  // As a fallback, also check the interactionTargetMap (to account for
  // cases where the element is removed from the DOM before reporting happens).
  const firstEntryWithTarget = metric.entries.find((entry) => entry.target);
  const interactionTargetElement =
    (firstEntryWithTarget && firstEntryWithTarget.target) ||
    interactionTargetMap.get(firstEntry.interactionId);

  // Since entry durations are rounded to the nearest 8ms, we need to clamp
  // the `nextPaintTime` value to be higher than the `processingEnd` or
  // end time of any LoAF entry.
  const nextPaintTimeCandidates = [
    firstEntry.startTime + firstEntry.duration,
    processingEnd,
  ].concat(
    longAnimationFrameEntries.map((loaf) => loaf.startTime + loaf.duration),
  );

  const nextPaintTime = Math.max.apply(Math, nextPaintTimeCandidates);

  const attribution: INPAttribution = {
    interactionTarget: getSelector(interactionTargetElement),
    interactionTargetElement: interactionTargetElement,
    interactionType: firstEntry.name.startsWith('key') ? 'keyboard' : 'pointer',
    interactionTime: firstEntry.startTime,
    nextPaintTime: nextPaintTime,
    processedEventEntries: processedEventEntries,
    longAnimationFrameEntries: longAnimationFrameEntries,
    inputDelay: processingStart - firstEntry.startTime,
    processingDuration: processingEnd - processingStart,
    presentationDelay: Math.max(nextPaintTime - processingEnd, 0),
    loadState: getLoadState(firstEntry.startTime),
  };

  // Use Object.assign to set property to keep tsc happy.
  const metricWithAttribution: INPMetricWithAttribution = Object.assign(
    metric,
    {attribution},
  );
  return metricWithAttribution;
};

/**
 * Calculates the [INP](https://web.dev/articles/inp) value for the current
 * page and calls the `callback` function once the value is ready, along with
 * the `event` performance entries reported for that interaction. The reported
 * value is a `DOMHighResTimeStamp`.
 *
 * A custom `durationThreshold` configuration option can optionally be passed to
 * control what `event-timing` entries are considered for INP reporting. The
 * default threshold is `40`, which means INP scores of less than 40 are
 * reported as 0. Note that this will not affect your 75th percentile INP value
 * unless that value is also less than 40 (well below the recommended
 * [good](https://web.dev/articles/inp#what_is_a_good_inp_score) threshold).
 *
 * If the `reportAllChanges` configuration option is set to `true`, the
 * `callback` function will be called as soon as the value is initially
 * determined as well as any time the value changes throughout the page
 * lifespan.
 *
 * _**Important:** INP should be continually monitored for changes throughout
 * the entire lifespan of a page—including if the user returns to the page after
 * it's been hidden/backgrounded. However, since browsers often [will not fire
 * additional callbacks once the user has backgrounded a
 * page](https://developer.chrome.com/blog/page-lifecycle-api/#advice-hidden),
 * `callback` is always called when the page's visibility state changes to
 * hidden. As a result, the `callback` function might be called multiple times
 * during the same page load._
 */
export const onINP = (
  onReport: (metric: INPMetricWithAttribution) => void,
  opts?: ReportOpts,
) => {
  if (!loafObserver) {
    loafObserver = observe('long-animation-frame', handleLoAFEntries);
  }
  unattributedOnINP((metric: INPMetric) => {
    const metricWithAttribution = attributeINP(metric);
    onReport(metricWithAttribution);
  }, opts);
};
