import { Injectable, Logger } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'

// Stalwart v0.16 removed the REST management API (`/api/principal`, `/api/dkim`,
// `/api/dns/records` all 404). All admin operations now go through the JMAP
// endpoint at `${base}/jmap` using the Stalwart-specific `urn:stalwart:jmap`
// capability. Domains are `x:Domain` objects (NOT JMAP principals — `type:
// "domain"` is rejected); DKIM signatures are `x:DkimSignature` objects.
//
// Auth is HTTP Basic with the fallback admin from config.toml
// (admin / STALWART_ADMIN_PASSWORD). Calls go to the internal docker hostname
// `http://stalwart:8080` — NOT the public `https://mail.dezky.local`, which is
// Traefik + a mkcert cert that Node's fetch rejects.
//
// Creating a domain auto-generates its DKIM keys (dkimManagement defaults to
// "Automatic") and leaves DNS as "Manual" (the customer publishes the records).
// The full set of records to publish comes back on the domain's server-set
// `dnsZoneFile` field as BIND zone text — we parse it rather than computing the
// records ourselves, so the DKIM public keys etc. are always authoritative.

const JMAP_USING = ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap']

// A single DNS record extracted from a domain's `dnsZoneFile`. `fqdn` carries
// the trailing-dot-stripped name (e.g. `_dmarc.acme.dk`); `value` is the
// unquoted record data (TXT strings joined). `priority` is set for MX only.
export interface StalwartZoneRecord {
  fqdn: string
  type: string // 'MX' | 'TXT' | 'CNAME' | 'SRV' | …
  value: string
  priority?: number
}

// What the WebAdmin's x:Domain/get returns (only the fields we read).
interface StalwartDomain {
  id: string
  name: string
  dnsZoneFile?: string
}

type JmapMethodCall = [string, Record<string, unknown>, string]
type JmapMethodResponse = [string, Record<string, any>, string]

@Injectable()
export class StalwartClient {
  private readonly logger = new Logger(StalwartClient.name)
  private readonly base: string
  private readonly authHeader: string

  // Live provisioning is gated like billing's `stripeLive`: off by default so
  // dev without a reachable Stalwart (or without the flag) records 'skipped'
  // instead of erroring. Requires the flag AND an admin password.
  readonly enabled: boolean

  constructor(config: ConfigService) {
    this.base = config.getOrThrow<string>('STALWART_API_URL')
    const user = config.get<string>('STALWART_ADMIN_USER') || 'admin'
    const password = config.get<string>('STALWART_ADMIN_PASSWORD') || ''
    this.authHeader = `Basic ${Buffer.from(`${user}:${password}`).toString('base64')}`
    this.enabled =
      config.get<string>('STALWART_PROVISIONING_ENABLED') === 'true' && !!password
    if (!this.enabled) {
      this.logger.warn(
        'Stalwart provisioning disabled (STALWART_PROVISIONING_ENABLED != true or no admin password) — domain steps record as skipped.',
      )
    }
  }

  // Static config present and provisioning turned on. DomainsService checks this
  // to decide between a real call and the honest "skipped" state.
  get configured(): boolean {
    return this.enabled
  }

  // Run one or more JMAP method calls. Returns the methodResponses array. A
  // request-level error (e.g. malformed envelope) comes back as a flat
  // {type,status,detail} object with no methodResponses — surfaced as a throw.
  private async jmap(methodCalls: JmapMethodCall[]): Promise<JmapMethodResponse[]> {
    const res = await fetch(`${this.base}/jmap`, {
      method: 'POST',
      headers: {
        Authorization: this.authHeader,
        'Content-Type': 'application/json',
        Accept: 'application/json',
      },
      body: JSON.stringify({ using: JMAP_USING, methodCalls }),
    })
    const text = await res.text()
    if (!res.ok) {
      throw new Error(`Stalwart JMAP → ${res.status}: ${text.slice(0, 300)}`)
    }
    let json: { methodResponses?: JmapMethodResponse[] }
    try {
      json = JSON.parse(text)
    } catch {
      throw new Error(`Stalwart JMAP returned non-JSON: ${text.slice(0, 200)}`)
    }
    if (!json.methodResponses) {
      throw new Error(`Stalwart JMAP error: ${text.slice(0, 300)}`)
    }
    return json.methodResponses
  }

