import ExcelJS from "exceljs";
import type { ProcessedReceipt, TaxBreakdownItem, LineItem } from "../schema/receipt";

/**
 * Dual-Sheet Excel-Export (Belegübersicht + Einzelpositionen).
 *
 * Grundsätze:
 *  - Es werden KEINE Werte erfunden. Fehlt eine Angabe, bleibt die Zelle leer.
 *  - Datumswerte sind echte Excel-Datumswerte (Sortierung, Monatsfilter,
 *    Pivot-Tabellen funktionieren nur damit), formatiert als TT.MM.JJJJ.
 *  - Die Steuerspalten werden aus den tatsächlich vorkommenden Steuersätzen
 *    aufgebaut. 7 % und 19 % sind immer vorhanden (deutsche Regelsätze), damit
 *    die Spaltenposition stabil bleibt; jeder weitere Satz mit Steuerbetrag
 *    (z. B. 10 % AT, 8,1 % CH) bekommt eine eigene Spalte, statt still im
 *    Bruttobetrag zu verschwinden.
 *  - Beträge in unterschiedlichen Währungen werden NIE zu einer Summe addiert.
 *
 * Formel-Injection-Sicherheit: Jeder Belegwert wird als PLAIN STRING-Zelle
 * geschrieben (ExcelJS `Value.getType` → String ⇒ `<t>`-String-Zelle im XLSX).
 * Excel evaluiert nur `<f>`-Formelzellen, nie String-Zellen — ein Händlername
 * `=HYPERLINK("http://evil","x")` bleibt daher in Excel reiner Text. Die
 * einzigen Formeln in dieser Datei (taxTotal, netCalc, paidTotal, SUM/SUMIF)
 * werden ausschließlich aus Spaltenbuchstaben/Zeilennummern konstruiert, nie
 * aus Belegdaten. (Verifiziert im Verify-Skript: round-trip read-back ergibt
 * String-Typ, kein Formula-Typ.)
 */

// ---------------------------------------------------------------------------
// Formate & Farben
// ---------------------------------------------------------------------------

/**
 * Excel-Datumsformat. Der Formatcode wird in der Datei immer englisch
 * gespeichert; ein deutsches Excel zeigt "DD.MM.YYYY" als "TT.MM.JJJJ" an.
 *
 * Englisch bewusst ISO statt MM/DD oder DD/MM — beide wären für ein
 * internationales Publikum mehrdeutig.
 */
const DATE_FMT_BY_LOCALE = { de: "DD.MM.YYYY", en: "YYYY-MM-DD" } as const;
const PERCENT_FMT = '0.## " %"';

const COLOR_HEADER_BG = "FF1E293B";
const COLOR_HEADER_BG2 = "FF0F766E";
const COLOR_ZEBRA = "FFF8FAFC";
const COLOR_ZEBRA2 = "FFF0FDFA";
const COLOR_TOTAL_BG = "FFE2E8F0";
const COLOR_BORDER = "FFE2E8F0";
const COLOR_OK = "FF16A34A";
const COLOR_WARN = "FFD97706";
/** Flaechenfarben der Statusspalte – bewusst blass, damit der Text lesbar bleibt. */
const COLOR_OK_BG = "FFDCFCE7";
const COLOR_WARN_BG = "FFFEF3C7";

/** Steuersätze, die immer eine Spalte bekommen (deutsche Regelsteuersätze). */
const STANDARD_RATES = [7, 19];

// ---------------------------------------------------------------------------
// Sprache
// ---------------------------------------------------------------------------

export type ExcelLocale = "de" | "en";

export interface ExcelExportOptions {
  /** Sprache der Beschriftungen. Zahlen- und Datumsformate bleiben unberührt. */
  locale?: ExcelLocale;
}

/**
 * Alle sichtbaren Beschriftungen der Mappe. Bewusst eine geschlossene Tabelle
 * statt verstreuter Ternaries: So ist auf einen Blick prüfbar, dass keine
 * deutsche Zeichenkette im englischen Export übrigbleibt.
 */
interface Labels {
  sheetOverview: string;
  sheetLineItems: string;
  colNr: string;
  colDate: string;
  colMerchant: string;
  colCategory: string;
  colDocType: string;
  colReceiptNo: string;
  colNet: string;
  colVat: (rate: string) => string;
  colGross: string;
  colCurrency: string;
  colTaxTotal: string;
  colNetCalc: string;
  colTip: string;
  colPaidTotal: string;
  colSmallInvoice: string;
  colSmallInvoiceNote: string;
  smallInvoiceYes: string;
  smallInvoiceNo: string;
  colTaxId: string;
  colStatus: string;
  colOccasion: string;
  colParticipants: string;
  colReceiptRef: string;
  colItem: string;
  colQty: string;
  colUnitPrice: string;
  colVatRate: string;
  colLineTotal: string;
  grandTotal: string;
  grandTotalMixed: string;
  grandTotalShort: string;
  grandTotalMixedShort: string;
  wholeReceipt: (category: string) => string;
  statusConfirmed: string;
  statusValid: string;
  statusReview: string;
  summaryTitle: string;
  summaryCategory: string;
  summaryCount: string;
  summaryNoCategory: string;
  noLineItems: string;
  page: string;
  of: string;
}

