/**
 * Invoice routes — invoices-core.
 * Mounted at /api/invoices in apps/zync-api/src/routes/index.ts (via manifest).
 *
 * All routes behind authMiddleware + requireModuleEnabled('invoices').
 *
 * Routes:
 *   GET    /                        → list (paginated, filterable)
 *   POST   /                        → create draft
 *   GET    /auto-issue              → internal: create + issue in one tx
 *   GET    /unbilled-time           → unbilled time entries for invoice form
 *   GET    /:id                     → detail + lines
 *   PATCH  /:id                     → edit draft
 *   DELETE /:id                     → delete draft only
 *   POST   /:id/send                → DRAFT → SENT (assigns proforma number)
 *   POST   /:id/approve             → SENT → APPROVED
 *   POST   /:id/reject              → SENT → REJECTED
 *   POST   /:id/issue-tax           → APPROVED → TAX_ISSUED (assigns invoice number)
 *   POST   /:id/record-payment      → TAX_ISSUED → PAID / PARTIALLY_PAID
 *   POST   /:id/credit-note         → create DRAFT credit note (invoice-credit-notes)
 *   GET    /:id/credit-notes        → list credit notes + netInvoiced (invoice-credit-notes)
 *   POST   /:creditNoteId/issue     → issue DRAFT credit note → TAX_ISSUED (invoice-credit-notes)
 *   POST   /:id/void                → DRAFT|SENT → VOID
 *   GET    /:id/html                → render invoice HTML
 *   GET    /:id/email-history       → email event history (invoice-email-history)
 */
import { Hono, type Context } from 'hono'
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 {
  listInvoices,
  getInvoice,
  getInvoiceWithLines,
  createInvoice,
  updateInvoice,
  deleteDraftInvoice,
  sendInvoice,
  approveInvoiceEnhanced,
  rejectInvoiceEnhanced,
  approveInvoiceEnhancedSchema,
  rejectInvoiceEnhancedSchema,
  bulkApproveInvoices,
  bulkApproveSchema,
  issueTaxInvoiceTx,
  recordInvoicePayment,
  getInvoiceAdapterSettings,
  getAdapterConfig,
  voidInvoiceTx,
  autoIssueInvoice,
  renderInvoiceHtml,
  ConflictError,
  NotFoundError,
  listInvoicesSchema,
  createInvoiceSchema,
  updateInvoiceSchema,
  voidInvoiceSchema,
  recordPaymentSchema,
  autoIssueSchema,
  assertDeletableCreditNote,
  CreditNoteError,
  insertEmailEvent,
  getTenantReminderSettings,
  parseReminderSchedule,
  computeNextReminder,
  setInvoiceReminderState,
  assertTenantOwnsCustomer,
  assertTenantOwnsProject,
  invalidTenantReferenceBody,
  InvalidTenantReferenceError,
  listUnbilledTimeEntries,
  getProjectById,
  getExpenseForInvoicing,
  addExpenseToInvoiceLine,
  createInvoiceFromTemplate,
  getInvoiceSettings,
} from '@zync/db/queries'
import type { Env, HourlyBillingConfig } from '@zync/types'
import { creditNoteRoutes } from './credit-notes'
import { emailHistoryRoutes } from './email-history'
import { z } from 'zod'
import { sendEmail } from '@zync/notifications'
import { resolveCustomerPrimaryEmail } from '../../services/payment-link'
import { pushInvoiceToAdapter } from '../invoice-adapters/router'
import {
  fetchInvoiceHtmlSnapshot,
  generateAndStoreInvoiceSnapshot,
  loadInvoiceRenderIdentity,
} from '../../lib/invoice-snapshot'
import {
  applyFieldPermissions,
  applyFieldPermissionsToPage,
  attachReadOnlyMeta,
  getFieldPermissionContext,
} from '../field-permissions/enforcement'
import { postIssue, reverseMovement } from '../../integrations/platform/inventory'

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

/** Optional body for /:id/send — allows overriding recipients, subject, message */
export const sendInvoiceBodySchema = z.object({
  to: z.array(z.string().email()).min(1).max(20).optional(),
  subject: z.string().max(255).optional(),
  message: z.string().max(5000).optional(),
})

const unbilledTimeQuerySchema = z.object({
  projectId: z.string().uuid().optional(),
  cursor: z.string().optional(),
  limit: z.coerce.number().int().min(1).max(200).optional(),
})

