/* Bee Smart Operations Portal - PM-197 Insights: five graphical data views over
   GET /api/trends (24h/7d/30d). Shared rules on every view: RangeSwitcher,
   DERIVED badge (formula on hover) on every derived metric, honest gaps (null
   bucket = void, '-' for absent sources: RSSI PM-160 / FW PM-175 / AQI PM-180,
   never interpolate). A 404 from /api/trends renders the explicit "trend service
   not yet deployed" state (phase-A honesty: /next fetches the PROD API). */

// Trend fetch hook: phase 'loading' | 'ready' | 'error' | 'unavailable'(404).
// Refetches on range switch; keeps the last data visible while reloading.
function useTrends(initial){
  const [range,setRange]=React.useState(initial||'24h');
  const [st,setSt]=React.useState({phase:'loading',data:null,err:null});
  // Request-id guard: on a fast range flip, an older slower response must never
  // overwrite a newer one (stale data under a fresh label violates honesty).
  const reqId=React.useRef(0);
  const load=React.useCallback(r=>{
    const my=++reqId.current;
    setSt(s=>({phase:'loading',data:s.data,err:null}));
    window.loadTrends(r)
      .then(data=>{ if(my===reqId.current) setSt({phase:'ready',data,err:null}); })
      .catch(e=>{ if(my===reqId.current) setSt({phase:e.code===404?'unavailable':'error',data:null,err:String(e&&e.message||e)}); });
  },[]);
  React.useEffect(()=>{ load(range); },[range,load]);
  return { range, setRange, phase:st.phase, data:st.data, err:st.err, reload:()=>load(range) };
}

// Formula lookup for DERIVED badges (served in every payload's derivedMeta).
function metaOf(data,key){ const m=data&&data.derivedMeta&&data.derivedMeta.find(x=>x.key===key); return m?m.formula:key; }

// Availability for the CURRENT range window.
function availFor(dev,range){ return range==='24h'?dev.availabilityPct.h24:range==='7d'?dev.availabilityPct.d7:dev.availabilityPct.d30; }

// Prototype-chrome note (mirrors Screens.jsx ReviewNote, which is file-local there).
function InsightNote({ title, items }){
  if(PROD_PORTAL) return null;
  return <div style={{border:`1px dashed ${O.azure}`,background:O.azure05,borderRadius:12,padding:'10px 14px',marginBottom:16,fontFamily:O.sans,fontSize:12.5,color:O.fg2,display:'flex',gap:8,alignItems:'flex-start'}}>
    <span style={{color:O.azure7,flexShrink:0,marginTop:1}}><OIcon name="clipboard-check" size={14}/></span>
    <span><b style={{color:O.azure7}}>Review note - {title}.</b> {items.join(' · ')}</span>
  </div>;
}

// Shared loading / 404 / error gate. children render only with data.
function TrendGate({ t, children }){
  if(t.phase==='loading'&&!t.data) return <Card style={{padding:'44px 20px',textAlign:'center'}}>
    <div className="spin" style={{width:28,height:28,margin:'0 auto 12px',border:`3px solid ${O.line}`,borderTopColor:O.azure,borderRadius:'50%'}}/>
    <div style={{fontFamily:O.mono,fontSize:12,color:O.fg3}}>loading trends...</div>
  </Card>;
  if(t.phase==='unavailable') return <Card style={{padding:'36px 20px',textAlign:'center'}}>
    <div style={{color:O.warn,display:'flex',justifyContent:'center',marginBottom:10}}><OIcon name="satellite-dish" size={30}/></div>
    <div style={{fontFamily:O.sans,fontWeight:700,fontSize:15,color:O.fg1}}>Trend service not yet deployed</div>
    <div style={{fontFamily:O.sans,fontSize:12.5,color:O.fg3,maxWidth:460,margin:'6px auto 0'}}>This surface fetches the production API, and <span style={{fontFamily:O.mono,fontSize:11.5}}>/api/trends</span> has not shipped to production yet - it arrives with the first server-side portal deploy (PM-197 phase B). Live fleet status everywhere else is unaffected.</div>
  </Card>;
  if(t.phase==='error') return <Card style={{padding:'32px 20px',textAlign:'center'}}>
    <div style={{color:O.crit,display:'flex',justifyContent:'center',marginBottom:8}}><OIcon name="alert-triangle" size={26}/></div>
    <div style={{fontFamily:O.sans,fontWeight:700,fontSize:14,color:O.fg1}}>Couldn't load trends</div>
    <div style={{fontFamily:O.mono,fontSize:11,color:O.fg3,margin:'4px 0 14px',wordBreak:'break-word'}}>{t.err}</div>
    <button className="clk" onClick={t.reload} style={{background:O.azure,color:'#fff',border:'none',borderRadius:9,padding:'8px 18px',fontFamily:O.sans,fontWeight:700,fontSize:12.5,cursor:'pointer'}}>Retry</button>
  </Card>;
  return children;
}

// PageTitle right slot: RangeSwitcher + a small in-flight pill while a refetch
// runs over existing data (TrendGate keeps the old payload visible; without this
// a slow range switch would look like nothing happened). View labels read the
// PAYLOAD's d.range so label and data always agree during the refetch window.
function RangeRight({ t }){
  return <span style={{display:'inline-flex',alignItems:'center',gap:10}}>
    {t.phase==='loading'&&t.data?<span className="spin" style={{width:14,height:14,border:`2px solid ${O.line}`,borderTopColor:O.azure,borderRadius:'50%',display:'inline-block'}}/>:null}
    <RangeSwitcher range={t.range} onRange={t.setRange}/>
  </span>;
}

