/**
 * Referral money-orchestration service.
 *
 * All balance mutations delegate to @platform-modules/affiliate and host-retained bridges.
 *
 * Money is always in agorot (integer, 1/100 ₪).
 */

import { eq, and, gt, count, sql, inArray } from 'drizzle-orm';
import type { TxDrizzleClient } from '../db/client.js';
import {
  referrals,
  referralLinks,
  deals,
  vendors,
  affiliateEnrollments,
  orderLine,
  order,
  dealSkus,
} from '../db/schema.js';

const PAID_ORDER_STATUSES = ['paid', 'fulfilled', 'completed'] as const;
import { lineRedeemedAtSql, lineExpiresAtSql } from '@/server/fulfillment/voucher-line-state.js';
import type { ReferralConfig } from '../env.js';
import { accrueCommission } from '@platform-modules/affiliate';
import {
  asModuleMoneyTx,
  redeemCreditForOrderInTx,
  type HostRetainedDeps,
} from '@/server/affiliate-module/host-retained.js';
import {
  computeAffiliateCommission,
  computeReferralCommission,
  resolveAffiliateTierPct,
  isSelfVendorPurchase,
  SELF_VENDOR_PURCHASE,
} from './commission.js';
import { isSelfReferral } from './attribution.js';
import { computeMatureAt } from './maturation.js';
import { getReferralSettings } from './settings.js';
import { applyClawback } from './clawback.js';
import {
  updateReferralCommissionCounter,
  updateReferralStatus,
} from '@/server/db/queries/referrals/referral-writes.js';

const hostRetainedDeps: HostRetainedDeps = { now: () => new Date() };

// ---------------------------------------------------------------------------
// 1. qualifyRefereeFirstPaid
// ---------------------------------------------------------------------------

/**
 * Called once a purchase is marked PAID.
 * Qualifies the pending referral for the referee's first paid order and credits
 * the referral_reward to the referrer.
 *
 * The entire flow runs inside ONE transaction with a SELECT … FOR UPDATE lock
 * on the referral row to prevent double-qualification under concurrent calls.
 *
 * Idempotency key: (referral_reward, referral, ref.id)
 * memo = purchaseId — clawbackForRefund uses this to locate the reward entry.
 *
 * Returns { referrerUserId, rewardAgorot } when a qualification occurred (first time),
 * or null when the referral was already qualified / not found / self-referral.
 * Callers use this to fire post-tx notifications without breaking the money path.
 */
