/**
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License.  You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */

import type React from 'react';
import { Route } from 'react-router-dom';
import fetchMock from 'fetch-mock';
import {
  DatasourceType,
  JsonObject,
  JsonResponse,
  SupersetClient,
} from '@superset-ui/core';
import {
  render,
  screen,
  act,
  userEvent,
  waitFor,
} from 'spec/helpers/testing-library';
import { fallbackExploreInitialData } from 'src/explore/fixtures';
import type { ColumnObject } from 'src/features/datasets/types';
import type Subject from 'src/types/Subject';
import DatasourceControl from '.';

// Mock DatasourceEditor to avoid mounting the full 2500+ line editor tree.
// The heavy editor (with CollectionTable, FilterableTable, DatabaseSelector, etc.)
// causes OOM in CI when rendered repeatedly. These tests only need to verify
// DatasourceControl's callback wiring through the modal save flow.
jest.mock('src/components/Datasource/components/DatasourceEditor', () => ({
  __esModule: true,
  default: () =>
    require('react').createElement(
      'div',
      { 'data-test': 'mock-datasource-editor' },
      'Mock Editor',
    ),
}));

const SupersetClientGet = jest.spyOn(SupersetClient, 'get');

let originalLocation: Location;

beforeEach(() => {
  originalLocation = window.location;
});

afterEach(() => {
  window.location = originalLocation;

  try {
    const unmatched = fetchMock.callHistory.calls('unmatched');
    if (unmatched.length > 0) {
      const urls = unmatched.map(call => call.url).join(', ');
      throw new Error(
        `fetchMock: ${unmatched.length} unmatched call(s): ${urls}`,
      );
    }
  } finally {
    fetchMock.clearHistory().removeRoutes();
    jest.clearAllMocks(); // Clears mock history but keeps spy in place
  }
});

afterAll(() => {
  // Restore the module-scope SupersetClient.get spy so it doesn't leak its
  // mocked behavior into other test files running in the same Jest worker.
  SupersetClientGet.mockRestore();
});

interface TestDatasource {
  id?: number;
  name: string;
  datasource_name?: string;
  database: {
    id: number;
    database_name: string;
    name?: string;
    backend?: string;
  };
  columns?: Partial<ColumnObject>[];
  type?: DatasourceType;
  main_dttm_col?: string | null;
  sql?: string;
  metrics?: Array<{ id: number; metric_name: string }>;
  editors?: Subject[];
  [key: string]: unknown;
}

const mockDatasource: TestDatasource = {
  id: 25,
  database: {
    id: 1,
    database_name: 'examples',
    name: 'examples',
  },
  name: 'channels',
  datasource_name: 'channels',
  type: DatasourceType.Table,
  columns: [],
  sql: 'SELECT * FROM mock_datasource_sql',
  editors: [{ id: 1, label: 'john doe', type: 1 }],
};

// Use type assertion for test props since the component is wrapped with withTheme
// The withTheme HOC makes the props type complex, so we cast through unknown to bypass type check
type DatasourceControlComponentProps = React.ComponentProps<
  typeof DatasourceControl
>;
const createProps = (
  overrides: JsonObject = {},
): DatasourceControlComponentProps =>
  ({
    hovered: false,
    type: 'DatasourceControl',
    label: 'Datasource',
    default: null,
    description: null,
    value: '25__table',
    form_data: {},
    datasource: mockDatasource,
    validationErrors: [],
    name: 'datasource',
    actions: {
      changeDatasource: jest.fn(),
      setControlValue: jest.fn(),
    },
    isEditable: true,
    user: {
      createdOn: '2021-04-27T18:12:38.952304',
      email: 'admin',
      firstName: 'admin',
      isActive: true,
      lastName: 'admin',
      permissions: {},
      roles: { Admin: Array(173) },
      userId: 1,
      username: 'admin',
    },
    onChange: jest.fn(),
    onDatasourceSave: jest.fn(),
    ...overrides,
  }) as unknown as DatasourceControlComponentProps;

const getDbWithQuery = 'glob:*/api/v1/database/?q=*';
const getDatasetWithAll = 'glob:*/api/v1/dataset/*';
const putDatasetWithAll = 'glob:*/api/v1/dataset/*';
const getDatasetWithAllMockRouteName = `get${getDatasetWithAll}`;
const putDatasetWithAllMockRouteName = `put${putDatasetWithAll}`;

