import express from 'express';
import cors from 'cors';
import multer from 'multer';
import { v4 as uuidv4 } from 'uuid';
import sharp from 'sharp';
import { createCanvas, loadImage, registerFont } from 'canvas';
import rateLimit from 'express-rate-limit';
import pino from 'pino';
import pinoHttp from 'pino-http';
import { z } from 'zod';
import { fileURLToPath } from 'url';
import { dirname, join, resolve, sep, basename } from 'path';
import {
  mkdirSync, existsSync, writeFileSync, readdirSync,
  statSync, rmSync, readFileSync,
} from 'fs';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

const app = express();
const PORT = process.env.PORT || 3001;
const IS_PRODUCTION = process.env.NODE_ENV === 'production';

// ── Logger (S22) ───────────────────────────────────────────────────────────
//
// pino emits structured JSON. In production this is exactly what log
// aggregators (Datadog, CloudWatch, Loki, …) want. In development it's
// less pretty than `console.log` but you can pipe through `pino-pretty`
// or `jq` if you want colored output:
//
//   npm run dev | npx pino-pretty
//
// Adding pino-pretty as a transport here would couple dev tooling into
// the runtime image, which we'd rather avoid. JSON-only is the simpler
// choice.
const logger = pino({
  level: process.env.LOG_LEVEL || 'info',
  base: { service: 'apparel-designer' },
});

// pino-http auto-logs every request with a per-request child logger that
// the handlers can grab via `req.log` if they want request-scoped fields.
// We exclude /api/health from the request log because the Docker
// HEALTHCHECK polls it every 30s and the noise is useless.
app.use(pinoHttp({
  logger,
  autoLogging: { ignore: (req) => req.url === '/api/health' },
}));

// ── Filesystem layout ──────────────────────────────────────────────────────

const uploadsDir = join(__dirname, 'uploads');
const exportsDir = join(__dirname, 'exports');
const uploadsRoot = resolve(uploadsDir);
const exportsRoot = resolve(exportsDir);
[uploadsDir, exportsDir].forEach((dir) => {
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
});

// Resolve a path supplied via /uploads/... and verify it stays inside uploadsDir.
// Returns null for anything that escapes the directory. Defensive against the
// C6 path-traversal class of bug; keep using it for any client-supplied src.
function safeUploadPath(srcPath) {
  if (typeof srcPath !== 'string') return null;
  if (!srcPath.startsWith('/uploads/')) return null;
  const rel = srcPath.slice('/uploads/'.length);
  const resolved = resolve(uploadsRoot, rel);
  if (resolved !== uploadsRoot && !resolved.startsWith(uploadsRoot + sep)) return null;
  return resolved;
}

// Sticker library directory. Stickers are part of the app's static assets
// (not user-uploaded), so they live in public/ during development and
// dist/ after build. We pick the first one that exists at server start.
//
// Why two locations:
//   In dev, the Vite dev server serves files directly out of public/, and
//   our Express process only handles API endpoints. There is no `dist/`
//   yet.
//   In prod (NODE_ENV=production), Express serves the built SPA from
//   `dist/`, and Vite has copied `public/` → `dist/` at build time, so
//   the stickers live at `dist/stickers/`. The original `public/` copy
//   is still there too, but `dist/` is the canonical runtime path.
const stickersDir = IS_PRODUCTION
  ? join(__dirname, 'dist', 'stickers')
  : join(__dirname, 'public', 'stickers');
const stickersRoot = resolve(stickersDir);

// Path-traversal guard for client-supplied /stickers/... paths. Same shape
// as safeUploadPath but for the sticker library. Returns null when the
// resolved path escapes stickersRoot (e.g. /stickers/../../etc/passwd).
function safeStickerPath(srcPath) {
  if (typeof srcPath !== 'string') return null;
  if (!srcPath.startsWith('/stickers/')) return null;
  const rel = srcPath.slice('/stickers/'.length);
  const resolved = resolve(stickersRoot, rel);
  if (resolved !== stickersRoot && !resolved.startsWith(stickersRoot + sep)) return null;
  return resolved;
}

// ── TTL cleanup (S16) ──────────────────────────────────────────────────────
//
// Uploads and exports accumulate forever otherwise. We delete files older
// than FILE_TTL_MS on a periodic interval. For a real product, retention
// should be a per-account preference (paid plans keep work longer); this
// time-based sweep is a reasonable v1 that prevents the disk from filling
// up on a long-running deployment.
//
// `uploads/preview/` is a subdirectory holding sharp-resized previews; the
// recursion handles it without special-casing.
const FILE_TTL_MS = parseInt(process.env.FILE_TTL_MS || '', 10) || 24 * 60 * 60 * 1000;
const CLEANUP_INTERVAL_MS = parseInt(process.env.CLEANUP_INTERVAL_MS || '', 10) || 60 * 60 * 1000;

function cleanupOldFiles(dir, ttlMs) {
  if (!existsSync(dir)) return 0;
  const now = Date.now();
  let removed = 0;
  let entries = [];
  try {
    entries = readdirSync(dir);
  } catch (err) {
    logger.warn({ err: err.message, dir }, 'Failed to list directory during cleanup');
    return 0;
  }
  for (const entry of entries) {
    const full = join(dir, entry);
    try {
      const stat = statSync(full);
      if (stat.isFile() && now - stat.mtimeMs > ttlMs) {
        rmSync(full);
        removed++;
      } else if (stat.isDirectory()) {
        // Recurse one level (e.g. uploads/preview).
        removed += cleanupOldFiles(full, ttlMs);
      }
    } catch (err) {
      // File could vanish between readdir and stat (concurrent cleanup,
      // user uploaded then deleted, etc) — log at debug, not warn.
      logger.debug({ err: err.message, file: full }, 'Cleanup skip');
    }
  }
  return removed;
}

