import type {
  Bill, Category, Payment, TrackerResponse, User,
  BankLedger, BankLedgerTransaction, AutoCategorizePreview,
  SpendingSummary, SpendingTransactionsResponse, SpendingIncomeResponse,
  SpendingRule, CopyBudgetsResponse, CategoryGroup,
  SubscriptionsResponse, Recommendation, SubscriptionTx,
  SnowballProjection, SnowballSettings,
} from '@/types';

// Fetch CSRF token from the server once and cache in memory.
// The cookie is httpOnly so document.cookie cannot access it directly.
let _csrfFetch: Promise<string> | null = null;
async function getCsrfToken(): Promise<string> {
  if (!_csrfFetch) {
    _csrfFetch = fetch('/api/auth/csrf-token', { credentials: 'include' })
      .then(r => r.json())
      .then(d => d.token || '')
      .catch(() => {
        _csrfFetch = null; // don't cache a failed fetch
        return '';
      });
  }
  return _csrfFetch;
}

// Common parameter/body shapes for the client. Endpoint responses are typed
// incrementally — most methods currently resolve to `unknown` (callers narrow),
// with the money-critical/consumed ones given real shapes below.
type Id = number | string;
type Body = unknown;
export type QueryParams = Record<string, string | number | boolean | null | undefined>;

/** Error thrown by the API client, carrying the server's structured fields. */
export interface ApiError extends Error {
  status?: number;
  data?: unknown;
  details?: unknown[];
  code?: string;
}

/** Result of toggling a tracker row paid/unpaid. */
export interface TogglePaidResult {
  paymentId?: number;
  [key: string]: unknown;
}

const MUTATING_METHODS = ['POST', 'PUT', 'DELETE', 'PATCH'];

// Parse a response body without assuming it is JSON. Returns null when the
// body is empty (204) or not valid JSON (e.g. an HTML error page from a proxy).
// A hung request (server accepted the connection but never responds) would
// otherwise spin the UI forever. 120s is generous enough for the slowest
// legitimate op (large import / bank sync / backup) on a self-hosted LAN while
// still bounding a truly dead request so it surfaces as an error, not a freeze.
const REQUEST_TIMEOUT_MS = 120_000;

// A parsed JSON response body. The error-envelope fields the client reads are
// typed; any other (success) field passes through the index signature. Avoids an
// `any` at the parse boundary without forcing casts at every read site.
type ResponseBody = { message?: string; error?: string; code?: string; details?: unknown[]; token?: string } & Record<string, unknown>;

async function parseJsonSafe(res: Response): Promise<ResponseBody | null> {
  if (res.status === 204) return null;
  const text = await res.text();
  if (!text) return null;
  try { return JSON.parse(text) as ResponseBody; } catch { return null; }
}

