import { crc32, crc32WithZeroedRange } from "./checksum";
import { CorruptStoreError, ExtentSizeMismatchError, StoreLimitError, StoreRecreationRequiredError } from "./errors";
import {
  ARENA_HEADER_BYTES,
  FORMAT_VERSION,
  LIMITS_PROFILE_VERSION,
  MAX_COMPONENT_BYTES,
  MAX_EXTENTS_PER_INODE,
  MAX_FRAME_PAYLOAD_BYTES,
  MAX_INODES,
  MAX_METADATA_BASE_READER_BYTES,
  MAX_METADATA_BASE_WRITER_BYTES,
  MAX_PATH_BYTES,
  MAX_TOTAL_EXTENTS,
  checkedU64,
  validateExtentSize,
} from "./limits";
import { validatePathComponent, validateSymlinkTarget } from "./path";
import { IndexedExtentSet, estimateTxnPayloadBytes, validateState } from "./state-machine";
import type {
  DirectoryInode,
  ExtentRun,
  FileInode,
  Inode,
  MetadataFile,
  SymlinkInode,
  TxnRecord,
  VfsState,
} from "./state-machine";

const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder("utf-8", { fatal: true });

const ARENA_MAGIC = textEncoder.encode("PGXRPA01");
const METADATA_MAGIC = textEncoder.encode("PGXRPM01");
const FRAME_MAGIC = textEncoder.encode("PGXRPF01");
const ACTIVATION_MAGIC = textEncoder.encode("PGXRPK01");

export const ARENA_HEADER_CHECKSUM_OFFSET = 24;
export const METADATA_HEADER_BYTES = 80;
export const TXN_FRAME_HEADER_BYTES = 48;
export const ACTIVATION_SLOT_BYTES = 128;

export const METADATA_HEADER_CHECKSUM_OFFSET = 64;
export const FRAME_CHECKSUM_OFFSET = 40;
export const ACTIVATION_CHECKSUM_OFFSET = 52;

class BinaryWriter {
  readonly bytes: Uint8Array;
  readonly #view: DataView;
  #offset = 0;

  constructor(length: number, initialOffset = 0) {
    if (
      !Number.isSafeInteger(length) ||
      length < 0 ||
      !Number.isSafeInteger(initialOffset) ||
      initialOffset < 0 ||
      initialOffset > length
    ) {
      throw new StoreLimitError("binary output length is invalid");
    }
    this.bytes = new Uint8Array(length);
    this.#view = new DataView(this.bytes.buffer);
    this.#offset = initialOffset;
  }

  u8(value: number): void {
    this.#require(1);
    if (!Number.isSafeInteger(value) || value < 0 || value > 0xff) {
      throw new StoreLimitError("unsigned 8-bit value is out of range");
    }
    this.#view.setUint8(this.#offset, value);
    this.#offset += 1;
  }

  u16(value: number): void {
    this.#require(2);
    if (!Number.isSafeInteger(value) || value < 0 || value > 0xffff) {
      throw new StoreLimitError("unsigned 16-bit value is out of range");
    }
    this.#view.setUint16(this.#offset, value, true);
    this.#offset += 2;
  }

  u32(value: number): void {
    this.#require(4);
    if (!Number.isSafeInteger(value) || value < 0 || value > 0xffff_ffff) {
      throw new StoreLimitError("unsigned 32-bit value is out of range");
    }
    this.#view.setUint32(this.#offset, value, true);
    this.#offset += 4;
  }

  u64(value: bigint): void {
    this.#require(8);
    this.#view.setBigUint64(this.#offset, checkedU64(value, "encoded integer"), true);
    this.#offset += 8;
  }

  validatedU64(value: bigint): void {
    this.#require(8);
    this.#view.setBigUint64(this.#offset, value, true);
    this.#offset += 8;
  }

  raw(value: Uint8Array): void {
    this.#require(value.byteLength);
    this.bytes.set(value, this.#offset);
    this.#offset += value.byteLength;
  }

  string(value: string): void {
    validatePathComponent(value);
    this.validatedString(textEncoder.encode(value));
  }

  /** A symbolic link's target: a whole PATH, so it is bounded by `MAX_PATH_BYTES`, not by a name. */
  symlinkTarget(value: string): void {
    validateSymlinkTarget(value);
    this.validatedString(textEncoder.encode(value));
  }

  validatedString(encoded: Uint8Array): void {
    this.u16(encoded.byteLength);
    this.raw(encoded);
  }

  finish(): Uint8Array {
    if (this.#offset !== this.bytes.byteLength) {
      throw new StoreLimitError(
        `binary encoder wrote ${this.#offset} bytes into a ${this.bytes.byteLength}-byte output`,
      );
    }
    return this.bytes;
  }

  #require(length: number): void {
    if (this.#offset + length > this.bytes.byteLength) {
      throw new StoreLimitError("binary encoder exceeded its preflighted size");
    }
  }
}

class BinaryReader {
  readonly #bytes: Uint8Array;
  readonly #view: DataView;
  #offset = 0;

