<?php
/**
 * Temperature & Humidity Laboratory Work Form
 * Maps directly to the Python main() calibration loop:
 *   - Certificate info (header)
 *   - Per-setpoint: ref temps, ref RH x2, EUT temps+RH
 *   - Auto-calculates std dev, deviations, and both uncertainties
 *     using the HumidityUncertaintyEngine (PHP port of Python script)
 *
 * Read-only fields from $request: equipment_name, manufacturer,
 * model, serial_number, company_name, request_number
 */
$pageTitle = 'Humidity Lab Form — ' . e($request['request_number']);
include __DIR__ . '/../../shared/header.php';

$f   = $form ?? [];
$s   = fn($k, $def = '') => htmlspecialchars((string)($f[$k] ?? $def), ENT_QUOTES, 'UTF-8');
$sel = fn($k, $val)       => ($f[$k] ?? '') == $val ? 'selected' : '';

$num_sp = (int)($f['num_setpoints']      ?? 3);
$num_m  = (int)($f['num_of_measurements'] ?? 5);
$res_rh   = $f['res_rh_eut']   ?? '0.1';
$res_temp = $f['res_temp_eut']  ?? '0.1';
?>

<div class="page-header">
  <div>
    <div class="page-title"><i class="bi bi-thermometer-half me-2"></i>Temperature &amp; Humidity Laboratory Work Form</div>
    <div class="page-subtitle">
      <code><?= e($request['request_number']) ?></code> &mdash;
      <?= e($request['lab_name']) ?>
      <?php if (!empty($f['form_status'])): ?>
        <span class="badge badge-<?= $f['form_status'] ?> ms-2"><?= ucfirst($f['form_status']) ?></span>
      <?php endif; ?>
    </div>
  </div>
  <a href="<?= APP_URL ?>/index.php?page=requests&action=view&id=<?= $request['request_id'] ?>" class="btn btn-outline-secondary">
    <i class="bi bi-arrow-left me-1"></i>Back to Request
  </a>
</div>

<!-- Read-only instrument summary from existing DB tables -->
<div class="card mb-3 border-0 bg-light">
  <div class="card-body py-2">
    <div class="row g-2 small">
      <div class="col-md-3"><span class="text-muted d-block" style="font-size:10px;">INSTRUMENT</span><strong><?= e($request['equipment_name']) ?></strong></div>
      <div class="col-md-2"><span class="text-muted d-block" style="font-size:10px;">MANUFACTURER</span><?= e($request['manufacturer']) ?></div>
      <div class="col-md-2"><span class="text-muted d-block" style="font-size:10px;">TYPE / MODEL</span><?= e($request['model']) ?></div>
      <div class="col-md-2"><span class="text-muted d-block" style="font-size:10px;">SERIAL NO.</span><code><?= e($request['serial_number']) ?></code></div>
      <div class="col-md-3"><span class="text-muted d-block" style="font-size:10px;">CUSTOMER</span><?= e($request['company_name']) ?></div>
    </div>
  </div>
</div>

<form method="POST" id="humidity-form">
  <input type="hidden" name="csrf_token" value="<?= csrfToken() ?>">

  <!-- ════════════════════════════════════════════════════════
       SECTION 1 — Certificate Info (mirrors Python certificate_info dict)
  ════════════════════════════════════════════════════════ -->
  <div class="card mb-3">
    <div class="card-header"><span class="card-title"><i class="bi bi-file-text me-2"></i>Certificate Information</span></div>
    <div class="card-body">
      <div class="row g-3">
        <div class="col-md-3">
          <label class="form-label">Certificate Number <span class="text-danger">*</span></label>
          <input type="text" class="form-control" name="certificate_number"
                 value="<?= $s('certificate_number') ?>" required placeholder="e.g. GSA/THL/2025/001">
        </div>

        <!-- Read-only: order number from requests table -->
        <div class="col-md-3">
          <label class="form-label">Order / Laboratory No. <span class="badge bg-secondary" style="font-size:9px;">auto</span></label>
          <input type="text" class="form-control bg-light" value="<?= e($request['request_number']) ?>" readonly>
        </div>

        <div class="col-md-3">
          <label class="form-label">Calibration Date(s)</label>
          <input type="text" class="form-control" name="calibration_date"
                 value="<?= $s('calibration_date') ?>" placeholder="e.g. 2025-01-30 to 2025-01-31">
        </div>

        <div class="col-md-3">
          <label class="form-label">Date of Issue <span class="text-danger">*</span></label>
          <input type="date" class="form-control" name="date_of_issue"
                 value="<?= $s('date_of_issue', date('Y-m-d')) ?>" required>
        </div>

        <!-- Read-only: instrument type from equipment.model -->
        <div class="col-md-3">
          <label class="form-label">Instrument Type <span class="badge bg-secondary" style="font-size:9px;">from equipment</span></label>
          <input type="text" class="form-control bg-light" value="<?= e($request['model']) ?>" readonly>
        </div>

        <!-- Read-only: serial from equipment table -->
        <div class="col-md-3">
          <label class="form-label">Serial Number <span class="badge bg-secondary" style="font-size:9px;">from equipment</span></label>
          <input type="text" class="form-control bg-light" value="<?= e($request['serial_number']) ?>" readonly>
        </div>

        <!-- Read-only: customer from companies table -->
        <div class="col-md-3">
          <label class="form-label">Customer <span class="badge bg-secondary" style="font-size:9px;">from company</span></label>
          <input type="text" class="form-control bg-light" value="<?= e($request['company_name']) ?>" readonly>
        </div>

        <div class="col-md-3">
          <label class="form-label">Place of Test</label>
          <input type="text" class="form-control" name="place_of_test"
                 value="<?= $s('place_of_test') ?>" placeholder="e.g. GSA Humidity Laboratory">
        </div>

        <div class="col-md-6">
          <label class="form-label">Procedure</label>
          <input type="text" class="form-control" name="procedure"
                 value="<?= $s('procedure', 'DKD-R 5-8:2019 (Humidity) / DKD R-5-1:2018 (Temperature)') ?>">
        </div>

        <div class="col-md-3">
          <label class="form-label">Calibrated By</label>
          <input type="text" class="form-control" name="calibrated_by" value="<?= $s('calibrated_by') ?>">
        </div>

        <div class="col-md-3">
          <label class="form-label">Checked By</label>
          <input type="text" class="form-control" name="checked_by"
                 value="<?= $s('checked_by', $_SESSION['name'] ?? '') ?>">
        </div>

        <div class="col-md-3">
          <label class="form-label">Due Date</label>
          <input type="date" class="form-control" name="due_date" value="<?= $s('due_date') ?>">
        </div>
      </div>
    </div>
  </div>

  <!-- ════════════════════════════════════════════════════════
       SECTION 2 — Measurement Configuration
  ════════════════════════════════════════════════════════ -->
  <div class="card mb-3">
    <div class="card-header"><span class="card-title"><i class="bi bi-sliders me-2"></i>Measurement Configuration</span></div>
    <div class="card-body">
      <div class="row g-3 align-items-end">
        <div class="col-md-3">
          <label class="form-label">Number of Setpoints <span class="text-danger">*</span>
            <small class="text-muted">(1–5)</small></label>
          <input type="number" class="form-control" name="num_setpoints" id="num_setpoints"
                 min="1" max="5" value="<?= $num_sp ?>" required>
        </div>
        <div class="col-md-3">
          <label class="form-label">Readings per Setpoint <span class="text-danger">*</span></label>
          <input type="number" class="form-control" name="num_of_measurements" id="num_of_measurements"
                 min="1" max="20" value="<?= $num_m ?>" required>
        </div>
        <div class="col-md-2">
          <label class="form-label">EUT RH Resolution (%RH)</label>
          <input type="number" class="form-control" name="res_rh_eut" id="res_rh_eut"
                 step="0.01" min="0.001" value="<?= $res_rh ?>">
        </div>
        <div class="col-md-2">
          <label class="form-label">EUT Temp Resolution (°C)</label>
          <input type="number" class="form-control" name="res_temp_eut" id="res_temp_eut"
                 step="0.01" min="0.001" value="<?= $res_temp ?>">
        </div>
        <div class="col-md-2">
          <button type="button" class="btn btn-outline-primary w-100" id="buildSetpoints">
            <i class="bi bi-gear me-1"></i>Build Setpoint Tabs
          </button>
        </div>
      </div>

      <!-- ── Setpoint value pairs ── -->
      <div id="setpointValuePairs" class="mt-3" style="display:none;">
        <div class="border rounded p-3 bg-light">
          <div class="d-flex align-items-center mb-2 gap-2">
            <i class="bi bi-thermometer-sun text-primary"></i>
            <span class="fw-semibold">Setpoint Target Values</span>
            <small class="text-muted">— enter target values, then upload a file and click Populate All, or click Build to enter readings manually</small>
          </div>
          <div class="row g-2" id="setpointValueRows"></div>
        </div>
      </div>

      <!-- ── Single global file upload ── -->
      <div class="mt-3 border rounded p-3" style="background:rgba(13,110,253,.05);border-color:rgba(13,110,253,.25)!important;">
        <label class="form-label mb-1 fw-semibold">
          <i class="bi bi-file-earmark-spreadsheet me-1 text-primary"></i>ALMEMO Data File
          <small class="text-muted fw-normal ms-1">(optional &mdash; populates all setpoints at once)</small>
        </label>
        <div class="d-flex align-items-center flex-wrap gap-3">
          <input type="file" class="form-control form-control-sm" id="global_file_input"
                 accept=".xls,.xlsx,.csv" style="max-width:340px;">
          <button type="button" class="btn btn-primary" id="populateAllBtn" disabled>
            <i class="bi bi-lightning-charge-fill me-1"></i>Populate All Setpoints
          </button>
          <button type="button" class="btn btn-outline-secondary btn-sm" id="clearFileBtn" style="display:none;">
            <i class="bi bi-x-circle me-1"></i>Clear file
          </button>
        </div>
        <div id="global_file_status" class="mt-2" style="display:none;"></div>
      </div>
    </div>
  </div>

  <!-- ════════════════════════════════════════════════════════
       SECTION 3 — Setpoint tabs (dynamically generated)
  ════════════════════════════════════════════════════════ -->
  <div class="card mb-3" id="setpointCard">
    <div class="card-header p-0 border-bottom-0">
      <ul class="nav nav-tabs px-3 pt-2" id="setpointTabs" role="tablist"></ul>
    </div>
    <div class="card-body">
      <div class="tab-content" id="setpointTabContent"></div>
    </div>
  </div>

  <!-- ════════════════════════════════════════════════════════
       SECTION 4 — Live Results Preview
  ════════════════════════════════════════════════════════ -->
  <div class="card mb-3" id="resultsPreviewCard" style="display:none;">
    <div class="card-header"><span class="card-title"><i class="bi bi-table me-2"></i>Calculated Results Preview</span></div>
    <div class="card-body">
      <!-- RH Results -->
      <h6 class="fw-bold mb-2">Relative Humidity Results</h6>
      <div class="table-responsive mb-3">
        <table class="table table-bordered table-sm" id="rh-results-table">
          <thead class="table-light">
            <tr>
              <th>Setpoint</th><th>Ref Temp (°C)</th><th>Ref RH (%RH)</th>
              <th>EUT RH (%RH)</th><th>Deviation (%RH)</th><th>Uncertainty U (%RH)</th>
            </tr>
          </thead>
          <tbody id="rh-results-body"></tbody>
        </table>
      </div>
      <!-- Temperature Results -->
      <h6 class="fw-bold mb-2">Temperature Results <small class="text-muted fw-normal">(at ~50 %RH setpoints)</small></h6>
      <div class="table-responsive">
        <table class="table table-bordered table-sm" id="temp-results-table">
          <thead class="table-light">
            <tr>
              <th>Setpoint</th><th>Ref Temp (°C)</th><th>EUT Temp (°C)</th>
              <th>Deviation (°C)</th><th>Uncertainty U (K)</th>
            </tr>
          </thead>
          <tbody id="temp-results-body"></tbody>
        </table>
      </div>
    </div>
  </div>

  <!-- ── Comments ─────────────────────────────────────────── -->
  <div class="card mb-3">
    <div class="card-header"><span class="card-title">Comments</span></div>
    <div class="card-body">
      <textarea name="comments" class="form-control" rows="3"
                placeholder="Any observations or additional remarks..."><?= $s('comments') ?></textarea>
    </div>
  </div>

  <!-- ── Submit ────────────────────────────────────────────── -->
  <div class="d-flex gap-3 mb-4 align-items-center">
    <button type="submit" name="form_status" value="draft" class="btn btn-outline-primary btn-lg">
      <i class="bi bi-floppy me-2"></i>Save as Draft
    </button>
    <button type="submit" name="form_status" value="submitted" class="btn btn-success btn-lg">
      <i class="bi bi-check-circle me-2"></i>Submit &amp; Generate Certificate
    </button>
    <a href="<?= APP_URL ?>/index.php?page=requests&action=view&id=<?= $request['request_id'] ?>" class="btn btn-outline-secondary btn-lg">
      Cancel
    </a>
  </div>