  // The Stalwart domain id for a name, or undefined if it doesn't exist.
  private async findDomainId(name: string): Promise<string | undefined> {
    const resp = await this.jmap([['x:Domain/query', { filter: { name } }, '0']])
    const ids = resp[0]?.[1]?.ids as string[] | undefined
    return ids?.[0]
  }

  // Idempotent: returns the existing domain id if present, otherwise creates the
  // domain (which auto-generates its DKIM keys) and returns the new id.
  async ensureDomain(name: string, _description?: string): Promise<{ id: string }> {
    const existing = await this.findDomainId(name)
    if (existing) {
      this.logger.log(`Stalwart domain "${name}" already exists (id=${existing})`)
      return { id: existing }
    }
    const resp = await this.jmap([['x:Domain/set', { create: { d1: { name } } }, '0']])
    const result = resp[0][1]
    const created = result.created?.d1
    if (!created?.id) {
      const err = result.notCreated?.d1
      throw new Error(`Stalwart domain create failed for "${name}": ${JSON.stringify(err)}`)
    }
    this.logger.log(`Created Stalwart domain "${name}" (id=${created.id})`)
    return { id: created.id }
  }

  // Fetch the domain's authoritative DNS records (parsed from its dnsZoneFile).
  // Returns [] if the domain isn't found. Used to populate the expected records
  // the customer must publish, including the live DKIM public keys.
  async getZoneRecords(name: string): Promise<StalwartZoneRecord[]> {
    const resp = await this.jmap([
      ['x:Domain/query', { filter: { name } }, '0'],
      [
        'x:Domain/get',
        { '#ids': { resultOf: '0', name: 'x:Domain/query', path: '/ids' } },
        '1',
      ],
    ])
    const domain = (resp[1]?.[1]?.list as StalwartDomain[] | undefined)?.[0]
    if (!domain?.dnsZoneFile) return []
    return parseZoneFile(domain.dnsZoneFile)
  }

  // Delete a domain. Stalwart enforces referential integrity: a domain can't be
  // destroyed while anything links to it, reported as notDestroyed/objectIsLinked
  // with the linked object ids. The auto-generated DKIM signatures always link,
  // so we remove those and retry — but any OTHER link (accounts, aliases, mailing
  // lists) is real user data, so we refuse with a DomainInUseError and let the
  // caller surface it. 404-equivalent (no such domain) is a silent no-op.
  async deleteDomain(name: string): Promise<void> {
    const id = await this.findDomainId(name)
    if (!id) return
    const resp = await this.jmap([['x:Domain/set', { destroy: [id] }, '0']])
    const result = resp[0][1]
    if ((result.destroyed as string[] | undefined)?.includes(id)) {
      this.logger.log(`Deleted Stalwart domain "${name}" (id=${id})`)
      return
    }
    const notDestroyed = result.notDestroyed?.[id]
    if (notDestroyed?.type === 'objectIsLinked') {
      const links: StalwartLinkedObject[] = notDestroyed.linkedObjects ?? []
      const blockers = links.filter((o) => o.object !== 'DkimSignature')
      if (blockers.length) {
        // The (failed) destroy above mutated nothing, so the domain is untouched.
        throw new DomainInUseError(name, blockers)
      }
      const dkimIds = links.filter((o) => o.object === 'DkimSignature').map((o) => o.id)
      await this.jmap([
        ['x:DkimSignature/set', { destroy: dkimIds }, '0'],
        ['x:Domain/set', { destroy: [id] }, '1'],
      ])
      this.logger.log(
        `Deleted Stalwart domain "${name}" (id=${id}) after removing ${dkimIds.length} DKIM signature(s)`,
      )
      return
    }
    throw new Error(
      `Stalwart domain delete failed for "${name}": ${JSON.stringify(notDestroyed)}`,
    )
  }

