import { parseIrr, sleep } from '@/utils';
import { ApiError } from '@/lib/api/errors';
import { mockGetBookingForRefund, mockMarkBookingCancelled } from '@/services/bookings/apis/mockApi';
import { BNPL_REFUND_ETA_BUSINESS_DAYS, MOCK_POLICY_TIERS } from '../constants';
import {
  isBookingCancellable,
  isTerminalRefundStatus,
  type AdminRefundResult,
  type CancelBookingInput,
  type CancellationPolicyCode,
  type CancellationPolicyPreview,
  type CancellationSessionPreview,
  type InitiateRefundInput,
  type RefundChannel,
  type RefundPreview,
  type RefundStatus,
  type RefundSummary,
  type RefundsApi,
} from '../types';

const MOCK_LATENCY_MS = 350;

// Integer-only rate math: fractions as parts-per-10000 so the money path never touches a float.
// (BigInt via the constructor — the tsconfig target predates ES2020 literals, matching utils/money.ts.)
const RATE_SCALE = BigInt(10_000);
const ZERO = BigInt(0);

function fractionPpm(fraction: number): bigint {
  return BigInt(Math.round(fraction * Number(RATE_SCALE)));
}

/**
 * Which channel a booking's refund runs through. In production this is derived from the original payment
 * method; the mock pins booking 5002 to BNPL so the ~7–10-business-day ETA banner is demoable, and defaults
 * everything else (incl. f9-converted bookings, which were paid by card) to `psp_card`.
 */
const CHANNEL_BY_BOOKING: Record<number, RefundChannel> = {
  5002: 'bnpl_revert',
};

function channelFor(bookingId: number): RefundChannel {
  return CHANNEL_BY_BOOKING[bookingId] ?? 'psp_card';
}

/**
 * Integer day difference between a `YYYY-MM-DD` date and today, both on the **UTC calendar day** — the
 * bookings mock seeds dates via `new Date().toISOString().slice(0,10)` (UTC), so the tier resolution must
 * use the same basis or a positive-offset timezone (e.g. Asia/Tehran +3:30, just after local midnight)
 * would compute a "today" session as -1 day and flip the tier from partial to no-show.
 */
function daysUntil(dateStr: string): number {
  const todayKey = new Date().toISOString().slice(0, 10);
  const target = Date.parse(`${dateStr}T00:00:00Z`);
  const today = Date.parse(`${todayKey}T00:00:00Z`);
  return Math.round((target - today) / 86_400_000);
}

/** Resolves the tier code from the earliest un-started session's lead time (mock stand-in for the server). */
function policyCodeForLead(days: number): CancellationPolicyCode {
  if (days >= 1) return 'free_24h';
  if (days === 0) return 'partial_under_24h';
  return 'customer_no_show';
}

/** The BNPL customer cash-back ETA: N business days out, Fridays skipped (product: ~7–10 business days). */
function businessDaysFromNow(days: number): string {
  const d = new Date();
  let added = 0;
  while (added < days) {
    d.setDate(d.getDate() + 1);
    if (d.getDay() !== 5) added += 1; // getDay() 5 = Friday
  }
  return d.toISOString().slice(0, 10);
}

/** A plausible masked (last-4) external reference for a non-card revert — opaque, never parsed. */
function maskedReference(bookingId: number): string {
  return `••••••${String(bookingId % 10000).padStart(4, '0')}`;
}

/**
 * Resolve the cancellation preview from the shared f8 bookings store: which sessions are refundable
 * (un-started) vs locked (completed-and-verified, still payout-eligible), the tier by lead time, and the
 * refund-vs-fee split decomposed across the two fee legs. All money is BigInt; `refundAmount + fee =
 * refundableGross` by construction, so the disclosure's `PriceBreakdown` reconciles to the rial.
 */
