/**
 * CrossSessionPseudonymizer -- Runtime application of pseudonymize/retain rules
 * for cross-session memory entries.
 *
 * Closes the loop started by PR #15 (SessionMemoryEnforcer gate) and integrates
 * with PR #22 (MemoryAuditChain) for tamper-evident pseudonymization records.
 *
 * Two modes per pack policy (derived from PACK_POLICIES in cross-session-memory.ts):
 *
 *   pseudonymize mode:
 *     - Each identifierField in the policy is replaced with a deterministic
 *       pseudonym: psn_<16-hex-of-sha256(orgId + fieldName + originalValue)>.
 *     - 16 hex chars = 64 bits -- ~2^-64 collision probability per pair.
 *     - Pseudonyms are deterministic within an org (orgId in hash input).
 *     - Cross-org leakage prevented: different orgIds produce different pseudonyms.
 *     - Reverse mapping (pseudonym -> original) stored in a per-org in-memory vault.
 *     - v1 plan: persist vault to operator-side key-manager; vault is NEVER
 *       co-located with the audit chain.
 *
 *   retain mode:
 *     - Value stored as-is; entry bound to retentionDays window.
 *     - On expiry: CROSS_SESSION_MEMORY_RETAINED_EXPIRED (NOTICE) emitted.
 *     - Auto-delete via registered retention timer.
 *     - Early delete requires memory.early-delete permission (PT-PSEUDONYM-3).
 *
 * Audit chain integration:
 *   - applyPolicies() emits a memory-audit-chain 'set' operation after
 *     transformation. The TRANSFORMED value (pseudonymized form) goes to the
 *     chain. The original value NEVER reaches the chain.
 *
 * Pseudonym vault:
 *   - Per-org Map: orgId -> Map<pseudonym, originalValue>.
 *   - deanonymize requires memory.deanonymize permission (OperatorRoles).
 *   - Cross-org resolution rejected at the API level.
 *   - Vault is operator-controlled and out-of-band from audit logs.
 *   - THREAT: if the vault for org A is leaked, pseudonyms in the audit chain
 *     become reversible. Mitigated by keeping the vault separate from audit
 *     chain storage and scoping it per-org. The vault is never logged.
 *
 * Pen-test coverage:
 *   PT-PSEUDONYM-1: Vault-leak threat model (vault is org-scoped; cross-org
 *     leak does not expose org A's mappings from org B context).
 *   PT-PSEUDONYM-2: Pseudonym collision -- 16-hex prefix used (64-bit, ~2^-64
 *     per pair). Collision detection fires PSEUDONYM_COLLISION_DETECTED ERROR.
 *   PT-PSEUDONYM-3: Retention-bypass -- clearAllForAgent mid-retention requires
 *     memory.early-delete permission; rejected without it.
 *
 * BUILD_PLAN_V3_FULL_LIFECYCLE.md line 241.
 *
 * Compliance bindings:
 *   - GDPR Art. 4(5) + Recital 26: pseudonymization as data protection measure
 *   - HIPAA 164.514(b): safe harbour de-identification
 *   - HMDA Reg C 1003.6: data privacy in HMDA reporting
 *   - CCPA 1798.100: right to know / access
 *
 * @connexum/ai-governance P3-T1
 */
