scripts/utils.jsjavascript
/**
 * @module utils
 * @description
 * Pure helpers shared across the codebase and exposed to plugin authors via
 * `window.gstmxx`. Three layers, top to bottom:
 *
 *   1. Math primitives — `distance`, `avgPoint`, `lerp`, `scaleFrom`, `point`.
 *      Trivial geometric building blocks. Pure functions, no DOM.
 *   2. Canvas drawing primitives — `drawClosedPath`, `drawOpenPath`,
 *      `drawLabel`, `roundRect`. Wrappers around `CanvasRenderingContext2D`
 *      that codify the project's visual language (label background, dashed
 *      lines, mirrored-text handling).
 *   3. Composed makeup helpers — `expandEyePolygon`, `drawEyeWing`,
 *      `drawCheekSweep`, `drawContourBand`. Built from the lower layers,
 *      these are what most 2D ghostyle plugins reach for.
 *
 * Plus the in-app logging helpers (`formatTime`, `setLog`, `updateLogDisplay`)
 * which write to `state.logsArchive` and rerender the log box.
 *
 * Why this module exists as the public plugin API surface: plugin authors
 * should never need to know about face-api or MediaPipe internals — they
 * should just `Ghostati.drawClosedPath(ctx, points, …)` and have it look
 * consistent with everything else. Keeping the helpers here (and pure) makes
 * them unit-testable without a browser.
 */
import { state } from './state.js';

/**
 * Euclidean distance between two equal-length numeric arrays. Returns
 * `Infinity` for malformed input rather than throwing, because the callers
 * are inference hot paths that prefer a degraded comparison over an
 * exception (an `Infinity` distance simply never wins a min/closest query).
 *
 * @param {number[]} a  First vector.
 * @param {number[]} b  Second vector, same length as `a`.
 * @returns {number} Euclidean distance, or `Infinity` if inputs are invalid.
 * @see scripts/engine.js – `seekFaceInDb()` and `computeCompositeMetrics()`
 *   use this against 128-D face-api descriptors.
 */
export function distance(a, b) {
   if (!a || !b || a.length !== b.length) return Number.POSITIVE_INFINITY;
   let sum = 0;
   for (let i = 0; i < a.length; i += 1) {
      const d = a[i] - b[i];
      sum += d * d;
   }
   return Math.sqrt(sum);
}

/**
 * Centroid of an array of 2D points. Plugin authors use it to anchor labels
 * or shapes at the visual centre of a feature (eye, mouth) without having to
 * reduce manually.
 *
 * @param {Array<{x:number, y:number}>} points  Non-empty array of points.
 * @returns {{x:number, y:number}} The averaged point.
 * @see expandEyePolygon – uses this to locate the eye centre.
 * @see ghostyles/smokey-eyes.js, ghostyles/lip-tint.js – representative
 *   plugin consumers via `Ghostati.avgPoint`.
 */
export function avgPoint(points) {
   const total = points.reduce((acc, p) => ({ x: acc.x + p.x, y: acc.y + p.y }), { x: 0, y: 0 });
   return { x: total.x / points.length, y: total.y / points.length };
}

/**
 * Linear interpolation between two 2D points. `t = 0` returns `a`, `t = 1`
 * returns `b`; values outside `[0, 1]` extrapolate, which is sometimes
 * useful for "slightly past the endpoint" effects (the `splash.js` plugin
 * does this with `t = 1.2` and `t = -0.6` for off-edge anchors).
 *
 * @param {{x:number, y:number}} a  Start point.
 * @param {{x:number, y:number}} b  End point.
 * @param {number} t  Interpolation factor; clamped only by the caller.
 * @returns {{x:number, y:number}} The interpolated point.
 * @see expandEyePolygon, drawEyeWing, drawCheekSweep – internal uses.
 * @see ghostyles/splash.js – extensive use via `Ghostati.lerp` for anchors.
 */
