// glyph-core.jsx — the Glyph primitive, its geometry, and the panel controls.
//
// Everything presentational is pure, so one Glyph definition serves the big
// specimen, the in-situ plates, and the exported SVG. Preview and export share
// glyphGeometry() rather than each doing the maths — they used to duplicate it,
// which is exactly how a download silently stops matching what's on screen.

// Fonts offered for text glyphs. These are the *user's* choice for their mark,
// not the page UI. Georgia and the mono stack resolve on any machine, which
// matters because an exported <text> element references the family by name —
// see the caveat under Export.
const FONT_STACK = {
  display: "'Big Shoulders Display', Impact, sans-serif",
  sans: "'Archivo', ui-sans-serif, system-ui, sans-serif",
  serif: "Georgia, 'Times New Roman', serif",
  mono: "ui-monospace, Menlo, Consolas, monospace",
};

// Inlined so the tool paints a real glyph on first frame; any other slug is
// fetched on demand.
const ICON_BODIES = {
  "chart-no-axes-gantt": '<path d="M8 6h10"/><path d="M6 12h9"/><path d="M11 18h7"/>',
};
const FALLBACK_ICON = { slug: "chart-no-axes-gantt", body: ICON_BODIES["chart-no-axes-gantt"] };

// Named pigments — an exported asset needs literal colours, never theme tokens.
const COLOR_OPTIONS = [
  { value: "#000000", name: "Black" },
  { value: "#ffffff", name: "White" },
  { value: "#be2a27", name: "Minium" },
  { value: "#d8a23a", name: "Ochre" },
  { value: "#2b5f6e", name: "Verdigris" },
];

// Autocomplete for the icon field. Someone arriving from a search for "favicon
// generator" has never heard of Lucide and has no way to guess a slug; this
// turns a blank prompt into a menu. Every entry verified against the package
// index — a suggestion that 404s is worse than no suggestion.
const SUGGESTED_ICONS = [
  "anchor", "aperture", "atom", "award", "book", "box", "brain", "camera", "circle",
  "cloud", "code", "coffee", "compass", "crown", "diamond", "eye", "feather", "flag",
  "flame", "globe", "heart", "hexagon", "house", "key", "layers", "leaf", "lightbulb",
  "lock", "map-pin", "moon", "music", "palette", "rocket", "scissors", "shield",
  "sparkles", "star", "sun", "target", "terminal", "triangle", "umbrella", "waves",
  "wrench", "zap",
];

const POLY_NAMES = {
  3: "Triangle", 4: "Square", 5: "Pentagon", 6: "Hexagon", 7: "Heptagon",
  8: "Octagon", 9: "Nonagon", 10: "Decagon", 11: "Hendecagon", 12: "Dodecagon",
};

// ── colour helpers ───────────────────────────────────────────────────────────
function normColor(c) {
  return String(c == null ? "" : c).trim().toLowerCase().replace(/\s+/g, " ");
}
function colorIsLight(c) {
  const s = normColor(c);
  if (s.startsWith("#")) {
    const h = s.slice(1);
    const x = h.length === 3 ? h.replace(/./g, (d) => d + d) : h.padEnd(6, "0");
    const n = parseInt(x.slice(0, 6), 16);
    if (Number.isNaN(n)) return true;
    const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
    return r * 299 + g * 587 + b * 114 > 148000;
  }
  const m = s.match(/oklch\(\s*([\d.]+)(%?)/);
  if (m) { let l = parseFloat(m[1]); if (m[2] === "%") l /= 100; return l > 0.62; }
  return true;
}
// WCAG contrast between two hex colours. The tool's whole promise is that a
// mark survives being small; a mark that vanishes into its own background
// fails that before size is even a factor. Returns null for anything it can't
// parse rather than a misleading number.
function contrastRatio(a, b) {
  const lum = (c) => {
    const s = normColor(c);
    if (!s.startsWith("#")) return null;
    let h = s.slice(1);
    if (h.length === 3) h = h.replace(/./g, (d) => d + d);
    if (h.length !== 6) return null;
    const n = parseInt(h, 16);
    if (Number.isNaN(n)) return null;
    const f = (v) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4);
    const [r, g, bl] = [(n >> 16) & 255, (n >> 8) & 255, n & 255].map((v) => f(v / 255));
    return 0.2126 * r + 0.7152 * g + 0.0722 * bl;
  };
  const la = lum(a), lb = lum(b);
  if (la == null || lb == null) return null;
  const [hi, lo] = la > lb ? [la, lb] : [lb, la];
  return (hi + 0.05) / (lo + 0.05);
}
// WCAG's non-text floor. Icons and shapes are graphics, not body copy.
const MIN_MARK_CONTRAST = 3;

