import type { Paginated } from '@/lib/api/types';
import type { PageParams } from '@/lib/api/types';
import { ADMIN_BATCH_DETAIL_PAGE_SIZE, MOCK_SCENARIO } from '../constants';
import type {
  AdminPayoutBatchDetail,
  AdminPayoutRow,
  EarningsListParams,
  EarningsState,
  EligibleNurseEarnings,
  NurseEarningsItem,
  NurseEarningsSummary,
  NursePayoutDetail,
  NursePayoutHistoryItem,
  PayoutBatchFilters,
  PayoutBatchPreview,
  PayoutBatchSummary,
  PayoutsApi,
  SkippedNurse,
} from '../types';

/**
 * In-memory `PayoutsApi` — **the primary implementation this phase** (b13 serves only the nurse history
 * endpoint; the summary, earnings list, and nurse payout detail are REQ-025 gaps — see `constants.ts`).
 *
 * The fixtures are engineered to exercise **every** UI state and to be **money-correct**:
 *  - all four earnings states (`pending`/`eligible`/`paid`/`clawback_applied`);
 *  - `gross = commission + payout` on every earnings row (the sacred three-amount invariant);
 *  - a **negative net balance** ("owed back") under the `clawback_heavy` scenario;
 *  - a `failed` payout (with a `failureReason`) plus `paid` and `submitted` payouts;
 *  - payout-detail booking links whose `payoutAmountIrr` sum to the payout's `grossEarningsIrr`, and
 *    `gross − clawback = net = amount` — the reconciliation the nurse checks.
 *
 * Booking ids align with the f8 bookings mock seeds (5001–5004) so an earnings row's "view booking"
 * deep-link lands on a real mock detail screen. Timestamps are computed **relative to now** so the pending
 * dispute-window countdown always ticks; all money stays an IRR digit-string end-to-end.
 */

const HOUR_MS = 60 * 60 * 1000;
const DAY_MS = 24 * HOUR_MS;

/** An ISO instant `hours` in the future (+) / past (−) from now — for dispute-window / paid-at fixtures. */
function isoFromNowHours(hours: number): string {
  return new Date(Date.now() + hours * HOUR_MS).toISOString();
}

/** An ISO `YYYY-MM-DD` date `days` ago (batch window / scheduled-date fixtures). */
function isoDateDaysAgo(days: number): string {
  return new Date(Date.now() - days * DAY_MS).toISOString().slice(0, 10);
}

const MASKED_IBAN = 'IR••••••••••••••••••4821';

// ── Earnings items (one per state; ids match the f8 bookings mock seeds) ──────────────────────────────
const EARNINGS: NurseEarningsItem[] = [
  {
    // pending — in escrow, dispute window still open (~40h left, always ticking)
    bookingId: 5001,
    patientName: 'حاج‌آقا موسوی',
    scheduledDate: isoDateDaysAgo(1),
    grossPriceIrr: '5000000',
    balinyaarCommissionIrr: '750000',
    nursePayoutAmount: '4250000',
    state: 'pending',
    disputeWindowEndsAt: isoFromNowHours(40),
    payoutEligibleAt: null,
    paidAt: null,
    transferReference: null,
    nursePayoutId: null,
    batchId: null,
    clawbackAppliedIrr: null,
    netAmountIrr: null,
  },
  {
    // eligible — cleared (dispute window closed), awaiting the next weekly batch
    bookingId: 5002,
    patientName: 'خانم احمدی',
    scheduledDate: isoDateDaysAgo(4),
    grossPriceIrr: '4000000',
    balinyaarCommissionIrr: '600000',
    nursePayoutAmount: '3400000',
    state: 'eligible',
    disputeWindowEndsAt: null,
    payoutEligibleAt: isoFromNowHours(-12),
    paidAt: null,
    transferReference: null,
    nursePayoutId: null,
    batchId: null,
    clawbackAppliedIrr: null,
    netAmountIrr: null,
  },
  {
    // paid — transferred, links to its payout detail
    bookingId: 5003,
    patientName: 'آقای کریمی',
    scheduledDate: isoDateDaysAgo(9),
    grossPriceIrr: '5000000',
    balinyaarCommissionIrr: '750000',
    nursePayoutAmount: '4250000',
    state: 'paid',
    disputeWindowEndsAt: null,
    payoutEligibleAt: isoFromNowHours(-96),
    paidAt: isoFromNowHours(-72),
    transferReference: 'PAYA-14050412-9001',
    nursePayoutId: 9001,
    batchId: 7001,
    clawbackAppliedIrr: null,
    netAmountIrr: null,
  },
  {
    // clawback_applied — booking refunded after payout: original 1,700,000 − clawback 1,700,000 = net 0
    bookingId: 5004,
    patientName: 'خانم صادقی',
    scheduledDate: isoDateDaysAgo(14),
    grossPriceIrr: '2000000',
    balinyaarCommissionIrr: '300000',
    nursePayoutAmount: '1700000',
    state: 'clawback_applied',
    disputeWindowEndsAt: null,
    payoutEligibleAt: isoFromNowHours(-240),
    paidAt: isoFromNowHours(-216),
    transferReference: 'SATNA-14050405-9002',
    nursePayoutId: 9002,
    batchId: 7000,
    clawbackAppliedIrr: '1700000',
    netAmountIrr: '0',
  },
];

