/**
 * useAddressFixer — rule-based "magic fix" engine for shipping addresses.
 *
 * Pure, side-effect-free analysis of an editable address (the `shippingForm`
 * shape used on the commissioning page). Produces:
 *   - `fixes`            atomic, explainable corrections for the CURRENT values
 *   - `warnings`         things we cannot fix automatically but the user should see
 *   - `recommendations`  full-address variants (recommended chain, alternatives,
 *                        single fixes, cosmetic cleanup) the user can pick from
 *
 * The engine is deliberately conservative: every fix carries a confidence and a
 * human-readable reason. Low-confidence (cosmetic) fixes never enter the
 * "Recommended" variant — they only show up in a separate "cosmetic" variant.
 *
 * Used by: pages/integrations/commission.vue (address-edit modal "Magic fix").
 */

export type AddressFixConfidence = 'high' | 'medium' | 'low'

export interface FixableAddress {
  name: string
  name2: string
  address: string
  houseNumber: string
  address2: string
  postalCode: string
  city: string
  state: string
  country: string
  countryName: string
  countryIso3: string
}

export type AddressFixField = keyof FixableAddress

export const ADDRESS_FIX_FIELDS: AddressFixField[] = [
  'name', 'name2', 'address', 'houseNumber', 'address2', 'postalCode', 'city', 'state', 'country', 'countryName', 'countryIso3'
]

export interface AddressFixAlternative {
  title: string
  reason?: string
  changes: Partial<FixableAddress>
}

export interface AddressFix {
  id: string
  title: string
  reason: string
  confidence: AddressFixConfidence
  changes: Partial<FixableAddress>
  alternatives?: AddressFixAlternative[]
}

export interface AddressWarning {
  id: string
  title: string
  reason: string
  level: 'warning' | 'info'
}

export interface AppliedAddressFix {
  id: string
  title: string
  reason: string
  confidence: AddressFixConfidence
}

export interface AddressRecommendation {
  id: string
  kind: 'recommended' | 'alternative' | 'single' | 'cosmetic'
  label: string
  address: FixableAddress
  fixes: AppliedAddressFix[]
  changedFields: AddressFixField[]
}

export interface AddressFixReport {
  fixes: AddressFix[]
  warnings: AddressWarning[]
  recommendations: AddressRecommendation[]
  hasSuggestions: boolean
}

export interface AddressFixOptions {
  /** 'dhl' | 'dpd' | anything else → DHL limits */
  carrier?: string
  /** Optional country master list ({ CountryCode, Name, ISOCountryCodeAlpha3 }) to resolve names/ISO3 */
  countries?: any[]
  /** Include low-confidence (cosmetic) fixes in the analysis result */
  includeLow?: boolean
  /**
   * Externally resolved state (e.g. an online postal-code → state lookup).
   * When the address has NO state anywhere (not even misplaced in another
   * field), this becomes a "state-from-lookup" proposal; when a state IS set
   * but contradicts the hint, a warning is raised instead.
   */
  stateHint?: { code: string; name?: string; source?: string } | null
}

// ---------------------------------------------------------------------------
// Reference data
// ---------------------------------------------------------------------------

/** Countries where the house number is written BEFORE the street name. */
const NUMBER_FIRST_COUNTRIES = new Set(['US', 'CA', 'GB', 'AU', 'NZ', 'IE', 'FR', 'LU'])

export const US_STATES: Record<string, string> = {
  AL: 'Alabama', AK: 'Alaska', AZ: 'Arizona', AR: 'Arkansas', CA: 'California', CO: 'Colorado',
  CT: 'Connecticut', DE: 'Delaware', DC: 'District of Columbia', FL: 'Florida', GA: 'Georgia',
  HI: 'Hawaii', ID: 'Idaho', IL: 'Illinois', IN: 'Indiana', IA: 'Iowa', KS: 'Kansas', KY: 'Kentucky',
  LA: 'Louisiana', ME: 'Maine', MD: 'Maryland', MA: 'Massachusetts', MI: 'Michigan', MN: 'Minnesota',
  MS: 'Mississippi', MO: 'Missouri', MT: 'Montana', NE: 'Nebraska', NV: 'Nevada', NH: 'New Hampshire',
  NJ: 'New Jersey', NM: 'New Mexico', NY: 'New York', NC: 'North Carolina', ND: 'North Dakota',
  OH: 'Ohio', OK: 'Oklahoma', OR: 'Oregon', PA: 'Pennsylvania', RI: 'Rhode Island', SC: 'South Carolina',
  SD: 'South Dakota', TN: 'Tennessee', TX: 'Texas', UT: 'Utah', VT: 'Vermont', VA: 'Virginia',
  WA: 'Washington', WV: 'West Virginia', WI: 'Wisconsin', WY: 'Wyoming', PR: 'Puerto Rico',
  GU: 'Guam', VI: 'Virgin Islands', AS: 'American Samoa', MP: 'Northern Mariana Islands'
}

export const CA_PROVINCES: Record<string, string> = {
  AB: 'Alberta', BC: 'British Columbia', MB: 'Manitoba', NB: 'New Brunswick',
  NL: 'Newfoundland and Labrador', NS: 'Nova Scotia', NT: 'Northwest Territories', NU: 'Nunavut',
  ON: 'Ontario', PE: 'Prince Edward Island', QC: 'Quebec', SK: 'Saskatchewan', YT: 'Yukon'
}

export const AU_STATES: Record<string, string> = {
  NSW: 'New South Wales', VIC: 'Victoria', QLD: 'Queensland', WA: 'Western Australia',
  SA: 'South Australia', TAS: 'Tasmania', ACT: 'Australian Capital Territory', NT: 'Northern Territory'
}

const STATE_MAPS: Record<string, Record<string, string>> = { US: US_STATES, CA: CA_PROVINCES, AU: AU_STATES }

/** Minimal country meta so a country change can be applied without the master list. */
const COUNTRY_META: Record<string, { name: string; iso3: string }> = {
  DE: { name: 'Germany', iso3: 'DEU' }, AT: { name: 'Austria', iso3: 'AUT' }, CH: { name: 'Switzerland', iso3: 'CHE' },
  NL: { name: 'Netherlands', iso3: 'NLD' }, BE: { name: 'Belgium', iso3: 'BEL' }, LU: { name: 'Luxembourg', iso3: 'LUX' },
  FR: { name: 'France', iso3: 'FRA' }, IT: { name: 'Italy', iso3: 'ITA' }, ES: { name: 'Spain', iso3: 'ESP' },
  PT: { name: 'Portugal', iso3: 'PRT' }, GB: { name: 'United Kingdom', iso3: 'GBR' }, IE: { name: 'Ireland', iso3: 'IRL' },
  DK: { name: 'Denmark', iso3: 'DNK' }, SE: { name: 'Sweden', iso3: 'SWE' }, NO: { name: 'Norway', iso3: 'NOR' },
  FI: { name: 'Finland', iso3: 'FIN' }, PL: { name: 'Poland', iso3: 'POL' }, CZ: { name: 'Czech Republic', iso3: 'CZE' },
  SK: { name: 'Slovakia', iso3: 'SVK' }, HU: { name: 'Hungary', iso3: 'HUN' }, SI: { name: 'Slovenia', iso3: 'SVN' },
  HR: { name: 'Croatia', iso3: 'HRV' }, RO: { name: 'Romania', iso3: 'ROU' }, BG: { name: 'Bulgaria', iso3: 'BGR' },
  GR: { name: 'Greece', iso3: 'GRC' }, LI: { name: 'Liechtenstein', iso3: 'LIE' }, EE: { name: 'Estonia', iso3: 'EST' },
  LV: { name: 'Latvia', iso3: 'LVA' }, LT: { name: 'Lithuania', iso3: 'LTU' }, US: { name: 'United States', iso3: 'USA' },
  CA: { name: 'Canada', iso3: 'CAN' }, AU: { name: 'Australia', iso3: 'AUS' }, NZ: { name: 'New Zealand', iso3: 'NZL' },
  JP: { name: 'Japan', iso3: 'JPN' }, CN: { name: 'China', iso3: 'CHN' }, TR: { name: 'Turkey', iso3: 'TUR' },
  MX: { name: 'Mexico', iso3: 'MEX' }, BR: { name: 'Brazil', iso3: 'BRA' }, IL: { name: 'Israel', iso3: 'ISR' },
  ZA: { name: 'South Africa', iso3: 'ZAF' }, IN: { name: 'India', iso3: 'IND' }, SG: { name: 'Singapore', iso3: 'SGP' },
  KR: { name: 'South Korea', iso3: 'KOR' }, AE: { name: 'United Arab Emirates', iso3: 'ARE' }
}

/** Country names (en/de/native, lower-case) → ISO2. Used to spot country names typed into other fields. */
const COUNTRY_NAME_TO_CODE: Record<string, string> = {
  'germany': 'DE', 'deutschland': 'DE', 'allemagne': 'DE', 'alemania': 'DE', 'brd': 'DE',
  'austria': 'AT', 'österreich': 'AT', 'oesterreich': 'AT', 'osterreich': 'AT',
  'switzerland': 'CH', 'schweiz': 'CH', 'suisse': 'CH', 'svizzera': 'CH',
  'netherlands': 'NL', 'niederlande': 'NL', 'nederland': 'NL', 'holland': 'NL', 'the netherlands': 'NL',
  'belgium': 'BE', 'belgien': 'BE', 'belgique': 'BE', 'belgië': 'BE', 'belgie': 'BE',
  'luxembourg': 'LU', 'luxemburg': 'LU',
  'france': 'FR', 'frankreich': 'FR', 'italy': 'IT', 'italien': 'IT', 'italia': 'IT',
  'spain': 'ES', 'spanien': 'ES', 'españa': 'ES', 'espana': 'ES', 'portugal': 'PT',
  'united kingdom': 'GB', 'great britain': 'GB', 'großbritannien': 'GB', 'grossbritannien': 'GB', 'england': 'GB',
  'scotland': 'GB', 'wales': 'GB', 'northern ireland': 'GB', 'uk': 'GB', 'u.k.': 'GB',
  'ireland': 'IE', 'irland': 'IE', 'denmark': 'DK', 'dänemark': 'DK', 'danmark': 'DK',
  'sweden': 'SE', 'schweden': 'SE', 'sverige': 'SE', 'norway': 'NO', 'norwegen': 'NO', 'norge': 'NO',
  'finland': 'FI', 'finnland': 'FI', 'suomi': 'FI', 'poland': 'PL', 'polen': 'PL', 'polska': 'PL',
  'czech republic': 'CZ', 'czechia': 'CZ', 'tschechien': 'CZ', 'česko': 'CZ', 'slovakia': 'SK', 'slowakei': 'SK',
  'hungary': 'HU', 'ungarn': 'HU', 'slovenia': 'SI', 'slowenien': 'SI', 'croatia': 'HR', 'kroatien': 'HR',
  'romania': 'RO', 'rumänien': 'RO', 'bulgaria': 'BG', 'bulgarien': 'BG', 'greece': 'GR', 'griechenland': 'GR',
  'liechtenstein': 'LI', 'estonia': 'EE', 'estland': 'EE', 'latvia': 'LV', 'lettland': 'LV', 'lithuania': 'LT', 'litauen': 'LT',
  'united states': 'US', 'united states of america': 'US', 'usa': 'US', 'u.s.a.': 'US', 'u.s.': 'US', 'america': 'US', 'vereinigte staaten': 'US',
  'canada': 'CA', 'kanada': 'CA', 'australia': 'AU', 'australien': 'AU', 'new zealand': 'NZ', 'neuseeland': 'NZ',
  'japan': 'JP', 'china': 'CN', 'turkey': 'TR', 'türkei': 'TR', 'türkiye': 'TR', 'mexico': 'MX', 'mexiko': 'MX',
  'brazil': 'BR', 'brasilien': 'BR', 'israel': 'IL', 'south africa': 'ZA', 'südafrika': 'ZA', 'india': 'IN', 'indien': 'IN',
  'singapore': 'SG', 'singapur': 'SG', 'south korea': 'KR', 'südkorea': 'KR', 'united arab emirates': 'AE', 'vae': 'AE', 'uae': 'AE'
}

