<template>
    <BaseLayout>
        <main class="content">
            <div class="container-fluid p-0">
                <h1 class="h3 mb-3">Print a label</h1>

                <div v-if="error" class="alert alert-danger" role="alert">{{ error }}</div>

                <div v-if="warnings.length" class="alert alert-warning" role="alert">
                    <div v-for="(w, i) in warnings" :key="i">{{ w.message }}</div>
                </div>

                <div v-if="!usbSupported" class="alert alert-warning">
                    This browser can't talk to USB label printers directly. Open this page in Chrome, Edge or
                    Opera over https:// (or http://localhost) to print straight from here, or use one of the
                    alternatives below.
                </div>

                <div class="row mb-3">
                    <div class="col-lg-8">
                        <div class="card h-100">
                            <div class="card-header">
                                <h5 class="card-title mb-0">Label content</h5>
                            </div>
                            <div class="card-body">
                                <div class="row g-3">
                                    <div :class="v === 'text' ? 'col-12' : 'col-md-6'" v-for="v in baseVars" :key="v">
                                        <label class="form-label">{{ varLabel(v) }}</label>
                                        <textarea v-if="v === 'text'" class="form-control" rows="3"
                                                  v-model="varValues[v]"
                                                  placeholder="https://example.com/…"></textarea>
                                        <input v-else type="text" class="form-control" v-model="varValues[v]">
                                    </div>
                                </div>
                            </div>
                        </div>
                    </div>
                    <div class="col-lg-4">
                        <div class="card h-100">
                            <div class="card-header">
                                <h5 class="card-title mb-0">Calculated</h5>
                            </div>
                            <div class="card-body">
                                <div v-for="v in derivedVars" :key="v" class="mb-2">
                                    <div class="small text-muted">{{ varLabel(v) }}</div>
                                    <div class="text-break">{{ fields[v] || "—" }}</div>
                                </div>
                            </div>
                        </div>
                    </div>
                </div>

                <div class="card mb-3 border-warning">
                    <div class="card-header d-flex justify-content-between align-items-center">
                        <h5 class="card-title mb-0">Experimental: optimal font by size</h5>
                        <small class="text-muted">for judging legibility - not part of the real label pipeline</small>
                    </div>
                    <div class="card-body">
                        <p class="text-muted small mb-3">
                            For every nominal size from 5px to 30px, the font and effective size
                            <code>optimal_fonts</code> actually picks for it - rendered live from the "Text"
                            field above through libfreetype-wasm's own monochrome rasterizer
                            (FT_LOAD_TARGET_MONO), a true 1-bit render, not canvas fillText's grayscale AA
                            thresholded after the fact. The line height under each canvas is
                            <code>ft_get_line_height</code> at that cell's actually-drawn size, the same
                            metric prototypes/freetype-wasm's own demo shows.
                        </p>
                        <div v-if="freetypeError" class="alert alert-warning small py-2">
                            FreeType failed to load: {{ freetypeError }} - canvases will stay blank.
                        </div>
                        <div class="d-flex flex-wrap gap-3">
                            <div v-for="size in previewSizes" :key="size" class="text-center">
                                <div class="small text-muted mb-1">{{ size }}px</div>
                                <canvas :ref="el => setPreviewCanvasRef(size, el)"
                                        class="pixel-test-canvas"></canvas>
                                <div class="small text-muted mt-1" v-if="optimalFontFor(size)">
                                    {{ optimalFontFor(size).family }} @{{ optimalFontFor(size).size }}px
                                </div>
                                <div class="small text-muted" v-if="freetypeLineHeights[size] !== undefined">
                                    line height: {{ freetypeLineHeights[size] }}px
                                </div>
                            </div>
                        </div>
                    </div>
                </div>

                <div v-if="!usbSupported" class="row">
                    <div class="col-lg-7">
                        <div class="card">
                            <div class="card-header">
                                <h5 class="card-title mb-0">Label preview</h5>
                            </div>
                            <div class="card-body">
                                <div class="label-preview mb-3" v-show="fallbackReady">
                                    <canvas ref="fallbackCanvas"></canvas>
                                </div>

                                <div class="mb-3">
                                    <label class="form-label">Orientation</label>
                                    <div class="btn-group d-block" role="group">
                                        <input type="radio" class="btn-check" id="orientation-along-fallback"
                                               autocomplete="off" value="along" v-model="orientation">
                                        <label class="btn btn-outline-secondary" for="orientation-along-fallback">
                                            Along tape
                                        </label>
                                        <input type="radio" class="btn-check" id="orientation-across-fallback"
                                               autocomplete="off" value="across" v-model="orientation">
                                        <label class="btn btn-outline-secondary" for="orientation-across-fallback">
                                            Across tape
                                        </label>
                                    </div>
                                </div>

                                <button class="btn btn-primary" :disabled="!fallbackReady" @click="downloadPng">
                                    <b-icon-download class="me-1"></b-icon-download>
                                    Download label as PNG
                                </button>
                            </div>
                        </div>
                    </div>
                    <div class="col-lg-5 mb-4">
                        <div class="card">
                            <div class="card-header">
                                <h5 class="card-title mb-0">Print from the command line</h5>
                            </div>
                            <div class="card-body">
                                <p class="text-muted">
                                    Download the PNG above, then send it to a label printer with its CLI tool.
                                </p>
                                <p class="mb-1"><strong>Brother QL-series</strong></p>
                                <pre class="code-block"><code>{{ brotherCliExample }}</code></pre>
                                <p class="mb-1"><strong>Niimbot</strong></p>
                                <pre class="code-block"><code>{{ niimbotCliExample }}</code></pre>
                            </div>
                        </div>
                    </div>
                </div>

                <div v-else class="row">

                    <div class="col-lg-7">
                        <div class="card">
                            <div class="card-header d-flex justify-content-between align-items-center">
                                <h5 class="card-title mb-0">Label</h5>
                                <small v-if="tape" class="text-muted">{{ tape.mediaWidthMm }} mm tape{{
                                        textSizesSummary
                                    }}</small>
                            </div>
                            <div class="card-body">
                                <p v-if="!tape" class="text-muted">
                                    Connect a printer to preview and print a label.
                                </p>
                                <template v-else>
                                    <div class="preview-row mb-3" v-show="labelBitmap">
                                        <div class="ruler-v">
                                            <div class="ruler-v-corner"></div>
                                            <div class="ruler ruler-v-ticks"
                                                 :style="{height: (tape.mediaWidthMm * tapePxPerMm) + 'px'}">
                                                <span v-for="t in verticalRulerTicks" :key="'tick-' + t.mm"
                                                      class="tick" :class="{'tick-major': t.major}"
                                                      :style="{top: t.pos + 'px'}"></span>
                                                <span v-for="t in verticalRulerTicks.filter(t => t.major)"
                                                      :key="'label-' + t.mm" class="tick-label"
                                                      :style="{top: t.pos + 'px'}">{{ t.mm }}</span>
                                            </div>
                                        </div>
                                        <div class="preview-track">
                                            <div class="ruler ruler-h"
                                                 :style="{width: (printedWidthPx * zoom) + 'px'}">
                                                <span v-for="t in horizontalRulerTicks" :key="'tick-' + t.mm"
                                                      class="tick" :class="{'tick-major': t.major}"
                                                      :style="{left: t.pos + 'px'}"></span>
                                                <span v-for="t in horizontalRulerTicks.filter(t => t.major)"
                                                      :key="'label-' + t.mm" class="tick-label"
                                                      :style="{left: t.pos + 'px'}">{{ t.mm }}</span>
                                            </div>
                                            <!-- Tape's full physical width; canvas is narrower/centered, margin is real tape. See docs/implementation.md#tape-full-print-margin. -->
                                            <div class="tape-full"
                                                 :style="{height: (tape.mediaWidthMm * tapePxPerMm) + 'px'}">
                                                <div class="label-preview">
                                                    <canvas ref="labelCanvas"></canvas>
                                                </div>
                                            </div>
                                        </div>
                                    </div>

                                    <div class="row g-2 align-items-end">
                                        <div class="col-auto">
                                            <label class="form-label">Orientation</label>
                                            <div class="btn-group" role="group">
                                                <input type="radio" class="btn-check" id="orientation-along"
                                                       autocomplete="off" value="along" v-model="orientation">
                                                <label class="btn btn-outline-secondary" for="orientation-along">
                                                    Along tape
                                                </label>
                                                <input type="radio" class="btn-check" id="orientation-across"
                                                       autocomplete="off" value="across" v-model="orientation">
                                                <label class="btn btn-outline-secondary" for="orientation-across">
                                                    Across tape
                                                </label>
                                            </div>
                                        </div>
                                        <div class="col-auto">
                                            <label class="form-label">Copies</label>
                                            <input type="number" class="form-control copies-input"
                                                   v-model.number="copies" min="1" max="20">
                                        </div>
                                        <div class="col-auto ms-auto">
                                            <button class="btn btn-primary" :disabled="!canPrint || busy"
                                                    @click="print">
                                                <b-icon-printer class="me-1"></b-icon-printer>
                                                Print
                                            </button>
                                        </div>
                                    </div>
                                </template>
                            </div>
                        </div>
                    </div>
                    <div class="col-lg-5 mb-4">
                        <div class="card">
                            <div class="card-header">
                                <h5 class="card-title mb-0">Printer</h5>
                            </div>
                            <div class="card-body">
                                <p v-if="!devices.length" class="text-muted">
                                    No label printer paired yet.
                                </p>
                                <ul v-else class="list-group mb-3">
                                    <li v-for="d in deviceRows" :key="d.index"
                                        class="list-group-item d-flex justify-content-between align-items-center">
                                        <div>
                                            <div>{{ d.name }}</div>
                                            <small v-if="d.isConnected" class="text-success">connected</small>
                                            <small v-else-if="!d.supported" class="text-muted">{{ d.reason }}</small>
                                        </div>
                                        <button v-if="d.isConnected" class="btn btn-sm btn-outline-secondary"
                                                :disabled="busy" @click="disconnect()">
                                            Disconnect
                                        </button>
                                        <button v-else-if="d.supported" class="btn btn-sm btn-outline-primary"
                                                :disabled="busy" @click="connect(d.index)">
                                            Connect
                                        </button>
                                    </li>
                                </ul>
                                <button class="btn btn-primary" :disabled="busy || !blobReady" @click="requestPrinter">
                                    <b-icon-plug class="me-1"></b-icon-plug>
                                    Pair new printer
                                </button>
                            </div>
                        </div>
                    </div>
                </div>

                <div class="row">
                    <div class="col-12">
                        <label-layout-preview :fields="fields" :value="selectedTemplate" :tape="tape"
                                              :recent-template-ids="recentTemplateIds"
                                              @input="selectedTemplate = $event"></label-layout-preview>
                    </div>
                </div>
            </div>
        </main>
    </BaseLayout>
