/**
 * `expense.process` queue consumer — expenses-module.
 *
 * Processes an expense file through the OCR → tax evaluation pipeline.
 * Moves status: PENDING → PROCESSING → COMPLETED | NEEDS_REVIEW | FAILED.
 *
 * Terminal errors (AI parse failures): mark FAILED, no retry.
 * Transient errors (R2 fetch, network): throw to trigger Queue retry/backoff.
 */
import type { MessageBatch } from '@cloudflare/workers-types'
import type { Env } from '@zync/types'
import { OCR_CONFIDENCE_AUTO_COMPLETE } from '@zync/types'
import { createDb } from '@zync/db/queries'
import {
  getExpenseById,
  setExpenseStatus,
  getExpenseSettings,
} from '@zync/expenses'
import { runOcr, evaluateDeductibility, ExpenseProcessingError } from '@zync/expenses'
import { emitExpenseWebhook } from '@zync/expenses'
import { serializeExpense } from '@zync/expenses'
import { pushOverWebSocket } from '@zync/notifications'
import { resolveApprovalStatus } from '@zync/db/queries'
import { postExpenseReceipts } from '../integrations/platform/inventory'

export interface ExpenseProcessJob {
  type: 'expense.process'
  tenantId: string
  expenseId: string
  /** Tenant tier for AI quota checks */
  tier?: string
}

