/**
 * useExport - Hook for exporting models in various formats
 *
 * Provides export functionality for:
 * - Recipe: leverages the Modeler API backend export_recipe_url
 * - Archive: leverages the Modeler API backend export_url
 * - JSON: client-side export with optional replay data and required modules
 * - SVG: client-side visual export of the current canvas
 */

import { useCallback, useMemo } from 'react';
import { t } from '../utils/translation';
import { getComponentLabel } from '../utils/componentUtils';
import { exportModelData } from '../utils/modelUtils';
import { fetchValidatedCsrfToken } from '../utils/validation';
import type { Settings } from '../types/settings';
import type { Node, Edge, Component } from '../store/store';
import type { ReplayStep } from './useSimpleReplaySync';

/** Export format identifiers */
export type ExportFormat = 'recipe' | 'archive' | 'json' | 'svg';

interface UseExportProps {
  settings: Settings;
  nodes: Node[];
  edges: Edge[];
  components: Component[];
  modelData: { id?: string; version?: string; metadata?: Record<string, any> } | null;
  replayData: ReplayStep[];
  /** Screen reader announcement callback */
  announce?: (text: string) => void;
}

interface UseExportReturn {
  /** Whether the export button should be visible */
  canExport: boolean;
  /** Available export formats based on current settings */
  availableFormats: ExportFormat[];
  /** Whether replay data is available for JSON export */
  hasReplayData: boolean;
  /** Execute the actual export for a given format */
  executeExport: (format: ExportFormat, includeReplayData?: boolean) => Promise<void>;
  /** Derive required modules from the model nodes */
  getRequiredModules: () => string[];
}

/**
 * Trigger a browser download for a string payload.
 */
function downloadFile(content: string, filename: string, mimeType: string): void {
  const blob = new Blob([content], { type: mimeType });
  const url = URL.createObjectURL(blob);
  const link = document.createElement('a');
  link.href = url;
  link.download = filename;
  document.body.appendChild(link);
  link.click();
  document.body.removeChild(link);
  URL.revokeObjectURL(url);
}

/**
 * Parse a CSS transform string for translate values.
 */
function parseTranslate(style: string): { x: number; y: number } {
  const match = style.match(/translate\(\s*([-\d.]+)px\s*,\s*([-\d.]+)px\s*\)/);
  if (match) {
    return { x: parseFloat(match[1]), y: parseFloat(match[2]) };
  }
  return { x: 0, y: 0 };
}

/**
 * Resolve a CSS variable to its computed value.
 */
function resolveVar(varName: string): string {
  const el = document.querySelector('.modeler');
  if (!el) return '';
  return getComputedStyle(el).getPropertyValue(varName).trim();
}

/** Escape special XML characters in text content. */
function escapeXml(text: string): string {
  return text
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;');
}

/** Split text into lines that fit within maxWidth (approximate). */
function wrapText(text: string, maxWidth: number, fontSize: number): string[] {
  const charWidth = fontSize * 0.6;
  const maxChars = Math.max(1, Math.floor(maxWidth / charWidth));
  const words = text.split(/\s+/);
  const lines: string[] = [];
  let current = '';
  for (const word of words) {
    const test = current ? `${current} ${word}` : word;
    if (test.length > maxChars && current) {
      lines.push(current);
      current = word;
    } else {
      current = test;
    }
  }
  if (current) lines.push(current);
  return lines.length > 0 ? lines : [''];
}

// ---------------------------------------------------------------------------
// Node type colors — resolved once at export time.
// ---------------------------------------------------------------------------
interface NodeColors {
  bgPrimary: string;
  bgSurface: string;
  borderLight: string;
  textPrimary: string;
  textSecondary: string;
  typeEvent: string;
  typeEventLight: string;
  typeEventText: string;
  typeAction: string;
  typeCondition: string;
  typeGateway: string;
  annotationBg: string;
  annotationBorder: string;
  annotationText: string;
  edgeLabel: string;
  interactive: string;
  textOnDark: string;
}

