// data.jsx — sensor store with IndexedDB persistence.
//
// Boot sequence:
//   1. Open IndexedDB (storage.jsx). On first ever run, generate ~30 days of
//      synthetic seed data, write it to disk, mark `seeded=true`.
//   2. On every subsequent load, read history from disk — your data is yours.
//   3. Every minute, the live ticker appends a fresh row to disk. A periodic
//      ring-buffer trim drops anything older than 90 days so the DB doesn't
//      grow forever (raise/remove `MAX_AGE_MS` if you want everything kept).
//
// Components see a fully-sync API: store.current(id), store.series(id, ms).
// They also subscribe(fn) and re-render on every tick.

const SENSOR_DEFS = [
  { id: 'co2',      label: 'CO₂',          unit: 'ppm',  range: [400, 2500], decimals: 0,
    base: 720,  daily: 180, hourly: 90,  drift: 4,    noise: 18, spikes: true },
  { id: 'temp',     label: 'Temperature',  unit: '°C',   range: [10, 35], decimals: 1,
    base: 22.4, daily: 3.2, hourly: 0.7, drift: 0.04, noise: 0.25 },
  { id: 'humidity', label: 'Humidity',     unit: '%',    range: [0, 100], decimals: 0,
    base: 48,   daily: 12,  hourly: 2.5, drift: 0.2,  noise: 0.8 },
  { id: 'soil',     label: 'Soil moisture', unit: '%',  range: [0, 100], decimals: 0,
    base: 38,   daily: 4,   hourly: 1.2, drift: -0.05, noise: 0.4, decay: true },
  { id: 'pm25',     label: 'PM2.5',        unit: 'µg/m³', range: [0, 150], decimals: 0,
    base: 12,   daily: 6,   hourly: 3,   drift: 0.05, noise: 1.5, spikes: true },
  { id: 'pm10',     label: 'PM10',         unit: 'µg/m³', range: [0, 250], decimals: 0,
    base: 22,   daily: 10,  hourly: 5,   drift: 0.05, noise: 2,   spikes: true },
  { id: 'voc',      label: 'VOC / TVOC',   unit: 'ppb', range: [0, 1500], decimals: 0,
    base: 180,  daily: 60,  hourly: 25,  drift: 0.5,  noise: 8,   spikes: true },
  { id: 'aqi',      label: 'Air quality',  unit: 'AQI', range: [0, 300], decimals: 0,
    base: 42,   daily: 20,  hourly: 8,   drift: 0.1,  noise: 3 },
  { id: 'pressure', label: 'Air pressure', unit: 'hPa', range: [970, 1040], decimals: 0,
    base: 1013, daily: 5,   hourly: 1.5, drift: 0.05, noise: 0.4 },
  { id: 'lux',      label: 'Light',        unit: 'lx',  range: [0, 2000], decimals: 0,
    base: 350,  daily: 320, hourly: 40,  drift: 0.5,  noise: 20 },
];

const STEP_MS    = 60 * 1000;            // 1 reading / minute persisted
const LIVE_TICK  = 60 * 1000;            // poll server every 60 s
const STATUS_TICK = 20 * 1000;           // poll /api/status every 20 s for the LIVE pill
const MAX_AGE_MS = 90 * 24 * 3600 * 1000;// trim DB rows older than 90 d
const HISTORY_FETCH_MS = 30 * 86400000;  // pull last 30 days from server on boot