function runCleanup() {
  const u = cleanupOldFiles(uploadsDir, FILE_TTL_MS);
  const e = cleanupOldFiles(exportsDir, FILE_TTL_MS);
  if (u > 0 || e > 0) {
    logger.info({ removedUploads: u, removedExports: e, ttlMs: FILE_TTL_MS }, 'Cleanup pass complete');
  }
}
runCleanup();
setInterval(runCleanup, CLEANUP_INTERVAL_MS).unref();

// ── Font registration ──────────────────────────────────────────────────────
//
// node-canvas only knows about fonts that are either (a) explicitly
// registered via registerFont() before any canvas is created, or (b)
// installed at the OS level and discoverable via fontconfig. Calling
// registerFont after the first createCanvas() is a no-op, so we do this
// at module load.
//
// Filename convention (matches scripts/fetch-fonts.mjs):
//   "Family_Name-Variant.ttf"  →  registerFont(path, { family: 'Family Name',
//                                                       weight: 'bold' if -Bold,
//                                                       style:  'italic' if -Italic })
function registerFontsFromDir(dir) {
  if (!existsSync(dir)) {
    logger.warn({ dir, hint: 'Run `npm run fetch-fonts` to download the brand fonts. Without them, text exports will silently render in node-canvas\'s tiny built-in fallback font.' }, '⚠️ No fonts directory — text exports will be broken');
    return 0;
  }

  let registered = 0, failed = 0;
  for (const entry of readdirSync(dir)) {
    if (!/\.(ttf|otf)$/i.test(entry)) continue;
    const base = entry.replace(/\.(ttf|otf)$/i, '');
    const dash = base.lastIndexOf('-');
    const familyPart = dash > 0 ? base.slice(0, dash) : base;
    const variant = dash > 0 ? base.slice(dash + 1) : 'Regular';
    const family = familyPart.replace(/_/g, ' ');

    const opts = { family };
    if (/Bold/i.test(variant)) opts.weight = 'bold';
    if (/Italic/i.test(variant)) opts.style = 'italic';

    try {
      registerFont(join(dir, entry), opts);
      registered++;
    } catch (err) {
      failed++;
      logger.warn({ err: err.message, font: entry }, 'Failed to register font');
    }
  }

  // Empty fonts/ directory is the most common cause of silent text-export
  // breakage — the directory exists (gitkept) but nobody ran fetch-fonts
  // yet, so the loop above completes with registered=0 and the server
  // appears to start cleanly. Surface this loudly so it's caught at
  // boot instead of by inspecting blank text in a downloaded PNG.
  if (registered === 0) {
    logger.warn({
      dir,
      failed,
      hint: 'Run `npm run fetch-fonts` to download the brand fonts, then restart this server. node-canvas registers fonts at first canvas creation — fetching them while the server is already running has no effect.',
    }, '⚠️ No fonts registered — text exports will silently render in a tiny fallback font');
  } else {
    logger.info({ registered, failed, dir }, 'Font registration complete');

    // Probe one of the registered families to confirm node-canvas is
    // actually honoring our registerFont() calls. We've seen cases where
    // the loop reports success (file opened, no exception thrown), but
    // ctx.font is still ignored at draw time — typically because the
    // node-canvas native module was compiled against an older Cairo that
    // doesn't understand the TTF, or because some other code (e.g. an
    // unrelated import) accidentally created a canvas before this
    // function ran, freezing the font registry. The probe makes the
    // failure mode visible at boot instead of silent at first export.
    //
    // We pick Pacifico if it's available (it's the brand-display face
    // and the one most users care about), otherwise the first registered
    // family alphabetically. The expected measured width of "M" at 100px
    // should be on the order of 50–120px depending on face; anything
    // under 20 means the size string was ignored.
    try {
      const probeCanvas = createCanvas(10, 10);
      const pctx = probeCanvas.getContext('2d');
      // Try Pacifico first because that's the brand face and the one
      // most likely to be exercised in user designs.
      const probeFamily = 'Pacifico';
      pctx.font = `100px "${probeFamily}"`;
      const w = pctx.measureText('M').width;
      if (w < 20) {
        logger.warn({
          probeFamily,
          probeFontPx: 100,
          measuredW: w,
          hint: 'registerFont() reported success but ctx.font is being ignored at draw time. Possible causes: (1) another module created a canvas before font registration ran — check for top-level `createCanvas()` calls in imported files; (2) the node-canvas native module was compiled against a Cairo that can\'t parse the downloaded TTFs; (3) the server was restarted, but a watcher reloaded the module without re-executing the registration block.',
        }, '⚠️ Font registration probe failed — fonts registered on disk but not honored at render time');
      } else {
        logger.info({ probeFamily, probeFontPx: 100, measuredW: w }, 'Font registration probe passed');
      }
    } catch (err) {
      logger.warn({ err: err.message }, 'Font registration probe threw');
    }
  }
  return registered;
}

const fontsDir = join(__dirname, 'fonts');
registerFontsFromDir(fontsDir);

// ── Middleware ─────────────────────────────────────────────────────────────

const corsOptions = IS_PRODUCTION
  ? { origin: process.env.CORS_ORIGIN || false }
  : { origin: true };
