/**
 * CloudAssemblyVisionService — client for the Cloud Assembly "MouldDetect
 * Inference API" (the `cloud_assembly` AI_ENGINE mode).
 *
 * ─────────────────────────────────────────────────────────────────────────────
 * WHAT THIS TALKS TO, AND WHY IT LOOKS LIKE AWSVisionService
 * ─────────────────────────────────────────────────────────────────────────────
 * The upstream is the delivered Prod-PoC inference API (ConvNeXt-V7 + TTA,
 * EigenCAM attention maps, a Bedrock-authored species narrative). It is a
 * THREE-CALL flow upstream — presign, presigned S3 upload, detect — but none
 * of that is visible here: the backend composes all three behind ONE endpoint,
 * `POST /api/vision/ca-classify`, taking the same envelope as
 * `/api/vision/classify`. That is deliberate. A browser driving the three calls
 * would also own the S3 field ordering, the retry rules, and a presigned URL it
 * must never persist.
 *
 * So this file mirrors AWSVisionService's shape on purpose: same 1536px q0.7
 * JPEG downscale, same `x-amz-content-sha256` requirement, same status→error
 * mapping, same result object. An engine swap should not change what the rest
 * of the app handles.
 *
 * SECURITY — the browser never holds an `x-api-key`. Same reason as
 * AWSVisionService (see its header): this app is a public static bundle, so
 * anything it holds is published — which is exactly how the Nyckel OAuth client
 * secret leaked once already. The key lives server-side, in Secrets Manager.
 *
 * ─────────────────────────────────────────────────────────────────────────────
 * FOUR RESULTS, TWO OF WHICH ARE NOT VERDICTS
 * ─────────────────────────────────────────────────────────────────────────────
 * The server maps the upstream's four results onto `label`:
 *   has_mould / no_mould → a verdict, `is_mould` true/false
 *   uncertain            → the model could not judge the photo. NOT "clean".
 *   rejected             → the image failed input-quality checks; the response
 *                          carries the reason ("too blurry", …).
 * Both non-verdicts are REJECTED here as errors so the caller shows an honest
 * retake prompt rather than rendering a percentage that means nothing —
 * `uncertain_verdict` (same code and copy AWSVisionService throws) and
 * `rejected_verdict`, which quotes the server's own reason because "hold the
 * camera steadier" is actionable in a way "we could not assess this" is not.
 *
 * SPECIES NARRATIVE IS INDICATIVE ONLY. `speciesSuggestion` is model-authored
 * markdown, never a determination. Anything that displays it must carry the
 * disclaimer (ScanDetails does) — this file relays it verbatim and adds nothing.
 *
 * Depends on: ImageUtils (compression), AWSVisionService (sha256Hex, the shared
 *             cloud-verdict persona, and the heat-map compositor — all exported
 *             by that module precisely so a second cloud engine can reuse them
 *             instead of duplicating the wording and the colourmap).
 * Must load AFTER ImageUtils/SeverityConfig/GradCAM/AWSVisionService and BEFORE
 * AIEngineAdapter.
 *
 * ES5 — no const, let, arrow functions, template literals, spread, optional chaining.
 */
