import {
  ConflictException,
  ForbiddenException,
  Injectable,
  Logger,
  NotFoundException,
} from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import { Model, Types } from 'mongoose'
import { AuditService, type AuditActor } from '../audit/audit.service.js'
import { AuthentikClient } from '../integrations/authentik.client.js'
import { StripeClient } from '../integrations/stripe.client.js'
import { PricesService } from '../prices/prices.service.js'
import type { PriceCurrency, PriceCycle, PriceDocument } from '../schemas/price.schema.js'
import { Subscription, SubscriptionDocument } from '../schemas/subscription.schema.js'
import { Tenant, TenantDocument } from '../schemas/tenant.schema.js'
import { User, UserDocument, roleForTenant } from '../schemas/user.schema.js'
import type { PartnerUpdateTenantDto } from '../me/dto/partner-update-tenant.dto.js'
import type { CreateTenantDto } from './dto/create-tenant.dto.js'
import type { UpdateBillingInfoDto } from './dto/update-billing-info.dto.js'
import type { UpdateSecurityPolicyDto } from './dto/update-security-policy.dto.js'
import type { UpdateTenantDto } from './dto/update-tenant.dto.js'
import { ProvisioningService } from './provisioning.service.js'

@Injectable()
export class TenantsService {
  private readonly logger = new Logger(TenantsService.name)

  constructor(
    @InjectModel(Tenant.name) private readonly tenantModel: Model<TenantDocument>,
    @InjectModel(User.name) private readonly userModel: Model<UserDocument>,
    @InjectModel(Subscription.name) private readonly subModel: Model<SubscriptionDocument>,
    private readonly provisioning: ProvisioningService,
    private readonly audit: AuditService,
    private readonly prices: PricesService,
    private readonly stripe: StripeClient,
    private readonly authentik: AuthentikClient,
  ) {}

  async listUsersForTenant(slug: string): Promise<UserDocument[]> {
    const tenant = await this.findOneBySlug(slug)
    return this.userModel
      .find({ tenantIds: tenant._id })
      .sort({ lastLoginAt: -1, createdAt: -1 })
      .exec()
  }

  async create(dto: CreateTenantDto, actor?: AuditActor): Promise<TenantDocument> {
    const exists = await this.tenantModel.exists({ slug: dto.slug })
    if (exists) throw new ConflictException(`Tenant with slug "${dto.slug}" already exists`)
    const tenant = await this.tenantModel.create({ ...dto, status: 'pending' })
    void this.audit.record(
      {
        action: 'tenant.created',
        resourceType: 'tenant',
        resourceId: String(tenant._id),
        resourceName: tenant.name,
        tenantSlug: tenant.slug,
        metadata: { plan: tenant.plan, domains: tenant.domains, seats: tenant.seats },
      },
      actor,
    )

    // Spin up the matching Subscription. Looks up the active Price row for
    // (plan, cycle) — single row regardless of currency now — then reads
    // amounts[currency] for the snapshot. If the chosen currency isn't
    // priced, we still create the sub but with perSeatAmount=0 so MRR
    // reports it as "Custom" rather than a misleading non-zero value.
    try {
      const plan = tenant.plan ?? 'mvp'
      const cycle = dto.cycle ?? 'monthly'
      const currency = dto.currency ?? 'DKK'
      const price = await this.prices.findActive(plan, cycle)
      const perSeatAmount = price ? (this.prices.amountFor(price, currency) ?? 0) : 0
      const sub = await this.subModel.create({
        tenantId: tenant._id,
        plan,
        cycle,
        currency,
        priceId: price?._id,
        perSeatAmount,
        seats: tenant.seats ?? 0,
        status: 'active',
      })

      // Mirror the subscription into Stripe (best-effort, only when enabled).
      // A Stripe failure must not fail tenant creation — the local sub still
      // drives derived MRR; /tenants/:slug/reconcile can retry later.
      await this.syncStripeOnCreate(tenant, sub, price, currency, perSeatAmount)
    } catch (err) {
      this.logger.warn(
        `Subscription auto-create failed for tenant ${tenant.slug}: ${
          err instanceof Error ? err.message : String(err)
        } — tenant created, MRR will read as 0 until the sub is added.`,
      )
    }

    // Provision external resources best-effort. Errors are recorded on the doc;
    // the caller can re-POST or call /tenants/:slug/reconcile to retry.
    return this.provisioning.reconcile(tenant)
  }

