/**
 * Unit tracking on goods receipt — serial numbers and charges (lot + MHD) as
 * native iDempiere M_AttributeSetInstance (ASI) rows.
 *
 * Ported from the blut24 branch (server/utils/bloodUnit.ts, lieferschein/import),
 * generalised for the LogShip catalogue:
 *   - a product is "tracked" when m_product.M_AttributeSet_ID points at one of the
 *     three LogShip sets below (created lazily, by Name, all fields optional);
 *   - a SERIAL is one unit  → one qty-1 m_inoutline carrying its own ASI (SerNo);
 *   - a CHARGE is one batch → one qty-n m_inoutline carrying a shared ASI (Lot +
 *     GuaranteeDate = MHD). Identical (set, org, lot, mhd) charges are REUSED so one
 *     physical batch never fragments into several m_storageonhand rows.
 *
 * REST facts this module relies on (verified against prod, see plan):
 *   - writes are lowercase-first (`serNo`, `lot`, `guaranteeDate`, `description`),
 *     reads come back PascalCase (`SerNo`, `Lot`, `GuaranteeDate`);
 *   - iDempiere does NOT build the ASI Description over REST — we send it;
 *   - `SerNo in ('a','b')` and `M_AttributeSetInstance_ID in (1,2)` filters work;
 *   - m_storageonhand has a composite PK: no `id` (use `uid`), always pass `$orderby`;
 *   - the OData not-equal operator is `neq`, date literals are unquoted.
 */
import fetchHelper from './fetchHelper'

export type TrackingMode = 'none' | 'serial' | 'charge' | 'mhd' | 'both'
export interface TrackingFlags { isSerNo: boolean; isLot: boolean; isGuaranteeDate: boolean }
export interface TrackingSet {
  id: number
  name: string
  mode: TrackingMode
  flags: TrackingFlags
  /** true for the three sets this module owns (name starts with "LogShip ") */
  isLogShip: boolean
}

export const TRACKING_SET_DEFS: Record<Exclude<TrackingMode, 'none'>, {
  name: string; description: string
  isSerNo: boolean; isLot: boolean; isGuaranteeDate: boolean; useGuaranteeDateForMPolicy: boolean
}> = {
  serial: {
    name: 'LogShip Seriennummer',
    description: 'LogShip — Seriennummer je Einheit (eine Wareneingangsposition pro Stück).',
    isSerNo: true, isLot: false, isGuaranteeDate: false, useGuaranteeDateForMPolicy: false
  },
  charge: {
    name: 'LogShip Charge',
    description: 'LogShip — Charge (Lot) mit MHD; FEFO über UseGuaranteeDateForMPolicy.',
    isSerNo: false, isLot: true, isGuaranteeDate: true, useGuaranteeDateForMPolicy: true
  },
  mhd: {
    name: 'LogShip MHD',
    description: 'LogShip — nur MHD (kein Lot); FEFO über UseGuaranteeDateForMPolicy.',
    isSerNo: false, isLot: false, isGuaranteeDate: true, useGuaranteeDateForMPolicy: true
  },
  both: {
    name: 'LogShip Seriennummer + Charge',
    description: 'LogShip — Seriennummer je Einheit plus Charge (Lot) und MHD.',
    isSerNo: true, isLot: true, isGuaranteeDate: true, useGuaranteeDateForMPolicy: true
  }
}

/** Client → server payload on a receive line (`line.tracking`). */
export interface LineTrackingPayload {
  attributeSetId: number
  /** units with a serial; lot/mhd optional (set "both") */
  serials?: Array<string | { serNo: string; lot?: string | null; mhd?: string | null }>
  /** batches without serials */
  charges?: Array<{ lot?: string | null; mhd?: string | null; qty: number }>
}

export interface ExpandResult {
  ok: boolean
  lines: any[]
  warnings: string[]
  errors: { productId: number | null; message: string }[]
  createdAsiIds: number[]
}

// --- small helpers ----------------------------------------------------------

const enc = encodeURIComponent
// quoted OData string literal (the repo's `odataQuote` in odataHelper.ts only escapes)
const odataStr = (s: any) => `'${String(s ?? '').replace(/'/g, "''")}'`
const truthy = (v: any) => v === true || v === 'Y' || v === 'true'

