/* ============================================================
   Living bento widgets (right column)
   ============================================================ */
const { useState: useS, useEffect: useE, useRef: useR } = React;

const fmt = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`;

/* count-up number animation */
function useCountUp(target, on, dur = 1100) {
  const [v, setV] = useS(on ? 0 : target);
  useE(() => {
    if (!on) { setV(target); return; }
    let raf, t0;
    const tick = (t) => {
      if (!t0) t0 = t;
      const p = Math.min(1, (t - t0) / dur);
      const e = 1 - Math.pow(1 - p, 3);
      setV(Math.round(target * e));
      if (p < 1) raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [target, on]);
  return v;
}

/* shared card wrapper with tilt */
function Card({ className = '', area, motion, style, children, label, ...rest }) {
  const ref = useTilt(motion);
  const cls = `card lift ${area ? 'b-' + area : ''} ${className}`;
  return <div ref={ref} className={cls} style={style} data-screen-label={label} {...rest}>{children}</div>;
}

/* ---------------- Now Playing (live Spotify, Discord-style card) ---------------- */
function NowPlaying({ data, motion }) {
  const cfg = data.spotify || {};
  // state.status: 'loading' | 'playing' | 'idle'
  const [st, setSt] = useS({ status: 'loading' });
  const [, tick] = useS(0);
  const pullRef = useR(null);
  const lastEndPoll = useR(0);
  const audioRef = useR(null);
  const fadeRef = useR(null);
  const unlockedRef = useR(false);
  const wantsPlayRef = useR(false);

  // poll the real endpoint
  useE(() => {
    const url = cfg.nowPlayingEndpoint;
    if (!url) { setSt({ status: 'idle' }); return; }
    let alive = true;
    const pull = async () => {
      try {
        const r = await fetch(url, { cache: 'no-store' });
        if (!r.ok) throw new Error(r.status);
        const j = await r.json();
        if (!alive) return;
        if (j && j.isPlaying) {
          setSt(prev => {
            const newTrack = j.title || j.track || 'Unknown';
            const sameTrack = prev.track === newTrack;
            return {
              status: 'playing',
              track: newTrack,
              artist: j.artist || '',
              album: j.album || j.albumName || '',
              art: j.albumImageUrl || j.albumArt || null,
              url: j.songUrl || j.url || null,
              durationMs: j.durationMs || 0,
              progressMs: j.progressMs || 0,
              previewUrl: j.previewUrl || (sameTrack ? prev.previewUrl : null),
              at: Date.now(),
            };
          });
        } else {
          setSt({ status: 'idle' });
        }
      } catch (e) { if (alive) setSt({ status: 'idle' }); }
    };
    pullRef.current = pull;
    pull();
    const id = setInterval(pull, cfg.pollMs || 15000);
    return () => { alive = false; clearInterval(id); };
  }, [cfg.nowPlayingEndpoint]);

  // smooth real-time ticker — re-derives progress from the anchor (no drift)
  useE(() => {
    if (st.status !== 'playing') return;
    const id = setInterval(() => {
      const el = st.progressMs + (Date.now() - st.at);
      // when the track should be over, re-poll promptly so the next song appears
      if (el >= (st.durationMs || 0) && Date.now() - lastEndPoll.current > 2500) {
        lastEndPoll.current = Date.now();
        pullRef.current && pullRef.current();
      }
      tick((t) => t + 1);
    }, 500);
    return () => clearInterval(id);
  }, [st.status, st.at, st.progressMs, st.durationMs]);

  // unlock browser audio policy on first click anywhere on page
  useE(() => {
    const unlock = () => {
      if (unlockedRef.current) return;
      unlockedRef.current = true;
      const a = new Audio();
      a.volume = 0;
      a.play().then(() => {
        a.pause();
        if (wantsPlayRef.current) onPreviewIn();
      }).catch(() => {});
      document.removeEventListener('pointerdown', unlock);
    };
    document.addEventListener('pointerdown', unlock);
    return () => document.removeEventListener('pointerdown', unlock);
  }, []);

  // cleanup audio on unmount
  useE(() => () => {
    clearInterval(fadeRef.current);
    if (audioRef.current) { audioRef.current.pause(); audioRef.current = null; }
  }, []);

  // stop old audio when track changes
  useE(() => {
    if (st.status !== 'playing') return;
    clearInterval(fadeRef.current);
    if (audioRef.current) { audioRef.current.pause(); audioRef.current = null; }
  }, [st.track]);

  const doFade = (to) => {
    clearInterval(fadeRef.current);
    const a = audioRef.current;
    if (!a) return;
    fadeRef.current = setInterval(() => {
      const nv = Math.max(0, Math.min(0.28, a.volume + (to > a.volume ? 0.025 : -0.025)));
      a.volume = nv;
      if (Math.abs(nv - to) < 0.026) {
        a.volume = to;
        clearInterval(fadeRef.current);
        if (to === 0) a.pause();
      }
    }, 30);
  };

  const onPreviewIn = () => {
    if (!unlockedRef.current) { wantsPlayRef.current = true; return; }
    wantsPlayRef.current = false;
    const url = st.previewUrl;
    if (!url) return;
    if (!audioRef.current || audioRef.current._src !== url) {
      if (audioRef.current) { clearInterval(fadeRef.current); audioRef.current.pause(); }
      const a = new Audio(url);
      a._src = url;
      a.loop = true;
      a.volume = 0;
      audioRef.current = a;
    }
    audioRef.current.play().catch(() => {});
    doFade(0.28);
  };

  const onPreviewOut = () => { wantsPlayRef.current = false; doFade(0); };

  const playing = st.status === 'playing';
  const durationMs = Math.max(1, st.durationMs || 1);
  const elapsedMs = playing ? Math.min(durationMs, st.progressMs + (Date.now() - st.at)) : 0;
  const pct = playing ? Math.min(100, (elapsedMs / durationMs) * 100) : 0;

  // ---- Idle / not listening ----
  if (!playing) {
    return (
      <Card area="now" motion={motion} label="Now playing" className="np np-idle">
        <div className="card-head">
          <span className="np-brand"><Icon.spotify /> Spotify</span>
          <span className="np-dot-off"></span>
        </div>
        <div className="np-idle-body">
          <div className="np-art np-art-idle"><Icon.spotify width={26} height={26} /></div>
          <div className="np-track">
            <b>{st.status === 'loading' ? 'Checking…' : 'Not listening right now'}</b>
            <span>{st.status === 'loading' ? 'one sec' : 'Offline · no track playing'}</span>
          </div>
        </div>
      </Card>
    );
  }

  // ---- Playing (Discord-style, horizontal) ----
  const Wrapper = st.url ? 'a' : 'div';
  const wrapProps = st.url ? { href: st.url, target: '_blank', rel: 'noreferrer' } : {};
  return (
    <Card area="now" motion={motion} label="Now playing" className="np np-live has-art"
          onMouseEnter={st.previewUrl ? onPreviewIn : undefined}
          onMouseLeave={onPreviewOut}>
      {st.art && <div className={`np-bg ${motion ? 'drift' : ''}`} style={{ backgroundImage: `url(${st.art})` }}></div>}
      <div className="np-scrim"></div>
      <div className="np-live-grid">
        <Wrapper className="np-art np-art-link" {...wrapProps} style={st.art ? { backgroundImage: `url(${st.art})`, backgroundSize: 'cover' } : undefined}></Wrapper>
        <div className="np-live-info">
          <div className="np-live-top">
            <span className="np-brand on"><Icon.spotify /> Listening on Spotify</span>
            <div className="eq on"><i /><i /><i /><i /><i /></div>
          </div>
          <Wrapper className="np-live-meta" {...wrapProps}>
            <b>{st.track}</b>
            <span className="np-artist">{st.artist}</span>
            {st.album && <span className="np-album">{st.album}</span>}
          </Wrapper>
          <div className="np-live-bottom">
            <div className="np-bar"><i style={{ width: pct + '%' }} /></div>
            <div className="np-time"><span>{fmt(Math.floor(elapsedMs / 1000))}</span><span>{fmt(Math.floor(durationMs / 1000))}</span></div>
          </div>
        </div>
      </div>
    </Card>
  );
}

/* ---------------- Weather / location ---------------- */
const WX_INFO = (code) => {
  if (code === 0)   return { sky: 'Clear',        icon: 'sun'            };
  if (code <= 2)    return { sky: 'Partly Cloudy', icon: 'cloudSun'      };
  if (code === 3)   return { sky: 'Overcast',      icon: 'cloud'          };
  if (code <= 48)   return { sky: 'Foggy',         icon: 'cloudFog'       };
  if (code <= 55)   return { sky: 'Drizzle',       icon: 'cloudDrizzle'   };
  if (code <= 67)   return { sky: 'Rainy',         icon: 'cloudRain'      };
  if (code <= 82)   return { sky: 'Showers',       icon: 'cloudRain'      };
  if (code <= 99)   return { sky: 'Thunderstorm',  icon: 'cloudLightning' };
  return { sky: 'Clear', icon: 'sun' };
};

const WX_BG = {
  sun:                'https://images.unsplash.com/photo-1531366936337-7c912a4589a7?w=600&q=65&fit=crop',
  sunNight:           'https://images.unsplash.com/photo-1419242902214-272b3f66ee7a?w=600&q=65&fit=crop',
  cloudSun:           'https://images.unsplash.com/photo-1534088568595-a066f410bcda?w=600&q=65&fit=crop',
  cloudSunNight:      'https://images.unsplash.com/photo-1507400492013-162706c8c05e?w=600&q=65&fit=crop',
  cloud:              'https://images.unsplash.com/photo-1462275646964-a0e3386b89fa?w=600&q=65&fit=crop',
  cloudNight:         'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=600&q=65&fit=crop',
  cloudFog:           'https://images.unsplash.com/photo-1509515837298-2c67a3933321?w=600&q=65&fit=crop',
  cloudDrizzle:       'https://images.unsplash.com/photo-1530908295418-a12e326966ba?w=600&q=65&fit=crop',
  cloudRain:          'https://images.unsplash.com/photo-1519692933481-e162a57d6721?w=600&q=65&fit=crop',
  cloudLightning:     'https://images.unsplash.com/photo-1472220625704-91e1462799b2?w=600&q=65&fit=crop',
};

function Weather({ data, motion }) {
  const clk = useClock(data.location.tzOffset);
  const [wx, setWx] = useS(null);
  const [imgs, setImgs] = useS({ a: null, b: null, front: 'a' });

  useE(() => {
    const { lat, lon } = data.location;
    if (!lat || !lon) return;
    const pull = async () => {
      try {
        const r = await fetch(
          `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}&current=temperature_2m,weather_code&temperature_unit=celsius&timezone=Asia%2FBangkok`
        );
        const j = await r.json();
        const c = j.current || {};
        const info = WX_INFO(c.weather_code ?? 0);
        setWx({ temp: Math.round(c.temperature_2m), ...info });
      } catch (e) { /* keep hardcoded fallback */ }
    };
    pull();
    const id = setInterval(pull, 15 * 60 * 1000);
    return () => clearInterval(id);
  }, []);

  const icon = wx ? wx.icon : 'cloudSun';
  const isNight = clk.hour >= 18 || clk.hour < 6;
  const bgIcon = (isNight && WX_BG[icon + 'Night']) ? icon + 'Night' : icon;

  useE(() => {
    const newBg = WX_BG[bgIcon] || WX_BG.cloudSun;
    setImgs(prev => {
      const curBg = prev.front === 'a' ? prev.a : prev.b;
      if (curBg === newBg) return prev;
      if (prev.front === 'a') return { a: prev.a, b: newBg, front: 'b' };
      return { a: newBg, b: prev.b, front: 'a' };
    });
  }, [bgIcon]);

  const temp   = wx ? wx.temp : data.location.temp;
  const sky    = wx ? wx.sky  : data.location.sky;
  const WxIcon = Icon[icon] || Icon.cloudSun;
  const aFront = imgs.front === 'a';

  return (
    <Card area="weather" motion={motion} label="Weather">
      <div className="wx-bg" style={{ backgroundImage: imgs.a ? `url(${imgs.a})` : undefined, opacity: aFront ? 1 : 0 }} />
      <div className="wx-bg" style={{ backgroundImage: imgs.b ? `url(${imgs.b})` : undefined, opacity: aFront ? 0 : 1 }} />
      <div className="wx-overlay" />
      <span className="t-label">{data.location.city} · {data.location.tz}</span>
      <div className="wx-temp">{temp}°</div>
      <div className="wx-row"><WxIcon /> {sky}</div>
      <div className="wx-time">{clk.hh}:{clk.mm} local</div>
    </Card>
  );
}

/* ---------------- Status (LIVE Discord presence via Lanyard) ---------------- */
const STATUS_TEXT = { online: 'Online', idle: 'Idle', dnd: 'Do Not Disturb', offline: 'Offline' };
const ACT_VERB = { 0: 'Playing', 1: 'Streaming', 2: 'Listening to', 3: 'Watching', 5: 'Competing in' };

function lanyardAsset(appId, img) {
  if (!img) return null;
  if (img.startsWith('mp:external/')) return 'https://media.discordapp.net/external/' + img.slice('mp:external/'.length);
  if (img.startsWith('mp:')) return 'https://media.discordapp.net/' + img.slice(3);
  if (img.startsWith('spotify:')) return 'https://i.scdn.co/image/' + img.slice('spotify:'.length);
  if (/^https?:\/\//.test(img)) return img;
  return appId ? `https://cdn.discordapp.com/app-assets/${appId}/${img}.png` : null;
}
function fmtElapsed(ms) {
  if (ms < 0) ms = 0;
  const s = Math.floor(ms / 1000);
  const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60;
  const pad = (n) => String(n).padStart(2, '0');
  return h > 0 ? `${h}:${pad(m)}:${pad(sec)}` : `${m}:${pad(sec)}`;
}

