// 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 Core from '../core/core.js';
import type * as Lantern from '../types/types.js';

import type {CPUNode} from './CPUNode.js';
import type {NetworkNode} from './NetworkNode.js';

/**
 * A union of all types derived from BaseNode, allowing type check discrimination
 * based on `node.type`. If a new node type is created, it should be added here.
 */
export type Node<T = Lantern.AnyNetworkObject> = CPUNode<T>|NetworkNode<T>;

/**
 * @file This class encapsulates logic for handling resources and tasks used to model the
 * execution dependency graph of the page. A node has a unique identifier and can depend on other
 * nodes/be depended on. The construction of the graph maintains some important invariants that are
 * inherent to the model:
 *
 *    1. The graph is a DAG, there are no cycles.
 *    2. There is always a root node upon which all other nodes eventually depend.
 *
 * This allows particular optimizations in this class so that we do no need to check for cycles as
 * these methods are called and we can always start traversal at the root node.
 */

class BaseNode<T = Lantern.AnyNetworkObject> {
  static types = {
    NETWORK: 'network',
    CPU: 'cpu',
  } as const;

  _id: string;
  _isMainDocument: boolean;
  dependents: Node[];
  dependencies: Node[];

  constructor(id: string) {
    this._id = id;
    this._isMainDocument = false;
    this.dependents = [];
    this.dependencies = [];
  }

  get id(): string {
    return this._id;
  }

  get type(): 'network'|'cpu' {
    throw new Core.LanternError('Unimplemented');
  }

  /**
   * In microseconds
   */
  get startTime(): number {
    throw new Core.LanternError('Unimplemented');
  }

  /**
   * In microseconds
   */
  get endTime(): number {
    throw new Core.LanternError('Unimplemented');
  }

  setIsMainDocument(value: boolean): void {
    this._isMainDocument = value;
  }

  isMainDocument(): boolean {
    return this._isMainDocument;
  }

  getDependents(): Node[] {
    return this.dependents.slice();
  }

  getNumberOfDependents(): number {
    return this.dependents.length;
  }

  getDependencies(): Node[] {
    return this.dependencies.slice();
  }

  getNumberOfDependencies(): number {
    return this.dependencies.length;
  }

  getRootNode(): Node<T> {
    let rootNode = this as BaseNode as Node;
    while (rootNode.dependencies.length) {
      rootNode = rootNode.dependencies[0];
    }

    return rootNode;
  }

  addDependent(node: Node): void {
    node.addDependency(this as BaseNode as Node);
  }

  addDependency(node: Node): void {
    // @ts-expect-error - in checkJs, ts doesn't know that CPUNode and NetworkNode *are* BaseNodes.
    if (node === this) {
      throw new Core.LanternError('Cannot add dependency on itself');
    }

    if (this.dependencies.includes(node)) {
      return;
    }

    node.dependents.push(this as BaseNode as Node);
    this.dependencies.push(node);
  }

  removeDependent(node: Node): void {
    node.removeDependency(this as BaseNode as Node);
  }

  removeDependency(node: Node): void {
    if (!this.dependencies.includes(node)) {
      return;
    }

    const thisIndex = node.dependents.indexOf(this as BaseNode as Node);
    node.dependents.splice(thisIndex, 1);
    this.dependencies.splice(this.dependencies.indexOf(node), 1);
  }

  // Unused in devtools, but used in LH.
  removeAllDependencies(): void {
    for (const node of this.dependencies.slice()) {
      this.removeDependency(node);
    }
  }

  /**
   * Computes whether the given node is anywhere in the dependency graph of this node.
   * While this method can prevent cycles, it walks the graph and should be used sparingly.
   * Nodes are always considered dependent on themselves for the purposes of cycle detection.
   */
  isDependentOn(node: BaseNode<T>): boolean {
    let isDependentOnNode = false;
    this.traverse(
        currentNode => {
          if (isDependentOnNode) {
            return;
          }
          isDependentOnNode = currentNode === node;
        },
        currentNode => {
          // If we've already found the dependency, don't traverse further.
          if (isDependentOnNode) {
            return [];
          }
          // Otherwise, traverse the dependencies.
          return currentNode.getDependencies();
        });

    return isDependentOnNode;
  }

  /**
   * Clones the node's information without adding any dependencies/dependents.
   */
  cloneWithoutRelationships(): Node<T> {
    const node = new BaseNode(this.id) as Node<T>;
    node.setIsMainDocument(this._isMainDocument);
    return node;
  }

