/**
 * Shared Amazon Selling Partner API (SP-API) helper.
 *
 * Auth model: LWA refresh-token -> access-token exchange (no AWS SigV4 needed),
 * access token sent as the `x-amz-access-token` header. Credentials live per
 * order source on `c_ordersource` (marketplace_key / marketplace_secret /
 * marketplace_token). EU endpoint only.
 *
 * The LWA auth + marketplace resolution were originally inlined in
 * server/api/invoices/amazon-upload.post.ts; this util centralises them and adds
 * the Finances API helpers used by the accounting / settlement-report feature
 * (server/api/accounting/amazon/{settlements,settlement}.get.ts) and the Orders
 * API helpers used by the order-reconciliation ("Bestellabgleich") feature.
 */

export const LWA_TOKEN_URL = 'https://api.amazon.com/auth/o2/token'
export const SP_API_BASE_URL = 'https://sellingpartnerapi-eu.amazon.com'

// EU Marketplace IDs
export const MARKETPLACE_IDS: Record<string, string> = {
  'DE': 'A1PA6795UKMFR9',
  'FR': 'A13V1IB3VIYZZH',
  'IT': 'APJ6JRA9NG5V4',
  'ES': 'A1RKKUPIHCS9HS',
  'NL': 'A1805IZSGTT6HS',
  'BE': 'AMEN7PMS3EDWL',
  'UK': 'A1F83G8C2ARO7P',
  'GB': 'A1F83G8C2ARO7P',
  'PL': 'A1C3SOZRARQ6R3',
  'SE': 'A2NODRKZP88ZB9',
  'AT': 'A2CVHYRTWLQO9T'
}

const KNOWN_MARKETPLACE_IDS = new Set(Object.values(MARKETPLACE_IDS))

// Resolve an Amazon marketplaceId from c_ordersource.
// Tries Marketplace.identifier (raw marketplaceId, country code, or suffixed),
// then falls back to parsing the Description URL (e.g. "https://sellercentral.amazon.de/..." -> DE).
// Returns null when no match is found.
export const resolveMarketplaceId = (
  identifier: string | null | undefined,
  description: string | null | undefined = null
): string | null => {
  const raw = String(identifier ?? '').trim()
  if (raw) {
    if (KNOWN_MARKETPLACE_IDS.has(raw)) return raw
    const upper = raw.toUpperCase()
    if (MARKETPLACE_IDS[upper]) return MARKETPLACE_IDS[upper]
    const suffixMatch = upper.match(/[-._ ]([A-Z]{2})$/)
    if (suffixMatch && MARKETPLACE_IDS[suffixMatch[1]]) return MARKETPLACE_IDS[suffixMatch[1]]
  }
  const desc = String(description ?? '')
  if (desc) {
    const urlMatch = desc.match(/amazon\.([a-z]{2,3}(?:\.[a-z]{2})?)/i)
    if (urlMatch) {
      const tld = urlMatch[1].toLowerCase()
      const tldToCountry: Record<string, string> = {
        'de': 'DE', 'fr': 'FR', 'it': 'IT', 'es': 'ES', 'nl': 'NL',
        'be': 'BE', 'pl': 'PL', 'se': 'SE', 'at': 'AT',
        'co.uk': 'UK', 'uk': 'UK'
      }
      const country = tldToCountry[tld]
      if (country && MARKETPLACE_IDS[country]) return MARKETPLACE_IDS[country]
    }
  }
  return null
}

// Get LWA access token using a refresh token.
export const getAmazonAccessToken = async (
  clientId: string,
  clientSecret: string,
  refreshToken: string
): Promise<string> => {
  const response = await fetch(LWA_TOKEN_URL, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'refresh_token',
      refresh_token: refreshToken,
      client_id: clientId,
      client_secret: clientSecret
    })
  })

  if (!response.ok) {
    const errorText = await response.text()
    throw new Error(`Failed to get Amazon access token (${response.status}): ${errorText}`)
  }

  const data: any = await response.json()
  return data.access_token
}