const fromExpenseBodySchema = z.object({
  expense_id: z.string().uuid(),
  description: z.string().min(1).max(500).optional(),
  amount: z.number().nonnegative().optional(),
})

const createInvoiceFromTemplateRouteSchema = z.object({
  customerId: z.string().uuid(),
})

function resolveProjectHourlyRate(
  project: Awaited<ReturnType<typeof getProjectById>>,
): number | null {
  if (!project || project.billing_type !== 'hourly' || !project.billing_config) {
    return null
  }
  const cfg = project.billing_config as HourlyBillingConfig
  return typeof cfg.rate_per_hour === 'number' ? cfg.rate_per_hour : null
}

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

export const invoiceRoutes = new Hono<AppEnv>()

invoiceRoutes.use('*', authMiddleware)
invoiceRoutes.use('*', requireModuleEnabled('invoices'))
invoiceRoutes.use('*', bumpFinancialsVersionOnWrite)

// Mount credit-note sub-routes (wave-12: invoice-credit-notes)
invoiceRoutes.route('/', creditNoteRoutes)

// Mount email-history sub-routes (wave-13: invoice-email-history)
// Must be BEFORE /:id to avoid id capture on sub-paths
invoiceRoutes.route('/', emailHistoryRoutes)

// Helper: extract tenant country code from session (falls back to 'IL')
function getTenantCountry(c: Context<AppEnv>): string {
  const session = c.get('session')
  if (session && 'countryCode' in session && typeof session.countryCode === 'string') {
    return session.countryCode
  }
  return 'IL'
}

function getExecutionCtxOrNull(
  c: Context<AppEnv>,
): { waitUntil(promise: Promise<unknown>): void } | undefined {
  try {
    return c.executionCtx
  } catch {
    return undefined
  }
}

// ── GET /api/invoices ─────────────────────────────────────────────────────────

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

  const query = c.req.query()
  const parsed = listInvoicesSchema.safeParse({
    cursor: query.cursor,
    limit: query.limit,
    status: query.status,
    customerId: query.customerId,
    projectId: query.projectId,
    dateFrom: query.dateFrom,
    dateTo: query.dateTo,
    recurringTemplateId: query.recurring_template_id ?? query.recurringTemplateId,
    jobId: query.job_id ?? query.jobId,
    isTemplate: query.is_template ?? query.isTemplate,
    source: query.source,
  })

  if (!parsed.success) {
    return c.json({ error: 'Validation failed', issues: parsed.error.issues }, 400)
  }

  const db = c.get('db')
  const result = await listInvoices(db, session.tid, parsed.data)
  const { role, rules } = await getFieldPermissionContext(db, session)
  return c.json(applyFieldPermissionsToPage(result, 'invoice', role, rules), 200)
})

invoiceRoutes.post('/from-template/:id', requirePermission('invoices:write'), async (c) => {
  const session = c.get('session')
  if (!session || session.type !== 'user' || !session.tid) {
    return c.json({ error: 'Unauthorized' }, 401)
  }

  const templateId = c.req.param('id')
  const body = await c.req.json().catch(() => null)
  const parsed = createInvoiceFromTemplateRouteSchema.safeParse({
    customerId:
      body && typeof body === 'object'
        ? (body as { customerId?: unknown; customer_id?: unknown }).customerId ??
          (body as { customerId?: unknown; customer_id?: unknown }).customer_id
        : undefined,
  })
  if (!parsed.success) {
    return c.json({ error: 'Validation failed', issues: parsed.error.issues }, 400)
  }

  try {
    const db = c.get('db')
    const result = await createInvoiceFromTemplate(
      db,
      session.tid,
      session.sub,
      templateId,
      parsed.data,
    )
    return c.json(result, 201)
  } catch (err) {
    if (err instanceof Error && err.message.includes('not found')) {
      return c.json({ error: 'Template not found' }, 404)
    }
    throw err
  }
})

// ── POST /api/invoices ────────────────────────────────────────────────────────

invoiceRoutes.post('/', requirePermission('invoices:write'), 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 = createInvoiceSchema.safeParse(body)
  if (!parsed.success) {
    return c.json({ error: 'Validation failed', issues: parsed.error.issues }, 400)
  }

  const db = c.get('db')
  const countryCode = getTenantCountry(c)
  try {
    const invoice = await createInvoice(db, session.tid, session.sub, parsed.data, countryCode)
    return c.json(invoice, 201)
  } catch (err) {
    if (err instanceof InvalidTenantReferenceError) {
      return c.json(invalidTenantReferenceBody(err.field), 400)
    }
    if (err instanceof ConflictError) {
      return c.json({ error: err.message }, 409)
    }
    throw err
  }
})

