// Copyright 2014 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 Platform from '../../core/platform/platform.js';
import type * as Protocol from '../../generated/protocol.js';

import {ProfileNode, ProfileTreeModel} from './ProfileTreeModel.js';

export class CPUProfileNode extends ProfileNode {
  override id: number;
  override self: number;
  // Position ticks are available in profile nodes coming from CDP
  // profiles and not in those coming from tracing. They are used to
  // calculate the line level execution time shown in the Sources panel
  // after recording a profile. For trace CPU profiles we use the
  // `lines` array instead.
  positionTicks: Protocol.Profiler.PositionTickInfo[]|undefined;
  override deoptReason: string|null;

  constructor(node: Protocol.Profiler.ProfileNode, samplingInterval: number /* milliseconds */) {
    const callFrame = node.callFrame || ({
                        // TODO(crbug.com/1172300) Ignored during the jsdoc to ts migration
                        // @ts-expect-error
                        functionName: node['functionName'],
                        // TODO(crbug.com/1172300) Ignored during the jsdoc to ts migration
                        // @ts-expect-error
                        scriptId: node['scriptId'],
                        // TODO(crbug.com/1172300) Ignored during the jsdoc to ts migration
                        // @ts-expect-error
                        url: node['url'],
                        // TODO(crbug.com/1172300) Ignored during the jsdoc to ts migration
                        // @ts-expect-error
                        lineNumber: node['lineNumber'] - 1,
                        // TODO(crbug.com/1172300) Ignored during the jsdoc to ts migration
                        // @ts-expect-error
                        columnNumber: node['columnNumber'] - 1,
                      } as Protocol.Runtime.CallFrame);
    super(callFrame);
    this.id = node.id;
    this.self = (node.hitCount || 0) * samplingInterval;
    this.positionTicks = node.positionTicks;
    // Compatibility: legacy backends could provide "no reason" for optimized functions.
    this.deoptReason = node.deoptReason && node.deoptReason !== 'no reason' ? node.deoptReason : null;
  }
}

export class CPUProfileDataModel extends ProfileTreeModel {
  profileStartTime: number /* milliseconds */;
  profileEndTime: number /* milliseconds */;
  timestamps: number[];
  samples: number[]|undefined;
  /**
   * Contains trace ids assigned to samples, if any. Trace ids are
   * keyed by the sample index in the profile. These are only created
   * for CPU profiles coming from traces.
   */
  traceIds?: Record<string, number>;
  /**
   * Each item in the `lines` array contains the script line executing
   * when the sample in that array position was taken.
   */
  lines?: number[];
  /**
   * Same as `lines` above, but with the script column.
   */
  columns?: number[];
  totalHitCount: number;
  profileHead: CPUProfileNode;
  /**
   * A cache for the nodes we have parsed.
   * Note: "Parsed" nodes are different from the "Protocol" nodes, the
   * latter being the raw data we receive from the backend.
   */
  #idToParsedNode!: Map<number, ProfileNode>;
  gcNode?: ProfileNode;
  programNode?: ProfileNode;
  idleNode?: ProfileNode;
  #stackStartTimes?: number[];
  #stackChildrenDuration?: number[];
  constructor(profile: ExtendedProfile) {
    super();
    // @ts-expect-error Legacy types
    const isLegacyFormat = Boolean(profile['head']);
    if (isLegacyFormat) {
      // Legacy format contains raw timestamps and start/stop times are in seconds.
      this.profileStartTime = profile.startTime * 1000;
      this.profileEndTime = profile.endTime * 1000;
      // @ts-expect-error Legacy types
      this.timestamps = profile.timestamps;
      this.compatibilityConversionHeadToNodes(profile);
    } else {
      // Current format encodes timestamps as deltas. Start/stop times are in microseconds.
      this.profileStartTime = profile.startTime / 1000;
      this.profileEndTime = profile.endTime / 1000;
      this.timestamps = this.convertTimeDeltas(profile);
    }
    this.traceIds = profile.traceIds;
    this.samples = profile.samples;

    this.lines = profile.lines;
    this.columns = profile.columns;
    this.totalHitCount = 0;
    this.profileHead = this.translateProfileTree(profile.nodes);
    this.initialize(this.profileHead);
    this.extractMetaNodes();
    if (this.samples?.length) {
      this.sortSamples();
      this.normalizeTimestamps();
      this.fixMissingSamples();
    }
  }

