/**
 * Heuristic field extraction for German/EU supplier invoices (Tier 1 text +
 * Tier 2 OCR). Pure functions — no I/O. Returns a partial ZugferdInvoiceData plus
 * per-field confidence and (best-effort) bounding boxes.
 *
 * A vendor profile, when supplied, is applied FIRST (its learned anchors/boxes
 * win and carry high confidence); these generic anchors are the cold-start
 * fallback for unknown vendors.
 */

import type { ZugferdInvoiceData } from '../zugferd/buildInvoiceXml'
import type { TextItem } from './pdfTextLayer'

export interface FieldBox {
  x: number; y: number; w: number; h: number
  /** 1-based page + page-normalized (0..1, top-left) box for the UI overlay. */
  page?: number; nx?: number; ny?: number; nw?: number; nh?: number
}

export interface ExtractionResult {
  fields: Partial<ZugferdInvoiceData>
  confidence: Record<string, number>
  bbox: Record<string, FieldBox>
}

/* ------------------------------- parsing ------------------------------- */

/** Parse a German/Intl money token ("1.234,56" | "1,234.56" | "1234,56" | "1234.56"). */
export const parseAmount = (raw: string): number | null => {
  if (!raw) return null
  let s = String(raw).replace(/[^0-9.,-]/g, '')
  if (!s) return null
  const hasDot = s.includes('.')
  const hasComma = s.includes(',')
  if (hasDot && hasComma) {
    // last separator is the decimal one
    if (s.lastIndexOf(',') > s.lastIndexOf('.')) s = s.replace(/\./g, '').replace(',', '.')
    else s = s.replace(/,/g, '')
  } else if (hasComma) {
    // comma decimal (German) if 1-2 trailing digits, else thousands sep
    const m = s.match(/,(\d{1,2})$/)
    s = m ? s.replace(/\./g, '').replace(',', '.') : s.replace(/,/g, '')
  }
  const n = Number(s)
  return Number.isFinite(n) ? n : null
}

/** Parse a date string to ISO yyyy-mm-dd; supports dd.mm.yyyy, dd/mm/yyyy, yyyy-mm-dd, dd. Monat yyyy. */
export const parseDate = (raw: string): string => {
  if (!raw) return ''
  const s = String(raw).trim()
  let m = s.match(/(\d{4})-(\d{2})-(\d{2})/)
  if (m) return `${m[1]}-${m[2]}-${m[3]}`
  m = s.match(/(\d{1,2})[.\/-](\d{1,2})[.\/-](\d{2,4})/)
  if (m) {
    let [, d, mo, y] = m
    if (y.length === 2) y = (Number(y) > 70 ? '19' : '20') + y
    return `${y}-${mo.padStart(2, '0')}-${d.padStart(2, '0')}`
  }
  const months: Record<string, string> = {
    januar: '01', jan: '01', februar: '02', feb: '02', märz: '03', maerz: '03', mar: '03',
    april: '04', apr: '04', mai: '05', juni: '06', jun: '06', juli: '07', jul: '07',
    august: '08', aug: '08', september: '09', sep: '09', oktober: '10', okt: '10', oct: '10',
    november: '11', nov: '11', dezember: '12', dez: '12', dec: '12'
  }
  m = s.match(/(\d{1,2})\.?\s+([A-Za-zäöü]+)\s+(\d{4})/)
  if (m && months[m[2].toLowerCase()]) {
    return `${m[3]}-${months[m[2].toLowerCase()]}-${m[1].padStart(2, '0')}`
  }
  return ''
}

const AMOUNT_RE = '([0-9][0-9.,]*[0-9]|[0-9])'
const DATE_RE = '(\\d{4}-\\d{2}-\\d{2}|\\d{1,2}[.\\/-]\\d{1,2}[.\\/-]\\d{2,4}|\\d{1,2}\\.?\\s+[A-Za-zäöü]+\\s+\\d{4})'