// Generic SP-API GET. Returns parsed JSON, throws an Error carrying the Amazon
// error message + status on a non-2xx (so callers can surface it verbatim).
export const spApiGet = async (accessToken: string, path: string): Promise<any> => {
  const response = await fetch(`${SP_API_BASE_URL}${path}`, {
    method: 'GET',
    headers: {
      'x-amz-access-token': accessToken,
      'Content-Type': 'application/json'
    }
  })

  const text = await response.text()
  let json: any = {}
  try { json = text ? JSON.parse(text) : {} } catch { json = { raw: text } }

  if (!response.ok) {
    const msg = json?.errors?.[0]?.message || json?.errors?.[0]?.details || text || response.statusText
    const err: any = new Error(`SP-API ${response.status}: ${msg}`)
    err.status = response.status
    err.spapi = json
    throw err
  }
  return json
}

// Robustly parse a settlement amount string to a Number (handles "-12.34",
// "1,234.56" en and "1.234,56" de). Returns 0 for blank/invalid. Also used for
// Amazon JSON numeric fields (harmless no-op on an already-numeric value).
export const parseAmazonAmount = (v: any): number => {
  if (v == null) return 0
  let s = String(v).trim()
  if (!s) return 0
  const hasComma = s.includes(',')
  const hasDot = s.includes('.')
  if (hasComma && hasDot) {
    if (s.lastIndexOf(',') > s.lastIndexOf('.')) s = s.replace(/\./g, '').replace(',', '.')
    else s = s.replace(/,/g, '')
  } else if (hasComma) {
    s = s.replace(',', '.')
  }
  const n = parseFloat(s)
  return isNaN(n) ? 0 : n
}

// ---------------------------------------------------------------------------
// Finances API (v0) — settlement periods + line-item detail. Replaces the old
// Reports-API-based GET_V2_SETTLEMENT_REPORT_DATA_FLAT_FILE_V2 flow: that
// endpoint hard-rejects a `createdSince` older than 90 days (verified against
// production — "RequestedFromDate ... is more than 90 days old"), so settlement
// history beyond ~90 days was permanently unreachable through it. The Finances
// API's `financialEventGroups` has no such cap — it reaches back to the
// account's actual history start. Verified 1:1 against the old flat file across
// 7 real settlement periods (308 order/fee rows): every amount and date matches
// to the cent; see reference/recipes.md for the reconciliation notes.
// ---------------------------------------------------------------------------

export interface FinancialEventGroupRef {
  groupId: string
  periodStart: string | null
  periodEnd: string | null
  totalAmount: number
  currency: string
  fundTransferStatus: string | null
  fundTransferDate: string | null
}

// List Closed settlement periods (financial event groups) in one window. Amazon
// rejects FinancialEventGroupStartedBefore/After pairs more than 180 days apart,
// so a longer history must be chunked — see listFinancialEventGroupsSince.
export const listFinancialEventGroups = async (
  accessToken: string,
  opts: { startedAfter?: string; startedBefore?: string; nextToken?: string; pageSize?: number } = {}
): Promise<{ groups: FinancialEventGroupRef[]; nextToken: string | null }> => {
  let path: string
  if (opts.nextToken) {
    const params = new URLSearchParams()
    params.set('MaxResultsPerPage', String(opts.pageSize ?? 100))
    params.set('NextToken', opts.nextToken)
    path = `/finances/v0/financialEventGroups?${params.toString()}`
  } else {
    const params = new URLSearchParams()
    params.set('MaxResultsPerPage', String(opts.pageSize ?? 100))
    if (opts.startedAfter) params.set('FinancialEventGroupStartedAfter', opts.startedAfter)
    if (opts.startedBefore) params.set('FinancialEventGroupStartedBefore', opts.startedBefore)
    path = `/finances/v0/financialEventGroups?${params.toString()}`
  }

  const json = await spApiGet(accessToken, path)
  const groups: FinancialEventGroupRef[] = (json.payload?.FinancialEventGroupList || [])
    .filter((g: any) => g.ProcessingStatus === 'Closed')
    .map((g: any) => ({
      groupId: g.FinancialEventGroupId,
      periodStart: g.FinancialEventGroupStart ?? null,
      periodEnd: g.FinancialEventGroupEnd ?? null,
      totalAmount: Number(g.OriginalTotal?.CurrencyAmount ?? 0),
      currency: g.OriginalTotal?.CurrencyCode || 'EUR',
      fundTransferStatus: g.FundTransferStatus ?? null,
      fundTransferDate: g.FundTransferDate ?? null
    }))
  return { groups, nextToken: json.payload?.NextToken ?? null }
}

