import { Injectable, Logger } from '@nestjs/common'
import { Resolver } from 'node:dns/promises'
import type { DomainRecord, RecordStatus } from '../schemas/domain.schema.js'

export interface CheckResult {
  observed?: string
  status: RecordStatus
}

// Verifies a domain's DNS records against what Stalwart expects. Uses a
// dedicated resolver pointed at public DNS (Cloudflare / Google) rather than the
// container's system resolver, so results reflect real-world propagation and
// aren't skewed by Docker's internal resolver cache. The instance is private —
// we never touch the global `dns.setServers`, which would affect the rest of
// the app (Authentik/OCIS/Stripe fetches).
//
// Tone semantics mirror the customer-admin DNS_FIX copy on the Domains page:
//   ok   — record present and correct
//   warn — present but weak/secondary (e.g. SPF ~all, DMARC p=none)
//   bad  — missing or wrong
@Injectable()
export class DnsVerifierService {
  private readonly logger = new Logger(DnsVerifierService.name)
  private readonly resolver: Resolver

  constructor() {
    this.resolver = new Resolver({ timeout: 5000, tries: 2 })
    this.resolver.setServers(['1.1.1.1', '8.8.8.8'])
  }

  // Dispatch a single expected record to the right check. The DomainsService
  // calls this for every record on a (re)check and writes back observed+status.
  async check(record: DomainRecord, domain: string): Promise<CheckResult> {
    switch (record.kind) {
      case 'ownership':
        return this.checkOwnership(record.fqdn, record.expected)
      case 'mx':
        return this.checkMx(domain, record.expected)
      case 'spf':
        return this.checkSpf(domain, record.expected)
      case 'dkim':
        return this.checkDkim(record.fqdn, record.expected)
      case 'dmarc':
        return this.checkDmarc(domain)
      default:
        return { status: 'pending' }
    }
  }

  // Resolve TXT, joining each record's character-strings into one value.
  private async txt(fqdn: string): Promise<string[]> {
    try {
      const recs = await this.resolver.resolveTxt(fqdn)
      return recs.map((chunks) => chunks.join(''))
    } catch {
      // NXDOMAIN / NODATA / SERVFAIL → treat as "no record".
      return []
    }
  }

  // Ownership: the `_dezky-verify.<domain>` TXT must contain our token verbatim.
  private async checkOwnership(fqdn: string, token: string): Promise<CheckResult> {
    const records = await this.txt(fqdn)
    const hit = records.find((v) => v.includes(token))
    return hit ? { observed: hit, status: 'ok' } : { status: 'bad' }
  }

  // MX: at least one exchange must point at the expected host. A record that
  // exists but doesn't match is a secondary/foreign MX → warn (allowed for
  // failover, but the customer should know).
  private async checkMx(domain: string, expectedHost: string): Promise<CheckResult> {
    let mx: { exchange: string; priority: number }[]
    try {
      mx = await this.resolver.resolveMx(domain)
    } catch {
      return { status: 'bad' }
    }
    if (!mx.length) return { status: 'bad' }
    const norm = (h: string) => h.replace(/\.$/, '').toLowerCase()
    const want = norm(expectedHost)
    const observed = mx
      .sort((a, b) => a.priority - b.priority)
      .map((r) => `${r.priority} ${norm(r.exchange)}`)
      .join(', ')
    const match = mx.some((r) => norm(r.exchange) === want)
    return { observed, status: match ? 'ok' : 'warn' }
  }

  // SPF: find the v=spf1 record at the apex. Correct = authorises our sender
  // (the expected mechanism — `mx` or an `include:` — is present) AND hardfails
  // with `-all`. A softfail (`~all`/`?all`) or a missing mechanism is warn;
  // no SPF record at all is bad.
  private async checkSpf(domain: string, expected: string): Promise<CheckResult> {
    const records = await this.txt(domain)
    const spf = records.find((v) => /^v=spf1\b/i.test(v.trim()))
    if (!spf) return { status: 'bad' }
    const mechanism = expected.match(/include:\S+/i)?.[0] ?? 'mx'
    const hasMechanism = new RegExp(`(^|\\s)${escapeRegex(mechanism)}(\\s|$)`, 'i').test(spf)
    const hardFail = /[\s]-all\b/.test(spf) || /(^|\s)-all$/.test(spf.trim())
    if (hasMechanism && hardFail) return { observed: spf, status: 'ok' }
    return { observed: spf, status: 'warn' }
  }

  // DKIM: the selector TXT must carry the same public key Stalwart generated.
  // We compare the `p=` value. Present-but-different = warn; absent = bad.
  private async checkDkim(fqdn: string, expected: string): Promise<CheckResult> {
    const records = await this.txt(fqdn)
    const dkim = records.find((v) => /(^|;)\s*v=DKIM1/i.test(v) || /k=(rsa|ed25519)/i.test(v))
    if (!dkim) return { status: 'bad' }
    const expectedKey = expected.match(/p=([A-Za-z0-9+/=]+)/)?.[1]
    const observedKey = dkim.match(/p=([A-Za-z0-9+/=]+)/)?.[1]
    if (expectedKey && observedKey && expectedKey === observedKey) {
      return { observed: dkim, status: 'ok' }
    }
    return { observed: dkim, status: 'warn' }
  }

  // DMARC: a `_dmarc` TXT must exist. p=none is monitor-only (warn); any
  // stronger policy (quarantine/reject) is ok; no record is bad.
  private async checkDmarc(domain: string): Promise<CheckResult> {
    const records = await this.txt(`_dmarc.${domain}`)
    const dmarc = records.find((v) => /^v=DMARC1\b/i.test(v.trim()))
    if (!dmarc) return { status: 'bad' }
    const policy = dmarc.match(/(^|;)\s*p=(\w+)/i)?.[2]?.toLowerCase()
    return { observed: dmarc, status: policy === 'none' ? 'warn' : 'ok' }
  }
}

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