/**
 * asReportModel — the Assessment report, DERIVED at render and never stored
 * (ADR-009 D4, ASSESSMENT-REPORT-SPEC.md R2).
 *
 * Spec: architecture/ASSESSMENT-REPORT-SPEC.md §3 (this file, rule for rule),
 * §5 (the branding resolver at the bottom). Source of the derivation rules:
 * the Mould Assessment app's `assets/report.js` — max roll-up, RISK/GAUGE maps,
 * the status words, the stat cards and the action-plan rule table are that
 * file's logic verbatim (R1), with one documented Mould Detect extension for
 * lab results (§3.6.5) and one for the lab stat card (§3.5).
 *
 * TWO functions, both PURE and both free of React, because the Swift port runs
 * them against the same fixture (the assessment-report fixture under the shared-tests folder,
 * §6):
 *
 *   asReportModel(assessment, property, scansById, entitlements, branding)
 *   asResolveBranding(entitlements, brandingRecord)
 *
 * Determinism is a contract here, not a nicety — the fixture is a deep-equal
 * assertion in two languages:
 *   • No `new Date()` anywhere. "Generated {date}" is a fact about the moment
 *     of printing, not about the inspection, so the PAGE stamps it into the
 *     footer; the model never carries it.
 *   • Every date is formatted by hand (`arDate`) against a fixed month table.
 *     `toLocaleDateString` would make the fixture depend on the machine's
 *     locale, and the Swift port could never match it.
 *   • Output is JSON primitives only — string/number/boolean/null, never
 *     undefined — the same contract every stored record here honours.
 *
 * File-local helpers use the `ar` prefix (assessment report). The app has no
 * module system, so every top-level var is a window global (CLAUDE.md hard
 * rule); only the two spec'd functions are exported under their `as` names.
 *
 * Written in ES5 for Babel 6: no const/let, no arrow functions, no template
 * literals.
 */

/* ── §3.1 severity scale ──────────────────────────────────────────────────
   0 None · 1 Low · 2 Moderate · 3 Severe, each paired with a band colour
   (CLAUDE.md: never colour alone — every consumer of `band` also renders
   `word`). The hexes are the app's band tokens, restated here so the model is
   self-contained for the Swift port. */
var AR_SEV_WORDS = ['None', 'Low', 'Moderate', 'Severe'];
var AR_SEV_BANDS = ['good', 'fair', 'poor', 'very_poor'];
var AR_BAND_HEX  = { good: '#0fbd80', fair: '#d4a373', poor: '#D4836B', very_poor: '#c1440e' };

/* ── §3.3 risk profile ────────────────────────────────────────────────────
   RISK and GAUGE_PCT are `report.js`'s own maps, key for key. */
var AR_RISK = [
    { profile: 'No Issues Found',      badge: 'No Issues' },
    { profile: 'Low Risk Profile',     badge: 'Low Risk' },
    { profile: 'Moderate Risk Profile', badge: 'Moderate' },
    { profile: 'High Risk Profile',    badge: 'Severe Risk' }
];
var AR_GAUGE_PCT = [6, 40, 70, 100];

/* ── §3 field mapping ─────────────────────────────────────────────────────
   Our aggregate's axis names → the source's. A Riteflo inspector and a Mould
   Detect Business user must read the same score under the same word, so the
   REPORT speaks the source's language even though the record speaks ours. */
var AR_AXES = [
    { field: 'damage',  as: 'damageOrStains', label: 'Damage or stains', short: 'Damage' },
    { field: 'mould',   as: 'visibleMould',   label: 'Visible mould',    short: 'Mould' },
    { field: 'wetDamp', as: 'wetOrDamp',      label: 'Wet or damp',      short: 'Damp' }
];

/* Ventilation: our four-value enum → the source's three answers (§3). An
   unanswered ventilation is null, NOT "No" — "we did not assess it" and "it is
   inadequate" are different findings and only one of them raises an action. */
var AR_VENTILATION_ANSWER = { Good: 'Yes', Fair: 'Uncertain', Poor: 'No', None: 'No' };

/* §3.7 ventilation metric: word + the source's severity index, verbatim.
   `band` is OURS: the source's sev index is an ordering (1 = least concern),
   not a band, so colouring "Good" with sev 1 would paint adequate ventilation
   amber. The number stays verbatim for the port; the colour follows the
   finding. */
var AR_VENT_METRIC = {
    Yes:       { word: 'Good',      sev: 1, band: 'good' },
    Uncertain: { word: 'Uncertain', sev: 2, band: 'fair' },
    No:        { word: 'Poor',      sev: 3, band: 'poor' }
};

/* §4.6 — the seven manual DMAT device readings, labels and units mirroring
   ASV_READING_FIELDS on the visit screen (which is where they are typed). */
var AR_READING_FIELDS = [
    { key: 'temp',         label: 'Temperature',  unit: '°C' },
    { key: 'humidity',     label: 'Humidity',     unit: '% RH' },
    { key: 'co2',          label: 'CO₂ level',    unit: 'ppm' },
    { key: 'lightMin',     label: 'Light — min',  unit: 'lux' },
    { key: 'lightMax',     label: 'Light — max',  unit: 'lux' },
    { key: 'formaldehyde', label: 'Formaldehyde', unit: 'μg/m³' },
    { key: 'vocs',         label: 'VOCs (total)', unit: 'ppb' }
];

/* §3.9 — what an amendment stamp is called in the trail. `sample` gets the
   longer phrase because a lab result is the amendment an inspector is actually
   looking for ("Amended 24 Sep 2026 (lab result · sample MB-14)"). */
