import { Types } from "mongoose";
import UserModel from "../models/UserModel";
import WalletTransactionModel from "../models/WalletTransactionModel";
import { getStripe, stripeConfig } from "../config/stripe";
import { isStripeMissingCustomerError } from "./stripeCustomerService";

const DUPLICATE_KEY_CODE = 11000;
const SYNC_PI_LIMIT = 100;
/** Max ledger top-up rows we poll Stripe for on each GET /wallet (covers 2nd+ top-ups even when PI list misses). */
const SYNC_LOCAL_PI_ROWS_LIMIT = 12;

/** Per-user cooldown so GET /wallet doesn't call Stripe on every request. */
const SYNC_THROTTLE_MS = 15_000;
const lastSyncAt = new Map<string, number>();
const MAX_THROTTLE_MAP_SIZE = 5_000;

function shouldThrottleSync(userId: string): boolean {
  const now = Date.now();
  const last = lastSyncAt.get(userId);
  if (last && now - last < SYNC_THROTTLE_MS) return true;

  if (lastSyncAt.size >= MAX_THROTTLE_MAP_SIZE) {
    const oldest = [...lastSyncAt.entries()].sort((a, b) => a[1] - b[1]);
    for (let i = 0; i < Math.floor(MAX_THROTTLE_MAP_SIZE / 4); i++) {
      lastSyncAt.delete(oldest[i][0]);
    }
  }
  lastSyncAt.set(userId, now);
  return false;
}

function isDuplicateKey(err: unknown): boolean {
  return (
    typeof err === "object" &&
    err !== null &&
    "code" in err &&
    (err as { code: number }).code === DUPLICATE_KEY_CODE
  );
}

/**
 * Returns true ONLY when the duplicate key error is on the `stripePaymentIntentId`
 * index — i.e. the row we're trying to insert is genuinely already present.
 *
 * This is critical: the previous implementation treated EVERY E11000 as "already
 * recorded" and fell back to an updateOne by stripePaymentIntentId. When the
 * duplicate was actually on the (userId, externalRef:null) sparse-unique index
 * (the "phantom duplicate" bug), that fallback matched zero rows and silently
 * succeeded — so top-ups vanished without a trace.
 */
function isDuplicateOnPaymentIntentId(err: unknown): boolean {
  if (!isDuplicateKey(err)) return false;
  const e = err as {
    keyPattern?: Record<string, number>;
    keyValue?: Record<string, unknown>;
    message?: string;
  };
  if (e.keyPattern && "stripePaymentIntentId" in e.keyPattern) return true;
  if (e.keyValue && "stripePaymentIntentId" in e.keyValue) return true;
  // Fallback for older drivers that don't populate keyPattern/keyValue.
  return /stripePaymentIntentId/i.test(e.message ?? "");
}

export async function getWalletBalanceCents(userId: string): Promise<number> {
  const user = await UserModel.findById(userId)
    .select("walletBalanceCents")
    .lean();
  return user?.walletBalanceCents ?? 0;
}

export interface RecentTopUp {
  paymentIntentId: string;
  status: string;
  amountCents: number;
  amountReceivedCents: number;
  currency: string;
  createdAt: number;
  credited: boolean;
}

export interface ReconcileResult {
  expectedCents: number;
  actualBefore: number;
  actualAfter: number;
  correctedCents: number;
  staleRecovered: number;
  skippedDueToInFlight: boolean;
}

// ─────────────────────────────────────────────────────────────────────────────
// SYNC + RECONCILE (the heart of the wallet system)
// ─────────────────────────────────────────────────────────────────────────────

/**
 * Pull succeeded wallet-topup PaymentIntents from Stripe and ensure each one has a
 * matching "completed" ledger row. Then reconcile the User.walletBalanceCents to
 * equal the sum of all completed deltas.
 *
 * Algorithm:
 *   1. Collect candidate PIs from two sources, deduped by id:
 *      - Source A: recent top-up rows in our DB (any status) → fetch each PI from Stripe
 *        so a second top-up is never missed when it is still pending while an older row is already completed.
 *      - Source B: Stripe customer's PI list (catches succeeded intents with no DB row yet if customer id is known)
 *   2. For each succeeded PI: atomic UPSERT a "completed" ledger row.
 *      For each canceled PI: mark its row "failed".
 *   3. Run `reconcileWalletBalance` so User.walletBalanceCents matches the ledger.
 *
 * This is INTENTIONALLY simpler than the old "pending → processing → completed"
 * state machine. The previous flow was losing rows when create-then-$inc partially
 * failed; the new flow has zero in-between states (rows are "completed" or nothing)
 * and the balance is derived authoritatively from the ledger.
 */
