import { Request, Response } from 'express';
import fs from 'fs';
import prisma from '../lib/prisma.js';
import * as contractService from '../services/contract.service.js';
import * as contractCockpitService from '../services/contractCockpit.service.js';
import * as contractHistoryService from '../services/contractHistory.service.js';
import * as authorizationService from '../services/authorization.service.js';
import { recordPredecessorFinalReading } from '../services/customer.service.js';
import { ApiResponse, AuthRequest } from '../types/index.js';
import { logChange } from '../services/audit.service.js';
import { sanitizeContract, sanitizeContractStrict, sanitizeContracts, sanitizeContractsStrict, stripHtml, sanitizeNotes, validateContractDocumentType, validateOptionalIsoDate, isContractIdentifierField, validateContractIdentifier, validatePortalUsername } from '../utils/sanitize.js';
import { ApiError } from '../utils/apiError.js';
import { canAccessContract, canAccessCustomer } from '../utils/accessControl.js';
import { maybeActivateOnDeliveryConfirmation, maybeCancelOnCancellationConfirmation, withContractDocumentLock } from '../services/contractStatusScheduler.service.js';

/**
 * Walk-and-clean: strippt HTML/Script-/URI-Schemata in allen String-Werten
 * eines Body-Objekts (rekursiv über energyDetails, internetDetails etc.).
 * Pentest 2026-05-24 (MEDIUM, 31.1): providerName, tariffName und die
 * price*-Felder nahmen rohe HTML-Payloads an (`<script>`, `<svg onload>`)
 * und lieferten sie 1:1 an Portal-User zurück. Verträge enthalten KEINE
 * HTML-Felder (Richtige HTML-Texte liegen in AppSettings), deshalb ist
 * Strip safe.
 *
 * AUSNAHME: Passwort-/Secret-Felder. `stripHtml` filtert `<…>`-Sequenzen
 * und URI-Schemata wie `data:`, also würde ein PW wie `Pass<TAG>word!`
 * zu `Password!` mutilieren oder `data:secret` zu `blocked:secret`.
 * Das Passwort wird sowieso verschlüsselt persistiert (`encrypt()`),
 * niemals als HTML ausgegeben – also kein XSS-Risk, und die Mangling
 * ist ein Bug (2026-05-27, intern gemeldet: "Portal-Passwörter werden
 * nicht gespeichert").
 */
const PASSTHROUGH_KEYS = new Set(['portalPassword', 'password']);

function sanitizeContractBody(body: unknown, parentKey?: string): unknown {
  if (body === null || body === undefined) return body;
  if (typeof body === 'string') {
    if (parentKey && PASSTHROUGH_KEYS.has(parentKey)) return body;
    // Pentest 86.1/86.2 (LOW, 2026-06-19): Längen- + Whitelist-Check auf
    // Kunden-/Vertrags-/Auftragsnummer-Feldern. validateContractIdentifier
    // wirft ApiError(400) bei Verstoß → saubere 400-Antwort statt 500.
    //
    // Pentest 87.1 (LOW, 2026-06-19): Identifier-Felder MÜSSEN gegen den
    // Raw-Input geprüft werden, NICHT gegen den stripHtml-Output. Sonst
    // verschluckt der Sanitizer Tag-Verstöße still: `<b>bold</b>` würde
    // als `"bold"` mit 200 OK durchgehen, `<script>alert(1)</script>`
    // sogar zu `null` und damit den vorherigen Wert überschreiben.
    // Die strikte Whitelist (`^[A-Za-z0-9_\-/. ]{0,100}$`) deckt alle
    // Bypässe ab, die stripHtml normalerweise auffangen würde
    // (Tags, Schemes, Zero-Width-Chars, Homoglyphe, Percent-Encoding) –
    // sie sind alle nicht in der Allowlist und fliegen mit 400 raus.
    if (parentKey && isContractIdentifierField(parentKey)) {
      return validateContractIdentifier(body, parentKey);
    }
    // Pentest 95.1/95.3/95.4 (LOW–MEDIUM, 2026-06-21): portalUsername
    // (Manual-Modus) hatte gar keine Validierung – CRLF/Header-Injection,
    // silent stripHtml-Mutation und VARCHAR-Overflow möglich. Gleiches
    // Raw-Input-Pattern wie R87.
    if (parentKey === 'portalUsername') {
      return validatePortalUsername(body, parentKey);
    }
    return stripHtml(body);
  }
  if (Array.isArray(body)) return body.map((v) => sanitizeContractBody(v, parentKey));
  if (typeof body === 'object') {
    const out: Record<string, unknown> = {};
    for (const [k, v] of Object.entries(body as Record<string, unknown>)) {
      out[k] = sanitizeContractBody(v, k);
    }
    return out;
  }
  return body;
}

