import fs from 'fs/promises';
import fsSync from 'fs';
import os from 'os';
import path from 'path';
import crypto from 'crypto';
import { execFileSync } from 'child_process';
import { isMainThread } from 'worker_threads';
import type lbug from '@ladybugdb/core';
import { logger } from '../logger.js';

// ─── Windows non-ASCII path workaround (#1811) ───────────────────────────────
//
// KuzuDB's native C++ layer on Windows uses CreateFileA (ANSI), not
// CreateFileW. Non-ASCII path bytes from Node.js (UTF-8) are
// misinterpreted via the system's Active Code Page (e.g. GBK), producing
// a garbled path — "Error 3: The system cannot find the path."
//
// Layered workaround:
//   1. Try 8.3 short-name form (fast, no persistent state)
//   2. Fall back to an NTFS junction from an ASCII temp path
//   3. If both fail, log a diagnostic and return the original path

const NON_ASCII_RE = /[^\x00-\x7F]/;
const JUNCTION_PREFIX = 'gitnexus-junction-';

const activeJunctions = new Set<string>();
let cleanupRegistered = false;
let orphanScanDone = false;

function junctionHash(targetDir: string): string {
  return crypto.createHash('sha256').update(targetDir).digest('hex').slice(0, 16);
}

function tryShortPath(p: string): string | null {
  try {
    // Pass the path via environment variable so the command string is
    // static — avoids CodeQL command-injection taint (the path never
    // appears in the shell command text).
    const result = execFileSync('cmd.exe', ['/c', 'for %I in ("%GITNEXUS_SP%") do @echo %~sI'], {
      encoding: 'utf-8',
      timeout: 5000,
      windowsHide: true,
      stdio: ['pipe', 'pipe', 'pipe'],
      env: { ...process.env, GITNEXUS_SP: p },
    });
    const shortPath = result.trim();
    if (
      shortPath &&
      !NON_ASCII_RE.test(shortPath) &&
      (!shortPath.includes('?') || p.includes('?'))
    ) {
      return shortPath;
    }
  } catch {
    // 8.3 unavailable or cmd failed
  }
  return null;
}

function tryJunction(targetDir: string, leaf: string): string | null {
  const hash = junctionHash(targetDir);
  const junctionLink = path.join(os.tmpdir(), `${JUNCTION_PREFIX}${hash}`);

  if (fsSync.existsSync(junctionLink)) {
    try {
      const existing = fsSync.readlinkSync(junctionLink);
      if (path.resolve(existing) === path.resolve(targetDir)) {
        activeJunctions.add(junctionLink);
        return path.join(junctionLink, leaf);
      }
      fsSync.rmSync(junctionLink, { recursive: true, force: true });
    } catch {
      // Stale or broken junction — remove and recreate
      try {
        fsSync.rmSync(junctionLink, { recursive: true, force: true });
      } catch {
        /* best effort */
      }
    }
  }

  try {
    fsSync.symlinkSync(targetDir, junctionLink, 'junction');
    activeJunctions.add(junctionLink);
    return path.join(junctionLink, leaf);
  } catch (err: any) {
    if (err.code === 'EEXIST') {
      try {
        const existing = fsSync.readlinkSync(junctionLink);
        if (path.resolve(existing) === path.resolve(targetDir)) {
          activeJunctions.add(junctionLink);
          return path.join(junctionLink, leaf);
        }
      } catch {
        /* cannot verify — fall through */
      }
    }
  }
  return null;
}

function registerCleanupHandlers(): void {
  if (cleanupRegistered) return;
  cleanupRegistered = true;

  process.on('exit', () => cleanupNativePathJunctions());

  for (const signal of ['SIGTERM', 'SIGINT'] as const) {
    process.on(signal, () => {
      cleanupNativePathJunctions();
      if (process.platform === 'win32') {
        process.exit(signal === 'SIGINT' ? 130 : 143);
      } else {
        process.kill(process.pid, signal);
      }
    });
  }
}

function scanOrphanedJunctions(): void {
  if (orphanScanDone) return;
  orphanScanDone = true;
  try {
    const tmpdir = os.tmpdir();
    const entries = fsSync.readdirSync(tmpdir);
    for (const entry of entries) {
      if (!entry.startsWith(JUNCTION_PREFIX)) continue;
      const junctionPath = path.join(tmpdir, entry);
      try {
        const target = fsSync.readlinkSync(junctionPath);
        try {
          fsSync.lstatSync(target);
        } catch {
          fsSync.rmSync(junctionPath, { recursive: true, force: true });
        }
      } catch {
        // Not a symlink/junction or unreadable — leave it
      }
    }
  } catch {
    // tmpdir unreadable — skip scan
  }
}

