import { FOLLOW_REQUEST_APPROVED_EVENT } from '../../../libs/events/src';
import { Injectable, OnApplicationBootstrap, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectConnection, InjectModel } from '@nestjs/mongoose';
import { randomUUID } from 'crypto';
import { Connection, Model } from 'mongoose';
import { AppLoggerService } from '../../infrastructure/logging/app-logger.service';
import { NotificationServiceClient } from '../notifications/notification-service.client';
import { OutboxEvent, OutboxEventDocument } from './schemas/outbox-event.schema';

@Injectable()
export class OutboxDispatcherService implements OnApplicationBootstrap, OnModuleDestroy {
  private timer?: NodeJS.Timeout;
  private running = false;
  private stopped = false;
  private readonly instanceId = `${process.env.HOSTNAME ?? 'gateway'}-${process.pid}-${randomUUID()}`;

  constructor(
    @InjectModel(OutboxEvent.name) private readonly model: Model<OutboxEventDocument>,
    @InjectConnection() private readonly connection: Connection,
    private readonly config: ConfigService,
    private readonly client: NotificationServiceClient,
    private readonly logger: AppLoggerService,
  ) {}

  async onApplicationBootstrap(): Promise<void> {
    if (!this.enabled()) return;
    await this.assertTransactionCapableMongo();
    this.schedule(0);
  }

  async onModuleDestroy(): Promise<void> {
    this.stopped = true;
    if (this.timer) clearTimeout(this.timer);
    while (this.running) await new Promise((resolve) => setTimeout(resolve, 10));
  }

  async poll(): Promise<void> {
    if (!this.enabled() || this.running || this.stopped) return;
    this.running = true;
    let claimed = 0;
    let processed = 0;
    let failed = 0;
    try {
      await this.recoverExpiredLeases();
      for (let index = 0; index < this.number('OUTBOX_BATCH_SIZE', 25); index += 1) {
        const event = await this.claimNext();
        if (!event) break;
        claimed += 1;
        try {
          await this.dispatch(event);
          await this.markProcessed(event);
          processed += 1;
        } catch (error) {
          await this.markFailed(event, error);
          failed += 1;
        }
      }
      if (claimed > 0) this.logger.log({ message: 'outbox poll completed', instanceId: this.instanceId, claimed, processed, failed }, OutboxDispatcherService.name);
    } finally {
      this.running = false;
    }
  }

  async claimNext(now = new Date()): Promise<OutboxEventDocument | null> {
    const staleBefore = new Date(now.getTime() - this.number('OUTBOX_LEASE_TIMEOUT_MS', 60_000));
    return this.model.findOneAndUpdate(
      {
        eventType: 'follow_request_approved_notification',
        status: { $in: ['pending', 'failed'] },
        $and: [
          { $or: [{ nextRetryAt: { $lte: now } }, { nextRetryAt: { $exists: false } }] },
          { $or: [{ lockedAt: null }, { lockedAt: { $exists: false } }, { lockedAt: { $lte: staleBefore } }] },
        ],
      },
      { $set: { status: 'processing', lockedAt: now, lockedBy: this.instanceId } },
      { new: true, sort: { createdAt: 1 } },
    ).exec();
  }

  async recoverExpiredLeases(now = new Date()): Promise<number> {
    const staleBefore = new Date(now.getTime() - this.number('OUTBOX_LEASE_TIMEOUT_MS', 60_000));
    const result = await this.model.updateMany(
      { eventType: 'follow_request_approved_notification', status: 'processing', lockedAt: { $lte: staleBefore } },
      { $set: { status: 'failed', nextRetryAt: now, lastError: 'processing lease expired', lockedAt: null, lockedBy: null } },
    ).exec();
    return result.modifiedCount;
  }

  calculateBackoff(attempts: number): number {
    return Math.min(
      this.number('OUTBOX_RETRY_MAX_MS', 60_000),
      this.number('OUTBOX_RETRY_BASE_MS', 1_000) * (2 ** Math.max(0, attempts - 1)),
    );
  }

  private async dispatch(event: OutboxEventDocument): Promise<void> {
    await this.client.publishFollowRequestApproved({
      eventId: event.eventId || `follow.request.approved:${String(event.payload.requestId ?? '')}`,
      eventType: FOLLOW_REQUEST_APPROVED_EVENT,
      occurredAt: event.createdAt.toISOString(),
      payload: {
        requestId: String(event.payload.requestId ?? ''),
        actorId: String(event.payload.actorId ?? ''),
        recipientId: String(event.payload.recipientId ?? ''),
      },
    });
  }

  private async markProcessed(event: OutboxEventDocument): Promise<void> {
    await this.model.updateOne(
      { _id: event._id, status: 'processing', lockedBy: this.instanceId },
      { $set: { status: 'processed', processedAt: new Date(), lastError: '', lockedAt: null, lockedBy: null } },
    ).exec();
  }

  private async markFailed(event: OutboxEventDocument, error: unknown): Promise<void> {
    const attempts = event.attempts + 1;
    const dead = attempts >= this.number('OUTBOX_MAX_ATTEMPTS', 8);
    const message = this.safeError(error);
    await this.model.updateOne(
      { _id: event._id, status: 'processing', lockedBy: this.instanceId },
      { $set: {
        status: dead ? 'dead' : 'failed', attempts, lastError: message,
        nextRetryAt: new Date(Date.now() + this.calculateBackoff(attempts)),
        deadAt: dead ? new Date() : null, lockedAt: null, lockedBy: null,
      } },
    ).exec();
    this.logger.warn({ eventId: event.eventId, attempts, status: dead ? 'dead' : 'failed', error: message }, OutboxDispatcherService.name);
  }

  private schedule(delay: number): void {
    if (this.stopped) return;
    this.timer = setTimeout(async () => {
      try { await this.poll(); }
      catch (error) { this.logger.error({ error: this.safeError(error) }, OutboxDispatcherService.name); }
      finally { this.schedule(this.number('OUTBOX_POLL_INTERVAL_MS', 1_000)); }
    }, delay);
    this.timer.unref?.();
  }

  private enabled(): boolean {
    return String(this.config.get('OUTBOX_DISPATCHER_ENABLED') ?? 'false').toLowerCase() === 'true'
      && String(this.config.get('NOTIFICATION_SERVICE_ENABLED') ?? 'false').toLowerCase() === 'true';
  }

  private async assertTransactionCapableMongo(): Promise<void> {
    const hello = await this.connection.db?.admin().command({ hello: 1 }) as { setName?: string; msg?: string };
    if (!hello?.setName && hello?.msg !== 'isdbgrid') throw new Error('Outbox dispatcher requires a transaction-capable MongoDB replica set or mongos');
  }

  private number(key: string, fallback: number): number {
    return Number(this.config.get<string>(key) ?? fallback);
  }

  private safeError(error: unknown): string {
    return (error instanceof Error ? error.message : String(error)).replace(/amqps?:\/\/[^@\s]+@/gi, 'amqp://[redacted]@').slice(0, 500);
  }
}
