// 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 from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor, act, fireEvent, cleanup, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom/vitest';
import App from './App';

// ===== Mocks =====

// Mock child components to avoid their complexity
vi.mock('./components/VisualizationPanel', () => {
  interface MockProps {
    nDiagram: Record<string, unknown> | null;
    n1Diagram: Record<string, unknown> | null;
    activeTab: string;
    configLoading: boolean;
    layoutPath: string;
    networkPath: string;
    onOpenSettings: (tab: string) => void;
  }
  const MockVisualizationPanel = (props: MockProps) => {
    const { nDiagram, n1Diagram, activeTab, configLoading, layoutPath, networkPath, onOpenSettings } = props;
    const [warningDismissed, setWarningDismissed] = React.useState(false);
    const hasAnyDiagram = !!nDiagram?.svg || !!n1Diagram?.svg;
    const showPathWarning = !warningDismissed && !hasAnyDiagram;

    return (
      <div
        data-testid="visualization-panel"
        data-n1-diagram-present={!!n1Diagram}
        data-active-tab={activeTab}
      >
        {!hasAnyDiagram && !configLoading && showPathWarning && (
          <div>
            <div>Configuration Paths</div>
            <button onClick={() => setWarningDismissed(true)}>✕</button>
            <div>Layout Path: {layoutPath}</div>
            <div>Output Folder: {networkPath ? (networkPath.includes('/') ? networkPath.substring(0, networkPath.lastIndexOf('/')) : networkPath) : 'Not set'}</div>
            <button onClick={() => onOpenSettings('paths')}>Change in settings</button>
          </div>
        )}
      </div>
    );
  };
  return { default: MockVisualizationPanel };
});
vi.mock('./components/ActionFeed', () => ({
  default: (props: { linesOverloaded: string[]; pendingAnalysisResult: object | null; analysisLoading: boolean; onDisplayPrioritizedActions: () => void; onRunAnalysis: () => void; canRunAnalysis: boolean }) => (
    <div
      data-testid="action-feed"
      data-ol-count={props.linesOverloaded?.length || 0}
      data-ol-names={(props.linesOverloaded ?? []).join('|')}
      data-pending={!!props.pendingAnalysisResult}
      data-loading={!!props.analysisLoading}
    >
      <div>
        <h3 data-testid="action-feed-header">Remedial Actions</h3>
      </div>
      {props.analysisLoading ? (
        <button disabled>⚙️ Analyzing…</button>
      ) : props.pendingAnalysisResult ? (
        <button onClick={props.onDisplayPrioritizedActions}>Display prioritized actions</button>
      ) : (
        <button onClick={props.onRunAnalysis} disabled={!props.canRunAnalysis}>🔍 Analyze & Suggest</button>
      )}
    </div>
  ),
}));
vi.mock('./components/OverloadPanel', () => ({
  default: (props: { n1Overloads: string[]; selectedOverloads: Set<string>; onToggleOverload?: (overload: string) => void }) => (
    <div
      data-testid="overload-panel"
      data-n1-ol-count={props.n1Overloads?.length || 0}
      data-sel-ol-count={props.selectedOverloads?.size || 0}
    >
      {(props.n1Overloads ?? []).map((name) => (
        <button
          key={name}
          data-testid={`toggle-overload-${name}`}
          onClick={() => props.onToggleOverload?.(name)}
        />
      ))}
    </div>
  ),
}));

// Mock hooks
vi.mock('./hooks/usePanZoom', () => ({
  usePanZoom: () => ({ viewBox: null, setViewBox: vi.fn() }),
}));

// Mock hooks
vi.mock('./hooks/usePanZoom', () => ({
  usePanZoom: () => ({ viewBox: null, setViewBox: vi.fn() }),
}));

// Mock SVG utilities
vi.mock('./utils/svgUtils', () => ({
  processSvg: (svg: string) => ({ svg, viewBox: { x: 0, y: 0, w: 100, h: 100 } }),
  buildMetadataIndex: () => null,
  applyOverloadedHighlights: vi.fn(),
  applyDeltaVisuals: vi.fn(),
  applyActionTargetHighlights: vi.fn(),
  applyContingencyHighlight: vi.fn(),
  getIdMap: () => new Map(),
  invalidateIdMapCache: vi.fn(),
  isCouplingAction: vi.fn(() => false),
  attachVlInteractions: vi.fn(() => () => {}),
}));


