// ------------------------------------------------------------------------
//  hooks/useAppState.js - All state and side-effect logic for UTCIForecast.
//
//  Extracted from app.js to keep the main component under the AI edit
//  safe-zone. This hook owns every useState, useEffect, useCallback and
//  useRef that app.js needs, returning them as a single flat object.
//
//  Usage in app.js:
//    const state = useAppState();
//    const { forecast, isPro, visibleCols, ... } = state;
//
//  Reading order:
//    1. Location + search
//    2. Day-tab scroll refs
//    3. Pro tier
//    4. Profile + column visibility
//    5. Skin, vehicle, indoor, pollen
//    6. Table refs
//    7. Column popup + table scroll hooks
//    8. Geocoding search effect
//    9. Computation - rows, days, current row
//   10. Banner - events, snooze, slideshow
// ------------------------------------------------------------------------

import { useState, useEffect, useRef, useCallback } from '../../vendor/preact-hooks.js';
import {
  FREE_DAYS, FILTER_PROFILES, OUTDOORS_VARIANTS,
  profileButtonOrder, variantIcons,
  activityVariantKeys, placeVariantKeys, workVariantKeys,
  UTCI_ENVIRONMENTS, VARIANT_DEFAULT_ENV,
} from '../config.js';
import { utciCategory } from '../utils.js';
import { buildHourlyRows, aggregateRows } from '../compute.js';
import { useForecast } from './useForecast.js';
import { useColumnPopup } from './useColumnPopup.js';
import { useTableScroll } from './useTableScroll.js';
import { getActiveEvents, getLensEvent } from '../events.js';

