/**
 * Receipts queries + services — invoice-receipt-document (wave-12, spec 179).
 *
 * Covers:
 *   - listReceipts / getReceiptWithLines
 *   - nextReceiptNumber (gapless sequence)
 *   - issueStandaloneReceipt — POST /api/invoices/:id/receipts (doc_type='receipt')
 *   - issueInvoiceReceipt — POST /api/invoices/:id/invoice-receipt (doc_type='invoice_receipt')
 *   - voidReceipt — POST /api/receipts/:id/void
 *   - serializeReceiptRow / serializeReceiptLine
 *
 * DB conventions:
 * - All mutations run in db.transaction() with paired auditLog insert.
 * - Money stays as string from NUMERIC column (never parseFloat until display).
 */

import { z } from 'zod'
import { eq, and, desc, lt, or, isNull, sql, count, inArray } from 'drizzle-orm'
import { alias } from 'drizzle-orm/pg-core'
import type { Db } from './index'
import type { DbTx } from '../client'
import { receipts, receiptPaymentLines } from '../schema/receipts'
import { invoicePayments } from '../schema/invoice-payments'
import { invoices } from '../schema/invoices'
import { customers } from '../schema/customers'
import { users } from '../schema/users'
import { recordInvoicePaymentTx, reverseInvoicePaymentTx } from './invoice-payments'
import { issueTaxInvoiceTx } from './invoices'
import { getExchangeRate } from './exchange-rates'
import { auditLog } from '../schema/audit-log'
import type {
  ReceiptRow,
  ReceiptPaymentLineRow,
} from '../schema/receipts'
import type {
  ReceiptObject,
  ReceiptPaymentLineObject,
  ReceiptDocType,
  ReceiptStatus,
  ReceiptPaymentMethod,
  ReceiptListPage,
} from '@zync/types'

// ── Errors ────────────────────────────────────────────────────────────────────

export class ReceiptNotFoundError extends Error {
  constructor(id: string) {
    super(`Receipt not found: ${id}`)
    this.name = 'ReceiptNotFoundError'
  }
}

export class ReceiptConflictError extends Error {
  constructor(message: string) {
    super(message)
    this.name = 'ReceiptConflictError'
  }
}

const RECEIPT_AMOUNT_TOLERANCE = 0.005

const RECEIPTABLE_INVOICE_STATUSES = new Set(['TAX_ISSUED', 'PARTIALLY_PAID', 'PAID'])

/**
 * Derive the allowed receipt amount for a standalone receipt on an invoice.
 * Pre-linked-only: sum of linked invoice_payment row amounts.
 * Fresh or mixed lines: sum of line amounts (amount received now), capped at outstanding.
 */
export function deriveStandaloneReceiptAmount(
  invoice: { total: string; amountPaid: string },
  lines: { amount: string; invoicePaymentId?: string }[],
  linkedPaymentAmounts: Map<string, string>,
): number {
  const outstanding =
    Math.round((parseFloat(invoice.total) - parseFloat(invoice.amountPaid)) * 100) / 100

  const paymentLinked = lines.filter((l) => l.invoicePaymentId)
  const freshLines = lines.filter((l) => !l.invoicePaymentId)

  if (paymentLinked.length > 0 && freshLines.length === 0) {
    let sum = 0
    for (const line of paymentLinked) {
      const paymentAmount = linkedPaymentAmounts.get(line.invoicePaymentId!)
      if (paymentAmount == null) {
        throw new ReceiptConflictError(`Invoice payment not found: ${line.invoicePaymentId}`)
      }
      sum += parseFloat(paymentAmount)
    }
    return Math.round(sum * 100) / 100
  }

  if (outstanding <= 0) {
    throw new ReceiptConflictError('Invoice has no outstanding balance for a receipt')
  }

  for (const line of paymentLinked) {
    if (!linkedPaymentAmounts.has(line.invoicePaymentId!)) {
      throw new ReceiptConflictError(`Invoice payment not found: ${line.invoicePaymentId}`)
    }
  }

  const linesSum =
    Math.round(lines.reduce((sum, l) => sum + parseFloat(l.amount), 0) * 100) / 100

  if (linesSum <= 0) {
    throw new ReceiptConflictError('Receipt amount must be greater than zero')
  }

  if (linesSum > outstanding + RECEIPT_AMOUNT_TOLERANCE) {
    throw new ReceiptConflictError(
      `Receipt amount ${linesSum.toFixed(2)} exceeds outstanding balance ${outstanding.toFixed(2)}`,
    )
  }

  return linesSum
}