const colorName = (c) => {
  const hit = COLOR_OPTIONS.find((o) => normColor(o.value) === normColor(c));
  return hit ? hit.name : String(c).toUpperCase();
};
const Check = ({ light, size = 15 }) => (
  <svg viewBox="0 0 14 14" width={size} height={size} aria-hidden="true">
    <path d="M3 7.2 5.8 10 11 4.2" fill="none" strokeWidth="2.2"
          strokeLinecap="round" strokeLinejoin="round"
          stroke={light ? "rgba(0,0,0,.85)" : "#fff"} />
  </svg>
);

// ── geometry ─────────────────────────────────────────────────────────────────
function polygonPoints(cx, cy, r, sides, orientation, rotationDeg) {
  const step = 360 / sides;
  const base = orientation === "pointy" ? -90 : -90 + step / 2;
  const pts = [];
  for (let i = 0; i < sides; i++) {
    const a = ((base + rotationDeg + i * step) * Math.PI) / 180;
    pts.push([cx + r * Math.cos(a), cy + r * Math.sin(a)]);
  }
  return pts.map(([x, y]) => `${x.toFixed(2)},${y.toFixed(2)}`).join(" ");
}

// The single source of truth for the shape. Corner radius and edge inset scale
// with size, so a 16px favicon keeps the hero's proportions instead of
// collapsing when an absolute radius exceeds the tile.
function glyphGeometry({ size, sides, orientation, rotation, cornerRadius, contentSize }) {
  const scale = size / 280;
  const cr = cornerRadius * scale;
  const r = size / 2 - (cr / 2 + 6 * scale);
  const cx = size / 2, cy = size / 2;
  const rendered = contentSize * scale;
  return { cx, cy, cr, rendered, iconScale: rendered / 24, pts: polygonPoints(cx, cy, r, sides, orientation, rotation) };
}

// One sentence describing the current mark, for the preview's accessible name.
function describeGlyph(o) {
  const shape = POLY_NAMES[o.sides] || `${o.sides}-sided polygon`;
  const face = o.orientation === "pointy" ? "point up" : "flat top";
  const turn = o.rotation ? `, rotated ${o.rotation} degrees` : "";
  const inner = o.mode === "text"
    ? (o.text || "").trim() ? `the letters “${o.text}”` : "no content"
    : `the ${o.iconSlug} icon`;
  return `${shape}, ${face}${turn}, in ${colorName(o.bg)}, containing ${inner} in ${colorName(o.contentColor)}.`;
}

