import {
  BadRequestException,
  ConflictException,
  Injectable,
  Logger,
  NotFoundException,
} from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import { randomBytes } from 'node:crypto'
import { Model, Types } from 'mongoose'
import { AuditService, type AuditActor } from '../audit/audit.service.js'
import {
  DomainInUseError,
  StalwartClient,
  type StalwartLinkedObject,
  type StalwartZoneRecord,
} from '../integrations/stalwart.client.js'
import {
  Domain,
  DomainDocument,
  DomainRecord,
  DmarcPolicy,
  DomainStatus,
  RecordKind,
  RecordStatus,
} from '../schemas/domain.schema.js'
import { Tenant, TenantDocument } from '../schemas/tenant.schema.js'
import { User, UserDocument } from '../schemas/user.schema.js'
import { DnsVerifierService } from './dns-verifier.service.js'

// The four status slots the customer-admin Domains page renders, plus ownership.
const CHECK_KINDS: RecordKind[] = ['ownership', 'mx', 'spf', 'dkim', 'dmarc']

// Minimal tenant identity the service needs — the controller resolves the full
// doc for its membership gate and hands us this.
export interface TenantRef {
  _id: Types.ObjectId
  slug: string
}

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

  constructor(
    @InjectModel(Domain.name) private readonly domainModel: Model<DomainDocument>,
    @InjectModel(Tenant.name) private readonly tenantModel: Model<TenantDocument>,
    @InjectModel(User.name) private readonly userModel: Model<UserDocument>,
    private readonly stalwart: StalwartClient,
    private readonly dns: DnsVerifierService,
    private readonly audit: AuditService,
  ) {}

  async list(tenant: TenantRef): Promise<DomainView[]> {
    const docs = await this.domainModel.find({ tenantId: tenant._id }).sort({ createdAt: 1 }).exec()
    return Promise.all(docs.map((d) => this.toView(d, tenant)))
  }

  async getOne(tenant: TenantRef, domain: string): Promise<DomainView> {
    const doc = await this.findOrThrow(tenant, domain)
    return this.toView(doc, tenant)
  }

  // Add a domain: provision it in Stalwart (which auto-generates DKIM), seed the
  // expected records from Stalwart's zone file + our ownership token, then run an
  // immediate DNS check so the page shows live status right away.
  async add(tenant: TenantRef, rawDomain: string, actor: AuditActor): Promise<DomainView> {
    const domain = rawDomain.trim().toLowerCase()
    const existing = await this.domainModel.findOne({ tenantId: tenant._id, domain }).exec()
    if (existing) throw new ConflictException(`Domain "${domain}" is already added`)

    const isPrimary = (await this.domainModel.countDocuments({ tenantId: tenant._id })) === 0
    const verificationToken = `dezky-verify=${randomBytes(16).toString('hex')}`

    let stalwartId: string | undefined
    let provisioned = false
    let records: DomainRecord[] = [ownershipRecord(domain, verificationToken)]

    // Provision in Stalwart BEFORE persisting. A rejected domain (bad name,
    // Stalwart outage) then fails the add cleanly with the real reason, rather
    // than leaving an orphaned error-domain on the customer's list. On a partial
    // failure (created but couldn't read the zone) we roll back the Stalwart side.
    if (this.stalwart.configured) {
      try {
        const created = await this.stalwart.ensureDomain(domain, `Dezky tenant ${tenant.slug}`)
        stalwartId = created.id
        // Stalwart generates the DKIM keypair asynchronously after the domain is
        // created, so the zone file omits the DKIM records for a moment. Poll
        // briefly so the very first add response already carries them.
        const zone = await this.zoneWithDkim(domain)
        records = this.buildRecords(domain, zone, verificationToken, 'quarantine')
        provisioned = true
      } catch (err) {
        const msg = (err as Error).message
        this.logger.error(`Stalwart provisioning failed for "${domain}": ${msg}`)
        await this.stalwart.deleteDomain(domain).catch(() => {})
        throw new BadRequestException(
          `Could not provision "${domain}" in the mail server: ${cleanStalwartError(msg)}`,
        )
      }
    }

    const doc = await this.domainModel.create({
      tenantId: tenant._id,
      domain,
      isPrimary,
      verificationToken,
      dmarcPolicy: 'quarantine',
      status: 'pending',
      stalwartId,
      stalwartProvisioned: provisioned,
      records,
    })

    await this.tenantModel.updateOne({ _id: tenant._id }, { $addToSet: { domains: domain } }).exec()
    await this.audit.record(
      {
        action: 'domain.added',
        resourceType: 'domain',
        resourceId: domain,
        resourceName: domain,
        tenantSlug: tenant.slug,
        metadata: { stalwartProvisioned: doc.stalwartProvisioned, isPrimary },
      },
      actor,
    )

    // Immediate check so the customer sees real status without a manual refresh.
    await this.runChecks(doc)
    return this.toView(doc, tenant)
  }

  // Re-run the DNS checks for a domain on demand (the "Re-check" buttons).
  async recheck(tenant: TenantRef, domain: string): Promise<DomainView> {
    const doc = await this.findOrThrow(tenant, domain)
    // (Re)seed expected records if they're incomplete — Stalwart was unreachable
    // at add time, or the async DKIM keys hadn't landed yet (no dkim record).
    const hasMail = doc.records.some((r) => r.kind === 'mx')
    const hasDkim = doc.records.some((r) => r.kind === 'dkim')
    if (this.stalwart.configured && (!hasMail || !hasDkim)) {
      try {
        const { id } = await this.stalwart.ensureDomain(domain, `Dezky tenant ${tenant.slug}`)
        const zone = await this.zoneWithDkim(domain)
        doc.stalwartId = id
        doc.stalwartProvisioned = true
        doc.stalwartError = undefined
        doc.records = this.buildRecords(domain, zone, doc.verificationToken, doc.dmarcPolicy)
      } catch (err) {
        doc.stalwartError = (err as Error).message
      }
    }
    await this.runChecks(doc)
    return this.toView(doc, tenant)
  }

  // Read the domain's zone records, polling briefly until the asynchronously
  // generated DKIM records have all appeared (Stalwart creates the keypair just
  // after the domain — and the ed25519 + RSA selectors land a beat apart). We
  // wait until the DKIM count is non-zero AND stable across two reads, so we
  // don't capture just the first selector. Budget-capped (~5s) either way.
  private async zoneWithDkim(domain: string): Promise<StalwartZoneRecord[]> {
    const dkimCount = (z: StalwartZoneRecord[]) =>
      z.filter((r) => r.fqdn.includes('._domainkey.')).length
    let zone = await this.stalwart.getZoneRecords(domain)
    let prev = -1
    for (let i = 0; i < 8; i++) {
      const count = dkimCount(zone)
      if (count > 0 && count === prev) break // stable — all selectors present
      prev = count
      await new Promise((resolve) => setTimeout(resolve, 600))
      zone = await this.stalwart.getZoneRecords(domain)
    }
    return zone
  }

  // Set the DMARC enforcement level (wizard step 5) and re-verify.
  async setDmarcPolicy(
    tenant: TenantRef,
    domain: string,
    policy: DmarcPolicy,
    actor: AuditActor,
  ): Promise<DomainView> {
    const doc = await this.findOrThrow(tenant, domain)
    doc.dmarcPolicy = policy
    const dmarc = doc.records.find((r) => r.kind === 'dmarc')
    if (dmarc) dmarc.expected = dmarcExpected(domain, policy)
    await this.audit.record(
      {
        action: 'domain.dmarc_policy_set',
        resourceType: 'domain',
        resourceId: domain,
        resourceName: domain,
        tenantSlug: tenant.slug,
        metadata: { dmarcPolicy: policy },
      },
      actor,
    )
    await this.runChecks(doc)
    return this.toView(doc, tenant)
  }

  // Remove a domain: delete it from Stalwart (best-effort) and drop our records.
  // Guarded — a domain that still has mailboxes can't be removed, or those users
  // would lose their email identity. This is enforced here (not just in the UI)
  // so the rule holds even for direct API callers.
  async remove(tenant: TenantRef, domain: string, actor: AuditActor): Promise<void> {
    const doc = await this.findOrThrow(tenant, domain)
    const mailboxes = await this.mailboxCount(tenant, doc.domain)
    if (mailboxes > 0) {
      throw new ConflictException(
        `Cannot remove "${doc.domain}" — ${mailboxes} mailbox${mailboxes === 1 ? '' : 'es'} still use${
          mailboxes === 1 ? 's' : ''
        } it. Remove or reassign those users first.`,
      )
    }
    if (this.stalwart.configured) {
      try {
        await this.stalwart.deleteDomain(doc.domain)
      } catch (err) {
        // Accounts / aliases / mailing lists still on the domain — block the
        // removal with a clear, actionable message rather than orphaning them.
        if (err instanceof DomainInUseError) {
          throw new ConflictException(
            `Cannot remove "${doc.domain}" — it still has ${summarizeLinks(err.linkedObjects)} in the mail server. Remove ${err.linkedObjects.length === 1 ? 'it' : 'them'} first.`,
          )
        }
        // Any other Stalwart failure: don't drop our record, so the two sides
        // stay consistent and the customer can retry.
        this.logger.error(`Stalwart delete failed for "${doc.domain}": ${(err as Error).message}`)
        throw err
      }
    }
    await this.domainModel.deleteOne({ _id: doc._id }).exec()
    await this.tenantModel.updateOne({ _id: tenant._id }, { $pull: { domains: domain } }).exec()
    await this.audit.record(
      {
        action: 'domain.removed',
        resourceType: 'domain',
        resourceId: domain,
        resourceName: domain,
        tenantSlug: tenant.slug,
      },
      actor,
    )
  }

  // ── internals ──────────────────────────────────────────────────────────────

  private async findOrThrow(tenant: TenantRef, domain: string): Promise<DomainDocument> {
    const doc = await this.domainModel
      .findOne({ tenantId: tenant._id, domain: domain.trim().toLowerCase() })
      .exec()
    if (!doc) throw new NotFoundException(`Domain "${domain}" not found`)
    return doc
  }

  // Check every record against DNS, write back observed+status, recompute the
  // domain's overall status, and persist. Records out to public DNS in parallel.
  private async runChecks(doc: DomainDocument): Promise<void> {
    const wasVerified = doc.ownershipVerified
    await Promise.all(
      doc.records.map(async (rec) => {
        const { observed, status } = await this.dns.check(rec, doc.domain)
        rec.observed = observed
        rec.status = status
        rec.checkedAt = new Date()
      }),
    )

    const tone = (kind: RecordKind): RecordStatus => aggregateTone(doc.records, kind)
    doc.ownershipVerified = tone('ownership') === 'ok'
    if (doc.ownershipVerified && !wasVerified) doc.verifiedAt = new Date()

    if (doc.stalwartError) {
      doc.status = 'error'
    } else if (!doc.ownershipVerified) {
      doc.status = 'pending'
    } else {
      const mail: RecordKind[] = ['mx', 'spf', 'dkim', 'dmarc']
      doc.status = mail.every((k) => tone(k) === 'ok') ? 'active' : 'verifying'
    }

    doc.lastCheckedAt = new Date()
    doc.markModified('records')
    await doc.save()
  }

  // Map Stalwart's parsed zone records → our verifiable record set. Always
  // includes the ownership TXT (which Stalwart doesn't know about). DMARC is
  // overridden to reflect the customer's chosen policy.
  private buildRecords(
    domain: string,
    zone: StalwartZoneRecord[],
    token: string,
    policy: DmarcPolicy,
  ): DomainRecord[] {
    const records: DomainRecord[] = [ownershipRecord(domain, token)]
    for (const z of zone) {
      const kind = classify(z, domain)
      if (!kind) continue
      records.push({
        kind,
        type: z.type,
        host: relativeHost(z.fqdn, domain),
        fqdn: z.fqdn,
        expected: kind === 'dmarc' ? dmarcExpected(domain, policy) : z.value,
        priority: z.priority,
        status: 'pending',
      })
    }
    return records
  }

  // Count mailboxes on this domain — tenant users whose email is @domain.
  private async mailboxCount(tenant: TenantRef, domain: string): Promise<number> {
    return this.userModel
      .countDocuments({
        tenantIds: tenant._id,
        email: { $regex: `@${escapeRegex(domain)}$`, $options: 'i' },
      })
      .exec()
  }

  private async toView(doc: DomainDocument, tenant: TenantRef): Promise<DomainView> {
    const mailboxes = await this.mailboxCount(tenant, doc.domain)
    return {
      id: String(doc._id),
      domain: doc.domain,
      isPrimary: doc.isPrimary,
      status: doc.status,
      ownershipVerified: doc.ownershipVerified,
      verificationToken: doc.verificationToken,
      dmarcPolicy: doc.dmarcPolicy,
      stalwartProvisioned: doc.stalwartProvisioned,
      stalwartError: doc.stalwartError,
      mailboxes,
      checks: {
        ownership: aggregateTone(doc.records, 'ownership'),
        mx: aggregateTone(doc.records, 'mx'),
        spf: aggregateTone(doc.records, 'spf'),
        dkim: aggregateTone(doc.records, 'dkim'),
        dmarc: aggregateTone(doc.records, 'dmarc'),
      },
      records: doc.records.map((r) => ({
        kind: r.kind,
        type: r.type,
        host: r.host,
        fqdn: r.fqdn,
        expected: r.expected,
        priority: r.priority,
        observed: r.observed,
        status: r.status,
      })),
      lastCheckedAt: doc.lastCheckedAt?.toISOString(),
    }
  }
}

