// Copyright (c) 2020 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 type * as Bindings from '../../models/bindings/bindings.js';
import * as Common from '../../core/common/common.js';
import * as Platform from '../../core/platform/platform.js';
import * as SDK from '../../core/sdk/sdk.js';
import * as TextUtils from '../../models/text_utils/text_utils.js';
import type * as Protocol from '../../generated/protocol.js';

export const enum CoverageType {
  CSS = (1 << 0),
  JavaScript = (1 << 1),
  JavaScriptPerFunction = (1 << 2),
}

export const enum SuspensionState {
  Active = 'Active',
  Suspending = 'Suspending',
  Suspended = 'Suspended',
}

// TODO(crbug.com/1167717): Make this a const enum again
// eslint-disable-next-line rulesdir/const_enum
export enum Events {
  CoverageUpdated = 'CoverageUpdated',
  CoverageReset = 'CoverageReset',
}

export type EventTypes = {
  [Events.CoverageUpdated]: CoverageInfo[],
  [Events.CoverageReset]: void,
};

const COVERAGE_POLLING_PERIOD_MS: number = 200;

interface BacklogItem<T> {
  rawCoverageData: Array<T>;
  stamp: number;
}

export class CoverageModel extends SDK.SDKModel.SDKModel<EventTypes> {
  private cpuProfilerModel: SDK.CPUProfilerModel.CPUProfilerModel|null;
  private cssModel: SDK.CSSModel.CSSModel|null;
  private debuggerModel: SDK.DebuggerModel.DebuggerModel|null;
  private coverageByURL: Map<Platform.DevToolsPath.UrlString, URLCoverageInfo>;
  private coverageByContentProvider: Map<TextUtils.ContentProvider.ContentProvider, CoverageInfo>;
  private coverageUpdateTimes: Set<number>;
  private suspensionState: SuspensionState;
  private pollTimer: number|null;
  private currentPollPromise: Promise<void>|null;
  private shouldResumePollingOnResume: boolean|null;
  private jsBacklog: BacklogItem<Protocol.Profiler.ScriptCoverage>[];
  private cssBacklog: BacklogItem<Protocol.CSS.RuleUsage>[];
  private performanceTraceRecording: boolean|null;

  constructor(target: SDK.Target.Target) {
    super(target);
    this.cpuProfilerModel = target.model(SDK.CPUProfilerModel.CPUProfilerModel);
    this.cssModel = target.model(SDK.CSSModel.CSSModel);
    this.debuggerModel = target.model(SDK.DebuggerModel.DebuggerModel);

    this.coverageByURL = new Map();
    this.coverageByContentProvider = new Map();

    // We keep track of the update times, because the other data-structures don't change if an
    // update doesn't change the coverage. Some visualizations want to convey to the user that
    // an update was received at a certain time, but did not result in a coverage change.
    this.coverageUpdateTimes = new Set();

    this.suspensionState = SuspensionState.Active;
    this.pollTimer = null;
    this.currentPollPromise = null;
    this.shouldResumePollingOnResume = false;
    this.jsBacklog = [];
    this.cssBacklog = [];
    this.performanceTraceRecording = false;
  }

  async start(jsCoveragePerBlock: boolean): Promise<boolean> {
    if (this.suspensionState !== SuspensionState.Active) {
      throw Error('Cannot start CoverageModel while it is not active.');
    }
    const promises = [];
    if (this.cssModel) {
      // Note there's no JS coverage since JS won't ever return
      // coverage twice, even after it's restarted.
      this.clearCSS();

      this.cssModel.addEventListener(SDK.CSSModel.Events.StyleSheetAdded, this.handleStyleSheetAdded, this);
      promises.push(this.cssModel.startCoverage());
    }
    if (this.cpuProfilerModel) {
      promises.push(
          this.cpuProfilerModel.startPreciseCoverage(jsCoveragePerBlock, this.preciseCoverageDeltaUpdate.bind(this)));
    }

    await Promise.all(promises);
    return Boolean(this.cssModel || this.cpuProfilerModel);
  }

  preciseCoverageDeltaUpdate(timestamp: number, occasion: string, coverageData: Protocol.Profiler.ScriptCoverage[]):
      void {
    this.coverageUpdateTimes.add(timestamp);
    void this.backlogOrProcessJSCoverage(coverageData, timestamp);
  }