// ================= FLEET RHYTHMS (Shell page, C-layout) =================
function FleetRhythms(){
  const t=useTrends('24h'); const d=t.data;
  return <div>
    <PageTitle title="Fleet Rhythms" sub="Throughput, reporting heat, availability and freshness" right={<RangeRight t={t}/>}/>
    <InsightNote title="Fleet Rhythms" items={["NEW page (PM-197) on /api/trends","DS tokens + new Ribbon/HeatCal primitives","availability + gaps are DERIVED (badges)","null buckets render as voids - never interpolated"]}/>
    <TrendGate t={t}>{d&&<>
      <Card style={{marginBottom:14}}>
        <CardHead title="Fleet throughput" sub={`readings per ${d.bucketMinutes}m bucket · ${d.range} · day/night shaded`} icon="activity"/>
        <Ribbon buckets={d.buckets} series={[{label:'fleet',color:O.azure,values:d.fleet.traffic}]} h={130} unit="readings"/>
      </Card>
      <div style={{display:'grid',gridTemplateColumns:'1fr 1fr',gap:14,marginBottom:14}} className="ops-2col">
        <Card>
          <CardHead title="Reporting heat" sub="readings per bucket, by day" icon="calendar" right={<DerivedBadge formula={metaOf(d,'gaps')}/>}/>
          <HeatCal buckets={d.buckets} values={d.fleet.traffic.map(v=>v===0?null:v)}/>
          <div style={{fontFamily:O.mono,fontSize:9.5,color:O.fg3,marginTop:8}}>dashed cell = true gap (no readings in the bucket)</div>
        </Card>
        <Card>
          <CardHead title="Availability" sub={`% of buckets with data · ${d.range}`} icon="check-circle-2" right={<DerivedBadge formula={metaOf(d,'availabilityPct')}/>}/>
          {d.devices.map(dev=>{ const a=availFor(dev,d.range);
            return <div key={dev.id} style={{display:'flex',alignItems:'center',gap:10,marginBottom:9}}>
              <span style={{fontFamily:O.sans,fontWeight:600,fontSize:12.5,color:O.fg1,width:110,whiteSpace:'nowrap',overflow:'hidden',textOverflow:'ellipsis'}} title={dev.name}>{dev.name}</span>
              <div style={{flex:1,height:9,background:O.sunken,borderRadius:5,overflow:'hidden'}}>
                {a!=null&&<span style={{display:'block',height:'100%',width:a+'%',background:a>=99?O.leaf:a>=90?O.warn:O.crit,borderRadius:5}}/>}
              </div>
              <span style={{fontFamily:O.mono,fontSize:11.5,fontWeight:700,color:O.fg1,width:54,textAlign:'right'}}>{a==null?'-':a+'%'}</span>
              <span title={metaOf(d,'gaps')} style={{fontFamily:O.mono,fontSize:9.5,color:dev.gaps.length?O.crit:O.fg3,background:dev.gaps.length?O.critBg:O.sunken,borderRadius:999,padding:'2px 8px',whiteSpace:'nowrap'}}>{dev.gaps.length} gaps</span>
            </div>;})}
        </Card>
      </div>
      <Card>
        <CardHead title="Freshness" sub="age of last reading vs expected cadence" icon="timer" right={<DerivedBadge formula={metaOf(d,'freshness')}/>}/>
        <div style={{display:'grid',gridTemplateColumns:'repeat(auto-fill,minmax(170px,1fr))',gap:10}}>
          {d.devices.map(dev=>{ const f=dev.freshness;
            const tone=f.lastSeenMin<=f.expectedCadenceMin?'ok':f.lastSeenMin<=90?'warn':'crit';
            return <ScoreTile key={dev.id} label={dev.name} value={fmtAgo(f.lastSeenMin)} sub={'expect <= '+f.expectedCadenceMin+'m'} tone={tone}/>;})}
        </div>
      </Card>
    </>}</TrendGate>
  </div>;
}

// ================= MICROCLIMATE (Shell page, C-layout) =================
// Stacked aligned ribbons: temp / RH / baro / NOISE (dB - SHOWN per the s376
// owner decision, resolving the s353 open question), sensor lines + gateway env
// lines; DERIVED dew-point ribbon (Magnus, server-derived); Sensor-1-vs-Sensor-2
// divergence; pressure-trend card.

// Client-side divergence bands per channel (display heuristic, labeled DERIVED).
const DIVERGE_BAND={ TEMP:2, HUMID:5, AIR_PRESS:0.05, NOISE:6 };
const DIVERGE_FORMULA='Sensor 1 - Sensor 2 per bucket (both reporting); flagged beyond the per-channel band';