export async function syncSucceededWalletTopUpsFromStripe(
  userId: string,
  stripeCustomerId: string | null | undefined,
  opts?: { force?: boolean },
): Promise<RecentTopUp[]> {
  if (!stripeConfig.secretKey) {
    return [];
  }
  if (!Types.ObjectId.isValid(userId)) {
    return [];
  }
  if (!opts?.force && shouldThrottleSync(userId)) {
    return [];
  }

  const uid = String(userId);
  const stripe = getStripe();
  const recent: RecentTopUp[] = [];
  const seenPiIds = new Set<string>();

  // ─── Source A: recent top-up ledger rows (any status) ────────────────────────
  const localLedgerRows = await WalletTransactionModel.find({
    userId: new Types.ObjectId(userId),
    type: "topup",
    stripePaymentIntentId: { $type: "string" },
  })
    .sort({ createdAt: -1 })
    .limit(SYNC_LOCAL_PI_ROWS_LIMIT)
    .select("stripePaymentIntentId")
    .lean();

  const localPiIdList = Array.from(
    new Set(
      localLedgerRows
        .map((row) => row.stripePaymentIntentId)
        .filter((pid): pid is string => typeof pid === "string" && pid.length > 0)
    )
  );

  const localPiResults = await Promise.all(
    localPiIdList.map(async (pid) => {
      try {
        return await stripe.paymentIntents.retrieve(pid);
      } catch (e) {
        if (isStripeMissingCustomerError(e) && stripeCustomerId) {
          await UserModel.findByIdAndUpdate(userId, {
            $set: { stripeCustomerId: null },
          });
          console.warn("[wallet] Cleared stale stripeCustomerId", userId);
        } else {
          console.warn(
            "[wallet] Could not retrieve PI",
            pid,
            ":",
            e instanceof Error ? e.message : e
          );
        }
        return null;
      }
    })
  );

  // ─── Source B: Stripe customer's PI list ─────────────────────────────────────
  let stripeListPis: any[] = [];
  if (stripeCustomerId) {
    try {
      const list = await stripe.paymentIntents.list({
        customer: stripeCustomerId,
        limit: SYNC_PI_LIMIT,
      });
      stripeListPis = list.data;
    } catch (e) {
      if (isStripeMissingCustomerError(e)) {
        await UserModel.findByIdAndUpdate(userId, {
          $set: { stripeCustomerId: null },
        });
        console.warn(
          "[wallet] Cleared stale stripeCustomerId (list)",
          userId
        );
      } else {
        console.warn(
          "[wallet] Could not list PIs for customer:",
          e instanceof Error ? e.message : e
        );
      }
    }
  }

  const allPis = [
    ...localPiResults.filter((p): p is NonNullable<typeof p> => p !== null),
    ...stripeListPis,
  ];

  // ─── Process each unique PI ──────────────────────────────────────────────────
  for (const pi of allPis) {
    if (!pi || seenPiIds.has(pi.id)) continue;
    seenPiIds.add(pi.id);

    if (pi.metadata?.purpose !== "wallet_topup") continue;
    const piUserId = String(pi.metadata?.userId ?? "");
    if (piUserId && piUserId !== uid) continue;
    const currency = (pi.currency ?? "usd").toLowerCase();
    if (currency !== "usd") continue;

    try {
      await applyStripePaymentIntentToLedger({
        userId,
        pi,
        currency,
      });
    } catch (e) {
      console.error(
        "[wallet] applyStripePaymentIntentToLedger failed for pi=" + pi.id,
        e instanceof Error ? e.message : e
      );
    }

    const finalRow = await WalletTransactionModel.findOne({
      stripePaymentIntentId: pi.id,
    })
      .select("status")
      .lean();
    const credited = finalRow?.status === "completed";

    recent.push({
      paymentIntentId: pi.id,
      status: pi.status,
      amountCents: pi.amount,
      amountReceivedCents: pi.amount_received ?? 0,
      currency,
      createdAt: pi.created,
      credited,
    });
  }

  // ─── Reconcile balance to match the ledger ───────────────────────────────────
  await reconcileWalletBalance(userId);

  recent.sort((a, b) => b.createdAt - a.createdAt);
  return recent;
}

