import prisma from '../lib/prisma.js';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
import { JwtPayload } from '../types/index.js';
import { encrypt, decrypt } from '../utils/encryption.js';
import { sendEmail, SmtpCredentials } from './smtpService.js';
import { getSystemEmailCredentials } from './emailProvider/emailProviderService.js';
import { getAuthorizedCustomerIds } from './authorization.service.js';
import * as appSettingService from './appSetting.service.js';

// Token-Lifetimes
// - Access-Token: kurzlebig, nur im Browser-Memory → XSS klaut max. 15 min
// - Refresh-Token: lang, im httpOnly-Cookie → kein JS-Zugriff
const ACCESS_TOKEN_EXPIRES_IN = (process.env.JWT_EXPIRES_IN || '15m') as jwt.SignOptions['expiresIn'];
const REFRESH_TOKEN_EXPIRES_IN = (process.env.JWT_REFRESH_EXPIRES_IN || '7d') as jwt.SignOptions['expiresIn'];

// Helper: signiert ein Access- bzw. Refresh-JWT mit dem `type`-Claim als
// Unterscheidung. Der Refresh-Token landet im httpOnly-Cookie und wird beim
// /auth/refresh-Endpoint geprüft, der dann einen neuen Access ausgibt.
export function signAccessToken(payload: JwtPayload): string {
  return jwt.sign({ ...payload, type: 'access' }, process.env.JWT_SECRET as string, {
    expiresIn: ACCESS_TOKEN_EXPIRES_IN,
  });
}
export function signRefreshToken(payload: JwtPayload, jti?: string, familyId?: string): string {
  return jwt.sign(
    { ...payload, type: 'refresh', ...(jti ? { jti } : {}), ...(familyId ? { fam: familyId } : {}) },
    process.env.JWT_SECRET as string,
    { expiresIn: REFRESH_TOKEN_EXPIRES_IN },
  );
}

// ===================== Refresh-Token-Replay-Schutz (R164-02) =====================
//
// Kulanzfenster: Mehrere Tabs teilen sich denselben Cookie und koennen einen
// Refresh praktisch gleichzeitig ausloesen. Ohne Toleranz wuerde der zweite
// legitime Aufruf als Replay gewertet und die Sitzung gesprengt. Die Toleranz
// ist bewusst eng (Sekunden UND Anzahl begrenzt): ein Burst mit vielen
// Wiederverwendungen - das typische Bild beim Missbrauch eines gestohlenen
// Tokens - schlaegt weiterhin an.
const REFRESH_REUSE_GRACE_MS = 15_000;
const REFRESH_REUSE_MAX = 3;

/** Legt einen neuen Sitzungs-Refresh-Token an (neue Familie). */
export async function issueRefreshToken(
  payload: JwtPayload,
  opts: {
    userId?: number;
    customerId?: number;
    isCustomerPortal: boolean;
    familyId?: string;
    /** Beim Rotieren bereits beim Einloesen reservierte jti des Nachfolgers. */
    jti?: string;
  },
): Promise<string> {
  const jti = opts.jti || crypto.randomUUID();
  const familyId = opts.familyId || crypto.randomUUID();
  const token = signRefreshToken(payload, jti, familyId);
  const decoded: any = jwt.decode(token);

  await prisma.refreshTokenRecord.create({
    data: {
      jti,
      familyId,
      userId: opts.userId ?? null,
      customerId: opts.customerId ?? null,
      isCustomerPortal: opts.isCustomerPortal,
      expiresAt: new Date((decoded?.exp ?? 0) * 1000),
    },
  });
  return token;
}

/** Widerruft eine komplette Sitzungsfamilie. */
async function revokeFamily(familyId: string, reason: string): Promise<void> {
  await prisma.refreshTokenRecord.updateMany({
    where: { familyId, revokedAt: null },
    data: { revokedAt: new Date(), revokedReason: reason },
  });
}

/** Widerruft alle Familien eines Subjekts (Logout, Rechteaenderung). */
export async function revokeAllRefreshTokens(
  subject: { userId?: number; customerId?: number },
  reason: string,
): Promise<void> {
  const where = subject.userId != null ? { userId: subject.userId } : { customerId: subject.customerId };
  await prisma.refreshTokenRecord.updateMany({
    where: { ...where, revokedAt: null },
    data: { revokedAt: new Date(), revokedReason: reason },
  });
}

/** Abgelaufene Datensaetze entfernen (beim Login, seltener als beim Refresh). */
async function pruneExpiredRefreshTokens(): Promise<void> {
  try {
    await prisma.refreshTokenRecord.deleteMany({ where: { expiresAt: { lt: new Date() } } });
  } catch {
    // Aufraeumen darf den Login nie blockieren.
  }
}

/**
 * Prueft die jti eines vorgelegten Refresh-Tokens und verbraucht sie.
 * Wirft mit `code = 'REFRESH_REPLAY'`, wenn ein bereits eingeloester Token
 * erneut auftaucht - der Controller meldet das als Sicherheitsvorfall.
 */
