/**
 * Sanitize-Helpers: entfernen sensible Felder aus DB-Ergebnissen, bevor sie
 * als API-Response rausgehen. Zentrale Stelle, damit keine Passwort-Hashes,
 * Verschlüsselungen oder Reset-Tokens versehentlich durch die API leaken.
 */

import { ApiError } from './apiError.js';

// Felder die NIE in einer API-Response an den Client gehen dürfen
const SENSITIVE_CUSTOMER_FIELDS = [
  'portalPasswordHash',
  'portalPasswordResetToken',
  'portalPasswordResetExpiresAt',
  // consentHash ist ein Pseudo-Credential für den öffentlichen Consent-Link
  // (jeder mit dem Hash kann Einwilligungen erteilen + Name/Kundennummer
  // anzeigen). Über GET /customers/:id darf es nicht raus. Wer ihn legitim
  // braucht, holt ihn über GET /gdpr/customer/:id/consent-status (eigener
  // Endpoint mit canAccessCustomer-Check). Pentest Runde 5 (2026-05-16).
  'consentHash',
  // Pentest 57.7 (2026-06-01): TTL des Public-Consent-Links – kein Leak
  // an die Standard-Customer-Response, da Existenz/Ablaufzeit Info über
  // den Workflow gibt.
  'consentHashExpiresAt',
  // Session-/OTP-State – Pentest Runde 15 (2026-05-18, 20.4 HOCH): zeigt
  // einem externen Beobachter, ob ein Kunde gerade im OTP-Flow ist und
  // wann zuletzt seine Tokens invalidiert wurden. Reiner Info-Leak ohne
  // Auth-Bypass, aber unnötig. Wenn Admin diese Information legitim
  // braucht (z.B. UI-Hinweis "OTP wurde noch nicht eingelöst"), führen
  // wir bei Bedarf einen eigenen Endpoint ein.
  'portalPasswordMustChange',
  'portalTokenInvalidatedAt',
] as const;

// Zusätzliche Felder die Portal-User nicht in ihrer Customer-Response sehen
// sollen – Interne Session-/Workflow-State, kein direkter Auth-Bypass, aber
// unnötige Informationsleckage über den DB-Aufbau.
// Pentest Runde 7 (2026-05-17), MEDIUM.
const PORTAL_HIDDEN_CUSTOMER_FIELDS = [
  // portalTokenInvalidatedAt + portalPasswordMustChange sind jetzt in
  // SENSITIVE_CUSTOMER_FIELDS (immer raus), nicht mehr nur für Portal.
  'portalLastLogin',
  'lastBirthdayGreetingYear',
  // privacyPolicyPath etc. sind interne Datei-Pfade – Portal nutzt
  // dedizierte PDF-Endpoints, nicht den Pfad direkt
  'privacyPolicyPath',
  'businessRegistrationPath',
  'commercialRegisterPath',
  // Pentest Runde 10 (2026-05-17): notes sind interne CRM-Vermerke
  // ("Kunde ist schwierig" etc.) und gehören nicht in die Portal-Sicht.
  'notes',
] as const;

// Felder die im Contract NIE rausgehen dürfen (auch nicht an Mitarbeiter).
// portalPasswordEncrypted ist nur über den dedizierten /password-Endpoint
// (mit Audit-Log) abrufbar – im /contracts/:id selbst nutzlos.
const SENSITIVE_CONTRACT_FIELDS = [
  'portalPasswordEncrypted',
] as const;

// Zusätzliche Felder die Portal-User nicht sehen sollen (interne CRM-Daten).
// Pentest Runde 7 (2026-05-17): commission + notes leakten an Portal-User.
const PORTAL_HIDDEN_CONTRACT_FIELDS = [
  'commission',
  'notes',
  'nextReviewDate', // Snooze-Workflow ist internes Cockpit-Feature
] as const;

// User-eingabe String-Felder am Contract, die in der UI dargestellt werden.
// Werden beim Read über stripHtml geschickt, damit Alt-Daten mit rohen
// XSS-Payloads (vor Einführung von sanitizeContractBody) nicht mehr als
// `<script>alert(...)</script>` in der Liste auftauchen. Neue Daten sind
// schon beim Write gestrippt, aber doppelt hält besser.
const CONTRACT_DISPLAY_STRING_FIELDS = [
  'providerName',
  'tariffName',
  'customerNumberAtProvider',
  'contractNumberAtProvider',
  'orderNumberAtSalesPlatform',
  'customerNumberAtSalesPlatform',
  'contractNumberAtSalesPlatform',
  'portalUsername',
  'previousProviderName',
  'previousCustomerNumber',
  'previousContractNumber',
  'notes',
  // Preisfelder sind im Schema `String?` (freitextlich, nicht numerisch),
  // damit Tarifangaben wie "0,28 €/kWh" oder "27,90 € + 10 € Bonus"
  // möglich sind. Pentest 2026-05-30 (MEDIUM, 42.5): rohe HTML-Payloads
  // in den drei Feldern überlebten den Write-Strip nicht und kommen
  // beim Read 1:1 wieder raus.
  'priceFirst12Months',
  'priceFrom13Months',
  'priceAfter24Months',
] as const;

// User-eingabe String-Felder am Customer für dieselbe Read-Time-Defensive.
const CUSTOMER_DISPLAY_STRING_FIELDS = [
  'firstName',
  'lastName',
  'companyName',
  'salutation',
  'email',
  'phone',
  'mobile',
  'portalEmail',
  'portalUsername',
  'taxNumber',
  'commercialRegisterNumber',
  'notes',
] as const;

const SENSITIVE_USER_FIELDS = [
  'password',
  'passwordResetToken',
  'passwordResetExpiresAt',
  'tokenInvalidatedAt',
] as const;

/**
 * Entfernt Passwort-Hash, Reset-Token etc. aus einem Customer-Objekt.
 * `portalPasswordEncrypted` bleibt nur drin, wenn der Caller Admin-Rechte hat
 * (wird in einem zweiten Schritt vom Controller gemacht). Dieser Helper entfernt
 * es standardmäßig. Embedded `contracts[]` werden ebenfalls sanitisiert
 * (Pentest Runde 10 – DTO-Leak in eingebetteten Objekten).
 */