// ── POST /api/invoices/auto-issue ─────────────────────────────────────────────
// Internal endpoint for billing module. Must be before /:id routes.

invoiceRoutes.post('/auto-issue', requirePermission('invoices:write'), 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 = autoIssueSchema.safeParse(body)
  if (!parsed.success) {
    return c.json({ error: 'Validation failed', issues: parsed.error.issues }, 400)
  }

  const db = c.get('db')
  if (!(await assertTenantOwnsCustomer(db, session.tid, parsed.data.customerId))) {
    return c.json(invalidTenantReferenceBody('customerId'), 400)
  }
  if (!(await assertTenantOwnsProject(db, session.tid, parsed.data.projectId ?? null))) {
    return c.json(invalidTenantReferenceBody('projectId'), 400)
  }
  const countryCode = getTenantCountry(c)
  const result = await autoIssueInvoice(db, session.tid, session.sub, parsed.data, countryCode)
  try {
    await generateAndStoreInvoiceSnapshot(
      { db, env: c.env, executionCtx: c.executionCtx },
      session.tid,
      result.invoiceId,
    )
  } catch (err) {
    console.error('[auto-issue] snapshot failed (invoice issued; retry issue-tax to backfill):', err)
  }
  return c.json(result, 201)
})

// ── GET /api/invoices/unbilled-time ──────────────────────────────────────────
// Invoice-form data source: unbilled billable time entries (invoice_id IS NULL).

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

  const query = c.req.query()
  const parsed = unbilledTimeQuerySchema.safeParse({
    projectId: query.projectId,
    cursor: query.cursor,
    limit: query.limit,
  })
  if (!parsed.success) {
    return c.json({ error: 'Validation failed', issues: parsed.error.issues }, 400)
  }

  const db = c.get('db')
  const { projectId, cursor, limit } = parsed.data

  if (projectId && !(await assertTenantOwnsProject(db, session.tid, projectId))) {
    return c.json(invalidTenantReferenceBody('projectId'), 400)
  }

  const result = await listUnbilledTimeEntries(db, {
    tenantId: session.tid,
    projectId,
    cursor,
    limit,
  })

  let hourlyRate: number | null = null
  if (projectId) {
    const project = await getProjectById(db, session.tid, projectId)
    hourlyRate = resolveProjectHourlyRate(project)
  }

  const items = result.entries.map((entry) => {
    const hoursTotal = entry.durationSeconds / 3600
    const hours = Math.floor(entry.durationSeconds / 3600)
    const minutes = Math.floor((entry.durationSeconds % 3600) / 60)
    const hoursDecimal = Math.round(hoursTotal * 100) / 100
    const rate = hourlyRate
    const amount =
      rate != null ? Math.round(hoursDecimal * rate * 100) / 100 : null

    return {
      id: entry.id,
      date: entry.startedAt.slice(0, 10),
      description: entry.description ?? '',
      hours,
      minutes,
      hoursDecimal,
      durationSeconds: entry.durationSeconds,
      startedAt: entry.startedAt,
      stoppedAt: entry.stoppedAt,
      userId: entry.userId,
      approvalStatus: entry.approvalStatus,
      rate,
      amount,
    }
  })

  return c.json({ items, nextCursor: result.nextCursor, hourlyRate }, 200)
})

// ── POST /api/invoices/bulk-approve ───────────────────────────────────────────

invoiceRoutes.post('/bulk-approve', requirePermission('invoices:write'), 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 = bulkApproveSchema.safeParse(body)
  if (!parsed.success) {
    return c.json({ error: 'Validation failed', issues: parsed.error.issues }, 400)
  }

  const db = c.get('db')
  const result = await bulkApproveInvoices(db, session.tid, session.sub, parsed.data.ids)
  return c.json(result, 200)
})