</form>

<!-- ════════ Saved data for JS re-population ════════ -->
<script>
// Saved form data (for re-populating tabs on page load)
const SAVED = <?= json_encode([
    'num_sp'  => $num_sp,
    'num_m'   => $num_m,
    'res_rh'  => (float)$res_rh,
    'res_temp'=> (float)$res_temp,
    'setpoints' => (function() use ($f, $num_sp) {
        $sp = [];
        for ($i = 1; $i <= $num_sp; $i++) {
            $p = "sp{$i}_";
            $sp[$i] = [
                'label'       => $f["{$p}setpoint_label"]    ?? "SP{$i}",
                'temp_sp'     => $f["{$p}temp_setpoint"]      ?? null,
                'rh_sp'       => $f["{$p}rh_setpoint"]        ?? null,
                'ref_temps'   => json_decode($f["{$p}ref_temps"]        ?? '[]', true) ?? [],
                'corr_temp'   => $f["{$p}correction_temp"]              ?? 0,
                'ref_rh1'     => json_decode($f["{$p}ref_rh1_readings"] ?? '[]', true) ?? [],
                'corr_rh1'    => $f["{$p}correction_rh1"]               ?? 0,
                'ref_rh2'     => json_decode($f["{$p}ref_rh2_readings"] ?? '[]', true) ?? [],
                'corr_rh2'    => $f["{$p}correction_rh2"]               ?? 0,
                'eut_temps'   => json_decode($f["{$p}eut_temps"]        ?? '[]', true) ?? [],
                'eut_rhs'     => json_decode($f["{$p}eut_rhs"]          ?? '[]', true) ?? [],
            ];
        }
        return $sp;
    })(),
]) ?>;

// ════════════════════════════════════════════════════════════
// Uncertainty Engine — JavaScript port of HumidityUncertaintyEngine.php
// Mirrors the Python functions exactly for live preview.
// ════════════════════════════════════════════════════════════

const MAGNUS_A = 6.112, MAGNUS_B = 17.62, MAGNUS_C = 243.12;

function partialDerivativeRhWrtTemp(temp_c, dew_point_c) {
    const alpha = (MAGNUS_B * temp_c)       / (MAGNUS_C + temp_c);
    const beta  = (MAGNUS_B * dew_point_c)  / (MAGNUS_C + dew_point_c);
    const e_s   = MAGNUS_A * Math.exp(alpha);
    const e_d   = MAGNUS_A * Math.exp(beta);
    const d_es_dT = e_s * MAGNUS_B * MAGNUS_C / Math.pow(MAGNUS_C + temp_c, 2);
    return -100.0 * (e_d / (e_s * e_s)) * d_es_dT;
}

function stdev(arr) {
    if (arr.length < 2) return 0;
    const mean = arr.reduce((a,b) => a+b, 0) / arr.length;
    const sq   = arr.reduce((a,b) => a + Math.pow(b - mean, 2), 0);
    return Math.sqrt(sq / (arr.length - 1));
}

function calcUncertaintyRh(std_dev_ref, std_dev_eut, res_eut, k=2) {
    const s3 = Math.sqrt(3);
    const sens = partialDerivativeRhWrtTemp(10, -6.77);
    const sv = std_dev_ref;
    const s_std = Math.pow(sv/1,2) + Math.pow(0.7/2,2) + Math.pow(0.7/2,2)
        + Math.pow(0.1/(2*s3),2) + Math.pow(0.1/(2*s3),2) + Math.pow(0.5/s3,2)
        + Math.pow(0.1/s3,2) + Math.pow(0.1/s3,2) + Math.pow(0.31/s3,2)
        + Math.pow(0.21/s3,2) + Math.pow(0/s3,2) + Math.pow(0.6/s3,2)
        + Math.pow(0*sens,2) + Math.pow(0.25/s3*sens,2);
    const s_chamb = Math.pow(0.01/s3,2) + Math.pow(0.29/s3,2) + Math.pow(0.09/s3,2);
    const s_eut = Math.pow(std_dev_eut/1,2) + Math.pow(res_eut/(2*s3),2)
        + Math.pow(0,2) + Math.pow(0.1/s3*sens,2) + Math.pow(0,2);
    return +(k * Math.sqrt(s_std + s_chamb + s_eut)).toFixed(3);
}

function calcUncertaintyTemp(std_dev_ref, std_dev_eut, res_eut, k=2) {
    const s3 = Math.sqrt(3);
    const s_std = Math.pow(0.01/(2*s3),2) + Math.pow(0.2/s3,2) + Math.pow(0.23/s3,2)
        + Math.pow(0.001/s3,2) + Math.pow(0.001/s3,2) + Math.pow(0/s3,2)
        + Math.pow(0.03/s3,2) + Math.pow(0.01/s3,2) + Math.pow(0.2/s3,2)
        + Math.pow(std_dev_ref/s3,2);
    const s_chamb = Math.pow(0.3/s3,2) + Math.pow(0.14/s3,2) + Math.pow(0.01/s3,2);
    const s_eut = Math.pow(std_dev_eut/1,2) + Math.pow(res_eut/(2*s3),2)
        + Math.pow(0,2) + Math.pow(0,2) + Math.pow(0,2);
    return +(k * Math.sqrt(s_std + s_chamb + s_eut)).toFixed(3);
}