// ── live lucide fetch ────────────────────────────────────────────────────────
function extractSlug(raw) {
  if (!raw) return null;
  const s = String(raw).trim();
  if (!s) return null;
  if (/^[a-z0-9][a-z0-9-]*$/i.test(s)) return s.toLowerCase();
  try {
    const u = new URL(s);
    const m = u.pathname.match(/\/icons?\/([a-z0-9-]+)(?:\.svg)?\/?$/i);
    if (m) return m[1].toLowerCase();
  } catch (_) {}
  const tail = s.split(/[?#]/)[0].split("/").filter(Boolean).pop();
  if (tail && /^[a-z0-9-]+(?:\.svg)?$/i.test(tail)) return tail.replace(/\.svg$/i, "").toLowerCase();
  return null;
}
const __iconCache = new Map();
async function fetchLucideIcon(slug) {
  if (!slug) return null;
  if (__iconCache.has(slug)) return __iconCache.get(slug);
  if (ICON_BODIES[slug]) { const o = { slug, body: ICON_BODIES[slug] }; __iconCache.set(slug, o); return o; }
  const res = await fetch(`https://unpkg.com/lucide-static@latest/icons/${slug}.svg`);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  const doc = new DOMParser().parseFromString(await res.text(), "image/svg+xml");
  if (doc.querySelector("parsererror")) throw new Error("parse");
  const svg = doc.querySelector("svg");
  if (!svg) throw new Error("no svg");
  const parts = Array.from(svg.children).map((el) => {
    const clone = el.cloneNode(true);
    const strip = (node) => {
      if (node.nodeType !== 1) return;
      ["stroke", "fill", "stroke-width", "stroke-linecap", "stroke-linejoin"].forEach((a) => node.removeAttribute(a));
      Array.from(node.childNodes).forEach(strip);
    };
    strip(clone);
    return new XMLSerializer().serializeToString(clone);
  });
  const out = { slug, body: parts.join("") };
  __iconCache.set(slug, out);
  return out;
}
// Keeps the last good icon while a new one loads, so the stage never blanks.
function useLucideIcon(url) {
  const [state, setState] = React.useState({ icon: FALLBACK_ICON, status: "ok", error: null });
  React.useEffect(() => {
    const slug = extractSlug(url);
    if (!slug) { setState((s) => ({ ...s, status: "error", error: "Not a Lucide icon name or URL" })); return; }
    let cancelled = false;
    setState((s) => ({ ...s, status: "loading", error: null, pending: slug }));
    fetchLucideIcon(slug)
      .then((icon) => { if (!cancelled) setState({ icon, status: "ok", error: null }); })
      .catch(() => { if (!cancelled) setState((s) => ({ ...s, status: "error", error: `Couldn't load “${slug}”` })); });
    return () => { cancelled = true; };
  }, [url]);
  return state;
}

// ── persisted state ──────────────────────────────────────────────────────────
function useSettings(defaults, key = "glyphforge:v3") {
  const [values, setValues] = React.useState(() => {
    try { const s = JSON.parse(localStorage.getItem(key)); return s ? { ...defaults, ...s } : defaults; }
    catch (_) { return defaults; }
  });
  const set = React.useCallback((k, val) => {
    setValues((prev) => {
      const next = (typeof k === "object" && k !== null) ? { ...prev, ...k } : { ...prev, [k]: val };
      try { localStorage.setItem(key, JSON.stringify(next)); } catch (_) {}
      return next;
    });
  }, [key]);
  return [values, set];
}

// Live, not read once — someone can flip the OS setting while the page is open.
function usePrefersReducedMotion() {
  const [reduced, setReduced] = React.useState(
    () => window.matchMedia("(prefers-reduced-motion: reduce)").matches
  );
  React.useEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    const on = () => setReduced(mq.matches);
    mq.addEventListener("change", on);
    return () => mq.removeEventListener("change", on);
  }, []);
  return reduced;
}

// "system" | "light" | "dark". The inline script in <head> applies the stored
// value before first paint; this only has to keep it in sync afterwards.
function useTheme(key = "glyphforge:theme") {
  const [theme, setTheme] = React.useState(() => {
    try { return localStorage.getItem(key) || "system"; } catch (_) { return "system"; }
  });
  React.useEffect(() => {
    if (theme === "system") delete document.documentElement.dataset.theme;
    else document.documentElement.dataset.theme = theme;
    try {
      if (theme === "system") localStorage.removeItem(key);
      else localStorage.setItem(key, theme);
    } catch (_) {}
  }, [theme, key]);
  return [theme, setTheme];
}