async function openAndSaveChanges(
  datasource: TestDatasource | Record<string, unknown>,
) {
  fetchMock.removeRoute(getDbWithQuery);
  fetchMock.get(getDbWithQuery, { result: [] }, { name: getDbWithQuery });

  fetchMock.removeRoute(putDatasetWithAllMockRouteName);
  fetchMock.put(
    putDatasetWithAll,
    {},
    { name: putDatasetWithAllMockRouteName },
  );

  fetchMock.removeRoute(getDatasetWithAllMockRouteName);
  fetchMock.get(
    getDatasetWithAll,
    { result: datasource },
    {
      name: getDatasetWithAllMockRouteName,
    },
  );
  await userEvent.click(screen.getByTestId('datasource-menu-trigger'));
  await userEvent.click(await screen.findByTestId('edit-dataset'));
  await userEvent.click(await screen.findByTestId('datasource-modal-save'));
  await userEvent.click(await screen.findByText('Confirm'));
}

test('Should render', async () => {
  const props = createProps();
  render(<DatasourceControl {...props} />, { useRouter: true });
  expect(await screen.findByTestId('datasource-control')).toBeVisible();
});

test('Should have elements', async () => {
  const props = createProps();
  render(<DatasourceControl {...props} />, { useRouter: true });
  expect(await screen.findByText('channels')).toBeVisible();
  expect(screen.getByTestId('datasource-menu-trigger')).toBeVisible();
});

test('Should open a menu', async () => {
  const props = createProps();
  render(<DatasourceControl {...props} />, { useRouter: true });

  expect(screen.queryByText('Edit dataset')).not.toBeInTheDocument();
  expect(screen.queryByText('Swap dataset')).not.toBeInTheDocument();
  expect(screen.queryByText('View in SQL Lab')).not.toBeInTheDocument();

  await userEvent.click(screen.getByTestId('datasource-menu-trigger'));

  expect(await screen.findByText('Edit dataset')).toBeInTheDocument();
  expect(screen.getByText('Swap dataset')).toBeInTheDocument();
  expect(screen.getByText('View in SQL Lab')).toBeInTheDocument();
});

test('Should not show SQL Lab for non sql_lab role', async () => {
  const props = createProps({
    user: {
      createdOn: '2021-04-27T18:12:38.952304',
      email: 'gamma',
      firstName: 'gamma',
      isActive: true,
      lastName: 'gamma',
      permissions: {},
      roles: { Gamma: [] },
      userId: 2,
      username: 'gamma',
    },
  });
  render(<DatasourceControl {...props} />, { useRouter: true });

  await userEvent.click(screen.getByTestId('datasource-menu-trigger'));

  expect(await screen.findByText('Edit dataset')).toBeInTheDocument();
  expect(screen.getByText('Swap dataset')).toBeInTheDocument();
  expect(screen.queryByText('View in SQL Lab')).not.toBeInTheDocument();
});

test('Should show SQL Lab for sql_lab role', async () => {
  const props = createProps({
    user: {
      createdOn: '2021-04-27T18:12:38.952304',
      email: 'sql',
      firstName: 'sql',
      isActive: true,
      lastName: 'sql',
      permissions: {},
      roles: { Gamma: [], sql_lab: [['menu_access', 'SQL Lab']] },
      userId: 2,
      username: 'sql',
    },
  });
  render(<DatasourceControl {...props} />, { useRouter: true });

  await userEvent.click(screen.getByTestId('datasource-menu-trigger'));

  expect(await screen.findByText('Edit dataset')).toBeInTheDocument();
  expect(screen.getByText('Swap dataset')).toBeInTheDocument();
  expect(screen.getByText('View in SQL Lab')).toBeInTheDocument();
});

test('Click on Swap dataset option', async () => {
  const props = createProps();
  SupersetClientGet.mockImplementationOnce(
    async ({ endpoint }: { endpoint: string }) => {
      if (endpoint.includes('_info')) {
        return {
          json: { permissions: ['can_read', 'can_write'] },
        } as unknown as JsonResponse;
      }
      return { json: { result: [] } } as unknown as JsonResponse;
    },
  );

  render(<DatasourceControl {...props} />, {
    useRedux: true,
    useRouter: true,
  });
  await userEvent.click(screen.getByTestId('datasource-menu-trigger'));

  await act(async () => {
    await userEvent.click(screen.getByText('Swap dataset'));
  });
  expect(
    screen.getByText(
      'Changing the dataset may break the chart if the chart relies on columns or metadata that does not exist in the target dataset',
    ),
  ).toBeInTheDocument();
});