function StatusCard({ data, motion }) {
  const DISCORD_ID = data.discord.userId || '373411032191991808';
  const [ly, setLy] = useS(null);
  const [now, setNow] = useS(Date.now());
  const [appIcon, setAppIcon] = useS({});

  // poll Lanyard for live presence (no token needed; user must be in discord.gg/lanyard)
  useE(() => {
    let alive = true;
    const pull = async () => {
      try {
        const j = await fetch(`https://api.lanyard.rest/v1/users/${DISCORD_ID}`).then((r) => r.json());
        if (alive && j && j.success) setLy(j.data);
      } catch (e) { /* offline — fall back to static */ }
    };
    pull();
    const id = setInterval(pull, 20000);
    return () => { alive = false; clearInterval(id); };
  }, [DISCORD_ID]);

  // tick every second so the elapsed timer counts up
  useE(() => {
    const id = setInterval(() => setNow(Date.now()), 1000);
    return () => clearInterval(id);
  }, []);

  // pick the activity to feature: a game / non-music activity (Spotify is handled by Now Playing)
  let activity = null, kind = null;
  if (ly) {
    const a = (ly.activities || []).find((x) => x.type !== 4 && x.type !== 2 && x.name !== 'Spotify');
    if (a) {
      kind = 'game';
      const bigUrl = a.assets && lanyardAsset(a.application_id, a.assets.large_image);
      activity = {
        verb: ACT_VERB[a.type] || 'Playing', name: a.name, line1: a.details, line2: a.state,
        appId: a.application_id,
        big: bigUrl || appIcon[a.application_id] || null,
        small: a.assets && lanyardAsset(a.application_id, a.assets.small_image),
        start: a.timestamps && a.timestamps.start,
      };
    }
  }

  // fetch app icon from Discord public RPC when large_image is missing
  useE(() => {
    if (!ly) return;
    const a = (ly.activities || []).find((x) => x.type !== 4 && x.type !== 2 && x.name !== 'Spotify');
    if (!a || !a.application_id) return;
    const hasImg = a.assets && a.assets.large_image;
    if (hasImg || appIcon[a.application_id]) return;
    fetch(`https://discord.com/api/v10/applications/${a.application_id}/rpc`)
      .then((r) => r.json())
      .then((j) => {
        if (j.icon) setAppIcon((prev) => ({
          ...prev,
          [a.application_id]: `https://cdn.discordapp.com/app-icons/${a.application_id}/${j.icon}.png?size=256`,
        }));
      })
      .catch(() => {});
  }, [ly]);
  const custom = ly && (ly.activities || []).find((x) => x.type === 4);
  const state = ly ? ly.discord_status : null;
  const dotColor = state ? (STATUS_COLOR[state] || 'var(--status-offline)') : STATUS_COLOR[data.status.state];

  // ---- Rich activity view ----
  if (activity && activity.name) {
    const elapsed = activity.start ? fmtElapsed(now - activity.start) : null;
    return (
      <Card area="status" motion={motion} label="Status" className="act-card">
        {activity.big && <div className="act-bg" style={{ backgroundImage: `url(${activity.big})` }}></div>}
        <div className="act-scrim"></div>
        <div className="act-head">
          <span className="act-verb">{activity.verb}</span>
          <span className="dot pulse" style={{ background: dotColor }}></span>
        </div>
        <div className="act-body">
          <div className="act-thumb">
            {activity.big
              ? <img src={activity.big} alt="" />
              : <div className="act-thumb-fallback"><Icon.gamepad /></div>}
            {activity.small && <img className="act-thumb-sm" src={activity.small} alt="" />}
          </div>
          <div className="act-info">
            <div className="act-name">{activity.name}</div>
            {activity.line1 && <div className="act-line">{activity.line1}</div>}
            {activity.line2 && <div className="act-line dim">{activity.line2}</div>}
            {elapsed && <div className="act-time"><Icon.clock /> {elapsed} elapsed</div>}
          </div>
        </div>
      </Card>
    );
  }

  // ---- Plain presence view (no activity) ----
  const label = state ? STATUS_TEXT[state] : data.status.label;
  const sub = state
    ? (custom ? custom.state : (state === 'offline' ? 'Catch me on Discord' : data.status.sub))
    : data.status.sub;
  return (
    <Card area="status" motion={motion} label="Status">
      <div className="card-head">
        <span className="t-label">{ly ? 'Discord status' : 'Status'}</span>
        <span className="dot pulse" style={{ background: dotColor }}></span>
      </div>
      <div className="status-big">{label}</div>
      <div className="status-sub">{sub}</div>
    </Card>
  );
}