app.use(cors(corsOptions));
app.use(express.json({ limit: '50mb' }));
app.use(express.urlencoded({ extended: true, limit: '50mb' }));

// ── Rate limiters (S15) ────────────────────────────────────────────────────
//
// Per-IP rate limits prevent the trivial DOS of "spam-export 4500×4500
// PNGs in a loop". The numbers below are conservative; a real product
// would tune them based on traffic patterns. `standardHeaders: true`
// emits the IETF draft `RateLimit-*` headers so well-behaved clients
// can back off voluntarily.
const exportLimiter = rateLimit({
  windowMs: 60 * 60 * 1000,
  max: 60,
  standardHeaders: true,
  legacyHeaders: false,
  message: { error: 'Too many export requests. Please try again later.' },
});
const uploadLimiter = rateLimit({
  windowMs: 60 * 60 * 1000,
  max: 30,
  standardHeaders: true,
  legacyHeaders: false,
  message: { error: 'Too many upload requests. Please try again later.' },
});

// ── Static / SPA serving ──────────────────────────────────────────────────

app.use('/uploads', express.static(uploadsDir));
app.use('/exports', express.static(exportsDir));

if (IS_PRODUCTION) {
  const clientDist = join(__dirname, 'dist');
  app.use(express.static(clientDist));
} else {
  // Dev UX: backend doesn't serve the SPA; Vite does.
  // Vite is configured to listen on port 3000 (see vite.config.js).
  const VITE_DEV_PORT = process.env.VITE_DEV_PORT || 3000;
  app.get('/', (_req, res) => {
    res
      .status(302)
      .set('Location', `http://localhost:${VITE_DEV_PORT}/`)
      .send('Redirecting to Vite dev server...');
  });
}

// ── Upload pipeline ────────────────────────────────────────────────────────

const MIME_TO_EXT = {
  'image/jpeg': 'jpg',
  'image/png': 'png',
  'image/webp': 'webp',
};

const storage = multer.diskStorage({
  destination: (_req, _file, cb) => cb(null, uploadsDir),
  filename: (_req, file, cb) => {
    const ext = MIME_TO_EXT[file.mimetype] || 'bin';
    cb(null, `${uuidv4()}.${ext}`);
  },
});

const fileFilter = (_req, file, cb) => {
  if (MIME_TO_EXT[file.mimetype]) {
    cb(null, true);
  } else {
    cb(new Error('Invalid file type. Only JPEG, PNG, and WebP are allowed.'), false);
  }
};

const upload = multer({
  storage,
  fileFilter,
  limits: { fileSize: 20 * 1024 * 1024 },
});

// ── Zod schemas (S19) ──────────────────────────────────────────────────────
//
// Validates the export request body BEFORE we start allocating canvases
// and loading images. A malformed request that previously crashed deep
// inside renderElement now returns a clean 400 with the validation path.
//
// `passthrough()` on the element schema accepts unknown fields rather
// than rejecting them — the editor evolves and adds fields (filter,
// stroke, locked, etc.) faster than this schema would; we only enforce
// the fields the renderer actually reads.
const ElementSchema = z.object({
  id: z.string().optional(),
  type: z.enum(['image', 'text', 'sticker']).optional(),
  x: z.number().optional(),
  y: z.number().optional(),
  width: z.number().optional(),
  height: z.number().optional(),
  rotation: z.number().optional(),
  opacity: z.number().min(0).max(1).optional(),
  flipX: z.boolean().optional(),
  flipY: z.boolean().optional(),
  locked: z.boolean().optional(),
  filter: z.string().optional(),
  src: z.string().optional(),
  emoji: z.string().optional(),
  text: z.string().optional(),
  fontSize: z.number().optional(),
  fontFamily: z.string().optional(),
  fill: z.string().optional(),
  stroke: z.string().nullable().optional(),
  strokeWidth: z.number().optional(),
  arc: z.number().optional(),
  crop: z.object({
    sx: z.number(), sy: z.number(),
    sWidth: z.number(), sHeight: z.number(),
  }).optional(),
  slotId: z.string().optional(),
  nonPrintable: z.boolean().optional(),
}).passthrough();

const ExportRequestSchema = z.object({
  designName: z.string().max(200).optional(),
  elements: z.array(ElementSchema),
  template: z.object({
    id: z.string().optional(),
    background: z.object({
      type: z.enum(['color', 'image']),
      color: z.string().optional(),
      src: z.string().optional(),
    }).optional(),
    overlay: z.array(ElementSchema).optional(),
  }).nullable().optional(),
});

// ── Image filter pipeline (S21) ────────────────────────────────────────────
//
// The editor applies Konva filters (grayscale / sepia / invert) client-side
// to image elements. The export now applies the equivalent transform via
// sharp BEFORE handing the pixels to node-canvas, so the printed shirt
// matches the on-screen preview.
//
// Filter ids and their meaning come from src/constants/imageFilters.js.
// Adding a new preset is a two-file change: register it client-side, then
// add the matching sharp pipeline below.
const SEPIA_MATRIX = [
  [0.393, 0.769, 0.189],
  [0.349, 0.686, 0.168],
  [0.272, 0.534, 0.131],
];

function applyFilterToSharp(s, filterId) {
  switch (filterId) {
    case 'grayscale': return s.grayscale();
    case 'sepia':     return s.recomb(SEPIA_MATRIX);
    case 'invert':    return s.negate();
    case 'none':
    default:          return s;
  }
}

/**
 * Resolve `el.src` to either a buffer (for data: URLs, local /uploads/
 * paths, and /stickers/ library paths) or a string URL (for external
 * http://). Returns null on invalid paths — caller should skip the
 * element with a logged warning.
 */
