// SPDX-License-Identifier: Apache-2.0
// Copyright 2024-2026 Vorion LLC
/**
 * Audit Service
 *
 * Records and queries audit events with chain integrity.
 *
 * @packageDocumentation
 */
import { eq, and, desc, asc, gte, lte, lt, sql } from 'drizzle-orm';
import { createHash } from 'crypto';
import { createLogger } from '../common/logger.js';
import { secureRandomString } from '../common/random.js';
import { getDatabase } from '../common/db.js';
import { getTraceContext } from '../common/trace.js';
import { auditRecords } from '../intent/schema.js';
import { DatabaseError, ServiceError, isVorionError } from '../common/errors.js';
import { AUDIT_EVENT_TYPES as eventTypes } from './types.js';
const logger = createLogger({ component: 'audit-service' });
/**
 * Generate a unique request ID if not provided
 */
function generateRequestId() {
    return `req-${Date.now()}-${secureRandomString(7)}`;
}
/**
 * Compute hash for an audit record
 */
function computeRecordHash(record) {
    const payload = {
        tenantId: record.tenantId,
        eventType: record.eventType,
        actor: record.actor,
        target: record.target,
        action: record.action,
        outcome: record.outcome,
        sequenceNumber: record.sequenceNumber,
        previousHash: record.previousHash,
        eventTime: record.eventTime,
    };
    return createHash('sha256')
        .update(JSON.stringify(payload, Object.keys(payload).sort()))
        .digest('hex');
}
/**
 * Get event metadata (category and severity) for an event type
 */
function getEventMetadata(eventType) {
    const metadata = eventTypes[eventType];
    if (metadata) {
        return metadata;
    }
    // Default for unknown event types
    return { category: 'system', severity: 'info' };
}
/**
 * Audit Service class
 */
