const { useState, useMemo, useEffect, useRef } = React;

const LIVE_SOURCES = ['foreplay', 'adspy', 'brandsearch'];
const MANUAL_SEARCH_ALL_COMPETITORS_ID = '__all_competitors__';
const AR_TARGET_MIN = 1;
const AR_TARGET_MAX = window.RESILIA_LIMITS?.autoresearchPerDirectionMax || 1000;
const SEARCH_ADS_PER_DIRECTION_MAX = window.RESILIA_LIMITS?.searchAdsPerDirectionMax || 10000;
const SEARCH_ADS_PER_PLATFORM_MAX = window.RESILIA_LIMITS?.searchAdsPerPlatformMax || 100000;
const AUTORESEARCH_PAGE_SIZE = 50;
const AUTO_REMODEL_COUNT_MAX = window.RESILIA_LIMITS?.autoRemodelCountMax || 10000;
const AUTO_REMODEL_PAGE_SIZE = 50;
const AUTO_REMODEL_PREVIOUS_PAGE_SIZE = 10;
const PIPELINE_PAGE_SIZE = 50;
const TAB_STORAGE_KEY = 'resilia.dashboard.activeTab';
const SEARCH_PASS_THRESHOLD = 7.5;
const DASHBOARD_TABS = new Set(['autoRemodel', 'research', 'filter', 'remodel', 'video', 'accessRequests', 'adminDirections', 'adminWinningCriteria', 'adminCosts', 'adminWorkspaces', 'adminUsers']);
const savedDashboardTab = () => {
  try {
    const saved = window.localStorage.getItem(TAB_STORAGE_KEY);
    return DASHBOARD_TABS.has(saved) ? saved : null;
  } catch {
    return null;
  }
};
const boundedPositiveIntOrNull = (value, max = AR_TARGET_MAX) => {
  if (value === null || value === undefined || value === '') return null;
  const n = Number(value);
  if (!Number.isFinite(n)) return null;
  return Math.max(AR_TARGET_MIN, Math.min(max, Math.floor(n)));
};
const COST_FLOW_ORDER = ['search', 'autoresearch', 'auto_remodel'];
const COST_FLOW_LABELS = {
  search: 'Search',
  autoresearch: 'Auto Research',
  auto_remodel: 'Auto-Remodel',
};
const costMoney = (value) => new Intl.NumberFormat('en-US', {
  style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 4,
}).format(Number(value || 0));
const compactNumber = (value) => new Intl.NumberFormat('en-US').format(Number(value || 0));
const sanitizeArTargetInput = (value, max = AR_TARGET_MAX) => {
  if (value === '') return '';
  const n = boundedPositiveIntOrNull(value, max);
  return n === null ? '' : String(n);
};
const sourceState = (selected = ['foreplay']) => {
  const active = new Set(Array.isArray(selected) ? selected : [selected]);
  return {
    foreplay: active.has('foreplay'),
    adspy: active.has('adspy'),
    brandsearch: active.has('brandsearch'),
    metaads: false,
  };
};
const allSourceState = () => sourceState(LIVE_SOURCES);
const toggleSourceState = (current, key) => {
  if (!LIVE_SOURCES.includes(key)) return current || sourceState();
  const next = { ...(current || sourceState()) };
  next[key] = !next[key];
  next.metaads = false;
  return next;
};
const defaultSearchFilters = () => ({
  fpFormat: 'video', fpPublisherPlatforms: '', fpLanguages: '', fpLiveStatus: 'live',
  fpMinDays: '15', fpOrder: 'longest_running',
  minLikes: '', minDailyLikes: '', orderBy: '',
  adspySiteType: '', adspyMediaType: '', adspyGender: '',
  adspyAgeMin: '', adspyAgeMax: '', adspyCountries: '', adspyLang: '',
  adspyCreatedFrom: '', adspyCreatedTo: '', adspySeenFrom: '', adspySeenTo: '',
  adspyUsername: '', adspyUserId: '', adspyAffNetwork: '', adspyAffId: '',
  adspyOfferId: '', adspyButtons: '', adspyUrl: '', adspyLandingUrl: '',
  bsPlatform: '', bsMinViews: '', bsMinLikes: '', bsMinEngagement: '', bsSort: '',
  foreplayUrl: '', brandsearchUrl: '',
});

const DIRECTION_PLATFORM_DEFAULT_FILTERS = {
  foreplay: {
    display_format: ['video'],
    languages: ['en'],
    running_duration_min_days: 15,
    live: true,
    order: 'longest_running',
  },
  adspy: { mediaType: 'video' },
  brandsearch: { display_format: ['video'], brandsearch_status: 'active' },
};
const cloneDirectionPlatformDefaults = (platform) => Object.fromEntries(
  Object.entries(DIRECTION_PLATFORM_DEFAULT_FILTERS[platform] || {})
    .map(([key, value]) => [key, Array.isArray(value) ? [...value] : value]),
);
const emptyDirectionPlatformFilters = () => Object.fromEntries(
  LIVE_SOURCES.map(platform => [platform, cloneDirectionPlatformDefaults(platform)]),
);
const DIRECTION_FILTER_FIELDS = {
  foreplay: [
    { key: 'display_format', label: 'Format', type: 'select', array: true, options: [['', 'Any format'], ['video', 'Video'], ['image', 'Image'], ['carousel', 'Carousel']] },
    { key: 'publisher_platform', label: 'Publisher', type: 'list', placeholder: 'facebook, instagram' },
    { key: 'languages', label: 'Lang', type: 'list', placeholder: 'en' },
    { key: 'running_duration_min_days', label: 'Min days', type: 'number', min: 0, placeholder: '14' },
    { key: 'live', label: 'Status', type: 'boolean', emptyLabel: 'Any status', trueLabel: 'Live only', falseLabel: 'Inactive only' },
    { key: 'order', label: 'Order', type: 'select', options: [['', 'Default'], ['longest_running', 'Longest'], ['saved_newest', 'Newest'], ['saved_oldest', 'Oldest']] },
  ],
  adspy: [
    { key: 'siteType', label: 'Network', type: 'select', options: [['', 'FB+IG'], ['facebook', 'Facebook'], ['instagram', 'Instagram']] },
    { key: 'mediaType', label: 'Format', type: 'select', options: [['', 'Any format'], ['video', 'Video'], ['photo', 'Photo']] },
    { key: 'gender', label: 'Gender', type: 'select', options: [['', 'Any gender'], ['female', 'Female'], ['male', 'Male']] },
    { key: 'ages', label: 'Age', type: 'range', min: 18, max: 64, startPlaceholder: '18', endPlaceholder: '64' },
    { key: 'totalLikes', label: 'Likes', type: 'range', min: 0, max: 100000, startPlaceholder: '0', endPlaceholder: '100000' },
    { key: 'dailyLikes', label: 'Daily', type: 'range', min: 0, max: 1000, startPlaceholder: '0', endPlaceholder: '1000' },
    { key: 'orderBy', label: 'Sort', type: 'select', options: [['', 'Default'], ['total_likes', 'Likes'], ['total_shares', 'Shares'], ['created_on_asc', 'Oldest']] },
    { key: 'countries', label: 'Geo', type: 'list', placeholder: 'US, GB' },
    { key: 'lang', label: 'Lang', type: 'text', placeholder: 'eng' },
    { key: 'username', label: 'User', type: 'text', placeholder: 'username' },
    { key: 'userId', label: 'Page ID', type: 'number', min: 1, placeholder: 'id' },
    { key: 'affNetwork', label: 'Net', type: 'number', min: 1, placeholder: 'id' },
    { key: 'affId', label: 'Aff', type: 'text', placeholder: 'aff id' },
    { key: 'offerId', label: 'Offer', type: 'text', placeholder: 'offer id' },
    { key: 'buttons', label: 'CTA', type: 'list', placeholder: 'Shop Now' },
    { key: 'createdBetween', label: 'Created', type: 'dateRange' },
    { key: 'seenBetween', label: 'Seen', type: 'dateRange' },
  ],
  brandsearch: [
    { key: 'brandsearch_platforms', label: 'Network', type: 'select', array: true, options: [['', 'All BS'], ['meta', 'Meta'], ['tiktok', 'TikTok'], ['instagram', 'Instagram']] },
    { key: 'display_format', label: 'Format', type: 'select', array: true, options: [['', 'Any format'], ['video', 'Video'], ['image', 'Image']] },
    { key: 'brandsearch_status', label: 'Status', type: 'select', options: [['', 'Any status'], ['active', 'Live only'], ['inactive', 'Inactive only']] },
    { key: 'brandsearch_min_views', label: 'Views', type: 'number', min: 0, placeholder: '0' },
    { key: 'brandsearch_min_likes', label: 'Likes', type: 'number', min: 0, placeholder: '0' },
    { key: 'brandsearch_min_engagement_rate', label: 'ER%', type: 'number', min: 0, step: 0.1, placeholder: '0' },
    { key: 'brandsearch_sort', label: 'Sort', type: 'select', options: [['', 'Default'], ['play_count', 'Views'], ['like_count', 'Likes'], ['engagement_rate', 'ER%'], ['eu_total_reach', 'Reach'], ['eu_total_spend', 'Spend']] },
    { key: 'brandsearch_sort_order', label: 'Order', type: 'select', options: [['', 'Default'], ['desc', 'Desc'], ['asc', 'Asc']] },
    { key: 'brandsearch_min_reach', label: 'Reach', type: 'number', min: 0, placeholder: '0' },
    { key: 'brandsearch_min_spend', label: 'Min spend', type: 'number', min: 0, placeholder: '0' },
    { key: 'brandsearch_max_spend', label: 'Max spend', type: 'number', min: 0, placeholder: '0' },
  ],
};

const asFilterList = (value) => {
  if (value === null || value === undefined || value === '') return [];
  if (Array.isArray(value)) return value.filter(item => item !== null && item !== undefined && item !== '');
  if (typeof value === 'string') return value.split(',').map(item => item.trim()).filter(Boolean);
  return [value];
};
const listText = (value) => asFilterList(value).join(', ');
const setIfMissing = (out, key, value) => {
  if (out[key] === undefined && value !== undefined && value !== null && value !== '') out[key] = value;
};
const normalizeDirectionApiFilters = (platform, filters = {}) => {
  const out = {
    ...cloneDirectionPlatformDefaults(platform),
    ...(filters && typeof filters === 'object' && !Array.isArray(filters) ? filters : {}),
  };
  if (platform === 'adspy') {
    setIfMissing(out, 'siteType', out.adspy_site_type);
    setIfMissing(out, 'mediaType', out.adspy_media_type);
    setIfMissing(out, 'gender', out.adspy_gender);
    setIfMissing(out, 'orderBy', out.adspy_order);
    setIfMissing(out, 'username', out.adspy_username);
    setIfMissing(out, 'userId', out.adspy_user_id);
    setIfMissing(out, 'affNetwork', out.adspy_aff_network);
    setIfMissing(out, 'affId', out.adspy_aff_id);
    setIfMissing(out, 'offerId', out.adspy_offer_id);
    setIfMissing(out, 'buttons', out.adspy_buttons);
    setIfMissing(out, 'lang', Array.isArray(out.languages) ? out.languages[0] : out.languages);
    if (out.ages === undefined && (out.adspy_age_min || out.adspy_age_max)) out.ages = [out.adspy_age_min, out.adspy_age_max].filter(Boolean);
    if (out.totalLikes === undefined && (out.adspy_min_likes || out.adspy_max_likes)) out.totalLikes = [out.adspy_min_likes, out.adspy_max_likes].filter(Boolean);
    if (out.dailyLikes === undefined && (out.adspy_min_daily_likes || out.adspy_max_daily_likes)) out.dailyLikes = [out.adspy_min_daily_likes, out.adspy_max_daily_likes].filter(Boolean);
    if (out.createdBetween === undefined && (out.adspy_created_from || out.adspy_created_to)) out.createdBetween = [out.adspy_created_from, out.adspy_created_to].filter(Boolean);
    if (out.seenBetween === undefined && (out.adspy_seen_from || out.adspy_seen_to)) out.seenBetween = [out.adspy_seen_from, out.adspy_seen_to].filter(Boolean);
  }
  if (platform === 'brandsearch') {
    setIfMissing(out, 'brandsearch_platforms', out.brandsearch_platform);
  }
  return out;
};
const cleanApiFilters = (filters = {}) => Object.fromEntries(
  Object.entries(filters || {}).flatMap(([key, value]) => {
    if (value === null || value === undefined || value === '') return [];
    if (Array.isArray(value)) {
      const list = value.map(item => (typeof item === 'string' ? item.trim() : item)).filter(item => item !== null && item !== undefined && item !== '');
      return list.length ? [[key, list]] : [];
    }
    if (typeof value === 'string') {
      const text = value.trim();
      return text ? [[key, text]] : [];
    }
    return [[key, value]];
  }),
);

