/**
 * Standalone verification of the wallet reconcile + sync algorithm.
 *
 * Mocks UserModel + WalletTransactionModel with in-memory stubs so we can
 * exercise the algorithm without spinning up Mongo.
 *
 * Covers:
 *   - The exact user bug (3 succeeded PIs, balance shows only first one)
 *   - Missing rows (sync upserts and reconcile sets balance)
 *   - Stale processing rows
 *   - Mixed corruption
 *   - Already-correct
 *   - Over-balance correction (authoritative set in both directions)
 *
 * Run: npx ts-node src/scripts/testWalletReconcile.ts
 */

import { Types } from "mongoose";

type Status = "pending" | "processing" | "completed" | "failed";

interface MockTxRow {
  _id: string;
  userId: Types.ObjectId;
  deltaCents: number;
  balanceAfterCents?: number;
  status: Status;
  type: string;
  description?: string;
  stripePaymentIntentId?: string | null;
  externalRef?: string | null;
  updatedAt: Date;
}

const txStore: MockTxRow[] = [];
const userStore: Record<string, { walletBalanceCents: number }> = {};
let idCounter = 0;
const nextId = () => `tx_${++idCounter}`;

function matches(row: MockTxRow, filter: Record<string, unknown>): boolean {
  for (const [k, v] of Object.entries(filter)) {
    if (k === "_id") {
      if (row._id !== v) return false;
    } else if (k === "userId") {
      if (String((row as any).userId) !== String(v)) return false;
    } else if (k === "updatedAt") {
      const cond = v as Record<string, Date>;
      if (cond.$lt && !(row.updatedAt < cond.$lt)) return false;
      if (cond.$gte && !(row.updatedAt >= cond.$gte)) return false;
    } else if (k === "stripePaymentIntentId") {
      if ((row as any).stripePaymentIntentId !== v) return false;
    } else {
      const val = (row as any)[k];
      if (typeof v === "object" && v !== null && "$in" in (v as any)) {
        if (!((v as any).$in as unknown[]).includes(val)) return false;
      } else if (val !== v) {
        return false;
      }
    }
  }
  return true;
}

// Mock that EMULATES the real Mongo behaviour: insert (create) rejects rows
// missing required fields. Critical for catching the bug where $setOnInsert
// omitted balanceAfterCents and Mongo silently dropped the insert.
const REQUIRED_ON_INSERT = ["userId", "deltaCents", "balanceAfterCents", "type"];

const mockTxModel = {
  async find(filter: Record<string, unknown>) {
    return txStore.filter((r) => matches(r, filter));
  },
  async findOne(filter: Record<string, unknown>) {
    return txStore.find((r) => matches(r, filter)) ?? null;
  },
  async updateOne(
    filter: Record<string, unknown>,
    update: { $set?: Partial<MockTxRow> }
  ): Promise<{ matchedCount: number; modifiedCount: number }> {
    for (const row of txStore) {
      if (matches(row, filter)) {
        Object.assign(row, update.$set ?? {});
        row.updatedAt = new Date();
        return { matchedCount: 1, modifiedCount: 1 };
      }
    }
    return { matchedCount: 0, modifiedCount: 0 };
  },
  async updateMany(
    filter: Record<string, unknown>,
    update: { $set?: Partial<MockTxRow> }
  ) {
    let modifiedCount = 0;
    for (const row of txStore) {
      if (matches(row, filter)) {
        Object.assign(row, update.$set ?? {});
        row.updatedAt = new Date();
        modifiedCount++;
      }
    }
    return { matchedCount: modifiedCount, modifiedCount };
  },
  async create(doc: Partial<MockTxRow>) {
    // Enforce required fields (mirrors Mongoose schema strictness).
    for (const field of REQUIRED_ON_INSERT) {
      if ((doc as any)[field] === undefined || (doc as any)[field] === null) {
        const err = new Error(
          `Validation failed: ${field}: Path \`${field}\` is required.`
        );
        throw err;
      }
    }
    // Enforce unique stripePaymentIntentId.
    if (
      doc.stripePaymentIntentId &&
      txStore.some(
        (r) => r.stripePaymentIntentId === doc.stripePaymentIntentId
      )
    ) {
      const dup = new Error("E11000 duplicate key error");
      (dup as any).code = 11000;
      throw dup;
    }
    const row: MockTxRow = {
      _id: nextId(),
      userId: doc.userId as Types.ObjectId,
      deltaCents: doc.deltaCents as number,
      status: (doc.status ?? "completed") as Status,
      type: (doc.type ?? "topup") as string,
      stripePaymentIntentId: doc.stripePaymentIntentId ?? null,
      description: doc.description ?? "",
      updatedAt: new Date(),
    };
    txStore.push(row);
    return row;
  },
  async aggregate(pipeline: any[]) {
    let rows = [...txStore];
    for (const stage of pipeline) {
      if (stage.$match) {
        rows = rows.filter((r) => matches(r, stage.$match));
      } else if (stage.$group) {
        const totalDelta = rows.reduce((s, r) => s + r.deltaCents, 0);
        return [{ totalDelta }];
      }
    }
    return [];
  },
};