invoiceRoutes.post('/:id/lines/from-expense', requirePermission('invoices:write'), async (c) => {
  const session = c.get('session')
  if (!session || session.type !== 'user' || !session.tid) {
    return c.json({ error: 'Unauthorized' }, 401)
  }
  if (!session.permissions?.includes('expenses:read')) {
    return c.json({ error: 'Forbidden' }, 403)
  }

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

  const invoiceId = c.req.param('id')
  const db = c.get('db')
  const invoice = await getInvoice(db, session.tid, invoiceId)
  if (!invoice) {
    return c.json({ error: 'Invoice not found' }, 404)
  }
  if (invoice.status !== 'DRAFT') {
    return c.json({ error: 'Invoice is not editable' }, 409)
  }
  const expense = await getExpenseForInvoicing(db, session.tid, parsed.data.expense_id)

  if (!expense) {
    return c.json({ error: 'Expense not found' }, 404)
  }
  if (expense.status !== 'COMPLETED' || expense.billedAt || !expense.projectId) {
    return c.json({ error: 'Expense is not billable' }, 409)
  }
  if (expense.customerId !== invoice.customerId) {
    return c.json({ error: 'Expense customer does not match invoice customer' }, 422)
  }

  const tenantId = session.tid
  const fallbackDescription =
    [expense.vendorName || expense.description, expense.expenseDate].filter(Boolean).join(' - ')
    || expense.description
  const description = parsed.data.description ?? fallbackDescription
  const amount = (parsed.data.amount ?? parseFloat(expense.amount || '0')).toFixed(2)

  try {
    const result = await db.transaction((tx) => addExpenseToInvoiceLine(tx, {
      tenantId,
      invoiceId,
      expenseId: expense.id,
      description,
      amount,
    }))

    const updatedInvoice = await getInvoiceWithLines(db, session.tid, invoiceId)
    if (!updatedInvoice) {
      return c.json({ error: 'Invoice not found' }, 404)
    }
    const line = updatedInvoice.lines.find((entry) => entry.id === result.invoiceLineId)
    if (!line) {
      return c.json({ error: 'Invoice line not found after insert' }, 500)
    }
    return c.json({ line, invoice: updatedInvoice }, 201)
  } catch (err) {
    if (err instanceof ConflictError) {
      return c.json({ error: err.message }, 409)
    }
    if (err instanceof NotFoundError) {
      return c.json({ error: err.message }, 404)
    }
    throw err
  }
})

// ── GET /api/invoices/:id ─────────────────────────────────────────────────────

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

  const id = c.req.param('id')
  const db = c.get('db')
  const invoice = await getInvoiceWithLines(db, session.tid, id)
  if (!invoice) {
    return c.json({ error: 'Not found' }, 404)
  }
  const { role, rules } = await getFieldPermissionContext(db, session)
  const filtered = applyFieldPermissions(invoice, 'invoice', role, rules)
  return c.json(attachReadOnlyMeta(filtered.data, filtered.readOnly), 200)
})

// ── PATCH /api/invoices/:id ───────────────────────────────────────────────────

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

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

  const db = c.get('db')
  if (parsed.data.customerId !== undefined) {
    if (!(await assertTenantOwnsCustomer(db, session.tid, parsed.data.customerId))) {
      return c.json(invalidTenantReferenceBody('customerId'), 400)
    }
  }
  if (parsed.data.projectId !== undefined) {
    if (!(await assertTenantOwnsProject(db, session.tid, parsed.data.projectId))) {
      return c.json(invalidTenantReferenceBody('projectId'), 400)
    }
  }

  try {
    const invoice = await updateInvoice(db, session.tid, id, session.sub, parsed.data)
    return c.json(invoice, 200)
  } catch (err) {
    if (err instanceof Error && err.message.includes('not found')) {
      return c.json({ error: 'Not found' }, 404)
    }
    if (err instanceof Error && err.message.includes('Only DRAFT')) {
      return c.json({ error: err.message }, 409)
    }
    throw err
  }
})

// ── DELETE /api/invoices/:id ──────────────────────────────────────────────────
// For credit notes (source='credit_note'): only DRAFT credit notes may be deleted.
// Issued credit notes (TAX_ISSUED/PAID) return 422.

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

  const id = c.req.param('id')
  const db = c.get('db')
  try {
    // For credit notes, guard against deleting an issued credit note
    const existing = await getInvoice(db, session.tid, id)
    if (!existing) {
      return c.json({ error: 'Not found' }, 404)
    }
    assertDeletableCreditNote(existing)
    await deleteDraftInvoice(db, session.tid, id, session.sub)
    return new Response(null, { status: 204 })
  } catch (err) {
    if (err instanceof NotFoundError || (err instanceof Error && err.message.includes('not found'))) {
      return c.json({ error: 'Not found' }, 404)
    }
    if (err instanceof CreditNoteError) {
      return c.json({ error: { code: err.code, message: err.message } }, 422)
    }
    if (err instanceof ConflictError || (err instanceof Error && err.message.includes('Only DRAFT'))) {
      return c.json({ error: err.message }, 409)
    }
    throw err
  }
})