// Mock API — use vi.hoisted to define mock before vi.mock hoists
const mockApi = vi.hoisted(() => ({
  updateConfig: vi.fn().mockResolvedValue({ monitored_lines_count: 10, total_lines_count: 10 }),
  getBranches: vi.fn().mockResolvedValue({ branches: ['BRANCH_A', 'BRANCH_B', 'BRANCH_C'], name_map: {} }),
  getVoltageLevels: vi.fn().mockResolvedValue({ voltage_levels: ['VL1', 'VL2'], name_map: {} }),
  getNominalVoltages: vi.fn().mockResolvedValue({ mapping: {}, unique_kv: [63, 225] }),
  getVoltageLevelSubstations: vi.fn().mockResolvedValue({ mapping: {} }),
  getNetworkDiagram: vi.fn().mockResolvedValue({ svg: '<svg></svg>', metadata: null }),
  getContingencyDiagram: vi.fn().mockResolvedValue({ svg: '<svg></svg>', metadata: null, lines_overloaded: [] }),
  pickPath: vi.fn(),
  runAnalysisStep1: vi.fn().mockResolvedValue({ can_proceed: true, lines_overloaded: ['LINE_OL1'] }),
  runAnalysisStep2Stream: vi.fn(),
  getActionVariantDiagram: vi.fn().mockResolvedValue({ svg: '<svg></svg>', metadata: null }),
  getUserConfig: vi.fn().mockResolvedValue({
    network_path: '/home/user/data/grid.xiidm',
    action_file_path: '/home/user/data/actions.json'
  }),
  getConfigFilePath: vi.fn().mockResolvedValue('/home/user/data/config.json'),
  saveUserConfig: vi.fn().mockResolvedValue({}),
  setConfigFilePath: vi.fn().mockResolvedValue({ config_file_path: '/home/user/data/config.json', config: {} }),
  getNSld: vi.fn(),
  getContingencySld: vi.fn(),
  getActionVariantSld: vi.fn(),
}));

vi.mock('./api', () => ({
  api: mockApi,
}));

afterEach(() => {
  cleanup();
});

// Helper: render App, load config, wait for branches to appear
async function renderAndLoadStudy() {
  render(<App />);

  // Click Load Study
  const loadBtn = screen.getByText('🔄 Load Study');
  await userEvent.click(loadBtn);

  // Wait for branches to be loaded (which means handleLoadConfig is done)
  await waitFor(() => {
    expect(screen.getByText('⚡ Select Contingency')).toBeInTheDocument();
  }, { timeout: 5000 });
}

// Helper: add ``branchName`` to the pending contingency by typing it
// into the react-select multi-select and pressing Enter to confirm
// the highlighted option. After a contingency has already been
// committed the picker card is hidden behind the sticky banner Clear
// shortcut, so transparently click Clear (and confirm the dialog when
// analysis state would be lost) to reveal the picker again.
async function pickBranch(branchName: string) {
  let combobox = screen.queryByRole('combobox');
  if (!combobox) {
    const clearBtn = screen.queryByTestId('sidebar-summary-clear');
    if (clearBtn) {
      await act(async () => { await userEvent.click(clearBtn); });
      const confirmDialog = screen.queryByText('Change Contingency?');
      if (confirmDialog) {
        await act(async () => { await userEvent.click(screen.getByText('Confirm')); });
      }
    }
    combobox = screen.getByRole('combobox');
  }
  await act(async () => {
    await userEvent.click(combobox);
    await userEvent.type(combobox, branchName);
    await userEvent.keyboard('{Enter}');
  });
}

// Helper: click the Trigger button to commit the pending list.
async function triggerContingency() {
  const trigger = await screen.findByRole('button', { name: /Trigger/ });
  await act(async () => {
    await userEvent.click(trigger);
  });
}

// Helper: full pick + trigger + wait for the contingency-diagram fetch.
async function selectBranch(branchName: string) {
  await pickBranch(branchName);
  await triggerContingency();
  await waitFor(() => {
    expect(mockApi.getContingencyDiagram).toHaveBeenCalledWith([branchName]);
  });
}

