/**
 * Expenses route group — expenses-module.
 * Mounted at /api/expenses in apps/zync-api/src/routes/index.ts (via manifest).
 *
 * All routes run behind authMiddleware + requireModuleEnabled('expenses').
 *
 * Routes:
 *   GET    /                         → list (paginated, filterable, ≤100)
 *   POST   /upload                   → upload receipt(s) — rate-limited 10/min/user
 *   POST   /per-diem                 → log per-diem expense (no OCR)
 *   GET    /review                   → NEEDS_REVIEW queue (expense-ocr-correction-ux)
 *   GET    /:id                      → expense detail + corrections
 *   PATCH  /:id                      → manual correction (COMPLETED/NEEDS_REVIEW only)
 *   DELETE /:id                      → soft delete
 *   POST   /:id/evaluate             → trigger / re-trigger AI tax evaluation
 *   GET    /:id/file                 → signed R2 URL (30-min TTL)
 *   POST   /:id/reprocess            → re-queue OCR (expense-ocr-correction-ux)
 *   POST   /:id/approve              → OCR review approve (expense-ocr-correction-ux)
 *   POST   /:id/correct              → post-approval correction (expense-ocr-correction-ux)
 *   POST   /:id/void                 → soft-delete via voided_at (expense-ocr-correction-ux)
 */
import { Hono } from 'hono'
import { z } from 'zod'
import type { AppEnv } from '../../types'
import { authMiddleware } from '../../middleware/auth'
import { bumpFinancialsVersionOnWrite } from '../../middleware/bump-financials-version'
import { requireModuleEnabled } from '../../middleware/require-module-enabled'
import { requirePermission } from '../../middleware/guards'
import { signSignedToken } from '@zync/auth'
import {
  listExpenses,
  getExpense,
  createExpense,
  updateExpense,
  softDeleteExpense,
  getExpenseById,
  setExpenseStatus,
  serializeExpense,
  createPerDiemExpense,
  evaluateDeductibility,
  getExpenseSettings,
  emitExpenseWebhook,
  listExpensesByIds,
  listNeedsReviewExpenseIds,
  buildExpenseReportXlsx,
} from '@zync/expenses'
import { pushOverWebSocket } from '@zync/notifications'
import {
  assertTenantOwnsProject,
  invalidTenantReferenceBody,
  rejectExpense,
  isExpenseApprover,
} from '@zync/db/queries'
import type { ExpenseProcessJob } from '../../queues/expense-process'
import type { ExpenseStatus } from '@zync/types'
import type { ExpenseCategoryId } from '@zync/types'
import { ocrCorrectionRoutes } from './ocr-corrections'

const ALLOWED_FILE_TYPES = new Set(['jpg', 'png', 'heic', 'pdf'])
const MIME_TO_TYPE: Record<string, string> = {
  'image/jpeg':      'jpg',
  'image/jpg':       'jpg',
  'image/png':       'png',
  'image/heic':      'heic',
  'application/pdf': 'pdf',
}
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10 MB
const MAX_FILES_PER_BATCH = 20

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

const listQuerySchema = z.object({
  cursor:       z.string().optional(),
  limit:        z.coerce.number().int().min(1).max(100).default(50),
  dateFrom:     z.string().optional(),
  dateTo:       z.string().optional(),
  category:     z.string().optional(),
  deductionPct: z.coerce.number().int().min(0).max(100).optional(),
  status:       z.enum(['PENDING', 'PROCESSING', 'COMPLETED', 'FAILED', 'NEEDS_REVIEW']).optional(),
  source:       z.enum(['upload', 'email', 'whatsapp', 'telegram']).optional(),
  projectId:    z.string().uuid().optional(),
  tab:          z.enum(['all', 'needs_review', 'recurring']).optional(),
  billable:     z.coerce.boolean().optional(),
})

const patchExpenseSchema = z.object({
  vendorName:        z.string().nullable().optional(),
  vendorTaxId:       z.string().nullable().optional(),
  invoiceNumber:     z.string().nullable().optional(),
  invoiceTotal:      z.string().nullable().optional(),
  vatAmount:         z.string().nullable().optional(),
  currency:          z.string().length(3).optional(),
  allocationNumber:  z.string().nullable().optional(),
  expenseDate:       z.string().nullable().optional(),
  amount:            z.string().nullable().optional(),
  vatDeductible:     z.boolean().optional(),
  expenseCategory:   z.enum([
    'office', 'marketing', 'professional', 'vehicle', 'equipment',
    'finance', 'welfare', 'exceptional', 'travel',
  ]).nullable().optional(),
  deductionPct:      z.number().int().min(0).max(100).nullable().optional(),
  notes:             z.string().nullable().optional(),
  business_percent:  z.number().int().min(0).max(100).optional(),
})

