import {
  BadRequestException,
  ConflictException,
  ForbiddenException,
  Injectable,
  Logger,
  NotFoundException,
} from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { InjectModel } from '@nestjs/mongoose'
import { Model, Types } from 'mongoose'
import type { AuditEventDocument } from '../schemas/audit-event.schema.js'
import { AuditService, type AuditActor } from '../audit/audit.service.js'
import { AuthentikClient } from '../integrations/authentik.client.js'
import { OcisClient } from '../integrations/ocis.client.js'
import { StalwartClient } from '../integrations/stalwart.client.js'
import { Domain, DomainDocument } from '../schemas/domain.schema.js'
import { Partner, PartnerDocument } from '../schemas/partner.schema.js'
import { Price, PriceDocument } from '../schemas/price.schema.js'
import { Subscription, SubscriptionDocument } from '../schemas/subscription.schema.js'
import { Tenant, TenantDocument } from '../schemas/tenant.schema.js'
import { User, UserDocument, roleForTenant } from '../schemas/user.schema.js'
import type { CreateUserDto } from './dto/create-user.dto.js'
import type { InviteOperatorDto } from './dto/invite-operator.dto.js'
import type { InvitePartnerUserDto } from './dto/invite-partner-user.dto.js'
import type { UpdateUserDto } from './dto/update-user.dto.js'

// Authentik group every partner-staff invite gets added to. We use ONE
// group across all partners (instead of `dezky-partner-{slug}` per partner)
// because partner scoping is enforced server-side via User.partnerId — the
// group claim just marks "this user is partner staff, route them to the
// partner portal." Simpler than reconciling groups on partner rename.
const PARTNER_STAFF_GROUP = 'dezky-partner-staff'

@Injectable()
export class UsersService {
  private readonly logger = new Logger(UsersService.name)
  private readonly platformAdminGroup: string
  // Cached after first successful lookup. The dezky-platform-admins group is
  // created once during Authentik bootstrap and never moves; no need to look
  // it up every invite.
  private platformAdminGroupId: string | null = null
  // Same caching for the partner-staff group. Created lazily on first
  // invitePartnerUser call via ensureGroup (idempotent).
  private partnerStaffGroupId: string | null = null

  constructor(
    @InjectModel(User.name) private readonly userModel: Model<UserDocument>,
    @InjectModel(Tenant.name) private readonly tenantModel: Model<TenantDocument>,
    @InjectModel(Partner.name) private readonly partnerModel: Model<PartnerDocument>,
    @InjectModel(Subscription.name) private readonly subModel: Model<SubscriptionDocument>,
    @InjectModel(Price.name) private readonly priceModel: Model<PriceDocument>,
    @InjectModel(Domain.name) private readonly domainModel: Model<DomainDocument>,
    private readonly audit: AuditService,
    private readonly authentik: AuthentikClient,
    private readonly stalwart: StalwartClient,
    private readonly ocis: OcisClient,
    config: ConfigService,
  ) {
    this.platformAdminGroup =
      config.get<string>('PLATFORM_ADMIN_BOOTSTRAP_GROUP') ?? 'dezky-platform-admins'
  }

  async create(dto: CreateUserDto): Promise<UserDocument> {
    const exists = await this.userModel.exists({ authentikSubjectId: dto.authentikSubjectId })
    if (exists) throw new ConflictException(`User ${dto.authentikSubjectId} already exists`)

    const tenantIds = await this.resolveTenantIds(dto.tenantSlugs ?? [])
    const role = dto.role ?? 'member'
    // Seed the per-tenant role for every tenant this user is created into, so
    // their effective role is explicit from the start rather than relying on
    // the global-role fallback. Omitted when there are no tenants.
    const tenantRoles = tenantIds.length
      ? Object.fromEntries(tenantIds.map((id) => [String(id), role]))
      : undefined
    return this.userModel.create({
      authentikSubjectId: dto.authentikSubjectId,
      email: dto.email,
      name: dto.name,
      role,
      tenantIds,
      tenantRoles,
    })
  }

  async findAllForTenants(tenantIds: Types.ObjectId[]): Promise<UserDocument[]> {
    return this.userModel.find({ tenantIds: { $in: tenantIds } }).sort({ createdAt: -1 }).exec()
  }

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

  async findOneBySubject(subject: string): Promise<UserDocument> {
    const user = await this.userModel.findOne({ authentikSubjectId: subject }).exec()
    if (!user) throw new NotFoundException(`User ${subject} not found`)
    return user
  }

  async update(subject: string, dto: UpdateUserDto): Promise<UserDocument> {
    const patch: Record<string, unknown> = { ...dto }
    if (dto.tenantSlugs !== undefined) {
      patch.tenantIds = await this.resolveTenantIds(dto.tenantSlugs)
      delete patch.tenantSlugs
    }
    const user = await this.userModel
      .findOneAndUpdate({ authentikSubjectId: subject }, patch, { new: true, runValidators: true })
      .exec()
    if (!user) throw new NotFoundException(`User ${subject} not found`)
    return user
  }

  async deactivate(subject: string, actor?: AuditActor): Promise<void> {
    const user = await this.userModel
      .findOneAndUpdate({ authentikSubjectId: subject }, { active: false }, { new: true })
      .exec()
    if (!user) throw new NotFoundException(`User ${subject} not found`)
    void this.audit.record(
      {
        action: 'user.deactivated',
        resourceType: 'user',
        resourceId: String(user._id),
        resourceName: user.email,
      },
      actor,
    )
  }

  // Called on every authenticated request from /users/me. The JWT's groups claim
  // is treated as a hint for first-time membership sync — the DB is the source of
  // truth for all subsequent authorization decisions.
  // What /users/me returns — the user doc plus the partner object when
  // User.partnerId is set. Frontends use the presence of `partner` to decide
  // whether to render the partner-admin surface vs. the end-user surface.
  async meWithPartner(payload: {
    subject: string
    email: string
    name: string
    tenantSlugs: string[]
    platformAdmin: boolean
  }): Promise<UserDocument & { partner?: { _id: string; slug: string; name: string; status: string } }> {
    const user = await this.upsertFromAuthentik(payload)
    if (!user.partnerId) {
      return user as UserDocument & { partner?: never }
    }
    const partner = await this.partnerModel
      .findById(user.partnerId, { slug: 1, name: 1, status: 1 })
      .exec()
    if (!partner) {
      // Partner was deleted out from under the user. Don't fail the whole
      // /me call — just omit the partner field; the frontend will treat
      // them as a regular end-user.
      return user as UserDocument & { partner?: never }
    }
    const userObj = user.toObject() as UserDocument & {
      partner?: { _id: string; slug: string; name: string; status: string }
    }
    userObj.partner = {
      _id: String(partner._id),
      slug: partner.slug,
      name: partner.name,
      status: partner.status,
    }
    return userObj
  }

  async upsertFromAuthentik(payload: {
    subject: string
    email: string
    name: string
    tenantSlugs: string[]
    platformAdmin: boolean
  }): Promise<UserDocument> {
    const tenantIds = await this.resolveTenantIds(payload.tenantSlugs)
    return this.userModel
      .findOneAndUpdate(
        { authentikSubjectId: payload.subject },
        {
          $set: {
            email: payload.email,
            name: payload.name,
            tenantIds,
            platformAdmin: payload.platformAdmin,
            lastLoginAt: new Date(),
          },
          $setOnInsert: { role: 'member', active: true },
        },
        { new: true, upsert: true, runValidators: true },
      )
      .exec()
  }

  private async resolveTenantIds(slugs: string[]): Promise<Types.ObjectId[]> {
    if (slugs.length === 0) return []
    const tenants = await this.tenantModel.find({ slug: { $in: slugs } }, { _id: 1 }).exec()
    return tenants.map((t) => t._id)
  }