export function assertStandaloneReceiptAmount(
  clientAmount: string,
  expectedAmount: number,
  lines: { amount: string }[],
): string {
  const client = parseFloat(clientAmount)
  if (Math.abs(client - expectedAmount) > RECEIPT_AMOUNT_TOLERANCE) {
    throw new ReceiptConflictError(
      `Receipt amount ${clientAmount} does not match expected ${expectedAmount.toFixed(2)}`,
    )
  }

  const linesSum = lines.reduce((sum, l) => sum + parseFloat(l.amount), 0)
  if (Math.abs(linesSum - expectedAmount) > RECEIPT_AMOUNT_TOLERANCE) {
    throw new ReceiptConflictError(
      `Payment lines sum (${linesSum.toFixed(2)}) does not match receipt amount (${expectedAmount.toFixed(2)})`,
    )
  }

  return expectedAmount.toFixed(2)
}

function receiptMethodToPaymentSource(
  method: ReceiptPaymentMethod,
): 'manual' | 'bank_transfer' {
  return method === 'bank_transfer' ? 'bank_transfer' : 'manual'
}

function computeAmountIls(amount: string, rate: string): string {
  return (Math.round(parseFloat(amount) * parseFloat(rate) * 100) / 100).toFixed(2)
}

/**
 * Snapshot ILS exchange rate + amount at receipt issue.
 * Uses caller-supplied rate when present; otherwise looks up tenant exchange_rates.
 */
async function resolveReceiptIlsSnapshot(
  tx: DbTx,
  tenantId: string,
  currency: string,
  amount: string,
  issueDate: string,
  suppliedRate?: string | null,
): Promise<{ ilsExchangeRate: string; amountIls: string }> {
  const normalizedCurrency = currency.toUpperCase()
  if (normalizedCurrency === 'ILS') {
    return { ilsExchangeRate: '1.0000', amountIls: amount }
  }

  const rate =
    suppliedRate ??
    (await getExchangeRate(tx, tenantId, normalizedCurrency, 'ILS', issueDate))?.rate

  if (!rate) {
    throw new ReceiptConflictError(
      `No exchange rate found for ${normalizedCurrency}/ILS on or before ${issueDate}`,
    )
  }

  return {
    ilsExchangeRate: rate,
    amountIls: computeAmountIls(amount, rate),
  }
}

// ── Zod schemas (re-exported for routes per require-zod-validation-in-routes) ──

export const issueReceiptLineSchema = z.object({
  method: z.enum(['cash', 'bank_transfer', 'cheque', 'credit_card', 'other']),
  amount: z.string().regex(/^\d+(\.\d{1,2})?$/),
  chequeNumber: z.string().optional(),
  chequeBank: z.string().optional(),
  chequeBranch: z.string().optional(),
  chequeAccount: z.string().optional(),
  chequeDueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
  cardLastFour: z.string().max(4).optional(),
  cardBrand: z.string().optional(),
  reference: z.string().optional(),
  invoicePaymentId: z.string().uuid().optional(),
})

export const issueStandaloneReceiptSchema = z.object({
  customerId: z.string().uuid(),
  currency: z.string().length(3).default('ILS'),
  amount: z.string().regex(/^\d+(\.\d{1,2})?$/),
  ilsExchangeRate: z.string().regex(/^\d+(\.\d{1,4})?$/).optional(),
  paidAt: z.string().datetime({ offset: true }).or(z.string().date()).optional(),
  lines: z.array(issueReceiptLineSchema).min(1),
})

export const issueInvoiceReceiptSchema = z.object({
  lines: z.array(issueReceiptLineSchema).min(1),
})

export const voidReceiptSchema = z.object({
  reason: z.string().min(1).max(500),
})

export const DEFAULT_RECEIPT_SEQUENCE_PREFIXES = {
  receipt: 'REC-',
  invoice_receipt: 'TIR-',
} as const

export const listReceiptsSchema = z.object({
  cursor: z.string().optional(),
  limit: z.coerce.number().int().min(1).max(100).default(50),
  status: z.enum(['DRAFT', 'ISSUED', 'VOIDED']).optional(),
  docType: z.enum(['receipt', 'invoice_receipt']).optional(),
  customer: z.string().uuid().optional(),
  dateFrom: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
  dateTo: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
  invoiceId: z.string().uuid().optional(),
})

export type IssueStandaloneReceiptInput = z.infer<typeof issueStandaloneReceiptSchema>
export type IssueInvoiceReceiptInput = z.infer<typeof issueInvoiceReceiptSchema>
export type IssueInvoiceReceiptResult = {
  receipt: ReceiptObject
  /** True when DRAFT/SENT/APPROVED invoice was tax-issued in this transaction (Flow B cash sale). */
  taxIssued: boolean
}
export type VoidReceiptInput = z.infer<typeof voidReceiptSchema>
export type ListReceiptsInput = z.infer<typeof listReceiptsSchema>

