// Tempo — Actualités (carte de l'Accueil et analyse fondamentale).
// Source unique : le worker Tempo (route /fond/news). Il lit côté serveur des flux
// RSS marchés et macro (ForexLive, FXStreet, MarketWatch, Investing, BFM Business,
// Fed, BCE), écarte le bruit hors marché (finances personnelles, résultats
// d'entreprises sans portée macro) et répond avec les en-têtes CORS. Les relais
// CORS publics utilisés auparavant sont hors service ou payants : ils sont retirés.
// En cas d'échec, les derniers titres reçus (cache local) restent affichés avec un
// bandeau « Réessayer » (charte §7).
// Heures et jours : TOUJOURS dans le fuseau du profil (window.__appTz, charte §4),
// calculés au rendu, jamais stockés dans le cache.

// Le briefing ne montre QUE les titres du JOUR (filtre plus bas), au fil de la journée.
// v8 : nouvelle source (worker) ; les caches précédents sont purgés.
const NEWS_CACHE_KEY = 'tempo:news:v8';
const NEWS_LEGACY_CACHE_KEYS = ['tempo:news:v6', 'tempo:news:v7'];
const NEWS_TTL_MS = 12 * 60 * 1000;          // 12 min — cohérent avec le refresh auto
const NEWS_REFRESH_MS = 12 * 60 * 1000;      // re-fetch périodique pendant la journée
const NEWS_MAX_ITEMS = 6;                    // 6 titres max, pas un fil fouillis
const NEWS_FETCH_TIMEOUT_MS = 10000;         // le worker répond depuis son cache en < 1 s
const NEWS_WORKER_PATH = '/fond/news';
const NEWS_WORKER_FALLBACK = 'https://tempo-econ-proxy.guillaume-briere92.workers.dev';
// Nom d'éditeur affiché pour les libellés internes du worker.
const NEWS_SOURCE_LABELS = { 'BFM Éco': 'BFM Business', 'Investing FR': 'Investing' };

try { NEWS_LEGACY_CACHE_KEYS.forEach(k => localStorage.removeItem(k)); } catch (e) { /* stockage indisponible */ }

// Base du worker : la même que le Calendrier éco. (econ.jsx, chargé avant).
function newsWorkerBase() {
  try { if (typeof getEconProxyURL === 'function') return getEconProxyURL(); } catch (e) { /* repli */ }
  return NEWS_WORKER_FALLBACK;
}

function decodeCdata(s) {
  return (s || '').replace(/<!\[CDATA\[/g, '').replace(/\]\]>/g, '').trim();
}
function stripTags(s) {
  return (s || '').replace(/<[^>]*>/g, '').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ').trim();
}
// Sécurité : les liens viennent de flux tiers, un lien ne doit jamais devenir un
// href javascript:/data:. Seuls http(s) sont acceptés, sinon ''.
function newsSafeLink(u) {
  return (typeof u === 'string' && /^https?:\/\//i.test(u.trim())) ? u.trim() : '';
}
function decodeEntities(s) {
  if (!s) return '';
  const map = { '&amp;': '&', '&lt;': '<', '&gt;': '>', '&quot;': '"', '&#39;': "'", '&apos;': "'", '&rsquo;': '’', '&lsquo;': '‘', '&laquo;': '«', '&raquo;': '»', '&hellip;': '…', '&nbsp;': ' ' };
  // 1) Named entities from map
  let out = s.replace(/&[a-z]+;/gi, m => map[m.toLowerCase()] || m);
  // 2) Hex numeric entities: &#xE9; → 'é'
  out = out.replace(/&#x([0-9a-f]+);/gi, (_, hex) => {
    try { return String.fromCodePoint(parseInt(hex, 16)); } catch (e) { return _; }
  });
  // 3) Decimal numeric entities: &#233; → 'é'
  out = out.replace(/&#(\d+);/g, (_, dec) => {
    try { return String.fromCodePoint(parseInt(dec, 10)); } catch (e) { return _; }
  });
  return out;
}

