// WebAssembly pages are 65536 kb
const PAGE_SIZE_BITS = 16;
const PAGE_SIZE = 1 << PAGE_SIZE_BITS;
const PAGE_MASK = PAGE_SIZE - 1;

// Wasm32 pointer size is 4 bytes
const PTR_SIZE_BITS = 2;
const PTR_SIZE = 1 << PTR_SIZE_BITS;
const PTR_MASK = PTR_SIZE - 1;
const PTR_VIEW = Uint32Array;

export const BLOCK_OVERHEAD = PTR_SIZE;
export const OBJECT_OVERHEAD = 16;
export const TOTAL_OVERHEAD = BLOCK_OVERHEAD + OBJECT_OVERHEAD;

function assert(x) {
  if (!x) throw Error("assertion failed");
  return x;
}

Error.stackTraceLimit = 15;

function trimStacktrace(stack, levels) {
  return stack.split(/\r?\n/).slice(1 + levels);
}

const hrtime = typeof performance !== "undefined" && performance.now
  ? performance.now
  : typeof process !== "undefined" && process.hrtime
    ? () => { let t = process.hrtime(); return t[0] * 1e3 + t[1] / 1e6; }
    : Date.now;

const mmTagsToString = [
  "",
  "FREE",
  "LEFTFREE",
  "FREE+LEFTFREE"
];

const gcColorToString = [
  "BLACK/WHITE",
  "WHITE/BLACK",
  "GRAY",
  "INVALID"
];

export class Rtrace {

  constructor(options) {
    this.options = options || {};
    this.onerror = this.options.onerror || function() { /* nop */ };
    this.oninfo = this.options.oninfo || function() { /* nop */ };
    this.oncollect_ = this.options.oncollect || function() { /* nop */ };
    this.memory = null;
    this.shadow = null;
    this.shadowStart = 0x100000000;
    this.blocks = new Map();
    this.allocSites = new Map();
    this.freedBlocks = new Map();
    this.gcProfileStart = 0;
    this.gcProfile = [];

    this.allocCount = 0;
    this.resizeCount = 0;
    this.moveCount = 0;
    this.freeCount = 0;
    this.heapBase = 0x100000000;
  }

  install(imports) {
    if (!imports) imports = {};
    imports.rtrace = Object.assign(imports.rtrace || {}, {
      oninit: this.oninit.bind(this),
      onalloc: this.onalloc.bind(this),
      onresize: this.onresize.bind(this),
      onmove: this.onmove.bind(this),
      onvisit: this.onvisit.bind(this),
      onfree: this.onfree.bind(this),
      oninterrupt: this.oninterrupt.bind(this),
      onyield: this.onyield.bind(this),
      oncollect: this.oncollect.bind(this),
      onstore: this.onstore.bind(this),
      onload: this.onload.bind(this)
    });
    return imports;
  }

  /** Synchronizes the shadow memory with the module's memory. */
  syncShadow() {
    if (!this.memory) {
      this.memory = assert(this.options.getMemory());
      this.shadow = new WebAssembly.Memory({
        initial: ((this.memory.buffer.byteLength + PAGE_MASK) & ~PAGE_MASK) >>> PAGE_SIZE_BITS
      });
    } else {
      let diff = this.memory.buffer.byteLength - this.shadow.buffer.byteLength;
      if (diff > 0) this.shadow.grow(diff >>> 16);
    }
  }

  /** Marks a block's presence in shadow memory. */
  markShadow(info, oldSize = 0) {
    assert(this.shadow && this.shadow.byteLength == this.memory.byteLength);
    assert((info.size & PTR_MASK) == 0);
    if (info.ptr < this.shadowStart) {
      this.shadowStart = info.ptr;
    }
    let len = info.size >>> PTR_SIZE_BITS;
    let view = new PTR_VIEW(this.shadow.buffer, info.ptr, len);
    let errored = false;
    let start = oldSize >>> PTR_SIZE_BITS;
    for (let i = 0; i < start; ++i) {
      if (view[i] != info.ptr && !errored) {
        this.onerror(Error("shadow region mismatch: " + view[i] + " != " + info.ptr), info);
        errored = true;
      }
    }
    errored = false;
    for (let i = start; i < len; ++i) {
      if (view[i] != 0 && !errored) {
        this.onerror(Error("shadow region already in use: " + view[i] + " != 0"), info);
        errored = true;
      }
      view[i] = info.ptr;
    }
  }