export function sanitizeCustomer<T extends Record<string, unknown>>(customer: T | null): T | null {
  if (!customer) return customer;
  const copy: Record<string, unknown> = { ...customer };
  for (const field of SENSITIVE_CUSTOMER_FIELDS) {
    delete copy[field];
  }
  for (const field of CUSTOMER_DISPLAY_STRING_FIELDS) {
    if (typeof copy[field] === 'string') {
      copy[field] = stripForDisplay(copy[field]);
    }
  }
  if (Array.isArray(copy.contracts)) {
    copy.contracts = (copy.contracts as Record<string, unknown>[]).map((c) => sanitizeContract(c));
  }
  // portalPasswordEncrypted bleibt hier zunächst drin, damit Mitarbeiter das
  // Portal-Passwort ggf. in der UI anzeigen können. Wird per requirePermission
  // auf 'customers:update' implizit gesichert.
  return copy as T;
}

/**
 * Entfernt portalPasswordEncrypted + portal-interne Workflow-Felder zusätzlich
 * zu den allgemein sensiblen Feldern. Für Kontexte in denen der Caller KEIN
 * Admin ist (z.B. Portal-Kunde). Embedded `contracts[]` werden mit der
 * Strict-Variante sanitisiert.
 */
export function sanitizeCustomerStrict<T extends Record<string, unknown>>(customer: T | null): T | null {
  if (!customer) return customer;
  const copy = sanitizeCustomer(customer) as Record<string, unknown> | null;
  if (!copy) return null;
  delete copy.portalPasswordEncrypted;
  for (const field of PORTAL_HIDDEN_CUSTOMER_FIELDS) {
    delete copy[field];
  }
  if (Array.isArray(copy.contracts)) {
    copy.contracts = (copy.contracts as Record<string, unknown>[]).map((c) => sanitizeContractStrict(c));
  }
  return copy as T;
}

/**
 * Sanitize-Liste von Customers.
 */
export function sanitizeCustomers<T extends Record<string, unknown>>(customers: T[]): T[] {
  return customers.map((c) => sanitizeCustomer(c)).filter((c): c is T => c !== null);
}

/**
 * Sanitize Contract-Objekt für alle Caller. Entfernt das verschlüsselte
 * Provider-Passwort (nur über den dedizierten /password-Endpoint mit
 * Audit-Log abrufbar) und sanitisiert das embedded customer.
 */
// Sanitisierung für freitextliche User-Notizen (ContractDocument.notes,
// Invoice.notes, MeterReading.notes etc.). Pentest 55.2 (MEDIUM,
// 2026-06-01): 50 000-Zeichen-Inputs mit XSS-Payload und CRLF gingen
// roh in die DB. Selbst wenn React escapt, sind sie ein Header-Injection-
// und Speicher-Risiko, wenn die Notiz mal in Mail/PDF/CSV-Export fließt.
// - Tags + gefährliche Schemata via stripHtml
// - CRLF auf Newline normalisieren, keine Carriage-Returns persistieren
// - Length-Cap default 2000 Zeichen (genug für sinnvolle Anmerkungen)
// Pentest 58.1 (MEDIUM, 2026-06-01): documentType wurde nur durch
// stripHtml geschickt, aber NICHT gegen eine Whitelist geprüft. Damit
// landeten beliebige Strings (`NICHT_ERLAUBT`, `DROP TABLE …`,
// Tippfehler-Werte aus alten UI-Versionen) als documentType in der
// ContractDocument-Tabelle und brachen Frontend-Filter, Auto-Activation
// (Lieferbestätigung-Trigger) und Reports.
//
// Whitelist spiegelt die Konstante CONTRACT_DOCUMENT_TYPES aus
// SaveAttachmentModal / SaveEmailAsPdfModal im Frontend. Beide
// Listen MÜSSEN synchron gehalten werden – idealerweise später
// in eine geteilte Konfiguration gehoben.
export const ALLOWED_CONTRACT_DOCUMENT_TYPES = [
  'Auftragsformular',
  'Auftragsbestätigung',
  'Lieferbestätigung',
  'Vertragsunterlagen',
  'Vollmacht',
  'Widerrufsbelehrung',
  'Preisblatt',
  'Sonstiges',
] as const;

const CONTRACT_DOCUMENT_TYPE_SET: Set<string> = new Set(ALLOWED_CONTRACT_DOCUMENT_TYPES);

/**
 * Validiert + normalisiert einen documentType-Wert. Wirft einen Fehler
 * mit klarer Liste, wenn der Wert nicht in der Whitelist steht (der
 * aufrufende Controller mappt das auf 400). Trimmt Whitespace und macht
 * den Vergleich case-insensitive – damit `"lieferbestätigung"` aus
 * Drittsystemen sauber matched, aber `"Lieferbestätigung_DROP"` rausfliegt.
 */
export function validateContractDocumentType(raw: unknown): string {
  if (typeof raw !== 'string') {
    throw new Error(`documentType ist erforderlich. Erlaubt: ${ALLOWED_CONTRACT_DOCUMENT_TYPES.join(', ')}`);
  }
  const cleaned = stripHtml(raw) as string;
  const trimmed = cleaned.trim();
  if (trimmed === '') {
    throw new Error(`documentType ist erforderlich. Erlaubt: ${ALLOWED_CONTRACT_DOCUMENT_TYPES.join(', ')}`);
  }
  const canonical = ALLOWED_CONTRACT_DOCUMENT_TYPES.find((t) => t.toLowerCase() === trimmed.toLowerCase());
  if (!canonical) {
    throw new Error(`Ungültiger documentType '${trimmed}'. Erlaubt: ${ALLOWED_CONTRACT_DOCUMENT_TYPES.join(', ')}`);
  }
  return canonical;
}