  async stop(): Promise<void> {
    await this.stopPolling();
    const promises = [];
    if (this.cpuProfilerModel) {
      promises.push(this.cpuProfilerModel.stopPreciseCoverage());
    }
    if (this.cssModel) {
      promises.push(this.cssModel.stopCoverage());
      this.cssModel.removeEventListener(SDK.CSSModel.Events.StyleSheetAdded, this.handleStyleSheetAdded, this);
    }
    await Promise.all(promises);
  }

  reset(): void {
    this.coverageByURL = new Map();
    this.coverageByContentProvider = new Map();
    this.coverageUpdateTimes = new Set();
    this.dispatchEventToListeners(Events.CoverageReset);
  }

  async startPolling(): Promise<void> {
    if (this.currentPollPromise || this.suspensionState !== SuspensionState.Active) {
      return;
    }
    await this.pollLoop();
  }

  private async pollLoop(): Promise<void> {
    this.clearTimer();
    this.currentPollPromise = this.pollAndCallback();
    await this.currentPollPromise;
    if (this.suspensionState === SuspensionState.Active || this.performanceTraceRecording) {
      this.pollTimer = window.setTimeout(() => this.pollLoop(), COVERAGE_POLLING_PERIOD_MS);
    }
  }

  async stopPolling(): Promise<void> {
    this.clearTimer();
    await this.currentPollPromise;
    this.currentPollPromise = null;
    // Do one last poll to get the final data.
    await this.pollAndCallback();
  }

  private async pollAndCallback(): Promise<void> {
    if (this.suspensionState === SuspensionState.Suspended && !this.performanceTraceRecording) {
      return;
    }
    const updates = await this.takeAllCoverage();
    // This conditional should never trigger, as all intended ways to stop
    // polling are awaiting the `_currentPollPromise` before suspending.
    console.assert(
        this.suspensionState !== SuspensionState.Suspended || Boolean(this.performanceTraceRecording),
        'CoverageModel was suspended while polling.');
    if (updates.length) {
      this.dispatchEventToListeners(Events.CoverageUpdated, updates);
    }
  }

  private clearTimer(): void {
    if (this.pollTimer) {
      clearTimeout(this.pollTimer);
      this.pollTimer = null;
    }
  }

  /**
   * Stops polling as preparation for suspension. This function is idempotent
   * due because it changes the state to suspending.
   */
  async preSuspendModel(reason?: string): Promise<void> {
    if (this.suspensionState !== SuspensionState.Active) {
      return;
    }
    this.suspensionState = SuspensionState.Suspending;
    if (reason === 'performance-timeline') {
      this.performanceTraceRecording = true;
      // Keep polling to the backlog if a performance trace is recorded.
      return;
    }
    if (this.currentPollPromise) {
      await this.stopPolling();
      this.shouldResumePollingOnResume = true;
    }
  }

  async suspendModel(_reason?: string): Promise<void> {
    this.suspensionState = SuspensionState.Suspended;
  }

  async resumeModel(): Promise<void> {
  }

  /**
   * Restarts polling after suspension. Note that the function is idempotent
   * because starting polling is idempotent.
   */
  async postResumeModel(): Promise<void> {
    this.suspensionState = SuspensionState.Active;
    this.performanceTraceRecording = false;
    if (this.shouldResumePollingOnResume) {
      this.shouldResumePollingOnResume = false;
      await this.startPolling();
    }
  }

  entries(): URLCoverageInfo[] {
    return Array.from(this.coverageByURL.values());
  }

  getCoverageForUrl(url: Platform.DevToolsPath.UrlString): URLCoverageInfo|null {
    return this.coverageByURL.get(url) || null;
  }

  usageForRange(contentProvider: TextUtils.ContentProvider.ContentProvider, startOffset: number, endOffset: number):
      boolean|undefined {
    const coverageInfo = this.coverageByContentProvider.get(contentProvider);
    return coverageInfo && coverageInfo.usageForRange(startOffset, endOffset);
  }