const mockUserModel = {
  async findById(userId: string) {
    return userStore[String(userId)] ?? null;
  },
  async findByIdAndUpdate(
    userId: string,
    update: { $set?: { walletBalanceCents?: number } }
  ) {
    const u = userStore[String(userId)] ?? { walletBalanceCents: 0 };
    if (update.$set?.walletBalanceCents !== undefined) {
      u.walletBalanceCents = update.$set.walletBalanceCents;
    }
    userStore[String(userId)] = u;
    return u;
  },
};

// ───────────────────────────────────────────────────────────────────────────────
// Inline copy of the reconcile + sync algorithm (mirrors walletService.ts)
// ───────────────────────────────────────────────────────────────────────────────
async function applyPiToLedger(
  userId: string,
  pi: { id: string; status: string; amount: number; amount_received?: number; currency: string }
) {
  const amount = pi.amount_received ?? pi.amount;

  if (pi.status === "succeeded") {
    const updateExisting = () =>
      mockTxModel.updateOne(
        { stripePaymentIntentId: pi.id },
        {
          $set: {
            deltaCents: amount,
            status: "completed",
            description: "Wallet top-up (Stripe)",
          },
        }
      );

    const first = await updateExisting();
    if (first.matchedCount > 0) return;

    try {
      await mockTxModel.create({
        userId: new Types.ObjectId(userId),
        deltaCents: amount,
        balanceAfterCents: 0,
        type: "topup",
        status: "completed",
        description: "Wallet top-up (Stripe)",
        stripePaymentIntentId: pi.id,
      });
    } catch (e: any) {
      if (e?.code === 11000) {
        await updateExisting();
      } else {
        throw e;
      }
    }
  }
}

async function syncFromStripe(
  userId: string,
  stripePis: Array<{ id: string; status: string; amount: number; amount_received?: number; currency: string }>
) {
  for (const pi of stripePis) {
    await applyPiToLedger(userId, pi);
  }
  return reconcile(userId);
}

async function reconcile(userId: string) {
  const STALE_PROCESSING_MS = 60 * 1000;
  const staleThreshold = new Date(Date.now() - STALE_PROCESSING_MS);
  const userObjId = new Types.ObjectId(userId);

  const stale = await mockTxModel.updateMany(
    {
      userId: userObjId,
      status: "processing",
      updatedAt: { $lt: staleThreshold },
    },
    { $set: { status: "completed" } }
  );
  const staleRecovered = stale.modifiedCount;

  const [agg] = await mockTxModel.aggregate([
    { $match: { userId: userObjId, status: "completed" } },
    { $group: { _id: null, totalDelta: { $sum: "$deltaCents" } } },
  ]);
  const expected = Math.max(0, agg?.totalDelta ?? 0);

  const userBefore = await mockUserModel.findById(userId);
  if (!userBefore) {
    return { expected, before: 0, after: 0, corrected: 0, staleRecovered };
  }
  const before = userBefore.walletBalanceCents ?? 0;
  if (before === expected) {
    return { expected, before, after: before, corrected: 0, staleRecovered };
  }
  await mockUserModel.findByIdAndUpdate(userId, {
    $set: { walletBalanceCents: expected },
  });
  return {
    expected,
    before,
    after: expected,
    corrected: expected - before,
    staleRecovered,
  };
}