  // Create a user mailbox on a domain. The account's address is name@domain
  // (Stalwart forms it from the domain), and the password lets the user sign in
  // to webmail / IMAP / SMTP. `credentials` is an index-keyed MAP (not an array)
  // — a quirk of Stalwart's patch format. Returns the new account id.
  async createMailbox(input: {
    domainId: string
    localPart: string
    fullName: string
    password: string
  }): Promise<{ id: string }> {
    const resp = await this.jmap([
      [
        'x:Account/set',
        {
          create: {
            u1: {
              '@type': 'User',
              name: input.localPart,
              domainId: input.domainId,
              description: input.fullName,
              credentials: { '0': { '@type': 'Password', secret: input.password } },
            },
          },
        },
        '0',
      ],
    ])
    const result = resp[0][1]
    const created = result.created?.u1
    if (!created?.id) {
      const err = result.notCreated?.u1
      throw new Error(`Stalwart mailbox create failed for "${input.localPart}": ${JSON.stringify(err)}`)
    }
    this.logger.log(`Created Stalwart mailbox "${input.localPart}" (id=${created.id})`)
    return { id: created.id }
  }

  // Freeze / unfreeze a mailbox. Suspending disables the authenticate / send /
  // receive permissions (so they can't sign in, send, or receive), while keeping
  // the account + password intact — resuming restores the inherited defaults, so
  // the user's original credential works again.
  async setMailboxSuspended(accountId: string, suspended: boolean): Promise<void> {
    const permissions = suspended
      ? {
          '@type': 'Merge',
          disabledPermissions: { authenticate: true, emailReceive: true, emailSend: true },
        }
      : { '@type': 'Inherit' }
    const resp = await this.jmap([
      ['x:Account/set', { update: { [accountId]: { permissions } } }, '0'],
    ])
    const notUpdated = resp[0][1].notUpdated?.[accountId]
    if (notUpdated) {
      throw new Error(
        `Stalwart mailbox ${suspended ? 'suspend' : 'resume'} failed (id=${accountId}): ${JSON.stringify(notUpdated)}`,
      )
    }
  }

  // Update a mailbox's display name (stored as the account `description`, which
  // is what we set as `fullName` at create time). Best-effort cosmetic sync so
  // the mailbox's display name tracks the directory profile.
  async setMailboxName(accountId: string, fullName: string): Promise<void> {
    const resp = await this.jmap([
      ['x:Account/set', { update: { [accountId]: { description: fullName } } }, '0'],
    ])
    const notUpdated = resp[0][1].notUpdated?.[accountId]
    if (notUpdated) {
      throw new Error(`Stalwart mailbox name update failed (id=${accountId}): ${JSON.stringify(notUpdated)}`)
    }
  }

  // Set a new mailbox password (replaces the primary credential).
  async setMailboxPassword(accountId: string, password: string): Promise<void> {
    const resp = await this.jmap([
      [
        'x:Account/set',
        { update: { [accountId]: { credentials: { '0': { '@type': 'Password', secret: password } } } } },
        '0',
      ],
    ])
    const notUpdated = resp[0][1].notUpdated?.[accountId]
    if (notUpdated) {
      throw new Error(`Stalwart mailbox password update failed (id=${accountId}): ${JSON.stringify(notUpdated)}`)
    }
  }

