/**
 * eBay listings adapter — Sell Inventory API (REST) + Trading API fallback for legacy listings.
 *
 * Credentials (same shape the Laravel EbayTokenRepository uses): c_ordersource.marketplace_token =
 * JSON { access_token, refresh_token, expires_in, obtained_at, … }, marketplace_key / _secret = the
 * app's client id / secret (needed to refresh), ebay_environment = production | sandbox. A refreshed
 * token is written back to the order source so Laravel and the ERP share it.
 *
 * eBay structure: an INVENTORY ITEM is keyed by SKU; variations of one listing are an INVENTORY ITEM
 * GROUP (each variation = own SKU); an OFFER (offerId) ties one SKU to one marketplace with price,
 * quantity and policies; publishing an offer creates the LISTING (listingId = the public item id,
 * shared by all variation offers of a group).
 *   ListingKey          offerId                       (legacy items: "item:<ItemID>" or "item:<ItemID>:<SKU>")
 *   ExternalProductId   listingId / ItemID            ExternalInventoryId  SKU
 *   MetaJson            { marketplaceId, listingId, groupKey, variantTitle, imageUrl, legacy, storefrontUrl, adminUrl }
 *   import  all inventory items → their offers (one row per offer) + GetMyeBaySelling for listings
 *           that were never migrated to the Inventory API (read + price/qty via Trading API)
 *   price/qty  bulkUpdatePriceQuantity (offers) · ReviseInventoryStatus (legacy)
 *   delist     withdrawOffer / EndFixedPriceItem · relist publishOffer / RelistFixedPriceItem (new ItemID)
 *   create     createOrReplaceInventoryItem + createOffer (first fulfillment/payment/return policy,
 *              first inventory location, category suggestion for the title) + publishOffer
 * eBay has no per-second price throttle like Amazon → the adapter is `immediate`: routes flush right away.
 */
import { XMLParser, XMLBuilder } from 'fast-xml-parser'
import type { AdapterCtx, ListingAdapter, ImportResult, PushItem, PushResult, CompetitionResult, RemoteListing } from './types'

interface EbayConn { apiUrl: string; accessToken: string; marketplaceId: string; siteId: number; currency: string; storefront: string }

const SCOPES = 'https://api.ebay.com/oauth/api_scope https://api.ebay.com/oauth/api_scope/sell.inventory https://api.ebay.com/oauth/api_scope/sell.inventory.readonly https://api.ebay.com/oauth/api_scope/sell.account https://api.ebay.com/oauth/api_scope/sell.account.readonly https://api.ebay.com/oauth/api_scope/sell.fulfillment https://api.ebay.com/oauth/api_scope/sell.fulfillment.readonly https://api.ebay.com/oauth/api_scope/sell.marketing https://api.ebay.com/oauth/api_scope/sell.marketing.readonly https://api.ebay.com/oauth/api_scope/sell.analytics.readonly https://api.ebay.com/oauth/api_scope/sell.finances https://api.ebay.com/oauth/api_scope/sell.payment.dispute https://api.ebay.com/oauth/api_scope/commerce.identity.readonly'
const ENDPOINTS = {
  production: { api: 'https://api.ebay.com', token: 'https://api.ebay.com/identity/v1/oauth2/token' },
  sandbox: { api: 'https://api.sandbox.ebay.com', token: 'https://api.sandbox.ebay.com/identity/v1/oauth2/token' }
}
// marketplace → Trading site id, currency, storefront host
const MARKETS: Record<string, { siteId: number; currency: string; host: string; categoryTree: string }> = {
  EBAY_DE: { siteId: 77, currency: 'EUR', host: 'www.ebay.de', categoryTree: '77' },
  EBAY_AT: { siteId: 16, currency: 'EUR', host: 'www.ebay.at', categoryTree: '16' },
  EBAY_CH: { siteId: 193, currency: 'CHF', host: 'www.ebay.ch', categoryTree: '193' },
  EBAY_GB: { siteId: 3, currency: 'GBP', host: 'www.ebay.co.uk', categoryTree: '3' },
  EBAY_US: { siteId: 0, currency: 'USD', host: 'www.ebay.com', categoryTree: '0' },
  EBAY_FR: { siteId: 71, currency: 'EUR', host: 'www.ebay.fr', categoryTree: '71' },
  EBAY_IT: { siteId: 101, currency: 'EUR', host: 'www.ebay.it', categoryTree: '101' },
  EBAY_ES: { siteId: 186, currency: 'EUR', host: 'www.ebay.es', categoryTree: '186' },
  EBAY_NL: { siteId: 146, currency: 'EUR', host: 'www.ebay.nl', categoryTree: '146' }
}
const TRADING_COMPAT = '1301'
const connCache = new Map<number, { at: number; conn: EbayConn }>()
const importCache = new Map<number, { at: number; rows: RemoteListing[] }>()
// listingId → primary category (id + full path) for the fee schedule; GetMyeBaySelling does not return it
const categoryCache = new Map<string, { at: number; categoryId: string | null; categoryPath: string | null }>()
const CATEGORY_CACHE_MS = 24 * 60 * 60 * 1000
const IMPORT_CACHE_MS = 5 * 60 * 1000

