/**
 * 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
}

// Every raw call into Amazon (LWA token exchange, SP-API, report document
// download) is tagged with `isAmazonError` on failure so route handlers can
// tell an Amazon-side problem apart from an iDempiere auth failure — see
// refreshTokenHelper.ts's `isRetryableAuthFailure`, which only ever looks at
// `event.context.lastIdempiereError`. Without this tag, an Amazon timeout/429
// falls through to that helper's "no fetch error recorded -> plain refresh"
// default and the ENTIRE route handler (report poll, or a full price push)
// silently re-runs a second time, which is what made this page occasionally
// hang for 90s+ instead of failing cleanly at ~45s.
const tagAmazonError = (err: any): any => {
  err.isAmazonError = true
  return err
}

// No SP-API/LWA call in this file had a timeout before — a stalled request
// hung indefinitely. 20s is short enough to fail fast and let the callers'
// own retry/poll loops (pollReport's 45s cap, the price-push poll) take over.
const SP_API_TIMEOUT_MS = 20000
const SP_API_MAX_RETRIES = 2

// Get LWA access token using a refresh token.
export const getAmazonAccessToken = async (
  clientId: string,
  clientSecret: string,
  refreshToken: string
): Promise<string> => {
  let response: Response
  try {
    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
      }),
      signal: AbortSignal.timeout(SP_API_TIMEOUT_MS)
    })
  } catch (e: any) {
    throw tagAmazonError(new Error(`Failed to reach Amazon LWA token endpoint: ${e?.name === 'TimeoutError' ? 'timed out' : (e?.message || String(e))}`))
  }

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

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

// Shared low-level SP-API request: timeout, retry-after-aware backoff on
// 429/503 (mirrors amazonAds/adsApi.ts's adsFetch, which already does this
// for the Ads API), and consistent error tagging/shape for every caller
// (spApiGet/spApiWrite below, so Reports/Listings/Fees/Finances/Orders all
// get this for free).
const spApiRequest = async (accessToken: string, method: 'GET' | 'POST' | 'PATCH', path: string, body?: any): Promise<any> => {
  for (let attempt = 0; ; attempt++) {
    let response: Response
    try {
      response = await fetch(`${SP_API_BASE_URL}${path}`, {
        method,
        headers: {
          'x-amz-access-token': accessToken,
          'Content-Type': 'application/json'
        },
        body: body !== undefined ? JSON.stringify(body) : undefined,
        signal: AbortSignal.timeout(SP_API_TIMEOUT_MS)
      })
    } catch (e: any) {
      if (attempt < SP_API_MAX_RETRIES) { await sleep(1000 * (attempt + 1)); continue }
      throw tagAmazonError(new Error(`SP-API request failed: ${e?.name === 'TimeoutError' ? 'timed out' : (e?.message || String(e))}`))
    }

    if ((response.status === 429 || response.status === 503) && attempt < SP_API_MAX_RETRIES) {
      const retryAfter = Number(response.headers.get('retry-after'))
      const delayMs = Math.min((Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : (attempt + 1) * 2) * 1000, 15000)
      await sleep(delayMs)
      continue
    }

    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 = tagAmazonError(new Error(`SP-API ${response.status}: ${msg}`))
      err.status = response.status
      err.spapi = json
      throw err
    }
    return json
  }
}

// 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> => spApiRequest(accessToken, 'GET', path)

// 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 }
}

// ---------------------------------------------------------------------------
// Shared order-source -> Amazon connection resolution. Used by every SIS
// route (listings, offers, price-update) to avoid repeating the credential +
// marketplace-id lookup boilerplate three times.
// ---------------------------------------------------------------------------

export interface AmazonOrderSourceConnection {
  orgId: number
  sellerId: string
  marketplaceId: string
  accessToken: string
}

// Resolves one c_ordersource's Amazon credentials into a ready-to-use access
// token + marketplaceId + sellerId. Throws a plain Error with a clear message
// (never a raw SP-API error) when the order source is missing required setup —
// callers surface `.message` directly via the route's normal error response.
export async function resolveOrderSourceAmazonConnection(
  event: any,
  token: string,
  orderSourceId: number
): Promise<AmazonOrderSourceConnection> {
  const orderSource: any = await fetchHelperForAmazon(event, `models/c_ordersource/${orderSourceId}`, token)
  if (!orderSource) {
    const err: any = new Error('Order source not found')
    err.status = 404
    throw err
  }

  const orgId = orderSource?.AD_Org_ID?.id ?? null
  const clientId = orderSource.marketplace_key
  const clientSecret = orderSource.marketplace_secret
  const refreshToken = orderSource.marketplace_token
  const sellerId = orderSource.amazon_merchant_id || orderSource.Amazon_Merchant_ID || ''

  const configError = (msg: string) => {
    const err: any = new Error(msg)
    err.status = 400
    return err
  }

  if (!clientId || !clientSecret || !refreshToken) {
    throw configError('Missing Amazon SP-API credentials on this order source (Client ID / Secret / Refresh Token).')
  }
  if (!sellerId) throw configError('Missing Amazon Merchant ID on this order source.')
  if (!orgId) throw configError('Order source has no organization set.')

  const marketplaceId = resolveMarketplaceId(orderSource?.Marketplace?.identifier, orderSource?.Description)
  if (!marketplaceId) throw configError('Could not resolve the Amazon marketplace for this order source.')

  const accessToken = await getAmazonAccessToken(clientId, clientSecret, refreshToken)
  return { orgId, sellerId, marketplaceId, accessToken }
}

// Thin wrapper so this file doesn't need to import fetchHelper directly (it
// has no other iDempiere dependency) — kept local/unexported.
async function fetchHelperForAmazon(event: any, path: string, token: string): Promise<any> {
  return event.context.fetch(path, 'GET', token, null)
}

// ---------------------------------------------------------------------------
// Generic SP-API write helpers (POST/PATCH) — same error normalization as
// spApiGet. Added for the SIS (Sales Information System) pricing-control page:
// Reports API report creation, Listings Items PATCH, Fees Estimate POST.
// ---------------------------------------------------------------------------

const spApiWrite = async (accessToken: string, method: 'POST' | 'PATCH', path: string, body?: any): Promise<any> => spApiRequest(accessToken, method, path, body)

export const spApiPost = (accessToken: string, path: string, body?: any): Promise<any> => spApiWrite(accessToken, 'POST', path, body)
export const spApiPatch = (accessToken: string, path: string, body?: any): Promise<any> => spApiWrite(accessToken, 'PATCH', path, body)

// Runs `fn` over `items` one at a time with a fixed delay between calls
// (never in parallel) — SP-API's Pricing/Fees/Listings operations are tightly
// rate-limited (~1 req/sec) and have no bulk equivalent, so a page of a few
// dozen SKUs needs pacing, not a burst. Never aborts the batch on one item's
// failure; a single 429 gets one backed-off retry before being recorded as a
// per-item error (mirrors the retry shape in listAllOrders above, simplified
// since these are single calls, not paginated).
export const throttledBatch = async <T, R>(
  items: T[],
  fn: (item: T) => Promise<R>,
  delayMs: number = 1100
): Promise<{ item: T; result: R | null; error: string | null }[]> => {
  const out: { item: T; result: R | null; error: string | null }[] = []
  for (let i = 0; i < items.length; i++) {
    try {
      out.push({ item: items[i], result: await fn(items[i]), error: null })
    } catch (e: any) {
      if (e?.status === 429) {
        await sleep(3000)
        try {
          out.push({ item: items[i], result: await fn(items[i]), error: null })
          if (i < items.length - 1) await sleep(delayMs)
          continue
        } catch (e2: any) {
          out.push({ item: items[i], result: null, error: e2?.message || String(e2) })
          if (i < items.length - 1) await sleep(delayMs)
          continue
        }
      }
      out.push({ item: items[i], result: null, error: e?.message || String(e) })
    }
    if (i < items.length - 1) await sleep(delayMs)
  }
  return out
}

// Serializes SIS price pushes per order source (seller) so the frontend's
// deliberately non-blocking per-field push (see index.vue pushPriceDirect —
// the user can tab to the next row before the previous push resolves) can
// never fire genuinely concurrent Listings Items calls at the same seller.
// Each push's Amazon calls run to completion, then the next queued push
// waits at least PRICE_PUSH_MIN_SPACING_MS before starting — the same ~1
// req/sec pacing throttledBatch above already assumes Listings operations
// need. Per-process Map, same convention as listingsReportCache/
// recentPushedPrices below.
const priceQueueTail = new Map<number, Promise<void>>()
const PRICE_PUSH_MIN_SPACING_MS = 1100

export const withPricePushQueue = async <T>(orderSourceId: number, fn: () => Promise<T>): Promise<T> => {
  const prev = priceQueueTail.get(orderSourceId) || Promise.resolve()
  let releaseNext: () => void = () => {}
  const gate = new Promise<void>((resolve) => { releaseNext = resolve })
  priceQueueTail.set(orderSourceId, prev.then(() => gate))
  await prev
  try {
    return await fn()
  } finally {
    setTimeout(releaseNext, PRICE_PUSH_MIN_SPACING_MS)
  }
}

// ---------------------------------------------------------------------------
// Reports API (2021-06-30) — bulk merchant listings. This is the primary
// source of "what's actually live on Amazon" for SIS: one report gives
// seller-sku, asin, price, quantity, fulfillment channel and status for the
// ENTIRE catalog, instead of guessing from the local catalog or looping
// Listings Items GET per SKU (which also requires already knowing the SKU —
// this report is how we discover it in the first place).
// ---------------------------------------------------------------------------

export interface MerchantListingRow {
  sellerSku: string
  asin: string
  itemName: string
  price: number | null
  quantity: number | null
  fulfillmentChannel: string
  status: string
  openDate: string | null
}

const REPORT_POLL_INTERVAL_MS = 4000
// A GET_MERCHANT_LISTINGS_ALL_DATA report normally finishes in well under a
// minute. Kept under nginx's 60s proxy timeout (see the "iDempiere REST load
// rules" recipe) so a slow report fails cleanly with a message the frontend
// can show ("still generating, try again shortly") instead of trailing off
// into an opaque 502/504 — the next request re-polls the same reportId's
// result via a fresh report (report creation is cheap; Amazon dedupes/queues
// close-together requests for the same report type on its side).
const REPORT_POLL_MAX_MS = 45000

export const createReport = async (accessToken: string, reportType: string, marketplaceIds: string[]): Promise<string> => {
  const json = await spApiPost(accessToken, '/reports/2021-06-30/reports', { reportType, marketplaceIds })
  return json.reportId
}

export const pollReport = async (
  accessToken: string,
  reportId: string
): Promise<{ status: string; reportDocumentId: string | null }> => {
  const startedAt = Date.now()
  while (Date.now() - startedAt < REPORT_POLL_MAX_MS) {
    const json = await spApiGet(accessToken, `/reports/2021-06-30/reports/${encodeURIComponent(reportId)}`)
    const status = json.processingStatus
    if (status === 'DONE') return { status, reportDocumentId: json.reportDocumentId ?? null }
    if (status === 'CANCELLED' || status === 'FATAL') return { status, reportDocumentId: null }
    await sleep(REPORT_POLL_INTERVAL_MS)
  }
  return { status: 'TIMEOUT', reportDocumentId: null }
}

export const downloadReportDocument = async (accessToken: string, reportDocumentId: string): Promise<string> => {
  const meta = await spApiGet(accessToken, `/reports/2021-06-30/documents/${encodeURIComponent(reportDocumentId)}`)
  const url = meta.url
  if (!url) throw tagAmazonError(new Error('Report document has no download URL'))
  let response: Response
  try {
    response = await fetch(url, { signal: AbortSignal.timeout(30000) })
  } catch (e: any) {
    throw tagAmazonError(new Error(`Failed to download report document: ${e?.name === 'TimeoutError' ? 'timed out' : (e?.message || String(e))}`))
  }
  if (!response.ok) throw tagAmazonError(new Error(`Failed to download report document (${response.status})`))
  const buffer = Buffer.from(await response.arrayBuffer())
  if (meta.compressionAlgorithm === 'GZIP') {
    const zlib = await import('node:zlib')
    return zlib.gunzipSync(buffer).toString('utf-8')
  }
  return buffer.toString('utf-8')
}

// Parses the GET_MERCHANT_LISTINGS_ALL_DATA tab-separated report. Column
// order isn't guaranteed stable across accounts/marketplaces, so this reads
// the header row rather than assuming fixed positions. Rows without a
// seller-sku are skipped (not a real listing).
export const parseMerchantListingsReport = (tsv: string): MerchantListingRow[] => {
  const lines = tsv.split(/\r?\n/).filter(l => l.length > 0)
  if (lines.length < 2) return []
  const headers = lines[0].split('\t')
  const idx = (name: string) => headers.indexOf(name)
  const iSku = idx('seller-sku')
  const iAsin = idx('asin1')
  const iName = idx('item-name')
  const iPrice = idx('price')
  const iQty = idx('quantity')
  const iChannel = idx('fulfillment-channel')
  const iStatus = idx('status')
  const iOpenDate = idx('open-date')

  const rows: MerchantListingRow[] = []
  for (let i = 1; i < lines.length; i++) {
    const cols = lines[i].split('\t')
    const sellerSku = (iSku >= 0 ? cols[iSku] : '') || ''
    if (!sellerSku) continue
    rows.push({
      sellerSku,
      asin: (iAsin >= 0 ? cols[iAsin] : '') || '',
      itemName: (iName >= 0 ? cols[iName] : '') || '',
      price: iPrice >= 0 && cols[iPrice] ? parseAmazonAmount(cols[iPrice]) : null,
      quantity: iQty >= 0 && cols[iQty] ? Number(cols[iQty]) : null,
      fulfillmentChannel: (iChannel >= 0 ? cols[iChannel] : '') || 'DEFAULT',
      status: (iStatus >= 0 ? cols[iStatus] : '') || '',
      openDate: (iOpenDate >= 0 ? cols[iOpenDate] : null) || null
    })
  }
  return rows
}

// Per-order-source cache of the parsed listings report — report generation is
// slow (seconds to a couple minutes) and there is no reason to regenerate it
// on every page view. 30 min TTL; a failed regeneration falls back to serving
// the stale cache rather than breaking the page.
const LISTINGS_REPORT_TTL_MS = 30 * 60 * 1000
const listingsReportCache = new Map<number, { rows: MerchantListingRow[]; at: number }>()

// Overlay of just-pushed prices, keyed by "orderSourceId:sku" — survives a
// full report REGENERATION, not just cache reads. Verified against real
// production data: Amazon's bulk GET_MERCHANT_LISTINGS_ALL_DATA report lags
// well behind a confirmed Listings Items price push (still showed the old
// price on a forced "Refresh from Amazon" more than 40s after a push that
// getListingsItem had already confirmed) — so a fresh report can otherwise
// silently REVERT the just-pushed price in the UI. Entries expire after
// RECENT_PUSH_TTL_MS on the assumption Amazon's own report has caught up by
// then; until it does, the overlay wins over whatever the report says.
const RECENT_PUSH_TTL_MS = 20 * 60 * 1000
const recentPushedPrices = new Map<string, { price: number; at: number }>()
const recentPushKey = (orderSourceId: number, sellerSku: string) => `${orderSourceId}:${sellerSku}`

const applyRecentPushOverlay = (orderSourceId: number, rows: MerchantListingRow[]): MerchantListingRow[] => {
  if (recentPushedPrices.size === 0) return rows
  const now = Date.now()
  return rows.map(row => {
    const key = recentPushKey(orderSourceId, row.sellerSku)
    const pushed = recentPushedPrices.get(key)
    if (!pushed) return row
    if (now - pushed.at > RECENT_PUSH_TTL_MS) {
      recentPushedPrices.delete(key)
      return row
    }
    return { ...row, price: pushed.price }
  })
}

export const getMerchantListingsReport = async (
  accessToken: string,
  orderSourceId: number,
  marketplaceId: string,
  opts: { forceRefresh?: boolean } = {}
): Promise<{ rows: MerchantListingRow[]; fromCache: boolean; generatedAt: number }> => {
  const cached = listingsReportCache.get(orderSourceId)
  if (!opts.forceRefresh && cached && Date.now() - cached.at < LISTINGS_REPORT_TTL_MS) {
    return { rows: applyRecentPushOverlay(orderSourceId, cached.rows), fromCache: true, generatedAt: cached.at }
  }

  try {
    const reportId = await createReport(accessToken, 'GET_MERCHANT_LISTINGS_ALL_DATA', [marketplaceId])
    const { status, reportDocumentId } = await pollReport(accessToken, reportId)
    if (status !== 'DONE' || !reportDocumentId) {
      throw tagAmazonError(new Error(`Amazon listings report did not complete (status: ${status})`))
    }
    const tsv = await downloadReportDocument(accessToken, reportDocumentId)
    const rows = parseMerchantListingsReport(tsv)
    const at = Date.now()
    listingsReportCache.set(orderSourceId, { rows, at })
    return { rows: applyRecentPushOverlay(orderSourceId, rows), fromCache: false, generatedAt: at }
  } catch (err) {
    if (cached) return { rows: applyRecentPushOverlay(orderSourceId, cached.rows), fromCache: true, generatedAt: cached.at }
    throw err
  }
}

// Records a just-pushed price so it survives both a cached read AND a full
// report regeneration until Amazon's own bulk report catches up (see the
// overlay note above). Call this right after patchListingsItemPrice
// succeeds. Also patches the cached report row in place (cheap, keeps the
// two sources of truth consistent even before the overlay would kick in).
export const updateCachedListingPrice = (orderSourceId: number, sellerSku: string, newPrice: number): void => {
  recentPushedPrices.set(recentPushKey(orderSourceId, sellerSku), { price: newPrice, at: Date.now() })
  const cached = listingsReportCache.get(orderSourceId)
  if (!cached) return
  const row = cached.rows.find(r => r.sellerSku === sellerSku)
  if (row) row.price = newPrice
}

// ---------------------------------------------------------------------------
// Listings Items API (2021-08-01) — one-SKU read + price patch. Used by SIS
// to confirm the live price/quantity for a single row and to push a new
// price. Chosen over the Feeds API for the push action: single-item,
// admin-triggered, no feed-document upload/polling dance to set up.
//
// NOT actually synchronous, though — verified against real production data
// (server/api/sales/sis/price-update.post.ts): the PATCH returns `status:
// "ACCEPTED"` with a `submissionId`, the same async-submission shape as the
// Feeds API, just normally fast. A GET immediately after can still return
// the pre-patch price. Callers that need the applied value should poll
// getListingsItem briefly rather than trusting one immediate read.
// ---------------------------------------------------------------------------

export interface ListingsItemOffer {
  price: number | null
  currency: string | null
  quantity: number | null
  fulfillmentChannel: string
  status: string
  productType: string | null
}

export const getListingsItem = async (
  accessToken: string,
  sellerId: string,
  sku: string,
  marketplaceId: string
): Promise<ListingsItemOffer | null> => {
  const params = new URLSearchParams()
  params.set('marketplaceIds', marketplaceId)
  params.set('includedData', 'summaries,offers,fulfillmentAvailability')
  const json = await spApiGet(
    accessToken,
    `/listings/2021-08-01/items/${encodeURIComponent(sellerId)}/${encodeURIComponent(sku)}?${params.toString()}`
  )
  const offer = (json.offers || [])[0]
  const avail = (json.fulfillmentAvailability || [])[0]
  const summary = (json.summaries || [])[0]
  return {
    price: offer?.price?.amount != null ? Number(offer.price.amount) : null,
    currency: offer?.price?.currency ?? null,
    quantity: avail?.quantity ?? null,
    fulfillmentChannel: avail?.fulfillmentChannelCode || 'DEFAULT',
    status: summary?.status || '',
    productType: summary?.productType || null
  }
}

// Pushes a new "our price" for one SKU. `productType` is per-listing (part
// of Amazon's category taxonomy) and required on every Listings Items PATCH —
// resolved here from the item's own summary rather than hardcoded/guessed,
// which would make every push fail with a 400.
export const patchListingsItemPrice = async (
  accessToken: string,
  sellerId: string,
  sku: string,
  marketplaceId: string,
  newPrice: number,
  currency: string = 'EUR',
  // Callers that already fetched the listing (e.g. price-update.post.ts's
  // pre-push `before` read) can pass it here to skip a second, redundant GET
  // of the same SKU just to read productType.
  preloadedItem: ListingsItemOffer | null = null
): Promise<any> => {
  const current = preloadedItem?.productType ? preloadedItem : await getListingsItem(accessToken, sellerId, sku, marketplaceId)
  if (!current?.productType) {
    const err: any = tagAmazonError(new Error(`Could not resolve productType for SKU "${sku}" — item may not be listed in this marketplace`))
    err.status = 404
    throw err
  }

  const params = new URLSearchParams()
  params.set('marketplaceIds', marketplaceId)
  return spApiPatch(
    accessToken,
    `/listings/2021-08-01/items/${encodeURIComponent(sellerId)}/${encodeURIComponent(sku)}?${params.toString()}`,
    {
      productType: current.productType,
      patches: [
        {
          op: 'replace',
          path: '/attributes/purchasable_offer',
          value: [
            {
              marketplace_id: marketplaceId,
              currency,
              our_price: [{ schedule: [{ value_with_tax: newPrice }] }]
            }
          ]
        }
      ]
    }
  )
}

// ---------------------------------------------------------------------------
// Product Pricing API (v0) — Buy Box / competitive offers for one ASIN. No
// bulk equivalent exists, so this stays a per-ASIN call — SIS throttles it
// via throttledBatch and caches the result.
// ---------------------------------------------------------------------------

export interface ItemOffersSummary {
  buyBoxWinner: boolean | null
  buyBoxPrice: number | null
  buyBoxCurrency: string | null
  competitorCount: number
  lowestOtherPrice: number | null
}

export const getItemOffers = async (
  accessToken: string,
  asin: string,
  marketplaceId: string,
  itemCondition: string = 'New'
): Promise<ItemOffersSummary | null> => {
  const params = new URLSearchParams()
  params.set('MarketplaceId', marketplaceId)
  params.set('ItemCondition', itemCondition)
  const json = await spApiGet(accessToken, `/products/pricing/v0/items/${encodeURIComponent(asin)}/offers?${params.toString()}`)
  const payload = json.payload
  if (!payload) return null

  const offers: any[] = payload.Offers || []
  const myOffer = offers.find((o: any) => o.MyOffer === true)
  const buyBoxEntry = (payload.Summary?.BuyBoxPrices || [])[0]
  const otherPrices = offers
    .filter((o: any) => o.MyOffer !== true)
    .map((o: any) => currencyAmount(o.ListingPrice) + currencyAmount(o.Shipping || {}))

  return {
    buyBoxWinner: myOffer ? myOffer.IsBuyBoxWinner === true : null,
    buyBoxPrice: buyBoxEntry ? currencyAmount(buyBoxEntry.ListingPrice) + currencyAmount(buyBoxEntry.Shipping || {}) : null,
    buyBoxCurrency: buyBoxEntry?.ListingPrice?.CurrencyCode ?? null,
    competitorCount: Math.max(0, offers.length - (myOffer ? 1 : 0)),
    lowestOtherPrice: otherPrices.length ? Math.min(...otherPrices) : null
  }
}

// ---------------------------------------------------------------------------
// Product Fees API (v0) — estimated referral/fulfillment fees for one SKU at
// a given price. No bulk equivalent; per-SKU call like Buy Box above.
// ---------------------------------------------------------------------------

export interface FeesEstimateResult {
  totalFees: number | null
  currency: string | null
  breakdown: { type: string; amount: number }[]
}

export const getFeesEstimate = async (
  accessToken: string,
  sku: string,
  marketplaceId: string,
  price: number,
  isAmazonFulfilled: boolean,
  currency: string = 'EUR'
): Promise<FeesEstimateResult | null> => {
  const json = await spApiPost(accessToken, `/products/fees/v0/listings/${encodeURIComponent(sku)}/feesEstimate`, {
    FeesEstimateRequest: {
      MarketplaceId: marketplaceId,
      IsAmazonFulfilled: isAmazonFulfilled,
      PriceToEstimateFees: {
        ListingPrice: { CurrencyCode: currency, Amount: price }
      },
      Identifier: `sis-${sku}-${Date.now()}`
    }
  })
  const feesResult = json?.payload?.FeesEstimateResult?.FeesEstimate
  if (!feesResult) return null
  return {
    totalFees: feesResult.TotalFeesEstimate ? currencyAmount(feesResult.TotalFeesEstimate) : null,
    currency: feesResult.TotalFeesEstimate?.CurrencyCode ?? null,
    breakdown: (feesResult.FeeDetailList || []).map((f: any) => ({
      type: f.FeeType || '',
      amount: currencyAmount(f.FeeAmount)
    }))
  }
}
