import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ComponentProps } from 'react';
import { DatasourceSrvMock, MockDataSourceApi } from 'test/mocks/datasource_srv';

import {
  LoadingState,
  createDataFrame,
  FieldType,
  LogsSortOrder,
  CoreApp,
  getDefaultTimeRange,
  LogsDedupStrategy,
  EventBusSrv,
  DataFrameType,
  LogSortOrderChangeEvent,
} from '@grafana/data';
import { getAppEvents } from '@grafana/runtime';
import * as grafanaUI from '@grafana/ui';
import * as styles from 'app/features/logs/components/getLogRowStyles';
import { LogRowContextModal } from 'app/features/logs/components/log-context/LogRowContextModal';

import { LogsPanel } from './LogsPanel';

type LogsPanelProps = ComponentProps<typeof LogsPanel>;
type LogRowContextModalProps = ComponentProps<typeof LogRowContextModal>;

const logRowContextModalMock = jest.fn().mockReturnValue(<div>LogRowContextModal</div>);
jest.mock('app/features/logs/components/log-context/LogRowContextModal', () => ({
  LogRowContextModal: (props: LogRowContextModalProps) => logRowContextModalMock(props),
}));

const defaultDs = new MockDataSourceApi('default datasource', { data: ['default data'] });
const noShowContextDs = new MockDataSourceApi('no-show-context');
const showContextDs = new MockDataSourceApi('show-context') as MockDataSourceApi & { getLogRowContext: jest.Mock };

const datasourceSrv = new DatasourceSrvMock(defaultDs, {
  'no-show-context': noShowContextDs,
  'show-context': showContextDs,
});
const getDataSourceSrvMock = jest.fn().mockReturnValue(datasourceSrv);
jest.mock('@grafana/runtime', () => ({
  ...jest.requireActual('@grafana/runtime'),
  getAppEvents: jest.fn(),
  getDataSourceSrv: () => getDataSourceSrvMock(),
}));

const hasLogsContextSupport = jest.fn().mockImplementation((ds) => {
  if (!ds) {
    return false;
  }
  return ds.name === 'show-context';
});
jest.mock('@grafana/data', () => ({
  ...jest.requireActual('@grafana/data'),
  hasLogsContextSupport: (ds: MockDataSourceApi) => hasLogsContextSupport(ds),
}));

const defaultProps = {
  data: {
    error: undefined,
    request: {
      panelId: 4,
      app: 'dashboard',
      requestId: 'A',
      timezone: 'browser',
      interval: '30s',
      intervalMs: 30000,
      maxDataPoints: 823,
      targets: [],
      range: getDefaultTimeRange(),
      scopedVars: {},
      startTime: 1,
    },
    series: [
      createDataFrame({
        refId: 'A',
        fields: [
          {
            name: 'timestamp',
            type: FieldType.time,
            values: ['2019-04-26T09:28:11.352440161Z'],
          },
          {
            name: 'body',
            type: FieldType.string,
            values: ['logline text'],
          },
          {
            name: 'labels',
            type: FieldType.other,
            values: [
              {
                app: 'common_app',
              },
            ],
          },
        ],
        meta: {
          type: DataFrameType.LogLines,
        },
      }),
    ],
    state: LoadingState.Done,
    timeRange: getDefaultTimeRange(),
  },
  timeZone: 'utc',
  timeRange: getDefaultTimeRange(),
  options: {
    showLabels: false,
    showTime: false,
    wrapLogMessage: false,
    showCommonLabels: false,
    prettifyLogMessage: false,
    sortOrder: LogsSortOrder.Descending,
    dedupStrategy: LogsDedupStrategy.none,
    enableLogDetails: false,
    showLogContextToggle: false,
  },
  title: 'Logs panel',
  id: 1,
  transparent: false,
  width: 400,
  height: 100,
  renderCounter: 0,
  fieldConfig: {
    defaults: {},
    overrides: [],
  },
  eventBus: new EventBusSrv(),
  onOptionsChange: jest.fn(),
  onFieldConfigChange: jest.fn(),
  replaceVariables: jest.fn(),
  onChangeTimeRange: jest.fn(),
};