export function cleanupNativePathJunctions(): void {
  for (const junctionPath of activeJunctions) {
    try {
      fsSync.rmSync(junctionPath, { recursive: true, force: true });
    } catch {
      // Best effort — EPERM on Windows is common during exit
    }
  }
  activeJunctions.clear();
}

export function toNativeSafePath(p: string): string {
  if (process.platform !== 'win32') return p;
  if (!NON_ASCII_RE.test(p)) return p;

  if (isMainThread) {
    scanOrphanedJunctions();
    registerCleanupHandlers();
  }

  const shortPath = tryShortPath(p);
  if (shortPath) return shortPath;

  if (!isMainThread) {
    logger.warn(
      `GitNexus: non-ASCII path in worker thread — junction fallback skipped. ` +
        `Path: "${p}". 8.3 short names may need to be enabled on this volume.`,
    );
    return p;
  }

  const targetDir = path.dirname(p);
  const leaf = path.basename(p);
  if (fsSync.existsSync(targetDir)) {
    const junctionResult = tryJunction(targetDir, leaf);
    if (junctionResult) return junctionResult;
  }

  logger.warn(
    `GitNexus: non-ASCII path "${p}" could not be converted to an ASCII-safe form. ` +
      'LadybugDB may fail with "Cannot open file." To fix: move the repo to a path ' +
      'without CJK/Unicode characters, or enable 8.3 short names on this volume ' +
      '(fsutil 8dot3name set 0).',
  );
  return p;
}

/**
 * Shared configuration for `@ladybugdb/core` `Database` construction.
 *
 * Two values changed meaningfully in `@ladybugdb/core` 0.16.0 and need to be
 * pinned explicitly by every caller, otherwise GitNexus regresses:
 *
 * 1. `maxDBSize` defaults to `0`, which the native runtime interprets as
 *    "use the platform's full mmap address space" — typically 8 TB on
 *    64-bit Linux. Constrained environments (CI runners, containers, WSL)
 *    cannot reserve that much address space and crash with
 *    `Buffer manager exception: Mmap for size 8796093022208 failed.`
 *    See LadybugDB upstream JSDoc:
 *    > "introduced temporarily for now to get around with the default 8TB
 *    > mmap address space limit some environment".
 *
 * 2. `enableCompression` flipped its default from `false` (0.15.x) to
 *    `true` (0.16.0). Existing call sites that relied on the positional
 *    default must now pass `false` explicitly to preserve behaviour.
 *
 * Putting both in one shared module guarantees every `new lbug.Database(...)`
 * call site agrees on the same ceiling and behaviour.
 */

/**
 * Upper bound for any single GitNexus LadybugDB file (graph index, group
 * bridge, install scratch, test fixture). 16 GiB is intentionally generous
 * for real-world code graphs (the GitNexus self-index uses < 50 MiB) while
 * remaining far below any 64-bit OS mmap ceiling.
 *
 * Override with the `GITNEXUS_LBUG_MAX_DB_SIZE` environment variable when
 * indexing genuinely huge monorepos. Values are coerced to a positive
 * integer; anything invalid falls back to the default.
 */
export const LBUG_MAX_DB_SIZE: number = (() => {
  const raw = process.env.GITNEXUS_LBUG_MAX_DB_SIZE;
  if (raw) {
    const parsed = Number(raw);
    if (Number.isFinite(parsed) && parsed > 0) return Math.floor(parsed);
  }
  return 16 * 1024 * 1024 * 1024;
})();

export const parseWalCheckpointThreshold = (raw: string | undefined): number | undefined => {
  if (raw === undefined) return undefined;
  const normalized = raw.trim();
  if (normalized.length === 0) return undefined;
  const parsed = Number(normalized);
  if (!Number.isInteger(parsed) || parsed < -1) return undefined;
  return parsed;
};

/**
 * Default GitNexus WAL auto-checkpoint threshold in bytes (64 MiB).
 *
 * Larger than Ladybug's stock ~16 MiB to reduce checkpoint rename/remove
 * churn under heavy analyze write load — the original race that motivated
 * issue #1741 triggered at the stock threshold. README examples in
 * `README.md` and `gitnexus/README.md` and the recovery hint in
 * `analyze.ts` MUST stay in sync with this value.
 */
