<script>
  import { onMount, untrack } from 'svelte';
  import { goto, afterNavigate } from '$app/navigation';
  import { page } from '$app/stores';
  import { getJSON, postJSON } from '$lib/api.js';
  import * as P from '$lib/prefs.js';
  import Header from '$lib/components/Header.svelte';
  import BottomNav from '$lib/components/BottomNav.svelte';
  import MoodNav from '$lib/components/MoodNav.svelte';
  import LanePicker from '$lib/components/LanePicker.svelte';
  import ArticleCard from '$lib/components/ArticleCard.svelte';
  import SignIn from '$lib/components/SignIn.svelte';
  import SavedFlyout from '$lib/components/SavedFlyout.svelte';
  import { auth, refresh as refreshAuth } from '$lib/auth.svelte.js';
  import { prefs, initPrefs, active as prefsActive, applyPrefAction, persistPrefs, syncPrefsOnLogin } from '$lib/prefs.svelte.js';
  import { initHistory, deviceIds, record, loadServerHistory } from '$lib/history.svelte.js';
  import { trackVisit, track } from '$lib/analytics.js';

  let moods = $state([]);
  let topics = $state([]);
  let families = $state([]);
  let lanePool = $state(null); // /api/lanes: { pinned, default, groups }
  let showLanes = $state(false);
  // The URL is the single source of truth for the current view, so the in-page
  // Back button and the browser Back button share ONE history. The view is
  // derived from query params: /?view=latest, /?tag=clean-energy, /?source=7,
  // /?view=<mood|topic>, or bare / for Highlights.
  function parseView(url) {
    const p = url.searchParams;
    if (p.get('source')) return 'source:' + p.get('source');
    if (p.get('tag')) return 'tag:' + p.get('tag');
    return p.get('view') || 'today';
  }
  function urlForView(key) {
    if (key === 'today') return '/';
    if (key.startsWith('source:')) return '/?source=' + encodeURIComponent(key.slice(7));
    if (key.startsWith('tag:')) return '/?tag=' + encodeURIComponent(key.slice(4));
    return '/?view=' + encodeURIComponent(key);
  }
  let selected = $derived(parseView($page.url));
  let sourceNames = $state({}); // source id -> name, for an instant header label
  let brief = $state(null);
  let heroIdx = $state(0);
  let feed = $state([]);
  let feedDone = $state(false);   // no more pages for the current feed view
  let loadingMore = $state(false);
  let showSignIn = $state(false);
  let showSaved = $state(false); // Saved flyout
  let loading = $state(true);
  let error = $state('');
  let notice = $state('');

  // Device-local browsing memory (separate from history): seen = "displayed"
  // (so Replace doesn't recycle), dismissed = swapped/avoided this session.
  const SEEN_KEY = 'goodnews:seen';
  const DISMISSED_KEY = 'goodnews:dismissed';
  const BRIEF_VIEW_KEY = 'goodnews:brief_view';
  let seenIds = new Set();
  let dismissed = $state(new Set());

  function persistSession() {
    P.saveJSON(SEEN_KEY, [...seenIds]);
    P.saveJSON(DISMISSED_KEY, [...dismissed]);
  }
  function markDisplayed(items) {
    let changed = false;
    for (const a of items || []) {
      if (a && !seenIds.has(a.id)) { seenIds.add(a.id); changed = true; }
    }
    if (changed) P.saveJSON(SEEN_KEY, [...seenIds]);
  }

  function openAccount() {
    if (auth.user) goto('/account');
    else showSignIn = true;
  }

  // React to sign-in only (untrack the body so browsing doesn't retrigger it).
  $effect(() => {
    const u = auth.user;
    if (u && typeof window !== 'undefined') untrack(() => onLogin(u));
  });

  async function onLogin(u) {
    const key = 'goodnews:imported:' + u.id;
    if (!localStorage.getItem(key)) {
      try {
        await postJSON('/api/import', { seen: deviceIds(), saved: [] });
        localStorage.setItem(key, '1');
      } catch { /* best-effort */ }
    }
    loadServerHistory();
    await syncPrefsOnLogin(); // adopt account prefs or seed from device
    loadView(selected, true); // reflect any adopted boundaries in the current view
  }

  const cap = (s) => (s ? s[0].toUpperCase() + s.slice(1) : s);
  const humanize = (s) => (s || '').replace(/-/g, ' ');

  // Show the brief's date in the VIEWER's local timezone (from its UTC freshness
  // stamp), so everyone sees their own correct date — never "tomorrow."
  function localDateLabel(b) {
    const ts = b?.generated_at;
    if (ts) {
      const d = new Date(ts.replace(' ', 'T') + 'Z');
      if (!isNaN(d)) return d.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' });
    }
    return b?.brief_date ?? '';
  }
  let filtersOn = $derived(prefsActive());
  let currentMood = $derived(moods.find((m) => m.key === selected));
  let currentTopic = $derived(topics.find((t) => t.key === selected));
  let currentTag = $derived(selected.startsWith('tag:') ? selected.slice(4) : null);
  let tagFamily = $derived(
    currentTag ? families.find((f) => f.tags.some((t) => t.key === currentTag)) : null
  );
  let viewLabel = $derived(
    selected === 'today' ? 'Highlights from Today'
      : selected.startsWith('source:') ? (sourceNames[selected.slice(7)] ?? feed[0]?.source ?? 'Source')
      : selected === 'latest' ? 'Latest'
        : currentTag ? humanize(currentTag)
          : (currentMood?.label ?? cap(currentTopic?.key) ?? '')
  );
  let viewSubtitle = $derived(
    selected === 'today' ? localDateLabel(brief)
      : selected.startsWith('source:') ? 'Latest from this source'
      : selected === 'latest' ? 'Freshest calm reads — newest first'
        : currentTag ? (tagFamily?.description ?? '')
          : (currentMood?.description ?? currentTopic?.description ?? '')
  );
  let activeTab = $derived(
    selected === 'today' ? 'today' : selected === 'latest' ? 'latest' : 'browse'
  );

  // Customizable nav rail: the pinned lanes (Highlights + Latest) are always
  // first, then the reader's chosen lanes (or the default set if they've never
  // customized). Resolve each key to its {label, description} from the pool.
  let laneMap = $derived(
    new Map(
      lanePool
        ? [
            ...lanePool.pinned.map((p) => [p.key, p]),
            ...lanePool.groups.flatMap((g) => g.lanes.map((l) => [l.key, l])),
          ]
        : []
    )
  );
  let pinnedLaneKeys = $derived(
    prefs.data.lanes?.length ? prefs.data.lanes : (lanePool?.default ?? [])
  );
  let navLanes = $derived(
    lanePool ? [...lanePool.pinned, ...pinnedLaneKeys.map((k) => laneMap.get(k)).filter(Boolean)] : []
  );

  function saveLanes(keys) {
    prefs.data.lanes = keys;
    persistPrefs();
    // If the reader unpinned the lane they're viewing, fall back to Highlights.
    // (The pinned Highlights/Latest lanes are never in `keys`, so don't bounce.)
    if (selected !== 'today' && selected !== 'latest' && !keys.includes(selected)) navigate('today');
  }

  // Hero is the only image slot; if its image won't load, promote the next one.
  let heroArticle = $derived(brief?.items?.[heroIdx] ?? null);
  let restArticles = $derived((brief?.items ?? []).filter((_, i) => i !== heroIdx));
  function heroImageFailed() {
    const items = brief?.items ?? [];
    for (let i = heroIdx + 1; i < items.length; i++) {
      if (items[i]?.image_url) { heroIdx = i; return; }
    }
  }

  function viewFilter(key = selected) {
    if (key === 'today') return {};
    const m = moods.find((x) => x.key === key);
    if (m) return m.filter ?? {};
    return { include_topics: [key] };
  }
  function mergedParam() {
    return P.param(P.merge(prefs.data, viewFilter()));
  }

  async function loadToday(fresh) {
    const q = P.param(prefs.data);
    const ex = Array.from(dismissed).join(',');
    const fetched = await getJSON(`/api/brief?limit=7${q ? '&' + q : ''}${ex ? '&exclude=' + ex : ''}`);
    const view = P.loadJSON(BRIEF_VIEW_KEY, null);
    const sameServerBrief =
      view && view.generated_at && fetched.generated_at && view.generated_at === fetched.generated_at;
    if (!fresh && sameServerBrief && Array.isArray(view.items) && view.items.length) {
      const freshById = new Map(fetched.items.map((a) => [a.id, a]));
      const items = view.items.map((it) => freshById.get(it.id) ?? it);
      brief = { ...fetched, items };
      P.saveJSON(BRIEF_VIEW_KEY, { generated_at: fetched.generated_at, items });
    } else {
      brief = fetched;
      P.saveJSON(BRIEF_VIEW_KEY, { generated_at: fetched.generated_at, items: fetched.items });
    }
    heroIdx = 0;
    markDisplayed(brief.items);
  }

  // Build a /api/feed URL for a lane view at a given page offset. 'latest' is
  // the chronological firehose (sort=latest); 'tag:x' browses a grouping tag;
  // anything else resolves through its mood/topic filter.
  const PAGE = 24;
  function feedUrl(key, offset) {
    const ex = Array.from(dismissed).join(',');
    const exq = ex ? `&exclude=${ex}` : '';
    if (key === 'latest') {
      const q = P.param(prefs.data);
      return `/api/feed?sort=latest&limit=${PAGE}&offset=${offset}${q ? '&' + q : ''}${exq}`;
    }
    if (key.startsWith('tag:')) {
      const q = P.param(prefs.data);
      return `/api/feed?limit=${PAGE}&offset=${offset}&tag=${encodeURIComponent(key.slice(4))}${q ? '&' + q : ''}${exq}`;
    }
    if (key.startsWith('source:')) {
      // A publication feed: this source's articles, newest first, still
      // accepted/non-duplicate/boundary-filtered.
      const q = P.param(prefs.data);
      return `/api/feed?source_id=${encodeURIComponent(key.slice(7))}&sort=latest&limit=${PAGE}&offset=${offset}${q ? '&' + q : ''}${exq}`;
    }
    const q = P.param(P.merge(prefs.data, viewFilter(key)));
    return `/api/feed?limit=${PAGE}&offset=${offset}${q ? '&' + q : ''}${exq}`;
  }

  // All navigation goes through the URL (goto), so browser Back/Forward and the
  // in-page Back button traverse the same single history.
  function navigate(key) {
    goto(urlForView(key));
  }
  // Drilling into a tag/source from a card: cache the source name for an instant
  // label, then navigate by URL (no private stack).
  function drill(key, source = null) {
    if (source) sourceNames = { ...sourceNames, [source.id]: source.name };
    navigate(key);
  }
  // How many forward in-app navigations deep we are from the landing entry, so
  // the in-page Back stays INSIDE the app even when someone deep-links in from
  // email/social/an article (history.length can't tell us that).
  let appNavDepth = 0;
  function goBack() {
    if (appNavDepth > 0 && typeof history !== 'undefined') history.back();
    else goto(urlForView('today')); // landed here directly → app-safe Highlights
  }

  // Load the data for a view. Called by afterNavigate (URL-driven: in-app goto,
  // browser back/forward, initial load) and directly on a boundary re-filter.
  let loadSeq = 0;
  async function loadView(key, fresh = false) {
    const seq = ++loadSeq; // a newer navigation supersedes a slow in-flight one
    error = '';
    feedDone = false;
    try {
      if (key === 'today') {
        await loadToday(fresh);
        if (seq !== loadSeq) return; // a newer navigation superseded this one
      } else {
        const items = (await getJSON(feedUrl(key, 0))).items;
        if (seq !== loadSeq) return;
        feed = items;
        feedDone = items.length < PAGE;
        markDisplayed(feed);
        if (key.startsWith('source:') && items[0]) {
          sourceNames = { ...sourceNames, [key.slice(7)]: items[0].source };
        }
      }
    } catch (e) {
      if (seq === loadSeq) error = 'Something went quiet — could not reach the feed.';
    }
    if (seq === loadSeq && typeof window !== 'undefined') window.scrollTo({ top: 0, behavior: 'smooth' });
  }

  // Every URL change (in-app goto, browser back/forward, link) reloads the view.
  // The initial 'enter' is handled by onMount, after its data deps are fetched.
  afterNavigate((nav) => {
    if (nav.type === 'enter') return;
    // Forward in-app nav deepens the stack; back/forward (popstate) unwinds it.
    // Clamped at 0, so an out-of-app landing keeps Back app-safe (→ Highlights).
    if (nav.type === 'goto' || nav.type === 'link') appNavDepth += 1;
    else if (nav.type === 'popstate') appNavDepth = Math.max(0, appNavDepth - 1);
    loadView(selected);
  });

  // "Load more" for any feed view (Latest, topics, tags, moods): fetch the next
  // page at the current length and append, de-duping against what's shown.
  async function loadMore() {
    if (loadingMore || feedDone || selected === 'today') return;
    loadingMore = true;
    try {
      const items = (await getJSON(feedUrl(selected, feed.length))).items;
      const have = new Set(feed.map((a) => a.id));
      const fresh = items.filter((a) => !have.has(a.id));
      feed = [...feed, ...fresh];
      feedDone = items.length < PAGE;
      markDisplayed(fresh);
    } catch {
      flash('Could not load more just now.');
    } finally {
      loadingMore = false;
    }
  }

  const MIX_EVENT = { notToday: 'not_today', lessLikeThis: 'less_like_this', alwaysHide: 'hide_topic' };
  function applyAction(kind, value) {
    applyPrefAction(kind, value); // updates + persists + syncs to account
    if (MIX_EVENT[kind]) track(MIX_EVENT[kind]); // emotional-mix signal (no value stored)
    loadView(selected, true); // re-filter the current view in place (no nav)
  }

  function flash(msg) {
    notice = msg;
    if (typeof window !== 'undefined') setTimeout(() => (notice = ''), 4000);
  }

  async function replaceArticle(article) {
    const list = selected === 'today' ? brief?.items : feed;
    if (!list) return;
    const isHero = selected === 'today' && heroArticle?.id === article.id;
    const exclude = Array.from(seenIds).join(',');
    const q = mergedParam();
    const url = `/api/replacement?exclude=${exclude}&avoid_paywall=true${isHero ? '&gentle=true' : ''}${q ? '&' + q : ''}`;
    let repl;
    try {
      repl = await getJSON(url);
    } catch {
      flash('Could not reach the feed just now.');
      return;
    }
    if (!repl) {
      flash("That's everything fresh for now — nothing new to swap in.");
      track('replace_none');
      return;
    }
    track('replace_used', article.id);
    if (article.paywalled) track('paywall_replace', article.id);
    dismissed.add(article.id);
    seenIds.add(article.id);
    markDisplayed([repl]);
    record(article); // keep the swapped-away story (recoverable)
    persistSession();
    if (selected === 'today') {
      const i = brief.items.findIndex((a) => a.id === article.id);
      if (i >= 0) {
        brief.items[i] = repl;
        brief = { ...brief, items: [...brief.items] };
        P.saveJSON(BRIEF_VIEW_KEY, { generated_at: brief.generated_at, items: brief.items });
      }
    } else {
      const i = feed.findIndex((a) => a.id === article.id);
      if (i >= 0) {
        feed[i] = repl;
        feed = [...feed];
      }
    }
  }

  async function browse() {
    if (selected !== 'today') await goto(urlForView('today'));
    document.getElementById('explore')?.scrollIntoView({ behavior: 'smooth' });
  }

  onMount(async () => {
    initPrefs();
    initHistory();
    seenIds = new Set(P.loadJSON(SEEN_KEY, []));
    dismissed = new Set(P.loadJSON(DISMISSED_KEY, []));
    refreshAuth();
    trackVisit();
    try {
      moods = await getJSON('/api/moods');
      topics = (await getJSON('/api/categories')).topics;
      try { lanePool = await getJSON('/api/lanes'); } catch { lanePool = null; }
      try { families = await getJSON('/api/families'); } catch { families = []; }
      // Load whatever the URL asks for (Highlights, or a deep-linked source/tag/
      // view) — the param stays in the URL so reload and share are honest.
      await loadView(selected);
    } catch (e) {
      error = 'Could not reach Upbeat Bytes.';
    }
    loading = false;
  });