export class AuditService {
    db = null;
    injectedDb = null;
    /**
     * Create a new AuditService instance.
     *
     * @param deps - Optional dependencies for dependency injection.
     *               If not provided, uses global singletons (backward compatible).
     *
     * @example
     * // Default usage (backward compatible)
     * const service = new AuditService();
     *
     * @example
     * // With dependency injection (for testing)
     * const service = new AuditService({ database: mockDb });
     */
    constructor(deps = {}) {
        if (deps.database) {
            this.injectedDb = deps.database;
            this.db = deps.database;
        }
    }
    /**
     * Get the database instance (lazy initialization if not injected)
     */
    getDb() {
        if (!this.db) {
            this.db = this.injectedDb ?? getDatabase();
        }
        return this.db;
    }
    /**
     * Record an audit event
     */
    async record(input) {
        try {
            const db = this.getDb();
            // Get trace context if available
            const traceContext = getTraceContext();
            // Determine event metadata
            const { category, severity } = getEventMetadata(input.eventType);
            // Generate or use provided IDs
            const requestId = input.requestId ?? traceContext?.traceId ?? generateRequestId();
            const eventTime = input.eventTime ?? new Date().toISOString();
            // Get the latest record for this tenant to build the chain
            const [latestRecord] = await db
                .select({
                sequenceNumber: auditRecords.sequenceNumber,
                recordHash: auditRecords.recordHash,
            })
                .from(auditRecords)
                .where(eq(auditRecords.tenantId, input.tenantId))
                .orderBy(desc(auditRecords.sequenceNumber))
                .limit(1);
            const sequenceNumber = Number(latestRecord?.sequenceNumber ?? 0) + 1;
            const previousHash = latestRecord?.recordHash ?? null;
            // Compute the record hash
            const recordHash = computeRecordHash({
                ...input,
                requestId,
                sequenceNumber,
                previousHash,
                eventTime,
            });
            // Insert the record
            const [row] = await db
                .insert(auditRecords)
                .values({
                tenantId: input.tenantId,
                eventType: input.eventType,
                eventCategory: category,
                severity,
                actorType: input.actor.type,
                actorId: input.actor.id,
                actorName: input.actor.name,
                actorIp: input.actor.ip,
                targetType: input.target.type,
                targetId: input.target.id,
                targetName: input.target.name,
                requestId,
                traceId: input.traceId ?? traceContext?.traceId,
                spanId: input.spanId ?? traceContext?.spanId,
                action: input.action,
                outcome: input.outcome,
                reason: input.reason,
                beforeState: input.stateChange?.before,
                afterState: input.stateChange?.after,
                diffState: input.stateChange?.diff,
                metadata: input.metadata,
                tags: input.tags,
                sequenceNumber,
                previousHash,
                recordHash,
                eventTime: new Date(eventTime),
            })
                .returning();
            if (!row) {
                throw new DatabaseError('Failed to insert audit record - no row returned', {
                    operation: 'record',
                    tenantId: input.tenantId,
                    eventType: input.eventType,
                });
            }
            logger.info({
                auditId: row.id,
                eventType: input.eventType,
                tenantId: input.tenantId,
                actorId: input.actor.id,
                targetId: input.target.id,
                sequenceNumber,
            }, 'Audit event recorded');
            return this.rowToAuditRecord(row);
        }
        catch (error) {
            if (isVorionError(error)) {
                throw error;
            }
            logger.error({ error, tenantId: input.tenantId, eventType: input.eventType }, 'Failed to record audit event');
            throw new DatabaseError(`Failed to record audit event: ${error instanceof Error ? error.message : 'Unknown error'}`, { operation: 'record', tenantId: input.tenantId, eventType: input.eventType });
        }
    }
    /**
     * Query audit records with filters
     */
    async query(filters) {
        try {
            const db = this.getDb();
            const conditions = [eq(auditRecords.tenantId, filters.tenantId)];
            if (filters.eventType) {
                conditions.push(eq(auditRecords.eventType, filters.eventType));
            }
            if (filters.eventCategory) {
                conditions.push(eq(auditRecords.eventCategory, filters.eventCategory));
            }
            if (filters.severity) {
                conditions.push(eq(auditRecords.severity, filters.severity));
            }
            if (filters.actorId) {
                conditions.push(eq(auditRecords.actorId, filters.actorId));
            }
            if (filters.actorType) {
                conditions.push(eq(auditRecords.actorType, filters.actorType));
            }
            if (filters.targetId) {
                conditions.push(eq(auditRecords.targetId, filters.targetId));
            }
            if (filters.targetType) {
                conditions.push(eq(auditRecords.targetType, filters.targetType));
            }
            if (filters.outcome) {
                conditions.push(eq(auditRecords.outcome, filters.outcome));
            }
            if (filters.requestId) {
                conditions.push(eq(auditRecords.requestId, filters.requestId));
            }
            if (filters.traceId) {
                conditions.push(eq(auditRecords.traceId, filters.traceId));
            }
            if (filters.startTime) {
                conditions.push(gte(auditRecords.eventTime, new Date(filters.startTime)));
            }
            if (filters.endTime) {
                conditions.push(lte(auditRecords.eventTime, new Date(filters.endTime)));
            }
            if (filters.tags && filters.tags.length > 0) {
                // Check if any of the provided tags are in the record's tags array
                conditions.push(sql `${auditRecords.tags} && ${filters.tags}`);
            }
            const limit = Math.min(filters.limit ?? 50, 1000);
            const offset = filters.offset ?? 0;
            const orderBy = filters.orderBy === 'recordedAt' ? auditRecords.recordedAt : auditRecords.eventTime;
            const orderFn = filters.orderDirection === 'asc' ? asc : desc;
            // Get total count
            const [countResult] = await db
                .select({ count: sql `count(*)` })
                .from(auditRecords)
                .where(and(...conditions));
            const total = Number(countResult?.count ?? 0);
            // Get records
            const rows = await db
                .select()
                .from(auditRecords)
                .where(and(...conditions))
                .orderBy(orderFn(orderBy))
                .limit(limit)
                .offset(offset);
            return {
                records: rows.map((row) => this.rowToAuditRecord(row)),
                total,
                hasMore: offset + rows.length < total,
            };
        }
        catch (error) {
            if (isVorionError(error)) {
                throw error;
            }
            logger.error({ error, tenantId: filters.tenantId }, 'Failed to query audit records');
            throw new DatabaseError(`Failed to query audit records: ${error instanceof Error ? error.message : 'Unknown error'}`, { operation: 'query', tenantId: filters.tenantId });
        }
    }
    /**
     * Get a single audit record by ID
     */
    async findById(id, tenantId) {
        try {
            const db = this.getDb();
            const [row] = await db
                .select()
                .from(auditRecords)
                .where(and(eq(auditRecords.id, id), eq(auditRecords.tenantId, tenantId)))
                .limit(1);
            return row ? this.rowToAuditRecord(row) : null;
        }
        catch (error) {
            logger.error({ error, id, tenantId }, 'Failed to find audit record by ID');
            throw new DatabaseError(`Failed to find audit record: ${error instanceof Error ? error.message : 'Unknown error'}`, { operation: 'findById', id, tenantId });
        }
    }
    /**
     * Get audit records for a specific target
     */
    async getForTarget(tenantId, targetType, targetId, options) {
        try {
            const db = this.getDb();
            const limit = options?.limit ?? 100;
            const offset = options?.offset ?? 0;
            const rows = await db
                .select()
                .from(auditRecords)
                .where(and(eq(auditRecords.tenantId, tenantId), eq(auditRecords.targetType, targetType), eq(auditRecords.targetId, targetId)))
                .orderBy(desc(auditRecords.eventTime))
                .limit(limit)
                .offset(offset);
            return rows.map((row) => this.rowToAuditRecord(row));
        }
        catch (error) {
            logger.error({ error, tenantId, targetType, targetId }, 'Failed to get audit records for target');
            throw new DatabaseError(`Failed to get audit records for target: ${error instanceof Error ? error.message : 'Unknown error'}`, { operation: 'getForTarget', tenantId, targetType, targetId });
        }
    }
    /**
     * Get audit records for a specific trace
     */
    async getByTrace(tenantId, traceId) {
        try {
            const db = this.getDb();
            const rows = await db
                .select()
                .from(auditRecords)
                .where(and(eq(auditRecords.tenantId, tenantId), eq(auditRecords.traceId, traceId)))
                .orderBy(asc(auditRecords.eventTime));
            return rows.map((row) => this.rowToAuditRecord(row));
        }
        catch (error) {
            logger.error({ error, tenantId, traceId }, 'Failed to get audit records by trace');
            throw new DatabaseError(`Failed to get audit records by trace: ${error instanceof Error ? error.message : 'Unknown error'}`, { operation: 'getByTrace', tenantId, traceId });
        }
    }
    /**
     * Verify chain integrity for a tenant
     */
    async verifyChainIntegrity(tenantId, options) {
        try {
            const db = this.getDb();
            const conditions = [eq(auditRecords.tenantId, tenantId)];
            if (options?.startSequence !== undefined) {
                conditions.push(gte(auditRecords.sequenceNumber, options.startSequence));
            }
            const limit = options?.limit ?? 10000;
            const rows = await db
                .select({
                id: auditRecords.id,
                sequenceNumber: auditRecords.sequenceNumber,
                previousHash: auditRecords.previousHash,
                recordHash: auditRecords.recordHash,
            })
                .from(auditRecords)
                .where(and(...conditions))
                .orderBy(asc(auditRecords.sequenceNumber))
                .limit(limit);
            if (rows.length === 0) {
                return {
                    valid: true,
                    recordsChecked: 0,
                };
            }
            let previousHash = null;
            let firstRecord;
            let lastRecord;
            for (const row of rows) {
                if (!firstRecord) {
                    firstRecord = row.id;
                }
                lastRecord = row.id;
                // First record in sequence should have null previousHash or match our starting point
                if (previousHash !== null && row.previousHash !== previousHash) {
                    logger.error({
                        recordId: row.id,
                        sequenceNumber: row.sequenceNumber,
                        expectedPreviousHash: previousHash,
                        actualPreviousHash: row.previousHash,
                    }, 'Chain integrity violation detected');
                    return {
                        valid: false,
                        recordsChecked: rows.indexOf(row) + 1,
                        firstRecord,
                        lastRecord: row.id,
                        brokenAt: row.id,
                        error: `Hash chain broken at sequence ${row.sequenceNumber}`,
                    };
                }
                previousHash = row.recordHash;
            }
            return {
                valid: true,
                recordsChecked: rows.length,
                firstRecord,
                lastRecord,
            };
        }
        catch (error) {
            logger.error({ error, tenantId }, 'Failed to verify chain integrity');
            throw new ServiceError(`Failed to verify chain integrity: ${error instanceof Error ? error.message : 'Unknown error'}`, 'audit', 'verifyChainIntegrity', { tenantId });
        }
    }
    /**
     * Get audit statistics for a tenant
     */
    async getStats(tenantId, options) {
        const db = this.getDb();
        const conditions = [eq(auditRecords.tenantId, tenantId)];
        if (options?.startTime) {
            conditions.push(gte(auditRecords.eventTime, new Date(options.startTime)));
        }
        if (options?.endTime) {
            conditions.push(lte(auditRecords.eventTime, new Date(options.endTime)));
        }
        // Total count
        const [totalResult] = await db
            .select({ count: sql `count(*)` })
            .from(auditRecords)
            .where(and(...conditions));
        // By category
        const categoryResults = await db
            .select({
            category: auditRecords.eventCategory,
            count: sql `count(*)`,
        })
            .from(auditRecords)
            .where(and(...conditions))
            .groupBy(auditRecords.eventCategory);
        // By severity
        const severityResults = await db
            .select({
            severity: auditRecords.severity,
            count: sql `count(*)`,
        })
            .from(auditRecords)
            .where(and(...conditions))
            .groupBy(auditRecords.severity);
        // By outcome
        const outcomeResults = await db
            .select({
            outcome: auditRecords.outcome,
            count: sql `count(*)`,
        })
            .from(auditRecords)
            .where(and(...conditions))
            .groupBy(auditRecords.outcome);
        return {
            totalRecords: Number(totalResult?.count ?? 0),
            byCategory: Object.fromEntries(categoryResults.map((r) => [r.category, Number(r.count)])),
            bySeverity: Object.fromEntries(severityResults.map((r) => [r.severity, Number(r.count)])),
            byOutcome: Object.fromEntries(outcomeResults.map((r) => [r.outcome, Number(r.count)])),
        };
    }
    /**
     * Convert database row to AuditRecord
     */
    rowToAuditRecord(row) {
        return {
            id: row.id,
            tenantId: row.tenantId,
            eventType: row.eventType,
            eventCategory: row.eventCategory,
            severity: row.severity,
            actor: {
                type: row.actorType,
                id: row.actorId,
                name: row.actorName ?? undefined,
                ip: row.actorIp ?? undefined,
            },
            target: {
                type: row.targetType,
                id: row.targetId,
                name: row.targetName ?? undefined,
            },
            requestId: row.requestId,
            traceId: row.traceId,
            spanId: row.spanId,
            action: row.action,
            outcome: row.outcome,
            reason: row.reason,
            stateChange: (row.beforeState || row.afterState || row.diffState)
                ? {
                    before: row.beforeState,
                    after: row.afterState,
                    diff: row.diffState,
                }
                : undefined,
            metadata: row.metadata,
            tags: row.tags ?? undefined,
            sequenceNumber: Number(row.sequenceNumber),
            previousHash: row.previousHash,
            recordHash: row.recordHash,
            eventTime: row.eventTime.toISOString(),
            recordedAt: row.recordedAt.toISOString(),
            archived: row.archived,
            archivedAt: row.archivedAt?.toISOString() ?? null,
        };
    }
    // ==========================================================================
    // ARCHIVE & RETENTION METHODS
    // ==========================================================================
    /**
     * Archive audit records older than specified days.
     * Archived records are marked but not deleted, preserving chain integrity.
     *
     * @param archiveAfterDays - Archive records older than this many days
     * @param batchSize - Number of records to process per batch
     * @returns Archive operation result
     */
    async archiveOldRecords(archiveAfterDays, batchSize = 1000) {
        const startTime = performance.now();
        const db = this.getDb();
        const cutoffDate = new Date();
        cutoffDate.setDate(cutoffDate.getDate() - archiveAfterDays);
        // Update records in batches to avoid locking issues
        const result = await db
            .update(auditRecords)
            .set({
            archived: true,
            archivedAt: new Date(),
        })
            .where(and(eq(auditRecords.archived, false), lt(auditRecords.eventTime, cutoffDate)))
            .returning({
            id: auditRecords.id,
            eventTime: auditRecords.eventTime,
        });
        const durationMs = Math.round(performance.now() - startTime);
        const sortedByTime = result.sort((a, b) => a.eventTime.getTime() - b.eventTime.getTime());
        const archiveResult = {
            recordsArchived: result.length,
            durationMs,
            oldestArchivedDate: sortedByTime[0]?.eventTime.toISOString(),
            newestArchivedDate: sortedByTime[sortedByTime.length - 1]?.eventTime.toISOString(),
        };
        logger.info({
            recordsArchived: archiveResult.recordsArchived,
            archiveAfterDays,
            cutoffDate: cutoffDate.toISOString(),
            durationMs,
        }, 'Audit records archived');
        return archiveResult;
    }
    /**
     * Permanently delete audit records older than the retention period.
     * Only deletes archived records to ensure recent records are preserved.
     *
     * IMPORTANT: This permanently removes data. Ensure compliance requirements
     * are met before calling this method.
     *
     * @param retentionDays - Delete records older than this many days
     * @param batchSize - Number of records to delete per batch
     * @returns Purge operation result
     */
    async purgeOldRecords(retentionDays, batchSize = 1000) {
        const startTime = performance.now();
        const db = this.getDb();
        const cutoffDate = new Date();
        cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
        // Only delete archived records past retention period
        // This provides a safety net: records must be archived first
        const result = await db
            .delete(auditRecords)
            .where(and(eq(auditRecords.archived, true), lt(auditRecords.eventTime, cutoffDate)))
            .returning({
            id: auditRecords.id,
            eventTime: auditRecords.eventTime,
        });
        const durationMs = Math.round(performance.now() - startTime);
        const sortedByTime = result.sort((a, b) => a.eventTime.getTime() - b.eventTime.getTime());
        const purgeResult = {
            recordsPurged: result.length,
            durationMs,
            oldestPurgedDate: sortedByTime[0]?.eventTime.toISOString(),
            newestPurgedDate: sortedByTime[sortedByTime.length - 1]?.eventTime.toISOString(),
        };
        logger.info({
            recordsPurged: purgeResult.recordsPurged,
            retentionDays,
            cutoffDate: cutoffDate.toISOString(),
            durationMs,
        }, 'Audit records purged');
        return purgeResult;
    }
    /**
     * Run full audit cleanup: archive old records, then purge expired ones.
     * This is the main entry point for scheduled audit maintenance.
     *
     * @param options - Cleanup configuration
     * @returns Combined cleanup result
     */
    async runCleanup(options) {
        const startTime = performance.now();
        const errors = [];
        const batchSize = options.batchSize ?? 1000;
        let archived = {
            recordsArchived: 0,
            durationMs: 0,
        };
        let purged = {
            recordsPurged: 0,
            durationMs: 0,
        };
        // Step 1: Archive old records (if enabled)
        if (options.archiveEnabled !== false) {
            try {
                archived = await this.archiveOldRecords(options.archiveAfterDays, batchSize);
            }
            catch (error) {
                const message = error instanceof Error ? error.message : 'Unknown error';
                errors.push(`Archive failed: ${message}`);
                logger.error({ error }, 'Audit archive failed');
            }
        }
        // Step 2: Purge expired archived records
        try {
            purged = await this.purgeOldRecords(options.retentionDays, batchSize);
        }
        catch (error) {
            const message = error instanceof Error ? error.message : 'Unknown error';
            errors.push(`Purge failed: ${message}`);
            logger.error({ error }, 'Audit purge failed');
        }
        const totalDurationMs = Math.round(performance.now() - startTime);
        const result = {
            archived,
            purged,
            totalDurationMs,
            errors,
        };
        logger.info({
            archived: archived.recordsArchived,
            purged: purged.recordsPurged,
            errors: errors.length,
            totalDurationMs,
        }, 'Audit cleanup completed');
        return result;
    }
    /**
     * Get retention statistics for a tenant
     */
    async getRetentionStats(tenantId) {
        const db = this.getDb();
        // Total count
        const [totalResult] = await db
            .select({ count: sql `count(*)` })
            .from(auditRecords)
            .where(eq(auditRecords.tenantId, tenantId));
        // Active (non-archived) count
        const [activeResult] = await db
            .select({ count: sql `count(*)` })
            .from(auditRecords)
            .where(and(eq(auditRecords.tenantId, tenantId), eq(auditRecords.archived, false)));
        // Archived count
        const [archivedResult] = await db
            .select({ count: sql `count(*)` })
            .from(auditRecords)
            .where(and(eq(auditRecords.tenantId, tenantId), eq(auditRecords.archived, true)));
        // Oldest record
        const [oldest] = await db
            .select({ eventTime: auditRecords.eventTime })
            .from(auditRecords)
            .where(eq(auditRecords.tenantId, tenantId))
            .orderBy(asc(auditRecords.eventTime))
            .limit(1);
        // Newest record
        const [newest] = await db
            .select({ eventTime: auditRecords.eventTime })
            .from(auditRecords)
            .where(eq(auditRecords.tenantId, tenantId))
            .orderBy(desc(auditRecords.eventTime))
            .limit(1);
        // Oldest archived
        const [oldestArchived] = await db
            .select({ archivedAt: auditRecords.archivedAt })
            .from(auditRecords)
            .where(and(eq(auditRecords.tenantId, tenantId), eq(auditRecords.archived, true)))
            .orderBy(asc(auditRecords.archivedAt))
            .limit(1);
        return {
            totalRecords: Number(totalResult?.count ?? 0),
            activeRecords: Number(activeResult?.count ?? 0),
            archivedRecords: Number(archivedResult?.count ?? 0),
            oldestRecord: oldest?.eventTime.toISOString(),
            newestRecord: newest?.eventTime.toISOString(),
            oldestArchived: oldestArchived?.archivedAt?.toISOString(),
        };
    }
}
/**
 * Create a new audit service instance with dependency injection.
 *
 * This is the preferred way to create services in production code
 * as it makes dependencies explicit and testable.
 *
 * @param deps - Optional dependencies. If not provided, uses global singletons.
 * @returns Configured AuditService instance
 *
 * @example
 * // Default usage (backward compatible)
 * const service = createAuditService();
 *
 * @example
 * // With custom dependencies
 * const service = createAuditService({ database: customDb });
 */