const DEFAULT_WAL_CHECKPOINT_THRESHOLD = 64 * 1024 * 1024;

const resolveCheckpointThreshold = (): number => {
  const raw = process.env.GITNEXUS_WAL_CHECKPOINT_THRESHOLD;
  if (raw === undefined) return DEFAULT_WAL_CHECKPOINT_THRESHOLD;
  const parsed = parseWalCheckpointThreshold(raw);
  if (parsed !== undefined) return parsed;
  // Non-empty but unparseable input: warn the operator and fall back. Mirrors
  // the CLI's `--wal-checkpoint-threshold` validation (which hard-errors)
  // but the env-var path stays soft to preserve "set once in your shell"
  // ergonomics across mixed-version invocations.
  if (raw.trim().length > 0) {
    logger.warn(
      { rawValue: raw, fallback: DEFAULT_WAL_CHECKPOINT_THRESHOLD },
      `Ignoring invalid GITNEXUS_WAL_CHECKPOINT_THRESHOLD=${raw}; expected integer >= -1; falling back to default (${DEFAULT_WAL_CHECKPOINT_THRESHOLD}).`,
    );
  }
  return DEFAULT_WAL_CHECKPOINT_THRESHOLD;
};

/** Matches WAL corruption errors from the LadybugDB engine. */
const WAL_CORRUPTION_RE = /corrupt(ed)?\s+wal|invalid\s+wal\s+record|wal.*corrupt|checksum.*wal/i;

export const WAL_RECOVERY_SUGGESTION =
  'WAL corruption detected. Run `gitnexus analyze --force` to rebuild the index.';

export function isWalCorruptionError(err: unknown): boolean {
  if (!err) return false;
  const msg = err instanceof Error ? err.message : String(err);
  return WAL_CORRUPTION_RE.test(msg);
}

// ─── Ladybug WAL checkpoint IO error matchers ───────────────────────────────
//
// Matched against LadybugDB v0.16.1 (see `gitnexus/package.json`
// @ladybugdb/core). Strict regexes encode local_file_system.cpp wording
// verified at that version. Two-tier strategy: strict matchers first so we
// only fire on real checkpoint-rotation shapes; a permissive fallback
// catches future Ladybug message drift so the recovery hint keeps surfacing
// even if upstream wording changes.
//
// From Ladybug native LocalFileSystem exceptions (`local_file_system.cpp`),
// surfaced in Node as:
// "Runtime exception: IO exception: Error renaming file ..."
// "Runtime exception: IO exception: Error removing directory or file ..."
// We only match checkpoint-rotation shapes:
//   - "<db>.wal -> <db>.wal.checkpoint" rename failures
//   - "<db>.wal.checkpoint" remove failures
// Example matches:
//   "Runtime exception: IO exception: Error renaming file /x/lbug.wal to /x/lbug.wal.checkpoint. ErrorMessage: Permission denied"
//   "Runtime exception: IO exception: Error removing directory or file /x/lbug.wal.checkpoint.  Error Message: Permission denied"
// Matching is case-insensitive to remain robust across wrappers/platforms.
const LBUG_CHECKPOINT_RENAME_RE =
  /^runtime exception: io exception:\s*error renaming file\s+.+?\.wal\s+to\s+.+?\.wal\.checkpoint(?:\.|\s|$)/i;
const LBUG_CHECKPOINT_REMOVE_RE =
  /^runtime exception: io exception:\s*error removing directory or file\s+.+?\.wal\.checkpoint(?:\.|\s|$)/i;
/**
 * Permissive fallback: any IO-exception-shaped message that mentions a
 * `.wal.checkpoint` path. Catches future Ladybug message drift (different
 * verb, additional preamble, locale variation) so the recovery hint keeps
 * surfacing even if the strict regexes go stale.
 */
const LBUG_CHECKPOINT_PERMISSIVE_RE = /io exception.*\.wal\.checkpoint/i;

/**
 * True when `err` looks like a Ladybug WAL-checkpoint rotation/remove IO
 * failure. Tries strict matchers first (renames + removes), then falls
 * back to the permissive matcher.
 */