/** ISO-normalise yyyymmdd / ISO-ish / dd.mm.yyyy. Returns '' for anything else. */
export const isoDate = (raw: any): string => {
  const s = String(raw ?? '').trim()
  if (!s) return ''
  if (/^\d{8}$/.test(s)) return `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}`
  if (/^\d{4}-\d{2}-\d{2}/.test(s)) return s.slice(0, 10)
  const m = s.match(/^(\d{1,2})[./-](\d{1,2})[./-](\d{2}|\d{4})$/)
  if (m) {
    const y = m[3].length === 2 ? '20' + m[3] : m[3]
    return `${y}-${m[2].padStart(2, '0')}-${m[1].padStart(2, '0')}`
  }
  return ''
}

const addDays = (iso: string, days: number) => {
  const d = new Date(`${iso}T00:00:00Z`)
  d.setUTCDate(d.getUTCDate() + days)
  return d.toISOString().slice(0, 10)
}

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
}

/** Run `fn` over `items` with at most `limit` in flight (order of results preserved). */
const mapPool = async <T, R>(items: T[], limit: number, fn: (item: T, index: number) => Promise<R>): Promise<R[]> => {
  const results: R[] = new Array(items.length)
  let next = 0
  const worker = async () => {
    while (next < items.length) {
      const i = next++
      results[i] = await fn(items[i], i)
    }
  }
  await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker))
  return results
}

export const modeFromFlags = (f: TrackingFlags): TrackingMode => {
  const batch = f.isLot || f.isGuaranteeDate
  if (f.isSerNo && batch) return 'both'
  if (f.isSerNo) return 'serial'
  if (f.isLot) return 'charge'
  if (f.isGuaranteeDate) return 'mhd'
  return 'none'
}

const toTrackingSet = (r: any): TrackingSet => {
  const flags: TrackingFlags = {
    isSerNo: truthy(r?.IsSerNo ?? r?.isSerNo),
    isLot: truthy(r?.IsLot ?? r?.isLot),
    isGuaranteeDate: truthy(r?.IsGuaranteeDate ?? r?.isGuaranteeDate)
  }
  const name = String(r?.Name ?? r?.name ?? '').trim()
  return { id: Number(r?.id), name, mode: modeFromFlags(flags), flags, isLogShip: name.startsWith('LogShip ') }
}

// --- attribute-set cache ------------------------------------------------------
// Resolving the sets costs one REST call (four POSTs the very first time); the
// receive pages and the products grid ask for flags on every product, so keep
// them per process with a TTL. Single-flight so two concurrent first requests
// cannot both create the sets.

const TTL_MS = 10 * 60 * 1000
const g = globalThis as any
interface SetCache {
  byMode: Record<'serial' | 'charge' | 'mhd' | 'both', TrackingSet> | null
  expires: number
  byId: Map<number, { set: TrackingSet; expires: number }>
  inflight: Promise<Record<'serial' | 'charge' | 'mhd' | 'both', TrackingSet>> | null
}
const cache: SetCache = g.__logshipTrackingSets ?? (g.__logshipTrackingSets = {
  byMode: null, expires: 0, byId: new Map(), inflight: null
})

/** Drop the cache (tests / after manual edits of the sets in iDempiere). */
export const resetTrackingSetCache = () => { cache.byMode = null; cache.expires = 0; cache.byId.clear() }

/**
 * Token used to CREATE the shared master data (the three sets live in org 0 so
 * every organisation sees them). A normal staff session may lack org-0 write
 * access, so the service token is preferred when configured.
 */
const masterDataToken = (token: string) => {
  try {
    const config = useRuntimeConfig()
    return (config.api as any)?.idempieretoken || token
  } catch { return token }
}

const ALL_MODES = ['serial', 'charge', 'mhd', 'both'] as const

