/**
 * AIEngineAdapter — Configurable adapter for AI mould detection engines.
 *
 * Implements the Strategy pattern: each engine is an object conforming to the
 * EngineStrategy interface. AnalysisPage calls AIEngineAdapter.resolve() once,
 * receives a strategy, and calls strategy.analyze() — engine details are fully
 * encapsulated here. Adding a new external AI provider requires only a new
 * strategy object; no changes to AnalysisPage, ScanStore, or any UI component.
 *
 * Engine priority (read from AI_ENGINE feature flag). These values, and their
 * meanings, mirror the Swift app — the canonical implementation (see
 * Mould-Detect-Swift-App/MouldDetect/Data/Vision/ and its feature-flags.json):
 *   'local'            → LocalCNNStrategy      — full on-device pipeline
 *   'hybrid'           → HybridStrategy        — Nyckel verdict + Local CNN enrichment
 *   'nyckel'           → NyckelStrategy        — cloud detection only
 *   'aws_vision'       → AWSVisionStrategy     — Mould Detect's own cloud verdict
 *                                                + Local CNN enrichment (the hybrid
 *                                                role, first-party verdict source)
 *   'aws_lambda_only'  → AWSLambdaOnlyStrategy — Mould Detect's own cloud verdict
 *                                                ALONE. No local models, no local
 *                                                enrichment; the server's own
 *                                                saliency map is the only heat map
 *   'cloud_assembly'   → CloudAssemblyStrategy — the Cloud Assembly inference API
 *                                                (ConvNeXt-V7 + TTA, EigenCAM map,
 *                                                Bedrock species narrative) as the
 *                                                verdict source, same cloud-only
 *                                                shape as aws_lambda_only plus an
 *                                                indicative species assessment
 *   'none'             → NoneStrategy          — error state
 *
 * The `cloud` placeholder value is gone (TD-08), matching Swift, which removed
 * the equivalent `AIEngine.cloud` case when `aws_vision` shipped for real. It
 * never produced stored data — nothing wrote `aiEngine: 'cloud_ai'` to a scan —
 * so no migration is needed. A stale `cloud` flag value now takes resolve()'s
 * unknown-value path, which warns and falls back to Nyckel: byte-for-byte the
 * behaviour the placeholder strategy had.
 *
 * EngineResult contract (superset of all engine outputs):
 * {
 *   // Core detection — all engines
 *   percentage      number       0–100 confidence
 *   verdict         string       'Very Likely Mould' | 'Possible Mould' | 'Uncertain' | 'Unlikely Mould'
 *   severity        string       'critical' | 'high' | 'moderate' | 'low'
 *   label           string       'Mould' | 'No Mould'
 *   insight         string       contextual explanation text
 *   aiEngine        string       'local_cnn' | 'hybrid' | 'nyckel' | 'aws_vision'
 *                                | 'aws_lambda_only' | 'cloud_assembly'
 *                                — persisted verbatim on the scan record
 *                                  (ScanStore.addScan), so it is also the value
 *                                  ScanDetails/reports read back. The aws_* values
 *                                  are the same raw strings the Swift app stores
 *                                  (AIEngine.awsVision / .awsLambdaOnly).
 *
 *   // Enrichment — Local CNN, Hybrid and AWS Vision engines (null for plain Nyckel;
 *   // for aws_lambda_only only the server heat map is present)
 *   hasEnrichment   boolean      true when enrichment fields are present
 *   freqSpectrum    object|null  { low, mid, high, signature, description }
 *   saliencyLevel   string|null  'HIGH' | 'MODERATE' | 'DIFFUSE'
 *   saliencyPeakToMean number|null
 *   isShortcut      boolean      border attention warning
 *   heatmapDataURL  string|null  base64 JPEG overlay
 *   speciesTop      object|null  { label, confidence, description, color }
 *   speciesRanked   array        [{ label, confidence, color, description }]
 *   speciesIsUncertain boolean
 *   speciesSummary  string
 *   genus           object|null  the vision API's raw genus candidates block, passed
 *                                through verbatim (mould genus classifier plan §5).
 *                                aws_lambda_only and cloud_assembly are its producers;
 *                                null everywhere else and whenever the server sent none.
 *
 *   // Cloud Assembly engine only (null for every other engine)
 *   modelVersion      string|null the upstream weights that produced the verdict
 *   speciesSuggestion string|null model-authored markdown narrative about likely
 *                                species. INDICATIVE ONLY, never a diagnosis — any
 *                                surface that renders it carries the disclaimer.
 *   speciesConfidence string|null the upstream's own qualifier, currently the literal
 *                                'INDICATIVE' — relayed rather than interpreted.
 *   confidenceTier    string|null 'HIGH' | 'UNCERTAIN' | 'LOW' | 'REJECTED'. The
 *                                upstream's judgement about how much weight its own
 *                                number deserves; drive messaging from this, not from
 *                                the raw percentage.
 *   saliencyScore     number|null 0–1 concentration of the attention map. Distinct from
 *                                saliencyLevel, which is the LOCAL pipeline's band.
 *
 *   // Hybrid-only state
 *   enrichmentPending  boolean   true while Local CNN pass is still running
 *   enrichmentFailed   boolean   true if Local CNN pass failed (verdict still valid)
 *
 *   // Diagnostic fields — Local CNN only (null otherwise)
 *   rawScore            number|null
 *   darkMouldConsensus  boolean
 *   patchContribution   number|null
 *   textureIrregularity number|null
 *   textureIrregularityLevel string|null
 *   interChannelDiff    number|null
 * }
 *
 * Depends on: FeatureFlags, MouldDetectService, LocalCNNService, ThumbnailService
 * ES5 — no const, let, arrow functions, template literals, optional chaining.
 */
