import {
  deleteCategoriesNotIn, deleteStaleSourceItems, getCursor, isRunning, listStores, recordRun, skusByProductIds, softDeleteSku, replaceAttributeSet, setCursor,
  softDeleteProductsNotIn, upsertAttributes, upsertStores, upsertCategories, upsertProducts, upsertProductStoreData, upsertSalable, upsertSourceItems,
  type CategoryRow, type CategoryStoreRow, type Db, type SourceItemRow,
} from '@znet/catalog-db'
import type { Logger } from '@znet/shared'
import type { MagentoApi, Product } from '@znet/magento'
import { mapAttribute, mapCategory, mapCategoryStore, mapProduct, mapProductStore, mapStores, parseMagentoDate, toMagentoDate } from './mappers.js'

export interface SyncContext {
  db: Db
  magento: MagentoApi
  log: Logger
  /** Store view codes to sync; empty = all active. */
  storeCodes: string[]
  stockId: number
}

/** Re-read this much before the cursor to tolerate clock skew and same-second updates. */
const CURSOR_OVERLAP_MS = 2 * 60_000
const DEFAULT_SCOPE = 'all'
/** Ids per `entity_id in (...)` lookup: ~10 URL bytes each, so 300 keeps the request line ~3 KB. */
const CHILD_LOOKUP_CHUNK = 300

/** Store views to sync, as `{ code, storeId }`, excluding the admin scope. */
async function storeScopes(ctx: SyncContext) {
  const all = (await listStores(ctx.db)).filter(s => s.storeId !== 0 && s.isActive)
  return ctx.storeCodes.length ? all.filter(s => ctx.storeCodes.includes(s.code)) : all
}

// ── Reference data ─────────────────────────────────────────────────────────

export function syncReference(ctx: SyncContext) {
  return recordRun(ctx.db, 'sync-reference', async () => {
    const { magento, db } = ctx
    const [views, configs, websites] = await Promise.all([magento.storeViews(), magento.storeConfigs(), magento.websites()])
    await upsertStores(db, mapStores(views, configs, websites))

    let attributeCount = 0
    for await (const page of magento.productAttributes()) {
      await upsertAttributes(db, page.map(mapAttribute))
      attributeCount += page.length
    }

    let setCount = 0
    for await (const page of magento.attributeSets()) {
      for (const set of page) {
        const members = await magento.attributeSetMembers(set.attribute_set_id)
        await replaceAttributeSet(
          db,
          { attributeSetId: set.attribute_set_id, name: set.attribute_set_name },
          members.map(m => ({ attributeCode: m.attribute_code, isRequired: Boolean(m.is_required) })),
        )
        setCount++
      }
    }

    // Categories: default scope defines the tree; each store view adds its own names/flags.
    const cats: CategoryRow[] = []
    const catStore: CategoryStoreRow[] = []
    for await (const page of magento.categories(DEFAULT_SCOPE)) {
      for (const c of page) {
        cats.push(mapCategory(c))
        catStore.push(mapCategoryStore(c, 0))
      }
    }
    for (const store of await storeScopes(ctx)) {
      for await (const page of magento.categories(store.code)) catStore.push(...page.map(c => mapCategoryStore(c, store.storeId)))
    }
    await upsertCategories(db, cats, catStore)
    const removedCategories = cats.length ? await deleteCategoriesNotIn(db, cats.map(c => c.categoryId)) : 0
    await setCursor(db, 'reference', null)

    return { stores: views.length, attributes: attributeCount, attributeSets: setCount, categories: cats.length, removedCategories }
  })
}

// ── Products ───────────────────────────────────────────────────────────────

