import { string } from 'alga-js'
import {
  AMOUNT_TOLERANCE,
  EUR_CURRENCY_ID,
  computeOpenAmounts,
  fetchLinesForStatements,
  normalizeForDocNoScan,
  odataLit
} from './bankReconciliation'

/**
 * Match-suggestion engine for bank statement lines (Bankabgleich).
 * Shared by
 *   GET /api/accounting/bank-reconciliation/{id}/suggestions   (one statement)
 *   GET /api/accounting/bank-reconciliation/suggestions?ids=…   (many statements — "Alle" view)
 *
 * Signals per line×invoice:
 *   docNo       invoice DocumentNo (normalized, len>=5) found in remittance text
 *   amount      openAmt == |line amount| (±0.005)
 *   ibanPartner counterpart IBAN -> c_bp_bankaccount -> same partner
 *   namePartner FUZZY match of counterpart vs partner name (see nameSimilarity): brand word,
 *               prefixes, accents, filler words ignored — "AMAZON PAYMENTS EUROPE S.C.A."
 *               matches "Amazon EU S.à r.l., Niederlassung Deutschland"; also the brand word
 *               found in the remittance text when the counterpart field is empty
 *   date        statement line date close to the invoice date (−5 … +60 days)
 *
 * A candidate is only suggested when AT LEAST 2 of the 4 CRITERIA match — document number,
 * amount, partner (IBAN or name count as ONE criterion) and date. One matching field alone
 * (e.g. "same amount as some open invoice") produced too many wrong suggestions.
 * Ranking = score, best first: docNo 50 · amount 30 · IBAN 25 / name 10–25 (by similarity) ·
 * date 0–15 (by closeness); ties → newer invoice. Up to 8 candidates per line: the list page
 * shows the first one, the match modal the whole ranked list.
 * Tiers: high = docNo + 1 more, or amount + partner + date | medium = amount + one more |
 *        low = partner + date only (no amount: partial payment / collective payment)
 *
 * The open-invoice set (+ allocations) is fetched ONCE per call, so computing
 * for 50 statements costs about the same as for one — which is why the "Alle"
 * view must use the multi-id route instead of N per-statement calls.
 */

// legal forms + filler words that say nothing about WHO the partner is
const NAME_STOPWORDS = new Set([
  'gmbh', 'mbh', 'ag', 'ug', 'kg', 'kgaa', 'ohg', 'ek', 'gbr', 'co', 'cokg', 'inc', 'ltd', 'llc', 'plc', 'corp', 'bv', 'nv', 'sa', 'sl', 'sas', 'spa',
  'sarl', 'srl', 'sca', 'se', 'ab', 'as', 'oy', 'und', 'and', 'the', 'die', 'der', 'das', 'von', 'de', 'la', 'le',
  'niederlassung', 'zweigniederlassung', 'deutschland', 'germany', 'europe', 'europa', 'eu', 'international', 'intl', 'global',
  'services', 'service', 'payments', 'payment', 'holding', 'group', 'gruppe', 'company', 'handel', 'handels', 'vertrieb', 'vertriebs',
  // generic leading adjectives — must never act as the "brand" ("Deutsche Bank" is not "Deutsche Post")
  'deutsche', 'deutscher', 'deutsches', 'german', 'erste', 'neue', 'allgemeine', 'vereinigte', 'national', 'nationale'
])

// lower-case, German umlauts spelled out FIRST ("Müller" = "Mueller"), then remaining accents stripped
export const foldText = (v: any): string => String(v || '').toLowerCase()
  .replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss')
  .normalize('NFD').replace(/[\u0300-\u036f]/g, '')

/** Significant name tokens in order (the first one is usually the brand / family name). */
export const nameTokens = (name: any): string[] => {
  const out: string[] = []
  for (const t of foldText(name).replace(/[^a-z0-9 ]/g, ' ').split(/\s+/)) {
    if (t.length >= 2 && !NAME_STOPWORDS.has(t) && !out.includes(t)) out.push(t)
  }
  return out
}