  // Invite a new platform admin. Creates the user in Authentik, adds them to
  // the dezky-platform-admins group, pre-creates the local User doc so they
  // appear in the operator-team list immediately, and returns whichever
  // credential-handoff path Authentik supports for our brand:
  //   - `link`  — single-use recovery URL (preferred; requires a recovery
  //               flow configured in Authentik. User clicks → sets password
  //               + enrolls MFA themselves.)
  //   - `tempPassword` — random 16-char password we set on the user. Used
  //                      when no recovery flow exists; operator hands this
  //                      to the new user out-of-band and they change it on
  //                      first login via Authentik's password-change flow.
  // On their first login, upsertFromAuthentik() patches lastLoginAt +
  // reconciles group state from the JWT — no further work needed.
  //
  // Email delivery is the operator's job for now (we return the credential);
  // when outbound SMTP is wired (Phase 5/6), Authentik can email directly.
  async inviteOperator(
    dto: InviteOperatorDto,
    actor?: AuditActor,
  ): Promise<{
    subject: string
    userId: string
    link?: string
    tempPassword?: string
  }> {
    // Prevent duplicate by email — Authentik will 400 but its error message
    // isn't friendly. Check up front for a clean conflict.
    const existing = await this.authentik.findUserByEmail(dto.email)
    if (existing) {
      throw new ConflictException(
        `User with email ${dto.email} already exists in Authentik (uid=${existing.uid})`,
      )
    }

    const groupPk = await this.resolvePlatformAdminGroupId()
    const username = dto.email  // Authentik convention — keep email + username aligned

    const created = await this.authentik.createUser({
      username,
      email: dto.email,
      name: dto.name,
      groupPks: [groupPk],
      attributes: { invitedBy: actor?.email, invitedAt: new Date().toISOString() },
    })

    // Pre-create the local User doc so the operator-team list reflects the
    // invite immediately. On their first login /users/me will upsert and
    // reconcile lastLoginAt + platformAdmin from the JWT.
    await this.userModel
      .findOneAndUpdate(
        { authentikSubjectId: created.uid },
        {
          $set: {
            email: dto.email,
            name: dto.name,
            platformAdmin: true,
          },
          $setOnInsert: { role: 'admin', active: true, tenantIds: [] },
        },
        { upsert: true, new: true, runValidators: true },
      )
      .exec()

    // Try the preferred recovery-link path first. If Authentik has no
    // recovery flow configured (returns undefined), fall back to setting a
    // generated temp password — and mark it as expired so Authentik forces
    // a change on the user's first login (defense-in-depth against the
    // operator's plaintext-handling window).
    let link: string | undefined
    let tempPassword: string | undefined
    link = await this.authentik.recoveryLink(created.pk)
    if (!link) {
      tempPassword = generateTempPassword()
      await this.authentik.setInitialPassword(created.pk, tempPassword)
      await this.authentik.markPasswordExpired(created.pk)
    }

    void this.audit.record(
      {
        action: 'platform.user_invited',
        resourceType: 'user',
        resourceId: created.uid,
        resourceName: dto.email,
        metadata: {
          role: 'platform-admin',
          name: dto.name,
          handoff: link ? 'recovery-link' : 'temp-password',
        },
      },
      actor,
    )

    return { subject: created.uid, userId: String(created.pk), link, tempPassword }
  }

  // Invite a user that works at a partner organization. Same shape as
  // inviteOperator but adds the dezky-partner-staff group, sets
  // User.partnerId, and records a partner-scoped audit event. The caller
  // (PartnersController) already resolved the slug → partner, so we receive
  // the partnerId directly.
  async invitePartnerUser(
    dto: InvitePartnerUserDto,
    partner: { _id: Types.ObjectId; slug: string },
    actor?: AuditActor,
  ): Promise<{
    subject: string
    userId: string
    // True if we attached an existing Authentik user instead of creating one.
    // When attached, link/tempPassword are omitted (the user already has a
    // password) and the UI shows a simpler success view.
    attached?: boolean
    link?: string
    tempPassword?: string
  }> {
    const groupPk = await this.resolvePartnerStaffGroupId()

    // ── Attach path: user already exists in Authentik ────────────────────
    // Common when:
    //   - Operator promotes an existing platform admin to also work for a
    //     partner (e.g. internal staff cross-referenced as a partner contact).
    //   - User was created via a different invite path (operator team) and
    //     should now also be visible under a partner.
    // Refuse only if their local User doc already points at a DIFFERENT
    // partner — silently moving them would erase the prior relationship.
    const existing = await this.authentik.findUserByEmail(dto.email)
    if (existing) {
      const localUser = await this.userModel.findOne({ authentikSubjectId: existing.uid }).exec()
      if (
        localUser?.partnerId &&
        String(localUser.partnerId) !== String(partner._id)
      ) {
        throw new ConflictException(
          `User ${dto.email} already belongs to partner ${String(localUser.partnerId)} — detach them from that partner first.`,
        )
      }

      await this.authentik.addUserToGroup(existing.pk, groupPk)

      // Upsert local User. Existing docs get partnerId set; missing docs
      // (Authentik-only users like akadmin pre-/users/me) are created so the
      // partner team list shows them immediately instead of waiting for first
      // login to materialize the doc.
      await this.userModel
        .findOneAndUpdate(
          { authentikSubjectId: existing.uid },
          {
            $set: {
              email: existing.email,
              // Don't clobber the local name if we have one (e.g. they
              // already logged in and set it from the JWT); only seed on insert.
              partnerId: partner._id,
              authentikUserPk: existing.pk,
            },
            $setOnInsert: {
              name: existing.name || dto.name,
              role: 'member',
              active: true,
              tenantIds: [],
              platformAdmin: false,
            },
          },
          { upsert: true, new: true, runValidators: true },
        )
        .exec()

      void this.audit.record(
        {
          action: 'partner.user_attached',
          resourceType: 'user',
          resourceId: existing.uid,
          resourceName: existing.email,
          partnerSlug: partner.slug,
          metadata: { name: existing.name || dto.name, role: 'partner-staff' },
        },
        actor,
      )

      return { subject: existing.uid, userId: String(existing.pk), attached: true }
    }

    // ── Create path: brand-new user in Authentik ─────────────────────────
    const created = await this.authentik.createUser({
      username: dto.email,
      email: dto.email,
      name: dto.name,
      groupPks: [groupPk],
      attributes: {
        partnerSlug: partner.slug,
        invitedBy: actor?.email,
        invitedAt: new Date().toISOString(),
      },
    })

    // Pre-create the local User doc with partnerId so the partner team list
    // reflects the invite immediately. On first login, /users/me upserts
    // and reconciles email/name/lastLoginAt from the JWT (partnerId is
    // preserved — see upsertFromAuthentik).
    await this.userModel
      .findOneAndUpdate(
        { authentikSubjectId: created.uid },
        {
          $set: {
            email: dto.email,
            name: dto.name,
            partnerId: partner._id,
            authentikUserPk: created.pk,
          },
          $setOnInsert: { role: 'member', active: true, tenantIds: [], platformAdmin: false },
        },
        { upsert: true, new: true, runValidators: true },
      )
      .exec()

    let link: string | undefined
    let tempPassword: string | undefined
    link = await this.authentik.recoveryLink(created.pk)
    if (!link) {
      tempPassword = generateTempPassword()
      await this.authentik.setInitialPassword(created.pk, tempPassword)
      await this.authentik.markPasswordExpired(created.pk)
    }

    void this.audit.record(
      {
        action: 'partner.user_invited',
        resourceType: 'user',
        resourceId: created.uid,
        resourceName: dto.email,
        partnerSlug: partner.slug,
        metadata: {
          role: 'partner-staff',
          name: dto.name,
          handoff: link ? 'recovery-link' : 'temp-password',
        },
      },
      actor,
    )

    return { subject: created.uid, userId: String(created.pk), link, tempPassword }
  }

  // List users belonging to a partner. Called by the operator partner-detail
  // page's Team section.
  async listPartnerUsers(partnerId: Types.ObjectId): Promise<UserDocument[]> {
    return this.userModel.find({ partnerId }).sort({ createdAt: -1 }).exec()
  }

  // Partner-portal team list. Same set as listPartnerUsers, enriched with:
  //   - accessLevel/accessCount derived from partnerTenantAccess (absent = all)
  //   - mfaEnabled from a live Authentik authenticator-count lookup
  // MFA lookups run in bounded parallel and degrade to null on any error so a
  // flaky/unavailable Authentik never breaks the team list. Kept separate from
  // listPartnerUsers (operator path) so that path stays a pure DB query with
  // no external coupling.
  async listPartnerUsersEnriched(partnerId: Types.ObjectId): Promise<
    Array<
      UserDocument & {
        mfaEnabled: boolean | null
        accessLevel: 'all' | 'scoped'
        accessCount: number | null
      }
    >
  > {
    const users = await this.userModel.find({ partnerId }).sort({ createdAt: -1 }).exec()
    if (users.length === 0) return []
    const mfa = await Promise.all(
      users.map(async (u) => {
        if (!u.authentikUserPk) return null
        try {
          return (await this.authentik.countAuthenticators(u.authentikUserPk)) > 0
        } catch {
          return null
        }
      }),
    )
    return users.map((u, i) => {
      const obj = u.toObject() as UserDocument & {
        mfaEnabled: boolean | null
        accessLevel: 'all' | 'scoped'
        accessCount: number | null
      }
      const access = u.partnerTenantAccess
      const scoped = !!access && access.length > 0
      obj.accessLevel = scoped ? 'scoped' : 'all'
      obj.accessCount = scoped ? access!.length : null
      obj.mfaEnabled = mfa[i] ?? null
      return obj
    })
  }