  async reconcile(slug: string): Promise<TenantDocument> {
    const tenant = await this.findOneBySlug(slug)
    return this.provisioning.reconcile(tenant)
  }

  async findAll(): Promise<TenantDocument[]> {
    return this.tenantModel.find().sort({ createdAt: -1 }).exec()
  }

  async findByIds(ids: Types.ObjectId[]): Promise<TenantDocument[]> {
    if (ids.length === 0) return []
    return this.tenantModel
      .find({ _id: { $in: ids } })
      .sort({ createdAt: -1 })
      .exec()
  }

  async findOneBySlug(slug: string): Promise<TenantDocument> {
    const tenant = await this.tenantModel.findOne({ slug }).exec()
    if (!tenant) throw new NotFoundException(`Tenant "${slug}" not found`)
    return tenant
  }

  async findOneById(id: string | Types.ObjectId): Promise<TenantDocument> {
    const tenant = await this.tenantModel.findById(id).exec()
    if (!tenant) throw new NotFoundException(`Tenant ${id} not found`)
    return tenant
  }

  /**
   * Loads a tenant by slug and asserts it belongs to the given partner.
   *
   * Every partner-scoped mutation MUST funnel through this. The bare
   * update/setStatus/softDelete methods are slug-keyed with no ownership
   * check (operators may touch any tenant), so without this guard a partner
   * could mutate any tenant simply by guessing its slug. Returns the loaded
   * tenant so callers don't re-fetch.
   */
  async assertPartnerOwnsTenant(
    slug: string,
    partnerId: string | Types.ObjectId,
  ): Promise<TenantDocument> {
    const tenant = await this.findOneBySlug(slug)
    if (!tenant.partnerId || String(tenant.partnerId) !== String(partnerId)) {
      throw new ForbiddenException(`Tenant "${slug}" is not in your portfolio`)
    }
    return tenant
  }

  // Partner-scoped tenant edit. Funnels through assertPartnerOwnsTenant so a
  // partner can only touch customers in their own portfolio. A seat change is
  // mirrored onto the tenant's Subscription so MRR stays correct.
  async partnerUpdate(
    slug: string,
    partnerId: string | Types.ObjectId,
    dto: PartnerUpdateTenantDto,
    actor?: AuditActor,
  ): Promise<TenantDocument> {
    const tenant = await this.assertPartnerOwnsTenant(slug, partnerId)
    const set: Record<string, unknown> = {}
    if (dto.name !== undefined) set.name = dto.name
    if (dto.industry !== undefined) set.industry = dto.industry
    if (dto.brandColor !== undefined) set.brandColor = dto.brandColor
    if (dto.domains !== undefined) set.domains = dto.domains
    if (dto.seats !== undefined) set.seats = dto.seats
    if (Object.keys(set).length === 0) return tenant

    // Filter by partnerId too (not just slug) so a concurrent operator
    // re-assignment between the ownership assert and this update can't let the
    // write land on a tenant the partner no longer owns — it returns null then.
    const updated = await this.tenantModel
      .findOneAndUpdate({ slug, partnerId }, { $set: set }, { new: true, runValidators: true })
      .exec()
    if (!updated) throw new ForbiddenException(`Tenant "${slug}" is not in your portfolio`)

    if (dto.seats !== undefined) {
      await this.subModel.updateOne({ tenantId: updated._id }, { $set: { seats: dto.seats } }).exec()
      // Mirror the seat count onto the live Stripe subscription quantity.
      await this.syncStripeQuantity(updated._id, dto.seats)
    }

    void this.audit.record(
      {
        action: 'tenant.updated',
        resourceType: 'tenant',
        resourceId: String(updated._id),
        resourceName: updated.name,
        tenantSlug: updated.slug,
        metadata: { changes: Object.keys(set), via: 'partner' },
      },
      actor,
    )
    return updated
  }