// Helper: run analysis to create analysis state
async function runAnalysis() {
  // Mock runAnalysisStep2Stream to return a streaming Response
  const mockStream = new ReadableStream({
    start(controller) {
      controller.enqueue(new TextEncoder().encode(
        JSON.stringify({ type: 'result', actions: { ACT1: { is_manual: false, rho_before: [1.02], rho_after: [0.95] } }, lines_overloaded: ['LINE_OL1'], message: 'done', dc_fallback: false }) + '\n'
      ));
      controller.close();
    },
  });
  mockApi.runAnalysisStep2Stream.mockResolvedValue({
    ok: true,
    body: mockStream,
  });

  const runBtn = screen.getByText('🔍 Analyze & Suggest');
  await act(async () => {
    await userEvent.click(runBtn);
  });

  await waitFor(() => {
    const running = screen.queryByText('⚙️ Analyzing…');
    if (running) throw new Error('Still running...');
  }, { timeout: 5000 });

  // Click Display Actions if present
  const displayBtn = await screen.findByText(/Display.*prioritized actions/, {}, { timeout: 3000 });
  await userEvent.click(displayBtn);
}

describe('Contingency Change Confirmation', () => {
  beforeEach(() => {
    vi.clearAllMocks();
    localStorage.clear();
    // Reset fetch stub
    vi.unstubAllGlobals();
  });

  it('does NOT show confirmation dialog when clearing without analysis state', async () => {
    await renderAndLoadStudy();
    await selectBranch('BRANCH_A');

    // Clear from the sticky banner. No analysis state has been built
    // up yet, so the Clear shortcut bypasses the confirmation dialog
    // and the picker reappears immediately.
    mockApi.getContingencyDiagram.mockClear();
    await act(async () => {
      await userEvent.click(screen.getByTestId('sidebar-summary-clear'));
    });
    expect(screen.queryByText('Change Contingency?')).not.toBeInTheDocument();

    // Pick a fresh contingency through the now-visible picker.
    await pickBranch('BRANCH_B');
    await triggerContingency();
    await waitFor(() => {
      expect(mockApi.getContingencyDiagram).toHaveBeenCalledWith(['BRANCH_B']);
    });
  });

  it('shows confirmation dialog when clearing after running analysis', async () => {
    await renderAndLoadStudy();
    await selectBranch('BRANCH_A');
    await runAnalysis();

    await act(async () => {
      await userEvent.click(screen.getByTestId('sidebar-summary-clear'));
    });
    await waitFor(() => {
      expect(screen.getByText('Change Contingency?')).toBeInTheDocument();
    }, { timeout: 3000 });
  });

  it('proceeds with contingency change after confirmation', async () => {
    await renderAndLoadStudy();
    await selectBranch('BRANCH_A');
    await runAnalysis();

    await act(async () => {
      await userEvent.click(screen.getByTestId('sidebar-summary-clear'));
    });
    await screen.findByText('Change Contingency?');
    await userEvent.click(screen.getByText('Confirm'));

    mockApi.getContingencyDiagram.mockClear();
    await pickBranch('BRANCH_B');
    await triggerContingency();
    await waitFor(() => {
      expect(mockApi.getContingencyDiagram).toHaveBeenCalledWith(['BRANCH_B']);
    });
    expect(screen.queryByText('Change Contingency?')).not.toBeInTheDocument();
  });

  it('cancels contingency clear on dismissal', async () => {
    await renderAndLoadStudy();
    await selectBranch('BRANCH_A');
    await runAnalysis();

    await act(async () => {
      await userEvent.click(screen.getByTestId('sidebar-summary-clear'));
    });
    await screen.findByText('Change Contingency?');
    await userEvent.click(screen.getByText('Cancel'));

    // BRANCH_A stays applied; its name still surfaces in the sticky
    // banner. (Use getAllByText since both the banner link and the
    // action-card data attribute may surface the name.)
    expect(screen.getAllByText('BRANCH_A').length).toBeGreaterThan(0);
  });

  it('does not trigger a clear dialog for partial/invalid branch text', async () => {
    await renderAndLoadStudy();
    // The contingency picker stays visible until the operator
    // triggers a contingency, so partial input via the multi-select
    // never affects the banner / Clear flow.
    const combobox = screen.getByRole('combobox');
    await userEvent.click(combobox);
    await userEvent.type(combobox, 'INVALID_NAME');
    expect(screen.queryByText('Change Contingency?')).not.toBeInTheDocument();
  });

  it('surfaces an analysis stream error in the status toast (QW4)', async () => {
    await renderAndLoadStudy();
    await selectBranch('BRANCH_A');

    // Step 2 emits an `error` event. `useAnalysis` records it on its OWN
    // `error` state (not `infoMessage`), which nothing rendered before
    // QW4; the fix wires that hook error into the shared StatusToasts
    // channel so an analysis failure is no longer a silent dead spinner.
    const errStream = new ReadableStream({
      start(controller) {
        controller.enqueue(new TextEncoder().encode(
          JSON.stringify({ type: 'error', message: 'Backend crashed' }) + '\n'
        ));
        controller.close();
      },
    });
    mockApi.runAnalysisStep2Stream.mockResolvedValue({ ok: true, body: errStream });

    await act(async () => {
      await userEvent.click(screen.getByText('🔍 Analyze & Suggest'));
    });

    expect(
      await screen.findByText(/Analysis failed: Backend crashed/, {}, { timeout: 3000 })
    ).toBeInTheDocument();
  });

  it('dismisses a surfaced analysis error when the contingency is cleared (QW4)', async () => {
    await renderAndLoadStudy();
    await selectBranch('BRANCH_A');

    const errStream = new ReadableStream({
      start(controller) {
        controller.enqueue(new TextEncoder().encode(
          JSON.stringify({ type: 'error', message: 'Backend crashed' }) + '\n'
        ));
        controller.close();
      },
    });
    mockApi.runAnalysisStep2Stream.mockResolvedValue({ ok: true, body: errStream });
    await act(async () => {
      await userEvent.click(screen.getByText('🔍 Analyze & Suggest'));
    });
    await screen.findByText(/Analysis failed: Backend crashed/, {}, { timeout: 3000 });

    // Clearing the contingency must also clear the analysis-hook error —
    // otherwise the failure banner would outlive the state it describes.
    await act(async () => {
      await userEvent.click(screen.getByTestId('sidebar-summary-clear'));
    });
    const maybeConfirm = screen.queryByText('Confirm');
    if (maybeConfirm) {
      await act(async () => { await userEvent.click(maybeConfirm); });
    }

    await waitFor(() => {
      expect(screen.queryByText(/Analysis failed: Backend crashed/)).not.toBeInTheDocument();
    }, { timeout: 3000 });
  });
});