// ── POST /api/invoices/:id/send ───────────────────────────────────────────────

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

  const id = c.req.param('id')
  const today = new Date().toISOString().slice(0, 10)
  const db = c.get('db')
  const countryCode = getTenantCountry(c)

  // Optional body: to[], subject, message — for send-again flow
  const rawBody = await c.req.json().catch(() => null)
  const parsedBody = sendInvoiceBodySchema.safeParse(rawBody ?? {})
  const sendBody = parsedBody.success ? parsedBody.data : {}

  try {
    const invoice = await sendInvoice(db, session.tid, id, session.sub, countryCode, today)

    // invoice-payment-reminders (wave-13): initialize reminder state on SENT transition
    try {
      const tenantReminderSettings = await getTenantReminderSettings(db, session.tid)
      if (tenantReminderSettings.enabled && invoice.dueDate) {
        const schedule = parseReminderSchedule(tenantReminderSettings.schedule)
        const dueDate = new Date(invoice.dueDate)
        const next = computeNextReminder(dueDate, schedule, null)
        await setInvoiceReminderState(db, invoice.id, session.tid, next)
      }
    } catch {
      // Non-fatal: reminder init failure does not abort invoice send
    }

    // invoice-payment-link-generation (wave-12):
    // Attach a stateless payment link only when the tenant explicitly allows it.
    let paymentLinkUrl: string | undefined
    try {
      const { tenantHasActiveGateway } = await import('../../services/payment-link')
      const { signInvoicePaymentToken, buildPaymentLinkUrl } = await import('@zync/payments')
      const [hasGateway, invoicingSettings] = await Promise.all([
        tenantHasActiveGateway(db, session.tid),
        getInvoiceSettings(db, session.tid),
      ])
      if (hasGateway && invoicingSettings.invoice_show_payment_link) {
        const secret = c.env.INVOICE_PAYMENT_LINK_KEY
        const token = await signInvoicePaymentToken(invoice.id, session.tid, secret)
        const baseUrl = c.env.APP_BASE_URL ?? 'https://app.zync.is'
        paymentLinkUrl = buildPaymentLinkUrl(token, baseUrl)
      }
    } catch {
      // Non-fatal: proceed without link if signing fails
    }

    // invoice-email-history (wave-13): send email to recipients and record events
    // Resolve recipients: body.to overrides → customer primary email → skip silently
    let recipients: string[] = sendBody.to ?? []
    if (recipients.length === 0) {
      try {
        const primaryEmail = await resolveCustomerPrimaryEmail(db, session.tid, invoice.customerId ?? '')
        if (primaryEmail) recipients = [primaryEmail]
      } catch {
        // Non-fatal: no recipient found, skip email send
      }
    }

    const proformaLabel = invoice.proformaNumber ?? invoice.id
    const defaultSubject = sendBody.subject ?? `Invoice ${proformaLabel}`
    const renderIdentity = await loadInvoiceRenderIdentity(db, session.tid, invoice.customerId)

    for (const toAddress of recipients) {
      try {
        const actionButtons = paymentLinkUrl
          ? `<a href="${paymentLinkUrl}" style="display:inline-block;padding:12px 24px;background:oklch(39% 0.18 302);color:oklch(100% 0 0);text-decoration:none;border-radius:4px;font-size:16px;">View Invoice</a>`
          : ''
        const result = await sendEmail(
          {
            to: toAddress,
            templateKey: 'invoice-sent',
            vars: {
              subject: defaultSubject,
              title: `Invoice ${proformaLabel}`,
              body: sendBody.message ?? '',
              actionButtons,
            },
            locale: renderIdentity.locale,
            tags: { invoice_id: invoice.id },
          },
          c.env,
        )
        await insertEmailEvent(db, {
          tenantId: session.tid,
          invoiceId: invoice.id,
          sentBy: session.sub,
          toAddress,
          eventType: 'sent',
          metadata: { resend_id: result.id },
        })
      } catch (emailErr) {
        // Non-fatal per-recipient: record failure event and continue
        await insertEmailEvent(db, {
          tenantId: session.tid,
          invoiceId: invoice.id,
          sentBy: session.sub,
          toAddress,
          eventType: 'failed',
          metadata: { error: emailErr instanceof Error ? emailErr.message : String(emailErr) },
        }).catch(() => {
          // Swallow insertEmailEvent failures — never abort the send response
        })
      }
    }

    return c.json({ ...invoice, paymentLinkUrl: paymentLinkUrl ?? null }, 200)
  } catch (err) {
    if (err instanceof Error && err.message.includes('not found')) {
      return c.json({ error: 'Not found' }, 404)
    }
    if (err instanceof Error && err.message.includes('Cannot send')) {
      return c.json({ error: err.message }, 409)
    }
    throw err
  }
})

