<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="!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: new font candidates</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">
                            Nine pixel/bitmap-style fonts found in frontend/public, rendered live from the "Text"
                            field above at a range of sizes so they can be judged the same way Tom Thumb/Silkscreen
                            were. <strong>CodersCrux</strong> and <strong>712Serif</strong> fail to load as web
                            fonts at all - Chromium's OTS sanitizer rejects their cmap table - so their cells below
                            are actually just the browser's fallback serif, not a rendering bug in this page.
                            <strong>Chava</strong> is uppercase-only, same dealbreaker as PICO-8.
                            <strong>6pxExpert</strong> only has pixel glyphs for digits; its letters are the same
                            fallback serif.
                        </p>
                        <div v-for="cfg in candidateFonts" :key="cfg.family" class="mb-3">
                            <div class="fw-bold small mb-1">{{ cfg.family }}</div>
                            <div class="d-flex flex-wrap gap-3">
                                <div v-for="size in candidateSizes" :key="cfg.family + '-' + size" class="text-center">
                                    <div class="small text-muted mb-1">{{ size }}px</div>
                                    <canvas :ref="el => setCandidateCanvasRef(cfg.family + '-' + size, el)"
                                            class="pixel-test-canvas"></canvas>
                                </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="selectedContent">
                                    <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>

                                <div class="mb-3">
                                    <label class="form-label">Code type</label>
                                    <div class="btn-group d-block" role="group">
                                        <template v-for="opt in codeTypeOptions" :key="opt.id">
                                            <input type="radio" class="btn-check"
                                                   :id="'code-type-' + opt.id + '-fallback'"
                                                   autocomplete="off" :value="opt.id" v-model="codeType">
                                            <label class="btn btn-outline-secondary"
                                                   :for="'code-type-' + opt.id + '-fallback'">
                                                {{ opt.label }}
                                            </label>
                                        </template>
                                    </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="selectedContent">
                                        <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>
                                            <!-- The tape's full physical width, printable area included - the
                                                 print head can't mark all the way to the tape's outer edges, so
                                                 the canvas (printAreaPx tall) is narrower than this and centered
                                                 within it; the rest is real, if unprintable, tape 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">Code type</label>
                                            <div class="btn-group" role="group">
                                                <template v-for="opt in codeTypeOptions" :key="opt.id">
                                                    <input type="radio" class="btn-check"
                                                           :id="'code-type-' + opt.id" autocomplete="off"
                                                           :value="opt.id" v-model="codeType">
                                                    <label class="btn btn-outline-secondary" :for="'code-type-' + opt.id">
                                                        {{ opt.label }}
                                                    </label>
                                                </template>
                                            </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" :code-type="codeType"
                                               @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 BaseLayout from "@/components/BaseLayout.vue";
import LabelLayoutPreview from "@/components/LabelLayoutPreview.vue";

import {MultiPrinterBlob, canvasToBitmap, bitmapToCanvas} from "../../vendor/weblabel.js";
import {tapeFromStatus, drawLabel, drawFallbackLabel, buildLabelContent, buildLabelFields, preloadQrEncoder, QR_CODE_TYPES} from "@/label.js";
import {LABEL_TEMPLATES, BASE_VARS, DERIVED_VARS, withDerivedVars, templateContent} from "@/label-layouts.js";

// Served verbatim from public/vendor/ rather than bundled: libweblabel.js's
// own emscripten glue resolves its .wasm sibling relative to *its own*
// import.meta.url at runtime, so both files need to keep sitting together,
// unhashed, at a stable URL - not a Vite-fingerprinted asset path.
const BLOB_URL = "/vendor/libweblabel.js";

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 */
// How far apart plain and labeled/major ticks sit, both coarser the longer the ruler itself runs
// - tightly spaced ticks (and their labels) get too cramped to read/render once there are enough
// of them. Ordered smallest threshold first; rulerTicks below uses the last entry whose `aboveMm`
// the ruler's own length clears, so add a finer/coarser tier here rather than growing a pile of
// separate constants. Every tier's majorEveryMm is a multiple of its own tickMm, so major ticks
// always land on a tick that's actually drawn.
const RULER_TIERS = [
    {aboveMm: 0, tickMm: 1, majorEveryMm: 5},
    {aboveMm: 100, tickMm: 1, majorEveryMm: 10},
    {aboveMm: 500, tickMm: 5, majorEveryMm: 25},
];