/** Map configurable child ids → SKUs: local DB first, then one Magento lookup for the misses. */
async function resolveChildSkus(ctx: SyncContext, page: Product[]): Promise<Map<number, string>> {
  const ids = [...new Set(page.flatMap(p => p.extension_attributes?.configurable_product_links ?? []))]
  if (!ids.length) return new Map()
  const map = await skusByProductIds(ctx.db, ids)
  for (const p of page) map.set(p.id, p.sku)
  const missing = ids.filter(id => !map.has(id))
  // The `in` list goes into the GET query string; chunk it so the URL stays under Apache's
  // 8 KB request-line limit (414 otherwise — a cold full sync can miss thousands of children).
  for (let i = 0; i < missing.length; i += CHILD_LOOKUP_CHUNK) {
    const chunk = missing.slice(i, i + CHILD_LOOKUP_CHUNK)
    for await (const rows of ctx.magento.client.paginate<{ id: number; sku: string }>(
      '/V1/products',
      { filterGroups: [[{ field: 'entity_id', value: chunk, condition: 'in' }]], pageSize: CHILD_LOOKUP_CHUNK },
      { fields: 'items[id,sku],total_count' },
    )) {
      for (const r of rows) map.set(r.id, r.sku)
    }
  }
  return map
}

async function ingestDefaultScopePage(ctx: SyncContext, page: Product[]) {
  const childSkus = await resolveChildSkus(ctx, page)
  await upsertProducts(ctx.db, page.map(p => mapProduct(p, childSkus)))
}

/** Pull products changed since each scope's cursor. Default scope first so store rows have parents. */
export async function syncProductsIncremental(ctx: SyncContext): Promise<Record<string, number>> {
  // The hourly full sweep already covers everything; don't double the load on Magento.
  if (await isRunning(ctx.db, 'sync-products-full')) {
    ctx.log.info('full sweep in progress — skipping incremental run')
    return { skipped: 1 }
  }
  return recordRun(ctx.db, 'sync-products-incremental', async () => {
    const stats: Record<string, number> = {}
    const scopes = [{ code: DEFAULT_SCOPE, storeId: 0 }, ...(await storeScopes(ctx)).map(s => ({ code: s.code, storeId: s.storeId }))]

    for (const scope of scopes) {
      const stream = `products:${scope.code}`
      const cursor = await getCursor(ctx.db, stream)
      const since = cursor ? toMagentoDate(new Date(parseMagentoDate(cursor)!.getTime() - CURSOR_OVERLAP_MS)) : null
      let maxSeen = cursor
      let count = 0

      for await (const page of ctx.magento.productsUpdatedSince(scope.code, since)) {
        if (scope.storeId === 0) await ingestDefaultScopePage(ctx, page)
        else await upsertProductStoreData(ctx.db, page.map(p => mapProductStore(p, scope.storeId)))
        count += page.length
        for (const p of page) if (!maxSeen || p.updated_at > maxSeen) maxSeen = p.updated_at
        // Advance per page so a crash mid-sweep resumes near where it stopped.
        if (maxSeen) await setCursor(ctx.db, stream, maxSeen)
      }
      if (!count) await setCursor(ctx.db, stream, maxSeen)
      stats[stream] = count
      ctx.log.info({ stream, since, count }, 'incremental product sync')
    }
    return stats
  })
}

/** Full sweep of every scope + delete reconciliation. Run nightly and for the initial load. */
export function syncProductsFull(ctx: SyncContext) {
  return recordRun(ctx.db, 'sync-products-full', async () => {
    const stats: Record<string, number> = {}
    const startedAt = new Date()

    const seenIds: number[] = []
    for await (const page of ctx.magento.productIds()) seenIds.push(...page.map(p => p.id))
    stats.magentoProducts = seenIds.length

    const scopes = [{ code: DEFAULT_SCOPE, storeId: 0 }, ...(await storeScopes(ctx)).map(s => ({ code: s.code, storeId: s.storeId }))]
    for (const scope of scopes) {
      let count = 0
      let maxSeen: string | null = null
      for await (const page of ctx.magento.productsUpdatedSince(scope.code, null)) {
        if (scope.storeId === 0) await ingestDefaultScopePage(ctx, page)
        else await upsertProductStoreData(ctx.db, page.map(p => mapProductStore(p, scope.storeId)))
        count += page.length
        for (const p of page) if (!maxSeen || p.updated_at > maxSeen) maxSeen = p.updated_at
      }
      // Never move a cursor backwards past what incremental already saw.
      const prev = await getCursor(ctx.db, `products:${scope.code}`)
      await setCursor(ctx.db, `products:${scope.code}`, [prev, maxSeen, toMagentoDate(startedAt)].filter(Boolean).sort().at(-1)!)
      stats[`products:${scope.code}`] = count
    }

    stats.softDeleted = await softDeleteProductsNotIn(ctx.db, seenIds)
    ctx.log.info(stats, 'full product sync')
    return stats
  })
}

