import {loadAnyDCode} from "../vendor/anyd-qr.js";
import {encodeHandleForUrl} from "@/router"

// Mirrors loadAnyDCode()'s memoized wasm instance for synchronous use in buildRenderTree. See docs/implementation.md#qr-encoder-loading.
let anyd = null;

export function preloadQrEncoder() {
    return loadAnyDCode().then(instance => { anyd = instance; });
}

// Maps label-layouts.js leaf types to anyd-qr.js symbology/ecc/size options, via a naming
// convention (plain id = anyd defaults; "-<name>" suffix names an ecc letter or rMQR size
// strategy). See docs/implementation.md#qr-leaf-type-mapping.
const QR_LEAF_TYPES = {
    "qr-l": {codeType: "qr", ecc: "L"},
    qr: {codeType: "qr", ecc: "M"},
    "qr-q": {codeType: "qr", ecc: "Q"},
    "qr-h": {codeType: "qr", ecc: "H"},
    "mqr-d": {codeType: "micro-qr", ecc: "Detection"},
    "mqr-l": {codeType: "micro-qr", ecc: "L"},
    mqr: {codeType: "micro-qr", ecc: "M"},
    "mqr-q": {codeType: "micro-qr", ecc: "Q"},
    rmqr: {codeType: "rmqr", ecc: "M"},
    "rmqr-min": {codeType: "rmqr", ecc: "M", size: "min"},
    "rmqr-max": {codeType: "rmqr", ecc: "M", size: "max"},
    "rmqr-h": {codeType: "rmqr", ecc: "H"},
    "rmqr-h-min": {codeType: "rmqr", ecc: "H", size: "min"},
    "rmqr-h-max": {codeType: "rmqr", ecc: "H", size: "max"},
};

function isQrLeaf(node) {
    return node.type in QR_LEAF_TYPES;
}

function encodeQr(text, codeType, options) {
    if (!anyd) {
        const err = new Error("The QR encoder is still loading — try again in a moment.");
        err.short = "loading…";
        throw err;
    }
    // BitMatrix-alike shim over anyd's row-major matrix, matching the old "qrcode" package's
    // modules.size/get() shape that drawQrLeaf/snapQrToCrispSize expect. See
    // docs/implementation.md#qr-module-matrix-shim.
    try {
        const {width, height, modules} = anyd.encode(codeType, new TextEncoder().encode(text), options).matrix;
        return {width, height, get: (row, col) => modules[row * width + col] !== 0};
    } catch (e) {
        // anyd's own errors (e.g. "capacity exceeded: …") have no `short` of their own - every
        // failure here comes down to the content not fitting the chosen QR variant's capacity.
        if (e && typeof e === "object" && !("short" in e)) {
            e.short = "too long";
        }
        throw e;
    }
}

const TRAILING_PADDING_PX = 3;		/* blank columns after the cut, same idea as the leading margin */

/* Sizing a label needs numbers only the driver can supply. */
export function tapeFromStatus(status) {
    const printAreaPx = status?.tape?.printAreaPx;
    const dpi = status?.printer?.dpi;
    if (!(printAreaPx > 0) || !(dpi > 0)) {
        return null;
    }
    return {
        printAreaPx,
        dpi,
        mediaWidthMm: status.tape.mediaWidthMm,
        printLengthPx: status.tape.printLengthPx > 0 ? status.tape.printLengthPx : 0,
        /* Brother's documented margin for the mounted tape, in raster columns. */
        leadPx: status.tape.marginsMm
            ? Math.round(status.tape.marginsMm * dpi / 25.4)
            : TRAILING_PADDING_PX,
    };
}

// A layout tree alternates row/column split nodes by nesting depth, with QR/text/empty leaves.
// See docs/implementation.md#layout-tree-structure.
const TEXT_REFERENCE_PX = 100;		/* font size text leaves measure their natural aspect ratio at */

