/**
 * Staff profile routes — settings-module.
 * Mounted at /api/profile in apps/zync-api/src/routes/index.ts.
 *
 * GET  / → current user profile (id, name, email, role, avatarUrl, phone, timezone)
 * PATCH / → update name, avatarUrl, phone, timezone (self only)
 * PATCH /password → change password (revokes other sessions)
 * GET/PATCH /notifications → notification preferences
 * POST /email/request → request email change
 * GET /email/verify → verify email change token (public)
 */
import { Hono } from 'hono'
import { z } from 'zod'
import { and, eq, gt, sql } from '@zync/db'
import { users } from '@zync/db/schema'
import {
  createDb,
  setUserTimezone,
  findUserById,
  findUserByEmail,
  getNotificationPreferences,
  updateNotificationPreferences,
  revokeOtherUserSessions,
  logAuditEvent,
} from '@zync/db/queries'
import type { NotificationPreferences } from '@zync/db/queries'
import type { TenantId, UserId } from '@zync/types'
import {
  hashPassword,
  verifyPassword,
  hashToken,
  blocklistRevokedTokens,
  parseSessionCookie,
} from '@zync/auth'
import type { AppEnv } from '../types'
import { authMiddleware } from '../middleware/auth'
import { withDoHash } from '../lib/password-hash-do'
import { appOriginForRequest } from '../lib/origins'
import {
  sendEmailChangeVerification,
  sendEmailChangeRequested,
} from '../adapters/email'
import {
  UploadContentRejectedError,
  validateUploadContent,
} from '../lib/upload-mime-guard'
import {
  MAX_PROFILE_AVATAR_BYTES,
  profileAvatarKeyFromPublicUrl,
  storeProfileAvatar,
} from '../services/profile-avatar'

// ── Zod schemas ───────────────────────────────────────────────────────────────

const patchProfileSchema = z.object({
  name: z.string().max(200).optional(),
  avatarUrl: z
    .union([z.null(), z.string().max(500).startsWith('/api/profile/avatar/')])
    .optional(),
  phone: z.string().max(30).nullable().optional(),
  timezone: z.string().max(100).optional(),
})

const patchPasswordSchema = z.object({
  currentPassword: z.string().min(1),
  newPassword: z.string().min(8).regex(/\d/, 'must contain a digit'),
})

const notificationEventPrefsSchema = z.object({
  invoicePaid: z.boolean().optional(),
  invoiceOverdue: z.boolean().optional(),
  newLead: z.boolean().optional(),
  projectMilestone: z.boolean().optional(),
  ticketReply: z.boolean().optional(),
})

const patchNotificationPrefsSchema = z
  .object({
    email: notificationEventPrefsSchema.optional(),
    inApp: notificationEventPrefsSchema.optional(),
    digest: z.enum(['none', 'daily', 'weekly']).optional(),
  })
  .refine((data) => data.email !== undefined || data.inApp !== undefined || data.digest !== undefined, {
    message: 'No updatable field supplied',
  })

const emailChangeRequestSchema = z.object({
  newEmail: z.string().email(),
  currentPassword: z.string().min(1),
})

const ALLOWED_AVATAR_CONTENT_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp'])

const EMAIL_CHANGE_TTL_MS = 24 * 60 * 60 * 1000

async function readBoundedBody(
  req: { header: (name: string) => string | undefined; raw: Request },
  maxBytes: number,
): Promise<Uint8Array | 'too_large'> {
  const contentLengthHeader = req.header('Content-Length')
  if (contentLengthHeader !== undefined) {
    const declared = Number(contentLengthHeader)
    if (!Number.isFinite(declared) || declared < 0 || declared > maxBytes) {
      return 'too_large'
    }
  }

  const reader = req.raw.body?.getReader()
  if (!reader) {
    return new Uint8Array(0)
  }

  const chunks: Uint8Array[] = []
  let total = 0
  try {
    while (true) {
      const { done, value } = await reader.read()
      if (done) break
      total += value.byteLength
      if (total > maxBytes) {
        await reader.cancel()
        return 'too_large'
      }
      chunks.push(value)
    }
  } catch {
    await reader.cancel().catch(() => {})
    throw new Error('Failed to read request body')
  }

  const out = new Uint8Array(total)
  let offset = 0
  for (const chunk of chunks) {
    out.set(chunk, offset)
    offset += chunk.byteLength
  }
  return out
}

