import type { Anchors, GroupLayer, Layer, Layout } from '@/types/layout';
import { Medal, Quote, Star, Trophy } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { resolveAnchors } from './anchors';
import { createShapeLayer, createTextLayer } from './factories';
import catalog from '../../elements/catalog.json';

/**
 * Curated "elements" — pre-designed badges / social-proof / CTA pieces that
 * insert as an editable GROUP (a shape + text bound together). One definition,
 * two entry points: the toolbar "Elements" picker (manual) and the AI chat's
 * `insert_element` action. After insertion they're normal layers/groups — fully
 * editable + restyleable.
 */

const newId = (): string =>
    typeof crypto !== 'undefined' && 'randomUUID' in crypto
        ? crypto.randomUUID()
        : `l_${Math.random().toString(36).slice(2, 10)}`;

/** Non-visual group container (zero-box anchors; children carry real anchors). */
function group(id: string, name: string): GroupLayer {
    return {
        id,
        type: 'group',
        name,
        anchors: {
            left: { type: 'percent', value: 0 },
            top: { type: 'percent', value: 0 },
            width: { type: 'percent', value: 0 },
            height: { type: 'percent', value: 0 },
        },
    };
}

/** Position by percent, size by px → consistent badge size across form factors.
 *  `centered` anchors the box around `leftPct` (use 50 to centre on canvas). */
function box(leftPct: number, topPct: number, wPx: number, hPx: number, centered = false): Anchors {
    return {
        left: { type: 'percent', value: leftPct, ...(centered ? { align: 'center' as const } : {}) },
        top: { type: 'percent', value: topPct },
        width: { type: 'px', value: wPx },
        height: { type: 'px', value: hPx },
    };
}

function label(
    gid: string,
    name: string,
    anchors: Anchors,
    text: string,
    opts: { size: number; weight?: number; color?: string; spacing?: number; transform?: string; font?: string },
): Layer {
    return createTextLayer({
        parentId: gid,
        name,
        anchors,
        content: { default: text },
        fontSize: opts.size,
        fontWeight: opts.weight ?? 600,
        color: opts.color ?? '#111111',
        align: 'center',
        fitMode: 'fixed',
        ...(opts.font !== undefined ? { fontFamily: opts.font } : {}),
        ...(opts.spacing !== undefined ? { letterSpacing: opts.spacing } : {}),
        ...(opts.transform !== undefined ? { textTransform: opts.transform } : {}),
    } as Parameters<typeof createTextLayer>[0]);
}

// ── Laurel wreath (procedural filled SVG) ────────────────────────────────────
// Each side is a curved frond: a cubic-bezier stem sweeping up + out then curling
// back in at the top, with plump overlapping leaves branching up-and-outward.
const rnd = (n: number): number => Math.round(n * 100) / 100;
type Pt = [number, number];

const LCX = 110;

const bez = (p: Pt[], t: number): Pt => {
    const u = 1 - t;
    return [
        u * u * u * p[0][0] + 3 * u * u * t * p[1][0] + 3 * u * t * t * p[2][0] + t * t * t * p[3][0],
        u * u * u * p[0][1] + 3 * u * u * t * p[1][1] + 3 * u * t * t * p[2][1] + t * t * t * p[3][1],
    ];
};
const dbez = (p: Pt[], t: number): Pt => {
    const u = 1 - t;
    return [
        3 * u * u * (p[1][0] - p[0][0]) + 6 * u * t * (p[2][0] - p[1][0]) + 3 * t * t * (p[3][0] - p[2][0]),
        3 * u * u * (p[1][1] - p[0][1]) + 6 * u * t * (p[2][1] - p[1][1]) + 3 * t * t * (p[3][1] - p[2][1]),
    ];
};
const unit = (v: Pt): Pt => {
    const l = Math.hypot(v[0], v[1]) || 1;
    return [v[0] / l, v[1] / l];
};

type Seg = { c: 'M' | 'L' | 'Q'; pts: Pt[] };

/** A leaf as filled-almond segments from base (bx,by) along (dirx,diry), length
 *  L, bulged `halfW` and biased toward the base for a leaf-y profile. */