  // Partner-scoped suspend/resume. Ownership-guarded, then delegates to the
  // shared setStatus (which records the tenant.suspended / tenant.resumed audit).
  async partnerSetStatus(
    slug: string,
    partnerId: string | Types.ObjectId,
    status: 'active' | 'suspended',
    actor?: AuditActor,
  ): Promise<TenantDocument> {
    await this.assertPartnerOwnsTenant(slug, partnerId)
    return this.setStatus(slug, status, actor)
  }

  async update(slug: string, dto: UpdateTenantDto, actor?: AuditActor): Promise<TenantDocument> {
    // Build $set / $unset explicitly. Doing `findOneAndUpdate({slug}, dto, ...)`
    // with a class-transformer instance leaks undefined slots into the update,
    // and Mongoose doesn't always cast string→ObjectId for ref fields when
    // wrapped that way. Explicit $set keeps the intent clear and handles the
    // detach case (`partnerId: null`) cleanly via $unset.
    const set: Record<string, unknown> = {}
    const unset: Record<string, ''> = {}
    if (dto.name !== undefined) set.name = dto.name
    if (dto.status !== undefined) set.status = dto.status
    if (dto.plan !== undefined) set.plan = dto.plan
    if (dto.domains !== undefined) set.domains = dto.domains
    if (dto.partnerId !== undefined) {
      if (dto.partnerId === null) unset.partnerId = ''
      else set.partnerId = new Types.ObjectId(dto.partnerId)
    }
    const update: Record<string, unknown> = {}
    if (Object.keys(set).length) update.$set = set
    if (Object.keys(unset).length) update.$unset = unset

    const tenant = await this.tenantModel
      .findOneAndUpdate({ slug }, update, { new: true, runValidators: true })
      .exec()
    if (!tenant) throw new NotFoundException(`Tenant "${slug}" not found`)

    void this.audit.record(
      {
        action: 'tenant.updated',
        resourceType: 'tenant',
        resourceId: String(tenant._id),
        resourceName: tenant.name,
        tenantSlug: tenant.slug,
        metadata: {
          changes: Object.keys(set).concat(Object.keys(unset).map((k) => `${k}:cleared`)),
        },
      },
      actor,
    )
    return tenant
  }

  // Narrow update of just the company/tax fields, for the customer-admin billing
  // page. Uses dotted $set so only the provided keys change (and '' clears one)
  // without disturbing the rest of billingInfo.
  async updateBillingInfo(
    slug: string,
    dto: UpdateBillingInfoDto,
    actor?: AuditActor,
  ): Promise<TenantDocument> {
    const set: Record<string, unknown> = {}
    if (dto.companyName !== undefined) set['billingInfo.companyName'] = dto.companyName
    if (dto.vatId !== undefined) set['billingInfo.vatId'] = dto.vatId
    if (dto.country !== undefined) set['billingInfo.country'] = dto.country
    if (dto.contactEmail !== undefined) set['billingInfo.contactEmail'] = dto.contactEmail

    const tenant = await this.tenantModel
      .findOneAndUpdate({ slug }, { $set: set }, { new: true, runValidators: true })
      .exec()
    if (!tenant) throw new NotFoundException(`Tenant "${slug}" not found`)

    void this.audit.record(
      {
        action: 'tenant.billing_info_updated',
        resourceType: 'tenant',
        resourceId: String(tenant._id),
        resourceName: tenant.name,
        tenantSlug: tenant.slug,
        metadata: { fields: Object.keys(set) },
      },
      actor,
    )
    return tenant
  }

  // Narrow update of the security policy (stored intent). Dotted $set so each
  // provided field changes independently; arrays replace wholesale.
  async updateSecurityPolicy(
    slug: string,
    dto: UpdateSecurityPolicyDto,
    actor?: AuditActor,
  ): Promise<TenantDocument> {
    const set: Record<string, unknown> = {}
    if (dto.mfaMode !== undefined) set['securityPolicy.mfaMode'] = dto.mfaMode
    if (dto.sessionIdleMinutes !== undefined) set['securityPolicy.sessionIdleMinutes'] = dto.sessionIdleMinutes
    if (dto.sessionAbsoluteHours !== undefined) set['securityPolicy.sessionAbsoluteHours'] = dto.sessionAbsoluteHours
    if (dto.allowedCountries !== undefined) {
      set['securityPolicy.allowedCountries'] = dto.allowedCountries.map((c) => c.toUpperCase())
    }
    if (dto.ipAllowlist !== undefined) set['securityPolicy.ipAllowlist'] = dto.ipAllowlist

    const tenant = await this.tenantModel
      .findOneAndUpdate({ slug }, { $set: set }, { new: true, runValidators: true })
      .exec()
    if (!tenant) throw new NotFoundException(`Tenant "${slug}" not found`)

    void this.audit.record(
      {
        action: 'tenant.security_policy_updated',
        resourceType: 'tenant',
        resourceId: String(tenant._id),
        resourceName: tenant.name,
        tenantSlug: tenant.slug,
        metadata: { fields: Object.keys(set) },
      },
      actor,
    )
    return tenant
  }