function Microclimate(){
  const t=useTrends('7d'); const d=t.data; // owner direction: Microclimate defaults to 7-day (Device Vitals stays 24h)
  const sensors=d?d.devices.filter(x=>x.kind==='sensor'):[];
  const gws=d?d.devices.filter(x=>x.kind==='gateway'):[];
  const colors=[O.azure,O.teal,O.gold,O.leaf4];
  const lastOf=v=>{ for(let i=v.length-1;i>=0;i--) if(v[i]!=null) return v[i]; return null; };
  const envRibbon=(title,key,gwKey,unit,icon,derivedKey)=>{
    const series=[
      ...sensors.map((s,i)=>({label:s.name,color:colors[i%colors.length],values:(derivedKey?s.derived.dewPointF:s.channels[key])||[]})),
      ...(gwKey?gws.map(g=>({label:g.name+' (gw)',color:O.fg3,values:(derivedKey?g.derived.dewPointF:g.channels[gwKey])||[]})):[]),
    ].filter(s=>s.values.length);
    return <Card key={title} style={{marginBottom:14}}>
      <CardHead title={title} sub={d.range+' · aligned buckets · day/night shaded'} icon={icon}
        right={derivedKey?<DerivedBadge formula={metaOf(d,'dewPointF')}/>:undefined}/>
      <Ribbon buckets={d.buckets} series={series} h={96} unit={unit}/>
    </Card>;
  };
  // per-channel divergence (needs exactly the first two sensors)
  const s0=sensors[0], s1=sensors[1];
  const delta=key=>{ if(!s0||!s1) return null;
    const a=s0.channels[key]||[], b=s1.channels[key]||[];
    return a.map((v,i)=>v!=null&&b[i]!=null?Math.round((v-b[i])*100)/100:null); };
  const presSrc = sensors.find(s=>s.channels.AIR_PRESS&&s.channels.AIR_PRESS.some(v=>v!=null))||gws.find(g=>g.channels.GW_AIR_PRESS&&g.channels.GW_AIR_PRESS.some(v=>v!=null))||null;
  const presSeries = presSrc ? (presSrc.kind==='gateway'?presSrc.channels.GW_AIR_PRESS:presSrc.channels.AIR_PRESS) : [];
  const presTrend = presSrc ? presSrc.derived.pressureTrend : null;
  return <div>
    <PageTitle title="Microclimate" sub="Environment over time - sensors vs gateway, with derived insight" right={<RangeRight t={t}/>}/>
    <InsightNote title="Microclimate" items={["NEW page (PM-197)","noise (dB) ribbon SHOWN per s376 owner decision (resolves s353)","dew point + pressure trend are server-DERIVED (Magnus / 3h-delta)","divergence is a client-side delta of the two sensor series (DERIVED)"]}/>
    <TrendGate t={t}>{d&&<>
      {envRibbon('Temperature','TEMP','GW_TEMP','F','thermometer')}
      {envRibbon('Humidity','HUMID','GW_HUMID','%','droplets')}
      {envRibbon('Barometric pressure','AIR_PRESS','GW_AIR_PRESS','inHg','gauge')}
      {envRibbon('Noise','NOISE',null,'dB','volume-2')}
      {envRibbon('Dew point','TEMP','GW_TEMP','F','cloud-drizzle',true)}
      <div style={{display:'grid',gridTemplateColumns:'1.4fr 1fr',gap:14}} className="ops-2col">
        <Card>
          <CardHead title="Sensor divergence" sub={s0&&s1?s0.name+' minus '+s1.name:'needs two reporting sensors'} icon="git-compare" right={<DerivedBadge formula={DIVERGE_FORMULA}/>}/>
          {s0&&s1?<>
            {['TEMP','HUMID','AIR_PRESS','NOISE'].map((key,i)=>{ const dv=delta(key); const cur=dv?lastOf(dv):null; const band=DIVERGE_BAND[key];
              const flagged=cur!=null&&Math.abs(cur)>band;
              return <div key={key} style={{display:'flex',alignItems:'center',gap:12,marginBottom:10}}>
                <span style={{fontFamily:O.mono,fontSize:10,fontWeight:600,letterSpacing:'.05em',textTransform:'uppercase',color:O.fg3,width:78}}>{key.replace('AIR_PRESS','BARO')}</span>
                <div style={{flex:1}}><Ribbon buckets={d.buckets} series={[{label:'delta',color:flagged?O.crit:colors[i%colors.length],values:dv}]} h={42}/></div>
                <span style={{fontFamily:O.mono,fontSize:12,fontWeight:700,color:flagged?O.crit:O.fg1,background:flagged?O.critBg:O.sunken,borderRadius:8,padding:'4px 10px',whiteSpace:'nowrap'}} title={'band +/-'+band}>{cur==null?'-':(cur>0?'+':'')+cur}{flagged?' !':''}</span>
              </div>;})}
          </>:<div style={{fontFamily:O.sans,fontSize:12.5,color:O.fg3,padding:'10px 0'}}>Divergence needs two sensors reporting in range.</div>}
        </Card>
        <Card>
          <CardHead title="Pressure trend" sub={presSrc?('from '+presSrc.name+' · last 3h vs prior 3h'):'no baro source in range'} icon="trending-up" right={<DerivedBadge formula={metaOf(d,'pressureTrend')}/>}/>
          <div style={{display:'flex',alignItems:'center',gap:14,marginBottom:10}}>
            <span style={{fontFamily:O.disp,fontWeight:800,fontSize:26,color:presTrend==='rising'?O.leaf:presTrend==='falling'?O.crit:presTrend?O.fg1:O.faint}}>
              {presTrend==null?'-':presTrend.toUpperCase()}</span>
            {presTrend&&<OIcon name={presTrend==='rising'?'trending-up':presTrend==='falling'?'trending-down':'minus'} size={24} color={presTrend==='rising'?O.leaf:presTrend==='falling'?O.crit:O.fg3}/>}
          </div>
          {presSeries.length>0&&<Ribbon buckets={d.buckets} series={[{label:'baro',color:O.azure,values:presSeries}]} h={56} unit="inHg"/>}
        </Card>
      </div>
    </>}</TrendGate>
  </div>;
}