function resolveImageSource(src) {
  if (typeof src !== 'string') return null;
  if (src.startsWith('data:')) {
    const comma = src.indexOf(',');
    if (comma < 0) return null;
    const base64 = src.slice(comma + 1);
    try {
      return { type: 'buffer', value: Buffer.from(base64, 'base64') };
    } catch {
      return null;
    }
  }
  if (src.startsWith('/stickers/')) {
    // Image sticker from the bundled library. URLs are stable
    // (`/stickers/<filename>`) across dev and prod — see the Vite
    // stickerManifestPlugin in vite.config.js for the client side, and
    // `stickersDir` above for the corresponding disk lookup.
    const safe = safeStickerPath(src);
    if (!safe) return null;
    try {
      return { type: 'buffer', value: readFileSync(safe) };
    } catch {
      return null;
    }
  }
  if (src.startsWith('/')) {
    const safe = safeUploadPath(src);
    if (!safe) return null;
    try {
      return { type: 'buffer', value: readFileSync(safe) };
    } catch {
      return null;
    }
  }
  // External URL — node-canvas's loadImage handles fetching, but we
  // can't pipe that through sharp without an extra fetch. For external
  // URLs, the filter is silently skipped (a known limitation, also
  // documented in src/constants/imageFilters.js).
  return { type: 'url', value: src };
}

async function loadImageWithFilter(src, filterId = 'none') {
  const resolved = resolveImageSource(src);
  if (!resolved) throw new Error(`Invalid image source: ${src}`);

  // Fast path: no filter, just hand the source to loadImage directly.
  if (!filterId || filterId === 'none') {
    return loadImage(resolved.type === 'buffer' ? resolved.value : resolved.value);
  }
  // External URL with filter → skip filter, log once.
  if (resolved.type === 'url') {
    logger.debug({ src }, 'External URL with filter; filter not applied');
    return loadImage(resolved.value);
  }
  // Buffer + filter → run through sharp, then load the filtered buffer.
  const filtered = await applyFilterToSharp(sharp(resolved.value), filterId).toBuffer();
  return loadImage(filtered);
}

// ── Arc'd text rendering ────────────────────────────────────────────────
//
// Konva's TextPath lays glyphs along a path; node-canvas has no equivalent
// primitive, so we walk the path ourselves and draw one character at a
// time at the correct position-and-tangent on the curve.
//
// The path is a quadratic Bézier (matching TextElement.jsx). Endpoints:
//   P0 = (0, baselineY)
//   P2 = (pathW, baselineY)
// Control point:
//   P1 = (pathW/2, controlY)   where controlY = baselineY ± bulgeMag
//
// The point on the curve at parameter t in [0, 1] is:
//   B(t)   = (1-t)²·P0 + 2(1-t)t·P1 + t²·P2
//   B'(t)  = 2(1-t)·(P1-P0) + 2t·(P2-P1)   — tangent vector
//
// To place glyph N starting at arc-length L from the path start, we need
// to invert B — find t such that arc-length-from-0-to-t equals L. There's
// no closed form, so we sample the curve, build a cumulative arc-length
// table, and look up t with a linear interpolation between samples.

const ARC_PATH_OVERSHOOT = 1.02; // matches TextElement.jsx pathW factor.
const ARC_SAMPLES = 100;          // 100 samples is well under sub-pixel error
                                  // for typical curvatures, and the table
                                  // is built once per text element.

/**
 * Build a sample table of (t, cumulativeArcLength) along a quadratic
 * Bézier from P0 through P1 to P2. We approximate arc length by summing
 * the chord lengths between consecutive sample points — standard
 * polyline-approximation approach.
 */
function buildArcLengthTable(p0, p1, p2) {
  const table = new Array(ARC_SAMPLES + 1);
  table[0] = { t: 0, len: 0, x: p0.x, y: p0.y };
  let prevX = p0.x;
  let prevY = p0.y;
  let cumLen = 0;
  for (let i = 1; i <= ARC_SAMPLES; i++) {
    const t = i / ARC_SAMPLES;
    const omt = 1 - t;
    const x = omt * omt * p0.x + 2 * omt * t * p1.x + t * t * p2.x;
    const y = omt * omt * p0.y + 2 * omt * t * p1.y + t * t * p2.y;
    cumLen += Math.hypot(x - prevX, y - prevY);
    table[i] = { t, len: cumLen, x, y };
    prevX = x; prevY = y;
  }
  return table;
}

/**
 * Given an arc-length value, return { t, x, y, angle } at that distance
 * along the curve. Angle is the path tangent in radians — the glyph
 * will be rotated by this so it sits perpendicular to the curve like
 * letters on a road sign.
 *
 * Clamps to the curve endpoints if the requested length is outside
 * [0, totalLength].
 */
