// Copyright (c) 2025-2026, RTE (https://www.rte-france.com)
// This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0.
// If a copy of the Mozilla Public License, version 2.0 was not distributed with this file,
// you can obtain one at http://mozilla.org/MPL/2.0/.
// SPDX-License-Identifier: MPL-2.0
// This file is part of Co-Study4Grid a Power Grid Study tool Assistant Interface to help solve contigencies for a grid state under study. 

import React, { useState, useEffect, useRef, useMemo } from 'react';
import type { ActionDetail, NodeMeta, EdgeMeta, AvailableAction, AnalysisResult, CombinedAction, DiagramData, ActionOverviewFilters } from '../types';
import { actionPassesOverviewFilter } from '../utils/svgUtils';
import { DEFAULT_ACTION_OVERVIEW_FILTERS, matchesActionTypeFilter, rowPassesActionFilters } from '../utils/actionTypes';
import { api } from '../api';
import type { ModelDescriptor } from '../api';
import { interactionLogger } from '../utils/interactionLogger';
import { parseNdjsonStream } from '../utils/ndjsonStream';
import CombinedActionsModal from './CombinedActionsModal';
import ActionCard from './ActionCard';
import AdditionalLinesPicker from './AdditionalLinesPicker';
import ActionSearchDropdown from './ActionSearchDropdown';
import { colors } from '../styles/tokens';
import { apiErrorMessage } from '../utils/apiError';

/**
 * Per-stage analysis execution-time breakdown (seconds), grouped (D4)
 * instead of six flat props. All are echoed by the backend in the step-2
 * ``result`` event and rendered together as the one-line breakdown under
 * "Suggestions produced by …". ``overflowGraphTime`` is ``null`` when the
 * active model does not consume the overflow graph (its column is hidden
 * in that case). ``step1Time`` is the contingency simulation that runs
 * before step 2; ``enrichmentTime`` is the Co-Study4Grid post-processing
 * after assessment. ``wallClockTime`` is the frontend-measured round-trip
 * from the click to the result event. Optional as a whole.
 */
export interface AnalysisTimingControls {
    overflowGraphTime?: number | null;
    actionPredictionTime?: number | null;
    assessmentTime?: number | null;
    step1Time?: number | null;
    enrichmentTime?: number | null;
    wallClockTime?: number | null;
}

/**
 * Recommendation-model selector wiring, grouped (D4). The dropdown above
 * "Analyze & Suggest" (mirror of the same control in the Settings modal)
 * lets the operator pick a different model and re-run without opening
 * Settings; ``activeModelLabel`` is the display label of the model that
 * produced the currently-shown suggestions (echoed by the backend in the
 * step-2 ``result`` event as ``active_model``). Optional as a whole.
 */
export interface ModelSelectorControls {
    recommenderModel?: string;
    setRecommenderModel?: (v: string) => void;
    availableModels?: ModelDescriptor[];
    activeModelLabel?: string | null;
}

/**
 * "Additional lines to cut" picker wiring, grouped (D4): the catalogue of
 * disconnectable elements feeding the "additional lines to prevent flow
 * increase" picker rendered above the Analyze & Suggest button, the
 * operator-selected extras (ExpertAgent's ``additionalLinesToCut``), and
 * the N-1 detected overloads excluded from the picker suggestions.
 * Optional as a whole.
 */
export interface AdditionalLinesControls {
    branches?: string[];
    additionalLinesToCut?: Set<string>;
    onToggleAdditionalLineToCut?: (line: string) => void;
    n1Overloads?: string[];
}

interface ActionFeedProps {
    actions: Record<string, ActionDetail>;
    actionScores?: Record<string, Record<string, unknown>>;
    linesOverloaded: string[];
    selectedActionId: string | null;
    selectedActionIds: Set<string>;
    rejectedActionIds: Set<string>;
    pendingAnalysisResult: AnalysisResult | null;
    /**
     * When set, the feed scrolls to the action card matching the id.
     * The `seq` counter lets the same action trigger a re-scroll
     * (e.g. tapping the same pin twice).  Driven by pin single-click
     * on the action overview diagram.
     */
    scrollTarget?: { id: string; seq: number } | null;
    onDisplayPrioritizedActions: () => void;
    onRunAnalysis: () => void;
    /** Abort an in-flight analysis run. When provided, a Cancel control
     *  appears alongside the "Analyzing…" indicator (D5). */
    onCancelAnalysis?: () => void;
    canRunAnalysis: boolean;
    onActionSelect: (actionId: string | null) => void;
    onActionFavorite: (actionId: string) => void;
    onActionReject: (actionId: string) => void;
    onAssetClick: (actionId: string, assetName: string, tab?: 'action' | 'contingency') => void;
    nodesByEquipmentId: Map<string, NodeMeta> | null;
    edgesByEquipmentId: Map<string, EdgeMeta> | null;
    /** Currently APPLIED contingency (list of element IDs disconnected). */
    disconnectedElement: string[];
    onManualActionAdded: (actionId: string, detail: ActionDetail, linesOverloaded: string[]) => void;
    onActionResimulated: (actionId: string, detail: ActionDetail, linesOverloaded: string[]) => void;
    analysisLoading: boolean;
    monitoringFactor: number;
    manuallyAddedIds: Set<string>;
    onVlDoubleClick?: (actionId: string, vlName: string) => void;
    combinedActions: Record<string, CombinedAction> | null;
    onUpdateCombinedEstimation?: (pairId: string, estimation: { estimated_max_rho: number; estimated_max_rho_line: string }) => void;
    /** Resolve an element/VL ID to its human-readable display name. Falls back to the ID. */
    displayName?: (id: string) => string;
    /**
     * Optional pre-fetch hook. When provided, the Add-action / target-MW /
     * target-tap handlers stream both the simulation metrics AND the
     * post-action NAD in a single request, and invoke this callback with
     * the ready-to-render diagram. A subsequent click on the action card
     * reads this cache (see useDiagrams.handleActionSelect) and paints
     * instantly — saving ~5-7 s of pypowsybl NAD regeneration on large
     * grids. When the prop is absent, the legacy `simulateManualAction`
     * single-shot call is used, preserving backward compat for tests and
     * call sites that do not wire the cache through.
     */
    onActionDiagramPrimed?: (actionId: string, diagram: DiagramData & { svg: string }, voltageLevelsLength: number) => void;
    /** Current voltage-levels count, forwarded to the primer callback's
     * `processSvg` pass. Unused when onActionDiagramPrimed is absent. */
    voltageLevelsLength?: number;
    /**
     * Shared category + threshold filters from the Remedial Action
     * overview. When provided, action cards whose severity bucket is
     * disabled OR whose max_rho exceeds the threshold are hidden from
     * the Suggested / Rejected / Selected lists — so the operator
     * sees the same set of actions on the overview and in the feed.
     */
    overviewFilters?: ActionOverviewFilters;
    /** Update the shared filter state (owned by App.tsx). */
    onOverviewFiltersChange?: (next: ActionOverviewFilters) => void;
    /** "Additional lines to cut" picker wiring, grouped (D4). See AdditionalLinesControls. */
    additionalLines?: AdditionalLinesControls;
    /** Recommendation-model selector wiring, grouped (D4). See ModelSelectorControls. */
    modelSelector?: ModelSelectorControls;
    /** Per-stage analysis execution-time breakdown, grouped (D4). See AnalysisTimingControls. */
    timing?: AnalysisTimingControls;
    /**
     * Clear the un-touched recommender suggestions — wipes entries
     * still in ``suggestedByRecommenderIds`` that the operator has
     * NOT starred / rejected / manually added. Lets the user relaunch
     * with a different model without losing their decisions.
     */
    onClearSuggested?: () => void;
}

