import OpenAI from 'openai';
import { z } from 'zod';
import { jsonrepair } from 'jsonrepair';
import { renderMjml, type MjmlRenderResult } from '@/modules/editor/lib/render-mjml';
import { runPreflight, type SpamReport } from '@/modules/spam-check/lib/preflight';
import { generateHeroImage, type HeroImageResult } from '@/modules/briefing/lib/hero-image';
import { getSystemPromptGenerate } from '@/lib/db/repositories';
import { DEFAULT_SYSTEM_PROMPT_GENERATE } from '@/lib/db/defaults';
import { injectExtraSections, type ResolvedSection } from '@/modules/editor/lib/inject-sections';
import { renderFooterSection, detectLanguage as detectStringLang } from '@/modules/editor/lib/render-template';
import { getOpenRouterKey, getOpenRouterKeyOptional } from '@/core/settings';

// Expliziter Pin auf Sonnet 4.6 (1M Context, 64k Output, $3/$15 in/out per M-Token).
// Vorher '~anthropic/claude-sonnet-latest' — der Tilde-Pointer routet bei neuen Releases automatisch um,
// was Tool-Use-Verhalten ändern kann. Direkter Pin = deterministisch + reproduzierbar.
const DEFAULT_MODEL = 'anthropic/claude-sonnet-4.6';
// 32k Output-Budget — halbes Output-Limit von Sonnet 4.6 (64k). Tool-Call enthält strategy +
// komplettes MJML in einem Roundtrip, MJML allein kann 3-4k Tokens belegen. 8k war zu knapp
// → "Unterminated string"-Fehler. 32k gibt 4× Headroom mit überschaubarem Worst-Case-Cost
// (~$0.48 pro fully-saturated Generation = praktisch nie ausgeschöpft).
const MAX_TOKENS = 32000;

// ---- Output-Schemas ---------------------------------------------------------

// Schema lenient gehalten — moderne Mail-Clients haben keine harten Limits.
// Sonnet kennt Marketing-Best-Practices via System-Prompt (50 chars Subject, 80-150 Preheader),
// aber wir verlassen uns nicht auf strikte Schema-Caps.
const StrategySchema = z.object({
  subject_variants: z.array(z.string().min(5)).min(1).max(5),
  preheader: z.string().min(20),
  hero_headline: z.string().min(8),
  intro: z.string(),
  pain_point: z.string(),
  solution: z.string(),
  benefits: z.array(z.string()).min(2).max(8),
  cta_primary_text: z.string(),
  // .nullish() statt .optional() — Sonnet liefert für nicht-gesetzte optionale Felder
  // via Tool-Call manchmal `null` statt sie wegzulassen. .optional() akzeptiert nur
  // `undefined`, .nullish() = `.nullable().optional()` akzeptiert `string | null | undefined`.
  // Konsumenten (StrategyEdit-Panel in page.tsx, MJML-Builder unten) nutzen Truthy-Checks
  // bzw. `?? ''` und sind damit gegen beide Falsy-Varianten robust.
  cta_secondary_text: z.string().nullish(),
  ps_line: z.string(),
  image_brief: z.string().describe('Brief für fal.ai gpt-image — Lifestyle, kein Produktshot, kein On-Image-Text'),
  tone_notes: z.string().nullish(),
});

const PipelineOutputSchema = z.object({
  strategy: StrategySchema,
  mjml: z.string().min(50).describe('Vollständiger MJML-Source, beginnend mit <mjml>'),
});

export type Strategy = z.infer<typeof StrategySchema>;
export type PipelineOutput = z.infer<typeof PipelineOutputSchema>;

// System-Prompt wird zur Laufzeit aus app_settings.system_prompt_generate geladen
// (Fallback: DEFAULT_SYSTEM_PROMPT_GENERATE in src/lib/db/defaults.ts).
// Siehe Verwendung in callOpenRouter() unten.

// ---- Tool-Definition (OpenAI-Format für OpenRouter) -----------------------

const PRODUCE_TOOL: OpenAI.Chat.Completions.ChatCompletionTool = {
  type: 'function',
  function: {
    name: 'produce_email',
    description: 'Liefert die finale Marketing-Email als Strategy + MJML-Source.',
    parameters: {
      type: 'object',
      required: ['strategy', 'mjml'],
      properties: {
        strategy: {
          type: 'object',
          required: ['subject_variants', 'preheader', 'hero_headline', 'intro', 'pain_point', 'solution', 'benefits', 'cta_primary_text', 'ps_line', 'image_brief'],
          properties: {
            subject_variants: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 3, description: '1-3 Subject-Zeilen ≤ 50 Zeichen, unterschiedliche Pattern' },
            preheader: { type: 'string', description: '80-110 Zeichen' },
            hero_headline: { type: 'string' },
            intro: { type: 'string' },
            pain_point: { type: 'string' },
            solution: { type: 'string' },
            benefits: { type: 'array', items: { type: 'string' }, minItems: 2, maxItems: 5 },
            cta_primary_text: { type: 'string' },
            cta_secondary_text: { type: 'string' },
            ps_line: { type: 'string' },
            image_brief: { type: 'string' },
            tone_notes: { type: 'string' },
          },
        },
        mjml: { type: 'string', description: 'Kompletter MJML-Source ab <mjml>' },
      },
    },
  },
};

// ---- Pipeline-Input ---------------------------------------------------------