function leafSegs(bx: number, by: number, dirx: number, diry: number, L: number, halfW: number): Seg[] {
    const tip: Pt = [bx + dirx * L, by + diry * L];
    const px = -diry * halfW;
    const py = dirx * halfW;
    const c1: Pt = [bx + dirx * L * 0.42 + px, by + diry * L * 0.42 + py];
    const c2: Pt = [bx + dirx * L * 0.42 - px, by + diry * L * 0.42 - py];
    return [
        { c: 'M', pts: [[bx, by]] },
        { c: 'Q', pts: [c1, tip] },
        { c: 'Q', pts: [c2, [bx, by]] },
    ];
}

/** One LEFT frond as a list of shapes (stem ribbon + leaves), in base coords. */
function frondShapes(): Seg[][] {
    const side = 1;
    const P: Pt[] = [
        [LCX - side * 12, 138],
        [LCX - side * 64, 128],
        [LCX - side * 70, 40],
        [LCX - side * 22, 14],
    ];
    const shapes: Seg[][] = [];

    // Stem — a thin ribbon offset ± along the bezier normal.
    const N = 16;
    const stemW = 2.4;
    const fwd: Pt[] = [];
    const rev: Pt[] = [];
    for (let i = 0; i <= N; i++) {
        const t = i / N;
        const pt = bez(P, t);
        const d = unit(dbez(P, t));
        fwd.push([pt[0] - d[1] * stemW * 0.5, pt[1] + d[0] * stemW * 0.5]);
        rev.push([pt[0] + d[1] * stemW * 0.5, pt[1] - d[0] * stemW * 0.5]);
    }
    const stem: Seg[] = [{ c: 'M', pts: [fwd[0]] }];
    for (let i = 1; i <= N; i++) stem.push({ c: 'L', pts: [fwd[i]] });
    for (let i = N; i >= 0; i--) stem.push({ c: 'L', pts: [rev[i]] });
    shapes.push(stem);

    // Leaves — branch up-and-outward (outward normal blended with the up-tangent).
    const n = 7;
    for (let i = 0; i < n; i++) {
        const t = 0.12 + (0.86 - 0.12) * (i / (n - 1));
        const pt = bez(P, t);
        const tan = unit(dbez(P, t));
        let nrm: Pt = [-tan[1], tan[0]];
        if (nrm[0] * side > 0) nrm = [-nrm[0], -nrm[1]]; // outward, away from centre
        const dir = unit([nrm[0] * 0.82 + tan[0] * 0.62, nrm[1] * 0.82 + tan[1] * 0.62]);
        const L = 34 * (1 - 0.32 * t); // taper toward the tip
        shapes.push(leafSegs(pt[0], pt[1], dir[0], dir[1], L, L * 0.36));
    }

    // A small leaf capping the top tip, pointing up.
    const tip = bez(P, 0.97);
    shapes.push(leafSegs(tip[0], tip[1], side * 0.18, -0.98, 22, 7));
    return shapes;
}

/** Stringify a shape's segments through a coordinate transform. */
function pathOf(segs: Seg[], tf: (p: Pt) => Pt): string {
    let s = '';
    for (const seg of segs) {
        const tp = seg.pts.map(tf);
        if (seg.c === 'M') s += `M ${rnd(tp[0][0])} ${rnd(tp[0][1])} `;
        else if (seg.c === 'L') s += `L ${rnd(tp[0][0])} ${rnd(tp[0][1])} `;
        else s += `Q ${rnd(tp[0][0])} ${rnd(tp[0][1])} ${rnd(tp[1][0])} ${rnd(tp[1][1])} `;
    }
    return s + 'Z';
}

/** A single laurel frond normalized to its own tight viewBox + an x-mirrored
 *  twin. Left + right ship as INDEPENDENT layers, so the text between them sets
 *  the spacing and each side can be nudged on its own. */
function laurelFronds(): { left: string[]; right: string[]; viewBox: [number, number] } {
    const shapes = frondShapes();
    let minX = Infinity;
    let minY = Infinity;
    let maxX = -Infinity;
    let maxY = -Infinity;
    for (const sh of shapes) {
        for (const seg of sh) {
            for (const p of seg.pts) {
                if (p[0] < minX) minX = p[0];
                if (p[0] > maxX) maxX = p[0];
                if (p[1] < minY) minY = p[1];
                if (p[1] > maxY) maxY = p[1];
            }
        }
    }
    const pad = 4;
    const vbW = maxX - minX + pad * 2;
    const vbH = maxY - minY + pad * 2;
    const tl = (p: Pt): Pt => [p[0] - minX + pad, p[1] - minY + pad];
    const tr = (p: Pt): Pt => [vbW - (p[0] - minX + pad), p[1] - minY + pad];
    return {
        left: shapes.map((s) => pathOf(s, tl)),
        right: shapes.map((s) => pathOf(s, tr)),
        viewBox: [rnd(vbW), rnd(vbH)],
    };
}

