/**
 * asCsv — the Assessment CSV export (slice 5, Professional+).
 *
 * Spec: architecture/MOULD-ASSESSMENT-MIGRATION-BRIEF.md §6 slice 5 and §8
 * decision 4 — **the Mould Assessment app stays live**, so a spreadsheet an
 * analyst built against its export must keep working against ours. That is the
 * whole design constraint, and it produces exactly one rule:
 *
 *   > The first 47 columns are the source's 47, in the source's order, under
 *   > the source's header text. Mould Detect columns are APPENDED after them.
 *   > Positions never move, for any reason.
 *
 * The source list is `CSV_HEADERS` in the Assessment app's `assets/app.js`
 * (~L1393), which carries the same rule in its own comment ("always append,
 * NEVER reorder the columns above — analyst spreadsheets key on position").
 * Three of its blocks are questions this app does not ask, and they are emitted
 * as `""` rather than filled with something plausible: a guessed answer in a
 * professional deliverable is worse than a blank one. The full column-by-column
 * mapping, with indexes, is the assessment-csv-columns document in the
 * shared-tests folder. (Paths to that folder are spelled out rather than
 * quoted: the deploy allowlist check reads a quoted one as a runtime fetch of a
 * directory the app does not publish.)
 *
 * TWO functions, both PURE and both free of React, so the Swift port can run
 * them against the same fixture (assessment-report-fixture.csv in that same
 * folder, generated from assessment-report-fixture.json and diffed byte for
 * byte):
 *
 *   asCsvRows(assessment, property, scansById, model) → array of arrays
 *       Row 0 is the header row. Then ONE ROW PER PRESENT COMPONENT, and one
 *       row per room for a room with no present components (the source does the
 *       same: a room that was looked at and had nothing in it is a finding, not
 *       an absence).
 *   asCsvText(rows) → the RFC 4180 document: `"` doubled inside quoted fields,
 *       fields quoted only when they contain `,` `"` CR or LF, CRLF between
 *       records, and a UTF-8 BOM in front so Excel opens `°C`, `μg/m³` and
 *       `spores/m³` as text rather than mojibake.
 *
 * **"Photo URLs" holds scan ids, not URLs.** There are no photo URLs in this
 * app: a component photo IS a Scan (ADR-009 D3), its image lives in IndexedDB
 * on the device, and there is no server address to print. The column keeps its
 * position and its name — an analyst's sheet reads column 31 — and carries the
 * ids, which is the only join key this data has. `MD Photo Verdicts` beside it
 * says what those photos concluded. Since 2026-10-05 a component can also
 * carry uploaded photos that were never scanned: those export their PHOTO id
 * here and "Not scanned" as their verdict, so the two columns stay aligned.
 *
 * Determinism, as in `asReportModel`: no `new Date()`, no locale formatting,
 * output is strings/numbers/booleans only. The rows are derived from the report
 * MODEL wherever the model already has the value, so the CSV and the printed
 * report can never disagree about a score, a label or a verdict.
 *
 * File-local helpers use the `acsv` prefix (the app has no module system, so
 * every top-level var is a window global — CLAUDE.md hard rule).
 *
 * Written in ES5 for Babel 6: no const/let, no arrow functions, no template
 * literals.
 */

/**
 * The source's 47, verbatim. Copied rather than imported because the two apps
 * are separate deployments — this array IS the contract, and the headless suite
 * asserts it against a second copy of the list so a silent edit here fails.
 */
var AS_CSV_SOURCE_HEADERS = [
    'Submission ID', 'Submitted At', 'Inspector ID',
    'Date', 'Address', 'Building', 'Building Age', 'Floor',
    'Room Type', 'Assessor', 'Room Description', 'Cardinal Direction', 'Mould Odour', 'Odour Source',
    'Vent Adequate', 'Vent Mechanical', 'Vent Natural', 'Windows Present', 'Windows Openable', 'Windows Open Frequency',
    'Component', 'Damage/Stains', 'Damage Near Ext Wall', 'Visible Mould', 'Mould Near Ext Wall', 'Wet/Damp', 'Wet Near Ext Wall',
    'Materials', 'Assessment Notes', 'Photo Count', 'Photo URLs', 'Room Notes',
    // Source schema v2 (its ADR-0025) — per-room extras and property-level
    // environment answers. We ask none of these.
    'MoistureReadingPct', 'Condensation', 'RoomLeak', 'SampleRecommended',
    'IndoorHumidityPct', 'FloodZone', 'FloodHistory', 'RecentLeak',
    // Source's Spore Station subfloor question set. We carry one fact of it:
    // whether a subfloor component was marked present.
    'SubfloorPresent', 'SubfloorAccess', 'SubfloorGround', 'SubfloorMoistureReadingPct', 'SubfloorVentsClear',
    // Source's voice-note transcripts. Voice notes are deferred here (brief §3).
    'Voice Notes', 'Exterior Voice Notes'
];