function processSetpoint(data, res_rh, res_temp) {
    const ref_temps  = data.ref_temps.map(Number).filter(v => !isNaN(v));
    const ref_rh1    = data.ref_rh1.map(Number).filter(v => !isNaN(v));
    const ref_rh2    = data.ref_rh2.map(Number).filter(v => !isNaN(v));
    const eut_temps  = data.eut_temps.map(Number).filter(v => !isNaN(v));
    const eut_rhs    = data.eut_rhs.map(Number).filter(v => !isNaN(v));

    if (!ref_temps.length || !eut_temps.length || !eut_rhs.length) return null;

    const avg_ref_temp       = ref_temps.reduce((a,b)=>a+b,0) / ref_temps.length;
    const corrected_ref_temp = avg_ref_temp + Number(data.corr_temp || 0);

    const avg_rh1 = ref_rh1.length ? ref_rh1.reduce((a,b)=>a+b,0)/ref_rh1.length : 0;
    const avg_rh2 = ref_rh2.length ? ref_rh2.reduce((a,b)=>a+b,0)/ref_rh2.length : 0;
    const corr_rh1 = (avg_rh1 + Number(data.corr_rh1 || 0));
    const corr_rh2 = (avg_rh2 + Number(data.corr_rh2 || 0));
    const corrected_ref_rh = (corr_rh1 + corr_rh2) / 2;

    const avg_eut_temp = eut_temps.reduce((a,b)=>a+b,0) / eut_temps.length;
    const avg_eut_rh   = eut_rhs.reduce((a,b)=>a+b,0)   / eut_rhs.length;
    const std_temp     = stdev(eut_temps);
    const std_rh       = stdev(eut_rhs);

    const temp_dev = avg_eut_temp - corrected_ref_temp;
    const rh_dev   = avg_eut_rh   - corrected_ref_rh;

    const temp_unc = calcUncertaintyTemp(std_temp, std_temp, res_temp);
    const rh_unc   = calcUncertaintyRh(std_rh, std_rh, res_rh);

    return {
        label:               data.label || 'SP?',
        corrected_ref_temp:  corrected_ref_temp.toFixed(4),
        corrected_ref_rh:    corrected_ref_rh.toFixed(4),
        avg_eut_temp:        avg_eut_temp.toFixed(4),
        avg_eut_rh:          avg_eut_rh.toFixed(4),
        std_eut_temp:        std_temp.toFixed(6),
        std_eut_rh:          std_rh.toFixed(6),
        temp_deviation:      temp_dev.toFixed(4),
        rh_deviation:        rh_dev.toFixed(4),
        temp_uncertainty:    temp_unc,
        rh_uncertainty:      rh_unc,
        include_temp:        corrected_ref_rh > 45 && corrected_ref_rh < 55,
    };
}

// ════════════════════════════════════════════════════════════
// XLS File Parser — reads ALMEMO data and extracts readings
// ════════════════════════════════════════════════════════════

/**
 * Parse the uploaded XLS/CSV data in the browser.
 * We expect the ALMEMO format: 7-row header, then data rows with
 * columns: DATE, TIME, M00, M01, M02, M03, M04, M05, M10, M11, ...
 * Indices (0-based after stripping col 0 DATE and col 1 TIME):
 *   col index 3  = M01 (reference temperature °C)
 *   col index 8  = M10 (ref RH sensor 1 %RH)
 *   col index 9  = M11 (ref RH sensor 2 %RH)
 */

// Parsed file data stored here so all setpoints can access it
let PARSED_FILE_DATA = null;

/**
 * Read a .xls file using SheetJS (loaded via CDN below).
 * Returns array of row objects: { M01, M10, M11 }
 */
async function parseAlmemoFile(file) {
    return new Promise((resolve, reject) => {
        const reader = new FileReader();
        reader.onload = (e) => {
            try {
                const data = new Uint8Array(e.target.result);
                const workbook = XLSX.read(data, { type: 'array' });
                const sheet = workbook.Sheets[workbook.SheetNames[0]];
                const rows = XLSX.utils.sheet_to_json(sheet, { header: 1, defval: null });

                // Find header row: look for a row containing "M01" or "M01: °C"
                let headerRowIdx = -1;
                for (let i = 0; i < Math.min(rows.length, 15); i++) {
                    const row = rows[i];
                    if (row && row.some(cell => typeof cell === 'string' && cell.includes('M01'))) {
                        headerRowIdx = i;
                        break;
                    }
                }
                if (headerRowIdx === -1) {
                    reject(new Error('Could not find header row with M01 column. Please ensure this is an ALMEMO data file.'));
                    return;
                }

                const headers = rows[headerRowIdx].map(h => (h || '').toString().trim());
                // Find column indices by header name (flexible matching)
                const findCol = (pattern) => headers.findIndex(h => h.replace(/\s/g,'').toUpperCase().includes(pattern.toUpperCase()));
                const idxM01 = findCol('M01');
                const idxM10 = findCol('M10');
                const idxM11 = findCol('M11');

                if (idxM01 === -1) { reject(new Error('M01 column not found in file.')); return; }
                if (idxM10 === -1) { reject(new Error('M10 column not found in file.')); return; }
                if (idxM11 === -1) { reject(new Error('M11 column not found in file.')); return; }

                const parsed = [];
                for (let i = headerRowIdx + 1; i < rows.length; i++) {
                    const row = rows[i];
                    if (!row || row.every(c => c === null || c === '')) continue;
                    const m01 = parseFloat(row[idxM01]);
                    const m10 = parseFloat(row[idxM10]);
                    const m11 = parseFloat(row[idxM11]);
                    if (isNaN(m01) && isNaN(m10) && isNaN(m11)) continue;
                    parsed.push({ M01: isNaN(m01) ? null : m01,
                                  M10: isNaN(m10) ? null : m10,
                                  M11: isNaN(m11) ? null : m11 });
                }

                resolve(parsed);
            } catch (err) {
                reject(new Error('Failed to parse file: ' + err.message));
            }
        };
        reader.onerror = () => reject(new Error('Failed to read file.'));
        reader.readAsArrayBuffer(file);
    });
}

/**
 * Sliding-window standard deviation — pick the N most stable readings.
 * Tries every consecutive window of size N; picks the window with lowest stdev.
 * Falls back to non-consecutive best-N if not enough consecutive values exist.
 */
function pickMostStable(values, n) {
    if (values.length <= n) return values.slice();
    let bestWindow = null, bestStd = Infinity;
    for (let i = 0; i <= values.length - n; i++) {
        const window = values.slice(i, i + n);
        const sd = stdev(window);
        if (sd < bestStd) { bestStd = sd; bestWindow = window; }
    }
    return bestWindow || values.slice(0, n);
}

/**
 * Extract readings for one setpoint from the parsed file data.
 *
 * ── Decision flow ──────────────────────────────────────────────────────────
 *
 * STRICT FILTER (attempted first for all three channels):
 *   Ref Temps : M01 ±TEMP_TOL  AND  M10 ±RH_TOL  AND  M11 ±RH_TOL  (joint)
 *   Sensor 1  : M10 ±RH_TOL   AND  M01 ±TEMP_TOL                   (joint)
 *   Sensor 2  : M11 ±RH_TOL   AND  M01 ±TEMP_TOL                   (joint)
 *
 * FALLBACK — HUMIDITY SETPOINTS (RH ≠ 50 %RH):
 *   Triggered when the strict joint filter produces < FALLBACK_MIN rows for
 *   Sensor 1 or Sensor 2 individually.
 *
 *   1. Evaluate each sensor's individual strict filter independently.
 *   2. Pick the most stable FALLBACK_MIN values from that sensor's readings.
 *      If both sensors individually qualify, prefer the one with lower stdev.
 *   3. Identify the row-indices that produced those FALLBACK_MIN values.
 *   4. Use those same row-indices to pull:
 *        • The corresponding values from the OTHER humidity sensor (same rows).
 *        • The corresponding M01 temperature values (same rows).
 *   The pulled cross-channel values may be outside tolerance — they are
 *   reported as-is so the operator can judge their acceptability.
 *
 * FALLBACK — TEMPERATURE SETPOINTS (RH ≈ 50 %RH):
 *   Triggered when the strict joint filter on Ref Temps produces < FALLBACK_MIN rows.
 *
 *   1. Evaluate M01 alone (no RH guard), get its individual strict rows.
 *   2. Pick the most stable FALLBACK_MIN temperature values.
 *   3. Pull M10 and M11 from the same rows without requiring them to be
 *      within ±RH_TOL of the setpoint.
 *
 * FALLBACK is never triggered silently — the result always carries a
 * `fallback` descriptor that the status UI renders as a distinct amber badge.
 *
 * Returns:
 *   refTemps, refRh1, refRh2  — selected value arrays (may be empty)
 *   counts      — { temp, rh1, rh2 }  strict-filter row counts (before fallback)
 *   fallback    — null | { usedFor, refChannel, refCount, strictFailReason }
 *   diagnostics — { temp, rh1, rh2 }  human-readable explanations when strict count = 0
 */
