"use strict";
/**
 * LegalHoldStore -- P4-RET-03 Legal Hold Mechanism
 *
 * In-memory Map-based implementation for this cycle.
 * Prisma-backed persistence is a follow-up (migration flagged below).
 *
 * Core contract:
 *   - create()  -- validates scope, routes to approval queue, returns
 *                  a 'pending' hold that does NOT match deletion queries.
 *   - extend()  -- updates expiresAt after a fresh approval.
 *   - release() -- marks 'released' after a fresh approval. Does NOT
 *                  auto-delete previously held data (AC 10).
 *   - findHoldsMatching() -- deletion-engine query: returns active holds
 *                  whose scope covers the given record.
 *   - list()    -- partner-admin / legal-reviewer query.
 *
 * Approval-queue hook:
 *   create() and release() route through the provided ApprovalQueueHook
 *   before committing status changes. The hook is intentionally thin: it
 *   enqueues an entry and returns a promise that resolves when the entry
 *   is resolved (approved/denied). This decouples LegalHoldStore from the
 *   governance-server's full ApprovalQueue implementation so it can be
 *   used in the core SDK without dragging in HTTP server dependencies.
 *
 * Spec: P4-RET-01 Section 2.3, AC 7-10
 *
 * @connexum/ai-governance
 */
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    var desc = Object.getOwnPropertyDescriptor(m, k);
    if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
      desc = { enumerable: true, get: function() { return m[k]; } };
    }
    Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
    Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
    o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
    var ownKeys = function(o) {
        ownKeys = Object.getOwnPropertyNames || function (o) {
            var ar = [];
            for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
            return ar;
        };
        return ownKeys(o);
    };
    return function (mod) {
        if (mod && mod.__esModule) return mod;
        var result = {};
        if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
        __setModuleDefault(result, mod);
        return result;
    };
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.LegalHoldStore = void 0;
exports.makeNoOpAuditEmitter = makeNoOpAuditEmitter;
const crypto = __importStar(require("node:crypto"));
const legal_hold_errors_1 = require("./legal-hold-errors");
// ---------------------------------------------------------------------------
// Default no-op emitter (used in tests and SDK-only contexts)
// ---------------------------------------------------------------------------
function makeNoOpAuditEmitter() {
    return async (eventName, holdId, actor) => ({
        eventName,
        timestamp: new Date().toISOString(),
        chainEntryId: `noop_${crypto.randomUUID()}`,
        actor,
    });
}
// ---------------------------------------------------------------------------
// Helper: validate scope has at least one non-dateRange field
// ---------------------------------------------------------------------------
function assertScopeNotEmpty(scope) {
    const hasField = scope.tenantId != null ||
        scope.contractId != null ||
        scope.agentId != null ||
        scope.caseTag != null ||
        scope.packId != null;
    if (!hasField) {
        throw new legal_hold_errors_1.LegalHoldScopeEmptyError();
    }
}
// ---------------------------------------------------------------------------
// Helper: does a record fall within a dateRange?
// ---------------------------------------------------------------------------
function withinDateRange(timestamp, dateRange) {
    if (!dateRange)
        return true;
    if (!timestamp)
        return true; // no timestamp on record = no date filter applied
    const ts = new Date(timestamp).getTime();
    const from = new Date(dateRange.from).getTime();
    if (ts < from)
        return false;
    if (dateRange.to) {
        const to = new Date(dateRange.to).getTime();
        if (ts > to)
            return false;
    }
    return true;
}
// ---------------------------------------------------------------------------
// Helper: does a record match a hold's scope?
//
// Matching semantics per spec:
//   - OR over primary scope fields (tenantId, contractId, agentId, caseTag, packId).
//   - Tenant-scope is authoritative: a tenantId hold catches all records
//     for that tenant regardless of other scope attributes on the hold.
//   - packId matches if the record's packBinding array includes the packId.
//   - dateRange is a secondary AND-filter applied after a primary match.
// ---------------------------------------------------------------------------
function recordMatchesScope(record, scope) {
    let primaryMatch = false;
    // Tenant-scope is authoritative: if the hold has a tenantId and the
    // record belongs to that tenant, the hold matches regardless of
    // narrower scope attributes.
    if (scope.tenantId != null && record.tenantId === scope.tenantId) {
        primaryMatch = true;
    }
    if (!primaryMatch && scope.contractId != null && record.contractId === scope.contractId) {
        primaryMatch = true;
    }
    if (!primaryMatch && scope.agentId != null && record.agentId === scope.agentId) {
        primaryMatch = true;
    }
    if (!primaryMatch && scope.caseTag != null && record.caseTag === scope.caseTag) {
        primaryMatch = true;
    }
    if (!primaryMatch && scope.packId != null) {
        if (record.packBinding?.includes(scope.packId)) {
            primaryMatch = true;
        }
    }
    if (!primaryMatch)
        return false;
    // Apply dateRange as secondary AND-filter.
    return withinDateRange(record.timestamp, scope.dateRange);
}
// ---------------------------------------------------------------------------
// LegalHoldStore
// ---------------------------------------------------------------------------
/**
 * Stores, retrieves, and queries LegalHold entities.
 *
 * Thread-safety note: Node.js is single-threaded; the Map operations
 * here are effectively atomic. The ConcurrencyError guard exists for
 * the logical race (pending -> release without active transition) not
 * for actual memory races.
 */
