/**
 * Invitation create + accept — foundation-auth-rbac (Task 14).
 *
 *  - POST /api/auth/invite (guarded requirePermission('users:invite')):
 *    enforce seat cap (getMaxTeamMembers vs active members) -> 402 at cap,
 *    create invitation (token hash, 7d expiry), email plaintext token.
 *  - GET /api/auth/invite/accept?token (public): look up by hashToken,
 *    create membership (existing user) or mini-signup (new user). Honours
 *    tenant.require_approval (membership starts status='pending_approval').
 *
 * Create is mounted behind authMiddleware + requirePermission; accept is public.
 */
import { Hono } from 'hono'
import { withDoHash } from '../../lib/password-hash-do'
import {
  generateOpaqueToken,
  getMaxTeamMembers,
  hashPassword,
  hashToken,
  verifyPassword,
  DEVICE_TRUST_COOKIE_NAME,
  PENDING_2FA_TTL_SECONDS,
  PENDING_2FA_TOKEN_PREFIX,
} from '@zync/auth'
import { getCookie, deleteCookie } from 'hono/cookie'
import {
  acceptInvitationExistingUser,
  countActiveMembers,
  createDb,
  createInvitation,
  createPendingUser,
  findUserByEmail,
  getInvitationByHash,
  getInvitationPublicMetadataByHash,
  getPermissionsForRole,
  getRoleById,
  getTenantById,
  getUser2FAStatus,
  getTenant2FASettings,
  findValidTrustedDevice,
  insertMagicLinkToken,
} from '@zync/db/queries'
import { SYSTEM_ROLE_PERMISSIONS } from '@zync/db/seed/permission-keys'
import type { TenantId, TenantTier, UserId } from '@zync/types'
import type { AppEnv } from '../../types'
import { acceptInviteSchema, createInviteSchema } from '../../schemas/auth'
import { sendInvitationEmail } from '../../adapters/email'
import { requirePermission } from '../../middleware/guards'
import {
  issueSessionForTenant,
  issueSessionForTenantWith2FA,
  SessionCapExceededError,
} from '../../lib/issue-session'
import { appOriginForRequest } from '../../lib/origins'

const INVITE_TTL_MS = 1000 * 60 * 60 * 24 * 7 // 7 days

// Create requires a session (authMiddleware) + permission; accept is public.
export const inviteCreateRoute = new Hono<AppEnv>()
export const inviteAcceptRoute = new Hono<AppEnv>()

// --- Create (guarded) ---
inviteCreateRoute.post('/invite', requirePermission('users:invite'), async (c) => {
  const session = c.get('session')
  if (!session || session.type !== 'user' || !session.tid) {
    return c.json({ error: 'Unauthorized' }, 401)
  }
  const parsed = createInviteSchema.safeParse(await c.req.json())
  if (!parsed.success) {
    return c.json({ error: 'Invalid request' }, 400)
  }
  const { email, roleId } = parsed.data
  const tenantId = session.tid as TenantId

  const db = createDb(c.env)
  const tenant = await getTenantById(db, tenantId)
  if (!tenant) return c.json({ error: 'Tenant not found' }, 404)

  const active = await countActiveMembers(db, tenantId)
  if (active >= getMaxTeamMembers(tenant.tier as TenantTier)) {
    return c.json({ error: 'Upgrade required', requiredTier: 'business' }, 402)
  }

  // Anti-privilege-escalation: the inviter may only grant a role whose
  // permission set is a subset of their own (the JWT-expanded
  // `session.permissions`). The existence + tenant check runs FIRST and is
  // load-bearing: a bogus roleId would yield an empty permission set that
  // passes the subset test vacuously, then detonate at accept (FK violation).
  const grantedRole = await getRoleById(db, roleId)
  if (!grantedRole || grantedRole.tenantId !== tenantId) {
    return c.json({ error: 'Invalid role' }, 400)
  }
  const grantedPerms = await getPermissionsForRole(db, roleId)
  const inviterPerms = new Set(session.permissions)
  if (!grantedPerms.every((p) => inviterPerms.has(p))) {
    return c.json({ error: 'Cannot grant a role exceeding your permissions' }, 403)
  }

  // Accountant invites must use the system ACCOUNTANT role with its exact
  // financial-only permission set — no custom roles, no extra grants.
  if (grantedRole.name === 'ACCOUNTANT') {
    if (!grantedRole.isSystemRole) {
      return c.json({ error: 'Invalid accountant role' }, 400)
    }
    const expected = new Set(SYSTEM_ROLE_PERMISSIONS.ACCOUNTANT)
    const actual = new Set(grantedPerms)
    if (
      expected.size !== actual.size ||
      ![...expected].every((p) => actual.has(p))
    ) {
      return c.json({ error: 'Invalid accountant role permissions' }, 400)
    }
  }

  const plain = generateOpaqueToken()
  const tokenHash = await hashToken(plain)
  await createInvitation(db, {
    tenantId,
    email,
    roleId,
    tokenHash,
    expiresAt: new Date(Date.now() + INVITE_TTL_MS),
    invitedBy: session.sub as UserId,
    actorIp: c.req.header('CF-Connecting-IP') ?? null,
    requestId: c.req.header('CF-Ray') ?? null,
  })

  await sendInvitationEmail(c.env, email, plain, tenantId, session.sub, appOriginForRequest(c.req.url))
  return c.json({ status: 'sent' }, 201)
})

