import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
import { HydratedDocument, Types } from 'mongoose'

export type UserDocument = HydratedDocument<User>

export type UserRole = 'owner' | 'admin' | 'member'

// toObject must flatten Maps too: meWithPartner() returns user.toObject() for
// partner-staff, and JSON.stringify on a raw Map yields {} — which would drop
// tenantRoles from the /me payload. toJSON already flattens by default; this
// makes toObject() agree so the field survives in every serialization path.
@Schema({ collection: 'users', timestamps: true, toObject: { flattenMaps: true } })
export class User {
  // Authentik subject claim — stable identity across login sessions.
  @Prop({ required: true, unique: true, index: true })
  authentikSubjectId!: string

  // Tenants this user belongs to. A user can belong to multiple tenants (e.g. partner staff).
  @Prop({ type: [Types.ObjectId], ref: 'Tenant', default: [], index: true })
  tenantIds!: Types.ObjectId[]

  // Partner this user works FOR (staff/admin at a partner organization).
  // Optional — most users (tenant members + platform admins) leave this null.
  // When set, /partners/:slug/users lists this user and future partner-portal
  // scope guards can verify the JWT-resolved user belongs to the requested
  // partner. Per-partner role is implicit in being on the list for v1;
  // refine to per-partner role enum later if partner-portal RBAC needs it.
  @Prop({ type: Types.ObjectId, ref: 'Partner', index: true })
  partnerId?: Types.ObjectId

  @Prop({ required: true, lowercase: true, trim: true, index: true })
  email!: string

  @Prop({ required: true, trim: true })
  name!: string

  // Legacy global role + the fallback when a tenant has no explicit entry in
  // tenantRoles below. For partner staff this is their partner-org role (used
  // by the partner last-admin guard). For tenant users it's superseded by the
  // per-tenant entry. Always resolve a tenant-scoped role via roleForTenant(),
  // never by reading this field directly.
  @Prop({ enum: ['owner', 'admin', 'member'], default: 'member' })
  role!: UserRole

  // Per-tenant role overrides, keyed by stringified tenant ObjectId. A present
  // key wins for that tenant; absence falls back to the global `role` above
  // (so existing single-role users keep their role everywhere with no
  // migration). This lets one user be 'admin' of tenant A and 'member' of
  // tenant B — the flat global role couldn't express that, and re-inviting an
  // existing user into a second tenant used to silently keep their first
  // tenant's role. Resolve via roleForTenant().
  @Prop({ type: Map, of: String, default: undefined })
  tenantRoles?: Map<string, UserRole>

  @Prop({ default: true })
  active!: boolean

  // Cross-tenant admin flag — independent of per-tenant role above.
  // Set at upsert time based on Authentik group membership; once set, the DB is the
  // source of truth and a future revocation requires explicit setUserAdmin().
  @Prop({ default: false, index: true })
  platformAdmin!: boolean

  @Prop()
  lastLoginAt?: Date

  // ── Contact information (directory profile) ──────────────────────────────
  // Editable from the customer-admin user drawer. `name` above stays the
  // display name (what shows in lists and as the mailbox description); these
  // are the structured extras O365's "Kontaktoplysninger" surfaces. Mirrored
  // to Authentik attributes (best-effort) so the IdP profile agrees, but the
  // DB is the source of truth.
  @Prop({ trim: true })
  firstName?: string

  @Prop({ trim: true })
  lastName?: string

  @Prop({ trim: true })
  phone?: string

  // A recovery/secondary address for reaching the user OUTSIDE their workspace
  // mailbox (e.g. a private email). Not a mail alias — it routes nowhere in
  // Stalwart; it's contact metadata only.
  @Prop({ lowercase: true, trim: true })
  alternativeEmail?: string

  // Partner-staff only: explicit subset of the partner's tenants this user may
  // access. Absent/empty = full portfolio ("all") — backward compatible with
  // existing staff. Lets a partner scope e.g. a sales rep to specific customers.
  @Prop({ type: [Types.ObjectId], ref: 'Tenant', default: undefined })
  partnerTenantAccess?: Types.ObjectId[]

  // Authentik internal numeric pk, captured at invite time so admin endpoints
  // (MFA device list, group add/remove) work without an email lookup.
  @Prop({ type: Number })
  authentikUserPk?: number

  // Mail + storage handles, filled by the tenant-member create flow. The mailbox
  // address is the user's working email on the tenant's domain.
  @Prop({ lowercase: true, trim: true })
  mailboxAddress?: string

  @Prop()
  stalwartAccountId?: string

  @Prop()
  ocisUserId?: string

  // Per-system provisioning outcome for this user. Authentik must succeed (the
  // identity); stalwart/ocis are best-effort — 'skipped' means not attempted or
  // deferred (e.g. OCIS auto-provisions on first sign-in).
  @Prop({ type: Object, default: undefined })
  provisioning?: {
    authentik?: 'ok' | 'error'
    stalwart?: 'ok' | 'error' | 'skipped'
    ocis?: 'ok' | 'error' | 'skipped'
    stalwartError?: string
    ocisNote?: string
  }
}

export const UserSchema = SchemaFactory.createForClass(User)

// Single source of truth for tenant-scoped role resolution. A per-tenant entry
// in tenantRoles wins; absent, fall back to the legacy global `role`, then
// 'member'. Backend and frontend (apps/portal useMe.roleForTenant) must keep
// this precedence in sync. Accepts both a hydrated Map and a plain object (e.g.
// a lean() result or a JSON-deserialized doc).
export function roleForTenant(
  user: Pick<User, 'role' | 'tenantRoles'>,
  tenantId: Types.ObjectId | string,
): UserRole {
  const key = String(tenantId)
  const roles = user.tenantRoles
  const fromMap =
    roles instanceof Map ? roles.get(key) : (roles as Record<string, UserRole> | undefined)?.[key]
  return fromMap ?? user.role ?? 'member'
}