export interface PipelineInput {
  mode: 'preserve' | 'rewrite';
  language: 'auto' | 'de' | 'en';
  campaignName: string;
  /**
   * Optional. Im Preserve-Mode darf leer sein — der LLM extrahiert Hero-Headline,
   * Subject und image_brief stattdessen aus briefHtml. Im Rewrite-Mode erzwingt
   * die API-Layer-Cross-Field-Validation, dass dieses Feld gesetzt ist.
   */
  offer?: string;
  /** Optional analog zu offer — siehe dort. */
  painPoint?: string;
  ctaPrimaryText: string;
  /** Optional — falls leer, rendert der Button mit href="#" als Platzhalter. */
  ctaPrimaryUrl?: string;
  ctaSecondaryText?: string;
  ctaSecondaryUrl?: string;
  workspaceSlug: string;
  brandName: string;
  brandPrimaryColor: string;
  brandSenderName: string;
  brandFooterText: string;
  /**
   * Optional: Custom-MJML-Footer. Wenn gesetzt (nicht-leer), wird der Standard-Footer
   * im finalen MJML (sowohl Stub- als auch LLM-Pfad) durch diesen Block ersetzt.
   * System-Tags ({{BROWSER_URL}}, {{UNSUBSCRIBE_URL}}, {{IMPRINT_URL}}, {{PRIVACY_URL}})
   * werden weiterhin vom Worker zur Send-Zeit ersetzt — User muss sie selbst im Block
   * positionieren.
   */
  brandFooterMjml?: string;
  /**
   * Optional: Anwaltsgeprüfter Legal-Pflicht-Block (DE). Wenn gesetzt und `brandFooterMjml` leer,
   * wird der Standard-LLM-Footer nach dem Generate-Schritt durch einen structured Block ersetzt
   * (analog zu Custom-MJML, aber aus den Workspace-Footer-Feldern aufgebaut).
   */
  brandFooterLegalText?: string;
  /** Optional: EN-Reserve des Legal-Blocks. Wird genutzt wenn detektierte Mail-Sprache 'en' ist. */
  brandFooterLegalTextEn?: string;
  /** Optional: Confidentiality-/Haftungs-Block (DE) — kursiv unter dem Legal-Block. */
  brandFooterConfidentialityNotice?: string;
  /** Optional: EN-Reserve des Confidentiality-Blocks. */
  brandFooterConfidentialityNoticeEn?: string;
  /** Default true. Wenn explizit false → "Abmelden"-Link wird im Footer weggelassen. */
  brandFooterShowUnsubscribe?: boolean;
  /**
   * Standard-Signatur (DE) aus dem Workspace-Kit. Wird unter der P.S.-Zeile gerendert.
   * Ersetzt das frühere editierbare `strategy.signature`-Feld. Wenn leer/undefined →
   * Hardcode-Fallback im Renderer ("Beste Grüße,\nIhr Team").
   */
  brandDefaultSignature?: string;
  /** EN-Reserve der Standard-Signatur. */
  brandDefaultSignatureEn?: string;
  /**
   * Optional: Logo-URL aus den Workspace-Einstellungen (DB-Feld `logo_url`).
   * Wenn gesetzt → wird in `applyLogoUrl()` für `{{LOGO_URL}}` im finalen MJML eingesetzt.
   * Wenn leer/undefined → Logo-Section wird komplett aus der Mail entfernt
   *   (kein Broken-Image-Fallback, konsistent zu `heroEnabled=false`-Verhalten).
   */
  brandLogoUrl?: string;
  brandVoice?: string;          // z.B. "B2B sachlich, Sie-Form"
  brandWordingStyle?: string;   // z.B. "konkrete Zahlen, keine Superlative"
  brandDesignStyle?: string;    // z.B. "minimalist, viel Whitespace"
  briefHtml?: string;
  heroEnabled: boolean;
  heroBriefHint?: string;
  /** Optional: max. Hero-Höhe in px → MJML rendert mit object-fit:cover-Crop. */
  heroHeight?: number;
  /** Optional: Crop-Position für object-position. Default 'center' wenn nicht gesetzt. */
  heroCropPosition?: 'top' | 'center' | 'bottom';
  /**
   * Optional: bereits-existente Hero-Bild-URL (z.B. aus Webseiten-Scrape oder Upload).
   * Wenn gesetzt → fal.ai-Generierung wird übersprungen, MJML nutzt direkt diese URL.
   */
  heroImageUrl?: string;
  /**
   * Optional: zusätzliche modulare Sections (Kontakt, App, Testimonial, FAQ etc.) die
   * NACH der LLM-Generation deterministisch ins MJML eingespliced werden. Die KI sieht
   * diese Sections nicht — sie generiert nur Hero+Body, der `injectExtraSections`-Helper
   * fügt die User-Sections an den richtigen Anchor-Punkten ein.
   */
  extraSections?: ResolvedSection[];
}

export interface PipelineResult {
  strategy: Strategy;
  mjml: string;
  rendered: MjmlRenderResult;
  spam: SpamReport;
  hero?: HeroImageResult;
  modelUsed: string;
  tokensIn?: number;
  tokensOut?: number;
  durationMs: number;
}

// ---- Hauptfunktion ----------------------------------------------------------