var CloudAssemblyVisionService = (function () {

    var cavConfig = window.MOULD_DETECT_CONFIG || {};

    // Same-origin: CloudFront routes /api/* to the Lambda that holds the key.
    var CAV_CLASSIFY_URL = cavConfig.cloudAssemblyClassifyUrl || '/api/vision/ca-classify';

    // Identical to AWSVisionService: 1536px q0.7 is what every Mould Detect
    // client sends, so the model sees the same image shape from all of them.
    var CAV_MAX_DIMENSION = 1536;
    var CAV_JPEG_QUALITY  = 0.7;

    // 60s, far longer than AWSVisionService's 10s. Not caution — arithmetic: the
    // server does three upstream calls plus a heat-map fetch plus a genus call,
    // and detect alone measures ~8s. Its own budget is ~45s, so this ceiling
    // exists to outlast that budget and let the SERVER's legible error arrive,
    // rather than aborting first and reporting a generic timeout.
    var CAV_TIMEOUT_MS = 60000;

    // ════════════════════════════════════════════════════════════════════════
    // THE PROXY CONTRACT — the ONLY place in the app that knows the request or
    // response shape of POST /api/vision/ca-classify.
    //
    // Request  : { image_base64: <raw base64 JPEG> }
    // Response : { label, confidence, percentage, is_mould, uncertain,
    //              rejected_reason, confidence_tier, saliency_score,
    //              model_version, request_id, species_suggestion,
    //              species_confidence, heatmap_png_base64, genus, engine }
    //
    // Everything below reads defensively: no field is assumed to exist and
    // numbers may arrive as strings. Nothing here throws on a missing optional.
    // ════════════════════════════════════════════════════════════════════════

    function cavBuildRequestBody(base64Jpeg) {
        return { image_base64: base64Jpeg };
    }

    /**
     * cavMapResult — proxy response → the normalised shape the rest of the app uses.
     *
     * Throws an Error carrying a `.code` when the response is not a verdict:
     *   'unrecognised_response' — no usable label/confidence
     *   'uncertain_verdict'     — the model could not judge the photo
     *   'rejected_verdict'      — input-quality refusal, `.reason` carries why
     */
    function cavMapResult(payload) {
        var body = cavUnwrap(payload);
        if (!body || typeof body !== 'object') {
            throw cavError('Unrecognised API response. Please try again.', 'unrecognised_response');
        }

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

        // Input-quality refusal FIRST: a rejected image was never scored, so
        // there is no confidence to read and nothing to say about mould. The
        // server's reason is quoted because it is the actionable part.
        if (label === 'rejected') {
            var reason = cavReadString(body, ['rejected_reason', 'rejection_reason', 'reason']);
            var err = cavError(
                reason
                    ? ('This photo could not be assessed: ' + reason + ' Please retake it and try again.')
                    : 'This photo could not be assessed. Please retake it — closer, in focus, and in better light.',
                'rejected_verdict'
            );
            err.reason = reason || '';
            throw err;
        }

        // `confidence` is the probability of the MOULD class after TTA, whichever
        // label it produced — no per-label inversion (unlike Nyckel's "No Mold").
        var confidence = cavReadNumber(body, ['confidence', 'mould_probability', 'score']);
        var percentage = cavReadNumber(body, ['percentage']);
        if (confidence === null && percentage === null) {
            throw cavError('Unrecognised API response. Please try again.', 'unrecognised_response');
        }
        if (percentage === null) {
            // A probability arrives in 0–1; a value above 1 is read as an
            // already-scaled percentage, the same tolerance AWSVisionService applies.
            percentage = (confidence > 1) ? Math.round(confidence) : Math.round(confidence * 100);
        }
        percentage = Math.max(0, Math.min(100, Math.round(percentage)));

        // The third state (see the header): `uncertain` is NOT "clean". Coercing
        // it into the binary seam would tell a user with mould that they are fine.
        if (label === 'uncertain' || body.uncertain === true) {
            throw cavError(
                '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 cavError('Unrecognised API response. Please try again.', 'unrecognised_response');
        }

        return {
            percentage: percentage,
            // Web-parity mould boundary — the single 50% rule shared with every
            // other engine, and the same seam the upstream's own threshold uses.
            isMould: percentage >= 50,
            serverLabel: label,
            heatmapBase64:   cavReadString(body, ['heatmap_png_base64', 'heatmapPngBase64']) || null,
            modelVersion:    cavReadString(body, ['model_version', 'modelVersion']) || null,
            requestId:       cavReadString(body, ['request_id', 'requestId']) || null,
            confidenceTier:  cavReadString(body, ['confidence_tier', 'confidenceTier']) || null,
            saliencyScore:   cavReadNumber(body, ['saliency_score', 'saliencyScore']),
            // Model-authored markdown. INDICATIVE ONLY — relayed verbatim, never
            // reworded here into anything that reads like a determination.
            speciesSuggestion: cavReadString(body, ['species_suggestion', 'speciesSuggestion']) || null,
            speciesConfidence: cavReadString(body, ['species_confidence', 'speciesConfidence']) || null,
            // Genus block, normalised server-side into the same nested shape the
            // other engine produces. Absent is normal (negative verdicts, and any
            // genus failure — which by contract never fails the scan).
            genus: (body.genus && typeof body.genus === 'object' && !Array.isArray(body.genus)) ? body.genus : null
        };
    }

    /** Error detail from a non-200 body — our proxy's envelope is { error, reason, code }. */
    function cavMapErrorDetail(payload) {
        var body = cavUnwrap(payload);
        if (!body || typeof body !== 'object') return 'unknown error';
        return cavReadString(body, ['reason', 'detail', 'error', 'message']) || 'unknown error';
    }

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

    function cavUnwrap(payload) {
        if (!payload || typeof payload !== 'object') return payload;
        var keys = ['result', 'data', 'body'];
        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 cavReadString(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 cavReadNumber(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.
            if (n !== null && isFinite(n)) return n;
        }
        return null;
    }

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

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

    // 'data:image/jpeg;base64,XXXX' → 'XXXX'.
    function cavBase64Payload(dataURL) {
        if (typeof dataURL !== 'string') return '';
        var comma = dataURL.indexOf(',');
        return (comma === -1) ? dataURL : dataURL.slice(comma + 1);
    }

    // CloudFront OAC signs POST payloads toward the Lambda Function URL and
    // rejects an unsigned body before it reaches the application. AWSVisionService
    // exports its hasher for exactly this reuse; if it is missing we resolve null
    // and send without the header rather than sending a WRONG hash (a plain-http
    // dev origin has no OAC to satisfy).
    function cavSha256Hex(text) {
        if (window.AWSVisionService && window.AWSVisionService.sha256Hex) {
            return window.AWSVisionService.sha256Hex(text);
        }
        return Promise.resolve(null);
    }

    function cavPostClassify(base64Jpeg) {
        var bodyText = JSON.stringify(cavBuildRequestBody(base64Jpeg));

        return cavSha256Hex(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();
            }, CAV_TIMEOUT_MS);

            var headers = { 'Content-Type': 'application/json' };
            if (hash) {
                headers['x-amz-content-sha256'] = hash;
            } else {
                console.warn('[CloudAssemblyVisionService] No body hash available — 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(CAV_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 cavError('Unrecognised API response. Please try again.', 'unrecognised_response');
                            }
                            return parsed;
                        }
                        throw cavStatusError(res.status, parsed);
                    });
                })
                ['catch'](function (err) {
                    clearTimeout(timer);
                    if (err && err.code) throw err;
                    if (timedOut || (err && err.name === 'AbortError')) {
                        throw cavError('The vision request timed out. Please try again.', 'timeout');
                    }
                    // fetch() rejects with a TypeError for network/CORS failures.
                    throw cavError('Vision service unreachable: ' + ((err && err.message) || 'network error'), 'proxy_unreachable');
                });
        });
    }

    // Status mapping matches AWSVisionService exactly — same codes, same message
    // substrings AnalysisPage._friendlyError() already matches on ('401',
    // 'timeout', 'unrecognised api'), so an engine swap changes no error copy.
    function cavStatusError(status, payload) {
        var detail = cavMapErrorDetail(payload);
        if (status === 401 || status === 403) {
            return cavError('Vision service auth failed: ' + status, 'auth_failed');
        }
        if (status === 413) {
            return cavError('The photo is too large to analyse. Please try a smaller image. (413 ' + detail + ')', 'payload_too_large');
        }
        if (status === 503) {
            return cavError('The analysis service is temporarily unavailable (503 ' + detail + ').', 'service_unavailable');
        }
        if (status === 400) {
            return cavError('The analysis service rejected the request (400 ' + detail + ').', 'bad_request');
        }
        if (status === 429) {
            // Our proxy returns 429 only for the GLOBAL DAILY CAP, which resets at
            // midnight UTC. An upstream rate limit arrives as a 503 instead, so
            // "try again tomorrow" is never shown for a throttle clearing in seconds.
            return cavError('The daily analysis limit for this deployment has been reached. Please try again tomorrow. (429)', 'daily_cap');
        }
        return cavError('Detection API failed: ' + status + ' ' + detail, 'server_error');
    }

    // ════════════════════════════════════════════════════════════════════════
    // 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.onStage {function} progress callback (string message)
     * @returns {Promise<{percentage:number, label:string, verdict:string,
     *                    severity:string, insight:string, isMould:boolean,
     *                    serverHeatmapBase64:string|null, modelVersion:string|null,
     *                    requestId:string|null, confidenceTier:string|null,
     *                    saliencyScore:number|null, speciesSuggestion:string|null,
     *                    speciesConfidence:string|null, genus:object|null, raw:object}>}
     *
     * Rejects with an Error carrying `.code` (see cavMapResult / cavStatusError).
     * The heat map and species narrative are NOT requested flags here: the server
     * decides both (a heat map exists for positive detections only), so there is
     * no client opt-in to get wrong.
     */
    function analyze(imageDataURL, options) {
        var opts = options || {};
        var notify = opts.onStage || function () {};

        if (!imageDataURL) {
            return Promise.reject(cavError('Image not ready. Please go back and select your photo again.', 'no_image'));
        }
        // Fail fast when offline rather than waiting out a 60s timeout.
        if (typeof navigator !== 'undefined' && navigator.onLine === false) {
            return Promise.reject(cavError('offline', 'offline'));
        }
        if (typeof ImageUtils === 'undefined' || !ImageUtils.compressToJpeg) {
            return Promise.reject(cavError('Image tools are unavailable. Please reload the app.', 'image_encoding_failed'));
        }
        // The verdict persona (verdictFor/severityFor/insightFor) lives in
        // AWSVisionService and is shared rather than duplicated — one wording for
        // every cloud engine. Without it there is no honest copy to show.
        if (typeof window.AWSVisionService === 'undefined') {
            return Promise.reject(cavError('The AI vision service is unavailable. Please reload the app.', 'service_unavailable'));
        }

        return ImageUtils.compressToJpeg(imageDataURL, CAV_MAX_DIMENSION, CAV_JPEG_QUALITY)
            .then(function (jpegDataURL) {
                notify('Analysing image…');
                return cavPostClassify(cavBase64Payload(jpegDataURL));
            })
            .then(function (payload) {
                var mapped = cavMapResult(payload);
                var pct = mapped.percentage;
                return {
                    percentage: pct,
                    label:    mapped.isMould ? 'Mould' : 'No Mould',
                    isMould:  mapped.isMould,
                    verdict:  window.AWSVisionService.verdictFor(pct),
                    severity: window.AWSVisionService.severityFor(pct),
                    insight:  window.AWSVisionService.insightFor(pct),
                    serverHeatmapBase64: mapped.heatmapBase64,
                    // The upstream sends an attention map only when it has one to
                    // send, so presence IS significance here — unlike the other
                    // engine, which reports a separate significance flag.
                    heatmapSignificant:  mapped.heatmapBase64 ? true : null,
                    modelVersion:        mapped.modelVersion,
                    requestId:           mapped.requestId,
                    confidenceTier:      mapped.confidenceTier,
                    saliencyScore:       mapped.saliencyScore,
                    speciesSuggestion:   mapped.speciesSuggestion,
                    speciesConfidence:   mapped.speciesConfidence,
                    genus:               mapped.genus,
                    raw: payload
                };
            });
    }

    return {
        analyze: analyze,
        // Exposed for reconciliation with the proxy and for tests — the whole
        // request/response contract is these three functions.
        buildRequestBody: cavBuildRequestBody,
        mapResult: cavMapResult,
        mapErrorDetail: cavMapErrorDetail,
        classifyUrl: CAV_CLASSIFY_URL
    };
})();

window.CloudAssemblyVisionService = CloudAssemblyVisionService;