/** Anchors for a frond pinned by its INNER edge to `innerPct` (so the gap is
 *  controlled by the text). Left frond pins its right edge; right frond its left. */
function frondBox(innerPct: number, topPct: number, wPx: number, hPx: number, side: 'left' | 'right'): Anchors {
    return {
        left: { type: 'percent', value: innerPct, align: side === 'left' ? 'end' : 'start' },
        top: { type: 'percent', value: topPct },
        width: { type: 'px', value: wPx },
        height: { type: 'px', value: hPx },
    };
}

/** Measure rendered text width (canvas px) for content-aware spacing. */
export function measureTextWidth(text: string, fontSize: number, fontWeight: number, fontFamily = 'Inter'): number {
    if (typeof document === 'undefined') return 0;
    const ctx = document.createElement('canvas').getContext('2d');
    if (!ctx) return 0;
    ctx.font = `${fontWeight} ${fontSize}px ${fontFamily}, sans-serif`;
    return ctx.measureText(text).width;
}

const LAUREL_MARGIN = 30; // px breathing room between the value and each frond

/** A frond's inner-edge percent derived from the value's pixel width, anchored
 *  to the value's centre `cx` (defaults to canvas centre) so the gap AUTO-ADJUSTS
 *  to the content AND tracks the value when the element is moved/resized. */
function innerPct(width: number, canvasWidth: number, margin: number, side: 'left' | 'right', cx = canvasWidth / 2): number {
    const half = width / 2;
    const px = side === 'left' ? cx - half - margin : cx + half + margin;
    const pct = (px / canvasWidth) * 100;
    return side === 'left' ? Math.max(5, pct) : Math.min(95, pct);
}

const LAUREL_FILL = '#1A1A1A';

/** A laurel frond layer wired to track the value text's width via `spacingLink`. */
function laurelFrondLayer(
    gid: string,
    side: 'left' | 'right',
    valueId: string,
    w: number,
    canvasWidth: number,
    fw: number,
    fh: number,
    paths: string[],
    viewBox: [number, number],
): Layer {
    return createShapeLayer('rect', {
        parentId: gid,
        name: side === 'left' ? 'Laurel left' : 'Laurel right',
        anchors: frondBox(innerPct(w, canvasWidth, LAUREL_MARGIN, side), 3.4, fw, fh, side),
        vectorPaths: paths,
        vectorViewBox: viewBox,
        style: { fill: { type: 'solid', value: LAUREL_FILL } },
        spacingLink: { valueId, side, margin: LAUREL_MARGIN, w },
    });
}

/** The badge's centre value as an auto-sizing (never-wrapping) text layer. */
function laurelValueLayer(gid: string, id: string, value: string): Layer {
    return createTextLayer({
        id,
        parentId: gid,
        name: 'Value',
        anchors: box(50, 8.5, 720, 120, true),
        content: { default: value },
        fontSize: 100,
        fontWeight: 800,
        color: '#111111',
        align: 'center',
        fitMode: 'auto',
    } as Parameters<typeof createTextLayer>[0]);
}

/** Re-space laurel fronds whose linked value text has changed width. Runs after
 *  every store mutation but only repositions when the value's width actually
 *  moved — so manual frond dragging is preserved between text edits. */