async function consumeRefreshJti(decoded: any): Promise<{
  familyId: string;
  /** Frische Rotation: Datensatz fuer diese jti muss noch angelegt werden. */
  issueJti?: string;
  /** Kulanz: dieser bereits ausgestellte Nachfolger wird erneut ausgegeben. */
  reuseJti?: string;
}> {
  const jti: string | undefined = decoded?.jti;
  const fam: string | undefined = decoded?.fam;

  if (!jti || !fam) {
    // Token aus der Zeit vor dem Replay-Schutz: bewusst fail-closed, damit
    // kein Altbestand die Einmalverwendung umgehen kann. Kostet einmalig
    // eine Neuanmeldung.
    const err: any = new Error('Sitzung muss erneuert werden – bitte neu anmelden');
    err.code = 'REFRESH_LEGACY';
    throw err;
  }

  const rec = await prisma.refreshTokenRecord.findUnique({ where: { jti } });
  if (!rec) {
    const err: any = new Error('Refresh-Token ungültig oder abgelaufen');
    err.code = 'REFRESH_INVALID';
    throw err;
  }
  if (rec.revokedAt) {
    const err: any = new Error('Refresh-Token wurde invalidiert (Logout/Rechteänderung)');
    err.code = 'REFRESH_REVOKED';
    throw err;
  }

  // Einloesen MUSS atomar sein. Ein Lesen-dann-Schreiben wuerde bei parallelen
  // Anfragen nichts verhindern: alle wuerden den Token als "noch unbenutzt"
  // sehen und durchgelassen (im Test kamen 90 gleichzeitige Requests
  // ausnahmslos durch). Deshalb wird der Zustandswechsel als bedingtes UPDATE
  // ausgefuehrt - die Datenbank entscheidet, wer zuerst war.
  // Die jti des Nachfolgers wird SCHON HIER festgelegt und im selben UPDATE
  // hinterlegt. Dadurch weiss eine spaetere Wiedervorlage, welcher Nachfolger
  // bereits ausgestellt wurde - Grundlage der idempotenten Kulanz (R168-01).
  const nachfolgerJti = crypto.randomUUID();
  const beansprucht = await prisma.refreshTokenRecord.updateMany({
    where: { jti, usedAt: null, revokedAt: null },
    data: { usedAt: new Date(), replacedByJti: nachfolgerJti },
  });
  if (beansprucht.count === 1) {
    return { familyId: rec.familyId, issueJti: nachfolgerJti };
  }

  // Bereits eingeloest. Innerhalb des engen Kulanzfensters begrenzt tolerieren
  // (parallele Tabs) - als bedingtes UPDATE, damit die Obergrenze unter Last
  // wirklich haelt.
  const fensterAb = new Date(Date.now() - REFRESH_REUSE_GRACE_MS);
  const toleriert = await prisma.refreshTokenRecord.updateMany({
    where: {
      jti,
      revokedAt: null,
      usedAt: { gte: fensterAb },
      reuseCount: { lt: REFRESH_REUSE_MAX },
    },
    data: { reuseCount: { increment: 1 } },
  });
  if (toleriert.count === 1) {
    // IDEMPOTENT: denselben, bereits ausgestellten Nachfolger zurueckgeben -
    // NICHT erneut rotieren (Pentest R168-01).
    //
    // Vorher entstand bei jeder Kulanz-Wiedervorlage eine frische Linie mit
    // eigenem, zurueckgesetztem Zaehler. Ein Angreifer mit gestohlenem Token
    // konnte damit aus dem erkennbaren Replay-Zustand in eine eigene,
    // sauber weiterrotierende Sitzung entkommen, die nie wieder mit der des
    // Opfers kollidiert - also dauerhaft unsichtbar blieb.
    //
    // Jetzt laufen parallele Tabs auf DIESELBE Linie zusammen. Wer den Token
    // spaeter erneut vorlegt (also ausserhalb des Fensters), kollidiert
    // zwangslaeufig und loest den Familien-Widerruf aus.
    const aktuell = await prisma.refreshTokenRecord.findUnique({ where: { jti } });
    if (aktuell?.replacedByJti) {
      const nachfolger = await prisma.refreshTokenRecord.findUnique({
        where: { jti: aktuell.replacedByJti },
      });
      if (nachfolger?.revokedAt) {
        const err: any = new Error('Refresh-Token wurde invalidiert (Logout/Rechteänderung)');
        err.code = 'REFRESH_REVOKED';
        throw err;
      }
      return { familyId: rec.familyId, reuseJti: aktuell.replacedByJti };
    }
    // Kein Nachfolger hinterlegt: nicht ersatzweise rotieren (das waere genau
    // der Fork). Fail-closed als Replay behandeln.
  }

  // Weder frei noch tolerierbar: War der Token zwischenzeitlich widerrufen
  // (z. B. durch einen parallelen Replay-Fund), ist das kein neuer Vorfall.
  const aktuell = await prisma.refreshTokenRecord.findUnique({ where: { jti } });
  if (aktuell?.revokedAt) {
    const err: any = new Error('Refresh-Token wurde invalidiert (Logout/Rechteänderung)');
    err.code = 'REFRESH_REVOKED';
    throw err;
  }

  // Replay: gesamte Familie widerrufen - der Angreifer verliert den Zugang,
  // der legitime Nutzer muss sich neu anmelden und merkt es dadurch.
  await revokeFamily(rec.familyId, 'Replay eines bereits eingelösten Refresh-Tokens');
  const err: any = new Error('Refresh-Token wurde bereits verwendet – Sitzung aus Sicherheitsgründen beendet');
  err.code = 'REFRESH_REPLAY';
  throw err;
}

// Kurzlebiger Download-Token (60s, single-purpose). Wird vom Frontend
// abgerufen, wenn ein Endpoint per `?token=` aufgerufen werden muss
// (z.B. PDF-iframe, Audit-Export-Window). Selbst wenn dieser Token in
// nginx-Access-Logs oder der Browser-History landet, ist er nach
// 60 Sekunden wertlos. Pentest Runde 7 (2026-05-17) – NIEDRIG.
export function signDownloadToken(payload: JwtPayload): string {
  return jwt.sign({ ...payload, type: 'download' }, process.env.JWT_SECRET as string, {
    expiresIn: '60s',
  });
}