/** Find a labeled value: <label> ... <captureRegex> on the same or next ~40 chars. */
const matchLabeled = (text: string, labels: string[], captureRe: string): string | null => {
  for (const label of labels) {
    const re = new RegExp(`${label}\\s*[:#\\-]?\\s*${captureRe}`, 'i')
    const m = text.match(re)
    if (m && m[1]) return m[1].trim()
  }
  return null
}

/* ------------------------------ locators ------------------------------ */

const findBox = (value: string, items?: TextItem[]): FieldBox | undefined => {
  if (!items || !value) return undefined
  const needle = value.trim().slice(0, 24)
  const hit = items.find(it => it.text && it.text.includes(needle))
  if (!hit) return undefined
  return { x: hit.x, y: hit.y, w: hit.w, h: hit.h, page: hit.page, nx: hit.nx, ny: hit.ny, nw: hit.nw, nh: hit.nh }
}

const CURRENCY_MAP: Array<[RegExp, string]> = [
  [/€|\bEUR\b|\bEURO?\b/i, 'EUR'],
  [/\$|\bUSD\b/i, 'USD'],
  [/£|\bGBP\b/i, 'GBP'],
  [/\bCHF\b|\bSFr\b/i, 'CHF']
]

/* ------------------------------ extract ------------------------------- */

