// Copyright 2024 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

/**
 * @fileoverview This file implements the current state of the "Scopes" proposal
 * for the source map spec.
 *
 * See https://github.com/tc39/source-map-rfc/blob/main/proposals/scopes.md.
 *
 * The proposal is still being worked on so we expect the implementation details
 * in this file to change frequently.
 */

import {type SourceMapV3Object, TokenIterator} from './SourceMap.js';

/**
 * A scope in the authored source.
 */
export interface OriginalScope {
  start: Position;
  end: Position;

  /**
   * JavaScript-like languages are encouraged to use 'global', 'class', 'function' and 'block'.
   * Other languages might require language-specific scope kinds, in which case we'll print the
   * kind as-is.
   */
  kind?: string;
  name?: string;
  isStackFrame: boolean;
  variables: string[];
  children: OriginalScope[];
  parent?: OriginalScope;
}

/**
 * A range (can be a scope) in the generated JavaScript.
 */
export interface GeneratedRange {
  start: Position;
  end: Position;
  originalScope?: OriginalScope;

  /**
   * Whether this generated range is an actual JavaScript function in the generated code.
   */
  isStackFrame: boolean;
  /**
   * Whether calls to this generated range should be hidden from stack traces even if
   * this range has an `originalScope`.
   */
  isHidden: boolean;

  /**
   * If this `GeneratedRange` is the result of inlining `originalScope`, then `callsite`
   * refers to where `originalScope` was called in the original ("authored") code.
   */
  callsite?: OriginalPosition;

  /**
   * Expressions that compute the values of the variables of this OriginalScope. The length
   * of `values` must match the length of `originalScope.variables`.
   *
   * For each variable this can either be a single expression (valid for the full `GeneratedRange`),
   * or an array of `BindingRange`s, e.g. if computing the value requires different expressions
   * throughout the range or if the variable is only available in parts of the `GeneratedRange`.
   *
   * `undefined` denotes that the value of a variable is unavailble in the whole range.
   * This can happen e.g. if the variable was optimized out and can't be recomputed.
   */
  values: (string|undefined|BindingRange[])[];
  children: GeneratedRange[];
}

export interface BindingRange {
  value?: string;
  from: Position;
  to: Position;
}

export interface Position {
  line: number;
  column: number;
}

/** @returns 0 if both positions are equal, a negative number if a < b and a positive number if a > b */
export function comparePositions(a: Position, b: Position): number {
  return a.line - b.line || a.column - b.column;
}

export interface OriginalPosition extends Position {
  sourceIndex: number;
}

interface OriginalScopeTree {
  readonly root: OriginalScope;
  readonly scopeForItemIndex: Map<number, OriginalScope>;
}

export function decodeScopes(
    map: Pick<SourceMapV3Object, 'names'|'originalScopes'|'generatedRanges'>, basePosition: Position = {
      line: 0,
      column: 0
    }): {originalScopes: OriginalScope[], generatedRanges: GeneratedRange[]} {
  if (!map.originalScopes || map.generatedRanges === undefined) {
    throw new Error('Cant decode scopes without "originalScopes" or "generatedRanges"');
  }
  const scopeTrees = decodeOriginalScopes(map.originalScopes, map.names ?? []);
  const originalScopes = scopeTrees.map(tree => tree.root);
  const generatedRanges = decodeGeneratedRanges(map.generatedRanges, scopeTrees, map.names ?? [], basePosition);
  return {originalScopes, generatedRanges};
}

export function decodeOriginalScopes(encodedOriginalScopes: string[], names: string[]): OriginalScopeTree[] {
  return encodedOriginalScopes.map(scope => decodeOriginalScope(scope, names));
}

function decodeOriginalScope(encodedOriginalScope: string, names: string[]): OriginalScopeTree {
  const scopeForItemIndex = new Map<number, OriginalScope>();
  const scopeStack: OriginalScope[] = [];
  let line = 0;
  let kindIdx = 0;

  for (const [index, item] of decodeOriginalScopeItems(encodedOriginalScope)) {
    line += item.line;
    const {column} = item;
    if (isStart(item)) {
      let kind: string|undefined;
      if (item.kind !== undefined) {
        kindIdx += item.kind;
        kind = resolveName(kindIdx, names);
      }
      const name = resolveName(item.name, names);
      const variables = item.variables.map(idx => names[idx]);
      const scope: OriginalScope = {
        start: {line, column},
        end: {line, column},
        kind,
        name,
        isStackFrame: Boolean(item.flags & EncodedOriginalScopeFlag.IS_STACK_FRAME),
        variables,
        children: [],
      };
      scopeStack.push(scope);
      scopeForItemIndex.set(index, scope);
    } else {
      const scope = scopeStack.pop();
      if (!scope) {
        throw new Error('Scope items not nested properly: encountered "end" item without "start" item');
      }
      scope.end = {line, column};

      if (scopeStack.length === 0) {
        // We are done. There might be more top-level scopes but we only allow one.
        return {root: scope, scopeForItemIndex};
      }
      scope.parent = scopeStack[scopeStack.length - 1];
      scopeStack[scopeStack.length - 1].children.push(scope);
    }
  }
  throw new Error('Malformed original scope encoding');
}

