import { Injectable, Logger } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import { Model } from 'mongoose'
import { AuthentikClient } from '../integrations/authentik.client.js'
import { OcisClient } from '../integrations/ocis.client.js'
import { StalwartClient } from '../integrations/stalwart.client.js'
import {
  IntegrationState,
  Tenant,
  TenantDocument,
} from '../schemas/tenant.schema.js'

// Orchestrates provisioning across Authentik / Stalwart / OCIS. Each step is
// independent — one failure doesn't roll back the others — and the per-step
// status is recorded on the tenant document so the operation is idempotent
// when retried.
@Injectable()
export class ProvisioningService {
  private readonly logger = new Logger(ProvisioningService.name)

  constructor(
    @InjectModel(Tenant.name) private readonly tenantModel: Model<TenantDocument>,
    private readonly authentik: AuthentikClient,
    private readonly stalwart: StalwartClient,
    private readonly ocis: OcisClient,
  ) {}

  // Runs all integrations and writes back per-step state. Returns the refreshed
  // tenant doc so the controller can return it to the caller.
  async reconcile(tenant: TenantDocument): Promise<TenantDocument> {
    this.logger.log(`Reconciling tenant "${tenant.slug}"`)

    await this.runStep(tenant, 'authentik', async () => {
      const group = await this.authentik.ensureGroup(tenant.slug, { tenantId: tenant.id })
      tenant.authentikGroupId = String(group.pk)
    })

    // Stalwart provisioning is real when STALWART_PROVISIONING_ENABLED is on;
    // otherwise we record the honest 'skipped' state. ensureDomain is idempotent
    // and auto-generates the domain's DKIM keys.
    await this.runStep(tenant, 'stalwart', async () => {
      const domain = this.domainFor(tenant.slug)
      if (!this.stalwart.configured) return 'skipped'
      await this.stalwart.ensureDomain(domain, `Mail domain for tenant ${tenant.slug}`)
      tenant.stalwartDomain = domain
      // falls through to 'ok' — a real upstream call succeeded
    })

    await this.runStep(tenant, 'ocis', async () => {
      const space = await this.ocis.ensureSpace(tenant.slug)
      tenant.ocisSpaceId = space.id
      return 'skipped'
    })

    // If every required integration is either 'ok' or 'skipped' (not 'error' /
    // 'pending'), activate the tenant. Skipped steps don't block activation —
    // they just won't have their resources wired up yet.
    const keys = ['authentik', 'stalwart', 'ocis'] as const
    const allSettled = keys.every((k) => {
      const s = tenant.provisioningStatus[k]
      return s === 'ok' || s === 'skipped'
    })
    if (allSettled && tenant.status === 'pending') {
      tenant.status = 'active'
    }

    // Mongoose doesn't auto-detect mutations inside nested subdocuments — flag
    // these paths as modified so the save() actually persists our changes.
    tenant.markModified('provisioningStatus')
    tenant.markModified('provisioningErrors')

    await tenant.save()
    return tenant
  }

  // Step returns its terminal state explicitly. Returning void means "this step
  // ran a real upstream call successfully" — that's mapped to 'ok'. Returning a
  // specific state ('skipped', etc.) lets stub integrations be honest about
  // not actually doing the work.
  private async runStep(
    tenant: TenantDocument,
    key: 'authentik' | 'stalwart' | 'ocis',
    work: () => Promise<IntegrationState | void>,
  ): Promise<void> {
    try {
      const result = await work()
      tenant.provisioningStatus[key] = result ?? 'ok'
      if (tenant.provisioningErrors[key]) delete tenant.provisioningErrors[key]
    } catch (err) {
      const msg = (err as Error).message
      tenant.provisioningStatus[key] = 'error'
      tenant.provisioningErrors[key] = msg
      this.logger.error(`Tenant "${tenant.slug}" — ${key} step failed: ${msg}`)
    }
  }

  // Maps tenant slug → mail domain. Production should use a real registered
  // domain (e.g. acme.dezky.com); locally we use the .local hierarchy.
  private domainFor(slug: string): string {
    return `${slug}.dezky.local`
  }

  // Best-effort cleanup. Called when a tenant is hard-deleted (not soft-deleted).
  async tearDown(tenant: TenantDocument): Promise<void> {
    if (tenant.authentikGroupId) {
      await this.authentik.deleteGroup(tenant.authentikGroupId).catch((err) => {
        this.logger.error(`Failed to delete Authentik group: ${(err as Error).message}`)
      })
    }
    if (tenant.stalwartDomain) {
      await this.stalwart.deleteDomain(tenant.stalwartDomain).catch((err) => {
        this.logger.error(`Failed to delete Stalwart domain: ${(err as Error).message}`)
      })
    }
    if (tenant.ocisSpaceId) {
      await this.ocis.deleteSpace(tenant.ocisSpaceId).catch((err) => {
        this.logger.error(`Failed to delete OCIS space: ${(err as Error).message}`)
      })
    }
  }
}