// Bcrypt-Cost-Faktor: 12 = OWASP-empfohlen (Stand 2026), ca. 250 ms pro Hash.
// Bestehende Hashes mit Faktor 10 bleiben gültig (bcrypt kodiert den Faktor im Hash).
const BCRYPT_COST = 12;

// Dummy-Hash mit Cost 12 für Timing-Attack-Schutz: bei nicht-existierendem User
// führen wir trotzdem ein bcrypt.compare() durch, damit die Antwortzeit nicht
// verrät, ob die E-Mail existiert. Konstanter Hash hat keine Bedeutung außer
// dem Timing-Angleich.
const DUMMY_BCRYPT_HASH = '$2a$12$CwTycUXWue0Thq9StjUM0uJ8gQKwqKjq8lZ3TZ9qg8aJ0A9hPn4Wy';

/**
 * Upgrade eines bestehenden Passwort-Hashes auf aktuellen BCRYPT_COST.
 * Wird nach erfolgreichem Login aufgerufen. Alte User (z.B. admin mit Cost 10
 * aus der Installation) werden so lazy auf Cost 12 migriert – damit sich die
 * Antwortzeit beim Login der Dummy-Zeit bei ungültigen Usern angleicht.
 */
async function maybeUpgradePasswordHash(
  table: 'user' | 'customer',
  id: number,
  plaintextPassword: string,
  currentHash: string,
): Promise<void> {
  const match = currentHash.match(/^\$2[aby]\$(\d+)\$/);
  const currentCost = match ? parseInt(match[1], 10) : 0;
  if (currentCost === BCRYPT_COST) return;
  try {
    const newHash = await bcrypt.hash(plaintextPassword, BCRYPT_COST);
    if (table === 'user') {
      await prisma.user.update({ where: { id }, data: { password: newHash } });
    } else {
      await prisma.customer.update({ where: { id }, data: { portalPasswordHash: newHash } });
    }
  } catch (err) {
    // Nicht kritisch – Login war erfolgreich, Rehash kann beim nächsten Login nachgeholt werden
    console.warn('[maybeUpgradePasswordHash] Fehler beim Rehash:', err);
  }
}

// Mitarbeiter-Login
export async function login(email: string, password: string) {
  const user = await prisma.user.findUnique({
    where: { email },
    include: {
      roles: {
        include: {
          role: {
            include: {
              permissions: {
                include: {
                  permission: true,
                },
              },
            },
          },
        },
      },
    },
  });

  if (!user || !user.isActive) {
    // Timing-Attack-Schutz: Dummy-bcrypt-compare damit die Antwortzeit bei
    // nicht-existierendem/deaktiviertem User der eines gültigen Users entspricht.
    await bcrypt.compare(password, DUMMY_BCRYPT_HASH);
    throw new Error('Ungültige Anmeldedaten');
  }

  const isValid = await bcrypt.compare(password, user.password);
  if (!isValid) {
    throw new Error('Ungültige Anmeldedaten');
  }

  // Lazy-Upgrade: ältere Cost-10-Hashes auf aktuellen BCRYPT_COST rehashen.
  // Async, nicht blockierend für die Response.
  maybeUpgradePasswordHash('user', user.id, password, user.password).catch(() => {});

  // Collect all permissions from all roles
  const permissions = new Set<string>();
  for (const userRole of user.roles) {
    for (const rolePerm of userRole.role.permissions) {
      permissions.add(
        `${rolePerm.permission.resource}:${rolePerm.permission.action}`
      );
    }
  }

  const payload: JwtPayload = {
    userId: user.id,
    email: user.email,
    permissions: Array.from(permissions),
    customerId: user.customerId ?? undefined,
    isCustomerPortal: false,
  };

  await pruneExpiredRefreshTokens();
  const accessToken = signAccessToken(payload);
  // Neue Sitzungsfamilie (Replay-Schutz, R164-02)
  const refreshToken = await issueRefreshToken(payload, { userId: user.id, isCustomerPortal: false });

  return {
    accessToken,
    refreshToken,
    user: {
      id: user.id,
      email: user.email,
      firstName: user.firstName,
      lastName: user.lastName,
      permissions: Array.from(permissions),
      customerId: user.customerId,
      isCustomerPortal: false,
      // Wird von der Audit-Middleware gelesen, um planmaessige
      // Dienstkonto-Anmeldungen als Routine einzustufen (R182).
      isServiceAccount: user.isServiceAccount,
    },
  };
}

