import vine from '@vinejs/vine'
import type { HttpContext } from '@adonisjs/core/http'
import { Exception } from '@adonisjs/core/exceptions'
import { DateTime } from 'luxon'
import logger from '@adonisjs/core/services/logger'

import Organization from '#models/organization'
import User from '#models/user'
import { getOrgSubscriptionState } from '#services/billing'
import { getStripe, STRIPE_LOOKUP_KEYS, getPriceByLookup } from '#services/stripe'
import env from '#start/env'
import type Stripe from 'stripe'

function requireOrgId(auth: HttpContext['auth']): string {
  const user = auth.getUserOrFail()
  if (!user.organizationId) {
    throw new Exception('Aucune organisation rattachée', { status: 404, code: 'not_found' })
  }
  return user.organizationId
}

const checkoutValidator = vine.compile(
  vine.object({
    plan: vine.enum(['pro', 'business']),
    cycle: vine.enum(['monthly', 'yearly']),
  })
)

function lookupKeyFor(plan: 'pro' | 'business', cycle: 'monthly' | 'yearly') {
  if (plan === 'pro') {
    return cycle === 'monthly' ? STRIPE_LOOKUP_KEYS.pro_monthly : STRIPE_LOOKUP_KEYS.pro_yearly
  }
  return cycle === 'monthly' ? STRIPE_LOOKUP_KEYS.business_monthly : STRIPE_LOOKUP_KEYS.business_yearly
}

/**
 * Crée ou retrouve le Stripe Customer associé à une org. On stocke
 * `stripeCustomerId` sur l'org dès la 1re fois pour éviter les doublons.
 */
async function ensureStripeCustomer(org: Organization, user: User): Promise<string> {
  if (org.stripeCustomerId) return org.stripeCustomerId
  const stripe = getStripe()
  const customer = await stripe.customers.create({
    email: user.email,
    name: org.name || user.fullName || user.email,
    metadata: {
      organization_id: org.id,
      user_id: user.id,
    },
  })
  org.stripeCustomerId = customer.id
  await org.save()
  return customer.id
}

export default class BillingController {
  /**
   * GET /api/v1/billing/subscription — auth.
   * Retourne le plan courant + caps + état de souscription pour l'UI.
   */
  async subscription({ auth, response }: HttpContext) {
    const organizationId = requireOrgId(auth)
    const state = await getOrgSubscriptionState(organizationId)
    return response.json({ data: state })
  }

  /**
   * POST /api/v1/billing/checkout — auth.
   * Crée une session Stripe Checkout et renvoie l'URL hostée — le SPA
   * redirige vers Stripe pour que l'user paye en sécurité.
   *
   * Body: { plan: 'pro'|'business', cycle: 'monthly'|'yearly' }
   */
  async checkout({ auth, request, response }: HttpContext) {
    const organizationId = requireOrgId(auth)
    const user = auth.getUserOrFail()
    const { plan, cycle } = await request.validateUsing(checkoutValidator)

    const org = await Organization.findOrFail(organizationId)
    const customerId = await ensureStripeCustomer(org, user)

    const price = await getPriceByLookup(lookupKeyFor(plan, cycle))
    const stripe = getStripe()
    const webUrl = env.get('WEB_URL', 'http://localhost:5173')

    const session = await stripe.checkout.sessions.create({
      mode: 'subscription',
      customer: customerId,
      line_items: [{ price: price.id, quantity: 1 }],
      success_url: `${webUrl}/parametres/abonnement?checkout=success&session_id={CHECKOUT_SESSION_ID}`,
      cancel_url: `${webUrl}/parametres/abonnement?checkout=cancel`,
      // On stocke org_id en metadata pour pouvoir lier côté webhook
      // sans avoir besoin de regarder le customer.
      subscription_data: {
        metadata: { organization_id: organizationId, plan },
      },
      metadata: { organization_id: organizationId, plan },
      allow_promotion_codes: true,
      billing_address_collection: 'auto',
      locale: 'fr',
    })

    return response.json({ data: { url: session.url } })
  }