// Below 10px, a general-purpose sans-serif gets illegible, so drawTextLeaf switches to a bitmap
// font (Tom Thumb/Silkscreen) instead; see the empirical rationale (font choice, `scale`, and why
// sizes aren't dpi-adjusted) at docs/implementation.md#pixel-font-selection. The 10px+ tier names
// Inter explicitly (see ../scss/_label-fonts.scss) rather than falling back to the CSS generic
// "sans-serif" keyword, which resolves to a different real font per browser/OS and would make the
// same label print differently depending on where it was rendered from.
const FONT_TIERS = [
    {belowPx: 8, family: "Tom Thumb", scale: 3.2, pixel: true},
    {belowPx: 10, family: "Silkscreen", pixel: true},
    {belowPx: Infinity, family: "Inter"},
];

function fontFamilyFor(fontPx) {
    return FONT_TIERS.find(t => fontPx < t.belowPx);
}

// Below this font size (px) or physical height (mm) - Tom Thumb's real-Chromium-tested legibility
// floor - text is a hard failure, the same two-metric shape as MIN_RECOMMENDED_QR_PX_PER_MODULE/
// MIN_RECOMMENDED_QR_MODULE_MM below (see checkTextSizes). No longer just silently blanked.
const MIN_TEXT_PX = 5;
const MIN_TEXT_MM = 0.5;
// Below this, still legible but a soft warning - flagged rather than blocking, same idea as
// MIN_RECOMMENDED_QR_PX_PER_MODULE/MIN_RECOMMENDED_QR_MODULE_MM.
const MIN_RECOMMENDED_TEXT_PX = 8;
const MIN_RECOMMENDED_TEXT_MM = 1;

// Below this many pixels per module, a QR-family code still technically fits (see
// snapQrToCrispSize's scale >= 1 hard requirement) but risks blurring together on a real thermal
// printer's dot pitch - a soft warning rather than the outright rejection scale < 1 gets.
const MIN_RECOMMENDED_QR_PX_PER_MODULE = 3;
// Below this per-module physical size (in mm), a QR-family code is a rule-of-thumb risk for a
// phone camera to resolve at normal scanning distance even when crisply printed at full
// resolution - also a soft warning, independent of the pixels-per-module check above (a printer
// can hit that check's px/module floor at any dpi, but only a high enough dpi keeps modules this
// physically small still legible).
const MIN_RECOMMENDED_QR_MODULE_MM = 0.5;

function isSplit(node) {
    return Array.isArray(node);
}

function parseMm(value, key) {
    const match = typeof value === "string" && /^([\d.]+)mm$/.exec(value);
    if (!match) {
        throw new Error(`An "empty" layout node needs a "${key}" like "2mm", got ${JSON.stringify(value)}.`);
    }
    return parseFloat(match[1]);
}

// Every node's width/height relate affinely (width = A*height + B, or symmetrically); `ownAxis`
// and `wantWidth` pick which direction and against which split axis. See
// docs/implementation.md#affine-width-height-relations.
function relation(node, ownAxis, wantWidth, pxPerMm) {
    if (!isSplit(node)) {
        if (isQrLeaf(node) && node.crispWidth !== undefined) {
            return {a: 0, b: wantWidth ? node.crispWidth : node.crispHeight};
        }
        if (isQrLeaf(node) || node.type === "text") {
            const aspect = node.aspect;
            return wantWidth ? {a: aspect, b: 0} : {a: 1 / aspect, b: 0};
        }
        const key = ownAxis ? "min-width" : "min-height";
        return {a: 0, b: parseMm(node[key], key) * pxPerMm};
    }

    const axis = !ownAxis;					// this split's own row(true)/column(false) axis
    const combinesAsWidth = axis;				// a row sums widths for a shared height
    const parts = node.map(child => relation(child, axis, combinesAsWidth, pxPerMm));
    const a = parts.reduce((sum, p) => sum + p.a, 0);
    const b = parts.reduce((sum, p) => sum + p.b, 0);
    if (combinesAsWidth === wantWidth) {
        return {a, b};
    }
    if (a === 0) {
        // Every child is fixed-size (a === 0) in the combining direction, so inverting would
        // divide by zero. See docs/implementation.md#fixed-size-relation-edge-case.
        const otherParts = node.map(child => relation(child, axis, wantWidth, pxPerMm));
        return {a: 0, b: Math.max(...otherParts.map(p => p.b))};
    }
    return {a: 1 / a, b: -b / a};				// invert: solve the affine relation the other way
}

