// CardPreview — the single SVG rendering implementation for the QSL card.
// Proposals, the live editor, thumbnails and final rasterization ALL go
// through this component so the output can never drift from the preview.
// Style presets are layer-stack recipes (see internal/qslcard/presets.go and
// section 4 of the design spec); the gel stack renders the text 7 times,
// back to front: halo, drop shadow, outline, bevel base, bevel light edge,
// gradient face, wavy gloss band clipped to the glyphs.

import { useEffect, useMemo, useRef, useState } from 'react';
import type { RenderModel, CardElement, StyleParams, QSOBox } from './qslTypes';
import { QSO_FIELD_LABELS } from './qslTypes';
import type { CardAssets } from './qslAssets';
import { FONT_WEIGHTS } from './qslAssets';
import { renderTextFx, type TextFxParams, type TextFxKind, type TextFxResult } from './textFx';

// An editor selection: an element index or the QSO box.
export type CardSelection = number | 'box' | null;

// Fraction of the font size between a glyph's em-box top and its cap line.
// Used to make a text element's y align to the visible letter tops.
const CAP_INSET = 0.18;

interface Props {
  model: RenderModel;
  assets: CardAssets;
  width: number; // display width in CSS px
  selected?: CardSelection;
  onSelect?: (sel: CardSelection) => void;
  onMove?: (sel: Exclude<CardSelection, null>, x: number, y: number) => void;
  svgRef?: (el: SVGSVGElement | null) => void;
}

// Fallbacks mirroring the preset defaults in presets.go, applied when a
// document omits a knob the stack needs.
const GOLD = ['#FFD83A', '#FFC312', '#EE9400'];
const DEF_SHINE = { coverage: 0.5, opacity: 0.95 };
const DEF_HALO = { color: '#cdd9e4', blur: 6, opacity: 0.4 };
const DEF_SHADOW = { dx: 5, dy: 8, blur: 5, color: '#14243a', opacity: 0.5 };
const DEF_BEVEL = { dx: -2, dy: -4, dark: '#C27500', light: '#FFEFA0' };

// fontSpec maps a template font name to a concrete family + weight.
// "system-bold-sans" is the info-line workhorse: heavy UI sans, no embed.
function fontSpec(font?: string): { family: string; weight: number | 'normal' } {
  if (!font || font === 'system-bold-sans') {
    return { family: "'Segoe UI', Arial, sans-serif", weight: 800 };
  }
  return { family: `'${font}'`, weight: FONT_WEIGHTS[font] ?? 'normal' };
}

// Presets whose call text is rendered with the ported canvas FX (the only way
// to get the exact glossy/western look). Everything else stays plain SVG text.
const FX_GLOSSY = new Set(['gel_gold', 'gel_silver']);
const FX_WESTERN = new Set(['gel_gold_grunge', 'gel_silver_grunge']);
function fxKindFor(preset?: string): TextFxKind | null {
  if (preset && FX_GLOSSY.has(preset)) return 'glossy';
  if (preset && FX_WESTERN.has(preset)) return 'western';
  return null;
}
function buildFxParams(e: CardElement): TextFxParams | null {
  const kind = fxKindFor(e.style_preset);
  if (!kind || !e.size) return null;
  const fs = fontSpec(e.font);
  const sp = e.style_params ?? {};
  const fx = sp.fx ?? {};
  const grad = sp.gradient ?? (kind === 'western' ? ['#FFC83A', '#F0A21E', '#D87A00'] : ['#ffe22d', '#ffd600', '#ffcc00']);
  const silver = e.style_preset?.includes('silver');
  return {
    kind,
    text: e.text ?? '',
    weight: String(fs.weight === 'normal' ? 400 : fs.weight),
    family: fs.family,
    size: e.size,
    space: e.size * (kind === 'western' ? 0.08 : 0.04),
    cTop: grad[0], cMid: grad[1] ?? grad[0], cBot: grad[2] ?? grad[1] ?? grad[0],
    // Dark inter-letter edge + rim — from the chosen colour palette, else the
    // default near-black edge (the navy outline adaptStyle sets is for the old
    // SVG stack, not this look).
    cDark: fx.dark ?? (kind === 'western' ? '#1c130a' : '#262630'),
    cOuter: fx.outer ?? (silver ? '#e8edf2' : '#ced3db'),
    // Per-call overrides from the editor (undefined → renderer default).
    plump: fx.plump, edge: fx.edge, outerw: fx.outerw, gloss: fx.gloss,
    glossH: fx.gloss_h, glossI: fx.gloss_i, innerB: fx.inner_b,
    depth: fx.depth, angle: fx.angle, slant: fx.slant, grunge: fx.grunge,
    bevel: fx.bevel, seed: fx.seed,
  };
}
// Stable signature of an element's FX-relevant fields, so dragging (x/y only)
// doesn't trigger an expensive re-render of the canvas text.
function fxSignature(e: CardElement): string {
  if (!fxKindFor(e.style_preset)) return '';
  return [e.text, e.font, e.size, e.style_preset, (e.style_params?.gradient ?? []).join(','), JSON.stringify(e.style_params?.fx ?? {})].join('|');
}