</script>

<Header onSaved={() => (showSaved = true)} onaccount={openAccount} user={auth.user} boundariesActive={filtersOn} />

{#if showSignIn}<SignIn onclose={() => (showSignIn = false)} />{/if}
{#if showSaved && auth.user}<SavedFlyout onclose={() => (showSaved = false)} />{/if}
{#if showLanes && lanePool}
  <LanePicker pool={lanePool} selected={pinnedLaneKeys} onsave={saveLanes} onclose={() => (showLanes = false)} />
{/if}

<main class="container">
  {#if navLanes.length}
    <MoodNav lanes={navLanes} {selected} onselect={navigate} oncustomize={() => (showLanes = true)} />
  {/if}

  {#if notice}<p class="notice rise">{notice}</p>{/if}

  {#if loading}
    <p class="muted center pad">Gathering the good news…</p>
  {:else if error}
    <p class="muted center pad">{error}</p>
  {:else}
    {#key selected}
      <header class="view-head rise">
        <div class="vh-text">
          <h1>{viewLabel}</h1>
          {#if viewSubtitle}<p class="sub">{viewSubtitle}</p>{/if}
        </div>
        {#if selected !== 'today'}
          <button class="viewback" onclick={goBack} aria-label="Go back">
            <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M19 12H5M11 6l-6 6 6 6" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"/></svg>
            Back
          </button>
        {/if}
      </header>

      {#if selected === 'today'}
        {#if brief?.items?.length}
          <section class="rise">
            <ArticleCard article={heroArticle} hero onaction={applyAction} onreplace={replaceArticle} ontag={(t) => drill('tag:' + t)} onsource={(id, name) => drill('source:' + id, { id, name })} onview={record} onimageerror={heroImageFailed} />
            {#if restArticles.length}
              <div class="grid rest">
                {#each restArticles as a (a.id)}
                  <ArticleCard article={a} thumb onaction={applyAction} onreplace={replaceArticle} ontag={(t) => drill('tag:' + t)} onsource={(id, name) => drill('source:' + id, { id, name })} onview={record} />
                {/each}
              </div>
            {/if}
          </section>
          <p class="endcap rise">✦ that's the good news for today ✦</p>
        {:else}
          <p class="muted center pad">No highlights yet today — try a calmer filter, or check back soon.</p>
        {/if}
      {:else if feed.length}
        <div class="grid rise">
          {#each feed as a (a.id)}
            <ArticleCard article={a} thumb onaction={applyAction} onreplace={replaceArticle} ontag={(t) => drill('tag:' + t)} onsource={(id, name) => drill('source:' + id, { id, name })} onview={record} />
          {/each}
        </div>
        {#if !feedDone}
          <div class="loadmore">
            <button onclick={loadMore} disabled={loadingMore}>
              {loadingMore ? 'Loading…' : 'Load more'}
            </button>
          </div>
        {:else}
          <p class="endcap rise">✦ you're all caught up ✦</p>
        {/if}
      {:else}
        <p class="muted center pad">Nothing here right now — try another, or ease a boundary.</p>
      {/if}
    {/key}

    {#if families.length}
      <section id="explore" class="explore">
        <h2>Explore Upbeat Bytes</h2>
        <div class="families">
          {#each families as f (f.name)}
            {@const tags = f.tags.filter((t) => t.count > 0)}
            {#if tags.length}
              <div class="family">
                <h3>{f.name}</h3>
                <p class="fdesc">{f.description}</p>
                <div class="chips">
                  {#each tags as t (t.key)}
                    <button class="chip" class:active={selected === 'tag:' + t.key} onclick={() => drill('tag:' + t.key)}>{humanize(t.key)}</button>
                  {/each}
                </div>
              </div>
            {/if}
          {/each}
        </div>
      </section>
    {/if}
  {/if}
</main>

<BottomNav active={activeTab} onToday={() => navigate('today')} onLatest={() => navigate('latest')} onBrowse={browse} onYou={openAccount} user={auth.user} />

<style>
  main.container { padding-top: 6px; padding-bottom: 40px; min-height: 60vh; }

  .view-head {
    margin: 18px 0 18px; display: flex; align-items: flex-start;
    justify-content: space-between; gap: 16px;
  }
  .view-head .vh-text { flex: 1; min-width: 0; }
  .viewback {
    flex-shrink: 0; margin-top: 8px;
    display: inline-flex; align-items: center; gap: 5px;
    background: none; border: 1px solid var(--line); color: var(--accent-deep);
    border-radius: 999px; padding: 6px 14px; font-size: 0.85rem; cursor: pointer;
    transition: border-color 0.14s ease;
  }
  .viewback:hover { border-color: var(--accent); }
  .viewback svg { width: 16px; height: 16px; display: block; }
  .view-head h1 { font-size: clamp(2.1rem, 5.5vw, 2.8rem); line-height: 1.05; text-transform: capitalize; }
  .view-head .sub { margin: 8px 0 0; color: var(--muted); font-size: 1.02rem; }
  .view-head .vh-text::after {
    content: ''; display: block; width: 46px; height: 3px;
    background: var(--accent); border-radius: 2px; margin-top: 14px; opacity: 0.8;
  }

  .explore { margin: 52px 0 8px; padding-top: 28px; border-top: 1px solid var(--line); }
  .explore h2 {
    font-size: 0.74rem; text-transform: uppercase; letter-spacing: 0.14em;
    color: var(--muted); font-family: var(--label); font-weight: 400; margin: 0 0 22px;
  }
  .families { display: grid; grid-template-columns: repeat(auto-fit, minmax(248px, 1fr)); gap: 26px 32px; }
  .family h3 { font-size: 1.16rem; margin: 0 0 2px; }
  .family .fdesc { margin: 0 0 11px; color: var(--muted); font-size: 0.86rem; line-height: 1.45; }
  .explore .chips { display: flex; flex-wrap: wrap; gap: 7px; }
  .explore .chip {
    border: 1px solid var(--line); background: var(--surface); color: var(--ink);
    border-radius: 999px; padding: 5px 13px; font-size: 0.82rem; cursor: pointer;
    transition: all 0.14s ease; text-transform: capitalize;
  }
  .explore .chip:hover { border-color: var(--accent); color: var(--accent-deep); }
  .explore .chip.active { background: var(--accent); border-color: var(--accent); color: #fff; }

  .notice {
    text-align: center; color: var(--accent-deep); background: var(--accent-soft);
    border-radius: 999px; padding: 8px 16px; margin: 10px auto 0; width: fit-content; font-size: 0.86rem;
  }
  .rest { margin-top: 18px; }
  .center { text-align: center; }
  .pad { padding: 48px 0; }
  .endcap {
    text-align: center; color: var(--muted); font-family: var(--serif);
    font-style: italic; margin: 40px 0 10px; letter-spacing: 0.02em;
  }
  .loadmore { display: flex; justify-content: center; margin: 30px 0 6px; }
  .loadmore button {
    background: var(--surface); border: 1px solid var(--line); color: var(--accent-deep);
    border-radius: 999px; padding: 10px 28px; font-size: 0.92rem; cursor: pointer;
    transition: all 0.14s ease;
  }
  .loadmore button:hover { border-color: var(--accent); background: var(--accent-soft); }
  .loadmore button:disabled { opacity: 0.6; cursor: default; }
</style>
