/**
 * An in-memory `RepackedPort` with the same exclusive-ownership, volatile/durable, and fault-injection
 * behaviour as the real OPFS port. It is production source, not test support: the sync broker
 * (`../broker/`) and any engine-agnostic host need a port that works off the main thread and outside a
 * browser, and every store-level test in the repo already diffs real behaviour against it.
 */
import { StoreOwnedError } from "./errors";
import { OWNED_FILE_NAMES } from "./port";
import type { OwnedFileName, RepackedFileHandle, RepackedPort, RepackedPortEntry } from "./port";

export type MemoryOperation = "enumerate" | "acquire" | "getSize" | "read" | "write" | "truncate" | "flush";
type FaultOutcome = "short" | "throw-before" | "throw-after" | "quota";

export interface MemoryFault {
  operation: MemoryOperation;
  outcome: FaultOutcome;
  file?: string;
  label?: string;
  bytes?: number;
  occurrence?: number;
}

interface WriteEffect {
  id: number;
  file: OwnedFileName;
  kind: "write";
  at: number;
  data: Uint8Array;
}

interface TruncateEffect {
  id: number;
  file: OwnedFileName;
  kind: "truncate";
  size: number;
}

type MemoryEffect = WriteEffect | TruncateEffect;
export type TerminationDecision = "absent" | "full" | number;

export interface MemoryEffectSummary {
  id: number;
  file: OwnedFileName;
  operation: "write" | "truncate";
  bytes: number;
}

export interface MemoryOperationSummary {
  readonly operation: MemoryOperation;
  readonly file: OwnedFileName | undefined;
  readonly label: string;
  /**
   * Bytes this operation copied into the DURABLE image. Recorded on `flush` only, where it is the
   * deterministic stand-in for flush cost: a flush must copy the bytes written since the previous
   * flush, never the size of the file. Absent on every other operation.
   */
  readonly copiedBytes?: number;
}

/** The private, still-writable form of a record: `handleFlush` fills `copiedBytes` in after the copy. */
interface MemoryOperationRecord {
  readonly operation: MemoryOperation;
  readonly file: OwnedFileName | undefined;
  readonly label: string;
  copiedBytes?: number;
}

function clone(bytes: Uint8Array): Uint8Array {
  return bytes.slice();
}

/**
 * Replay a write onto a MATERIALIZED copy, for `terminate()`. Pure, and deliberately so: the
 * termination model rebuilds a fresh image from the durable bytes plus the effect log, and nothing in
 * it may alias the live volatile buffer.
 */
function applyWrite(current: Uint8Array, at: number, source: Uint8Array): Uint8Array {
  const required = at + source.byteLength;
  const next = required <= current.byteLength ? clone(current) : new Uint8Array(required);
  if (next.byteLength > current.byteLength) next.set(current);
  next.set(source, at);
  return next;
}

/** The `terminate()` twin of `applyWrite`, and pure for the same reason. */
function applyTruncate(current: Uint8Array, size: number): Uint8Array {
  if (size === current.byteLength) return clone(current);
  const next = new Uint8Array(size);
  next.set(current.subarray(0, Math.min(size, current.byteLength)));
  return next;
}

/**
 * Resize a LIVE image — the volatile file or the durable one — keeping the bytes it already holds and
 * reusing spare capacity.
 *
 * A live image is always `new Uint8Array(buffer, 0, length)`, so its backing buffer may be larger
 * than the file and `bytes.buffer.byteLength` is its capacity. That separation is what keeps this port
 * usable at real store sizes: replacing the whole array on every write and every truncate — which is
 * what the pure `apply*` helpers above do — makes each operation cost the size of the FILE rather than
 * the size of the operation, so an in-memory store grows QUADRATICALLY in the bytes written. Seeding a
 * 41 MB Postgres datadir (1,477 files, ~3,000 arena writes and growth truncates) took 98 seconds that
 * way and copied roughly 80 GB. Nothing needed those copies: the effect log holds the CALLER's source
 * bytes rather than the file's, `durableBytes()` clones on the way out, and the two images never share
 * a buffer — so both are safe to mutate in place, and the same seed takes about a second.
 *
 * Growth doubles, and any newly exposed region is zeroed: capacity left behind by an earlier truncate
 * still holds that truncated file's bytes, and a hole must read as zeros.
 */
function resizeImage(current: Uint8Array, size: number): Uint8Array {
  if (size <= current.byteLength) return new Uint8Array(current.buffer, 0, size);
  if (size <= current.buffer.byteLength) {
    const grown = new Uint8Array(current.buffer, 0, size);
    grown.fill(0, current.byteLength, size);
    return grown;
  }
  const capacity = Math.max(size, current.buffer.byteLength * 2, 64);
  const grown = new Uint8Array(new ArrayBuffer(capacity), 0, size);
  grown.set(current);
  return grown;
}

