// DPD WebConnect helper (LoginService 2.0 + ShipmentService 4.5)
//
// LoginService is REST (JSON), ShipmentService is SOAP-only.
// Token is cached for 1 business day per DPD policy; max 10 logins/account/day.
// Shipment service is limited to 60 calls/minute and must be sequential.
//
// Credentials are resolved in this order:
//   1. `credentials` argument — from C_OrderSource when DPD_USE_CUSTOM=true
//      (DPDURL, DPDDELISID, DPDPASSWORD, DPDCUSTOMERNUMBER, DPDDEPOT)
//   2. runtimeConfig env vars (DPDURL, DPDDELISID, DPDPASSWORD, etc.)
//      — set in .env for the global default account
//
// If neither source provides delisId + password, dpdGetAuth throws.

const TOKEN_COOKIE_PREFIX = 'logship_dpd_'
const TOKEN_TTL_SECONDS = 23 * 60 * 60 // 23h — DPD says ~1 business day, refresh after 03:01
const DEFAULT_BASE_URL = 'https://public-ws.dpd.com/services'

export interface DpdCredentials {
  baseUrl: string
  delisId: string
  password: string
  customerNumber: string
  depot?: string
}

export interface DpdAuth {
  authToken: string
  depot: string
  delisId: string
  customerUid: string
}

// ----------------------------------------------------------------------------
// Credential resolution
// ----------------------------------------------------------------------------

export function resolveDpdCredentials(custom?: Partial<DpdCredentials> | null): DpdCredentials {
  const config = useRuntimeConfig()
  const baseUrl = String(custom?.baseUrl || (config.api as any).dpdurl || DEFAULT_BASE_URL).replace(/\/+$/, '')
  const delisId = String(custom?.delisId || (config.api as any).dpddelisid || '')
  const password = String(custom?.password || (config.api as any).dpdpassword || '')
  const customerNumber = String(custom?.customerNumber || (config.api as any).dpdcustomernumber || '')
  const depot = String(custom?.depot || (config.api as any).dpddepot || '')

  if (!delisId || !password) {
    throw new Error(
      'DPD credentials missing. Either set DPDDELISID + DPDPASSWORD in .env (global default), ' +
      'or enable DPD_USE_CUSTOM on the C_OrderSource and populate DPDDELISID/DPDPASSWORD/DPDCUSTOMERNUMBER there.'
    )
  }

  return { baseUrl, delisId, password, customerNumber, depot }
}

// ----------------------------------------------------------------------------
// XML helpers
// ----------------------------------------------------------------------------