const LABELS: Record<ExcelLocale, Labels> = {
  de: {
    sheetOverview: "Belegübersicht",
    // Name bewusst unverändert: Der deutsche Export ist der Bestand, und
    // Blattnamen sind eine öffentliche Schnittstelle (Formeln, Makros, Tests).
    sheetLineItems: "Einzelpositionen Detail",
    colNr: "Lfd. Nr.",
    colDate: "Belegdatum",
    colMerchant: "Händler / Aussteller",
    colCategory: "Kategorie",
    colDocType: "Dokumenttyp",
    colReceiptNo: "Beleg-Nr.",
    colNet: "Netto",
    colVat: (rate: string) => `MwSt ${rate}`,
    colGross: "Brutto Gesamt",
    colCurrency: "Währung",
    colTaxTotal: "MwSt gesamt",
    colNetCalc: "Netto rechnerisch",
    colTip: "Trinkgeld",
    colPaidTotal: "Gesamt gezahlt",
    colSmallInvoice: "Kleinbetrag § 33",
    colSmallInvoiceNote:
      "Kleinbetragsrechnung nach § 33 UStDV: bis 250 € brutto gelten erleichterte Pflichtangaben. " +
      "Darüber sind Leistungsempfänger, Rechnungsnummer und gesondert ausgewiesene Steuer erforderlich (§ 14 Abs. 4 UStG). " +
      "Bei Fremdwährungsbelegen bleibt die Spalte leer, da die Grenze ein Euro-Betrag ist.",
    smallInvoiceYes: "Ja",
    smallInvoiceNo: "Nein",
    colTaxId: "Steuernummer / USt-IdNr.",
    colStatus: "Plausibilität",
    colOccasion: "Anlass (Bewirtung)",
    colParticipants: "Teilnehmer (Bewirtung)",
    colReceiptRef: "Beleg #",
    colItem: "Artikel / Position",
    colQty: "Menge",
    colUnitPrice: "Einzelpreis",
    colVatRate: "MwSt %",
    colLineTotal: "Gesamtpreis",
    grandTotal: "GESAMTSUMME",
    grandTotalMixed: "GESAMTSUMME – mehrere Währungen, siehe Auswertung rechts",
    statusConfirmed: "Bestätigt ✓",
    statusValid: "Valide ✓",
    statusReview: "Prüfen ⚠",
    summaryTitle: "Auswertung je Kategorie",
    summaryCategory: "Kategorie",
    summaryCount: "Anzahl",
    summaryNoCategory: "Ohne Kategorie",
    noLineItems: "Für diese Belege wurden keine Einzelpositionen erfasst.",
    grandTotalShort: "Gesamt",
    grandTotalMixedShort: "Gesamt (je Währung siehe oben)",
    wholeReceipt: (category: string) =>
      `Gesamtbeleg (${category}) – keine Einzelpositionen erfasst`,
    page: "Seite",
    of: "von",
  },
  en: {
    sheetOverview: "Receipts",
    sheetLineItems: "Line items",
    colNr: "No.",
    colDate: "Receipt date",
    colMerchant: "Merchant / Issuer",
    colCategory: "Category",
    colDocType: "Document type",
    colReceiptNo: "Receipt no.",
    colNet: "Net",
    colVat: (rate: string) => `VAT ${rate}`,
    colGross: "Gross total",
    colCurrency: "Currency",
    colTaxTotal: "VAT total",
    colNetCalc: "Net calculated",
    colTip: "Tip",
    colPaidTotal: "Total paid",
    colSmallInvoice: "Small-value § 33",
    colSmallInvoiceNote:
      "Small-value invoice under § 33 UStDV (German VAT rules): up to €250 gross, reduced mandatory details apply. " +
      "Above that, recipient, invoice number and separately stated VAT are required (§ 14 (4) UStG). " +
      "Left blank for non-EUR receipts, as the threshold is a euro amount.",
    smallInvoiceYes: "Yes",
    smallInvoiceNo: "No",
    colTaxId: "Tax ID / VAT no.",
    colStatus: "Plausibility",
    colOccasion: "Occasion (hospitality)",
    colParticipants: "Attendees (hospitality)",
    colReceiptRef: "Receipt #",
    colItem: "Item / Position",
    colQty: "Qty",
    colUnitPrice: "Unit price",
    colVatRate: "VAT %",
    colLineTotal: "Line total",
    grandTotal: "GRAND TOTAL",
    grandTotalMixed: "GRAND TOTAL – multiple currencies, see breakdown on the right",
    statusConfirmed: "Confirmed ✓",
    statusValid: "Valid ✓",
    statusReview: "Review ⚠",
    summaryTitle: "Breakdown by category",
    summaryCategory: "Category",
    summaryCount: "Count",
    summaryNoCategory: "Uncategorised",
    noLineItems: "No line items were captured for these receipts.",
    grandTotalShort: "Total",
    grandTotalMixedShort: "Total (per currency, see above)",
    wholeReceipt: (category: string) => `Whole receipt (${category}) – no line items captured`,
    page: "Page",
    of: "of",
  },
};

/** Bruttogrenze einer Kleinbetragsrechnung (§ 33 UStDV). */
const KLEINBETRAG_LIMIT = 250;

// ---------------------------------------------------------------------------
// Defensive Zugriffshelfer (Altdatensätze aus IndexedDB kennen neue Felder nicht)
// ---------------------------------------------------------------------------