export const extractFields = (text: string, items?: TextItem[]): ExtractionResult => {
  const fields: Partial<ZugferdInvoiceData> = {}
  const confidence: Record<string, number> = {}
  const bbox: Record<string, FieldBox> = {}
  const set = (key: string, value: any, conf: number, boxValue?: string) => {
    ;(fields as any)[key] = value
    confidence[key] = conf
    const b = findBox(boxValue ?? String(value), items)
    if (b) bbox[key] = b
  }

  const flat = text.replace(/\r/g, '')

  // Document number
  const docNo = matchLabeled(flat,
    ['Rechnungs?-?\\s?(?:nr|nummer|no)\\.?', 'Invoice\\s*(?:no|number|#)', 'Beleg-?\\s?(?:nr|nummer)', 'Rechnung'],
    '([A-Za-z0-9][A-Za-z0-9\\-_/.]{2,30})')
  if (docNo) set('documentNo', docNo, 0.8)

  // Invoice date
  const dateRaw = matchLabeled(flat, ['Rechnungsdatum', 'Belegdatum', 'Invoice\\s*date', 'Datum', 'Date'], DATE_RE)
  if (dateRaw) { const iso = parseDate(dateRaw); if (iso) set('issueDate', iso, 0.8, dateRaw) }

  // Due date
  const dueRaw = matchLabeled(flat, ['Fälligkeitsdatum', 'fällig\\s*(?:am|bis)?', 'Due\\s*date', 'Zahlbar\\s*bis'], DATE_RE)
  if (dueRaw) { const iso = parseDate(dueRaw); if (iso) set('dueDate', iso, 0.7, dueRaw) }

  // Currency
  for (const [re, code] of CURRENCY_MAP) { if (re.test(flat)) { set('currency', code, 0.6); break } }
  if (!fields.currency) set('currency', 'EUR', 0.3)

  // Grand total (gross / amount due)
  const grossRaw = matchLabeled(flat,
    ['Gesamtbetrag', 'Rechnungsbetrag', 'Zu\\s*zahlen(?:der\\s*Betrag)?', 'Gesamtsumme', 'Bruttobetrag', 'Brutto',
     'Total\\s*(?:amount)?', 'Amount\\s*due', 'Grand\\s*total'],
    AMOUNT_RE)
  if (grossRaw != null) { const a = parseAmount(grossRaw); if (a != null) set('grandTotal', a, 0.7, grossRaw) }

  // Net / line total
  const netRaw = matchLabeled(flat,
    ['Nettobetrag', 'Netto', 'Zwischensumme', 'Gesamt\\s*netto', 'Net\\s*(?:amount|total)?', 'Subtotal'],
    AMOUNT_RE)
  if (netRaw != null) { const a = parseAmount(netRaw); if (a != null) { set('lineTotal', a, 0.6, netRaw); set('taxBasisTotal', a, 0.5) } }

  // Tax (rate + amount). NEVER invent a rate: a guessed "19 %" on a Kleinunternehmer /
  // reverse-charge invoice (gross == net) made the review claim "19 % detected".
  //  - amount: real tax labels only (not "Steuernummer" / "Tax ID" / "USt-IdNr."), and the
  //    value must be a money amount with 2 decimals (a bare "2026" is a year, not a tax);
  //  - rate:   a "n %" that sits next to a tax label; a generic "n %" (Skonto!) only when
  //            it agrees with the amounts; otherwise derived from gross ÷ net;
  //  - exemption phrases (§ 19 UStG, reverse charge, intra-community) → 0 %.
  const TAX_AMOUNT_RE = '(-?[0-9][0-9.]*,[0-9]{2}|-?[0-9][0-9,]*\\.[0-9]{2})'
  const PCT = '(?:\\(?\\s*\\d{1,2}(?:[.,]\\d{1,2})?\\s*%\\s*\\)?)?'
  const taxAmtRaw = matchLabeled(flat, [
    `(?:zzgl\\.?\\s*)?(?:MwSt|USt|Umsatzsteuer|Mehrwertsteuer)\\.?\\s*${PCT}`,
    `\\bVAT\\b(?!\\s*(?:id|no|number|reg))\\s*${PCT}`,
    `\\bTax\\b(?!\\s*(?:id|no|number|invoice))\\s*${PCT}`,
    '\\bSteuer\\b(?!\\s*-?\\s*(?:nr|nummer|id))'
  ], TAX_AMOUNT_RE)
  const netAmt = Number.isFinite(Number(fields.taxBasisTotal)) ? Number(fields.taxBasisTotal) : null
  const grossAmt = Number.isFinite(Number(fields.grandTotal)) ? Number(fields.grandTotal) : null
  let taxAmt = taxAmtRaw != null ? parseAmount(taxAmtRaw) : null
  // a "tax" that reaches the net/gross amount is a misread
  if (taxAmt != null && ((netAmt != null && Math.abs(taxAmt) >= Math.abs(netAmt)) || (grossAmt != null && Math.abs(taxAmt) >= Math.abs(grossAmt)))) taxAmt = null

  const TAX_WORD = '(?:MwSt|USt|Umsatzsteuer|Mehrwertsteuer|VAT|Tax)'
  const nearLabel = flat.match(new RegExp(`${TAX_WORD}[^\\n%]{0,25}?(\\d{1,2}(?:[.,]\\d{1,2})?)\\s*%`, 'i'))
    || flat.match(new RegExp(`(\\d{1,2}(?:[.,]\\d{1,2})?)\\s*%\\s*${TAX_WORD}`, 'i'))
  let rate: number | null = nearLabel ? parseAmount(nearLabel[1]) : null
  if (rate != null && rate > 30) rate = null
  let rateConf = rate != null ? 0.6 : 0

  const agrees = (r: number) =>
    (netAmt != null && taxAmt != null && Math.abs(netAmt * r / 100 - taxAmt) <= 0.05) ||
    (netAmt != null && grossAmt != null && Math.abs(netAmt * (1 + r / 100) - grossAmt) <= 0.05)
  if (rate == null) {
    for (const m of flat.matchAll(/\b(\d{1,2}(?:[.,]\d{1,2})?)\s*%/g)) {
      const r = parseAmount(m[1])
      if (r != null && r > 0 && r <= 30 && agrees(r)) { rate = r; rateConf = 0.55; break }
    }
  }
  if (rate == null && netAmt != null && grossAmt != null && netAmt > 0 && grossAmt > netAmt) {
    const r = Math.round(((grossAmt / netAmt) - 1) * 1000) / 10
    if (r <= 30) { rate = r; rateConf = 0.5 }
  }

  let exemptCategory = ''
  if (/Reverse[\s-]*Charge|Steuerschuldnerschaft\s+des\s+Leistungsempf|§\s*13\s*b/i.test(flat)) exemptCategory = 'AE'
  else if (/innergemeinschaftliche\s+Lieferung|intra[\s-]*community\s+supply/i.test(flat)) exemptCategory = 'K'
  else if (/§\s*19\s*(?:Abs\.?\s*1\s*)?UStG|Kleinunternehmer|VAT\s+exempt|not\s+subject\s+to\s+VAT|umsatzsteuerfrei/i.test(flat)) exemptCategory = 'E'
  const noVatByAmounts = netAmt != null && grossAmt != null && Math.abs(grossAmt - netAmt) < 0.01
  const exempt = !!exemptCategory && (rate == null || noVatByAmounts)
  if (exempt) { rate = 0; rateConf = 0.7; taxAmt = null; (fields as any).taxTotal = 0; confidence['taxTotal'] = 0.7 }   // no box: "0" is not on the page

  if (taxAmt != null) set('taxTotal', taxAmt, 0.55, taxAmtRaw || undefined)
  if (rate != null) {
    fields.taxes = [{
      rate,
      amount: taxAmt ?? (netAmt != null ? Math.round(netAmt * rate) / 100 : 0),
      basis: netAmt ?? 0,
      category: rate > 0 ? 'S' : (exemptCategory || 'Z')
    }]
    confidence['taxes'] = rateConf
  }

  // VAT id (seller)
  const vat = flat.match(/\b([A-Z]{2}\d{8,12})\b/) || flat.match(/USt-?IdNr\.?\s*[:]?\s*([A-Z]{2}\d{8,12})/i)
  // IBAN
  const ibanM = flat.match(/\b([A-Z]{2}\d{2}(?:\s?[A-Z0-9]){11,30})\b/)
  const iban = ibanM ? ibanM[1].replace(/\s+/g, '') : null

  // Seller name — topmost line carrying a legal-form suffix, else first non-trivial line.
  const lines = flat.split('\n').map(l => l.trim()).filter(l => l.length > 2)
  const suffixRe = /\b(GmbH(?:\s*&\s*Co\.?\s*KG)?|AG|UG(?:\s*\(haftungsbeschränkt\))?|KG|OHG|GbR|e\.?K\.?|mbH|Ltd\.?|Inc\.?|S\.?A\.?|B\.?V\.?|e\.?V\.?)\b/i
  let sellerName = lines.find(l => suffixRe.test(l) && l.length <= 60) || lines[0] || ''
  sellerName = sellerName.replace(/\s{2,}/g, ' ').trim()

  const seller: any = {}
  if (sellerName) { seller.name = sellerName; confidence['seller.name'] = suffixRe.test(sellerName) ? 0.6 : 0.35; const b = findBox(sellerName, items); if (b) bbox['seller.name'] = b }
  if (vat) { seller.vatId = vat[1]; confidence['seller.vatId'] = 0.85; const b = findBox(vat[1], items); if (b) bbox['seller.vatId'] = b }
  if (Object.keys(seller).length) fields.seller = seller
  if (iban) { fields.paymentIban = iban; confidence['paymentIban'] = 0.85; const b = findBox(ibanM![1], items); if (b) bbox['paymentIban'] = b }

  // Tax-included heuristic: explicit "inkl. MwSt" / "incl. VAT", or gross≈net+tax.
  if (/inkl\.?\s*(?:MwSt|USt)|incl\.?\s*VAT|Bruttopreise/i.test(flat)) {
    fields.isTaxIncluded = true as any; confidence['isTaxIncluded'] = 0.6
  } else if (/zzgl\.?\s*(?:MwSt|USt)|excl\.?\s*VAT|Nettopreise/i.test(flat)) {
    fields.isTaxIncluded = false as any; confidence['isTaxIncluded'] = 0.6
  }

  return { fields, confidence, bbox }
}

export default extractFields