/**
 * Apply a single Stripe PaymentIntent to the ledger.
 *
 * - succeeded: ensure a "completed" row exists with the right delta.
 * - canceled:  mark any matching pending/processing row as "failed".
 * - other:     leave any pending row in place — next sync will pick it up.
 *
 * Pattern: explicit UPDATE-or-CREATE (NOT findOneAndUpdate+upsert).
 *
 * Why not upsert? Mongoose v8 `findOneAndUpdate` with `upsert: true` runs
 * schema validation on the inserted doc. Our schema has `balanceAfterCents`
 * as `required: true` with no default, so $setOnInsert without that field
 * silently fails the insert path (only the UPDATE path works). This is
 * EXACTLY the bug that caused the 3 older PIs to never get DB rows after
 * being deleted manually.
 *
 * The new pattern:
 *   1. updateOne — succeeds if a row already exists (any status).
 *   2. If no row matched, create a fresh row with ALL required fields.
 *   3. If create races with another caller (duplicate key), updateOne again.
 *
 * No $inc here. Balance is derived from the ledger by reconcileWalletBalance.
 */
async function applyStripePaymentIntentToLedger(args: {
  userId: string;
  pi: any;
  currency: string;
}): Promise<void> {
  const { userId, pi, currency } = args;
  const amount = pi.amount_received ?? pi.amount;

  if (pi.status === "succeeded") {
    const updateExisting = async () =>
      WalletTransactionModel.updateOne(
        { stripePaymentIntentId: pi.id },
        {
          $set: {
            userId: new Types.ObjectId(userId),
            deltaCents: amount,
            currency,
            status: "completed",
            description: "Wallet top-up (Stripe)",
          },
        }
      );

    // Step 1 — try update first.
    const firstUpdate = await updateExisting();
    if (firstUpdate.matchedCount > 0) {
      return;
    }

    // Step 2 — no row exists; create one with ALL required schema fields set.
    try {
      await WalletTransactionModel.create({
        userId: new Types.ObjectId(userId),
        deltaCents: amount,
        balanceAfterCents: 0, // recomputed by reconcileWalletBalance via ledger sum
        currency,
        type: "topup",
        status: "completed",
        description: "Wallet top-up (Stripe)",
        stripePaymentIntentId: pi.id,
      });
      console.log(
        `[wallet] Created completed row for pi=${pi.id} userId=${userId} amount=${amount}`
      );
      return;
    } catch (e) {
      // Step 3 — race with concurrent caller for this *same* PI (rare).
      // Re-update what they inserted.
      if (isDuplicateOnPaymentIntentId(e)) {
        await updateExisting();
        return;
      }
      // Any other duplicate-key error means a different unique index is
      // blocking us (e.g. a misconfigured (userId, externalRef) index). Surface
      // it loudly — silent return here is what caused the original "ghost
      // top-up" bug where rows never landed in the DB.
      console.error(
        `[wallet] CREATE failed for pi=${pi.id} userId=${userId} amount=${amount}:`,
        e instanceof Error ? e.message : e,
        e && typeof e === "object" && "keyPattern" in e
          ? { keyPattern: (e as any).keyPattern, keyValue: (e as any).keyValue }
          : undefined
      );
      throw e;
    }
  }

  if (pi.status === "canceled") {
    await WalletTransactionModel.updateOne(
      {
        stripePaymentIntentId: pi.id,
        status: { $in: ["pending", "processing"] },
      },
      {
        $set: {
          status: "failed",
          description: "Top-up canceled on Stripe",
        },
      }
    );
  }
  // For other PI statuses (requires_payment_method, requires_confirmation,
  // requires_action, processing) we leave the pending row in place — the next
  // sync will pick it up once Stripe transitions it.
}