export function lerp(a, b, t) {
   return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
}

/**
 * Scale a point outward from a centre by a uniform factor. `scale > 1`
 * pushes the point away from the centre; `scale < 1` pulls it in. Used to
 * grow or shrink a feature polygon while keeping its centroid fixed.
 *
 * @param {{x:number, y:number}} center  Pivot of the scaling.
 * @param {{x:number, y:number}} point   Point to scale.
 * @param {number} scale                 Multiplicative factor.
 * @returns {{x:number, y:number}} The scaled point.
 * @see expandEyePolygon – the canonical caller, used to enlarge eye outlines.
 */
export function scaleFrom(center, point, scale) {
   return { x: center.x + (point.x - center.x) * scale, y: center.y + (point.y - center.y) * scale };
}

/**
 * Convenience constructor for a `{x, y}` point. Exists so plugin code reads
 * fluently (`Ghostati.point(120, 40)` rather than `{x: 120, y: 40}`) when
 * inline-building anchor positions or pushing extra points onto a path.
 *
 * @param {number} x
 * @param {number} y
 * @returns {{x:number, y:number}}
 * @see ghostyles/smokey-eyes.js, ghostyles/stage-mask.js – use this when
 *   appending custom anchors to landmark-derived polygons.
 */
export function point(x, y) {
   return { x, y };
}

/**
 * Stroke and/or fill a closed polygon defined by an array of points. Either
 * `fillStyle` or `strokeStyle` can be `null` to skip that operation, so the
 * same function works for filled shapes, outlines only, or both. No-ops
 * silently on an empty `points` array.
 *
 * @param {CanvasRenderingContext2D} ctx  Target canvas context.
 * @param {Array<{x:number, y:number}>} points  Polygon vertices.
 * @param {string|null} [fillStyle=null]   Fill color, or `null` to skip.
 * @param {string|null} [strokeStyle=null] Stroke color, or `null` to skip.
 * @param {number} [lineWidth=2]           Stroke width (ignored if no stroke).
 * @see drawEyeWing – fills and strokes the eye shape via this.
 * @see drawCheekSweep – fills the cheek polygon.
 * @see drawContourBand – uses `drawOpenPath` instead (this is for closed
 *   shapes only).
 */
export function drawClosedPath(ctx, points, fillStyle = null, strokeStyle = null, lineWidth = 2) {
   if (!points.length) return;
   ctx.beginPath();
   ctx.moveTo(points[0].x, points[0].y);
   for (let i = 1; i < points.length; i += 1) ctx.lineTo(points[i].x, points[i].y);
   ctx.closePath();
   if (fillStyle) {
      ctx.fillStyle = fillStyle;
      ctx.fill();
   }
   if (strokeStyle) {
      ctx.lineWidth = lineWidth;
      ctx.strokeStyle = strokeStyle;
      ctx.stroke();
   }
}

/**
 * Stroke an open polyline through the given points. Supports an optional
 * dashed style for the "graphical liner" aesthetic some plugins use. Wraps
 * `ctx.save`/`ctx.restore` so the dash pattern doesn't leak into subsequent
 * drawing.
 *
 * @param {CanvasRenderingContext2D} ctx
 * @param {Array<{x:number, y:number}>} points
 * @param {string} strokeStyle    Stroke colour.
 * @param {number} [lineWidth=2]
 * @param {boolean} [dashed=false]  When `true`, applies a `[10, 8]` dash pattern.
 * @see drawEyeWing – uses this for the eyeline strokes.
 * @see drawContourBand – uses this twice (dashed + solid) for layered bands.
 */
export function drawOpenPath(ctx, points, strokeStyle, lineWidth = 2, dashed = false) {
   if (!points.length) return;
   ctx.save();
   ctx.beginPath();
   ctx.moveTo(points[0].x, points[0].y);
   for (let i = 1; i < points.length; i += 1) ctx.lineTo(points[i].x, points[i].y);
   ctx.lineWidth = lineWidth;
   ctx.strokeStyle = strokeStyle;
   if (dashed) ctx.setLineDash([10, 8]);
   ctx.stroke();
   ctx.restore();
}