  constructor(bytes: Uint8Array) {
    this.#bytes = bytes;
    this.#view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
  }

  get remaining(): number {
    return this.#bytes.byteLength - this.#offset;
  }

  u8(): number {
    this.#require(1);
    const value = this.#view.getUint8(this.#offset);
    this.#offset += 1;
    return value;
  }

  u16(): number {
    this.#require(2);
    const value = this.#view.getUint16(this.#offset, true);
    this.#offset += 2;
    return value;
  }

  u32(): number {
    this.#require(4);
    const value = this.#view.getUint32(this.#offset, true);
    this.#offset += 4;
    return value;
  }

  u64(): bigint {
    this.#require(8);
    const value = this.#view.getBigUint64(this.#offset, true);
    this.#offset += 8;
    return value;
  }

  raw(length: number): Uint8Array {
    this.#require(length);
    const value = this.#bytes.subarray(this.#offset, this.#offset + length);
    this.#offset += length;
    return value;
  }

  string(): string {
    const length = this.u16();
    if (length === 0 || length > MAX_COMPONENT_BYTES) {
      throw new CorruptStoreError("encoded path component length is invalid");
    }
    let value: string;
    try {
      value = textDecoder.decode(this.raw(length));
    } catch (cause) {
      throw new CorruptStoreError("encoded path component is not valid UTF-8", { cause });
    }
    if (textEncoder.encode(value).byteLength !== length) {
      throw new CorruptStoreError("encoded path component is not canonical UTF-8");
    }
    try {
      validatePathComponent(value);
    } catch (cause) {
      throw new CorruptStoreError(`encoded path component is invalid: ${String(cause)}`, { cause });
    }
    return value;
  }

  /** The reader half of {@link BinaryWriter.symlinkTarget}. */
  symlinkTarget(): string {
    const length = this.u16();
    if (length === 0 || length > MAX_PATH_BYTES) {
      throw new CorruptStoreError("encoded symlink target length is invalid");
    }
    let value: string;
    try {
      value = textDecoder.decode(this.raw(length));
    } catch (cause) {
      throw new CorruptStoreError("encoded symlink target is not valid UTF-8", { cause });
    }
    if (textEncoder.encode(value).byteLength !== length) {
      throw new CorruptStoreError("encoded symlink target is not canonical UTF-8");
    }
    try {
      validateSymlinkTarget(value);
    } catch (cause) {
      throw new CorruptStoreError(`encoded symlink target is invalid: ${String(cause)}`, { cause });
    }
    return value;
  }

  end(): void {
    if (this.remaining !== 0) {
      throw new CorruptStoreError("binary value has trailing bytes");
    }
  }

  #require(length: number): void {
    if (!Number.isSafeInteger(length) || length < 0 || length > this.remaining) {
      throw new CorruptStoreError("binary value is truncated");
    }
  }
}

function bytesEqual(left: Uint8Array, right: Uint8Array): boolean {
  if (left.byteLength !== right.byteLength) {
    return false;
  }
  for (let index = 0; index < left.byteLength; index += 1) {
    if (left[index] !== right[index]) {
      return false;
    }
  }
  return true;
}

function requireMagic(bytes: Uint8Array, magic: Uint8Array, label: string): void {
  if (!bytesEqual(bytes.subarray(0, magic.byteLength), magic)) {
    throw new CorruptStoreError(`${label} magic is invalid`);
  }
}

function requireZero(bytes: Uint8Array, start: number, end: number, label: string): void {
  for (let offset = start; offset < end; offset += 1) {
    if (bytes[offset] !== 0) {
      throw new CorruptStoreError(`${label} padding is not canonical`);
    }
  }
}

function requireChecksum(bytes: Uint8Array, offset: number, label: string): void {
  const stored = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(offset, true);
  const calculated = crc32WithZeroedRange(bytes, offset, 4);
  if (stored !== calculated) {
    throw new CorruptStoreError(`${label} checksum is invalid`);
  }
}

function requireCurrentIdentity(version: number, profile: number): void {
  if (version !== FORMAT_VERSION || profile !== LIMITS_PROFILE_VERSION) {
    throw new StoreRecreationRequiredError(
      `store format ${version}/limits profile ${profile} is unsupported; delete the store and create it fresh`,
    );
  }
}

export interface ArenaHeader {
  extentSize: number;
  limitsProfileVersion: number;
  formatVersion: number;
}

export function encodeArenaHeader(options: { extentSize: number }): Uint8Array {
  const extentSize = validateExtentSize(options.extentSize);
  const bytes = new Uint8Array(ARENA_HEADER_BYTES);
  const view = new DataView(bytes.buffer);
  bytes.set(ARENA_MAGIC, 0);
  view.setUint32(8, FORMAT_VERSION, true);
  view.setUint32(12, LIMITS_PROFILE_VERSION, true);
  view.setUint32(16, extentSize, true);
  view.setUint32(ARENA_HEADER_CHECKSUM_OFFSET, crc32WithZeroedRange(bytes, ARENA_HEADER_CHECKSUM_OFFSET, 4), true);
  return bytes;
}

