import { ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import { Model, Types } from 'mongoose'
import { StripeClient } from '../integrations/stripe.client.js'
import { Price, PriceDocument, type PriceCycle, type PriceCurrency, type PricePlan } from '../schemas/price.schema.js'
import { Subscription, SubscriptionDocument } from '../schemas/subscription.schema.js'
import type { CreatePriceDto } from './dto/create-price.dto.js'
import type { UpdatePriceDto } from './dto/update-price.dto.js'

const CURRENCIES: PriceCurrency[] = ['DKK', 'EUR', 'USD']

// Subscriptions in these states still bill, so they feel a catalog price change
// at their next renewal. Canceled / incomplete subs are left untouched.
const LIVE_STATUSES = ['trialing', 'active', 'past_due']

@Injectable()
export class PricesService {
  private readonly logger = new Logger(PricesService.name)
  constructor(
    @InjectModel(Price.name) private readonly priceModel: Model<PriceDocument>,
    @InjectModel(Subscription.name) private readonly subModel: Model<SubscriptionDocument>,
    private readonly stripe: StripeClient,
  ) {}

  async findAll(includeInactive = false): Promise<PriceDocument[]> {
    const filter = includeInactive ? {} : { active: true }
    return this.priceModel.find(filter).sort({ plan: 1, cycle: 1 }).exec()
  }

  // Lookup used by tenant provisioning: which active row covers this
  // (plan, cycle)? Single row per pair now — currency selection happens at
  // amount-read time, not row-lookup time. Returns null when the row is
  // missing entirely (e.g. Enterprise that the operator hasn't priced).
  async findActive(plan: PricePlan, cycle: PriceCycle): Promise<PriceDocument | null> {
    return this.priceModel.findOne({ plan, cycle, active: true }).exec()
  }

  // Extract a currency's amount from a Price row, returning undefined when
  // that currency isn't priced. Centralizes the lookup so callers don't
  // reach into the amounts object directly.
  amountFor(price: PriceDocument, currency: PriceCurrency): number | undefined {
    return price.amounts[currency]
  }

  // Persist the Stripe Price id we created for (this row, currency) so the
  // next tenant on the same plan/cycle/currency reuses it instead of creating
  // a duplicate Stripe Price. Keyed write — never clobbers other currencies.
  async setStripePriceId(
    priceId: PriceDocument['_id'],
    currency: PriceCurrency,
    stripePriceId: string,
  ): Promise<void> {
    await this.priceModel
      .updateOne({ _id: priceId }, { $set: { [`stripePriceIds.${currency}`]: stripePriceId } })
      .exec()
  }

  async create(dto: CreatePriceDto): Promise<PriceDocument> {
    try {
      return await this.priceModel.create({ ...dto, active: dto.active ?? true })
    } catch (err: unknown) {
      const e = err as { code?: number; message?: string }
      if (e.code === 11000) {
        throw new ConflictException(
          `Active price for ${dto.plan}/${dto.cycle} already exists — deactivate or PATCH it.`,
        )
      }
      throw err
    }
  }

  // Count live subscriptions on a catalog row, per currency. Drives the
  // operator's "N customers affected" warning shown before a price edit commits
  // — a price change only touches subs billed in the currency that changed.
  async impact(id: string): Promise<Record<PriceCurrency, number>> {
    const out: Record<PriceCurrency, number> = { DKK: 0, EUR: 0, USD: 0 }
    if (!Types.ObjectId.isValid(id)) return out
    const priceId = new Types.ObjectId(id)
    for (const currency of CURRENCIES) {
      out[currency] = await this.subModel.countDocuments({
        priceId,
        currency,
        status: { $in: LIVE_STATUSES },
      })
    }
    return out
  }

  async update(id: string, dto: UpdatePriceDto): Promise<PriceDocument> {
    // Snapshot the pre-edit amounts so we can tell which currencies actually
    // changed value (and only re-price those).
    const before = dto.amounts ? await this.priceModel.findById(id).exec() : null
    if (dto.amounts && !before) throw new NotFoundException(`Price ${id} not found`)

    // Treat amounts as a partial merge so PATCH { amounts: { EUR: 700 } }
    // updates only EUR without clobbering DKK and USD.
    const $set: Record<string, unknown> = {}
    if (dto.amounts) {
      if (dto.amounts.DKK !== undefined) $set['amounts.DKK'] = dto.amounts.DKK
      if (dto.amounts.EUR !== undefined) $set['amounts.EUR'] = dto.amounts.EUR
      if (dto.amounts.USD !== undefined) $set['amounts.USD'] = dto.amounts.USD
    }
    if (dto.active !== undefined) $set.active = dto.active

    const price = await this.priceModel
      .findByIdAndUpdate(id, { $set }, { new: true, runValidators: true })
      .exec()
    if (!price) throw new NotFoundException(`Price ${id} not found`)

    // Propagate amount edits to Stripe + live subscriptions.
    if (before && dto.amounts) {
      const changed = CURRENCIES.filter(
        (c) => dto.amounts![c] !== undefined && dto.amounts![c] !== before.amounts?.[c],
      )
      if (changed.length > 0) await this.repriceCurrencies(price, changed)
    }
    return price
  }

  // monthly/quarterly/yearly → Stripe recurring interval + count.
  private stripeInterval(cycle: PriceCycle): { interval: 'month' | 'year'; intervalCount: number } {
    if (cycle === 'yearly') return { interval: 'year', intervalCount: 1 }
    if (cycle === 'quarterly') return { interval: 'month', intervalCount: 3 }
    return { interval: 'month', intervalCount: 1 }
  }

  // Apply a catalog amount change to Stripe and to existing customers. Stripe
  // Prices are immutable, so for each changed currency we mint a fresh Stripe
  // Price at the new amount and repoint every live subscription on this
  // (row, currency) onto it — effective at the next renewal, no proration.
  // The refreshed stripePriceIds cache means future provisions also use the new
  // Price. Stripe-disabled or unpriced currencies skip the API calls but still
  // refresh the local per-seat snapshot so MRR reflects the go-forward rate.
  // Per-subscription failures are logged, never fatal — the catalog stays saved.
  private async repriceCurrencies(price: PriceDocument, currencies: PriceCurrency[]): Promise<void> {
    for (const currency of currencies) {
      const amount = price.amounts?.[currency] ?? 0
      let newStripePriceId: string | null = null

      if (this.stripe.enabled && amount > 0) {
        try {
          const { interval, intervalCount } = this.stripeInterval(price.cycle)
          newStripePriceId = await this.stripe.createPrice({
            productName: `Dezky ${price.plan} (${price.cycle})`,
            currency,
            unitAmountMinor: amount,
            interval,
            intervalCount,
            metadata: { plan: price.plan, cycle: price.cycle, currency },
          })
          // Overwrite the cache so future provisions use the new Price too.
          await this.setStripePriceId(price._id, currency, newStripePriceId)
        } catch (err) {
          this.logger.warn(
            `Stripe Price mint failed for ${price.plan}/${price.cycle}/${currency}: ${
              err instanceof Error ? err.message : String(err)
            } — catalog saved; existing subs keep their old price. Reconcile to retry.`,
          )
        }
      }

      const subs = await this.subModel
        .find({ priceId: price._id, currency, status: { $in: LIVE_STATUSES } })
        .exec()
      let moved = 0
      for (const sub of subs) {
        try {
          if (newStripePriceId && sub.stripeSubscriptionId) {
            await this.stripe.updateSubscriptionPrice(sub.stripeSubscriptionId, newStripePriceId)
          }
          sub.perSeatAmount = amount
          await sub.save()
          moved++
        } catch (err) {
          this.logger.warn(
            `Re-price failed for subscription ${sub._id} (tenant ${sub.tenantId}): ${
              err instanceof Error ? err.message : String(err)
            }`,
          )
        }
      }
      if (subs.length > 0) {
        this.logger.log(
          `Re-priced ${moved}/${subs.length} live ${currency} subscription(s) on ` +
            `${price.plan}/${price.cycle} → ${amount} (effective next billing cycle)`,
        )
      }
    }
  }

  async deactivate(id: string): Promise<PriceDocument> {
    return this.update(id, { active: false })
  }

  // Bootstrap. Runs in two phases:
  //   1. Migrate any legacy rows (have top-level `currency` + `perSeatAmount`)
  //      into the new per-row currency-map shape. Preserves operator edits:
  //      if Starter/Monthly was edited to 48 DKK before, the new row keeps
  //      DKK=4800 and only fills in EUR/USD defaults.
  //   2. Insert any missing default rows (Starter + Business × 3 cycles)
  //      with sensible DKK/EUR/USD round numbers.
  // Both phases are idempotent — safe to run on every boot.
  async ensureDefaults(): Promise<void> {
    await this.migrateLegacy()

    type Defaults = {
      plan: PricePlan
      cycle: PriceCycle
      amounts: { DKK: number; EUR: number; USD: number }
    }
    const defaults: Defaults[] = [
      // Round numbers in each currency, not FX-derived. Operator can edit.
      { plan: 'mvp', cycle: 'monthly',   amounts: { DKK: 4900,   EUR: 700,   USD: 700   } },
      { plan: 'mvp', cycle: 'quarterly', amounts: { DKK: 13200,  EUR: 1900,  USD: 1900  } },
      { plan: 'mvp', cycle: 'yearly',    amounts: { DKK: 48800,  EUR: 7000,  USD: 7000  } },
      { plan: 'pro', cycle: 'monthly',   amounts: { DKK: 12900,  EUR: 1800,  USD: 1800  } },
      { plan: 'pro', cycle: 'quarterly', amounts: { DKK: 34800,  EUR: 4900,  USD: 4900  } },
      { plan: 'pro', cycle: 'yearly',    amounts: { DKK: 128800, EUR: 17800, USD: 17800 } },
    ]
    for (const row of defaults) {
      const exists = await this.priceModel.findOne({ plan: row.plan, cycle: row.cycle, active: true })
      if (!exists) {
        await this.priceModel.create({ ...row, active: true }).catch(() => {
          // Race / unique-index hit — another boot inserted it; ignore.
        })
      }
    }
  }

  // Migration: legacy docs had { currency, perSeatAmount } and one row per
  // (plan, cycle, currency). Collapse them: pick the active row per
  // (plan, cycle), move perSeatAmount under amounts[currency], and drop
  // the legacy fields. Inactive duplicates get deleted (their identity has
  // been folded into the surviving row).
  private async migrateLegacy(): Promise<void> {
    const legacy = await this.priceModel
      .find({ currency: { $exists: true }, perSeatAmount: { $exists: true } } as Record<string, unknown>)
      .lean()
      .exec()
    if (legacy.length === 0) return

    this.logger.log(`Migrating ${legacy.length} legacy Price row(s) to per-row currency map`)

    // Group legacy rows by (plan, cycle). Keep the survivor with the most
    // currencies represented (or just any active one) and merge amounts in.
    const groups = new Map<string, typeof legacy>()
    for (const row of legacy) {
      const key = `${row.plan}|${row.cycle}`
      const list = groups.get(key) ?? []
      list.push(row)
      groups.set(key, list)
    }

    for (const [key, rows] of groups) {
      // Choose survivor: prefer an active row, then the lowest _id (stable).
      const survivor = rows.find((r) => (r as { active?: boolean }).active) ?? rows[0]
      const merged: { DKK?: number; EUR?: number; USD?: number } = {}
      for (const r of rows) {
        const c = (r as { currency?: PriceCurrency }).currency
        const v = (r as { perSeatAmount?: number }).perSeatAmount
        if (c && typeof v === 'number') merged[c] = v
      }

      await this.priceModel.updateOne(
        { _id: survivor._id },
        {
          $set: { amounts: merged, active: true },
          $unset: { currency: '', perSeatAmount: '' },
        },
      )

      // Delete the non-survivor rows (their amounts are now in the merged map).
      const losers = rows.filter((r) => String(r._id) !== String(survivor._id)).map((r) => r._id)
      if (losers.length > 0) {
        await this.priceModel.deleteMany({ _id: { $in: losers } })
      }
      this.logger.log(`Migrated ${key}: amounts=${JSON.stringify(merged)} (dropped ${losers.length} duplicate row(s))`)
    }
  }
}