describe('Overload Clearing Logic', () => {
  beforeEach(() => {
    cleanup();
    vi.clearAllMocks();
    Object.values(mockApi).forEach(m => {
      if (vi.isMockFunction(m)) m.mockReset();
    });
    // Restore defaults after reset
    mockApi.updateConfig.mockResolvedValue({ monitored_lines_count: 10, total_lines_count: 10 });
    mockApi.getBranches.mockResolvedValue({ branches: ['BRANCH_A', 'BRANCH_B', 'BRANCH_C'], name_map: {} });
    mockApi.getVoltageLevels.mockResolvedValue({ voltage_levels: ['VL1', 'VL2'], name_map: {} });
    mockApi.getNominalVoltages.mockResolvedValue({ mapping: {}, unique_kv: [63, 225] });
    mockApi.getVoltageLevelSubstations.mockResolvedValue({ mapping: {} });
    mockApi.getNetworkDiagram.mockResolvedValue({ svg: '<svg></svg>', metadata: null });
    mockApi.getContingencyDiagram.mockResolvedValue({ svg: '<svg></svg>', metadata: null, lines_overloaded: [] });
    mockApi.runAnalysisStep1.mockResolvedValue({ can_proceed: true, lines_overloaded: ['LINE_OL1'] });
    mockApi.getActionVariantDiagram.mockResolvedValue({ svg: '<svg></svg>', metadata: null });

    localStorage.clear();
    vi.unstubAllGlobals();
  });

  it('clears overloads from UI components immediately upon contingency change confirmation', async () => {
    await renderAndLoadStudy();

    // 1. Select BRANCH_A with overloads
    mockApi.getContingencyDiagram.mockResolvedValueOnce({
      svg: '<svg></svg>',
      lines_overloaded: ['OL_1', 'OL_2']
    });
    await pickBranch('BRANCH_A');
    await triggerContingency();

    // The banner echoes both overloads as soon as the N-1 diagram
    // fetch resolves (sticky strip rendered by SidebarSummary).
    await waitFor(() => {
      const banner = screen.getByTestId('sidebar-summary-overloads');
      expect(banner.textContent).toMatch(/OL_1/);
      expect(banner.textContent).toMatch(/OL_2/);
    }, { timeout: 3000 });

    // Run analysis to create state
    await runAnalysis();

    // 2. Click the banner Clear shortcut — the confirmation dialog
    // appears because analysis state would otherwise be lost.
    await act(async () => {
      await userEvent.click(screen.getByTestId('sidebar-summary-clear'));
    });
    await waitFor(() => {
      expect(screen.getByText('Change Contingency?')).toBeInTheDocument();
    }, { timeout: 3000 });

    // 3. Confirm change
    fireEvent.click(screen.getByText('Confirm'));

    // 4. VERIFY IMMEDIATE CLEAR — banner falls back to the empty
    // state (no contingency, no overloads), and the picker reappears.
    await waitFor(() => {
      expect(screen.queryByTestId('sidebar-summary-overloads')).not.toBeInTheDocument();
      expect(screen.getByText('⚡ Select Contingency')).toBeInTheDocument();
    });
  });

  it('clears ActionFeed overloads immediately when starting a new analysis', async () => {
    await renderAndLoadStudy();

    // Ensure analysis finds overloads
    mockApi.runAnalysisStep1.mockResolvedValue({ can_proceed: true, lines_overloaded: ['OL_1'] });

    await selectBranch('BRANCH_A');
    await runAnalysis();

    // Verify initial overloads in feed
    await waitFor(() => {
      expect(screen.getByTestId('action-feed')).toHaveAttribute('data-ol-count', '1');
    }, { timeout: 3000 });

    // Start new analysis. Slow it down to catch the 0 state.
    let resolveStep1: (val: { can_proceed: boolean; lines_overloaded: string[] }) => void;
    const slowStep1 = new Promise<{ can_proceed: boolean; lines_overloaded: string[] }>(resolve => { resolveStep1 = resolve; });
    mockApi.runAnalysisStep1.mockReturnValue(slowStep1 as Promise<{ can_proceed: boolean; lines_overloaded: string[] }>);

    await userEvent.click(screen.getByText('🔍 Analyze & Suggest'));

    // VERIFY IMMEDIATE CLEAR in ActionFeed
    await waitFor(() => {
      expect(screen.getByTestId('action-feed')).toHaveAttribute('data-ol-count', '0');
    }, { timeout: 3000 });

    // Cleanup
    resolveStep1!({ can_proceed: true, lines_overloaded: [] });
  });

  it('preserves N-1 diagram in VisualizationPanel when running analysis (regression test)', async () => {
    await renderAndLoadStudy();
    await selectBranch('BRANCH_A');

    // Initially, N-1 diagram should be present
    await waitFor(() => {
      expect(screen.getByTestId('visualization-panel')).toHaveAttribute('data-n1-diagram-present', 'true');
    });

    // Run analysis (which used to trigger clearContingencyState and wipe the diagram)
    await runAnalysis();

    // VERIFY: N-1 diagram is STILL present in the panel
    expect(screen.getByTestId('visualization-panel')).toHaveAttribute('data-n1-diagram-present', 'true');
  });
  it('pins contingency + overloads while only the action feed scrolls', async () => {
    await renderAndLoadStudy();
    await selectBranch('BRANCH_A');

    // The sidebar itself no longer scrolls — it hosts a non-scrolling
    // sticky header (contingency + overloads) and a scrolling body
    // (the ActionFeed). Scrolling down in actions must leave the
    // contingency and overload information fully visible.
    const sidebar = await screen.findByTestId('sidebar');
    expect(sidebar).toBeInTheDocument();
    expect(sidebar).toHaveStyle({ overflow: 'hidden' });

    // The ActionFeed is inside a scrolling wrapper that takes the
    // remaining sidebar height. It only mounts after a contingency
    // has been committed, mirroring the new sidebar visibility gate.
    const sidebarActionsHeader = await within(sidebar).findByTestId('action-feed-header');
    const scrollWrapper = sidebarActionsHeader.closest('div[style*="overflow-y: auto"]');
    expect(scrollWrapper).toBeInTheDocument();
    expect(scrollWrapper).toHaveStyle({ overflowY: 'auto' });
  });

  it('switches to overflow tab as soon as PDF event is received (regression test)', async () => {
    await renderAndLoadStudy();
    await selectBranch('BRANCH_A');

    // Mock a streaming response that yields a PDF event first, then delays the result
    let resolveStream: (value: void) => void;
    const streamDelay = new Promise<void>(resolve => { resolveStream = resolve; });

    const mockStream = new ReadableStream({
      async start(controller) {
        const encoder = new TextEncoder();
        // 1. Send PDF event
        controller.enqueue(encoder.encode(
          JSON.stringify({ type: 'pdf', pdf_url: '/results/pdf/graph.pdf', pdf_path: '/tmp/graph.pdf' }) + '\n'
        ));
        
        // Wait for a bit to simulate processing delay before the final result
        await streamDelay;

        // 2. Send Result event
        controller.enqueue(encoder.encode(
          JSON.stringify({ type: 'result', actions: {}, lines_overloaded: ['LINE_OL1'], message: 'done', dc_fallback: false }) + '\n'
        ));
        controller.close();
      },
    });

    mockApi.runAnalysisStep2Stream.mockResolvedValue({
      ok: true,
      body: mockStream,
    });

    // Start analysis
    const runBtn = screen.getByText('🔍 Analyze & Suggest');
    await act(async () => {
      await userEvent.click(runBtn);
    });

    // VERIFY: Tab should have switched to 'overflow' immediately after PDF event, 
    // even though the stream is still pending (resolveStream not called yet).
    await waitFor(() => {
      expect(screen.getByTestId('visualization-panel')).toHaveAttribute('data-active-tab', 'overflow');
    }, { timeout: 3000 });

    // Now complete the stream
    await act(async () => {
      resolveStream();
    });

    // Final result should now be processed
    await waitFor(() => {
      expect(screen.queryByText(/Display.*prioritized actions/)).toBeInTheDocument();
    });
  });
});

