import {
  ForbiddenException,
  Injectable,
  Logger,
  NotFoundException,
  ServiceUnavailableException,
} from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import type Stripe from 'stripe'
import { Model, Types } from 'mongoose'
import { AuditService, type AuditActor } from '../audit/audit.service.js'
import { StripeClient } from '../integrations/stripe.client.js'
import { Invoice, InvoiceDocument } from '../schemas/invoice.schema.js'
import { Payout, PayoutDocument } from '../schemas/payout.schema.js'
import { Subscription, SubscriptionDocument } from '../schemas/subscription.schema.js'
import { Partner, PartnerDocument } from '../schemas/partner.schema.js'
import { Tenant, TenantDocument } from '../schemas/tenant.schema.js'
import { UsersService } from '../users/users.service.js'

type Currency = 'DKK' | 'EUR' | 'USD'

function toCurrency(c?: string): Currency {
  const up = (c ?? 'DKK').toUpperCase()
  return up === 'EUR' || up === 'USD' ? up : 'DKK'
}

// Current calendar month as YYYY-MM (UTC). The payout ledger keys on this.
function currentPeriodMonth(): string {
  return new Date().toISOString().slice(0, 7)
}

// Billing reads run on DERIVED data (Subscription + Price + marginPct + the
// Invoice/Payout collections), so the billing pages show real numbers in dev
// even with no live Stripe. The webhook populates Invoices/Subscription status
// once Stripe is enabled.
@Injectable()
export class BillingService {
  private readonly logger = new Logger(BillingService.name)

  constructor(
    @InjectModel(Invoice.name) private readonly invoiceModel: Model<InvoiceDocument>,
    @InjectModel(Payout.name) private readonly payoutModel: Model<PayoutDocument>,
    @InjectModel(Subscription.name) private readonly subModel: Model<SubscriptionDocument>,
    @InjectModel(Partner.name) private readonly partnerModel: Model<PartnerDocument>,
    @InjectModel(Tenant.name) private readonly tenantModel: Model<TenantDocument>,
    private readonly users: UsersService,
    private readonly stripe: StripeClient,
    private readonly audit: AuditService,
  ) {}

  // ── Partner billing reads ────────────────────────────────────────────────
  async partnerSummary(partnerId: Types.ObjectId): Promise<{
    marginPct: number
    mrr: Array<{ currency: Currency; monthlyMinor: number; partnerCutMinor: number; netMinor: number }>
    customers: number
    openInvoices: number
    openAmountMinor: number
    stripeLive: boolean
  }> {
    const [mrr, partner] = await Promise.all([
      this.users.partnerMrr(partnerId),
      this.partnerModel.findById(partnerId, { marginPct: 1 }).exec(),
    ])
    const marginPct = partner?.marginPct ?? 0
    const mrrRows = mrr.totals.map((t) => {
      const partnerCutMinor = Math.round((t.monthlyMinor * marginPct) / 100)
      return {
        currency: t.currency,
        monthlyMinor: t.monthlyMinor,
        partnerCutMinor,
        netMinor: t.monthlyMinor - partnerCutMinor,
      }
    })
    const openInv = await this.invoiceModel
      .find({ partnerId, status: { $in: ['open', 'past_due'] } })
      .exec()
    const openAmountMinor = openInv.reduce((s, i) => s + (i.amountDue - i.amountPaid), 0)

    return {
      marginPct,
      mrr: mrrRows,
      customers: mrr.breakdown.length,
      openInvoices: openInv.length,
      openAmountMinor,
      stripeLive: this.stripe.enabled,
    }
  }

  async partnerInvoices(partnerId: Types.ObjectId): Promise<InvoiceDocument[]> {
    return this.invoiceModel.find({ partnerId }).sort({ createdAt: -1 }).limit(100).exec()
  }

  async partnerPayouts(partnerId: Types.ObjectId): Promise<PayoutDocument[]> {
    return this.payoutModel.find({ partnerId }).sort({ periodMonth: -1 }).exec()
  }