  /**
   * POST /api/v1/billing/reactivate — auth.
   *
   * Annule l'annulation programmée au period_end : set Stripe
   * `cancel_at_period_end: false` et persiste côté org. Pas de proration,
   * pas de paiement immédiat — la subscription continue son cycle normal.
   */
  async reactivate({ auth, response }: HttpContext) {
    const organizationId = requireOrgId(auth)
    const org = await Organization.findOrFail(organizationId)

    if (!org.stripeSubscriptionId) {
      throw new Exception('Aucune souscription active à réactiver', {
        status: 400,
        code: 'no_active_subscription',
      })
    }
    if (!org.cancelAtPeriodEnd) {
      // Idempotent : déjà actif, on renvoie OK sans toucher Stripe.
      return response.json({ data: { ok: true } })
    }

    const stripe = getStripe()
    // Stripe expose 2 mécaniques d'annulation et REFUSE qu'on passe les 2
    // dans le même update :
    //   - `cancel_at_period_end: true` (booléen) — API directe / CLI
    //   - `cancel_at: <timestamp>`     — Customer Portal
    //
    // On retrieve le sub d'abord pour savoir laquelle est posée, puis on
    // clear uniquement celle-là.
    const current = await stripe.subscriptions.retrieve(org.stripeSubscriptionId)
    const updatePayload: Stripe.SubscriptionUpdateParams = current.cancel_at
      ? { cancel_at: null }
      : { cancel_at_period_end: false }

    const updated = await stripe.subscriptions.update(
      org.stripeSubscriptionId,
      updatePayload
    )
    org.cancelAtPeriodEnd =
      !!updated.cancel_at_period_end || !!updated.cancel_at // = false normalement
    org.subscriptionStatus = updated.status
    await org.save()

    logger.info(
      { orgId: org.id, subscriptionId: org.stripeSubscriptionId },
      'Subscription réactivée (cancel_at_period_end=false)'
    )

    return response.json({ data: { ok: true } })
  }

  /**
   * POST /api/v1/billing/portal — auth.
   * Crée une session Stripe Customer Portal pour gérer abonnement, CB,
   * factures Stripe, annulation. UI hosted.
   */
  async portal({ auth, response }: HttpContext) {
    const organizationId = requireOrgId(auth)
    const org = await Organization.findOrFail(organizationId)

    if (!org.stripeCustomerId) {
      throw new Exception(
        'Aucun customer Stripe pour cette organisation — passez d\'abord par checkout',
        { status: 400, code: 'no_stripe_customer' }
      )
    }

    const stripe = getStripe()
    const webUrl = env.get('WEB_URL', 'http://localhost:5173')
    const session = await stripe.billingPortal.sessions.create({
      customer: org.stripeCustomerId,
      return_url: `${webUrl}/parametres/abonnement`,
      locale: 'fr',
    })

    return response.json({ data: { url: session.url } })
  }

  /**
   * POST /api/v1/billing/webhook — public (auth via signature Stripe).
   *
   * Stripe envoie les events de subscription ici. On vérifie la signature
   * via le webhook secret puis on dispatch :
   *
   *   - checkout.session.completed       → premier paiement OK, set plan
   *   - customer.subscription.updated    → renouvellement, plan change
   *   - customer.subscription.deleted    → annulation effective → free
   *   - invoice.payment_failed           → past_due (UI rappelle l'user)
   *
   * Idempotent : on traite chaque event en read-then-write sans assumer
   * qu'il arrive une seule fois (Stripe peut re-livrer).
   */
  async webhook({ request, response }: HttpContext) {
    const stripe = getStripe()
    const webhookSecret = env.get('STRIPE_WEBHOOK_SECRET')
    if (!webhookSecret) {
      throw new Exception('STRIPE_WEBHOOK_SECRET manquant', {
        status: 500,
        code: 'webhook_secret_missing',
      })
    }
    const sig = request.header('stripe-signature')
    if (!sig) {
      throw new Exception('Signature Stripe manquante', { status: 400, code: 'missing_signature' })
    }
    // Adonis a déjà parsé le body en JSON : Stripe a besoin du raw, on le
    // récupère via `request.raw()`.
    const raw = request.raw()
    if (!raw) {
      throw new Exception('Raw body indisponible', {
        status: 400,
        code: 'no_raw_body',
      })
    }

    let event: Stripe.Event
    try {
      event = stripe.webhooks.constructEvent(raw, sig, webhookSecret)
    } catch (err) {
      logger.warn({ err }, 'Stripe webhook : signature invalide')
      throw new Exception('Signature invalide', {
        status: 400,
        code: 'invalid_signature',
      })
    }

    logger.info({ type: event.type, id: event.id }, 'Stripe webhook reçu')

    try {
      switch (event.type) {
        case 'checkout.session.completed':
          await this.handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session)
          break
        case 'customer.subscription.created':
        case 'customer.subscription.updated':
          await this.handleSubscriptionUpdate(event.data.object as Stripe.Subscription)
          break
        case 'customer.subscription.deleted':
          await this.handleSubscriptionDeleted(event.data.object as Stripe.Subscription)
          break
        case 'invoice.payment_failed':
          await this.handlePaymentFailed(event.data.object as Stripe.Invoice)
          break
        default:
          // On ignore les autres events. Stripe en envoie beaucoup, on
          // n'en a besoin que d'une poignée.
          break
      }
    } catch (err) {
      // En cas d'erreur de traitement, on log mais on renvoie 200 quand
      // même : Stripe va retry plein de fois sinon. Mieux vaut perdre un
      // event qu'avoir 50 doublons. Pour les events critiques on a déjà
      // l'idempotence (lookup par customer/subscription id).
      logger.error({ err, type: event.type, id: event.id }, 'Stripe webhook : erreur de traitement')
    }