const perDiemSchema = z.object({
  travel_type: z.enum(['domestic', 'international']),
  days:        z.number().positive().max(365),
  date:        z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be YYYY-MM-DD'),
  project_id:  z.string().uuid().optional(),
  notes:       z.string().max(1000).optional(),
})

const rejectApprovalSchema = z.object({
  reason: z.string().min(1).max(1000),
})

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

export const expenseRoutes = new Hono<AppEnv>()

expenseRoutes.use('*', authMiddleware)
expenseRoutes.use('*', requireModuleEnabled('expenses'))
expenseRoutes.use('*', bumpFinancialsVersionOnWrite)

// ── OCR correction routes — BEFORE /:id to avoid id-capture ──────────────────
// Mounts: GET /review, POST /:id/reprocess, POST /:id/approve,
//         POST /:id/correct, POST /:id/void
expenseRoutes.route('', ocrCorrectionRoutes)

// ── GET /api/expenses ──────────────────────────────────────────────────────────

expenseRoutes.get('/', requirePermission('expenses:read'), async (c) => {
  const session = c.get('session')
  if (!session || session.type !== 'user' || !session.tid) {
    return c.json({ error: 'Unauthorized' }, 401)
  }

  const url = new URL(c.req.url)
  const raw = Object.fromEntries(url.searchParams.entries())
  const parsed = listQuerySchema.safeParse(raw)
  if (!parsed.success) {
    return c.json({ error: 'Invalid query', issues: parsed.error.issues }, 400)
  }

  const db = c.get('db')
  const result = await listExpenses(db, session.tid, {
    cursor:       parsed.data.cursor,
    limit:        parsed.data.limit,
    dateFrom:     parsed.data.dateFrom,
    dateTo:       parsed.data.dateTo,
    category:     parsed.data.category as ExpenseCategoryId | undefined,
    deductionPct: parsed.data.deductionPct,
    status:       parsed.data.status as ExpenseStatus | undefined,
    source:       parsed.data.source as Parameters<typeof listExpenses>[2]['source'],
    projectId:    parsed.data.projectId,
    tab:          parsed.data.tab,
    billable:     parsed.data.billable,
  })

  return c.json(result, 200)
})

// ── POST /api/expenses/bulk/evaluate-pending ───────────────────────────────────