  // Remove a partner-staff user from the partner: unset partnerId +
  // partnerTenantAccess and drop them from the dezky-partner-staff Authentik
  // group. Ownership-guarded (must belong to the caller's partner) and refuses
  // to strip the last partner admin/owner so a partner can't lock itself out.
  async removePartnerUser(
    subject: string,
    partnerId: Types.ObjectId,
    actor?: AuditActor,
  ): Promise<{ removed: boolean }> {
    const user = await this.userModel.findOne({ authentikSubjectId: subject }).exec()
    if (!user) throw new NotFoundException(`User ${subject} not found`)
    if (!user.partnerId || String(user.partnerId) !== String(partnerId)) {
      throw new ForbiddenException('User is not part of your partner organization')
    }
    if (user.role === 'owner' || user.role === 'admin') {
      const admins = await this.userModel
        .countDocuments({ partnerId, role: { $in: ['owner', 'admin'] } })
        .exec()
      if (admins <= 1) {
        throw new ConflictException('Cannot remove the last partner admin')
      }
    }
    // Best-effort Authentik group removal — never block the local detach on it.
    try {
      const groupPk = await this.resolvePartnerStaffGroupId()
      const pk = user.authentikUserPk ?? (await this.authentik.findUserByEmail(user.email))?.pk
      if (pk) await this.authentik.removeUserFromGroup(pk, groupPk)
    } catch (err) {
      this.logger.warn(
        `Failed to remove ${user.email} from partner-staff group: ${
          err instanceof Error ? err.message : String(err)
        }`,
      )
    }
    await this.userModel
      .updateOne({ _id: user._id }, { $unset: { partnerId: '', partnerTenantAccess: '' } })
      .exec()
    void this.audit.record(
      {
        action: 'partner.user_removed',
        resourceType: 'user',
        resourceId: subject,
        resourceName: user.email,
        metadata: { partnerId: String(partnerId) },
      },
      actor,
    )
    return { removed: true }
  }

  // List tenants attached to a partner. Used by the partner-portal's
  // /partner/customers page (via /users/me/partner/tenants) and could be
  // reused for operator surfaces that want partner-scoped tenant queries.
  // Each tenant carries a userCount (admins + members) so the seat "used"
  // column can render N/M without a second round-trip from the client.
  async listPartnerTenants(
    partnerId: Types.ObjectId,
  ): Promise<
    Array<
      TenantDocument & {
        userCount: number
        newUserCount30d: number
        healthScore: number
        healthBand: 'healthy' | 'watch' | 'at-risk'
      }
    >
  > {
    const tenants = await this.tenantModel.find({ partnerId }).sort({ createdAt: -1 }).exec()
    if (tenants.length === 0) return []
    const since30d = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
    // Single aggregation across all of the partner's tenants. We compute
    // both the total active user count and the subset created in the last
    // 30 days using $cond — one pass over the collection regardless of
    // how many tenants the partner has.
    const counts = await this.userModel.aggregate<{
      _id: Types.ObjectId
      n: number
      new30d: number
    }>([
      { $match: { tenantIds: { $in: tenants.map((t) => t._id) }, active: true } },
      { $unwind: '$tenantIds' },
      { $match: { tenantIds: { $in: tenants.map((t) => t._id) } } },
      {
        $group: {
          _id: '$tenantIds',
          n: { $sum: 1 },
          new30d: { $sum: { $cond: [{ $gte: ['$createdAt', since30d] }, 1, 0] } },
        },
      },
    ])
    const countMap = new Map(counts.map((c) => [String(c._id), c]))
    return tenants.map((t) => {
      const obj = t.toObject() as TenantDocument & {
        userCount: number
        newUserCount30d: number
        healthScore: number
        healthBand: 'healthy' | 'watch' | 'at-risk'
      }
      const c = countMap.get(String(t._id))
      obj.userCount = c?.n ?? 0
      obj.newUserCount30d = c?.new30d ?? 0
      const health = this.tenantHealth(t, obj.userCount)
      obj.healthScore = health.healthScore
      obj.healthBand = health.healthBand
      return obj
    })
  }

  // Portfolio-health heuristic for a tenant, 0–100, computed (never stored).
  // Penalises non-active status, poor seat adoption, and failed/pending
  // provisioning. Band: >=80 healthy, 60–79 watch, <60 at-risk.
  private tenantHealth(
    t: TenantDocument,
    userCount: number,
  ): { healthScore: number; healthBand: 'healthy' | 'watch' | 'at-risk' } {
    let score = 100
    if (t.status === 'pending') score -= 15
    else if (t.status === 'suspended' || t.status === 'deleted') score -= 60

    const seats = t.seats ?? 0
    if (seats > 0) {
      const u = userCount / seats
      if (u < 0.25) score -= 25
      else if (u < 0.5) score -= 10
      else if (u > 1.0) score -= 5
    }

    const ps = t.provisioningStatus
    for (const s of [ps?.authentik, ps?.stalwart, ps?.ocis]) {
      if (s === 'error') score -= 10
      else if (s === 'pending') score -= 5
    }

    score = Math.max(0, Math.min(100, score))
    const healthBand: 'healthy' | 'watch' | 'at-risk' =
      score >= 80 ? 'healthy' : score >= 60 ? 'watch' : 'at-risk'
    return { healthScore: score, healthBand }
  }

  // Create (or attach) the first admin user for a freshly-provisioned
  // tenant. Same shape as invitePartnerUser but adds the user to the
  // tenant's Authentik group (created during provisioning) instead of
  // dezky-partner-staff, sets User.tenantIds to include this tenant, and
  // audits with tenantSlug.
  //
  // Failures are surfaced to the caller (TenantsController) rather than
  // swallowed — the wizard wants to show "admin invite failed: ..." in the
  // done state so the operator can retry rather than silently shipping a
  // tenant with no admin.
  // Create a workspace member and provision them across systems: Authentik SSO
  // (required), a Stalwart mailbox on the tenant's default domain (best-effort),
  // and an OCIS account (best-effort; auto-provisions on first login otherwise).
  // One temp password is set on BOTH Authentik and the mailbox, so the new user
  // has a single credential — returned once for the admin to hand over.
  async createTenantMember(
    tenant: { _id: Types.ObjectId; slug: string; authentikGroupId?: string },
    dto: { name: string; localPart: string; role: 'admin' | 'member'; domain?: string },
    actor?: AuditActor,
  ): Promise<{
    email: string
    tempPassword: string
    provisioning: { authentik: 'ok'; stalwart: 'ok' | 'error' | 'skipped'; ocis: 'ok' | 'error' | 'skipped' }
    stalwartError?: string
    ocisNote?: string
  }> {
    if (!tenant.authentikGroupId) {
      throw new BadRequestException(
        `Workspace "${tenant.slug}" isn't fully provisioned (no identity group). Reconcile it first.`,
      )
    }

    // Resolve the target domain — the named one, else the primary, else the oldest.
    let domainDoc: DomainDocument | null
    if (dto.domain) {
      domainDoc = await this.domainModel
        .findOne({ tenantId: tenant._id, domain: dto.domain.toLowerCase() })
        .exec()
    } else {
      domainDoc = await this.domainModel.findOne({ tenantId: tenant._id, isPrimary: true }).exec()
      if (!domainDoc) {
        domainDoc = await this.domainModel
          .findOne({ tenantId: tenant._id })
          .sort({ createdAt: 1 })
          .exec()
      }
    }
    if (!domainDoc) {
      throw new BadRequestException('Add a domain to this workspace before creating users.')
    }

    const localPart = dto.localPart.trim().toLowerCase()
    if (!/^[a-z0-9._-]+$/.test(localPart)) {
      throw new BadRequestException(
        'The address prefix may only contain letters, numbers, dots, hyphens and underscores.',
      )
    }
    const email = `${localPart}@${domainDoc.domain}`

    const dupe = await this.userModel.findOne({ email, tenantIds: tenant._id }).exec()
    if (dupe) throw new ConflictException(`${email} already exists in this workspace.`)

    const role: 'admin' | 'member' = dto.role === 'admin' ? 'admin' : 'member'
    const tempPassword = generateTempPassword()

    // 1) Authentik SSO identity (required) — same temp password so one credential
    //    works for sign-in (and OCIS, which authenticates via Authentik).
    const created = await this.authentik.createUser({
      username: email,
      email,
      name: dto.name,
      groupPks: [tenant.authentikGroupId],
      attributes: {
        tenantSlug: tenant.slug,
        invitedBy: actor?.email,
        invitedAt: new Date().toISOString(),
      },
    })
    await this.authentik.setInitialPassword(created.pk, tempPassword)

    const prov: { authentik: 'ok'; stalwart: 'ok' | 'error' | 'skipped'; ocis: 'ok' | 'error' | 'skipped' } = {
      authentik: 'ok',
      stalwart: 'skipped',
      ocis: 'skipped',
    }

    // 2) Stalwart mailbox (best-effort) — the same temp password signs into webmail.
    let stalwartAccountId: string | undefined
    let stalwartError: string | undefined
    if (this.stalwart.configured && domainDoc.stalwartId) {
      try {
        const mbx = await this.stalwart.createMailbox({
          domainId: domainDoc.stalwartId,
          localPart,
          fullName: dto.name,
          password: tempPassword,
        })
        stalwartAccountId = mbx.id
        prov.stalwart = 'ok'
      } catch (err) {
        prov.stalwart = 'error'
        stalwartError = (err as Error).message
        this.logger.error(`Mailbox provisioning failed for ${email}: ${stalwartError}`)
      }
    }

    // 3) OCIS account (best-effort; auto-provisions on first sign-in otherwise).
    let ocisUserId: string | undefined
    let ocisNote: string | undefined
    const ocisRes = await this.ocis.ensureUser({ username: email, displayName: dto.name, mail: email })
    if (ocisRes.deferred) {
      prov.ocis = 'skipped'
      ocisNote = 'auto-provisions on first sign-in'
    } else {
      prov.ocis = 'ok'
      ocisUserId = ocisRes.id
    }

    // 4) Our User doc.
    await this.userModel
      .findOneAndUpdate(
        { authentikSubjectId: created.uid },
        {
          $set: {
            email,
            name: dto.name,
            [`tenantRoles.${tenant._id}`]: role,
            mailboxAddress: email,
            stalwartAccountId,
            ocisUserId,
            authentikUserPk: created.pk,
            provisioning: { ...prov, stalwartError, ocisNote },
          },
          $setOnInsert: { role, active: true, platformAdmin: false },
          $addToSet: { tenantIds: tenant._id },
        },
        { upsert: true, new: true, runValidators: true },
      )
      .exec()

    void this.audit.record(
      {
        action: 'tenant.user_created',
        resourceType: 'user',
        resourceId: created.uid,
        resourceName: email,
        tenantSlug: tenant.slug,
        metadata: { name: dto.name, role, mailbox: prov.stalwart, ocis: prov.ocis },
      },
      actor,
    )

    return { email, tempPassword, provisioning: prov, stalwartError, ocisNote }
  }