  private compatibilityConversionHeadToNodes(profile: Protocol.Profiler.Profile): void {
    // @ts-expect-error Legacy types
    if (!profile.head || profile.nodes) {
      return;
    }
    const nodes: Protocol.Profiler.ProfileNode[] = [];
    // @ts-expect-error Legacy types
    convertNodesTree(profile.head);
    profile.nodes = nodes;
    // @ts-expect-error Legacy types
    delete profile.head;
    function convertNodesTree(node: Protocol.Profiler.ProfileNode): number {
      nodes.push(node);
      // @ts-expect-error Legacy types
      node.children = (node.children as Protocol.Profiler.ProfileNode[]).map(convertNodesTree);
      return node.id;
    }
  }

  /**
   * Calculate timestamps using timeDeltas. Some CPU profile formats,
   * like the ones contained in traces have timeDeltas instead of
   * timestamps.
   */
  private convertTimeDeltas(profile: Protocol.Profiler.Profile): number[] {
    if (!profile.timeDeltas) {
      return [];
    }
    let lastTimeMicroSec = profile.startTime;
    const timestamps = new Array(profile.timeDeltas.length);
    for (let i = 0; i < profile.timeDeltas.length; ++i) {
      lastTimeMicroSec += profile.timeDeltas[i];
      timestamps[i] = lastTimeMicroSec;
    }
    return timestamps;
  }

  /**
   * Creates a Tree of CPUProfileNodes using the Protocol.Profiler.ProfileNodes.
   * As the tree is built, samples of native code (prefixed with "native ") are
   * filtered out. Samples of filtered nodes are replaced with the parent of the
   * node being filtered.
   *
   * This function supports legacy and new definitions of the CDP Profiler.Profile
   * type.
   */
  private translateProfileTree(nodes: Protocol.Profiler.ProfileNode[]): CPUProfileNode {
    function buildChildrenFromParents(nodes: Protocol.Profiler.ProfileNode[]): void {
      if (nodes[0].children) {
        return;
      }
      nodes[0].children = [];
      for (let i = 1; i < nodes.length; ++i) {
        const node = nodes[i];
        // @ts-expect-error Legacy types
        const parentNode = protocolNodeById.get(node.parent);
        if (!parentNode) {
          continue;
        }
        if (parentNode.children) {
          parentNode.children.push(node.id);
        } else {
          parentNode.children = [node.id];
        }
      }
    }

    /**
     * Calculate how many times each node was sampled in the profile, if
     * not available in the profile data.
     */
    function buildHitCountFromSamples(nodes: Protocol.Profiler.ProfileNode[], samples: number[]|undefined): void {
      // If hit count is available, this profile has the new format, so
      // no need to continue.`
      if (typeof (nodes[0].hitCount) === 'number') {
        return;
      }
      if (!samples) {
        throw new Error('Error: Neither hitCount nor samples are present in profile.');
      }
      for (let i = 0; i < nodes.length; ++i) {
        nodes[i].hitCount = 0;
      }
      for (let i = 0; i < samples.length; ++i) {
        const node = protocolNodeById.get(samples[i]);
        if (node?.hitCount === undefined) {
          continue;
        }
        node.hitCount++;
      }
    }

    // A cache for the raw nodes received from the traces / CDP.
    const protocolNodeById = new Map<number, Protocol.Profiler.ProfileNode>();
    for (let i = 0; i < nodes.length; ++i) {
      const node = nodes[i];
      protocolNodeById.set(node.id, node);
    }

    buildHitCountFromSamples(nodes, this.samples);
    buildChildrenFromParents(nodes);
    this.totalHitCount = nodes.reduce((acc, node) => acc + (node.hitCount || 0), 0);
    const sampleTime = (this.profileEndTime - this.profileStartTime) / this.totalHitCount;
    const root = nodes[0];
    // If a node is filtered out, its samples are replaced with its parent,
    // so we keep track of the which id to use in the samples data.
    const idToUseForRemovedNode = new Map<number, number>([[root.id, root.id]]);
    this.#idToParsedNode = new Map();

    const resultRoot = new CPUProfileNode(root, sampleTime);
    this.#idToParsedNode.set(root.id, resultRoot);
    if (!root.children) {
      throw new Error('Missing children for root');
    }
    const parentNodeStack = root.children.map(() => resultRoot);
    const sourceNodeStack = root.children.map(id => protocolNodeById.get(id));
    while (sourceNodeStack.length) {
      let parentNode = parentNodeStack.pop();
      const sourceNode = sourceNodeStack.pop();
      if (!sourceNode || !parentNode) {
        continue;
      }
      if (!sourceNode.children) {
        sourceNode.children = [];
      }
      const targetNode = new CPUProfileNode(sourceNode, sampleTime);
      parentNode.children.push(targetNode);
      parentNode = targetNode;

      idToUseForRemovedNode.set(sourceNode.id, parentNode.id);
      parentNodeStack.push.apply(parentNodeStack, sourceNode.children.map(() => parentNode));
      sourceNodeStack.push.apply(sourceNodeStack, sourceNode.children.map(id => protocolNodeById.get(id)));
      this.#idToParsedNode.set(sourceNode.id, targetNode);
    }
    if (this.samples) {
      this.samples = this.samples.map(id => idToUseForRemovedNode.get(id) as number);
    }
    return resultRoot;
  }

