import { afterEach, describe, expect, test } from 'bun:test';
import { link, mkdir, mkdtemp, readFile, readdir, rm, stat, symlink, writeFile, chmod } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
  HOOK_CONTROL_REGISTRY,
  HookControlsError,
  hookControlsInternals,
  isRegisteredHookControl,
  readHookControls,
  repairHookControls,
  setHookControl,
} from '../src/hook-controls';

const dirs: string[] = [];
async function dir() { const value = await mkdtemp(join(tmpdir(), 'hook-controls-')); dirs.push(value); return value; }

async function waitForHandshake(path: string, timeoutMs = 5_000): Promise<void> {
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    try {
      await stat(path);
      return;
    } catch {
      await Bun.sleep(10);
    }
  }
  throw new Error(`timed out waiting for lock holder handshake at ${path}`);
}

async function reapChild(holder: ReturnType<typeof Bun.spawn>) {
  if (!holder.killed) holder.kill();
  await holder.exited;
}

afterEach(async () => {
  hookControlsInternals.reset();
  await Promise.all(dirs.splice(0).map((value) => rm(value, { recursive: true, force: true })));
});

describe('hook controls store', () => {
  test('materializes registry defaults and preserves future boolean keys', async () => {
    const root = await dir();
    expect((await readHookControls(root)).controls.hooks).toEqual({ 'background-jobs-blocker': HOOK_CONTROL_REGISTRY['background-jobs-blocker'].defaultEnabled });
    await writeFile(join(root, 'hook-controls.json'), JSON.stringify({ version: 'hook-controls/v1', hooks: { future: false } }));
    await setHookControl('background-jobs-blocker', false, root);
    expect(JSON.parse(await readFile(join(root, 'hook-controls.json'), 'utf8')).hooks).toEqual({ future: false, 'background-jobs-blocker': false });
    expect((await stat(root)).mode & 0o777).toBe(0o700);
    expect((await stat(join(root, 'hook-controls.json'))).mode & 0o777).toBe(0o600);
  });

  test('marks malformed bytes as repairable and nonregular targets as nonrepairable', async () => {
    const root = await dir();
    const invalid = '{broken';
    await writeFile(join(root, 'hook-controls.json'), invalid);
    expect((await readHookControls(root)).issue).toEqual({
      code: 'invalid-hook-controls',
      detail: 'hook-controls.json is not valid JSON',
      repairable: true,
    });
    const blocked = await dir();
    await mkdir(join(blocked, 'hook-controls.json'));
    expect((await readHookControls(blocked)).issue).toEqual({
      code: 'invalid-hook-controls',
      detail: 'hook-controls.json is not a regular file',
      repairable: false,
    });
    await expect(repairHookControls(blocked)).rejects.toMatchObject({ code: 'invalid' });
    expect((await readdir(blocked)).filter((name) => name === 'hook-controls.json')).toEqual(['hook-controls.json']);
  });

  test('repairs invalid bytes only after writing a durable private backup', async () => {
    const root = await dir(); const invalid = Buffer.from([0xff, 0x7b, 0x62, 0x72, 0x6f, 0x6b, 0x65, 0x6e]);
    await writeFile(join(root, 'hook-controls.json'), invalid);
    expect((await readHookControls(root)).issue?.code).toBe('invalid-hook-controls');
    await repairHookControls(root);
    const backups = (await readdir(root)).filter((name) => name.startsWith('hook-controls.json.corrupt.'));
    expect(backups).toHaveLength(1);
    expect(Buffer.compare(await readFile(join(root, backups[0]!)), invalid)).toBe(0);
    expect((await stat(join(root, backups[0]!))).mode & 0o777).toBe(0o600);
  });

  test('removes corruption backup when repair fails before rename commit', async () => {
    const root = await dir();
    const invalid = Buffer.from([0xff, 0x7b, 0x62, 0x72, 0x6f, 0x6b, 0x65, 0x6e]);
    await writeFile(join(root, 'hook-controls.json'), invalid);
    hookControlsInternals.setSyncFile(async (path) => {
      if (path.includes('.tmp')) throw new Error('pre-rename fsync failed');
    });
    await expect(repairHookControls(root)).rejects.toMatchObject({ code: 'write-failed' });
    expect((await readdir(root)).filter((name) => name.startsWith('hook-controls.json.corrupt.'))).toHaveLength(0);
    expect(Buffer.compare(await readFile(join(root, 'hook-controls.json')), invalid)).toBe(0);
  });

  test('surfaces backup cleanup failure when repair fails before rename commit', async () => {
    const root = await dir();
    const invalid = Buffer.from([0xff, 0x7b, 0x62, 0x72, 0x6f, 0x6b, 0x65, 0x6e]);
    await writeFile(join(root, 'hook-controls.json'), invalid);
    hookControlsInternals.setSyncFile(async (path) => {
      if (path.includes('.tmp')) throw new Error('pre-rename fsync failed');
    });
    hookControlsInternals.setRm(async (path, options) => {
      if (String(path).includes('.corrupt.')) throw new Error('backup cleanup failed');
      return rm(path, options);
    });
    const err = await repairHookControls(root).catch((e) => e);
    expect(err).toBeInstanceOf(HookControlsError);
    expect(String(err.message)).toContain('pre-rename fsync failed');
    expect(String(err.message)).toContain('backup cleanup failed');
    expect((await readdir(root)).filter((name) => name.startsWith('hook-controls.json.corrupt.'))).toHaveLength(1);
  });

  test('removes corruption backup when backup write fails after exclusive create during repair', async () => {
    const root = await dir();
    const invalid = Buffer.from([0xff, 0x7b, 0x62, 0x72, 0x6f, 0x6b, 0x65, 0x6e]);
    await writeFile(join(root, 'hook-controls.json'), invalid);
    hookControlsInternals.setBackupWriteAfterCreateFailure(new Error('backup write after create failed'));
    await expect(repairHookControls(root)).rejects.toMatchObject({ code: 'write-failed' });
    expect((await readdir(root)).filter((name) => name.startsWith('hook-controls.json.corrupt.'))).toHaveLength(0);
    expect(Buffer.compare(await readFile(join(root, 'hook-controls.json')), invalid)).toBe(0);
  });

  test('removes corruption backup when backup file fsync fails during repair', async () => {
    const root = await dir();
    const invalid = Buffer.from([0xff, 0x7b, 0x62, 0x72, 0x6f, 0x6b, 0x65, 0x6e]);
    await writeFile(join(root, 'hook-controls.json'), invalid);
    hookControlsInternals.setBackupSyncFailure(new Error('backup file fsync failed'));
    await expect(repairHookControls(root)).rejects.toMatchObject({ code: 'write-failed' });
    expect((await readdir(root)).filter((name) => name.startsWith('hook-controls.json.corrupt.'))).toHaveLength(0);
    expect(Buffer.compare(await readFile(join(root, 'hook-controls.json')), invalid)).toBe(0);
  });

  test('removes corruption backup when post-backup directory fsync fails during repair', async () => {
    const root = await dir();
    const invalid = Buffer.from([0xff, 0x7b, 0x62, 0x72, 0x6f, 0x6b, 0x65, 0x6e]);
    await writeFile(join(root, 'hook-controls.json'), invalid);
    let directorySyncAttempts = 0;
    hookControlsInternals.setSyncDirectory(async (dirPath) => {
      directorySyncAttempts += 1;
      if (directorySyncAttempts === 1) throw new Error('post-backup directory fsync failed');
      return hookControlsInternals.defaultSyncDirectory(dirPath);
    });
    await expect(repairHookControls(root)).rejects.toMatchObject({ code: 'write-failed' });
    expect(directorySyncAttempts).toBe(2);
    expect((await readdir(root)).filter((name) => name.startsWith('hook-controls.json.corrupt.'))).toHaveLength(0);
    expect(Buffer.compare(await readFile(join(root, 'hook-controls.json')), invalid)).toBe(0);
  });

  test('surfaces backup cleanup failure when backup file fsync fails during repair', async () => {
    const root = await dir();
    const invalid = Buffer.from([0xff, 0x7b, 0x62, 0x72, 0x6f, 0x6b, 0x65, 0x6e]);
    await writeFile(join(root, 'hook-controls.json'), invalid);
    hookControlsInternals.setBackupSyncFailure(new Error('backup file fsync failed'));
    hookControlsInternals.setRm(async (path, options) => {
      if (String(path).includes('.corrupt.')) throw new Error('backup cleanup failed');
      return rm(path, options);
    });
    const err = await repairHookControls(root).catch((e) => e);
    expect(err).toBeInstanceOf(HookControlsError);
    expect(String(err.message)).toContain('backup file fsync failed');
    expect(String(err.message)).toContain('backup cleanup failed');
    expect((await readdir(root)).filter((name) => name.startsWith('hook-controls.json.corrupt.'))).toHaveLength(1);
  });

  test('rejects prototype pollution IDs with unknown-hook-control', () => {
    expect(isRegisteredHookControl('toString')).toBe(false);
    expect(isRegisteredHookControl('constructor')).toBe(false);
    expect(isRegisteredHookControl('background-jobs-blocker')).toBe(true);
    expect(setHookControl('toString' as never, true, '/tmp/should-not-run')).rejects.toBeInstanceOf(HookControlsError);
  });

  test('rejects symlink lock paths without following or truncating them', async () => {
    const root = await dir();
    const realLock = join(root, 'real.lock');
    const linkPath = join(root, '.hook-controls.lock');
    await writeFile(realLock, 'prior-lock-bytes');
    await symlink(realLock, linkPath);
    await expect(setHookControl('background-jobs-blocker', false, root)).rejects.toMatchObject({ code: 'write-failed' });
    expect(await readFile(realLock, 'utf8')).toBe('prior-lock-bytes');
  });

  test('rejects hard-linked lock files', async () => {
    const root = await dir();
    const lockPath = join(root, '.hook-controls.lock');
    await writeFile(lockPath, '', { mode: 0o600 });
    await link(lockPath, join(root, '.hook-controls.lock.extra'));
    await expect(setHookControl('background-jobs-blocker', false, root)).rejects.toMatchObject({ code: 'write-failed' });
  });

  test('unlocks and closes the lock descriptor when post-flock initialization fails, then allows a later acquisition', async () => {
    const root = await dir();
    hookControlsInternals.setLockInitFailure(new Error('injected lock init failure'));
    await expect(setHookControl('background-jobs-blocker', false, root)).rejects.toMatchObject({ code: 'write-failed' });
    hookControlsInternals.setLockInitFailure(null);
    await setHookControl('background-jobs-blocker', false, root);
    expect((await readHookControls(root)).controls.hooks['background-jobs-blocker']).toBe(false);
  });

  test('releases a stale advisory lock after the holder exits', async () => {
    const root = await dir();
    const lockPath = join(root, '.hook-controls.lock');
    const handshakePath = join(root, '.lock-holder-ready');
    const holder = Bun.spawn(['bun', '-e', `
      import { dlopen, FFIType } from 'bun:ffi';
      import { open, writeFile } from 'node:fs/promises';
      import { constants } from 'node:fs';
      const O_NOFOLLOW = constants.O_NOFOLLOW ?? 0x10000;
      const { symbols: { flock } } = dlopen('libc.so.6', { flock: { args: [FFIType.i32, FFIType.i32], returns: FFIType.i32 } });
      const handle = await open(process.argv[1], constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | O_NOFOLLOW, 0o600).catch(async (error) => {
        if (error.code !== 'EEXIST') throw error;
        return open(process.argv[1], constants.O_RDONLY | O_NOFOLLOW);
      });
      if (flock(handle.fd, 2) !== 0) process.exit(2);
      await writeFile(process.argv[2], 'ready');
      await Bun.sleep(50);
    `, lockPath, handshakePath], { stdout: 'pipe', stderr: 'pipe' });
    try {
      await waitForHandshake(handshakePath);
      await holder.exited;
      await setHookControl('background-jobs-blocker', false, root);
      expect((await readHookControls(root)).controls.hooks['background-jobs-blocker']).toBe(false);
    } finally {
      await reapChild(holder);
    }
  });

  test('fails closed when a live advisory lock is held past the bounded wait', async () => {
    const root = await dir();
    const lockPath = join(root, '.hook-controls.lock');
    const handshakePath = join(root, '.lock-holder-ready');
    const holder = Bun.spawn(['bun', '-e', `
      import { dlopen, FFIType } from 'bun:ffi';
      import { open, writeFile } from 'node:fs/promises';
      import { constants } from 'node:fs';
      const O_NOFOLLOW = constants.O_NOFOLLOW ?? 0x10000;
      const { symbols: { flock } } = dlopen('libc.so.6', { flock: { args: [FFIType.i32, FFIType.i32], returns: FFIType.i32 } });
      const handle = await open(process.argv[1], constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | O_NOFOLLOW, 0o600).catch(async (error) => {
        if (error.code !== 'EEXIST') throw error;
        return open(process.argv[1], constants.O_RDONLY | O_NOFOLLOW);
      });
      if (flock(handle.fd, 2) !== 0) process.exit(2);
      await writeFile(process.argv[2], 'ready');
      await new Promise(() => {});
    `, lockPath, handshakePath], { stdout: 'pipe', stderr: 'pipe' });
    try {
      await waitForHandshake(handshakePath);
      await expect(setHookControl('background-jobs-blocker', false, root)).rejects.toMatchObject({ code: 'locked' });
    } finally {
      await reapChild(holder);
    }
  });

  test('serializes concurrent mutations without lost updates', async () => {
    const root = await dir();
    await Promise.all([
      setHookControl('background-jobs-blocker', false, root),
      setHookControl('background-jobs-blocker', true, root),
    ]);
    const value = (await readHookControls(root)).controls.hooks['background-jobs-blocker'];
    expect(typeof value).toBe('boolean');
    expect(JSON.parse(await readFile(join(root, 'hook-controls.json'), 'utf8')).hooks['background-jobs-blocker']).toBe(value);
  });

  test('preserves prior bytes when pre-rename persistence fails', async () => {
    const root = await dir();
    const prior = JSON.stringify({ version: 'hook-controls/v1', hooks: { 'background-jobs-blocker': true } }, null, 2) + '\n';
    await writeFile(join(root, 'hook-controls.json'), prior);
    hookControlsInternals.setSyncFile(async (path) => {
      if (path.includes('.tmp')) throw new Error('pre-rename fsync failed');
    });
    await expect(setHookControl('background-jobs-blocker', false, root)).rejects.toMatchObject({ code: 'write-failed' });
    expect(await readFile(join(root, 'hook-controls.json'), 'utf8')).toBe(prior);
  });

  test('returns observed indeterminate success when post-rename parent fsync fails', async () => {
    const root = await dir();
    const prior = JSON.stringify({ version: 'hook-controls/v1', hooks: { 'background-jobs-blocker': true } }, null, 2) + '\n';
    await writeFile(join(root, 'hook-controls.json'), prior);
    let parentSyncCalls = 0;
    hookControlsInternals.setSyncDirectory(async () => {
      parentSyncCalls += 1;
      throw new Error('post-rename fsync failed');
    });
    const result = await setHookControl('background-jobs-blocker', false, root);
    expect(result.persistence).toBe('indeterminate');
    expect(result.persistenceDetail).toContain('post-rename fsync failed');
    expect(result.controls.hooks['background-jobs-blocker']).toBe(false);
    expect(JSON.parse(await readFile(join(root, 'hook-controls.json'), 'utf8')).hooks['background-jobs-blocker']).toBe(false);
    const names = await readdir(root);
    expect(names.some((name) => name.includes('.rollback'))).toBe(false);
    expect(names).not.toContain('.hook-controls.persistence');
  });

  test('returns committed indeterminate success when post-rename fsync and reread both fail', async () => {
    const root = await dir();
    const prior = JSON.stringify({ version: 'hook-controls/v1', hooks: { 'background-jobs-blocker': true } }, null, 2) + '\n';
    await writeFile(join(root, 'hook-controls.json'), prior);
    let readCalls = 0;
    hookControlsInternals.setSyncDirectory(async () => { throw new Error('post-rename fsync failed'); });
    hookControlsInternals.setReadRaw(async (dir) => {
      readCalls += 1;
      if (readCalls === 1) return hookControlsInternals.defaultReadRaw(dir);
      throw new Error('post-commit reread failed');
    });
    const result = await setHookControl('background-jobs-blocker', false, root);
    expect(result.persistence).toBe('indeterminate');
    expect(result.persistenceDetail).toContain('post-rename fsync failed');
    expect(result.persistenceDetail).toContain('reread failed: Error: post-commit reread failed');
    expect(result.controls.hooks['background-jobs-blocker']).toBe(false);
    expect(JSON.parse(await readFile(join(root, 'hook-controls.json'), 'utf8')).hooks['background-jobs-blocker']).toBe(false);
  });

  test('GET fsync retries then confirms persistence', async () => {
    const root = await dir();
    await setHookControl('background-jobs-blocker', false, root);
    hookControlsInternals.reset();
    let attempts = 0;
    hookControlsInternals.setSyncDirectory(async () => {
      attempts += 1;
      if (attempts < 2) throw new Error('transient fsync failure');
    });
    const result = await readHookControls(root);
    expect(attempts).toBe(2);
    expect(result.persistence).toBeUndefined();
    expect(result.controls.hooks['background-jobs-blocker']).toBe(false);
  });

  test('GET reports indeterminate persistence when directory fsync keeps failing', async () => {
    const root = await dir();
    await writeFile(join(root, 'hook-controls.json'), JSON.stringify({ version: 'hook-controls/v1', hooks: { 'background-jobs-blocker': false } }, null, 2) + '\n');
    hookControlsInternals.setSyncDirectory(async () => { throw new Error('persistent fsync failure'); });
    const result = await readHookControls(root);
    expect(result.persistence).toBe('indeterminate');
    expect(result.persistenceDetail).toContain('persistent fsync failure');
    expect(result.controls.hooks['background-jobs-blocker']).toBe(false);
  });

  test('GET confirmed generation matches disk while lock serializes concurrent POST', async () => {
    const root = await dir();
    const prior = JSON.stringify({ version: 'hook-controls/v1', hooks: { 'background-jobs-blocker': true } }, null, 2) + '\n';
    await writeFile(join(root, 'hook-controls.json'), prior);
    hookControlsInternals.reset();
    let getFsyncStarted = false;
    hookControlsInternals.setSyncDirectory(async (dir) => {
      getFsyncStarted = true;
      await Bun.sleep(100);
      await hookControlsInternals.defaultSyncDirectory(dir);
    });
    let postCompleted = false;
    const getPromise = readHookControls(root);
    while (!getFsyncStarted) await Bun.sleep(5);
    const postPromise = setHookControl('background-jobs-blocker', false, root).then((result) => {
      postCompleted = true;
      return result;
    });
    await Bun.sleep(25);
    expect(postCompleted).toBe(false);
    const getResult = await getPromise;
    expect(getResult.persistence).toBeUndefined();
    expect(getResult.controls.hooks['background-jobs-blocker']).toBe(true);
    expect(JSON.parse(await readFile(join(root, 'hook-controls.json'), 'utf8')).hooks['background-jobs-blocker']).toBe(true);
    const postResult = await postPromise;
    expect(postResult.controls.hooks['background-jobs-blocker']).toBe(false);
  });

  test('closes the lock descriptor in finally when release fails after commit', async () => {
    const root = await dir();
    hookControlsInternals.setLockReleaseFailure(new Error('injected release failure'));
    await setHookControl('background-jobs-blocker', false, root);
    expect(hookControlsInternals.getLockDescriptorCloseCount()).toBe(1);
    hookControlsInternals.setLockReleaseFailure(null);
    await setHookControl('background-jobs-blocker', true, root);
    expect((await readHookControls(root)).controls.hooks['background-jobs-blocker']).toBe(true);
  });

  test('returns indeterminate success when lock release fails after commit', async () => {
    const root = await dir();
    hookControlsInternals.setLockReleaseFailure(new Error('injected close failure'));
    const result = await setHookControl('background-jobs-blocker', false, root);
    expect(result.persistence).toBe('indeterminate');
    expect(result.persistenceDetail).toContain('injected close failure');
    expect(result.controls.hooks['background-jobs-blocker']).toBe(false);
    expect(JSON.parse(await readFile(join(root, 'hook-controls.json'), 'utf8')).hooks['background-jobs-blocker']).toBe(false);
  });

  test('leaves no marker or rollback artifacts after indeterminate write', async () => {
    const root = await dir();
    hookControlsInternals.setSyncDirectory(async () => { throw new Error('post-rename fsync failed'); });
    await setHookControl('background-jobs-blocker', false, root);
    const names = await readdir(root);
    expect(names.filter((name) => name.includes('rollback') || name.includes('persistence'))).toEqual([]);
  });
  test('appends lock release failure when GET persistence is already indeterminate', async () => {
    const root = await dir();
    hookControlsInternals.setSyncDirectory(async () => { throw new Error('persistent fsync failure'); });
    hookControlsInternals.setLockReleaseFailure(new Error('injected release failure'));
    const result = await readHookControls(root);
    expect(result.persistence).toBe('indeterminate');
    expect(result.persistenceDetail).toContain('persistent fsync failure');
    expect(result.persistenceDetail).toContain('injected release failure');
  });

  test('includes temp cleanup failure in write error before commit', async () => {
    const root = await dir();
    hookControlsInternals.setSyncFile(async (path) => {
      if (path.includes('.tmp')) throw new Error('pre-rename fsync failed');
    });
    hookControlsInternals.setRm(async () => { throw new Error('cleanup failed'); });
    const err = await setHookControl('background-jobs-blocker', false, root).catch((e) => e);
    expect(err).toBeInstanceOf(HookControlsError);
    expect(String(err.message)).toContain('pre-rename fsync failed');
    expect(String(err.message)).toContain('temp cleanup failed');
  });

  test('marks persistence indeterminate when temp cleanup fails after commit', async () => {
    const root = await dir();
    hookControlsInternals.setRm(async () => { throw new Error('post-commit cleanup failed'); });
    const result = await setHookControl('background-jobs-blocker', false, root);
    expect(result.controls.hooks['background-jobs-blocker']).toBe(false);
    expect(result.persistence).toBe('indeterminate');
    expect(result.persistenceDetail).toContain('post-commit cleanup failed');
  });

  test('allows concurrent GET readers under shared flock', async () => {
    const root = await dir();
    await setHookControl('background-jobs-blocker', false, root);
    hookControlsInternals.reset();
    let activeReaders = 0;
    let maxReaders = 0;
    hookControlsInternals.setSyncDirectory(async () => {
      activeReaders += 1;
      maxReaders = Math.max(maxReaders, activeReaders);
      await Bun.sleep(50);
      activeReaders -= 1;
    });
    const [a, b] = await Promise.all([readHookControls(root), readHookControls(root)]);
    expect(maxReaders).toBeGreaterThanOrEqual(2);
    expect(a.controls.hooks['background-jobs-blocker']).toBe(false);
    expect(b.controls.hooks['background-jobs-blocker']).toBe(false);
  });

});