/**
 * The Mould Detect columns, APPENDED — never inserted. They carry what this app
 * knows and the source does not: the derived report's headline, the vision
 * verdicts on the component's photos, and the lab work.
 */
var AS_CSV_MD_HEADERS = [
    'MD Report Ref',            // model.ref — "MD-" + the first 8 of the visit id
    'MD Overall Severity',      // the whole visit's roll-up word (None/Low/Moderate/Severe)
    'MD Status',                // the DERIVED report status: Action Required / Monitor / Satisfactory
    'MD Component Severity',    // this component's compMax word; blank on a room-only row
    'MD Photo Verdicts',        // what each photo's scan concluded, in photo order
    'MD Samples',               // lab samples taken against this component
    'MD Lab Genus',             // reported genus/genera for those samples
    'MD Lab Interpretation',    // the lab's own words (High / Elevated / Normal / …)
    'MD Lab Received',          // when each result came back
    'MD Completed At',          // the visit's completedAt
    'MD Amended At',            // the visit's amendedAt
    'MD Revisions'              // how many amendment stamps the record carries
];

var AS_CSV_HEADERS = AS_CSV_SOURCE_HEADERS.concat(AS_CSV_MD_HEADERS);

/* The source's own odour wording (`form-config.js` odourLevels) against our
   0–3 scale. Same four words the room screen and the report use. */
var ACSV_ODOUR_WORDS = ['None', 'Faint', 'Noticeable', 'Strong'];

/* Our ventilation enum → the source's "Is the ventilation adequate?" answers
   (Yes / Uncertain / No). Identical to AR_VENTILATION_ANSWER in asReportModel —
   the report and the export must not be able to disagree — and an UNANSWERED
   ventilation stays blank rather than becoming "No". */
var ACSV_VENTILATION_ANSWER = { Good: 'Yes', Fair: 'Uncertain', Poor: 'No', None: 'No' };

/* The source's `ynna` vocabulary for SubfloorPresent: Yes / No / N-A. */
var ACSV_YES = 'Yes';
var ACSV_NO = 'No';

/* Multi-value cells are joined with "; " — the separator the source uses for
   Materials and Assessment Notes, reused for every list we add so one split
   rule reads the whole file. */
var ACSV_JOIN = '; ';

/** acsvStr — a trimmed string from anything; '' for null/undefined/objects. */
function acsvStr(value) {
    if (value === null || value === undefined) return '';
    if (typeof value === 'number') return isFinite(value) ? String(value) : '';
    if (typeof value === 'boolean') return value ? 'true' : 'false';
    if (typeof value !== 'string') return '';
    return value.replace(/^\s+|\s+$/g, '');
}

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

/** acsvJoin — a list of values as one cell, blanks dropped. */
function acsvJoin(list) {
    var out = [];
    var src = acsvArray(list);
    for (var i = 0; i < src.length; i++) {
        var s = acsvStr(src[i]);
        if (s) out.push(s);
    }
    return out.join(ACSV_JOIN);
}

/**
 * acsvEscape — one field, RFC 4180.
 *
 * Quoted only when it has to be (comma, quote, CR or LF), with `"` doubled
 * inside. Booleans print as `true`/`false`, matching the source's near-exterior
 * -wall cells, which are JavaScript booleans stringified the same way.
 */