  // Delete a mailbox by account id. Missing id is a silent no-op.
  async deleteMailbox(accountId: string): Promise<void> {
    const resp = await this.jmap([['x:Account/set', { destroy: [accountId] }, '0']])
    const result = resp[0][1]
    if ((result.destroyed as string[] | undefined)?.includes(accountId)) {
      this.logger.log(`Deleted Stalwart mailbox (id=${accountId})`)
      return
    }
    const notDestroyed = result.notDestroyed?.[accountId]
    if (notDestroyed && notDestroyed.type !== 'notFound') {
      throw new Error(`Stalwart mailbox delete failed (id=${accountId}): ${JSON.stringify(notDestroyed)}`)
    }
  }

  // The Stalwart account id whose primary address matches `email`, or undefined.
  // Stalwart's account query has no email filter, so we list + match in memory.
  async findAccountIdByEmail(email: string): Promise<string | undefined> {
    const target = email.trim().toLowerCase()
    const accounts = await this.listAccountsWithAliases()
    return accounts.find((a) => a.emailAddress?.toLowerCase() === target)?.id
  }

  // ── App passwords (per-account credentials for on-behalf calendar access) ────
  // Used by dezky Scheduling to obtain a scoped credential for a host without a
  // user-facing "connect calendar" step. Admin mints on-behalf by passing the
  // target account id; the returned `secret` is shown once and must be stored
  // encrypted by the caller (it is never logged here).

  async createAppPassword(accountId: string, description: string): Promise<{ id: string; secret: string }> {
    const resp = await this.jmap([
      [
        'x:AppPassword/set',
        {
          accountId,
          create: { ap: { description, permissions: { '@type': 'Inherit' }, allowedIps: {} } },
        },
        '0',
      ],
    ])
    const created = resp[0][1].created?.ap
    if (!created?.id || !created?.secret) {
      throw new Error(
        `Stalwart app-password create failed for account ${accountId}: ${JSON.stringify(resp[0][1].notCreated)}`,
      )
    }
    return { id: created.id, secret: created.secret }
  }

  async deleteAppPassword(accountId: string, id: string): Promise<void> {
    const resp = await this.jmap([['x:AppPassword/set', { accountId, destroy: [id] }, '0']])
    const result = resp[0][1]
    if ((result.destroyed as string[] | undefined)?.includes(id)) return
    const notDestroyed = result.notDestroyed?.[id]
    if (notDestroyed && notDestroyed.type !== 'notFound') {
      throw new Error(`Stalwart app-password delete failed (id=${id}): ${JSON.stringify(notDestroyed)}`)
    }
  }

  // ── Aliases (extra addresses that route to a mailbox) ──────────────────────

  // Every mailbox + its aliases. Stalwart's account query has no domain filter,
  // so the caller narrows by domainId. `aliases` is an index-keyed map on the
  // wire; we hand back a plain array.
  async listAccountsWithAliases(): Promise<StalwartAccountAliases[]> {
    const resp = await this.jmap([
      ['x:Account/query', { filter: {} }, '0'],
      [
        'x:Account/get',
        {
          '#ids': { resultOf: '0', name: 'x:Account/query', path: '/ids' },
          properties: ['emailAddress', 'aliases'],
        },
        '1',
      ],
    ])
    const list = (resp[1]?.[1]?.list ?? []) as Array<{
      id: string
      emailAddress: string
      aliases?: Record<string, StalwartAlias>
    }>
    return list.map((a) => ({
      id: a.id,
      emailAddress: a.emailAddress,
      aliases: aliasMapToArray(a.aliases),
    }))
  }

  // Add an alias (localpart@domain) that delivers to this mailbox. Idempotent.
  async addAlias(accountId: string, name: string, domainId: string): Promise<void> {
    const aliases = await this.getAccountAliases(accountId)
    if (aliases.some((a) => a.name === name && a.domainId === domainId)) return
    aliases.push({ name, domainId, enabled: true })
    await this.writeAliases(accountId, aliases)
  }

  async removeAlias(accountId: string, name: string, domainId: string): Promise<void> {
    const aliases = (await this.getAccountAliases(accountId)).filter(
      (a) => !(a.name === name && a.domainId === domainId),
    )
    await this.writeAliases(accountId, aliases)
  }