export function createAuditService(deps = {}) {
    return new AuditService(deps);
}
/**
 * Convenience function for recording common audit events
 */
export class AuditHelper {
    service;
    constructor(service) {
        this.service = service;
    }
    /**
     * Record an intent lifecycle event
     */
    async recordIntentEvent(tenantId, eventType, intentId, actor, options) {
        return this.service.record({
            tenantId,
            eventType,
            actor,
            target: { type: 'intent', id: intentId },
            action: eventType.replace('intent.', ''),
            outcome: options?.outcome ?? 'success',
            reason: options?.reason,
            stateChange: options?.stateChange,
            metadata: options?.metadata,
        });
    }
    /**
     * Record a policy evaluation event
     */
    async recordPolicyEvaluation(tenantId, policyId, intentId, actor, result) {
        return this.service.record({
            tenantId,
            eventType: 'policy.evaluation.completed',
            actor,
            target: { type: 'policy', id: policyId },
            action: 'evaluate',
            outcome: result.matched ? 'success' : 'failure',
            metadata: {
                intentId,
                action: result.action,
                rulesEvaluated: result.rulesEvaluated,
            },
        });
    }
    /**
     * Record an escalation event
     */
    async recordEscalationEvent(tenantId, eventType, escalationId, intentId, actor, options) {
        return this.service.record({
            tenantId,
            eventType,
            actor,
            target: { type: 'escalation', id: escalationId },
            action: eventType.replace('escalation.', ''),
            outcome: options?.outcome ?? 'success',
            reason: options?.reason,
            metadata: {
                ...options?.metadata,
                intentId,
            },
        });
    }
}
/**
 * Create an audit helper
 */
export function createAuditHelper(service) {
    return new AuditHelper(service);
}
//# sourceMappingURL=service.js.map