/**
 * A WASI preview1 FILESYSTEM adapter over `RepackedSyncClient`.
 *
 * ## Why this file exists
 *
 * The broker gives a futex-parked thread synchronous access to ONE repacked store. A wasm engine does
 * not speak that API — it speaks `wasi_snapshot_preview1`, thirty-odd i32-returning imports over its
 * own linear memory. This module is the seam: every WASI file call a guest makes is translated into a
 * broker request, and the errno the broker already speaks (the protocol's numbers ARE WASI preview1
 * errnos) is handed straight back. Nothing here knows about pgwasm, OPFS, or any particular engine —
 * it needs a client, a way to reach the guest's memory, and nothing else.
 *
 * ## What it owns and what it refuses to touch
 *
 * The adapter owns fd `preopenFd` (3 by default — the "/" preopen) and every fd at or above `fdBase`
 * (4 by default). It NEVER touches fds 0–2: a host keeps its own stdin/stdout/stderr, and
 * {@link WasiPreview1Fs.compose} builds the merged import object that routes each call to whichever
 * side owns the fd. `owns()` is public so a host can make that decision itself.
 *
 * ## The fd table
 *
 * One entry per open descriptor, keyed by the WASI fd the guest sees:
 *
 *     { fd, path, isDir, clientFd, offset, fdflags, rightsBase, rightsInheriting, readable, writable }
 *
 * - `path` is the normalized absolute path the fd was opened with. It is kept for the whole life of
 *   the descriptor because the store resizes by PATH, not by descriptor: `fd_filestat_set_size` and
 *   `fd_allocate` resolve through it.
 * - `clientFd` is the broker's descriptor for a FILE. A DIRECTORY has none: the store cannot open a
 *   directory at all (its `open` answers `EISDIR`), so a directory fd is adapter-local — a remembered
 *   path plus a listing snapshot — and is still usable for `fd_readdir`, `fd_filestat_get`, `fd_sync`
 *   and as a dirfd for every path operation.
 * - `offset` is tracked HERE, not in the store. Every read and write the adapter issues carries an
 *   explicit position (the store's `pread`/`pwrite` form), so the store's own cursor is never used and
 *   `fd_seek`/`fd_tell` are exact even though several threads share one store.
 *
 * ## The mapping, decision by decision
 *
 * - **rights → access.** `RIGHTS_FD_READ` grants read, `RIGHTS_FD_WRITE` grants write; a request that
 *   asks for neither is read-only. That access is enforced by the BROKER (per its own fd) and again
 *   here, because a POSIX `open(O_RDONLY|O_CREAT)` has to open the store descriptor wider than the
 *   guest asked for — see below.
 * - **oflags → POSIX bits.** `CREAT`→`O_CREAT`, `EXCL`→`O_EXCL`, `TRUNC`→`O_TRUNC`, and the access
 *   mode from the rights. `planOpen` (server side) rejects `O_CREAT`/`O_TRUNC` without write access,
 *   so a create/truncate request always adds write to the POSIX access mode while the adapter keeps
 *   the NARROWER access the rights asked for and rejects a later `fd_write` with `ENOTCAPABLE`.
 * - **`O_DIRECTORY` and directories.** With `DIRECTORY` set the adapter never calls `open` (that would
 *   create a file); it stats and builds a directory fd. Without it, `open` is attempted and an
 *   `EISDIR` answer is recognised as "this is a directory" and turned into a directory fd too — which
 *   costs nothing on the common file path.
 * - **`O_APPEND` is emulated here, never passed to the broker.** An append write resolves end-of-file
 *   with `fstat` and then `pwrite`s there, so the adapter's own offset stays exact and
 *   `fd_fdstat_set_flags` can turn `APPEND` on and off on a descriptor that was not opened with it.
 * - **`fd_seek`.** `SET`/`CUR` are pure arithmetic on the adapter's offset; `END` resolves the size
 *   with `fstat` on the broker fd (never `size(path)`, which would race a rename).
 * - **`fd_readdir` cookies.** A cookie is an index into a listing SNAPSHOT taken on cookie 0, exactly
 *   as a POSIX `readdir` may. The snapshot is built out of `readdirPage` calls, and it exists for a
 *   protocol reason as much as a semantic one: the broker treats a cursor past the end of the listing
 *   as a protocol violation and DETACHES the client, so a stale cookie from a directory that shrank
 *   must never reach it. A cookie past the snapshot's end reports zero bytes used — end of directory.
 * - **truncate by path.** `fd_filestat_set_size` and `fd_allocate` call `truncate(path, size)` with
 *   the descriptor's remembered path. The store has no hard links, so nothing is lost.
 * - **`fd_sync`/`fd_datasync`.** Both map to the broker's `fsync`, whose durability is STORE-WIDE: on
 *   success every byte written through the broker by ANY client before the call returned is
 *   recoverable. That is stronger than `fd_sync` promises, never weaker.
 * - **symlinks.** The store HAS them, and `SYMLINK_FOLLOW` therefore means something everywhere it
 *   appears: `path_filestat_get` picks `stat` or `lstat`, and a `path_open` without it sends
 *   `O_NOFOLLOW` so a final component that IS a link answers `ELOOP` instead of opening its target.
 *   `path_unlink_file` removes the LINK, never the target — that is the core's `unlink`, not
 *   something added here. `path_symlink` writes a link and `path_readlink` reads one back, both
 *   verbatim: the store takes ABSOLUTE targets only (see `validateSymlinkTarget`), so a relative
 *   target is `EINVAL` rather than being silently reinterpreted, and an absolute one is a STORE
 *   path — which is the same thing as a guest path whenever the preopen is the store root, the
 *   default and the only arrangement a datadir uses. `path_link` remains `ENOTSUP`: the store has
 *   no hard links.
 * - **`fd_advise`** is a no-op success. **`fd_filestat_set_times`/`path_filestat_set_times`** answer
 *   `ENOTSUP`: the broker exposes no `utimes` opcode, and inventing an adapter-local timestamp would
 *   make two threads on ONE store disagree about a file's mtime, which is exactly what this whole
 *   arrangement exists to prevent.
 *
 * ## Failure discipline
 *
 * Every exported function is wrapped so that a JS exception can never leave it. A throw out of a WASI
 * import unwinds through the guest's nounwind frames and surfaces as a bare `RuntimeError:
 * unreachable` with no attribution at all; instead the wrapper reports `EIO` and hands the cause to
 * `onError` with the call name. A transport failure (the coordinator died, the client was detached) is
 * therefore an `EIO` the guest can act on rather than an abort it cannot.
 */