// ── POST /api/invoices/:id/approve ────────────────────────────────────────────

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

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

  try {
    await approveInvoiceEnhanced(db, session.tid, id, session.sub, parsed.data)
    const invoice = await getInvoiceWithLines(db, session.tid, id)
    if (!invoice) {
      return c.json({ error: 'Not found' }, 404)
    }
    return c.json(invoice, 200)
  } catch (err) {
    if (err instanceof Error && err.message.includes('not found')) {
      return c.json({ error: 'Not found' }, 404)
    }
    if (err instanceof Error && err.message.includes('Cannot approve')) {
      return c.json({ error: err.message }, 409)
    }
    throw err
  }
})

// ── POST /api/invoices/:id/reject ─────────────────────────────────────────────

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

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

  const db = c.get('db')

  try {
    await rejectInvoiceEnhanced(db, session.tid, id, session.sub, parsed.data)
    const invoice = await getInvoiceWithLines(db, session.tid, id)
    if (!invoice) {
      return c.json({ error: 'Not found' }, 404)
    }

    if (parsed.data.notifyCustomer) {
      try {
        const primaryEmail = await resolveCustomerPrimaryEmail(
          db,
          session.tid,
          invoice.customerId ?? '',
        )
        if (primaryEmail) {
          const proformaLabel = invoice.proformaNumber ?? invoice.id
          const defaultSubject = `Invoice ${proformaLabel} rejected`
          const renderIdentity = await loadInvoiceRenderIdentity(db, session.tid, invoice.customerId)
          try {
            const result = await sendEmail(
              {
                to: primaryEmail,
                templateKey: 'invoice-sent',
                vars: {
                  subject: defaultSubject,
                  title: defaultSubject,
                  body: parsed.data.reason,
                  actionButtons: '',
                },
                locale: renderIdentity.locale,
                tags: { invoice_id: invoice.id },
              },
              c.env,
            )
            await insertEmailEvent(db, {
              tenantId: session.tid,
              invoiceId: invoice.id,
              sentBy: session.sub,
              toAddress: primaryEmail,
              eventType: 'sent',
              metadata: { resend_id: result.id, kind: 'rejection_notice' },
            })
          } catch (emailErr) {
            await insertEmailEvent(db, {
              tenantId: session.tid,
              invoiceId: invoice.id,
              sentBy: session.sub,
              toAddress: primaryEmail,
              eventType: 'failed',
              metadata: {
                error: emailErr instanceof Error ? emailErr.message : String(emailErr),
                kind: 'rejection_notice',
              },
            }).catch(() => {
              // Swallow insertEmailEvent failures — never abort the reject response
            })
          }
        }
      } catch {
        // Non-fatal: email failure must not fail the reject
      }
    }

    return c.json(invoice, 200)
  } catch (err) {
    if (err instanceof Error && err.message.includes('not found')) {
      return c.json({ error: 'Not found' }, 404)
    }
    if (err instanceof Error && err.message.includes('Cannot reject')) {
      return c.json({ error: err.message }, 409)
    }
    throw err
  }
})

// ── POST /api/invoices/:id/issue-tax ──────────────────────────────────────────