export const ensureTrackingSets = async (event: any, token: string) => {
  // a cache written by an older build may lack a mode → re-resolve instead of trusting it
  if (cache.byMode && cache.expires > Date.now() && ALL_MODES.every((m) => cache.byMode![m])) return cache.byMode
  if (cache.inflight) return cache.inflight
  cache.inflight = (async () => {
    try {
      const res: any = await fetchHelper(event,
        `models/m_attributeset?$filter=${enc("startswith(Name,'LogShip ')")}&$top=20`, 'GET', token, null)
      const rows: any[] = res?.records ?? []
      const byMode: any = {}
      for (const mode of ALL_MODES) {
        const def = TRACKING_SET_DEFS[mode]
        let row = rows.find((r) => String(r?.Name ?? r?.name ?? '').trim() === def.name)
        if (!row) {
          row = await fetchHelper(event, 'models/m_attributeset', 'POST', masterDataToken(token), {
            AD_Org_ID: 0,
            name: def.name,
            description: def.description,
            isSerNo: def.isSerNo,
            isSerNoMandatory: false,
            isLot: def.isLot,
            isLotMandatory: false,
            isGuaranteeDate: def.isGuaranteeDate,
            isGuaranteeDateMandatory: false,
            useGuaranteeDateForMPolicy: def.useGuaranteeDateForMPolicy,
            mandatoryType: 'N',
            isInstanceAttribute: true,
            isActive: true,
            tableName: 'M_AttributeSet'
          })
          if (!row?.id) throw new Error(`Attribut-Set "${def.name}" konnte nicht angelegt werden`)
        }
        const set = toTrackingSet(row)
        byMode[mode] = set
        cache.byId.set(set.id, { set, expires: Date.now() + TTL_MS })
      }
      cache.byMode = byMode
      cache.expires = Date.now() + TTL_MS
      return byMode as Record<'serial' | 'charge' | 'mhd' | 'both', TrackingSet>
    } finally {
      cache.inflight = null
    }
  })()
  return cache.inflight
}

/** Flags of ANY attribute set (LogShip or custom); null for 0 / missing. Cached. */
export const getTrackingSet = async (event: any, token: string, setId: any): Promise<TrackingSet | null> => {
  const id = Number(setId?.id ?? setId) || 0
  if (!id) return null
  const hit = cache.byId.get(id)
  if (hit && hit.expires > Date.now()) return hit.set
  try {
    const row: any = await fetchHelper(event, `models/m_attributeset/${id}`, 'GET', token, null)
    if (!row?.id) return null
    const set = toTrackingSet(row)
    cache.byId.set(id, { set, expires: Date.now() + TTL_MS })
    return set
  } catch {
    return null
  }
}

/** The set for a requested mode (creating the sets on first use). */
export const trackingSetForMode = async (event: any, token: string, mode: TrackingMode): Promise<TrackingSet | null> => {
  if (mode === 'none') return null
  const sets = await ensureTrackingSets(event, token)
  return sets[mode] ?? null
}

// --- ASI create / lookup --------------------------------------------------------

export const buildAsiDescription = (u: { serNo?: string | null; lot?: string | null; guaranteeDate?: string | null }) => {
  const parts: string[] = []
  if (u.serNo) parts.push(`SN ${u.serNo}`)
  if (u.lot) parts.push(`Lot ${u.lot}`)
  if (u.guaranteeDate) parts.push(`MHD ${u.guaranteeDate}`)
  return parts.join(' · ')
}

export const createAsi = async (event: any, token: string, orgId: number,
  u: { setId: number; serNo?: string | null; lot?: string | null; guaranteeDate?: string | null }): Promise<number> => {
  const serNo = String(u.serNo ?? '').trim()
  const lot = String(u.lot ?? '').trim()
  const guaranteeDate = isoDate(u.guaranteeDate)
  const payload: any = {
    AD_Org_ID: { id: Number(orgId), tableName: 'AD_Org' },
    M_AttributeSet_ID: { id: Number(u.setId), tableName: 'M_AttributeSet' },
    description: buildAsiDescription({ serNo, lot, guaranteeDate }),
    isActive: true,
    tableName: 'M_AttributeSetInstance'
  }
  if (serNo) payload.serNo = serNo
  if (lot) payload.lot = lot
  if (guaranteeDate) payload.guaranteeDate = guaranteeDate
  const asi: any = await fetchHelper(event, 'models/m_attributesetinstance', 'POST', token, payload)
  if (!asi?.id) throw new Error('M_AttributeSetInstance konnte nicht angelegt werden')
  return Number(asi.id)
}