  // Remove a member from a workspace, tearing down their provisioned accounts.
  // The mailbox lives on one of THIS tenant's domains, so it's deleted (only if
  // it actually belongs here — a multi-tenant user's mailbox on another tenant is
  // left alone). The SSO identity + OCIS account are global, so they're deleted
  // only when the user belongs to no other tenant (and isn't partner staff / a
  // platform admin); otherwise we just detach this tenant.
  async removeTenantMember(
    tenant: { _id: Types.ObjectId; slug: string; authentikGroupId?: string },
    userId: string,
    actor?: AuditActor,
  ): Promise<void> {
    let _id: Types.ObjectId
    try {
      _id = new Types.ObjectId(userId)
    } catch {
      throw new NotFoundException('User not found')
    }
    const user = await this.userModel.findById(_id).exec()
    if (!user || !user.tenantIds.some((t) => t.equals(tenant._id))) {
      throw new NotFoundException('User not found in this workspace')
    }

    // Is the user's mailbox on a domain owned by THIS tenant?
    const mailboxDomain = user.mailboxAddress?.split('@')[1]?.toLowerCase()
    const mailboxIsHere =
      !!mailboxDomain &&
      !!(await this.domainModel.exists({ tenantId: tenant._id, domain: mailboxDomain }))

    // 1) Delete the mailbox (best-effort) when it belongs to this tenant.
    if (this.stalwart.configured && user.stalwartAccountId && mailboxIsHere) {
      await this.stalwart.deleteMailbox(user.stalwartAccountId).catch((err) => {
        this.logger.error(`Mailbox delete failed for ${user.email}: ${(err as Error).message}`)
      })
    }

    // 2) Remove from this tenant's Authentik group.
    if (tenant.authentikGroupId && user.authentikUserPk) {
      await this.authentik
        .removeUserFromGroup(user.authentikUserPk, tenant.authentikGroupId)
        .catch((err) => {
          this.logger.error(`Authentik group remove failed for ${user.email}: ${(err as Error).message}`)
        })
    }

    const remaining = user.tenantIds.filter((t) => !t.equals(tenant._id))
    const fullyRemove = remaining.length === 0 && !user.partnerId && !user.platformAdmin

    if (fullyRemove) {
      if (user.ocisUserId) await this.ocis.deleteUser(user.ocisUserId)
      if (user.authentikUserPk) {
        await this.authentik.deleteUser(user.authentikUserPk).catch((err) => {
          this.logger.error(`Authentik user delete failed for ${user.email}: ${(err as Error).message}`)
        })
      }
      await this.userModel.deleteOne({ _id }).exec()
    } else {
      // Keep the global identity; detach this tenant. Drop the mailbox handle
      // only if the mailbox we removed was actually this tenant's.
      const unset: Record<string, ''> = { [`tenantRoles.${tenant._id}`]: '' }
      if (mailboxIsHere) {
        unset.mailboxAddress = ''
        unset.stalwartAccountId = ''
      }
      await this.userModel
        .updateOne({ _id }, { $pull: { tenantIds: tenant._id }, $unset: unset })
        .exec()
    }

    void this.audit.record(
      {
        action: 'tenant.user_removed',
        resourceType: 'user',
        resourceId: user.authentikSubjectId,
        resourceName: user.email,
        tenantSlug: tenant.slug,
        metadata: { fullyRemoved: fullyRemove },
      },
      actor,
    )
  }

  // Shared lookup for the per-member lifecycle actions: the user doc (verified to
  // belong to this tenant) + whether their mailbox is on one of this tenant's
  // domains (so mailbox-side changes only touch mailboxes we own).
  private async loadMember(
    tenant: { _id: Types.ObjectId },
    userId: string,
  ): Promise<{ user: UserDocument; mailboxIsHere: boolean }> {
    let _id: Types.ObjectId
    try {
      _id = new Types.ObjectId(userId)
    } catch {
      throw new NotFoundException('User not found')
    }
    const user = await this.userModel.findById(_id).exec()
    if (!user || !user.tenantIds.some((t) => t.equals(tenant._id))) {
      throw new NotFoundException('User not found in this workspace')
    }
    const mailboxDomain = user.mailboxAddress?.split('@')[1]?.toLowerCase()
    const mailboxIsHere =
      !!mailboxDomain &&
      !!(await this.domainModel.exists({ tenantId: tenant._id, domain: mailboxDomain }))
    return { user, mailboxIsHere }
  }

  // Suspend or resume a member: toggles Authentik sign-in (is_active) and freezes
  // / unfreezes the mailbox. Reversible — resume restores the original password.
  async setMemberSuspended(
    tenant: { _id: Types.ObjectId; slug: string },
    userId: string,
    suspended: boolean,
    actor?: AuditActor,
  ): Promise<void> {
    const { user, mailboxIsHere } = await this.loadMember(tenant, userId)
    // Identity first — if this fails, abort before touching anything else.
    if (user.authentikUserPk) {
      await this.authentik.setUserActive(user.authentikUserPk, !suspended)
    }
    if (this.stalwart.configured && user.stalwartAccountId && mailboxIsHere) {
      await this.stalwart.setMailboxSuspended(user.stalwartAccountId, suspended).catch((err) => {
        this.logger.error(
          `Mailbox ${suspended ? 'suspend' : 'resume'} failed for ${user.email}: ${(err as Error).message}`,
        )
      })
    }
    user.active = !suspended
    await user.save()
    void this.audit.record(
      {
        action: suspended ? 'tenant.user_suspended' : 'tenant.user_resumed',
        resourceType: 'user',
        resourceId: user.authentikSubjectId,
        resourceName: user.email,
        tenantSlug: tenant.slug,
      },
      actor,
    )
  }

  // Force-logout: terminate the member's active SSO sessions.
  async forceLogoutMember(
    tenant: { _id: Types.ObjectId; slug: string },
    userId: string,
    actor?: AuditActor,
  ): Promise<{ sessions: number }> {
    const { user } = await this.loadMember(tenant, userId)
    let sessions = 0
    if (user.authentikUserPk) {
      sessions = await this.authentik.terminateSessions(user.authentikUserPk).catch(() => 0)
    }
    void this.audit.record(
      {
        action: 'tenant.user_logout_forced',
        resourceType: 'user',
        resourceId: user.authentikSubjectId,
        resourceName: user.email,
        tenantSlug: tenant.slug,
        metadata: { sessions },
      },
      actor,
    )
    return { sessions }
  }