type ProfileRow = {
  id: string
  name: string | null
  email: string
  avatar_url: string | null
  phone: string | null
  timezone: string | null
  role: string
}

function asRows<T>(result: unknown): T[] {
  if (Array.isArray(result)) return result as T[]
  if (result && typeof result === 'object' && 'rows' in result) {
    return (result as { rows: T[] }).rows
  }
  return []
}

function serializeProfile(row: ProfileRow) {
  return {
    id: row.id,
    name: row.name ?? '',
    email: row.email,
    role: row.role,
    avatarUrl: row.avatar_url,
    phone: row.phone,
    timezone: row.timezone ?? 'Asia/Jerusalem',
  }
}

function generateHexToken(byteLength = 32): string {
  const bytes = crypto.getRandomValues(new Uint8Array(byteLength))
  return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('')
}

function mergeNotificationPrefs(
  current: NotificationPreferences,
  patch: z.infer<typeof patchNotificationPrefsSchema>,
): NotificationPreferences {
  return {
    email: { ...current.email, ...patch.email },
    inApp: { ...current.inApp, ...patch.inApp },
    digest: patch.digest ?? current.digest,
  }
}

async function fetchProfileRow(
  db: ReturnType<typeof createDb>,
  userId: UserId,
  tenantId: TenantId,
): Promise<Omit<ProfileRow, 'timezone'> | null> {
  const result = await db.execute(sql`
    SELECT u.id, u.name, u.email, u.avatar_url, u.phone, r.name AS role
    FROM users u
    JOIN tenant_memberships tm ON tm.user_id = u.id AND tm.tenant_id = ${tenantId}
    JOIN roles r ON r.id = tm.role_id
    WHERE u.id = ${userId}
    LIMIT 1
  `)
  const rows = asRows<Omit<ProfileRow, 'timezone'>>(result)
  return rows[0] ?? null
}

async function fetchUserTimezone(
  db: ReturnType<typeof createDb>,
  userId: UserId,
  tenantId: TenantId,
): Promise<string> {
  const result = await db.execute(sql`
    SELECT timezone FROM user_preferences
    WHERE user_id = ${userId} AND tenant_id = ${tenantId}
    LIMIT 1
  `)
  const rows = asRows<{ timezone: string | null }>(result)
  return rows[0]?.timezone ?? 'Asia/Jerusalem'
}

async function currentAccessTokenHash(c: { req: { header: (name: string) => string | undefined } }): Promise<string> {
  const cookie = parseSessionCookie(c.req.header('Cookie'))
  const auth = c.req.header('Authorization')
  const token =
    cookie ??
    (auth && auth.startsWith('Bearer ') ? auth.slice('Bearer '.length).trim() : null)
  if (!token) return ''
  return hashToken(token)
}

// ── Router ────────────────────────────────────────────────────────────────────

export const profileRoute = new Hono<AppEnv>()