function elementTransform(e: CardElement): string {
  const r = e.rotate ? ` rotate(${e.rotate})` : '';
  return `translate(${e.x} ${e.y})${r}`;
}

// Shared <text> layer of a style stack. All layers must carry identical
// glyph geometry — only paint/transform/filter differ.
function Layer({
  e, content, fill, stroke, strokeWidth, transform, filter, opacity, paintOrder,
}: {
  e: CardElement;
  content: string;
  fill: string;
  stroke?: string;
  strokeWidth?: number;
  transform?: string;
  filter?: string;
  opacity?: number;
  paintOrder?: 'stroke';
}) {
  const f = fontSpec(e.font);
  return (
    <text
      x={0}
      y={0}
      fontFamily={f.family}
      fontWeight={f.weight}
      fontSize={e.size}
      dominantBaseline="text-before-edge"
      fill={fill}
      stroke={stroke}
      strokeWidth={strokeWidth}
      strokeLinejoin="round"
      paintOrder={paintOrder}
      transform={transform}
      filter={filter}
      opacity={opacity}
      style={{ userSelect: 'none' }}
    >
      {content}
    </text>
  );
}

// glossWavePath builds the gel highlight band: full glyph width, bottom
// edge a gentle quadratic wave (amplitude ≈ 4.5% of the font size, period
// ≈ 25% — i.e. ~10 px and ~55 px at the 220 px design size). The wavy line
// is what produces the "gel" look; the band is clipped to the glyphs.
function glossWavePath(e: CardElement, content: string, coverage: number): string {
  const size = e.size ?? 100;
  const w = 0.78 * size * Math.max(content.length, 1);
  const capTop = 0.16 * size; // cap height starts below the em-box top
  const bottom = capTop + coverage * 0.72 * size;
  const amp = 0.045 * size;
  const period = 0.25 * size;
  let d = `M 0 0 H ${w.toFixed(1)} V ${bottom.toFixed(1)}`;
  let i = 0;
  for (let x = w; x > 0.1; x -= period, i++) {
    const x2 = Math.max(0, x - period);
    const mid = (x + x2) / 2;
    const dy = i % 2 === 0 ? amp : -amp;
    d += ` Q ${mid.toFixed(1)} ${(bottom + dy).toFixed(1)} ${x2.toFixed(1)} ${bottom.toFixed(1)}`;
  }
  return d + ' Z';
}