// ── The Glyph ────────────────────────────────────────────────────────────────
function Glyph({
  size = 256, bg, contentColor,
  sides = 6, orientation = "flat", rotation = 0,
  cornerRadius = 22, strokeWidth = 2, contentSize = 128,
  mode = "icon", iconBody = FALLBACK_ICON.body,
  text = "A", font = "display", bold = false, italic = false,
  title,
}) {
  const g = glyphGeometry({ size, sides, orientation, rotation, cornerRadius, contentSize });

  const payload = mode === "text" ? (
    <text x={g.cx} y={g.cy} textAnchor="middle" dominantBaseline="central"
          fontFamily={FONT_STACK[font] || FONT_STACK.display} fontSize={g.rendered}
          fontWeight={bold ? 700 : 400} fontStyle={italic ? "italic" : "normal"}
          fill={contentColor}>{text}</text>
  ) : (
    <g transform={`translate(${g.cx - g.rendered / 2} ${g.cy - g.rendered / 2}) scale(${g.iconScale})`}>
      <g fill="none" stroke={contentColor} strokeWidth={strokeWidth}
         strokeLinecap="round" strokeLinejoin="round"
         dangerouslySetInnerHTML={{ __html: iconBody }} />
    </g>
  );

  return (
    <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}
         xmlns="http://www.w3.org/2000/svg"
         role={title ? "img" : "presentation"} aria-label={title || undefined}
         aria-hidden={title ? undefined : "true"}>
      <polygon points={g.pts} fill={bg} stroke={bg} strokeWidth={g.cr}
               strokeLinejoin="round" strokeLinecap="round" paintOrder="stroke fill" />
      {payload}
    </svg>
  );
}

// ── SVG export ───────────────────────────────────────────────────────────────
function buildSvgString(o) {
  const g = glyphGeometry(o);
  let payload;
  if (o.mode === "text") {
    const esc = (s) => String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
    payload =
      `<text x="${g.cx}" y="${g.cy}" text-anchor="middle" dominant-baseline="central" ` +
      `font-family="${FONT_STACK[o.font] || FONT_STACK.display}" font-size="${g.rendered}" ` +
      `font-weight="${o.bold ? 700 : 400}" font-style="${o.italic ? "italic" : "normal"}" ` +
      `fill="${o.contentColor}">${esc(o.text)}</text>`;
  } else {
    payload =
      `<g transform="translate(${g.cx - g.rendered / 2} ${g.cy - g.rendered / 2}) scale(${g.iconScale})">` +
      `<g fill="none" stroke="${o.contentColor}" stroke-width="${o.strokeWidth}" ` +
      `stroke-linecap="round" stroke-linejoin="round">${o.iconBody}</g></g>`;
  }
  return [
    `<svg xmlns="http://www.w3.org/2000/svg" width="${o.size}" height="${o.size}" viewBox="0 0 ${o.size} ${o.size}">`,
    `<polygon points="${g.pts}" fill="${o.bg}" stroke="${o.bg}" stroke-width="${g.cr}" stroke-linejoin="round" stroke-linecap="round" paint-order="stroke fill"/>`,
    payload,
    `</svg>`,
  ].join("");
}
function saveBlob(filename, blob) {
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url; a.download = filename;
  document.body.appendChild(a); a.click(); document.body.removeChild(a);
  setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function downloadSvg(filename, svgString) {
  saveBlob(filename, new Blob([svgString], { type: "image/svg+xml;charset=utf-8" }));
}

// PNG sizes worth offering: a favicon, a touch icon, a store tile, and a
// master. Arbitrary pixel values help nobody — these are the ones platforms
// actually ask for.
const PNG_SIZES = [64, 256, 512, 1024];

// Rasterise by drawing the SVG into a canvas. The SVG is self-contained (paths
// and text, no external refs) so the canvas stays untainted and toBlob works.
//
// Caveat that matters: an <image>-loaded SVG renders in an isolated context
// with no access to this document's webfonts, so a text glyph rasterises in a
// fallback face. buildPngBlob reports that back rather than silently shipping
// the wrong letterforms.
function buildPngBlob(svgString, size) {
  return new Promise((resolve, reject) => {
    const url = URL.createObjectURL(new Blob([svgString], { type: "image/svg+xml;charset=utf-8" }));
    const img = new Image();
    img.onload = () => {
      const canvas = document.createElement("canvas");
      canvas.width = canvas.height = size;
      const ctx = canvas.getContext("2d");
      ctx.drawImage(img, 0, 0, size, size);
      URL.revokeObjectURL(url);
      canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error("toBlob returned nothing"))), "image/png");
    };
    img.onerror = () => { URL.revokeObjectURL(url); reject(new Error("SVG could not be rasterised")); };
    img.src = url;
  });
}