function sampleAtArcLength(table, p0, p1, p2, targetLen) {
  const totalLen = table[ARC_SAMPLES].len;
  if (targetLen <= 0) {
    // Clamp to start. Tangent at t=0 is 2*(P1-P0).
    const tx = 2 * (p1.x - p0.x);
    const ty = 2 * (p1.y - p0.y);
    return { t: 0, x: p0.x, y: p0.y, angle: Math.atan2(ty, tx) };
  }
  if (targetLen >= totalLen) {
    // Clamp to end. Tangent at t=1 is 2*(P2-P1).
    const tx = 2 * (p2.x - p1.x);
    const ty = 2 * (p2.y - p1.y);
    return { t: 1, x: p2.x, y: p2.y, angle: Math.atan2(ty, tx) };
  }
  // Find the sample bracket [i, i+1] such that table[i].len <= targetLen
  // <= table[i+1].len. Linear scan is fine — ARC_SAMPLES is small enough
  // that binary search would be marginal at best.
  let i = 0;
  while (i < ARC_SAMPLES && table[i + 1].len < targetLen) i++;
  const a = table[i];
  const b = table[i + 1];
  const segLen = b.len - a.len;
  const frac = segLen > 0 ? (targetLen - a.len) / segLen : 0;
  const t = a.t + (b.t - a.t) * frac;
  // Recompute point and tangent at the refined t.
  const omt = 1 - t;
  const x = omt * omt * p0.x + 2 * omt * t * p1.x + t * t * p2.x;
  const y = omt * omt * p0.y + 2 * omt * t * p1.y + t * t * p2.y;
  const tx = 2 * omt * (p1.x - p0.x) + 2 * t * (p2.x - p1.x);
  const ty = 2 * omt * (p1.y - p0.y) + 2 * t * (p2.y - p1.y);
  return { t, x, y, angle: Math.atan2(ty, tx) };
}

/**
 * Draw a string along a quadratic Bézier path, mirroring Konva's
 * TextPath with align="center".
 *
 * Caller has already set ctx.font, ctx.fillStyle, and (if needed)
 * ctx.strokeStyle / lineWidth. We restore textAlign / textBaseline at
 * the end so the caller's state stays intact.
 *
 * The (x, y) coordinates passed in are the text element's design-coord
 * origin scaled to export coords — the same anchor we'd use for flat
 * text. The path coordinates are computed relative to this origin so
 * the arc visually anchors where the user placed it on the canvas.
 */
function renderTextOnArc(ctx, {
  text, x, y, fontSize, fontFamily,
  approxW, approxH, arc,
  fill, stroke, strokeWidth,
}) {
  const prevAlign = ctx.textAlign;
  const prevBaseline = ctx.textBaseline;

  // Path geometry — mirrors TextElement.jsx exactly.
  const arcAbs = Math.min(100, Math.abs(arc));
  const baselineY = approxH * 0.85;
  const bulgeMag = (arcAbs / 100) * approxH * 1.4;
  const controlY = arc > 0 ? baselineY - bulgeMag : baselineY + bulgeMag;
  const pathW = approxW * ARC_PATH_OVERSHOOT;

  // Translate so the path's local origin (0, 0) lines up with the text
  // element's design-coord origin in export coords.
  ctx.save();
  ctx.translate(x, y);

  const p0 = { x: 0,         y: baselineY };
  const p1 = { x: pathW / 2, y: controlY };
  const p2 = { x: pathW,     y: baselineY };

  const table = buildArcLengthTable(p0, p1, p2);
  const totalLen = table[ARC_SAMPLES].len;

  // Center-align: leading offset so the text sits centered on the path.
  // Measure each glyph individually so we use the same font metrics the
  // upcoming draw calls will use, instead of measuring the whole string
  // (which can differ slightly from sum-of-parts due to kerning that
  // Canvas's measureText doesn't expose at the character level anyway).
  const widths = new Array(text.length);
  let textLen = 0;
  for (let i = 0; i < text.length; i++) {
    widths[i] = ctx.measureText(text[i]).width;
    textLen += widths[i];
  }
  // Lead-in offset: half of the leftover path length, so text is centered.
  // Clamped to 0 if text is longer than the path (rare with our 2%
  // overshoot, but defensive).
  const leadIn = Math.max(0, (totalLen - textLen) / 2);

  ctx.textBaseline = 'alphabetic'; // glyph baseline sits on the path
  ctx.textAlign = 'center';        // each glyph centered on its sample point

  let cursor = leadIn;
  for (let i = 0; i < text.length; i++) {
    const ch = text[i];
    const w = widths[i];
    // Place the glyph centered at the midpoint of its allocated arc
    // length. Drawing each character one at a time means we lose
    // kerning pairs that the font might otherwise apply, but that's
    // an unavoidable trade-off for path-laid text — same as Konva's
    // own TextPath.
    const sample = sampleAtArcLength(table, p0, p1, p2, cursor + w / 2);
    ctx.save();
    ctx.translate(sample.x, sample.y);
    ctx.rotate(sample.angle);
    if (fill) {
      ctx.fillStyle = fill;
      ctx.fillText(ch, 0, 0);
    }
    if (stroke && strokeWidth > 0) {
      ctx.lineWidth = strokeWidth;
      ctx.strokeStyle = stroke;
      ctx.lineJoin = 'round';
      ctx.miterLimit = 2;
      ctx.strokeText(ch, 0, 0);
    }
    ctx.restore();
    cursor += w;
  }

  ctx.restore();
  ctx.textAlign = prevAlign;
  ctx.textBaseline = prevBaseline;
}

// ── API: Health check that exercises the renderer (S20) ────────────────────

app.get('/api/health', async (_req, res) => {
  // Returns 200 only when (a) node-canvas can render a tiny scene, and
  // (b) sharp can resize that scene's output. The Docker HEALTHCHECK
  // polls this endpoint, so a degraded renderer takes the container
  // out of the load balancer rather than serving broken exports.
  try {
    const c = createCanvas(50, 50);
    const cctx = c.getContext('2d');
    cctx.fillStyle = '#ec4899';
    cctx.fillRect(0, 0, 50, 50);
    cctx.fillStyle = '#fff';
    cctx.font = '16px sans-serif';
    cctx.fillText('ok', 8, 32);
    const buf = c.toBuffer('image/png');
    await sharp(buf).resize(25, 25).toBuffer();
    res.json({ status: 'ok', timestamp: new Date().toISOString() });
  } catch (err) {
    logger.error({ err: err.message }, 'Health check failed');
    res.status(503).json({
      status: 'degraded',
      error: err.message,
      timestamp: new Date().toISOString(),
    });
  }
});

