import type { AuditEntry, AuditWriter } from '@i-learned/core';
import { redact } from '@i-learned/core';

import type { Db } from './client';
import { prisma } from './client';
import { type AuditAction } from '@prisma/client';

/**
 * The append-only audit writer (SPEC.md §25).
 *
 * Two properties matter here:
 *
 *   1. **No update or delete exists.** The port declares only `write`, and the
 *      database enforces the same with triggers, so an audit entry cannot be
 *      altered by any code path.
 *
 *   2. **Everything is redacted first.** `before`/`after` summaries pass
 *      through the same redactor the logger uses, so a careless caller cannot
 *      put a password hash, a session token or a document body into a table
 *      that many staff can read.
 */
export class PrismaAuditWriter implements AuditWriter {
  constructor(
    private readonly db: Db = prisma,
    private readonly institutionId: string | null = null,
    private readonly requestId: string | null = null,
  ) {}

  /**
   * Returns a writer bound to an open transaction, so the audit entry commits
   * or rolls back with the change it describes. A service that writes outside
   * its transaction can leave the log claiming something that never happened.
   */
  withTransaction(tx: Db): PrismaAuditWriter {
    return new PrismaAuditWriter(tx, this.institutionId, this.requestId);
  }

  withRequest(requestId: string, institutionId: string | null): PrismaAuditWriter {
    return new PrismaAuditWriter(this.db, institutionId, requestId);
  }

  async write(entry: AuditEntry): Promise<void> {
    await this.db.auditLog.create({
      data: {
        institutionId: this.institutionId,
        actorUserId: entry.actorUserId,
        actorRoles: [...entry.actorRoles],
        action: entry.action as AuditAction,
        targetType: entry.targetType,
        targetId: entry.targetId,
        before: sanitize(entry.before),
        after: sanitize(entry.after),
        reason: entry.reason ?? null,
        ipAddress: entry.ipAddress ?? null,
        userAgent: truncate(entry.userAgent, 512),
        sessionId: entry.sessionId ?? null,
        requestId: this.requestId,
        metadata: sanitize(entry.metadata),
      },
    });
  }
}

/**
 * Redacts and JSON-normalizes a summary object. Returns `undefined` (rather
 * than `null`) for absent values so Prisma omits the column instead of
 * writing a JSON null, which reads confusingly in the audit viewer.
 */
function sanitize(value: Record<string, unknown> | null | undefined): object | undefined {
  if (value === null || value === undefined) return undefined;
  const cleaned = redact(value);
  if (cleaned === null || typeof cleaned !== 'object') return undefined;
  return cleaned as object;
}

function truncate(value: string | null | undefined, max: number): string | null {
  if (!value) return null;
  return value.length > max ? value.slice(0, max) : value;
}

/**
 * Builds a compact before/after pair from two versions of a record, including
 * only the fields that actually changed.
 *
 * Storing whole records would bloat the log and copy far more personal data
 * than the audit purpose requires — data minimization applies to audit trails
 * too (SPEC.md §25).
 */
export function diffForAudit<T extends Record<string, unknown>>(
  before: T | null,
  after: T | null,
  fields: readonly (keyof T)[],
): { before: Record<string, unknown> | null; after: Record<string, unknown> | null } {
  if (!before) return { before: null, after: after ? pick(after, fields) : null };
  if (!after) return { before: pick(before, fields), after: null };

  const changedBefore: Record<string, unknown> = {};
  const changedAfter: Record<string, unknown> = {};

  for (const field of fields) {
    const previous = before[field];
    const next = after[field];
    if (!Object.is(normalize(previous), normalize(next))) {
      changedBefore[String(field)] = previous;
      changedAfter[String(field)] = next;
    }
  }

  const hasChanges = Object.keys(changedAfter).length > 0;
  return {
    before: hasChanges ? changedBefore : null,
    after: hasChanges ? changedAfter : null,
  };
}

function pick<T extends Record<string, unknown>>(
  source: T,
  fields: readonly (keyof T)[],
): Record<string, unknown> {
  const result: Record<string, unknown> = {};
  for (const field of fields) {
    result[String(field)] = source[field];
  }
  return result;
}

/** Dates and Decimals compare by value, not identity. */
function normalize(value: unknown): unknown {
  if (value instanceof Date) return value.getTime();
  if (value && typeof value === 'object' && 'toFixed' in value) {
    return String(value);
  }
  return value;
}
