var _createContext_AS = React.createContext;
var _useState_AS      = React.useState;
var _useContext_AS    = React.useContext;
var _useCallback_AS   = React.useCallback;
var _useEffect_AS     = React.useEffect;
var _useRef_AS        = React.useRef;
var _useMemo_AS       = React.useMemo;

/**
 * AssessmentStore — the Mould Assessment aggregate (ADR-009).
 *
 * Spec: architecture/MOULD-ASSESSMENT-MIGRATION-BRIEF.md §3 (the record), §5
 * (phone rules), §6 slice 1 (this build), §8 decision 1 (always editable, every
 * later write stamped).
 *
 * Why its own store and not fields on the Scan record (§2, ADR-009 D1): the
 * source model is Property → Visit → Room[] → Component[], while a Scan is one
 * photo with one component matrix and a free-text room name. A three-bedroom
 * inspection is one visit with several rooms; the flat record cannot hold it.
 * An assessment also has a completely different lifecycle from a scan — it is
 * edited over days or weeks as lab results arrive — so mixing the two would
 * rewrite the whole scans array on every keystroke in a room name.
 *
 * Mechanics mirror UserProfileStore/ScanStore deliberately: hydrate on mount,
 * 300 ms debounced persist, flush on pagehide/visibilitychange and on provider
 * unmount, and persistence stays DISABLED for the session if hydration failed,
 * so a failed read can never overwrite good stored assessments with an empty
 * array.
 *
 * Contract: nothing here throws. Every writer is a strict allowlist writer that
 * builds the record field by field (unknown keys dropped, `undefined` coerced to
 * `null`, enums validated), and a corrupt stored record is DROPPED with a
 * console.warn rather than crashing the provider — an inspector must never lose
 * the whole day's work to one bad row.
 *
 * Record shape is §3 verbatim plus `legacyScanId` (nullable), JSON primitives
 * only (string/number/boolean/null, never undefined) so it rides the future
 * opaque S3 cloud sync (`mould-detect-sync-lambda`, §4) and the Swift `Codable`
 * port unchanged, like weatherSnapshot and profileSnapshot.
 *
 * Written in ES5 for Babel 6 compatibility. File-local helpers use the `as`
 * (assessment) namespace prefix — the app has no module system, so every
 * top-level var is a window global (CLAUDE.md hard rule).
 */

var AssessmentContext = _createContext_AS(null);

// Current record version. Bump only with a matching branch in asMigrateAssessment.
var AS_ASSESSMENT_VERSION = 1;

// §3 enums. Anything else is coerced to null (allowlist discipline — the same
// rule as addScan: unknown values never reach storage).
var AS_DIRECTIONS     = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
var AS_VENTILATION    = ['Good', 'Fair', 'Poor', 'None'];
var AS_COMPONENT_KEYS = ['ceiling', 'walls', 'floor', 'windows', 'furnishings', 'hvac', 'supplies', 'pipes', 'subfloor'];
var AS_SAMPLE_TYPES   = ['tape', 'swab', 'air', 'bulk', 'other'];
// Option lists for the lab-result sheet (slice 3). They constrain the UI only:
// `unit` and `interpretation` are free strings in the §3 record, because a lab
// report that says something these lists do not must still be recordable.
var AS_SAMPLE_UNITS   = ['spores/m³', 'CFU/cm²', 'other'];
var AS_INTERPRETATIONS = ['Normal', 'Elevated', 'High', 'Very high', 'Indeterminate'];
var AS_STATUSES       = ['draft', 'complete', 'amended'];
var AS_REVISION_WHATS = ['visit', 'room', 'component', 'sample', 'readings', 'remediation', 'signoff'];

/* ── The change log (ADR-009 D10, 2026-09-28; Swift port: ADR-012) ──────────
   Each revision names the FIELD that changed and its before/after value, so the
   trail answers "what changed", not just "something in the kitchen changed on
   Tuesday". Field keys are stable identifiers (never display words): the words
   live in AS_REVISION_FIELDS below and are resolved at render time, so a
   relabel never rewrites history. */

// Event "fields": a thing happened to the whole target rather than to one of
// its values. They are never coalesced and never dropped as a no-op.
var AS_REVISION_EVENTS = ['added', 'removed', 'reopened'];

// A before/after value longer than this is cut with an ellipsis. The trail is
// a record of WHAT changed; the record itself holds the full text.
var AS_REVISION_VALUE_MAX = 80;

// Who made an edit when nobody is signed in. Honest about what we know: the
// edit came from this browser, and we cannot name the person holding it.
var AS_REVISION_DEVICE = 'This device';

// The target words when a stamp cannot be resolved to something more specific
// (and the category words for the fixed targets).
var AS_REVISION_WORDS = {
    visit: 'Visit details', room: 'Room', component: 'Component',
    sample: 'Lab sample', readings: 'Readings', remediation: 'Remediation', signoff: 'Sign-off'
};

/* Field registry, keyed `what.field`. `label` names the field after the target
   ("Kitchen › Wall — Visible mould"); `short` prefixes the change ("Mould 1 → 2").
   kind: score (0–3, no colon: "Mould 1 → 2") · date (ISO → "28 Sep 2026") ·
   bool (Yes/No) · result (null reads "pending") · lead (null reads "Visit lead") ·
   event (no before/after). `unit` is appended to numbers. */
var AS_REVISION_FIELDS = {
    'visit.date':            { label: 'Visit date', short: 'Date', kind: 'date' },
    'visit.assessorName':    { label: 'Lead assessor', short: 'Lead assessor' },
    'visit.assessorCertNo':  { label: 'Lead assessor certificate', short: 'Certificate' },
    'visit.building':        { label: 'Building', short: 'Building' },
    'visit.buildingAge':     { label: 'Building age', short: 'Building age' },
    'visit.floor':           { label: 'Floor', short: 'Floor' },
    'visit.team':            { label: 'Team', short: 'Team' },
    'visit.reopened':        { label: 'Reopened to amend', kind: 'event' },
    'room.added':            { label: 'Room added', kind: 'event' },
    'room.removed':          { label: 'Room removed', kind: 'event' },
    'room.name':             { label: 'Room name', short: 'Name' },
    'room.assessorName':     { label: 'Assessed by', short: 'Assessed by', kind: 'lead' },
    'room.direction':        { label: 'Faces', short: 'Faces' },
    'room.odour':            { label: 'Mould odour', short: 'Odour', kind: 'score' },
    'room.odourSource':      { label: 'Odour source', short: 'Odour source' },
    'room.ventilation':      { label: 'Ventilation', short: 'Ventilation' },
    'component.present':     { label: 'Present', short: 'Present', kind: 'bool' },
    'component.damage':      { label: 'Damage or stains', short: 'Damage', kind: 'score' },
    'component.mould':       { label: 'Visible mould', short: 'Mould', kind: 'score' },
    'component.wetDamp':     { label: 'Wet or damp', short: 'Damp', kind: 'score' },
    'component.nearExtWall.damage':  { label: 'Damage near external wall', short: 'Near external wall', kind: 'bool' },
    'component.nearExtWall.mould':   { label: 'Mould near external wall', short: 'Near external wall', kind: 'bool' },
    'component.nearExtWall.wetDamp': { label: 'Damp near external wall', short: 'Near external wall', kind: 'bool' },
    'component.materials':   { label: 'Materials', short: 'Materials' },
    'component.notes':       { label: 'Notes', short: 'Notes' },
    'component.affectedAreaM2': { label: 'Affected area', short: 'Area', unit: 'm²' },
    'component.photos':      { label: 'Photos', short: 'Photos' },
    'sample.added':          { label: 'Sample added', kind: 'event' },
    'sample.removed':        { label: 'Sample removed', kind: 'event' },
    'sample.result':         { label: 'Lab result', short: 'Lab result', kind: 'result' },
    'sample.resultReceivedAt': { label: 'Result received', short: 'Received', kind: 'date' },
    'sample.sampleRef':      { label: 'Sample reference', short: 'Reference' },
    'sample.sampleType':     { label: 'Sample type', short: 'Type' },
    'sample.lab':            { label: 'Laboratory', short: 'Laboratory' },
    'sample.collectedAt':    { label: 'Collected', short: 'Collected', kind: 'date' },
    'sample.sentAt':         { label: 'Sent to lab', short: 'Sent', kind: 'date' },
    'readings.temp':         { label: 'Temperature', short: 'Temperature', unit: '°C' },
    'readings.humidity':     { label: 'Humidity', short: 'Humidity', unit: '% RH' },
    'readings.co2':          { label: 'CO₂ level', short: 'CO₂', unit: 'ppm' },
    'readings.lightMin':     { label: 'Light — min', short: 'Light min', unit: 'lux' },
    'readings.lightMax':     { label: 'Light — max', short: 'Light max', unit: 'lux' },
    'readings.formaldehyde': { label: 'Formaldehyde', short: 'Formaldehyde', unit: 'μg/m³' },
    'readings.vocs':         { label: 'VOCs (total)', short: 'VOCs', unit: 'ppb' },
    'remediation.riskClassification': { label: 'Risk classification', short: 'Risk' },
    'remediation.method':          { label: 'Method', short: 'Method' },
    'remediation.containmentType': { label: 'Containment', short: 'Containment' },
    'remediation.ppeLevel':        { label: 'PPE level', short: 'PPE' },
    'remediation.affectedAreaM2':  { label: 'Affected area', short: 'Area', unit: 'm²' },
    'signoff.technicianName':   { label: 'Technician', short: 'Technician' },
    'signoff.technicianCertNo': { label: 'Technician certificate', short: 'Certificate' },
    'signoff.signedAt':         { label: 'Signed', short: 'Signed', kind: 'date' }
};

// Slice 5 — the visit team (brief §4: multi-assessor is Professional+).
//
// `role` is an enum for the same reason every other string here is: a chip the
// report and the CSV can render without guessing. 'lead' is IN the enum because
// a large job really can carry a second certified lead — but the VISIT's lead is
// always `visit.assessorName`, never a team row, so there is exactly one answer
// to "whose visit is this" no matter what the roles say.
var AS_TEAM_ROLES = ['lead', 'assessor', 'trainee'];

// Five ADDITIONAL members. Not a storage limit — a phone-form limit: a visit
// header with a dozen rows in it stops being a header.
var AS_TEAM_MAX = 5;

/* The one line shown wherever slice 5's two Professional+ capabilities are
   gated (the report toolbar's Export CSV, the visit header's team editor). A
   constant because two files render it and the headless suite asserts it. */
var AS_PRO_GATE_COPY = 'CSV export and team members are on the Professional plan';

// The eight scoreable components a room is assessed against on the phone (§5.1,
// the "n/8" counter). `subfloor` is the ninth key the matrix allows but is not
// part of every room's set — it is scored where it exists.
var AS_ROOM_COMPONENT_TARGET = 8;

// The eight that make up that denominator, in the order the room screen stacks
// them (slice 2). `subfloor` is deliberately NOT here: it sits behind its own
// "Subfloor (if inspected)" toggle, defaults to not present, and must never
// turn an honest 8/8 into 8/9.
var AS_PRIMARY_COMPONENT_KEYS = ['ceiling', 'walls', 'floor', 'windows', 'furnishings', 'hvac', 'supplies', 'pipes'];

// The seven manual DMAT device readings (Section D), unchanged shape (§3).
var AS_READING_KEYS = ['temp', 'humidity', 'co2', 'lightMin', 'lightMax', 'formaldehyde', 'vocs'];

var AS_TEXT_MAX  = 500;   // notes / free text fields
var AS_SHORT_MAX = 120;   // names, ids, labels

/** asGenerateId — same ID rule as ScanStore.generateId, duplicated rather than
 *  depended on so this file has no load-order requirement against ScanStore. */
function asGenerateId() {
    if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
        return crypto.randomUUID();
    }
    return Date.now().toString() + '-' + Math.random().toString(36).substr(2, 9);
}

/** asNow — ISO-8601 timestamp, the format §3 specifies for every date field. */
function asNow() {
    return new Date().toISOString();
}

/** asOneOf — value if it is in the allowed list, else null. */
function asOneOf(value, allowed) {
    if (typeof value !== 'string') return null;
    for (var i = 0; i < allowed.length; i++) {
        if (allowed[i] === value) return value;
    }
    return null;
}

/** asStr — a trimmed string, capped; '' when absent. Never null-vs-'' ambiguity
 *  for the always-present text fields of §3 (which are written as ""). */
function asStr(value, maxLen) {
    if (typeof value === 'number' && isFinite(value)) value = String(value);
    if (typeof value !== 'string') return '';
    var trimmed = value.replace(/^\s+|\s+$/g, '');
    if (!trimmed) return '';
    return (maxLen && trimmed.length > maxLen) ? trimmed.slice(0, maxLen) : trimmed;
}

/** asStrOrNull — the same, but '' becomes null (for the "ISO|null" fields). */
function asStrOrNull(value, maxLen) {
    var s = asStr(value, maxLen);
    return s === '' ? null : s;
}

/** asScore — a DMAT 0–3 score. Anything else is 0 (§5.2: scores are 0–3). */
function asScore(value) {
    var n = (typeof value === 'number') ? value : parseInt(value, 10);
    if (!isFinite(n)) return 0;
    n = Math.round(n);
    if (n < 0) return 0;
    if (n > 3) return 3;
    return n;
}

/** asNumOrNull — a finite number or null; never NaN, never undefined. */
function asNumOrNull(value) {
    if (value === null || value === undefined || value === '') return null;
    var n = (typeof value === 'number') ? value : parseFloat(value);
    return isFinite(n) ? n : null;
}

/** asBool — a real boolean, never undefined. */
function asBool(value) {
    return value === true;
}

/** asStrList — an array of trimmed strings; drops everything else. */
function asStrList(value, maxLen) {
    var out = [];
    if (!value || Object.prototype.toString.call(value) !== '[object Array]') return out;
    for (var i = 0; i < value.length; i++) {
        var s = asStr(value[i], maxLen || AS_SHORT_MAX);
        if (s) out.push(s);
    }
    return out;
}

/** asNearExtWall — exactly three booleans (damage / mould / wet-damp). */
function asNearExtWall(value) {
    var src = (value && Object.prototype.toString.call(value) === '[object Array]') ? value : [];
    return [asBool(src[0]), asBool(src[1]), asBool(src[2])];
}

/**
 * asNormaliseComponent — one row of a room's component matrix, §3 verbatim.
 * A component whose key is not in AS_COMPONENT_KEYS is rejected (null) by the
 * caller rather than stored under a key nothing can render.
 */
/**
 * asEditedAtOf — the component's `editedAt` (form-state amendment, 2026-09-28):
 * epoch ms of the inspector's first explicit edit of a finding on this
 * component, moved forward on every later edit; `null` = never touched.
 *
 * Why it exists: every score defaults to 0, and 0 is also a real answer ("None").
 * A component where the inspector deliberately tapped "0 None" three times was
 * byte-identical to one nobody opened, so a recorded clean finding read as
 * "Not assessed". `editedAt` is the only way to tell the two apart.
 *
 * Backfill, for records written before the field existed (the key is ABSENT —
 * not null): a component the old positive-evidence rule already called scored
 * gets `backfillMs` (the record's updatedAt/createdAt), everything else null.
 * An explicit `null` is never backfilled, so this runs once per component —
 * the first normalise writes the key and every later read keeps it.
 */
function asEditedAtOf(raw, backfillMs) {
    var v = raw.editedAt;
    if (typeof v === 'number' && isFinite(v) && v > 0) return Math.round(v);
    if (Object.prototype.hasOwnProperty.call(raw, 'editedAt')) return null;
    if (typeof backfillMs !== 'number' || !isFinite(backfillMs)) return null;
    return asComponentHasEvidence(raw) ? backfillMs : null;
}

/**
 * asComponentHasEvidence — the pre-2026-09-28 "scored" rule: present AND (a
 * score above zero, notes, or materials). Positive evidence of an assessment,
 * readable from the values alone. Kept as its own function because it is ALSO
 * the legacy backfill test above; `asComponentScored` is the public rule.
 */
function asComponentHasEvidence(comp) {
    if (!comp || typeof comp !== 'object') return false;
    if (comp.present !== true) return false;
    if (asScore(comp.damage) > 0 || asScore(comp.mould) > 0 || asScore(comp.wetDamp) > 0) return true;
    if (asStr(comp.notes, AS_TEXT_MAX) !== '') return true;
    return asStrList(comp.materials).length > 0;
}

/** asBackfillMs — the record's own last-known stamp, for asEditedAtOf. */
function asBackfillMs(raw) {
    if (!raw || typeof raw !== 'object') return null;
    var ms = Date.parse(raw.updatedAt || raw.createdAt || '');
    return isFinite(ms) ? ms : null;
}

function asNormaliseComponent(raw, backfillMs) {
    if (!raw || typeof raw !== 'object') return null;
    var key = asOneOf(raw.key, AS_COMPONENT_KEYS);
    if (!key) return null;
    return {
        key:         key,
        present:     asBool(raw.present),
        damage:      asScore(raw.damage),
        mould:       asScore(raw.mould),
        wetDamp:     asScore(raw.wetDamp),
        nearExtWall: asNearExtWall(raw.nearExtWall),
        materials:   asStrList(raw.materials),
        notes:       asStr(raw.notes, AS_TEXT_MAX),
        // Per-component affected area in m² (slice 2). The enterprise form had
        // this on every matrix row and it had nowhere to go in the §3 shape;
        // the remediation block's own affectedAreaM2 is the VISIT total, not a
        // sum of these, because a plan covers areas nobody itemised.
        affectedAreaM2: asNumOrNull(raw.affectedAreaM2),
        photoIds:    asStrList(raw.photoIds),
        scanIds:     asStrList(raw.scanIds),
        // Component photos that are NOT (yet) scans (review 2026-10-05): an
        // inspector's context shot. Metadata only — the image lives under its
        // own IndexedDB key (StorageService.saveComponentPhoto). See
        // asNormaliseAttachment.
        attachments: asNormaliseAttachments(raw.attachments),
        // Form-state amendment (2026-09-28, ADR-009 D9): epoch ms of the first
        // explicit edit, updated on later edits; null = never touched.
        editedAt:    asEditedAtOf(raw, backfillMs)
    };
}

/**
 * asDefaultComponent — the row a room starts with for a key nothing has
 * touched yet (slice 2).
 *
 * `present` defaults TRUE for the eight primaries and FALSE for `subfloor`:
 * the eight are in every room until the inspector says otherwise, while a
 * subfloor is only there if it was actually inspected. The room screen renders
 * this shape for keys the record does not carry yet, so what is on screen
 * before the first tap is exactly what the first write persists.
 */
function asDefaultComponent(key) {
    return asNormaliseComponent({
        key: key,
        present: key !== 'subfloor',
        damage: 0, mould: 0, wetDamp: 0,
        nearExtWall: [false, false, false],
        materials: [], notes: '', affectedAreaM2: null,
        photoIds: [], scanIds: [], attachments: [],
        editedAt: null
    });
}

/**
 * asComponentScored — the ONE definition of "this component has been assessed"
 * (ADR-009 D4). Used by the room rows' n/8 counter and by asDeriveSummary, so
 * the two surfaces can never disagree.
 *
 * Scored = **present** AND (the inspector edited it — `editedAt` set — OR any
 * of the three 0–3 scores above zero, OR notes written, OR materials chosen).
 * Present alone is not enough: every primary component starts present, so
 * counting presence would show 8/8 on a room nobody has looked at yet. A clean
 * component IS assessed — "0 None" tapped on purpose is a finding, not an
 * omission, and `editedAt` is what lets it count (ADR-009 D9, 2026-09-28).
 * The positive-evidence rules stay so a record from a client that does not
 * stamp `editedAt` yet still reads the same.
 */
function asComponentScored(comp) {
    if (!comp || typeof comp !== 'object') return false;
    if (comp.present !== true) return false;
    if (typeof comp.editedAt === 'number' && isFinite(comp.editedAt) && comp.editedAt > 0) return true;
    return asComponentHasEvidence(comp);
}

/**
 * asComponentNothingFound — assessed, and every axis 0: the explicit clean
 * finding the row words as "Assessed · nothing found".
 */
function asComponentNothingFound(comp) {
    return asComponentScored(comp) &&
        asScore(comp.damage) === 0 && asScore(comp.mould) === 0 && asScore(comp.wetDamp) === 0;
}

/**
 * asRoomScored — the room row's numerator: scored components among the EIGHT
 * primaries (AS_ROOM_COMPONENT_TARGET is the denominator). Subfloor is counted
 * by asDeriveSummary, which measures the whole visit, but never here — "9/8"
 * would be nonsense on a row.
 */