// ── API: Upload ────────────────────────────────────────────────────────────

app.post('/api/upload', uploadLimiter, upload.single('image'), async (req, res) => {
  try {
    if (!req.file) {
      return res.status(400).json({ error: 'No file uploaded' });
    }

    const originalUrl = `/uploads/${req.file.filename}`;

    // Create preview by resizing to max 1000px
    const previewFilename = `${uuidv4()}.png`;
    const previewDir = join(uploadsDir, 'preview');
    if (!existsSync(previewDir)) mkdirSync(previewDir, { recursive: true });

    await sharp(req.file.path)
      .resize({ width: 1000, height: 1000, fit: 'inside' })
      .png()
      .toFile(join(previewDir, previewFilename));

    res.json({
      success: true,
      original: {
        url: originalUrl,
        filename: req.file.filename,
        size: req.file.size,
        mimetype: req.file.mimetype,
      },
      preview: {
        url: `/uploads/preview/${previewFilename}`,
        filename: previewFilename,
      },
    });
  } catch (err) {
    req.log?.error({ err: err.message }, 'Upload pipeline failure');
    res.status(500).json({ error: 'Failed to process upload', details: err.message });
  }
});

// ── API: Export ────────────────────────────────────────────────────────────

const EXPORT_SCALE = 15;
const EXPORT_SIZE = 4500;