// ── Payout history (newest first; exercises all four PayoutStatus values) ─────────────────────────────
const HISTORY: NursePayoutHistoryItem[] = [
  {
    id: 9004,
    batchId: 7003,
    status: 'submitted',
    grossEarningsIrr: '2000000',
    clawbackAppliedIrr: '0',
    netAmountIrr: '2000000',
    maskedIban: MASKED_IBAN,
    transferReference: null,
    paidAt: null,
    periodStart: isoDateDaysAgo(7),
    periodEnd: isoDateDaysAgo(1),
    failureReason: null,
  },
  {
    id: 9003,
    batchId: 7002,
    status: 'failed',
    grossEarningsIrr: '3400000',
    clawbackAppliedIrr: '0',
    netAmountIrr: '3400000',
    maskedIban: MASKED_IBAN,
    transferReference: null,
    paidAt: null,
    periodStart: isoDateDaysAgo(14),
    periodEnd: isoDateDaysAgo(8),
    failureReason: 'invalid_sheba',
  },
  {
    id: 9001,
    batchId: 7001,
    status: 'paid',
    grossEarningsIrr: '4250000',
    clawbackAppliedIrr: '0',
    netAmountIrr: '4250000',
    maskedIban: MASKED_IBAN,
    transferReference: 'PAYA-14050412-9001',
    paidAt: isoFromNowHours(-72),
    periodStart: isoDateDaysAgo(14),
    periodEnd: isoDateDaysAgo(8),
    failureReason: null,
  },
  {
    // demonstrates clawback netting in a paid payout: 5,000,000 gross − 750,000 clawback = 4,250,000 net
    id: 9002,
    batchId: 7000,
    status: 'paid',
    grossEarningsIrr: '5000000',
    clawbackAppliedIrr: '750000',
    netAmountIrr: '4250000',
    maskedIban: MASKED_IBAN,
    transferReference: 'SATNA-14050405-9002',
    paidAt: isoFromNowHours(-240),
    periodStart: isoDateDaysAgo(21),
    periodEnd: isoDateDaysAgo(15),
    failureReason: null,
  },
];