export async function qualifyRefereeFirstPaid(
  db: TxDrizzleClient,
  args: { refereeUserId: string; purchaseId: string; amountPaidAgorot: number },
): Promise<{ referrerUserId: string; rewardAgorot: number } | null> {
  let result: { referrerUserId: string; rewardAgorot: number } | null = null;

  await db.transaction(async (tx) => {
    const [ref] = await tx
      .select()
      .from(referrals)
      .where(and(eq(referrals.refereeUserId, args.refereeUserId), eq(referrals.status, 'pending')))
      .limit(1)
      .for('update');
    if (!ref) return;

    const paidCountResult = await tx
      .select({ value: count() })
      .from(order)
      .where(
        and(
          eq(order.buyerUserId, args.refereeUserId),
          inArray(order.status, [...PAID_ORDER_STATUSES]),
        ),
      );
    if ((paidCountResult[0]?.value ?? 0) > 1) return;

    const self = await isSelfReferral(tx, {
      referrerUserId: ref.referrerUserId,
      refereeUserId: args.refereeUserId,
    });
    if (self) {
      await updateReferralStatus(tx, ref.id, { status: 'rejected' });
      return;
    }

    const now = new Date();
    const settings = await getReferralSettings(db);
    if (ref.kind === 'affiliate') {
      const [link] = await tx
        .select()
        .from(referralLinks)
        .where(eq(referralLinks.id, ref.linkId))
        .limit(1);
      // Stamp commissionPct only for an explicit link override; null lets tier resolution run at accrual.
      const commissionPct = link?.commissionPctOverride ?? null;
      const windowDays = link?.commissionWindowDaysOverride ?? settings.affiliateWindowDays;
      const windowStart = ref.clickedAt ?? now;
      await updateReferralStatus(tx, ref.id, {
        status: 'qualified',
        qualifiedAt: now,
        commissionPct,
        commissionWindowEndsAt: new Date(windowStart.getTime() + windowDays * 86_400_000),
        commissionOrdersRemaining: settings.affiliateMaxOrders,
      });
    } else {
      await updateReferralStatus(tx, ref.id, { status: 'qualified', qualifiedAt: now });
    }

    // Fetch orderLine + deal + vendor to compute mature_at (B1 fix) and self-vendor guard.
    const [purchaseRow] = await tx
      .select({
        createdAt: orderLine.createdAt,
        redeemedAt: lineRedeemedAtSql(orderLine.id),
        expiresAt: lineExpiresAtSql(orderLine.id),
        dealType: deals.dealType,
        vendorOwnerUserId: vendors.ownerUserId,
      })
      .from(orderLine)
      .innerJoin(dealSkus, eq(dealSkus.id, orderLine.variantId))
      .innerJoin(deals, eq(deals.id, dealSkus.dealId))
      .innerJoin(vendors, eq(vendors.id, deals.vendorId))
      .where(eq(orderLine.id, args.purchaseId))
      .limit(1);

    const matureAt = purchaseRow
      ? computeMatureAt(
          {
            kind: purchaseRow.dealType === 'COUPON' ? 'coupon' : 'physical',
            paidAt: purchaseRow.createdAt,
            expiresAt: purchaseRow.expiresAt,
            redeemedAt: purchaseRow.redeemedAt ?? null,
          },
          { holdDays: settings.holdDays },
        )
      : new Date();

    // B3: self-vendor block — referrer or buyer is the vendor owner → reward = 0, audit row.
    const blockedSelfVendor =
      purchaseRow != null &&
      (isSelfVendorPurchase(ref.referrerUserId, purchaseRow.vendorOwnerUserId) ||
        isSelfVendorPurchase(args.refereeUserId, purchaseRow.vendorOwnerUserId));

    const referralRewardAgorot = blockedSelfVendor
      ? 0
      : computeReferralCommission(args.amountPaidAgorot, settings);
    const isNew = (
      await accrueCommission(asModuleMoneyTx(tx), {
        userId: ref.referrerUserId,
        amountMinor: BigInt(referralRewardAgorot),
        entryType: 'referral_reward',
        sourceType: 'referral',
        sourceId: ref.id,
        matureAt,
        referralId: ref.id,
        memo: blockedSelfVendor ? SELF_VENDOR_PURCHASE : args.purchaseId,
        resolvedPct: settings.referralPct,
      })
    ).inserted;

    // Signal to the caller that a fresh qualification occurred.
    if (isNew) {
      result = { referrerUserId: ref.referrerUserId, rewardAgorot: referralRewardAgorot };
    }
  });

  return result;
}

// ---------------------------------------------------------------------------
// 2. accrueAffiliateCommission
// ---------------------------------------------------------------------------

/**
 * Accrue commission for the affiliate referrer on a subsequent referee purchase.
 * Only fires when the referral is qualified, the referrer has an active enrollment,
 * the window is open, and orders remain.
 *
 * The referral row is locked (SELECT … FOR UPDATE) so the orders-remaining cap
 * holds under concurrent webhook delivery.
 *
 * Idempotency key: (affiliate_commission, purchase, purchaseId)
 */