  private async getAccountAliases(accountId: string): Promise<StalwartAlias[]> {
    const resp = await this.jmap([
      ['x:Account/get', { ids: [accountId], properties: ['aliases'] }, '0'],
    ])
    return aliasMapToArray(resp[0]?.[1]?.list?.[0]?.aliases)
  }

  // Stalwart replaces the whole `aliases` field on update, so we always write the
  // full set back as an index-keyed map.
  private async writeAliases(accountId: string, aliases: StalwartAlias[]): Promise<void> {
    const map: Record<string, StalwartAlias> = {}
    aliases.forEach((a, i) => {
      map[String(i)] = { name: a.name, domainId: a.domainId, enabled: a.enabled }
    })
    const resp = await this.jmap([
      ['x:Account/set', { update: { [accountId]: { aliases: map } } }, '0'],
    ])
    const notUpdated = resp[0][1].notUpdated?.[accountId]
    if (notUpdated) {
      throw new Error(`Stalwart alias update failed (id=${accountId}): ${JSON.stringify(notUpdated)}`)
    }
  }

  // ── Mailing lists (one address fans out to many recipients) ────────────────

  async listMailingLists(): Promise<StalwartMailingList[]> {
    const resp = await this.jmap([
      ['x:MailingList/query', { filter: {} }, '0'],
      [
        'x:MailingList/get',
        {
          '#ids': { resultOf: '0', name: 'x:MailingList/query', path: '/ids' },
          properties: ['name', 'emailAddress', 'domainId', 'recipients', 'description'],
        },
        '1',
      ],
    ])
    const list = (resp[1]?.[1]?.list ?? []) as Array<{
      id: string
      name: string
      emailAddress: string
      domainId: string
      recipients?: Record<string, boolean>
      description?: string
    }>
    return list.map((l) => ({
      id: l.id,
      name: l.name,
      emailAddress: l.emailAddress,
      domainId: l.domainId,
      recipients: l.recipients ? Object.keys(l.recipients) : [],
      description: l.description,
    }))
  }

  async createMailingList(input: {
    domainId: string
    name: string
    recipients: string[]
    description?: string
  }): Promise<{ id: string }> {
    const resp = await this.jmap([
      [
        'x:MailingList/set',
        {
          create: {
            l1: {
              name: input.name,
              domainId: input.domainId,
              description: input.description ?? null,
              recipients: recipientsToMap(input.recipients),
            },
          },
        },
        '0',
      ],
    ])
    const created = resp[0][1].created?.l1
    if (!created?.id) {
      throw new Error(`Stalwart mailing list create failed: ${JSON.stringify(resp[0][1].notCreated)}`)
    }
    return { id: created.id }
  }

  async updateMailingListRecipients(id: string, recipients: string[]): Promise<void> {
    const resp = await this.jmap([
      ['x:MailingList/set', { update: { [id]: { recipients: recipientsToMap(recipients) } } }, '0'],
    ])
    const notUpdated = resp[0][1].notUpdated?.[id]
    if (notUpdated) {
      throw new Error(`Stalwart mailing list update failed (id=${id}): ${JSON.stringify(notUpdated)}`)
    }
  }

  async deleteMailingList(id: string): Promise<void> {
    const resp = await this.jmap([['x:MailingList/set', { destroy: [id] }, '0']])
    const result = resp[0][1]
    if ((result.destroyed as string[] | undefined)?.includes(id)) return
    const notDestroyed = result.notDestroyed?.[id]
    if (notDestroyed && notDestroyed.type !== 'notFound') {
      throw new Error(`Stalwart mailing list delete failed (id=${id}): ${JSON.stringify(notDestroyed)}`)
    }
  }
}

export interface StalwartLinkedObject {
  object: string // 'DkimSignature' | 'MailingList' | 'Account' | …
  id: string
}