/** ASI ids (of the given list) that currently carry stock somewhere. */
export const asiIdsInStock = async (event: any, token: string, asiIds: number[]): Promise<Set<number>> => {
  const out = new Set<number>()
  const ids = Array.from(new Set(asiIds.map(Number).filter((n) => n > 0)))
  for (const part of chunk(ids, 100)) {
    const res: any = await fetchHelper(event,
      `models/m_storageonhand?$filter=${enc(`M_AttributeSetInstance_ID in (${part.join(',')}) AND QtyOnHand gt 0`)}`
      + `&$orderby=${enc('M_AttributeSetInstance_ID asc')}&$top=1000`, 'GET', token, null)
    for (const r of res?.records ?? []) {
      const id = Number(r?.M_AttributeSetInstance_ID?.id ?? r?.M_AttributeSetInstance_ID) || 0
      if (id) out.add(id)
    }
  }
  return out
}

export interface KnownSerial { serNo: string; asiId: number; setId: number; lot: string; mhd: string; inStock: boolean }

/**
 * Existing ASIs of this org for the given serials. When a serial has several
 * ASIs (re-received returns), an in-stock one wins (it blocks), else the newest.
 */
export const findAsisBySerials = async (event: any, token: string, orgId: number, serNos: string[]): Promise<Map<string, KnownSerial>> => {
  const out = new Map<string, KnownSerial>()
  const wanted = Array.from(new Set(serNos.map((s) => String(s ?? '').trim()).filter(Boolean))).slice(0, 500)
  if (!wanted.length) return out
  const rows: any[] = []
  for (const part of chunk(wanted, 25)) {
    const res: any = await fetchHelper(event,
      `models/m_attributesetinstance?$filter=${enc(`AD_Org_ID eq ${Number(orgId)} AND SerNo in (${part.map(odataStr).join(',')})`)}`
      + `&$orderby=${enc('M_AttributeSetInstance_ID desc')}&$top=500`, 'GET', token, null)
    rows.push(...(res?.records ?? []))
  }
  if (!rows.length) return out
  const inStock = await asiIdsInStock(event, token, rows.map((r) => Number(r.id)))
  for (const r of rows) {
    const serNo = String(r?.SerNo ?? r?.serNo ?? '').trim()
    if (!serNo) continue
    const cand: KnownSerial = {
      serNo,
      asiId: Number(r.id),
      setId: Number(r?.M_AttributeSet_ID?.id ?? r?.M_AttributeSet_ID) || 0,
      lot: String(r?.Lot ?? r?.lot ?? '').trim(),
      mhd: String(r?.GuaranteeDate ?? r?.guaranteeDate ?? '').slice(0, 10),
      inStock: inStock.has(Number(r.id))
    }
    const prev = out.get(serNo)
    if (!prev || (cand.inStock && !prev.inStock) || (cand.inStock === prev.inStock && cand.asiId > prev.asiId)) {
      out.set(serNo, cand)
    }
  }
  return out
}

/** Reuse lookup for a batch ASI: same set, org, lot and MHD, no serial. */
export const findChargeAsi = async (event: any, token: string, orgId: number, setId: number,
  lot: string, mhd: string): Promise<number | null> => {
  const lotV = String(lot ?? '').trim()
  const mhdV = isoDate(mhd)
  if (!lotV && !mhdV) return null
  let filter = `M_AttributeSet_ID eq ${Number(setId)} AND AD_Org_ID eq ${Number(orgId)}`
  if (lotV) filter += ` AND Lot eq ${odataStr(lotV)}`
  else filter += ` AND GuaranteeDate ge ${mhdV} AND GuaranteeDate lt ${addDays(mhdV, 1)}`
  const res: any = await fetchHelper(event,
    `models/m_attributesetinstance?$filter=${enc(filter)}&$orderby=${enc('M_AttributeSetInstance_ID desc')}&$top=50`,
    'GET', token, null)
  for (const r of res?.records ?? []) {
    if (String(r?.SerNo ?? '').trim()) continue
    if (String(r?.Lot ?? '').trim() !== lotV) continue
    if (String(r?.GuaranteeDate ?? '').slice(0, 10) !== mhdV) continue
    return Number(r.id)
  }
  return null
}

