import { ConflictException, ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import { createHmac, randomBytes } from 'node:crypto'
import { Model, Types } from 'mongoose'
import type { BookingDocument } from '../../schemas/booking.schema.js'
import {
  WebhookDelivery,
  WebhookDeliveryDocument,
} from '../../schemas/webhook-delivery.schema.js'
import {
  WebhookSubscription,
  WebhookSubscriptionDocument,
  type WebhookEvent,
} from '../../schemas/webhook-subscription.schema.js'
import type { CreateWebhookDto, UpdateWebhookDto } from './dto/webhook-dtos.js'

const MAX_ATTEMPTS = 8

// Minimal, booking-agnostic view of a booking the dispatcher serialises into the
// webhook payload. Keeping this a plain interface (not the Mongoose document)
// keeps BookingsService decoupled from the webhook wire format.
export interface WebhookBookingView {
  id: string
  tenantId: Types.ObjectId
  hostId: Types.ObjectId
  eventTypeId: Types.ObjectId
  status: string
  startUtc: Date
  endUtc: Date
  attendeeName: string
  attendeeEmail: string
  attendeeTimezone: string
  attendeeNotes?: string
  locationType?: string
  locationUrl?: string
  cancellationReason?: string
  rescheduledFromBookingId?: Types.ObjectId | null
}

// Maps a persisted booking document to the wire view. Exported so callers
// (BookingsService) can hand us a plain view without us importing their types.
export function toWebhookBookingView(b: BookingDocument): WebhookBookingView {
  return {
    id: String(b._id),
    tenantId: b.tenantId,
    hostId: b.hostId,
    eventTypeId: b.eventTypeId,
    status: b.status,
    startUtc: b.startUtc,
    endUtc: b.endUtc,
    attendeeName: b.attendeeName,
    attendeeEmail: b.attendeeEmail,
    attendeeTimezone: b.attendeeTimezone,
    attendeeNotes: b.attendeeNotes,
    locationType: b.locationType,
    locationUrl: b.locationUrl,
    cancellationReason: b.cancellationReason,
    rescheduledFromBookingId: b.rescheduledFromBookingId ?? null,
  }
}

// Tenant webhooks for booking lifecycle. Admin CRUD over subscriptions plus a
// fire-and-forget `dispatch` that fans an event out to every active matching
// subscription by persisting one signed WebhookDelivery per subscription. The
// actual HTTP POSTs (with retries) are driven asynchronously by the
// WebhookDeliveryWorker so booking flows never block on a slow receiver.
@Injectable()
export class WebhooksService {
  private readonly logger = new Logger(WebhooksService.name)

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

  // ── Admin CRUD ─────────────────────────────────────────────────────────────
  list(tenantId: Types.ObjectId): Promise<WebhookSubscriptionDocument[]> {
    return this.subModel.find({ tenantId }).sort({ createdAt: -1 }).exec()
  }

  async create(tenantId: Types.ObjectId, dto: CreateWebhookDto): Promise<WebhookSubscriptionDocument> {
    const existing = await this.subModel.findOne({ tenantId, url: dto.url }).exec()
    if (existing) throw new ConflictException('A webhook with that URL already exists.')
    return this.subModel.create({
      tenantId,
      url: dto.url,
      events: dto.events,
      secret: generateSecret(),
      active: true,
    })
  }

  async update(tenantId: Types.ObjectId, id: string, dto: UpdateWebhookDto): Promise<WebhookSubscriptionDocument> {
    const sub = await this.getOwned(tenantId, id)
    if (dto.url !== undefined) sub.url = dto.url
    if (dto.events !== undefined) sub.events = dto.events
    if (dto.active !== undefined) sub.active = dto.active
    return sub.save()
  }

  async rotateSecret(tenantId: Types.ObjectId, id: string): Promise<WebhookSubscriptionDocument> {
    const sub = await this.getOwned(tenantId, id)
    sub.secret = generateSecret()
    return sub.save()
  }

  async remove(tenantId: Types.ObjectId, id: string): Promise<void> {
    const sub = await this.getOwned(tenantId, id)
    await this.subModel.deleteOne({ _id: sub._id }).exec()
  }

  // Recent deliveries for a subscription (admin observability).
  async listDeliveries(tenantId: Types.ObjectId, id: string): Promise<WebhookDeliveryDocument[]> {
    await this.getOwned(tenantId, id)
    return this.deliveryModel
      .find({ tenantId, subscriptionId: new Types.ObjectId(id) })
      .sort({ createdAt: -1 })
      .limit(50)
      .exec()
  }

  private async getOwned(tenantId: Types.ObjectId, id: string): Promise<WebhookSubscriptionDocument> {
    if (!Types.ObjectId.isValid(id)) throw new NotFoundException('Webhook not found')
    const sub = await this.subModel.findById(id).exec()
    if (!sub) throw new NotFoundException('Webhook not found')
    if (!sub.tenantId.equals(tenantId)) throw new ForbiddenException('No access to this webhook')
    return sub
  }

  // ── Dispatch (called from BookingsService) ─────────────────────────────────
  /**
   * Fan an event out to every active subscription for the tenant that listens
   * for it, by persisting one signed WebhookDelivery each. Best-effort and
   * non-throwing: callers (booking flows) should `.catch()` defensively but a
   * failure here must never break a booking. Returns the number of deliveries
   * enqueued.
   */
  async dispatch(event: WebhookEvent, booking: WebhookBookingView): Promise<number> {
    try {
      const subs = await this.subModel
        .find({ tenantId: booking.tenantId, active: true, events: event })
        .exec()
      if (subs.length === 0) return 0

      const sentAt = new Date()
      let enqueued = 0
      for (const sub of subs) {
        const payload = JSON.stringify({
          event,
          sentAt: sentAt.toISOString(),
          subscriptionId: String(sub._id),
          data: serialiseBooking(booking),
        })
        const signature = sign(sub.secret, payload)
        await this.deliveryModel.create({
          tenantId: booking.tenantId,
          subscriptionId: sub._id,
          event,
          url: sub.url,
          payload,
          signature,
          status: 'pending',
          attempts: 0,
          maxAttempts: MAX_ATTEMPTS,
          nextAttemptAt: sentAt,
        })
        enqueued += 1
      }
      return enqueued
    } catch (err) {
      this.logger.error(`Webhook dispatch for "${event}" failed: ${(err as Error).message}`)
      return 0
    }
  }
}

// Hex HMAC-SHA256 of the body under the subscription secret.
export function sign(secret: string, body: string): string {
  return createHmac('sha256', secret).update(body).digest('hex')
}

function generateSecret(): string {
  return `whsec_${randomBytes(24).toString('hex')}`
}

// Public-safe booking projection for the payload. No manage token / internal
// calendar ids — just the booking facts a receiver needs.
function serialiseBooking(b: WebhookBookingView) {
  return {
    id: b.id,
    hostId: String(b.hostId),
    eventTypeId: String(b.eventTypeId),
    status: b.status,
    startUtc: b.startUtc.toISOString(),
    endUtc: b.endUtc.toISOString(),
    attendeeName: b.attendeeName,
    attendeeEmail: b.attendeeEmail,
    attendeeTimezone: b.attendeeTimezone,
    attendeeNotes: b.attendeeNotes ?? null,
    locationType: b.locationType ?? null,
    locationUrl: b.locationUrl ?? null,
    cancellationReason: b.cancellationReason ?? null,
    rescheduledFromBookingId: b.rescheduledFromBookingId ? String(b.rescheduledFromBookingId) : null,
  }
}