// Display labels for label.js's QR_CODE_TYPES - the code-type button group below reads this
// (via codeTypeOptions) rather than hard-coding its own list, so a new symbology added to
// anyd-qr.js/QR_CODE_TYPES only needs a label here, not a whole new radio group.
const CODE_TYPE_LABELS = {
    "qr": "QR",
    "micro-qr": "Micro QR",
    "rmqr": "rMQR",
};

export default {
    name: "Print",
    components: {
        BaseLayout,
        LabelLayoutPreview,
        ...BIcons
    },
    props: {
        // {kind, components} prefilled from the ?kind=…&… query params when arriving from e.g.
        // an item's "Print label" button (see InventoryDetail.vue) - the router turns those
        // query params into this prop (router.js's /print route), rather than the component
        // reading $route directly. buildLabelContent turns it into the literal string below.
        prefill: {
            type: Object,
            default: null
        }
    },
    data() {
        return {
            usbSupported: true,
            blobReady: false,
            busy: false,
            error: null,

            devices: [],
            connected: null,
            tape: null,
            labelBitmap: null,
            // The tape-fed preview's current on-screen scale and printed pixel width (see
            // fitZoom/redraw) - tracked reactively, rather than read straight off the canvas
            // element, purely so the mm ruler below can recompute its tick positions whenever
            // either one changes.
            zoom: 1,
            printedWidthPx: 0,
            // Each "text" leaf's effective font size in the current render (see label.js's
            // drawLabel) - shown alongside the tape width so a field rendering blank (too small
            // even for the smallest pixel font) shows up as a suspiciously tiny number here rather
            // than just silently not being there.
            textSizesPx: [],

            // One input per *base* template variable (see label-layouts.js's BASE_VARS) - the
            // derived ones (userHandle, itemUrl, itemHandle) are format strings calculated from
            // these, not typed directly, so they're only ever shown (see the `fields` computed
            // below), never stored here. Prefilled from the ?kind=…&… query params where
            // buildLabelContent/buildLabelFields have a value for them, editable from there so a
            // template needing e.g. domain isn't stuck depending on a prefill that never arrives.
            varValues: {
                ...Object.fromEntries(BASE_VARS.map(v => [v, ""])),
                text: buildLabelContent(this.prefill),
                // Defaults to wherever this page itself is being served from - editable since any
                // frontend can resolve any handle (see label-layouts.js's DERIVED_VARS.itemUrl),
                // so a label doesn't have to point back at this particular one.
                webdomain: window.location.origin,
                ...buildLabelFields(this.prefill),
            },
            copies: 1,
            selectedTemplate: LABEL_TEMPLATES[0].id,
            // "along" draws a layout reading along the tape's feed direction (the usual case -
            // constrained by the tape's cross-web width, growing as long as the content needs);
            // "across" turns it 90deg, constrained by that same width but along the *other* axis
            // instead, so it reads across the tape rather than along it. See label.js's
            // drawLabel/drawFallbackLabel for how that turn is actually drawn.
            orientation: "along",
            // Which of label.js's QR_CODE_TYPES every "qrcode" leaf in the current template
            // renders as - one global choice for the whole label, same as `orientation` above,
            // since a template only ever has one scannable code in practice.
            codeType: "qr",

            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",

            // Nine pixel/bitmap-style font candidates found in frontend/public (see
            // ../assets/fonts/pixel-candidates/LICENSE.md) - none of these are used by label.js's
            // real PIXEL_FONT_TIERS; this card exists purely so they can be judged the same way
            // Tom Thumb/Silkscreen were, against real (live-typed) content instead of a fixed
            // example.
            candidateFonts: [
                {family: "Pixelon"},
                {family: "Pixelbasel"},
                {family: "Chava"},
                {family: "CodersCrux"},
                {family: "712Serif"},
                {family: "6pxNormal"},
                {family: "6pxExpert"},
                {family: "Jersey10"},
                {family: "Jersey15"},
            ],
            candidateSizes: [5, 6, 7, 8, 10, 12, 16, 20],
            candidateZoom: 4,
        };
    },
    computed: {
        // The base variables the "Label content" form renders an input for, and the derived ones
        // it instead calculates and lists read-only beside that form - plain passthroughs, but
        // keep the template from importing label-layouts.js just for these.
        baseVars() {
            return BASE_VARS;
        },
        // {id, label} pairs for the code-type button group (see CODE_TYPE_LABELS above), in
        // label.js's QR_CODE_TYPES order.
        codeTypeOptions() {
            return QR_CODE_TYPES.map(id => ({id, label: CODE_TYPE_LABELS[id]}));
        },
        derivedVars() {
            return Object.keys(DERIVED_VARS);
        },
        // Named content fields the templates draw from: the form's own base vars, plus every
        // DERIVED_VARS format string calculated live from those - so typing a userHandle and
        // itemId (whether by hand or via prefill) recalculates itemUrl/itemHandle the same way
        // either way. A blank/uncalculated value is dropped rather than passed through as an
        // empty string, so it reads as *absent* to templateIsAvailable/templateContent the same
        // way a prefill that never supplied it would - that's what LabelLayoutPreview.vue greys a
        // template's thumbnail out on.
        fields() {
            const base = {};
            for (const v of BASE_VARS) {
                if (this.varValues[v]) {
                    base[v] = this.varValues[v];
                }
            }
            return withDerivedVars(base);
        },
        currentTemplate() {
            return LABEL_TEMPLATES.find(t => t.id === this.selectedTemplate) || LABEL_TEMPLATES[0];
        },
        selectedContent() {
            return templateContent(this.currentTemplate, this.fields);
        },
        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);
        },
        // On-screen pixels per real millimeter of tape, at the preview's current zoom - what
        // turns a physical mm into a tick position the ruler can actually draw. Only meaningful
        // for the tape-fed preview (see redraw's printedWidthPx) - the no-webusb fallback preview
        // isn't fed from any particular real tape/dpi, so it gets no ruler (see the template).
        tapePxPerMm() {
            return this.tape ? (this.tape.dpi / 25.4) * this.zoom : 0;
        },
        // The physical length, in mm, each ruler axis actually needs to cover - see
        // horizontalRulerTicks/verticalRulerTicks below for what each one measures and why.
        horizontalTotalMm() {
            return (this.tape && this.printedWidthPx) ? this.printedWidthPx / (this.tape.dpi / 25.4) : 0;
        },
        verticalTotalMm() {
            return this.tape ? this.tape.mediaWidthMm : 0;
        },
        // The single RULER_TIERS entry both rulers draw from, keyed off whichever axis is
        // physically longer - so a long label's ruler doesn't end up coarser (or finer) than the
        // tape-width ruler right next to it just because the other axis happens to be shorter.
        rulerTier() {
            return RULER_TIERS.filter(t => Math.max(this.horizontalTotalMm, this.verticalTotalMm) >= t.aboveMm)
                .at(-1);
        },
        // Ticks along the tape's length (the printed bitmap's actual width, lead/trailing feed
        // margin included, since that's real physical tape too).
        horizontalRulerTicks() {
            if (!this.tape || !this.printedWidthPx) {
                return [];
            }
            return this.rulerTicks(this.horizontalTotalMm);
        },
        // Ticks across the tape's full physical width, mediaWidthMm - not printAreaPx/dpi: a
        // print head can't reach the tape's outer edges, so the printable area (see .tape-full in
        // the template) is genuinely narrower than the tape itself, by an amount that isn't a
        // fixed/predictable fraction of it. The ruler still has to show the *whole* tape - its
        // container is sized from mediaWidthMm too (see the template's inline height) precisely so
        // these ticks can't run past it, the way they did when both were sized from printAreaPx.
        verticalRulerTicks() {
            return this.tape ? this.rulerTicks(this.verticalTotalMm) : [];
        },
        // "(5px, 23px)" for the current render's text leaves (see data's textSizesPx), or "" once
        // there's nothing to show - appended straight onto the tape-width <small>, so the blank
        // string here just means that text is left with no trailing space.
        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.drawCandidateFontTests();
            },
            deep: true,
        },
        selectedTemplate() {
            if (this.usbSupported) {
                this.redraw();
            } else {
                this.redrawFallback();
            }
        },
        orientation() {
            if (this.usbSupported) {
                this.redraw();
            } else {
                this.redrawFallback();
            }
        },
        codeType() {
            if (this.usbSupported) {
                this.redraw();
            } else {
                this.redrawFallback();
            }
        },
        // Covers connecting/disconnecting/switching printers - anything that changes the
        // tape dimensions redraw() sizes the canvas from. flush: 'post' because the canvas
        // itself only exists once `tape` is truthy (see the v-if/v-else in the template), so
        // this has to run after Vue has actually mounted it, not before.
        tape: {
            handler() {
                this.redraw();
            },
            flush: 'post'
        },
    },
    methods: {
        // Turns a camelCase variable name (see label-layouts.js's KNOWN_VARS) into a form label,
        // e.g. "itemHandle" -> "Item Handle" - so adding a new template variable doesn't also
        // require hand-writing a label for it here.
        varLabel(v) {
            return v.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/^./, c => c.toUpperCase());
        },

        // Ticks from 0 up to totalMm, each positioned in on-screen pixels via tapePxPerMm - shared
        // by the horizontal/vertical ruler computeds above. Both the plain tick spacing and the
        // labeled/major one come from the shared rulerTier (see above), not from this totalMm, so
        // both rulers always coarsen together once *either* axis is long enough to need it.
        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;
        },

        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 navigator.usb.getDevices()).map((d) => markRaw(d));
            /* A printer unplugged while open is off the list: the handle it was
               opened through is gone, so drop it rather than keep the label
               card open on a connection that no longer exists. */
            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;
            }
        },

        connect(index) {
            this.guard(async () => {
                // Only one open device connection at a time - pressing Connect on a different
                // printer while one is already open implicitly disconnects it first, rather
                // than requiring an explicit Disconnect click.
                await this.closeConnection();
                const device = this.devices[index];
                this.blob.setDevices([device]);
                await this.blob.open(device.vendorId, device.productId);
                this.connected = 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;
            });
        },

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

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

        redrawFallback() {
            this.fallbackReady = false;
            const content = this.selectedContent;
            if (!content) {
                return;
            }
            const canvas = this.$refs.fallbackCanvas;
            if (!canvas) {
                return;
            }
            this.resizeObserver.observe(canvas.parentElement);
            try {
                drawFallbackLabel(canvas, content, this.orientation, this.codeType);
            } catch (e) {
                this.error = e.message;
                return;
            }
            this.error = null;
            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();
        },

        /* Fit the preview to its card without ever needing a horizontal
           scrollbar for a label this small, magnifying short labels up to
           MAX_ZOOM rather than showing them at native (tiny) size - and never
           past MAX_PREVIEW_HEIGHT_PX tall, however long/wide the label itself
           runs. Returns the zoom actually used, so callers that care (see
           redraw's ruler bookkeeping) don't have to re-derive it. */
        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);
            // When magnifying, round DOWN to a whole number: image-rendering:pixelated below
            // only actually looks crisp when every source pixel maps to the *same* number of
            // screen pixels - at a fractional zoom (the overwhelmingly common case, since rawZoom
            // is just whatever ratio the tape/card happen to produce) some source pixels get
            // rounded up to one extra screen pixel and others don't, unevenly warping fine,
            // already-pixel-perfect detail like a crisp QR module or a tiny bitmap font glyph.
            // Flooring (never rounding/ceiling) keeps the same "never bigger than available
            // space" guarantee rawZoom already had. Shrinking (zoom < 1) has no equivalent "whole
            // factor" to snap to - downsampling always blends source pixels - so it's left as-is.
            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});
            });
        },

        /* Re-fits whichever canvas sits in a resized container - covers a window resize, but
           also a sidebar toggle, a font finishing loading, or any other layout change that
           isn't a window resize at all. 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(() => this.refreshDevices());
        },

        // A plain :ref="key" inside v-for would still get Vue's refInFor array-collecting
        // behavior (see LabelLayoutPreview.vue's setTemplateCanvasRef for the same pattern), so
        // this keys the canvases by the caller's own composite key string explicitly instead.
        setCandidateCanvasRef(key, el) {
            if (el) {
                this.candidateCanvases[key] = el;
            } else {
                delete this.candidateCanvases[key];
            }
        },

        // Draws the live "Text" field's value into a single canvas at the exact size/family
        // given - no layout math, no snapping, just ctx.font as requested, so what's judged here
        // is the font itself rather than anything label.js's real pipeline does to it. Canvas size
        // is measured from the text itself, and ink-centered vertically (see label.js's
        // drawTextLeaf for the same idea) so a font with unreliable declared metrics still lands
        // fully inside the canvas instead of clipped off the top/bottom.
        drawCandidateCell(canvas, family, fontPx) {
            if (!canvas) {
                return;
            }
            const text = this.varValues.text;
            const ctx = canvas.getContext("2d");
            ctx.font = `${fontPx}px "${family}"`;
            const {width, actualBoundingBoxAscent: up, actualBoundingBoxDescent: down} = ctx.measureText(text);
            canvas.width = Math.ceil(width) + 4;
            canvas.height = Math.ceil(up + down) + 8;
            // Re-set font/alignment - changing canvas.width/height above resets the 2D context.
            ctx.font = `${fontPx}px "${family}"`;
            ctx.textAlign = "left";
            ctx.textBaseline = "alphabetic";
            ctx.fillStyle = "#fff";
            ctx.fillRect(0, 0, canvas.width, canvas.height);
            ctx.fillStyle = "#000";
            ctx.fillText(text, 2, (canvas.height + up - down) / 2);
            canvas.style.width = `${canvas.width * this.candidateZoom}px`;
            canvas.style.height = `${canvas.height * this.candidateZoom}px`;
        },

        // Draws every (candidateFonts x candidateSizes) cell - see the card in the template.
        async drawCandidateFontTests() {
            await Promise.all(this.candidateFonts
                .map(({family}) => document.fonts.load(`20px "${family}"`).catch(() => {})));
            for (const {family} of this.candidateFonts) {
                for (const size of this.candidateSizes) {
                    const canvas = this.candidateCanvases[`${family}-${size}`];
                    this.drawCandidateCell(canvas, family, size);
                }
            }
        },
    },
    created() {
        this.blob = null;
        this.resizeObserver = null;
        this.resizeTimer = null;
        this.candidateCanvases = {};
    },
    async mounted() {
        this.drawCandidateFontTests();
        this.resizeObserver = new ResizeObserver(this.handleContainerResize);
        // Kicked off here rather than awaited immediately, so it loads concurrently with
        // MultiPrinterBlob below instead of serializing two independent wasm fetches - every
        // redraw()/redrawFallback() call below still waits on it first, since a "qrcode" leaf
        // throws (see label.js's encodeQr) until it resolves.
        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(() => this.refreshDevices());
    },
    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 (see Print.vue's template/script) - a horizontal track above
   the canvas and a vertical one to its left, both ticked in real physical millimeters rather than
   preview pixels, since what they're measuring is the actual label. */