// Top-down: assigns the fixed (width, height) box `node` must exactly fill, recursively, to
// every descendant; `ownAxis` carries the same meaning as in relation() above.
function layoutTree(node, ownAxis, width, height, pxPerMm) {
    node.box = {width, height};
    if (!isSplit(node)) {
        return;
    }
    const axis = !ownAxis;
    for (const child of node) {
        if (axis) {
            const {a, b} = relation(child, axis, true, pxPerMm);
            layoutTree(child, axis, a * height + b, height, pxPerMm);
        } else {
            const {a, b} = relation(child, axis, false, pxPerMm);
            layoutTree(child, axis, width, a * width + b, pxPerMm);
        }
    }
}

// Second top-down pass: turns each already-sized box into an absolute (x, y) position; kept
// separate from layoutTree since a node's size doesn't depend on its position.
function positionTree(node, ownAxis, x, y) {
    node.box.x = x;
    node.box.y = y;
    if (!isSplit(node)) {
        return;
    }
    const axis = !ownAxis;
    let cursor = axis ? x : y;
    for (const child of node) {
        if (axis) {
            positionTree(child, axis, cursor, y);
            cursor += child.box.width;
        } else {
            positionTree(child, axis, x, cursor);
            cursor += child.box.height;
        }
    }
}

// Pins each QR-family leaf's real crisp-pixel box.width/box.height so relation() above starts
// treating it as fixed-size. Appends a {short, message} entry to `warnings` for each of
// MIN_RECOMMENDED_QR_PX_PER_MODULE/MIN_RECOMMENDED_QR_MODULE_MM the leaf falls short of (still
// printable, just flagged as a scan-reliability risk), and unconditionally appends its raw
// {scale, moduleMm} to `qrInfo` - callers needing to show those numbers regardless of whether
// they tripped a threshold (see LabelLayoutPreview.vue's debug line) shouldn't have to re-derive
// them. See docs/implementation.md#crisp-qr-sizing.
function snapQrToCrispSize(node, pxPerMm, warnings, qrInfo) {
    if (isSplit(node)) {
        node.forEach(child => snapQrToCrispSize(child, pxPerMm, warnings, qrInfo));
        return;
    }
    if (isQrLeaf(node)) {
        const {width: modulesW, height: modulesH} = node.qr;
        const scale = Math.floor(Math.min(node.box.width / modulesW, node.box.height / modulesH));
        if (!(scale >= 1)) {
            const err = new Error("This text needs a bigger code than the tape allows — "
                + "try a shorter value or a wider tape.");
            err.short = "too big";
            throw err;
        }
        node.crispWidth = modulesW * scale;
        node.crispHeight = modulesH * scale;

        const moduleMm = scale / pxPerMm;
        qrInfo.push({scale, moduleMm});

        if (scale < MIN_RECOMMENDED_QR_PX_PER_MODULE) {
            warnings.push({
                short: `${scale}px/mod`,
                message: `This code's modules are only ${scale}px wide - they may blur together `
                    + "when printed; consider a bigger label or shorter content.",
            });
        }
        if (moduleMm < MIN_RECOMMENDED_QR_MODULE_MM) {
            warnings.push({
                short: `${moduleMm.toFixed(2)}mm/mod`,
                message: `This code's modules are only ${moduleMm.toFixed(2)}mm across - it may `
                    + "be too small to scan reliably; consider a bigger label or shorter content.",
            });
        }
    }
}

// The rendered font size, floored to a whole pixel - same reasoning as snapQrToCrispSize's
// integer module scale (rounding up could overflow the box a fractional size would have fit
// exactly). checkTextSizes and drawTextLeaf both call this rather than each computing their own
// raw fraction, so what gets measured/threshold-checked is exactly what gets drawn.
function effectiveFontPx(node, referencePx) {
    return Math.floor(referencePx * (node.box.height / node.naturalHeight));
}