var AR_REVISION_WORDS = {
    visit: 'visit details', room: 'room', component: 'component',
    sample: 'lab result', readings: 'readings',
    remediation: 'remediation plan', signoff: 'sign-off'
};

var AR_MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];

/* Spelled numbers for the summary sentence (§4.2). Past ten the digits read
   better than the words, which is also where a report stops being a sentence
   about a house and starts being a table. */
var AR_NUMBER_WORDS = ['zero', 'one', 'two', 'three', 'four', 'five', 'six',
                       'seven', 'eight', 'nine', 'ten'];

var AR_ACTION_CAP = 8;

/* §4.8 — the decision-support disclaimer, ADR-0007's wording verbatim. Carried
   in the model so the Swift renderer cannot paraphrase it. */
var AR_DISCLAIMER = 'This report supports decision-making. It is not a medical or engineering opinion; engage a qualified professional for remediation.';

/* §4.6 caption — says plainly which readings these are, because the app holds
   two kinds and merging them would corrupt both the report and the moat. */
var AR_READINGS_CAPTION = "Inspector's device readings at the visit. Outdoor conditions captured with each photo are kept separately.";

/* §5 — the Mould Detect default brand block. The wordmark is the same asset
   the splash uses. */
var AR_DEFAULT_LOGO = 'logo-mould-detect-cropped.png';
var AR_POWERED_BY = 'Powered by Mould Detect';

/* ── small, boring helpers ─────────────────────────────────────────────── */

function arStr(value) {
    if (typeof value !== 'string') return '';
    return value.replace(/^\s+|\s+$/g, '');
}

function arScore(value) {
    if (typeof value !== 'number' || !isFinite(value)) return 0;
    var n = Math.round(value);
    if (n < 0) return 0;
    if (n > 3) return 3;
    return n;
}

function arArray(value) {
    return (Object.prototype.toString.call(value) === '[object Array]') ? value : [];
}

function arBandFor(sev) { return AR_SEV_BANDS[arScore(sev)]; }
function arWordFor(sev) { return AR_SEV_WORDS[arScore(sev)]; }

/** arSeverity — the {sev, word, band, hex} quad every score in the model carries. */
function arSeverity(sev) {
    var s = arScore(sev);
    return { sev: s, word: AR_SEV_WORDS[s], band: AR_SEV_BANDS[s], hex: AR_BAND_HEX[AR_SEV_BANDS[s]] };
}

/**
 * arDate — an ISO date (or a bare YYYY-MM-DD) as "18 Sep 2026".
 *
 * Parsed by hand rather than through `new Date(...)`: a bare "2026-09-18" is
 * parsed as UTC midnight by the Date constructor, which in Sydney renders as
 * the 18th but in Los Angeles as the 17th. An inspection date must not move
 * because of where the reader is standing.
 */
function arDate(raw) {
    var s = arStr(raw);
    if (!s) return '';
    var m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s);
    if (!m) return s;
    var month = parseInt(m[2], 10);
    if (month < 1 || month > 12) return s;
    return String(parseInt(m[3], 10)) + ' ' + AR_MONTHS[month - 1] + ' ' + m[1];
}

/** arDay — the YYYY-MM-DD an ISO stamp belongs to (§3.9 groups by day). */
function arDay(raw) {
    var s = arStr(raw);
    var m = /^(\d{4}-\d{2}-\d{2})/.exec(s);
    return m ? m[1] : '';
}

/** arNumberWord — "two" up to ten, "14" after that. */
function arNumberWord(n) {
    if (typeof n !== 'number' || !isFinite(n) || n < 0) return String(n);
    var i = Math.round(n);
    return (i <= 10) ? AR_NUMBER_WORDS[i] : String(i);
}

function arCapitalise(text) {
    if (!text) return '';
    return text.charAt(0).toUpperCase() + text.slice(1);
}

/**
 * arComponentLabel — the DMAT label for a component key, read from
 * AppConstants at CALL time (never at file load) so this file has no
 * load-order requirement against it. AppConstants.ASSESSMENT_COMPONENTS is the
 * DMAT source of truth the retired enterprise form used and the room screen
 * still uses — reused, never restated.
 *
 * Falls back to the key itself rather than "surface": a report that cannot name
 * the component should say the key, not invent a noun.
 */
function arComponentLabel(key) {
    var list = (typeof AppConstants !== 'undefined' && AppConstants && AppConstants.ASSESSMENT_COMPONENTS) || [];
    for (var i = 0; i < list.length; i++) {
        if (list[i].key === key) return list[i].label;
    }
    return arStr(key);
}

/**
 * arCompMax — §3.2, the source's `compMax`: the worst of the three axes on one
 * component. Presence is the CALLER's filter (§3: only `present === true`
 * components count), so this stays a pure max.
 */
function arCompMax(comp) {
    return Math.max(arScore(comp.damage), Math.max(arScore(comp.mould), arScore(comp.wetDamp)));
}

/**
 * arIsAssessed — the store's ONE definition of "assessed" (`asComponentScored`:
 * present AND editedAt or positive evidence, ADR-009 D9). Fallback when the
 * store is not loaded: presence, the pre-D9 rule.
 */
function arIsAssessed(comp) {
    if (typeof asComponentScored === 'function') return asComponentScored(comp);
    return !!comp && comp.present === true;
}