const errMsg = (e: any): string => {
  const d = e?.data?.errors?.[0]
  return String(d ? `${d.message || ''}${d.longMessage ? ' — ' + d.longMessage : ''}`.trim() : (e?.data?.error_description || e?.message || e)).slice(0, 1900)
}
const isTransient = (e: any): boolean => {
  const st = Number(e?.status || e?.statusCode || 0)
  return st === 429 || st >= 500 || /timed out|ECONNRESET|fetch failed/i.test(String(e?.message || ''))
}
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms))
const xmlParser = new XMLParser({ ignoreAttributes: true, parseTagValue: false, isArray: (name) => ['Item', 'Variation', 'NameValueList', 'Value', 'Errors', 'InventoryStatus', 'PictureURL'].includes(name) })
const xmlBuilder = new XMLBuilder({ ignoreAttributes: true, suppressEmptyNode: true })

export const ebayMarketplaceOf = (orderSource: any): string => {
  const explicit = String(orderSource?.ebay_marketplace_id || orderSource?.Ebay_Marketplace_ID || '').toUpperCase()
  if (explicit && MARKETS[explicit]) return explicit
  const desc = String(orderSource?.Description || '') + ' ' + String(orderSource?.marketplace_url || '')
  const m = desc.match(/ebay\.(co\.uk|com\.au|[a-z]{2})/i)
  const tld = (m?.[1] || 'de').toLowerCase()
  return ({ de: 'EBAY_DE', at: 'EBAY_AT', ch: 'EBAY_CH', 'co.uk': 'EBAY_GB', com: 'EBAY_US', fr: 'EBAY_FR', it: 'EBAY_IT', es: 'EBAY_ES', nl: 'EBAY_NL' } as Record<string, string>)[tld] || 'EBAY_DE'
}

