import { Injectable, Logger } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import { Cron, CronExpression } from '@nestjs/schedule'
import { Model } from 'mongoose'
import {
  WebhookDelivery,
  WebhookDeliveryDocument,
} from '../../schemas/webhook-delivery.schema.js'

const REQUEST_TIMEOUT_MS = 10_000

// Periodic worker that drains due 'pending' webhook deliveries. For each it POSTs
// the captured (already signed) payload to the receiver. A 2xx marks it
// 'delivered'; anything else (non-2xx, network error, timeout) increments
// attempts and either schedules an exponential-backoff retry or, once
// maxAttempts is hit, marks it terminally 'failed'. Each row is claimed
// atomically (status flip via findOneAndUpdate) so overlapping runs never
// double-send the same delivery.
@Injectable()
export class WebhookDeliveryWorker {
  private readonly logger = new Logger(WebhookDeliveryWorker.name)

  constructor(
    @InjectModel(WebhookDelivery.name)
    private readonly deliveryModel: Model<WebhookDeliveryDocument>,
  ) {}

  @Cron(CronExpression.EVERY_MINUTE, { name: 'webhook-deliveries' })
  async run(): Promise<void> {
    const now = new Date()
    const due = await this.deliveryModel
      .find({ status: 'pending', nextAttemptAt: { $lte: now } })
      .sort({ nextAttemptAt: 1 })
      .limit(100)
      .exec()

    for (const delivery of due) {
      await this.attempt(delivery).catch((e) =>
        this.logger.warn(`Webhook delivery ${delivery._id} errored: ${(e as Error).message}`),
      )
    }
  }

  private async attempt(delivery: WebhookDeliveryDocument): Promise<void> {
    // Claim it: only proceed if it's still pending and due, bumping nextAttemptAt
    // far out so a concurrent run won't pick it up while we're in flight.
    const claim = await this.deliveryModel
      .findOneAndUpdate(
        { _id: delivery._id, status: 'pending', nextAttemptAt: { $lte: new Date() } },
        { $set: { nextAttemptAt: new Date(Date.now() + REQUEST_TIMEOUT_MS + 60_000) } },
      )
      .exec()
    if (!claim) return // another run claimed it

    const attempts = claim.attempts + 1
    try {
      const res = await this.post(claim.url, claim.payload, claim.signature, claim.event)
      if (res.ok) {
        await this.deliveryModel
          .updateOne(
            { _id: claim._id },
            { $set: { status: 'delivered', attempts, deliveredAt: new Date(), lastStatusCode: res.status, lastError: undefined } },
          )
          .exec()
        this.logger.log(`Delivered webhook ${claim._id} (${claim.event}) → ${claim.url} [${res.status}]`)
        return
      }
      await this.fail(claim._id, attempts, claim.maxAttempts, `HTTP ${res.status}`, res.status)
    } catch (err) {
      await this.fail(claim._id, attempts, claim.maxAttempts, (err as Error).message)
    }
  }

  private async post(
    url: string,
    body: string,
    signature: string,
    event: string,
  ): Promise<{ ok: boolean; status: number }> {
    const controller = new AbortController()
    const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS)
    try {
      const res = await fetch(url, {
        method: 'POST',
        headers: {
          'content-type': 'application/json',
          'user-agent': 'dezky-webhooks/1',
          'x-dezky-event': event,
          'X-Dezky-Signature': `sha256=${signature}`,
        },
        body,
        signal: controller.signal,
      })
      return { ok: res.ok, status: res.status }
    } finally {
      clearTimeout(timer)
    }
  }

  private async fail(
    id: unknown,
    attempts: number,
    maxAttempts: number,
    error: string,
    statusCode?: number,
  ): Promise<void> {
    if (attempts >= maxAttempts) {
      await this.deliveryModel
        .updateOne({ _id: id as any }, { $set: { status: 'failed', attempts, lastError: error, lastStatusCode: statusCode } })
        .exec()
      this.logger.error(`Webhook ${id} failed terminally after ${attempts} attempts: ${error}`)
      return
    }
    // Exponential backoff capped at ~1h: 2^attempts minutes.
    const backoffMs = Math.min(2 ** attempts, 60) * 60_000
    await this.deliveryModel
      .updateOne(
        { _id: id as any },
        {
          $set: {
            status: 'pending',
            attempts,
            lastError: error,
            lastStatusCode: statusCode,
            nextAttemptAt: new Date(Date.now() + backoffMs),
          },
        },
      )
      .exec()
    this.logger.warn(`Webhook ${id} attempt ${attempts}/${maxAttempts} failed (${error}); retrying in ${backoffMs / 60_000}m`)
  }
}