  // Live MFA-enrollment overview for the workspace. Reads each member's
  // Authentik authenticator count (TOTP/WebAuthn/static); a count > 0 = enrolled.
  // Best-effort per user — a failed Authentik lookup counts as not-enrolled
  // rather than failing the whole call.
  async mfaStatusForTenant(slug: string): Promise<{
    total: number
    enrolled: number
    members: Array<{ id: string; name: string; email: string; role: string; enrolled: boolean }>
  }> {
    const tenant = await this.findOneBySlug(slug)
    const users = await this.userModel
      .find({ tenantIds: tenant._id, active: { $ne: false } })
      .sort({ name: 1 })
      .exec()

    const members = await Promise.all(
      users.map(async (u) => {
        let enrolled = false
        if (u.authentikUserPk != null) {
          try {
            enrolled = (await this.authentik.countAuthenticators(u.authentikUserPk)) > 0
          } catch {
            enrolled = false
          }
        }
        // Tenant-scoped role, not the global fallback — a user who is admin
        // here but member elsewhere must read 'admin' in this list.
        return {
          id: String(u._id),
          name: u.name,
          email: u.email,
          role: roleForTenant(u, tenant._id),
          enrolled,
        }
      }),
    )
    return {
      total: members.length,
      enrolled: members.filter((m) => m.enrolled).length,
      members,
    }
  }

  async softDelete(slug: string, actor?: AuditActor): Promise<void> {
    const result = await this.tenantModel
      .findOneAndUpdate({ slug }, { status: 'deleted' }, { new: true })
      .exec()
    if (!result) throw new NotFoundException(`Tenant "${slug}" not found`)
    // Cancel the live Stripe subscription (best-effort) so a deleted tenant
    // stops accruing charges.
    await this.syncStripeCancel(result._id)
    void this.audit.record(
      {
        action: 'tenant.deleted',
        resourceType: 'tenant',
        resourceId: String(result._id),
        resourceName: result.name,
        tenantSlug: result.slug,
      },
      actor,
    )
  }

  async setStatus(
    slug: string,
    status: 'active' | 'suspended',
    actor?: AuditActor,
  ): Promise<TenantDocument> {
    const tenant = await this.tenantModel
      .findOneAndUpdate({ slug }, { status }, { new: true })
      .exec()
    if (!tenant) throw new NotFoundException(`Tenant "${slug}" not found`)
    // Pause/resume the live Stripe subscription to match (best-effort).
    await this.syncStripeStatus(tenant._id, status)
    void this.audit.record(
      {
        action: status === 'suspended' ? 'tenant.suspended' : 'tenant.resumed',
        resourceType: 'tenant',
        resourceId: String(tenant._id),
        resourceName: tenant.name,
        tenantSlug: tenant.slug,
      },
      actor,
    )
    return tenant
  }

  // ── Stripe lifecycle sync ──────────────────────────────────────────────────
  // All helpers below are best-effort and gated on `stripe.enabled`. When Stripe
  // is disabled (dev default) they are cheap no-ops, so the local Subscription
  // doc remains the single source of truth for derived MRR.