/** arHasPhotos — at least one photo (the union of photoIds/scanIds). */
function arHasPhotos(comp) {
    // Uploaded photos count too, scanned or not (2026-10-05): evidence.
    if (typeof asComponentPhotoItems === 'function') return asComponentPhotoItems(comp).length > 0;
    if (typeof asComponentPhotoIds === 'function') return asComponentPhotoIds(comp).length > 0;
    return arArray(comp && comp.photoIds).length + arArray(comp && comp.scanIds).length > 0;
}

/**
 * arPresentComponents — the components a room's report lists (ADR-009 D9,
 * amended 2026-09-28): present AND (assessed OR carrying a photo).
 *
 * Present alone used to be enough, which let an untouched row print as a clean
 * "0 / 0 / 0" finding. A report must never say "nothing found" about a
 * component nobody looked at — but a PHOTO is evidence and must never vanish
 * from the report either. So an unassessed component with photos stays in and
 * is rendered as "Not assessed" (the model carries `assessed: false`, null
 * scores, and it contributes nothing to any metric or action); an unassessed
 * component with no photo is left out. The CSV reads this model, so it follows.
 */
function arPresentComponents(room) {
    var out = [];
    var comps = arArray(room && room.components);
    for (var i = 0; i < comps.length; i++) {
        if (!comps[i] || comps[i].present !== true) continue;
        if (!arIsAssessed(comps[i]) && !arHasPhotos(comps[i])) continue;
        out.push(comps[i]);
    }
    return out;
}

/**
 * arVerdictChip — §3.8, what a component photo's scan concluded, as a word AND
 * a band colour.
 *
 * Thresholds are §3.8's (Mould ≥ 60 · Uncertain 40–59 · Clear < 40 · No
 * result), which are the vision service's own. The ≥ 80 split only changes the
 * band, never the word: "Mould" at 95 % and "Mould" at 62 % are the same
 * finding with different confidence, and a stored scan carries only its number.
 *
 * A scan with no percentage reads "No result" rather than inventing one — the
 * 40–59 band is the model saying it could not judge the photo, and collapsing
 * either case into "Clear" is the one thing an inspector must not be told.
 */
function arVerdictChip(scan) {
    var pct = (scan && typeof scan.percentage === 'number' && isFinite(scan.percentage)) ? scan.percentage : null;
    if (pct === null) return { key: 'unknown', label: 'No result', band: 'fair', hex: AR_BAND_HEX.fair };
    if (pct >= 80) return { key: 'positive', label: 'Mould', band: 'very_poor', hex: AR_BAND_HEX.very_poor };
    if (pct >= 60) return { key: 'positive', label: 'Mould', band: 'poor', hex: AR_BAND_HEX.poor };
    if (pct >= 40) return { key: 'uncertain', label: 'Uncertain', band: 'fair', hex: AR_BAND_HEX.fair };
    return { key: 'negative', label: 'Clear', band: 'good', hex: AR_BAND_HEX.good };
}

/**
 * arPhotos — §3.8: a component's scan ids resolved through `scansById`.
 *
 * Ids come from `asComponentPhotoIds` — the UNION of `photoIds` and `scanIds`,
 * never the sum, because `attachScan` writes the same id into both (ADR-009
 * D3a). Getting this wrong shows one photo as two.
 *
 * A photo whose scan is no longer stored still gets a row (`missing: true`)
 * rather than being dropped: the inspector took it, and a silently shorter
 * gallery is a worse lie than an honest placeholder.
 */
function arPhotos(comp, scansById) {
    // One entry per picture (2026-10-05): uploaded photos, scanned or not,
    // plus scan-flow photos — asComponentPhotoItems owns the union rule.
    var items = (typeof asComponentPhotoItems === 'function')
        ? asComponentPhotoItems(comp)
        : arArray(comp && comp.scanIds).map(function (id) { return { photoId: null, scanId: id }; });
    var map = (scansById && typeof scansById === 'object') ? scansById : {};
    var out = [];
    for (var i = 0; i < items.length; i++) {
        var scanId = items[i].scanId || null;
        var photoId = items[i].photoId || null;
        if (!scanId) {
            // An uploaded photo nobody has scanned: shown as evidence, labelled
            // "Not scanned" — never a verdict, never a band colour. Its image
            // is on the device under its own key; the page resolves it.
            out.push({
                scanId: null, missing: false,
                thumbnail: '', fileName: '', percentage: null,
                verdict: { key: 'not-scanned', label: 'Not scanned', band: null, hex: null },
                photoId: photoId, scanned: false
            });
            continue;
        }
        var scan = map[scanId] || null;
        out.push({
            scanId: scanId,
            missing: !scan,
            thumbnail: (scan && typeof scan.thumbnail === 'string') ? scan.thumbnail : '',
            fileName: (scan && arStr(scan.fileName)) || '',
            percentage: (scan && typeof scan.percentage === 'number' && isFinite(scan.percentage)) ? scan.percentage : null,
            verdict: arVerdictChip(scan),
            // Appended (2026-10-05) so older consumers' key order is unchanged:
            // the uploaded photo this scan was made from (null for a scan-flow
            // photo), and whether the photo has been scanned at all.
            photoId: photoId,
            scanned: true
        });
    }
    return out;
}

/**
 * arTeam — the visit's additional assessors (slice 5), normalised for display.
 *
 * The lead is `visit.assessorName` and is NEVER a row here, so "Assessed by
 * {lead} with n others" can never double-count the person who signed it.
 */