import type { RepackedSyncClient } from "../broker/client";
import { O_CREAT, O_EXCL, O_NOFOLLOW, O_RDONLY, O_RDWR, O_TRUNC, O_WRONLY } from "../broker/protocol";
import type { BrokerStat } from "../broker/protocol";

/**
 * WASI preview1 errno numbers. A superset of the store's own `FS_ERRNO` (whose values already ARE
 * these numbers); the extra codes are ones a filesystem ADAPTER has to answer and the store core
 * never produces, so they live here rather than widening the core's error vocabulary.
 */
export const WASI_ERRNO = {
  SUCCESS: 0,
  ACCES: 2,
  BADF: 8,
  EXIST: 20,
  INVAL: 28,
  IO: 29,
  ISDIR: 31,
  LOOP: 32,
  NOENT: 44,
  NOSYS: 52,
  NOTDIR: 54,
  NOTEMPTY: 55,
  NOTSUP: 58,
  OVERFLOW: 61,
  PERM: 63,
  SPIPE: 70,
  NOTCAPABLE: 76,
} as const;

/** WASI preview1 filetypes. */
export const WASI_FILETYPE = {
  UNKNOWN: 0,
  BLOCK_DEVICE: 1,
  CHARACTER_DEVICE: 2,
  DIRECTORY: 3,
  REGULAR_FILE: 4,
  SOCKET_DGRAM: 5,
  SOCKET_STREAM: 6,
  SYMBOLIC_LINK: 7,
} as const;

/** `path_open` oflags. */
export const OFLAGS_CREAT = 1;
export const OFLAGS_DIRECTORY = 2;
export const OFLAGS_EXCL = 4;
export const OFLAGS_TRUNC = 8;

/** `fdflags`, on `path_open` and `fd_fdstat_set_flags`. */
export const FDFLAGS_APPEND = 1;
export const FDFLAGS_DSYNC = 2;
export const FDFLAGS_NONBLOCK = 4;
export const FDFLAGS_RSYNC = 8;
export const FDFLAGS_SYNC = 16;

/** `lookupflags`, on every path operation that could follow a symlink. */
export const LOOKUPFLAGS_SYMLINK_FOLLOW = 1;

/** The two rights the adapter reads; the rest are carried through untouched. */
export const RIGHTS_FD_READ = 1n << 1n;
export const RIGHTS_FD_WRITE = 1n << 6n;
/** What a preopen advertises when the host asked for nothing narrower. */
export const RIGHTS_ALL = 0xffff_ffff_ffff_ffffn;

/** `fd_seek` whence values. */
export const WHENCE_SET = 0;
export const WHENCE_CUR = 1;
export const WHENCE_END = 2;

/** `filestat` is 64 bytes; `dirent` is 24 bytes plus the name. */
const FILESTAT_BYTES = 64;
const DIRENT_HEADER_BYTES = 24;
/** What `stat` reports for a directory — the store has no directory size, and `0` confuses callers. */
const DIRECTORY_SIZE = 4096n;
const NS_PER_MS = 1_000_000n;

/** One open descriptor the adapter owns. */
interface AdapterFd {
  readonly fd: number;
  /** The normalized absolute path this fd was opened with; the store resizes by path only. */
  path: string;
  readonly isDir: boolean;
  /** The broker's descriptor — `undefined` for a directory, which the store cannot open. */
  readonly clientFd: number | undefined;
  /** The adapter's own cursor. Every transfer carries an explicit position, so this is authoritative. */
  offset: bigint;
  fdflags: number;
  rightsBase: bigint;
  rightsInheriting: bigint;
  readonly readable: boolean;
  readonly writable: boolean;
  /** The listing snapshot a `fd_readdir` cookie indexes into, taken on cookie 0. */
  listing: DirentSnapshot | undefined;
}

interface DirentSnapshot {
  readonly names: readonly string[];
  /** Lazily filled per emitted entry: a `d_type` costs one `lstat`, so only emitted names pay it. */
  readonly filetypes: Map<string, number>;
}

export interface WasiPreview1FsOptions {
  /** The synchronous broker client this adapter turns WASI calls into. */
  readonly client: RepackedSyncClient;
  /**
   * The guest's linear memory, resolved on EVERY call. A shared wasm memory grows underneath the host
   * and any cached `Uint8Array`/`DataView` goes stale (or detaches outright, for a non-shared one), so
   * nothing here holds a view across a call.
   */
  readonly memory: () => ArrayBuffer | SharedArrayBuffer;
  /** The fd the guest sees the preopened directory as. Defaults to 3, the WASI convention. */
  readonly preopenFd?: number;
  /** The directory that preopen names. Defaults to the store root. */
  readonly preopenPath?: string;
  /** The first fd the adapter hands out, and the bottom of the range it claims. Defaults to 4. */
  readonly fdBase?: number;
  /** The adapter's clock, in milliseconds. Defaults to the wall clock; used for diagnostics. */
  readonly now?: () => bigint;
  /** Where a JS exception that escaped a WASI call is reported. Defaults to `console.error`. */
  readonly onError?: (call: string, cause: unknown) => void;
}