// TextStack renders one text element through its style preset.
function TextStack({ e, idx, content }: { e: CardElement; idx: number; content: string }) {
  const p: StyleParams = e.style_params ?? {};
  const preset = e.style_preset ?? 'flat_modern';
  const ow = p.outline_width ?? 0;

  if (preset === 'flat_modern') {
    return <Layer e={e} content={content} fill={p.color ?? '#ffffff'} />;
  }
  if (preset === 'script_white' || preset === 'outlined_white') {
    return (
      <>
        {p.shadow && (
          <Layer
            e={e} content={content} fill={p.shadow.color} stroke={p.shadow.color}
            strokeWidth={2} transform={`translate(${p.shadow.dx} ${p.shadow.dy})`}
            filter={`url(#qsl-blur-sh-${idx})`} opacity={p.shadow.opacity}
          />
        )}
        <Layer
          e={e} content={content} fill={p.color ?? '#ffffff'}
          stroke={p.outline_color ?? '#22364e'} strokeWidth={ow || 2} paintOrder="stroke"
        />
      </>
    );
  }

  // Gel family + classic white outline: the layered stack.
  const gradient = p.gradient ?? GOLD;
  const shadow = p.shadow ?? DEF_SHADOW;
  const outline = p.outline_color ?? '#2a3f5c';
  const outlineW = ow || 9;
  const isGrunge = preset === 'gel_gold_grunge' || preset === 'gel_silver_grunge';
  const isGel = preset === 'gel_gold' || preset === 'gel_silver' || isGrunge;
  const halo = p.halo ?? DEF_HALO;
  const bevel = p.bevel_offset ?? DEF_BEVEL;
  const shine = p.shine ?? DEF_SHINE;
  const faceT = isGel ? `translate(${bevel.dx} ${bevel.dy})` : undefined;
  return (
    <>
      {isGel && (
        <Layer
          e={e} content={content} fill="none" stroke={halo.color}
          strokeWidth={outlineW * 2.5} filter={`url(#qsl-blur-halo-${idx})`} opacity={halo.opacity}
        />
      )}
      <Layer
        e={e} content={content} fill={shadow.color} stroke={shadow.color} strokeWidth={2}
        transform={`translate(${shadow.dx} ${shadow.dy})`}
        filter={`url(#qsl-blur-sh-${idx})`} opacity={shadow.opacity}
      />
      <Layer
        e={e} content={content} fill={outline} stroke={outline}
        strokeWidth={outlineW} paintOrder="stroke"
      />
      {isGel && <Layer e={e} content={content} fill={bevel.dark} />}
      {isGel && (
        <Layer
          e={e} content={content} fill={bevel.light}
          transform={`translate(${2 * bevel.dx} ${2 * bevel.dy})`}
        />
      )}
      <Layer e={e} content={content} fill={`url(#qsl-grad-${idx})`} transform={faceT} />
      {isGel && (
        <path
          d={glossWavePath(e, content, shine.coverage)}
          fill={`url(#qsl-gloss-${idx})`}
          clipPath={`url(#qsl-clip-${idx})`}
          transform={faceT}
          opacity={shine.opacity}
        />
      )}
      {/* Distressed/vintage speckle, clipped to the glyphs (HS0ZLE look). */}
      {isGrunge && (
        <rect
          x={0} y={0}
          width={0.82 * (e.size ?? 0) * Math.max(content.length, 1)}
          height={(e.size ?? 0) * 1.05}
          fill="#241405"
          filter={`url(#qsl-grunge-${idx})`}
          clipPath={`url(#qsl-clip-${idx})`}
          transform={faceT}
          opacity={0.6}
        />
      )}
      {/* defs that belong to this stack (unique ids per element index) */}
      <defs>
        <linearGradient id={`qsl-grad-${idx}`} x1="0" y1="0" x2="0" y2="1">
          {gradient.map((c, i) => (
            <stop key={i} offset={`${(i / Math.max(gradient.length - 1, 1)) * 100}%`} stopColor={c} />
          ))}
        </linearGradient>
        {isGel && (
          <>
            <linearGradient id={`qsl-gloss-${idx}`} x1="0" y1="0" x2="0" y2="1">
              <stop offset="0%" stopColor="#ffffff" stopOpacity="0.95" />
              <stop offset="100%" stopColor="#ffffff" stopOpacity="0.05" />
            </linearGradient>
            <clipPath id={`qsl-clip-${idx}`}>
              <text
                x={0} y={0} fontFamily={fontSpec(e.font).family} fontWeight={fontSpec(e.font).weight}
                fontSize={e.size} dominantBaseline="text-before-edge"
              >
                {content}
              </text>
            </clipPath>
            <filter id={`qsl-blur-halo-${idx}`} x="-40%" y="-40%" width="180%" height="180%">
              <feGaussianBlur stdDeviation={halo.blur} />
            </filter>
          </>
        )}
        {isGrunge && (
          <filter id={`qsl-grunge-${idx}`} x="-5%" y="-5%" width="110%" height="110%">
            {/* sparse dark specks: fractal noise → threshold into the alpha */}
            <feTurbulence type="fractalNoise" baseFrequency="0.14 0.2" numOctaves={2} seed={9} result="n" />
            <feColorMatrix in="n" type="matrix"
              values="0 0 0 0 0.14  0 0 0 0 0.08  0 0 0 0 0.02  0 0 0 6 -4.2" />
          </filter>
        )}
        <filter id={`qsl-blur-sh-${idx}`} x="-40%" y="-40%" width="180%" height="180%">
          <feGaussianBlur stdDeviation={shadow.blur} />
        </filter>
      </defs>
    </>
  );
}