export function decodeArenaHeader(bytes: Uint8Array, configuredExtentSize?: number): ArenaHeader {
  if (configuredExtentSize !== undefined) {
    validateExtentSize(configuredExtentSize);
  }
  if (bytes.byteLength !== ARENA_HEADER_BYTES) {
    throw new CorruptStoreError("arena header has the wrong length");
  }
  requireMagic(bytes, ARENA_MAGIC, "arena header");
  requireZero(bytes, 20, 24, "arena header");
  requireZero(bytes, 28, bytes.byteLength, "arena header");
  requireChecksum(bytes, ARENA_HEADER_CHECKSUM_OFFSET, "arena header");
  const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
  const formatVersion = view.getUint32(8, true);
  const limitsProfileVersion = view.getUint32(12, true);
  requireCurrentIdentity(formatVersion, limitsProfileVersion);
  const extentSize = view.getUint32(16, true);
  try {
    validateExtentSize(extentSize);
  } catch (cause) {
    throw new CorruptStoreError("arena header extent size is invalid", { cause });
  }
  if (configuredExtentSize !== undefined && configuredExtentSize !== extentSize) {
    throw new ExtentSizeMismatchError(configuredExtentSize, extentSize);
  }
  return { extentSize, limitsProfileVersion, formatVersion };
}

function metadataFileCode(file: MetadataFile): number {
  return file === "a" ? 0 : 1;
}

function decodeMetadataFile(value: number): MetadataFile {
  if (value === 0) return "a";
  if (value === 1) return "b";
  throw new CorruptStoreError("metadata file selector is invalid");
}

function compareBigint(left: bigint, right: bigint): number {
  return left < right ? -1 : left > right ? 1 : 0;
}

function compareUtf8Names(left: string, right: string): number {
  let leftIndex = 0;
  let rightIndex = 0;
  while (leftIndex < left.length && rightIndex < right.length) {
    const leftPoint = left.codePointAt(leftIndex)!;
    const rightPoint = right.codePointAt(rightIndex)!;
    if (leftPoint !== rightPoint) return leftPoint - rightPoint;
    leftIndex += leftPoint > 0xffff ? 2 : 1;
    rightIndex += rightPoint > 0xffff ? 2 : 1;
  }
  return left.length - right.length;
}

function sortIfNeeded<Value>(values: Value[], compare: (left: Value, right: Value) => number): Value[] {
  for (let index = 1; index < values.length; index += 1) {
    if (compare(values[index - 1]!, values[index]!) > 0) {
      values.sort(compare);
      break;
    }
  }
  return values;
}

const INODE_KIND_DIRECTORY = 0;
const INODE_KIND_FILE = 1;
const INODE_KIND_SYMLINK = 2;

function writeInode(writer: BinaryWriter, inode: Inode): void {
  writer.validatedU64(inode.id);
  writer.u8(
    inode.kind === "directory" ? INODE_KIND_DIRECTORY : inode.kind === "file" ? INODE_KIND_FILE : INODE_KIND_SYMLINK,
  );
  writer.u32(inode.mode);
  writer.validatedU64(inode.atimeMs);
  writer.validatedU64(inode.mtimeMs);
  writer.validatedU64(inode.ctimeMs);
  if (inode.kind === "directory") {
    const children = sortIfNeeded([...inode.children], ([left], [right]) => compareUtf8Names(left, right));
    writer.u32(children.length);
    for (const [name, inodeId] of children) {
      writer.validatedString(textEncoder.encode(name));
      writer.validatedU64(inodeId);
    }
  } else if (inode.kind === "symlink") {
    writer.symlinkTarget(inode.target);
  } else {
    writer.validatedU64(inode.size);
    writer.u32(inode.extents.length);
    for (const extentId of inode.extents) writer.validatedU64(extentId);
  }
}

function encodeMetadataPayload(state: VfsState, length: number, prefixBytes = 0): Uint8Array {
  if (length > MAX_METADATA_BASE_WRITER_BYTES) {
    throw new StoreLimitError(`metadata base payload exceeds ${MAX_METADATA_BASE_WRITER_BYTES} bytes`);
  }
  const writer = new BinaryWriter(prefixBytes + length, prefixBytes);
  writer.validatedU64(state.allocator.totalExtents);
  writer.validatedU64(state.nextInodeId);
  writer.u32(state.inodes.size);
  const inodes = sortIfNeeded([...state.inodes.values()], (left, right) => compareBigint(left.id, right.id));
  for (const inode of inodes) {
    writeInode(writer, inode);
  }
  const available = sortIfNeeded([...state.allocator.available.values()], compareBigint);
  for (const extentId of available) writer.validatedU64(extentId);
  const quarantine = sortIfNeeded([...state.allocator.quarantine], ([left], [right]) => compareBigint(left, right));
  for (const [extentId, generation] of quarantine) {
    writer.validatedU64(extentId);
    writer.validatedU64(generation ?? 0n);
  }
  return writer.finish();
}