  // Reset a member's password: one fresh temp password set on both their SSO
  // login and their mailbox, returned once for the admin to hand over.
  async resetMemberPassword(
    tenant: { _id: Types.ObjectId; slug: string },
    userId: string,
    actor?: AuditActor,
  ): Promise<{ email: string; tempPassword: string }> {
    const { user, mailboxIsHere } = await this.loadMember(tenant, userId)
    const tempPassword = generateTempPassword()
    if (user.authentikUserPk) {
      await this.authentik.setInitialPassword(user.authentikUserPk, tempPassword)
    }
    if (this.stalwart.configured && user.stalwartAccountId && mailboxIsHere) {
      await this.stalwart.setMailboxPassword(user.stalwartAccountId, tempPassword).catch((err) => {
        this.logger.error(`Mailbox password reset failed for ${user.email}: ${(err as Error).message}`)
      })
    }
    void this.audit.record(
      {
        action: 'tenant.user_password_reset',
        resourceType: 'user',
        resourceId: user.authentikSubjectId,
        resourceName: user.email,
        tenantSlug: tenant.slug,
      },
      actor,
    )
    return { email: user.email, tempPassword }
  }

  // Update a member's directory profile (display name + contact info) and/or
  // their in-tenant role. Contact info is mirrored to Authentik attributes and
  // the mailbox display name on a best-effort basis — the DB is authoritative,
  // so an unreachable Authentik/Stalwart never blocks the local save. Returns
  // the updated doc so the caller can echo the fresh state back to the UI.
  async updateTenantMember(
    tenant: { _id: Types.ObjectId; slug: string },
    userId: string,
    dto: {
      name?: string
      firstName?: string
      lastName?: string
      phone?: string
      alternativeEmail?: string
      role?: 'admin' | 'member'
    },
    actor?: AuditActor,
  ): Promise<UserDocument> {
    const { user } = await this.loadMember(tenant, userId)

    // Role change (in-tenant). The workspace's primary account — its owner and
    // the mailbox-less bootstrap admin (invited on a private email to set the
    // workspace up) — has an immutable role; so does the actor's own account
    // (no self-demotion). Contact-info edits on these accounts are still
    // allowed — only the role is frozen. We also never strip the last
    // admin/owner so a workspace can't lock itself out.
    if (dto.role !== undefined) {
      const current = roleForTenant(user, tenant._id)
      const isPrimary = current === 'owner' || (!user.stalwartAccountId && !user.mailboxAddress)
      if (isPrimary) {
        throw new ForbiddenException('The primary account’s role can’t be changed.')
      }
      if (actor?.userId && actor.userId === String(user._id)) {
        throw new ForbiddenException('You can’t change your own role.')
      }
      if (dto.role === 'member' && current === 'admin') {
        const tenantUsers = await this.userModel.find({ tenantIds: tenant._id }).exec()
        const otherAdmins = tenantUsers.filter(
          (u) =>
            !u._id.equals(user._id) &&
            ['admin', 'owner'].includes(roleForTenant(u, tenant._id)),
        )
        if (otherAdmins.length === 0) {
          throw new ConflictException('Can’t remove the last admin of this workspace.')
        }
      }
    }

    const set: Record<string, unknown> = {}
    if (dto.name !== undefined) set.name = dto.name
    if (dto.firstName !== undefined) set.firstName = dto.firstName
    if (dto.lastName !== undefined) set.lastName = dto.lastName
    if (dto.phone !== undefined) set.phone = dto.phone
    if (dto.alternativeEmail !== undefined) set.alternativeEmail = dto.alternativeEmail
    if (dto.role !== undefined) set[`tenantRoles.${tenant._id}`] = dto.role

    if (Object.keys(set).length === 0) return user

    const updated = await this.userModel
      .findOneAndUpdate({ _id: user._id }, { $set: set }, { new: true, runValidators: true })
      .exec()
    if (!updated) throw new NotFoundException('User not found')

    // Best-effort Authentik sync: display name + contact attributes.
    const attrs: Record<string, unknown> = {}
    if (dto.firstName !== undefined) attrs.firstName = dto.firstName
    if (dto.lastName !== undefined) attrs.lastName = dto.lastName
    if (dto.phone !== undefined) attrs.phone = dto.phone
    if (dto.alternativeEmail !== undefined) attrs.alternativeEmail = dto.alternativeEmail
    if (user.authentikUserPk && (dto.name !== undefined || Object.keys(attrs).length > 0)) {
      await this.authentik
        .updateUser(user.authentikUserPk, {
          name: dto.name,
          attributesMerge: Object.keys(attrs).length > 0 ? attrs : undefined,
        })
        .catch((err) =>
          this.logger.warn(
            `Authentik profile sync failed for ${user.email}: ${(err as Error).message}`,
          ),
        )
    }

    // Best-effort mailbox display-name sync.
    if (dto.name !== undefined && this.stalwart.configured && user.stalwartAccountId) {
      await this.stalwart
        .setMailboxName(user.stalwartAccountId, dto.name)
        .catch((err) =>
          this.logger.warn(
            `Mailbox name sync failed for ${user.email}: ${(err as Error).message}`,
          ),
        )
    }

    void this.audit.record(
      {
        action: 'tenant.user_updated',
        resourceType: 'user',
        resourceId: updated.authentikSubjectId,
        resourceName: updated.email,
        tenantSlug: tenant.slug,
        metadata: {
          fields: Object.keys(set),
          ...(dto.role !== undefined ? { role: dto.role } : {}),
        },
      },
      actor,
    )

    return updated
  }

  // Change a mailbox-less member's primary email — their Authentik sign-in
  // username + email, kept aligned, plus our User.email. This is ONLY for
  // identities with no Stalwart mailbox (e.g. the bootstrap admin invited on a
  // private email): for a real mailbox, the primary address IS the inbox, so
  // moving it would split sign-in from delivery and we refuse. Authentik's
  // stable `uid` (our authentikSubjectId) is unaffected by an email change, so
  // the user's identity survives the rename.
  async changeMemberPrimaryEmail(
    tenant: { _id: Types.ObjectId; slug: string },
    userId: string,
    newEmailRaw: string,
    actor?: AuditActor,
  ): Promise<UserDocument> {
    const { user } = await this.loadMember(tenant, userId)

    if (user.stalwartAccountId || user.mailboxAddress) {
      throw new BadRequestException(
        'This member has a mailbox — their primary address is their inbox and can’t be changed here. Add an alias instead.',
      )
    }
    if (!user.authentikUserPk) {
      throw new BadRequestException('This member isn’t linked to an identity yet.')
    }

    const newEmail = newEmailRaw.trim().toLowerCase()
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(newEmail)) {
      throw new BadRequestException('Enter a valid email address.')
    }
    if (newEmail === user.email.toLowerCase()) return user

    // Not already taken — in Authentik or our own collection.
    const existingAk = await this.authentik.findUserByEmail(newEmail)
    if (existingAk && existingAk.uid !== user.authentikSubjectId) {
      throw new ConflictException(`${newEmail} is already in use.`)
    }
    const existingLocal = await this.userModel
      .findOne({ email: newEmail, _id: { $ne: user._id } })
      .exec()
    if (existingLocal) throw new ConflictException(`${newEmail} is already in use.`)

    // Identity source first — if Authentik rejects, abort before our record so
    // the two never diverge.
    await this.authentik.updateUser(user.authentikUserPk, { username: newEmail, email: newEmail })

    const prevEmail = user.email
    user.email = newEmail
    await user.save()

    void this.audit.record(
      {
        action: 'tenant.user_primary_email_changed',
        resourceType: 'user',
        resourceId: user.authentikSubjectId,
        resourceName: newEmail,
        tenantSlug: tenant.slug,
        metadata: { from: prevEmail, to: newEmail },
      },
      actor,
    )