// Titres du worker : { source, headline, link, ts, w } (ts en ms ou null), ou null
// si le worker n'a pas répondu. Liste complète, dans l'ordre du worker : la carte
// de l'Accueil n'en garde que les titres du jour (mergeNewsResults), l'analyse
// fondamentale la lit en entier (feFetchNews, fondamental-engine.jsx). Aucune
// heure formatée ici : elle dépend du fuseau du profil (voir newsWithTime). Le
// délai couvre aussi la lecture du corps.
async function fetchNewsItems() {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), NEWS_FETCH_TIMEOUT_MS);
  try {
    const r = await fetch(newsWorkerBase() + NEWS_WORKER_PATH, { mode: 'cors', signal: controller.signal });
    if (!r.ok) throw new Error('HTTP ' + r.status);
    const j = await r.json();
    const raw = (j && Array.isArray(j.items)) ? j.items : [];
    const now = Date.now();
    const out = [];
    for (const it of raw) {
      if (!it) continue;
      const headline = decodeEntities(stripTags(decodeCdata(String(it.t || '')))).slice(0, 220);
      if (!headline) continue;
      // ts en secondes ou en millisecondes selon le flux : normalisé en ms, borné
      // au présent (pas de date future aberrante).
      const n = Number(it.ts);
      const ts = Number.isFinite(n) && n > 0 ? Math.min(n > 1e12 ? n : n * 1000, now) : null;
      const src = String(it.src || '').trim();
      // w : pertinence marché donnée par le worker (0 = sans terme macro).
      const w = Number.isFinite(Number(it.w)) ? Number(it.w) : null;
      out.push({ source: NEWS_SOURCE_LABELS[src] || src, headline, link: newsSafeLink(it.link), ts, w });
    }
    if (!out.length) throw new Error('aucun titre');
    return out;
  } catch (e) {
    console.warn('[Tempo] actualités : worker indisponible (' + ((e && e.message) || e) + ')');
    return null;
  } finally {
    clearTimeout(timer);
  }
}

// Aplatit les résultats (un tableau par appel), dédoublonne (titre normalisé), trie par récence,
// limite à NEWS_MAX_ITEMS. Les items sans date passent après les datés.
function mergeNewsResults(results) {
  const flat = [];
  results.forEach(r => { if (Array.isArray(r)) r.forEach(it => flat.push(it)); });
  const seen = new Set();
  const deduped = [];
  for (const it of flat) {
    const key = (it.headline || '').toLowerCase().replace(/[^a-z0-9àâäéèêëïîôöùûüç]+/gi, ' ').trim();
    if (!key || seen.has(key)) continue;
    seen.add(key);
    deduped.push(it);
  }
  deduped.sort((a, b) => {
    if (a.ts == null && b.ts == null) return 0;
    if (a.ts == null) return 1;
    if (b.ts == null) return -1;
    return b.ts - a.ts;
  });
  // Ne garder que les titres du JOUR (jour vécu dans le fuseau du profil) : au fil
  // de la journée, pas un backlog des jours passés. Repli sur les plus récents si
  // rien n'est encore tombé aujourd'hui (très tôt le matin), pour ne jamais
  // afficher un briefing vide.
  const todayKey = localDayInTz(new Date());
  const todays = deduped.filter(it => it.ts != null && localDayInTz(it.ts) === todayKey);
  // Priorité aux titres qui parlent de marchés (w > 0 selon le worker) : les
  // brèves d'entreprise sans portée macro ne passent que s'il manque des titres.
  const relevant = todays.filter(it => it.w !== 0);
  const chosen = relevant.length >= 3 ? relevant : (todays.length ? todays : deduped);
  return chosen.slice(0, NEWS_MAX_ITEMS);
}

