/**
 * Audited write helpers for foundation-auth-rbac routes.
 *
 * Every tenant-scoped mutation runs inside `db.transaction` and writes an
 * `audit_log` row in the SAME transaction (acceptance criterion; enforced by
 * `require-audit-in-transaction`). The audit table is forward-declared in
 * `_audit-forward.ts` (owned downstream by audit-compliance, spec 28); the
 * `auditLog` binding here is imported as a BARE identifier so the lint rule
 * recognises the paired `tx.insert(auditLog)`.
 *
 * Routes never open transactions themselves — they call these helpers, keeping
 * route files free of raw Drizzle tables (`no-raw-drizzle-from-routes`).
 */
import { and, eq, isNull } from 'drizzle-orm'
import type { TenantId, UserId } from '@zync/types'
import type { Db } from '../client'
import {
  users,
  tenants,
  tenantSettings,
  tenantMemberships,
  roles,
  permissions,
  rolePermissions,
  refreshTokens,
  invitations,
  adminUsers,
} from '../schema'
import {
  DEFAULT_SEQUENCE_PREFIXES,
  syncInvoiceSequencePrefixes,
} from './invoice-sequences'
import {
  PERMISSION_KEYS,
  SYSTEM_ROLE_NAMES,
  SYSTEM_ROLE_PERMISSIONS,
  type SystemRoleName,
} from '../seed/permission-keys'
import { auditLog } from './_audit-forward'
import { seedTenantModules } from './tenant-modules'
import { seedTaskStatuses } from '../seed/task-statuses'
import { seedDefaultStockLocation } from '../seed/inventory-default-location'

/** Drizzle transaction handle — the methods used here match the top-level Db. */
type Tx = Parameters<Parameters<Db['transaction']>[0]>[0]

/** Slugify a tenant name into a URL-safe, lowercase slug. */
function slugify(name: string): string {
  const base = name
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/^-+|-+$/g, '')
  return base || 'workspace'
}

/**
 * Seed the 5 system roles + role_permissions for a tenant INSIDE an open
 * transaction. Mirrors seedSystemRoles (queries/seed-rbac.ts) but operates on
 * the tx so it participates in the verify-email transaction atomically.
 */
async function seedSystemRolesTx(tx: Tx, tenantId: string): Promise<void> {
  await tx
    .insert(roles)
    .values(SYSTEM_ROLE_NAMES.map((name) => ({ tenantId, name, isSystemRole: true })))
    .onConflictDoNothing({ target: [roles.tenantId, roles.name] })

  const roleRows = await tx
    .select({ id: roles.id, name: roles.name })
    .from(roles)
    .where(eq(roles.tenantId, tenantId))
  const roleIdByName = new Map(roleRows.map((r) => [r.name, r.id]))

  const permRows = await tx.select({ id: permissions.id, key: permissions.key }).from(permissions)
  const permIdByKey = new Map(permRows.map((p) => [p.key, p.id]))

  const links: { roleId: string; permissionId: string }[] = []
  for (const roleName of SYSTEM_ROLE_NAMES) {
    const roleId = roleIdByName.get(roleName)
    if (!roleId) continue
    for (const key of SYSTEM_ROLE_PERMISSIONS[roleName as SystemRoleName]) {
      const permissionId = permIdByKey.get(key)
      if (permissionId) links.push({ roleId, permissionId })
    }
  }
  if (links.length > 0) {
    await tx
      .insert(rolePermissions)
      .values(links)
      .onConflictDoNothing({ target: [rolePermissions.roleId, rolePermissions.permissionId] })
  }
  void PERMISSION_KEYS
}

/**
 * Signup: create a PENDING_EMAIL user (email_verified_at NULL). Single write,
 * no tenant context yet — therefore NOT a transaction and NOT audited
 * (audit_log.tenant_id is NOT NULL; there is no tenant until verify-email).
 */