// ── Payout details (batch context + booking links; every one reconciles) ──────────────────────────────
const DETAILS: Record<number, NursePayoutDetail> = {
  9001: {
    id: 9001,
    batchId: 7001,
    status: 'paid',
    grossEarningsIrr: '4250000',
    clawbackAppliedIrr: '0',
    netAmountIrr: '4250000',
    amountIrr: '4250000',
    maskedIban: MASKED_IBAN,
    transferReference: 'PAYA-14050412-9001',
    paidAt: isoFromNowHours(-72),
    failureReason: null,
    batch: {
      id: 7001,
      periodStart: isoDateDaysAgo(14),
      periodEnd: isoDateDaysAgo(8),
      processingDate: isoDateDaysAgo(7),
      status: 'completed',
      totalAmount: '4250000',
      payoutCount: 1,
      processedAt: isoFromNowHours(-72),
    },
    bookings: [{ bookingId: 5003, sessionId: 1, payoutAmountIrr: '4250000' }],
  },
  9002: {
    // Σ booking links (2,750,000 + 2,250,000) = 5,000,000 gross − 750,000 clawback = 4,250,000 net/amount
    id: 9002,
    batchId: 7000,
    status: 'paid',
    grossEarningsIrr: '5000000',
    clawbackAppliedIrr: '750000',
    netAmountIrr: '4250000',
    amountIrr: '4250000',
    maskedIban: MASKED_IBAN,
    transferReference: 'SATNA-14050405-9002',
    paidAt: isoFromNowHours(-240),
    failureReason: null,
    batch: {
      id: 7000,
      periodStart: isoDateDaysAgo(21),
      periodEnd: isoDateDaysAgo(15),
      processingDate: isoDateDaysAgo(14),
      status: 'completed',
      totalAmount: '4250000',
      payoutCount: 1,
      processedAt: isoFromNowHours(-240),
    },
    bookings: [
      { bookingId: 4990, sessionId: 1, payoutAmountIrr: '2750000' },
      { bookingId: 4991, sessionId: 1, payoutAmountIrr: '2250000' },
    ],
  },
  9003: {
    id: 9003,
    batchId: 7002,
    status: 'failed',
    grossEarningsIrr: '3400000',
    clawbackAppliedIrr: '0',
    netAmountIrr: '3400000',
    amountIrr: '3400000',
    maskedIban: MASKED_IBAN,
    transferReference: null,
    paidAt: null,
    failureReason: 'invalid_sheba',
    batch: {
      id: 7002,
      periodStart: isoDateDaysAgo(14),
      periodEnd: isoDateDaysAgo(8),
      processingDate: isoDateDaysAgo(7),
      status: 'partially_failed',
      totalAmount: '3400000',
      payoutCount: 1,
      processedAt: isoFromNowHours(-168),
    },
    bookings: [{ bookingId: 5005, sessionId: 1, payoutAmountIrr: '3400000' }],
  },
  9004: {
    id: 9004,
    batchId: 7003,
    status: 'submitted',
    grossEarningsIrr: '2000000',
    clawbackAppliedIrr: '0',
    netAmountIrr: '2000000',
    amountIrr: '2000000',
    maskedIban: MASKED_IBAN,
    transferReference: null,
    paidAt: null,
    failureReason: null,
    batch: {
      id: 7003,
      periodStart: isoDateDaysAgo(7),
      periodEnd: isoDateDaysAgo(1),
      processingDate: isoDateDaysAgo(0),
      status: 'processing',
      totalAmount: '2000000',
      payoutCount: 1,
      processedAt: null,
    },
    bookings: [{ bookingId: 5010, sessionId: 1, payoutAmountIrr: '2000000' }],
  },
};

/**
 * The ledger-derived summary. `paid` is a **lifetime** total; the **net payable balance** is
 * `pending + eligible − clawbackOutstanding` (accrued-unpaid earnings minus receivables), computed with
 * BigInt and **not clamped** — under `clawback_heavy` it goes negative ("owed back"). `paid` never enters
 * the net balance (it already left the ledger).
 */
function buildSummary(): NurseEarningsSummary {
  const pending = BigInt(4_250_000);
  const eligible = BigInt(3_400_000);
  const paid = BigInt(8_500_000); // 9001 (4,250,000) + 9002 net (4,250,000)
  const clawbackOutstanding = MOCK_SCENARIO === 'clawback_heavy' ? BigInt(12_000_000) : BigInt(1_700_000);
  const net = pending + eligible - clawbackOutstanding;
  return {
    pendingTotalIrr: String(pending),
    eligibleTotalIrr: String(eligible),
    paidTotalIrr: String(paid),
    clawbackOutstandingIrr: String(clawbackOutstanding),
    netPayableBalanceIrr: String(net),
  };
}