// ── Serializers ───────────────────────────────────────────────────────────────

export function serializeReceiptLine(row: ReceiptPaymentLineRow): ReceiptPaymentLineObject {
  return {
    id: row.id,
    receiptId: row.receiptId,
    method: row.method as ReceiptPaymentMethod,
    amount: String(row.amount),
    chequeNumber: row.chequeNumber ?? null,
    chequeBank: row.chequeBank ?? null,
    chequeBranch: row.chequeBranch ?? null,
    chequeAccount: row.chequeAccount ?? null,
    chequeDueDate: row.chequeDueDate ?? null,
    cardLastFour: row.cardLastFour ?? null,
    cardBrand: row.cardBrand ?? null,
    reference: row.reference ?? null,
    invoicePaymentId: row.invoicePaymentId ?? null,
  }
}

export function serializeReceiptRow(
  row: ReceiptRow,
  lines?: ReceiptPaymentLineRow[],
  pdfBaseUrl?: string,
  extras?: {
    customerName?: string | null
    invoiceNumber?: string | null
    issuedByName?: string | null
    voidedByName?: string | null
  },
): ReceiptObject {
  const pdfUrl = row.pdfR2Key && pdfBaseUrl
    ? `${pdfBaseUrl}/${row.pdfR2Key}`
    : null
  return {
    id: row.id,
    tenantId: row.tenantId,
    customerId: row.customerId,
    customerName: extras?.customerName ?? null,
    docType: row.docType as ReceiptDocType,
    receiptNumber: row.receiptNumber ?? null,
    status: row.status as ReceiptStatus,
    invoiceId: row.invoiceId ?? null,
    invoiceNumber: extras?.invoiceNumber ?? null,
    currency: row.currency,
    amount: String(row.amount),
    ilsExchangeRate: row.ilsExchangeRate !== null && row.ilsExchangeRate !== undefined ? String(row.ilsExchangeRate) : null,
    amountIls: row.amountIls !== null && row.amountIls !== undefined ? String(row.amountIls) : null,
    issuedAt: row.issuedAt ? row.issuedAt.toISOString() : null,
    issuedBy: row.issuedBy ?? null,
    issuedByName: extras?.issuedByName ?? null,
    pdfR2Key: row.pdfR2Key ?? null,
    pdfUrl,
    voidReason: row.voidReason ?? null,
    voidedAt: row.voidedAt ? row.voidedAt.toISOString() : null,
    voidedBy: row.voidedBy ?? null,
    voidedByName: extras?.voidedByName ?? null,
    createdAt: row.createdAt.toISOString(),
    updatedAt: row.updatedAt.toISOString(),
    lines: lines ? lines.map(serializeReceiptLine) : undefined,
  }
}

// ── Cursor helpers ────────────────────────────────────────────────────────────

function encodeReceiptCursor(
  id: string,
  issuedAt: string | null,
  createdAt: string,
): string {
  return Buffer.from(
    JSON.stringify({ id, issued_at: issuedAt, created_at: createdAt }),
  ).toString('base64')
}

function decodeReceiptCursor(
  cursor: string,
): { id: string; issued_at: string | null; created_at: string } | null {
  try {
    const parsed = JSON.parse(Buffer.from(cursor, 'base64').toString('utf8')) as {
      id: string
      issued_at?: string | null
      created_at: string
    }
    return {
      id: parsed.id,
      issued_at: parsed.issued_at ?? null,
      created_at: parsed.created_at,
    }
  } catch {
    return null
  }
}

function defaultReceiptPrefix(docType: ReceiptDocType): string {
  return DEFAULT_RECEIPT_SEQUENCE_PREFIXES[docType]
}

function listReceiptsWhere(tenantId: string, opts: ListReceiptsInput) {
  return and(
    eq(receipts.tenantId, tenantId),
    opts.status ? eq(receipts.status, opts.status) : undefined,
    opts.docType ? eq(receipts.docType, opts.docType) : undefined,
    opts.customer ? eq(receipts.customerId, opts.customer) : undefined,
    opts.invoiceId ? eq(receipts.invoiceId, opts.invoiceId) : undefined,
    opts.dateFrom
      ? sql`DATE(${receipts.issuedAt}) >= ${opts.dateFrom}::date`
      : undefined,
    opts.dateTo
      ? sql`DATE(${receipts.issuedAt}) <= ${opts.dateTo}::date`
      : undefined,
  )
}

// ── Sequence helper ───────────────────────────────────────────────────────────

/**
 * Atomically increments and returns next receipt number.
 * Must be called inside a transaction.
 */