interface EncodedOriginalScopeStart {
  line: number;
  column: number;
  flags: number;
  name?: number;
  kind?: number;
  variables: number[];
}

export const enum EncodedOriginalScopeFlag {
  HAS_NAME = 0x1,
  HAS_KIND = 0x2,
  IS_STACK_FRAME = 0x4,
}

interface EncodedOriginalScopeEnd {
  line: number;
  column: number;
}

function isStart(item: EncodedOriginalScopeStart|EncodedOriginalScopeEnd): item is EncodedOriginalScopeStart {
  return 'flags' in item;
}

function*
    decodeOriginalScopeItems(encodedOriginalScope: string):
        Generator<[number, EncodedOriginalScopeStart | EncodedOriginalScopeEnd]> {
  const iter = new TokenIterator(encodedOriginalScope);
  let prevColumn = 0;
  let itemCount = 0;

  while (iter.hasNext()) {
    if (iter.peek() === ',') {
      iter.next();  // Consume ','.
    }

    const [line, column] = [iter.nextVLQ(), iter.nextVLQ()];
    if (line === 0 && column < prevColumn) {
      throw new Error('Malformed original scope encoding: start/end items must be ordered w.r.t. source positions');
    }
    prevColumn = column;

    if (!iter.hasNext() || iter.peek() === ',') {
      yield [itemCount++, {line, column}];
      continue;
    }

    const startItem: EncodedOriginalScopeStart = {
      line,
      column,
      flags: iter.nextVLQ(),
      variables: [],
    };

    if (startItem.flags & EncodedOriginalScopeFlag.HAS_NAME) {
      startItem.name = iter.nextVLQ();
    }
    if (startItem.flags & EncodedOriginalScopeFlag.HAS_KIND) {
      startItem.kind = iter.nextVLQ();
    }

    while (iter.hasNext() && iter.peek() !== ',') {
      startItem.variables.push(iter.nextVLQ());
    }

    yield [itemCount++, startItem];
  }
}

export function decodeGeneratedRanges(
    encodedGeneratedRange: string, originalScopeTrees: OriginalScopeTree[], names: string[], basePosition: Position = {
      line: 0,
      column: 0
    }): GeneratedRange[] {
  // We insert a pseudo range as there could be multiple top-level ranges and we need a root range those can be attached to.
  const rangeStack: GeneratedRange[] = [{
    start: {line: 0, column: 0},
    end: {line: 0, column: 0},
    isStackFrame: false,
    isHidden: false,
    children: [],
    values: [],
  }];
  const rangeToStartItem = new Map<GeneratedRange, EncodedGeneratedRangeStart>();

  for (const item of decodeGeneratedRangeItems(encodedGeneratedRange, basePosition)) {
    if (isRangeStart(item)) {
      const range: GeneratedRange = {
        start: {line: item.line, column: item.column},
        end: {line: item.line, column: item.column},
        isStackFrame: Boolean(item.flags & EncodedGeneratedRangeFlag.IS_STACK_FRAME),
        isHidden: Boolean(item.flags & EncodedGeneratedRangeFlag.IS_HIDDEN),
        values: [],
        children: [],
      };

      if (item.definition) {
        const {scopeIdx, sourceIdx} = item.definition;
        if (!originalScopeTrees[sourceIdx]) {
          throw new Error('Invalid source index!');
        }
        const originalScope = originalScopeTrees[sourceIdx].scopeForItemIndex.get(scopeIdx);
        if (!originalScope) {
          throw new Error('Invalid original scope index!');
        }
        range.originalScope = originalScope;
      }

      if (item.callsite) {
        const {sourceIdx, line, column} = item.callsite;
        if (!originalScopeTrees[sourceIdx]) {
          throw new Error('Invalid source index!');
        }
        range.callsite = {
          sourceIndex: sourceIdx,
          line,
          column,
        };
      }

      rangeToStartItem.set(range, item);
      rangeStack.push(range);
    } else {
      const range = rangeStack.pop();
      if (!range) {
        throw new Error('Range items not nested properly: encountered "end" item without "start" item');
      }
      range.end = {line: item.line, column: item.column};
      resolveBindings(range, names, rangeToStartItem.get(range)?.bindings);
      rangeStack[rangeStack.length - 1].children.push(range);
    }
  }

  if (rangeStack.length !== 1) {
    throw new Error('Malformed generated range encoding');
  }
  return rangeStack[0].children;
}

function resolveBindings(
    range: GeneratedRange, names: string[],
    bindingsForAllVars: EncodedGeneratedRangeStart['bindings']|undefined): void {
  if (bindingsForAllVars === undefined) {
    return;
  }

  range.values = bindingsForAllVars.map(bindings => {
    if (bindings.length === 1) {
      return resolveName(bindings[0].nameIdx, names);
    }

    const bindingRanges: BindingRange[] = bindings.map(binding => ({
                                                         from: {line: binding.line, column: binding.column},
                                                         to: {line: binding.line, column: binding.column},
                                                         value: resolveName(binding.nameIdx, names),
                                                       }));
    for (let i = 1; i < bindingRanges.length; ++i) {
      bindingRanges[i - 1].to = {...bindingRanges[i].from};
    }
    bindingRanges[bindingRanges.length - 1].to = {...range.end};
    return bindingRanges;
  });
}