// GET /api/profile/email/verify — public (link from email; no session required)
profileRoute.get('/email/verify', async (c) => {
  const appOrigin = appOriginForRequest(c.req.url)
  const profileUrl = `${appOrigin}/profile`
  const token = c.req.query('token')
  if (!token) {
    return c.redirect(`${profileUrl}?email_error=expired`, 302)
  }

  const tokenHash = await hashToken(token)
  const db = createDb(c.env)
  const now = new Date()

  const [user] = await db
    .select()
    .from(users)
    .where(
      and(eq(users.pendingEmailToken, tokenHash), gt(users.pendingEmailExpiresAt, now)),
    )
    .limit(1)

  if (!user || !user.pendingEmail) {
    return c.redirect(`${profileUrl}?email_error=expired`, 302)
  }

  const taken = await findUserByEmail(db, user.pendingEmail)
  if (taken && taken.id !== user.id) {
    return c.redirect(`${profileUrl}?email_error=in_use`, 302)
  }

  await db
    .update(users)
    .set({
      email: user.pendingEmail,
      pendingEmail: null,
      pendingEmailToken: null,
      pendingEmailExpiresAt: null,
    })
    .where(eq(users.id, user.id))

  const currentTokenHash = await currentAccessTokenHash(c)
  const membership = await db.execute(sql`
    SELECT tm.tenant_id
    FROM tenant_memberships tm
    WHERE tm.user_id = ${user.id} AND tm.status = 'active'
    ORDER BY tm.created_at
    LIMIT 1
  `)
  const membershipRows = asRows<{ tenant_id: string }>(membership)
  const tenantId = membershipRows[0]?.tenant_id
  if (tenantId) {
    const { tokens } = await revokeOtherUserSessions(
      db,
      user.id,
      tenantId,
      currentTokenHash,
      'user',
    )
    if (tokens.length > 0) {
      await blocklistRevokedTokens(c.env.RATELIMIT_KV, tokens)
    }
  }

  return c.redirect(`${profileUrl}?email_changed=1`, 302)
})

profileRoute.use('*', authMiddleware)

// GET /api/profile/avatar/:key — same-origin authed proxy for private-bucket avatars
profileRoute.get('/avatar/:key{.+}', async (c) => {
  const session = c.get('session')
  if (!session || session.type !== 'user' || !session.tid) {
    return c.json({ error: 'Unauthorized' }, 401)
  }

  const key = c.req.param('key')
  if (key.includes('..') || key.includes('\\') || key.startsWith('/')) {
    return c.json({ error: 'Forbidden' }, 403)
  }

  const tenantPrefix = `avatars/${session.tid}/`
  if (!key.startsWith(tenantPrefix)) {
    return c.json({ error: 'Forbidden' }, 403)
  }

  const obj = await c.env.STORAGE.get(key)
  if (!obj) {
    return c.json({ error: 'Not found' }, 404)
  }

  const headers = new Headers()
  headers.set('Content-Type', obj.httpMetadata?.contentType ?? 'image/png')
  headers.set('Cache-Control', 'private, max-age=300')
  if (obj.httpEtag) {
    headers.set('etag', obj.httpEtag)
  }

  return new Response(obj.body, { headers })
})

// GET /api/profile
profileRoute.get('/', async (c) => {
  const session = c.get('session')
  if (!session || session.type !== 'user' || !session.tid) {
    return c.json({ error: 'Unauthorized' }, 401)
  }

  const db = c.get('db') ?? createDb(c.env)
  const userId = session.sub as UserId
  const tenantId = session.tid as TenantId
  const row = await fetchProfileRow(db, userId, tenantId)
  if (!row) {
    return c.json({ error: 'Membership not found' }, 404)
  }

  const timezone = await fetchUserTimezone(db, userId, tenantId)
  return c.json(serializeProfile({ ...row, timezone }), 200)
})

// PATCH /api/profile
profileRoute.patch('/', async (c) => {
  const session = c.get('session')
  if (!session || session.type !== 'user' || !session.tid) {
    return c.json({ error: 'Unauthorized' }, 401)
  }

  const body = await c.req.json().catch(() => null)
  const parsed = patchProfileSchema.safeParse(body)
  if (!parsed.success) {
    return c.json({ error: 'Validation failed', issues: parsed.error.issues }, 400)
  }

  const { name, avatarUrl, phone, timezone } = parsed.data
  if (
    name === undefined &&
    avatarUrl === undefined &&
    phone === undefined &&
    timezone === undefined
  ) {
    return c.json({ error: 'No updatable field supplied' }, 422)
  }

  const db = c.get('db') ?? createDb(c.env)
  const userId = session.sub as UserId
  const tenantId = session.tid as TenantId

  if (name !== undefined || avatarUrl !== undefined || phone !== undefined) {
    await db
      .update(users)
      .set({
        ...(name !== undefined ? { name } : {}),
        ...(avatarUrl !== undefined ? { avatarUrl } : {}),
        ...(phone !== undefined ? { phone } : {}),
      })
      .where(eq(users.id, userId))
  }

  if (timezone !== undefined) {
    await setUserTimezone(db, userId, tenantId, timezone)
  }

  const row = await fetchProfileRow(db, userId, tenantId)
  if (!row) {
    return c.json({ error: 'Membership not found' }, 404)
  }

  const resolvedTimezone = await fetchUserTimezone(db, userId, tenantId)
  return c.json(serializeProfile({ ...row, timezone: resolvedTimezone }), 200)
})