function asRoomScored(room) {
    var components = (room && room.components && Object.prototype.toString.call(room.components) === '[object Array]') ? room.components : [];
    var scored = 0;
    for (var i = 0; i < components.length; i++) {
        var comp = components[i];
        if (!comp || comp.key === 'subfloor') continue;
        if (asComponentScored(comp)) scored += 1;
    }
    return scored;
}

/**
 * asRoomProgress — the room's section state (form-state amendment, D9),
 * derived, never stored. Among the eight primaries a component is DECIDED when
 * it is assessed or explicitly marked not present (a present:false row only
 * exists after a tap — the primaries default to present).
 *
 *   untouched   — nothing decided yet            "Not started"
 *   in_progress — some decided                    "In progress"
 *   complete    — all eight decided               "All components done"
 */
function asRoomProgress(room) {
    var components = (room && room.components && Object.prototype.toString.call(room.components) === '[object Array]') ? room.components : [];
    var byKey = {};
    for (var i = 0; i < components.length; i++) {
        if (components[i] && components[i].key) byKey[components[i].key] = components[i];
    }
    var decided = 0;
    for (var k = 0; k < AS_PRIMARY_COMPONENT_KEYS.length; k++) {
        var comp = byKey[AS_PRIMARY_COMPONENT_KEYS[k]];
        if (!comp) continue;
        if (comp.present !== true || asComponentScored(comp)) decided += 1;
    }
    if (decided === 0) return { state: 'untouched', label: 'Not started', decided: 0 };
    if (decided >= AS_ROOM_COMPONENT_TARGET) return { state: 'complete', label: 'All components done', decided: decided };
    return { state: 'in_progress', label: 'In progress', decided: decided };
}

/**
 * asSectionState — the visit's collapsible sections, derived from data (D9).
 * Returns { state: 'untouched'|'in_progress'|'complete', label }. `complete`
 * only where a real completion rule exists: sign-off signed, or every field of
 * a section filled. Team is optional, so an empty team is untouched but worded
 * as a fact ("Just the lead"), never as a to-do.
 */
function asSectionState(section, assessment) {
    var a = assessment || {};
    if (section === 'team') {
        var team = (a.visit && a.visit.team && Object.prototype.toString.call(a.visit.team) === '[object Array]') ? a.visit.team : [];
        if (!team.length) return { state: 'untouched', label: 'Just the lead' };
        return { state: 'in_progress', label: team.length + ' added' };
    }
    if (section === 'readings') {
        var readings = a.environmentalReadings || {};
        var filled = 0;
        for (var r = 0; r < AS_READING_KEYS.length; r++) {
            if (asStr(readings[AS_READING_KEYS[r]], AS_SHORT_MAX) !== '') filled += 1;
        }
        if (filled === 0) return { state: 'untouched', label: 'Not started' };
        if (filled === AS_READING_KEYS.length) return { state: 'complete', label: 'All ' + filled + ' recorded' };
        return { state: 'in_progress', label: filled + ' of ' + AS_READING_KEYS.length + ' recorded' };
    }
    if (section === 'remediation') {
        var rem = a.remediation || {};
        var set = 0;
        if (asStr(rem.riskClassification, AS_SHORT_MAX)) set += 1;
        if (asStr(rem.method, AS_SHORT_MAX)) set += 1;
        if (asStr(rem.containmentType, AS_SHORT_MAX)) set += 1;
        if (asStr(rem.ppeLevel, AS_SHORT_MAX)) set += 1;
        if (asNumOrNull(rem.affectedAreaM2) !== null) set += 1;
        if (set === 0) return { state: 'untouched', label: 'Not started' };
        if (set === 5) return { state: 'complete', label: 'All 5 set' };
        return { state: 'in_progress', label: set + ' of 5 set' };
    }
    if (section === 'signoff') {
        var sign = a.signoff || {};
        if (sign.signedAt) return { state: 'complete', label: 'Signed' };
        if (asStr(sign.technicianName, AS_SHORT_MAX) || asStr(sign.technicianCertNo, AS_SHORT_MAX)) {
            return { state: 'in_progress', label: 'Not signed yet' };
        }
        return { state: 'untouched', label: 'Not signed yet' };
    }
    return { state: 'untouched', label: 'Not started' };
}

/**
 * asComponentPhotoIds — the scan ids attached to one component, de-duplicated
 * across `photoIds` and `scanIds` (slice 3).
 *
 * In this prototype **a photo IS a scan** (ADR-009 D3), so `attachScan` writes
 * the same id into both keys: `scanIds` is the link the data moat cares about,
 * `photoIds` is the key the Swift port and the source app's report renderer
 * expect. Both are kept, so every count and every strip must take the UNION —
 * summing the two lengths would show one photo as two, which is what the
 * slice-2 placeholder counters did while both arrays were always empty.
 *
 * The legacy migration (D5) fills only `scanIds`, so the union is also what
 * makes a migrated component's photo appear at all.
 */
function asComponentPhotoIds(comp) {
    var out = [];
    var seen = {};
    var lists = [(comp && comp.photoIds) || [], (comp && comp.scanIds) || []];
    for (var l = 0; l < lists.length; l++) {
        var list = (Object.prototype.toString.call(lists[l]) === '[object Array]') ? lists[l] : [];
        for (var i = 0; i < list.length; i++) {
            var id = asStr(list[i], AS_SHORT_MAX);
            if (!id || seen[id]) continue;
            seen[id] = true;
            out.push(id);
        }
    }
    return out;
}

/** asRoomPhotoCount — photos attached anywhere in a room, de-duplicated per
 *  component by asComponentPhotoIds. The room screen header, the room rows on
 *  the visit screen and the collapsed component rows all read this rule. */
function asRoomPhotoCount(room) {
    var components = (room && room.components && Object.prototype.toString.call(room.components) === '[object Array]') ? room.components : [];
    var total = 0;
    for (var i = 0; i < components.length; i++) total += asComponentPhotoItems(components[i]).length;
    return total;
}

/* ── Component photos that are not scans (App review, 2026-10-05) ────────────
 *
 * Slice 3 made every component photo a scan (D3). Inspectors also take context
 * shots — the outside of the wall, the room from the door — that do not need
 * (or suit) a mould verdict, and forcing each through the scan flow cost a
 * network round-trip and put a meaningless "Clear" on a picture of a gutter.
 *
 * The least disruptive extension: a component gains `attachments[]`, one small
 * metadata entry per uploaded photo, `{ id, createdAt, mime, scanId }`. The
 * image itself lives under its own IndexedDB key (like heatmaps, CR-02) so the
 * aggregate — rewritten on every keystroke — stays small. `photoIds`/`scanIds`
 * keep their D3a meaning (scan ids, union-counted) untouched, so the CSV,
 * samples and genus write-through need no change.
 *
 * Scanning an attachment is an ordinary scan through AnalysisPage (all the
 * capture rules hold); `assessmentRef.photoId` names the attachment, and
 * `attachScan` both appends the scan id (as D3a) AND sets `attachment.scanId`,
 * which is the link back.
 */
var AS_PHOTO_MIMES = ['image/jpeg', 'image/png', 'image/webp'];
/** Fired on `window` after component-photo images change outside this store
 *  (a cloud restore). SyncService uses the same literal. */
var AS_PHOTOS_CHANGED_EVENT = 'mould-detect-component-photos-changed';
var AS_PHOTO_MAX_DIM = 1600;   // stored copy's long edge, px
var AS_PHOTO_QUALITY = 0.85;

function asNormaliseAttachment(raw) {
    if (!raw || typeof raw !== 'object') return null;
    var id = asStr(raw.id, AS_SHORT_MAX);
    if (!id) return null;
    var createdAt = asStr(raw.createdAt, AS_SHORT_MAX);
    if (!createdAt || !isFinite(Date.parse(createdAt))) createdAt = asNow();
    return {
        id: id,
        createdAt: createdAt,
        mime: asOneOf(raw.mime, AS_PHOTO_MIMES) || 'image/jpeg',
        scanId: asStrOrNull(raw.scanId, AS_SHORT_MAX)
    };
}

function asNormaliseAttachments(value) {
    var list = (Object.prototype.toString.call(value) === '[object Array]') ? value : [];
    var out = [];
    var seen = {};
    for (var i = 0; i < list.length; i++) {
        var a = asNormaliseAttachment(list[i]);
        if (!a || seen[a.id]) continue;
        seen[a.id] = true;
        out.push(a);
    }
    return out;
}

/* ── Deleted photos: tombstones (2026-10-05) ──────────────────────────────────
 *
 * Deleting a component photo removes the attachment (and, for a scanned photo
 * or a scan-flow photo, the scan id from `photoIds`/`scanIds`) AND appends a
 * tombstone `{ photoId, scanId, deletedAt }` to the ASSESSMENT. The tombstone
 * is what makes the delete stick:
 *
 *   • asNormaliseAssessment strips every tombstoned attachment / scan id, so a
 *     restored or stale copy of the record cannot re-show the photo;
 *   • importRecords UNIONS the tombstones of the local and remote copies, so a
 *     delete made on either device survives whichever copy wins the merge;
 *   • attachScan refuses a tombstoned scan id or photo id, so the room screen's
 *     append-retry (a scan still carrying `assessmentRef`) cannot re-link it;
 *   • SyncService skips (and server-deletes) any `component-photos/<id>` whose
 *     id is tombstoned in a local or restored assessment.
 *
 * The SCAN record is never touched: a scanned photo's scan stays in scan
 * history (it is a scan in its own right); only its link to the component goes.
 * Contract: mould-detect-sync-lambda/CLIENT-CONTRACT.md "Component photos".
 */
/*
 * Tombstone caps (code review Q-01 F10; CLIENT-CONTRACT.md R5, 1.0.1): 2,000
 * per list per visit, oldest dropped first. Was 500, which a heavy visit could
 * pass, letting a stale copy on another device resurrect the oldest deletes.
 * Why a cap at all, and why not age-out: a tombstone is the ONLY thing that
 * stops a stale copy resurrecting a delete (R5 "a delete is final"), and this
 * store cannot know when every other copy has seen it — the cloud holding the
 * record does not mean a laptop that was offline for a month has. So entries
 * are never aged out; the cap is only a size bound on the record. At ~150
 * bytes per `deletedPhotos` entry and ~80 per `deletedItems` entry, both lists
 * full add under 0.5 MiB to `record.json` — inside a ~1 MiB JSON ceiling the
 * Lambda may impose per object (Q-01 F4). 2,000 deletions on ONE visit is far
 * past any real inspection, so in practice nothing is ever dropped.
 */
var AS_DELETED_PHOTOS_MAX = 2000;

function asNormaliseDeletedPhotos(value) {
    var list = (Object.prototype.toString.call(value) === '[object Array]') ? value : [];
    var out = [];
    var seen = {};
    for (var i = 0; i < list.length; i++) {
        var raw = list[i];
        if (!raw || typeof raw !== 'object') continue;
        var photoId = asStrOrNull(raw.photoId, AS_SHORT_MAX);
        var scanId = asStrOrNull(raw.scanId, AS_SHORT_MAX);
        if (!photoId && !scanId) continue;
        var key = (photoId || '') + '|' + (scanId || '');
        if (seen[key]) continue;
        seen[key] = true;
        var deletedAt = asStr(raw.deletedAt, 40);
        if (!deletedAt || !isFinite(Date.parse(deletedAt))) deletedAt = asNow();
        out.push({ photoId: photoId, scanId: scanId, deletedAt: deletedAt });
    }
    // Oldest dropped first past the cap: a tombstone matters most while copies
    // that predate the delete are still around.
    out.sort(function (a, b) { return Date.parse(a.deletedAt) - Date.parse(b.deletedAt); });
    return out.length > AS_DELETED_PHOTOS_MAX ? out.slice(out.length - AS_DELETED_PHOTOS_MAX) : out;
}

/** asDeletedPhotoIndex — { photoIds: {id:true}, scanIds: {id:true} } for a record. */
function asDeletedPhotoIndex(record) {
    var index = { photoIds: {}, scanIds: {} };
    var list = (record && record.deletedPhotos) || [];
    for (var i = 0; i < list.length; i++) {
        if (list[i].photoId) index.photoIds[list[i].photoId] = true;
        if (list[i].scanId) index.scanIds[list[i].scanId] = true;
    }
    return index;
}

/** asApplyDeletedPhotos — strip tombstoned photos from a normalised record, in place. */
function asApplyDeletedPhotos(record) {
    if (!record || !record.deletedPhotos || record.deletedPhotos.length === 0) return record;
    var index = asDeletedPhotoIndex(record);
    var keepScan = function (id) { return !index.scanIds[id]; };
    for (var r = 0; r < record.rooms.length; r++) {
        var comps = record.rooms[r].components || [];
        for (var c = 0; c < comps.length; c++) {
            comps[c].attachments = comps[c].attachments.filter(function (a) { return !index.photoIds[a.id]; });
            comps[c].photoIds = comps[c].photoIds.filter(keepScan);
            comps[c].scanIds = comps[c].scanIds.filter(keepScan);
        }
    }
    return record;
}

/** asUnionDeletedPhotos — both copies' tombstones, normalised (dedupe + cap). */
function asUnionDeletedPhotos(a, b) {
    return asNormaliseDeletedPhotos(((a && a.deletedPhotos) || []).concat((b && b.deletedPhotos) || []));
}

/* ── Deleted rooms and samples: tombstones (2026-10-05, contract 1.0) ─────────
 *
 * The cloud merge (asMergeAssessment) UNIONS rooms and samples by id, so a room
 * added on the phone survives an office edit made on the desktop at the same
 * time. A union on its own would also bring back a room or sample that one
 * device deliberately removed, so removeRoom/removeSample leave a tombstone
 * `{ kind: 'room'|'sample', id, deletedAt }` in `deletedItems[]` — the same
 * shape and the same rules as deletedPhotos:
 *
 *   • normalise strips every tombstoned room, every tombstoned sample, and
 *     every sample whose room is tombstoned (an orphan sample would read as a
 *     pending lab result nothing can open);
 *   • merge UNIONS both copies' tombstones, whichever copy wins;
 *   • a delete is final: there is no un-delete, and ids are never reused.
 *
 * Additive field → `v` stays 1. Contract: CLIENT-CONTRACT.md "Restore &
 * conflict rules" (R5).
 */
var AS_DELETED_ITEMS_MAX = 2000;   // same reasoning as AS_DELETED_PHOTOS_MAX (Q-01 F10)
var AS_DELETED_ITEM_KINDS = ['room', 'sample'];

function asNormaliseDeletedItems(value) {
    var list = (Object.prototype.toString.call(value) === '[object Array]') ? value : [];
    var out = [];
    var seen = {};
    for (var i = 0; i < list.length; i++) {
        var raw = list[i];
        if (!raw || typeof raw !== 'object') continue;
        var kind = asOneOf(raw.kind, AS_DELETED_ITEM_KINDS);
        var id = asStr(raw.id, AS_SHORT_MAX);
        if (!kind || !id) continue;
        var key = kind + '|' + id;
        if (seen[key]) continue;
        seen[key] = true;
        var deletedAt = asStr(raw.deletedAt, 40);
        if (!deletedAt || !isFinite(Date.parse(deletedAt))) deletedAt = asNow();
        out.push({ kind: kind, id: id, deletedAt: deletedAt });
    }
    out.sort(function (a, b) { return Date.parse(a.deletedAt) - Date.parse(b.deletedAt); });
    return out.length > AS_DELETED_ITEMS_MAX ? out.slice(out.length - AS_DELETED_ITEMS_MAX) : out;
}

/** asApplyDeletedItems — strip tombstoned rooms and samples from a normalised record, in place. */
function asApplyDeletedItems(record) {
    if (!record || !record.deletedItems || record.deletedItems.length === 0) return record;
    var rooms = {};
    var samples = {};
    for (var i = 0; i < record.deletedItems.length; i++) {
        if (record.deletedItems[i].kind === 'room') rooms[record.deletedItems[i].id] = true;
        else samples[record.deletedItems[i].id] = true;
    }
    record.rooms = record.rooms.filter(function (r) { return !rooms[r.id]; });
    record.samples = record.samples.filter(function (s) { return !samples[s.id] && !(s.roomId && rooms[s.roomId]); });
    return record;
}

/** asUnionDeletedItems — both copies' room/sample tombstones, normalised. */
function asUnionDeletedItems(a, b) {
    return asNormaliseDeletedItems(((a && a.deletedItems) || []).concat((b && b.deletedItems) || []));
}

/**
 * asNextStamp — the `updatedAt` for a write to a record whose current stamp is
 * `prevIso`: now, but never earlier than 1 ms after the stamp it replaces.
 *
 * Why (clock skew, contract R2): every merge orders copies by a stamp written
 * by a DEVICE clock. A phone whose clock runs ten minutes slow would otherwise
 * stamp an edit it made AFTER restoring the desktop's copy as older than that
 * copy, and the edit would lose every later merge. With this rule an edit made
 * after seeing a version always sorts after it, whatever the clock says.
 */
function asNextStamp(prevIso) {
    var now = Date.now();
    var prev = (typeof prevIso === 'string') ? Date.parse(prevIso) : NaN;
    return new Date((isFinite(prev) && prev >= now) ? prev + 1 : now).toISOString();
}

/**
 * asComponentPhotoItems — every photo on a component, ONE entry per picture:
 *   { photoId: string|null, scanId: string|null, createdAt: ISO|null }
 *
 * Attachments first-class (their scanId, if any, is the scan made FROM them);
 * then every D3a scan id that no attachment claims (a photo taken through the
 * scan flow, or a legacy record). A scanned attachment is therefore never
 * counted twice. The room counters, the revision diff, the sheet's strip and
 * the report all read this, so they cannot disagree about how many photos a
 * component has.
 */
function asComponentPhotoItems(comp) {
    var out = [];
    var claimed = {};
    var attachments = asNormaliseAttachments(comp && comp.attachments);
    for (var i = 0; i < attachments.length; i++) {
        out.push({ photoId: attachments[i].id, scanId: attachments[i].scanId, createdAt: attachments[i].createdAt });
        if (attachments[i].scanId) claimed[attachments[i].scanId] = true;
    }
    var ids = asComponentPhotoIds(comp);
    for (var s = 0; s < ids.length; s++) {
        if (claimed[ids[s]]) continue;
        out.push({ photoId: null, scanId: ids[s], createdAt: null });
    }
    return out;
}

/**
 * asPrepareComponentPhoto(file) — downscale an uploaded image to the stored
 * copy and read its metadata. Resolves { dataURL, thumbnail, mime, exif };
 * rejects with a plain-English message for a file that is not an image.
 * The EXIF read runs against the ORIGINAL file (the compressed copy has none),
 * so a later scan of this photo still gets its captureContext.
 */
function asPrepareComponentPhoto(file) {
    if (!file || !file.type || file.type.indexOf('image/') !== 0) {
        return Promise.reject(new Error('Please choose a photo (JPEG, PNG or HEIC).'));
    }
    var exifPromise = (typeof ExifService !== 'undefined' && ExifService.extract)
        ? ExifService.extract(file)['catch'](function () { return null; })
        : Promise.resolve(null);
    var readPromise = new Promise(function (resolve, reject) {
        var reader = new FileReader();
        reader.onload = function (e) { resolve(e.target.result); };
        reader.onerror = function () { reject(new Error('Could not open this photo. Please try another.')); };
        reader.readAsDataURL(file);
    });
    return readPromise.then(function (original) {
        return Promise.all([
            ImageUtils.compressToJpeg(original, AS_PHOTO_MAX_DIM, AS_PHOTO_QUALITY),
            exifPromise
        ]);
    }).then(function (results) {
        var dataURL = results[0];
        var thumbP = (typeof ThumbnailService !== 'undefined' && ThumbnailService.generate)
            // 400 px: the sheet shows photos as ~170 px cards on 2–3× screens.
            ? ThumbnailService.generate(dataURL, 400, 0.75)['catch'](function () { return ''; })
            : Promise.resolve('');
        return thumbP.then(function (thumb) {
            return { dataURL: dataURL, thumbnail: thumb || '', mime: 'image/jpeg', exif: results[1] || null };
        });
    });
}

/**
 * useComponentPhotoImages(ids) — { photoId: { thumbnail, dataURL } | null }
 * for the given attachment ids, read from their own IndexedDB keys. `null`
 * means "looked, and this device has no image" (e.g. after a cloud restore,
 * which carries the metadata but not the pixels); a missing key means "still
 * loading". Never throws.
 */