    return user
  }

  // List a member's mailbox aliases as full addresses. Aliases live in Stalwart
  // as {name, domainId}; we resolve domainId → domain name via this tenant's
  // domains so the UI gets `info@acme.dk`, not an opaque id. Members without a
  // mailbox (SSO-only) come back with hasMailbox=false and no aliases.
  async listMemberAliases(
    tenant: { _id: Types.ObjectId; slug: string },
    userId: string,
  ): Promise<{ hasMailbox: boolean; primary: string; aliases: string[] }> {
    const { user } = await this.loadMember(tenant, userId)
    const primary = user.mailboxAddress ?? user.email
    if (!this.stalwart.configured || !user.stalwartAccountId) {
      return { hasMailbox: false, primary, aliases: [] }
    }
    const [accounts, domains] = await Promise.all([
      this.stalwart.listAccountsWithAliases(),
      this.domainModel.find({ tenantId: tenant._id }, { domain: 1, stalwartId: 1 }).exec(),
    ])
    const acct = accounts.find((a) => a.id === user.stalwartAccountId)
    if (!acct) return { hasMailbox: true, primary, aliases: [] }
    const domainById = new Map(
      domains.filter((d) => d.stalwartId).map((d) => [d.stalwartId as string, d.domain]),
    )
    const aliases = acct.aliases
      .map((a) => {
        const dom = domainById.get(a.domainId)
        return dom ? `${a.name}@${dom}` : null
      })
      .filter((x): x is string => x !== null)
    return { hasMailbox: true, primary: acct.emailAddress || primary, aliases }
  }

  // Add an alias to a member's mailbox. The alias domain must be one of this
  // tenant's provisioned mail domains, and the address must be free. Returns
  // the refreshed alias view.
  async addMemberAlias(
    tenant: { _id: Types.ObjectId; slug: string },
    userId: string,
    dto: { localPart: string; domain: string },
    actor?: AuditActor,
  ): Promise<{ hasMailbox: boolean; primary: string; aliases: string[] }> {
    const { user } = await this.loadMember(tenant, userId)
    if (!this.stalwart.configured || !user.stalwartAccountId) {
      throw new BadRequestException('This member has no mailbox to attach an alias to.')
    }
    const localPart = dto.localPart.trim().toLowerCase()
    if (!/^[a-z0-9._-]+$/.test(localPart)) {
      throw new BadRequestException(
        'The alias prefix may only contain letters, numbers, dots, hyphens and underscores.',
      )
    }
    const domain = dto.domain.trim().toLowerCase()
    const domainDoc = await this.domainModel.findOne({ tenantId: tenant._id, domain }).exec()
    if (!domainDoc?.stalwartId) {
      throw new BadRequestException(`${domain} isn’t a provisioned mail domain for this workspace.`)
    }
    const address = `${localPart}@${domain}`
    const taken = await this.stalwart.findAccountIdByEmail(address)
    if (taken) throw new ConflictException(`${address} is already in use.`)

    await this.stalwart.addAlias(user.stalwartAccountId, localPart, domainDoc.stalwartId)
    void this.audit.record(
      {
        action: 'tenant.user_alias_added',
        resourceType: 'user',
        resourceId: user.authentikSubjectId,
        resourceName: user.email,
        tenantSlug: tenant.slug,
        metadata: { alias: address },
      },
      actor,
    )
    return this.listMemberAliases(tenant, userId)
  }

  // Remove an alias (by full address) from a member's mailbox.
  async removeMemberAlias(
    tenant: { _id: Types.ObjectId; slug: string },
    userId: string,
    addressRaw: string,
    actor?: AuditActor,
  ): Promise<{ hasMailbox: boolean; primary: string; aliases: string[] }> {
    const { user } = await this.loadMember(tenant, userId)
    if (!this.stalwart.configured || !user.stalwartAccountId) {
      throw new BadRequestException('This member has no mailbox.')
    }
    const [localPart, domain] = addressRaw.trim().toLowerCase().split('@')
    if (!localPart || !domain) throw new BadRequestException('Invalid alias address.')
    const domainDoc = await this.domainModel.findOne({ tenantId: tenant._id, domain }).exec()
    if (!domainDoc?.stalwartId) {
      throw new BadRequestException(`${domain} isn’t a mail domain for this workspace.`)
    }
    await this.stalwart.removeAlias(user.stalwartAccountId, localPart, domainDoc.stalwartId)
    void this.audit.record(
      {
        action: 'tenant.user_alias_removed',
        resourceType: 'user',
        resourceId: user.authentikSubjectId,
        resourceName: user.email,
        tenantSlug: tenant.slug,
        metadata: { alias: `${localPart}@${domain}` },
      },
      actor,
    )
    return this.listMemberAliases(tenant, userId)
  }

  // Provision a Stalwart mailbox for a member who doesn't have one yet — the
  // bootstrap admin who signs in with an external email, typically. Their
  // sign-in identity (Authentik username/email + User.email) is left untouched;
  // we only attach a mailbox on a tenant domain and store its handles, so they
  // can use webmail/IMAP and (the point of it) receive aliases. A fresh temp
  // password is set on the mailbox and returned once for hand-off.
  async createMailboxForMember(
    tenant: { _id: Types.ObjectId; slug: string },
    userId: string,
    dto: { localPart: string; domain?: string },
    actor?: AuditActor,
  ): Promise<{ email: string; tempPassword: string; user: UserDocument }> {
    const { user } = await this.loadMember(tenant, userId)
    if (user.stalwartAccountId || user.mailboxAddress) {
      throw new ConflictException('This member already has a mailbox.')
    }
    if (!this.stalwart.configured) {
      throw new BadRequestException('Mail provisioning is disabled — can’t create a mailbox right now.')
    }

    // Resolve the target domain — named, else primary, else oldest (mirrors
    // createTenantMember).
    let domainDoc: DomainDocument | null
    if (dto.domain) {
      domainDoc = await this.domainModel
        .findOne({ tenantId: tenant._id, domain: dto.domain.toLowerCase() })
        .exec()
    } else {
      domainDoc = await this.domainModel.findOne({ tenantId: tenant._id, isPrimary: true }).exec()
      if (!domainDoc) {
        domainDoc = await this.domainModel
          .findOne({ tenantId: tenant._id })
          .sort({ createdAt: 1 })
          .exec()
      }
    }
    if (!domainDoc) {
      throw new BadRequestException('Add a domain to this workspace before creating a mailbox.')
    }
    if (!domainDoc.stalwartId) {
      throw new BadRequestException(`${domainDoc.domain} isn’t a provisioned mail domain for this workspace.`)
    }

    const localPart = dto.localPart.trim().toLowerCase()
    if (!/^[a-z0-9._-]+$/.test(localPart)) {
      throw new BadRequestException(
        'The address prefix may only contain letters, numbers, dots, hyphens and underscores.',
      )
    }
    const email = `${localPart}@${domainDoc.domain}`
    const taken = await this.stalwart.findAccountIdByEmail(email)
    if (taken) throw new ConflictException(`${email} is already in use.`)

    const tempPassword = generateTempPassword()
    const mbx = await this.stalwart.createMailbox({
      domainId: domainDoc.stalwartId,
      localPart,
      fullName: user.name,
      password: tempPassword,
    })

    user.mailboxAddress = email
    user.stalwartAccountId = mbx.id
    user.provisioning = { ...(user.provisioning ?? {}), stalwart: 'ok' }
    await user.save()

    void this.audit.record(
      {
        action: 'tenant.user_mailbox_created',
        resourceType: 'user',
        resourceId: user.authentikSubjectId,
        resourceName: email,
        tenantSlug: tenant.slug,
        metadata: { mailbox: email, signIn: user.email },
      },
      actor,
    )

    return { email, tempPassword, user }
  }

  // Transfer workspace ownership to another member. The named member becomes
  // 'owner' for this tenant; any existing owner is demoted to 'admin' (never
  // removed — they keep access until an admin decides otherwise). Built for the
  // "owner left the company" case, so the actor doesn't have to BE the owner —
  // the controller gates it to tenant admins / platform admins. Only the
  // per-tenant role moves; the legacy global `role` and every other tenant's
  // role are untouched.
  async transferOwnership(
    tenant: { _id: Types.ObjectId; slug: string },
    newOwnerUserId: string,
    actor?: AuditActor,
  ): Promise<{ newOwner: UserDocument; previousOwners: UserDocument[] }> {
    const { user: newOwner } = await this.loadMember(tenant, newOwnerUserId)
    if (roleForTenant(newOwner, tenant._id) === 'owner') {
      throw new ConflictException('This member is already the owner.')
    }

    const tenantUsers = await this.userModel.find({ tenantIds: tenant._id }).exec()
    const previousOwners = tenantUsers.filter(
      (u) => roleForTenant(u, tenant._id) === 'owner' && !u._id.equals(newOwner._id),
    )

    await this.userModel
      .updateOne({ _id: newOwner._id }, { $set: { [`tenantRoles.${tenant._id}`]: 'owner' } })
      .exec()
    for (const prev of previousOwners) {
      await this.userModel
        .updateOne({ _id: prev._id }, { $set: { [`tenantRoles.${tenant._id}`]: 'admin' } })
        .exec()
    }

    void this.audit.record(
      {
        action: 'tenant.ownership_transferred',
        resourceType: 'user',
        resourceId: newOwner.authentikSubjectId,
        resourceName: newOwner.email,
        tenantSlug: tenant.slug,
        metadata: { to: newOwner.email, from: previousOwners.map((p) => p.email) },
      },
      actor,
    )

    // Return fresh docs so the caller's roleForTenant() reflects the new state.
    const freshNewOwner = await this.userModel.findById(newOwner._id).exec()
    const freshPrev = previousOwners.length
      ? await this.userModel.find({ _id: { $in: previousOwners.map((p) => p._id) } }).exec()
      : []
    return { newOwner: freshNewOwner ?? newOwner, previousOwners: freshPrev }
  }

  async inviteTenantAdmin(
    tenant: { _id: Types.ObjectId; slug: string; authentikGroupId?: string },
    dto: { name: string; email: string },
    actor?: AuditActor,
  ): Promise<{
    subject: string
    userId: string
    attached?: boolean
    link?: string
    tempPassword?: string
  }> {
    if (!tenant.authentikGroupId) {
      throw new Error(
        `Tenant ${tenant.slug} has no authentikGroupId — provisioning didn't complete. Retry /tenants/${tenant.slug}/reconcile and re-invite.`,
      )
    }

    // ── Attach existing Authentik user ─────────────────────────────────
    const existing = await this.authentik.findUserByEmail(dto.email)
    if (existing) {
      await this.authentik.addUserToGroup(existing.pk, tenant.authentikGroupId)
      await this.userModel
        .findOneAndUpdate(
          { authentikSubjectId: existing.uid },
          {
            // tenantRoles via $set (not $setOnInsert) so an EXISTING user
            // invited as admin to this tenant actually becomes admin here,
            // without disturbing their role in other tenants. The global
            // `role` stays $setOnInsert as the legacy/first-tenant fallback.
            $set: { email: existing.email, [`tenantRoles.${tenant._id}`]: 'admin' },
            $setOnInsert: {
              name: existing.name || dto.name,
              role: 'admin',
              active: true,
              platformAdmin: false,
            },
            $addToSet: { tenantIds: tenant._id },
          },
          { upsert: true, new: true, runValidators: true },
        )
        .exec()
      void this.audit.record(
        {
          action: 'tenant.admin_attached',
          resourceType: 'user',
          resourceId: existing.uid,
          resourceName: existing.email,
          tenantSlug: tenant.slug,
          metadata: { name: existing.name || dto.name, role: 'admin' },
        },
        actor,
      )
      return { subject: existing.uid, userId: String(existing.pk), attached: true }
    }

    // ── Create new Authentik user ──────────────────────────────────────
    const created = await this.authentik.createUser({
      username: dto.email,
      email: dto.email,
      name: dto.name,
      groupPks: [tenant.authentikGroupId],
      attributes: {
        tenantSlug: tenant.slug,
        invitedBy: actor?.email,
        invitedAt: new Date().toISOString(),
      },
    })

    await this.userModel
      .findOneAndUpdate(
        { authentikSubjectId: created.uid },
        {
          // Per-tenant admin role via $set (see attach branch above).
          $set: { email: dto.email, name: dto.name, [`tenantRoles.${tenant._id}`]: 'admin' },
          $setOnInsert: { role: 'admin', active: true, platformAdmin: false },
          $addToSet: { tenantIds: tenant._id },
        },
        { upsert: true, new: true, runValidators: true },
      )
      .exec()

    let link: string | undefined
    let tempPassword: string | undefined
    link = await this.authentik.recoveryLink(created.pk)
    if (!link) {
      tempPassword = generateTempPassword()
      await this.authentik.setInitialPassword(created.pk, tempPassword)
      await this.authentik.markPasswordExpired(created.pk)
    }

    void this.audit.record(
      {
        action: 'tenant.admin_invited',
        resourceType: 'user',
        resourceId: created.uid,
        resourceName: dto.email,
        tenantSlug: tenant.slug,
        metadata: {
          name: dto.name,
          role: 'admin',
          handoff: link ? 'recovery-link' : 'temp-password',
        },
      },
      actor,
    )

    return { subject: created.uid, userId: String(created.pk), link, tempPassword }
  }

  // Recent audit events scoped to a partner — events whose partnerSlug
  // matches OR whose tenantSlug belongs to one of the partner's tenants.
  // Used by the partner dashboard's Activity card.
  async partnerActivity(
    partnerId: Types.ObjectId,
    partnerSlug: string,
    opts: { limit?: number; before?: Date } = {},
  ): Promise<AuditEventDocument[]> {
    const tenants = await this.tenantModel.find({ partnerId }, { slug: 1 }).exec()
    return this.audit.listForPartner({
      partnerSlug,
      tenantSlugs: tenants.map((t) => t.slug),
      limit: opts.limit,
      before: opts.before,
    })
  }

  // MRR aggregation for a partner, grouped by currency. Each subscription
  // contributes to the bucket for its currency — no FX conversion, since
  // the partner gets paid in whatever currency the customer was billed in.
  //
  // Per-seat amount is read from Subscription.perSeatAmount (the snapshot
  // taken at provision time) instead of the live Price doc, so historical
  // MRR is stable even if the operator edits the catalog later.
  async partnerMrr(partnerId: Types.ObjectId): Promise<{
    totals: Array<{ currency: 'DKK' | 'EUR' | 'USD'; monthlyMinor: number }>
    breakdown: Array<{
      tenantId: string
      tenantSlug: string
      tenantName: string
      plan: 'mvp' | 'pro' | 'enterprise'
      cycle: 'monthly' | 'quarterly' | 'yearly'
      currency: 'DKK' | 'EUR' | 'USD'
      seats: number
      monthlyMinor: number
      custom: boolean  // true when the sub has no priced amount (Enterprise / pre-catalog)
    }>
  }> {
    const tenants = await this.tenantModel.find({ partnerId }).exec()
    if (tenants.length === 0) {
      return { totals: [], breakdown: [] }
    }
    const tenantIds = tenants.map((t) => t._id)
    const subs = await this.subModel.find({ tenantId: { $in: tenantIds }, status: 'active' }).exec()
    const tenantById = new Map(tenants.map((t) => [String(t._id), t]))

    const breakdown = subs.map((s) => {
      const tenant = tenantById.get(String(s.tenantId))!
      const monthlyMinor = normalizeToMonthly(s.perSeatAmount * s.seats, s.cycle)
      return {
        tenantId: String(tenant._id),
        tenantSlug: tenant.slug,
        tenantName: tenant.name,
        plan: s.plan,
        cycle: s.cycle,
        currency: s.currency,
        seats: s.seats,
        monthlyMinor,
        custom: s.perSeatAmount === 0,
      }
    })

    // Group by currency. Use a Map to preserve insertion-on-first-seen
    // ordering — but emit totals in a stable order regardless: DKK, EUR, USD.
    const byCurrency = new Map<string, number>()
    for (const row of breakdown) {
      byCurrency.set(row.currency, (byCurrency.get(row.currency) ?? 0) + row.monthlyMinor)
    }
    const ORDER: Array<'DKK' | 'EUR' | 'USD'> = ['DKK', 'EUR', 'USD']
    const totals = ORDER.filter((c) => byCurrency.has(c)).map((c) => ({
      currency: c,
      monthlyMinor: byCurrency.get(c)!,
    }))

    return { totals, breakdown }
  }

  // Analytics for the partner reports page. Reuses listPartnerTenants (health)
  // and partnerMrr (revenue) plus a signup-cohort pass over the tenants. Churn
  // retention is APPROXIMATE for v1 — "retained" = currently active, since we
  // don't track cancellation dates until billing (Phase 3) lands.
  async partnerReports(partnerId: Types.ObjectId): Promise<{
    health: { healthy: number; watch: number; atRisk: number; total: number; avgScore: number }
    revenueByPlan: Array<{
      plan: 'mvp' | 'pro' | 'enterprise'
      currency: 'DKK' | 'EUR' | 'USD'
      monthlyMinor: number
      count: number
    }>
    topCustomers: Array<{
      tenantId: string
      tenantName: string
      currency: 'DKK' | 'EUR' | 'USD'
      monthlyMinor: number
      custom: boolean
    }>
    churnCohorts: Array<{ month: string; total: number; retained: number; retentionPct: number }>
    totals: Array<{ currency: 'DKK' | 'EUR' | 'USD'; monthlyMinor: number }>
    marginPct: number
  }> {
    const [tenants, mrr, partner] = await Promise.all([
      this.listPartnerTenants(partnerId),
      this.partnerMrr(partnerId),
      this.partnerModel.findById(partnerId, { marginPct: 1 }).exec(),
    ])

    // Health cohorts (exclude soft-deleted from the cohort).
    const live = tenants.filter((t) => t.status !== 'deleted')
    const health = { healthy: 0, watch: 0, atRisk: 0, total: live.length, avgScore: 0 }
    let scoreSum = 0
    for (const t of live) {
      scoreSum += t.healthScore
      if (t.healthBand === 'healthy') health.healthy++
      else if (t.healthBand === 'watch') health.watch++
      else health.atRisk++
    }
    health.avgScore = live.length ? Math.round(scoreSum / live.length) : 0

    // Revenue grouped by plan × currency.
    const planMap = new Map<
      string,
      { plan: 'mvp' | 'pro' | 'enterprise'; currency: 'DKK' | 'EUR' | 'USD'; monthlyMinor: number; count: number }
    >()
    for (const row of mrr.breakdown) {
      const key = `${row.plan}|${row.currency}`
      const e = planMap.get(key) ?? { plan: row.plan, currency: row.currency, monthlyMinor: 0, count: 0 }
      e.monthlyMinor += row.monthlyMinor
      e.count++
      planMap.set(key, e)
    }
    const revenueByPlan = [...planMap.values()].sort((a, b) => b.monthlyMinor - a.monthlyMinor)

    // Top customers by MRR.
    const topCustomers = [...mrr.breakdown]
      .sort((a, b) => b.monthlyMinor - a.monthlyMinor)
      .slice(0, 10)
      .map((r) => ({
        tenantId: r.tenantId,
        tenantName: r.tenantName,
        currency: r.currency,
        monthlyMinor: r.monthlyMinor,
        custom: r.custom,
      }))

    // Signup cohorts (approximate retention).
    const cohortMap = new Map<string, { total: number; retained: number }>()
    for (const t of tenants) {
      const created = (t as { createdAt?: string | Date }).createdAt
      if (!created) continue
      const month = new Date(created).toISOString().slice(0, 7) // YYYY-MM
      const e = cohortMap.get(month) ?? { total: 0, retained: 0 }
      e.total++
      if (t.status === 'active') e.retained++
      cohortMap.set(month, e)
    }
    const churnCohorts = [...cohortMap.entries()]
      .sort((a, b) => a[0].localeCompare(b[0]))
      .map(([month, v]) => ({
        month,
        total: v.total,
        retained: v.retained,
        retentionPct: v.total ? Math.round((v.retained / v.total) * 100) : 0,
      }))

    return {
      health,
      revenueByPlan,
      topCustomers,
      churnCohorts,
      totals: mrr.totals,
      marginPct: partner?.marginPct ?? 0,
    }
  }

  // Platform-wide analytics for the operator reports page (all tenants/subs,
  // not partner-scoped). Status distribution + revenue by plan + top tenants +
  // signup growth.
  async platformReports(): Promise<{
    tenants: { active: number; pending: number; suspended: number; deleted: number; total: number }
    revenueByPlan: Array<{
      plan: 'mvp' | 'pro' | 'enterprise'
      currency: 'DKK' | 'EUR' | 'USD'
      monthlyMinor: number
      count: number
    }>
    totals: Array<{ currency: 'DKK' | 'EUR' | 'USD'; monthlyMinor: number }>
    topTenants: Array<{ tenantId: string; tenantName: string; currency: 'DKK' | 'EUR' | 'USD'; monthlyMinor: number }>
    growth: Array<{ month: string; count: number }>
  }> {
    const [tenants, subs] = await Promise.all([
      this.tenantModel.find().exec(),
      this.subModel.find({ status: 'active' }).exec(),
    ])
    const tenantById = new Map(tenants.map((t) => [String(t._id), t]))

    const statusCounts = { active: 0, pending: 0, suspended: 0, deleted: 0, total: tenants.length }
    for (const t of tenants) statusCounts[t.status] += 1

    const breakdown = subs.map((s) => {
      const t = tenantById.get(String(s.tenantId))
      return {
        tenantId: String(s.tenantId),
        tenantName: t?.name ?? String(s.tenantId),
        plan: s.plan,
        currency: s.currency,
        monthlyMinor: normalizeToMonthly(s.perSeatAmount * s.seats, s.cycle),
      }
    })

    const planMap = new Map<
      string,
      { plan: 'mvp' | 'pro' | 'enterprise'; currency: 'DKK' | 'EUR' | 'USD'; monthlyMinor: number; count: number }
    >()
    const byCurrency = new Map<'DKK' | 'EUR' | 'USD', number>()
    for (const r of breakdown) {
      const key = `${r.plan}|${r.currency}`
      const e = planMap.get(key) ?? { plan: r.plan, currency: r.currency, monthlyMinor: 0, count: 0 }
      e.monthlyMinor += r.monthlyMinor
      e.count++
      planMap.set(key, e)
      byCurrency.set(r.currency, (byCurrency.get(r.currency) ?? 0) + r.monthlyMinor)
    }
    const ORDER: Array<'DKK' | 'EUR' | 'USD'> = ['DKK', 'EUR', 'USD']
    const totals = ORDER.filter((c) => byCurrency.has(c)).map((c) => ({ currency: c, monthlyMinor: byCurrency.get(c)! }))

    const topTenants = [...breakdown].sort((a, b) => b.monthlyMinor - a.monthlyMinor).slice(0, 10)

    const growthMap = new Map<string, number>()
    for (const t of tenants) {
      const created = (t as { createdAt?: string | Date }).createdAt
      if (!created) continue
      const month = new Date(created).toISOString().slice(0, 7)
      growthMap.set(month, (growthMap.get(month) ?? 0) + 1)
    }
    const growth = [...growthMap.entries()]
      .sort((a, b) => a[0].localeCompare(b[0]))
      .map(([month, count]) => ({ month, count }))

    return {
      tenants: statusCounts,
      revenueByPlan: [...planMap.values()].sort((a, b) => b.monthlyMinor - a.monthlyMinor),
      totals,
      topTenants,
      growth,
    }
  }

  // Resolve + cache the dezky-platform-admins group ID. The group is created
  // by Authentik bootstrap so it's reliably present; ensureGroup is
  // idempotent so the worst case is a no-op extra API call on cold start.
  private async resolvePlatformAdminGroupId(): Promise<string> {
    if (this.platformAdminGroupId) return this.platformAdminGroupId
    const group = await this.authentik.ensureGroup(this.platformAdminGroup, {
      role: 'platform-admin',
    })
    this.platformAdminGroupId = group.pk
    return group.pk
  }

  // Same caching pattern for the partner-staff group. Created lazily the
  // first time a partner invite runs — by then Authentik is past bootstrap
  // and ensureGroup will either find or create it.
  private async resolvePartnerStaffGroupId(): Promise<string> {
    if (this.partnerStaffGroupId) return this.partnerStaffGroupId
    const group = await this.authentik.ensureGroup(PARTNER_STAFF_GROUP, {
      role: 'partner-staff',
    })
    this.partnerStaffGroupId = group.pk
    return group.pk
  }
}