// equal, or one is a prefix of the other ("amazon" ~ "amazonpayments") — prefixes only from 4 letters
const tokenMatch = (x: string, y: string): boolean => x === y || (Math.min(x.length, y.length) >= 4 && (x.startsWith(y) || y.startsWith(x)))

/**
 * 0 … 1 similarity of two party names — deliberately NOT exact: bank counterpart names are
 * abbreviated, upper-cased, carry other legal forms or a different group entity.
 *   - share of matching tokens (relative to the shorter name),
 *   - brand match: the first significant tokens match (≥ 4 letters) → at least 0.7,
 *   - the other name's brand token appears inside the run-together text ("AMAZONPAYMENTS") → 0.6.
 */
export const nameSimilarity = (a: any, b: any): number => {
  const ta = nameTokens(a)
  const tb = nameTokens(b)
  if (!ta.length || !tb.length) return 0
  const hits = ta.filter(x => tb.some(y => tokenMatch(x, y))).length
  let score = hits / Math.min(ta.length, tb.length)
  if (Math.min(ta[0].length, tb[0].length) >= 4 && tokenMatch(ta[0], tb[0])) score = Math.max(score, 0.7)
  const ca = foldText(a).replace(/[^a-z0-9]/g, '')
  const cb = foldText(b).replace(/[^a-z0-9]/g, '')
  if ((tb[0].length >= 5 && ca.includes(tb[0])) || (ta[0].length >= 5 && cb.includes(ta[0]))) score = Math.max(score, 0.6)
  return Math.min(1, score)
}
const NAME_MATCH_MIN = 0.5

/** Partner's brand token (≥ 5 letters) inside free text — for lines without a counterpart name. */
const brandInText = (partnerName: any, foldedCompactText: string): boolean => {
  const brand = nameTokens(partnerName)[0]
  return !!brand && brand.length >= 5 && foldedCompactText.includes(brand)
}

const DATE_WINDOW_BEFORE = 5    // payment up to 5 days BEFORE the invoice date (prepayment / dated later)
const DATE_WINDOW_AFTER = 60    // … or up to 60 days after it
const dayNumber = (v: any): number | null => { const m = String(v || '').match(/^(\d{4})-(\d{2})-(\d{2})/); return m ? Math.floor(Date.UTC(+m[1], +m[2] - 1, +m[3]) / 86400000) : null }

const FEE_MEMO_REGEX = /entgelt|abschluss|geb(ü|ue)hr|kontof(ü|ue)hrung|auszug|porto|rechnungsabschluss|verwahrentgelt/i

export const fetchOpenInvoices = async (event: any, token: any, isSOTrx: boolean, q: string = '') => {
  let filter = `IsSOTrx eq ${isSOTrx} and IsPaid eq 'N' and (DocStatus eq 'CO' or DocStatus eq 'CL') and C_Currency_ID eq ${EUR_CURRENCY_ID}`
  if (q) {
    filter += ` and contains(DocumentNo,${odataLit(q)})`
  }
  const res: any = await event.context.fetch(
    `models/c_invoice?$filter=${string.urlEncode(filter)}` +
    `&$select=C_Invoice_ID,DocumentNo,GrandTotal,DateInvoiced,C_BPartner_ID,AD_Org_ID` +
    `&$expand=C_BPartner_ID($select=Name)` +
    `&$orderby=${string.urlEncode('DateInvoiced desc')}&$top=${q ? 50 : 500}`,
    'GET', token, null
  )
  return res?.records || []
}

/** Free invoice search for the match modal (openAmt computed for the results). */
export const searchOpenInvoices = async (event: any, token: any, q: string, direction: any) => {
  const isSOTrx = direction !== 'debit'
  const invoices = await fetchOpenInvoices(event, token, isSOTrx, q)
  const openAmounts = await computeOpenAmounts(event, token, invoices)
  return invoices
    .filter((inv: any) => (openAmounts[inv.id] ?? 0) > AMOUNT_TOLERANCE)
    .map((inv: any) => ({
      invoiceId: inv.id,
      documentNo: inv.DocumentNo || '',
      partnerId: inv.C_BPartner_ID?.id || null,
      partnerName: inv.C_BPartner_ID?.Name || inv.C_BPartner_ID?.identifier || '',
      grandTotal: inv.GrandTotal,
      openAmt: openAmounts[inv.id],
      dateInvoiced: inv.DateInvoiced || ''
    }))
}