// ── Panel controls ───────────────────────────────────────────────────────────
function CSection({ label, children }) {
  return <section className="sec"><h2>{label}</h2>{children}</section>;
}
function CRow({ id, label, value, valueBad, children }) {
  return (
    <div className="row">
      <div className="head">
        <label className="k" htmlFor={id}>{label}</label>
        {value != null && <span className={valueBad ? "v bad" : "v"} title={String(value)}>{value}</span>}
      </div>
      {children}
    </div>
  );
}
function CSlider({ label, value, min, max, step = 1, unit = "", onChange }) {
  const id = React.useId();
  const pct = ((value - min) / (max - min)) * 100;
  return (
    <CRow id={id} label={label} value={`${value}${unit}`}>
      <input id={id} type="range" className="rng" min={min} max={max} step={step} value={value}
             style={{ "--pct": `${pct}%` }}
             onChange={(e) => onChange(Number(e.target.value))} />
    </CRow>
  );
}
// Exclusive choice — real radios, so arrow keys work without any JS.
function CSeg({ label, value, options, onChange, mini, hideLabel }) {
  const name = React.useId();
  const group = (
    <div className={mini ? "seg mini" : "seg"}>
      {options.map((o) => (
        <label key={o.value}>
          <input type="radio" name={name} value={o.value}
                 checked={o.value === value} onChange={() => onChange(o.value)} />
          {o.label}
        </label>
      ))}
    </div>
  );
  if (hideLabel) return <div role="group" aria-label={label}>{group}</div>;
  return (
    <div className="row">
      <div className="head"><span className="k" id={`${name}-l`}>{label}</span></div>
      <div role="group" aria-labelledby={`${name}-l`}>{group}</div>
    </div>
  );
}
// Independent toggles, so pressed buttons rather than radios.
function CFormat({ bold, italic, onBold, onItalic }) {
  return (
    <div className="row">
      <div className="head"><span className="k">Style</span></div>
      <div className="toggles">
        <button type="button" className="tg b" aria-pressed={bold} onClick={() => onBold(!bold)}>Bold</button>
        <button type="button" className="tg i" aria-pressed={italic} onClick={() => onItalic(!italic)}>Italic</button>
      </div>
    </div>
  );
}
function CText({ label, value, placeholder, hint, hintBad, describedBy, options, after, onChange }) {
  const id = React.useId();
  const listId = options ? `${id}-list` : undefined;
  return (
    <CRow id={id} label={label} value={hint} valueBad={hintBad}>
      <input id={id} className="txt" type="text" value={value} placeholder={placeholder}
             spellCheck={false} aria-describedby={describedBy} list={listId}
             onChange={(e) => onChange(e.target.value)} />
      {options && (
        <datalist id={listId}>
          {options.map((o) => <option key={o} value={o} />)}
        </datalist>
      )}
      {after}
    </CRow>
  );
}
// Named swatches plus a custom picker. Selection is marked by a checkmark as
// well as a border, so colour is never the only signal.
function CColor({ label, value, onChange }) {
  const preset = COLOR_OPTIONS.find((o) => normColor(o.value) === normColor(value));
  const [open, setOpen] = React.useState(!preset);
  const [draft, setDraft] = React.useState(preset ? "#888888" : value);
  const hexId = React.useId();
  React.useEffect(() => { if (!preset) setDraft(value); }, [value, preset]);

  const commit = (raw) => {
    let v = String(raw).trim();
    if (/^#?[0-9a-f]{3}$/i.test(v) || /^#?[0-9a-f]{6}$/i.test(v)) {
      onChange(v.startsWith("#") ? v : "#" + v);
    }
  };
  const swatchVal = /^#[0-9a-f]{6}$/i.test(draft) ? draft : "#888888";

  return (
    <div className="row">
      <div className="head">
        <span className="k">{label}</span>
        <span className="v">{colorName(value)}</span>
      </div>
      <div className="swatches" role="group" aria-label={label}>
        {COLOR_OPTIONS.map((o) => {
          const on = normColor(o.value) === normColor(value);
          return (
            <button key={o.value} type="button" className="sw" aria-pressed={on}
                    aria-label={o.name} style={{ background: o.value }}
                    onClick={() => onChange(o.value)}>
              {on && <Check light={colorIsLight(o.value)} />}
            </button>
          );
        })}
        <button type="button" className="sw custom" aria-pressed={!preset}
                aria-expanded={open} aria-label="Custom colour"
                style={!preset ? { background: value } : undefined}
                onClick={() => setOpen((s) => !s)}>
          {preset
            ? <svg viewBox="0 0 16 16" width="15" height="15" aria-hidden="true">
                <path d="M8 3v10M3 8h10" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
              </svg>
            : <Check light={colorIsLight(value)} />}
        </button>
      </div>
      {open && (
        <div className="hexrow">
          <input type="color" value={swatchVal} aria-label="Pick a colour"
                 onChange={(e) => { setDraft(e.target.value); onChange(e.target.value); }} />
          <label className="vh" htmlFor={hexId}>Hex value</label>
          <input id={hexId} type="text" className="txt" value={draft} placeholder="#RRGGBB" spellCheck={false}
                 onChange={(e) => { setDraft(e.target.value); commit(e.target.value); }} />
        </div>
      )}
    </div>
  );
}

