import { act, render, screen } from '@testing-library/react';
import { useEffect, useRef, useState } from 'react';

import {
  type DataQueryError,
  DataSourceApi,
  type DataSourceJsonData,
  type DataQueryResponse,
  getDefaultTimeRange,
  LoadingState,
  type TestDataSourceResponse,
} from '@grafana/data';
import { VizPanel } from '@grafana/scenes';
import { type DataQuery } from '@grafana/schema';

import { QueryEditorType } from '../constants';

import { QueryEditorProvider } from './QueryEditorContext';
import { QueryEditorRenderer } from './QueryEditorRenderer';
import {
  ds1SettingsMock,
  mockActions,
  mockQueryOptionsState,
  mockTransformToggles,
  mockTypeConfig,
  mockUIStateBase,
  renderWithQueryEditorProvider,
} from './testUtils';

jest.mock('app/features/query/components/QueryEditorRow', () => ({
  filterPanelDataToQuery: jest.fn(() => undefined),
}));

jest.mock('app/features/query/components/QueryErrorAlert', () => ({
  QueryErrorAlert: ({ error }: { error: DataQueryError }) => <div data-testid="query-error-alert">{error.message}</div>,
}));

interface TestQuery extends DataQuery {
  legendFormat?: string;
}

function getLegendFormat(query: DataQuery): string {
  return 'legendFormat' in query && typeof query.legendFormat === 'string' ? query.legendFormat : '';
}

// Fake query editor that simulates an uncontrolled input — it initialises its
// display value from props.query on mount and never syncs again.
function UncontrolledQueryEditor({ query }: { query: DataQuery }) {
  const [legend] = useState(getLegendFormat(query));
  return <div data-testid="query-editor-legend">{legend}</div>;
}

class MockDataSourceApi extends DataSourceApi<DataQuery, DataSourceJsonData> {
  constructor(components: DataSourceApi<DataQuery, DataSourceJsonData>['components']) {
    super(ds1SettingsMock);
    this.components = components;
  }

  query(): Promise<DataQueryResponse> {
    return Promise.resolve({ data: [] });
  }

  testDatasource(): Promise<TestDataSourceResponse> {
    return Promise.resolve({ status: 'success', message: 'OK' });
  }
}

const selectedQueryDsData = {
  datasource: new MockDataSourceApi({ QueryEditor: UncontrolledQueryEditor }),
  dsSettings: ds1SettingsMock,
};

const queryA: TestQuery = { refId: 'A', legendFormat: 'series-a' };
const queryB: TestQuery = { refId: 'B', legendFormat: 'series-b' };

function renderRenderer(
  selectedQuery: DataQuery | null,
  uiStateOverrides: NonNullable<Parameters<typeof renderWithQueryEditorProvider>[1]>['uiStateOverrides'] = {}
) {
  return renderWithQueryEditorProvider(<QueryEditorRenderer />, {
    queries: [queryA, queryB],
    selectedQuery,
    uiStateOverrides: { selectedQueryDsData, ...uiStateOverrides },
  });
}