// Pentest 26.7 LOW (defense-in-depth, 2026-06-02): documentPath wird
// (außer beim Upload-Endpoint) NIE direkt aus User-Input übernommen.
// Falls doch jemand auf die Idee kommt, das Feld irgendwo zu mappen,
// fangen wir hier Path-Traversal / Javascript-URIs / HTML ab.
// Spiegelt isValidDocumentPath aus prisma/cleanup-xss-and-mass-assignment.ts
// 1:1 – Single Source of Truth für Lese- UND Schreibpfad.
export function isValidDocumentPath(v: string | null | undefined): boolean {
  if (!v) return true; // null/leer ist OK – Feld bleibt einfach unbesetzt
  if (typeof v !== 'string') return false;
  if (v.includes('..')) return false;
  if (/(?:javascript|data|vbscript)\s*:/i.test(v)) return false;
  if (/<[a-z!/]/i.test(v)) return false;
  return /^\/?uploads\/[A-Za-z0-9._\-/]+$/.test(v);
}

export function assertValidDocumentPath(v: string | null | undefined, fieldLabel = 'documentPath'): void {
  if (!isValidDocumentPath(v)) {
    throw new Error(`${fieldLabel} ist kein gültiger Upload-Pfad (erlaubt: /uploads/<safe>).`);
  }
}

// Pentest 68.1 (LOW, 2026-06-03): PDFs mit JavaScript, /Launch (externes
// Programm), /EmbeddedFile (eingebettete Executables) oder /RichMedia
// (Flash) kamen durch den reinen Magic-Byte-Check (%PDF-) und wurden
// inline ausgeliefert. Browser-PDF-Viewer (PDFium/PDF.js) führen kein JS
// aus, aber sobald jemand die PDF in Adobe Acrobat öffnet, läuft sie.
// → Wir blocken das schon beim Upload.
//
// PDF-Name-Objekte sind laut PDF 32000-1:2008 §7.3.5 case-sensitive, also
// kein /i auf den Patterns. Whitespace nach `/` ist im Standard zwar
// erlaubt, in real-world Exploits aber praktisch nie zu sehen – wir
// bleiben hier pragmatisch.
//
// Hinweis: erkannt wird nur, was im Klartext im PDF-Body steht.
// Komprimierte oder verschlüsselte Streams entgehen dem String-Scan.
// Für unser Bedrohungsmodell (kompromittierter Staff-Account, LOW) reicht
// das – ein vollständiger PDF-Parser wäre Overkill.
const PDF_DANGER_PATTERNS: { pattern: RegExp; label: string }[] = [
  { pattern: /\/JavaScript\b/, label: 'JavaScript-Action' },
  { pattern: /\/JS\b/, label: 'JavaScript-Action' },
  { pattern: /\/Launch\b/, label: 'Launch-Action (externes Programm)' },
  { pattern: /\/EmbeddedFile\b/, label: 'eingebettete Datei' },
  { pattern: /\/RichMedia\b/, label: 'RichMedia-Inhalt (Flash)' },
];

export function assertSafePdf(buf: Buffer): void {
  if (buf.length < 5 || buf.subarray(0, 5).toString('latin1') !== '%PDF-') {
    return; // keine PDF → andere Validatoren zuständig
  }
  // Stream-Inhalte (Bilder/Fonts/Komprimiertes) aus dem Scan rausnehmen.
  // Jpeg-Bytes können zufällig "/JavaScript" enthalten → false-positive
  // bei jsPDF-generierten PDFs mit eingebetteten Fotos (stage-Bug
  // 2026-06-03). Echte aktive PDF-Inhalte stehen IMMER im PDF-
  // Object-Stream (außerhalb von `stream..endstream`-Blöcken).
  const scanTarget = buf.toString('latin1').replace(/stream\s[\s\S]*?endstream/g, '');
  for (const { pattern, label } of PDF_DANGER_PATTERNS) {
    if (pattern.test(scanTarget)) {
      throw new ApiError(
        415,
        `PDF enthält nicht erlaubte aktive Inhalte (${label}). Bitte ohne JavaScript / Auto-Actions / eingebettete Dateien hochladen.`,
      );
    }
  }
}

// Pentest 51.3 + 60.3 (MEDIUM, 2026-06-01): Telefon-/Vorwahl-Felder
// dürfen NIE CRLF oder andere Control-Chars enthalten – sonst sind sie
// ein Header-Injection-Vektor (Mail, HTTP), wenn der Wert mal in einen
// Header fließt (PDF/Mail-Templates, CSV-Export). Whitespace bewusst auf
// literales Space beschränkt, NICHT `\s` – das matched sonst `\r\n\t`.
// Allowed: Ziffern, Plus, Minus, Slash, Klammern, Punkt, Space. Bis 40 Zeichen.
//
// 51.3 deckte nur Contract-Phone-Felder ab; 60.3: `Customer.phone` /
// `Customer.mobile` waren immer noch offen, weil pickCustomerUpdate nur
// stripHtml laufen ließ – das filtert keine Control-Chars.
const PHONE_FIELD_ALLOWED = /^[0-9+\-/(). ]{0,40}$/;
export function sanitizePhoneField(raw: unknown, fieldLabel: string): string | undefined {
  if (raw == null) return undefined;
  const trimmed = String(raw).trim();
  if (trimmed === '') return undefined;
  if (!PHONE_FIELD_ALLOWED.test(trimmed)) {
    throw new Error(`${fieldLabel} enthält unzulässige Zeichen (erlaubt sind Ziffern, +, Leerzeichen, -, /, Klammern).`);
  }
  return trimmed;
}

// Pentest 62.7 (LOW, 2026-06-02): deliveryDate/confirmationDate-Felder
// liefen ungeprüft in maybeActivateOnDeliveryConfirmation. XSS-Payloads
// gingen mit 200 durch, weil das ungültige Datum nur silent als null
// behandelt wurde. Impact gering, aber API-Hygiene: ungültige Eingabe
// soll 400 zurückgeben, nicht 200.
//
// Akzeptiert: ISO-8601-Datum (YYYY-MM-DD) und Datum+Zeit (mit oder ohne
// Zeitzone). Whitespace wird getrimmt. null / leerer String / undefined
// sind OK – der Aufrufer behandelt das als "Datum nicht gesetzt".
const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/;
export function validateOptionalIsoDate(raw: unknown, fieldLabel: string): string | null {
  if (raw == null) return null;
  if (typeof raw !== 'string') {
    throw new Error(`${fieldLabel} muss ein Datums-String (YYYY-MM-DD) sein.`);
  }
  const trimmed = raw.trim();
  if (trimmed === '') return null;
  if (!ISO_DATE_REGEX.test(trimmed)) {
    throw new Error(`${fieldLabel} muss ISO-8601-Format haben (YYYY-MM-DD oder YYYY-MM-DDTHH:MM:SS).`);
  }
  const parsed = new Date(trimmed);
  if (isNaN(parsed.getTime())) {
    throw new Error(`${fieldLabel} ist kein gültiges Datum.`);
  }
  return trimmed;
}

