/**
 * PolicyMatrix -- P4-RET-02 Per-tenant per-contract retention resolver.
 *
 * Resolves the effective retention policy for a
 * (tenantId, contractId, dataClass) tuple using strictest-wins semantics
 * across applicable compliance packs plus optional contract-term overrides.
 *
 * Resolution order (AC 5):
 *   1. Explicit ContractTerm for (tenantId, contractId, dataClass) -- most
 *      specific; if active, it is the starting point before pack rules apply.
 *   2. Most-strict pack retention -- for every pack ID in packBinding,
 *      retentionDays = max(all applicable pack days), action computed per
 *      strictest-wins rules (see resolveAction()).
 *   3. Tenant default -- operator-level override above system default.
 *   4. System default.
 *
 * Strictest-wins (AC 5):
 *   - retentionDays: max of all applicable days (longer = stricter for
 *     compliance: you must keep data at least this long).
 *   - action: 'delete' wins when any pack has forceDelete=true.
 *     Otherwise 'archive' wins when any pack requires archiving.
 *     Otherwise 'delete'.
 *   - jurisdiction: union of all pack sovereignty + contract jurisdiction.
 *   - appliedHoldCheckRequired: true if any applicable pack has
 *     requiresLegalHoldSupport=true (default: true for all packs,
 *     per conservative default in pack extension).
 *
 * Fail-closed: MissingRetentionPolicyError when no policy source covers
 * the tuple (no pack, no contract term, no tenant default, no system default).
 *
 * Pack-binding primacy (Thomas directive 2026-04-22): pack retention is
 * read FIRST, then contract-term overrides are applied on top. A contract
 * term can extend retentionDays (stricter) but cannot shorten below the
 * max pack requirement.
 *
 * Spec: P4-RET-01 Section 2.2, AC 4-6
 *
 * @connexum/ai-governance
 */
import { MissingRetentionPolicyError, ConflictingContractTermsError, InvalidPackRetentionError, } from './policy-matrix-errors.js';
// ---------------------------------------------------------------------------
// Key helpers
// ---------------------------------------------------------------------------
/**
 * Build the map key for exact (tenant, contract, dataClass) lookup.
 */
function exactKey(tenantId, contractId, dataClass) {
    return `${tenantId}\0${contractId}\0${dataClass}`;
}
/**
 * Build the map key for wildcard (tenant, contract, any dataClass) lookup.
 */
function wildcardKey(tenantId, contractId) {
    return `${tenantId}\0${contractId}\0*`;
}
/**
 * Check if a ContractTerm is active at the given point in time.
 */
function isTermActive(term, at = new Date()) {
    const from = new Date(term.effectiveFrom).getTime();
    if (at.getTime() < from)
        return false;
    if (term.effectiveTo !== undefined && term.effectiveTo !== null) {
        const to = new Date(term.effectiveTo).getTime();
        if (at.getTime() > to)
            return false;
    }
    return true;
}
/**
 * Union two jurisdiction arrays without duplicates.
 */
function unionJurisdictions(a, b) {
    const set = new Set([...a, ...b]);
    return Array.from(set).sort();
}
/**
 * Resolve the effective action across all pack retention policies.
 *
 * Rules (AC 5):
 *   - If any pack has forceDelete=true, result is 'delete'.
 *   - Else if any pack has action='archive', result is 'archive'.
 *   - Else 'delete'.
 */