// Titre du briefing selon l'heure et le jour vécus dans le fuseau du profil
// (window.__appTz, charte §4), jamais ceux du navigateur. `at` : instant (Date
// ou ms), maintenant par défaut. Un nombre de 0 à 23 (ancienne signature :
// une heure passée par l'appelant) est ignoré : l'heure est relue ici.
function briefingTitle(at) {
  const d = (at instanceof Date && !isNaN(at.getTime())) ? at
    : (typeof at === 'number' && Number.isFinite(at) && at > 23) ? new Date(at)
    : new Date();
  const day = localDayInTz(d);                             // 'YYYY-MM-DD', fuseau du profil
  const wd = new Date(day + 'T12:00:00Z').getUTCDay();     // 0 = dimanche, sans projection
  if (wd === 0 || wd === 6) return 'Revue du week-end';
  const h = parseInt(tzTimeIn(d).slice(0, 2), 10);         // heure du profil (0-23)
  if (h < 5)  return 'Marchés de la nuit';
  if (h < 12) return 'Point du matin';
  if (h < 18) return 'Point marché';
  return 'Clôture et après-séance';
}

// Un titre relu du cache ou prêt à y être écrit : uniquement les champs connus,
// lien assaini, ts numérique ou null. Tout autre champ (ex. une ancienne heure
// figée) est ignoré.
function newsCleanItem(it) {
  if (!it || typeof it.headline !== 'string' || !it.headline) return null;
  return {
    source: typeof it.source === 'string' ? it.source : '',
    headline: it.headline,
    link: newsSafeLink(it.link),
    ts: Number.isFinite(it.ts) ? it.ts : null,
    w: Number.isFinite(it.w) ? it.w : null,
  };
}

// Derniers titres gardés sur l'appareil : { ts (date d'écriture), items } ou
// null. Lu aussi par l'analyse fondamentale quand sa lecture en direct échoue.
function readNewsCache() {
  try {
    const c = JSON.parse(localStorage.getItem(NEWS_CACHE_KEY) || 'null');
    if (!c || !Array.isArray(c.items)) return null;
    return { ts: c.ts, items: c.items.map(newsCleanItem).filter(Boolean) };
  } catch (e) { return null; }
}
function writeNewsCache(items) {
  try {
    localStorage.setItem(NEWS_CACHE_KEY, JSON.stringify({ ts: Date.now(), items: items.map(newsCleanItem).filter(Boolean) }));
  } catch (e) { /* stockage indisponible : le briefing marche sans cache */ }
}

// Heure affichée d'un titre, calculée au rendu dans le fuseau du profil
// (tzTimeIn, charte §4) : « 14:30 » pour un titre du jour, « 26 sept. · 21:40 »
// pour un titre plus ancien (repli quand rien n'est encore tombé aujourd'hui).
function newsWithTime(items) {
  const today = localDayInTz(new Date());
  return (items || []).filter(Boolean).map(it => {
    if (it.ts == null) return { ...it, time: '' };
    const time = localDayInTz(it.ts) === today ? tzTimeIn(it.ts) : fmtDateTime(new Date(it.ts));
    return { ...it, time };
  });
}

// Nombre de sources distinctes parmi les titres (et non le nombre de titres).
function newsSourceCount(items) {
  return new Set((items || []).map(n => n && n.source).filter(Boolean)).size;
}

// Une seule vague de requêtes à la fois : les appels simultanés (carte de
// l'Accueil remontée au premier rendu, « Réessayer ») partagent la même
// promesse au lieu de relancer chacun une requête.
let newsWave = null;
function fetchNewsWave() {
  if (newsWave) return newsWave;
  newsWave = (async () => {
    const items = await fetchNewsItems();
    const fresh = items ? mergeNewsResults([items]) : [];
    if (fresh.length) {
      writeNewsCache(fresh);
      return { items: fresh.map(newsCleanItem).filter(Boolean), status: 'ready' };
    }
    const c = readNewsCache();
    const hasCache = !!(c && c.items.length);
    console.warn('[Tempo] actualités : aucun titre reçu' + (hasCache ? ', derniers titres conservés' : ''));
    return hasCache ? { items: c.items, status: 'stale' } : { items: [], status: 'error' };
  })().finally(() => { newsWave = null; });
  return newsWave;
}