function round2(value: number): number {
  return Math.round((value + Number.EPSILON) * 100) / 100;
}

function num(value: unknown): number {
  return typeof value === "number" && Number.isFinite(value) ? value : 0;
}

function isNum(value: unknown): value is number {
  return typeof value === "number" && Number.isFinite(value);
}

function text(value: unknown): string {
  return typeof value === "string" ? value : "";
}

function colLetter(index: number): string {
  let n = index;
  let out = "";
  while (n > 0) {
    const rest = (n - 1) % 26;
    out = String.fromCharCode(65 + rest) + out;
    n = Math.floor((n - 1) / 26);
  }
  return out;
}

function taxEntries(receipt: ProcessedReceipt): TaxBreakdownItem[] {
  const list = (receipt as { taxBreakdown?: unknown }).taxBreakdown;
  if (!Array.isArray(list)) return [];
  return list.filter((t): t is TaxBreakdownItem => !!t && typeof t === "object");
}

function lineItemsOf(receipt: ProcessedReceipt): LineItem[] {
  const list = (receipt as { lineItems?: unknown }).lineItems;
  if (!Array.isArray(list)) return [];
  return list.filter((i): i is LineItem => !!i && typeof i === "object");
}

function grossOf(receipt: ProcessedReceipt): number {
  return round2(num(receipt?.totalAmount?.value));
}

function totalTaxOf(receipt: ProcessedReceipt): number {
  return round2(taxEntries(receipt).reduce((sum, t) => sum + num(t.taxAmount), 0));
}

/** Netto laut Beleg; fehlt es, rechnerisch aus Brutto abzüglich aller Steuern. */
function netOf(receipt: ProcessedReceipt): number {
  const declared = (receipt as { netAmount?: unknown }).netAmount;
  if (isNum(declared)) return round2(declared);
  return round2(grossOf(receipt) - totalTaxOf(receipt));
}

/**
 * Trinkgeld laut Beleg, oder null wenn keins erfasst wurde. Nicht Teil von
 * netOf/grossOf: Auf das Trinkgeld wird keine MwSt ausgewiesen.
 */
function tipOf(receipt: ProcessedReceipt): number | null {
  const val = (receipt as any).tipAmount;
  return typeof val === "number" && !isNaN(val) && val > 0 ? Math.round(val * 100) / 100 : null;
}

/**
 * Kleinbetragsrechnung nach § 33 UStDV: Bis 250 € brutto gelten erleichterte
 * Anforderungen (kein Leistungsempfaenger, keine fortlaufende Rechnungsnummer,
 * kein gesondert ausgewiesener Steuerbetrag noetig). Darueber greift der volle
 * Pflichtkatalog des § 14 Abs. 4 UStG.
 *
 * Massgeblich ist der Rechnungsbetrag — Trinkgeld zaehlt nicht mit, es ist nicht
 * Teil der Rechnung. Bei Gutschriften entscheidet der Betrag ohne Vorzeichen.
 *
 * Rueckgabe `null` bei Fremdwaehrung: Die Grenze ist ein Euro-Betrag, und ohne
 * Umrechnungskurs waere jede Aussage geraten. Lieber leer als falsch.
 */
function isSmallValueInvoice(receipt: ProcessedReceipt): boolean | null {
  if (currencyOf(receipt) !== "EUR") return null;
  return Math.abs(grossOf(receipt)) <= KLEINBETRAG_LIMIT;
}

/**
 * Normalisierter Steuersatz als Gruppierungsschlüssel. Nur Fließkomma-Rauschen
 * wird geglättet – echte Nachkommastellen (z. B. 8,875 % oder 8,1 % CH)
 * bleiben erhalten, damit die Spaltenüberschrift den Beleg nicht verfälscht.
 */
function rateKey(rate: number): number {
  return Math.round(rate * 1000) / 1000;
}

function taxAmountForRate(receipt: ProcessedReceipt, rate: number): number {
  return round2(
    taxEntries(receipt)
      .filter((t) => isNum(t.ratePercent) && rateKey(t.ratePercent) === rate)
      .reduce((sum, t) => sum + num(t.taxAmount), 0)
  );
}

function currencyOf(receipt: ProcessedReceipt): string {
  const raw = text((receipt as { currency?: unknown }).currency).trim().toUpperCase();
  return raw.length > 0 ? raw : "EUR";
}

/** Zahlenformat mit Währungssymbol. Fremdwährungen werden mit ihrem Code beschriftet. */
/**
 * Zahlenformat mit Waehrungssymbol; negative Betraege rot.
 * Gutschriften, Pfandrueckgaben und Stornos sind sonst in einer Spalte voller
 * aehnlicher Zahlen nicht zu erkennen.
 */
function currencyFormat(code: string): string {
  if (code === "EUR") return '#,##0.00 "€";[Red]-#,##0.00 "€"';
  const safe = code.replace(/[^A-Z0-9]/g, "").slice(0, 5);
  return safe.length > 0
    ? `#,##0.00 "${safe}";[Red]-#,##0.00 "${safe}"`
    : '#,##0.00;[Red]-#,##0.00';
}

function currencySymbol(code: string): string {
  return code === "EUR" ? "€" : code;
}