  private clearCSS(): void {
    for (const entry of this.coverageByContentProvider.values()) {
      if (entry.type() !== CoverageType.CSS) {
        continue;
      }
      const contentProvider = entry.getContentProvider() as SDK.CSSStyleSheetHeader.CSSStyleSheetHeader;
      this.coverageByContentProvider.delete(contentProvider);
      const urlEntry = this.coverageByURL.get(entry.url());
      if (!urlEntry) {
        continue;
      }
      const key = `${contentProvider.startLine}:${contentProvider.startColumn}`;
      urlEntry.removeCoverageEntry(key, entry);
      if (urlEntry.numberOfEntries() === 0) {
        this.coverageByURL.delete(entry.url());
      }
    }

    if (this.cssModel) {
      for (const styleSheetHeader of this.cssModel.getAllStyleSheetHeaders()) {
        this.addStyleSheetToCSSCoverage(styleSheetHeader);
      }
    }
  }

  private async takeAllCoverage(): Promise<CoverageInfo[]> {
    const [updatesCSS, updatesJS] = await Promise.all([this.takeCSSCoverage(), this.takeJSCoverage()]);
    return [...updatesCSS, ...updatesJS];
  }

  private async takeJSCoverage(): Promise<CoverageInfo[]> {
    if (!this.cpuProfilerModel) {
      return [];
    }
    const {coverage, timestamp} = await this.cpuProfilerModel.takePreciseCoverage();
    this.coverageUpdateTimes.add(timestamp);
    return this.backlogOrProcessJSCoverage(coverage, timestamp);
  }

  getCoverageUpdateTimes(): Set<number> {
    return this.coverageUpdateTimes;
  }

  private async backlogOrProcessJSCoverage(
      freshRawCoverageData: Protocol.Profiler.ScriptCoverage[], freshTimestamp: number): Promise<CoverageInfo[]> {
    if (freshRawCoverageData.length > 0) {
      this.jsBacklog.push({rawCoverageData: freshRawCoverageData, stamp: freshTimestamp});
    }
    if (this.suspensionState !== SuspensionState.Active) {
      return [];
    }
    const ascendingByTimestamp = (x: {stamp: number}, y: {stamp: number}): number => x.stamp - y.stamp;
    const results = [];
    for (const {rawCoverageData, stamp} of this.jsBacklog.sort(ascendingByTimestamp)) {
      results.push(this.processJSCoverage(rawCoverageData, stamp));
    }
    this.jsBacklog = [];
    return results.flat();
  }

  async processJSBacklog(): Promise<void> {
    void this.backlogOrProcessJSCoverage([], 0);
  }

  private processJSCoverage(scriptsCoverage: Protocol.Profiler.ScriptCoverage[], stamp: number): CoverageInfo[] {
    if (!this.debuggerModel) {
      return [];
    }
    const updatedEntries = [];
    for (const entry of scriptsCoverage) {
      const script = this.debuggerModel.scriptForId(entry.scriptId);
      if (!script) {
        continue;
      }

      const ranges = [];
      let type = CoverageType.JavaScript;
      for (const func of entry.functions) {
        // Do not coerce undefined to false, i.e. only consider blockLevel to be false
        // if back-end explicitly provides blockLevel field, otherwise presume blockLevel
        // coverage is not available. Also, ignore non-block level functions that weren't
        // ever called.
        if (func.isBlockCoverage === false && !(func.ranges.length === 1 && !func.ranges[0].count)) {
          type |= CoverageType.JavaScriptPerFunction;
        }
        for (const range of func.ranges) {
          ranges.push(range);
        }
      }
      const subentry = this.addCoverage(
          script, script.contentLength, script.lineOffset, script.columnOffset, ranges, type as CoverageType, stamp);
      if (subentry) {
        updatedEntries.push(subentry);
      }
    }
    return updatedEntries;
  }

  private handleStyleSheetAdded(
      event: Common.EventTarget.EventTargetEvent<SDK.CSSStyleSheetHeader.CSSStyleSheetHeader>): void {
    this.addStyleSheetToCSSCoverage(event.data);
  }

  private async takeCSSCoverage(): Promise<CoverageInfo[]> {
    // Don't poll if we have no model, or are suspended.
    if (!this.cssModel || this.suspensionState !== SuspensionState.Active) {
      return [];
    }
    const {coverage, timestamp} = await this.cssModel.takeCoverageDelta();
    this.coverageUpdateTimes.add(timestamp);
    return this.backlogOrProcessCSSCoverage(coverage, timestamp);
  }