export async function getContracts(req: AuthRequest, res: Response): Promise<void> {
  try {
    const { customerId, type, status, search, page, limit, tree, includeDeactivated } = req.query;

    // Baumstruktur für Kundenansicht
    if (tree === 'true' && customerId) {
      const customerIdNum = parseInt(customerId as string);
      // Pentest R120 (CRITICAL IDOR): Der tree=true-Zweig returnte früh,
      // BEVOR die Portal-User-customerIds-Filterung unten für die flache
      // Liste griff. Ein Portal-User konnte damit den vollständigen
      // Vertragsbaum eines beliebigen Fremdkunden lesen
      // (?tree=true&customerId=<fremd>). Ownership-Check nachgezogen –
      // canAccessCustomer prüft eigene ID + vertretene MIT Live-Vollmacht
      // und sendet selbst die 403-Response. Nicht-Portal-User (Staff)
      // passieren unverändert.
      if (!(await canAccessCustomer(req, res, customerIdNum))) return;
      const treeData = await contractService.getContractTreeForCustomer(
        customerIdNum,
        includeDeactivated === 'true',
      );
      res.json({ success: true, data: treeData } as ApiResponse);
      return;
    }

    // Für Kundenportal-Benutzer: nur eigene + vertretene Kunden MIT Vollmacht.
    // Fail-closed (Pentest R145): Portal-Token wird IMMER gescoped; fehlt wider
    // Erwarten die customerId, ergibt das eine leere Menge (nicht alle).
    let customerIds: number[] | undefined;
    if (req.user?.isCustomerPortal) {
      customerIds = req.user.customerId ? [req.user.customerId] : [];
      if (req.user.customerId) {
        // Vertretene Kunden nur wenn Vollmacht erteilt
        const representedIds: number[] = req.user.representedCustomerIds || [];
        for (const repCustId of representedIds) {
          const hasAuth = await authorizationService.hasAuthorization(repCustId, req.user.customerId);
          if (hasAuth) {
            customerIds.push(repCustId);
          }
        }
      }
    }

    const result = await contractService.getAllContracts({
      customerId: customerId ? parseInt(customerId as string) : undefined,
      customerIds, // Wird nur für Kundenportal-Benutzer gesetzt
      type: type as any,
      status: status as any,
      search: search as string,
      page: page ? parseInt(page as string) : undefined,
      limit: limit ? parseInt(limit as string) : undefined,
      includeDeactivated: includeDeactivated === 'true',
    });
    // Portal-User bekommen die Strict-Variante (ohne commission/notes/
    // nextReviewDate/portalPasswordEncrypted), Mitarbeiter die normale.
    const isPortal = !!req.user?.isCustomerPortal;
    const data = isPortal
      ? sanitizeContractsStrict(result.contracts as any[])
      : sanitizeContracts(result.contracts as any[]);
    res.json({
      success: true,
      data,
      pagination: result.pagination,
    } as ApiResponse);
  } catch (error) {
    res.status(500).json({
      success: false,
      error: 'Fehler beim Laden der Verträge',
    } as ApiResponse);
  }
}

export async function getContract(req: AuthRequest, res: Response): Promise<void> {
  try {
    const contract = await contractService.getContractById(parseInt(req.params.id));
    if (!contract) {
      res.status(404).json({
        success: false,
        error: 'Vertrag nicht gefunden',
      } as ApiResponse);
      return;
    }

    // Für Kundenportal-Benutzer: Zugriff nur auf eigene + vertretene Kunden MIT Vollmacht
    if (req.user?.isCustomerPortal && req.user.customerId) {
      const allowedCustomerIds = [req.user.customerId];
      const representedIds: number[] = req.user.representedCustomerIds || [];
      for (const repCustId of representedIds) {
        const hasAuth = await authorizationService.hasAuthorization(repCustId, req.user.customerId);
        if (hasAuth) {
          allowedCustomerIds.push(repCustId);
        }
      }
      if (!allowedCustomerIds.includes(contract.customerId)) {
        res.status(403).json({
          success: false,
          error: 'Kein Zugriff auf diesen Vertrag',
        } as ApiResponse);
        return;
      }
    }

    const isPortal = !!req.user?.isCustomerPortal;
    const data = isPortal
      ? sanitizeContractStrict(contract as any)
      : sanitizeContract(contract as any);
    res.json({ success: true, data } as ApiResponse);
  } catch (error) {
    res.status(500).json({
      success: false,
      error: 'Fehler beim Laden des Vertrags',
    } as ApiResponse);
  }
}

export async function createContract(req: AuthRequest, res: Response): Promise<void> {
  try {
    // Input-Validierung: type + customerId sind Pflicht, sonst stürzte der
    // Service mit einer kryptischen JS-Message ab (Pentest Runde 12, INFO).
    const body = (req.body || {}) as Record<string, unknown>;
    if (!body.type || typeof body.type !== 'string') {
      res.status(400).json({ success: false, error: 'Vertrags-Typ (type) ist erforderlich' } as ApiResponse);
      return;
    }
    if (!body.customerId || typeof body.customerId !== 'number') {
      res.status(400).json({ success: false, error: 'Kunde (customerId) ist erforderlich' } as ApiResponse);
      return;
    }
    const sanitizedBody = sanitizeContractBody(body);
    const contract = await contractService.createContract(sanitizedBody as any);
    await logChange({
      req, action: 'CREATE', resourceType: 'Contract',
      resourceId: contract.id.toString(),
      label: `Vertrag ${contract.contractNumber} angelegt`,
      customerId: contract.customerId,
    });
    const isPortal = !!req.user?.isCustomerPortal;
    const sanitized = isPortal ? sanitizeContractStrict(contract as any) : sanitizeContract(contract as any);
    res.status(201).json({ success: true, data: sanitized } as ApiResponse);
  } catch (error) {
    res.status(400).json({
      success: false,
      error: error instanceof Error ? error.message : 'Fehler beim Erstellen des Vertrags',
    } as ApiResponse);
  }
}