// Kundenportal-Login
export async function customerLogin(email: string, password: string) {
  console.log('[CustomerLogin] Versuch mit E-Mail:', email);

  const customer = await prisma.customer.findUnique({
    where: { portalEmail: email },
    include: {
      // Kunden, die dieser Kunde vertreten kann
      representingFor: {
        where: { isActive: true },
        include: {
          customer: {
            select: {
              id: true,
              customerNumber: true,
              firstName: true,
              lastName: true,
              companyName: true,
              type: true,
            },
          },
        },
      },
    },
  });

  console.log('[CustomerLogin] Kunde gefunden:', customer ? `ID ${customer.id}, portalEnabled: ${customer.portalEnabled}, hasPasswordHash: ${!!customer.portalPasswordHash}` : 'NEIN');

  if (!customer || !customer.portalEnabled || !customer.portalPasswordHash) {
    console.log('[CustomerLogin] Abbruch: Kunde nicht gefunden oder Portal nicht aktiviert');
    // Timing-Attack-Schutz (siehe login())
    await bcrypt.compare(password, DUMMY_BCRYPT_HASH);
    throw new Error('Ungültige Anmeldedaten');
  }

  const isValid = await bcrypt.compare(password, customer.portalPasswordHash);
  console.log('[CustomerLogin] Passwort-Check:', isValid ? 'OK' : 'FALSCH');

  if (!isValid) {
    throw new Error('Ungültige Anmeldedaten');
  }

  // Einmalpasswort-Check: wurde es per "Zugangsdaten versenden" verschickt?
  // Falls ja, jetzt sofort verbrauchen – Hash + Encrypted nullen, damit
  // weder Re-Login noch Klartext-Abruf möglich ist. Customer landet im
  // Force-Change-Password-Flow.
  const mustChangePassword = customer.portalPasswordMustChange === true;
  if (mustChangePassword) {
    await prisma.customer.update({
      where: { id: customer.id },
      data: {
        portalPasswordHash: null,
        portalPasswordEncrypted: null,
        portalLastLogin: new Date(),
      },
    });
  } else {
    // Lazy-Upgrade analog zu Mitarbeiter-Login
    maybeUpgradePasswordHash('customer', customer.id, password, customer.portalPasswordHash).catch(() => {});

    // Letzte Anmeldung aktualisieren
    await prisma.customer.update({
      where: { id: customer.id },
      data: { portalLastLogin: new Date() },
    });
  }

  // IDs der Kunden sammeln, die dieser Kunde vertreten kann –
  // GEFILTERT auf aktive Vollmacht (isGranted: true). Ohne diesen Filter
  // hätte das frische JWT nach Vollmacht-Widerruf weiterhin die alte
  // representedCustomerIds-Liste; die UI würde dem Vertreter noch
  // anzeigen, dass er vertreten kann, obwohl der Live-Check beim
  // Datenzugriff dann 403 wirft. Pentest Runde 10 (2026-05-17), MEDIUM.
  const grantedCustomerIds = new Set(await getAuthorizedCustomerIds(customer.id));
  const grantedRepresentingFor = customer.representingFor.filter((rep) =>
    grantedCustomerIds.has(rep.customer.id),
  );
  const representedCustomerIds = grantedRepresentingFor.map((rep) => rep.customer.id);

  // Kundenportal-Berechtigungen (eingeschränkt)
  const customerPermissions = [
    'contracts:read',    // Eigene Verträge lesen
    'customers:read',    // Eigene Kundendaten lesen
  ];

  const payload: JwtPayload = {
    email: customer.portalEmail!,
    permissions: customerPermissions,
    customerId: customer.id,
    isCustomerPortal: true,
    representedCustomerIds,
  };

  await pruneExpiredRefreshTokens();
  const accessToken = signAccessToken(payload);
  // Neue Sitzungsfamilie (Replay-Schutz, R164-02)
  const refreshToken = await issueRefreshToken(payload, { customerId: customer.id, isCustomerPortal: true });

  return {
    accessToken,
    refreshToken,
    mustChangePassword,
    user: {
      id: customer.id,
      email: customer.portalEmail,
      firstName: customer.firstName,
      lastName: customer.lastName,
      permissions: customerPermissions,
      customerId: customer.id,
      isCustomerPortal: true,
      mustChangePassword,
      representedCustomers: grantedRepresentingFor.map((rep) => ({
        id: rep.customer.id,
        customerNumber: rep.customer.customerNumber,
        firstName: rep.customer.firstName,
        lastName: rep.customer.lastName,
        companyName: rep.customer.companyName,
        type: rep.customer.type,
      })),
    },
  };
}