class LegalHoldStore {
    holds = new Map();
    approvalQueue;
    emitAudit;
    constructor(approvalQueue, emitAudit) {
        this.approvalQueue = approvalQueue;
        this.emitAudit = emitAudit ?? makeNoOpAuditEmitter();
    }
    // -------------------------------------------------------------------------
    // create()
    // -------------------------------------------------------------------------
    /**
     * Create a new LegalHold.
     *
     * The hold starts in 'pending' status and does NOT match deletion queries
     * until a legal-reviewer approves it. Approval is handled via the
     * ApprovalQueueHook provided at construction time.
     *
     * Throws LegalHoldScopeEmptyError if the scope has no populated fields.
     *
     * @param input  Creation parameters (scope, reason, optional expiresAt).
     * @param actor  Authenticated actor submitting the request (JWT sub claim).
     * @returns      The hold in 'pending' status after enqueuing approval.
     *               The returned hold becomes 'active' asynchronously once
     *               a legal-reviewer approves it. Callers should poll or
     *               subscribe to audit events for the transition.
     */
    async create(input, actor) {
        assertScopeNotEmpty(input.scope);
        const holdId = `hold_${crypto.randomUUID()}`;
        const now = new Date().toISOString();
        const hold = {
            holdId,
            scope: { ...input.scope },
            status: 'pending',
            createdBy: actor,
            createdAt: now,
            reason: input.reason,
            expiresAt: input.expiresAt,
            chainOfCustody: [],
        };
        // Persist in pending state BEFORE enqueuing so the hold ID is stable
        // if the approval-queue call throws.
        this.holds.set(holdId, hold);
        // Enqueue approval. This may resolve immediately (in tests / auto-approve
        // mode) or resolve later after a human reviews.
        const subject = `Create legal hold: ${input.reason} (tenant: ${input.scope.tenantId ?? 'N/A'}, pack: ${input.scope.packId ?? 'N/A'})`;
        let approvalEntryId;
        try {
            approvalEntryId = await this.approvalQueue.enqueue(subject, holdId, actor, 'create');
        }
        catch (err) {
            // Approval denied or queue error: remove the pending hold and surface
            // the error. The hold never existed from the caller's perspective.
            this.holds.delete(holdId);
            throw err;
        }
        // Approval granted. Transition to active.
        hold.pendingApprovalId = undefined;
        hold.status = 'active';
        const custodyRef = await this.emitAudit('retention:legal-hold-created', holdId, actor, {
            scope: hold.scope,
            reason: hold.reason,
            approvalEntryId,
        });
        hold.chainOfCustody.push(custodyRef);
        return { ...hold };
    }
    // -------------------------------------------------------------------------
    // extend()
    // -------------------------------------------------------------------------
    /**
     * Extend the expiry of an active LegalHold.
     *
     * Requires a fresh legal-reviewer approval (same queue, new entry).
     * Only 'active' holds can be extended; pending holds must first become
     * active. Released holds cannot be extended.
     *
     * @param holdId      The hold to extend.
     * @param newExpires  New expiry ISO-8601 timestamp.
     * @param actor       Authenticated actor requesting the extension.
     */
    async extend(holdId, newExpires, actor) {
        const hold = this.holds.get(holdId);
        if (!hold)
            throw new legal_hold_errors_1.LegalHoldNotFoundError(holdId);
        if (hold.status !== 'active') {
            throw new Error(`LegalHold '${holdId}' is '${hold.status}'; only active holds can be extended.`);
        }
        const subject = `Extend legal hold ${holdId} to ${newExpires} (reason: ${hold.reason})`;
        let approvalEntryId;
        try {
            approvalEntryId = await this.approvalQueue.enqueue(subject, holdId, actor, 'extend');
        }
        catch (err) {
            throw err;
        }
        const previousExpiry = hold.expiresAt;
        hold.expiresAt = newExpires;
        const custodyRef = await this.emitAudit('retention:legal-hold-extended', holdId, actor, {
            previousExpiry,
            newExpiry: newExpires,
            approvalEntryId,
        });
        hold.chainOfCustody.push(custodyRef);
        return { ...hold };
    }
    // -------------------------------------------------------------------------
    // release()
    // -------------------------------------------------------------------------
    /**
     * Release a LegalHold.
     *
     * Only 'active' holds can be released. A 'pending' hold cannot be
     * released directly -- that would be a concurrency / logic bypass.
     * Throwing LegalHoldConcurrencyError on the pending->released path
     * covers the PT-RET-2 race scenario.
     *
     * Release does NOT auto-delete previously held data (AC 10). Records
     * past their retention window at release time are flagged by the next
     * scheduled job.
     *
     * @param holdId  The hold to release.
     * @param actor   Authenticated actor requesting the release.
     */
    async release(holdId, actor) {
        const hold = this.holds.get(holdId);
        if (!hold)
            throw new legal_hold_errors_1.LegalHoldNotFoundError(holdId);
        if (hold.status === 'pending') {
            // A hold that never became active cannot be released directly.
            // This is the concurrency guard: pending -> released without an
            // intervening approval event is a logic bypass.
            throw new legal_hold_errors_1.LegalHoldConcurrencyError(holdId, 'pending -> released');
        }
        if (hold.status === 'released') {
            // Idempotent: already released, return current state.
            return { ...hold };
        }
        // hold.status === 'active'. Require fresh approval for release.
        const subject = `Release legal hold ${holdId} (reason: ${hold.reason})`;
        let approvalEntryId;
        try {
            approvalEntryId = await this.approvalQueue.enqueue(subject, holdId, actor, 'release');
        }
        catch (err) {
            throw err;
        }
        const releasedAt = new Date().toISOString();
        hold.status = 'released';
        hold.releasedBy = actor;
        hold.releasedAt = releasedAt;
        const custodyRef = await this.emitAudit('retention:legal-hold-released', holdId, actor, {
            releasedAt,
            approvalEntryId,
            note: 'Release does not auto-delete held records. Records past retention window are flagged for next scheduled job.',
        });
        hold.chainOfCustody.push(custodyRef);
        return { ...hold };
    }
    // -------------------------------------------------------------------------
    // findHoldsMatching()
    // -------------------------------------------------------------------------
    /**
     * Deletion-engine query.
     *
     * Returns all ACTIVE holds whose scope matches the given record.
     * Pending and released holds are excluded (only 'active' blocks deletion).
     *
     * The tenant-scope is authoritative: a hold with tenantId='acme' catches
     * every record where record.tenantId === 'acme', regardless of whether
     * the hold also has a narrower agentId or packId constraint.
     *
     * @param record  Descriptor of the record being considered for deletion.
     * @returns       Array of active LegalHold objects that cover the record.
     *                Empty array means the record is not held and deletion may proceed.
     */
    async findHoldsMatching(record) {
        const result = [];
        for (const hold of this.holds.values()) {
            if (hold.status !== 'active')
                continue;
            if (recordMatchesScope(record, hold.scope)) {
                result.push({ ...hold });
            }
        }
        return result;
    }
    // -------------------------------------------------------------------------
    // list()
    // -------------------------------------------------------------------------
    /**
     * Partner-admin / legal-reviewer query.
     *
     * Returns holds matching the given filter criteria (all fields are
     * optional AND-combined). Returns all holds when no filter is supplied.
     *
     * @param filter  Optional filter criteria.
     * @returns       Matching holds, ordered by createdAt descending.
     */
    async list(filter = {}) {
        let results = Array.from(this.holds.values());
        if (filter.status != null) {
            results = results.filter((h) => h.status === filter.status);
        }
        if (filter.tenantId != null) {
            results = results.filter((h) => h.scope.tenantId === filter.tenantId);
        }
        if (filter.contractId != null) {
            results = results.filter((h) => h.scope.contractId === filter.contractId);
        }
        if (filter.agentId != null) {
            results = results.filter((h) => h.scope.agentId === filter.agentId);
        }
        if (filter.caseTag != null) {
            results = results.filter((h) => h.scope.caseTag === filter.caseTag);
        }
        if (filter.packId != null) {
            results = results.filter((h) => h.scope.packId === filter.packId);
        }
        return results
            .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
            .map((h) => ({ ...h }));
    }
    // -------------------------------------------------------------------------
    // Internal: activate() -- called by the governance-server after an
    // out-of-band approval resolves (async approval flow, not used in the
    // inline path above but needed for non-blocking approval workflows).
    // -------------------------------------------------------------------------
    /**
     * Activate a pending hold after approval.
     * Used by routes that implement asynchronous approval resolution
     * (e.g. a human clicks Approve in the dashboard after the initial
     * POST /legal-holds request returned a 202).
     *
     * Emits the 'retention:legal-hold-created' audit event at activation
     * time (not at enqueue time) so the chain reflects actual approval.
     */
    async activatePending(holdId, approvalEntryId, actor) {
        const hold = this.holds.get(holdId);
        if (!hold)
            throw new legal_hold_errors_1.LegalHoldNotFoundError(holdId);
        if (hold.status !== 'pending') {
            // Already active or released; idempotent.
            return { ...hold };
        }
        hold.status = 'active';
        hold.pendingApprovalId = undefined;
        const custodyRef = await this.emitAudit('retention:legal-hold-created', holdId, actor, {
            scope: hold.scope,
            reason: hold.reason,
            approvalEntryId,
            note: 'Activated via async approval resolution.',
        });
        hold.chainOfCustody.push(custodyRef);
        return { ...hold };
    }
    /**
     * Get a single hold by ID. Returns undefined if not found.
     * Used by routes for GET single-item endpoints.
     */
    get(holdId) {
        const hold = this.holds.get(holdId);
        return hold ? { ...hold } : undefined;
    }
    /**
     * Internal size accessor (used in tests).
     */
    get size() {
        return this.holds.size;
    }
}
exports.LegalHoldStore = LegalHoldStore;
//# sourceMappingURL=legal-hold-store.js.map