function computePreview(bookingId: number): CancellationPolicyPreview {
  const booking = mockGetBookingForRefund(bookingId); // throws 404 if unknown
  const channel = channelFor(bookingId);

  const sessions: CancellationSessionPreview[] = booking.sessions.map((s) => ({
    bookingSessionId: s.id,
    sessionIndex: s.sessionIndex,
    scheduledDate: s.scheduledDate,
    refundable: s.status === 'scheduled',
    reasonCode: s.status === 'scheduled' ? 'un_started' : s.status,
  }));

  const refundableSessions = booking.sessions.filter((s) => s.status === 'scheduled');
  const cancellable = isBookingCancellable(booking.status) && refundableSessions.length > 0;

  // Proportional decomposition: the refundable slice of the payout leg is exact (Σ per-session payout);
  // its commission share is proportional. refundableGross = refundablePayout + refundableCommission.
  const totalPayout = parseIrr(booking.nursePayoutAmount);
  const commission = parseIrr(booking.balinyaarCommissionIrr);
  const refundablePayout = refundableSessions.reduce((acc, s) => acc + parseIrr(s.visitPayoutAmount), ZERO);
  const refundableCommission = totalPayout > ZERO ? (commission * refundablePayout) / totalPayout : ZERO;
  const refundableGross = refundablePayout + refundableCommission;

  const earliestDate = refundableSessions
    .map((s) => s.scheduledDate)
    .sort()
    .at(0);
  const policyCode = policyCodeForLead(earliestDate ? daysUntil(earliestDate) : 0);
  const tier = MOCK_POLICY_TIERS[policyCode];
  const ppm = fractionPpm(tier.refundFraction);

  const nursePayoutRefunded = (refundablePayout * ppm) / RATE_SCALE;
  const platformFeeRefunded = (refundableCommission * ppm) / RATE_SCALE;
  const refundAmount = nursePayoutRefunded + platformFeeRefunded;
  const feeAmount = refundableGross - refundAmount; // the retained remainder — reconciles by construction

  return {
    bookingId,
    cancellable,
    cancellationPolicyCode: policyCode,
    refundPercentageApplied: tier.refundFraction,
    feePercentage: Math.round((1 - tier.refundFraction) * 100) / 100,
    refundAmountIrr: refundAmount.toString(),
    feeAmountIrr: feeAmount.toString(),
    refundableAmountIrr: refundableGross.toString(),
    platformFeeRefundedIrr: platformFeeRefunded.toString(),
    nursePayoutRefundedIrr: nursePayoutRefunded.toString(),
    appliesTo: refundableSessions.length === booking.sessions.length ? 'whole_booking' : 'remaining_sessions',
    leadTimeLabel: tier.leadTimeLabel,
    refundChannel: channel,
    expectedCustomerRefundEta: channel === 'bnpl_revert' ? businessDaysFromNow(BNPL_REFUND_ETA_BUSINESS_DAYS) : null,
    refundableSessionIds: refundableSessions.map((s) => s.id),
    sessions,
  };
}

/** The mock's stored refund — a `RefundSummary` plus a read counter that drives the BNPL walk. */
interface MockRefund extends RefundSummary {
  reads: number;
}

let nextRefundId = 7001;
const refundsByBooking: Record<number, MockRefund> = {};

// Seed: a FAILED refund on the already-cancelled booking 5004, so the refund-status screen shows the
// needs-attention / contact-support state out of the box (retry is admin-only — DEFERRED to f15).
refundsByBooking[5004] = {
  id: nextRefundId++,
  bookingId: 5004,
  refundStatus: 'failed',
  refundChannel: 'psp_card',
  totalRefundedIrr: '6000000',
  expectedCustomerRefundEta: null,
  externalRevertReference: maskedReference(5004),
  refundPercentageApplied: 0.5,
  cancellationPolicyCode: 'partial_under_24h',
  platformFeeRefundedIrr: '720000',
  nursePayoutRefundedIrr: '5280000',
  createdAt: new Date(Date.now() - 2 * 86_400_000).toISOString(),
  completedAt: null,
  reads: 0,
};

function toRefundSummary(refund: MockRefund): RefundSummary {
  const { reads: _reads, ...summary } = refund;
  return { ...summary };
}

