import RelanceTask from '#models/relance_task'
import Plan from '#models/plan'
import type Invoice from '#models/invoice'
import { getQueue } from '#services/queue'
import * as clock from '#services/clock'
import app from '@adonisjs/core/services/app'
import logger from '@adonisjs/core/services/logger'
import type { TransactionClientContract } from '@adonisjs/lucid/types/database'

const RELANCE_QUEUE = 'relances'

/**
 * En tests, les RelanceTasks DB sont créées (utile pour assertions) mais
 * l'enqueue BullMQ est skippé : les tx auto-rollback laisseraient des jobs
 * orphelins en Redis sinon, et on ne veut pas dépendre d'une instance
 * Redis live pour tourner les tests.
 */
function shouldEnqueue(): boolean {
  return app.getEnvironment() !== 'test'
}

/**
 * Programme toutes les relances d'une facture selon son plan.
 *
 *  - Pour chaque step du plan, calcule sendAt = invoice.dueDate + offsetDays
 *  - Crée une RelanceTask `scheduled`
 *  - Enqueue un BullMQ job `send-relance` avec delay = sendAt - now
 *
 * Si une facture est déjà en retard quand l'utilisateur confirme "toujours
 * en attente", on n'envoie pas toutes les étapes passées d'un coup :
 * la première étape éligible part à `now + 1 min`, puis les suivantes
 * gardent l'écart du plan à partir de ce nouveau départ.
 *
 * Idempotent par invoice.id : si des tasks `scheduled` existent déjà
 * pour cette facture, on les annule avant de re-programmer (cas où on
 * change de plan).
 */
export async function scheduleRelancesForInvoice(
  invoice: Invoice,
  trx?: TransactionClientContract
): Promise<RelanceTask[]> {
  if (!invoice.planId) return []

  const plan = await Plan.query(trx ? { client: trx } : undefined)
    .where('id', invoice.planId)
    .preload('steps', (q) => q.orderBy('order', 'asc'))
    .first()
  if (!plan) return []

  const alreadyActive = await RelanceTask.query(trx ? { client: trx } : undefined)
    .where('invoice_id', invoice.id)
    .whereIn('status', ['scheduled', 'sent'])
  if (alreadyActive.length > 0) {
    logger.info(
      {
        invoiceId: invoice.id,
        numero: invoice.numero,
        existingTasks: alreadyActive.length,
      },
      'scheduleRelancesForInvoice: tasks déjà actives, no-op'
    )
    return alreadyActive
  }

  // Cancel les tasks scheduled existantes (re-scheduling après changement
  // de plan ou de dueDate).
  const existing = await RelanceTask.query(trx ? { client: trx } : undefined)
    .where('invoice_id', invoice.id)
    .where('status', 'scheduled')
  const queue = shouldEnqueue() ? getQueue(RELANCE_QUEUE) : null
  for (const t of existing) {
    if (t.queueJobId && queue) {
      await queue.remove(t.queueJobId).catch(() => {
        // Ignore — le job peut déjà être consommé.
      })
    }
    t.useTransaction(trx ?? (null as never))
    t.status = 'cancelled'
    await t.save()
  }

  const now = await clock.now(invoice.organizationId)
  const created: RelanceTask[] = []
  const steps = plan.steps.slice().sort((a, b) => a.order - b.order)
  const firstOverdueStep = steps.find(
    (step) => invoice.dueDate.plus({ days: step.offsetDays }) < now
  )
  const catchUpAnchor = firstOverdueStep
    ? {
        offsetDays: firstOverdueStep.offsetDays,
        sendAt: now.plus({ minutes: 1 }),
      }
    : null

  for (const step of steps) {
    const sendAtRaw = invoice.dueDate.plus({ days: step.offsetDays })
    const sendAt =
      catchUpAnchor && step.offsetDays >= catchUpAnchor.offsetDays
        ? catchUpAnchor.sendAt.plus({
            days: step.offsetDays - catchUpAnchor.offsetDays,
          })
        : sendAtRaw

    const task = await RelanceTask.create(
      {
        organizationId: invoice.organizationId,
        invoiceId: invoice.id,
        planStepId: step.id,
        sendAt,
        status: 'scheduled',
        sentAt: null,
        queueJobId: null,
      },
      trx ? { client: trx } : undefined
    )

    const delay = Math.max(0, sendAt.toMillis() - now.toMillis())
    const job = queue
      ? await queue.add(
          'send-relance',
          { taskId: task.id },
          {
            delay,
            // Idempotency : un seul job actif par task.
            // BullMQ 5+ interdit `:` dans les custom jobIds → tiret.
            jobId: `relance-${task.id}`,
            // Retry exponentiel — si Mailpit est down, BullMQ retry 5x avec
            // backoff (cf. backend.md §13.2).
            attempts: 5,
            backoff: { type: 'exponential', delay: 30_000 },
          }
        )
      : null

    task.queueJobId = job?.id ?? null
    await task.save()
    created.push(task)

    logger.info(
      {
        invoiceId: invoice.id,
        numero: invoice.numero,
        stepOrder: step.order,
        offsetDays: step.offsetDays,
        sendAt: sendAt.toISO(),
        delayMs: delay,
        delaySec: Math.round(delay / 1000),
        taskId: task.id,
        queueJobId: task.queueJobId,
        enqueued: queue !== null,
      },
      'scheduleRelancesForInvoice: tâche créée + job BullMQ enqueué'
    )
  }

  if (created.length === 0) {
    logger.warn(
      { invoiceId: invoice.id, numero: invoice.numero, planId: plan.id, stepCount: steps.length },
      'scheduleRelancesForInvoice: aucune tâche créée (plan vide ?)'
    )
  } else {
    logger.info(
      {
        invoiceId: invoice.id,
        numero: invoice.numero,
        taskCount: created.length,
        enqueueEnabled: queue !== null,
      },
      'scheduleRelancesForInvoice: terminé'
    )
  }

  return created
}

/**
 * Annule toutes les relances futures d'une facture (appelé quand on
 * mark-paid ou cancel une invoice). Les tasks déjà `sent` restent
 * intactes — c'est de l'historique.
 */
export async function cancelFutureRelances(
  invoiceId: string,
  trx?: TransactionClientContract
): Promise<void> {
  const tasks = await RelanceTask.query(trx ? { client: trx } : undefined)
    .where('invoice_id', invoiceId)
    .where('status', 'scheduled')
  if (tasks.length === 0) return

  const queue = shouldEnqueue() ? getQueue(RELANCE_QUEUE) : null
  for (const t of tasks) {
    if (t.queueJobId && queue) {
      await queue.remove(t.queueJobId).catch(() => {})
    }
    t.useTransaction(trx ?? (null as never))
    t.status = 'cancelled'
    await t.save()
  }
}