/** Bewirtungsangaben (§ 4 Abs. 5 Satz 1 Nr. 2 EStG) – Feld ist optional. */
function readHospitality(
  receipt: ProcessedReceipt
): { occasion: string; participants: string } | null {
  const raw = (receipt as { hospitality?: unknown }).hospitality;
  if (!raw || typeof raw !== "object") return null;
  const rec = raw as { occasion?: unknown; participants?: unknown };
  return { occasion: text(rec.occasion), participants: text(rec.participants) };
}

function isHospitalityReceipt(receipt: ProcessedReceipt): boolean {
  return (
    receipt?.documentType === "BEWIRTUNGSBELEG" || receipt?.suggestedCategory === "Bewirtung"
  );
}

/** Anzahl der Validierungsbefunde – das Feld fehlt bei Altdatensätzen. */
function issueCount(receipt: ProcessedReceipt): number {
  const issues = (receipt?.validation as { issues?: unknown } | undefined)?.issues;
  return Array.isArray(issues) ? issues.length : 0;
}

function isUserConfirmed(receipt: ProcessedReceipt): boolean {
  return (receipt?.validation as { userConfirmed?: unknown } | undefined)?.userConfirmed === true;
}

/**
 * Echtes Excel-Datum aus dem ISO-Belegdatum.
 * Nicht parsbare Werte (z. B. Altdatensätze mit "15.08.2026") werden
 * unverändert als Text übernommen, statt ein Datum zu erfinden.
 */
function excelDate(isoDate: unknown): Date | string {
  const raw = text(isoDate).trim();
  const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(raw);
  if (!match) return raw;
  const year = Number(match[1]);
  const month = Number(match[2]);
  const day = Number(match[3]);
  const date = new Date(Date.UTC(year, month - 1, day));
  if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) {
    return raw;
  }
  return date;
}

/** Beschriftung eines Steuersatzes in deutscher Schreibweise ("7 %", "8,1 %"). */
function rateLabel(rate: number, locale: ExcelLocale = "de"): string {
  const value = rateKey(rate);
  if (locale === "en") return `${value}%`;
  const str = Number.isInteger(value) ? String(value) : String(value).replace(".", ",");
  return `${str} %`;
}

/**
 * Steuersatzspalten: immer 7 % und 19 %, dazu jeder weitere Satz, für den
 * tatsächlich ein Steuerbetrag ausgewiesen ist.
 */
function collectTaxRates(receipts: ProcessedReceipt[]): number[] {
  const rates = new Set<number>(STANDARD_RATES);
  for (const receipt of receipts) {
    for (const entry of taxEntries(receipt)) {
      if (!isNum(entry.ratePercent)) continue;
      if (round2(num(entry.taxAmount)) === 0) continue;
      rates.add(rateKey(entry.ratePercent));
    }
  }
  return Array.from(rates).sort((a, b) => a - b);
}

function statusLabel(receipt: ProcessedReceipt, L: Labels): string {
  if (isUserConfirmed(receipt)) return L.statusConfirmed;
  if (receipt?.validation?.isMathValid) return L.statusValid;
  const count = issueCount(receipt);
  return count > 0 ? `${L.statusReview} (${count})` : L.statusReview;
}

// ---------------------------------------------------------------------------
// Export
// ---------------------------------------------------------------------------

export async function generateDualSheetExcel(
  receipts: ProcessedReceipt[],
  options: ExcelExportOptions = {}
): Promise<Buffer> {
  const list = Array.isArray(receipts) ? receipts.filter((r): r is ProcessedReceipt => !!r) : [];
  const locale: ExcelLocale = options.locale === "en" ? "en" : "de";
  const L = LABELS[locale];

  const workbook = new ExcelJS.Workbook();
  workbook.creator = "ScanReceipts (scanreceipts.app)";
  workbook.lastModifiedBy = "ScanReceipts Engine";
  workbook.created = new Date();
  workbook.modified = new Date();

  buildOverviewSheet(workbook, list, L, locale);
  buildLineItemSheet(workbook, list, L, locale);

  const buffer = await workbook.xlsx.writeBuffer();
  return Buffer.from(buffer);
}

// ---------------------------------------------------------------------------
// Blatt 1: Belegübersicht
// ---------------------------------------------------------------------------