/**
 * Suggestions for ALL unmatched lines of the given statements.
 * Returns { [lineId]: candidate[] } — best match first, max 8 candidates per line.
 */
export const computeLineSuggestions = async (
  event: any,
  token: any,
  statementIds: number[]
): Promise<Record<number, any[]>> => {
  const rawLines = await fetchLinesForStatements(
    event, token, statementIds,
    '$select=C_BankStatementLine_ID,StatementLineDate,ValutaDate,StmtAmt,EftPayee,EftPayeeAccount,EftMemo,Description,ReferenceNo,C_Payment_ID,C_Charge_ID'
  )
  const lines = rawLines.filter((l: any) => !l.C_Payment_ID?.id && !l.C_Charge_ID?.id)

  if (!lines.length) {
    return {}
  }

  const hasCredits = lines.some((l: any) => (l.StmtAmt || 0) > 0)
  const hasDebits = lines.some((l: any) => (l.StmtAmt || 0) < 0)

  const [arInvoices, apInvoices] = await Promise.all([
    hasCredits ? fetchOpenInvoices(event, token, true) : Promise.resolve([]),
    hasDebits ? fetchOpenInvoices(event, token, false) : Promise.resolve([])
  ])

  const allInvoices = [...arInvoices, ...apInvoices]
  const openAmounts = await computeOpenAmounts(event, token, allInvoices)

  // Partner-IBAN index from counterpart IBANs (c_bp_bankaccount)
  const ibans = Array.from(new Set(
    lines.map((l: any) => String(l.EftPayeeAccount || '').toUpperCase().replace(/\s/g, '')).filter((v: string) => v.length >= 15)
  ))
  const ibanToPartner: Record<string, number> = {}
  for (let i = 0; i < ibans.length; i += 40) {
    const chunk = ibans.slice(i, i + 40)
    const filter = chunk.map((iban: string) => `IBAN eq ${odataLit(iban)}`).join(' OR ')
    try {
      const res: any = await event.context.fetch(
        `models/c_bp_bankaccount?$filter=${string.urlEncode(filter)}&$select=C_BPartner_ID,IBAN&$top=200`,
        'GET', token, null
      )
      for (const rec of (res?.records || [])) {
        const iban = String(rec.IBAN || '').toUpperCase().replace(/\s/g, '')
        if (iban && rec.C_BPartner_ID?.id) {
          ibanToPartner[iban] = rec.C_BPartner_ID.id
        }
      }
    } catch (err) {
      // no IBAN index for this chunk — signal simply won't fire
    }
  }

  // Precompute normalized doc numbers once
  const arSet = new Set(arInvoices)
  const invoiceMeta = allInvoices
    .filter((inv: any) => (openAmounts[inv.id] ?? 0) > AMOUNT_TOLERANCE)
    .map((inv: any) => ({
      inv,
      isSOTrx: arSet.has(inv),
      normDocNo: normalizeForDocNoScan(inv.DocumentNo),
      normDocNoNoZeros: normalizeForDocNoScan(inv.DocumentNo).replace(/^([A-Z]*)0+/, '$1'),
      openAmt: openAmounts[inv.id]
    }))

  const suggestions: Record<number, any[]> = {}
  const tierRank: Record<string, number> = { high: 0, medium: 1, low: 2 }

  for (const line of lines) {
    const amount = Number(line.StmtAmt || 0)
    const absAmount = Math.abs(amount)
    const isCredit = amount > 0
    const scanText = normalizeForDocNoScan(
      (line.EftMemo || '') + ' ' + (line.Description || '') + ' ' + (line.ReferenceNo || '')
    )
    const lineIban = String(line.EftPayeeAccount || '').toUpperCase().replace(/\s/g, '')
    const ibanPartnerId = ibanToPartner[lineIban] || null

    const lineDay = dayNumber(line.StatementLineDate) ?? dayNumber(line.ValutaDate)
    const payeeName = String(line.EftPayee || '').trim()
    const memoCompact = payeeName ? '' : foldText((line.EftMemo || '') + ' ' + (line.Description || '')).replace(/[^a-z0-9]/g, '')

    const candidates: any[] = []

    for (const meta of invoiceMeta) {
      if (meta.isSOTrx !== isCredit) continue

      const partnerName = meta.inv.C_BPartner_ID?.Name || meta.inv.C_BPartner_ID?.identifier || ''
      const nameScore = payeeName ? nameSimilarity(payeeName, partnerName) : (brandInText(partnerName, memoCompact) ? 0.5 : 0)
      const invDay = dayNumber(meta.inv.DateInvoiced)
      const dateGap = lineDay != null && invDay != null ? lineDay - invDay : null
      const signals = {
        docNo: meta.normDocNo.length >= 5 && (
          scanText.includes(meta.normDocNo) ||
          (meta.normDocNoNoZeros.length >= 5 && scanText.includes(meta.normDocNoNoZeros))
        ),
        amount: Math.abs(meta.openAmt - absAmount) <= AMOUNT_TOLERANCE,
        ibanPartner: !!ibanPartnerId && meta.inv.C_BPartner_ID?.id === ibanPartnerId,
        namePartner: nameScore >= NAME_MATCH_MIN,
        date: dateGap != null && dateGap >= -DATE_WINDOW_BEFORE && dateGap <= DATE_WINDOW_AFTER
      }
      // the 4 criteria — IBAN and name are two ways to identify the SAME thing (the partner)
      const partner = signals.ibanPartner || signals.namePartner
      const matchCount = [signals.docNo, signals.amount, partner, signals.date].filter(Boolean).length
      if (matchCount < 2) continue

      const dateCloseness = signals.date ? 1 - Math.abs(dateGap as number) / DATE_WINDOW_AFTER : 0
      const score = Math.round(
        (signals.docNo ? 50 : 0) +
        (signals.amount ? 30 : 0) +
        (signals.ibanPartner ? 25 : (signals.namePartner ? 10 + 15 * nameScore : 0)) +
        15 * dateCloseness
      )

      let confidence = 'low'
      if ((signals.docNo && matchCount >= 2) || (signals.amount && partner && signals.date)) {
        confidence = 'high'
      } else if (signals.amount) {
        confidence = 'medium'
      }

      candidates.push({
        type: 'invoice',
        invoiceId: meta.inv.id,
        documentNo: meta.inv.DocumentNo || '',
        partnerId: meta.inv.C_BPartner_ID?.id || null,
        partnerName,
        grandTotal: meta.inv.GrandTotal,
        openAmt: meta.openAmt,
        dateInvoiced: meta.inv.DateInvoiced || '',
        confidence,
        signals,
        matchCount,
        score,
        nameScore: Math.round(nameScore * 100) / 100,
        dateGapDays: dateGap
      })
    }

    // best / nearest match first
    candidates.sort((a, b) =>
      (b.score - a.score) ||
      (tierRank[a.confidence] - tierRank[b.confidence]) ||
      String(b.dateInvoiced).localeCompare(String(a.dateInvoiced))
    )

    const top = candidates.slice(0, 8)

    // Fee heuristic for unexplained debits
    if (!isCredit) {
      const counterpartEmpty = !String(line.EftPayee || '').trim()
      const looksLikeFee = counterpartEmpty || FEE_MEMO_REGEX.test(String(line.EftMemo || '') + ' ' + String(line.EftPayee || ''))
      if (looksLikeFee && !top.some(c => c.confidence === 'high')) {
        top.unshift({ type: 'charge', confidence: 'medium', signals: { fee: true } })
      }
    }

    if (top.length) {
      suggestions[line.id] = top
    }
  }

  return suggestions
}