// ───────────────────────────────────────────────────────────────────────────────
// Test helpers
// ───────────────────────────────────────────────────────────────────────────────
function reset() {
  txStore.length = 0;
  for (const k of Object.keys(userStore)) delete userStore[k];
  idCounter = 0;
}

function addRow(opts: {
  userId: string;
  deltaCents: number;
  status: Status;
  ageMs?: number;
  pi?: string;
}): MockTxRow {
  const row: MockTxRow = {
    _id: nextId(),
    userId: new Types.ObjectId(opts.userId),
    deltaCents: opts.deltaCents,
    status: opts.status,
    type: "topup",
    stripePaymentIntentId: opts.pi ?? `pi_${idCounter}`,
    updatedAt: new Date(Date.now() - (opts.ageMs ?? 5 * 60 * 1000)),
  };
  txStore.push(row);
  return row;
}

function setBalance(userId: string, cents: number) {
  userStore[String(userId)] = { walletBalanceCents: cents };
}

let pass = 0;
let fail = 0;
function assertEq(actual: unknown, expected: unknown, label: string) {
  const ok = JSON.stringify(actual) === JSON.stringify(expected);
  if (ok) {
    pass++;
    console.log(`  PASS  ${label}`);
  } else {
    fail++;
    console.log(`  FAIL  ${label}`);
    console.log(`        expected: ${JSON.stringify(expected)}`);
    console.log(`        actual:   ${JSON.stringify(actual)}`);
  }
}