export interface DecodedMetadataBase {
  state: VfsState;
  payloadBytes: number;
  baseEnd: bigint;
  baseDigest: number;
}

export interface MetadataBaseHeader {
  formatVersion: number;
  limitsProfileVersion: number;
  generation: bigint;
  extentSize: number;
  metadataFileCode: number;
  rootInodeId: bigint;
  payloadLength: number;
  availableCount: number;
  quarantineCount: number;
  baseEnd: bigint;
}

export function inspectMetadataBaseHeader(header: Uint8Array): MetadataBaseHeader {
  if (header.byteLength !== METADATA_HEADER_BYTES) {
    throw new CorruptStoreError("metadata base header has the wrong length");
  }
  requireMagic(header, METADATA_MAGIC, "metadata base");
  requireZero(header, 29, 32, "metadata base");
  requireZero(header, 52, 56, "metadata base");
  requireZero(header, 68, 80, "metadata base");
  const view = new DataView(header.buffer, header.byteOffset, header.byteLength);
  const payloadLength = view.getUint32(40, true);
  if (payloadLength > MAX_METADATA_BASE_READER_BYTES) {
    throw new CorruptStoreError("metadata base payload declaration exceeds the reader limit");
  }
  const baseEnd = view.getBigUint64(56, true);
  if (baseEnd !== BigInt(METADATA_HEADER_BYTES + payloadLength)) {
    throw new CorruptStoreError("metadata base end offset is inconsistent");
  }
  const availableCount = view.getUint32(44, true);
  const quarantineCount = view.getUint32(48, true);
  return {
    formatVersion: view.getUint32(8, true),
    limitsProfileVersion: view.getUint32(12, true),
    generation: view.getBigUint64(16, true),
    extentSize: view.getUint32(24, true),
    metadataFileCode: view.getUint8(28),
    rootInodeId: view.getBigUint64(32, true),
    payloadLength,
    availableCount,
    quarantineCount,
    baseEnd,
  };
}

export function encodeMetadataBase(state: VfsState): Uint8Array {
  validateState(state);
  return encodeValidatedMetadataBase(state);
}

/** @internal The caller must have validated this exact state immediately before encoding it. */
export function encodeValidatedMetadataBase(state: VfsState): Uint8Array {
  const baseEnd = METADATA_HEADER_BYTES + state.basePayloadBytes;
  const bytes = encodeMetadataPayload(state, state.basePayloadBytes, METADATA_HEADER_BYTES);
  const view = new DataView(bytes.buffer);
  bytes.set(METADATA_MAGIC, 0);
  view.setUint32(8, FORMAT_VERSION, true);
  view.setUint32(12, LIMITS_PROFILE_VERSION, true);
  view.setBigUint64(16, state.generation, true);
  view.setUint32(24, state.extentSize, true);
  view.setUint8(28, metadataFileCode(state.activeMetadataFile));
  view.setBigUint64(32, state.rootInodeId, true);
  view.setUint32(40, state.basePayloadBytes, true);
  view.setUint32(44, state.allocator.available.size, true);
  view.setUint32(48, state.allocator.quarantine.size, true);
  view.setBigUint64(56, BigInt(baseEnd), true);
  view.setUint32(
    METADATA_HEADER_CHECKSUM_OFFSET,
    crc32WithZeroedRange(bytes, METADATA_HEADER_CHECKSUM_OFFSET, 4),
    true,
  );
  return bytes;
}

function readBoundedCount(reader: BinaryReader, maximum: number, label: string): number {
  const count = reader.u32();
  if (count > maximum) {
    throw new CorruptStoreError(`${label} count exceeds ${maximum}`);
  }
  return count;
}

function readInode(reader: BinaryReader): Inode {
  const id = reader.u64();
  const kind = reader.u8();
  const mode = reader.u32();
  const atimeMs = reader.u64();
  const mtimeMs = reader.u64();
  const ctimeMs = reader.u64();
  if (kind === INODE_KIND_DIRECTORY) {
    const childCount = readBoundedCount(reader, MAX_INODES, "directory child");
    const children = new Map<string, bigint>();
    let previousName: string | undefined;
    for (let index = 0; index < childCount; index += 1) {
      const name = reader.string();
      if (previousName !== undefined && compareUtf8Names(previousName, name) >= 0) {
        throw new CorruptStoreError("directory children are not in canonical order");
      }
      children.set(name, reader.u64());
      previousName = name;
    }
    const inode: DirectoryInode = { id, kind: "directory", mode, atimeMs, mtimeMs, ctimeMs, children };
    return inode;
  }
  if (kind === INODE_KIND_FILE) {
    const size = reader.u64();
    const extentCount = readBoundedCount(reader, MAX_EXTENTS_PER_INODE, "file extent");
    const extents: bigint[] = [];
    for (let index = 0; index < extentCount; index += 1) extents.push(reader.u64());
    const inode: FileInode = { id, kind: "file", mode, atimeMs, mtimeMs, ctimeMs, size, extents };
    return inode;
  }
  if (kind === INODE_KIND_SYMLINK) {
    const inode: SymlinkInode = {
      id,
      kind: "symlink",
      mode,
      atimeMs,
      mtimeMs,
      ctimeMs,
      target: reader.symlinkTarget(),
    };
    return inode;
  }
  throw new CorruptStoreError("inode type is invalid");
}