function extractSetpointReadings(fileRows, tempSetpoint, rhSetpoint, numReadings) {
    const TEMP_TOL     = 3;           // ±3 °C
    const RH_TOL       = 3;           // ±3 %RH
    const FALLBACK_MIN = numReadings; // need at least numReadings stable values to qualify

    const isTemp50 = Math.abs(rhSetpoint - 50) < 0.001;

    // ── Helper: build per-channel zero-match diagnostic string ───────────────
    function strictDiagRh(sensor, sensorCol, otherOk, sensorOk, sensorVals, m01vals) {
        if (sensorVals.length === 0)
            return `${sensorCol} (${sensor}) contains no numeric data in the file`;
        const mn = Math.min(...sensorVals).toFixed(2), mx = Math.max(...sensorVals).toFixed(2);
        if (sensorOk === 0)
            return `${sensorCol} (${sensor}) has no values within ±${RH_TOL}%RH of ${rhSetpoint}%RH — file range is ${mn}–${mx}%RH`;
        if (otherOk === 0) {
            const t1 = Math.min(...m01vals).toFixed(2), t2 = Math.max(...m01vals).toFixed(2);
            return `${sensorCol} matched ${sensorOk} rows but M01 (Ref Temp) has no values within ±${TEMP_TOL}°C of ${tempSetpoint}°C — file range is ${t1}–${t2}°C`;
        }
        return `${sensorCol} matched ${sensorOk} rows and M01 matched ${otherOk} rows individually, but no row satisfies both simultaneously`;
    }

    // ════════════════════════════════════════════════════════════════════════
    // STEP 1 — STRICT JOINT FILTERS
    // ════════════════════════════════════════════════════════════════════════

    // Joint filter for Ref Temps: all three channels within tolerance
    const strictTemp = fileRows.filter(r =>
        r.M01 !== null && r.M10 !== null && r.M11 !== null &&
        Math.abs(r.M01 - tempSetpoint) <= TEMP_TOL &&
        Math.abs(r.M10 - rhSetpoint)   <= RH_TOL   &&
        Math.abs(r.M11 - rhSetpoint)   <= RH_TOL
    );

    // Joint filter for Sensor 1: M10 + M01 within tolerance
    const strictRh1 = fileRows.filter(r =>
        r.M01 !== null && r.M10 !== null &&
        Math.abs(r.M10 - rhSetpoint)   <= RH_TOL &&
        Math.abs(r.M01 - tempSetpoint) <= TEMP_TOL
    );

    // Joint filter for Sensor 2: M11 + M01 within tolerance
    const strictRh2 = fileRows.filter(r =>
        r.M01 !== null && r.M11 !== null &&
        Math.abs(r.M11 - rhSetpoint)   <= RH_TOL &&
        Math.abs(r.M01 - tempSetpoint) <= TEMP_TOL
    );

    // ════════════════════════════════════════════════════════════════════════
    // STEP 2 — BUILD STRICT DIAGNOSTICS (always, regardless of fallback)
    // ════════════════════════════════════════════════════════════════════════

    const diagnostics = { temp: null, rh1: null, rh2: null };
    const m01vals_all = fileRows.map(r => r.M01).filter(v => v !== null);
    const m10vals_all = fileRows.map(r => r.M10).filter(v => v !== null);
    const m11vals_all = fileRows.map(r => r.M11).filter(v => v !== null);

    if (strictTemp.length === 0) {
        const m01Only = fileRows.filter(r => r.M01 !== null && Math.abs(r.M01 - tempSetpoint) <= TEMP_TOL);
        const m10Only = fileRows.filter(r => r.M10 !== null && Math.abs(r.M10 - rhSetpoint) <= RH_TOL);
        const m11Only = fileRows.filter(r => r.M11 !== null && Math.abs(r.M11 - rhSetpoint) <= RH_TOL);
        const parts = [];
        if (m01Only.length === 0) {
            const mn = Math.min(...m01vals_all).toFixed(2), mx = Math.max(...m01vals_all).toFixed(2);
            parts.push(`M01 (Ref Temp) has no values within ±${TEMP_TOL}°C of ${tempSetpoint}°C — file range is ${mn}–${mx}°C`);
        }
        if (m10Only.length === 0) {
            if (m10vals_all.length) {
                const mn = Math.min(...m10vals_all).toFixed(2), mx = Math.max(...m10vals_all).toFixed(2);
                parts.push(`M10 (Sensor 1) has no values within ±${RH_TOL}%RH of ${rhSetpoint}%RH — file range is ${mn}–${mx}%RH`);
            } else {
                parts.push(`M10 (Sensor 1) has no numeric values in the file`);
            }
        }
        if (m11Only.length === 0) {
            if (m11vals_all.length) {
                const mn = Math.min(...m11vals_all).toFixed(2), mx = Math.max(...m11vals_all).toFixed(2);
                parts.push(`M11 (Sensor 2) has no values within ±${RH_TOL}%RH of ${rhSetpoint}%RH — file range is ${mn}–${mx}%RH`);
            } else {
                parts.push(`M11 (Sensor 2) has no numeric values in the file`);
            }
        }
        if (parts.length === 0) {
            parts.push(`M01 matched ${m01Only.length} rows and sensors matched individually, but no single row satisfies all three conditions simultaneously — temperature and RH readings were not stable together at the setpoint values`);
        }
        diagnostics.temp = parts.join('; ');
    }

    if (strictRh1.length === 0) {
        const m01ok = fileRows.filter(r => r.M01 !== null && Math.abs(r.M01 - tempSetpoint) <= TEMP_TOL).length;
        const m10ok = fileRows.filter(r => r.M10 !== null && Math.abs(r.M10 - rhSetpoint) <= RH_TOL).length;
        diagnostics.rh1 = strictDiagRh('Sensor 1', 'M10', m01ok, m10ok, m10vals_all, m01vals_all);
    }

    if (strictRh2.length === 0) {
        const m01ok = fileRows.filter(r => r.M01 !== null && Math.abs(r.M01 - tempSetpoint) <= TEMP_TOL).length;
        const m11ok = fileRows.filter(r => r.M11 !== null && Math.abs(r.M11 - rhSetpoint) <= RH_TOL).length;
        diagnostics.rh2 = strictDiagRh('Sensor 2', 'M11', m01ok, m11ok, m11vals_all, m01vals_all);
    }

    // ════════════════════════════════════════════════════════════════════════
    // STEP 3 — ATTEMPT STRICT SELECTION (happy path)
    // ════════════════════════════════════════════════════════════════════════

    // pickMostStableRows — wraps the outer pickMostStable() to also return the
    // originating rows, which the fallback path needs for cross-channel pulls.
    // All window-selection logic lives in pickMostStable(); this adds no duplication.
    function pickMostStableRows(rows, getVal, n) {
        if (rows.length === 0) return { rows: [], values: [] };
        const vals        = rows.map(getVal);
        const stableVals  = pickMostStable(vals, n);   // use original outer function
        // Find the starting offset of the chosen window so we can slice the rows array
        const windowStart = rows.findIndex((_, i) =>
            i + stableVals.length <= rows.length &&
            rows.slice(i, i + stableVals.length).map(getVal).join(',') === stableVals.join(',')
        );
        const start    = windowStart >= 0 ? windowStart : 0;
        const selected = rows.slice(start, start + stableVals.length);
        return { rows: selected, values: stableVals };
    }

    const strictTempSel = pickMostStableRows(strictTemp, r => r.M01, numReadings);
    const strictRh1Sel  = pickMostStableRows(strictRh1,  r => r.M10, numReadings);
    const strictRh2Sel  = pickMostStableRows(strictRh2,  r => r.M11, numReadings);

    // ════════════════════════════════════════════════════════════════════════
    // STEP 4 — FALLBACK (only if strict gave < FALLBACK_MIN rows)
    // ════════════════════════════════════════════════════════════════════════

    let fallback = null;

    // ── Helper: describe why the strict filter failed (short form) ───────
    function strictFailReason(strictCount, m01Count, sensorCount, sensorLabel) {
        if (strictCount === 0 && m01Count === 0)
            return `M01 matched 0 rows (temperature out of range)`;
        if (strictCount === 0 && sensorCount === 0)
            return `${sensorLabel} matched 0 rows (humidity out of range)`;
        if (strictCount < FALLBACK_MIN)
            return `strict joint filter produced only ${strictCount} row(s), fewer than the ${FALLBACK_MIN} needed`;
        return `strict filter produced ${strictCount} rows`;
    }

    if (!isTemp50) {
        // ── HUMIDITY FALLBACK ─────────────────────────────────────────────
        // Only triggered when the strict selection for a sensor produced
        // fewer than FALLBACK_MIN values.
        const rh1StrictOk = strictRh1Sel.values.length >= FALLBACK_MIN;
        const rh2StrictOk = strictRh2Sel.values.length >= FALLBACK_MIN;

        if (!rh1StrictOk || !rh2StrictOk) {
            // Individual per-sensor filters (M01 only guards temperature)
            const indivRh1 = fileRows.filter(r =>
                r.M01 !== null && r.M10 !== null &&
                Math.abs(r.M10 - rhSetpoint)   <= RH_TOL &&
                Math.abs(r.M01 - tempSetpoint) <= TEMP_TOL
            );
            const indivRh2 = fileRows.filter(r =>
                r.M01 !== null && r.M11 !== null &&
                Math.abs(r.M11 - rhSetpoint)   <= RH_TOL &&
                Math.abs(r.M01 - tempSetpoint) <= TEMP_TOL
            );

            // Check which individual sensor has enough stable readings
            const indivRh1Sel = pickMostStableRows(indivRh1, r => r.M10, FALLBACK_MIN);
            const indivRh2Sel = pickMostStableRows(indivRh2, r => r.M11, FALLBACK_MIN);
            const rh1Qualifies = indivRh1Sel.values.length >= FALLBACK_MIN;
            const rh2Qualifies = indivRh2Sel.values.length >= FALLBACK_MIN;

            if (rh1Qualifies || rh2Qualifies) {
                // Choose reference sensor: if both qualify, prefer lower stdev
                let refSel, refSensor, otherSensor, refCol, otherCol, otherValFn;
                const sd1 = rh1Qualifies ? stdev(indivRh1Sel.values) : Infinity;
                const sd2 = rh2Qualifies ? stdev(indivRh2Sel.values) : Infinity;

                if (sd1 <= sd2) {
                    refSel = indivRh1Sel; refSensor = 'Sensor 1 (M10)';
                    otherSensor = 'Sensor 2 (M11)'; refCol = 'M10'; otherCol = 'M11';
                    otherValFn  = r => r.M11;
                } else {
                    refSel = indivRh2Sel; refSensor = 'Sensor 2 (M11)';
                    otherSensor = 'Sensor 1 (M10)'; refCol = 'M11'; otherCol = 'M10';
                    otherValFn  = r => r.M10;
                }

                // Cross-pull: use the SAME rows as the reference to get the other channel + M01
                const crossRows    = refSel.rows;
                const crossOther   = crossRows.map(otherValFn).filter(v => v !== null);
                const crossTemps   = crossRows.map(r => r.M01).filter(v => v !== null);

                // Build reason describing why strict filter was insufficient
                const m01ok  = fileRows.filter(r => r.M01 !== null && Math.abs(r.M01 - tempSetpoint) <= TEMP_TOL).length;
                const m10ok  = fileRows.filter(r => r.M10 !== null && Math.abs(r.M10 - rhSetpoint) <= RH_TOL).length;
                const m11ok  = fileRows.filter(r => r.M11 !== null && Math.abs(r.M11 - rhSetpoint) <= RH_TOL).length;
                const whyFailed = !rh1StrictOk
                    ? strictFailReason(strictRh1.length, m01ok, m10ok, 'M10')
                    : strictFailReason(strictRh2.length, m01ok, m11ok, 'M11');

                fallback = {
                    usedFor:         'humidity',
                    refChannel:      refSensor,
                    otherChannel:    otherSensor,
                    refCount:        refSel.values.length,
                    strictFailReason: whyFailed,
                    strictRh1Count:  strictRh1.length,
                    strictRh2Count:  strictRh2.length,
                };

                // Override results with fallback selections
                return {
                    refTemps:   crossTemps,
                    refRh1:     refCol === 'M10' ? refSel.values : crossOther,
                    refRh2:     refCol === 'M11' ? refSel.values : crossOther,
                    counts:     { temp: strictTemp.length, rh1: strictRh1.length, rh2: strictRh2.length },
                    fallback,
                    diagnostics,
                };
            }
            // Neither sensor qualifies individually — fall through to strict results (empty)
        }

    } else {
        // ── TEMPERATURE FALLBACK (RH ≈ 50 %RH) ──────────────────────────
        // Triggered when strict joint filter for Ref Temps produced < FALLBACK_MIN.
        if (strictTempSel.values.length < FALLBACK_MIN) {
            // Individual M01 filter: temperature alone, no RH guard
            const indivTemp = fileRows.filter(r =>
                r.M01 !== null &&
                Math.abs(r.M01 - tempSetpoint) <= TEMP_TOL
            );
            const indivTempSel = pickMostStableRows(indivTemp, r => r.M01, FALLBACK_MIN);

            if (indivTempSel.values.length >= FALLBACK_MIN) {
                // Pull M10 and M11 from the same rows without RH tolerance guard
                const crossRows  = indivTempSel.rows;
                const crossRh1   = crossRows.map(r => r.M10).filter(v => v !== null);
                const crossRh2   = crossRows.map(r => r.M11).filter(v => v !== null);

                const m10ok = fileRows.filter(r => r.M10 !== null && Math.abs(r.M10 - rhSetpoint) <= RH_TOL).length;
                const m11ok = fileRows.filter(r => r.M11 !== null && Math.abs(r.M11 - rhSetpoint) <= RH_TOL).length;
                const whyFailed = strictFailReason(strictTemp.length,
                    indivTemp.length,          // m01 count (used as proxy)
                    Math.min(m10ok, m11ok),    // weakest RH sensor count
                    'RH sensors');

                fallback = {
                    usedFor:         'temperature',
                    refChannel:      'M01 (Ref Temp)',
                    otherChannel:    'M10 + M11 (cross-pulled from same rows)',
                    refCount:        indivTempSel.values.length,
                    strictFailReason: whyFailed,
                    strictTempCount: strictTemp.length,
                };

                return {
                    refTemps:   indivTempSel.values,
                    refRh1:     crossRh1,
                    refRh2:     crossRh2,
                    counts:     { temp: strictTemp.length, rh1: strictRh1.length, rh2: strictRh2.length },
                    fallback,
                    diagnostics,
                };
            }
            // M01 alone also couldn't reach FALLBACK_MIN — fall through to strict (empty)
        }
    }

    // ════════════════════════════════════════════════════════════════════════
    // STEP 5 — RETURN STRICT RESULTS (no fallback triggered or needed)
    // Identical to the original extractSetpointReadings return — pickMostStable
    // is called directly here, exactly as it was before the fallback was added.
    // ════════════════════════════════════════════════════════════════════════
    return {
        refTemps:   strictTemp.length ? pickMostStable(strictTemp.map(r => r.M01), numReadings) : [],
        refRh1:     strictRh1.length  ? pickMostStable(strictRh1.map(r => r.M10),  numReadings) : [],
        refRh2:     strictRh2.length  ? pickMostStable(strictRh2.map(r => r.M11),  numReadings) : [],
        counts:     { temp: strictTemp.length, rh1: strictRh1.length, rh2: strictRh2.length },
        fallback:   null,
        diagnostics,
    };
}

