/**
 * Redemption workflow - FDS §6.4 (QR scan) and §6.5 (expiry cron).
 *
 * Voucher-based redemption via platform commerce-fulfillment (voucherId in QR token).
 */

import { eq, and, lt, isNotNull, sql } from 'drizzle-orm';
import { bytesToHex } from '@/lib/encoding.js';
import { formatAgorotPlain } from '@/lib/money';
import { REDEEMABLE_PAYMENT_STATUSES } from '@/lib/enums/payment-status';
import type { DrizzleClient, TxDrizzleClient } from '@/server/db/client.js';
import { he } from '@/lib/i18n/he';
import {
  deals,
  vendors,
  order,
  refundIntent,
  orderLine,
  orderLineVoucherExt,
} from '@/server/db/schema.js';
import {
  voucher,
  redeemVoucher as platformRedeemVoucher,
  isVoucherAlreadyRedeemedError,
  isVoucherExpiredError,
  isVoucherWrongVendorError,
  isFulfillmentValidationError,
} from '@platform-modules/commerce-fulfillment';
import type { FulfillmentSchema } from '@platform-modules/commerce-fulfillment';
import type { Transaction } from '@platform-modules/db';
import * as purchaseQueries from '@/server/db/queries/purchases.js';
import type { PushClient } from '@/server/push/types.js';
import { captureCaught } from '@/server/observability/capture.server';
import { decide } from '@/server/domain/redemption/machine.js';
import { applyEffects } from '@/server/domain/redemption/apply-effects.js';
import type { RedemptionState } from '@/server/domain/redemption/events.js';
import {
  toFulfillmentDb,
  verifyVoucherQrToken,
  verifyLegacyQrToken,
} from '@/server/fulfillment/fulfillment-platform.js';
import {
  asOrderLineId,
  asVendorId,
  asVoucherId,
  moduleRefAsUuid,
  toModuleRef,
} from '@/server/platform-seams/ids.js';

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

export interface RedemptionDeps {
  db: TxDrizzleClient;
  qrSecret: string;
  push: PushClient;
}

export interface RedeemVoucherInput {
  token: string;
  vendorId: string;
}

export type RedeemVoucherResult =
  | {
      ok: true;
      purchaseId: string;
      vendorId: string;
      customerDisplayName: string;
      dealId: string;
      dealTitle: string;
      dealSkuId: string | null;
    }
  | {
      ok: false;
      reason: 'already_redeemed' | 'expired' | 'wrong_vendor' | 'invalid';
    };

export interface ExpireDeps {
  db: DrizzleClient;
}

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

async function hashToken(token: string): Promise<string> {
  const enc = new TextEncoder();
  const hashBuffer = await crypto.subtle.digest('SHA-256', enc.encode(token));
  return bytesToHex(hashBuffer);
}

class RefundInProgressError extends Error {}

/**
 * Resolve a pre-cutover legacy QR token to a voucherId.
 *
 * Legacy tokens are keyed by purchaseId (order_line.id), not voucherId — one
 * voucher per line existed before the per-unit voucher table. Joins
 * order_line_voucher_ext (holds the legacy qr_token_hash retained during the
 * voucher cutover) to the platform voucher table, filters out terminal states,
 * and orders by unitIndex ASC (matches the platform's own loadVoucherIds
 * ordering) for deterministic multi-unit resolution.
 */
export async function resolveVoucherIdFromLegacyToken(
  db: DrizzleClient,
  purchaseId: string,
  legacyTokenHash: string,
): Promise<string | null> {
  const purchaseRef = toModuleRef(asOrderLineId(purchaseId));
  const [row] = await db
    .select({ voucherId: orderLineVoucherExt.voucherId })
    .from(orderLineVoucherExt)
    // voucher.id is uuid; order_line_voucher_ext.voucher_id is text — cast the
    // join key (same idiom as voucher-line-state.ts's loadVouchersForLines).
    .innerJoin(voucher, eq(sql`${voucher.id}::text`, orderLineVoucherExt.voucherId))
    .where(
      and(
        eq(orderLineVoucherExt.lineId, purchaseRef),
        eq(orderLineVoucherExt.qrTokenHash, legacyTokenHash),
        eq(voucher.state, 'UNREDEEMED'),
      ),
    )
    .orderBy(voucher.unitIndex)
    .limit(1);

  return row?.voucherId ? toModuleRef(asVoucherId(row.voucherId)) : null;
}

// ---------------------------------------------------------------------------
// §6.4 - Voucher QR Redemption
// ---------------------------------------------------------------------------