// POST /api/profile/avatar — authed binding upload (raw image bytes)
profileRoute.post('/avatar', async (c) => {
  const session = c.get('session')
  if (!session || session.type !== 'user' || !session.tid) {
    return c.json({ error: 'Unauthorized' }, 401)
  }

  const contentType = c.req.header('Content-Type')?.split(';')[0]?.trim() ?? ''
  if (!ALLOWED_AVATAR_CONTENT_TYPES.has(contentType)) {
    return c.json({ error: 'Unsupported media type' }, 415)
  }
  const avatarContentType = contentType as 'image/png' | 'image/jpeg'

  let bytes: Uint8Array
  try {
    const body = await readBoundedBody(c.req, MAX_PROFILE_AVATAR_BYTES)
    if (body === 'too_large') {
      return c.json({ error: 'Avatar exceeds maximum size' }, 413)
    }
    if (body.byteLength === 0) {
      return c.json({ error: 'Empty body' }, 400)
    }
    bytes = body
  } catch {
    return c.json({ error: 'Failed to read request body' }, 400)
  }

  try {
    validateUploadContent(avatarContentType, bytes)
  } catch (err) {
    if (err instanceof UploadContentRejectedError) {
      return c.json({ error: 'Unsupported media type' }, 415)
    }
    throw err
  }

  const db = c.get('db') ?? createDb(c.env)
  const userId = session.sub as UserId
  const tenantId = session.tid as TenantId

  const user = await findUserById(db, userId)
  const previousAvatarUrl = user?.avatarUrl ?? null

  let avatar_url: string
  try {
    const stored = await storeProfileAvatar(
      c.env,
      tenantId,
      userId,
      avatarContentType,
      bytes,
    )
    avatar_url = stored.avatar_url
  } catch {
    return c.json({ error: 'Failed to store avatar' }, 500)
  }

  const newKey = profileAvatarKeyFromPublicUrl(avatar_url)
  try {
    await db.update(users).set({ avatarUrl: avatar_url }).where(eq(users.id, userId))
  } catch {
    if (newKey) {
      await c.env.STORAGE.delete(newKey).catch(() => {})
    }
    return c.json({ error: 'Failed to update profile' }, 500)
  }

  if (previousAvatarUrl) {
    const oldKey = profileAvatarKeyFromPublicUrl(previousAvatarUrl)
    if (oldKey && oldKey !== newKey) {
      await c.env.STORAGE.delete(oldKey).catch(() => {})
    }
  }

  return c.json({ avatar_url }, 200)
})

// PATCH /api/profile/password
profileRoute.patch('/password', async (c) => {
  const session = c.get('session')
  if (!session || session.type !== 'user' || !session.tid) {
    return c.json({ error: 'Unauthorized' }, 401)
  }

  const body = await c.req.json().catch(() => null)
  const parsed = patchPasswordSchema.safeParse(body)
  if (!parsed.success) {
    return c.json({ error: 'Validation failed', issues: parsed.error.issues }, 400)
  }

  const db = c.get('db') ?? createDb(c.env)
  const userId = session.sub as UserId
  const tenantId = session.tid as TenantId
  const user = await findUserById(db, userId)
  if (!user?.passwordHash) {
    return c.json({ error: 'Unauthorized' }, 401)
  }

  const valid = await verifyPassword(
    parsed.data.currentPassword,
    user.passwordHash,
    withDoHash(c.env),
  )
  if (!valid) {
    return c.json({ error: 'incorrect_password' }, 401)
  }

  const passwordHash = await hashPassword(parsed.data.newPassword, withDoHash(c.env))
  await db.update(users).set({ passwordHash }).where(eq(users.id, userId))

  const currentTokenHash = c.get('accessTokenHash') ?? ''
  const { tokens } = await revokeOtherUserSessions(db, userId, tenantId, currentTokenHash, 'user')
  if (tokens.length > 0) {
    await blocklistRevokedTokens(c.env.RATELIMIT_KV, tokens)
  }

  void logAuditEvent({ env: c.env }, {
    tenantId,
    userId,
    eventType: 'auth.password_changed',
    ipAddress: c.req.header('CF-Connecting-IP') ?? undefined,
  })

  return c.json({ ok: true }, 200)
})