function arTeam(visit) {
    var src = arArray(visit && visit.team);
    var out = [];
    for (var i = 0; i < src.length; i++) {
        var member = src[i];
        if (!member || typeof member !== 'object') continue;
        var name = arStr(member.name);
        if (!name) continue;
        out.push({
            name: name,
            certNo: arStr(member.certNo),
            role: arStr(member.role) || 'assessor',
            roleLabel: arCapitalise(arStr(member.role) || 'assessor')
        });
    }
    return out;
}

/**
 * arRoomAssessor — who assessed one room (slice 5), RESOLVED.
 *
 * `room.assessorName` is a reference: null means "the visit lead". Resolving it
 * here rather than storing a copy is what keeps a three-room visit from
 * disagreeing with its own header after the lead's name is corrected. Reuses
 * the store's `asRoomAssessor` when it is loaded (one rule, one place) and
 * falls back to the same expression when it is not, so this file keeps its "no
 * load-order requirement" property.
 */
function arRoomAssessor(assessment, room) {
    if (typeof asRoomAssessor === 'function') return arStr(asRoomAssessor(assessment, room));
    return arStr(room && room.assessorName) ||
           arStr(assessment && assessment.visit && assessment.visit.assessorName);
}

/* ── §3.6 the Remediation Action Plan ─────────────────────────────────────
   Rules 1–3 are `report.js`'s `derivedActions` verbatim, including the
   lower-cased component label and the full stop that rule 1 gets when the
   component is not at 3. Rule 4 is its ventilation clause. Rule 5 is the Mould
   Detect lab-result extension, which the scoring key labels as an extension
   (§3.6.5) so nobody mistakes it for the source's output. */

function arComponentAction(room, comp, compMax) {
    var where = arStr(room && room.name) || 'this area';
    var label = arComponentLabel(comp.key).toLowerCase();
    var mould = arScore(comp.mould);
    var damp = arScore(comp.wetDamp);
    var title, text;

    if (mould >= 2 && mould >= damp) {
        title = 'Professional mould remediation';
        text = 'Remediate ' + label + ' mould in ' + where +
            (compMax === 3 ? ' — extensive colonisation requires professional cleaning and containment.' : '.');
    } else if (damp >= 2) {
        title = 'Address moisture ingress';
        text = 'Investigate and rectify the moisture source affecting the ' + label + ' in ' + where +
            ' to stop recurring dampness.';
    } else {
        title = 'Repair surface damage';
        text = 'Repair the damage and staining recorded on the ' + label + ' in ' + where + '.';
    }

    return {
        sev: compMax, source: 'component', extension: false,
        room: where, roomId: arStr(room && room.id),
        componentKey: comp.key, componentLabel: arComponentLabel(comp.key),
        title: title, text: text
    };
}

function arVentilationAction(room) {
    var where = arStr(room && room.name) || 'this area';
    return {
        sev: 1, source: 'ventilation', extension: false,
        room: where, roomId: arStr(room && room.id),
        componentKey: null, componentLabel: null,
        title: 'Improve ventilation',
        text: 'Ventilation was assessed as inadequate in ' + where +
            ' — improve airflow to reduce baseline humidity.'
    };
}

/**
 * arLabAction — §3.6.5, the Mould Detect extension.
 *
 * Only a sample with a RESULT can raise one: a pending sample is a question,
 * not a finding, and briefing a remediator on a lab report that has not arrived
 * is not an action anybody can take. "Very high"/"High" → severity 3,
 * "Elevated" → 2; anything else (Normal, Indeterminate, a phrase the lab
 * invented) raises nothing, because the report may not decide what an
 * interpretation it does not recognise means.
 */
function arLabAction(sample, roomName, componentLabel) {
    var result = (sample && sample.result && typeof sample.result === 'object') ? sample.result : null;
    if (!result) return null;
    var interpretation = arStr(result.interpretation);
    var sev;
    if (interpretation === 'Very high' || interpretation === 'High') sev = 3;
    else if (interpretation === 'Elevated') sev = 2;
    else return null;

    var genus = arStr(result.genus) || 'The laboratory';
    var count = (typeof result.sporeCount === 'number' && isFinite(result.sporeCount)) ? String(result.sporeCount) : '';
    var unit = arStr(result.unit);
    var countPart = (count || unit) ? (' (' + arStr(count + ' ' + unit) + ')') : '';
    var where = roomName || 'this area';

    return {
        sev: sev, source: 'lab', extension: true,
        room: where, roomId: arStr(sample.roomId),
        componentKey: sample.componentKey || null,
        componentLabel: componentLabel || null,
        title: 'Act on laboratory result',
        text: genus + ' reported ' + interpretation + countPart + ' for ' + where +
            (componentLabel ? ' · ' + componentLabel : '') +
            ' — brief a remediator on the lab report.'
    };
}

/**
 * arActions — build, sort, cap.
 *
 * Order: every room in record order (its components, then its ventilation
 * clause — `report.js`'s own order), then the lab extensions. The sort is
 * severity descending and STABLE, so ties keep that order, which is what §3.6
 * means by "then room order". Array.prototype.sort is stable in every engine
 * this app runs on and in Swift's `sorted(by:)` only if the port keeps the
 * index — the fixture pins the result either way.
 */