export async function accrueAffiliateCommission(
  db: TxDrizzleClient,
  _cfg: ReferralConfig,
  args: {
    refereeUserId: string;
    purchaseId: string;
    amountPaidAgorot: number;
    orderLineId?: string;
  },
): Promise<number> {
  let creditedAgorot = 0;

  await db.transaction(async (tx) => {
    const [joined] = await tx
      .select()
      .from(referrals)
      .innerJoin(
        affiliateEnrollments,
        and(
          eq(affiliateEnrollments.userId, referrals.referrerUserId),
          eq(affiliateEnrollments.status, 'active'),
        ),
      )
      .where(
        and(
          eq(referrals.refereeUserId, args.refereeUserId),
          eq(referrals.kind, 'affiliate'),
          eq(referrals.status, 'qualified'),
        ),
      )
      .limit(1)
      .for('update');
    if (!joined) return;
    const ref = joined.referrals;

    const now = Date.now();
    if (ref.commissionWindowEndsAt && now >= ref.commissionWindowEndsAt.getTime()) return;
    if (ref.commissionOrdersRemaining !== null && ref.commissionOrdersRemaining <= 0) return;

    // B3: self-vendor block — referrer or buyer is the vendor owner → commission = 0, audit row.
    const [dealVendor] = await tx
      .select({ ownerUserId: vendors.ownerUserId })
      .from(orderLine)
      .innerJoin(dealSkus, eq(dealSkus.id, orderLine.variantId))
      .innerJoin(deals, eq(deals.id, dealSkus.dealId))
      .innerJoin(vendors, eq(vendors.id, deals.vendorId))
      .where(eq(orderLine.id, args.purchaseId))
      .limit(1);

    if (
      dealVendor &&
      (isSelfVendorPurchase(ref.referrerUserId, dealVendor.ownerUserId) ||
        isSelfVendorPurchase(args.refereeUserId, dealVendor.ownerUserId))
    ) {
      await accrueCommission(asModuleMoneyTx(tx), {
        userId: ref.referrerUserId,
        amountMinor: 0n,
        entryType: 'affiliate_commission',
        sourceType: args.orderLineId ? 'order_line' : 'purchase',
        sourceId: args.orderLineId ?? args.purchaseId,
        matureAt: new Date(),
        referralId: ref.id,
        memo: SELF_VENDOR_PURCHASE,
      });
      return;
    }

    const settings = await getReferralSettings(db);

    const salesCountResult = await tx
      .select({ value: count() })
      .from(order)
      .innerJoin(referrals, eq(referrals.refereeUserId, order.buyerUserId))
      .where(
        and(
          eq(referrals.referrerUserId, ref.referrerUserId),
          eq(referrals.kind, 'affiliate'),
          inArray(order.status, [...PAID_ORDER_STATUSES]),
          gt(order.createdAt, new Date(Date.now() - 30 * 86_400_000)),
        ),
      );
    const monthlySales = salesCountResult[0]?.value ?? 1;

    let resolvedPct: number;
    if (ref.commissionPct != null) {
      // Explicit override stored as % of sale (admin/UI contract); ceiling applied in computeAffiliateCommission.
      resolvedPct = Math.max(0, ref.commissionPct);
    } else {
      resolvedPct = resolveAffiliateTierPct(monthlySales, settings);
    }

    const amount = computeAffiliateCommission(args.amountPaidAgorot, resolvedPct);
    if (amount <= 0) return;

    // Fetch orderLine + deal to compute mature_at (B1 fix).
    const [purchaseRow] = await tx
      .select({
        createdAt: orderLine.createdAt,
        redeemedAt: lineRedeemedAtSql(orderLine.id),
        expiresAt: lineExpiresAtSql(orderLine.id),
        dealType: deals.dealType,
      })
      .from(orderLine)
      .innerJoin(dealSkus, eq(dealSkus.id, orderLine.variantId))
      .innerJoin(deals, eq(deals.id, dealSkus.dealId))
      .where(eq(orderLine.id, args.purchaseId))
      .limit(1);

    const matureAt = purchaseRow
      ? computeMatureAt(
          {
            kind: purchaseRow.dealType === 'COUPON' ? 'coupon' : 'physical',
            paidAt: purchaseRow.createdAt,
            expiresAt: purchaseRow.expiresAt,
            redeemedAt: purchaseRow.redeemedAt ?? null,
          },
          { holdDays: settings.holdDays },
        )
      : new Date();

    const isNew = (
      await accrueCommission(asModuleMoneyTx(tx), {
        userId: ref.referrerUserId,
        amountMinor: BigInt(amount),
        entryType: 'affiliate_commission',
        sourceType: args.orderLineId ? 'order_line' : 'purchase',
        sourceId: args.orderLineId ?? args.purchaseId,
        matureAt,
        referralId: ref.id,
        memo: args.purchaseId,
        resolvedPct,
      })
    ).inserted;
    if (isNew) {
      creditedAgorot = amount;
      if (ref.commissionOrdersRemaining !== null) {
        await updateReferralCommissionCounter(tx, ref.id, ref.commissionOrdersRemaining - 1);
      }
    }
  });

  return creditedAgorot;
}