const publishMock = jest.fn();
beforeAll(() => {
  jest.mocked(getAppEvents).mockReturnValue({
    publish: publishMock,
    getStream: jest.fn(),
    subscribe: jest.fn(),
    removeAllListeners: jest.fn(),
    newScopedBus: jest.fn(),
  });
});

describe('LogsPanel', () => {
  it('publishes an event with the current sort order', async () => {
    publishMock.mockClear();
    setup();

    await screen.findByText('logline text');

    expect(publishMock).toHaveBeenCalledTimes(1);
    expect(publishMock).toHaveBeenCalledWith(
      new LogSortOrderChangeEvent({
        order: LogsSortOrder.Descending,
      })
    );
  });

  describe('when returned series include common labels', () => {
    const seriesWithCommonLabels = [
      createDataFrame({
        fields: [
          {
            name: 'timestamp',
            type: FieldType.time,
            values: ['2019-04-26T09:28:11.352440161Z', '2019-04-26T14:42:50.991981292Z'],
          },
          {
            name: 'body',
            type: FieldType.string,
            values: [
              't=2019-04-26T11:05:28+0200 lvl=info msg="Initializing DatasourceCacheService" logger=server',
              't=2019-04-26T16:42:50+0200 lvl=eror msg="new token…t unhashed token=56d9fdc5c8b7400bd51b060eea8ca9d7',
            ],
          },
          {
            name: 'labels',
            type: FieldType.other,
            values: [
              {
                app: 'common_app',
                job: 'common_job',
              },
              {
                app: 'common_app',
                job: 'common_job',
              },
            ],
          },
        ],
        meta: {
          type: DataFrameType.LogLines,
        },
      }),
    ];

    it('shows common labels when showCommonLabels is set to true', async () => {
      setup({
        data: { ...defaultProps.data, series: seriesWithCommonLabels },
        options: { ...defaultProps.options, showCommonLabels: true },
      });

      expect(await screen.findByText(/common labels:/i)).toBeInTheDocument();
      expect(await screen.findByText(/common_app/i)).toBeInTheDocument();
      expect(await screen.findByText(/common_job/i)).toBeInTheDocument();
    });
    it('shows common labels on top when descending sort order', async () => {
      const { container } = setup({
        data: { ...defaultProps.data, series: seriesWithCommonLabels },
        options: { ...defaultProps.options, showCommonLabels: true, sortOrder: LogsSortOrder.Descending },
      });
      expect(await screen.findByText(/common labels:/i)).toBeInTheDocument();
      expect(container.firstChild?.childNodes[0].textContent).toMatch(/^Common labels:app=common_appjob=common_job/);
    });
    it('shows common labels on bottom when ascending sort order', async () => {
      const { container } = setup({
        data: { ...defaultProps.data, series: seriesWithCommonLabels },
        options: { ...defaultProps.options, showCommonLabels: true, sortOrder: LogsSortOrder.Ascending },
      });
      expect(await screen.findByText(/common labels:/i)).toBeInTheDocument();
      expect(container.firstChild?.childNodes[0].textContent).toMatch(/Common labels:app=common_appjob=common_job$/);
    });
    it('does not show common labels when showCommonLabels is set to false', async () => {
      setup({
        data: { ...defaultProps.data, series: seriesWithCommonLabels },
        options: { ...defaultProps.options, showCommonLabels: false },
      });

      await waitFor(async () => {
        expect(screen.queryByText(/common labels:/i)).toBeNull();
        expect(screen.queryByText(/common_app/i)).toBeNull();
        expect(screen.queryByText(/common_job/i)).toBeNull();
      });
    });
  });
  describe('when returned series does not include common labels', () => {
    const seriesWithoutCommonLabels = [
      createDataFrame({
        fields: [
          {
            name: 'timestamp',
            type: FieldType.time,
            values: ['2019-04-26T09:28:11.352440161Z', '2019-04-26T14:42:50.991981292Z'],
          },
          {
            name: 'body',
            type: FieldType.string,
            values: [
              't=2019-04-26T11:05:28+0200 lvl=info msg="Initializing DatasourceCacheService" logger=server',
              't=2019-04-26T16:42:50+0200 lvl=eror msg="new token…t unhashed token=56d9fdc5c8b7400bd51b060eea8ca9d7',
            ],
          },
        ],
        meta: {
          type: DataFrameType.LogLines,
        },
      }),
    ];
    it('shows (no common labels) when showCommonLabels is set to true', async () => {
      setup({
        data: { ...defaultProps.data, series: seriesWithoutCommonLabels },
        options: { ...defaultProps.options, showCommonLabels: true },
      });

      expect(await screen.findByText(/common labels:/i)).toBeInTheDocument();
      expect(await screen.findByText(/(no common labels)/i)).toBeInTheDocument();
    });
    it('does not show common labels when showCommonLabels is set to false', async () => {
      setup({
        data: { ...defaultProps.data, series: seriesWithoutCommonLabels },
        options: { ...defaultProps.options, showCommonLabels: false },
      });
      await waitFor(async () => {
        expect(screen.queryByText(/common labels:/i)).toBeNull();
        expect(screen.queryByText(/(no common labels)/i)).toBeNull();
      });
    });
  });

  describe('log context', () => {
    const series = [
      createDataFrame({
        refId: 'A',
        fields: [
          {
            name: 'timestamp',
            type: FieldType.time,
            values: ['2019-04-26T09:28:11.352440161Z', '2019-04-26T14:42:50.991981292Z'],
          },
          {
            name: 'body',
            type: FieldType.string,
            values: ['logline text', 'more text'],
          },
          {
            name: 'labels',
            type: FieldType.other,
            values: [
              {
                app: 'common_app',
                job: 'common_job',
              },
              {
                app: 'common_app',
                job: 'common_job',
              },
            ],
          },
        ],
        meta: {
          type: DataFrameType.LogLines,
        },
      }),
    ];

    beforeEach(() => {
      showContextDs.getLogRowContext = jest.fn().mockImplementation(() => {});
    });

    it('should not show the toggle if the datasource does not support show context', async () => {
      setup({
        data: {
          ...defaultProps.data,
          series,
          request: {
            ...defaultProps.data.request,
            app: CoreApp.Dashboard,
            targets: [{ refId: 'A', datasource: { uid: 'no-show-context' } }],
          },
        },
      });

      await waitFor(async () => {
        await userEvent.hover(screen.getByText(/logline text/i));
        expect(screen.queryByLabelText(/show context/i)).toBeNull();
      });
    });

    it('should show the toggle if the datasource does support show context', async () => {
      setup({
        data: {
          ...defaultProps.data,
          series,
          request: {
            ...defaultProps.data.request,
            app: CoreApp.Dashboard,
            targets: [{ refId: 'A', datasource: { uid: 'show-context' } }],
          },
        },
      });

      await waitFor(async () => {
        await userEvent.hover(screen.getByText(/logline text/i));
        expect(screen.getByLabelText(/show context/i)).toBeInTheDocument();
      });
    });

    it('should not show the toggle if the datasource does support show context but the app is not Dashboard', async () => {
      setup({
        data: {
          ...defaultProps.data,
          series,
          request: {
            ...defaultProps.data.request,
            app: CoreApp.CloudAlerting,
            targets: [{ refId: 'A', datasource: { uid: 'show-context' } }],
          },
        },
      });

      await waitFor(async () => {
        await userEvent.hover(screen.getByText(/logline text/i));
        expect(screen.queryByLabelText(/show context/i)).toBeNull();
      });
    });

    it('should render the mocked `LogRowContextModal` after click', async () => {
      setup({
        data: {
          ...defaultProps.data,
          series,
          request: {
            ...defaultProps.data.request,
            app: CoreApp.Dashboard,
            targets: [{ refId: 'A', datasource: { uid: 'show-context' } }],
          },
        },
      });
      await waitFor(async () => {
        await userEvent.hover(screen.getByText(/logline text/i));
        await userEvent.click(screen.getByLabelText(/show context/i));
        expect(screen.getByText(/LogRowContextModal/i)).toBeInTheDocument();
      });
    });

    it('should call `getLogRowContext` if the user clicks the show context toggle', async () => {
      setup({
        data: {
          ...defaultProps.data,
          series,
          request: {
            ...defaultProps.data.request,
            app: CoreApp.Dashboard,
            targets: [{ refId: 'A', datasource: { uid: 'show-context' } }],
          },
        },
      });
      await waitFor(async () => {
        await userEvent.hover(screen.getByText(/logline text/i));
        await userEvent.click(screen.getByLabelText(/show context/i));

        const getRowContextCb = logRowContextModalMock.mock.calls[0][0].getRowContext;
        getRowContextCb({}, {});
        expect(showContextDs.getLogRowContext).toBeCalled();
      });
    });

    it('supports adding custom options to the log row menu', async () => {
      const logRowMenuIconsBefore = [
        <grafanaUI.IconButton name="eye-slash" tooltip="Addon before" aria-label="Addon before" key={1} />,
      ];
      const logRowMenuIconsAfter = [
        <grafanaUI.IconButton name="rss" tooltip="Addon after" aria-label="Addon after" key={1} />,
      ];

      setup({
        data: {
          ...defaultProps.data,
          series,
        },
        options: {
          ...defaultProps.options,
          logRowMenuIconsBefore,
          logRowMenuIconsAfter,
        },
      });

      await waitFor(async () => {
        await userEvent.hover(screen.getByText(/logline text/i));
        expect(screen.getByLabelText('Addon before')).toBeInTheDocument();
        expect(screen.getByLabelText('Addon after')).toBeInTheDocument();
      });
    });
  });

  describe('Performance regressions', () => {
    const series = [
      createDataFrame({
        refId: 'A',
        fields: [
          {
            name: 'timestamp',
            type: FieldType.time,
            values: ['2019-04-26T09:28:11.352440161Z'],
          },
          {
            name: 'body',
            type: FieldType.string,
            values: ['logline text'],
          },
          {
            name: 'labels',
            type: FieldType.other,
            values: [
              {
                app: 'common_app',
                job: 'common_job',
              },
            ],
          },
        ],
        meta: {
          type: DataFrameType.LogLines,
        },
      }),
    ];

    beforeEach(() => {
      /**
       * For the lack of a better option, we spy on getLogRowStyles calls to count re-renders.
       */
      jest.spyOn(styles, 'getLogRowStyles');
      jest.mocked(styles.getLogRowStyles).mockClear();
    });

    it('does not rerender without changes', async () => {
      const { rerender, props } = setup({
        data: {
          ...defaultProps.data,
          series,
        },
      });

      expect(await screen.findByRole('row')).toBeInTheDocument();

      rerender(<LogsPanel {...props} />);

      expect(await screen.findByRole('row')).toBeInTheDocument();
      expect(styles.getLogRowStyles).toHaveBeenCalledTimes(3);
    });

    it('rerenders when prop changes', async () => {
      const { rerender, props } = setup({
        data: {
          ...defaultProps.data,
          series,
        },
      });

      expect(await screen.findByRole('row')).toBeInTheDocument();

      rerender(<LogsPanel {...props} data={{ ...props.data, series: [...series] }} />);

      expect(await screen.findByRole('row')).toBeInTheDocument();
      expect(jest.mocked(styles.getLogRowStyles).mock.calls.length).toBeGreaterThan(3);
    });

    it('does not re-render when data is loading', async () => {
      const { rerender, props } = setup({
        data: {
          ...defaultProps.data,
          series,
        },
      });

      expect(await screen.findByRole('row')).toBeInTheDocument();

      rerender(<LogsPanel {...props} data={{ ...props.data, state: LoadingState.Loading }} />);

      expect(await screen.findByRole('row')).toBeInTheDocument();
      expect(styles.getLogRowStyles).toHaveBeenCalledTimes(3);
    });
  });

  describe('Filters', () => {
    const series = [
      createDataFrame({
        refId: 'A',
        fields: [
          {
            name: 'timestamp',
            type: FieldType.time,
            values: ['2019-04-26T09:28:11.352440161Z'],
          },
          {
            name: 'body',
            type: FieldType.string,
            values: ['logline text'],
          },
          {
            name: 'labels',
            type: FieldType.other,
            values: [
              {
                app: 'common_app',
              },
            ],
          },
        ],
        meta: {
          type: DataFrameType.LogLines,
        },
      }),
    ];

    it('allows to filter for a value or filter out a value', async () => {
      const filterForMock = jest.fn();
      const filterOutMock = jest.fn();
      const isFilterLabelActiveMock = jest.fn();
      setup({
        data: {
          ...defaultProps.data,
          series,
        },
        options: {
          ...defaultProps.options,
          showLabels: false,
          showTime: false,
          wrapLogMessage: false,
          showCommonLabels: false,
          prettifyLogMessage: false,
          sortOrder: LogsSortOrder.Descending,
          dedupStrategy: LogsDedupStrategy.none,
          enableLogDetails: true,
          onClickFilterLabel: filterForMock,
          onClickFilterOutLabel: filterOutMock,
          isFilterLabelActive: isFilterLabelActiveMock,
        },
      });

      expect(await screen.findByRole('row')).toBeInTheDocument();

      await userEvent.click(screen.getByText('logline text'));
      await userEvent.click(screen.getByLabelText('Filter for value'));
      expect(filterForMock).toHaveBeenCalledTimes(1);
      await userEvent.click(screen.getByLabelText('Filter out value'));
      expect(filterOutMock).toHaveBeenCalledTimes(1);

      expect(isFilterLabelActiveMock).toHaveBeenCalledTimes(1);
    });

    describe('invalid handlers', () => {
      it('does not show the controls if onAddAdHocFilter is not defined', async () => {
        jest.spyOn(grafanaUI, 'usePanelContext').mockReturnValue({
          eventsScope: 'global',
          eventBus: new EventBusSrv(),
        });

        setup({
          data: {
            ...defaultProps.data,
            series,
          },
          options: {
            ...defaultProps.options,
            enableLogDetails: true,
          },
        });

        expect(await screen.findByRole('row')).toBeInTheDocument();

        await userEvent.click(screen.getByText('logline text'));

        expect(screen.queryByLabelText('Filter for value')).not.toBeInTheDocument();
        expect(screen.queryByLabelText('Filter out value')).not.toBeInTheDocument();
      });
      it('shows the controls if onAddAdHocFilter is defined', async () => {
        jest.spyOn(grafanaUI, 'usePanelContext').mockReturnValue({
          eventsScope: 'global',
          eventBus: new EventBusSrv(),
          onAddAdHocFilter: jest.fn(),
        });

        setup({
          data: {
            ...defaultProps.data,
            series,
          },
          options: {
            ...defaultProps.options,
            enableLogDetails: true,
          },
        });

        expect(await screen.findByRole('row')).toBeInTheDocument();

        await userEvent.click(screen.getByText('logline text'));

        expect(await screen.findByText('common_app')).toBeInTheDocument();

        expect(screen.getByLabelText('Filter for value')).toBeInTheDocument();
        expect(screen.getByLabelText('Filter out value')).toBeInTheDocument();
      });
    });
  });

  describe('Show/hide fields', () => {
    const series = [
      createDataFrame({
        refId: 'A',
        fields: [
          {
            name: 'timestamp',
            type: FieldType.time,
            values: ['2019-04-26T09:28:11.352440161Z'],
          },
          {
            name: 'body',
            type: FieldType.string,
            values: ['logline text'],
          },
          {
            name: 'labels',
            type: FieldType.other,
            values: [
              {
                app: 'common_app',
              },
            ],
          },
        ],
        meta: {
          type: DataFrameType.LogLines,
        },
      }),
    ];

    it('displays the provided fields instead of the log line', async () => {
      setup({
        data: {
          ...defaultProps.data,
          series,
        },
        options: {
          ...defaultProps.options,
          showLabels: false,
          showTime: false,
          wrapLogMessage: false,
          showCommonLabels: false,
          prettifyLogMessage: false,
          sortOrder: LogsSortOrder.Descending,
          dedupStrategy: LogsDedupStrategy.none,
          enableLogDetails: true,
          displayedFields: ['app'],
          onClickHideField: undefined,
          onClickShowField: undefined,
        },
      });

      expect(await screen.findByRole('row')).toBeInTheDocument();
      expect(screen.queryByText('logline text')).not.toBeInTheDocument();

      await userEvent.click(screen.getByText('app=common_app'));

      expect(screen.getByLabelText('Hide this field')).toBeInTheDocument();

      await userEvent.click(screen.getByLabelText('Hide this field'));

      expect(screen.getByText('logline text')).toBeInTheDocument();
    });

    it('updates the provided fields instead of the log line', async () => {
      const { rerender, props } = setup({
        data: {
          ...defaultProps.data,
          series,
        },
        options: {
          ...defaultProps.options,
          showLabels: false,
          showTime: false,
          wrapLogMessage: false,
          showCommonLabels: false,
          prettifyLogMessage: false,
          sortOrder: LogsSortOrder.Descending,
          dedupStrategy: LogsDedupStrategy.none,
          enableLogDetails: true,
          onClickHideField: undefined,
          onClickShowField: undefined,
        },
      });

      expect(await screen.findByRole('row')).toBeInTheDocument();
      expect(screen.getByText('logline text')).toBeInTheDocument();

      rerender(<LogsPanel {...props} options={{ ...props.options, displayedFields: ['app'] }} />);

      expect(screen.getByText('app=common_app')).toBeInTheDocument();
    });

    it('enables the behavior with a default implementation', async () => {
      setup({
        data: {
          ...defaultProps.data,
          series,
        },
        options: {
          ...defaultProps.options,
          showLabels: false,
          showTime: false,
          wrapLogMessage: false,
          showCommonLabels: false,
          prettifyLogMessage: false,
          sortOrder: LogsSortOrder.Descending,
          dedupStrategy: LogsDedupStrategy.none,
          enableLogDetails: true,
          displayedFields: [],
          onClickHideField: undefined,
          onClickShowField: undefined,
        },
      });

      expect(await screen.findByRole('row')).toBeInTheDocument();

      await userEvent.click(screen.getByText('logline text'));
      await userEvent.click(screen.getByLabelText('Show this field instead of the message'));

      expect(screen.getByText('app=common_app')).toBeInTheDocument();

      await userEvent.click(screen.getByLabelText('Hide this field'));

      expect(screen.getByText('logline text')).toBeInTheDocument();
    });

    it('overrides the default implementation when the callbacks are provided', async () => {
      const onClickShowFieldMock = jest.fn();

      setup({
        data: {
          ...defaultProps.data,
          series,
        },
        options: {
          ...defaultProps.options,
          showLabels: false,
          showTime: false,
          wrapLogMessage: false,
          showCommonLabels: false,
          prettifyLogMessage: false,
          sortOrder: LogsSortOrder.Descending,
          dedupStrategy: LogsDedupStrategy.none,
          enableLogDetails: true,
          onClickHideField: jest.fn(),
          onClickShowField: onClickShowFieldMock,
        },
      });

      expect(await screen.findByRole('row')).toBeInTheDocument();

      await userEvent.click(screen.getByText('logline text'));
      await userEvent.click(screen.getByLabelText('Show this field instead of the message'));

      expect(onClickShowFieldMock).toHaveBeenCalledTimes(1);
    });
  });
});

const setup = (propsOverrides?: Partial<LogsPanelProps>) => {
  const props: LogsPanelProps = {
    ...defaultProps,
    data: {
      ...(propsOverrides?.data || defaultProps.data),
    },
    options: {
      ...(propsOverrides?.options || defaultProps.options),
    },
  };

  return { ...render(<LogsPanel {...props} />), props };
};