export function decodeMetadataBase(bytes: Uint8Array): DecodedMetadataBase {
  if (bytes.byteLength < METADATA_HEADER_BYTES) {
    throw new CorruptStoreError("metadata base header is truncated");
  }
  const inspected = inspectMetadataBaseHeader(bytes.subarray(0, METADATA_HEADER_BYTES));
  const { payloadLength, baseEnd } = inspected;
  if (bytes.byteLength !== Number(baseEnd)) {
    throw new CorruptStoreError("metadata base end offset is inconsistent");
  }
  requireChecksum(bytes, METADATA_HEADER_CHECKSUM_OFFSET, "metadata base");
  const payload = bytes.subarray(METADATA_HEADER_BYTES);
  requireCurrentIdentity(inspected.formatVersion, inspected.limitsProfileVersion);
  const { generation, extentSize, rootInodeId, availableCount, quarantineCount } = inspected;
  if (generation === 0n) throw new CorruptStoreError("metadata generation must be positive");
  try {
    validateExtentSize(extentSize);
  } catch (cause) {
    throw new CorruptStoreError("metadata base extent size is invalid", { cause });
  }
  const activeMetadataFile = decodeMetadataFile(inspected.metadataFileCode);
  if (availableCount > MAX_TOTAL_EXTENTS || quarantineCount > MAX_TOTAL_EXTENTS) {
    throw new CorruptStoreError("metadata allocator count exceeds the extent limit");
  }

  try {
    const reader = new BinaryReader(payload);
    const totalExtents = reader.u64();
    if (totalExtents > BigInt(MAX_TOTAL_EXTENTS)) {
      throw new CorruptStoreError("metadata total extent count exceeds the extent limit");
    }
    const nextInodeId = reader.u64();
    const inodeCount = readBoundedCount(reader, MAX_INODES, "inode");
    const inodes = new Map<bigint, Inode>();
    const ownedBy = new Map<bigint, bigint>();
    let previousInodeId: bigint | undefined;
    for (let index = 0; index < inodeCount; index += 1) {
      const inode = readInode(reader);
      if (previousInodeId !== undefined && inode.id <= previousInodeId) {
        throw new CorruptStoreError("inodes are not in canonical order");
      }
      inodes.set(inode.id, inode);
      previousInodeId = inode.id;
      if (inode.kind === "file") {
        for (const extentId of inode.extents) {
          if (ownedBy.has(extentId)) throw new CorruptStoreError("metadata contains duplicate extent ownership");
          ownedBy.set(extentId, inode.id);
        }
      }
    }
    const available = new IndexedExtentSet();
    let previousAvailable: bigint | undefined;
    for (let index = 0; index < availableCount; index += 1) {
      const extentId = reader.u64();
      if (previousAvailable !== undefined && extentId <= previousAvailable) {
        throw new CorruptStoreError("available extents are not in canonical order");
      }
      available.add(extentId);
      previousAvailable = extentId;
    }
    const quarantine = new Map<bigint, bigint | null>();
    let previousQuarantine: bigint | undefined;
    for (let index = 0; index < quarantineCount; index += 1) {
      const extentId = reader.u64();
      const encodedGeneration = reader.u64();
      if (previousQuarantine !== undefined && extentId <= previousQuarantine) {
        throw new CorruptStoreError("quarantine extents are not in canonical order");
      }
      quarantine.set(extentId, encodedGeneration === 0n ? null : encodedGeneration);
      previousQuarantine = extentId;
    }
    reader.end();
    const parentByInode = new Map<bigint, { parentId: bigint; name: string }>();
    for (const inode of inodes.values()) {
      if (inode.kind !== "directory") continue;
      for (const [name, childId] of inode.children) {
        if (parentByInode.has(childId)) {
          throw new CorruptStoreError("metadata inode has multiple directory owners");
        }
        parentByInode.set(childId, { parentId: inode.id, name });
      }
    }
    const retainedGenerations: Record<MetadataFile, bigint | null> = { a: null, b: null };
    retainedGenerations[activeMetadataFile] = generation;
    const state: VfsState = {
      generation,
      nextInodeId,
      rootInodeId,
      extentSize,
      activeMetadataFile,
      retainedGenerations,
      basePayloadBytes: payloadLength,
      inodes,
      parentByInode,
      allocator: { totalExtents, ownedBy, available, quarantine },
    };
    validateState(state);
    return { state, payloadBytes: payloadLength, baseEnd, baseDigest: crc32(bytes) };
  } catch (cause) {
    if (cause instanceof CorruptStoreError) throw cause;
    throw new CorruptStoreError(`metadata base is semantically invalid: ${String(cause)}`, { cause });
  }
}