var AIEngineAdapter = (function () {

    // ── Null-safe enrichment defaults ────────────────────────────────────────
    // Applied when an engine does not produce enrichment fields.
    // Ensures EngineResult shape is always complete.

    var EMPTY_ENRICHMENT = {
        hasEnrichment:          false,
        freqSpectrum:           null,
        saliencyLevel:          null,
        saliencyPeakToMean:     null,
        isShortcut:             false,
        heatmapDataURL:         null,
        speciesTop:             null,
        speciesRanked:          [],
        speciesIsUncertain:     false,
        speciesSummary:         '',
        genus:                  null,
        // Cloud Assembly fields — null for every other engine, so the EngineResult
        // shape stays complete and no consumer has to test for their existence.
        speciesSuggestion:      null,
        speciesConfidence:      null,
        confidenceTier:         null,
        saliencyScore:          null,
        enrichmentPending:      false,
        enrichmentFailed:       false,
        rawScore:               null,
        darkMouldConsensus:     false,
        patchContribution:      null,
        textureIrregularity:    null,
        textureIrregularityLevel: null,
        interChannelDiff:       null
    };

    // ── Merge helper ─────────────────────────────────────────────────────────

    function _merge(base, overrides) {
        var result = {};
        var k;
        for (k in base)      { result[k] = base[k]; }
        for (k in overrides) { result[k] = overrides[k]; }
        return result;
    }

    // ── Strategy: LocalCNNStrategy ────────────────────────────────────────────
    // Full on-device pipeline. Returns complete EngineResult including all
    // enrichment fields in a single Promise.

    var LocalCNNStrategy = {
        engineId: 'local_cnn',

        analyze: function (imageData, onStage) {
            var imgEl = imageData.imgElement;
            var file  = imageData.file;

            // MR-05 FIX: guard both paths — without imgElement the heatmap canvas
            // fails; without file the tensor path falls back to imgElement which
            // may be null. Provide a user-friendly message rather than a TF.js crash.
            if (!imgEl && !file) {
                return Promise.reject(new Error('Image not ready. Please go back and select your photo again.'));
            }
            if (LocalCNNService.getLoadState() !== 'ready') {
                return Promise.reject(new Error('The AI engine is still loading. Please wait a moment and try again.'));
            }

            return LocalCNNService.analyze(imgEl, onStage, file)
                .then(function (res) {
                    return _merge(EMPTY_ENRICHMENT, {
                        // Core
                        percentage:             res.percentage,
                        verdict:                res.verdict,
                        severity:               res.severity,
                        label:                  res.label,
                        insight:                res.insight,
                        aiEngine:               'local_cnn',
                        // Enrichment
                        hasEnrichment:          true,
                        freqSpectrum:           res.freqSpectrum          || null,
                        saliencyLevel:          res.saliencyLevel         || null,
                        saliencyPeakToMean:     res.saliencyPeakToMean    || null,
                        isShortcut:             res.isShortcut            || false,
                        heatmapDataURL:         res.heatmapDataURL        || null,
                        speciesTop:             res.speciesTop            || null,
                        speciesRanked:          res.speciesRanked         || [],
                        speciesIsUncertain:     res.speciesIsUncertain    || false,
                        speciesSummary:         res.speciesSummary        || '',
                        enrichmentPending:      false,
                        enrichmentFailed:       false,
                        // Diagnostics
                        rawScore:               res.rawScore              || null,
                        darkMouldConsensus:     res.darkMouldConsensus    || false,
                        patchContribution:      res.patchContribution     || null,
                        textureIrregularity:    res.textureIrregularity   || null,
                        textureIrregularityLevel: res.textureIrregularityLevel || null,
                        interChannelDiff:       res.interChannelDiff      || null
                    });
                });
        }
    };

    // ── Strategy: NyckelStrategy ──────────────────────────────────────────────
    // Cloud detection only. No enrichment fields.

    var NyckelStrategy = {
        engineId: 'nyckel',

        analyze: function (imageData, onStage) {
            var notify = onStage || function () {};
            notify('Analysing image…');

            return MouldDetectService.analyze(imageData.dataURL)
                .then(function (res) {
                    return _merge(EMPTY_ENRICHMENT, {
                        percentage: res.percentage,
                        verdict:    res.verdict,
                        severity:   res.severity,
                        label:      res.label,
                        insight:    res.insight,
                        aiEngine:   'nyckel'
                    });
                });
        }
    };

    // ── Shared: cloud verdict + Local CNN enrichment ─────────────────────────
    // ONE merge path for every engine whose shape is "a cloud service supplies
    // the primary verdict, the on-device pipeline supplies the explainability
    // panels". Only the cloud client differs — Nyckel for `hybrid`, Mould
    // Detect's own AWS Vision API for `aws_vision`.
    //
    // This mirrors the canonical Swift implementation exactly: there,
    // `NyckelVerdict` and `AWSVisionVerdict` both conform to the `CloudVerdict`
    // protocol and `AnalysisViewModel` runs a single merge, differing only in
    // which client is dispatched (AWSVisionEngine.swift, MouldFinding.merging).
    //
    // The returned result resolves immediately with the cloud verdict and
    // enrichmentPending=true. The caller (AnalysisPage) also receives the
    // enrichment Promise via result._enrichmentPromise and calls updateScan()
    // when it resolves. This two-phase pattern gives the user immediate feedback
    // while enrichment panels load progressively.
    //
    // @param spec {{ aiEngine: string, logTag: string,
    //                fetchVerdict: function(imageData): Promise<verdict> }}
    //        fetchVerdict must resolve to { percentage, verdict, severity, label, insight }.

    function _cloudVerdictWithEnrichment(spec, imageData, onStage) {
        var notify = onStage || function () {};
        notify('Analysing image…');

        // HR-04 FIX: cancellation flag shared between the cloud call and
        // enrichment. If the cloud verdict rejects, cancelled=true stops the
        // enrichment result from patching state after the error phase has
        // already been shown.
        var cancelled = false;

        // Phase 1: cloud verdict — resolves quickly
        var cloudPromise;
        try {
            cloudPromise = spec.fetchVerdict(imageData, notify);
        } catch (err) {
            // A missing/unloaded client must surface as a rejected promise,
            // never as a synchronous throw out of analyze().
            cloudPromise = Promise.reject(err);
        }

        // Phase 2: Local CNN enrichment — starts in parallel, resolves later.
        // LocalCNNEnrichmentService is loaded only for the enrichment-capable
        // engines. If it is not available (not yet loaded / load failed), we
        // resolve with an empty enrichment so the cloud result is still usable.
        var enrichmentPromise;
        if (typeof window.LocalCNNEnrichmentService !== 'undefined' &&
            window.LocalCNNEnrichmentService.isReady()) {
            enrichmentPromise = window.LocalCNNEnrichmentService.enrich(
                imageData.imgElement,
                imageData.file,
                function (msg) { notify('Enriching — ' + msg); }
            )
            .then(function (result) {
                if (cancelled) return null; // HR-04: discard if the cloud call already failed
                return result;
            })
            ['catch'](function (err) {
                console.warn(spec.logTag + ' Enrichment failed, proceeding with the cloud verdict only:', err.message);
                return null;
            });
        } else {
            console.warn(spec.logTag + ' LocalCNNEnrichmentService not ready. Enrichment skipped.');
            enrichmentPromise = Promise.resolve(null);
        }

        return cloudPromise
            ['catch'](function (err) {
                cancelled = true; // HR-04: stop enrichment from patching state
                throw err;
            })
            .then(function (cloudRes) {
            // Immediate result with enrichmentPending=true
            var immediateResult = _merge(EMPTY_ENRICHMENT, {
                percentage:        cloudRes.percentage,
                verdict:           cloudRes.verdict,
                severity:          cloudRes.severity,
                label:             cloudRes.label,
                insight:           cloudRes.insight,
                aiEngine:          spec.aiEngine,
                enrichmentPending: true,
                enrichmentFailed:  false
            });

            // Attach the enrichment promise so AnalysisPage can chain it
            // without needing to know about LocalCNNEnrichmentService.
            // This is not part of the EngineResult contract — it is a
            // transport mechanism between the strategy and AnalysisPage.
            immediateResult._enrichmentPromise = enrichmentPromise.then(function (enrichment) {
                if (!enrichment) {
                    // Enrichment failed or service unavailable
                    return _merge(immediateResult, {
                        enrichmentPending: false,
                        enrichmentFailed:  true,
                        _enrichmentPromise: undefined
                    });
                }
                return _merge(immediateResult, {
                    hasEnrichment:          true,
                    freqSpectrum:           enrichment.freqSpectrum          || null,
                    saliencyLevel:          enrichment.saliencyLevel         || null,
                    saliencyPeakToMean:     enrichment.saliencyPeakToMean    || null,
                    isShortcut:             enrichment.isShortcut            || false,
                    heatmapDataURL:         enrichment.heatmapDataURL        || null,
                    speciesTop:             enrichment.speciesTop            || null,
                    speciesRanked:          enrichment.speciesRanked         || [],
                    speciesIsUncertain:     enrichment.speciesIsUncertain    || false,
                    speciesSummary:         enrichment.speciesSummary        || '',
                    enrichmentPending:      false,
                    enrichmentFailed:       false,
                    _enrichmentPromise:     undefined
                });
            });

            return immediateResult;
        });
    }

    // ── Strategy: HybridStrategy ──────────────────────────────────────────────
    // Nyckel provides the primary verdict; Local CNN enrichment runs in parallel.
    // Behaviour is unchanged — the body now lives in _cloudVerdictWithEnrichment,
    // shared with AWSVisionStrategy.

    var HybridStrategy = {
        engineId: 'hybrid',

        analyze: function (imageData, onStage) {
            return _cloudVerdictWithEnrichment({
                aiEngine: 'hybrid',
                logTag:   '[HybridStrategy]',
                fetchVerdict: function (data) {
                    return MouldDetectService.analyze(data.dataURL);
                }
            }, imageData, onStage);
        }
    };

    // ── Strategy: AWSVisionStrategy ───────────────────────────────────────────
    // Mould Detect's OWN cloud vision API supplies the primary verdict; Local
    // CNN enrichment supplies the heatmap/frequency/species panels. Identical
    // composition to Hybrid — only the verdict source differs — which is exactly
    // how the canonical Swift app models it (one merge path, two clients).
    //
    // The API key is NOT in this app: AWSVisionService posts to our own
    // same-origin proxy, which holds the credential server-side. See the header
    // of AWSVisionService.jsx for why (the Nyckel client secret leaked exactly
    // once this way already).
    //
    // heatmap stays OFF here: the on-device gradient-saliency pipeline is the
    // heat-map source in this mode, so the panels stay consistent with the
    // `local` engine — and the server map costs ~145x the compute.

    var AWSVisionStrategy = {
        engineId: 'aws_vision',

        analyze: function (imageData, onStage) {
            return _cloudVerdictWithEnrichment({
                aiEngine: 'aws_vision',
                logTag:   '[AWSVisionStrategy]',
                fetchVerdict: function (data, notify) {
                    if (typeof window.AWSVisionService === 'undefined') {
                        return Promise.reject(new Error('The AI vision service is unavailable. Please reload the app.'));
                    }
                    return window.AWSVisionService.analyze(data.dataURL, {
                        heatmap: false,
                        onStage: notify
                    });
                }
            }, imageData, onStage);
        }
    };

    // ── Strategy: AWSLambdaOnlyStrategy ───────────────────────────────────────
    // Cloud verdict ALONE. Different in kind from aws_vision, not in degree:
    // the on-device models are never loaded, so there is no local enrichment,
    // no local saliency, and no offline fallback — a cloud failure is a genuine
    // error state (Swift: AnalysisViewModel.runCloudOnlyAnalysis).
    //
    // This is the one caller that opts into the server heat map, because it has
    // no local heat map to fall back to. The server returns a RAW GRAYSCALE
    // saliency map, so it is colourised and composited through the same
    // colourmap the local pipeline uses (GradCAM.colourmap, via
    // AWSVisionService.composeHeatmapOverlay) — LocalHeatmapPanel's jet legend
    // must mean the same thing regardless of engine. A flat/absent/undecodable
    // map yields null, which simply hides the panel; it never fails the scan.
    //
    // It also opts into the server-side genus classifier (`genus: true`, mould
    // genus classifier plan §5) for the same reason: with no local species head
    // in this mode, the server block is the only genus source. Like the heat
    // map, a missing genus block never fails the scan — the server omits it on
    // negative verdicts and on any genus failure, and until the classifier
    // ships it is simply always absent.
    //
    // hasEnrichment is true ONLY when an overlay actually rendered: it stays
    // strictly HEATMAP-driven. There is no frequency spectrum in this mode —
    // that field stays null rather than being filled with a placeholder that
    // would read as real local analysis — while the species display fields are
    // populated from the server genus block when one arrives (see
    // _genusDisplayFields), and only then.

    var AWSLambdaOnlyStrategy = {
        engineId: 'aws_lambda_only',

        analyze: function (imageData, onStage) {
            var notify = onStage || function () {};

            if (typeof window.AWSVisionService === 'undefined') {
                return Promise.reject(new Error('The AI vision service is unavailable. Please reload the app.'));
            }

            notify('Analysing image…');

            return window.AWSVisionService.analyze(imageData.dataURL, {
                heatmap: true,
                genus: true,
                onStage: notify
            })
            .then(function (res) {
                // heatmap_significant === false means the server itself found no
                // meaningful attribution — do not render a map that says nothing.
                var skipHeatmap = (res.heatmapSignificant === false) || !res.serverHeatmapBase64;
                if (skipHeatmap) {
                    return _awsCloudOnlyResult(res, null);
                }
                notify('Rendering AI attention map…');
                return window.AWSVisionService.composeHeatmapOverlay(
                    res.serverHeatmapBase64,
                    imageData.previewURL || imageData.dataURL
                )
                ['catch'](function () { return null; })
                .then(function (overlayDataURL) {
                    return _awsCloudOnlyResult(res, overlayDataURL);
                });
            });
        }
    };

    // ── Strategy: CloudAssemblyStrategy ───────────────────────────────────────
    // The Cloud Assembly inference API as the primary verdict source. Same SHAPE
    // as AWSLambdaOnlyStrategy — cloud verdict alone, no on-device models, the
    // server's own attention map as the only heat map, genus captured
    // unconditionally — modelled on it deliberately so the two remain swappable.
    //
    // What it adds is the species narrative: an INDICATIVE, model-authored
    // markdown assessment plus the upstream's own confidence tier and saliency
    // score. Those ride on the result and are persisted (AnalysisPage.caSnapshot)
    // whether or not any surface displays them today — capture is unconditional,
    // display is gated, exactly as the weather/air/genus snapshots are.
    //
    // The three-call upstream flow (presign → S3 upload → detect) is composed
    // SERVER-side behind one endpoint, so nothing about it appears here; see
    // CloudAssemblyVisionService.jsx and the backend's app/vision/cloud_assembly.py.
    //
    // Two failure states are honest errors rather than verdicts, and both arrive
    // as rejections from the service: `uncertain_verdict` (the model could not
    // judge the photo) and `rejected_verdict` (input quality, carrying the
    // server's own reason). AnalysisPage renders the message; neither is ever
    // coerced into a percentage.

    var CloudAssemblyStrategy = {
        engineId: 'cloud_assembly',

        analyze: function (imageData, onStage) {
            var notify = onStage || function () {};

            if (typeof window.CloudAssemblyVisionService === 'undefined') {
                return Promise.reject(new Error('The AI vision service is unavailable. Please reload the app.'));
            }

            notify('Analysing image…');

            return window.CloudAssemblyVisionService.analyze(imageData.dataURL, {
                onStage: notify
            })
            .then(function (res) {
                if (!res.serverHeatmapBase64) {
                    return _cloudAssemblyResult(res, null);
                }
                notify('Rendering AI attention map…');
                // Composited through the SAME colourmap the local pipeline uses,
                // so LocalHeatmapPanel's jet legend means the same thing whichever
                // engine produced the map (AWSVisionService owns that compositor;
                // it is shared, not duplicated). A flat/undecodable map yields
                // null, which hides the panel and never fails the scan.
                return window.AWSVisionService.composeHeatmapOverlay(
                    res.serverHeatmapBase64,
                    imageData.previewURL || imageData.dataURL
                )
                ['catch'](function () { return null; })
                .then(function (overlayDataURL) {
                    return _cloudAssemblyResult(res, overlayDataURL);
                });
            });
        }
    };

    // Shared shape for the Cloud Assembly result. Every field that would describe
    // the LOCAL pipeline stays at its EMPTY_ENRICHMENT default — no local pipeline
    // ran, and a placeholder there would read as real on-device analysis. The
    // species DISPLAY fields are fed by the server genus block through the same
    // _genusDisplayFields path aws_lambda_only uses (identical block shape), while
    // the narrative fields travel separately: they are prose, not a candidate list.
    function _cloudAssemblyResult(res, overlayDataURL) {
        var rawGenus = (res.genus && typeof res.genus === 'object') ? res.genus : null;
        var result = _merge(EMPTY_ENRICHMENT, {
            percentage:        res.percentage,
            verdict:           res.verdict,
            severity:          res.severity,
            label:             res.label,
            insight:           res.insight,
            aiEngine:          'cloud_assembly',
            hasEnrichment:     !!overlayDataURL,
            heatmapDataURL:    overlayDataURL || null,
            // Capture is unconditional (data-moat rule): the raw block always
            // travels, mapped for display or not.
            genus:             rawGenus,
            // The weights that produced this verdict, persisted on the scan record
            // (caSnapshot) so a result can be correlated with a model version long
            // after the model has moved on.
            modelVersion:      res.modelVersion      || null,
            speciesSuggestion: res.speciesSuggestion || null,
            speciesConfidence: res.speciesConfidence || null,
            confidenceTier:    res.confidenceTier    || null,
            saliencyScore:     (typeof res.saliencyScore === 'number') ? res.saliencyScore : null,
            enrichmentPending: false,
            enrichmentFailed:  false
        });
        var speciesFields = _genusDisplayFields(rawGenus);
        if (speciesFields) {
            result = _merge(result, speciesFields);
        }
        return result;
    }

    // _genusDisplayFields — maps a usable server genus block onto the existing
    // species display fields (speciesTop/speciesRanked/speciesSummary/
    // speciesIsUncertain) via GenusCatalog, so LocalSpeciesPanel renders
    // identically whichever pipeline produced the data. Field names stay
    // "species" even though the classes are genus-level — they are already
    // written into IndexedDB scan records, and renaming them orphans every
    // existing scan (plan §6).
    //
    // Returns null — leaving the EMPTY_ENRICHMENT defaults in place — when the
    // block is absent, abstained (the genus model disagreed with the positive
    // binary verdict and stood down; plan §5), or carries no candidates: the
    // display fields are only ever populated with genuine data, never
    // placeholders. Also returns null when GenusCatalog has not loaded (it is
    // being built in parallel with this code) — the raw block still travels on
    // the result either way, so only the display convenience is lost, never
    // the captured data.
    function _genusDisplayFields(genus) {
        if (!genus || genus.abstained === true) return null;
        var candidates = genus.candidates;
        if (!candidates || !candidates.length) return null;
        if (typeof window.GenusCatalog === 'undefined') {
            console.warn('[AWSLambdaOnlyStrategy] GenusCatalog unavailable — genus captured but not mapped for display.');
            return null;
        }
        var ranked = [];
        // The contract caps candidates at 3; cap here too so a misbehaving
        // response cannot flood the panel. Server confidences arrive 0..1;
        // the species fields (and GenusCatalog.summary) speak display
        // percentages 0–100, exactly as the local head always has.
        for (var i = 0; i < candidates.length && i < 3; i++) {
            var c = candidates[i] || {};
            var pct = (typeof c.confidence === 'number' && isFinite(c.confidence))
                ? Math.round(Math.max(0, Math.min(1, c.confidence)) * 100)
                : 0;
            // Unknown labels get a neutral swatch rather than being dropped —
            // the model's vocabulary may grow ahead of the catalog.
            var entry = window.GenusCatalog.lookup(c.label) || { color: '#57534e', description: '' };
            ranked.push({
                label:       c.label,
                confidence:  pct,
                description: entry.description || '',
                color:       entry.color || '#57534e'
            });
        }
        return {
            speciesTop:         ranked[0],
            speciesRanked:      ranked,
            speciesSummary:     window.GenusCatalog.summary(ranked),
            // Below 35% the top candidate is a hint, not a lead — matches the
            // uncertainty framing the local head's display used.
            speciesIsUncertain: ranked[0].confidence < 35
        };
    }

    // Shared shape for the cloud-only result — every field that would normally
    // describe the LOCAL pipeline stays at its EMPTY_ENRICHMENT default, since
    // no local pipeline ran and there is no honest value to report. The species
    // display fields are the one exception: fed by the SERVER's genus
    // classifier when it answered (see _genusDisplayFields above).
    function _awsCloudOnlyResult(res, overlayDataURL) {
        var rawGenus = (res.genus && typeof res.genus === 'object') ? res.genus : null;
        var result = _merge(EMPTY_ENRICHMENT, {
            percentage:        res.percentage,
            verdict:           res.verdict,
            severity:          res.severity,
            label:             res.label,
            insight:           res.insight,
            aiEngine:          'aws_lambda_only',
            // Heat-map-driven, deliberately untouched by genus: repurposing it
            // would hide the genus panel whenever the server map came back flat.
            hasEnrichment:     !!overlayDataURL,
            heatmapDataURL:    overlayDataURL || null,
            // The raw block ALWAYS travels, mapped for display or not — capture
            // is unconditional (data-moat rule); display data is the bonus.
            genus:             rawGenus,
            enrichmentPending: false,
            enrichmentFailed:  false
        });
        var speciesFields = _genusDisplayFields(rawGenus);
        if (speciesFields) {
            result = _merge(result, speciesFields);
        }
        return result;
    }

    // ── Strategy: NoneStrategy ────────────────────────────────────────────────
    // All engines disabled. Returns a rejected Promise so AnalysisPage
    // transitions to the error phase.

    var NoneStrategy = {
        engineId: 'none',

        analyze: function () {
            return Promise.reject(new Error('No AI engine is currently enabled. Set AI_ENGINE to a valid value in feature-flags.json.'));
        }
    };

    // ── Strategy registry ────────────────────────────────────────────────────
    // Maps AI_ENGINE flag values to strategy objects.
    // Add new external AI providers here — one entry, no other changes needed.

    var STRATEGY_REGISTRY = {
        'local':            LocalCNNStrategy,
        'hybrid':           HybridStrategy,
        'nyckel':           NyckelStrategy,
        'aws_vision':       AWSVisionStrategy,
        'aws_lambda_only':  AWSLambdaOnlyStrategy,
        'cloud_assembly':   CloudAssemblyStrategy,
        'none':             NoneStrategy
    };

    // ── Public API ────────────────────────────────────────────────────────────

    /**
     * resolve() — Returns the active engine strategy based on AI_ENGINE flag.
     *
     * Reads AI_ENGINE at call time (not cached) so flag changes take effect
     * without a page reload during development.
     *
     * @returns {EngineStrategy} — one of the strategy objects above
     */
    function resolve() {
        var engineValue = FeatureFlags.getValue('AI_ENGINE') || 'nyckel';
        var strategy = STRATEGY_REGISTRY[engineValue];
        if (!strategy) {
            console.warn('[AIEngineAdapter] Unknown AI_ENGINE value: "' + engineValue + '". Falling back to Nyckel.');
            return NyckelStrategy;
        }
        return strategy;
    }

    return {
        resolve: resolve
    };
})();

window.AIEngineAdapter = AIEngineAdapter;