app.post('/api/export', exportLimiter, async (req, res) => {
  // Validate up-front. Failure here is cheap (no canvas allocated yet).
  const parsed = ExportRequestSchema.safeParse(req.body);
  if (!parsed.success) {
    req.log?.warn({ issues: parsed.error.flatten() }, 'Export request validation failed');
    return res.status(400).json({
      error: 'Invalid export request',
      details: parsed.error.flatten(),
    });
  }
  const { elements, designName = 'design', template } = parsed.data;

  try {
    const canvas = createCanvas(EXPORT_SIZE, EXPORT_SIZE);
    const ctx = canvas.getContext('2d');

    // Template background
    //
    // Three behaviors:
    //   - color: fill the entire export with the template's color. Templates
    //     opting into a color background do so deliberately (it's how a
    //     framed-art template or a tile design carries its base layer).
    //   - image: draw the template's image as a full-bleed background.
    //   - (no template, or template without a background block): leave
    //     the canvas transparent. The export's purpose is the design,
    //     not the shirt mockup — the shirt is what the design gets
    //     PRINTED onto, so the export needs to be transparent for the
    //     printer to composite the design over the actual fabric.
    //     Filling white here would screen-print a literal white
    //     rectangle onto every shirt color, which is the opposite of
    //     what the user wants.
    //
    // Image-background load failure falls through to transparent (not
    // white) so a broken template doesn't bake an opaque white frame
    // into the export. The render still completes; the user just sees
    // the design's foreground elements over the page's natural
    // background in any preview, and over the shirt color when printed.
    if (template?.background) {
      const bg = template.background;
      if (bg.type === 'color') {
        ctx.fillStyle = bg.color;
        ctx.fillRect(0, 0, EXPORT_SIZE, EXPORT_SIZE);
      } else if (bg.type === 'image' && bg.src) {
        try {
          let imgUrl;
          if (bg.src.startsWith('/')) {
            const safe = safeUploadPath(bg.src);
            if (!safe) throw new Error(`Invalid template background path: ${bg.src}`);
            imgUrl = safe;
          } else {
            imgUrl = bg.src;
          }
          const img = await loadImage(imgUrl);
          ctx.drawImage(img, 0, 0, EXPORT_SIZE, EXPORT_SIZE);
        } catch (imgError) {
          req.log?.warn({ err: imgError.message }, 'Template background load failed; leaving canvas transparent');
        }
      }
    }
    // No `else` branch — the canvas stays at its default fully
    // transparent state, which is what we want for printable exports.

    // Render a single element.
    // Konva rotates around the node's (x, y) origin, NOT the center, so the
    // export mirrors that: translate to (x, y), rotate, draw at (x, y).
    //
    // 'image' and 'sticker' types share the same rendering path (S10).
    // Stickers are images whose `src` is a data: URL of a rasterized
    // emoji glyph; both go through `loadImageWithFilter`.
    const renderElement = async (el) => {
      ctx.save();

      const x = (el.x || 0) * EXPORT_SCALE;
      const y = (el.y || 0) * EXPORT_SCALE;
      const rotation = el.rotation || 0;
      const opacity = typeof el.opacity === 'number' ? el.opacity : 1;
      ctx.globalAlpha = opacity;

      if (rotation) {
        ctx.translate(x, y);
        ctx.rotate((rotation * Math.PI) / 180);
        ctx.translate(-x, -y);
      }

      const isImage = el.type === 'image' || el.type === 'sticker';

      if (isImage && el.src) {
        try {
          const img = await loadImageWithFilter(el.src, el.filter || 'none');
          const width = (el.width || 100) * EXPORT_SCALE;
          const height = (el.height || 100) * EXPORT_SCALE;

          // 🖼️ Diagnostic logging for image / sticker elements. Captures the
          // resolved image's natural dimensions, the draw box, and whether
          // the source carried an alpha channel. node-canvas's loadImage
          // returns a Canvas-like Image with `width`/`height` properties
          // even when the underlying PNG had alpha; the alpha is only
          // preserved if the render canvas was set up with alpha support
          // (which createCanvas does by default).
          logger.info({
            type: el.type,
            srcPrefix: typeof el.src === 'string' ? el.src.slice(0, 60) : undefined,
            srcKind: el.src.startsWith('data:') ? 'data-url'
              : el.src.startsWith('/stickers/') ? 'sticker-library'
              : el.src.startsWith('/') ? 'upload' : 'external',
            naturalSize: { w: img.width, h: img.height },
            drawAt: { x, y, width, height },
            crop: el.crop,
            rotation: el.rotation,
            opacity: el.opacity,
            flip: { x: el.flipX, y: el.flipY },
          }, '🖼️ image render');

          // Apply flip via context transform around the element's center.
          if (el.flipX || el.flipY) {
            const cx = x + width / 2;
            const cy = y + height / 2;
            ctx.translate(cx, cy);
            ctx.scale(el.flipX ? -1 : 1, el.flipY ? -1 : 1);
            ctx.translate(-cx, -cy);
          }

          if (el.crop) {
            ctx.drawImage(img, el.crop.sx, el.crop.sy, el.crop.sWidth, el.crop.sHeight, x, y, width, height);
          } else {
            ctx.drawImage(img, x, y, width, height);
          }
        } catch (imgError) {
          req.log?.warn({ err: imgError.message, src: el.src }, '⚠️ Image element load failed');
        }
      } else if (el.type === 'text') {
        // Mirror Konva's Text positioning AND alignment.
        //
        // On the client, TextElement renders with `align="center"` and an
        // explicit `width = measuredW` (the actual canvas-measured glyph
        // width). That visually centers the text within the width box.
        // When `width === measuredW` (the common case), centering is a
        // no-op horizontally and the visible glyph's left edge sits at x.
        // But for short text where `width = max(fontSize, measuredW)` >
        // measuredW (e.g. a single short word), the visible text sits
        // centered within the wider width.
        //
        // To match that exactly, we mirror Konva's behavior here: measure
        // the rendered text width on the export's font, then center it
        // within the bbox. We use `ctx.textAlign = 'center'` and draw at
        // x + width/2 (a half-width offset from the left edge). If the
        // client somehow sent a width less than the measured width we
        // default to the measured value so the export never under-allocates.
        const fontSize = (el.fontSize || 32) * EXPORT_SCALE;
        const fontFamily = el.fontFamily || 'DM Sans';
        ctx.font = `${fontSize}px "${fontFamily}"`;
        ctx.textBaseline = 'top';

        const textStr = el.text || '';
        const measured = ctx.measureText(textStr).width;

        // Arc routing. When the user has dialed in non-zero arc, the
        // client renders via Konva's TextPath. We mirror that here by
        // walking the same quadratic Bézier and drawing one glyph at a
        // time. See renderTextOnArc above for the math; this branch
        // delegates everything (positioning, centering, stroke) to it.
        //
        // approxW / approxH are the client's measurements scaled to
        // export coords. LINE_HEIGHT_RATIO mirrors the constants file
        // (1.2) — hardcoded here because server.js doesn't import the
        // client constants module.
        const arc = el.arc ?? 0;
        const arcAbs = Math.min(100, Math.abs(arc));
        const isArced = arcAbs > 0.5;
        if (isArced) {
          const LINE_HEIGHT_RATIO = 1.2;
          const approxW = Math.max(fontSize, measured);
          const approxH = fontSize * LINE_HEIGHT_RATIO;
          renderTextOnArc(ctx, {
            text: textStr,
            x, y,
            fontSize, fontFamily,
            approxW, approxH, arc,
            fill: el.fill || '#000000',
            stroke: el.stroke,
            strokeWidth: (el.strokeWidth || 0) * EXPORT_SCALE,
          });
          // ✍️ Diagnostic for the arc'd path.
          logger.info({
            text: textStr,
            fontFamily,
            fontSizePx: fontSize,
            arc,
            drawAt: { x, y },
            measuredW: measured,
            fill: el.fill,
          }, '✍️ text render (arc)');
          ctx.restore();
          return;
        }

        // Konva-side width is in design units; scale to export units. If
        // not supplied (older saved state, mid-flight elements), fall
        // back to the measured glyph width — same as Konva's `width =
        // max(fontSize, measuredW)` behavior.
        const designW = typeof el.width === 'number' && el.width > 0 ? el.width * EXPORT_SCALE : measured;
        const boxW = Math.max(designW, measured);
        const drawX = x + boxW / 2;

        ctx.textAlign = 'center';

        // Sanity check — if the font lookup silently failed, node-canvas
        // falls back to a ~10–12px built-in font regardless of the size
        // we requested in the ctx.font string. This is its documented
        // behavior and there's no API to detect it directly, but we can
        // infer it: a 720px font rendering "Bonnie" should measure
        // thousands of pixels wide, not tens. If the measured width is
        // wildly smaller than the requested font size suggests, the font
        // didn't take. We log a loud warning so missing-font failures
        // surface in the log instead of producing a silent
        // tiny-text-in-the-corner export.
        //
        // Heuristic: any non-empty text whose measured width is less than
        // 30% of the font size is suspicious. "i" at full size measures
        // ~30% of the em-square; anything shorter than that suggests the
        // glyphs are being rendered at the default size, not the requested
        // size.
        if (textStr.length > 0 && measured < fontSize * 0.3 * textStr.length / Math.max(textStr.length, 4)) {
          req.log?.warn({
            text: textStr,
            fontFamily,
            requestedFontPx: fontSize,
            measuredW: measured,
            hint: `Font "${fontFamily}" may not be registered with node-canvas. Run \`npm run fetch-fonts\` and restart the server.`,
          }, '⚠️ text render produced suspiciously small width — font likely missing');
        }

        // Text stroke (S8). Konva renders fill-then-stroke by default,
        // so the stroke draws on top of the fill, which is what reads
        // visually as an outline. We mirror that here.
        ctx.fillStyle = el.fill || '#000000';
        ctx.fillText(textStr, drawX, y);

        if (el.stroke && typeof el.strokeWidth === 'number' && el.strokeWidth > 0) {
          ctx.lineWidth = el.strokeWidth * EXPORT_SCALE;
          ctx.strokeStyle = el.stroke;
          ctx.lineJoin = 'round';
          ctx.miterLimit = 2;
          ctx.strokeText(textStr, drawX, y);
        }

        // ✍️ Diagnostic logging — fires once per text element rendered.
        // Emoji-prefixed so it's easy to grep export-related log lines
        // out of a noisy request log. Promoted from `debug` to `info`
        // level so it surfaces without LOG_LEVEL adjustment when
        // investigating missing-text reports.
        logger.info({
          text: textStr,
          fontFamily,
          fontSizePx: fontSize,
          drawAt: { x: drawX, y },
          measuredW: measured,
          boxW,
          fill: el.fill,
        }, '✍️ text render');
      }

      ctx.restore();
    };

    // 📦 Diagnostic: log the element manifest received from the client so we
    // can correlate "the user says X is missing" against "the server saw X
    // in the payload". Trims `src` strings to a short prefix so the log
    // line doesn't blow up with a multi-KB data: URL. Emoji prefixes
    // (🎨 📦 🖼️ ✍️ ✅ ⚠️) make export-related log lines easy to grep out of
    // a noisy request log.
    req.log?.info({
      count: elements.length,
      elements: elements.map((el) => ({
        type: el.type,
        x: el.x, y: el.y,
        width: el.width, height: el.height,
        text: el.type === 'text' ? el.text : undefined,
        fontSize: el.fontSize,
        fontFamily: el.fontFamily,
        fill: el.fill,
        srcPrefix: typeof el.src === 'string' ? el.src.slice(0, 60) : undefined,
        srcLen: typeof el.src === 'string' ? el.src.length : undefined,
        rotation: el.rotation,
        nonPrintable: el.nonPrintable,
      })),
    }, '🎨 Export request element manifest');

    for (const el of elements) {
      if (el.nonPrintable) continue;
      await renderElement(el);
    }

    if (template?.overlay) {
      for (const overlayEl of template.overlay) {
        if (overlayEl.nonPrintable) continue;
        await renderElement(overlayEl);
      }
    }

    const exportFilename = `${designName.replace(/[^a-z0-9]/gi, '_')}_${uuidv4()}.png`;
    const exportPath = join(exportsDir, exportFilename);
    writeFileSync(exportPath, canvas.toBuffer('image/png'));

    req.log?.info({ filename: exportFilename, sizeBytes: canvas.toBuffer('image/png').length }, '✅ Export complete');

    res.json({
      success: true,
      export: {
        url: `/exports/${exportFilename}`,
        filename: exportFilename,
        width: EXPORT_SIZE,
        height: EXPORT_SIZE,
        dpi: 300,
        sizeInches: '15x15',
      },
    });
  } catch (err) {
    req.log?.error({ err: err.message }, '⚠️ Export pipeline failure');
    res.status(500).json({ error: 'Failed to export design', details: err.message });
  }
});