/** Access token from the stored JSON, refreshed (and written back to c_ordersource) when expired. */
const connect = async (ctx: AdapterCtx): Promise<EbayConn> => {
  const c = connCache.get(ctx.orderSourceId)
  if (c && Date.now() - c.at < 20 * 60 * 1000) return c.conn
  const os = ctx.orderSource
  const envKey = String(os?.ebay_environment || 'production').toLowerCase() === 'sandbox' ? 'sandbox' : 'production'
  const ep = ENDPOINTS[envKey]
  let tokens: any = null
  try { tokens = typeof os?.marketplace_token === 'string' ? JSON.parse(os.marketplace_token) : os?.marketplace_token } catch { tokens = null }
  if (!tokens?.access_token && !tokens?.refresh_token) throw Object.assign(new Error('eBay order source is not connected (marketplace_token holds no OAuth tokens) — connect it via OAuth first'), { status: 400 })
  const obtained = tokens?.obtained_at ? new Date(tokens.obtained_at).getTime() : 0
  const expiresIn = Number(tokens?.expires_in || 7200) * 1000
  let accessToken: string = tokens?.access_token || ''
  if (!accessToken || !obtained || Date.now() - obtained >= expiresIn - 5 * 60 * 1000) {
    const clientId = os?.marketplace_key, clientSecret = os?.marketplace_secret
    if (!clientId || !clientSecret) throw Object.assign(new Error('eBay access token expired and the order source has no app client id / secret (marketplace_key / marketplace_secret) to refresh it'), { status: 400 })
    if (!tokens?.refresh_token) throw Object.assign(new Error('eBay access token expired and no refresh token is stored — reconnect via OAuth'), { status: 400 })
    const res: any = await $fetch(ep.token, {
      method: 'POST', retry: 0, timeout: 30_000,
      headers: { 'Content-Type': 'application/x-www-form-urlencoded', Authorization: 'Basic ' + Buffer.from(`${clientId}:${clientSecret}`).toString('base64') },
      body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: tokens.refresh_token, scope: SCOPES }).toString()
    } as any)
    if (!res?.access_token) throw new Error('eBay token refresh answered without access_token')
    accessToken = res.access_token
    const updated = JSON.stringify({ ...tokens, access_token: accessToken, token_type: 'User Access Token', expires_in: res.expires_in || 7200, obtained_at: new Date().toISOString() })
    try { await ctx.fetch(`models/c_ordersource/${ctx.orderSourceId}`, 'PUT', { marketplace_token: updated }) } catch (e: any) { ctx.log(`refreshed eBay token could not be stored on the order source: ${e?.message || e}`) }
    os.marketplace_token = updated
  }
  const marketplaceId = ebayMarketplaceOf(os)
  const mk = MARKETS[marketplaceId] || MARKETS.EBAY_DE!
  const conn: EbayConn = { apiUrl: ep.api, accessToken, marketplaceId, siteId: mk.siteId, currency: mk.currency, storefront: `https://${mk.host}` }
  connCache.set(ctx.orderSourceId, { at: Date.now(), conn })
  return conn
}

const rest = async (conn: EbayConn, method: 'GET' | 'POST' | 'PUT' | 'DELETE', path: string, body?: any, attempt = 0): Promise<any> => {
  try {
    return await $fetch(`${conn.apiUrl}${path}`, {
      method, body, retry: 0, timeout: 60_000,
      // Content-Language must be a plain locale eBay accepts (de-DE is rejected on the Inventory API → "en-US")
      headers: { Authorization: `Bearer ${conn.accessToken}`, Accept: 'application/json', 'Content-Type': 'application/json', 'Content-Language': 'en-US', 'Accept-Language': 'en-US', 'X-EBAY-C-MARKETPLACE-ID': conn.marketplaceId }
    } as any)
  } catch (e: any) {
    if (attempt < 3 && isTransient(e)) { await sleep(1000 * (attempt + 1)); return rest(conn, method, path, body, attempt + 1) }
    throw e
  }
}

/** Trading API call (XML) with the same OAuth user token (IAF header). */
const trading = async (conn: EbayConn, callName: string, request: any): Promise<any> => {
  const xml = `<?xml version="1.0" encoding="utf-8"?><${callName}Request xmlns="urn:ebay:apis:eBLBaseComponents">${xmlBuilder.build(request)}</${callName}Request>`
  const text: string = await $fetch(`${conn.apiUrl}/ws/api.dll`, {
    method: 'POST', body: xml, retry: 0, timeout: 90_000, responseType: 'text',
    headers: { 'Content-Type': 'text/xml', 'X-EBAY-API-IAF-TOKEN': conn.accessToken, 'X-EBAY-API-CALL-NAME': callName, 'X-EBAY-API-SITEID': String(conn.siteId), 'X-EBAY-API-COMPATIBILITY-LEVEL': TRADING_COMPAT }
  } as any)
  const parsed = xmlParser.parse(text)
  const res = parsed?.[`${callName}Response`] || {}
  if (res.Ack === 'Failure' || (res.Ack === 'PartialFailure' && !res.ItemID)) {
    const errs: any[] = res.Errors || []
    throw Object.assign(new Error(errs.map(e => `${e.ShortMessage || ''}${e.LongMessage ? ' — ' + e.LongMessage : ''}`).join('; ') || `${callName} failed`), { data: res })
  }
  return res
}