// Pentest 86.1 + 86.2 (LOW, 2026-06-19): Kunden-/Vertrags-/Auftrags-
// Nummern bei Anbieter und Vertriebsplattform hatten keine Längen- oder
// Zeichen-Validierung. >1000 Zeichen-Strings warfen einen generischen
// 500er (DB-Overflow VARCHAR(191)) statt eines 400ers. Außerdem
// überlebten Attribut-Injection-Payloads wie `foo" onerror="alert(1)`
// die stripHtml-Defense (kein umschließender Tag → kein Match), die
// in PDF-/Mail-Export potentiell aktiv werden könnten.
//
// Whitelist orientiert sich am Vorschlag des Pentesters
// `^[\w\-\/\s\.]*$` – Whitespace ist hier bewusst NUR ein literales
// Space, NICHT `\s` (kein CRLF/Tab → kein Header-Injection-Vektor
// in CSV-/Mail-Exporten). Max 100 Zeichen reicht für jede reale
// Kunden-/Vertrags-Nummer und bleibt deutlich unter dem VARCHAR(191)-
// Limit der DB-Spalte.
const CONTRACT_IDENTIFIER_FIELDS: ReadonlySet<string> = new Set([
  // Interne CRM-Vertragsnummer. Wird beim Create serverseitig generiert
  // (`generateContractNumber`), aber der Update-Path filtert sie nicht
  // aus – ein Admin (oder Angreifer mit users:update+contracts:update-
  // JWT) könnte sie sonst überschreiben. Pentest 2026-07-04 INFO:
  // `<script>` wurde durch stripHtml zu leerem String → silent Overwrite
  // der Vertragsnummer. Whitelist analog zu den 5 Anbieter-/Vertriebs-
  // Nummern-Feldern, damit der Pattern konsistent bleibt.
  'contractNumber',
  'customerNumberAtProvider',
  'contractNumberAtProvider',
  'orderNumberAtSalesPlatform',
  'customerNumberAtSalesPlatform',
  'contractNumberAtSalesPlatform',
]);
const CONTRACT_IDENTIFIER_ALLOWED = /^[A-Za-z0-9_\-/. ]{0,100}$/;
const CONTRACT_IDENTIFIER_MAX_LEN = 100;

export function isContractIdentifierField(key: string): boolean {
  return CONTRACT_IDENTIFIER_FIELDS.has(key);
}

export function validateContractIdentifier(
  raw: unknown,
  fieldLabel: string,
): string | null {
  if (raw == null) return null;
  if (typeof raw !== 'string') {
    throw new ApiError(400, `${fieldLabel} muss ein Text sein.`);
  }
  const trimmed = raw.trim();
  if (trimmed === '') return null;
  if (trimmed.length > CONTRACT_IDENTIFIER_MAX_LEN) {
    throw new ApiError(
      400,
      `${fieldLabel} darf maximal ${CONTRACT_IDENTIFIER_MAX_LEN} Zeichen lang sein.`,
    );
  }
  if (!CONTRACT_IDENTIFIER_ALLOWED.test(trimmed)) {
    throw new ApiError(
      400,
      `${fieldLabel} enthält unzulässige Zeichen (erlaubt: Buchstaben, Ziffern, Punkt, Bindestrich, Schrägstrich, Unterstrich, Leerzeichen).`,
    );
  }
  return trimmed;
}

// Pentest 95.1/95.3/95.4 (MEDIUM/LOW, 2026-06-21): Manuelles
// `portalUsername` am Vertrag hatte gar keine Validierung. Drei
// nachweisbare Effekte:
//   - `foo\r\nBcc:evil@x.de` (CRLF) verbatim gespeichert →
//     Header-Injection-Vektor sobald der Wert in Mail-Templates
//     oder PDF-Footers landet.
//   - `<script>alert(1)</script>@x.de` lief durch stripHtml →
//     stille Mutation (R87.1/R89.2-Pattern auf neuem Feld).
//   - >190 Zeichen → VARCHAR-Overflow → generischer 500 statt 400.
//
// Bewusst NICHT übernommen wurde R95.2 (Email-Format-Pflicht):
// `portalUsername` ist im Manual-Modus nicht zwingend eine
// E-Mail. Vodafone, 1&1, EWE und etliche Stadtwerke nutzen
// Kundennummern, Pseudonyme oder Customer-IDs als Portal-Login.
// Eine `email().regex()`-Pflicht würde legitime Logins ablehnen.
// Der Stressfrei-Modus hängt eh an einer schon validierten
// Email-Stammdate (assertValidForwardingEmail).
//
// Allowed: Alphanumerisch + `_`, `-`, `.`, `/`, `@`, `+`, Space.
// Damit sind Vodafone-Kunden-IDs (`12345678`), Pseudonyme
// (`max.mustermann`), Plus-Tag-Emails (`m+tag@example.com`)
// und gemischte Formen abgedeckt. Strukturell sind CRLF, Tab,
// alle Control-Chars, Tags und Quotes raus → R95.1+R95.3 ohne
// extra Check. Max 100 Zeichen << VARCHAR(191) → R95.4.
const PORTAL_USERNAME_ALLOWED = /^[A-Za-z0-9_\-/.@+ ]{0,100}$/;
const PORTAL_USERNAME_MAX_LEN = 100;

export function validatePortalUsername(
  raw: unknown,
  fieldLabel = 'portalUsername',
): string | null {
  if (raw == null) return null;
  if (typeof raw !== 'string') {
    throw new ApiError(400, `${fieldLabel} muss ein Text sein.`);
  }
  const trimmed = raw.trim();
  if (trimmed === '') return null;
  if (trimmed.length > PORTAL_USERNAME_MAX_LEN) {
    throw new ApiError(
      400,
      `${fieldLabel} darf maximal ${PORTAL_USERNAME_MAX_LEN} Zeichen lang sein.`,
    );
  }
  if (!PORTAL_USERNAME_ALLOWED.test(trimmed)) {
    throw new ApiError(
      400,
      `${fieldLabel} enthält unzulässige Zeichen (erlaubt: Buchstaben, Ziffern, Punkt, Bindestrich, Schrägstrich, Unterstrich, @, +, Leerzeichen).`,
    );
  }
  return trimmed;
}