  private async backlogOrProcessCSSCoverage(freshRawCoverageData: Protocol.CSS.RuleUsage[], freshTimestamp: number):
      Promise<CoverageInfo[]> {
    if (freshRawCoverageData.length > 0) {
      this.cssBacklog.push({rawCoverageData: freshRawCoverageData, stamp: freshTimestamp});
    }
    if (this.suspensionState !== SuspensionState.Active) {
      return [];
    }
    const ascendingByTimestamp = (x: {stamp: number}, y: {stamp: number}): number => x.stamp - y.stamp;
    const results = [];
    for (const {rawCoverageData, stamp} of this.cssBacklog.sort(ascendingByTimestamp)) {
      results.push(this.processCSSCoverage(rawCoverageData, stamp));
    }
    this.cssBacklog = [];
    return results.flat();
  }

  private processCSSCoverage(ruleUsageList: Protocol.CSS.RuleUsage[], stamp: number): CoverageInfo[] {
    if (!this.cssModel) {
      return [];
    }
    const updatedEntries = [];
    const rulesByStyleSheet = new Map<SDK.CSSStyleSheetHeader.CSSStyleSheetHeader, RangeUseCount[]>();
    for (const rule of ruleUsageList) {
      const styleSheetHeader = this.cssModel.styleSheetHeaderForId(rule.styleSheetId);
      if (!styleSheetHeader) {
        continue;
      }
      let ranges = rulesByStyleSheet.get(styleSheetHeader);
      if (!ranges) {
        ranges = [];
        rulesByStyleSheet.set(styleSheetHeader, ranges);
      }
      ranges.push({startOffset: rule.startOffset, endOffset: rule.endOffset, count: Number(rule.used)});
    }
    for (const entry of rulesByStyleSheet) {
      const styleSheetHeader = entry[0] as SDK.CSSStyleSheetHeader.CSSStyleSheetHeader;
      const ranges = entry[1] as RangeUseCount[];
      const subentry = this.addCoverage(
          styleSheetHeader, styleSheetHeader.contentLength, styleSheetHeader.startLine, styleSheetHeader.startColumn,
          ranges, CoverageType.CSS, stamp);
      if (subentry) {
        updatedEntries.push(subentry);
      }
    }
    return updatedEntries;
  }

  private static convertToDisjointSegments(ranges: RangeUseCount[], stamp: number): CoverageSegment[] {
    ranges.sort((a, b) => a.startOffset - b.startOffset);

    const result: CoverageSegment[] = [];
    const stack = [];
    for (const entry of ranges) {
      let top: RangeUseCount = stack[stack.length - 1];
      while (top && top.endOffset <= entry.startOffset) {
        append(top.endOffset, top.count);
        stack.pop();
        top = stack[stack.length - 1];
      }
      append(entry.startOffset, top ? top.count : 0);
      stack.push(entry);
    }

    for (let top = stack.pop(); top; top = stack.pop()) {
      append(top.endOffset, top.count);
    }

    function append(end: number, count: number): void {
      const last = result[result.length - 1];
      if (last) {
        if (last.end === end) {
          return;
        }
        if (last.count === count) {
          last.end = end;
          return;
        }
      }
      result.push({end: end, count: count, stamp: stamp});
    }

    return result;
  }

  private addStyleSheetToCSSCoverage(styleSheetHeader: SDK.CSSStyleSheetHeader.CSSStyleSheetHeader): void {
    this.addCoverage(
        styleSheetHeader, styleSheetHeader.contentLength, styleSheetHeader.startLine, styleSheetHeader.startColumn, [],
        CoverageType.CSS, Date.now());
  }