// --- line expansion -------------------------------------------------------------

const normSerial = (s: any) => String(s ?? '').replace(/[\r\n]/g, '').replace(/\s+/g, ' ').trim()

/**
 * Turn receive lines that carry `tracking` into iDempiere lines: one qty-1 line
 * per serial, one line per charge group, the untracked remainder as a plain
 * line. Validation (over-count, serial already in stock, duplicates) runs
 * BEFORE any ASI is created, so an error response leaves nothing behind.
 */
export const expandTrackedLines = async (event: any, token: string, orgId: number, inOutLines: any[]): Promise<ExpandResult> => {
  const result: ExpandResult = { ok: true, lines: [], warnings: [], errors: [], createdAsiIds: [] }
  const org = Number(orgId)

  type Unit = { serNo: string; lot: string; mhd: string }
  type Prep = {
    base: any; productId: number | null; qty: number; setId: number
    units: Unit[]; charges: { lot: string; mhd: string; qty: number }[]; remainder: number
  }
  const prepared: (Prep | { passthrough: any })[] = []
  const seenSerials = new Map<string, number | null>()

  for (const raw of inOutLines ?? []) {
    const t: LineTrackingPayload | undefined = raw?.tracking
    const { tracking: _drop, ...base } = raw ?? {}
    if (!t || !t.attributeSetId) { prepared.push({ passthrough: base }); continue }

    const productId = Number(base?.M_Product_ID?.id ?? base?.M_Product_ID) || null
    const qty = Number(base?.movementQty ?? base?.qtyEntered ?? 0)
    const units: Unit[] = []
    for (const s of t.serials ?? []) {
      const serNo = normSerial(typeof s === 'string' ? s : s?.serNo)
      if (!serNo) continue
      if (seenSerials.has(serNo)) {
        result.errors.push({ productId, message: `Seriennummer ${serNo} ist doppelt erfasst` })
        continue
      }
      seenSerials.set(serNo, productId)
      units.push({
        serNo,
        lot: typeof s === 'string' ? '' : String(s?.lot ?? '').trim(),
        mhd: typeof s === 'string' ? '' : isoDate(s?.mhd)
      })
    }
    const charges = (t.charges ?? [])
      .map((c) => ({ lot: String(c?.lot ?? '').trim(), mhd: isoDate(c?.mhd), qty: Number(c?.qty ?? 0) }))
      .filter((c) => c.qty > 0)
    const used = units.length + charges.reduce((a, c) => a + c.qty, 0)
    if (used > qty) {
      result.errors.push({ productId, message: `Erfasste Einheiten (${used}) übersteigen die Menge (${qty})` })
    }
    prepared.push({ base, productId, qty, setId: Number(t.attributeSetId), units, charges, remainder: qty - used })
  }

  // Serial duplicates against existing stock — one lookup for all lines.
  const allSerials = prepared.flatMap((p) => 'units' in p ? p.units.map((u) => u.serNo) : [])
  if (allSerials.length > 500) {
    result.errors.push({ productId: null, message: `Zu viele Seriennummern in einer Buchung (${allSerials.length}, max. 500)` })
  }
  const known = allSerials.length && allSerials.length <= 500
    ? await findAsisBySerials(event, token, org, allSerials)
    : new Map<string, KnownSerial>()
  for (const [serNo, k] of known) {
    if (k.inStock) result.errors.push({ productId: seenSerials.get(serNo) ?? null, message: `Seriennummer ${serNo} ist bereits im Bestand` })
  }
  if (result.errors.length) { result.ok = false; return result }

  // Create / reuse ASIs (reads first, then a bounded pool of POSTs).
  const chargeAsiCache = new Map<string, number>()
  const resolveCharge = async (setId: number, lot: string, mhd: string): Promise<number | null> => {
    if (!lot && !mhd) return null
    const key = `${setId}|${lot}|${mhd}`
    if (chargeAsiCache.has(key)) return chargeAsiCache.get(key)!
    let id = await findChargeAsi(event, token, org, setId, lot, mhd)
    if (!id) {
      id = await createAsi(event, token, org, { setId, lot, guaranteeDate: mhd })
      result.createdAsiIds.push(id)
    }
    chargeAsiCache.set(key, id)
    return id
  }

  let lineNo = 0
  const nextLine = () => { lineNo += 10; return lineNo }
  const withAsi = (base: any, qty: number, asiId: number | null, extra: any = {}) => ({
    ...base,
    ...extra,
    line: nextLine(),
    qtyEntered: qty,
    movementQty: qty,
    ...(asiId ? { M_AttributeSetInstance_ID: { id: asiId, tableName: 'M_AttributeSetInstance' } } : {})
  })

  for (const p of prepared) {
    if ('passthrough' in p) { result.lines.push({ ...p.passthrough, line: nextLine() }); continue }

    // serial units — pool of 4 POSTs
    const serialAsiIds = await mapPool(p.units, 4, async (u) => {
      const k = known.get(u.serNo)
      if (k && !k.inStock && k.setId === p.setId && k.lot === u.lot && k.mhd === u.mhd) return k.asiId
      const id = await createAsi(event, token, org, { setId: p.setId, serNo: u.serNo, lot: u.lot, guaranteeDate: u.mhd })
      result.createdAsiIds.push(id)
      return id
    })
    p.units.forEach((u, i) => {
      result.lines.push(withAsi(p.base, 1, serialAsiIds[i], { description: buildAsiDescription({ serNo: u.serNo, lot: u.lot, guaranteeDate: u.mhd }) }))
    })

    // charge groups
    for (const c of p.charges) {
      const asiId = await resolveCharge(p.setId, c.lot, c.mhd)
      if (!asiId) {
        result.warnings.push(`Charge ohne Lot und MHD wurde ohne Tracking gebucht (${c.qty} Stk)`)
        result.lines.push(withAsi(p.base, c.qty, null))
        continue
      }
      result.lines.push(withAsi(p.base, c.qty, asiId, { description: buildAsiDescription({ lot: c.lot, guaranteeDate: c.mhd }) }))
    }

    // untracked remainder
    if (p.remainder > 0) {
      if (p.units.length || p.charges.length) {
        result.warnings.push(`${p.remainder} Stk ohne Seriennummer/Charge gebucht (Artikel ${p.productId ?? '?'})`)
      }
      result.lines.push(withAsi(p.base, p.remainder, null))
    }
  }

  return result
}