test('Click on Edit dataset', async () => {
  const props = createProps();
  fetchMock.removeRoute(getDbWithQuery);
  fetchMock.get(getDbWithQuery, { result: [] }, { name: getDbWithQuery });
  render(<DatasourceControl {...props} />, {
    useRedux: true,
    useRouter: true,
  });
  await userEvent.click(screen.getByTestId('datasource-menu-trigger'));

  await act(async () => {
    await userEvent.click(screen.getByText('Edit dataset'));
  });

  expect(screen.getByTestId('mock-datasource-editor')).toBeInTheDocument();
});

test('Edit dataset should be disabled when user is not admin', async () => {
  const props = createProps();
  props.user.roles = {};
  props.datasource.editors = [];

  render(<DatasourceControl {...props} />, {
    useRedux: true,
    useRouter: true,
  });

  await userEvent.click(screen.getByTestId('datasource-menu-trigger'));

  expect(await screen.findByTestId('edit-dataset')).toHaveAttribute(
    'aria-disabled',
    'true',
  );
});

test('Click on View in SQL Lab', async () => {
  const props = createProps();

  const { queryByTestId, getByTestId } = render(
    <>
      <Route
        path="/sqllab"
        render={({ location }) => (
          <div data-test="mock-sqllab-route">
            {JSON.stringify(location.state)}
          </div>
        )}
      />
      <DatasourceControl {...props} />
    </>,
    {
      useRedux: true,
      useRouter: true,
    },
  );
  await userEvent.click(screen.getByTestId('datasource-menu-trigger'));

  expect(queryByTestId('mock-sqllab-route')).not.toBeInTheDocument();

  await act(async () => {
    await userEvent.click(screen.getByText('View in SQL Lab'));
  });

  expect(getByTestId('mock-sqllab-route')).toBeInTheDocument();
  expect(JSON.parse(`${getByTestId('mock-sqllab-route').textContent}`)).toEqual(
    {
      requestedQuery: {
        datasourceKey: `${mockDatasource.id}__${mockDatasource.type}`,
        sql: mockDatasource.sql,
      },
    },
  );
});

test('Should open a different menu when datasource=query', async () => {
  const props = createProps();
  const queryProps = {
    ...props,
    datasource: {
      ...props.datasource,
      type: DatasourceType.Query,
    },
  };
  render(<DatasourceControl {...queryProps} />, { useRouter: true });

  expect(screen.queryByText('Query preview')).not.toBeInTheDocument();
  expect(screen.queryByText('View in SQL Lab')).not.toBeInTheDocument();
  expect(screen.queryByText('Save as dataset')).not.toBeInTheDocument();

  await userEvent.click(screen.getByTestId('datasource-menu-trigger'));

  expect(await screen.findByText('Query preview')).toBeInTheDocument();
  expect(screen.getByText('View in SQL Lab')).toBeInTheDocument();
  expect(screen.getByText('Save as dataset')).toBeInTheDocument();
});

test('Click on Save as dataset', async () => {
  const props = createProps();
  const queryProps = {
    ...props,
    datasource: {
      ...props.datasource,
      type: DatasourceType.Query,
    },
  };

  render(<DatasourceControl {...queryProps} />, {
    useRedux: true,
    useRouter: true,
  });
  await userEvent.click(screen.getByTestId('datasource-menu-trigger'));
  expect(
    screen.queryByRole('button', { name: /save/i }),
  ).not.toBeInTheDocument();
  expect(
    screen.queryByRole('button', { name: /close/i }),
  ).not.toBeInTheDocument();
  expect(
    screen.queryByText(/select or type dataset name/i),
  ).not.toBeInTheDocument();
  await userEvent.click(screen.getByText('Save as dataset'));

  // Renders a save dataset modal
  const saveRadioBtn = await screen.findByRole('radio', {
    name: /save as new/i,
  });
  const overwriteRadioBtn = screen.getByRole('radio', {
    name: /overwrite existing/i,
  });
  const dropdownField = screen.getByText(/select or type dataset name/i);
  expect(saveRadioBtn).toBeInTheDocument();
  expect(overwriteRadioBtn).toBeInTheDocument();
  expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
  expect(screen.getByRole('button', { name: /close/i })).toBeInTheDocument();
  expect(dropdownField).toBeInTheDocument();
});