import * as crypto from 'crypto';
import { PACK_POLICIES } from './memory/cross-session-memory.js';
// ---------------------------------------------------------------------------
// Errors
// ---------------------------------------------------------------------------
/** Thrown when a cross-session write has pack binding but no applicable policy. */
export class NoApplicablePseudonymizeOrRetainPolicyError extends Error {
    orgId;
    agentId;
    key;
    packBinding;
    constructor(opts) {
        super(`No pseudonymize-or-retain policy applies to key '${opts.key}' for agent '${opts.agentId}' ` +
            `org '${opts.orgId}'. Bound packs [${opts.packBinding.join(', ')}] do not include a ` +
            `PackIdentifierPolicy (e.g. gdpr, hipaa, hmda). Defense-in-depth check -- ` +
            `primary gate is in SessionMemoryEnforcer.set(). ` +
            `Bind a compliant pack or downgrade the scope to 'session'.`);
        this.name = 'NoApplicablePseudonymizeOrRetainPolicyError';
        this.orgId = opts.orgId;
        this.agentId = opts.agentId;
        this.key = opts.key;
        this.packBinding = opts.packBinding;
    }
}
/** Thrown when deanonymize is attempted without memory.deanonymize permission. */
export class DeanonymizePermissionDeniedError extends Error {
    operatorId;
    orgId;
    constructor(opts) {
        super(`Operator '${opts.operatorId}' does not have 'memory.deanonymize' permission ` +
            `required to resolve pseudonyms for org '${opts.orgId}'. ` +
            `Grant the ADMIN role or contact your system administrator.`);
        this.name = 'DeanonymizePermissionDeniedError';
        this.operatorId = opts.operatorId;
        this.orgId = opts.orgId;
    }
}
/** Thrown when deanonymize is attempted for a pseudonym from a different org. */
export class CrossOrgResolutionRejectedError extends Error {
    requestingOrgId;
    constructor(requestingOrgId) {
        super(`Cross-org pseudonym resolution rejected for org '${requestingOrgId}'. ` +
            `Pseudonym vaults are strictly scoped per-org. ` +
            `Pseudonyms from one org cannot be resolved from another org context.`);
        this.name = 'CrossOrgResolutionRejectedError';
        this.requestingOrgId = requestingOrgId;
    }
}
/** Thrown when early deletion is attempted without memory.early-delete permission. */
export class EarlyDeletePermissionDeniedError extends Error {
    operatorId;
    orgId;
    agentId;
    constructor(opts) {
        super(`Operator '${opts.operatorId}' does not have 'memory.early-delete' permission ` +
            `required for early deletion of retain-mode entries for agent '${opts.agentId}' ` +
            `org '${opts.orgId}'. Retention window is active.`);
        this.name = 'EarlyDeletePermissionDeniedError';
        this.operatorId = opts.operatorId;
        this.orgId = opts.orgId;
        this.agentId = opts.agentId;
    }
}
// ---------------------------------------------------------------------------
// Pseudonym vault (per-org)
// ---------------------------------------------------------------------------
/** Pseudonym vault: orgId -> Map<pseudonym, originalValue>. NEVER logged. */
class PseudonymVault {
    /** Outer map keyed by orgId. Inner map keyed by pseudonym. */
    vaults = new Map();
    getOrCreate(orgId) {
        if (!this.vaults.has(orgId)) {
            this.vaults.set(orgId, new Map());
        }
        return this.vaults.get(orgId);
    }
    set(orgId, pseudonym, originalValue) {
        this.getOrCreate(orgId).set(pseudonym, originalValue);
    }
    /** Returns the original value, or undefined if not found in THIS org's vault. */
    get(orgId, pseudonym) {
        return this.vaults.get(orgId)?.get(pseudonym);
    }
    has(orgId, pseudonym) {
        return this.vaults.get(orgId)?.has(pseudonym) ?? false;
    }
    /** Return all pseudonyms for an org (for internal use only). */
    keysForOrg(orgId) {
        return [...(this.vaults.get(orgId)?.keys() ?? [])];
    }
    deleteForOrg(orgId) {
        this.vaults.delete(orgId);
    }
    /** Isolation check: returns false if the pseudonym exists in a DIFFERENT org's vault. */
    existsInOtherOrg(requestingOrgId, pseudonym) {
        for (const [orgId, vault] of this.vaults) {
            if (orgId !== requestingOrgId && vault.has(pseudonym))
                return true;
        }
        return false;
    }
}
// ---------------------------------------------------------------------------
// CrossSessionPseudonymizer
// ---------------------------------------------------------------------------
/**
 * CrossSessionPseudonymizer applies pack policies (pseudonymize or retain) to
 * cross-session memory writes and manages the reverse-lookup vault and
 * retention lifecycle.
 *
 * Integration recipe (PR #15 call site):
 *   After SessionMemoryEnforcer.set() gate passes (scope === 'cross-session'
 *   and hasPseudonymizeOrRetainRule(packBinding)), the call site that persists
 *   to CrossSessionMemoryStore should:
 *
 *     const { transformedValue, appliedPolicies } = pseudonymizer.applyPolicies(
 *       orgId, agentId, key, rawValue, 'cross-session', packBinding,
 *     );
 *     crossSessionStore.write(agentId, key, transformedValue);
 *     // transformedValue (pseudonymized form) goes to audit chain, never rawValue.
 *
 * This PR does NOT modify PR #15 source (SessionMemoryEnforcer) or PR #22
 * source (MemoryAuditChain). It provides the transformer that the integration
 * call site should use.
 */