</template>

<script>
import * as BIcons from "bootstrap-icons-vue";
import {markRaw, nextTick} from "vue";
import {mapActions, mapGetters} from "vuex";
import BaseLayout from "@/components/BaseLayout.vue";
import LabelLayoutPreview from "@/components/LabelLayoutPreview.vue";
import printerManager from "@/printerManager.js";

import {MultiPrinterBlob, canvasToBitmap, bitmapToCanvas} from "../../vendor/weblabel.js";
import {
    tapeFromStatus,
    drawLabel,
    drawFallbackLabel,
    buildLabelContent,
    buildLabelFields,
    preloadQrEncoder
} from "@/label.js";
import {LABEL_TEMPLATES, BASE_VARS, DERIVED_VARS, withDerivedVars, templateContent} from "@/label-layouts.js";
import {shortenedRoute} from "@/router";
import {loadFreeType, loadFontFace, drawMonoText} from "@/freetype-preview.js";

// URLs (not raw bytes - fetched lazily, see drawOptimalFontPreviews) for each font optimal_fonts
// can pick, fed to freetype-preview.js's loadFontFace. Silkscreen and Inter each need their own TTF
// copy (see pixel-candidates/LICENSE.md) since this build of FreeType can't parse WOFF2 (no brotli
// support - confirmed: FT error 2 on both production pixel/Silkscreen-Regular.woff2 and
// label/Inter-Regular.woff2).
import pixelonFontUrl from "@/assets/fonts/pixel-candidates/Pixelon.ttf?url";
import chavaFontUrl from "@/assets/fonts/pixel-candidates/Chava.ttf?url";
import terminusFontUrl from "@/assets/fonts/pixel-candidates/Terminus.ttf?url";
import silkscreenCandidateFontUrl from "@/assets/fonts/pixel-candidates/Silkscreen.ttf?url";
import interCandidateFontUrl from "@/assets/fonts/pixel-candidates/Inter.ttf?url";