export async function nextReceiptNumber(
  tx: Parameters<Parameters<Db['transaction']>[0]>[0],
  tenantId: string,
  docType: ReceiptDocType,
): Promise<string> {
  const defaultPrefix = defaultReceiptPrefix(docType)
  await tx.execute(
    sql`
      INSERT INTO receipt_sequences (tenant_id, doc_type, prefix, next_number)
      VALUES (${tenantId}, ${docType}, ${defaultPrefix}, 1)
      ON CONFLICT (tenant_id, doc_type) DO NOTHING
    `,
  )
  const result = await tx.execute(
    sql`
      UPDATE receipt_sequences
      SET next_number = next_number + 1,
          prefix = CASE WHEN prefix = '' THEN ${defaultPrefix} ELSE prefix END
      WHERE tenant_id = ${tenantId} AND doc_type = ${docType}
      RETURNING prefix, next_number - 1 AS last_number
    `,
  )
  const row = result[0] as { prefix: string; last_number: number }
  const padded = String(row.last_number).padStart(5, '0')
  return `${row.prefix}${padded}`
}

// ── List receipts ─────────────────────────────────────────────────────────────

export async function listReceipts(
  db: Db,
  tenantId: string,
  opts: ListReceiptsInput,
): Promise<ReceiptListPage> {
  const limit = Math.min(opts.limit ?? 50, 100)

  const whereClause = listReceiptsWhere(tenantId, opts)

  const countRows = await db
    .select({ total: count() })
    .from(receipts)
    .where(whereClause)
  const totalCount = countRows[0]?.total ?? 0

  let cursorFilter = undefined
  if (opts.cursor) {
    const decoded = decodeReceiptCursor(opts.cursor)
    if (decoded) {
      const cursorIssuedAt = decoded.issued_at ? new Date(decoded.issued_at) : null
      const cursorCreatedAt = new Date(decoded.created_at)
      if (cursorIssuedAt) {
        cursorFilter = or(
          lt(receipts.issuedAt, cursorIssuedAt),
          and(
            eq(receipts.issuedAt, cursorIssuedAt),
            lt(receipts.createdAt, cursorCreatedAt),
          ),
          and(
            eq(receipts.issuedAt, cursorIssuedAt),
            eq(receipts.createdAt, cursorCreatedAt),
            lt(receipts.id, decoded.id),
          ),
        )
      } else {
        // Legacy cursor (pre–issued_at sort) or draft rows with null issued_at
        cursorFilter = or(
          lt(receipts.createdAt, cursorCreatedAt),
          and(
            eq(receipts.createdAt, cursorCreatedAt),
            lt(receipts.id, decoded.id),
          ),
        )
      }
    }
  }

  const rows = await db
    .select({
      receipt: receipts,
      customerName: customers.name,
      invoiceNumber: invoices.invoiceNumber,
    })
    .from(receipts)
    .leftJoin(
      customers,
      and(eq(receipts.customerId, customers.id), eq(customers.tenantId, tenantId)),
    )
    .leftJoin(
      invoices,
      and(eq(receipts.invoiceId, invoices.id), eq(invoices.tenantId, tenantId)),
    )
    .where(and(whereClause, cursorFilter))
    .orderBy(desc(receipts.issuedAt), desc(receipts.createdAt), desc(receipts.id))
    .limit(limit + 1)

  const hasMore = rows.length > limit
  const items = rows.slice(0, limit)
  const lastItem = items[items.length - 1]

  return {
    items: items.map((r) =>
      serializeReceiptRow(r.receipt, undefined, undefined, {
        customerName: r.customerName ?? null,
        invoiceNumber: r.invoiceNumber ?? null,
      }),
    ),
    nextCursor: hasMore && lastItem
      ? encodeReceiptCursor(
          lastItem.receipt.id,
          lastItem.receipt.issuedAt ? lastItem.receipt.issuedAt.toISOString() : null,
          lastItem.receipt.createdAt.toISOString(),
        )
      : null,
    total: Number(totalCount),
  }
}

// ── Get receipt with lines ────────────────────────────────────────────────────