describe('QueryEditorRenderer', () => {
  it('renders nothing when no query is selected', () => {
    renderRenderer(null);
    expect(screen.queryByTestId('query-editor-legend')).not.toBeInTheDocument();
  });

  it('shows a loading spinner while the datasource is loading', () => {
    renderRenderer(queryA, { selectedQueryDsLoading: true, selectedQueryDsData: null });
    expect(screen.getByText(/loading datasource/i)).toBeInTheDocument();
  });

  it('shows an actionable error when the datasource fails to load', () => {
    renderRenderer(queryA, { selectedQueryDsData: null });
    expect(screen.getByText(/failed to load datasource for this query/i)).toBeInTheDocument();
    expect(screen.getByText(/select a datasource for this query/i)).toBeInTheDocument();
  });

  it('renders the query editor for the selected query', () => {
    renderRenderer(queryA);
    expect(screen.getByTestId('query-editor-legend')).toHaveTextContent('series-a');
  });

  it('contains errors thrown by the datasource query editor', () => {
    const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();

    function ThrowingQueryEditor(): never {
      throw new Error('Query editor crashed');
    }

    try {
      renderRenderer(queryA, {
        selectedQueryDsData: {
          datasource: new MockDataSourceApi({ QueryEditor: ThrowingQueryEditor }),
          dsSettings: ds1SettingsMock,
        },
      });

      expect(screen.getByText('An unexpected error happened')).toBeInTheDocument();
      expect(screen.getByText(/Query editor crashed/)).toBeInTheDocument();
    } finally {
      consoleErrorSpy.mockRestore();
    }
  });

  it('remounts the query editor when switching queries, resetting uncontrolled input state', () => {
    // Regression test: datasource plugin editors (e.g. Loki) use uncontrolled
    // inputs (defaultValue) for options like Legend. Without a `key` prop on
    // the QueryEditorComponent, React reuses the mounted instance when
    // switching queries and the DOM values stay stale from the previous query.
    //
    // Example: user edits Legend on query A → "foo", switches to query B which
    // has legendFormat "bar" — without the fix the Legend field still shows "foo".

    // RTL's rerender requires a full ReactElement, so we construct the provider
    // tree directly here rather than going through renderWithQueryEditorProvider.
    function buildJsx(selectedQuery: DataQuery) {
      return (
        <QueryEditorProvider
          dsState={{ datasource: undefined, dsSettings: undefined, dsError: undefined }}
          qrState={{ queries: [queryA, queryB], data: undefined, queryError: undefined }}
          panelState={{ panel: new VizPanel({ key: 'panel-1' }), transformations: [] }}
          alertingState={{ alertRules: [], loading: false, isDashboardSaved: true }}
          uiState={{
            selectedQuery,
            selectedTransformation: null,
            selectedAlert: null,
            setSelectedQuery: jest.fn(),
            setSelectedTransformation: jest.fn(),
            setSelectedAlert: jest.fn(),
            queryOptions: mockQueryOptionsState,
            selectedQueryDsData,
            selectedQueryDsLoading: false,
            showingDatasourceHelp: false,
            toggleDatasourceHelp: jest.fn(),
            transformToggles: mockTransformToggles,
            cardType: QueryEditorType.Query,
            pendingExpression: null,
            setPendingExpression: jest.fn(),
            finalizePendingExpression: jest.fn(),
            pendingTransformation: null,
            setPendingTransformation: jest.fn(),
            finalizePendingTransformation: jest.fn(),
            pendingSavedQuery: null,
            setPendingSavedQuery: jest.fn(),
            showVersionBanner: false,
            selectedQueryRefIds: [],
            selectedTransformationIds: [],
            multiSelectMode: false,
            setMultiSelectMode: jest.fn(),
            toggleQuerySelection: jest.fn(),
            toggleTransformationSelection: jest.fn(),
            clearSelection: jest.fn(),
            stackedMode: mockUIStateBase.stackedMode,
            confirmingDeleteActionKey: null,
            setConfirmingDeleteActionKey: jest.fn(),
          }}
          actions={mockActions}
          typeConfig={mockTypeConfig}
        >
          <QueryEditorRenderer />
        </QueryEditorProvider>
      );
    }

    const { rerender } = render(buildJsx(queryA));
    expect(screen.getByTestId('query-editor-legend')).toHaveTextContent('series-a');

    rerender(buildJsx(queryB));

    // Must show series-b, not the stale series-a value from query A's editor instance
    expect(screen.getByTestId('query-editor-legend')).toHaveTextContent('series-b');
  });

  it('applies onChange called from a useEffect cleanup when switching away from a query', async () => {
    const updateSelectedQuery = jest.fn();

    // Editor that flushes a pending edit via onChange in its unmount cleanup.
    function CleanupOnChangeEditor({ query, onChange }: { query: DataQuery; onChange: (q: DataQuery) => void }) {
      const pendingEdit = { ...query, legendFormat: `${getLegendFormat(query)}-edited` };
      const pendingEditRef = useRef(pendingEdit);
      pendingEditRef.current = pendingEdit;
      const onChangeRef = useRef(onChange);
      onChangeRef.current = onChange;

      useEffect(() => {
        return () => onChangeRef.current(pendingEditRef.current);
      }, []);

      return <div data-testid="cleanup-editor">{getLegendFormat(query)}</div>;
    }

    const mockDatasourceWithCleanup = new MockDataSourceApi({ QueryEditor: CleanupOnChangeEditor });

    function buildJsx(selectedQuery: DataQuery) {
      return (
        <QueryEditorProvider
          dsState={{ datasource: undefined, dsSettings: undefined, dsError: undefined }}
          qrState={{ queries: [queryA, queryB], data: undefined, queryError: undefined }}
          panelState={{ panel: new VizPanel({ key: 'panel-1' }), transformations: [] }}
          alertingState={{ alertRules: [], loading: false, isDashboardSaved: true }}
          uiState={{
            selectedQuery,
            selectedTransformation: null,
            selectedAlert: null,
            setSelectedQuery: jest.fn(),
            setSelectedTransformation: jest.fn(),
            setSelectedAlert: jest.fn(),
            queryOptions: mockQueryOptionsState,
            selectedQueryDsData: {
              datasource: mockDatasourceWithCleanup,
              dsSettings: ds1SettingsMock,
            },
            selectedQueryDsLoading: false,
            showingDatasourceHelp: false,
            toggleDatasourceHelp: jest.fn(),
            transformToggles: mockTransformToggles,
            cardType: QueryEditorType.Query,
            pendingExpression: null,
            setPendingExpression: jest.fn(),
            finalizePendingExpression: jest.fn(),
            pendingTransformation: null,
            setPendingTransformation: jest.fn(),
            finalizePendingTransformation: jest.fn(),
            pendingSavedQuery: null,
            setPendingSavedQuery: jest.fn(),
            showVersionBanner: false,
            selectedQueryRefIds: [],
            selectedTransformationIds: [],
            multiSelectMode: false,
            setMultiSelectMode: jest.fn(),
            toggleQuerySelection: jest.fn(),
            toggleTransformationSelection: jest.fn(),
            clearSelection: jest.fn(),
            stackedMode: mockUIStateBase.stackedMode,
            confirmingDeleteActionKey: null,
            setConfirmingDeleteActionKey: jest.fn(),
          }}
          actions={{ ...mockActions, updateSelectedQuery }}
          typeConfig={mockTypeConfig}
        >
          <QueryEditorRenderer />
        </QueryEditorProvider>
      );
    }

    const { rerender } = render(buildJsx(queryA));

    await act(async () => {
      rerender(buildJsx(queryB));
    });

    expect(updateSelectedQuery).toHaveBeenCalledWith(
      expect.objectContaining({ refId: 'A', legendFormat: 'series-a-edited' }),
      'A'
    );
  });

  it('shows an error when the query has an error', () => {
    renderWithQueryEditorProvider(<QueryEditorRenderer />, {
      queries: [queryA, queryB],
      selectedQuery: queryA,
      uiStateOverrides: { selectedQueryDsData },
      qrState: {
        data: {
          state: LoadingState.Error,
          series: [],
          timeRange: getDefaultTimeRange(),
          errors: [{ message: 'Error!!', refId: queryA.refId }],
        },
      },
    });

    expect(screen.getByText('Error!!')).toBeInTheDocument();
  });
});