function isOwnedFileName(name: string): name is OwnedFileName {
  return (OWNED_FILE_NAMES as readonly string[]).includes(name);
}

class MemoryHandle implements RepackedFileHandle {
  readonly name: OwnedFileName;
  readonly #port: MemoryRepackedPort;
  readonly #epoch: number;
  #closed = false;

  constructor(port: MemoryRepackedPort, name: OwnedFileName, epoch: number) {
    this.#port = port;
    this.name = name;
    this.#epoch = epoch;
  }

  getSize(label: string): number {
    this.#assertOpen();
    return this.#port.handleGetSize(this.name, label);
  }

  read(target: Uint8Array, at: number, label: string): number {
    this.#assertOpen();
    return this.#port.handleRead(this.name, target, at, label);
  }

  write(source: Uint8Array, at: number, label: string): number {
    this.#assertOpen();
    return this.#port.handleWrite(this.name, source, at, label);
  }

  truncate(size: number, label: string): void {
    this.#assertOpen();
    this.#port.handleTruncate(this.name, size, label);
  }

  flush(label: string): void {
    this.#assertOpen();
    this.#port.handleFlush(this.name, label);
  }

  close(): void {
    if (this.#closed) return;
    this.#closed = true;
    if (this.#epoch === this.#port.epoch) this.#port.handleClose(this.name);
  }

  #assertOpen(): void {
    if (this.#closed || this.#epoch !== this.#port.epoch) {
      throw new Error(`memory handle ${this.name} is closed`);
    }
  }
}

export class MemoryRepackedPort implements RepackedPort {
  readonly #durable = new Map<OwnedFileName, Uint8Array>();
  readonly #volatile = new Map<OwnedFileName, Uint8Array>();
  readonly #entryKinds = new Map<string, "file" | "directory">();
  readonly #open = new Set<OwnedFileName>();
  readonly #faults: MemoryFault[] = [];
  readonly #operations: MemoryOperationRecord[] = [];
  #effects: MemoryEffect[] = [];
  #nextEffectId = 1;
  #epoch = 1;

  get epoch(): number {
    return this.#epoch;
  }