expenseRoutes.post('/bulk/evaluate-pending', requirePermission('expenses:write'), 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')
  const ids = await listNeedsReviewExpenseIds(db, session.tid)
  const settings = await getExpenseSettings(db, session.tid)
  const ctx = { db, env: c.env }
  let evaluated = 0

  for (const id of ids) {
    const row = await getExpenseById(db, session.tid, id)
    if (!row) continue
    const expense = serializeExpense(row)
    const evalResult = await evaluateDeductibility(
      ctx as Parameters<typeof evaluateDeductibility>[0],
      expense,
      settings.business_category,
    )
    await setExpenseStatus(db, session.tid, id, expense.status as ExpenseStatus, {
      expenseCategory: evalResult.expenseCategory,
      deductionPct: evalResult.deductionPct,
      deductionConfidence: String(evalResult.deductionConfidence),
      deductionReasoningHe: evalResult.reasoningHe,
      deductionReasoningEn: evalResult.reasoningEn,
      evaluatedAt: new Date(),
    })
    evaluated++
  }

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

// ── GET /api/expenses/export/xlsx ──────────────────────────────────────────────

expenseRoutes.get('/export/xlsx', requirePermission('expenses:read'), async (c) => {
  const session = c.get('session')
  if (!session || session.type !== 'user' || !session.tid) {
    return c.json({ error: 'Unauthorized' }, 401)
  }

  const idsParam = c.req.query('ids')
  if (!idsParam) {
    return c.json({ error: 'ids query parameter required' }, 400)
  }
  const ids = idsParam.split(',').map((s) => s.trim()).filter(Boolean)
  if (ids.length === 0) {
    return c.json({ error: 'No expense IDs provided' }, 400)
  }

  const db = c.get('db')
  const rows = await listExpensesByIds(db, session.tid, ids)
  const buffer = await buildExpenseReportXlsx(rows)

  return new Response(buffer, {
    status: 200,
    headers: {
      'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
      'Content-Disposition': 'attachment; filename="expenses-selected.xlsx"',
    },
  })
})

// ── POST /api/expenses/upload ──────────────────────────────────────────────────

expenseRoutes.post('/upload', requirePermission('expenses:write'), async (c) => {
  const session = c.get('session')
  if (!session || session.type !== 'user' || !session.tid) {
    return c.json({ error: 'Unauthorized' }, 401)
  }

  // Rate limit: 10 uploads/minute per user
  const rateLimitKey = `${session.sub}:expense-upload`
  const { success: rateLimitOk } = await (async () => { try { const _rl = await c.env.RATE_LIMITER_EXPENSE_UPLOAD?.limit({ key: rateLimitKey }); return _rl ?? { success: true }; } catch { return { success: true }; } })()
  if (!rateLimitOk) {
    return c.json({ error: 'Rate limit exceeded. Maximum 10 uploads per minute.' }, 429)
  }

  let formData: FormData
  try {
    formData = await c.req.formData()
  } catch {
    return c.json({ error: 'Invalid multipart form data' }, 400)
  }

  const files = formData.getAll('files').filter((v) => typeof v === 'object') as unknown as Array<{ type: string; name: string; size: number; arrayBuffer: () => Promise<ArrayBuffer> }>
  if (!files || files.length === 0) {
    return c.json({ error: 'No files provided' }, 400)
  }
  if (files.length > MAX_FILES_PER_BATCH) {
    return c.json({ error: `Maximum ${MAX_FILES_PER_BATCH} files per upload batch` }, 400)
  }

  const db = c.get('db')
  const created = []

  for (const file of files) {
    // Validate file type
    const rawType = MIME_TO_TYPE[file.type] ?? file.name.split('.').pop()?.toLowerCase()
    if (!rawType || !ALLOWED_FILE_TYPES.has(rawType)) {
      return c.json(
        { error: `File type not allowed: ${file.name}. Accepted: JPG, PNG, HEIC, PDF` },
        400,
      )
    }

    // Validate size
    if (file.size > MAX_FILE_SIZE) {
      return c.json({ error: `File too large: ${file.name}. Maximum 10 MB per file.` }, 400)
    }

    const expenseId = crypto.randomUUID()
    const r2Key = `${session.tid}/expenses/${expenseId}/${file.name}`

    // Upload to R2
    const fileBytes = await file.arrayBuffer()
    await c.env.STORAGE.put(r2Key, fileBytes, {
      httpMetadata: { contentType: file.type },
    })

    // Create expense record
    const expense = await createExpense(db, session.tid, {
      tenantId: session.tid,
      createdBy: session.sub,
      r2Key,
      fileName: file.name,
      fileType: rawType as 'pdf' | 'jpg' | 'png' | 'heic',
      fileSizeBytes: file.size,
      source: 'upload',
      status: 'PENDING',
    })

    // Emit uploaded webhook
    await emitExpenseWebhook(c.env, session.tid, 'expense.uploaded', {
      expenseId: expense.id,
      fileName: file.name,
      source: 'upload',
    })

    // Enqueue processing job
    const job: ExpenseProcessJob = {
      type: 'expense.process',
      tenantId: session.tid,
      expenseId: expense.id,
      tier: session.tier ?? 'freelancer',
    }
    await c.env.EXPENSE_QUEUE.send(job)

    created.push(expense)
  }

  return c.json({ items: created }, 201)
})

// ── POST /api/expenses/per-diem ────────────────────────────────────────────────

expenseRoutes.post('/per-diem', requirePermission('expenses:write'), async (c) => {
  const session = c.get('session')
  if (!session || session.type !== 'user' || !session.tid) {
    return c.json({ error: 'Unauthorized' }, 401)
  }

  const parsed = perDiemSchema.safeParse(await c.req.json())
  if (!parsed.success) {
    return c.json({ error: 'Invalid request', issues: parsed.error.issues }, 400)
  }

  const db = c.get('db')

  if (!(await assertTenantOwnsProject(db, session.tid, parsed.data.project_id))) {
    return c.json(invalidTenantReferenceBody('project_id'), 400)
  }

  const result = await createPerDiemExpense(db, session.tid, session.sub, parsed.data, session.tier ?? 'freelancer')

  return c.json({ expenseId: result.expenseId }, 201)
})

// ── POST /api/expenses/:id/reject ─────────────────────────────────────────────

expenseRoutes.post('/:id/reject', requirePermission('expenses:approve'), async (c) => {
  const session = c.get('session')
  if (!session || session.type !== 'user' || !session.tid) {
    return c.json({ error: 'Unauthorized' }, 401)
  }

  const approver = await isExpenseApprover({
    tenantId: session.tid,
    userId: session.sub,
    db: c.get('db'),
  })
  if (!approver) {
    return c.json({ error: 'Not an authorized approver' }, 403)
  }

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

  try {
    const updated = await rejectExpense({
      tenantId: session.tid,
      expenseId: c.req.param('id'),
      approverUserId: session.sub,
      reason: parsed.data.reason,
      db: c.get('db'),
    })

    await pushOverWebSocket(
      session.sub,
      session.tid,
      { type: 'expense.updated', titleKey: 'expense.updated', params: { expenseId: c.req.param('id') } },
      c.env,
    ).catch(() => { /* best-effort */ })

    return c.json(updated, 200)
  } catch (err) {
    return c.json({ error: (err as Error).message }, 409)
  }
})

// ── GET /api/expenses/:id ──────────────────────────────────────────────────────

expenseRoutes.get('/:id', requirePermission('expenses:read'), 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')
  const result = await getExpense(db, session.tid, c.req.param('id'))
  if (!result) return c.json({ error: 'Not found' }, 404)

  return c.json(result, 200)
})