/**
 * Draw a small labelled box at `(x, y)`. The label background is a rounded
 * dark rectangle with a thin outline; the text is rendered in the
 * project-standard sans-serif. When the canvas is mirrored
 * (`state.isMirrored`) the label is un-mirrored locally so the text remains
 * readable left-to-right.
 *
 * @param {CanvasRenderingContext2D} ctx
 * @param {string} text  Label text.
 * @param {number} x     Left anchor (the box grows rightward from here).
 * @param {number} y     Bottom-of-label baseline.
 * @see drawEyeWing, drawCheekSweep, drawContourBand – call this to caption
 *   the corresponding region.
 * @see roundRect – used internally to draw the background pill.
 */
export function drawLabel(ctx, text, x, y) {
   ctx.save();
   ctx.font = '700 14px Inter, system-ui, sans-serif';
   const padX = 10;
   const padY = 7;
   const width = ctx.measureText(text).width + padX * 2;
   const height = 30;

   if (state.isMirrored) {
      ctx.translate(x + width / 2, y - height / 2);
      ctx.scale(-1, 1);
      ctx.translate(-(x + width / 2), -(y - height / 2));
   }

   ctx.fillStyle = 'rgba(15, 17, 21, 0.78)';
   ctx.strokeStyle = 'rgba(255,255,255,0.10)';
   ctx.lineWidth = 1;
   roundRect(ctx, x, y - height, width, height, 12);
   ctx.fill();
   ctx.stroke();
   ctx.fillStyle = 'rgba(238, 242, 255, 0.96)';
   ctx.fillText(text, x + padX, y - 10);
   ctx.restore();
}

/**
 * Append a rounded-rectangle subpath to the current canvas path. Caller is
 * responsible for `ctx.fill()` / `ctx.stroke()` afterwards. The corners are
 * drawn with `arcTo` so the radius is honoured exactly even on rectangles
 * whose dimensions approach `2 * r`.
 *
 * @param {CanvasRenderingContext2D} ctx
 * @param {number} x  Top-left x.
 * @param {number} y  Top-left y.
 * @param {number} w  Width.
 * @param {number} h  Height.
 * @param {number} r  Corner radius.
 * @see drawLabel – the only caller in-tree; uses this for the label pill.
 */
export function roundRect(ctx, x, y, w, h, r) {
   ctx.beginPath();
   ctx.moveTo(x + r, y);
   ctx.arcTo(x + w, y, x + w, y + h, r);
   ctx.arcTo(x + w, y + h, x, y + h, r);
   ctx.arcTo(x, y + h, x, y, r);
   ctx.arcTo(x, y, x + w, y, r);
   ctx.closePath();
}

/**
 * Build an enlarged eye-shape polygon by combining the upper eyebrow points
 * (lifted toward the eye via `lerp`) with the lower eye points (radially
 * expanded via `scaleFrom`). The output is an ordered ring of points
 * suitable for `drawClosedPath`.
 *
 * The two tuning factors are intentionally exposed so plugins can dial the
 * dramatic level: `scale` controls how far the lower lid bulges out,
 * `eyebrowLift` controls how close the top of the shape sits to the eye
 * (`0` = right on the brow, `1` = right at the eye).
 *
 * @param {Array<{x:number, y:number}>} eye      Six face-api eye landmarks.
 * @param {Array<{x:number, y:number}>} eyebrow  Five face-api eyebrow landmarks.
 * @param {number} [scale=1.22]        Outward scaling factor of the lower lid.
 * @param {number} [eyebrowLift=0.72]  Lerp factor between eye and eyebrow.
 * @returns {Array<{x:number, y:number}>} Ring of points defining the eye region.
 * @see drawEyeWing – the primary caller.
 * @see avgPoint, scaleFrom, lerp – primitives composed here.
 */
