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

                <div class="form-check form-switch mb-3">
                    <input class="form-check-input" type="checkbox" role="switch" id="visitFirstMatch"
                           v-model="visitFirstMatch">
                    <label class="form-check-label" for="visitFirstMatch">Visit first match</label>
                </div>

                <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">Results</h5>
                            </div>
                            <div class="card-body">
                                <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">
                                            <a v-if="c.link?.href" :href="c.link.href" target="_blank"
                                               rel="noopener noreferrer">{{ c.text }}</a>
                                            <template v-else-if="c.link">
                                                <router-link :to="c.link.to">{{ c.text }}
                                                    <span v-if="c.error" class="text-danger"> &rarr; {{
                                                            c.error
                                                        }}</span>
                                                    <span v-else-if="c.description"> &rarr; {{ c.description }}</span>
                                                    <span v-else-if="c.description === undefined" class="text-muted"> &rarr; resolving&hellip;</span>
                                                </router-link>
                                            </template>
                                            <template v-else>{{ c.text }}</template>
                                        </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">
                                <CameraScanner @scan="onCameraScan" @error="e => error = e"/>
                            </div>
                        </div>
                    </div>
                </div>
            </div>
        </main>
    </BaseLayout>
</template>

<script>
import {mapActions} from "vuex";
import BaseLayout from "@/components/BaseLayout.vue";
import CameraScanner from "@/components/CameraScanner.vue";
import {encodeHandleForUrl, expandedRoute} from "@/router";
import {decodeShortId, deserializeShortId} from "@/short-id";