// ── Wordmark mark ────────────────────────────────────────────────────────────
// Counts up through side counts — diamond, pentagon, hexagon, heptagon,
// octagon — advancing the pigment after each full pass. It is the product
// demonstrating itself in the corner of its own interface.
//
// Deliberately slow: a mark that ticks faster than this competes with the
// specimen for attention, and the whole page exists to let you look at that.
const MARK_SIDES = [4, 5, 6, 7, 8];
const MARK_INKS = ["var(--mark-a)", "var(--mark-b)", "var(--mark-c)"];
const MARK_MS = 1900;

function WordmarkMark() {
  const reduced = usePrefersReducedMotion();
  const [step, setStep] = React.useState(0);

  React.useEffect(() => {
    if (reduced) return;
    const id = setInterval(() => setStep((n) => n + 1), MARK_MS);
    return () => clearInterval(id);
  }, [reduced]);

  // Reduced motion gets the resting state, not a frozen arbitrary frame.
  const sides = reduced ? 6 : MARK_SIDES[step % MARK_SIDES.length];
  const ink = reduced
    ? MARK_INKS[0]
    : MARK_INKS[Math.floor(step / MARK_SIDES.length) % MARK_INKS.length];

  return (
    <svg className="mark" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
      {/* fill goes through style, not the presentation attribute — var() is only
          substituted in a real CSS declaration. */}
      <polygon points={polygonPoints(12, 12, 11, sides, "pointy", 0)} style={{ fill: ink }} />
    </svg>
  );
}

Object.assign(window, {
  FONT_STACK, ICON_BODIES, FALLBACK_ICON, COLOR_OPTIONS, POLY_NAMES,
  normColor, colorIsLight, colorName, Check, contrastRatio, MIN_MARK_CONTRAST, SUGGESTED_ICONS,
  polygonPoints, glyphGeometry, describeGlyph, WordmarkMark,
  useLucideIcon, useSettings, useTheme, usePrefersReducedMotion,
  Glyph, buildSvgString, downloadSvg, saveBlob, buildPngBlob, PNG_SIZES,
  CSection, CRow, CSlider, CSeg, CFormat, CText, CColor,
});