  /**
   * Sorts the samples array using the timestamps array (there is a one
   * to one matching by index between the two).
   */
  private sortSamples(): void {
    if (!this.timestamps || !this.samples) {
      return;
    }

    const timestamps = this.timestamps;
    const samples = this.samples;
    const orderedIndices = timestamps.map((_x, index) => index);
    orderedIndices.sort((a, b) => timestamps[a] - timestamps[b]);

    this.timestamps = [];
    this.samples = [];

    for (let i = 0; i < orderedIndices.length; i++) {
      const orderedIndex = orderedIndices[i];
      this.timestamps.push(timestamps[orderedIndex]);
      this.samples.push(samples[orderedIndex]);
    }
  }

  /**
   * Fills in timestamps and/or time deltas from legacy profiles where
   * they could be missing.
   */
  private normalizeTimestamps(): void {
    if (!this.samples) {
      return;
    }
    let timestamps: number[] = this.timestamps;
    if (!timestamps) {
      // Support loading CPU profiles that are missing timestamps and
      // timedeltas
      const profileStartTime = this.profileStartTime;
      const interval = (this.profileEndTime - profileStartTime) / this.samples.length;
      // Add an extra timestamp used to calculate the last sample duration.
      timestamps = new Array(this.samples.length + 1);
      for (let i = 0; i < timestamps.length; ++i) {
        timestamps[i] = profileStartTime + i * interval;
      }
      this.timestamps = timestamps;
      return;
    }

    // Convert samples from micro to milliseconds
    for (let i = 0; i < timestamps.length; ++i) {
      timestamps[i] /= 1000;
    }
    if (this.samples.length === timestamps.length) {
      // Add an extra timestamp used to calculate the last sample duration.
      const lastTimestamp = timestamps.at(-1) || 0;
      const averageIntervalTime = (lastTimestamp - timestamps[0]) / (timestamps.length - 1);
      this.timestamps.push(lastTimestamp + averageIntervalTime);
    }
    this.profileStartTime = timestamps.at(0) || this.profileStartTime;
    this.profileEndTime = timestamps.at(-1) || this.profileEndTime;
  }

  /**
   * Some nodes do not refer to JS samples but to V8 system tasks, AKA
   * "meta" nodes. This function extracts those nodes from the profile.
   */
  private extractMetaNodes(): void {
    const topLevelNodes = this.profileHead.children;
    for (let i = 0; i < topLevelNodes.length && !(this.gcNode && this.programNode && this.idleNode); i++) {
      const node = topLevelNodes[i];
      if (node.functionName === '(garbage collector)') {
        this.gcNode = node;
      } else if (node.functionName === '(program)') {
        this.programNode = node;
      } else if (node.functionName === '(idle)') {
        this.idleNode = node;
      }
    }
  }

