/**
 * Audit log export service (PR-23 / HARDENING_PLAN P3-2, May 2026).
 *
 * Writes the day's AuditLog entries to a local directory in two
 * formats:
 *   - CSV   (auditlog-YYYY-MM-DD.csv)   — friendly for spreadsheets
 *                                          and ad-hoc grep
 *   - CEF   (auditlog-YYYY-MM-DD.cef)   — ArcSight/Splunk SIEM format,
 *                                          one line per event
 *
 * Designed for a small operator team (Evidence Action) doing on-host
 * log shipping (rsyslog / Vector / Filebeat picks the files up).
 * S3 / object-storage upload is intentionally NOT here — that's a
 * separate concern with its own credentials path; this service writes
 * to disk only.
 *
 * Idempotency: a file for `today` is *rewritten* on every pass (covers
 * the case where the worker fires multiple times in one day). Files
 * for past days are only rewritten if they don't exist yet (covers
 * worker downtime — operator can manually run `runOnce` to backfill).
 *
 * Retention: files older than AUDIT_EXPORT_RETENTION_DAYS are deleted
 * on each pass. Default 90 days.
 */
import fs from 'fs/promises';
import path from 'path';
import type { PrismaClient } from '@prisma/client';
import { prisma as defaultPrisma } from '@/lib/prisma';
import { log } from '@/lib/observability/logger';
import { captureException } from '@/lib/observability/sentry';

export interface AuditExportOptions {
    /** Output directory. Defaults to AUDIT_EXPORT_DIR env, fallback ./var/log/audit. */
    outputDir?: string;
    /** Retention in days. Defaults to AUDIT_EXPORT_RETENTION_DAYS env, fallback 90. */
    retentionDays?: number;
    prisma?: PrismaClient;
    now?: () => Date;
}

interface AuditEntry {
    id: number;
    createdAt: Date;
    action: string;
    resource: string;
    resourceId: string | null;
    details: string | null;
    user: { email: string | null; name: string | null } | null;
}

function resolveOutputDir(opt?: string): string {
    return opt ?? process.env.AUDIT_EXPORT_DIR ?? path.resolve(process.cwd(), 'var', 'log', 'audit');
}

function resolveRetention(opt?: number): number {
    if (opt && Number.isFinite(opt) && opt > 0) return opt;
    const env = process.env.AUDIT_EXPORT_RETENTION_DAYS;
    const parsed = env ? parseInt(env, 10) : 90;
    return Number.isFinite(parsed) && parsed > 0 ? parsed : 90;
}

function dateKey(d: Date): string {
    const y = d.getUTCFullYear();
    const m = String(d.getUTCMonth() + 1).padStart(2, '0');
    const day = String(d.getUTCDate()).padStart(2, '0');
    return `${y}-${m}-${day}`;
}

function startOfDayUtc(d: Date): Date {
    return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), 0, 0, 0, 0));
}

function endOfDayUtc(d: Date): Date {
    return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), 23, 59, 59, 999));
}

/**
 * RFC 4180-style CSV cell escaping. Wrap in quotes if the value
 * contains a comma, double-quote, CR, or LF.
 */