export async function createPendingUser(
  db: Db,
  input: {
    email: string
    passwordHash: string
    name: string | null
    /**
     * Mark the user email-verified at creation. Default false (signup flow:
     * verification happens via the emailed token). Set true only when email
     * control is already proven out-of-band — e.g. accepting an invitation
     * delivered to that address (the invite link IS the proof).
     */
    emailVerified?: boolean
  },
): Promise<{ id: UserId }> {
  const [row] = await db
    .insert(users)
    .values({
      email: input.email,
      passwordHash: input.passwordHash,
      name: input.name,
      emailVerifiedAt: input.emailVerified ? new Date() : null,
    })
    .returning({ id: users.id })
  return { id: row!.id as UserId }
}

export interface VerifyEmailResult {
  tenantId: TenantId
  roleId: string
  role: string
  tenantSlug: string
  tier: string
}

export async function markUserEmailVerified(
  db: Db,
  userId: UserId,
): Promise<void> {
  await db
    .update(users)
    .set({ emailVerifiedAt: new Date() })
    .where(eq(users.id, userId))
}

/**
 * Verify-email: mark the user verified, create their first tenant +
 * tenant_settings, seed system roles, create the OWNER membership — all in ONE
 * transaction with an audit row.
 */
export async function completeEmailVerificationTx(
  tx: Tx,
  args: {
    userId: UserId
    tenantName: string
    actorIp?: string | null
    requestId?: string | null
    markEmailVerified?: boolean
  },
): Promise<VerifyEmailResult> {
  if (args.markEmailVerified ?? true) {
    await tx
      .update(users)
      .set({ emailVerifiedAt: new Date() })
      .where(eq(users.id, args.userId))
  }

  const slug = `${slugify(args.tenantName)}-${crypto.randomUUID().slice(0, 8)}`
  const [tenant] = await tx
    .insert(tenants)
    .values({ slug, name: args.tenantName, tier: 'freelancer' })
    .returning({ id: tenants.id, slug: tenants.slug, tier: tenants.tier })
  const tenantId = tenant!.id

  // Ensure a settings row exists so module columns are read/writable w/o null-check.
  await tx
    .insert(tenantSettings)
    .values({ tenantId })
    .onConflictDoNothing({ target: tenantSettings.tenantId })

  await syncInvoiceSequencePrefixes(tx, tenantId, {
    invoice: DEFAULT_SEQUENCE_PREFIXES.invoice,
    proforma: DEFAULT_SEQUENCE_PREFIXES.proforma,
    credit_note: DEFAULT_SEQUENCE_PREFIXES.credit_note,
  })

  await seedSystemRolesTx(tx, tenantId)

  // Seed all 14 toggleable modules as enabled=true for the new tenant.
  // Idempotent (ON CONFLICT DO NOTHING). 'system' is never inserted.
  await seedTenantModules(tx, tenantId)

  // tasks-board-engine: seed 8 default Kanban statuses for new tenant
  await seedTaskStatuses(tx, tenantId)

  await seedDefaultStockLocation(tx, tenantId)

  const [ownerRole] = await tx
    .select({ id: roles.id, name: roles.name })
    .from(roles)
    .where(and(eq(roles.tenantId, tenantId), eq(roles.name, 'OWNER')))
    .limit(1)

  await tx.insert(tenantMemberships).values({
    userId: args.userId,
    tenantId,
    roleId: ownerRole!.id,
    status: 'active',
  })

  await tx.insert(auditLog).values({
    tenantId,
    actorId: args.userId,
    actorType: 'user',
    entityType: 'tenant',
    entityId: tenantId,
    action: 'tenant.created',
    ip: args.actorIp ?? null,
    requestId: args.requestId ?? null,
  })

  return {
    tenantId: tenantId as TenantId,
    roleId: ownerRole!.id,
    role: ownerRole!.name,
    tenantSlug: tenant!.slug,
    tier: tenant!.tier,
  }
}

export async function completeEmailVerification(
  db: Db,
  args: {
    userId: UserId
    tenantName: string
    actorIp?: string | null
    requestId?: string | null
    markEmailVerified?: boolean
  },
): Promise<VerifyEmailResult> {
  return db.transaction((tx) => completeEmailVerificationTx(tx, args))
}

