import { createDbService, type DrizzleDb } from '@/server/services/db.js';
/**
 * Invoice job enqueue + execution for ITEM purchases.
 *
 * Uses the `invoices` table as the job store (status: pending → running → succeeded | failed).
 * Idempotent enqueue: unique constraint (purchase_id, provider) prevents duplicate pending rows.
 */

import { and, eq, inArray, sql } from 'drizzle-orm';
import { he } from '@/lib/i18n/he';
import {
  invoices,
  order,
  orderLine,
  dealSkus,
  vendorInvoiceSettings,
  vendors,
  deals,
  users,
} from '@/server/db/schema.js';
import { getVendorInvoiceProvider } from '@/server/invoicing/registry.js';
import { decryptCredentials } from '@/server/crypto/invoice-credentials.js';
import { captureCaught } from '@/server/observability/capture.server.js';
import type { MultidealEnv } from '@/server/env.js';
import { getVatSchedule, toIsraelDateString, findRateInSchedule } from '../db/queries/vat.js';
import { extractVat, fromPercent } from '@platform-modules/tax/rates-table';

// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------

const MAX_RETRIES = 5;

/** Base backoff interval in ms (doubles with each attempt). */
const BACKOFF_BASE_MS = 60_000; // 1 minute

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

type InvoiceRow = typeof invoices.$inferSelect;

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

/**
 * Compute next retry timestamp using exponential backoff.
 * Attempt 1 → 1 min, 2 → 2 min, 3 → 4 min, 4 → 8 min, 5 → 16 min.
 */
function nextRetryMs(attempt: number): number {
  return BACKOFF_BASE_MS * Math.pow(2, attempt - 1);
}

// ---------------------------------------------------------------------------
// enqueueInvoiceJobsOnPaid
// ---------------------------------------------------------------------------

/**
 * Called when a payment_intent transitions to `succeeded`.
 * Finds all ITEM purchases for the given payment intent and inserts a pending
 * invoice job row for each. Idempotent: re-enqueue for the same purchase+provider
 * is a no-op (DO NOTHING on unique constraint).
 */
export async function enqueueInvoiceJobsOnPaid(
  env: MultidealEnv,
  args: { paymentIntentId: string },
): Promise<void> {
  const db = createDbService({ DATABASE_URL: env.DATABASE_URL });

  // Load all ITEM orderLines for this payment intent (chargeRef on order).
  const rows = await db
    .select({
      orderLineId: orderLine.id,
      vendorId: orderLine.vendorId,
      buyerId: order.buyerUserId,
      lineTotal: orderLine.lineTotal,
      createdAt: orderLine.createdAt,
    })
    .from(orderLine)
    .innerJoin(order, eq(order.id, orderLine.orderId))
    .innerJoin(dealSkus, eq(dealSkus.id, orderLine.variantId))
    .innerJoin(deals, eq(deals.id, dealSkus.dealId))
    .where(and(eq(order.chargeRef, args.paymentIntentId), eq(deals.dealType, 'ITEM')));

  if (rows.length === 0) return;

  const vatSchedule = await getVatSchedule(db);

  const vendorIds = [
    ...new Set(rows.map((row) => row.vendorId).filter((id): id is string => id != null)),
  ];
  const settingsRows =
    vendorIds.length > 0
      ? await db
          .select({
            vendorId: vendorInvoiceSettings.vendorId,
            provider: vendorInvoiceSettings.provider,
          })
          .from(vendorInvoiceSettings)
          .where(inArray(vendorInvoiceSettings.vendorId, vendorIds))
      : [];
  const providerByVendor = new Map(settingsRows.map((row) => [row.vendorId, row.provider]));

  const pendingInvoices: Array<typeof invoices.$inferInsert> = [];
  for (const row of rows) {
    if (!row.buyerId) continue; // guest purchases: no buyer user record
    if (!row.vendorId) continue; // unassigned vendor — no invoice

    const providerKind = providerByVendor.get(row.vendorId) ?? 'self_handled';

    // Derive gross/vat/net in agorot. lineTotal is already in agorot (bigint).
    const grossAgorot = Number(row.lineTotal);
    const purchaseDateIl = toIsraelDateString(new Date(row.createdAt as string | Date));
    const vatRatePercent = findRateInSchedule(vatSchedule, purchaseDateIl);

    let vatAgorot: number;
    let netAgorot: number;

    try {
      if (!Number.isInteger(grossAgorot) || grossAgorot < 0) {
        throw new Error(`invalid lineTotal: ${String(row.lineTotal)}`);
      }
      // Inclusive VAT extraction via @platform-modules/tax (exact BigInt round-half-up).
      const { net, vat } = extractVat(BigInt(grossAgorot), fromPercent(vatRatePercent));
      vatAgorot = Number(vat);
      netAgorot = Number(net);
    } catch (err) {
      // Isolate per-row: skip corrupt data without aborting sibling rows. No invoice insert
      // here — the order line stays without a pending invoice row and remains re-enqueueable.
      captureCaught(err, {
        scope: 'invoice-job.enqueue-vat',
        extra: { orderLineId: row.orderLineId, lineTotal: row.lineTotal, vatRatePercent },
      });
      continue;
    }

    pendingInvoices.push({
      orderLineId: row.orderLineId,
      vendorId: row.vendorId,
      buyerId: row.buyerId,
      provider: providerKind,
      status: 'pending',
      grossAgorot,
      vatAgorot,
      netAgorot,
      retryCount: 0,
    });
  }

  if (pendingInvoices.length > 0) {
    await db.insert(invoices).values(pendingInvoices).onConflictDoNothing();
  }
}