/** The `wasi_snapshot_preview1` filesystem surface, with the ABI's exact signatures. */
export interface WasiPreview1FsFunctions {
  fd_prestat_get(fd: number, resultPtr: number): number;
  fd_prestat_dir_name(fd: number, pathPtr: number, pathLen: number): number;
  fd_close(fd: number): number;
  fd_read(fd: number, iovsPtr: number, iovsLen: number, nreadPtr: number): number;
  fd_pread(fd: number, iovsPtr: number, iovsLen: number, offset: bigint, nreadPtr: number): number;
  fd_write(fd: number, iovsPtr: number, iovsLen: number, nwrittenPtr: number): number;
  fd_pwrite(fd: number, iovsPtr: number, iovsLen: number, offset: bigint, nwrittenPtr: number): number;
  fd_seek(fd: number, offset: bigint, whence: number, resultPtr: number): number;
  fd_tell(fd: number, resultPtr: number): number;
  fd_fdstat_get(fd: number, resultPtr: number): number;
  fd_fdstat_set_flags(fd: number, fdflags: number): number;
  fd_fdstat_set_rights(fd: number, rightsBase: bigint, rightsInheriting: bigint): number;
  fd_filestat_get(fd: number, resultPtr: number): number;
  fd_filestat_set_size(fd: number, size: bigint): number;
  fd_filestat_set_times(fd: number, atim: bigint, mtim: bigint, fstflags: number): number;
  fd_readdir(fd: number, bufPtr: number, bufLen: number, cookie: bigint, bufusedPtr: number): number;
  fd_sync(fd: number): number;
  fd_datasync(fd: number): number;
  fd_allocate(fd: number, offset: bigint, length: bigint): number;
  fd_advise(fd: number, offset: bigint, length: bigint, advice: number): number;
  path_open(
    dirfd: number,
    dirflags: number,
    pathPtr: number,
    pathLen: number,
    oflags: number,
    rightsBase: bigint,
    rightsInheriting: bigint,
    fdflags: number,
    resultPtr: number,
  ): number;
  path_filestat_get(dirfd: number, flags: number, pathPtr: number, pathLen: number, resultPtr: number): number;
  path_filestat_set_times(
    dirfd: number,
    flags: number,
    pathPtr: number,
    pathLen: number,
    atim: bigint,
    mtim: bigint,
    fstflags: number,
  ): number;
  path_create_directory(dirfd: number, pathPtr: number, pathLen: number): number;
  path_remove_directory(dirfd: number, pathPtr: number, pathLen: number): number;
  path_unlink_file(dirfd: number, pathPtr: number, pathLen: number): number;
  path_rename(dirfd: number, oldPtr: number, oldLen: number, newDirfd: number, newPtr: number, newLen: number): number;
  path_readlink(
    dirfd: number,
    pathPtr: number,
    pathLen: number,
    bufPtr: number,
    bufLen: number,
    bufusedPtr: number,
  ): number;
  path_symlink(oldPtr: number, oldLen: number, dirfd: number, newPtr: number, newLen: number): number;
  path_link(
    oldDirfd: number,
    oldFlags: number,
    oldPtr: number,
    oldLen: number,
    newDirfd: number,
    newPtr: number,
    newLen: number,
  ): number;
}

/** The adapter: the WASI surface plus the three composition/lifecycle helpers a host needs. */
export interface WasiPreview1Fs extends WasiPreview1FsFunctions {
  /** Whether this adapter answers for `fd`: the preopen, or anything at or above `fdBase`. */
  owns(fd: number): boolean;
  /** How many descriptors the adapter currently holds, preopen excluded. */
  openFdCount(): number;
  /**
   * A merged `wasi_snapshot_preview1` object: every filesystem call goes to the adapter when the fd
   * (or the dirfd, for a path operation) is adapter-owned and to `base` otherwise. Everything in
   * `base` that is not a filesystem call — `args_get`, `clock_time_get`, `poll_oneoff`, `proc_exit`,
   * `random_get`, `sched_yield`, the socket stubs — is carried through untouched, as are `base`'s own
   * fd 0/1/2 handlers.
   */
  compose(base: Readonly<Record<string, unknown>>): Record<string, unknown>;
  /**
   * Close every descriptor, returning how many were released. The call a thread makes on its way out:
   * without it the coordinator holds the thread's store descriptors until the whole channel detaches.
   */
  closeAll(): number;
}

/** Every filesystem import name, mapped to the argument index carrying the fd or dirfd. */
const FD_ARGUMENT_INDEX: Readonly<Record<keyof WasiPreview1FsFunctions, number>> = {
  fd_prestat_get: 0,
  fd_prestat_dir_name: 0,
  fd_close: 0,
  fd_read: 0,
  fd_pread: 0,
  fd_write: 0,
  fd_pwrite: 0,
  fd_seek: 0,
  fd_tell: 0,
  fd_fdstat_get: 0,
  fd_fdstat_set_flags: 0,
  fd_fdstat_set_rights: 0,
  fd_filestat_get: 0,
  fd_filestat_set_size: 0,
  fd_filestat_set_times: 0,
  fd_readdir: 0,
  fd_sync: 0,
  fd_datasync: 0,
  fd_allocate: 0,
  fd_advise: 0,
  path_open: 0,
  path_filestat_get: 0,
  path_filestat_set_times: 0,
  path_create_directory: 0,
  path_remove_directory: 0,
  path_unlink_file: 0,
  path_rename: 0,
  path_readlink: 0,
  // The only call whose directory argument is not first: `path_symlink(old_path, old_len, fd, …)`.
  path_symlink: 2,
  path_link: 0,
};

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

/**
 * Canonicalize a guest path the way the store demands: absolute, no `.`/`..`, no empty or repeated
 * separator, no trailing slash. Deliberately lenient in the same places a WASI host has to be —
 * wasi-libc hands preopen-RELATIVE paths, but an absolute one is accepted too, and a trailing NUL from
 * a fixed-size buffer is stripped rather than rejected.
 */
export function normalizeWasiPath(path: string): string {
  let value = path.replace(/\0+$/u, "");
  if (value === "" || value === ".") return "/";
  if (!value.startsWith("/")) value = `/${value}`;
  const parts: string[] = [];
  for (const segment of value.split("/")) {
    if (segment === "" || segment === ".") continue;
    if (segment === "..") {
      parts.pop();
      continue;
    }
    parts.push(segment);
  }
  return `/${parts.join("/")}`;
}

/**
 * A stable synthetic inode number for a path. The store has no inode numbers on the wire, and a WASI
 * `filestat`/`dirent` must carry one; a 64-bit FNV-1a of the path is stable for as long as the path
 * is, never zero, and never collides in practice within one datadir.
 */
function inodeOf(path: string): bigint {
  let hash = 0xcbf2_9ce4_8422_2325n;
  const bytes = textEncoder.encode(path);
  for (const byte of bytes) {
    hash = BigInt.asUintN(64, (hash ^ BigInt(byte)) * 0x1000_0000_01b3n);
  }
  return hash === 0n ? 1n : hash;
}

function toBigInt(value: bigint | number): bigint {
  return typeof value === "bigint" ? value : BigInt(Math.trunc(value));
}