function buildOverviewSheet(
  workbook: ExcelJS.Workbook,
  receipts: ProcessedReceipt[],
  L: Labels,
  locale: ExcelLocale
): void {
  const sheet = workbook.addWorksheet(L.sheetOverview, {
    // xSplit: Nr., Datum und Haendler bleiben beim Scrollen nach rechts stehen —
    // bei vielen Steuerspalten sonst nicht mehr zuzuordnen, welche Zeile man liest.
    views: [{ showGridLines: false, state: "frozen", xSplit: 3, ySplit: 1 }],
  });
  sheet.properties.tabColor = { argb: COLOR_HEADER_BG };
  const DATE_FMT = DATE_FMT_BY_LOCALE[locale];

  const rates = collectTaxRates(receipts);
  const currencies = Array.from(new Set(receipts.map(currencyOf))).sort();
  const singleCurrency = currencies.length <= 1 ? currencies[0] ?? "EUR" : null;
  const moneyFmt = currencyFormat(singleCurrency ?? "EUR");
  const amountSuffix = singleCurrency ? ` (${currencySymbol(singleCurrency)})` : "";
  const showHospitality = receipts.some(
    (r) => isHospitalityReceipt(r) || readHospitality(r) !== null
  );
  // Trinkgeldspalten nur zeigen, wenn im Export überhaupt eines vorkommt —
  // sonst trägt jede Tabelle zwei leere Spalten mit sich herum.
  const showTips = receipts.some((r) => tipOf(r) !== null);

  // --- Spaltendefinition -----------------------------------------------------
  const columns: Partial<ExcelJS.Column>[] = [
    { header: L.colNr, key: "nr", width: 8 },
    { header: L.colDate, key: "date", width: 14 },
    { header: L.colMerchant, key: "merchant", width: 30 },
    { header: L.colCategory, key: "category", width: 22 },
    { header: L.colDocType, key: "docType", width: 18 },
    { header: L.colReceiptNo, key: "receiptNo", width: 18 },
    { header: `${L.colNet}${amountSuffix}`, key: "netAmount", width: 15 },
  ];

  const taxKeys = rates.map((rate, idx) => `tax_${idx}`);
  rates.forEach((rate, idx) => {
    columns.push({
      header: `${L.colVat(rateLabel(rate, locale))}${amountSuffix}`,
      key: taxKeys[idx],
      width: 15,
    });
  });

  columns.push(
    { header: `${L.colGross}${amountSuffix}`, key: "grossAmount", width: 18 },
    { header: L.colCurrency, key: "currency", width: 10 },
    { header: `${L.colTaxTotal}${amountSuffix}`, key: "taxTotal", width: 16 },
    { header: `${L.colNetCalc}${amountSuffix}`, key: "netCalc", width: 19 },
    // Trinkgeld trägt keine ausgewiesene MwSt und bleibt deshalb außerhalb von
    // "Brutto Gesamt". "Gesamt gezahlt" ist der Betrag, der tatsächlich vom
    // Konto ging — die Zahl, die auf dem Kontoauszug steht.
    ...(showTips
      ? [
          { header: `${L.colTip}${amountSuffix}`, key: "tipAmount", width: 14 },
          { header: `${L.colPaidTotal}${amountSuffix}`, key: "paidTotal", width: 18 },
        ]
      : []),
    { header: L.colSmallInvoice, key: "smallInvoice", width: 16 },
    { header: L.colTaxId, key: "taxId", width: 22 },
    { header: L.colStatus, key: "status", width: 18 }
  );
  if (showHospitality) {
    columns.push(
      { header: L.colOccasion, key: "occasion", width: 30 },
      { header: L.colParticipants, key: "participants", width: 34 }
    );
  }

  sheet.columns = columns;

  const firstTaxCol = 8;
  const lastTaxCol = firstTaxCol + rates.length - 1;
  const grossCol = lastTaxCol + 1;
  const netCol = 7;
  const currencyCol = grossCol + 1;
  const taxTotalCol = grossCol + 2;
  const netCalcCol = grossCol + 3;
  const tipCol = showTips ? netCalcCol + 1 : null;
  const paidTotalCol = showTips ? netCalcCol + 2 : null;
  const smallInvoiceCol = netCalcCol + (showTips ? 2 : 0) + 1;
  const taxIdCol = smallInvoiceCol + 1;
  const statusCol = taxIdCol + 1;
  const lastCol = columns.length;

  styleHeaderRow(sheet.getRow(1), COLOR_HEADER_BG);
  // "Kleinbetrag § 33" ist ohne Erlaeuterung nicht selbsterklaerend — die
  // Rechtsgrundlage haengt als Zellkommentar an der Ueberschrift.
  sheet.getRow(1).getCell(smallInvoiceCol).note = L.colSmallInvoiceNote;

  // --- Datenzeilen -----------------------------------------------------------
  receipts.forEach((receipt, idx) => {
    const rowNo = idx + 2;
    const currency = currencyOf(receipt);
    const rowFmt = currencyFormat(currency);
    const hospitality = readHospitality(receipt);

    const values: Record<string, unknown> = {
      nr: idx + 1,
      date: excelDate(receipt?.date?.isoDate),
      merchant: text(receipt?.merchant?.name),
      category: text(receipt?.suggestedCategory),
      docType: text(receipt?.documentType),
      receiptNo: text(receipt?.receiptNumber) || "-",
      netAmount: netOf(receipt),
      grossAmount: grossOf(receipt),
      currency,
      // Live-Formeln: MwSt gesamt = Summe der Steuerspalten,
      // Netto rechnerisch = Brutto abzüglich der Steuerspalten.
      taxTotal: {
        formula: `SUM(${colLetter(firstTaxCol)}${rowNo}:${colLetter(lastTaxCol)}${rowNo})`,
      },
      netCalc: {
        formula: `${colLetter(grossCol)}${rowNo}-${colLetter(taxTotalCol)}${rowNo}`,
      },
      smallInvoice: (() => {
        const small = isSmallValueInvoice(receipt);
        if (small === null) return "";
        return small ? L.smallInvoiceYes : L.smallInvoiceNo;
      })(),
      taxId: text(receipt?.merchant?.taxId),
      status: statusLabel(receipt, L),
    };
    if (showTips) {
      const tip = tipOf(receipt);
      values.tipAmount = tip;
      // Als Formel, damit eine Korrektur an Brutto oder Trinkgeld direkt
      // durchschlägt — konsistent mit den übrigen Rechenspalten.
      values.paidTotal = {
        formula: `${colLetter(grossCol)}${rowNo}+N(${colLetter(tipCol!)}${rowNo})`,
      };
    }
    rates.forEach((rate, i) => {
      values[taxKeys[i]] = taxAmountForRate(receipt, rate);
    });
    if (showHospitality) {
      // Fehlende Angaben bleiben leer – § 4 Abs. 5 EStG verlangt sie, wir
      // erfinden sie aber nicht.
      values.occasion = hospitality?.occasion ?? "";
      values.participants = hospitality?.participants ?? "";
    }

    const row = sheet.addRow(values);
    row.height = 22;
    row.alignment = { vertical: "middle" };

    row.getCell("nr").alignment = { horizontal: "center", vertical: "middle" };
    const dateCell = row.getCell("date");
    dateCell.alignment = { horizontal: "center", vertical: "middle" };
    if (dateCell.value instanceof Date) dateCell.numFmt = DATE_FMT;

    row.getCell("netAmount").numFmt = rowFmt;
    row.getCell("grossAmount").numFmt = rowFmt;
    row.getCell("taxTotal").numFmt = rowFmt;
    row.getCell("netCalc").numFmt = rowFmt;
    if (showTips) {
      row.getCell("tipAmount").numFmt = rowFmt;
      row.getCell("paidTotal").numFmt = rowFmt;
    }
    taxKeys.forEach((key) => {
      row.getCell(key).numFmt = rowFmt;
    });
    row.getCell("currency").alignment = { horizontal: "center", vertical: "middle" };
    row.getCell("smallInvoice").alignment = { horizontal: "center", vertical: "middle" };
    row.getCell("status").alignment = { horizontal: "center", vertical: "middle" };

    // Statuszelle flaechig einfaerben: Beim Ueberfliegen von hundert Zeilen ist
    // eine Hintergrundfarbe erheblich schneller zu erfassen als Schriftfarbe.
    const ok = receipt?.validation?.isMathValid === true;
    row.getCell("status").font = {
      name: "Segoe UI",
      size: 10,
      color: { argb: ok ? COLOR_OK : COLOR_WARN },
      bold: true,
    };
    row.getCell("status").fill = {
      type: "pattern",
      pattern: "solid",
      fgColor: { argb: ok ? COLOR_OK_BG : COLOR_WARN_BG },
    };

    if (idx % 2 === 1) {
      for (let c = 1; c <= lastCol; c++) {
        // Statusspalte auslassen — ihre Ampelfarbe traegt Information.
        if (c === statusCol) continue;
        row.getCell(c).fill = { type: "pattern", pattern: "solid", fgColor: { argb: COLOR_ZEBRA } };
      }
    }
    for (let c = 1; c <= lastCol; c++) {
      row.getCell(c).border = { bottom: { style: "thin", color: { argb: COLOR_BORDER } } };
    }
  });

  // --- Summenzeile -----------------------------------------------------------
  const lastDataRow = receipts.length + 1;
  const totalRow = sheet.addRow({});
  const mixedCurrency = singleCurrency === null;

  totalRow.getCell("merchant").value = mixedCurrency ? L.grandTotalMixed : L.grandTotal;

  if (receipts.length > 0 && !mixedCurrency) {
    const sumCols = [
      netCol,
      ...rates.map((_, i) => firstTaxCol + i),
      grossCol,
      taxTotalCol,
      netCalcCol,
      ...(showTips ? [tipCol!, paidTotalCol!] : []),
    ];
    for (const col of sumCols) {
      const letter = colLetter(col);
      const cell = totalRow.getCell(col);
      cell.value = { formula: `SUM(${letter}2:${letter}${lastDataRow})` };
      cell.numFmt = moneyFmt;
    }
    totalRow.getCell(currencyCol).value = singleCurrency;
    totalRow.getCell(currencyCol).alignment = { horizontal: "center", vertical: "middle" };
  }

  totalRow.height = 26;
  totalRow.font = { name: "Segoe UI", size: 11, bold: true };
  totalRow.getCell("merchant").alignment = { horizontal: "right", vertical: "middle" };
  for (let c = 1; c <= lastCol; c++) {
    const cell = totalRow.getCell(c);
    cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: COLOR_TOTAL_BG } };
    cell.border = {
      top: { style: "medium", color: { argb: "FF0F172A" } },
      bottom: { style: "double", color: { argb: "FF0F172A" } },
    };
  }

  // --- Auswertung je Kategorie (live per SUMIF) ------------------------------
  if (receipts.length > 0) {
    buildCategorySummary(sheet, receipts, L, locale, {
      startCol: lastCol + 2,
      lastDataRow,
      categoryCol: 4,
      currencyCol,
      netCol,
      taxTotalCol,
      grossCol,
      singleCurrency,
    });
  }

  // --- Filter & Druckbild ----------------------------------------------------
  if (receipts.length > 0) {
    sheet.autoFilter = {
      from: { row: 1, column: 1 },
      to: { row: lastDataRow, column: lastCol },
    };
  }
  applyPrintSetup(sheet, L);
}