function paginate<T>(all: T[], params: PageParams): Paginated<T> {
  const page = Math.max(1, params.page ?? 1);
  const pageSize = Math.max(1, params.pageSize ?? all.length);
  const start = (page - 1) * pageSize;
  return { items: all.slice(start, start + pageSize), total: all.length, page, pageSize };
}

/** Small artificial latency so loading skeletons are observable in dev. */
const LATENCY_MS = 250;
function delay<T>(value: T): Promise<T> {
  return new Promise((resolve) => setTimeout(() => resolve(value), LATENCY_MS));
}

const STATE_ORDER: Record<EarningsState, number> = { pending: 0, eligible: 1, paid: 2, clawback_applied: 3 };

// ══ Admin batch actions (b13 admin_payouts/*) — mutable in-memory state ════════════════════════════════
//
// Engineered to exercise every admin UI state and stay money-correct: batches spanning completed /
// partially_failed / processing, at least one holiday-shifted; per-payout rows including a `failed` row
// (so retry is demonstrable) and `paid` rows with a last-4 masked IBAN + transfer reference. Every row
// reconciles `gross − clawback = net = amount`, its booking links sum to its gross, and each batch's
// `totalAmount = Σ its payouts' net`. Rows are **mutated in place** by retry / record-reference so state
// persists across calls within the session.

/** ISO `YYYY-MM-DD` shifted `days` off `isoDate` (holiday-shifted processing date). */
function isoDateShift(isoDate: string, days: number): string {
  return new Date(new Date(isoDate).getTime() + days * DAY_MS).toISOString().slice(0, 10);
}

/** IRR-string sum (integer-safe BigInt; never a float). */
function sumIrr(values: string[]): string {
  return String(values.reduce((total, v) => total + BigInt(v), BigInt(0)));
}

const ADMIN_MASKED_IBAN_A = 'IR••••••••••••••••••4821';
const ADMIN_MASKED_IBAN_B = 'IR••••••••••••••••••7734';
const ADMIN_MASKED_IBAN_C = 'IR••••••••••••••••••1092';