const XML_ESC: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;' }
export function xmlEscape(v: any): string {
  if (v === null || v === undefined) return ''
  return String(v).replace(/[&<>"']/g, c => XML_ESC[c])
}

// Tiny element extractor — DPD response shapes are well-defined, so a
// regex-based parser is enough and avoids pulling in a heavy XML lib.
// Matches the FIRST occurrence of <tag>...</tag> (namespace-agnostic).
export function xmlPick(xml: string, tag: string): string | null {
  const re = new RegExp(`<(?:[\\w-]+:)?${tag}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/(?:[\\w-]+:)?${tag}>`, 'i')
  const m = xml.match(re)
  return m ? m[1] : null
}

// Match ALL occurrences (for parcelLabelNumber list on multi-parcel responses)
export function xmlPickAll(xml: string, tag: string): string[] {
  const re = new RegExp(`<(?:[\\w-]+:)?${tag}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/(?:[\\w-]+:)?${tag}>`, 'gi')
  const out: string[] = []
  let m: RegExpExecArray | null
  while ((m = re.exec(xml)) !== null) out.push(m[1])
  return out
}

// ----------------------------------------------------------------------------
// LoginService (REST)
// ----------------------------------------------------------------------------

// LoginService is REST-callable but lives under /restservices/, while
// ShipmentService is SOAP-only and lives under /services/. The configured
// base URL is the SOAP root; rewrite to the REST root for login.
function toRestBaseUrl(soapBaseUrl: string): string {
  if (/\/restservices(?:\/|$)/.test(soapBaseUrl)) return soapBaseUrl
  return soapBaseUrl.replace(/\/services(?=\/|$)/, '/restservices')
}

async function dpdLoginRest(creds: DpdCredentials): Promise<DpdAuth> {
  const url = `${toRestBaseUrl(creds.baseUrl)}/LoginService/V2_0/getAuth`
  const res: any = await $fetch(url, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
    body: {
      delisId: creds.delisId,
      password: creds.password,
      messageLanguage: 'de_DE'
    }
  })

  const status = res?.status?.type
  if (status && status !== 'OK') {
    const code = res?.status?.code || 'LOGIN_FAIL'
    const msg = res?.status?.message || 'DPD login failed'
    const err: any = new Error(`DPD login error [${code}]: ${msg}`)
    err.dpdCode = code
    err.dpdMessage = msg
    throw err
  }

  const ret = res?.getAuthResponse?.return
  if (!ret?.authToken) {
    throw new Error('DPD login: no authToken in response')
  }

  return {
    authToken: String(ret.authToken),
    depot: String(ret.depot || creds.depot || ''),
    delisId: String(ret.delisId || creds.delisId),
    customerUid: String(ret.customerUid || creds.delisId)
  }
}

// ----------------------------------------------------------------------------
// Token caching (per delisId so multi-OrderSource setups don't clash)
// ----------------------------------------------------------------------------

function tokenCookieKey(delisId: string) {
  return `${TOKEN_COOKIE_PREFIX}${delisId}`
}

function readCachedAuth(event: any, delisId: string): DpdAuth | null {
  const raw = getCookie(event, tokenCookieKey(delisId))
  if (!raw) return null
  try {
    const parsed = JSON.parse(raw)
    if (!parsed?.authToken) return null
    return parsed
  } catch { return null }
}

function writeCachedAuth(event: any, auth: DpdAuth) {
  setCookie(event, tokenCookieKey(auth.delisId), JSON.stringify(auth), {
    maxAge: TOKEN_TTL_SECONDS,
    httpOnly: true,
    sameSite: 'lax'
  })
}

function clearCachedAuth(event: any, delisId: string) {
  setCookie(event, tokenCookieKey(delisId), '', { maxAge: 0 })
}

export async function dpdGetAuth(event: any, credentials?: Partial<DpdCredentials> | null, forceRefresh = false): Promise<{ auth: DpdAuth; creds: DpdCredentials }> {
  const creds = resolveDpdCredentials(credentials)
  if (!forceRefresh) {
    const cached = readCachedAuth(event, creds.delisId)
    if (cached) return { auth: cached, creds }
  }
  const auth = await dpdLoginRest(creds)
  // Apply OrderSource-level depot override if configured
  if (creds.depot) auth.depot = creds.depot
  writeCachedAuth(event, auth)
  return { auth, creds }
}

// ----------------------------------------------------------------------------
// ShipmentService (SOAP)
// ----------------------------------------------------------------------------

const SHIPMENT_NS = 'http://dpd.com/common/service/types/ShipmentService/4.4'
const AUTH_NS = 'http://dpd.com/common/service/types/Authentication/2.0'
const SOAP_NS = 'http://schemas.xmlsoap.org/soap/envelope/'

function buildShipmentEnvelope(auth: DpdAuth, storeOrdersBodyInner: string): string {
  return `<?xml version="1.0" encoding="UTF-8"?>
<soapenv:Envelope xmlns:soapenv="${SOAP_NS}" xmlns:ns="${AUTH_NS}" xmlns:ns1="${SHIPMENT_NS}">
 <soapenv:Header>
  <ns:authentication>
   <delisId>${xmlEscape(auth.delisId)}</delisId>
   <authToken>${xmlEscape(auth.authToken)}</authToken>
   <messageLanguage>de_DE</messageLanguage>
  </ns:authentication>
 </soapenv:Header>
 <soapenv:Body>
  <ns1:storeOrders>
${storeOrdersBodyInner}
  </ns1:storeOrders>
 </soapenv:Body>
</soapenv:Envelope>`
}

// Result shape from a successful storeOrders call. Mirrors the chunks of the
// DHL response that downstream code already knows how to consume.
export interface DpdShipmentResult {
  mpsId: string            // DPD shipment-level id  (~ DHL_Shipment_Code)
  parcelLabelNumbers: string[]   // tracking numbers, one per parcel
  labelPdfBase64: string   // first/primary label PDF (combined when splitByParcel=false)
  parcelLabels: Array<{ trackingNumber: string; pdfBase64: string }>  // per-parcel labels when splitByParcel=true
  faults: Array<{ code: string; message: string }>
  raw: string              // raw SOAP response for debugging
}

function parseFaults(xml: string): Array<{ code: string; message: string }> {
  const out: Array<{ code: string; message: string }> = []
  // <faults><faultCode>X</faultCode><message>Y</message></faults>
  const blockRe = /<(?:[\w-]+:)?faults(?:\s[^>]*)?>([\s\S]*?)<\/(?:[\w-]+:)?faults>/gi
  let m: RegExpExecArray | null
  while ((m = blockRe.exec(xml)) !== null) {
    const inner = m[1]
    out.push({
      code: xmlPick(inner, 'faultCode') || '',
      message: xmlPick(inner, 'message') || ''
    })
  }
  return out
}

function parseSoapFault(xml: string): { code: string; message: string } | null {
  const faultBlock = xmlPick(xml, 'Fault')
  if (!faultBlock) return null
  return {
    code: xmlPick(faultBlock, 'faultcode') || xmlPick(faultBlock, 'errorCode') || 'SOAP_FAULT',
    message: xmlPick(faultBlock, 'faultstring') || xmlPick(faultBlock, 'errorMessage') || 'Unknown SOAP fault'
  }
}

function parseStoreOrdersResponse(xml: string): DpdShipmentResult {
  // In the response, the per-shipment block is named <shipmentResponses>
  // (field name from storeOrdersResponseType, not the type name "shipmentResponse").
  // Try the plural first, fall back to singular just in case.
  const shipmentResp = xmlPick(xml, 'shipmentResponses') || xmlPick(xml, 'shipmentResponse') || ''
  const mpsId = xmlPick(shipmentResp, 'mpsId') || ''
  const faults = parseFaults(shipmentResp)

  // Each parcel arrives in its own <parcelInformation> child of shipmentResponses
  const parcelInfoBlocks: string[] = []
  const piRe = /<(?:[\w-]+:)?parcelInformation(?:\s[^>]*)?>([\s\S]*?)<\/(?:[\w-]+:)?parcelInformation>/gi
  let m: RegExpExecArray | null
  while ((m = piRe.exec(shipmentResp)) !== null) parcelInfoBlocks.push(m[1])

  const parcelLabelNumbers: string[] = parcelInfoBlocks
    .map(b => xmlPick(b, 'parcelLabelNumber') || '')
    .filter(Boolean)

  // Per-parcel labels: when splitByParcel=true, each <parcelInformation>
  // carries its own <output><content>. Capture all of them so the route
  // can return an array.
  const parcelLabels = parcelInfoBlocks.map(b => {
    const out = xmlPick(b, 'output') || ''
    return {
      trackingNumber: xmlPick(b, 'parcelLabelNumber') || '',
      pdfBase64: (xmlPick(out, 'content') || '').replace(/\s+/g, '')
    }
  })

  // The combined label PDF lives on storeOrdersResponseType.output, sibling
  // of shipmentResponses (used when splitByParcel=false).
  const topOutput = xmlPick(xml, 'output') || ''
  const combinedPdf = (xmlPick(topOutput, 'content') || '').replace(/\s+/g, '')

  // Pick the "primary" label that older callers expect on labelPdfBase64:
  // combined if present, else the first per-parcel PDF.
  const labelPdfBase64 = combinedPdf || (parcelLabels[0]?.pdfBase64 || '')

  return { mpsId, parcelLabelNumbers, labelPdfBase64, parcelLabels, faults, raw: xml }
}

async function postSoap(creds: DpdCredentials, auth: DpdAuth, body: string): Promise<string> {
  const url = `${creds.baseUrl}/ShipmentService/V4_4/`
  // Use raw fetch — $fetch tries to JSON-parse on success which would munge our XML
  const response = await fetch(url, {
    method: 'POST',
    headers: {
      'Content-Type': 'text/xml; charset=utf-8',
      Accept: 'text/xml',
      SOAPAction: ''
    },
    body
  })
  const text = await response.text()
  if (!response.ok) {
    // SOAP faults arrive as HTTP 500 with a soap:Fault body
    const fault = parseSoapFault(text)
    const err: any = new Error(fault ? `DPD SOAP fault [${fault.code}]: ${fault.message}` : `DPD HTTP ${response.status}`)
    err.statusCode = response.status
    err.dpdFault = fault
    err.dpdRaw = text
    throw err
  }
  // Even on 200 a soap:Fault could be present — check
  const fault = parseSoapFault(text)
  if (fault) {
    const err: any = new Error(`DPD SOAP fault [${fault.code}]: ${fault.message}`)
    err.dpdFault = fault
    err.dpdRaw = text
    throw err
  }
  return text
}

// Re-issue auth + retry once on LOGIN_5/LOGIN_6 (per DPD dev guidelines).
async function storeOrdersWithRetry(event: any, creds: DpdCredentials, auth: DpdAuth, soapBody: string): Promise<string> {
  try {
    return await postSoap(creds, auth, buildShipmentEnvelope(auth, soapBody))
  } catch (err: any) {
    const code = err?.dpdFault?.code || ''
    const isAuthExpired = /LOGIN_5|LOGIN_6/i.test(code) || /authToken/i.test(err?.dpdFault?.message || '')
    if (!isAuthExpired) throw err
    clearCachedAuth(event, auth.delisId)
    const fresh = await dpdGetAuth(event, creds, true)
    return await postSoap(fresh.creds, fresh.auth, buildShipmentEnvelope(fresh.auth, soapBody))
  }
}

/**
 * Send a storeOrders SOAP request. `storeOrdersBodyInner` is the XML
 * inside <ns1:storeOrders>...</ns1:storeOrders> (printOptions + order(s)).
 */
export async function dpdStoreOrders(
  event: any,
  credentials: Partial<DpdCredentials> | null,
  storeOrdersBodyInner: string
): Promise<{ result: DpdShipmentResult; auth: DpdAuth; creds: DpdCredentials }> {
  const { auth, creds } = await dpdGetAuth(event, credentials)
  const xml = await storeOrdersWithRetry(event, creds, auth, storeOrdersBodyInner)
  const result = parseStoreOrdersResponse(xml)
  return { result, auth, creds }
}

// ----------------------------------------------------------------------------
// Domain helpers used by the route layer
// ----------------------------------------------------------------------------

// DPD wants weight in 10-gram units as integer: e.g. 3kg → 300
export function kgTo10GramUnits(kg: number | string): number {
  const v = parseFloat(String(kg).replace(',', '.'))
  if (!isFinite(v) || v <= 0) return 1
  return Math.max(1, Math.round(v * 100))
}

// Volume: LLLWWWHHH in cm without separators (e.g. 30cm x 20cm x 15cm → 030020015)
export function buildVolumeString(lengthCm: number, widthCm: number, heightCm: number): string {
  const pad = (n: number) => String(Math.max(0, Math.round(n))).padStart(3, '0')
  return `${pad(lengthCm)}${pad(widthCm)}${pad(heightCm)}`
}

// ISO 3166-1 alpha-2 → 3-digit numeric (used by DPD V4_4 customsOrigin).
// Covers the most common shipping origins; falls back to 276 (DE) when
// the code isn't in the table.
const ISO_ALPHA2_TO_NUMERIC: Record<string, number> = {
  AD: 20, AE: 784, AF: 4, AG: 28, AL: 8, AM: 51, AO: 24, AR: 32, AT: 40, AU: 36,
  AZ: 31, BA: 70, BB: 52, BD: 50, BE: 56, BF: 854, BG: 100, BH: 48, BI: 108, BJ: 204,
  BN: 96, BO: 68, BR: 76, BS: 44, BT: 64, BW: 72, BY: 112, BZ: 84, CA: 124, CD: 180,
  CG: 178, CH: 756, CI: 384, CL: 152, CM: 120, CN: 156, CO: 170, CR: 188, CU: 192, CV: 132,
  CY: 196, CZ: 203, DE: 276, DJ: 262, DK: 208, DM: 212, DO: 214, DZ: 12, EC: 218, EE: 233,
  EG: 818, ER: 232, ES: 724, ET: 231, FI: 246, FJ: 242, FR: 250, GA: 266, GB: 826, GD: 308,
  GE: 268, GH: 288, GM: 270, GN: 324, GQ: 226, GR: 300, GT: 320, GW: 624, GY: 328, HK: 344,
  HN: 340, HR: 191, HT: 332, HU: 348, ID: 360, IE: 372, IL: 376, IN: 356, IQ: 368, IR: 364,
  IS: 352, IT: 380, JM: 388, JO: 400, JP: 392, KE: 404, KG: 417, KH: 116, KI: 296, KM: 174,
  KP: 408, KR: 410, KW: 414, KZ: 398, LA: 418, LB: 422, LC: 662, LI: 438, LK: 144, LR: 430,
  LS: 426, LT: 440, LU: 442, LV: 428, LY: 434, MA: 504, MC: 492, MD: 498, ME: 499, MG: 450,
  MH: 584, MK: 807, ML: 466, MM: 104, MN: 496, MR: 478, MT: 470, MU: 480, MV: 462, MW: 454,
  MX: 484, MY: 458, MZ: 508, NA: 516, NE: 562, NG: 566, NI: 558, NL: 528, NO: 578, NP: 524,
  NR: 520, NZ: 554, OM: 512, PA: 591, PE: 604, PG: 598, PH: 608, PK: 586, PL: 616, PT: 620,
  PW: 585, PY: 600, QA: 634, RO: 642, RS: 688, RU: 643, RW: 646, SA: 682, SB: 90, SC: 690,
  SD: 729, SE: 752, SG: 702, SI: 705, SK: 703, SL: 694, SM: 674, SN: 686, SO: 706, SR: 740,
  SS: 728, ST: 678, SV: 222, SY: 760, SZ: 748, TD: 148, TG: 768, TH: 764, TJ: 762, TL: 626,
  TM: 795, TN: 788, TO: 776, TR: 792, TT: 780, TV: 798, TW: 158, TZ: 834, UA: 804, UG: 800,
  US: 840, UY: 858, UZ: 860, VA: 336, VC: 670, VE: 862, VN: 704, VU: 548, WS: 882, YE: 887,
  ZA: 710, ZM: 894, ZW: 716
}

export function iso2ToCustomsOrigin(alpha2: string | undefined | null): number {
  if (!alpha2) return 276 // DE fallback
  const code = String(alpha2).trim().toUpperCase()
  return ISO_ALPHA2_TO_NUMERIC[code] ?? 276
}

// Convert dimension value to cm (matches DHL logic):
// - No decimal point → value is in mm → divide by 10
// - Has decimal (. or ,) → value is in meters → multiply by 100
export function toCm(val: any): number {
  if (val === null || val === undefined || val === '') return 0
  const raw = String(val).trim()
  if (raw === '' || raw === '0') return 0
  const hasDecimal = raw.includes('.') || raw.includes(',')
  const numeric = parseFloat(raw.replace(',', '.'))
  if (isNaN(numeric) || numeric <= 0) return 0
  const cm = hasDecimal ? numeric * 100 : numeric / 10
  return parseFloat(cm.toFixed(2))
}

// Look up DPD credential overrides on a C_OrderSource record. Returns null if
// DPD_USE_CUSTOM is not enabled. Returns a per-field breakdown when enabled so
// the route can give the user a precise diagnostic.
export interface OrderSourceDpdExtraction {
  creds: Partial<DpdCredentials>
  missing: string[]   // names of required iDempiere columns that are empty
}

const isBlank = (v: any) => v === null || v === undefined || String(v).trim() === ''

export function extractOrderSourceDpdCreds(orderSource: any): OrderSourceDpdExtraction | null {
  if (!orderSource) return null
  if (orderSource.DPD_USE_CUSTOM !== true && orderSource.DPD_USE_CUSTOM !== 'Y') return null

  const missing: string[] = []
  if (isBlank(orderSource.DPDDELISID)) missing.push('DPDDELISID')
  if (isBlank(orderSource.DPDPASSWORD)) missing.push('DPDPASSWORD')
  // DPDURL, DPDCUSTOMERNUMBER, DPDDEPOT are optional (helper has sensible defaults)

  return {
    creds: {
      baseUrl: isBlank(orderSource.DPDURL) ? undefined : String(orderSource.DPDURL).trim(),
      delisId: isBlank(orderSource.DPDDELISID) ? undefined : String(orderSource.DPDDELISID).trim(),
      password: isBlank(orderSource.DPDPASSWORD) ? undefined : String(orderSource.DPDPASSWORD),
      customerNumber: isBlank(orderSource.DPDCUSTOMERNUMBER) ? undefined : String(orderSource.DPDCUSTOMERNUMBER).trim(),
      depot: isBlank(orderSource.DPDDEPOT) ? undefined : String(orderSource.DPDDEPOT).trim()
    },
    missing
  }
}