/**
 * Insert a fresh refresh-token row (hash only). Single write, no business
 * mutation to audit — issuing a refresh token alongside a session is not a
 * tenant-state change, so it is not wrapped in an audited transaction.
 */
export async function insertRefreshToken(
  db: Db,
  input: { userId: UserId; tenantId: TenantId | null; tokenHash: string; expiresAt: Date },
): Promise<void> {
  await db.insert(refreshTokens).values({
    userId: input.userId,
    tenantId: input.tenantId,
    tokenHash: input.tokenHash,
    expiresAt: input.expiresAt,
  })
}

/**
 * Refresh-token rotation: revoke the presented token and insert its successor
 * atomically. Two writes that must not interleave -> transaction -> audited
 * (a session lifecycle event is legitimately auditable).
 */
export async function rotateRefreshToken(
  db: Db,
  args: {
    oldTokenHash: string
    userId: UserId
    tenantId: TenantId | null
    newTokenHash: string
    expiresAt: Date
    actorIp?: string | null
    requestId?: string | null
  },
): Promise<boolean> {
  return db.transaction(async (tx) => {
    const revoked = await tx
      .update(refreshTokens)
      .set({ revokedAt: new Date() })
      .where(
        and(eq(refreshTokens.tokenHash, args.oldTokenHash), isNull(refreshTokens.revokedAt)),
      )
      .returning({ id: refreshTokens.id })

    if (revoked.length === 0) return false

    await tx.insert(refreshTokens).values({
      userId: args.userId,
      tenantId: args.tenantId,
      tokenHash: args.newTokenHash,
      expiresAt: args.expiresAt,
    })

    if (args.tenantId) {
      await tx.insert(auditLog).values({
        tenantId: args.tenantId,
        actorId: args.userId,
        actorType: 'user',
        entityType: 'session',
        entityId: args.userId,
        action: 'session.refreshed',
        ip: args.actorIp ?? null,
        requestId: args.requestId ?? null,
      })
    }
    return true
  })
}

/** Logout: revoke a single refresh token by hash (idempotent). No tenant audit. */
export async function revokeRefreshToken(db: Db, tokenHash: string): Promise<void> {
  await db
    .update(refreshTokens)
    .set({ revokedAt: new Date() })
    .where(eq(refreshTokens.tokenHash, tokenHash))
}

/**
 * Password reset: set the new hash. No tenant context (reset is user-global),
 * so this is a single write, not an audited tenant transaction. The caller
 * also calls bumpUserVersion to kill live sessions.
 */
export async function resetUserPassword(
  db: Db,
  userId: UserId,
  passwordHash: string,
): Promise<void> {
  await db.update(users).set({ passwordHash }).where(eq(users.id, userId))
}

type CreateInvitationArgs = {
  tenantId: TenantId
  email: string
  roleId: string
  tokenHash: string
  expiresAt: Date
  invitedBy: UserId
  actorIp?: string | null
  requestId?: string | null
}

async function insertInvitationWithAudit(
  tx: Tx,
  args: CreateInvitationArgs,
): Promise<{ id: string }> {
  const [inv] = await tx
    .insert(invitations)
    .values({
      tenantId: args.tenantId,
      email: args.email,
      roleId: args.roleId,
      tokenHash: args.tokenHash,
      expiresAt: args.expiresAt,
    })
    .returning({ id: invitations.id })

  await tx.insert(auditLog).values({
    tenantId: args.tenantId,
    actorId: args.invitedBy,
    actorType: 'user',
    entityType: 'invitation',
    entityId: inv!.id,
    action: 'invitation.created',
    ip: args.actorIp ?? null,
    requestId: args.requestId ?? null,
  })
  return { id: inv!.id }
}

/**
 * Create an invitation on an existing transaction (token hash only, caller emails the plaintext).
 * Tenant-scoped write + audit on the provided tx handle.
 */
export async function createInvitationInTx(
  tx: Tx,
  args: CreateInvitationArgs,
): Promise<{ id: string }> {
  return insertInvitationWithAudit(tx, args)
}