// ---------------------------------------------------------------------------
// runInvoiceJob
// ---------------------------------------------------------------------------

/**
 * Atomically picks up a pending invoice job and processes it.
 *
 * - Atomic claim: UPDATE … WHERE id=$1 AND status='pending' RETURNING *
 *   If 0 rows → job was already claimed or doesn't exist → noop.
 * - On provider success: status='succeeded' + document fields set.
 * - On provider error: status='failed' if attempts exhausted, else 'pending'
 *   (re-eligible after backoff calculated from retryCount).
 */
export async function runInvoiceJob(env: MultidealEnv, jobId: string): Promise<void> {
  const db = createDbService({ DATABASE_URL: env.DATABASE_URL });

  // Atomic pick-up.
  const claimed = await db
    .update(invoices)
    .set({ status: 'running', updatedAt: sql`NOW()` })
    .where(and(eq(invoices.id, jobId), eq(invoices.status, 'pending')))
    .returning();

  const job: InvoiceRow | undefined = claimed[0];
  if (!job) {
    // Already claimed or doesn't exist — noop.
    return;
  }

  // ── Load supporting data ────────────────────────────────────────────────

  const [vendorSettings] = await db
    .select({
      provider: vendorInvoiceSettings.provider,
      ciphertext: vendorInvoiceSettings.credentialsCiphertext,
      iv: vendorInvoiceSettings.credentialsIv,
      tag: vendorInvoiceSettings.credentialsTag,
    })
    .from(vendorInvoiceSettings)
    .where(eq(vendorInvoiceSettings.vendorId, job.vendorId))
    .limit(1);

  const [vendor] = await db
    .select({ id: vendors.id, displayName: vendors.displayName })
    .from(vendors)
    .where(eq(vendors.id, job.vendorId))
    .limit(1);

  const [buyer] = await db
    .select({ id: users.id, displayName: users.displayName, email: users.email })
    .from(users)
    .where(eq(users.id, job.buyerId))
    .limit(1);

  const [purchase] = await db
    .select({
      id: orderLine.id,
      dealId: dealSkus.dealId,
      quantity: orderLine.qty,
      createdAt: orderLine.createdAt,
    })
    .from(orderLine)
    .innerJoin(dealSkus, eq(dealSkus.id, orderLine.variantId))
    .where(eq(orderLine.id, job.orderLineId))
    .limit(1);

  const [deal] = purchase
    ? await db
        .select({ title: deals.title })
        .from(deals)
        .where(eq(deals.id, purchase.dealId))
        .limit(1)
    : [];

  // Guard against missing records (shouldn't happen in normal flow).
  if (!vendor || !buyer || !purchase) {
    await db
      .update(invoices)
      .set({
        status: 'failed',
        failureReason: 'missing_related_records',
        updatedAt: sql`NOW()`,
      })
      .where(eq(invoices.id, jobId));
    return;
  }

  // ── Decrypt vendor credentials ──────────────────────────────────────────

  let credentialsJson: string | null = null;
  const kek = env.INVOICE_KEK;

  if (kek && vendorSettings?.ciphertext && vendorSettings.iv && vendorSettings.tag) {
    try {
      credentialsJson = await decryptCredentials(
        {
          ciphertext: vendorSettings.ciphertext,
          iv: vendorSettings.iv,
          tag: vendorSettings.tag,
        },
        kek,
      );
    } catch (decryptErr) {
      captureCaught(decryptErr, {
        scope: 'invoice-job.decrypt',
        extra: { jobId, vendorId: job.vendorId },
      });
      await _markFailed(db, job, 'credential_decrypt_failed');
      return;
    }
  }

  // ── Get provider ────────────────────────────────────────────────────────

  let providerInstance;
  try {
    providerInstance = getVendorInvoiceProvider(job.provider);
  } catch (providerErr) {
    captureCaught(providerErr, {
      scope: 'invoice-job.get-provider',
      extra: { jobId, provider: job.provider },
    });
    await _markFailed(db, job, `unknown_provider:${job.provider}`);
    return;
  }

  // ── Build invoice args ──────────────────────────────────────────────────

  // Parse decrypted credentials (if any) — providers validate their own schema.
  const parsedCreds = credentialsJson ? JSON.parse(credentialsJson) : {};

  // Validate credentials so the provider instance is properly configured.
  // (Provider's createInvoice relies on credentials being valid at call time.)
  if (vendorSettings && vendorSettings.provider !== 'self_handled') {
    const validation = await providerInstance.validateCredentials(parsedCreds);
    if (!validation.ok) {
      await _markFailed(db, job, `invalid_credentials:${validation.reason}`);
      return;
    }
  }

  const lineItems = [
    {
      description: deal?.title ?? `Purchase ${purchase.id}`,
      qty: purchase.quantity,
      unitAgorot: Math.round(job.grossAgorot / purchase.quantity),
    },
  ];

  const invoiceArgs = {
    vendor: {
      id: vendor.id,
      name: vendor.displayName,
    },
    buyer: {
      id: buyer.id,
      name: buyer.displayName ?? he.common.anonymous_buyer,
      email: buyer.email ?? '',
    },
    purchase: {
      id: purchase.id,
      createdAt: purchase.createdAt,
    },
    lineItems,
    grossAgorot: job.grossAgorot,
    vatAgorot: job.vatAgorot,
  };

  // ── Call provider ────────────────────────────────────────────────────────

  try {
    const result = await providerInstance.createInvoice(invoiceArgs);

    await db
      .update(invoices)
      .set({
        status: 'succeeded',
        providerDocumentId: result.documentId,
        providerDocumentNumber: result.documentNumber,
        providerDocumentUrl: result.documentUrl,
        failureReason: null,
        updatedAt: sql`NOW()`,
      })
      .where(eq(invoices.id, jobId));
  } catch (err) {
    captureCaught(err, {
      scope: 'invoice-job.create-invoice',
      extra: { jobId, provider: job.provider, orderLineId: job.orderLineId },
    });

    const newRetryCount = job.retryCount + 1;
    const exhausted = newRetryCount >= MAX_RETRIES;

    if (exhausted) {
      await db
        .update(invoices)
        .set({
          status: 'failed',
          failureReason: err instanceof Error ? err.message : String(err),
          retryCount: newRetryCount,
          updatedAt: sql`NOW()`,
        })
        .where(eq(invoices.id, jobId));
    } else {
      // Back to pending — eligible for retry after backoff.
      // updatedAt carries the timestamp; callers can compute next_retry_at
      // from updatedAt + backoff(retryCount) when selecting for retry.
      const _backoffMs = nextRetryMs(newRetryCount); // exposed for callers
      void _backoffMs; // suppress unused-variable lint
      await db
        .update(invoices)
        .set({
          status: 'pending',
          failureReason: err instanceof Error ? err.message : String(err),
          retryCount: newRetryCount,
          updatedAt: sql`NOW()`,
        })
        .where(eq(invoices.id, jobId));
    }
  }
}

// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------

async function _markFailed(db: DrizzleDb, job: InvoiceRow, reason: string): Promise<void> {
  const newRetryCount = job.retryCount + 1;
  const exhausted = newRetryCount >= MAX_RETRIES;

  await db
    .update(invoices)
    .set({
      status: exhausted ? 'failed' : 'pending',
      failureReason: reason,
      retryCount: newRetryCount,
      updatedAt: sql`NOW()`,
    })
    .where(eq(invoices.id, job.id));
}