// ================= HEALTH SCORECARD (Shell page, C-layout) =================
// Devices x four golden signals (traffic / freshness / errors / saturation) +
// availability SLA columns 24h/7d/30d + fleet summary row + honest alert
// burn-down (current posture only; history '-' until PM-161). Defaults to 30d so
// all three SLA windows fill. Row tiles drill into Device Vitals.
function HealthScorecard({ onVitals, siteId }){
  const t=useTrends('30d'); const d=t.data;
  const site=siteId?(SITES.find(s=>s.id===siteId)||null):null; // per-grower scope (null = whole fleet)
  const sla=v=>v==null?'-':v+'%';
  const slaTone=v=>v==null?'idle':v>=99?'ok':v>=90?'warn':'crit';
  const sig=dev=>{
    const a24=dev.availabilityPct.h24;
    const w=Math.round(24*60/d.bucketMinutes);
    const readings=dev.traffic.slice(-w).reduce((s,v)=>s+(v||0),0);
    return {
      traffic:{ value:a24==null?null:Math.round(readings/24*10)/10+'/h', tone:a24==null?'idle':a24>=99?'ok':a24>=90?'warn':'crit' },
      fresh:{ value:fmtAgo(dev.freshness.lastSeenMin), tone:dev.freshness.lastSeenMin<=dev.freshness.expectedCadenceMin?'ok':dev.freshness.lastSeenMin<=90?'warn':'crit' },
      errors:{ value:dev.gaps.length+' gaps', tone:dev.gaps.length===0?'ok':dev.gaps.length<=2?'warn':'crit' },
      sat:{ value:dev.battery.currentPct==null?null:dev.battery.currentPct+'%',
        sub:dev.battery.drainPctPerDay!=null?('-'+dev.battery.drainPctPerDay+'%/d'):undefined,
        tone:dev.battery.currentPct==null?'idle':dev.battery.currentPct<=25?'crit':dev.battery.currentPct<50?'warn':'ok' },
    };
  };
  const sevCount=k=>activeAlerts.filter(a=>a.severity===k&&(!siteId||a.site===siteId)).length; // scope alert posture to this grower
  return <div>
    <PageTitle title={site?('Mission Control - '+site.name):'Mission Control'} right={<RangeRight t={t}/>}/>
    <InsightNote title="Health Scorecard" items={["per-grower drill-down from the Mission Control fleet landing","golden signals mapped honestly: traffic=readings/h, latency=freshness, errors=gaps, saturation=battery","SLA columns need the 30d range (24h/7d requests leave longer windows '-')","alert history '-' until PM-161"]}/>
    <TrendGate t={t}>{d&&(()=>{ const rows=siteId?d.devices.filter(dev=>{ const cd=DEVICES.find(x=>x.id===dev.id); return cd&&cd.site===siteId; }):d.devices; return <>
      {/* Alert posture moved to the top of the page (owner direction) */}
      <Card style={{marginBottom:14}}>
        <CardHead title="Alert burn-down" sub="current posture from the live snapshot" icon="bell"/>
        <div style={{display:'flex',gap:10,flexWrap:'wrap'}}>
          {[['critical',O.crit,O.critBg],['warning',O.warn,O.warnBg],['info',O.info,O.infoBg]].map(([k,c,bg])=>
            <div key={k} style={{flex:1,minWidth:120,border:`1px solid ${c}33`,background:bg,borderRadius:10,padding:'10px 12px',textAlign:'center'}}>
              <div style={{fontFamily:O.disp,fontWeight:800,fontSize:24,color:c}}>{sevCount(k)}</div>
              <div style={{fontFamily:O.mono,fontSize:9.5,textTransform:'uppercase',letterSpacing:'.05em',color:O.fg3,marginTop:2}}>{k} active</div>
            </div>)}
          <div style={{flex:2,minWidth:200,border:`1px solid ${O.line}`,borderRadius:10,padding:'10px 12px',display:'flex',alignItems:'center',justifyContent:'center',fontFamily:O.mono,fontSize:11.5,color:O.faint}}>
            burn-down history: - (alert lifecycle lands with PM-161)
          </div>
        </div>
      </Card>
      <Card pad={0} style={{overflow:'hidden',marginBottom:14}}>
        <div style={{overflowX:'auto'}}>
        <table style={{width:'100%',borderCollapse:'collapse',minWidth:900}}>
          <thead><tr style={{background:O.sunken}}>
            {['Device','Traffic','Freshness','Errors','Saturation','SLA 24h','SLA 7d','SLA 30d'].map((h,i)=>
              <th key={h} style={{textAlign:i===0?'left':'center',padding:'10px 14px',fontFamily:O.mono,fontSize:10,fontWeight:600,letterSpacing:'.06em',textTransform:'uppercase',color:O.fg3,whiteSpace:'nowrap',paddingLeft:i===0?20:14}}>
                {h}{(h==='Traffic'||h.startsWith('SLA'))&&<span style={{marginLeft:6}}><DerivedBadge formula={metaOf(d,'availabilityPct')}/></span>}
              </th>)}
          </tr></thead>
          <tbody>
            {rows.map(dev=>{ const s=sig(dev); const cd=DEVICES.find(x=>x.id===dev.id);
              return <tr key={dev.id} style={{borderTop:`1px solid ${O.line}`}} className="ops-row">
                <td style={{padding:'10px 14px 10px 20px',whiteSpace:'nowrap'}}>
                  <div style={{fontFamily:O.sans,fontWeight:700,fontSize:13,color:O.fg1}}>{dev.name}</div>
                  <div style={{fontFamily:O.mono,fontSize:10,color:O.fg3}}>{dev.node} · {dev.kind}</div></td>
                {[s.traffic,s.fresh,s.errors,s.sat].map((c,i)=><td key={i} style={{padding:'8px 8px',minWidth:96}}>
                  <ScoreTile label={['traffic','freshness','errors','saturation'][i]} value={c.value} sub={c.sub} tone={c.tone} onClick={cd&&onVitals?()=>onVitals(cd):undefined}/></td>)}
                {[dev.availabilityPct.h24,dev.availabilityPct.d7,dev.availabilityPct.d30].map((v,i)=>
                  <td key={'sla'+i} style={{padding:'8px 8px',minWidth:80}}><ScoreTile label={['24h','7d','30d'][i]} value={sla(v)} tone={slaTone(v)}/></td>)}
              </tr>;})}
          </tbody>
        </table>
        </div>
      </Card>
    </>; })()}</TrendGate>
  </div>;
}