// Regression: when the user selects a contingency, the N-1 diagram comes
// back from the backend with `lines_overloaded` already populated. Those
// overloaded lines must be picked up by the app state IMMEDIATELY — and
// fed into the highlight pipeline — without waiting for the user to run
// "Analyze & Suggest". The previous implementation only sourced highlight
// data from `result.lines_overloaded` (set post-analysis), so the orange
// halos never appeared on the freshly-loaded N-1 view. The pure
// computation that selects which lines to highlight is unit-tested in
// `src/utils/overloadHighlights.test.ts`. The integration assertions
// here verify the data flows through App state into the panels that
// drive that computation, even before any analysis has run.
describe('N-1 overload state is populated before action analysis', () => {
  beforeEach(() => {
    cleanup();
    vi.clearAllMocks();
    Object.values(mockApi).forEach(m => {
      if (vi.isMockFunction(m)) m.mockReset();
    });
    mockApi.updateConfig.mockResolvedValue({ monitored_lines_count: 10, total_lines_count: 10 });
    mockApi.getBranches.mockResolvedValue({ branches: ['BRANCH_A', 'BRANCH_B', 'BRANCH_C'], name_map: {} });
    mockApi.getVoltageLevels.mockResolvedValue({ voltage_levels: ['VL1', 'VL2'], name_map: {} });
    mockApi.getNominalVoltages.mockResolvedValue({ mapping: {}, unique_kv: [63, 225] });
    mockApi.getVoltageLevelSubstations.mockResolvedValue({ mapping: {} });
    mockApi.getNetworkDiagram.mockResolvedValue({ svg: '<svg></svg>', metadata: null });
    mockApi.getContingencyDiagram.mockResolvedValue({ svg: '<svg></svg>', metadata: null, lines_overloaded: [] });
    localStorage.clear();
    vi.unstubAllGlobals();
  });

  it('exposes N-1 overloads + a default selection set as soon as the N-1 diagram loads (no analysis yet)', async () => {
    mockApi.getContingencyDiagram.mockResolvedValueOnce({
      svg: '<svg></svg>',
      metadata: null,
      lines_overloaded: ['LINE_OL_A', 'LINE_OL_B'],
    });

    await renderAndLoadStudy();
    await selectBranch('BRANCH_A');

    // The sticky banner reflects the two overloads from the N-1
    // diagram fetch — and they are auto-selected (rendered as blue
    // dotted-underlined links, the SidebarSummary convention) so the
    // computeN1OverloadHighlights helper will return them as the
    // highlight set on the very next render of the N-1 tab.
    await waitFor(() => {
      const banner = screen.getByTestId('sidebar-summary-overloads');
      expect(banner.textContent).toMatch(/LINE_OL_A/);
      expect(banner.textContent).toMatch(/LINE_OL_B/);
    });

    // No analysis was run, so ``result.lines_overloaded`` is empty —
    // but the ActionFeed falls back to the N-1 diagram's authoritative
    // overload list so manual-simulation action cards display the
    // friendly pypowsybl identifiers (e.g. ``BEON L31CPVAN``) instead
    // of grid2op's synthetic ``line_<i>`` strings the backend emits
    // when no ``_analysis_context`` is set yet. See the App.tsx wiring
    // on the ``ActionFeed.linesOverloaded`` prop.
    expect(screen.getByTestId('action-feed')).toHaveAttribute('data-ol-count', '2');
    // The fallback delivers the exact friendly-name list — same
    // identifiers the OverloadPanel above is wired to — so the
    // ``ActionCard.linesOverloaded[i]`` lookup in the "Overload
    // loading after" row resolves through ``displayName`` cleanly.
    expect(screen.getByTestId('action-feed')).toHaveAttribute(
      'data-ol-names',
      'LINE_OL_A|LINE_OL_B',
    );
  });

  // Companion to the test above: once an analysis result is present,
  // ``result.lines_overloaded`` MUST win over the ``n1Diagram``
  // fallback — the step2 stream reports the resolved-and-filtered set
  // (post monitoring deselect / additional-lines etc), and that
  // authority beats the raw N-1 diagram scan. Pins both directions of
  // the App.tsx ternary on the ``ActionFeed.linesOverloaded`` prop.
  it('prefers result.lines_overloaded over the n1Diagram fallback once analysis has populated it', async () => {
    mockApi.getContingencyDiagram.mockResolvedValueOnce({
      svg: '<svg></svg>',
      metadata: null,
      // Raw n1Diagram overload scan — what the fallback would surface
      // if the analysis result were empty. TWO entries.
      lines_overloaded: ['LINE_OL_A', 'LINE_OL_B'],
    });
    // Step1 filters the set down to a single resolved overload.
    mockApi.runAnalysisStep1.mockResolvedValue({
      can_proceed: true,
      lines_overloaded: ['LINE_OL_A'],
    });

    await renderAndLoadStudy();
    await selectBranch('BRANCH_A');
    // Confirm the fallback fires pre-analysis (n1Diagram count = 2).
    await waitFor(() => {
      expect(screen.getByTestId('action-feed')).toHaveAttribute('data-ol-count', '2');
    });

    // Drive the full step1 → step2 stream → display-prioritized
    // path so ``result.lines_overloaded`` becomes non-empty.
    await runAnalysis();

    // The analysis-result list wins — single ``LINE_OL1`` from the
    // stream mock above, not the two-entry n1Diagram fallback.
    await waitFor(() => {
      expect(screen.getByTestId('action-feed')).toHaveAttribute('data-ol-count', '1');
      expect(screen.getByTestId('action-feed')).toHaveAttribute(
        'data-ol-names',
        'LINE_OL1',
      );
    });
  });

  // Regression: a useEffect in App.tsx re-seeds selectedOverloads to the
  // full n1Diagram.lines_overloaded list when the analysis memo refreshes.
  // The original implementation compared against the live selectedOverloads
  // set, so every user toggle retriggered the effect and re-added the
  // overload that was just removed ("blinks but does not switch to
  // unselected"). Lock in the fix: deselecting an overload must persist.
  it('preserves user-deselected overloads across re-renders (info bubble unselect)', async () => {
    mockApi.getContingencyDiagram.mockResolvedValueOnce({
      svg: '<svg></svg>',
      metadata: null,
      lines_overloaded: ['LINE_OL_A', 'LINE_OL_B'],
    });

    await renderAndLoadStudy();
    await selectBranch('BRANCH_A');

    // Open the info bubble next to the Overloads banner label —
    // the popover hosts the per-overload monitoring toggles. Both
    // overloads start checked (auto-selected) so both checkboxes
    // are initially ticked.
    await waitFor(() => screen.getByTestId('sidebar-summary-overloads-bubble'));
    await act(async () => {
      screen.getByTestId('sidebar-summary-overloads-bubble').click();
    });
    const popover = await screen.findByTestId('overload-info-popover');
    const toggleA = within(popover).getByTestId('overload-info-toggle-LINE_OL_A')
      .querySelector('input[type="checkbox"]') as HTMLInputElement;
    const toggleB = within(popover).getByTestId('overload-info-toggle-LINE_OL_B')
      .querySelector('input[type="checkbox"]') as HTMLInputElement;
    expect(toggleA.checked).toBe(true);
    expect(toggleB.checked).toBe(true);

    await act(async () => { toggleA.click(); });
    await waitFor(() => {
      expect((within(screen.getByTestId('overload-info-popover'))
        .getByTestId('overload-info-toggle-LINE_OL_A')
        .querySelector('input[type="checkbox"]') as HTMLInputElement).checked).toBe(false);
    });

    // Force an additional render cycle — the buggy effect retriggered on
    // every analysis-memo refresh and re-added the deselected overload.
    await act(async () => {
      (within(screen.getByTestId('overload-info-popover'))
        .getByTestId('overload-info-toggle-LINE_OL_B')
        .querySelector('input[type="checkbox"]') as HTMLInputElement).click();
    });

    await waitFor(() => {
      expect((within(screen.getByTestId('overload-info-popover'))
        .getByTestId('overload-info-toggle-LINE_OL_A')
        .querySelector('input[type="checkbox"]') as HTMLInputElement).checked).toBe(false);
      expect((within(screen.getByTestId('overload-info-popover'))
        .getByTestId('overload-info-toggle-LINE_OL_B')
        .querySelector('input[type="checkbox"]') as HTMLInputElement).checked).toBe(false);
    });
  });

  it('replaces the overload selection when switching contingencies without running analysis', async () => {
    mockApi.getContingencyDiagram.mockResolvedValueOnce({
      svg: '<svg></svg>',
      metadata: null,
      lines_overloaded: ['LINE_OL_A', 'LINE_OL_B'],
    });

    await renderAndLoadStudy();
    await selectBranch('BRANCH_A');

    await waitFor(() => {
      const banner = screen.getByTestId('sidebar-summary-overloads');
      expect(banner.textContent).toMatch(/LINE_OL_A/);
      expect(banner.textContent).toMatch(/LINE_OL_B/);
    });

    mockApi.getContingencyDiagram.mockResolvedValueOnce({
      svg: '<svg></svg>',
      metadata: null,
      lines_overloaded: ['LINE_OL_C'],
    });

    // pickBranch automatically routes through the Clear shortcut
    // (no analysis state → no confirmation), so the new contingency
    // is the lone BRANCH_B rather than the legacy ['A', 'B'] append.
    await pickBranch('BRANCH_B');
    await triggerContingency();

    await waitFor(() => {
      expect(mockApi.getContingencyDiagram).toHaveBeenCalledWith(['BRANCH_B']);
      const banner = screen.getByTestId('sidebar-summary-overloads');
      expect(banner.textContent).toMatch(/LINE_OL_C/);
      expect(banner.textContent).not.toMatch(/LINE_OL_A/);
      expect(banner.textContent).not.toMatch(/LINE_OL_B/);
    });
  });
});