  /** Unmarks a block's presence in shadow memory. */
  unmarkShadow(info, oldSize = info.size) {
    assert(this.shadow && this.shadow.byteLength == this.memory.byteLength);
    let len = oldSize >>> PTR_SIZE_BITS;
    let view = new PTR_VIEW(this.shadow.buffer, info.ptr, len);
    let errored = false;
    let start = 0;
    if (oldSize != info.size) {
      assert(oldSize > info.size);
      start = info.size >>> PTR_SIZE_BITS;
    }
    for (let i = 0; i < len; ++i) {
      if (view[i] != info.ptr && !errored) {
        this.onerror(Error("shadow region mismatch: " + view[i] + " != " + info.ptr), info);
        errored = true;
      }
      if (i >= start) view[i] = 0;
    }
  }

  /** Performs an access to shadow memory. */
  accessShadow(ptr, size, isLoad, isRT) {
    this.syncShadow();
    if (ptr < this.shadowStart) return;
    let value = new Uint32Array(this.shadow.buffer, ptr & ~PTR_MASK, 1)[0];
    if (value != 0) return;
    if (!isRT) {
      let stack = trimStacktrace(new Error().stack, 2);
      this.onerror(new Error("OOB " + (isLoad ? "load" : "store") + (8 * size) + " at address " + ptr + "\n" + stack.join("\n")));
    }
  }

  /** Obtains information about a block. */
  getBlockInfo(ptr) {
    const [
      mmInfo,
      gcInfo,
      gcInfo2,
      rtId,
      rtSize
    ] = new Uint32Array(this.memory.buffer, ptr, 5);
    const size = mmInfo & ~3;
    return {
      ptr,
      size: BLOCK_OVERHEAD + size, // total incl. overhead
      mmInfo: {
        tags: mmTagsToString[mmInfo & 3],
        size: size // as stored excl. overhead
      },
      gcInfo: {
        color: gcColorToString[gcInfo & 3],
        next: gcInfo & ~3,
        prev: gcInfo2
      },
      rtId,
      rtSize
    };
  }

  /** Checks if rtrace is active, i.e. at least one event has occurred. */
  get active() {
    return Boolean(this.allocCount || this.resizeCount || this.moveCount || this.freeCount);
  }

  /** Checks if there are any leaks and emits them via `oninfo`. Returns the number of live blocks. */
  check() {
    if (this.oninfo) {
      for (let [ptr, info] of this.blocks) {
        this.oninfo("LIVE " + ptr + "\n" + info.allocStack.join("\n"));
      }
    }
    return this.blocks.size;
  }

  // Runtime instrumentation

  oninit(heapBase) {
    this.heapBase = heapBase;
    this.gcProfileStart = 0;
    this.gcProfile.length = 0;
    this.oninfo("INIT heapBase=" + heapBase);
  }

  onalloc(ptr) {
    this.syncShadow();
    ++this.allocCount;
    let info = this.getBlockInfo(ptr);
    if (this.blocks.has(ptr)) {
      this.onerror(Error("duplicate alloc: " + ptr), info);
    } else {
      this.oninfo("ALLOC " + ptr + ".." + (ptr + info.size));
      this.markShadow(info);
      let allocStack = trimStacktrace(new Error().stack, 1); // strip onalloc
      this.blocks.set(ptr, Object.assign(info, { allocStack }));
    }
  }
  