// GET /api/profile/notifications
profileRoute.get('/notifications', async (c) => {
  const session = c.get('session')
  if (!session || session.type !== 'user' || !session.tid) {
    return c.json({ error: 'Unauthorized' }, 401)
  }

  const db = c.get('db') ?? createDb(c.env)
  const prefs = await getNotificationPreferences(db, session.tid, session.sub)
  return c.json(prefs, 200)
})

// PATCH /api/profile/notifications
profileRoute.patch('/notifications', async (c) => {
  const session = c.get('session')
  if (!session || session.type !== 'user' || !session.tid) {
    return c.json({ error: 'Unauthorized' }, 401)
  }

  const body = await c.req.json().catch(() => null)
  const parsed = patchNotificationPrefsSchema.safeParse(body)
  if (!parsed.success) {
    return c.json({ error: 'Validation failed', issues: parsed.error.issues }, 400)
  }

  const db = c.get('db') ?? createDb(c.env)
  const userId = session.sub
  const tenantId = session.tid
  const current = await getNotificationPreferences(db, tenantId, userId)
  const merged = mergeNotificationPrefs(current, parsed.data)

  await updateNotificationPreferences(db, tenantId, userId, merged, {
    actorIp: c.req.header('CF-Connecting-IP') ?? null,
    requestId: c.req.header('CF-Ray') ?? null,
  })

  return c.json(merged, 200)
})

// POST /api/profile/email/request
profileRoute.post('/email/request', async (c) => {
  const session = c.get('session')
  if (!session || session.type !== 'user' || !session.tid) {
    return c.json({ error: 'Unauthorized' }, 401)
  }

  const body = await c.req.json().catch(() => null)
  const parsed = emailChangeRequestSchema.safeParse(body)
  if (!parsed.success) {
    return c.json({ error: 'Validation failed', issues: parsed.error.issues }, 400)
  }

  const db = c.get('db') ?? createDb(c.env)
  const userId = session.sub as UserId
  const user = await findUserById(db, userId)
  if (!user?.passwordHash) {
    return c.json({ error: 'Unauthorized' }, 401)
  }

  const valid = await verifyPassword(
    parsed.data.currentPassword,
    user.passwordHash,
    withDoHash(c.env),
  )
  if (!valid) {
    return c.json({ error: 'incorrect_password' }, 401)
  }

  const newEmail = parsed.data.newEmail.trim().toLowerCase()
  const existing = await findUserByEmail(db, newEmail)
  if (existing && existing.id !== userId) {
    return c.json({ error: 'email_in_use' }, 409)
  }

  const rawToken = generateHexToken()
  const tokenHash = await hashToken(rawToken)
  const expiresAt = new Date(Date.now() + EMAIL_CHANGE_TTL_MS)

  await db
    .update(users)
    .set({
      pendingEmail: newEmail,
      pendingEmailToken: tokenHash,
      pendingEmailExpiresAt: expiresAt,
    })
    .where(eq(users.id, userId))

  const appOrigin = appOriginForRequest(c.req.url)
  const verifyUrl = `${appOrigin}/api/profile/email/verify?token=${encodeURIComponent(rawToken)}`
  await sendEmailChangeVerification(c.env, newEmail, verifyUrl, userId, appOrigin)
  await sendEmailChangeRequested(c.env, user.email, newEmail, userId, appOrigin)

  return c.json({ ok: true }, 200)
})