/** Postal-code shape per country (after normalisation). */
const POSTAL_PATTERNS: Record<string, { re: RegExp; hint: string }> = {
  DE: { re: /^\d{5}$/, hint: '5 digits' }, AT: { re: /^\d{4}$/, hint: '4 digits' }, CH: { re: /^\d{4}$/, hint: '4 digits' },
  LI: { re: /^\d{4}$/, hint: '4 digits' }, NL: { re: /^\d{4} [A-Z]{2}$/, hint: '4 digits + 2 letters (1234 AB)' },
  BE: { re: /^\d{4}$/, hint: '4 digits' }, LU: { re: /^\d{4}$/, hint: '4 digits' }, DK: { re: /^\d{4}$/, hint: '4 digits' },
  FR: { re: /^\d{5}$/, hint: '5 digits' }, IT: { re: /^\d{5}$/, hint: '5 digits' }, ES: { re: /^\d{5}$/, hint: '5 digits' },
  PT: { re: /^\d{4}-\d{3}$/, hint: '1234-567' }, PL: { re: /^\d{2}-\d{3}$/, hint: '12-345' },
  CZ: { re: /^\d{3} \d{2}$/, hint: '123 45' }, SK: { re: /^\d{3} \d{2}$/, hint: '123 45' }, SE: { re: /^\d{3} \d{2}$/, hint: '123 45' },
  NO: { re: /^\d{4}$/, hint: '4 digits' }, FI: { re: /^\d{5}$/, hint: '5 digits' }, HU: { re: /^\d{4}$/, hint: '4 digits' },
  SI: { re: /^\d{4}$/, hint: '4 digits' }, HR: { re: /^\d{5}$/, hint: '5 digits' }, RO: { re: /^\d{6}$/, hint: '6 digits' },
  BG: { re: /^\d{4}$/, hint: '4 digits' }, GR: { re: /^\d{3} \d{2}$/, hint: '123 45' }, EE: { re: /^\d{5}$/, hint: '5 digits' },
  LV: { re: /^LV-\d{4}$/, hint: 'LV-1234' }, LT: { re: /^LT-\d{5}$/, hint: 'LT-12345' },
  GB: { re: /^[A-Z]{1,2}\d[A-Z\d]? \d[A-Z]{2}$/, hint: 'UK postcode (SW1A 1AA)' },
  IE: { re: /^[A-Z]\d{2} [A-Z\d]{4}$/, hint: 'Eircode (D02 X285)' },
  US: { re: /^\d{5}(?:-\d{4})?$/, hint: '5 digits (12345 or 12345-6789)' },
  CA: { re: /^[A-Z]\d[A-Z] \d[A-Z]\d$/, hint: 'A1A 1A1' }, AU: { re: /^\d{4}$/, hint: '4 digits' }, NZ: { re: /^\d{4}$/, hint: '4 digits' },
  JP: { re: /^\d{3}-\d{4}$/, hint: '123-4567' }, CN: { re: /^\d{6}$/, hint: '6 digits' }, IN: { re: /^\d{6}$/, hint: '6 digits' },
  BR: { re: /^\d{5}-\d{3}$/, hint: '12345-678' }, MX: { re: /^\d{5}$/, hint: '5 digits' }, TR: { re: /^\d{5}$/, hint: '5 digits' },
  IL: { re: /^\d{5,7}$/, hint: '5–7 digits' }, ZA: { re: /^\d{4}$/, hint: '4 digits' }, KR: { re: /^\d{5}$/, hint: '5 digits' },
  SG: { re: /^\d{6}$/, hint: '6 digits' }
}

/** Carrier field length limits (DHL Parcel DE Shipping API v2 / DPD). */
const CARRIER_LIMITS: Record<string, Record<string, number>> = {
  dhl: { name: 50, name2: 50, address: 50, houseNumber: 10, address2: 50, postalCode: 10, city: 40, state: 20 },
  dpd: { name: 35, name2: 35, address: 35, houseNumber: 8, address2: 35, postalCode: 9, city: 35, state: 20 }
}

const FIELD_LABELS: Record<AddressFixField, string> = {
  name: 'Name', name2: 'Name 2', address: 'Street', houseNumber: 'Nr.', address2: 'Address 2',
  postalCode: 'Postal', city: 'City', state: 'State', country: 'Country', countryName: 'Country name', countryIso3: 'Country ISO3'
}

export const addressFieldLabel = (f: AddressFixField) => FIELD_LABELS[f] || f

// ---------------------------------------------------------------------------
// Regex building blocks
// ---------------------------------------------------------------------------