export const isLbugCheckpointIoError = (err: unknown): boolean => {
  if (!err) return false;
  const msg = err instanceof Error ? err.message : String(err);
  if (LBUG_CHECKPOINT_RENAME_RE.test(msg) || LBUG_CHECKPOINT_REMOVE_RE.test(msg)) return true;
  return LBUG_CHECKPOINT_PERMISSIVE_RE.test(msg);
};

type LbugModule = typeof lbug;

export interface LbugDatabaseOptions {
  readOnly?: boolean;
  throwOnWalReplayFailure?: boolean;
}

export interface LbugConnectionHandle {
  db: lbug.Database;
  conn: lbug.Connection;
}

/**
 * Return true when the error message indicates that a LadybugDB file lock
 * could not be acquired — either at construction time
 * (`new lbug.Database(...)` raises from `local_file_system.cpp`) or during
 * a query (another writer holds the exclusive lock).
 *
 * Lives here (not in `lbug-adapter.ts`) so both the construction-time
 * retry (`openWithLockRetry` in this file) and the query-time retry
 * (`withLbugDb` in `lbug-adapter.ts`) consult the same matcher. Callers
 * import directly from this module — no re-export to keep in sync.
 */
export const isDbBusyError = (err: unknown): boolean => {
  const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
  // `lock` already subsumes `could not set lock`; the broader term is kept
  // because graph-DB transient errors include "deadlock", "lock contention",
  // and the LadybugDB native module's "could not set lock on file" — all of
  // which deserve a retry. If a non-transient lock-shaped error ever
  // surfaces (e.g., "lock file missing" during recovery), tighten this
  // matcher rather than raising the retry budget.
  return msg.includes('busy') || msg.includes('lock') || msg.includes('already in use');
};

export function createLbugDatabase(
  lbugModule: LbugModule,
  databasePath: string,
  options: LbugDatabaseOptions = {},
): lbug.Database {
  // .d.ts declares fewer args than the native constructor accepts.
  return new (lbugModule.Database as any)(
    databasePath,
    0, // bufferManagerSize
    false, // enableCompression (pinned for v0.16.0)
    options.readOnly ?? false,
    LBUG_MAX_DB_SIZE,
    true, // autoCheckpoint (always on)
    resolveCheckpointThreshold(), // checkpointThreshold (default 64 MiB; override with GITNEXUS_WAL_CHECKPOINT_THRESHOLD; -1 keeps Ladybug stock ~16 MiB)
    options.throwOnWalReplayFailure ?? true,
    true, // enableChecksums
  ) as lbug.Database;
}

// ─── Lock-busy retry tuning knobs ───────────────────────────────────────────
//
// All four GitNexus retry pairs that touch native LadybugDB locks live with
// a comment cross-reference here so an SRE tuning Windows flakes finds them
// in one grep:
//
//   1. OPEN_LOCK_RETRY_ATTEMPTS / OPEN_LOCK_RETRY_DELAY_MS  (this file)
//      → `new lbug.Database()` constructor lock failures
//   2. HANDLE_RELEASE_PROBE_ATTEMPTS / HANDLE_RELEASE_PROBE_DELAY_MS  (this file)
//      → post-close fs.open probe to absorb Windows handle-release lag
//   3. DB_LOCK_RETRY_ATTEMPTS / DB_LOCK_RETRY_DELAY_MS  (lbug-adapter.ts withLbugDb)
//      → query-time busy/lock retry around already-open connections
//
// `new lbug.Database()` calls into the native module which performs an
// OS-level exclusive lock on `<dbPath>`. On Windows that lock can fail
// for reasons specific to the OS (Defender briefly opens new files,
// libuv handle release lags the JS-side close). 5 attempts × 100ms
// linear back-off (max sleep 100+200+300+400 = 1s, plus 5 ctor RTTs
// of 10–50ms each = ~1.0–1.2s worst case) clears the typical
// AV-scanner hold without masking real cross-process conflicts.
//
// Source: https://github.com/LadybugDB/ladybug/blob/v0.16.1/src/common/file_system/local_file_system.cpp#L126
const OPEN_LOCK_RETRY_ATTEMPTS = 5;
const OPEN_LOCK_RETRY_DELAY_MS = 100;

const HANDLE_RELEASE_PROBE_ATTEMPTS = 5;
const HANDLE_RELEASE_PROBE_DELAY_MS = 50;
const HANDLE_RELEASE_LOCK_CODES = new Set(['EBUSY', 'EPERM', 'EACCES']);