function resolveAction(policies) {
    if (policies.some((p) => p.forceDelete === true)) {
        return 'delete';
    }
    // Packs don't have an explicit top-level 'action' field in PackRetentionPolicy;
    // they declare retentionDays per data type. forceDelete controls delete vs archive.
    // When forceDelete is absent/false and no pack forces delete, we default to 'delete'
    // (safest: expired data is deleted unless a pack explicitly requires archiving).
    // A pack requires archiving when forceDelete is explicitly false AND it
    // also sets requiresLegalHoldSupport=true (indicating records may be needed).
    // NOTE: The strictest-wins spec says 'archive wins over delete when a pack
    // explicitly requires retention past the deletion date'. Since our pack
    // interface signals this via forceDelete=false (explicit), we treat any
    // pack that has forceDelete explicitly set to false as preferring 'archive'.
    const anyExplicitlyArchive = policies.some((p) => Object.prototype.hasOwnProperty.call(p, 'forceDelete') && p.forceDelete === false);
    if (anyExplicitlyArchive) {
        return 'archive';
    }
    return 'delete';
}
// ---------------------------------------------------------------------------
// PolicyMatrix
// ---------------------------------------------------------------------------
/**
 * Resolves the effective retention policy for a
 * (tenantId, contractId, dataClass) tuple.
 *
 * Construction validates all packs and indexes all contract terms.
 * Resolution is synchronous and fail-closed.
 */