// ── view types ─────────────────────────────────────────────────────────────

export interface DomainRecordView {
  kind: RecordKind
  type: string
  host: string
  fqdn: string
  expected: string
  priority?: number
  observed?: string
  status: RecordStatus
}

export interface DomainView {
  id: string
  domain: string
  isPrimary: boolean
  status: DomainStatus
  ownershipVerified: boolean
  verificationToken: string
  dmarcPolicy: DmarcPolicy
  stalwartProvisioned: boolean
  stalwartError?: string
  mailboxes: number
  checks: Record<'ownership' | 'mx' | 'spf' | 'dkim' | 'dmarc', RecordStatus>
  records: DomainRecordView[]
  lastCheckedAt?: string
}

// ── pure helpers ─────────────────────────────────────────────────────────────

function ownershipRecord(domain: string, token: string): DomainRecord {
  return {
    kind: 'ownership',
    type: 'TXT',
    host: '_dezky-verify',
    fqdn: `_dezky-verify.${domain}`,
    expected: token,
    status: 'pending',
  }
}

// Classify a Stalwart zone record into one of our verifiable kinds, or null for
// the records we don't surface as status slots (SRV/MTA-STS/autoconfig/…).
function classify(z: StalwartZoneRecord, domain: string): RecordKind | null {
  if (z.type === 'MX' && z.fqdn === domain) return 'mx'
  if (z.type === 'TXT' && z.fqdn === domain && /^v=spf1\b/i.test(z.value)) return 'spf'
  if (z.type === 'TXT' && z.fqdn.endsWith(`._domainkey.${domain}`)) return 'dkim'
  if (z.type === 'TXT' && z.fqdn === `_dmarc.${domain}` && /^v=DMARC1\b/i.test(z.value)) return 'dmarc'
  return null
}

