import { Injectable } from '@nestjs/common';
import { PrismaService } from 'nestjs-prisma';
import { DEFAULT_LIMIT, MAX_LIMIT, type AdminAuditQueryDto } from './audit-query.dto';

/**
 * A single audit row projected for SPA consumption. Naming matches
 * the `AuditEvent` Prisma model but exposes ISO-string timestamps
 * (`created_at` → ISO 8601) and `Record<string, unknown>` payloads —
 * the SPA shouldn't have to know about Prisma `Json` runtime
 * shapes.
 */
export interface AdminAuditEventDto {
  readonly id: string;
  readonly createdAt: string;
  readonly eventType: string;
  readonly audience: string;
  readonly actorIdHash: string | null;
  readonly traceId: string | null;
  readonly subject: string | null;
  readonly outcome: string;
  readonly payload: Record<string, unknown> | null;
}

export interface AdminAuditPage {
  readonly items: readonly AdminAuditEventDto[];
  /**
   * Total rows matching the filter, ignoring `limit` / `offset`.
   * COUNT(*) on the filtered set — fine at v1 audit volume; if the
   * table grows past a few million rows we'll switch to keyset
   * pagination and drop the total.
   */
  readonly total: number;
  readonly limit: number;
  readonly offset: number;
}

/**
 * Raw shape returned by the underlying `$queryRawUnsafe`. Postgres
 * returns timestamps as `Date` and json columns as already-parsed
 * objects; the projection in {@link toDto} normalises both into
 * SPA-friendly types.
 */
interface RawAuditRow {
  id: string;
  created_at: Date;
  event_type: string;
  audience: string;
  actor_id_hash: string | null;
  trace_id: string | null;
  subject: string | null;
  outcome: string;
  payload: Record<string, unknown> | null;
}

/**
 * `AuditReader` — single entry point for ADR-0013 audit-log reads.
 *
 * Contract
 * --------
 * - **Always runs under `audit_reader`.** Every query is wrapped in
 *   a transaction whose first statement is `SET LOCAL ROLE
 *   audit_reader`. That role has SELECT-only on `audit.events`
 *   (per the migration that created the schema); UPDATE / INSERT /
 *   DELETE all fail at the Postgres level even when the BFF's
 *   connection is otherwise privileged. The role resets at
 *   transaction end. Mirrors the `AuditWriter`'s role-locking
 *   posture for symmetry.
 *
 * - **Parameterised SQL only.** Filter values flow into the
 *   `$queryRawUnsafe` parameter array, never concatenated into the
 *   SQL string — defends against admin-side SQL injection through
 *   the audit-viewer filter form.
 *
 * - **No PII redaction here.** The payload JSON is returned as-is
 *   to admin callers. Per ADR-0013, redaction is the writer's
 *   responsibility; what landed in the row is what comes out. The
 *   admin role is the trust boundary.
 */
@Injectable()
export class AuditReader {
  constructor(private readonly prisma: PrismaService) {}

  async findEvents(filters: AdminAuditQueryDto): Promise<AdminAuditPage> {
    const limit = filters.limit ?? DEFAULT_LIMIT;
    const offset = filters.offset ?? 0;
    // Hard cap at the DTO ceiling even if a future caller bypasses
    // the ValidationPipe — defense in depth.
    const safeLimit = Math.min(limit, MAX_LIMIT);

    const { whereSql, params } = buildWhere(filters);

    return this.prisma.$transaction(async (tx) => {
      await tx.$executeRawUnsafe(`SET LOCAL ROLE audit_reader`);

      const totalRows = await tx.$queryRawUnsafe<Array<{ count: bigint }>>(
        `SELECT COUNT(*)::bigint AS count FROM "audit"."events" ${whereSql}`,
        ...params,
      );
      const total = Number(totalRows[0]?.count ?? 0n);

      // Ordering is `created_at DESC, id DESC` so the SPA gets the
      // newest events first (typical audit-viewer expectation) and
      // ties on identical timestamps break deterministically on the
      // uuid — preventing pagination drift if two events share a
      // microsecond.
      //
      // Postgres parameter numbering continues from the WHERE clause
      // (`params.length`), so LIMIT lands at `$N+1` and OFFSET at
      // `$N+2`.
      const items = await tx.$queryRawUnsafe<RawAuditRow[]>(
        `SELECT id, created_at, event_type, audience, actor_id_hash,
                trace_id, subject, outcome, payload
         FROM "audit"."events"
         ${whereSql}
         ORDER BY created_at DESC, id DESC
         LIMIT $${params.length + 1}
         OFFSET $${params.length + 2}`,
        ...params,
        safeLimit,
        offset,
      );

      return {
        items: items.map(toDto),
        total,
        limit: safeLimit,
        offset,
      };
    });
  }
}

/**
 * Build the parameterised WHERE clause + the matching positional
 * parameter array. Every filter is optional; missing filters drop
 * out of the SQL so the planner can pick the cheapest index.
 */
function buildWhere(filters: AdminAuditQueryDto): { whereSql: string; params: unknown[] } {
  const clauses: string[] = [];
  const params: unknown[] = [];

  if (filters.eventType !== undefined) {
    params.push(filters.eventType);
    clauses.push(`event_type = $${params.length}`);
  }
  if (filters.actorIdHash !== undefined) {
    params.push(filters.actorIdHash);
    clauses.push(`actor_id_hash = $${params.length}`);
  }
  if (filters.audience !== undefined) {
    params.push(filters.audience);
    clauses.push(`audience = $${params.length}::"audit"."AuditAudience"`);
  }
  if (filters.outcome !== undefined) {
    params.push(filters.outcome);
    clauses.push(`outcome = $${params.length}::"audit"."AuditOutcome"`);
  }
  if (filters.subjectPrefix !== undefined) {
    // `LIKE` with a literal escape so a `%` in the prefix doesn't
    // act as a wildcard. The prefix bound is concatenated *to a
    // parameter*, never to the SQL string — the parameter is the
    // already-escaped literal followed by `%`.
    params.push(`${escapeLikeLiteral(filters.subjectPrefix)}%`);
    clauses.push(`subject LIKE $${params.length} ESCAPE '\\'`);
  }
  if (filters.createdAtFrom !== undefined) {
    params.push(new Date(filters.createdAtFrom));
    clauses.push(`created_at >= $${params.length}`);
  }
  if (filters.createdAtTo !== undefined) {
    params.push(new Date(filters.createdAtTo));
    clauses.push(`created_at < $${params.length}`);
  }

  const whereSql = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '';
  return { whereSql, params };
}

function escapeLikeLiteral(raw: string): string {
  // `\` first, then the two LIKE meta-characters. Order matters —
  // if `%`/`_` were escaped first their inserted `\` would be
  // double-escaped on the second pass.
  return raw.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_');
}

function toDto(row: RawAuditRow): AdminAuditEventDto {
  return {
    id: row.id,
    createdAt: row.created_at.toISOString(),
    eventType: row.event_type,
    audience: row.audience,
    actorIdHash: row.actor_id_hash,
    traceId: row.trace_id,
    subject: row.subject,
    outcome: row.outcome,
    payload: row.payload,
  };
}