test('should set the default temporal column', async () => {
  const props = createProps();
  const overrideProps = {
    ...props,
    form_data: {
      granularity_sqla: 'test-col',
    },
    datasource: {
      ...props.datasource,
      main_dttm_col: 'test-default',
      columns: [
        {
          column_name: 'test-col',
          is_dttm: false,
        },
        {
          column_name: 'test-default',
          is_dttm: true,
        },
      ],
    },
  };
  render(<DatasourceControl {...props} {...overrideProps} />, {
    useRedux: true,
    useRouter: true,
  });

  await openAndSaveChanges(overrideProps.datasource);
  await waitFor(() => {
    expect(props.actions.setControlValue).toHaveBeenCalledWith(
      'granularity_sqla',
      'test-default',
      undefined,
      { programmatic: true },
    );
  });
});

test('should set the first available temporal column', async () => {
  const props = createProps();
  const overrideProps = {
    ...props,
    form_data: {
      granularity_sqla: 'test-col',
    },
    datasource: {
      ...props.datasource,
      main_dttm_col: null,
      columns: [
        {
          column_name: 'test-col',
          is_dttm: false,
        },
        {
          column_name: 'test-first',
          is_dttm: true,
        },
      ],
    },
  };
  render(<DatasourceControl {...props} {...overrideProps} />, {
    useRedux: true,
    useRouter: true,
  });

  await openAndSaveChanges(overrideProps.datasource);
  await waitFor(() => {
    expect(props.actions.setControlValue).toHaveBeenCalledWith(
      'granularity_sqla',
      'test-first',
      undefined,
      { programmatic: true },
    );
  });
});

test('should not set the temporal column', async () => {
  const props = createProps();
  const overrideProps = {
    ...props,
    form_data: {
      granularity_sqla: undefined,
    },
    datasource: {
      ...props.datasource,
      main_dttm_col: undefined,
      columns: [
        {
          column_name: 'test-col',
          is_dttm: false,
        },
        {
          column_name: 'test-col-2',
          is_dttm: false,
        },
      ],
    },
  };
  render(<DatasourceControl {...props} {...overrideProps} />, {
    useRedux: true,
    useRouter: true,
  });

  await openAndSaveChanges(overrideProps.datasource);
  await waitFor(() => {
    expect(props.actions.setControlValue).toHaveBeenCalledWith(
      'granularity_sqla',
      null,
      undefined,
      { programmatic: true },
    );
  });
});

test('editing a dataset still emits a dirty signal for the restore gate', async () => {
  // The derived granularity_sqla write is programmatic, so it is invisible to
  // the version-history session log. On the *swap* route that is fine —
  // ChangeDatasourceModal's own onChange emits a recorded control change. The
  // Edit Dataset route has no such change, so the reconciliation dispatched by
  // changeDatasource is the only thing standing between an edited chart and a
  // restore that silently discards the reconciled value.
  const props = createProps();
  const overrideProps = {
    ...props,
    form_data: { granularity_sqla: 'test-col' },
    datasource: {
      ...props.datasource,
      main_dttm_col: 'test-default',
      columns: [
        { column_name: 'test-col', is_dttm: false },
        { column_name: 'test-default', is_dttm: true },
      ],
    },
  };
  render(<DatasourceControl {...props} {...overrideProps} />, {
    useRedux: true,
    useRouter: true,
  });

  await openAndSaveChanges(overrideProps.datasource);

  await waitFor(() => {
    expect(props.actions.setControlValue).toHaveBeenCalledWith(
      'granularity_sqla',
      'test-default',
      undefined,
      { programmatic: true },
    );
  });
  // changeDatasource dispatches UPDATE_FORM_DATA_BY_DATASOURCE (pinned in
  // datasourcesActions.test.ts), which the session-log middleware records.
  expect(props.actions.changeDatasource).toHaveBeenCalledWith(
    expect.objectContaining({ id: overrideProps.datasource.id }),
  );
});

test('should show missing params state', () => {
  const props = createProps({ datasource: fallbackExploreInitialData.dataset });
  render(<DatasourceControl {...props} />, { useRedux: true, useRouter: true });
  expect(screen.getByText(/missing dataset/i)).toBeVisible();
  expect(screen.getByText(/missing url parameters/i)).toBeVisible();
  expect(
    screen.getByText(
      /the url is missing the dataset_id or slice_id parameters/i,
    ),
  ).toBeVisible();
});

test('should show missing dataset state', () => {
  jest.spyOn(window, 'location', 'get').mockReturnValue({
    ...window.location,
    search: '?slice_id=152',
  } as Location);
  const props = createProps({ datasource: fallbackExploreInitialData.dataset });
  render(<DatasourceControl {...props} />, { useRedux: true, useRouter: true });
  expect(screen.getAllByText(/missing dataset/i)).toHaveLength(2);
  expect(
    screen.getByText(
      /the dataset linked to this chart may have been deleted\./i,
    ),
  ).toBeVisible();
});