/** Per-nurse payout rows keyed by batch id. Mutated in place by `retryPayout` / `recordTransferReference`. */
const BATCH_DETAILS: Record<number, AdminPayoutRow[]> = {
  // 7101 — partially_failed: one paid + one failed (retryable). Σ net = 4,250,000 + 3,400,000 = 7,650,000.
  7101: [
    {
      id: 9201,
      nurseId: 301,
      nurseName: 'زهرا موسوی',
      maskedIban: ADMIN_MASKED_IBAN_A,
      grossEarningsIrr: '4250000',
      clawbackAppliedIrr: '0',
      netAmountIrr: '4250000',
      amountIrr: '4250000',
      status: 'paid',
      transferReference: 'PAYA-14050412-9201',
      paidAt: isoFromNowHours(-20),
      failureReason: null,
      bookings: [{ bookingId: 5003, sessionId: 1, payoutAmountIrr: '4250000' }],
    },
    {
      id: 9202,
      nurseId: 302,
      nurseName: 'مریم احمدی',
      maskedIban: ADMIN_MASKED_IBAN_B,
      grossEarningsIrr: '3400000',
      clawbackAppliedIrr: '0',
      netAmountIrr: '3400000',
      amountIrr: '3400000',
      status: 'failed',
      transferReference: null,
      paidAt: null,
      failureReason: 'invalid_sheba',
      bookings: [{ bookingId: 5002, sessionId: 1, payoutAmountIrr: '3400000' }],
    },
  ],
  // 7102 — completed: a clawback-netted paid + a clean paid. Σ net = 4,250,000 + 2,000,000 = 6,250,000.
  7102: [
    {
      id: 9203,
      nurseId: 303,
      nurseName: 'فاطمه کریمی',
      maskedIban: ADMIN_MASKED_IBAN_C,
      grossEarningsIrr: '5000000',
      clawbackAppliedIrr: '750000',
      netAmountIrr: '4250000',
      amountIrr: '4250000',
      status: 'paid',
      transferReference: 'SATNA-14050405-9203',
      paidAt: isoFromNowHours(-96),
      failureReason: null,
      bookings: [
        { bookingId: 4990, sessionId: 1, payoutAmountIrr: '2750000' },
        { bookingId: 4991, sessionId: 1, payoutAmountIrr: '2250000' },
      ],
    },
    {
      id: 9204,
      nurseId: 304,
      nurseName: 'سکینه رضایی',
      maskedIban: ADMIN_MASKED_IBAN_A,
      grossEarningsIrr: '2000000',
      clawbackAppliedIrr: '0',
      netAmountIrr: '2000000',
      amountIrr: '2000000',
      status: 'paid',
      transferReference: 'PAYA-14050405-9204',
      paidAt: isoFromNowHours(-100),
      failureReason: null,
      bookings: [{ bookingId: 5006, sessionId: 1, payoutAmountIrr: '2000000' }],
    },
  ],
  // 7103 — processing: submitted, awaiting settlement. Σ net = 3,000,000.
  7103: [
    {
      id: 9205,
      nurseId: 305,
      nurseName: 'اکرم حسینی',
      maskedIban: ADMIN_MASKED_IBAN_B,
      grossEarningsIrr: '3000000',
      clawbackAppliedIrr: '0',
      netAmountIrr: '3000000',
      amountIrr: '3000000',
      status: 'submitted',
      transferReference: null,
      paidAt: null,
      failureReason: null,
      bookings: [{ bookingId: 5010, sessionId: 1, payoutAmountIrr: '3000000' }],
    },
  ],
  // 7104 — completed (holiday-shifted), older. Σ net = 4,250,000.
  7104: [
    {
      id: 9206,
      nurseId: 301,
      nurseName: 'زهرا موسوی',
      maskedIban: ADMIN_MASKED_IBAN_A,
      grossEarningsIrr: '4250000',
      clawbackAppliedIrr: '0',
      netAmountIrr: '4250000',
      amountIrr: '4250000',
      status: 'paid',
      transferReference: 'SATNA-14050328-9206',
      paidAt: isoFromNowHours(-260),
      failureReason: null,
      bookings: [{ bookingId: 4980, sessionId: 1, payoutAmountIrr: '4250000' }],
    },
  ],
};

/** Batch headers (newest-first by `createdAt` at read time). `totalAmount = Σ its detail rows' net`. */
const BATCHES: PayoutBatchSummary[] = [
  {
    id: 7103,
    periodStart: isoDateDaysAgo(7),
    periodEnd: isoDateDaysAgo(1),
    processingDate: isoDateDaysAgo(0),
    totalAmount: '3000000',
    payoutCount: 1,
    status: 'processing',
    processedAt: null,
    failureNotes: null,
    createdAt: isoFromNowHours(-6),
    holidayShifted: false,
  },
  {
    id: 7101,
    periodStart: isoDateDaysAgo(14),
    periodEnd: isoDateDaysAgo(8),
    processingDate: isoDateDaysAgo(6),
    totalAmount: '7650000',
    payoutCount: 2,
    status: 'partially_failed',
    processedAt: isoFromNowHours(-20),
    failureNotes: '۱ انتقال توسط سامانه بانکی رد شد (invalid_sheba)',
    createdAt: isoFromNowHours(-26),
    holidayShifted: true,
  },
  {
    id: 7102,
    periodStart: isoDateDaysAgo(14),
    periodEnd: isoDateDaysAgo(8),
    processingDate: isoDateDaysAgo(7),
    totalAmount: '6250000',
    payoutCount: 2,
    status: 'completed',
    processedAt: isoFromNowHours(-96),
    failureNotes: null,
    createdAt: isoFromNowHours(-120),
    holidayShifted: false,
  },
  {
    id: 7104,
    periodStart: isoDateDaysAgo(21),
    periodEnd: isoDateDaysAgo(15),
    processingDate: isoDateDaysAgo(13),
    totalAmount: '4250000',
    payoutCount: 1,
    status: 'completed',
    processedAt: isoFromNowHours(-260),
    failureNotes: null,
    createdAt: isoFromNowHours(-264),
    holidayShifted: true,
  },
];

