import { createHash } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
import { PrismaService } from 'nestjs-prisma';
import { REDIS_CLIENT, type Redis } from '../redis/redis.token';
import type { AdminAuditStatsQueryDto } from './audit-stats.dto';

/**
 * Aggregated audit-event statistics — the shape consumed by the
 * portal-admin `/audit` page's "Charts" tab (PR 2 of the stats
 * chantier).
 *
 * Each axis matches one of the three charts:
 *   - `dailyVolume` → bar chart of events per UTC day,
 *   - `outcomeBreakdown` → donut of success/failure/denied counts,
 *   - `eventTypeByDay` → stacked-bar of events per day, by type.
 *
 * Returned to the SPA as plain JSON; consumed by the shared chart
 * components without further transformation.
 */
export interface AdminAuditStats {
  readonly dailyVolume: ReadonlyArray<{ day: string; count: number }>;
  readonly outcomeBreakdown: ReadonlyArray<{ outcome: string; count: number }>;
  readonly eventTypeByDay: ReadonlyArray<{ day: string; eventType: string; count: number }>;
  /** Sum of `dailyVolume.count` — drives the donut centre label. */
  readonly total: number;
}

/**
 * Redis cache TTL for stats queries. Audit rows are append-only so
 * past aggregations are stable, but new events are continuously
 * inserted — a 5-minute TTL is the chosen compromise (per the
 * chantier brief): admins see at most 5-minute-stale aggregations
 * which is fine for "approximate dashboard" usage; not appropriate
 * for "did the last event land yet" debugging (use the list
 * endpoint for that).
 */
const STATS_CACHE_TTL_SECONDS = 300;

/** Key prefix in Redis so cache keys are scoped under one root. */
const CACHE_KEY_PREFIX = 'audit:stats:';

/**
 * `AuditStatsReader` — server-side audit aggregations behind a
 * Redis cache.
 *
 * Contract mirrors `AuditReader` for consistency:
 *   - Every query runs in a transaction that opens with
 *     `SET LOCAL ROLE audit_reader`, so SELECT is the only DB
 *     verb that can succeed even if the BFF's connection were
 *     otherwise privileged.
 *   - Parameterised SQL only; filter values flow through positional
 *     parameters, never concatenated into the SQL string.
 *
 * Cache key is a SHA-256 of the canonical (sorted-keys) JSON
 * representation of the filter object — deterministic across
 * processes, immune to JSON key ordering.
 */
@Injectable()
export class AuditStatsReader {
  constructor(
    private readonly prisma: PrismaService,
    @Inject(REDIS_CLIENT) private readonly redis: Redis,
  ) {}

  async getStats(filters: AdminAuditStatsQueryDto): Promise<AdminAuditStats> {
    const cacheKey = `${CACHE_KEY_PREFIX}${hashFilters(filters)}`;
    const cached = await this.redis.get(cacheKey);
    if (cached) {
      return JSON.parse(cached) as AdminAuditStats;
    }

    const stats = await this.computeStats(filters);
    // Best-effort cache write. If Redis is briefly unavailable, we
    // serve the live result and the next call rebuilds the cache.
    // The DB read already happened — don't fail the response on a
    // cache-write blip.
    try {
      await this.redis.set(cacheKey, JSON.stringify(stats), 'EX', STATS_CACHE_TTL_SECONDS);
    } catch {
      // swallow — see comment above
    }
    return stats;
  }

  private async computeStats(filters: AdminAuditStatsQueryDto): Promise<AdminAuditStats> {
    const { whereSql, params } = buildWhere(filters);

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

      // Three GROUP BY queries, each scoped by the same WHERE clause.
      // `date_trunc('day', ...)::date` yields a Postgres date (no
      // time, no zone) that serializes to `YYYY-MM-DD` directly —
      // matches the SPA's chart bucket convention.
      const dailyRows = await tx.$queryRawUnsafe<Array<{ day: Date; count: bigint }>>(
        `SELECT date_trunc('day', created_at)::date AS day,
                COUNT(*)::bigint AS count
         FROM "audit"."events"
         ${whereSql}
         GROUP BY day
         ORDER BY day`,
        ...params,
      );

      const outcomeRows = await tx.$queryRawUnsafe<Array<{ outcome: string; count: bigint }>>(
        `SELECT outcome::text AS outcome,
                COUNT(*)::bigint AS count
         FROM "audit"."events"
         ${whereSql}
         GROUP BY outcome
         ORDER BY outcome`,
        ...params,
      );

      const eventTypeRows = await tx.$queryRawUnsafe<
        Array<{ day: Date; event_type: string; count: bigint }>
      >(
        `SELECT date_trunc('day', created_at)::date AS day,
                event_type,
                COUNT(*)::bigint AS count
         FROM "audit"."events"
         ${whereSql}
         GROUP BY day, event_type
         ORDER BY day, event_type`,
        ...params,
      );

      const dailyVolume = dailyRows.map((r) => ({
        day: toIsoDay(r.day),
        count: Number(r.count),
      }));
      const total = dailyVolume.reduce((acc, r) => acc + r.count, 0);

      return {
        dailyVolume,
        outcomeBreakdown: outcomeRows.map((r) => ({
          outcome: r.outcome,
          count: Number(r.count),
        })),
        eventTypeByDay: eventTypeRows.map((r) => ({
          day: toIsoDay(r.day),
          eventType: r.event_type,
          count: Number(r.count),
        })),
        total,
      };
    });
  }
}

/**
 * Deterministic hash of the filter object. Sorted keys + JSON
 * stringify so two requests with the same filters land on the same
 * cache key regardless of ordering or whitespace. Truncated to
 * 16 hex chars (64 bits) — collision probability negligible at
 * realistic admin-side query volumes.
 */
function hashFilters(filters: AdminAuditStatsQueryDto): string {
  const sortedKeys = Object.keys(filters).sort();
  const canonical = sortedKeys
    .map((k) => `${k}=${(filters as Record<string, unknown>)[k] ?? ''}`)
    .join('&');
  return createHash('sha256').update(canonical).digest('hex').slice(0, 16);
}

interface BuiltWhere {
  readonly whereSql: string;
  readonly params: unknown[];
}

/**
 * Same shape as `AuditReader`'s `buildWhere` — kept in sync by
 * convention. Diverges only in that the stats DTO doesn't carry
 * `limit` / `offset`, so those clauses don't appear here.
 */
function buildWhere(filters: AdminAuditStatsQueryDto): BuiltWhere {
  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) {
    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 {
  return raw.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_');
}

/**
 * `date_trunc('day', ...)::date` round-trips through node-postgres
 * as a JS Date at UTC midnight. Slice the ISO string back to
 * `YYYY-MM-DD` so the SPA gets a stable bucket key — `Date#toJSON`
 * would otherwise serialise the full ISO timestamp with the local
 * timezone offset, which is not what the chart x-axis wants.
 */
function toIsoDay(d: Date): string {
  return d.toISOString().slice(0, 10);
}