// { items, status } — 'ready' : cache frais ou titres reçus ; 'stale' : le worker
// n'a pas répondu, derniers titres conservés ; 'error' : rien à montrer.
// force=true → ignore le cache frais (utilisé par le refresh périodique).
async function fetchNewsState(force) {
  const c = readNewsCache();
  if (!force && c && Date.now() - c.ts < NEWS_TTL_MS && c.items.length) {
    return { items: c.items, status: 'ready' };
  }
  return fetchNewsWave();
}

// Briefing en direct : { items, status: 'loading'|'ready'|'stale'|'error', busy, retry }.
// Un échec ne vide jamais la liste déjà affichée (charte §7) ; `busy` = requête
// en cours (anti double envoi du bouton « Réessayer »).
function useNewsFeed() {
  const [state, setState] = React.useState(() => {
    const c = readNewsCache();
    return (c && c.items.length)
      ? { items: c.items, status: 'ready', busy: false }
      : { items: [], status: 'loading', busy: false };
  });
  const runRef = React.useRef(null);

  React.useEffect(() => {
    let live = true;
    let inflight = false;
    const run = async (force) => {
      if (inflight) return;
      inflight = true;
      setState(prev => prev.items.length ? { ...prev, busy: true } : { items: [], status: 'loading', busy: true });
      let r;
      try { r = await fetchNewsState(force); }
      catch (e) {
        console.warn('[Tempo] actualités : chargement impossible', e);
        r = { items: [], status: 'error' };
      }
      inflight = false;
      if (!live) return;
      setState(prev => (r.items.length || !prev.items.length)
        ? { items: r.items, status: r.status, busy: false }
        : { items: prev.items, status: 'stale', busy: false });
    };
    runRef.current = () => run(true);
    run(false);
    // Refresh auto pendant la journée (bypass du cache frais à chaque tick).
    const id = setInterval(() => run(true), NEWS_REFRESH_MS);
    return () => { live = false; clearInterval(id); runRef.current = null; };
  }, []);

  const retry = React.useCallback(() => { if (runRef.current) runRef.current(); }, []);
  // Heures recalculées si le fuseau du profil change ou si le jour change.
  const tz = window.__appTz;
  const today = localDayInTz(new Date());
  const items = React.useMemo(() => newsWithTime(state.items), [state.items, tz, today]);
  return { items, status: state.status, busy: state.busy, retry };
}

