/**
 * SIS (Sales Information System) — matches Amazon's bulk merchant-listings
 * report rows (see amazonSpApi.ts -> getMerchantListingsReport) to local
 * m_product records and joins in the internal qty-on-hand and sales price.
 *
 * Match order matters: `seller-sku` should always equal a LogShip
 * M_Product.Value or M_Product.SKU (that's what gets shipped as the SKU when
 * listing), so SKU is the primary key. m_product.asin is NOT always
 * populated in LogShip, so it is a fallback used only for rows the SKU tiers
 * miss — do not flip this order.
 */
import { string } from 'alga-js'
import fetchHelper from './fetchHelper'
import { fetchStorageRowsForProducts, groupStorageByProduct } from './productStorageHelper'
import getReturnLocatorIds from './returnLocatorHelper'
import type { MerchantListingRow } from './amazonSpApi'

export interface SisRow {
  sellerSku: string
  asin: string
  amazonItemName: string
  amazonPrice: number | null
  amazonQty: number | null
  fulfillmentChannel: string
  status: string
  openDate: string | null
  productId: number | null
  productName: string
  productValue: string
  strapiProductDocumentId: string | null
  isBom: boolean
  internalPrice: number | null
  internalQtyOnHand: number | null
  returnQtyOnHand: number
}

const CHUNK_SIZE = 80

// Same combined-flag pattern used across the app (OrderPositionsTable.vue,
// ProductSearchSelect.vue, SearchModal.vue, …) — a product counts as BOM
// when either the native IsBOM flag or the JTL-synced isJtlBom flag is set.
const isBomFlag = (product: any): boolean => {
  const v = product?.IsBOM ?? product?.isBOM
  const j = product?.isJtlBom ?? product?.IsJtlBom
  return v === true || v === 'Y' || j === true || j === 'Y'
}

const chunk = <T,>(arr: T[], size: number): T[][] => {
  const out: T[][] = []
  for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size))
  return out
}

// Runs `fn` over every chunk with bounded concurrency instead of one at a
// time — these are plain OData filter queries against m_product (no
// $expand), so they don't hit the synchronized-lock path the "iDempiere REST
// load rules" recipe warns about; a cap (rather than unbounded Promise.all)
// just avoids opening dozens of simultaneous connections on a large catalog.
const CHUNK_CONCURRENCY = 5
async function forEachChunk<T>(chunks: T[][], fn: (c: T[]) => Promise<void>): Promise<void> {
  let next = 0
  const workers = Array.from({ length: Math.min(CHUNK_CONCURRENCY, chunks.length) }, async () => {
    for (;;) {
      const i = next++
      if (i >= chunks.length) return
      await fn(chunks[i]!)
    }
  })
  await Promise.all(workers)
}