/** Idempotency ledgers: the SAME key returns the SAME result (never a double-run / double-pay). */
const RUN_BATCH_IDEMPOTENCY = new Map<string, PayoutBatchSummary>();
const RETRY_IDEMPOTENCY = new Set<string>();
let nextBatchId = 7200;
let nextAdminPayoutId = 9300;

/**
 * The eligibility dry-run: ≥3 eligible nurses (one with a netted clawback), one flagged with no verified
 * IBAN (`hasVerifiedPrimaryIban:false` — shown, not dropped), one skipped (`no_verified_primary_iban`), and
 * a holiday-shifted processing date. `totalNetIrr = Σ eligible.netAmountIrr`. The server owns eligibility +
 * the shifted date; the client only renders this.
 */
function buildPreview(periodStart: string, periodEnd: string): PayoutBatchPreview {
  const eligible: EligibleNurseEarnings[] = [
    {
      nurseId: 301,
      nurseName: 'زهرا موسوی',
      bookingCount: 2,
      grossEarningsIrr: '5000000',
      clawbackAppliedIrr: '0',
      netAmountIrr: '5000000',
      hasVerifiedPrimaryIban: true,
    },
    {
      nurseId: 302,
      nurseName: 'مریم احمدی',
      bookingCount: 1,
      grossEarningsIrr: '3400000',
      clawbackAppliedIrr: '0',
      netAmountIrr: '3400000',
      hasVerifiedPrimaryIban: true,
    },
    {
      // netted clawback: 6,000,000 gross − 1,500,000 clawback = 4,500,000 net
      nurseId: 303,
      nurseName: 'فاطمه کریمی',
      bookingCount: 3,
      grossEarningsIrr: '6000000',
      clawbackAppliedIrr: '1500000',
      netAmountIrr: '4500000',
      hasVerifiedPrimaryIban: true,
    },
    {
      // flagged (no verified primary IBAN) — surfaced with the flag, NOT dropped (contract semantics)
      nurseId: 306,
      nurseName: 'نرگس علوی',
      bookingCount: 1,
      grossEarningsIrr: '2000000',
      clawbackAppliedIrr: '0',
      netAmountIrr: '2000000',
      hasVerifiedPrimaryIban: false,
    },
  ];
  const skipped: SkippedNurse[] = [
    { nurseId: 307, nurseName: 'طاهره یوسفی', grossEarningsIrr: '1200000', reason: 'no_verified_primary_iban' },
  ];
  return {
    periodStart,
    periodEnd,
    // holiday-shifted a couple days past periodEnd (the server owns the shift; the client renders it)
    processingDate: isoDateShift(periodEnd, 2),
    holidayShifted: true,
    eligible,
    skipped,
    totalNetIrr: sumIrr(eligible.map((e) => e.netAmountIrr)),
  };
}