// ════════════════════════════════════════════════════════════
// Dynamic tab builder
// ════════════════════════════════════════════════════════════

function buildTab(sp, numM, savedData, tempSetpointVal, rhSetpointVal) {
    const d = savedData || {};
    const makeInputs = (prefix, label, values, numM) => {
        let html = `<div class="col-12"><label class="form-label fw-semibold">${label}</label></div>`;
        for (let i = 1; i <= numM; i++) {
            const val = (values && values[i-1] !== undefined) ? values[i-1] : '';
            html += `<div class="col-auto">
              <label class="form-label" style="font-size:11px;">#${i}</label>
              <input type="number" step="any" class="form-control form-control-sm reading-input"
                     style="width:90px;" data-sp="${sp}" data-type="${prefix}" value="${val}">
            </div>`;
        }
        return html;
    };

    // Format setpoint label from target values if provided
    const spLabel = d.label || (
        (tempSetpointVal !== null && rhSetpointVal !== null)
            ? `${tempSetpointVal}°C / ${rhSetpointVal}%RH`
            : `SP${sp}`
    );

    // Resolve display values: prefer explicitly passed args, fall back to saved data
    const resolvedTemp = tempSetpointVal !== null ? tempSetpointVal : (d.temp_sp !== null && d.temp_sp !== undefined ? d.temp_sp : '');
    const resolvedRh   = rhSetpointVal   !== null ? rhSetpointVal   : (d.rh_sp   !== null && d.rh_sp   !== undefined ? d.rh_sp   : '');

    // Encode setpoint values as data attributes for the auto-populate button
    const tempAttr = tempSetpointVal !== null ? `data-temp-sp="${tempSetpointVal}"` : '';
    const rhAttr   = rhSetpointVal   !== null ? `data-rh-sp="${rhSetpointVal}"`     : '';

    return `
<div class="row g-3 align-items-end">
  <div class="col-md-3">
    <label class="form-label">Setpoint Label</label>
    <input type="text" class="form-control" name="sp${sp}_setpoint_label"
           value="${escHtml(spLabel)}" placeholder="e.g. 23°C / 50%RH">
  </div>
  <div class="col-md-3">
    <label class="form-label">Temp Setpoint (°C)</label>
    <input type="number" step="any" class="form-control sp-temp-val" id="sp${sp}_temp_sp_val"
           name="sp${sp}_temp_setpoint"
           placeholder="e.g. 23" value="${escHtml(String(resolvedTemp))}">
  </div>
  <div class="col-md-3">
    <label class="form-label">Humidity Setpoint (%RH)</label>
    <input type="number" step="any" class="form-control sp-rh-val" id="sp${sp}_rh_sp_val"
           name="sp${sp}_rh_setpoint"
           placeholder="e.g. 50" value="${escHtml(String(resolvedRh))}">
  </div>
</div>

<!-- File status / feedback for this setpoint (written by populateAll) -->
<div id="sp${sp}_file_status" class="mt-1" style="display:none;"></div>

<input type="hidden" name="sp${sp}_ref_temps"        id="sp${sp}_ref_temps"        value="${JSON.stringify(d.ref_temps  ||[])}">
<input type="hidden" name="sp${sp}_ref_rh1_readings" id="sp${sp}_ref_rh1_readings" value="${JSON.stringify(d.ref_rh1    ||[])}">
<input type="hidden" name="sp${sp}_ref_rh2_readings" id="sp${sp}_ref_rh2_readings" value="${JSON.stringify(d.ref_rh2    ||[])}">
<input type="hidden" name="sp${sp}_eut_temps"         id="sp${sp}_eut_temps"        value="${JSON.stringify(d.eut_temps  ||[])}">
<input type="hidden" name="sp${sp}_eut_rhs"           id="sp${sp}_eut_rhs"          value="${JSON.stringify(d.eut_rhs    ||[])}">

<div class="row g-3 mt-1 gy-2">
  ${makeInputs('ref_temp', '🌡 Reference Temperature Readings (°C)', d.ref_temps, numM)}
</div>
<div class="row g-3 mt-2">
  <div class="col-md-4">
    <label class="form-label">Correction for Ref Temp (°C)</label>
    <input type="number" step="any" class="form-control" name="sp${sp}_correction_temp"
           data-sp="${sp}" data-type="corr_temp" value="${d.corr_temp||0}">
  </div>
</div>

<div class="row g-3 mt-2 gy-2">
  ${makeInputs('ref_rh1', '💧 Ref RH Sensor 1 Readings (%RH)', d.ref_rh1, numM)}
  <div class="col-md-4 align-self-end">
    <label class="form-label">Correction Sensor 1 (%RH)</label>
    <input type="number" step="any" class="form-control" name="sp${sp}_correction_rh1"
           data-sp="${sp}" data-type="corr_rh1" value="${d.corr_rh1||0}">
  </div>
</div>

<div class="row g-3 mt-2 gy-2">
  ${makeInputs('ref_rh2', '💧 Ref RH Sensor 2 Readings (%RH)', d.ref_rh2, numM)}
  <div class="col-md-4 align-self-end">
    <label class="form-label">Correction Sensor 2 (%RH)</label>
    <input type="number" step="any" class="form-control" name="sp${sp}_correction_rh2"
           data-sp="${sp}" data-type="corr_rh2" value="${d.corr_rh2||0}">
  </div>
</div>

<hr>

<div class="row g-3 mt-1 gy-2">
  ${makeInputs('eut_temp', '📟 EUT Temperature Readings (°C)', d.eut_temps, numM)}
</div>
<div class="row g-3 mt-2 gy-2">
  ${makeInputs('eut_rh', '📟 EUT RH Readings (%RH)', d.eut_rhs, numM)}
</div>

<div class="card mt-3 border-0 bg-light" id="sp${sp}_calc_result" style="display:none;">
  <div class="card-body py-2 small" id="sp${sp}_calc_display"></div>
</div>`;
}