  // 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 }
  }

  // Resolve (creating + persisting once) the Stripe Price id for a catalog row
  // in a given currency. Returns null when the row has no amount in that
  // currency (e.g. Enterprise / unpriced) — caller then skips the subscription.
  private async resolveStripePriceId(
    price: PriceDocument,
    currency: PriceCurrency,
    perSeatAmount: number,
  ): Promise<string | null> {
    if (perSeatAmount <= 0) return null
    const existing = price.stripePriceIds?.[currency]
    if (existing) return existing
    const { interval, intervalCount } = this.stripeInterval(price.cycle)
    const stripePriceId = await this.stripe.createPrice({
      productName: `Dezky ${price.plan} (${price.cycle})`,
      currency,
      unitAmountMinor: perSeatAmount,
      interval,
      intervalCount,
      metadata: { plan: price.plan, cycle: price.cycle, currency },
    })
    await this.prices.setStripePriceId(price._id, currency, stripePriceId)
    return stripePriceId
  }

  // On tenant create: open a Stripe customer and, when the plan is priced and
  // has at least one seat, a matching subscription. Persists the Stripe ids
  // back onto the local Subscription doc so the webhook can correlate events.
  private async syncStripeOnCreate(
    tenant: TenantDocument,
    sub: SubscriptionDocument,
    price: PriceDocument | null,
    currency: PriceCurrency,
    perSeatAmount: number,
  ): Promise<void> {
    if (!this.stripe.enabled) return
    try {
      const customerId = await this.stripe.createCustomer({
        name: tenant.name,
        metadata: { tenantId: String(tenant._id), slug: tenant.slug },
      })
      sub.stripeCustomerId = customerId

      const seats = sub.seats ?? 0
      const stripePriceId = price
        ? await this.resolveStripePriceId(price, currency, perSeatAmount)
        : null
      if (stripePriceId && seats >= 1) {
        const created = await this.stripe.createSubscription({
          customerId,
          priceId: stripePriceId,
          quantity: seats,
        })
        sub.stripeSubscriptionId = created.id
        if (created.currentPeriodEnd) sub.currentPeriodEnd = new Date(created.currentPeriodEnd * 1000)
      }
      await sub.save()
      this.logger.log(
        `Stripe billing provisioned for ${tenant.slug}: customer=${customerId}${
          sub.stripeSubscriptionId ? ` sub=${sub.stripeSubscriptionId}` : ' (no subscription — unpriced or 0 seats)'
        }`,
      )
    } catch (err) {
      this.logger.warn(
        `Stripe provisioning failed for tenant ${tenant.slug}: ${
          err instanceof Error ? err.message : String(err)
        } — local subscription kept; reconcile to retry.`,
      )
    }
  }

  private async syncStripeQuantity(tenantId: Types.ObjectId, seats: number): Promise<void> {
    if (!this.stripe.enabled || seats < 1) return
    const sub = await this.subModel.findOne({ tenantId }, { stripeSubscriptionId: 1 }).exec()
    if (!sub?.stripeSubscriptionId) return
    try {
      await this.stripe.updateSubscriptionQuantity(sub.stripeSubscriptionId, seats)
    } catch (err) {
      this.logger.warn(
        `Stripe quantity update failed for tenant ${tenantId}: ${
          err instanceof Error ? err.message : String(err)
        }`,
      )
    }
  }

  private async syncStripeStatus(
    tenantId: Types.ObjectId,
    status: 'active' | 'suspended',
  ): Promise<void> {
    if (!this.stripe.enabled) return
    const sub = await this.subModel.findOne({ tenantId }, { stripeSubscriptionId: 1 }).exec()
    if (!sub?.stripeSubscriptionId) return
    try {
      if (status === 'suspended') await this.stripe.pauseSubscription(sub.stripeSubscriptionId)
      else await this.stripe.resumeSubscription(sub.stripeSubscriptionId)
    } catch (err) {
      this.logger.warn(
        `Stripe pause/resume failed for tenant ${tenantId}: ${
          err instanceof Error ? err.message : String(err)
        }`,
      )
    }
  }

  private async syncStripeCancel(tenantId: Types.ObjectId): Promise<void> {
    if (!this.stripe.enabled) return
    const sub = await this.subModel.findOne({ tenantId }, { stripeSubscriptionId: 1 }).exec()
    if (!sub?.stripeSubscriptionId) return
    try {
      await this.stripe.cancelSubscription(sub.stripeSubscriptionId)
    } catch (err) {
      this.logger.warn(
        `Stripe cancel failed for tenant ${tenantId}: ${
          err instanceof Error ? err.message : String(err)
        }`,
      )
    }
  }
}
