// tests/kb-update/test-lock-file.test.mjs
// Unit tests for scripts/kb-update/lib/lock-file.mjs

import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
  mkdtempSync,
  rmSync,
  writeFileSync,
  readFileSync,
  existsSync,
  utimesSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
  acquireLock,
  isPidAlive,
} from '../../scripts/kb-update/lib/lock-file.mjs';

const DEAD_PID = 99999999; // far above typical PID_MAX; reliably non-existent

function withTmp(fn) {
  const dir = mkdtempSync(join(tmpdir(), 'lf-test-'));
  try {
    return fn(dir);
  } finally {
    rmSync(dir, { recursive: true, force: true });
  }
}

function writeFakeLock(path, { pid, started, host = 'test-host', ageMs = 0 }) {
  writeFileSync(
    path,
    JSON.stringify({
      pid,
      started: started ?? Date.now() - ageMs,
      host,
      version: 1,
    }),
    'utf8'
  );
  if (ageMs > 0) {
    const past = new Date(Date.now() - ageMs);
    utimesSync(path, past, past);
  }
}

test('isPidAlive — current process is alive', () => {
  assert.equal(isPidAlive(process.pid), true);
});

test('isPidAlive — non-existent PID is dead', () => {
  assert.equal(isPidAlive(DEAD_PID), false);
});

test('isPidAlive — invalid input is dead', () => {
  assert.equal(isPidAlive(0), false);
  assert.equal(isPidAlive(-1), false);
  assert.equal(isPidAlive(NaN), false);
  assert.equal(isPidAlive(undefined), false);
});

test('acquireLock — creates lock file with current PID metadata', () => {
  withTmp((dir) => {
    const path = join(dir, 'test.lock');
    const lock = acquireLock(path, { registerCleanup: false });
    try {
      assert.equal(lock.lockPath, path);
      assert.equal(existsSync(path), true);
      const data = JSON.parse(readFileSync(path, 'utf8'));
      assert.equal(data.pid, process.pid);
      assert.equal(data.version, 1);
      assert.equal(typeof data.started, 'number');
      assert.equal(typeof data.host, 'string');
    } finally {
      lock.release();
    }
  });
});

test('acquireLock — second call same process throws ELOCKED', () => {
  withTmp((dir) => {
    const path = join(dir, 'test.lock');
    const lock = acquireLock(path, { registerCleanup: false });
    try {
      assert.throws(
        () => acquireLock(path, { registerCleanup: false }),
        (err) => err.code === 'ELOCKED' && err.holderPid === process.pid
      );
    } finally {
      lock.release();
    }
  });
});

test('acquireLock — concurrent live holder (fixture lock-fil) throws ELOCKED', () => {
  withTmp((dir) => {
    const path = join(dir, 'test.lock');
    // Pre-write a lock as if held by another live process (we use process.pid
    // as a stand-in for "guaranteed alive" without forking).
    writeFakeLock(path, { pid: process.pid, ageMs: 0 });
    assert.throws(
      () => acquireLock(path, { registerCleanup: false }),
      (err) => err.code === 'ELOCKED'
    );
  });
});

test('acquireLock — release deletes the lock file', () => {
  withTmp((dir) => {
    const path = join(dir, 'test.lock');
    const lock = acquireLock(path, { registerCleanup: false });
    assert.equal(existsSync(path), true);
    lock.release();
    assert.equal(existsSync(path), false);
  });
});

test('acquireLock — release on already-released lock is a no-op', () => {
  withTmp((dir) => {
    const path = join(dir, 'test.lock');
    const lock = acquireLock(path, { registerCleanup: false });
    lock.release();
    // Second release must not throw.
    lock.release();
    assert.equal(existsSync(path), false);
  });
});

test('acquireLock — stale lock with dead PID + old mtime is cleaned', () => {
  withTmp((dir) => {
    const path = join(dir, 'test.lock');
    writeFakeLock(path, { pid: DEAD_PID, ageMs: 2 * 60 * 60 * 1000 });
    const lock = acquireLock(path, { registerCleanup: false });
    try {
      const data = JSON.parse(readFileSync(path, 'utf8'));
      assert.equal(data.pid, process.pid);
    } finally {
      lock.release();
    }
  });
});

test('acquireLock — stale lock with live PID but old mtime is also cleaned', () => {
  withTmp((dir) => {
    const path = join(dir, 'test.lock');
    // Live PID (us) but mtime older than default 1h threshold.
    writeFakeLock(path, { pid: process.pid, ageMs: 2 * 60 * 60 * 1000 });
    const lock = acquireLock(path, { registerCleanup: false });
    try {
      const data = JSON.parse(readFileSync(path, 'utf8'));
      assert.equal(data.pid, process.pid);
      // started is rewritten to fresh wallclock
      assert.ok(Date.now() - data.started < 5000);
    } finally {
      lock.release();
    }
  });
});

test('acquireLock — fresh lock with live PID is NOT cleaned', () => {
  withTmp((dir) => {
    const path = join(dir, 'test.lock');
    writeFakeLock(path, { pid: process.pid, ageMs: 0 });
    assert.throws(
      () => acquireLock(path, { registerCleanup: false }),
      (err) => err.code === 'ELOCKED' && err.holderPid === process.pid
    );
  });
});

test('acquireLock — staleThresholdMs is honored', () => {
  withTmp((dir) => {
    const path = join(dir, 'test.lock');
    // 5s-old, live PID. Default 1h threshold → not stale → ELOCKED.
    writeFakeLock(path, { pid: process.pid, ageMs: 5_000 });
    assert.throws(
      () => acquireLock(path, { registerCleanup: false }),
      (err) => err.code === 'ELOCKED'
    );

    // Same fixture but threshold 1s → stale → cleaned.
    writeFakeLock(path, { pid: process.pid, ageMs: 5_000 });
    const lock = acquireLock(path, {
      registerCleanup: false,
      staleThresholdMs: 1_000,
    });
    lock.release();
    assert.equal(existsSync(path), false);
  });
});