/* ---------------- Discord ---------------- */
const fmtCount = (n) => (typeof n !== 'number') ? n
  : n >= 1000 ? (n / 1000).toFixed(n >= 10000 ? 0 : 1).replace(/\.0$/, '') + 'k' : String(n);

function Discord({ data, motion }) {
  const d = data.discord;
  const ref = useR(null);
  const [seen, setSeen] = useS(false);
  const [live, setLive] = useS(null);

  // pull real server icon, counts & online member avatars (no bot token needed)
  useE(() => {
    const code = d.inviteCode;
    if (!code) return;
    let alive = true;
    (async () => {
      try {
        const inv = await fetch(`https://discord.com/api/v10/invites/${code}?with_counts=true`).then((r) => r.json());
        const g = inv.guild || {};
        const iconUrl = g.icon ? `https://cdn.discordapp.com/icons/${g.id}/${g.icon}.${g.icon.startsWith('a_') ? 'gif' : 'png'}?size=160` : null;
        const bannerUrl = g.splash
          ? `https://cdn.discordapp.com/splashes/${g.id}/${g.splash}.jpg?size=1024`
          : g.banner
            ? `https://cdn.discordapp.com/banners/${g.id}/${g.banner}.jpg?size=1024`
            : null;
        let avatars = [];
        try {
          const w = await fetch(`https://discord.com/api/guilds/${g.id}/widget.json`).then((r) => r.json());
          avatars = (w.members || []).filter((m) => m.avatar_url).slice(0, 6).map((m) => m.avatar_url);
        } catch (e) { /* widget disabled — fall back to gradient avatars */ }
        if (!alive) return;
        setLive({
          name: g.name || d.server,
          iconUrl,
          bannerUrl,
          members: inv.approximate_member_count,
          online: inv.approximate_presence_count,
          avatars,
        });
      } catch (e) { /* offline — keep static fallback */ }
    })();
    return () => { alive = false; };
  }, [d.inviteCode]);

  useE(() => {
    if (!ref.current) return;
    const io = new IntersectionObserver(([e]) => e.isIntersecting && setSeen(true), { threshold: 0.4 });
    io.observe(ref.current);
    return () => io.disconnect();
  }, []);

  const name = (live && live.name) || d.server;
  const membersStr = live ? fmtCount(live.members) : d.members;
  const onlineTarget = live ? live.online : d.online;
  const online = useCountUp(onlineTarget, seen && motion);
  const realAvatars = live && live.avatars && live.avatars.length ? live.avatars : null;
  const moreCount = live ? Math.max(0, live.online - (realAvatars ? realAvatars.length : 0)) : 9;

  return (
    <Card area="discord" motion={motion} label="Discord" className="dc">
      {live && (live.bannerUrl || live.iconUrl) && <div className={`dc-banner ${motion ? 'drift' : ''}`} style={{ backgroundImage: `url(${live.bannerUrl || live.iconUrl})` }}></div>}
      <div className="dc-scrim"></div>
      <div ref={ref} className="dc-top">
        <div className="dc-ico-wrap">
          <div className="dc-ico" style={live && live.iconUrl ? { background: 'none' } : undefined}>
            {live && live.iconUrl
              ? <img className="dc-ico-img" src={live.iconUrl} alt="" />
              : <img src="https://cdn.simpleicons.org/discord/ffffff" width="34" alt="" />}
          </div>
          <span className="dc-ico-ring"></span>
        </div>
        <div className="dc-id">
          <span className="dc-server">{name}</span>
          <span className="dc-handle">discord.gg/{d.inviteCode || 'invite'}</span>
          <div className="dc-stats">
            <span className="dc-on"><span className="dot pulse" style={{ background: 'var(--status-online)' }}></span><b>{online}</b> online</span>
            <span className="dc-sep">·</span>
            <span className="dc-mem"><b>{membersStr}</b> members</span>
          </div>
        </div>
        <a className="dc-join" href={d.invite} target="_blank" rel="noreferrer">Join</a>
      </div>
      <div className="dc-foot">
        <div className="dc-avis">
          {realAvatars
            ? realAvatars.map((u, i) => <div key={i} className="av" style={{ backgroundImage: `url(${u})` }} />)
            : d.avatars.map((g, i) => <div key={i} className="av" style={{ background: g }} />)}
          {moreCount > 0 && <div className="dc-more">+{fmtCount(moreCount)}</div>}
        </div>
        <span className="dc-foot-label">members hanging out</span>
      </div>
    </Card>
  );
}