// Pentest 89.1 + 89.2 (MEDIUM/LOW, 2026-06-21): Postadressen am
// Provider (`contactAddress`, `cancellationAddress`). sanitizeNotes
// hat das Length-Cap silent durchgeschoben (slice statt Error) und
// stripHtml lief vor dem Length-Check – derselbe Fehler wie R87:
// `<script>…</script>` reduziert auf leeren String → null in der
// DB → vorheriger Wert ohne Fehlermeldung überschrieben.
//
// Lösung wie R87: Raw-Input validieren, harte 400 statt silent-Mutation.
// - max 500 Zeichen (mehrzeilige Postadresse ≈ 4 Zeilen × 80)
// - `<` oder `>` direkt 400 (Postadressen brauchen kein HTML)
// - Steuerzeichen außer `\n` direkt 400 (kein CR/Tab/Null/etc)
// - leerer/getrimmt-leerer Input → null (Feld zurücksetzen)
// - CRLF → LF normalisieren
const PROVIDER_ADDRESS_MAX_LEN = 500;
// Erlaubt: nur LF (`\x0A`) als Newline. Alles andere – inkl. Tab (`\x09`) –
// fliegt raus. Tab in Postadressen ist Header-Injection-Vektor für CSV/Mail
// und nichts, was ein Mensch je tippt.
const PROVIDER_ADDRESS_BAD_CHARS = /[\x00-\x09\x0B\x0C\x0E-\x1F\x7F<>]/;

export function validateProviderAddress(
  raw: unknown,
  fieldLabel: string,
): string | null {
  if (raw == null) return null;
  if (typeof raw !== 'string') {
    throw new ApiError(400, `${fieldLabel} muss ein Text sein.`);
  }
  // CRLF → LF NORMALISIEREN bevor wir auf Länge prüfen – ein Editor der
  // immer `\r\n` schickt würde sonst bei jedem Zeilenumbruch zwei
  // Zeichen gegen das 500er-Cap zählen.
  const normalized = raw.replace(/\r\n?/g, '\n');
  if (PROVIDER_ADDRESS_BAD_CHARS.test(normalized)) {
    throw new ApiError(
      400,
      `${fieldLabel} enthält unzulässige Zeichen (HTML, Tabs oder Steuerzeichen sind in Postadressen nicht erlaubt).`,
    );
  }
  if (normalized.length > PROVIDER_ADDRESS_MAX_LEN) {
    throw new ApiError(
      400,
      `${fieldLabel} darf maximal ${PROVIDER_ADDRESS_MAX_LEN} Zeichen lang sein.`,
    );
  }
  const trimmed = normalized.trim();
  return trimmed === '' ? null : trimmed;
}

const NOTES_DEFAULT_MAX = 2000;
export function sanitizeNotes(raw: unknown, maxLength: number = NOTES_DEFAULT_MAX): string | null {
  if (raw == null) return null;
  if (typeof raw !== 'string') return null;
  const stripped = stripHtml(raw) as string;
  // CR allein → entfernen (CRLF → LF); restliche Steuerzeichen außer \n
  // herausfiltern. Null/Form-Feed/Tabs raus.
  const normalized = stripped
    .replace(/\r\n?/g, '\n')
    .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
  const trimmed = normalized.trim();
  if (trimmed === '') return null;
  return trimmed.slice(0, maxLength);
}

// Hilfs-Wrapper: stripHtml + Cleanup des `blocked:`-Markers in reinen
// Display-Strings. Der Marker ist sinnvoll bei URL-Feldern (man sieht,
// dass ein gefährliches Scheme abgewehrt wurde), in einem Tarif-Namen
// oder Preisfeld ist er nur kosmetischer Müll.
// Pentest 2026-05-30 (INFO, 43.5): `javascript:alert(1)` in
// priceFirst12Months wurde als "blocked:alert(1)" angezeigt.
function stripForDisplay(value: unknown): unknown {
  const stripped = stripHtml(value);
  if (typeof stripped === 'string' && stripped.includes('blocked:')) {
    return stripped.replace(/blocked:/g, '').trim();
  }
  return stripped;
}

export function sanitizeContract<T extends Record<string, unknown>>(contract: T | null): T | null {
  if (!contract) return contract;
  const copy: Record<string, unknown> = { ...contract };
  for (const field of SENSITIVE_CONTRACT_FIELDS) {
    delete copy[field];
  }
  for (const field of CONTRACT_DISPLAY_STRING_FIELDS) {
    if (typeof copy[field] === 'string') {
      copy[field] = stripForDisplay(copy[field]);
    }
  }
  // Nested: previousProviderName liegt im energyDetails-Sub-Objekt
  if (copy.energyDetails && typeof copy.energyDetails === 'object') {
    const ed = copy.energyDetails as Record<string, unknown>;
    if (typeof ed.previousProviderName === 'string') {
      ed.previousProviderName = stripHtml(ed.previousProviderName);
    }
    if (typeof ed.previousCustomerNumber === 'string') {
      ed.previousCustomerNumber = stripHtml(ed.previousCustomerNumber);
    }
  }
  // Nested: previousContract wird rekursiv auch sanitisiert
  if (copy.previousContract && typeof copy.previousContract === 'object') {
    copy.previousContract = sanitizeContract(copy.previousContract as Record<string, unknown>);
  }
  if (copy.customer && typeof copy.customer === 'object') {
    copy.customer = sanitizeCustomer(copy.customer as Record<string, unknown>);
  }
  return copy as T;
}

/**
 * Sanitize Contract für Portal-User: zusätzlich werden interne CRM-Felder
 * (Provision, Notizen, Snooze-Date) gestrippt und das embedded customer
 * mit `sanitizeCustomerStrict` gefiltert. Pentest Runde 7 (2026-05-17).
 */