  private addCoverage(
      contentProvider: TextUtils.ContentProvider.ContentProvider, contentLength: number, startLine: number,
      startColumn: number, ranges: RangeUseCount[], type: CoverageType, stamp: number): CoverageInfo|null {
    const url = contentProvider.contentURL();
    if (!url) {
      return null;
    }
    let urlCoverage = this.coverageByURL.get(url);
    let isNewUrlCoverage = false;
    if (!urlCoverage) {
      isNewUrlCoverage = true;
      urlCoverage = new URLCoverageInfo(url);
      this.coverageByURL.set(url, urlCoverage);
    }

    const coverageInfo = urlCoverage.ensureEntry(contentProvider, contentLength, startLine, startColumn, type);
    this.coverageByContentProvider.set(contentProvider, coverageInfo);
    const segments = CoverageModel.convertToDisjointSegments(ranges, stamp);
    const last = segments[segments.length - 1];
    if (last && last.end < contentLength) {
      segments.push({end: contentLength, stamp: stamp, count: 0});
    }
    const usedSizeDelta = coverageInfo.mergeCoverage(segments);
    if (!isNewUrlCoverage && usedSizeDelta === 0) {
      return null;
    }
    urlCoverage.addToSizes(usedSizeDelta, 0);
    return coverageInfo;
  }

  async exportReport(fos: Bindings.FileUtils.FileOutputStream): Promise<void> {
    const result: {url: string, ranges: {start: number, end: number}[], text: string|null}[] = [];
    const coverageByUrlKeys = Array.from(this.coverageByURL.keys()).sort();
    for (const urlInfoKey of coverageByUrlKeys) {
      const urlInfo = this.coverageByURL.get(urlInfoKey);
      if (!urlInfo) {
        continue;
      }
      const url = urlInfo.url();
      if (url.startsWith('extensions::') || url.startsWith('chrome-extension://')) {
        continue;
      }
      result.push(...await urlInfo.entriesForExport());
    }
    await fos.write(JSON.stringify(result, undefined, 2));
    void fos.close();
  }
}

SDK.SDKModel.SDKModel.register(CoverageModel, {capabilities: SDK.Target.Capability.None, autostart: false});

export interface EntryForExport {
  url: Platform.DevToolsPath.UrlString;
  ranges: {start: number, end: number}[];
  text: string|null;
}

function locationCompare(a: string, b: string): number {
  const [aLine, aPos] = a.split(':');
  const [bLine, bPos] = b.split(':');
  return Number.parseInt(aLine, 10) - Number.parseInt(bLine, 10) ||
      Number.parseInt(aPos, 10) - Number.parseInt(bPos, 10);
}

export class URLCoverageInfo extends Common.ObjectWrapper.ObjectWrapper<URLCoverageInfo.EventTypes> {
  private readonly urlInternal: Platform.DevToolsPath.UrlString;
  private coverageInfoByLocation: Map<string, CoverageInfo>;
  private sizeInternal: number;
  private usedSizeInternal: number;
  private typeInternal!: CoverageType;
  private isContentScriptInternal: boolean;

  constructor(url: Platform.DevToolsPath.UrlString) {
    super();

    this.urlInternal = url;
    this.coverageInfoByLocation = new Map();
    this.sizeInternal = 0;
    this.usedSizeInternal = 0;
    this.isContentScriptInternal = false;
  }

  url(): Platform.DevToolsPath.UrlString {
    return this.urlInternal;
  }

  type(): CoverageType {
    return this.typeInternal;
  }

  size(): number {
    return this.sizeInternal;
  }

  usedSize(): number {
    return this.usedSizeInternal;
  }

  unusedSize(): number {
    return this.sizeInternal - this.usedSizeInternal;
  }

  usedPercentage(): number {
    // Per convention, empty files are reported as 100 % uncovered
    if (this.sizeInternal === 0) {
      return 0;
    }
    return this.usedSize() / this.size();
  }

  unusedPercentage(): number {
    // Per convention, empty files are reported as 100 % uncovered
    if (this.sizeInternal === 0) {
      return 100;
    }
    return this.unusedSize() / this.size();
  }

  isContentScript(): boolean {
    return this.isContentScriptInternal;
  }

  entries(): IterableIterator<CoverageInfo> {
    return this.coverageInfoByLocation.values();
  }

  numberOfEntries(): number {
    return this.coverageInfoByLocation.size;
  }

  removeCoverageEntry(key: string, entry: CoverageInfo): void {
    if (!this.coverageInfoByLocation.delete(key)) {
      return;
    }
    this.addToSizes(-entry.getUsedSize(), -entry.getSize());
  }