export function applyLaurelSpacing(layout: Layout, activeLocale: string, defaultLocale: string): void {
    const cw = layout.canvas?.width;
    if (!cw || cw <= 0) return;
    if (!layout.layers.some((l) => l.type === 'shape' && l.spacingLink)) return; // fast no-op
    const localeKey = activeLocale === defaultLocale ? 'default' : activeLocale;
    const byId = new Map(layout.layers.map((l) => [l.id, l]));
    for (const l of layout.layers) {
        if (l.type !== 'shape' || !l.spacingLink) continue;
        const val = byId.get(l.spacingLink.valueId);
        if (!val || val.type !== 'text') continue;
        const text = String(val.content?.[localeKey] ?? val.content?.default ?? '');
        const w = measureTextWidth(text, val.fontSize ?? 100, Number(val.fontWeight) || 700);
        if (Math.abs(w - l.spacingLink.w) < 0.5) continue; // unchanged → keep manual position
        l.spacingLink.w = w;
        // Flank the value's ACTUAL centre (not the canvas centre) so the fronds
        // track the value when the element is moved or group-resized off-centre.
        const valRect = resolveAnchors(val.anchors, layout.canvas);
        const cx = valRect.x + valRect.width / 2;
        l.anchors.left = {
            type: 'percent',
            value: innerPct(w, cw, l.spacingLink.margin, l.spacingLink.side, cx),
            align: l.spacingLink.side === 'left' ? 'end' : 'start',
        };
    }
}

/** Context passed to element builders (e.g. canvas width for content-aware spacing). */
export type ElementCtx = { canvasWidth?: number };
type ElementDef = { icon: LucideIcon; build: (fields: Record<string, string>, ctx: ElementCtx) => Layer[] };

// ── Shared design tokens + graphic builders ──────────────────────────────────
const GOLD = '#F5A524';
const INK = '#1A1A1A';
const MUTED = '#8A8A8A';

type ShadowSpec = { color: string; blur: number; offsetX: number; offsetY: number; opacity: number };

/** A filled vector-graphic shape layer (decorative, recolourable via fill). */
function vec(
    gid: string,
    name: string,
    anchors: Anchors,
    paths: string[],
    viewBox: [number, number],
    fill: string,
    opts: { fillRule?: 'nonzero' | 'evenodd'; shadow?: ShadowSpec } = {},
): Layer {
    return createShapeLayer('rect', {
        parentId: gid,
        name,
        anchors,
        vectorPaths: paths,
        vectorViewBox: viewBox,
        ...(opts.fillRule ? { vectorFillRule: opts.fillRule } : {}),
        style: { fill: { type: 'solid', value: fill }, ...(opts.shadow ? { shadow: opts.shadow } : {}) },
    });
}

/** A white card with a soft drop shadow — backing for stat / review elements. */
function card(gid: string, anchors: Anchors, fill = '#FFFFFF', radius = 32): Layer {
    return createShapeLayer('rect', {
        parentId: gid,
        name: 'Card',
        anchors,
        style: {
            fill: { type: 'solid', value: fill },
            radius,
            shadow: { color: '#1A1A1A', blur: 44, offsetX: 0, offsetY: 16, opacity: 0.14 },
        },
    });
}

// Shared star-row geometry (one source of truth for track + rating overlay).
const STAR = { gap: 40, cy: 19, outer: 17, inner: 7.4, h: 38 };

/** Star outline vertices (10 points for a 5-point star) centred at (cx,cy). */
function starVerts(cx: number, cy: number, outer: number, inner: number, points = 5, rotDeg = -90): Pt[] {
    const v: Pt[] = [];
    for (let i = 0; i < points * 2; i++) {
        const r = i % 2 === 0 ? outer : inner;
        const a = (rotDeg * Math.PI) / 180 + (i * Math.PI) / points;
        v.push([cx + r * Math.cos(a), cy + r * Math.sin(a)]);
    }
    return v;
}

const polyPath = (v: Pt[]): string =>
    v.length === 0 ? '' : `${v.map((p, i) => `${i === 0 ? 'M' : 'L'} ${rnd(p[0])} ${rnd(p[1])} `).join('')}Z`;

/** Keep the polygon portion with x ≤ `xline` (Sutherland–Hodgman, one edge) → a
 *  left-half star for the half-rating case. */
function clipLeft(v: Pt[], xline: number): Pt[] {
    const out: Pt[] = [];
    for (let i = 0; i < v.length; i++) {
        const cur = v[i];
        const nxt = v[(i + 1) % v.length];
        const curIn = cur[0] <= xline;
        const nxtIn = nxt[0] <= xline;
        if (curIn) out.push(cur);
        if (curIn !== nxtIn) {
            const t = (xline - cur[0]) / (nxt[0] - cur[0]);
            out.push([xline, cur[1] + t * (nxt[1] - cur[1])]);
        }
    }
    return out;
}

/** One star `d` centred at (cx,cy). */
const starD = (cx: number, cy: number, outer: number, inner: number, points = 5, rotDeg = -90): string =>
    polyPath(starVerts(cx, cy, outer, inner, points, rotDeg));

