/**
 * Purchase workflows - FDS apps/web/src/server/storage/imagePipeline.wasm.ts.2 (registered) and apps/web/src/server/storage/imagePipeline.wasm.ts.3 (guest).
 *
 * Creates PENDING purchase rows. Caller (/api/checkout/intent) drives the
 * provider.charge call and receives the clientSecret for Stripe Elements.
 * finalizePurchase (payments/finalize.ts) runs on webhook success.
 *
 * All DB access goes through db/queries/* helpers.
 * No raw SQL. All inputs are zod-validated at the API boundary.
 */
import { and, eq, sql } from 'drizzle-orm';
import { bytesToHex } from '@/lib/encoding.js';
import type { DrizzleClient, TxDrizzleClient } from '@/server/db/client.js';
import { getDefaultSku } from '@/server/domain/variants/read.js';
import { type ResendEnv } from '@/server/services/email';
import { type IssueMagicLinkEnv } from '@/server/auth/credentials.js';
import {
  deals,
  order,
  orderLine,
  orderGuestContact,
  orderShareAttribution,
} from '@/server/db/schema.js';
import { createOrder, type OrdersSchema } from '@platform-modules/commerce-orders';
import * as dealQueries from '@/server/db/queries/deals.js';
import * as vendorQueries from '@/server/db/queries/vendors.js';
import type { Transaction } from '@platform-modules/db';
import { encrypt } from '@/server/db/crypto.js';
import type { PushClient } from '@/server/push/types.js';
import {
  deriveGuestAccessToken,
  issueGuestAccessToken,
  verifyGuestAccessToken,
} from '@/server/db/queries/guest-access-token.js';
import { invalidateCatalog } from '@/server/cache/invalidate.js';
import { splitCommission } from '@/lib/money.js';
import { PUBLIC_VENDOR_STATES } from '@/server/catalog/_shared/predicates.js';
import {
  asOrderLineId,
  asSkuId,
  asUserId,
  asVendorId,
  toModuleRef,
} from '@/server/platform-seams/ids.js';

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

export interface PurchaseDeps {
  db: TxDrizzleClient;
  email: ResendEnv;
  auth: IssueMagicLinkEnv;
  storage: {
    bucket: R2Bucket;
    publicBase: string;
  };
  qrSecret: string;
  push: PushClient;
}

export interface PurchaseGuestDeps extends PurchaseDeps {
  guestAccessTokenSecret: string;
}

export interface PurchaseRegisteredInput {
  userId: string;
  dealId: string;
  paymentMethodId: string;
  idempotencyKey: string;
  csrfToken?: string;
  shareSlug?: string;
}

export interface PurchaseGuestInput {
  guestEmail: string;
  guestPhone: string;
  dealId: string;
  idempotencyKey: string;
  recoverySecret: string;
  shareSlug?: string;
}

export interface PurchaseResult {
  purchaseId: string;
  /**
   * Plaintext guest access token (BUG-2). Returned only by the guest path;
   * required to view `/purchases/{id}/confirmation`. Undefined for
   * registered-user purchases — those are gated by session ownership.
   */
  guestAccessToken?: string;
}

// ---------------------------------------------------------------------------
// QR token generation - HMAC-SHA256 with Web Crypto (Cloudflare Workers safe)
// ---------------------------------------------------------------------------

/**
 * Generates an HMAC-SHA256 signed QR token.
 *
 * Payload: { purchaseId, exp (unix seconds), nonce }
 * Returns base64url-encoded signed token and its SHA-256 hash for DB storage.
 * The raw token is embedded in the QR PNG only - never stored in plain text.
 */
export async function generateQrToken(
  purchaseId: string,
  qrSecret: string,
): Promise<{ token: string; tokenHash: string }> {
  const nonce = crypto.randomUUID();
  // Token valid for 180 days (matching typical deal expiry windows)
  const exp = Math.floor(Date.now() / 1000) + 180 * 24 * 60 * 60;
  const payload = JSON.stringify({ purchaseId, exp, nonce });

  // Encode payload as base64url
  const enc = new TextEncoder();
  const payloadBytes = enc.encode(payload);
  const payloadB64 = btoa(String.fromCharCode(...payloadBytes))
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=/g, '');

  // Import HMAC key
  const key = await crypto.subtle.importKey(
    'raw',
    enc.encode(qrSecret),
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['sign'],
  );

  // Sign the payload
  const signatureBuffer = await crypto.subtle.sign('HMAC', key, enc.encode(payloadB64));
  const signatureB64 = btoa(String.fromCharCode(...new Uint8Array(signatureBuffer)))
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=/g, '');

  const token = `${payloadB64}.${signatureB64}`;

  // SHA-256 hash of the full token for DB storage (never store raw token)
  const hashBuffer = await crypto.subtle.digest('SHA-256', enc.encode(token));
  const tokenHash = bytesToHex(hashBuffer);

  return { token, tokenHash };
}