interface CategorySummaryLayout {
  startCol: number;
  lastDataRow: number;
  categoryCol: number;
  currencyCol: number;
  netCol: number;
  taxTotalCol: number;
  grossCol: number;
  singleCurrency: string | null;
}

/**
 * Kompakter Auswertungsblock rechts neben der Tabelle.
 * Alle Werte sind lebende SUMIF-/SUMIFS-Formeln – ändert der Nutzer eine
 * Betragszelle, aktualisiert sich die Auswertung mit.
 *
 * Der Block ist bewusst nie höher als die Haupttabelle (Kopfzeile +
 * Gruppen + Gesamtzeile <= Kopfzeile + Belege + Summenzeile).
 */
function buildCategorySummary(
  sheet: ExcelJS.Worksheet,
  receipts: ProcessedReceipt[],
  L: Labels,
  locale: ExcelLocale,
  layout: CategorySummaryLayout
): void {
  const { startCol, lastDataRow, categoryCol, currencyCol, netCol, taxTotalCol, grossCol } = layout;
  const mixed = layout.singleCurrency === null;

  const groups = new Map<string, { category: string; currency: string }>();
  for (const receipt of receipts) {
    const category = text(receipt?.suggestedCategory) || L.summaryNoCategory;
    const currency = currencyOf(receipt);
    groups.set(`${category}|${currency}`, { category, currency });
  }
  const sorted = Array.from(groups.values()).sort(
    (a, b) =>
      a.category.localeCompare(b.category, locale) ||
      a.currency.localeCompare(b.currency)
  );

  const headers = [
    L.summaryCategory,
    L.colCurrency,
    L.summaryCount,
    L.colNet,
    L.colTaxTotal,
    L.colGross,
  ];
  headers.forEach((caption, i) => {
    const cell = sheet.getCell(1, startCol + i);
    cell.value = caption;
    cell.font = { name: "Segoe UI", size: 11, bold: true, color: { argb: "FFFFFFFF" } };
    cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: COLOR_HEADER_BG } };
    cell.alignment = { vertical: "middle", horizontal: "center" };
  });
  sheet.getColumn(startCol).width = 24;
  sheet.getColumn(startCol + 1).width = 11;
  sheet.getColumn(startCol + 2).width = 10;
  for (let i = 3; i < headers.length; i++) sheet.getColumn(startCol + i).width = 16;

  const catRange = `$${colLetter(categoryCol)}$2:$${colLetter(categoryCol)}$${lastDataRow}`;
  const curRange = `$${colLetter(currencyCol)}$2:$${colLetter(currencyCol)}$${lastDataRow}`;
  const netRange = `$${colLetter(netCol)}$2:$${colLetter(netCol)}$${lastDataRow}`;
  const taxRange = `$${colLetter(taxTotalCol)}$2:$${colLetter(taxTotalCol)}$${lastDataRow}`;
  const grossRange = `$${colLetter(grossCol)}$2:$${colLetter(grossCol)}$${lastDataRow}`;

  sorted.forEach((group, idx) => {
    const rowNo = 2 + idx;
    const catCell = `$${colLetter(startCol)}${rowNo}`;
    const curCell = `$${colLetter(startCol + 1)}${rowNo}`;

    sheet.getCell(rowNo, startCol).value = group.category;
    sheet.getCell(rowNo, startCol + 1).value = group.currency;
    sheet.getCell(rowNo, startCol + 1).alignment = { horizontal: "center" };

    const count = mixed
      ? `COUNTIFS(${catRange},${catCell},${curRange},${curCell})`
      : `COUNTIF(${catRange},${catCell})`;
    sheet.getCell(rowNo, startCol + 2).value = { formula: count };
    sheet.getCell(rowNo, startCol + 2).alignment = { horizontal: "center" };

    const sums: Array<[number, string]> = [
      [startCol + 3, netRange],
      [startCol + 4, taxRange],
      [startCol + 5, grossRange],
    ];
    for (const [col, range] of sums) {
      const formula = mixed
        ? `SUMIFS(${range},${catRange},${catCell},${curRange},${curCell})`
        : `SUMIF(${catRange},${catCell},${range})`;
      const cell = sheet.getCell(rowNo, col);
      cell.value = { formula };
      cell.numFmt = currencyFormat(group.currency);
    }
  });

  // Gesamtzeile – bei gemischten Währungen bewusst ohne Summe.
  const totalRowNo = 2 + sorted.length;
  const labelCell = sheet.getCell(totalRowNo, startCol);
  labelCell.value = mixed ? L.grandTotalMixedShort : L.grandTotalShort;
  labelCell.font = { bold: true };
  if (!mixed) {
    for (let i = 2; i <= 5; i++) {
      const letter = colLetter(startCol + i);
      const cell = sheet.getCell(totalRowNo, startCol + i);
      cell.value = { formula: `SUM(${letter}2:${letter}${totalRowNo - 1})` };
      cell.font = { bold: true };
      if (i > 2) cell.numFmt = currencyFormat(layout.singleCurrency ?? "EUR");
    }
  }
}

