/* ═══════════════════════════════════════════════════════════════════
   SeoLive.jsx — LIVE data engine for the SEO Tracker. v1.1.0
   Real-data SWAP for the v2 SEO surface: replaces the SeoData.jsx sample
   set with the live, account-scoped /seo/rank/* endpoints (tier-2,
   requireJWT('wpsb-app') — JWT/session only, NO API key in the browser).

   Endpoints consumed (read the real shapes — server.js DFS Step-2):
     GET  /seo/rank/latest             -> { results:[{target_id,keyword,target_domain,device,status,position,url,serp_features,checked_at,…}],
                                            targets_without_results:[{target_id,keyword,target_domain,last_checked_at}],
                                            targets_total, results_count, truncated, as_of,
                                            allowance:{plan,keyword_allowance,used,remaining} }
     GET  /seo/rank/results?target_id= -> { target_id, results:[{status,position,url,serp_features,checked_at,requested_at}] }
     POST /seo/rank/run-now            -> 202 { batch_id, enqueued_ops, targets }
     GET  /seo/rank/batch/:batchId     -> { batch_id, total, complete, counts }
     POST /seo/rank/targets            -> 201 { added } | 402 gated
     DELETE /seo/rank/targets/:id

   Honest-by-construction (Radical Transparency, locked rule #13 — no false
   zeros): fields the rank endpoints DO NOT return are emitted as null, never 0:
     vol (search volume) · diff (keyword difficulty) · ai/aiPos (GEO/LLM
     visibility) · competitors · suggestions · traffic. The presentational
     components render '—'/honest empty states for null, never a fabricated
     value. position === null = checked-but-not-ranked / not-yet-checked —
     rendered '—'/'pending', NEVER 0. See the PR's api-lane gap list.

   N+1 RESOLVED (v1.1.0): the overview now loads in ONE call via the bulk
   GET /seo/rank/latest (latest-per-target). The per-target /results fan-out
   is gone; full position history (trend/prev/best) is fetched LAZILY for a
   single target only when its keyword detail is opened (fetchHistory) — 1
   call on demand, never N. meta.truncated mirrors the endpoint's honest cap
   flag (a few targets' latest may lag on very large keyword sets).

   Load order: THIRD (after SeoCharts.jsx + SeoData.jsx, before SeoTracker).
   Registers: window.WPSB.SeoLive = { useSeoLiveData, normalizeSerp, relTime,
   buildRow, fetchTargetHistory }.
   ═══════════════════════════════════════════════════════════════════ */
