// 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 { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom/vitest';
import { createRef } from 'react';
import ActionSearchDropdown from './ActionSearchDropdown';
import { DEFAULT_ACTION_OVERVIEW_FILTERS } from '../utils/actionTypes';
import type { ActionDetail, ActionOverviewFilters } from '../types';

describe('ActionSearchDropdown', () => {
    const defaultProps = {
        dropdownRef: createRef<HTMLDivElement>(),
        searchInputRef: createRef<HTMLInputElement>(),
        searchQuery: '',
        onSearchQueryChange: vi.fn(),
        filters: DEFAULT_ACTION_OVERVIEW_FILTERS as ActionOverviewFilters,
        onFiltersChange: vi.fn(),
        error: null as string | null,
        loadingActions: false,
        scoredActionsList: [] as { type: string; actionId: string; score: number; mwStart: number | null }[],
        filteredActions: [] as { id: string; description: string; type?: string }[],
        actionScores: undefined as Record<string, Record<string, unknown>> | undefined,
        actions: {} as Record<string, ActionDetail>,
        cardEditMw: {} as Record<string, string>,
        onCardEditMwChange: vi.fn(),
        cardEditTap: {} as Record<string, string>,
        onCardEditTapChange: vi.fn(),
        simulating: null as string | null,
        resimulating: null as string | null,
        onAddAction: vi.fn(),
        onResimulate: vi.fn(),
        onResimulateTap: vi.fn(),
        onShowTooltip: vi.fn(),
        onHideTooltip: vi.fn(),
        monitoringFactor: 0.95,
    };

    it('renders search input with placeholder', () => {
        render(<ActionSearchDropdown {...defaultProps} />);
        expect(screen.getByPlaceholderText('Search action by ID or description...')).toBeInTheDocument();
    });

    it('renders the shared ActionFilterRings (severity + action-type rings)', () => {
        render(<ActionSearchDropdown {...defaultProps} />);
        expect(screen.getByTestId('sidebar-action-filters')).toBeInTheDocument();
        for (const t of ['disco', 'reco', 'ls', 'rc', 'pst', 'open', 'close']) {
            expect(screen.getByTestId(`sidebar-filter-type-${t}`)).toBeInTheDocument();
        }
        for (const c of ['green', 'orange', 'red', 'grey']) {
            expect(screen.getByTestId(`sidebar-filter-category-${c}`)).toBeInTheDocument();
        }
    });

    it('calls onFiltersChange when an action-type ring toggle is clicked', () => {
        const onFiltersChange = vi.fn();
        render(<ActionSearchDropdown {...defaultProps} onFiltersChange={onFiltersChange} />);
        fireEvent.click(screen.getByTestId('sidebar-filter-type-pst'));
        expect(onFiltersChange).toHaveBeenCalledWith(
            expect.objectContaining({ actionType: 'pst' }),
        );
    });

    it('marks the active action-type toggle with aria-pressed="true"', () => {
        render(
            <ActionSearchDropdown
                {...defaultProps}
                filters={{ ...DEFAULT_ACTION_OVERVIEW_FILTERS, actionType: 'disco' }}
            />,
        );
        expect(screen.getByTestId('sidebar-filter-type-disco').getAttribute('aria-pressed')).toBe('true');
        expect(screen.getByTestId('sidebar-filter-type-pst').getAttribute('aria-pressed')).toBe('false');
    });

    it('calls onSearchQueryChange when input changes', () => {
        const onSearchQueryChange = vi.fn();
        render(<ActionSearchDropdown {...defaultProps} onSearchQueryChange={onSearchQueryChange} />);
        fireEvent.change(screen.getByPlaceholderText('Search action by ID or description...'), { target: { value: 'test' } });
        expect(onSearchQueryChange).toHaveBeenCalledWith('test');
    });

    it('displays error message when error is set', () => {
        render(<ActionSearchDropdown {...defaultProps} error="Something went wrong" />);
        expect(screen.getByText('Something went wrong')).toBeInTheDocument();
    });

    it('shows loading message when loadingActions is true', () => {
        render(<ActionSearchDropdown {...defaultProps} loadingActions={true} />);
        expect(screen.getByText('Loading actions...')).toBeInTheDocument();
    });

    it('shows "All actions already added" when no actions available and no query', () => {
        render(<ActionSearchDropdown {...defaultProps} />);
        expect(screen.getByText('All actions already added')).toBeInTheDocument();
    });

    it('renders filtered actions list', () => {
        const filteredActions = [
            { id: 'reco_1', description: 'Close line L1' },
            { id: 'reco_2', description: 'Close line L2' },
        ];
        render(<ActionSearchDropdown {...defaultProps} searchQuery="reco" filteredActions={filteredActions} />);
        expect(screen.getByText('reco_1')).toBeInTheDocument();
        expect(screen.getByText('reco_2')).toBeInTheDocument();
    });

    it('calls onAddAction when an action item is clicked', () => {
        const onAddAction = vi.fn();
        const filteredActions = [{ id: 'reco_1', description: 'Close line L1' }];
        render(<ActionSearchDropdown {...defaultProps} searchQuery="reco" filteredActions={filteredActions} onAddAction={onAddAction} />);
        fireEvent.click(screen.getByText('reco_1'));
        expect(onAddAction).toHaveBeenCalledWith('reco_1');
    });

    it('shows manual ID option when searchQuery does not match any filtered action', () => {
        render(<ActionSearchDropdown {...defaultProps} searchQuery="my_custom_action" />);
        expect(screen.getByTestId('manual-id-option-my_custom_action')).toBeInTheDocument();
        expect(screen.getByText('my_custom_action')).toBeInTheDocument();
    });

    it('calls onAddAction with manual ID when manual option is clicked', () => {
        const onAddAction = vi.fn();
        render(<ActionSearchDropdown {...defaultProps} searchQuery="manual_42" onAddAction={onAddAction} />);
        fireEvent.click(screen.getByTestId('manual-id-option-manual_42'));
        expect(onAddAction).toHaveBeenCalledWith('manual_42');
    });

    it('renders scored actions table when scored actions exist and no search query', () => {
        const scoredActionsList = [
            { type: 'line_reconnection', actionId: 'act_1', score: 10, mwStart: null },
        ];
        const actionScores = {
            line_reconnection: { scores: { act_1: 10 }, params: {} },
        };
        render(<ActionSearchDropdown {...defaultProps} scoredActionsList={scoredActionsList} actionScores={actionScores} />);
        expect(screen.getByText('Scored Actions')).toBeInTheDocument();
        expect(screen.getByText('act_1')).toBeInTheDocument();
        expect(screen.getByText('10.00')).toBeInTheDocument();
    });

    it('shows simulating state on action items', () => {
        const filteredActions = [{ id: 'act_sim', description: 'Test Action' }];
        render(<ActionSearchDropdown {...defaultProps} searchQuery="act" filteredActions={filteredActions} simulating="act_sim" />);
        expect(screen.getByText('Simulating...')).toBeInTheDocument();
    });

    describe('wide mode (centered overlay when scoring is computed)', () => {
        it('renders the narrow inline dropdown by default', () => {
            render(<ActionSearchDropdown {...defaultProps} />);
            expect(screen.getByTestId('manual-selection-dropdown')).toBeInTheDocument();
            expect(screen.queryByTestId('manual-selection-wide')).not.toBeInTheDocument();
            expect(screen.queryByTestId('manual-selection-backdrop')).not.toBeInTheDocument();
        });

        it('renders a wide centered overlay with backdrop when wide=true', () => {
            render(<ActionSearchDropdown {...defaultProps} wide />);
            expect(screen.queryByTestId('manual-selection-dropdown')).not.toBeInTheDocument();
            const wide = screen.getByTestId('manual-selection-wide');
            expect(wide).toBeInTheDocument();
            // Mirrors the Combine Actions modal layout: centered, fixed,
            // 80vw wide so the score table has room for its columns.
            const styleAttr = wide.getAttribute('style') || '';
            expect(styleAttr).toContain('position: fixed');
            expect(styleAttr).toContain('width: 80vw');
            expect(screen.getByTestId('manual-selection-backdrop')).toBeInTheDocument();
        });

        it('anchors the wide overlay to a fixed viewport top (no vertical re-centering as content grows)', () => {
            // Operators picking a chip that filters the score table down
            // to zero rows (or back up) used to see the modal hop
            // vertically: ``top: 50% / translate(-50%, -50%)`` re-centers
            // on the body's new height. The fix anchors the top of the
            // modal to a fixed viewport offset so the title + filter
            // header stay at the same screen height regardless of the
            // table's row count.
            render(<ActionSearchDropdown {...defaultProps} wide />);
            const styleAttr = screen.getByTestId('manual-selection-wide').getAttribute('style') || '';
            expect(styleAttr).toContain('top: 7.5vh');
            expect(styleAttr).toContain('translateX(-50%)');
            expect(styleAttr).not.toContain('translate(-50%, -50%)');
        });

        it('still renders the score table inside the wide overlay', () => {
            const scoredActionsList = [
                { type: 'line_reconnection', actionId: 'act_wide_1', score: 7.25, mwStart: null },
            ];
            const actionScores = {
                line_reconnection: { scores: { act_wide_1: 7.25 }, params: {} },
            };
            render(
                <ActionSearchDropdown
                    {...defaultProps}
                    scoredActionsList={scoredActionsList}
                    actionScores={actionScores}
                    wide
                />,
            );
            expect(screen.getByTestId('manual-selection-wide')).toBeInTheDocument();
            expect(screen.getByText('Scored Actions')).toBeInTheDocument();
            expect(screen.getByText('act_wide_1')).toBeInTheDocument();
            expect(screen.getByText('7.25')).toBeInTheDocument();
        });
    });

    describe('Target MW sync (Bug 1)', () => {
        // A computed load-shedding action is already in the `actions` map
        // with a simulated shedded_mw value. The score table row for that
        // action must display the simulated value by default instead of an
        // empty input, so the user can see what the action was run with.
        it('populates LS score-table input with stored shedded_mw for computed actions', () => {
            const scoredActionsList = [
                { type: 'load_shedding', actionId: 'load_shedding_L1', score: 1.0, mwStart: 6.4 },
            ];
            const actionScores = {
                load_shedding: { scores: { load_shedding_L1: 1.0 }, params: {} },
            };
            const actions: Record<string, ActionDetail> = {
                load_shedding_L1: {
                    description_unitaire: 'shed L1',
                    rho_before: null,
                    rho_after: null,
                    max_rho: 0.5,
                    max_rho_line: 'LINE_A',
                    is_rho_reduction: true,
                    load_shedding_details: [
                        { load_name: 'L1', voltage_level_id: 'VL1', shedded_mw: 5.4 },
                    ],
                },
            };
            render(
                <ActionSearchDropdown
                    {...defaultProps}
                    scoredActionsList={scoredActionsList}
                    actionScores={actionScores}
                    actions={actions}
                />,
            );
            const input = screen.getByTestId('target-mw-load_shedding_L1') as HTMLInputElement;
            expect(input.value).toBe('5.4');
        });

        // Same guarantee for renewable curtailment: the stored curtailed_mw
        // must show up as the default value for computed rows.
        it('populates RC score-table input with stored curtailed_mw for computed actions', () => {
            const scoredActionsList = [
                { type: 'renewable_curtailment', actionId: 'curtail_G1', score: 1.0, mwStart: 8.0 },
            ];
            const actionScores = {
                renewable_curtailment: { scores: { curtail_G1: 1.0 }, params: {} },
            };
            const actions: Record<string, ActionDetail> = {
                curtail_G1: {
                    description_unitaire: 'curtail G1',
                    rho_before: null,
                    rho_after: null,
                    max_rho: 0.6,
                    max_rho_line: 'LINE_B',
                    is_rho_reduction: true,
                    curtailment_details: [
                        { gen_name: 'G1', voltage_level_id: 'VL2', curtailed_mw: 3.1 },
                    ],
                },
            };
            render(
                <ActionSearchDropdown
                    {...defaultProps}
                    scoredActionsList={scoredActionsList}
                    actionScores={actionScores}
                    actions={actions}
                />,
            );
            const input = screen.getByTestId('target-mw-curtail_G1') as HTMLInputElement;
            expect(input.value).toBe('3.1');
        });

        // The cardEditMw value (written when the user edits the input on the
        // prioritized action card) must be reflected in the score table row
        // input, so the two UIs stay synchronized.
        it('mirrors cardEditMw value in the score-table input', () => {
            const onCardEditMwChange = vi.fn();
            const scoredActionsList = [
                { type: 'load_shedding', actionId: 'load_shedding_L1', score: 1.0, mwStart: 6.4 },
            ];
            const actionScores = {
                load_shedding: { scores: { load_shedding_L1: 1.0 }, params: {} },
            };
            const actions: Record<string, ActionDetail> = {
                load_shedding_L1: {
                    description_unitaire: 'shed L1',
                    rho_before: null,
                    rho_after: null,
                    max_rho: 0.5,
                    max_rho_line: 'LINE_A',
                    is_rho_reduction: true,
                    load_shedding_details: [
                        { load_name: 'L1', voltage_level_id: 'VL1', shedded_mw: 5.4 },
                    ],
                },
            };
            render(
                <ActionSearchDropdown
                    {...defaultProps}
                    scoredActionsList={scoredActionsList}
                    actionScores={actionScores}
                    actions={actions}
                    cardEditMw={{ load_shedding_L1: '4.2' }}
                    onCardEditMwChange={onCardEditMwChange}
                />,
            );
            const input = screen.getByTestId('target-mw-load_shedding_L1') as HTMLInputElement;
            // cardEditMw overrides the stored shedded_mw default.
            expect(input.value).toBe('4.2');
        });

        // Typing in the score-table row must propagate the change through
        // onCardEditMwChange (the shared edit state used by both the row and
        // the action card).
        it('forwards score-table edits through onCardEditMwChange', () => {
            const onCardEditMwChange = vi.fn();
            const scoredActionsList = [
                { type: 'load_shedding', actionId: 'load_shedding_L1', score: 1.0, mwStart: 6.4 },
            ];
            const actionScores = {
                load_shedding: { scores: { load_shedding_L1: 1.0 }, params: {} },
            };
            const actions: Record<string, ActionDetail> = {
                load_shedding_L1: {
                    description_unitaire: 'shed L1',
                    rho_before: null,
                    rho_after: null,
                    max_rho: 0.5,
                    max_rho_line: 'LINE_A',
                    is_rho_reduction: true,
                    load_shedding_details: [
                        { load_name: 'L1', voltage_level_id: 'VL1', shedded_mw: 5.4 },
                    ],
                },
            };
            render(
                <ActionSearchDropdown
                    {...defaultProps}
                    scoredActionsList={scoredActionsList}
                    actionScores={actionScores}
                    actions={actions}
                    onCardEditMwChange={onCardEditMwChange}
                />,
            );
            const input = screen.getByTestId('target-mw-load_shedding_L1');
            fireEvent.change(input, { target: { value: '4.0' } });
            expect(onCardEditMwChange).toHaveBeenCalledWith('load_shedding_L1', '4.0');
        });

        // A non-computed LS row (no action detail yet) should render an
        // empty input — the stored-MW fallback only applies once the action
        // has been simulated.
        it('leaves LS input empty for non-computed actions', () => {
            const scoredActionsList = [
                { type: 'load_shedding', actionId: 'load_shedding_L2', score: 1.0, mwStart: 6.4 },
            ];
            const actionScores = {
                load_shedding: { scores: { load_shedding_L2: 1.0 }, params: {} },
            };
            render(
                <ActionSearchDropdown
                    {...defaultProps}
                    scoredActionsList={scoredActionsList}
                    actionScores={actionScores}
                    actions={{}}
                />,
            );
            const input = screen.getByTestId('target-mw-load_shedding_L2') as HTMLInputElement;
            expect(input.value).toBe('');
        });
    });

    // Redispatch actions edit a SIGNED delta (raise > 0 / lower < 0), not an
    // absolute target. They get a dedicated "Δ MW" column in the score table,
    // mirroring the editable increment already shown on the action card.
    describe('Redispatch delta column', () => {
        const redispatchScores = {
            redispatch: { scores: { redispatch_G1: 1.0 }, params: {} },
        };
        const scoredRedispatch = [
            { type: 'redispatch', actionId: 'redispatch_G1', score: 1.0, mwStart: 14.3 },
        ];
        const computedRedispatch: Record<string, ActionDetail> = {
            redispatch_G1: {
                description_unitaire: 'redispatch G1',
                rho_before: null,
                rho_after: null,
                max_rho: 0.8,
                max_rho_line: 'LINE_A',
                is_rho_reduction: true,
                redispatch_details: [
                    { gen_name: 'G1', voltage_level_id: 'VL1', delta_mw: 10.0, target_mw: 24.3, direction: 'up', current_mw: 14.3, max_raise_mw: 30.0, max_lower_mw: 14.3 },
                ],
            },
        };

        it('renders the Δ MW header and an editable delta input for redispatch rows', () => {
            render(
                <ActionSearchDropdown
                    {...defaultProps}
                    scoredActionsList={scoredRedispatch}
                    actionScores={redispatchScores}
                    actions={{}}
                />,
            );
            expect(screen.getByText('Δ MW')).toBeInTheDocument();
            expect(screen.getByTestId('delta-mw-redispatch_G1')).toBeInTheDocument();
        });

        it('populates the delta input with the simulated delta_mw for computed actions', () => {
            render(
                <ActionSearchDropdown
                    {...defaultProps}
                    scoredActionsList={scoredRedispatch}
                    actionScores={redispatchScores}
                    actions={computedRedispatch}
                />,
            );
            const input = screen.getByTestId('delta-mw-redispatch_G1') as HTMLInputElement;
            expect(input.value).toBe('10.0');
        });

        it('leaves the delta input empty for non-computed redispatch rows', () => {
            render(
                <ActionSearchDropdown
                    {...defaultProps}
                    scoredActionsList={scoredRedispatch}
                    actionScores={redispatchScores}
                    actions={{}}
                />,
            );
            const input = screen.getByTestId('delta-mw-redispatch_G1') as HTMLInputElement;
            expect(input.value).toBe('');
        });

        it('forwards delta edits through onCardEditMwChange', () => {
            const onCardEditMwChange = vi.fn();
            render(
                <ActionSearchDropdown
                    {...defaultProps}
                    scoredActionsList={scoredRedispatch}
                    actionScores={redispatchScores}
                    actions={{}}
                    onCardEditMwChange={onCardEditMwChange}
                />,
            );
            fireEvent.change(screen.getByTestId('delta-mw-redispatch_G1'), { target: { value: '-5' } });
            expect(onCardEditMwChange).toHaveBeenCalledWith('redispatch_G1', '-5');
        });

        it('adds a non-computed redispatch action with the typed delta as target MW', () => {
            const onAddAction = vi.fn();
            render(
                <ActionSearchDropdown
                    {...defaultProps}
                    scoredActionsList={scoredRedispatch}
                    actionScores={redispatchScores}
                    actions={{}}
                    cardEditMw={{ redispatch_G1: '8' }}
                    onAddAction={onAddAction}
                />,
            );
            // Click the row (not the input) to add the action.
            fireEvent.click(screen.getByTestId('score-redispatch_G1'));
            expect(onAddAction).toHaveBeenCalledWith('redispatch_G1', 8, undefined);
        });

        it('re-simulates a computed redispatch action when the delta is edited', () => {
            const onResimulate = vi.fn();
            render(
                <ActionSearchDropdown
                    {...defaultProps}
                    scoredActionsList={scoredRedispatch}
                    actionScores={redispatchScores}
                    actions={computedRedispatch}
                    cardEditMw={{ redispatch_G1: '15' }}
                    onResimulate={onResimulate}
                />,
            );
            fireEvent.click(screen.getByTestId('score-redispatch_G1'));
            expect(onResimulate).toHaveBeenCalledWith('redispatch_G1', 15);
        });

        it('accepts a negative delta for a lower-direction redispatch action', () => {
            const lowerRedispatch: Record<string, ActionDetail> = {
                redispatch_G1: {
                    description_unitaire: 'redispatch G1 lower',
                    rho_before: null,
                    rho_after: null,
                    max_rho: 0.8,
                    max_rho_line: 'LINE_A',
                    is_rho_reduction: true,
                    redispatch_details: [
                        { gen_name: 'G1', voltage_level_id: 'VL1', delta_mw: -5.0, target_mw: 9.3, direction: 'down', current_mw: 14.3, max_raise_mw: 30.0, max_lower_mw: 14.3 },
                    ],
                },
            };
            render(
                <ActionSearchDropdown
                    {...defaultProps}
                    scoredActionsList={scoredRedispatch}
                    actionScores={redispatchScores}
                    actions={lowerRedispatch}
                />,
            );
            const input = screen.getByTestId('delta-mw-redispatch_G1') as HTMLInputElement;
            expect(input.value).toBe('-5.0');
        });

        it('mirrors cardEditMw value in the delta input (overriding stored delta)', () => {
            render(
                <ActionSearchDropdown
                    {...defaultProps}
                    scoredActionsList={scoredRedispatch}
                    actionScores={redispatchScores}
                    actions={computedRedispatch}
                    cardEditMw={{ redispatch_G1: '-3' }}
                />,
            );
            const input = screen.getByTestId('delta-mw-redispatch_G1') as HTMLInputElement;
            expect(input.value).toBe('-3');
        });
    });

    // Regression: once "Analyze & Suggest" has produced action scores,
    // applying a type filter may filter the score list down to zero.
    // Previously the dropdown silently fell back to the full network
    // action list, which misled the operator into thinking the
    // analysis recommended any of those actions. A yellow warning
    // banner must now tell them that no scored action matched the
    // selected type. The banner is suppressed when the analysis
    // hasn't been run (actionScores undefined) or when the 'all'
    // filter is active, to avoid false positives.
    describe('no-relevant-action warning (after analyze & suggest)', () => {
        it('shows the warning when actionScores are non-empty but the type filter hides them all', () => {
            const actionScores = {
                pst_tap_change: {
                    scores: { 'pst-A': 5 },
                    params: {},
                },
            };
            render(
                <ActionSearchDropdown
                    {...defaultProps}
                    // Type filter is RECO but the only scored action is a PST.
                    filters={{ ...DEFAULT_ACTION_OVERVIEW_FILTERS, actionType: 'reco' }}
                    scoredActionsList={[]}
                    actionScores={actionScores}
                />,
            );
            const banner = screen.getByTestId('no-relevant-action-warning');
            expect(banner).toBeInTheDocument();
            expect(banner.textContent).toMatch(/no relevant action detected/i);
        });

        it('does NOT show the warning when actionScores is undefined (analysis not yet run)', () => {
            render(
                <ActionSearchDropdown
                    {...defaultProps}
                    filters={{ ...DEFAULT_ACTION_OVERVIEW_FILTERS, actionType: 'reco' }}
                    scoredActionsList={[]}
                    actionScores={undefined}
                />,
            );
            expect(screen.queryByTestId('no-relevant-action-warning')).not.toBeInTheDocument();
        });

        it('does NOT show the warning when actionScores is empty (analysis ran but produced nothing)', () => {
            render(
                <ActionSearchDropdown
                    {...defaultProps}
                    filters={{ ...DEFAULT_ACTION_OVERVIEW_FILTERS, actionType: 'reco' }}
                    scoredActionsList={[]}
                    actionScores={{}}
                />,
            );
            expect(screen.queryByTestId('no-relevant-action-warning')).not.toBeInTheDocument();
        });

        it('does NOT show the warning under the `all` filter (user is not filtering by type)', () => {
            const actionScores = {
                pst_tap_change: { scores: { 'pst-A': 5 }, params: {} },
            };
            render(
                <ActionSearchDropdown
                    {...defaultProps}
                    filters={{ ...DEFAULT_ACTION_OVERVIEW_FILTERS, actionType: 'all' }}
                    scoredActionsList={[]}
                    actionScores={actionScores}
                />,
            );
            expect(screen.queryByTestId('no-relevant-action-warning')).not.toBeInTheDocument();
        });

        it('does NOT show the warning when there is at least one scored action after filtering', () => {
            const actionScores = {
                pst_tap_change: { scores: { 'pst-A': 5 }, params: {} },
            };
            render(
                <ActionSearchDropdown
                    {...defaultProps}
                    filters={{ ...DEFAULT_ACTION_OVERVIEW_FILTERS, actionType: 'pst' }}
                    scoredActionsList={[
                        { type: 'pst_tap_change', actionId: 'pst-A', score: 5, mwStart: null },
                    ]}
                    actionScores={actionScores}
                />,
            );
            expect(screen.queryByTestId('no-relevant-action-warning')).not.toBeInTheDocument();
        });

        it('does NOT show the warning while the user is typing in the search box', () => {
            const actionScores = {
                pst_tap_change: { scores: { 'pst-A': 5 }, params: {} },
            };
            render(
                <ActionSearchDropdown
                    {...defaultProps}
                    searchQuery="foo"
                    filters={{ ...DEFAULT_ACTION_OVERVIEW_FILTERS, actionType: 'reco' }}
                    scoredActionsList={[]}
                    actionScores={actionScores}
                />,
            );
            expect(screen.queryByTestId('no-relevant-action-warning')).not.toBeInTheDocument();
        });
    });

    // Operator-requested addition: a "Simulated Max ρ" column in the
    // score table so the user can compare action effectiveness from
    // inside the manual-selection modal without bouncing back to the
    // action card stack. Pending rows render an em-dash; simulated
    // rows show the max-ρ percentage with the same green / orange /
    // red severity colouring the ActionCard uses, and divergent /
    // islanded simulations render the matching warning label.
    describe('Simulated Max ρ column', () => {
        const baseScored = [
            { type: 'line_reconnection', actionId: 'reco_1', score: 5, mwStart: null },
        ];
        const baseScores = {
            line_reconnection: { scores: { reco_1: 5 }, params: {} },
        };

        it('renders the column header in the score table', () => {
            render(
                <ActionSearchDropdown
                    {...defaultProps}
                    scoredActionsList={baseScored}
                    actionScores={baseScores}
                />,
            );
            expect(screen.getByText(/Simulated Max/i)).toBeInTheDocument();
        });

        it('renders an em-dash for an unsimulated row', () => {
            render(
                <ActionSearchDropdown
                    {...defaultProps}
                    scoredActionsList={baseScored}
                    actionScores={baseScores}
                />,
            );
            const cell = screen.getByTestId('sim-max-rho-reco_1');
            expect(cell.getAttribute('data-state')).toBe('pending');
            expect(cell.textContent).toBe('—');
        });

        it('renders the green-severity max ρ once the action has been simulated below the monitoring band', () => {
            const actions: Record<string, ActionDetail> = {
                reco_1: {
                    description_unitaire: 'r1',
                    rho_before: [1.05],
                    rho_after: [0.5],
                    max_rho: 0.5,
                    max_rho_line: 'LINE_A',
                    is_rho_reduction: true,
                },
            };
            render(
                <ActionSearchDropdown
                    {...defaultProps}
                    scoredActionsList={baseScored}
                    actionScores={baseScores}
                    actions={actions}
                />,
            );
            const cell = screen.getByTestId('sim-max-rho-reco_1');
            expect(cell.getAttribute('data-state')).toBe('green');
            expect(cell.textContent).toBe('50.0%');
            expect(cell.getAttribute('title')).toBe('Max ρ on LINE_A');
        });

        it('renders orange severity when max_rho is in the (mf - 0.05, mf] band', () => {
            const actions: Record<string, ActionDetail> = {
                reco_1: {
                    description_unitaire: 'r1',
                    rho_before: [1.05],
                    rho_after: [0.92],
                    // monitoringFactor = 0.95 (default). 0.92 is in
                    // (mf - 0.05, mf] → orange.
                    max_rho: 0.92,
                    max_rho_line: 'LINE_A',
                    is_rho_reduction: true,
                },
            };
            render(
                <ActionSearchDropdown
                    {...defaultProps}
                    scoredActionsList={baseScored}
                    actionScores={baseScores}
                    actions={actions}
                />,
            );
            expect(screen.getByTestId('sim-max-rho-reco_1').getAttribute('data-state')).toBe('orange');
        });

        it('renders red severity when max_rho is above the monitoring factor', () => {
            const actions: Record<string, ActionDetail> = {
                reco_1: {
                    description_unitaire: 'r1',
                    rho_before: [1.05],
                    rho_after: [1.02],
                    max_rho: 1.02,
                    max_rho_line: 'LINE_A',
                    is_rho_reduction: false,
                },
            };
            render(
                <ActionSearchDropdown
                    {...defaultProps}
                    scoredActionsList={baseScored}
                    actionScores={baseScores}
                    actions={actions}
                />,
            );
            const cell = screen.getByTestId('sim-max-rho-reco_1');
            expect(cell.getAttribute('data-state')).toBe('red');
            expect(cell.textContent).toBe('102.0%');
        });

        it('renders a "divergent" label for a non-convergent simulation', () => {
            const actions: Record<string, ActionDetail> = {
                reco_1: {
                    description_unitaire: 'r1',
                    rho_before: null,
                    rho_after: null,
                    // Backend writes max_rho = 0 on non-convergence — the
                    // numeric value must NOT leak as "0.0%". The
                    // non_convergence flag wins.
                    max_rho: 0,
                    max_rho_line: 'N/A',
                    is_rho_reduction: false,
                    non_convergence: 'LoadFlow failure: foo',
                },
            };
            render(
                <ActionSearchDropdown
                    {...defaultProps}
                    scoredActionsList={baseScored}
                    actionScores={baseScores}
                    actions={actions}
                />,
            );
            const cell = screen.getByTestId('sim-max-rho-reco_1');
            expect(cell.getAttribute('data-state')).toBe('divergent');
            expect(cell.textContent).toBe('divergent');
            expect(cell.getAttribute('title')).toContain('LoadFlow failure');
        });

        it('renders an "islanded" label for an islanded simulation', () => {
            const actions: Record<string, ActionDetail> = {
                reco_1: {
                    description_unitaire: 'r1',
                    rho_before: null,
                    rho_after: null,
                    max_rho: 0.4,
                    max_rho_line: 'LINE_A',
                    is_rho_reduction: false,
                    is_islanded: true,
                    disconnected_mw: 42.5,
                },
            };
            render(
                <ActionSearchDropdown
                    {...defaultProps}
                    scoredActionsList={baseScored}
                    actionScores={baseScores}
                    actions={actions}
                />,
            );
            const cell = screen.getByTestId('sim-max-rho-reco_1');
            expect(cell.getAttribute('data-state')).toBe('islanded');
            expect(cell.textContent).toBe('islanded');
            expect(cell.getAttribute('title')).toContain('42.5 MW');
        });
    });

    // Mirrors the ``Simulated Line`` column in
    // ``ComputedPairsTable`` — surfaces the branch carrying max ρ on
    // the post-action observation, resolved through ``displayName``
    // so the operator sees the friendly pypowsybl substation pair
    // (e.g. ``BEON L31CPVAN``) instead of the raw element ID. Pending
    // rows and faulted simulations (divergent / islanded) render an
    // em-dash because the post-action max-ρ branch is not meaningful
    // in those cases.
    describe('Simulated Line column', () => {
        const baseScored = [
            { type: 'line_reconnection', actionId: 'reco_1', score: 5, mwStart: null },
        ];
        const baseScores = {
            line_reconnection: { scores: { reco_1: 5 }, params: {} },
        };

        it('renders the column header in the score table', () => {
            render(
                <ActionSearchDropdown
                    {...defaultProps}
                    scoredActionsList={baseScored}
                    actionScores={baseScores}
                />,
            );
            expect(screen.getByText('Simulated Line')).toBeInTheDocument();
        });

        it('renders an em-dash for an unsimulated row', () => {
            render(
                <ActionSearchDropdown
                    {...defaultProps}
                    scoredActionsList={baseScored}
                    actionScores={baseScores}
                />,
            );
            const cell = screen.getByTestId('sim-line-reco_1');
            expect(cell.getAttribute('data-state')).toBe('pending');
            expect(cell.textContent).toBe('—');
        });

        it('renders displayName(max_rho_line) once the action has been simulated', () => {
            const actions: Record<string, ActionDetail> = {
                reco_1: {
                    description_unitaire: 'r1',
                    rho_before: [1.05],
                    rho_after: [0.85],
                    max_rho: 0.85,
                    max_rho_line: 'BRANCH_ID_42',
                    is_rho_reduction: true,
                },
            };
            const displayName = (id: string) => id === 'BRANCH_ID_42' ? 'BEON L31CPVAN' : id;
            render(
                <ActionSearchDropdown
                    {...defaultProps}
                    scoredActionsList={baseScored}
                    actionScores={baseScores}
                    actions={actions}
                    displayName={displayName}
                />,
            );
            const cell = screen.getByTestId('sim-line-reco_1');
            expect(cell.getAttribute('data-state')).toBe('resolved');
            expect(cell.textContent).toBe('BEON L31CPVAN');
            // Raw ID still on the title so the operator can copy it
            // if they need to debug at the data layer.
            expect(cell.getAttribute('title')).toBe('BRANCH_ID_42');
        });

        it('falls back to the raw id when displayName has no mapping', () => {
            const actions: Record<string, ActionDetail> = {
                reco_1: {
                    description_unitaire: 'r1',
                    rho_before: [1.05],
                    rho_after: [0.85],
                    max_rho: 0.85,
                    max_rho_line: 'UNMAPPED_LINE',
                    is_rho_reduction: true,
                },
            };
            render(
                <ActionSearchDropdown
                    {...defaultProps}
                    scoredActionsList={baseScored}
                    actionScores={baseScores}
                    actions={actions}
                />,
            );
            expect(screen.getByTestId('sim-line-reco_1').textContent).toBe('UNMAPPED_LINE');
        });

        it('renders an em-dash for a divergent simulation', () => {
            const actions: Record<string, ActionDetail> = {
                reco_1: {
                    description_unitaire: 'r1',
                    rho_before: null,
                    rho_after: null,
                    max_rho: 0,
                    max_rho_line: 'N/A',
                    is_rho_reduction: false,
                    non_convergence: 'LoadFlow failure',
                },
            };
            render(
                <ActionSearchDropdown
                    {...defaultProps}
                    scoredActionsList={baseScored}
                    actionScores={baseScores}
                    actions={actions}
                />,
            );
            const cell = screen.getByTestId('sim-line-reco_1');
            expect(cell.getAttribute('data-state')).toBe('unavailable');
            expect(cell.textContent).toBe('—');
        });

        it('renders an em-dash for an islanded simulation', () => {
            const actions: Record<string, ActionDetail> = {
                reco_1: {
                    description_unitaire: 'r1',
                    rho_before: null,
                    rho_after: null,
                    max_rho: 0.4,
                    max_rho_line: 'LINE_A',
                    is_rho_reduction: false,
                    is_islanded: true,
                    disconnected_mw: 12,
                },
            };
            render(
                <ActionSearchDropdown
                    {...defaultProps}
                    scoredActionsList={baseScored}
                    actionScores={baseScores}
                    actions={actions}
                />,
            );
            const cell = screen.getByTestId('sim-line-reco_1');
            expect(cell.getAttribute('data-state')).toBe('unavailable');
            expect(cell.textContent).toBe('—');
        });
    });

    // Score column moved to position 2 (right after the Action
    // name) so the ranking is visible without the operator's eye
    // having to scan past the per-row MW / tap inputs. Pin both the
    // header order AND the row cell order so a future drag-resize
    // or re-shuffle can't silently regress it.
    describe('Score column position', () => {
        const baseScored = [
            { type: 'line_reconnection', actionId: 'reco_1', score: 7.25, mwStart: null },
        ];
        const baseScores = {
            line_reconnection: { scores: { reco_1: 7.25 }, params: {} },
        };

        it('renders the Score header in the second column (right after Action)', () => {
            render(
                <ActionSearchDropdown
                    {...defaultProps}
                    scoredActionsList={baseScored}
                    actionScores={baseScores}
                />,
            );
            const headers = screen.getAllByRole('columnheader');
            expect(headers[0].textContent).toBe('Action');
            expect(headers[1].textContent).toBe('Score');
        });

        it('renders the score value cell in the second column of the data row', () => {
            render(
                <ActionSearchDropdown
                    {...defaultProps}
                    scoredActionsList={baseScored}
                    actionScores={baseScores}
                />,
            );
            const actionRow = screen.getAllByRole('row').find(r => r.textContent?.includes('reco_1'));
            const cells = actionRow!.querySelectorAll('td');
            // Column 0 = action name; column 1 = score (newly moved).
            expect(cells[0].textContent).toContain('reco_1');
            expect(cells[1].textContent).toBe('7.25');
            expect(screen.getByTestId('score-reco_1').textContent).toBe('7.25');
        });
    });

    // Operator-requested addition: the wide modal now stays mounted
    // across multiple simulations (see
    // ``ActionFeed.handleAddAction``), so it needs an explicit
    // dismiss affordance. Matches the Combine Actions modal close
    // button (``CombinedActionsModal.tsx:391``). The narrow inline
    // dropdown does not get a header — it relies on outside-click
    // dismissal as before.
    describe('Close button (wide mode)', () => {
        it('renders the close button only when wide AND onClose is wired', () => {
            const onClose = vi.fn();
            render(<ActionSearchDropdown {...defaultProps} wide onClose={onClose} />);
            expect(screen.getByTestId('manual-selection-close')).toBeInTheDocument();
        });

        it('does not render the close button in narrow mode', () => {
            const onClose = vi.fn();
            render(<ActionSearchDropdown {...defaultProps} onClose={onClose} />);
            expect(screen.queryByTestId('manual-selection-close')).not.toBeInTheDocument();
        });

        it('does not render the close button when onClose is omitted', () => {
            render(<ActionSearchDropdown {...defaultProps} wide />);
            expect(screen.queryByTestId('manual-selection-close')).not.toBeInTheDocument();
        });

        it('invokes onClose when the close button is clicked', () => {
            const onClose = vi.fn();
            render(<ActionSearchDropdown {...defaultProps} wide onClose={onClose} />);
            fireEvent.click(screen.getByTestId('manual-selection-close'));
            expect(onClose).toHaveBeenCalledTimes(1);
        });

        it('uses the multiplication-sign glyph (×) so it matches the Combine modal', () => {
            const onClose = vi.fn();
            render(<ActionSearchDropdown {...defaultProps} wide onClose={onClose} />);
            // &times; → ×
            expect(screen.getByTestId('manual-selection-close').textContent).toBe('×');
        });
    });
});