// ---------------------------------------------------------------------------
// Blatt 2: Einzelpositionen
// ---------------------------------------------------------------------------

function buildLineItemSheet(
  workbook: ExcelJS.Workbook,
  receipts: ProcessedReceipt[],
  L: Labels,
  locale: ExcelLocale
): void {
  const sheet = workbook.addWorksheet(L.sheetLineItems, {
    views: [{ showGridLines: false, state: "frozen", xSplit: 3, ySplit: 1 }],
  });
  sheet.properties.tabColor = { argb: COLOR_HEADER_BG2 };
  const DATE_FMT = DATE_FMT_BY_LOCALE[locale];

  sheet.columns = [
    { header: L.colReceiptRef, key: "receiptIdx", width: 10 },
    { header: L.colDate, key: "date", width: 14 },
    { header: L.colMerchant, key: "merchant", width: 26 },
    { header: L.colItem, key: "description", width: 36 },
    { header: L.colQty, key: "qty", width: 9 },
    { header: L.colUnitPrice, key: "unitPrice", width: 14 },
    { header: L.colVatRate, key: "taxRate", width: 11 },
    { header: L.colLineTotal, key: "price", width: 16 },
    { header: L.colCurrency, key: "currency", width: 10 },
  ];

  styleHeaderRow(sheet.getRow(1), COLOR_HEADER_BG2);

  let written = 0;
  receipts.forEach((receipt, idx) => {
    const items = lineItemsOf(receipt);
    const currency = currencyOf(receipt);
    const rowFmt = currencyFormat(currency);
    const dateValue = excelDate(receipt?.date?.isoDate);
    const merchant = text(receipt?.merchant?.name);

    const pushRow = (values: Record<string, unknown>) => {
      written++;
      const row = sheet.addRow({
        receiptIdx: idx + 1,
        date: dateValue,
        merchant,
        currency,
        ...values,
      });
      row.height = 20;
      row.alignment = { vertical: "middle" };
      row.getCell("receiptIdx").alignment = { horizontal: "center", vertical: "middle" };
      const dateCell = row.getCell("date");
      dateCell.alignment = { horizontal: "center", vertical: "middle" };
      if (dateCell.value instanceof Date) dateCell.numFmt = DATE_FMT;
      row.getCell("qty").alignment = { horizontal: "center", vertical: "middle" };
      row.getCell("taxRate").alignment = { horizontal: "center", vertical: "middle" };
      row.getCell("taxRate").numFmt = PERCENT_FMT;
      row.getCell("unitPrice").numFmt = rowFmt;
      row.getCell("price").numFmt = rowFmt;
      row.getCell("currency").alignment = { horizontal: "center", vertical: "middle" };
      if (written % 2 === 1) {
        for (let c = 1; c <= 9; c++) {
          row.getCell(c).fill = {
            type: "pattern",
            pattern: "solid",
            fgColor: { argb: COLOR_ZEBRA2 },
          };
        }
      }
    };

    if (items.length > 0) {
      for (const item of items) {
        // unitPrice ist optional und fehlt bei Altdatensätzen komplett.
        const unitPrice = (item as { unitPrice?: unknown }).unitPrice;
        pushRow({
          description: text(item.description),
          qty: isNum(item.quantity) ? item.quantity : "",
          unitPrice: isNum(unitPrice) ? round2(unitPrice) : "",
          taxRate: isNum(item.taxRate) ? round2(item.taxRate) : "",
          price: round2(num(item.price)),
        });
      }
      return;
    }

    // Kein erfundener Steuersatz: Ohne Einzelpositionen bleibt die Spalte leer.
    pushRow({
      description: L.wholeReceipt(text(receipt?.suggestedCategory)),
      qty: "",
      unitPrice: "",
      taxRate: "",
      price: grossOf(receipt),
    });
  });

  if (written > 0) {
    sheet.autoFilter = { from: { row: 1, column: 1 }, to: { row: written + 1, column: 9 } };
  } else {
    // Ein leeres Blatt mit nur einer Kopfzeile liest sich wie ein Fehler.
    const note = sheet.addRow({ receiptIdx: L.noLineItems });
    sheet.mergeCells(note.number, 1, note.number, 9);
    note.getCell(1).alignment = { horizontal: "center", vertical: "middle" };
    note.getCell(1).font = { name: "Segoe UI", size: 11, italic: true, color: { argb: "FF64748B" } };
    note.height = 30;
  }
  applyPrintSetup(sheet, L);
}