  // ── Payout ledger generation ──────────────────────────────────────────────
  // v1 is a COMPUTED ledger (not Stripe Connect): for the given month we snapshot
  // each partner's gross MRR per currency and their marginPct cut into Payout rows.
  // Idempotent — re-running the same period refreshes the (still-pending) amounts;
  // a row already marked `paid` is left untouched so a settled payout is never
  // rewritten. Disbursement stays out-of-band (operator marks rows paid).
  //
  // NOTE: MRR is a CURRENT snapshot, so regenerating a closed month would re-read
  // today's subscriptions. The worker only ever generates the current month, so a
  // past month freezes at its last snapshot once the month rolls over.
  async generatePayouts(
    periodMonth?: string,
    actor?: AuditActor,
  ): Promise<{ periodMonth: string; partners: number; rows: number }> {
    const period = periodMonth ?? currentPeriodMonth()
    const partners = await this.partnerModel.find({}, { marginPct: 1 }).exec()
    let rows = 0
    for (const p of partners) {
      const marginPct = p.marginPct ?? 0
      const mrr = await this.users.partnerMrr(p._id)
      for (const t of mrr.totals) {
        // Never mutate a settled payout.
        const existing = await this.payoutModel
          .findOne({ partnerId: p._id, periodMonth: period, currency: t.currency }, { status: 1 })
          .exec()
        if (existing?.status === 'paid') continue
        const payoutMinor = Math.round((t.monthlyMinor * marginPct) / 100)
        await this.payoutModel
          .updateOne(
            { partnerId: p._id, periodMonth: period, currency: t.currency },
            {
              $set: { grossMrrMinor: t.monthlyMinor, marginPct, payoutMinor },
              $setOnInsert: { status: 'pending' },
            },
            { upsert: true },
          )
          .exec()
        rows++
      }
    }
    void this.audit.record(
      {
        action: 'billing.payouts_generated',
        resourceType: 'subscription',
        resourceId: period,
        metadata: { periodMonth: period, partners: partners.length, rows },
      },
      actor,
    )
    this.logger.log(`Generated payouts for ${period}: ${rows} row(s) across ${partners.length} partner(s)`)
    return { periodMonth: period, partners: partners.length, rows }
  }

  async platformPayouts(): Promise<PayoutDocument[]> {
    return this.payoutModel.find().sort({ periodMonth: -1 }).exec()
  }

  // ── Operator (platform-wide) billing reads ───────────────────────────────
  async platformSummary(): Promise<{
    invoicedMinor: number
    paidMinor: number
    outstandingMinor: number
    openInvoices: number
    pastDueInvoices: number
    stripeLive: boolean
  }> {
    const invoices = await this.invoiceModel.find().exec()
    let invoicedMinor = 0
    let paidMinor = 0
    let outstandingMinor = 0
    let openInvoices = 0
    let pastDueInvoices = 0
    for (const i of invoices) {
      invoicedMinor += i.amountDue
      paidMinor += i.amountPaid
      if (i.status === 'open' || i.status === 'past_due') {
        outstandingMinor += i.amountDue - i.amountPaid
        openInvoices++
        if (i.status === 'past_due') pastDueInvoices++
      }
    }
    return { invoicedMinor, paidMinor, outstandingMinor, openInvoices, pastDueInvoices, stripeLive: this.stripe.enabled }
  }

  async platformInvoices(): Promise<InvoiceDocument[]> {
    return this.invoiceModel.find().sort({ createdAt: -1 }).limit(200).exec()
  }

  async tenantInvoices(tenantId: Types.ObjectId): Promise<InvoiceDocument[]> {
    return this.invoiceModel.find({ tenantId }).sort({ createdAt: -1 }).exec()
  }

  async tenantInvoicesBySlug(slug: string): Promise<InvoiceDocument[]> {
    const tenant = await this.tenantModel.findOne({ slug }, { _id: 1 }).exec()
    if (!tenant) return []
    return this.tenantInvoices(tenant._id)
  }

