<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. Open this page in Chrome, Edge or Opera
                    over https:// (or http://localhost).
                </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</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="mb-3">
                                        <label class="form-label">QR code content</label>
                                        <input type="text" class="form-control" v-model="value"
                                               placeholder="https://example.com/…" autofocus>
                                    </div>

                                    <div class="label-preview mb-3" v-show="value">
                                        <canvas ref="labelCanvas"></canvas>
                                    </div>

                                    <div class="row g-2 align-items-end">
                                        <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 || connected !== null" @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>
        </main>
    </BaseLayout>
</template>

<script>
import * as BIcons from "bootstrap-icons-vue";
import {markRaw, nextTick} from "vue";
import QRCode from "qrcode";
import BaseLayout from "@/components/BaseLayout.vue";

import {MultiPrinterBlob, canvasToBitmap, bitmapToCanvas} from "../../vendor/weblabel.js";

const BLOB_URL = new URL("../../vendor/libweblabel.js", import.meta.url).href;

const MAX_ZOOM = 4;			/* never magnify the preview more than this */
const TRAILING_PADDING_PX = 3;		/* blank columns after the cut, same idea as the leading margin */
/* A quiet zone narrower than the spec's usual 4 modules: the printer's own
   feed margin already keeps the code clear of the tape edge and the cut. */
const QUIET_ZONE_MODULES = 2;

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

/*
	Draw the QR code as large as the tape allows, centered in a square, with an
	integer number of pixels per module so it stays crisp at printer
	resolution rather than blurring at a fractional scale.
*/
function drawQrLabel(canvas, qr, tape) {
    const modules = qr.modules.size + QUIET_ZONE_MODULES * 2;
    const maxLength = tape.printLengthPx
        ? tape.printLengthPx - tape.leadPx - TRAILING_PADDING_PX
        : Infinity;
    const scale = Math.floor(Math.min(tape.printAreaPx, maxLength) / modules);
    if (!(scale >= 1)) {
        throw new Error("This text needs a bigger QR code than the tape allows — "
            + "try a shorter value or a wider tape.");
    }
    const square = modules * scale;

    const width = tape.printLengthPx || (square + tape.leadPx + TRAILING_PADDING_PX);
    canvas.width = width;
    canvas.height = tape.printAreaPx;

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

    const left = tape.leadPx + Math.floor((width - tape.leadPx - TRAILING_PADDING_PX - square) / 2);
    const top = Math.floor((canvas.height - square) / 2);
    ctx.fillStyle = "#000";
    for (let row = 0; row < qr.modules.size; row++) {
        for (let col = 0; col < qr.modules.size; col++) {
            if (qr.modules.get(row, col)) {
                ctx.fillRect(
                    left + (col + QUIET_ZONE_MODULES) * scale,
                    top + (row + QUIET_ZONE_MODULES) * scale,
                    scale, scale,
                );
            }
        }
    }
}

export default {
    name: "Print",
    components: {
        BaseLayout,
        ...BIcons
    },
    data() {
        return {
            usbSupported: true,
            blobReady: false,
            busy: false,
            error: null,

            devices: [],
            connected: null,

            value: "",
            copies: 1,
        };
    },
    computed: {
        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.value && this.tape && this.labelBitmap && !this.busy);
        },
    },
    watch: {
        value() {
            this.redraw();
        },
    },
    methods: {
        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();
            });
        },

        connect(index) {
            this.guard(async () => {
                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();
                this.tape = this.blob.can("print") ? tapeFromStatus(status) : null;
                await nextTick();
                this.redraw();
            });
        },

        disconnect() {
            this.guard(async () => {
                await this.blob.close();
                this.connected = null;
                this.tape = null;
            });
        },

        redraw() {
            this.labelBitmap = null;
            if (!this.tape || !this.value) {
                return;
            }
            const canvas = this.$refs.labelCanvas;
            if (!canvas) {
                return;
            }
            try {
                const qr = QRCode.create(this.value);
                drawQrLabel(canvas, qr, this.tape);
            } catch (e) {
                this.error = e.message;
                return;
            }
            this.error = null;
            const bitmap = canvasToBitmap(canvas);
            bitmapToCanvas(canvas, bitmap);
            this.labelBitmap = bitmap;
            this.fitZoom(canvas);
        },

        /* 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. */
        fitZoom(canvas) {
            const available = canvas.parentElement.clientWidth;
            if (!(available > 0)) {
                return;
            }
            const zoom = Math.min(MAX_ZOOM, available / canvas.width);
            canvas.style.width = `${canvas.width * zoom}px`;
            canvas.style.height = `${canvas.height * zoom}px`;
            canvas.style.imageRendering = zoom >= 1 ? "pixelated" : "auto";
        },

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

        handleResize() {
            clearTimeout(this.resizeTimer);
            this.resizeTimer = setTimeout(() => this.redraw(), 100);
        },

        onUsbChange() {
            this.guard(() => this.refreshDevices());
        },
    },
    created() {
        this.blob = null;
        this.tape = null;
        this.labelBitmap = null;
        this.resizeTimer = null;
    },
    async mounted() {
        if (!("usb" in navigator)) {
            this.usbSupported = false;
            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);
        window.addEventListener("resize", this.handleResize);
        await this.guard(() => this.refreshDevices());
    },
    beforeUnmount() {
        if ("usb" in navigator) {
            navigator.usb.removeEventListener("connect", this.onUsbChange);
            navigator.usb.removeEventListener("disconnect", this.onUsbChange);
        }
        window.removeEventListener("resize", this.handleResize);
        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;
}

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

.copies-input {
    width: 5.5rem;
}
</style>