export async function handleExpenseProcess(
  batch: MessageBatch<ExpenseProcessJob>,
  env: Env,
): Promise<void> {
  for (const msg of batch.messages) {
    const job = msg.body

    if (!job?.tenantId || !job?.expenseId) {
      console.warn('[expense-process] Malformed job, discarding:', job)
      msg.ack()
      continue
    }

    const db = createDb(env)

    try {
      const expenseRow = await getExpenseById(db, job.tenantId, job.expenseId)
      if (!expenseRow) {
        console.warn('[expense-process] Expense not found:', job.expenseId)
        msg.ack()
        continue
      }

      // Mark PROCESSING
      await setExpenseStatus(db, job.tenantId, job.expenseId, 'PROCESSING', {
        processingStartedAt: new Date(),
      })

      const expense = serializeExpense(expenseRow)

      // Fetch file from R2
      let fileBytes: ArrayBuffer
      let mediaType: string
      try {
        const object = await env.STORAGE.get(expense.r2Key)
        if (!object) throw new Error(`R2 object not found: ${expense.r2Key}`)
        fileBytes = await object.arrayBuffer()
        mediaType = object.httpMetadata?.contentType ?? mimeFromFileType(expense.fileType)
      } catch (err) {
        // Transient: R2 unavailable or not yet consistent
        throw err
      }

      // Build AI context
      const ctx = { db, env }

      // Step 1: OCR
      let ocrResult
      try {
        ocrResult = await runOcr(ctx as Parameters<typeof runOcr>[0], expense, fileBytes, mediaType)
      } catch (err) {
        if (err instanceof ExpenseProcessingError && err.kind === 'terminal') {
          await setExpenseStatus(db, job.tenantId, job.expenseId, 'FAILED', {
            processingError: err.message,
            processedAt: new Date(),
          })
          await emitExpenseWebhook(env, job.tenantId, 'expense.failed', {
            expenseId: job.expenseId,
            error: err.message,
          })
          msg.ack()
          continue
        }
        throw err // transient: retry
      }

      // Persist OCR results
      await setExpenseStatus(db, job.tenantId, job.expenseId, 'PROCESSING', {
        vendorName: ocrResult.vendorName ?? undefined,
        vendorTaxId: ocrResult.vendorTaxId ?? undefined,
        invoiceNumber: ocrResult.invoiceNumber ?? undefined,
        invoiceTotal: ocrResult.invoiceTotal != null ? String(ocrResult.invoiceTotal) : undefined,
        vatAmount: ocrResult.vatAmount ?? undefined,
        vatDeductible: ocrResult.vatDeductible,
        currency: ocrResult.currency ?? 'ILS',
        allocationNumber: ocrResult.allocationNumber ?? undefined,
        rawOcrText: ocrResult.rawOcrText,
        ocrConfidence: String(ocrResult.ocrConfidence),
        expenseDate: ocrResult.expenseDate ?? undefined,
        amount: ocrResult.amount ?? undefined,
      })

      // Step 2: Tax evaluation
      const settings = await getExpenseSettings(db, job.tenantId)
      const updatedExpenseRow = await getExpenseById(db, job.tenantId, job.expenseId)
      if (!updatedExpenseRow) throw new Error('Expense disappeared after OCR')
      const updatedExpense = serializeExpense(updatedExpenseRow)

      let evalResult
      try {
        evalResult = await evaluateDeductibility(
          ctx as Parameters<typeof evaluateDeductibility>[0],
          updatedExpense,
          settings.business_category,
        )
      } catch (err) {
        if (err instanceof ExpenseProcessingError && err.kind === 'terminal') {
          await setExpenseStatus(db, job.tenantId, job.expenseId, 'FAILED', {
            processingError: err.message,
            processedAt: new Date(),
          })
          await emitExpenseWebhook(env, job.tenantId, 'expense.failed', {
            expenseId: job.expenseId,
            error: err.message,
          })
          msg.ack()
          continue
        }
        throw err // transient: retry
      }

      // Step 3: Determine final status — use canonical thresholds from @zync/types
      // confidence >= OCR_CONFIDENCE_AUTO_COMPLETE (0.85) → COMPLETED
      // anything else (including NULL OCR failure) → NEEDS_REVIEW
      // Foreign-currency rows with missing FX rate must never auto-complete.
      const canAutoComplete =
        !ocrResult.fxRateMissing &&
        ocrResult.ocrConfidence != null &&
        ocrResult.ocrConfidence >= OCR_CONFIDENCE_AUTO_COMPLETE
      const finalStatus = canAutoComplete ? 'COMPLETED' : 'NEEDS_REVIEW'

      const approvalAmount = ocrResult.amount ?? updatedExpense.amount
      const approvalStatus = await resolveApprovalStatus({
        tenantId: job.tenantId,
        amount: approvalAmount != null ? parseFloat(approvalAmount) : null,
        tier: job.tier ?? 'freelancer',
        db,
      })

      if (finalStatus === 'COMPLETED') {
        await postExpenseReceipts(db as never, {
          id: job.expenseId,
          tenantId: job.tenantId,
          amount: approvalAmount ?? updatedExpense.amount,
          vatAmount: ocrResult.vatAmount ?? updatedExpense.vatAmount,
          processedAt: new Date().toISOString(),
          updatedAt: updatedExpense.updatedAt,
          createdAt: updatedExpense.createdAt,
          sourceMetadata: updatedExpense.sourceMetadata,
        })
      }

      await setExpenseStatus(db, job.tenantId, job.expenseId, finalStatus, {
        expenseCategory: evalResult.expenseCategory,
        deductionPct: evalResult.deductionPct,
        deductionConfidence: String(evalResult.deductionConfidence),
        deductionReasoningHe: evalResult.reasoningHe,
        deductionReasoningEn: evalResult.reasoningEn,
        evaluatedAt: new Date(),
        processedAt: new Date(),
        approvalStatus,
        processingError: ocrResult.fxRateMissing ? 'fx_rate_missing' : undefined,
      })

      // Step 4: Emit webhook + realtime push
      await emitExpenseWebhook(env, job.tenantId, 'expense.processed', {
        expenseId: job.expenseId,
        status: finalStatus,
        category: evalResult.expenseCategory,
        deductionPct: evalResult.deductionPct,
        confidence: evalResult.deductionConfidence,
      })

      // Push realtime op (best-effort, non-blocking)
      await pushOverWebSocket(
        expenseRow.createdBy,
        job.tenantId,
        { type: 'expense.updated', titleKey: 'expense.updated', params: { expenseId: job.expenseId } },
        env,
      ).catch(() => { /* best-effort */ })

      msg.ack()
    } catch (err) {
      console.error('[expense-process] Transient failure, will retry:', err)
      // Re-read and mark failed if this is last retry (queue handles max retries)
      try {
        const db2 = createDb(env)
        await setExpenseStatus(db2, job.tenantId, job.expenseId, 'FAILED', {
          processingError: err instanceof Error ? err.message : String(err),
          processedAt: new Date(),
        })
        await emitExpenseWebhook(env, job.tenantId, 'expense.failed', {
          expenseId: job.expenseId,
          error: err instanceof Error ? err.message : String(err),
        })
      } catch {
        // Don't swallow the original error
      }
      msg.retry()
    }
  }
}

function mimeFromFileType(fileType: string): string {
  switch (fileType) {
    case 'jpg':  return 'image/jpeg'
    case 'png':  return 'image/png'
    case 'heic': return 'image/heic'
    case 'pdf':  return 'application/pdf'
    default:     return 'application/octet-stream'
  }
}