/* ---------------- Currently building ---------------- */
function Building({ data, motion }) {
  const b = data.building;
  const ref = useTilt(motion);
  return (
    <a className="card lift b-building proj-card" data-screen-label="Building"
       href={b.link} target="_blank" rel="noopener" ref={ref} title={`Visit ${b.title}`}>
      <div className="card-head">
        <span className="t-label" style={{ color: 'var(--accent)' }}><Icon.sparkles style={{ width: 12, height: 12, verticalAlign: '-2px' }} /> Currently building</span>
        <Icon.arrowUpRight className="proj-go" />
      </div>
      <h3 className="proj-x" style={{ margin: '10px 0 0' }}><span className="t-title" style={{ fontSize: 19 }}>{b.title}</span></h3>
      <p style={{ margin: '6px 0 0', fontSize: 12.5, color: 'var(--fg-muted)', lineHeight: 1.45 }}>{b.desc}</p>
      <div className="tags">
        {b.tags.map((t, i) => <span key={t} className={`tag ${i === 0 ? 'hot' : ''}`}>{t}</span>)}
      </div>
      <span className="proj-link">{b.link.replace(/^https?:\/\//, '').replace(/\/$/, '')}</span>
    </a>
  );
}

/* ---------------- GitHub profile ---------------- */
function GitHub({ data, motion }) {
  const g = data.github;
  const ref = useR(null);
  const [seen, setSeen] = useS(false);
  const [live, setLive] = useS(null);
  useE(() => {
    if (!ref.current) return;
    const io = new IntersectionObserver(([e]) => e.isIntersecting && setSeen(true), { threshold: 0.3 });
    io.observe(ref.current);
    return () => io.disconnect();
  }, []);
  // pull REAL GitHub stats from the public API (no auth needed)
  useE(() => {
    let alive = true;
    (async () => {
      try {
        const u = await fetch(`https://api.github.com/users/${g.user}`).then((r) => r.json());
        if (!alive || !u || u.message) return;
        let totalStars = 0;
        try {
          const repos = await fetch(`https://api.github.com/users/${g.user}/repos?per_page=100&type=owner`).then((r) => r.json());
          if (Array.isArray(repos)) totalStars = repos.reduce((s, r) => s + (r.stargazers_count || 0), 0);
        } catch (e) {}
        if (!alive) return;
        setLive({
          repos: u.public_repos != null ? u.public_repos : g.repos,
          followers: u.followers != null ? u.followers : g.followers,
          stars: totalStars,
          since: u.created_at ? new Date(u.created_at).getFullYear() : null,
        });
      } catch (e) { /* offline — keep fallback numbers */ }
    })();
    return () => { alive = false; };
  }, [g.user]);
  const repos = useCountUp(live ? live.repos : g.repos, seen && motion, 900);
  const followers = useCountUp(live ? live.followers : g.followers, seen && motion, 1100);
  const stars = useCountUp(live ? live.stars : g.stars, seen && motion, 1300);
  // deterministic contribution levels (18 weeks × 7 days, square cells)
  const cells = useR(Array.from({ length: 18 * 7 }, (_, i) => {
    const r = (Math.sin(i * 12.9898) * 43758.5453) % 1;
    const x = Math.abs(r);
    return x > 0.78 ? 4 : x > 0.6 ? 3 : x > 0.42 ? 2 : x > 0.24 ? 1 : 0;
  })).current;
  return (
    <Card area="github" motion={motion} label="GitHub" className="gh-card">
      <div ref={ref} className="card-head">
        <div className="icotile"><Icon.github /></div>
        <div className="gh-head-r">{live && live.since ? <><Icon.flame className="flame" /> Since {live.since}</> : <><Icon.flame className="flame" /> {g.streak} day streak</>}</div>
      </div>
      <div className="gh-user"><b>@{g.user}</b><span>github.com/{g.user}</span></div>
      <div className="gh-stats">
        <div><div className="n">{repos}</div><div className="l">Repos</div></div>
        <div><div className="n">{followers}</div><div className="l">Followers</div></div>
        <div><div className="n">{stars}</div><div className="l">★ Stars</div></div>
      </div>
      <div className="gh-graph">
        {cells.map((lvl, i) => (
          <div key={i} className={`gh-cell ${lvl ? 'l' + lvl : ''}`}
               style={motion ? { animationDelay: (i * 4) + 'ms' } : undefined} />
        ))}
      </div>
    </Card>
  );
}

/* ---------------- Photo (shows latest posted photo, else drop slot) ---------------- */
function Photo({ photo, motion, area, post, scatterPosts = [] }) {
  const tiltRef = useTilt(motion);
  const ghostsRaw = post ? scatterPosts.filter(p => p.img !== post.img).slice(0, 2) : [];
  /* with only 1 extra photo, duplicate it so both left and right layers fill */
  const ghosts = ghostsRaw.length === 1 ? [ghostsRaw[0], ghostsRaw[0]] : ghostsRaw;
  const hasStack = motion && ghosts.length > 0;

  if (post) {
    const ghostB = ghosts[1] ?? ghosts[0];
    const ghostF = ghosts[0] ?? null;
    return (
      <a className={`card photo lift posted b-${area}${hasStack ? ' has-stack' : ''}`}
         data-screen-label="Photo" href="https://photos.mashowmagic.com" title={post.title}
         ref={tiltRef}>
        {ghostB && (
          <div className="ph-ghost ph-ghost-b">
            <div className="ph-inner"><img src={ghostB.img} alt="" draggable="false" /></div>
          </div>
        )}
        {ghostF && (
          <div className="ph-ghost ph-ghost-f">
            <div className="ph-inner"><img src={ghostF.img} alt="" draggable="false" /></div>
          </div>
        )}
        <div className="ph-main">
          <img src={post.img} alt={post.title || ''} className="ph-img" />
          <div className="ph-overlay">
            <span className="ph-title">{post.title || photo.caption}</span>
            {post.location ? <span className="ph-loc">{post.location}</span> : null}
          </div>
        </div>
      </a>
    );
  }
  return (
    <Card className="photo" area={area} motion={motion} label="Photo">
      <image-slot id={`photo-${photo.id}`} shape="rect" placeholder={`Drop a photo · ${photo.caption}`}
                  style={{ position: 'absolute', inset: 0, width: '100%', height: '100%' }}></image-slot>
      <span className="ph-cap">{photo.caption}</span>
    </Card>
  );
}

/* ---------------- Blog ---------------- */
function Blog({ data, motion }) {
  const byYear = {};
  data.blog.forEach((p) => {
    const year = p.date.split('·')[0].trim();
    if (!byYear[year]) byYear[year] = [];
    byYear[year].push(p);
  });
  const years = Object.keys(byYear).sort((a, b) => b - a);

  const fmtDate = (d) => {
    const raw = d.split('·')[1]?.trim() || '';
    const [mon, day] = raw.split(' ').filter(Boolean);
    if (!mon || !day) return d;
    return `${day} ${mon.charAt(0) + mon.slice(1).toLowerCase()}`;
  };

  return (
    <Card area="blog" motion={motion} label="Blog">
      <div className="card-head">
        <span className="t-label">From the blog</span>
        <Icon.rss style={{ width: 15, height: 15, color: 'var(--fg-subtle)' }} />
      </div>
      <div className="blog-list">
        {years.map((year) => (
          <div key={year} className="blog-year-group">
            <div className="blog-year">{year}</div>
            {byYear[year].map((p) => (
              <a key={p.title} className="blog-item" href={p.href || '#'}>
                <span className="blog-title">{p.title}</span>
                <span className="blog-date">{fmtDate(p.date)}</span>
              </a>
            ))}
          </div>
        ))}
      </div>
    </Card>
  );
}

/* ---------------- Ko-fi ---------------- */
function Kofi({ motion }) {
  return (
    <Card area="kofi" motion={motion} label="Ko-fi" style={{ background: 'linear-gradient(150deg, color-mix(in srgb, var(--kofi) 12%, var(--surface)), var(--surface))', justifyContent: 'space-between' }}>
      <div className="card-head">
        <div className="icotile" style={{ background: 'color-mix(in srgb, var(--kofi) 16%, transparent)', color: 'var(--kofi)' }}><Icon.coffee /></div>
      </div>
      <div className="kofi-body">
        <div>
          <div className="kofi-big">Buy me a coffee <span className="kofi-heart">☕</span></div>
          <div className="kofi-sub">Support the late-night builds</div>
        </div>
        <a className="btn btn-primary" style={{ background: 'var(--kofi)' }} href="https://ko-fi.com/mashowmagic" target="_blank" rel="noreferrer">Ko-fi</a>
      </div>
    </Card>
  );
}

Object.assign(window, { Card, NowPlaying, Weather, StatusCard, Discord, Building, GitHub, Photo, Blog, Kofi, useCountUp });