export function expandEyePolygon(eye, eyebrow, scale = 1.22, eyebrowLift = 0.72) {
   const center = avgPoint(eye);
   const topBrow = eyebrow.map((b, idx) => {
      const eyeRef = eye[Math.min(idx + 1, eye.length - 1)] || eye[eye.length - 1];
      return lerp(eyeRef, b, eyebrowLift);
   });
   const expandedEye = eye.map(pt => scaleFrom(center, pt, scale));
   return [...topBrow, expandedEye[3], expandedEye[4], expandedEye[5], expandedEye[0]];
}

/**
 * Draw a stylised winged-eye shape with a tail extending past the outer
 * corner, the underlying enlarged-eye polygon, and a label. Side-aware: the
 * `tone.side` argument (`'left'` or `'right'`) flips which eye corner becomes
 * the tail anchor and on which side the label sits.
 *
 * `tone` collects every styling decision so the function stays generic across
 * different plugins: scale of the polygon, eyebrow lift, fill / stroke / line
 * colours, the tail offsets, and the side. Plugins typically pass a literal
 * object inline; see `graphic-liner.js` and `splash.js` for usage examples.
 *
 * @param {CanvasRenderingContext2D} ctx
 * @param {Array<{x:number, y:number}>} eye      Six face-api eye landmarks.
 * @param {Array<{x:number, y:number}>} eyebrow  Five face-api eyebrow landmarks.
 * @param {string} label                    Label drawn near the tail.
 * @param {{
 *   scale:number, brow:number,
 *   fill:string, stroke:string, line:string,
 *   side:('left'|'right'),
 *   tailX:number, tailY:number
 * }} tone  Styling and geometry knobs.
 * @see expandEyePolygon – called first to compute the base shape.
 * @see drawClosedPath, drawOpenPath, drawLabel – the rendering primitives.
 */
export function drawEyeWing(ctx, eye, eyebrow, label, tone) {
   const eyeShape = expandEyePolygon(eye, eyebrow, tone.scale, tone.brow);
   drawClosedPath(ctx, eyeShape, tone.fill, tone.stroke, 2.2);
   const outerCorner = tone.side === 'left'
      ? eye.reduce((best, p) => (p.x < best.x ? p : best), eye[0])
      : eye.reduce((best, p) => (p.x > best.x ? p : best), eye[0]);
   const tailTop = point(outerCorner.x + tone.tailX, outerCorner.y - tone.tailY);
   const tailLow = point(outerCorner.x + tone.tailX * 0.7, outerCorner.y + tone.tailY * 0.12);
   drawClosedPath(ctx, [outerCorner, tailTop, tailLow], tone.fill, tone.stroke, 2.2);
   const sorted = [...eye].sort((a, b) => a.x - b.x);
   const linePts = tone.side === 'left' ? [sorted[2], sorted[1], sorted[0], tailTop] : [sorted[sorted.length - 3], sorted[sorted.length - 2], sorted[sorted.length - 1], tailTop];
   drawOpenPath(ctx, linePts, tone.line, 3.2);
   drawLabel(ctx, label, tailTop.x + (tone.side === 'left' ? -52 : 10), tailTop.y - 10);
}

/**
 * Draw a cheek-sweep shape: a six-vertex polygon traced from an upper anchor
 * down to the jawline and back through the mouth corner. Internally
 * interpolates intermediate vertices with `lerp` so the curve feels organic
 * rather than landmark-blocky.
 *
 * @param {CanvasRenderingContext2D} ctx
 * @param {{x:number, y:number}} anchor       Upper anchor (typically the
 *                                            outer eye corner).
 * @param {{x:number, y:number}} noseSide     Side-of-nose anchor.
 * @param {{x:number, y:number}} mouthCorner  Outer mouth corner.
 * @param {{x:number, y:number}} jawPoint     Lower jaw anchor.
 * @param {string} label
 * @param {string} fill                       Fill colour.
 * @param {string} stroke                     Stroke colour.
 * @see drawClosedPath – used to render the polygon.
 * @see drawLabel – captions the swept region.
 * @see lerp – used to compute intermediate vertices.
 */