/** A horizontal row of `n` stars + its viewBox (used for the grey track). */
function starRow(n = 5): { paths: string[]; viewBox: [number, number] } {
    const { gap, cy, outer, inner, h } = STAR;
    const paths: string[] = [];
    for (let i = 0; i < n; i++) paths.push(starD(gap / 2 + i * gap, cy, outer, inner));
    return { paths, viewBox: [gap * n, h] };
}

/** Gold star paths for a `rating` out of `max` — full stars + an optional
 *  left-half star, rounded to the nearest 0.5. Overlays the grey track. */
function ratingGoldPaths(rating: number, max = 5): string[] {
    const { gap, cy, outer, inner } = STAR;
    const r = Math.max(0, Math.min(max, Math.round(rating * 2) / 2));
    const full = Math.floor(r);
    const paths: string[] = [];
    for (let i = 0; i < full; i++) paths.push(starD(gap / 2 + i * gap, cy, outer, inner));
    if (r - full >= 0.5) {
        const cx = gap / 2 + full * gap;
        paths.push(polyPath(clipLeft(starVerts(cx, cy, outer, inner), cx)));
    }
    return paths;
}

const roundHalf = (r: number, max: number): number => Math.max(0, Math.min(max, Math.round(r * 2) / 2));


/** The gold star overlay, wired (via `ratingLink`) to track the score text. */
function ratingStarsLayer(gid: string, scoreId: string, rating: number, anchors: Anchors, viewBox: [number, number]): Layer {
    return createShapeLayer('rect', {
        parentId: gid,
        name: 'Stars',
        anchors,
        vectorPaths: ratingGoldPaths(rating),
        vectorViewBox: viewBox,
        style: { fill: { type: 'solid', value: GOLD } },
        ratingLink: { scoreId, max: 5, r: roundHalf(rating, 5) },
    });
}

/** The big rating score as a text layer with a stable id (the star overlay refs it). */
function scoreLayer(gid: string, id: string, anchors: Anchors, text: string): Layer {
    return createTextLayer({
        id,
        parentId: gid,
        name: 'Score',
        anchors,
        content: { default: text },
        fontSize: 92,
        fontWeight: 800,
        color: INK,
        align: 'center',
        fitMode: 'fixed',
    } as Parameters<typeof createTextLayer>[0]);
}

/** Regenerate gold star overlays to match their linked score text. Runs after
 *  every store mutation; only rebuilds paths when the rounded rating changed. */
export function applyStarRating(layout: Layout, activeLocale: string, defaultLocale: string): void {
    if (!layout.layers.some((l) => l.type === 'shape' && l.ratingLink)) return;
    const localeKey = activeLocale === defaultLocale ? 'default' : activeLocale;
    const byId = new Map(layout.layers.map((l) => [l.id, l]));
    for (const l of layout.layers) {
        if (l.type !== 'shape' || !l.ratingLink) continue;
        const score = byId.get(l.ratingLink.scoreId);
        if (!score || score.type !== 'text') continue;
        const parsed = parseFloat(String(score.content?.[localeKey] ?? score.content?.default ?? ''));
        if (!Number.isFinite(parsed)) continue;
        const r = roundHalf(parsed, l.ratingLink.max);
        if (r === l.ratingLink.r) continue; // unchanged → no path churn
        l.ratingLink.r = r;
        l.vectorPaths = ratingGoldPaths(r, l.ratingLink.max);
    }
}