interface EncodedGeneratedRangeStart {
  line: number;
  column: number;
  flags: number;
  definition?: {
    sourceIdx: number,
    scopeIdx: number,
  };
  callsite?: {
    sourceIdx: number,
    line: number,
    column: number,
  };
  bindings: {
    line: number,
    column: number,
    nameIdx: number,
  }[][];
}

interface EncodedGeneratedRangeEnd {
  line: number;
  column: number;
}

export const enum EncodedGeneratedRangeFlag {
  HAS_DEFINITION = 0x1,
  HAS_CALLSITE = 0x2,
  IS_STACK_FRAME = 0x4,
  IS_HIDDEN = 0x8,
}

function isRangeStart(item: EncodedGeneratedRangeStart|EncodedGeneratedRangeEnd): item is EncodedGeneratedRangeStart {
  return 'flags' in item;
}

function*
    decodeGeneratedRangeItems(encodedGeneratedRange: string, basePosition: Position):
        Generator<EncodedGeneratedRangeStart|EncodedGeneratedRangeEnd> {
  const iter = new TokenIterator(encodedGeneratedRange);
  let line = basePosition.line;

  // The state are the fields of the last produced item, tracked because many
  // are relative to the preceeding item.
  const state = {
    line: basePosition.line,
    column: basePosition.column,
    defSourceIdx: 0,
    defScopeIdx: 0,
    callsiteSourceIdx: 0,
    callsiteLine: 0,
    callsiteColumn: 0,
  };

  while (iter.hasNext()) {
    if (iter.peek() === ';') {
      iter.next();  // Consume ';'.
      ++line;
      continue;
    } else if (iter.peek() === ',') {
      iter.next();  // Consume ','.
      continue;
    }

    state.column = iter.nextVLQ() + (line === state.line ? state.column : 0);
    state.line = line;
    if (iter.peekVLQ() === null) {
      yield {line, column: state.column};
      continue;
    }

    const startItem: EncodedGeneratedRangeStart = {
      line,
      column: state.column,
      flags: iter.nextVLQ(),
      bindings: [],
    };

    if (startItem.flags & EncodedGeneratedRangeFlag.HAS_DEFINITION) {
      const sourceIdx = iter.nextVLQ();
      const scopeIdx = iter.nextVLQ();
      state.defScopeIdx = scopeIdx + (sourceIdx === 0 ? state.defScopeIdx : 0);
      state.defSourceIdx += sourceIdx;
      startItem.definition = {
        sourceIdx: state.defSourceIdx,
        scopeIdx: state.defScopeIdx,
      };
    }

    if (startItem.flags & EncodedGeneratedRangeFlag.HAS_CALLSITE) {
      const sourceIdx = iter.nextVLQ();
      const line = iter.nextVLQ();
      const column = iter.nextVLQ();
      state.callsiteColumn = column + (line === 0 && sourceIdx === 0 ? state.callsiteColumn : 0);
      state.callsiteLine = line + (sourceIdx === 0 ? state.callsiteLine : 0);
      state.callsiteSourceIdx += sourceIdx;
      startItem.callsite = {
        sourceIdx: state.callsiteSourceIdx,
        line: state.callsiteLine,
        column: state.callsiteColumn,
      };
    }

    while (iter.hasNext() && iter.peek() !== ';' && iter.peek() !== ',') {
      const bindings: EncodedGeneratedRangeStart['bindings'][number] = [];
      startItem.bindings.push(bindings);

      const idxOrSubrangeCount = iter.nextVLQ();
      if (idxOrSubrangeCount >= -1) {
        // Variable is available under the same expression in the whole range, or it's unavailable in the whole range.
        bindings.push({line: startItem.line, column: startItem.column, nameIdx: idxOrSubrangeCount});
        continue;
      }

      // Variable is available under different expressions in this range or unavailable in parts of this range.
      bindings.push({line: startItem.line, column: startItem.column, nameIdx: iter.nextVLQ()});
      const rangeCount = -idxOrSubrangeCount;
      for (let i = 0; i < rangeCount - 1; ++i) {
        // line, column, valueOffset
        const line = iter.nextVLQ();
        const column = iter.nextVLQ();
        const nameIdx = iter.nextVLQ();

        const lastLine = bindings.at(-1)?.line ?? 0;      // Only to make TS happy. `bindings` has one entry guaranteed.
        const lastColumn = bindings.at(-1)?.column ?? 0;  // Only to make TS happy. `bindings` has one entry guaranteed.

        bindings.push({
          line: line + lastLine,
          column: column + (line === 0 ? lastColumn : 0),
          nameIdx,
        });
      }
    }

    yield startItem;
  }
}

function resolveName(idx: number|undefined, names: string[]): string|undefined {
  if (idx === undefined || idx < 0) {
    return undefined;
  }
  return names[idx];
}