const ActionFeed: React.FC<ActionFeedProps> = ({
    actions,
    actionScores,
    linesOverloaded,
    selectedActionId,
    scrollTarget,
    selectedActionIds,
    rejectedActionIds,
    pendingAnalysisResult,
    onDisplayPrioritizedActions,
    onRunAnalysis,
    onCancelAnalysis,
    canRunAnalysis,
    onActionSelect,
    onActionFavorite,
    onActionReject,
    onAssetClick,
    nodesByEquipmentId,
    edgesByEquipmentId,
    disconnectedElement,
    onManualActionAdded,
    onActionResimulated,
    analysisLoading,
    monitoringFactor,
    manuallyAddedIds,
    onVlDoubleClick,
    combinedActions,
    onUpdateCombinedEstimation,
    displayName = (id: string) => id,
    onActionDiagramPrimed,
    voltageLevelsLength,
    overviewFilters,
    additionalLines,
    modelSelector,
    timing,
    onClearSuggested,
}) => {
    // Unpack the grouped D4 controls so the body below keeps referring to
    // the individual fields unchanged (defaults preserved from the former
    // flat props).
    const {
        branches,
        additionalLinesToCut,
        onToggleAdditionalLineToCut,
        n1Overloads,
    } = additionalLines ?? {};
    const {
        recommenderModel,
        setRecommenderModel,
        availableModels,
        activeModelLabel,
    } = modelSelector ?? {};
    const {
        overflowGraphTime,
        actionPredictionTime,
        assessmentTime,
        step1Time,
        enrichmentTime,
        wallClockTime,
    } = timing ?? {};
    const [searchOpen, setSearchOpen] = useState(false);
    const [searchQuery, setSearchQuery] = useState('');
    const [combineModalOpen, setCombineModalOpen] = useState(false);
    const [availableActions, setAvailableActions] = useState<AvailableAction[]>([]);
    const [loadingActions, setLoadingActions] = useState(false);
    const [simulating, setSimulating] = useState<string | null>(null);
    const [error, setError] = useState<string | null>(null);
    const searchInputRef = useRef<HTMLInputElement>(null);
    const dropdownRef = useRef<HTMLDivElement>(null);
    const [tooltip, setTooltip] = useState<{ content: React.ReactNode; x: number; y: number } | null>(null);
    const [suggestedTab, setSuggestedTab] = useState<'prioritized' | 'rejected'>('prioritized');
    const [dismissedSelectedWarning, setDismissedSelectedWarning] = useState(false);
    // Per-action editable MW for LS/RC actions. This state is shared between
    // the score table row input (in the manual-selection dropdown) and the
    // action card input, so that editing one reflects immediately in the other.
    const [cardEditMw, setCardEditMw] = useState<Record<string, string>>({});
    // Per-action editable tap position for PST action re-simulation (keyed by actionId)
    const [cardEditTap, setCardEditTap] = useState<Record<string, string>>({});
    const [resimulating, setResimulating] = useState<string | null>(null);
    const [dismissedRejectedWarning, setDismissedRejectedWarning] = useState(false);

    const showTooltip = (e: React.MouseEvent, content: React.ReactNode) => {
        const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
        setTooltip({ content, x: rect.left, y: rect.bottom + 5 });
    };
    const hideTooltip = () => setTooltip(null);

    // Shared helper: simulate a manual action and — when the parent hook
    // provided `onActionDiagramPrimed` — also pre-fetch the post-action NAD
    // in the same streamed request. Returns the same `simulate_manual_action`
    // shape used downstream so the three call sites (Add / target_mw /
    // target_tap re-sim) stay structurally identical to the pre-stream code.
    //
    // When `onActionDiagramPrimed` is not wired up (older tests, call sites
    // that don't care about the cache), this transparently falls back to
    // the single-shot `api.simulateManualAction` endpoint.
    // Whether the parent hook wired up the pre-fetch primer. When false we
    // keep firing the legacy single-shot `api.simulateManualAction` directly
    // from each call site (preserving exact call arity for tests that assert
    // on it). When true each call site funnels through
    // `streamSimulateAndPrimeDiagram` instead, which consumes the NDJSON
    // stream, returns the metrics event shape (same as
    // `simulate_manual_action`), and pushes the diagram event into the
    // `useDiagrams` cache so a subsequent click on the action card paints
    // the SVG instantly.
    const canPrimeDiagram = !!onActionDiagramPrimed && voltageLevelsLength != null;
    const streamSimulateAndPrimeDiagram = async (
        actionId: string,
        disconnectedEls: string[],
        actionContent: Record<string, unknown> | null,
        linesOvl: string[] | null,
        targetMw: number | null | undefined,
        targetTap: number | null | undefined,
    ): Promise<Awaited<ReturnType<typeof api.simulateManualAction>>> => {
        const response = await api.simulateAndVariantDiagramStream({
            action_id: actionId,
            disconnected_elements: disconnectedEls,
            action_content: actionContent,
            lines_overloaded: linesOvl,
            target_mw: targetMw ?? null,
            target_tap: targetTap ?? null,
        });
        let metrics: Awaited<ReturnType<typeof api.simulateManualAction>> | null = null;
        let streamErr: string | null = null;
        for await (const raw of parseNdjsonStream(response)) {
            const event = raw as Record<string, unknown>;
            if (event.type === 'metrics') {
                // eslint-disable-next-line @typescript-eslint/no-unused-vars
                const { type: _t, ...rest } = event;
                metrics = rest as Awaited<ReturnType<typeof api.simulateManualAction>>;
            } else if (event.type === 'diagram') {
                // eslint-disable-next-line @typescript-eslint/no-unused-vars
                const { type: _t, ...rest } = event;
                // `canPrimeDiagram` is verified before this helper is called.
                onActionDiagramPrimed!(actionId, rest as unknown as DiagramData & { svg: string }, voltageLevelsLength!);
            } else if (event.type === 'error') {
                streamErr = (event.message as string) || 'stream error';
            }
        }
        if (streamErr) throw new Error(streamErr);
        if (!metrics) throw new Error('Stream ended without metrics event');
        return metrics;
    };

    // Fetch available actions when search is opened
    const handleOpenSearch = async () => {
        if (searchOpen) { setSearchOpen(false); return; }
        setSearchOpen(true);
        setSearchQuery('');
        setError(null);
        if (availableActions.length === 0) {
            setLoadingActions(true);
            try {
                const list = await api.getAvailableActions();
                setAvailableActions(list);
            } catch (e) {
                console.error('Failed to fetch actions:', e);
                setError('Failed to load actions list');
            } finally {
                setLoadingActions(false);
            }
        }
        setTimeout(() => searchInputRef.current?.focus(), 50);
    };

    // Shared severity + action-type filter for the manual-selection
    // dropdown — its own `ActionFilterRings` instance, independent of
    // the sidebar's global overviewFilters and the Combine modal's.
    const [dropdownFilters, setDropdownFilters] = useState<ActionOverviewFilters>(DEFAULT_ACTION_OVERVIEW_FILTERS);

    const filteredActions = useMemo(() => {
        const q = searchQuery.toLowerCase();
        const alreadyShown = new Set(Object.keys(actions));
        // Catalogue search rows have no simulated / estimated value, so
        // only the action-type ring applies here — the severity ring
        // would otherwise empty the raw-ID search entirely.
        return availableActions
            .filter(a => !alreadyShown.has(a.id))
            .filter(a => matchesActionTypeFilter(dropdownFilters.actionType, a.id, a.description || null, a.type || null))
            .filter(a => a.id.toLowerCase().includes(q) || (a.description || '').toLowerCase().includes(q))
            .slice(0, 20);
    }, [searchQuery, availableActions, actions, dropdownFilters.actionType]);

    // Whether the analysis has produced ANY scored action — independent
    // of the current chip-filter selection. Drives the wide-modal
    // layout so the operator stays in the centered overlay even when a
    // type filter narrows ``scoredActionsList`` down to zero rows; the
    // alternative (toggling between the wide modal and the small
    // button-anchored dropdown on every chip click) reads as the modal
    // "closing" mid-interaction.
    const hasAnyScoredAction = useMemo(() => {
        if (!actionScores) return false;
        return Object.values(actionScores).some(d =>
            d && typeof d === 'object'
            && Object.keys((d as { scores?: Record<string, number> }).scores ?? {}).length > 0
        );
    }, [actionScores]);

    // Format scored actions — filtered by the shared rings (type +
    // severity). Severity uses the simulated max-loading once the
    // action has been computed; scored-but-untested rows have no
    // value and drop out as soon as a severity bucket is deselected.
    const scoredActionsList = useMemo(() => {
        if (!actionScores) return [];
        const list: { type: string; actionId: string; score: number; mwStart: number | null }[] = [];
        for (const [type, data] of Object.entries(actionScores)) {
            const scores = data?.scores || {};
            for (const [actionId, score] of Object.entries(scores)) {
                const detail = actions[actionId];
                const passes = rowPassesActionFilters(dropdownFilters, {
                    actionId,
                    description: detail?.description_unitaire ?? null,
                    scoreType: type,
                    simulatedMaxRho: detail?.max_rho ?? null,
                    estimatedMaxRho: detail?.estimated_max_rho ?? null,
                    isFault: !!(detail?.is_islanded || detail?.non_convergence),
                }, monitoringFactor);
                if (!passes) continue;
                const mwStartMap = (data as { mw_start?: Record<string, number | null> })?.mw_start;
                const mwStart = mwStartMap?.[actionId] ?? null;
                list.push({ type, actionId, score: Number(score), mwStart: mwStart != null ? Number(mwStart) : null });
            }
        }
        return list.sort((a, b) => {
            if (a.type !== b.type) {
                if (a.type === 'line_disconnection') return 1;
                if (b.type === 'line_disconnection') return -1;
                return a.type.localeCompare(b.type);
            }
            return b.score - a.score;
        });
    }, [actionScores, dropdownFilters, actions, monitoringFactor]);

    // Close dropdown on outside click
    useEffect(() => {
        if (!searchOpen) return;
        const handler = (e: MouseEvent) => {
            if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
                setSearchOpen(false);
                setSearchQuery('');
                setError(null);
            }
        };
        document.addEventListener('mousedown', handler);
        return () => document.removeEventListener('mousedown', handler);
    }, [searchOpen]);

    const handleAddAction = async (actionId: string, targetMw?: number, targetTap?: number) => {
        const trimmedId = actionId.trim();
        if (!disconnectedElement || disconnectedElement.length === 0) {
            setError('Select a contingency first.');
            return;
        }
        setSimulating(trimmedId);
        setError(null);
        try {
            // Build actionContent from topologies if available (especially for combined actions)
            let actionContent: Record<string, unknown> | null = null;
            if (trimmedId.includes('+')) {
                const parts = trimmedId.split('+').map(p => p.trim());
                const perAction: Record<string, unknown> = {};
                for (const part of parts) {
                    const partDetail = actions[part];
                    if (partDetail?.action_topology) {
                        perAction[part] = partDetail.action_topology;
                    }
                }
                if (Object.keys(perAction).length > 0) {
                    actionContent = perAction;
                }
            } else {
                const detail = actions[trimmedId];
                if (detail?.action_topology) {
                    actionContent = detail.action_topology as unknown as Record<string, unknown>;
                }
            }

            const result = canPrimeDiagram
                ? await streamSimulateAndPrimeDiagram(trimmedId, disconnectedElement, actionContent, linesOverloaded, targetMw, targetTap)
                : await api.simulateManualAction(trimmedId, disconnectedElement, actionContent, linesOverloaded, targetMw, targetTap);
            const detail: ActionDetail = {
                description_unitaire: result.description_unitaire,
                rho_before: result.rho_before,
                rho_after: result.rho_after,
                max_rho: result.max_rho,
                max_rho_line: result.max_rho_line,
                is_rho_reduction: result.is_rho_reduction,
                is_islanded: result.is_islanded,
                n_components: result.n_components,
                disconnected_mw: result.disconnected_mw,
                non_convergence: result.non_convergence,
                lines_overloaded_after: result.lines_overloaded_after,
                load_shedding_details: result.load_shedding_details,
                curtailment_details: result.curtailment_details,
                redispatch_details: result.redispatch_details,
                pst_details: result.pst_details,

            };
            onManualActionAdded(trimmedId, detail, result.lines_overloaded || []);
            // Keep the wide score-table modal open after a successful
            // simulation so the operator can chain several manual
            // simulations in a row (same UX contract as
            // ``CombinedActionsModal.handleSimulate``). Closing was
            // forcing a re-open + re-scroll for every row the
            // operator wanted to compare. The narrow no-score search
            // dropdown still auto-dismisses since that mode is
            // typically a one-shot "type an ID, hit enter" flow.
            if (!hasAnyScoredAction) {
                setSearchOpen(false);
                setSearchQuery('');
            }
        } catch (e: unknown) {
            console.error('Simulation failed:', e);
            const err = e as { response?: { data?: { detail?: string } } };
            setError(apiErrorMessage(err, 'Simulation failed'));
        } finally {
            setSimulating(null);
        }
    };

    // Refresh combined estimations for all pairs that include the given action
    const refreshCombinedEstimations = async (actionId: string) => {
        console.log('[refreshCombinedEstimations] called for:', actionId,
            'combinedActions:', combinedActions ? Object.keys(combinedActions).length : null,
            'disconnectedElement:', disconnectedElement,
            'hasCallback:', !!onUpdateCombinedEstimation);
        if (!combinedActions || !disconnectedElement || disconnectedElement.length === 0 || !onUpdateCombinedEstimation) return;
        const relatedPairs = Object.entries(combinedActions).filter(([pairId]) => {
            const parts = pairId.split('+').map(p => p.trim());
            return parts.includes(actionId);
        });
        console.log('[refreshCombinedEstimations] found', relatedPairs.length, 'related pairs:',
            relatedPairs.map(([id]) => id));
        for (const [pairId] of relatedPairs) {
            const parts = pairId.split('+').map(p => p.trim());
            const [id1, id2] = parts;
            try {
                const result = await api.computeSuperposition(id1, id2, disconnectedElement);
                console.log('[refreshCombinedEstimations] superposition result for', pairId, ':', {
                    error: result.error,
                    estimated_max_rho: result.estimated_max_rho,
                    max_rho: result.max_rho,
                    max_rho_line: result.max_rho_line,
                });
                if (!result.error) {
                    const estRho = result.estimated_max_rho ?? result.max_rho;
                    const estLine = result.estimated_max_rho_line ?? result.max_rho_line;
                    console.log('[refreshCombinedEstimations] updating pair', pairId, 'with estRho:', estRho, 'estLine:', estLine);
                    onUpdateCombinedEstimation(pairId, { estimated_max_rho: estRho, estimated_max_rho_line: estLine });
                }
            } catch (e) {
                console.error(`Failed to refresh estimation for pair ${pairId}:`, e);
            }
        }
    };

    // Re-simulate an existing action with a new target MW value
    const handleResimulate = async (actionId: string, newTargetMw: number) => {
        if (!disconnectedElement || disconnectedElement.length === 0) return;
        // Log the user-edited target value so a replay agent can type
        // the exact same MW into the card's input before clicking
        // Re-simulate. Distinct from manual_action_simulated because
        // re-simulation keeps the action in its current bucket
        // (suggested vs. manually added).
        interactionLogger.record('action_mw_resimulated', {
            action_id: actionId,
            target_mw: newTargetMw,
        });
        setResimulating(actionId);
        try {
            const detail = actions[actionId];
            const actionContent = detail?.action_topology ? detail.action_topology as unknown as Record<string, unknown> : null;
            const result = canPrimeDiagram
                ? await streamSimulateAndPrimeDiagram(actionId, disconnectedElement, actionContent, linesOverloaded, newTargetMw, undefined)
                : await api.simulateManualAction(actionId, disconnectedElement, actionContent, linesOverloaded, newTargetMw);
            const newDetail: ActionDetail = {
                description_unitaire: result.description_unitaire,
                rho_before: result.rho_before,
                rho_after: result.rho_after,
                max_rho: result.max_rho,
                max_rho_line: result.max_rho_line,
                is_rho_reduction: result.is_rho_reduction,
                is_islanded: result.is_islanded,
                n_components: result.n_components,
                disconnected_mw: result.disconnected_mw,
                non_convergence: result.non_convergence,
                lines_overloaded_after: result.lines_overloaded_after,
                load_shedding_details: result.load_shedding_details,
                curtailment_details: result.curtailment_details,
                redispatch_details: result.redispatch_details,
                pst_details: result.pst_details,
            };
            onActionResimulated(actionId, newDetail, result.lines_overloaded || []);
            // Clear the edit input so it picks up the new shedded/curtailed MW from results
            setCardEditMw(prev => {
                if (!prev[actionId]) return prev;
                const next = { ...prev };
                delete next[actionId];
                return next;
            });
            // Refresh combined estimations for pairs containing this action
            refreshCombinedEstimations(actionId);
        } catch (e: unknown) {
            console.error('Re-simulation failed:', e);
        } finally {
            setResimulating(null);
        }
    };

    // Re-simulate an existing PST action with a new tap position
    const handleResimulateTap = async (actionId: string, newTap: number) => {
        if (!disconnectedElement || disconnectedElement.length === 0) return;
        // Log the new tap position so a replay agent can enter the
        // same value in the PST detail input before clicking
        // Re-simulate. Backend clamps out-of-range values to
        // [low_tap, high_tap], but the logged value is the raw
        // user-entered integer.
        interactionLogger.record('pst_tap_resimulated', {
            action_id: actionId,
            target_tap: newTap,
        });
        setResimulating(actionId);
        try {
            const detail = actions[actionId];
            const actionContent = detail?.action_topology ? detail.action_topology as unknown as Record<string, unknown> : null;
            const result = canPrimeDiagram
                ? await streamSimulateAndPrimeDiagram(actionId, disconnectedElement, actionContent, linesOverloaded, null, newTap)
                : await api.simulateManualAction(actionId, disconnectedElement, actionContent, linesOverloaded, null, newTap);
            const newDetail: ActionDetail = {
                description_unitaire: result.description_unitaire,
                rho_before: result.rho_before,
                rho_after: result.rho_after,
                max_rho: result.max_rho,
                max_rho_line: result.max_rho_line,
                is_rho_reduction: result.is_rho_reduction,
                is_islanded: result.is_islanded,
                n_components: result.n_components,
                disconnected_mw: result.disconnected_mw,
                non_convergence: result.non_convergence,
                lines_overloaded_after: result.lines_overloaded_after,
                load_shedding_details: result.load_shedding_details,
                curtailment_details: result.curtailment_details,
                redispatch_details: result.redispatch_details,
                pst_details: result.pst_details,
            };
            onActionResimulated(actionId, newDetail, result.lines_overloaded || []);
            // Clear the edit input so it picks up the new tap from results
            setCardEditTap(prev => {
                if (!prev[actionId]) return prev;
                const next = { ...prev };
                delete next[actionId];
                return next;
            });
            // Refresh combined estimations for pairs containing this action
            refreshCombinedEstimations(actionId);
        } catch (e: unknown) {
            console.error('PST re-simulation failed:', e);
        } finally {
            setResimulating(null);
        }
    };

    // actionId → recommender score-type bucket (`line_reconnection`,
    // `pst_tap_change`, …). The action-type filter feeds this `type`
    // into `classifyActionType` — the SAME signal the Combine-modal
    // Explore-Pairs filter uses (`ExplorePairsTab` passes `item.type`).
    // Without it, `classifyActionType` can't tell reco from disco for
    // ids like ``reco_GEN.PY762`` (its disco/reco heuristics look at
    // the score type + description, never the id), so the whole
    // reco / disco bucket would be filtered out of the feed.
    const scoreTypeByActionId = useMemo(() => {
        const m = new Map<string, string>();
        if (!actionScores) return m;
        for (const [type, data] of Object.entries(actionScores)) {
            const scores = data?.scores || {};
            for (const actionId of Object.keys(scores)) {
                if (!m.has(actionId)) m.set(actionId, type);
            }
        }
        return m;
    }, [actionScores]);

    // Sort actions by max_rho ascending (matching standalone)
    // Filter out combined actions that are only estimations (they will have '+' in ID but no rho_after yet)
    const sortedActionEntries = useMemo(() => {
        return Object.entries(actions)
            .filter(([id, details]) => {
                const isCombined = id.includes('+');
                if (isCombined) {
                    // Only show combined if it has been fully simulated (not just estimated)
                    // Simulated actions will NOT have the is_estimated flag set.
                    if (details.is_estimated) return false;
                    if (!details.rho_after || details.rho_after.length === 0) return false;
                }
                // Shared overview filter: hide cards whose severity or
                // max_rho falls outside the active category/threshold
                // picked from the overview header. We skip the filter
                // when overviewFilters is undefined so isolated tests
                // (which don't wire it) keep their existing behaviour.
                if (overviewFilters && !actionPassesOverviewFilter(
                    details, monitoringFactor,
                    overviewFilters.categories, overviewFilters.threshold,
                )) return false;
                // Shared action-type chip filter. Combined actions
                // (key contains '+') are considered in scope when
                // EITHER constituent matches — they're inherently
                // multi-type, so hiding them because only one side
                // matches would surprise the operator.
                // `actionType ?? 'all'` keeps legacy call sites that
                // don't yet set the field (older session reloads /
                // tests) behaving as "no type filter".
                const typeFilter = overviewFilters?.actionType ?? 'all';
                if (typeFilter !== 'all') {
                    if (id.includes('+')) {
                        const [id1, id2] = id.split('+');
                        const d1 = actions[id1];
                        const d2 = actions[id2];
                        const ok = (d1 && matchesActionTypeFilter(typeFilter, id1, d1.description_unitaire, scoreTypeByActionId.get(id1) ?? null))
                            || (d2 && matchesActionTypeFilter(typeFilter, id2, d2.description_unitaire, scoreTypeByActionId.get(id2) ?? null));
                        if (!ok) return false;
                    } else if (!matchesActionTypeFilter(
                        typeFilter, id, details.description_unitaire, scoreTypeByActionId.get(id) ?? null,
                    )) {
                        return false;
                    }
                }
                return true;
            })
            .sort(([, a], [, b]) => {
                // Sink load-flow faults (divergent / islanded) to the bottom of
                // the stack regardless of their reported max_rho. The backend
                // emits ``max_rho = 0`` (not ``null``) for non-convergent
                // simulations — sorting by max_rho alone would float those
                // cards to the top, ahead of legitimate solving actions.
                const aFault = !!(a.is_islanded || a.non_convergence);
                const bFault = !!(b.is_islanded || b.non_convergence);
                if (aFault !== bFault) return aFault ? 1 : -1;
                return (a.max_rho ?? 999) - (b.max_rho ?? 999);
            });
    }, [actions, overviewFilters, monitoringFactor, scoreTypeByActionId]);

    const analysisActionIds = useMemo(() => {
        const ids = new Set<string>();
        if (!actionScores) return ids;
        for (const data of Object.values(actionScores)) {
            const scores = data?.scores || {};
            for (const actionId of Object.keys(scores)) {
                ids.add(actionId);
            }
        }
        return ids;
    }, [actionScores]);

    const selectedEntries = useMemo(() => {
        return sortedActionEntries.filter(([id]) => selectedActionIds.has(id));
    }, [sortedActionEntries, selectedActionIds]);

    const prioritizedEntries = useMemo(() => {
        if (analysisLoading) return [];
        return sortedActionEntries.filter(([id]) => !selectedActionIds.has(id) && !rejectedActionIds.has(id));
    }, [sortedActionEntries, selectedActionIds, rejectedActionIds, analysisLoading]);

    // Inline contextual hint counter — "N actions hidden by the
    // overview filter" — surfaces when the user has tightened the
    // shared overview filters so action cards disappear from the feed.
    // Implements the second half of recommendation #4 in
    // `docs/proposals/ui-design-critique.md`: tier the warnings, drop
    // the redundant yellow banner stack, and add small grey contextual
    // hints under the relevant control. Only counts non-estimation,
    // non-combined-incomplete entries so the hint matches what the
    // operator would actually see if they cleared the filter.
    const overviewFilteredOutCount = useMemo(() => {
        if (!overviewFilters) return 0;
        const typeFilter = overviewFilters.actionType ?? 'all';
        let hidden = 0;
        for (const [id, details] of Object.entries(actions)) {
            if (id.includes('+')) {
                if (details.is_estimated) continue;
                if (!details.rho_after || details.rho_after.length === 0) continue;
            }
            const passesCategory = actionPassesOverviewFilter(
                details, monitoringFactor,
                overviewFilters.categories, overviewFilters.threshold,
            );
            let passesType = true;
            if (typeFilter !== 'all') {
                if (id.includes('+')) {
                    const [id1, id2] = id.split('+');
                    const d1 = actions[id1];
                    const d2 = actions[id2];
                    passesType = !!((d1 && matchesActionTypeFilter(typeFilter, id1, d1.description_unitaire, scoreTypeByActionId.get(id1) ?? null))
                        || (d2 && matchesActionTypeFilter(typeFilter, id2, d2.description_unitaire, scoreTypeByActionId.get(id2) ?? null)));
                } else {
                    passesType = matchesActionTypeFilter(typeFilter, id, details.description_unitaire, scoreTypeByActionId.get(id) ?? null);
                }
            }
            if (!passesCategory || !passesType) hidden += 1;
        }
        return hidden;
    }, [actions, overviewFilters, monitoringFactor, scoreTypeByActionId]);

    // When an action becomes the currently-viewed one (typically
    // after the user double-clicks a pin in the action overview
    // diagram, or clicks an action card body), scroll the
    // sidebar so the matching card is centred in the viewport.
    // Without this, double-clicking a pin can activate an action
    // that is many cards down the feed and the operator has to
    // hunt for it manually.
    //
    // Implementation note: we look up the card by its existing
    // `data-testid="action-card-${id}"` attribute and call
    // `scrollIntoView({ block: 'center' })`. The browser walks
    // up to the nearest scrollable ancestor (the sidebar's
    // overflow-y: auto wrapper in App.tsx) and scrolls that.
    //
    // Wrapped in a rAF so the scroll runs after the matching
    // card has had a chance to mount/move in response to the
    // selection change in the same render cycle.
    useEffect(() => {
        if (!selectedActionId) return;
        let cancelled = false;
        const rafId = requestAnimationFrame(() => {
            if (cancelled) return;
            const el = document.querySelector(
                `[data-testid="action-card-${CSS.escape(selectedActionId)}"]`,
            );
            if (el && typeof (el as HTMLElement).scrollIntoView === 'function') {
                (el as HTMLElement).scrollIntoView({
                    block: 'center',
                    inline: 'nearest',
                    behavior: 'smooth',
                });
            }
        });
        return () => {
            cancelled = true;
            cancelAnimationFrame(rafId);
        };
    }, [selectedActionId]);

    // Scroll to a card when a pin on the action overview is
    // single-clicked (preview).  This is separate from the
    // selectedActionId scroll above because pin preview does NOT
    // select the action (no drill-down).  The `seq` counter in the
    // scrollTarget object ensures that clicking the same pin twice
    // still triggers a fresh scroll.
    useEffect(() => {
        if (!scrollTarget) return;
        let cancelled = false;
        const rafId = requestAnimationFrame(() => {
            if (cancelled) return;
            const el = document.querySelector(
                `[data-testid="action-card-${CSS.escape(scrollTarget.id)}"]`,
            );
            if (el && typeof (el as HTMLElement).scrollIntoView === 'function') {
                (el as HTMLElement).scrollIntoView({
                    block: 'center',
                    inline: 'nearest',
                    behavior: 'smooth',
                });
            }
        });
        return () => {
            cancelled = true;
            cancelAnimationFrame(rafId);
        };
    }, [scrollTarget]);

    const rejectedEntries = useMemo(() => {
        return sortedActionEntries.filter(([id]) => rejectedActionIds.has(id));
    }, [sortedActionEntries, rejectedActionIds]);

    const activeAnalysisResult = useMemo(() => {
        if (pendingAnalysisResult) return pendingAnalysisResult;
        return {
            actions,
            combined_actions: combinedActions || {},
            lines_overloaded: linesOverloaded,
            action_scores: actionScores,
        } as AnalysisResult;
    }, [pendingAnalysisResult, actions, combinedActions, linesOverloaded, actionScores]);

    const renderActionList = (entries: [string, ActionDetail][]) => {
        return entries.map(([id, details], index) => {
            if (!details) return null;
            return (
                <ActionCard
                    key={id}
                    id={id}
                    details={details}
                    index={index}
                    isViewing={selectedActionId === id}
                    isSelected={selectedActionIds.has(id)}
                    isRejected={rejectedActionIds.has(id)}
                    linesOverloaded={linesOverloaded}
                    monitoringFactor={monitoringFactor}
                    nodesByEquipmentId={nodesByEquipmentId}
                    edgesByEquipmentId={edgesByEquipmentId}
                    cardEditMw={cardEditMw}
                    cardEditTap={cardEditTap}
                    resimulating={resimulating}
                    onActionSelect={onActionSelect}
                    onActionFavorite={onActionFavorite}
                    onActionReject={onActionReject}
                    onAssetClick={onAssetClick}
                    onVlDoubleClick={onVlDoubleClick}
                    onCardEditMwChange={(actionId, value) => setCardEditMw(prev => ({ ...prev, [actionId]: value }))}
                    onCardEditTapChange={(actionId, value) => setCardEditTap(prev => ({ ...prev, [actionId]: value }))}
                    onResimulate={handleResimulate}
                    onResimulateTap={handleResimulateTap}
                    displayName={displayName}
                    availableModels={availableModels}
                />
            );
        });
    };

    return (
        <div style={{ padding: '15px' }}>
            {/* Header with search */}
            <div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '10px', position: 'relative' }}>
                <h3 data-testid="action-feed-header" style={{ margin: 0, flex: 1 }}>Remedial Actions</h3>
                <button
                    onClick={handleOpenSearch}
                    style={{
                        background: searchOpen ? colors.brand : colors.surfaceMuted,
                        color: searchOpen ? colors.textOnBrand : colors.textPrimary,
                        border: 'none',
                        borderRadius: '6px',
                        padding: '4px 10px',
                        cursor: 'pointer',
                        fontSize: '13px',
                        fontWeight: 600,
                        marginRight: '6px'
                    }}
                >
                    + Manual Selection
                </button>
                <button
                    onClick={() => setCombineModalOpen(true)}
                    style={{
                        background: combineModalOpen ? colors.brand : colors.surfaceMuted,
                        color: combineModalOpen ? colors.textOnBrand : colors.textPrimary,
                        border: 'none',
                        borderRadius: '6px',
                        padding: '4px 10px',
                        cursor: 'pointer',
                        fontSize: '13px',
                        fontWeight: 600,
                    }}
                >
                    ++ Combine
                </button>

                {/* Search dropdown — when scored actions are available
                    (analysis has produced suggestions), expand to a wide
                    centered overlay that mirrors the Combine Actions
                    modal layout so the score table has room for its
                    Action ID, MW Start and Score columns. */}
                {searchOpen && (
                    <ActionSearchDropdown
                        dropdownRef={dropdownRef}
                        searchInputRef={searchInputRef}
                        searchQuery={searchQuery}
                        onSearchQueryChange={setSearchQuery}
                        filters={dropdownFilters}
                        onFiltersChange={setDropdownFilters}
                        error={error}
                        loadingActions={loadingActions}
                        scoredActionsList={scoredActionsList}
                        filteredActions={filteredActions}
                        actionScores={actionScores}
                        actions={actions}
                        cardEditMw={cardEditMw}
                        onCardEditMwChange={(actionId, value) => setCardEditMw(prev => ({ ...prev, [actionId]: value }))}
                        cardEditTap={cardEditTap}
                        onCardEditTapChange={(actionId, value) => setCardEditTap(prev => ({ ...prev, [actionId]: value }))}
                        simulating={simulating}
                        resimulating={resimulating}
                        onAddAction={handleAddAction}
                        onResimulate={handleResimulate}
                        onResimulateTap={handleResimulateTap}
                        monitoringFactor={monitoringFactor}
                        displayName={displayName}
                        onClose={() => { setSearchOpen(false); setSearchQuery(''); }}
                        onShowTooltip={showTooltip}
                        onHideTooltip={hideTooltip}
                        wide={hasAnyScoredAction}
                    />
                )}
            </div>
            {/* Action-dictionary info now lives in NoticesPanel
                (tier-warning-system PR — see `docs/proposals/ui-design-critique.md`
                recommendation #4). The inline yellow banner that
                stacked here was retired so the sidebar header carries
                a single dismissable pill instead of up to five
                concurrent banners. */}
            <div style={{ marginBottom: '15px' }}>
                <h4 style={{ margin: '0 0 10px 0', fontSize: '14px', color: colors.textPrimary, borderBottom: `1px solid ${colors.borderSubtle}`, paddingBottom: '4px', display: 'flex', justifyContent: 'flex-start', alignItems: 'center', gap: '8px' }}>
                    Selected Actions
                    {selectedEntries.length > 0 && <span style={{ background: colors.surfaceMuted, color: colors.textSecondary, fontSize: '11px', padding: '2px 6px', borderRadius: '10px' }}>{selectedEntries.length}</span>}
                </h4>
                {selectedEntries.length > 0 ? (
                    <>
                        {!dismissedSelectedWarning && selectedEntries.some(([id]) => manuallyAddedIds.has(id) && analysisActionIds.has(id)) && (() => {
                            const overlapIds = selectedEntries.filter(([id]) => manuallyAddedIds.has(id) && analysisActionIds.has(id)).map(([id]) => id).join(', ');
                            return (
                                <div
                                    data-testid="selected-overlap-hint"
                                    style={{
                                        display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start',
                                        gap: '6px', marginBottom: '8px',
                                        fontSize: '11px', color: colors.textTertiary, fontStyle: 'italic',
                                    }}
                                >
                                    <div>Also recommended by the recent analysis: {overlapIds}</div>
                                    <button onClick={() => setDismissedSelectedWarning(true)} style={{ flexShrink: 0, background: 'none', border: 'none', cursor: 'pointer', padding: '0', fontSize: '14px', lineHeight: 1, color: colors.textTertiary }} title="Dismiss">&times;</button>
                                </div>
                            );
                        })()}
                        {renderActionList(selectedEntries)}
                    </>
                ) : (
                    <div style={{ display: 'flex', flexDirection: 'column', gap: '8px', margin: '5px 0 15px 0' }}>
                        <p style={{ color: colors.textTertiary, fontStyle: 'italic', fontSize: '13px', margin: 0 }}>Select an action manually or from suggested ones.</p>
                        {/* "Make a first guess" is a pre-analysis shortcut.
                            Once the operator has launched "Analyze & Suggest"
                            (or the analysis has completed / is pending
                            display), we stop offering the shortcut so the
                            UI funnels the user through the Manual Selection
                            dropdown (which now carries the score table).
                            The button re-appears only after a full reset
                            (contingency change, study reload) — at which
                            point `actionScores` / `pendingAnalysisResult` /
                            `actions` are all cleared by `resetAllState`. */}
                        {!analysisLoading
                            && !pendingAnalysisResult
                            && (!actionScores || Object.keys(actionScores).length === 0)
                            && Object.keys(actions).length === 0 && (
                            <button
                                onClick={handleOpenSearch}
                                data-testid="make-first-guess-button"
                                style={{
                                    padding: '10px',
                                    backgroundColor: colors.surfaceMuted,
                                    border: `1px dashed ${colors.brand}`,
                                    color: colors.brand,
                                    borderRadius: '8px',
                                    cursor: 'pointer',
                                    fontWeight: 600,
                                    fontSize: '14px',
                                    transition: 'all 0.2s',
                                    display: 'flex',
                                    alignItems: 'center',
                                    justifyContent: 'center',
                                    gap: '8px',
                                }}
                                onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.backgroundColor = colors.brandSoft; }}
                                onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.backgroundColor = colors.surfaceMuted; }}
                            >
                                <span style={{ fontSize: '16px' }}>💡</span> Make a first guess
                            </button>
                        )}
                    </div>
                )}
            </div>

            <div style={{ marginBottom: '15px' }}>
                {overviewFilteredOutCount > 0 && (
                    <div
                        data-testid="overview-filter-hint"
                        style={{
                            marginBottom: '6px',
                            fontSize: '11px',
                            color: colors.textTertiary,
                            fontStyle: 'italic',
                        }}
                    >
                        {overviewFilteredOutCount} action{overviewFilteredOutCount === 1 ? '' : 's'} hidden by the overview filter.
                    </div>
                )}
                <div style={{ display: 'flex', borderBottom: `1px solid ${colors.borderSubtle}`, marginBottom: '10px' }}>
                    <button
                        onClick={() => setSuggestedTab('prioritized')}
                        style={{ flex: 1, padding: '8px', cursor: 'pointer', border: 'none', background: 'none', borderBottom: suggestedTab === 'prioritized' ? `2px solid ${colors.brand}` : 'none', fontWeight: suggestedTab === 'prioritized' ? 'bold' : 'normal', color: suggestedTab === 'prioritized' ? colors.brand : colors.textTertiary, display: 'flex', justifyContent: 'center', alignItems: 'center', gap: '6px' }}
                    >Suggested Actions {prioritizedEntries.length > 0 && <span style={{ background: suggestedTab === 'prioritized' ? colors.brandSoft : colors.surfaceMuted, color: suggestedTab === 'prioritized' ? colors.brand : colors.textSecondary, fontSize: '11px', padding: '2px 6px', borderRadius: '10px', fontWeight: 'bold' }}>{prioritizedEntries.length}</span>}</button>
                    <button
                        onClick={() => setSuggestedTab('rejected')}
                        style={{ flex: 1, padding: '8px', cursor: 'pointer', border: 'none', background: 'none', borderBottom: suggestedTab === 'rejected' ? `2px solid ${colors.danger}` : 'none', fontWeight: suggestedTab === 'rejected' ? 'bold' : 'normal', color: suggestedTab === 'rejected' ? colors.danger : colors.textTertiary, display: 'flex', justifyContent: 'center', alignItems: 'center', gap: '6px' }}
                    >Rejected Actions {rejectedEntries.length > 0 && <span style={{ background: suggestedTab === 'rejected' ? colors.dangerSoft : colors.surfaceMuted, color: suggestedTab === 'rejected' ? colors.danger : colors.textSecondary, fontSize: '11px', padding: '2px 6px', borderRadius: '10px', fontWeight: 'bold' }}>{rejectedEntries.length}</span>}</button>
                </div>

                {suggestedTab === 'prioritized' && prioritizedEntries.length > 0 && activeModelLabel && (() => {
                    const fmt = (s: number) => s >= 10 ? `${s.toFixed(1)}s` : `${s.toFixed(2)}s`;
                    const hasPrediction = typeof actionPredictionTime === 'number';
                    const hasAssessment = typeof assessmentTime === 'number';
                    const hasOverflow = typeof overflowGraphTime === 'number';
                    const hasStep1 = typeof step1Time === 'number';
                    const hasEnrichment = typeof enrichmentTime === 'number';
                    const hasWallClock = typeof wallClockTime === 'number';
                    const backendSum = (hasStep1 ? step1Time! : 0)
                        + (hasOverflow ? overflowGraphTime! : 0)
                        + (hasPrediction ? actionPredictionTime! : 0)
                        + (hasAssessment ? assessmentTime! : 0)
                        + (hasEnrichment ? enrichmentTime! : 0);
                    // Prefer the wall-clock figure as the headline number —
                    // it matches what the operator perceives between
                    // pressing "Analyze & Suggest" and the "Display N
                    // prioritized actions" button appearing. The per-stage
                    // backend buckets explain how that wall-clock was
                    // spent; the residual is network / NDJSON overhead.
                    const total = hasWallClock ? wallClockTime! : backendSum;
                    const showBreakdown = hasPrediction || hasAssessment || hasOverflow
                        || hasStep1 || hasEnrichment || hasWallClock;
                    // Native <title> tooltip lists the per-stage breakdown
                    // so the operator can see where each chunk of the
                    // total came from on hover, without dedicating
                    // sidebar real-estate to a multi-column row.
                    const breakdownLines: string[] = [];
                    if (hasStep1) breakdownLines.push(`Step 1 (contingency simulation): ${fmt(step1Time!)}`);
                    if (hasOverflow) breakdownLines.push(`Overflow analysis: ${fmt(overflowGraphTime!)}`);
                    if (hasPrediction) breakdownLines.push(`Action prediction: ${fmt(actionPredictionTime!)}`);
                    if (hasAssessment) breakdownLines.push(`Action assessment: ${fmt(assessmentTime!)}`);
                    if (hasEnrichment) breakdownLines.push(`Enrichment / post-process: ${fmt(enrichmentTime!)}`);
                    if (hasWallClock) {
                        const residual = Math.max(0, wallClockTime! - backendSum);
                        breakdownLines.push(`Other (network / streaming): ${fmt(residual)}`);
                        breakdownLines.push(`──`);
                        breakdownLines.push(`Total (wall-clock, click → display): ${fmt(wallClockTime!)}`);
                    }
                    const breakdownTooltip = breakdownLines.length
                        ? `Execution time breakdown\n  ${breakdownLines.join('\n  ')}`
                        : '';
                    return (
                        <div
                            data-testid="active-model-reminder"
                            style={{
                                display: 'flex', flexDirection: 'column',
                                gap: '4px', marginBottom: '8px',
                                fontSize: '11px', color: colors.textTertiary, fontStyle: 'italic',
                            }}
                        >
                            <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '8px' }}>
                                <span>
                                    Suggestions produced by <strong style={{ fontStyle: 'normal', color: colors.textSecondary }}>{activeModelLabel}</strong>
                                    {showBreakdown && (
                                        <>
                                            {' '}in{' '}
                                            <span
                                                data-testid="execution-time-total"
                                                title={breakdownTooltip}
                                                style={{
                                                    fontStyle: 'normal', cursor: 'help',
                                                    fontVariantNumeric: 'tabular-nums',
                                                    color: colors.textSecondary, fontWeight: 600,
                                                    borderBottom: `1px dotted ${colors.borderStrong}`,
                                                }}
                                            >
                                                {fmt(total)}
                                                <span aria-hidden="true" style={{
                                                    marginLeft: '3px', fontWeight: 700,
                                                    color: colors.textTertiary, fontSize: '10px',
                                                }}>&#9432;</span>
                                            </span>
                                        </>
                                    )}
                                </span>
                                {onClearSuggested && (
                                    <button
                                        type="button"
                                        onClick={onClearSuggested}
                                        title="Clear un-touched suggestions (keeps starred / rejected / manually-added actions) so a new analysis can be launched, optionally with a different model."
                                        style={{
                                            padding: '3px 10px',
                                            background: colors.danger,
                                            color: colors.textOnBrand,
                                            border: 'none',
                                            borderRadius: '4px',
                                            cursor: 'pointer',
                                            fontSize: '11px',
                                            fontStyle: 'normal',
                                            fontWeight: 'bold',
                                            flexShrink: 0,
                                        }}
                                    >
                                        Clear
                                    </button>
                                )}
                            </div>
                        </div>
                    );
                })()}

                {/* Unified analysis action slot: Analyze & Suggest → Analyzing… → Display N prioritized actions */}
                {/* Show the analysis trigger slot whenever the Suggested
                    feed is empty — not just when result.actions has no
                    recommender-produced entry. After "Clear", the
                    operator's kept rejected actions stay in
                    result.actions with is_manual=false; gating on
                    prioritizedEntries (which already excludes selected +
                    rejected) is what makes the Analyze & Suggest button
                    reappear post-Clear.

                    When the overview filter hides every otherwise-
                    eligible action, suppress the Analyze & Suggest
                    button and surface a "no actions match the filter"
                    notice instead — the operator should clear the
                    filter before re-running an analysis. */}
                {(analysisLoading || pendingAnalysisResult || (prioritizedEntries.length === 0 && overviewFilteredOutCount === 0)) && (
                    <div style={{ marginBottom: '10px' }}>
                        {analysisLoading ? (
                            <>
                                <button disabled style={{
                                    width: '100%', padding: '10px 16px',
                                    background: colors.warningSoft, color: colors.warningText,
                                    border: `1px solid ${colors.warningBorder}`, borderRadius: '8px',
                                    cursor: 'not-allowed', fontSize: '14px', fontWeight: 700,
                                    boxShadow: '0 2px 8px rgba(0,0,0,0.05)',
                                }}>
                                    ⚙️ Analyzing…
                                </button>
                                {onCancelAnalysis && (
                                    <button
                                        data-testid="cancel-analysis"
                                        onClick={onCancelAnalysis}
                                        style={{
                                            width: '100%', marginTop: '6px', padding: '8px 16px',
                                            background: 'transparent', color: colors.danger,
                                            border: `1px solid ${colors.danger}`, borderRadius: '8px',
                                            cursor: 'pointer', fontSize: '13px', fontWeight: 600,
                                        }}
                                    >
                                        ✕ Cancel
                                    </button>
                                )}
                            </>
                        ) : pendingAnalysisResult ? (
                            <button
                                data-testid="display-prioritized-actions"
                                onClick={onDisplayPrioritizedActions}
                                style={{
                                    width: '100%', padding: '10px 16px',
                                    background: colors.success,
                                    color: colors.textOnBrand, border: 'none', borderRadius: '8px',
                                    cursor: 'pointer', fontSize: '14px', fontWeight: 700,
                                    boxShadow: '0 2px 8px rgba(39,174,96,0.3)', transition: 'transform 0.1s',
                                }}
                                onMouseEnter={(e) => (e.target as HTMLButtonElement).style.transform = 'scale(1.02)'}
                                onMouseLeave={(e) => (e.target as HTMLButtonElement).style.transform = 'scale(1)'}
                            >
                                📊 Display {Object.keys(pendingAnalysisResult.actions || {}).length} prioritized actions
                            </button>
                        ) : (
                            <>
                                {branches && additionalLinesToCut && onToggleAdditionalLineToCut && (
                                    <AdditionalLinesPicker
                                        branches={branches}
                                        n1Overloads={n1Overloads ?? []}
                                        additionalLinesToCut={additionalLinesToCut}
                                        onToggle={onToggleAdditionalLineToCut}
                                        displayName={displayName}
                                    />
                                )}
                                {recommenderModel != null && setRecommenderModel && (
                                    <div style={{
                                        display: 'flex', alignItems: 'center', gap: '6px',
                                        marginBottom: '8px', fontSize: '12px',
                                        color: colors.textSecondary,
                                    }}>
                                        <label
                                            htmlFor="analyzeRecommenderModel"
                                            style={{ flexShrink: 0 }}
                                        >Model:</label>
                                        <select
                                            id="analyzeRecommenderModel"
                                            value={recommenderModel}
                                            onChange={e => {
                                                interactionLogger.record('recommender_model_changed', {
                                                    model: e.target.value, source: 'action_feed',
                                                });
                                                setRecommenderModel(e.target.value);
                                            }}
                                            style={{
                                                flex: 1, padding: '4px 6px',
                                                border: `1px solid ${colors.border}`,
                                                borderRadius: '4px',
                                                background: colors.surface,
                                                color: colors.textPrimary,
                                                fontSize: '12px',
                                            }}
                                        >
                                            {(!availableModels || availableModels.length === 0) && (
                                                <option value="expert">Expert system</option>
                                            )}
                                            {availableModels?.map(m => (
                                                <option key={m.name} value={m.name}>{m.label}</option>
                                            ))}
                                        </select>
                                    </div>
                                )}
                                <button
                                    data-testid="analyze-suggest"
                                    onClick={onRunAnalysis}
                                    disabled={!canRunAnalysis}
                                    style={{
                                        width: '100%', padding: '10px 16px',
                                        background: canRunAnalysis ? colors.success : colors.disabled,
                                        color: colors.textOnBrand, border: 'none', borderRadius: '8px',
                                        cursor: canRunAnalysis ? 'pointer' : 'not-allowed',
                                        fontSize: '14px', fontWeight: 700,
                                        boxShadow: canRunAnalysis ? '0 2px 8px rgba(39,174,96,0.3)' : 'none',
                                        transition: 'transform 0.1s',
                                    }}
                                    onMouseEnter={(e) => { if (canRunAnalysis) (e.target as HTMLButtonElement).style.transform = 'scale(1.02)'; }}
                                    onMouseLeave={(e) => (e.target as HTMLButtonElement).style.transform = 'scale(1)'}
                                >
                                    🔍 Analyze & Suggest
                                </button>
                            </>
                        )}
                    </div>
                )}

                {suggestedTab === 'prioritized' && (
                    prioritizedEntries.length > 0 ? renderActionList(prioritizedEntries) : (
                        !analysisLoading ? (
                            <div style={{ textAlign: 'center' }}>
                                <p
                                    data-testid={overviewFilteredOutCount > 0 && !pendingAnalysisResult ? 'no-actions-match-filter' : undefined}
                                    style={{ color: colors.textTertiary, fontStyle: 'italic', fontSize: '13px', margin: '5px 0' }}
                                >
                                    {overviewFilteredOutCount > 0 && !pendingAnalysisResult
                                        ? 'No actions match the active filter. Clear the filter to see suggestions.'
                                        : (!pendingAnalysisResult ? 'Click \u201cAnalyze & Suggest\u201d above to get action suggestions.' : 'No suggested actions available.')}
                                </p>
                                {/* Recommender thresholds banner moved into NoticesPanel
                                    (tier-warning-system PR — see `docs/proposals/ui-design-critique.md`
                                    recommendation #4). */}
                            </div>
                        ) : null
                    )
                )}
                {suggestedTab === 'rejected' && (
                    rejectedEntries.length > 0 ? (
                        <>
                            {!dismissedRejectedWarning && rejectedEntries.some(([id]) => analysisActionIds.has(id)) && (() => {
                                const overlapIds = rejectedEntries.filter(([id]) => analysisActionIds.has(id)).map(([id]) => id).join(', ');
                                return (
                                    <div
                                        data-testid="rejected-overlap-hint"
                                        style={{
                                            display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start',
                                            gap: '6px', marginBottom: '8px',
                                            fontSize: '11px', color: colors.textTertiary, fontStyle: 'italic',
                                        }}
                                    >
                                        <div>Recommended by the recent analysis: {overlapIds}</div>
                                        <button onClick={() => setDismissedRejectedWarning(true)} style={{ flexShrink: 0, background: 'none', border: 'none', cursor: 'pointer', padding: '0', fontSize: '14px', lineHeight: 1, color: colors.textTertiary }} title="Dismiss">&times;</button>
                                    </div>
                                );
                            })()}
                            {renderActionList(rejectedEntries)}
                        </>
                    ) : (
                        <p style={{ color: colors.textTertiary, fontStyle: 'italic', fontSize: '13px', margin: '5px 0', textAlign: 'center' }}>No rejected actions.</p>
                    )
                )}
            </div>

            {/* Fixed-position tooltip rendered outside any overflow context */}
            {tooltip && (
                <div style={{
                    position: 'fixed',
                    top: tooltip.y,
                    left: tooltip.x,
                    zIndex: 99999,
                    backgroundColor: colors.chrome,
                    color: colors.textOnBrand,
                    textAlign: 'left',
                    borderRadius: '4px',
                    padding: '6px 8px',
                    fontSize: '10px',
                    fontWeight: 'normal',
                    whiteSpace: 'nowrap',
                    boxShadow: '0 2px 8px rgba(0,0,0,0.4)',
                    lineHeight: 1.4,
                    pointerEvents: 'none',
                    maxWidth: '90vw',
                }}>
                    {tooltip.content}
                </div>
            )}

            {/* Combined Actions Modal */}
            <CombinedActionsModal
                isOpen={combineModalOpen}
                onClose={() => setCombineModalOpen(false)}
                analysisResult={activeAnalysisResult}
                simulatedActions={actions}
                disconnectedElement={disconnectedElement}
                onSimulateCombined={onManualActionAdded}
                onSimulateSingleAction={onActionResimulated}
                monitoringFactor={monitoringFactor}
                linesOverloaded={linesOverloaded}
                displayName={displayName}
            />
        </div>
    );
};

export default React.memo(ActionFeed);