// ================= MISSION CONTROL - FLEET LANDING =================
// Higher-level landing for the Mission Control nav: a fleet-posture metrics band +
// one card per Grower. Each grower card drills into that grower's Mission Control
// scorecard (HealthScorecard scoped by siteId). Reuses the same 30d trends the
// scorecard already pulls -- no new API. Per-grower aggregates from DEVICES + trends.
function MissionFleet({ onMission }){
  const t=useTrends('30d'); const d=t.data;
  const sla=v=>v==null?'-':v+'%';
  const slaTone=v=>v==null?'idle':v>=99?'ok':v>=90?'warn':'crit';
  const sevCount=k=>activeAlerts.filter(a=>a.severity===k).length;
  const devOn=DEVICES.filter(x=>x.status==='online').length;
  return <div>
    <PageTitle title="Mission Control" right={<RangeRight t={t}/>}/>
    <InsightNote title="Mission Control - fleet" items={["NEW fleet landing: fleet-posture band + one card per Grower","each grower card drills into that grower's Mission Control scorecard","SLA columns need the 30d range"]}/>
    <TrendGate t={t}>{d&&<>
      <Card style={{marginBottom:14}}>
        <CardHead title="Fleet Posture" sub="aggregate across all Grower deployments" icon="activity"/>
        <div style={{display:'grid',gridTemplateColumns:'repeat(auto-fit,minmax(108px,1fr))',gap:10}}>
          <ScoreTile label="growers" value={SITES.length}/>
          <ScoreTile label="devices online" value={devOn+' / '+DEVICES.length} tone={devOn===DEVICES.length?'ok':devOn>0?'warn':'crit'}/>
          <ScoreTile label="SLA 24h" value={sla(d.fleet.availabilityPct.h24)} tone={slaTone(d.fleet.availabilityPct.h24)}/>
          <ScoreTile label="SLA 7d" value={sla(d.fleet.availabilityPct.d7)} tone={slaTone(d.fleet.availabilityPct.d7)}/>
          <ScoreTile label="SLA 30d" value={sla(d.fleet.availabilityPct.d30)} tone={slaTone(d.fleet.availabilityPct.d30)}/>
          <ScoreTile label="critical" value={sevCount('critical')} tone={sevCount('critical')>0?'crit':'ok'}/>
          <ScoreTile label="warning" value={sevCount('warning')} tone={sevCount('warning')>0?'warn':'ok'}/>
          <ScoreTile label="info" value={sevCount('info')} tone={sevCount('info')>0?'idle':'ok'}/>
        </div>
      </Card>
      <div style={{display:'grid',gridTemplateColumns:'repeat(auto-fill,minmax(330px,1fr))',gap:14}}>
        {SITES.map(st=>{
          const ds=DEVICES.filter(x=>x.site===st.id);
          const son=ds.filter(x=>x.status==='online').length;
          const slow=ds.filter(x=>isBattLow(x.battery)).length;
          const salerts=activeAlerts.filter(a=>a.site===st.id).length;
          const av=d.devices.filter(dev=>{ const cd=DEVICES.find(x=>x.id===dev.id); return cd&&cd.site===st.id; }).map(dev=>dev.availabilityPct.d30).filter(v=>v!=null);
          const avg=av.length?Math.round(av.reduce((s,v)=>s+v,0)/av.length*10)/10:null;
          return <div key={st.id} role="button" tabIndex={0} className="clk" onClick={()=>onMission(st.id)} onKeyDown={e=>{ if(e.key==='Enter'||e.key===' '){ e.preventDefault(); onMission(st.id); } }} title={'Open Mission Control - '+st.name} style={{textAlign:'left',background:O.raised,border:`1px solid ${O.line}`,borderRadius:14,padding:16}}>
            <div style={{display:'flex',alignItems:'flex-start',gap:9,marginBottom:12}}>
              <span style={{width:40,height:40,borderRadius:10,background:O.leaf05,color:O.leaf,display:'flex',alignItems:'center',justifyContent:'center',flexShrink:0}}><OIcon name="sprout" size={20}/></span>
              <div style={{flex:1,minWidth:0}}>
                <div style={{display:'flex',alignItems:'center',gap:8}}><span style={{fontFamily:O.sans,fontWeight:700,fontSize:15,color:O.fg1}}>{st.name}</span><span style={{fontFamily:O.mono,fontSize:9,fontWeight:700,letterSpacing:'.05em',textTransform:'uppercase',color:st.kind==='commercial'?O.leaf:O.azure,background:st.kind==='commercial'?O.leaf05:O.azure05,padding:'2px 7px',borderRadius:999}}>{st.kind}</span></div>
                <div style={{fontFamily:O.sans,fontSize:12,color:O.fg3,marginTop:2}}>{[st.crop,st.region,st.site].filter(Boolean).join(' · ')||'-'}</div>
              </div>
              <OIcon name="chevron-right" size={17} color={O.faint}/>
            </div>
            <div style={{display:'grid',gridTemplateColumns:'repeat(2,1fr)',gap:8}}>
              <ScoreTile label="devices online" value={son+' / '+ds.length} tone={son===ds.length?'ok':son>0?'warn':'crit'}/>
              <ScoreTile label="availability 30d" value={avg==null?'-':avg+'%'} tone={slaTone(avg)}/>
              <ScoreTile label="low battery" value={slow} tone={slow>0?'warn':'ok'}/>
              <ScoreTile label="active alerts" value={salerts} tone={salerts>0?'crit':'ok'}/>
            </div>
          </div>;
        })}
      </div>
    </>}</TrendGate>
  </div>;
}