function escHtml(str) {
    return String(str).replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}

// ────────────────────────────────────────────────────────────
// Setpoint value pairs (generated as soon as num_setpoints changes)
// ────────────────────────────────────────────────────────────
function renderSetpointValuePairs() {
    const numSp  = parseInt(document.getElementById('num_setpoints').value) || 3;
    const pairsContainer = document.getElementById('setpointValueRows');
    const pairsSection   = document.getElementById('setpointValuePairs');

    // Preserve existing values
    const existingTemps = [], existingRhs = [];
    document.querySelectorAll('.sp-pair-temp').forEach(el => existingTemps.push(el.value));
    document.querySelectorAll('.sp-pair-rh').forEach(el   => existingRhs.push(el.value));

    let html = '';
    for (let sp = 1; sp <= numSp; sp++) {
        const tVal = existingTemps[sp-1] || '';
        const rVal = existingRhs[sp-1]   || '';
        html += `
        <div class="col-12 col-md-auto d-flex align-items-center gap-2 mb-1">
          <span class="badge bg-secondary" style="width:32px;">SP${sp}</span>
          <div>
            <label class="form-label mb-0" style="font-size:11px;">Temp (°C)</label>
            <input type="number" step="any" class="form-control form-control-sm sp-pair-temp"
                   style="width:85px;" placeholder="e.g. 23"
                   data-sp="${sp}" value="${escHtml(tVal)}">
          </div>
          <div>
            <label class="form-label mb-0" style="font-size:11px;">RH (%RH)</label>
            <input type="number" step="any" class="form-control form-control-sm sp-pair-rh"
                   style="width:85px;" placeholder="e.g. 50"
                   data-sp="${sp}" value="${escHtml(rVal)}">
          </div>
        </div>`;
    }
    pairsContainer.innerHTML = html;
    pairsSection.style.display = '';
}

// ────────────────────────────────────────────────────────────
// Main tab renderer
// ────────────────────────────────────────────────────────────
function renderTabs() {
    const numSp = parseInt(document.getElementById('num_setpoints').value) || 3;
    const numM  = parseInt(document.getElementById('num_of_measurements').value) || 5;
    const tabs  = document.getElementById('setpointTabs');
    const cont  = document.getElementById('setpointTabContent');
    tabs.innerHTML = '';
    cont.innerHTML = '';

    for (let sp = 1; sp <= numSp; sp++) {
        const active = sp === 1 ? 'active' : '';
        const show   = sp === 1 ? 'show active' : '';

        tabs.innerHTML += `<li class="nav-item">
          <a class="nav-link ${active}" data-bs-toggle="tab" href="#sp_tab_${sp}">Setpoint ${sp}</a>
        </li>`;

        const saved = SAVED.setpoints[sp] || null;

        // Read any setpoint target values already entered in the pair inputs
        const tempPairEl = document.querySelector(`.sp-pair-temp[data-sp="${sp}"]`);
        const rhPairEl   = document.querySelector(`.sp-pair-rh[data-sp="${sp}"]`);
        // Prefer what the user typed in the pair row; fall back to saved DB values
        const savedTemp  = (saved && saved.temp_sp !== null && saved.temp_sp !== undefined) ? saved.temp_sp : null;
        const savedRh    = (saved && saved.rh_sp   !== null && saved.rh_sp   !== undefined) ? saved.rh_sp   : null;
        const tempSpVal  = (tempPairEl && tempPairEl.value !== '') ? parseFloat(tempPairEl.value) : savedTemp;
        const rhSpVal    = (rhPairEl   && rhPairEl.value   !== '') ? parseFloat(rhPairEl.value)   : savedRh;

        // Also seed the pair inputs themselves if they're empty and we have saved values
        if (tempPairEl && tempPairEl.value === '' && savedTemp !== null) tempPairEl.value = savedTemp;
        if (rhPairEl   && rhPairEl.value   === '' && savedRh   !== null) rhPairEl.value   = savedRh;

        cont.innerHTML += `<div class="tab-pane fade ${show}" id="sp_tab_${sp}">
          ${buildTab(sp, numM, saved, tempSpVal, rhSpVal)}
        </div>`;
    }

    bindReadingInputs();
    updateAllResults();
}