function App() {
  const [authState, setAuthState] = useState({ status: 'loading', user: null });
  const queryTab = new URLSearchParams(window.location.search).get('tab');
  const initialTab = window.location.pathname === '/admin/access-requests'
    ? 'accessRequests'
    : (DASHBOARD_TABS.has(queryTab) ? queryTab : (savedDashboardTab() || 'research'));
  const [tab, setTab] = useState(initialTab);

  // search rows (manual keyword / competitor search)
  const [rows, setRows] = useState([
    { id: 1, query: '', competitorId: null, count: 20,
      filters: defaultSearchFilters(),
      platforms: sourceState() },
  ]);
  const [competitors, setCompetitors] = useState([]);
  const [directions, setDirections] = useState([]);
  const directionsRef = useRef([]);
  const arDefaultDirectionsLoadedRef = useRef(false);

  // pipeline stages
  const [researchAds, setResearchAds] = useState([]);
  const [filteredAds, setFilteredAds] = useState([]);
  const [remodeledAds, setRemodeledAds] = useState([]);
  const [filterPage, setFilterPage] = useState({ loaded: 0, hasMore: false, loading: false });
  const [remodelPage, setRemodelPage] = useState({ loaded: 0, hasMore: false, loading: false });
  const [autoRemodelAds, setAutoRemodelAds] = useState([]);
  const [remodelScripts, setRemodelScripts] = useState({}); // { [adId]: [{tag, body}] | 'pending' | 'error' }
  const remodelPollRef = useRef(null);
  const [convertedAds, setConvertedAds] = useState([]);
  const [autoresearchActive, setAutoresearchActive] = useState(false);  // true if last Research view should show winners-only badge

  const [selResearch, setSelResearch] = useState(new Set());
  const [selFilter, setSelFilter] = useState(new Set());
  const [selRemodel, setSelRemodel] = useState(new Set());

  // autoresearch controls
  const [arSelected, setArSelected] = useState(new Set());
  const [arTarget, setArTarget] = useState('1');
  const setBoundedArTarget = (value) => setArTarget(sanitizeArTargetInput(value));
  const [arMaxAdsPerDirection, setArMaxAdsPerDirection] = useState('100');
  const [arMaxAdsPerPlatform, setArMaxAdsPerPlatform] = useState('');
  const setBoundedArMaxAdsPerDirection = (value) =>
    setArMaxAdsPerDirection(sanitizeArTargetInput(value, SEARCH_ADS_PER_DIRECTION_MAX));
  const setBoundedArMaxAdsPerPlatform = (value) =>
    setArMaxAdsPerPlatform(sanitizeArTargetInput(value, SEARCH_ADS_PER_PLATFORM_MAX));
  const [arNumDirections, setArNumDirections] = useState('');
  const [arPickedDirections, setArPickedDirections] = useState(new Set());
  const [arRunning, setArRunning] = useState(false);
  const [arProgress, setArProgress] = useState(null); // { totalScored, winners, directionsSatisfied, directionsTotal, crawlState }
  const [arSources, setArSources] = useState(sourceState());
  const [autoresearchPage, setAutoresearchPage] = useState({ loaded: 0, total: null, hasMore: false, loading: false });
  const autoresearchLoadedIdsRef = useRef(new Set());
  const autoresearchCurrentIdsRef = useRef(new Set());
  // Bug retention — each Autoresearch run is tagged with a monotonically
  // increasing batch id so the UI can show new winners on top while older
  // batches stay below as history.
  const autoresearchBatchIdRef = useRef(null);
  // Platform-level credit balances surfaced in the top bar. Foreplay is live;
  // the other three are visual placeholders until their backends land.
  const [platformCredits, setPlatformCredits] = useState({
    foreplay: null, adspy: null, brandsearch: null, metaads: null,
  });
  const arPollRef = useRef(null);

  // ---- Auto-Remodel state -----------------------------------------------
  // The orchestrator's directions picker is shared with manual autoresearch
  // — both pull from the same full direction pool. We keep it in App so the
  // panel + the autoresearch panel can stay independent components without
  // losing user picks when one re-renders.
  const [arOrchPickedDirections, setArOrchPickedDirections] = useState(new Set());
  const toggleArOrchPickedDirection = (id) =>
    setArOrchPickedDirections(s => {
      const n = new Set(s);
      n.has(id) ? n.delete(id) : n.add(id);
      return n;
    });
  const clearArOrchPickedDirections = () => setArOrchPickedDirections(new Set());

  const [arRunId, setArRunId] = useState(null);
  const [arStage, setArStage] = useState(null);
  const [arLastEvent, setArLastEvent] = useState(null);
  const [arPickedIds, setArPickedIds] = useState([]);
  const [arStartPending, setArStartPending] = useState(false);
  // Refs that mirror the orchestrator state — the SSE callback below is
  // installed once via useEffect([]), so any state value referenced inside
  // its closure is captured at mount-time and goes stale. Refs let the
  // handler read the *current* value when an event arrives.
  const arRunIdRef = useRef(null);
  const arStageRef = useRef(null);
  const arPickedIdsRef = useRef([]);
  const arTargetAdsRef = useRef(0);
  const arAcceptedAdIdsRef = useRef(new Set());
  const arStartPendingRef = useRef(false);
  useEffect(() => { arRunIdRef.current = arRunId; }, [arRunId]);
  useEffect(() => { arStageRef.current = arStage; }, [arStage]);
  useEffect(() => { arPickedIdsRef.current = arPickedIds; }, [arPickedIds]);
  useEffect(() => { arTargetAdsRef.current = arTargetAds; }, [arTargetAds]);
  // Live progress counters — fed by SSE so the panel meta line + progress
  // bar tick exactly like the autoresearch panel does. ``arNVariants`` is
  // captured at Run time so we know the variants_total denominator.
  const [arDirectionsDone, setArDirectionsDone] = useState(0);
  const [arDirectionsTotal, setArDirectionsTotal] = useState(0);
  const [arAdsFound, setArAdsFound] = useState(0);
  const [arTargetAds, setArTargetAds] = useState(0);
  const [arVariantsDone, setArVariantsDone] = useState(0);
  const [arNVariants, setArNVariants] = useState(1);
  const [arVariantsTotalOverride, setArVariantsTotalOverride] = useState(null);
  const [autoRemodelHydration, setAutoRemodelHydration] = useState({ total: 0, loaded: 0, loading: false });
  const [autoRemodelRestoring, setAutoRemodelRestoring] = useState(false);
  const autoRemodelLoadedIdsRef = useRef(new Set());
  const autoRemodelHydratingPageRef = useRef(false);
  const arNVariantsRef = useRef(1);
  const arRunningOrch = !!arRunId && !['done', 'failed', 'cancelled'].includes(arStage);

  useEffect(() => { arNVariantsRef.current = arNVariants; }, [arNVariants]);

  // Platform toggles for the Auto-Remodel panel. Kept separate from
  // ``arSources`` (which the manual autoresearch panel owns) so the user
  // can configure each panel independently.
  const [arOrchSources, setArOrchSources] = useState(allSourceState());
  const toggleArOrchSource = (key) => {
    if (!LIVE_SOURCES.includes(key)) return;
    setArOrchSources(s => toggleSourceState(s, key));
  };
  const selectAllArOrchSources = () => setArOrchSources(allSourceState());

  const toggleArPickedDirection = (id) =>
    setArPickedDirections(s => {
      const n = new Set(s);
      n.has(id) ? n.delete(id) : n.add(id);
      return n;
    });
  const clearArPickedDirections = () => setArPickedDirections(new Set());
  const selectAllArPickedDirections = () => setArPickedDirections(new Set(directionsRef.current.map(d => d.id)));

  const toggleArSource = (key) => {
    if (!LIVE_SOURCES.includes(key)) return;
    setArSources(s => toggleSourceState(s, key));
  };
  const selectAllArSources = () => setArSources(allSourceState());

  const [tweaks, setTweaks] = useState({ theme: 'light', density: 'comfortable', cardStyle: 'standard', accent: 'teal' });
  const [tweaksOpen, setTweaksOpen] = useState(false);
  const [toast, setToast] = useState(null);
  const [searching, setSearching] = useState(false);
  const [searchingRowIds, setSearchingRowIds] = useState(new Set());
  // High-severity per-platform warnings from the last manual Search run
  // (e.g. "no AdSpy ads matched 'oregano oil' \u2014 showing top 5 instead").
  // Rendered as a red banner above the search results panels.
  const [searchWarnings, setSearchWarnings] = useState([]);
  const [latestSearchRun, setLatestSearchRun] = useState(null);
  const [latestAutoresearchRun, setLatestAutoresearchRun] = useState(null);
  const [filterRunState, setFilterRunState] = useState(null);
  const [remodelRunState, setRemodelRunState] = useState(null);
  const [filterBusy, setFilterBusy] = useState(false);
  const [remodelBusy, setRemodelBusy] = useState(false);
  const [expandedAd, setExpandedAd] = useState(null);
  const [pendingAccessCount, setPendingAccessCount] = useState(0);
  const [managementCollapsed, setManagementCollapsed] = useState(false);
  const filterPollRef = useRef(null);

  useEffect(() => {
    let cancelled = false;
    const loadAuth = async () => {
      try {
        const me = await RESILIA_API.authMe();
        if (cancelled) return;
        if (me && me.authenticated) {
          setAuthState({ status: 'authenticated', user: me.user });
        } else {
          setAuthState({ status: 'signed_out', user: null });
        }
      } catch (e) {
        if (!cancelled) setAuthState({ status: 'signed_out', user: null });
      }
    };
    const onUnauthorized = () => setAuthState({ status: 'signed_out', user: null });
    window.addEventListener('resilia:unauthorized', onUnauthorized);
    loadAuth();
    return () => {
      cancelled = true;
      window.removeEventListener('resilia:unauthorized', onUnauthorized);
    };
  }, []);

  // edit-mode host protocol
  useEffect(() => {
    const onMsg = (e) => {
      if (e.data?.type === '__activate_edit_mode') setTweaksOpen(true);
      if (e.data?.type === '__deactivate_edit_mode') setTweaksOpen(false);
    };
    window.addEventListener('message', onMsg);
    window.parent.postMessage({ type: '__edit_mode_available' }, '*');
    return () => window.removeEventListener('message', onMsg);
  }, []);

  useEffect(() => { document.documentElement.setAttribute('data-theme', tweaks.theme); }, [tweaks.theme]);
  useEffect(() => { document.documentElement.setAttribute('data-accent', tweaks.accent); }, [tweaks.accent]);
  useEffect(() => { directionsRef.current = directions; }, [directions]);
  useEffect(() => {
    if (!directions.length || arDefaultDirectionsLoadedRef.current) return;
    const allDirectionIds = directions.map(direction => direction.id).filter(Boolean);
    if (!allDirectionIds.length) return;
    setArPickedDirections(new Set(allDirectionIds));
    setArNumDirections(String(allDirectionIds.length));
    arDefaultDirectionsLoadedRef.current = true;
  }, [directions]);

  const refreshAccessRequestCount = async () => {
    if (!authState.user?.is_admin) {
      setPendingAccessCount(0);
      return;
    }
    try {
      const accessCounts = await RESILIA_API.accessRequestCounts();
      const nextCounts = accessCounts.counts || accessCounts || {};
      setPendingAccessCount(nextCounts.pending || 0);
    } catch (e) {
      console.warn('access request count fetch failed', e);
    }
  };

  const refreshDirectionCatalog = async () => {
    const [comps, dirs] = await Promise.all([
      RESILIA_API.listCompetitors(),
      RESILIA_API.listDirections(),
    ]);
    const loadedDirections = dirs || [];
    directionsRef.current = loadedDirections;
    setCompetitors(comps || []);
    setDirections(loadedDirections);
    return loadedDirections;
  };

  useEffect(() => {
    if (authState.status !== 'authenticated') return undefined;
    refreshAccessRequestCount();
  }, [authState.status, authState.user?.is_admin]);

  useEffect(() => {
    try {
      window.localStorage.setItem(TAB_STORAGE_KEY, tab);
    } catch {
      // localStorage can be unavailable in hardened/private browser contexts.
    }
  }, [tab]);

  useEffect(() => {
    if (authState.status === 'authenticated' && !authState.user?.is_admin && ['accessRequests', 'adminDirections', 'adminWinningCriteria', 'adminCosts', 'adminWorkspaces', 'adminUsers'].includes(tab)) {
      setTab('research');
    }
  }, [authState.status, authState.user?.is_admin, tab]);

  // Load competitors + directions + any cached session on mount
  useEffect(() => {
    if (authState.status !== 'authenticated') return undefined;
    (async () => {
      let loadedDirections = [];
      try {
        const [comps, dirs] = await Promise.all([
          RESILIA_API.listCompetitors(),
          RESILIA_API.listDirections(),
        ]);
        loadedDirections = dirs || [];
        directionsRef.current = loadedDirections;
        setCompetitors(comps);
        setDirections(loadedDirections);
      } catch (e) {
        showToast('Failed to load directions: ' + e.message, 'Error');
      }
      // Prime the Foreplay credit badge in the top bar. Other platforms stay
      // null until their backends land and wire a matching summary field.
      try {
        const s = await RESILIA_API.summary();
        if (s && typeof s.credits_remaining === 'number') {
          setPlatformCredits(pc => ({ ...pc, foreplay: s.credits_remaining }));
        }
      } catch (e) {
        console.warn('initial summary fetch failed', e);
      }
      // Restore last search from Postgres cache
      try {
        const cached = await RESILIA_API.getCache('last_search_results');
        const cachedAds = cached && cached.payload && Array.isArray(cached.payload.ads) ? cached.payload.ads : [];
        if (cachedAds.length) {
          const ads = cachedAds.map(a => ({ ...RESILIA_ADAPT.adaptSearchAd(a, 'foreplay'), source: 'search' }));
          setResearchAds(prev => [...prev.filter(a => a.source === 'autoresearch'), ...ads]);
        }
      } catch (e) {
        console.warn('restore search cache failed', e);
      }
      let restoredAutoresearchState = null;
      try {
        restoredAutoresearchState = await RESILIA_API.getAutoresearchState();
        const state = restoredAutoresearchState?.state || {};
        if (restoredAutoresearchState?.running) {
          const runId = state.run_id || `ar-restored-${Date.now()}`;
          autoresearchBatchIdRef.current = runId;
          autoresearchLoadedIdsRef.current = new Set();
          autoresearchCurrentIdsRef.current = new Set();
          setArRunning(true);
          setAutoresearchActive(true);
          setArSelected(new Set(state.selected || []));
          setLatestAutoresearchRun({
            id: runId,
            count: 0,
            status: 'running',
            labels: state.sources || [],
          });
          setArProgress({
            crawlState: state.status || 'running',
            directionsSatisfied: state.run_directions_done || 0,
            directionsTotal: state.run_directions_total || 0,
            platformAdsFetched: state.platform_ads_fetched || {},
          });
          if (arPollRef.current) clearInterval(arPollRef.current);
          arPollRef.current = setInterval(pollWinners, 4000);
        }
      } catch (e) {
        console.warn('restore autoresearch running state failed', e);
      }
      // Restore last autoresearch winners
      try {
        const restored = await loadAutoresearchWinnersPage({ reset: true, directionSource: loadedDirections });
        await loadAutoresearchHistory({ directionSource: loadedDirections });
        if (restored.ads.length) {
          setAutoresearchActive(true);
        }
        if (restoredAutoresearchState?.running) pollWinners();
      } catch (e) {
        console.warn('restore autoresearch cache failed', e);
      }
      try {
        await loadFilterPage({ reset: true });
        const filterState = await refreshFilterRunState();
        if (filterState?.running) startFilterStatePolling();
      } catch (e) {
        console.warn('restore filter state failed', e);
      }
      try {
        await loadRemodelPage({ reset: true });
        const remodelState = await refreshRemodelRunState();
        const restoreAdIds = remodelState?.state?.ad_ids || remodelState?.run?.state?.ad_ids || [];
        if (remodelState?.running && restoreAdIds.length) {
          if (remodelPollRef.current) clearInterval(remodelPollRef.current);
          remodelPollRef.current = setInterval(() => {
            pollRemodelJobs(restoreAdIds, { source: 'manual' });
            refreshRemodelRunState();
          }, 3000);
          pollRemodelJobs(restoreAdIds, { source: 'manual' });
        }
      } catch (e) {
        console.warn('restore remodel state failed', e);
      }
      setAutoRemodelRestoring(true);
      try {
        const auto = await RESILIA_API.getAutoRemodelState();
        let restoredAutoRemodel = false;
        if (auto && auto.status === 'ok' && auto.state) {
          syncAutoRemodelState(auto.state);
          restoredAutoRemodel = await hydrateAutoRemodelPickedState(auto.state, loadedDirections);
        }
        if (!restoredAutoRemodel) {
          const jobs = await RESILIA_API.listRemodelJobs({});
          const autoJobs = Array.isArray(jobs)
            ? jobs.filter(j => j.source === 'auto_remodel' && j.ad_id)
            : [];
          const restoredIds = [];
          const seenAutoAds = new Set();
          const readyAutoJobs = autoJobs.filter(j => j.status === 'done' && j.response_text);
          const pendingAutoJobs = autoJobs.filter(j => !(j.status === 'done' && j.response_text));
          [
            ...readyAutoJobs.slice().sort((a, b) => Number(b.id || 0) - Number(a.id || 0)),
            ...pendingAutoJobs.slice().sort((a, b) => Number(b.id || 0) - Number(a.id || 0)),
          ]
            .forEach(j => {
              if (seenAutoAds.has(j.ad_id)) return;
              seenAutoAds.add(j.ad_id);
              restoredIds.push(j.ad_id);
            });
          if (restoredIds.length) {
            const ids = restoredIds.slice(0, AUTO_REMODEL_COUNT_MAX);
            setArPickedIds(ids);
            arPickedIdsRef.current = ids;
            const ads = await loadAutoRemodelPage({ ids, directionSource: loadedDirections, reset: true });
            setArStage('done');
            setArTargetAds(ids.length);
            const idSet = new Set(ids);
            const restoredJobsForIds = autoJobs.filter(j => idSet.has(j.ad_id));
            const restoredDone = restoredJobsForIds.filter(j => j.status === 'done' || j.status === 'failed').length;
            setArVariantsDone(restoredDone);
            setArVariantsTotalOverride(restoredJobsForIds.length || null);
            setArLastEvent({ stage: 'done', picked_ad_ids: ids, picked_count: ids.length, target_ads: ids.length });
            setRemodelScripts(prev => {
              const next = { ...prev };
              ads.forEach(a => { if (!next[a.id]) next[a.id] = { status: 'pending', variants: [] }; });
              return next;
            });
          }
        }
        await restorePreviousAutoRemodelHistory({
          excludeIds: arPickedIdsRef.current,
          directionSource: loadedDirections,
        });
      } catch (e) {
        console.warn('restore auto-remodel state failed', e);
      } finally {
        setAutoRemodelRestoring(false);
      }
    })();
    return () => {
      if (arPollRef.current) clearInterval(arPollRef.current);
      if (filterPollRef.current) clearInterval(filterPollRef.current);
      if (remodelPollRef.current) clearInterval(remodelPollRef.current);
    };
  }, [authState.status]);

  const showToast = (msg, badge) => {
    setToast({ msg, badge });
    setTimeout(() => setToast(null), 3200);
  };

  const directionMetaMap = (items) => Object.fromEntries((items || []).map(d => {
    const keywords = Array.isArray(d.keywords) ? d.keywords.filter(Boolean) : [];
    const label = d.description || d.display_name || d.brand_name || d.id;
    const searchLabel = keywords.length
      ? keywords.slice(0, 4).join(', ')
      : (d.brand_name || d.display_name || label || d.id);
    return [d.id, { label, searchLabel }];
  }));

  const attachDirectionMeta = (ad, metaMap) => {
    const meta = metaMap[ad.directionId];
    ad.directionLabel = meta?.label || ad.directionId || null;
    ad.directionSearchLabel = meta?.searchLabel || ad.directionLabel || null;
    return ad;
  };

  const adaptAutoresearchRows = (rows, directionSource = directionsRef.current) => {
    const dirMap = directionMetaMap(directionSource);
    const ads = [];
    const seen = new Set();
    for (const row of rows || []) {
      if (!row?.ad_id || seen.has(row.ad_id)) continue;
      seen.add(row.ad_id);
      const adapted = RESILIA_ADAPT.adaptEvaluatedAd(row);
      attachDirectionMeta(adapted, dirMap);
      ads.push({ ...adapted, source: 'autoresearch' });
    }
    return ads;
  };

  const mergeRecentAds = (incoming, existing = []) => {
    const out = [];
    const seen = new Set();
    [...(incoming || []), ...(existing || [])].forEach(ad => {
      if (!ad?.id || seen.has(ad.id)) return;
      seen.add(ad.id);
      out.push(ad);
    });
    return out;
  };

  const appendOlderAds = (existing = [], incoming = []) => {
    const out = [];
    const seen = new Set();
    [...(existing || []), ...(incoming || [])].forEach(ad => {
      if (!ad?.id || seen.has(ad.id)) return;
      seen.add(ad.id);
      out.push(ad);
    });
    return out;
  };

  const pipelineFallbackAd = (id, source = 'manual') => ({
    id,
    brand: id || 'Recovered ad',
    platform: 'foreplay',
    copy: '',
    score: null,
    reasoning: null,
    daysRunning: 0,
    views: '—',
    thumb: RESILIA_ADAPT.gradFor(id || source),
    duration: '',
    scrapedAt: '—',
    cta: null,
    transcript: [],
    transcriptRaw: '',
    source,
    raw: { ad_id: id, source },
  });

  const adaptPipelineRows = (rows, source = 'manual') => (rows || [])
    .filter(row => row?.ad_id)
    .map(row => ({
      ...RESILIA_ADAPT.adaptEvaluatedAd(row),
      source,
      filterVerdict: row.filter_verdict || null,
      pipelineAt: row.filter_verdict?.filtered_at || row.saved_at || null,
    }));

  const remodelEntryFromJobs = (jobs) => {
    const rows = Array.isArray(jobs) ? jobs : [];
    if (!rows.length) return { status: 'pending', variants: [] };
    const done = rows.filter(j => j.status === 'done' && j.response_text);
    const failed = rows.filter(j => j.status === 'failed');
    if (done.length > 0) {
      return {
        status: 'ready',
        variants: done
          .slice()
          .sort((a, b) => (a.variant_idx || 0) - (b.variant_idx || 0))
          .map(j => ({
            jobId: j.id,
            variantIdx: j.variant_idx || 0,
            raw: j.response_text,
            lines: parseRemodelScript(j.response_text),
            promotedAt: j.promoted_at || null,
          })),
      };
    }
    if (failed.length === rows.length) {
      return {
        status: 'error',
        variants: [],
        error: failed[0]?.error || 'Generation failed',
      };
    }
    return { status: 'pending', variants: [] };
  };

  const groupJobsByAd = (jobs) => {
    const grouped = new Map();
    (jobs || []).forEach(job => {
      if (!job?.ad_id) return;
      if (!grouped.has(job.ad_id)) grouped.set(job.ad_id, []);
      grouped.get(job.ad_id).push(job);
    });
    return grouped;
  };

  const loadAutoresearchWinnersPage = async ({
    reset = false,
    offset = null,
    directionSource = directionsRef.current,
  } = {}) => {
    const perDir = boundedPositiveIntOrNull(arTarget) || 1;
    const pageOffset = offset === null
      ? (reset ? 0 : autoresearchLoadedIdsRef.current.size)
      : Math.max(0, parseInt(offset, 10) || 0);
    setAutoresearchPage(prev => ({ ...prev, loading: true }));
    try {
      const page = await RESILIA_API.autoresearchWinners(
        AUTORESEARCH_PAGE_SIZE,
        perDir,
        { offset: pageOffset, paged: true },
      );
      const rows = Array.isArray(page) ? page : (Array.isArray(page?.ads) ? page.ads : []);
      const adsRaw = adaptAutoresearchRows(rows, directionSource);
      const currentBatchId = autoresearchBatchIdRef.current || 'latest-autoresearch';
      const ads = adsRaw.map(ad => ({
        ...ad,
        _arBatchId: currentBatchId,
        currentAutoresearchRun: true,
        currentRunLabel: 'Current',
      }));
      const loadedIds = reset ? new Set() : new Set(autoresearchLoadedIdsRef.current);
      const currentIds = reset ? new Set() : new Set(autoresearchCurrentIdsRef.current);
      ads.forEach(ad => loadedIds.add(ad.id));
      ads.forEach(ad => currentIds.add(ad.id));
      autoresearchLoadedIdsRef.current = loadedIds;
      autoresearchCurrentIdsRef.current = currentIds;
      setResearchAds(prev => {
        const searchAds = prev.filter(a => a.source !== 'autoresearch');
        const priorAutoAds = prev
          .filter(a => (
            a.source === 'autoresearch'
            && (!a.currentAutoresearchRun || (a._arBatchId && a._arBatchId !== currentBatchId))
          ))
          .map(a => ({ ...a, currentAutoresearchRun: false, currentRunLabel: null }));
        const currentAutoAds = reset
          ? []
          : prev.filter(a => (
              a.source === 'autoresearch'
              && a.currentAutoresearchRun
              && (a._arBatchId || currentBatchId) === currentBatchId
            ));
        const byId = new Map(currentAutoAds.map(ad => [ad.id, ad]));
        ads.forEach(ad => byId.set(ad.id, ad));
        // Layout: search ads on top (Bug 11 quick fix), then current batch
        // of autoresearch winners, then older autoresearch batches as
        // history beneath.
        return [...searchAds, ...Array.from(byId.values()), ...priorAutoAds];
      });
      const hasMore = Array.isArray(page) ? ads.length >= AUTORESEARCH_PAGE_SIZE : !!page?.has_more;
      const total = Array.isArray(page) ? null : (page?.total ?? null);
      const nextState = { loaded: loadedIds.size, total, hasMore, loading: false };
      setAutoresearchPage(nextState);
      setLatestAutoresearchRun(prev => ({
        id: currentBatchId,
        count: currentIds.size,
        total: total ?? prev?.total ?? null,
        status: prev?.status || 'restored',
        labels: prev?.labels || [],
      }));
      if (loadedIds.size > 0) setAutoresearchActive(true);
      return { ads, ...nextState };
    } finally {
      setAutoresearchPage(prev => ({ ...prev, loading: false }));
    }
  };

  const loadAutoresearchHistory = async ({
    directionSource = directionsRef.current,
    limit = 500,
  } = {}) => {
    const page = await RESILIA_API.autoresearchHistory(limit);
    const rows = Array.isArray(page) ? page : (Array.isArray(page?.ads) ? page.ads : []);
    const historyAds = adaptAutoresearchRows(rows, directionSource).map(ad => ({
      ...ad,
      _arBatchId: ad._arBatchId || 'history',
      currentAutoresearchRun: false,
      currentRunLabel: null,
    }));
    if (!historyAds.length) return [];
    setResearchAds(prev => {
      const existingIds = new Set(prev.map(ad => ad.id));
      const newHistory = historyAds.filter(ad => !existingIds.has(ad.id));
      if (!newHistory.length) return prev;
      const searchAds = prev.filter(ad => ad.source !== 'autoresearch');
      const autoAds = prev.filter(ad => ad.source === 'autoresearch');
      return [...searchAds, ...autoAds, ...newHistory];
    });
    setAutoresearchActive(true);
    return historyAds;
  };

  const loadFilterPage = async ({ reset = false } = {}) => {
    const offset = reset ? 0 : filterPage.loaded;
    setFilterPage(prev => ({ ...prev, loading: true }));
    try {
      const rows = await RESILIA_API.listFilterVerdicts({
        passesOnly: false,
        limit: PIPELINE_PAGE_SIZE,
        offset,
      });
      const ads = adaptPipelineRows(rows, 'filter');
      setFilteredAds(prev => reset ? mergeRecentAds(ads, []) : appendOlderAds(prev, ads));
      const nextState = {
        loaded: offset + rows.length,
        hasMore: rows.length >= PIPELINE_PAGE_SIZE,
        loading: false,
      };
      setFilterPage(nextState);
      return { ads, ...nextState };
    } finally {
      setFilterPage(prev => ({ ...prev, loading: false }));
    }
  };

  const loadRemodelPage = async ({ reset = false } = {}) => {
    const offset = reset ? 0 : remodelPage.loaded;
    setRemodelPage(prev => ({ ...prev, loading: true }));
    try {
      const jobs = await RESILIA_API.listRemodelJobs({
        source: 'manual',
        limit: PIPELINE_PAGE_SIZE,
        offset,
      });
      const grouped = groupJobsByAd(jobs);
      const adIds = [...grouped.keys()];
      const swipeRows = adIds.length ? await RESILIA_API.getSwipesBatch(adIds) : [];
      const rowsById = new Map((swipeRows || []).map(row => [row.ad_id, row]));
      const ads = adIds.map(id => {
        const row = rowsById.get(id);
        return row ? { ...RESILIA_ADAPT.adaptEvaluatedAd(row), source: 'remodel' } : pipelineFallbackAd(id, 'remodel');
      });
      setRemodelScripts(prev => {
        const next = { ...prev };
        grouped.forEach((rows, adId) => { next[adId] = remodelEntryFromJobs(rows); });
        return next;
      });
      setRemodeledAds(prev => reset ? mergeRecentAds(ads, []) : appendOlderAds(prev, ads));
      const nextState = {
        loaded: offset + jobs.length,
        hasMore: jobs.length >= PIPELINE_PAGE_SIZE,
        loading: false,
      };
      setRemodelPage(nextState);
      return { ads, ...nextState };
    } finally {
      setRemodelPage(prev => ({ ...prev, loading: false }));
    }
  };

  const refreshFilterRunState = async () => {
    const state = await RESILIA_API.getFilterState();
    if (state) {
      setFilterRunState(state);
      setFilterBusy(!!state.running);
      if (!state.running && filterPollRef.current) {
        clearInterval(filterPollRef.current);
        filterPollRef.current = null;
      }
    }
    return state;
  };

  const startFilterStatePolling = () => {
    if (filterPollRef.current) clearInterval(filterPollRef.current);
    filterPollRef.current = setInterval(async () => {
      try {
        await Promise.all([
          loadFilterPage({ reset: true }),
          refreshFilterRunState(),
        ]);
      } catch (e) {
        console.warn('filter state poll failed', e);
      }
    }, 4000);
  };

  const refreshRemodelRunState = async () => {
    const state = await RESILIA_API.getRemodelState();
    if (state) {
      setRemodelRunState(state);
      setRemodelBusy(!!state.running);
    }
    return state;
  };

  const autoRemodelFallbackAd = (id, job = {}) => ({
    id,
    brand: job.ad_id || id,
    platform: job.platform || 'foreplay',
    copy: '',
    score: null,
    reasoning: null,
    daysRunning: 0,
    directionLabel: 'Auto-Remodel',
    directionSearchLabel: 'Auto-Remodel',
    transcript: [],
    transcriptRaw: null,
    source: 'auto_remodel',
  });

  const syncAutoRemodelState = (state) => {
    if (!state) return;
    setArRunId(state.run_id || null);
    arRunIdRef.current = state.run_id || null;
    setArStage(state.stage || null);
    arStageRef.current = state.stage || null;
    setArLastEvent({ ...state, run_id: state.run_id, stage: state.stage });
    if (typeof state.directions_done === 'number') setArDirectionsDone(state.directions_done);
    if (typeof state.directions_total === 'number') setArDirectionsTotal(state.directions_total);
    if (typeof state.ads_found === 'number') setArAdsFound(state.ads_found);
    if (typeof state.target_ads === 'number') {
      setArTargetAds(state.target_ads);
      arTargetAdsRef.current = state.target_ads;
    }
    if (typeof state.n_variants === 'number') {
      setArNVariants(state.n_variants);
      arNVariantsRef.current = state.n_variants;
    }
    if (Array.isArray(state.picked_ad_ids)) {
      setArPickedIds(state.picked_ad_ids);
      arPickedIdsRef.current = state.picked_ad_ids;
    }
  };

  const searchFiltersPayload = (filters = {}) => {
    const out = {};
    const minLikes = parseInt(filters.minLikes, 10);
    const minDailyLikes = parseInt(filters.minDailyLikes, 10);
    const adspyAgeMin = parseInt(filters.adspyAgeMin, 10);
    const adspyAgeMax = parseInt(filters.adspyAgeMax, 10);
    const adspyUserId = parseInt(filters.adspyUserId, 10);
    const adspyAffNetwork = parseInt(filters.adspyAffNetwork, 10);
    const fpMinDays = parseInt(filters.fpMinDays, 10);
    const bsMinViews = parseInt(filters.bsMinViews, 10);
    const bsMinLikes = parseInt(filters.bsMinLikes, 10);
    const bsMinEngagement = parseFloat(filters.bsMinEngagement);
    if (filters.fpFormat) out.foreplay_display_format = [filters.fpFormat];
    if ((filters.fpPublisherPlatforms || '').trim()) out.foreplay_publisher_platform = filters.fpPublisherPlatforms.split(',').map(s => s.trim()).filter(Boolean);
    if ((filters.fpLanguages || '').trim()) out.foreplay_languages = filters.fpLanguages.split(',').map(s => s.trim()).filter(Boolean);
    if (filters.fpLiveStatus === 'live') out.foreplay_live = true;
    else if (filters.fpLiveStatus === 'inactive') out.foreplay_live = false;
    if (Number.isFinite(fpMinDays) && fpMinDays > 0) out.foreplay_running_duration_min_days = fpMinDays;
    if (filters.fpOrder) out.foreplay_order = filters.fpOrder;
    if (Number.isFinite(minLikes) && minLikes > 0) out.adspy_min_likes = minLikes;
    if (Number.isFinite(minDailyLikes) && minDailyLikes > 0) out.adspy_min_daily_likes = minDailyLikes;
    if (filters.orderBy) out.adspy_order = filters.orderBy;
    if (filters.adspySiteType) out.adspy_site_type = filters.adspySiteType;
    if (filters.adspyMediaType) out.adspy_media_type = filters.adspyMediaType;
    if (filters.adspyGender) out.adspy_gender = filters.adspyGender;
    if (Number.isFinite(adspyAgeMin) && adspyAgeMin > 0) out.adspy_age_min = adspyAgeMin;
    if (Number.isFinite(adspyAgeMax) && adspyAgeMax > 0) out.adspy_age_max = adspyAgeMax;
    if ((filters.adspyCountries || '').trim()) out.countries = filters.adspyCountries.split(',').map(s => s.trim()).filter(Boolean);
    if ((filters.adspyLang || '').trim()) out.languages = [filters.adspyLang.trim()];
    if ((filters.adspyCreatedFrom || '').trim()) out.adspy_created_from = filters.adspyCreatedFrom.trim();
    if ((filters.adspyCreatedTo || '').trim()) out.adspy_created_to = filters.adspyCreatedTo.trim();
    if ((filters.adspySeenFrom || '').trim()) out.adspy_seen_from = filters.adspySeenFrom.trim();
    if ((filters.adspySeenTo || '').trim()) out.adspy_seen_to = filters.adspySeenTo.trim();
    if ((filters.adspyUsername || '').trim()) out.adspy_username = filters.adspyUsername.trim();
    if (Number.isFinite(adspyUserId) && adspyUserId > 0) out.adspy_user_id = adspyUserId;
    if (Number.isFinite(adspyAffNetwork) && adspyAffNetwork > 0) out.adspy_aff_network = adspyAffNetwork;
    if ((filters.adspyAffId || '').trim()) out.adspy_aff_id = filters.adspyAffId.trim();
    if ((filters.adspyOfferId || '').trim()) out.adspy_offer_id = filters.adspyOfferId.trim();
    if ((filters.adspyButtons || '').trim()) out.adspy_buttons = filters.adspyButtons.trim();
    if ((filters.adspyLandingUrl || '').trim()) out.adspy_url_search_type = 'lp_urls';
    else if ((filters.adspyUrl || '').trim()) out.adspy_url_search_type = 'urls';
    if (filters.bsPlatform) out.brandsearch_platforms = [filters.bsPlatform];
    if (Number.isFinite(bsMinViews) && bsMinViews > 0) out.brandsearch_min_views = bsMinViews;
    if (Number.isFinite(bsMinLikes) && bsMinLikes > 0) out.brandsearch_min_likes = bsMinLikes;
    if (Number.isFinite(bsMinEngagement) && bsMinEngagement > 0) out.brandsearch_min_engagement_rate = bsMinEngagement;
    if (filters.bsSort) out.brandsearch_sort = filters.bsSort;
    return Object.keys(out).length ? out : null;
  };

  const searchSourceUrlsPayload = (filters = {}) => {
    const out = {};
    const foreplayUrl = (filters.foreplayUrl || '').trim();
    const adspyUrl = (filters.adspyUrl || '').trim();
    const adspyLandingUrl = (filters.adspyLandingUrl || '').trim();
    const brandsearchUrl = (filters.brandsearchUrl || '').trim();
    if (foreplayUrl) out.foreplay = foreplayUrl;
    if (adspyUrl || adspyLandingUrl) out.adspy = adspyUrl || adspyLandingUrl;
    if (brandsearchUrl) out.brandsearch = brandsearchUrl;
    return Object.keys(out).length ? out : null;
  };

  const expandAd = (ad) => {
    setExpandedAd(ad);
    if (!ad) return;
    // Bug: the dashboard used to only hydrate transcripts for BrandSearch
    // ads. AdSpy and Foreplay video ads now also Whisper-transcribe on
    // demand server-side (search_service.get_ad_transcript), so trigger the
    // same hydration whenever the card has no real transcript yet.
    const hasTranscript = (ad.transcriptRaw && String(ad.transcriptRaw).trim())
      || (Array.isArray(ad.transcript) && ad.transcript.length > 0);
    if (hasTranscript) return;
    RESILIA_API.fetchTranscript(ad.id).then(detail => {
      if (!detail || !detail.transcript) return;
      const patch = (item) => {
        if (item.id !== ad.id) return item;
        const adapted = RESILIA_ADAPT.adaptSearchAd({
          ...(item.raw || {}),
          ad_id: item.id,
          brand: item.brand,
          platform: item.publisherPlatform,
          source_platform: item.platform,
          score: item.score,
          reasoning: item.reasoning,
          running_days: item.daysRunning,
          creative_url: item.creativeUrl,
          thumbnail_url: item.thumbnailUrl,
          transcript: detail.transcript,
          raw: { ...(item.raw || {}), ...(detail.raw || {}) },
          metrics: item.raw?.metrics || detail.raw?.metrics || {},
        }, item.platform);
        adapted.directionId = item.directionId;
        adapted.directionLabel = item.directionLabel;
        adapted.directionSearchLabel = item.directionSearchLabel;
        return { ...item, ...adapted, source: item.source };
      };
      setResearchAds(prev => prev.map(patch));
      setFilteredAds(prev => prev.map(patch));
      setRemodeledAds(prev => prev.map(patch));
      setAutoRemodelAds(prev => prev.map(patch));
      setExpandedAd(prev => prev && prev.id === ad.id ? patch(prev) : prev);
    }).catch(e => console.warn('brandsearch transcript hydration failed', e));
  };

  // ---- Manual search ------------------------------------------------------
  const manualSearchLabelForRow = (row) => {
    if (row.competitorId) {
      if (row.competitorId === MANUAL_SEARCH_ALL_COMPETITORS_ID) return 'All competitors';
      const competitor = competitors.find(c => String(c.id) === String(row.competitorId));
      return competitor?.name || row.competitorId;
    }
    const keyword = String(row.query || '').trim();
    if (keyword) return keyword;
    const urls = searchSourceUrlsPayload(row.filters);
    if (urls?.foreplay_url) return urls.foreplay_url;
    if (urls?.adspy_url) return urls.adspy_url;
    if (urls?.adspy_landing_url) return urls.adspy_landing_url;
    if (urls?.brandsearch_url) return urls.brandsearch_url;
    return null;
  };

  const runSearchRows = async (targetRows, { global = false } = {}) => {
    const activeRows = (targetRows || []).filter(row => {
      const sourceUrls = searchSourceUrlsPayload(row.filters);
      return row.query || row.competitorId || sourceUrls;
    });
    if (!activeRows.length) {
      showToast('No searchable row configured. Add a keyword, competitor, or source URL.', 'Search');
      return [];
    }
    const activeRowIds = activeRows.map(row => row.id).filter(id => id !== null && id !== undefined);
    const searchBatchId = `manual_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
    const searchRunLabels = [...new Set(activeRows.map(manualSearchLabelForRow).filter(Boolean))];
    if (global) setSearching(true);
    setSearchingRowIds(prev => {
      const next = new Set(prev);
      activeRowIds.forEach(id => next.add(id));
      return next;
    });
    if (global) {
      setResearchAds(prev => prev.map(ad => (
        ad.source === 'autoresearch' ? ad : { ...ad, currentSearchRun: false }
      )));
    }
    setSearchWarnings([]);
    const collectedWarnings = [];
    const rowResults = new Map();
    const updateSearchSummary = () => {
      const count = [...rowResults.values()].reduce((n, ads) => n + ads.length, 0);
      setLatestSearchRun({ id: searchBatchId, count, labels: searchRunLabels });
    };
    const runOneRow = async (row) => {
      const rowKey = row.id ?? `${searchBatchId}_${rowResults.size}`;
      const rowRunId = `${searchBatchId}_${rowKey}`;
      const sourceUrls = searchSourceUrlsPayload(row.filters);
      const searchReturnLabel = manualSearchLabelForRow(row);
      const platforms = Object.keys(row.platforms).filter(k => row.platforms[k]);
      const payload = {
        query: row.query,
        competitorId: row.competitorId,
        count: Math.min(row.count, 100),
        platforms,
        filters: searchFiltersPayload(row.filters),
        sourceUrls,
      };
      try {
        let resp = await RESILIA_API.search(payload);
        if (resp && resp.status === 'started' && resp.run_id && RESILIA_API.waitForSearchRun) {
          resp = await RESILIA_API.waitForSearchRun(resp.run_id);
        }
        const ads = (resp.ads || []).map(a => ({
          ...RESILIA_ADAPT.adaptSearchAd(a, 'foreplay'),
          source: 'search',
          searchReturnLabel,
          searchRunId: rowRunId,
          searchBatchId,
          searchRowId: rowKey,
          currentSearchRun: true,
        }));
        const seen = new Set();
        const deduped = [];
        for (const ad of ads) {
          if (seen.has(ad.id)) continue;
          seen.add(ad.id);
          deduped.push(ad);
        }
        rowResults.set(rowKey, deduped);
        setResearchAds(prev => {
          const autoresearchAds = prev.filter(a => a.source === 'autoresearch');
          const priorSearchAds = prev.filter(a => a.source !== 'autoresearch');
          const newIds = new Set(deduped.map(a => a.id));
          const remainingPrior = priorSearchAds
            .filter(a => !newIds.has(a.id))
            .map(a => (
              a.searchRowId === rowKey
                ? { ...a, currentSearchRun: false }
                : a
            ));
          return [...deduped, ...remainingPrior, ...autoresearchAds];
        });
        updateSearchSummary();
        if (resp && typeof resp.credits_remaining === 'number') {
          setPlatformCredits(pc => ({ ...pc, foreplay: resp.credits_remaining }));
        }
        if (resp && Array.isArray(resp.warnings) && resp.warnings.length) {
          for (const w of resp.warnings) {
            if (w && typeof w === 'object') {
              collectedWarnings.push({ ...w, query: row.query || null });
            }
          }
          const labels = resp.warnings.map(w => w.source).filter(Boolean).join(', ');
          showToast(`Search completed with ${labels || resp.warnings.length} warning(s).`, 'Warn');
        }
        return deduped;
      } catch (e) {
        rowResults.set(rowKey, []);
        updateSearchSummary();
        showToast(`Search failed for ${row.query || row.competitorId}: ${e.message}`, 'Error');
        return [];
      } finally {
        if (row.id !== null && row.id !== undefined) {
          setSearchingRowIds(prev => {
            const next = new Set(prev);
            next.delete(row.id);
            return next;
          });
        }
      }
    };
    try {
      const results = (await Promise.all(activeRows.map(runOneRow))).flat();
      // dedupe by ad id across rows
      const seen = new Set();
      const deduped = [];
      for (const a of results) {
        if (seen.has(a.id)) continue;
        seen.add(a.id);
        deduped.push(a);
      }
      setLatestSearchRun({ id: searchBatchId, count: deduped.length, labels: searchRunLabels });
      if (deduped.length === 0) {
        showToast('No ads returned. Check your query or competitor.', 'Search');
      } else {
        showToast(`${deduped.length} ads fetched (scored).`, 'Search');
      }
      setSearchWarnings(collectedWarnings);
      return deduped;
    } finally {
      if (global) setSearching(false);
      setSearchingRowIds(prev => {
        const next = new Set(prev);
        activeRowIds.forEach(id => next.delete(id));
        return next;
      });
    }
  };

  const runSearch = async () => {
    await runSearchRows(rows, { global: true });
  };

  const runSearchRow = async (rowId) => {
    const row = rows.find(item => item.id === rowId);
    if (!row) return;
    await runSearchRows([row]);
  };

  // ---- Autoresearch -------------------------------------------------------
  const pollWinners = async () => {
    try {
      // Checkpoint-aware: only returns winners evaluated since the last
      // /api/autoresearch/checkpoint call, so previous runs never leak in.
      const page = await loadAutoresearchWinnersPage({
        reset: autoresearchLoadedIdsRef.current.size === 0,
        offset: 0,
        directionSource: directionsRef.current,
      });
      const loadedWinners = page.loaded ?? autoresearchLoadedIdsRef.current.size;

      const summary = await RESILIA_API.summary();
      if (summary) {
        const runTotal = summary.run_directions_total || 0;
        const runTotalScored = runTotal > 0
          ? (summary.run_total_scored ?? 0)
          : summary.total_scored;
        setArProgress({
          totalScored: runTotalScored,
          winners: loadedWinners,
          totalWinners: runTotal > 0 ? (summary.run_winners ?? loadedWinners) : summary.winners,
          // Prefer the per-run counter while a crawl is active so the bar
          // tracks "directions finished this run", not lifetime DB state.
          directionsSatisfied: runTotal > 0 ? summary.run_directions_done : summary.directions_satisfied,
          directionsTotal: runTotal > 0 ? runTotal : summary.directions_total,
          crawlState: summary.crawl_state,
          creditsRemaining: summary.credits_remaining,
          platformAdsFetched: summary.platform_ads_fetched || {},
        });
        if (typeof summary.credits_remaining === 'number') {
          setPlatformCredits(pc => ({ ...pc, foreplay: summary.credits_remaining }));
        }
      }
      if (summary && summary.crawl_state !== 'idle') {
        setLatestAutoresearchRun(prev => prev ? ({
          ...prev,
          status: 'running',
          count: autoresearchCurrentIdsRef.current.size,
        }) : prev);
      }
      if (summary && summary.crawl_state === 'idle') {
        setArRunning(false);
        setLatestAutoresearchRun(prev => prev ? ({ ...prev, status: 'done', count: autoresearchCurrentIdsRef.current.size }) : prev);
        if (arPollRef.current) { clearInterval(arPollRef.current); arPollRef.current = null; }
        showToast(`Autoresearch complete. ${loadedWinners} winners loaded.`, 'Autoresearch');
      }
    } catch (e) {
      console.warn('poll winners failed', e);
    }
  };

  const runAutoresearch = async () => {
    const directionPool = directions;
    if (directionPool.length === 0) {
      showToast('No directions configured for the selected sources.', 'Autoresearch');
      return;
    }
    // Direction selection: explicit checkbox picks win; otherwise run the
    // full direction pool. Autoresearch should never silently sample only a
    // few directions when the user expects the whole catalog.
    let ids;
    if (arPickedDirections.size > 0) {
      ids = directions.filter(d => arPickedDirections.has(d.id)).map(d => d.id);
    } else {
      ids = directionPool.map(d => d.id);
      setArPickedDirections(new Set(ids));
      setArNumDirections(String(ids.length));
    }
    if (ids.length === 0) {
      showToast('No directions resolved — check the picker or the cap.', 'Autoresearch');
      return;
    }
    const sources = Object.keys(arSources).filter(k => arSources[k]);
    if (sources.length === 0) {
      showToast('Pick at least one source (Foreplay, AdSpy, …).', 'Autoresearch');
      return;
    }
    setArSelected(new Set(ids));
    setAutoresearchActive(true);
    setArProgress({ crawlState: 'starting', winners: 0, totalScored: 0, directionsSatisfied: 0, directionsTotal: ids.length * sources.length });
    // Bug retention — keep prior autoresearch winners on screen as a history
    // strip below the new batch. Stamp untagged old ads with a 'previous'
    // batch id, then bump the current batch id so new ads land on top.
    setResearchAds(prev => prev.map(ad => (
      ad.source === 'autoresearch' && !ad._arBatchId
        ? { ...ad, _arBatchId: 'previous', currentAutoresearchRun: false, currentRunLabel: null }
        : ad.source === 'autoresearch'
          ? { ...ad, currentAutoresearchRun: false, currentRunLabel: null }
          : ad
    )));
    const newBatchId = `ar-${Date.now()}`;
    autoresearchBatchIdRef.current = newBatchId;
    autoresearchLoadedIdsRef.current = new Set();
    autoresearchCurrentIdsRef.current = new Set();
    setAutoresearchPage({ loaded: 0, total: null, hasMore: false, loading: false });
    setLatestAutoresearchRun({
      id: newBatchId,
      count: 0,
      total: null,
      status: 'starting',
      labels: sources,
    });
    try {
      const perDirectionTarget = boundedPositiveIntOrNull(arTarget);
      if (arTarget && String(perDirectionTarget) !== arTarget) {
        setArTarget(perDirectionTarget === null ? '' : String(perDirectionTarget));
      }
      const maxAdsPerDirection = boundedPositiveIntOrNull(
        arMaxAdsPerDirection,
        SEARCH_ADS_PER_DIRECTION_MAX,
      );
      const maxAdsPerPlatform = boundedPositiveIntOrNull(
        arMaxAdsPerPlatform,
        SEARCH_ADS_PER_PLATFORM_MAX,
      );
      // Mark a fresh checkpoint on the backend — winners from prior runs
      // won't show up in /api/autoresearch/winners anymore.
      await RESILIA_API.setAutoresearchCheckpoint({
        directionIds: ids,
        perDirectionTarget,
        platforms: sources,
        maxAdsPerDirection,
        maxAdsPerPlatform,
      });
      await RESILIA_API.startAutoresearch({
        directionIds: ids,
        perDirectionTarget,
        platforms: sources,
        maxAdsPerDirection,
        maxAdsPerPlatform,
        force: true,
      });
      setArRunning(true);
      setLatestAutoresearchRun(prev => prev ? ({ ...prev, status: 'running' }) : prev);
      showToast(`Autoresearch started for ${ids.length} direction(s) across ${sources.length} source(s).`, 'Autoresearch');
      if (arPollRef.current) clearInterval(arPollRef.current);
      arPollRef.current = setInterval(pollWinners, 4000);
      pollWinners();
    } catch (e) {
      showToast('Autoresearch failed to start: ' + e.message, 'Error');
    }
  };

  const loadMoreAutoresearchWinners = async () => {
    try {
      await loadAutoresearchWinnersPage();
    } catch (e) {
      showToast('Failed to load more Autoresearch winners: ' + e.message, 'Error');
    }
  };

  const stopAutoresearch = async () => {
    try {
      await RESILIA_API.stopAutoresearch();
      setArRunning(false);
      setLatestAutoresearchRun(prev => prev ? ({ ...prev, status: 'stopping' }) : prev);
      if (arPollRef.current) { clearInterval(arPollRef.current); arPollRef.current = null; }
      showToast('Autoresearch stopping…', 'Autoresearch');
    } catch (e) {
      showToast('Failed to stop: ' + e.message, 'Error');
    }
  };

  // ---- Auto-Remodel orchestrator ----------------------------------------
  // Once the orchestrator picks ads, hydrate the swipe rows for them and
  // push them through the existing Research → Filter → Remodel views as the
  // pipeline transitions stages. This reuses the same panels the manual
  // flow uses (no duplicate UI to maintain).
  const hydratePickedAds = async (adIds, directionSource = directionsRef.current) => {
    if (!adIds || adIds.length === 0) return [];
    const dirMap = directionMetaMap(directionSource);
    const uniqueIds = [...new Set(adIds.filter(Boolean))];
    const getSwipeWithRetry = async (id) => {
      for (let attempt = 0; attempt < 6; attempt += 1) {
        const swipe = await RESILIA_API.getSwipe(id);
        if (swipe) return swipe;
        await new Promise(resolve => setTimeout(resolve, 500));
      }
      return null;
    };
    const getSwipesWithRetry = async (ids) => {
      const rowsById = new Map();
      try {
        for (let attempt = 0; attempt < 6 && rowsById.size < ids.length; attempt += 1) {
          const missingIds = ids.filter(id => !rowsById.has(id));
          const rows = await RESILIA_API.getSwipesBatch(missingIds);
          rows.forEach(row => { if (row?.ad_id) rowsById.set(row.ad_id, row); });
          if (rowsById.size >= ids.length) break;
          await new Promise(resolve => setTimeout(resolve, 500));
        }
        return ids.map(id => rowsById.get(id)).filter(Boolean);
      } catch (e) {
        console.warn('swipe batch hydration failed; falling back to single-row fetches', e);
        return Promise.all(ids.map(getSwipeWithRetry));
      }
    };
    const swipeRows = await getSwipesWithRetry(uniqueIds);
    const rows = await Promise.all(swipeRows.map(async (swipe) => {
      const id = swipe?.ad_id;
      try {
        if (!swipe) return null;
        let row = swipe;
        if (!row.transcript) {
          const detail = await RESILIA_API.fetchTranscript(id);
          if (detail && detail.transcript) {
            row = { ...row, transcript: detail.transcript, raw: { ...(row.raw || {}), ...(detail.raw || {}) } };
          }
        }
        // ``adaptEvaluatedAd`` expects evaluated_ads-shaped rows; swipe rows
        // have the same column names for everything we need (ad_id, brand,
        // platform, transcript, score, reasoning, running_days, raw, …).
        const adapted = RESILIA_ADAPT.adaptEvaluatedAd(row);
        attachDirectionMeta(adapted, dirMap);
        return { ...adapted, source: 'auto_remodel' };
      } catch (e) {
        console.warn('hydratePickedAds failed for', id, e);
        return null;
      }
    }));
    return rows.filter(Boolean);
  };

  const restorePreviousAutoRemodelHistory = async ({
    excludeIds = arPickedIdsRef.current,
    directionSource = directionsRef.current,
    limit = 200,
  } = {}) => {
    const excluded = new Set((excludeIds || []).filter(Boolean));
    const jobs = await RESILIA_API.listRemodelJobs({
      source: 'auto_remodel',
      limit,
    });
    const historyIds = [];
    const seen = new Set();
    (Array.isArray(jobs) ? jobs : []).forEach(job => {
      const id = job?.ad_id;
      if (!id || excluded.has(id) || seen.has(id)) return;
      seen.add(id);
      historyIds.push(id);
    });
    if (!historyIds.length) return [];
    const historyAds = (await hydratePickedAds(historyIds, directionSource)).map(ad => ({
      ...ad,
      _arRunId: ad._arRunId || 'previous',
      currentAutoRemodelRun: false,
    }));
    if (!historyAds.length) return [];
    setAutoRemodelAds(prev => {
      const existingIds = new Set(prev.map(ad => ad.id));
      const additions = historyAds.filter(ad => !existingIds.has(ad.id));
      return additions.length ? [...prev, ...additions] : prev;
    });
    setRemodelScripts(prev => {
      const next = { ...prev };
      historyAds.forEach(a => { if (!next[a.id]) next[a.id] = { status: 'pending', variants: [] }; });
      return next;
    });
    pollRemodelJobs(historyAds.map(ad => ad.id), autoRemodelJobOptions({ markMissingAsError: true }));
    return historyAds;
  };

  const loadAutoRemodelPage = async ({
    ids = arPickedIdsRef.current,
    directionSource = directionsRef.current,
    reset = false,
    pageSize = AUTO_REMODEL_PAGE_SIZE,
  } = {}) => {
    const allIds = [...new Set((ids || []).filter(Boolean))];
    if (!allIds.length) return [];
    if (autoRemodelHydratingPageRef.current) return [];
    autoRemodelHydratingPageRef.current = true;
    const loadedIds = reset ? new Set() : new Set(autoRemodelLoadedIdsRef.current);
    if (reset) {
      autoRemodelLoadedIdsRef.current = loadedIds;
    }
    const pageIds = allIds.filter(id => !loadedIds.has(id)).slice(0, pageSize);
    if (!pageIds.length) {
      setAutoRemodelHydration({ total: allIds.length, loaded: loadedIds.size, loading: false });
      autoRemodelHydratingPageRef.current = false;
      return [];
    }
    setAutoRemodelHydration({ total: allIds.length, loaded: loadedIds.size, loading: true });
    try {
      const hydratedAds = await hydratePickedAds(pageIds, directionSource);
      const currentRunId = arRunIdRef.current;
      const hydratedById = new Map(hydratedAds.map(ad => [ad.id, ad]));
      const pageAds = pageIds.map(id => {
        const base = hydratedById.get(id) || autoRemodelFallbackAd(id);
        const runId = currentRunId || base._arRunId || 'current';
        return { ...base, _arRunId: runId, currentAutoRemodelRun: true };
      });
      pageIds.forEach(id => loadedIds.add(id));
      autoRemodelLoadedIdsRef.current = loadedIds;
      setAutoRemodelAds(prev => {
        // Current-run ads stay current even when they streamed via ad_scored
        // before the picked-id list caught up.
        const nextById = new Map(prev.map(ad => [ad.id, ad]));
        pageAds.forEach(ad => nextById.set(ad.id, ad));
        const allIdSet = new Set(allIds);
        const currentIds = new Set(allIds);
        [...nextById.values()].forEach(ad => {
          if (currentRunId && ad._arRunId === currentRunId) currentIds.add(ad.id);
        });
        const currentOrdered = allIds
          .filter(id => nextById.has(id))
          .map(id => ({ ...nextById.get(id), _arRunId: currentRunId || nextById.get(id)._arRunId || 'current', currentAutoRemodelRun: true }));
        const streamedCurrent = [...nextById.values()]
          .filter(ad => currentIds.has(ad.id) && !allIdSet.has(ad.id))
          .map(ad => ({ ...ad, _arRunId: currentRunId || ad._arRunId || 'current', currentAutoRemodelRun: true }));
        const remainder = [...nextById.values()]
          .filter(ad => !currentIds.has(ad.id))
          .map(ad => ({ ...ad, currentAutoRemodelRun: false }));
        return [...currentOrdered, ...streamedCurrent, ...remainder];
      });
      setRemodelScripts(prev => {
        const next = { ...prev };
        pageAds.forEach(a => { if (!next[a.id]) next[a.id] = { status: 'pending', variants: [] }; });
        return next;
      });
      setAutoRemodelHydration({ total: allIds.length, loaded: loadedIds.size, loading: false });
      const pageAdIds = pageAds.map(ad => ad.id);
      if (pageAdIds.length) pollRemodelJobs(pageAdIds, autoRemodelJobOptions());
      return pageAds;
    } finally {
      setAutoRemodelHydration(prev => ({ ...prev, loading: false }));
      autoRemodelHydratingPageRef.current = false;
    }
  };

  const hydrateAutoRemodelPickedState = async (
    state,
    directionSource = directionsRef.current,
    { announce = false, focus = false } = {},
  ) => {
    if (!state || !Array.isArray(state.picked_ad_ids) || state.picked_ad_ids.length === 0) return false;
    const pickedIds = state.picked_ad_ids.slice(0, AUTO_REMODEL_COUNT_MAX);
    if (!pickedIds.length) return false;
    arPickedIdsRef.current = pickedIds;
    setArPickedIds(pickedIds);
    if (typeof state.target_ads === 'number') setArTargetAds(state.target_ads);
    if (typeof state.ads_found === 'number') setArAdsFound(Math.min(state.ads_found, pickedIds.length));

    const ads = await loadAutoRemodelPage({ ids: pickedIds, directionSource, reset: true });
    if (!ads.length) return false;
    setRemodelScripts(prev => {
      const next = { ...prev };
      ads.forEach(a => { if (!next[a.id]) next[a.id] = { status: 'pending', variants: [] }; });
      return next;
    });
    if (focus) setTab('autoRemodel');
    if (announce) {
      showToast(
        `Auto-Remodel: showing ${ads.length} of ${pickedIds.length} saved ad${pickedIds.length === 1 ? '' : 's'} while variants generate.`,
        'Auto-Remodel',
      );
    }
    return true;
  };

  useEffect(() => {
    const ids = arPickedIds || [];
    if (!ids.length || !['picked', 'filtering', 'filter_done', 'remodeling', 'done'].includes(arStage)) return;
    if (autoRemodelLoadedIdsRef.current.size > 0) return;
    let cancelled = false;
    (async () => {
      const ads = await loadAutoRemodelPage({ ids, reset: true });
      if (cancelled || ads.length === 0) return;
      setRemodelScripts(prev => {
        const next = { ...prev };
        ads.forEach(a => { if (!next[a.id]) next[a.id] = { status: 'pending', variants: [] }; });
        return next;
      });
    })();
    return () => { cancelled = true; };
    // arPickedIds is an array from SSE/state restore; use its contents as the trigger.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [arStage, arPickedIds.join('|')]);

  // SSE subscription. The same channel powers the existing autoresearch
  // panel's live progress; we layer Auto-Remodel on top with no new
  // transport. Returned cleanup closes the EventSource on unmount / HMR.
  useEffect(() => {
    if (authState.status !== 'authenticated') return undefined;

    const close = RESILIA_API.subscribe((ev) => {
      if (!ev || !ev.type) return;

      if (ev.type === 'auto_remodel_event') {
        // Late subscribers latch onto whichever run is broadcasting — keep
        // the first run_id we see for this session unless dismissed.
        setArRunId(prev => prev || ev.run_id);
        if (!arRunIdRef.current && ev.run_id) arRunIdRef.current = ev.run_id;
        if (arRunIdRef.current && ev.run_id && ev.run_id !== arRunIdRef.current) return;
        setArStage(ev.stage || null);
        arStageRef.current = ev.stage || null;
        setArLastEvent(ev);

        // Crawl-side progress counters from the orchestrator.
        if (typeof ev.directions_done === 'number') setArDirectionsDone(ev.directions_done);
        if (typeof ev.directions_total === 'number') setArDirectionsTotal(ev.directions_total);
        if (typeof ev.ads_found === 'number') {
          const target = arTargetAdsRef.current || ev.target_ads || 0;
          setArAdsFound(target > 0 ? Math.min(ev.ads_found, target) : ev.ads_found);
        }
        if (typeof ev.target_ads === 'number') {
          setArTargetAds(ev.target_ads);
          arTargetAdsRef.current = ev.target_ads;
        }

        if (Array.isArray(ev.picked_ad_ids) && ev.picked_ad_ids.length) {
          const target = ev.target_ads || arTargetAdsRef.current || ev.picked_ad_ids.length;
          const cappedIds = ev.picked_ad_ids.slice(0, Math.max(1, Math.min(AUTO_REMODEL_COUNT_MAX, target)));
          arPickedIdsRef.current = cappedIds;
          setArPickedIds(cappedIds);
        }

        // Pipeline now skips the Filter step entirely (Sonnet pre-approves
        // every pick at score ≥ 7.5). Stages we react to:
        //   * picked (with non-empty picks)
        //                → hydrate ads, switch to Remodel tab, push to
        //                   remodeledAds with pending variant slots, start
        //                   polling.
        //   * picked (empty picks)
        //                → orchestrator's fresh-run filter found nothing
        //                   new. Stay on the current tab; toast explains.
        //   * remodeling → no-op (just a status update).
        //   * done/failed → final poll + toast.
        (async () => {
          if (ev.stage === 'picked' && Array.isArray(ev.picked_ad_ids)) {
            if (ev.picked_ad_ids.length === 0) {
              // Critical: never switch tabs on empty picks. The user clicked
              // Run expecting ads — switching to a blank Remodel view feels
              // like the app broke. Stay put; the orchestrator will fire
              // ``done`` next anyway.
              showToast(
                'Auto-Remodel: no fresh ads found this run. Try different directions or wait for new winners to surface.',
                'Auto-Remodel',
              );
              return;
            }
            const target = ev.target_ads || arTargetAdsRef.current || ev.picked_ad_ids.length;
            const pickedIds = ev.picked_ad_ids.slice(0, Math.max(1, Math.min(AUTO_REMODEL_COUNT_MAX, target)));
            // Bug — the orchestrator emits ``picked`` once per batch as
            // ``_queue_fresh_remodels`` runs (not just at the very end), and
            // ``ev.picked_ad_ids`` is the running cumulative list. Calling
            // ``loadAutoRemodelPage({ reset: true })`` on every batch wiped
            // every ad of the current run from screen, flashed the
            // "Restoring Auto-Remodel results — Loading saved ads and
            // scripts" empty state (which is keyed on
            // ``ads.length === 0 && (restoring || loadingMore)``), then
            // re-hydrated from scratch — and any remodelled script that had
            // already streamed in disappeared until the next ``remodel_*``
            // event re-populated it. Hydrate only the genuinely new ids and
            // leave the existing rendered ads / scripts in place.
            const alreadyLoaded = autoRemodelLoadedIdsRef.current;
            const newIds = (Array.isArray(ev.new_picked_ad_ids) && ev.new_picked_ad_ids.length
              ? ev.new_picked_ad_ids
              : pickedIds
            ).filter(id => id && !alreadyLoaded.has(id));
            if (newIds.length === 0) {
              // Nothing new to render in this batch — still refresh the
              // remodel-job poll so the variant counter keeps ticking.
              if (remodelPollRef.current) clearInterval(remodelPollRef.current);
              const allLoadedIds = [...autoRemodelLoadedIdsRef.current];
              if (allLoadedIds.length) {
                remodelPollRef.current = setInterval(
                  () => pollRemodelJobs(allLoadedIds, autoRemodelJobOptions()),
                  3000,
                );
              }
              return;
            }
            // Incremental hydration: keeps existing ads + scripts on screen
            // and just appends the new picks to the bottom.
            const ads = await loadAutoRemodelPage({ ids: pickedIds, reset: false });
            setRemodelScripts(prev => {
              const next = { ...prev };
              ads.forEach(a => {
                if (!next[a.id]) next[a.id] = { status: 'pending', variants: [] };
              });
              return next;
            });
            if (remodelPollRef.current) clearInterval(remodelPollRef.current);
            const allLoadedIds = [...autoRemodelLoadedIdsRef.current];
            if (allLoadedIds.length) {
              remodelPollRef.current = setInterval(
                () => pollRemodelJobs(allLoadedIds, autoRemodelJobOptions()),
                3000,
              );
            }
            showToast(
              `Auto-Remodel: +${newIds.length} new ad${newIds.length === 1 ? '' : 's'} (${pickedIds.length}/${target}).`,
              'Auto-Remodel',
            );
          } else if (ev.stage === 'done') {
            // Suppress the redundant "done" toast when we already toasted
            // about an empty pick — same run, same news.
            if ((ev.picked ?? 0) > 0) {
              showToast(
                `Auto-Remodel done — ${ev.completed ?? 0}/${ev.jobs_created ?? 0} jobs ok`,
                ev.failed ? 'Warn' : 'Auto-Remodel',
              );
            }
            const loadedIds = [...autoRemodelLoadedIdsRef.current];
            if (loadedIds.length) pollRemodelJobs(loadedIds, autoRemodelJobOptions({ markMissingAsError: true }));
          } else if (ev.stage === 'failed') {
            showToast(`Auto-Remodel failed: ${ev.error || 'unknown'}`, 'Error');
          } else if (ev.stage === 'cancelled') {
            showToast('Auto-Remodel stopped.', 'Auto-Remodel');
          }
        })();
      } else if (ev.type === 'remodel_completed' || ev.type === 'remodel_failed' || ev.type === 'remodel_edited') {
        // Variant-level updates: refresh just the affected ad's scripts via
        // the existing poll helper (handles the parsing + status logic).
        const isAutoRemodelAd = ev.ad_id && arPickedIdsRef.current.includes(ev.ad_id);
        if (isAutoRemodelAd) {
          pollRemodelJobs([ev.ad_id], autoRemodelJobOptions());
          // Each remodel_completed/_failed represents one variant finishing
          // (in any state) — tick the panel's variant counter.
          if (ev.type === 'remodel_completed' || ev.type === 'remodel_failed') {
            setArVariantsDone(n => n + 1);
          }
        } else if (ev.ad_id) {
          pollRemodelJobs([ev.ad_id], { source: 'manual' });
          RESILIA_API.getSwipe(ev.ad_id)
            .then(row => {
              const ad = row
                ? { ...RESILIA_ADAPT.adaptEvaluatedAd(row), source: 'remodel' }
                : pipelineFallbackAd(ev.ad_id, 'remodel');
              setRemodeledAds(prev => mergeRecentAds([ad], prev));
            })
            .catch(e => console.warn('manual remodel swipe refresh failed', e));
        }
      } else if (ev.type === 'filter_ad_verdict' && ev.ad_id) {
        RESILIA_API.getSwipe(ev.ad_id)
          .then(row => {
            if (!row) return;
            const ad = {
              ...RESILIA_ADAPT.adaptEvaluatedAd(row),
              source: 'filter',
              filterVerdict: row.filter_verdict || null,
              pipelineAt: row.filter_verdict?.filtered_at || row.saved_at || null,
            };
            setFilteredAds(prev => mergeRecentAds([ad], prev));
          })
          .catch(e => console.warn('filter verdict swipe refresh failed', e));
      } else if (ev.type === 'filter_completed') {
        loadFilterPage({ reset: true }).catch(e => console.warn('filter restore after completion failed', e));
      } else if (ev.type === 'remodel_promoted') {
        if (ev.ad_id && arPickedIdsRef.current.includes(ev.ad_id)) {
          pollRemodelJobs([ev.ad_id], autoRemodelJobOptions());
        }
        showToast(`Promoted to Video Queue (job ${ev.job_id})`, 'Promote');
      } else if (ev.type === 'filter_verdicts_deleted') {
        const deleted = new Set(ev.ad_ids || []);
        setFilteredAds(prev => prev.filter(a => !deleted.has(a.id)));
      } else if (ev.type === 'remodel_jobs_deleted') {
        const deleted = new Set(ev.ad_ids || []);
        setRemodeledAds(prev => prev.filter(a => !deleted.has(a.id)));
        setRemodelScripts(prev => {
          const next = { ...prev };
          deleted.forEach(id => { delete next[id]; });
          return next;
        });
      } else if (ev.type === 'evaluated_deleted') {
        showToast(`Bulk delete — ${ev.count} ad(s) removed`, 'Delete');
      } else if (ev.type === 'ad_scored' && ev.passes === true) {
        // The crawler fires ``ad_scored`` for every ad it evaluates — when
        // ``passes`` is true the ad just qualified. Stream qualifying ads
        // into Auto-Remodel results immediately so the user sees winners
        // landing while the all-source crawl is still running.
        const stageNow = arStageRef.current;
        const active = !!arRunIdRef.current && stageNow !== 'done' && stageNow !== 'failed';
        if (!active) return;
        const target = arTargetAdsRef.current || 5;
        if (arAcceptedAdIdsRef.current.has(ev.ad_id)) return;
        if (arAcceptedAdIdsRef.current.size >= target) return;
        arAcceptedAdIdsRef.current.add(ev.ad_id);
        setArAdsFound(Math.min(arAcceptedAdIdsRef.current.size, target));

        const dirMap = directionMetaMap(directionsRef.current);
        const synth = {
          ad_id: ev.ad_id,
          direction_id: ev.direction_id,
          score: ev.score,
          passes: true,
          reasoning: ev.reasoning,
          transcript: ev.transcript_preview,
          brand: ev.brand,
          platform: ev.platform,
          source_platform: ev.source_platform,
          running_days: ev.running_days,
          creative_url: ev.creative_url,
          thumbnail_url: ev.thumbnail_url,
          link_url: ev.link_url,
          metrics: ev.metrics || {},
          raw: { source_platform: ev.source_platform, metrics: ev.metrics || {} },
        };
        const adapted = RESILIA_ADAPT.adaptEvaluatedAd(synth);
        attachDirectionMeta(adapted, dirMap);
        const ad = { ...adapted, source: 'auto_remodel', _arRunId: arRunIdRef.current, currentAutoRemodelRun: true };
        RESILIA_API.fetchTranscript(ev.ad_id).then(detail => {
          if (!detail || !detail.transcript) return;
          const patchTranscript = (item) => {
            if (item.id !== ev.ad_id) return item;
            const patched = RESILIA_ADAPT.adaptEvaluatedAd({
              ...synth,
              transcript: detail.transcript,
              raw: { ...(synth.raw || {}), ...(detail.raw || {}) },
            });
            patched.directionLabel = item.directionLabel;
            patched.directionSearchLabel = item.directionSearchLabel;
            return { ...item, ...patched, source: item.source, _arRunId: item._arRunId };
          };
          setAutoRemodelAds(prev => prev.map(patchTranscript));
        }).catch(e => console.warn('auto-remodel transcript hydration failed', e));
        setAutoRemodelAds(prev => {
          if (prev.some(a => a.id === ad.id)) return prev;
          // Cap by current-run pick count, not by every auto_remodel ad on
          // screen — ads from prior runs are kept below the new ones for
          // history (Bug 8) and must not block new picks.
          const currentRunId = arRunIdRef.current;
          const currentRunAds = prev.filter(
            a => a.source === 'auto_remodel' && a._arRunId === currentRunId,
          );
          if (currentRunAds.length >= target) return prev;
          // Append to the end of the current-run section so within-run order
          // matches scoring order, while previous-run ads stay below.
          const otherAds = prev.filter(
            a => !(a.source === 'auto_remodel' && a._arRunId === currentRunId),
          );
          return [...currentRunAds, ad, ...otherAds];
        });
        setRemodelScripts(prev => {
          if (prev[ad.id]) return prev;
          return { ...prev, [ad.id]: { status: 'scored', variants: [] } };
        });
      }
    });
    return close;
    // We intentionally don't include arRunId/arPickedIds in deps — re-creating
    // the SSE connection on every state change would thrash the server. The
    // closure reads the latest values via the setState callbacks.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [authState.status]);

  const startAutoRemodel = async ({
    mode,
    directionId,
    directionIds,
    count,
    nVariants,
    platforms,
    maxAdsPerDirection,
    maxAdsPerPlatform,
  }) => {
    const activeStage = arStageRef.current;
    const alreadyActive = arRunIdRef.current && !['done', 'failed', 'cancelled'].includes(activeStage);
    if (arStartPendingRef.current || alreadyActive) {
      showToast('Auto-Remodel is already starting or running.', 'Auto-Remodel');
      return;
    }
    arStartPendingRef.current = true;
    setArStartPending(true);
    try {
      const out = await RESILIA_API.runAutoRemodel({
        mode,
        directionId,
        directionIds,
        count,
        nVariants,
        platforms,
        maxAdsPerDirection,
        maxAdsPerPlatform,
      });
      if (out.status === 'already_running') {
        if (out.state) syncAutoRemodelState(out.state);
        else if (out.run_id) {
          setArRunId(out.run_id);
          arRunIdRef.current = out.run_id;
        }
        showToast('Auto-Remodel is already running.', 'Auto-Remodel');
        return;
      }
      // Fresh run — clear stale state and keep the user on Auto-Remodel so
      // live scored ads and picked ads appear in the same panel. Stop the
      // manual-flow remodel poller too, otherwise it could overwrite the
      // orchestrator's state.
      if (remodelPollRef.current) {
        clearInterval(remodelPollRef.current);
        remodelPollRef.current = null;
      }
      setArRunId(out.run_id);
      arRunIdRef.current = out.run_id;
      setArStage('queued');
      arStageRef.current = 'queued';
      setArLastEvent(null);
      setArPickedIds([]);
      arPickedIdsRef.current = [];
      // Keep previously-rendered Auto-Remodel ads on screen as a run history
      // strip beneath the new run's results (Bug 8). Tag them with whatever
      // ``_arRunId`` they already had — or 'previous' if they predate the
      // tag — so the new run's hydration doesn't touch them.
      setAutoRemodelAds(prev => prev.map(ad => (
        ad._arRunId
          ? { ...ad, currentAutoRemodelRun: false }
          : { ...ad, _arRunId: 'previous', currentAutoRemodelRun: false }
      )));
      // Loaded ids are scoped to the active run. Previous ads stay rendered,
      // but must not block a newly picked copy from hydrating as current.
      autoRemodelLoadedIdsRef.current = new Set();
      setAutoRemodelHydration({ total: 0, loaded: 0, loading: false });
      arAcceptedAdIdsRef.current = new Set();
      setArDirectionsDone(0);
      setArDirectionsTotal(0);
      setArAdsFound(0);
      const requestedTarget = Math.max(1, Math.min(AUTO_REMODEL_COUNT_MAX, parseInt(count, 10) || 5));
      setArTargetAds(requestedTarget);
      arTargetAdsRef.current = requestedTarget;
      setArVariantsDone(0);
      const requestedVariants = Math.max(1, parseInt(nVariants, 10) || 1);
      setArNVariants(requestedVariants);
      arNVariantsRef.current = requestedVariants;
      setArVariantsTotalOverride(null);
      // Bug 8 — do NOT clear autoRemodelAds or remodelScripts here. Previous-
      // run picks and their generated scripts stay visible underneath the new
      // run's results so the user keeps their history when they re-run.
      setTab('autoRemodel');
      // Note — we deliberately do NOT clear ``researchAds`` here. Auto-Remodel
      // results live in ``autoRemodelAds`` (rendered on the Auto-Remodel tab);
      // the Research tab's autoresearch winners are independent state that the
      // user expects to find intact when they switch back. A previous version
      // wiped ``researchAds.filter(a => a.source === 'autoresearch')`` here,
      // which made the Research tab look empty after every Auto-Remodel run.
      showToast(`Auto-Remodel started (${out.run_id}) — staying here until picks land.`, 'Auto-Remodel');
      (async () => {
        for (let attempt = 0; attempt < 8; attempt += 1) {
          const snapshot = await RESILIA_API.getAutoRemodelState(out.run_id);
          if (snapshot && snapshot.status === 'ok' && snapshot.state) {
            syncAutoRemodelState(snapshot.state);
            const hydrated = await hydrateAutoRemodelPickedState(
              snapshot.state,
              directionsRef.current,
              { announce: attempt > 0 },
            );
            if (hydrated) return;
          }
          await new Promise(resolve => setTimeout(resolve, 250));
        }
      })().catch(e => console.warn('auto-remodel hydrate failed', e));
    } catch (e) {
      showToast('Failed to start Auto-Remodel: ' + e.message, 'Error');
    } finally {
      arStartPendingRef.current = false;
      setArStartPending(false);
    }
  };

  const loadMoreAutoRemodelAds = async () => {
    try {
      await loadAutoRemodelPage({ ids: arPickedIdsRef.current });
    } catch (e) {
      showToast('Failed to load more Auto-Remodel ads: ' + e.message, 'Error');
    }
  };

  const stopAutoRemodel = async () => {
    try {
      const out = await RESILIA_API.stopAutoRemodel();
      if (out.status === 'not_running') {
        setArStage('cancelled');
        arStageRef.current = 'cancelled';
        setArLastEvent({ stage: 'cancelled', reason: 'No active Auto-Remodel run.' });
        showToast('Auto-Remodel is not running.', 'Auto-Remodel');
        return;
      }
      setArStage('cancelled');
      arStageRef.current = 'cancelled';
      setArLastEvent({ stage: 'cancelled', run_id: out.run_id, reason: 'Stopped by user' });
      showToast('Auto-Remodel stopping...', 'Auto-Remodel');
    } catch (e) {
      showToast('Failed to stop Auto-Remodel: ' + e.message, 'Error');
    }
  };

  const regenerateRemodelJob = async (jobId) => {
    try {
      const out = await RESILIA_API.retryRemodelJob(jobId);
      const adId = Object.keys(remodelScripts).find(id => {
        const e = remodelScripts[id];
        return e && Array.isArray(e.variants) && e.variants.some(v => v.jobId === jobId);
      });
      if (adId) {
        setRemodelScripts(prev => ({ ...prev, [adId]: { status: 'pending', variants: [] } }));
        if (remodelPollRef.current) clearInterval(remodelPollRef.current);
        remodelPollRef.current = setInterval(() => pollRemodelJobs([adId]), 3000);
        pollRemodelJobs([adId]);
      }
      showToast(`Regeneration started (${out.run_id || jobId}).`, 'Remodel');
    } catch (e) {
      showToast('Regenerate failed: ' + e.message, 'Error');
    }
  };

  const discardRemodelJob = async (jobId) => {
    if (!confirm('Discard this remodel variant?')) return;
    try {
      await RESILIA_API.deleteRemodelJob(jobId);
      setRemodelScripts(prev => {
        const next = { ...prev };
        for (const adId of Object.keys(next)) {
          const entry = next[adId];
          if (!entry || !Array.isArray(entry.variants)) continue;
          const variants = entry.variants.filter(v => v.jobId !== jobId);
          next[adId] = { ...entry, variants, status: variants.length ? entry.status : 'error' };
        }
        return next;
      });
      showToast('Variant discarded.', 'Remodel');
    } catch (e) {
      showToast('Discard failed: ' + e.message, 'Error');
    }
  };

  const promoteRemodelJob = async (jobId) => {
    try {
      const out = await RESILIA_API.promoteRemodelJob(jobId);
      const job = out.job || out;
      const ad = remodeledAds.find(a => a.id === job.ad_id) || autoRemodelAds.find(a => a.id === job.ad_id);
      if (ad) {
        setConvertedAds(prev => prev.some(a => a.id === ad.id) ? prev : [...prev, ad]);
      }
      if (job.ad_id) pollRemodelJobs([job.ad_id]);
      showToast(`Promoted to Video Queue (job ${job.id || jobId}).`, 'Promote');
      return job;
    } catch (e) {
      showToast('Promote failed: ' + e.message, 'Error');
      return null;
    }
  };

  const promoteAllAutoRemodelJobs = async () => {
    const jobIds = [];
    autoRemodelAds.forEach(ad => {
      const entry = remodelScripts[ad.id];
      if (!entry || !Array.isArray(entry.variants)) return;
      entry.variants.forEach(v => {
        if (v.jobId) jobIds.push(v.jobId);
      });
    });
    if (!jobIds.length) {
      showToast('No Auto-Remodel scripts are ready.', 'Promote');
      return;
    }
    try {
      const promoted = [];
      for (const jobId of jobIds) {
        const out = await RESILIA_API.promoteRemodelJob(jobId);
        const job = out.job || out;
        if (job) promoted.push(job);
      }
      const promotedAdIds = new Set(promoted.map(j => j.ad_id).filter(Boolean));
      setConvertedAds(prev => {
        const seen = new Set(prev.map(a => a.id));
        const next = [...prev];
        autoRemodelAds.forEach(ad => {
          if (promotedAdIds.has(ad.id) && !seen.has(ad.id)) next.push(ad);
        });
        return next;
      });
      if (promotedAdIds.size) pollRemodelJobs([...promotedAdIds]);
      showToast(`${promoted.length} Auto-Remodel script${promoted.length === 1 ? '' : 's'} promoted to Video Queue.`, 'Promote');
    } catch (e) {
      showToast('Promote all failed: ' + e.message, 'Error');
    }
  };

  // ---- Research-tab bulk delete -----------------------------------------
  const bulkDeleteResearch = async () => {
    const ids = [...selResearch];
    if (!ids.length) return;
    if (!confirm(`Remove ${ids.length} selected Research card${ids.length === 1 ? '' : 's'}?\n\nSearch history, AutoSearch history, and evaluated rows are updated. Swipe-file rows stay available.`)) return;
    try {
      const out = await RESILIA_API.bulkDeleteEvaluated(ids);
      setResearchAds(prev => prev.filter(a => !selResearch.has(a.id)));
      setSelResearch(new Set());
      showToast(`${out.removed} ad(s) removed.`, 'Delete');
    } catch (e) {
      showToast('Bulk delete failed: ' + e.message, 'Error');
    }
  };

  const loadMoreFilterAds = async () => {
    try {
      await loadFilterPage();
    } catch (e) {
      showToast('Failed to load more filtered ads: ' + e.message, 'Error');
    }
  };

  const loadMoreRemodelAds = async () => {
    try {
      await loadRemodelPage();
    } catch (e) {
      showToast('Failed to load more remodels: ' + e.message, 'Error');
    }
  };

  const bulkDeleteFilter = async () => {
    const ids = [...selFilter];
    if (!ids.length) return;
    if (!confirm(`Remove ${ids.length} filtered video${ids.length === 1 ? '' : 's'} from the Filter tab?\n\nSwipe-file rows stay available for search and future runs.`)) return;
    try {
      const out = await RESILIA_API.bulkDeleteFilterVerdicts(ids);
      setFilteredAds(prev => prev.filter(a => !selFilter.has(a.id)));
      setSelFilter(new Set());
      showToast(`${out.removed} filtered video${out.removed === 1 ? '' : 's'} removed.`, 'Delete');
    } catch (e) {
      showToast('Filter delete failed: ' + e.message, 'Error');
    }
  };

  const bulkDeleteRemodel = async () => {
    const ids = [...selRemodel];
    if (!ids.length) return;
    if (!confirm(`Delete remodel jobs for ${ids.length} video${ids.length === 1 ? '' : 's'}?\n\nThis removes the scripts from the Remodel tab.`)) return;
    try {
      const out = await RESILIA_API.bulkDeleteRemodelJobs(ids);
      setRemodeledAds(prev => prev.filter(a => !selRemodel.has(a.id)));
      setRemodelScripts(prev => {
        const next = { ...prev };
        ids.forEach(id => { delete next[id]; });
        return next;
      });
      setSelRemodel(new Set());
      showToast(`${out.removed} remodel job${out.removed === 1 ? '' : 's'} deleted.`, 'Delete');
    } catch (e) {
      showToast('Remodel delete failed: ' + e.message, 'Error');
    }
  };

  // ---- Pipeline transitions ----------------------------------------------
  const toggleSel = (setter) => (id) => {
    setter(prev => {
      const n = new Set(prev);
      n.has(id) ? n.delete(id) : n.add(id);
      return n;
    });
  };

  const ensureAdsInSwipe = async (picked) => {
    // Filter/Remodel backends read transcripts from swipe_file, so ads
    // coming straight from search need to be persisted first. Autoresearch
    // ads are already in swipe_file via the promote-winners flow.
    for (const ad of picked) {
      try {
        await RESILIA_API.addToSwipe({
          adId: ad.id,
          raw: ad.raw || {},
          directionId: ad.directionId || null,
        });
      } catch (e) {
        console.warn('addToSwipe failed for', ad.id, e);
      }
    }
  };

  const passToFilter = async () => {
    const picked = researchAds.filter(a => selResearch.has(a.id));
    if (picked.length === 0) return;
    setFilterBusy(true);
    const seededAds = picked.map(a => ({ ...a, source: 'filter' }));
    setFilteredAds(prev => mergeRecentAds(seededAds, prev));
    setSelResearch(new Set());
    setTab('filter');
    try {
      await ensureAdsInSwipe(picked);
      await RESILIA_API.runFilter({ adIds: picked.map(a => a.id) });
      await refreshFilterRunState();
      startFilterStatePolling();
      showToast(`Filter started on ${picked.length} ads.`, 'Filter');
    } catch (e) {
      showToast('Filter failed: ' + e.message, 'Error');
    } finally {
      if (!filterPollRef.current) setFilterBusy(false);
    }
  };

  const passWithoutFilter = async () => {
    const picked = researchAds.filter(a => selResearch.has(a.id));
    if (picked.length === 0) return;
    const seededAds = picked.map(a => ({ ...a, source: 'remodel' }));
    setRemodeledAds(prev => mergeRecentAds(seededAds, prev));
    setSelResearch(new Set());
    setTab('remodel');
    try {
      await ensureAdsInSwipe(picked);
      await startRemodelAndPoll(picked);
      showToast(`${picked.length} ads passed without filtering. Remodeling…`, 'Bypass');
    } catch (e) {
      showToast('Pass failed: ' + e.message, 'Error');
    }
  };

  // Extract just the REMODELED SCRIPT prose from the remodeler output — skip
  // the analysis header and any surrounding metadata. Each paragraph becomes
  // one untagged line so the UI renders it as a readable transcript rather
  // than a HOOK/PROBLEM/SOLUTION breakdown.
  const parseRemodelScript = (text) => {
    if (!text) return [];
    const scriptMatch = text.match(/###\s*REMODELED\s+SCRIPT\s*\n([\s\S]*?)(?=\n###\s|$)/i);
    const body = scriptMatch ? scriptMatch[1].trim() : text.trim();
    const paras = body.split(/\n\s*\n/).map(p => p.trim()).filter(Boolean);
    return paras.map(p => ({
      tag: '',
      body: p.replace(/^\*\*[^*]+\*\*\s*/, '').replace(/\n+/g, ' '),
    }));
  };

  // ``editRemodelScript`` now takes a job id directly (RemodelView passes
  // the jobId of the specific variant being edited). Each ad can have
  // multiple variants in flight, so an adId-only lookup would be ambiguous.
  const editRemodelScript = async (jobId, newText) => {
    if (!jobId) {
      showToast('No remodel job to edit yet — wait for it to finish.', 'Remodel');
      return;
    }
    try {
      await RESILIA_API.updateRemodelScript({ jobId, responseText: newText });
      // Refresh by re-polling the affected ad — keeps the variants array in
      // sync with the DB without a bespoke patch path.
      const adId = Object.keys(remodelScripts).find(id => {
        const e = remodelScripts[id];
        return e && Array.isArray(e.variants) && e.variants.some(v => v.jobId === jobId);
      });
      if (adId) pollRemodelJobs([adId]);
      showToast('Edit saved.', 'Remodel');
    } catch (e) {
      showToast('Save failed: ' + e.message, 'Error');
      throw e;
    }
  };

  // Each ad can spawn N variants (one Sonnet call per variant). We track
  // them as ``{ status, variants: [{ jobId, variantIdx, raw, lines }] }``
  // so the Remodel view can render every finished variant as its own
  // editable card. Polling stops only when every variant for every ad has
  // reached done/failed.
  const autoRemodelJobOptions = (extra = {}) => ({
    source: 'auto_remodel',
    limitPerAd: Math.max(1, parseInt(arNVariantsRef.current, 10) || 1),
    ...extra,
  });

  const pollRemodelJobs = async (adIds, options = {}) => {
    const markMissingAsError = !!options.markMissingAsError;
    try {
      const cleanIds = [...new Set((adIds || []).filter(Boolean))];
      if (!cleanIds.length) return;
      const jobs = await RESILIA_API.listRemodelJobs({
        adIds: cleanIds,
        source: options.source || null,
        limitPerAd: options.limitPerAd || null,
        limit: Math.min(1000, cleanIds.length * Math.max(1, options.limitPerAd || 10)),
      });
      const jobsByAd = new Map();
      (jobs || []).forEach(job => {
        if (!job?.ad_id) return;
        if (!jobsByAd.has(job.ad_id)) jobsByAd.set(job.ad_id, []);
        jobsByAd.get(job.ad_id).push(job);
      });
      setRemodelScripts(prev => {
        const next = { ...prev };
        let stillPending = 0;
        cleanIds.forEach((id) => {
          const jobs = jobsByAd.get(id) || [];
          if (jobs.length === 0) {
            if (markMissingAsError) {
              next[id] = {
                status: 'error',
                variants: [],
                error: 'No remodel job was queued for this ad. The picked ad may be missing a swipe row or transcript.',
              };
            } else {
              next[id] = { status: 'pending', variants: [] };
              stillPending++;
            }
            return;
          }
          const done = jobs.filter(j => j.status === 'done' && j.response_text);
          const failed = jobs.filter(j => j.status === 'failed');
          const inFlight = jobs.length - done.length - failed.length;
          if (done.length > 0) {
            next[id] = {
              status: 'ready',
              variants: done
                .slice()
                .sort((a, b) => (a.variant_idx || 0) - (b.variant_idx || 0))
                .map(j => ({
                  jobId: j.id,
                  variantIdx: j.variant_idx || 0,
                  raw: j.response_text,
                  lines: parseRemodelScript(j.response_text),
                  promotedAt: j.promoted_at || null,
                })),
            };
            if (inFlight > 0) stillPending++;
          } else if (failed.length === jobs.length && jobs.length > 0) {
            next[id] = {
              status: 'error',
              variants: [],
              error: failed[0]?.error || 'Generation failed',
            };
          } else {
            next[id] = { status: 'pending', variants: [] };
            stillPending++;
          }
        });
        if (stillPending === 0 && remodelPollRef.current) {
          clearInterval(remodelPollRef.current);
          remodelPollRef.current = null;
        }
        return next;
      });
    } catch (e) {
      console.warn('poll remodel jobs failed', e);
    }
  };

  const startRemodelAndPoll = async (picked) => {
    const adIds = picked.map(a => a.id);
    setRemodelScripts(prev => {
      const next = { ...prev };
      adIds.forEach(id => { next[id] = 'pending'; });
      return next;
    });
    await RESILIA_API.runRemodel({ adIds, nVariants: 1 });
    await refreshRemodelRunState();
    if (remodelPollRef.current) clearInterval(remodelPollRef.current);
    remodelPollRef.current = setInterval(() => {
      pollRemodelJobs(adIds, { source: 'manual' });
      refreshRemodelRunState();
    }, 3000);
    pollRemodelJobs(adIds, { source: 'manual' });
  };

  const runRemodel = async () => {
    const picked = filteredAds.filter(a => selFilter.has(a.id));
    if (picked.length === 0) return;
    setRemodelBusy(true);
    try {
      await ensureAdsInSwipe(picked);
      await startRemodelAndPoll(picked);
      setRemodeledAds(prev => mergeRecentAds(picked.map(a => ({ ...a, source: 'remodel' })), prev));
      setSelFilter(new Set());
      setTab('remodel');
      showToast(`Remodel started on ${picked.length} ads (1 variant each).`, 'Remodel');
    } catch (e) {
      showToast('Remodel failed: ' + e.message, 'Error');
    } finally {
      if (!remodelPollRef.current) setRemodelBusy(false);
    }
  };

  const convert = () => {
    const picked = remodeledAds.filter(a => selRemodel.has(a.id));
    setConvertedAds(picked);
    setSelRemodel(new Set());
    setTab('video');
    showToast(`${picked.length} ads queued for video conversion.`, 'Video');
  };

  const signIn = () => {
    window.location.assign(RESILIA_API.loginUrl());
  };

  const signOut = async () => {
    await RESILIA_API.logout();
    setAuthState({ status: 'signed_out', user: null });
  };

  const pipelineStatusText = (pipelineState, label) => {
    const run = pipelineState?.run;
    if (!run) return null;
    const effectiveStatus = pipelineState?.state?.effective_status || run.status;
    const total = run.total || pipelineState?.state?.job_count || 0;
    const completed = run.completed || 0;
    const failed = run.failed || 0;
    if (effectiveStatus === 'running') {
      return `${label} running: ${completed}/${total || '?'} processed${failed ? `, ${failed} failed` : ''}.`;
    }
    if (effectiveStatus === 'interrupted') {
      return `${label} was interrupted after ${completed}/${total || '?'} processed. Saved results are shown.`;
    }
    if (effectiveStatus === 'failed') {
      return `${label} finished with failures: ${completed}/${total || '?'} processed, ${failed} failed.`;
    }
    if (effectiveStatus === 'done' && total > 0) {
      return `${label} restored: ${completed}/${total} processed.`;
    }
    return null;
  };

  const counts = {
    research: researchAds.length,
    autoRemodel: autoRemodelAds.length || arPickedIds.length,
    filter: filteredAds.length,
    remodel: remodeledAds.length,
    video: convertedAds.length,
  };
  const isAdmin = !!authState.user?.is_admin;

  // group by platform
  const groupByPlatform = (ads) => {
    const g = { foreplay: [], adspy: [], brandsearch: [], metaads: [] };
    ads.forEach(a => g[a.platform]?.push(a));
    return g;
  };

  if (authState.status !== 'authenticated') {
    return (
      <div className="auth-shell">
        <div className="auth-panel">
          <img src="../../assets/mark.svg" alt="" className="auth-mark" />
          <div className="auth-brand">Resilia</div>
          <button className="btn-primary auth-button" onClick={signIn}>Sign in</button>
        </div>
      </div>
    );
  }

  return (
    <div className="app">
      <TopBar
        credits={platformCredits}
        user={authState.user}
        pendingAccessCount={pendingAccessCount}
        onOpenAccessRequests={() => setTab('accessRequests')}
        onLogout={signOut}
      />
      <TabBar
        active={tab}
        counts={counts}
        onChange={setTab}
      />
      <div className="workspace-shell">
        {isAdmin && (
          <ManagementSidebar
            active={tab}
            collapsed={managementCollapsed}
            pendingAccessCount={pendingAccessCount}
            onToggle={() => setManagementCollapsed(v => !v)}
            onChange={setTab}
          />
        )}
        <main className="content">
          {tab === 'adminDirections' && isAdmin && (
            <AdminDirectionManager onCatalogChanged={refreshDirectionCatalog} />
          )}
          {tab === 'adminWinningCriteria' && isAdmin && (
            <AdminWinningCriteriaManager />
          )}
          {tab === 'adminCosts' && isAdmin && (
            <AdminCostManager />
          )}
          {tab === 'adminWorkspaces' && isAdmin && (
            <AdminWorkspaceMirror />
          )}
          {tab === 'accessRequests' && isAdmin && (
            <AccessRequestsAdminView onPendingCountChange={setPendingAccessCount} />
          )}
          {tab === 'adminUsers' && isAdmin && (
            <AdminUsersView />
          )}
          {tab === 'research' && (
            <ResearchView
              rows={rows} setRows={setRows}
              competitors={competitors}
              ads={researchAds} searching={searching}
              searchingRowIds={searchingRowIds}
              searchWarnings={searchWarnings}
              latestSearchRun={latestSearchRun}
              latestAutoresearchRun={latestAutoresearchRun}
              sel={selResearch} onToggle={toggleSel(setSelResearch)}
              onSearch={runSearch}
              onSearchRow={runSearchRow}
              onExpandAd={expandAd}
              density={tweaks.density}
              minimal={tweaks.cardStyle === 'minimal'}
              groupByPlatform={groupByPlatform}
              arRunning={arRunning}
              arProgress={arProgress}
              arSources={arSources}
              onToggleArSource={toggleArSource}
              onSelectAllArSources={selectAllArSources}
              arTarget={arTarget}
              setArTarget={setBoundedArTarget}
              arMaxAdsPerDirection={arMaxAdsPerDirection}
              setArMaxAdsPerDirection={setBoundedArMaxAdsPerDirection}
              arMaxAdsPerPlatform={arMaxAdsPerPlatform}
              setArMaxAdsPerPlatform={setBoundedArMaxAdsPerPlatform}
              arNumDirections={arNumDirections}
              setArNumDirections={setArNumDirections}
              directions={directions}
              arPickedDirections={arPickedDirections}
              onToggleArPickedDirection={toggleArPickedDirection}
              onClearArPickedDirections={clearArPickedDirections}
              onSelectAllArPickedDirections={selectAllArPickedDirections}
              autoresearchPage={autoresearchPage}
              onLoadMoreAutoresearch={loadMoreAutoresearchWinners}
              onRunAutoresearch={runAutoresearch}
              onStopAutoresearch={stopAutoresearch}
            />
          )}
          {tab === 'filter' && (
            <PipelineView
              title="Filter" subtitle="Ads running through the filtering layer. Select and pass to Remodel."
              ads={filteredAds} sel={selFilter} onToggle={toggleSel(setSelFilter)}
              onExpandAd={expandAd}
              density={tweaks.density} minimal={tweaks.cardStyle === 'minimal'}
              groupByPlatform={groupByPlatform}
              emptyTitle="Nothing filtered yet"
              emptyBody="Select ads in Research and press Pass to Filter."
              pagination={filterPage}
              onLoadMore={loadMoreFilterAds}
              statusText={pipelineStatusText(filterRunState, 'Filter')}
              preserveOrder={true}
            />
          )}
          {tab === 'autoRemodel' && (
            <AutoRemodelWorkspace
              ads={autoRemodelAds}
              scripts={remodelScripts}
              directionOptions={directions}
              running={arRunningOrch}
              starting={arStartPending}
              stage={arStage}
              lastEvent={arLastEvent}
              adsFound={arAdsFound}
              targetAds={arTargetAds}
              variantsDone={arVariantsDone}
              variantsTotal={arVariantsTotalOverride ?? arPickedIds.length * arNVariants}
              resultsTotal={autoRemodelHydration.total || arPickedIds.length}
              resultsLoaded={autoRemodelHydration.loaded}
              resultsLoading={autoRemodelHydration.loading}
              resultsRestoring={autoRemodelRestoring}
              sources={arOrchSources}
              onToggleSource={toggleArOrchSource}
              onSelectAllSources={selectAllArOrchSources}
              onRun={startAutoRemodel}
              onStop={stopAutoRemodel}
              onLoadMore={loadMoreAutoRemodelAds}
              onEditScript={editRemodelScript}
              onRegenerate={regenerateRemodelJob}
              onDiscard={discardRemodelJob}
              onPromote={promoteRemodelJob}
              onPromoteAll={promoteAllAutoRemodelJobs}
            />
          )}
          {tab === 'remodel' && (
            <RemodelView
              ads={remodeledAds} sel={selRemodel} onToggle={toggleSel(setSelRemodel)}
              scripts={remodelScripts}
              onEditScript={editRemodelScript}
              onRegenerate={regenerateRemodelJob}
              onDiscard={discardRemodelJob}
              onPromote={promoteRemodelJob}
              pagination={remodelPage}
              onLoadMore={loadMoreRemodelAds}
              statusText={pipelineStatusText(remodelRunState, 'Remodel')}
            />
          )}
          {tab === 'video' && (
            <VideoView ads={convertedAds} />
          )}
        </main>
      </div>

      {tab === 'research' && (
        <SelectionBar
          count={selResearch.size} total={researchAds.length}
          ctaLabel={filterBusy ? 'Filtering…' : 'Pass to Filter'} icon="filter"
          onCTA={passToFilter}
          secondaryLabel="Pass without Filtering"
          secondaryIcon="arrowRight"
          onSecondary={passWithoutFilter}
          dangerLabel="Delete selected"
          dangerIcon="x"
          onDanger={bulkDeleteResearch}
          onClear={() => setSelResearch(new Set())}
          onSelectAll={() => setSelResearch(new Set(researchAds.map(a => a.id)))}
        />
      )}
      {tab === 'filter' && (
        <SelectionBar
          count={selFilter.size} total={filteredAds.length}
          ctaLabel={remodelBusy ? 'Running…' : 'Run Remodel'} icon="scroll"
          onCTA={runRemodel}
          dangerLabel="Delete selected"
          dangerIcon="trash"
          onDanger={bulkDeleteFilter}
          onClear={() => setSelFilter(new Set())}
          onSelectAll={() => setSelFilter(new Set(filteredAds.map(a => a.id)))}
        />
      )}
      {tab === 'remodel' && (
        <SelectionBar
          count={selRemodel.size} total={remodeledAds.length}
          ctaLabel="Convert to Video" icon="film"
          onCTA={convert}
          dangerLabel="Delete selected"
          dangerIcon="trash"
          onDanger={bulkDeleteRemodel}
          onClear={() => setSelRemodel(new Set())}
          onSelectAll={() => setSelRemodel(new Set(remodeledAds.map(a => a.id)))}
        />
      )}

      <AdDrawer ad={expandedAd} onClose={() => setExpandedAd(null)} />

      {toast && (
        <div className="toast">
          <span className="toast-badge">{toast.badge}</span>
          <span>{toast.msg}</span>
        </div>
      )}

      {!tweaksOpen && (
        <button className="tweaks-fab" onClick={() => setTweaksOpen(true)} title="Tweaks">
          <Icon name="sliders" size={18} />
        </button>
      )}
      {tweaksOpen && (
        <Tweaks
          state={tweaks}
          setState={(next) => {
            setTweaks(next);
            window.parent.postMessage({ type: '__edit_mode_set_keys', edits: next }, '*');
          }}
          onClose={() => setTweaksOpen(false)}
        />
      )}
    </div>
  );
}

function ManagementSidebar({ active, collapsed, pendingAccessCount, onToggle, onChange }) {
  const items = [
    { id: 'adminDirections', label: 'Direction Manager', icon: 'target', meta: 'Directions' },
    { id: 'adminWinningCriteria', label: 'Winning Ads Criteria', icon: 'trend', meta: 'Scoring rules' },
    { id: 'adminCosts', label: 'Cost Dashboard', icon: 'bolt', meta: 'Spend' },
    { id: 'adminWorkspaces', label: 'User Work Viewer', icon: 'users', meta: 'User snapshots' },
    { id: 'adminUsers', label: 'User Manager', icon: 'users', meta: 'Grant or revoke admin' },
    { id: 'accessRequests', label: 'Access Manager', icon: 'shield', meta: `${pendingAccessCount || 0} pending`, count: pendingAccessCount || 0 },
  ];
  return (
    <aside className={`management-sidebar ${collapsed ? 'collapsed' : ''}`} aria-label="Management">
      <div className="management-sidebar-head">
        {!collapsed && (
          <div>
            <div className="management-title">Management</div>
            <div className="management-subtitle">Admin tools</div>
          </div>
        )}
        <button className="management-toggle" onClick={onToggle} title={collapsed ? 'Expand management' : 'Collapse management'}>
          <Icon name={collapsed ? 'arrowRight' : 'chevronDown'} size={15} />
        </button>
      </div>
      <nav className="management-nav">
        {items.map(item => (
          <button
            key={item.id}
            className={`management-nav-item ${active === item.id ? 'active' : ''}`}
            onClick={() => onChange(item.id)}
            aria-label={item.count > 0 ? `${item.label}, ${item.count} pending` : item.label}
            title={collapsed ? item.label : undefined}
          >
            <Icon name={item.icon} size={16} />
            {!collapsed && (
              <span className="management-nav-copy">
                <span>{item.label}</span>
                <small>{item.meta}</small>
              </span>
            )}
            {item.count > 0 && <span className="management-count">{item.count}</span>}
          </button>
        ))}
      </nav>
    </aside>
  );
}

const blankDirectionForm = () => ({
  display_name: '', search_method: 'keyword_discovery', keywords: '',
  platforms: { foreplay: true, adspy: true, brandsearch: true },
  target_n: '5', status: 'active',
  platform_filters: emptyDirectionPlatformFilters(),
});

function AdminDirectionManager({ onCatalogChanged }) {
  const [directions, setDirections] = useState([]);
  const [selectedId, setSelectedId] = useState(null);
  const [form, setForm] = useState(blankDirectionForm());
  const [loading, setLoading] = useState(false);
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState('');

  const activeDirections = directions.filter(d => d.status !== 'archived');
  const archivedCount = directions.length - activeDirections.length;
  const selected = directions.find(d => d.id === selectedId) || null;

  const splitKeywords = (value) => String(value || '')
    .split(/[\n,]+/)
    .map(item => item.trim())
    .filter(Boolean);
  const keywordText = (direction) => {
    const keywords = Array.isArray(direction?.keywords) ? direction.keywords : [];
    return keywords.filter(Boolean).join('\n');
  };
  const platformsForDirection = (direction) => {
    const saved = Array.isArray(direction?.platforms)
      ? direction.platforms.map(item => String(item).toLowerCase()).filter(item => LIVE_SOURCES.includes(item))
      : [];
    if (saved.length) return saved;
    const platformKeywords = direction?.platform_keywords || {};
    const keyed = LIVE_SOURCES.filter(platform => Array.isArray(platformKeywords[platform]) && platformKeywords[platform].length);
    if (keyed.length) return keyed;
    return LIVE_SOURCES;
  };
  const platformStateForDirection = (direction) => {
    const selected = new Set(platformsForDirection(direction));
    return Object.fromEntries(LIVE_SOURCES.map(platform => [platform, selected.has(platform)]));
  };
  const selectedFormPlatforms = () => LIVE_SOURCES.filter(platform => form.platforms?.[platform]);
  const platformFiltersForDirection = (direction) => Object.fromEntries(
    LIVE_SOURCES.map(platform => {
      const config = direction?.platform_configs?.[platform] || {};
      const filters = config && typeof config === 'object' && !Array.isArray(config) && config.api_filters
        ? config.api_filters
        : config;
      return [platform, normalizeDirectionApiFilters(platform, filters || {})];
    }),
  );
  const methodLabel = (method) => method === 'brand_lookup' ? 'Brand lookup' : 'Keyword discovery';

  const load = async () => {
    setLoading(true);
    setError('');
    try {
      const directionData = await RESILIA_API.adminListDirections();
      const nextDirections = directionData.directions || [];
      setDirections(nextDirections);
      if (!selectedId && nextDirections.length) setSelectedId(nextDirections[0].id);
    } catch (e) {
      setError(e.message || 'Admin data failed to load');
    } finally {
      setLoading(false);
    }
  };

  useEffect(() => { load(); }, []);
  useEffect(() => {
    if (!selected) return;
    setForm({
      display_name: selected.display_name || selected.brand_name || selected.id || '',
      search_method: selected.search_method || (selected.type === 'competitor' ? 'brand_lookup' : 'keyword_discovery'),
      keywords: keywordText(selected),
      platforms: platformStateForDirection(selected),
      target_n: String(selected.target_n || 5),
      status: selected.status || 'active',
      platform_filters: platformFiltersForDirection(selected),
    });
  }, [selected]);

  const updateForm = (patch) => setForm(current => ({ ...current, ...patch }));
  const updatePlatform = (platform, value) => setForm(current => ({
    ...current,
    platforms: { ...current.platforms, [platform]: value },
  }));
  const updatePlatformFilterValue = (platform, key, value) => setForm(current => {
    const filters = { ...(current.platform_filters?.[platform] || {}) };
    if (value === null || value === undefined || value === '' || (Array.isArray(value) && value.length === 0)) delete filters[key];
    else filters[key] = value;
    return {
      ...current,
      platform_filters: { ...current.platform_filters, [platform]: filters },
    };
  });
  const updatePlatformFilterRange = (platform, key, index, value, numeric) => setForm(current => {
    const filters = { ...(current.platform_filters?.[platform] || {}) };
    const existing = asFilterList(filters[key]);
    const next = [existing[0] || '', existing[1] || ''];
    if (value === '') next[index] = '';
    else if (numeric) {
      const numberValue = Number(value);
      next[index] = Number.isFinite(numberValue) ? numberValue : value;
    } else {
      next[index] = value;
    }
    const compact = next.filter(item => item !== null && item !== undefined && item !== '');
    if (compact.length) filters[key] = compact;
    else delete filters[key];
    return {
      ...current,
      platform_filters: { ...current.platform_filters, [platform]: filters },
    };
  });

  const newDirection = () => {
    setSelectedId(null);
    setForm(blankDirectionForm());
    setError('');
  };

  const syncDirectionKeywords = async (directionId, platforms, keywords) => {
    if (!selectedId || !directionId) return;
    const desired = new Set();
    platforms.forEach(platform => keywords.forEach(keyword => desired.add(`${platform}\u0000${keyword.toLowerCase()}`)));
    const existingItems = Array.isArray(selected?.keyword_items) ? selected.keyword_items : [];
    const existing = new Set(
      existingItems.map(item => `${String(item.platform || 'all').toLowerCase()}\u0000${String(item.keyword || '').trim().toLowerCase()}`),
    );
    for (const item of existingItems) {
      const key = `${String(item.platform || 'all').toLowerCase()}\u0000${String(item.keyword || '').trim().toLowerCase()}`;
      if (!desired.has(key)) await RESILIA_API.adminArchiveDirectionKeyword(directionId, item.id);
    }
    for (const platform of platforms) {
      for (const keyword of keywords) {
        const key = `${platform}\u0000${keyword.toLowerCase()}`;
        if (!existing.has(key)) await RESILIA_API.adminAddDirectionKeyword(directionId, { platform, keyword });
      }
    }
  };

  const renderPlatformFilterField = (platform, field, disabled) => {
    const filters = form.platform_filters?.[platform] || {};
    const value = filters[field.key];
    const title = `${PLATFORMS[platform]?.label || platform} ${field.key}`;
    if (field.type === 'select') {
      const currentValue = Array.isArray(value) ? (value[0] || '') : (value || '');
      return (
        <label key={field.key} className="admin-filter-field" title={title}>
          <span>{field.label}</span>
          <select
            value={currentValue}
            disabled={disabled}
            onChange={e => updatePlatformFilterValue(platform, field.key, field.array ? (e.target.value ? [e.target.value] : []) : e.target.value)}
          >
            {field.options.map(([optionValue, label]) => <option key={optionValue} value={optionValue}>{label}</option>)}
          </select>
        </label>
      );
    }
    if (field.type === 'boolean') {
      const currentValue = value === true ? 'true' : value === false ? 'false' : '';
      return (
        <label key={field.key} className="admin-filter-field" title={title}>
          <span>{field.label}</span>
          <select
            value={currentValue}
            disabled={disabled}
            onChange={e => updatePlatformFilterValue(platform, field.key, e.target.value === '' ? '' : e.target.value === 'true')}
          >
            <option value="">{field.emptyLabel || 'Any'}</option>
            <option value="true">{field.trueLabel || 'True'}</option>
            <option value="false">{field.falseLabel || 'False'}</option>
          </select>
        </label>
      );
    }
    if (field.type === 'list') {
      return (
        <label key={field.key} className="admin-filter-field wide" title={title}>
          <span>{field.label}</span>
          <input
            value={listText(value)}
            disabled={disabled}
            placeholder={field.placeholder || ''}
            onChange={e => updatePlatformFilterValue(platform, field.key, asFilterList(e.target.value))}
          />
        </label>
      );
    }
    if (field.type === 'range' || field.type === 'dateRange') {
      const range = asFilterList(value);
      const numeric = field.type === 'range';
      return (
        <label key={field.key} className="admin-filter-field range" title={title}>
          <span>{field.label}</span>
          <div className="admin-filter-range">
            <input
              type={field.type === 'dateRange' ? 'text' : 'number'}
              min={field.min}
              max={field.max}
              value={range[0] || ''}
              disabled={disabled}
              placeholder={field.startPlaceholder || (field.type === 'dateRange' ? 'YYYY-MM-DD' : 'From')}
              onChange={e => updatePlatformFilterRange(platform, field.key, 0, e.target.value, numeric)}
            />
            <input
              type={field.type === 'dateRange' ? 'text' : 'number'}
              min={field.min}
              max={field.max}
              value={range[1] || ''}
              disabled={disabled}
              placeholder={field.endPlaceholder || (field.type === 'dateRange' ? 'YYYY-MM-DD' : 'To')}
              onChange={e => updatePlatformFilterRange(platform, field.key, 1, e.target.value, numeric)}
            />
          </div>
        </label>
      );
    }
    return (
      <label key={field.key} className="admin-filter-field" title={title}>
        <span>{field.label}</span>
        <input
          type={field.type === 'number' ? 'number' : 'text'}
          min={field.min}
          step={field.step}
          value={value ?? ''}
          disabled={disabled}
          placeholder={field.placeholder || ''}
          onChange={e => {
            if (field.type !== 'number') updatePlatformFilterValue(platform, field.key, e.target.value);
            else if (e.target.value === '') updatePlatformFilterValue(platform, field.key, '');
            else {
              const numberValue = Number(e.target.value);
              updatePlatformFilterValue(platform, field.key, Number.isFinite(numberValue) ? numberValue : e.target.value);
            }
          }}
        />
      </label>
    );
  };

  const saveDirection = async () => {
    setBusy(true);
    setError('');
    try {
      const name = form.display_name.trim();
      if (!name) throw new Error('Name is required');
      const selectedPlatforms = selectedFormPlatforms();
      if (!selectedPlatforms.length) throw new Error('Select at least one platform');
      const keywords = splitKeywords(form.keywords);
      const firstLookup = keywords[0] || '';
      const platformFilters = Object.fromEntries(
        LIVE_SOURCES.map(platform => [platform, { api_filters: cleanApiFilters(form.platform_filters?.[platform] || {}) }]),
      );
      const body = {
        id: selectedId || null,
        type: form.search_method === 'brand_lookup' ? 'competitor' : 'keyword',
        display_name: name,
        description: null,
        search_method: form.search_method,
        brand_name: form.search_method === 'brand_lookup' ? name : null,
        brand_domain: form.search_method === 'brand_lookup' && firstLookup.includes('.') ? firstLookup : null,
        target_n: Math.max(1, Math.min(1000, parseInt(form.target_n, 10) || 5)),
        status: form.status || 'active',
        api_filters: {},
        keywords: [],
        platforms: selectedPlatforms,
        platform_keywords: Object.fromEntries(selectedPlatforms.map(platform => [platform, keywords])),
        platform_configs: platformFilters,
      };
      const out = await RESILIA_API.adminSaveDirection(body);
      const savedId = out.direction?.id || selectedId || null;
      if (savedId) await syncDirectionKeywords(savedId, selectedPlatforms, keywords);
      await load();
      await onCatalogChanged?.();
      setSelectedId(savedId || out.direction?.id || body.id);
    } catch (e) {
      setError(e.message || 'Save failed');
    } finally {
      setBusy(false);
    }
  };

  const archiveDirection = async () => {
    if (!selectedId || !confirm(`Archive ${selectedId}?`)) return;
    setBusy(true);
    setError('');
    try {
      await RESILIA_API.adminArchiveDirection(selectedId);
      setSelectedId(null);
      setForm(blankDirectionForm());
      await load();
      await onCatalogChanged?.();
    } catch (e) {
      setError(e.message || 'Archive failed');
    } finally {
      setBusy(false);
    }
  };

  return (
    <section className="admin-grid-view">
      <div className="content-hdr">
        <div>
          <div className="content-title">Direction Manager</div>
          <div className="content-sub">{activeDirections.length} active · {archivedCount} archived</div>
        </div>
        <div className="spacer" />
        <button className="btn-secondary" onClick={newDirection}><Icon name="plus" size={14} /> New</button>
        <button className="btn-primary" onClick={saveDirection} disabled={busy}><Icon name="check" size={14} /> Save</button>
      </div>
      {error && <div className="access-error">{error}</div>}
      <div className="admin-grid directions">
        <aside className="admin-list">
          {loading && <div className="access-loading">Loading...</div>}
          {directions.map(direction => {
            const directionPlatforms = platformsForDirection(direction);
            return (
              <button
                key={direction.id}
                className={`admin-list-row ${selectedId === direction.id ? 'active' : ''} ${direction.status === 'archived' ? 'muted' : ''}`}
                onClick={() => setSelectedId(direction.id)}
              >
                <span>{direction.display_name || direction.brand_name || direction.id}</span>
                <small>{methodLabel(direction.search_method)} · {directionPlatforms.map(platform => PLATFORMS[platform]?.label || platform).join(', ')}</small>
                <small>{(direction.keywords || []).length || 0} keywords</small>
              </button>
            );
          })}
        </aside>
        <section className="admin-editor">
          <div className="admin-form-grid simple">
            <label>Name<input value={form.display_name} onChange={e => updateForm({ display_name: e.target.value })} /></label>
            <label>Method<select value={form.search_method} onChange={e => updateForm({ search_method: e.target.value })}><option value="keyword_discovery">Keyword discovery</option><option value="brand_lookup">Brand lookup</option></select></label>
          </div>
          <label className="admin-field-full direction-keywords">Keywords<textarea value={form.keywords} onChange={e => updateForm({ keywords: e.target.value })} spellCheck="true" /></label>
          <div className="admin-platform-select-row">
            {LIVE_SOURCES.map(platform => (
              <label key={platform} className={`admin-platform-toggle ${form.platforms?.[platform] ? 'active' : ''}`}>
                <input type="checkbox" checked={!!form.platforms?.[platform]} onChange={e => updatePlatform(platform, e.target.checked)} />
                <span>{PLATFORMS[platform]?.label || platform}</span>
              </label>
            ))}
          </div>

          <div className="admin-platform-grid">
            {LIVE_SOURCES.map(platform => {
              const disabled = !form.platforms?.[platform];
              return (
                <div key={platform} className={`admin-platform-box ${disabled ? 'disabled' : ''}`}>
                  <div className="admin-platform-box-head">
                    <span>{PLATFORMS[platform].label} Filters</span>
                    <small>api_filters</small>
                  </div>
                  <div className="admin-filter-fields">
                    {(DIRECTION_FILTER_FIELDS[platform] || []).map(field => renderPlatformFilterField(platform, field, disabled))}
                  </div>
                </div>
              );
            })}
          </div>
          <div className="admin-danger-row">
            <button className="btn-secondary danger" onClick={archiveDirection} disabled={busy || !selectedId}><Icon name="trash" size={14} /> Archive Direction</button>
          </div>
        </section>
      </div>
    </section>
  );
}

const CRITERIA_PLATFORM_ORDER = ['foreplay', 'adspy', 'brandsearch'];
const blankCriteriaForm = () => ({ pass_threshold: '7.5', video_scoring_criteria: '', rubric: '', source_specific_signals: '' });

function CostStackedBar({ segments, total }) {
  const safeTotal = Math.max(Number(total || 0), 0);
  return (
    <div className="cost-stacked-bar" aria-label={`Total ${costMoney(safeTotal)}`}>
      {segments.map(segment => {
        const value = Number(segment.value || 0);
        const width = safeTotal > 0 ? Math.max((value / safeTotal) * 100, value > 0 ? 2 : 0) : 0;
        return (
          <span
            key={segment.key}
            className={`cost-stack-segment ${segment.key}`}
            style={{ width: `${width}%` }}
            title={`${segment.label}: ${costMoney(value)}`}
          />
        );
      })}
    </div>
  );
}

function AdminCostManager() {
  const [summary, setSummary] = useState(null);
  const [selectedUserIds, setSelectedUserIds] = useState([]);
  const [userOptions, setUserOptions] = useState([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState('');
  const loadSeqRef = useRef(0);

  const load = async (userIds = selectedUserIds) => {
    const requestedUserIds = [...(userIds || [])];
    const loadSeq = loadSeqRef.current + 1;
    loadSeqRef.current = loadSeq;
    setLoading(true);
    setError('');
    try {
      const data = await RESILIA_API.adminCostSummary({ userIds: requestedUserIds });
      if (loadSeq !== loadSeqRef.current) return;
      setSummary(data);
      const options = data.available_users?.length ? data.available_users : data.users || [];
      if (options.length) setUserOptions(options);
    } catch (e) {
      if (loadSeq !== loadSeqRef.current) return;
      setError(e.message || 'Cost summary failed to load');
    } finally {
      if (loadSeq === loadSeqRef.current) setLoading(false);
    }
  };

  useEffect(() => { load([]); }, []);

  const org = summary?.organization || {};
  const users = summary?.users || [];
  const filterActive = selectedUserIds.length > 0;
  const selectedUserValue = selectedUserIds[0] || '__all__';
  const selectedUserNames = selectedUserIds
    .map(userId => userOptions.find(user => user.user_id === userId)?.email || userId)
    .join(', ');
  const daily = (summary?.daily || []).slice(-14);
  const platformConfig = summary?.config?.platform_api || {};
  const modelConfig = summary?.config?.model_api || {};
  const maxUserCost = Math.max(1, ...users.map(user => Number(user.total_cost_usd || 0)));
  const maxDailyCost = Math.max(1, ...daily.map(day => Number(day.total_cost_usd || 0)));
  const flowSegments = (row) => COST_FLOW_ORDER.map(flow => ({
    key: flow,
    label: COST_FLOW_LABELS[flow],
    value: row?.flows?.[flow]?.total_cost_usd || 0,
  }));
  const platformRows = [...LIVE_SOURCES, 'unknown'].map(platform => ({
    key: platform,
    label: platformConfig?.[platform]?.label || PLATFORMS?.[platform]?.label || platform,
    total: summary?.platforms?.[platform] || {},
    config: platformConfig?.[platform] || {},
  }));
  const platformFlowRows = platformRows.map(row => ({
    ...row,
    flows: summary?.platform_flows?.[row.key] || {},
  }));
  const modelRows = Object.entries(modelConfig).map(([key, item]) => ({ key, ...(item || {}) }));
  const handleUserFilterChange = (event) => {
    const userId = event.target.value;
    const nextUserIds = userId === '__all__' ? [] : [userId];
    setSelectedUserIds(nextUserIds);
    load(nextUserIds);
  };

  return (
    <section className="cost-dashboard admin-grid-view">
      <div className="content-hdr">
        <div>
          <div className="content-title">Cost Dashboard</div>
          <div className="content-sub">Search, Auto Research, and Auto-Remodel spend by user and platform</div>
        </div>
        <div className="spacer" />
        <button className="btn-secondary" onClick={() => load(selectedUserIds)} disabled={loading}><Icon name="loader" size={14} /> Refresh</button>
      </div>
      {error && <div className="access-error">{error}</div>}
      {loading && !summary && <div className="access-loading">Loading costs...</div>}
      {summary && (
        <>
          <section className="cost-panel cost-user-filter-panel">
            <div className="cost-panel-head">
              <div><strong>User Filter</strong><span>{filterActive ? `Showing ${selectedUserNames}` : 'Showing all users'}</span></div>
            </div>
            <div className="cost-user-filter-control">
              <select className="cost-user-filter-select" value={selectedUserValue} onChange={handleUserFilterChange} disabled={loading || userOptions.length === 0}>
                <option value="__all__">Select All</option>
                {userOptions.map(user => (
                  <option key={user.user_id} value={user.user_id}>{user.email ? `${user.email} - ${user.user_id}` : user.user_id}</option>
                ))}
              </select>
              {filterActive && <button className="btn-secondary compact" onClick={() => { setSelectedUserIds([]); load([]); }} disabled={loading}>Clear</button>}
            </div>
            {userOptions.length === 0 && <div className="access-empty">No users with cost activity yet.</div>}
          </section>

          <div className="cost-kpi-grid">
            <div className="cost-kpi primary"><span>{filterActive ? 'Selected User Cost' : 'Total Organization Cost'}</span><strong>{costMoney(org.total_cost_usd)}</strong><small>{compactNumber(org.users)} users in view</small></div>
            <div className="cost-kpi"><span>Tracked LLM Cost</span><strong>{costMoney(org.tracked_cost_usd)}</strong><small>Filter + remodel ledger</small></div>
            <div className="cost-kpi"><span>Estimated Search Cost</span><strong>{costMoney(org.estimated_cost_usd)}</strong><small>Platform + scoring config</small></div>
            <div className="cost-kpi"><span>Winning Ads Found</span><strong>{compactNumber(org.winning_ads_found)}</strong><small>{compactNumber(org.videos_searched)} videos searched</small></div>
          </div>

          <div className="cost-main-grid">
            <section className="cost-panel cost-user-panel">
              <div className="cost-panel-head">
                <div><strong>User Cost</strong><span>Stacked by flow</span></div>
                <div className="cost-legend">
                  {COST_FLOW_ORDER.map(flow => <span key={flow}><i className={flow} />{COST_FLOW_LABELS[flow]}</span>)}
                </div>
              </div>
              <div className="cost-user-list">
                {users.length === 0 && <div className="access-empty">No cost rows yet.</div>}
                {users.map(user => (
                  <div className="cost-user-row" key={user.user_id}>
                    <div className="cost-user-id">
                      <strong>{user.email || user.user_id}</strong>
                      <span>{compactNumber(user.videos_searched)} videos - {compactNumber(user.winning_ads_found)} winners</span>
                    </div>
                    <div className="cost-user-bar-wrap">
                      <CostStackedBar segments={flowSegments(user)} total={maxUserCost} />
                    </div>
                    <div className="cost-user-total">
                      <strong>{costMoney(user.total_cost_usd)}</strong>
                      <span>{costMoney(user.tracked_cost_usd)} tracked</span>
                    </div>
                  </div>
                ))}
              </div>
            </section>

            <section className="cost-panel cost-daily-panel">
              <div className="cost-panel-head"><div><strong>Daily Cost</strong><span>Last {daily.length || 0} days</span></div></div>
              <div className="cost-daily-bars">
                {daily.length === 0 && <div className="access-empty">No daily activity yet.</div>}
                {daily.map(day => (
                  <div className="cost-daily-row" key={day.date}>
                    <span>{day.date}</span>
                    <div className="cost-daily-track"><CostStackedBar segments={flowSegments(day)} total={maxDailyCost} /></div>
                    <strong>{costMoney(day.total_cost_usd)}</strong>
                  </div>
                ))}
              </div>
            </section>
          </div>

          <div className="cost-platform-grid">
            {platformRows.map(row => (
              <section className="cost-platform-card" key={row.key}>
                <div className="cost-platform-title"><span>{row.label}</span><strong>{costMoney(row.total.total_cost_usd)}</strong></div>
                <div className="cost-platform-metrics">
                  <span>{compactNumber(row.total.videos_searched)} videos</span>
                  <span>{compactNumber(row.total.winning_ads_found)} winners</span>
                  <span>{costMoney(row.total.tracked_cost_usd)} tracked</span>
                </div>
              </section>
            ))}
          </div>

          <section className="cost-panel cost-platform-flow-panel">
            <div className="cost-panel-head"><div><strong>Platform Split</strong><span>Cost by platform and flow</span></div></div>
            <div className="cost-table-scroll">
              <table className="cost-platform-flow-table">
                <thead>
                  <tr>
                    <th>Platform</th>
                    {COST_FLOW_ORDER.map(flow => <th key={flow}>{COST_FLOW_LABELS[flow]}</th>)}
                    <th>Videos</th>
                    <th>Winners</th>
                    <th>Total</th>
                  </tr>
                </thead>
                <tbody>
                  {platformFlowRows.map(row => (
                    <tr key={row.key}>
                      <td>{row.label}</td>
                      {COST_FLOW_ORDER.map(flow => {
                        const flowMetrics = row.flows?.[flow] || {};
                        return (
                          <td key={flow}>
                            <div className="cost-flow-cell">
                              <strong>{costMoney(flowMetrics.total_cost_usd || 0)}</strong>
                              <span>{compactNumber(flowMetrics.videos_searched)} videos</span>
                            </div>
                          </td>
                        );
                      })}
                      <td>{compactNumber(row.total.videos_searched)}</td>
                      <td>{compactNumber(row.total.winning_ads_found)}</td>
                      <td><strong>{costMoney(row.total.total_cost_usd)}</strong></td>
                    </tr>
                  ))}
                </tbody>
              </table>
            </div>
          </section>

          <section className="cost-panel cost-config-panel">
            <div className="cost-panel-head"><div><strong>Cost Config</strong><span>Update values in settings when rates change</span></div></div>
            <div className="cost-config-grid">
              <table className="cost-config-table">
                <thead><tr><th>Platform</th><th>Video Search Cost</th><th>API Key</th><th>Status</th></tr></thead>
                <tbody>
                  {LIVE_SOURCES.map(platform => {
                    const item = platformConfig?.[platform] || {};
                    return (
                      <tr key={platform}>
                        <td>{item.label || PLATFORMS?.[platform]?.label || platform}</td>
                        <td>{costMoney(item.cost_per_video_searched_usd || 0)} / video</td>
                        <td><code>{item.api_key_env_var || 'Not set'}</code></td>
                        <td><span className={`cost-key-state ${item.api_key_configured ? 'ok' : 'missing'}`}>{item.api_key_configured ? 'Configured' : 'Missing'}</span></td>
                      </tr>
                    );
                  })}
                </tbody>
              </table>
              <table className="cost-config-table">
                <thead><tr><th>Model Stage</th><th>Model</th><th>Cost</th><th>API Key</th></tr></thead>
                <tbody>
                  {modelRows.map(row => (
                    <tr key={row.key}>
                      <td>{row.label || row.key}</td>
                      <td>{row.model || row.provider || 'Configured model'}</td>
                      <td>{row.cost_per_video_usd !== undefined ? `${costMoney(row.cost_per_video_usd)} / video` : `${costMoney(row.input_per_mtok_usd)} in / ${costMoney(row.output_per_mtok_usd)} out MTok`}</td>
                      <td><code>{row.api_key_env_var || 'Not set'}</code> <span className={`cost-key-state ${row.api_key_configured ? 'ok' : 'missing'}`}>{row.api_key_configured ? 'Configured' : 'Missing'}</span></td>
                    </tr>
                  ))}
                </tbody>
              </table>
            </div>
          </section>
        </>
      )}
    </section>
  );
}

function AdminWinningCriteriaManager() {
  const [rows, setRows] = useState([]);
  const [selectedPlatform, setSelectedPlatform] = useState('foreplay');
  const [form, setForm] = useState(blankCriteriaForm());
  const [loading, setLoading] = useState(false);
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState('');

  const orderedRows = CRITERIA_PLATFORM_ORDER
    .map(platform => rows.find(row => row.platform === platform))
    .filter(Boolean);
  const selected = rows.find(row => row.platform === selectedPlatform) || orderedRows[0] || null;
  const signalsText = (signals) => Array.isArray(signals) ? signals.join('\n') : (signals || '');

  const load = async () => {
    setLoading(true);
    setError('');
    try {
      const data = await RESILIA_API.adminListWinningAdCriteria();
      const nextRows = data.criteria || [];
      setRows(nextRows);
      if (!nextRows.some(row => row.platform === selectedPlatform) && nextRows[0]) {
        setSelectedPlatform(nextRows[0].platform);
      }
    } catch (e) {
      setError(e.message || 'Winning ad criteria failed to load');
    } finally {
      setLoading(false);
    }
  };

  useEffect(() => { load(); }, []);
  useEffect(() => {
    if (!selected) return;
    const criteria = selected.criteria || {};
    setForm({
      pass_threshold: String(criteria.pass_threshold ?? 7.5),
      video_scoring_criteria: criteria.video_scoring_criteria || criteria.score_rubric || '',
      rubric: criteria.rubric || '',
      source_specific_signals: signalsText(criteria.source_specific_signals),
    });
  }, [selected]);

  const updateForm = (patch) => setForm(current => ({ ...current, ...patch }));
  const splitSignals = (value) => String(value || '')
    .split('\n')
    .map(line => line.trim())
    .filter(Boolean);

  const save = async () => {
    if (!selectedPlatform) return;
    const threshold = Number(form.pass_threshold);
    if (!Number.isFinite(threshold)) {
      setError('Pass threshold must be a number');
      return;
    }
    setBusy(true);
    setError('');
    try {
      const criteria = {
        ...(selected?.criteria || {}),
        pass_threshold: Math.max(0, Math.min(10, Math.round(threshold * 10) / 10)),
        video_scoring_criteria: form.video_scoring_criteria.trim(),
        rubric: form.rubric.trim(),
        source_specific_signals: splitSignals(form.source_specific_signals),
      };
      const out = await RESILIA_API.adminSaveWinningAdCriteria(selectedPlatform, criteria);
      const saved = out.criteria;
      setRows(current => current.map(row => row.platform === saved.platform ? saved : row));
    } catch (e) {
      setError(e.message || 'Save failed');
    } finally {
      setBusy(false);
    }
  };

  return (
    <section className="admin-grid-view">
      <div className="content-hdr">
        <div>
          <div className="content-title">Winning Ads Criteria</div>
          <div className="content-sub">{orderedRows.length || 0} platforms · scoring and winner selection</div>
        </div>
        <div className="spacer" />
        <button className="btn-secondary" onClick={load} disabled={loading || busy}><Icon name="loader" size={14} /> Refresh</button>
        <button className="btn-primary" onClick={save} disabled={busy || !selected}><Icon name="check" size={14} /> Save</button>
      </div>
      {error && <div className="access-error">{error}</div>}
      <div className="admin-grid criteria">
        <aside className="admin-list">
          {loading && <div className="access-loading">Loading...</div>}
          {orderedRows.map(row => {
            const platformMeta = PLATFORMS[row.platform] || {};
            return (
              <button
                key={row.platform}
                className={`admin-list-row ${selectedPlatform === row.platform ? 'active' : ''}`}
                onClick={() => setSelectedPlatform(row.platform)}
              >
                <span className="criteria-platform-name"><i style={{ background: platformMeta.color || 'var(--primary-500)' }} />{row.label || platformMeta.label || row.platform}</span>
                <small>Pass threshold {row.criteria?.pass_threshold ?? 7.5}</small>
                <small>{row.updated_at ? `Updated ${row.updated_at.slice(0, 19)}` : 'Default criteria'}</small>
              </button>
            );
          })}
        </aside>
        <section className="admin-editor criteria-editor">
          <div className="criteria-top-row">
            <div>
              <div className="admin-section-title">{selected?.label || 'Platform'}</div>
              <div className="content-sub">{selected?.platform || '—'}</div>
            </div>
            <label className="criteria-threshold">Pass Threshold<input type="number" min="0" max="10" step="0.1" value={form.pass_threshold} onChange={e => updateForm({ pass_threshold: e.target.value })} /></label>
          </div>
          <label className="admin-field-full criteria-score">Video Scoring Criteria<textarea value={form.video_scoring_criteria} onChange={e => updateForm({ video_scoring_criteria: e.target.value })} spellCheck="true" /></label>
          <label className="admin-field-full criteria-rubric">Platform Winning Criteria<textarea value={form.rubric} onChange={e => updateForm({ rubric: e.target.value })} spellCheck="true" /></label>
          <label className="admin-field-full criteria-signals">Source Signals<textarea value={form.source_specific_signals} onChange={e => updateForm({ source_specific_signals: e.target.value })} spellCheck="true" /></label>
          <div className="criteria-meta-grid">
            <div><span>Created</span><strong>{selected?.created_at ? selected.created_at.slice(0, 19) : '—'}</strong></div>
            <div><span>Updated</span><strong>{selected?.updated_at ? selected.updated_at.slice(0, 19) : '—'}</strong></div>
            <div><span>Updated By</span><strong>{selected?.updated_by_email || selected?.updated_by || 'system'}</strong></div>
          </div>
        </section>
      </div>
    </section>
  );
}

function AdminWorkspaceMirror() {
  const [users, setUsers] = useState([]);
  const [selectedId, setSelectedId] = useState(null);
  const [workspace, setWorkspace] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState('');

  const loadUsers = async () => {
    setLoading(true);
    setError('');
    try {
      const data = await RESILIA_API.adminListWorkspaces();
      const rows = data.workspaces || [];
      setUsers(rows);
      if (!selectedId && rows.length) setSelectedId(rows[0].user_id);
    } catch (e) {
      setError(e.message || 'User work failed to load');
    } finally {
      setLoading(false);
    }
  };

  useEffect(() => { loadUsers(); }, []);
  useEffect(() => {
    if (!selectedId) return;
    let cancelled = false;
    (async () => {
      setError('');
      try {
        const data = await RESILIA_API.adminGetWorkspace(selectedId);
        if (!cancelled) setWorkspace(data);
      } catch (e) {
        if (!cancelled) setError(e.message || 'Workspace failed to load');
      }
    })();
    return () => { cancelled = true; };
  }, [selectedId]);

  const renderAds = (title, ads) => (
    <section className="admin-work-card">
      <header><strong>{title}</strong><span>{(ads || []).length}</span></header>
      {(ads || []).length === 0 ? <div className="admin-work-empty">No rows</div> : (
        <div className="admin-work-ads">
          {(ads || []).slice(0, 12).map(ad => (
            <div className="admin-work-ad" key={`${title}-${ad.ad_id || ad.id}`}>
              <span>{ad.brand || ad.ad_id || ad.id}</span>
              <small>{ad.direction_id || 'manual'} · {ad.source_platform || ad.platform || 'source'} · {typeof ad.score === 'number' ? ad.score.toFixed(1) : '—'}</small>
            </div>
          ))}
        </div>
      )}
    </section>
  );

  return (
    <section className="admin-grid-view">
      <div className="content-hdr">
        <div>
          <div className="content-title">User Work Viewer</div>
          <div className="content-sub">{users.length} saved workspace{users.length === 1 ? '' : 's'} · latest Search, Autoresearch, and Auto-Remodel snapshots</div>
        </div>
        <div className="spacer" />
        <button className="btn-secondary" onClick={loadUsers}><Icon name="loader" size={14} /> Refresh</button>
      </div>
      {error && <div className="access-error">{error}</div>}
      <div className="admin-grid workspaces">
        <aside className="admin-list">
          {loading && <div className="access-loading">Loading...</div>}
          {users.map(user => (
            <button key={user.user_id} className={`admin-list-row ${selectedId === user.user_id ? 'active' : ''}`} onClick={() => setSelectedId(user.user_id)}>
              <span>{user.email || user.user_id}</span>
              <small>{user.search_count} search · {user.autoresearch_count} autoresearch · {user.auto_remodel_count} auto</small>
            </button>
          ))}
        </aside>
        <section className="admin-workspace-detail">
          {!workspace ? <div className="empty"><span className="empty-display">No user selected</span></div> : (
            <>
              <div className="admin-work-summary">
                <div><span>Search</span><strong>{workspace.search_run?.result_count || 0}</strong></div>
                <div><span>Autoresearch</span><strong>{workspace.autoresearch_run?.result_count || 0}</strong></div>
                <div><span>Auto-Remodel</span><strong>{workspace.auto_remodel_run?.result_count || 0}</strong></div>
                <div><span>Filter</span><strong>{workspace.filter_count || 0}</strong></div>
                <div><span>Remodel</span><strong>{workspace.remodel_count || 0}</strong></div>
              </div>
              <div className="admin-work-sections">
                {renderAds('Search', workspace.search_ads)}
                {renderAds('Autoresearch', workspace.autoresearch_ads)}
                {renderAds('Auto-Remodel', workspace.auto_remodel_ads)}
              </div>
            </>
          )}
        </section>
      </div>
    </section>
  );
}

function AccessRequestsAdminView({ onPendingCountChange }) {
  const filters = ['pending', 'approved', 'rejected', 'all'];
  const [status, setStatus] = useState('pending');
  const [rows, setRows] = useState([]);
  const [counts, setCounts] = useState({ pending: 0, approved: 0, rejected: 0, all: 0 });
  const [notes, setNotes] = useState({});
  const [grantAdminMap, setGrantAdminMap] = useState({});
  const [busyId, setBusyId] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState('');

  const load = async () => {
    setLoading(true);
    setError('');
    try {
      const data = await RESILIA_API.listAccessRequests(status);
      const nextCounts = data.counts || { pending: 0, approved: 0, rejected: 0, all: 0 };
      setRows(Array.isArray(data.requests) ? data.requests : []);
      setCounts(nextCounts);
      onPendingCountChange(nextCounts.pending || 0);
    } catch (e) {
      setError(e.message || 'Failed to load access requests');
    } finally {
      setLoading(false);
    }
  };

  useEffect(() => { load(); }, [status]);

  const formatDate = (value) => {
    if (!value) return 'Unknown';
    const date = new Date(value);
    return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
  };

  const review = async (row, action) => {
    setBusyId(row.id);
    setError('');
    try {
      const note = notes[row.id] || '';
      if (action === 'approve') {
        await RESILIA_API.approveAccessRequest(row.id, note, { grantAdmin: !!grantAdminMap[row.id] });
      } else {
        await RESILIA_API.rejectAccessRequest(row.id, note);
      }
      setNotes(current => ({ ...current, [row.id]: '' }));
      setGrantAdminMap(current => ({ ...current, [row.id]: false }));
      await load();
    } catch (e) {
      setError(e.message || `Failed to ${action} request`);
    } finally {
      setBusyId(null);
    }
  };

  return (
    <section className="access-admin">
      <div className="content-hdr">
        <div>
          <div className="content-title">Access Requests</div>
          <div className="content-sub">{counts.pending || 0} pending</div>
        </div>
        <div className="spacer" />
        <div className="access-filters" role="tablist" aria-label="Access request filters">
          {filters.map(key => (
            <button
              key={key}
              className={`access-filter ${status === key ? 'active' : ''}`}
              onClick={() => setStatus(key)}
            >
              {key}
              <span>{counts[key] || 0}</span>
            </button>
          ))}
        </div>
      </div>

      {error && <div className="access-error">{error}</div>}
      {loading && <div className="access-loading">Loading requests...</div>}
      {!loading && rows.length === 0 && (
        <div className="empty">
          <div className="empty-display">No requests</div>
          <p className="empty-body">The selected queue is clear.</p>
        </div>
      )}
      {!loading && rows.length > 0 && (
        <div className="access-table">
          {rows.map(row => (
            <article className="access-row" key={row.id}>
              <div className="access-main">
                <div className="access-name-line">
                  <strong>{row.name}</strong>
                  <span className={`status-pill ${row.status}`}>{row.status}</span>
                </div>
                <div className="access-meta">
                  <span>{row.email}</span>
                  <span>{row.role}</span>
                  <span>{formatDate(row.requested_at)}</span>
                  {row.company_team && <span>{row.company_team}</span>}
                </div>
                <p className="access-reason">{row.reason}</p>
                {row.notes && <p className="access-notes">Notes: {row.notes}</p>}
              </div>
              {row.status === 'pending' && (
                <div className="access-actions">
                  <textarea
                    value={notes[row.id] || ''}
                    onChange={(event) => setNotes(current => ({ ...current, [row.id]: event.target.value }))}
                    placeholder="Optional notes"
                  />
                  <label className="access-grant-admin" style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12 }}>
                    <input
                      type="checkbox"
                      checked={!!grantAdminMap[row.id]}
                      onChange={(event) => setGrantAdminMap(current => ({ ...current, [row.id]: event.target.checked }))}
                    />
                    Grant admin access on approval
                  </label>
                  <div className="access-action-buttons">
                    <button
                      className="btn-primary"
                      disabled={busyId === row.id}
                      onClick={() => review(row, 'approve')}
                    >
                      {grantAdminMap[row.id] ? 'Approve as admin' : 'Approve'}
                    </button>
                    <button
                      className="btn-secondary danger"
                      disabled={busyId === row.id}
                      onClick={() => review(row, 'reject')}
                    >
                      Reject
                    </button>
                  </div>
                </div>
              )}
            </article>
          ))}
        </div>
      )}
    </section>
  );
}

function AdminUsersView() {
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
  const displayName = (user) => {
    const name = (user.username || '').trim();
    if (!name || UUID_RE.test(name)) {
      return (user.email || '').split('@')[0] || user.email || 'Unknown user';
    }
    return name;
  };
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(false);
  const [busyEmail, setBusyEmail] = useState(null);
  const [error, setError] = useState('');
  const [filter, setFilter] = useState('');

  const load = async () => {
    setLoading(true);
    setError('');
    try {
      const data = await RESILIA_API.listAdminUsers();
      setUsers(Array.isArray(data.users) ? data.users : []);
    } catch (e) {
      setError(e.message || 'Failed to load users');
    } finally {
      setLoading(false);
    }
  };

  useEffect(() => { load(); }, []);

  const formatDate = (value) => {
    if (!value) return '—';
    const date = new Date(value);
    return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
  };

  const toggleAdmin = async (user, nextValue) => {
    if (user.admin_locked) return;
    setBusyEmail(user.email);
    setError('');
    try {
      await RESILIA_API.setAdminStatus(user.email, nextValue);
      await load();
    } catch (e) {
      setError(e.message || 'Failed to update admin status');
    } finally {
      setBusyEmail(null);
    }
  };

  const visible = filter
    ? users.filter(u => {
        const needle = filter.toLowerCase();
        return (u.email || '').toLowerCase().includes(needle)
          || displayName(u).toLowerCase().includes(needle);
      })
    : users;
  const adminCount = users.filter(u => u.is_admin).length;

  return (
    <section className="access-admin">
      <div className="content-hdr">
        <div>
          <div className="content-title">User Manager</div>
          <div className="content-sub">{users.length} known users · {adminCount} admin</div>
        </div>
        <div className="spacer" />
        <input
          className="input"
          placeholder="Filter by email or name"
          value={filter}
          onChange={(e) => setFilter(e.target.value)}
          style={{ minWidth: 220 }}
        />
        <button className="btn-secondary" onClick={load} disabled={loading} style={{ marginLeft: 8 }}>
          {loading ? 'Refreshing…' : 'Refresh'}
        </button>
      </div>

      {error && <div className="access-error">{error}</div>}
      {loading && users.length === 0 && <div className="access-loading">Loading users…</div>}
      {!loading && visible.length === 0 && (
        <div className="empty">
          <div className="empty-display">No users</div>
          <p className="empty-body">No users match the current filter.</p>
        </div>
      )}
      {visible.length > 0 && (
        <div className="access-table">
          {visible.map(user => {
            const busy = busyEmail === user.email;
            return (
              <article className="access-row" key={user.email}>
                <div className="access-main">
                  <div className="access-name-line">
                    <strong>{displayName(user)}</strong>
                    {user.is_admin && (
                      <span className={`status-pill approved`} title={user.admin_source === 'config' ? 'Configured in settings — cannot be revoked here' : 'Granted from this dashboard'}>
                        admin{user.admin_source === 'config' ? ' · locked' : ''}
                      </span>
                    )}
                    {!user.is_admin && <span className="status-pill pending">user</span>}
                  </div>
                  <div className="access-meta">
                    <span>{user.email}</span>
                    <span>Last seen: {formatDate(user.last_seen_at)}</span>
                    {user.granted_by && <span>Granted by: {user.granted_by}</span>}
                  </div>
                </div>
                <div className="access-actions">
                  <div className="access-action-buttons">
                    {user.is_admin ? (
                      <button
                        className="btn-secondary danger"
                        disabled={busy || user.admin_locked}
                        title={user.admin_locked ? 'Configured in settings — edit settings.yaml to revoke' : 'Revoke admin access'}
                        onClick={() => toggleAdmin(user, false)}
                      >
                        {user.admin_locked ? 'Locked' : (busy ? 'Revoking…' : 'Revoke admin')}
                      </button>
                    ) : (
                      <button
                        className="btn-primary"
                        disabled={busy}
                        onClick={() => toggleAdmin(user, true)}
                      >
                        {busy ? 'Granting…' : 'Make admin'}
                      </button>
                    )}
                  </div>
                </div>
              </article>
            );
          })}
        </div>
      )}
    </section>
  );
}

function ResearchView({
  rows, setRows, competitors,
  ads, searching, searchingRowIds, sel, onToggle, onSearch, onSearchRow, onExpandAd, density, minimal, groupByPlatform,
  searchWarnings, latestSearchRun, latestAutoresearchRun,
  arRunning, arProgress, arSources, onToggleArSource, onSelectAllArSources,
  arTarget, setArTarget, onRunAutoresearch, onStopAutoresearch,
  arMaxAdsPerDirection, setArMaxAdsPerDirection,
  arMaxAdsPerPlatform, setArMaxAdsPerPlatform,
  arNumDirections, setArNumDirections,
  directions, arPickedDirections, onToggleArPickedDirection, onClearArPickedDirections,
  onSelectAllArPickedDirections,
  autoresearchPage, onLoadMoreAutoresearch,
}) {
  const updateRow = (id, patch) => setRows(rows.map(r => r.id === id ? patch : r));
  const removeRow = (id) => setRows(rows.filter(r => r.id !== id));
  const addRow = () => setRows([...rows, {
    id: Date.now(), query: '', competitorId: null, count: 20,
    filters: defaultSearchFilters(),
    platforms: sourceState(),
  }]);

  const searchAds = ads.filter(a => a.source !== 'autoresearch');
  const autoAds = ads.filter(a => a.source === 'autoresearch');
  const currentSearchAds = searchAds.filter(a => a.currentSearchRun);
  const previousSearchAds = searchAds.filter(a => !a.currentSearchRun);
  const currentAutoAds = autoAds.filter(a => a.currentAutoresearchRun);
  const previousAutoAds = autoAds.filter(a => !a.currentAutoresearchRun);
  // Bug 15 — Manual Search defaults to "passing only" (score >= 7.5 / passes
  // flag from the server) so low-score ads do not clutter the list. Users
  // can flip the toggle off for prompt calibration / scorer sanity-checks.
  const [searchPassesOnly, setSearchPassesOnly] = React.useState(true);
  const isPassing = React.useCallback((a) => {
    if (typeof a.passes === 'boolean') return a.passes;
    return typeof a.score === 'number' && a.score >= 7.5;
  }, []);
  const passingSearchAds = React.useMemo(() => {
    if (!searchPassesOnly) return searchAds;
    return searchAds.filter(isPassing);
  }, [searchAds, searchPassesOnly, isPassing]);
  // Same passes-only filter applied to Autoresearch winners so the toggle
  // affects every result panel on the Research tab in one click.
  const passingAutoAds = React.useMemo(() => {
    if (!searchPassesOnly) return autoAds;
    return autoAds.filter(isPassing);
  }, [autoAds, searchPassesOnly, isPassing]);
  const passingCurrentAutoAds = React.useMemo(() => {
    if (!searchPassesOnly) return currentAutoAds;
    return currentAutoAds.filter(isPassing);
  }, [currentAutoAds, searchPassesOnly, isPassing]);
  const passingPreviousAutoAds = React.useMemo(() => {
    if (!searchPassesOnly) return previousAutoAds;
    return previousAutoAds.filter(isPassing);
  }, [previousAutoAds, searchPassesOnly, isPassing]);
  const passingCurrentSearchAds = React.useMemo(() => {
    if (!searchPassesOnly) return currentSearchAds;
    return currentSearchAds.filter(isPassing);
  }, [currentSearchAds, searchPassesOnly, isPassing]);
  const passingPreviousSearchAds = React.useMemo(() => {
    if (!searchPassesOnly) return previousSearchAds;
    return previousSearchAds.filter(isPassing);
  }, [previousSearchAds, searchPassesOnly, isPassing]);
  const hiddenLowScoreCount =
    (searchAds.length - passingSearchAds.length) +
    (autoAds.length - passingAutoAds.length);
  const currentSearchHiddenCount = Math.max(0, currentSearchAds.length - passingCurrentSearchAds.length);
  const previousSearchHiddenCount = Math.max(0, previousSearchAds.length - passingPreviousSearchAds.length);
  const currentAutoHiddenCount = Math.max(0, currentAutoAds.length - passingCurrentAutoAds.length);
  const previousAutoHiddenCount = Math.max(0, previousAutoAds.length - passingPreviousAutoAds.length);
  const currentSearchGrouped = groupByPlatform(passingCurrentSearchAds);
  const previousSearchGrouped = groupByPlatform(passingPreviousSearchAds);
  const currentAutoGrouped = groupByPlatform(passingCurrentAutoAds);
  const previousAutoGrouped = groupByPlatform(passingPreviousAutoAds);
  const currentSearchPlatforms = Object.keys(currentSearchGrouped).filter(p => currentSearchGrouped[p].length > 0);
  const previousSearchPlatforms = Object.keys(previousSearchGrouped).filter(p => previousSearchGrouped[p].length > 0);
  const currentAutoPlatforms = Object.keys(currentAutoGrouped).filter(p => currentAutoGrouped[p].length > 0);
  const previousAutoPlatforms = Object.keys(previousAutoGrouped).filter(p => previousAutoGrouped[p].length > 0);
  const visibleSearchPlatformCount = currentSearchPlatforms.length + previousSearchPlatforms.length;
  const visibleAutoPlatformCount = currentAutoPlatforms.length + previousAutoPlatforms.length;
  const showAutoLoadMore = currentAutoPlatforms.length > 0 && autoresearchPage?.hasMore;

  return (
    <>
      <div className="search-card">
        <div className="sc-hdr">
          <span className="sc-title">Search</span>
          <span className="sc-sub">Multi-row keyword or competitor-scoped search · Foreplay default, AdSpy and BrandSearch selectable</span>
          <div className="sc-actions">
            <label
              className="sc-passes-toggle"
              title="Hide ads scored below the 7.5 winner threshold. Toggle off to see the full Sonnet score distribution."
              onClick={(e) => e.stopPropagation()}
              style={{
                display: 'inline-flex',
                alignItems: 'center',
                gap: 6,
                fontSize: 12,
                color: 'var(--fg-2)',
                userSelect: 'none',
                cursor: 'pointer',
              }}
            >
              <input
                type="checkbox"
                checked={searchPassesOnly}
                onClick={(e) => e.stopPropagation()}
                onChange={(e) => {
                  e.stopPropagation();
                  setSearchPassesOnly(e.target.checked);
                }}
              />
              Show only passing ads (≥7.5)
              {searchPassesOnly && hiddenLowScoreCount > 0 && (
                <span
                  style={{ color: 'var(--fg-3)' }}
                  title={`${hiddenLowScoreCount} below-threshold ad(s) hidden across Search and Autoresearch`}
                >
                  · {hiddenLowScoreCount} hidden
                </span>
              )}
            </label>
            <button
              type="button"
              className="btn-primary"
              onClick={onSearch}
              disabled={searching}
            >
              {searching ? <><Icon name="loader" size={14} className="icon-spin" />Searching…</> : <><Icon name="search" size={14} />Search</>}
            </button>
          </div>
        </div>
        <div className="search-rows">
          {rows.map(r => (
            <SearchRow
              key={r.id}
              row={r}
              competitors={competitors}
              onChange={(patch) => updateRow(r.id, patch)}
              onRemove={() => removeRow(r.id)}
              onSearch={() => onSearchRow && onSearchRow(r.id)}
              searching={!!searchingRowIds?.has(r.id)}
              canRemove={rows.length > 1}
            />
          ))}
        </div>
        <button className="sr-add" onClick={addRow}>
          <Icon name="plus" size={14} /> Add row
        </button>
      </div>

      {Array.isArray(searchWarnings) && searchWarnings.filter(w => w && w.severity === 'high').length > 0 && (
        <div
          role="alert"
          style={{
            margin: '12px 14px 0',
            padding: '10px 14px',
            borderRadius: 8,
            background: '#fff1f1',
            border: '1px solid #f5c2c2',
            color: '#a01c1c',
            display: 'flex',
            flexDirection: 'column',
            gap: 4,
            fontSize: 13,
          }}
        >
          <div style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontWeight: 600 }}>
            <span aria-hidden="true">⚠</span>
            Some platforms returned no matching ads
          </div>
          {searchWarnings
            .filter(w => w && w.severity === 'high')
            .map((w, i) => (
              <div key={i} style={{ paddingLeft: 20 }}>
                <strong>{(w.source || 'platform').toUpperCase()}</strong>
                {w.query ? <> · query <em>“{w.query}”</em></> : null}
                {' — '}
                {w.detail || 'no ads matched the criteria.'}
              </div>
            ))}
        </div>
      )}

      {latestSearchRun && (
        <div className="search-run-summary current" role="status" aria-live="polite">
          <div className="search-run-main">
            <span className="search-run-title"><Icon name="search" size={13} /> Current search output</span>
            <span className="search-run-count">{Math.max(latestSearchRun.count || 0, currentSearchAds.length)} ads output</span>
            {searchPassesOnly && currentSearchHiddenCount > 0 && (
              <span className="search-run-muted">{currentSearchHiddenCount} hidden below 7.5</span>
            )}
          </div>
          {Array.isArray(latestSearchRun.labels) && latestSearchRun.labels.length > 0 && (
            <span className="search-run-labels" title={latestSearchRun.labels.join(', ')}>
              {latestSearchRun.labels.slice(0, 3).join(' + ')}{latestSearchRun.labels.length > 3 ? ` +${latestSearchRun.labels.length - 3}` : ''}
            </span>
          )}
        </div>
      )}

      {currentSearchPlatforms.length > 0 && currentSearchPlatforms.map(p => (
        <PlatformPanel
          key={`current-s-${p}`}
          platform={p}
          ads={currentSearchGrouped[p]}
          selected={sel}
          onToggleAd={onToggle}
          onExpandAd={onExpandAd}
          minimal={minimal}
          density={density}
          showWinnersBadge={false}
        />
      ))}

      {latestSearchRun && latestSearchRun.count === 0 && !searching && (
        <div className="search-run-empty">Current search returned 0 ads.</div>
      )}

      {previousSearchPlatforms.length > 0 && (
        <div className="search-run-summary previous">
          <div className="search-run-main">
            <span className="search-run-title">Previous search ads</span>
            <span className="search-run-count">{previousSearchAds.length} retained</span>
            {searchPassesOnly && previousSearchHiddenCount > 0 && (
              <span className="search-run-muted">{previousSearchHiddenCount} hidden below 7.5</span>
            )}
          </div>
        </div>
      )}

      {previousSearchPlatforms.length > 0 && previousSearchPlatforms.map(p => (
        <PlatformPanel
          key={`previous-s-${p}`}
          platform={p}
          ads={previousSearchGrouped[p]}
          selected={sel}
          onToggleAd={onToggle}
          onExpandAd={onExpandAd}
          minimal={minimal}
          density={density}
          showWinnersBadge={false}
        />
      ))}

      {searching && visibleSearchPlatformCount === 0 && (
        <div className="empty" role="status" aria-live="polite">
          <span className="empty-display" style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
            <Icon name="loader" size={16} /> Searching…
          </span>
          <p className="empty-body">
            Fetching ads from the selected platforms. Results will appear here as soon as the first batch returns.
          </p>
        </div>
      )}

      <AutoresearchPanel
        sources={arSources}
        onToggleSource={onToggleArSource}
        onSelectAllSources={onSelectAllArSources}
        perDirectionTarget={arTarget}
        onPerDirectionTarget={setArTarget}
        maxAdsPerDirection={arMaxAdsPerDirection}
        onMaxAdsPerDirection={setArMaxAdsPerDirection}
        maxAdsPerPlatform={arMaxAdsPerPlatform}
        onMaxAdsPerPlatform={setArMaxAdsPerPlatform}
        numDirections={arNumDirections}
        onNumDirections={setArNumDirections}
        directions={directions}
        pickedDirections={arPickedDirections}
        onTogglePickedDirection={onToggleArPickedDirection}
        onClearPickedDirections={onClearArPickedDirections}
        onSelectAllPickedDirections={onSelectAllArPickedDirections}
        onRun={onRunAutoresearch}
        onStop={onStopAutoresearch}
        running={arRunning}
        progress={arProgress}
        disabled={false}
      />

      {latestAutoresearchRun && (
        <div className="search-run-summary current" role="status" aria-live="polite">
          <div className="search-run-main">
            <span className="search-run-title"><Icon name="bolt" size={13} /> Current Autoresearch output</span>
            <span className="search-run-count">{latestAutoresearchRun.count || currentAutoAds.length} ads output</span>
            {latestAutoresearchRun.status && latestAutoresearchRun.status !== 'restored' && (
              <span className="search-run-muted">{latestAutoresearchRun.status}</span>
            )}
            {searchPassesOnly && currentAutoHiddenCount > 0 && (
              <span className="search-run-muted">{currentAutoHiddenCount} hidden below 7.5</span>
            )}
          </div>
          {Array.isArray(latestAutoresearchRun.labels) && latestAutoresearchRun.labels.length > 0 && (
            <span className="search-run-labels" title={latestAutoresearchRun.labels.join(', ')}>
              {latestAutoresearchRun.labels.join(' + ')}
            </span>
          )}
        </div>
      )}

      {currentAutoPlatforms.length > 0 && currentAutoPlatforms.map(p => (
        <PlatformPanel
          key={`current-a-${p}`}
          platform={p}
          ads={currentAutoGrouped[p]}
          selected={sel}
          onToggleAd={onToggle}
          onExpandAd={onExpandAd}
          minimal={minimal}
          density={density}
          showWinnersBadge={true}
        />
      ))}

      {showAutoLoadMore && (
        <div style={{ display: 'flex', justifyContent: 'center', gap: 12, padding: '0 14px 14px' }}>
          <button
            className="btn-secondary"
            onClick={onLoadMoreAutoresearch}
            disabled={autoresearchPage?.loading}
            title="Load the next Autoresearch winner page"
          >
            {autoresearchPage?.loading
              ? <><Icon name="loader" size={14} className="icon-spin" /> Loading</>
              : <><Icon name="chevronDown" size={14} /> Load more winners</>}
          </button>
        </div>
      )}

      {previousAutoPlatforms.length > 0 && (
        <div className="search-run-summary previous">
          <div className="search-run-main">
            <span className="search-run-title">Previous Autoresearch ads</span>
            <span className="search-run-count">{previousAutoAds.length} retained</span>
            {searchPassesOnly && previousAutoHiddenCount > 0 && (
              <span className="search-run-muted">{previousAutoHiddenCount} hidden below 7.5</span>
            )}
          </div>
        </div>
      )}

      {previousAutoPlatforms.length > 0 && previousAutoPlatforms.map(p => (
        <PlatformPanel
          key={`previous-a-${p}`}
          platform={p}
          ads={previousAutoGrouped[p]}
          selected={sel}
          onToggleAd={onToggle}
          onExpandAd={onExpandAd}
          minimal={minimal}
          density={density}
          showWinnersBadge={true}
        />
      ))}

      {visibleSearchPlatformCount === 0 && visibleAutoPlatformCount === 0 && !searching && !latestSearchRun && !latestAutoresearchRun && (
        <div className="empty">
          <span className="empty-display">No results yet.</span>
          <p className="empty-body">Run a search above or pick autoresearch directions and press Run Autoresearch.</p>
        </div>
      )}
    </>
  );
}

function PipelineView({ title, subtitle, ads, sel, onToggle, onExpandAd, density, minimal, groupByPlatform, emptyTitle, emptyBody, pagination, onLoadMore, preserveOrder, statusText }) {
  const grouped = groupByPlatform(ads);
  const activePlatforms = Object.keys(grouped).filter(p => grouped[p].length > 0);
  // Cycle: score → createdAt → arrival (raw insertion order) → score …
  // Defaults to 'score' so winners stay on top by default — mirrors the
  // PlatformPanel default used by Search and Autoresearch.
  const PIPELINE_SORTS = ['score', 'createdAt', 'arrival'];
  const [pipelineSort, setPipelineSort] = React.useState('score');
  const cyclePipelineSort = () => setPipelineSort(prev => {
    const idx = PIPELINE_SORTS.indexOf(prev);
    return PIPELINE_SORTS[(idx + 1) % PIPELINE_SORTS.length];
  });
  const pipelineSortLabel = pipelineSort === 'score'
    ? 'Score ↓'
    : pipelineSort === 'createdAt'
      ? 'Created at ↓'
      : 'Recent first';
  const orderedAds = React.useMemo(() => {
    if (pipelineSort === 'score') {
      return [...ads].sort((a, b) => (b.score ?? -1) - (a.score ?? -1));
    }
    if (pipelineSort === 'createdAt') {
      return [...ads].sort((a, b) => (b.createdAtMs || 0) - (a.createdAtMs || 0));
    }
    return ads;
  }, [ads, pipelineSort]);
  const loadMoreButton = pagination?.hasMore ? (
    <button className="btn-secondary" onClick={onLoadMore} disabled={pagination.loading} style={{ alignSelf: 'center' }}>
      {pagination.loading ? <><Icon name="loader" size={14} className="icon-spin" /> Loading</> : <><Icon name="chevronDown" size={14} /> Load more</>}
    </button>
  ) : null;
  return (
    <>
      <div className="content-hdr">
        <span className="content-title">{title}</span>
        <span className="content-sub">{subtitle}</span>
      </div>
      {statusText && <div className="inline-note">{statusText}</div>}
      {activePlatforms.length === 0 ? (
        <div className="empty">
          <span className="empty-display">{emptyTitle}</span>
          <p className="empty-body">{emptyBody}</p>
        </div>
      ) : preserveOrder ? (
        <>
          <section className="platform-panel">
            <header className="pp-hdr">
              <span className="pp-chip" style={{ background: '#e6fbf7', color: '#00796b' }}>{pipelineSortLabel}</span>
              <span className="pp-count">{ads.length} ads</span>
              <div className="pp-spacer" />
              {sel.size > 0 && <span className="pp-selected">{sel.size} selected</span>}
              <button className="ghost-btn" onClick={cyclePipelineSort} title="Cycle sort: Recent → Score → Created at">
                Sort: {pipelineSortLabel}
              </button>
            </header>
            <div className={`pp-grid density-${density}`}>
              {orderedAds.map(ad => (
                <AdCard
                  key={ad.id}
                  ad={ad}
                  selected={sel.has(ad.id)}
                  onToggle={() => onToggle(ad.id)}
                  onExpand={onExpandAd}
                  minimal={minimal}
                />
              ))}
            </div>
          </section>
          {loadMoreButton}
        </>
      ) : (
        <>
          {activePlatforms.map(p => (
            <PlatformPanel
              key={p}
              platform={p}
              ads={grouped[p]}
              selected={sel}
              onToggleAd={onToggle}
              onExpandAd={onExpandAd}
              minimal={minimal}
              density={density}
            />
          ))}
          {loadMoreButton}
        </>
      )}
    </>
  );
}

function AutoRemodelWorkspace({
  ads,
  scripts,
  directionOptions,
  running,
  starting,
  stage,
  lastEvent,
  adsFound,
  targetAds,
  variantsDone,
  variantsTotal,
  resultsTotal,
  resultsLoaded,
  resultsLoading,
  resultsRestoring,
  sources,
  onToggleSource,
  onSelectAllSources,
  onRun,
  onStop,
  onLoadMore,
  onEditScript,
  onRegenerate,
  onDiscard,
  onPromote,
  onPromoteAll,
}) {
  const readyJobs = ads.flatMap(ad => {
    const entry = scripts[ad.id];
    if (!entry || !Array.isArray(entry.variants)) return [];
    return entry.variants.filter(v => v.jobId).map(v => v.jobId);
  });
  return (
    <>
      <div className="content-hdr">
        <span className="content-title">Auto-Remodel</span>
        <span className="content-sub">Autonomous search, scoring, filter audit, and script generation.</span>
      </div>
      <AutoRemodelPanel
        open={true}
        directionOptions={directionOptions || []}
        running={running}
        starting={starting}
        stage={stage}
        lastEvent={lastEvent}
        adsFound={adsFound}
        targetAds={targetAds}
        variantsDone={variantsDone}
        variantsTotal={variantsTotal}
        sources={sources}
        onToggleSource={onToggleSource}
        onSelectAllSources={onSelectAllSources}
        onRun={onRun}
        onStop={onStop}
      />
      <AutoRemodelResultsView
        ads={ads}
        scripts={scripts}
        readyJobs={readyJobs}
        total={resultsTotal}
        loaded={resultsLoaded}
        loadingMore={resultsLoading}
        restoring={resultsRestoring}
        running={running}
        stage={stage}
        onLoadMore={onLoadMore}
        onEditScript={onEditScript}
        onRegenerate={onRegenerate}
        onDiscard={onDiscard}
        onPromote={onPromote}
        onPromoteAll={onPromoteAll}
      />
    </>
  );
}

function AutoRemodelResultsView({
  ads,
  scripts,
  readyJobs,
  total,
  loaded,
  loadingMore,
  restoring,
  running,
  stage,
  onLoadMore,
  onEditScript,
  onRegenerate,
  onDiscard,
  onPromote,
  onPromoteAll,
}) {
  const canPromoteAll = readyJobs.length > 0;
  // Bug 15 (Auto-Remodel) — same passing-only filter as Manual Search /
  // Autoresearch. Default ON: only ads scoring >= 7.5 (or with passes flag
  // true) appear. Toggle off to inspect the full Sonnet distribution.
  const [arPassesOnly, setArPassesOnly] = React.useState(true);
  const isPassingAr = React.useCallback((a) => {
    // Score wins if present — Auto-Remodel ads come from /api/swipe which
    // does not carry the filter_verdicts.passes flag, so ``adaptEvaluatedAd``
    // always sets ``passes: false`` for them. Filtering on ``a.passes`` alone
    // would hide every Auto-Remodel ad above 7.5. Prefer the numeric score;
    // fall back to ``passes`` only when the score is unknown.
    if (typeof a.score === 'number') return a.score >= SEARCH_PASS_THRESHOLD;
    if (typeof a.passes === 'boolean') return a.passes;
    return true;
  }, []);
  const visibleAds = React.useMemo(() => {
    if (!arPassesOnly) return ads;
    return ads.filter(isPassingAr);
  }, [ads, arPassesOnly, isPassingAr]);
  const arHiddenCount = ads.length - visibleAds.length;
  const currentRunAds = React.useMemo(() => ads.filter(a => a.currentAutoRemodelRun), [ads]);
  const previousRunAds = React.useMemo(() => ads.filter(a => !a.currentAutoRemodelRun), [ads]);
  const visibleCurrentRunAds = React.useMemo(() => visibleAds.filter(a => a.currentAutoRemodelRun), [visibleAds]);
  const visiblePreviousRunAds = React.useMemo(() => visibleAds.filter(a => !a.currentAutoRemodelRun), [visibleAds]);
  const currentRunHiddenCount = Math.max(0, currentRunAds.length - visibleCurrentRunAds.length);
  const previousRunHiddenCount = Math.max(0, previousRunAds.length - visiblePreviousRunAds.length);
  const totalCount = Math.max(total || 0, ads.length);
  const loadedCount = loaded || 0;
  const hasMore = (total || 0) > loadedCount;
  const [previousPageSize, setPreviousPageSize] = React.useState(AUTO_REMODEL_PREVIOUS_PAGE_SIZE);
  // Bug 16 — preserve scroll position when new ads append while a run is
  // streaming. Without this, every new card appended at the top (sorted by
  // score) shifts existing cards down, which the user perceives as an
  // unwanted auto-scroll-to-top jump. Strategy: before each render where
  // the ads array grew, capture the wrapper's bounding-rect offset; after
  // the new DOM is committed, if the user is not near the top of the page
  // (>200px scrolled), add the height delta to window.scrollY so the cards
  // they were inspecting stay visually fixed in the viewport.
  const wrapperRef = React.useRef(null);
  const prevHeightRef = React.useRef(0);
  const prevAdsLenRef = React.useRef(ads.length);
  React.useLayoutEffect(() => {
    const node = wrapperRef.current;
    if (!node) return;
    const newHeight = node.getBoundingClientRect().height;
    const grew = ads.length > prevAdsLenRef.current;
    const heightDelta = newHeight - prevHeightRef.current;
    if (grew && heightDelta > 0 && running) {
      const scrolledY = window.scrollY || document.documentElement.scrollTop || 0;
      // Only adjust when the user has scrolled into the results — anyone
      // still near the top wants to watch new ads stream in normally.
      if (scrolledY > 200) {
        window.scrollTo({ top: scrolledY + heightDelta, left: 0, behavior: 'instant' in window ? 'instant' : 'auto' });
      }
    }
    prevHeightRef.current = newHeight;
    prevAdsLenRef.current = ads.length;
  }, [ads.length, running]);
  // Sort cycle: score (default) -> createdAt -> ready (script generation
  // status, original behavior). Mirrors PipelineView so users can flip
  // between "best ads" and "newest ads" with one click.
  const AR_RESULT_SORTS = ['score', 'createdAt', 'ready'];
  const [arResultSort, setArResultSort] = React.useState('score');
  const cycleArResultSort = () => setArResultSort(prev => {
    const idx = AR_RESULT_SORTS.indexOf(prev);
    return AR_RESULT_SORTS[(idx + 1) % AR_RESULT_SORTS.length];
  });
  const arResultSortLabel = arResultSort === 'score'
    ? 'Score ↓'
    : arResultSort === 'createdAt'
      ? 'Created at ↓'
      : 'Ready first';
  const sortedAds = React.useMemo(() => {
    const copy = visibleAds.slice();
    if (arResultSort === 'score') {
      copy.sort((a, b) => (b.score ?? -1) - (a.score ?? -1));
    } else if (arResultSort === 'createdAt') {
      copy.sort((a, b) => (b.createdAtMs || 0) - (a.createdAtMs || 0));
    } else {
      copy.sort((a, b) => {
        const entryA = scripts[a.id];
        const entryB = scripts[b.id];
        const readyA = entryA && Array.isArray(entryA.variants) && entryA.variants.length > 0;
        const readyB = entryB && Array.isArray(entryB.variants) && entryB.variants.length > 0;
        if (readyA !== readyB) return readyA ? -1 : 1;
        const hasTranscriptA = !!(a.transcriptRaw || (Array.isArray(a.transcript) && a.transcript.length));
        const hasTranscriptB = !!(b.transcriptRaw || (Array.isArray(b.transcript) && b.transcript.length));
        if (hasTranscriptA !== hasTranscriptB) return hasTranscriptA ? -1 : 1;
        return 0;
      });
    }
    return copy;
  }, [visibleAds, scripts, arResultSort]);
  const sortedCurrentAds = React.useMemo(
    () => sortedAds.filter(a => a.currentAutoRemodelRun),
    [sortedAds],
  );
  const sortedPreviousAds = React.useMemo(
    () => sortedAds.filter(a => !a.currentAutoRemodelRun),
    [sortedAds],
  );
  const pagedPreviousAds = React.useMemo(
    () => sortedPreviousAds.slice(0, previousPageSize),
    [sortedPreviousAds, previousPageSize],
  );
  const hasMorePreviousAds = sortedPreviousAds.length > pagedPreviousAds.length;
  // Transcribing-progress indicator across all visible Auto-Remodel ads
  // (AdSpy + BrandSearch). Keeps the spinner up until every video has its
  // transcript generated server-side.
  const arTranscriptStats = React.useMemo(() => {
    let pending = 0;
    let done = 0;
    let total = 0;
    for (const a of ads) {
      if (a.platform !== 'adspy' && a.platform !== 'brandsearch') continue;
      if (!a.creativeUrl) continue;
      total += 1;
      const status = a.transcriptStatus || null;
      const has = a.transcript && a.transcript.length > 0;
      if (has) { done += 1; continue; }
      if (status === 'fallback' || status === 'unavailable' || status === 'empty') {
        done += 1;
        continue;
      }
      pending += 1;
    }
    return { pending, done, total };
  }, [ads]);
  return (
    <div className="platform-panel" ref={wrapperRef}>
      <header className="pp-hdr">
        <span className="pp-chip" style={{ background: '#e6fbf7', color: '#00796b' }}>Auto-Remodel Results</span>
        <span className="pp-title">
          {totalCount > visibleAds.length
            ? `Showing ${visibleAds.length} of ${totalCount} ads`
            : `${visibleAds.length} ad${visibleAds.length === 1 ? '' : 's'}`}
          {arPassesOnly && arHiddenCount > 0 && (
            <span style={{ color: 'var(--fg-3)', fontWeight: 400, marginLeft: 6 }} title={`${arHiddenCount} below-7.5 ad(s) hidden`}>
              · {arHiddenCount} hidden
            </span>
          )}
        </span>
        {arTranscriptStats.pending > 0 && (
          <span
            className="pp-transcribing"
            title={`Generating transcripts for ${arTranscriptStats.pending} of ${arTranscriptStats.total} video ads (Whisper/ElevenLabs). Typically 5–20s per ad.`}
            style={{
              display: 'inline-flex',
              alignItems: 'center',
              gap: 4,
              fontSize: 11,
              color: 'var(--fg-3)',
              padding: '2px 8px',
              borderRadius: 999,
              background: 'rgba(0,0,0,0.04)',
            }}
          >
            <Icon name="loader" size={11} />
            Transcribing {arTranscriptStats.done}/{arTranscriptStats.total}
          </span>
        )}
        <div className="pp-spacer" />
        <label
          className="sc-passes-toggle"
          title="Hide Auto-Remodel ads scored below the 7.5 winner threshold."
          onClick={(e) => e.stopPropagation()}
          style={{
            display: 'inline-flex',
            alignItems: 'center',
            gap: 6,
            fontSize: 12,
            color: 'var(--fg-2)',
            userSelect: 'none',
            cursor: 'pointer',
          }}
        >
          <input
            type="checkbox"
            checked={arPassesOnly}
            onClick={(e) => e.stopPropagation()}
            onChange={(e) => { e.stopPropagation(); setArPassesOnly(e.target.checked); }}
          />
          ≥7.5 only
        </label>
        <button type="button" className="ghost-btn" onClick={cycleArResultSort} title="Cycle sort">
          Sort: {arResultSortLabel}
        </button>
        {hasMore && (
          <button type="button" className="btn-secondary" onClick={onLoadMore} disabled={loadingMore} title="Load the next Auto-Remodel result page">
            {loadingMore ? <><Icon name="loader" size={14} className="icon-spin" /> Loading</> : <><Icon name="chevronDown" size={14} /> Load more</>}
          </button>
        )}
        <button type="button" className="btn-primary" onClick={onPromoteAll} disabled={!canPromoteAll} title="Promote every ready Auto-Remodel script">
          <Icon name="film" size={14} /> Promote All to Video Queue
        </button>
      </header>
      {ads.length === 0 && (restoring || loadingMore) ? (
        <div className="empty" style={{ margin: 0 }}>
          <span className="empty-display">
            {restoring ? 'Restoring Auto-Remodel results' : 'Loading Auto-Remodel results'}
          </span>
          <p className="empty-body">Loading saved ads and scripts.</p>
        </div>
      ) : ads.length === 0 ? (
        <div className="empty" style={{ margin: 0 }}>
          <span className="empty-display">No Auto-Remodel results yet</span>
          <p className="empty-body">
            {running
              ? 'The run is working. Results will appear here as soon as top ads are picked.'
              : stage === 'cancelled'
                ? 'The last run was stopped.'
                : stage === 'failed'
                  ? 'The last run failed.'
                  : 'Configure Auto-Remodel above and run it.'}
          </p>
        </div>
      ) : visibleAds.length === 0 ? (
        <div className="empty" style={{ margin: 0 }}>
          <span className="empty-display">All {ads.length} ads are below the 7.5 winner threshold</span>
          <p className="empty-body">Toggle off “≥7.5 only” in the header to see them.</p>
        </div>
      ) : (
        <div style={{ display: 'grid', gap: 14, padding: 14 }}>
          <div className="search-run-summary current" style={{ margin: 0 }}>
            <div className="search-run-main">
              <span className="search-run-title"><Icon name="bolt" size={13} /> Current Auto-Remodel output</span>
              <span className="search-run-count">{currentRunAds.length} ads output</span>
              {running && <span className="search-run-muted">running</span>}
              {arPassesOnly && currentRunHiddenCount > 0 && (
                <span className="search-run-muted">{currentRunHiddenCount} hidden below 7.5</span>
              )}
            </div>
          </div>
          {sortedCurrentAds.map(ad => (
            <AutoRemodelResultCard
              key={ad.id}
              ad={ad}
              entry={scripts[ad.id]}
              onEditScript={onEditScript}
              onRegenerate={onRegenerate}
              onDiscard={onDiscard}
              onPromote={onPromote}
            />
          ))}
          {previousRunAds.length > 0 && (
            <div className="search-run-summary previous" style={{ margin: 0 }}>
              <div className="search-run-main">
                <span className="search-run-title">Previous Auto-Remodel ads</span>
                <span className="search-run-count">
                  {pagedPreviousAds.length} of {sortedPreviousAds.length} shown
                </span>
                {sortedPreviousAds.length < previousRunAds.length && (
                  <span className="search-run-muted">{previousRunAds.length} retained</span>
                )}
                {arPassesOnly && previousRunHiddenCount > 0 && (
                  <span className="search-run-muted">{previousRunHiddenCount} hidden below 7.5</span>
                )}
              </div>
            </div>
          )}
          {pagedPreviousAds.map(ad => (
            <AutoRemodelResultCard
              key={ad.id}
              ad={ad}
              entry={scripts[ad.id]}
              onEditScript={onEditScript}
              onRegenerate={onRegenerate}
              onDiscard={onDiscard}
              onPromote={onPromote}
            />
          ))}
          {hasMorePreviousAds && (
            <button
              className="btn-secondary"
              onClick={() => setPreviousPageSize(n => n + AUTO_REMODEL_PREVIOUS_PAGE_SIZE)}
              style={{ justifySelf: 'center' }}
              title="Load the next page of previous Auto-Remodel ads"
            >
              <Icon name="chevronDown" size={14} /> Load more previous ads
            </button>
          )}
          {hasMore && (
            <button
              className="btn-secondary"
              onClick={onLoadMore}
              disabled={loadingMore}
              style={{ justifySelf: 'center' }}
              title="Load the next Auto-Remodel result page"
            >
              {loadingMore ? <><Icon name="loader" size={14} className="icon-spin" /> Loading</> : <><Icon name="chevronDown" size={14} /> Load more</>}
            </button>
          )}
        </div>
      )}
    </div>
  );
}

function AutoRemodelResultCard({ ad, entry, onEditScript, onRegenerate, onDiscard, onPromote }) {
  const variants = entry && Array.isArray(entry.variants) ? entry.variants : [];
  const status = entry && entry.status ? entry.status : (entry === 'error' ? 'error' : 'pending');
  const errorText = entry && entry.error ? entry.error : 'Regenerate from the job action when a failed variant is available.';
  const transcriptText = ad.transcriptRaw
    || (Array.isArray(ad.transcript)
      ? ad.transcript.map(seg => seg.body || seg.t || '').filter(Boolean).join('\n')
      : (ad.transcript || ''));
  const directionWords = ad.directionSearchLabel || ad.directionLabel;
  const statusLabel = variants.length > 0
    ? `${variants.length} remodeled script${variants.length === 1 ? '' : 's'}`
    : status === 'error'
      ? 'Generation failed'
      : status === 'scored'
        ? 'Found while searching'
        : 'Generating scripts';
  return (
    <article className={`transcript ar-result-card ${ad.currentAutoRemodelRun ? 'current-run' : ''}`}>
      <header className="t-hdr ar-result-hdr">
        <span className="t-brand">{ad.brand || ad.id}</span>
        <span className="t-meta">{ad.id}</span>
        <div className="t-hdr-spacer" />
        {ad.currentAutoRemodelRun && <span className="ar-result-status ready">Current</span>}
        <span className={`ar-result-status ${status === 'error' ? 'error' : variants.length > 0 ? 'ready' : 'pending'}`}>{statusLabel}</span>
        {typeof ad.score === 'number' && <span className="ad-score high">{ad.score.toFixed(1)}</span>}
      </header>
      <div className="ar-result-meta">
        {ad.daysRunning ? <span>{ad.daysRunning}d running</span> : null}
        {directionWords ? (
          <span className="ad-direction" title={`Search direction: ${directionWords}`}>
            <Icon name="target" size={10} />
            <span>{directionWords}</span>
          </span>
        ) : null}
        {ad.platform ? <span>{ad.platform}</span> : null}
      </div>
      <AutoRemodelMediaPreview ad={ad} transcriptText={transcriptText} />
      {(transcriptText || ad.reasoning) && (
        <details style={{ margin: '10px 14px 0', border: '1px solid var(--border-subtle)', borderRadius: 8, padding: '8px 10px', background: 'var(--bg-sunken)' }}>
          <summary style={{ cursor: 'pointer', fontSize: 12, color: 'var(--fg-2)' }}>Original transcript, Sonnet score, and reasoning</summary>
          {typeof ad.score === 'number' && <p style={{ margin: '8px 0', fontSize: 12 }}>Sonnet score: {ad.score.toFixed(1)}</p>}
          {ad.reasoning && <p style={{ margin: '8px 0', fontSize: 12, color: 'var(--fg-2)' }}>{ad.reasoning}</p>}
          {transcriptText && <pre style={{ whiteSpace: 'pre-wrap', maxHeight: 220, overflow: 'auto', fontSize: 12, lineHeight: 1.45 }}>{transcriptText}</pre>}
        </details>
      )}
      <div style={{ padding: 14, display: 'grid', gap: 10 }}>
        {status === 'scored' && variants.length === 0 && (
          <div className="empty" style={{ margin: 0, padding: 18 }}>
            <span className="empty-display">Found while searching</span>
            <p className="empty-body">This qualified ad is in the live Auto-Remodel shortlist.</p>
          </div>
        )}
        {status === 'pending' && variants.length === 0 && (
          <div className="empty" style={{ margin: 0, padding: 18 }}>
            <span className="empty-display">Generating scripts</span>
            <p className="empty-body">Claude Sonnet is still working on this ad.</p>
          </div>
        )}
        {status === 'error' && variants.length === 0 && (
          <div className="empty" style={{ margin: 0, padding: 18 }}>
            <span className="empty-display">Generation failed</span>
            <p className="empty-body">{errorText}</p>
          </div>
        )}
        {variants.map(v => (
          <AutoRemodelScriptEditor
            key={`${ad.id}-${v.variantIdx}-${v.jobId}`}
            variant={v}
            onSave={(text) => onEditScript(v.jobId, text)}
            onRegenerate={() => onRegenerate(v.jobId)}
            onDiscard={() => onDiscard(v.jobId)}
            onPromote={() => onPromote(v.jobId)}
          />
        ))}
      </div>
    </article>
  );
}

function AutoRemodelMediaPreview({ ad, transcriptText }) {
  const [playing, setPlaying] = React.useState(false);
  const p = PLATFORMS[ad.platform] || PLATFORMS.foreplay;
  const metricChips = RESILIA_ADAPT.metricChips(ad);
  const hasVideo = !!ad.creativeUrl;
  const hasThumbnail = !!ad.thumbnailUrl;
  const showVideoPreview = hasVideo && !hasThumbnail && !playing;
  const scoreText = typeof ad.score === 'number'
    ? (ad.score > 10 ? Math.round(ad.score) : ad.score.toFixed(1))
    : null;
  const sourceCopy = ad.copy || (transcriptText ? transcriptText.slice(0, 220) : '');
  const directionWords = ad.directionSearchLabel || ad.directionLabel;
  return (
    <div className="ar-source-preview">
      <div className="ar-source-media" style={{ background: ad.thumb }}>
        {playing && hasVideo ? (
          <video
            src={ad.creativeUrl}
            poster={ad.thumbnailUrl || undefined}
            controls
            autoPlay
            playsInline
            className="ar-source-video"
          />
        ) : (
          <>
            {showVideoPreview && (
              <video
                className="ar-source-video-preview"
                src={ad.creativeUrl}
                muted
                autoPlay
                loop
                playsInline
                preload="auto"
                onLoadedData={(e) => {
                  const video = e.currentTarget;
                  try { e.currentTarget.currentTime = 0.15; } catch { /* preview seek can fail on some streams */ }
                  try { video.play(); } catch { /* muted autoplay can still be blocked in rare cases */ }
                }}
                onError={(e) => { e.currentTarget.style.display = 'none'; }}
              />
            )}
            <span className="ad-plat" style={{ background: p.bg, color: p.color }}>
              <span className="ad-plat-dot" style={{ background: p.color }} />
              {p.label}
            </span>
            <button
              className="ad-play ar-source-play"
              onClick={() => hasVideo && setPlaying(true)}
              title={hasVideo ? 'Play original video' : 'No video creative available'}
              disabled={!hasVideo}
            >
              <Icon name="play" size={16} />
            </button>
            {ad.duration && <span className="ad-duration">{ad.duration}</span>}
            {scoreText !== null && (
              <span className="ar-source-score" title="Evaluator score">
                <Icon name="trend" size={10} />
                {scoreText}
              </span>
            )}
          </>
        )}
      </div>
      <div className="ar-source-body">
        <div className="ar-source-title-row">
          <span className="ar-source-title">Original ad</span>
          {hasVideo ? <span className="ar-source-pill">video</span> : <span className="ar-source-pill muted">thumbnail</span>}
        </div>
        {directionWords && (
          <div className="ad-direction" title={`Search direction: ${directionWords}`}>
            <Icon name="target" size={10} />
            <span>{directionWords}</span>
          </div>
        )}
        {sourceCopy && <p className="ar-source-copy">{sourceCopy}</p>}
        <div className="ar-source-meta">
          {scoreText !== null && <span><Icon name="trend" size={10} /> {scoreText}</span>}
          {metricChips.map(({ label, value, title, className }) => (
            <span key={label} className={`ad-metric-chip ${className}`.trim()} title={title}>
              <span className="ad-metric-label">{label}</span>{value}
            </span>
          ))}
          {ad.daysRunning ? <span>{ad.daysRunning}d running</span> : null}
          {ad.directionLabel && ad.directionLabel !== directionWords ? <span>{ad.directionLabel}</span> : null}
        </div>
      </div>
    </div>
  );
}

function AutoRemodelScriptEditor({ variant, onSave, onRegenerate, onDiscard, onPromote }) {
  const [draft, setDraft] = React.useState(variant.raw || '');
  const [expanded, setExpanded] = React.useState(false);
  const [editing, setEditing] = React.useState(false);
  const [saving, setSaving] = React.useState(false);
  React.useEffect(() => { setDraft(variant.raw || ''); }, [variant.raw, variant.jobId]);
  const startEdit = () => {
    setExpanded(true);
    setEditing(true);
  };
  const save = async () => {
    setSaving(true);
    try {
      await onSave(draft);
      setEditing(false);
    } finally {
      setSaving(false);
    }
  };
  const cancelEdit = () => {
    setDraft(variant.raw || '');
    setEditing(false);
  };
  return (
    <section style={{ border: '1px solid var(--border-subtle)', borderRadius: 8, overflow: 'hidden', background: 'var(--bg-sunken)' }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 10px', borderBottom: '1px solid var(--border-subtle)' }}>
        <button
          className={`ar-script-toggle ${expanded ? 'open' : ''}`}
          onClick={() => setExpanded(v => !v)}
          title={expanded ? 'Hide remodeled script' : 'Show remodeled script'}
          aria-expanded={expanded}
        >
          <Icon name="chevronDown" size={14} />
        </button>
        <strong style={{ fontSize: 12 }}>Remodeled</strong>
        {variant.promotedAt && <span style={{ fontSize: 12, color: 'var(--fg-3)' }}>Promoted {String(variant.promotedAt).slice(0, 16)}</span>}
        <div className="t-hdr-spacer" />
        {!editing && (
          <button className="t-edit-btn" onClick={startEdit} title="Edit generated script">
            <Icon name="edit" size={12} /> Edit
          </button>
        )}
        {!editing && (
          <button className="t-edit-btn" onClick={onRegenerate}>
            <Icon name="loader" size={12} /> Regenerate
          </button>
        )}
        {!editing && (
          <button className="t-edit-btn ghost" onClick={onDiscard}>
            <Icon name="trash" size={12} /> Discard
          </button>
        )}
        {!editing && (
          <button className="t-edit-btn primary" onClick={onPromote}>
            <Icon name="film" size={12} /> {variant.promotedAt ? 'Promoted' : 'Promote to Video Queue'}
          </button>
        )}
        {editing && (
          <>
            <button className="t-edit-btn ghost" onClick={cancelEdit} disabled={saving}>
              Cancel
            </button>
            <button className="t-edit-btn primary" onClick={save} disabled={saving || !draft.trim()}>
              <Icon name="check" size={12} /> {saving ? 'Saving…' : 'Save'}
            </button>
          </>
        )}
      </div>
      {expanded && (editing ? (
        <textarea
          className="t-edit-area"
          value={draft}
          onChange={(e) => setDraft(e.target.value)}
          spellCheck
          autoFocus
          style={{ minHeight: 180, border: 0, borderRadius: 0 }}
        />
      ) : (
        <pre className="ar-script-readonly">{variant.raw || ''}</pre>
      ))}
    </section>
  );
}

function RemodelView({ ads, sel, onToggle, scripts, onEditScript, onRegenerate, onDiscard, onPromote, pagination, onLoadMore, statusText }) {
  // ``scripts[ad.id]`` is one of:
  //   { status: 'ready',   variants: [{ jobId, variantIdx, raw, lines }] }
  //   { status: 'pending', variants: [] }
  //   { status: 'error',   variants: [] }
  // Each variant renders as its own editable TranscriptCard so multi-variant
  // Auto-Remodel runs surface every Sonnet output. Backwards-compatible with
  // the old single-entry shape so manual flows that haven't re-polled yet
  // still render.
  const cardsForAd = (ad) => {
    const entry = scripts[ad.id];
    let status = 'pending';
    let variants = [];
    let errorText = 'Remodel failed for this ad. Check server logs.';
    if (entry && Array.isArray(entry.variants)) {
      status = entry.status || 'pending';
      variants = entry.variants;
      if (entry.error) errorText = entry.error;
    } else if (entry && typeof entry === 'object' && entry.jobId) {
      status = 'ready';
      variants = [{ jobId: entry.jobId, variantIdx: 0, raw: entry.raw, lines: entry.lines }];
    } else if (entry === 'error') {
      status = 'error';
    } else if (Array.isArray(entry) && entry.length) {
      status = 'ready';
      variants = [{ jobId: null, variantIdx: 0, raw: '', lines: entry }];
    }
    if (status === 'pending' || (status === 'ready' && variants.length === 0)) {
      return [(
        <TranscriptCard
          key={`${ad.id}-pending`}
          ad={ad}
          script={[{ tag: 'REMODELING', body: 'Claude Sonnet is generating the remodel — this usually takes 20–60s. Polling…' }]}
          selected={sel.has(ad.id)}
          onToggle={() => onToggle(ad.id)}
        />
      )];
    }
    if (status === 'error') {
      return [(
        <TranscriptCard
          key={`${ad.id}-error`}
          ad={ad}
          script={[{ tag: 'ERROR', body: errorText }]}
          selected={sel.has(ad.id)}
          onToggle={() => onToggle(ad.id)}
        />
      )];
    }
    const multi = variants.length > 1;
    return variants.map(v => {
      // Decorate the brand label so users can tell variants apart.
      const adForCard = multi ? { ...ad, brand: `${ad.brand} · variant ${v.variantIdx + 1}` } : ad;
      const editable = !!v.jobId;
      return (
        <TranscriptCard
          key={`${ad.id}-v${v.variantIdx}-${v.jobId || 'nojob'}`}
          ad={adForCard}
          script={v.lines}
          selected={sel.has(ad.id)}
          onToggle={() => onToggle(ad.id)}
          editable={editable}
          rawText={editable ? v.raw : ''}
          onSave={editable ? (newText) => onEditScript(v.jobId, newText) : null}
          onRegenerate={editable ? () => onRegenerate(v.jobId) : null}
          onDiscard={editable ? () => onDiscard(v.jobId) : null}
          onPromote={editable ? () => onPromote(v.jobId) : null}
          promotedAt={v.promotedAt}
        />
      );
    });
  };
  const isAutoRemodel = ads.some(a => a.source === 'auto_remodel');
  return (
    <>
      <div className="content-hdr">
        <span className="content-title">{isAutoRemodel ? 'Auto-Remodel Results' : 'Remodel'}</span>
        <span className="content-sub">Review final scripts, edit inline, regenerate, discard, or promote to the video queue.</span>
      </div>
      {statusText && <div className="inline-note">{statusText}</div>}
      {ads.length === 0 ? (
        <div className="empty">
          <span className="empty-display">Nothing remodeled yet</span>
          <p className="empty-body">Select ads in Filter and press Run Remodel, Pass without Filtering from Research, or kick off Auto-Remodel.</p>
        </div>
      ) : (
        <>
          {ads.map(ad => (
            <React.Fragment key={ad.id}>{cardsForAd(ad)}</React.Fragment>
          ))}
          {pagination?.hasMore && (
            <button className="btn-secondary" onClick={onLoadMore} disabled={pagination.loading} style={{ alignSelf: 'center' }}>
              {pagination.loading ? <><Icon name="loader" size={14} className="icon-spin" /> Loading</> : <><Icon name="chevronDown" size={14} /> Load more</>}
            </button>
          )}
        </>
      )}
    </>
  );
}

function VideoView({ ads }) {
  return (
    <>
      <div className="content-hdr">
        <span className="content-title">Video-Conversion</span>
        <span className="content-sub">Queued for the video-editing pipeline (backend in development).</span>
      </div>
      {ads.length === 0 ? (
        <div className="empty">
          <span className="empty-display">Queue is empty</span>
          <p className="empty-body">Select remodeled scripts in Remodel and press Convert to Video.</p>
        </div>
      ) : (
        <div className="platform-panel">
          <header className="pp-hdr">
            <span className="pp-chip" style={{ background: '#fdf1da', color: '#a56c00' }}>
              <span className="pp-dot" style={{ background: '#d97706' }} />
              Queued
            </span>
            <span className="pp-count">{ads.length} jobs</span>
            <div className="pp-spacer" />
            <span className="pp-dedupe">ETA ~{ads.length * 90}s</span>
          </header>
          <div style={{ padding: '14px 16px', display: 'flex', flexDirection: 'column', gap: 10 }}>
            {ads.map(ad => (
              <div key={ad.id} style={{ display: 'flex', gap: 12, alignItems: 'center', padding: 10, background: 'var(--bg-sunken)', borderRadius: 8, border: '1px solid var(--border-subtle)' }}>
                <div style={{ width: 40, height: 50, background: ad.thumb, borderRadius: 6, flexShrink: 0 }} />
                <div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 3 }}>
                  <span style={{ fontSize: 13, fontWeight: 600 }}>{ad.brand}</span>
                  <span style={{ fontSize: 11, color: 'var(--fg-3)', fontFamily: 'var(--font-mono)' }}>{ad.id} · remodeled · awaiting editor</span>
                </div>
                <span className="pill">Pending</span>
              </div>
            ))}
          </div>
        </div>
      )}
    </>
  );
}

ReactDOM.createRoot(document.getElementById('root')).render(<App />);