/**
 * Accelerated mock reconciliation so a BNPL refund's stepper visibly walks *submitted → on its way →
 * completed* as the status poll ticks (a real BNPL revert takes ~7–10 business days). A card refund is
 * already `succeeded` at creation — nothing to advance. Forward-only, and stops once terminal.
 */
function advanceRefund(refund: MockRefund): void {
  if (refund.refundChannel !== 'bnpl_revert' || isTerminalRefundStatus(refund.refundStatus)) return;
  refund.reads += 1;
  if (refund.reads >= 4) {
    refund.refundStatus = 'succeeded';
    refund.completedAt = new Date().toISOString();
  } else {
    refund.refundStatus = 'processing';
  }
}

/* --------------------------------------------------------------------------------------------------
 * Admin refund tooling mock (b11 `admin_refunds`). Self-contained fixtures keyed by a **sentinel booking
 * id** — one per console panel state — kept separate from the customer booking store above so every admin
 * state is deterministic. Each fixture reconciles `platformFeeRefunded + nursePayoutRefunded === amount`
 * (BigInt). The states:
 *   6001 — normal card:   `psp_card`, `succeeded` immediately, no ETA, no clawback.
 *   6002 — BNPL:          `bnpl_revert`, `processing` on initiate, a ~9-business-day `expectedCustomerRefundEta`.
 *   6003 — post-payout:   `willCreateClawback` (nurse already paid), initiate returns a `clawbackId`.
 *   6004 — provider decline: the FIRST `initiateRefund` returns `failed` (channel refused); a subsequent
 *          `approveRefund` (retry) succeeds — so the retry path is demonstrable.
 * ------------------------------------------------------------------------------------------------ */

/** Sentinel booking whose first refund attempt the channel declines (`failed`), then a retry succeeds. */
const PROVIDER_DECLINE_BOOKING_ID = 6004;

/** The admin decomposition preview per fixture booking (the BNPL ETA is filled dynamically at read time). */
const ADMIN_PREVIEW_FIXTURES: Record<number, Omit<RefundPreview, 'expectedCustomerRefundEta'>> = {
  6001: {
    bookingId: 6001,
    refundPercentageApplied: 1,
    amountIrr: '10000000',
    platformFeeRefundedIrr: '1500000',
    nursePayoutRefundedIrr: '8500000',
    refundChannel: 'psp_card',
    willCreateClawback: false,
    cancellationPolicyCode: 'free_24h',
  },
  6002: {
    bookingId: 6002,
    refundPercentageApplied: 1,
    amountIrr: '6000000',
    platformFeeRefundedIrr: '900000',
    nursePayoutRefundedIrr: '5100000',
    refundChannel: 'bnpl_revert',
    willCreateClawback: false,
    cancellationPolicyCode: 'free_24h',
  },
  6003: {
    bookingId: 6003,
    refundPercentageApplied: 0.5,
    amountIrr: '5000000',
    platformFeeRefundedIrr: '750000',
    nursePayoutRefundedIrr: '4250000',
    refundChannel: 'psp_card',
    willCreateClawback: true,
    cancellationPolicyCode: 'partial_under_24h',
  },
  [PROVIDER_DECLINE_BOOKING_ID]: {
    bookingId: PROVIDER_DECLINE_BOOKING_ID,
    refundPercentageApplied: 1,
    amountIrr: '8000000',
    platformFeeRefundedIrr: '1200000',
    nursePayoutRefundedIrr: '6800000',
    refundChannel: 'psp_card',
    willCreateClawback: false,
    cancellationPolicyCode: 'free_24h',
  },
};

/** The BNPL admin ETA — ~9 business days out (Fridays skipped), within the product's ~7–10-day window. */
const ADMIN_BNPL_ETA_BUSINESS_DAYS = 9;

/** Assert the fee legs sum to the total (BigInt) — a preview must reconcile to the rial before it ships. */
function assertReconciles(preview: Omit<RefundPreview, 'expectedCustomerRefundEta'>): void {
  const sum = parseIrr(preview.platformFeeRefundedIrr) + parseIrr(preview.nursePayoutRefundedIrr);
  if (sum !== parseIrr(preview.amountIrr)) {
    throw new Error(`Admin refund preview ${preview.bookingId} does not reconcile`);
  }
}