export const ELEMENTS: Record<string, ElementDef> = {
    ratings_badge: {
        icon: Star,
        build: (f) => {
            const gid = newId();
            const scoreId = newId();
            const sr = starRow(5);
            const starsBox = box(50, 6.8, 230, 40, true);
            const ratingText = f.rating ?? '4.8';
            const rating = Number.isFinite(parseFloat(ratingText)) ? parseFloat(ratingText) : 4.8;
            return [
                group(gid, 'Star rating'),
                card(gid, box(50, 6, 560, 250, true)),
                // Grey track (all 5) + gold overlay (count follows the score).
                vec(gid, 'Stars track', starsBox, sr.paths, sr.viewBox, '#E6E1D8'),
                ratingStarsLayer(gid, scoreId, rating, starsBox, sr.viewBox),
                scoreLayer(gid, scoreId, box(50, 8.5, 420, 100, true), ratingText),
                label(gid, 'Caption', box(50, 12.8, 500, 30, true), f.caption ?? 'App Store rating', {
                    size: 23, weight: 700, color: '#5F5F5F', spacing: 2, transform: 'uppercase',
                }),
            ];
        },
    },
    review_card: {
        icon: Quote,
        build: (f) => {
            const gid = newId();
            const sr = starRow(5);
            return [
                group(gid, 'Review card'),
                card(gid, box(50, 4, 760, 360, true)),
                label(gid, 'Quote mark', box(50, 3.5, 220, 120, true), '“', { size: 150, weight: 800, color: '#EBE4D8' }),
                vec(gid, 'Stars', box(50, 8.0, 200, 34, true), sr.paths, sr.viewBox, GOLD),
                label(
                    gid,
                    'Quote',
                    box(50, 9.7, 640, 130, true),
                    f.quote ?? 'This app completely changed how I work. I honestly can’t imagine my day without it.',
                    { size: 36, weight: 500, color: INK },
                ),
                label(gid, 'Author', box(50, 14.2, 600, 30, true), f.author ?? '— Sarah K., Product Designer', {
                    size: 22, weight: 600, color: MUTED,
                }),
            ];
        },
    },
    laurel_stat: {
        icon: Medal,
        build: (f, ctx) => {
            const gid = newId();
            const valueId = newId();
            const value = f.value ?? '2M+';
            const cw = ctx.canvasWidth ?? 1320;
            const w = measureTextWidth(value, 100, 800);
            const lf = laurelFronds();
            const fh = 409;
            const fw = rnd((fh * lf.viewBox[0]) / lf.viewBox[1]);
            return [
                group(gid, 'Laurel stat'),
                laurelFrondLayer(gid, 'left', valueId, w, cw, fw, fh, lf.left, lf.viewBox),
                laurelFrondLayer(gid, 'right', valueId, w, cw, fw, fh, lf.right, lf.viewBox),
                label(gid, 'Eyebrow', box(50, 6.6, 360, 32, true), f.eyebrow ?? 'TRUSTED BY', {
                    size: 22, weight: 600, spacing: 3, transform: 'uppercase',
                }),
                laurelValueLayer(gid, valueId, value),
                label(gid, 'Label', box(50, 13.2, 360, 30, true), f.label ?? 'USERS', {
                    size: 24, weight: 600, spacing: 3, transform: 'uppercase',
                }),
            ];
        },
    },
    laurel_rating: {
        icon: Trophy,
        build: (f, ctx) => {
            const gid = newId();
            const valueId = newId();
            const value = f.value ?? '4.5k+';
            const cw = ctx.canvasWidth ?? 1320;
            const w = measureTextWidth(value, 100, 800);
            const lf = laurelFronds();
            const fh = 409;
            const fw = rnd((fh * lf.viewBox[0]) / lf.viewBox[1]);
            return [
                group(gid, 'Laurel rating'),
                laurelFrondLayer(gid, 'left', valueId, w, cw, fw, fh, lf.left, lf.viewBox),
                laurelFrondLayer(gid, 'right', valueId, w, cw, fw, fh, lf.right, lf.viewBox),
                label(gid, 'Stars', box(50, 6.4, 340, 36, true), '★ ★ ★ ★ ★', { size: 30, weight: 600, color: '#C9A227', spacing: 2 }),
                laurelValueLayer(gid, valueId, value),
                label(gid, 'Label', box(50, 13.2, 360, 30, true), f.label ?? 'REVIEWS', {
                    size: 24, weight: 600, spacing: 3, transform: 'uppercase',
                }),
            ];
        },
    },
};

/** Materialize an element to a fresh group + children, ready for `addLayers`.
 *  `ctx.canvasWidth` enables content-aware spacing (e.g. laurel gap). */
export function materializeElement(
    key: string,
    fields: Record<string, string> = {},
    ctx: ElementCtx = {},
): Layer[] {
    return ELEMENTS[key]?.build(fields ?? {}, ctx) ?? [];
}

/** Catalog manifest (key/label/fields) — shared with the server + AI prompt. */
export const ELEMENT_CATALOG = catalog as Array<{
    key: string;
    label: string;
    description: string;
    fields: Array<{ name: string; example: string }>;
}>;