export async function updateContract(req: AuthRequest, res: Response): Promise<void> {
  try {
    const contractId = parseInt(req.params.id);
    // Pentest 56.3 (latent, 2026-06-01): Defense-in-Depth –
    // canAccessContract explizit aufrufen, statt sich nur auf die
    // Route-Permission zu verlassen. Portal-User mit kompromittierter
    // Token-Permission würden sonst beliebige Verträge editieren können.
    if (!(await canAccessContract(req, res, contractId))) return;
    // Vorherigen Stand laden für Audit-Vergleich
    const before = await prisma.contract.findUnique({
      where: { id: contractId },
      include: { energyDetails: true, internetDetails: true, mobileDetails: true, tvDetails: true, carInsuranceDetails: true },
    });

    // HTML/JS-Strip auf allen String-Werten (Pentest 2026-05-24, 31.1)
    const sanitizedBody = sanitizeContractBody(req.body);

    // Kündigungs-Datumsfelder robust normalisieren: `validateOptionalIsoDate`
    // akzeptiert Datum-only (YYYY-MM-DD) UND volles ISO und liefert einen
    // sauberen Date-Wert an Prisma. Ohne das lehnte Prisma ein Datum-only
    // ab (Pentest R138, Hygiene-Punkt). Konsistent zum Upload-Pfad, der
    // `confirmationDate` bereits so validiert.
    for (const df of ['cancellationConfirmationDate', 'cancellationConfirmationOptionsDate'] as const) {
      if (df in (sanitizedBody as Record<string, unknown>)) {
        let validated: string | null;
        try {
          validated = validateOptionalIsoDate((sanitizedBody as Record<string, unknown>)[df], df);
        } catch (err) {
          res.status(400).json({
            success: false,
            error: err instanceof Error ? err.message : 'Ungültiges Datum',
          } as ApiResponse);
          return;
        }
        (sanitizedBody as Record<string, unknown>)[df] = validated ? new Date(validated) : null;
      }
    }

    const contract = await contractService.updateContract(contractId, sanitizedBody as any);

    // Wenn über das Formular ein (neues/geändertes) Kündigungsbestätigungs-
    // datum gesetzt wurde: Vertrag → CANCELLED_CONFIRMED ("Gekündigt / bestätigt")
    // + Vertragsende = Kündigungsdatum. Nur bei tatsächlicher Änderung auslösen,
    // damit manuelle Status-Korrekturen nicht sofort zurückgesetzt werden.
    {
      const asDay = (d: unknown) =>
        d ? new Date(d as string).toISOString().split('T')[0] : null;
      const beforeConf = asDay((before as any)?.cancellationConfirmationDate);
      const afterConf = asDay((sanitizedBody as any)?.cancellationConfirmationDate);
      if (afterConf && afterConf !== beforeConf) {
        await maybeCancelOnCancellationConfirmation(contractId, req);
      }
    }

    // Geänderte Felder ermitteln
    const changes: Record<string, { von: unknown; nach: unknown }> = {};
    const fieldLabels: Record<string, string> = {
      status: 'Status', startDate: 'Vertragsbeginn', endDate: 'Vertragsende',
      portalUsername: 'Portal-Benutzername', customerNumberAtProvider: 'Kundennummer beim Anbieter',
      contractNumberAtProvider: 'Vertragsnummer beim Anbieter',
      orderNumberAtSalesPlatform: 'Auftragsnummer bei Vertriebsplattform',
      customerNumberAtSalesPlatform: 'Kundennummer bei Vertriebsplattform',
      contractNumberAtSalesPlatform: 'Vertragsnummer bei Vertriebsplattform',
      providerId: 'Anbieter', tariffId: 'Tarif', cancellationPeriodId: 'Kündigungsfrist',
      contractDurationId: 'Vertragslaufzeit', platformId: 'Vertriebsplattform',
      cancellationDate: 'Kündigungsdatum', cancellationSentDate: 'Kündigung gesendet am',
      identityDocumentId: 'Ausweis', bankCardId: 'Bankverbindung', addressId: 'Adresse',
      commission: 'Provision', notes: 'Notizen',
      portalCredentialsNotRequired: 'Portaldaten-Opt-out',
      stressfreiEmailId: 'Stressfrei-/Corporate-Adresse',
    };
    const energyLabels: Record<string, string> = {
      meterId: 'Zähler', maloId: 'MaLo-ID', annualConsumption: 'Jahresverbrauch',
      basePrice: 'Grundpreis', unitPrice: 'Arbeitspreis', unitPriceNt: 'NT-Arbeitspreis',
      instantBonus: 'Sofort-Bonus', newCustomerBonus: 'Neukunden-Bonus',
    };

    // Hauptfelder vergleichen – gegen die SANITISIERTE Version, damit
    // das Audit-Log die echten DB-Werte widerspiegelt, nicht den
    // rohen Request-Body mit ggf. gestrippter HTML.
    const body = sanitizedBody as any;
    if (before) {
      for (const [key, newVal] of Object.entries(body)) {
        if (['energyDetails', 'internetDetails', 'mobileDetails', 'tvDetails', 'carInsuranceDetails', 'password'].includes(key)) continue;
        const oldVal = (before as any)[key];
        const norm = (v: unknown) => (v === null || v === undefined || v === '' ? null : v);
        if (JSON.stringify(norm(oldVal)) !== JSON.stringify(norm(newVal))) {
          const label = fieldLabels[key] || key;
          changes[label] = { von: oldVal ?? '-', nach: newVal ?? '-' };
        }
      }
      // Energie-Details vergleichen
      if (body.energyDetails && before.energyDetails) {
        for (const [key, newVal] of Object.entries(body.energyDetails)) {
          const oldVal = (before.energyDetails as any)[key];
          const norm = (v: unknown) => (v === null || v === undefined || v === '' ? null : v);
          if (JSON.stringify(norm(oldVal)) !== JSON.stringify(norm(newVal))) {
            const label = energyLabels[key] || key;
            changes[label] = { von: oldVal ?? '-', nach: newVal ?? '-' };
          }
        }
      }
    }

    const changeList = Object.entries(changes).map(([f, c]) => `${f}: ${c.von} → ${c.nach}`).join(', ');
    await logChange({
      req, action: 'UPDATE', resourceType: 'Contract',
      resourceId: contractId.toString(),
      label: changeList
        ? `Vertrag ${before?.contractNumber || contractId} aktualisiert: ${changeList}`
        : `Vertrag ${before?.contractNumber || contractId} aktualisiert`,
      details: Object.keys(changes).length > 0 ? changes : undefined,
      customerId: before?.customerId,
    });

    // Zusätzliches CRITICAL-Event für das „Portaldaten nicht benötigt"-Opt-out.
    // Landet unter resourceType ContractPassword (sensitivity CRITICAL laut
    // audit.service determineSensitivity) und liegt damit in derselben
    // DSGVO-Reihe wie Klartext-Password-Reads. Motivation: das Setzen des
    // Flags räumt server-seitig portalUsername/portalPasswordEncrypted/
    // stressfreiEmailId auf NULL – diese Löschung soll unabhängig vom
    // generischen UPDATE-Diff nachweisbar sein. Rücknahme des Flags wird
    // ebenfalls geloggt, damit die Kette in beide Richtungen konsistent ist.
    if (
      before
      && 'portalCredentialsNotRequired' in body
      && before.portalCredentialsNotRequired !== body.portalCredentialsNotRequired
    ) {
      const toggledOn = body.portalCredentialsNotRequired === true;
      await logChange({
        req,
        action: 'UPDATE',
        resourceType: 'ContractPassword',
        resourceId: contractId.toString(),
        label: toggledOn
          ? `Vertrag ${before.contractNumber || contractId}: Portal-Zugangsdaten als „nicht erforderlich" markiert (Username/Passwort/Stressfrei-ID auf NULL gesetzt)`
          : `Vertrag ${before.contractNumber || contractId}: „Portaldaten nicht erforderlich"-Marker zurückgenommen`,
        details: {
          vorher: before.portalCredentialsNotRequired,
          nachher: body.portalCredentialsNotRequired,
          hatUsernameVorher: !!before.portalUsername,
          hatPasswortVorher: !!before.portalPasswordEncrypted,
          hatStressfreiVorher: !!before.stressfreiEmailId,
        },
        customerId: before.customerId,
      });
    }

    // Response sanitisieren – sonst leakt portalPasswordEncrypted etc.
    // (Pentest Runde 15, gleiche Klasse wie 20.3 für Customer).
    const isPortal = !!req.user?.isCustomerPortal;
    const sanitized = isPortal
      ? sanitizeContractStrict(contract as any)
      : sanitizeContract(contract as any);
    res.json({ success: true, data: sanitized } as ApiResponse);
  } catch (error) {
    res.status(400).json({
      success: false,
      error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Vertrags',
    } as ApiResponse);
  }
}