var useComponentPhotoImages = function (ids) {
    var list = ids || [];
    var key = list.join('|');
    var st = _useState_AS({});
    var images = st[0];
    var setImages = st[1];
    // A cloud restore writes images under existing ids (2026-10-05), which the
    // `ids` key alone would never notice; SyncService fires this event after
    // saving them, and the bump re-reads.
    var bumpState = _useState_AS(0);
    var bump = bumpState[0];
    var setBump = bumpState[1];
    _useEffect_AS(function () {
        var onChanged = function () { setBump(function (n) { return n + 1; }); };
        window.addEventListener(AS_PHOTOS_CHANGED_EVENT, onChanged);
        return function () { window.removeEventListener(AS_PHOTOS_CHANGED_EVENT, onChanged); };
    }, []);
    _useEffect_AS(function () {
        var cancelled = false;
        if (!list.length || typeof StorageService === 'undefined' || !StorageService.loadComponentPhoto) return;
        Promise.all(list.map(function (id) {
            return StorageService.loadComponentPhoto(id)['catch'](function () { return null; });
        })).then(function (records) {
            if (cancelled) return;
            var next = {};
            for (var i = 0; i < list.length; i++) {
                var r = records[i];
                next[list[i]] = (r && typeof r.dataURL === 'string')
                    ? { thumbnail: r.thumbnail || r.dataURL, dataURL: r.dataURL }
                    : null;
            }
            setImages(next);
        });
        return function () { cancelled = true; };
    }, [key, bump]);
    return images;
};

/**
 * asNormaliseAssessmentRef — the `assessmentRef` block written onto a Scan when
 * the photo was taken FROM a component (ADR-009 D3, slice 3).
 *
 * Same contract as every other snapshot on a scan record: versioned `v:1`, JSON
 * primitives only, never `undefined`, so it rides the opaque S3 cloud sync and
 * the Swift `Codable` port unchanged. Returns null for anything incomplete —
 * a ref that names no room or no component would attach a scan to nothing.
 */
function asNormaliseAssessmentRef(raw) {
    if (!raw || typeof raw !== 'object') return null;
    var assessmentId = asStr(raw.assessmentId, AS_SHORT_MAX);
    var roomId       = asStr(raw.roomId, AS_SHORT_MAX);
    var componentKey = asOneOf(raw.componentKey, AS_COMPONENT_KEYS);
    if (!assessmentId || !roomId || !componentKey) return null;
    // photoId (2026-10-05): the component attachment this scan was made FROM
    // ("Scan for mould" on an uploaded photo), else null. Additive and
    // nullable, so v stays 1 and older records read as "taken through the
    // scan flow".
    return { v: 1, assessmentId: assessmentId, roomId: roomId, componentKey: componentKey,
             photoId: asStrOrNull(raw.photoId, AS_SHORT_MAX) };
}

/**
 * asGenusConfirmationFromSample — the `genusLabConfirmation` write-through
 * (brief §3, slice 3).
 *
 * When a lab result names a genus and the sample was taken against a component
 * photo, the same fact is written onto that Scan in the EXACT shape
 * GenusFeedback writes today — `{v, confirmedGenus, lab, confirmedAt, notes,
 * recordedAt}` — so the genus classifier's ground-truth set keeps filling from
 * real inspections through one shape, not two.
 *
 * Returns null when there is nothing to write (no genus, or no scan to write
 * it against). The caller treats a null as "nothing to do": the sample result
 * is saved either way, and this write never blocks it.
 */
function asGenusConfirmationFromSample(sample, result) {
    if (!sample || typeof sample !== 'object') return null;
    if (!asStr(sample.scanId, AS_SHORT_MAX)) return null;
    var src = (result && typeof result === 'object') ? result : (sample.result || {});
    var genus = asStr(src.genus, AS_SHORT_MAX);
    if (!genus) return null;
    var lab = asStr(sample.lab, AS_SHORT_MAX);
    var notes = asStr(src.notes, AS_TEXT_MAX);
    return {
        v: 1,
        confirmedGenus: genus,
        lab: lab ? lab : null,
        confirmedAt: asStr(src.resultReceivedAt, 40) || asStr(sample.resultReceivedAt, 40) || asNow(),
        notes: notes ? notes : null,
        recordedAt: asNow()
    };
}

/**
 * asNormaliseTeamMember — one additional assessor on the visit (slice 5).
 *
 * A member with no name is dropped: a certificate number with nobody attached
 * to it names nobody, and the report and the CSV both print the name.
 */
function asNormaliseTeamMember(raw) {
    if (!raw || typeof raw !== 'object') return null;
    var name = asStr(raw.name, AS_SHORT_MAX);
    if (!name) return null;
    return {
        name:   name,
        certNo: asStr(raw.certNo, AS_SHORT_MAX),
        role:   asOneOf(raw.role, AS_TEAM_ROLES) || 'assessor'
    };
}

/** asNormaliseTeam — the visit's additional assessors, capped at AS_TEAM_MAX. */
function asNormaliseTeam(raw) {
    var out = [];
    if (!raw || Object.prototype.toString.call(raw) !== '[object Array]') return out;
    for (var i = 0; i < raw.length && out.length < AS_TEAM_MAX; i++) {
        var member = asNormaliseTeamMember(raw[i]);
        if (member) out.push(member);
    }
    return out;
}

/**
 * asRoomAssessor — who assessed this room (slice 5).
 *
 * `room.assessorName` is a REFERENCE, not a copy: `null` means "the visit
 * lead", and it is resolved at READ time. Copying the lead's name into every
 * room at write time would leave a three-room visit disagreeing with itself the
 * moment the lead's name is corrected in the header.
 */
function asRoomAssessor(assessment, room) {
    var named = asStr(room && room.assessorName, AS_SHORT_MAX);
    if (named) return named;
    return asStr(assessment && assessment.visit && assessment.visit.assessorName, AS_SHORT_MAX);
}

/**
 * asVisitAssessorNames — the lead followed by the team, de-duplicated: the list
 * the room screen's "Assessed by" select offers. The lead is always first and
 * always present, even on a visit with no team.
 */
function asVisitAssessorNames(assessment) {
    var out = [];
    var seen = {};
    var visit = (assessment && assessment.visit) || {};
    var lead = asStr(visit.assessorName, AS_SHORT_MAX);
    if (lead) { out.push(lead); seen[lead] = true; }
    var team = (visit.team && Object.prototype.toString.call(visit.team) === '[object Array]') ? visit.team : [];
    for (var i = 0; i < team.length; i++) {
        var name = asStr(team[i] && team[i].name, AS_SHORT_MAX);
        if (name && !seen[name]) { out.push(name); seen[name] = true; }
    }
    return out;
}

/** asNewRoom — a fresh room (§3), with the id the visit screen navigates to. */
function asNewRoom(name) {
    return {
        id:           asGenerateId(),
        name:         asStr(name, AS_SHORT_MAX),
        // null = "the visit lead", resolved at read time (asRoomAssessor).
        assessorName: null,
        direction:    null,
        odour:        0,
        odourSource:  '',
        ventilation:  '',
        components:   []
    };
}

/** asNormaliseRoom — strict allowlist for one room. */
function asNormaliseRoom(raw, backfillMs) {
    if (!raw || typeof raw !== 'object') return null;
    var components = [];
    var srcComponents = (raw.components && Object.prototype.toString.call(raw.components) === '[object Array]') ? raw.components : [];
    for (var i = 0; i < srcComponents.length; i++) {
        var c = asNormaliseComponent(srcComponents[i], backfillMs);
        if (c) components.push(c);
    }
    return {
        id:           asStr(raw.id, AS_SHORT_MAX) || asGenerateId(),
        name:         asStr(raw.name, AS_SHORT_MAX),
        assessorName: asStrOrNull(raw.assessorName, AS_SHORT_MAX),
        direction:    asOneOf(raw.direction, AS_DIRECTIONS),
        odour:        asScore(raw.odour),
        odourSource:  asStr(raw.odourSource, AS_TEXT_MAX),
        ventilation:  asOneOf(raw.ventilation, AS_VENTILATION) || '',
        components:   components
    };
}

/** asNormaliseSample — one lab sample (§3). `result` stays null until the lab reports. */
function asNormaliseSample(raw) {
    if (!raw || typeof raw !== 'object') return null;
    var result = null;
    if (raw.result && typeof raw.result === 'object') {
        result = {
            genus:          asStr(raw.result.genus, AS_SHORT_MAX),
            speciesText:    asStr(raw.result.speciesText, AS_SHORT_MAX),
            sporeCount:     asNumOrNull(raw.result.sporeCount),
            unit:           asStr(raw.result.unit, AS_SHORT_MAX),
            interpretation: asStr(raw.result.interpretation, AS_TEXT_MAX),
            notes:          asStr(raw.result.notes, AS_TEXT_MAX)
        };
    }
    return {
        id:              asStr(raw.id, AS_SHORT_MAX) || asGenerateId(),
        roomId:          asStrOrNull(raw.roomId, AS_SHORT_MAX),
        componentKey:    asOneOf(raw.componentKey, AS_COMPONENT_KEYS),
        scanId:          asStrOrNull(raw.scanId, AS_SHORT_MAX),
        sampleType:      asOneOf(raw.sampleType, AS_SAMPLE_TYPES) || 'other',
        sampleRef:       asStr(raw.sampleRef, AS_SHORT_MAX),
        collectedAt:     asStrOrNull(raw.collectedAt, 40),
        lab:             asStr(raw.lab, AS_SHORT_MAX),
        sentAt:          asStrOrNull(raw.sentAt, 40),
        resultReceivedAt: asStrOrNull(raw.resultReceivedAt, 40),
        result:          result
    };
}

/**
 * asRevisionValue — a before/after value as a JSON primitive (ADR-009 D10).
 * string | number | boolean | null only: arrays (materials, the team) become a
 * comma list, long strings are cut at AS_REVISION_VALUE_MAX with an ellipsis,
 * a non-finite number or anything else becomes null. '' stays '' (an emptied
 * field is a real value; the renderer shows it as "—").
 */
function asRevisionValue(value) {
    if (value === null || value === undefined) return null;
    if (typeof value === 'boolean') return value;
    if (typeof value === 'number') return isFinite(value) ? value : null;
    if (Object.prototype.toString.call(value) === '[object Array]') {
        var parts = [];
        for (var i = 0; i < value.length; i++) {
            if (typeof value[i] === 'string' && value[i]) parts.push(value[i]);
            else if (typeof value[i] === 'number' && isFinite(value[i])) parts.push(String(value[i]));
        }
        value = parts.join(', ');
    }
    if (typeof value !== 'string') return null;
    var s = value.replace(/\s+/g, ' ').replace(/^\s+|\s+$/g, '');
    return (s.length > AS_REVISION_VALUE_MAX) ? s.slice(0, AS_REVISION_VALUE_MAX - 1) + '…' : s;
}

/**
 * asNormaliseRevision — one amendment stamp (§3, decision 1; field-level since
 * ADR-009 D10). The four D10 keys are additive and nullable: a stamp written
 * before them reads back `field: null, label: '', from: null, to: null` and
 * renders as its category plus the best target name we can still resolve.
 */
function asNormaliseRevision(raw) {
    if (!raw || typeof raw !== 'object') return null;
    var at = asStrOrNull(raw.at, 40);
    if (!at) return null;
    return {
        at:    at,
        what:  asOneOf(raw.what, AS_REVISION_WHATS) || 'visit',
        ref:   asStr(raw.ref, AS_SHORT_MAX),
        by:    asStr(raw.by, AS_SHORT_MAX),
        field: asStrOrNull(raw.field, 60),
        label: asStr(raw.label, AS_SHORT_MAX),
        from:  asRevisionValue(raw.from),
        to:    asRevisionValue(raw.to)
    };
}

/** asDefaultAssessment — an empty draft. Never shared by reference. */
function asDefaultAssessment(propertyId) {
    var now = asNow();
    return {
        v: AS_ASSESSMENT_VERSION,
        id: asGenerateId(),
        propertyId: asStrOrNull(propertyId, AS_SHORT_MAX),
        // Set only by the one-shot legacy migration (ADR-009 D5); the field
        // exists on every record so the shape never changes underneath sync.
        legacyScanId: null,
        visit: {
            date: '',
            assessorName: '',
            assessorCertNo: '',
            building: '',
            buildingAge: '',
            floor: '',
            // Slice 5 — the ADDITIONAL assessors on this visit; the lead is
            // `assessorName` above and is never a row in here.
            team: []
        },
        rooms: [],
        // Manual DMAT device readings (Section D) — null until the inspector
        // enters one (updateReadings, slice 2). Distinct from the automatic
        // weatherSnapshot/airSnapshot on a Scan; never merge them (CLAUDE.md).
        environmentalReadings: null,
        samples: [],
        revisions: [],
        // Deleted component photos (2026-10-05): tombstones, so a cloud restore
        // or another device's older copy can never bring a deleted photo back.
        // See asNormaliseDeletedPhotos.
        deletedPhotos: [],
        // Removed rooms and samples (2026-10-05, contract 1.0): tombstones, so
        // the cloud merge's room/sample union never brings a removed one back.
        // See asNormaliseDeletedItems.
        deletedItems: [],
        remediation: {
            riskClassification: '',
            method: '',
            containmentType: '',
            ppeLevel: '',
            affectedAreaM2: null
        },
        signoff: {
            signedAt: null,
            technicianName: '',
            technicianCertNo: ''
        },
        status: 'draft',
        completedAt: null,
        amendedAt: null,
        createdAt: now,
        updatedAt: now
    };
}

/**
 * asNormaliseAssessment — build a record field by field from an arbitrary
 * object. This is THE allowlist writer (§3): unknown fields are dropped,
 * `undefined` becomes `null` or '', enums are validated, and the result is
 * always a complete, JSON-primitive-only record at the current version.
 *
 * It is also the deep-copy used by every writer, so no caller can accidentally
 * mutate stored state.
 */
function asNormaliseAssessment(raw) {
    var out = asDefaultAssessment(null);
    if (!raw || typeof raw !== 'object') return out;

    out.id           = asStr(raw.id, AS_SHORT_MAX) || out.id;
    out.propertyId   = asStrOrNull(raw.propertyId, AS_SHORT_MAX);
    out.legacyScanId = asStrOrNull(raw.legacyScanId, AS_SHORT_MAX);

    var visit = (raw.visit && typeof raw.visit === 'object') ? raw.visit : {};
    out.visit.date           = asStr(visit.date, 40);
    out.visit.assessorName   = asStr(visit.assessorName, AS_SHORT_MAX);
    out.visit.assessorCertNo = asStr(visit.assessorCertNo, AS_SHORT_MAX);
    out.visit.building       = asStr(visit.building, AS_SHORT_MAX);
    out.visit.buildingAge    = asStr(visit.buildingAge, AS_SHORT_MAX);
    out.visit.floor          = asStr(visit.floor, AS_SHORT_MAX);
    out.visit.team           = asNormaliseTeam(visit.team);

    // Records written before `editedAt` existed carry components WITHOUT the
    // key; asEditedAtOf backfills the ones with positive evidence from this
    // stamp. Additive nullable field → `v` stays 1 (the slice-5 rule, D9).
    var backfillMs = asBackfillMs(raw);
    var rooms = (raw.rooms && Object.prototype.toString.call(raw.rooms) === '[object Array]') ? raw.rooms : [];
    for (var i = 0; i < rooms.length; i++) {
        var room = asNormaliseRoom(rooms[i], backfillMs);
        if (room) out.rooms.push(room);
    }

    if (raw.environmentalReadings && typeof raw.environmentalReadings === 'object') {
        var readings = {};
        for (var r = 0; r < AS_READING_KEYS.length; r++) {
            readings[AS_READING_KEYS[r]] = asStr(raw.environmentalReadings[AS_READING_KEYS[r]], AS_SHORT_MAX);
        }
        out.environmentalReadings = readings;
    }

    var samples = (raw.samples && Object.prototype.toString.call(raw.samples) === '[object Array]') ? raw.samples : [];
    for (var s = 0; s < samples.length; s++) {
        var sample = asNormaliseSample(samples[s]);
        if (sample) out.samples.push(sample);
    }

    var revisions = (raw.revisions && Object.prototype.toString.call(raw.revisions) === '[object Array]') ? raw.revisions : [];
    for (var v = 0; v < revisions.length; v++) {
        var rev = asNormaliseRevision(revisions[v]);
        if (rev) out.revisions.push(rev);
    }

    // Tombstones are applied on EVERY normalise, so whichever path a record
    // arrives by (a restore, an older backup, a stale tab) a deleted photo
    // cannot reappear on a component. Additive field → `v` stays 1.
    out.deletedPhotos = asNormaliseDeletedPhotos(raw.deletedPhotos);
    asApplyDeletedPhotos(out);
    // Removed rooms/samples, same contract (CLIENT-CONTRACT.md R5).
    out.deletedItems = asNormaliseDeletedItems(raw.deletedItems);
    asApplyDeletedItems(out);

    var rem = (raw.remediation && typeof raw.remediation === 'object') ? raw.remediation : {};
    out.remediation.riskClassification = asStr(rem.riskClassification, AS_SHORT_MAX);
    out.remediation.method             = asStr(rem.method, AS_SHORT_MAX);
    out.remediation.containmentType    = asStr(rem.containmentType, AS_SHORT_MAX);
    out.remediation.ppeLevel           = asStr(rem.ppeLevel, AS_SHORT_MAX);
    out.remediation.affectedAreaM2     = asNumOrNull(rem.affectedAreaM2);

    var sign = (raw.signoff && typeof raw.signoff === 'object') ? raw.signoff : {};
    out.signoff.signedAt         = asStrOrNull(sign.signedAt, 40);
    out.signoff.technicianName   = asStr(sign.technicianName, AS_SHORT_MAX);
    out.signoff.technicianCertNo = asStr(sign.technicianCertNo, AS_SHORT_MAX);

    out.completedAt = asStrOrNull(raw.completedAt, 40);
    out.amendedAt   = asStrOrNull(raw.amendedAt, 40);
    out.createdAt   = asStrOrNull(raw.createdAt, 40) || out.createdAt;
    out.updatedAt   = asStrOrNull(raw.updatedAt, 40) || out.updatedAt;

    // status is derived-checked, not trusted: an "amended" record with no
    // completedAt is nonsense, and a stored "complete" with no completedAt
    // would make the UI offer "Reopen" on a draft.
    var status = asOneOf(raw.status, AS_STATUSES) || 'draft';
    if (!out.completedAt) status = 'draft';
    out.status = status;

    return out;
}

/**
 * asMigrateAssessment — the migration hook (§3). Switch on the stored `v`;
 * today 1 → 1 is the identity normalisation. Corrupt records and records from a
 * future build return **null** — the caller drops them with a console.warn.
 * Unlike the single-record profile store there is no "safe default" to fall back
 * to here: inventing an empty visit in place of a record we cannot read would
 * look to the inspector like the visit was wiped.
 */
function asMigrateAssessment(raw) {
    if (!raw || typeof raw !== 'object') return null;
    var v = (typeof raw.v === 'number') ? raw.v : 0;
    switch (v) {
        case 1:
            return asNormaliseAssessment(raw);
        default:
            return null;
    }
}

/* ── The two-device merge (contract 1.0, CLIENT-CONTRACT.md R4) ────────────── */

var AS_STATUS_RANK = { draft: 0, complete: 1, amended: 2 };

/** asMergeUnionIds — `a` then every id of `b` that `a` lacks (strings). */
function asMergeUnionIds(a, b) {
    var out = (a || []).slice();
    for (var i = 0; i < (b || []).length; i++) if (out.indexOf(b[i]) === -1) out.push(b[i]);
    return out;
}

/** asMergeComponent — one component row present on both copies. */
function asMergeComponent(base, other) {
    // The copy edited last wins the FINDINGS (scores, flags, materials, notes,
    // area): `editedAt` is stamped per component on every finding edit
    // (ADR-011), so two inspectors scoring different components of the same
    // room both keep their work. A tie (or neither edited) keeps the base row.
    var pick = ((other.editedAt || 0) > (base.editedAt || 0)) ? other : base;
    var out = JSON.parse(JSON.stringify(pick));
    // Photos are evidence, never a finding: union them, whichever row won.
    var attachments = (base.attachments || []).slice();
    var seen = {};
    for (var i = 0; i < attachments.length; i++) seen[attachments[i].id] = true;
    for (var j = 0; j < (other.attachments || []).length; j++) {
        if (!seen[other.attachments[j].id]) { attachments.push(other.attachments[j]); seen[other.attachments[j].id] = true; }
    }
    out.attachments = JSON.parse(JSON.stringify(attachments));
    out.photoIds = asMergeUnionIds(base.photoIds, other.photoIds);
    out.scanIds = asMergeUnionIds(base.scanIds, other.scanIds);
    return out;
}