// ---- seeded prng ---------------------------------------------------------
function mulberry32(seed) {
  return function() {
    seed |= 0; seed = (seed + 0x6D2B79F5) | 0;
    let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

function generateHistory(sensor, now, points, stepMs) {
  const rng = mulberry32(sensor.id.split('').reduce((a, c) => a + c.charCodeAt(0), 0) * 9973);
  const out = new Array(points);
  let drift = 0, lastSpike = -Infinity;
  for (let i = 0; i < points; i++) {
    const t = now - (points - 1 - i) * stepMs;
    const dayPhase = ((t / 86400000) % 1) * Math.PI * 2;
    const hourPhase = ((t / 3600000) % 1) * Math.PI * 2;
    drift += (rng() - 0.5) * sensor.drift * 0.1;
    drift = Math.max(-sensor.daily, Math.min(sensor.daily, drift));
    let v = sensor.base
      + Math.sin(dayPhase - 1.5) * sensor.daily
      + Math.sin(hourPhase) * sensor.hourly * 0.4
      + drift + (rng() - 0.5) * sensor.noise * 2;
    if (sensor.spikes && t - lastSpike > 1000 * 60 * 60 * (3 + rng() * 5) && rng() < 0.05) lastSpike = t;
    if (sensor.spikes) {
      const since = (t - lastSpike) / (1000 * 60 * 30);
      if (since >= 0 && since < 4) v += 600 * Math.exp(-since * 0.8);
    }
    v = Math.max(sensor.range[0], Math.min(sensor.range[1], v));
    out[i] = { t, v };
  }
  return out;
}

class SensorStore {
  constructor() {
    this.subs = new Set();
    this.connected = false;
    this.ready = false;
    this.lastUpdate = Date.now();
    // Server-authoritative "last time the Arduino successfully POSTed". Set
    // by _pollStatus(), drives the LIVE/OFFLINE pill. Survives reloads,
    // independent of the browser's data fetch cadence and the Arduino's clock.
    this.lastIngestAt = null;
    this.step = STEP_MS;
    this.history = {};
    this.live = {};
    SENSOR_DEFS.forEach(s => { this.history[s.id] = []; this.live[s.id] = []; });
    this._boot();
  }

  async _boot() {
    try {
      await Storage.open();

      // Try the server first (source of truth). If it answers, we use that and cache to IndexedDB.
      // If the server is unreachable, fall back to whatever IndexedDB already has so the dashboard
      // still works offline.
      const since = Date.now() - HISTORY_FETCH_MS;
      let serverOk = false;
      try {
        const fetches = await Promise.all(SENSOR_DEFS.map(async s => {
          const r = await fetch(`/api/readings?sensorId=${s.id}&since=${since}`);
          if (!r.ok) throw new Error(`HTTP ${r.status} for ${s.id}`);
          const { readings } = await r.json();
          return { id: s.id, readings };
        }));
        for (const f of fetches) this.history[f.id] = f.readings;
        serverOk = true;
        // Cache fresh server data into IndexedDB for offline reloads.
        const rows = [];
        for (const f of fetches) {
          for (const p of f.readings) rows.push({ sensorId: f.id, t: p.t, v: p.v });
        }
        if (rows.length) await Storage.appendMany(rows);
      } catch (err) {
        console.warn('[SensorStore] server fetch failed, falling back to IndexedDB:', err.message);
        for (const s of SENSOR_DEFS) {
          this.history[s.id] = await Storage.allReadings(s.id);
        }
      }

      // prime live buffer with last reading per sensor (or empty if we have nothing).
      // Also seed lastUpdate to the freshest reading we have, so the staleness pill
      // reflects actual data age, not "we just booted".
      let latestT = 0;
      SENSOR_DEFS.forEach(s => {
        const h = this.history[s.id];
        if (h.length) {
          this.live[s.id] = [h[h.length - 1]];
          if (h[h.length - 1].t > latestT) latestT = h[h.length - 1].t;
        } else {
          this.live[s.id] = [];
        }
      });
      this.lastUpdate = latestT || 0;

      setInterval(() => Storage.purgeOlderThan(Date.now() - MAX_AGE_MS).catch(() => {}), 60 * 60 * 1000);

      this.ready = true;
      this.connected = serverOk;
      this._timer = setInterval(this._tick.bind(this), LIVE_TICK);
      this._setupStatusPolling();
      this.subs.forEach(fn => fn());
    } catch (err) {
      console.error('[SensorStore] boot failed', err);
      this.ready = true;
      this.connected = false;
      this._timer = setInterval(this._tick.bind(this), LIVE_TICK);
      this._setupStatusPolling();
      this.subs.forEach(fn => fn());
    }
  }

  // Drives the LIVE/OFFLINE pill from server-recorded ingest time. Runs more
  // often than _tick so the pill updates promptly, and re-polls on tab focus
  // so it doesn't lag behind reality after the user comes back.
  _setupStatusPolling() {
    this._pollStatus();
    this._statusTimer = setInterval(() => this._pollStatus(), STATUS_TICK);
    if (typeof document !== 'undefined') {
      document.addEventListener('visibilitychange', () => {
        if (document.visibilityState === 'visible') this._pollStatus();
      });
    }
  }

  async _pollStatus() {
    try {
      const r = await fetch('/api/status');
      if (!r.ok) return;
      const { devices } = await r.json();
      // Use the freshest device — the dashboard treats any active device as
      // "system live", which matches the user's mental model of one Arduino.
      const newest = (devices || []).reduce(
        (max, d) => (d && typeof d.lastIngestAt === 'number' && d.lastIngestAt > max ? d.lastIngestAt : max),
        0
      );
      if (newest > 0 && newest !== this.lastIngestAt) {
        this.lastIngestAt = newest;
        this.subs.forEach(fn => fn());
      }
    } catch (_) { /* network blip — try again next tick */ }
  }

  async _tick() {
    let anyNew = false;
    let serverOk = false;
    let newestT = 0;

    // Poll each sensor for readings newer than what we already have.
    await Promise.all(SENSOR_DEFS.map(async s => {
      const hist = this.history[s.id];
      const lastT = hist.length ? hist[hist.length - 1].t : 0;
      try {
        const r = await fetch(`/api/readings?sensorId=${s.id}&since=${lastT + 1}`);
        if (!r.ok) return;
        serverOk = true;
        const { readings } = await r.json();
        if (readings.length) {
          hist.push(...readings);
          this.live[s.id] = [readings[readings.length - 1]];
          anyNew = true;
          const latest = readings[readings.length - 1].t;
          if (latest > newestT) newestT = latest;
          if (window.Storage) {
            Storage.appendMany(readings.map(p => ({ sensorId: s.id, t: p.t, v: p.v })))
              .catch(err => console.warn('[SensorStore] cache write failed', err));
          }
        }
      } catch (_) { /* network blip — try again next tick */ }
    }));

    this.connected = serverOk;
    // Only bump lastUpdate when actual new data arrived (drives the LIVE/STALE pill correctly).
    if (anyNew && newestT > this.lastUpdate) this.lastUpdate = newestT;
    if (anyNew || !serverOk) this.subs.forEach(fn => fn());
  }

  subscribe(fn) { this.subs.add(fn); return () => this.subs.delete(fn); }
  current(id) { const b = this.live[id]; return b && b.length ? b[b.length - 1] : null; }
  series(id, windowMs) {
    const hist = this.history[id] || [];
    if (windowMs === 'live') return (this.live[id] || []).slice();
    if (windowMs == null) return hist.slice();
    const cutoff = Date.now() - windowMs;
    let lo = 0, hi = hist.length;
    while (lo < hi) {
      const mid = (lo + hi) >> 1;
      if (hist[mid].t < cutoff) lo = mid + 1; else hi = mid;
    }
    return hist.slice(lo);
  }

  // expose for "danger zone" controls in settings
  async wipeAll() {
    await Storage.clearAll();
    location.reload();
  }
}

window.SENSOR_DEFS = SENSOR_DEFS;
window.SensorStore = SensorStore;
