// Merchant Onboarding orchestrator — creates the full set of linked iDempiere records for
// a new merchant in dependency order, STOPS at the first failing step, and returns a
// structured report of what was created / failed / skipped (no auto-rollback).
//
// Deliberately does NOT use the boilerplate "retry the whole handleFunc on error" pattern:
// re-running would duplicate already-created records. Instead a single token is obtained
// once and each iDempiere call is wrapped so that a 401 triggers ONE shared refresh +
// retry of just that call — earlier steps are never re-run.
//
// Steps (see reference/recipes / the approved plan):
//   1  C_BPartner            (under the operator org)
//   2  C_Location            (merchant address)
//   3  C_BPartner_Location   (link bpartner <-> location, ship/pay/bill/remit)
//   4  AD_Org                (the new merchant org, linked to the bpartner)
//   4b PUT C_BPartner        (set Accounting_AD_Org_ID = the new org)
//   5  AD_User               (login user under the new org)
//   6  M_Warehouse           (under new org, C_Location = operator org's ship-to location)
//   7  M_Locator             ("Wareneingang" pallet locator, default)
//   8  AD_Role               (clone of the template role via copyRoleHelper)
//   9  AD_User_Roles         (assign the user to the new role)

import fetchHelper from "../../../utils/fetchHelper"
import refreshTokenHelper from "../../../utils/refreshTokenHelper"
import copyRoleHelper from "../../../utils/copyRoleHelper"

const extractErr = (e: any): string => {
  const d = e?.data ?? e
  const msg = d?.detail || d?.title || d?.summary || d?.message || e?.message
  if (msg) return String(msg)
  try { return JSON.stringify(d) } catch { return 'Unknown error' }
}

const isAuthError = (e: any): boolean => {
  const s = e?.statusCode ?? e?.status ?? e?.response?.status
  return Number(s) === 401
}

// Transient gateway/server errors worth a short retry (the iDempiere REST layer can
// briefly 502/503 under load or during a restart).
const isTransientError = (e: any): boolean => {
  const s = e?.statusCode ?? e?.status ?? e?.response?.status
  return [502, 503, 504].includes(Number(s))
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))

// Coerce a numeric-ish input to a number, or undefined when empty/blank.
const num = (v: any): number | undefined => {
  if (v === undefined || v === null || v === '') return undefined
  const n = Number(v)
  return Number.isNaN(n) ? undefined : n
}
const str = (v: any): string | undefined => {
  if (v === undefined || v === null) return undefined
  const s = String(v).trim()
  return s === '' ? undefined : s
}
const bool = (v: any): boolean => v === true || v === 'true' || v === 'Y' || v === 1