/**
 * asMergeRevisions — the union of two amendment trails (ADR-012).
 *
 * An audit trail never loses an entry, so both copies' stamps are kept, in
 * time order, exact duplicates once. One subtlety: ADR-012 coalesces repeated
 * edits of the same field by the same person inside two minutes into ONE stamp
 * that keeps the FIRST `from` and moves `at`. A copy taken mid-window still
 * holds the earlier, un-coalesced stamp; it is recognisable as the same edit
 * (same what/ref/field/by and the same `from`, under two minutes earlier than
 * the other) and is dropped, so the union reads exactly like one device's log.
 */
function asMergeRevisions(a, b) {
    var all = (a || []).concat(b || []);
    var exact = {};
    var list = [];
    for (var i = 0; i < all.length; i++) {
        var key = JSON.stringify([all[i].at, all[i].what, all[i].ref, all[i].field, all[i].by, all[i].from, all[i].to]);
        if (exact[key]) continue;
        exact[key] = true;
        list.push(all[i]);
    }
    list = list.map(function (r, n) { return { r: r, n: n, t: Date.parse(r.at) || 0 }; })
        .sort(function (x, y) { return (x.t - y.t) || (x.n - y.n); })
        .map(function (x) { return x.r; });
    var out = [];
    for (var k = 0; k < list.length; k++) {
        var s = list[k];
        var superseded = false;
        if (s.field && AS_REVISION_EVENTS.indexOf(s.field) === -1) {
            for (var m = k + 1; m < list.length; m++) {
                var later = list[m];
                var gap = (Date.parse(later.at) || 0) - (Date.parse(s.at) || 0);
                if (gap >= AS_REVISION_COALESCE_MS) break;
                if (later.what === s.what && later.ref === s.ref && later.field === s.field &&
                    later.by === s.by && later.from === s.from) { superseded = true; break; }
            }
        }
        if (!superseded) out.push(s);
    }
    return JSON.parse(JSON.stringify(out));
}

/**
 * asMergeAssessment(local, remote) — the ONE merge of two copies of the same
 * visit. Pure and deterministic; SyncService's backup and AssessmentStore's
 * importer both call it, and the Swift port copies it (shared fixture:
 * the shared sync-merge fixture under shared-tests). Rules, in order (contract R4):
 *
 *   1. BASE = the copy with the strictly newer `updatedAt` (a tie keeps
 *      `local`). Every scalar — visit header, readings, remediation, sign-off
 *      names, room names — comes from the base. Last writer wins.
 *   2. TOMBSTONES (deletedPhotos, deletedItems) are unioned: a delete on either
 *      device stays deleted.
 *   3. ROOMS and SAMPLES are unioned by id, minus tombstones: work added on
 *      either device survives. A room on both copies keeps the base's room
 *      fields; its components merge per key by `editedAt` (newest findings
 *      win), with photos (attachments, photoIds, scanIds) unioned.
 *      A sample on both copies: a reported result beats "pending" (a lab result
 *      never un-arrives); otherwise the base's copy.
 *   4. REVISIONS (the amendment trail) are unioned (asMergeRevisions).
 *   5. COMPLETION is one-way: completedAt and a signature (`signoff.signedAt`)
 *      present on either copy survive; status is the higher of the two on
 *      draft < complete < amended (and `amended` when the merged trail is
 *      non-empty); amendedAt is the newest of the two.
 *   6. `updatedAt` = the newer of the two. The merge is not an edit and does
 *      not stamp "now".
 *
 * Either side unreadable (null, or a `v` this build does not know) ⇒ the other
 * side unchanged. Returns a normalised record.
 */
function asMergeAssessment(local, remote) {
    var l = asMigrateAssessment(local);
    var r = asMigrateAssessment(remote);
    if (!l) return r;
    if (!r) return l;
    var remoteWins = (Date.parse(r.updatedAt) || 0) > (Date.parse(l.updatedAt) || 0);
    var base = asNormaliseAssessment(remoteWins ? r : l);     // a deep copy
    var other = remoteWins ? l : r;

    base.deletedPhotos = asUnionDeletedPhotos(base, other);
    base.deletedItems = asUnionDeletedItems(base, other);

    // Rooms: union by id; a room on both merges its components.
    var i, j;
    for (i = 0; i < other.rooms.length; i++) {
        var theirs = other.rooms[i];
        var mine = asFindById(base.rooms, theirs.id);
        if (!mine) { base.rooms.push(JSON.parse(JSON.stringify(theirs))); continue; }
        for (j = 0; j < theirs.components.length; j++) {
            var tc = theirs.components[j];
            var found = -1;
            for (var c = 0; c < mine.components.length; c++) if (mine.components[c].key === tc.key) { found = c; break; }
            if (found === -1) mine.components.push(JSON.parse(JSON.stringify(tc)));
            else mine.components[found] = asMergeComponent(mine.components[found], tc);
        }
    }

    // Samples: union by id; on both, a reported result beats pending.
    for (i = 0; i < other.samples.length; i++) {
        var os = other.samples[i];
        var idx = -1;
        for (j = 0; j < base.samples.length; j++) if (base.samples[j].id === os.id) { idx = j; break; }
        if (idx === -1) base.samples.push(JSON.parse(JSON.stringify(os)));
        else if (!base.samples[idx].result && os.result) base.samples[idx] = JSON.parse(JSON.stringify(os));
    }

    base.revisions = asMergeRevisions(base.revisions, other.revisions);

    // Completion is one-way.
    if (!base.completedAt && other.completedAt) base.completedAt = other.completedAt;
    if (!base.signoff.signedAt && other.signoff.signedAt) base.signoff = JSON.parse(JSON.stringify(other.signoff));
    var status = (AS_STATUS_RANK[other.status] > AS_STATUS_RANK[base.status]) ? other.status : base.status;
    if (base.completedAt && base.revisions.length > 0) status = 'amended';
    if (base.completedAt && status === 'draft') status = 'complete';
    base.status = status;
    // amendedAt is the newest surviving stamp (ADR-012); with no trail, the
    // newer of the two copies' values.
    if (base.completedAt && base.revisions.length > 0) {
        var newest = base.revisions[0].at;
        for (i = 1; i < base.revisions.length; i++) if (Date.parse(base.revisions[i].at) > Date.parse(newest)) newest = base.revisions[i].at;
        base.amendedAt = newest;
    } else if (other.amendedAt && (!base.amendedAt || Date.parse(other.amendedAt) > Date.parse(base.amendedAt))) {
        base.amendedAt = other.amendedAt;
    }

    // The merge is not an edit: the newer stamp, never "now".
    if ((Date.parse(other.updatedAt) || 0) > (Date.parse(base.updatedAt) || 0)) base.updatedAt = other.updatedAt;

    // Normalise last: re-applies every tombstone (rooms, samples, photos).
    return asNormaliseAssessment(base);
}

/**
 * asDeriveSummary — the numbers every list row and card shows, derived, never
 * stored (§3: "Report is derived, not stored").
 *
 *   roomCount        — rooms on the visit
 *   componentsScored — components across all rooms that have been assessed,
 *                      per asComponentScored (present AND a score, notes or
 *                      materials). Counts subfloor too: this number measures
 *                      the whole visit, unlike a room row's n/8.
 *   pendingSamples   — samples collected whose lab result has not arrived
 *   status           — draft | complete | amended
 */
function asDeriveSummary(assessment) {
    var empty = { roomCount: 0, componentsScored: 0, pendingSamples: 0, status: 'draft' };
    if (!assessment || typeof assessment !== 'object') return empty;

    var rooms = (assessment.rooms && Object.prototype.toString.call(assessment.rooms) === '[object Array]') ? assessment.rooms : [];
    var scored = 0;
    for (var i = 0; i < rooms.length; i++) {
        var components = (rooms[i] && rooms[i].components && Object.prototype.toString.call(rooms[i].components) === '[object Array]') ? rooms[i].components : [];
        for (var c = 0; c < components.length; c++) {
            if (asComponentScored(components[c])) scored += 1;
        }
    }

    var samples = (assessment.samples && Object.prototype.toString.call(assessment.samples) === '[object Array]') ? assessment.samples : [];
    var pending = 0;
    for (var s = 0; s < samples.length; s++) {
        if (samples[s] && !samples[s].result) pending += 1;
    }

    return {
        roomCount: rooms.length,
        componentsScored: scored,
        pendingSamples: pending,
        status: asOneOf(assessment.status, AS_STATUSES) || 'draft'
    };
}

/**
 * asPendingSamples — the samples still waiting on a lab, each paired with the
 * room it was taken in (slice 3). The visit screen turns this into the link
 * list under "n lab results pending", so an inspector opening the app three
 * days later lands on the sample row rather than hunting for it (§5.1b).
 *
 * Shape: [{ sample, roomId, roomName }]. A sample whose room has been removed
 * is not listed: `removeRoom` already drops those, and one that survived some
 * other way has nowhere to jump to.
 */
function asPendingSamples(assessment) {
    var out = [];
    if (!assessment || typeof assessment !== 'object') return out;
    var rooms = (assessment.rooms && Object.prototype.toString.call(assessment.rooms) === '[object Array]') ? assessment.rooms : [];
    var samples = (assessment.samples && Object.prototype.toString.call(assessment.samples) === '[object Array]') ? assessment.samples : [];
    for (var s = 0; s < samples.length; s++) {
        var sample = samples[s];
        if (!sample || sample.result) continue;
        for (var r = 0; r < rooms.length; r++) {
            if (rooms[r].id !== sample.roomId) continue;
            out.push({ sample: sample, roomId: rooms[r].id, roomName: rooms[r].name || 'Unnamed room' });
        }
    }
    return out;
}

/**
 * asStampedWrite — apply a mutation to a normalised copy of a record and, when
 * the visit has already been completed, stamp it (decision 1, §8).
 *
 * "A completed visit is never locked, only stamped": lab results arrive days or
 * weeks later, so the aggregate stays editable forever. Every write after
 * `completedAt` appends a `revisions[]` entry and moves the status to
 * `amended`, with `amendedAt` set. That is the whole amendment mechanism — there
 * is no lock, no copy, no separate revision document.
 *
 * Field-level (ADR-009 D10, 2026-09-28). The store diffs the record before and
 * after the mutation for the target the writer names (`what` + `ref`) and
 * writes ONE revision per changed field: `{at, what, ref, by, field, label,
 * from, to}`. The writers do not have to thread values through — a diff of the
 * normalised record cannot miss a field a writer forgot to report, and it
 * cannot report a change that did not land. A write that changes nothing (the
 * same value re-typed, a redundant autosave) stamps nothing.
 *
 * Coalescing (AS_REVISION_COALESCE_MS): a write to the same what + ref + FIELD,
 * by the same editor, within two minutes of that field's last stamp keeps the
 * FIRST `from`, takes the latest `to` and moves `at`. Autosave writes on every
 * keystroke, so without this, correcting one room name would produce a dozen
 * lines. If the coalesced value is back where it started (changed and changed
 * back) the entry is DROPPED — nothing was amended. A different field is its
 * own entry. Events (`added`/`removed`/`reopened`) never coalesce and are never
 * dropped. Two edits to the same field a week apart still stamp twice, which is
 * the case the trail exists for.
 *
 * `opts.by` is the actual editor (the provider passes the signed-in user's
 * display name), else AS_REVISION_DEVICE. `opts.event` stamps an event instead
 * of diffing (reopen).
 */
var AS_REVISION_COALESCE_MS = 120000;

/** asFindById — the element of `list` with this id, or null. */
function asFindById(list, id) {
    var arr = (list && Object.prototype.toString.call(list) === '[object Array]') ? list : [];
    for (var i = 0; i < arr.length; i++) {
        if (arr[i] && arr[i].id === id) return arr[i];
    }
    return null;
}

/** asFindComponent — one room's component row by key, or null. */
function asFindComponent(room, key) {
    var comps = (room && room.components && Object.prototype.toString.call(room.components) === '[object Array]') ? room.components : [];
    for (var i = 0; i < comps.length; i++) {
        if (comps[i] && comps[i].key === key) return comps[i];
    }
    return null;
}

/** asComponentLabel — the DMAT word for a component key ("walls" → "Wall"),
 *  read from AppConstants at CALL time (no load-order dependency). Falls back
 *  to the key, never an invented noun. */
function asComponentLabel(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 asStr(key, AS_SHORT_MAX);
}

/** asResultSummary — a lab result as one comparable line ("Aspergillus ·
 *  1200 spores/m³ · Elevated"); null when there is no result yet. A result is
 *  entered as one unit on the result sheet, so it is ONE change in the trail. */
function asResultSummary(result) {
    if (!result || typeof result !== 'object') return null;
    var parts = [];
    if (result.genus) parts.push(result.genus);
    if (result.speciesText) parts.push(result.speciesText);
    if (typeof result.sporeCount === 'number' && isFinite(result.sporeCount)) {
        parts.push(result.sporeCount + (result.unit ? ' ' + result.unit : ''));
    }
    if (result.interpretation) parts.push(result.interpretation);
    return parts.join(' · ');
}

/**
 * asRevisionTarget — the words for what a stamp is about, resolved against a
 * record: "Kitchen", "Kitchen › Wall", "Kitchen › Sample MB-14", or the fixed
 * category for visit / readings / remediation / sign-off. '' when the target is
 * no longer in the record (the caller then falls back to the stamp's `label`).
 * Used at WRITE time (to capture `label`) and at RENDER time (so a renamed room
 * reads under its current name).
 */
function asRevisionTarget(assessment, what, ref) {
    var a = (assessment && typeof assessment === 'object') ? assessment : {};
    if (what === 'visit' || what === 'readings' || what === 'remediation' || what === 'signoff') {
        return AS_REVISION_WORDS[what];
    }
    var r = typeof ref === 'string' ? ref : '';
    if (what === 'room') {
        var room = asFindById(a.rooms, r);
        return room ? (room.name || 'Unnamed room') : '';
    }
    if (what === 'component') {
        var slash = r.indexOf('/');
        if (slash < 0) return '';
        var compRoom = asFindById(a.rooms, r.slice(0, slash));
        if (!compRoom) return '';
        return (compRoom.name || 'Unnamed room') + ' › ' + asComponentLabel(r.slice(slash + 1));
    }
    if (what === 'sample') {
        var sample = asFindById(a.samples, r);
        if (!sample) return '';
        var sampleRoom = sample.roomId ? asFindById(a.rooms, sample.roomId) : null;
        var name = 'Sample' + (sample.sampleRef ? ' ' + sample.sampleRef : '');
        return sampleRoom ? (sampleRoom.name || 'Unnamed room') + ' › ' + name : name;
    }
    return '';
}

/**
 * asRevisionChanges — the field-level diff of one target between two
 * normalised records: [{ field, from, to }], values already JSON primitives.
 * Pure. `editedAt` and `updatedAt` are bookkeeping, never listed.
 */
function asRevisionChanges(before, after, what, ref) {
    var out = [];
    var diff = function (field, a, b) {
        var from = asRevisionValue(a);
        var to = asRevisionValue(b);
        if (from !== to) out.push({ field: field, from: from, to: to });
    };
    var each = function (fields, a, b) {
        for (var i = 0; i < fields.length; i++) {
            diff(fields[i], a ? a[fields[i]] : null, b ? b[fields[i]] : null);
        }
    };
    var teamNames = function (team) {
        var names = [];
        var list = (team && Object.prototype.toString.call(team) === '[object Array]') ? team : [];
        for (var t = 0; t < list.length; t++) if (list[t] && list[t].name) names.push(list[t].name);
        return names.join(', ');
    };

    if (what === 'visit') {
        each(['date', 'assessorName', 'assessorCertNo', 'building', 'buildingAge', 'floor'], before.visit, after.visit);
        diff('team', teamNames(before.visit.team), teamNames(after.visit.team));
    } else if (what === 'room') {
        var rb = asFindById(before.rooms, ref);
        var ra = asFindById(after.rooms, ref);
        if (!rb && ra) out.push({ field: 'added', from: null, to: asRevisionValue(ra.name) });
        else if (rb && !ra) out.push({ field: 'removed', from: asRevisionValue(rb.name), to: null });
        else if (rb && ra) each(['name', 'assessorName', 'direction', 'odour', 'odourSource', 'ventilation'], rb, ra);
    } else if (what === 'component') {
        var slash = ref.indexOf('/');
        var roomId = slash > -1 ? ref.slice(0, slash) : '';
        var key = slash > -1 ? ref.slice(slash + 1) : ref;
        var roomAfter = asFindById(after.rooms, roomId);
        if (roomAfter && asOneOf(key, AS_COMPONENT_KEYS)) {
            // A row the record does not carry yet IS its default on screen
            // (updateComponent creates it on first write), so diff against that.
            var cb = asFindComponent(asFindById(before.rooms, roomId), key) || asDefaultComponent(key);
            var ca = asFindComponent(roomAfter, key) || asDefaultComponent(key);
            each(['present', 'damage', 'mould', 'wetDamp'], cb, ca);
            var axes = ['damage', 'mould', 'wetDamp'];
            for (var k = 0; k < axes.length; k++) {
                diff('nearExtWall.' + axes[k], cb.nearExtWall[k] === true, ca.nearExtWall[k] === true);
            }
            diff('materials', cb.materials, ca.materials);
            each(['notes', 'affectedAreaM2'], cb, ca);
            diff('photos', asComponentPhotoItems(cb).length, asComponentPhotoItems(ca).length);
        }
    } else if (what === 'sample') {
        var sb = asFindById(before.samples, ref);
        var sa = asFindById(after.samples, ref);
        if (!sb && sa) out.push({ field: 'added', from: null, to: asRevisionValue(sa.sampleRef) });
        else if (sb && !sa) out.push({ field: 'removed', from: asRevisionValue(sb.sampleRef), to: null });
        else if (sb && sa) {
            diff('result', asResultSummary(sb.result), asResultSummary(sa.result));
            // The received date travels with a FIRST result (one event); it is
            // its own change only when a result that already existed is edited.
            if (sb.result) diff('resultReceivedAt', sb.resultReceivedAt, sa.resultReceivedAt);
            each(['sampleRef', 'sampleType', 'lab', 'collectedAt', 'sentAt'], sb, sa);
        }
    } else if (what === 'readings') {
        var eb = before.environmentalReadings || {};
        var ea = after.environmentalReadings || {};
        // null readings and '' readings both mean "not measured".
        for (var r = 0; r < AS_READING_KEYS.length; r++) {
            diff(AS_READING_KEYS[r], eb[AS_READING_KEYS[r]] || '', ea[AS_READING_KEYS[r]] || '');
        }
    } else if (what === 'remediation') {
        each(['riskClassification', 'method', 'containmentType', 'ppeLevel', 'affectedAreaM2'], before.remediation, after.remediation);
    } else if (what === 'signoff') {
        each(['technicianName', 'technicianCertNo', 'signedAt'], before.signoff, after.signoff);
    }
    return out;
}

/** asAppendRevisions — append stamps under the coalescing rule above. Pure;
 *  returns a new array. The list stays in chronological order (a coalesced
 *  entry moves to the end, because its `at` moved). */
function asAppendRevisions(list, stamps) {
    var out = list.slice();
    for (var i = 0; i < stamps.length; i++) {
        var s = stamps[i];
        if (!s.field || AS_REVISION_EVENTS.indexOf(s.field) >= 0) { out.push(s); continue; }
        var j = -1;
        for (var k = out.length - 1; k >= 0; k--) {
            if (out[k].what === s.what && out[k].ref === s.ref && out[k].field === s.field) { j = k; break; }
        }
        var recent = j > -1 && out[j].by === s.by && (Date.parse(s.at) - Date.parse(out[j].at)) < AS_REVISION_COALESCE_MS;
        if (!recent) { out.push(s); continue; }
        var merged = {
            at: s.at, what: s.what, ref: s.ref, by: s.by, field: s.field,
            label: s.label || out[j].label, from: out[j].from, to: s.to
        };
        out.splice(j, 1);
        if (merged.from !== merged.to) out.push(merged);
    }
    return out;
}