function resolveColors(): NodeColors {
  return {
    bgPrimary: resolveVar('--modeler-color-bg-primary') || '#ffffff',
    bgSurface: resolveVar('--modeler-color-bg-surface') || '#f9fafb',
    borderLight: resolveVar('--modeler-color-border-light') || '#e5e7eb',
    textPrimary: resolveVar('--modeler-color-text-primary') || '#374151',
    textSecondary: resolveVar('--modeler-color-text-secondary') || '#4b5563',
    typeEvent: resolveVar('--modeler-color-type-event') || '#ff9800',
    typeEventLight: resolveVar('--modeler-color-type-event-light') || '#fff3e0',
    typeEventText: resolveVar('--modeler-color-warning-darker') || '#b45309',
    typeAction: resolveVar('--modeler-color-type-action') || '#4caf50',
    typeCondition: resolveVar('--modeler-color-type-condition') || '#2196f3',
    typeGateway: resolveVar('--modeler-color-type-gateway') || '#9c27b0',
    annotationBg: resolveVar('--modeler-color-warning-light') || '#fef3c7',
    annotationBorder: resolveVar('--modeler-color-warning') || '#f59e0b',
    annotationText: resolveVar('--modeler-color-warning-darkest') || '#92400e',
    edgeLabel: resolveVar('--modeler-color-edge-default') || '#8b8b8b',
    interactive: resolveVar('--modeler-color-interactive') || '#3b82f6',
    textOnDark: resolveVar('--modeler-color-text-on-dark') || '#f9fafb',
  };
}

/**
 * Clean extracted edge SVG markup so it renders correctly as a standalone
 * file.  This handles several issues:
 *
 * 1. Resolve CSS custom properties (`var(--modeler-...)`) — standalone SVGs
 *    cannot access an external stylesheet.
 * 2. Add `fill:none` to edge-path `<path>` style attributes — the SVG spec
 *    defaults `fill` to black; in the browser ReactFlow sets `fill:none` via
 *    a CSS rule that is absent from the exported file.
 * 3. Remove invisible interaction paths (`.react-flow__edge-interaction`)
 *    which are thick transparent hit-areas only relevant in the browser.
 * 4. Strip interactive DOM attributes (`tabindex`, `role`, `aria-label`,
 *    `aria-describedby`, `data-testid`) that add noise to the export.
 */
function cleanEdgeMarkup(markup: string): string {
  let result = markup;

  // 1. Resolve CSS variables.
  result = result.replace(
    /var\(\s*(--modeler-[^)]+)\s*\)/g,
    (_match, varName: string) => resolveVar(varName) || _match,
  );

  // 2. Add fill:none to path style attributes that don't already set fill.
  result = result.replace(
    /(<path\b[^>]*\bstyle=")((?:(?!fill)[^"])*")/g,
    (_m, before: string, rest: string) => `${before}fill: none; ${rest}`,
  );

  // 3. Remove interaction paths (invisible hit-area overlays).
  result = result.replace(
    /<path\b[^>]*class="[^"]*react-flow__edge-interaction[^"]*"[^>]*><\/path>/g,
    '',
  );

  // 4. Strip interactive DOM attributes.
  result = result.replace(
    /\s*(?:tabindex|role|aria-label|aria-describedby|data-testid)="[^"]*"/g,
    '',
  );

  return result;
}

// ---------------------------------------------------------------------------
// Render individual node types as native SVG <g> elements.
// ---------------------------------------------------------------------------

interface NodeInfo {
  x: number;
  y: number;
  w: number;
  h: number;
  label: string;
  plugin: string;
  type: string;          // 'start' | 'element' | 'gateway' | 'subprocess'
  configured: boolean;
  annotation: string;
  annotationsVisible: boolean;
}