function arActions(assessment, roomsByIdName) {
    var raw = [];
    var rooms = arArray(assessment && assessment.rooms);

    for (var r = 0; r < rooms.length; r++) {
        var room = rooms[r];
        var present = arPresentComponents(room);
        for (var c = 0; c < present.length; c++) {
            var compMax = arCompMax(present[c]);
            if (compMax < 2) continue;
            raw.push(arComponentAction(room, present[c], compMax));
        }
        if (AR_VENTILATION_ANSWER[arStr(room && room.ventilation)] === 'No') {
            raw.push(arVentilationAction(room));
        }
    }

    var samples = arArray(assessment && assessment.samples);
    for (var s = 0; s < samples.length; s++) {
        var sample = samples[s];
        if (!sample) continue;
        var roomName = roomsByIdName[arStr(sample.roomId)] || '';
        var componentLabel = sample.componentKey ? arComponentLabel(sample.componentKey) : null;
        var action = arLabAction(sample, roomName, componentLabel);
        if (action) raw.push(action);
    }

    // Decorate with the original index so the sort is stable regardless of the
    // engine's own guarantee — the Swift port needs the same tie-break.
    for (var i = 0; i < raw.length; i++) raw[i]._i = i;
    raw.sort(function (a, b) {
        if (b.sev !== a.sev) return b.sev - a.sev;
        return a._i - b._i;
    });

    var capped = raw.slice(0, AR_ACTION_CAP);
    var out = [];
    for (var k = 0; k < capped.length; k++) {
        var a = capped[k];
        out.push({
            id: 'action-' + (k + 1),
            sev: a.sev,
            priority: arPriorityLabel(a.sev),
            band: arBandFor(a.sev),
            source: a.source,
            extension: a.extension,
            room: a.room,
            roomId: a.roomId,
            componentKey: a.componentKey,
            componentLabel: a.componentLabel,
            title: a.title,
            text: a.text
        });
    }
    return { actions: out, total: raw.length, truncated: raw.length > AR_ACTION_CAP };
}

/** §3.6 priority labels: 3 High · 2 Medium · 1 Low. */
function arPriorityLabel(sev) {
    if (sev >= 3) return 'High';
    if (sev === 2) return 'Medium';
    return 'Low';
}

/* ── §4.2 the summary sentence ───────────────────────────────────────────
   One sentence of plain copy GENERATED from the model — never a stored string,
   never a template with a blank in it that could read as a fact nobody
   recorded. Two clauses at most: what the rooms show, and where the lab is up
   to. */
function arSummarySentence(overall, rooms, axis, lab) {
    var roomCount = rooms.length;
    var clause;

    if (roomCount === 0) {
        clause = 'No rooms were recorded on this visit';
    } else if (overall === 0) {
        clause = 'No mould, damp or damage was recorded across ' +
            arNumberWord(roomCount) + ' room' + (roomCount !== 1 ? 's' : '');
    } else {
        // Rooms that reach the overall score ON THE NAMED AXIS — not rooms that
        // merely reach the score somehow. A bathroom that is severe because the
        // vanity is soaked must not be counted in a sentence about mould.
        var atOverall = 0;
        for (var i = 0; i < rooms.length; i++) {
            var comps = rooms[i].components;
            for (var c = 0; c < comps.length; c++) {
                if (comps[c][axis.field] === overall) { atOverall += 1; break; }
            }
        }
        clause = arCapitalise(arNumberWord(atOverall)) + ' of ' + arNumberWord(roomCount) +
            ' room' + (roomCount !== 1 ? 's' : '') + ' show' + (atOverall === 1 ? 's' : '') + ' ' +
            arWordFor(overall).toLowerCase() + ' ' + axis.word;
    }

    var tail = '';
    if (lab.pending > 0) {
        tail = '; ' + arNumberWord(lab.pending) + ' lab result' +
            (lab.pending === 1 ? ' is' : 's are') + ' pending';
    } else if (lab.reported > 0) {
        tail = '; ' + arNumberWord(lab.reported) + ' lab result' +
            (lab.reported === 1 ? ' has' : 's have') + ' been reported';
    }

    return arCapitalise(clause) + tail + '.';
}

/**
 * arWorstAxis — which finding the summary sentence names: `{field, word}`.
 *
 * The axis that ATTAINS the overall score, preferring mould, then damp, then
 * damage. The preference is not arbitrary: when a wall is at 3 for both mould
 * and damp, "severe mould" is the sentence an occupant needs to read first,
 * and the damp is in the action plan directly beneath it.
 *
 * `field` is the model's (mapped) key, so the sentence can count the rooms
 * that actually show THAT finding rather than any finding at all.
 */
function arWorstAxis(rooms, overall) {
    if (overall === 0) return { field: 'visibleMould', word: 'findings' };
    var hasMould = false, hasDamp = false;
    for (var r = 0; r < rooms.length; r++) {
        var comps = rooms[r].components;
        for (var c = 0; c < comps.length; c++) {
            if (comps[c].visibleMould === overall) hasMould = true;
            if (comps[c].wetOrDamp === overall) hasDamp = true;
        }
    }
    if (hasMould) return { field: 'visibleMould', word: 'mould' };
    if (hasDamp) return { field: 'wetOrDamp', word: 'damp' };
    return { field: 'damageOrStains', word: 'damage or staining' };
}

/* ── §3.9 amendments ─────────────────────────────────────────────────────── */