function asStampedWrite(record, mutate, what, ref, opts) {
    var o = opts || {};
    var before = asNormaliseAssessment(record);
    var next = asNormaliseAssessment(record);
    mutate(next);
    var at = asNextStamp(before.updatedAt);   // never behind the stamp it replaces (clock skew, R2)
    next.updatedAt = at;
    if (next.completedAt) {
        var cleanWhat = asOneOf(what, AS_REVISION_WHATS) || 'visit';
        var cleanRef = asStr(ref, AS_SHORT_MAX);
        var after = asNormaliseAssessment(next);
        var changes = (o.event && AS_REVISION_EVENTS.indexOf(o.event) > -1)
            ? [{ field: o.event, from: null, to: null }]
            : asRevisionChanges(before, after, cleanWhat, cleanRef);
        if (changes.length) {
            var label = asStr(asRevisionTarget(after, cleanWhat, cleanRef) || asRevisionTarget(before, cleanWhat, cleanRef), AS_SHORT_MAX);
            var by = asStr(o.by, AS_SHORT_MAX) || AS_REVISION_DEVICE;
            var stamps = [];
            for (var c = 0; c < changes.length; c++) {
                stamps.push({
                    at: at, what: cleanWhat, ref: cleanRef, by: by,
                    field: changes[c].field, label: label, from: changes[c].from, to: changes[c].to
                });
            }
            next.revisions = asAppendRevisions(next.revisions, stamps);
            // amendedAt is the newest surviving stamp. If coalescing dropped the
            // only amendment (changed and changed back), the visit is simply
            // complete again: nothing about it was amended.
            var newest = null;
            for (var n = 0; n < next.revisions.length; n++) {
                if (!newest || Date.parse(next.revisions[n].at) > Date.parse(newest)) newest = next.revisions[n].at;
            }
            next.status = newest ? 'amended' : 'complete';
            next.amendedAt = newest;
        }
    }
    return asNormaliseAssessment(next);
}

/* ── The change log as the reader sees it (ADR-009 D10) ─────────────────── */

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

/** asRevisionDate — an ISO date value as "28 Sep 2026", by hand (the same
 *  rule as the report's arDate: deterministic, locale-free, fixture-safe). */
function asRevisionDate(raw) {
    var m = /^(\d{4})-(\d{2})-(\d{2})/.exec(typeof raw === 'string' ? raw : '');
    if (!m) return String(raw);
    var month = parseInt(m[2], 10);
    if (month < 1 || month > 12) return String(raw);
    return String(parseInt(m[3], 10)) + ' ' + AS_REVISION_MONTHS[month - 1] + ' ' + m[1];
}

/** asRevisionText — one stored value in words, per its field's kind. Empty is
 *  "—"; a missing lab result is "pending"; a room with no assessor of its own
 *  is the "Visit lead" (null there is a reference, not a blank). */
function asRevisionText(meta, value) {
    var kind = meta.kind;
    if (kind === 'result' && value === null) return 'pending';
    if (kind === 'lead' && (value === null || value === '')) return 'Visit lead';
    if (value === null || value === '') return '—';
    if (typeof value === 'boolean') return value ? 'Yes' : 'No';
    if (kind === 'date' && typeof value === 'string') return asRevisionDate(value);
    if (typeof value === 'number') return String(value) + (meta.unit ? ' ' + meta.unit : '');
    if (meta.unit && /^-?\d+([.,]\d+)?$/.test(value)) return value + ' ' + meta.unit;
    return String(value);
}

/**
 * asChangeLog — the amendment history, NEWEST FIRST, in the words every surface
 * shows (the visit screen, the report appendix, the amendments CSV). PURE and
 * deterministic — no Date formatting, no locale — so it is part of the shared
 * fixture; each renderer formats `at` itself (local time, minutes, zone).
 *
 * Entry: { at, what, ref, field, target, fieldLabel, title, change, fromText,
 *          toText, by, legacy }
 *   target  — resolved NOW from `ref` ("Kitchen › Wall"), else the stamp's
 *             write-time `label` (the target was since deleted), else the
 *             category word. Never an internal id.
 *   title   — "Kitchen › Wall — Visible mould"; a legacy stamp (no field):
 *             "Component · Kitchen › Wall".
 *   change  — "Mould 1 → 2", "Lab result: pending → Aspergillus"; '' for events
 *             and legacy stamps.
 */
function asChangeLog(assessment) {
    var revs = (assessment && assessment.revisions && Object.prototype.toString.call(assessment.revisions) === '[object Array]') ? assessment.revisions : [];
    var rows = [];
    for (var i = 0; i < revs.length; i++) {
        var rev = asNormaliseRevision(revs[i]);
        if (!rev) continue;
        var category = AS_REVISION_WORDS[rev.what] || 'Visit details';
        var target = asRevisionTarget(assessment, rev.what, rev.ref) || rev.label || category;
        var meta = rev.field ? (AS_REVISION_FIELDS[rev.what + '.' + rev.field] || { label: rev.field, short: rev.field }) : null;
        var fieldLabel = meta ? meta.label : '';
        var fromText = '';
        var toText = '';
        var change = '';
        if (meta && meta.kind !== 'event') {
            fromText = asRevisionText(meta, rev.from);
            toText = asRevisionText(meta, rev.to);
            change = meta.short + (meta.kind === 'score' ? ' ' : ': ') + fromText + ' → ' + toText;
        }
        var title;
        if (meta) title = target + ' — ' + fieldLabel;
        else title = (target === category) ? category : category + ' · ' + target;
        rows.push({
            i: i,
            entry: {
                at: rev.at, what: rev.what, ref: rev.ref, field: rev.field,
                target: target, fieldLabel: fieldLabel, title: title, change: change,
                fromText: fromText, toText: toText, by: rev.by, legacy: !rev.field
            }
        });
    }
    // Newest first; for equal stamps the later-written entry first.
    rows.sort(function (a, b) {
        var ta = Date.parse(a.entry.at);
        var tb = Date.parse(b.entry.at);
        if (ta !== tb) return (isFinite(tb) ? tb : 0) - (isFinite(ta) ? ta : 0);
        return b.i - a.i;
    });
    var out = [];
    for (var r = 0; r < rows.length; r++) out.push(rows[r].entry);
    return out;
}

/* Render-time time words — NOT part of the pure log (they depend on the
   reader's locale and time zone, exactly as they should). */

/** asRevisionDayKey — the reader's LOCAL calendar day of a stamp (YYYY-MM-DD). */
function asRevisionDayKey(iso) {
    var d = new Date(iso);
    if (isNaN(d.getTime())) return '';
    return d.getFullYear() + '-' + ('0' + (d.getMonth() + 1)).slice(-2) + '-' + ('0' + d.getDate()).slice(-2);
}