/** Rounded rectangle node (action / condition / start / subprocess). */
function renderRectNode(
  n: NodeInfo,
  c: NodeColors,
): string {
  const isStart = n.type === 'start';
  const isSubprocess = n.type === 'subprocess';
  const borderColor = isStart
    ? c.typeEvent
    : isSubprocess
      ? c.typeCondition
      : c.borderLight;
  const headerBg = isStart ? c.typeEventLight : c.bgSurface;
  const headerText = isStart ? c.typeEventText : c.textSecondary;
  // eslint-disable-next-line i18n/no-untranslated-strings
  const borderStyle = isSubprocess ? 'stroke-dasharray="6 4"' : '';
  const typeLabel = isStart
    ? getComponentLabel('start')
    : isSubprocess
      ? getComponentLabel('subprocess')
      : getComponentLabel('element');

  const r = 8; // border-radius
  const headerH = 28;
  const bodyPadY = 10;
  const labelFs = 13;
  const pluginFs = 10;
  const headerFs = 10;

  // Body content lines
  const labelLines = wrapText(n.label, n.w - 24, labelFs);
  const pluginLines = n.plugin ? [n.plugin] : [];

  // Build body text elements
  const labelStartY = headerH + bodyPadY + labelFs;
  const bodyTextEls: string[] = [];

  labelLines.forEach((line, i) => {
    bodyTextEls.push(
      `<text x="${n.w / 2}" y="${labelStartY + i * (labelFs + 3)}" ` +
      `text-anchor="middle" fill="${c.textPrimary}" font-size="${labelFs}" ` +
      `font-weight="500">${escapeXml(line)}</text>`,
    );
  });

  const pluginY = labelStartY + labelLines.length * (labelFs + 3) + 4;
  pluginLines.forEach((line, i) => {
    bodyTextEls.push(
      `<text x="${n.w / 2}" y="${pluginY + i * (pluginFs + 2)}" ` +
      `text-anchor="middle" fill="${c.textSecondary}" font-size="${pluginFs}">` +
      `${escapeXml(line)}</text>`,
    );
  });

  // Annotation label above node
  let annotationEl = '';
  if (n.annotation && n.annotationsVisible) {
    annotationEl = [
      `<rect x="${n.w / 2 - 60}" y="-22" width="120" height="18" rx="3" `,
      `  fill="${c.annotationBg}" stroke="${c.annotationBorder}" stroke-width="1"/>`,
      `<text x="${n.w / 2}" y="-9" text-anchor="middle" fill="${c.annotationText}" `,
      `  font-size="10">${escapeXml(n.annotation.slice(0, 25))}${n.annotation.length > 25 ? '…' : ''}</text>`,
    ].join('\n');
  }

  // Configured gear indicator
  const configuredEl = n.configured
    ? `<circle cx="${n.w - 12}" cy="12" r="5" fill="none" ` +
      `stroke="${c.typeAction}" stroke-width="1.5"/>` +
      `<circle cx="${n.w - 12}" cy="12" r="1.5" fill="${c.typeAction}"/>`
    : '';

  return [
    `<g transform="translate(${n.x}, ${n.y})">`,
    annotationEl,
    `  <rect x="0" y="0" width="${n.w}" height="${n.h}" rx="${r}" ry="${r}"`,
    `    fill="${c.bgPrimary}" stroke="${borderColor}" stroke-width="2" ${borderStyle}/>`,
    // Header background (clipped to top rounded corners)
    `  <clipPath id="hdr-${n.x}-${n.y}">`,
    `    <rect x="0" y="0" width="${n.w}" height="${headerH}" rx="${r}" ry="${r}"/>`,
    `  </clipPath>`,
    `  <rect x="0" y="0" width="${n.w}" height="${headerH}" fill="${headerBg}"`,
    `    clip-path="url(#hdr-${n.x}-${n.y})"/>`,
    `  <line x1="0" y1="${headerH}" x2="${n.w}" y2="${headerH}" stroke="${c.borderLight}" stroke-width="1"/>`,
    // Header text
    `  <text x="12" y="${headerH / 2 + 4}" fill="${headerText}" font-size="${headerFs}"`,
    `    font-weight="600" text-transform="uppercase" letter-spacing="0.5">${typeLabel.toUpperCase()}</text>`,
    // Body text
    ...bodyTextEls,
    configuredEl,
    `</g>`,
  ].join('\n');
}