// Print.vue-local calculated fields on top of label-layouts.js's DERIVED_VARS. See
// docs/implementation.md#calculated-short-link-fields.
const SHORT_URL_VAR = "shortUrl";
// See docs/implementation.md#calculated-short-link-fields.
const SHORT_ID_VAR = "shortId";

// Served unbundled so its wasm sibling stays resolvable. See docs/implementation.md#libweblabel-served-unbundled.
const BLOB_URL = "/vendor/libweblabel.js";

// localStorage key for the most-recently-printed template ids (see rememberPrintedTemplate/loadRecentTemplateIds), same naming style as cameraManager.js's recentCameraIds.
const RECENT_TEMPLATES_KEY = "recentLabelTemplateIds";
// How many recently-printed templates LabelLayoutPreview.vue bubbles to the front of the grid.
const MAX_RECENT_TEMPLATES = 4;

const MAX_ZOOM = 4;			/* never magnify the preview more than this */
const MAX_PREVIEW_HEIGHT_PX = 300;	/* never let the on-screen preview grow taller than this */
// Tick spacing tiers, coarser the longer the ruler runs. See docs/implementation.md#ruler-tier-selection.
const RULER_TIERS = [
    {aboveMm: 0, tickMm: 1, majorEveryMm: 5},
    {aboveMm: 100, tickMm: 1, majorEveryMm: 10},
    {aboveMm: 500, tickMm: 5, majorEveryMm: 25},
];