export default defineEventHandler(async (event) => {
  const config = useRuntimeConfig()
  const body = await readBody(event)
  const operatorOrgId = getCookie(event, 'logship_organization_id')

  // ---- single token + one-shot refresh-on-401 -----------------------------------------
  let token = await getTokenHelper(event)
  let refreshing: Promise<string> | null = null
  const doRefresh = async (): Promise<string> => {
    if (!refreshing) refreshing = refreshTokenHelper(event).finally(() => { refreshing = null }) as Promise<string>
    return refreshing
  }
  const apiCall = async (url: string, method: string, payload: any): Promise<any> => {
    let lastErr: any
    for (let attempt = 0; attempt < 3; attempt++) {
      try {
        return await fetchHelper(event, url, method, token, payload)
      } catch (e: any) {
        lastErr = e
        if (isAuthError(e)) { token = await doRefresh(); continue }
        if (isTransientError(e) && attempt < 2) { await sleep(600 * (attempt + 1)); continue }
        throw e
      }
    }
    throw lastErr
  }

  // ---- report + step runner -----------------------------------------------------------
  const report: any[] = []
  const ctx: any = {}
  let failed = false

  const step = async (meta: any, fn: () => Promise<any>) => {
    if (failed) {
      report.push({ ...meta, status: 'skipped', id: null, message: '' })
      return null
    }
    try {
      const res = await fn()
      report.push({ ...meta, status: 'success', id: res?.id ?? res?.newRoleId ?? null, message: '', detail: res?._detail })
      return res
    } catch (e: any) {
      failed = true
      report.push({ ...meta, status: 'error', id: null, message: extractErr(e) })
      return null
    }
  }

  // ---- field mapping ------------------------------------------------------------------
  const companyName = str(body.companyName)
  const ceoName = str(body.ceoName)
  const shortName = str(body.shortName)
  const isFulfillment = bool(body.isFulfillmentCustomer)
  // A01 or A03: Name = company (else ceo); Name2 = ceo only when a company name exists.
  const bpName = companyName || ceoName || shortName || ''
  const bpName2 = companyName ? (ceoName || '') : ''

  // ---- Step 1: C_BPartner (under the operator org) ------------------------------------
  const bp = await step(
    { step: 1, key: 'bpartner', entity: 'C_BPartner', label: 'Business Partner', name: bpName },
    async () => {
      const payload: any = {
        AD_Org_ID: { id: operatorOrgId, tableName: 'AD_Org' },
        isActive: true,
        name: bpName,
        name2: bpName2,
        // Value intentionally omitted — iDempiere auto-fills C_BPartner.Value.
        isCustomer: true,
        isVendor: false,
        isFulfillmentCustomer: isFulfillment,
        tableName: 'C_Bpartner',
      }
      const taxId = str(body.taxId);                           if (taxId !== undefined) payload.taxID = taxId
      const email = str(body.companyEmail);                    if (email !== undefined) payload.EMail = email
      const hrg = str(body.hrgNumber);                         if (hrg !== undefined) payload.hrg_number = hrg
      const contract = str(body.contractedSigned);             if (contract !== undefined) payload.contractedSigned = contract
      // fulfillment pricing
      const baseprice = num(body.fulfillmentOrderBaseprice);   if (baseprice !== undefined) payload.fulfillment_order_baseprice = baseprice
      const pickprice = num(body.fulfillmentOrderPickprice);   if (pickprice !== undefined) payload.fulfillment_order_pickprice = pickprice
      const qtyPickfree = num(body.fulfillmentOrderQtyPickfree); if (qtyPickfree !== undefined) payload.fulfillment_order_qty_pickfree = qtyPickfree
      const retBase = num(body.fulfillmentOrderReturnBaseprice); if (retBase !== undefined) payload.fulfillment_order_return_baseprice = retBase
      const retPick = num(body.fulfillmentOrderReturnPickprice); if (retPick !== undefined) payload.fulfillment_order_return_pickprice = retPick
      const shelfLarge = num(body.shelfRentLargePrice);        if (shelfLarge !== undefined) payload.Shelf_Rent_Large_Price = shelfLarge
      const shelfSmall = num(body.shelfRentSmallPrice);        if (shelfSmall !== undefined) payload.Shelf_Rent_Small_Price = shelfSmall
      const palett = num(body.rentPalettSpacePrice);           if (palett !== undefined) payload.rent_palett_space_price = palett
      const hourly = num(body.fulfillmentServiceHourlyRate);   if (hourly !== undefined) payload.fulfillment_service_hourly_rate = hourly
      // Parcel packaging costs (incl. wrapping paper) per volume size + ZUGFeRD opt-in.
      const pkgS = num(body.paketSPrice);   if (pkgS !== undefined) payload.M_Product_Paket_2_S_Price = pkgS
      const pkgM = num(body.paketMPrice);   if (pkgM !== undefined) payload.M_Product_Paket_3_M_Price = pkgM
      const pkgL = num(body.paketLPrice);   if (pkgL !== undefined) payload.M_Product_Paket_4_L_Price = pkgL
      const pkgXL = num(body.paketXLPrice); if (pkgXL !== undefined) payload.M_Product_Paket_5_XL_Price = pkgXL
      if (bool(body.isZugPferdInvoice)) payload.isZugPferdInvoice = true
      // accounting
      const rentStarts = str(body.accoutingWarehouseRentStarts); if (rentStarts !== undefined) payload.AccoutingWarehouseRentStarts = rentStarts
      if (bool(body.isAccountingWarehouseVolume)) payload.isAccountingWarehouseVolume = true
      if (bool(body.isAccountLogShip)) payload.isAccountLogShip = true
      const monthly = num(body.logshipMonthlyFee);             if (monthly !== undefined) payload.logshipmonthlyfee = monthly
      if (bool(body.isAllowFeeReportDownload)) payload.isAllowFeeReportDownload = true
      const returnEmail = str(body.returnEmail);               if (returnEmail !== undefined) payload.return_email = returnEmail
      // FK selections
      const groupId = num(body.partnerGroupId);                if (groupId !== undefined) payload.C_BP_Group_ID = { id: groupId, tableName: 'C_BP_Group' }
      const priceListId = num(body.priceListId);               if (priceListId !== undefined) payload.M_PriceList_ID = { id: priceListId, tableName: 'M_PriceList' }
      const deliveryViaRuleId = str(body.deliveryViaRuleId);   if (deliveryViaRuleId !== undefined) payload.DeliveryViaRule = { id: deliveryViaRuleId }
      const shipperId = num(body.shipperOrderDefaultId);       if (shipperId !== undefined) payload.M_Shipper_OrderDefault_ID = { id: shipperId, tableName: 'M_Shipper' }

      const res = await apiCall('models/c_bpartner', 'POST', payload)
      if (!res?.id) throw new Error(res?.summary || res?.message || 'No id returned for C_BPartner')
      return res
    }
  )
  if (bp) ctx.bpartnerId = bp.id

  // ---- Step 2: C_Location -------------------------------------------------------------
  const loc = await step(
    { step: 2, key: 'location', entity: 'C_Location', label: 'Address', name: `${str(body.street) || ''}, ${str(body.zip) || ''} ${str(body.city) || ''}` },
    async () => {
      const payload: any = {
        AD_Org_ID: { id: operatorOrgId, tableName: 'AD_Org' },
        isActive: true,
        address1: str(body.street) || '',
        city: str(body.city) || '',
        postal: str(body.zip) || '',
        isValid: true,
        tableName: 'C_Location',
      }
      const countryId = num(body.countryId)
      if (countryId !== undefined) payload.C_Country_ID = { id: countryId, tableName: 'C_Country' }
      const res = await apiCall('models/c_location', 'POST', payload)
      if (!res?.id) throw new Error(res?.summary || 'No id returned for C_Location')
      return res
    }
  )
  if (loc) ctx.locationId = loc.id

  // ---- Step 3: C_BPartner_Location (link) ---------------------------------------------
  const bpLoc = await step(
    { step: 3, key: 'bpLocation', entity: 'C_BPartner_Location', label: 'Partner Location', name: bpName },
    async () => {
      const payload: any = {
        AD_Org_ID: { id: operatorOrgId, tableName: 'AD_Org' },
        isActive: true,
        name: bpName,
        isBillTo: true,
        isShipTo: true,
        isPayFrom: true,
        isRemitTo: true,
        C_BPartner_ID: { id: ctx.bpartnerId, tableName: 'C_BPartner' },
        C_Location_ID: { id: ctx.locationId, tableName: 'C_Location' },
        tableName: 'C_BPartner_Location',
      }
      const phone = str(body.phoneWired);  if (phone !== undefined) payload.phone = phone
      const phone2 = str(body.phoneMobile); if (phone2 !== undefined) payload.phone2 = phone2
      // CEO name → Name2 on the partner location (when a distinct CEO name was entered).
      if (ceoName && ceoName !== bpName) payload.name2 = ceoName
      const res = await apiCall('models/c_bpartner_location', 'POST', payload)
      if (!res?.id) throw new Error(res?.summary || 'No id returned for C_BPartner_Location')
      return res
    }
  )
  if (bpLoc) ctx.bpLocationId = bpLoc.id

  // ---- Step 4: AD_Org -----------------------------------------------------------------
  const org = await step(
    { step: 4, key: 'org', entity: 'AD_Org', label: 'Organization', name: shortName },
    async () => {
      const payload: any = {
        isActive: true,
        name: shortName,
        value: shortName,
        isSummary: false,
        C_BPartner_ID: { id: ctx.bpartnerId, tableName: 'C_BPartner' },
        AD_Org_ID: { id: 0, tableName: 'AD_Org' },
        tableName: 'AD_Org',
      }
      const res = await apiCall('models/ad_org', 'POST', payload)
      if (!res?.id) throw new Error(res?.summary || 'No id returned for AD_Org')
      return res
    }
  )
  if (org) ctx.newOrgId = org.id

  // ---- Step 4b: PUT C_BPartner -> Accounting_AD_Org_ID = new org ----------------------
  await step(
    { step: 4, key: 'bpartnerAccountingOrg', entity: 'C_BPartner', label: 'Link Accounting Org', name: bpName },
    async () => {
      const res = await apiCall(`models/c_bpartner/${ctx.bpartnerId}`, 'PUT', {
        Accounting_AD_Org_ID: { id: ctx.newOrgId, tableName: 'AD_Org' },
        tableName: 'C_Bpartner',
      })
      return { id: ctx.bpartnerId, _detail: `Accounting_AD_Org_ID = ${ctx.newOrgId}` }
    }
  )

  // ---- Step 5: AD_User ----------------------------------------------------------------
  // Note: A11 e-mail is set on BOTH the bpartner and the user per spec. If the iDempiere
  // instance enforces unique user e-mail this may fail (then the report stops here).
  const user = await step(
    { step: 5, key: 'user', entity: 'AD_User', label: 'Login User', name: shortName },
    async () => {
      const payload: any = {
        AD_Org_ID: { id: ctx.newOrgId, tableName: 'AD_Org' },
        isActive: true,
        name: shortName,
        value: shortName,
        password: str(body.password) || '',
        eMail: str(body.companyEmail) || '',
        C_BPartner_ID: { id: ctx.bpartnerId, tableName: 'C_BPartner' },
        tableName: 'AD_User',
      }
      const res = await apiCall('models/ad_user', 'POST', payload)
      if (!res?.id) throw new Error(res?.summary || 'No id returned for AD_User')
      return res
    }
  )
  if (user) ctx.userId = user.id

  // ---- Step 6: M_Warehouse (location = operator org's ship-to location) ---------------
  const warehouse = await step(
    { step: 6, key: 'warehouse', entity: 'M_Warehouse', label: 'Warehouse', name: shortName },
    async () => {
      // resolve operator org -> bpartner -> ship-to location
      let opLocationId: any = null
      const orgRec = await apiCall(`models/ad_org/${operatorOrgId}?$select=C_BPartner_ID`, 'GET', null)
      const opBpId = orgRec?.C_BPartner_ID?.id
      if (opBpId) {
        const shipTo = await apiCall(`models/c_bpartner_location?$filter=C_BPartner_ID eq ${opBpId} and IsShipTo eq true&$top=1`, 'GET', null)
        opLocationId = shipTo?.records?.[0]?.C_Location_ID?.id
        if (!opLocationId) {
          const anyLoc = await apiCall(`models/c_bpartner_location?$filter=C_BPartner_ID eq ${opBpId}&$top=1`, 'GET', null)
          opLocationId = anyLoc?.records?.[0]?.C_Location_ID?.id
        }
      }
      if (!opLocationId) throw new Error('Could not resolve the operator org warehouse location (C_Location_ID)')
      ctx.warehouseLocationId = opLocationId
      const payload: any = {
        AD_Org_ID: { id: ctx.newOrgId, tableName: 'AD_Org' },
        isActive: true,
        name: shortName,
        // Value intentionally omitted — iDempiere auto-fills M_Warehouse.Value.
        separator: '-',
        isInTransit: false,
        isDisallowNegativeInv: false,
        C_Location_ID: { id: opLocationId, tableName: 'C_Location' },
        tableName: 'M_Warehouse',
      }
      const res = await apiCall('models/m_warehouse', 'POST', payload)
      if (!res?.id) throw new Error(res?.summary || 'No id returned for M_Warehouse')
      return res
    }
  )
  if (warehouse) ctx.warehouseId = warehouse.id

  // ---- Step 7: M_Locator ("Wareneingang") --------------------------------------------
  const locator = await step(
    { step: 7, key: 'locator', entity: 'M_Locator', label: 'Locator (Wareneingang)', name: 'Wareneingang' },
    async () => {
      const payload: any = {
        AD_Org_ID: { id: ctx.newOrgId, tableName: 'AD_Org' },
        isActive: true,
        value: 'Wareneingang',
        priorityNo: 50,
        X: '0',
        Y: '0',
        Z: '0',
        isDefault: true,
        M_Warehouse_ID: { id: ctx.warehouseId, tableName: 'M_Warehouse' },
        M_LocatorType_ID: { id: Number(config.merchantPalettLocatorTypeId), tableName: 'M_LocatorType' },
        tableName: 'm_locator',
      }
      const res = await apiCall('models/m_locator', 'POST', payload)
      if (!res?.id) throw new Error(res?.summary || 'No id returned for M_Locator')
      // Set the just-created locator as the warehouse's reserve locator (best-effort: the
      // locator already exists, so a failed link is reported but does not fail the step).
      let reserveNote = ''
      try {
        await apiCall(`models/m_warehouse/${ctx.warehouseId}`, 'PUT', {
          M_ReserveLocator_ID: { id: res.id, tableName: 'M_Locator' },
          tableName: 'M_Warehouse',
        })
        reserveNote = ' · set as warehouse reserve locator'
      } catch (e: any) {
        reserveNote = ' · reserve-locator link failed: ' + extractErr(e)
      }
      res._detail = 'Wareneingang' + reserveNote
      return res
    }
  )
  if (locator) ctx.locatorId = locator.id

  // ---- Step 8: Copy Role --------------------------------------------------------------
  const role = await step(
    { step: 8, key: 'role', entity: 'AD_Role', label: 'Role', name: shortName },
    async () => {
      const result = await copyRoleHelper(apiCall, {
        sourceRoleId: config.merchantTemplateRoleId,
        newOrgId: ctx.newOrgId,
        newRoleName: shortName || '',
      })
      return {
        id: result.newRoleId,
        newRoleId: result.newRoleId,
        _detail: result.orgAccessOk
          ? 'Auto-provisioned by iDempiere (Fulfillment-Customer access); org access OK'
          : 'Role created; org access not confirmed' + (result.orgAccessError ? ` (${result.orgAccessError})` : ''),
        copyResult: result,
      }
    }
  )
  if (role) {
    ctx.newRoleId = role.newRoleId
    ctx.roleCopy = role.copyResult
  }

  // ---- Step 9: AD_User_Roles ----------------------------------------------------------
  const assignment = await step(
    { step: 9, key: 'userRole', entity: 'AD_User_Roles', label: 'User → Role assignment', name: shortName },
    async () => {
      const res = await apiCall('models/ad_user_roles', 'POST', {
        AD_Org_ID: { id: ctx.newOrgId, tableName: 'AD_Org' },
        AD_User_ID: { id: ctx.userId, tableName: 'AD_User' },
        AD_Role_ID: { id: ctx.newRoleId, tableName: 'AD_Role' },
        isActive: true,
        tableName: 'AD_User_Roles',
      })
      // AD_User_Roles has a composite PK (no surrogate id): a successful POST returns the
      // record with a `uid` but no `id`, so confirm via uid (checking id alone false-fails).
      if (!res?.uid && !res?.id) throw new Error(res?.summary || res?.message || 'AD_User_Roles not created')
      return res
    }
  )
  if (assignment) ctx.userRoleId = assignment.id

  // ---- Step 10: org-access isolation cleanup ------------------------------------------
  // Creating the new org makes iDempiere auto-grant org-access to EVERY existing role, so
  // other merchants could otherwise see the new org. Remove the grants for OTHER merchant
  // roles (FrontendMenu = 'c'), keeping the new merchant role + all admin/staff roles (so
  // your team can still manage the merchant). Deletes are by `uid` — AD_Role_OrgAccess has
  // a composite PK (no surrogate id). Runs whenever the org + new role exist (even if a
  // prior step failed), since the leak exists the moment the org is created.
  if (ctx.newOrgId && ctx.newRoleId) {
    let removed = 0
    let kept = 0
    const errs: string[] = []
    try {
      const res: any = await apiCall(
        `models/ad_role_orgaccess?$filter=AD_Org_ID eq ${ctx.newOrgId}&$expand=AD_Role_ID&$top=1000`,
        'GET', null
      )
      const rows: any[] = res?.records || []
      for (const r of rows) {
        const roleId = r.AD_Role_ID?.id
        const frontendMenu = r.AD_Role_ID?.FrontendMenu?.id ?? r.AD_Role_ID?.FrontendMenu
        const isNewRole = String(roleId) === String(ctx.newRoleId)
        const isMerchant = String(frontendMenu) === 'c'
        if (isNewRole || !isMerchant || !r.uid) { kept++; continue }
        try { await apiCall(`models/ad_role_orgaccess/${r.uid}`, 'DELETE', null); removed++ }
        catch (e: any) { errs.push(extractErr(e)) }
      }
      report.push({
        step: 10, key: 'orgAccessCleanup', entity: 'AD_Role_OrgAccess', label: 'Org-access isolation',
        status: (removed === 0 && errs.length > 0) ? 'error' : 'success', id: null,
        message: errs.length ? errs.slice(0, 3).join('; ') : '',
        detail: `removed ${removed} other-merchant grant(s), kept ${kept} (new role + admin/staff)`,
      })
    } catch (e: any) {
      report.push({
        step: 10, key: 'orgAccessCleanup', entity: 'AD_Role_OrgAccess', label: 'Org-access isolation',
        status: 'error', id: null, message: extractErr(e), detail: '',
      })
    }
  }

  const failedStep = report.find((r) => r.status === 'error') || null

  return {
    status: failed ? 500 : 200,
    message: failed ? `Onboarding stopped at step ${failedStep?.step} (${failedStep?.entity})` : '',
    report,
    ctx,
    failedStep,
    meta: {
      createdAt: new Date().toISOString(),
      loginUrl: 'https://app.logship.de',
      shortName,
      companyName: bpName,
    },
  }
})