/** Resolve the full preview for a fixture booking (filling the BNPL ETA); `404` on an unknown booking. */
function adminPreviewFor(bookingId: number): RefundPreview {
  const base = ADMIN_PREVIEW_FIXTURES[bookingId];
  if (!base) throw new ApiError(404, 'No captured payment for this booking', 'not_found');
  assertReconciles(base);
  return {
    ...base,
    expectedCustomerRefundEta:
      base.refundChannel === 'bnpl_revert' ? businessDaysFromNow(ADMIN_BNPL_ETA_BUSINESS_DAYS) : null,
  };
}

/** The executed status for a channel: a card refund succeeds immediately; BNPL/manual go to `processing`. */
function executedStatusFor(channel: RefundChannel): RefundStatus {
  return channel === 'psp_card' ? 'succeeded' : 'processing';
}

let nextAdminRefundId = 9001;
let nextClawbackId = 4001;
const adminRefundsById: Record<number, AdminRefundResult> = {};
// Per-booking initiate counter — drives the provider-decline sentinel's first-attempt failure.
const adminInitiateAttempts: Record<number, number> = {};

/**
 * In-memory mock behind the `RefundsApi` seam — the whole customer cancel + refund surface b11 doesn't
 * serve (admin-only refunds; no cancel command / policy preview / refund-by-booking / decomposition on the
 * customer status → REQ-019/020/021). It reads the shared f8 bookings store to resolve the tier + per-
 * session refundability, flips the booking to `cancelled` on confirm (so the booking-detail cache reflects
 * it after invalidation), enforces the outside-policy `409`, and drives the refund through the customer
 * steps (card immediate `succeeded`; BNPL `processing` with an ETA that reconciles over polls). Swap to the
 * real `clientApi` once REQ-019/020/021 land (`USE_REFUNDS_MOCK = false`).
 */