  addToSizes(usedSize: number, size: number): void {
    this.usedSizeInternal += usedSize;
    this.sizeInternal += size;

    if (usedSize !== 0 || size !== 0) {
      this.dispatchEventToListeners(URLCoverageInfo.Events.SizesChanged);
    }
  }

  ensureEntry(
      contentProvider: TextUtils.ContentProvider.ContentProvider, contentLength: number, lineOffset: number,
      columnOffset: number, type: CoverageType): CoverageInfo {
    const key = `${lineOffset}:${columnOffset}`;
    let entry = this.coverageInfoByLocation.get(key);

    if ((type & CoverageType.JavaScript) && !this.coverageInfoByLocation.size) {
      this.isContentScriptInternal = (contentProvider as SDK.Script.Script).isContentScript();
    }
    this.typeInternal |= type;

    if (entry) {
      entry.addCoverageType(type);
      return entry;
    }

    if ((type & CoverageType.JavaScript) && !this.coverageInfoByLocation.size) {
      this.isContentScriptInternal = (contentProvider as SDK.Script.Script).isContentScript();
    }

    entry = new CoverageInfo(contentProvider, contentLength, lineOffset, columnOffset, type);
    this.coverageInfoByLocation.set(key, entry);
    this.addToSizes(0, contentLength);

    return entry;
  }

  async getFullText(): Promise<TextUtils.Text.Text|null> {
    // For .html resources, multiple scripts share URL, but have different offsets.
    let useFullText = false;
    const url = this.url();
    for (const info of this.coverageInfoByLocation.values()) {
      const {lineOffset, columnOffset} = info.getOffsets();
      if (lineOffset || columnOffset) {
        useFullText = Boolean(url);
        break;
      }
    }

    if (!useFullText) {
      return null;
    }
    const resource = SDK.ResourceTreeModel.ResourceTreeModel.resourceForURL(url);
    if (!resource) {
      return null;
    }
    const content = (await resource.requestContent()).content;
    return new TextUtils.Text.Text(content || '');
  }

  entriesForExportBasedOnFullText(fullText: TextUtils.Text.Text): EntryForExport {
    const coverageByLocationKeys = Array.from(this.coverageInfoByLocation.keys()).sort(locationCompare);
    const entry: EntryForExport = {url: this.url(), ranges: [], text: fullText.value()};
    for (const infoKey of coverageByLocationKeys) {
      const info = this.coverageInfoByLocation.get(infoKey);
      if (!info) {
        continue;
      }
      const {lineOffset, columnOffset} = info.getOffsets();
      const offset = fullText ? fullText.offsetFromPosition(lineOffset, columnOffset) : 0;
      entry.ranges.push(...info.rangesForExport(offset));
    }
    return entry;
  }

  async entriesForExportBasedOnContent(): Promise<EntryForExport[]> {
    const coverageByLocationKeys = Array.from(this.coverageInfoByLocation.keys()).sort(locationCompare);
    const result = [];
    for (const infoKey of coverageByLocationKeys) {
      const info = this.coverageInfoByLocation.get(infoKey);
      if (!info) {
        continue;
      }
      const entry: EntryForExport = {
        url: this.url(),
        ranges: info.rangesForExport(),
        text: (await info.getContentProvider().requestContent()).content,
      };
      result.push(entry);
    }
    return result;
  }

  async entriesForExport(): Promise<EntryForExport[]> {
    const fullText = await this.getFullText();

    // We have full text for this resource, resolve the offsets using the text line endings.
    if (fullText) {
      return [await this.entriesForExportBasedOnFullText(fullText)];
    }

    // Fall back to the per-script operation.
    return this.entriesForExportBasedOnContent();
  }
}

export namespace URLCoverageInfo {
  // TODO(crbug.com/1167717): Make this a const enum again
  // eslint-disable-next-line rulesdir/const_enum
  export enum Events {
    SizesChanged = 'SizesChanged',
  }

  export type EventTypes = {
    [Events.SizesChanged]: void,
  };
}