export class PolicyMatrix {
    packsById;
    /**
     * Index of active contract terms.
     * Keys: exactKey(tenantId, contractId, dataClass) for class-specific terms,
     *       wildcardKey(tenantId, contractId) for class-wildcard terms.
     *
     * Value: the single active term for that key.
     * ConflictingContractTermsError thrown at construction if two active terms
     * share the same key.
     */
    exactTermIndex;
    wildcardTermIndex;
    /** Tenant defaults indexed by tenantId. */
    tenantDefaultsById;
    systemDefaults;
    allPacks;
    constructor(config) {
        // Validate and index packs.
        this.packsById = new Map();
        for (const pack of config.packs) {
            this.validatePackRetention(pack);
            this.packsById.set(pack.id, pack);
        }
        // Build contract term indexes. Throw on conflicts.
        this.exactTermIndex = new Map();
        this.wildcardTermIndex = new Map();
        const now = new Date();
        for (const term of config.contractTerms) {
            if (!isTermActive(term, now))
                continue;
            if (term.dataClass !== undefined && term.dataClass !== null && term.dataClass !== '') {
                const key = exactKey(term.tenantId, term.contractId, term.dataClass);
                const existing = this.exactTermIndex.get(key);
                if (existing) {
                    throw new ConflictingContractTermsError(term.tenantId, term.contractId, term.dataClass, existing, term);
                }
                this.exactTermIndex.set(key, term);
            }
            else {
                const key = wildcardKey(term.tenantId, term.contractId);
                const existing = this.wildcardTermIndex.get(key);
                if (existing) {
                    throw new ConflictingContractTermsError(term.tenantId, term.contractId, '', existing, term);
                }
                this.wildcardTermIndex.set(key, term);
            }
        }
        // Tenant defaults.
        this.tenantDefaultsById = new Map();
        for (const td of config.tenantDefaults ?? []) {
            this.tenantDefaultsById.set(td.tenantId, td);
        }
        this.systemDefaults = config.systemDefaults;
        this.allPacks = config.packs;
    }
    // -------------------------------------------------------------------------
    // resolve()
    // -------------------------------------------------------------------------
    /**
     * Resolve the effective retention for (tenantId, contractId, dataClass, packBinding).
     *
     * Resolution order (strictest-wins):
     *   1. Pack retention across all bound packs (primary -- pack binding primacy).
     *   2. Contract term override applied on top of pack floor.
     *   3. Tenant default if no pack or contract term applies.
     *   4. System default if nothing else applies.
     *
     * Throws MissingRetentionPolicyError if no source covers the tuple.
     * Throws UnboundDataError (via resolveForRecord) if packBinding is empty.
     *
     * @param input The tuple to resolve.
     * @returns The effective ResolvedRetention.
     */
    resolve(input) {
        const { tenantId, contractId, dataClass, packBinding } = input;
        // Collect applicable packs.
        const applicablePacks = [];
        for (const packId of packBinding) {
            const pack = this.packsById.get(packId);
            if (pack)
                applicablePacks.push(pack);
            // Packs not in the matrix config are silently skipped (the pack may
            // not declare a retentionPolicy row, but the binding still stands).
        }
        // --- Step 1: Pack floor (strictest-wins across all bound packs) ---
        let packRetentionDays = 0;
        let packAction = 'delete';
        let packJurisdiction = [];
        let holdCheckRequired = false;
        const drivingPackIds = [];
        let hasPackPolicy = false;
        if (applicablePacks.length > 0) {
            const policies = applicablePacks.map((p) => p.retentionPolicy);
            // Max retention across all packs (longest window = strictest).
            const maxDays = Math.max(...policies.map((p) => Math.max(p.auditLogRetentionDays ?? 0, p.sessionRetentionDays ?? 0, p.incidentRetentionDays ?? 0)));
            packRetentionDays = maxDays;
            packAction = resolveAction(policies);
            // Jurisdiction union from pack sovereignty.
            for (const policy of policies) {
                if (policy.sovereignty) {
                    packJurisdiction = unionJurisdictions(packJurisdiction, policy.sovereignty);
                }
            }
            // Hold check: true if ANY pack requires it.
            // Default is true (conservative) when field is absent.
            holdCheckRequired = policies.some((p) => p.requiresLegalHoldSupport !== false);
            // Track which packs drove the max retentionDays.
            for (const pack of applicablePacks) {
                const p = pack.retentionPolicy;
                const days = Math.max(p.auditLogRetentionDays ?? 0, p.sessionRetentionDays ?? 0, p.incidentRetentionDays ?? 0);
                if (days === maxDays) {
                    drivingPackIds.push(pack.id);
                }
                // Also include any forceDelete pack in strictestBoundBy.
                if (p.forceDelete === true && !drivingPackIds.includes(pack.id)) {
                    drivingPackIds.push(pack.id);
                }
            }
            hasPackPolicy = maxDays > 0 || packBinding.length > 0;
        }
        // --- Step 2: Contract term override ---
        // Exact class-specific term takes precedence over wildcard term.
        const exactTerm = this.exactTermIndex.get(exactKey(tenantId, contractId, dataClass));
        const wildcardTerm = this.wildcardTermIndex.get(wildcardKey(tenantId, contractId));
        const contractTerm = exactTerm ?? wildcardTerm;
        let contractRetentionDays = 0;
        let contractAction = null;
        let contractJurisdiction = [];
        let contractTermId = null;
        if (contractTerm) {
            contractRetentionDays = contractTerm.retentionDays;
            contractAction = contractTerm.action;
            contractJurisdiction = contractTerm.jurisdiction ?? [];
            contractTermId = contractTerm.termId;
        }
        // --- Merge pack floor + contract term ---
        let finalRetentionDays;
        let finalAction;
        let finalJurisdiction;
        let policySource;
        const strictestBoundBy = [];
        if (hasPackPolicy || contractTerm) {
            // Pack-binding primacy: start from pack floor.
            // Contract term can only increase retentionDays (stricter), not decrease.
            finalRetentionDays = packRetentionDays;
            finalAction = packAction;
            finalJurisdiction = [...packJurisdiction];
            strictestBoundBy.push(...drivingPackIds);
            if (contractTerm) {
                // Contract can extend the floor but not shorten it.
                if (contractRetentionDays > finalRetentionDays) {
                    finalRetentionDays = contractRetentionDays;
                    // Contract action only takes effect if it is stricter than pack action.
                    // 'delete' is stricter than 'archive' when the pack doesn't require delete.
                    // If pack already requires delete (forceDelete=true), delete stays.
                    if (finalAction !== 'delete') {
                        finalAction = contractAction ?? finalAction;
                    }
                    strictestBoundBy.push(contractTermId);
                }
                else if (contractRetentionDays === finalRetentionDays) {
                    // Same days -- merge action and mark contract term as a driver.
                    if (contractAction === 'delete') {
                        finalAction = 'delete';
                    }
                    if (contractTermId && !strictestBoundBy.includes(contractTermId)) {
                        strictestBoundBy.push(contractTermId);
                    }
                }
                finalJurisdiction = unionJurisdictions(finalJurisdiction, contractJurisdiction);
                policySource = 'contract';
            }
            else {
                policySource = 'pack';
            }
        }
        else {
            // No pack or contract term. Try tenant default, then system default.
            const tenantDefault = this.tenantDefaultsById.get(tenantId);
            if (tenantDefault) {
                finalRetentionDays = tenantDefault.retentionDays;
                finalAction = tenantDefault.action;
                finalJurisdiction = [];
                policySource = 'default';
                strictestBoundBy.push(`tenant-default:${tenantId}`);
            }
            else if (this.systemDefaults.retentionDays > 0) {
                // Only use system default when it specifies a meaningful (> 0) retention window.
                // A system default of 0 days is treated as "no policy configured" -- fail closed.
                finalRetentionDays = this.systemDefaults.retentionDays;
                finalAction = this.systemDefaults.action;
                finalJurisdiction = [];
                policySource = 'default';
                strictestBoundBy.push('system-default');
            }
            else {
                // Fail-closed: no policy covers this tuple.
                // systemDefaults.retentionDays is 0 (or negative) = operator has not configured
                // a meaningful default. Retention engine refuses to proceed without an explicit policy.
                throw new MissingRetentionPolicyError(tenantId, contractId, dataClass);
            }
        }
        return {
            retentionDays: finalRetentionDays,
            action: finalAction,
            archiveZone: finalAction === 'archive'
                ? (finalJurisdiction[0] ?? undefined)
                : undefined,
            jurisdiction: finalJurisdiction,
            policySource,
            strictestBoundBy,
            appliedHoldCheckRequired: holdCheckRequired,
        };
    }
    // -------------------------------------------------------------------------
    // resolveForRecord()
    // -------------------------------------------------------------------------
    /**
     * Convenience wrapper that reads tenantId, contractId, dataClass, and
     * packBinding directly off a DataClassTag.
     *
     * Throws UnboundDataError (re-thrown from caller) if tag.packBinding is
     * empty, since an unbound tag is a classification invariant violation.
     *
     * @param tag  A DataClassTag stamped at ingestion time.
     * @returns    The effective ResolvedRetention.
     */
    resolveForRecord(tag) {
        if (tag.packBinding.length === 0) {
            // Import the error class at call-site to avoid circular deps.
            // We throw the same error class that classifier.ts throws for empty bindings.
            const { UnboundDataError } = require('./classifier');
            throw new UnboundDataError('policy-matrix:resolveForRecord', tag.tenantId);
        }
        return this.resolve({
            tenantId: tag.tenantId,
            contractId: tag.contractId,
            dataClass: tag.dataClass,
            packBinding: tag.packBinding,
        });
    }
    // -------------------------------------------------------------------------
    // explain()
    // -------------------------------------------------------------------------
    /**
     * Auditor-facing method. Returns the resolved retention AND a step-by-step
     * rationale chain showing every source consulted and its contribution to
     * the final resolution.
     *
     * Each ExplainStep records: the source, the rule applied, and the
     * cumulative effect on retentionDays, action, jurisdiction, holdCheck.
     *
     * @param input  Same tuple as resolve().
     * @returns      { resolved, rationale } where rationale is ordered 1..N.
     */
    explain(input) {
        const { tenantId, contractId, dataClass, packBinding } = input;
        const steps = [];
        let runningDays = 0;
        let runningAction = this.systemDefaults.action;
        let runningJurisdiction = [];
        let runningHoldCheck = false;
        // --- Step 1: System default (always the baseline) ---
        runningDays = this.systemDefaults.retentionDays;
        runningAction = this.systemDefaults.action;
        steps.push({
            step: 1,
            source: 'system-default',
            sourceId: 'system',
            rule: `System default: retain ${runningDays} days, action=${runningAction}`,
            retentionDaysAfterStep: runningDays,
            actionAfterStep: runningAction,
            jurisdictionAfterStep: [],
            holdCheckRequiredAfterStep: false,
        });
        // --- Step 2: Tenant default (if present) ---
        const tenantDefault = this.tenantDefaultsById.get(tenantId);
        if (tenantDefault) {
            if (tenantDefault.retentionDays > runningDays) {
                runningDays = tenantDefault.retentionDays;
            }
            runningAction = tenantDefault.action;
            steps.push({
                step: steps.length + 1,
                source: 'tenant-default',
                sourceId: tenantId,
                rule: `Tenant default for "${tenantId}": retain ${tenantDefault.retentionDays} days, action=${tenantDefault.action}`,
                retentionDaysAfterStep: runningDays,
                actionAfterStep: runningAction,
                jurisdictionAfterStep: [...runningJurisdiction],
                holdCheckRequiredAfterStep: runningHoldCheck,
            });
        }
        // --- Step 3: Each pack in packBinding ---
        for (const packId of packBinding) {
            const pack = this.packsById.get(packId);
            if (!pack) {
                steps.push({
                    step: steps.length + 1,
                    source: 'pack',
                    sourceId: packId,
                    rule: `Pack "${packId}" not found in PolicyMatrix config; skipped.`,
                    retentionDaysAfterStep: runningDays,
                    actionAfterStep: runningAction,
                    jurisdictionAfterStep: [...runningJurisdiction],
                    holdCheckRequiredAfterStep: runningHoldCheck,
                });
                continue;
            }
            const policy = pack.retentionPolicy;
            const packMaxDays = Math.max(policy.auditLogRetentionDays ?? 0, policy.sessionRetentionDays ?? 0, policy.incidentRetentionDays ?? 0);
            const prevDays = runningDays;
            const prevAction = runningAction;
            if (packMaxDays > runningDays) {
                runningDays = packMaxDays;
            }
            // Resolve action with this pack included.
            if (policy.forceDelete === true) {
                runningAction = 'delete';
            }
            else if (Object.prototype.hasOwnProperty.call(policy, 'forceDelete') &&
                policy.forceDelete === false &&
                runningAction !== 'delete') {
                runningAction = 'archive';
            }
            // Merge jurisdiction.
            if (policy.sovereignty) {
                runningJurisdiction = unionJurisdictions(runningJurisdiction, policy.sovereignty);
            }
            // Hold check: conservative default.
            if (policy.requiresLegalHoldSupport !== false) {
                runningHoldCheck = true;
            }
            const daysChanged = packMaxDays > prevDays;
            const actionChanged = runningAction !== prevAction;
            steps.push({
                step: steps.length + 1,
                source: 'pack',
                sourceId: packId,
                rule: `Pack "${packId}" (${pack.name}): ` +
                    `max retention=${packMaxDays} days ` +
                    `(auditLog=${policy.auditLogRetentionDays}, session=${policy.sessionRetentionDays}, incident=${policy.incidentRetentionDays}), ` +
                    `forceDelete=${policy.forceDelete ?? 'unset'}, ` +
                    `requiresLegalHoldSupport=${policy.requiresLegalHoldSupport ?? 'unset (default true)'}. ` +
                    (daysChanged ? `Raised retention floor to ${runningDays} days. ` : '') +
                    (actionChanged ? `Changed action to "${runningAction}". ` : '') +
                    (policy.sovereignty ? `Added jurisdictions: ${policy.sovereignty.join(', ')}.` : ''),
                retentionDaysAfterStep: runningDays,
                actionAfterStep: runningAction,
                jurisdictionAfterStep: [...runningJurisdiction],
                holdCheckRequiredAfterStep: runningHoldCheck,
            });
        }
        // --- Step 4: Contract term ---
        const exactTerm = this.exactTermIndex.get(exactKey(tenantId, contractId, dataClass));
        const wildcardTerm = this.wildcardTermIndex.get(wildcardKey(tenantId, contractId));
        const contractTerm = exactTerm ?? wildcardTerm;
        if (contractTerm) {
            const prevDays = runningDays;
            const contractDays = contractTerm.retentionDays;
            const contractJurisdiction = contractTerm.jurisdiction ?? [];
            if (contractDays > runningDays) {
                runningDays = contractDays;
                if (runningAction !== 'delete') {
                    runningAction = contractTerm.action;
                }
            }
            else if (contractTerm.action === 'delete') {
                runningAction = 'delete';
            }
            runningJurisdiction = unionJurisdictions(runningJurisdiction, contractJurisdiction);
            const termScope = contractTerm.dataClass
                ? `(specific to dataClass="${contractTerm.dataClass}")`
                : `(wildcard, applies to all data classes)`;
            steps.push({
                step: steps.length + 1,
                source: 'contract-term',
                sourceId: contractTerm.termId,
                rule: `ContractTerm "${contractTerm.termId}" for tenant "${tenantId}", contract "${contractId}" ${termScope}: ` +
                    `retentionDays=${contractDays}, action=${contractTerm.action}, ` +
                    `jurisdiction=[${contractJurisdiction.join(', ')}]. ` +
                    (contractDays > prevDays
                        ? `Extended retention floor from ${prevDays} to ${runningDays} days. `
                        : `Pack floor (${prevDays} days) exceeds contract term (${contractDays} days); pack floor retained. `) +
                    `Final jurisdiction union: [${runningJurisdiction.join(', ')}].`,
                retentionDaysAfterStep: runningDays,
                actionAfterStep: runningAction,
                jurisdictionAfterStep: [...runningJurisdiction],
                holdCheckRequiredAfterStep: runningHoldCheck,
            });
        }
        // Get the resolved retention (re-use resolve() to ensure consistency).
        let resolved;
        try {
            resolved = this.resolve(input);
        }
        catch (err) {
            // If resolve() throws MissingRetentionPolicyError, re-throw with the
            // rationale steps attached so the auditor can see what was consulted.
            throw err;
        }
        return { resolved, rationale: steps };
    }
    // -------------------------------------------------------------------------
    // resolveAll()
    // -------------------------------------------------------------------------
    /**
     * Resolve the full matrix for a tenant+contract combination:
     * one row per DataClass for the given packBinding.
     *
     * Returns an array of { dataClass, resolved } tuples. Used by the
     * GET /api/v1/retention/matrix endpoint.
     *
     * @param tenantId    Tenant to resolve for.
     * @param contractId  Contract to resolve for.
     * @param packBinding Active packs for this tenant's contract.
     */
    resolveAll(tenantId, contractId, packBinding) {
        // Import DataClass enum to iterate all known classes.
        const { DataClass } = require('./data-class');
        const results = [];
        for (const dc of Object.values(DataClass)) {
            try {
                const resolved = this.resolve({
                    tenantId,
                    contractId,
                    dataClass: dc,
                    packBinding,
                });
                results.push({ dataClass: dc, resolved });
            }
            catch (err) {
                const e = err;
                results.push({ dataClass: dc, resolved: null, error: e.message });
            }
        }
        return results;
    }
    // -------------------------------------------------------------------------
    // Private: validatePackRetention()
    // -------------------------------------------------------------------------
    /**
     * Validate that a pack's retentionPolicy is well-formed at construction
     * time. Throws InvalidPackRetentionError on any malformed value.
     */
    validatePackRetention(pack) {
        const p = pack.retentionPolicy;
        if (!p) {
            throw new InvalidPackRetentionError(pack.id, 'retentionPolicy is missing');
        }
        if (p.auditLogRetentionDays !== undefined &&
            (typeof p.auditLogRetentionDays !== 'number' || p.auditLogRetentionDays < 0)) {
            throw new InvalidPackRetentionError(pack.id, `auditLogRetentionDays must be a non-negative number, got ${p.auditLogRetentionDays}`);
        }
        if (p.sessionRetentionDays !== undefined &&
            (typeof p.sessionRetentionDays !== 'number' || p.sessionRetentionDays < 0)) {
            throw new InvalidPackRetentionError(pack.id, `sessionRetentionDays must be a non-negative number, got ${p.sessionRetentionDays}`);
        }
        if (p.incidentRetentionDays !== undefined &&
            (typeof p.incidentRetentionDays !== 'number' || p.incidentRetentionDays < 0)) {
            throw new InvalidPackRetentionError(pack.id, `incidentRetentionDays must be a non-negative number, got ${p.incidentRetentionDays}`);
        }
    }
}
//# sourceMappingURL=policy-matrix.js.map