const asArray = (v: any): any[] => (v == null ? [] : Array.isArray(v) ? v : [v])
const pool = async <T, R>(items: T[], size: number, fn: (t: T) => Promise<R>): Promise<R[]> => {
  const out: R[] = new Array(items.length)
  let i = 0
  await Promise.all(Array.from({ length: Math.min(size, items.length) }, async () => { while (i < items.length) { const k = i++; out[k] = await fn(items[k]!) } }))
  return out
}
const variantTitleOf = (aspects: any): string => {
  if (!aspects || typeof aspects !== 'object') return ''
  return Object.entries(aspects).filter(([k]) => /size|gr[öo]ße|colou?r|farbe|variante|variation|style|material|menge|pack/i.test(k)).map(([, v]) => asArray(v).join('/')).filter(Boolean).join(' · ')
}
const itemUrl = (conn: EbayConn, listingId: string | null) => (listingId ? `${conn.storefront}/itm/${listingId}` : null)
const editUrl = (conn: EbayConn, listingId: string | null) => (listingId ? `${conn.storefront}/sl/sell?mode=ReviseItem&itemId=${listingId}` : null)

export const ebayAdapter: ListingAdapter = {
  key: 'ebay',
  immediate: true,

  async importRemote(ctx, opts = {}): Promise<ImportResult> {
    const cached = importCache.get(ctx.orderSourceId)
    if (!opts.forceRefresh && cached && Date.now() - cached.at < IMPORT_CACHE_MS) return { pending: false, rows: cached.rows, generatedAt: cached.at, fromCache: true }
    const conn = await connect(ctx)
    const rows: RemoteListing[] = []
    const seenListingIds = new Set<string>()

    // 1. Inventory API: items → offers
    const items: any[] = []
    for (let offset = 0; offset < 100_000; offset += 100) {
      const res: any = await rest(conn, 'GET', `/sell/inventory/v1/inventory_item?limit=100&offset=${offset}`)
      items.push(...(res?.inventoryItems || []))
      if (!res?.next || (res?.inventoryItems || []).length < 100) break
    }
    const offersBySku = await pool(items, 4, async (it) => {
      try { const r: any = await rest(conn, 'GET', `/sell/inventory/v1/offer?sku=${encodeURIComponent(it.sku)}&marketplace_id=${conn.marketplaceId}&limit=20`); return (r?.offers || []) as any[] } catch (e: any) { if (Number(e?.status) === 404) return []; throw e }
    })
    items.forEach((it, i) => {
      for (const o of offersBySku[i] || []) {
        const listingId = o?.listing?.listingId || null
        if (listingId) seenListingIds.add(String(listingId))
        const published = o.status === 'PUBLISHED'
        const live = published && (!o?.listing?.listingStatus || o.listing.listingStatus === 'ACTIVE')
        rows.push({
          listingKey: String(o.offerId), sellerSku: String(it.sku || ''), externalProductId: listingId, externalInventoryId: String(it.sku || ''),
          title: String(it?.product?.title || o?.listingDescription?.slice(0, 80) || it.sku),
          price: o?.pricingSummary?.price?.value != null ? Number(o.pricingSummary.price.value) : null,
          currency: o?.pricingSummary?.price?.currency || conn.currency,
          qty: o?.availableQuantity != null ? Number(o.availableQuantity) : (it?.availability?.shipToLocationAvailability?.quantity ?? null),
          fulfillmentChannel: 'DEFAULT',
          status: live ? 'Active' : (published ? String(o?.listing?.listingStatus || 'Inactive').charAt(0) + String(o?.listing?.listingStatus || 'Inactive').slice(1).toLowerCase() : 'Unpublished'),
          dateListed: null,
          meta: {
            marketplaceId: conn.marketplaceId, listingId, offerStatus: o.status, listingStatus: o?.listing?.listingStatus || null, format: o.format,
            groupKey: (it.groupIds || [])[0] || null, variantTitle: variantTitleOf(it?.product?.aspects), imageUrl: (it?.product?.imageUrls || [])[0] || null,
            categoryId: o.categoryId || null, merchantLocationKey: o.merchantLocationKey || null, legacy: false,
            storefrontUrl: itemUrl(conn, listingId), adminUrl: editUrl(conn, listingId)
          }
        })
      }
    })

    // 2. Legacy listings (created in Seller Hub / Trading API, never migrated): GetMyeBaySelling ActiveList
    try {
      for (let page = 1; page <= 50; page++) {
        const res = await trading(conn, 'GetMyeBaySelling', { ActiveList: { Include: true, Pagination: { EntriesPerPage: 200, PageNumber: page }, Sort: 'TimeLeft' }, DetailLevel: 'ReturnAll', OutputSelector: ['ActiveList.ItemArray.Item.ItemID', 'ActiveList.ItemArray.Item.Title', 'ActiveList.ItemArray.Item.SKU', 'ActiveList.ItemArray.Item.Quantity', 'ActiveList.ItemArray.Item.QuantityAvailable', 'ActiveList.ItemArray.Item.SellingStatus', 'ActiveList.ItemArray.Item.BuyItNowPrice', 'ActiveList.ItemArray.Item.ListingDetails', 'ActiveList.ItemArray.Item.PictureDetails', 'ActiveList.ItemArray.Item.Variations', 'ActiveList.ItemArray.Item.ListingType', 'ActiveList.ItemArray.Item.PrimaryCategory', 'ActiveList.PaginationResult'] })
        const list: any[] = res?.ActiveList?.ItemArray?.Item || []
        for (const it of list) {
          const id = String(it.ItemID || '')
          if (!id || seenListingIds.has(id)) continue
          const start = it?.ListingDetails?.StartTime || null
          const pic = asArray(it?.PictureDetails?.PictureURL)[0] || it?.PictureDetails?.GalleryURL || null
          const price = it?.SellingStatus?.CurrentPrice ?? it?.BuyItNowPrice ?? null
          const variations: any[] = it?.Variations?.Variation || []
          // category for the fee schedule (feeModel.ts): id + full path "Kleidung & Accessoires:Herren:…"
          const cat = { categoryId: it?.PrimaryCategory?.CategoryID ? String(it.PrimaryCategory.CategoryID) : null, categoryPath: it?.PrimaryCategory?.CategoryName ? String(it.PrimaryCategory.CategoryName) : null }
          if (variations.length) {
            for (const v of variations) {
              const specifics = (v?.VariationSpecifics?.NameValueList || []).map((n: any) => `${n.Name}: ${asArray(n.Value).join('/')}`).join(' · ')
              const sku = String(v.SKU || '')
              rows.push({
                listingKey: `item:${id}:${sku || specifics}`, sellerSku: sku, externalProductId: id, externalInventoryId: sku || null,
                title: `${it.Title} – ${specifics}`, price: v?.StartPrice != null ? Number(v.StartPrice) : (price != null ? Number(price) : null), currency: conn.currency,
                qty: v?.Quantity != null ? Math.max(0, Number(v.Quantity) - Number(v?.SellingStatus?.QuantitySold || 0)) : null,
                fulfillmentChannel: 'DEFAULT', status: 'Active', dateListed: start,
                meta: { marketplaceId: conn.marketplaceId, listingId: id, legacy: true, ...cat, variantTitle: specifics, variationSpecifics: v?.VariationSpecifics?.NameValueList || [], imageUrl: asArray(v?.VariationPictures?.Pictures?.PictureURL)[0] || pic, storefrontUrl: itemUrl(conn, id), adminUrl: editUrl(conn, id) }
              })
            }
          } else {
            rows.push({
              listingKey: `item:${id}`, sellerSku: String(it.SKU || ''), externalProductId: id, externalInventoryId: it.SKU ? String(it.SKU) : null,
              title: String(it.Title || id), price: price != null ? Number(price) : null, currency: conn.currency,
              qty: it?.QuantityAvailable != null ? Number(it.QuantityAvailable) : (it?.Quantity != null ? Math.max(0, Number(it.Quantity) - Number(it?.SellingStatus?.QuantitySold || 0)) : null),
              fulfillmentChannel: 'DEFAULT', status: 'Active', dateListed: start,
              meta: { marketplaceId: conn.marketplaceId, listingId: id, legacy: true, ...cat, variantTitle: '', imageUrl: pic, listingType: it.ListingType || null, storefrontUrl: itemUrl(conn, id), adminUrl: editUrl(conn, id) }
            })
          }
          seenListingIds.add(id)
        }
        const total = Number(res?.ActiveList?.PaginationResult?.TotalNumberOfPages || 1)
        if (page >= total || !list.length) break
      }
    } catch (e: any) {
      ctx.log(`legacy listings (Trading API) not read: ${errMsg(e)}`)
    }
    // 3. Primary category per listing (one cached GetItem per ItemID) → meta.categoryId / categoryPath
    //    ("Kleidung & Accessoires:Herren:…"), the key of the eBay fee schedule in feeModel.ts.
    const listingIds = [...new Set(rows.map(r => r.externalProductId).filter((id): id is string => !!id && !rows.find(x => x.externalProductId === id && x.meta?.categoryPath)))]
    const cats = await pool(listingIds, 4, async (id) => {
      const c = categoryCache.get(id)
      if (c && Date.now() - c.at < CATEGORY_CACHE_MS) return c
      try {
        const res = await trading(conn, 'GetItem', { ItemID: id, OutputSelector: ['Item.PrimaryCategory'] })
        const pc = asArray(res?.Item)[0]?.PrimaryCategory
        const v = { at: Date.now(), categoryId: pc?.CategoryID ? String(pc.CategoryID) : null, categoryPath: pc?.CategoryName ? String(pc.CategoryName) : null }
        categoryCache.set(id, v)
        return v
      } catch (e: any) { ctx.log(`eBay category of ${id} not read: ${errMsg(e)}`); return null }
    })
    listingIds.forEach((id, i) => { const c = cats[i]; if (c) for (const r of rows) if (r.externalProductId === id && r.meta) Object.assign(r.meta, { categoryId: c.categoryId || r.meta.categoryId || null, categoryPath: c.categoryPath }) })
    ctx.log(`ebay import: ${rows.length} row(s) — ${items.length} inventory item(s), ${rows.filter(r => r.meta?.legacy).length} legacy, ${listingIds.length} categor${listingIds.length === 1 ? 'y' : 'ies'} resolved`)
    importCache.set(ctx.orderSourceId, { at: Date.now(), rows })
    return { pending: false, rows, generatedAt: Date.now(), fromCache: false }
  },

  async push(ctx, items): Promise<PushResult[]> {
    if (!items.length) return []
    const conn = await connect(ctx)
    const out: PushResult[] = []
    for (const item of items) {
      const l = item.listing
      const base = { listingId: l.id, changeIds: item.changes.map(c => c.id), deliveryMode: 'patch' as const }
      const warnings: string[] = []
      if (item.minPrice != null || item.maxPrice != null) warnings.push('min/max price are not supported on eBay (ignored)')
      try {
        if (item.op === 'delete') { out.push({ ...base, status: 'error', error: 'Deleting an eBay listing from here is not supported — end it (delist) or remove it in Seller Hub' }); continue }
        if (item.op === 'create') { out.push(await createOffer(ctx, conn, item)); continue }
        const legacy = !!l.meta?.legacy || String(l.listingKey).startsWith('item:')
        let qty: number | null | undefined = item.qty
        if (item.op === 'delist') qty = 0
        if (item.op === 'relist') qty = item.qty ?? l.qty ?? 0
        const price = item.price != null && item.op !== 'delist' ? Number(item.price) : null

        if (!legacy) {
          const offerId = String(l.listingKey)
          if (item.op === 'delist') {
            await rest(conn, 'POST', `/sell/inventory/v1/offer/${encodeURIComponent(offerId)}/withdraw`, {})
          } else {
            if (price != null || qty != null) {
              const req: any = { offers: [{ offerId, ...(qty != null ? { availableQuantity: Math.max(0, Math.trunc(qty)) } : {}), ...(price != null ? { price: { currency: l.currency || conn.currency, value: price.toFixed(2) } } : {}) }], sku: l.sellerSku }
              if (qty != null) req.shipToLocationAvailability = { quantity: Math.max(0, Math.trunc(qty)) }
              const res: any = await rest(conn, 'POST', '/sell/inventory/v1/bulk_update_price_quantity', { requests: [req] })
              const r = (res?.responses || [])[0]
              if (r && Number(r.statusCode) >= 300) throw new Error((r.errors || []).map((e: any) => e.message).join('; ') || `bulk update answered ${r.statusCode}`)
            }
            if (item.op === 'relist') {
              const pub: any = await rest(conn, 'POST', `/sell/inventory/v1/offer/${encodeURIComponent(offerId)}/publish`, {})
              if (pub?.listingId) { out.push({ ...base, status: 'sent', confirmed: true, submissionId: String(pub.listingId), warnings, remote: { externalProductId: String(pub.listingId), meta: { ...(l.meta || {}), listingId: String(pub.listingId), storefrontUrl: itemUrl(conn, String(pub.listingId)), adminUrl: editUrl(conn, String(pub.listingId)) } } }); continue }
            }
          }
          out.push({ ...base, status: 'sent', confirmed: true, submissionId: null, warnings })
          continue
        }

        // legacy listing → Trading API
        const itemId = String(l.externalProductId || l.meta?.listingId || '')
        if (!itemId) throw new Error('Legacy eBay listing has no ItemID (ExternalProductId) — re-import it')
        if (item.op === 'delist') {
          await trading(conn, 'EndFixedPriceItem', { ItemID: itemId, EndingReason: 'NotAvailable' })
          out.push({ ...base, status: 'sent', confirmed: true, submissionId: itemId, warnings })
          continue
        }
        if (item.op === 'relist') {
          const res = await trading(conn, 'RelistFixedPriceItem', { Item: { ItemID: itemId, ...(price != null ? { StartPrice: price.toFixed(2) } : {}), ...(qty != null ? { Quantity: Math.max(0, Math.trunc(qty)) } : {}) } })
          const newId = String(res?.ItemID || itemId)
          out.push({ ...base, status: 'sent', confirmed: true, submissionId: newId, warnings, remote: { listingKey: String(l.listingKey).replace(itemId, newId), externalProductId: newId, meta: { ...(l.meta || {}), listingId: newId, storefrontUrl: itemUrl(conn, newId), adminUrl: editUrl(conn, newId) } } })
          continue
        }
        const status: any = { ItemID: itemId }
        if (l.sellerSku) status.SKU = l.sellerSku
        else if ((l.meta?.variationSpecifics || []).length) status.VariationSpecifics = { NameValueList: l.meta.variationSpecifics }
        if (price != null) status.StartPrice = price.toFixed(2)
        if (qty != null) status.Quantity = Math.max(0, Math.trunc(qty))
        await trading(conn, 'ReviseInventoryStatus', { InventoryStatus: status })
        out.push({ ...base, status: 'sent', confirmed: true, submissionId: itemId, warnings })
      } catch (e: any) {
        out.push({ ...base, status: 'error', error: errMsg(e), transient: isTransient(e) })
      }
    }
    return out
  },

  async fetchCompetition(_ctx, listings): Promise<CompetitionResult[]> {
    return listings.map(l => ({ listingId: l.id, buyBoxStatus: 'unknown', buyBoxPrice: null, lowestOtherPrice: null, competitorCount: null, feesEstimate: null, feesJson: null, error: 'not available on eBay' }))
  },

  validateCreate(_ctx, product) {
    if (!String(product.value || '').trim()) return 'Product has no product number (Value) — eBay needs it as the SKU'
    return null
  }
}

