// Copyright 2021 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.

import * as SDK from '../core/sdk/sdk.js';

const base64Digits = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';

export function encodeVlq(n: number): string {
  // Set the sign bit as the least significant bit.
  n = n >= 0 ? 2 * n : 1 - 2 * n;
  // Encode into a base64 run.
  let result = '';
  while (true) {
    // Extract the lowest 5 bits and remove them from the number.
    const digit = n & 0x1f;
    n >>= 5;
    // Is there anything more left to encode?
    if (n === 0) {
      // We are done encoding, finish the run.
      result += base64Digits[digit];
      break;
    } else {
      // There is still more encode, so add the digit and the continuation bit.
      result += base64Digits[0x20 + digit];
    }
  }
  return result;
}

export function encodeVlqList(list: number[]) {
  return list.map(encodeVlq).join('');
}

// Encode array mappings of the form "compiledLine:compiledColumn => srcFile:srcLine:srcColumn@name"
// as a source map.
export function encodeSourceMap(textMap: string[], sourceRoot?: string): SDK.SourceMap.SourceMapV3Object {
  let mappings = '';
  const sources: string[] = [];
  const names: string[] = [];
  let sourcesContent: (null|string)[]|undefined;

  const state = {
    line: -1,
    column: 0,
    srcFile: 0,
    srcLine: 0,
    srcColumn: 0,
    srcName: 0,
  };

  for (const mapping of textMap) {
    let match = mapping.match(/^(\d+):(\d+)(?:\s*=>\s*([^:]+):(\d+):(\d+)(?:@(\S+))?)?$/);
    if (!match) {
      match = mapping.match(/^([^:]+):\s*(.+)$/);
      if (!match) {
        throw new Error(`Cannot parse mapping "${mapping}"`);
      }
      (sourcesContent = sourcesContent ?? [])[getOrAddString(sources, match[1])] = match[2];
      continue;
    }

    const lastState = Object.assign({}, state);
    state.line = Number(match[1]);
    state.column = Number(match[2]);
    const hasSource = match[3] !== undefined;
    const hasName = hasSource && (match[6] !== undefined);
    if (hasSource) {
      state.srcFile = getOrAddString(sources, match[3]);
      state.srcLine = Number(match[4]);
      state.srcColumn = Number(match[5]);
      if (hasName) {
        state.srcName = getOrAddString(names, match[6]);
      }
    }

    if (state.line < lastState.line) {
      throw 'Line numbers must be increasing';
    }

    const isNewLine = state.line !== lastState.line;

    if (isNewLine) {
      // Fixup for the first line mapping.
      if (lastState.line === -1) {
        lastState.line = 0;
      }
      // Insert semicolons for all the new lines.
      mappings += ';'.repeat(state.line - lastState.line);
      // Reset the compiled code column counter.
      lastState.column = 0;
    } else {
      mappings += ',';
    }

    // Encode the mapping and add it to the list of mappings.
    const toEncode = [state.column - lastState.column];
    if (hasSource) {
      toEncode.push(
          state.srcFile - lastState.srcFile, state.srcLine - lastState.srcLine, state.srcColumn - lastState.srcColumn);
      if (hasName) {
        toEncode.push(state.srcName - lastState.srcName);
      }
    }
    mappings += encodeVlqList(toEncode);
  }

  const sourceMapV3: SDK.SourceMap.SourceMapV3 = {version: 3, mappings, sources, names};
  if (sourceRoot !== undefined) {
    sourceMapV3.sourceRoot = sourceRoot;
  }
  if (sourcesContent !== undefined) {
    for (let i = 0; i < sources.length; ++i) {
      if (typeof sourcesContent[i] !== 'string') {
        sourcesContent[i] = null;
      }
    }
    sourceMapV3.sourcesContent = sourcesContent;
  }
  return sourceMapV3;

  function getOrAddString(array: string[], s: string) {
    const index = array.indexOf(s);
    if (index >= 0) {
      return index;
    }
    array.push(s);
    return array.length - 1;
  }
}