/** asRevisionDayLabel — a day header: "Mon, 28 Sep 2026" in the reader's locale. */
function asRevisionDayLabel(iso) {
    var d = new Date(iso);
    if (isNaN(d.getTime())) return String(iso);
    try {
        return new Intl.DateTimeFormat(undefined, { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' }).format(d);
    } catch (err) {
        return d.toDateString();
    }
}

/** asRevisionTime — "14:32 AEST": to the minute, with the zone, in the
 *  reader's locale (12- or 24-hour as their locale prefers). */
function asRevisionTime(iso) {
    var d = new Date(iso);
    if (isNaN(d.getTime())) return String(iso);
    try {
        return new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit', timeZoneName: 'short' }).format(d);
    } catch (err) {
        return d.toLocaleTimeString();
    }
}

/** asRevisionStamp — the full "28 Sep 2026, 14:32 AEST" (tooltips, CSV-free
 *  surfaces that show one stamp without a day header). */
function asRevisionStamp(iso) {
    var d = new Date(iso);
    if (isNaN(d.getTime())) return String(iso);
    try {
        return new Intl.DateTimeFormat(undefined, {
            day: 'numeric', month: 'short', year: 'numeric', hour: 'numeric', minute: '2-digit', timeZoneName: 'short'
        }).format(d);
    } catch (err) {
        return d.toLocaleString();
    }
}

/** asChangeLogDays — the newest-first log grouped under local day headers:
 *  [{ key, label, entries }]. Order is preserved, so days are newest first. */
function asChangeLogDays(entries) {
    var days = [];
    var index = {};
    for (var i = 0; i < (entries || []).length; i++) {
        var key = asRevisionDayKey(entries[i].at) || 'unknown';
        if (!index[key]) {
            index[key] = { key: key, label: asRevisionDayLabel(entries[i].at), entries: [] };
            days.push(index[key]);
        }
        index[key].entries.push(entries[i]);
    }
    return days;
}

/** asChangeLogByline — who made a change, in the words every surface shows.
 *  A stamp with no named editor (empty, or the AS_REVISION_DEVICE sentinel the
 *  store writes when nobody is signed in) reads "Recorded on this device";
 *  a named one reads "By Roz". "By This device" is not a sentence (owner,
 *  29 Sep 2026). Presentation only: `entry.by` and the amendments CSV keep the
 *  stored value, so the shared change-log fixture is unchanged. */
function asChangeLogByline(entry) {
    var by = entry && typeof entry.by === 'string' ? entry.by.trim() : '';
    if (!by || by === AS_REVISION_DEVICE) return 'Recorded on this device';
    return 'By ' + by;
}

/** asEditorName — the signed-in user's display name for `revisions[].by`
 *  (read-only use of the user AuthProvider exposes), or '' when
 *  nobody is signed in — the stamp then says AS_REVISION_DEVICE. Never an
 *  email address: the trail prints on a customer-facing report. */
function asEditorName(auth) {
    try {
        if (!auth || !auth.isSignedIn || !auth.user) return '';
        var u = auth.user;
        var joined = [u.firstName, u.lastName].filter(function (p) { return typeof p === 'string' && p; }).join(' ');
        return asStr(u.fullName, AS_SHORT_MAX) || asStr(joined, AS_SHORT_MAX) || asStr(u.username, AS_SHORT_MAX);
    } catch (err) {
        return '';
    }
}

/**
 * asStatusChip — the one place the three visit states get their words and
 * colours. Never colour alone (CLAUDE.md): each chip carries its own label, and
 * every fill is light with forest text, so nothing here can put light text on a
 * light fill or sage on green.
 */
function asStatusChip(status) {
    if (status === 'complete') return { label: 'Complete', cls: 'bg-primary/20 text-forest' };
    if (status === 'amended')  return { label: 'Amended',  cls: 'bg-sage/30 text-forest' };
    // Neutral, not the fair band tan: band colours mean finding severity only
    // (DESIGN.md §2), and a draft is a workflow state.
    return { label: 'Draft', cls: 'bg-stone-200 text-forest' };
}

/**
 * asFromLegacyScan — the one-shot legacy migration (ADR-009 D5).
 *
 * An enterprise Scan written by `EnterpriseMouldAssessment` before this store
 * existed holds a whole DMAT assessment in flat, nullable fields. It becomes a
 * ONE-ROOM Assessment: visit ← Section A, room ← the room/area fields, the
 * component matrix ← `assessmentComponents`, and Sections D–F ← the remaining
 * flat fields.
 *
 * The scan is NOT modified — the flat fields stay readable and are still
 * written until slice 2 rewires the form. `legacyScanId` is what makes this
 * idempotent: a scan that already has an Assessment is skipped.
 */
function asFromLegacyScan(scan) {
    if (!scan || typeof scan !== 'object') return null;
    if (!scan.assessmentComponents || typeof scan.assessmentComponents !== 'object') return null;

    var record = asDefaultAssessment(scan.propertyId || null);
    record.legacyScanId = asStrOrNull(scan.id, AS_SHORT_MAX);

    record.visit.date           = asStr(scan.assessmentDate, 40);
    record.visit.assessorName   = asStr(scan.observerName || scan.technicianName, AS_SHORT_MAX);
    record.visit.assessorCertNo = asStr(scan.technicianCertNo, AS_SHORT_MAX);
    record.visit.floor          = asStr(scan.floor, AS_SHORT_MAX);

    // The room: the enterprise form captured one area per scan, named by the
    // room/area id with the free-text type description as the fallback.
    var room = asNewRoom(asStr(scan.roomAreaId, AS_SHORT_MAX) || asStr(scan.roomAreaTypeDesc, AS_SHORT_MAX) || asStr(scan.roomName, AS_SHORT_MAX));
    room.odour = asScore(scan.odourLevel);
    room.odourSource = asStr(scan.odourSource, AS_TEXT_MAX);

    // The matrix is keyed by component; the aggregate stores it as an array in
    // the §3 component shape. Legacy field names differ (damageScore /
    // mouldScore / wetDampScore, three separate *NearExt booleans).
    var src = scan.assessmentComponents;
    // editedAt (D9): the legacy form had no touched signal, so a migrated row
    // counts as edited when it carries positive evidence OR the form's own
    // explicit `nothingFound` answer on a present component — that checkbox IS
    // the "0 None on purpose" finding this field exists to keep. Stamped with
    // the scan's capture time (the moment the form was filled), else now.
    var legacyMs = (typeof scan.timestamp === 'number' && isFinite(scan.timestamp)) ? scan.timestamp : Date.now();
    for (var k = 0; k < AS_COMPONENT_KEYS.length; k++) {
        var key = AS_COMPONENT_KEYS[k];
        var row = src[key];
        if (!row || typeof row !== 'object') continue;
        var component = asNormaliseComponent({
            key: key,
            present: row.present,
            damage: row.damageScore,
            mould: row.mouldScore,
            wetDamp: row.wetDampScore,
            nearExtWall: [row.damageNearExt, row.mouldNearExt, row.wetDampNearExt],
            materials: row.materials,
            notes: row.componentNotes,
            // The matrix row carried its own affected area; slice 2 gave the
            // §3 component a home for it, so the migration no longer drops it.
            affectedAreaM2: row.affectedAreaM2,
            photoIds: [],
            // The scan the matrix came from IS the component's photo (§3:
            // "a photo is a scan"), so the link is preserved rather than lost.
            scanIds: scan.id ? [scan.id] : [],
            editedAt: null
        });
        if (component && (asComponentHasEvidence(component) || (component.present && row.nothingFound === true))) {
            component.editedAt = legacyMs;
        }
        if (component) room.components.push(component);
    }
    record.rooms.push(room);

    if (scan.environmentalReadings && typeof scan.environmentalReadings === 'object') {
        var readings = {};
        var any = false;
        for (var r = 0; r < AS_READING_KEYS.length; r++) {
            var value = asStr(scan.environmentalReadings[AS_READING_KEYS[r]], AS_SHORT_MAX);
            readings[AS_READING_KEYS[r]] = value;
            if (value) any = true;
        }
        if (any) record.environmentalReadings = readings;
    }

    record.remediation.riskClassification = asStr(scan.riskClassification, AS_SHORT_MAX);
    record.remediation.method             = asStr(scan.remediationMethod, AS_SHORT_MAX);
    record.remediation.containmentType    = asStr(scan.containmentType, AS_SHORT_MAX);
    record.remediation.ppeLevel           = asStr(scan.ppeLevel, AS_SHORT_MAX);
    record.remediation.affectedAreaM2     = asNumOrNull(scan.affectedAreaM2);

    record.signoff.technicianName   = asStr(scan.technicianName, AS_SHORT_MAX);
    record.signoff.technicianCertNo = asStr(scan.technicianCertNo, AS_SHORT_MAX);

    // A migrated visit is a record of something that already happened, but it is
    // NOT signed off here: nobody pressed "complete" on it, and stamping a
    // completion the inspector never made would be an invention.
    if (typeof scan.timestamp === 'number' && isFinite(scan.timestamp)) {
        record.createdAt = new Date(scan.timestamp).toISOString();
    }
    return asNormaliseAssessment(record);
}

/**
 * asMigrateLegacyScans — the idempotent sweep run once per hydrate.
 * Returns the assessments to APPEND (possibly none). Pure: it reads scans and
 * the already-loaded assessments and writes nothing itself.
 */
function asMigrateLegacyScans(scans, assessments) {
    var created = [];
    if (!scans || Object.prototype.toString.call(scans) !== '[object Array]') return created;

    var seen = {};
    for (var a = 0; a < assessments.length; a++) {
        if (assessments[a] && assessments[a].legacyScanId) seen[assessments[a].legacyScanId] = true;
    }

    for (var i = 0; i < scans.length; i++) {
        var scan = scans[i];
        if (!scan || typeof scan !== 'object' || !scan.id) continue;
        if (!scan.assessmentComponents) continue;
        // `assessmentRef` is written by slice 3 when a scan is taken FROM an
        // assessment; such a scan already belongs to one and must not spawn a
        // second visit.
        if (scan.assessmentRef) continue;
        if (seen[scan.id]) continue;
        var record = asFromLegacyScan(scan);
        if (!record) continue;
        seen[scan.id] = true;
        created.push(record);
    }
    return created;
}

var AssessmentProvider = function (props) {
    var listState = _useState_AS([]);
    var assessments = listState[0];
    var setAssessments = listState[1];

    var hydratedState = _useState_AS(false);
    var hydrated = hydratedState[0];
    var setHydrated = hydratedState[1];

    var failedState = _useState_AS(false);
    var hydrationFailed = failedState[0];
    var setHydrationFailed = failedState[1];

    /**
     * pendingCapture — "the next scan belongs to THIS component" (slice 3).
     *
     * Deliberately in-memory only and deliberately NOT in the URL: routing is a
     * MemoryRouter (App.jsx), so a route carries no state a reload could
     * restore, and a capture intent is not a fact about the inspection — it is
     * a few seconds of user intent between tapping "Add photo" and the scan
     * saving. Persisting it would mean a phone call mid-capture silently
     * attaching tomorrow's photo to yesterday's wall.
     *
     * Shape is the assessmentRef the Scan gets ({v, assessmentId, roomId,
     * componentKey}), so AnalysisPage writes what it was handed rather than
     * rebuilding it.
     */
    var pendingState = _useState_AS(null);
    var pendingCapture = pendingState[0];
    var setPendingCapture = pendingState[1];

    /* The actual editor for `revisions[].by` (ADR-009 D10): the signed-in
       signed-in user's name (useAuth), read-only, refreshed every render so a
       sign-in mid-visit is picked up by the next write. The writers read the
       ref (never a closure over the value), so their memoised callbacks never
       stamp a stale name. No provider → the context default (signed out). */
    var editorAuth = (typeof useAuth === 'function') ? useAuth() : null;
    var editorRef = _useRef_AS('');
    editorRef.current = asEditorName(editorAuth);
    var asStampOpts = function (event) {
        return { by: editorRef.current, event: event || null };
    };

    // Bumped when a debounced write lands — the screens' "Saved" line reads it.
    // Not persisted: it is UI feedback, not part of any record.
    var savedState = _useState_AS(0);
    var savedAt = savedState[0];
    var setSavedAt = savedState[1];

    /**
     * saveStatus — the record-level form state (ADR-009 D9, 2026-09-28):
     *   'idle'    nothing edited this session (the stored record IS saved)
     *   'pending' an edit is inside the 300 ms debounce window ("changed")
     *   'saving'  the IndexedDB write is in flight
     *   'saved'   the last write landed (savedAt says when)
     *   'error'   the write rejected — "Couldn't save · Retry", never green
     *   'disabled' hydration failed, so writes are off for the session
     * UI state only, never persisted.
     */
    var saveStatusState = _useState_AS('idle');
    var saveStatus = saveStatusState[0];
    var setSaveStatus = saveStatusState[1];
    // Every commit bumps this. A write that resolves after a NEWER commit must
    // not claim "saved": the newer edit is still only pending.
    var commitGenRef = _useRef_AS(0);

    // Gate for the debounced write: never persist the initial empty array before
    // the stored records have been read (ScanStore.jsx:89-101).
    var hydratedRef = _useRef_AS(false);
    // Second gate: this store's state also changes ON hydration, and writing
    // then would rewrite every record for no reason. Only a real writer (or the
    // legacy migration, which genuinely created records) sets this.
    var dirtyRef = _useRef_AS(false);

    // Mirror of the latest list, so writers can build from it SYNCHRONOUSLY and
    // return the record they wrote (the visit screen needs the new id to
    // navigate to it, and a function updater does not run until the next
    // render — the latestRef discipline from UserProfileStore review F4).
    var latestRef = _useRef_AS(assessments);
    latestRef.current = assessments;

    // --- Hydrate from IndexedDB on mount ---
    _useEffect_AS(function () {
        Promise.all([
            StorageService.loadAssessments(),
            // Scans are read directly rather than through ScanStore so the
            // legacy sweep does not depend on provider ordering or on that
            // store's own hydration having finished. Nothing is written back to
            // them: the flat fields stay exactly as they are (ADR-009 D5).
            StorageService.loadScans()
        ]).then(function (results) {
            var raw = results[0];
            var loaded = [];
            var dropped = 0;
            var list = (raw && Object.prototype.toString.call(raw) === '[object Array]') ? raw : [];
            for (var i = 0; i < list.length; i++) {
                var record = asMigrateAssessment(list[i]);
                if (record) loaded.push(record);
                else dropped += 1;
            }
            if (dropped > 0) {
                console.warn('AssessmentStore: dropped ' + dropped + ' unreadable assessment record(s) — the rest were loaded normally.');
            }

            var created = asMigrateLegacyScans(results[1], loaded);
            if (created.length > 0) {
                console.info('AssessmentStore: migrated ' + created.length + ' legacy enterprise scan(s) into assessments (ADR-009).');
                loaded = loaded.concat(created);
                // The migration created records, so they must be persisted —
                // this is the one place hydration is legitimately "dirty".
                dirtyRef.current = true;
            }

            latestRef.current = loaded;
            hydratedRef.current = true;
            setAssessments(loaded);
            setHydrated(true);
        })['catch'](function (err) {
            // Deliberately do NOT mark hydratedRef: persisting an empty array
            // after a failed read would delete every stored assessment. Writes
            // stay disabled for this session; the data is intact next launch.
            console.error('AssessmentStore hydration failed — assessment persistence disabled for this session:', err);
            setHydrationFailed(true);
            setSaveStatus('disabled');
            setHydrated(true);
        });
    }, []);

    // --- Debounced persistence (300 ms, same coalescing rule as ScanStore) ---
    var debounceRef = _useRef_AS(null);

    /**
     * asPersist — the ONE write path, and the one place the save status moves
     * past 'pending'. The status reports what storage actually said:
     * StorageService.saveAssessments REJECTS on failure (it used to swallow the
     * error and resolve, which painted a green "Saved" over a lost write).
     */
    var asPersist = _useCallback_AS(function (list, why) {
        var gen = commitGenRef.current;
        setSaveStatus('saving');
        var p;
        try {
            p = StorageService.saveAssessments(list);
        } catch (syncErr) {
            p = Promise.reject(syncErr);
        }
        return Promise.resolve(p).then(function () {
            if (gen !== commitGenRef.current) return; // a newer edit is pending
            setSavedAt(Date.now());
            setSaveStatus('saved');
        })['catch'](function (err) {
            console.error('Failed to persist assessments' + (why ? ' (' + why + ')' : '') + ':', err);
            if (gen !== commitGenRef.current) return; // the newer write retries
            setSaveStatus('error');
        });
    }, []);

    _useEffect_AS(function () {
        if (!hydratedRef.current || !dirtyRef.current) return;
        if (debounceRef.current) clearTimeout(debounceRef.current);
        debounceRef.current = setTimeout(function () {
            debounceRef.current = null;
            asPersist(latestRef.current);
        }, 300);
        return function () {
            if (debounceRef.current) clearTimeout(debounceRef.current);
        };
    }, [assessments, asPersist]);

    var flushPendingWrite = _useCallback_AS(function () {
        if (!debounceRef.current) return;
        clearTimeout(debounceRef.current);
        debounceRef.current = null;
        asPersist(latestRef.current, 'flush on page hide');
    }, [asPersist]);

    /** retrySave() — the error chip's Retry: write the live list now. */
    var retrySave = _useCallback_AS(function () {
        if (!hydratedRef.current) return;
        if (debounceRef.current) { clearTimeout(debounceRef.current); debounceRef.current = null; }
        asPersist(latestRef.current, 'retry');
    }, [asPersist]);

    _useEffect_AS(function () {
        var onVisibilityChange = function () {
            if (document.visibilityState === 'hidden') flushPendingWrite();
        };
        window.addEventListener('pagehide', flushPendingWrite);
        document.addEventListener('visibilitychange', onVisibilityChange);
        return function () {
            window.removeEventListener('pagehide', flushPendingWrite);
            document.removeEventListener('visibilitychange', onVisibilityChange);
            flushPendingWrite(); // provider unmount
        };
    }, [flushPendingWrite]);

    /**
     * asCommit — the single state-writing path. Builds the next list from
     * latestRef (never from a function updater), so the writer can return the
     * record it just wrote to its caller synchronously.
     */
    var asCommit = _useCallback_AS(function (nextList) {
        latestRef.current = nextList;
        dirtyRef.current = true;
        commitGenRef.current += 1;
        // "changed": the edit is in memory and inside the debounce window.
        setSaveStatus(hydratedRef.current ? 'pending' : 'disabled');
        setAssessments(nextList);
    }, []);

    /**
     * asWriteOne — replace one record via `fn`, returning the new record (or
     * null when the id is unknown). Never throws: a writer that blew up mid-
     * inspection would lose the visit, so failures degrade to "nothing changed"
     * plus a console.warn.
     */
    var asWriteOne = _useCallback_AS(function (id, fn) {
        try {
            var prev = latestRef.current || [];
            var out = [];
            var written = null;
            for (var i = 0; i < prev.length; i++) {
                if (prev[i] && prev[i].id === id) {
                    written = fn(prev[i]);
                    out.push(written);
                } else {
                    out.push(prev[i]);
                }
            }
            if (!written) return null;
            asCommit(out);
            return written;
        } catch (err) {
            console.warn('AssessmentStore: write failed (ignored):', err);
            return null;
        }
    }, [asCommit]);

    /** createAssessment({ propertyId }) — a new draft visit for a property. */
    var createAssessment = _useCallback_AS(function (data) {
        try {
            data = data || {};
            var record = asNormaliseAssessment(asDefaultAssessment(data.propertyId || null));
            if (window.MetricsService) MetricsService.track('assessment_created'); // product metrics §2
            asCommit((latestRef.current || []).concat([record]));
            return record;
        } catch (err) {
            console.warn('AssessmentStore: createAssessment failed (ignored):', err);
            return null;
        }
    }, [asCommit]);

    /** updateVisit(id, patch) — the visit header block (§3 `visit`). */
    var updateVisit = _useCallback_AS(function (id, patch) {
        return asWriteOne(id, function (prev) {
            return asStampedWrite(prev, function (next) {
                var p = patch || {};
                if (p.date !== undefined)           next.visit.date = asStr(p.date, 40);
                if (p.assessorName !== undefined)   next.visit.assessorName = asStr(p.assessorName, AS_SHORT_MAX);
                if (p.assessorCertNo !== undefined) next.visit.assessorCertNo = asStr(p.assessorCertNo, AS_SHORT_MAX);
                if (p.building !== undefined)       next.visit.building = asStr(p.building, AS_SHORT_MAX);
                if (p.buildingAge !== undefined)    next.visit.buildingAge = asStr(p.buildingAge, AS_SHORT_MAX);
                if (p.floor !== undefined)          next.visit.floor = asStr(p.floor, AS_SHORT_MAX);
            }, 'visit', id, asStampOpts());
        });
    }, [asWriteOne]);

    /**
     * updateTeam(id, team) — the visit's additional assessors (slice 5).
     *
     * Whole-array replacement, not a per-member patch: the editor holds five
     * rows at most and always knows the full list, and an index-based patch
     * against a list the user is adding to and removing from is a race waiting
     * for a slow write. Normalised through `asNormaliseTeam`, so a nameless row
     * never reaches storage and the cap is enforced here rather than in the UI.
     *
     * Stamped as a `visit` revision: who was on site is a fact about the visit,
     * and adding a name to a completed report is exactly the kind of change the
     * amendment trail exists for.
     */
    var updateTeam = _useCallback_AS(function (id, team) {
        return asWriteOne(id, function (prev) {
            return asStampedWrite(prev, function (next) {
                next.visit.team = asNormaliseTeam(team);
            }, 'visit', id, asStampOpts());
        });
    }, [asWriteOne]);

    /** addRoom(id, { name }) — appends a room and returns the WHOLE record; the
     *  new room is its last entry (the caller navigates to `room.id`). */
    var addRoom = _useCallback_AS(function (id, data) {
        // Built outside the mutation so the stamp's ref is the NEW ROOM's id
        // (D10): "Kitchen — Room added" resolves; the visit id never did.
        var room = asNewRoom((data || {}).name);
        return asWriteOne(id, function (prev) {
            return asStampedWrite(prev, function (next) {
                next.rooms = next.rooms.concat([room]);
            }, 'room', room.id, asStampOpts());
        });
    }, [asWriteOne]);

    /** updateRoom(id, roomId, patch) — one room's own fields (§5.1). */
    var updateRoom = _useCallback_AS(function (id, roomId, patch) {
        return asWriteOne(id, function (prev) {
            return asStampedWrite(prev, function (next) {
                var p = patch || {};
                for (var i = 0; i < next.rooms.length; i++) {
                    if (next.rooms[i].id !== roomId) continue;
                    if (p.name !== undefined)        next.rooms[i].name = asStr(p.name, AS_SHORT_MAX);
                    // null (or '') means "the visit lead" — a reference, never
                    // a copy of the lead's name (asRoomAssessor resolves it).
                    if (p.assessorName !== undefined) next.rooms[i].assessorName = asStrOrNull(p.assessorName, AS_SHORT_MAX);
                    if (p.direction !== undefined)   next.rooms[i].direction = asOneOf(p.direction, AS_DIRECTIONS);
                    if (p.odour !== undefined)       next.rooms[i].odour = asScore(p.odour);
                    if (p.odourSource !== undefined) next.rooms[i].odourSource = asStr(p.odourSource, AS_TEXT_MAX);
                    if (p.ventilation !== undefined) next.rooms[i].ventilation = asOneOf(p.ventilation, AS_VENTILATION) || '';
                }
            }, 'room', roomId, asStampOpts());
        });
    }, [asWriteOne]);

    /**
     * updateComponent(id, roomId, key, patch) — one component row of one room
     * (§5.3, slice 2). The component is CREATED from asDefaultComponent on the
     * first write for a key, because the room screen renders all nine whether
     * or not the record carries them: what is on screen before the first tap is
     * exactly what that tap persists.
     *
     * Strict allowlist, same discipline as every other writer here: only the
     * fields below are read off the patch, scores clamp to 0–3, materials are
     * trimmed strings, notes cap at 500, affectedAreaM2 is a number or null.
     * `photoIds`/`scanIds` are NOT patchable here — slice 3 appends to them
     * through its own path, and a free-form patch could silently orphan a scan.
     */
    var updateComponent = _useCallback_AS(function (id, roomId, key, patch) {
        return asWriteOne(id, function (prev) {
            return asStampedWrite(prev, function (next) {
                var componentKey = asOneOf(key, AS_COMPONENT_KEYS);
                if (!componentKey) return;
                var p = patch || {};
                for (var i = 0; i < next.rooms.length; i++) {
                    if (next.rooms[i].id !== roomId) continue;
                    var components = next.rooms[i].components;
                    var found = null;
                    for (var c = 0; c < components.length; c++) {
                        if (components[c].key === componentKey) found = components[c];
                    }
                    if (!found) {
                        found = asDefaultComponent(componentKey);
                        components.push(found);
                    }
                    if (p.present !== undefined)        found.present = asBool(p.present);
                    if (p.damage !== undefined)         found.damage = asScore(p.damage);
                    if (p.mould !== undefined)          found.mould = asScore(p.mould);
                    if (p.wetDamp !== undefined)        found.wetDamp = asScore(p.wetDamp);
                    if (p.nearExtWall !== undefined)    found.nearExtWall = asNearExtWall(p.nearExtWall);
                    if (p.materials !== undefined)      found.materials = asStrList(p.materials);
                    if (p.notes !== undefined)          found.notes = asStr(p.notes, AS_TEXT_MAX);
                    if (p.affectedAreaM2 !== undefined) found.affectedAreaM2 = asNumOrNull(p.affectedAreaM2);
                    // editedAt (D9): any explicit edit of a FINDING stamps it —
                    // a score tap (0 included), near-ext, materials, notes,
                    // area. `present` alone does not: toggling a component off
                    // and back on is not an assessment of its condition, and
                    // stamping it would turn a never-scored row into a "nothing
                    // found" finding. Absent rows count as decided anyway.
                    if (p.damage !== undefined || p.mould !== undefined || p.wetDamp !== undefined ||
                        p.nearExtWall !== undefined || p.materials !== undefined || p.notes !== undefined ||
                        p.affectedAreaM2 !== undefined) {
                        found.editedAt = Date.now();
                    }
                }
            }, 'component', roomId + '/' + key, asStampOpts());
        });
    }, [asWriteOne]);

    /** startCapture(assessmentId, roomId, componentKey) — the component sheet's
     *  "Add photo" tile. Returns the ref it set (or null for an unusable
     *  target), so the caller can decide whether to navigate. */
    var startCapture = _useCallback_AS(function (assessmentId, roomId, componentKey, photoId) {
        // photoId (optional): "Scan for mould" on an uploaded component photo —
        // AnalysisPage loads that image instead of asking for a new one.
        var ref = asNormaliseAssessmentRef({ assessmentId: assessmentId, roomId: roomId, componentKey: componentKey, photoId: photoId });
        setPendingCapture(ref);
        return ref;
    }, []);

    /** clearPendingCapture() — the chip's ×, and every path that consumes it. */
    var clearPendingCapture = _useCallback_AS(function () {
        setPendingCapture(null);
    }, []);

    /**
     * attachScan(id, roomId, key, scanId) — link a saved Scan to a component
     * (slice 3). Appends the id to BOTH `scanIds` and `photoIds`: they hold the
     * same ids here because a photo is a scan (ADR-009 D3), and both keys are
     * kept for the Swift port and the source app's report renderer.
     *
     * **Idempotent by scan id.** A scan already attached returns the record
     * untouched rather than writing — which matters twice over: the room screen
     * retries the append on mount for a scan that was saved while this store was
     * unavailable, and a redundant write on a COMPLETED visit would otherwise
     * stamp an amendment for a change that never happened.
     *
     * Always clears `pendingCapture`: whether the append landed, was already
     * there, or the target has gone, that capture intent is spent.
     */
    var attachScan = _useCallback_AS(function (id, roomId, key, scanId, photoId) {
        try {
            var cleanId = asStr(scanId, AS_SHORT_MAX);
            var componentKey = asOneOf(key, AS_COMPONENT_KEYS);
            // The attachment this scan was made from, if any (2026-10-05). Its
            // `scanId` is the link back; set once, never overwritten.
            var cleanPhotoId = asStr(photoId, AS_SHORT_MAX);
            if (!cleanId || !componentKey) { clearPendingCapture(); return null; }
            var photoLinked = function (comp) {
                if (!cleanPhotoId) return true;
                for (var x = 0; x < comp.attachments.length; x++) {
                    if (comp.attachments[x].id === cleanPhotoId) return !!comp.attachments[x].scanId;
                }
                return true; // the attachment is gone: nothing to link
            };

            // Pre-check against the live list: see "idempotent" above.
            var list = latestRef.current || [];
            for (var a = 0; a < list.length; a++) {
                if (!list[a] || list[a].id !== id) continue;
                // A deleted photo stays deleted (2026-10-05): the append-retry
                // would otherwise re-link a scan whose assessmentRef still
                // names this component.
                var deleted = asDeletedPhotoIndex(list[a]);
                if (deleted.scanIds[cleanId] || (cleanPhotoId && deleted.photoIds[cleanPhotoId])) {
                    clearPendingCapture();
                    return list[a];
                }
                for (var r = 0; r < list[a].rooms.length; r++) {
                    if (list[a].rooms[r].id !== roomId) continue;
                    var comps = list[a].rooms[r].components;
                    for (var c = 0; c < comps.length; c++) {
                        if (comps[c].key !== componentKey) continue;
                        if (asComponentPhotoIds(comps[c]).indexOf(cleanId) >= 0 && photoLinked(comps[c])) {
                            clearPendingCapture();
                            return list[a];
                        }
                    }
                }
            }

            var written = asWriteOne(id, function (prev) {
                return asStampedWrite(prev, function (next) {
                    for (var i = 0; i < next.rooms.length; i++) {
                        if (next.rooms[i].id !== roomId) continue;
                        var components = next.rooms[i].components;
                        var found = null;
                        for (var k = 0; k < components.length; k++) {
                            if (components[k].key === componentKey) found = components[k];
                        }
                        if (!found) {
                            found = asDefaultComponent(componentKey);
                            components.push(found);
                        }
                        if (found.scanIds.indexOf(cleanId) < 0) found.scanIds = found.scanIds.concat([cleanId]);
                        if (found.photoIds.indexOf(cleanId) < 0) found.photoIds = found.photoIds.concat([cleanId]);
                        if (cleanPhotoId) {
                            for (var x = 0; x < found.attachments.length; x++) {
                                if (found.attachments[x].id === cleanPhotoId && !found.attachments[x].scanId) {
                                    found.attachments[x].scanId = cleanId;
                                }
                            }
                        }
                    }
                }, 'component', roomId + '/' + componentKey, asStampOpts());
            });
            clearPendingCapture();
            return written;
        } catch (err) {
            // Capture must never block or fail a scan (PRD §11.6): a broken
            // append is a missing link, not a lost photo — the scan itself
            // already carries assessmentRef and the room screen retries.
            console.warn('AssessmentStore: attachScan failed (ignored):', err);
            clearPendingCapture();
            return null;
        }
    }, [asWriteOne, clearPendingCapture]);

    /**
     * addComponentPhoto(id, roomId, key, photo) — record an uploaded photo on a
     * component WITHOUT scanning it (App review 2026-10-05). `photo` is
     * `{ id, createdAt, mime }`; the caller has ALREADY written the image under
     * its own key (StorageService.saveComponentPhoto), so the metadata never
     * points at a picture that is not there. Strict allowlist via
     * asNormaliseAttachment; `scanId` always starts null.
     *
     * Does NOT stamp `editedAt` (D9: a photo is evidence, not a score). On a
     * completed visit it IS an amendment ("Photos 1 → 2"), like any photo.
     * Returns the whole record, or null.
     */
    var addComponentPhoto = _useCallback_AS(function (id, roomId, key, photo) {
        var componentKey = asOneOf(key, AS_COMPONENT_KEYS);
        var clean = asNormaliseAttachment({
            id: photo && photo.id, createdAt: photo && photo.createdAt, mime: photo && photo.mime, scanId: null
        });
        if (!componentKey || !clean) return null;
        return asWriteOne(id, function (prev) {
            return asStampedWrite(prev, function (next) {
                for (var i = 0; i < next.rooms.length; i++) {
                    if (next.rooms[i].id !== roomId) continue;
                    var components = next.rooms[i].components;
                    var found = null;
                    for (var c = 0; c < components.length; c++) {
                        if (components[c].key === componentKey) found = components[c];
                    }
                    if (!found) {
                        found = asDefaultComponent(componentKey);
                        components.push(found);
                    }
                    for (var a = 0; a < found.attachments.length; a++) {
                        if (found.attachments[a].id === clean.id) return; // idempotent
                    }
                    found.attachments = found.attachments.concat([clean]);
                }
            }, 'component', roomId + '/' + key, asStampOpts());
        });
    }, [asWriteOne]);

    /**
     * removeComponentPhoto(id, roomId, key, { photoId, scanId }) — delete ONE
     * photo from a component (2026-10-05). `photoId` names an uploaded
     * attachment; `scanId` a scan linked to the component (a scanned
     * attachment's scan, or a photo taken through the scan flow). Either may be
     * null, not both.
     *
     * Removes the attachment and the scan id from `photoIds`/`scanIds`, and
     * appends a tombstone (see asNormaliseDeletedPhotos) so neither a restore
     * nor the append-retry can bring it back. The SCAN RECORD is untouched — it
     * stays in scan history. Stamped like any photo change: on a completed
     * visit the change log records "Photos 2 → 1".
     *
     * The caller removes the image key (StorageService.removeComponentPhoto)
     * and queues the cloud delete (SyncService.deleteComponentPhoto) AFTER this
     * returns a record, so the aggregate never names a picture that is gone.
     * Returns the whole record, or null when there was nothing to remove.
     */
    var removeComponentPhoto = _useCallback_AS(function (id, roomId, key, target) {
        var componentKey = asOneOf(key, AS_COMPONENT_KEYS);
        var photoId = asStrOrNull(target && target.photoId, AS_SHORT_MAX);
        var scanId = asStrOrNull(target && target.scanId, AS_SHORT_MAX);
        if (!componentKey || (!photoId && !scanId)) return null;
        return asWriteOne(id, function (prev) {
            return asStampedWrite(prev, function (next) {
                for (var i = 0; i < next.rooms.length; i++) {
                    if (next.rooms[i].id !== roomId) continue;
                    var components = next.rooms[i].components;
                    for (var c = 0; c < components.length; c++) {
                        if (components[c].key !== componentKey) continue;
                        var comp = components[c];
                        comp.attachments = comp.attachments.filter(function (a) {
                            if (photoId && a.id === photoId) {
                                if (a.scanId && !scanId) scanId = a.scanId;  // a scanned attachment unlinks its scan too
                                return false;
                            }
                            return true;
                        });
                        if (scanId) {
                            comp.photoIds = comp.photoIds.filter(function (x) { return x !== scanId; });
                            comp.scanIds = comp.scanIds.filter(function (x) { return x !== scanId; });
                        }
                    }
                }
                next.deletedPhotos = asNormaliseDeletedPhotos(next.deletedPhotos.concat([
                    { photoId: photoId, scanId: scanId, deletedAt: asNow() }
                ]));
            }, 'component', roomId + '/' + key, asStampOpts());
        });
    }, [asWriteOne]);

    /**
     * addSample(id, sample) — a lab sample collected on this visit (§3
     * `samples[]`, slice 3). Strict allowlist via asNormaliseSample: the id is
     * generated here, `result` starts null, and `resultReceivedAt` stays null
     * until the lab reports — which may be weeks (decision 1).
     *
     * Returns the whole record; the new sample is its last entry.
     */
    var addSample = _useCallback_AS(function (id, sample) {
        var newId = asGenerateId();
        return asWriteOne(id, function (prev) {
            return asStampedWrite(prev, function (next) {
                var src = sample || {};
                var record = asNormaliseSample({
                    id: newId,
                    roomId: src.roomId,
                    componentKey: src.componentKey,
                    scanId: src.scanId,
                    sampleType: src.sampleType,
                    sampleRef: src.sampleRef,
                    collectedAt: src.collectedAt,
                    lab: src.lab,
                    sentAt: src.sentAt,
                    resultReceivedAt: null,
                    result: null
                });
                if (record) next.samples = next.samples.concat([record]);
            }, 'sample', newId, asStampOpts());
        });
    }, [asWriteOne]);

    /**
     * setSampleResult(id, sampleId, result) — the lab result, recorded when it
     * arrives (decision 1: that is the event this whole design exists for).
     *
     * Writes `resultReceivedAt` on the sample and the §3 `result` block. On a
     * completed visit this is an amendment like any other write, which is how
     * "pending" flips to "reported" and the trail records when.
     *
     * The genus write-through onto the linked Scan is NOT done here: this store
     * owns assessments, never scan records. The caller pairs this with
     * `asGenusConfirmationFromSample` + `ScanStore.updateScan`, so a failure
     * over there can never cost the inspector the lab result recorded here.
     */
    var setSampleResult = _useCallback_AS(function (id, sampleId, result) {
        if (window.MetricsService) MetricsService.track('lab_result_added', { interpretation: (result && result.interpretation) ? 'recorded' : 'none' }); // product metrics §2 — WHETHER an interpretation was written, never its words
        return asWriteOne(id, function (prev) {
            return asStampedWrite(prev, function (next) {
                var p = result || {};
                for (var i = 0; i < next.samples.length; i++) {
                    if (next.samples[i].id !== sampleId) continue;
                    next.samples[i].resultReceivedAt = asStrOrNull(p.resultReceivedAt, 40);
                    next.samples[i].result = {
                        genus:          asStr(p.genus, AS_SHORT_MAX),
                        speciesText:    asStr(p.speciesText, AS_SHORT_MAX),
                        sporeCount:     asNumOrNull(p.sporeCount),
                        unit:           asStr(p.unit, AS_SHORT_MAX),
                        interpretation: asStr(p.interpretation, AS_TEXT_MAX),
                        notes:          asStr(p.notes, AS_TEXT_MAX)
                    };
                }
            }, 'sample', sampleId, asStampOpts());
        });
    }, [asWriteOne]);

    /** removeSample(id, sampleId) — a sample entered by mistake. */
    var removeSample = _useCallback_AS(function (id, sampleId) {
        return asWriteOne(id, function (prev) {
            return asStampedWrite(prev, function (next) {
                var samples = [];
                for (var i = 0; i < next.samples.length; i++) {
                    if (next.samples[i].id !== sampleId) samples.push(next.samples[i]);
                }
                next.samples = samples;
                // Tombstone (contract R5): the cloud merge unions samples by id,
                // so without it another device's copy would bring this one back.
                next.deletedItems = asNormaliseDeletedItems(next.deletedItems.concat([
                    { kind: 'sample', id: sampleId, deletedAt: asNow() }
                ]));
            }, 'sample', sampleId, asStampOpts());
        });
    }, [asWriteOne]);

    /**
     * updateReadings(id, patch) — the manual DMAT device readings, Section D
     * (§3 `environmentalReadings`). Every key is optional free text, stored as
     * typed so "18.5" and "18,5" both survive to the report.
     *
     * These are readings a technician takes with their own instrument. They are
     * NEVER merged with a Scan's automatic `weatherSnapshot`/`airSnapshot`
     * (CLAUDE.md, PRD §11.6): one is what the inspector measured in the room,
     * the other is what the model said about the outdoor air — averaging or
     * substituting either for the other would corrupt both the report and the
     * data moat.
     */
    var updateReadings = _useCallback_AS(function (id, patch) {
        return asWriteOne(id, function (prev) {
            return asStampedWrite(prev, function (next) {
                var p = patch || {};
                var readings = next.environmentalReadings || {};
                var out = {};
                for (var r = 0; r < AS_READING_KEYS.length; r++) {
                    var key = AS_READING_KEYS[r];
                    out[key] = (p[key] !== undefined) ? asStr(p[key], AS_SHORT_MAX) : asStr(readings[key], AS_SHORT_MAX);
                }
                next.environmentalReadings = out;
            }, 'readings', id, asStampOpts());
        });
    }, [asWriteOne]);

    /** updateRemediation(id, patch) — Section E (§3 `remediation`). The four
     *  option fields are stored verbatim from the enterprise form's lists;
     *  affectedAreaM2 is the VISIT total, not a sum of the component areas. */
    var updateRemediation = _useCallback_AS(function (id, patch) {
        return asWriteOne(id, function (prev) {
            return asStampedWrite(prev, function (next) {
                var p = patch || {};
                if (p.riskClassification !== undefined) next.remediation.riskClassification = asStr(p.riskClassification, AS_SHORT_MAX);
                if (p.method !== undefined)             next.remediation.method = asStr(p.method, AS_SHORT_MAX);
                if (p.containmentType !== undefined)    next.remediation.containmentType = asStr(p.containmentType, AS_SHORT_MAX);
                if (p.ppeLevel !== undefined)           next.remediation.ppeLevel = asStr(p.ppeLevel, AS_SHORT_MAX);
                if (p.affectedAreaM2 !== undefined)     next.remediation.affectedAreaM2 = asNumOrNull(p.affectedAreaM2);
            }, 'remediation', id, asStampOpts());
        });
    }, [asWriteOne]);

    /**
     * signOff(id, patch) — Section F. Typing a name or cert number patches
     * `signoff` only; calling it with `{ sign: true }` stamps `signedAt` and,
     * if the visit has not been completed yet, completes it in the same write.
     *
     * Signing IS completing: an inspector who has signed the report has
     * finished the visit, and leaving it a "draft" afterwards would be a state
     * the paperwork contradicts. It still locks nothing (D2) — a lab result
     * next week amends it like any other later write.
     */
    var signOff = _useCallback_AS(function (id, patch) {
        return asWriteOne(id, function (prev) {
            var p = patch || {};
            var sign = p.sign === true;
            var wasComplete = !!prev.completedAt;
            var next = asStampedWrite(prev, function (draft) {
                if (p.technicianName !== undefined)   draft.signoff.technicianName = asStr(p.technicianName, AS_SHORT_MAX);
                if (p.technicianCertNo !== undefined) draft.signoff.technicianCertNo = asStr(p.technicianCertNo, AS_SHORT_MAX);
                if (sign) {
                    // Fall back to the visit's assessor: the sign-off card shows
                    // those names until the technician types different ones, so
                    // what was on screen is what gets stored.
                    if (!draft.signoff.technicianName) draft.signoff.technicianName = draft.visit.assessorName;
                    if (!draft.signoff.technicianCertNo) draft.signoff.technicianCertNo = draft.visit.assessorCertNo;
                    draft.signoff.signedAt = asNow();
                }
            }, 'signoff', id, asStampOpts());
            if (sign && !wasComplete) {
                next.completedAt = next.signoff.signedAt || asNow();
                next.status = 'complete';
                next = asNormaliseAssessment(next);
            }
            return next;
        });
    }, [asWriteOne]);

    /**
     * removeRoom(id, roomId) — drops a room and everything attached to it.
     *
     * Its component photos go too (code review Q-01 F9): once the room is
     * tombstoned, nothing names those photo ids any more, so backup never
     * uploads them again and restore never asks for them — but the copies
     * already in the bucket would stay there for ever. After the write lands,
     * each photo's local image key is removed and its cloud objects are queued
     * for `POST /delete` exactly as a single photo delete is
     * (SyncService.deleteComponentPhoto: queued, tried now, retried by every
     * backup pass). A scan linked to the room is NOT deleted: it stays in scan
     * history, as with a single photo.
     */
    var removeRoom = _useCallback_AS(function (id, roomId) {
        var photoIds = [];
        var written = asWriteOne(id, function (prev) {
            return asStampedWrite(prev, function (next) {
                var rooms = [];
                for (var i = 0; i < next.rooms.length; i++) {
                    if (next.rooms[i].id !== roomId) { rooms.push(next.rooms[i]); continue; }
                    var comps = next.rooms[i].components || [];
                    for (var c = 0; c < comps.length; c++) {
                        var atts = comps[c].attachments || [];
                        for (var a = 0; a < atts.length; a++) {
                            if (atts[a] && atts[a].id && photoIds.indexOf(atts[a].id) === -1) photoIds.push(atts[a].id);
                        }
                    }
                }
                next.rooms = rooms;
                // Samples reference a room; an orphan sample would show as a
                // pending lab result nothing can open.
                var samples = [];
                for (var s = 0; s < next.samples.length; s++) {
                    if (next.samples[s].roomId !== roomId) samples.push(next.samples[s]);
                }
                next.samples = samples;
                // Tombstone (contract R5): the room's samples go with it, here
                // and — via the normaliser — on every copy that merges this.
                next.deletedItems = asNormaliseDeletedItems(next.deletedItems.concat([
                    { kind: 'room', id: roomId, deletedAt: asNow() }
                ]));
            }, 'room', roomId, asStampOpts());
        });
        if (written && photoIds.length) {
            photoIds.forEach(function (photoId) {
                if (typeof StorageService !== 'undefined' && StorageService.removeComponentPhoto) {
                    Promise.resolve(StorageService.removeComponentPhoto(photoId))['catch'](function (err) {
                        console.warn('AssessmentStore: local photo image not removed (orphan key):', err);
                    });
                }
            });
            // One queue write and one flush for the whole room, not one per photo.
            if (typeof SyncService !== 'undefined' && SyncService.deleteComponentPhotos) {
                Promise.resolve(SyncService.deleteComponentPhotos(photoIds))['catch'](function () { /* never rejects; belt and braces */ });
            }
        }
        return written;
    }, [asWriteOne]);

    /**
     * completeAssessment(id) — "Mark complete". Stamps `completedAt` and moves
     * the status to `complete`. NOT a lock (decision 1): every later write goes
     * through asStampedWrite and becomes an amendment. A visit that is already
     * complete is left alone rather than re-stamped.
     */
    var completeAssessment = _useCallback_AS(function (id) {
        return asWriteOne(id, function (prev) {
            if (prev.completedAt) return prev;
            if (window.MetricsService) MetricsService.track('assessment_completed', mxAssessmentCounts(prev)); // product metrics §2 — three counts, built in MetricsService.jsx; no names, no notes
            var next = asNormaliseAssessment(prev);
            var at = asNextStamp(prev.updatedAt);   // never behind the stamp it replaces (R2)
            next.completedAt = at;
            next.updatedAt = at;
            next.status = 'complete';
            return asNormaliseAssessment(next);
        });
    }, [asWriteOne]);

    /**
     * reopenAssessment(id) — "Reopen to amend". The visit was never locked, so
     * this changes nothing about what can be edited; it records that the
     * inspector came back, which is exactly what the amendment trail is for.
     */
    var reopenAssessment = _useCallback_AS(function (id) {
        return asWriteOne(id, function (prev) {
            if (!prev.completedAt) return prev;
            if (window.MetricsService) MetricsService.track('assessment_amended'); // product metrics §2 — the explicit "reopen to amend", not every stamped write
            return asStampedWrite(prev, function () {}, 'visit', id, asStampOpts('reopened'));
        });
    }, [asWriteOne]);

    /**
     * importRecords(list) — the ONLY way a cloud restore reaches this store
     * (review F1). Merges remote assessments into LIVE state under the service's
     * own newer-wins rule and lets the ordinary 300 ms debounce persist the
     * result, so an edit made while the restore was in flight survives it.
     *
     * Every incoming record goes through `asMigrateAssessment` first: a backup
     * is an untrusted, possibly older-shaped payload, and this store's invariant
     * is that nothing enters the list unnormalised. An unreadable record is
     * dropped, not merged.
     *
     * The merge is `asMergeAssessment` (contract 1.0, R4) — not whole-record
     * newer-wins: rooms, samples, photos, tombstones and the amendment trail
     * are unioned, scalars go to the newer copy, completion is one-way. So two
     * devices editing the same visit both keep their work, and the result does
     * not depend on which device restores first.
     *
     * Returns `{ added, replaced, merged, kept }` — `replaced`: the remote copy
     * won outright; `merged`: the result combines both; `kept`: nothing to take
     * — or null when hydration failed: importing into a store that could not
     * read its own records would write an almost-empty array over a day's work.
     */
    var importRecords = _useCallback_AS(function (records, tombstones) {
        if (!hydratedRef.current) return null;
        // R5: an id the cloud tombstones (`deleted.json`) is removed here and
        // never re-added. Nothing in the app deletes a whole visit today, but
        // readers honour the tombstone so a future delete cannot be undone by
        // a client that predates it.
        var gone = {};
        for (var g = 0; g < (tombstones || []).length; g++) gone[tombstones[g]] = true;
        var clean = [];
        for (var i = 0; i < (records || []).length; i++) {
            var record = asMigrateAssessment(records[i]);
            if (record && record.id && !gone[record.id]) clean.push(record);
        }
        var current = latestRef.current || [];
        var next = current.filter(function (a) { return !(a && gone[a.id]); });
        var removed = current.length - next.length;
        if (clean.length === 0) {
            if (removed) asCommit(next);
            return { added: 0, replaced: 0, merged: 0, kept: 0, removed: removed };
        }
        var added = 0, replaced = 0, merged = 0, kept = 0;
        for (var r = 0; r < clean.length; r++) {
            var at = -1;
            for (var l = 0; l < next.length; l++) if (next[l] && next[l].id === clean[r].id) { at = l; break; }
            if (at === -1) { next.push(clean[r]); added += 1; continue; }
            var result = asMergeAssessment(next[at], clean[r]);
            var before = JSON.stringify(asNormaliseAssessment(next[at]));
            var after = JSON.stringify(result);
            if (after === before) { kept += 1; continue; }
            next[at] = result;
            if (after === JSON.stringify(clean[r])) replaced += 1; else merged += 1;
        }
        if (added || replaced || merged || removed) asCommit(next);
        return { added: added, replaced: replaced, merged: merged, kept: kept, removed: removed };
    }, [asCommit]);

    _useEffect_AS(function () {
        if (typeof SyncService === 'undefined' || !SyncService.registerImporter) return;
        return SyncService.registerImporter('assessments', importRecords);
    }, [importRecords]);

    /* The live region's text (rendered below). Declared before the context
       value so screens can announce their own outcomes through it. */
    var announceState = _useState_AS('');
    var announceText = announceState[0];
    var setAnnounce = announceState[1];
    var announce = _useCallback_AS(function (message) {
        if (typeof message === 'string' && message) setAnnounce(message);
    }, []);

    var contextValue = _useMemo_AS(function () {
        return {
            // --- state ---
            assessments: assessments,
            hydrated: hydrated,
            hydrationFailed: hydrationFailed,
            savedAt: savedAt,
            // Form state (D9): idle | pending | saving | saved | error | disabled.
            saveStatus: saveStatus,
            retrySave: retrySave,
            // In-memory capture intent (slice 3) — never persisted.
            pendingCapture: pendingCapture,
            // --- readers ---
            getById: function (id) {
                for (var i = 0; i < assessments.length; i++) {
                    if (assessments[i].id === id) return assessments[i];
                }
                return null;
            },
            getForProperty: function (propertyId) {
                var out = [];
                for (var i = 0; i < assessments.length; i++) {
                    if (assessments[i].propertyId === propertyId) out.push(assessments[i]);
                }
                return out;
            },
            // --- writers ---
            createAssessment: createAssessment,
            updateVisit: updateVisit,
            updateTeam: updateTeam,
            addRoom: addRoom,
            updateRoom: updateRoom,
            updateComponent: updateComponent,
            updateReadings: updateReadings,
            updateRemediation: updateRemediation,
            signOff: signOff,
            removeRoom: removeRoom,
            completeAssessment: completeAssessment,
            reopenAssessment: reopenAssessment,
            // --- slice 3: photos are scans, samples are lab tests ---
            startCapture: startCapture,
            clearPendingCapture: clearPendingCapture,
            attachScan: attachScan,
            addComponentPhoto: addComponentPhoto,
            removeComponentPhoto: removeComponentPhoto,
            addSample: addSample,
            setSampleResult: setSampleResult,
            removeSample: removeSample,
            // --- slice 6: cloud restore ---
            importRecords: importRecords,
            // The ONE polite live region (D9) for outcomes a screen owns, e.g.
            // "Photo deleted" (2026-10-05). Outcomes only, never progress.
            announce: announce
        };
    }, [assessments, hydrated, hydrationFailed, savedAt, saveStatus, retrySave, pendingCapture, createAssessment, updateVisit,
        updateTeam, addRoom, updateRoom, updateComponent, updateReadings, updateRemediation, signOff,
        removeRoom, completeAssessment, reopenAssessment,
        startCapture, clearPendingCapture, attachScan, addComponentPhoto, removeComponentPhoto, addSample, setSampleResult, removeSample,
        importRecords, announce]);

    /* ONE polite live region for the whole app (D9). Several chips can be on
       screen at once (room header + component sheet), so the announcement
       cannot live in the chip or it would be read twice. Only the OUTCOMES
       are spoken — "saved" and "couldn't save" — never "saving…" on every
       keystroke, which would talk over the inspector's own typing. */
    _useEffect_AS(function () {
        if (saveStatus === 'saved') setAnnounce('All changes saved');
        else if (saveStatus === 'error') setAnnounce('Couldn’t save your changes. Retry is available.');
        else if (saveStatus === 'disabled' && hydrated) setAnnounce('Saving is off for this session. Reload the app.');
    }, [saveStatus, savedAt, hydrated]);

    return React.createElement(AssessmentContext.Provider, { value: contextValue },
        props.children,
        React.createElement('div', {
            role: 'status', 'aria-live': 'polite', 'data-testid': 'assessment-live-region',
            className: 'sr-only'
        }, announceText)
    );
};

/**
 * useAssessments — context hook. Like useUserProfile (and unlike useScanStore)
 * it does NOT throw when the provider is missing: a tree mounted without it
 * degrades to "no assessments", never to a crash.
 */
var useAssessments = function () {
    var ctx = _useContext_AS(AssessmentContext);
    if (ctx) return ctx;
    return {
        assessments: [],
        hydrated: false,
        hydrationFailed: true,
        savedAt: 0,
        saveStatus: 'disabled',
        retrySave: function () {},
        pendingCapture: null,
        getById: function () { return null; },
        getForProperty: function () { return []; },
        createAssessment: function () { return null; },
        updateVisit: function () { return null; },
        updateTeam: function () { return null; },
        addRoom: function () { return null; },
        updateRoom: function () { return null; },
        updateComponent: function () { return null; },
        updateReadings: function () { return null; },
        updateRemediation: function () { return null; },
        signOff: function () { return null; },
        removeRoom: function () { return null; },
        completeAssessment: function () { return null; },
        reopenAssessment: function () { return null; },
        startCapture: function () { return null; },
        clearPendingCapture: function () {},
        attachScan: function () { return null; },
        addComponentPhoto: function () { return null; },
        removeComponentPhoto: function () { return null; },
        addSample: function () { return null; },
        setSampleResult: function () { return null; },
        removeSample: function () { return null; },
        importRecords: function () { return null; },
        announce: function () {}
    };
};

/* ── Form states: ONE vocabulary (ADR-009 D9, 2026-09-28) ─────────────────────
 *
 * Three display states, shared by every assessment surface so a word means the
 * same thing everywhere:
 *
 *   untouched   — hollow grey ring + "Not started" / "Not assessed yet"
 *   in_progress — half-filled forest ring (has data, not finished)
 *   complete    — forest check_circle (only where a real completion rule exists)
 *   changed     — pencil + "Unsaved changes" (an explicit-Save sheet that is dirty)
 *
 * plus the record-level SAVE status (AssessmentSavedChip below): Saving… →
 * Saved {when} → or "Couldn't save · Retry".
 *
 * Colour rule: form state NEVER uses the severity band colours (good #0fbd80 ·
 * fair #d4a373 · poor #D4836B · very_poor #c1440e). Those mean "how bad is the
 * mould"; borrowing them for "did it save" would put a green "fine" signal on
 * an unscored wall. Form state is forest / stone neutrals, and the one error
 * colour is Tailwind red-800 — outside the band set. Never colour alone: every
 * state carries its own word, and the icons differ by SHAPE too.
 */

/** asRelativeSaved — "just now" / "2 min ago" / "3 h ago" / a short date. */
function asRelativeSaved(ms, now) {
    if (typeof ms !== 'number' || !isFinite(ms)) return '';
    var diff = Math.max(0, (now || Date.now()) - ms);
    if (diff < 45000) return 'just now';
    var mins = Math.round(diff / 60000);
    if (mins < 60) return mins + ' min ago';
    var hours = Math.round(mins / 60);
    if (hours < 24) return hours + ' h ago';
    return 'on ' + new Date(ms).toLocaleDateString(undefined, { day: 'numeric', month: 'short' });
}

/** The shape glyph for a form state — inline SVG so no subset-font risk. */
function asStateGlyph(state) {
    if (state === 'complete') {
        return <span className="material-symbols-outlined text-forest text-[14px] leading-none" aria-hidden="true">check_circle</span>;
    }
    if (state === 'changed') {
        return <span className="material-symbols-outlined text-stone-700 text-[13px] leading-none" aria-hidden="true">edit</span>;
    }
    if (state === 'in_progress') {
        return (
            <svg viewBox="0 0 12 12" className="w-3 h-3 shrink-0" aria-hidden="true">
                <circle cx="6" cy="6" r="4.75" fill="none" stroke="#1a4332" strokeWidth="1.5" />
                <path d="M6 1.25 A4.75 4.75 0 0 1 6 10.75 Z" fill="#1a4332" />
            </svg>
        );
    }
    return (
        <svg viewBox="0 0 12 12" className="w-3 h-3 shrink-0" aria-hidden="true">
            <circle cx="6" cy="6" r="4.75" fill="none" stroke="#78716c" strokeWidth="1.5" strokeDasharray="2 1.6" />
        </svg>
    );
}

/**
 * AssessmentStateChip — one form-state chip: glyph + word, never colour alone.
 * Props: state ('untouched'|'in_progress'|'complete'|'changed'), label, testid.
 * Only ever placed on light surfaces (bio-bg cards, sheets) — forest/stone text
 * on a light fill, per the legibility rule.
 */
var AssessmentStateChip = function (props) {
    var state = props.state || 'untouched';
    var textCls = state === 'untouched' ? 'text-stone-600' : (state === 'changed' ? 'text-stone-700' : 'text-forest');
    return (
        <span
            data-testid={props.testid}
            data-state={state}
            className={'inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-background-light text-[10px] font-black whitespace-nowrap ' + textCls}
        >
            {asStateGlyph(state)}
            {props.label}
        </span>
    );
};

/**
 * AssessmentSavedChip — the record-level save status (was: a 2-second "Saved"
 * flash). Autosave screens have no Save button (§5.6), so this is the one
 * place the inspector learns whether their work is kept — and workflow tools
 * (Linear, Jira, Google Docs) keep that status VISIBLE rather than flashing it.
 *
 *   idle      → "Saved {when}" from the record's own updatedAt (prop), if given
 *   pending / saving → neutral spinner + "Saving…"
 *   saved     → forest check + "Saved just now" / "Saved 2 min ago" (ticks)
 *   error     → red-800 error glyph + "Couldn't save" + a 44 px Retry button
 *   disabled  → "Not saving — reload the app" (hydration failed; a retry here
 *               would write an empty list over stored work, so none is offered)
 *
 * Announcements are NOT made here: the provider owns one polite live region,
 * so two chips on screen never read the same news twice.
 */
var AssessmentSavedChip = function (props) {
    var store = useAssessments();
    var status = store.saveStatus || 'idle';
    var tickState = _useState_AS(0);
    var setTick = tickState[1];

    _useEffect_AS(function () {
        var timer = setInterval(function () { setTick(function (t) { return t + 1; }); }, 30000);
        return function () { clearInterval(timer); };
    }, []);

    var base = 'inline-flex items-center gap-1 text-[10px] font-black whitespace-nowrap';

    if (status === 'pending' || status === 'saving') {
        return (
            <span data-testid="assessment-save-status" data-state={status} className={base + ' text-stone-600'}>
                <span className="w-2.5 h-2.5 rounded-full border-2 border-stone-400 border-t-transparent animate-spin motion-reduce:animate-none" aria-hidden="true"></span>
                Saving…
            </span>
        );
    }
    if (status === 'error') {
        return (
            <span data-testid="assessment-save-status" data-state="error" className={base + ' text-red-800'}>
                <span className="material-symbols-outlined text-[14px] leading-none" aria-hidden="true">error</span>
                Couldn’t save
                <button
                    type="button"
                    data-testid="assessment-save-retry"
                    onClick={function (e) { e.stopPropagation(); store.retrySave(); }}
                    className="ml-1 min-h-[44px] min-w-[44px] px-3 rounded-xl bg-forest text-white text-[11px] font-extrabold btn-nature hover:brightness-110"
                >
                    Retry
                </button>
            </span>
        );
    }
    if (status === 'disabled') {
        return (
            <span data-testid="assessment-save-status" data-state="disabled" className={base + ' text-red-800'}>
                <span className="material-symbols-outlined text-[14px] leading-none" aria-hidden="true">error</span>
                Not saving — reload the app
            </span>
        );
    }
    var when = null;
    if (status === 'saved' && store.savedAt) when = store.savedAt;
    else if (props && props.updatedAt) {
        var parsed = Date.parse(props.updatedAt);
        if (isFinite(parsed)) when = parsed;
    }
    if (when === null) return null;
    return (
        <span data-testid="assessment-save-status" data-state={status === 'saved' ? 'saved' : 'idle'} className={base + ' text-forest'}>
            <span className="material-symbols-outlined text-[14px] leading-none" aria-hidden="true">check_circle</span>
            {'Saved ' + asRelativeSaved(when)}
        </span>
    );
};

/* ── Who gets the full Assessment (owner decision 2026-09-29) ─────────────────
 *
 * General users keep the ORIGINAL simple form: ScanObservationForm (odour,
 * textures, severity, notes) on every scan, which renders for everyone and is
 * not touched by this gate. The full Assessment (visits, rooms, report, the
 * Assessments cards) is for specialists and professionals.
 *
 * "Tier, else onboarding" (ADR-007 D2):
 *   subscribed   → the canonical tier decides: Business, Professional or
 *                  Enterprise get it; Essential does not. The
 *                  ENTITLEMENTS_PREVIEW_TIER dev flag counts as a subscription
 *                  (obIsSubscribed / obEffectiveTier already say so), which is
 *                  how both sides are tested on dev.
 *   unsubscribed → the onboarding cohort decides: business / professional /
 *                  enterprise get it; homeowner / unsure / unanswered do not.
 *   MANAGE_MOULD is the master switch above both: off → nobody.
 *
 * Never hide a user's own data: a general user who already HAS assessment
 * records (dev testing before this gate, or a professional who later changed
 * their profile) keeps the cards and can open, finish and report on those
 * visits. Only STARTING a new one is locked (`allowed: false, canOpen: true`).
 *
 * ONE decision, used by every entry point (property card, dashboard card,
 * /assessment/* routes). Surfaces read `useAssessmentAccess()` and nothing
 * else — no flag or cohort checks of their own (DESIGN.md §5).
 */
var AS_ACCESS_MIN_TIER = 'business';
var AS_ACCESS_PRO_COHORTS = ['business', 'professional', 'enterprise'];

/* The one line shown wherever a general user meets the locked "Start" — a
   constant because two places render it and the headless suite asserts it. */
var AS_ACCESS_LOCKED_COPY = 'New assessments are for inspectors and remediation professionals';

/**
 * asAssessmentAccess — the pure decision. Input:
 *   { flagOn, profile, profileHydrated, assessmentsHydrated, assessmentCount }
 * Returns { allowed, canOpen, reason, subscribed, tier }:
 *   allowed  — may START new assessments (and everything else)
 *   canOpen  — may see the cards and open existing records
 *   reason   — 'flag_off' | 'loading' | 'plan' | 'profile' | 'existing_records' | 'general'
 *   subscribed / tier — which way out the locked copy should offer
 * Never throws: a missing profile helper or record degrades to "general".
 */
function asAssessmentAccess(input) {
    var i = input || {};
    var profile = i.profile || null;
    var subscribed = false;
    var tier = 'free';
    try {
        subscribed = typeof obIsSubscribed === 'function' ? !!obIsSubscribed(profile) : false;
        tier = typeof obEffectiveTier === 'function' ? (obEffectiveTier(profile) || 'free') : 'free';
    } catch (e) { subscribed = false; tier = 'free'; }
    var out = function (allowed, canOpen, reason) {
        return { allowed: allowed, canOpen: canOpen, reason: reason, subscribed: subscribed, tier: tier };
    };
    if (!i.flagOn) return out(false, false, 'flag_off');
    // The stored profile has not been read yet: the default profile would say
    // "general" and flash the lock at a professional for one frame.
    if (!i.profileHydrated) return out(false, false, 'loading');
    if (subscribed) {
        if (Tiers.isAtLeast(tier, AS_ACCESS_MIN_TIER)) return out(true, true, 'plan');
    } else {
        var cohort = profile && profile.onboarding ? profile.onboarding.cohort : null;
        if (AS_ACCESS_PRO_COHORTS.indexOf(cohort) !== -1) return out(true, true, 'profile');
    }
    if ((i.assessmentCount || 0) > 0) return out(false, true, 'existing_records');
    // Records not read yet: "you have none" would be a guess.
    if (!i.assessmentsHydrated) return out(false, false, 'loading');
    return out(false, false, 'general');
}

/** useAssessmentAccess — the hook every entry point reads. */
var useAssessmentAccess = function () {
    var flagOn = useFeatureFlag('MANAGE_MOULD');
    var profileStore = useUserProfile();
    var store = useAssessments();
    var count = store.assessments ? store.assessments.length : 0;
    return _useMemo_AS(function () {
        return asAssessmentAccess({
            flagOn: flagOn,
            profile: profileStore.profile,
            profileHydrated: profileStore.hydrated,
            assessmentsHydrated: store.hydrated,
            assessmentCount: count
        });
    }, [flagOn, profileStore.profile, profileStore.hydrated, store.hydrated, count]);
};

/**
 * AssessmentAccessWays — "how to get it", shared by the route explainer and
 * the property card's locked Start. Unsubscribed: the free route is the
 * profile (the user type is what decides for them), offered as a button that
 * reopens onboarding the same way the dashboard's setup chip does. Subscribed:
 * the tier is canonical, so a profile change would do nothing — name the plans.
 */
var AssessmentAccessWays = function (props) {
    var access = props.access;
    var navigate = ReactRouterDOM.useNavigate();
    var profileStore = useUserProfile();
    if (access.subscribed) {
        return (
            <p data-testid="assessment-access-plan" className="text-xs font-medium text-forest leading-relaxed">
                {'Assessments come with the Business, Professional and Enterprise plans. Your plan is ' + Tiers.label(access.tier) + '.'}
            </p>
        );
    }
    return (
        <div className="space-y-2">
            <button
                type="button"
                data-testid="assessment-access-profile"
                onClick={function () { profileStore.openOnboarding(); navigate('/analysis'); }}
                className="w-full min-h-[44px] bg-forest text-white font-extrabold py-3 rounded-xl shadow-clay flex items-center justify-center gap-2 btn-nature hover:brightness-110 transition-all"
            >
                <span className="material-symbols-outlined text-lg text-white" aria-hidden="true">person</span>
                Update your profile
            </button>
            <p className="text-xs font-medium text-forest leading-relaxed">
                Choose a business, professional or enterprise profile, or a Business plan or above.
            </p>
        </div>
    );
};

/**
 * AssessmentAccessLocked — what a direct /assessment/* link shows a general
 * user: locked, not broken (DESIGN.md §5). Never a blank page or a 404.
 */
var AssessmentAccessLocked = function (props) {
    var access = props.access;
    var navigate = ReactRouterDOM.useNavigate();
    var flagOff = access.reason === 'flag_off';
    return (
        <Layout>
            <main className="flex-1 w-full mx-auto pb-36 overflow-y-auto overflow-x-hidden">
                <PageHeader title="Assessment" showMenu={true} />
                <div className="px-5 pt-4">
                    <section
                        data-testid="assessment-locked"
                        data-reason={access.reason}
                        aria-labelledby="assessment-locked-title"
                        className="bio-bg bio-bg-10 rounded-xl p-5 shadow-soft border border-stone-100/50 space-y-4"
                    >
                        <div className="w-12 h-12 rounded-2xl bg-background-light border border-dashed border-sage/50 text-forest flex items-center justify-center">
                            <span className="material-symbols-outlined text-2xl" aria-hidden="true">lock</span>
                        </div>
                        {flagOff ? (
                            <div className="space-y-2">
                                <h2 id="assessment-locked-title" className="text-base font-extrabold text-forest">Assessments aren’t available in this version</h2>
                                <p className="text-xs font-medium text-forest leading-relaxed">Your scans still carry their observations: odour, textures, severity and notes.</p>
                            </div>
                        ) : (
                            <div className="space-y-4">
                                <div className="space-y-2">
                                    <h2 id="assessment-locked-title" className="text-base font-extrabold text-forest leading-snug">
                                        Room-by-room mould assessments are for inspectors and remediation professionals
                                    </h2>
                                    <p className="text-xs font-medium text-forest leading-relaxed">
                                        A visit with rooms, component scores, lab samples and a signed report. For your own home, every scan
                                        already has a quick observation form: odour, textures, severity and notes.
                                    </p>
                                </div>
                                <div className="bg-surface rounded-xl p-4 shadow-soft border border-stone-100/50 space-y-3">
                                    <h3 className="text-[10px] font-black text-forest uppercase tracking-[0.15em]">How to get it</h3>
                                    <AssessmentAccessWays access={access} />
                                </div>
                            </div>
                        )}
                        <button
                            type="button"
                            data-testid="assessment-locked-back"
                            onClick={function () { navigate('/'); }}
                            className="w-full min-h-[44px] text-forest font-extrabold py-2 text-sm rounded-xl flex items-center justify-center gap-2 btn-nature hover:bg-forest hover:text-white transition-all"
                        >
                            <span className="material-symbols-outlined text-lg" aria-hidden="true">arrow_back</span>
                            Back to Home
                        </button>
                    </section>
                </div>
            </main>
        </Layout>
    );
};

/**
 * AssessmentAccessGate — wraps the three /assessment/* routes. Loading renders
 * the pages' own quiet loading line rather than a lock that might be wrong.
 */
var AssessmentAccessGate = function (props) {
    var access = useAssessmentAccess();
    if (access.canOpen) return props.children;
    if (access.reason === 'loading') {
        return (
            <Layout>
                <main className="flex-1 w-full mx-auto pb-36 overflow-y-auto overflow-x-hidden">
                    <PageHeader title="Assessment" showMenu={true} />
                    <div className="px-5 pt-4">
                        <div className="bio-bg bio-bg-10 rounded-xl p-5 shadow-soft border border-stone-100/50">
                            <p className="text-sm font-semibold text-forest">Loading…</p>
                        </div>
                    </div>
                </main>
            </Layout>
        );
    }
    return <AssessmentAccessLocked access={access} />;
};

window.AssessmentProvider = AssessmentProvider;
// Who gets the full Assessment (owner decision 2026-09-29, DESIGN.md §5.1).
window.asAssessmentAccess = asAssessmentAccess;
window.useAssessmentAccess = useAssessmentAccess;
window.AssessmentAccessGate = AssessmentAccessGate;
window.AssessmentAccessLocked = AssessmentAccessLocked;
window.AssessmentAccessWays = AssessmentAccessWays;
window.AS_ACCESS_LOCKED_COPY = AS_ACCESS_LOCKED_COPY;
window.AS_ACCESS_MIN_TIER = AS_ACCESS_MIN_TIER;
window.AS_ACCESS_PRO_COHORTS = AS_ACCESS_PRO_COHORTS;
window.AssessmentSavedChip = AssessmentSavedChip;
window.AssessmentStateChip = AssessmentStateChip;
window.asRelativeSaved = asRelativeSaved;
window.asRoomProgress = asRoomProgress;
window.asSectionState = asSectionState;
window.asComponentNothingFound = asComponentNothingFound;
window.useAssessments = useAssessments;
window.asStatusChip = asStatusChip;
window.asNormaliseAssessment = asNormaliseAssessment;
window.asMigrateAssessment = asMigrateAssessment;
window.asNewRoom = asNewRoom;
window.asDefaultComponent = asDefaultComponent;
window.asComponentScored = asComponentScored;
window.asRoomScored = asRoomScored;
window.asDeriveSummary = asDeriveSummary;
window.asComponentPhotoIds = asComponentPhotoIds;
window.asComponentPhotoItems = asComponentPhotoItems;
window.asNormaliseAttachment = asNormaliseAttachment;
window.asNormaliseDeletedPhotos = asNormaliseDeletedPhotos;
window.asDeletedPhotoIndex = asDeletedPhotoIndex;
window.asUnionDeletedPhotos = asUnionDeletedPhotos;
// The two-device merge and its tombstones (contract 1.0, CLIENT-CONTRACT.md R4/R5).
window.asMergeAssessment = asMergeAssessment;
window.asMergeRevisions = asMergeRevisions;
window.asNormaliseDeletedItems = asNormaliseDeletedItems;
window.asUnionDeletedItems = asUnionDeletedItems;
window.asNextStamp = asNextStamp;
window.asPrepareComponentPhoto = asPrepareComponentPhoto;
window.asGenerateId = asGenerateId;
window.useComponentPhotoImages = useComponentPhotoImages;
window.asNormaliseTeam = asNormaliseTeam;
window.asRoomAssessor = asRoomAssessor;
window.asVisitAssessorNames = asVisitAssessorNames;
window.asRoomPhotoCount = asRoomPhotoCount;
window.asNormaliseAssessmentRef = asNormaliseAssessmentRef;
window.asGenusConfirmationFromSample = asGenusConfirmationFromSample;
window.asPendingSamples = asPendingSamples;
window.asFromLegacyScan = asFromLegacyScan;
window.asMigrateLegacyScans = asMigrateLegacyScans;
window.AS_DIRECTIONS = AS_DIRECTIONS;
window.AS_VENTILATION = AS_VENTILATION;
window.AS_COMPONENT_KEYS = AS_COMPONENT_KEYS;
window.AS_PRIMARY_COMPONENT_KEYS = AS_PRIMARY_COMPONENT_KEYS;
window.AS_ROOM_COMPONENT_TARGET = AS_ROOM_COMPONENT_TARGET;
window.AS_READING_KEYS = AS_READING_KEYS;
window.AS_TEAM_ROLES = AS_TEAM_ROLES;
window.AS_TEAM_MAX = AS_TEAM_MAX;
window.AS_PRO_GATE_COPY = AS_PRO_GATE_COPY;
window.AS_SAMPLE_TYPES = AS_SAMPLE_TYPES;
window.AS_SAMPLE_UNITS = AS_SAMPLE_UNITS;
window.AS_INTERPRETATIONS = AS_INTERPRETATIONS;
window.AS_ASSESSMENT_VERSION = AS_ASSESSMENT_VERSION;
// The change log (ADR-009 D10): pure derivation + render-time time words.
window.asChangeLog = asChangeLog;
window.asChangeLogDays = asChangeLogDays;
window.asChangeLogByline = asChangeLogByline;
window.asRevisionTime = asRevisionTime;
window.asRevisionStamp = asRevisionStamp;
window.asRevisionDayLabel = asRevisionDayLabel;
window.asRevisionTarget = asRevisionTarget;
window.asRevisionValue = asRevisionValue;
window.asStampedWrite = asStampedWrite;
window.asEditorName = asEditorName;
window.AS_REVISION_FIELDS = AS_REVISION_FIELDS;
window.AS_REVISION_DEVICE = AS_REVISION_DEVICE;
window.AS_REVISION_COALESCE_MS = AS_REVISION_COALESCE_MS;
