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

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

                <div v-if="!insecureContext" class="alert alert-warning">
                    This page isn't in a secure context ({{ insecureOrigin }}), so the camera API is
                    unavailable. From another device, use https:// (or http://localhost) instead.
                </div>

                <div class="row">
                    <div class="col-lg-6 mb-4">
                        <div class="card h-100">
                            <div class="card-header">
                                <h5 class="card-title mb-0">Scan from image</h5>
                            </div>
                            <div class="card-body">
                                <input ref="fileInput" type="file" accept="image/*" class="form-control mb-3"
                                       @change="onFileInputChange">
                                <div class="dropzone" :class="{'dropzone-hover': dropHover}"
                                     @dragover="onDragOver" @dragleave="onDragLeave" @drop="onDrop">
                                    Drag &amp; drop an image here, paste one, or use the file picker above.
                                </div>
                                <div class="scan-canvas-wrap" v-show="hasFileImage">
                                    <canvas ref="fileCanvas" class="scan-canvas"></canvas>
                                </div>
                                <ul class="scan-results">
                                    <li v-if="hasFileImage && !fileResults.length" class="scan-results-empty">
                                        No codes found.
                                    </li>
                                    <li v-for="(r, i) in fileResults" :key="i">
                                        <span class="scan-result-type">{{ r.type }}</span>
                                        <span v-if="r.metaText" class="text-muted"> {{ r.metaText }}</span>
                                        <div class="text-break">{{ r.text }}</div>
                                    </li>
                                </ul>
                            </div>
                        </div>
                    </div>

                    <div class="col-lg-6 mb-4">
                        <div class="card h-100">
                            <div class="card-header">
                                <h5 class="card-title mb-0">Scan from camera</h5>
                            </div>
                            <div class="card-body">
                                <p v-if="!cameraSupported" class="text-muted">
                                    This browser has no camera API available.
                                </p>
                                <template v-else>
                                    <div class="row g-2 align-items-end mb-3">
                                        <div class="col-auto flex-grow-1">
                                            <label class="form-label">Camera</label>
                                            <select class="form-control" v-model="selectedCameraId"
                                                    :disabled="cameraRunning">
                                                <option value="">Auto (prefer rear camera)</option>
                                                <option v-for="(c, i) in cameras" :key="c.deviceId"
                                                        :value="c.deviceId">
                                                    {{ c.label || ('Camera ' + (i + 1)) }}
                                                </option>
                                            </select>
                                        </div>
                                        <div class="col-auto">
                                            <button v-if="!cameraRunning" class="btn btn-primary"
                                                    :disabled="!insecureContext" @click="startCamera">
                                                <b-icon-camera class="me-1"></b-icon-camera>
                                                Start camera
                                            </button>
                                            <button v-else class="btn btn-outline-secondary" @click="stopCamera">
                                                <b-icon-stop-fill class="me-1"></b-icon-stop-fill>
                                                Stop
                                            </button>
                                        </div>
                                    </div>
                                    <p v-if="cameraRes" class="text-muted small">{{ cameraRes }}</p>
                                    <div class="video-wrap">
                                        <video ref="video" autoplay muted playsinline></video>
                                        <canvas ref="overlay"></canvas>
                                    </div>
                                </template>
                                <ul class="scan-results">
                                    <li v-for="(c, i) in cameraLog" :key="i">
                                        <span class="scan-result-type">{{ c.type }}</span>
                                        <span v-if="c.metaText" class="text-muted"> {{ c.metaText }}</span>
                                        <span class="text-muted"> @ {{ c.time }}</span>
                                        <div class="text-break">{{ c.text }}</div>
                                    </li>
                                </ul>
                            </div>
                        </div>
                    </div>
                </div>
            </div>
        </main>
    </BaseLayout>
</template>

<script>
import * as BIcons from "bootstrap-icons-vue";
import BaseLayout from "@/components/BaseLayout.vue";
import {loadAnyDCode} from "../../vendor/anyd-qr.js";

// A decode result's metadata (see anyd-qr.js's SymbolMetadata) as one short, human-readable
// string, e.g. "(v7, ec=M, mask=3)" - shared by both the "from image" and "from camera" result
// lists below rather than each formatting it its own way.
function metaSummary(metadata) {
    const parts = [];
    if (metadata.version != null) {
        parts.push(`v${metadata.version}`);
    }
    if (metadata.size) {
        parts.push(metadata.size);
    }
    if (metadata.ecc) {
        parts.push(`ec=${metadata.ecc}`);
    }
    if (metadata.mask != null) {
        parts.push(`mask=${metadata.mask}`);
    }
    return parts.length ? `(${parts.join(", ")})` : "";
}

