import { writeFileSync, unlinkSync, readFileSync, existsSync } from "fs";
import { join } from "path";

/**
 * A simple file-based mutex used to prevent concurrent Pi CLI executions.
 *
 * The lock file stores the acquisition timestamp (milliseconds since epoch)
 * as plain text. Any lock older than {@link STALE_LOCK_MS} is automatically
 * treated as stale and removed, ensuring a crashed process can never
 * permanently block future runs.
 *
 * Lock files are stored in `dir` (default `/tmp`) as
 * `pi-worker-<name>.lock`.
 *
 * @example
 * ```ts
 * const lock = new FileLock("task-loop");
 * if (!lock.acquire()) return; // another execution in progress
 * try {
 *   await doWork();
 * } finally {
 *   lock.release();
 * }
 * ```
 */
export class FileLock {
  private lockPath: string;
  private held = false;

  /** Locks older than this (ms) are considered stale and are removed on the next acquire(). */
  private static readonly STALE_LOCK_MS = 15 * 60 * 1000; // 15 minutes

  /**
   * @param name - Logical name for this lock (e.g. `"task-loop"`).
   *   Combined with the prefix to form the lock filename.
   * @param dir  - Directory in which to create the lock file. Defaults to `/tmp`.
   */
  constructor(name: string, dir: string = "/tmp") {
    this.lockPath = join(dir, `pi-worker-${name}.lock`);
  }

  /**
   * Attempts to acquire the lock.
   *
   * If a lock file already exists and is **not** stale, returns `false`
   * immediately (non-blocking). Stale lock files are removed and the
   * acquisition is retried once.
   *
   * Uses the `wx` (exclusive create) flag so that two processes racing to
   * acquire the lock cannot both succeed.
   *
   * @returns `true` if the lock was acquired, `false` if it is already held.
   */
  acquire(): boolean {
    // Check for stale lock first
    if (existsSync(this.lockPath)) {
      try {
        const content = readFileSync(this.lockPath, "utf-8");
        const lockTime = parseInt(content);
        if (Date.now() - lockTime > FileLock.STALE_LOCK_MS) {
          // Stale lock — remove it
          try { unlinkSync(this.lockPath); } catch (e) {
            process.stderr.write(`[lock] Failed to remove stale lock ${this.lockPath}: ${e}\n`);
          }
        } else {
          return false;
        }
      } catch (e) {
        // Corrupted lock file — remove it so we don't get permanently stuck
        process.stderr.write(`[lock] Corrupted lock file ${this.lockPath}, removing: ${e}\n`);
        try { unlinkSync(this.lockPath); } catch {}
      }
    }

    // Atomic create — fails with EEXIST if another process won the race
    try {
      writeFileSync(this.lockPath, Date.now().toString(), { flag: 'wx' });
      this.held = true;
      return true;
    } catch (e) {
      const code = (e as NodeJS.ErrnoException).code;
      if (code !== "EEXIST") {
        // Unexpected error (permissions, disk full, etc.) — log it
        process.stderr.write(`[lock] acquire failed for ${this.lockPath}: ${code || e}\n`);
      }
      return false;
    }
  }

  /**
   * Releases the lock by deleting the lock file.
   *
   * Only deletes if this instance actually holds the lock. Safe to call
   * even if the lock is not currently held (no-op in that case).
   */
  release() {
    if (!this.held) return;
    try {
      unlinkSync(this.lockPath);
    } catch (e) {
      // Suppress ENOENT — lock was already cleaned up (e.g. by stale detection)
      if ((e as NodeJS.ErrnoException).code !== "ENOENT") {
        process.stderr.write(`[lock] Failed to release lock ${this.lockPath}: ${e}\n`);
      }
    } finally {
      this.held = false;
    }
  }

  /**
   * Returns `true` if a lock file exists on disk (regardless of whether it
   * is stale). Useful for diagnostics; prefer {@link acquire} for actual
   * mutual-exclusion logic.
   */
  isLocked(): boolean {
    return existsSync(this.lockPath);
  }
}