// Fetch every Closed settlement period in [sinceISO, untilISO] (untilISO
// defaults to now), chunking into ≤170-day windows (Amazon rejects Before/After
// pairs >180 days apart) and de-duping by groupId across chunk boundaries.
// Capped at MAX_WINDOWS so a very old `since` can't cause unbounded latency — a
// window with no data in it is a cheap, ordinary empty response, not an error
// (verified against production).
export const listFinancialEventGroupsSince = async (
  accessToken: string,
  sinceISO: string,
  untilISO?: string
): Promise<FinancialEventGroupRef[]> => {
  const WINDOW_MS = 170 * 86400000
  const MAX_WINDOWS = 14
  const now = Date.now()
  const until = untilISO ? Math.min(new Date(untilISO).getTime(), now) : now
  const since = new Date(sinceISO).getTime()
  const byId = new Map<string, FinancialEventGroupRef>()

  let windowStart = since
  let windows = 0
  while (windowStart < until && windows < MAX_WINDOWS) {
    const windowEnd = Math.min(windowStart + WINDOW_MS, until)
    // Amazon requires "Before" to be at least ~2 minutes in the past; omit it
    // entirely only when the window's end is genuinely "now" — an explicit
    // past `until` always gets passed through so the query is actually bounded.
    const isFinalWindow = windowEnd >= now - 5 * 60000
    let nextToken: string | undefined
    do {
      const { groups, nextToken: next } = await listFinancialEventGroups(accessToken, {
        startedAfter: new Date(windowStart).toISOString(),
        startedBefore: isFinalWindow ? undefined : new Date(windowEnd).toISOString(),
        nextToken
      })
      for (const g of groups) byId.set(g.groupId, g)
      nextToken = next ?? undefined
    } while (nextToken)
    windowStart = windowEnd
    windows++
  }

  return [...byId.values()].sort((a, b) => (b.periodStart || '').localeCompare(a.periodStart || ''))
}

// Fetch every page of line-item financial events for one settlement period.
// Returns the raw per-page `FinancialEvents` payloads for flattenFinancialEvents
// to walk — kept as an array rather than deep-merged so a page-boundary bug
// can't silently corrupt data by merging the wrong keys together.
export const listFinancialEventsForGroup = async (
  accessToken: string,
  groupId: string,
  opts: { maxPages?: number } = {}
): Promise<any[]> => {
  const maxPages = opts.maxPages ?? 20
  const pages: any[] = []
  let nextToken: string | undefined
  let count = 0
  do {
    const params = new URLSearchParams()
    params.set('MaxResultsPerPage', '100')
    if (nextToken) params.set('NextToken', nextToken)
    const json = await spApiGet(
      accessToken,
      `/finances/v0/financialEventGroups/${encodeURIComponent(groupId)}/financialEvents?${params.toString()}`
    )
    pages.push(json.payload?.FinancialEvents || {})
    nextToken = json.payload?.NextToken || undefined
    count++
  } while (nextToken && count < maxPages)
  return pages
}

export type SettlementCategory =
  | 'umsaetze'
  | 'retouren'
  | 'amazonFees'
  | 'fbaFees'
  | 'werbekosten'
  | 'sonstiges'

export interface SettlementDetailRow {
  category: SettlementCategory
  transactionType: string
  amountType: string
  amountDescription: string
  amount: number
  orderId: string
  merchantOrderId: string
  sku: string
  qty: number | null
  postedDate: string
  marketplace: string
}