export async function getReceiptWithLines(
  db: Db,
  tenantId: string,
  id: string,
): Promise<ReceiptObject | null> {
  const issuedByUser = alias(users, 'issued_by_user')
  const voidedByUser = alias(users, 'voided_by_user')

  const [row] = await db
    .select({
      receipt: receipts,
      customerName: customers.name,
      invoiceNumber: invoices.invoiceNumber,
      issuedByName: issuedByUser.name,
      voidedByName: voidedByUser.name,
    })
    .from(receipts)
    .leftJoin(
      customers,
      and(eq(receipts.customerId, customers.id), eq(customers.tenantId, tenantId)),
    )
    .leftJoin(
      invoices,
      and(eq(receipts.invoiceId, invoices.id), eq(invoices.tenantId, tenantId)),
    )
    .leftJoin(issuedByUser, eq(receipts.issuedBy, issuedByUser.id))
    .leftJoin(voidedByUser, eq(receipts.voidedBy, voidedByUser.id))
    .where(and(eq(receipts.id, id), eq(receipts.tenantId, tenantId)))
    .limit(1)

  if (!row) return null

  const lines = await db
    .select()
    .from(receiptPaymentLines)
    .where(eq(receiptPaymentLines.receiptId, id))

  return serializeReceiptRow(row.receipt, lines, undefined, {
    customerName: row.customerName ?? null,
    invoiceNumber: row.invoiceNumber ?? null,
    issuedByName: row.issuedByName ?? null,
    voidedByName: row.voidedByName ?? null,
  })
}

// ── Issue standalone receipt (doc_type='receipt') ────────────────────────────

export async function issueStandaloneReceipt(
  db: Db,
  tenantId: string,
  actorId: string,
  invoiceId: string,
  input: IssueStandaloneReceiptInput,
): Promise<ReceiptObject> {
  return db.transaction(async (tx) => {
    // Verify invoice belongs to tenant
    const [inv] = await tx
      .select({
        id: invoices.id,
        status: invoices.status,
        total: invoices.total,
        amountPaid: invoices.amountPaid,
        customerId: invoices.customerId,
      })
      .from(invoices)
      .where(and(eq(invoices.id, invoiceId), eq(invoices.tenantId, tenantId)))
      .limit(1)
    if (!inv) throw new ReceiptNotFoundError(invoiceId)

    if (!RECEIPTABLE_INVOICE_STATUSES.has(inv.status)) {
      throw new ReceiptConflictError(
        `Invoice must be TAX_ISSUED, PARTIALLY_PAID, or PAID to issue a receipt (current: ${inv.status})`,
      )
    }

    if (inv.customerId && inv.customerId !== input.customerId) {
      throw new ReceiptConflictError('customerId does not match invoice customer')
    }

    const paymentIds = input.lines
      .map((l) => l.invoicePaymentId)
      .filter((id): id is string => id != null)

    const linkedPaymentAmounts = new Map<string, string>()
    if (paymentIds.length > 0) {
      const paymentRows = await tx
        .select({ id: invoicePayments.id, amount: invoicePayments.amount })
        .from(invoicePayments)
        .where(
          and(
            eq(invoicePayments.tenantId, tenantId),
            eq(invoicePayments.invoiceId, invoiceId),
            inArray(invoicePayments.id, paymentIds),
          ),
        )
      for (const row of paymentRows) {
        linkedPaymentAmounts.set(row.id, row.amount)
      }
    }

    const expectedAmount = deriveStandaloneReceiptAmount(inv, input.lines, linkedPaymentAmounts)
    const receiptAmount = assertStandaloneReceiptAmount(input.amount, expectedAmount, input.lines)

    const receiptNumber = await nextReceiptNumber(tx, tenantId, 'receipt')
    const now = new Date()
    const issueDate = now.toISOString().slice(0, 10)
    const paymentPaidAt = input.paidAt ?? issueDate

    // Record fresh payments (no invoicePaymentId) or verify pre-linked payments
    const resolvedLines: Array<
      IssueStandaloneReceiptInput['lines'][number] & { invoicePaymentId: string }
    > = []
    for (const line of input.lines) {
      if (line.invoicePaymentId) {
        if (!linkedPaymentAmounts.has(line.invoicePaymentId)) {
          throw new ReceiptConflictError(`Invoice payment not found: ${line.invoicePaymentId}`)
        }
        resolvedLines.push({ ...line, invoicePaymentId: line.invoicePaymentId })
      } else {
        const payment = await recordInvoicePaymentTx(
          tx,
          tenantId,
          invoiceId,
          actorId,
          {
            amount: parseFloat(line.amount),
            paidAt: paymentPaidAt,
            source: receiptMethodToPaymentSource(line.method),
            reference: line.reference ?? undefined,
          },
        )
        resolvedLines.push({ ...line, invoicePaymentId: payment.id })
      }
    }

    const currency = input.currency ?? 'ILS'
    const { ilsExchangeRate, amountIls } = await resolveReceiptIlsSnapshot(
      tx,
      tenantId,
      currency,
      receiptAmount,
      issueDate,
      input.ilsExchangeRate,
    )

    const insertedReceipts = await tx
      .insert(receipts)
      .values({
        tenantId,
        customerId: input.customerId,
        docType: 'receipt',
        receiptNumber,
        status: 'ISSUED',
        invoiceId,
        currency,
        amount: receiptAmount,
        ilsExchangeRate,
        amountIls,
        issuedAt: now,
        issuedBy: actorId,
        updatedAt: now,
      })
      .returning()
    const receipt = insertedReceipts[0]
    if (!receipt) throw new Error('Failed to insert receipt')

    // Insert payment lines and link invoice_payments.receipt_id
    if (resolvedLines.length > 0) {
      await tx.insert(receiptPaymentLines).values(
        resolvedLines.map((l) => ({
          receiptId: receipt.id,
          method: l.method,
          amount: l.amount,
          chequeNumber: l.chequeNumber ?? null,
          chequeBank: l.chequeBank ?? null,
          chequeBranch: l.chequeBranch ?? null,
          chequeAccount: l.chequeAccount ?? null,
          chequeDueDate: l.chequeDueDate ?? null,
          cardLastFour: l.cardLastFour ?? null,
          cardBrand: l.cardBrand ?? null,
          reference: l.reference ?? null,
          invoicePaymentId: l.invoicePaymentId,
        })),
      )

      for (const line of resolvedLines) {
        await tx
          .update(invoicePayments)
          .set({ receiptId: receipt.id })
          .where(
            and(
              eq(invoicePayments.id, line.invoicePaymentId),
              eq(invoicePayments.tenantId, tenantId),
              isNull(invoicePayments.receiptId),
            ),
          )
      }
    }

    // Audit log (ESLint zync/require-audit-in-transaction)
    await tx.insert(auditLog).values({
      tenantId,
      actorId,
      actorType: 'user',
      entityType: 'receipt',
      entityId: receipt.id,
      action: 'issue_receipt',
      changes: { receiptNumber: [null, receiptNumber], status: ['DRAFT', 'ISSUED'] },
    })

    const lines = await tx
      .select()
      .from(receiptPaymentLines)
      .where(eq(receiptPaymentLines.receiptId, receipt.id))

    return serializeReceiptRow(receipt, lines)
  })
}