export async function generateEmail(input: PipelineInput, opts?: { baseUrl?: string }): Promise<PipelineResult> {
  const start = Date.now();
  const useStub = !getOpenRouterKeyOptional();
  const { strategy, mjml: rawMjml, modelUsed, tokensIn, tokensOut } = useStub
    ? produceStubResponse(input)
    : await callOpenRouter(input);

  // Hero-Bild: Pre-Existing-URL (Scrape/Upload) hat Vorrang vor fal.ai-Generierung
  let hero: HeroImageResult | undefined;
  let mjml = rawMjml;
  if (input.heroEnabled) {
    if (input.heroImageUrl) {
      // User hat ein Bild aus Webseite übernommen oder selbst hochgeladen → fal.ai überspringen.
      // Wir setzen ein synthetisches HeroImageResult zusammen damit das Caller-API stable bleibt.
      hero = {
        url: input.heroImageUrl,
        absoluteUrl: input.heroImageUrl,
        localPath: '',
        promptHash: 'preset',
        bytes: 0,
        cached: true,
        durationMs: 0,
        prompt: '[preset hero from upload/scrape]',
      };
      mjml = mjml.replace(/\{\{HERO_URL\}\}/g, input.heroImageUrl);
    } else {
      try {
        hero = await generateHeroImage(
          {
            imageBrief: strategy.image_brief,
            brandPrimaryColor: input.brandPrimaryColor,
            brandName: input.brandName,
            aspectRatio: '3:2',
          },
          opts?.baseUrl,
        );
        mjml = mjml.replace(/\{\{HERO_URL\}\}/g, hero.absoluteUrl);
      } catch (e) {
        console.warn('[pipeline] hero-image failed:', e instanceof Error ? e.message : e);
        // Mail wird trotzdem ausgeliefert — ohne Hero-Bild oder mit Placeholder
      }
    }
  }

  // Logo-URL-Post-Processing: sowohl Stub-Pfad als auch OpenRouter-Pfad emittieren `{{LOGO_URL}}`
  // als Platzhalter (System-Prompt + buildStubMjml). Hier zentral auflösen — analog zum HERO_URL-Replace.
  // Wenn keine Logo-URL gesetzt ist, wird die komplette Logo-Section aus der Mail entfernt
  // (kein Broken-Image-Fallback, konsistent zu heroEnabled=false-Verhalten).
  mjml = applyLogoUrl(mjml, input.brandLogoUrl);

  // Footer-Post-Processing: drei Vorrang-Stufen.
  //  1. Custom-MJML (User-Override aus Workspace-Settings) → 1:1 ersetzen.
  //  2. Anwaltsblock (legalFooterText / confidentialityNotice / showUnsubscribe-Flag aus dem
  //     BrandKit) → wir bauen via `renderFooterSection()` einen structured-Footer und ersetzen
  //     den LLM-Footer damit. Mail-Sprache wird aus input.language abgeleitet (auto-Mode
  //     detected aus offer+painPoint).
  //  3. Keins von beiden → no-op, LLM-/Stub-Footer bleibt stehen.
  // System-Tags ({{BROWSER_URL}}, {{UNSUBSCRIBE_URL}} etc.) bleiben unangetastet — der Worker
  // ersetzt sie zur Send-Zeit. Bei showUnsubscribe=false ist der UNSUBSCRIBE_URL-Tag gar nicht
  // erst im Output, das ist die korrekte Pfad-Variante für Info-Workspaces.
  if (input.brandFooterMjml?.trim()) {
    mjml = applyFooterMjml(mjml, input.brandFooterMjml);
  } else if (hasStructuredFooterFields(input)) {
    // Im preserve-Mode können offer/painPoint leer sein — Fallback auf briefHtml für die
    // Sprach-Detection, damit der Footer in der richtigen Sprache rendert.
    const langSample = `${input.offer ?? ''} ${input.painPoint ?? ''} ${input.briefHtml ?? ''}`.trim();
    const lang: 'de' | 'en' = input.language === 'en'
      ? 'en'
      : input.language === 'de'
        ? 'de'
        : detectStringLang(langSample || input.campaignName);
    const structured = renderFooterSection({
      name: input.brandName,
      primaryColor: input.brandPrimaryColor,
      footerText: input.brandFooterText,
      footerLegalText: input.brandFooterLegalText,
      footerLegalTextEn: input.brandFooterLegalTextEn,
      footerConfidentialityNotice: input.brandFooterConfidentialityNotice,
      footerConfidentialityNoticeEn: input.brandFooterConfidentialityNoticeEn,
      footerShowUnsubscribe: input.brandFooterShowUnsubscribe,
    }, lang);
    mjml = applyFooterMjml(mjml, structured);
  }

  // {{BRAND_NAME}}-Platzhalter im LLM-generierten MJML auflösen.
  // Der System-Prompt deklariert {{BRAND_NAME}} als gültigen Token (siehe Zeile ~91),
  // Sonnet nutzt ihn im Footer-Disclaimer. Analog zu applyLogoUrl: Build-Zeit-Replace,
  // damit Preview und HTML-Snapshot identisch zum versendeten Mail-Body sind.
  mjml = mjml.replace(/\{\{BRAND_NAME\}\}/g, input.brandName);

  // Hero-Height-Post-Processing: bei OPENROUTER-Pfad weiß die KI nichts von unserer heroHeight,
  // im Stub-Pfad ist's bereits eingebaut. Daher hier idempotent nachträglich injecten.
  if (input.heroEnabled && input.heroHeight) {
    mjml = applyHeroHeightToMjml(mjml, input.heroHeight, input.heroCropPosition);
  }

  // Extra-Sections (Kontakt, App-Download, Testimonial, FAQ, …) deterministisch nach
  // der LLM-Generation einsplicen. Die KI hat diese Sections nicht generiert — wir
  // injizieren sie an den configured Anchor-Punkten direkt im MJML-Source.
  if (input.extraSections?.length) {
    mjml = injectExtraSections(mjml, input.extraSections, {
      brandPrimaryColor: input.brandPrimaryColor,
      brandName: input.brandName,
    });
  }

  // CTA-URL-Platzhalter ({{CTA_PRIMARY_URL}}, {{CTA_SECONDARY_URL}}) NACH dem Section-Inject
  // auflösen — so können auch User-importierte Sections (mjml_raw / html_raw) das Token
  // nutzen. System-Prompt + Stub-Pfad emittieren beide Tokens, der Worker (Send-Pfad) kennt
  // sie aber nicht in SYSTEM_TAGS — also müssen wir sie hier zur Build-Zeit ersetzen, sonst
  // landet ein unklickbarer href="{{CTA_PRIMARY_URL}}" in der finalen Mail.
  //
  // Verhalten an render-template.ts angeglichen: Leere URL → kompletter <mj-button>-Block
  // wird gestrippt statt einen kaputten href="#"-Button zu rendern. Bewahrt Konsistenz
  // zwischen AI-Generate und Re-Render und vermeidet tote Buttons in der finalen Mail.
  const primaryUrl = input.ctaPrimaryUrl?.trim();
  if (primaryUrl) {
    mjml = mjml.replace(/\{\{CTA_PRIMARY_URL\}\}/g, primaryUrl);
  } else {
    mjml = mjml.replace(
      /<mj-button[^>]*href=["']\{\{CTA_PRIMARY_URL\}\}["'][^>]*>[\s\S]*?<\/mj-button>/gi,
      '',
    );
    mjml = mjml.replace(/\{\{CTA_PRIMARY_URL\}\}/g, '#');
  }
  const secondaryUrl = input.ctaSecondaryUrl?.trim();
  if (secondaryUrl) {
    mjml = mjml.replace(/\{\{CTA_SECONDARY_URL\}\}/g, secondaryUrl);
  } else {
    mjml = mjml.replace(
      /<mj-button[^>]*href=["']\{\{CTA_SECONDARY_URL\}\}["'][^>]*>[\s\S]*?<\/mj-button>/gi,
      '',
    );
    mjml = mjml.replace(
      /<a[^>]*href=["']\{\{CTA_SECONDARY_URL\}\}["'][^>]*>[\s\S]*?<\/a>/gi,
      '',
    );
    mjml = mjml.replace(/\{\{CTA_SECONDARY_URL\}\}/g, '#');
  }

  const rendered = await renderMjml(mjml);

  const subjectForCheck = strategy.subject_variants[0];
  const spam = runPreflight({
    html: rendered.html,
    text: rendered.text,
    subject: subjectForCheck,
    preheader: strategy.preheader,
    hasListUnsubscribeHeader: true,
    brandTerms: [input.brandName.toUpperCase()],
  });

  return {
    strategy,
    mjml,
    rendered,
    spam,
    hero,
    modelUsed,
    tokensIn,
    tokensOut,
    durationMs: Date.now() - start,
  };
}

// ---- OpenRouter-Call (via OpenAI SDK) --------------------------------------

async function callOpenRouter(input: PipelineInput) {
  const model = process.env.OPENROUTER_MODEL ?? DEFAULT_MODEL;
  const client = new OpenAI({
    baseURL: 'https://openrouter.ai/api/v1',
    apiKey: getOpenRouterKey(),
    defaultHeaders: {
      'HTTP-Referer': process.env.OPENROUTER_APP_URL ?? 'http://localhost:3000',
      'X-Title': process.env.OPENROUTER_APP_TITLE ?? 'MailForge',
    },
  });

  const completion = await client.chat.completions.create({
    model,
    max_tokens: MAX_TOKENS,
    messages: [
      { role: 'system', content: getSystemPromptGenerate() || DEFAULT_SYSTEM_PROMPT_GENERATE },
      { role: 'user', content: buildUserBrief(input) },
    ],
    tools: [PRODUCE_TOOL],
    tool_choice: { type: 'function', function: { name: 'produce_email' } },
  });

  const choice = completion.choices[0];
  const toolCall = choice?.message?.tool_calls?.[0];
  if (!toolCall || toolCall.type !== 'function') {
    throw new Error(`Model ${model} did not call produce_email. finish_reason=${choice?.finish_reason}`);
  }

  // Explicit Truncation-Check — Wenn finish_reason=length, ist der Tool-Call abgeschnitten.
  // Ohne diesen Guard endet das in einem irreführenden "Unterminated string"-JSON-Parse-Fehler.
  if (choice.finish_reason === 'length') {
    const got = completion.usage?.completion_tokens ?? '?';
    throw new Error(
      `Model-Output abgeschnitten (max_tokens=${MAX_TOKENS} erreicht, ${got} verbraucht). ` +
        `Bitte Briefing kürzen oder MAX_TOKENS in pipeline.ts erhöhen.`,
    );
  }

  const rawArgs = toolCall.function.arguments;
  let args: Record<string, unknown>;
  try {
    args = JSON.parse(rawArgs) as Record<string, unknown>;
  } catch (e) {
    console.error('[pipeline] outer JSON.parse failed. Raw args length:', rawArgs.length, 'first 300 chars:', JSON.stringify(rawArgs.slice(0, 300)), 'last 300:', JSON.stringify(rawArgs.slice(-300)));
    throw new Error(`Model returned invalid JSON in tool args: ${e instanceof Error ? e.message : 'parse error'}`);
  }

  // Diagnostic logging
  console.log('[pipeline] tool args keys:', Object.keys(args),
    '| strategy type:', typeof args.strategy,
    '| mjml type:', typeof args.mjml,
    '| raw size:', rawArgs.length);

  // Defensive Layer 1-3: nested JSON-String → Object (manche Modelle stringifyen das verschachtelte Object)
  if (typeof args.strategy === 'string') {
    const strategyStr = args.strategy as string;
    console.log('[pipeline] strategy is string, length:', strategyStr.length);
    args.strategy = parseStrictThenRepair(strategyStr, 'strategy');
  }
  // mjml könnte auch wrapped sein (selten)
  if (typeof args.mjml === 'object' && args.mjml !== null) {
    args.mjml = JSON.stringify(args.mjml);
  }

  const parsed = PipelineOutputSchema.safeParse(args);
  if (!parsed.success) {
    const issues = parsed.error.issues.slice(0, 3).map((i) => `${i.path.join('.')}: ${i.message}`).join('; ');
    throw new Error(`Schema validation failed: ${issues}`);
  }

  return {
    strategy: parsed.data.strategy,
    mjml: parsed.data.mjml,
    modelUsed: model,
    tokensIn: completion.usage?.prompt_tokens,
    tokensOut: completion.usage?.completion_tokens,
  };
}

function buildUserBrief(i: PipelineInput): string {
  const langHint =
    i.language === 'de' ? 'Sprache: de (Deutsch — antworte auf Deutsch in Sie-Form)' :
    i.language === 'en' ? 'Sprache: en (Englisch — antworte auf Englisch)' :
    'Sprache: auto (erkenne aus Brief-Inhalt und antworte in derselben Sprache)';
  const hasOffer = !!i.offer?.trim();
  const hasPainPoint = !!i.painPoint?.trim();
  const modeHint =
    i.mode === 'preserve'
      ? `Modus: PRESERVE — Übernimm Body-Text aus briefHtml WORTWÖRTLICH in mj-text-Blöcke. Korrigiere nur Layout (Bullets, Absätze, Headings). Ergänze NUR Anrede, P.S., Footer. Subject/Preheader/Hero-Headline/image_brief generierst du selbst aus briefHtml.${(!hasOffer || !hasPainPoint) ? ' Hinweis: Angebot und/oder Schmerzpunkt sind im Briefing leer — extrahiere sie selbst aus briefHtml für Subject/Preheader und image_brief.' : ''}`
      : 'Modus: REWRITE — Formuliere Body-Inhalte komplett selbst aus dem Briefing.';
  return [
    `# Mode & Sprache`,
    modeHint,
    langHint,
    ``,
    `# Kampagne`,
    `Name: ${i.campaignName}`,
    ``,
    hasOffer ? `# Angebot\n${i.offer}\n` : '',
    hasPainPoint ? `# Schmerzpunkt der Zielgruppe\n${i.painPoint}\n` : '',
    `# Workspace`,
    `Name: ${i.brandName}`,
    `Slug: ${i.workspaceSlug}`,
    `Primary-Color: ${i.brandPrimaryColor}`,
    `Absender: ${i.brandSenderName}`,
    `Footer: ${i.brandFooterText}`,
    i.brandVoice ? `Workspace-Voice: ${i.brandVoice}` : '',
    i.brandWordingStyle ? `Wording-Style: ${i.brandWordingStyle}` : '',
    i.brandDesignStyle ? `Design-Hinweise (für image_brief): ${i.brandDesignStyle}` : '',
    ``,
    `# CTAs`,
    `Primär: "${i.ctaPrimaryText}" → ${i.ctaPrimaryUrl || '(URL noch nicht eingetragen — Button trotzdem rendern, href Platzhalter ist OK)'}`,
    i.ctaSecondaryText ? `Sekundär: "${i.ctaSecondaryText}" → ${i.ctaSecondaryUrl ?? '(URL fehlt)'}` : '',
    i.briefHtml ? `\n# Zusätzlicher User-Brief (Tiptap)\n${i.briefHtml}` : '',
    i.heroEnabled
      ? `\n# Hero-Bild\nGeneriere image_brief für fal.ai gpt-image. ${i.heroBriefHint ? `User-Hinweis: ${i.heroBriefHint}` : 'Wähle Lifestyle-Szene die den Nutzen visualisiert.'}`
      : `\n# Hero-Bild\nDeaktiviert — keine mj-image im Hero verwenden, stattdessen text-starker Header.`,
    i.extraSections && i.extraSections.length > 0
      ? `\n# Hinweis zu Zusatz-Sections\nDiese Mail wird ${i.extraSections.length} zusätzliche Section(s) enthalten (z.B. Kontakt, Testimonial, FAQ), die NACH deiner Generation eingefügt werden. Schreibe den Body so, dass er ohne diese Zusatzbausteine vollständig ist — duplicate keinen Inhalt der dort schon kommt (z.B. keinen Service-Hinweis im Body, wenn eine Kontakt-Section folgt).`
      : '',
    ``,
    `Erzeuge jetzt Strategy + MJML via produce_email-Tool. Nutze {{HERO_URL}}, {{CTA_PRIMARY_URL}}, {{FIRST_NAME}}, {{UNSUBSCRIBE_URL}} usw. als Platzhalter.`,
  ]
    .filter(Boolean)
    .join('\n');
}

// ---- Stub-Response (wenn kein OPENROUTER_API_KEY) --------------------------

function produceStubResponse(i: PipelineInput): {
  strategy: Strategy;
  mjml: string;
  modelUsed: string;
  tokensIn?: number;
  tokensOut?: number;
} {
  // Im Preserve-Mode können offer/painPoint leer sein — defensiv defaulten damit
  // .slice()/.toLowerCase() nicht crashen. Echter Content kommt aus briefHtml in dem Modus.
  const safeOffer = i.offer?.trim() || i.campaignName;
  const safePainPoint = i.painPoint?.trim() || 'Ihr aktuelles Anliegen';
  const strategy: Strategy = {
    subject_variants: [
      `${i.campaignName.slice(0, 35)} – Ihr nächster Schritt`,
      `In 3 Schritten: ${safePainPoint.slice(0, 28)}…`,
    ],
    preheader: `${safeOffer.slice(0, 90)}${safeOffer.length > 90 ? '…' : ''}`,
    hero_headline: `So lösen Sie ${safePainPoint.slice(0, 40)}`,
    intro: `Hallo {{FIRST_NAME}}, kennen Sie das Gefühl, wenn ${safePainPoint.toLowerCase()}?`,
    pain_point: safePainPoint,
    solution: `Wir haben für unsere Kunden eine Lösung entwickelt, die genau hier ansetzt: ${safeOffer}`,
    benefits: ['Zeitersparnis im Alltag', 'Klare Übersicht über Ihre Daten', 'Keine versteckten Kosten'],
    cta_primary_text: i.ctaPrimaryText,
    cta_secondary_text: i.ctaSecondaryText,
    ps_line: 'P.S. Antworten Sie einfach auf diese Mail bei Fragen — wir lesen mit.',
    image_brief: i.heroBriefHint ?? `Lifestyle-Szene die ${safePainPoint} visualisiert, helle natürliche Beleuchtung, professionell aber nahbar`,
    tone_notes: '[Stub] echter Output kommt mit OPENROUTER_API_KEY',
  };

  const mjml = buildStubMjml(i, strategy);
  return { strategy, mjml, modelUsed: 'stub-no-api-key' };
}

function buildStubMjml(i: PipelineInput, s: Strategy): string {
  const heroHeightAttrs = i.heroHeight
    ? ` height="${i.heroHeight}px" css-class="mf-hero-cover"`
    : '';
  const heroBlock = i.heroEnabled
    ? `<mj-image src="{{HERO_URL}}" alt="${escapeXml(s.hero_headline)}" width="600px" padding="0"${heroHeightAttrs} />`
    : '';
  const heroCropStyle = i.heroHeight
    ? `<mj-style inline="inline">.mf-hero-cover img { height: ${i.heroHeight}px !important; object-fit: cover !important; object-position: ${i.heroCropPosition ?? 'center'} !important; }</mj-style>`
    : '';
  // Inline-Editing-Marker: data-edit-key markiert Slots, die im Preview-iframe per Click editiert werden können.
  const editable = (key: string, content: string, index?: number): string => {
    const idxAttr = index !== undefined ? ` data-edit-index="${index}"` : '';
    return `<span data-edit-key="${key}"${idxAttr}>${content}</span>`;
  };
  const benefits = s.benefits.map((b, idx) => `<li>${editable('benefits', escapeXml(b), idx)}</li>`).join('');
  const secondary = s.cta_secondary_text && i.ctaSecondaryUrl
    ? `<mj-text align="center" font-size="13px" color="#71717a"><a href="{{CTA_SECONDARY_URL}}" style="color:#71717a;text-decoration:underline">${escapeXml(s.cta_secondary_text)}</a></mj-text>`
    : '';

  return `<mjml>
  <mj-head>
    <mj-title>${escapeXml(s.subject_variants[0])}</mj-title>
    <mj-preview>${escapeXml(s.preheader)}</mj-preview>
    <mj-attributes>
      <mj-all font-family="Helvetica, Arial, sans-serif" />
      <mj-text font-size="15px" line-height="1.6" color="#27272a" />
      <mj-button background-color="${i.brandPrimaryColor}" color="#ffffff" font-size="15px" font-weight="600" border-radius="6px" padding="12px 28px" />
    </mj-attributes>
    ${heroCropStyle}
  </mj-head>
  <mj-body width="600px" background-color="#f4f4f5">
    <mj-section background-color="#ffffff" padding="20px 24px 0">
      <mj-column>
        <mj-image src="{{LOGO_URL}}" alt="${escapeXml(i.brandName)}" width="120px" align="left" padding="0" />
      </mj-column>
    </mj-section>
    ${heroBlock ? `<mj-section background-color="#ffffff" padding="20px 24px 0"><mj-column>${heroBlock}</mj-column></mj-section>` : ''}
    <mj-section background-color="#ffffff" padding="24px 24px 0">
      <mj-column>
        <mj-text font-size="24px" font-weight="700" color="#18181b" line-height="1.25">${editable('hero_headline', escapeXml(s.hero_headline))}</mj-text>
        <mj-text>${editable('intro', escapeXml(s.intro))}</mj-text>
        <mj-text>${editable('solution', escapeXml(s.solution))}</mj-text>
        <mj-text font-weight="600">Ihre Vorteile auf einen Blick:</mj-text>
        <mj-text><ul style="padding-left:18px;margin:0">${benefits}</ul></mj-text>
      </mj-column>
    </mj-section>
    <mj-section background-color="#ffffff" padding="8px 24px 24px">
      <mj-column>
        <mj-button href="{{CTA_PRIMARY_URL}}">${escapeXml(s.cta_primary_text)}</mj-button>
        ${secondary}
        <mj-text font-style="italic" color="#52525b" font-size="14px" padding-top="20px">${editable('ps_line', escapeXml(s.ps_line))}</mj-text>
        <mj-text padding-top="16px">${escapeXml(i.brandDefaultSignature?.trim() || `Beste Grüße,\nIhr ${i.brandName}-Team`).replace(/\n/g, '<br/>')}</mj-text>
      </mj-column>
    </mj-section>
    ${i.brandFooterMjml?.trim()
      ? i.brandFooterMjml.trim()
      : `<mj-section background-color="#f4f4f5" padding="16px 24px">
      <mj-column>
        <mj-text font-size="11px" color="#a1a1aa" align="center" line-height="1.6">
          ${escapeXml(i.brandFooterText)}<br/>
          <a href="{{BROWSER_URL}}" style="color:#a1a1aa">Im Browser ansehen</a> · <a href="{{IMPRINT_URL}}" style="color:#a1a1aa">Impressum</a> · <a href="{{PRIVACY_URL}}" style="color:#a1a1aa">Datenschutz</a> · <a href="{{UNSUBSCRIBE_URL}}" style="color:#a1a1aa">Abmelden</a>
        </mj-text>
      </mj-column>
    </mj-section>`}
  </mj-body>
</mjml>`;
}

function escapeXml(s: string): string {
  return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}

/**
 * Defensive JSON-Parser mit Fallback-Strategien für unzuverlässiges Tool-Use-Output.
 * Reihenfolge: strict → cleanup-fences → jsonrepair → return string (Zod erkennt das).
 */
function parseStrictThenRepair(s: string, fieldName: string): unknown {
  // Layer 1: Strict
  try {
    return JSON.parse(s);
  } catch { /* try next */ }
  // Layer 2: Markdown-Fence-Cleanup
  const cleaned = s.replace(/^```(?:json)?\s*/i, '').replace(/\s*```\s*$/i, '').trim();
  try {
    return JSON.parse(cleaned);
  } catch { /* try next */ }
  // Layer 3: jsonrepair (heuristisch fixen — unescaped quotes, trailing commas, etc.)
  try {
    const repaired = jsonrepair(cleaned);
    const parsed = JSON.parse(repaired);
    console.log(`[pipeline] ✓ ${fieldName} repaired via jsonrepair`);
    return parsed;
  } catch (e) {
    console.error(`[pipeline] ✗ ${fieldName} all parse strategies failed:`, e instanceof Error ? e.message : 'unknown');
    return s; // bleibt String, Zod wird sauber erroren
  }
}

/**
 * Post-Processor: reichert MJML-Source mit Hero-Höhe an.
 * - Setzt `height="${px}px" css-class="mf-hero-cover"` auf der ersten `<mj-image>` (= Hero)
 * - Injectiert `<mj-style inline="inline">` mit object-fit:cover-Crop in `<mj-head>`
 *
 * Idempotent: wenn `mf-hero-cover` schon vorhanden ist, wird nicht doppelt manipuliert.
 *
 * Heuristik für Hero-Identifikation: erste `<mj-image>` im MJML mit width="600px" — das ist
 * die Hero-Konvention im Template (Logo läuft mit width="120px", inline-Bilder anders).
 */
/**
 * Löst `{{LOGO_URL}}`-Platzhalter im MJML auf.
 *
 * - **Wenn `logoUrl` gesetzt**: Direkt einsetzen — das `<mj-image>` rendert das Logo wie konfiguriert.
 * - **Wenn `logoUrl` leer/undefined**: Komplette Logo-Section entfernen statt mit Broken-Image-
 *   Platzhalter zu rendern. Dafür greifen wir die ganze `<mj-section>...</mj-section>`-Klammer
 *   um das `<mj-image src="{{LOGO_URL}}">` weg. Konsistent zu `heroEnabled=false`-Verhalten:
 *   keine Konfiguration → keine Section, statt Broken-State.
 *
 * Sowohl der Stub-Pfad (`buildStubMjml`) als auch der OpenRouter-Pfad (über System-Prompt-
 * Konvention) emittieren `{{LOGO_URL}}` — diese Funktion ist die zentrale Resolve-Stelle.
 */
function applyLogoUrl(mjml: string, logoUrl: string | undefined): string {
  if (logoUrl && logoUrl.trim()) {
    // Standard-Fall: URL einsetzen. escapeAttr ist hier nicht nötig, weil die URL aus
    // einer validierten DB-Spalte kommt — falls später User-Generated-Logos kommen,
    // muss hier escapeAttr/Whitelist greifen.
    return mjml.replace(/\{\{LOGO_URL\}\}/g, logoUrl);
  }

  // Fallback: Logo-Section komplett entfernen. Wir matchen die `<mj-section>`, die das
  // `<mj-image src="{{LOGO_URL}}">` enthält, mit non-greedy-Pattern. Das umfasst beide
  // Layouts (mit/ohne `headerPhone`-2-Spalten) aus render-template.ts und buildStubMjml.
  return mjml.replace(
    /<mj-section\b[^>]*>(?:(?!<\/mj-section>)[\s\S])*?<mj-image[^>]*src="\{\{LOGO_URL\}\}"[^>]*\/>(?:(?!<\/mj-section>)[\s\S])*?<\/mj-section>\s*/g,
    '',
  );
}

/**
 * Ersetzt den Standard-Footer-Bereich im fertigen MJML durch einen User-eigenen MJML-Block.
 *
 * Der Standard-Footer ist eine `<mj-section background-color="#f4f4f5" ...>`-Section am Ende
 * des `<mj-body>`. Wir matchen die LETZTE Section mit dieser Background-Color (idempotente
 * Heuristik — alle aktuellen Pfade emittieren genau eine solche Section am Ende). Falls keine
 * gefunden wird, fügen wir den Custom-Block vor `</mj-body>` ein.
 *
 * Idempotent: wenn `customMjml` leer oder nicht-gesetzt ist, bleibt das MJML unverändert.
 * Wenn der Custom-Block bereits identisch im MJML steht (Stub-Pfad hat ihn direkt eingebaut),
 * wirkt das Replace als no-op weil unsere Regex ohnehin nur den Standard-Footer matcht —
 * Custom-Footer hat keinen `background-color="#f4f4f5"` außer der User schreibt ihn so.
 * Im Worst-Case schreibt der User selbst eine f4f4f5-Section: dann wäre der Custom-Block
 * schon drin, das Replace tauscht ihn 1:1 gegen sich selbst aus.
 */
/**
 * Truthy wenn die Workspace mindestens ein structured Footer-Feld konfiguriert hat
 * (Anwaltsblock DE/EN oder Confidentiality DE/EN oder explizit showUnsubscribe=false).
 * Dann lohnt sich der `renderFooterSection()`-Replace-Schritt.
 */
function hasStructuredFooterFields(input: PipelineInput): boolean {
  return !!(
    input.brandFooterLegalText?.trim() ||
    input.brandFooterLegalTextEn?.trim() ||
    input.brandFooterConfidentialityNotice?.trim() ||
    input.brandFooterConfidentialityNoticeEn?.trim() ||
    input.brandFooterShowUnsubscribe === false
  );
}

function applyFooterMjml(mjml: string, customMjml: string | undefined): string {
  const trimmed = customMjml?.trim();
  if (!trimmed) return mjml;

  // Letzte `<mj-section ... background-color="#f4f4f5" ... >...</mj-section>` finden.
  // Non-greedy auf Section-Body, global mit lastIndex-Tracking.
  const re = /<mj-section\b[^>]*background-color="#f4f4f5"[^>]*>[\s\S]*?<\/mj-section>/g;
  const matches = [...mjml.matchAll(re)];
  if (matches.length > 0) {
    const last = matches[matches.length - 1]!;
    const start = last.index ?? 0;
    const end = start + last[0].length;
    return `${mjml.slice(0, start)}${trimmed}${mjml.slice(end)}`;
  }

  // Fallback: keine Standard-Footer-Section gefunden → vor </mj-body> einfügen
  if (mjml.includes('</mj-body>')) {
    return mjml.replace('</mj-body>', `${trimmed}\n  </mj-body>`);
  }
  return mjml;
}

function applyHeroHeightToMjml(
  mjml: string,
  heroHeight: number,
  heroCropPosition?: 'top' | 'center' | 'bottom',
): string {
  if (mjml.includes('mf-hero-cover')) return mjml; // schon angewendet

  // 1. Erste mj-image mit width="600px" → Hero. Append height + css-class.
  let modified = mjml.replace(
    /<mj-image([^>]*?)width="600px"([^>]*?)\/>/,
    (match, before: string, after: string) => {
      // Wenn die mj-image schon ein height oder css-class trägt, nicht touchen.
      if (match.includes('height=') || match.includes('css-class=')) return match;
      return `<mj-image${before}width="600px"${after} height="${heroHeight}px" css-class="mf-hero-cover" />`;
    },
  );

  // 2. mj-style-Block in mj-head injecten (vor </mj-head>).
  const styleBlock = `<mj-style inline="inline">.mf-hero-cover img { height: ${heroHeight}px !important; object-fit: cover !important; object-position: ${heroCropPosition ?? 'center'} !important; }</mj-style>`;
  if (modified.includes('</mj-head>')) {
    modified = modified.replace('</mj-head>', `  ${styleBlock}\n  </mj-head>`);
  } else {
    // Falls KI vergessen hat mj-head zu schreiben — direkt hinter <mjml> einfügen
    modified = modified.replace('<mjml>', `<mjml>\n  <mj-head>${styleBlock}</mj-head>`);
  }

  return modified;
}