// ---------------------------------------------------------------------------
// Idempotency check
// ---------------------------------------------------------------------------

async function findByIdempotencyKey(
  db: DrizzleClient,
  idempotencyKey: string,
  buyerUserId: string | null,
) {
  const [row] = await db
    .select({ id: orderLine.id })
    .from(orderLine)
    .innerJoin(order, eq(order.id, orderLine.orderId))
    .where(
      and(
        eq(order.idempotencyKey, idempotencyKey),
        buyerUserId ? eq(order.buyerUserId, buyerUserId) : sql`${order.buyerUserId} IS NULL`,
      ),
    )
    .limit(1);
  return row ?? null;
}

// ---------------------------------------------------------------------------
// Deal validation helpers
// ---------------------------------------------------------------------------

async function validateDealForPurchase(
  db: DrizzleClient,
  dealId: string,
  buyerUserId: string | null,
) {
  const deal = await dealQueries.findById(db, dealId);
  if (!deal) {
    throw new PurchaseError('DEAL_NOT_FOUND', 'Deal not found');
  }
  if (deal.dealState !== 'ACTIVE') {
    throw new PurchaseError('DEAL_NOT_ACTIVE', `Deal is not active: ${deal.dealState}`);
  }
  if (deal.windowEnd && deal.windowEnd < new Date()) {
    throw new PurchaseError('DEAL_EXPIRED', 'Deal has expired');
  }
  if (deal.personalDealForUserId && deal.personalDealForUserId !== buyerUserId) {
    throw new PurchaseError('DEAL_NOT_FOUND', 'Deal not found');
  }
  const remaining = deal.stockRemaining ?? 0;
  if (remaining <= 0) {
    throw new PurchaseError('DEAL_SOLD_OUT', 'Deal is sold out');
  }
  const defaultSku = await getDefaultSku(db, dealId);
  if (!defaultSku.isActive) {
    throw new PurchaseError('DEAL_NOT_ACTIVE', 'Default deal option is not active');
  }
  if (defaultSku.quantitySold >= defaultSku.quantityTotal) {
    throw new PurchaseError('DEAL_SOLD_OUT', 'Default deal option is sold out');
  }
  return { deal, remaining, defaultSku };
}

function assertVendorPurchasable(accountState: string): void {
  if (!PUBLIC_VENDOR_STATES.some((state) => state === accountState)) {
    throw new PurchaseError('VENDOR_TERMINAL_INACTIVE', 'Vendor is not available for purchases');
  }
}

// ---------------------------------------------------------------------------
// Sold-out handling - mark deal SOLD_OUT with 4-hour gold expiry window
// ---------------------------------------------------------------------------

export async function checkAndMarkSoldOut(db: DrizzleClient, dealId: string) {
  const deal = await dealQueries.findById(db, dealId);
  if (!deal) return;
  if ((deal.stockRemaining ?? 1) <= 0) {
    const now = new Date();
    // 5 hours — consistent with deals.ts markSoldOut and the gold-window alarm cron
    const goldExpiry = new Date(now.getTime() + 5 * 60 * 60 * 1000);
    await db
      .update(deals)
      .set({
        dealState: 'SOLD_OUT',
        soldOutAt: now,
        soldOutGoldExpiresAt: goldExpiry,
      })
      .where(eq(deals.id, dealId));
    await invalidateCatalog(db, { scope: 'deal', dealId });
  }
}

// ---------------------------------------------------------------------------
// Typed error
// ---------------------------------------------------------------------------

export class PurchaseError extends Error {
  constructor(
    public readonly code:
      | 'DEAL_NOT_FOUND'
      | 'DEAL_NOT_ACTIVE'
      | 'DEAL_EXPIRED'
      | 'DEAL_SOLD_OUT'
      | 'PAYMENT_FAILED'
      | 'PAYMENT_METHOD_NOT_FOUND'
      | 'VENDOR_NOT_FOUND'
      | 'VENDOR_TERMINAL_INACTIVE'
      | 'IDEMPOTENCY_CONFLICT',
    message: string,
  ) {
    super(message);
    this.name = 'PurchaseError';
  }
}