/** Diamond gateway node. */
function renderGatewayNode(
  n: NodeInfo,
  c: NodeColors,
): string {
  // The diamond is centered at (x + w/2, y + h/2)
  const size = Math.max(56, Math.min(120, n.w));
  const half = size / 2;
  const cx = n.x + n.w / 2;
  const cy = n.y + n.h / 2;

  const labelLines = wrapText(n.label, size * 0.65, 10);

  let annotationEl = '';
  if (n.annotation && n.annotationsVisible) {
    annotationEl = [
      `<rect x="${cx - 60}" y="${cy - half - 24}" width="120" height="18" rx="3"`,
      `  fill="${c.annotationBg}" stroke="${c.annotationBorder}" stroke-width="1"/>`,
      `<text x="${cx}" y="${cy - half - 11}" text-anchor="middle" fill="${c.annotationText}"`,
      `  font-size="10">${escapeXml(n.annotation.slice(0, 25))}${n.annotation.length > 25 ? '…' : ''}</text>`,
    ].join('\n');
  }

  const textEls = labelLines.map((line, i) => {
    const yOff = -((labelLines.length - 1) * 6) + i * 13;
    return `<text x="${cx}" y="${cy + yOff + 4}" text-anchor="middle" ` +
      `fill="${c.textPrimary}" font-size="10" font-weight="500">${escapeXml(line)}</text>`;
  });

  return [
    `<g>`,
    annotationEl,
    `  <rect x="${cx - half}" y="${cy - half}" width="${size}" height="${size}"`,
    `    transform="rotate(45, ${cx}, ${cy})"`,
    `    fill="${c.bgPrimary}" stroke="${c.typeGateway}" stroke-width="2"`,
    `    rx="4" ry="4"/>`,
    ...textEls,
    `</g>`,
  ].join('\n');
}

// ---------------------------------------------------------------------------
// Render edge labels (conditions, annotations, order badges) as SVG.
// ---------------------------------------------------------------------------

interface EdgeLabelInfo {
  x: number;
  y: number;
  label: string;
  annotation: string;
  annotationsVisible: boolean;
  order: number | null;
  totalEdges: number;
}

function renderEdgeLabel(info: EdgeLabelInfo, c: NodeColors): string {
  const parts: string[] = [];

  // Condition label
  if (info.label) {
    const tw = Math.min(info.label.length * 7 + 16, 200);
    const th = 22;
    parts.push(
      `<rect x="${info.x - tw / 2}" y="${info.y - th / 2}" width="${tw}" height="${th}" rx="4"`,
      `  fill="${c.bgPrimary}" stroke="${c.borderLight}" stroke-width="1"/>`,
      `<text x="${info.x}" y="${info.y + 4}" text-anchor="middle" fill="${c.textPrimary}"`,
      `  font-size="11">${escapeXml(info.label)}</text>`,
    );
  }

  // Annotation label above condition
  if (info.annotation && info.annotationsVisible) {
    const ay = info.label ? info.y - 24 : info.y;
    parts.push(
      `<rect x="${info.x - 55}" y="${ay - 9}" width="110" height="16" rx="3"`,
      `  fill="${c.annotationBg}" stroke="${c.annotationBorder}" stroke-width="1"/>`,
      `<text x="${info.x}" y="${ay + 2}" text-anchor="middle" fill="${c.annotationText}"`,
      `  font-size="9">${escapeXml(info.annotation.slice(0, 25))}${info.annotation.length > 25 ? '…' : ''}</text>`,
    );
  }

  // Order badge
  if (info.order !== null && info.totalEdges > 1) {
    const bx = info.x;
    const by = info.label ? info.y - 24 : info.y - 16;
    parts.push(
      `<circle cx="${bx}" cy="${by}" r="9" fill="${c.interactive}" stroke="${c.bgPrimary}" stroke-width="1"/>`,
      `<text x="${bx}" y="${by + 4}" text-anchor="middle" fill="${c.textOnDark}"`,
      `  font-size="9" font-weight="bold">${info.order}</text>`,
    );
  }

  return parts.join('\n');
}

// ---------------------------------------------------------------------------
// Main export function — pure native SVG, no foreignObject.
// ---------------------------------------------------------------------------