// ================= DEVICE VITALS (drill-down; deep link #/vitals/<id>) =================
// Identity header; battery series + DERIVED drain/projection (suppressed when
// the slope is noise/charging); GapTimeline; per-channel small multiples;
// '-' tiles for the absent sources with their owning PM numbers.
function DeviceVitals({ device }){
  const t=useTrends('24h'); const d=t.data;
  // canon id convention matches the trends payload: gateway = uuid, leaf = node
  const td=d?d.devices.find(x=>x.id===(device.uuid||device.id)||x.id===device.id):null;
  const lastOf=v=>{ for(let i=v.length-1;i>=0;i--) if(v[i]!=null) return v[i]; return null; };
  const CH_LABEL={TEMP:'Temperature',HUMID:'Humidity',AIR_PRESS:'Pressure',NOISE:'Noise',GAS_RES:'Gas (MOhm)',GW_TEMP:'Temperature',GW_HUMID:'Humidity',GW_AIR_PRESS:'Pressure'};
  const CH_UNIT={TEMP:'F',HUMID:'%',AIR_PRESS:'inHg',NOISE:'dB',GAS_RES:'',GW_TEMP:'F',GW_HUMID:'%',GW_AIR_PRESS:'inHg'};
  return <div>
    <PageTitle title="Device Vitals" sub={device.name+' · '+(d?d.range:t.range)+' trend window'} right={<RangeRight t={t}/>}/>
    <InsightNote title="Device Vitals" items={["NEW drill-down (PM-197), deep-linkable (#/vitals/"+device.id+")","battery drain + days-to-empty are DERIVED and SUPPRESSED when the slope is noise/charging","'-' tiles carry their owning PM numbers (RSSI PM-160 / FW PM-175 / AQI PM-180)"]}/>
    {/* identity header (live snapshot - renders before/without trends) */}
    <Card style={{marginBottom:14}}>
      <div style={{display:'flex',gap:14,alignItems:'center',flexWrap:'wrap'}}>
        <span style={{width:46,height:46,borderRadius:11,background:O.azure05,color:O.azure,display:'flex',alignItems:'center',justifyContent:'center',flexShrink:0}}><OIcon name={KIND[device.kind].icon} size={22}/></span>
        <div style={{minWidth:180}}>
          <div style={{display:'flex',alignItems:'center',gap:10,flexWrap:'wrap'}}><span style={{fontFamily:O.disp,fontWeight:800,fontSize:20,color:O.fg1}}>{device.name}</span><StatusPill status={device.status}/></div>
          <div style={{fontFamily:O.mono,fontSize:11.5,color:O.fg3,marginTop:3}}>{device.node} · {KIND[device.kind].label} · {siteName(device.site)} · {device.link}</div>
        </div>
        <div style={{marginLeft:'auto',display:'flex',gap:14,alignItems:'center',fontFamily:O.mono,fontSize:12,color:O.fg2}}>
          <span title="last seen">{fmtAgo(device.lastSeen)}</span>
          {device.battery!=null&&<Battery pct={device.battery}/>}
        </div>
      </div>
    </Card>
    <TrendGate t={t}>{d&&td&&<>
      <div style={{display:'grid',gridTemplateColumns:'1.4fr 1fr',gap:14,marginBottom:14}} className="ops-2col">
        <Card>
          <CardHead title="Battery" sub={d.range+' series + projection'} icon="battery" right={<DerivedBadge formula={metaOf(d,'drainPctPerDay')}/>}/>
          <Ribbon buckets={d.buckets} series={[{label:'battery %',color:O.leaf,values:td.battery.series}]} h={84} unit="%"/>
          <div style={{display:'flex',gap:10,marginTop:12,flexWrap:'wrap'}}>
            <ScoreTile label="current" value={td.battery.currentPct==null?null:td.battery.currentPct+'%'} tone={td.battery.currentPct==null?'idle':td.battery.currentPct<=25?'crit':td.battery.currentPct<50?'warn':'ok'}/>
            <ScoreTile label="drain /day" value={td.battery.drainPctPerDay==null?null:td.battery.drainPctPerDay+'%'} tone={td.battery.drainPctPerDay==null?'idle':td.battery.drainPctPerDay>2?'warn':'ok'}/>
            <ScoreTile label="days to empty" value={td.battery.daysToEmpty==null?null:td.battery.daysToEmpty+'d'} tone={td.battery.daysToEmpty==null?'idle':td.battery.daysToEmpty<14?'crit':td.battery.daysToEmpty<45?'warn':'ok'}/>
          </div>
          {td.battery.drainPctPerDay==null&&<div style={{fontFamily:O.mono,fontSize:9.5,color:O.faint,marginTop:8}}>projection suppressed: slope is flat/noisy or the pack is charging ({metaOf(d,'drainPctPerDay')})</div>}
        </Card>
        <Card>
          <CardHead title="Reporting timeline" sub={td.gaps.length+' gap'+(td.gaps.length===1?'':'s')+' in range'} icon="timer" right={<DerivedBadge formula={metaOf(d,'gaps')}/>}/>
          <GapTimeline buckets={d.buckets} values={td.traffic} bucketMinutes={d.bucketMinutes}/>
          <div style={{display:'flex',justifyContent:'space-between',fontFamily:O.mono,fontSize:9,color:O.fg3,marginTop:5}}>
            <span>{fmtBucketTick(d.buckets[0])}</span><span>green = reporting · red = gap (hover for start/end/duration)</span><span>{fmtBucketTick(d.buckets[d.buckets.length-1])}</span>
          </div>
          <div style={{display:'flex',gap:10,marginTop:12}}>
            <ScoreTile label={'availability '+d.range} value={availFor(td,d.range)==null?null:availFor(td,d.range)+'%'} tone={availFor(td,d.range)==null?'idle':availFor(td,d.range)>=99?'ok':availFor(td,d.range)>=90?'warn':'crit'}/>
            <ScoreTile label="freshness" value={fmtAgo(td.freshness.lastSeenMin)} tone={td.freshness.lastSeenMin<=td.freshness.expectedCadenceMin?'ok':td.freshness.lastSeenMin<=90?'warn':'crit'} sub={'expect <= '+td.freshness.expectedCadenceMin+'m'}/>
          </div>
        </Card>
      </div>
      <Card>
        <CardHead title="Channels" sub="per-channel small multiples · aligned buckets" icon="layout-grid"/>
        <div style={{display:'grid',gridTemplateColumns:'repeat(auto-fill,minmax(240px,1fr))',gap:12}}>
          {Object.keys(td.channels).map((key,i)=>{ const vals=td.channels[key]; const cur=lastOf(vals);
            const colors=[O.azure,O.teal,O.gold,O.leaf4,O.azure6];
            return <div key={key} style={{border:`1px solid ${O.line}`,borderRadius:10,padding:'10px 12px'}}>
              <div style={{display:'flex',justifyContent:'space-between',alignItems:'baseline',marginBottom:6}}>
                <span style={{fontFamily:O.mono,fontSize:9.5,fontWeight:600,letterSpacing:'.05em',textTransform:'uppercase',color:O.fg3}}>{CH_LABEL[key]||key}</span>
                <span style={{fontFamily:O.mono,fontSize:12.5,fontWeight:700,color:O.fg1}}>{cur==null?'-':cur}{cur!=null&&CH_UNIT[key]?' '+CH_UNIT[key]:''}</span>
              </div>
              <Ribbon buckets={d.buckets} series={[{label:key,color:colors[i%colors.length],values:vals}]} h={52}/>
            </div>;})}
          {td.kind!=='router'&&<div style={{border:`1px solid ${O.line}`,borderRadius:10,padding:'10px 12px'}}>
            <div style={{display:'flex',justifyContent:'space-between',alignItems:'baseline',marginBottom:6}}>
              <span style={{fontFamily:O.mono,fontSize:9.5,fontWeight:600,letterSpacing:'.05em',textTransform:'uppercase',color:O.fg3}}>Dew point</span>
              <span style={{display:'inline-flex',gap:6,alignItems:'baseline'}}><span style={{fontFamily:O.mono,fontSize:12.5,fontWeight:700,color:O.fg1}}>{lastOf(td.derived.dewPointF)==null?'-':lastOf(td.derived.dewPointF)+' F'}</span><DerivedBadge formula={metaOf(d,'dewPointF')}/></span>
            </div>
            <Ribbon buckets={d.buckets} series={[{label:'dew point',color:O.cyan,values:td.derived.dewPointF}]} h={52}/>
          </div>}
          {[['Signal / RSSI','PM-160'],['Firmware','PM-175'],['Air quality','PM-180']].map(([lbl,pm])=>
            <div key={lbl} style={{border:`1px dashed ${O.line}`,borderRadius:10,padding:'10px 12px',display:'flex',flexDirection:'column',justifyContent:'center',minHeight:88,textAlign:'center'}}>
              <span style={{fontFamily:O.mono,fontSize:9.5,fontWeight:600,letterSpacing:'.05em',textTransform:'uppercase',color:O.fg3}}>{lbl}</span>
              <span style={{fontFamily:O.mono,fontSize:16,color:O.faint,marginTop:6}}>-</span>
              <span style={{fontFamily:O.mono,fontSize:9,color:O.faint,marginTop:4}}>not collected yet · {pm}</span>
            </div>)}
        </div>
      </Card>
    </>}</TrendGate>
    {d&&!td&&<Card style={{padding:'24px',textAlign:'center',fontFamily:O.sans,fontSize:13,color:O.fg3}}>No trend entry for this device in the current payload.</Card>}
  </div>;
}