//after manually comparing all fonts at all sizes, it was decides that in some cases it's more readable to round down the font size to a more readable
// this is a lookup table for these optimal settings
const optimal_fonts = (size) => {
    if (size > 32)
        return {family: "Inter", url: interCandidateFontUrl, size: size};
    if (size >= 28)
        return {family: "Terminus", url: terminusFontUrl, size: 28}; // would even support bold mode >=14px
    if (size >= 24)
        return {family: "Terminus", url: terminusFontUrl, size: 24}; // would even support bold mode >=14px
    if (size >= 12)
        return {family: "Terminus", url: terminusFontUrl, size: (size - (size % 2))}; // would even support bold mode >=14px
    if (size >= 10)
        return {family: "Pixelon", url: pixelonFontUrl, size: 10};
    if (size >= 8)
        return {family: "Chava", url: chavaFontUrl, size: 8};
    if (size === 7)
        return {family: "Chava", url: chavaFontUrl, size: 7};
    if (size >= 5)
        return {family: "Silkscreen", url: silkscreenCandidateFontUrl, size: 8}; //Silkscreen comes out to an effective height of 5px at size 8
    return null;
};

export default {
    name: "Print",
    components: {
        BaseLayout,
        LabelLayoutPreview,
        ...BIcons
    },
    props: {
        // {kind, components} from the ?kind=…&… query params (router.js's /print route builds this prop); buildLabelContent turns it into the text field below.
        prefill: {
            type: Object,
            default: null
        },
        // Every query param other than `kind` (router.js), so any base var - e.g. ?text=... - can
        // be set directly without needing a `kind` builder in label-layouts.js.
        queryFields: {
            type: Object,
            default: () => ({})
        }
    },
    data() {
        return {
            usbSupported: true,
            blobReady: false,
            busy: false,
            error: null,

            devices: [],
            connected: null,
            tape: null,
            labelBitmap: null,
            // Tracked reactively (not read off the canvas) so the mm ruler can recompute tick positions when either changes (see fitZoom/redraw).
            zoom: 1,
            printedWidthPx: 0,
            // Each "text" leaf's effective font size (see label.js's drawLabel), shown beside the tape width so a too-small-to-render field reads as a suspiciously tiny number rather than silently absent.
            textSizesPx: [],
            // Scan-reliability messages from label.js's drawLabel/drawFallbackLabel (too few px per QR module, or too small in mm) - the label still rendered/prints fine, just flagged as a risk.
            warnings: [],

            // One input per BASE_VARS entry; derived vars (userHandle/itemUrl/itemHandle) are calculated-only (see the `fields` computed), never stored here. Prefilled from query params but left editable. queryFields is applied last so an explicit ?text=... etc. always wins over a `kind` builder's computed value.
            varValues: {
                ...Object.fromEntries(BASE_VARS.map(v => [v, ""])),
                text: buildLabelContent(this.prefill),
                // Defaults to this page's own origin; editable since any frontend can resolve any handle, so a label needn't point back at this one.
                webdomain: window.location.origin,
                ...buildLabelFields(this.prefill),
                ...Object.fromEntries(BASE_VARS.filter(v => v in this.queryFields).map(v => [v, this.queryFields[v]])),
            },
            copies: 1,
            selectedTemplate: LABEL_TEMPLATES[0].id,
            // Ids of the last MAX_RECENT_TEMPLATES distinct templates printed/downloaded, most recent first. See rememberPrintedTemplate/loadRecentTemplateIds.
            recentTemplateIds: [],
            // "along" reads along the tape's feed direction (usual case, width-constrained); "across" turns 90deg on that same width instead. See label.js's drawLabel/drawFallbackLabel.
            orientation: "along",

            fallbackReady: false,
            // TODO: replace with the real commands for our printers.
            brotherCliExample: "brother_ql --model QL-000 --printer usb://0000:0000 print --label 00 label.png",
            niimbotCliExample: "niimprint --model b00 --conn usb print --density 3 --image label.png",

            // Nominal sizes previewed by the "optimal font by size" card - 5px to 30px inclusive.
            previewSizes: Array.from({length: 36}, (_, i) => i + 5),
            previewZoom: 4,
            // Keyed by family, populated lazily the first time drawOptimalFontPreviews loads a font -
            // avoids refetching the same bytes on every varValues-triggered redraw.
            freetypeFontBytes: {},
            // Set if FreeType itself (not a specific font) fails to load - shown once above the grid
            // instead of failing every cell silently.
            freetypeError: null,
            // Keyed by nominal size - each cell's effective line height in px (ft_get_line_height at
            // the actually-drawn size), same metric prototypes/freetype-wasm's own index.html demo
            // shows below its canvas.
            freetypeLineHeights: {},
        };
    },
    computed: {
        ...mapGetters(["identityIdByHandle"]),
        // Plain passthroughs of the form's base/derived vars, keeping the template from importing
        // label-layouts.js just for these. SHORT_URL_VAR/SHORT_ID_VAR exclusion: see
        // docs/implementation.md#calculated-short-link-fields.
        baseVars() {
            return BASE_VARS.filter(v => v !== SHORT_URL_VAR && v !== SHORT_ID_VAR);
        },
        derivedVars() {
            return [...Object.keys(DERIVED_VARS), SHORT_ID_VAR, SHORT_URL_VAR];
        },
        // Named content fields the templates draw from, dropping blank values. See docs/implementation.md#fields-computed-dropping-blank-values.
        fields() {
            const base = {};
            for (const v of BASE_VARS) {
                if (this.varValues[v]) {
                    base[v] = this.varValues[v];
                }
            }
            const derived = withDerivedVars(base);
            const shortId = this.shortId(derived);
            if (shortId) {
                derived[SHORT_ID_VAR] = shortId;
                if (derived.webdomain) {
                    derived[SHORT_URL_VAR] = derived.webdomain + "/" + shortId;
                }
            }
            return derived;
        },
        currentTemplate() {
            return LABEL_TEMPLATES.find(t => t.id === this.selectedTemplate) || LABEL_TEMPLATES[0];
        },
        deviceRows() {
            if (!this.blob) {
                return [];
            }
            return this.devices.map((d, index) => {
                const info = this.blob.findDevice(d.vendorId, d.productId);
                return {
                    index,
                    name: info ? info.name : (d.productName || "Unknown device"),
                    supported: Boolean(info && info.supported),
                    reason: info ? (info.unsupportedReason || "unsupported") : "no driver for this device",
                    isConnected: d === this.connected,
                };
            });
        },
        canPrint() {
            return Boolean(this.tape && this.labelBitmap && !this.busy);
        },
        // px per real mm at current zoom; meaningful only for the tape-fed preview (the no-webusb fallback has no real tape/dpi, so no ruler).
        tapePxPerMm() {
            return this.tape ? (this.tape.dpi / 25.4) * this.zoom : 0;
        },
        // Physical mm length each ruler axis must cover; see horizontal/verticalRulerTicks for what each measures.
        horizontalTotalMm() {
            return (this.tape && this.printedWidthPx) ? this.printedWidthPx / (this.tape.dpi / 25.4) : 0;
        },
        verticalTotalMm() {
            return this.tape ? this.tape.mediaWidthMm : 0;
        },
        // Shared tier keyed off whichever axis is longer. See docs/implementation.md#ruler-tier-selection.
        rulerTier() {
            return RULER_TIERS.filter(t => Math.max(this.horizontalTotalMm, this.verticalTotalMm) >= t.aboveMm)
                .at(-1);
        },
        // Ticks along the tape's printed length, feed margins included (still real tape).
        horizontalRulerTicks() {
            if (!this.tape || !this.printedWidthPx) {
                return [];
            }
            return this.rulerTicks(this.horizontalTotalMm);
        },
        // Ticks across the tape's full width, not just the printable area. See docs/implementation.md#tape-full-print-margin.
        verticalRulerTicks() {
            return this.tape ? this.rulerTicks(this.verticalTotalMm) : [];
        },
        // e.g. "(5px, 23px)", or "" so it appends cleanly onto the tape-width <small> with nothing shown.
        textSizesSummary() {
            if (!this.textSizesPx.length) {
                return "";
            }
            return ` (${this.textSizesPx.map(px => Math.round(px) + "px").join(", ")})`;
        },
    },
    watch: {
        varValues: {
            handler() {
                if (this.usbSupported) {
                    this.redraw();
                } else {
                    this.redrawFallback();
                }
                this.drawOptimalFontPreviews();
            },
            deep: true,
        },
        selectedTemplate() {
            if (this.usbSupported) {
                this.redraw();
            } else {
                this.redrawFallback();
            }
        },
        orientation() {
            if (this.usbSupported) {
                this.redraw();
            } else {
                this.redrawFallback();
            }
        },
        // Catches printer connect/disconnect/switch; flush:'post' since the canvas only exists once `tape` is truthy (template's v-if).
        tape: {
            handler() {
                this.redraw();
            },
            flush: 'post'
        },
    },
    methods: {
        ...mapActions(["fetchIdMap"]),

        // camelCase -> Title Case (e.g. "itemHandle" -> "Item Handle") so a new template var needs no hand-written label.
        varLabel(v) {
            return v.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/^./, c => c.toUpperCase());
        },

        // Builds the bare short-id.js token for the current fields. See docs/implementation.md#shortid-resolution.
        shortId(f) {
            if (!f.userHandle) {
                return null;
            }
            const owner_identity_id = this.identityIdByHandle[f.userHandle];
            if (owner_identity_id === undefined) {
                return null;
            }
            if (f.itemId) {
                return shortenedRoute({kind: "item", owner_identity_id, item_local_id: f.itemId}).slice(1);
            }
            if (f.locationId) {
                return shortenedRoute({
                    kind: "storage_location", owner_identity_id, storage_location_id: f.locationId
                }).slice(1);
            }
            return null;
        },

        // Ticks 0..totalMm via tapePxPerMm, shared by both ruler computeds; spacing comes from the shared rulerTier, so both rulers coarsen together.
        rulerTicks(totalMm) {
            const {tickMm, majorEveryMm} = this.rulerTier;
            const ticks = [];
            for (let mm = 0; mm <= totalMm; mm += tickMm) {
                ticks.push({mm, pos: mm * this.tapePxPerMm, major: mm % majorEveryMm === 0});
            }
            return ticks;
        },

        // Reads the recently-printed template ids back from localStorage; same try/catch shape as
        // cameraManager.js's getRecentCameras since either a disabled/full localStorage shouldn't
        // break printing.
        loadRecentTemplateIds() {
            try {
                const saved = localStorage.getItem(RECENT_TEMPLATES_KEY);
                return saved ? JSON.parse(saved) : [];
            } catch (e) {
                return [];
            }
        },

        // Moves `id` to the front of the recent-templates list (deduping any earlier occurrence),
        // capped to MAX_RECENT_TEMPLATES, so LabelLayoutPreview.vue's grid always bubbles the
        // last four printed/downloaded layouts to the top.
        rememberPrintedTemplate(id) {
            const updated = [id, ...this.recentTemplateIds.filter(t => t !== id)].slice(0, MAX_RECENT_TEMPLATES);
            this.recentTemplateIds = updated;
            try {
                localStorage.setItem(RECENT_TEMPLATES_KEY, JSON.stringify(updated));
            } catch (e) {
                // Best-effort only - a disabled/full localStorage shouldn't block printing.
            }
        },

        async guard(fn) {
            this.error = null;
            this.busy = true;
            try {
                await fn();
            } catch (e) {
                this.error = e.message;
            } finally {
                this.busy = false;
            }
        },

        async refreshDevices() {
            this.devices = (await printerManager.enumerateDevices()).map((d) => markRaw(d));
            /* A printer unplugged while open drops from the list: its handle is gone, so close the card rather than keep it open on a dead connection. */
            if (this.connected !== null && !this.devices.includes(this.connected)) {
                this.connected = null;
                this.tape = null;
            }
        },

        requestPrinter() {
            this.guard(async () => {
                try {
                    await navigator.usb.requestDevice({filters: this.blob.usbFilters()});
                } catch (e) {
                    if (e.name === "NotFoundError") {
                        return;
                    }
                    throw e;
                }
                await this.refreshDevices();
            });
        },

        async closeConnection() {
            if (this.connected) {
                await this.blob.close();
                this.connected = null;
                this.tape = null;
            }
        },

        // Only one open connection at a time: connecting a different printer disconnects the current one first.
        async connectToDevice(device) {
            await this.closeConnection();
            this.blob.setDevices([device]);
            await this.blob.open(device.vendorId, device.productId);
            this.connected = device;
            printerManager.savePreferredPrinter(device);
            const status = await this.blob.status();
            // Setting `tape` alone is enough to redraw - see the tape watcher above.
            this.tape = this.blob.can("print") ? tapeFromStatus(status) : null;
        },

        connect(index) {
            this.guard(() => this.connectToDevice(this.devices[index]));
        },

        // Auto-connects to the most-recently-used printer once it's paired and visible again (page
        // load, or plugged back in - see onUsbChange), mirroring cameraManager's preferred-camera
        // auto-select. Gated on "nothing connected yet" since a manual connect() to a different
        // paired printer shouldn't be silently overridden by a later device-list refresh.
        autoConnectPreferred() {
            if (this.connected) {
                return Promise.resolve();
            }
            const preferred = printerManager.findPreferredDevice(this.devices);
            return preferred ? this.connectToDevice(preferred) : Promise.resolve();
        },

        disconnect() {
            this.guard(() => this.closeConnection());
        },

        redraw() {
            this.labelBitmap = null;
            if (!this.tape) {
                return;
            }
            const canvas = this.$refs.labelCanvas;
            if (!canvas) {
                return;
            }
            this.resizeObserver.observe(canvas.parentElement);
            let textSizesPx, warnings;
            try {
                const content = templateContent(this.currentTemplate, this.fields);
                ({textSizesPx, warnings} = drawLabel(canvas, this.tape, content, this.orientation));
            } catch (e) {
                this.error = e.message;
                return;
            }
            this.error = null;
            this.textSizesPx = textSizesPx;
            this.warnings = warnings;
            const bitmap = canvasToBitmap(canvas);
            bitmapToCanvas(canvas, bitmap);
            this.labelBitmap = bitmap;
            this.printedWidthPx = canvas.width;
            this.zoom = this.fitZoom(canvas);
        },

        redrawFallback() {
            this.fallbackReady = false;
            const canvas = this.$refs.fallbackCanvas;
            if (!canvas) {
                return;
            }
            this.resizeObserver.observe(canvas.parentElement);
            let warnings;
            try {
                const content = templateContent(this.currentTemplate, this.fields);
                ({warnings} = drawFallbackLabel(canvas, content, this.orientation));
            } catch (e) {
                this.error = e.message;
                return;
            }
            this.error = null;
            this.warnings = warnings;
            this.fallbackReady = true;
            this.fitZoom(canvas);
        },

        downloadPng() {
            const canvas = this.$refs.fallbackCanvas;
            if (!canvas) {
                return;
            }
            const link = document.createElement("a");
            link.download = "label.png";
            link.href = canvas.toDataURL("image/png");
            link.click();
            this.rememberPrintedTemplate(this.selectedTemplate);
        },

        // Fits the preview to its card, magnifying up to MAX_ZOOM/MAX_PREVIEW_HEIGHT_PX. See docs/implementation.md#fit-zoom-preview-scaling.
        fitZoom(canvas) {
            const available = canvas.parentElement.clientWidth;
            if (!(available > 0)) {
                return 1;
            }
            const rawZoom = Math.min(MAX_ZOOM, available / canvas.width, MAX_PREVIEW_HEIGHT_PX / canvas.height);
            // Floors rather than rounds/ceils when magnifying, to keep image-rendering:pixelated crisp. See docs/implementation.md#fit-zoom-preview-scaling.
            const zoom = rawZoom >= 1 ? Math.max(1, Math.floor(rawZoom)) : rawZoom;
            canvas.style.width = `${canvas.width * zoom}px`;
            canvas.style.height = `${canvas.height * zoom}px`;
            canvas.style.imageRendering = zoom >= 1 ? "pixelated" : "auto";
            return zoom;
        },

        print() {
            this.guard(async () => {
                const copies = Math.max(1, Math.min(20, Number(this.copies) || 1));
                await this.blob.printBitmap(this.labelBitmap, {copies});
                this.rememberPrintedTemplate(this.selectedTemplate);
            });
        },

        // Re-fits the resized container's canvas (window resize, sidebar toggle, font load, etc.); debounced since ResizeObserver can fire in bursts.
        handleContainerResize(entries) {
            clearTimeout(this.resizeTimer);
            this.resizeTimer = setTimeout(() => {
                for (const entry of entries) {
                    const canvas = entry.target.querySelector("canvas");
                    if (canvas) {
                        this.zoom = this.fitZoom(canvas);
                    }
                }
            }, 100);
        },

        onUsbChange() {
            this.guard(async () => {
                await this.refreshDevices();
                await this.autoConnectPreferred();
            });
        },


        // Looks up the (family, url, effective size) optimal_fonts picks for a nominal size -
        // exposed as a method (not a computed) since the template calls it per-size inside a v-for.
        optimalFontFor(size) {
            return optimal_fonts(size);
        },

        setPreviewCanvasRef(size, el) {
            if (el) {
                this.freetypeCanvases[size] = el;
            } else {
                delete this.freetypeCanvases[size];
            }
        },

        // Draws every previewSizes cell - see the card in the template. For each nominal size, looks
        // up the font optimal_fonts actually picks and draws through libfreetype-wasm's own
        // FT_LOAD_TARGET_MONO rasterizer - a true 1-bit render, not canvas fillText's grayscale AA
        // thresholded after the fact. Font bytes are cached per family (see freetypeFontBytes) since
        // several sizes can share the same font.
        async drawOptimalFontPreviews() {
            let ft;
            try {
                ft = await loadFreeType();
            } catch (e) {
                this.freetypeError = e.message;
                return;
            }
            this.freetypeError = null;
            for (const size of this.previewSizes) {
                const cfg = optimal_fonts(size);
                const canvas = this.freetypeCanvases[size];
                if (!cfg || !canvas) {
                    continue;
                }
                let bytes = this.freetypeFontBytes[cfg.family];
                if (!bytes) {
                    const res = await fetch(cfg.url);
                    bytes = new Uint8Array(await res.arrayBuffer());
                    this.freetypeFontBytes[cfg.family] = bytes;
                }
                if (loadFontFace(ft, bytes) !== 0) {
                    continue; // fails to parse for FreeType too - leave that cell blank.
                }
                this.freetypeLineHeights[size] =
                    drawMonoText(ft, canvas, cfg.size, this.varValues.text, this.previewZoom);
            }
        },
    },
    created() {
        this.blob = null;
        this.resizeObserver = null;
        this.resizeTimer = null;
        this.freetypeCanvases = {};
        this.recentTemplateIds = this.loadRecentTemplateIds();
    },
    async mounted() {
        this.drawOptimalFontPreviews();
        // Not awaited: resolving late just means shortUrl briefly shows "—" instead of blocking the unrelated printer/wasm setup below.
        this.fetchIdMap().catch(e => {
            this.error = e.message;
        });
        this.resizeObserver = new ResizeObserver(this.handleContainerResize);
        // Loaded concurrently with MultiPrinterBlob below rather than serialized. See docs/implementation.md#concurrent-wasm-loading.
        const qrReady = preloadQrEncoder();
        if (!("usb" in navigator)) {
            this.usbSupported = false;
            await Promise.all([nextTick(), qrReady]);
            this.redrawFallback();
            return;
        }
        try {
            this.blob = await MultiPrinterBlob.load(BLOB_URL);
        } catch (e) {
            this.error = `Could not load the printer driver: ${e.message}`;
            return;
        }
        this.blobReady = true;
        navigator.usb.addEventListener("connect", this.onUsbChange);
        navigator.usb.addEventListener("disconnect", this.onUsbChange);
        await qrReady;
        await this.guard(async () => {
            await this.refreshDevices();
            await this.autoConnectPreferred();
        });
    },
    beforeUnmount() {
        if ("usb" in navigator) {
            navigator.usb.removeEventListener("connect", this.onUsbChange);
            navigator.usb.removeEventListener("disconnect", this.onUsbChange);
        }
        this.resizeObserver.disconnect();
        clearTimeout(this.resizeTimer);
    },
}
</script>