// Generates a 16-character random password with mixed character classes.
// Authentik's default password policy requires length + complexity; this
// generator clears every reasonable policy. The new user changes it on
// first login.
function generateTempPassword(): string {
  const upper = 'ABCDEFGHJKLMNPQRSTUVWXYZ'  // omit I + O (visually confusable)
  const lower = 'abcdefghijkmnpqrstuvwxyz' // omit l (confusable with 1)
  const digit = '23456789'                  // omit 0 + 1
  const symbol = '!@#$%&*+-='
  const all = upper + lower + digit + symbol

  // Pick at least one from each class; fill the rest from `all`; shuffle.
  const out: string[] = [
    upper[Math.floor(Math.random() * upper.length)],
    lower[Math.floor(Math.random() * lower.length)],
    digit[Math.floor(Math.random() * digit.length)],
    symbol[Math.floor(Math.random() * symbol.length)],
  ]
  while (out.length < 16) {
    out.push(all[Math.floor(Math.random() * all.length)])
  }
  for (let i = out.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1))
    ;[out[i], out[j]] = [out[j], out[i]]
  }
  return out.join('')
}

// Convert a per-cycle subscription total (in minor units) to its monthly
// equivalent. Used by MRR aggregation. Integer math throughout — final
// rounding happens once at the partner-total level so per-row drift can't
// accumulate visibly.
function normalizeToMonthly(perCycleMinor: number, cycle: 'monthly' | 'quarterly' | 'yearly'): number {
  if (cycle === 'monthly') return perCycleMinor
  if (cycle === 'quarterly') return Math.round(perCycleMinor / 3)
  return Math.round(perCycleMinor / 12)
}