export function drawCheekSweep(ctx, anchor, noseSide, mouthCorner, jawPoint, label, fill, stroke) {
   const upper = lerp(anchor, noseSide, 0.42);
   const lower = lerp(mouthCorner, jawPoint, 0.36);
   const side = lerp(anchor, jawPoint, 0.54);
   const cheek = [
      upper,
      lerp(anchor, side, 0.45),
      side,
      lower,
      lerp(lower, mouthCorner, 0.55),
      lerp(mouthCorner, noseSide, 0.42)
   ];
   drawClosedPath(ctx, cheek, fill, stroke, 1.8);
   drawLabel(ctx, label, side.x - 20, side.y - 12);
}

/**
 * Draw a "contour band" along an arbitrary polyline: two stacked
 * `drawOpenPath` strokes, one dashed-warm and one wider translucent-dark,
 * with a label anchored at the midpoint. The double-stroke gives a
 * makeup-contour aesthetic on top of an open landmark sequence
 * (jawline, nose ridge).
 *
 * @param {CanvasRenderingContext2D} ctx
 * @param {Array<{x:number, y:number}>} pts  Polyline points (e.g. a jawline slice).
 * @param {string} label
 * @see drawOpenPath – called twice for the layered stroke effect.
 * @see drawLabel – places the caption at the midpoint of the band.
 */
export function drawContourBand(ctx, pts, label) {
   drawOpenPath(ctx, pts, 'rgba(193, 154, 107, 0.95)', 7, true);
   drawOpenPath(ctx, pts, 'rgba(90, 54, 33, 0.22)', 16);
   const mid = pts[Math.floor(pts.length / 2)];
   drawLabel(ctx, label, mid.x + 10, mid.y - 6);
}

function asFaceApiPoints(landmarks) {
   if (!landmarks) return [];
   if (Array.isArray(landmarks)) return landmarks.filter((p) => p && Number.isFinite(p.x) && Number.isFinite(p.y));
   if (typeof landmarks.positions !== 'undefined' && Array.isArray(landmarks.positions)) {
      return landmarks.positions.filter((p) => p && Number.isFinite(p.x) && Number.isFinite(p.y));
   }
   if (typeof landmarks.getPositions === 'function') {
      const pts = landmarks.getPositions();
      return Array.isArray(pts) ? pts.filter((p) => p && Number.isFinite(p.x) && Number.isFinite(p.y)) : [];
   }
   return [];
}

function asMediapipePoints(landmarks3d) {
   if (!Array.isArray(landmarks3d)) return [];
   return landmarks3d.filter((p) => p && Number.isFinite(p.x) && Number.isFinite(p.y));
}

/**
 * Clip the current canvas context to the left half of a face-api landmark cloud.
 *
 * The split axis is the vertical line that passes through the landmarks centroid.
 * This helper exists so a plugin can render a side-by-side comparison where one
 * half of the face shows a 2D pixel-space strategy and the other half is left
 * untouched (or rendered by a different callback).
 *
 * IMPORTANT: call `ctx.save()` before using this helper and `ctx.restore()`
 * afterwards. The function only applies the clip path.
 *
 * @param {CanvasRenderingContext2D} ctx - 2D rendering context.
 * @param {object|Array<{x:number,y:number}>} landmarks - face-api landmarks object or array of points.
 * @returns {boolean} `true` when clip has been applied, `false` when landmarks are missing.
 * @see ghostyles/smokey-eyes.js
 * @see ghostyles/soft-contour.js
 */