function arAmendments(assessment, roomsByIdName) {
    var revisions = arArray(assessment && assessment.revisions);
    var samples = arArray(assessment && assessment.samples);
    var days = [];
    var index = {};

    for (var i = 0; i < revisions.length; i++) {
        var rev = revisions[i];
        if (!rev) continue;
        var day = arDay(rev.at);
        if (!day) continue;

        var what = arStr(rev.what);
        var part = AR_REVISION_WORDS[what] || what || 'visit details';
        var ref = arStr(rev.ref);

        if (what === 'sample') {
            var sampleRef = '';
            for (var s = 0; s < samples.length; s++) {
                if (samples[s] && arStr(samples[s].id) === ref) { sampleRef = arStr(samples[s].sampleRef); break; }
            }
            if (sampleRef) part += ' · sample ' + sampleRef;
        } else if (what === 'room') {
            var roomName = roomsByIdName[ref] || '';
            if (roomName) part += ' · ' + roomName;
        } else if (what === 'component') {
            // A component revision's `ref` is "<roomId>/<componentKey>" (the
            // store writes it that way so one amendment names one component of
            // one room). Handing the whole thing to arComponentLabel matched
            // nothing, so the report printed a raw uuid at the reader (review
            // F3). Split it, resolve both halves, and say "Room · Component" —
            // the same words the room screen uses. An unresolvable room is left
            // out rather than guessed at; an unknown key still falls back to
            // the key itself, never an invented noun.
            var slash = ref.indexOf('/');
            var compRoom = (slash > -1) ? roomsByIdName[ref.slice(0, slash)] : '';
            var compKey = (slash > -1) ? ref.slice(slash + 1) : ref;
            var label = arComponentLabel(compKey);
            if (compRoom) part += ' · ' + compRoom;
            if (label) part += ' · ' + label;
        }

        if (!index[day]) {
            index[day] = { day: day, dateLabel: arDate(day), parts: [], line: '' };
            days.push(index[day]);
        }
        var parts = index[day].parts;
        var seen = false;
        for (var p = 0; p < parts.length; p++) if (parts[p] === part) { seen = true; break; }
        if (!seen) parts.push(part);
    }

    for (var d = 0; d < days.length; d++) {
        days[d].line = 'Amended ' + days[d].dateLabel + ' (' + days[d].parts.join(' · ') + ')';
    }
    return days;
}

/* ── §5 branding resolver ────────────────────────────────────────────────── */

/**
 * asResolveBranding — R4: white-label is gated at the SEAM, not the surface.
 *
 * This runs INSIDE the renderer and re-checks the entitlement itself. A
 * `ReportBranding` record can exist on any account (the editor writes one the
 * moment the preview flag lets someone try it, and a lapsed subscription leaves
 * one behind); below Business the Mould Detect default renders anyway. An
 * unentitled render can therefore never leak white-label content, whatever the
 * page above it believed. Same principle as the Swift app's
 * `BrandService.resolved(applying:)` (`BrandEntitlements` #4).
 *
 * `brandingPreview` is the one documented override — the `REPORT_BRANDING_PREVIEW`
 * dev flag (§5), passed in by the page as part of the entitlements object so it
 * is a NAMED field this function decides on, not a surface-level bypass that
 * skips the seam. It is off in the committed feature-flags.json.
 *
 * "Powered by Mould Detect" is appended by the renderer regardless (R6), which
 * is why it is not part of `footerLine`.
 */
function asResolveBranding(entitlements, record) {
    var ent = (entitlements && typeof entitlements === 'object') ? entitlements : {};
    var allowed = (ent.whiteLabelPdf === true) || (ent.brandingPreview === true);
    var rec = (record && typeof record === 'object') ? record : null;
    var name = rec ? arStr(rec.businessName) : '';

    if (allowed && name) {
        var logo = rec.logoDataUrl;
        return {
            name: name,
            logo: (typeof logo === 'string' && logo) ? logo : null,
            contact: arStr(rec.contactLine),
            intro: arStr(rec.introCopy),
            footerLine: arStr(rec.footerCopy) || name,
            whiteLabel: true
        };
    }

    return {
        name: 'Mould Detect',
        logo: AR_DEFAULT_LOGO,
        contact: '',
        intro: '',
        footerLine: 'Mould Detect · moulddetect.ai',
        whiteLabel: false
    };
}

/* ── the model ───────────────────────────────────────────────────────────── */

/**
 * asReportModel — everything §4's document renders, derived from the aggregate.
 *
 * @param {object} assessment  — the Assessment aggregate (ADR-009)
 * @param {object} property    — the Property record, or null
 * @param {object} scansById   — { scanId: scanRecord } for the photo galleries
 * @param {object} entitlements— useEntitlements() (+ `brandingPreview`)
 * @param {object} branding    — the stored ReportBranding record, or null
 * @returns {object} model — JSON primitives only; null for an unreadable input.
 */