inviteAcceptRoute.get('/invite/:token', async (c) => {
  const token = c.req.param('token')
  const db = createDb(c.env)
  const tokenHash = await hashToken(token)
  const invitation = await getInvitationPublicMetadataByHash(db, tokenHash)
  if (!invitation) {
    return c.json({ error: 'not_found' }, 404)
  }

  const existingUser = await findUserByEmail(db, invitation.email)
  return c.json(
    {
      email: invitation.email,
      tenantName: invitation.tenantName,
      inviterName: invitation.inviterName ?? 'צוות Zync',
      isNewUser: !existingUser,
    },
    200,
  )
})

// --- Accept (public) ---
inviteAcceptRoute.post('/invite/accept', async (c) => {
  const parsed = acceptInviteSchema.safeParse(await c.req.json())
  if (!parsed.success) {
    return c.json({ error: 'Invalid request' }, 400)
  }
  const { token, fullName, password } = parsed.data

  const db = createDb(c.env)
  const tokenHash = await hashToken(token)
  const invitation = await getInvitationByHash(db, tokenHash)
  if (!invitation) {
    return c.json({ error: 'Invalid or expired invitation' }, 400)
  }

  const tenant = await getTenantById(db, invitation.tenantId as TenantId)
  if (!tenant) return c.json({ error: 'Tenant not found' }, 404)
  const pendingApproval = tenant.requireApproval

  // Resolve or create the invited user.
  let user = await findUserByEmail(db, invitation.email)
  if (!user) {
    if (!fullName || !password) {
      return c.json({ error: 'Account setup required', requiresSignup: true }, 422)
    }
    const passwordHash = await hashPassword(password, withDoHash(c.env))
    // The invitation was delivered to this email and the bearer clicked
    // through with a valid token -> email control is proven. Create the user
    // already verified (otherwise login.ts would reject them on emailVerifiedAt).
    const created = await createPendingUser(db, {
      email: invitation.email,
      passwordHash,
      name: fullName,
      emailVerified: true,
    })
    user = await findUserByEmail(db, invitation.email)
    if (!user) {
      return c.json({ error: 'Account creation failed' }, 500)
    }
    void created
  } else if (!password || !(await verifyPassword(password, user.passwordHash, withDoHash(c.env)))) {
    return c.json({ error: 'invalid_password' }, 401)
  }

  const invitedRole = await getRoleById(db, invitation.roleId)
  const isAccountant = invitedRole?.name === 'ACCOUNTANT'

  await acceptInvitationExistingUser(db, {
    invitationId: invitation.id,
    tokenHash,
    tenantId: invitation.tenantId as TenantId,
    userId: user.id as UserId,
    roleId: invitation.roleId,
    pendingApproval,
    isAccountant,
    actorIp: c.req.header('CF-Connecting-IP') ?? null,
    requestId: c.req.header('CF-Ray') ?? null,
  })

  // require_approval tenants: membership is pending_approval until an admin
  // approves — no session is issued yet.
  if (pendingApproval) {
    return c.json({ status: 'pending_approval' }, 202)
  }

  const userId = user.id as UserId
  const tenantId = invitation.tenantId as TenantId

  const [user2fa, tenant2fa] = await Promise.all([
    getUser2FAStatus(db, userId),
    getTenant2FASettings(db, tenantId),
  ])

  const twoFactorEnabled = user2fa?.twoFactorEnabled ?? false
  const enforce2fa = tenant2fa?.enforce2fa ?? false

  if (twoFactorEnabled || enforce2fa) {
    const rawDeviceTrustToken = getCookie(c, DEVICE_TRUST_COOKIE_NAME)
    if (rawDeviceTrustToken) {
      const deviceTokenHash = await hashToken(rawDeviceTrustToken)
      const trustedDevice = await findValidTrustedDevice(
        db,
        deviceTokenHash,
        userId,
        tenantId,
      )
      if (trustedDevice) {
        let issued
        try {
          issued = await issueSessionForTenantWith2FA(
            c,
            db,
            userId,
            tenantId,
            { enforce2fa, twoFactorVerified: true },
          )
        } catch (error) {
          if (error instanceof SessionCapExceededError) {
            return c.json({ error: error.message }, 429)
          }
          throw error
        }
        if (!issued) {
          return c.json({ error: 'Membership activation failed' }, 500)
        }
        return c.json({ status: 'joined', expiresAt: issued.expiresAt }, 200)
      }
      deleteCookie(c, DEVICE_TRUST_COOKIE_NAME, {
        httpOnly: true,
        secure: true,
        sameSite: 'Strict',
        domain: '.zync.is',
        path: '/',
      })
    }
  }

  if (twoFactorEnabled) {
    const plainToken = `${PENDING_2FA_TOKEN_PREFIX}${generateOpaqueToken()}`
    const pendingTokenHash = await hashToken(plainToken)
    const expiresAt = new Date(Date.now() + PENDING_2FA_TTL_SECONDS * 1000)
    await insertMagicLinkToken(db, {
      tenantId,
      userId,
      tokenHash: pendingTokenHash,
      purpose: 'pending_2fa',
      expiresAt,
    })
    const disableRememberDevice = tenant2fa?.disable2faRememberDevice ?? false
    return c.json(
      {
        status: 'joined',
        requires_2fa: true,
        session_token: plainToken,
        phone_suffix: user2fa?.twoFactorPhoneSuffix ?? null,
        allow_remember_device: !disableRememberDevice,
      },
      200,
    )
  }

  if (enforce2fa && !twoFactorEnabled) {
    const plainToken = `${PENDING_2FA_TOKEN_PREFIX}${generateOpaqueToken()}`
    const pendingTokenHash = await hashToken(plainToken)
    const expiresAt = new Date(Date.now() + PENDING_2FA_TTL_SECONDS * 1000)
    await insertMagicLinkToken(db, {
      tenantId,
      userId,
      tokenHash: pendingTokenHash,
      purpose: 'pending_2fa_setup',
      expiresAt,
    })
    return c.json(
      { status: 'joined', requires_2fa_setup: true, session_token: plainToken },
      200,
    )
  }

  let issued
  try {
    issued = await issueSessionForTenant(c, db, userId, tenantId)
  } catch (error) {
    if (error instanceof SessionCapExceededError) {
      return c.json({ error: error.message }, 429)
    }
    throw error
  }
  if (!issued) {
    return c.json({ error: 'Membership activation failed' }, 500)
  }
  return c.json({ status: 'joined', expiresAt: issued.expiresAt }, 200)
})