// ── Issue invoice-receipt (doc_type='invoice_receipt') ───────────────────────

const CASH_SALE_INVOICE_STATUSES = new Set(['DRAFT', 'SENT', 'APPROVED'])
const ATTACH_INVOICE_RECEIPT_STATUSES = new Set(['TAX_ISSUED', 'PAID', 'PARTIALLY_PAID'])

export async function issueInvoiceReceipt(
  db: Db,
  tenantId: string,
  actorId: string,
  invoiceId: string,
  input: IssueInvoiceReceiptInput,
  countryCode: string,
): Promise<IssueInvoiceReceiptResult> {
  return db.transaction(async (tx) => {
    // Verify invoice belongs to tenant and is in an issuable state
    const [inv] = await tx
      .select({
        id: invoices.id,
        status: invoices.status,
        currency: invoices.currency,
        total: invoices.total,
        customerId: invoices.customerId,
      })
      .from(invoices)
      .where(and(eq(invoices.id, invoiceId), eq(invoices.tenantId, tenantId)))
      .limit(1)
    if (!inv) throw new ReceiptNotFoundError(invoiceId)

    const isCashSale = CASH_SALE_INVOICE_STATUSES.has(inv.status)
    const isAttachPath = ATTACH_INVOICE_RECEIPT_STATUSES.has(inv.status)
    if (!isCashSale && !isAttachPath) {
      throw new ReceiptConflictError(
        `Invoice must be DRAFT, SENT, APPROVED, TAX_ISSUED, PAID or PARTIALLY_PAID to issue invoice_receipt (current: ${inv.status})`,
      )
    }
    if (!inv.customerId) {
      throw new ReceiptConflictError('Invoice has no customer')
    }

    // Only one non-voided invoice_receipt per invoice
    const existing = await tx
      .select({ id: receipts.id })
      .from(receipts)
      .where(
        and(
          eq(receipts.invoiceId, invoiceId),
          eq(receipts.docType, 'invoice_receipt'),
          eq(receipts.status, 'ISSUED'),
        ),
      )
      .limit(1)
    if (existing.length > 0) {
      throw new ReceiptConflictError('An invoice_receipt already exists for this invoice. Void it first.')
    }

    const receiptNumber = await nextReceiptNumber(tx, tenantId, 'invoice_receipt')
    const now = new Date()
    const issueDate = now.toISOString().slice(0, 10)

    let totalAmount: string
    let currency: string
    let customerId: string
    let resolvedLines: Array<
      IssueInvoiceReceiptInput['lines'][number] & { invoicePaymentId?: string }
    >
    const taxIssued = isCashSale

    if (isCashSale) {
      const issuedInvoice = await issueTaxInvoiceTx(
        tx,
        tenantId,
        invoiceId,
        actorId,
        issueDate,
        countryCode,
        { allowedStatuses: ['DRAFT', 'SENT', 'APPROVED'] },
      )
      if (!issuedInvoice.customerId) {
        throw new ReceiptConflictError('Invoice has no customer')
      }

      const invoiceTotal = parseFloat(issuedInvoice.total)
      const linesSum = input.lines.reduce((sum, l) => sum + parseFloat(l.amount), 0)
      if (Math.abs(linesSum - invoiceTotal) > RECEIPT_AMOUNT_TOLERANCE) {
        throw new ReceiptConflictError(
          `Payment lines sum (${linesSum.toFixed(2)}) does not match invoice total (${invoiceTotal.toFixed(2)})`,
        )
      }

      resolvedLines = []
      for (const line of input.lines) {
        if (line.invoicePaymentId) {
          throw new ReceiptConflictError(
            'Pre-linked invoice payments are not supported for cash-sale invoice_receipt',
          )
        }
        const payment = await recordInvoicePaymentTx(
          tx,
          tenantId,
          invoiceId,
          actorId,
          {
            amount: parseFloat(line.amount),
            paidAt: issueDate,
            source: receiptMethodToPaymentSource(line.method),
            reference: line.reference ?? undefined,
          },
        )
        resolvedLines.push({ ...line, invoicePaymentId: payment.id })
      }

      totalAmount = invoiceTotal.toFixed(2)
      currency = issuedInvoice.currency ?? 'ILS'
      customerId = issuedInvoice.customerId
    } else {
      const invoiceTotal = parseFloat(String(inv.total ?? '0'))
      const linesSum = input.lines.reduce((sum, l) => sum + parseFloat(l.amount), 0)
      if (Math.abs(linesSum - invoiceTotal) > RECEIPT_AMOUNT_TOLERANCE) {
        throw new ReceiptConflictError(
          `Payment lines sum (${linesSum.toFixed(2)}) does not match invoice total (${invoiceTotal.toFixed(2)})`,
        )
      }

      totalAmount = invoiceTotal.toFixed(2)
      currency = inv.currency ?? 'ILS'
      customerId = inv.customerId
      resolvedLines = input.lines.map((l) => ({ ...l }))
    }

    const { ilsExchangeRate, amountIls } = await resolveReceiptIlsSnapshot(
      tx,
      tenantId,
      currency,
      totalAmount,
      issueDate,
    )

    const insertedIR = await tx
      .insert(receipts)
      .values({
        tenantId,
        customerId,
        docType: 'invoice_receipt',
        receiptNumber,
        status: 'ISSUED',
        invoiceId,
        currency,
        amount: totalAmount,
        ilsExchangeRate,
        amountIls,
        issuedAt: now,
        issuedBy: actorId,
        updatedAt: now,
      })
      .returning()
    const receipt = insertedIR[0]
    if (!receipt) throw new Error('Failed to insert invoice_receipt')

    if (resolvedLines.length > 0) {
      await tx.insert(receiptPaymentLines).values(
        resolvedLines.map((l) => ({
          receiptId: receipt.id,
          method: l.method,
          amount: l.amount,
          chequeNumber: l.chequeNumber ?? null,
          chequeBank: l.chequeBank ?? null,
          chequeBranch: l.chequeBranch ?? null,
          chequeAccount: l.chequeAccount ?? null,
          chequeDueDate: l.chequeDueDate ?? null,
          cardLastFour: l.cardLastFour ?? null,
          cardBrand: l.cardBrand ?? null,
          reference: l.reference ?? null,
          invoicePaymentId: l.invoicePaymentId,
        })),
      )

      const paymentIds = resolvedLines
        .map((l) => l.invoicePaymentId)
        .filter((id): id is string => id != null)
      for (const paymentId of paymentIds) {
        await tx
          .update(invoicePayments)
          .set({ receiptId: receipt.id })
          .where(
            and(
              eq(invoicePayments.id, paymentId),
              eq(invoicePayments.tenantId, tenantId),
              isNull(invoicePayments.receiptId),
            ),
          )
      }
    }

    await tx.insert(auditLog).values({
      tenantId,
      actorId,
      actorType: 'user',
      entityType: 'receipt',
      entityId: receipt.id,
      action: 'issue_invoice_receipt',
      changes: { receiptNumber: [null, receiptNumber], status: ['DRAFT', 'ISSUED'] },
    })

    const lines = await tx
      .select()
      .from(receiptPaymentLines)
      .where(eq(receiptPaymentLines.receiptId, receipt.id))

    return { receipt: serializeReceiptRow(receipt, lines), taxIssued }
  })
}

