// tools/parallel-session-lock.mjs
/**
 * Pure parallel-session lock — router-gate v4 Stream H Task 7.
 *
 * Prevents two Claude sessions on the same workspace from concurrently
 * mutating files. Lock file lives at
 *   ~/.claude/runtime/session-lock-<workspaceHash>.json
 * with TTL-based stale recovery (default 5 minutes).
 *
 * I/O is injected (readLock/writeLock/deleteLock) so this module stays pure
 * and unit-testable. The wrapper hook (enforce-parallel-session-lock.mjs)
 * binds real fs implementations.
 *
 * Lock format:
 *   { schema_version: 1, session_id, pid, acquired_at: <ms>, ttl_ms }
 */
import { createHash } from 'node:crypto';

export const LOCK_SCHEMA_VERSION = 1;
export const LOCK_DEFAULT_TTL_MS = 5 * 60 * 1000;

/** Derive a deterministic 12-char hex workspace hash from a path. */
export function computeWorkspaceHash(workspacePath) {
  return createHash('md5').update(String(workspacePath || ''), 'utf-8').digest('hex').slice(0, 12);
}

function isStale(record, now) {
  if (!record || typeof record !== 'object') return true;
  const ttl = typeof record.ttl_ms === 'number' ? record.ttl_ms : LOCK_DEFAULT_TTL_MS;
  return now - (record.acquired_at || 0) > ttl;
}

/**
 * 7.5 (дыра 5): жив ли процесс-держатель. process.kill(pid, 0) не убивает — лишь проверяет
 * существование: ESRCH → мёртв; EPERM → существует (нет прав) → жив; иначе → жив. Невалидный
 * pid → мёртв. Реальная реализация для обёртки; в acquire инъектируется (чистота/тестируемость).
 */
export function isProcessAlive(pid) {
  if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 0) return false;
  try { process.kill(pid, 0); return true; }
  catch (e) { return e && e.code === 'EPERM'; }
}

/**
 * Try to acquire the lock for sessionId. Takes over stale or same-session locks.
 *
 * @param {object} args
 * @param {string} args.sessionId
 * @param {number} args.pid
 * @param {string} args.workspaceHash
 * @param {number} args.now              - unix ms
 * @param {function} args.readLock       - () => record | null
 * @param {function} args.writeLock      - (record) => void
 * @param {number} [args.ttlMs]          - override default TTL
 * @returns {{acquired: boolean, holder?: {session_id, pid, acquired_at}}}
 */
export function acquire({ sessionId, pid, workspaceHash, now, readLock, writeLock, ttlMs = LOCK_DEFAULT_TTL_MS, isPidAlive }) {
  const existing = readLock();
  // 7.5 (дыра 5): держатель свежего лока, чей процесс МЁРТВ, не должен блокировать на весь TTL.
  // Перехватываем, если isPidAlive предоставлен и holder.pid не жив. Без isPidAlive — старое
  // поведение (backward-compat): перехват только по stale/same-session.
  const holderDead = !!existing && typeof isPidAlive === 'function' && !isPidAlive(existing.pid);
  // Stale OR same-session OR dead-holder → take over.
  if (!existing || isStale(existing, now) || existing.session_id === sessionId || holderDead) {
    const record = {
      schema_version: LOCK_SCHEMA_VERSION,
      session_id: sessionId,
      pid,
      acquired_at: now,
      ttl_ms: ttlMs,
    };
    writeLock(record);
    return { acquired: true, holder: { session_id: sessionId, pid, acquired_at: now } };
  }
  // Fresh lock from another session — blocked.
  return {
    acquired: false,
    holder: { session_id: existing.session_id, pid: existing.pid, acquired_at: existing.acquired_at },
  };
}

/**
 * Same-session refresh — bumps acquired_at if we still own the lock.
 * Other-session refresh is a no-op (does not steal).
 *
 * @returns {{refreshed: boolean}}
 */
export function refresh({ sessionId, workspaceHash, now, readLock, writeLock, ttlMs = LOCK_DEFAULT_TTL_MS }) {
  const existing = readLock();
  if (!existing || existing.session_id !== sessionId) return { refreshed: false };
  writeLock({
    schema_version: LOCK_SCHEMA_VERSION,
    session_id: sessionId,
    pid: existing.pid,
    acquired_at: now,
    ttl_ms: ttlMs,
  });
  return { refreshed: true };
}

/**
 * Release the lock if we own it; no-op otherwise.
 */
export function release({ sessionId, workspaceHash, readLock, deleteLock }) {
  const existing = readLock();
  if (existing && existing.session_id === sessionId) deleteLock();
}
