import { createHash, randomUUID } from 'node:crypto';
import type { Database } from '@pig/db';
import { channelLinks, notificationOutbox, type NotificationOutboxItem } from '@pig/db';
import { and, asc, eq, inArray, isNull, lt, lte, or, sql } from 'drizzle-orm';
import { z } from 'zod';
import type { AvailabilityRow } from './capacity';
import {
  NotificationDeliveryError,
  NOTIFIER_PROVIDERS,
  isNotifierProvider,
  type IdleCapacityNotification,
  type Notification,
  type Notifier,
  type StageChangeNotification,
} from './notifier';

type Transaction = Parameters<Parameters<Database['transaction']>[0]>[0];

const notificationSchema = z.discriminatedUnion('kind', [
  z.object({
    kind: z.literal('stage_change'),
    accountId: z.string().uuid(),
    dealId: z.string().uuid(),
    dealSide: z.enum(['demand', 'supply']),
    dealName: z.string(),
    fromStage: z.string(),
    toStage: z.string(),
    changedAt: z.string().datetime(),
  }),
  z.object({
    kind: z.literal('idle_capacity'),
    accountId: z.string().uuid(),
    commitmentId: z.string().uuid(),
    commitmentName: z.string(),
    gpuType: z.string(),
    idleGpuHours: z.number().nonnegative(),
    idleCostCents: z.number().int().nonnegative(),
    utilisation: z.number().min(0).max(1),
    observedAt: z.string().datetime(),
  }),
]);

export interface StageChangeEvent extends Omit<StageChangeNotification, 'kind'> {
  requestedByUserId?: string;
}

export interface IdleCapacityRow extends AvailabilityRow {
  idleGpuHours: number;
  idleCostCents: number;
}

export class NotificationOutbox {
  constructor(private readonly db: Database) {}

  async enqueueStageChange(tx: Transaction, event: StageChangeEvent): Promise<number> {
    if (event.fromStage === event.toStage) return 0;
    const links = await tx
      .select()
      .from(channelLinks)
      .where(
        and(
          inArray(channelLinks.platform, [...NOTIFIER_PROVIDERS]),
          eq(channelLinks.accountId, event.accountId),
        ),
      );
    const subscribedLinks = links.filter(
      (link) => isNotifierProvider(link.platform) && link.notifyOn.includes('stage_change'),
    );
    if (subscribedLinks.length === 0) return 0;

    const notification: StageChangeNotification = { kind: 'stage_change', ...event };
    const eventKey = hashKey([
      notification.kind,
      notification.dealSide,
      notification.dealId,
      notification.changedAt,
      notification.fromStage,
      notification.toStage,
    ]);
    const inserted = await tx
      .insert(notificationOutbox)
      .values(
        subscribedLinks.map((link) => ({
          provider: link.platform,
          kind: notification.kind,
          linkId: link.id,
          workspaceId: link.workspaceId,
          destination: link.channelId,
          payload: { ...notification },
          idempotencyKey: `${link.platform}:${link.id}:${eventKey}`,
          requestedByUserId: event.requestedByUserId,
        })),
      )
      .onConflictDoNothing({ target: notificationOutbox.idempotencyKey })
      .returning({ id: notificationOutbox.id });
    return inserted.length;
  }

  async enqueueIdleCapacity(rows: IdleCapacityRow[], observedAt = new Date()): Promise<number> {
    const day = observedAt.toISOString().slice(0, 10);
    return this.db.transaction(async (tx) => {
      let count = 0;
      for (const row of rows) {
        const links = await tx
          .select()
          .from(channelLinks)
          .where(
            and(
              inArray(channelLinks.platform, [...NOTIFIER_PROVIDERS]),
              eq(channelLinks.accountId, row.accountId),
            ),
          );
        const subscribedLinks = links.filter(
          (link) => isNotifierProvider(link.platform) && link.notifyOn.includes('idle_capacity'),
        );
        if (subscribedLinks.length === 0) continue;

        const notification: IdleCapacityNotification = {
          kind: 'idle_capacity',
          accountId: row.accountId,
          commitmentId: row.commitmentId,
          commitmentName: row.name,
          gpuType: row.gpuType,
          idleGpuHours: row.idleGpuHours,
          idleCostCents: row.idleCostCents,
          utilisation: row.utilisation,
          observedAt: observedAt.toISOString(),
        };
        const inserted = await tx
          .insert(notificationOutbox)
          .values(
            subscribedLinks.map((link) => ({
              provider: link.platform,
              kind: notification.kind,
              linkId: link.id,
              workspaceId: link.workspaceId,
              destination: link.channelId,
              payload: { ...notification },
              idempotencyKey: `${link.platform}:${link.id}:idle:${row.commitmentId}:${day}`,
            })),
          )
          .onConflictDoNothing({ target: notificationOutbox.idempotencyKey })
          .returning({ id: notificationOutbox.id });
        count += inserted.length;
      }
      return count;
    });
  }
}