export function sanitizeContractStrict<T extends Record<string, unknown>>(contract: T | null): T | null {
  if (!contract) return contract;
  const copy = sanitizeContract(contract) as Record<string, unknown> | null;
  if (!copy) return null;
  for (const field of PORTAL_HIDDEN_CONTRACT_FIELDS) {
    delete copy[field];
  }
  if (copy.customer && typeof copy.customer === 'object') {
    copy.customer = sanitizeCustomerStrict(copy.customer as Record<string, unknown>);
  }
  return copy as T;
}

export function sanitizeContracts<T extends Record<string, unknown>>(contracts: T[]): T[] {
  return contracts.map((c) => sanitizeContract(c)).filter((c): c is T => c !== null);
}

export function sanitizeContractsStrict<T extends Record<string, unknown>>(contracts: T[]): T[] {
  return contracts.map((c) => sanitizeContractStrict(c)).filter((c): c is T => c !== null);
}

/**
 * Sanitize User-Objekt für API-Responses.
 */
export function sanitizeUser<T extends Record<string, unknown>>(user: T | null): T | null {
  if (!user) return user;
  const copy = { ...user };
  for (const field of SENSITIVE_USER_FIELDS) {
    delete copy[field];
  }
  return copy;
}

// ==================== REQUEST-BODY WHITELISTS ====================
// Gegen Mass-Assignment: Nur explizit erlaubte Felder aus req.body übernehmen.

const CUSTOMER_UPDATABLE_FIELDS = [
  'type',
  'salutation',
  'useInformalAddress',
  'firstName',
  'lastName',
  'companyName',
  'foundingDate',
  'birthDate',
  'birthPlace',
  'email',
  'phone',
  'mobile',
  'taxNumber',
  'vatExempt',
  'commercialRegisterNumber',
  'notes',
  'portalEnabled',
  'portalEmail',
  'autoBirthdayGreeting',
  'autoBirthdayChannel',
  // Nicht: portalPasswordHash, portalPasswordEncrypted, portalPasswordResetToken,
  // portalTokenInvalidatedAt, customerNumber, id, createdAt, updatedAt, consentHash,
  // lastBirthdayGreetingYear, privacyPolicyPath, businessRegistrationPath, commercialRegisterPath
] as const;

const CUSTOMER_CREATE_FIELDS = [
  ...CUSTOMER_UPDATABLE_FIELDS,
  // customerNumber wird vom Service generiert – nicht aus req.body übernehmen
] as const;

const USER_UPDATABLE_FIELDS = [
  'email',
  'firstName',
  'lastName',
  'isActive',
  // Kennzeichnet planmaessig arbeitende Konten (z. B. das Gegenbuch). Ihre
  // Anmeldungen werden als Routine statt CRITICAL gefuehrt, dafuer wacht der
  // Heartbeat-Monitor ueber ihr AUSBLEIBEN (Pentest R182/R183).
  'isServiceAccount',
  'whatsappNumber',
  'telegramUsername',
  'signalNumber',
  'roleIds',
  // hasGdprAccess + hasDeveloperAccess sind keine User-Spalten – der Service
  // mappt sie auf die versteckten Rollen DSGVO/Developer (siehe
  // setUserGdprAccess / setUserDeveloperAccess). Müssen aber auf der Whitelist
  // stehen, damit pick() sie nicht aus dem Request entfernt.
  'hasGdprAccess',
  'hasDeveloperAccess',
  // Eingreifende Audit-Rechte (versiegeln/aufraeumen/Aufbewahrung), bewusst
  // getrennt von hasGdprAccess: Aufsicht und Eingriff sind zwei Rollen
  // (Pentest R186). Mappt auf die versteckte Rolle 'Audit-Betrieb'.
  'hasAuditOpsAccess',
  // Nicht: id, customerId, tokenInvalidatedAt, passwordResetToken, passwordResetExpiresAt
  // Nicht: password – wird über dedizierten Endpoint POST /users/:id/password
  // gesetzt (Pentest Runde 12 (2026-05-18) – MITTEL: generisches User-Update
  // hatte password in der Whitelist, ein Admin konnte stillschweigend ohne
  // dedizierten Audit-Trail Passwörter überschreiben).
] as const;

// Bei CREATE braucht's das initial-Passwort
const USER_CREATE_FIELDS = [
  ...USER_UPDATABLE_FIELDS,
  'password',
] as const;

/**
 * Strippt HTML-Tags und Script-/Style-Inhalt aus einem String, damit ein
 * gespeicherter Wert nicht später irgendwo zum aktiven XSS-Vektor wird
 * (z.B. PDF-Generator, E-Mail-Template oder ein dangerouslySetInnerHTML
 * im Frontend). React-Auto-Escaping fängt den normalen Fall ab, aber
 * Defense-in-Depth speichert lieber gleich nichts Bösartiges.
 *
 * Verlauf:
 * - Pentest Runde 11 (2026-05-18): <script>… in companyName.
 * - Pentest 2026-05-20 (28.1/28.2): URI-Schema-Liste + Entity-Decoding.
 * - Pentest 2026-05-20 (29.1/29.2/29.3): Zero-Width-Chars,
 *   Percent-Encoding und Cyrillic-Homoglyph-Bypass. Reihenfolge ist
 *   wichtig: zuerst Unicode-Müll raus, dann percent + entity dekodieren,
 *   dann Homoglyphe normalisieren, ZULETZT Tag-/Scheme-Strip.
 * - Pentest 2026-05-20 (28.1 Restarbeit): blob:/about:/ws:/wss:/
 *   ldap:/dict: ergänzt.
 */

// Schemes die wir aktiv blocken – durch "blocked:" ersetzen statt
// löschen, damit legitimer Text drumherum erhalten bleibt.
// Bewusst nicht in der Liste: http(s):, mailto:, tel: (legitime URLs in
// Notizfeldern). Alles andere geht selten in einem Plain-Text-Feld vor
// und kann im worst case immer noch durch JS interpretiert werden.
const DANGEROUS_URI_SCHEMES =
  /(?:javascript|data|vbscript|file|ftp|blob|about|ws|wss|ldap|dict)\s*:/gi;

