/**
 * AWSVisionService — client for Mould Detect's own AWS Lambda Vision API
 * (the `aws_vision` and `aws_lambda_only` AI_ENGINE modes).
 *
 * CANONICAL REFERENCE: Mould-Detect-Swift-App/MouldDetect/Data/Vision/AWSVisionEngine.swift
 * (+ its tests). This is the web port of that engine: same 1536px JPEG downscale,
 * same `heatmap` opt-in, same three-verdict handling, same percentage derivation,
 * same persona insight wording.
 *
 * ─────────────────────────────────────────────────────────────────────────────
 * SECURITY — WHY THIS DOES NOT CALL AWS DIRECTLY
 * ─────────────────────────────────────────────────────────────────────────────
 * Swift holds `awsVisionAPIKey` inline in CloudConfig.swift. That is acceptable
 * for a compiled binary and is NOT acceptable here: this app is a public static
 * bundle, so anything the browser holds is published (exactly how the Nyckel
 * OAuth client secret leaked from index.html — see MouldDetectService.jsx).
 *
 * So the browser NEVER sees an `x-api-key`. It POSTs to OUR OWN same-origin
 * proxy (`/api/vision/classify`), which holds the key server-side and forwards
 * to the Function URL. Same posture the integration guide's §3 recommends
 * ("put a thin backend in front") and the same posture TD-30 gives the Swift
 * app via `CloudConfig.visionProxyEndpoint`.
 *
 * The `x-amz-content-sha256` header is required because CloudFront OAC signs
 * POSTs toward the Lambda Function URL and rejects an unsigned payload before
 * it ever reaches the application (same reason MouldDetectService sends it on
 * `/api/vision-token`; that request has no body so it can use a constant, this
 * one has a body so the hash is computed per request).
 *
 * ─────────────────────────────────────────────────────────────────────────────
 * HEATMAP COSTS ~145x THE COMPUTE — OFF BY DEFAULT
 * ─────────────────────────────────────────────────────────────────────────────
 * `heatmap` is false unless a caller opts in. `aws_vision` never opts in (the
 * on-device pipeline is the heat-map source there, so panels stay consistent
 * with the `local` engine); `aws_lambda_only` is the one caller that does,
 * because it has no local heat map at all.
 *
 * Depends on: ImageUtils (compression), SeverityConfig (severity banding),
 *             GradCAM (colourmap, for the server heat-map overlay only).
 * Must load AFTER ImageUtils/SeverityConfig/GradCAM and BEFORE AIEngineAdapter.
 *
 * ES5 — no const, let, arrow functions, template literals, spread, optional chaining.
 */