// ── PATCH /api/expenses/:id ────────────────────────────────────────────────────

expenseRoutes.patch('/:id', requirePermission('expenses:write'), async (c) => {
  const session = c.get('session')
  if (!session || session.type !== 'user' || !session.tid) {
    return c.json({ error: 'Unauthorized' }, 401)
  }

  const parsed = patchExpenseSchema.safeParse(await c.req.json())
  if (!parsed.success) {
    return c.json({ error: 'Invalid request', issues: parsed.error.issues }, 400)
  }

  const db = c.get('db')
  try {
    const { business_percent, ...rest } = parsed.data
    const expense = await updateExpense(
      db,
      session.tid,
      session.sub,
      c.req.param('id'),
      {
        ...rest,
        businessPercent: business_percent,
      },
    )
    return c.json({ expense }, 200)
  } catch (err) {
    if (err instanceof Error && err.message === 'Expense not found') {
      return c.json({ error: 'Not found' }, 404)
    }
    if (err instanceof Error && err.message === 'Expense is not editable in current status') {
      return c.json({ error: err.message }, 422)
    }
    throw err
  }
})

// ── DELETE /api/expenses/:id ───────────────────────────────────────────────────

expenseRoutes.delete('/:id', requirePermission('expenses:delete'), 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')
  await softDeleteExpense(db, session.tid, c.req.param('id'))
  return c.json({ ok: true }, 200)
})

// ── POST /api/expenses/:id/evaluate ───────────────────────────────────────────

expenseRoutes.post('/:id/evaluate', requirePermission('expenses:write'), 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')
  const id = c.req.param('id')

  const row = await getExpenseById(db, session.tid, id)
  if (!row) {
    return c.json({ error: 'Not found' }, 404)
  }

  const expense = serializeExpense(row)
  const settings = await getExpenseSettings(db, session.tid)

  const ctx = { db, env: c.env }
  const evalResult = await evaluateDeductibility(
    ctx as Parameters<typeof evaluateDeductibility>[0],
    expense,
    settings.business_category,
  )

  await setExpenseStatus(db, session.tid, id, expense.status as ExpenseStatus, {
    expenseCategory: evalResult.expenseCategory,
    deductionPct: evalResult.deductionPct,
    deductionConfidence: String(evalResult.deductionConfidence),
    deductionReasoningHe: evalResult.reasoningHe,
    deductionReasoningEn: evalResult.reasoningEn,
    evaluatedAt: new Date(),
  })

  // Push realtime update
  await pushOverWebSocket(
    session.sub,
    session.tid,
    { type: 'expense.updated', titleKey: 'expense.updated', params: { expenseId: id } },
    c.env,
  ).catch(() => { /* best-effort */ })

  const updated = await getExpenseById(db, session.tid, id)
  if (!updated) return c.json({ error: 'Not found' }, 404)

  return c.json({ expense: serializeExpense(updated) }, 200)
})

// ── GET /api/expenses/:id/file ─────────────────────────────────────────────────

expenseRoutes.get('/:id/file', requirePermission('expenses:read'), 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')
  const id = c.req.param('id')

  const row = await getExpenseById(db, session.tid, id)
  if (!row) {
    return c.json({ error: 'Not found' }, 404)
  }

  if (!row.r2Key) {
    return c.json({ error: 'Not found' }, 404)
  }

  // Short-lived (30-min) HS256-signed token. CF Workers R2 has no native presigned
  // URLs, so /api/files/:token re-verifies the signature + exp + tenant before
  // streaming the object. The token is tamper-proof — a client cannot forge key/tid.
  const signedKey = await signSignedToken(
    { key: row.r2Key, tid: session.tid },
    c.env.FILE_SIGNING_KEY,
    30 * 60,
  )

  const url = new URL(c.req.url)
  const fileUrl = `${url.protocol}//${url.host}/api/files/${signedKey}`

  return c.json({ url: fileUrl }, 200)
})