// Same shape as snapQrToCrispSize but for "text" leaves: throws below MIN_TEXT_PX/MIN_TEXT_MM
// (too small to read at all), appends a {short, message} warning to `warnings` for each of
// MIN_RECOMMENDED_TEXT_PX/MIN_RECOMMENDED_TEXT_MM it falls short of, and unconditionally appends
// its raw {fontPx, fontMm} to `textInfo`. Must run after the tree's *final* layoutTree pass (unlike
// snapQrToCrispSize, which runs before solve() re-resolves the tree) - a text leaf's box.height
// isn't stable until then, since (unlike a QR leaf's crispWidth/crispHeight) it doesn't feed back
// into that re-resolve.
function checkTextSizes(node, referencePx, pxPerMm, warnings, textInfo) {
    if (isSplit(node)) {
        node.forEach(child => checkTextSizes(child, referencePx, pxPerMm, warnings, textInfo));
        return;
    }
    if (node.type !== "text") {
        return;
    }
    const fontPx = effectiveFontPx(node, referencePx);
    const fontMm = fontPx / pxPerMm;
    if (fontPx < MIN_TEXT_PX || fontMm < MIN_TEXT_MM) {
        const err = new Error(`This text only fits at ${fontPx}px `
            + `(${fontMm.toFixed(2)}mm) - too small to read; try a shorter value, a different `
            + "layout, or a bigger label.");
        err.short = "too small";
        throw err;
    }
    textInfo.push({fontPx, fontMm});

    if (fontPx < MIN_RECOMMENDED_TEXT_PX) {
        warnings.push({
            short: `${fontPx}px text`,
            message: `This text renders at only ${fontPx}px - it may be hard to read; `
                + "consider a bigger label or shorter content.",
        });
    }
    if (fontMm < MIN_RECOMMENDED_TEXT_MM) {
        warnings.push({
            short: `${fontMm.toFixed(2)}mm text`,
            message: `This text renders at only ${fontMm.toFixed(2)}mm tall - it may be hard to `
                + "read; consider a bigger label or shorter content.",
        });
    }
}

// Always measures in Inter at the reference size, since the eventual font (see FONT_TIERS) isn't
// known until layoutTree sizes the box this aspect ratio feeds into; the resulting mismatch (when
// a pixel font tier ends up chosen instead) is invisible in practice, and checkTextSizes still
// catches real failures.
function measureTextBlock(ctx, lines, referencePx) {
    ctx.font = `${referencePx}px "Inter"`;
    const width = Math.max(...lines.map(line => ctx.measureText(line).width));
    const height = referencePx * 1.15 * lines.length;
    return {width, height};
}

// Converts a resolved content tree (see templateContent) into one ready for layout. See
// docs/implementation.md#render-tree-construction.
function buildRenderTree(ctx, node, referencePx) {
    if (isSplit(node)) {
        return node.map(child => buildRenderTree(ctx, child, referencePx));
    }
    if (isQrLeaf(node)) {
        const {codeType, ...options} = QR_LEAF_TYPES[node.type];
        const qr = encodeQr(node.value, codeType, options);
        return {type: node.type, aspect: qr.width / qr.height, qr};
    }
    if (node.type === "text") {
        const lines = Array.isArray(node.value) ? node.value : [node.value];
        const {width, height} = measureTextBlock(ctx, lines, referencePx);
        return {type: "text", aspect: width / height, naturalHeight: height, lines};
    }
    return {type: "empty", "min-width": node["min-width"], "min-height": node["min-height"]};
}

function drawQrLeaf(ctx, node) {
    const {width: modulesW, height: modulesH} = node.qr;
    const scale = Math.floor(Math.min(node.box.width / modulesW, node.box.height / modulesH));
    const w = modulesW * scale;
    const h = modulesH * scale;
    const left = node.box.x + Math.floor((node.box.width - w) / 2);
    const top = node.box.y + Math.floor((node.box.height - h) / 2);
    for (let row = 0; row < modulesH; row++) {
        for (let col = 0; col < modulesW; col++) {
            if (node.qr.get(row, col)) {
                ctx.fillRect(left + col * scale, top + row * scale, scale, scale);
            }
        }
    }
}