/**
 * Create an invitation (token hash only, caller emails the plaintext).
 * Tenant-scoped write -> transaction + audit.
 */
export async function createInvitation(
  db: Db,
  args: CreateInvitationArgs,
): Promise<{ id: string }> {
  return db.transaction(async (tx) => insertInvitationWithAudit(tx, args))
}

/**
 * Accept an invitation for an EXISTING user: create the membership, mark the
 * invitation accepted, in one audited transaction. `pendingApproval` records
 * whether the membership should start frozen (require_approval tenants).
 */
export async function acceptInvitationExistingUser(
  db: Db,
  args: {
    invitationId: string
    tokenHash: string
    tenantId: TenantId
    userId: UserId
    roleId: string
    pendingApproval: boolean
    isAccountant?: boolean
    actorIp?: string | null
    requestId?: string | null
  },
): Promise<void> {
  await db.transaction(async (tx) => {
    await tx
      .update(invitations)
      .set({ acceptedAt: new Date() })
      .where(eq(invitations.id, args.invitationId))

    await tx
      .insert(tenantMemberships)
      .values({
        userId: args.userId,
        tenantId: args.tenantId,
        roleId: args.roleId,
        // require_approval: start in the distinct 'pending_approval' state; an
        // admin approve flips it to 'active'. NOT 'frozen' — freeze is a separate
        // admin-initiated suspension, and conflating them would make freeze-state
        // queries (status='frozen') wrongly catch members awaiting approval.
        status: args.pendingApproval ? 'pending_approval' : 'active',
        freezeReason: null,
        isAccountant: args.isAccountant ?? false,
      })
      .onConflictDoNothing({ target: [tenantMemberships.userId, tenantMemberships.tenantId] })

    await tx.insert(auditLog).values({
      tenantId: args.tenantId,
      actorId: args.userId,
      actorType: 'user',
      entityType: 'membership',
      entityId: args.userId,
      action: 'invitation.accepted',
      ip: args.actorIp ?? null,
      requestId: args.requestId ?? null,
    })
  })
}

/**
 * Freeze a membership: set status='frozen' + reason, revoke ALL the user's
 * refresh tokens for the tenant — one audited transaction. Caller follows with
 * bumpUserVersion for immediate (<=60s) session revocation.
 */
export async function freezeMembership(
  db: Db,
  args: {
    tenantId: TenantId
    targetUserId: UserId
    reason: string
    actorId: UserId
    actorIp?: string | null
    requestId?: string | null
  },
): Promise<void> {
  await db.transaction(async (tx) => {
    await tx
      .update(tenantMemberships)
      .set({ status: 'frozen', freezeReason: args.reason })
      .where(
        and(
          eq(tenantMemberships.userId, args.targetUserId),
          eq(tenantMemberships.tenantId, args.tenantId),
        ),
      )

    await tx
      .update(refreshTokens)
      .set({ revokedAt: new Date() })
      .where(
        and(
          eq(refreshTokens.userId, args.targetUserId),
          eq(refreshTokens.tenantId, args.tenantId),
        ),
      )

    await tx.insert(auditLog).values({
      tenantId: args.tenantId,
      actorId: args.actorId,
      actorType: 'user',
      entityType: 'membership',
      entityId: args.targetUserId,
      action: 'membership.frozen',
      changes: { status: ['active', 'frozen'] },
      ip: args.actorIp ?? null,
      requestId: args.requestId ?? null,
    })
  })
}

/**
 * Store an admin's encrypted TOTP secret in admin_users. Admin actions are
 * cross-tenant and have no tenant_id, so they are NOT written to the
 * tenant-scoped audit_log (NOT NULL tenant_id). Single write.
 */
export async function setAdminTotpSecret(
  db: Db,
  adminId: string,
  encryptedSecret: string,
): Promise<void> {
  await db.update(adminUsers).set({ totpSecret: encryptedSecret }).where(eq(adminUsers.id, adminId))
}