<style scoped>
.label-preview {
    overflow-x: auto;
    padding: .75rem;
    //background: rgba(127, 127, 127, .08);
    border-radius: .35rem;
    text-align: center;
    max-height: 300px;
}

.label-preview canvas {
    display: inline-block;
    background: #fff;
    //box-shadow: 0 0 0 1px rgba(127, 127, 127, .5);
}

/* The tape-fed preview's mm ruler: horizontal track above canvas, vertical to its left, both ticked in real physical mm. See docs/implementation.md#mm-ruler-layout. */
.preview-row {
    display: flex;
    align-items: flex-start;
}

/* Ruler/canvas container deliberately never scrollable. See docs/implementation.md#preview-track-no-scroll-invariant. */
.preview-track {
    flex: 1 1 auto;
    min-width: 0;
}

/* Nested override: .label-preview must neither scroll nor center its canvas here. See docs/implementation.md#label-preview-override-in-preview-track. */
.preview-track .label-preview {
    overflow-x: visible;
    text-align: left;
    padding: 0;
}

/* Tape's full physical width; canvas is narrower/centered, faint tint marks the real unprintable margin. See docs/implementation.md#tape-full-print-margin. */
.tape-full {
    display: flex;
    flex-direction: column;
    justify-content: center;
    background: rgba(127, 127, 127, .08);
    border-radius: .35rem;
}