export async function deleteContract(req: Request, res: Response): Promise<void> {
  try {
    const contractId = parseInt(req.params.id);
    // Pentest 56.3 (latent): Defense-in-Depth – Ownership-Check vor Delete.
    if (!(await canAccessContract(req as AuthRequest, res, contractId))) return;
    const contract = await prisma.contract.findUnique({ where: { id: contractId }, select: { contractNumber: true, customerId: true } });
    await contractService.deleteContract(contractId);
    await logChange({
      req, action: 'DELETE', resourceType: 'Contract',
      resourceId: contractId.toString(),
      label: `Vertrag ${contract?.contractNumber} gelöscht`,
      customerId: contract?.customerId,
    });
    res.json({ success: true, message: 'Vertrag gelöscht' } as ApiResponse);
  } catch (error) {
    res.status(400).json({
      success: false,
      error: error instanceof Error ? error.message : 'Fehler beim Löschen des Vertrags',
    } as ApiResponse);
  }
}

export async function createFollowUp(req: AuthRequest, res: Response): Promise<void> {
  try {
    const previousContractId = parseInt(req.params.id);
    if (!(await canAccessContract(req, res, previousContractId))) return;

    // Vorgängervertrag laden für Vertragsnummer
    const previousContract = await prisma.contract.findUnique({
      where: { id: previousContractId },
      select: { contractNumber: true },
    });

    if (!previousContract) {
      res.status(404).json({ success: false, error: 'Vorgängervertrag nicht gefunden' } as ApiResponse);
      return;
    }

    const contract = await contractService.createFollowUpContract(previousContractId);
    const createdBy = req.user?.email || 'unbekannt';

    // Historie-Eintrag für den Vorgängervertrag erstellen
    await contractHistoryService.createFollowUpHistoryEntry(
      previousContractId,
      contract.contractNumber,
      createdBy
    );

    // Historie-Eintrag für den neuen Folgevertrag erstellen
    await contractHistoryService.createNewContractFromPredecessorEntry(
      contract.id,
      previousContract.contractNumber,
      createdBy
    );

    await logChange({
      req, action: 'CREATE', resourceType: 'Contract',
      resourceId: contract.id.toString(),
      label: `Folgevertrag erstellt für ${previousContract.contractNumber}`,
      customerId: contract.customerId,
    });

    const isPortal = !!req.user?.isCustomerPortal;
    const sanitized = isPortal ? sanitizeContractStrict(contract as any) : sanitizeContract(contract as any);
    res.status(201).json({ success: true, data: sanitized } as ApiResponse);
  } catch (error) {
    res.status(400).json({
      success: false,
      error: error instanceof Error ? error.message : 'Fehler beim Erstellen des Folgevertrags',
    } as ApiResponse);
  }
}