export const refundsMockApi: RefundsApi = {
  resolveCancellationPolicy: async (bookingId) => {
    await sleep(MOCK_LATENCY_MS);
    return computePreview(bookingId);
  },

  cancelBooking: async ({ bookingId, sessionIds, reasonCategory, reasonNotes }: CancelBookingInput) => {
    await sleep(MOCK_LATENCY_MS);
    const preview = computePreview(bookingId);
    if (!preview.cancellable) {
      throw new ApiError(409, 'Booking cannot be cancelled', 'not_cancellable');
    }
    if (sessionIds && sessionIds.some((id) => !preview.refundableSessionIds.includes(id))) {
      // Never offer to refund a session the policy marks non-refundable (completed-and-verified).
      throw new ApiError(409, 'Session is not refundable', 'session_not_refundable');
    }

    const channel = preview.refundChannel;
    const now = new Date().toISOString();
    mockMarkBookingCancelled(bookingId, {
      cancelledBy: 'customer',
      cancellationReason: reasonNotes?.trim() || reasonCategory,
      cancellationPolicyCode: preview.cancellationPolicyCode,
      cancellationRefundPercentage: preview.refundPercentageApplied,
      refundableAmountIrr: preview.refundableAmountIrr,
      cancelledSessionIds: preview.refundableSessionIds,
    });

    const refund: MockRefund = {
      id: nextRefundId++,
      bookingId,
      // Card refunds succeed immediately; BNPL/manual sit in the reconciliation window (start approved →
      // processing → succeeded so the customer sees the walk).
      refundStatus: channel === 'psp_card' ? 'succeeded' : 'approved',
      refundChannel: channel,
      totalRefundedIrr: preview.refundAmountIrr,
      expectedCustomerRefundEta: preview.expectedCustomerRefundEta,
      externalRevertReference: channel === 'psp_card' ? null : maskedReference(bookingId),
      refundPercentageApplied: preview.refundPercentageApplied,
      cancellationPolicyCode: preview.cancellationPolicyCode,
      platformFeeRefundedIrr: preview.platformFeeRefundedIrr,
      nursePayoutRefundedIrr: preview.nursePayoutRefundedIrr,
      createdAt: now,
      completedAt: channel === 'psp_card' ? now : null,
      reads: 0,
    };
    refundsByBooking[bookingId] = refund;
    return toRefundSummary(refund);
  },

  getRefundByBooking: async (bookingId) => {
    await sleep(MOCK_LATENCY_MS);
    const refund = refundsByBooking[bookingId];
    if (!refund) return null; // no refund (e.g. not cancelled) — a clean empty state, not an error
    advanceRefund(refund);
    return toRefundSummary(refund);
  },

  getRefund: async (refundId) => {
    await sleep(MOCK_LATENCY_MS);
    const refund = Object.values(refundsByBooking).find((r) => r.id === refundId);
    if (!refund) throw new ApiError(404, 'Refund not found', 'not_found');
    advanceRefund(refund);
    return toRefundSummary(refund);
  },

  // --- Admin refund tooling (ticket-linked; the mock serves the whole console this phase). ---

  getRefundPreview: async (bookingId, _ticketId) => {
    await sleep(MOCK_LATENCY_MS);
    return adminPreviewFor(bookingId);
  },

  initiateRefund: async (input: InitiateRefundInput) => {
    await sleep(MOCK_LATENCY_MS);
    const preview = adminPreviewFor(input.bookingId); // 404 if unknown
    const channel = input.refundChannel ?? preview.refundChannel;

    const attempt = (adminInitiateAttempts[input.bookingId] ?? 0) + 1;
    adminInitiateAttempts[input.bookingId] = attempt;

    // The provider-decline fixture fails its first attempt (channel refused); a later approve/retry recovers.
    const declined = input.bookingId === PROVIDER_DECLINE_BOOKING_ID && attempt === 1;
    const status: RefundStatus = declined ? 'failed' : executedStatusFor(channel);

    const result: AdminRefundResult = {
      refundId: nextAdminRefundId++,
      bookingId: input.bookingId,
      status,
      refundChannel: channel,
      // The client renders the server's decomposition verbatim; it never recomputes the legs.
      amount: preview.amountIrr,
      platformFeeRefundedIrr: preview.platformFeeRefundedIrr,
      nursePayoutRefundedIrr: preview.nursePayoutRefundedIrr,
      expectedCustomerRefundEta: status === 'failed' ? null : preview.expectedCustomerRefundEta,
      // Post-payout: the nurse was already paid, so executing opens a pending clawback — never on a decline.
      clawbackId: preview.willCreateClawback && !declined ? nextClawbackId++ : null,
    };
    adminRefundsById[result.refundId] = result;
    return result;
  },

  approveRefund: async (refundId) => {
    await sleep(MOCK_LATENCY_MS);
    const existing = adminRefundsById[refundId];
    if (!existing) throw new ApiError(404, 'Refund not found', 'not_found');
    // Approve/retry only applies to a refund awaiting execution or one the channel declined.
    if (existing.status !== 'failed' && existing.status !== 'requested') {
      throw new ApiError(409, 'Refund is not awaiting approval', 'not_approvable');
    }
    const status = executedStatusFor(existing.refundChannel);
    const updated: AdminRefundResult = {
      ...existing,
      status,
      expectedCustomerRefundEta:
        existing.refundChannel === 'bnpl_revert' ? businessDaysFromNow(ADMIN_BNPL_ETA_BUSINESS_DAYS) : null,
    };
    adminRefundsById[refundId] = updated;
    return updated;
  },

  rejectRefund: async (refundId, reason) => {
    await sleep(MOCK_LATENCY_MS);
    if (!reason?.trim()) throw new ApiError(400, 'A rejection reason is required', 'reason_required');
    const existing = adminRefundsById[refundId];
    if (!existing) throw new ApiError(404, 'Refund not found', 'not_found');
    if (isTerminalRefundStatus(existing.status)) {
      throw new ApiError(409, 'Refund is already terminal', 'not_rejectable');
    }
    adminRefundsById[refundId] = { ...existing, status: 'rejected' };
  },
};