.ruler-v {
    flex: none;
    width: 2.2rem;
    color: rgba(127, 127, 127, .9);
}

/* Matches .ruler-h's height so the vertical ruler's tick 0 lines up with the canvas's top edge. */
.ruler-v-corner {
    height: 1.6rem;
}

.ruler-v-ticks {
    position: relative;
}

.ruler-h {
    position: relative;
    height: 1.6rem;
    color: rgba(127, 127, 127, .9);
}

.ruler .tick {
    position: absolute;
    background: currentColor;
}

/* Ticks anchor to the edge nearest the canvas and grow outward (pointing at the label); mm labels sit on the opposite outer edge. */
.ruler-v-ticks .tick {
    right: 0;
    width: .4rem;
    height: 1px;
}

.ruler-v-ticks .tick-major {
    width: .7rem;
}

.ruler-h .tick {
    bottom: 0;
    width: 1px;
    height: .4rem;
}

.ruler-h .tick-major {
    height: .7rem;
}

.tick-label {
    position: absolute;
    font-size: .6rem;
    line-height: 1;
    white-space: nowrap;
}

/* transform (not a fixed em nudge) centers the label on the tick's mm position regardless of text size. */
.ruler-v-ticks .tick-label {
    left: 0;
    transform: translateY(-50%);
}

.ruler-h .tick-label {
    top: 0;
    transform: translateX(-50%);
}

.copies-input {
    width: 5.5rem;
}

.pixel-test-canvas {
    display: block;
    background: #fff;
    box-shadow: 0 0 0 1px rgba(127, 127, 127, .5);
    image-rendering: pixelated;
}

.code-block {
    background: rgba(127, 127, 127, .08);
    border-radius: .35rem;
    padding: .75rem;
    overflow-x: auto;
    white-space: pre-wrap;
    word-break: break-all;
}
</style>