  // Membership-gated variant for the customer-admin portal: only a member of
  // the tenant (or a platform admin) may read its invoices. Mirrors the
  // membership check used across the tenant-scoped portal endpoints.
  async tenantInvoicesForActor(
    slug: string,
    actor: { platformAdmin: boolean; tenantIds: Types.ObjectId[] },
  ): Promise<InvoiceDocument[]> {
    const tenant = await this.tenantModel.findOne({ slug }, { _id: 1 }).exec()
    if (!tenant) return []
    if (!actor.platformAdmin && !actor.tenantIds.some((id) => id.equals(tenant._id))) {
      throw new ForbiddenException(`No access to tenant "${slug}"`)
    }
    return this.tenantInvoices(tenant._id)
  }

  // ── Customer payment method (card on file) ────────────────────────────────

  // Load the tenant + its subscription, enforcing membership. Throws 404 if the
  // tenant is unknown, 403 if the caller isn't a member.
  private async resolveTenantSub(
    slug: string,
    actor: { platformAdmin: boolean; tenantIds: Types.ObjectId[] },
  ): Promise<{ tenant: TenantDocument; sub: SubscriptionDocument | null }> {
    const tenant = await this.tenantModel.findOne({ slug }).exec()
    if (!tenant) throw new NotFoundException(`Tenant "${slug}" not found`)
    if (!actor.platformAdmin && !actor.tenantIds.some((id) => id.equals(tenant._id))) {
      throw new ForbiddenException(`No access to tenant "${slug}"`)
    }
    const sub = await this.subModel.findOne({ tenantId: tenant._id }).exec()
    return { tenant, sub }
  }

  // Ensure the tenant's subscription has a Stripe customer, creating one on
  // demand (older tenants provisioned before Stripe was enabled lack it).
  private async ensureStripeCustomer(tenant: TenantDocument, sub: SubscriptionDocument | null): Promise<string> {
    if (!this.stripe.enabled) throw new ServiceUnavailableException('Billing is not enabled')
    if (!sub) throw new NotFoundException(`No subscription for tenant "${tenant.slug}"`)
    if (sub.stripeCustomerId) return sub.stripeCustomerId
    const customerId = await this.stripe.createCustomer({
      name: tenant.name,
      email: tenant.billingInfo?.contactEmail,
      metadata: { tenantId: String(tenant._id), slug: tenant.slug },
    })
    sub.stripeCustomerId = customerId
    await sub.save()
    return customerId
  }

  // Read-only: the card on file, or null. Doesn't create a customer just to read.
  async getPaymentMethod(
    slug: string,
    actor: { platformAdmin: boolean; tenantIds: Types.ObjectId[] },
  ): Promise<{ brand: string; last4: string; expMonth: number; expYear: number } | null> {
    if (!this.stripe.enabled) return null
    const { sub } = await this.resolveTenantSub(slug, actor)
    if (!sub?.stripeCustomerId) return null
    return this.stripe.getDefaultCard(sub.stripeCustomerId)
  }

  // Begin a card-update: returns the SetupIntent secret + publishable key for
  // the portal to mount Stripe Elements. Creates a Stripe customer if missing.
  async createPaymentSetupIntent(
    slug: string,
    actor: { platformAdmin: boolean; tenantIds: Types.ObjectId[] },
  ): Promise<{ clientSecret: string; publishableKey: string }> {
    const { tenant, sub } = await this.resolveTenantSub(slug, actor)
    const customerId = await this.ensureStripeCustomer(tenant, sub)
    const clientSecret = await this.stripe.createSetupIntent(customerId)
    return { clientSecret, publishableKey: this.stripe.publishableKey ?? '' }
  }