/**
 * Set walletBalanceCents = max(0, sum of completed deltaCents) for the user.
 *
 * This is the AUTHORITATIVE balance source. The ledger is the source of truth;
 * walletBalanceCents is derived from it. Idempotent. Always safe to run.
 *
 * Race note: applyWalletDebit $inc's the user balance then inserts the debit
 * row; reconcileWalletBalance is called afterward on that path so a concurrent
 * GET /wallet cannot leave walletBalanceCents out of sync with the ledger for long.
 */
export async function reconcileWalletBalance(
  userId: string
): Promise<ReconcileResult> {
  const userObjId = new Types.ObjectId(userId);

  // Phase 1: recover stale "processing" rows. They get marked "completed" with
  // their existing deltaCents so they're included in the sum. (We never $inc
  // here — sum-based set below is the source of truth.)
  const STALE_PROCESSING_MS = 60 * 1000;
  const staleThreshold = new Date(Date.now() - STALE_PROCESSING_MS);
  const staleRecover = await WalletTransactionModel.updateMany(
    {
      userId: userObjId,
      status: "processing",
      updatedAt: { $lt: staleThreshold },
    },
    {
      $set: {
        status: "completed",
        description: "Wallet top-up (Stripe) — reconciled from stale processing",
      },
    }
  );
  const staleRecovered = staleRecover.modifiedCount ?? 0;
  if (staleRecovered > 0) {
    console.warn(
      `[wallet:reconcile] Recovered ${staleRecovered} stale processing row(s) for userId=${userId}`
    );
  }

  // Phase 2: compute expected balance from the ledger.
  const [agg] = await WalletTransactionModel.aggregate<{ totalDelta: number }>([
    { $match: { userId: userObjId, status: "completed" } },
    { $group: { _id: null, totalDelta: { $sum: "$deltaCents" } } },
  ]);
  const expectedCents = Math.max(0, agg?.totalDelta ?? 0);

  const userBefore = await UserModel.findById(userId)
    .select("walletBalanceCents")
    .lean();
  if (!userBefore) {
    return {
      expectedCents,
      actualBefore: 0,
      actualAfter: 0,
      correctedCents: 0,
      staleRecovered,
      skippedDueToInFlight: false,
    };
  }
  const actualBefore = userBefore.walletBalanceCents ?? 0;

  if (actualBefore === expectedCents) {
    return {
      expectedCents,
      actualBefore,
      actualAfter: actualBefore,
      correctedCents: 0,
      staleRecovered,
      skippedDueToInFlight: false,
    };
  }

  // Authoritatively set the balance to the ledger sum.
  const updated = await UserModel.findByIdAndUpdate(
    userId,
    { $set: { walletBalanceCents: expectedCents } },
    { new: true }
  );
  const actualAfter = updated?.walletBalanceCents ?? expectedCents;
  const corrected = actualAfter - actualBefore;

  console.warn(
    `[wallet:reconcile] Set userId=${userId} balance from ${actualBefore} ` +
      `to ${actualAfter} (delta=${corrected})`
  );

  return {
    expectedCents,
    actualBefore,
    actualAfter,
    correctedCents: corrected,
    staleRecovered,
    skippedDueToInFlight: false,
  };
}

// ─────────────────────────────────────────────────────────────────────────────
// PUBLIC API USED BY CONTROLLER + WEBHOOK
// ─────────────────────────────────────────────────────────────────────────────

/**
 * Insert a placeholder ledger row when a top-up PaymentIntent is created.
 * Idempotent (unique stripePaymentIntentId index dedupes). On unexpected
 * failure we throw — the caller MUST be aware so it can retry or notify the
 * user. This avoids the previous silent-failure mode that left orphan PIs
 * with no DB rows.
 */