// Returns the effective (un-normalized) font size drawn at; drawTree collects these into
// drawLabel/drawFallbackLabel's textSizesPx. Always draws - checkTextSizes (run earlier, on the
// same tree, before any of this) already rejected anything below MIN_TEXT_PX/MIN_TEXT_MM, so
// there's no "too small to draw" case left to special-case here.
function drawTextLeaf(ctx, node, referencePx) {
    const fontPx = effectiveFontPx(node, referencePx);
    const {family, scale = 1, pixel: isPixelFont = false} = fontFamilyFor(fontPx);
    // Position (not size - fontPx is already a whole pixel) still needs snapping for pixel fonts
    // to stay grid-aligned, since node.box.x/y are ordinary (fractional) layout math; Inter is
    // left exact since anti-aliasing handles fractional positions fine.
    const snap = isPixelFont ? Math.round : (v) => v;
    // `scale` (Tom Thumb only, see FONT_TIERS above) corrects the size handed to ctx.font for its
    // real ink; fontPx itself stays the logical size used for centering/stacking math.
    ctx.font = `${fontPx * scale}px "${family}"`;
    // Canvas text silently falls back if drawn before a not-yet-loaded font resolves, unlike DOM
    // text. See docs/implementation.md#canvas-font-loading. Every tier now names a real,
    // self-hosted webfont (see FONT_TIERS above), so this always applies, not just to pixel fonts.
    document.fonts.load(ctx.font);
    ctx.textAlign = "center";
    const centerX = snap(node.box.x + node.box.width / 2);
    const lineHeight = node.box.height / node.lines.length;
    // Lines stack as a block, each centered under the last, so a multi-line field reads as one unit.
    let sliceTop = node.box.y;
    for (const line of node.lines) {
        if (isPixelFont) {
            // textBaseline:"middle" centers on declared ascent/descent, which is backwards for
            // Tom Thumb; centering on actualBoundingBox{Ascent,Descent} instead measures this
            // string's real rendered ink and stays correct regardless.
            ctx.textBaseline = "alphabetic";
            const {actualBoundingBoxAscent: up, actualBoundingBoxDescent: down} = ctx.measureText(line);
            ctx.fillText(line, centerX, snap(sliceTop + (lineHeight + up - down) / 2));
        } else {
            ctx.textBaseline = "middle";
            ctx.fillText(line, centerX, sliceTop + lineHeight / 2);
        }
        sliceTop += lineHeight;
    }
    return fontPx;
}

// Manual debug toggle (flip in a debugger) to outline every leaf's box, including
// normally-invisible "empty" ones, in a color that can't be mistaken for real label ink; never
// wired up to any UI.
let DEBUG_LEAF_BORDERS = false;

function drawDebugBorder(ctx, node) {
    ctx.save();
    ctx.strokeStyle = "red";
    ctx.lineWidth = 1;
    // Inset by half a pixel so the 1px stroke lands crisply on-pixel instead of straddling the edge.
    ctx.strokeRect(node.box.x + 0.5, node.box.y + 0.5, node.box.width - 1, node.box.height - 1);
    ctx.restore();
}

// Collects each text leaf's effective font size so callers (Print.vue) can spot a suspiciously
// small field (checkTextSizes' warnings/textInfo cover the same numbers in more structured form).
function drawTree(ctx, node, referencePx, textSizesPx) {
    if (isSplit(node)) {
        node.forEach(child => drawTree(ctx, child, referencePx, textSizesPx));
        return;
    }
    if (isQrLeaf(node)) {
        drawQrLeaf(ctx, node);
    } else if (node.type === "text") {
        textSizesPx.push(drawTextLeaf(ctx, node, referencePx));
    }
    // "empty" leaves carry no ink - their box just reserves the space.
    if (DEBUG_LEAF_BORDERS) {
        drawDebugBorder(ctx, node);
    }
}