// QSO box field column weights — the date string is far wider than the rest,
// so equal columns let it spill into the next field. Wider weight = more room.
const QSO_FIELD_WEIGHT: Record<string, number> = {
  qso_date: 2, time_on: 1, band: 1, mode: 1, rst_sent: 0.9, freq: 1.4, submode: 1.1,
};

// QSOBoxView renders the confirmation box with per-QSO values.
function QSOBoxView({ box, values }: { box: QSOBox; values: Record<string, string> }) {
  const fg = box.fg || '#14243a'; // text colour (field labels use it at 0.6 opacity)
  const avail = box.w - 56;
  const total = box.fields.reduce((s, f) => s + (QSO_FIELD_WEIGHT[f] ?? 1), 0) || 1;
  let cursor = 28;
  const cols = box.fields.map((f) => {
    const w = (avail * (QSO_FIELD_WEIGHT[f] ?? 1)) / total;
    const col = { f, x: cursor, w };
    cursor += w;
    return col;
  });
  return (
    <>
      <rect width={box.w} height={box.h} rx={box.radius} fill={box.bg} opacity={box.bg_opacity} />
      <text x={28} y={22} fontSize={34} fontWeight={700} fill={fg}
        fontFamily="'Segoe UI', Arial, sans-serif" dominantBaseline="text-before-edge">
        {box.title}
      </text>
      {cols.map(({ f, x }) => (
        <g key={f} transform={`translate(${Math.round(x)} ${box.h * 0.42})`}>
          <text fontSize={19} fill={fg} opacity={0.6} letterSpacing={1.5}
            fontFamily="'Segoe UI', Arial, sans-serif" dominantBaseline="text-before-edge">
            {(QSO_FIELD_LABELS[f] ?? f).toUpperCase()}
          </text>
          <text y={26} fontSize={28} fontWeight={700} fill={fg}
            fontFamily="'Segoe UI', Arial, sans-serif" dominantBaseline="text-before-edge">
            {values[f] ?? ''}
          </text>
        </g>
      ))}
      {box.footer && (
        <text x={28} y={box.h - 18} fontSize={24} fontStyle="italic" fill={fg} opacity={0.85}
          fontFamily="'Segoe UI', Arial, sans-serif">
          {box.footer}
        </text>
      )}
    </>
  );
}