/**
 * VVL = Vertragsverlängerung beim selben Anbieter.
 * Erstellt einen neuen Vertrag mit allen Daten des Vorgängers (außer
 * Auftragsdokument), Startdatum = altes Start + Vertragslaufzeit.
 */
export async function createRenewal(req: AuthRequest, res: Response): Promise<void> {
  try {
    const previousContractId = parseInt(req.params.id);
    if (!(await canAccessContract(req, res, previousContractId))) return;

    const previousContract = await prisma.contract.findUnique({
      where: { id: previousContractId },
      select: { contractNumber: true },
    });
    if (!previousContract) {
      res.status(404).json({ success: false, error: 'Vorgängervertrag nicht gefunden' } as ApiResponse);
      return;
    }

    const contract = await contractService.createRenewalContract(previousContractId);
    if (!contract) {
      res.status(500).json({ success: false, error: 'VVL konnte nicht erstellt werden' } as ApiResponse);
      return;
    }
    const createdBy = req.user?.email || 'unbekannt';

    await contractHistoryService.createRenewalHistoryEntry(
      previousContractId,
      contract.contractNumber,
      createdBy,
    );
    await contractHistoryService.createNewRenewalFromPredecessorEntry(
      contract.id,
      previousContract.contractNumber,
      createdBy,
    );

    await logChange({
      req, action: 'CREATE', resourceType: 'Contract',
      resourceId: contract.id.toString(),
      label: `VVL erstellt für ${previousContract.contractNumber}`,
      customerId: contract.customerId,
    });

    const isPortal = !!req.user?.isCustomerPortal;
    const sanitized = isPortal ? sanitizeContractStrict(contract as any) : sanitizeContract(contract as any);
    res.status(201).json({ success: true, data: sanitized } as ApiResponse);
  } catch (error) {
    res.status(400).json({
      success: false,
      error: error instanceof Error ? error.message : 'Fehler beim Erstellen der VVL',
    } as ApiResponse);
  }
}

export async function getContractPassword(req: AuthRequest, res: Response): Promise<void> {
  try {
    const contractId = parseInt(req.params.id);
    if (!(await canAccessContract(req, res, contractId))) return;

    const password = await contractService.getContractPassword(contractId);
    if (password === null) {
      res.status(404).json({
        success: false,
        error: 'Kein Passwort hinterlegt',
      } as ApiResponse);
      return;
    }
    // Klartext-Passwort-Read auditieren (CRITICAL)
    await logChange({
      req,
      action: 'READ',
      resourceType: 'ContractPassword',
      resourceId: contractId.toString(),
      label: `Klartext-Anbieter-Passwort von Vertrag #${contractId} entschlüsselt`,
    });
    res.json({ success: true, data: { password } } as ApiResponse);
  } catch (error) {
    res.status(500).json({
      success: false,
      error: 'Fehler beim Entschlüsseln des Passworts',
    } as ApiResponse);
  }
}

export async function getSimCardCredentials(req: AuthRequest, res: Response): Promise<void> {
  try {
    const simCardId = parseInt(req.params.simCardId);
    // SimCard → MobileDetails → Contract
    const sim = await prisma.simCard.findUnique({
      where: { id: simCardId },
      select: { mobileDetails: { select: { contractId: true } } },
    });
    if (!sim?.mobileDetails) {
      res.status(404).json({ success: false, error: 'SIM-Karte nicht gefunden' } as ApiResponse);
      return;
    }
    if (!(await canAccessContract(req, res, sim.mobileDetails.contractId))) return;

    const credentials = await contractService.getSimCardCredentials(simCardId);
    // Klartext-Read (PIN/PUK) auditieren (CRITICAL)
    await logChange({
      req,
      action: 'READ',
      resourceType: 'SimCardCredentials',
      resourceId: simCardId.toString(),
      label: `Klartext-SIM-Karten-PIN/PUK von SIM #${simCardId} (Vertrag #${sim.mobileDetails.contractId}) entschlüsselt`,
    });
    res.json({ success: true, data: credentials } as ApiResponse);
  } catch (error) {
    res.status(500).json({
      success: false,
      error: 'Fehler beim Entschlüsseln der SIM-Karten-Daten',
    } as ApiResponse);
  }
}

export async function getInternetCredentials(req: AuthRequest, res: Response): Promise<void> {
  try {
    const contractId = parseInt(req.params.id);
    if (!(await canAccessContract(req, res, contractId))) return;

    const credentials = await contractService.getInternetCredentials(contractId);
    // Klartext-DSL/Internet-Login auditieren (CRITICAL)
    await logChange({
      req,
      action: 'READ',
      resourceType: 'InternetCredentials',
      resourceId: contractId.toString(),
      label: `Klartext-Internet-Zugangsdaten von Vertrag #${contractId} entschlüsselt`,
    });
    res.json({ success: true, data: credentials } as ApiResponse);
  } catch (error) {
    res.status(500).json({
      success: false,
      error: 'Fehler beim Entschlüsseln des Internet-Passworts',
    } as ApiResponse);
  }
}