export const payoutsMockApi: PayoutsApi = {
  getNurseEarningsBalance: async () => delay(buildSummary()),

  getNurseEarnings: async (params: EarningsListParams) => {
    const filtered = params.state ? EARNINGS.filter((e) => e.state === params.state) : [...EARNINGS];
    filtered.sort((a, b) => STATE_ORDER[a.state] - STATE_ORDER[b.state] || b.bookingId - a.bookingId);
    return delay(paginate(filtered, params));
  },

  getNursePayoutHistory: async (params: PageParams) => delay(paginate([...HISTORY], params)),

  getNursePayoutDetail: async (payoutId: number) => {
    const detail = DETAILS[payoutId];
    if (!detail) throw new Error(`Mock payout ${payoutId} not found`);
    return delay(detail);
  },

  // ── Admin batch actions ─────────────────────────────────────────────────────────────────────────────

  listPayoutBatches: async (filters: PayoutBatchFilters, params: PageParams) => {
    const sorted = [...BATCHES].sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1));
    const filtered = filters.status ? sorted.filter((b) => b.status === filters.status) : sorted;
    return delay(paginate(filtered, params));
  },

  previewPayoutBatch: async (periodStart: string, periodEnd: string) =>
    delay(buildPreview(periodStart, periodEnd)),

  runPayoutBatch: async (periodStart: string, periodEnd: string, idempotencyKey: string) => {
    // Idempotency: the same key returns the same batch — a retried run never opens a second batch.
    const prior = RUN_BATCH_IDEMPOTENCY.get(idempotencyKey);
    if (prior) return delay(prior);

    const preview = buildPreview(periodStart, periodEnd);
    // Only nurses with a verified primary IBAN are materialized into payouts (the flagged ones are skipped).
    const payable = preview.eligible.filter((e) => e.hasVerifiedPrimaryIban);
    const id = nextBatchId++;
    const batch: PayoutBatchSummary = {
      id,
      periodStart,
      periodEnd,
      processingDate: preview.processingDate,
      totalAmount: sumIrr(payable.map((e) => e.netAmountIrr)),
      payoutCount: payable.length,
      status: 'processing',
      processedAt: null,
      failureNotes: null,
      createdAt: new Date().toISOString(),
      holidayShifted: preview.holidayShifted,
    };
    BATCHES.unshift(batch);
    BATCH_DETAILS[id] = payable.map((e) => ({
      id: nextAdminPayoutId++,
      nurseId: e.nurseId,
      nurseName: e.nurseName,
      maskedIban: ADMIN_MASKED_IBAN_A,
      grossEarningsIrr: e.grossEarningsIrr,
      clawbackAppliedIrr: e.clawbackAppliedIrr,
      netAmountIrr: e.netAmountIrr,
      amountIrr: e.netAmountIrr,
      status: 'submitted',
      transferReference: null,
      paidAt: null,
      failureReason: null,
      bookings: [{ bookingId: 5000 + e.nurseId, sessionId: 1, payoutAmountIrr: e.grossEarningsIrr }],
    }));
    RUN_BATCH_IDEMPOTENCY.set(idempotencyKey, batch);
    return delay(batch);
  },

  getPayoutBatchDetail: async (batchId: number, page: number) => {
    const batch = BATCHES.find((b) => b.id === batchId);
    if (!batch) throw new Error(`Mock payout batch ${batchId} not found`);
    const rows = BATCH_DETAILS[batchId] ?? [];
    const pageSize = ADMIN_BATCH_DETAIL_PAGE_SIZE;
    const p = Math.max(1, page);
    const start = (p - 1) * pageSize;
    const detail: AdminPayoutBatchDetail = {
      batch,
      payouts: rows.slice(start, start + pageSize),
      total: rows.length,
      page: p,
      pageSize,
    };
    return delay(detail);
  },

  retryPayout: async (payoutId: number, idempotencyKey: string) => {
    // Idempotency: a re-fired retry with the same key never re-applies (no double-pay).
    if (RETRY_IDEMPOTENCY.has(idempotencyKey)) return delay(undefined);
    RETRY_IDEMPOTENCY.add(idempotencyKey);
    for (const [key, rows] of Object.entries(BATCH_DETAILS)) {
      const row = rows.find((r) => r.id === payoutId);
      if (!row) continue;
      if (row.status === 'failed') {
        row.status = 'paid';
        row.failureReason = null;
        row.paidAt = new Date().toISOString();
        row.transferReference = `PAYA-RETRY-${payoutId}`;
        row.amountIrr = row.netAmountIrr;
        const batch = BATCHES.find((b) => b.id === Number(key));
        // if that was the last failure in the batch, it re-settles partially_failed → completed
        if (batch && batch.status === 'partially_failed' && rows.every((r) => r.status !== 'failed')) {
          batch.status = 'completed';
          batch.processedAt = new Date().toISOString();
          batch.failureNotes = null;
        }
      }
      break;
    }
    return delay(undefined);
  },

  recordTransferReference: async (payoutId: number, reference: string) => {
    for (const rows of Object.values(BATCH_DETAILS)) {
      const row = rows.find((r) => r.id === payoutId);
      if (row) {
        row.transferReference = reference;
        break;
      }
    }
    return delay(undefined);
  },
};