/**
 * Generate an SVG string from the current ReactFlow canvas.
 *
 * Strategy: read node positions/sizes and edge paths from the live DOM,
 * then re-render everything as native SVG elements (rect, text, path,
 * circle).  This avoids foreignObject which only works in browsers.
 */
function exportCanvasToSvg(): string {
  const viewport = document.querySelector('.react-flow__viewport') as HTMLElement | null;
  if (!viewport) {
    throw new Error(t('Canvas viewport not found'));
  }

  const colors = resolveColors();

  // ---------------------------------------------------------------------------
  // 1.  Read node info from the DOM.
  // ---------------------------------------------------------------------------
  const nodeEls = viewport.querySelectorAll<HTMLElement>('.react-flow__node');
  const visibleNodeEls = Array.from(nodeEls).filter(
    n => !n.style.display || n.style.display !== 'none',
  );

  if (visibleNodeEls.length === 0) {
    throw new Error(t('No visible elements to export'));
  }

  // Check if annotations are currently visible
  const annotationsVisible = !!viewport.querySelector('.annotation-label');

  const nodeInfos: NodeInfo[] = visibleNodeEls.map(el => {
    const style = el.style.cssText || el.getAttribute('style') || '';
    const { x, y } = parseTranslate(style);
    const w = el.offsetWidth || 200;
    const h = el.offsetHeight || 80;
    const label = el.querySelector('.node-label')?.textContent?.trim() || '';
    const plugin = el.querySelector('.node-plugin')?.textContent?.trim() || '';
    const configured = !!el.querySelector('.configured-icon');
    const annotation = el.querySelector('.annotation-icon')?.getAttribute('title') || '';

    let type = 'element';
    if (el.classList.contains('react-flow__node-start')) type = 'start';
    else if (el.classList.contains('react-flow__node-gateway')) type = 'gateway';
    else if (el.querySelector('.subprocess-node')) type = 'subprocess';

    return { x, y, w, h, label, plugin, type, configured, annotation, annotationsVisible };
  });

  // ---------------------------------------------------------------------------
  // 2.  Calculate bounds.
  // ---------------------------------------------------------------------------
  let minX = Infinity;
  let minY = Infinity;
  let maxX = -Infinity;
  let maxY = -Infinity;

  nodeInfos.forEach(n => {
    const extra = n.type === 'gateway' ? 20 : 0; // gateway diamond extends beyond bounding box
    minX = Math.min(minX, n.x - extra);
    minY = Math.min(minY, n.y - extra - (n.annotation && n.annotationsVisible ? 30 : 0));
    maxX = Math.max(maxX, n.x + n.w + extra);
    maxY = Math.max(maxY, n.y + n.h + extra);
  });

  const padding = 40;
  const vbX = minX - padding;
  const vbY = minY - padding;
  const vbW = maxX - minX + padding * 2;
  const vbH = maxY - minY + padding * 2;

  // ---------------------------------------------------------------------------
  // 3.  Extract native SVG edges from the DOM (already valid SVG).
  // ---------------------------------------------------------------------------
  const edgesSvgEls = viewport.querySelectorAll('.react-flow__edges');
  const edgesMarkupParts: string[] = [];
  edgesSvgEls.forEach(el => {
    // We need the inner content (<defs> + <g>s), not the wrapping <svg>
    // so we can place them directly in our output SVG.
    // Clean edge markup for standalone SVG rendering.
    edgesMarkupParts.push(cleanEdgeMarkup(el.innerHTML));
  });

  // ---------------------------------------------------------------------------
  // 4.  Extract edge labels from the DOM.
  // ---------------------------------------------------------------------------
  const edgeLabelEls = viewport.querySelectorAll<HTMLElement>('.edge-label-container');
  const edgeLabelInfos: EdgeLabelInfo[] = Array.from(edgeLabelEls).map(el => {
    const style = el.getAttribute('style') || '';
    // Parse the double-translate: translate(-50%, -50%) translate(Xpx, Ypx)
    const matches = style.match(/translate\(\s*([-\d.]+)px\s*,\s*([-\d.]+)px\s*\)/g);
    let lx = 0;
    let ly = 0;
    if (matches && matches.length >= 2) {
      const m = matches[1].match(/([-\d.]+)px\s*,\s*([-\d.]+)px/);
      if (m) { lx = parseFloat(m[1]); ly = parseFloat(m[2]); }
    } else if (matches && matches.length === 1) {
      const m = matches[0].match(/([-\d.]+)px\s*,\s*([-\d.]+)px/);
      if (m) { lx = parseFloat(m[1]); ly = parseFloat(m[2]); }
    }

    const label = el.querySelector('.condition-edge-label span')?.textContent?.trim() || '';
    const annotation = el.querySelector('.annotation-icon')?.getAttribute('title') || '';

    return { x: lx, y: ly, label, annotation, annotationsVisible, order: null, totalEdges: 0 };
  });

  // Edge order badges
  const orderEls = viewport.querySelectorAll<HTMLElement>('.edge-order-number');
  const orderInfos: EdgeLabelInfo[] = Array.from(orderEls).map(el => {
    const style = el.getAttribute('style') || '';
    const matches = style.match(/translate\(\s*([-\d.]+)px\s*,\s*([-\d.]+)px\s*\)/g);
    let ox = 0;
    let oy = 0;
    if (matches && matches.length >= 2) {
      const m = matches[1].match(/([-\d.]+)px\s*,\s*([-\d.]+)px/);
      if (m) { ox = parseFloat(m[1]); oy = parseFloat(m[2]); }
    } else if (matches && matches.length === 1) {
      const m = matches[0].match(/([-\d.]+)px\s*,\s*([-\d.]+)px/);
      if (m) { ox = parseFloat(m[1]); oy = parseFloat(m[2]); }
    }
    const badge = el.querySelector('.edge-order-badge');
    const order = badge ? parseInt(badge.textContent || '0', 10) : null;
    return {
      x: ox, y: oy, label: '', annotation: '', annotationsVisible: false,
      order, totalEdges: 2,
    };
  });

  // ---------------------------------------------------------------------------
  // 5.  Build the SVG.
  // ---------------------------------------------------------------------------
  const nodeSvgParts = nodeInfos.map(n =>
    n.type === 'gateway'
      ? renderGatewayNode(n, colors)
      : renderRectNode(n, colors),
  );

  const edgeLabelParts = [
    ...edgeLabelInfos.map(info => renderEdgeLabel(info, colors)),
    ...orderInfos.map(info => renderEdgeLabel(info, colors)),
  ];

  const fontFamily = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif";

  return [
    `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"`,
    `  width="${Math.ceil(vbW)}" height="${Math.ceil(vbH)}"`,
    `  viewBox="${vbX} ${vbY} ${vbW} ${vbH}"`,
    `  font-family="${fontFamily}">`,
    // Edge paths (native SVG from ReactFlow)
    `  <g class="edges">`,
    ...edgesMarkupParts,
    `  </g>`,
    // Edge labels
    `  <g class="edge-labels">`,
    ...edgeLabelParts,
    `  </g>`,
    // Nodes
    `  <g class="nodes">`,
    ...nodeSvgParts,
    `  </g>`,
    `</svg>`,
  ].join('\n');
}