// Refresh-Token verifizieren und neuen Access-Token ausstellen. Wirft bei
// ungültigem/abgelaufenem/invalidiertem Token. Greift auch tokenInvalidatedAt
// vom User/Customer ab → bei Rolle-Ändern oder Logout sind alle Tokens (auch
// das Refresh) sofort tot.
export async function refreshAccessToken(refreshToken: string): Promise<{
  accessToken: string;
  refreshToken: string;
  user: any;
}> {
  let decoded: any;
  try {
    decoded = jwt.verify(refreshToken, process.env.JWT_SECRET as string, {
      algorithms: ['HS256'],
    });
  } catch (e) {
    // Code erhalten, damit der Controller abgelaufen (benigne, LOW) von
    // manipuliert/ungültiger Signatur (verdächtig, HIGH) trennen kann.
    const err: any = new Error('Refresh-Token ungültig oder abgelaufen');
    err.code = e instanceof jwt.TokenExpiredError ? 'REFRESH_EXPIRED' : 'REFRESH_INVALID';
    throw err;
  }
  if (decoded.type !== 'refresh') {
    throw new Error('Falscher Token-Typ');
  }
  // Einmalverwendung durchsetzen und Sitzungsfamilie bestimmen (R164-02).
  // Wirft bei Replay – danach ist die gesamte Familie widerrufen.
  const rotation = await consumeRefreshJti(decoded);
  // Bei Kulanz wird derselbe, bereits ausgestellte Nachfolger erneut signiert -
  // ohne neuen Datensatz, damit keine zweite Linie entsteht (R168-01).
  const naechsterRefreshToken = async (
    payload: JwtPayload,
    subjekt: { userId?: number; customerId?: number; isCustomerPortal: boolean },
  ): Promise<string> =>
    rotation.reuseJti
      ? signRefreshToken(payload, rotation.reuseJti, rotation.familyId)
      : issueRefreshToken(payload, {
          ...subjekt,
          familyId: rotation.familyId,
          jti: rotation.issueJti,
        });
  const issuedAt = decoded.iat ? decoded.iat * 1000 : 0;

  // Mitarbeiter
  if (!decoded.isCustomerPortal && decoded.userId) {
    const user = await prisma.user.findUnique({
      where: { id: decoded.userId },
      include: {
        roles: { include: { role: { include: { permissions: { include: { permission: true } } } } } },
      },
    });
    if (!user || !user.isActive) throw new Error('Benutzer nicht aktiv');
    if (user.tokenInvalidatedAt && issuedAt < user.tokenInvalidatedAt.getTime()) {
      throw new Error('Refresh-Token wurde invalidiert (Logout/Rechteänderung)');
    }
    const permissions = new Set<string>();
    for (const ur of user.roles) {
      for (const rp of ur.role.permissions) {
        permissions.add(`${rp.permission.resource}:${rp.permission.action}`);
      }
    }
    const payload: JwtPayload = {
      userId: user.id,
      email: user.email,
      permissions: Array.from(permissions),
      customerId: user.customerId ?? undefined,
      isCustomerPortal: false,
    };
    return {
      accessToken: signAccessToken(payload),
      // Nachfolger bleibt in derselben Familie – ein Replay des Vorgaengers
      // sprengt damit auch alle daraus entstandenen Tokens.
      refreshToken: await naechsterRefreshToken(payload, { userId: user.id, isCustomerPortal: false }),
      user: {
        id: user.id,
        email: user.email,
        firstName: user.firstName,
        lastName: user.lastName,
        permissions: Array.from(permissions),
        customerId: user.customerId,
        isCustomerPortal: false,
      },
    };
  }

  // Customer-Portal
  if (decoded.isCustomerPortal && decoded.customerId) {
    const customer = await prisma.customer.findUnique({ where: { id: decoded.customerId } });
    if (!customer || !customer.portalEmail) throw new Error('Portal-Konto nicht gefunden');
    if (customer.portalTokenInvalidatedAt && issuedAt < customer.portalTokenInvalidatedAt.getTime()) {
      throw new Error('Refresh-Token wurde invalidiert');
    }
    const portalUser = await getCustomerPortalUser(customer.id);
    if (!portalUser) throw new Error('Portal-Konto nicht gefunden');
    const payload: JwtPayload = {
      email: customer.portalEmail,
      permissions: portalUser.permissions,
      customerId: customer.id,
      isCustomerPortal: true,
      representedCustomerIds: portalUser.representedCustomers?.map((c: any) => c.id),
    };
    return {
      accessToken: signAccessToken(payload),
      refreshToken: await naechsterRefreshToken(payload, {
        customerId: customer.id,
        isCustomerPortal: true,
      }),
      user: portalUser,
    };
  }

  throw new Error('Refresh-Token konnte nicht interpretiert werden');
}

// Kundenportal-Passwort setzen/ändern
export async function setCustomerPortalPassword(customerId: number, password: string) {
  console.log('[SetPortalPassword] Setze Passwort für Kunde:', customerId);

  const hashedPassword = await bcrypt.hash(password, BCRYPT_COST);
  const encryptedPassword = encrypt(password);

  console.log('[SetPortalPassword] Hash erstellt, Länge:', hashedPassword.length);

  // Manuelles Setzen ist KEIN Einmalpasswort → Flag immer zurücksetzen,
  // falls vorher ein OTP gesetzt war.
  await prisma.customer.update({
    where: { id: customerId },
    data: {
      portalPasswordHash: hashedPassword,
      portalPasswordEncrypted: encryptedPassword,
      portalPasswordMustChange: false,
    },
  });

  console.log('[SetPortalPassword] Passwort gespeichert');
}

// Vom Endkunden selbst gesetztes Initial-Passwort nach OTP-Login.
// Speichert neuen Hash, löscht das verbrauchte Encrypted-Feld (Klartext-
// Speicherung soll bei OFF self-service nicht zurückkommen) und invalidiert
// sofort alle bestehenden Sessions, damit Login mit dem neuen Passwort
// gefordert wird.
export async function changeInitialPortalPassword(customerId: number, newPassword: string) {
  const hashedPassword = await bcrypt.hash(newPassword, BCRYPT_COST);
  await prisma.customer.update({
    where: { id: customerId },
    data: {
      portalPasswordHash: hashedPassword,
      portalPasswordEncrypted: null,
      portalPasswordMustChange: false,
      portalTokenInvalidatedAt: new Date(),
    },
  });
}

export async function markPortalPasswordForChange(customerId: number) {
  await prisma.customer.update({
    where: { id: customerId },
    data: { portalPasswordMustChange: true },
  });
}

// Ergebnis des Klartext-Passwort-Abrufs.
//  - 'ok'     → password ist das gültige Klartext-Passwort
//  - 'none'   → kein verschlüsseltes Passwort hinterlegt (z.B. self-service
//               gesetzt) → nichts zum Anzeigen/Versenden
//  - 'desync' → verschlüsseltes Passwort vorhanden, passt aber NICHT zum
//               Login-Hash (oder Entschlüsselung fehlgeschlagen). Darf NIE
//               ausgegeben werden – sonst liest der Support ein Passwort vor,
//               das beim Login scheitert (Pentest-Hinweis).
export type PortalPasswordReveal =
  | { status: 'ok'; password: string }
  | { status: 'none' }
  | { status: 'desync' };