function writeExtentRuns(writer: BinaryWriter, runs: readonly ExtentRun[]): void {
  writer.u32(runs.length);
  for (const run of runs) {
    writer.u64(run.start);
    writer.u32(run.count);
  }
}

function readExtentRuns(reader: BinaryReader): ExtentRun[] {
  const runCount = readBoundedCount(reader, MAX_EXTENTS_PER_INODE, "extent run");
  const runs: ExtentRun[] = [];
  let extentCount = 0;
  for (let index = 0; index < runCount; index += 1) {
    const start = reader.u64();
    const count = reader.u32();
    if (count === 0 || extentCount + count > MAX_EXTENTS_PER_INODE) {
      throw new CorruptStoreError("extent run count is invalid");
    }
    extentCount += count;
    runs.push({ start, count });
  }
  return runs;
}

const RECORD_CODES: Record<TxnRecord["kind"], number> = {
  createDirectories: 1,
  createFile: 2,
  removeFile: 3,
  changeMode: 4,
  changeTimes: 5,
  resizeFile: 6,
  removeDirectory: 7,
  rename: 8,
  reserveQuarantine: 9,
  createSymlink: 10,
};

function encodeTxnRecord(record: TxnRecord): Uint8Array {
  const writer = new BinaryWriter(estimateTxnPayloadBytes(record));
  writer.u8(RECORD_CODES[record.kind]);
  switch (record.kind) {
    case "createDirectories":
      writer.u32(record.entries.length);
      for (const entry of record.entries) {
        writer.u64(entry.parentId);
        writer.string(entry.name);
        writer.u64(entry.inodeId);
        writer.u32(entry.mode);
        writer.u64(entry.atimeMs);
        writer.u64(entry.mtimeMs);
        writer.u64(entry.ctimeMs);
      }
      break;
    case "createFile":
      writer.u64(record.parentId);
      writer.string(record.name);
      writer.u64(record.inodeId);
      writer.u32(record.mode);
      writer.u64(record.atimeMs);
      writer.u64(record.mtimeMs);
      writer.u64(record.ctimeMs);
      writer.u64(record.size);
      writeExtentRuns(writer, record.extents);
      break;
    case "removeFile":
    case "removeDirectory":
      writer.u64(record.parentId);
      writer.string(record.name);
      writer.u64(record.parentMtimeMs);
      writer.u64(record.parentCtimeMs);
      break;
    case "changeMode":
      writer.u64(record.inodeId);
      writer.u32(record.mode);
      writer.u64(record.ctimeMs);
      break;
    case "changeTimes":
      writer.u64(record.inodeId);
      writer.u64(record.atimeMs);
      writer.u64(record.mtimeMs);
      writer.u64(record.ctimeMs);
      break;
    case "resizeFile":
      writer.u8(record.operation === "truncate" ? 0 : 1);
      writer.u64(record.inodeId);
      writer.u64(record.size);
      writeExtentRuns(writer, record.allocated);
      writer.u64(record.mtimeMs);
      writer.u64(record.ctimeMs);
      break;
    case "rename":
      writer.u64(record.sourceParentId);
      writer.string(record.sourceName);
      writer.u64(record.destinationParentId);
      writer.string(record.destinationName);
      writer.u64(record.timestampMs);
      break;
    case "createSymlink":
      writer.u64(record.parentId);
      writer.string(record.name);
      writer.u64(record.inodeId);
      writer.u32(record.mode);
      writer.u64(record.atimeMs);
      writer.u64(record.mtimeMs);
      writer.u64(record.ctimeMs);
      writer.symlinkTarget(record.target);
      break;
    case "reserveQuarantine":
      writeExtentRuns(writer, record.extents);
      break;
  }
  return writer.finish();
}