export function useExport({
  settings,
  nodes,
  edges,
  components,
  modelData,
  replayData,
  announce,
}: UseExportProps): UseExportReturn {
  const hasExportUrl = !!settings.modeler_api?.export_url;
  const hasExportRecipeUrl = !!settings.modeler_api?.export_recipe_url;
  const hasReplay = replayData.length > 0;

  // Export is available as long as there are nodes to export
  const canExport = nodes.length > 0;

  // Determine available formats based on settings
  const availableFormats = useMemo((): ExportFormat[] => {
    const formats: ExportFormat[] = [];
    // Recipe and Archive require backend URLs (not available for new models)
    if (hasExportRecipeUrl) {
      formats.push('recipe');
    }
    if (hasExportUrl) {
      formats.push('archive');
    }
    // JSON and SVG are always available client-side
    formats.push('json');
    formats.push('svg');
    return formats;
  }, [hasExportUrl, hasExportRecipeUrl]);

  /**
   * Derive the list of required Drupal modules from node/edge providers.
   */
  const getRequiredModules = useCallback((): string[] => {
    const modules = new Set<string>();

    // Collect providers from all nodes by matching their plugin to the
    // components list
    nodes.forEach(node => {
      const plugin = node.data?.plugin;
      if (plugin) {
        const comp = components.find(c => c.plugin === plugin);
        if (comp?.provider) {
          modules.add(comp.provider);
        }
      }
    });

    // Also collect providers from edge conditions
    edges.forEach(edge => {
      const condition = edge.data?.condition;
      if (condition) {
        const comp = components.find(c => c.plugin === condition);
        if (comp?.provider) {
          modules.add(comp.provider);
        }
      }
    });

    return Array.from(modules).sort();
  }, [nodes, edges, components]);

  /**
   * Fetch configuration form schemas for all unique plugins used in the
   * model.  Returns a map from plugin ID to form field array.  Errors
   * for individual plugins are silently skipped — the resulting map will
   * simply omit their entries.
   */
  const fetchAllConfigForms = useCallback(async (): Promise<Record<string, any[]>> => {
    const configUrl = settings.modeler_api?.config_url;
    const tokenUrl = settings.modeler_api?.token_url;
    if (!configUrl || !tokenUrl) return {};

    // Collect unique plugin IDs for nodes and edge conditions
    const pluginRequests: { pluginId: string; componentType: string; componentId: string; configuration: Record<string, any> }[] = [];
    const seen = new Set<string>();

    nodes.forEach(node => {
      const plugin = node.data?.plugin;
      if (plugin && plugin !== 'gateway' && !seen.has(plugin)) {
        seen.add(plugin);
        pluginRequests.push({
          pluginId: plugin,
          componentType: node.data?.componentType || '4',
          componentId: node.id,
          configuration: node.data?.configuration || {},
        });
      }
    });

    edges.forEach(edge => {
      const condition = edge.data?.condition;
      if (condition && !seen.has(condition)) {
        seen.add(condition);
        pluginRequests.push({
          pluginId: condition,
          componentType: '5',
          componentId: edge.id,
          configuration: edge.data?.conditionConfiguration || {},
        });
      }
    });

    if (pluginRequests.length === 0) return {};

    try {
      const token = await fetchValidatedCsrfToken(tokenUrl);
      const modelId = settings.modeler?.modelId || '';
      const isNew = settings.modeler_api?.isNew || false;

      const results = await Promise.allSettled(
        pluginRequests.map(async (req) => {
          const response = await fetch(configUrl, {
            method: 'POST',
            headers: {
              'Content-Type': 'application/json;charset=UTF-8',
              'X-CSRF-Token': token.trim(),
            },
            body: JSON.stringify({
              component_type: req.componentType,
              component_id: req.componentId,
              model_id: modelId,
              is_new: isNew,
              plugin_id: req.pluginId,
              configuration: req.configuration,
            }),
          });
          if (!response.ok) return { pluginId: req.pluginId, form: null };
          const data = await response.json();
          const form = data?.form;
          return {
            pluginId: req.pluginId,
            form: Array.isArray(form) ? form : null,
          };
        }),
      );

      const configForms: Record<string, any[]> = {};
      results.forEach(result => {
        if (result.status === 'fulfilled' && result.value.form) {
          configForms[result.value.pluginId] = result.value.form;
        }
      });
      return configForms;
    } catch (_error) {
      // If token fetch or other global error, return empty
      return {};
    }
  }, [settings, nodes, edges]);

  /**
   * Build a minimal components list for the standalone viewer from the
   * plugins used in the model.
   */
  const getUsedComponents = useCallback((): Record<string, any>[] => {
    const seen = new Set<string>();
    const result: Record<string, any>[] = [];

    const addComponent = (pluginId: string) => {
      if (!pluginId || seen.has(pluginId)) return;
      seen.add(pluginId);
      const comp = components.find(c => c.plugin === pluginId);
      if (comp) {
        result.push({
          plugin: comp.plugin,
          label: comp.label,
          category: comp.category,
          provider: comp.provider,
          componentType: comp.componentType,
          type: comp.type,
          description: comp.description || '',
          documentationUrl: comp.documentationUrl || null,
        });
      }
    };

    nodes.forEach(node => addComponent(node.data?.plugin));
    edges.forEach(edge => {
      if (edge.data?.condition) addComponent(edge.data.condition);
    });

    return result;
  }, [nodes, edges, components]);

  /**
   * Execute the export for a given format.
   */
  const executeExport = useCallback(async (format: ExportFormat, includeReplayData = false): Promise<void> => {
    try {
      switch (format) {
        case 'recipe': {
          // Open the recipe export form in a new window
          const recipeUrl = settings.modeler_api?.export_recipe_url;
          if (recipeUrl) {
            window.open(recipeUrl, '_blank', 'noopener,noreferrer');
            announce?.(t('Recipe export opened in new tab'));
          }
          break;
        }

        case 'archive': {
          // Download the archive via the backend
          const exportUrl = settings.modeler_api?.export_url;
          const tokenUrl = settings.modeler_api?.token_url;
          if (exportUrl && tokenUrl) {
            const token = await fetchValidatedCsrfToken(tokenUrl);
            // Trigger download via a hidden link with CSRF token
            const response = await fetch(exportUrl, {
              method: 'GET',
              headers: {
                'X-CSRF-Token': token,
              },
            });
            if (response.ok) {
              const blob = await response.blob();
              const contentDisposition = response.headers.get('Content-Disposition');
              let filename = 'export.tar.gz';
              if (contentDisposition) {
                const match = contentDisposition.match(/filename="?([^";\s]+)"?/);
                if (match) {
                  filename = match[1];
                }
              }
              const url = URL.createObjectURL(blob);
              const link = document.createElement('a');
              link.href = url;
              link.download = filename;
              document.body.appendChild(link);
              link.click();
              document.body.removeChild(link);
              URL.revokeObjectURL(url);
              announce?.(t('Archive exported successfully'));
            } else {
              throw new Error(t('Failed to download archive: @status', { '@status': response.statusText }));
            }
          }
          break;
        }

        case 'json': {
          const data = exportModelData(
            nodes,
            edges,
            { id: modelData?.id, version: modelData?.version, ...modelData?.metadata },
          );

          // Add required modules
          const requiredModules = getRequiredModules();
          const exportData: Record<string, any> = {
            ...data,
            requiredModules,
          };

          // Optionally include replay data
          if (includeReplayData && replayData.length > 0) {
            exportData.replayData = replayData;
          }

          // Fetch all configuration form schemas for the standalone viewer.
          // This requires backend access; skip silently if unavailable.
          announce?.(t('Fetching configuration forms...'));
          const configForms = await fetchAllConfigForms();
          if (Object.keys(configForms).length > 0) {
            exportData.configForms = configForms;
          }

          // Include a minimal component list so the standalone viewer can
          // derive component type, category, and documentation links.
          const usedComponents = getUsedComponents();
          if (usedComponents.length > 0) {
            exportData.components = usedComponents;
          }

          const json = JSON.stringify(exportData, null, 2);
          const filename = `${data.id || 'model'}.json`;
          downloadFile(json, filename, 'application/json');
          announce?.(t('JSON exported successfully'));
          break;
        }

        case 'svg': {
          const svgContent = exportCanvasToSvg();
          const modelId = modelData?.id || modelData?.metadata?.label || 'model';
          const filename = `${modelId}.svg`;
          downloadFile(svgContent, filename, 'image/svg+xml');
          announce?.(t('SVG exported successfully'));
          break;
        }
      }
    } catch (error) {
      const message = error instanceof Error ? error.message : t('Export failed');
      announce?.(message);
      throw error;
    }
  }, [settings, nodes, edges, modelData, replayData, getRequiredModules, fetchAllConfigForms, getUsedComponents, announce]);

  return {
    canExport,
    availableFormats,
    hasReplayData: hasReplay,
    executeExport,
    getRequiredModules,
  };
}