var AWSVisionService = (function () {

    var config = window.MOULD_DETECT_CONFIG || {};

    // Same-origin: CloudFront routes /api/* to the Lambda that holds the key.
    var CLASSIFY_URL = config.visionClassifyUrl || '/api/vision/classify';

    // Guide §7/§8 "downscale before sending, 1024–2048px longest side" — 1536px
    // JPEG q0.7 is what AWSVisionEngine.compressedJPEG(from:) sends.
    var MAX_DIMENSION = 1536;
    var JPEG_QUALITY  = 0.7;

    // Timeouts mirror AWSVisionEngine.makeRequest: fail fast normally (a stalled
    // cloud call must not hold the user), but allow 30s when a heat map was
    // requested — that path costs ~145x the server compute on top of any cold
    // start, and its one caller (aws_lambda_only) has no local fallback.
    var TIMEOUT_MS         = 10000;
    var HEATMAP_TIMEOUT_MS = 30000;

    // ════════════════════════════════════════════════════════════════════════
    // THE PROXY CONTRACT — the ONLY place in the app that knows the request or
    // response shape of POST /api/vision/classify. The proxy is being built in
    // parallel; when its final shape lands, reconcile it HERE and nowhere else.
    //
    // Assumed request  : { image_base64: <raw base64 JPEG>, heatmap: <bool>,
    //                      genus: <bool> }
    // Assumed response : the Lambda's own /classify body passed through verbatim
    //                    (API-INTEGRATION-GUIDE.md §4):
    //                    { label, confidence, raw_probability, uncertain,
    //                      threshold, model_version, model_sha256, request_id,
    //                      heatmap_png_base64, heatmap_significant, ... }
    //                    plus, ONLY on a positive verdict when `genus` was
    //                    requested, an optional `genus` object (mould genus
    //                    classifier plan §5). The proxy normalises that block's
    //                    internals; it is passed through here opaquely, and its
    //                    absence — negative verdict, not requested, or a genus
    //                    failure server-side — is today's normal, never an error.
    //
    // Everything below reads defensively: no field is assumed to exist, the
    // payload may arrive wrapped by the proxy (`result` / `data` / `body`), and
    // numbers may arrive as strings. Nothing here throws on a missing optional.
    // ════════════════════════════════════════════════════════════════════════

    function buildRequestBody(base64Jpeg, includeHeatmap, includeGenus) {
        return {
            image_base64: base64Jpeg,
            heatmap: includeHeatmap === true,
            genus: includeGenus === true
        };
    }

    /**
     * mapResult — proxy response → the normalised shape the rest of the app uses.
     *
     * Throws an Error carrying a `.code` when the response cannot be trusted:
     *   'unrecognised_response' — no usable label/confidence
     *   'uncertain_verdict'     — the server's third verdict (see below)
     *
     * @returns {{ percentage:number, isMould:boolean, serverLabel:string,
     *             heatmapBase64:string|null, heatmapSignificant:boolean|null,
     *             modelVersion:string|null, requestId:string|null,
     *             genus:object|null }}
     */
    function mapResult(payload) {
        var body = _unwrap(payload);
        if (!body || typeof body !== 'object') {
            throw _error('Unrecognised API response. Please try again.', 'unrecognised_response');
        }

        var label = _readString(body, ['label', 'prediction', 'class', 'verdict']);
        if (!label) {
            throw _error('Unrecognised API response. Please try again.', 'unrecognised_response');
        }
        label = label.toLowerCase();

        // `confidence` is documented as the CALIBRATED PROBABILITY OF THE MOULD
        // CLASS (guide §4) — not "confidence in whichever label was chosen" — so
        // it maps straight onto the 0–100 mould-likelihood this app expects with
        // no per-label inversion (unlike Nyckel's "No Mold" label, which does
        // invert: see MouldDetectService.parseResult).
        var confidence = _readNumber(body, ['confidence', 'raw_probability', 'score', 'probability']);
        if (confidence === null) {
            throw _error('Unrecognised API response. Please try again.', 'unrecognised_response');
        }
        // A calibrated probability arrives in 0–1 (guide §4). A value above 1 is
        // read as an already-scaled percentage — the same tolerance
        // MouldDetectService.parseResult applies to Nyckel, in case the proxy
        // normalises on the way through. 1.0 therefore means 100%, not 1%
        // (matching AWSVisionEngineTests.confidenceAcceptsStringAndIntEncodings).
        var percentage = (confidence > 1) ? Math.round(confidence) : Math.round(confidence * 100);
        percentage = Math.max(0, Math.min(100, percentage));

        // The three-verdict model (guide §4): `uncertain` means the model could
        // not judge the photo (blank/blurry/out-of-distribution). It is NOT
        // "clean" — coercing it into this binary seam would tell a user with
        // mould that they are fine. Reject it like a network failure so
        // aws_vision falls back to the local result and aws_lambda_only shows
        // an honest error/retake prompt.
        if (label === 'uncertain' || body.uncertain === true) {
            throw _error(
                'We could not assess this photo. Please retake it — closer, in focus, and in better light.',
                'uncertain_verdict'
            );
        }
        if (label !== 'has_mould' && label !== 'no_mould') {
            throw _error('Unrecognised API response. Please try again.', 'unrecognised_response');
        }

        return {
            percentage: percentage,
            // Web-parity mould boundary — the single rule shared with the Nyckel
            // verdict and with Swift's `CloudVerdict.isMould`. Deliberately NOT
            // the server's own label, whose threshold (0.6 by default) differs.
            isMould: percentage >= 50,
            serverLabel: label,
            heatmapBase64:      _readString(body, ['heatmap_png_base64', 'heatmapPngBase64']) || null,
            heatmapSignificant: (typeof body.heatmap_significant === 'boolean') ? body.heatmap_significant : null,
            modelVersion:       _readString(body, ['model_version', 'modelVersion']) || null,
            requestId:          _readString(body, ['request_id', 'requestId']) || null,
            // Optional genus block — present only on positive verdicts when the
            // caller opted in (plan §5). Deliberately NOT validated deeply: the
            // proxy normalises it, so a plain-object type guard is the honest
            // boundary here. Anything else (absent, null, array, string) reads
            // as "no genus ran" — a normal outcome, never a failed scan.
            genus:              (body.genus && typeof body.genus === 'object' && !Array.isArray(body.genus)) ? body.genus : null
        };
    }

    /** Error detail from a non-200 body — guide §4: `{ error, detail }`. */
    function mapErrorDetail(payload) {
        var body = _unwrap(payload);
        if (!body || typeof body !== 'object') return 'unknown error';
        return _readString(body, ['detail', 'error', 'message', 'reason']) || 'unknown error';
    }

    // ════════════════════════════════════════════════════════════════════════
    // Defensive readers — nothing above assumes a field exists or has a type.
    // ════════════════════════════════════════════════════════════════════════

    // The proxy may hand back the Lambda body verbatim or wrapped. Accept both.
    function _unwrap(payload) {
        if (!payload || typeof payload !== 'object') return payload;
        var keys = ['result', 'data', 'body', 'classification'];
        for (var i = 0; i < keys.length; i++) {
            var nested = payload[keys[i]];
            if (nested && typeof nested === 'object' &&
                (nested.label !== undefined || nested.confidence !== undefined)) {
                return nested;
            }
        }
        return payload;
    }

    function _readString(obj, names) {
        for (var i = 0; i < names.length; i++) {
            var v = obj[names[i]];
            if (typeof v === 'string' && v.length > 0) return v;
        }
        return null;
    }

    function _readNumber(obj, names) {
        for (var i = 0; i < names.length; i++) {
            var v = obj[names[i]];
            var n = null;
            if (typeof v === 'number') {
                n = v;
            } else if (typeof v === 'string' && v.length > 0) {
                n = parseFloat(v);
            }
            // isFinite, not just !isNaN: a malformed response parsing to
            // +/-Infinity would otherwise sail through and produce nonsense
            // (the same guard AWSVisionEngine.parseVerdict makes explicit).
            if (n !== null && isFinite(n)) return n;
        }
        return null;
    }

    function _error(message, code) {
        var err = new Error(message);
        err.code = code;
        err.engine = 'aws_vision';
        return err;
    }

    // ════════════════════════════════════════════════════════════════════════
    // Verdict text — shared cloud-verdict persona.
    //
    // Port of Swift's `CloudVerdictPersona` (declared in AWSVisionEngine.swift
    // and reused by Nyckel there). The wording is identical to
    // MouldDetectService.getPersonaResponse; it is duplicated here rather than
    // imported because MouldDetectService does not export it. Exposed on the
    // public API so MouldDetectService can delegate to it in a later pass and
    // the wording lives in one place, as it now does natively.
    // ════════════════════════════════════════════════════════════════════════

    function verdictFor(pct) {
        if (pct >= 80) return 'Very Likely Mould';
        if (pct >= 60) return 'Possible Mould';
        if (pct >= 40) return 'Uncertain';
        return 'Unlikely Mould';
    }

    function severityFor(pct) {
        if (typeof SeverityConfig !== 'undefined' && SeverityConfig.fromPercentage) {
            return SeverityConfig.fromPercentage(pct).severity;
        }
        if (pct >= 80) return 'critical';
        if (pct >= 60) return 'high';
        if (pct >= 40) return 'moderate';
        return 'low';
    }

    function insightFor(pct) {
        if (pct >= 80)
            return 'This image shows strong visual indicators consistent with mould (' + pct + '% confidence). Check for moisture sources such as leaks, condensation, or poor ventilation. Professional inspection is recommended.';
        if (pct >= 60)
            return 'There are visible patterns that may be consistent with mould (' + pct + '% confidence). Monitor this area closely. Improving airflow and addressing possible moisture sources may help.';
        if (pct >= 40)
            return 'The result is inconclusive (' + pct + '% confidence). This could be surface staining, dirt, or early-stage mould. Keep an eye on changes over time and check for dampness or odours.';
        return 'This image does not show strong visual indicators of mould (' + pct + '% confidence). It may be surface marks or lighting effects. If you experience musty smells or moisture issues, further investigation is worthwhile.';
    }

    // ════════════════════════════════════════════════════════════════════════
    // Transport
    // ════════════════════════════════════════════════════════════════════════

    // SHA-256 hex of the exact request body. CloudFront OAC signs the payload,
    // so this must be the hash of what is actually sent. Web Crypto needs a
    // secure context (https / localhost) — both of which describe every context
    // where CloudFront is in front of us. If it is unavailable we send the
    // request without the header rather than sending a wrong hash: a plain-http
    // dev server has no OAC to satisfy, and a wrong hash would be rejected.
    function _sha256Hex(text) {
        var subtle = (typeof crypto !== 'undefined' && crypto && crypto.subtle) ? crypto.subtle : null;
        if (!subtle || typeof TextEncoder === 'undefined') return Promise.resolve(null);
        var bytes;
        try {
            bytes = new TextEncoder().encode(text);
        } catch (e) {
            return Promise.resolve(null);
        }
        return Promise.resolve(subtle.digest('SHA-256', bytes))
            .then(function (buffer) {
                var view = new Uint8Array(buffer);
                var hex = '';
                for (var i = 0; i < view.length; i++) {
                    var h = view[i].toString(16);
                    hex += (h.length === 1) ? ('0' + h) : h;
                }
                return hex;
            })
            ['catch'](function () { return null; });
    }

    // 'data:image/jpeg;base64,XXXX' → 'XXXX'. The API accepts a data URI too
    // (guide §4) but raw base64 is what the Swift client sends, so both ends
    // stay on one shape.
    function _base64Payload(dataURL) {
        if (typeof dataURL !== 'string') return '';
        var comma = dataURL.indexOf(',');
        return (comma === -1) ? dataURL : dataURL.slice(comma + 1);
    }

    function _postClassify(base64Jpeg, includeHeatmap, includeGenus) {
        var bodyText = JSON.stringify(buildRequestBody(base64Jpeg, includeHeatmap, includeGenus));
        var timeoutMs = includeHeatmap ? HEATMAP_TIMEOUT_MS : TIMEOUT_MS;

        return _sha256Hex(bodyText).then(function (hash) {
            var controller = (typeof AbortController !== 'undefined') ? new AbortController() : null;
            var timedOut = false;
            var timer = setTimeout(function () {
                timedOut = true;
                if (controller) controller.abort();
            }, timeoutMs);

            var headers = { 'Content-Type': 'application/json' };
            if (hash) {
                headers['x-amz-content-sha256'] = hash;
            } else {
                console.warn('[AWSVisionService] Web Crypto unavailable — sending without x-amz-content-sha256. This will be rejected by CloudFront OAC; expected only on a plain-http dev origin.');
            }

            var options = { method: 'POST', headers: headers, body: bodyText };
            if (controller) options.signal = controller.signal;

            return fetch(CLASSIFY_URL, options)
                .then(function (res) {
                    clearTimeout(timer);
                    // Read the body once, tolerating a non-JSON error page.
                    return res.text().then(function (text) {
                        var parsed = null;
                        try { parsed = text ? JSON.parse(text) : null; } catch (e) { parsed = null; }
                        if (res.ok) {
                            if (parsed === null) {
                                throw _error('Unrecognised API response. Please try again.', 'unrecognised_response');
                            }
                            return parsed;
                        }
                        throw _statusError(res.status, parsed);
                    });
                })
                ['catch'](function (err) {
                    clearTimeout(timer);
                    if (err && err.code) throw err;
                    if (timedOut || (err && err.name === 'AbortError')) {
                        throw _error('The vision request timed out. Please try again.', 'timeout');
                    }
                    // fetch() rejects with a TypeError for network/CORS failures.
                    throw _error('Vision service unreachable: ' + ((err && err.message) || 'network error'), 'proxy_unreachable');
                });
        });
    }

    // Status mapping mirrors AWSVisionEngine.detect's switch (guide §4 error
    // table). Messages deliberately contain the substrings AnalysisPage's
    // _friendlyError() already matches on ('401', 'timeout', 'unrecognised
    // api'), so users see plain English, and `.code` is carried for callers
    // that want to branch precisely.
    function _statusError(status, payload) {
        var detail = mapErrorDetail(payload);
        if (status === 401 || status === 403) {
            return _error('Vision service auth failed: ' + status, 'auth_failed');
        }
        if (status === 413) {
            return _error('The photo is too large to analyse. Please try a smaller image. (413 ' + detail + ')', 'payload_too_large');
        }
        if (status === 503) {
            return _error('The analysis service is temporarily unavailable (503 ' + detail + ').', 'service_unavailable');
        }
        if (status === 400) {
            return _error('The analysis service rejected the request (400 ' + detail + ').', 'bad_request');
        }
        if (status === 429) {
            // Our proxy only ever returns 429 for the GLOBAL DAILY CAP, which resets at
            // midnight UTC — not for a short rate limit. "Wait a moment" would send someone
            // to retry in thirty seconds, forever.
            return _error('The daily analysis limit for this deployment has been reached. Please try again tomorrow. (429)', 'daily_cap');
        }
        return _error('Detection API failed: ' + status + ' ' + detail, 'server_error');
    }

    // ════════════════════════════════════════════════════════════════════════
    // Server heat map → overlay
    //
    // The API returns a RAW GRAYSCALE saliency PNG. Swift colourises and
    // composites it through the SAME renderer as the local pipeline so the
    // heat-map panel and its jet legend mean the same thing regardless of
    // engine (AnalysisViewModel.runCloudOnlyAnalysis). This does the same with
    // GradCAM.colourmap — never hand LocalHeatmapPanel a grayscale image beside
    // a colour legend it does not obey.
    //
    // Resolves null (never rejects) on a flat map, a decode failure, or a
    // missing dependency — the panel is then simply not shown.
    // ════════════════════════════════════════════════════════════════════════

    function composeHeatmapOverlay(heatmapBase64, baseImageDataURL, alpha) {
        var blend = (alpha === undefined) ? 0.5 : alpha;

        if (!heatmapBase64 || !baseImageDataURL) return Promise.resolve(null);
        if (typeof GradCAM === 'undefined' || !GradCAM.colourmap) {
            console.warn('[AWSVisionService] GradCAM unavailable — server heat map not rendered.');
            return Promise.resolve(null);
        }

        var heatSrc = (heatmapBase64.indexOf('data:') === 0)
            ? heatmapBase64
            : 'data:image/png;base64,' + heatmapBase64;

        return Promise.all([_loadImage(baseImageDataURL), _loadImage(heatSrc)])
            .then(function (images) {
                var baseImg = images[0];
                var heatImg = images[1];

                // Cap the composite so a DSLR original does not allocate a
                // 40-megapixel canvas on a phone.
                var maxDim = 1024;
                var w = baseImg.width;
                var h = baseImg.height;
                if (w > h && w > maxDim)      { h = Math.round(h * maxDim / w); w = maxDim; }
                else if (h > maxDim)          { w = Math.round(w * maxDim / h); h = maxDim; }
                if (w < 1 || h < 1) return null;

                var canvas = document.createElement('canvas');
                canvas.width = w;
                canvas.height = h;
                var ctx = canvas.getContext('2d');
                ctx.drawImage(baseImg, 0, 0, w, h);
                var baseData = ctx.getImageData(0, 0, w, h);

                // Upscale the (typically 224x224) saliency map to the composite
                // size, then read its luminance.
                var heatCanvas = document.createElement('canvas');
                heatCanvas.width = w;
                heatCanvas.height = h;
                var heatCtx = heatCanvas.getContext('2d');
                heatCtx.drawImage(heatImg, 0, 0, w, h);
                var heatData = heatCtx.getImageData(0, 0, w, h).data;

                var count = w * h;
                var values = new Float32Array(count);
                var max = 0;
                var min = 255;
                var i;
                for (i = 0; i < count; i++) {
                    // Grayscale PNG: R=G=B. Average anyway, in case the server
                    // ever returns an already-colourised map.
                    var lum = (heatData[i * 4] + heatData[i * 4 + 1] + heatData[i * 4 + 2]) / 3;
                    values[i] = lum;
                    if (lum > max) max = lum;
                    if (lum < min) min = lum;
                }

                // A flat map carries no attribution — Swift hides the panel in
                // exactly this case rather than showing a uniform wash.
                if (max - min < 1) {
                    _releaseCanvas(canvas, ctx, w, h);
                    _releaseCanvas(heatCanvas, heatCtx, w, h);
                    return null;
                }

                var range = max - min;
                var out = baseData.data;
                for (i = 0; i < count; i++) {
                    var t = (values[i] - min) / range;
                    var rgb = GradCAM.colourmap(t);
                    out[i * 4]     = Math.round(out[i * 4]     * (1 - blend) + rgb[0] * blend);
                    out[i * 4 + 1] = Math.round(out[i * 4 + 1] * (1 - blend) + rgb[1] * blend);
                    out[i * 4 + 2] = Math.round(out[i * 4 + 2] * (1 - blend) + rgb[2] * blend);
                    out[i * 4 + 3] = 255;
                }
                ctx.putImageData(baseData, 0, 0);
                var result = canvas.toDataURL('image/jpeg', 0.85);

                _releaseCanvas(canvas, ctx, w, h);
                _releaseCanvas(heatCanvas, heatCtx, w, h);
                return result;
            })
            ['catch'](function (err) {
                console.warn('[AWSVisionService] Server heat map could not be rendered:', err && err.message);
                return null;
            });
    }

    function _loadImage(src) {
        return new Promise(function (resolve, reject) {
            var img = new Image();
            img.onload = function () {
                img.onload = img.onerror = null;
                resolve(img);
            };
            img.onerror = function () {
                img.onload = img.onerror = null;
                reject(new Error('image decode failed'));
            };
            img.src = src;
        });
    }

    function _releaseCanvas(canvas, ctx, w, h) {
        try {
            ctx.clearRect(0, 0, w, h);
            canvas.width = 0;
            canvas.height = 0;
        } catch (e) { /* nothing to release */ }
    }

    // ════════════════════════════════════════════════════════════════════════
    // Public API
    // ════════════════════════════════════════════════════════════════════════

    /**
     * analyze — compress, POST to the proxy, and return a complete cloud verdict.
     *
     * @param {string} imageDataURL — the source image as a base64 data URL
     * @param {object} [options]
     *        options.heatmap  {boolean}  request the server saliency map (default false —
     *                                    it costs ~145x the compute)
     *        options.genus    {boolean}  request the mould-genus candidates block (default
     *                                    false — same never-defaulted-on discipline as
     *                                    heatmap; the server only honours it on positive
     *                                    verdicts, plan §5)
     *        options.onStage  {function} progress callback (string message)
     * @returns {Promise<{percentage:number, label:string, verdict:string,
     *                    severity:string, insight:string, isMould:boolean,
     *                    serverHeatmapBase64:string|null, heatmapSignificant:boolean|null,
     *                    modelVersion:string|null, requestId:string|null,
     *                    genus:object|null, raw:object}>}
     *
     * Rejects with an Error carrying `.code` (see mapResult / _statusError).
     */
    function analyze(imageDataURL, options) {
        var opts = options || {};
        var includeHeatmap = opts.heatmap === true;
        var includeGenus   = opts.genus === true;
        var notify = opts.onStage || function () {};

        if (!imageDataURL) {
            return Promise.reject(_error('Image not ready. Please go back and select your photo again.', 'no_image'));
        }
        // Fail fast when offline rather than waiting out the timeout — same
        // guard MouldDetectService.getToken makes, and AnalysisPage's
        // _friendlyError already maps the message 'offline'.
        if (typeof navigator !== 'undefined' && navigator.onLine === false) {
            return Promise.reject(_error('offline', 'offline'));
        }
        if (typeof ImageUtils === 'undefined' || !ImageUtils.compressToJpeg) {
            return Promise.reject(_error('Image tools are unavailable. Please reload the app.', 'image_encoding_failed'));
        }

        return ImageUtils.compressToJpeg(imageDataURL, MAX_DIMENSION, JPEG_QUALITY)
            .then(function (jpegDataURL) {
                notify('Analysing image…');
                return _postClassify(_base64Payload(jpegDataURL), includeHeatmap, includeGenus);
            })
            .then(function (payload) {
                var mapped = mapResult(payload);
                var pct = mapped.percentage;
                return {
                    percentage: pct,
                    // EngineResult contract label — derived from the shared
                    // isMould boundary, not from the server's own threshold.
                    label:    mapped.isMould ? 'Mould' : 'No Mould',
                    isMould:  mapped.isMould,
                    verdict:  verdictFor(pct),
                    severity: severityFor(pct),
                    insight:  insightFor(pct),
                    serverHeatmapBase64: mapped.heatmapBase64,
                    heatmapSignificant:  mapped.heatmapSignificant,
                    modelVersion:        mapped.modelVersion,
                    requestId:           mapped.requestId,
                    // Opaque genus candidates block, or null when the server sent
                    // none — which is every response until the classifier ships,
                    // every negative verdict, and every genus failure thereafter.
                    genus:               mapped.genus,
                    raw: payload
                };
            });
    }

    return {
        // Exported for other same-origin POST callers — CloudFront OAC requires
        // x-amz-content-sha256 on POST bodies (see header comment). Pure util, safe to share.
        sha256Hex: _sha256Hex,
        analyze: analyze,
        composeHeatmapOverlay: composeHeatmapOverlay,
        // Exposed for reconciliation with the proxy and for tests — the whole
        // request/response contract is these three functions.
        buildRequestBody: buildRequestBody,
        mapResult: mapResult,
        mapErrorDetail: mapErrorDetail,
        // Shared cloud-verdict persona (Swift: CloudVerdictPersona).
        verdictFor: verdictFor,
        severityFor: severityFor,
        insightFor: insightFor,
        classifyUrl: CLASSIFY_URL
    };
})();

window.AWSVisionService = AWSVisionService;