function decodeTxnRecord(bytes: Uint8Array): TxnRecord {
  const reader = new BinaryReader(bytes);
  const type = reader.u8();
  let record: TxnRecord;
  switch (type) {
    case 1: {
      const count = readBoundedCount(reader, MAX_INODES, "created directory");
      if (count === 0) throw new CorruptStoreError("directory transaction must not be empty");
      const entries = [];
      for (let index = 0; index < count; index += 1) {
        entries.push({
          parentId: reader.u64(),
          name: reader.string(),
          inodeId: reader.u64(),
          mode: reader.u32(),
          atimeMs: reader.u64(),
          mtimeMs: reader.u64(),
          ctimeMs: reader.u64(),
        });
      }
      record = { kind: "createDirectories", entries };
      break;
    }
    case 2:
      record = {
        kind: "createFile",
        parentId: reader.u64(),
        name: reader.string(),
        inodeId: reader.u64(),
        mode: reader.u32(),
        atimeMs: reader.u64(),
        mtimeMs: reader.u64(),
        ctimeMs: reader.u64(),
        size: reader.u64(),
        extents: readExtentRuns(reader),
      };
      break;
    case 3:
    case 7:
      record = {
        kind: type === 3 ? "removeFile" : "removeDirectory",
        parentId: reader.u64(),
        name: reader.string(),
        parentMtimeMs: reader.u64(),
        parentCtimeMs: reader.u64(),
      };
      break;
    case 4:
      record = { kind: "changeMode", inodeId: reader.u64(), mode: reader.u32(), ctimeMs: reader.u64() };
      break;
    case 5:
      record = {
        kind: "changeTimes",
        inodeId: reader.u64(),
        atimeMs: reader.u64(),
        mtimeMs: reader.u64(),
        ctimeMs: reader.u64(),
      };
      break;
    case 6: {
      const operation = reader.u8();
      if (operation !== 0 && operation !== 1) throw new CorruptStoreError("resize operation is invalid");
      record = {
        kind: "resizeFile",
        operation: operation === 0 ? "truncate" : "write",
        inodeId: reader.u64(),
        size: reader.u64(),
        allocated: readExtentRuns(reader),
        mtimeMs: reader.u64(),
        ctimeMs: reader.u64(),
      };
      break;
    }
    case 8:
      record = {
        kind: "rename",
        sourceParentId: reader.u64(),
        sourceName: reader.string(),
        destinationParentId: reader.u64(),
        destinationName: reader.string(),
        timestampMs: reader.u64(),
      };
      break;
    case 9:
      record = { kind: "reserveQuarantine", extents: readExtentRuns(reader) };
      break;
    case 10:
      record = {
        kind: "createSymlink",
        parentId: reader.u64(),
        name: reader.string(),
        inodeId: reader.u64(),
        mode: reader.u32(),
        atimeMs: reader.u64(),
        mtimeMs: reader.u64(),
        ctimeMs: reader.u64(),
        target: reader.symlinkTarget(),
      };
      break;
    default:
      throw new CorruptStoreError("transaction record type is invalid");
  }
  reader.end();
  return record;
}

export interface TxnFrame {
  generation: bigint;
  sequence: bigint;
  record: TxnRecord;
}

export interface TxnFrameHeader {
  generation: bigint;
  sequence: bigint;
  payloadLength: number;
  frameBytes: number;
}

export function txnFrameBytes(record: TxnRecord): number {
  const payloadBytes = estimateTxnPayloadBytes(record);
  if (payloadBytes > MAX_FRAME_PAYLOAD_BYTES) {
    throw new StoreLimitError(`transaction payload exceeds ${MAX_FRAME_PAYLOAD_BYTES} bytes`);
  }
  return TXN_FRAME_HEADER_BYTES + payloadBytes;
}

export function inspectTxnFrameHeader(header: Uint8Array): TxnFrameHeader {
  if (header.byteLength !== TXN_FRAME_HEADER_BYTES) {
    throw new CorruptStoreError("transaction frame header has the wrong length");
  }
  const view = new DataView(header.buffer, header.byteOffset, header.byteLength);
  requireMagic(header, FRAME_MAGIC, "transaction frame");
  requireZero(header, 36, 40, "transaction frame");
  requireZero(header, 44, 48, "transaction frame");
  const payloadLength = view.getUint32(32, true);
  if (payloadLength > MAX_FRAME_PAYLOAD_BYTES) {
    throw new CorruptStoreError("transaction payload declaration exceeds the frame limit");
  }
  if (view.getUint32(8, true) !== FORMAT_VERSION || view.getUint32(12, true) !== LIMITS_PROFILE_VERSION) {
    throw new CorruptStoreError("transaction frame format is invalid");
  }
  return {
    generation: view.getBigUint64(16, true),
    sequence: view.getBigUint64(24, true),
    payloadLength,
    frameBytes: TXN_FRAME_HEADER_BYTES + payloadLength,
  };
}

export function encodeTxnFrame(frame: TxnFrame): Uint8Array {
  const payload = encodeTxnRecord(frame.record);
  if (payload.byteLength > MAX_FRAME_PAYLOAD_BYTES) {
    throw new StoreLimitError(`transaction payload exceeds ${MAX_FRAME_PAYLOAD_BYTES} bytes`);
  }
  const bytes = new Uint8Array(TXN_FRAME_HEADER_BYTES + payload.byteLength);
  const view = new DataView(bytes.buffer);
  bytes.set(FRAME_MAGIC, 0);
  view.setUint32(8, FORMAT_VERSION, true);
  view.setUint32(12, LIMITS_PROFILE_VERSION, true);
  view.setBigUint64(16, checkedU64(frame.generation, "frame generation"), true);
  view.setBigUint64(24, checkedU64(frame.sequence, "frame sequence"), true);
  view.setUint32(32, payload.byteLength, true);
  bytes.set(payload, TXN_FRAME_HEADER_BYTES);
  view.setUint32(FRAME_CHECKSUM_OFFSET, crc32WithZeroedRange(bytes, FRAME_CHECKSUM_OFFSET, 4), true);
  return bytes;
}