function acsvEscape(value) {
    var s;
    if (value === null || value === undefined) s = '';
    else if (typeof value === 'boolean') s = value ? 'true' : 'false';
    else if (typeof value === 'number') s = isFinite(value) ? String(value) : '';
    else s = String(value);
    if (/[",\r\n]/.test(s)) return '"' + s.replace(/"/g, '""') + '"';
    return s;
}

/** acsvSourceRoom — the stored room behind a model room, by id. */
function acsvSourceRoom(assessment, roomId) {
    var rooms = acsvArray(assessment && assessment.rooms);
    for (var i = 0; i < rooms.length; i++) {
        if (rooms[i] && acsvStr(rooms[i].id) === roomId) return rooms[i];
    }
    return null;
}

/**
 * acsvSubfloorPresent — the one subfloor fact we hold.
 *
 * "Yes"/"No" when the room carries a subfloor component at all, '' when it does
 * not. A room nobody asked the question about must not read as "No": that is an
 * answer, and we do not have one.
 */
function acsvSubfloorPresent(sourceRoom) {
    var comps = acsvArray(sourceRoom && sourceRoom.components);
    for (var i = 0; i < comps.length; i++) {
        if (comps[i] && comps[i].key === 'subfloor') return comps[i].present === true ? ACSV_YES : ACSV_NO;
    }
    return '';
}

/**
 * acsvSamplesFor — the samples a row is about.
 *
 * A component row takes the samples pinned to that component; a ROOM-ONLY row
 * (a room with no present components) takes every sample in the room. The two
 * can never double-count, because a room only produces a room-only row when it
 * has no component rows at all.
 */
function acsvSamplesFor(assessment, roomId, componentKey) {
    var out = [];
    var samples = acsvArray(assessment && assessment.samples);
    for (var i = 0; i < samples.length; i++) {
        var sample = samples[i];
        if (!sample || acsvStr(sample.roomId) !== roomId) continue;
        if (componentKey !== null && acsvStr(sample.componentKey) !== componentKey) continue;
        out.push(sample);
    }
    return out;
}

/** acsvLabCells — {genus, interpretation, received} across a row's samples. */
function acsvLabCells(samples) {
    var genus = [];
    var interpretation = [];
    var received = [];
    for (var i = 0; i < samples.length; i++) {
        var result = (samples[i].result && typeof samples[i].result === 'object') ? samples[i].result : null;
        // A pending sample contributes nothing to these three cells — it is a
        // question, not a finding (ADR-009 D3a). It is still counted in
        // "MD Samples", which is how a reader sees that one is outstanding.
        if (!result) continue;
        if (acsvStr(result.genus)) genus.push(acsvStr(result.genus));
        if (acsvStr(result.interpretation)) interpretation.push(acsvStr(result.interpretation));
        if (acsvStr(samples[i].resultReceivedAt)) received.push(acsvStr(samples[i].resultReceivedAt));
    }
    return {
        genus: genus.join(ACSV_JOIN),
        interpretation: interpretation.join(ACSV_JOIN),
        received: received.join(ACSV_JOIN)
    };
}

/**
 * asCsvRows — the whole export as an array of arrays, header row first.
 *
 * @param {object} assessment — the Assessment aggregate (ADR-009 §3)
 * @param {object} property   — the Property record, or null
 * @param {object} scansById  — { scanId: scanRecord }, for the photo verdicts
 * @param {object} model      — an `asReportModel` output; derived here when the
 *                              caller has none, so the two surfaces always
 *                              agree about labels, scores and verdicts.
 * @returns {Array<Array>} rows — header row, then one row per present
 *          component (one per room where a room has none).
 */
function asCsvRows(assessment, property, scansById, model) {
    var rows = [AS_CSV_HEADERS.slice(0)];
    if (!assessment || typeof assessment !== 'object') return rows;

    var scans = (scansById && typeof scansById === 'object') ? scansById : {};
    var m = model;
    if (!m && typeof asReportModel === 'function') {
        // Entitlements `{}` and no branding: nothing in the CSV is branded, and
        // the resolver's decision has no effect on a single cell here.
        m = asReportModel(assessment, property || null, scans, {}, null);
    }
    if (!m) return rows;

    var submissionId = acsvStr(assessment.id);
    var submittedAt = acsvStr(assessment.completedAt) || acsvStr(assessment.updatedAt);
    var inspectorId = acsvStr(m.visit.assessorCertNo);
    var address = acsvStr(m.property.address) || acsvStr(m.property.name);
    var completedAt = acsvStr(assessment.completedAt);
    var amendedAt = acsvStr(assessment.amendedAt);
    var revisions = acsvArray(assessment.revisions).length;

    for (var r = 0; r < m.rooms.length; r++) {
        var room = m.rooms[r];
        var sourceRoom = acsvSourceRoom(assessment, room.id);

        /* Columns 1–20. `Assessor` is the ROOM's assessor (resolved: the room's
           own name, or the visit lead) — which is the entire point of carrying
           a team: an analyst can group a visit's rows by who walked the room. */
        var base = [
            submissionId, submittedAt, inspectorId,
            acsvStr(m.visit.date), address, acsvStr(m.visit.building), acsvStr(m.visit.buildingAge), acsvStr(m.visit.floor),
            acsvStr(room.name), acsvStr(room.assessorName),
            '',                                  // Room Description — not captured
            acsvStr(room.direction),
            ACSV_ODOUR_WORDS[room.odour] || '',
            acsvStr(room.odourSource),
            ACSV_VENTILATION_ANSWER[acsvStr(room.ventilation)] || '',
            '', '',                              // Vent Mechanical / Natural — not asked
            '', '', ''                           // Windows Present / Openable / Open Frequency — not asked
        ];

        /* Columns 33–47. Everything blank except the subfloor presence we do
           hold; repeated on every row of the room, as the source repeats its
           own property-level tail. */
        var tail = [
            '', '', '', '',                      // MoistureReadingPct · Condensation · RoomLeak · SampleRecommended
            '', '', '', '',                      // IndoorHumidityPct · FloodZone · FloodHistory · RecentLeak
            acsvSubfloorPresent(sourceRoom),     // SubfloorPresent
            '', '', '', '',                      // SubfloorAccess · Ground · MoistureReadingPct · VentsClear
            '', ''                               // Voice Notes · Exterior Voice Notes (deferred)
        ];

        /* The Mould Detect tail that is the same on every row of the visit. */
        var visitTail = [completedAt, amendedAt, revisions];

        if (room.components.length === 0) {
            var roomSamples = acsvSamplesFor(assessment, room.id, null);
            var roomLab = acsvLabCells(roomSamples);
            rows.push(base.concat([
                '', '', '', '', '', '', '',      // Component + the three scores and their near-ext flags
                '', '',                          // Materials · Assessment Notes
                '', '',                          // Photo Count · Photo URLs
                ''                               // Room Notes — not captured
            ], tail, [
                m.ref, m.risk.word, m.status,
                '',                              // MD Component Severity — there is no component
                '',                              // MD Photo Verdicts
                roomSamples.length, roomLab.genus, roomLab.interpretation, roomLab.received
            ], visitTail));
            continue;
        }

        for (var c = 0; c < room.components.length; c++) {
            var comp = room.components[c];
            var samples = acsvSamplesFor(assessment, room.id, comp.key);
            var lab = acsvLabCells(samples);

            var photoIds = [];
            var verdicts = [];
            for (var p = 0; p < comp.photos.length; p++) {
                // An uploaded photo that was never scanned has no scan id;
                // its photo id keeps the column aligned with the verdicts
                // ("Not scanned") beside it (2026-10-05).
                photoIds.push(comp.photos[p].scanId || comp.photos[p].photoId || '');
                verdicts.push(comp.photos[p].verdict.label);
            }

            // ADR-009 D9: a photo-only component the inspector never scored
            // is exported with BLANK score and near-ext cells, never 0 — a
            // blank is "not recorded", a 0 would be a clean finding nobody
            // made. Same columns; MD Component Severity reads "Not assessed".
            var scoredRow = comp.assessed !== false;
            rows.push(base.concat([
                comp.label,
                scoredRow ? comp.damageOrStains : '', scoredRow ? comp.nearExtWall[0] : '',
                scoredRow ? comp.visibleMould : '',   scoredRow ? comp.nearExtWall[1] : '',
                scoredRow ? comp.wetOrDamp : '',      scoredRow ? comp.nearExtWall[2] : '',
                acsvJoin(comp.materials),
                acsvStr(comp.notes),
                comp.photoCount,
                acsvJoin(photoIds),
                ''                               // Room Notes — not captured
            ], tail, [
                m.ref, m.risk.word, m.status,
                comp.severity.word,
                acsvJoin(verdicts),
                samples.length, lab.genus, lab.interpretation, lab.received
            ], visitTail));
        }
    }

    return rows;
}

/**
 * asCsvText — rows → the file, RFC 4180 with a UTF-8 BOM.
 *
 * The BOM is not decoration: without it Excel on Windows reads the file as the
 * system code page and the units in the notes (`°C`, `μg/m³`, `spores/m³`)
 * arrive as mojibake in a document an inspector hands to a client. No trailing
 * CRLF — the source's export has none either, and a trailing blank line reads
 * as an empty row in some tools.
 */
function asCsvText(rows) {
    var src = acsvArray(rows);
    var lines = [];
    for (var i = 0; i < src.length; i++) {
        var row = acsvArray(src[i]);
        var cells = [];
        for (var c = 0; c < row.length; c++) cells.push(acsvEscape(row[c]));
        lines.push(cells.join(','));
    }
    return '﻿' + lines.join('\r\n');
}

/** asCsvFileName — "MD-a7f3c1d2.csv" from the model's ref. One place, so the
 *  toolbar button and any future share sheet name the file identically. */
function asCsvFileName(model) {
    var ref = acsvStr(model && model.ref) || 'MD-export';
    return ref.replace(/[^A-Za-z0-9._-]/g, '-') + '.csv';
}

/* ── The amendment history as its OWN file (ADR-009 D10, 2026-09-28) ────────

   Why a second CSV and not a trailing section in the first: RFC 4180 is one
   table per file — every record has the same field count, the first line is
   the header. A "blank line, then a second table" appendix is invisible to that
   contract: Excel shows it as ragged rows under the data, pandas / Power Query /
   an analyst's positional import read it as more component rows with a
   different meaning in column 1, and the byte-for-byte fixture the Swift port
   diffs would change for every visit that was ever amended. The 47 + 12
   columns are the cross-application contract (D7); this file sits BESIDE it,
   uniform and self-describing, joined back on `Report Ref`.

   One row per amendment, newest first (the order the report prints). Values
   are the change log's own words, so the three surfaces agree; an empty value
   is an empty cell (not "—", which is a display glyph). `Changed At (UTC)` is
   ISO 8601 — sortable, zone-unambiguous, what an auditor's tooling expects. */
var AS_CSV_AMENDMENT_HEADERS = [
    'Report Ref', 'Changed At (UTC)', 'Changed By', 'Category', 'Target', 'Field', 'From', 'To', 'Target Ref'
];

var ACSV_CATEGORY_WORDS = {
    visit: 'Visit details', room: 'Room', component: 'Component', sample: 'Lab sample',
    readings: 'Readings', remediation: 'Remediation', signoff: 'Sign-off'
};

/** acsvAmendValue — a change-log display value as a CSV cell ("—" → ""). */
function acsvAmendValue(text) {
    var s = acsvStr(text);
    return s === '—' ? '' : s;
}

/**
 * asCsvAmendmentRows(assessment) → array of arrays. Row 0 is the header row.
 * PURE, derived from `asChangeLog` (AssessmentStore.jsx, read at call time).
 * A visit with no amendments yields the header row alone.
 */
function asCsvAmendmentRows(assessment) {
    var rows = [AS_CSV_AMENDMENT_HEADERS.slice()];
    if (!assessment || typeof assessment !== 'object' || typeof asChangeLog !== 'function') return rows;
    var ref = 'MD-' + acsvStr(assessment.id).slice(0, 8);
    var log = asChangeLog(assessment);
    for (var i = 0; i < log.length; i++) {
        var e = log[i];
        rows.push([
            ref,
            acsvStr(e.at),
            acsvStr(e.by),
            ACSV_CATEGORY_WORDS[e.what] || acsvStr(e.what),
            acsvStr(e.target),
            acsvStr(e.fieldLabel),
            acsvAmendValue(e.fromText),
            acsvAmendValue(e.toText),
            acsvStr(e.ref)
        ]);
    }
    return rows;
}

/** asCsvAmendmentFileName — "MD-a7f3c1d2-amendments.csv", beside the main export. */
function asCsvAmendmentFileName(model) {
    return asCsvFileName(model).replace(/\.csv$/, '') + '-amendments.csv';
}

window.asCsvRows = asCsvRows;
window.asCsvText = asCsvText;
window.asCsvFileName = asCsvFileName;
window.asCsvAmendmentRows = asCsvAmendmentRows;
window.asCsvAmendmentFileName = asCsvAmendmentFileName;
window.AS_CSV_AMENDMENT_HEADERS = AS_CSV_AMENDMENT_HEADERS;
window.AS_CSV_HEADERS = AS_CSV_HEADERS;
window.AS_CSV_SOURCE_HEADERS = AS_CSV_SOURCE_HEADERS;
window.AS_CSV_MD_HEADERS = AS_CSV_MD_HEADERS;