export function csvEscape(value: unknown): string {
    if (value === null || value === undefined) return '';
    const s = String(value);
    if (/[",\r\n]/.test(s)) {
        return '"' + s.replace(/"/g, '""') + '"';
    }
    return s;
}

/**
 * CEF (Common Event Format) extension-field escaping per ArcSight spec:
 * escape backslash, equals, pipe, and CR/LF inside extension values.
 */
export function cefEscape(value: unknown): string {
    if (value === null || value === undefined) return '';
    return String(value)
        .replace(/\\/g, '\\\\')
        .replace(/=/g, '\\=')
        .replace(/\|/g, '\\|')
        .replace(/[\r\n]/g, ' ');
}

export function entryToCsvRow(e: AuditEntry): string {
    return [
        e.id,
        e.createdAt.toISOString(),
        e.user?.email ?? '',
        e.action,
        e.resource,
        e.resourceId ?? '',
        e.details ?? '',
    ].map(csvEscape).join(',');
}

const CSV_HEADER = 'id,createdAt,actor_email,action,resource,resourceId,details';

/**
 * CEF line format:
 *   CEF:Version|DeviceVendor|DeviceProduct|DeviceVersion|SignatureId|Name|Severity|Extension
 * Reference: ArcSight Common Event Format v23.
 */
export function entryToCefLine(e: AuditEntry): string {
    const ext = [
        `rt=${e.createdAt.getTime()}`,
        `suser=${cefEscape(e.user?.email ?? 'system')}`,
        `act=${cefEscape(e.action)}`,
        `cs1Label=resource`,
        `cs1=${cefEscape(e.resource)}`,
        `cs2Label=resourceId`,
        `cs2=${cefEscape(e.resourceId ?? '')}`,
        `externalId=${e.id}`,
        `msg=${cefEscape(e.details ?? '')}`,
    ].join(' ');
    return `CEF:0|EvidenceAction|UptimeSentinel|1.0|${cefEscape(e.action)}|${cefEscape(e.action)} on ${cefEscape(e.resource)}|3|${ext}`;
}

export class AuditExportService {
    private readonly prisma: PrismaClient;
    private readonly outputDir: string;
    private readonly retentionDays: number;
    private readonly now: () => Date;

    constructor(opts: AuditExportOptions = {}) {
        this.prisma = opts.prisma ?? (defaultPrisma as unknown as PrismaClient);
        this.outputDir = resolveOutputDir(opts.outputDir);
        this.retentionDays = resolveRetention(opts.retentionDays);
        this.now = opts.now ?? (() => new Date());
    }

    /**
     * Export ONE day's audit log to CSV + CEF files. Idempotent for
     * today (always rewrites); skip-if-exists for past days (so the
     * worker doesn't repeatedly rewrite settled history).
     */
    async exportDay(date: Date, opts: { force?: boolean } = {}): Promise<{ written: boolean; rows: number; csvPath: string; cefPath: string }> {
        const key = dateKey(date);
        const csvPath = path.join(this.outputDir, `auditlog-${key}.csv`);
        const cefPath = path.join(this.outputDir, `auditlog-${key}.cef`);

        const isToday = dateKey(this.now()) === key;
        const force = opts.force ?? isToday;

        if (!force) {
            // Skip if a file already exists for this past day (operator
            // backfills via { force: true } if they really want a rewrite).
            try {
                await fs.stat(csvPath);
                return { written: false, rows: 0, csvPath, cefPath };
            } catch {
                // file missing — fall through and write
            }
        }

        await fs.mkdir(this.outputDir, { recursive: true });

        const start = startOfDayUtc(date);
        const end = endOfDayUtc(date);

        const entries = await this.prisma.auditLog.findMany({
            where: { createdAt: { gte: start, lte: end } },
            orderBy: { createdAt: 'asc' },
            include: { user: { select: { email: true, name: true } } },
        });

        const csvBody = CSV_HEADER + '\n' + entries.map(entryToCsvRow).join('\n') + (entries.length > 0 ? '\n' : '');
        const cefBody = entries.map(entryToCefLine).join('\n') + (entries.length > 0 ? '\n' : '');

        await fs.writeFile(csvPath, csvBody, 'utf8');
        await fs.writeFile(cefPath, cefBody, 'utf8');

        return { written: true, rows: entries.length, csvPath, cefPath };
    }

    /**
     * Delete export files older than `retentionDays`. Returns the
     * count of files deleted.
     */
    async rotateOldExports(): Promise<number> {
        try {
            const files = await fs.readdir(this.outputDir);
            const cutoff = this.now().getTime() - this.retentionDays * 24 * 60 * 60 * 1000;
            let deleted = 0;
            for (const file of files) {
                const m = file.match(/^auditlog-(\d{4}-\d{2}-\d{2})\.(csv|cef)$/);
                if (!m) continue;
                const dStr = m[1];
                const fileDate = new Date(dStr + 'T00:00:00Z').getTime();
                if (fileDate < cutoff) {
                    await fs.unlink(path.join(this.outputDir, file));
                    deleted++;
                }
            }
            return deleted;
        } catch (err) {
            // Directory missing on first run is fine.
            if ((err as NodeJS.ErrnoException).code === 'ENOENT') return 0;
            throw err;
        }
    }

    /**
     * Worker entry point: export yesterday (settled) + today (in-progress
     * snapshot) and rotate retention. Idempotent. Safe to call hourly
     * — yesterday is skipped after the first successful write.
     */
    async runDailyPass(): Promise<{ today: { rows: number; written: boolean }; yesterday: { rows: number; written: boolean }; rotated: number }> {
        const now = this.now();
        const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);

        try {
            const today = await this.exportDay(now);
            const ydn = await this.exportDay(yesterday);
            const rotated = await this.rotateOldExports();
            return {
                today: { rows: today.rows, written: today.written },
                yesterday: { rows: ydn.rows, written: ydn.written },
                rotated,
            };
        } catch (err) {
            log.error({ err }, 'AuditExportService.runDailyPass failed');
            captureException(err, { stage: 'audit-export' });
            throw err;
        }
    }
}