  private fixMissingSamples(): void {
    // Sometimes the V8 sampler is not able to parse the JS stack and returns
    // a (program) sample instead. The issue leads to call frames being split
    // apart when they shouldn't.
    // Here's a workaround for that. When there's a single (program) sample
    // between two call stacks sharing the same bottom node, it is replaced
    // with the preceding sample.
    const samples = this.samples;
    if (!samples) {
      return;
    }
    const samplesCount = samples.length;
    if (!this.programNode || samplesCount < 3) {
      return;
    }
    const idToNode = this.#idToParsedNode;
    const programNodeId = this.programNode.id;
    const gcNodeId = this.gcNode ? this.gcNode.id : -1;
    const idleNodeId = this.idleNode ? this.idleNode.id : -1;
    let prevNodeId: number = samples[0];
    let nodeId: number = samples[1];
    for (let sampleIndex = 1; sampleIndex < samplesCount - 1; sampleIndex++) {
      const nextNodeId = samples[sampleIndex + 1];
      const prevNode = idToNode.get(prevNodeId);
      const nextNode = idToNode.get(nextNodeId);
      if (prevNodeId === undefined || nextNodeId === undefined || !prevNode || !nextNode) {
        console.error(`Unexpectedly found undefined nodes: ${prevNodeId} ${nextNodeId}`);
        continue;
      }
      if (nodeId === programNodeId && !isSystemNode(prevNodeId) && !isSystemNode(nextNodeId) &&
          bottomNode(prevNode) === bottomNode(nextNode)) {
        samples[sampleIndex] = prevNodeId;
      }
      prevNodeId = nodeId;
      nodeId = nextNodeId;
    }
    function bottomNode(node: ProfileNode): ProfileNode {
      while (node.parent?.parent) {
        node = node.parent;
      }
      return node;
    }
    function isSystemNode(nodeId: number): boolean {
      return nodeId === programNodeId || nodeId === gcNodeId || nodeId === idleNodeId;
    }
  }