export async function redeemVoucher(
  deps: RedemptionDeps,
  input: RedeemVoucherInput,
): Promise<RedeemVoucherResult> {
  const { db, qrSecret } = deps;

  const tokenHash = await hashToken(input.token);

  let voucherId: string;
  const verifyResult = await verifyVoucherQrToken(input.token, qrSecret);
  if (verifyResult.valid) {
    voucherId = toModuleRef(asVoucherId(verifyResult.voucherId));
  } else {
    // New-format verify failed — fall back to the pre-cutover legacy token
    // shape (purchaseId-keyed payload, same HMAC scheme) so vouchers issued
    // before the platform voucher-table cutover keep redeeming.
    const legacyResult = await verifyLegacyQrToken(input.token, qrSecret);
    if (!legacyResult.valid) {
      return { ok: false, reason: 'invalid' };
    }
    const resolvedVoucherId = await resolveVoucherIdFromLegacyToken(
      db,
      legacyResult.purchaseId,
      tokenHash,
    );
    if (!resolvedVoucherId) {
      return { ok: false, reason: 'invalid' };
    }
    voucherId = resolvedVoucherId;
  }

  const [extRow] = await db
    .select({
      lineId: orderLineVoucherExt.lineId,
      qrTokenHash: orderLineVoucherExt.qrTokenHash,
    })
    .from(orderLineVoucherExt)
    .where(
      and(
        eq(orderLineVoucherExt.voucherId, toModuleRef(asVoucherId(voucherId))),
        eq(orderLineVoucherExt.qrTokenHash, tokenHash),
      ),
    )
    .limit(1);

  if (!extRow) {
    return { ok: false, reason: 'invalid' };
  }

  const [voucherRow] = await db
    .select({
      id: voucher.id,
      orderId: voucher.orderId,
      lineId: voucher.lineId,
      state: voucher.state,
      expiresAt: voucher.expiresAt,
      vendorId: voucher.vendorId,
    })
    .from(voucher)
    .where(eq(voucher.id, toModuleRef(asVoucherId(voucherId))))
    .limit(1);

  if (!voucherRow) {
    return { ok: false, reason: 'invalid' };
  }

  const purchase = await purchaseQueries.findById(db, extRow.lineId);
  if (!purchase) {
    return { ok: false, reason: 'invalid' };
  }

  if (!(REDEEMABLE_PAYMENT_STATUSES as readonly string[]).includes(purchase.paymentStatus)) {
    return { ok: false, reason: 'invalid' };
  }

  const now = new Date();
  const voucherState = voucherRow.state as RedemptionState;
  const decideResult = decide(
    { state: voucherState },
    {
      kind: 'redeem_attempted',
      purchaseId: purchase.id,
      currentState: voucherState,
      scanningVendorId: input.vendorId,
      purchaseVendorId: purchase.vendorId,
      purchaseUserId: purchase.userId ?? null,
      dealId: purchase.dealId,
      expiresAt: voucherRow.expiresAt ?? purchase.expiresAt ?? new Date(0),
      at: now,
    },
  );

  if (!decideResult.ok) {
    switch (decideResult.error) {
      case 'ALREADY_REDEEMED':
        return { ok: false, reason: 'already_redeemed' };
      case 'EXPIRED':
        return { ok: false, reason: 'expired' };
      case 'WRONG_VENDOR':
        return { ok: false, reason: 'wrong_vendor' };
      case 'INVALID':
      case 'INVALID_STATE':
      default:
        return { ok: false, reason: 'invalid' };
    }
  }

  // Atomic redeem: the platform state write and the ext-row (reviewEligible)
  // update commit together or not at all. A failure in either rolls back
  // both, so a voucher can never end up REDEEMED with a stale ext row.
  try {
    await db.transaction(async (tx) => {
      const [lockedOrder] = await tx
        .select({ id: order.id, status: order.status })
        .from(order)
        .where(eq(order.id, voucherRow.orderId))
        .for('update');
      if (
        !lockedOrder ||
        !(REDEEMABLE_PAYMENT_STATUSES as readonly string[]).includes(lockedOrder.status)
      ) {
        throw new RefundInProgressError();
      }
      const [pendingRefund] = await tx
        .select({ id: refundIntent.id })
        .from(refundIntent)
        .where(
          and(eq(refundIntent.orderId, voucherRow.orderId), eq(refundIntent.status, 'pending')),
        )
        .limit(1);
      if (pendingRefund) throw new RefundInProgressError();

      await platformRedeemVoucher(
        toFulfillmentDb(tx) as unknown as Transaction<FulfillmentSchema>,
        {
          voucherId: toModuleRef(asVoucherId(voucherId)),
          scanningVendorId: toModuleRef(asVendorId(input.vendorId)),
        },
      );

      await tx
        .update(orderLineVoucherExt)
        .set({ reviewEligible: true })
        .where(eq(orderLineVoucherExt.voucherId, toModuleRef(asVoucherId(voucherId))));
    });
  } catch (err) {
    if (err instanceof RefundInProgressError) {
      return { ok: false, reason: 'invalid' };
    }
    if (isVoucherAlreadyRedeemedError(err)) {
      return { ok: false, reason: 'already_redeemed' };
    }
    if (isVoucherExpiredError(err)) {
      return { ok: false, reason: 'expired' };
    }
    if (isVoucherWrongVendorError(err)) {
      return { ok: false, reason: 'wrong_vendor' };
    }
    if (isFulfillmentValidationError(err)) {
      return { ok: false, reason: 'invalid' };
    }
    throw err;
  }

  // Best-effort external I/O (push) — intentionally outside the transaction,
  // runs only after the redeem commit succeeds, never rolls anything back.
  await applyEffects(
    {
      db,
      push: deps.push,
      buildRedemptionPush: async ({ dealId }) => {
        const [dealRow] = await db
          .select({ title: deals.title, vendorId: deals.vendorId })
          .from(deals)
          .where(eq(deals.id, dealId))
          .limit(1);

        let vendorDisplayName: string | undefined;
        if (dealRow) {
          const [vendorRow] = await db
            .select({ displayName: vendors.displayName })
            .from(vendors)
            .where(eq(vendors.id, dealRow.vendorId))
            .limit(1);
          vendorDisplayName = vendorRow?.displayName;
        }

        const pd = he.purchase_detail;
        return {
          title: pd.redemption_push_title,
          body:
            vendorDisplayName && dealRow
              ? pd.redemption_push_body_with_vendor
                  .replace('{dealTitle}', dealRow.title)
                  .replace('{vendorName}', vendorDisplayName)
              : (dealRow?.title ?? pd.redemption_push_body_fallback),
          url: `/purchases/${purchase.id}`,
          tag: 'redemption_confirmed',
          data: {
            dealTitle: dealRow?.title ?? '',
            businessName: vendorDisplayName ?? '',
            purchaseId: purchase.id,
          },
        };
      },
    },
    decideResult.effects,
  );

  try {
    await purchaseQueries.advanceOrderFulfillment(db, voucherRow.orderId);
  } catch (err) {
    captureCaught(err, {
      scope: 'server.workflows.redemption.fulfillment',
      severity: 'warning',
      extra: { orderId: voucherRow.orderId, purchaseId: purchase.id },
    });
  }

  const customerDisplayName = purchase.userId
    ? `Customer #${purchase.userId.slice(0, 8)}`
    : 'Guest Customer';

  let dealTitle = '';
  try {
    const [dealRow] = await db
      .select({ title: deals.title })
      .from(deals)
      .where(eq(deals.id, purchase.dealId))
      .limit(1);
    dealTitle = dealRow?.title ?? '';
  } catch (err) {
    captureCaught(err, {
      scope: 'server.workflows.redemption.dealTitle',
      severity: 'info',
    });
  }

  return {
    ok: true,
    purchaseId: purchase.id,
    vendorId: purchase.vendorId,
    customerDisplayName,
    dealId: purchase.dealId,
    dealTitle,
    dealSkuId: purchase.dealSkuId ?? null,
  };
}

