/**
 * @license
 * Copyright Google Inc. All Rights Reserved.
 *
 * Use of this source code is governed by an MIT-style license that can be
 * found in the LICENSE file at https://angular.io/license
 */

import {utf8Encode} from '../util';

// https://docs.google.com/document/d/1U1RGAehQwRypUTovF1KRlpiOFze0b-_2gc6fAH0KY0k/edit
const VERSION = 3;

const JS_B64_PREFIX = '# sourceMappingURL=data:application/json;base64,';

type Segment = {
  col0: number,
  sourceUrl?: string,
  sourceLine0?: number,
  sourceCol0?: number,
};

export type SourceMap = {
  version: number,
  file?: string,
  sourceRoot: string,
  sources: string[],
  sourcesContent: (string | null)[],
  mappings: string,
};

export class SourceMapGenerator {
  private sourcesContent: Map<string, string|null> = new Map();
  private lines: Segment[][] = [];
  private lastCol0: number = 0;
  private hasMappings = false;

  constructor(private file: string|null = null) {}

  // The content is `null` when the content is expected to be loaded using the URL
  addSource(url: string, content: string|null = null): this {
    if (!this.sourcesContent.has(url)) {
      this.sourcesContent.set(url, content);
    }
    return this;
  }

  addLine(): this {
    this.lines.push([]);
    this.lastCol0 = 0;
    return this;
  }

  addMapping(col0: number, sourceUrl?: string, sourceLine0?: number, sourceCol0?: number): this {
    if (!this.currentLine) {
      throw new Error(`A line must be added before mappings can be added`);
    }
    if (sourceUrl != null && !this.sourcesContent.has(sourceUrl)) {
      throw new Error(`Unknown source file "${sourceUrl}"`);
    }
    if (col0 == null) {
      throw new Error(`The column in the generated code must be provided`);
    }
    if (col0 < this.lastCol0) {
      throw new Error(`Mapping should be added in output order`);
    }
    if (sourceUrl && (sourceLine0 == null || sourceCol0 == null)) {
      throw new Error(`The source location must be provided when a source url is provided`);
    }

    this.hasMappings = true;
    this.lastCol0 = col0;
    this.currentLine.push({col0, sourceUrl, sourceLine0, sourceCol0});
    return this;
  }

  private get currentLine(): Segment[]|null { return this.lines.slice(-1)[0]; }

  toJSON(): SourceMap|null {
    if (!this.hasMappings) {
      return null;
    }

    const sourcesIndex = new Map<string, number>();
    const sources: string[] = [];
    const sourcesContent: (string | null)[] = [];

    Array.from(this.sourcesContent.keys()).forEach((url: string, i: number) => {
      sourcesIndex.set(url, i);
      sources.push(url);
      sourcesContent.push(this.sourcesContent.get(url) || null);
    });

    let mappings: string = '';
    let lastCol0: number = 0;
    let lastSourceIndex: number = 0;
    let lastSourceLine0: number = 0;
    let lastSourceCol0: number = 0;

    this.lines.forEach(segments => {
      lastCol0 = 0;

      mappings += segments
                      .map(segment => {
                        // zero-based starting column of the line in the generated code
                        let segAsStr = toBase64VLQ(segment.col0 - lastCol0);
                        lastCol0 = segment.col0;

                        if (segment.sourceUrl != null) {
                          // zero-based index into the “sources” list
                          segAsStr +=
                              toBase64VLQ(sourcesIndex.get(segment.sourceUrl) ! - lastSourceIndex);
                          lastSourceIndex = sourcesIndex.get(segment.sourceUrl) !;
                          // the zero-based starting line in the original source
                          segAsStr += toBase64VLQ(segment.sourceLine0 ! - lastSourceLine0);
                          lastSourceLine0 = segment.sourceLine0 !;
                          // the zero-based starting column in the original source
                          segAsStr += toBase64VLQ(segment.sourceCol0 ! - lastSourceCol0);
                          lastSourceCol0 = segment.sourceCol0 !;
                        }

                        return segAsStr;
                      })
                      .join(',');
      mappings += ';';
    });

    mappings = mappings.slice(0, -1);

    return {
      'file': this.file || '',
      'version': VERSION,
      'sourceRoot': '',
      'sources': sources,
      'sourcesContent': sourcesContent,
      'mappings': mappings,
    };
  }

  toJsComment(): string {
    return this.hasMappings ? '//' + JS_B64_PREFIX + toBase64String(JSON.stringify(this, null, 0)) :
                              '';
  }
}

export function toBase64String(value: string): string {
  let b64 = '';
  value = utf8Encode(value);
  for (let i = 0; i < value.length;) {
    const i1 = value.charCodeAt(i++);
    const i2 = value.charCodeAt(i++);
    const i3 = value.charCodeAt(i++);
    b64 += toBase64Digit(i1 >> 2);
    b64 += toBase64Digit(((i1 & 3) << 4) | (isNaN(i2) ? 0 : i2 >> 4));
    b64 += isNaN(i2) ? '=' : toBase64Digit(((i2 & 15) << 2) | (i3 >> 6));
    b64 += isNaN(i2) || isNaN(i3) ? '=' : toBase64Digit(i3 & 63);
  }

  return b64;
}

function toBase64VLQ(value: number): string {
  value = value < 0 ? ((-value) << 1) + 1 : value << 1;

  let out = '';
  do {
    let digit = value & 31;
    value = value >> 5;
    if (value > 0) {
      digit = digit | 32;
    }
    out += toBase64Digit(digit);
  } while (value > 0);

  return out;
}

const B64_DIGITS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';

function toBase64Digit(value: number): string {
  if (value < 0 || value >= 64) {
    throw new Error(`Can only encode value in the range [0, 63]`);
  }

  return B64_DIGITS[value];
}