export async function getSipCredentials(req: AuthRequest, res: Response): Promise<void> {
  try {
    const phoneNumberId = parseInt(req.params.phoneNumberId);
    // PhoneNumber → InternetDetails → Contract
    const phone = await prisma.phoneNumber.findUnique({
      where: { id: phoneNumberId },
      select: { internetDetails: { select: { contractId: true } } },
    });
    if (!phone?.internetDetails) {
      res.status(404).json({ success: false, error: 'Rufnummer nicht gefunden' } as ApiResponse);
      return;
    }
    if (!(await canAccessContract(req, res, phone.internetDetails.contractId))) return;

    const credentials = await contractService.getSipCredentials(phoneNumberId);
    // Klartext-SIP/Telefon-Login auditieren (CRITICAL)
    await logChange({
      req,
      action: 'READ',
      resourceType: 'SipCredentials',
      resourceId: phoneNumberId.toString(),
      label: `Klartext-SIP-Zugangsdaten von Rufnummer #${phoneNumberId} (Vertrag #${phone.internetDetails.contractId}) entschlüsselt`,
    });
    res.json({ success: true, data: credentials } as ApiResponse);
  } catch (error) {
    res.status(500).json({
      success: false,
      error: 'Fehler beim Entschlüsseln des SIP-Passworts',
    } as ApiResponse);
  }
}

// ==================== VERTRAGS-COCKPIT ====================

export async function getCockpit(req: AuthRequest, res: Response): Promise<void> {
  try {
    // Portal-User dürfen nur ihre eigenen + vertretene Kunden (mit Vollmacht) sehen.
    // Analog zu getContracts. Sonst leakt das Cockpit ALLE Verträge ALLER Kunden
    // (Pentest Runde 4, 2026-05-16: HOCH).
    // Fail-closed (Pentest R146): Portal-Token wird IMMER gescoped; fehlt wider
    // Erwarten die customerId, ergibt das eine leere Menge (nicht alle) – der
    // Cockpit-Service filtert `customerIds: []` bereits als „keine Treffer".
    let customerIds: number[] | undefined;
    if (req.user?.isCustomerPortal) {
      customerIds = req.user.customerId ? [req.user.customerId] : [];
      if (req.user.customerId) {
        const representedIds: number[] = req.user.representedCustomerIds || [];
        for (const repCustId of representedIds) {
          const hasAuth = await authorizationService.hasAuthorization(repCustId, req.user.customerId);
          if (hasAuth) {
            customerIds.push(repCustId);
          }
        }
      }
    }

    const cockpitData = await contractCockpitService.getCockpitData({ customerIds });
    res.json({ success: true, data: cockpitData } as ApiResponse);
  } catch (error) {
    console.error('Cockpit error:', error);
    res.status(500).json({
      success: false,
      error: 'Fehler beim Laden des Vertrags-Cockpits',
    } as ApiResponse);
  }
}

// ==================== FOLGEZÄHLER ====================