/**
 * Test-fixture directory prefixes recognized by `isTestFixturePath`.
 *
 * IMPORTANT: this list must stay in sync with the prefixes passed to
 * `createTempDir` in `gitnexus/test/helpers/test-db.ts` and the prefixes
 * used by `withTestLbugDB` (`gitnexus/test/helpers/test-indexed-db.ts`).
 * If you add a new test that passes a custom prefix to `createTempDir`,
 * add it here too — otherwise the stale-sidecar sweep silently won't
 * fire for that fixture and CI flakes return.
 *
 * The default `createTempDir('gitnexus-test-')` and the lbug variant
 * `'gitnexus-lbug-'` cover today's call sites.
 */
const TEST_FIXTURE_PREFIXES = ['gitnexus-lbug-', 'gitnexus-test-'];

/**
 * Marker symbol attached to lock errors after `openWithLockRetry` exhausts
 * its budget. `withLbugDb`'s outer query-time retry consults this so it
 * does not re-retry a path that just spent up to ~1.5s in the open-time
 * loop — preventing 6s tail latencies (3× outer × 5× inner attempts).
 *
 * The symbol is internal to GitNexus; consumers should treat the underlying
 * error message as the user-visible signal.
 */
export const LBUG_OPEN_RETRY_EXHAUSTED = Symbol.for('gitnexus.lbug.openRetryExhausted');

export const isOpenRetryExhausted = (err: unknown): boolean => {
  if (err === null || err === undefined || typeof err !== 'object') return false;
  return (err as { [LBUG_OPEN_RETRY_EXHAUSTED]?: boolean })[LBUG_OPEN_RETRY_EXHAUSTED] === true;
};

const tagOpenRetryExhausted = (err: unknown): unknown => {
  if (err && typeof err === 'object') {
    (err as { [LBUG_OPEN_RETRY_EXHAUSTED]?: boolean })[LBUG_OPEN_RETRY_EXHAUSTED] = true;
  }
  return err;
};

/**
 * True when `dbPath` resolves to a recognized test fixture under the OS
 * temp directory. Used to gate the stale-sidecar sweep so production
 * paths never have their `.wal` / `.lock` files deleted.
 *
 * Defensive shape:
 *   - `path.resolve` normalizes `..` segments before the prefix check, so
 *     `<tmp>/gitnexus-lbug-x/../../etc/passwd` is rejected.
 *   - The tmpRoot check trims any trailing separator returned by some
 *     Windows TMP configurations (`C:\Users\X\Temp\`) so the startsWith
 *     comparison stays correct.
 *   - Only the IMMEDIATE parent directory is matched against the prefix
 *     list. An ancestor walk would let a tmpdir whose own basename starts
 *     with `gitnexus-lbug-` accept arbitrary nested paths under it.
 */
const isTestFixturePath = (dbPath: string): boolean => {
  const tmpRoot = os.tmpdir().replace(new RegExp(`${path.sep === '\\' ? '\\\\' : path.sep}+$`), '');
  const resolved = path.resolve(dbPath);
  if (!resolved.startsWith(tmpRoot + path.sep) && resolved !== tmpRoot) return false;
  const parentBase = path.basename(path.dirname(resolved));
  return TEST_FIXTURE_PREFIXES.some((p) => parentBase.startsWith(p));
};

/** Exported only for direct unit testing — production callers use `openWithLockRetry`. */
export const _isTestFixturePathForTest = isTestFixturePath;

const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));

/**
 * Attempt to remove stale `.wal` / `.lock` sidecars that a previous aborted
 * test run may have left behind. Best-effort: ENOENT is normal, anything
 * else is swallowed so the caller's retry can surface the original error.
 */
const sweepStaleSidecars = async (dbPath: string): Promise<void> => {
  for (const suffix of ['.wal', '.lock']) {
    try {
      await fs.unlink(dbPath + suffix);
    } catch {
      /* missing sidecar or permission error — let the open retry surface it */
    }
  }
};

/**
 * Run `construct` with bounded retries when `new lbug.Database(...)` throws
 * a busy/lock error. The original (loop-captured) error is preferred over
 * any post-sweep error so triage sees the real LadybugDB lock message.
 * On exhaustion the rethrown error is tagged via
 * `LBUG_OPEN_RETRY_EXHAUSTED` so the outer query-time retry in
 * `withLbugDb` skips re-retrying a freshly-exhausted path.
 */