// Builds, sizes and validates the tree for a fixed dimension plus pxPerMm; runs sizing twice so
// QR-family leaves' real crisp size is known before the tree is finally resolved, then checks
// every text leaf's final font size. See docs/implementation.md#label-content-layout.
function layoutContent(ctx, content, fixedSize, maxLength, referencePx, pxPerMm, orientation) {
    const tree = buildRenderTree(ctx, content, referencePx);
    const alongTape = orientation !== "across";

    const solve = () => {
        const {a, b} = relation(tree, false, alongTape, pxPerMm);
        return alongTape
            ? {width: a * fixedSize + b, height: fixedSize}
            : {width: fixedSize, height: a * fixedSize + b};
    };

    let {width, height} = solve();
    layoutTree(tree, false, width, height, pxPerMm);
    const warnings = [];
    const qrInfo = [];
    snapQrToCrispSize(tree, pxPerMm, warnings, qrInfo);

    ({width, height} = solve());
    const length = alongTape ? width : height;
    if (maxLength !== Infinity && length > maxLength) {
        const err = new Error("This doesn't fit on this tape — "
            + "try a shorter value, a different layout, or a bigger label.");
        err.short = "too big";
        throw err;
    }
    layoutTree(tree, false, width, height, pxPerMm);
    const textInfo = [];
    checkTextSizes(tree, referencePx, pxPerMm, warnings, textInfo);
    return {tree, length, warnings, qrInfo, textInfo};
}

// The tape-fed layout: draws a fully resolved content tree (see templateContent) at the tape's
// real pixel dimensions. See docs/implementation.md#tape-fed-label-drawing. Returns
// {textSizesPx}: each "text" leaf's effective font size, in the tree's own left-to-right,
// top-to-bottom order; {warnings}: {short, message} scan-reliability entries from
// snapQrToCrispSize/checkTextSizes, if any; {qrInfo}: each QR-family leaf's raw
// {scale, moduleMm}; {textInfo}: each text leaf's raw {fontPx, fontMm} - both regardless of
// whether they tripped a warning.
export function drawLabel(canvas, tape, content, orientation = "along") {
    const maxLength = tape.printLengthPx
        ? tape.printLengthPx - tape.leadPx - TRAILING_PADDING_PX
        : Infinity;
    const measureCtx = canvas.getContext("2d");
    const pxPerMm = tape.dpi / 25.4;
    const {tree, length: contentLength, warnings, qrInfo, textInfo} = layoutContent(
        measureCtx, content, tape.printAreaPx, maxLength, TEXT_REFERENCE_PX, pxPerMm, orientation);

    const printedLength = tape.printLengthPx || Math.ceil(contentLength + tape.leadPx + TRAILING_PADDING_PX);
    canvas.width = printedLength;
    canvas.height = tape.printAreaPx;

    const ctx = canvas.getContext("2d", {willReadFrequently: true});
    ctx.fillStyle = "#fff";
    ctx.fillRect(0, 0, printedLength, canvas.height);
    ctx.fillStyle = "#000";

    const origin = tape.leadPx
        + Math.floor((printedLength - tape.leadPx - TRAILING_PADDING_PX - contentLength) / 2);
    const textSizesPx = [];
    if (orientation === "across") {
        // Rotates/translates the width-fixed tree into the physical raster a quarter turn at a
        // time. See docs/implementation.md#across-orientation-rotation.
        positionTree(tree, false, 0, origin);
        ctx.save();
        ctx.translate(0, tape.printAreaPx);
        ctx.rotate(-Math.PI / 2);
        drawTree(ctx, tree, TEXT_REFERENCE_PX, textSizesPx);
        ctx.restore();
    } else {
        positionTree(tree, false, origin, 0);
        drawTree(ctx, tree, TEXT_REFERENCE_PX, textSizesPx);
    }
    return {textSizesPx, warnings, qrInfo, textInfo};
}

const FALLBACK_LABEL_HEIGHT_PX = 200;	/* reference height the no-webusb preview/PNG scales from */
const FALLBACK_DPI = 203;		/* reference resolution for turning "empty" leaves' mm sizes into px */