async function _fetch<T = unknown>(method: string, path: string, body?: unknown, _retried = false): Promise<T> {
  const headers: Record<string, string> = { 'Content-Type': 'application/json' };
  const opts: RequestInit = { method, headers, credentials: 'include' };
  // Add CSRF token header for state-changing methods
  if (MUTATING_METHODS.includes(method)) {
    const csrfToken = await getCsrfToken();
    if (csrfToken) {
      headers['x-csrf-token'] = csrfToken;
    }
  }
  if (body !== undefined) opts.body = JSON.stringify(body);
  let res: Response;
  try {
    res = await fetch('/api' + path, { ...opts, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
  } catch (e) {
    // Timeout (AbortSignal.timeout → TimeoutError) or network failure (TypeError)
    // — both would otherwise leave the UI hung or show a raw "Failed to fetch".
    const timedOut = (e as Error)?.name === 'TimeoutError';
    const err = new Error(timedOut
      ? 'The request timed out — the server may be busy or unreachable. Please try again.'
      : 'Network error — could not reach the server. Check your connection and try again.') as ApiError;
    err.status = timedOut ? 408 : 0;
    err.code = timedOut ? 'REQUEST_TIMEOUT' : 'NETWORK_ERROR';
    throw err;
  }
  const data = await parseJsonSafe(res);
  if (!res.ok) {
    // Stale CSRF token (cookie rotated/expired since first fetch): refresh the
    // cached token and retry the request once instead of forcing a page reload.
    if (!_retried && res.status === 403 && data?.code === 'CSRF_INVALID' && MUTATING_METHODS.includes(method)) {
      _csrfFetch = null;
      return _fetch<T>(method, path, body, true);
    }
    const err = new Error(data?.message || data?.error || `HTTP ${res.status}`) as ApiError;
    err.status = res.status;
    err.data = data || {};
    err.details = data?.details || [];
    err.code = data?.code;
    throw err;
  }
  return (data ?? {}) as T;
}

function queryString(params: QueryParams = {}): string {
  const qs = new URLSearchParams();
  Object.entries(params).forEach(([key, value]) => {
    if (value !== undefined && value !== null && value !== '') qs.set(key, String(value));
  });
  const value = qs.toString();
  return value ? `?${value}` : '';
}

const get    = <T = unknown>(path: string, params?: QueryParams): Promise<T> => _fetch<T>('GET', path + (params ? queryString(params) : ''));
const post   = <T = unknown>(path: string, body?: unknown): Promise<T> => _fetch<T>('POST', path, body);
const put    = <T = unknown>(path: string, body?: unknown): Promise<T> => _fetch<T>('PUT', path, body);
const patch  = <T = unknown>(path: string, body?: unknown): Promise<T> => _fetch<T>('PATCH', path, body);
const del    = <T = unknown>(path: string): Promise<T> => _fetch<T>('DELETE', path);

function filenameFromDisposition(value: string | null): string | null {
  if (!value) return null;
  const match = value.match(/filename="?([^"]+)"?/i);
  return match ? (match[1] ?? null) : null;
}

export const api = {
  // Auth
  me:                 ()          => get<{ user?: User | null; single_user_mode?: boolean; has_new_version?: boolean }>('/auth/me'),
  authMode:           ()          => get('/auth/mode'),
  login:              (data: Body) => post<{ requires_totp?: boolean; challenge_token?: string; user?: User }>('/auth/login', data),
  logout:             ()          => post('/auth/logout'),
  restoreMultiUserMode: ()        => post('/auth/restore-multi-user-mode'),
  changePassword:     (data: Body) => post('/auth/change-password', data),
  acknowledgePrivacy:  ()         => post('/auth/acknowledge-privacy'),
  acknowledgeVersion:  ()         => post('/auth/acknowledge-version'),
  loginHistory:        ()         => get('/auth/login-history'),
  // Spending
  spendingSummary:     (p?: QueryParams) => get<SpendingSummary>('/spending/summary', p),
  spendingTransactions:(p?: QueryParams) => get<SpendingTransactionsResponse>('/spending/transactions', p),
  categorizeTransaction: (id: Id, d: Body) => patch(`/spending/transactions/${id}/category`, d),
  spendingBudgets:     (p?: QueryParams) => get('/spending/budgets', p),
  setSpendingBudget:   (d: Body)  => put('/spending/budgets', d),
  copySpendingBudgets: (d: Body)  => post<CopyBudgetsResponse>('/spending/budgets/copy', d),
  spendingIncome:      (p?: QueryParams) => get<SpendingIncomeResponse>('/spending/income', p),
  spendingCategoryRules: ()       => get<{ rules?: SpendingRule[] }>('/spending/category-rules'),
  addSpendingRule:     (d: Body)  => post('/spending/category-rules', d),
  deleteSpendingRule:  (id: Id)   => del(`/spending/category-rules/${id}`),

  totpStatus:          ()         => get('/auth/totp/status'),
  totpSetup:           ()         => get('/auth/totp/setup'),
  totpEnable:          (data: Body) => post('/auth/totp/enable', data),
  totpDisable:         (data: Body) => post('/auth/totp/disable', data),
  totpChallenge:       (data: Body) => post<{ user?: User }>('/auth/totp/challenge', data),

  webauthnStatus:      ()         => get('/auth/webauthn/status'),
  webauthnSetup:       ()         => get('/auth/webauthn/setup'),
  webauthnEnable:      (data: Body) => post('/auth/webauthn/enable', data),
  webauthnDisable:     (data: Body) => post('/auth/webauthn/disable', data),
  webauthnCredentials: ()         => get('/auth/webauthn/credentials'),
  webauthnDeleteCred:  (id: Id, data: Body) => _fetch('DELETE', `/auth/webauthn/credentials/${encodeURIComponent(id)}`, data),
  webauthnChallenge:   (data: Body) => post('/auth/webauthn/challenge', data),

  // Admin
  hasUsers:           ()          => get<{ has_users?: boolean }>('/admin/has-users'),
  adminUsers:         ()          => get('/admin/users'),
  createUser:         (data: Body) => post('/admin/users', data),
  resetPassword:      (id: Id, data: Body) => put(`/admin/users/${id}/password`, data),
  updateUserRole:     (id: Id, data: Body) => put(`/admin/users/${id}/role`, data),
  updateUserActive:   (id: Id, data: Body) => put(`/admin/users/${id}/active`, data),
  deleteUser:         (id: Id)    => del(`/admin/users/${id}`),
  authModeConfig:     ()          => get('/admin/auth-mode'),
  setAuthMode:        (data: Body) => put('/admin/auth-mode', data),
  testOidcConfig:     (data: Body) => post('/admin/auth-mode/oidc-test', data),
  adminBackups:       ()          => get('/admin/backups'),
  createAdminBackup:  ()          => post('/admin/backups'),
  deleteAdminBackup:  (id: Id)    => del(`/admin/backups/${encodeURIComponent(id)}`),
  restoreAdminBackup: (id: Id)    => post(`/admin/backups/${encodeURIComponent(id)}/restore`),
  adminBackupSettings: ()         => get('/admin/backups/settings'),
  saveAdminBackupSettings: (data: Body) => put('/admin/backups/settings', data),
  runScheduledBackupNow: ()       => post('/admin/backups/run-scheduled-now'),
  adminCleanup:        ()         => get('/admin/cleanup'),
  saveAdminCleanup:    (data: Body) => put('/admin/cleanup', data),
  runAdminCleanup:     ()         => post('/admin/cleanup/run'),
  seedDemoData:        ()         => post('/user/seed-demo-data'),
  clearDemoData:       ()         => post('/user/clear-demo-data'),
  seededStatus:        ()         => get('/user/seeded-status'),
  eraseMyData:         (confirm: unknown) => post('/user/erase-data', { confirm }),
  downloadAdminBackup: async (id: Id) => {
    const res = await fetch(`/api/admin/backups/${encodeURIComponent(id)}/download`, {
      credentials: 'include',
    });
    if (!res.ok) {
      let data: ResponseBody = {};
      try { data = await res.json(); } catch { /* non-JSON error body */ }
      throw new Error(data.error || `HTTP ${res.status}`);
    }
    return {
      blob: await res.blob(),
      filename: filenameFromDisposition(res.headers.get('Content-Disposition')) || String(id),
    };
  },
  importAdminBackup: async (file: File) => {
    const csrfToken = await getCsrfToken();
    const res = await fetch('/api/admin/backups/import', {
      method: 'POST',
      credentials: 'include',
      headers: { 'Content-Type': 'application/octet-stream', 'x-csrf-token': csrfToken },
      body: file,
    });
    const data = await res.json();
    if (!res.ok) {
      const err = new Error(data.message || data.error || `HTTP ${res.status}`) as ApiError;
      err.status = res.status;
      err.data = data;
      err.details = data.details || [];
      err.code = data.code;
      throw err;
    }
    return data;
  },

  // Notifications (admin)
  notifAdmin:         ()          => get('/notifications/admin'),
  saveNotifAdmin:     (data: Body) => put('/notifications/admin', data),
  testEmail:          (data: Body) => post('/notifications/test', data),
  testPushNotification: ()        => post('/notifications/test-push', {}),
  notifMe:            ()          => get('/notifications/me'),
  saveNotifMe:        (data: Body) => put('/notifications/me', data),

  // Profile
  profile:            ()          => get('/profile'),
  updateProfile:      (data: Body) => _fetch('PATCH', '/profile', data),
  profileSettings:    ()          => get('/profile/settings'),
  updateProfileSettings: (data: Body) => _fetch('PATCH', '/profile/settings', data),
  changeProfilePassword: (data: Body) => post('/profile/change-password', data),
  profileExports:     ()          => get('/profile/exports'),
  profileImportHistory: ()        => get('/profile/import-history'),

  // Tracker
  tracker:            (y: number, m: number, params: QueryParams = {}) => get<TrackerResponse>(`/tracker${queryString({ year: y, month: m, ...params })}`),
  upcomingBills:      (days = 30) => get(`/tracker/upcoming?days=${days}`),
  overdueCount:       ()          => get('/tracker/overdue-count'),
  snoozeOverdue:      (id: Id, data: Body) => put(`/bills/${id}/monthly-state`, data),

  // Calendar
  calendar:           (y: number, m: number) => get(`/calendar?year=${y}&month=${m}`),
  calendarFeed:       ()          => get('/calendar/feed'),
  createCalendarFeed: ()          => post('/calendar/feed', {}),
  regenerateCalendarFeed: ()      => post('/calendar/feed/regenerate', {}),
  revokeCalendarFeed: ()          => del('/calendar/feed'),
  calendarFeedPreview:(limit = 10) => get('/calendar/feed/preview', { limit }),

  // Summary
  summary:            (y: number, m: number) => get(`/summary?year=${y}&month=${m}`),
  saveSummaryIncome:  (data: Body) => put('/summary/income', data),
  getMonthlyStartingAmounts: (y: number, m: number) => get(`/monthly-starting-amounts?year=${y}&month=${m}`),
  updateMonthlyStartingAmounts: (data: Body) => put('/monthly-starting-amounts', data),

  // Bills
  bills:              (params: QueryParams = {}) => get<Bill[]>(`/bills${queryString(params)}`),
  allBills:           (params: QueryParams = {}) => get<Bill[]>(`/bills${queryString({ inactive: true, ...params })}`),
  deletedBills:       ()          => get<Bill[]>('/bills/deleted'),
  billAudit:          (includeInactive = false) => get(`/bills/audit${includeInactive ? '?inactive=true' : ''}`),
  bill:               (id: Id)    => get<Bill>(`/bills/${id}`),
  createBill:         (data: Body) => post<Bill>('/bills', data),
  updateBill:         (id: Id, data: Body) => put<Bill>(`/bills/${id}`, data),
  reorderBills:       (order: Body) => put('/bills/reorder', order),
  archiveBill:        (id: Id, archived = true) => put(`/bills/${id}/archived`, { archived }),
  updateBillBalance:  (id: Id, bal: unknown) => _fetch('PATCH', `/bills/${id}/balance`, { current_balance: bal }),
  billAmortization:   (id: Id, opts: { payment?: number | string; max_months?: number | string } = {}) => {
    const params = new URLSearchParams();
    if (opts.payment)    params.set('payment',    String(opts.payment));
    if (opts.max_months) params.set('max_months', String(opts.max_months));
    const qs = params.toString();
    return get(`/bills/${id}/amortization${qs ? `?${qs}` : ''}`);
  },
  updateBillSnowball: (id: Id, data: Body) => _fetch('PATCH', `/bills/${id}/snowball`, data),
  deleteBill:         (id: Id)    => del(`/bills/${id}`),
  restoreBill:        (id: Id)    => post(`/bills/${id}/restore`),
  duplicateBill:      (id: Id, data: Body) => post(`/bills/${id}/duplicate`, data),
  verifyAutopay:      (id: Id)    => post(`/bills/${id}/verify-autopay`, {}),
  togglePaid:         (id: Id, data: Body) => post<TogglePaidResult>(`/bills/${id}/toggle-paid`, data),
  billPayments:       (id: Id, p?: number, l?: number) => get(`/bills/${id}/payments?page=${p||1}&limit=${l||20}`),
  billTransactions:   (id: Id)    => get(`/bills/${id}/transactions`),
  syncBillSimplefinPayments:  (id: Id)         => post(`/bills/${id}/sync-simplefin-payments`),
  allBillMerchantRules:       ()               => get('/bills/merchant-rules'),
  billMerchantRules:          (id: Id)         => get(`/bills/${id}/merchant-rules`),
  previewMerchantRule:        (id: Id, merchant: string) => get(`/bills/${id}/merchant-rules/preview?merchant=${encodeURIComponent(merchant)}`),
  addMerchantRule:            (id: Id, merchant: string) => post(`/bills/${id}/merchant-rules`, { merchant }),
  deleteMerchantRule:         (id: Id, ruleId: Id) => del(`/bills/${id}/merchant-rules/${ruleId}`),
  merchantRuleCandidates:         (id: Id)     => get(`/bills/${id}/merchant-rules/candidates`),
  toggleRuleAutoAttribute:        (id: Id, ruleId: Id, on: unknown) => _fetch('PATCH', `/bills/${id}/merchant-rules/${ruleId}/auto-attribute`, { enabled: on }),
  importHistoricalPayments:   (id: Id, ids: unknown) => post(`/bills/${id}/merchant-rules/import-historical`, { transaction_ids: ids }),
  billMonthlyState:   (id: Id, y: number, m: number) => get(`/bills/${id}/monthly-state?year=${y}&month=${m}`),
  saveBillMonthlyState: (id: Id, data: Body) => put(`/bills/${id}/monthly-state`, data),
  billHistoryRanges:  (id: Id)    => get(`/bills/${id}/history-ranges`),
  createBillHistoryRange: (id: Id, data: Body) => post(`/bills/${id}/history-ranges`, data),
  updateBillHistoryRange: (id: Id, rangeId: Id, data: Body) => put(`/bills/${id}/history-ranges/${rangeId}`, data),
  deleteBillHistoryRange: (id: Id, rangeId: Id) => del(`/bills/${id}/history-ranges/${rangeId}`),
  driftReport:        ()          => get('/bills/drift-report'),
  snoozeBillDrift:    (id: Id)    => post(`/bills/${id}/snooze-drift`, {}),
  billTemplates:      ()          => get('/bills/templates'),
  saveBillTemplate:   (data: Body) => post('/bills/templates', data),
  deleteBillTemplate: (id: Id)    => del(`/bills/templates/${id}`),

  // Subscriptions
  subscriptions:       ()          => get<SubscriptionsResponse>('/subscriptions'),
  confirmTransactionMatch: (transactionId: Id, billId: Id) => post('/matches/confirm', { transaction_id: transactionId, bill_id: billId }),
  matchRecommendationToBill: (transactionIds: unknown, billId: Id, merchant: unknown, catalogId: unknown, confidence: unknown) => post<{ matched_count?: number; bill_name?: string }>('/subscriptions/recommendations/match-bill', {
    transaction_ids: transactionIds,
    bill_id: billId,
    merchant,
    catalog_id: catalogId,
    confidence,
  }),
  subscriptionRecommendations: ()  => get<{ recommendations?: Recommendation[] }>('/subscriptions/recommendations'),
  subscriptionTransactionMatches: (params: QueryParams = {}) => get<SubscriptionTx[] | { transactions?: SubscriptionTx[] }>(`/subscriptions/transaction-matches${queryString(params)}`),
  updateSubscription:  (id: Id, data: Body) => _fetch('PATCH', `/subscriptions/${id}`, data),
  createSubscriptionFromRecommendation: (data: Body) => post<{ id?: number; name?: string }>('/subscriptions/recommendations/create', data),
  declineRecommendation: (recommendation: Recommendation) => post('/subscriptions/recommendations/decline', {
    decline_key: recommendation?.decline_key || recommendation,
    catalog_id: recommendation?.catalog_match?.id,
    merchant: recommendation?.merchant,
    confidence: recommendation?.confidence,
  }),
  subscriptionCatalog:          ()                 => get('/subscriptions/catalog'),
  updateSubscriptionCatalogLink:(id: Id, catalogId: Id) => _fetch('PUT', `/subscriptions/${id}/catalog-link`, { catalog_id: catalogId }),
  addCatalogDescriptor:         (catalogId: Id, d: unknown) => post(`/subscriptions/catalog/${catalogId}/descriptors`, { descriptor: d }),
  deleteCatalogDescriptor:      (id: Id)           => _fetch('DELETE', `/subscriptions/catalog/descriptors/${id}`),

  // Payments
  quickPay:           (data: Body) => post<Payment>('/payments/quick', data),
  confirmAutopaySuggestion: (billId: Id, data: Body) => post(`/payments/autopay-suggestions/${billId}/confirm`, data),
  dismissAutopaySuggestion: (billId: Id, data: Body) => post(`/payments/autopay-suggestions/${billId}/dismiss`, data),
  bulkPay:            (items: Body) => post('/payments/bulk', items),
  createPayment:      (data: Body) => post<Payment>('/payments', data),
  updatePayment:      (id: Id, data: Body) => put<Payment>(`/payments/${id}`, data),
  deletePayment:      (id: Id)    => del(`/payments/${id}`),
  restorePayment:     (id: Id)    => post<Payment>(`/payments/${id}/restore`),
  recentAutoMatched:  ()          => get('/payments/recent-auto'),
  undoAutoMatch:      (id: Id)    => post(`/payments/${id}/undo-auto`),

  // Snowball
  snowball:              ()        => get<Bill[]>('/snowball'),
  snowballSettings:      ()        => get<SnowballSettings>('/snowball/settings'),
  saveSnowballSettings:  (data: Body) => _fetch<SnowballSettings>('PATCH', '/snowball/settings', data),
  saveSnowballOrder:     (items: Body) => _fetch('PATCH', '/snowball/order', items),
  snowballProjection: (params: QueryParams = {}) => get<SnowballProjection>(`/snowball/projection${queryString(params)}`),
  snowballPlans:         ()        => get<{ plans?: unknown[] }>('/snowball/plans'),
  snowballActivePlan:    ()        => get('/snowball/plans/active'),
  startSnowballPlan:     (data: Body) => post('/snowball/plans', data),
  updateSnowballPlan:    (id: Id, d: Body) => _fetch('PATCH', `/snowball/plans/${id}`, d),
  pauseSnowballPlan:     (id: Id)  => post(`/snowball/plans/${id}/pause`, {}),
  resumeSnowballPlan:    (id: Id)  => post(`/snowball/plans/${id}/resume`, {}),
  completeSnowballPlan:  (id: Id)  => post(`/snowball/plans/${id}/complete`, {}),
  abandonSnowballPlan:   (id: Id)  => post(`/snowball/plans/${id}/abandon`, {}),

  // Categories
  categories:         ()          => get<Category[]>('/categories'),
  createCategory:     (data: Body) => post<Category>('/categories', data),
  reorderCategories:  (order: Body) => put('/categories/reorder', order),
  updateCategory:     (id: Id, data: Body) => put<Category>(`/categories/${id}`, data),
  toggleCategorySpending: (id: Id, val: unknown) => patch(`/categories/${id}/spending`, { spending_enabled: val }),
  deleteCategory:     (id: Id)    => del(`/categories/${id}`),
  restoreCategory:    (id: Id)    => post(`/categories/${id}/restore`),

  // Category groups
  categoryGroups:       ()          => get<CategoryGroup[]>('/categories/groups'),
  createCategoryGroup:  (data: Body) => post('/categories/groups', data),
  updateCategoryGroup:  (id: Id, data: Body) => put(`/categories/groups/${id}`, data),
  deleteCategoryGroup:  (id: Id)    => del(`/categories/groups/${id}`),

  // Settings
  settings:           ()          => get<Record<string, unknown>>('/settings'),
  saveSettings:       (data: Body) => put('/settings', data),

  // Analytics
  analyticsSummary:   (params: QueryParams = {}) => {
    const qs = new URLSearchParams();
    Object.entries(params).forEach(([key, value]) => {
      if (value !== undefined && value !== null && value !== '') qs.set(key, String(value));
    });
    const query = qs.toString();
    return get(`/analytics/summary${query ? `?${query}` : ''}`);
  },

  // Status
  status:             ()          => get('/status'),

  // Version (public)
  about:              ()          => get('/about'),
  privacy:            ()          => get('/privacy'),
  aboutAdmin:         ()          => get('/about-admin'),
  roadmap:            (refresh = false) => get(`/about-admin/roadmap${refresh ? '?refresh=1' : ''}`),
  updateStatus:       ()          => get('/version/update-status'),
  checkForUpdates:    ()          => post('/about-admin/check-updates'),
  getUpdateCheckSetting: ()       => get('/about-admin/update-check-setting'),
  setUpdateCheckSetting: (enabled: unknown) => put('/about-admin/update-check-setting', { enabled }),
  devLog:             ()          => get('/about-admin/dev-log'),
  version:            ()          => get('/version'),
  releaseHistory:     ()          => get('/version/history'),

  // Export (returns a URL to navigate to, not a fetch)
  exportUrl:          (year: Id, fmt?: string): string => `/api/export?year=${year}&format=${fmt||'csv'}`,
  // Export a specific date range (both bounds inclusive) — used to export one
  // tracker month. The /api/export route accepts ?from&to in addition to ?year.
  exportRangeUrl:     (from: string, to: string, fmt?: string): string => `/api/export?from=${from}&to=${to}&format=${fmt||'csv'}`,

  // Spreadsheet Import
  previewSpreadsheetImport: async (file: File, options: { parseAllSheets?: boolean; defaultYear?: number | string; defaultMonth?: number | string } = {}) => {
    const params = new URLSearchParams();
    if (options.parseAllSheets) params.set('parse_all_sheets', 'true');
    if (options.defaultYear)    params.set('year',  String(options.defaultYear));
    if (options.defaultMonth)   params.set('month', String(options.defaultMonth));
    const qs  = params.toString();
    const csrfToken = await getCsrfToken();
    const res = await fetch(`/api/import/spreadsheet/preview${qs ? `?${qs}` : ''}`, {
      method:      'POST',
      credentials: 'include',
      headers: {
        'Content-Type': 'application/octet-stream',
        'x-csrf-token': csrfToken,
        ...(file.name ? { 'X-Filename': file.name } : {}),
      },
      body: file,
    });
    const data = await res.json();
    if (!res.ok) {
      const err = new Error(data.message || data.error || `HTTP ${res.status}`) as ApiError;
      err.status = res.status;
      err.data = data;
      err.details = data.details || [];
      err.code = data.code;
      throw err;
    }
    return data;
  },
  applySpreadsheetImport: (data: Body) => post('/import/spreadsheet/apply', data),
  previewCsvTransactionImport: async (file: File) => {
    const csrfToken = await getCsrfToken();
    const res = await fetch('/api/import/csv/preview', {
      method:      'POST',
      credentials: 'include',
      headers: {
        'Content-Type': 'text/csv',
        'x-csrf-token': csrfToken,
        ...(file.name ? { 'X-Filename': file.name } : {}),
      },
      body: file,
    });
    const data = await res.json();
    if (!res.ok) {
      const err = new Error(data.message || data.error || `HTTP ${res.status}`) as ApiError;
      err.status = res.status;
      err.data = data;
      err.details = data.details || [];
      err.code = data.code;
      throw err;
    }
    return data;
  },
  commitCsvTransactionImport: (data: Body) => post('/import/csv/commit', data),
  previewOfxTransactionImport: async (file: File) => {
    const csrfToken = await getCsrfToken();
    const res = await fetch('/api/import/ofx/preview', {
      method:      'POST',
      credentials: 'include',
      headers: {
        'Content-Type': 'application/x-ofx',
        'x-csrf-token': csrfToken,
        ...(file.name ? { 'X-Filename': file.name } : {}),
      },
      body: file,
    });
    const data = await res.json();
    if (!res.ok) {
      const err = new Error(data.message || data.error || `HTTP ${res.status}`) as ApiError;
      err.status = res.status;
      err.data = data;
      err.details = data.details || [];
      err.code = data.code;
      throw err;
    }
    return data;
  },
  commitOfxTransactionImport: (data: Body) => post('/import/ofx/commit', data),
  importHistory:          ()      => get('/import/history'),

  // Transactions
  transactions:           (params: QueryParams = {}) => get(`/transactions${queryString(params)}`),
  bankTransactionsLedger: (params: QueryParams = {}) => get<BankLedger>(`/transactions/bank-ledger${queryString(params)}`),
  createManualTransaction: (data: Body) => post('/transactions/manual', data),
  updateTransaction:      (id: Id, data: Body) => put(`/transactions/${id}`, data),
  deleteTransaction:      (id: Id) => del(`/transactions/${id}`),
  matchTransaction:       (id: Id, billId: Id) => post<{ transaction: BankLedgerTransaction }>(`/transactions/${id}/match`, { billId }),
  unmatchTransaction:     (id: Id) => post<{ transaction: BankLedgerTransaction }>(`/transactions/${id}/unmatch`),
  unmatchTransactionBulk: (matches: Body) => post('/transactions/unmatch-bulk', { matches }),
  ignoreTransaction:      (id: Id) => post<BankLedgerTransaction>(`/transactions/${id}/ignore`),
  unignoreTransaction:    (id: Id) => post<BankLedgerTransaction>(`/transactions/${id}/unignore`),
  transactionMerchantMatch:      (id: Id) => get(`/transactions/${id}/merchant-match`),
  applyTransactionMerchantMatch: (id: Id) => post<{ matched?: boolean; category?: { id: number; name: string } }>(`/transactions/${id}/apply-merchant-match`),
  autoCategorizeTransactions:    (opts: Body = {}) => post<AutoCategorizePreview>('/transactions/auto-categorize', opts),

  // Match suggestions
  matchSuggestions:       (params: QueryParams = {}) => get(`/matches/suggestions${queryString(params)}`),
  rejectMatchSuggestion:  (id: Id) => post(`/matches/${encodeURIComponent(id)}/reject`),

  // Data sources & SimpleFIN bank sync
  dataSources:            (params: QueryParams = {}) => get(`/data-sources${queryString(params)}`),
  simplefinStatus:        ()            => get('/data-sources/simplefin/status'),
  connectSimplefin:       (setupToken: unknown) => post('/data-sources/simplefin/connect', { setupToken }),
  syncDataSource:         (id: Id)      => post(`/data-sources/${id}/sync`),
  backfillDataSource:     (id: Id)      => post(`/data-sources/${id}/backfill`),
  deleteDataSource:       (id: Id)      => del(`/data-sources/${id}`),
  dataSourceAccounts:     (sourceId: Id) => get(`/data-sources/${sourceId}/accounts`),
  setAccountMonitored:    (sourceId: Id, accountId: Id, monitored: unknown) => put(`/data-sources/${sourceId}/accounts/${accountId}`, { monitored }),
  allFinancialAccounts:   ()            => get('/data-sources/accounts/all'),
  syncAllSources:             ()        => post('/data-sources/sync-all', {}),
  attributePaymentToMonth:    (id: Id, paid_date: unknown) => _fetch('PATCH', `/payments/${id}/attribute-to-month`, { paid_date }),

  // Admin — bank sync feature flag
  bankSyncConfig:         ()            => get('/admin/bank-sync-config'),
  setBankSyncConfig:      (data: Body)  => put('/admin/bank-sync-config', data),

  // User SQLite import
  previewUserDbImport: async (file: File) => {
    const csrfToken = await getCsrfToken();
    const res = await fetch('/api/import/user-db/preview', {
      method:      'POST',
      credentials: 'include',
      headers: {
        'Content-Type': 'application/octet-stream',
        'x-csrf-token': csrfToken,
        ...(file.name ? { 'X-Filename': file.name } : {}),
      },
      body: file,
    });
    const data = await res.json();
    if (!res.ok) {
      const err = new Error(data.message || data.error || `HTTP ${res.status}`) as ApiError;
      err.status = res.status;
      err.data = data;
      err.details = data.details || [];
      err.code = data.code;
      throw err;
    }
    return data;
  },
  applyUserDbImport: (data: Body) => post('/import/user-db/apply', data),
};