export function useAppState() {

  // ── 1. LOCATION ──────────────────────────────────────────────────────
  const [location, setLocation] = useState(() => {
    try {
      const saved = localStorage.getItem('sunscope_last_location');
      if (saved) return JSON.parse(saved);
    } catch (e) { /* ignore */ }
    return { name: 'London, England', lat: 51.509, lon: -0.126, country: 'GB' };
  });

  const setLocationAndSave = (loc) => {
    try { localStorage.setItem('sunscope_last_location', JSON.stringify(loc)); } catch (e) { /* ignore */ }
    setLocation(loc);
  };

  // Pro tier flag is read early so useForecast can pick its refresh cadence
  // (Pro: 5 min, free: 15 min). Full setup notes in the PRO TIER section below.
  const [isPro, setIsPro] = useState(() => {
    const params = new URLSearchParams(window.location.search);
    if (params.get('pro') === '1') {
      localStorage.setItem('sunscope_pro', '1');
      window.history.replaceState({}, '', window.location.pathname);
      return true;
    }
    return localStorage.getItem('sunscope_pro') === '1';
  });

  const { forecast, airQuality, loading, error, now, fetchedAt, liveElev } = useForecast(location, isPro);

  const [searchQuery, setSearchQuery]     = useState('');
  const [searchResults, setSearchResults] = useState([]);
  const [searching, setSearching]         = useState(false);
  const [selectedDay, setSelectedDay]     = useState(0);
  const [proPromptDay, setProPromptDay]   = useState(null);
  const [proPromptSource, setProPromptSource] = useState('day');

  // ── 2. DAY-TAB SCROLL ────────────────────────────────────────────────
  const dayTabsRef = useRef(null);
  const [canScrollLeft, setCanScrollLeft]   = useState(false);
  const [canScrollRight, setCanScrollRight] = useState(false);

  useEffect(() => {
    const el = dayTabsRef.current;
    if (!el) return;
    const update = () => {
      setCanScrollLeft(el.scrollLeft > 1);
      setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
    };
    update();
    el.addEventListener('scroll', update, { passive: true });
    window.addEventListener('resize', update);
    let ro = null;
    if (typeof ResizeObserver !== 'undefined') {
      ro = new ResizeObserver(update);
      ro.observe(el);
    }
    return () => {
      el.removeEventListener('scroll', update);
      window.removeEventListener('resize', update);
      if (ro) ro.disconnect();
    };
  }, [forecast]);

  useEffect(() => {
    const el = dayTabsRef.current;
    if (!el) return;
    const activeTab = el.querySelector('.utci-day-tab.active');
    if (!activeTab) return;
    const elRect  = el.getBoundingClientRect();
    const tabRect = activeTab.getBoundingClientRect();
    if (tabRect.left < elRect.left + 8) {
      el.scrollBy({ left: tabRect.left - elRect.left - 24, behavior: 'smooth' });
    } else if (tabRect.right > elRect.right - 8) {
      el.scrollBy({ left: tabRect.right - elRect.right + 24, behavior: 'smooth' });
    }
  }, [selectedDay]);

  const scrollDayTabs = (dir) => {
    const el = dayTabsRef.current;
    if (!el) return;
    el.scrollBy({ left: dir * 200, behavior: 'smooth' });
  };

  // ── 3. PRO TIER ──────────────────────────────────────────────────────
  // (isPro is declared near the top so useForecast can read it; see above.)

  // ── 4. PROFILE + COLUMN VISIBILITY ───────────────────────────────────
  const [activeProfile, setActiveProfile] = useState(() => {
    try { return localStorage.getItem('sunscope_profile') || 'basic'; } catch (e) { return 'basic'; }
  });

  const [visibleCols, setVisibleCols] = useState(() => {
    try {
      const saved = localStorage.getItem('sunscope_profile') || 'basic';
      // If saved profile is outdoors. read columns from the saved variant - Beach. Running. etc.
      // so the column buttons match the autoloaded variant on startup.
      if (saved === 'outdoors') {
        const savedVariant = localStorage.getItem('sunscope_outdoors_variant') || 'urban';
        const variantCols = OUTDOORS_VARIANTS[savedVariant]?.cols
          ?? FILTER_PROFILES.outdoors.cols;
        return { ...variantCols };
      }
      // For custom profile, load from config - blank slate with only Hour on.
      if (saved === 'custom') {
        return { ...FILTER_PROFILES.custom?.cols ?? FILTER_PROFILES.basic.cols };
      }
      return { ...(FILTER_PROFILES[saved]?.cols ?? FILTER_PROFILES.basic.cols) };
    } catch (e) { return { ...FILTER_PROFILES.basic.cols }; }
  });

  const toggleCol = (col) => setVisibleCols(prev => ({ ...prev, [col]: !prev[col] }));

  const activateProfile = (key) => {
    const profile = FILTER_PROFILES[key];
    try { localStorage.setItem('sunscope_profile', key); } catch (e) { /* ignore */ }
    setActiveProfile(key);
    if (key !== 'custom') {
      setVisibleCols({ ...profile.cols });
      const hasIndoor = profile.cols['indoorT'] || profile.cols['managedT'];
      setIndoorMode(hasIndoor ? 'on' : 'off');
      setIndoorManaged(false);
      if (hasIndoor) setBuildingTypeAndSave('brick');
      const defaultEnv = VARIANT_DEFAULT_ENV[key];
      if (defaultEnv) setUtciEnvAndSave(defaultEnv);
    } else {
      // Custom profile initializes from config - blank slate, user picks columns.
      const customCols = FILTER_PROFILES.custom?.cols;
      if (customCols) {
        setVisibleCols({ ...customCols });
        const hasIndoor = customCols['indoorT'] || customCols['managedT'];
        setIndoorMode(hasIndoor ? 'on' : 'off');
        setIndoorManaged(false);
      }
    }
  };

  // ── 5. SKIN, VEHICLE, INDOOR, POLLEN ─────────────────────────────────
  const [skinType, setSkinType]       = useState('II');
  const [vehicleType, setVehicleType] = useState(() => {
    try { return localStorage.getItem('sunscope_vehicle_type') || 'car'; } catch (e) { return 'car'; }
  });
  const setVehicleTypeAndSave = (v) => {
    try { localStorage.setItem('sunscope_vehicle_type', v); } catch (e) { /* ignore */ }
    setVehicleType(v);
  };
  const [vehicleVent, setVehicleVent] = useState(() => {
    try { return localStorage.getItem('sunscope_vehicle_vent') === '1'; } catch (e) { return false; }
  });
  const setVehicleVentAndSave = (v) => {
    try { localStorage.setItem('sunscope_vehicle_vent', v ? '1' : '0'); } catch (e) { /* ignore */ }
    setVehicleVent(v);
  };

  const [outdoorsVariant, setOutdoorsVariant] = useState(() => {
    try { return localStorage.getItem('sunscope_outdoors_variant') || 'urban'; } catch (e) { return 'urban'; }
  });
  const setOutdoorsVariantAndSave = (v) => {
    try { localStorage.setItem('sunscope_outdoors_variant', v); } catch (e) { /* ignore */ }
    setOutdoorsVariant(v);
    // Sync the column toggle buttons to the variants own cols so the UI matches
    // the autoloaded selection - eg Beach selects Dew. Sun. UV-B and Running selects RH. Pollen. UTCI.
    const variantCols = OUTDOORS_VARIANTS[v]?.cols;
    if (variantCols) {
      setVisibleCols({ ...variantCols });
      const hasIndoor = variantCols['indoorT'] || variantCols['managedT'];
      setIndoorMode(hasIndoor ? 'on' : 'off');
    }
    // Auto-set UTCI environment modifier to match the selected variant.
    const defaultEnv = VARIANT_DEFAULT_ENV[v] ?? 'open';
    setUtciEnvAndSave(defaultEnv);
  };

  const activeCols = activeProfile === 'outdoors'
    ? (OUTDOORS_VARIANTS[outdoorsVariant]?.cols ?? FILTER_PROFILES.outdoors.cols)
    : (FILTER_PROFILES[activeProfile]?.cols ?? FILTER_PROFILES.basic.cols);

  const [buildingType, setBuildingType] = useState(() => {
    try {
      const saved = localStorage.getItem('sunscope_building_type');
      if (saved) return saved;
      // Fall back to office if the saved variant is office and no explicit building saved
      const savedVariant = localStorage.getItem('sunscope_outdoors_variant') || '';
      if (savedVariant === 'office') return 'office';
    } catch (e) { /* ignore */ }
    return 'brick';
  });
  const setBuildingTypeAndSave = (v) => {
    try { localStorage.setItem('sunscope_building_type', v); } catch (e) { /* ignore */ }
    setBuildingType(v);
  };
  const [indoorManaged, setIndoorManaged] = useState(() => {
    try { return localStorage.getItem('sunscope_indoor_managed') === '1'; } catch (e) { return false; }
  });
  const setIndoorManagedAndSave = (v) => {
    try { localStorage.setItem('sunscope_indoor_managed', v ? '1' : '0'); } catch (e) { /* ignore */ }
    setIndoorManaged(v);
  };
  const [indoorMode, setIndoorMode] = useState(() => {
    try {
      const saved = localStorage.getItem('sunscope_indoor_mode');
      if (saved) return saved;
      const profile = localStorage.getItem('sunscope_profile') || 'basic';
      const cols = FILTER_PROFILES[profile]?.cols ?? FILTER_PROFILES.basic.cols;
      return (cols['indoorT'] || cols['managedT']) ? 'on' : 'off';
    } catch (e) { return 'off'; }
  });
  const setIndoorModeAndSave = (v) => {
    try { localStorage.setItem('sunscope_indoor_mode', v); } catch (e) { /* ignore */ }
    setIndoorMode(v);
  };

  const [pollenType, setPollenType] = useState(() => {
    try { return localStorage.getItem('sunscope_pollen_type') || 'all_pollen'; } catch (e) { return 'all_pollen'; }
  });
  const setPollenTypeAndSave = (v) => {
    try { localStorage.setItem('sunscope_pollen_type', v); } catch (e) { /* ignore */ }
    setPollenType(v);
  };

  const [utciEnv, setUtciEnv] = useState(() => {
    try {
      const saved = localStorage.getItem('sunscope_utci_env');
      if (saved && UTCI_ENVIRONMENTS[saved]) return saved;
      // No saved env - derive from the saved variant only when the outdoors
      // profile is active. Other profiles (Basic, Vehicle etc.) always open.
      const savedProfile = localStorage.getItem('sunscope_profile') || 'basic';
      if (savedProfile === 'outdoors') {
        const savedVariant = localStorage.getItem('sunscope_outdoors_variant') || '';
        return VARIANT_DEFAULT_ENV[savedVariant] ?? 'open';
      }
      return 'open';
    } catch (e) { return 'open'; }
  });
  const setUtciEnvAndSave = (v) => {
    try { localStorage.setItem('sunscope_utci_env', v); } catch (e) { /* ignore */ }
    setUtciEnv(v);
  };
  
  const [showDecimals, setShowDecimals] = useState(() => {
    try { const s = localStorage.getItem('sunscope_show_decimals'); return s === null ? false : s !== '0'; } catch (e) { return false; }
  });
  const toggleShowDecimals = () => setShowDecimals(v => {
    const next = !v;
    try { localStorage.setItem('sunscope_show_decimals', next ? '1' : '0'); } catch (e) { /* ignore */ }
    return next;
  });

  const [showUnits, setShowUnits] = useState(() => {
    try {
      const s = localStorage.getItem('sunscope_show_units');
      if (s !== null) return s !== '0';
      return typeof window !== 'undefined' ? window.innerWidth >= 640 : true;
    } catch (e) { return true; }
  });
  const toggleShowUnits = () => setShowUnits(v => {
    const next = !v;
    try { localStorage.setItem('sunscope_show_units', next ? '1' : '0'); } catch (e) { /* ignore */ }
    return next;
  });

  // Table row interval: 1 = every hour (default), 2/3/4 = clock-aligned buckets.
  const [tableInterval, setTableIntervalState] = useState(() => {
    try { const n = parseInt(localStorage.getItem('sunscope_table_interval'), 10); return [1, 2, 3, 4].includes(n) ? n : 3; } catch (e) { return 3; }
  });
  const setTableInterval = (n) => setTableIntervalState(() => {
    const next = [1, 2, 3, 4].includes(n) ? n : 1;
    try { localStorage.setItem('sunscope_table_interval', String(next)); } catch (e) { /* ignore */ }
    return next;
  });

  const searchTimeout = useRef(null);

  // Derived selectors used by profile controls
  const activityOptions = activityVariantKeys.map((k) => {
    const v = OUTDOORS_VARIANTS[k];
    const locked = v.proOnly && !isPro;
    return {
      value: k,
      label: `${variantIcons[k]} ${v.name}${locked ? ' 🔒' : ''}`,
      name:  `${v.name}${locked ? ' 🔒' : ''}`,
      scene: v.scene,
      icon:  v.icon,
      disabled: locked,
    };
  });
  const placeOptions = placeVariantKeys.map((k) => {
    const v = OUTDOORS_VARIANTS[k];
    const locked = v.proOnly && !isPro;
    return {
      value: k,
      label: `${variantIcons[k]} ${v.name}${locked ? ' 🔒' : ''}`,
      name:  `${v.name}${locked ? ' 🔒' : ''}`,
      scene: v.scene,
      icon:  v.icon,
      disabled: locked,
    };
  });
  const workOptions = workVariantKeys.map((k) => {
    const v = k === 'farming'
      ? { name: FILTER_PROFILES.farming.label, proOnly: false, scene: FILTER_PROFILES.farming.scene, icon: FILTER_PROFILES.farming.icon }
      : OUTDOORS_VARIANTS[k];
    const locked = v.proOnly && !isPro;
    const iconGlyph = k === 'farming' ? FILTER_PROFILES.farming.icon : variantIcons[k];
    return {
      value: k,
      label: `${iconGlyph} ${v.name}${locked ? ' 🔒' : ''}`,
      name:  `${v.name}${locked ? ' 🔒' : ''}`,
      scene: v.scene,
      icon:  v.icon || iconGlyph,
      disabled: locked,
    };
  });

  const activityValue = activeProfile === 'outdoors' && activityVariantKeys.includes(outdoorsVariant)
    ? outdoorsVariant
    : 'off';
  const activityLabel = activityOptions.find((o) => o.value === activityValue)?.name;
  const placeValue    = activeProfile === 'outdoors' && placeVariantKeys.includes(outdoorsVariant)
    ? outdoorsVariant
    : 'off';
  const placeLabel    = placeOptions.find((o) => o.value === placeValue)?.name;
  const workValue     = activeProfile === 'farming'
    ? 'farming'
    : activeProfile === 'outdoors' && workVariantKeys.includes(outdoorsVariant)
      ? outdoorsVariant
      : 'off';
  const workLabel     = workOptions.find((o) => o.value === workValue)?.name;

  // ── 6. TABLE REFS ────────────────────────────────────────────────────
  const headStickyRef = useRef(null);
  const headTrackRef  = useRef(null);
  const headTableRef  = useRef(null);
  const bodyScrollRef = useRef(null);
  const bodyTableRef  = useRef(null);
  const tableWrapRef  = useRef(null);

  // ── 7. COLUMN POPUP + TABLE SCROLL ───────────────────────────────────
  const {
    colPopup, colPopupRef,
    handleThClick, handleThEnter, handleThLeave,
    handlePopupEnter, handlePopupLeave,
    eventTagPopup, eventTagPopupRef,
    evSlideIndex, evTransition, evSlideTo,
    handleEventTagClick, handleEventTagEnter, handleEventTagLeave,
    handleEventTagPopupEnter, handleEventTagPopupLeave,
    closePopup, closeEventTagPopup,
  } = useColumnPopup();

  const {
    tableCanScrollLeft,
    tableCanScrollRight,
    handleBodyScroll,
  } = useTableScroll({
    headTableRef, bodyTableRef, bodyScrollRef, headTrackRef,
    forecast, visibleCols, selectedDay, skinType, vehicleType,
    indoorMode, indoorManaged, showDecimals, showUnits,
  });

  // ── 8. GEOCODING SEARCH ───────────────────────────────────────────────
  useEffect(() => {
    if (searchQuery.length < 2) { setSearchResults([]); return; }
    if (searchTimeout.current) clearTimeout(searchTimeout.current);
    searchTimeout.current = setTimeout(async () => {
      setSearching(true);
      try {
        const r = await fetch(
          `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(searchQuery)}&count=6&language=en&format=json`
        );
        const j = await r.json();
        setSearchResults(j.results || []);
      } catch { setSearchResults([]); }
      finally { setSearching(false); }
    }, 300);
  }, [searchQuery]);

  // ── 9. COMPUTATION ───────────────────────────────────────────────────
  const { hourlyRows, days, utcOffsetMs } = buildHourlyRows({
    forecast, airQuality, location, vehicleType, vehicleVent, buildingType, utciEnv,
  });

  const visible = days[selectedDay]?.rows || [];
  // Rows actually rendered in the table - bucketed by the interval selector.
  // `visible` stays full-resolution so the dial, glance and event detection
  // keep seeing every hour.
  const tableRows = aggregateRows(visible, tableInterval);

  const nowLocalISO = new Date(now.getTime() + utcOffsetMs).toISOString().slice(0, 13);
  const currentRow  = hourlyRows.length > 0
    ? (hourlyRows.find(row => row.iso.slice(0, 13) === nowLocalISO)
      ?? hourlyRows.reduce((best, row) =>
        Math.abs(row.dt - now) < Math.abs(best.dt - now) ? row : best))
    : null;
  const currentCat  = currentRow
    ? utciCategory(currentRow.utciAdj)
    : { bg: '#4a4228', fg: '#ede4cc', label: 'No data' };

  // ── 10. BANNER + EVENTS ───────────────────────────────────────────────
  const [dismissedEventIds, setDismissedEventIds] = useState([]);

  const BANNER_SNOOZE_HOURS = 6;
  const BANNER_SNOOZE_MS    = BANNER_SNOOZE_HOURS * 60 * 60 * 1000;
  const BANNER_SNOOZE_KEY   = id => `sunscope.banner.snoozed.${id}`;

  const [bannerIndex, setBannerIndex]           = useState(0);
  const [bannerTransition, setBannerTransition] = useState(null);
  const [bannerPrevIndex, setBannerPrevIndex]   = useState(null);
  const [bannerVisible, setBannerVisible]       = useState(false);
  const bannerIndexRef = useRef(0);
  const bannerStageRef = useRef(null);

  useEffect(() => { bannerIndexRef.current = bannerIndex; }, [bannerIndex]);

  const isSnoozed = id => {
    try {
      const ts = localStorage.getItem(BANNER_SNOOZE_KEY(id));
      return ts && (Date.now() - Number(ts)) < BANNER_SNOOZE_MS;
    } catch (e) { return false; }
  };

  const todayRows       = days[0]?.rows || [];
  const activeEvents    = getActiveEvents(todayRows, location)
    .filter(ev => !dismissedEventIds.includes(ev.id) && !isSnoozed(ev.id));
  const lensEvent       = getLensEvent(activeEvents);
  const selectedDayEvents = getActiveEvents(visible, location);

  // Only show banner when events first appear - not on every re-render
  // Using a ref to track previous length avoids re-showing during dismiss animation
  const prevActiveEventsLenRef = useRef(0);
  useEffect(() => {
    const prev = prevActiveEventsLenRef.current;
    prevActiveEventsLenRef.current = activeEvents.length;
    if (activeEvents.length > 0 && prev === 0) setBannerVisible(true);
  }, [activeEvents.length]);

  const bannerSlideTo = useCallback((next) => {
    const stage = bannerStageRef.current;
    if (stage) {
      stage.style.height     = stage.offsetHeight + 'px';
      stage.style.transition = 'height 0.45s cubic-bezier(0.4,0,0.2,1)';
    }
    setBannerPrevIndex(bannerIndexRef.current);
    setBannerIndex(next);
    setBannerTransition('crossfading');
    setTimeout(() => {
      if (stage) {
        const incoming = stage.querySelector('.event-banner:not(.event-banner--outgoing)');
        if (incoming) stage.style.height = incoming.offsetHeight + 'px';
      }
    }, 16);
    setTimeout(() => {
      setBannerTransition(null);
      setBannerPrevIndex(null);
      if (stage) { stage.style.height = ''; stage.style.transition = ''; }
    }, 460);
  }, []);

  const dismissBanner = useCallback((evId) => {
    // Do NOT write the snooze to localStorage yet. activeEvents filters out snoozed events
    // synchronously on the very next render. so writing here would cause the banner to be
    // unmounted before the fade animation can start - which is what made it snap closed.
    // We snooze AFTER the animation finishes inside the setTimeout below.
    requestAnimationFrame(() => {
      setBannerVisible(false);
      setTimeout(() => {
        try { localStorage.setItem(BANNER_SNOOZE_KEY(evId), String(Date.now())); } catch (e) {}
        setDismissedEventIds(ids => [...ids, evId]);
        setBannerIndex(0);
      }, 500);
    });
  }, []);

  useEffect(() => {
    if (activeEvents.length <= 1) { setBannerIndex(0); return; }
    const id = setInterval(() => {
      const next = (bannerIndexRef.current + 1) % activeEvents.length;
      bannerSlideTo(next);
    }, 10000);
    return () => clearInterval(id);
  }, [activeEvents.length, activeEvents.map(e => e.id).join(','), bannerSlideTo]);

  useEffect(() => {
    if (bannerIndex >= activeEvents.length) setBannerIndex(0);
  }, [activeEvents.length]);

  // ── BANNER POSITION + SHELL PADDING ───────────────────────────────────
  // The banner is position:absolute so it floats over content. We (1) pin its
  // top to the bottom edge of the site-nav so it sits flush below the nav on
  // every screen size, and (2) add matching padding-top to the shell so the
  // page content sits just below the banner.
  // On close: keep the banner pinned to the nav and animate the padding back
  // to 0 so content slides up smoothly as the banner collapses.
  const bannerMaxHeightRef = useRef(0);

  useEffect(() => {
    const shell = document.querySelector('.utci-shell');
    const wrap  = document.querySelector('.event-banner-wrap');
    if (!shell || !wrap) return;

    // The site-nav's outer height is the banner's resting top edge. The banner
    // lives inside the shell, so translate that into the shell's coordinate
    // space by subtracting the shell's own offset from the page top.
    const bannerTop = () => {
      const navEl = document.getElementById('site-nav');
      const navOuterHeight = navEl ? navEl.offsetHeight : 0;
      return navOuterHeight - shell.offsetTop;
    };

    if (!bannerVisible) {
      // Keep the collapsing banner pinned to the nav's bottom edge, and animate
      // padding back to zero matching the banner collapse duration.
      wrap.style.top = bannerTop() + 'px';
      shell.style.transition = 'padding-top 0.45s cubic-bezier(0.4, 0, 0.2, 1)';
      shell.style.paddingTop = '0px';
      const tid = setTimeout(() => {
        shell.style.transition = '';
        shell.style.paddingTop = '';
        bannerMaxHeightRef.current = 0;
      }, 500);
      return () => clearTimeout(tid);
    }

    const applyPadding = (allowShrink = false) => {
      const top = bannerTop();
      wrap.style.top = top + 'px';
      const h = top + wrap.getBoundingClientRect().height;
      if (allowShrink || h > bannerMaxHeightRef.current) {
        bannerMaxHeightRef.current = h;
        shell.style.transition = '';   // no transition while open/resizing
        shell.style.paddingTop = h + 'px';
      }
    };

    // Pin the banner immediately so it appears flush under the nav, then size
    // the content padding once the open animation settles.
    wrap.style.top = bannerTop() + 'px';
    const onResize = () => applyPadding(true);
    const tid = setTimeout(applyPadding, 460);
    window.addEventListener('resize', onResize);
    return () => {
      clearTimeout(tid);
      window.removeEventListener('resize', onResize);
    };
  }, [bannerVisible, bannerIndex, activeEvents.length]);

  // ── RETURN ALL STATE + HANDLERS ───────────────────────────────────────
  return {
    // location
    location, setLocationAndSave,
    // forecast
    forecast, airQuality, loading, error, now, fetchedAt,
    // search
    searchQuery, setSearchQuery,
    searchResults, setSearchResults,
    searching,
    // day selection
    selectedDay, setSelectedDay,
    proPromptDay, setProPromptDay,
    proPromptSource, setProPromptSource,
    // day-tab scroll
    dayTabsRef, canScrollLeft, canScrollRight, scrollDayTabs,
    // pro
    isPro, setIsPro,
    // profile
    activeProfile, setActiveProfile,
    activateProfile, activeCols,
    visibleCols, setVisibleCols, toggleCol,
    activityOptions, placeOptions, workOptions,
    activityValue, activityLabel,
    placeValue, placeLabel,
    workValue, workLabel,
    // skin, vehicle, indoor, pollen
    skinType, setSkinType,
    vehicleType, setVehicleType: setVehicleTypeAndSave,
    vehicleVent, setVehicleVent: setVehicleVentAndSave,
    outdoorsVariant, setOutdoorsVariantAndSave,
    buildingType, setBuildingType: setBuildingTypeAndSave,
    indoorManaged, setIndoorManaged: setIndoorManagedAndSave,
    indoorMode, setIndoorMode: setIndoorModeAndSave,
    pollenType, setPollenTypeAndSave,
    utciEnv, setUtciEnv: setUtciEnvAndSave,
	showDecimals, toggleShowDecimals,
    showUnits, toggleShowUnits,
    tableInterval, setTableInterval,
    // table refs
    headStickyRef, headTrackRef, headTableRef,
    bodyScrollRef, bodyTableRef, tableWrapRef,
    // column popup
    colPopup, colPopupRef,
    handleThClick, handleThEnter, handleThLeave,
    handlePopupEnter, handlePopupLeave,
    eventTagPopup, eventTagPopupRef,
    evSlideIndex, evTransition, evSlideTo,
    handleEventTagClick, handleEventTagEnter, handleEventTagLeave,
    handleEventTagPopupEnter, handleEventTagPopupLeave,
    closePopup, closeEventTagPopup,
    // table scroll
    tableCanScrollLeft, tableCanScrollRight, handleBodyScroll,
    // computation
    hourlyRows, days, utcOffsetMs,
    visible, tableRows, nowLocalISO, currentRow, currentCat,
    liveElev,
    // banner + events
    activeEvents, lensEvent, selectedDayEvents,
    bannerIndex, bannerTransition, bannerPrevIndex,
    bannerVisible, bannerStageRef,
    bannerSlideTo, dismissBanner,
  };
}