/** Refresh one SKU across all scopes, plus its live salable quantity. */
export function syncProductSku(ctx: SyncContext, sku: string) {
  return recordRun(ctx.db, 'sync-product-sku', async () => {
    const product = await ctx.magento.product(sku, DEFAULT_SCOPE)
    if (!product) {
      return { found: 0, storeRows: 0, softDeleted: await softDeleteSku(ctx.db, sku) }
    }
    await ingestDefaultScopePage(ctx, [product])
    let storeRows = 0
    for (const store of await storeScopes(ctx)) {
      const scoped = await ctx.magento.product(sku, store.code)
      if (scoped) storeRows += await upsertProductStoreData(ctx.db, [mapProductStore(scoped, store.storeId)])
    }
    const [qty, salable] = await Promise.all([ctx.magento.salableQty(sku, ctx.stockId), ctx.magento.isSalable(sku, ctx.stockId)])
    await upsertSalable(ctx.db, { sku, stockId: ctx.stockId, salableQty: qty, isSalable: salable })
    return { found: 1, storeRows, softDeleted: 0 }
  })
}

// ── Inventory ──────────────────────────────────────────────────────────────

/**
 * Full stock sweep. Magento has no updated_at on stock, so this is always a complete pass.
 * Uses MSI source items; falls back to legacy stock_item on product payloads when MSI is absent.
 */
export function syncInventory(ctx: SyncContext) {
  return recordRun(ctx.db, 'sync-inventory', async () => {
    const sweepStartedAt = new Date()
    let count = 0
    for await (const page of ctx.magento.sourceItems()) {
      await upsertSourceItems(ctx.db, page.map(s => ({ sku: s.sku, sourceCode: s.source_code, quantity: s.quantity, inStock: s.status === 1, syncedAt: new Date() })))
      count += page.length
    }

    let mode = 1 // 1 = MSI, 0 = legacy
    if (count === 0) {
      mode = 0
      for await (const page of ctx.magento.client.paginate<Product>(
        '/V1/products',
        { pageSize: 500 },
        { fields: 'items[sku,type_id,extension_attributes[stock_item[qty,is_in_stock]]],total_count' },
      )) {
        const rows: SourceItemRow[] = page
          .filter(p => p.extension_attributes?.stock_item)
          .map(p => ({ sku: p.sku, sourceCode: 'default', quantity: p.extension_attributes!.stock_item!.qty ?? 0, inStock: p.extension_attributes!.stock_item!.is_in_stock, syncedAt: new Date() }))
        await upsertSourceItems(ctx.db, rows)
        count += rows.length
      }
    }

    // Only prune when the sweep returned data — an empty/failed sweep must not wipe stock.
    const removed = count ? await deleteStaleSourceItems(ctx.db, sweepStartedAt) : 0
    await setCursor(ctx.db, 'inventory', toMagentoDate(sweepStartedAt))
    return { sourceItems: count, removed, msi: mode }
  })
}

export const JOBS = {
  'sync-reference': syncReference,
  'sync-products-incremental': syncProductsIncremental,
  'sync-products-full': syncProductsFull,
  'sync-inventory': syncInventory,
} as const

export type JobName = keyof typeof JOBS