// ── API: Download ──────────────────────────────────────────────────────────

app.get('/api/download/:filename', (req, res) => {
  const safeName = basename(req.params.filename || '');
  if (!safeName || safeName.startsWith('.')) {
    return res.status(400).json({ error: 'Invalid filename' });
  }
  const filePath = join(exportsDir, safeName);
  const resolvedPath = resolve(filePath);
  if (resolvedPath !== exportsRoot && !resolvedPath.startsWith(exportsRoot + sep)) {
    return res.status(400).json({ error: 'Invalid filename' });
  }
  if (!existsSync(filePath)) {
    return res.status(404).json({ error: 'File not found' });
  }
  res.download(filePath);
});

// ── 404 / SPA catch-alls ───────────────────────────────────────────────────

app.all('/api/*', (_req, res) => {
  res.status(404).json({ error: 'API route not found' });
});

if (IS_PRODUCTION) {
  app.get('*', (_req, res) => {
    res.sendFile(join(__dirname, 'dist', 'index.html'));
  });
}

// ── Error handler ──────────────────────────────────────────────────────────

app.use((err, req, res, _next) => {
  if (err instanceof multer.MulterError) {
    if (err.code === 'LIMIT_FILE_SIZE') {
      return res.status(400).json({ error: 'File too large. Maximum size is 20MB.' });
    }
    return res.status(400).json({ error: err.message });
  }
  req.log?.error({ err: err.message, stack: err.stack }, 'Unhandled error');
  res.status(500).json({ error: 'Internal server error', details: err.message });
});

app.listen(PORT, () => {
  logger.info({ port: PORT, mode: IS_PRODUCTION ? 'production' : 'development' }, 'Server listening');
});