  async enumerate(label: string): Promise<readonly RepackedPortEntry[]> {
    this.#recordOperation("enumerate", undefined, label);
    const fault = this.#takeFault("enumerate", undefined, label);
    if (fault !== undefined) throw new Error(`injected enumerate failure ${fault.outcome}`);
    return [...this.#entryKinds]
      .sort(([left], [right]) => left.localeCompare(right))
      .map(([name, kind]) => ({
        name,
        kind,
      }));
  }

  async acquire(name: OwnedFileName, label: string): Promise<RepackedFileHandle> {
    this.#recordOperation("acquire", name, label);
    const fault = this.#takeFault("acquire", name, label);
    if (fault?.outcome === "throw-before") throw new Error("injected acquire failure before effect");
    if (this.#open.has(name)) throw new StoreOwnedError();
    if (this.#entryKinds.get(name) === "directory") throw new Error(`memory entry ${name} is not a file`);
    if (!this.#volatile.has(name)) {
      this.#volatile.set(name, new Uint8Array());
      this.#durable.set(name, new Uint8Array());
      this.#entryKinds.set(name, "file");
    }
    if (fault !== undefined) throw new Error(`injected acquire failure ${fault.outcome}`);
    this.#open.add(name);
    return new MemoryHandle(this, name, this.#epoch);
  }

  injectEntry(name: string, kind: "file" | "directory"): void {
    if (isOwnedFileName(name) && this.#open.has(name)) {
      throw new Error(`cannot replace acquired memory entry ${name}`);
    }
    this.#entryKinds.set(name, kind);
    if (isOwnedFileName(name)) {
      // Replacing an owned entry replaces BOTH images, so anything still pending for it describes a
      // file that no longer exists; keeping it would let a later flush or termination replay it onto
      // the replacement and break `durable + pending effects == volatile`.
      this.#effects = this.#effects.filter((effect) => effect.file !== name);
      if (kind === "file") {
        this.#volatile.set(name, new Uint8Array());
        this.#durable.set(name, new Uint8Array());
      } else {
        this.#volatile.delete(name);
        this.#durable.delete(name);
      }
    }
  }

  injectFault(fault: MemoryFault): void {
    if (fault.outcome === "quota" && fault.operation !== "truncate") {
      throw new TypeError("quota faults are supported only for arena growth truncates");
    }
    if (fault.bytes !== undefined && (!Number.isSafeInteger(fault.bytes) || fault.bytes < 0)) {
      throw new TypeError("fault byte count must be a non-negative safe integer");
    }
    if (fault.occurrence !== undefined && (!Number.isSafeInteger(fault.occurrence) || fault.occurrence < 0)) {
      throw new TypeError("fault occurrence must be a non-negative safe integer");
    }
    this.#faults.push({ ...fault });
  }

  pendingEffects(): readonly MemoryEffectSummary[] {
    return this.#effects.map((effect) => ({
      id: effect.id,
      file: effect.file,
      operation: effect.kind,
      bytes: effect.kind === "write" ? effect.data.byteLength : effect.size,
    }));
  }

  openHandleCount(): number {
    return this.#open.size;
  }

  observedOperations(): readonly MemoryOperationSummary[] {
    return Object.freeze(this.#operations.map((operation) => Object.freeze({ ...operation })));
  }

  clearObservedOperations(): void {
    this.#operations.length = 0;
  }

  durableBytes(name: OwnedFileName): Uint8Array {
    return clone(this.#durable.get(name) ?? new Uint8Array());
  }

  terminate(decisions: Readonly<Record<number, TerminationDecision>> = {}): void {
    const materialized = new Map<OwnedFileName, Uint8Array>();
    for (const [name, bytes] of this.#durable) materialized.set(name, clone(bytes));
    for (const effect of this.#effects) {
      const decision = decisions[effect.id] ?? "absent";
      if (decision === "absent") continue;
      const current = materialized.get(effect.file) ?? new Uint8Array();
      if (effect.kind === "truncate") {
        if (decision !== "full") {
          throw new TypeError("truncate termination decisions must be absent or full");
        }
        materialized.set(effect.file, applyTruncate(current, effect.size));
      } else {
        const bytes = decision === "full" ? effect.data.byteLength : decision;
        if (!Number.isSafeInteger(bytes) || bytes < 0 || bytes > effect.data.byteLength) {
          throw new TypeError("write termination prefix is outside the effect");
        }
        if (bytes > 0) materialized.set(effect.file, applyWrite(current, effect.at, effect.data.subarray(0, bytes)));
      }
    }
    this.#durable.clear();
    this.#volatile.clear();
    for (const [name, bytes] of materialized) {
      this.#durable.set(name, clone(bytes));
      this.#volatile.set(name, clone(bytes));
    }
    this.#effects = [];
    this.#faults.length = 0;
    this.#open.clear();
    this.#epoch += 1;
  }

  handleGetSize(name: OwnedFileName, label: string): number {
    this.#recordOperation("getSize", name, label);
    const fault = this.#takeFault("getSize", name, label);
    if (fault !== undefined) throw new Error(`injected getSize failure ${fault.outcome}`);
    return this.#file(name).byteLength;
  }

  handleRead(name: OwnedFileName, target: Uint8Array, at: number, label: string): number {
    this.#validateRange(at, target.byteLength);
    this.#recordOperation("read", name, label);
    const fault = this.#takeFault("read", name, label);
    if (fault?.outcome === "throw-before" || fault?.outcome === "throw-after") {
      throw new Error(`injected read failure ${fault.outcome === "throw-before" ? "before" : "after"} effect`);
    }
    const source = this.#file(name);
    const available = Math.max(0, Math.min(target.byteLength, source.byteLength - at));
    const count = fault?.outcome === "short" ? Math.min(available, fault.bytes ?? 0) : available;
    target.set(source.subarray(at, at + count));
    return count;
  }

  handleWrite(name: OwnedFileName, source: Uint8Array, at: number, label: string): number {
    this.#validateRange(at, source.byteLength);
    this.#recordOperation("write", name, label);
    const fault = this.#takeFault("write", name, label);
    if (fault?.outcome === "throw-before") throw new Error("injected write failure before effect");
    const count =
      fault?.outcome === "short" || fault?.outcome === "throw-after"
        ? Math.min(source.byteLength, fault.bytes ?? source.byteLength)
        : source.byteLength;
    if (count > 0) this.#recordWrite(name, at, source.subarray(0, count));
    if (fault?.outcome === "throw-after") throw new Error("injected write failure after effect");
    return count;
  }

  handleTruncate(name: OwnedFileName, size: number, label: string): void {
    this.#validateRange(size, 0);
    this.#recordOperation("truncate", name, label);
    const fault = this.#takeFault("truncate", name, label);
    if (fault?.outcome === "quota") throw new DOMException("injected arena quota exhaustion", "QuotaExceededError");
    if (fault?.outcome === "throw-before") throw new Error("injected truncate failure before effect");
    this.#volatile.set(name, resizeImage(this.#file(name), size));
    this.#effects.push({ id: this.#nextEffectId++, file: name, kind: "truncate", size });
    if (fault?.outcome === "throw-after") throw new Error("injected truncate failure after effect");
  }

  handleFlush(name: OwnedFileName, label: string): void {
    const record = this.#recordOperation("flush", name, label);
    record.copiedBytes = 0;
    const fault = this.#takeFault("flush", name, label);
    // A throw-before flush made nothing durable and left every effect pending, so it copies nothing.
    if (fault?.outcome === "throw-before") throw new Error("injected flush failure before effect");
    record.copiedBytes = this.#makeDurable(name);
    if (fault?.outcome === "throw-after") throw new Error("injected flush failure after effect");
  }

  handleClose(name: OwnedFileName): void {
    this.#open.delete(name);
  }

  #file(name: OwnedFileName): Uint8Array {
    const bytes = this.#volatile.get(name);
    if (bytes === undefined) throw new Error(`memory file ${name} was not acquired`);
    return bytes;
  }

  #recordWrite(name: OwnedFileName, at: number, source: Uint8Array): void {
    // The effect log owns its own copy: it is replayed against a MATERIALIZED image in `terminate()`,
    // long after the live buffer moved on.
    const data = clone(source);
    const current = this.#file(name);
    const required = at + data.byteLength;
    const target = required > current.byteLength ? resizeImage(current, required) : current;
    target.set(data, at);
    this.#volatile.set(name, target);
    this.#effects.push({ id: this.#nextEffectId++, file: name, kind: "write", at, data });
  }

  /**
   * Apply this file's pending effects onto the durable image, in order, and drop them. Returns the
   * bytes copied.
   *
   * The old implementation made a file durable by cloning the whole volatile image, so every flush
   * cost the size of the FILE. That is what the broker charges to every committing statement: a guest
   * `fd_sync` becomes a store-wide `strictSync()`, and on a seeded Postgres datadir the arena is tens
   * of MB, which showed up as a flat ~25 ms per writing statement in the browser benchmark. The effect
   * log already IS the dirty set — `durable + pending effects == volatile` for every file, at all
   * times — so replaying it costs the bytes written since the previous flush instead, and reproduces
   * the volatile image byte for byte because both sides take the same steps through `resizeImage`.
   */
  #makeDurable(name: OwnedFileName): number {
    let image = this.#durable.get(name) ?? new Uint8Array();
    const remaining: MemoryEffect[] = [];
    let copied = 0;
    for (const effect of this.#effects) {
      if (effect.file !== name) {
        remaining.push(effect);
        continue;
      }
      if (effect.kind === "truncate") {
        const resized = resizeImage(image, effect.size);
        // Growth past the current capacity carries the bytes already held into a new buffer; every
        // other resize is a view onto the same one. A truncate copies nothing of its own.
        if (resized.buffer !== image.buffer) copied += image.byteLength;
        image = resized;
        continue;
      }
      const required = effect.at + effect.data.byteLength;
      if (required > image.byteLength) {
        const grown = resizeImage(image, required);
        if (grown.buffer !== image.buffer) copied += image.byteLength;
        image = grown;
      }
      image.set(effect.data, effect.at);
      copied += effect.data.byteLength;
    }
    this.#durable.set(name, image);
    this.#effects = remaining;
    return copied;
  }

  #takeFault(operation: MemoryOperation, file: string | undefined, label: string): MemoryFault | undefined {
    const index = this.#faults.findIndex(
      (fault) =>
        fault.operation === operation &&
        (fault.file === undefined || fault.file === file) &&
        (fault.label === undefined || fault.label === label),
    );
    if (index < 0) return undefined;
    const fault = this.#faults[index]!;
    if ((fault.occurrence ?? 0) > 0) {
      this.#faults[index] = { ...fault, occurrence: fault.occurrence! - 1 };
      return undefined;
    }
    return this.#faults.splice(index, 1)[0];
  }

  #recordOperation(operation: MemoryOperation, file: OwnedFileName | undefined, label: string): MemoryOperationRecord {
    const record: MemoryOperationRecord = { operation, file, label };
    this.#operations.push(record);
    return record;
  }

  #validateRange(at: number, length: number): void {
    if (!Number.isSafeInteger(at) || !Number.isSafeInteger(length) || at < 0 || length < 0) {
      throw new RangeError("memory port range is invalid");
    }
    if (!Number.isSafeInteger(at + length)) throw new RangeError("memory port range exceeds safe integers");
  }
}