export interface StalwartAlias {
  name: string
  domainId: string
  enabled: boolean
}

export interface StalwartAccountAliases {
  id: string
  emailAddress: string
  aliases: StalwartAlias[]
}

export interface StalwartMailingList {
  id: string
  name: string
  emailAddress: string
  domainId: string
  recipients: string[]
  description?: string
}

function aliasMapToArray(map?: Record<string, StalwartAlias>): StalwartAlias[] {
  if (!map) return []
  return Object.values(map).map((a) => ({
    name: a.name,
    domainId: a.domainId,
    enabled: a.enabled !== false,
  }))
}

function recipientsToMap(recipients: string[]): Record<string, boolean> {
  const map: Record<string, boolean> = {}
  for (const r of recipients) map[r.trim().toLowerCase()] = true
  return map
}

// Thrown when a domain still has accounts, aliases or mailing lists in Stalwart
// and therefore can't be removed. `linkedObjects` excludes the auto-generated
// DKIM signatures (which we remove automatically).
export class DomainInUseError extends Error {
  constructor(
    public readonly domain: string,
    public readonly linkedObjects: StalwartLinkedObject[],
  ) {
    super(`Domain "${domain}" is still in use by ${linkedObjects.length} mail object(s)`)
    this.name = 'DomainInUseError'
  }
}

// ── BIND zone-file parsing ───────────────────────────────────────────────────
// Stalwart's dnsZoneFile is BIND zone text. RSA DKIM records span multiple lines
// with parenthesised, concatenated quoted strings, e.g.:
//   sel._domainkey.acme.dk. IN TXT (
//       "v=DKIM1; k=rsa; ... "
//       "...more base64..."
//   )
// We first fold parenthesised groups onto one logical line, then tokenise each
// line as `<name> IN <type> <rest>`. Exported for unit testing.
export function parseZoneFile(zone: string): StalwartZoneRecord[] {
  const records: StalwartZoneRecord[] = []
  for (const line of foldZoneLines(zone)) {
    const trimmed = line.trim()
    if (!trimmed || trimmed.startsWith(';')) continue
    // <name> IN <TYPE> <rest…>
    const m = trimmed.match(/^(\S+)\s+IN\s+(\S+)\s+(.+)$/)
    if (!m) continue
    const [, rawName, type, rest] = m
    const fqdn = rawName.replace(/\.$/, '')
    if (type === 'TXT') {
      records.push({ fqdn, type, value: unquoteTxt(rest) })
    } else if (type === 'MX') {
      const mx = rest.match(/^(\d+)\s+(\S+)$/)
      if (mx) {
        records.push({ fqdn, type, priority: Number(mx[1]), value: mx[2].replace(/\.$/, '') })
      }
    } else {
      records.push({ fqdn, type, value: rest.trim().replace(/\.$/, '') })
    }
  }
  return records
}

// Collapse parenthesised multi-line records into single logical lines.
function foldZoneLines(zone: string): string[] {
  const out: string[] = []
  let buffer = ''
  let depth = 0
  for (const raw of zone.split('\n')) {
    const line = raw
    for (const ch of line) {
      if (ch === '(') depth++
      else if (ch === ')') depth = Math.max(0, depth - 1)
    }
    buffer += (buffer ? ' ' : '') + line.replace(/[()]/g, ' ').trim()
    if (depth === 0) {
      out.push(buffer)
      buffer = ''
    }
  }
  if (buffer) out.push(buffer)
  return out
}

// Join the quoted character-strings of a TXT record into its logical value.
// `"v=DKIM1; ..." "more"` → `v=DKIM1; ...more`.
function unquoteTxt(rest: string): string {
  const parts = rest.match(/"((?:[^"\\]|\\.)*)"/g)
  if (!parts) return rest.trim()
  return parts.map((p) => p.slice(1, -1)).join('')
}