// Unsichtbare Unicode-Steuerzeichen, die wie Whitespace fehlen, aber
// Regex-Matches auf "javascript:" zerteilen können. Plain-Text-Felder
// enthalten diese nie legitim. Pentest 29.3.
// U+200B–U+200F (ZWSP, ZWNJ, ZWJ, LRM, RLM), U+202A–U+202E (Embedding/
// Override), U+2060–U+2064 (Word-Joiner & co.), U+FEFF (BOM).
const ZERO_WIDTH_CHARS = /[​-‏‪-‮⁠-⁤﻿]/g;

// Cyrillic/Greek-Homoglyphe, die in URL-Schemes als Spoofing taugen
// (Pentest 29.1: "jаvascript:" mit kyrillischem а = U+0430).
// Bewusst eng gehalten: nur die Buchstaben, die in JS-/HTML-Schlüssel-
// wörtern vorkommen – wir wollen legitimes Russisch/Griechisch (z.B. in
// einem Notizfeld) nicht komplett zerlegen. Das Risiko, dass ein
// einzelnes "а" in einem ru-Wort versehentlich zu "a" wird, ist
// akzeptabel – die häufiger genutzten cyrillischen Buchstaben (б, в,
// г, …) sind nicht in der Map.
const HOMOGLYPH_TO_ASCII: Record<string, string> = {
  'а': 'a', 'А': 'A', // U+0430 / U+0410
  'е': 'e', 'Е': 'E', // U+0435 / U+0415
  'о': 'o', 'О': 'O', // U+043E / U+041E
  'р': 'p', 'Р': 'P', // U+0440 / U+0420
  'с': 'c', 'С': 'C', // U+0441 / U+0421
  'у': 'y', 'У': 'Y', // U+0443 / U+0423
  'х': 'x', 'Х': 'X', // U+0445 / U+0425
  'і': 'i', 'І': 'I', // U+0456 / U+0406 (Ukr.)
  'ј': 'j', 'Ј': 'J', // U+0458 / U+0408 (Mac.)
  'ѕ': 's', 'Ѕ': 'S', // U+0455 / U+0405
  'ο': 'o', 'Ο': 'O', // U+03BF / U+039F (Greek)
  'α': 'a', // U+03B1
};
const HOMOGLYPH_RE = new RegExp(Object.keys(HOMOGLYPH_TO_ASCII).join('|'), 'g');

function decodeHtmlEntities(s: string): string {
  return s
    .replace(/&#(\d+);?/g, (_m, code) => {
      const n = parseInt(code, 10);
      return n >= 0 && n <= 0x10FFFF ? String.fromCodePoint(n) : '';
    })
    .replace(/&#x([0-9a-fA-F]+);?/g, (_m, code) => {
      const n = parseInt(code, 16);
      return n >= 0 && n <= 0x10FFFF ? String.fromCodePoint(n) : '';
    })
    .replace(/&lt;/gi, '<')
    .replace(/&gt;/gi, '>')
    .replace(/&quot;/gi, '"')
    .replace(/&#?apos;/gi, "'")
    .replace(/&amp;/gi, '&');
}

// Percent-decoded String. Iterativ bis stabil – ein Angreifer kann
// "java%2573cript:" schreiben (`%25` ist `%`), das nach einer
// Iteration zu "java%73cript:" wird, was wiederum zu "javascript:".
// Max 5 Iterationen reichen für realistische Verschachtelungen.
// `decodeURIComponent` würde bei ungültigen Sequenzen werfen, deshalb
// machen wir es per Regex.
function percentDecode(s: string): string {
  let prev = s;
  for (let i = 0; i < 5; i++) {
    const next = prev.replace(/%([0-9A-Fa-f]{2})/g, (_m, hex) =>
      String.fromCharCode(parseInt(hex, 16)),
    );
    if (next === prev) return next;
    prev = next;
  }
  return prev;
}

/**
 * Strikte Email-Validierung. Wichtig vor allem für **SMTP-Header-
 * Injection**: ein gespeicherter Wert wie `test@x.de\nBcc:attacker@evil.de`
 * würde, wenn er in einen To-Header geschrieben wird, einen zusätzlichen
 * Bcc-Empfänger einschleusen (Pentest 29.4). Wir verbieten daher:
 *   - Whitespace, Newlines, Tabs, Steuerzeichen
 *   - mehr als ein `@`
 *   - Domain ohne Punkt
 *   - Länge > 254 (RFC 5321)
 * Format: `local@domain.tld`, local 1–64 ASCII, domain DNS-konform.
 *
 * `null`/leer ist erlaubt (Email ist oft optional). Aufrufer entscheidet,
 * ob `null` ok ist.
 */
export function isValidEmail(value: unknown): boolean {
  if (value === null || value === undefined || value === '') return true;
  if (typeof value !== 'string') return false;
  if (value.length > 254) return false;
  // Newline/Tab/Steuerzeichen explizit ablehnen – das ist der
  // Header-Injection-Vektor.
  if (/[\r\n\t\0\v\f]/.test(value)) return false;
  // Basic RFC-5322-ähnlich, ohne quoted-string-Local-Part.
  // Local-Part: 1–64 Zeichen aus [a-z0-9._%+-], muss nicht mit Punkt
  // beginnen/enden. Domain: Labels mit Punkten, TLD mind. 2 Zeichen.
  return /^[A-Za-z0-9._%+\-]{1,64}@[A-Za-z0-9.\-]{1,253}\.[A-Za-z]{2,}$/.test(value);
}

export function stripHtml(value: unknown): unknown {
  if (typeof value !== 'string') return value;
  let s = value;
  // 1) Unicode-Steuerzeichen raus, sonst zerteilen sie Regex-Matches.
  s = s.replace(ZERO_WIDTH_CHARS, '');
  // 2) Percent-Encoding auflösen (iterativ bis stabil).
  s = percentDecode(s);
  // 3) Homoglyphe normalisieren, damit "jаvascript:" zu "javascript:" wird.
  s = s.replace(HOMOGLYPH_RE, (m) => HOMOGLYPH_TO_ASCII[m] || m);
  // 4) HTML-Entities dekodieren.
  s = decodeHtmlEntities(s);
  // 5) Tags + dangerous Schemes strippen.
  s = s
    .replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
    .replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
    .replace(/<\/?[a-z][^>]*>/gi, '')
    .replace(DANGEROUS_URI_SCHEMES, 'blocked:');
  return s;
}

