import React, { useState, useEffect, useCallback, useRef } from 'react';
import axios from 'axios';
import './App.css';
import API_BASE_URL, {
  AI_SUMMARY_ON,
  API_KEY,
  SEARCH_SEMANTIC_HIGHLIGHTS,
  SEMANTIC_HIGHLIGHT_THRESHOLD,
  SEARCH_RESULTS_PAGE_SIZE,
  APP_BASE_PATH,
  GA_MEASUREMENT_ID,
  USER_MODULE,
  USER_MODULE_MODE,
} from './config';

import {
  SearchResponse,
  Facets,
  FacetValue,
  SearchFilters,
  SearchResult,
  ModelComboConfig,
  SummaryModelConfig,
  DrilldownNode,
} from './types/api';
import { useDrilldownTree, AiSummarySnapshot } from './hooks/useDrilldownTree';
import { Documents } from './components/Documents';
import { Stats } from './components/Stats';
import { Pipeline, Processing } from './components/Pipeline';
import TocModal from './components/TocModal';
import { MetadataModal } from './components/documents/MetadataModal';
import { SummaryModal } from './components/documents/SummaryModal';
import { TopBar } from './components/layout/TopBar';
import { NavTabs } from './components/layout/NavTabs';
import { SearchBox } from './components/SearchBox';
import { PdfPreviewOverlay } from './components/app/PdfPreviewOverlay';
import { SearchTabContent } from './components/app/SearchTabContent';
import { HeatmapTabContent } from './components/app/HeatmapTabContent';
import { TabContent } from './components/app/TabContent';
import { CookieConsent, getGaConsent } from './components/CookieConsent';
import FeedbackButton from './components/feedback/FeedbackButton';
import SavedResearchModal from './components/SavedResearchModal';
import { AuthContext, useAuthState } from './hooks/useAuth';
import { useGroupDefaults } from './hooks/useGroupDefaults';
import { useActivityLogging } from './hooks/useActivityLogging';
import { buildContextualSearchQuery, serializeDrilldownTree, serializeFullDrilldownTree, patchNodeInTree } from './utils/drilldownUtils';
import { generateUUID } from './utils/uuid';
import { mergeFacetField } from './utils/facetMerge';
import AdminPanel from './components/admin/AdminPanel';
import { AssistantTab } from './components/assistant/AssistantTab';
import { AuthGate } from './components/auth/AuthGate';
import { DEFAULT_SECTION_TYPES, DEFAULT_FIELD_BOOST_FIELDS, buildSearchURL, getSearchStateFromURL } from './utils/searchUrl';
import { streamAiSummary, AiSummaryUsage } from './utils/aiSummaryStream';
import {
  highlightTextWithAPI,
  findSemanticMatches,
  TextMatch
} from './utils/textHighlighting';
// datasource config is now fetched dynamically
// import datasourcesConfig from './datasources.config.json';

const AI_SUMMARY_ERROR = 'Uh oh. Something went wrong asking the AI.';

/** Wrapper to isolate conditional render from main App CC */
const LoadResearchModalWrapper: React.FC<{
  show: boolean;
  onClose: () => void;
  onLoad: (id: string) => void;
}> = ({ show, onClose, onLoad }) => {
  if (!show) return null;
  return <SavedResearchModal onClose={onClose} onLoadResearch={onLoad} />;
};

const getCsrfToken = (): string | null => {
  const match = document.cookie.match(/(?:^|;\s*)evidencelab_csrf=([^;]*)/);
  return match ? decodeURIComponent(match[1]) : null;
};

// Type for datasource configuration
interface FieldMapping {
  [coreField: string]: string; // core field name -> source field name
}

interface FilterFields {
  [coreField: string]: string; // core field name -> display label
}

export type DataSourceConfig = {
  [key: string]: DataSourceConfigItem;
};

export interface DataSourceConfigItem {
  data_subdir: string;
  field_mapping: FieldMapping;
  filter_fields: FilterFields;
  metadata_panel_fields?: FilterFields;
  example_queries?: string[];
  pipeline?: any; // Add pipeline to access taxonomies
  total_documents?: number;
}

type DataSourcesConfig = DataSourceConfig;

type DatasetTotals = Record<string, number | undefined>;

// Valid tab names for URL routing
const VALID_TABS = ['search', 'assistant', 'heatmap', 'documents', 'pipeline', 'processing', 'info', 'tech', 'data', 'privacy', 'terms', 'stats', 'admin', 'docs'] as const;
type TabName = typeof VALID_TABS[number];

const isGatewayError = (error: any): boolean => {
  const status = error?.response?.status;
  return status === 502 || status === 503 || status === 504;
};

const isServerError = (error: any): boolean => error?.response?.status === 500;

const isNetworkError = (error: any): boolean =>
  error?.code === 'ERR_NETWORK' || error?.message?.includes('Network Error');

const withBasePath = (path: string): string => {
  if (!APP_BASE_PATH) return path;
  const normalized = path.startsWith('/') ? path : `/${path}`;
  return `${APP_BASE_PATH}${normalized}`;
};

const stripBasePath = (pathname: string): string => {
  if (!APP_BASE_PATH) return pathname;
  if (!pathname.startsWith(APP_BASE_PATH)) return pathname;
  const stripped = pathname.slice(APP_BASE_PATH.length);
  return stripped || '/';
};

const buildSearchErrorMessage = (error: any): string => {
  if (isGatewayError(error)) {
    return 'Backend server is unreachable (502 Bad Gateway). Please check if the backend service is running.';
  }
  if (isServerError(error)) {
    return 'Backend server error (500). Please try again later.';
  }
  if (isNetworkError(error)) {
    return 'Network error. Please check your connection and ensure the backend is accessible.';
  }
  return 'Search failed. Make sure the backend is running.';
};

const translateViaApi = async (text: string, targetLanguage: string, sourceLanguage?: string): Promise<string | null> => {
  try {
    const csrfToken = getCsrfToken();
    const resp = await fetch(`${API_BASE_URL}/translate`, {
      method: 'POST',
      credentials: 'include',
      headers: { 'Content-Type': 'application/json', ...(API_KEY ? { 'X-API-Key': API_KEY } : {}), ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) },
      body: JSON.stringify({ text, target_language: targetLanguage, source_language: sourceLanguage })
    });
    if (resp.ok) {
      const data = await resp.json();
      return data.translated_text;
    }
  } catch (e) {
    console.error('Translation failed', e);
  }
  return null;
};

const buildChunkTextForTranslation = (result: SearchResult): string => {
  if (result.chunk_elements && result.chunk_elements.length > 0) {
    return result.chunk_elements
      .filter(el => el.element_type === 'text')
      .map(el => el.text)
      .join('\n\n');
  }
  return result.text;
};

const translateHeadings = async (
  headings: string[],
  targetLanguage: string,
  sourceLanguage?: string
): Promise<string | undefined> => {
  if (!headings.length) {
    return undefined;
  }
  const translated = await translateViaApi(headings.join(' > '), targetLanguage, sourceLanguage);
  return translated ?? undefined;
};

const updateResultsForChunk = (
  setResults: React.Dispatch<React.SetStateAction<SearchResult[]>>,
  chunkId: string,
  updater: (result: SearchResult) => SearchResult
) => {
  setResults((prev: SearchResult[]) =>
    prev.map((r) => (r.chunk_id === chunkId ? updater(r) : r))
  );
};

const resetTranslationState = (
  setResults: React.Dispatch<React.SetStateAction<SearchResult[]>>,
  chunkId: string
) => {
  updateResultsForChunk(setResults, chunkId, (r) => ({
    ...r,
    translated_snippet: undefined,
    translated_title: undefined,
    translated_headings_display: undefined,
    translated_language: undefined,
    highlightedText: undefined
  }));
};

const setTranslationInProgress = (
  setResults: React.Dispatch<React.SetStateAction<SearchResult[]>>,
  chunkId: string,
  newLang: string
) => {
  updateResultsForChunk(setResults, chunkId, (r) => ({
    ...r,
    translated_language: newLang,
    is_translating: true
  }));
};

const applyTranslationError = (
  setResults: React.Dispatch<React.SetStateAction<SearchResult[]>>,
  chunkId: string
) => {
  updateResultsForChunk(setResults, chunkId, (r) => ({
    ...r,
    translated_language: undefined,
    is_translating: false
  }));
};

const applyTranslationResult = (
  setResults: React.Dispatch<React.SetStateAction<SearchResult[]>>,
  result: SearchResult,
  newLang: string,
  translatedTitle: string | null,
  translatedText: string | null,
  translatedHeadings: string | undefined,
  translatedSemanticMatches: TextMatch[] | undefined
) => {
  updateResultsForChunk(setResults, result.chunk_id, (r) => ({
    ...r,
    translated_title: translatedTitle ?? result.title,
    translated_snippet: translatedText ?? result.text,
    translated_headings_display: translatedHeadings,
    translated_language: newLang,
    translatedSemanticMatches,
    is_translating: false
  }));
};

const computeTranslatedSemanticMatches = async ({
  translatedText,
  translatedQuery,
  originalText,
  originalQuery,
  semanticHighlightModelConfig,
}: {
  translatedText: string | null;
  translatedQuery: string | null;
  originalText: string;
  originalQuery: string;
  semanticHighlightModelConfig: SummaryModelConfig | null;
}): Promise<TextMatch[] | undefined> => {
  if (!SEARCH_SEMANTIC_HIGHLIGHTS) {
    return undefined;
  }
  const textForHighlight = translatedText ?? originalText;
  if (!textForHighlight) {
    return undefined;
  }
  try {
    return await findSemanticMatches(
      textForHighlight,
      translatedQuery ?? originalQuery,
      SEMANTIC_HIGHLIGHT_THRESHOLD,
      semanticHighlightModelConfig
    );
  } catch (err) {
    console.error('Failed to highlight translated text', err);
    return undefined;
  }
};

const resolveRequestHighlightHandler = (
  featureEnabled: boolean,
  semanticHighlighting: boolean,
  handler: (chunkId: string, text: string) => void
): ((chunkId: string, text: string) => void) | undefined => {
  if (!featureEnabled || !semanticHighlighting) {
    return undefined;
  }
  return handler;
};

const buildSearchParams = ({
  query,
  filters,
  searchDenseWeight,
  rerankEnabled,
  recencyBoostEnabled,
  recencyWeight,
  recencyScaleDays,
  sectionTypes,
  keywordBoostShortQueries,
  minChunkSize,
  rerankModel,
  rerankModelPageSize,
  searchModel,
  dataSource,
  autoMinScore,
  deduplicateEnabled,
  fieldBoostEnabled,
  fieldBoostFields,
}: {
  query: string;
  filters: SearchFilters;
  searchDenseWeight: number;
  rerankEnabled: boolean;
  recencyBoostEnabled: boolean;
  recencyWeight: number;
  recencyScaleDays: number;
  sectionTypes: string[];
  keywordBoostShortQueries: boolean;
  minChunkSize: number;
  rerankModel: string | null;
  rerankModelPageSize: number | null;
  searchModel: string | null;
  dataSource: string;
  autoMinScore: boolean;
  deduplicateEnabled: boolean;
  fieldBoostEnabled: boolean;
  fieldBoostFields: Record<string, number>;
}): URLSearchParams => {
  const params = new URLSearchParams({ q: query, limit: SEARCH_RESULTS_PAGE_SIZE });
  for (const [field, value] of Object.entries(filters)) {
    if (value) {
      params.append(field, value);
    }
  }
  params.append('dense_weight', searchDenseWeight.toString());
  params.append('rerank', rerankEnabled.toString());
  params.append('recency_boost', recencyBoostEnabled.toString());
  params.append('recency_weight', recencyWeight.toString());
  params.append('recency_scale_days', recencyScaleDays.toString());
  if (sectionTypes.length > 0) {
    params.append('section_types', sectionTypes.join(','));
  }
  params.append('keyword_boost_short_queries', keywordBoostShortQueries.toString());
  if (minChunkSize > 0) {
    params.append('min_chunk_size', minChunkSize.toString());
  }
  if (rerankModel) {
    params.append('rerank_model', rerankModel);
  }
  if (rerankModelPageSize != null && rerankModelPageSize > 0) {
    params.append('rerank_model_page_size', rerankModelPageSize.toString());
  }
  if (searchModel) {
    params.append('model', searchModel);
  }
  if (autoMinScore) {
    params.append('auto_min_score', 'true');
  }
  params.append('deduplicate', deduplicateEnabled.toString());
  params.append('field_boost', fieldBoostEnabled.toString());
  if (fieldBoostEnabled && Object.keys(fieldBoostFields).length > 0) {
    const encoded = Object.entries(fieldBoostFields)
      .map(([f, w]) => `${f}:${w}`)
      .join(',');
    params.append('field_boost_fields', encoded);
  }
  params.append('data_source', dataSource);
  return params;
};

type ModelCombos = Record<string, ModelComboConfig>;

const fetchModelCombos = async (
  apiBaseUrl: string,
  setModelCombos: React.Dispatch<React.SetStateAction<ModelCombos>>,
  setLoading: React.Dispatch<React.SetStateAction<boolean>>,
  dataSource?: string
): Promise<void> => {
  try {
    const params = dataSource ? `?data_source=${encodeURIComponent(dataSource)}` : '';
    const response = await axios.get<ModelCombos>(`${apiBaseUrl}/config/model-combos${params}`);
    const data = response.data as ModelCombos;
    setModelCombos(data);
  } catch (error: any) {
    console.error('Error fetching model combos:', error);
    if (isGatewayError(error)) {
      console.warn(
        'Backend server is unreachable (502 Bad Gateway). Model selection will not be available until the backend is running.'
      );
    }
  } finally {
    setLoading(false);
  }
};

// Get initial tab from URL path
const getTabFromPath = (): TabName => {
  const params = new URLSearchParams(window.location.search);
  const tabParam = params.get('tab');
  if (tabParam && VALID_TABS.includes(tabParam as TabName)) {
    return tabParam as TabName;
  }
  if (params.get('search') || params.get('view') || params.get('page')) {
    return 'documents';
  }
  if (params.get('q')) {
    return 'search';
  }
  const path = stripBasePath(window.location.pathname).replace('/', '').toLowerCase();
  return VALID_TABS.includes(path as TabName) ? (path as TabName) : 'search';
};