test('should show forbidden dataset state', () => {
  jest.spyOn(window, 'location', 'get').mockReturnValue({
    ...window.location,
    search: '?slice_id=152',
  } as Location);
  const error = {
    error_type: 'TABLE_SECURITY_ACCESS_ERROR',
    statusText: 'FORBIDDEN',
    message: 'You do not have access to the following tables: blocked_table',
    extra: {
      datasource: 152,
      datasource_name: 'forbidden dataset',
    },
  };
  const props = createProps({
    datasource: {
      ...fallbackExploreInitialData.dataset,
      extra: {
        error,
      },
    },
  });
  render(<DatasourceControl {...props} />, { useRedux: true, useRouter: true });
  expect(screen.getByText(error.message)).toBeInTheDocument();
  expect(screen.getByText(error.statusText)).toBeVisible();
});

test('should allow creating new metrics in dataset editor', async () => {
  const props = createProps({
    datasource: { ...mockDatasource, metrics: [] },
  });

  render(<DatasourceControl {...props} />, {
    useRedux: true,
    useRouter: true,
  });

  // The GET response after save includes the new metric
  await openAndSaveChanges({
    ...mockDatasource,
    metrics: [{ id: 1, metric_name: 'test_metric' }],
  });

  await waitFor(() => {
    expect(props.onDatasourceSave).toHaveBeenCalled();
  });
});

test('should allow deleting metrics in dataset editor', async () => {
  const props = createProps({
    datasource: {
      ...mockDatasource,
      metrics: [{ id: 1, metric_name: 'existing_metric' }],
    },
  });

  render(<DatasourceControl {...props} />, {
    useRedux: true,
    useRouter: true,
  });

  // The GET response after save reflects the metric was deleted
  await openAndSaveChanges({ ...mockDatasource, metrics: [] });

  await waitFor(() => {
    expect(props.onDatasourceSave).toHaveBeenCalled();
  });
});

test('should handle metric save confirmation modal', async () => {
  const props = createProps();

  render(<DatasourceControl {...props} />, {
    useRedux: true,
    useRouter: true,
  });

  // Set up fetch mocks for the save flow
  fetchMock.removeRoute(getDbWithQuery);
  fetchMock.get(getDbWithQuery, { result: [] }, { name: getDbWithQuery });
  fetchMock.removeRoute(putDatasetWithAllMockRouteName);
  fetchMock.put(
    putDatasetWithAll,
    {},
    { name: putDatasetWithAllMockRouteName },
  );
  fetchMock.removeRoute(getDatasetWithAllMockRouteName);
  fetchMock.get(
    getDatasetWithAll,
    { result: mockDatasource },
    { name: getDatasetWithAllMockRouteName },
  );

  // Open edit dataset modal
  await userEvent.click(screen.getByTestId('datasource-menu-trigger'));
  await userEvent.click(await screen.findByTestId('edit-dataset'));

  // Click save to trigger confirmation modal
  await userEvent.click(await screen.findByTestId('datasource-modal-save'));

  // Verify confirmation modal appears
  expect(await screen.findByText('Confirm')).toBeInTheDocument();

  // Confirm save
  await userEvent.click(screen.getByText('Confirm'));

  await waitFor(() => {
    expect(props.onDatasourceSave).toHaveBeenCalled();
  });
});

test('should verify DatasourceControl callback fires on save', async () => {
  const mockOnDatasourceSave = jest.fn();
  const props = createProps({
    datasource: mockDatasource,
    onDatasourceSave: mockOnDatasourceSave,
  });

  render(<DatasourceControl {...props} />, {
    useRedux: true,
    useRouter: true,
  });

  expect(screen.getByTestId('datasource-control')).toBeInTheDocument();

  await openAndSaveChanges(mockDatasource);

  await waitFor(() => {
    expect(mockOnDatasourceSave).toHaveBeenCalled();
  });

  // Verify callback received a datasource object
  expect(mockOnDatasourceSave).toHaveBeenCalledWith(
    expect.objectContaining({
      id: expect.any(Number),
      name: expect.any(String),
    }),
  );
});

// Note: Cross-component integration test removed due to complex Redux/user context setup
// The existing callback tests provide sufficient coverage for metric creation workflows
// Future enhancement could add MetricsControl integration when test infrastructure supports it