export async function recordPendingWalletTopUp(args: {
  userId: string;
  amountCents: number;
  currency: string;
  stripePaymentIntentId: string;
}): Promise<void> {
  const { userId, amountCents, currency, stripePaymentIntentId } = args;
  if (!Types.ObjectId.isValid(userId)) {
    throw new Error("Invalid userId for wallet top-up");
  }
  try {
    await WalletTransactionModel.create({
      userId: new Types.ObjectId(userId),
      deltaCents: amountCents,
      balanceAfterCents: 0,
      currency,
      type: "topup",
      status: "pending",
      description: "Wallet top-up — awaiting Stripe confirmation",
      stripePaymentIntentId,
    });
  } catch (e) {
    if (isDuplicateOnPaymentIntentId(e)) {
      // Genuine "this exact PI is already recorded" — refresh the amount in
      // case the same PI was reused with a different amount.
      await WalletTransactionModel.updateOne(
        { stripePaymentIntentId, status: { $in: ["pending", "failed"] } },
        { $set: { deltaCents: amountCents, currency } }
      );
      return;
    }
    // A duplicate on a DIFFERENT index (or any other failure) means the row
    // was NOT inserted. Throw so the caller surfaces the error instead of
    // returning a Stripe PaymentIntent that has no DB ledger row behind it.
    console.error(
      "[wallet] recordPendingWalletTopUp insert FAILED:",
      e instanceof Error ? e.message : e,
      e && typeof e === "object" && "keyPattern" in e
        ? { keyPattern: (e as any).keyPattern, keyValue: (e as any).keyValue }
        : undefined
    );
    throw e;
  }
}

/**
 * Webhook entry-point: idempotently apply a Stripe PaymentIntent to the ledger
 * and reconcile the balance.
 *
 * Equivalent to running a one-PI sync: upsert "completed" row + reconcile.
 */
export async function creditWalletFromTopUpStripe(args: {
  userId: string;
  amountCents: number;
  currency: string;
  stripePaymentIntentId: string;
}): Promise<void> {
  const { userId, amountCents, currency, stripePaymentIntentId } = args;
  if (!Types.ObjectId.isValid(userId)) {
    throw new Error("Invalid userId in payment metadata");
  }

  await applyStripePaymentIntentToLedger({
    userId,
    pi: {
      id: stripePaymentIntentId,
      status: "succeeded",
      amount: amountCents,
      amount_received: amountCents,
      currency,
    },
    currency,
  });

  await reconcileWalletBalance(userId);
}

/**
 * Debit wallet (in-app payment or withdrawal). Optional externalRef for idempotency.
 *
 * Flow: atomic compare-and-$inc on User, then insert completed debit ledger row,
 * then reconcile so cached balance matches the ledger (heals races with GET /wallet).
 */
export async function applyWalletDebit(args: {
  userId: string;
  amountCents: number;
  type: "payment_debit" | "withdrawal";
  description: string;
  externalRef?: string;
  metadata?: Record<string, unknown>;
}): Promise<
  | { ok: true; balanceAfterCents: number; transactionId: string }
  | { ok: false; reason: "insufficient_funds" }
> {
  const { userId, amountCents, type, description, externalRef, metadata } = args;

  if (externalRef) {
    const existing = await WalletTransactionModel.findOne({
      userId: new Types.ObjectId(userId),
      externalRef,
    }).lean();
    if (existing) {
      return {
        ok: true,
        balanceAfterCents: existing.balanceAfterCents,
        transactionId: String(existing._id),
      };
    }
  }

  // Atomic compare-and-decrement. If user has insufficient balance, this no-ops.
  const user = await UserModel.findOneAndUpdate(
    {
      _id: userId,
      $expr: {
        $gte: [{ $ifNull: ["$walletBalanceCents", 0] }, amountCents],
      },
    },
    { $inc: { walletBalanceCents: -amountCents } },
    { new: true }
  );

  if (!user) {
    return { ok: false, reason: "insufficient_funds" };
  }

  const after = user.walletBalanceCents ?? 0;
  const tx = await WalletTransactionModel.create({
    userId: new Types.ObjectId(userId),
    deltaCents: -amountCents,
    balanceAfterCents: after,
    currency: "usd",
    type,
    status: "completed",
    description,
    // Only set externalRef when the caller actually supplied one. A null
    // value here would land in the (userId, externalRef) unique index and,
    // depending on the index definition, could collide with other null rows.
    ...(externalRef ? { externalRef } : {}),
    metadata,
  });

  // Heal drift if reconcileWalletBalance ran between the $inc above and this
  // insert (GET /wallet sync can reset cached balance from ledger sum).
  const reconciled = await reconcileWalletBalance(userId);

  return {
    ok: true,
    balanceAfterCents: reconciled.actualAfter,
    transactionId: String(tx._id),
  };
}