invoiceRoutes.post('/:id/issue-tax', requirePermission('invoices:write'), async (c) => {
  const session = c.get('session')
  if (!session || session.type !== 'user' || !session.tid) {
    return c.json({ error: 'Unauthorized' }, 401)
  }

  const id = c.req.param('id')
  const today = new Date().toISOString().slice(0, 10)
  const db = c.get('db')
  const countryCode = getTenantCountry(c)
  const tenantId = session.tid

  try {
    let invoice: Awaited<ReturnType<typeof issueTaxInvoiceTx>>
    try {
      invoice = await db.transaction(async (tx) => {
        const issued = await issueTaxInvoiceTx(
          tx,
          tenantId,
          id,
          session.sub,
          today,
          countryCode,
        )
        await postIssue(tx, { tenantId, invoiceId: id })
        return issued
      })
    } catch (issueErr) {
      if (
        issueErr instanceof Error &&
        issueErr.message.includes('Cannot issue tax invoice in status TAX_ISSUED')
      ) {
        const existing = await getInvoice(db, session.tid, id)
        if (!existing) {
          return c.json({ error: 'Not found' }, 404)
        }
        if (existing.htmlSnapshotUrl) {
          return c.json({ error: issueErr.message }, 409)
        }
        if (existing.status !== 'TAX_ISSUED' || !existing.invoiceNumber) {
          return c.json({ error: issueErr.message }, 409)
        }
        invoice = existing
      } else {
        throw issueErr
      }
    }

    try {
      const snapshotted = await generateAndStoreInvoiceSnapshot(
        { db, env: c.env, executionCtx: getExecutionCtxOrNull(c) },
        session.tid,
        id,
      )
      if (snapshotted) {
        invoice = snapshotted
      }
    } catch (snapshotErr) {
      console.error(
        '[issue-tax] snapshot failed (invoice issued; retry issue-tax to backfill):',
        snapshotErr,
      )
    }

    // invoice-payment-reminders (wave-13): ensure reminder state is live on TAX_ISSUED
    try {
      const tenantReminderSettings = await getTenantReminderSettings(db, session.tid)
      if (tenantReminderSettings.enabled && invoice.dueDate) {
        const schedule = parseReminderSchedule(tenantReminderSettings.schedule)
        const dueDate = new Date(invoice.dueDate)
        const next = computeNextReminder(dueDate, schedule, null)
        await setInvoiceReminderState(db, invoice.id, session.tid, next)
      }
    } catch {
      // Non-fatal
    }

    try {
      const settings = await getInvoiceAdapterSettings(db, session.tid)
      if (settings.adapter && settings.autoSyncOnTaxIssue && c.env.QUEUE) {
        await c.env.QUEUE.send({ type: 'invoice.push', invoiceId: id, tenantId: session.tid })
      }
    } catch (queueErr) {
      console.error('[issue-tax] invoice.push enqueue failed:', queueErr)
    }

    return c.json(invoice, 200)
  } catch (err) {
    if (err instanceof Error && err.message.includes('not found')) {
      return c.json({ error: 'Not found' }, 404)
    }
    if (err instanceof Error && err.message.includes('Cannot issue')) {
      return c.json({ error: err.message }, 409)
    }
    throw err
  }
})

// ── POST /api/invoices/:id/record-payment ─────────────────────────────────────

invoiceRoutes.post('/:id/record-payment', requirePermission('invoices:write'), async (c) => {
  const session = c.get('session')
  if (!session || session.type !== 'user' || !session.tid) {
    return c.json({ error: 'Unauthorized' }, 401)
  }

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

  const db = c.get('db')

  const userPaymentSources = new Set(['manual', 'bank_transfer', 'auto_billing'])
  const source = userPaymentSources.has(parsed.data.paymentMethod)
    ? (parsed.data.paymentMethod as 'manual' | 'bank_transfer' | 'auto_billing')
    : 'manual'
  const note = userPaymentSources.has(parsed.data.paymentMethod)
    ? null
    : parsed.data.paymentMethod

  try {
    await recordInvoicePayment(db, session.tid, id, session.sub, {
      amount: parsed.data.amount,
      paidAt: parsed.data.paymentDate,
      source,
      reference: parsed.data.reference ?? null,
      note,
    })

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

    // invoice-payment-reminders (wave-13): clear reminder on PAID
    if (invoice.status === 'PAID') {
      try {
        await setInvoiceReminderState(db, invoice.id, session.tid, null)
      } catch {
        // Non-fatal
      }
      try {
        const settings = await getInvoiceAdapterSettings(db, session.tid)
        if (settings.adapter && c.env.QUEUE) {
          await c.env.QUEUE.send({ type: 'invoice.payment_sync', invoiceId: id, tenantId: session.tid })
        }
      } catch (queueErr) {
        console.error('[record-payment] invoice.payment_sync enqueue failed:', queueErr)
      }
    }

    return c.json(invoice, 200)
  } catch (err) {
    if (err instanceof Error && err.message.includes('not found')) {
      return c.json({ error: 'Not found' }, 404)
    }
    if (err instanceof Error && err.message.includes('Cannot record')) {
      return c.json({ error: err.message }, 409)
    }
    throw err
  }
})

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

  const invoiceId = c.req.param('id')
  const db = c.get('db')
  const settings = await getInvoiceAdapterSettings(db, session.tid)
  if (!settings.adapter) {
    return c.json({ error: 'No adapter configured' }, 404)
  }

  const config = await getAdapterConfig(
    db,
    session.tid,
    settings.adapter,
    c.env.INTEGRATION_ENCRYPTION_KEY,
  )
  if (!config) {
    return c.json({ error: 'Adapter credentials not configured' }, 404)
  }

  const result = await pushInvoiceToAdapter({
    db,
    tenantId: session.tid,
    actorId: session.sub,
    provider: settings.adapter,
    invoiceId,
    config,
  })

  return c.json(result, 200)
})