// ---------------------------------------------------------------------------
// §6.2 - Registered user purchase
// ---------------------------------------------------------------------------

export async function createPurchaseRegistered(
  deps: PurchaseDeps,
  input: PurchaseRegisteredInput,
): Promise<PurchaseResult> {
  const { db } = deps;

  // --- Idempotency check ---
  const existing = await findByIdempotencyKey(db, input.idempotencyKey, input.userId);
  if (existing) {
    return { purchaseId: existing.id };
  }

  // --- Validate deal ---
  const { deal, defaultSku } = await validateDealForPurchase(db, input.dealId, input.userId);

  // --- Load vendor (existence check only) ---
  const vendor = await vendorQueries.findById(db, deal.vendorId);
  if (!vendor) {
    throw new PurchaseError('VENDOR_NOT_FOUND', `Vendor not found for deal ${input.dealId}`);
  }
  assertVendorPurchasable(vendor.accountState);

  // --- Vendor fee math ---
  const amountPaid = defaultSku.discountedPrice;
  const { commission: commissionAmount, vendor: vendorAmount } = splitCommission(
    amountPaid,
    deal.commissionRate,
  );

  // --- Share attribution: resolve __sh slug → share_links.id ---
  let shareSlugId: string | undefined;
  if (input.shareSlug) {
    const rows = (await db.execute<{ id: string }>(sql`
      SELECT id FROM share_links
      WHERE slug = ${input.shareSlug}
        AND is_active = true
      LIMIT 1
    `)) as { rows: Array<{ id: string }> };
    shareSlugId = rows.rows[0]?.id;
  }

  // --- Insert PENDING order (commerce-orders) ---
  const rawLineTotal = BigInt(Math.round(parseFloat(amountPaid) * 100));
  const unitPrice = rawLineTotal / 1n;
  const adjustedLineTotal = unitPrice * 1n;
  const commissionTotal = BigInt(Math.round(parseFloat(commissionAmount) * 100));
  const vendorSplitAmount = BigInt(Math.round(parseFloat(vendorAmount) * 100));

  return db.transaction(async (tx) => {
    const orderTx = tx as unknown as Transaction<OrdersSchema>;
    const newOrder = await createOrder(orderTx, {
      idempotencyKey: input.idempotencyKey,
      buyerRef: { userId: toModuleRef(asUserId(input.userId)) },
      currency: 'ILS',
      priceMode: 'inclusive',
      subtotal: adjustedLineTotal,
      tax: 0n,
      discount: 0n,
      total: adjustedLineTotal,
      lines: [
        {
          variantId: toModuleRef(asSkuId(defaultSku.id)),
          kind: 'voucher',
          qty: 1,
          unitPrice,
          lineTotal: adjustedLineTotal,
          currency: 'ILS',
          vendorId: toModuleRef(asVendorId(deal.vendorId)),
        },
      ],
      splits: [
        {
          vendorId: toModuleRef(asVendorId(deal.vendorId)),
          amount: vendorSplitAmount,
          funder: 'vendor',
        },
        { vendorId: null, amount: commissionTotal, funder: 'platform' },
      ],
    });

    const orderLineId = newOrder.lines[0]?.id;
    if (!orderLineId) {
      throw new PurchaseError('PAYMENT_FAILED', 'Failed to create order line');
    }

    if (shareSlugId) {
      await tx.insert(orderShareAttribution).values({
        orderId: newOrder.id,
        shareSlugId,
      });
    }

    return { purchaseId: toModuleRef(asOrderLineId(orderLineId)) };
  });
}

// ---------------------------------------------------------------------------
// §6.3 - Guest purchase
// ---------------------------------------------------------------------------