// ────────────────────────────────────────────────────────────
// Global populate handler — one file, all setpoints
// ────────────────────────────────────────────────────────────
function bindGlobalPopulate() {
    const fileInput    = document.getElementById('global_file_input');
    const populateBtn  = document.getElementById('populateAllBtn');
    const clearBtn     = document.getElementById('clearFileBtn');
    const globalStatus = document.getElementById('global_file_status');

    if (!fileInput) return;

    // Enable/disable button and show clear link when file is selected
    fileInput.addEventListener('change', () => {
        const hasFile = fileInput.files && fileInput.files.length > 0;
        populateBtn.disabled = !hasFile;
        clearBtn.style.display = hasFile ? '' : 'none';
        if (!hasFile) {
            globalStatus.style.display = 'none';
            PARSED_FILE_DATA = null;
        }
    });

    clearBtn.addEventListener('click', () => {
        fileInput.value = '';
        populateBtn.disabled = true;
        clearBtn.style.display = 'none';
        globalStatus.style.display = 'none';
        PARSED_FILE_DATA = null;
    });

    populateBtn.addEventListener('click', async () => {
        const numSp  = parseInt(document.getElementById('num_setpoints').value) || 3;
        const numM   = parseInt(document.getElementById('num_of_measurements').value) || 5;
        const file   = fileInput.files[0];

        if (!file) return;

        // Validate that all setpoint target pairs are filled
        const missing = [];
        for (let sp = 1; sp <= numSp; sp++) {
            const tEl = document.getElementById(`sp${sp}_temp_sp_val`);
            const rEl = document.getElementById(`sp${sp}_rh_sp_val`);
            if (!tEl || tEl.value === '' || !rEl || rEl.value === '') missing.push(sp);
        }
        if (missing.length) {
            showGlobalStatus('warning',
                `<i class="bi bi-exclamation-triangle me-1"></i>
                 Please enter both Temperature and Humidity Setpoint values for
                 <strong>Setpoint${missing.length > 1 ? 's' : ''} ${missing.join(', ')}</strong>
                 before populating.`);
            return;
        }

        populateBtn.disabled = true;
        populateBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Reading file…';
        showGlobalStatus('info', '<i class="bi bi-hourglass-split me-1"></i>Parsing file…');

        try {
            // Parse once, reuse for all setpoints
            if (!PARSED_FILE_DATA) {
                PARSED_FILE_DATA = await parseAlmemoFile(file);
            }
            if (!PARSED_FILE_DATA.length) throw new Error('No data rows found in file.');

            showGlobalStatus('info', `<i class="bi bi-hourglass-split me-1"></i>File loaded (${PARSED_FILE_DATA.length.toLocaleString()} rows). Extracting setpoints…`);

            // ── Process each setpoint ──────────────────────────
            const summaryRows = [];
            for (let sp = 1; sp <= numSp; sp++) {
                const tempSp = parseFloat(document.getElementById(`sp${sp}_temp_sp_val`).value);
                const rhSp   = parseFloat(document.getElementById(`sp${sp}_rh_sp_val`).value);
                const result = extractSetpointReadings(PARSED_FILE_DATA, tempSp, rhSp, numM);
                const isTemp50 = Math.abs(rhSp - 50) < 0.001;

                // ── Populate ref temp inputs ──────────────────
                const refTempInputs = document.querySelectorAll(`.reading-input[data-sp="${sp}"][data-type="ref_temp"]`);
                refTempInputs.forEach((inp, i) => {
                    inp.value = result.refTemps[i] !== undefined ? result.refTemps[i] : '';
                    inp.classList.toggle('border-success', result.refTemps[i] !== undefined);
                    inp.classList.toggle('border-warning',  result.refTemps[i] === undefined && result.counts.temp === 0);
                });

                // ── Populate sensor 1 inputs ──────────────────
                const rh1Inputs = document.querySelectorAll(`.reading-input[data-sp="${sp}"][data-type="ref_rh1"]`);
                rh1Inputs.forEach((inp, i) => {
                    inp.value = result.refRh1[i] !== undefined ? result.refRh1[i] : '';
                    inp.classList.toggle('border-success', result.refRh1[i] !== undefined);
                    inp.classList.toggle('border-warning',  result.refRh1[i] === undefined && result.counts.rh1 === 0);
                });

                // ── Populate sensor 2 inputs ──────────────────
                const rh2Inputs = document.querySelectorAll(`.reading-input[data-sp="${sp}"][data-type="ref_rh2"]`);
                rh2Inputs.forEach((inp, i) => {
                    inp.value = result.refRh2[i] !== undefined ? result.refRh2[i] : '';
                    inp.classList.toggle('border-success', result.refRh2[i] !== undefined);
                    inp.classList.toggle('border-warning',  result.refRh2[i] === undefined && result.counts.rh2 === 0);
                });

                collectReadings(sp);
                recalcSetpoint(sp);

                // ── Build per-setpoint status badge ──────────
                const spLabel  = `${tempSp}°C / ${rhSp}%RH`;
                const tempIcon = isTemp50 ? '🌡 ' : '';

                // Render one channel line: strict count + optional fallback note
                const makeCount = (label, strictN, diag, usedFallback) => {
                    let line = '';
                    if (strictN > 0) {
                        line = `<span class="text-success">${label}: ${strictN} matched (strict)</span>`;
                    } else if (usedFallback) {
                        line = `<span class="text-warning fw-semibold">${label}: 0 strict matches — fallback applied</span>`;
                    } else {
                        line = `<span class="text-danger fw-semibold">${label}: 0 matches</span>`
                             + (diag ? ` <span class="text-muted small">— ${diag}</span>` : '');
                    }
                    return line;
                };

                const fb = result.fallback;
                // Determine which channels were covered by fallback
                const fbTemp = fb && fb.usedFor === 'temperature';
                const fbRh   = fb && fb.usedFor === 'humidity';

                const parts = [
                    makeCount('Ref Temp (M01)', result.counts.temp, result.diagnostics.temp, fbTemp),
                    makeCount('Sensor 1 (M10)', result.counts.rh1,  result.diagnostics.rh1,  fbRh),
                    makeCount('Sensor 2 (M11)', result.counts.rh2,  result.diagnostics.rh2,  fbRh),
                ];

                // Fallback summary block (shown below the channel lines when active)
                let fallbackNote = '';
                if (fb) {
                    const fbIcon = fb.usedFor === 'temperature' ? '🌡' : '💧';
                    fallbackNote = `
                    <div class="mt-1 pt-1 border-top border-warning-subtle">
                      <span class="badge bg-warning text-dark me-1">FALLBACK</span>
                      <strong>Reference channel:</strong> ${fb.refChannel}
                      &bull; <strong>${fb.refCount}</strong> rows selected
                      &bull; ${fb.otherChannel} cross-pulled from same rows
                      <br><span class="text-muted small">
                        <strong>Why strict filter failed:</strong> ${fb.strictFailReason}
                        ${fb.usedFor === 'humidity'
                            ? `(strict Sensor 1: ${fb.strictRh1Count} rows, Sensor 2: ${fb.strictRh2Count} rows)`
                            : `(strict joint filter: ${fb.strictTempCount} rows)`}
                      </span>
                    </div>`;
                }

                const hasAny  = result.counts.temp > 0 || result.counts.rh1 > 0 || result.counts.rh2 > 0 || !!fb;
                const hasAll  = (result.counts.temp > 0 || fbTemp) &&
                                (result.counts.rh1  > 0 || fbRh)   &&
                                (result.counts.rh2  > 0 || fbRh);
                const alertType = hasAll ? (fb ? 'warning' : 'success') : (hasAny ? 'warning' : 'danger');

                const spStatusEl = document.getElementById(`sp${sp}_file_status`);
                if (spStatusEl) {
                    spStatusEl.style.display = '';
                    spStatusEl.innerHTML = `<div class="alert alert-${alertType} py-1 px-2 mb-0 small">
                        <strong>${tempIcon}SP${sp} (${spLabel}):</strong>
                        ${parts.join(' &bull; ')}
                        ${fallbackNote}
                    </div>`;
                }

                summaryRows.push({ sp, spLabel, counts: result.counts, fallback: fb, diags: result.diagnostics });
            }

            // ── Global summary ─────────────────────────────────
            const strictOk   = summaryRows.every(r =>
                r.counts.temp > 0 && r.counts.rh1 > 0 && r.counts.rh2 > 0);
            const fallbackOk = !strictOk && summaryRows.every(r => {
                const fb = r.fallback;
                return (r.counts.temp > 0 || (fb && fb.usedFor === 'temperature')) &&
                       (r.counts.rh1  > 0 || (fb && fb.usedFor === 'humidity'))   &&
                       (r.counts.rh2  > 0 || (fb && fb.usedFor === 'humidity'));
            });
            const anyFail    = summaryRows.some(r => {
                const fb = r.fallback;
                return (r.counts.temp === 0 && !(fb && fb.usedFor === 'temperature')) ||
                       (r.counts.rh1  === 0 && !(fb && fb.usedFor === 'humidity'))   ||
                       (r.counts.rh2  === 0 && !(fb && fb.usedFor === 'humidity'));
            });
            const fbCount    = summaryRows.filter(r => r.fallback).length;

            let gType, gMsg;
            if (strictOk) {
                gType = 'success';
                gMsg  = `<i class="bi bi-check-circle-fill me-1"></i><strong>All ${numSp} setpoints populated successfully</strong> using strict filters from ${PARSED_FILE_DATA.length.toLocaleString()} file rows. Scroll down to review each tab.`;
            } else if (fallbackOk) {
                gType = 'warning';
                gMsg  = `<i class="bi bi-exclamation-triangle-fill me-1"></i>
                         <strong>All ${numSp} setpoints populated — fallback logic was used for ${fbCount} setpoint(s).</strong>
                         Fallback means the strict joint filter did not produce enough rows, so the most stable
                         single channel was used as the reference and the other channels were cross-pulled from
                         the same rows. Review the <span class="badge bg-warning text-dark">FALLBACK</span>
                         badges in each tab before submitting.`;
            } else {
                const failList = summaryRows.filter(r => {
                    const fb = r.fallback;
                    return (r.counts.temp === 0 && !(fb && fb.usedFor === 'temperature')) ||
                           (r.counts.rh1  === 0 && !(fb && fb.usedFor === 'humidity'))   ||
                           (r.counts.rh2  === 0 && !(fb && fb.usedFor === 'humidity'));
                });
                gType = 'danger';
                gMsg  = `<i class="bi bi-exclamation-triangle-fill me-1"></i>
                         <strong>${failList.length} setpoint(s) could not be populated</strong> — neither the strict
                         filter nor the fallback produced enough rows. Check each tab for details. Common causes:
                         <ul class="mb-0 mt-1">
                           <li>Setpoint target value is outside the range recorded in the file</li>
                           <li>Chamber hadn't stabilised at the target — try adjusting by ±1°C or ±1%RH</li>
                           <li>File contains data from a different calibration run</li>
                           <li>Fewer than ${numM} consecutive stable readings exist for any channel</li>
                         </ul>`;
            }
            showGlobalStatus(gType, gMsg);

        } catch (err) {
            showGlobalStatus('danger', `<i class="bi bi-exclamation-triangle me-1"></i><strong>Error:</strong> ${err.message}`);
            PARSED_FILE_DATA = null;
        }

        populateBtn.disabled = false;
        populateBtn.innerHTML = '<i class="bi bi-lightning-charge-fill me-1"></i>Populate All Setpoints';
    });
}