export function clipLeftHalf(ctx, landmarks) {
   const pts = asFaceApiPoints(landmarks);
   if (!pts.length) return false;

   const c = avgPoint(pts);
   const margin = Math.max(ctx.canvas.width, ctx.canvas.height) * 2;
   ctx.beginPath();
   ctx.rect(-margin, -margin, c.x + margin, ctx.canvas.height + margin * 2);
   ctx.clip();
   return true;
}

/**
 * Clip the current canvas context to the right half of a face-api landmark cloud.
 *
 * The split axis is the vertical line through the landmarks centroid. Use this
 * together with `clipLeftHalf` when a plugin needs to compare two rendering
 * strategies on the same face without visual overlap.
 *
 * IMPORTANT: call `ctx.save()` before using this helper and `ctx.restore()`
 * afterwards. The function only applies the clip path.
 *
 * @param {CanvasRenderingContext2D} ctx - 2D rendering context.
 * @param {object|Array<{x:number,y:number}>} landmarks - face-api landmarks object or array of points.
 * @returns {boolean} `true` when clip has been applied, `false` when landmarks are missing.
 * @see ghostyles/smokey-eyes.js
 * @see ghostyles/soft-contour.js
 */
export function clipRightHalf(ctx, landmarks) {
   const pts = asFaceApiPoints(landmarks);
   if (!pts.length) return false;

   const c = avgPoint(pts);
   const margin = Math.max(ctx.canvas.width, ctx.canvas.height) * 2;
   ctx.beginPath();
   ctx.rect(c.x, -margin, ctx.canvas.width - c.x + margin * 2, ctx.canvas.height + margin * 2);
   ctx.clip();
   return true;
}

/**
 * Clip a UV canvas to the left half of the MediaPipe landmark cloud.
 *
 * The landmarks are normalized in `[0,1]`, so the centroid x is converted to
 * pixel coordinates of the UV canvas before clipping. This makes it possible to
 * show UV-space rendering only on one side while another strategy occupies the
 * opposite side.
 *
 * IMPORTANT: call `ctx.save()` before using this helper and `ctx.restore()`
 * afterwards. The function only applies the clip path.
 *
 * @param {CanvasRenderingContext2D} ctx - UV texture rendering context.
 * @param {Array<{x:number,y:number,z:?number}>} landmarks3d - MediaPipe face landmarks.
 * @returns {boolean} `true` when clip has been applied, `false` when landmarks are missing.
 * @see ghostyles/smokey-eyes.js
 * @see ghostyles/soft-contour.js
 */
export function clipLeftHalfUV(ctx, landmarks3d) {
   const pts = asMediapipePoints(landmarks3d);
   if (!pts.length) return false;

   const c = avgPoint(pts);
   const axisX = c.x * ctx.canvas.width;
   const margin = Math.max(ctx.canvas.width, ctx.canvas.height) * 2;
   ctx.beginPath();
   ctx.rect(-margin, -margin, axisX + margin, ctx.canvas.height + margin * 2);
   ctx.clip();
   return true;
}

/**
 * Clip a UV canvas to the right half of the MediaPipe landmark cloud.
 *
 * This is the UV-space counterpart of `clipRightHalf`. It supports split-view
 * plugins where one side is painted in pixel-space and the other side in
 * UV-space, keeping the didactic comparison readable.
 *
 * IMPORTANT: call `ctx.save()` before using this helper and `ctx.restore()`
 * afterwards. The function only applies the clip path.
 *
 * @param {CanvasRenderingContext2D} ctx - UV texture rendering context.
 * @param {Array<{x:number,y:number,z:?number}>} landmarks3d - MediaPipe face landmarks.
 * @returns {boolean} `true` when clip has been applied, `false` when landmarks are missing.
 * @see ghostyles/smokey-eyes.js
 * @see ghostyles/soft-contour.js
 */