export function CardPreview({ model, assets, width, selected, onSelect, onMove, svgRef }: Props) {
  const { template: t, qso_fields } = model;
  const card = t.card;
  const scale = card.w / width;
  const rootRef = useRef<SVGSVGElement | null>(null);
  const groupRefs = useRef(new Map<string, SVGGElement>());
  const [selBox, setSelBox] = useState<{ t: string; x: number; y: number; w: number; h: number } | null>(null);
  const drag = useRef<{ sel: Exclude<CardSelection, null>; sx: number; sy: number; ox: number; oy: number } | null>(null);
  const measureCanvas = useRef<HTMLCanvasElement | null>(null);

  // Embedded fonts may still be loading when the card first mounts; the canvas
  // FX needs them for correct metrics, so (re)render once they're ready.
  const [fontsReady, setFontsReady] = useState(false);
  useEffect(() => {
    let alive = true;
    const ready = (document as Document & { fonts?: FontFaceSet }).fonts?.ready ?? Promise.resolve();
    void ready.then(() => { if (alive) setFontsReady(true); });
    return () => { alive = false; };
  }, [assets]);

  // Canvas-rendered call text (glossy/western), keyed on the FX-relevant fields
  // so dragging doesn't regenerate it. Maps element index → PNG + size.
  const fxSig = t.elements.map(fxSignature).join(';');
  const fxImages = useMemo(() => {
    const map = new Map<number, TextFxResult>();
    if (!fontsReady) return map;
    t.elements.forEach((e, idx) => {
      const params = buildFxParams(e);
      if (!params) return;
      try { map.set(idx, renderTextFx(params)); } catch { /* leave unrendered */ }
    });
    return map;
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [fxSig, fontsReady]);

  // Measure the selected group for the dashed selection outline. For plain SVG
  // text we use the glyphs' real ink metrics — getBBox would span the font's
  // full ascent/descent. FX call images fall through to getBBox (already tight).
  useEffect(() => {
    if (selected === null || selected === undefined) { setSelBox(null); return; }
    const key = String(selected);
    const g = groupRefs.current.get(key);
    if (!g) { setSelBox(null); return; }
    const transform = g.getAttribute('transform') ?? '';
    const el = typeof selected === 'number' ? t.elements[selected] : undefined;
    const isFx = el ? !!fxKindFor(el.style_preset) : false;
    if (!isFx && el && (el.type === 'callsign' || el.type === 'operator' || el.type === 'info_line') && el.size) {
      const cv = (measureCanvas.current ??= document.createElement('canvas'));
      const ctx = cv.getContext('2d');
      if (ctx) {
        const f = fontSpec(el.font);
        ctx.font = `${f.weight} ${el.size}px ${f.family}`;
        const m = ctx.measureText(el.text ?? '');
        const ascent = m.fontBoundingBoxAscent ?? el.size * 0.8;
        const baselineY = -el.size * CAP_INSET + ascent; // -CAP_INSET matches the render's inner shift
        const inkTop = baselineY - (m.actualBoundingBoxAscent ?? el.size * 0.7);
        const inkBottom = baselineY + (m.actualBoundingBoxDescent ?? 0);
        const left = -(m.actualBoundingBoxLeft ?? 0);
        const right = m.actualBoundingBoxRight ?? m.width;
        setSelBox({ t: transform, x: left, y: inkTop, w: right - left, h: inkBottom - inkTop });
        return;
      }
    }
    const b = g.getBBox();
    setSelBox({ t: transform, x: b.x, y: b.y, w: b.width, h: b.height });
  }, [selected, model, assets, width, t.elements, fxImages]);

  const startDrag = (sel: Exclude<CardSelection, null>, ox: number, oy: number) =>
    (ev: React.PointerEvent<SVGGElement>) => {
      onSelect?.(sel);
      if (!onMove) return;
      ev.stopPropagation();
      drag.current = { sel, sx: ev.clientX, sy: ev.clientY, ox, oy };
      (ev.currentTarget as Element).setPointerCapture(ev.pointerId);
    };

  const onPointerMove = (ev: React.PointerEvent<SVGSVGElement>) => {
    const d = drag.current;
    if (!d || !onMove) return;
    const x = Math.round(d.ox + (ev.clientX - d.sx) * scale);
    const y = Math.round(d.oy + (ev.clientY - d.sy) * scale);
    onMove(d.sel, x, y);
  };
  const endDrag = () => { drag.current = null; };

  const hero = assets.photos[t.hero.photo];
  const crop = t.hero.crop;

  const setGroupRef = (key: string) => (el: SVGGElement | null) => {
    if (el) groupRefs.current.set(key, el);
    else groupRefs.current.delete(key);
  };

  return (
    <svg
      ref={(el) => { rootRef.current = el; svgRef?.(el); }}
      viewBox={`0 0 ${card.w} ${card.h}`}
      width={width}
      height={Math.round(width * (card.h / card.w))}
      xmlns="http://www.w3.org/2000/svg"
      style={{ display: 'block', borderRadius: 4 }}
      onPointerMove={onPointerMove}
      onPointerUp={endDrag}
      onPointerLeave={endDrag}
      onPointerDown={() => onSelect?.(null)}
    >
      <defs>
        {/* Fonts must live INSIDE the SVG so rasterization (SVG → <img> →
            canvas) sees them; document styles don't apply there. */}
        <style>{assets.fontCss}</style>
        <filter id="qsl-insert-shadow" x="-30%" y="-30%" width="160%" height="160%">
          <feDropShadow dx="6" dy="10" stdDeviation="8" floodColor="#0a1422" floodOpacity="0.45" />
        </filter>
      </defs>

      {/* hero photo, source crop mapped onto the full card */}
      <rect width={card.w} height={card.h} fill="#1a2433" />
      {hero && crop.w > 0 && crop.h > 0 && (
        <image
          href={hero.url}
          x={(-crop.x * card.w) / crop.w}
          y={(-crop.y * card.h) / crop.h}
          width={(hero.w * card.w) / crop.w}
          height={(hero.h * card.h) / crop.h}
          preserveAspectRatio="none"
        />
      )}
      {t.hero.scrim?.enabled && (
        <rect width={card.w} height={card.h} fill={t.hero.scrim.color} opacity={t.hero.scrim.opacity} />
      )}

      {t.elements.map((e, idx) => {
        const key = String(idx);
        if (e.hidden) return null; // toggled off in the editor
        if (e.type === 'insert') {
          const ph = assets.photos[e.photo ?? ''];
          if (!ph || !e.w) return null;
          const h = (e.w * ph.h) / ph.w;
          const b = e.border_px ?? 0;
          return (
            <g
              key={key} ref={setGroupRef(key)}
              transform={`translate(${e.x} ${e.y})${e.rotate ? ` rotate(${e.rotate} ${e.w / 2} ${h / 2})` : ''}`}
              onPointerDown={startDrag(idx, e.x, e.y)}
              style={{ cursor: onMove ? 'move' : undefined }}
            >
              <rect x={-b} y={-b} width={e.w + 2 * b} height={h + 2 * b} fill="#ffffff"
                filter={e.shadow ? 'url(#qsl-insert-shadow)' : undefined} />
              <image href={ph.url} width={e.w} height={h} preserveAspectRatio="none" />
            </g>
          );
        }
        if (e.type === 'country') {
          const size = e.size ?? 30;
          const flagH = size * 1.4;
          const flagW = flagH * (4 / 3);
          const hasFlag = !!assets.flagUrl;
          return (
            <g
              key={key} ref={setGroupRef(key)} transform={elementTransform(e)}
              onPointerDown={startDrag(idx, e.x, e.y)}
              style={{ cursor: onMove ? 'move' : undefined }}
            >
              {hasFlag && <image href={assets.flagUrl} width={flagW} height={flagH} />}
              <text
                x={hasFlag ? flagW + 16 : 0} y={flagH / 2}
                fontSize={size} fontWeight={700} fontFamily="'Segoe UI', Arial, sans-serif"
                fill="#ffffff" stroke="#22364e" strokeWidth={4} paintOrder="stroke"
                strokeLinejoin="round" dominantBaseline="middle"
              >
                {e.label === 'auto' ? '' : e.label}
              </text>
            </g>
          );
        }
        // Text elements. Glossy/western calls render through the ported canvas
        // FX as an <image> (its trimmed bounds already give a tight frame, so
        // no CAP_INSET shim). Everything else is plain SVG text; the inner
        // translate pulls the glyphs up to the em-box cap line so the element's
        // y means "top of the visible letters".
        const fx = fxImages.get(idx);
        return (
          <g
            key={key} ref={setGroupRef(key)} transform={elementTransform(e)}
            onPointerDown={startDrag(idx, e.x, e.y)}
            style={{ cursor: onMove ? 'move' : undefined }}
          >
            {fx ? (
              <image href={fx.url} x={0} y={0} width={fx.w} height={fx.h} />
            ) : (
              <g transform={`translate(0 ${-(e.size ?? 0) * CAP_INSET})`}>
                <TextStack e={e} idx={idx} content={e.text ?? ''} />
              </g>
            )}
          </g>
        );
      })}

      {t.qso_box?.enabled && (
        <g
          ref={setGroupRef('box')}
          transform={`translate(${t.qso_box.x} ${t.qso_box.y})`}
          onPointerDown={startDrag('box', t.qso_box.x, t.qso_box.y)}
          style={{ cursor: onMove ? 'move' : undefined }}
        >
          <QSOBoxView box={t.qso_box} values={qso_fields} />
        </g>
      )}

      {/* selection outline (editor only) */}
      {selBox && onSelect && (
        <g transform={selBox.t} pointerEvents="none">
          <rect
            x={selBox.x - 8} y={selBox.y - 8} width={selBox.w + 16} height={selBox.h + 16}
            fill="none" stroke="#38bdf8" strokeWidth={3 * scale > 3 ? 3 * scale : 3}
            strokeDasharray={`${10 * scale} ${6 * scale}`}
          />
        </g>
      )}
    </svg>
  );
}