// Kundenportal-Passwort im Klartext abrufen – mit Konsistenzprüfung gegen den
// Login-Hash, damit niemals ein „falsches" (out-of-sync) Passwort herausgeht.
export async function getCustomerPortalPassword(customerId: number): Promise<PortalPasswordReveal> {
  const customer = await prisma.customer.findUnique({
    where: { id: customerId },
    select: { portalPasswordEncrypted: true, portalPasswordHash: true },
  });

  if (!customer?.portalPasswordEncrypted) {
    return { status: 'none' };
  }

  let decrypted: string;
  try {
    decrypted = decrypt(customer.portalPasswordEncrypted);
  } catch (error) {
    console.error('Fehler beim Entschlüsseln des Passworts:', error);
    return { status: 'desync' };
  }

  // Ohne Login-Hash ist ein Login gar nicht möglich – ein gespeichertes
  // Klartext-Passwort wäre irreführend (Support würde etwas vorlesen, das
  // nirgends funktioniert). Als inkonsistent behandeln (Pentest R143).
  if (!customer.portalPasswordHash) {
    console.warn(`[getCustomerPortalPassword] Desync: verschlüsseltes Passwort ohne Login-Hash (Kunde #${customerId})`);
    return { status: 'desync' };
  }

  // Muss zum Login-Hash passen – sonst ist der gespeicherte Klartext veraltet
  // und würde beim Login fehlschlagen.
  const matches = await bcrypt.compare(decrypted, customer.portalPasswordHash);
  if (!matches) {
    console.warn(`[getCustomerPortalPassword] Desync: gespeichertes Passwort passt nicht zum Login-Hash (Kunde #${customerId})`);
    return { status: 'desync' };
  }

  return { status: 'ok', password: decrypted };
}

export async function createUser(data: {
  email: string;
  password: string;
  firstName: string;
  lastName: string;
  roleIds: number[];
  customerId?: number;
}) {
  const hashedPassword = await bcrypt.hash(data.password, BCRYPT_COST);

  const user = await prisma.user.create({
    data: {
      email: data.email,
      password: hashedPassword,
      firstName: data.firstName,
      lastName: data.lastName,
      customerId: data.customerId,
      roles: {
        create: data.roleIds.map((roleId) => ({ roleId })),
      },
    },
    include: {
      roles: {
        include: {
          role: true,
        },
      },
    },
  });

  return {
    id: user.id,
    email: user.email,
    firstName: user.firstName,
    lastName: user.lastName,
    roles: user.roles.map((ur) => ur.role.name),
  };
}

export async function getUserById(id: number) {
  const user = await prisma.user.findUnique({
    where: { id },
    include: {
      roles: {
        include: {
          role: {
            include: {
              permissions: {
                include: {
                  permission: true,
                },
              },
            },
          },
        },
      },
    },
  });

  if (!user) return null;

  console.log('auth.getUserById - user roles:', user.roles.map(ur => ur.role.name));

  const permissions = new Set<string>();
  for (const userRole of user.roles) {
    for (const rolePerm of userRole.role.permissions) {
      permissions.add(
        `${rolePerm.permission.resource}:${rolePerm.permission.action}`
      );
    }
  }

  console.log('auth.getUserById - permissions:', Array.from(permissions));

  return {
    id: user.id,
    email: user.email,
    firstName: user.firstName,
    lastName: user.lastName,
    isActive: user.isActive,
    customerId: user.customerId,
    whatsappNumber: user.whatsappNumber,
    telegramUsername: user.telegramUsername,
    signalNumber: user.signalNumber,
    roles: user.roles.map((ur) => ur.role.name),
    permissions: Array.from(permissions),
    isCustomerPortal: false,
  };
}

// Kundenportal-Benutzer laden (für /me Endpoint)
export async function getCustomerPortalUser(customerId: number) {
  const customer = await prisma.customer.findUnique({
    where: { id: customerId },
    include: {
      representingFor: {
        where: { isActive: true },
        include: {
          customer: {
            select: {
              id: true,
              customerNumber: true,
              firstName: true,
              lastName: true,
              companyName: true,
              type: true,
            },
          },
        },
      },
    },
  });

  if (!customer || !customer.portalEnabled) return null;

  const customerPermissions = [
    'contracts:read',
    'customers:read',
  ];

  // Selbe Live-Vollmacht-Filterung wie in customerLogin (Pentest Runde 10):
  // ohne sie zeigt /me dem Vertreter weiterhin widerrufene Beziehungen.
  const grantedCustomerIds = new Set(await getAuthorizedCustomerIds(customer.id));
  const grantedRepresentingFor = customer.representingFor.filter((rep) =>
    grantedCustomerIds.has(rep.customer.id),
  );

  return {
    id: customer.id,
    email: customer.portalEmail,
    firstName: customer.firstName,
    lastName: customer.lastName,
    isActive: customer.portalEnabled,
    customerId: customer.id,
    permissions: customerPermissions,
    isCustomerPortal: true,
    representedCustomers: grantedRepresentingFor.map((rep) => ({
      id: rep.customer.id,
      customerNumber: rep.customer.customerNumber,
      firstName: rep.customer.firstName,
      lastName: rep.customer.lastName,
      companyName: rep.customer.companyName,
      type: rep.customer.type,
    })),
  };
}

// ==================== PASSWORT-RESET ====================

const RESET_TOKEN_EXPIRY_HOURS = 2;

function generateResetToken(): string {
  return crypto.randomBytes(32).toString('hex');
}

/**
 * Liefert die Basis-URL für an Kunden verschickte Links (Portal-Login,
 * Passwort-Reset). Reihenfolge:
 *   1. `portalLoginUrl` aus AppSettings (vom Admin in Settings → Kundenportal
 *      gepflegt). Wenn HTTPS-Domain hier eingetragen, wird die in Mails
 *      verwendet, nicht der Localhost-Default.
 *   2. `PUBLIC_URL`-Env (für Setups ohne Admin-UI-Konfiguration).
 *   3. Fallback `http://localhost:5173` (Dev-Default).
 * Hat Trailing-Slash-Bereinigung, sonst kommen Links wie
 * `https://crm.de//portal/login` zustande.
 */