  onresize(ptr, oldSize) {
    this.syncShadow();
    ++this.resizeCount;
    const info = this.getBlockInfo(ptr);
    if (!this.blocks.has(ptr)) {
      this.onerror(Error("orphaned resize: " + ptr), info);
    } else {
      const beforeInfo = this.blocks.get(ptr);
      if (beforeInfo.size != oldSize) {
        this.onerror(Error(`size mismatch upon resize: ${ptr} (${beforeInfo.size} != ${oldSize})`), info);
      }
      const newSize = info.size;
      this.oninfo("RESIZE " + ptr + ".." + (ptr + newSize) + " (" + oldSize + "->" + newSize + ")");
      this.blocks.set(ptr, Object.assign(info, { allocStack: beforeInfo.allocStack }));
      if (newSize > oldSize) {
        this.markShadow(info, oldSize);
      } else if (newSize < oldSize) {
        this.unmarkShadow(info, oldSize);
      }
    }
  }

  onmove(oldPtr, newPtr) {
    this.syncShadow();
    ++this.moveCount;
    let oldInfo = this.getBlockInfo(oldPtr);
    let newInfo = this.getBlockInfo(newPtr);
    if (!this.blocks.has(oldPtr)) {
      this.onerror(Error("orphaned move (old): " + oldPtr), oldInfo);
    } else {
      if (!this.blocks.has(newPtr)) {
        this.onerror(Error("orphaned move (new): " + newPtr), newInfo);
      } else {
        const beforeInfo = this.blocks.get(oldPtr);
        const oldSize = oldInfo.size;
        const newSize = newInfo.size;
        if (beforeInfo.size != oldSize) {
          this.onerror(Error(`size mismatch upon move: ${oldPtr} (${beforeInfo.size} != ${oldSize})`), oldInfo);
        }
        this.oninfo("MOVE " + oldPtr + ".." + (oldPtr + oldSize) + " -> " + newPtr + ".." + (newPtr + newSize));
        // calls new alloc before and old free after
      }
    }
  }

  onvisit(ptr) {
    // Indicates that a block has been freed but it still visited by the GC
    if (ptr > this.heapBase && !this.blocks.has(ptr)) {
      let err = Error("orphaned visit: " + ptr);
      let info = this.freedBlocks.get(ptr);
      if (info) {
        err.stack += "\n^ allocated at:\n" + info.allocStack.join("\n");
        err.stack += "\n^ freed at:\n" + info.freeStack.join("\n");
      }
      this.onerror(err, null);
      return false;
    }
    return true;
  }

  onfree(ptr) {
    this.syncShadow();
    ++this.freeCount;
    let info = this.getBlockInfo(ptr);
    if (!this.blocks.has(ptr)) {
      this.onerror(Error("orphaned free: " + ptr), info);
    } else {
      const oldInfo = this.blocks.get(ptr);
      if (info.size != oldInfo.size) {
        this.onerror(Error(`size mismatch upon free: ${ptr} (${oldInfo.size} != ${info.size})`), info);
      }
      this.oninfo("FREE " + ptr + ".." + (ptr + info.size));
      this.unmarkShadow(info);
      const allocInfo = this.blocks.get(ptr);
      this.blocks.delete(ptr);
      const allocStack = allocInfo.allocStack;
      const freeStack = trimStacktrace(new Error().stack, 1); // strip onfree
      // (not much) TODO: Maintaining these is essentially a memory leak
      this.freedBlocks.set(ptr, { allocStack, freeStack });
    }
  }

  oncollect(total) {
    this.oninfo(`COLLECT at ${total}`);
    this.plot(total);
    this.oncollect_();
  }

  // GC profiling

  plot(total, pause = 0) {
    if (!this.gcProfileStart) this.gcProfileStart = Date.now();
    this.gcProfile.push([ Date.now() - this.gcProfileStart, total, pause ]);
  }

  oninterrupt(total) {
    this.interruptStart = hrtime();
    this.plot(total);
  }

  onyield(total) {
    let pause = hrtime() - this.interruptStart;
    if (pause >= 1) console.log("interrupted for " + pause.toFixed(1) + "ms");
    this.plot(total, pause);
  }

  // Memory instrumentation

  onstore(ptr, offset, bytes, isRT) {
    this.accessShadow(ptr + offset, bytes, false, isRT);
    return ptr;
  }

  onload(ptr, offset, bytes, isRT) {
    this.accessShadow(ptr + offset, bytes, true, isRT);
    return ptr;
  }
}

export default {
  Rtrace
};