/** "List products": inventory item + offer (default policies / location / suggested category) + publish. */
const createOffer = async (ctx: AdapterCtx, conn: EbayConn, item: PushItem): Promise<PushResult> => {
  const l = item.listing
  const base = { listingId: l.id, changeIds: item.changes.map(c => c.id), deliveryMode: 'patch' as const }
  const warnings: string[] = []
  const price = item.price ?? l.price
  if (price == null) return { ...base, status: 'error', error: 'price missing' }
  const sku = l.sellerSku
  const qty = Math.max(0, Math.trunc(Number(item.qty ?? l.qty ?? 0)))
  const [ful, pay, ret, loc] = await Promise.all([
    rest(conn, 'GET', `/sell/account/v1/fulfillment_policy?marketplace_id=${conn.marketplaceId}`).catch(() => null),
    rest(conn, 'GET', `/sell/account/v1/payment_policy?marketplace_id=${conn.marketplaceId}`).catch(() => null),
    rest(conn, 'GET', `/sell/account/v1/return_policy?marketplace_id=${conn.marketplaceId}`).catch(() => null),
    rest(conn, 'GET', '/sell/inventory/v1/location?limit=20').catch(() => null)
  ])
  const policies = { fulfillmentPolicyId: ful?.fulfillmentPolicies?.[0]?.fulfillmentPolicyId, paymentPolicyId: pay?.paymentPolicies?.[0]?.paymentPolicyId, returnPolicyId: ret?.returnPolicies?.[0]?.returnPolicyId }
  const merchantLocationKey = loc?.locations?.[0]?.merchantLocationKey
  if (!policies.fulfillmentPolicyId || !policies.paymentPolicyId || !policies.returnPolicyId) return { ...base, status: 'error', error: 'eBay business policies (fulfillment / payment / return) missing for this marketplace — create them in Seller Hub first' }
  if (!merchantLocationKey) return { ...base, status: 'error', error: 'No eBay inventory location — create one in Seller Hub (or via the Inventory API) first' }
  const tree = MARKETS[conn.marketplaceId]?.categoryTree || '77'
  const sug: any = await rest(conn, 'GET', `/commerce/taxonomy/v1/category_tree/${tree}/get_category_suggestions?q=${encodeURIComponent(l.name.slice(0, 80))}`).catch(() => null)
  const categoryId = sug?.categorySuggestions?.[0]?.category?.categoryId
  if (!categoryId) return { ...base, status: 'error', error: `No eBay category suggestion for "${l.name}" — list it manually once, then import` }
  await rest(conn, 'PUT', `/sell/inventory/v1/inventory_item/${encodeURIComponent(sku)}`, {
    availability: { shipToLocationAvailability: { quantity: qty } }, condition: 'NEW',
    product: { title: l.name.slice(0, 80), description: l.description || l.name, ...(l.meta?.imageUrl ? { imageUrls: [l.meta.imageUrl] } : {}) }
  })
  const offer: any = await rest(conn, 'POST', '/sell/inventory/v1/offer', {
    sku, marketplaceId: conn.marketplaceId, format: 'FIXED_PRICE', availableQuantity: qty, categoryId, merchantLocationKey,
    listingDescription: l.description || l.name, listingPolicies: policies,
    pricingSummary: { price: { currency: conn.currency, value: Number(price).toFixed(2) } }
  })
  const offerId = String(offer?.offerId || '')
  if (!offerId) return { ...base, status: 'error', error: 'createOffer answered without offerId' }
  let listingId: string | null = null
  try { const pub: any = await rest(conn, 'POST', `/sell/inventory/v1/offer/${offerId}/publish`, {}); listingId = pub?.listingId ? String(pub.listingId) : null; for (const w of (pub?.warnings || [])) warnings.push(String(w.message || '')) } catch (e: any) { warnings.push(`offer ${offerId} created but not published: ${errMsg(e)}`) }
  ctx.log(`created eBay offer ${offerId}${listingId ? ` / listing ${listingId}` : ''} for ${sku}`)
  return {
    ...base, status: 'sent', confirmed: true, submissionId: offerId, warnings,
    remote: { listingKey: offerId, externalProductId: listingId, externalInventoryId: sku, meta: { ...(l.meta || {}), marketplaceId: conn.marketplaceId, listingId, legacy: false, categoryId, merchantLocationKey, storefrontUrl: itemUrl(conn, listingId), adminUrl: editUrl(conn, listingId) } }
  }
}