export function decodeTxnFrame(bytes: Uint8Array): TxnFrame {
  if (bytes.byteLength < TXN_FRAME_HEADER_BYTES) throw new CorruptStoreError("transaction frame is truncated");
  const inspected = inspectTxnFrameHeader(bytes.subarray(0, TXN_FRAME_HEADER_BYTES));
  if (bytes.byteLength !== inspected.frameBytes) {
    throw new CorruptStoreError("transaction frame length is inconsistent");
  }
  requireChecksum(bytes, FRAME_CHECKSUM_OFFSET, "transaction frame");
  return {
    generation: inspected.generation,
    sequence: inspected.sequence,
    record: decodeTxnRecord(bytes.subarray(TXN_FRAME_HEADER_BYTES)),
  };
}

export interface ActivationRecord {
  sequence: bigint;
  metadataFile: MetadataFile;
  generation: bigint;
  baseEnd: bigint;
  baseDigest: number;
}

export function encodeActivationSlot(record: ActivationRecord): Uint8Array {
  if (record.sequence === 0n || record.generation === 0n || record.baseEnd < BigInt(METADATA_HEADER_BYTES)) {
    throw new StoreLimitError("activation identity values are outside the encodable domain");
  }
  const bytes = new Uint8Array(ACTIVATION_SLOT_BYTES);
  const view = new DataView(bytes.buffer);
  bytes.set(ACTIVATION_MAGIC, 0);
  view.setUint32(8, FORMAT_VERSION, true);
  view.setUint32(12, LIMITS_PROFILE_VERSION, true);
  view.setBigUint64(16, checkedU64(record.sequence, "activation sequence"), true);
  view.setUint8(24, metadataFileCode(record.metadataFile));
  view.setBigUint64(32, checkedU64(record.generation, "activation generation"), true);
  view.setBigUint64(40, checkedU64(record.baseEnd, "activation base end"), true);
  if (!Number.isSafeInteger(record.baseDigest) || record.baseDigest < 0 || record.baseDigest > 0xffff_ffff) {
    throw new StoreLimitError("activation base digest is outside the unsigned 32-bit range");
  }
  view.setUint32(48, record.baseDigest, true);
  view.setUint32(ACTIVATION_CHECKSUM_OFFSET, crc32WithZeroedRange(bytes, ACTIVATION_CHECKSUM_OFFSET, 4), true);
  return bytes;
}

export function decodeActivationSlot(bytes: Uint8Array): ActivationRecord {
  if (bytes.byteLength !== ACTIVATION_SLOT_BYTES) {
    throw new CorruptStoreError("activation slot has the wrong length");
  }
  requireMagic(bytes, ACTIVATION_MAGIC, "activation slot");
  requireZero(bytes, 25, 32, "activation slot");
  requireZero(bytes, 56, bytes.byteLength, "activation slot");
  requireChecksum(bytes, ACTIVATION_CHECKSUM_OFFSET, "activation slot");
  const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
  requireCurrentIdentity(view.getUint32(8, true), view.getUint32(12, true));
  const sequence = view.getBigUint64(16, true);
  const generation = view.getBigUint64(32, true);
  const baseEnd = view.getBigUint64(40, true);
  if (sequence === 0n || generation === 0n || baseEnd < BigInt(METADATA_HEADER_BYTES)) {
    throw new CorruptStoreError("activation slot contains invalid identity values");
  }
  return {
    sequence,
    metadataFile: decodeMetadataFile(view.getUint8(24)),
    generation,
    baseEnd,
    baseDigest: view.getUint32(48, true),
  };
}

export function tryDecodeActivationSlot(bytes: Uint8Array): ActivationRecord | null {
  try {
    return decodeActivationSlot(bytes);
  } catch (cause) {
    if (cause instanceof CorruptStoreError) return null;
    throw cause;
  }
}

export function selectActivation(leftBytes: Uint8Array, rightBytes: Uint8Array): ActivationRecord {
  return selectActivationPair(leftBytes, rightBytes).selected;
}

export interface ActivationPairSelection {
  selected: ActivationRecord;
  other: ActivationRecord | null;
}

export function selectActivationPair(leftBytes: Uint8Array, rightBytes: Uint8Array): ActivationPairSelection {
  const left = tryDecodeActivationSlot(leftBytes);
  const right = tryDecodeActivationSlot(rightBytes);
  if (left === null && right === null) {
    throw new CorruptStoreError("both activation slots are invalid");
  }
  if (left === null) return { selected: right!, other: null };
  if (right === null) return { selected: left, other: null };
  const older = left.sequence < right.sequence ? left : right;
  const newer = older === left ? right : left;
  if (newer.sequence !== older.sequence + 1n || newer.metadataFile === older.metadataFile) {
    throw new CorruptStoreError("activation slots do not form one exact alternating progression");
  }
  return { selected: newer, other: older };
}

export function metadataBaseDigest(bytes: Uint8Array): number {
  return crc32(bytes);
}
