import { firstValueFrom, take } from 'rxjs';

import { type AppPluginConfig } from '@grafana/data';
import { config } from '@grafana/runtime';

import { log } from '../logs/log';
import { resetLogMock } from '../logs/testUtils';
import { basicApp } from '../test-fixtures/config.apps';

import { AddedLinksRegistry } from './AddedLinksRegistry';
import { MSG_CANNOT_REGISTER_READ_ONLY } from './Registry';

jest.mock('../logs/log', () => {
  const { createLogMock } = jest.requireActual('../logs/testUtils');
  const original = jest.requireActual('../logs/log');

  return {
    ...original,
    log: createLogMock(),
  };
});

describe('AddedLinksRegistry', () => {
  const pluginId = basicApp.id;
  const apps = [basicApp];
  const createRegistry = async (override: AppPluginConfig[] = apps) => new AddedLinksRegistry(override);

  beforeEach(async () => {
    resetLogMock(log);
    config.buildInfo.env = 'production';
  });

  it('should return empty registry when no extensions registered', async () => {
    const addedLinksRegistry = await createRegistry();
    const observable = addedLinksRegistry.asObservable();
    const registry = await firstValueFrom(observable);
    expect(registry).toEqual({});
  });

  it('should be possible to register link extensions in the registry', async () => {
    const addedLinksRegistry = await createRegistry();

    addedLinksRegistry.register({
      pluginId,
      configs: [
        {
          title: 'Link 1',
          description: 'Link 1 description',
          path: `/a/${pluginId}/declare-incident`,
          targets: 'grafana/dashboard/panel/menu',
          configure: jest.fn().mockReturnValue({}),
        },
        {
          title: 'Link 2',
          description: 'Link 2 description',
          path: `/a/${pluginId}/declare-incident`,
          targets: 'plugins/myorg-basic-app/start',
          configure: jest.fn().mockImplementation((context) => ({ title: context?.title })),
          openInNewTab: true,
        },
      ],
    });

    const registry = await addedLinksRegistry.getState();

    expect(registry).toEqual({
      'grafana/dashboard/panel/menu': [
        {
          pluginId: pluginId,
          title: 'Link 1',
          description: 'Link 1 description',
          path: `/a/${pluginId}/declare-incident`,
          extensionPointId: 'grafana/dashboard/panel/menu',
          configure: expect.any(Function),
        },
      ],
      'plugins/myorg-basic-app/start': [
        {
          pluginId: pluginId,
          title: 'Link 2',
          description: 'Link 2 description',
          path: `/a/${pluginId}/declare-incident`,
          extensionPointId: 'plugins/myorg-basic-app/start',
          configure: expect.any(Function),
          openInNewTab: true,
        },
      ],
    });
  });
  it('should be possible to asynchronously register link extensions for the same placement (different plugins)', async () => {
    const pluginId1 = 'grafana-basic-app';
    const pluginId2 = 'grafana-basic-app2';
    const reactiveRegistry = await createRegistry();

    // Register extensions for the first plugin
    reactiveRegistry.register({
      pluginId: pluginId1,
      configs: [
        {
          title: 'Link 1',
          description: 'Link 1 description',
          path: `/a/${pluginId1}/declare-incident`,
          targets: 'grafana/dashboard/panel/menu',
          configure: jest.fn().mockReturnValue({}),
        },
      ],
    });

    const registry1 = await reactiveRegistry.getState();

    expect(registry1).toEqual({
      'grafana/dashboard/panel/menu': [
        {
          pluginId: pluginId1,
          title: 'Link 1',
          description: 'Link 1 description',
          path: `/a/${pluginId1}/declare-incident`,
          extensionPointId: 'grafana/dashboard/panel/menu',
          configure: expect.any(Function),
        },
      ],
    });

    // Register extensions for the second plugin to a different placement
    reactiveRegistry.register({
      pluginId: pluginId2,
      configs: [
        {
          title: 'Link 2',
          description: 'Link 2 description',
          path: `/a/${pluginId2}/declare-incident`,
          targets: 'grafana/dashboard/panel/menu',
          configure: jest.fn().mockReturnValue({}),
        },
      ],
    });

    const registry2 = await reactiveRegistry.getState();

    expect(registry2).toEqual({
      'grafana/dashboard/panel/menu': [
        {
          pluginId: pluginId1,
          title: 'Link 1',
          description: 'Link 1 description',
          path: `/a/${pluginId1}/declare-incident`,
          extensionPointId: 'grafana/dashboard/panel/menu',
          configure: expect.any(Function),
        },
        {
          pluginId: pluginId2,
          title: 'Link 2',
          description: 'Link 2 description',
          path: `/a/${pluginId2}/declare-incident`,
          extensionPointId: 'grafana/dashboard/panel/menu',
          configure: expect.any(Function),
        },
      ],
    });
  });

  it('should be possible to asynchronously register link extensions for a different placement (different plugin)', async () => {
    const pluginId1 = 'grafana-basic-app';
    const pluginId2 = 'grafana-basic-app2';
    const reactiveRegistry = await createRegistry();

    // Register extensions for the first plugin
    reactiveRegistry.register({
      pluginId: pluginId1,
      configs: [
        {
          title: 'Link 1',
          description: 'Link 1 description',
          path: `/a/${pluginId1}/declare-incident`,
          targets: 'grafana/dashboard/panel/menu',
          configure: jest.fn().mockReturnValue({}),
        },
      ],
    });

    const registry1 = await reactiveRegistry.getState();

    expect(registry1).toEqual({
      'grafana/dashboard/panel/menu': [
        {
          pluginId: pluginId1,

          title: 'Link 1',
          description: 'Link 1 description',
          path: `/a/${pluginId1}/declare-incident`,
          extensionPointId: 'grafana/dashboard/panel/menu',
          configure: expect.any(Function),
        },
      ],
    });

    // Register extensions for the second plugin to a different placement
    reactiveRegistry.register({
      pluginId: pluginId2,
      configs: [
        {
          title: 'Link 2',
          description: 'Link 2 description',
          path: `/a/${pluginId2}/declare-incident`,
          targets: 'plugins/myorg-basic-app/start',
          configure: jest.fn().mockReturnValue({}),
        },
      ],
    });

    const registry2 = await reactiveRegistry.getState();

    expect(registry2).toEqual({
      'grafana/dashboard/panel/menu': [
        {
          pluginId: pluginId1,
          title: 'Link 1',
          description: 'Link 1 description',
          path: `/a/${pluginId1}/declare-incident`,
          extensionPointId: 'grafana/dashboard/panel/menu',
          configure: expect.any(Function),
        },
      ],
      'plugins/myorg-basic-app/start': [
        {
          pluginId: pluginId2,
          title: 'Link 2',
          description: 'Link 2 description',
          path: `/a/${pluginId2}/declare-incident`,
          extensionPointId: 'plugins/myorg-basic-app/start',
          configure: expect.any(Function),
        },
      ],
    });
  });

  it('should be possible to asynchronously register link extensions for the same placement (same plugin)', async () => {
    const pluginId = 'grafana-basic-app';
    const reactiveRegistry = await createRegistry();

    // Register extensions for the first extension point
    reactiveRegistry.register({
      pluginId: pluginId,
      configs: [
        {
          title: 'Link 1',
          description: 'Link 1 description',
          path: `/a/${pluginId}/declare-incident-1`,
          targets: 'grafana/dashboard/panel/menu',
          configure: jest.fn().mockReturnValue({}),
        },
      ],
    });

    // Register extensions to a different extension point
    reactiveRegistry.register({
      pluginId: pluginId,
      configs: [
        {
          title: 'Link 2',
          description: 'Link 2 description',
          path: `/a/${pluginId}/declare-incident-2`,
          targets: 'grafana/dashboard/panel/menu',
          configure: jest.fn().mockReturnValue({}),
        },
      ],
    });

    const registry2 = await reactiveRegistry.getState();

    expect(registry2).toEqual({
      'grafana/dashboard/panel/menu': [
        {
          pluginId: pluginId,

          title: 'Link 1',
          description: 'Link 1 description',
          path: `/a/${pluginId}/declare-incident-1`,
          extensionPointId: 'grafana/dashboard/panel/menu',
          configure: expect.any(Function),
        },
        {
          pluginId: pluginId,

          title: 'Link 2',
          description: 'Link 2 description',
          path: `/a/${pluginId}/declare-incident-2`,
          extensionPointId: 'grafana/dashboard/panel/menu',
          configure: expect.any(Function),
        },
      ],
    });
  });

  it('should be possible to asynchronously register link extensions for a different placement (same plugin)', async () => {
    const pluginId = 'grafana-basic-app';
    const reactiveRegistry = await createRegistry();

    // Register extensions for the first extension point
    reactiveRegistry.register({
      pluginId: pluginId,
      configs: [
        {
          title: 'Link 1',
          description: 'Link 1 description',
          path: `/a/${pluginId}/declare-incident`,
          targets: 'grafana/dashboard/panel/menu',
          configure: jest.fn().mockReturnValue({}),
        },
      ],
    });

    // Register extensions to a different extension point
    reactiveRegistry.register({
      pluginId: pluginId,
      configs: [
        {
          title: 'Link 2',
          description: 'Link 2 description',
          path: `/a/${pluginId}/declare-incident`,
          targets: 'plugins/myorg-basic-app/start',
          configure: jest.fn().mockReturnValue({}),
        },
      ],
    });

    const registry2 = await reactiveRegistry.getState();

    expect(registry2).toEqual({
      'grafana/dashboard/panel/menu': [
        {
          pluginId: pluginId,

          title: 'Link 1',
          description: 'Link 1 description',
          path: `/a/${pluginId}/declare-incident`,
          extensionPointId: 'grafana/dashboard/panel/menu',
          configure: expect.any(Function),
        },
      ],
      'plugins/myorg-basic-app/start': [
        {
          pluginId: pluginId,

          title: 'Link 2',
          description: 'Link 2 description',
          path: `/a/${pluginId}/declare-incident`,
          extensionPointId: 'plugins/myorg-basic-app/start',
          configure: expect.any(Function),
        },
      ],
    });
  });

  it('should notify subscribers when the registry changes', async () => {
    const pluginId = 'grafana-basic-app';
    const reactiveRegistry = await createRegistry();
    const observable = reactiveRegistry.asObservable();
    const subscribeCallback = jest.fn();

    observable.subscribe(subscribeCallback);

    // Register extensions for the first plugin
    reactiveRegistry.register({
      pluginId: pluginId,
      configs: [
        {
          title: 'Link 1',
          description: 'Link 1 description',
          path: `/a/${pluginId}/declare-incident`,
          targets: 'grafana/dashboard/panel/menu',
          configure: jest.fn().mockReturnValue({}),
        },
      ],
    });

    expect(subscribeCallback).toHaveBeenCalledTimes(2);

    // Register extensions for the first plugin
    reactiveRegistry.register({
      pluginId: 'another-plugin',
      configs: [
        {
          title: 'Link 1',
          description: 'Link 1 description',
          path: `/a/another-plugin/declare-incident`,
          targets: 'grafana/dashboard/panel/menu',
          configure: jest.fn().mockReturnValue({}),
        },
      ],
    });

    expect(subscribeCallback).toHaveBeenCalledTimes(3);

    const registry = subscribeCallback.mock.calls[2][0];

    expect(registry).toEqual({
      'grafana/dashboard/panel/menu': [
        {
          pluginId: pluginId,

          title: 'Link 1',
          description: 'Link 1 description',
          path: `/a/${pluginId}/declare-incident`,
          extensionPointId: 'grafana/dashboard/panel/menu',
          configure: expect.any(Function),
        },
        {
          pluginId: 'another-plugin',

          title: 'Link 1',
          description: 'Link 1 description',
          path: `/a/another-plugin/declare-incident`,
          extensionPointId: 'grafana/dashboard/panel/menu',
          configure: expect.any(Function),
        },
      ],
    });
  });

  it('should give the last version of the registry for new subscribers', async () => {
    const pluginId = 'grafana-basic-app';
    const reactiveRegistry = await createRegistry();
    const observable = reactiveRegistry.asObservable();
    const subscribeCallback = jest.fn();

    reactiveRegistry.register({
      pluginId: pluginId,
      configs: [
        {
          title: 'Link 1',
          description: 'Link 1 description',
          path: `/a/${pluginId}/declare-incident`,
          targets: 'grafana/dashboard/panel/menu',
          configure: jest.fn().mockReturnValue({}),
        },
      ],
    });

    observable.subscribe(subscribeCallback);
    expect(subscribeCallback).toHaveBeenCalledTimes(1);

    const registry = subscribeCallback.mock.calls[0][0];

    expect(registry).toEqual({
      'grafana/dashboard/panel/menu': [
        {
          pluginId: pluginId,

          title: 'Link 1',
          description: 'Link 1 description',
          path: `/a/${pluginId}/declare-incident`,
          extensionPointId: 'grafana/dashboard/panel/menu',
          configure: expect.any(Function),
        },
      ],
    });
  });

  it('should not register a link extension if it has an invalid configure() function', async () => {
    const pluginId = 'grafana-basic-app';
    const reactiveRegistry = await createRegistry();
    const observable = reactiveRegistry.asObservable();
    const subscribeCallback = jest.fn();

    reactiveRegistry.register({
      pluginId: pluginId,
      configs: [
        {
          title: 'Link 1',
          description: 'Link 1 description',
          path: `/a/${pluginId}/declare-incident`,
          targets: 'grafana/dashboard/panel/menu',
          //@ts-ignore
          configure: '...',
        },
      ],
    });

    expect(log.error).toHaveBeenCalled();

    observable.subscribe(subscribeCallback);
    expect(subscribeCallback).toHaveBeenCalledTimes(1);

    const registry = subscribeCallback.mock.calls[0][0];
    expect(registry).toEqual({});
  });

  it('should not register a link extension if it has invalid properties (empty title / description)', async () => {
    const pluginId = 'grafana-basic-app';
    const reactiveRegistry = await createRegistry();
    const observable = reactiveRegistry.asObservable();
    const subscribeCallback = jest.fn();

    reactiveRegistry.register({
      pluginId: pluginId,
      configs: [
        {
          title: '',
          description: '',
          path: `/a/${pluginId}/declare-incident`,
          targets: 'grafana/dashboard/panel/menu',
          configure: jest.fn().mockReturnValue({}),
        },
      ],
    });

    expect(log.error).toHaveBeenCalled();

    observable.subscribe(subscribeCallback);
    expect(subscribeCallback).toHaveBeenCalledTimes(1);

    const registry = subscribeCallback.mock.calls[0][0];
    expect(registry).toEqual({});
  });

  it('should not be possible to register a link on a read-only registry', async () => {
    const pluginId = 'grafana-basic-app';
    const registry = await createRegistry();
    const readOnlyRegistry = registry.readOnly();

    expect(() => {
      readOnlyRegistry.register({
        pluginId,
        configs: [
          {
            title: 'Link 2',
            description: 'Link 2 description',
            path: `/a/${pluginId}/declare-incident`,
            targets: 'plugins/myorg-basic-app/start',
            configure: jest.fn().mockReturnValue({}),
          },
        ],
      });
    }).toThrow(MSG_CANNOT_REGISTER_READ_ONLY);

    const currentState = await readOnlyRegistry.getState();
    expect(Object.keys(currentState)).toHaveLength(0);
  });

  it('should pass down fresh registrations to the read-only version of the registry', async () => {
    const pluginId = 'grafana-basic-app';
    const registry = await createRegistry();
    const readOnlyRegistry = registry.readOnly();
    const subscribeCallback = jest.fn();
    let readOnlyState;

    // Should have no extensions registered in the beginning
    readOnlyState = await readOnlyRegistry.getState();
    expect(Object.keys(readOnlyState)).toHaveLength(0);

    readOnlyRegistry.asObservable().subscribe(subscribeCallback);

    // Register an extension to the original (writable) registry
    registry.register({
      pluginId,
      configs: [
        {
          title: 'Link 2',
          description: 'Link 2 description',
          path: `/a/${pluginId}/declare-incident`,
          targets: 'plugins/myorg-basic-app/start',
          configure: jest.fn().mockReturnValue({}),
        },
      ],
    });

    // The read-only registry should have received the new extension
    readOnlyState = await readOnlyRegistry.getState();
    expect(Object.keys(readOnlyState)).toHaveLength(1);

    expect(subscribeCallback).toHaveBeenCalledTimes(2);
    expect(Object.keys(subscribeCallback.mock.calls[1][0])).toEqual(['plugins/myorg-basic-app/start']);
  });

  it('should not register a link added by a plugin in dev-mode if the meta-info is missing from the plugin.json', async () => {
    // Enabling dev mode
    config.buildInfo.env = 'development';

    const registry = await createRegistry();
    const linkConfig = {
      title: 'Link 1',
      description: 'Link 1 description',
      path: `/a/${pluginId}/declare-incident`,
      targets: 'grafana/dashboard/panel/menu',
      configure: jest.fn().mockReturnValue({}),
    };

    registry.register({
      pluginId,
      configs: [linkConfig],
    });

    const currentState = await registry.getState();

    expect(Object.keys(currentState)).toHaveLength(0);
    expect(log.error).toHaveBeenCalled();
  });

  it('should register a link added by core Grafana in dev-mode even if the meta-info is missing', async () => {
    // Enabling dev mode
    config.buildInfo.env = 'development';

    const registry = await createRegistry();
    const linkConfig = {
      title: 'Link 1',
      description: 'Link 1 description',
      path: `/a/grafana/declare-incident`,
      targets: 'grafana/dashboard/panel/menu',
      configure: jest.fn().mockReturnValue({}),
    };

    registry.register({
      pluginId: 'grafana',
      configs: [linkConfig],
    });

    const currentState = await registry.getState();

    expect(Object.keys(currentState)).toHaveLength(1);
    expect(log.error).not.toHaveBeenCalled();
  });

  it('should register a link added by a plugin in production mode even if the meta-info is missing', async () => {
    const registry = await createRegistry();
    const linkConfig = {
      title: 'Link 1',
      description: 'Link 1 description',
      path: `/a/${pluginId}/declare-incident`,
      targets: 'grafana/dashboard/panel/menu',
      configure: jest.fn().mockReturnValue({}),
    };

    registry.register({
      pluginId,
      configs: [linkConfig],
    });

    const currentState = await registry.getState();

    expect(Object.keys(currentState)).toHaveLength(1);
    expect(log.error).not.toHaveBeenCalled();
  });

  it('should register a link added by a plugin in dev-mode if the meta-info is present', async () => {
    // Enabling dev mode
    config.buildInfo.env = 'development';

    const linkConfig = {
      title: 'Link 1',
      description: 'Link 1 description',
      path: `/a/${pluginId}/declare-incident`,
      targets: ['grafana/dashboard/panel/menu'],
      configure: jest.fn().mockReturnValue({}),
    };
    const { description, targets, title } = linkConfig;
    const app = {
      ...basicApp,
      extensions: { ...basicApp.extensions, addedLinks: [{ description, targets, title }] },
    };
    const registry = await createRegistry([app]);

    registry.register({
      pluginId,
      configs: [linkConfig],
    });

    const currentState = await registry.getState();

    expect(Object.keys(currentState)).toHaveLength(1);
    expect(log.error).not.toHaveBeenCalled();
  });

  describe('asObservableSlice', () => {
    it('should return the selected slice from the registry', async () => {
      const registry = await createRegistry();
      const extensionPointId = 'grafana/dashboard/panel/menu';

      registry.register({
        pluginId: 'test-plugin',
        configs: [
          {
            title: 'Test Link',
            description: 'Test description',
            path: '/test-path',
            targets: extensionPointId,
            configure: jest.fn().mockReturnValue({}),
          },
        ],
      });

      const observable = registry.asObservableSlice((state) => state[extensionPointId]).pipe(take(1));

      await expect(observable).toEmitValuesWith((received) => {
        const [slice] = received;

        expect(slice).toBeDefined();
        expect(Array.isArray(slice)).toBe(true);
        expect(slice?.length).toBe(1);
        expect(slice?.[0].title).toBe('Test Link');
      });
    });

    it('should return undefined when the selected key does not exist', async () => {
      const registry = await createRegistry();
      const observable = registry.asObservableSlice((state) => state['non-existent-key']).pipe(take(1));

      await expect(observable).toEmitValuesWith((received) => {
        const [slice] = received;
        expect(slice).toBeUndefined();
      });
    });

    it('should only emit when the selected slice changes (distinctUntilChanged)', async () => {
      const registry = await createRegistry();
      const extensionPointId = 'grafana/dashboard/panel/menu';
      const subscribeCallback = jest.fn();

      const observable = registry.asObservableSlice((state) => state[extensionPointId]);
      observable.subscribe(subscribeCallback);

      // Initial empty state
      expect(subscribeCallback).toHaveBeenCalledTimes(1);
      expect(subscribeCallback.mock.calls[0][0]).toBeUndefined();

      // Register first link
      registry.register({
        pluginId: 'test-plugin-1',
        configs: [
          {
            title: 'Link 1',
            description: 'Description 1',
            path: '/path1',
            targets: extensionPointId,
            configure: jest.fn().mockReturnValue({}),
          },
        ],
      });

      // Should emit because the slice changed
      expect(subscribeCallback).toHaveBeenCalledTimes(2);
      expect(subscribeCallback.mock.calls[1][0]?.length).toBe(1);

      // Register another link to the same extension point
      registry.register({
        pluginId: 'test-plugin-2',
        configs: [
          {
            title: 'Link 2',
            description: 'Description 2',
            path: '/path2',
            targets: extensionPointId,
            configure: jest.fn().mockReturnValue({}),
          },
        ],
      });

      // Should emit because the slice changed (array reference changed)
      expect(subscribeCallback).toHaveBeenCalledTimes(3);
      expect(subscribeCallback.mock.calls[2][0]?.length).toBe(2);

      // Register a link to a different extension point
      registry.register({
        pluginId: 'test-plugin-3',
        configs: [
          {
            title: 'Link 3',
            description: 'Description 3',
            path: '/path3',
            targets: 'grafana/other/point',
            configure: jest.fn().mockReturnValue({}),
          },
        ],
      });

      // Should NOT emit because the selected slice (for extensionPointId) didn't change
      expect(subscribeCallback).toHaveBeenCalledTimes(3);
    });

    it('should deep freeze the selected slice', async () => {
      const registry = await createRegistry();
      const extensionPointId = 'grafana/dashboard/panel/menu';

      registry.register({
        pluginId: 'test-plugin',
        configs: [
          {
            title: 'Test Link',
            description: 'Test description',
            path: '/test-path',
            targets: extensionPointId,
            configure: jest.fn().mockReturnValue({}),
          },
        ],
      });

      const observable = registry.asObservableSlice((state) => state[extensionPointId]).pipe(take(1));

      await expect(observable).toEmitValuesWith((received) => {
        const [slice] = received;

        expect(slice).toBeDefined();
        // @ts-expect-error - Testing that frozen objects cannot be modified
        expect(() => slice.push({})).toThrow();
        expect(() => (slice[0].title = 'Modified')).toThrow();
      });
    });

    it('should work with read-only registries', async () => {
      const registry = await createRegistry();
      const readOnlyRegistry = registry.readOnly();
      const extensionPointId = 'grafana/dashboard/panel/menu';

      registry.register({
        pluginId: 'test-plugin',
        configs: [
          {
            title: 'Test Link',
            description: 'Test description',
            path: '/test-path',
            targets: extensionPointId,
            configure: jest.fn().mockReturnValue({}),
          },
        ],
      });

      const observable = readOnlyRegistry.asObservableSlice((state) => state[extensionPointId]).pipe(take(1));

      await expect(observable).toEmitValuesWith((received) => {
        const [slice] = received;

        expect(slice).toBeDefined();
        expect(Array.isArray(slice)).toBe(true);
        expect(slice?.length).toBe(1);
        expect(slice?.[0].title).toBe('Test Link');
      });
    });

    it('should emit immediately to new subscribers with the current slice value', async () => {
      const registry = await createRegistry();
      const extensionPointId = 'grafana/dashboard/panel/menu';

      registry.register({
        pluginId: 'test-plugin',
        configs: [
          {
            title: 'Test Link',
            description: 'Test description',
            path: '/test-path',
            targets: extensionPointId,
            configure: jest.fn().mockReturnValue({}),
          },
        ],
      });

      // Subscribe after registration
      const observable = registry.asObservableSlice((state) => state[extensionPointId]);
      const subscribeCallback = jest.fn();
      observable.subscribe(subscribeCallback);

      // Should have been called immediately with the current value
      expect(subscribeCallback).toHaveBeenCalledTimes(1);
      expect(subscribeCallback.mock.calls[0][0]?.length).toBe(1);
      expect(subscribeCallback.mock.calls[0][0]?.[0].title).toBe('Test Link');
    });
  });
});