// Pentest 59.4 Nebenbefund (2026-06-01): Consent-URL kam mit
// `localhost:5173` raus, weil PUBLIC_URL nicht gesetzt war und
// req.headers.origin im Hintergrund-Pfad nicht greift. Helper jetzt
// EXPORT, damit auch der GDPR-Controller (sendConsentLink etc.)
// dieselbe Quelle der Wahrheit nutzt – inklusive admin-konfigurierbarer
// portalLoginUrl App-Setting.
export async function getPublicUrl(): Promise<string> {
  const fromSettings = await appSettingService.getSetting('portalLoginUrl');
  const raw = (fromSettings && fromSettings.trim())
    || process.env.PUBLIC_URL
    || 'http://localhost:5173';
  return raw.replace(/\/+$/, '');
}

/**
 * Portal-Zugangsdaten per E-Mail an den Kunden versenden. Nur durch Admin-
 * UI ausgelöst – nie automatisch –, weil das Klartext-Passwort im Mail-
 * Body steht. Login-URL zeigt auf das `/portal/login`-Frontend-Route.
 */
export async function sendPortalCredentialsEmail(params: {
  to: string;
  customer: { firstName: string | null; lastName: string | null; salutation: string | null; companyName: string | null };
  loginEmail: string;
  password: string;
}): Promise<void> {
  const systemEmail = await getSystemEmailCredentials();
  if (!systemEmail) {
    throw new Error('Kein System-E-Mail-Konto konfiguriert (Einstellungen → E-Mail-Provider)');
  }

  const credentials: SmtpCredentials = {
    host: systemEmail.smtpServer,
    port: systemEmail.smtpPort,
    user: systemEmail.emailAddress,
    password: systemEmail.password,
    encryption: systemEmail.smtpEncryption,
    allowSelfSignedCerts: systemEmail.allowSelfSignedCerts,
  };

  const loginUrl = `${await getPublicUrl()}/portal/login`;
  const name = params.customer.companyName?.trim()
    || `${params.customer.firstName || ''} ${params.customer.lastName || ''}`.trim()
    || 'Kunde';

  // HTML-Escape – Customer-Namen können theoretisch Sonderzeichen enthalten,
  // die wir nicht ungefiltert in die Mail rendern wollen.
  const esc = (s: string) =>
    s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');

  const html = `
    <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
      <h2 style="color: #1e40af;">Ihre Zugangsdaten zum Kundenportal</h2>
      <p>Hallo ${esc(name)},</p>
      <p>anbei Ihre Zugangsdaten zum Kundenportal:</p>
      <table style="border-collapse: collapse; margin: 16px 0;">
        <tr><td style="padding: 6px 12px; color: #6b7280;">Login-URL:</td>
            <td style="padding: 6px 12px;"><a href="${loginUrl}">${esc(loginUrl)}</a></td></tr>
        <tr><td style="padding: 6px 12px; color: #6b7280;">E-Mail:</td>
            <td style="padding: 6px 12px; font-family: monospace;">${esc(params.loginEmail)}</td></tr>
        <tr><td style="padding: 6px 12px; color: #6b7280;">Passwort:</td>
            <td style="padding: 6px 12px; font-family: monospace;">${esc(params.password)}</td></tr>
      </table>
      <p style="color: #b91c1c; font-size: 14px; font-weight: 600;">
        ⚠️ Dieses Passwort ist ein <u>Einmalpasswort</u>.
      </p>
      <p style="color: #6b7280; font-size: 14px;">
        Beim ersten Login werden Sie aufgefordert, ein eigenes Passwort zu vergeben.
        Danach ist dieses Passwort hier <strong>nicht mehr gültig</strong> – falls Sie den
        Vorgang abbrechen, fordern Sie bitte neue Zugangsdaten an oder nutzen die
        Passwort-vergessen-Funktion.
      </p>
      <hr style="border: none; border-top: 1px solid #e5e7eb; margin: 24px 0;">
      <p style="color: #9ca3af; font-size: 12px;">
        Diese Nachricht enthält sensible Zugangsdaten – bitte sicher verwahren oder nach
        dem Login löschen.
      </p>
    </div>
  `;

  await sendEmail(
    credentials,
    systemEmail.emailAddress,
    {
      to: params.to,
      subject: 'Ihre Zugangsdaten zum Kundenportal',
      html,
    },
    {
      context: 'portal-credentials',
      triggeredBy: 'admin-action',
    },
  );
}

/**
 * Passwort-Reset-Link per Email senden.
 * Findet User/Customer per Email. Wirft keinen Error wenn nicht gefunden
 * (Schutz vor User-Enumeration – Caller gibt immer success zurück).
 */