// ── Void receipt ──────────────────────────────────────────────────────────────

export async function voidReceipt(
  db: Db,
  tenantId: string,
  actorId: string,
  id: string,
  reason: string,
): Promise<ReceiptObject> {
  return db.transaction(async (tx) => {
    const [receipt] = await tx
      .select()
      .from(receipts)
      .where(and(eq(receipts.id, id), eq(receipts.tenantId, tenantId)))
      .limit(1)

    if (!receipt) throw new ReceiptNotFoundError(id)
    if (receipt.status !== 'ISSUED') {
      throw new ReceiptConflictError(`Receipt cannot be voided (current status: ${receipt.status})`)
    }

    const now = new Date()

    const updatedRows = await tx
      .update(receipts)
      .set({
        status: 'VOIDED',
        voidReason: reason,
        voidedAt: now,
        voidedBy: actorId,
        updatedAt: now,
      })
      .where(and(eq(receipts.tenantId, tenantId), eq(receipts.id, id)))
      .returning()
    const updated = updatedRows[0]
    if (!updated) throw new Error('Failed to void receipt')

    const lines = await tx
      .select()
      .from(receiptPaymentLines)
      .where(eq(receiptPaymentLines.receiptId, id))

    const linePaymentIds = lines
      .filter((l) => l.invoicePaymentId)
      .map((l) => l.invoicePaymentId as string)

    const receiptLinkedPayments = await tx
      .select({ id: invoicePayments.id, invoiceId: invoicePayments.invoiceId })
      .from(invoicePayments)
      .where(
        and(eq(invoicePayments.tenantId, tenantId), eq(invoicePayments.receiptId, id)),
      )

    const paymentIds = [
      ...new Set([
        ...linePaymentIds,
        ...receiptLinkedPayments.map((p) => p.id),
      ]),
    ]

    const paymentsToReverse =
      paymentIds.length === 0
        ? []
        : await tx
            .select({ id: invoicePayments.id, invoiceId: invoicePayments.invoiceId })
            .from(invoicePayments)
            .where(
              and(
                eq(invoicePayments.tenantId, tenantId),
                inArray(invoicePayments.id, paymentIds),
              ),
            )

    for (const payment of paymentsToReverse) {
      await tx
        .update(invoicePayments)
        .set({ receiptId: null })
        .where(
          and(eq(invoicePayments.tenantId, tenantId), eq(invoicePayments.id, payment.id)),
        )

      await reverseInvoicePaymentTx(
        tx,
        tenantId,
        payment.invoiceId,
        payment.id,
        actorId,
        'invoice.payment_reversed',
      )
    }

    // Audit log
    await tx.insert(auditLog).values({
      tenantId,
      actorId,
      actorType: 'user',
      entityType: 'receipt',
      entityId: id,
      action: 'void_receipt',
      changes: { status: ['ISSUED', 'VOIDED'], voidReason: [null, reason] },
    })

    return serializeReceiptRow(updated, lines)
  })
}