function asReportModel(assessment, property, scansById, entitlements, branding) {
    if (!assessment || typeof assessment !== 'object') return null;

    var brand = asResolveBranding(entitlements, branding);
    var prop = (property && typeof property === 'object') ? property : null;
    var visit = (assessment.visit && typeof assessment.visit === 'object') ? assessment.visit : {};
    var srcRooms = arArray(assessment.rooms);
    var leadAssessor = arStr(visit.assessorName);
    var team = arTeam(visit);

    // Room id → name, needed by the action plan, the lab table and the
    // amendment trail. Built once.
    var roomsByIdName = {};
    for (var n = 0; n < srcRooms.length; n++) {
        if (srcRooms[n]) roomsByIdName[arStr(srcRooms[n].id)] = arStr(srcRooms[n].name);
    }

    // ── rooms, components, metrics (§3.2, §3.7, §3.8) ─────────────────────
    var rooms = [];
    var dist = { severe: 0, moderate: 0, low: 0 };
    var flagged = 0;
    var critical = 0;
    var anyPresent = false;
    var overall = 0;

    for (var r = 0; r < srcRooms.length; r++) {
        var srcRoom = srcRooms[r];
        if (!srcRoom || typeof srcRoom !== 'object') continue;

        var present = arPresentComponents(srcRoom);

        var roomMax = 0;
        var maxMould = 0;
        var maxDamp = 0;
        var components = [];

        for (var c = 0; c < present.length; c++) {
            var src = present[c];

            // D9: a photo-only component is listed as "Not assessed" — its
            // evidence shown, no scores, and no weight in any metric (it was
            // never scored, so it is neither clean nor flagged).
            if (!arIsAssessed(src)) {
                var unPhotos = arPhotos(src, scansById);
                components.push({
                    key: src.key,
                    label: arComponentLabel(src.key),
                    assessed: false,
                    damageOrStains: null,
                    visibleMould: null,
                    wetOrDamp: null,
                    compMax: null,
                    severity: { sev: null, word: 'Not assessed', band: null, hex: null },
                    nearExtWall: [false, false, false],
                    materials: [],
                    notes: arStr(src.notes),
                    affectedAreaM2: null,
                    photos: unPhotos,
                    photoCount: unPhotos.length
                });
                continue;
            }
            anyPresent = true;

            var compMax = arCompMax(src);
            if (compMax > roomMax) roomMax = compMax;
            if (arScore(src.mould) > maxMould) maxMould = arScore(src.mould);
            if (arScore(src.wetDamp) > maxDamp) maxDamp = arScore(src.wetDamp);

            if (compMax >= 1) flagged += 1;
            if (compMax === 3) { critical += 1; dist.severe += 1; }
            else if (compMax === 2) dist.moderate += 1;
            else if (compMax === 1) dist.low += 1;

            var photos = arPhotos(src, scansById);
            components.push({
                key: src.key,
                label: arComponentLabel(src.key),
                assessed: true,
                // Field mapping (§3): our axis names become the source's, so a
                // Riteflo inspector and a Mould Detect user read the same word.
                damageOrStains: arScore(src.damage),
                visibleMould: arScore(src.mould),
                wetOrDamp: arScore(src.wetDamp),
                compMax: compMax,
                severity: arSeverity(compMax),
                nearExtWall: [
                    arArray(src.nearExtWall)[0] === true,
                    arArray(src.nearExtWall)[1] === true,
                    arArray(src.nearExtWall)[2] === true
                ],
                materials: arArray(src.materials).slice(0),
                notes: arStr(src.notes),
                affectedAreaM2: (typeof src.affectedAreaM2 === 'number' && isFinite(src.affectedAreaM2)) ? src.affectedAreaM2 : null,
                photos: photos,
                photoCount: photos.length
            });
        }

        if (roomMax > overall) overall = roomMax;

        var ventilationRaw = arStr(srcRoom.ventilation);
        var ventilationAnswer = AR_VENTILATION_ANSWER[ventilationRaw] || null;
        var ventMetric = ventilationAnswer ? AR_VENT_METRIC[ventilationAnswer]
                                           : { word: '—', sev: 0, band: 'good' };

        var roomAssessor = arRoomAssessor(assessment, srcRoom);

        rooms.push({
            id: arStr(srcRoom.id),
            name: arStr(srcRoom.name) || 'Unnamed room',
            // Slice 5 — resolved (never stored): the room's own assessor, or
            // the visit lead. `assessorDiffers` is what the room card reads:
            // naming the lead on every card is noise, naming the colleague who
            // actually walked that room is the finding.
            assessorName: roomAssessor,
            assessorDiffers: !!roomAssessor && roomAssessor !== leadAssessor,
            direction: arStr(srcRoom.direction) || null,
            odour: arScore(srcRoom.odour),
            odourWord: ['None', 'Faint', 'Noticeable', 'Strong'][arScore(srcRoom.odour)],
            odourSource: arStr(srcRoom.odourSource),
            ventilation: ventilationRaw || null,
            ventilationAnswer: ventilationAnswer,
            roomMax: roomMax,
            severity: arSeverity(roomMax),
            // §3.7 — three metrics, in this order, on every room card.
            metrics: [
                { label: 'Mould growth', word: arWordFor(maxMould), sev: maxMould, band: arBandFor(maxMould) },
                { label: 'Dampness', word: arWordFor(maxDamp), sev: maxDamp, band: arBandFor(maxDamp) },
                { label: 'Ventilation', word: ventMetric.word, sev: ventMetric.sev, band: ventMetric.band }
            ],
            components: components,
            componentCount: components.length,
            // §4.4: a room with nothing to say collapses to one line. It is
            // still printed — "we looked and found nothing" is a finding.
            collapsed: roomMax === 0
        });
    }

    // ── §3.5 lab stat (the Mould Detect addition) and §4.5 table ──────────
    var srcSamples = arArray(assessment.samples);
    var labRows = [];
    var reported = 0;
    var pending = 0;

    for (var s = 0; s < srcSamples.length; s++) {
        var sample = srcSamples[s];
        if (!sample || typeof sample !== 'object') continue;
        var result = (sample.result && typeof sample.result === 'object') ? sample.result : null;
        if (result) reported += 1; else pending += 1;

        labRows.push({
            id: arStr(sample.id),
            sampleRef: arStr(sample.sampleRef),
            sampleType: arStr(sample.sampleType),
            roomName: roomsByIdName[arStr(sample.roomId)] || '',
            componentLabel: sample.componentKey ? arComponentLabel(sample.componentKey) : '',
            collected: arStr(sample.collectedAt) || null,
            collectedLabel: arDate(sample.collectedAt),
            sentLabel: arDate(sample.sentAt),
            lab: arStr(sample.lab),
            // `result === null` IS the pending state (ADR-009 D3a) — there is
            // no second flag that could disagree with it.
            status: result ? 'Reported' : 'Pending',
            reportedLabel: arDate(sample.resultReceivedAt),
            result: result ? {
                genus: arStr(result.genus),
                speciesText: arStr(result.speciesText),
                sporeCount: (typeof result.sporeCount === 'number' && isFinite(result.sporeCount)) ? result.sporeCount : null,
                unit: arStr(result.unit),
                interpretation: arStr(result.interpretation),
                notes: arStr(result.notes)
            } : null
        });
    }

    var lab = { rows: labRows, reported: reported, pending: pending, total: labRows.length };

    // ── §3.3 risk, §3.4 status ────────────────────────────────────────────
    var risk = {
        score: overall,
        profile: AR_RISK[overall].profile,
        badge: AR_RISK[overall].badge,
        word: arWordFor(overall),
        band: arBandFor(overall),
        hex: AR_BAND_HEX[arBandFor(overall)],
        gaugePct: AR_GAUGE_PCT[overall]
    };

    var status = (overall >= 2) ? 'Action Required' : (anyPresent ? 'Monitor' : 'Satisfactory');

    // ── §3.6 action plan ──────────────────────────────────────────────────
    var plan = arActions(assessment, roomsByIdName);

    // ── §4.6 readings ─────────────────────────────────────────────────────
    var srcReadings = (assessment.environmentalReadings && typeof assessment.environmentalReadings === 'object')
        ? assessment.environmentalReadings : null;
    var readingItems = [];
    if (srcReadings) {
        for (var f = 0; f < AR_READING_FIELDS.length; f++) {
            var field = AR_READING_FIELDS[f];
            var value = srcReadings[field.key];
            var text = (typeof value === 'number' && isFinite(value)) ? String(value) : arStr(value);
            // Only what was measured. A blank row would read as a zero.
            if (!text) continue;
            readingItems.push({ key: field.key, label: field.label, value: text, unit: field.unit });
        }
    }

    // ── §4.7 remediation + sign-off ───────────────────────────────────────
    var rem = (assessment.remediation && typeof assessment.remediation === 'object') ? assessment.remediation : {};
    var sign = (assessment.signoff && typeof assessment.signoff === 'object') ? assessment.signoff : {};

    var amendments = arAmendments(assessment, roomsByIdName);

    return {
        v: 1,
        ref: 'MD-' + arStr(assessment.id).slice(0, 8),
        title: 'Mould Assessment Report',
        status: status,
        recordStatus: arStr(assessment.status) || 'draft',

        brand: brand,

        property: {
            name: prop ? arStr(prop.name) : '',
            address: prop ? arStr(prop.address) : '',
            buildingType: prop ? arStr(prop.buildingType) : ''
        },
        visit: {
            date: arStr(visit.date) || null,
            dateLabel: arDate(visit.date),
            assessorName: arStr(visit.assessorName),
            assessorCertNo: arStr(visit.assessorCertNo),
            building: arStr(visit.building),
            buildingAge: arStr(visit.buildingAge),
            floor: arStr(visit.floor),
            // Slice 5 — the additional assessors, and the number the header
            // line says ("Assessed by Mirko Nads with two others").
            team: team,
            teamCount: team.length
        },

        overall: overall,
        risk: risk,
        summary: arSummarySentence(overall, rooms, arWorstAxis(rooms, overall), lab),

        stats: {
            roomsAssessed: rooms.length,
            componentsFlagged: flagged,
            criticalIssues: critical,
            labReported: reported,
            labPending: pending,
            distribution: dist,
            distributionTotal: dist.severe + dist.moderate + dist.low
        },

        actions: plan.actions,
        actionsTotal: plan.total,
        actionsTruncated: plan.truncated,

        rooms: rooms,
        lab: lab,

        readings: { items: readingItems, caption: AR_READINGS_CAPTION },

        remediation: {
            riskClassification: arStr(rem.riskClassification),
            method: arStr(rem.method),
            containmentType: arStr(rem.containmentType),
            ppeLevel: arStr(rem.ppeLevel),
            affectedAreaM2: (typeof rem.affectedAreaM2 === 'number' && isFinite(rem.affectedAreaM2)) ? rem.affectedAreaM2 : null
        },
        signoff: {
            signed: !!arStr(sign.signedAt),
            signedAt: arStr(sign.signedAt) || null,
            signedAtLabel: arDate(sign.signedAt),
            technicianName: arStr(sign.technicianName),
            technicianCertNo: arStr(sign.technicianCertNo)
        },

        amendments: amendments,
        amendedLabel: amendments.length ? amendments[amendments.length - 1].dateLabel : '',

        disclaimer: AR_DISCLAIMER
    };
}

window.asReportModel = asReportModel;
window.asResolveBranding = asResolveBranding;
window.arVerdictChip = arVerdictChip;
window.arNumberWord = arNumberWord;
window.arDate = arDate;
window.AR_SEV_WORDS = AR_SEV_WORDS;
window.AR_AXES = AR_AXES;
window.AR_BAND_HEX = AR_BAND_HEX;
window.AR_DEFAULT_LOGO = AR_DEFAULT_LOGO;
window.AR_POWERED_BY = AR_POWERED_BY;
window.AR_ACTION_CAP = AR_ACTION_CAP;