.preview-row {
    display: flex;
    align-items: flex-start;
}

/* Holds the horizontal ruler and the canvas - deliberately never scrollable (no overflow-x:auto):
   fitZoom's zoom always satisfies `canvas.width * zoom <= available`, so the canvas can never
   actually be wider than this has room for, and a scrollbar here would let the ruler and canvas
   drift apart (or just look broken) for no reason. min-width:0 only lets this flex item shrink
   to the card's real available width - it doesn't enable scrolling. */
.preview-track {
    flex: 1 1 auto;
    min-width: 0;
}

/* Overrides the standalone rule above: nested here, .label-preview must neither scroll nor center
   its canvas - overflow-x:visible (never auto) rules out a second, inner scrollbar, and
   text-align:left keeps the canvas flush with the ruler's zero tick instead of drifting to the
   middle of whatever spare width this card has. padding:0 so the canvas's own edges are exactly
   this box's edges too - the ruler's ticks (see .ruler-h/.ruler-v-ticks below) line up with those
   same edges, so any padding here would leave the ticks and the actual canvas misaligned. */
.preview-track .label-preview {
    overflow-x: visible;
    text-align: left;
    padding: 0;
}

/* The tape's full physical width (see the template) - a print head can't mark all the way to a
   tape's outer edges, so .label-preview/the canvas is narrower than this and centered within it
   (the print area sits centered on the tape, with equal margin on both sides); a faint tint
   distinguishes the margin as real (if blank, unprintable) tape rather than empty space. */
.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 own height below - the vertical ruler's ticks start only after this, so tick
   0 lines up with the canvas's top edge rather than the horizontal ruler sitting above it. */
.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 (right for the vertical ruler, bottom for the
   horizontal one) and grow outward from it, so they read as pointing at the label; the mm labels
   sit on the opposite, outer edge, out of the ticks' way. */
.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, so the label's actual center - not its edge - lands on the
   tick's mm position (t.pos, set inline), whatever the text's width/height happens to be. */
.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>