// ── POST /api/invoices/:id/void ───────────────────────────────────────────────
// Privileged: requires OWNER or ADMIN role (checked via requirePermission)

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

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

    const db = c.get('db')
    const tenantId = session.tid

    try {
      const invoice = await db.transaction(async (tx) => {
        await reverseMovement(tx, { tenantId, invoiceId: id })
        return voidInvoiceTx(tx, tenantId, id, session.sub, parsed.data.reason)
      })

      // invoice-payment-reminders (wave-13): clear reminder on VOID
      try {
        await setInvoiceReminderState(db, invoice.id, session.tid, null)
      } catch {
        // Non-fatal
      }

      return c.json(invoice, 200)
    } catch (err) {
      if (err instanceof NotFoundError || (err instanceof Error && err.message.includes('not found'))) {
        return c.json({ error: 'Not found' }, 404)
      }
      if (err instanceof ConflictError) {
        return c.json({ error: err.message }, 409)
      }
      throw err
    }
  },
)

// ── GET /api/invoices/:id/html ────────────────────────────────────────────────
// Render invoice HTML for print-to-PDF.
// TAX_ISSUED serves R2 snapshot if available.

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

  const id = c.req.param('id')
  const db = c.get('db')
  const invoice = await getInvoiceWithLines(db, session.tid, id)

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

  const htmlResponseHeaders = {
    'Content-Type': 'text/html; charset=utf-8',
    'Content-Security-Policy':
      "default-src 'none'; style-src 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; img-src 'self' data:",
    'X-Content-Type-Options': 'nosniff',
  } as const

  // TAX_ISSUED: serve immutable R2 snapshot when available (key stored in htmlSnapshotUrl)
  if (invoice.status === 'TAX_ISSUED') {
    const r2 = c.env.INVOICE_SNAPSHOTS_BUCKET
    let snapshotKey = invoice.htmlSnapshotUrl

    if (snapshotKey) {
      const snapshotHtml = await fetchInvoiceHtmlSnapshot(r2, snapshotKey)
      if (snapshotHtml) {
        return new Response(snapshotHtml, { status: 200, headers: htmlResponseHeaders })
      }
    }

    // Backfill missing snapshot (e.g. issued before R2 storage fix)
    if (r2) {
      try {
        const snapshotted = await generateAndStoreInvoiceSnapshot(
          { db, env: c.env },
          session.tid,
          id,
        )
        snapshotKey = snapshotted?.htmlSnapshotUrl ?? snapshotKey
        if (snapshotKey) {
          const snapshotHtml = await fetchInvoiceHtmlSnapshot(r2, snapshotKey)
          if (snapshotHtml) {
            return new Response(snapshotHtml, { status: 200, headers: htmlResponseHeaders })
          }
        }
      } catch (backfillErr) {
        console.error('[invoices/html] snapshot backfill failed:', backfillErr)
      }
    }
  }

  // Live render (non-TAX_ISSUED, or snapshot unavailable)
  const identity = await loadInvoiceRenderIdentity(db, session.tid, invoice.customerId)
  const r2PublicUrl =
    (c.env as Env & { INVOICE_SNAPSHOTS_PUBLIC_URL?: string }).INVOICE_SNAPSHOTS_PUBLIC_URL?.trim() ||
    undefined
  const html = renderInvoiceHtml(invoice, {
    ...identity,
    r2PublicUrl,
  })

  return new Response(html, {
    status: 200,
    headers: htmlResponseHeaders,
  })
})