    return response.json({ received: true })
  }

  // -----------------------------------------------------------------------
  // Handlers webhook
  // -----------------------------------------------------------------------

  private async handleCheckoutCompleted(session: Stripe.Checkout.Session) {
    const orgId = session.metadata?.['organization_id']
    if (!orgId) {
      logger.warn({ session: session.id }, 'checkout.completed sans organization_id en metadata')
      return
    }
    if (!session.subscription || typeof session.subscription !== 'string') return
    const stripe = getStripe()
    const subscription = await stripe.subscriptions.retrieve(session.subscription, {
      expand: ['items.data.price'],
    })
    await this.applySubscriptionToOrg(orgId, subscription)
  }

  private async handleSubscriptionUpdate(subscription: Stripe.Subscription) {
    const orgId = subscription.metadata?.['organization_id']
    if (!orgId) {
      // Fallback : remonter via stripeCustomerId
      const customerId =
        typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id
      const org = await Organization.findBy('stripeCustomerId', customerId)
      if (!org) {
        logger.warn(
          { subscriptionId: subscription.id, customerId },
          'subscription.updated : org introuvable'
        )
        return
      }
      await this.applySubscriptionToOrg(org.id, subscription)
      return
    }
    await this.applySubscriptionToOrg(orgId, subscription)
  }

  private async handleSubscriptionDeleted(subscription: Stripe.Subscription) {
    const customerId =
      typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id
    const org = await Organization.findBy('stripeCustomerId', customerId)
    if (!org) return
    org.plan = 'free'
    org.stripeSubscriptionId = null
    org.subscriptionStatus = 'canceled'
    org.billingCycle = null
    org.currentPeriodEnd = null
    org.cancelAtPeriodEnd = false
    await org.save()
    logger.info({ orgId: org.id }, 'Org redescendue en plan free (subscription deleted)')
  }

  private async handlePaymentFailed(invoice: Stripe.Invoice) {
    const customerId = typeof invoice.customer === 'string' ? invoice.customer : invoice.customer?.id
    if (!customerId) return
    const org = await Organization.findBy('stripeCustomerId', customerId)
    if (!org) return
    org.subscriptionStatus = 'past_due'
    await org.save()
    logger.warn({ orgId: org.id, invoiceId: invoice.id }, 'Paiement échoué — org marquée past_due')
  }

  /**
   * Applique l'état d'une Stripe Subscription à une org : plan, cycle,
   * status, period_end. Idempotent.
   */
  private async applySubscriptionToOrg(orgId: string, subscription: Stripe.Subscription) {
    const org = await Organization.find(orgId)
    if (!org) {
      logger.warn({ orgId }, 'applySubscriptionToOrg : org introuvable')
      return
    }
    const item = subscription.items.data[0]
    if (!item) return
    const price = item.price as Stripe.Price
    const lookupKey = price.lookup_key as string | null
    const plan = this.planFromLookupKey(lookupKey)
    const cycle = this.cycleFromLookupKey(lookupKey)

    org.plan = plan
    org.stripeSubscriptionId = subscription.id
    org.subscriptionStatus = subscription.status
    org.billingCycle = cycle
    org.currentPeriodEnd = item.current_period_end
      ? DateTime.fromSeconds(item.current_period_end)
      : null

    // Détection de l'annulation programmée. Stripe expose DEUX mécaniques :
    //   - `cancel_at_period_end: true` (booléen) → utilisé par l'API directe
    //     (`stripe.subscriptions.update --cancel-at-period-end=true`)
    //   - `cancel_at: <timestamp>` (epoch) → utilisé par le Customer Portal
    //     qui schedule un cancel à une date précise (généralement = period_end).
    //
    // Sémantiquement c'est la même chose : "le sub s'éteindra à cette date".
    // On unifie en un seul booléen pour le reste de l'app.
    org.cancelAtPeriodEnd =
      !!subscription.cancel_at_period_end || !!subscription.cancel_at
    await org.save()

    logger.info(
      {
        orgId,
        plan,
        cycle,
        status: subscription.status,
        subscriptionId: subscription.id,
        cancelAtPeriodEnd: !!subscription.cancel_at_period_end,
        cancelAt: subscription.cancel_at,
      },
      'Subscription appliquée à l\'org'
    )
  }

  private planFromLookupKey(key: string | null): 'free' | 'pro' | 'business' {
    if (!key) return 'free'
    if (key.includes('business')) return 'business'
    if (key.includes('pro')) return 'pro'
    return 'free'
  }

  private cycleFromLookupKey(key: string | null): 'monthly' | 'yearly' | null {
    if (!key) return null
    if (key.endsWith('_yearly')) return 'yearly'
    if (key.endsWith('_monthly')) return 'monthly'
    return null
  }
}