  /**
   * Traverses the call tree derived from the samples calling back when a call is opened
   * and when it's closed
   */
  forEachFrame(
      openFrameCallback: (depth: number, node: ProfileNode, sampleIndex: number, timestamp: number) => void,
      closeFrameCallback:
          (depth: number, node: ProfileNode, sampleIndex: number, timestamp: number, dur: number,
           selfTime: number) => void,
      startTime?: number, stopTime?: number): void {
    if (!this.profileHead || !this.samples) {
      return;
    }

    startTime = startTime || 0;
    stopTime = stopTime || Infinity;
    const samples = this.samples;
    const timestamps = this.timestamps;
    const idToNode = this.#idToParsedNode;
    const gcNode = this.gcNode;
    const samplesCount = samples.length;
    const startIndex =
        Platform.ArrayUtilities.lowerBound(timestamps, startTime, Platform.ArrayUtilities.DEFAULT_COMPARATOR);
    let stackTop = 0;
    const stackNodes: ProfileNode[] = [];
    let prevId: number = this.profileHead.id;
    let sampleTime;
    let gcParentNode: ProfileNode|null = null;

    // Extra slots for gc being put on top,
    // and one at the bottom to allow safe stackTop-1 access.
    const stackDepth = this.maxDepth + 3;
    if (!this.#stackStartTimes) {
      this.#stackStartTimes = new Array(stackDepth);
    }
    const stackStartTimes = this.#stackStartTimes;
    if (!this.#stackChildrenDuration) {
      this.#stackChildrenDuration = new Array(stackDepth);
    }
    const stackChildrenDuration = this.#stackChildrenDuration;

    let node;
    let sampleIndex;
    for (sampleIndex = startIndex; sampleIndex < samplesCount; sampleIndex++) {
      sampleTime = timestamps[sampleIndex];
      if (sampleTime >= stopTime) {
        break;
      }
      const id = samples[sampleIndex];
      if (id === prevId) {
        continue;
      }
      node = idToNode.get(id);
      let prevNode: ProfileNode|null = idToNode.get(prevId) || null;
      if (!prevNode) {
        continue;
      }

      if (gcNode && node === gcNode) {
        // GC samples have no stack, so we just put GC node on top of the last recorded sample.
        gcParentNode = prevNode;
        openFrameCallback(gcParentNode.depth + 1, gcNode, sampleIndex, sampleTime);
        stackStartTimes[++stackTop] = sampleTime;
        stackChildrenDuration[stackTop] = 0;
        prevId = id;
        continue;
      }
      if (gcNode && prevNode === gcNode && gcParentNode) {
        // end of GC frame
        const start = stackStartTimes[stackTop];
        const duration = sampleTime - start;
        stackChildrenDuration[stackTop - 1] += duration;
        closeFrameCallback(
            gcParentNode.depth + 1, gcNode, sampleIndex, start, duration, duration - stackChildrenDuration[stackTop]);
        --stackTop;
        prevNode = gcParentNode;
        prevId = prevNode.id;
        gcParentNode = null;
      }

      // If the depth of this node is greater than the depth of the
      // previous one, new calls happened in between and we need to open
      // them, so track all of them in stackNodes.
      while (node && node.depth > prevNode.depth) {
        stackNodes.push(node);
        node = node.parent;
      }

      // If `prevNode` differs from `node`, the current sample was taken
      // after a change in the call stack, meaning that frames in the
      // path of `prevNode` that differ from those in the path of `node`
      // can be closed. So go down to the lowest common ancestor and
      // close current intervals.
      //
      // For example:
      //
      // prevNode  node
      //    |       |
      //    v       v
      // [---D--]
      // [---C--][--E--]
      // [------B------] <- LCA
      // [------A------]
      //
      // Because a sample was taken with A, B and E in the stack, it
      // means C and D finished and we can close them.
      while (prevNode && prevNode !== node) {
        const start = stackStartTimes[stackTop];
        const duration = sampleTime - start;
        stackChildrenDuration[stackTop - 1] += duration;
        closeFrameCallback(
            prevNode.depth, prevNode, sampleIndex, start, duration, duration - stackChildrenDuration[stackTop]);
        --stackTop;
        // Track calls to open after previous calls were closed
        // In the example above, this would add E to the tracking stack.
        if (node && node.depth === prevNode.depth) {
          stackNodes.push(node);
          node = node.parent;
        }
        prevNode = prevNode.parent;
      }

      // Go up the nodes stack and open new intervals.
      while (stackNodes.length) {
        const currentNode = stackNodes.pop();
        if (!currentNode) {
          break;
        }
        node = currentNode;
        openFrameCallback(currentNode.depth, currentNode, sampleIndex, sampleTime);
        stackStartTimes[++stackTop] = sampleTime;
        stackChildrenDuration[stackTop] = 0;
      }

      prevId = id;
    }

    // Close remaining intervals.
    sampleTime = timestamps[sampleIndex] || this.profileEndTime;
    if (node && gcParentNode && idToNode.get(prevId) === gcNode) {
      const start = stackStartTimes[stackTop];
      const duration = sampleTime - start;
      stackChildrenDuration[stackTop - 1] += duration;
      closeFrameCallback(
          gcParentNode.depth + 1, node, sampleIndex, start, duration, duration - stackChildrenDuration[stackTop]);
      --stackTop;
      prevId = gcParentNode.id;
    }
    for (let node = idToNode.get(prevId); node?.parent; node = node.parent) {
      const start = stackStartTimes[stackTop];
      const duration = sampleTime - start;
      stackChildrenDuration[stackTop - 1] += duration;
      closeFrameCallback(node.depth, node, sampleIndex, start, duration, duration - stackChildrenDuration[stackTop]);
      --stackTop;
    }
  }
  /**
   * Returns the node that corresponds to a given index of a sample.
   */
  nodeByIndex(index: number): ProfileNode|null {
    return this.samples && this.#idToParsedNode.get(this.samples[index]) || null;
  }
  /**
   * Returns the node that corresponds to a given node id.
   */
  nodeById(nodeId: number): ProfileNode|null {
    return this.#idToParsedNode.get(nodeId) || null;
  }

  nodes(): ProfileNode[]|null {
    if (!this.#idToParsedNode) {
      return null;
    }
    return [...this.#idToParsedNode.values()];
  }
}

/** Format used by profiles coming from traces. **/
export type ExtendedProfileNode = Protocol.Profiler.ProfileNode&{parent?: number};
export type ExtendedProfile = Protocol.Profiler.Profile&{
  nodes: Protocol.Profiler.ProfileNode[] | ExtendedProfileNode[],
  lines?: number[],
  columns?: number[],
  /**
   * A sample can be manually collected with v8::CpuProfiler::collectSample.
   * When this is done an id (trace id) can be passed to the API to
   * identify the collected sample in the resulting CPU profile. We
   * do this for several trace events, to efficiently calculate their
   * stack trace and improve the JS flamechart we build.
   *
   * This property, only present if there's any trace id provided within profileChunks,
   * contains the mapping of the trace ids with the shape traceId -> nodeId.
   */
  traceIds?: Record<string, number>,
};