// ================= MISSION CONTROL (full-bleed immersive, A-skin, kiosk) =================
// Full-viewport cosmos canvas (insights.css; CSS-only starfield, no JS loops).
// Center: constellation mesh map (gateway = primary star, router/sensors =
// satellites from topology adjacency; glow = DS status tokens; one CSS pulse
// when a device's reading arrived in the latest poll). Bottom: four golden-
// signal Radials over trends 24h (refreshed every 5 min). Right: live alert
// ticker (history '-' until PM-161). Fleet data rides the existing 30s poll
// (OpsApp re-renders this tree). Kiosk: ?kiosk=1 hides the exit + enlarges type.
function MissionControl({ onExit, onDevice, kiosk: kioskRoute }){
  const kiosk=kioskRoute||/[?&]kiosk=1/.test((typeof location!=='undefined'&&location.search)||'');
  const [trends,setTrends]=React.useState(null);
  const [tErr,setTErr]=React.useState(null);
  React.useEffect(()=>{ let on=true;
    const load=()=>window.loadTrends('24h')
      .then(x=>{ if(on){ setTrends(x); setTErr(null); } })
      .catch(e=>{ if(on) setTErr(e.code===404?'not-deployed':'error'); });
    load(); const id=setInterval(load,300000); // 5-min trend refresh per spec
    return ()=>{ on=false; clearInterval(id); };
  },[]);
  const devs=DEVICES;
  // --- constellation layout: gateways spread on the x axis, children on rings ---
  const W=1000,H=520;
  const gws=devs.filter(x=>x.kind==='gateway');
  const pos=new Map(), links=[];
  gws.forEach((g,gi)=>{
    const cx=W*(gi+1)/(gws.length+1), cy=H*0.52;
    pos.set(g.id,[cx,cy]);
    const kids=devs.filter(x=>x.parent===g.id);
    kids.forEach((k,ki)=>{
      const a=Math.PI*(1.15+0.7*(kids.length>1?ki/(kids.length-1):0.5)); // arc above the gateway
      const kx=cx+170*Math.cos(a), ky=cy+150*Math.sin(a);
      pos.set(k.id,[kx,ky]); links.push([g.id,k.id]);
      const gkids=devs.filter(x=>x.parent===k.id);
      gkids.forEach((s,si)=>{
        const b=a+(si-(gkids.length-1)/2)*0.55;
        pos.set(s.id,[kx+150*Math.cos(b), ky+130*Math.sin(b)]); links.push([k.id,s.id]);
      });
    });
  });
  const tone=s=>s==='online'?O.leaf4:s==='stale'?O.warn:O.crit;
  // --- golden signals from trends 24h + the live snapshot ---
  let radTraffic=null, radGaps=null, uptime=null, trendArrow=null;
  if(trends){
    // The LAST completed bucket is the current rate - a zero there is genuine
    // fleet silence and must show as 0 (honesty), not reach back to older data.
    const ft=trends.fleet.traffic;
    const cur=ft.length?ft[ft.length-1]:null;
    const mean=trends.fleet.traffic.reduce((s,v)=>s+v,0)/Math.max(1,trends.fleet.traffic.length);
    radTraffic={ value:cur==null?null:Math.round(cur*(60/trends.bucketMinutes)), base:mean*(60/trends.bucketMinutes) };
    trendArrow=cur==null?null:(cur>mean*1.1?1:cur<mean*0.9?-1:0);
    radGaps=trends.fleet.gapCount;
    uptime=trends.fleet.availabilityPct.h24;
  }
  // lastSeen is non-null by the adapter contract; if that ever breaks, a
  // never-seen device must read as WORST (stale), never as freshest.
  const worstAge=Math.max(...devs.map(x=>x.lastSeen==null?9999:x.lastSeen));
  const batts=devs.filter(x=>x.battery!=null);
  const minBatt=batts.length?Math.min(...batts.map(x=>x.battery)):null;
  const steepest=trends?Math.max(0,...trends.devices.map(x=>x.battery.drainPctPerDay||0)):0;
  const ticker=activeAlerts;
  return <div className={'cosmos'+(kiosk?' kiosk':'')}>
    <div className="starfield"/><div className="starfield2"/>
    <div style={{position:'relative',minHeight:'100vh',display:'grid',gridTemplateColumns:'1fr 320px',gridTemplateRows:'auto 1fr auto',gap:18,padding:'18px 22px',boxSizing:'border-box'}} className="mc-grid">
      {/* header */}
      <div style={{gridColumn:'1 / -1',display:'flex',alignItems:'center',gap:14}}>
        <img src="assets/bee-icon-final.svg?v=38" alt="" style={{width:34}}/>
        <div className="mc-title" style={{fontFamily:O.disp,fontWeight:800,fontSize:22,letterSpacing:'.02em',textTransform:'uppercase'}}>Mission Control</div>
        <span style={{fontFamily:O.mono,fontSize:11,opacity:.7}}>{devs.filter(x=>x.status==='online').length}/{devs.length} stars reporting</span>
        {tErr==='not-deployed'&&<span style={{fontFamily:O.mono,fontSize:10,color:O.warn,border:`1px solid ${O.warn}55`,borderRadius:999,padding:'2px 10px'}}>trend service not yet deployed - poll data only</span>}
        <button className="mc-exit clk" onClick={onExit} title="Exit Mission Control" style={{marginLeft:'auto',background:'rgba(14,28,51,0.7)',border:'1px solid rgba(174,196,230,0.3)',borderRadius:9,padding:'8px 16px',cursor:'pointer',color:'#E6EEF9',fontFamily:O.sans,fontWeight:700,fontSize:12.5,display:'inline-flex',alignItems:'center',gap:7}}><OIcon name="x" size={14}/>Exit</button>
      </div>
      {/* constellation */}
      <div className="cosmos-card" style={{padding:10,display:'flex',alignItems:'center',justifyContent:'center',minHeight:300}}>
        {/* viewBox top extends to -80: with the real fleet (gateway -> router ->
            2 sensors stacked upward) the grandchild stars land near y=0 and
            would clip without the headroom */}
        <svg viewBox={`0 -80 ${W} ${H+80}`} style={{width:'100%',height:'100%'}}>
          {links.map(([a,b],i)=>{ const pa=pos.get(a),pb=pos.get(b); if(!pa||!pb) return null;
            return <line key={i} x1={pa[0]} y1={pa[1]} x2={pb[0]} y2={pb[1]} stroke="rgba(174,196,230,0.28)" strokeWidth="1.2" strokeDasharray="3 5"/>;})}
          {devs.map(dv=>{ const p=pos.get(dv.id); if(!p) return null;
            const c=tone(dv.status), R=dv.kind==='gateway'?13:dv.kind==='router'?9:7;
            const fresh=dv.lastSeen===0; // reading arrived in the latest poll (one pulse per arrival: mounts at 0, unmounts at 1)
            return <g key={dv.id} transform={`translate(${p[0]} ${p[1]})`} onClick={()=>onDevice&&onDevice(dv)} style={{cursor:'pointer'}}>
              {fresh&&<circle className="pulse-ring" r={R+3} fill="none" stroke={c} strokeWidth="2"/>}
              <circle className="star-node" r={R} fill={c} color={c}/>
              <circle r={R*0.45} fill="#fff" opacity="0.85"/>
              <text y={R+16} textAnchor="middle" style={{fontFamily:O.mono,fontSize:11,fill:'#AEC4E6'}}>{dv.name}</text>
              <text y={R+30} textAnchor="middle" style={{fontFamily:O.mono,fontSize:9,fill:'rgba(174,196,230,0.6)'}}>{dv.kind==='gateway'?'LTE uplink':dv.node}</text>
            </g>;})}
        </svg>
      </div>
      {/* alert ticker rail */}
      <div className="cosmos-card mc-rail" style={{padding:'14px 14px',overflowY:'auto'}}>
        <div style={{fontFamily:O.mono,fontSize:10,fontWeight:700,letterSpacing:'.08em',textTransform:'uppercase',color:'#AEC4E6',marginBottom:10,display:'flex',alignItems:'center',gap:7}}><OIcon name="bell" size={13}/>Live alerts</div>
        {ticker.length===0
          ? <div style={{textAlign:'center',padding:'26px 8px'}}>
              <div className="glow-ok" style={{display:'flex',justifyContent:'center',marginBottom:8}}><OIcon name="shield-check" size={26}/></div>
              <div style={{fontFamily:O.sans,fontWeight:700,fontSize:14}}>All quiet</div>
              <div style={{fontFamily:O.mono,fontSize:10.5,opacity:.65,marginTop:4}}>24h availability {uptime==null?'-':uptime+'%'}</div>
            </div>
          : ticker.map(a=>{ const c=a.severity==='critical'?O.crit:a.severity==='warning'?O.warn:O.info;
              return <div key={a.id} className="mc-tick" style={{borderLeftColor:c}}>
                <div style={{fontFamily:O.sans,fontWeight:700,fontSize:12,color:'#E6EEF9'}}>{a.title}</div>
                <div style={{fontFamily:O.mono,fontSize:9.5,opacity:.65,marginTop:2}}>{siteName(a.site)}{a.node?' · '+a.node:''}</div>
              </div>;})}
        <div style={{fontFamily:O.mono,fontSize:9,opacity:.45,marginTop:10}}>alert history: - (PM-161)</div>
      </div>
      {/* golden-signal radials */}
      <div className="cosmos-card mc-radials" style={{gridColumn:'1 / -1',padding:'14px 18px',display:'flex',gap:18,alignItems:'center',justifyContent:'space-around',flexWrap:'wrap'}}>
        <Radial label="traffic /h" value={radTraffic?radTraffic.value:null} pct={radTraffic&&radTraffic.value!=null&&radTraffic.base>0?Math.min(1,radTraffic.value/(radTraffic.base*1.5)):null}
          tone={radTraffic&&radTraffic.value!=null?(radTraffic.value>=radTraffic.base*0.6?'ok':radTraffic.value>=radTraffic.base*0.3?'warn':'crit'):'ok'} trend={trendArrow} sub="vs 24h baseline"/>
        <Radial label="worst freshness" value={worstAge} unit="m" pct={1-Math.min(1,worstAge/90)} tone={worstAge<=15?'ok':worstAge<=90?'warn':'crit'} sub="age vs cadence"/>
        <Radial label="missed windows" value={radGaps} pct={radGaps==null?null:1-Math.min(1,radGaps/8)} tone={radGaps==null?'ok':radGaps===0?'ok':radGaps<=2?'warn':'crit'} sub="gaps · 24h"/>
        <Radial label="lowest battery" value={minBatt} unit="%" pct={minBatt==null?null:minBatt/100} tone={minBatt==null?'ok':minBatt<=25?'crit':minBatt<50?'warn':'ok'} sub={steepest>0?('steepest -'+steepest+'%/d'):'drain -'}/>
      </div>
    </div>
  </div>;
}

Object.assign(window,{ FleetRhythms, Microclimate, HealthScorecard, MissionFleet, DeviceVitals, MissionControl, useTrends });
