/**
 * JobLeaseClaimStore — WS4.3 durable, epoch-fenced job-claim leases.
 *
 * (MULTI-MACHINE-SEAMLESSNESS-SPEC §WS4.3, "Cutover discipline".)
 *
 * The journal-backed upgrade of the best-effort `JobClaimManager` bus
 * broadcast. A claim is a LEASE with a fenced ownership epoch: a receiver
 * rejects a stale-epoch claim (invariant-4 epoch fencing), so a demoted
 * machine's late claim cannot steal a job from the current lease-holder, and a
 * partition double-run is structurally prevented when the journal is reachable.
 *
 * Claim records store METADATA ONLY — machine id, job slug, epoch, timestamps —
 * never job payloads (the spec's explicit privacy bound). The store is the
 * local materialized view of the replicated claim kind; the journal carries
 * each record to peers and feeds them back through `applyRemote`.
 *
 * This store does NOT decide WHEN to use the journal path — that is the
 * `JobLeaseCutoverGate`. The store only manages the leases once the gate has
 * selected the journal path.
 */
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
const DEFAULT_LEASE_TIMEOUT_MS = 30 * 60_000;
const CLAIMS_FILE = 'job-lease-claims.json';
export class JobLeaseClaimStore {
    machineId;
    claimsDir;
    defaultLeaseTimeoutMs;
    now;
    claims = new Map(); // keyed by jobSlug
    constructor(config) {
        this.machineId = config.machineId;
        this.defaultLeaseTimeoutMs = config.defaultLeaseTimeoutMs ?? DEFAULT_LEASE_TIMEOUT_MS;
        this.now = config.now ?? (() => Date.now());
        this.claimsDir = path.join(config.stateDir, 'state');
        if (!fs.existsSync(this.claimsDir)) {
            fs.mkdirSync(this.claimsDir, { recursive: true });
        }
        this.load();
    }
    /**
     * Attempt to take a lease on a job at the given ownership epoch.
     *
     * Returns the new lease on success, or a rejection when a peer holds a live,
     * non-stale lease. Idempotent: re-claiming a slug this machine already leases
     * (at the same-or-newer epoch) returns the existing lease.
     */
    tryClaim(jobSlug, epoch, timeoutMs) {
        this.pruneExpired();
        const existing = this.claims.get(jobSlug);
        if (existing && !existing.completed && !this.isExpired(existing)) {
            if (existing.machineId === this.machineId) {
                return { ok: false, reason: 'already-own', claim: existing };
            }
            // A peer holds a live lease. We may only supersede it with a STRICTLY
            // newer epoch (the peer was demoted; our epoch advanced). A same/older
            // epoch is fenced out — never steal from a current lease-holder.
            if (epoch <= existing.epoch) {
                return { ok: false, reason: 'held-by-peer', heldBy: existing.machineId };
            }
        }
        const claim = {
            claimId: `lease_${crypto.randomBytes(8).toString('hex')}`,
            jobSlug,
            machineId: this.machineId,
            epoch,
            claimedAt: new Date(this.now()).toISOString(),
            expiresAt: new Date(this.now() + (timeoutMs ?? this.defaultLeaseTimeoutMs)).toISOString(),
            completed: false,
        };
        this.claims.set(jobSlug, claim);
        this.save();
        return { ok: true, claim };
    }
    /** Mark our lease on a slug complete (releases it for the next tick). */
    completeClaim(jobSlug, result) {
        const claim = this.claims.get(jobSlug);
        if (!claim || claim.machineId !== this.machineId)
            return;
        claim.completed = true;
        claim.result = result;
        claim.completedAt = new Date(this.now()).toISOString();
        this.save();
    }
    /**
     * Apply a claim record received from a peer over the journal. Epoch fencing:
     * a record whose epoch is OLDER than the one we already hold for the slug is
     * rejected (returns false) — a demoted machine's stale claim never overwrites
     * a fresher lease. Returns true if the record was applied.
     */
    applyRemote(record) {
        // Never let a remote record masquerade as our own machine's authority.
        if (record.machineId === this.machineId)
            return false;
        const existing = this.claims.get(record.jobSlug);
        if (existing && !existing.completed && !this.isExpired(existing)) {
            // Strict epoch fence — equal epoch keeps the incumbent (first-writer-wins
            // is resolved upstream by the journal's ordering; we never flip on a tie).
            if (record.epoch <= existing.epoch)
                return false;
        }
        this.claims.set(record.jobSlug, { ...record });
        this.save();
        return true;
    }
    /** True if a peer holds a live, non-stale lease on the slug. */
    hasRemoteClaim(jobSlug) {
        this.pruneExpired();
        const claim = this.claims.get(jobSlug);
        if (!claim || claim.completed || claim.machineId === this.machineId)
            return false;
        return !this.isExpired(claim);
    }
    getClaim(jobSlug) {
        this.pruneExpired();
        return this.claims.get(jobSlug);
    }
    getActiveClaims() {
        this.pruneExpired();
        return Array.from(this.claims.values()).filter((c) => !c.completed);
    }
    getAllClaims() {
        return Array.from(this.claims.values());
    }
    /** Remove expired (uncompleted past expiry) + old completed records. */
    pruneExpired() {
        const now = this.now();
        let pruned = 0;
        for (const [slug, claim] of this.claims) {
            if (claim.completed) {
                const completedAt = claim.completedAt ? new Date(claim.completedAt).getTime() : now;
                if (now - completedAt > 60 * 60_000) {
                    this.claims.delete(slug);
                    pruned++;
                }
            }
            else if (this.isExpired(claim)) {
                this.claims.delete(slug);
                pruned++;
            }
        }
        if (pruned > 0)
            this.save();
        return pruned;
    }
    isExpired(claim) {
        return this.now() > new Date(claim.expiresAt).getTime();
    }
    load() {
        try {
            const data = JSON.parse(fs.readFileSync(path.join(this.claimsDir, CLAIMS_FILE), 'utf-8'));
            this.claims.clear();
            for (const c of data)
                this.claims.set(c.jobSlug, c);
        }
        catch {
            /* @silent-fallback-ok — no ledger yet; start empty */
        }
    }
    save() {
        fs.writeFileSync(path.join(this.claimsDir, CLAIMS_FILE), JSON.stringify(Array.from(this.claims.values()), null, 2) + '\n');
    }
}
//# sourceMappingURL=JobLeaseClaimStore.js.map