/**
 * Filtert req.body anhand einer Whitelist. Unerlaubte Felder werden verworfen.
 * Verhindert Mass-Assignment-Angriffe (z.B. { portalPasswordHash: "..." } im Body).
 * Optional werden alle String-Werte durch stripHtml geschickt.
 */
function pick<T extends object>(obj: T, allowed: readonly string[], options: { stripHtmlFromStrings?: boolean } = {}): Partial<T> {
  const result: Partial<T> = {};
  for (const key of allowed) {
    if (key in obj) {
      let v = (obj as any)[key];
      if (options.stripHtmlFromStrings && typeof v === 'string') {
        v = stripHtml(v);
      }
      (result as any)[key] = v;
    }
  }
  return result;
}

export function pickCustomerUpdate(body: unknown): Partial<Record<string, unknown>> {
  return pick((body as object) || {}, CUSTOMER_UPDATABLE_FIELDS, { stripHtmlFromStrings: true });
}

export function pickCustomerCreate(body: unknown): Partial<Record<string, unknown>> {
  return pick((body as object) || {}, CUSTOMER_CREATE_FIELDS, { stripHtmlFromStrings: true });
}

export function pickUserUpdate(body: unknown): Partial<Record<string, unknown>> {
  return pick((body as object) || {}, USER_UPDATABLE_FIELDS, { stripHtmlFromStrings: true });
}

export function pickUserCreate(body: unknown): Partial<Record<string, unknown>> {
  return pick((body as object) || {}, USER_CREATE_FIELDS, { stripHtmlFromStrings: true });
}

// ==================== KATALOG-/CONFIG-WHITELISTS (Pentest R110) ====================
// Pentest 2026-07-11 (MEDIUM, R110): sieben Update-Endpunkte reichten
// `req.body` ungefiltert an Prisma durch – gleiches Muster wie das
// bekannte M1-Finding (Settings Mass Assignment). Bewiesen war es via
// `PUT /api/stressfrei-emails/:id`, wo `provisionError` (ausserhalb des
// TS-Types) durchgeschrieben werden konnte. Die anderen sechs (platform,
// tariff, contractCategory, cancellationPeriod, contractDuration,
// email-providers) haben denselben Bug-Pattern. Fix: pro Endpunkt eine
// enge Feld-Whitelist. Nur die Felder aus dem jeweiligen `updateXxx`-
// Service-Interface passieren.
//
// stripHtml auf String-Werten:
// - Für Katalog-/Anzeige-Felder (name, description, code, notes …) OK.
// - Beim EmailProvider bewusst AUS: enthält Passwörter/API-Keys, die
//   Sonderzeichen (` `, `<`, `>` etc.) enthalten dürfen. Der Endpunkt
//   ist Admin-only, XSS-Risk minimal – wichtiger, dass Passwort nicht
//   heimlich mutiliert wird.

const STRESSFREI_EMAIL_UPDATABLE_FIELDS = [
  'email',
  'platform',
  'notes',
  'isActive',
] as const;

const PLATFORM_UPDATABLE_FIELDS = [
  'name',
  'contactInfo',
  'isActive',
] as const;

const TARIFF_UPDATABLE_FIELDS = [
  'name',
  'isActive',
] as const;

const CONTRACT_CATEGORY_UPDATABLE_FIELDS = [
  'code',
  'name',
  'icon',
  'color',
  'sortOrder',
  'isActive',
] as const;

const CANCELLATION_PERIOD_UPDATABLE_FIELDS = [
  'code',
  'description',
  'isActive',
] as const;

const CONTRACT_DURATION_UPDATABLE_FIELDS = [
  'code',
  'description',
  'isActive',
] as const;

const EMAIL_PROVIDER_UPDATABLE_FIELDS = [
  'name',
  'type',
  'apiUrl',
  'apiKey',
  'username',
  'password',
  'domain',
  'defaultForwardEmail',
  'imapServer',
  'imapPort',
  'smtpServer',
  'smtpPort',
  'imapEncryption',
  'smtpEncryption',
  'allowSelfSignedCerts',
  'systemEmailAddress',
  'systemEmailPassword',
  'customerEmailLabel',
  'isActive',
  'isDefault',
] as const;

export function pickStressfreiEmailUpdate(body: unknown): Partial<Record<string, unknown>> {
  return pick((body as object) || {}, STRESSFREI_EMAIL_UPDATABLE_FIELDS, { stripHtmlFromStrings: true });
}

export function pickPlatformUpdate(body: unknown): Partial<Record<string, unknown>> {
  return pick((body as object) || {}, PLATFORM_UPDATABLE_FIELDS, { stripHtmlFromStrings: true });
}

export function pickTariffUpdate(body: unknown): Partial<Record<string, unknown>> {
  return pick((body as object) || {}, TARIFF_UPDATABLE_FIELDS, { stripHtmlFromStrings: true });
}

export function pickContractCategoryUpdate(body: unknown): Partial<Record<string, unknown>> {
  return pick((body as object) || {}, CONTRACT_CATEGORY_UPDATABLE_FIELDS, { stripHtmlFromStrings: true });
}

export function pickCancellationPeriodUpdate(body: unknown): Partial<Record<string, unknown>> {
  return pick((body as object) || {}, CANCELLATION_PERIOD_UPDATABLE_FIELDS, { stripHtmlFromStrings: true });
}

export function pickContractDurationUpdate(body: unknown): Partial<Record<string, unknown>> {
  return pick((body as object) || {}, CONTRACT_DURATION_UPDATABLE_FIELDS, { stripHtmlFromStrings: true });
}

/**
 * EmailProvider hat Passwörter/API-Keys unter den Feldern; stripHtml
 * würde Sonderzeichen wie `<`/`>` in einem legitimen Passwort mutilieren.
 * Whitelist-Filter ja, HTML-Strip nein.
 */
export function pickEmailProviderUpdate(body: unknown): Partial<Record<string, unknown>> {
  return pick((body as object) || {}, EMAIL_PROVIDER_UPDATABLE_FIELDS);
}