// OData string literals are single-quoted; escape embedded quotes so a SKU
// like O'Brien-123 can't break the filter.
const escapeOData = (v: string): string => v.replace(/'/g, "''")

/**
 * Resolves the current active m_pricelist_version for the app's default
 * price list (runtimeConfig.public.pricelistid, the same "101" default used
 * app-wide for new orders — see OrderQuickAddModal.vue). Cached briefly per
 * process since this rarely changes.
 */
const priceListVersionCache = new Map<string, { id: number | null; at: number }>()
const PRICE_LIST_VERSION_TTL_MS = 10 * 60 * 1000

export async function resolveActivePriceListVersionId(event: any, token: string, priceListId: string | number): Promise<number | null> {
  const key = String(priceListId)
  const cached = priceListVersionCache.get(key)
  if (cached && Date.now() - cached.at < PRICE_LIST_VERSION_TTL_MS) return cached.id

  let id: number | null = null
  try {
    const filter = `M_PriceList_ID eq ${Number(priceListId)} and isActive eq true`
    const res: any = await fetchHelper(
      event,
      `models/m_pricelist_version?$filter=${string.urlEncode(filter)}&$orderby=${string.urlEncode('ValidFrom desc')}&$top=1`,
      'GET',
      token,
      null
    )
    id = res?.records?.[0]?.id ?? null
  } catch {
    id = null
  }
  priceListVersionCache.set(key, { id, at: Date.now() })
  return id
}

/** Batch-resolves M_Product records matching a set of SKUs (Value or SKU field) or ASINs, scoped to one org. */
async function fetchMatchCandidates(
  event: any,
  token: string,
  orgId: number,
  skus: string[],
  asins: string[]
): Promise<{ bySku: Map<string, any>; byAsin: Map<string, any> }> {
  const bySku = new Map<string, any>()
  const byAsin = new Map<string, any>()

  await Promise.all([
    forEachChunk(chunk(skus, CHUNK_SIZE), async (skuChunk) => {
      if (skuChunk.length === 0) return
      const orClause = skuChunk
        .map(s => `Value eq '${escapeOData(s)}' or SKU eq '${escapeOData(s)}'`)
        .join(' or ')
      const filter = `AD_Org_ID eq ${orgId} and (${orClause})`
      const res: any = await fetchHelper(event, `models/m_product?$filter=${string.urlEncode(filter)}&$top=500`, 'GET', token, null)
      for (const p of (res?.records || [])) {
        if (p.Value) bySku.set(String(p.Value).toLowerCase(), p)
        if (p.SKU) bySku.set(String(p.SKU).toLowerCase(), p)
      }
    }),
    forEachChunk(chunk(asins, CHUNK_SIZE), async (asinChunk) => {
      if (asinChunk.length === 0) return
      const orClause = asinChunk.map(a => `asin eq '${escapeOData(a)}'`).join(' or ')
      const filter = `AD_Org_ID eq ${orgId} and (${orClause})`
      const res: any = await fetchHelper(event, `models/m_product?$filter=${string.urlEncode(filter)}&$top=500`, 'GET', token, null)
      for (const p of (res?.records || [])) {
        if (p.asin) byAsin.set(String(p.asin).toLowerCase(), p)
      }
    })
  ])

  return { bySku, byAsin }
}

/** Batch-resolves m_productprice.priceStd for a set of product ids at one price-list version. */
async function fetchPricesForProducts(
  event: any,
  token: string,
  productIds: number[],
  priceListVersionId: number | null
): Promise<Map<number, number>> {
  const prices = new Map<number, number>()
  if (!priceListVersionId || productIds.length === 0) return prices

  await forEachChunk(chunk(productIds, CHUNK_SIZE), async (idChunk) => {
    const filter = `M_PriceList_Version_ID eq ${priceListVersionId} and M_Product_ID in (${idChunk.join(',')})`
    const res: any = await fetchHelper(event, `models/m_productprice?$filter=${string.urlEncode(filter)}`, 'GET', token, null)
    for (const pp of (res?.records || [])) {
      const pid = Number(pp?.M_Product_ID?.id ?? pp?.M_Product_ID)
      const price = Number(pp?.priceStd ?? pp?.PriceStd)
      if (Number.isFinite(pid) && Number.isFinite(price)) prices.set(pid, price)
    }
  })
  return prices
}

/**
 * Joins Amazon listing rows to local products. Rows with no local match are
 * kept (productId: null) rather than dropped — a listing live on Amazon with
 * nothing tracked internally is useful information on its own.
 */
export async function matchListingsToProducts(
  event: any,
  token: string,
  orgId: number,
  listings: MerchantListingRow[],
  priceListId: string | number
): Promise<SisRow[]> {
  const skus = [...new Set(listings.map(l => l.sellerSku).filter(Boolean))]
  const asins = [...new Set(listings.map(l => l.asin).filter(Boolean))]

  const [{ bySku, byAsin }, priceListVersionId, returnLocatorIds] = await Promise.all([
    fetchMatchCandidates(event, token, orgId, skus, asins),
    resolveActivePriceListVersionId(event, token, priceListId),
    getReturnLocatorIds(event, token, orgId)
  ])

  const resolved = listings.map(l => {
    const bySkuMatch = bySku.get(l.sellerSku.toLowerCase())
    const product = bySkuMatch || (l.asin ? byAsin.get(l.asin.toLowerCase()) : null) || null
    return { listing: l, product }
  })

  const matchedIds = [...new Set(resolved.filter(r => r.product).map(r => Number(r.product.id)))]
  const [storageRows, prices] = await Promise.all([
    matchedIds.length ? fetchStorageRowsForProducts(event, token, matchedIds) : Promise.resolve([]),
    fetchPricesForProducts(event, token, matchedIds, priceListVersionId)
  ])
  const storageByProduct = groupStorageByProduct(storageRows)

  const qtyOnHandFor = (productId: number) => {
    const rows = storageByProduct.get(productId) || []
    let onHand = 0
    let returnOnHand = 0
    for (const s of rows) {
      const qty = Number(s?.QtyOnHand ?? 0)
      const locId = Number(s?.M_Locator_ID?.id ?? s?.M_Locator_ID)
      if (returnLocatorIds.has(locId)) returnOnHand += qty
      else onHand += qty
    }
    return { onHand, returnOnHand }
  }

  return resolved.map(({ listing, product }) => {
    const productId = product ? Number(product.id) : null
    const { onHand, returnOnHand } = productId ? qtyOnHandFor(productId) : { onHand: 0, returnOnHand: 0 }
    return {
      sellerSku: listing.sellerSku,
      asin: listing.asin,
      amazonItemName: listing.itemName,
      amazonPrice: listing.price,
      amazonQty: listing.quantity,
      fulfillmentChannel: listing.fulfillmentChannel,
      status: listing.status,
      openDate: listing.openDate,
      productId,
      productName: product?.Name || '',
      productValue: product?.Value || product?.SKU || '',
      strapiProductDocumentId: product?.Strapi_Product_documentId || null,
      isBom: product ? isBomFlag(product) : false,
      internalPrice: productId ? (prices.get(productId) ?? null) : null,
      internalQtyOnHand: productId ? onHand : null,
      returnQtyOnHand: returnOnHand
    }
  })
}

/**
 * Internal price / stock / identity for a set of product ids — the join the
 * persisted listings page needs (CUST_MarketplaceListing rows already carry
 * the Amazon side). Same price-list-version, storage and return-locator
 * sources as matchListingsToProducts, minus the SKU/ASIN matching.
 */
export interface BomComponentStock { productId: number; name: string; value: string; qtyBom: number; onHand: number; returnOnHand: number }
export interface ProductEnrichment {
  productId: number
  productName: string
  productValue: string
  asin: string | null
  strapiProductDocumentId: string | null
  isBom: boolean
  /** Components of the latest active pp_product_bom (empty for non-BOM / JTL-only bundles). */
  bomComponents: BomComponentStock[]
  internalPrice: number | null
  internalQtyOnHand: number | null
  returnQtyOnHand: number
}

/**
 * Component lines (with stock) of the latest active BOM of each product id.
 * pp_product_bomline → to-one expand of M_Product_ID (allowed); stock joined
 * by the caller from the same storage map as the parents.
 */
async function fetchBomLines(event: any, token: string, productIds: number[]): Promise<Map<number, { productId: number; name: string; value: string; qtyBom: number; line: number }[]>> {
  const out = new Map<number, { productId: number; name: string; value: string; qtyBom: number; line: number }[]>()
  if (!productIds.length) return out
  const bomByProduct = new Map<number, number>()
  await forEachChunk(chunk(productIds, CHUNK_SIZE), async (idChunk) => {
    const filter = `M_Product_ID in (${idChunk.join(',')}) and IsActive eq true`
    const res: any = await fetchHelper(event, `models/pp_product_bom?$filter=${string.urlEncode(filter)}&$orderby=${string.urlEncode('PP_Product_BOM_ID desc')}&$top=${idChunk.length * 5}`, 'GET', token, null)
    for (const b of (res?.records || [])) {
      const pid = Number(b?.M_Product_ID?.id)
      if (pid && !bomByProduct.has(pid)) bomByProduct.set(pid, Number(b.id))
    }
  })
  const bomIds = [...bomByProduct.values()]
  if (!bomIds.length) return out
  const productOfBom = new Map<number, number>([...bomByProduct].map(([pid, bid]) => [bid, pid]))
  await forEachChunk(chunk(bomIds, CHUNK_SIZE), async (bomChunk) => {
    const filter = `PP_Product_BOM_ID in (${bomChunk.join(',')}) and IsActive eq true`
    const res: any = await fetchHelper(event, `models/pp_product_bomline?$filter=${string.urlEncode(filter)}&$orderby=${string.urlEncode('Line asc')}&$top=2000&$expand=${string.urlEncode('M_Product_ID($select=Name,Value)')}`, 'GET', token, null)
    for (const l of (res?.records || [])) {
      const pid = productOfBom.get(Number(l?.PP_Product_BOM_ID?.id))
      const comp = l?.M_Product_ID || {}
      const cid = Number(comp?.id)
      if (!pid || !cid) continue
      const ident = String(comp?.identifier || '')
      if (!out.has(pid)) out.set(pid, [])
      out.get(pid)!.push({ productId: cid, name: comp?.Name || ident.split('_').slice(2).join('_') || ident, value: comp?.Value || ident.split('_')[1] || '', qtyBom: Number(l?.QtyBOM ?? 0), line: Number(l?.Line ?? 0) })
    }
  })
  for (const list of out.values()) list.sort((a, b) => a.line - b.line)
  return out
}

export async function enrichWithProducts(
  event: any,
  token: string,
  orgId: number,
  productIds: number[],
  priceListId: string | number
): Promise<Map<number, ProductEnrichment>> {
  const out = new Map<number, ProductEnrichment>()
  const ids = [...new Set(productIds.filter(id => Number.isFinite(Number(id)) && Number(id) > 0).map(Number))]
  if (ids.length === 0) return out

  const products = new Map<number, any>()
  const [priceListVersionId, returnLocatorIds] = await Promise.all([
    resolveActivePriceListVersionId(event, token, priceListId),
    getReturnLocatorIds(event, token, orgId),
    forEachChunk(chunk(ids, CHUNK_SIZE), async (idChunk) => {
      const filter = `M_Product_ID in (${idChunk.join(',')})`
      const res: any = await fetchHelper(event, `models/m_product?$filter=${string.urlEncode(filter)}&$top=${idChunk.length}`, 'GET', token, null)
      for (const p of (res?.records || [])) products.set(Number(p.id), p)
    })
  ])
  // BOM products: component lines first, so their stock comes with the same storage query.
  const bomLines = await fetchBomLines(event, token, ids.filter(id => isBomFlag(products.get(id))))
  const componentIds = [...new Set([...bomLines.values()].flat().map(c => c.productId))].filter(cid => !ids.includes(cid))
  const [storageRows, prices] = await Promise.all([
    fetchStorageRowsForProducts(event, token, [...ids, ...componentIds]),
    fetchPricesForProducts(event, token, ids, priceListVersionId)
  ])
  const storageByProduct = groupStorageByProduct(storageRows)
  const stockOf = (id: number) => {
    let onHand = 0, returnOnHand = 0
    for (const s of (storageByProduct.get(id) || [])) {
      const qty = Number(s?.QtyOnHand ?? 0)
      const locId = Number(s?.M_Locator_ID?.id ?? s?.M_Locator_ID)
      if (returnLocatorIds.has(locId)) returnOnHand += qty; else onHand += qty
    }
    return { onHand, returnOnHand }
  }
  for (const id of ids) {
    const p = products.get(id)
    const { onHand, returnOnHand } = stockOf(id)
    out.set(id, {
      productId: id,
      productName: p?.Name || '',
      productValue: p?.Value || p?.SKU || '',
      asin: p?.asin || null,
      strapiProductDocumentId: p?.Strapi_Product_documentId || null,
      isBom: p ? isBomFlag(p) : false,
      bomComponents: (bomLines.get(id) || []).map(c => ({ productId: c.productId, name: c.name, value: c.value, qtyBom: c.qtyBom, ...stockOf(c.productId) })),
      internalPrice: prices.get(id) ?? null,
      internalQtyOnHand: p ? onHand : null,
      returnQtyOnHand: returnOnHand
    })
  }
  return out
}