// ---------------------------------------------------------------------------
// Gemeinsames Styling
// ---------------------------------------------------------------------------

function styleHeaderRow(row: ExcelJS.Row, fill: string): void {
  row.height = 28;
  row.eachCell((cell) => {
    cell.font = { name: "Segoe UI", size: 11, bold: true, color: { argb: "FFFFFFFF" } };
    cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: fill } };
    cell.alignment = { vertical: "middle", horizontal: "center", wrapText: true };
    cell.border = {
      top: { style: "thin", color: { argb: "FF94A3B8" } },
      left: { style: "thin", color: { argb: "FF94A3B8" } },
      bottom: { style: "medium", color: { argb: "FF0F172A" } },
      right: { style: "thin", color: { argb: "FF94A3B8" } },
    };
  });
}

/** Querformat, auf Seitenbreite skaliert, Kopfzeile auf jeder Seite. */
function applyPrintSetup(sheet: ExcelJS.Worksheet, L: Labels): void {
  sheet.pageSetup = {
    paperSize: 9, // A4
    orientation: "landscape",
    fitToPage: true,
    fitToWidth: 1,
    fitToHeight: 0,
    horizontalCentered: true,
    printTitlesRow: "1:1",
    margins: {
      left: 0.4,
      right: 0.4,
      top: 0.6,
      bottom: 0.6,
      header: 0.3,
      footer: 0.3,
    },
  };
  sheet.headerFooter = {
    oddFooter: `&L&F – &A&R${L.page} &P ${L.of} &N`,
  };
}