// ── Get receipts for invoice ──────────────────────────────────────────────────

export async function listReceiptsForInvoice(
  db: Db,
  tenantId: string,
  invoiceId: string,
): Promise<ReceiptObject[]> {
  const rows = await db
    .select()
    .from(receipts)
    .where(and(eq(receipts.invoiceId, invoiceId), eq(receipts.tenantId, tenantId)))
    .orderBy(desc(receipts.createdAt))

  if (rows.length === 0) return []

  const allLines = await db
    .select()
    .from(receiptPaymentLines)
    .where(inArray(receiptPaymentLines.receiptId, rows.map((r) => r.id)))

  const linesByReceiptId = new Map<string, ReceiptPaymentLineRow[]>()
  for (const line of allLines) {
    const existing = linesByReceiptId.get(line.receiptId) ?? []
    existing.push(line)
    linesByReceiptId.set(line.receiptId, existing)
  }

  return rows.map((r) => serializeReceiptRow(r, linesByReceiptId.get(r.id) ?? []))
}

/** Persist R2 object key for immutable issued receipt HTML snapshot (served via GET /api/receipts/:id/pdf). */
export async function setReceiptPdfR2Key(
  db: Db,
  tenantId: string,
  id: string,
  pdfR2Key: string,
): Promise<ReceiptObject> {
  const [updated] = await db
    .update(receipts)
    .set({ pdfR2Key, updatedAt: new Date() })
    .where(and(eq(receipts.tenantId, tenantId), eq(receipts.id, id)))
    .returning()

  if (!updated) throw new ReceiptNotFoundError(id)

  const lines = await db
    .select()
    .from(receiptPaymentLines)
    .where(eq(receiptPaymentLines.receiptId, id))

  return serializeReceiptRow(updated, lines)
}