// A scanned label can encode any of these (see label-layouts.js's DERIVED_VARS): a full
// self-contained URL (itemUrl/shortUrl), a bare short-id.js token with no domain at all (the
// "mqr"/id-only layout), or the compact no-URL "<userHandle>:<itemId>" form (itemHandle). Detects
// which and returns a link target, or null if the text doesn't match any known format. `decoded`/
// `itemHandle` carry enough of the parsed token for describeLink (below) to also resolve a
// human-readable "what this points at" - not needed for the two URL cases, which link to
// somewhere already showing that.
const ITEM_HANDLE_RE = /^(#?[^\s@:/#+~]+@[^\s@:/#+~]+):(\d+)$/;

function classifyScanText(text) {
    if (!text) {
        return null;
    }
    try {
        const url = new URL(text);
        // Same-origin URLs (the common case - a label printed by this same app) get routed
        // in-app instead of forcing a full page reload through an <a> tag. `immediate`: a URL
        // needs no async lookup to confirm it's real (unlike the token/handle formats below), so
        // it's already "resolved" the moment it's classified - see resolveDescription and
        // maybeVisitFirstMatch.
        return url.origin === window.location.origin
            ? {to: url.pathname + url.search + url.hash, immediate: true}
            : {href: url.href};
    } catch {
        // Not an absolute URL - fall through to the other known formats below.
    }
    if (text.startsWith('~')) {
        try {
            const decoded = deserializeShortId(decodeShortId(text));
            // expandedRoute needs idmap already loaded to resolve item/group_item (see
            // router.js's NEEDS_IDMAP) - fall back to the token's own URL (ShortId.vue resolves
            // it from there, same as a cold-opened short link) when it can't yet.
            return {to: expandedRoute(decoded) || '/' + text, decoded};
        } catch {
            return null; // starts with '~' but isn't a real short id - leave as plain text
        }
    }
    const handleMatch = text.match(ITEM_HANDLE_RE);
    if (handleMatch) {
        const [, handle, id] = handleMatch;
        return {to: `/inventory/${encodeHandleForUrl(handle)}/${id}`, itemHandle: {handle, id}};
    }
    return null;
}

// Formats a decode result's metadata (see anyd-qr.js's SymbolMetadata) as a short string like
// "(v7, ec=M, mask=3)".
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(", ")})` : "";
}

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

            // One-shot: cleared by maybeVisitFirstMatch as soon as it navigates, so it doesn't
            // keep firing router.push for every later scan/decode while left switched on.
            // Defaults on when the page itself was opened with ?navigate=immediate (e.g. a link
            // shared for a "scan and go" workflow), so the toggle doesn't need a manual flip first.
            visitFirstMatch: this.$route.query.navigate === "immediate",

            cameraLog: [],
        };
    },
    methods: {
        ...mapActions(["fetchItemByHandle", "fetchGroup", "fetchStorageLocations", "fetchIdMap"]),

        // Resolves entry.link into entry.description ("[#7] Cordless drill") for the non-URL
        // formats classifyScanText recognizes - mutates the already-rendered entry in place once
        // the lookup lands, rather than delaying the log/result list from showing the raw scanned
        // text and link immediately. Left as `undefined` (template shows "resolving...") while in
        // flight, and settles to a string or `null` (nothing else known to show, e.g. a workflow
        // short id, or the owner/item genuinely couldn't be resolved).
        //
        // descriptionCache (keyed by the raw scanned text) memoizes the outcome - a still-in-frame
        // code gets re-decoded and re-logged several times a second (see logDecode), and
        // re-scanning the same printed label later is common too, so without this every repeat
        // would re-fire the same fetchItemByHandle/fetchGroup/fetchStorageLocations/fetchIdMap
        // round trip. Caching the in-flight promise itself (not just its settled value) also
        // dedupes concurrent lookups for the same still-in-frame code, rather than firing one
        // request per decode.
        resolveDescription(entry) {
            const {link, text} = entry;
            if (!link || link.href) {
                return; // full URLs already show where they go - see this feature's ask
            }
            if (link.immediate) {
                this.maybeVisitFirstMatch(link);
                return;
            }
            if (!this.descriptionCache.has(text)) {
                // Wrapped as {error} rather than swallowed to null: a lookup can fail for very
                // different reasons (not logged in, item not shared with this viewer, a genuine
                // network error) and collapsing them all to "no description" made every one of
                // them look identical to "nothing to show" - undiagnosable from the UI.
                this.descriptionCache.set(text, this.describeLink(link).catch(e => ({error: e.message ?? String(e)})));
            }
            this.descriptionCache.get(text).then(result => {
                if (result && typeof result === "object") {
                    entry.description = null;
                    entry.error = result.error;
                    return;
                }
                entry.description = result;
                // Only a truthy description confirms the target actually exists - a null/empty
                // one (unresolvable, or a kind with no title lookup wired up) shouldn't count as
                // a "match" to auto-visit.
                if (result) {
                    this.maybeVisitFirstMatch(link);
                }
            });
        },

        // Sends the viewer straight to the first scan this session that's confirmed to resolve
        // (immediately for a plain URL, or once resolveDescription confirms a real target for a
        // token/handle) while the "Visit first match" toggle is on. One-shot: switches the toggle
        // back off so it doesn't fire again for every later scan of the same or another code.
        maybeVisitFirstMatch(link) {
            if (!this.visitFirstMatch || !link?.to) {
                return;
            }
            this.visitFirstMatch = false;
            this.$router.push(link.to);
        },

        async describeLink(link) {
            if (link.itemHandle) {
                return this.describeItem(link.itemHandle.handle, link.itemHandle.id);
            }
            const decoded = link.decoded;
            if (!decoded) {
                return null;
            }
            if (decoded.kind === "item" || decoded.kind === "group_item") {
                const byId = decoded.kind === "item"
                    ? this.$store.getters.identityHandleById
                    : this.$store.getters.groupHandleById;
                const ownerId = decoded.kind === "item" ? decoded.owner_identity_id : decoded.owner_group_id;
                let handle = byId[ownerId];
                if (handle === undefined) {
                    await this.fetchIdMap();
                    handle = (decoded.kind === "item"
                        ? this.$store.getters.identityHandleById
                        : this.$store.getters.groupHandleById)[ownerId];
                }
                return handle === undefined ? null : this.describeItem(handle, decoded.item_local_id);
            }
            if (decoded.kind === "group") {
                const group = await this.fetchGroup({id: decoded.group_id});
                return group ? group.handle : null;
            }
            if (decoded.kind === "storage_location") {
                if (!this.$store.state.storage_locations.length) {
                    await this.fetchStorageLocations();
                }
                const location = this.$store.state.storage_locations.find(l => l.id === decoded.storage_location_id);
                return location ? `[#${location.id}] ${location.name}` : null;
            }
            return null; // workflow, category, file: no per-item title lookup wired up yet
        },

        async describeItem(handle, id) {
            const item = await this.fetchItemByHandle({handle, id});
            return item ? `[#${item.id}] ${item.name}` : null;
        },

        // Handler for CameraScanner's "scan" event - logs every code from the batch it decoded.
        onCameraScan(codes) {
            codes.forEach(this.logDecode);
        },

        logDecode(code) {
            const text = code.text ?? "";
            const entry = {
                type: code.type,
                text,
                metaText: metaSummary(code.metadata),
                time: new Date().toLocaleTimeString(),
                link: classifyScanText(text),
                description: undefined,
                error: null,
            };
            this.cameraLog.unshift(entry);
            // Resolve against cameraLog[0], not the plain `entry` object above: Vue's reactivity
            // tracks property sets through the reactive proxy unshift() just installed, and
            // mutating the pre-insertion raw object later bypasses that proxy entirely, so the
            // description would never appear to update (stuck on "resolving...") even once the
            // lookup actually finished.
            this.resolveDescription(this.cameraLog[0]);
            // Caps the log (old entries trimmed) rather than growing forever, matching
            // CameraScanner's own dedupe window that makes them stale for re-matching.
            this.cameraLog.length = Math.min(this.cameraLog.length, 20);
        },
    },
    created() {
        // Not component data: resolveDescription's cached values are read back into each entry's
        // own (reactive) `description` field, so the cache itself never needs to be reactive.
        this.descriptionCache = new Map();
    },
}
</script>

<style scoped>
.scan-results {
    list-style: none;
    padding: 0;
    margin: 0;
    display: flex;
    flex-direction: column;
    gap: .4rem;
    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);
}
</style>