  /**
   * Clones the entire graph connected to this node filtered by the optional predicate. If a node is
   * included by the predicate, all nodes along the paths between the node and the root will be included. If the
   * node this was called on is not included in the resulting filtered graph, the method will throw.
   *
   * This does not clone NetworkNode's `record` or `rawRecord` fields. It may be reasonable to clone the former,
   * to assist in graph construction, but the latter should never be cloned as one constraint of Lantern is that
   * the underlying data records are accessible for plain object reference equality checks.
   */
  cloneWithRelationships(predicate?: (arg0: Node) => boolean): Node {
    const rootNode = this.getRootNode();

    const idsToIncludedClones = new Map<string, Node>();

    // Walk down dependents.
    rootNode.traverse(node => {
      if (idsToIncludedClones.has(node.id)) {
        return;
      }

      if (predicate === undefined) {
        // No condition for entry, so clone every node.
        idsToIncludedClones.set(node.id, node.cloneWithoutRelationships());
        return;
      }

      if (predicate(node)) {
        // Node included, so walk back up dependencies, cloning nodes from here back to the root.
        node.traverse(
            node => idsToIncludedClones.set(node.id, node.cloneWithoutRelationships()),
            // Dependencies already cloned have already cloned ancestors, so no need to visit again.
            node => node.dependencies.filter(parent => !idsToIncludedClones.has(parent.id)),
        );
      }
    });

    // Copy dependencies between nodes.
    rootNode.traverse(originalNode => {
      const clonedNode = idsToIncludedClones.get(originalNode.id);
      if (!clonedNode) {
        return;
      }

      for (const dependency of originalNode.dependencies) {
        const clonedDependency = idsToIncludedClones.get(dependency.id);
        if (!clonedDependency) {
          throw new Core.LanternError('Dependency somehow not cloned');
        }
        clonedNode.addDependency(clonedDependency);
      }
    });

    const clonedThisNode = idsToIncludedClones.get(this.id);
    if (!clonedThisNode) {
      throw new Core.LanternError('Cloned graph missing node');
    }
    return clonedThisNode;
  }

  /**
   * Traverses all connected nodes in BFS order, calling `callback` exactly once
   * on each. `traversalPath` is the shortest (though not necessarily unique)
   * path from `node` to the root of the iteration.
   *
   * The `getNextNodes` function takes a visited node and returns which nodes to
   * visit next. It defaults to returning the node's dependents.
   */
  traverse(
      callback: (node: Node<T>, traversalPath: Array<Node<T>>) => void,
      getNextNodes?: (arg0: Node<T>) => Array<Node<T>>): void {
    for (const {node, traversalPath} of this.traverseGenerator(getNextNodes)) {
      callback(node, traversalPath);
    }
  }

  /**
   * @see BaseNode.traverse
   */
  // clang-format off
  *traverseGenerator(getNextNodes?: (arg0: Node) => Node[]):
      Generator<{node: Node, traversalPath: Node[]}, void, unknown> {
    // clang-format on
    if (!getNextNodes) {
      getNextNodes = node => node.getDependents();
    }

    // @ts-expect-error - only traverses graphs of Node, so force tsc to treat `this` as one
    const queue: Node[][] = [[this]];
    const visited = new Set([this.id]);

    while (queue.length) {
      // @ts-expect-error - queue has length so it's guaranteed to have an item
      const traversalPath: Node[] = queue.shift();
      const node = traversalPath[0];
      yield {node, traversalPath};

      for (const nextNode of getNextNodes(node)) {
        if (visited.has(nextNode.id)) {
          continue;
        }
        visited.add(nextNode.id);

        queue.push([nextNode, ...traversalPath]);
      }
    }
  }

  /**
   * If the given node has a cycle, returns a path representing that cycle.
   * Else returns null.
   *
   * Does a DFS on in its dependent graph.
   */
  static findCycle(node: Node, direction: 'dependents'|'dependencies'|'both' = 'both'): BaseNode[]|null {
    // Checking 'both' is the default entrypoint to recursively check both directions
    if (direction === 'both') {
      return BaseNode.findCycle(node, 'dependents') || BaseNode.findCycle(node, 'dependencies');
    }

    const visited = new Set();
    const currentPath: BaseNode[] = [];
    const toVisit = [node];
    const depthAdded = new Map([[node, 0]]);

    // Keep going while we have nodes to visit in the stack
    while (toVisit.length) {
      // Get the last node in the stack (DFS uses stack, not queue)
      // @ts-expect-error - toVisit has length so it's guaranteed to have an item
      const currentNode: BaseNode = toVisit.pop();

      // We've hit a cycle if the node we're visiting is in our current dependency path
      if (currentPath.includes(currentNode)) {
        return currentPath;
      }
      // If we've already visited the node, no need to revisit it
      if (visited.has(currentNode)) {
        continue;
      }

      // Since we're visiting this node, clear out any nodes in our path that we had to backtrack
      // @ts-expect-error
      while (currentPath.length > depthAdded.get(currentNode)) {
        currentPath.pop();
      }

      // Update our data structures to reflect that we're adding this node to our path
      visited.add(currentNode);
      currentPath.push(currentNode);

      // Add all of its dependents to our toVisit stack
      const nodesToExplore = direction === 'dependents' ? currentNode.dependents : currentNode.dependencies;
      for (const nextNode of nodesToExplore) {
        if (toVisit.includes(nextNode)) {
          continue;
        }
        toVisit.push(nextNode);
        depthAdded.set(nextNode, currentPath.length);
      }
    }

    return null;
  }

  canDependOn(node: Node): boolean {
    return node.startTime <= this.startTime;
  }
}

export {BaseNode};