export function clipRightHalfUV(ctx, landmarks3d) {
   const pts = asMediapipePoints(landmarks3d);
   if (!pts.length) return false;

   const c = avgPoint(pts);
   const axisX = c.x * ctx.canvas.width;
   const margin = Math.max(ctx.canvas.width, ctx.canvas.height) * 2;
   ctx.beginPath();
   ctx.rect(axisX, -margin, ctx.canvas.width - axisX + margin * 2, ctx.canvas.height + margin * 2);
   ctx.clip();
   return true;
}

/**
 * Current wall-clock time as a `HH:MM:SS` string. Used exclusively for log
 * timestamping so the workshop participants can correlate UI events with
 * what they did.
 *
 * @returns {string}
 * @see setLog – prefixes every log line with `[formatTime()]`.
 */
export function formatTime() {
   const now = new Date();
   return now.toTimeString().split(' ')[0]; // Returns HH:MM:SS
}

/**
 * Convert a date-like value into a compact Italian relative-time label.
 * Used by plugin buttons to show freshness metadata ("aggiornato X fa").
 *
 * @param {Date|string|number|null|undefined} dateLike
 * @returns {string} Relative time label (e.g. "ora", "17 minuti fa",
 *   "2 mesi fa") or "n/d" when input is invalid.
 */
export function formatRelativeTime(dateLike) {
   if (!dateLike) return 'n/d';

   const date = dateLike instanceof Date ? dateLike : new Date(dateLike);
   if (Number.isNaN(date.getTime())) return 'n/d';

   const diffMs = Date.now() - date.getTime();
   const inFuture = diffMs < 0;
   const absMs = Math.abs(diffMs);

   const minute = 60 * 1000;
   const hour = 60 * minute;
   const day = 24 * hour;
   const month = 30 * day;
   const year = 365 * day;

   if (absMs < minute) return 'ora';

   const suffix = inFuture ? 'tra' : 'fa';
   const asLabel = (value, singular, plural) => {
      const unit = value === 1 ? singular : plural;
      return inFuture ? `${suffix} ${value} ${unit}` : `${value} ${unit} ${suffix}`;
   };

   if (absMs < hour) {
      const value = Math.floor(absMs / minute);
      return asLabel(value, 'minuto', 'minuti');
   }

   if (absMs < day) {
      const value = Math.floor(absMs / hour);
      return asLabel(value, 'ora', 'ore');
   }

   if (absMs < month) {
      const value = Math.floor(absMs / day);
      return asLabel(value, 'giorno', 'giorni');
   }

   if (absMs < year) {
      const value = Math.floor(absMs / month);
      return asLabel(value, 'mese', 'mesi');
   }

   const value = Math.floor(absMs / year);
   return asLabel(value, 'anno', 'anni');
 }

/**
 * Re-render the on-screen log box from `state.logsArchive`. Has two modes:
 * expanded (full archive, oldest at top, autoscrolled to bottom — for
 * post-mortem) and collapsed (latest four lines, newest at top — for live
 * peripheral attention). Mode is driven by `state.isLogExpanded`, toggled
 * by clicking the log box.
 *
 * @see setLog – calls this after pushing a new entry.
 * @see scripts/main.js – wires the click handler that toggles
 *   `state.isLogExpanded`.
 */
export function updateLogDisplay() {
   const logBox = document.getElementById('logBox');
   if (!logBox) return;
   logBox.innerHTML = '';

   if (state.isLogExpanded) {
      logBox.classList.add('expanded');
      const startIdx = Math.max(0, state.logsArchive.length - 100);
      for (let i = startIdx; i < state.logsArchive.length; i++) {
         const clone = state.logsArchive[i].cloneNode(true);
         logBox.appendChild(clone);
      }
      logBox.scrollTop = logBox.scrollHeight;
   } else {
      logBox.classList.remove('expanded');
      let renderedCount = 0;
      for (let i = state.logsArchive.length - 1; i >= state.visibleLogStartIndex && renderedCount < 4; i--) {
         const clone = state.logsArchive[i].cloneNode(true);
         logBox.insertBefore(clone, logBox.firstChild);
         renderedCount++;
      }
   }
}