const openWithLockRetry = async (
  construct: () => lbug.Database,
  dbPath: string,
): Promise<lbug.Database> => {
  let originalLockError: unknown;
  for (let attempt = 1; attempt <= OPEN_LOCK_RETRY_ATTEMPTS; attempt++) {
    try {
      return construct();
    } catch (err) {
      if (!isDbBusyError(err)) throw err;
      originalLockError = err;
      if (attempt === OPEN_LOCK_RETRY_ATTEMPTS) break;
      await sleep(OPEN_LOCK_RETRY_DELAY_MS * attempt);
    }
  }

  // Final defense: only for recognized test fixtures, sweep stale sidecars
  // (a prior aborted test run can leave a `.wal` lock that survives the
  // tmp dir cleanup). Production paths never reach this branch — the guard
  // requires the immediate parent dir to match a test prefix AND the
  // resolved path to live under the OS temp directory.
  if (isTestFixturePath(dbPath)) {
    await sweepStaleSidecars(dbPath);
    try {
      return construct();
    } catch {
      // Intentionally do NOT overwrite originalLockError. The user-actionable
      // signal is "we exhausted lock retries" — a different error from the
      // post-sweep attempt is less useful than the lock failure that drove
      // the sweep in the first place.
    }
  }
  throw tagOpenRetryExhausted(originalLockError);
};

export async function openLbugConnection(
  lbugModule: LbugModule,
  databasePath: string,
  options: LbugDatabaseOptions = {},
): Promise<LbugConnectionHandle> {
  const safePath = toNativeSafePath(databasePath);
  let db: lbug.Database | undefined;
  try {
    db = await openWithLockRetry(() => createLbugDatabase(lbugModule, safePath, options), safePath);
    return { db, conn: new lbugModule.Connection(db) };
  } catch (err) {
    if (db) await db.close().catch(() => {});
    throw err;
  }
}

export async function closeLbugConnection(handle: LbugConnectionHandle): Promise<void> {
  await handle.conn.close().catch(() => {});
  await handle.db.close().catch(() => {});
}

/**
 * Probe `dbPath` AND its `.wal` sidecar after `db.close()` so any
 * residual native file handle surfaces as EBUSY/EPERM/EACCES and the
 * bounded retry absorbs the release lag. Windows-only — Linux/macOS do
 * not exhibit this race.
 *
 * Both files matter. Empirically, on rapid open→close→reopen cycles the
 * main `dbPath` handle releases first; the `.wal` handle from the
 * previous Database lingers and the new Database's first write (CREATE
 * NODE TABLE during schema init) fails with "Could not set lock on
 * file". Probing both makes safeClose actually return when the kernel
 * is fully done with the path.
 *
 * Returns `true` when both probes succeeded (or skipped on non-lock
 * errors / missing files). Returns `false` when either probe exhausted
 * its budget with a lock code still in flight.
 *
 * Defensive shape:
 *   - Opens read+write (`'r+'`) so the probe actually surfaces exclusive
 *     locks held by the previous Database. A read-only probe (`'r'`) is
 *     insufficient — Windows will grant read access while the previous
 *     handle's exclusive write lock is still in flight, which lets
 *     `safeClose` return before the next CREATE NODE TABLE can lock the
 *     file.
 *   - `try/finally` around `handle.close()` guarantees no fd leak even
 *     if close itself throws.
 */
export const waitForWindowsHandleRelease = async (dbPath: string): Promise<boolean> => {
  const mainReleased = await probeSinglePath(dbPath);
  const walReleased = await probeSinglePath(dbPath + '.wal');
  return mainReleased && walReleased;
};

const probeSinglePath = async (filePath: string): Promise<boolean> => {
  for (let attempt = 1; attempt <= HANDLE_RELEASE_PROBE_ATTEMPTS; attempt++) {
    let handle: fs.FileHandle | undefined;
    try {
      handle = await fs.open(filePath, 'r+');
      return true;
    } catch (err) {
      const code = (err as NodeJS.ErrnoException | undefined)?.code;
      if (!code || !HANDLE_RELEASE_LOCK_CODES.has(code)) return true; // ENOENT / unrelated → not our problem
      if (attempt === HANDLE_RELEASE_PROBE_ATTEMPTS) return false;
      await sleep(HANDLE_RELEASE_PROBE_DELAY_MS * attempt);
    } finally {
      if (handle) {
        try {
          await handle.close();
        } catch {
          /* swallow — caller cannot do anything useful with a probe-close failure */
        }
      }
    }
  }
  return false;
};