export class OriginalScopeBuilder {
  #encodedScope = '';
  #lastLine = 0;
  #lastKind = 0;

  readonly #names: string[];

  /** The 'names' field of the SourceMap. The builder will modify it. */
  constructor(names: string[]) {
    this.#names = names;
  }

  start(
      line: number, column: number,
      options?: {name?: string, kind?: string, isStackFrame?: boolean, variables?: string[]}): this {
    if (this.#encodedScope !== '') {
      this.#encodedScope += ',';
    }

    const lineDiff = line - this.#lastLine;
    this.#lastLine = line;
    let flags = 0;
    const nameIdxAndKindIdx: number[] = [];

    if (options?.name) {
      flags |= SDK.SourceMapScopes.EncodedOriginalScopeFlag.HAS_NAME;
      nameIdxAndKindIdx.push(this.#nameIdx(options.name));
    }
    if (options?.kind) {
      flags |= SDK.SourceMapScopes.EncodedOriginalScopeFlag.HAS_KIND;
      nameIdxAndKindIdx.push(this.#encodeKind(options?.kind));
    }
    if (options?.isStackFrame) {
      flags |= SDK.SourceMapScopes.EncodedOriginalScopeFlag.IS_STACK_FRAME;
    }

    this.#encodedScope += encodeVlqList([lineDiff, column, flags, ...nameIdxAndKindIdx]);

    if (options?.variables) {
      this.#encodedScope += encodeVlqList(options.variables.map(variable => this.#nameIdx(variable)));
    }

    return this;
  }

  end(line: number, column: number): this {
    if (this.#encodedScope !== '') {
      this.#encodedScope += ',';
    }

    const lineDiff = line - this.#lastLine;
    this.#lastLine = line;
    this.#encodedScope += encodeVlqList([lineDiff, column]);

    return this;
  }

  build(): string {
    const result = this.#encodedScope;
    this.#lastLine = 0;
    this.#encodedScope = '';
    return result;
  }

  #encodeKind(kind: string): number {
    const kindIdx = this.#nameIdx(kind);
    const encodedIdx = kindIdx - this.#lastKind;
    this.#lastKind = kindIdx;
    return encodedIdx;
  }

  #nameIdx(name: string): number {
    let idx = this.#names.indexOf(name);
    if (idx < 0) {
      idx = this.#names.length;
      this.#names.push(name);
    }
    return idx;
  }
}