// The no-webusb preview/PNG: same layout tree/renderer as drawLabel, scaled from a fixed
// reference height instead. See docs/implementation.md#fallback-label-preview. `orientation` and
// the {textSizesPx, warnings, qrInfo, textInfo} return, see drawLabel.
export function drawFallbackLabel(canvas, content, orientation = "along") {
    const measureCtx = canvas.getContext("2d");
    const pxPerMm = FALLBACK_DPI / 25.4;
    const {tree, length: contentLength, warnings, qrInfo, textInfo} = layoutContent(
        measureCtx, content, FALLBACK_LABEL_HEIGHT_PX, Infinity, TEXT_REFERENCE_PX, pxPerMm, orientation);

    canvas.width = Math.ceil(contentLength);
    canvas.height = FALLBACK_LABEL_HEIGHT_PX;

    const ctx = canvas.getContext("2d", {willReadFrequently: true});
    ctx.fillStyle = "#fff";
    ctx.fillRect(0, 0, canvas.width, canvas.height);
    ctx.fillStyle = "#000";

    const textSizesPx = [];
    if (orientation === "across") {
        positionTree(tree, false, 0, 0);
        ctx.save();
        ctx.translate(0, FALLBACK_LABEL_HEIGHT_PX);
        ctx.rotate(-Math.PI / 2);
        drawTree(ctx, tree, TEXT_REFERENCE_PX, textSizesPx);
        ctx.restore();
    } else {
        positionTree(tree, false, 0, 0);
        drawTree(ctx, tree, TEXT_REFERENCE_PX, textSizesPx);
    }
    return {textSizesPx, warnings, qrInfo, textInfo};
}

// Turns a {kind, components} prefill (see Print.vue's `prefill` prop) into the literal string a
// print label should show/encode; keyed by `kind` so each kind's format is defined in one place.
export const LABEL_CONTENT_BUILDERS = {
    // The self-contained Item URL (see docs/design-in-progress/items-labels.md), built from just
    // the prefill's {userHandle, item}; the short link (Print.vue's `shortUrl`) needs an async store
    // lookup, so it stays a separate field/template rather than being baked in here.
    "item": ({userHandle, item}) => `${window.location.origin}/i/${encodeHandleForUrl(userHandle)}/${item}`,
    // Storage locations have no long-form URL route yet (see router.js), so `text` starts blank;
    // the short link and any future location template still work via the base vars below.
};

export function buildLabelContent(prefill) {
    if (!prefill) {
        return "";
    }
    const build = LABEL_CONTENT_BUILDERS[prefill.kind];
    return build ? build(prefill.components) : "";
}

// Splits a prefill's {userHandle, item/location} into label-layouts.js's user/domain base vars (the same way
// store.js's lookupServer does), tagging on whichever id field the resource's templates key
// required_vars by.
function splitUserHandle(userHandle) {
    if (!userHandle) {
        return null;
    }
    const at = userHandle.indexOf("@");
    return {
        user: at === -1 ? userHandle : userHandle.slice(0, at),
        domain: at === -1 ? "" : userHandle.slice(at + 1),
    };
}

// Seeds label-layouts.js's BASE_VARS, keyed by `kind`; derived vars (userHandle, itemUrl, …) are
// computed live elsewhere (see DERIVED_VARS, Print.vue's `shortUrl`), and omitting a field
// (rather than leaving it present-but-empty) signals "not available" to templateIsAvailable.
const LABEL_FIELD_BUILDERS = {
    "item": ({userHandle, item}) => {
        const split = splitUserHandle(userHandle);
        if (!split || !item) {
            return {};
        }
        return {...split, itemId: String(item)};
    },
    "storage-location": ({userHandle, location}) => {
        const split = splitUserHandle(userHandle);
        if (!split || !location) {
            return {};
        }
        return {...split, locationId: String(location)};
    },
};

export function buildLabelFields(prefill) {
    if (!prefill) {
        return {};
    }
    const build = LABEL_FIELD_BUILDERS[prefill.kind];
    return build ? build(prefill.components) : {};
}