export async function createPurchaseGuest(
  deps: PurchaseGuestDeps,
  input: PurchaseGuestInput,
): Promise<PurchaseResult> {
  const { db } = deps;

  // --- Idempotency check ---
  const existing = await findByIdempotencyKey(db, input.idempotencyKey, null);
  if (existing) {
    const guestAccessToken = await deriveGuestAccessToken(
      deps.guestAccessTokenSecret,
      input.recoverySecret,
      existing.id,
    );
    if (!(await verifyGuestAccessToken(db, existing.id, guestAccessToken))) {
      throw new PurchaseError(
        'IDEMPOTENCY_CONFLICT',
        'Recovery secret does not match existing guest purchase',
      );
    }
    return {
      purchaseId: existing.id,
      guestAccessToken,
    };
  }

  // --- Validate deal ---
  const { deal, defaultSku: guestDefaultSku } = await validateDealForPurchase(
    db,
    input.dealId,
    null,
  );

  // --- Load vendor (existence check only) ---
  const guestVendor = await vendorQueries.findById(db, deal.vendorId);
  if (!guestVendor) {
    throw new PurchaseError('VENDOR_NOT_FOUND', `Vendor not found for deal ${input.dealId}`);
  }
  assertVendorPurchasable(guestVendor.accountState);

  // --- Vendor fee math ---
  const amountPaid = guestDefaultSku.discountedPrice;
  const { commission: commissionAmount, vendor: vendorAmount } = splitCommission(
    amountPaid,
    deal.commissionRate,
  );

  // --- Encrypt PII for guest fields ---
  const encryptedEmail = encrypt(input.guestEmail, deps.auth.PII_KEY);
  const encryptedPhone = encrypt(input.guestPhone, deps.auth.PII_KEY);

  // --- Share attribution: resolve __sh slug → share_links.id ---
  let shareSlugIdGuest: string | undefined;
  if (input.shareSlug) {
    const guestRows = (await db.execute<{ id: string }>(sql`
      SELECT id FROM share_links
      WHERE slug = ${input.shareSlug}
        AND is_active = true
      LIMIT 1
    `)) as { rows: Array<{ id: string }> };
    shareSlugIdGuest = guestRows.rows[0]?.id;
  }

  // --- Insert PENDING guest order (commerce-orders) ---
  const guestRawLineTotal = BigInt(Math.round(parseFloat(amountPaid) * 100));
  const guestUnitPrice = guestRawLineTotal / 1n;
  const guestAdjustedLineTotal = guestUnitPrice * 1n;
  const guestCommissionTotal = BigInt(Math.round(parseFloat(commissionAmount) * 100));
  const guestVendorSplitAmount = BigInt(Math.round(parseFloat(vendorAmount) * 100));

  const { orderLineId: guestOrderLineId, guestAccessToken } = await db.transaction(async (tx) => {
    const guestOrderTx = tx as unknown as Transaction<OrdersSchema>;
    const guestOrder = await createOrder(guestOrderTx, {
      idempotencyKey: input.idempotencyKey,
      buyerRef: { guestEmail: input.guestEmail },
      currency: 'ILS',
      priceMode: 'inclusive',
      subtotal: guestAdjustedLineTotal,
      tax: 0n,
      discount: 0n,
      total: guestAdjustedLineTotal,
      lines: [
        {
          variantId: toModuleRef(asSkuId(guestDefaultSku.id)),
          kind: 'voucher',
          qty: 1,
          unitPrice: guestUnitPrice,
          lineTotal: guestAdjustedLineTotal,
          currency: 'ILS',
          vendorId: toModuleRef(asVendorId(deal.vendorId)),
        },
      ],
      splits: [
        {
          vendorId: toModuleRef(asVendorId(deal.vendorId)),
          amount: guestVendorSplitAmount,
          funder: 'vendor',
        },
        { vendorId: null, amount: guestCommissionTotal, funder: 'platform' },
      ],
    });

    const lineId = guestOrder.lines[0]?.id;
    if (!lineId) {
      throw new PurchaseError('PAYMENT_FAILED', 'Failed to create guest order line');
    }

    await tx.insert(orderGuestContact).values({
      orderId: guestOrder.id,
      emailEnc: encryptedEmail as unknown as string,
      phoneEnc: encryptedPhone as unknown as string,
    });

    const { token: guestAccessToken } = await issueGuestAccessToken(
      tx as unknown as DrizzleClient,
      toModuleRef(asOrderLineId(lineId)),
      input.recoverySecret,
      deps.guestAccessTokenSecret,
    );

    if (shareSlugIdGuest) {
      await tx.insert(orderShareAttribution).values({
        orderId: guestOrder.id,
        shareSlugId: shareSlugIdGuest,
      });
    }

    return { orderLineId: toModuleRef(asOrderLineId(lineId)), guestAccessToken };
  });

  // No voucher is issued here. Fulfillment runs only after the Stripe webhook
  // confirms payment (payments/finalize.ts) — see JP-027.
  return { purchaseId: guestOrderLineId, guestAccessToken };
}