// Carte « briefing » de l'Accueil, identique sur ordinateur et sur mobile
// (`compact` = mobile) : titre selon l'heure du profil, nombre de sources,
// titres ; squelette pendant le premier chargement seulement ; en cas d'échec,
// « — » + une phrase + « Réessayer », ou un bandeau si des titres sont déjà là.
// À placer dans une `.card` (le cadre et son padding restent à l'appelant).
function NewsBriefing({ compact = false }) {
  const { items, status, busy, retry } = useNewsFeed();
  // Le titre suit l'heure du profil, même si le parent ne se re-rend pas.
  const [, setTick] = React.useState(0);
  React.useEffect(() => {
    const id = setInterval(() => setTick(n => n + 1), 60 * 1000);
    return () => clearInterval(id);
  }, []);

  const S = compact
    ? { headGap: 12, count: 11, row: '9px 0', src: 9.5, srcGap: 3, text: 12.5, skel: 3 }
    : { headGap: 14, count: 11.5, row: '10px 0', src: 10, srcGap: 4, text: 13, skel: 4 };
  const shown = items.slice(0, NEWS_MAX_ITEMS);
  const sources = newsSourceCount(shown);
  const retryLabel = busy ? 'Chargement…' : 'Réessayer';
  // Hors ligne seulement, on parle de la connexion ; sinon c'est la source qui
  // est en cause, pas l'utilisateur.
  const offline = typeof navigator !== 'undefined' && navigator.onLine === false;

  let body;
  if (shown.length) {
    body = shown.map((n, i) => {
      // Seuls les liens http(s) sont cliquables : sans lien sûr, la ligne est
      // rendue inerte (jamais de href vide ou « # »).
      const link = newsSafeLink(n.link);
      const rowStyle = {
        display: 'block', padding: S.row, borderTop: i ? '1px solid var(--line)' : 'none',
        cursor: link ? 'pointer' : 'default', textDecoration: 'none', color: 'inherit',
      };
      const content = (
        <>
          <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 8, marginBottom: S.srcGap }}>
            <span style={{ fontSize: S.src, fontWeight: 600, letterSpacing: '.08em', textTransform: 'uppercase', color: 'var(--blue-600)', minWidth: 0 }}>{n.source || ''}</span>
            {n.time && <span className="mono" style={{ fontSize: S.src, color: 'var(--fg-3)', whiteSpace: 'nowrap', flexShrink: 0 }}>{n.time}</span>}
          </div>
          <div style={{ fontSize: S.text, lineHeight: 1.4, color: 'var(--fg)', letterSpacing: '-.005em' }}>{n.headline}</div>
        </>
      );
      return link
        ? <a key={i} href={link} target="_blank" rel="noopener noreferrer" className="tap" style={rowStyle}>{content}</a>
        : <div key={i} style={rowStyle}>{content}</div>;
    });
  } else if (status === 'loading' || busy) {
    body = Array.from({ length: S.skel }, (_, i) => (
      <div key={i} aria-hidden="true" style={{ padding: S.row, borderTop: i ? '1px solid var(--line)' : 'none' }}>
        <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 6 }}>
          <span style={{ display: 'inline-block', width: 60, height: 8, background: 'var(--bg-elev)', borderRadius: 4 }}></span>
          <span style={{ display: 'inline-block', width: 32, height: 8, background: 'var(--bg-elev)', borderRadius: 4 }}></span>
        </div>
        <span style={{ display: 'block', width: '92%', height: S.text - 1, background: 'var(--bg-elev)', borderRadius: 4 }}></span>
      </div>
    ));
  } else {
    body = (
      <div role="status" style={{ padding: S.row }}>
        <div style={{ fontSize: S.text, lineHeight: 1.4, color: 'var(--fg-2)', margin: '0 0 12px' }}>{offline ? 'Impossible de charger les actualités. Vérifie ta connexion.' : 'Actualités momentanément indisponibles.'}</div>
        <button type="button" onClick={retry} disabled={busy} className="btn tap" style={{ fontSize: 12.5, color: 'var(--fg)' }}>{retryLabel}</button>
      </div>
    );
  }

  return (
    <>
      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 10, marginBottom: S.headGap }}>
        <CardTitle style={{ fontSize: 14 }}>{briefingTitle()}</CardTitle>
        {sources > 0 && <span style={{ fontSize: S.count, color: 'var(--fg-3)', whiteSpace: 'nowrap' }}>{sources} source{sources > 1 ? 's' : ''}</span>}
      </div>
      {status === 'stale' && shown.length > 0 && (
        <div role="alert" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8, padding: '8px 12px', marginBottom: 6, borderRadius: 8, background: 'var(--red-soft)' }}>
          <span style={{ fontSize: 12, fontWeight: 500, lineHeight: 1.4, color: 'var(--red-text)' }}>{offline ? 'Impossible de mettre à jour les actualités. Vérifie ta connexion.' : 'Actualités momentanément indisponibles : derniers titres reçus.'}</span>
          <button type="button" onClick={retry} disabled={busy} className="btn tap" style={{ fontSize: 12, padding: '4px 10px', color: 'var(--fg)' }}>{retryLabel}</button>
        </div>
      )}
      {body}
    </>
  );
}

Object.assign(window, {
  NEWS_CACHE_KEY, NEWS_TTL_MS, NEWS_REFRESH_MS, NEWS_MAX_ITEMS,
  fetchNewsItems, mergeNewsResults, fetchNewsState, readNewsCache,
  useNewsFeed, NewsBriefing, briefingTitle, newsSafeLink,
  newsWithTime, newsSourceCount,
});