export class GeneratedRangeBuilder {
  #encodedRange = '';
  #state = {
    line: 0,
    column: 0,
    defSourceIdx: 0,
    defScopeIdx: 0,
    callsiteSourceIdx: 0,
    callsiteLine: 0,
    callsiteColumn: 0,
  };

  readonly #names: string[];

  /** The 'names' field of the SourceMap. The builder will modify it. */
  constructor(names: string[]) {
    this.#names = names;
  }

  start(line: number, column: number, options?: {
    isStackFrame?: boolean,
    isHidden?: boolean,
    definition?: {sourceIdx: number, scopeIdx: number},
    callsite?: {sourceIdx: number, line: number, column: number},
    bindings?: (string|undefined|{line: number, column: number, name: string|undefined}[])[],
  }): this {
    this.#emitLineSeparator(line);
    this.#emitItemSepratorIfRequired();

    const emittedColumn = column - (this.#state.line === line ? this.#state.column : 0);
    this.#encodedRange += encodeVlq(emittedColumn);

    this.#state.line = line;
    this.#state.column = column;

    let flags = 0;
    if (options?.definition) {
      flags |= SDK.SourceMapScopes.EncodedGeneratedRangeFlag.HAS_DEFINITION;
    }
    if (options?.callsite) {
      flags |= SDK.SourceMapScopes.EncodedGeneratedRangeFlag.HAS_CALLSITE;
    }
    if (options?.isStackFrame) {
      flags |= SDK.SourceMapScopes.EncodedGeneratedRangeFlag.IS_STACK_FRAME;
    }
    if (options?.isHidden) {
      flags |= SDK.SourceMapScopes.EncodedGeneratedRangeFlag.IS_HIDDEN;
    }
    this.#encodedRange += encodeVlq(flags);

    if (options?.definition) {
      const {sourceIdx, scopeIdx} = options.definition;
      this.#encodedRange += encodeVlq(sourceIdx - this.#state.defSourceIdx);

      const emittedScopeIdx = scopeIdx - (this.#state.defSourceIdx === sourceIdx ? this.#state.defScopeIdx : 0);
      this.#encodedRange += encodeVlq(emittedScopeIdx);

      this.#state.defSourceIdx = sourceIdx;
      this.#state.defScopeIdx = scopeIdx;
    }

    if (options?.callsite) {
      const {sourceIdx, line, column} = options.callsite;
      this.#encodedRange += encodeVlq(sourceIdx - this.#state.callsiteSourceIdx);

      const emittedLine = line - (this.#state.callsiteSourceIdx === sourceIdx ? this.#state.callsiteLine : 0);
      this.#encodedRange += encodeVlq(emittedLine);

      const emittedColumn = column - (this.#state.callsiteLine === line ? this.#state.callsiteColumn : 0);
      this.#encodedRange += encodeVlq(emittedColumn);

      this.#state.callsiteSourceIdx = sourceIdx;
      this.#state.callsiteLine = line;
      this.#state.callsiteColumn = column;
    }

    for (const bindings of options?.bindings ?? []) {
      if (bindings === undefined || typeof bindings === 'string') {
        this.#encodedRange += encodeVlq(this.#nameIdx(bindings));
        continue;
      }

      this.#encodedRange += encodeVlq(-bindings.length);
      this.#encodedRange += encodeVlq(this.#nameIdx(bindings[0].name));
      if (bindings[0].line !== line || bindings[0].column !== column) {
        throw new Error('First binding line/column must match the range start line/column');
      }

      for (let i = 1; i < bindings.length; ++i) {
        const {line, column, name} = bindings[i];
        const emittedLine = line - bindings[i - 1].line;
        const emittedColumn = column - (line === bindings[i - 1].line ? bindings[i - 1].column : 0);
        this.#encodedRange += encodeVlq(emittedLine);
        this.#encodedRange += encodeVlq(emittedColumn);
        this.#encodedRange += encodeVlq(this.#nameIdx(name));
      }
    }

    return this;
  }

  end(line: number, column: number): this {
    this.#emitLineSeparator(line);
    this.#emitItemSepratorIfRequired();

    const emittedColumn = column - (this.#state.line === line ? this.#state.column : 0);
    this.#encodedRange += encodeVlq(emittedColumn);

    this.#state.line = line;
    this.#state.column = column;

    return this;
  }

  #emitLineSeparator(line: number): void {
    for (let i = this.#state.line; i < line; ++i) {
      this.#encodedRange += ';';
    }
  }

  #emitItemSepratorIfRequired(): void {
    if (this.#encodedRange !== '' && this.#encodedRange[this.#encodedRange.length - 1] !== ';') {
      this.#encodedRange += ',';
    }
  }

  #nameIdx(name?: string): number {
    if (name === undefined) {
      return -1;
    }

    let idx = this.#names.indexOf(name);
    if (idx < 0) {
      idx = this.#names.length;
      this.#names.push(name);
    }
    return idx;
  }

  build(): string {
    const result = this.#encodedRange;
    this.#state = {
      line: 0,
      column: 0,
      defSourceIdx: 0,
      defScopeIdx: 0,
      callsiteSourceIdx: 0,
      callsiteLine: 0,
      callsiteColumn: 0,
    };
    this.#encodedRange = '';
    return result;
  }
}