// Relative host for display ('@' at apex, otherwise the label prefix).
function relativeHost(fqdn: string, domain: string): string {
  if (fqdn === domain) return '@'
  return fqdn.endsWith(`.${domain}`) ? fqdn.slice(0, -(domain.length + 1)) : fqdn
}

function dmarcExpected(domain: string, policy: DmarcPolicy): string {
  return `v=DMARC1; p=${policy}; rua=mailto:postmaster@${domain}`
}

// Aggregate per-kind tone: a kind is only 'ok' if ALL its records are ok (DKIM
// has two — ed25519 + RSA — both must be published). Worst tone wins, with a
// kind that has no records yet reading as 'pending'.
function aggregateTone(records: DomainRecord[], kind: RecordKind): RecordStatus {
  const mine = records.filter((r) => r.kind === kind)
  if (!mine.length) return 'pending'
  const order: RecordStatus[] = ['bad', 'warn', 'pending', 'ok']
  return mine
    .map((r) => r.status)
    .sort((a, b) => order.indexOf(a) - order.indexOf(b))[0]
}

function escapeRegex(s: string): string {
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}

// Human summary of what still links to a domain, e.g. "2 mailboxes and 1
// mailing list". Groups Stalwart's linked-object types and pluralises.
function summarizeLinks(links: StalwartLinkedObject[]): string {
  const label: Record<string, string> = {
    MailingList: 'mailing list',
    Account: 'mailbox',
    Principal: 'mailbox',
    Individual: 'mailbox',
    Group: 'group',
    List: 'list',
  }
  const counts = new Map<string, number>()
  for (const l of links) {
    const name = label[l.object] ?? l.object.toLowerCase()
    counts.set(name, (counts.get(name) ?? 0) + 1)
  }
  return [...counts.entries()]
    .map(([name, n]) => `${n} ${name}${n === 1 ? '' : 's'}`)
    .join(' and ')
}

// Stalwart JMAP errors arrive as a JSON blob embedded in the message, e.g.
// `…: {"type":"invalidPatch","description":"Invalid domain name",…}`. Surface
// just the human description when present, otherwise the raw message.
function cleanStalwartError(msg: string): string {
  return msg.match(/"description":"([^"]+)"/)?.[1] ?? msg
}