const currencyAmount = (obj: any): number => {
  const v = obj?.CurrencyAmount
  return typeof v === 'number' ? v : Number(v ?? 0) || 0
}

// Flattens the Finances API's ~30-shape event payload into the row shape the
// settlement grid consumes (mirrors the old flat-file row shape so the rest of
// the page — matching, KPI cards, AG Grid, Excel export — needed no changes).
//
// The 7 event types explicitly handled below cover 100% of real data seen
// across a 20-month / 38-period sample of this account's history (445
// ShipmentEventList entries dominate; AdjustmentEventList 30, ProductAds 12,
// ServiceFee 12, RefundEventList 8, DebtRecovery 2, AdhocDisbursement 1 — see
// reference/recipes.md). Categorization here is STRUCTURAL — decided by which
// list/field a row came from, not by re-parsing a description string — which
// is more robust than the old flat-file categorizer's keyword matching: that
// approach mis-bucketed a real "Shipping label purchase for return" cost as
// Umsätze (revenue) purely because its description happened to contain
// "shipping". The equivalent Finances API entry (AdjustmentType
// "ReturnPostageBilling_*") is explicitly routed to Retouren below instead.
//
// Zero-amount entries are skipped, matching how Amazon's own flat file already
// omits fee/charge types that don't apply to a given line item.
export const flattenFinancialEvents = (pages: any[]): SettlementDetailRow[] => {
  const rows: SettlementDetailRow[] = []

  const push = (r: SettlementDetailRow) => {
    if (!r.amount) return
    rows.push({ ...r, amount: Math.round(r.amount * 100) / 100 })
  }

  const KNOWN_KEYS = new Set([
    'ShipmentEventList', 'RefundEventList', 'ServiceFeeEventList', 'ProductAdsPaymentEventList',
    'AdjustmentEventList', 'DebtRecoveryEventList', 'AdhocDisbursementEventList', 'NextToken'
  ])

  for (const events of pages) {
    for (const ev of events.ShipmentEventList || []) {
      const orderId = ev.AmazonOrderId || ''
      const date = ev.PostedDate || ''
      const marketplace = ev.MarketplaceName || ''
      for (const item of ev.ShipmentItemList || []) {
        const sku = item.SellerSKU || ''
        const qty = item.QuantityShipped ?? null
        for (const c of item.ItemChargeList || []) {
          push({ category: 'umsaetze', transactionType: 'Shipment', amountType: 'ItemPrice', amountDescription: c.ChargeType || '', amount: currencyAmount(c.ChargeAmount), orderId, merchantOrderId: '', sku, qty, postedDate: date, marketplace })
        }
        for (const c of item.ItemFeeList || []) {
          const category: SettlementCategory = c.FeeType === 'ShippingHB' ? 'fbaFees' : 'amazonFees'
          push({ category, transactionType: 'Shipment', amountType: 'ItemFees', amountDescription: c.FeeType || '', amount: currencyAmount(c.FeeAmount), orderId, merchantOrderId: '', sku, qty, postedDate: date, marketplace })
        }
        for (const c of item.PromotionList || []) {
          push({ category: 'umsaetze', transactionType: 'Shipment', amountType: 'Promotion', amountDescription: c.PromotionType || 'Promotion', amount: currencyAmount(c.PromotionAmount), orderId, merchantOrderId: '', sku, qty, postedDate: date, marketplace })
        }
      }
      // Order/shipment-level (not per-item) charge/fee lists — not seen in this
      // account's sample data but part of Amazon's schema (e.g. multi-item
      // shipments can carry the shipping charge at this level instead).
      for (const c of ev.OrderChargeList || []) {
        push({ category: 'umsaetze', transactionType: 'Shipment', amountType: 'ItemPrice', amountDescription: c.ChargeType || '', amount: currencyAmount(c.ChargeAmount), orderId, merchantOrderId: '', sku: '', qty: null, postedDate: date, marketplace })
      }
      for (const c of ev.OrderFeeList || []) {
        const category: SettlementCategory = c.FeeType === 'ShippingHB' ? 'fbaFees' : 'amazonFees'
        push({ category, transactionType: 'Shipment', amountType: 'ItemFees', amountDescription: c.FeeType || '', amount: currencyAmount(c.FeeAmount), orderId, merchantOrderId: '', sku: '', qty: null, postedDate: date, marketplace })
      }
    }

    for (const ev of events.RefundEventList || []) {
      const orderId = ev.AmazonOrderId || ''
      const date = ev.PostedDate || ''
      const marketplace = ev.MarketplaceName || ''
      for (const item of ev.ShipmentItemAdjustmentList || []) {
        const sku = item.SellerSKU || ''
        const qty = item.QuantityShipped ?? null
        for (const c of item.ItemChargeAdjustmentList || []) {
          push({ category: 'retouren', transactionType: 'Refund', amountType: 'ItemPrice', amountDescription: c.ChargeType || '', amount: currencyAmount(c.ChargeAmount), orderId, merchantOrderId: '', sku, qty, postedDate: date, marketplace })
        }
        // Fee adjustments on a refund are Amazon FEES, not returns: the (positive)
        // reversal of the original Commission/FBA fee plus Amazon's retained
        // "RefundCommission". Routing them to the fee buckets — the same split
        // PayJoe uses (AMA-BG-… / AMA-SG-…-RFNDCMMS rows next to the bare refund
        // row) — keeps the Retouren row of an order equal to the refunded price,
        // i.e. the amount of the credit memo lexoffice has to match it against.
        for (const c of item.ItemFeeAdjustmentList || []) {
          const category: SettlementCategory = c.FeeType === 'ShippingHB' ? 'fbaFees' : 'amazonFees'
          push({ category, transactionType: 'Refund', amountType: 'ItemFees', amountDescription: c.FeeType || '', amount: currencyAmount(c.FeeAmount), orderId, merchantOrderId: '', sku, qty, postedDate: date, marketplace })
        }
        for (const c of item.PromotionAdjustmentList || []) {
          push({ category: 'retouren', transactionType: 'Refund', amountType: 'Promotion', amountDescription: c.PromotionType || 'Promotion', amount: currencyAmount(c.PromotionAmount), orderId, merchantOrderId: '', sku, qty, postedDate: date, marketplace })
        }
      }
    }

    for (const ev of events.ServiceFeeEventList || []) {
      const date = ev.PostedDate || ''
      for (const c of ev.FeeList || []) {
        push({ category: 'amazonFees', transactionType: 'ServiceFee', amountType: 'ItemFees', amountDescription: c.FeeType || ev.FeeReason || 'ServiceFee', amount: currencyAmount(c.FeeAmount), orderId: ev.AmazonOrderId || '', merchantOrderId: '', sku: '', qty: null, postedDate: date, marketplace: '' })
      }
    }

    // Note the lowercase-first field names here — unlike every other event type
    // above, ProductAdsPaymentEventList entries use `postedDate`/`transactionType`,
    // not `PostedDate`/`TransactionType` (verified against production). Only
    // `transactionValue` is used: it already equals baseValue + taxValue, so
    // summing multiple fields here would double-count ad spend (verified — two
    // real settlement periods only reconciled to the cent once this was fixed).
    for (const ev of events.ProductAdsPaymentEventList || []) {
      const date = ev.postedDate || ev.PostedDate || ''
      const value = ev.transactionValue ?? ev.TransactionValue
      push({ category: 'werbekosten', transactionType: 'ServiceFee', amountType: 'Advertising', amountDescription: ev.transactionType || ev.TransactionType || 'ProductAds', amount: currencyAmount(value), orderId: '', merchantOrderId: ev.invoiceId || ev.InvoiceId || '', sku: '', qty: null, postedDate: date, marketplace: '' })
    }

    for (const ev of events.AdjustmentEventList || []) {
      const type = ev.AdjustmentType || 'Adjustment'
      // Return-shipping-label costs belong in Retouren — this is the one case
      // verified against real data where the OLD flat-file categorizer got it
      // wrong (see the file-level comment above). Everything else here is
      // genuinely mixed (reserve credit/debit movements, an unclassifiable
      // "Other") and stays in Sonstiges for manual review, same as the old
      // catch-all philosophy — never silently dropped.
      const category: SettlementCategory = type.startsWith('ReturnPostageBilling') ? 'retouren' : 'sonstiges'
      push({ category, transactionType: 'Adjustment', amountType: type, amountDescription: type, amount: currencyAmount(ev.AdjustmentAmount), orderId: '', merchantOrderId: '', sku: '', qty: null, postedDate: ev.PostedDate || '', marketplace: '' })
    }

    for (const ev of events.DebtRecoveryEventList || []) {
      // Deliberately uses ONLY the top-level RecoveryAmount. DebtRecoveryItemList
      // and ChargeInstrumentList re-express the same amount from other angles
      // (per-item breakdown / how it was collected) — summing those too would
      // double-count, the same class of bug fixed for ProductAdsPaymentEventList
      // above. Rare (2 occurrences across 20 months) — kept in Sonstiges for review.
      push({ category: 'sonstiges', transactionType: 'DebtRecovery', amountType: ev.DebtRecoveryType || 'DebtRecovery', amountDescription: ev.DebtRecoveryType || 'DebtRecovery', amount: currencyAmount(ev.RecoveryAmount), orderId: '', merchantOrderId: '', sku: '', qty: null, postedDate: ev.PostedDate || '', marketplace: '' })
    }

    for (const ev of events.AdhocDisbursementEventList || []) {
      push({ category: 'sonstiges', transactionType: 'AdhocDisbursement', amountType: ev.TransactionType || 'AdhocDisbursement', amountDescription: ev.TransactionType || 'AdhocDisbursement', amount: currencyAmount(ev.TransactionAmount), orderId: '', merchantOrderId: '', sku: '', qty: null, postedDate: ev.PostedDate || '', marketplace: '' })
    }

    // Generic fallback for the ~27 other Amazon event types never observed in
    // this account's real history — a different order source (FBA-liquidation-
    // or chargeback-heavy, say) may hit them. Best-effort: take the FIRST
    // top-level {CurrencyCode,CurrencyAmount} field on each entry (never sums
    // multiple fields — see the double-counting notes above) and always land it
    // in Sonstiges for manual review rather than silently dropping it.
    for (const key of Object.keys(events)) {
      if (KNOWN_KEYS.has(key)) continue
      const list = events[key]
      if (!Array.isArray(list)) continue
      for (const ev of list) {
        const amountField = Object.entries(ev).find(
          ([, v]: [string, any]) => v && typeof v === 'object' && typeof (v as any).CurrencyAmount !== 'undefined'
        ) as [string, any] | undefined
        if (!amountField) continue
        push({
          category: 'sonstiges',
          transactionType: key,
          amountType: amountField[0],
          amountDescription: `${key}: ${amountField[0]}`,
          amount: currencyAmount(amountField[1]),
          orderId: ev.AmazonOrderId || '',
          merchantOrderId: '',
          sku: '',
          qty: null,
          postedDate: ev.PostedDate || ev.postedDate || '',
          marketplace: ev.MarketplaceName || ''
        })
      }
    }
  }

  return rows
}