export async function addSuccessorMeter(req: AuthRequest, res: Response): Promise<void> {
  try {
    const contractId = parseInt(req.params.id);
    const { meterId, installedAt, finalReadingPrevious, deactivatePredecessor } = req.body;

    const contract = await prisma.contract.findUnique({
      where: { id: contractId },
      include: { energyDetails: { include: { contractMeters: { orderBy: { position: 'asc' } } } } },
    });

    if (!contract?.energyDetails) {
      res.status(404).json({ success: false, error: 'Energievertrag nicht gefunden' } as ApiResponse);
      return;
    }

    const ecdId = contract.energyDetails.id;
    const existingMeters = [...contract.energyDetails.contractMeters];
    const switchAt = installedAt ? new Date(installedAt) : new Date();

    // Vorgänger ermitteln (letzter ContractMeter oder Single-Meter-Vertrag)
    const predecessorMeterId = existingMeters.length > 0
      ? existingMeters[existingMeters.length - 1].meterId
      : contract.energyDetails.meterId;

    // Endstand bereits hier validieren (monoton-steigend gegen vorhandene
    // Zählerstände des Vorgängers), damit wir nicht halb-geschriebene
    // Zustände hinterlassen.
    if (finalReadingPrevious !== undefined && finalReadingPrevious !== null && predecessorMeterId) {
      const finalReadingValue = parseFloat(finalReadingPrevious);
      // recordPredecessorFinalReading läuft erst NACH den Writes – Pre-Check
      // ohne Write hier separat über die Service-Validierung (idempotent, weil
      // sie keinen Reading anlegt, wenn am Wechseltag schon einer existiert).
      // Wir lassen den eigentlichen Write am Ende laufen, damit ein Fehler
      // beim Reading die Kette nicht zerreißt.
      const dayStart = new Date(switchAt); dayStart.setHours(0, 0, 0, 0);
      const dayEnd = new Date(dayStart); dayEnd.setDate(dayEnd.getDate() + 1);
      const sameDay = await prisma.meterReading.findFirst({
        where: { meterId: predecessorMeterId, readingDate: { gte: dayStart, lt: dayEnd } },
      });
      if (!sameDay) {
        const lastBefore = await prisma.meterReading.findFirst({
          where: { meterId: predecessorMeterId, readingDate: { lte: switchAt } },
          orderBy: { readingDate: 'desc' },
        });
        if (lastBefore && finalReadingValue < lastBefore.value) {
          const fmtDate = (d: Date) => d.toLocaleDateString('de-DE');
          throw new Error(`Endstand (${finalReadingValue}) darf nicht kleiner sein als der Stand vom ${fmtDate(lastBefore.readingDate)} (${lastBefore.value})`);
        }
      }
    }

    // Backfill: Bei Single-Meter-Verträgen (kein ContractMeter-Eintrag) den
    // bisherigen `energyDetails.meterId` als position 0 nachtragen, damit die
    // Folgezähler-Kette lückenlos ist und der alte Zähler nicht aus dem
    // Vertrag verschwindet, wenn `energyDetails.meterId` gleich auf den Neuen
    // gedreht wird.
    if (existingMeters.length === 0 && contract.energyDetails.meterId) {
      const backfilled = await prisma.contractMeter.create({
        data: {
          energyContractDetailsId: ecdId,
          meterId: contract.energyDetails.meterId,
          position: 0,
          installedAt: null,
        },
      });
      existingMeters.push(backfilled);
    }

    const nextPosition = existingMeters.length > 0
      ? Math.max(...existingMeters.map(m => m.position)) + 1
      : 0;

    // Vorherigen Zähler als gewechselt markieren
    if (existingMeters.length > 0) {
      const prevMeter = existingMeters[existingMeters.length - 1];
      await prisma.contractMeter.update({
        where: { id: prevMeter.id },
        data: {
          removedAt: switchAt,
          finalReading: finalReadingPrevious !== undefined
            ? parseFloat(finalReadingPrevious)
            : prevMeter.finalReading,
        },
      });
    }

    const contractMeter = await prisma.contractMeter.create({
      data: {
        energyContractDetailsId: ecdId,
        meterId: parseInt(meterId),
        position: nextPosition,
        installedAt: switchAt,
      },
      include: { meter: { include: { readings: true } } },
    });

    // Aktuellen Zähler am Vertrag aktualisieren
    await prisma.energyContractDetails.update({
      where: { id: ecdId },
      data: { meterId: parseInt(meterId) },
    });

    // Endstand des Vorgängers als regulären Zählerstand erfassen, damit er in
    // die Verbrauchsberechnung einfließt und in der Zählerstände-Liste auftaucht.
    if (finalReadingPrevious !== undefined && finalReadingPrevious !== null && predecessorMeterId) {
      await recordPredecessorFinalReading(
        predecessorMeterId,
        switchAt,
        parseFloat(finalReadingPrevious),
      );
    }

    // Alten Zähler deaktivieren (Default), sofern der Aufrufer das nicht
    // explizit auf false setzt – ein-klick-fähiger Zählerwechsel.
    if (predecessorMeterId && deactivatePredecessor !== false) {
      await prisma.meter.update({
        where: { id: predecessorMeterId },
        data: { isActive: false },
      });
    }

    await logChange({
      req, action: 'CREATE', resourceType: 'ContractMeter',
      resourceId: contractMeter.id.toString(),
      label: `Folgezähler hinzugefügt zu Vertrag #${contractId}`,
      customerId: contract.customerId,
    });

    res.json({ success: true, data: contractMeter } as ApiResponse);
  } catch (error) {
    res.status(400).json({
      success: false,
      error: error instanceof Error ? error.message : 'Fehler beim Hinzufügen des Folgezählers',
    } as ApiResponse);
  }
}

export async function removeContractMeter(req: AuthRequest, res: Response): Promise<void> {
  try {
    const contractMeterId = parseInt(req.params.contractMeterId);
    const contractId = parseInt(req.params.id);
    if (!(await canAccessContract(req, res, contractId))) return;
    await prisma.contractMeter.delete({ where: { id: contractMeterId } });
    await logChange({
      req, action: 'DELETE', resourceType: 'ContractMeter',
      resourceId: contractMeterId.toString(),
      label: `Folgezähler entfernt von Vertrag #${contractId}`,
    });
    res.json({ success: true, data: null } as ApiResponse);
  } catch (error) {
    res.status(400).json({
      success: false,
      error: error instanceof Error ? error.message : 'Fehler beim Entfernen',
    } as ApiResponse);
  }
}

// ==================== VERTRAGSDOKUMENTE ====================

export async function getContractDocuments(req: AuthRequest, res: Response): Promise<void> {
  try {
    const contractId = parseInt(req.params.id);
    if (!(await canAccessContract(req, res, contractId))) return;

    const documents = await prisma.contractDocument.findMany({
      where: { contractId },
      orderBy: { createdAt: 'desc' },
    });
    res.json({ success: true, data: documents } as ApiResponse);
  } catch (error) {
    res.status(500).json({ success: false, error: 'Fehler beim Laden der Dokumente' } as ApiResponse);
  }
}