// Default filter fields (fallback for URL parsing before facets load)
const DEFAULT_FILTER_FIELDS = ['organization', 'title', 'published_year', 'document_type', 'country', 'language'];
const DEFAULT_PUBLISHED_YEARS = ['2020', '2021', '2022', '2023', '2024', '2025'];

function App() {
  // Auth state (only active when USER_MODULE is enabled)
  const authState = useAuthState();

  // Activity logging (fire-and-forget)
  const { logSearch, updateSummary: updateActivitySummary } = useActivityLogging();

  // Initialize search state from URL parameters
  const initialSearchState = getSearchStateFromURL(
    DEFAULT_FILTER_FIELDS,
    DEFAULT_SECTION_TYPES
  );
  const initialQueryFromUrlRef = useRef(Boolean(initialSearchState.query.trim()));
  // Capture doc_id/chunk_id from URL so we can auto-open the PDF modal after search completes
  const initialDocFromUrl = useRef<{ doc_id: string; chunk_id: string } | null>(
    (() => {
      const params = new URLSearchParams(window.location.search);
      const docId = params.get('doc_id');
      const chunkId = params.get('chunk_id');
      return docId && chunkId ? { doc_id: docId, chunk_id: chunkId } : null;
    })()
  );
  const [activeTab, setActiveTab] = useState<TabName>(getTabFromPath);

  // Config state
  const [datasourcesConfig, setDatasourcesConfig] = useState<DataSourcesConfig>({});
  const [loadingConfig, setLoadingConfig] = useState(true);
  const [datasetTotals, setDatasetTotals] = useState<DatasetTotals>({});

  // Model Selection State
  const [modelCombos, setModelCombos] = useState<ModelCombos>({});
  const [modelCombosLoading, setModelCombosLoading] = useState(true);
  const [selectedModelCombo, setSelectedModelCombo] = useState<string | null>(
    initialSearchState.modelCombo
  );
  const [searchModel, setSearchModel] = useState<string | null>(initialSearchState.model);
  const [summaryModelConfig, setSummaryModelConfig] = useState<SummaryModelConfig | null>(null);
  const [assistantModelConfig, setAssistantModelConfig] = useState<SummaryModelConfig | null>(null);
  const [semanticHighlightModelConfig, setSemanticHighlightModelConfig] =
    useState<SummaryModelConfig | null>(null);
  const [rerankModel, setRerankModel] = useState<string | null>(null);
  const [rerankModelPageSize, setRerankModelPageSize] = useState<number | null>(null);

  // Fetch datasources config.
  // In on_active mode, wait until the user is authenticated so the request
  // isn't rejected with 401 by ActiveAuthMiddleware.  When USER_MODULE is
  // off (or on_passive), fetch immediately on mount.
  useEffect(() => {
    if (USER_MODULE_MODE === 'on_active' && !authState.isAuthenticated) return;
    const fetchConfig = async () => {
      try {
        const response = await axios.get<DataSourcesConfig>(`${API_BASE_URL}/config/datasources`);
        const data = response.data;
        setDatasourcesConfig(data);

        // Extract totals from config response
        const totals: DatasetTotals = {};
        for (const [name, cfg] of Object.entries(data)) {
          const config = cfg as DataSourceConfigItem;
          if (config.total_documents !== undefined && !Number.isNaN(config.total_documents)) {
            totals[name] = config.total_documents;
          }
        }
        setDatasetTotals(totals);
      } catch (err) {
        console.error('Failed to fetch datasources config:', err);
      } finally {
        setLoadingConfig(false);
      }
    };
    fetchConfig();
  }, [authState.isAuthenticated]);

  // Model combos are fetched after dataSource is resolved (see below)

  // Get available domains from config
  const availableDomains = Object.keys(datasourcesConfig);

  const availableModelCombos = Object.keys(modelCombos);
  const defaultModelCombo = availableModelCombos[0] || '';
  const resolvedModelCombo = (
    selectedModelCombo && availableModelCombos.includes(selectedModelCombo)
  )
    ? selectedModelCombo
    : (defaultModelCombo || 'Models');

  const [selectedDomain, setSelectedDomain] = useState<string>(() => {
    // Initial state setup needs to handle empty config initially,
    // but we can default to dataset param if present, or wait for config load?
    // For now, let's just initialize with what we have from URL
    if (initialSearchState.dataset) {
      return initialSearchState.dataset;
    }
    return '';  // Will be set to first available domain when config loads
  });

  // Update selected domain when config loads if needed, or ensure it's valid
  useEffect(() => {
    if (!loadingConfig && availableDomains.length > 0) {
      // If URL has a dataset parameter, use it if it's valid
      if (initialSearchState.dataset && availableDomains.includes(initialSearchState.dataset)) {
        // Set it if it's different - this ensures dataSource is recalculated when config loads
        if (selectedDomain !== initialSearchState.dataset) {
          setSelectedDomain(initialSearchState.dataset);
        }
      } else if (!availableDomains.includes(selectedDomain)) {
        // Only override if current selection is invalid and no valid dataset in URL
        setSelectedDomain(availableDomains[0]);
      }
    }
  }, [loadingConfig, availableDomains, selectedDomain, initialSearchState.dataset]);

  // Update selected model combo when config loads if needed, or ensure it's valid
  useEffect(() => {
    if (modelCombosLoading || availableModelCombos.length === 0) {
      return;
    }
    if (
      initialSearchState.modelCombo
      && availableModelCombos.includes(initialSearchState.modelCombo)
    ) {
      if (selectedModelCombo !== initialSearchState.modelCombo) {
        setSelectedModelCombo(initialSearchState.modelCombo);
      }
      return;
    }
    if (!selectedModelCombo || !availableModelCombos.includes(selectedModelCombo)) {
      setSelectedModelCombo(availableModelCombos[0]);
    }
  }, [
    modelCombosLoading,
    availableModelCombos,
    selectedModelCombo,
    initialSearchState.modelCombo,
  ]);

  useEffect(() => {
    if (!selectedModelCombo) {
      return;
    }
    const combo = modelCombos[selectedModelCombo];
    if (!combo) {
      return;
    }
    setSearchModel(combo.embedding_model);
    setSummaryModelConfig(combo.summarization_model);
    setAssistantModelConfig(combo.assistant_model ?? combo.summarization_model);
    setSemanticHighlightModelConfig(combo.semantic_highlighting_model);
    setRerankModel(combo.reranker_model);
    setRerankModelPageSize(combo.rerank_model_page_size ?? null);
  }, [selectedModelCombo, modelCombos]);


  const [domainDropdownOpen, setDomainDropdownOpen] = useState(false);
  const [modelDropdownOpen, setModelDropdownOpen] = useState(false);
  const [helpDropdownOpen, setHelpDropdownOpen] = useState(false);
  const [contactModalOpen, setContactModalOpen] = useState(false);
  const [showDomainTooltip, setShowDomainTooltip] = useState(false);
  const [aboutContent, setAboutContent] = useState('');
  const [techContent, setTechContent] = useState('');
  const [dataContent, setDataContent] = useState('');
  const [privacyContent, setPrivacyContent] = useState('');
  const [termsContent, setTermsContent] = useState('');
  const [docsInitialPath, setDocsInitialPath] = useState<string | undefined>(undefined);
  const tooltipTimeoutRef = React.useRef<number | null>(null);

  const handleToggleDomainDropdown = useCallback(() => {
    setDomainDropdownOpen(!domainDropdownOpen);
    setModelDropdownOpen(false);
    setHelpDropdownOpen(false);
    setShowDomainTooltip(false);
    if (tooltipTimeoutRef.current) {
      clearTimeout(tooltipTimeoutRef.current);
      tooltipTimeoutRef.current = null;
    }
  }, [domainDropdownOpen]);

  const handleToggleModelDropdown = useCallback(() => {
    setModelDropdownOpen(!modelDropdownOpen);
    setDomainDropdownOpen(false);
    setHelpDropdownOpen(false);
  }, [modelDropdownOpen]);

  const handleDomainMouseEnter = useCallback(() => {
    tooltipTimeoutRef.current = window.setTimeout(() => {
      if (!domainDropdownOpen) {
        setShowDomainTooltip(true);
      }
    }, 2000);
  }, [domainDropdownOpen]);

  const handleDomainMouseLeave = useCallback(() => {
    if (tooltipTimeoutRef.current) {
      clearTimeout(tooltipTimeoutRef.current);
      tooltipTimeoutRef.current = null;
    }
    setShowDomainTooltip(false);
  }, []);

  const handleDomainBlur = useCallback(() => {
    setTimeout(() => setDomainDropdownOpen(false), 200);
  }, []);

  const handleModelBlur = useCallback(() => {
    setTimeout(() => setModelDropdownOpen(false), 200);
  }, []);

  const handleSelectDomain = useCallback((domainName: string) => {
    setSelectedDomain(domainName);
    const url = new URL(window.location.href);
    url.searchParams.set('dataset', domainName);
    window.history.replaceState(null, '', url.toString());
    setDomainDropdownOpen(false);
  }, []);

  const handleSelectModelCombo = useCallback((comboName: string) => {
    setSelectedModelCombo(comboName);
    setInitialSearchDone(false);
    const url = new URL(window.location.href);
    url.searchParams.set('model_combo', comboName);
    window.history.replaceState(null, '', url.toString());
    setModelDropdownOpen(false);
  }, []);

  const handleToggleHelpDropdown = useCallback(() => {
    setHelpDropdownOpen(!helpDropdownOpen);
    setDomainDropdownOpen(false);
    setModelDropdownOpen(false);
  }, [helpDropdownOpen]);

  const handleHelpBlur = useCallback(() => {
    setTimeout(() => setHelpDropdownOpen(false), 200);
  }, []);

  // Get current datasource config
  const currentDataSourceConfig = datasourcesConfig[selectedDomain] || {};

  // Get data source for API calls (from datasource config)
  // Ensure dataSource updates when config loads and selectedDomain is available
  const dataSource = React.useMemo(() => {
    // If we have a selectedDomain and config is loaded, get the data_subdir
    if (!loadingConfig && selectedDomain && datasourcesConfig[selectedDomain]) {
      return datasourcesConfig[selectedDomain].data_subdir || '';
    }
    // Config still loading — return empty to prevent premature API calls
    return currentDataSourceConfig?.data_subdir || '';
  }, [loadingConfig, selectedDomain, datasourcesConfig, currentDataSourceConfig]);

  // Fetch model combos filtered by active data source
  useEffect(() => {
    if (USER_MODULE_MODE === 'on_active' && !authState.isAuthenticated) return;
    if (!dataSource) return;
    fetchModelCombos(API_BASE_URL, setModelCombos, setModelCombosLoading, dataSource);
  }, [authState.isAuthenticated, dataSource]);

  const fieldMapping = currentDataSourceConfig?.field_mapping || {};
  const filterFields = currentDataSourceConfig?.filter_fields || {};
  const metadataPanelFields = React.useMemo(
    () => currentDataSourceConfig?.metadata_panel_fields
      || currentDataSourceConfig?.filter_fields
      || {},
    [currentDataSourceConfig],
  );


  // Initialize search state from URL parameters - MOVED TO TOP
  const [query, setQuery] = useState(initialSearchState.query);
  const [results, setResults] = useState<SearchResult[]>([]);
  const [searchId, setSearchId] = useState(() => generateUUID());
  const [facets, setFacets] = useState<Facets | null>(null);
  const [allFacets, setAllFacets] = useState<Facets | null>(null);
  const [facetsDataSource, setFacetsDataSource] = useState<string | null>(null);
  const [allFacetsDataSource, setAllFacetsDataSource] = useState<string | null>(null);
  const [filters, setFilters] = useState<SearchFilters>(initialSearchState.filters);
  const [initialSearchDone, setInitialSearchDone] = useState(false);
  const [loading, setLoading] = useState(false);
  const [hasSearchRun, setHasSearchRun] = useState(false);
  const [searchError, setSearchError] = useState<string | null>(null);
  const [selectedDoc, setSelectedDoc] = useState<SearchResult | null>(null);
  const [expandedCards, setExpandedCards] = useState<Set<string>>(new Set());
  // TOC modal state for search results
  const [tocModalOpen, setTocModalOpen] = useState(false);
  const [selectedTocDocId, setSelectedTocDocId] = useState<string>('');
  const [selectedToc, setSelectedToc] = useState<string>('');
  const [selectedTocPdfUrl, setSelectedTocPdfUrl] = useState<string>('');
  const [selectedTocPageCount, setSelectedTocPageCount] = useState<number | null>(null);
  const [loadingToc, setLoadingToc] = useState(false);
  const [metadataModalOpen, setMetadataModalOpen] = useState(false);
  const [metadataModalDoc, setMetadataModalDoc] = useState<Record<string, any> | null>(null);
  // Summary modal state for metadata panel
  const [summaryModalOpen, setSummaryModalOpen] = useState(false);
  const [selectedSummary, setSelectedSummary] = useState('');
  const [selectedSummaryTitle, setSelectedSummaryTitle] = useState('');
  const [selectedSummaryDocId, setSelectedSummaryDocId] = useState('');
  const fetchTocPdfUrl = useCallback(async (docId: string) => {
    try {
      const response = await axios.get(
        `${API_BASE_URL}/document/${docId}`,
        { params: { data_source: dataSource } }
      );
      const doc = response.data as {
        pdf_url?: string;
      };
      const pdfUrl = doc.pdf_url || '';
      if (!pdfUrl) {
        console.warn('No PDF link is available for this document.');
        return;
      }
      setSelectedTocPdfUrl(pdfUrl);
    } catch (error) {
      console.error('Error fetching PDF link for TOC:', error);
      alert('Failed to load PDF link for this TOC.');
    }
  }, [dataSource]);

  const [minScore, setMinScore] = useState<number>(0.0);
  const [maxScore, setMaxScore] = useState<number>(1.0);
  const [autoMinScore, setAutoMinScore] = useState<boolean>(initialSearchState.autoMinScore);
  const [searchDenseWeight, setSearchDenseWeight] = useState<number>(initialSearchState.denseWeight); // Default from .env or URL
  const [rerankEnabled, setRerankEnabled] = useState<boolean>(initialSearchState.rerank); // Reranker toggle from URL
  // Recency boost state
  const [recencyBoostEnabled, setRecencyBoostEnabled] = useState<boolean>(initialSearchState.recencyBoost);
  const [recencyWeight, setRecencyWeight] = useState<number>(initialSearchState.recencyWeight);
  const [recencyScaleDays, setRecencyScaleDays] = useState<number>(initialSearchState.recencyScaleDays);
  // Keyword boost for short queries
  const [keywordBoostShortQueries, setKeywordBoostShortQueries] = useState<boolean>(initialSearchState.keywordBoostShortQueries);
  // Semantic Highlighting state
  const [semanticHighlighting, setSemanticHighlighting] = useState<boolean>(initialSearchState.semanticHighlighting);
  // Content settings
  const [sectionTypes, setSectionTypes] = useState<string[]>(initialSearchState.sectionTypes);
  const [minChunkSize, setMinChunkSize] = useState<number>(initialSearchState.minChunkSize);
  // Deduplicate cross-document results
  const [deduplicateEnabled, setDeduplicateEnabled] = useState<boolean>(initialSearchState.deduplicate);
  // Field-level boosting (country, organization, etc.)
  const [fieldBoostEnabled, setFieldBoostEnabled] = useState<boolean>(initialSearchState.fieldBoost);
  const [fieldBoostFields, setFieldBoostFields] = useState<Record<string, number>>(initialSearchState.fieldBoostFields);
  // Group greeting message (overrides search placeholder on landing page)
  const [greetingMessage, setGreetingMessage] = useState<string>('');
  const [aiSummary, setAiSummary] = useState<string>('');
  const [aiSummaryLoading, setAiSummaryLoading] = useState<boolean>(false);
  const [aiPrompt, setAiPrompt] = useState<string>('');
  const [showPromptModal, setShowPromptModal] = useState<boolean>(false);
  const [aiSummaryCollapsed, setAiSummaryCollapsed] = useState<boolean>(false);
  const [aiSummaryExpanded, setAiSummaryExpanded] = useState<boolean>(false);
  const [aiSummaryResults, setAiSummaryResults] = useState<SearchResult[]>([]);
  const aiSummaryAbortRef = useRef<AbortController | null>(null);

  const [aiSummaryBuffer, setAiSummaryBuffer] = useState<string>(''); // Buffer for character animation
  const [aiSummaryTranslatedText, setAiSummaryTranslatedText] = useState<string | null>(null);
  const [aiSummaryTranslatingLang, setAiSummaryTranslatingLang] = useState<string | null>(null);
  const [aiSummaryTranslatedLang, setAiSummaryTranslatedLang] = useState<string | null>(null);

  // AI Summary Drilldown state (tree-based)
  const {
    drilldownTree, currentNodeId, isDrilldown, currentHighlight: drilldownHighlight,
    resetTree: resetDrilldownTree,
    loadTree: loadDrilldownTree,
    startDrilldown: startDrilldownInTree,
    addChildNode: addChildNodeInTree,
    addChildToNode: addChildToNodeInTree,
    removeNode: removeNodeInTree,
    getNode: getNodeInTree,
    updateNodeData: updateNodeDataInTree,
    navigateBack: navigateBackInTree,
    navigateToNode: navigateToNodeInTree,
  } = useDrilldownTree();
  const [findOutMoreLoading, setFindOutMoreLoading] = useState(false);
  const [findOutMoreActiveFact, setFindOutMoreActiveFact] = useState<string | null>(null);
  const [findOutMoreDone, setFindOutMoreDone] = useState(false);
  const [savedResearchId, setSavedResearchId] = useState<string | null>(null);
  const [saveResearchLoading, setSaveResearchLoading] = useState(false);
  const [saveResearchStatus, setSaveResearchStatus] = useState<'idle' | 'saved' | 'error'>('idle');
  const [showLoadResearchModal, setShowLoadResearchModal] = useState(false);
  const [addingNodeParentId, setAddingNodeParentId] = useState<string | null>(null);

  // Apply per-group search defaults (fetched when user is authenticated)
  useGroupDefaults(USER_MODULE, authState, {
    denseWeight: setSearchDenseWeight,
    rerank: setRerankEnabled,
    recencyBoost: setRecencyBoostEnabled,
    recencyWeight: setRecencyWeight,
    recencyScaleDays: setRecencyScaleDays,
    sectionTypes: setSectionTypes,
    keywordBoostShortQueries: setKeywordBoostShortQueries,
    minChunkSize: setMinChunkSize,
    semanticHighlighting: setSemanticHighlighting,
    autoMinScore: setAutoMinScore,
    deduplicate: setDeduplicateEnabled,
    fieldBoost: setFieldBoostEnabled,
    fieldBoostFields: setFieldBoostFields,
    greetingMessage: setGreetingMessage,
  });

  // Debug: Log semantic threshold on startup
  useEffect(() => {
    console.log(`[Config] Semantic Highlight Threshold: ${SEMANTIC_HIGHLIGHT_THRESHOLD}`);
    console.log(`[Config] Semantic Highlighting Enabled: ${SEARCH_SEMANTIC_HIGHLIGHTS}`);
  }, []);

  // Update URL when tab changes
  const handleTabChange = useCallback((tab: TabName) => {
    setActiveTab(tab);

    // Reset search filters when navigating to the search tab
    if (tab === 'search') {
      setSelectedFilters(buildEmptySelectedFilters());
    }

    let newPath = tab === 'search' ? '/' : `/${tab}`;
    newPath = withBasePath(newPath);

    // Preserve dataset/model in URL when switching tabs
    const params = new URLSearchParams();
    if (selectedDomain) {
      params.set('dataset', selectedDomain);
    }
    if (searchModel) {
      params.set('model', searchModel);
    }
    if (selectedModelCombo) {
      params.set('model_combo', selectedModelCombo);
    }
    if (tab !== 'search') {
      params.set('tab', tab);
    }

    const queryString = params.toString();
    if (queryString) {
      newPath += `?${queryString}`;
    }

    window.history.pushState(null, '', newPath);
  }, [selectedDomain, searchModel, selectedModelCombo]);

  const handleAboutClick = useCallback(() => {
    setDocsInitialPath('overview/about.md');
    handleTabChange('info');
    setHelpDropdownOpen(false);
  }, [handleTabChange]);

  const handleTechClick = useCallback(() => {
    setDocsInitialPath('overview/tech.md');
    handleTabChange('tech');
    setHelpDropdownOpen(false);
  }, [handleTabChange]);

  const handleDataClick = useCallback(() => {
    setDocsInitialPath('overview/data.md');
    handleTabChange('data');
    setHelpDropdownOpen(false);
  }, [handleTabChange]);

  const handlePrivacyClick = useCallback(() => {
    handleTabChange('privacy');
    setHelpDropdownOpen(false);
  }, [handleTabChange]);

  const handleTermsClick = useCallback(() => {
    handleTabChange('terms');
    setHelpDropdownOpen(false);
  }, [handleTabChange]);

  const handleDocsClick = useCallback(() => {
    handleTabChange('docs');
    setHelpDropdownOpen(false);
  }, [handleTabChange]);

  // Listen for browser back/forward navigation and URL changes
  useEffect(() => {
    const handlePopState = () => {
      setActiveTab(getTabFromPath());
      // Also restore search state from URL
      const searchState = getSearchStateFromURL(
        DEFAULT_FILTER_FIELDS,
        DEFAULT_SECTION_TYPES
      );
      setQuery(searchState.query);
      setFilters(searchState.filters);
      // Restore selected filter arrays (dynamic)
      setSelectedFilters(searchState.selectedFilters);
      // Restore range filter values
      setRangeFilters(searchState.rangeFilters);
      // Restore search mode settings
      setSearchDenseWeight(searchState.denseWeight);
      setRerankEnabled(searchState.rerank);
      // Restore content settings
      setSectionTypes(searchState.sectionTypes);
      // Restore min chunk size
      setMinChunkSize(searchState.minChunkSize);
      // Restore semantic highlighting
      setSemanticHighlighting(searchState.semanticHighlighting);
      setDeduplicateEnabled(searchState.deduplicate);
      setFieldBoostEnabled(searchState.fieldBoost);
      setFieldBoostFields(searchState.fieldBoostFields);
      setSearchModel(searchState.model);
      setSelectedModelCombo(searchState.modelCombo);

      // Restore dataset if valid
      if (searchState.dataset && availableDomains.includes(searchState.dataset)) {
        setSelectedDomain(searchState.dataset);
      }
    };

    // Also check URL on mount and when availableDomains changes (for direct navigation)
    const checkURLForDataset = () => {
      const searchState = getSearchStateFromURL(
        DEFAULT_FILTER_FIELDS,
        DEFAULT_SECTION_TYPES
      );
      if (searchState.dataset && availableDomains.includes(searchState.dataset) && selectedDomain !== searchState.dataset) {
        setSelectedDomain(searchState.dataset);
      }
    };

    window.addEventListener('popstate', handlePopState);
    // Check URL when domains are available (handles direct navigation to URLs with dataset param)
    if (!loadingConfig && availableDomains.length > 0) {
      checkURLForDataset();
    }

    return () => {
      window.removeEventListener('popstate', handlePopState);
    };
  }, [availableDomains, loadingConfig, selectedDomain]);


  const [filtersExpanded, setFiltersExpanded] = useState(window.innerWidth > 1024);
  const toggleFiltersExpanded = useCallback(() => {
    setFiltersExpanded((prev) => !prev);
  }, []);

  const [heatmapFiltersExpanded, setHeatmapFiltersExpanded] = useState(false);
  const toggleHeatmapFiltersExpanded = useCallback(() => {
    setHeatmapFiltersExpanded((prev) => !prev);
  }, []);

  const buildEmptySelectedFilters = useCallback(() => {
    const cleared: Record<string, string[]> = {};
    const fields = facets?.filter_fields
      ? Object.keys(facets.filter_fields)
      : DEFAULT_FILTER_FIELDS;
    for (const field of fields) {
      cleared[field] = [];
    }
    return cleared;
  }, [facets]);

  const defaultYearFiltersAppliedRef = useRef(false);

  // Multi-select filters (dynamic by core field name) - initialize from URL
  const [selectedFilters, setSelectedFilters] = useState<Record<string, string[]>>(initialSearchState.selectedFilters);

  // Range filters for numerical fields (min/max inputs)
  const [rangeFilters, setRangeFilters] = useState<Record<string, { min: string; max: string }>>(initialSearchState.rangeFilters);

  // Sync selectedFilters when facets load with new config-driven fields
  useEffect(() => {
    if (!facets?.filter_fields) return;
    const configFields = Object.keys(facets.filter_fields);
    setSelectedFilters((prev) => {
      const hasNewFields = configFields.some((f) => !(f in prev));
      if (!hasNewFields) return prev;
      const next = { ...prev };
      for (const field of configFields) {
        if (!(field in next)) {
          next[field] = [];
        }
      }
      return next;
    });
  }, [facets]);

  const [heatmapFilters, setHeatmapFilters] = useState<SearchFilters>({});
  const [heatmapSelectedFilters, setHeatmapSelectedFilters] = useState<Record<string, string[]>>(
    buildEmptySelectedFilters()
  );

  const buildHeatmapFacetFilters = useCallback(() => {
    const nextFilters = { ...heatmapFilters };
    delete nextFilters.published_year;
    return nextFilters;
  }, [heatmapFilters]);

  // Tracks which filter sections the user has expanded (everything collapsed by default)
  const [collapsedFilters, setCollapsedFilters] = useState<Set<string>>(new Set());
  const [heatmapCollapsedFilters, setHeatmapCollapsedFilters] = useState<Set<string>>(new Set());

  // Track which filter lists are expanded to show all items (default shows 5)
  const [expandedFilterLists, setExpandedFilterLists] = useState<Set<string>>(new Set());
  const [heatmapExpandedFilterLists, setHeatmapExpandedFilterLists] = useState<Set<string>>(new Set());

  // Search terms for each filter (dynamic by core field name)
  const [filterSearchTerms, setFilterSearchTerms] = useState<Record<string, string>>({});

  // Collapsed headings state for search results (all collapsed by default)
  const [collapsedHeadings, setCollapsedHeadings] = useState<Set<number>>(new Set());

  // Title search state
  const [titleSearchResults, setTitleSearchResults] = useState<FacetValue[]>([]);
  const [isSearchingTitles, setIsSearchingTitles] = useState(false);
  const titleSearchTimeoutRef = useRef<any>(null);

  const handleFilterChange = (key: string, value: string) => {
    setFilters(prev => ({ ...prev, [key]: value || undefined }));
  };

  const handleHeatmapFilterChange = (key: string, value: string) => {
    setHeatmapFilters(prev => ({ ...prev, [key]: value || undefined }));
  };

  const buildFilterValue = (values: string[]) =>
    values.length > 0 ? values.join(',') : '';

  const handleRemoveFilter = useCallback(
    (coreField: string, value: string) => {
      const selectedValues = selectedFilters[coreField] || [];
      const newValues = selectedValues.filter((item) => item !== value);
      setSelectedFilters((prev: Record<string, string[]>) => ({
        ...prev,
        [coreField]: newValues,
      }));
      handleFilterChange(coreField, buildFilterValue(newValues));
    },
    [buildFilterValue, handleFilterChange, selectedFilters]
  );

  const handleFilterValuesChange = useCallback(
    (coreField: string, nextValues: string[]) => {
      setSelectedFilters((prev: Record<string, string[]>) => ({
        ...prev,
        [coreField]: nextValues,
      }));
      handleFilterChange(coreField, buildFilterValue(nextValues));
    },
    [buildFilterValue, handleFilterChange]
  );

  const handleRangeChange = useCallback(
    (coreField: string, min: string, max: string) => {
      setRangeFilters((prev) => ({
        ...prev,
        [coreField]: { min, max },
      }));
      setFilters((prev) => {
        const next = { ...prev };
        if (min) {
          next[`${coreField}_min`] = min;
        } else {
          delete next[`${coreField}_min`];
        }
        if (max) {
          next[`${coreField}_max`] = max;
        } else {
          delete next[`${coreField}_max`];
        }
        return next;
      });
    },
    []
  );

  const handleHeatmapRemoveFilter = useCallback(
    (coreField: string, value: string) => {
      const selectedValues = heatmapSelectedFilters[coreField] || [];
      const newValues = selectedValues.filter((item) => item !== value);
      setHeatmapSelectedFilters((prev: Record<string, string[]>) => ({
        ...prev,
        [coreField]: newValues,
      }));
      handleHeatmapFilterChange(coreField, buildFilterValue(newValues));
    },
    [buildFilterValue, handleHeatmapFilterChange, heatmapSelectedFilters]
  );

  const handleHeatmapFilterValuesChange = useCallback(
    (coreField: string, nextValues: string[]) => {
      setHeatmapSelectedFilters((prev: Record<string, string[]>) => ({
        ...prev,
        [coreField]: nextValues,
      }));
      handleHeatmapFilterChange(coreField, buildFilterValue(nextValues));
    },
    [buildFilterValue, handleHeatmapFilterChange]
  );

  // No auto-collapse effects needed — all sections default to collapsed
  // because collapsedFilters now tracks expanded fields (inverted semantics)

  // Perform title search when title filter input changes
  useEffect(() => {
    const titleQuery = filterSearchTerms['title'];

    // clear previous timeout
    if (titleSearchTimeoutRef.current) {
      clearTimeout(titleSearchTimeoutRef.current);
    }

    if (!titleQuery || titleQuery.trim().length < 2) {
      setTitleSearchResults([]);
      setIsSearchingTitles(false);
      return;
    }

    // Debounce search
    titleSearchTimeoutRef.current = setTimeout(async () => {
      setIsSearchingTitles(true);
      try {
        const params = new URLSearchParams();
        params.append('q', titleQuery.trim());
        params.append('limit', '50');
        params.append('data_source', dataSource);

        if (searchModel) params.append('model', searchModel);

        const response = await axios.get<any[]>(`${API_BASE_URL}/search/titles?${params}`);
        const data = response.data as any[];

        // Map response to FacetValue format
        const facets: FacetValue[] = data.map((item: any) => ({
          value: item.title,
          count: 1, // Count is less relevant for direct search, but needed for interface
          organization: item.organization,
          published_year: item.year ? String(item.year) : undefined
        }));

        setTitleSearchResults(facets);
      } catch (error) {
        console.error('Title search failed:', error);
      } finally {
        setIsSearchingTitles(false);
      }
    }, 300); // 300ms debounce

    return () => {
      if (titleSearchTimeoutRef.current) {
        clearTimeout(titleSearchTimeoutRef.current);
      }
    };
  }, [filterSearchTerms['title'], dataSource, searchDenseWeight, searchModel]);

  // General Facet Value Search State
  // Map of field -> list of FacetValues found via search
  const [facetSearchResults, setFacetSearchResults] = useState<Record<string, FacetValue[]>>({});
  const facetSearchTimeoutRef = useRef<any>(null);

  // Perform generic facet value search when filter input changes (for fields other than title)
  useEffect(() => {
    // We only care about fields that have search terms AND are not title
    Object.entries(filterSearchTerms).forEach(([field, query]) => {
      if (field === 'title') return; // Handled by separate effect

      // Clear specific searches if query is empty
      if (!query || query.trim().length < 2) {
        setFacetSearchResults(prev => {
          const next = { ...prev };
          delete next[field];
          return next;
        });
      }
    });

    // Better Approach: Iterate and find which one triggered? No, simpler:
    // This effect runs on `filterSearchTerms`.
    // We can just debounce the API call for ANY field that has a value.
    // However, if we have "Uni" in Org and "20" in Year, we don't want to re-search Org when typing Year.
    // So we should track 'prevFilterSearchTerms' or similar.
    // OR just use a separate generic handler.

    // Simplest Robust Implementation:
    // Just handle the search in the `onChange` handler directly (debounced there) instead of a global effect?
    // But `filterSearchTerms` is state.
    // Let's stick to the Effect but use a ref to track which one changed?
    // No, let's just use a ref for the timeout.

    // We will initiate a search for the changed field.
    // Actually, we can reuse the `titleSearchTimeoutRef` pattern but generalized.
    // But since we can't easily know *which* key changed in a massive object dependency,
    // we'll implement a `performFacetSearch` function and call it from the `onChange` event in the JSX.
    // That avoids this complex Effect logic.

  }, [filterSearchTerms]); // We will actually remove this generic effect and move logic to specific handler.

  const performFacetSearch = (field: string, query: string) => {
    if (facetSearchTimeoutRef.current) clearTimeout(facetSearchTimeoutRef.current);

    if (!query || query.trim().length < 2) {
      setFacetSearchResults(prev => {
        const next = { ...prev };
        delete next[field];
        return next;
      });
      return;
    }

    facetSearchTimeoutRef.current = setTimeout(async () => {
      try {
        const params = new URLSearchParams();
        params.append('field', field);
        params.append('q', query.trim());
        params.append('limit', '100');
        params.append('data_source', dataSource);

        const response = await axios.get<any[]>(`${API_BASE_URL}/search/facet-values?${params}`);
        const data = response.data as any[];

        // Map response
        const facets: FacetValue[] = data.map((item: any) => ({
          value: item.value,
          count: item.count
        }));

        setFacetSearchResults(prev => ({
          ...prev,
          [field]: facets
        }));

      } catch (e) {
        console.error(`Facet search failed for ${field}:`, e);
      }
    }, 300);
  };

  const handleFilterSearchTermChange = useCallback(
    (coreField: string, value: string) => {
      setFilterSearchTerms((prev) => ({ ...prev, [coreField]: value }));
      if (coreField !== 'title') {
        performFacetSearch(coreField, value);
      }
    },
    [performFacetSearch]
  );


  const toggleFilter = (filterName: string) => {
    setCollapsedFilters(prev => {
      const newSet = new Set(prev);
      if (newSet.has(filterName)) {
        newSet.delete(filterName);
      } else {
        newSet.add(filterName);
      }
      return newSet;
    });
  };

  const toggleHeatmapFilter = (filterName: string) => {
    setHeatmapCollapsedFilters(prev => {
      const newSet = new Set(prev);
      if (newSet.has(filterName)) {
        newSet.delete(filterName);
      } else {
        newSet.add(filterName);
      }
      return newSet;
    });
  };

  const toggleFilterListExpansion = (filterKey: string) => {
    setExpandedFilterLists(prev => {
      const newSet = new Set(prev);
      if (newSet.has(filterKey)) {
        newSet.delete(filterKey);
      } else {
        newSet.add(filterKey);
      }
      return newSet;
    });
  };

  const toggleHeatmapFilterListExpansion = (filterKey: string) => {
    setHeatmapExpandedFilterLists(prev => {
      const newSet = new Set(prev);
      if (newSet.has(filterKey)) {
        newSet.delete(filterKey);
      } else {
        newSet.add(filterKey);
      }
      return newSet;
    });
  };

  const toggleHeadings = (resultIndex: number) => {
    setCollapsedHeadings(prev => {
      const newSet = new Set(prev);
      if (newSet.has(resultIndex)) {
        newSet.delete(resultIndex);
      } else {
        newSet.add(resultIndex);
      }
      return newSet;
    });
  };

  const loadFacets = useCallback(async (options?: { includeQuery?: boolean; filtersOverride?: SearchFilters; queryValue?: string }) => {
    try {
      if (!dataSource || (loadingConfig && initialSearchState.dataset)) {
        return;
      }
      const includeQuery = options?.includeQuery ?? false;
      const filtersToUse = options?.filtersOverride ?? filters;
      const queryToUse = options?.queryValue;
      const params = new URLSearchParams();
      // Add all filter values using core field names
      for (const [field, value] of Object.entries(filtersToUse)) {
        if (value) {
          params.append(field, value);
        }
      }
      params.append('data_source', dataSource);
      if (includeQuery && queryToUse && queryToUse.trim()) {
        params.append('q', queryToUse.trim());
      }

      const url = `${API_BASE_URL}/facets?${params}`;
      const response = await axios.get<Facets>(url);
      const data = response.data as Facets;

      // Always update facets
      setFacets(data);
      setFacetsDataSource(dataSource);
    } catch (error: any) {
      console.error('Error loading facets:', error);
      if (error?.response?.status === 502 || error?.response?.status === 503 || error?.response?.status === 504) {
        console.warn('Backend server is unreachable (502 Bad Gateway). Facets will not be available until the backend is running.');
      }
    }
  }, [loadingConfig, initialSearchState.dataset, filters, dataSource]);

  const loadAllFacets = useCallback(async () => {
    try {
      if (!dataSource || (loadingConfig && initialSearchState.dataset)) {
        return;
      }
      const params = new URLSearchParams();
      params.append('data_source', dataSource);
      const url = `${API_BASE_URL}/facets?${params}`;
      const response = await axios.get<Facets>(url);
      const data = response.data as Facets;
      setAllFacets(data);
      setAllFacetsDataSource(dataSource);
    } catch (error) {
      console.error('Error loading all facets:', error);
    }
  }, [loadingConfig, initialSearchState.dataset, dataSource]);

  // Load about/tech/data/privacy content when help tabs are active
  useEffect(() => {
    if (activeTab === 'info') {
      // Add timestamp to prevent caching during development
      fetch(`${withBasePath('/docs/overview/about.md')}?t=${Date.now()}`)
        .then(response => response.text())
        .then(text => setAboutContent(text))
        .catch(err => console.error('Failed to load about content:', err));
    }
    if (activeTab === 'tech') {
      // Add timestamp to prevent caching during development
      fetch(`${withBasePath('/docs/overview/tech.md')}?t=${Date.now()}`)
        .then(response => response.text())
        .then(text => setTechContent(text))
        .catch(err => console.error('Failed to load tech content:', err));
    }
    if (activeTab === 'data') {
      // Add timestamp to prevent caching during development
      fetch(`${withBasePath('/docs/overview/data.md')}?t=${Date.now()}`)
        .then(response => response.text())
        .then(text => setDataContent(text))
        .catch(err => console.error('Failed to load data content:', err));
    }
    if (activeTab === 'privacy') {
      // Add timestamp to prevent caching during development
      fetch(`${withBasePath('/docs/overview/privacy.md')}?t=${Date.now()}`)
        .then(response => response.text())
        .then(text => {
          if (GA_MEASUREMENT_ID && getGaConsent() !== 'denied') {
            const gaSection = [
              '',
              '## Analytics',
              '',
              'This instance of Evidence Lab uses [Google Analytics](https://marketingplatform.google.com/about/analytics/) to understand how the platform is used and to improve performance and usability. Analytics data is only collected after you consent via the cookie preferences popup shown on your first visit.',
              '',
              'The information collected may include anonymized IP addresses, page views, and basic device information. We do not use analytics data for advertising or cross-site tracking.',
              '',
              'Analytics data may be processed by Google LLC, including on servers outside the European Union, under appropriate legal safeguards.',
            ].join('\n');
            setPrivacyContent(text.trimEnd() + '\n' + gaSection);
          } else {
            setPrivacyContent(text);
          }
        })
        .catch(err => console.error('Failed to load privacy content:', err));
    }
    if (activeTab === 'terms') {
      fetch(`${withBasePath('/docs/overview/terms.md')}?t=${Date.now()}`)
        .then(response => response.text())
        .then(text => setTermsContent(text))
        .catch(err => console.error('Failed to load terms content:', err));
    }
  }, [activeTab]);

  // Load facets on mount, when filters change, or when data source changes
  // Heatmap uses full dataset facets by default, even if a query is present.
  // Note: Query-based facets loading is handled separately with debouncing
  useEffect(() => {
    if (activeTab === 'heatmap') {
      loadFacets({ includeQuery: false, filtersOverride: buildHeatmapFacetFilters() });
    } else {
      // Load facets without query - only based on filters
      loadFacets({ includeQuery: false });
    }
  }, [filters, heatmapFilters, dataSource, activeTab, loadFacets, buildHeatmapFacetFilters]);

  useEffect(() => {
    loadAllFacets();
  }, [dataSource, loadAllFacets]);

  useEffect(() => {
    defaultYearFiltersAppliedRef.current = false;
  }, [dataSource]);

  useEffect(() => {
    if (activeTab !== 'heatmap') {
      return;
    }
    if (defaultYearFiltersAppliedRef.current) {
      return;
    }
    if (heatmapFilters.published_year || (heatmapSelectedFilters.published_year || []).length > 0) {
      defaultYearFiltersAppliedRef.current = true;
      return;
    }
    const yearFacets = facets?.facets?.published_year || [];
    if (yearFacets.length === 0) {
      return;
    }
    const availableYears = new Set(yearFacets.map((item) => item.value));
    const nextYears = DEFAULT_PUBLISHED_YEARS.filter((year) => availableYears.has(year));
    defaultYearFiltersAppliedRef.current = true;
    if (nextYears.length === 0) {
      return;
    }
    setHeatmapSelectedFilters((prev) => ({ ...prev, published_year: nextYears }));
    setHeatmapFilters((prev) => ({ ...prev, published_year: nextYears.join(',') }));
  }, [
    activeTab,
    facets,
    heatmapFilters.published_year,
    heatmapSelectedFilters.published_year,
  ]);



  // Track if we've done initial search to avoid double-searching on load
  // Update URL when search state changes (after results are loaded)
  useEffect(() => {
    if (activeTab !== 'search') {
      return;
    }
    if (results.length > 0 && query.trim()) {
      const searchParams = buildSearchURL(
        query,
        filters,
        searchDenseWeight,
        rerankEnabled,
        recencyBoostEnabled,
        recencyWeight,
        recencyScaleDays,
        sectionTypes,
        keywordBoostShortQueries,
        minChunkSize,
        semanticHighlighting,
        autoMinScore,
        deduplicateEnabled,
        searchModel,
        selectedModelCombo,
        selectedDomain,
        fieldBoostEnabled,
        fieldBoostFields
      );
      // Build URLSearchParams from the base search params
      const params = new URLSearchParams(searchParams || '');
      // Append or remove doc_id/chunk_id depending on whether a document is selected
      if (selectedDoc) {
        params.set('doc_id', selectedDoc.doc_id);
        params.set('chunk_id', selectedDoc.chunk_id);
      } else {
        params.delete('doc_id');
        params.delete('chunk_id');
      }
      // Preserve carousel filter params if present in current URL
      const currentParams = new URLSearchParams(window.location.search);
      const carouselOrg = currentParams.get('carousel_org');
      const carouselDoc = currentParams.get('carousel_doc');
      if (carouselOrg) params.set('carousel_org', carouselOrg);
      if (carouselDoc) params.set('carousel_doc', carouselDoc);
      const finalParams = params.toString();
      const searchString = finalParams ? `?${finalParams}` : '';
      const newURL = withBasePath(finalParams ? `/?${finalParams}` : '/');
      if (window.location.search !== searchString) {
        window.history.replaceState(null, '', newURL);
      }
    }
  }, [
    activeTab,
    results,
    query,
    filters,
    searchDenseWeight,
    rerankEnabled,
    recencyBoostEnabled,
    recencyWeight,
    recencyScaleDays,
    sectionTypes,
    keywordBoostShortQueries,
    minChunkSize,
    semanticHighlighting,
    autoMinScore,
    deduplicateEnabled,
    fieldBoostEnabled,
    fieldBoostFields,
    searchModel,
    selectedModelCombo,
    selectedDomain,
    selectedDoc,
  ]);

  // Auto-open PDF modal if URL contained doc_id/chunk_id when page loaded
  useEffect(() => {
    if (!initialDocFromUrl.current || results.length === 0) return;
    const { doc_id, chunk_id } = initialDocFromUrl.current;
    const match = results.find(r => r.doc_id === doc_id && r.chunk_id === chunk_id);
    if (match) {
      setSelectedDoc(match);
      initialDocFromUrl.current = null; // Only do this once
    }
  }, [results]);

  const processingHighlightsRef = useRef<Set<string>>(new Set());
  const isSearchingRef = useRef(false);

  // Callback to perform highlighting for a single result
  const handleRequestHighlight = useCallback(async (chunkId: string, text: string) => {
    if (processingHighlightsRef.current.has(chunkId)) return;

    // Check if result already has highlighting
    // Since we don't depend on "results", we need to check this logic in context or assume caller checked.
    // The caller (SearchResultCard) checks !result.highlightedText before calling.
    // But we should double check if another request race finished?
    // We rely on processingHighlightsRef for in-flight dedupe.

    processingHighlightsRef.current.add(chunkId);
    console.log(`[Highlight] Starting semantic highlighting for ${chunkId}`);

    try {
      // Call unified highlight API which returns HTML with <em> tags

      // Use the improved findSemanticMatches utility which handles alignment and offsets correctly
      // This avoids the issue where "safety" highlights every instance of "safety" in the document.
      const semanticMatches = await findSemanticMatches(
        text,
        query,
        SEMANTIC_HIGHLIGHT_THRESHOLD,
        semanticHighlightModelConfig
      );

      // Update state
      setResults((prev: SearchResult[]) => prev.map((r: SearchResult) => {
        if (r.chunk_id === chunkId) {
          // Add similarity: 1.0 as a default for these matches since we don't get per-match scores from this flow easily
          // (The backend simply returns <em> tags).
          const matchesWithScore = semanticMatches.map(m => ({
            ...m,
            similarity: 1.0
          }));

          return {
            ...r,
            highlightedText: r.text, // Trigger re-render with highlighting enabled
            semanticMatches: matchesWithScore
          };
        }
        return r;
      }));
    } catch (error) {
      console.error(`[Highlight] Error for ${chunkId}:`, error);
    } finally {
      processingHighlightsRef.current.delete(chunkId);
    }
  }, [query, semanticHighlightModelConfig]);

  /** Shared helper: strip results to lean payload and stream a summary */
  const launchSummaryStream = useCallback((streamQuery: string, streamResults: SearchResult[]) => {
    if (aiSummaryAbortRef.current) {
      aiSummaryAbortRef.current.abort();
    }
    const abortController = new AbortController();
    aiSummaryAbortRef.current = abortController;

    setAiSummaryLoading(true);
    summaryStartMsRef.current = performance.now();
    setAiSummary('');
    setAiSummaryBuffer('');
    setAiPrompt('');
    setAiSummaryTranslatedText(null);
    setAiSummaryTranslatingLang(null);
    setAiSummaryTranslatedLang(null);

    const leanResults = streamResults.map((r) => ({
      chunk_id: r.chunk_id,
      doc_id: r.doc_id,
      text: r.text,
      title: r.title,
      organization: r.organization,
      year: r.year,
      page_num: r.page_num,
      headings: r.headings,
      score: r.score,
    })) as SearchResult[];

    streamAiSummary({
      apiBaseUrl: API_BASE_URL,
      dataSource,
      query: streamQuery,
      results: leanResults,
      summaryModelConfig,
      signal: abortController.signal,
      handlers: {
        onPrompt: setAiPrompt,
        onToken: setAiSummary,
        onDone: (data) => {
          // Stash usage so the trailing updateActivitySummary effect can
          // include token counts + model in the PATCH that fires next.
          aiSummaryUsageRef.current = data?.usage;
          setAiSummaryLoading(false);
        },
        onError: (message: string) => {
          console.error('AI summary streaming error:', message);
          setAiSummary(AI_SUMMARY_ERROR);
          setAiSummaryLoading(false);
        },
      },
    }).catch((error) => {
      if (error instanceof DOMException && error.name === 'AbortError') return;
      console.error('AI summary streaming failed:', error);
      setAiSummary(AI_SUMMARY_ERROR);
      setAiSummaryLoading(false);
    });
  }, [dataSource, summaryModelConfig]);

  const startAiSummaryStream = useCallback((summaryResults: SearchResult[]) => {
    if (!AI_SUMMARY_ON || summaryResults.length === 0) {
      setAiSummary('');
      setAiPrompt('');
      return;
    }
    if (!summaryModelConfig) {
      console.error('AI summary model config is missing.');
      setAiSummary('AI summary unavailable: no summary model configured.');
      setAiSummaryLoading(false);
      return;
    }

    resetDrilldownTree();
    setSavedResearchId(null);
    setAddingNodeParentId(null);
    setFindOutMoreDone(false);

    const sliced = summaryResults.slice(0, 20);
    setAiSummaryResults(sliced);
    setAiSummaryExpanded(false);
    launchSummaryStream(query, sliced);
  }, [query, summaryModelConfig, resetDrilldownTree, launchSummaryStream]);

  const handleAiSummaryForResults = useCallback((data: SearchResponse) => {
    startAiSummaryStream(data.results);
  }, [startAiSummaryStream]);

  // Helper: build current AI summary snapshot for drilldown save/restore
  const getSnapshot = useCallback((): AiSummarySnapshot => ({
    summary: aiSummary,
    prompt: aiPrompt,
    results: aiSummaryResults,
    expanded: aiSummaryExpanded,
    translatedText: aiSummaryTranslatedText,
    translatedLang: aiSummaryTranslatedLang,
  }), [aiSummary, aiPrompt, aiSummaryResults, aiSummaryExpanded,
      aiSummaryTranslatedText, aiSummaryTranslatedLang]);

  // Helper: restore UI state from a drilldown node
  const restoreFromNode = useCallback((node: { summary: string; prompt: string; results: SearchResult[]; expanded: boolean; translatedText: string | null; translatedLang: string | null }) => {
    setAiSummary(node.summary);
    setAiPrompt(node.prompt);
    setResults(node.results);
    setAiSummaryResults(node.results);
    setAiSummaryExpanded(node.expanded);
    setAiSummaryTranslatedText(node.translatedText);
    setAiSummaryTranslatedLang(node.translatedLang);
    setAiSummaryTranslatingLang(null);
    setAiSummaryLoading(false);
  }, []);

  // Drilldown: save current state, search for fresh results, stream focused summary
  const startDrilldown = useCallback(async (
    highlightedText: string,
    mode: 'subtopic' | 'newtopic' = 'subtopic',
  ) => {
    const isNewTopic = mode === 'newtopic';
    const snapshot = getSnapshot();
    if (isNewTopic) {
      // "New topic" mode: drop the existing drilldown chain so the
      // breadcrumb collapses and the AI summary stands alone — same shape
      // a fresh top-level search would produce. Updating ``query`` keeps
      // the URL, activity log and any future sub-drilldowns rooted at
      // this new topic.
      resetDrilldownTree();
      setQuery(highlightedText);
    } else {
      startDrilldownInTree(highlightedText, snapshot, query);
    }

    setAiSummaryExpanded(true);
    setAiSummaryLoading(true);
    setAiSummary('');
    setAiPrompt('');
    window.scrollTo({ top: 0, behavior: 'smooth' });

    // Sub-topic mode inherits the parent investigation's context into the
    // search query so retrieval is narrowed to chunks relevant to both the
    // leaf and the surrounding investigation. New-topic mode treats the
    // selection as a fresh independent question (search and summary use
    // the leaf alone, no parent inheritance).
    const searchQuery = isNewTopic
      ? highlightedText
      : buildContextualSearchQuery(highlightedText, query, drilldownHighlight);
    const params = buildSearchParams({
      query: searchQuery,
      filters,
      searchDenseWeight,
      rerankEnabled,
      recencyBoostEnabled,
      recencyWeight,
      recencyScaleDays,
      sectionTypes,
      keywordBoostShortQueries,
      minChunkSize,
      rerankModel,
      rerankModelPageSize,
      searchModel,
      dataSource,
      autoMinScore,
      deduplicateEnabled,
      fieldBoostEnabled,
      fieldBoostFields,
    });

    try {
      const response = await axios.get<SearchResponse>(`${API_BASE_URL}/search?${params}`);
      const freshResults = response.data.results.slice(0, 20);
      setResults(freshResults);
      setAiSummaryResults(freshResults);

      // For sub-topic mode, the summary prompt mirrors the search query's
      // inheritance (root + immediate parent label). For new-topic mode the
      // summary stands alone — same prompt the user would get if they typed
      // the selection into the main search box.
      let drilldownQuery: string;
      if (isNewTopic) {
        drilldownQuery = highlightedText;
      } else {
        const parentContext = drilldownHighlight && drilldownHighlight !== query
          ? `, specifically "${drilldownHighlight}"`
          : '';
        drilldownQuery = `Regarding: "${highlightedText}"\n\nProvide detail about this, in the context of: "${query}"${parentContext}`;
      }
      launchSummaryStream(drilldownQuery, freshResults);
    } catch (error) {
      console.error('Drilldown search failed:', error);
      setAiSummary(AI_SUMMARY_ERROR);
      setAiSummaryLoading(false);
    }
  }, [getSnapshot, startDrilldownInTree, resetDrilldownTree, query, drilldownHighlight, launchSummaryStream,
      filters, searchDenseWeight, rerankEnabled, recencyBoostEnabled,
      recencyWeight, recencyScaleDays, sectionTypes, keywordBoostShortQueries,
      minChunkSize, rerankModel, rerankModelPageSize, searchModel, dataSource,
      autoMinScore, deduplicateEnabled, fieldBoostEnabled, fieldBoostFields]);

  // Navigate back to parent node in the drilldown tree
  const navigateBackDrilldown = useCallback(() => {
    if (aiSummaryAbortRef.current) {
      aiSummaryAbortRef.current.abort();
      aiSummaryAbortRef.current = null;
    }
    const parent = navigateBackInTree(getSnapshot());
    if (parent) restoreFromNode(parent);
  }, [getSnapshot, navigateBackInTree, restoreFromNode]);

  // Navigate to any node in the drilldown tree by ID
  const navigateDrilldownToNode = useCallback((nodeId: string) => {
    if (aiSummaryAbortRef.current) {
      aiSummaryAbortRef.current.abort();
      aiSummaryAbortRef.current = null;
    }
    const target = navigateToNodeInTree(nodeId, getSnapshot());
    if (target) {
      restoreFromNode(target);
      setAiSummaryExpanded(true);
    }
  }, [getSnapshot, navigateToNodeInTree, restoreFromNode]);

  // Batch-research all Key Facts: create child nodes and populate them
  const handleFindOutMore = useCallback(async (keyFacts: string[]) => {
    if (keyFacts.length === 0) return;
    setFindOutMoreLoading(true);
    setFindOutMoreDone(false);

    const snapshot = getSnapshot();

    // Create stub child nodes for all facts
    const nodeIds = keyFacts.map((fact) =>
      addChildNodeInTree(fact, snapshot, query)
    );

    // Sequentially search and summarize each fact
    for (let i = 0; i < keyFacts.length; i++) {
      const fact = keyFacts[i];
      const nodeId = nodeIds[i];
      setFindOutMoreActiveFact(fact);
      try {
        // Inherit parent context into the search query, not just the summary
        // prompt — same reasoning as startDrilldown above.
        const contextualSearchQuery = buildContextualSearchQuery(
          fact,
          query,
          drilldownHighlight,
        );
        const params = buildSearchParams({
          query: contextualSearchQuery, filters, searchDenseWeight, rerankEnabled,
          recencyBoostEnabled, recencyWeight, recencyScaleDays, sectionTypes,
          keywordBoostShortQueries, minChunkSize, rerankModel, rerankModelPageSize,
          searchModel, dataSource, autoMinScore, deduplicateEnabled,
          fieldBoostEnabled, fieldBoostFields,
        });
        const searchResp = await axios.get<SearchResponse>(`${API_BASE_URL}/search?${params}`);
        const freshResults = searchResp.data.results.slice(0, 20);

        // Query inheritance (root + parent): same approach as startDrilldown above.
        // At root level parentContext is empty; deeper levels add the immediate
        // parent's label so the LLM stays focused without a long ancestor chain.
        const parentContext = drilldownHighlight && drilldownHighlight !== query
          ? `, specifically "${drilldownHighlight}"`
          : '';
        const summaryQuery = `Regarding: "${fact}"\n\nProvide detail about this, in the context of: "${query}"${parentContext}`;
        const leanResults = freshResults.map((r) => ({
          chunk_id: r.chunk_id, doc_id: r.doc_id, text: r.text,
          title: r.title, organization: r.organization, year: r.year,
          page_num: r.page_num, headings: r.headings, score: r.score,
        }));
        const summaryResp = await axios.post<{ summary: string; prompt: string }>(`${API_BASE_URL}/ai-summary?data_source=${dataSource}`, {
          query: summaryQuery,
          results: leanResults,
          max_results: 20,
          ...(summaryModelConfig ? { summary_model_config: summaryModelConfig } : {}),
        });

        updateNodeDataInTree(nodeId, {
          summary: summaryResp.data.summary,
          prompt: summaryResp.data.prompt,
          results: freshResults,
        });
      } catch (error) {
        console.error(`Find out more failed for: ${fact}`, error);
        updateNodeDataInTree(nodeId, { summary: 'Failed to generate summary.' });
      }
    }
    setFindOutMoreLoading(false);
    setFindOutMoreActiveFact(null);
    setFindOutMoreDone(true);
    window.scrollTo({ top: 0, behavior: 'smooth' });
  }, [getSnapshot, addChildNodeInTree, updateNodeDataInTree, query, drilldownHighlight, summaryModelConfig,
      filters, searchDenseWeight, rerankEnabled, recencyBoostEnabled,
      recencyWeight, recencyScaleDays, sectionTypes, keywordBoostShortQueries,
      minChunkSize, rerankModel, rerankModelPageSize, searchModel, dataSource,
      autoMinScore, deduplicateEnabled, fieldBoostEnabled, fieldBoostFields]);

  // Add a custom node to the tree: create stub, search, summarize, update
  const handleAddNodeToTree = useCallback(async (parentId: string, userQuery: string) => {
    setAddingNodeParentId(null);

    // Create stub child node
    const nodeId = addChildToNodeInTree(parentId, userQuery);
    updateNodeDataInTree(nodeId, { summary: 'Researching...' });

    // Build contextual query with parent label for inheritance
    const parentNode = getNodeInTree(parentId);
    const parentLabel = parentNode?.label;
    const parentContext = parentLabel && parentLabel !== query
      ? `, specifically "${parentLabel}"`
      : '';
    const summaryQuery = `Regarding: "${userQuery}"\n\nProvide detail about this, in the context of: "${query}"${parentContext}`;
    const contextualSearchQuery = buildContextualSearchQuery(
      userQuery,
      query,
      parentLabel,
    );

    try {
      const params = buildSearchParams({
        query: contextualSearchQuery, filters, searchDenseWeight, rerankEnabled,
        recencyBoostEnabled, recencyWeight, recencyScaleDays, sectionTypes,
        keywordBoostShortQueries, minChunkSize, rerankModel, rerankModelPageSize,
        searchModel, dataSource, autoMinScore, deduplicateEnabled,
        fieldBoostEnabled, fieldBoostFields,
      });
      const searchResp = await axios.get<SearchResponse>(`${API_BASE_URL}/search?${params}`);
      const freshResults = searchResp.data.results.slice(0, 20);

      const leanResults = freshResults.map((r) => ({
        chunk_id: r.chunk_id, doc_id: r.doc_id, text: r.text,
        title: r.title, organization: r.organization, year: r.year,
        page_num: r.page_num, headings: r.headings, score: r.score,
      }));
      const summaryResp = await axios.post<{ summary: string; prompt: string }>(
        `${API_BASE_URL}/ai-summary?data_source=${dataSource}`,
        {
          query: summaryQuery,
          results: leanResults,
          max_results: 20,
          ...(summaryModelConfig ? { summary_model_config: summaryModelConfig } : {}),
        }
      );

      updateNodeDataInTree(nodeId, {
        summary: summaryResp.data.summary,
        prompt: summaryResp.data.prompt,
        results: freshResults,
      });
    } catch (error) {
      console.error('Add node to tree failed:', error);
      updateNodeDataInTree(nodeId, { summary: 'Failed to generate summary.' });
    }
  }, [addChildToNodeInTree, getNodeInTree, updateNodeDataInTree, query, summaryModelConfig,
      filters, searchDenseWeight, rerankEnabled, recencyBoostEnabled,
      recencyWeight, recencyScaleDays, sectionTypes, keywordBoostShortQueries,
      minChunkSize, rerankModel, rerankModelPageSize, searchModel, dataSource,
      autoMinScore, deduplicateEnabled, fieldBoostEnabled, fieldBoostFields]);

  // Remove a node from the tree
  const handleRemoveNodeFromTree = useCallback((nodeId: string) => {
    removeNodeInTree(nodeId);
  }, [removeNodeInTree]);

  // Show add-node input for a specific parent
  const handleAddNodeClick = useCallback((parentId: string) => {
    setAddingNodeParentId(parentId);
  }, []);

  // Cancel adding a node
  const handleAddNodeCancel = useCallback(() => {
    setAddingNodeParentId(null);
  }, []);

  // Save research tree to backend
  const handleSaveResearch = useCallback(async (title: string) => {
    if (!drilldownTree || !authState.user) return;
    setSaveResearchLoading(true);
    setSaveResearchStatus('idle');

    try {
      const currentId = currentNodeId || drilldownTree.id;
      const patchedTree = patchNodeInTree(drilldownTree, currentId, aiSummary, aiSummaryResults);
      const fullTree = serializeFullDrilldownTree(patchedTree);

      if (savedResearchId) {
        await axios.put(`${API_BASE_URL}/research/${savedResearchId}`, {
          title,
          drilldown_tree: fullTree,
          filters,
        });
      } else {
        const resp = await axios.post<{ id: string }>(`${API_BASE_URL}/research/`, {
          title,
          query,
          filters,
          data_source: dataSource,
          drilldown_tree: fullTree,
        });
        setSavedResearchId(resp.data.id);
      }
      setSaveResearchStatus('saved');
      setTimeout(() => setSaveResearchStatus('idle'), 3000);
    } catch (error) {
      console.error('Failed to save research:', error);
      setSaveResearchStatus('error');
      setTimeout(() => setSaveResearchStatus('idle'), 4000);
    } finally {
      setSaveResearchLoading(false);
    }
  }, [drilldownTree, currentNodeId, savedResearchId, query, filters, dataSource,
      authState.user, aiSummary, aiSummaryResults]);

  // Load research tree from backend
  const handleLoadResearch = useCallback(async (researchId: string) => {
    try {
      interface SavedResearchData {
        id: string;
        query: string;
        filters: SearchFilters | null;
        data_source: string | null;
        drilldown_tree: DrilldownNode;
      }
      const resp = await axios.get<SavedResearchData>(`${API_BASE_URL}/research/${researchId}`);
      const data = resp.data;

      setQuery(data.query);
      if (data.filters) setFilters(data.filters);

      const tree = data.drilldown_tree;
      loadDrilldownTree(tree);
      restoreFromNode(tree);
      setSavedResearchId(data.id);
      setFindOutMoreDone(true);
      setAiSummaryExpanded(true);
      handleTabChange('search');
    } catch (error) {
      console.error('Failed to load research:', error);
    }
  }, [loadDrilldownTree, restoreFromNode, handleTabChange]);

  // Update root node when global summary is generated and display it
  const handleGlobalSummaryGenerated = useCallback((summary: string, globalResults: SearchResult[]) => {
    updateNodeDataInTree('root', { summary, results: globalResults });
    // Also update displayed state so the summary view renders it immediately
    setAiSummary(summary);
    setAiSummaryResults(globalResults);
  }, [updateNodeDataInTree]);

  const closeLoadResearchModal = useCallback(() => setShowLoadResearchModal(false), []);

  const handleLoadResearchFromModal = useCallback((id: string) => {
    setShowLoadResearchModal(false);
    handleLoadResearch(id);
  }, [handleLoadResearch]);

  const handlePostSearchResults = useCallback((data: SearchResponse) => {
    if (data.results.length > 0) {
      const calculatedMaxScore = Math.max(...data.results.map(r => r.score || 0));
      const roundedMaxScore = Math.ceil(calculatedMaxScore * 2) / 2;
      setMaxScore(roundedMaxScore);

      console.log('SEARCH_SEMANTIC_HIGHLIGHTS flag:', SEARCH_SEMANTIC_HIGHLIGHTS, 'User Setting:', semanticHighlighting);
      if (SEARCH_SEMANTIC_HIGHLIGHTS && semanticHighlighting) {
        const maxResults = Math.min(10, data.results.length);
        console.log('Starting explicit semantic highlighting for top', maxResults);

        const processResult = async (idx: number) => {
          if (idx >= maxResults) return;
          const res = data.results[idx];
          await handleRequestHighlight(res.chunk_id, res.text);
          processResult(idx + 1);
        };

        processResult(0);
      }
    } else {
      setMaxScore(3.0);
    }
  }, [semanticHighlighting, handleRequestHighlight]);

  // Auto min_score is now handled server-side - no client calculation needed

  const handleSearchError = useCallback((error: any) => {
    console.error('Error searching:', error);
    setSearchError(buildSearchErrorMessage(error));
  }, []);

  const performSearch = useCallback(async () => {
    if (!dataSource || !query.trim() || isSearchingRef.current) {
      if (isSearchingRef.current) {
        console.warn("Search already in progress, skipping double call.");
      }
      return;
    }

    setLoading(true);
    setHasSearchRun(true);
    setSearchError(null);
    processingHighlightsRef.current.clear(); // Clear highlight locks
    isSearchingRef.current = true;
    setSearchId(generateUUID());

    try {
      const params = buildSearchParams({
        query,
        filters,
        searchDenseWeight,
        rerankEnabled,
        recencyBoostEnabled,
        recencyWeight,
        recencyScaleDays,
        sectionTypes,
        keywordBoostShortQueries,
        minChunkSize,
        rerankModel,
        rerankModelPageSize,
        searchModel,
        dataSource,
        autoMinScore,
        deduplicateEnabled,
        fieldBoostEnabled,
        fieldBoostFields,
      });

      const searchStartTime = performance.now();
      console.log(`[Perf] Starting search request at ${new Date().toISOString()}`);
      console.time('Total Search API Time');

      const response = await axios.get<SearchResponse>(`${API_BASE_URL}/search?${params}`);
      const data = response.data as SearchResponse;

      console.timeEnd('Total Search API Time');
      const searchEndTime = performance.now();
      console.log(`[Perf] Search API took ${(searchEndTime - searchStartTime).toFixed(2)}ms`);
      console.log(`[Perf] Results count: ${data.results.length}`);
      searchDurationMsRef.current = Math.round(searchEndTime - searchStartTime);
      setResults(data.results);
      // Initialize all headings as collapsed by default
      setCollapsedHeadings(new Set(data.results.map((_, index) => index)));

      // Log search activity (fire-and-forget, works for all users)
      if (USER_MODULE) {
        logSearch(searchId, query, filters, data.results, {
          timing: { search_duration_ms: searchDurationMsRef.current },
        });
        activitySearchIdRef.current = searchId;
      }

      // Reload facets to reflect search result distribution (with query)
      loadFacets({ includeQuery: true, queryValue: query });

      handleAiSummaryForResults(data);
      handlePostSearchResults(data);
    } catch (error: any) {
      handleSearchError(error);
    } finally {
      setLoading(false);
      isSearchingRef.current = false;
    }
  }, [
    query,
    filters,
    searchDenseWeight,
    rerankEnabled,
    recencyBoostEnabled,
    recencyWeight,
    recencyScaleDays,
    sectionTypes,
    dataSource,
    keywordBoostShortQueries,
    semanticHighlighting,
    handleRequestHighlight,
    handleAiSummaryForResults,
    handlePostSearchResults,
    handleSearchError,
    minChunkSize,
    rerankModel,
    searchModel,
    loadFacets,
    autoMinScore,
    deduplicateEnabled,
    fieldBoostEnabled,
    fieldBoostFields,
    logSearch,
    searchId,
  ]);

  // Track if we've done initial search to avoid double-searching on load
  const hasSearchedRef = React.useRef(false);
  // Track pending example query search (set query state, then search on next render)
  const pendingExampleSearchRef = React.useRef(false);

  // Search only triggers via:
  // 1. Form submit (handleSearch)
  // 2. Initial page load with URL query params (below)

  // Trigger initial search only when a URL query was present on initial load.
  useEffect(() => {
    const modelsReady = !modelCombosLoading && (
      !availableModelCombos.length
      || (searchModel && summaryModelConfig && semanticHighlightModelConfig)
    );
    if (!initialSearchDone && initialQueryFromUrlRef.current && query.trim() && modelsReady) {
      setInitialSearchDone(true);
      hasSearchedRef.current = true;
      performSearch();
    }
  }, [
    availableModelCombos.length,
    initialSearchDone,
    modelCombosLoading,
    performSearch,
    query,
    searchModel,
  ]);

  // Trigger search after example query click (query state must update first)
  useEffect(() => {
    if (pendingExampleSearchRef.current && query.trim()) {
      pendingExampleSearchRef.current = false;
      performSearch();
      const searchParams = buildSearchURL(
        query, filters, searchDenseWeight, rerankEnabled,
        recencyBoostEnabled, recencyWeight, recencyScaleDays,
        sectionTypes, keywordBoostShortQueries, minChunkSize,
        semanticHighlighting, autoMinScore, deduplicateEnabled,
        searchModel, selectedModelCombo, selectedDomain,
        fieldBoostEnabled, fieldBoostFields
      );
      window.history.pushState(null, '', withBasePath(searchParams ? `/?${searchParams}` : '/'));
    }
  }, [query, performSearch]); // eslint-disable-line react-hooks/exhaustive-deps

  // Activity logging: update summary when AI summary stream finishes
  // Track the searchId that was used for the activity POST, so the PATCH
  // uses the same value (searchId state changes after setSearchId in performSearch).
  const activitySearchIdRef = useRef<string>('');
  const searchDurationMsRef = useRef<number>(0);
  const summaryStartMsRef = useRef<number>(0);
  const prevAiSummaryLoadingRef = useRef(false);
  // Captured token usage from the AI summary SSE done event so the
  // trailing PATCH /activity/{id}/summary can persist it.
  const aiSummaryUsageRef = useRef<AiSummaryUsage | undefined>(undefined);
  useEffect(() => {
    // Detect transition from loading → done and log the completed summary
    if (
      prevAiSummaryLoadingRef.current &&
      !aiSummaryLoading &&
      USER_MODULE &&
      aiSummary &&
      aiSummary !== AI_SUMMARY_ERROR &&
      !isDrilldown &&
      activitySearchIdRef.current
    ) {
      const summaryDurationMs = summaryStartMsRef.current
        ? Math.round(performance.now() - summaryStartMsRef.current)
        : undefined;
      console.debug('[Activity] Summary PATCH: summaryDurationMs=' + summaryDurationMs);
      updateActivitySummary(
        activitySearchIdRef.current,
        aiSummary,
        summaryDurationMs,
        drilldownTree ? serializeDrilldownTree(drilldownTree) : undefined,
        aiSummaryUsageRef.current,
      );
      aiSummaryUsageRef.current = undefined;
    }
    prevAiSummaryLoadingRef.current = aiSummaryLoading;
  }, [aiSummaryLoading, aiSummary, isDrilldown, updateActivitySummary, drilldownTree]);

  // Send drilldown tree updates to the activity record when the user
  // navigates the AI Summary Tree (i.e. when children are added).
  const prevDrilldownChildCountRef = useRef(0);
  useEffect(() => {
    const childCount = drilldownTree?.children?.length ?? 0;
    if (
      childCount > prevDrilldownChildCountRef.current &&
      activitySearchIdRef.current &&
      USER_MODULE &&
      drilldownTree
    ) {
      updateActivitySummary(
        activitySearchIdRef.current,
        undefined, // don't update ai_summary
        undefined, // no timing update
        serializeDrilldownTree(drilldownTree),
      );
    }
    prevDrilldownChildCountRef.current = childCount;
  }, [drilldownTree, updateActivitySummary]);

  // Handler for toggling auto min score mode
  const handleAutoMinScoreToggle = useCallback((enabled: boolean) => {
    setAutoMinScore(enabled);
    if (enabled) {
      // Reset to 0 when enabling auto mode - will be calculated after search
      setMinScore(0);
    }
  }, []);

  // Handler for manual min score changes - disables auto mode
  const handleMinScoreChange = useCallback((value: number) => {
    setMinScore(value);
    // Disable auto mode when user manually adjusts the slider
    if (autoMinScore) {
      setAutoMinScore(false);
    }
  }, [autoMinScore]);

  const handleSearch = async (e: React.FormEvent) => {
    e.preventDefault();
    // When form is submitted (Enter key), search immediately
    hasSearchedRef.current = true;
    performSearch();
    // Update URL with pushState for browser history
    if (query.trim()) {
      const searchParams = buildSearchURL(
        query,
        filters,
        searchDenseWeight,
        rerankEnabled,
        recencyBoostEnabled,
        recencyWeight,
        recencyScaleDays,
        sectionTypes,
        keywordBoostShortQueries,
        minChunkSize,
        semanticHighlighting,
        autoMinScore,
        deduplicateEnabled,
        searchModel,
        selectedModelCombo,
        selectedDomain,
        fieldBoostEnabled,
        fieldBoostFields
      );
      const newURL = withBasePath(searchParams ? `/?${searchParams}` : '/');
      window.history.pushState(null, '', newURL);
    }
  };

  const handleShowFilters = useCallback(() => {
    hasSearchedRef.current = true;
    setFiltersExpanded(true);
    setInitialSearchDone(true);
  }, []);

  const handleExampleQueryClick = useCallback((q: string) => {
    setQuery(q);
    hasSearchedRef.current = true;
    pendingExampleSearchRef.current = true;
  }, []);

  const handleClearFilters = () => {
    setFilters({});
    // Clear all selected filters dynamically
    setSelectedFilters(buildEmptySelectedFilters());
    setMinScore(0.0);
    // Update URL to remove filter params (keep search mode settings)
    if (query.trim()) {
      const searchParams = buildSearchURL(
        query,
        {},
        searchDenseWeight,
        rerankEnabled,
        recencyBoostEnabled,
        recencyWeight,
        recencyScaleDays,
        sectionTypes,
        keywordBoostShortQueries,
        minChunkSize,
        semanticHighlighting,
        autoMinScore,
        deduplicateEnabled,
        searchModel,
        selectedModelCombo,
        selectedDomain,
        fieldBoostEnabled,
        fieldBoostFields
      );
      const newURL = withBasePath(searchParams ? `/?${searchParams}` : '/');
      window.history.replaceState(null, '', newURL);
    }
  };

  const handleClearHeatmapFilters = () => {
    setHeatmapFilters({});
    setHeatmapSelectedFilters(buildEmptySelectedFilters());
  };

  const handleResultClick = (result: SearchResult) => {
    setSelectedDoc(result);
  };

  const handleClosePreview = () => {
    setSelectedDoc(null);
  };

  const toggleCardExpansion = (chunkId: string, e: React.MouseEvent) => {
    e.stopPropagation(); // Prevent card click from firing
    setExpandedCards(prev => {
      const newSet = new Set(prev);
      if (newSet.has(chunkId)) {
        newSet.delete(chunkId);
      } else {
        newSet.add(chunkId);
      }
      return newSet;
    });
  };

  const buildSearchMetadataDoc = useCallback((result: SearchResult) => {
    const metadata = result.metadata || {};
    return {
      doc_id: metadata.doc_id ?? result.doc_id,
      chunk_id: result.chunk_id,
      ...metadata,
      organization: metadata.organization ?? result.organization,
      published_year: metadata.published_year ?? result.year,
      title: metadata.title ?? result.title,
    };
  }, []);

  const handleOpenMetadata = useCallback((metadataDoc: Record<string, any>) => {
    setMetadataModalDoc(metadataDoc);
    setMetadataModalOpen(true);
  }, []);

  const handleOpenSearchMetadata = useCallback(
    (result: SearchResult) => {
      handleOpenMetadata(buildSearchMetadataDoc(result));
    },
    [buildSearchMetadataDoc, handleOpenMetadata]
  );

  const handleCloseMetadataModal = () => {
    setMetadataModalOpen(false);
  };

  const handleOpenSummaryFromMetadata = useCallback((summary: string, title: string, docId?: string) => {
    setSelectedSummary(summary);
    setSelectedSummaryTitle(title);
    setSelectedSummaryDocId(docId || '');
    setSummaryModalOpen(true);
  }, []);

  const handleOpenTocFromMetadata = useCallback((doc: any) => {
    const tocValue = doc.toc_classified || doc.sys_toc_classified || '';
    const docId = doc.doc_id || doc.id || '';
    const pdfUrl = doc.pdf_url || doc.map_pdf_url || '';
    const pageCount = doc.page_count ?? doc.sys_page_count ?? null;
    setSelectedTocDocId(docId);
    setSelectedToc(tocValue);
    setSelectedTocPdfUrl(pdfUrl);
    setSelectedTocPageCount(pageCount);
    if (!pdfUrl && docId) {
      fetchTocPdfUrl(docId);
    }
    setTocModalOpen(true);
  }, [fetchTocPdfUrl]);

  const handleResultLanguageChange = async (result: SearchResult, newLang: string) => {
    const originalLanguage = result.language || result.metadata?.language || 'en';
    if (newLang === originalLanguage) {
      resetTranslationState(setResults, result.chunk_id);
      return;
    }

    if (result.translated_language === newLang) {
      return;
    }

    setTranslationInProgress(setResults, result.chunk_id, newLang);

    try {
      const textToTranslate = buildChunkTextForTranslation(result);
      const [translatedTitle, translatedText, translatedQuery, translatedHeadings] =
        await Promise.all([
          translateViaApi(result.title, newLang, originalLanguage),
          translateViaApi(textToTranslate, newLang, originalLanguage),
          query.trim() ? translateViaApi(query, newLang) : Promise.resolve(null),
          translateHeadings(result.headings ?? [], newLang, originalLanguage)
        ]);

      const translatedSemanticMatches = await computeTranslatedSemanticMatches({
        translatedText,
        translatedQuery,
        originalText: result.text,
        originalQuery: query,
        semanticHighlightModelConfig,
      });

      applyTranslationResult(
        setResults,
        result,
        newLang,
        translatedTitle,
        translatedText,
        translatedHeadings,
        translatedSemanticMatches
      );
    } catch (error) {
      console.error("Translation error", error);
      applyTranslationError(setResults, result.chunk_id);
    }
  };

  const handleAiSummaryLanguageChange = async (newLang: string) => {
    if (newLang === 'en') {
      setAiSummaryTranslatedText(null);
      setAiSummaryTranslatingLang(null);
      setAiSummaryTranslatedLang(null);
      return;
    }
    if (aiSummaryTranslatedLang === newLang) {
      return;
    }
    setAiSummaryTranslatingLang(newLang);
    try {
      const translated = await translateViaApi(aiSummary, newLang, 'en');
      setAiSummaryTranslatedText(translated);
      setAiSummaryTranslatedLang(newLang);
    } catch (error) {
      console.error('AI summary translation error', error);
    }
    setAiSummaryTranslatingLang(null);
  };

  // Fetch TOC data for a document
  const fetchTocData = async (docId: string) => {
    setLoadingToc(true);
    try {
      const response = await axios.get(`${API_BASE_URL}/document/${docId}?data_source=${dataSource}`);
      const doc = response.data as {
        toc_classified?: string;
        toc?: string;
        page_count?: number;
        sys_page_count?: number;
      };
      // Use toc_classified if available, otherwise fall back to toc
      const toc = doc.toc_classified || doc.toc || '';
      setSelectedToc(toc);
      setSelectedTocPageCount(doc.page_count ?? doc.sys_page_count ?? null);
    } catch (error) {
      console.error('Error fetching TOC:', error);
      setSelectedToc('');
    } finally {
      setLoadingToc(false);
    }
  };

  const handleOpenToc = useCallback(
    (docId: string, toc: string, pdfUrl?: string, pageCount?: number | null) => {
      setSelectedTocDocId(docId);
      setSelectedToc(toc);
      setSelectedTocPdfUrl(pdfUrl || '');
      setSelectedTocPageCount(pageCount ?? null);
      if (!pdfUrl) {
        fetchTocPdfUrl(docId);
      }
      setTocModalOpen(true);
    },
    [fetchTocPdfUrl]
  );

  const handleTocUpdated = (newToc: string) => {
    setSelectedToc(newToc);
  };

  // renderMetadata removed - now in SearchResultCard component


  // Track whether user has performed a search (to keep layout in results mode permanently)
  // Once the user has searched, the layout stays in results mode even when clearing the search box
  const hasSearched = results.length > 0 || initialSearchDone || hasSearchedRef.current;

  const dataSourceLoading = Boolean(
    loadingConfig && initialSearchState.dataset && !datasourcesConfig[selectedDomain]
  );

  const dataSourceLoadingContent = (
    <div className="main-content">
      <div style={{ padding: '2rem', textAlign: 'center' }}></div>
    </div>
  );

  const pipelineLoadingContent = (
    <div className="main-content">
      <div style={{ padding: '2rem', textAlign: 'center' }}>Loading processing stats ...</div>
    </div>
  );

  const documentsTab = dataSourceLoading ? (
    dataSourceLoadingContent
  ) : (
    <Documents
      key={`documents-${dataSource}`}
      dataSource={dataSource}
      semanticHighlightModelConfig={semanticHighlightModelConfig}
      dataSourceConfig={currentDataSourceConfig}
    />
  );

  const pipelineTab = dataSourceLoading ? (
    pipelineLoadingContent
  ) : (
    <Pipeline key={`pipeline-${dataSource}`} dataSource={dataSource} />
  );

  const processingTab = dataSourceLoading ? (
    pipelineLoadingContent
  ) : (
    <Processing key={`processing-${dataSource}`} dataSource={dataSource} />
  );

  const handleNavigateToDocuments = (filter: { category: string; value: string }) => {
    // Switch to documents tab
    handleTabChange('documents');

    // Update URL with filter
    const url = new URL(window.location.href);
    url.searchParams.set('tab', 'documents');
    url.searchParams.set(filter.category, filter.value);

    // We change the history. State update in Documents component (via useDocumentsState)
    // will pick this up if it initializes from URL or listens to changes.
    // Since we are unmounting Stats and mounting Documents, Documents will read initialization from URL.
    // However, if we are already "alive" (unlikely given conditional rendering in TabContent), we might need to force update.
    // Pushing state:
    window.history.pushState({}, '', url.toString());

    // Note: useDocumentsState uses useSyncDocumentsUrlParams which updates URL on state change.
    // It also initializes from URL on mount.
    // So ensuring URL is correct before Documents mounts is key.
  };

  const statsTab = dataSourceLoading ? (
    dataSourceLoadingContent
  ) : (
    <Stats
      key={`stats-${dataSource}`}
      dataSource={dataSource}
      onNavigateToDocuments={handleNavigateToDocuments}
    />
  );

  const activeFiltersCount = Object.values(filters).filter(Boolean).length;
  const heatmapActiveFiltersCount = Object.values(heatmapFilters).filter(Boolean).length;

  // When search results are displayed, compute facet counts directly from
  // the actual results (deduped by doc_id) so counts exactly match what the
  // user sees.  All DB values from allFacets are preserved; values absent
  // from the results get count 0 (the UI hides the number for those).
  // Sidebar facet display:
  //   - List of values comes from the unfiltered all-DB facets (so every
  //     possible filter value is visible, even ones that don't appear in
  //     the current results).
  //   - Counts come from the backend's query-narrowed /facets response,
  //     which is computed against ~500 ranked chunks. That's a bigger
  //     lens than the ~50 the user can see on screen — values like a
  //     French translation of a primarily-English topic still get a
  //     real count, instead of dropping to 0 only to surface as 15
  //     after the user clicks the filter.
  //   - Falls back to the all-DB list when no query is active or the
  //     query-narrowed response hasn't loaded yet.
  const displayFacets = React.useMemo(() => {
    const all = allFacetsDataSource === dataSource ? allFacets : null;
    if (!all) return facets;
    if (!hasSearchRun || !query?.trim()) return all;
    const queryNarrowed = facetsDataSource === dataSource ? facets : null;
    if (!queryNarrowed) return all;

    const mergedFacetEntries: Record<string, FacetValue[]> = {};
    for (const [field, allValues] of Object.entries(all.facets)) {
      const counts = new Map<string, number>();
      for (const fv of queryNarrowed.facets?.[field] || []) {
        counts.set(fv.value, fv.count);
      }
      mergedFacetEntries[field] = mergeFacetField(allValues, counts);
    }
    return { ...all, facets: mergedFacetEntries } as Facets;
  }, [allFacets, allFacetsDataSource, facets, facetsDataSource, dataSource, hasSearchRun, query]);

  const requestHighlightHandler = resolveRequestHighlightHandler(
    SEARCH_SEMANTIC_HIGHLIGHTS,
    semanticHighlighting,
    handleRequestHighlight
  );

  const searchTab = (
    <SearchTabContent
      filtersExpanded={filtersExpanded}
      activeFiltersCount={activeFiltersCount}
      onToggleFiltersExpanded={toggleFiltersExpanded}
      onClearFilters={handleClearFilters}
      facets={displayFacets}
      selectedFilters={selectedFilters}
      rangeFilters={rangeFilters}
      collapsedFilters={collapsedFilters}
      expandedFilterLists={expandedFilterLists}
      filterSearchTerms={filterSearchTerms}
      titleSearchResults={titleSearchResults}
      facetSearchResults={facetSearchResults}
      onRemoveFilter={handleRemoveFilter}
      onToggleFilter={toggleFilter}
      onFilterSearchTermChange={handleFilterSearchTermChange}
      onToggleFilterListExpansion={toggleFilterListExpansion}
      onFilterValuesChange={handleFilterValuesChange}
      onRangeChange={handleRangeChange}
      searchDenseWeight={searchDenseWeight}
      onSearchDenseWeightChange={setSearchDenseWeight}
      keywordBoostShortQueries={keywordBoostShortQueries}
      onKeywordBoostChange={setKeywordBoostShortQueries}
      semanticHighlighting={semanticHighlighting}
      onSemanticHighlightingChange={setSemanticHighlighting}
      minScore={minScore}
      maxScore={maxScore}
      onMinScoreChange={handleMinScoreChange}
      autoMinScore={autoMinScore}
      onAutoMinScoreToggle={handleAutoMinScoreToggle}
      rerankEnabled={rerankEnabled}
      onRerankToggle={setRerankEnabled}
      recencyBoostEnabled={recencyBoostEnabled}
      onRecencyBoostToggle={setRecencyBoostEnabled}
      recencyWeight={recencyWeight}
      onRecencyWeightChange={setRecencyWeight}
      recencyScaleDays={recencyScaleDays}
      onRecencyScaleDaysChange={setRecencyScaleDays}
      minChunkSize={minChunkSize}
      onMinChunkSizeChange={setMinChunkSize}
      sectionTypes={sectionTypes}
      onSectionTypesChange={setSectionTypes}
      deduplicateEnabled={deduplicateEnabled}
      onDeduplicateToggle={setDeduplicateEnabled}
      fieldBoostEnabled={fieldBoostEnabled}
      onFieldBoostToggle={setFieldBoostEnabled}
      fieldBoostFields={fieldBoostFields}
      onFieldBoostFieldsChange={setFieldBoostFields}
      aiSummaryEnabled={AI_SUMMARY_ON}
      aiSummaryCollapsed={aiSummaryCollapsed}
      aiSummaryExpanded={aiSummaryExpanded}
      aiSummaryLoading={aiSummaryLoading}
      aiSummary={aiSummary}
      aiSummaryResults={aiSummaryResults}
      aiPrompt={aiPrompt}
      showPromptModal={showPromptModal}
      selectedDomain={selectedDomain}
      results={results}
      searchId={searchId}
      onRegenerateAiSummary={startAiSummaryStream}
      loading={loading}
      query={query}
      selectedDoc={selectedDoc}
      onResultClick={handleResultClick}
      onOpenPrompt={() => setShowPromptModal(true)}
      onClosePrompt={() => setShowPromptModal(false)}
      onToggleCollapsed={() => setAiSummaryCollapsed(!aiSummaryCollapsed)}
      onToggleExpanded={() => setAiSummaryExpanded(!aiSummaryExpanded)}
      onOpenMetadata={handleOpenSearchMetadata}
      onLanguageChange={handleResultLanguageChange}
      onRequestHighlight={requestHighlightHandler}
      aiSummaryTranslatedText={aiSummaryTranslatedText}
      aiSummaryTranslatingLang={aiSummaryTranslatingLang}
      aiSummaryTranslatedLang={aiSummaryTranslatedLang}
      onAiSummaryLanguageChange={handleAiSummaryLanguageChange}
      aiDrilldownStackDepth={isDrilldown ? 1 : 0}
      aiDrilldownHighlight={drilldownHighlight}
      onAiDrilldown={startDrilldown}
      onAiDrilldownBack={navigateBackDrilldown}
      aiDrilldownTree={drilldownTree}
      aiDrilldownCurrentNodeId={currentNodeId}
      onAiDrilldownNavigate={navigateDrilldownToNode}
      onFindOutMore={handleFindOutMore}
      findOutMoreLoading={findOutMoreLoading}
      findOutMoreActiveFact={findOutMoreActiveFact}
      requestShowGraph={findOutMoreDone}
      dataSource={dataSource}
      summaryModelConfig={summaryModelConfig}
      hasSearchRun={hasSearchRun}
      onSaveResearch={handleSaveResearch}
      saveResearchLoading={saveResearchLoading}
      saveResearchStatus={saveResearchStatus}
      onLoadPreviousResearch={() => setShowLoadResearchModal(true)}
      onGlobalSummaryGenerated={handleGlobalSummaryGenerated}
      onAddNodeToTree={handleAddNodeToTree}
      onRemoveNodeFromTree={handleRemoveNodeFromTree}
      addingNodeParentId={addingNodeParentId}
      onAddNodeClick={handleAddNodeClick}
      onAddNodeCancel={handleAddNodeCancel}
    />
  );

  const heatmapTab = (
    <HeatmapTabContent
      selectedDomain={selectedDomain}
      loadingConfig={loadingConfig}
      facetsDataSource={facetsDataSource}
      filtersExpanded={heatmapFiltersExpanded}
      activeFiltersCount={heatmapActiveFiltersCount}
      onToggleFiltersExpanded={toggleHeatmapFiltersExpanded}
      onClearFilters={handleClearHeatmapFilters}
      facets={facets}
      filters={heatmapFilters}
      selectedFilters={heatmapSelectedFilters}
      collapsedFilters={heatmapCollapsedFilters}
      expandedFilterLists={heatmapExpandedFilterLists}
      filterSearchTerms={filterSearchTerms}
      titleSearchResults={titleSearchResults}
      facetSearchResults={facetSearchResults}
      onRemoveFilter={handleHeatmapRemoveFilter}
      onToggleFilter={toggleHeatmapFilter}
      onFilterSearchTermChange={handleFilterSearchTermChange}
      onToggleFilterListExpansion={toggleHeatmapFilterListExpansion}
      onFilterValuesChange={handleHeatmapFilterValuesChange}
      searchModel={searchModel}
      searchDenseWeight={searchDenseWeight}
      onSearchDenseWeightChange={setSearchDenseWeight}
      keywordBoostShortQueries={keywordBoostShortQueries}
      onKeywordBoostChange={setKeywordBoostShortQueries}
      semanticHighlighting={semanticHighlighting}
      onSemanticHighlightingChange={setSemanticHighlighting}
      minScore={minScore}
      maxScore={maxScore}
      onMinScoreChange={handleMinScoreChange}
      autoMinScore={autoMinScore}
      onAutoMinScoreToggle={handleAutoMinScoreToggle}
      rerankEnabled={rerankEnabled}
      onRerankToggle={setRerankEnabled}
      recencyBoostEnabled={recencyBoostEnabled}
      onRecencyBoostToggle={setRecencyBoostEnabled}
      recencyWeight={recencyWeight}
      onRecencyWeightChange={setRecencyWeight}
      recencyScaleDays={recencyScaleDays}
      onRecencyScaleDaysChange={setRecencyScaleDays}
      minChunkSize={minChunkSize}
      onMinChunkSizeChange={setMinChunkSize}
      sectionTypes={sectionTypes}
      onSectionTypesChange={setSectionTypes}
      deduplicateEnabled={deduplicateEnabled}
      onDeduplicateToggle={setDeduplicateEnabled}
      fieldBoostEnabled={fieldBoostEnabled}
      onFieldBoostToggle={setFieldBoostEnabled}
      fieldBoostFields={fieldBoostFields}
      onFieldBoostFieldsChange={setFieldBoostFields}
      rerankModel={rerankModel}
      rerankModelPageSize={rerankModelPageSize}
      semanticHighlightModelConfig={semanticHighlightModelConfig}
      selectedModelCombo={resolvedModelCombo}
      dataSource={dataSource}
      selectedDoc={selectedDoc}
      onResultClick={handleResultClick}
      onOpenMetadata={handleOpenSearchMetadata}
      onLanguageChange={handleResultLanguageChange}
      onRequestHighlight={requestHighlightHandler}
    />
  );

  const appContent = (
    <div className="app" data-active-tab={activeTab}>
      <TopBar
        selectedDomain={selectedDomain}
        availableDomains={availableDomains}
        datasetTotals={datasetTotals}
        selectedModelCombo={resolvedModelCombo}
        availableModelCombos={availableModelCombos}
        modelCombos={modelCombos}
        domainDropdownOpen={domainDropdownOpen}
        modelDropdownOpen={modelDropdownOpen}
        helpDropdownOpen={helpDropdownOpen}
        showDomainTooltip={showDomainTooltip}
        onToggleDomainDropdown={handleToggleDomainDropdown}
        onToggleModelDropdown={handleToggleModelDropdown}
        onDomainMouseEnter={handleDomainMouseEnter}
        onDomainMouseLeave={handleDomainMouseLeave}
        onDomainBlur={handleDomainBlur}
        onModelBlur={handleModelBlur}
        onSelectDomain={handleSelectDomain}
        onSelectModelCombo={handleSelectModelCombo}
        onToggleHelpDropdown={handleToggleHelpDropdown}
        onHelpBlur={handleHelpBlur}
        onAboutClick={handleAboutClick}
        onTechClick={handleTechClick}
        onDataClick={handleDataClick}
        onDocsClick={handleDocsClick}
        onAdminClick={() => handleTabChange('admin')}
        onLoadResearch={handleLoadResearch}
        navTabs={<NavTabs activeTab={activeTab} onTabChange={handleTabChange} />}
      />

      <SearchBox
        isActive={activeTab === 'search'}
        hasSearched={hasSearched}
        query={query}
        loading={loading}
        searchError={searchError}
        onQueryChange={setQuery}
        onSubmit={handleSearch}
        onShowFilters={handleShowFilters}
        datasetName={selectedDomain}
        documentCount={datasetTotals[selectedDomain]}
        exampleQueries={currentDataSourceConfig?.example_queries}
        onExampleQueryClick={handleExampleQueryClick}
        greetingMessage={greetingMessage}
      />

      <TabContent
        activeTab={activeTab}
        hasSearched={hasSearched}
        searchTab={searchTab}
        assistantTab={
          <AssistantTab
            dataSource={dataSource}
            assistantModelConfig={assistantModelConfig}
            rerankerModel={rerankModel}
            searchSettings={{
              denseWeight: searchDenseWeight,
              recencyBoost: recencyBoostEnabled,
              recencyWeight,
              recencyScaleDays,
              sectionTypes,
              keywordBoostShortQueries,
              minChunkSize,
              fieldBoost: fieldBoostEnabled,
              fieldBoostFields,
            }}
            exampleQueries={currentDataSourceConfig?.example_queries}
            onResultClick={handleResultClick}
          />
        }
        heatmapTab={heatmapTab}
        documentsTab={documentsTab}
        statsTab={statsTab}
        pipelineTab={pipelineTab}
        processingTab={processingTab}
        aboutContent={aboutContent}
        techContent={techContent}
        dataContent={dataContent}
        privacyContent={privacyContent}
        termsContent={termsContent}
        basePath={APP_BASE_PATH}
        docsInitialPath={docsInitialPath}
        onTabChange={handleTabChange}
      />

      <AdminPanel isActive={activeTab === 'admin'} />

      <footer className="app-footer">
        <button
          type="button"
          className="app-footer-link"
          onClick={handleAboutClick}
        >
          About
        </button>
        <span className="app-footer-divider">•</span>
        <button
          type="button"
          className="app-footer-link"
          onClick={handleDataClick}
        >
          Data & Attribution
        </button>
        <span className="app-footer-divider">•</span>
        <a
          href="/privacy"
          className="app-footer-link"
          onClick={(e) => { e.preventDefault(); handlePrivacyClick(); }}
        >
          Privacy
        </a>
        <span className="app-footer-divider">•</span>
        <a
          href="/terms"
          className="app-footer-link"
          onClick={(e) => { e.preventDefault(); handleTermsClick(); }}
        >
          Terms
        </a>
        <span className="app-footer-divider">•</span>
        <a href="https://github.com/dividor/evidencelab" target="_blank" rel="noreferrer">
          GitHub
        </a>
        <span className="app-footer-divider">•</span>
        <button type="button" className="app-footer-link" onClick={() => setContactModalOpen(true)}>
          Contact
        </button>
      </footer>

      {contactModalOpen && (
        <div className="preview-overlay" onClick={() => setContactModalOpen(false)}>
          <div className="modal-panel contact-modal" onClick={(e) => e.stopPropagation()}>
            <div className="modal-header">
              <h2>Contact</h2>
              <button onClick={() => setContactModalOpen(false)} className="modal-close">×</button>
            </div>
            <div className="modal-body">
              <p>
                If you would like to have your public documents added to Evidence Lab for research,
                or would like to contribute to the project, or have general feedback and questions,
                please reach out to <a href="mailto:evidencelab@astrobagel.com">evidencelab@astrobagel.com</a>.
              </p>
            </div>
          </div>
        </div>
      )}

      <PdfPreviewOverlay
        selectedDoc={selectedDoc}
        query={query}
        dataSource={dataSource}
        semanticHighlightModelConfig={semanticHighlightModelConfig}
        onClose={handleClosePreview}
        onOpenMetadata={handleOpenMetadata}
        searchDenseWeight={searchDenseWeight}
        rerankEnabled={rerankEnabled}
        recencyBoostEnabled={recencyBoostEnabled}
        recencyWeight={recencyWeight}
        recencyScaleDays={recencyScaleDays}
        sectionTypes={sectionTypes}
        keywordBoostShortQueries={keywordBoostShortQueries}
        minChunkSize={minChunkSize}
        minScore={minScore}
        rerankModel={rerankModel}
        searchModel={searchModel}
      />

      {/* MetadataModal rendered first so TocModal/SummaryModal stack above it */}
      <MetadataModal
        isOpen={metadataModalOpen}
        onClose={handleCloseMetadataModal}
        metadataDoc={metadataModalDoc}
        metadataPanelFields={metadataPanelFields}
        onOpenSummary={handleOpenSummaryFromMetadata}
        onOpenToc={handleOpenTocFromMetadata}
      />

      {/* TOC Modal — rendered after MetadataModal so it appears on top */}
      <TocModal
        isOpen={tocModalOpen}
        onClose={() => setTocModalOpen(false)}
        toc={selectedToc}
        docId={selectedTocDocId}
        dataSource={dataSource}
        loading={loadingToc}
        pdfUrl={selectedTocPdfUrl}
        onTocUpdated={handleTocUpdated}
        pageCount={selectedTocPageCount}
      />

      <SummaryModal
        isOpen={summaryModalOpen}
        onClose={() => setSummaryModalOpen(false)}
        summary={selectedSummary}
        title={selectedSummaryTitle}
        docId={selectedSummaryDocId}
      />

      <LoadResearchModalWrapper
        show={showLoadResearchModal}
        onClose={closeLoadResearchModal}
        onLoad={handleLoadResearchFromModal}
      />

      <CookieConsent />
      <FeedbackButton />
    </div >
  );

  return (
    <AuthContext.Provider value={authState}>
      <AuthGate>{appContent}</AuthGate>
    </AuthContext.Provider>
  );
}

export default App;