export function createWasiPreview1Fs(options: WasiPreview1FsOptions): WasiPreview1Fs {
  const client = options.client;
  const preopenFd = options.preopenFd ?? 3;
  const preopenPath = normalizeWasiPath(options.preopenPath ?? "/");
  const fdBase = options.fdBase ?? preopenFd + 1;
  const now = options.now ?? (() => BigInt(Date.now()));
  const onError =
    options.onError ??
    ((call: string, cause: unknown) => {
      const detail = cause instanceof Error ? (cause.stack ?? `${cause.name}: ${cause.message}`) : String(cause);
      console.error(`[wasi-preview1 ${call} @${now()}] ${detail}`);
    });

  if (!Number.isSafeInteger(preopenFd) || preopenFd < 3) {
    throw new RangeError("a WASI preopen fd must be a safe integer of at least 3 (0-2 are stdio)");
  }
  if (!Number.isSafeInteger(fdBase) || fdBase <= preopenFd) {
    throw new RangeError("the WASI adapter fd base must be a safe integer above the preopen fd");
  }

  const table = new Map<number, AdapterFd>();
  let nextFd = fdBase;

  const preopenEntry = (): AdapterFd => ({
    fd: preopenFd,
    path: preopenPath,
    isDir: true,
    clientFd: undefined,
    offset: 0n,
    fdflags: 0,
    rightsBase: RIGHTS_ALL,
    rightsInheriting: RIGHTS_ALL,
    readable: true,
    writable: false,
    listing: undefined,
  });
  table.set(preopenFd, preopenEntry());

  // ---- memory access -------------------------------------------------------
  // Re-derived per call, never cached: the guest memory is shared and grows underneath us.
  const bytes = (): Uint8Array => new Uint8Array(options.memory());
  const view = (): DataView => new DataView(options.memory());

  function readGuestString(ptr: number, length: number): string {
    // `.slice()`, not `.subarray()`: TextDecoder refuses a SharedArrayBuffer-backed view in Chrome
    // (the [AllowShared] rule). Paths are short, so the copy costs nothing.
    return textDecoder.decode(bytes().slice(ptr, ptr + length));
  }

  function* iovecs(ptr: number, count: number): Generator<{ ptr: number; len: number }> {
    const data = view();
    for (let index = 0; index < count; index += 1) {
      const base = ptr + index * 8;
      yield { ptr: data.getUint32(base, true), len: data.getUint32(base + 4, true) };
    }
  }

  // ---- fd table ------------------------------------------------------------
  function owns(fd: number): boolean {
    return fd === preopenFd || (Number.isSafeInteger(fd) && fd >= fdBase);
  }

  function entryOf(fd: number): AdapterFd | undefined {
    return table.get(fd);
  }

  function allocate(entry: Omit<AdapterFd, "fd">): number {
    const fd = nextFd;
    nextFd += 1;
    table.set(fd, { ...entry, fd });
    return fd;
  }

  function resolve(dirfd: number, ptr: number, length: number): string | undefined {
    const dir = entryOf(dirfd);
    if (dir === undefined || !dir.isDir) return undefined;
    const relative = readGuestString(ptr, length);
    if (relative.startsWith("/")) return normalizeWasiPath(relative);
    return normalizeWasiPath(dir.path === "/" ? `/${relative}` : `${dir.path}/${relative}`);
  }

  // ---- stat ----------------------------------------------------------------
  function filetypeOf(kind: BrokerStat["kind"]): number {
    if (kind === "directory") return WASI_FILETYPE.DIRECTORY;
    return kind === "symlink" ? WASI_FILETYPE.SYMBOLIC_LINK : WASI_FILETYPE.REGULAR_FILE;
  }

  function writeFilestat(ptr: number, path: string, stat: BrokerStat): void {
    // Zero first: the struct has padding the guest is entitled to see as zero, and a reused stack
    // slot would otherwise hand it whatever the last call left there.
    bytes().fill(0, ptr, ptr + FILESTAT_BYTES);
    const data = view();
    const isDirectory = stat.kind === "directory";
    const times = [stat.atimeMs, stat.mtimeMs, stat.ctimeMs].map((ms) => ms * NS_PER_MS);
    data.setBigUint64(ptr + 0, 1n, true); // dev
    data.setBigUint64(ptr + 8, inodeOf(path), true); // ino
    data.setUint8(ptr + 16, filetypeOf(stat.kind));
    data.setBigUint64(ptr + 24, 1n, true); // nlink
    // A link's size is its target's byte length, exactly as POSIX `lstat` reports it.
    data.setBigUint64(ptr + 32, isDirectory ? DIRECTORY_SIZE : stat.size, true);
    data.setBigUint64(ptr + 40, times[0]!, true); // atim
    data.setBigUint64(ptr + 48, times[1]!, true); // mtim
    data.setBigUint64(ptr + 56, times[2]!, true); // ctim
  }

  /** The size of a descriptor's file, from the broker fd (never the path — a rename must not race it). */
  function sizeOf(entry: AdapterFd): { errno: number; size: bigint } {
    if (entry.clientFd === undefined) return { errno: 0, size: DIRECTORY_SIZE };
    const stat = client.fstat(entry.clientFd);
    if (stat.errno !== 0 || stat.stat === undefined) return { errno: stat.errno || WASI_ERRNO.IO, size: 0n };
    return { errno: 0, size: stat.stat.size };
  }

  // ---- the WASI surface ----------------------------------------------------
  const fs: WasiPreview1FsFunctions = {
    fd_prestat_get(fd, resultPtr) {
      if (fd !== preopenFd) return WASI_ERRNO.BADF;
      const data = view();
      data.setUint8(resultPtr, 0); // preopentype::dir
      data.setUint32(resultPtr + 4, textEncoder.encode(preopenPath).byteLength, true);
      return WASI_ERRNO.SUCCESS;
    },

    fd_prestat_dir_name(fd, pathPtr, pathLen) {
      if (fd !== preopenFd) return WASI_ERRNO.BADF;
      const encoded = textEncoder.encode(preopenPath);
      if (pathLen < encoded.byteLength) return WASI_ERRNO.INVAL;
      bytes().set(encoded, pathPtr);
      return WASI_ERRNO.SUCCESS;
    },

    fd_close(fd) {
      if (fd === preopenFd) return WASI_ERRNO.SUCCESS; // the preopen outlives every guest close
      const entry = entryOf(fd);
      if (entry === undefined) return WASI_ERRNO.BADF;
      table.delete(fd);
      if (entry.clientFd === undefined) return WASI_ERRNO.SUCCESS;
      return client.close(entry.clientFd).errno;
    },

    fd_read(fd, iovsPtr, iovsLen, nreadPtr) {
      const entry = entryOf(fd);
      if (entry === undefined) return WASI_ERRNO.BADF;
      if (entry.clientFd === undefined) return WASI_ERRNO.ISDIR;
      if (!entry.readable) return WASI_ERRNO.NOTCAPABLE;
      let total = 0;
      let errno: number = WASI_ERRNO.SUCCESS;
      for (const { ptr, len } of iovecs(iovsPtr, iovsLen)) {
        if (len === 0) continue;
        const chunk = client.read(entry.clientFd, len, entry.offset + BigInt(total));
        if (chunk.count > 0) bytes().set(chunk.bytes, ptr);
        total += chunk.count;
        if (chunk.errno !== 0) {
          errno = chunk.errno;
          break;
        }
        if (chunk.count < len) break; // end of file
      }
      entry.offset += BigInt(total);
      view().setUint32(nreadPtr, total, true);
      // A rejection part-way through still reports what transferred, exactly as a short read does.
      return total > 0 ? WASI_ERRNO.SUCCESS : errno;
    },

    fd_pread(fd, iovsPtr, iovsLen, offset, nreadPtr) {
      const entry = entryOf(fd);
      if (entry === undefined) return WASI_ERRNO.BADF;
      if (entry.clientFd === undefined) return WASI_ERRNO.ISDIR;
      if (!entry.readable) return WASI_ERRNO.NOTCAPABLE;
      let at = toBigInt(offset);
      let total = 0;
      let errno: number = WASI_ERRNO.SUCCESS;
      for (const { ptr, len } of iovecs(iovsPtr, iovsLen)) {
        if (len === 0) continue;
        const chunk = client.read(entry.clientFd, len, at);
        if (chunk.count > 0) bytes().set(chunk.bytes, ptr);
        at += BigInt(chunk.count);
        total += chunk.count;
        if (chunk.errno !== 0) {
          errno = chunk.errno;
          break;
        }
        if (chunk.count < len) break;
      }
      view().setUint32(nreadPtr, total, true);
      return total > 0 ? WASI_ERRNO.SUCCESS : errno;
    },

    fd_write(fd, iovsPtr, iovsLen, nwrittenPtr) {
      const entry = entryOf(fd);
      if (entry === undefined) return WASI_ERRNO.BADF;
      if (entry.clientFd === undefined) return WASI_ERRNO.ISDIR;
      if (!entry.writable) return WASI_ERRNO.NOTCAPABLE;
      // O_APPEND is emulated here: resolve end-of-file once, then write at explicit positions.
      if ((entry.fdflags & FDFLAGS_APPEND) !== 0) {
        const end = sizeOf(entry);
        if (end.errno !== 0) return end.errno;
        entry.offset = end.size;
      }
      let total = 0;
      let errno: number = WASI_ERRNO.SUCCESS;
      for (const { ptr, len } of iovecs(iovsPtr, iovsLen)) {
        if (len === 0) continue;
        const source = bytes().subarray(ptr, ptr + len);
        const written = client.write(entry.clientFd, source, entry.offset + BigInt(total));
        total += written.count;
        if (written.errno !== 0) {
          errno = written.errno;
          break;
        }
        if (written.count < len) break; // the store admitted less than asked
      }
      entry.offset += BigInt(total);
      view().setUint32(nwrittenPtr, total, true);
      return total > 0 ? WASI_ERRNO.SUCCESS : errno;
    },

    fd_pwrite(fd, iovsPtr, iovsLen, offset, nwrittenPtr) {
      const entry = entryOf(fd);
      if (entry === undefined) return WASI_ERRNO.BADF;
      if (entry.clientFd === undefined) return WASI_ERRNO.ISDIR;
      if (!entry.writable) return WASI_ERRNO.NOTCAPABLE;
      let at = toBigInt(offset);
      let total = 0;
      let errno: number = WASI_ERRNO.SUCCESS;
      for (const { ptr, len } of iovecs(iovsPtr, iovsLen)) {
        if (len === 0) continue;
        const written = client.write(entry.clientFd, bytes().subarray(ptr, ptr + len), at);
        at += BigInt(written.count);
        total += written.count;
        if (written.errno !== 0) {
          errno = written.errno;
          break;
        }
        if (written.count < len) break;
      }
      view().setUint32(nwrittenPtr, total, true);
      return total > 0 ? WASI_ERRNO.SUCCESS : errno;
    },

    fd_seek(fd, offset, whence, resultPtr) {
      const entry = entryOf(fd);
      if (entry === undefined) return WASI_ERRNO.BADF;
      if (entry.clientFd === undefined) return WASI_ERRNO.BADF; // a directory has no seekable offset
      const delta = toBigInt(offset);
      let target: bigint;
      if (whence === WHENCE_SET) target = delta;
      else if (whence === WHENCE_CUR) target = entry.offset + delta;
      else if (whence === WHENCE_END) {
        const end = sizeOf(entry);
        if (end.errno !== 0) return end.errno;
        target = end.size + delta;
      } else return WASI_ERRNO.INVAL;
      if (target < 0n) return WASI_ERRNO.INVAL;
      entry.offset = target;
      view().setBigUint64(resultPtr, target, true);
      return WASI_ERRNO.SUCCESS;
    },

    fd_tell(fd, resultPtr) {
      const entry = entryOf(fd);
      if (entry === undefined) return WASI_ERRNO.BADF;
      if (entry.clientFd === undefined) return WASI_ERRNO.BADF;
      view().setBigUint64(resultPtr, entry.offset, true);
      return WASI_ERRNO.SUCCESS;
    },

    fd_fdstat_get(fd, resultPtr) {
      const entry = entryOf(fd);
      if (entry === undefined) return WASI_ERRNO.BADF;
      const data = view();
      data.setUint8(resultPtr, entry.isDir ? WASI_FILETYPE.DIRECTORY : WASI_FILETYPE.REGULAR_FILE);
      data.setUint8(resultPtr + 1, 0);
      data.setUint16(resultPtr + 2, entry.fdflags, true);
      data.setUint32(resultPtr + 4, 0, true);
      data.setBigUint64(resultPtr + 8, entry.rightsBase, true);
      data.setBigUint64(resultPtr + 16, entry.rightsInheriting, true);
      return WASI_ERRNO.SUCCESS;
    },

    fd_fdstat_set_flags(fd, fdflags) {
      const entry = entryOf(fd);
      if (entry === undefined) return WASI_ERRNO.BADF;
      // APPEND is the adapter's own emulation, so it can be turned on and off freely; the rest of the
      // sync bits are accepted and ignored (the store's only durability primitive is `fsync`, which is
      // already store-wide and stronger than DSYNC/RSYNC/SYNC promise). NONBLOCK is meaningless here:
      // every broker call blocks by construction.
      entry.fdflags = fdflags & (FDFLAGS_APPEND | FDFLAGS_DSYNC | FDFLAGS_NONBLOCK | FDFLAGS_RSYNC | FDFLAGS_SYNC);
      return WASI_ERRNO.SUCCESS;
    },

    fd_fdstat_set_rights(fd, rightsBase, rightsInheriting) {
      const entry = entryOf(fd);
      if (entry === undefined) return WASI_ERRNO.BADF;
      // Rights may only ever be narrowed.
      const base = toBigInt(rightsBase);
      const inheriting = toBigInt(rightsInheriting);
      if ((base & ~entry.rightsBase) !== 0n || (inheriting & ~entry.rightsInheriting) !== 0n) {
        return WASI_ERRNO.NOTCAPABLE;
      }
      entry.rightsBase = base;
      entry.rightsInheriting = inheriting;
      return WASI_ERRNO.SUCCESS;
    },

    fd_filestat_get(fd, resultPtr) {
      const entry = entryOf(fd);
      if (entry === undefined) return WASI_ERRNO.BADF;
      const stat = entry.clientFd === undefined ? client.stat(entry.path) : client.fstat(entry.clientFd);
      if (stat.errno !== 0 || stat.stat === undefined) return stat.errno || WASI_ERRNO.IO;
      writeFilestat(resultPtr, entry.path, stat.stat);
      return WASI_ERRNO.SUCCESS;
    },

    fd_filestat_set_size(fd, size) {
      const entry = entryOf(fd);
      if (entry === undefined) return WASI_ERRNO.BADF;
      if (entry.clientFd === undefined) return WASI_ERRNO.ISDIR;
      if (!entry.writable) return WASI_ERRNO.NOTCAPABLE;
      // The store resizes by PATH only, which is why every fd remembers the path it was opened with.
      return client.truncate(entry.path, toBigInt(size)).errno;
    },

    fd_filestat_set_times(fd, _atim, _mtim, fstflags) {
      const entry = entryOf(fd);
      if (entry === undefined) return WASI_ERRNO.BADF;
      if ((fstflags & ~0b1111) !== 0) return WASI_ERRNO.INVAL;
      if (fstflags === 0) return WASI_ERRNO.SUCCESS;
      // The broker exposes no `utimes`. Answering ENOTSUP is the honest answer: a timestamp kept in
      // this adapter alone would make two threads on ONE store disagree about a file's mtime.
      return WASI_ERRNO.NOTSUP;
    },

    fd_readdir(fd, bufPtr, bufLen, cookie, bufusedPtr) {
      const entry = entryOf(fd);
      if (entry === undefined) return WASI_ERRNO.BADF;
      if (!entry.isDir) return WASI_ERRNO.NOTDIR;
      const start = Number(toBigInt(cookie));
      if (!Number.isSafeInteger(start) || start < 0) return WASI_ERRNO.INVAL;
      // Snapshot on cookie 0 so pagination is stable across guest mutations — and so a stale cookie
      // can never be forwarded to the broker, which treats a cursor past the listing as a protocol
      // violation and detaches the client.
      if (start === 0 || entry.listing === undefined) {
        const listed = client.readdir(entry.path);
        if (listed.errno !== 0) return listed.errno;
        entry.listing = { names: listed.entries, filetypes: new Map() };
      }
      const listing = entry.listing;
      const data = view();
      const memory = bytes();
      let used = 0;
      for (let index = start; index < listing.names.length; index += 1) {
        const name = listing.names[index]!;
        const nameBytes = textEncoder.encode(name);
        if (used + DIRENT_HEADER_BYTES >= bufLen) {
          // No room for another header: report the buffer full so the caller grows it and retries.
          used = bufLen;
          break;
        }
        let filetype = listing.filetypes.get(name);
        if (filetype === undefined) {
          // `lstat`, so a link is reported as one: a guest that walks a directory to decide what to
          // recurse into must see the link rather than whatever it points at.
          const child = client.lstat(entry.path === "/" ? `/${name}` : `${entry.path}/${name}`);
          filetype =
            child.errno !== 0 || child.stat === undefined ? WASI_FILETYPE.UNKNOWN : filetypeOf(child.stat.kind);
          listing.filetypes.set(name, filetype);
        }
        const at = bufPtr + used;
        data.setBigUint64(at, BigInt(index + 1), true); // d_next
        data.setBigUint64(at + 8, inodeOf(entry.path === "/" ? `/${name}` : `${entry.path}/${name}`), true); // d_ino
        data.setUint32(at + 16, nameBytes.byteLength, true); // d_namlen
        data.setUint8(at + 20, filetype);
        data.setUint8(at + 21, 0);
        data.setUint16(at + 22, 0, true);
        const room = bufLen - used - DIRENT_HEADER_BYTES;
        const copied = Math.min(nameBytes.byteLength, room);
        memory.set(nameBytes.subarray(0, copied), at + DIRENT_HEADER_BYTES);
        used += DIRENT_HEADER_BYTES + copied;
        if (copied < nameBytes.byteLength) break; // truncated name: the caller grows the buffer
      }
      data.setUint32(bufusedPtr, used, true);
      return WASI_ERRNO.SUCCESS;
    },

    fd_sync(fd) {
      const entry = entryOf(fd);
      if (entry === undefined) return WASI_ERRNO.BADF;
      // Store-wide by construction: on success every byte any client wrote before the call is durable.
      return entry.clientFd === undefined ? client.fsync().errno : client.fsync(entry.clientFd).errno;
    },

    fd_datasync(fd) {
      return fs.fd_sync(fd);
    },

    fd_allocate(fd, offset, length) {
      const entry = entryOf(fd);
      if (entry === undefined) return WASI_ERRNO.BADF;
      if (entry.clientFd === undefined) return WASI_ERRNO.ISDIR;
      if (!entry.writable) return WASI_ERRNO.NOTCAPABLE;
      const end = toBigInt(offset) + toBigInt(length);
      if (end < 0n) return WASI_ERRNO.INVAL;
      const current = sizeOf(entry);
      if (current.errno !== 0) return current.errno;
      // The store has no space RESERVATION primitive, so this extends by truncation instead. That
      // satisfies everything `posix_fallocate` observably promises — the file is at least that big,
      // existing bytes are untouched, the gap reads as zeros — but it does not pre-commit capacity, so
      // a later write into the range can still fail on a full store.
      if (end <= current.size) return WASI_ERRNO.SUCCESS;
      return client.truncate(entry.path, end).errno;
    },

    fd_advise(fd, _offset, _length, _advice) {
      return entryOf(fd) === undefined ? WASI_ERRNO.BADF : WASI_ERRNO.SUCCESS;
    },

    path_open(dirfd, dirflags, pathPtr, pathLen, oflags, rightsBase, rightsInheriting, fdflags, resultPtr) {
      const path = resolve(dirfd, pathPtr, pathLen);
      if (path === undefined) return WASI_ERRNO.BADF;
      const followLinks = (dirflags & LOOKUPFLAGS_SYMLINK_FOLLOW) !== 0;
      const base = toBigInt(rightsBase);
      const inheriting = toBigInt(rightsInheriting);
      const wantsRead = (base & RIGHTS_FD_READ) !== 0n;
      const wantsWrite = (base & RIGHTS_FD_WRITE) !== 0n;
      const create = (oflags & OFLAGS_CREAT) !== 0;
      const exclusive = (oflags & OFLAGS_EXCL) !== 0;
      const truncate = (oflags & OFLAGS_TRUNC) !== 0;
      const directory = (oflags & OFLAGS_DIRECTORY) !== 0;
      // A request that asks for neither right is a plain read-only open.
      const readable = wantsRead || !wantsWrite;
      const writable = wantsWrite;

      const openDirectory = (): number => {
        const stat = followLinks ? client.stat(path) : client.lstat(path);
        if (stat.errno !== 0 || stat.stat === undefined) return stat.errno || WASI_ERRNO.IO;
        // Without SYMLINK_FOLLOW a final component that IS a link must not be opened through.
        if (stat.stat.kind === "symlink") return WASI_ERRNO.LOOP;
        if (stat.stat.kind !== "directory") return WASI_ERRNO.NOTDIR;
        if (create && exclusive) return WASI_ERRNO.EXIST;
        const fd = allocate({
          path,
          isDir: true,
          clientFd: undefined,
          offset: 0n,
          fdflags: 0,
          rightsBase: base === 0n ? RIGHTS_ALL : base,
          rightsInheriting: inheriting === 0n ? RIGHTS_ALL : inheriting,
          readable: true,
          writable: false,
          listing: undefined,
        });
        view().setUint32(resultPtr, fd, true);
        return WASI_ERRNO.SUCCESS;
      };

      // With O_DIRECTORY the path must never be created as a file, so `open` is not attempted at all.
      if (directory) return openDirectory();

      // `planOpen` refuses O_CREAT/O_TRUNC without write access, but POSIX `open(O_RDONLY|O_CREAT)` is
      // perfectly legal — so the STORE descriptor is opened wide enough to satisfy the plan while the
      // adapter keeps the narrower access the rights asked for and rejects a later write itself.
      const posixWrite = writable || create || truncate;
      const posixRead = readable || !posixWrite;
      let flags = posixRead && posixWrite ? O_RDWR : posixWrite ? O_WRONLY : O_RDONLY;
      if (create) flags |= O_CREAT;
      if (exclusive) flags |= O_EXCL;
      if (truncate) flags |= O_TRUNC;
      // The broker follows a final link by default; the absent lookup flag is what turns that off.
      if (!followLinks) flags |= O_NOFOLLOW;
      // O_APPEND is never sent: it is emulated on this side so the adapter's offset stays exact and
      // `fd_fdstat_set_flags` can add or remove APPEND on an already-open descriptor.

      const opened = client.open(path, flags);
      if (opened.errno !== 0) {
        // The store cannot open a directory at all — it answers EISDIR — so that answer is how a
        // directory is recognised on the common path, at no cost to a file open.
        if (opened.errno === WASI_ERRNO.ISDIR) return create && exclusive ? WASI_ERRNO.EXIST : openDirectory();
        return opened.errno;
      }
      const fd = allocate({
        path,
        isDir: false,
        clientFd: opened.fd,
        offset: 0n,
        fdflags: fdflags & (FDFLAGS_APPEND | FDFLAGS_DSYNC | FDFLAGS_NONBLOCK | FDFLAGS_RSYNC | FDFLAGS_SYNC),
        rightsBase: base === 0n ? RIGHTS_ALL : base,
        rightsInheriting: inheriting === 0n ? RIGHTS_ALL : inheriting,
        readable,
        writable,
        listing: undefined,
      });
      view().setUint32(resultPtr, fd, true);
      return WASI_ERRNO.SUCCESS;
    },

    path_filestat_get(dirfd, flags, pathPtr, pathLen, resultPtr) {
      const path = resolve(dirfd, pathPtr, pathLen);
      if (path === undefined) return WASI_ERRNO.BADF;
      // The split that makes `std::fs::symlink_metadata` differ from `std::fs::metadata`: without
      // the flag the LINK is reported, with it the thing it points at.
      const follow = (flags & LOOKUPFLAGS_SYMLINK_FOLLOW) !== 0;
      const stat = follow ? client.stat(path) : client.lstat(path);
      if (stat.errno !== 0 || stat.stat === undefined) return stat.errno || WASI_ERRNO.IO;
      writeFilestat(resultPtr, path, stat.stat);
      return WASI_ERRNO.SUCCESS;
    },

    path_filestat_set_times(dirfd, _flags, pathPtr, pathLen, _atim, _mtim, fstflags) {
      const path = resolve(dirfd, pathPtr, pathLen);
      if (path === undefined) return WASI_ERRNO.BADF;
      if ((fstflags & ~0b1111) !== 0) return WASI_ERRNO.INVAL;
      const stat = client.lstat(path);
      if (stat.errno !== 0) return stat.errno;
      if (fstflags === 0) return WASI_ERRNO.SUCCESS;
      return WASI_ERRNO.NOTSUP; // see fd_filestat_set_times
    },

    path_create_directory(dirfd, pathPtr, pathLen) {
      const path = resolve(dirfd, pathPtr, pathLen);
      if (path === undefined) return WASI_ERRNO.BADF;
      return client.mkdir(path).errno;
    },

    path_remove_directory(dirfd, pathPtr, pathLen) {
      const path = resolve(dirfd, pathPtr, pathLen);
      if (path === undefined) return WASI_ERRNO.BADF;
      return client.rmdir(path).errno;
    },

    path_unlink_file(dirfd, pathPtr, pathLen) {
      const path = resolve(dirfd, pathPtr, pathLen);
      if (path === undefined) return WASI_ERRNO.BADF;
      return client.unlink(path).errno;
    },

    path_rename(dirfd, oldPtr, oldLen, newDirfd, newPtr, newLen) {
      const oldPath = resolve(dirfd, oldPtr, oldLen);
      const newPath = resolve(newDirfd, newPtr, newLen);
      if (oldPath === undefined || newPath === undefined) return WASI_ERRNO.BADF;
      const result = client.rename(oldPath, newPath);
      if (result.errno !== 0) return result.errno;
      // Every descriptor still open on the old path must follow it, or a later `fd_filestat_set_size`
      // would resize whatever now occupies the source name.
      const prefix = oldPath === "/" ? "/" : `${oldPath}/`;
      for (const entry of table.values()) {
        if (entry.path === oldPath) entry.path = newPath;
        else if (entry.path.startsWith(prefix)) entry.path = newPath + entry.path.slice(oldPath.length);
      }
      return WASI_ERRNO.SUCCESS;
    },

    path_readlink(dirfd, pathPtr, pathLen, bufPtr, bufLen, bufusedPtr) {
      const path = resolve(dirfd, pathPtr, pathLen);
      if (path === undefined) return WASI_ERRNO.BADF;
      // EINVAL for a path that is not a link, ENOENT for one that is not there — both come from the
      // store, whose errnos already ARE these numbers.
      const link = client.readlink(path);
      if (link.errno !== 0 || link.target === undefined) return link.errno || WASI_ERRNO.IO;
      const encoded = textEncoder.encode(link.target);
      // preview1 writes no terminator and truncates silently; `bufused === buflen` is how a caller
      // learns its buffer was too small, so a short buffer must NOT be an error here.
      const copied = Math.min(encoded.byteLength, bufLen);
      if (copied > 0) bytes().set(encoded.subarray(0, copied), bufPtr);
      view().setUint32(bufusedPtr, copied, true);
      return WASI_ERRNO.SUCCESS;
    },

    path_symlink(oldPtr, oldLen, dirfd, newPtr, newLen) {
      const path = resolve(dirfd, newPtr, newLen);
      if (path === undefined) return WASI_ERRNO.BADF;
      // The TARGET is the link's contents, not a path to walk: it is read verbatim (bar a trailing
      // NUL from a fixed-size guest buffer) and stored as given. The store takes absolute targets
      // only and answers EINVAL for anything else.
      const target = readGuestString(oldPtr, oldLen).replace(/\0+$/u, "");
      return client.symlink(target, path).errno;
    },

    path_link(oldDirfd, _oldFlags, _oldPtr, _oldLen, newDirfd, _newPtr, _newLen) {
      if (entryOf(oldDirfd)?.isDir !== true || entryOf(newDirfd)?.isDir !== true) return WASI_ERRNO.BADF;
      return WASI_ERRNO.NOTSUP; // the store has no hard links
    },
  };

  // ---- failure containment -------------------------------------------------
  // A JS exception thrown out of a WASI import unwinds through the guest's nounwind frames and
  // surfaces as a bare `RuntimeError: unreachable` with nothing attached. Every call is therefore
  // wrapped: the guest sees EIO and the host sees the stack.
  const guarded: Record<string, unknown> = {};
  for (const name of Object.keys(fs) as (keyof WasiPreview1FsFunctions)[]) {
    const inner = fs[name] as (...args: unknown[]) => number;
    guarded[name] = (...args: unknown[]): number => {
      try {
        return inner(...args);
      } catch (cause) {
        onError(name, cause);
        return WASI_ERRNO.IO;
      }
    };
  }

  const adapter = guarded as unknown as WasiPreview1Fs;

  adapter.owns = owns;

  adapter.openFdCount = (): number => table.size - (table.has(preopenFd) ? 1 : 0);

  adapter.closeAll = (): number => {
    let released = 0;
    for (const entry of table.values()) {
      if (entry.clientFd === undefined) continue;
      client.close(entry.clientFd);
      released += 1;
    }
    table.clear();
    table.set(preopenFd, preopenEntry());
    nextFd = fdBase;
    return released;
  };

  adapter.compose = (base: Readonly<Record<string, unknown>>): Record<string, unknown> => {
    const merged: Record<string, unknown> = { ...base };
    for (const [name, index] of Object.entries(FD_ARGUMENT_INDEX)) {
      const mine = guarded[name] as (...args: unknown[]) => number;
      const theirs = base[name];
      if (typeof theirs !== "function") {
        // The base host does not implement this call at all (`fd_tell`, `fd_allocate` and friends are
        // commonly absent): the adapter answers for its own fds and reports EBADF for anything else,
        // which is exactly what an absent implementation would have to say.
        merged[name] = (...args: unknown[]): number => (owns(Number(args[index])) ? mine(...args) : WASI_ERRNO.BADF);
        continue;
      }
      const fallback = theirs as (...args: unknown[]) => number;
      merged[name] = (...args: unknown[]): number => (owns(Number(args[index])) ? mine(...args) : fallback(...args));
    }
    return merged;
  };

  return adapter;
}