export interface NotificationWorkerOptions {
  workerId?: string;
  leaseSeconds?: number;
  pollIntervalMs?: number;
}

export class NotificationWorker {
  private readonly workerId: string;
  private readonly leaseSeconds: number;
  private readonly pollIntervalMs: number;

  constructor(
    private readonly db: Database,
    private readonly notifier: Notifier,
    options: NotificationWorkerOptions = {},
  ) {
    this.workerId = options.workerId ?? `notification-${process.pid}-${randomUUID()}`;
    this.leaseSeconds = options.leaseSeconds ?? 30;
    this.pollIntervalMs = options.pollIntervalMs ?? 2_000;
  }

  start(): () => void {
    let stopped = false;
    let working = false;
    const tick = () => {
      if (stopped || working) return;
      working = true;
      void this.processNext()
        .catch(() => {})
        .finally(() => {
          working = false;
        });
    };
    const timer = setInterval(tick, this.pollIntervalMs);
    timer.unref();
    tick();
    return () => {
      stopped = true;
      clearInterval(timer);
    };
  }

  async processNext(now = new Date()): Promise<boolean> {
    const item = await this.claimNext(now);
    if (!item) return false;

    const parsed = notificationSchema.safeParse(item.payload);
    if (!parsed.success) {
      await this.finishFailure(item, new NotificationDeliveryError('invalid_payload', false), now);
      return true;
    }

    try {
      const receipt = await this.notifier.send({
        idempotencyKey: item.idempotencyKey,
        destination: item.destination,
        workspaceId: item.workspaceId,
        notification: parsed.data as Notification,
      });
      await this.db
        .update(notificationOutbox)
        .set({
          status: 'delivered',
          deliveredAt: new Date(),
          externalId: receipt.externalId,
          leasedBy: null,
          leasedUntil: null,
          error: null,
        })
        .where(
          and(
            eq(notificationOutbox.id, item.id),
            eq(notificationOutbox.leasedBy, this.workerId),
            eq(notificationOutbox.status, 'leased'),
          ),
        );
    } catch (error) {
      const deliveryError =
        error instanceof NotificationDeliveryError
          ? error
          : new NotificationDeliveryError('provider_error', true);
      await this.finishFailure(item, deliveryError, new Date());
    }
    return true;
  }

  private async claimNext(now: Date): Promise<NotificationOutboxItem | null> {
    return this.db.transaction(async (tx) => {
      const [candidate] = await tx
        .select()
        .from(notificationOutbox)
        .where(
          and(
            eq(notificationOutbox.provider, this.notifier.provider),
            or(
              eq(notificationOutbox.status, 'pending'),
              and(
                eq(notificationOutbox.status, 'leased'),
                or(isNull(notificationOutbox.leasedUntil), lt(notificationOutbox.leasedUntil, now)),
              ),
            ),
            lte(notificationOutbox.dueAt, now),
            sql`${notificationOutbox.attempts} < ${notificationOutbox.maxAttempts}`,
          ),
        )
        .orderBy(asc(notificationOutbox.dueAt), asc(notificationOutbox.createdAt))
        .limit(1)
        .for('update', { skipLocked: true });
      if (!candidate) return null;

      const [claimed] = await tx
        .update(notificationOutbox)
        .set({
          status: 'leased',
          leasedBy: this.workerId,
          leasedUntil: new Date(now.getTime() + this.leaseSeconds * 1_000),
          attempts: sql`${notificationOutbox.attempts} + 1`,
          error: null,
        })
        .where(eq(notificationOutbox.id, candidate.id))
        .returning();
      return claimed ?? null;
    });
  }

  private async finishFailure(
    item: NotificationOutboxItem,
    error: NotificationDeliveryError,
    now: Date,
  ): Promise<void> {
    const retry = error.retryable && item.attempts < item.maxAttempts;
    const backoff = error.retryAfterMs ?? retryBackoffMs(item.attempts);
    await this.db
      .update(notificationOutbox)
      .set({
        status: retry ? 'pending' : 'failed',
        dueAt: retry ? new Date(now.getTime() + backoff) : item.dueAt,
        leasedBy: null,
        leasedUntil: null,
        error: error.code,
      })
      .where(
        and(
          eq(notificationOutbox.id, item.id),
          eq(notificationOutbox.leasedBy, this.workerId),
          eq(notificationOutbox.status, 'leased'),
        ),
      );
  }
}

export function retryBackoffMs(attempts: number): number {
  return Math.min(60 * 60_000, 30_000 * 2 ** Math.max(0, attempts - 1));
}

function hashKey(parts: string[]): string {
  return createHash('sha256').update(JSON.stringify(parts)).digest('hex');
}