function showGlobalStatus(type, html) {
    const el = document.getElementById('global_file_status');
    if (!el) return;
    el.style.display = '';
    el.innerHTML = `<div class="alert alert-${type} py-2 px-3 mb-0 small">${html}</div>`;
}

function bindReadingInputs() {
    document.querySelectorAll('.reading-input').forEach(inp => {
        inp.addEventListener('input', () => {
            const sp = inp.dataset.sp;
            collectReadings(sp);
            recalcSetpoint(sp);
        });
    });
    document.querySelectorAll('[name^="sp"][name$="_correction_temp"],[name^="sp"][name$="_correction_rh1"],[name^="sp"][name$="_correction_rh2"]').forEach(inp => {
        inp.addEventListener('input', () => {
            const sp = inp.name.replace('sp','').split('_')[0];
            collectReadings(sp);
            recalcSetpoint(sp);
        });
    });
}

function collectReadings(sp) {
    const getVals = (type) => Array.from(
        document.querySelectorAll(`.reading-input[data-sp="${sp}"][data-type="${type}"]`)
    ).map(i => i.value).filter(v => v !== '');

    const toHidden = (id, arr) => {
        const el = document.getElementById(id);
        if (el) el.value = JSON.stringify(arr.map(Number));
    };

    toHidden(`sp${sp}_ref_temps`,        getVals('ref_temp'));
    toHidden(`sp${sp}_ref_rh1_readings`, getVals('ref_rh1'));
    toHidden(`sp${sp}_ref_rh2_readings`, getVals('ref_rh2'));
    toHidden(`sp${sp}_eut_temps`,        getVals('eut_temp'));
    toHidden(`sp${sp}_eut_rhs`,          getVals('eut_rh'));
}

function recalcSetpoint(sp) {
    const res_rh   = parseFloat(document.getElementById('res_rh_eut').value)   || 0.1;
    const res_temp = parseFloat(document.getElementById('res_temp_eut').value) || 0.1;

    const getData = (id) => JSON.parse(document.getElementById(id)?.value || '[]');
    const getCorr = (name) => parseFloat(document.querySelector(`[name="sp${sp}_${name}"]`)?.value) || 0;

    const data = {
        label:     document.querySelector(`[name="sp${sp}_setpoint_label"]`)?.value || `SP${sp}`,
        ref_temps: getData(`sp${sp}_ref_temps`),
        corr_temp: getCorr('correction_temp'),
        ref_rh1:   getData(`sp${sp}_ref_rh1_readings`),
        corr_rh1:  getCorr('correction_rh1'),
        ref_rh2:   getData(`sp${sp}_ref_rh2_readings`),
        corr_rh2:  getCorr('correction_rh2'),
        eut_temps: getData(`sp${sp}_eut_temps`),
        eut_rhs:   getData(`sp${sp}_eut_rhs`),
    };

    const res = processSetpoint(data, res_rh, res_temp);
    const card = document.getElementById(`sp${sp}_calc_result`);
    const disp = document.getElementById(`sp${sp}_calc_display`);

    if (res && card && disp) {
        card.style.display = '';
        disp.innerHTML = `
          <div class="row g-2">
            <div class="col-auto"><strong>Ref Temp:</strong> ${res.corrected_ref_temp} °C</div>
            <div class="col-auto"><strong>Ref RH:</strong> ${res.corrected_ref_rh} %RH</div>
            <div class="col-auto"><strong>EUT Temp:</strong> ${res.avg_eut_temp} °C</div>
            <div class="col-auto"><strong>EUT RH:</strong> ${res.avg_eut_rh} %RH</div>
            <div class="col-auto"><strong>Temp Dev:</strong> ${res.temp_deviation} °C</div>
            <div class="col-auto"><strong>RH Dev:</strong> ${res.rh_deviation} %RH</div>
            <div class="col-auto text-success"><strong>U(RH):</strong> ${res.rh_uncertainty} %RH (k=2)</div>
            <div class="col-auto text-primary"><strong>U(T):</strong> ${res.include_temp ? res.temp_uncertainty + ' K (k=2)' : '<em>not applicable</em>'}</div>
          </div>`;
    }
    updateAllResults();
}

function updateAllResults() {
    const numSp   = parseInt(document.getElementById('num_setpoints').value)    || 3;
    const res_rh  = parseFloat(document.getElementById('res_rh_eut').value)     || 0.1;
    const res_temp= parseFloat(document.getElementById('res_temp_eut').value)   || 0.1;

    const rhBody   = document.getElementById('rh-results-body');
    const tempBody = document.getElementById('temp-results-body');
    if (!rhBody || !tempBody) return;

    rhBody.innerHTML   = '';
    tempBody.innerHTML = '';
    let hasRh = false, hasTemp = false;

    for (let sp = 1; sp <= numSp; sp++) {
        const getData = (id) => JSON.parse(document.getElementById(id)?.value || '[]');
        const getCorr = (name) => parseFloat(document.querySelector(`[name="sp${sp}_${name}"]`)?.value) || 0;

        const data = {
            label:     document.querySelector(`[name="sp${sp}_setpoint_label"]`)?.value || `SP${sp}`,
            ref_temps: getData(`sp${sp}_ref_temps`),
            corr_temp: getCorr('correction_temp'),
            ref_rh1:   getData(`sp${sp}_ref_rh1_readings`),
            corr_rh1:  getCorr('correction_rh1'),
            ref_rh2:   getData(`sp${sp}_ref_rh2_readings`),
            corr_rh2:  getCorr('correction_rh2'),
            eut_temps: getData(`sp${sp}_eut_temps`),
            eut_rhs:   getData(`sp${sp}_eut_rhs`),
        };

        const res = processSetpoint(data, res_rh, res_temp);
        if (!res) continue;

        hasRh = true;
        rhBody.innerHTML += `<tr>
          <td class="text-center">${res.label}</td>
          <td class="text-center">${res.corrected_ref_temp}</td>
          <td class="text-center">${res.corrected_ref_rh}</td>
          <td class="text-center">${res.avg_eut_rh}</td>
          <td class="text-center">${res.rh_deviation}</td>
          <td class="text-center text-success fw-semibold">${res.rh_uncertainty}</td>
        </tr>`;

        if (res.include_temp) {
            hasTemp = true;
            tempBody.innerHTML += `<tr>
              <td class="text-center">${res.label}</td>
              <td class="text-center">${res.corrected_ref_temp}</td>
              <td class="text-center">${res.avg_eut_temp}</td>
              <td class="text-center">${res.temp_deviation}</td>
              <td class="text-center text-primary fw-semibold">${res.temp_uncertainty}</td>
            </tr>`;
        }
    }

    if (!hasRh)   rhBody.innerHTML   = '<tr><td colspan="6" class="text-center text-muted">Enter readings above to see results.</td></tr>';
    if (!hasTemp) tempBody.innerHTML = '<tr><td colspan="5" class="text-center text-muted">Temperature results shown for ~50 %RH setpoints only.</td></tr>';

    document.getElementById('resultsPreviewCard').style.display = hasRh ? '' : 'none';
}

// ── Init ──────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
    // Load SheetJS for XLS parsing
    const xlsScript = document.createElement('script');
    xlsScript.src = 'https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js';
    document.head.appendChild(xlsScript);

    // Render setpoint value pairs immediately on num_setpoints change
    const numSpInput = document.getElementById('num_setpoints');
    numSpInput.addEventListener('input', renderSetpointValuePairs);

    // Build button
    document.getElementById('buildSetpoints').addEventListener('click', renderTabs);

    // Global file upload + populate-all
    bindGlobalPopulate();

    // Auto-build on load (uses saved num_sp)
    renderSetpointValuePairs();
    renderTabs();

    // Collect all readings before form submit
    document.getElementById('humidity-form').addEventListener('submit', () => {
        const numSp = parseInt(document.getElementById('num_setpoints').value) || 3;
        for (let sp = 1; sp <= numSp; sp++) collectReadings(sp);
    });
});
</script>

<?php include __DIR__ . '/../../shared/footer.php'; ?>