// How long a camera-detected code's bounding box stays drawn on the overlay before fading, so a
// one-off decode doesn't leave a stale box on screen once the code's moved out of frame.
const OVERLAY_CLEAR_MS = 2000;

export default {
    name: "Scan",
    components: {
        BaseLayout,
        ...BIcons
    },
    data() {
        return {
            error: null,
            insecureContext: window.isSecureContext,
            insecureOrigin: `${window.location.protocol}//${window.location.hostname}`,

            dropHover: false,
            hasFileImage: false,
            fileResults: [],

            cameraSupported: Boolean(navigator.mediaDevices?.getUserMedia),
            cameras: [],
            selectedCameraId: "",
            cameraRunning: false,
            cameraRes: "",
            cameraLog: [],
        };
    },
    methods: {
        // Draws `file`/a pasted or dropped Blob onto fileCanvas and decodes whatever's in it -
        // shared by the file input, drag&drop and paste handlers below rather than each
        // duplicating the createImageBitmap/getImageData/decodeImage sequence.
        async decodeBlob(file) {
            this.error = null;
            try {
                const bitmap = await createImageBitmap(file);
                const canvas = this.$refs.fileCanvas;
                canvas.width = bitmap.width;
                canvas.height = bitmap.height;
                const ctx = canvas.getContext("2d");
                ctx.drawImage(bitmap, 0, 0);
                const imageData = ctx.getImageData(0, 0, bitmap.width, bitmap.height);
                const anyd = await this.anydPromise;
                const results = anyd.decodeImage(imageData);
                this.drawBoxes(ctx, results);
                this.hasFileImage = true;
                this.fileResults = results.map(r => ({...r, metaText: metaSummary(r.metadata)}));
            } catch (e) {
                this.error = e.message ?? String(e);
            }
        },

        drawBoxes(ctx, results) {
            ctx.lineWidth = Math.max(2, ctx.canvas.width / 300);
            ctx.strokeStyle = "#3ce77c";
            ctx.fillStyle = "#3ce77c";
            ctx.font = `${Math.max(14, ctx.canvas.width / 60)}px sans-serif`;
            for (const r of results) {
                if (!r.box) {
                    continue;
                }
                const {x0, y0, x1, y1} = r.box;
                ctx.strokeRect(x0, y0, x1 - x0, y1 - y0);
                ctx.fillText(r.type, x0, Math.max(14, y0 - 4));
            }
        },

        onFileInputChange() {
            const file = this.$refs.fileInput.files[0];
            if (file) {
                this.decodeBlob(file);
            }
        },

        onDragOver(e) {
            e.preventDefault();
            this.dropHover = true;
        },

        onDragLeave() {
            this.dropHover = false;
        },

        onDrop(e) {
            e.preventDefault();
            this.dropHover = false;
            const file = e.dataTransfer.files[0];
            if (file) {
                this.decodeBlob(file);
            }
        },

        onPaste(e) {
            const item = [...(e.clipboardData?.items ?? [])].find(i => i.type.startsWith("image/"));
            if (item) {
                this.decodeBlob(item.getAsFile());
            }
        },

        // Device labels are only populated once camera permission has actually been granted, so
        // this is called again right after getUserMedia resolves in startCamera, not just once
        // up front.
        async populateCameraList() {
            if (!navigator.mediaDevices?.enumerateDevices) {
                return;
            }
            const devices = await navigator.mediaDevices.enumerateDevices();
            this.cameras = devices.filter(d => d.kind === "videoinput");
        },

        syncOverlaySize() {
            const video = this.$refs.video;
            const overlay = this.$refs.overlay;
            if (!video || !overlay) {
                return;
            }
            overlay.width = video.clientWidth;
            overlay.height = video.clientHeight;
        },

        logDecode(code) {
            this.cameraLog.unshift({
                type: code.type,
                text: code.text ?? "",
                metaText: metaSummary(code.metadata),
                time: new Date().toLocaleTimeString(),
            });
            // Caps the log rather than letting it grow forever - old entries are trimmed off the
            // end, same as CameraScanner's own dedupe window makes them stale for re-matching.
            this.cameraLog.length = Math.min(this.cameraLog.length, 20);
        },

        // Draws every detected code's box (in the video's own native pixel space, scaled to
        // however large the overlay is actually displayed) and fades them out after
        // OVERLAY_CLEAR_MS - a one-off decode shouldn't leave a stale box on screen once the code
        // has moved out of frame.
        drawOverlay(codes) {
            const overlay = this.$refs.overlay;
            const video = this.$refs.video;
            if (!overlay || !video || !video.videoWidth) {
                return;
            }
            const ctx = overlay.getContext("2d");
            ctx.clearRect(0, 0, overlay.width, overlay.height);
            const scaleX = overlay.width / video.videoWidth;
            const scaleY = overlay.height / video.videoHeight;
            ctx.lineWidth = 3;
            ctx.strokeStyle = "#3ce77c";
            ctx.fillStyle = "#3ce77c";
            ctx.font = "14px sans-serif";
            for (const code of codes) {
                if (!code.box) {
                    continue;
                }
                const {x0, y0, x1, y1} = code.box;
                ctx.strokeRect(x0 * scaleX, y0 * scaleY, (x1 - x0) * scaleX, (y1 - y0) * scaleY);
                ctx.fillText(`${code.type}: ${code.text ?? ""}`, x0 * scaleX, Math.max(14, y0 * scaleY - 4));
            }
            clearTimeout(this.clearOverlayTimer);
            this.clearOverlayTimer = setTimeout(
                () => ctx.clearRect(0, 0, overlay.width, overlay.height), OVERLAY_CLEAR_MS);
        },

        async startCamera() {
            this.error = null;
            try {
                const constraints = {
                    video: this.selectedCameraId
                        ? {deviceId: {exact: this.selectedCameraId}}
                        : {facingMode: {ideal: "environment"}},
                    audio: false,
                };
                this.stream = await navigator.mediaDevices.getUserMedia(constraints);
                const video = this.$refs.video;
                video.srcObject = this.stream;
                await video.play();
                this.syncOverlaySize();
                this.cameraRes = `Native resolution: ${video.videoWidth} x ${video.videoHeight}`;
                await this.populateCameraList();

                const anyd = await this.anydPromise;
                this.scanner = anyd.createCameraScanner(video, {
                    fps: 12,
                    downscale: 2,
                    onDecode: (codes) => {
                        codes.forEach(this.logDecode);
                        this.drawOverlay(codes);
                    },
                    onError: (err) => console.error("[camera scan]", err),
                });
                this.scanner.start();
                this.cameraRunning = true;
            } catch (e) {
                this.error = `Camera error: ${e.message ?? e}`;
            }
        },

        stopCamera() {
            this.scanner?.stop();
            this.scanner = null;
            this.stream?.getTracks().forEach(t => t.stop());
            this.stream = null;
            if (this.$refs.video) {
                this.$refs.video.srcObject = null;
            }
            this.cameraRunning = false;
            const overlay = this.$refs.overlay;
            if (overlay) {
                overlay.getContext("2d").clearRect(0, 0, overlay.width, overlay.height);
            }
        },
    },
    created() {
        // Kept as a promise (rather than awaited here) so every caller - decodeBlob, startCamera -
        // shares the same in-flight load instead of each triggering its own; loadAnyDCode's own
        // memoization (see anyd-qr.js) means a second call anywhere else in the app is free too.
        this.anydPromise = loadAnyDCode();
        this.stream = null;
        this.scanner = null;
        this.clearOverlayTimer = null;
    },
    mounted() {
        window.addEventListener("paste", this.onPaste);
        window.addEventListener("resize", this.syncOverlaySize);
        this.populateCameraList();
    },
    beforeUnmount() {
        this.stopCamera();
        window.removeEventListener("paste", this.onPaste);
        window.removeEventListener("resize", this.syncOverlaySize);
        clearTimeout(this.clearOverlayTimer);
    },
}
</script>