export async function requestPasswordReset(email: string, userType: 'admin' | 'portal'): Promise<void> {
  const token = generateResetToken();
  const expiresAt = new Date(Date.now() + RESET_TOKEN_EXPIRY_HOURS * 60 * 60 * 1000);

  let recipient: { email: string; firstName: string; lastName: string } | null = null;

  if (userType === 'admin') {
    const user = await prisma.user.findUnique({ where: { email } });
    if (!user || !user.isActive) return;

    await prisma.user.update({
      where: { id: user.id },
      data: {
        passwordResetToken: token,
        passwordResetExpiresAt: expiresAt,
      },
    });
    recipient = { email: user.email, firstName: user.firstName, lastName: user.lastName };
  } else {
    const customer = await prisma.customer.findUnique({ where: { portalEmail: email } });
    if (!customer || !customer.portalEnabled) return;

    await prisma.customer.update({
      where: { id: customer.id },
      data: {
        portalPasswordResetToken: token,
        portalPasswordResetExpiresAt: expiresAt,
      },
    });
    recipient = {
      email: customer.portalEmail!,
      firstName: customer.firstName,
      lastName: customer.lastName,
    };
  }

  if (!recipient) return;

  // Reset-Link + Email senden
  const resetUrl = `${await getPublicUrl()}/password-reset?token=${token}&type=${userType}`;
  const systemEmail = await getSystemEmailCredentials();

  if (!systemEmail) {
    console.warn(
      `[passwordReset] Kein System-E-Mail konfiguriert – Reset-Link für ${recipient.email}: ${resetUrl}`,
    );
    return;
  }

  const credentials: SmtpCredentials = {
    host: systemEmail.smtpServer,
    port: systemEmail.smtpPort,
    user: systemEmail.emailAddress,
    password: systemEmail.password,
    encryption: systemEmail.smtpEncryption,
    allowSelfSignedCerts: systemEmail.allowSelfSignedCerts,
  };

  const html = `
    <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
      <h2 style="color: #1e40af;">Passwort zurücksetzen</h2>
      <p>Hallo ${recipient.firstName} ${recipient.lastName},</p>
      <p>
        Sie haben angefordert, Ihr Passwort zurückzusetzen. Klicken Sie auf den folgenden
        Button, um ein neues Passwort zu vergeben. Der Link ist ${RESET_TOKEN_EXPIRY_HOURS} Stunden gültig.
      </p>
      <p style="text-align: center; margin: 32px 0;">
        <a href="${resetUrl}" style="background-color: #2563eb; color: #ffffff; padding: 14px 32px; text-decoration: none; border-radius: 8px; font-weight: bold; font-size: 16px; display: inline-block;">
          Neues Passwort vergeben
        </a>
      </p>
      <p style="color: #6b7280; font-size: 14px;">
        Alternativ können Sie diesen Link in Ihren Browser kopieren:<br>
        <a href="${resetUrl}" style="color: #2563eb; word-break: break-all;">${resetUrl}</a>
      </p>
      <hr style="border: none; border-top: 1px solid #e5e7eb; margin: 24px 0;">
      <p style="color: #9ca3af; font-size: 12px;">
        Haben Sie diesen Reset nicht angefordert? Dann ignorieren Sie diese E-Mail einfach –
        Ihr Passwort bleibt unverändert.
      </p>
    </div>
  `;

  await sendEmail(
    credentials,
    systemEmail.emailAddress,
    {
      to: recipient.email,
      subject: 'Passwort zurücksetzen',
      html,
    },
    {
      context: 'password-reset',
      triggeredBy: 'self-service',
    },
  );
}

/**
 * Stellt fest, ob ein Reset-Token zu einem Mitarbeiter (admin) oder einem
 * Portal-Customer (portal) gehört. Wird vom Controller benötigt, um den
 * passenden Komplexitäts-Schwellwert (25 bzw. 12 Zeichen) anzuwenden,
 * BEVOR das Passwort tatsächlich gesetzt wird. Pentest Runde 13.
 */
export async function getPasswordResetAudience(token: string): Promise<'admin' | 'portal' | null> {
  const user = await prisma.user.findUnique({
    where: { passwordResetToken: token },
    select: { id: true },
  });
  if (user) return 'admin';
  const customer = await prisma.customer.findUnique({
    where: { portalPasswordResetToken: token },
    select: { id: true },
  });
  if (customer) return 'portal';
  return null;
}

/**
 * Passwort-Reset bestätigen: Token prüfen, Passwort setzen, Token löschen.
 * Invalidiert alle bestehenden JWT-Sessions des Users.
 */
export async function confirmPasswordReset(token: string, newPassword: string): Promise<void> {
  // Erst beim User suchen
  const user = await prisma.user.findUnique({ where: { passwordResetToken: token } });

  if (user) {
    if (!user.passwordResetExpiresAt || user.passwordResetExpiresAt < new Date()) {
      throw new Error('Der Link ist abgelaufen. Bitte fordere einen neuen an.');
    }

    const hash = await bcrypt.hash(newPassword, BCRYPT_COST);
    await prisma.user.update({
      where: { id: user.id },
      data: {
        password: hash,
        passwordResetToken: null,
        passwordResetExpiresAt: null,
        // Alle bestehenden Sessions kicken
        tokenInvalidatedAt: new Date(),
      },
    });
    return;
  }

  // Sonst beim Customer (Portal)
  const customer = await prisma.customer.findUnique({ where: { portalPasswordResetToken: token } });

  if (customer) {
    if (!customer.portalPasswordResetExpiresAt || customer.portalPasswordResetExpiresAt < new Date()) {
      throw new Error('Der Link ist abgelaufen. Bitte fordere einen neuen an.');
    }

    const hash = await bcrypt.hash(newPassword, BCRYPT_COST);
    await prisma.customer.update({
      where: { id: customer.id },
      data: {
        portalPasswordHash: hash,
        // Pentest Runde 6 (MITTEL-01): Beim Self-Service-Reset speichern wir
        // KEINEN Klartext mehr. Encrypted-Feld ist nur für Admin-generierte
        // Einmalpasswörter sinnvoll (damit Admin sie in der UI sehen + per
        // Mail versenden kann); für ein vom Kunden selbst gesetztes Passwort
        // ist Klartext-Speicherung ein unnötiges Recover-Risiko bei DB+Key-Leak.
        portalPasswordEncrypted: null,
        portalPasswordResetToken: null,
        portalPasswordResetExpiresAt: null,
        // Alle bestehenden Portal-Sessions kicken
        portalTokenInvalidatedAt: new Date(),
        // OTP-Flow-Flag ist nach selbstgesetztem Passwort definitiv aus
        portalPasswordMustChange: false,
      },
    });
    return;
  }

  throw new Error('Ungültiger oder bereits verwendeter Link.');
}