export class CrossSessionPseudonymizer {
    /** Per-org pseudonym vaults. NEVER co-located with audit logs. */
    vault = new PseudonymVault();
    /** Retention registrations: (orgId, agentId, key) -> RetentionEntry. */
    retentions = new Map();
    operatorRoles;
    auditSink;
    auditChain;
    deleteCallback;
    /**
     * @param operatorRoles - Used for deanonymize and early-delete permission checks.
     * @param auditSink - Receives PSEUDONYM_COLLISION_DETECTED and CROSS_SESSION_MEMORY_RETAINED_EXPIRED events.
     * @param auditChain - Optional MemoryAuditChain. When supplied, applyPolicies() emits
     *   a 'set' recordOperation with the TRANSFORMED value (never the original).
     * @param deleteCallback - Called when a retain-mode entry expires (auto-delete).
     */
    constructor(opts) {
        this.operatorRoles = opts.operatorRoles;
        this.auditSink = opts.auditSink ?? (() => undefined);
        this.auditChain = opts.auditChain;
        this.deleteCallback = opts.deleteCallback;
    }
    // -------------------------------------------------------------------------
    // applyPolicies
    // -------------------------------------------------------------------------
    /**
     * Apply pseudonymize/retain policies to a cross-session write.
     *
     * For each pack in packBinding that has a matching PACK_POLICY:
     *   - pseudonymize: replace identifierField values with psn_<16hex> pseudonyms.
     *   - retain: record a retention window registration; value passed through as-is.
     *
     * Defense-in-depth: throws NoApplicablePseudonymizeOrRetainPolicyError if no
     * policy applies for a cross-session scope (mirrors the PR #15 gate).
     *
     * After transformation, emits a memory-audit-chain 'set' operation with the
     * TRANSFORMED value. The original value NEVER reaches the audit chain.
     *
     * @returns { transformedValue, appliedPolicies }
     */
    applyPolicies(orgId, agentId, key, value, scope, packBinding) {
        if (scope === 'cross-session') {
            const applicablePolicies = this.resolveApplicablePolicies(packBinding);
            if (applicablePolicies.length === 0) {
                throw new NoApplicablePseudonymizeOrRetainPolicyError({ orgId, agentId, key, packBinding });
            }
            let transformedValue = value;
            const appliedPolicies = [];
            for (const policy of applicablePolicies) {
                if (policy.mode === 'pseudonymize') {
                    const { result, fieldsTouched } = this.applyPseudonymize(orgId, value, policy);
                    transformedValue = result;
                    appliedPolicies.push({ packId: policy.packId, mode: 'pseudonymize', fieldsTouched });
                }
                else {
                    // retain mode: register lifecycle, value unchanged
                    this.registerRetention(orgId, agentId, key, policy.packId, policy.retentionDays ?? 30, packBinding);
                    appliedPolicies.push({
                        packId: policy.packId,
                        mode: 'retain',
                        fieldsTouched: [],
                        retentionDays: policy.retentionDays ?? 30,
                    });
                }
            }
            // Emit to audit chain: TRANSFORMED value only. Original never reaches chain.
            if (this.auditChain) {
                this.auditChain.recordOperation(orgId, agentId, {
                    kind: 'set',
                    key,
                    value: transformedValue,
                    scope,
                    packBinding: [...packBinding].sort(),
                    timestampNs: process.hrtime.bigint().toString(),
                });
            }
            return { transformedValue, appliedPolicies };
        }
        // Non-cross-session scope: pass through without transformation.
        return { transformedValue: value, appliedPolicies: [] };
    }
    // -------------------------------------------------------------------------
    // resolvePseudonym
    // -------------------------------------------------------------------------
    /**
     * Resolve a pseudonym back to the original value.
     *
     * Requires:
     *   - operatorId must have memory.deanonymize permission (OperatorRoles).
     *   - The pseudonym must belong to the requesting orgId's vault.
     *     Cross-org resolution is rejected with CrossOrgResolutionRejectedError.
     *
     * Returns undefined if the pseudonym is not found in the org's vault.
     */
    resolvePseudonym(operatorId, orgId, pseudonym) {
        const perm = this.operatorRoles.checkPermission(operatorId, 'memory.deanonymize');
        if (!perm.allowed) {
            throw new DeanonymizePermissionDeniedError({ operatorId, orgId });
        }
        // Cross-org isolation: reject if this pseudonym exists in a different org.
        if (!this.vault.has(orgId, pseudonym) && this.vault.existsInOtherOrg(orgId, pseudonym)) {
            throw new CrossOrgResolutionRejectedError(orgId);
        }
        return this.vault.get(orgId, pseudonym);
    }
    // -------------------------------------------------------------------------
    // clearAllForAgent
    // -------------------------------------------------------------------------
    /**
     * Clear all cross-session entries for an agent.
     *
     * Pseudonymize mode entries: deleted immediately. Emits a memory-audit-chain
     * 'delete' operation for each key if auditChain is wired.
     *
     * Retain mode entries: deferred to the retention window UNLESS:
     *   (a) the retention window has already elapsed (auto-expired), OR
     *   (b) the operatorId has memory.early-delete permission.
     *
     * Without early-delete permission, retain-mode entries still within their
     * window are NOT deleted -- clearAllForAgent returns them in a
     * deferredKeys list for the caller to surface to the operator.
     *
     * @returns { deletedKeys, deferredKeys } where deferredKeys are retain-mode
     *   entries that could not be deleted without early-delete permission.
     */
    clearAllForAgent(operatorId, orgId, agentId, packBinding) {
        const prefix = this.retentionKey(orgId, agentId, '');
        const deletedKeys = [];
        const deferredKeys = [];
        const toProcess = [];
        for (const rk of this.retentions.keys()) {
            if (rk.startsWith(prefix))
                toProcess.push(rk);
        }
        for (const rk of toProcess) {
            const entry = this.retentions.get(rk);
            const isExpired = this.isRetentionExpired(entry);
            if (isExpired || entry.expired) {
                // Already expired: cleanup timer and remove.
                clearTimeout(entry.timer);
                this.retentions.delete(rk);
                deletedKeys.push(entry.key);
                if (this.auditChain) {
                    this.auditChain.recordOperation(orgId, agentId, {
                        kind: 'delete',
                        key: entry.key,
                        scope: 'cross-session',
                        packBinding: packBinding.length > 0 ? packBinding : [entry.packId],
                        timestampNs: process.hrtime.bigint().toString(),
                    });
                }
            }
            else {
                // Retention window active: require early-delete permission.
                const perm = this.operatorRoles.checkPermission(operatorId, 'memory.early-delete');
                if (!perm.allowed) {
                    deferredKeys.push(entry.key);
                }
                else {
                    // Early delete authorized.
                    clearTimeout(entry.timer);
                    this.retentions.delete(rk);
                    deletedKeys.push(entry.key);
                    this.deleteCallback?.(orgId, agentId, entry.key);
                    if (this.auditChain) {
                        this.auditChain.recordOperation(orgId, agentId, {
                            kind: 'delete',
                            key: entry.key,
                            scope: 'cross-session',
                            packBinding: packBinding.length > 0 ? packBinding : [entry.packId],
                            timestampNs: process.hrtime.bigint().toString(),
                        });
                    }
                }
            }
        }
        return { deletedKeys, deferredKeys };
    }
    // -------------------------------------------------------------------------
    // Internal helpers
    // -------------------------------------------------------------------------
    /**
     * Resolve applicable policies from packBinding.
     * A policy applies when its packId appears in packBinding.
     * Mode: all packs in PACK_POLICIES use 'pseudonymize' mode.
     * Extend here when 'retain' mode packs are added to PACK_POLICIES.
     */
    resolveApplicablePolicies(packBinding) {
        const resolved = [];
        const bindingSet = new Set(packBinding);
        for (const policy of PACK_POLICIES) {
            if (bindingSet.has(policy.packId)) {
                // Current PACK_POLICIES all use 'pseudonymize' mode.
                // 'retain' mode is expressed as an extension: packs with retentionDays
                // but no identifierFields use retain mode.
                const mode = 'pseudonymize';
                resolved.push({
                    packId: policy.packId,
                    mode,
                    policy,
                    retentionDays: undefined,
                });
            }
        }
        return resolved;
    }
    /**
     * Apply pseudonymization to a value.
     *
     * Walks all string leaf values of the input object (JSON-serialized). For each
     * protected identifier class declared by the policy, replaces matches with
     * deterministic pseudonyms. Pseudonym format: psn_<16hex>.
     *
     * 16 hex chars = 64-bit space. Collision probability per distinct value pair:
     * ~2^-64. Over 10^6 distinct values: ~2^-44 total collision probability.
     * On collision: emits PSEUDONYM_COLLISION_DETECTED severity ERROR.
     *
     * Pseudonym derivation: SHA-256(orgId + '\x00' + fieldName + '\x00' + originalValue).
     * The '\x00' separators prevent concatenation ambiguity.
     */
    applyPseudonymize(orgId, value, resolvedPolicy) {
        const text = JSON.stringify(value);
        const fieldsTouched = [];
        let result = text;
        // For each protected class in the policy, replace matching patterns.
        for (const cls of resolvedPolicy.policy.protectedClasses) {
            const patterns = IDENTIFIER_PATTERNS[cls] ?? [];
            for (const pattern of patterns) {
                const globalPattern = new RegExp(pattern.source, 'g');
                result = result.replace(globalPattern, (match) => {
                    const pseudo = this.derivePseudonym(orgId, cls, match);
                    const fieldName = cls;
                    fieldsTouched.push(fieldName);
                    this.registerPseudonym(orgId, fieldName, match, pseudo);
                    return pseudo;
                });
            }
        }
        try {
            return { result: JSON.parse(result), fieldsTouched };
        }
        catch {
            return { result, fieldsTouched };
        }
    }
    /**
     * Derive a deterministic pseudonym for (orgId, fieldName, originalValue).
     * Format: psn_<16hex> (64-bit SHA-256 prefix).
     */
    derivePseudonym(orgId, fieldName, originalValue) {
        const input = orgId + '\x00' + fieldName + '\x00' + originalValue;
        const hex = crypto.createHash('sha256').update(input).digest('hex').slice(0, 16);
        return 'psn_' + hex;
    }
    /**
     * Register a pseudonym in the org vault. Detect collisions.
     */
    registerPseudonym(orgId, fieldName, originalValue, pseudonym) {
        if (this.vault.has(orgId, pseudonym)) {
            const existing = this.vault.get(orgId, pseudonym);
            if (existing !== originalValue) {
                // Collision detected: two distinct values produced the same pseudonym.
                const event = {
                    type: 'PSEUDONYM_COLLISION_DETECTED',
                    severity: 'ERROR',
                    orgId,
                    fieldName,
                    pseudonym,
                    existingValueHash: crypto.createHash('sha256').update(existing).digest('hex').slice(0, 16),
                    newValueHash: crypto.createHash('sha256').update(originalValue).digest('hex').slice(0, 16),
                    timestamp: new Date().toISOString(),
                };
                this.auditSink(event);
                // Do NOT overwrite: existing mapping preserved. Caller must handle ERROR event.
                return;
            }
            // Same value: idempotent -- no collision.
            return;
        }
        this.vault.set(orgId, pseudonym, originalValue);
    }
    /**
     * Register a retention window for a retain-mode entry.
     * Sets a timer that fires at expiry; emits CROSS_SESSION_MEMORY_RETAINED_EXPIRED
     * and calls deleteCallback.
     */
    registerRetention(orgId, agentId, key, packId, retentionDays, packBinding) {
        const rk = this.retentionKey(orgId, agentId, key);
        // Cancel any existing retention for this key.
        if (this.retentions.has(rk)) {
            clearTimeout(this.retentions.get(rk).timer);
        }
        const ttlMs = retentionDays * 24 * 60 * 60 * 1000;
        const entry = {
            orgId,
            agentId,
            key,
            packId,
            retentionDays,
            writtenAt: Date.now(),
            timer: setTimeout(() => {
                entry.expired = true;
                const expiredEvent = {
                    type: 'CROSS_SESSION_MEMORY_RETAINED_EXPIRED',
                    severity: 'NOTICE',
                    orgId,
                    agentId,
                    key,
                    packId,
                    retentionDays,
                    expiredAt: new Date().toISOString(),
                };
                this.auditSink(expiredEvent);
                // Delete from cross-session store.
                this.deleteCallback?.(orgId, agentId, key);
                // Emit memory audit chain delete.
                if (this.auditChain) {
                    this.auditChain.recordOperation(orgId, agentId, {
                        kind: 'delete',
                        key,
                        scope: 'cross-session',
                        packBinding: packBinding.length > 0 ? packBinding : [packId],
                        timestampNs: process.hrtime.bigint().toString(),
                    });
                }
                this.retentions.delete(rk);
            }, ttlMs),
            expired: false,
        };
        // Allow timer to not block process exit (Node.js pattern).
        if (typeof entry.timer.unref === 'function') {
            entry.timer.unref();
        }
        this.retentions.set(rk, entry);
    }
    retentionKey(orgId, agentId, key) {
        return `${orgId}::${agentId}::${key}`;
    }
    isRetentionExpired(entry) {
        const ttlMs = entry.retentionDays * 24 * 60 * 60 * 1000;
        return Date.now() - entry.writtenAt >= ttlMs;
    }
    // -------------------------------------------------------------------------
    // Test helpers (for deterministic time injection in tests)
    // -------------------------------------------------------------------------
    /**
     * Force-expire a retention entry (test-only). Fires the expiry callback
     * immediately without waiting for the timer.
     */
    _forceExpireRetention(orgId, agentId, key) {
        const rk = this.retentionKey(orgId, agentId, key);
        const entry = this.retentions.get(rk);
        if (!entry)
            return;
        clearTimeout(entry.timer);
        // Backdate writtenAt to simulate expiry.
        entry.writtenAt = 0;
        // Manually fire the expiry logic.
        entry.expired = true;
        const expiredEvent = {
            type: 'CROSS_SESSION_MEMORY_RETAINED_EXPIRED',
            severity: 'NOTICE',
            orgId,
            agentId,
            key,
            packId: entry.packId,
            retentionDays: entry.retentionDays,
            expiredAt: new Date().toISOString(),
        };
        this.auditSink(expiredEvent);
        this.deleteCallback?.(orgId, agentId, key);
        if (this.auditChain) {
            this.auditChain.recordOperation(orgId, agentId, {
                kind: 'delete',
                key,
                scope: 'cross-session',
                packBinding: [entry.packId],
                timestampNs: process.hrtime.bigint().toString(),
            });
        }
        this.retentions.delete(rk);
    }
    /** Expose vault for test isolation assertions. Returns a read-only snapshot. */
    _getVaultSnapshot(orgId) {
        const snap = new Map();
        for (const pseudo of this.vault.keysForOrg(orgId)) {
            snap.set(pseudo, this.vault.get(orgId, pseudo));
        }
        return snap;
    }
    /** Check if a retention entry exists (not yet expired). */
    _hasRetentionEntry(orgId, agentId, key) {
        const rk = this.retentionKey(orgId, agentId, key);
        const entry = this.retentions.get(rk);
        return !!entry && !entry.expired;
    }
}
// ---------------------------------------------------------------------------
// Shared identifier pattern registry (PR-2 HC-3 extraction)
// Previously a duplicate copy; now imported from the canonical shared module.
// The old comment said "This copy is intentional" -- it is no longer.
// ---------------------------------------------------------------------------
import { IDENTIFIER_PATTERNS } from './pii-patterns.js';
//# sourceMappingURL=cross-session-pseudonymizer.js.map