export async function uploadContractDocument(req: AuthRequest, res: Response): Promise<void> {
  try {
    const contractId = parseInt(req.params.id);
    if (!(await canAccessContract(req, res, contractId))) return;
    const { documentType, notes } = req.body;

    if (!req.file) {
      res.status(400).json({ success: false, error: 'Keine Datei hochgeladen' } as ApiResponse);
      return;
    }

    if (!documentType) {
      res.status(400).json({ success: false, error: 'Dokumenttyp erforderlich' } as ApiResponse);
      return;
    }

    // Pentest 62.7: deliveryDate validieren (ISO-8601 oder null).
    let deliveryDate: string | null;
    try {
      deliveryDate = validateOptionalIsoDate(req.body?.deliveryDate, 'deliveryDate');
    } catch (err) {
      try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
      res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültiges Lieferdatum' } as ApiResponse);
      return;
    }

    const documentPath = `/uploads/contract-documents/${req.file.filename}`;
    // Pentest 58.1: Whitelist-Validierung statt nur stripHtml. Multer hat
    // die Datei schon geschrieben – bei Reject räumen wir sie wieder weg.
    let cleanType: string;
    try {
      cleanType = validateContractDocumentType(documentType);
    } catch (err) {
      try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
      res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültiger Dokumenttyp' } as ApiResponse);
      return;
    }
    // Pentest 55.4: Race-Schutz – Lock + Recent-Duplicate-Check.
    const doc = await withContractDocumentLock(contractId, cleanType, () =>
      prisma.contractDocument.create({
        data: {
          contractId,
          documentType: cleanType,
          documentPath,
          originalName: req.file!.originalname,
          notes: sanitizeNotes(notes),
          uploadedBy: req.user?.email,
        },
      }),
    );

    const contract = await prisma.contract.findUnique({ where: { id: contractId }, select: { contractNumber: true, customerId: true } });
    await logChange({
      req, action: 'CREATE', resourceType: 'ContractDocument',
      resourceId: doc.id.toString(),
      label: `Dokument "${cleanType}" hochgeladen für Vertrag ${contract?.contractNumber}`,
      details: { typ: cleanType, datei: req.file.originalname },
      customerId: contract?.customerId,
    });

    // Falls Lieferbestätigung: DRAFT → ACTIVE + startDate setzen falls leer
    await maybeActivateOnDeliveryConfirmation(contractId, cleanType, req, deliveryDate);

    res.status(201).json({ success: true, data: doc } as ApiResponse);
  } catch (error) {
    // Pentest 64.1: ApiError mit eigenem statusCode honorieren (z.B. 400
    // vom Race-Lock); fallback bleibt 400 für sonstige ContractDocument-
    // Schreibfehler.
    const status = error instanceof ApiError ? error.statusCode : 400;
    // Multer hat die Datei schon geschrieben – bei Reject räumen.
    if (req.file?.path) try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
    res.status(status).json({
      success: false,
      error: error instanceof Error ? error.message : 'Fehler beim Hochladen',
    } as ApiResponse);
  }
}

export async function deleteContractDocument(req: AuthRequest, res: Response): Promise<void> {
  try {
    const documentId = parseInt(req.params.documentId);
    const contractId = parseInt(req.params.id);
    if (!(await canAccessContract(req, res, contractId))) return;

    const doc = await prisma.contractDocument.findUnique({ where: { id: documentId } });
    if (!doc || doc.contractId !== contractId) {
      res.status(404).json({ success: false, error: 'Dokument nicht gefunden' } as ApiResponse);
      return;
    }

    // Datei löschen
    const fs = await import('fs');
    const path = await import('path');
    const filePath = path.join(process.cwd(), doc.documentPath);
    if (fs.existsSync(filePath)) {
      fs.unlinkSync(filePath);
    }

    await prisma.contractDocument.delete({ where: { id: documentId } });

    const contract = await prisma.contract.findUnique({ where: { id: contractId }, select: { contractNumber: true, customerId: true } });
    await logChange({
      req, action: 'DELETE', resourceType: 'ContractDocument',
      resourceId: documentId.toString(),
      label: `Dokument "${doc.documentType}" gelöscht von Vertrag ${contract?.contractNumber}`,
      details: { typ: doc.documentType, datei: doc.originalName },
      customerId: contract?.customerId,
    });

    res.json({ success: true, message: 'Dokument gelöscht' } as ApiResponse);
  } catch (error) {
    res.status(400).json({
      success: false,
      error: error instanceof Error ? error.message : 'Fehler beim Löschen',
    } as ApiResponse);
  }
}

// ==================== SNOOZE (VERTRAG ZURÜCKSTELLEN) ====================

export async function snoozeContract(req: AuthRequest, res: Response): Promise<void> {
  try {
    const id = parseInt(req.params.id);
    if (!(await canAccessContract(req, res, id))) return;
    const { nextReviewDate, months } = req.body;

    let reviewDate: Date | null = null;

    if (nextReviewDate) {
      // Explizites Datum angegeben
      reviewDate = new Date(nextReviewDate);
    } else if (months) {
      // Monate angegeben → berechne Datum
      reviewDate = new Date();
      reviewDate.setMonth(reviewDate.getMonth() + months);
    }
    // Wenn beides leer → nextReviewDate wird auf null gesetzt (Snooze aufheben)

    const updated = await prisma.contract.update({
      where: { id },
      data: { nextReviewDate: reviewDate },
      select: {
        id: true,
        contractNumber: true,
        nextReviewDate: true,
      },
    });

    await logChange({
      req, action: 'UPDATE', resourceType: 'Contract',
      resourceId: id.toString(),
      label: `Vertrag ${updated.contractNumber} zurückgestellt`,
    });

    res.json({
      success: true,
      data: updated,
      message: reviewDate
        ? `Vertrag zurückgestellt bis ${reviewDate.toLocaleDateString('de-DE')}`
        : 'Zurückstellung aufgehoben',
    } as ApiResponse);
  } catch (error) {
    console.error('Snooze error:', error);
    res.status(500).json({
      success: false,
      error: 'Fehler beim Zurückstellen des Vertrags',
    } as ApiResponse);
  }
}