// ---------------------------------------------------------------------------
// Orders API (v0) — used by the Amazon order-reconciliation ("Bestellabgleich")
// page to list ALL orders in a date window and compare them to iDempiere.
// ---------------------------------------------------------------------------

const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))

export interface AmazonOrderRef {
  amazonOrderId: string
  purchaseDate: string | null
  lastUpdateDate: string | null
  orderStatus: string
  orderTotalAmount: number | null
  currency: string | null
  salesChannel: string | null
  orderType: string | null
  fulfillmentChannel: string | null
  numberOfItemsShipped: number | null
  numberOfItemsUnshipped: number | null
  marketplaceId: string | null
}

// Fetch EVERY Amazon order created in [createdAfter, createdBefore) for a
// marketplace, following NextToken pagination. Intentionally passes NO
// OrderStatuses filter so all statuses (Pending, Shipped, Canceled, …) are
// returned — the page filters by date only. Robust to 429 throttling with
// exponential backoff; caps the page count so a runaway never loops forever and
// surfaces `truncated` when the cap is hit.
export const listAllOrders = async (
  accessToken: string,
  marketplaceId: string,
  opts: { createdAfter: string; createdBefore?: string; maxPages?: number; pageDelayMs?: number; budgetMs?: number }
): Promise<{ orders: AmazonOrderRef[]; truncated: boolean; throttled: boolean; pages: number }> => {
  const maxPages = opts.maxPages ?? 200
  const pageDelayMs = opts.pageDelayMs ?? 600
  // getOrders is rate-limited to ~1 req/60s (burst 20). A high-volume year can
  // never page fully inside the HTTP proxy timeout, so cap wall-clock and return
  // a partial result + truncated flag (the page warns and suggests month mode).
  const budgetMs = opts.budgetMs ?? 45000
  const startedAt = Date.now()
  const orders: AmazonOrderRef[] = []
  let nextToken: string | null = null
  let pages = 0
  let truncated = false
  let throttled = false

  // Returns the page JSON, or null when pagination must stop early but we already
  // hold a usable partial result (terminal throttle, or a transient error after
  // at least one successful page). Throws only when nothing was collected yet, so
  // a real error (e.g. missing Orders role) is still surfaced verbatim.
  const fetchPage = async (): Promise<any> => {
    const MAX_RETRY = 6
    let attempt = 0
    while (true) {
      const params = new URLSearchParams()
      params.set('MarketplaceIds', marketplaceId)
      if (nextToken) {
        params.set('NextToken', nextToken)
      } else {
        params.set('CreatedAfter', opts.createdAfter)
        if (opts.createdBefore) params.set('CreatedBefore', opts.createdBefore)
        params.set('MaxResultsPerPage', '100')
      }
      try {
        return await spApiGet(accessToken, `/orders/v0/orders?${params.toString()}`)
      } catch (e: any) {
        // 429 = throttled; back off and retry within the retry + wall-clock budget.
        if (e?.status === 429 && attempt < MAX_RETRY) {
          throttled = true
          const wait = Math.min(2000 * Math.pow(2, attempt), 30000)
          // If the backoff sleep would blow the wall-clock budget and we already
          // hold data, stop now and return the partial — don't risk a proxy timeout.
          if (orders.length > 0 && (Date.now() - startedAt) + wait > budgetMs) return null
          await sleep(wait)
          attempt++
          continue
        }
        // Terminal: 429 retries exhausted, or a non-429 error. Don't discard the
        // orders already collected — stop gracefully with a partial result.
        if (orders.length > 0) {
          if (e?.status === 429) throttled = true
          return null
        }
        throw e
      }
    }
  }

  do {
    const json = await fetchPage()
    if (json === null) { truncated = true; break } // partial result — stop early
    const payload = json?.payload || json || {}
    for (const o of (payload.Orders || [])) {
      orders.push({
        amazonOrderId: o.AmazonOrderId || '',
        purchaseDate: o.PurchaseDate || null,
        lastUpdateDate: o.LastUpdateDate || null,
        orderStatus: o.OrderStatus || '',
        orderTotalAmount: o.OrderTotal?.Amount != null ? parseAmazonAmount(o.OrderTotal.Amount) : null,
        currency: o.OrderTotal?.CurrencyCode || null,
        salesChannel: o.SalesChannel || null,
        orderType: o.OrderType || null,
        fulfillmentChannel: o.FulfillmentChannel || null,
        numberOfItemsShipped: o.NumberOfItemsShipped ?? null,
        numberOfItemsUnshipped: o.NumberOfItemsUnshipped ?? null,
        marketplaceId: o.MarketplaceId || null
      })
    }
    nextToken = payload.NextToken || null
    pages++
    if (nextToken && pages >= maxPages) { truncated = true; break }
    if (nextToken) await sleep(pageDelayMs) // stay gentle on the getOrders rate limit
  } while (nextToken)

  return { orders, truncated, throttled, pages }
}