(function () {
const { useState: useStateL, useEffect: useEffectL, useCallback: useCallbackL, useRef: useRefL } = React;

const TREND_POINTS = 7;    // chart points (matches the design's 7-pt series)

function apiBase() { return (window.WPSBD && window.WPSBD.apiBase) || 'https://api.wpsitebeam.io'; }
function authToken() {
  return (window.WPSB && window.WPSB.getToken && window.WPSB.getToken())
      || (window.WPSBD && window.WPSBD.getToken && window.WPSBD.getToken())
      || (function () { try { return localStorage.getItem('wpsb-auth-token'); } catch (e) { return null; } })();
}

async function apiGet(path, signal) {
  const token = authToken();
  if (!token) { const e = new Error('Not signed in'); e.code = 'NO_TOKEN'; throw e; }
  const r = await fetch(apiBase() + path, { headers: { Authorization: 'Bearer ' + token }, signal });
  let body = null; try { body = await r.json(); } catch (e) { /* non-JSON */ }
  if (!r.ok) { const e = new Error((body && body.error) || ('HTTP ' + r.status)); e.status = r.status; e.code = body && body.code; throw e; }
  return body || {};
}

/* SERP features arrive as DataForSEO jsonb — normalize to the chip keys the
   design knows (snippet / ai / local); drop unrecognized rather than guess. */
const SERP_ALIAS = {
  featured_snippet: 'snippet', snippet: 'snippet', answer_box: 'snippet', people_also_ask: 'snippet',
  ai_overview: 'ai', ai: 'ai', generative: 'ai',
  local_pack: 'local', local: 'local', map: 'local', maps: 'local',
};
function normalizeSerp(raw) {
  let keys = [];
  if (Array.isArray(raw)) keys = raw.map(x => (typeof x === 'string' ? x : (x && (x.type || x.name)) || '')).filter(Boolean);
  else if (raw && typeof raw === 'object') keys = Object.keys(raw).filter(k => raw[k]);
  const out = [];
  keys.forEach(k => { const a = SERP_ALIAS[String(k).toLowerCase()]; if (a && out.indexOf(a) === -1) out.push(a); });
  return out;
}

function relTime(iso) {
  if (!iso) return null;
  const t = Date.parse(iso); if (isNaN(t)) return null;
  const s = Math.max(0, Math.round((Date.now() - t) / 1000));
  if (s < 60) return 'just now';
  const m = Math.round(s / 60); if (m < 60) return m + 'm ago';
  const h = Math.round(m / 60); if (h < 48) return h + 'h ago';
  const d = Math.round(h / 24); if (d < 14) return d + 'd ago';
  return Math.round(d / 7) + 'w ago';
}

/* Map one target + its rank-result history into the row shape the v2
   presentational components already consume (unserved fields => null). */
function buildRow(target, results) {
  const done = (results || [])
    .filter(r => r && r.status === 'completed' && typeof r.position === 'number')
    .slice(); // newest-first (endpoint orders by requested_at desc)
  const latest = done[0] || null;
  const positions = done.map(r => r.position);
  const trendAsc = done.slice(0, TREND_POINTS).map(r => r.position).reverse(); // oldest -> newest
  return {
    kw: target.keyword,
    pos: latest ? latest.position : null,
    prev: done[1] ? done[1].position : null,
    best: positions.length ? Math.min.apply(null, positions) : null,
    trend: trendAsc,                                  // [] when no completed checks yet
    url: latest && latest.url ? latest.url : null,
    serp: latest ? normalizeSerp(latest.serp_features) : [],
    checked: relTime(latest ? latest.checked_at : target.last_checked_at) || 'pending',
    // ── unserved by /seo/rank/* — honest null, never 0 (api-lane gaps) ──
    vol: null, diff: null, ai: null, aiPos: null,
    // wiring metadata (underscored; ignored by presentational components)
    _targetId: target.id,
    _domain: target.target_domain,
    _device: target.device,
    _pending: done.length === 0,
    _lastCheckedAt: latest ? latest.checked_at : (target.last_checked_at || null),
  };
}

/* Map ONE /seo/rank/latest result entry (latest check per target) into the
   overview row shape. History fields (prev/best/trend) are NOT in the bulk
   endpoint → null/[] here; they hydrate lazily on detail open (fetchHistory).
   position === null ⇒ checked-but-not-ranked — honest '—', never 0. */
function buildRowFromLatest(r) {
  return {
    kw: r.keyword,
    pos: (r.position == null ? null : r.position),    // NULL = not on page / awaiting — never 0
    prev: null,                                        // history not served by /latest (lazy on open)
    best: null,
    trend: [],
    url: r.url || null,
    serp: normalizeSerp(r.serp_features),
    checked: relTime(r.checked_at) || 'pending',
    // ── unserved by /seo/rank/* — honest null, never 0 (api-lane gaps) ──
    vol: null, diff: null, ai: null, aiPos: null,
    // wiring metadata (underscored; ignored by presentational components)
    _targetId: r.target_id,
    _domain: r.target_domain,
    _device: r.device || null,
    _pending: false,                                   // a completed/errored check row exists
    _lastCheckedAt: r.checked_at || null,
    _status: r.status || null,
  };
}

/* Map a /seo/rank/latest targets_without_results entry (active target, no
   checked result yet) into a pending overview row. */
function buildPendingRow(t) {
  return {
    kw: t.keyword,
    pos: null, prev: null, best: null, trend: [],
    url: null, serp: [], checked: 'pending',
    vol: null, diff: null, ai: null, aiPos: null,
    _targetId: t.target_id,
    _domain: t.target_domain,
    _device: null,
    _pending: true,
    _lastCheckedAt: t.last_checked_at || null,
    _status: null,
  };
}

/* Lazy per-target history: fetch the full /results series for ONE target and
   return just the history fields the detail view needs (trend/prev/best).
   1 call on demand (detail open) — NOT the old N+1 fan-out. Fail-soft → null. */
async function fetchTargetHistory(targetId, signal) {
  if (!targetId) return null;
  try {
    const r = await apiGet('/seo/rank/results?target_id=' + encodeURIComponent(targetId), signal);
    const built = buildRow({ id: targetId, keyword: '', target_domain: '', device: null, last_checked_at: null }, r.results);
    return { trend: built.trend, prev: built.prev, best: built.best };
  } catch (e) { return null; }
}

/* React hook: loads live rank data; returns a discriminated status the
   surface renders from (loading | error | locked | empty | ready). */
function useSeoLiveData() {
  const [state, setState] = useStateL({ status: 'loading' });
  const abortRef = useRefL(null);

  const load = useCallbackL(async function () {
    if (abortRef.current) abortRef.current.abort();
    const ctrl = new AbortController();
    abortRef.current = ctrl;
    setState(prev => (prev.status === 'ready' ? Object.assign({}, prev, { refreshing: true }) : { status: 'loading' }));
    try {
      // ── ONE bulk call: latest-per-target (N+1 fan-out removed, v1.1.0) ──
      const j = await apiGet('/seo/rank/latest', ctrl.signal);
      const allowance = j.allowance || { plan: 'free_scan', keyword_allowance: 0, used: 0, remaining: 0 };

      // Plan gate: allowance 0 => rank tracking not on this plan (under-tier/free).
      if ((allowance.keyword_allowance || 0) === 0) {
        setState({ status: 'locked', allowance: allowance });
        return;
      }
      if (!(j.targets_total > 0)) {
        setState({ status: 'empty', allowance: allowance, meta: { plan: allowance.plan } });
        return;
      }

      const results = Array.isArray(j.results) ? j.results : [];
      const without = Array.isArray(j.targets_without_results) ? j.targets_without_results : [];
      const rows = results.map(buildRowFromLatest).concat(without.map(buildPendingRow));

      // stats from real positions only (no false zeros — null avg if none ranked yet)
      const ranked = rows.map(r => r.pos).filter(p => typeof p === 'number');
      const stats = {
        tracked: allowance.used != null ? allowance.used : j.targets_total,
        avgPos: ranked.length ? Math.round(ranked.reduce((a, b) => a + b, 0) / ranked.length) : null,
        avgPosTrend: null, top10: ranked.filter(p => p <= 10).length,
        traffic: null, trafficTrend: null,
      };
      const asOf = j.as_of || null;
      const domain = (rows[0] && rows[0]._domain) || null;

      setState({
        status: 'ready', keywords: rows, stats: stats, allowance: allowance,
        competitors: [], suggestions: [],            // api-gap: not served by /seo/rank/*
        meta: { domain: domain, checkedAt: asOf, checkedRel: relTime(asOf),
                plan: allowance.plan, daily: false, truncated: !!j.truncated,
                shown: results.length, total: j.targets_total },
      });
    } catch (e) {
      if (e.name === 'AbortError') return;
      setState({ status: 'error', error: e.message || 'Could not load rank data', code: e.code });
    }
  }, []);

  useEffectL(function () { load(); return function () { if (abortRef.current) abortRef.current.abort(); }; }, [load]);

  // Lazy per-target history for the detail view (1 call on open; not N+1).
  const fetchHistory = useCallbackL(function (targetId) { return fetchTargetHistory(targetId); }, []);

  return Object.assign({}, state, { reload: load, fetchHistory: fetchHistory });
}

window.WPSB = window.WPSB || {};
window.WPSB.SeoLive = { useSeoLiveData: useSeoLiveData, normalizeSerp: normalizeSerp, relTime: relTime, buildRow: buildRow, fetchTargetHistory: fetchTargetHistory };
console.log('[WPSB] SeoLive v1.1.0 loaded (bulk /seo/rank/latest + lazy detail history)');
})();