/** Best-effort cleanup of ASIs created for a booking that then failed. */
export const deleteAsisQuietly = async (event: any, token: string, asiIds: number[]) => {
  for (const id of asiIds) {
    try { await fetchHelper(event, `models/m_attributesetinstance/${id}`, 'DELETE', token, null) } catch { /* orphan is harmless */ }
  }
}

// --- reading units ----------------------------------------------------------------

export interface AsiInfo { id: number; setId: number; serNo: string; lot: string; mhd: string; description: string }

/** Full ASI records for a list of ids (chunked `in (...)`). */
export const fetchAsis = async (event: any, token: string, asiIds: number[]): Promise<Map<number, AsiInfo>> => {
  const out = new Map<number, AsiInfo>()
  const ids = Array.from(new Set(asiIds.map(Number).filter((n) => n > 0)))
  for (const part of chunk(ids, 100)) {
    const res: any = await fetchHelper(event,
      `models/m_attributesetinstance?$filter=${enc(`M_AttributeSetInstance_ID in (${part.join(',')})`)}&$top=${part.length}`,
      'GET', token, null)
    for (const r of res?.records ?? []) {
      out.set(Number(r.id), {
        id: Number(r.id),
        setId: Number(r?.M_AttributeSet_ID?.id ?? r?.M_AttributeSet_ID) || 0,
        serNo: String(r?.SerNo ?? '').trim(),
        lot: String(r?.Lot ?? '').trim(),
        mhd: String(r?.GuaranteeDate ?? '').slice(0, 10),
        description: String(r?.Description ?? '')
      })
    }
  }
  return out
}

export const daysUntil = (iso: string): number | null => {
  if (!iso) return null
  const today = new Date(); today.setHours(0, 0, 0, 0)
  const d = new Date(`${iso}T00:00:00`)
  if (Number.isNaN(d.getTime())) return null
  return Math.round((d.getTime() - today.getTime()) / 86400000)
}