// House number: "5", "5a", "5 a", "12B", "5-7", "5/3", "12bis", "5 - 7"
const HOUSE_NO_SRC = '\\d{1,5}(?:[a-zA-Z]{1,2}(?![a-zA-Z])|\\s[a-zA-Z](?![a-zA-Z])|\\s?(?:bis|ter)(?![a-zA-Z]))?(?:\\s?[-/]\\s?\\d{1,5}(?:[a-zA-Z](?![a-zA-Z]))?)?'
const HOUSE_NO_EXACT_RE = new RegExp(`^${HOUSE_NO_SRC}$`, 'i')
// Prefixes people type in front of a house number
const HOUSE_NO_PREFIX_RE = /^(?:nr\.?|no\.?|n°|num\.?|number|haus\s*nr\.?|haus|hausnummer|hausnr\.?|#)\s*/i
// Street where the number comes LAST (DE/AT/CH/NL/…): "<street> <nr>[ <rest>]"
const STREET_NUMBER_LAST_RE = new RegExp(`^(.+?)[\\s,]+(${HOUSE_NO_SRC})(?:[\\s,]+(.*))?$`, 'i')
// Street where the number comes FIRST (US/GB/FR/…): "<nr> <street>"
const STREET_NUMBER_FIRST_RE = new RegExp(`^(${HOUSE_NO_SRC})[\\s,]+([A-Za-zÀ-ÿ#].*)$`, 'i')
// Unit / floor / apartment suffixes that belong into Address 2
const UNIT_SUFFIX_RE = /[\s,]+((?:\d{1,3}\.?\s+)?(?:apt|apartment|suite|ste|unit|floor|fl|bldg|building|room|rm|dept|department|#|og|eg|dg|ug|whg|wohnung|stock|etage|zimmer|app|appartement|appartment|top|tür|tuer|stiege|geb|gebäude|gebaeude|haus|block|trakt|aufgang|hinterhaus|vorderhaus|seitenflügel|gartenhaus|bei|c\/o)\.?\s*[^,]*)$/i
const CO_RE = /(?:^|[\s,])(c\/o\.?|care of|bei|z\.?\s?hd\.?|z\.hd\.|per adresse|p\.a\.)\s+(.+)$/i
const PO_BOX_RE = /\b(postfach|po\s*box|p\.?\s?o\.?\s?box|boite postale|bo[iî]te postale|apartado|casella postale|postbus|postboks)\b/i
const PACKSTATION_RE = /\b(packstation|postfiliale|paketshop|paketbox|dhl\s*shop|pickup\s*point|parcel\s*shop|locker)\b/i
const EMAIL_RE = /[\w.+-]+@[\w-]+\.[\w.-]+/
const PHONE_RE = /(?:\+|00)\s?\d[\d\s/().-]{6,}\d|\b(?:tel|tel\.|phone|mobil|mobile|handy|fon)\s*[:.]?\s*[+\d][\d\s/().-]{5,}\d/i
const EMAIL_LABEL_RE = /\b(?:e-?mail|mail)\s*[:.]?\s*/i

// ---------------------------------------------------------------------------
// String helpers
// ---------------------------------------------------------------------------

const str = (v: any) => (v === null || v === undefined) ? '' : String(v)

const clean = (v: any) => str(v)
  .replace(/[ \t\r\n]+/g, ' ')
  .replace(/\s{2,}/g, ' ')
  .replace(/\s+([,;])/g, '$1')
  .replace(/^[\s,;"'“”]+|[\s,;"'“”]+$/g, '')
  .trim()

const hasLetter = (v: string) => /[A-Za-zÀ-ÿ]/.test(v)
const hasDigit = (v: string) => /\d/.test(v)
const isHouseNumber = (v: string) => HOUSE_NO_EXACT_RE.test(clean(v))
const normKey = (v: string) => clean(v).toLowerCase().replace(/[.\s,-]+/g, ' ').trim()
const same = (a: string, b: string) => normKey(a) === normKey(b) && normKey(a) !== ''
const stripHouseNoPrefix = (v: string) => clean(v).replace(HOUSE_NO_PREFIX_RE, '').trim()
const tidyHouseNo = (v: string) => clean(v).replace(/\s*([-/])\s*/g, '$1').replace(/^(\d+)\s+([a-zA-Z])$/, '$1$2')
const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')

const TITLE_KEEP: Record<string, string> = {
  gmbh: 'GmbH', ag: 'AG', kg: 'KG', ohg: 'OHG', ug: 'UG', ek: 'e.K.', 'e.k.': 'e.K.', mbh: 'mbH', 'co.': 'Co.', co: 'Co',
  llc: 'LLC', inc: 'Inc', 'inc.': 'Inc.', ltd: 'Ltd', 'ltd.': 'Ltd.', plc: 'PLC', sa: 'SA', sl: 'SL', 'sl.': 'SL.', srl: 'SRL',
  bv: 'BV', 'b.v.': 'B.V.', nv: 'NV', sarl: 'SARL', sas: 'SAS', se: 'SE', 'dr.': 'Dr.', dr: 'Dr.', 'prof.': 'Prof.',
  von: 'von', van: 'van', de: 'de', der: 'der', den: 'den', du: 'du', la: 'la', le: 'le', di: 'di', da: 'da', y: 'y', und: 'und', and: 'and',
  usa: 'USA', uk: 'UK', po: 'PO', ny: 'NY', nyc: 'NYC', la_: 'LA', dc: 'DC', st: 'St', 'st.': 'St.', nw: 'NW', ne: 'NE', sw: 'SW', se_: 'SE'
}

const toTitleCase = (v: string) => {
  const words = clean(v).toLowerCase().split(' ')
  return words.map((w, i) => {
    const keep = TITLE_KEEP[w]
    if (keep && !(i === 0 && ['von', 'van', 'de', 'der', 'den', 'du', 'la', 'le', 'di', 'da', 'y', 'und', 'and'].includes(w))) return keep
    // hyphenated / slashed parts each capitalised: "müller-lüdenscheidt"
    return w.split(/([-/'’])/).map((p) => p.length && !/[-/'’]/.test(p) ? p.charAt(0).toUpperCase() + p.slice(1) : p).join('')
  }).join(' ')
}

const isShouty = (v: string) => {
  const letters = v.replace(/[^A-Za-zÀ-ÿ]/g, '')
  return letters.length >= 4 && letters === letters.toUpperCase()
}
const isAllLower = (v: string) => {
  const letters = v.replace(/[^A-Za-zÀ-ÿ]/g, '')
  return letters.length >= 4 && letters === letters.toLowerCase()
}

const countryCodeOf = (addr: FixableAddress) => {
  const c = str(addr.country).toUpperCase()
  if (c) return c
  const iso3 = str(addr.countryIso3).toUpperCase()
  const hit = Object.entries(COUNTRY_META).find(([, m]) => m.iso3 === iso3)
  return hit ? hit[0] : ''
}

const resolveCountryMeta = (code: string, countries?: any[]) => {
  const upper = str(code).toUpperCase()
  const fromList = (countries || []).find((c: any) => str(c?.CountryCode).toUpperCase() === upper)
  if (fromList) {
    return { name: str(fromList.Name || upper), iso3: str(fromList.ISOCountryCodeAlpha3 || COUNTRY_META[upper]?.iso3 || '') }
  }
  return COUNTRY_META[upper] || { name: upper, iso3: '' }
}

// ---------------------------------------------------------------------------
// Street parsing
// ---------------------------------------------------------------------------

interface StreetParse { street: string; houseNumber: string; rest: string; order: 'last' | 'first' }

/** Split "<street> <nr> [<rest>]" (number-last, DE style). */
const parseNumberLast = (text: string): StreetParse | null => {
  const t = clean(text)
  const m = t.match(STREET_NUMBER_LAST_RE)
  if (!m) return null
  const street = clean(m[1])
  if (!hasLetter(street)) return null
  // Numbers that are part of the street name ("Straße des 17. Juni") are followed by a dot
  return { street, houseNumber: tidyHouseNo(m[2]), rest: clean(m[3] || ''), order: 'last' }
}

/** Split "<nr> <street>" (number-first, US style). */
const parseNumberFirst = (text: string): StreetParse | null => {
  const t = clean(text)
  const m = t.match(STREET_NUMBER_FIRST_RE)
  if (!m) return null
  let street = clean(m[2])
  let rest = ''
  const u = street.match(UNIT_SUFFIX_RE)
  if (u && clean(street.slice(0, u.index)).length > 0) {
    rest = clean(u[1])
    street = clean(street.slice(0, u.index))
  }
  if (!hasLetter(street)) return null
  return { street, houseNumber: tidyHouseNo(m[1]), rest, order: 'first' }
}

/** Parse a street+number combination according to the country's convention; returns primary + alternative. */
const parseStreet = (text: string, country: string): { primary: StreetParse | null; alternative: StreetParse | null } => {
  const numberFirst = NUMBER_FIRST_COUNTRIES.has(country)
  const first = parseNumberFirst(text)
  const last = parseNumberLast(text)
  if (numberFirst) return { primary: first || last, alternative: first && last ? last : null }
  return { primary: last || first, alternative: last && first ? first : null }
}

/** Pull a unit/floor suffix off a street that has no number: "Hauptstraße Apt 4" → rest */
const splitUnitFromStreet = (street: string) => {
  const m = clean(street).match(UNIT_SUFFIX_RE)
  if (!m || m.index === undefined) return null
  const base = clean(street.slice(0, m.index))
  if (!base || !hasLetter(base)) return null
  return { street: base, rest: clean(m[1]) }
}

// ---------------------------------------------------------------------------
// State detection
// ---------------------------------------------------------------------------

interface StateHit { code: string; name: string; rest: string }

/** Find a state/province code or full name inside `text`. `mode: 'trailing'` only accepts hits at the end. */
const findStateIn = (text: string, map: Record<string, string>, mode: 'anywhere' | 'trailing'): StateHit | null => {
  const t = clean(text)
  if (!t) return null
  // 1) upper-case code token ("Miami FL", "Miami, FL", "FL")
  const codeRe = mode === 'trailing'
    ? /(?:^|[\s,])([A-Z]{2,3})\.?$/
    : /(?:^|[\s,])([A-Z]{2,3})\.?(?=$|[\s,])/
  const cm = t.match(codeRe)
  if (cm && map[cm[1]]) {
    const rest = clean(t.replace(new RegExp(`(?:^|[\\s,])${cm[1]}\\.?(?=$|[\\s,])`), ' '))
    return { code: cm[1], name: map[cm[1]], rest }
  }
  // 2) full name ("Los Angeles, California", "Florida")
  const names = Object.entries(map).sort((a, b) => b[1].length - a[1].length)
  for (const [code, name] of names) {
    const re = mode === 'trailing'
      ? new RegExp(`(?:^|[\\s,])${escapeRe(name)}\\.?$`, 'i')
      : new RegExp(`(?:^|[\\s,])${escapeRe(name)}\\.?(?=$|[\\s,])`, 'i')
    const nm = t.match(re)
    if (nm) {
      const rest = clean(t.replace(re, ' '))
      // A city that IS the state name ("Washington") is not a state hint
      if (mode === 'trailing' && !rest) continue
      return { code, name, rest }
    }
  }
  return null
}

const stateCodeFromValue = (value: string, map: Record<string, string>) => {
  const v = clean(value)
  if (!v) return null
  const upper = v.toUpperCase().replace(/\.$/, '')
  if (map[upper]) return { code: upper, name: map[upper] }
  const byName = Object.entries(map).find(([, n]) => n.toLowerCase() === v.toLowerCase())
  return byName ? { code: byName[0], name: byName[1] } : null
}

// ---------------------------------------------------------------------------
// Postal helpers
// ---------------------------------------------------------------------------

const POSTAL_COUNTRY_PREFIX_RE = /^(?:D|DE|A|AT|CH|NL|B|BE|F|FR|I|IT|E|ES|L|LU|DK|PL|CZ|SK|S|SE|N|NO|FIN|FI|GB|UK|US|USA|P|PT|H|HU|SLO|SI|HR|RO|BG|GR|EST|EE|IRL|IE|CDN|CA|AUS|AU|J|JP|TR)\s*[-–—]\s*(?=[A-Za-z0-9])/i

/** Normalise a postal code for the given country. Returns the normalised value (may equal the input). */
const normalisePostal = (postal: string, country: string): string => {
  let p = clean(postal).toUpperCase()
  if (!p) return p
  // Country prefix "D-10115", "CH-8000", "A-1010"
  const stripped = p.replace(POSTAL_COUNTRY_PREFIX_RE, '')
  if (stripped !== p && stripped.length >= 3) p = stripped
  const pat = POSTAL_PATTERNS[country]
  const digitsOnly = p.replace(/\D/g, '')
  switch (country) {
    case 'NL': {
      const m = p.replace(/\s+/g, '').match(/^(\d{4})([A-Z]{2})$/)
      if (m) p = `${m[1]} ${m[2]}`
      break
    }
    case 'GB': {
      const compact = p.replace(/\s+/g, '')
      if (/^[A-Z]{1,2}\d[A-Z\d]?\d[A-Z]{2}$/.test(compact)) p = `${compact.slice(0, -3)} ${compact.slice(-3)}`
      break
    }
    case 'IE': {
      const compact = p.replace(/\s+/g, '')
      if (/^[A-Z]\d{2}[A-Z\d]{4}$/.test(compact)) p = `${compact.slice(0, 3)} ${compact.slice(3)}`
      break
    }
    case 'CA': {
      const compact = p.replace(/\s+/g, '')
      if (/^[A-Z]\d[A-Z]\d[A-Z]\d$/.test(compact)) p = `${compact.slice(0, 3)} ${compact.slice(3)}`
      break
    }
    case 'US': {
      if (/^\d{9}$/.test(digitsOnly) && /^[\d\s-]+$/.test(p)) p = `${digitsOnly.slice(0, 5)}-${digitsOnly.slice(5)}`
      else if (/^\d{5}$/.test(digitsOnly) && /^[\d\s-]+$/.test(p)) p = digitsOnly
      else if (/^\d{1,4}$/.test(digitsOnly) && /^\d+$/.test(p)) p = digitsOnly.padStart(5, '0') // leading zeros dropped by spreadsheets
      break
    }
    case 'PL': {
      if (/^\d{5}$/.test(digitsOnly) && /^[\d\s-]+$/.test(p)) p = `${digitsOnly.slice(0, 2)}-${digitsOnly.slice(2)}`
      break
    }
    case 'PT': {
      if (/^\d{7}$/.test(digitsOnly) && /^[\d\s-]+$/.test(p)) p = `${digitsOnly.slice(0, 4)}-${digitsOnly.slice(4)}`
      break
    }
    case 'CZ': case 'SK': case 'SE': case 'GR': {
      if (/^\d{5}$/.test(digitsOnly) && /^[\d\s-]+$/.test(p)) p = `${digitsOnly.slice(0, 3)} ${digitsOnly.slice(3)}`
      break
    }
    case 'JP': {
      if (/^\d{7}$/.test(digitsOnly) && /^[\d\s-]+$/.test(p)) p = `${digitsOnly.slice(0, 3)}-${digitsOnly.slice(3)}`
      break
    }
    case 'BR': {
      if (/^\d{8}$/.test(digitsOnly) && /^[\d\s.-]+$/.test(p)) p = `${digitsOnly.slice(0, 5)}-${digitsOnly.slice(5)}`
      break
    }
    case 'LV': {
      if (/^\d{4}$/.test(digitsOnly)) p = `LV-${digitsOnly}`
      break
    }
    case 'LT': {
      if (/^\d{5}$/.test(digitsOnly)) p = `LT-${digitsOnly}`
      break
    }
    default: {
      // Plain numeric postal codes: drop inner spaces/dots ("10 115" → "10115")
      if (pat && /^\^\\d\{\d(?:,\d)?\}\$$/.test(pat.re.source) && /^[\d\s.]+$/.test(p)) p = digitsOnly
      // Leading zeros lost (DE "1067" → "01067") — only when the country has a fixed-length numeric pattern
      if (country === 'DE' && /^\d{4}$/.test(p)) p = '0' + p
    }
  }
  return p
}

const postalMatches = (postal: string, country: string) => {
  const pat = POSTAL_PATTERNS[country]
  if (!pat) return true
  return pat.re.test(clean(postal).toUpperCase())
}

/** Regex source that matches one postal code of the country (loose, for extraction out of free text). */
const postalLooseSrc = (country: string) => {
  switch (country) {
    case 'GB': return '[A-Z]{1,2}\\d[A-Z\\d]?\\s?\\d[A-Z]{2}'
    case 'IE': return '[A-Z]\\d{2}\\s?[A-Z\\d]{4}'
    case 'CA': return '[A-Z]\\d[A-Z]\\s?\\d[A-Z]\\d'
    case 'NL': return '\\d{4}\\s?[A-Z]{2}'
    case 'US': return '\\d{5}(?:-\\d{4})?'
    case 'PL': return '\\d{2}-?\\d{3}'
    case 'PT': return '\\d{4}-?\\d{3}'
    case 'CZ': case 'SK': case 'SE': case 'GR': return '\\d{3}\\s?\\d{2}'
    case 'JP': return '\\d{3}-?\\d{4}'
    case 'BR': return '\\d{5}-?\\d{3}'
    case 'LV': return '(?:LV-)?\\d{4}'
    case 'LT': return '(?:LT-)?\\d{5}'
    case 'RO': case 'CN': case 'IN': case 'SG': return '\\d{6}'
    case 'DE': case 'FR': case 'IT': case 'ES': case 'FI': case 'HR': case 'EE': case 'MX': case 'TR': case 'KR': return '\\d{5}'
    case 'AT': case 'CH': case 'LI': case 'BE': case 'LU': case 'DK': case 'NO': case 'HU': case 'SI': case 'BG': case 'AU': case 'NZ': case 'ZA': return '\\d{4}'
    default: return '\\d{4,6}'
  }
}

// ---------------------------------------------------------------------------
// Rules
// ---------------------------------------------------------------------------

type Rule = (a: FixableAddress, ctx: RuleContext) => AddressFix | AddressFix[] | null | undefined

interface RuleContext {
  country: string
  limits: Record<string, number>
  countries?: any[]
  includeLow: boolean
  stateHint?: { code: string; name?: string; source?: string } | null
}

const F = (id: string, title: string, reason: string, confidence: AddressFixConfidence, changes: Partial<FixableAddress>, alternatives?: AddressFixAlternative[]): AddressFix => ({
  id, title, reason, confidence, changes, ...(alternatives && alternatives.length ? { alternatives } : {})
})

/** Merge `extra` into address2 unless already present. */
const mergeAddress2 = (existing: string, extra: string) => {
  const e = clean(existing)
  const x = clean(extra)
  if (!x) return e
  if (!e) return x
  if (normKey(e).includes(normKey(x))) return e
  return `${e}, ${x}`
}

// 0a. DHL Packstation — canonical layout: Street "Packstation", Nr. = 3-digit
// station number, Name 2 = 6-10 digit DHL Postnummer. People scramble these
// three tokens across Street/Nr./Address 2/Name 2/Name, so scan everywhere.
const rulePackstation: Rule = (a) => {
  const scan: AddressFixField[] = ['address', 'houseNumber', 'address2', 'name2', 'name', 'city']
  const joined = scan.map((f) => clean(a[f])).join(' | ')
  if (!/packstation/i.test(joined)) return null

  let station = ''
  let postnummer = ''
  for (const f of scan) {
    const v = clean(a[f])
    if (!v) continue
    if (!station) {
      const near = v.match(/packstation\s*[:#.-]?\s*(\d{3})(?!\d)/i) || v.match(/(?<!\d)(\d{3})\s*packstation/i)
      if (near) station = near[1]
    }
    if (!postnummer) {
      const post = v.match(/(?<!\d)(\d{6,10})(?!\d)/)
      if (post) postnummer = post[1]
    }
  }
  // Fallback: a stand-alone 3-digit number in Nr. / Address 2 / Name 2 is the station number
  if (!station) {
    for (const f of ['houseNumber', 'address2', 'name2'] as AddressFixField[]) {
      const m = clean(a[f]).match(/(?<!\d)(\d{3})(?!\d)/)
      if (m) { station = m[1]; break }
    }
  }
  if (!station) return null

  // Already in canonical shape → nothing to do
  if (same(a.address, 'Packstation') && same(a.houseNumber, station)
    && (!postnummer || same(a.name2, postnummer))
    && !/packstation/i.test(`${clean(a.address2)} ${clean(a.name)} ${clean(a.city)}`)
    && !(postnummer && `${clean(a.address2)} ${clean(a.name)}`.includes(postnummer))) return null

  // Strip the recognised tokens out of a field, keep whatever else it says
  const strip = (v: any) => clean(str(v)
    .replace(/packstation\s*[:#.-]?\s*\d{3}(?!\d)/gi, ' ')
    .replace(/(?<!\d)\d{3}\s*packstation/gi, ' ')
    .replace(/packstation/gi, ' ')
    .replace(postnummer ? new RegExp(`(?<!\\d)${postnummer}(?!\\d)`, 'g') : /$^/, ' ')
    .replace(new RegExp(`(?<!\\d)${station}(?!\\d)`, 'g'), ' ')
    .replace(/\b(?:postnummer|post-?nr\.?|dhl)\s*[:#.-]?/gi, ' ')
    .replace(/^(?:nr\.?|no\.?|nummer|haus|#|[-,.:;\s])+$/i, ''))

  const changes: Partial<FixableAddress> = { address: 'Packstation', houseNumber: station }
  const name2Rest = strip(a.name2)
  const a2Rest = strip(a.address2)
  if (postnummer) {
    changes.name2 = postnummer
    changes.address2 = name2Rest && !same(name2Rest, postnummer) ? mergeAddress2(a2Rest, name2Rest) : a2Rest
  } else {
    if (str(a.address2) !== a2Rest) changes.address2 = a2Rest
    if (str(a.name2) !== name2Rest) changes.name2 = name2Rest
  }
  const nameRest = strip(a.name)
  if (nameRest !== clean(a.name) && nameRest) changes.name = nameRest
  const cityRest = strip(a.city)
  if (cityRest !== clean(a.city) && cityRest) changes.city = cityRest

  return F('packstation', 'Format as DHL Packstation address',
    `Detected a DHL Packstation${station ? ` (station ${station}` : ''}${postnummer ? `, Postnummer ${postnummer})` : station ? ')' : ''}. DHL expects: Street "Packstation", Nr. = station number${postnummer ? ', Name 2 = the Postnummer' : ''}.${postnummer ? '' : ' No 6-10 digit Postnummer found — DHL needs it in Name 2.'}`,
    postnummer ? 'high' : 'medium', changes)
}

// 0b. Street written into Name 2 (or Address 2) while the Street field holds a
// (often duplicated) person/company name.
// German/Dutch street suffixes are glued to the name ("Bahnhofstr.", "Ringstra\u00dfe")
// \u2192 match at the END of a word; international street words stand alone.
const STREET_SUFFIX_RE = /(stra\u00dfe|strasse|str\.?|weg|allee|platz|gasse|ring|damm|ufer|chaussee|steig|pfad|zeile|markt|straat|laan|gracht)(?=$|[\s,.:;])/i
const STREET_TOKEN_RE = /\b(street|road|rd\.?|lane|ln\.?|drive|dr\.?|court|ct\.?|avenue|ave\.?|blvd\.?|boulevard|rue|calle|carrer|via|viale|piazza|plein)\b/i
const hasStreetWord = (v: string) => STREET_SUFFIX_RE.test(v) || STREET_TOKEN_RE.test(v)
const UNIT_WORD_START_RE = /^(?:og|eg|dg|ug|whg|wohnung|apt|apartment|suite|ste|unit|floor|fl|etage|stock|zimmer|top|stiege|geb|c\/o|bei|hinterhaus|vorderhaus)\b/i

const looksLikeStreetText = (v: string) => {
  const t = clean(v)
  if (!t || !hasLetter(t) || UNIT_WORD_START_RE.test(t)) return false
  if (hasStreetWord(t)) return true
  const p = parseNumberLast(t) || parseNumberFirst(t)
  return !!(p && p.houseNumber && p.street.length >= 4 && !UNIT_WORD_START_RE.test(p.street))
}
const looksLikeNameText = (v: string) => {
  const t = clean(v)
  return !!t && hasLetter(t) && !hasDigit(t) && !hasStreetWord(t)
}

const ruleStreetInName2: Rule = (a, ctx) => {
  const street = clean(a.address)
  const name = clean(a.name)
  const streetIsDupName = !!street && !!name && same(street, name)
  const streetIsNamey = !!street && looksLikeNameText(street) && !looksLikeStreetText(street)
  // Only act when the Street field is clearly NOT a street: empty, a duplicate
  // of the recipient name, or plain name-like text
  if (street && !streetIsDupName && !streetIsNamey) return null

  for (const src of ['name2', 'address2'] as AddressFixField[]) {
    const v = clean(a[src])
    if (!v || !looksLikeStreetText(v)) continue
    const parsed = parseStreet(v, ctx.country).primary
    const changes: Partial<FixableAddress> = { address: parsed ? parsed.street : v }
    if (parsed?.houseNumber && (!clean(a.houseNumber) || same(a.houseNumber, parsed.houseNumber))) changes.houseNumber = parsed.houseNumber
    if (parsed?.rest && src !== 'address2') changes.address2 = mergeAddress2(a.address2, parsed.rest)

    let reason: string
    let confidence: AddressFixConfidence = 'high'
    if (streetIsDupName) {
      changes[src] = (parsed?.rest && src === 'address2') ? parsed.rest : ''
      reason = `${FIELD_LABELS[src]} contains the street ("${v}") while the Street field just repeats the recipient name ("${street}"). Move the street into place and drop the duplicated name.`
    } else if (!street) {
      changes[src] = (parsed?.rest && src === 'address2') ? parsed.rest : ''
      reason = `The Street field is empty and ${FIELD_LABELS[src]} contains the street ("${v}").`
    } else {
      // A different name in the Street field: keep it as Name 2 (c/o line)
      changes[src] = (parsed?.rest && src === 'address2') ? parsed.rest : ''
      if (src !== 'name2' || !same(street, clean(a.name2))) changes.name2 = street
      reason = `"${v}" in ${FIELD_LABELS[src]} is a street while "${street}" in the Street field looks like a name — swap them (the name moves to Name 2).`
      confidence = 'medium'
    }
    return F('street-in-' + src, `Move street from ${FIELD_LABELS[src]} to Street`, reason, confidence, changes)
  }
  return null
}

// 0. Swapped / misplaced fields ---------------------------------------------
const ruleSwappedFields: Rule = (a) => {
  const out: AddressFix[] = []
  const street = clean(a.address)
  const nr = clean(a.houseNumber)
  const postal = clean(a.postalCode)
  const city = clean(a.city)

  // Street field holds only a house number, Nr. field holds the street name
  if (street && isHouseNumber(stripHouseNoPrefix(street)) && nr && hasLetter(nr) && !isHouseNumber(nr) && !hasDigit(nr)) {
    out.push(F('swap-street-nr', 'Swap Street and Nr.', `"${street}" looks like a house number and "${nr}" like a street name — the fields seem swapped.`, 'high',
      { address: nr, houseNumber: tidyHouseNo(stripHouseNoPrefix(street)) }))
  } else if (street && isHouseNumber(stripHouseNoPrefix(street)) && !nr) {
    out.push(F('street-is-number', 'Move number from Street to Nr.', `The Street field only contains "${street}", which is a house number. The street name is missing.`, 'medium',
      { address: '', houseNumber: tidyHouseNo(stripHouseNoPrefix(street)) }))
  }

  // Postal and City swapped ("Berlin" in postal, "10115" in city)
  if (postal && city && hasLetter(postal) && !hasDigit(postal) && /^\d[\d\s-]*$/.test(city)) {
    out.push(F('swap-postal-city', 'Swap Postal and City', `"${postal}" is not a postal code and "${city}" is — the fields seem swapped.`, 'high',
      { postalCode: city, city: postal }))
  }

  // Name empty but Name 2 filled (but never promote a street-looking Name 2)
  if (!clean(a.name) && clean(a.name2) && !looksLikeStreetText(clean(a.name2))) {
    out.push(F('name2-to-name', 'Use Name 2 as recipient name', 'The recipient name is empty but Name 2 is filled — the carrier needs the name in the first line.', 'high',
      { name: clean(a.name2), name2: '' }))
  }

  // Street empty but Address 2 holds a street
  if (!street && clean(a.address2) && hasLetter(a.address2)) {
    out.push(F('address2-to-street', 'Use Address 2 as street', 'The Street field is empty but Address 2 contains text — it is probably the street.', 'medium',
      { address: clean(a.address2), address2: '' }))
  }
  return out
}

// 1. House number field contains street (+ number) ------------------------
const ruleHouseNumberContainsStreet: Rule = (a, ctx) => {
  const nr = clean(a.houseNumber)
  if (!nr || isHouseNumber(stripHouseNoPrefix(nr))) return null
  if (!hasDigit(nr) || !hasLetter(nr)) return null
  // Starts with the number in a number-last country ("5 OG 2", "5 Hauptstraße"):
  // that is number+extra noise, not a street — handled by ruleHouseNumberNoise.
  if (!NUMBER_FIRST_COUNTRIES.has(ctx.country) && /^\d/.test(stripHouseNoPrefix(nr))) return null
  const street = clean(a.address)
  const { primary, alternative } = parseStreet(nr, ctx.country)
  if (!primary) return null

  const changes: Partial<FixableAddress> = { houseNumber: primary.houseNumber }
  let reason = `The Nr. field contains "${nr}" — a street name and a house number.`
  const alternatives: AddressFixAlternative[] = []
  if (!street || same(street, nr) || same(street, primary.street)) {
    changes.address = primary.street
    reason += ` Split it into Street "${primary.street}" and Nr. "${primary.houseNumber}".`
  } else {
    // Street field already has a different street → keep it, take only the number; offer replacing as alternative
    reason += ` Keep Street "${street}" and use only the number "${primary.houseNumber}".`
    alternatives.push({ title: `Replace street with "${primary.street}"`, reason: `Use "${primary.street}" from the Nr. field as street and "${primary.houseNumber}" as number.`, changes: { address: primary.street, houseNumber: primary.houseNumber } })
  }
  if (primary.rest) changes.address2 = mergeAddress2(a.address2, primary.rest)
  if (alternative && alternative.houseNumber !== primary.houseNumber && !/^\d/.test(alternative.street) && !/\d$/.test(alternative.street)) {
    alternatives.push({ title: `Read as "${alternative.street}" Nr. "${alternative.houseNumber}"`, reason: 'Alternative reading with the house number on the other side.', changes: { address: alternative.street, houseNumber: alternative.houseNumber, ...(alternative.rest ? { address2: mergeAddress2(a.address2, alternative.rest) } : {}) } })
  }
  return F('nr-contains-street', 'Split street out of the Nr. field', reason, 'high', changes, alternatives)
}

// 2. Street field contains the house number ------------------------------
const ruleStreetContainsNumber: Rule = (a, ctx) => {
  const street = clean(a.address)
  if (!street || !hasDigit(street)) return null
  if (PO_BOX_RE.test(street)) return null
  const nr = stripHouseNoPrefix(a.houseNumber)
  const { primary, alternative } = parseStreet(street, ctx.country)
  if (!primary) return null
  // "Straße des 17. Juni" (number inside the name, no separate number) — parse returned street with the number
  const changes: Partial<FixableAddress> = { address: primary.street }
  const alternatives: AddressFixAlternative[] = []
  let reason: string
  let confidence: AddressFixConfidence = 'high'

  if (!nr) {
    changes.houseNumber = primary.houseNumber
    reason = `The Street field contains the house number: "${street}". Split into Street "${primary.street}" and Nr. "${primary.houseNumber}".`
    if (primary.order !== (NUMBER_FIRST_COUNTRIES.has(ctx.country) ? 'first' : 'last')) confidence = 'medium'
  } else if (same(nr, primary.houseNumber)) {
    reason = `"${primary.houseNumber}" is written in the Street field AND in the Nr. field. Remove the duplicate from the street.`
  } else if (same(street, nr)) {
    changes.houseNumber = primary.houseNumber
    reason = `Street and Nr. both contain "${street}". Split into Street "${primary.street}" and Nr. "${primary.houseNumber}".`
  } else {
    // Conflicting numbers: keep the explicit Nr. field, strip number from street; alternative uses the street's number
    reason = `The Street field "${street}" contains the number "${primary.houseNumber}" but the Nr. field says "${nr}". Keep Nr. "${nr}" and clean the street.`
    confidence = 'medium'
    alternatives.push({ title: `Use "${primary.houseNumber}" from the street instead of "${nr}"`, reason: `Take the number that was written together with the street name.`, changes: { address: primary.street, houseNumber: primary.houseNumber } })
  }
  if (primary.rest) changes.address2 = mergeAddress2(a.address2, primary.rest)
  if (alternative && (alternative.houseNumber !== primary.houseNumber || alternative.street !== primary.street) && !/^\d/.test(alternative.street) && !/\d$/.test(alternative.street)) {
    alternatives.push({ title: `Read as "${alternative.street}" Nr. "${alternative.houseNumber}"`, reason: 'Alternative reading with the house number on the other side.', changes: { address: alternative.street, houseNumber: alternative.houseNumber, ...(alternative.rest ? { address2: mergeAddress2(a.address2, alternative.rest) } : {}) } })
  }
  return F('street-contains-nr', 'Split house number out of the Street field', reason, confidence, changes, alternatives)
}

// 3. Unit / floor info inside the street (no number involved) --------------
const ruleUnitInStreet: Rule = (a) => {
  const street = clean(a.address)
  if (!street) return null
  const split = splitUnitFromStreet(street)
  if (!split) return null
  // c/o inside the street goes to Name 2 (DHL: name2 = c/o line)
  const co = split.rest.match(CO_RE) || (`, ${split.rest}`).match(CO_RE)
  if (co && !clean(a.name2)) {
    return F('street-co-to-name2', 'Move "c/o" from Street to Name 2', `"${split.rest}" is a care-of line and belongs into Name 2, not the street.`, 'medium', { address: split.street, name2: clean(split.rest) })
  }
  return F('street-unit-to-address2', 'Move floor/unit info to Address 2', `"${split.rest}" is apartment/floor information. Carriers expect it in Address 2, not in the street.`, 'medium',
    { address: split.street, address2: mergeAddress2(a.address2, split.rest) })
}

// 4. House number noise ("Nr. 5", "#5", "5 OG 2", "5 a") ------------------
const ruleHouseNumberNoise: Rule = (a) => {
  const raw = clean(a.houseNumber)
  if (!raw) return null
  const noPrefix = stripHouseNoPrefix(raw)
  if (isHouseNumber(noPrefix)) {
    const tidy = tidyHouseNo(noPrefix)
    if (tidy !== raw) {
      const prefixRemoved = noPrefix !== raw
      return F('nr-tidy', prefixRemoved ? 'Remove prefix from Nr.' : 'Tidy house number', prefixRemoved ? `"${raw}" → "${tidy}": the carrier only wants the number itself.` : `"${raw}" → "${tidy}".`, prefixRemoved ? 'high' : 'low', { houseNumber: tidy })
    }
    return null
  }
  // "5 OG 2", "12 Apt 4B", "5, 2. Stock" → number + rest
  const m = noPrefix.match(new RegExp(`^(${HOUSE_NO_SRC})[\\s,]+(.+)$`, 'i'))
  if (m) {
    const rest = clean(m[2])
    if (hasLetter(rest)) {
      // Rest looks like a street name typed after the number → it belongs into Street
      const STREETY_RE = /(?:straße|strasse|str\.?|weg|allee|platz|gasse|ring|damm|ufer|chaussee|steig|pfad|street|st\.?|road|rd\.?|lane|ln\.?|drive|dr\.?|court|ct\.?|avenue|ave\.?|blvd\.?|boulevard|way|rue|calle|via)$/i
      if (!hasDigit(rest) && STREETY_RE.test(rest) && (!clean(a.address) || same(a.address, noPrefix))) {
        return F('nr-has-street-after', 'Split street out of the Nr. field', `The Nr. field contains "${raw}" — number and street. Use Street "${rest}" and Nr. "${tidyHouseNo(m[1])}".`, 'high',
          { houseNumber: tidyHouseNo(m[1]), address: rest })
      }
      return F('nr-extra-to-address2', 'Move extra info from Nr. to Address 2', `"${raw}" contains "${rest}" besides the number. Only "${tidyHouseNo(m[1])}" goes into Nr.; the rest belongs into Address 2.`, 'high',
        { houseNumber: tidyHouseNo(m[1]), address2: mergeAddress2(a.address2, rest) })
    }
  }
  return null
}

// 5. House number sitting in Address 2 -----------------------------------
const ruleHouseNumberInAddress2: Rule = (a) => {
  const nr = clean(a.houseNumber)
  const street = clean(a.address)
  const a2 = clean(a.address2)
  if (nr || !a2 || !street || hasDigit(street)) return null
  const candidate = stripHouseNoPrefix(a2)
  if (isHouseNumber(candidate)) {
    return F('address2-is-nr', 'Use Address 2 as house number', `Nr. is empty and Address 2 only contains "${a2}", which is a house number.`, 'high', { houseNumber: tidyHouseNo(candidate), address2: '' })
  }
  // "Nr. 5, 2. OG" → 5 + rest
  const m = candidate.match(new RegExp(`^(${HOUSE_NO_SRC})[\\s,]+(.+)$`, 'i'))
  if (m && a2 !== candidate) {
    return F('address2-has-nr', 'Take house number from Address 2', `Nr. is empty and Address 2 starts with the number "${tidyHouseNo(m[1])}".`, 'medium', { houseNumber: tidyHouseNo(m[1]), address2: clean(m[2]) })
  }
  return null
}

// 6. Postal / city combined -----------------------------------------------
const rulePostalCityCombined: Rule = (a, ctx) => {
  const postal = clean(a.postalCode)
  const city = clean(a.city)
  const out: AddressFix[] = []
  const loose = postalLooseSrc(ctx.country)
  const stateMap = STATE_MAPS[ctx.country]

  // Postal field: "10115 Berlin" / "Berlin 10115" / "NY 10128" / "D-10115 Berlin"
  if (postal && hasLetter(postal) && hasDigit(postal)) {
    const p = postal.replace(POSTAL_COUNTRY_PREFIX_RE, '')
    const lead = p.match(new RegExp(`^(${loose})[\\s,]+([A-Za-zÀ-ÿ].*)$`, 'i'))
    const trail = p.match(new RegExp(`^([A-Za-zÀ-ÿ].*?)[\\s,]+(${loose})$`, 'i'))
    if (stateMap) {
      const st = p.match(new RegExp(`^([A-Z]{2,3})[\\s,]+(${loose})$`, 'i'))
      if (st && stateMap[st[1].toUpperCase()]) {
        out.push(F('postal-has-state', 'Split state out of Postal', `Postal "${postal}" contains the state "${st[1].toUpperCase()}" (${stateMap[st[1].toUpperCase()]}).`, 'high',
          { postalCode: normalisePostal(st[2], ctx.country), ...(clean(a.state) ? {} : { state: st[1].toUpperCase() }) }))
      }
    }
    if (lead && !(stateMap && stateMap[clean(lead[2]).toUpperCase()])) {
      const cityPart = clean(lead[2])
      out.push(F('postal-has-city', 'Split city out of Postal', `Postal "${postal}" also contains the city "${cityPart}".`, 'high',
        { postalCode: normalisePostal(lead[1], ctx.country), ...(!city || same(city, cityPart) ? { city: cityPart } : {}) }))
    } else if (trail && !(stateMap && stateMap[clean(trail[1]).toUpperCase()])) {
      const cityPart = clean(trail[1])
      out.push(F('postal-has-city', 'Split city out of Postal', `Postal "${postal}" also contains the city "${cityPart}".`, 'high',
        { postalCode: normalisePostal(trail[2], ctx.country), ...(!city || same(city, cityPart) ? { city: cityPart } : {}) }))
    }
  }

  // City field: "10115 Berlin", "Berlin 10115", "New York, NY 10001", "London SW1A 1AA"
  if (city && hasDigit(city)) {
    const c = city.replace(POSTAL_COUNTRY_PREFIX_RE, '')
    const lead = c.match(new RegExp(`^(${loose})[\\s,]+([A-Za-zÀ-ÿ].*)$`, 'i'))
    const trail = c.match(new RegExp(`^(.*?[A-Za-zÀ-ÿ].*?)[\\s,]+(${loose})$`, 'i'))
    const hit = lead ? { postal: lead[1], city: clean(lead[2]) } : (trail ? { postal: trail[2], city: clean(trail[1]) } : null)
    if (hit) {
      const normalised = normalisePostal(hit.postal, ctx.country)
      const changes: Partial<FixableAddress> = { city: hit.city }
      let reason = `City "${city}" contains the postal code "${hit.postal}".`
      if (!postal || same(postal, normalised) || same(postal, hit.postal)) {
        changes.postalCode = normalised
      } else {
        reason += ` The Postal field says "${postal}" — keeping that and only cleaning the city.`
      }
      out.push(F('city-has-postal', 'Split postal code out of City', reason, postal && !same(postal, normalised) && !same(postal, hit.postal) ? 'medium' : 'high', changes,
        postal && !same(postal, normalised) && !same(postal, hit.postal) ? [{ title: `Use "${normalised}" from the city as postal code`, changes: { city: hit.city, postalCode: normalised } }] : []))
    }
  }

  // Street / Address 2 carry "<postal> <city>" tail (full address pasted into one line)
  for (const field of ['address', 'address2'] as AddressFixField[]) {
    const v = clean(a[field])
    if (!v || !hasDigit(v)) continue
    let m = v.match(new RegExp(`^(.*?)[\\s,]+(${loose})[\\s,]+([A-Za-zÀ-ÿ][A-Za-zÀ-ÿ .'-]*)$`, 'i'))
    if (!m && field === 'address2') {
      // Address 2 IS "<postal> <city>" with nothing else
      const full = v.match(new RegExp(`^(${loose})[\\s,]+([A-Za-zÀ-ÿ][A-Za-zÀ-ÿ .'-]*)$`, 'i'))
      if (full) m = [full[0], '', full[1], full[2]] as any
    }
    if (!m) continue
    const head = clean(m[1])
    const postalPart = m[2]
    const cityPart = clean(m[3])
    if (field === 'address' && !hasLetter(head)) continue
    // (address2 may end up empty — that is fine)
    if (!postalMatches(normalisePostal(postalPart, ctx.country), ctx.country)) continue
    // Only when postal/city are empty or agree — otherwise it's just noise at the end of the field
    const agreePostal = !postal || same(postal, normalisePostal(postalPart, ctx.country))
    const agreeCity = !city || same(city, cityPart)
    const changes: Partial<FixableAddress> = { [field]: head } as any
    if (agreePostal) changes.postalCode = normalisePostal(postalPart, ctx.country)
    if (agreeCity) changes.city = cityPart
    out.push(F(`${field}-has-postal-city`, `Remove "${postalPart} ${cityPart}" from ${FIELD_LABELS[field]}`, `${FIELD_LABELS[field]} "${v}" ends with postal code and city. ${agreePostal || agreeCity ? 'Move them into their own fields.' : 'Postal/City are already filled with other values — just remove the duplicate.'}`, agreePostal && agreeCity ? 'high' : 'medium', changes))
  }
  return out
}

// 7. Postal format normalisation -----------------------------------------
const rulePostalFormat: Rule = (a, ctx) => {
  const postal = clean(a.postalCode)
  if (!postal) return null
  if (hasLetter(postal) && hasDigit(postal) && !['NL', 'GB', 'IE', 'CA', 'LV', 'LT'].includes(ctx.country) && !POSTAL_COUNTRY_PREFIX_RE.test(postal)) return null // handled by combined rule
  const normalised = normalisePostal(postal, ctx.country)
  if (normalised === postal) return null
  const prefixRemoved = POSTAL_COUNTRY_PREFIX_RE.test(postal)
  const valid = postalMatches(normalised, ctx.country)
  // A normalisation that ADDS digits (leading-zero restore) is a guess → medium
  const digitsChanged = normalised.replace(/\D/g, '') !== postal.replace(/\D/g, '').replace(POSTAL_COUNTRY_PREFIX_RE, '')
  return F('postal-format', prefixRemoved ? 'Remove country prefix from postal code' : 'Normalise postal code',
    `"${postal}" → "${normalised}"${prefixRemoved ? ' (the country is already selected; carriers reject the prefix)' : ` (${POSTAL_PATTERNS[ctx.country]?.hint || 'standard format'})`}.`,
    valid && !digitsChanged ? 'high' : 'medium', { postalCode: normalised })
}

// 8. State (US / CA / AU) --------------------------------------------------
const ruleState: Rule = (a, ctx) => {
  const map = STATE_MAPS[ctx.country]
  const out: AddressFix[] = []
  const state = clean(a.state)
  if (!map) {
    return null
  }
  // State written as full name / lower-case / with dot
  if (state) {
    const resolved = stateCodeFromValue(state, map)
    if (resolved && resolved.code !== state) {
      out.push(F('state-to-code', `Use state code "${resolved.code}"`, `"${state}" → "${resolved.code}" (${resolved.name}). The carrier expects the 2-letter code.`, 'high', { state: resolved.code }))
    }
    return out
  }
  // State missing → look for it in other fields (postal, city, address2, name2, address)
  const sources: { field: AddressFixField; mode: 'anywhere' | 'trailing' }[] = [
    { field: 'postalCode', mode: 'anywhere' }, { field: 'city', mode: 'trailing' }, { field: 'address2', mode: 'anywhere' }, { field: 'name2', mode: 'anywhere' }, { field: 'address', mode: 'trailing' }
  ]
  for (const src of sources) {
    const v = clean(a[src.field])
    if (!v) continue
    const hit = findStateIn(v, map, src.mode)
    if (!hit) continue
    const changes: Partial<FixableAddress> = { state: hit.code }
    // Strip the state out of the field it was found in (keep postal digits etc.)
    if (src.field === 'postalCode') {
      const digits = hit.rest.replace(POSTAL_COUNTRY_PREFIX_RE, '')
      if (digits) changes.postalCode = normalisePostal(digits, ctx.country)
    } else if (src.field === 'city') {
      if (hit.rest) changes.city = hit.rest
    } else {
      changes[src.field] = hit.rest as any
    }
    out.push(F('state-from-field', `Set state "${hit.code}" (${hit.name})`, `Found "${hit.name}" in ${FIELD_LABELS[src.field]} "${v}". ${ctx.country === 'US' ? 'DHL requires a state for US destinations.' : 'Use it as the state/province.'}`, 'high', changes))
    break
  }
  // State missing EVERYWHERE (not even misplaced in another field) → fall back
  // to the externally resolved hint (online postal-code / city lookup).
  if (!out.length && ctx.stateHint?.code) {
    const code = clean(ctx.stateHint.code).toUpperCase()
    if (map[code]) {
      out.push(F('state-from-lookup', `Set state "${code}" (${map[code]})`,
        `No state found anywhere in the address. ${ctx.stateHint.source || 'An online postal-code lookup'} resolves the destination to ${map[code]} (${code}).${ctx.country === 'US' ? ' DHL requires a state for US destinations.' : ''}`,
        'high', { state: code }))
    }
  }
  return out
}

// 9. Country names inside fields + country mismatch ----------------------
const ruleCountryInFields: Rule = (a, ctx) => {
  const out: AddressFix[] = []
  const names = Object.keys(COUNTRY_NAME_TO_CODE).sort((x, y) => y.length - x.length)
  for (const field of ['city', 'address2', 'postalCode', 'address', 'name2'] as AddressFixField[]) {
    const v = clean(a[field])
    if (!v || v.length < 2) continue
    for (const n of names) {
      // Short tokens (uk/usa/vae) must be stand-alone and upper-case; long names are matched case-insensitively as whole words
      const short = n.length <= 4
      const re = short
        ? new RegExp(`(?:^|[\\s,(/-])(${escapeRe(n.toUpperCase())})(?=$|[\\s,)/-])`)
        : new RegExp(`(?:^|[\\s,(/-])(${escapeRe(n)})(?=$|[\\s,)/-])`, 'i')
      const m = v.match(re)
      if (!m) continue
      const code = COUNTRY_NAME_TO_CODE[n]
      const rest = clean(v.replace(re, ' '))
      if (field === 'city' && !rest) continue // city IS the country name? leave it
      if (field === 'address' && !hasLetter(rest)) continue
      const meta = resolveCountryMeta(code, ctx.countries)
      const changes: Partial<FixableAddress> = { [field]: rest } as any
      if (code !== ctx.country) {
        changes.country = code
        changes.countryName = meta.name
        changes.countryIso3 = meta.iso3
        out.push(F(`country-${field}`, `Change country to ${meta.name}`, `${FIELD_LABELS[field]} "${v}" mentions "${m[1]}" but the selected country is ${a.countryName || ctx.country || 'not set'}. Switch to ${meta.name} and remove it from ${FIELD_LABELS[field]}.`, 'medium', changes,
          [{ title: `Keep ${a.countryName || ctx.country}, only remove "${m[1]}"`, changes: { [field]: rest } as any }]))
      } else {
        out.push(F(`country-${field}`, `Remove "${m[1]}" from ${FIELD_LABELS[field]}`, `The country is already set to ${meta.name}; "${m[1]}" in ${FIELD_LABELS[field]} is redundant and confuses the carrier.`, 'high', changes))
      }
      break
    }
  }
  return out
}

// 10. City noise ---------------------------------------------------------
const ruleCityNoise: Rule = (a, ctx) => {
  const city = clean(a.city)
  if (!city) return null
  const map = STATE_MAPS[ctx.country]
  const state = clean(a.state)
  // City still carries the already-set state ("Miami, FL" with state FL)
  if (map && state) {
    const hit = findStateIn(city, map, 'trailing')
    if (hit && hit.code === state && hit.rest) {
      return F('city-has-state', `Remove "${state}" from City`, `City "${city}" repeats the state "${state}", which is already set in the State field.`, 'high', { city: hit.rest })
    }
  }
  // District / "OT" suffix in brackets is fine; trailing "Germany" handled by country rule.
  return null
}

// 11. Name handling -------------------------------------------------------
const ruleName: Rule = (a, ctx) => {
  const out: AddressFix[] = []
  const name = clean(a.name)
  const name2 = clean(a.name2)
  if (name && name2 && same(name, name2)) {
    out.push(F('name2-dup', 'Clear duplicated Name 2', `Name 2 repeats the recipient name "${name}".`, 'high', { name2: '' }))
  }
  // "Max Mustermann c/o Firma" → Name + Name 2
  const co = name.match(CO_RE)
  if (co && co.index !== undefined && !name2) {
    const head = clean(name.slice(0, co.index))
    if (head) {
      out.push(F('name-co', 'Move "c/o" part to Name 2', `"${clean(co[0])}" is a care-of line; carriers expect it in Name 2.`, 'medium', { name: head, name2: clean(co[0]) }))
    }
  }
  // Too long → split into Name 2
  const limit = ctx.limits.name
  if (name.length > limit && !name2 && !out.some((f) => f.id === 'name-co')) {
    const cut = name.lastIndexOf(' ', limit)
    if (cut > 10) {
      out.push(F('name-split', `Split name (max ${limit} chars)`, `The name is ${name.length} characters long; the carrier allows ${limit}. Move "${name.slice(cut + 1)}" to Name 2.`, 'medium', { name: name.slice(0, cut), name2: name.slice(cut + 1) }))
    }
  }
  // Email / phone typed into the name line
  if (EMAIL_RE.test(name) || PHONE_RE.test(name)) {
    const stripped = clean(name.replace(EMAIL_LABEL_RE, '').replace(EMAIL_RE, ' ').replace(PHONE_RE, ' '))
    if (stripped && stripped !== name) {
      out.push(F('name-contact', 'Remove phone/email from Name', `"${name}" contains contact details that must not be printed in the name line.`, 'medium', { name: stripped }))
    }
  }
  return out
}

// 12. Address 2 / Name 2 noise ---------------------------------------------
const ruleAddress2Noise: Rule = (a) => {
  const out: AddressFix[] = []
  const a2 = clean(a.address2)
  const street = clean(a.address)
  const nr = clean(a.houseNumber)
  if (a2) {
    if (same(a2, street) || (street && nr && same(a2, `${street} ${nr}`)) || (street && nr && same(a2, `${nr} ${street}`))) {
      out.push(F('address2-dup', 'Clear duplicated Address 2', `Address 2 repeats the street "${a2}".`, 'high', { address2: '' }))
    } else if (nr && same(a2, nr)) {
      out.push(F('address2-dup-nr', 'Clear duplicated house number in Address 2', `Address 2 repeats the house number "${nr}".`, 'high', { address2: '' }))
    } else if (EMAIL_RE.test(a2) || PHONE_RE.test(a2)) {
      const stripped = clean(a2.replace(EMAIL_LABEL_RE, '').replace(EMAIL_RE, ' ').replace(PHONE_RE, ' '))
      out.push(F('address2-contact', stripped ? 'Remove phone/email from Address 2' : 'Clear phone/email from Address 2', `Address 2 "${a2}" contains contact details. They are not part of the address label.`, 'medium', { address2: stripped }))
    } else {
      const co = a2.match(CO_RE)
      if (co && !clean(a.name2) && co.index === 0) {
        out.push(F('address2-co', 'Move "c/o" from Address 2 to Name 2', `"${a2}" is a care-of line; DHL prints Name 2 as the c/o line.`, 'medium', { address2: '', name2: a2 }))
      }
    }
  }
  const name2 = clean(a.name2)
  if (name2 && (EMAIL_RE.test(name2) || PHONE_RE.test(name2))) {
    const stripped = clean(name2.replace(EMAIL_LABEL_RE, '').replace(EMAIL_RE, ' ').replace(PHONE_RE, ' '))
    out.push(F('name2-contact', stripped ? 'Remove phone/email from Name 2' : 'Clear phone/email from Name 2', `Name 2 "${name2}" contains contact details.`, 'medium', { name2: stripped }))
  }
  return out
}

// 13. Length limits -------------------------------------------------------
const ruleLengthLimits: Rule = (a, ctx) => {
  const out: AddressFix[] = []
  const street = clean(a.address)
  const limit = ctx.limits.address
  if (street.length > limit && !hasDigit(street)) {
    const cut = street.lastIndexOf(' ', limit)
    if (cut > 8 && !clean(a.address2)) {
      out.push(F('street-split', `Shorten street (max ${limit} chars)`, `The street is ${street.length} characters; the carrier allows ${limit}. Move "${street.slice(cut + 1)}" to Address 2.`, 'medium', { address: street.slice(0, cut), address2: street.slice(cut + 1) }))
    }
  }
  return out
}

// 14. Cosmetic: whitespace / punctuation --------------------------------
const ruleWhitespace: Rule = (a) => {
  const changes: Partial<FixableAddress> = {}
  const touched: string[] = []
  for (const f of ['name', 'name2', 'address', 'houseNumber', 'address2', 'postalCode', 'city', 'state'] as AddressFixField[]) {
    const raw = str(a[f])
    const c = clean(raw)
    if (c !== raw) {
      changes[f] = c as any
      touched.push(FIELD_LABELS[f])
    }
  }
  if (!touched.length) return null
  return F('whitespace', 'Clean up spacing/punctuation', `Extra spaces, line breaks or stray commas in ${touched.join(', ')}.`, 'medium', changes)
}

// 15. Cosmetic: casing + abbreviations (low confidence) ------------------
const ruleCasing: Rule = (a, ctx) => {
  if (!ctx.includeLow) return null
  const out: AddressFix[] = []
  const changes: Partial<FixableAddress> = {}
  const touched: string[] = []
  for (const f of ['name', 'name2', 'address', 'address2', 'city'] as AddressFixField[]) {
    const v = clean(a[f])
    if (!v) continue
    if (isShouty(v) || isAllLower(v)) {
      const t = toTitleCase(v)
      if (t !== v) {
        changes[f] = t as any
        touched.push(`${FIELD_LABELS[f]} "${v}" → "${t}"`)
      }
    }
  }
  if (touched.length) out.push(F('casing', 'Fix ALL CAPS / lower-case', touched.join('; '), 'low', changes))

  // "Hauptstr." → "Hauptstraße" (DE/AT/CH only)
  if (['DE', 'AT', 'CH', 'LI', 'LU'].includes(ctx.country)) {
    const street = clean(a.address)
    const expanded = street
      .replace(/(\S)str\.(?=$|\s)/i, (m, p1) => `${p1}${/^[A-Z]$/.test(m.charAt(1)) ? 'Straße' : 'straße'}`)
      .replace(/(^|\s)Str\.(?=$|\s)/, '$1Straße')
      .replace(/(^|\s)str\.(?=$|\s)/, '$1straße')
      .replace(/(\S)strasse(?=$|\s)/i, '$1straße')
    if (expanded !== street) {
      out.push(F('street-abbrev', 'Expand "Str." to "Straße"', `"${street}" → "${expanded}" — optional, carriers accept both.`, 'low', { address: expanded }))
    }
  }
  // State upper-case (cosmetic when no map)
  const st = clean(a.state)
  if (st && st.length <= 3 && st !== st.toUpperCase()) {
    out.push(F('state-upper', 'Upper-case state', `"${st}" → "${st.toUpperCase()}".`, 'low', { state: st.toUpperCase() }))
  }
  return out
}

const RULES: Rule[] = [
  rulePackstation,
  ruleStreetInName2,
  ruleSwappedFields,
  ruleHouseNumberContainsStreet,
  ruleStreetContainsNumber,
  ruleUnitInStreet,
  ruleHouseNumberNoise,
  ruleHouseNumberInAddress2,
  ruleCountryInFields,
  rulePostalCityCombined,
  rulePostalFormat,
  ruleState,
  ruleCityNoise,
  ruleName,
  ruleAddress2Noise,
  ruleLengthLimits,
  ruleWhitespace,
  ruleCasing
]

// ---------------------------------------------------------------------------
// Warnings (not auto-fixable)
// ---------------------------------------------------------------------------

const collectWarnings = (a: FixableAddress, ctx: RuleContext): AddressWarning[] => {
  const out: AddressWarning[] = []
  const all = `${a.name} ${a.name2} ${a.address} ${a.houseNumber} ${a.address2}`
  const missing: string[] = []
  if (!clean(a.name)) missing.push('Name')
  if (!clean(a.address)) missing.push('Street')
  if (!clean(a.houseNumber)) missing.push('Nr.')
  if (!clean(a.postalCode)) missing.push('Postal')
  if (!clean(a.city)) missing.push('City')
  if (!ctx.country) missing.push('Country')
  if (missing.length) out.push({ id: 'missing', level: 'warning', title: `Missing: ${missing.join(', ')}`, reason: 'Required by the carrier for label creation.' })

  if (PO_BOX_RE.test(all)) out.push({ id: 'pobox', level: 'warning', title: 'PO box / Postfach address', reason: 'Parcel carriers cannot deliver to a PO box. A street address is required.' })
  if (/packstation/i.test(all)) {
    const hasPostnummer = /(?<!\d)\d{6,10}(?!\d)/.test(all)
    out.push(hasPostnummer
      ? { id: 'packstation', level: 'info', title: 'DHL Packstation address', reason: 'DHL expects: Street = "Packstation", Nr. = station number, Name 2 = the customer\'s DHL Postnummer.' }
      : { id: 'packstation-no-postnummer', level: 'warning', title: 'Packstation without DHL Postnummer', reason: 'No 6-10 digit Postnummer found anywhere in the address. DHL cannot deliver to a Packstation without it (belongs in Name 2).' })
  } else if (PACKSTATION_RE.test(all)) {
    out.push({ id: 'parcelshop', level: 'info', title: 'Parcel shop / Postfiliale detected', reason: 'Check the carrier-specific format (Postfiliale needs the Postnummer in Name 2 as well).' })
  }

  const postal = clean(a.postalCode)
  if (postal && ctx.country && POSTAL_PATTERNS[ctx.country] && !postalMatches(normalisePostal(postal, ctx.country), ctx.country)) {
    out.push({ id: 'postal-shape', level: 'warning', title: `Postal code "${postal}" does not look like a ${a.countryName || ctx.country} postal code`, reason: `Expected ${POSTAL_PATTERNS[ctx.country].hint}. Check the postal code or the country.` })
  }
  if (ctx.country === 'US' && !clean(a.state)) out.push({ id: 'us-state', level: 'warning', title: 'US destination without state', reason: 'DHL rejects US shipments without a consignee state.' })
  const map = STATE_MAPS[ctx.country]
  if (map && clean(a.state) && !map[clean(a.state).toUpperCase()] && !stateCodeFromValue(a.state, map)) {
    out.push({ id: 'state-unknown', level: 'warning', title: `Unknown state "${clean(a.state)}"`, reason: `Not a known ${ctx.country} state/province code.` })
  }
  // Entered state contradicts the online postal-code lookup → warn, don't auto-fix
  // (the ZIP itself could be the wrong value; the user has to decide).
  if (map && clean(a.state) && ctx.stateHint?.code) {
    const resolved = stateCodeFromValue(a.state, map)
    const hintCode = clean(ctx.stateHint.code).toUpperCase()
    if (resolved && map[hintCode] && resolved.code !== hintCode) {
      out.push({ id: 'state-mismatch', level: 'warning', title: `State "${resolved.code}" does not match the postal code`, reason: `${ctx.stateHint.source || 'An online postal-code lookup'} resolves the destination to ${map[hintCode]} (${hintCode}). Check the state and the postal code.` })
    }
  }
  const nr = clean(a.houseNumber)
  if (nr && !isHouseNumber(stripHouseNoPrefix(nr))) out.push({ id: 'nr-shape', level: 'info', title: `House number "${nr}" looks unusual`, reason: 'Carriers expect something like "12", "12a" or "12-14".' })
  for (const f of ['name', 'name2', 'address', 'houseNumber', 'address2', 'postalCode', 'city', 'state'] as AddressFixField[]) {
    const lim = ctx.limits[f]
    const len = clean(a[f]).length
    if (lim && len > lim) out.push({ id: `len-${f}`, level: 'warning', title: `${FIELD_LABELS[f]} too long (${len}/${lim})`, reason: `The carrier allows at most ${lim} characters; the label call will be rejected or truncated.` })
  }
  const street = clean(a.address)
  if (street && !hasLetter(street)) out.push({ id: 'street-no-letters', level: 'warning', title: 'Street has no letters', reason: 'The street name seems to be missing.' })
  return out
}

// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------

const CONF_RANK: Record<AddressFixConfidence, number> = { high: 0, medium: 1, low: 2 }

export const normaliseFixableAddress = (input: any): FixableAddress => {
  const out: any = {}
  for (const f of ADDRESS_FIX_FIELDS) out[f] = str(input?.[f])
  if (out.country) out.country = out.country.toUpperCase()
  return out as FixableAddress
}

const buildContext = (a: FixableAddress, opts: AddressFixOptions, includeLow: boolean): RuleContext => {
  const carrier = str(opts.carrier).toLowerCase() === 'dpd' ? 'dpd' : 'dhl'
  return { country: countryCodeOf(a), limits: CARRIER_LIMITS[carrier], countries: opts.countries, includeLow, stateHint: opts.stateHint || null }
}

/** Run every rule once against the given address and return the (sorted) fixes. */
export const analyzeShippingAddress = (input: any, opts: AddressFixOptions = {}): AddressFix[] => {
  const a = normaliseFixableAddress(input)
  const ctx = buildContext(a, opts, !!opts.includeLow)
  const fixes: AddressFix[] = []
  for (const rule of RULES) {
    let res: any
    try { res = rule(a, ctx) } catch { res = null }
    if (!res) continue
    const list = Array.isArray(res) ? res : [res]
    for (const f of list) {
      if (!f) continue
      // Drop fixes that don't change anything
      const effective = Object.entries(f.changes).filter(([k, v]) => str((a as any)[k]) !== str(v))
      if (!effective.length) continue
      f.changes = Object.fromEntries(effective) as Partial<FixableAddress>
      fixes.push(f)
    }
  }
  // Stable sort by confidence (rule order preserved inside the same confidence)
  return fixes
    .map((f, i) => ({ f, i }))
    .sort((x, y) => (CONF_RANK[x.f.confidence] - CONF_RANK[y.f.confidence]) || (x.i - y.i))
    .map((x) => x.f)
}

export const applyAddressChanges = (a: FixableAddress, changes: Partial<FixableAddress>): FixableAddress => {
  const next: any = { ...a }
  for (const [k, v] of Object.entries(changes)) next[k] = str(v)
  if (next.country) next.country = String(next.country).toUpperCase()
  return next as FixableAddress
}

const addressKey = (a: FixableAddress) => ADDRESS_FIX_FIELDS.map((f) => str(a[f])).join('\u0001')

const diffFields = (a: FixableAddress, b: FixableAddress): AddressFixField[] =>
  ADDRESS_FIX_FIELDS.filter((f) => str(a[f]) !== str(b[f]))

interface ChainStep { before: FixableAddress; fix: AddressFix }

const runChain = (start: FixableAddress, opts: AddressFixOptions, includeLow: boolean, seed?: { changes: Partial<FixableAddress>; fix: AppliedAddressFix }) => {
  let cur = seed ? applyAddressChanges(start, seed.changes) : { ...start }
  const applied: AppliedAddressFix[] = seed ? [seed.fix] : []
  const steps: ChainStep[] = []
  const seen = new Set<string>([addressKey(cur)])
  for (let i = 0; i < 20; i++) {
    const fixes = analyzeShippingAddress(cur, { ...opts, includeLow }).filter((f) => includeLow || f.confidence !== 'low')
    if (!fixes.length) break
    const f = fixes[0]
    const next = applyAddressChanges(cur, f.changes)
    const k = addressKey(next)
    if (seen.has(k)) break
    seen.add(k)
    steps.push({ before: cur, fix: f })
    cur = next
    applied.push({ id: f.id, title: f.title, reason: f.reason, confidence: f.confidence })
  }
  return { address: cur, applied, steps }
}

/**
 * Build full-address recommendations for the given form values.
 * Always safe to call (never throws); returns `hasSuggestions: false` when nothing to propose.
 */
export const buildAddressRecommendations = (input: any, opts: AddressFixOptions = {}): AddressFixReport => {
  const original = normaliseFixableAddress(input)
  const ctx = buildContext(original, opts, true)
  let warnings: AddressWarning[] = []
  let fixes: AddressFix[] = []
  try {
    warnings = collectWarnings(original, ctx)
    fixes = analyzeShippingAddress(original, { ...opts, includeLow: true })
  } catch {
    return { fixes: [], warnings, recommendations: [], hasSuggestions: false }
  }

  const recs: AddressRecommendation[] = []
  const seenKeys = new Set<string>([addressKey(original)])
  const push = (rec: Omit<AddressRecommendation, 'changedFields'>) => {
    const k = addressKey(rec.address)
    if (seenKeys.has(k)) return false
    const changedFields = diffFields(original, rec.address)
    if (!changedFields.length) return false
    seenKeys.add(k)
    recs.push({ ...rec, changedFields })
    return true
  }

  try {
    // 1) Recommended: chain of high/medium fixes
    const main = runChain(original, opts, false)
    if (main.applied.length) {
      push({ id: 'recommended', kind: 'recommended', label: 'Recommended', address: main.address, fixes: main.applied })
    }

    // 2) Recommended + cosmetic (casing, abbreviations, …)
    const cosmetic = runChain(main.address, opts, true)
    if (cosmetic.applied.length) {
      push({ id: 'cosmetic', kind: 'cosmetic', label: main.applied.length ? 'Recommended + cosmetic cleanup' : 'Cosmetic cleanup', address: cosmetic.address, fixes: [...main.applied, ...cosmetic.applied] })
    }

    // 3) Alternatives: for every step in the main chain that had another reading, branch there
    for (const step of main.steps) {
      for (const alt of step.fix.alternatives || []) {
        const seedFix: AppliedAddressFix = { id: `${step.fix.id}:alt`, title: alt.title, reason: alt.reason || step.fix.reason, confidence: 'medium' }
        const prefix = main.applied.slice(0, main.steps.indexOf(step))
        const branch = runChain(step.before, opts, false, { changes: alt.changes, fix: seedFix })
        push({ id: `alt-${step.fix.id}-${recs.length}`, kind: 'alternative', label: 'Alternative', address: branch.address, fixes: [...prefix, ...branch.applied] })
      }
    }

    // 4) Single fixes (only meaningful when more than one fix is on the table)
    const singles = fixes.filter((f) => f.confidence !== 'low')
    if (singles.length > 1) {
      for (const f of singles) {
        push({ id: `single-${f.id}`, kind: 'single', label: 'Only this fix', address: applyAddressChanges(original, f.changes), fixes: [{ id: f.id, title: f.title, reason: f.reason, confidence: f.confidence }] })
        for (const alt of f.alternatives || []) {
          push({ id: `single-${f.id}-alt-${recs.length}`, kind: 'single', label: 'Only this fix', address: applyAddressChanges(original, alt.changes), fixes: [{ id: `${f.id}:alt`, title: alt.title, reason: alt.reason || f.reason, confidence: 'medium' }] })
        }
      }
    }
  } catch {
    // fall through with whatever was collected
  }

  return { fixes, warnings, recommendations: recs.slice(0, 12), hasSuggestions: recs.length > 0 }
}

/** Human readable one-line address for display. */
export const formatFixableAddress = (a: FixableAddress) => {
  const lines = [
    clean(a.name), clean(a.name2),
    NUMBER_FIRST_COUNTRIES.has(countryCodeOf(a)) ? clean(`${a.houseNumber} ${a.address}`) : clean(`${a.address} ${a.houseNumber}`),
    clean(a.address2),
    clean(`${a.postalCode} ${a.city}${a.state ? ', ' + a.state : ''}`),
    clean(a.countryName || a.country)
  ].filter(Boolean)
  return lines.join(', ')
}

export const useAddressFixer = () => ({
  analyze: analyzeShippingAddress,
  build: buildAddressRecommendations,
  apply: applyAddressChanges,
  format: formatFixableAddress,
  fieldLabel: addressFieldLabel,
  fields: ADDRESS_FIX_FIELDS
})