<style scoped>
.dropzone {
    margin-bottom: .75rem;
    border: 2px dashed var(--bs-gray-400);
    border-radius: .35rem;
    padding: 1.25rem;
    text-align: center;
    color: var(--bs-gray-600);
    font-size: .9rem;
}

.dropzone-hover {
    border-color: var(--bs-primary);
    color: var(--bs-primary);
}

.scan-canvas-wrap {
    text-align: center;
    margin-bottom: .75rem;
}

.scan-canvas {
    max-width: 100%;
    border-radius: .35rem;
    border: 1px solid var(--bs-gray-300);
}

.video-wrap {
    position: relative;
    margin-bottom: .75rem;
    background: #000;
    border-radius: .35rem;
    overflow: hidden;
}

.video-wrap video {
    display: block;
    width: 100%;
    height: auto;
}

.video-wrap canvas {
    position: absolute;
    inset: 0;
    pointer-events: none;
}

.scan-results {
    list-style: none;
    padding: 0;
    margin: 0;
    display: flex;
    flex-direction: column;
    gap: .4rem;
    max-height: 260px;
    overflow-y: auto;
}

.scan-results li {
    border: 1px solid var(--bs-gray-300);
    border-radius: .35rem;
    padding: .4rem .6rem;
    font-size: .85rem;
}

.scan-result-type {
    font-weight: 600;
    color: var(--bs-success);
}

.scan-results-empty {
    color: var(--bs-gray-600);
    border-style: dashed;
}
</style>