// ───────────────────────────────────────────────────────────────────────────────
// Tests covering the user's exact bug + edge cases
// ───────────────────────────────────────────────────────────────────────────────
async function main() {
  const uid = "507f1f77bcf86cd799439011";

  // SCENARIO 1 — THE USER'S EXACT BUG: 3 PIs in Stripe, only $25 row in DB.
  // GET /wallet should sync + reconcile to set balance = $40.
  console.log("\nScenario 1 — USER'S BUG: 3 succeeded PIs in Stripe, only 1 row in DB ($25):");
  reset();
  addRow({ userId: uid, deltaCents: 2500, status: "completed", pi: "pi_25" });
  setBalance(uid, 2500);
  const stripePis = [
    { id: "pi_25", status: "succeeded", amount: 2500, currency: "usd" },
    { id: "pi_10", status: "succeeded", amount: 1000, currency: "usd" },
    { id: "pi_5", status: "succeeded", amount: 500, currency: "usd" },
  ];
  const r1 = await syncFromStripe(uid, stripePis);
  assertEq(r1.expected, 4000, "expected = $40 after sync inserts missing rows");
  assertEq(r1.after, 4000, "balance fixed to $40");
  assertEq(r1.corrected, 1500, "corrected = +$15");
  assertEq(txStore.length, 3, "3 ledger rows now exist");

  // SCENARIO 2 — 3 completed rows already in DB but balance under-credited.
  console.log("\nScenario 2 — 3 completed rows in DB, balance only $25:");
  reset();
  addRow({ userId: uid, deltaCents: 2500, status: "completed", pi: "pi_25" });
  addRow({ userId: uid, deltaCents: 1000, status: "completed", pi: "pi_10" });
  addRow({ userId: uid, deltaCents: 500, status: "completed", pi: "pi_5" });
  setBalance(uid, 2500);
  const r2 = await reconcile(uid);
  assertEq(r2.expected, 4000, "expected = $40");
  assertEq(r2.after, 4000, "balance corrected to $40");
  assertEq(r2.corrected, 1500, "corrected = +$15");

  // SCENARIO 3 — Stale processing rows (idle > 60s) should be recovered.
  console.log("\nScenario 3 — Stale processing rows recovered + balance corrected:");
  reset();
  addRow({ userId: uid, deltaCents: 2500, status: "completed", pi: "pi_25" });
  addRow({ userId: uid, deltaCents: 1000, status: "processing", ageMs: 5 * 60 * 1000, pi: "pi_10" });
  addRow({ userId: uid, deltaCents: 500, status: "processing", ageMs: 5 * 60 * 1000, pi: "pi_5" });
  setBalance(uid, 2500);
  const r3 = await reconcile(uid);
  assertEq(r3.staleRecovered, 2, "2 stale processing rows recovered");
  assertEq(r3.after, 4000, "balance fixed to $40");

  // SCENARIO 4 — Webhook double-fires for same PI: idempotent, no double credit.
  console.log("\nScenario 4 — Webhook double-fires: idempotent:");
  reset();
  setBalance(uid, 0);
  await syncFromStripe(uid, [{ id: "pi_10", status: "succeeded", amount: 1000, currency: "usd" }]);
  const r4 = await syncFromStripe(uid, [{ id: "pi_10", status: "succeeded", amount: 1000, currency: "usd" }]);
  assertEq(r4.after, 1000, "balance = $10 (no double credit)");
  assertEq(txStore.length, 1, "still only 1 ledger row");

  // SCENARIO 5 — Already-correct balance: no-op.
  console.log("\nScenario 5 — Already-correct balance:");
  reset();
  addRow({ userId: uid, deltaCents: 4000, status: "completed", pi: "pi_40" });
  setBalance(uid, 4000);
  const r5 = await reconcile(uid);
  assertEq(r5.corrected, 0, "no correction");
  assertEq(r5.after, 4000, "balance unchanged");

  // SCENARIO 6 — Pending rows (e.g., user just initiated, hasn't paid yet) NOT counted.
  console.log("\nScenario 6 — Pending top-up rows not counted in balance:");
  reset();
  addRow({ userId: uid, deltaCents: 2500, status: "completed", pi: "pi_25" });
  addRow({ userId: uid, deltaCents: 1000, status: "pending", pi: "pi_10_pending" });
  setBalance(uid, 2500);
  const r6 = await reconcile(uid);
  assertEq(r6.expected, 2500, "expected = $25 (pending excluded)");
  assertEq(r6.after, 2500, "balance unchanged at $25");

  // SCENARIO 7 — Ledger includes debit (negative delta).
  console.log("\nScenario 7 — Mixed credit + debit, balance = net:");
  reset();
  addRow({ userId: uid, deltaCents: 4000, status: "completed", pi: "pi_40" });
  addRow({ userId: uid, deltaCents: -1500, status: "completed", pi: undefined });
  setBalance(uid, 2500);
  const r7 = await reconcile(uid);
  assertEq(r7.expected, 2500, "expected = $40 - $15 = $25");
  assertEq(r7.after, 2500, "balance correct at $25");

  // SCENARIO 8 — Sum goes negative (impossible in practice but defensive).
  console.log("\nScenario 8 — Negative sum clamped to 0:");
  reset();
  addRow({ userId: uid, deltaCents: -1000, status: "completed", pi: undefined });
  setBalance(uid, 0);
  const r8 = await reconcile(uid);
  assertEq(r8.expected, 0, "negative sum clamped to 0");
  assertEq(r8.after, 0, "balance stays at 0");

  // SCENARIO 9 — Sync ignores non-succeeded PIs.
  console.log("\nScenario 9 — Sync ignores requires_payment_method PIs:");
  reset();
  setBalance(uid, 0);
  const r9 = await syncFromStripe(uid, [
    { id: "pi_succ", status: "succeeded", amount: 1000, currency: "usd" },
    { id: "pi_pending", status: "requires_payment_method", amount: 500, currency: "usd" },
    { id: "pi_action", status: "requires_action", amount: 200, currency: "usd" },
  ]);
  assertEq(r9.after, 1000, "only succeeded PI counted");
  assertEq(txStore.length, 1, "only 1 row created");

  console.log("\n────────────────────────────────────");
  console.log(`Results: ${pass} passed, ${fail} failed`);
  console.log("────────────────────────────────────");
  process.exit(fail > 0 ? 1 : 0);
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});
