import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { OutboxBus } from './outbox.bus';
import { DlqService } from './dlq.service';
import { logJson } from '../observability/logger';

/**
 * Transactional-outbox relay (Bottom-Up §12). At-least-once by design; the
 * idempotent-consumer ledger (IiosProcessedEvent) makes effects exactly-once.
 *
 * relayOnce(): claim PENDING rows with FOR UPDATE SKIP LOCKED, publish each to
 * the bus once, mark PUBLISHED. On failure, reset to PENDING with a 30s backoff.
 * A scheduler (P9) calls this on an interval; tests call it directly.
 */
@Injectable()
export class OutboxRelay implements OnModuleInit, OnModuleDestroy {
  private readonly consumer = 'outbox-relay';
  private readonly logger = new Logger(OutboxRelay.name);
  private timer?: ReturnType<typeof setInterval>;

  private readonly maxAttempts = Number(process.env.IIOS_OUTBOX_MAX_ATTEMPTS ?? 5);

  constructor(
    private readonly prisma: PrismaService,
    private readonly bus: OutboxBus,
    private readonly dlq: DlqService,
  ) {}

  // Tests construct via `new OutboxRelay(...)`, so onModuleInit never runs there
  // (no timer). The running app ticks the relay so events flow to consumers.
  onModuleInit(): void {
    const ms = Number(process.env.IIOS_RELAY_INTERVAL_MS ?? 500);
    if (ms > 0) {
      this.timer = setInterval(() => {
        void this.relayOnce().catch((err) => this.logger.warn(`relay tick failed: ${(err as Error).message}`));
      }, ms);
    }
  }

  onModuleDestroy(): void {
    if (this.timer) clearInterval(this.timer);
  }

  async relayOnce(batchSize = 100): Promise<number> {
    const eventIds = await this.prisma.$transaction(async (tx) => {
      const picked = await tx.$queryRaw<Array<{ eventId: string }>>`
        SELECT "eventId" FROM "IiosOutboxEvent"
        WHERE status = 'PENDING' AND "nextAttemptAt" <= now()
        ORDER BY "createdAt" ASC
        LIMIT ${batchSize}
        FOR UPDATE SKIP LOCKED`;
      const ids = picked.map((r) => r.eventId);
      if (ids.length > 0) {
        await tx.iiosOutboxEvent.updateMany({
          where: { eventId: { in: ids } },
          data: { status: 'SENDING', attempts: { increment: 1 } },
        });
      }
      return ids;
    });

    let published = 0;
    for (const eventId of eventIds) {
      const evt = await this.prisma.iiosOutboxEvent.findUniqueOrThrow({ where: { eventId } });
      try {
        const fresh = await this.consumeOnce(eventId);
        if (fresh) this.bus.publish(evt.eventType, evt.cloudEvent);
        const ce = evt.cloudEvent as { insignia?: { correlationId?: string } } | null;
        logJson('info', 'relay.publish', { eventType: evt.eventType, aggregateId: evt.aggregateId, correlationId: ce?.insignia?.correlationId });
        await this.prisma.iiosOutboxEvent.update({
          where: { eventId },
          data: { status: 'PUBLISHED', publishedAt: new Date() },
        });
        published++;
      } catch (err) {
        // After maxAttempts, stop retrying forever — dead-letter the event (KG-13).
        if (evt.attempts >= this.maxAttempts) {
          const ce = evt.cloudEvent as { insignia?: { scopeSnapshotId?: string } } | null;
          await this.prisma.iiosOutboxEvent.update({ where: { eventId }, data: { status: 'FAILED' } });
          await this.dlq.record({ sourceType: 'outbox', sourceId: eventId, failureStage: 'relay.publish', errorCode: (err as Error)?.message?.slice(0, 160), payloadRef: eventId, scopeId: ce?.insignia?.scopeSnapshotId });
        } else {
          await this.prisma.iiosOutboxEvent.update({
            where: { eventId },
            data: { status: 'PENDING', nextAttemptAt: new Date(Date.now() + 30_000) },
          });
        }
      }
    }
    return published;
  }

  /** Records the event as processed exactly once. Returns false if already seen. */
  private async consumeOnce(eventId: string): Promise<boolean> {
    const res = await this.prisma.iiosProcessedEvent.createMany({
      data: [{ consumerName: this.consumer, eventId }],
      skipDuplicates: true,
    });
    return res.count > 0;
  }
}