// ---------------------------------------------------------------------------
// 3. redeemCreditForOrder
// ---------------------------------------------------------------------------

/**
 * Apply wallet credit toward an order.  Deducts min(balance, orderTotal).
 * Returns the amount actually applied (0 if nothing to apply).
 *
 * The wallet row is locked (SELECT … FOR UPDATE) and the applied amount is
 * recomputed inside the transaction to prevent overdraw / double-spend under
 * concurrent redemption attempts.
 *
 * Idempotency key: (redemption, purchase, purchaseId)
 */
export async function redeemCreditForOrder(
  db: TxDrizzleClient,
  args: { userId: string; orderTotalAgorot: number; purchaseId: string },
): Promise<number> {
  return db.transaction(async (tx) => {
    const applied = await redeemCreditForOrderInTx(tx, hostRetainedDeps, {
      userId: args.userId,
      orderTotalMinor: BigInt(args.orderTotalAgorot),
      purchaseId: args.purchaseId,
    });
    return applied > 0n ? Number(applied) : 0;
  });
}

// ---------------------------------------------------------------------------
// 4. clawbackForRefund
// ---------------------------------------------------------------------------

/**
 * Claw back earnings tied to a refunded purchase.
 * refundFraction ∈ [0, 1] — 1 = full refund, 0.5 = half refund.
 * refundEventId — the Stripe refund/dispute event id, unique per refund event.
 *
 * Delegates to applyClawback (clawback.ts) which classifies each entry by swept_at
 * and debits the correct wallet column. matured_agorot and balance_agorot are
 * negative-capable (carry-forward debt recovered against future earnings); only
 * pending_agorot is floored at 0 via GREATEST(0,..).
 *
 * Using refundEventId in the clawback sourceId means:
 *   - Distinct refund events (partial then full) each produce their own clawback row.
 *   - Retries of the same event are no-ops (idempotent via ON CONFLICT DO NOTHING).
 *   - The old per-purchase key would have blocked the second clawback on partial+full.
 *
 * Finds all positive earn entries for this purchase:
 *   - affiliate_commission with sourceId = purchaseId
 *   - referral_reward with memo = purchaseId
 *
 * For each entry, calls applyClawback with the proportional amount for this refund event.
 */
export async function clawbackForRefund(
  db: TxDrizzleClient,
  args: { purchaseId: string; refundEventId: string; refundFraction: number },
): Promise<void> {
  const { purchaseId, refundEventId, refundFraction } = args;
  const orderLineId: string | null = purchaseId;

  type EarnEntryRow = { id: string; amount_minor: bigint | number | string };
  type SqlRows<T extends Record<string, unknown>> = { rows: T[] };

  const commissionEntries = (await db.execute(sql`
      SELECT ae.entry_id AS id, le.delta AS amount_minor
      FROM affiliate_entries ae
      INNER JOIN ledger_entries le ON le.id = ae.entry_id
      WHERE ae.entry_type = 'affiliate_commission'
        AND (
          (ae.source_type = 'order_line' AND ae.source_id = ${orderLineId})
          OR
          (ae.source_type = 'purchase' AND ae.source_id = ${purchaseId})
        )
        AND le.delta > 0
  `)) as SqlRows<EarnEntryRow>;

  const rewardEntries = (await db.execute(sql`
      SELECT ae.entry_id AS id, le.delta AS amount_minor
      FROM affiliate_entries ae
      INNER JOIN ledger_entries le ON le.id = ae.entry_id
      WHERE ae.entry_type = 'referral_reward'
        AND ae.memo = ${purchaseId}
        AND le.delta > 0
  `)) as SqlRows<EarnEntryRow>;

  for (const entry of [...commissionEntries.rows, ...rewardEntries.rows]) {
    const amountMinor =
      typeof entry.amount_minor === 'bigint' ? entry.amount_minor : BigInt(entry.amount_minor);
    const proportionalAmount = Math.round(Number(amountMinor) * refundFraction);
    if (proportionalAmount <= 0) continue;

    await applyClawback(db, entry.id, proportionalAmount, refundEventId);
  }
}