/**
 * Append a log line to `state.logsArchive` and refresh the on-screen log.
 * Each line is timestamped via `formatTime()` and may carry an optional
 * source-plugin tag (rendered in accent colour) so a workshop participant
 * can tell at a glance which plugin emitted the message. The archive is
 * capped at 100 entries (FIFO drop); `visibleLogStartIndex` shifts in step
 * so the "clear visible logs" feature never accidentally reveals stale data.
 *
 * @param {string} message               Text of the log entry.
 * @param {string|null} [sourcePlugin=null]  Optional plugin tag.
 * @see formatTime – produces the timestamp prefix.
 * @see updateLogDisplay – refreshes the DOM after the push.
 */
export function setLog(message, sourcePlugin = null) {
   const line = document.createElement('div');
   line.className = 'log-line';

   const timeSpan = document.createElement('span');
   timeSpan.style.color = 'var(--muted)';
   timeSpan.style.marginRight = '8px';
   timeSpan.textContent = `[${formatTime()}]`;
   line.appendChild(timeSpan);

   if (sourcePlugin) {
      const span = document.createElement('span');
      span.style.color = 'var(--accent-2)';
      span.style.fontWeight = '800';
      span.style.marginRight = '8px';
      span.textContent = `[${sourcePlugin.toUpperCase()}]`;
      line.appendChild(span);
   }
   const textSpan = document.createElement('span');
   textSpan.textContent = message;
   line.appendChild(textSpan);

   state.logsArchive.push(line);
   if (state.logsArchive.length > 100) {
      state.logsArchive.shift();
      if (state.visibleLogStartIndex > 0) state.visibleLogStartIndex--;
   }

   updateLogDisplay();
}

/**
 * Converts thrown plugin failures into the compact label used by loader logs.
 * In this project it keeps broken `paintUV` errors readable while preserving
 * non-Error throws from third-party ghostyles.
 *
 * @param {*} err - Value caught while rendering a 3D ghostyle.
 * @returns {string} Error name and message, or a stringified thrown value.
 * @see deactivateBroken3dPlugin - Formats the `paintUV` failure logged for a disabled plugin.
 */
export function asErrorLabel(err) {
   if (err instanceof Error) return `${err.name}: ${err.message}`;
   return String(err);
}

/**
 * Keeps the hidden 3D plugin canvas matched to the live overlay dimensions so
 * UV paint is composited into the same coordinate space as the camera frame.
 *
 * @param {HTMLCanvasElement} canvas  The hidden 3D plugin canvas.
 * @param {HTMLCanvasElement} overlayEl  The main overlay canvas.
 * @returns {void}
 * @see initPlugins3dLoader - Called on each `landmarks3d` frame before drawing the active ghostyle.
 */
export function syncSize(canvas, overlayEl) {
   if (canvas.width !== overlayEl.width || canvas.height !== overlayEl.height) {
      canvas.width = overlayEl.width;
      canvas.height = overlayEl.height;
   }
}

/**
 * Mirrors the 3D plugin canvas with the main overlay transform, preserving the
 * webcam mirror mode when UV-painted effects are displayed or composited.
 *
 * @param {HTMLCanvasElement} canvas  The hidden 3D plugin canvas.
 * @param {HTMLCanvasElement} overlayEl  The main overlay canvas.
 * @returns {void}
 * @see initPlugins3dLoader - Called on each `landmarks3d` frame before drawing the active ghostyle.
 */
export function syncMirror(canvas, overlayEl) {
   const transform = overlayEl.style.transform;
   if (canvas.style.transform !== transform) canvas.style.transform = transform;
}