import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import { Model, Types } from 'mongoose'
import { AuditService, type AuditActor } from '../audit/audit.service.js'
import { Partner, PartnerDocument } from '../schemas/partner.schema.js'
import { Tenant, TenantDocument } from '../schemas/tenant.schema.js'
import type { PartnerSettingsDto } from '../me/dto/partner-settings.dto.js'
import type { CreatePartnerDto } from './dto/create-partner.dto.js'
import type { UpdatePartnerDto } from './dto/update-partner.dto.js'

// Aggregated view of a Partner — adds counts that are computed at query time
// rather than stored on the document. Storing denormalized would force a sync
// hop on every tenant create/suspend; the count query is cheap with the index
// on Tenant.partnerId.
export interface PartnerWithStats {
  partner: PartnerDocument
  customers: number
  // MRR sum is intentionally absent for now — Tenant doesn't carry a
  // monthlyAmount yet (lives on Subscription, no pricing tables). Wire when
  // Subscription gains a price column.
}

@Injectable()
export class PartnersService {
  constructor(
    @InjectModel(Partner.name) private readonly partnerModel: Model<PartnerDocument>,
    @InjectModel(Tenant.name) private readonly tenantModel: Model<TenantDocument>,
    private readonly audit: AuditService,
  ) {}

  // Partner-portal settings read — the whole partner doc; the portal projects
  // the fields it needs (profile, notificationPrefs, agreement, documents,
  // marginPct, contactInfo, billingInfo).
  async getSettings(partnerId: string | Types.ObjectId): Promise<PartnerDocument> {
    const partner = await this.partnerModel.findById(partnerId).exec()
    if (!partner) throw new NotFoundException('Partner not found')
    return partner
  }

  // Partner-editable settings update: profile + notification prefs only. Margin,
  // agreement terms, and documents are operator-controlled and ignored here.
  async updateSettings(
    partnerId: string | Types.ObjectId,
    dto: PartnerSettingsDto,
    actor?: AuditActor,
  ): Promise<PartnerDocument> {
    const set: Record<string, unknown> = {}
    if (dto.profile) {
      for (const [k, v] of Object.entries(dto.profile)) {
        if (v !== undefined && v !== null) set[`profile.${k}`] = v
      }
    }
    if (dto.notificationPrefs !== undefined) set.notificationPrefs = dto.notificationPrefs

    const partner = await this.partnerModel
      .findByIdAndUpdate(partnerId, { $set: set }, { new: true, runValidators: true })
      .exec()
    if (!partner) throw new NotFoundException('Partner not found')

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

  async create(dto: CreatePartnerDto, actor?: AuditActor): Promise<PartnerDocument> {
    const exists = await this.partnerModel.exists({ slug: dto.slug })
    if (exists) throw new ConflictException(`Partner "${dto.slug}" already exists`)
    const partner = await this.partnerModel.create({
      ...dto,
      status: dto.status ?? 'in-negotiation',
    })
    void this.audit.record(
      {
        action: 'partner.created',
        resourceType: 'partner',
        resourceId: String(partner._id),
        resourceName: partner.name,
        partnerSlug: partner.slug,
        metadata: { domain: partner.domain, marginPct: partner.marginPct },
      },
      actor,
    )
    return partner
  }

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

  async findAllWithStats(): Promise<PartnerWithStats[]> {
    const partners = await this.findAll()
    if (partners.length === 0) return []
    const counts = await this.tenantModel.aggregate<{ _id: Types.ObjectId; n: number }>([
      { $match: { partnerId: { $in: partners.map((p) => p._id) } } },
      { $group: { _id: '$partnerId', n: { $sum: 1 } } },
    ])
    const countMap = new Map(counts.map((c) => [String(c._id), c.n]))
    return partners.map((p) => ({ partner: p, customers: countMap.get(String(p._id)) ?? 0 }))
  }

  async findOneBySlug(slug: string): Promise<PartnerDocument> {
    const partner = await this.partnerModel.findOne({ slug }).exec()
    if (!partner) throw new NotFoundException(`Partner "${slug}" not found`)
    return partner
  }

  async findOneWithStats(slug: string): Promise<PartnerWithStats> {
    const partner = await this.findOneBySlug(slug)
    const customers = await this.tenantModel.countDocuments({ partnerId: partner._id })
    return { partner, customers }
  }

  async listTenants(slug: string): Promise<TenantDocument[]> {
    const partner = await this.findOneBySlug(slug)
    return this.tenantModel.find({ partnerId: partner._id }).sort({ createdAt: -1 }).exec()
  }

  async update(slug: string, dto: UpdatePartnerDto, actor?: AuditActor): Promise<PartnerDocument> {
    // Read first so we can record a before/after diff. The extra round-trip is
    // cheap — partner updates are not hot-path — and gives the audit log
    // enough context for an operator to reconstruct what changed without
    // diffing Mongo backups.
    const before = await this.partnerModel.findOne({ slug }).exec()
    if (!before) throw new NotFoundException(`Partner "${slug}" not found`)

    const partner = await this.partnerModel
      .findOneAndUpdate({ slug }, dto, { new: true, runValidators: true })
      .exec()
    if (!partner) throw new NotFoundException(`Partner "${slug}" not found`)

    const diff = buildPartnerDiff(before, partner, dto)
    void this.audit.record(
      {
        action: 'partner.updated',
        resourceType: 'partner',
        resourceId: String(partner._id),
        resourceName: partner.name,
        partnerSlug: partner.slug,
        metadata: { diff },
      },
      actor,
    )
    return partner
  }

  // Soft-terminate. We never hard-delete a partner — there may be customer
  // tenants pointing at it and we want the historical reference to survive.
  async terminate(slug: string, actor?: AuditActor): Promise<void> {
    const partner = await this.partnerModel
      .findOneAndUpdate({ slug }, { status: 'terminated' }, { new: true })
      .exec()
    if (!partner) {
      throw new NotFoundException(`Partner "${slug}" not found`)
    }
    void this.audit.record(
      {
        action: 'partner.terminated',
        resourceType: 'partner',
        resourceId: String(partner._id),
        resourceName: partner.name,
        partnerSlug: partner.slug,
      },
      actor,
    )
  }
}

// Build a { field: { from, to } } diff for the partner update audit event.
// Only keys actually present in the DTO body are considered (DTOs from class-
// validator's ValidationPipe carry every @IsOptional() field, so we filter on
// `undefined`), and only fields whose serialized representation actually
// differs are emitted — saving an unchanged form should not produce a row of
// no-op diffs in the audit log.
function buildPartnerDiff(
  before: PartnerDocument,
  after: PartnerDocument,
  dto: UpdatePartnerDto,
): Record<string, { from: unknown; to: unknown }> {
  const beforeObj = before.toObject({ flattenMaps: true })
  const afterObj = after.toObject({ flattenMaps: true })
  const diff: Record<string, { from: unknown; to: unknown }> = {}
  for (const key of Object.keys(dto) as (keyof UpdatePartnerDto)[]) {
    if ((dto as Record<string, unknown>)[key] === undefined) continue
    const from = (beforeObj as Record<string, unknown>)[key]
    const to = (afterObj as Record<string, unknown>)[key]
    if (JSON.stringify(from) === JSON.stringify(to)) continue
    diff[key] = { from: from ?? null, to: to ?? null }
  }
  return diff
}