export const mergeSegments = (segmentsA: CoverageSegment[], segmentsB: CoverageSegment[]): CoverageSegment[] => {
  const result: CoverageSegment[] = [];

  let indexA = 0;
  let indexB = 0;
  while (indexA < segmentsA.length && indexB < segmentsB.length) {
    const a = segmentsA[indexA];
    const b = segmentsB[indexB];
    const count = (a.count || 0) + (b.count || 0);
    const end = Math.min(a.end, b.end);
    const last = result[result.length - 1];
    const stamp = Math.min(a.stamp, b.stamp);
    if (!last || last.count !== count || last.stamp !== stamp) {
      result.push({end: end, count: count, stamp: stamp});
    } else {
      last.end = end;
    }
    if (a.end <= b.end) {
      indexA++;
    }
    if (a.end >= b.end) {
      indexB++;
    }
  }

  for (; indexA < segmentsA.length; indexA++) {
    result.push(segmentsA[indexA]);
  }
  for (; indexB < segmentsB.length; indexB++) {
    result.push(segmentsB[indexB]);
  }
  return result;
};

export class CoverageInfo {
  private contentProvider: TextUtils.ContentProvider.ContentProvider;
  private size: number;
  private usedSize: number;
  private statsByTimestamp: Map<number, number>;
  private lineOffset: number;
  private columnOffset: number;
  private coverageType: CoverageType;
  private segments: CoverageSegment[];

  constructor(
      contentProvider: TextUtils.ContentProvider.ContentProvider, size: number, lineOffset: number,
      columnOffset: number, type: CoverageType) {
    this.contentProvider = contentProvider;
    this.size = size;
    this.usedSize = 0;
    this.statsByTimestamp = new Map();
    this.lineOffset = lineOffset;
    this.columnOffset = columnOffset;
    this.coverageType = type;

    this.segments = [];
  }

  getContentProvider(): TextUtils.ContentProvider.ContentProvider {
    return this.contentProvider;
  }

  url(): Platform.DevToolsPath.UrlString {
    return this.contentProvider.contentURL();
  }

  type(): CoverageType {
    return this.coverageType;
  }

  addCoverageType(type: CoverageType): void {
    this.coverageType |= type;
  }

  getOffsets(): {lineOffset: number, columnOffset: number} {
    return {lineOffset: this.lineOffset, columnOffset: this.columnOffset};
  }

  /**
   * Returns the delta by which usedSize increased.
   */
  mergeCoverage(segments: CoverageSegment[]): number {
    const oldUsedSize = this.usedSize;
    this.segments = mergeSegments(this.segments, segments);
    this.updateStats();
    return this.usedSize - oldUsedSize;
  }

  usedByTimestamp(): Map<number, number> {
    return this.statsByTimestamp;
  }

  getSize(): number {
    return this.size;
  }

  getUsedSize(): number {
    return this.usedSize;
  }

  usageForRange(start: number, end: number): boolean {
    let index = Platform.ArrayUtilities.upperBound(this.segments, start, (position, segment) => position - segment.end);
    for (; index < this.segments.length && this.segments[index].end < end; ++index) {
      if (this.segments[index].count) {
        return true;
      }
    }
    return index < this.segments.length && Boolean(this.segments[index].count);
  }

  private updateStats(): void {
    this.statsByTimestamp = new Map();
    this.usedSize = 0;

    let last = 0;
    for (const segment of this.segments) {
      let previousCount = this.statsByTimestamp.get(segment.stamp);
      if (previousCount === undefined) {
        previousCount = 0;
      }

      if (segment.count) {
        const used = segment.end - last;
        this.usedSize += used;
        this.statsByTimestamp.set(segment.stamp, previousCount + used);
      }
      last = segment.end;
    }
  }

  rangesForExport(offset: number = 0): {start: number, end: number}[] {
    const ranges = [];
    let start = 0;
    for (const segment of this.segments) {
      if (segment.count) {
        const last = ranges.length > 0 ? ranges[ranges.length - 1] : null;
        if (last && last.end === start + offset) {
          // We can extend the last segment.
          last.end = segment.end + offset;
        } else {
          // There was a gap, add a new segment.
          ranges.push({start: start + offset, end: segment.end + offset});
        }
      }
      start = segment.end;
    }
    return ranges;
  }
}
export interface RangeUseCount {
  startOffset: number;
  endOffset: number;
  count: number;
}
export interface CoverageSegment {
  end: number;
  count: number;
  stamp: number;
}