// ---------------------------------------------------------------------------
// §6.5 - Expire past-due vouchers (called by cron)
// ---------------------------------------------------------------------------

/**
 * Walk UNREDEEMED vouchers past expires_at, mark them EXPIRED.
 * 50/50 revenue split is recorded (logged via outbox for settlement processing).
 *
 * Returns the count of order lines expired.
 */
export async function expirePastDueDeals(deps: ExpireDeps): Promise<number> {
  const { db } = deps;
  const now = new Date();

  const expiredRows = await db
    .selectDistinct({
      id: voucher.lineId,
      vendorId: orderLine.vendorId,
      lineTotal: orderLine.lineTotal,
    })
    .from(voucher)
    .innerJoin(orderLine, eq(orderLine.id, moduleRefAsUuid(sql`${voucher.lineId}`)))
    .where(
      and(
        eq(voucher.state, 'UNREDEEMED'),
        isNotNull(voucher.expiresAt),
        lt(voucher.expiresAt, now),
      ),
    );

  const expired = expiredRows.map((r) => ({
    id: r.id,
    vendorId: r.vendorId ?? '',
    amountPaid: formatAgorotPlain(Number(r.lineTotal)),
  }));

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

  const noopPush: PushClient = {
    sendToUser: async () => undefined,
    sendToVendor: async () => undefined,
  };

  for (const p of expired) {
    const decideResult = decide(
      { state: 'UNREDEEMED' },
      {
        kind: 'expiry_passed',
        purchaseId: p.id,
        currentState: 'UNREDEEMED',
        vendorId: p.vendorId,
        amountPaid: p.amountPaid,
        at: now,
      },
    );
    if (!decideResult.ok) continue;

    await applyEffects(
      {
        db,
        push: noopPush,
        buildRedemptionPush: async () => ({ title: '', body: '' }),
      },
      decideResult.effects,
    );
  }

  return expired.length;
}