  // Finish a card-update: make the newly-attached PM the default and return the
  // resulting card so the UI can refresh without a second round-trip.
  async setDefaultPaymentMethod(
    slug: string,
    actor: { platformAdmin: boolean; tenantIds: Types.ObjectId[] },
    paymentMethodId: string,
    auditActor?: AuditActor,
  ): Promise<{ brand: string; last4: string; expMonth: number; expYear: number } | null> {
    const { tenant, sub } = await this.resolveTenantSub(slug, actor)
    const customerId = await this.ensureStripeCustomer(tenant, sub)
    await this.stripe.setDefaultCard(customerId, paymentMethodId, sub?.stripeSubscriptionId)
    void this.audit.record(
      {
        action: 'billing.payment_method_updated',
        resourceType: 'subscription',
        resourceId: tenant.slug,
        resourceName: tenant.name,
        tenantSlug: tenant.slug,
        source: 'portal',
      },
      auditActor,
    )
    return this.stripe.getDefaultCard(customerId)
  }

  // ── Stripe webhook ────────────────────────────────────────────────────────
  // Idempotent upsert by stripeInvoiceId / subscription id. Only meaningful
  // when Stripe is enabled; no-op acknowledgement otherwise.
  async handleWebhookEvent(event: Stripe.Event, actor?: AuditActor): Promise<void> {
    switch (event.type) {
      case 'invoice.paid':
      case 'invoice.payment_failed':
      case 'invoice.finalized': {
        await this.upsertInvoiceFromStripe(event.data.object as Stripe.Invoice, event.type)
        break
      }
      case 'customer.subscription.updated':
      case 'customer.subscription.deleted': {
        await this.applySubscriptionEvent(event.data.object as Stripe.Subscription, event.type)
        break
      }
      default:
        // Ignore unhandled event types.
        return
    }
    void this.audit.record(
      {
        action: `billing.${event.type.replace(/\./g, '_')}`,
        resourceType: 'subscription',
        resourceId: event.id,
        source: 'platform-api',
        externalId: event.id,
      },
      actor,
    )
  }

  private async upsertInvoiceFromStripe(inv: Stripe.Invoice, eventType: string): Promise<void> {
    const customerId = typeof inv.customer === 'string' ? inv.customer : inv.customer?.id
    if (!customerId) return
    const sub = await this.subModel.findOne({ stripeCustomerId: customerId }).exec()
    if (!sub) {
      this.logger.warn(`Stripe invoice ${inv.id} for unknown customer ${customerId} — skipping`)
      return
    }
    const tenant = await this.tenantModel.findById(sub.tenantId, { partnerId: 1 }).exec()
    const status: InvoiceDocument['status'] =
      eventType === 'invoice.payment_failed'
        ? 'past_due'
        : inv.status === 'paid'
          ? 'paid'
          : ((inv.status as InvoiceDocument['status']) ?? 'open')

    await this.invoiceModel
      .findOneAndUpdate(
        { stripeInvoiceId: inv.id },
        {
          $set: {
            tenantId: sub.tenantId,
            partnerId: tenant?.partnerId,
            subscriptionId: sub._id,
            number: inv.number ?? undefined,
            currency: toCurrency(inv.currency),
            amountDue: inv.amount_due ?? 0,
            amountPaid: inv.amount_paid ?? 0,
            status,
            periodStart: inv.period_start ? new Date(inv.period_start * 1000) : undefined,
            periodEnd: inv.period_end ? new Date(inv.period_end * 1000) : undefined,
            hostedInvoiceUrl: inv.hosted_invoice_url ?? undefined,
            pdfUrl: inv.invoice_pdf ?? undefined,
          },
        },
        { upsert: true, new: true },
      )
      .exec()
  }

  private async applySubscriptionEvent(sub: Stripe.Subscription, eventType: string): Promise<void> {
    const set: Record<string, unknown> = {}
    if (eventType === 'customer.subscription.deleted') {
      set.status = 'canceled'
      set.canceledAt = new Date()
    } else {
      set.status = sub.status
    }
    const cpe = (sub as { current_period_end?: number }).current_period_end
    if (cpe) set.currentPeriodEnd = new Date(cpe * 1000)
    await this.subModel.updateOne({ stripeSubscriptionId: sub.id }, { $set: set }).exec()
  }
}
