import { Injector, signal } from '@angular/core';
import { Store } from '@ngrx/store';
import { Subject } from 'rxjs';
import { RealtimeSessionFacade } from '../../../../core/realtime';
import { DatabaseService } from '../../../../infrastructure/persistence';
import { ServerDirectoryFacade } from '../../../server-directory';
import { AttachmentFacade } from '../../../attachment';
import { VoiceConnectionFacade } from '../../../voice-connection/application/facades/voice-connection.facade';
import type {
  Channel,
  Message,
  Room,
  TojuPluginManifest,
  User
} from '../../../../shared-kernel';
import { MessagesActions } from '../../../../store/messages/messages.actions';
import { selectCurrentRoomMessages } from '../../../../store/messages/messages.selectors';
import {
  selectActiveChannelId,
  selectCurrentRoom,
  selectCurrentRoomChannels,
  selectCurrentRoomId
} from '../../../../store/rooms/rooms.selectors';
import { UsersActions } from '../../../../store/users/users.actions';
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
import {
  PLUGIN_CLIENT_API_CAPABILITY_REQUIREMENTS,
  assertPluginApiSurfaceImplemented,
  collectPluginApiMethodPaths,
  type PluginClientApiMethodPath
} from '../../domain/logic/plugin-client-api-surface.rules';
import { PluginCapabilityError, PluginCapabilityService } from './plugin-capability.service';
import { MessageRevisionService } from '../../../chat/application/services/message-revision.service';
import { MessageSigningService } from '../../../authentication/application/services/message-signing.service';
import { PluginClientApiService } from './plugin-client-api.service';
import { PluginDesktopStateService } from './plugin-desktop-state.service';
import { PluginLoggerService } from './plugin-logger.service';
import { PluginMessageBusService } from './plugin-message-bus.service';
import { PluginStorageService } from './plugin-storage.service';
import { PluginUiRegistryService } from './plugin-ui-registry.service';

const TEST_MANIFEST = createTestManifest();

describe('PluginClientApiService', () => {
  let context: ServiceTestContext;

  beforeEach(() => {
    context = createServiceTestContext();
    context.capabilities.grantAll(TEST_MANIFEST);
  });

  it('implements the full public plugin API surface', () => {
    const api = context.service.createApi(TEST_MANIFEST);

    expect(() => assertPluginApiSurfaceImplemented(api as Record<string, Record<string, unknown>>)).not.toThrow();
  });

  it('freezes the API object returned to plugins', () => {
    const api = context.service.createApi(TEST_MANIFEST);

    expect(Object.isFrozen(api)).toBe(true);
    expect(Object.isFrozen(api.commands)).toBe(true);
    expect(Object.isFrozen(api.messages)).toBe(true);
  });

  it('exposes current interaction context without capability checks', () => {
    const api = context.service.createApi(TEST_MANIFEST);

    expect(api.context.getCurrent()).toEqual({
      server: context.room(),
      source: 'manual',
      textChannel: context.channels().find((channel) => channel.id === 'general') ?? null,
      user: context.currentUser(),
      voiceChannel: null
    });
  });

  it('routes logger calls to the plugin logger service', () => {
    const api = context.service.createApi(TEST_MANIFEST);

    api.logger.debug('debug');
    api.logger.error('error');
    api.logger.info('info');
    api.logger.warn('warn');

    expect(context.logger.debug).toHaveBeenCalledWith(TEST_MANIFEST.id, 'debug', undefined);
    expect(context.logger.error).toHaveBeenCalledWith(TEST_MANIFEST.id, 'error', undefined);
    expect(context.logger.info).toHaveBeenCalledWith(TEST_MANIFEST.id, 'info', undefined);
    expect(context.logger.warn).toHaveBeenCalledWith(TEST_MANIFEST.id, 'warn', undefined);
  });

  it('dispatches profile updates through the users store', () => {
    const api = context.service.createApi(TEST_MANIFEST);

    api.profile.update({ displayName: 'Plugin User' });
    api.profile.updateAvatar({
      avatarHash: 'hash',
      avatarMime: 'image/png',
      avatarUrl: '/avatar.png'
    });

    expect(context.store.dispatch).toHaveBeenCalledWith(UsersActions.updateCurrentUserProfile({
      profile: expect.objectContaining({ displayName: 'Plugin User' })
    }));

    expect(context.store.dispatch).toHaveBeenCalledWith(UsersActions.updateCurrentUserAvatar({
      avatar: expect.objectContaining({ avatarUrl: '/avatar.png' })
    }));
  });

  it('sends plugin messages and broadcasts them to peers', async () => {
    const api = context.service.createApi(TEST_MANIFEST);
    const message = api.messages.send('hello plugin');

    expect(message.content).toBe('hello plugin');
    expect(message.roomId).toBe('room-1');

    await new Promise((resolve) => setTimeout(resolve, 0));

    expect(context.store.dispatch).toHaveBeenCalledWith(
      MessagesActions.sendMessageSuccess({
        message: expect.objectContaining({
          content: 'hello plugin',
          roomId: 'room-1'
        })
      })
    );

    expect(context.voice.broadcastMessage).toHaveBeenCalledWith(expect.objectContaining({
      type: 'chat-message',
      message: expect.objectContaining({
        content: 'hello plugin',
        roomId: 'room-1'
      })
    }));

    expect(context.messageRevisions.broadcastRevision).toHaveBeenCalled();
  });

  it('publishes typing state through the realtime facade', () => {
    const api = context.service.createApi(TEST_MANIFEST);

    api.messages.setTyping(true, 'general');

    expect(context.realtime.sendRawMessage).toHaveBeenCalledWith({
      type: 'typing',
      serverId: 'room-1',
      channelId: 'general',
      isTyping: true
    });
  });

  it('forwards slash command registration to the UI registry', () => {
    const api = context.service.createApi(TEST_MANIFEST);
    const contribution = {
      name: 'echo',
      run: () => {}
    };

    api.commands.register('echo', contribution);

    expect(context.uiRegistry.registerSlashCommand).toHaveBeenCalledWith(
      TEST_MANIFEST.id,
      'echo',
      contribution
    );
  });

  it('lists slash commands from the UI registry', () => {
    const commands = [{ name: 'echo', run: () => {} }];

    context.uiRegistry.slashCommands.mockReturnValue(commands);

    const api = context.service.createApi(TEST_MANIFEST);

    expect(api.commands.list()).toBe(commands);
  });

  it('routes storage APIs through the plugin storage service', async () => {
    const api = context.service.createApi(TEST_MANIFEST);

    api.storage.set('key', { ok: true });
    api.storage.get('key');
    api.storage.remove('key');
    await api.clientData.write('client-key', { ok: true });
    await api.clientData.read('client-key');
    await api.clientData.remove('client-key');
    await api.serverData.write('server-key', { ok: true });
    await api.serverData.read('server-key');
    await api.serverData.remove('server-key');

    expect(context.storage.setLocal).toHaveBeenCalledWith(TEST_MANIFEST.id, 'key', { ok: true });
    expect(context.storage.getLocal).toHaveBeenCalledWith(TEST_MANIFEST.id, 'key');
    expect(context.storage.removeLocal).toHaveBeenCalledWith(TEST_MANIFEST.id, 'key');
    expect(context.storage.writeClientData).toHaveBeenCalledWith(TEST_MANIFEST.id, 'client-key', { ok: true });
    expect(context.storage.readClientData).toHaveBeenCalledWith(TEST_MANIFEST.id, 'client-key');
    expect(context.storage.removeClientData).toHaveBeenCalledWith(TEST_MANIFEST.id, 'client-key');
    expect(context.storage.writeServerData).toHaveBeenCalledWith(TEST_MANIFEST.id, 'server-key', { ok: true });
    expect(context.storage.readServerData).toHaveBeenCalledWith(TEST_MANIFEST.id, 'server-key');
    expect(context.storage.removeServerData).toHaveBeenCalledWith(TEST_MANIFEST.id, 'server-key');
  });

  it('publishes declared server events through the realtime facade', () => {
    const api = context.service.createApi(TEST_MANIFEST);

    api.events.publishServer('e2e:server', { ok: true });

    expect(context.realtime.sendRawMessage).toHaveBeenCalledWith(expect.objectContaining({
      type: 'plugin_event',
      eventName: 'e2e:server',
      payload: { ok: true },
      pluginId: TEST_MANIFEST.id,
      serverId: 'room-1'
    }));
  });

  it('rejects undeclared plugin events', () => {
    const api = context.service.createApi(TEST_MANIFEST);

    expect(() => api.events.publishServer('missing:event', {})).toThrow(/did not declare event/);
  });

  it('enforces capability grants for privileged API methods', async () => {
    const api = context.service.createApi(TEST_MANIFEST);

    context.capabilities.revokeAll(TEST_MANIFEST.id);

    for (const path of collectPluginApiMethodPaths()) {
      const capability = PLUGIN_CLIENT_API_CAPABILITY_REQUIREMENTS[path as PluginClientApiMethodPath];

      if (!capability) {
        continue;
      }

      const [namespace, method] = path.split('.') as [keyof typeof api, string];
      const target = api[namespace] as Record<string, (...args: unknown[]) => unknown>;

      await expect(invokeApiMethod(target[method], path)).rejects.toThrow(PluginCapabilityError);
    }
  });
});

interface ServiceTestContext {
  capabilities: PluginCapabilityService;
  channels: ReturnType<typeof signal<Channel[]>>;
  currentUser: ReturnType<typeof signal<User | null>>;
  logger: {
    debug: ReturnType<typeof vi.fn>;
    error: ReturnType<typeof vi.fn>;
    info: ReturnType<typeof vi.fn>;
    warn: ReturnType<typeof vi.fn>;
  };
  messages: ReturnType<typeof signal<Message[]>>;
  realtime: {
    onSignalingMessage: Subject<unknown>;
    sendRawMessage: ReturnType<typeof vi.fn>;
  };
  room: ReturnType<typeof signal<Room | null>>;
  service: PluginClientApiService;
  storage: {
    getLocal: ReturnType<typeof vi.fn>;
    readClientData: ReturnType<typeof vi.fn>;
    readServerData: ReturnType<typeof vi.fn>;
    removeClientData: ReturnType<typeof vi.fn>;
    removeLocal: ReturnType<typeof vi.fn>;
    removeServerData: ReturnType<typeof vi.fn>;
    setLocal: ReturnType<typeof vi.fn>;
    writeClientData: ReturnType<typeof vi.fn>;
    writeServerData: ReturnType<typeof vi.fn>;
  };
  store: {
    dispatch: ReturnType<typeof vi.fn>;
  };
  uiRegistry: {
    registerSlashCommand: ReturnType<typeof vi.fn>;
    slashCommands: ReturnType<typeof vi.fn>;
  };
  voice: {
    broadcastMessage: ReturnType<typeof vi.fn>;
    getConnectedPeers: ReturnType<typeof vi.fn>;
    setInputVolume: ReturnType<typeof vi.fn>;
    setLocalStream: ReturnType<typeof vi.fn>;
    setOutputVolume: ReturnType<typeof vi.fn>;
  };
  messageRevisions: {
    broadcastRevision: ReturnType<typeof vi.fn>;
    createSignedRevision: ReturnType<typeof vi.fn>;
    persistRevision: ReturnType<typeof vi.fn>;
  };
}

function createServiceTestContext(): ServiceTestContext {
  installLocalStorageMock();
  installBrowserMediaMocks();

  const currentUser = signal<User | null>(createUser());
  const users = signal<User[]>(currentUser() ? [currentUser() as User] : []);
  const room = signal<Room | null>(createRoom());
  const channels = signal<Channel[]>(room()?.channels ?? []);
  const messages = signal<Message[]>([]);
  const activeChannelId = signal('general');
  const roomId = signal('room-1');
  const logger = {
    debug: vi.fn(),
    error: vi.fn(),
    info: vi.fn(),
    warn: vi.fn()
  };
  const storage = {
    getLocal: vi.fn(),
    setLocal: vi.fn(),
    removeLocal: vi.fn(),
    readClientData: vi.fn(async () => null),
    writeClientData: vi.fn(async () => undefined),
    removeClientData: vi.fn(async () => undefined),
    readServerData: vi.fn(async () => null),
    writeServerData: vi.fn(async () => undefined),
    removeServerData: vi.fn(async () => undefined)
  };
  const uiRegistry = {
    registerSlashCommand: vi.fn(() => ({ dispose: vi.fn() })),
    slashCommands: vi.fn(() => [])
  };
  const voice = {
    broadcastMessage: vi.fn(),
    getConnectedPeers: vi.fn(() => []),
    setInputVolume: vi.fn(),
    setLocalStream: vi.fn(async () => undefined),
    setOutputVolume: vi.fn()
  };
  const messageRevisions = {
    createSignedRevision: vi.fn(async (input: Parameters<MessageRevisionService['createSignedRevision']>[0]) => ({
      messageId: input.message.id,
      revision: input.type === 'create' ? 0 : (input.message.revision ?? 0) + 1,
      prevRevisionHash: input.type === 'create' ? '' : (input.message.headHash ?? ''),
      headHash: 'test-head-hash',
      type: input.type,
      actorId: input.actorId,
      senderId: input.message.senderId,
      roomId: input.message.roomId,
      channelId: input.message.channelId,
      senderName: input.message.senderName,
      content: input.content ?? input.message.content,
      editedAt: input.editedAt,
      isDeleted: input.isDeleted ?? false,
      replyToId: input.message.replyToId,
      pluginId: input.pluginId,
      signature: input.sign === false ? undefined : 'test-signature'
    })),
    persistRevision: vi.fn(async () => undefined),
    broadcastRevision: vi.fn()
  };
  const realtime = {
    onSignalingMessage: new Subject<unknown>(),
    sendRawMessage: vi.fn()
  };
  const store = {
    dispatch: vi.fn(),
    selectSignal: vi.fn((selector: unknown) => {
      if (selector === selectCurrentUser) {
        return currentUser;
      }

      if (selector === selectAllUsers) {
        return users;
      }

      if (selector === selectCurrentRoom) {
        return room;
      }

      if (selector === selectCurrentRoomChannels) {
        return channels;
      }

      if (selector === selectCurrentRoomMessages) {
        return messages;
      }

      if (selector === selectActiveChannelId) {
        return activeChannelId;
      }

      if (selector === selectCurrentRoomId) {
        return roomId;
      }

      throw new Error(`Unexpected selector in PluginClientApiService test: ${String(selector)}`);
    })
  };
  const injector = Injector.create({
    providers: [
      PluginClientApiService,
      PluginCapabilityService,
      {
        provide: AttachmentFacade,
        useValue: {
          publishAttachments: vi.fn(async () => undefined),
          rememberMessageRoom: vi.fn()
        }
      },
      {
        provide: DatabaseService,
        useValue: {
          getMessageById: vi.fn(async () => null),
          saveMessage: vi.fn(async () => undefined),
          updateMessage: vi.fn(async () => undefined),
          updateRoom: vi.fn(async () => undefined)
        }
      },
      {
        provide: MessageRevisionService,
        useValue: messageRevisions
      },
      {
        provide: MessageSigningService,
        useValue: {
          signRevision: vi.fn(async () => 'test-signature'),
          fetchSigningPublicKey: vi.fn(async () => null),
          verifyRevisionSignature: vi.fn(async () => true)
        }
      },
      {
        provide: PluginDesktopStateService,
        useValue: {
          readJson: vi.fn(async () => null),
          writeJson: vi.fn(async () => undefined)
        }
      },
      {
        provide: PluginLoggerService,
        useValue: logger
      },
      {
        provide: PluginMessageBusService,
        useValue: {
          publish: vi.fn(() => ({ topic: 'test' })),
          sendLatestMessages: vi.fn(() => ({ topic: 'test' })),
          subscribe: vi.fn(() => ({ dispose: vi.fn() }))
        }
      },
      {
        provide: PluginStorageService,
        useValue: storage
      },
      {
        provide: PluginUiRegistryService,
        useValue: uiRegistry
      },
      {
        provide: RealtimeSessionFacade,
        useValue: {
          broadcastMessage: vi.fn(),
          onSignalingMessage: realtime.onSignalingMessage.asObservable(),
          sendRawMessage: realtime.sendRawMessage
        }
      },
      {
        provide: ServerDirectoryFacade,
        useValue: {
          updateServer: vi.fn(() => ({ subscribe: vi.fn() }))
        }
      },
      {
        provide: Store,
        useValue: store
      },
      {
        provide: VoiceConnectionFacade,
        useValue: voice
      }
    ]
  });

  return {
    capabilities: injector.get(PluginCapabilityService),
    channels,
    currentUser,
    logger,
    messages,
    realtime,
    room,
    service: injector.get(PluginClientApiService),
    storage,
    store,
    uiRegistry,
    voice,
    messageRevisions
  };
}

function createTestManifest(): TojuPluginManifest {
  return {
    apiVersion: '1.0.0',
    capabilities: [
      'profile.read',
      'profile.write',
      'users.read',
      'users.manage',
      'roles.read',
      'roles.manage',
      'messages.read',
      'messages.send',
      'messages.editOwn',
      'messages.deleteOwn',
      'messages.moderate',
      'messages.sync',
      'channels.read',
      'channels.manage',
      'server.read',
      'server.manage',
      'p2p.data',
      'media.playAudio',
      'media.addAudioStream',
      'media.addVideoStream',
      'audio.volume',
      'ui.settings',
      'ui.pages',
      'ui.sidePanel',
      'ui.channelsSection',
      'ui.embeds',
      'ui.dom',
      'ui.commands',
      'storage.local',
      'storage.serverData.read',
      'storage.serverData.write',
      'events.server.publish',
      'events.server.subscribe',
      'events.p2p.publish',
      'events.p2p.subscribe'
    ],
    compatibility: {
      minimumTojuVersion: '1.0.0'
    },
    description: 'Plugin API service test fixture',
    events: [
      {
        direction: 'serverRelay',
        eventName: 'e2e:server',
        scope: 'server'
      }
    ],
    id: 'test.plugin-api',
    kind: 'client',
    schemaVersion: 1,
    title: 'Plugin API Service Fixture',
    version: '1.0.0'
  };
}

function createUser(): User {
  return {
    displayName: 'Alice',
    id: 'user-1',
    isOnline: true,
    joinedAt: Date.now(),
    oderId: 'user-1',
    role: 'host',
    status: 'online',
    username: 'alice'
  };
}

function createRoom(): Room {
  return {
    channels: [{ id: 'general', name: 'general', position: 0, type: 'text' }, { id: 'voice', name: 'voice', position: 1, type: 'voice' }],
    description: 'Plugin API room',
    hostId: 'user-1',
    id: 'room-1',
    isPrivate: false,
    members: [],
    name: 'Plugin API Room',
    roles: []
  };
}

async function invokeApiMethod(method: (...args: unknown[]) => unknown, path: string): Promise<unknown> {
  switch (path) {
    case 'attachments.import':
      return method({ files: [], messageId: 'message-1' });
    case 'channels.addAudioChannel':
      return method({ name: 'Audio' });
    case 'channels.addTextChannel':
      return method({ name: 'Text' });
    case 'channels.addVideoChannel':
      return method({ name: 'Video' });
    case 'channels.remove':
      return method('general');
    case 'channels.rename':
      return method('general', 'renamed');
    case 'channels.select':
      return method('general');
    case 'clientData.read':
    case 'serverData.read':
      return method('key');
    case 'clientData.remove':
    case 'serverData.remove':
      return method('key');
    case 'clientData.write':
    case 'serverData.write':
      return method('key', { ok: true });
    case 'commands.register':
      return method('echo', { name: 'echo', run: () => {} });
    case 'events.publishP2p':
    case 'events.publishServer':
      return method('e2e:server', {});
    case 'events.subscribeP2p':
    case 'events.subscribeServer':
      return method({ eventName: 'e2e:server', handler: () => {} });
    case 'media.addCustomAudioStream':
    case 'media.addCustomVideoStream':
      return method({ stream: new MediaStream() });
    case 'media.playAudioClip':
      return method({ url: 'data:audio/wav;base64,' });
    case 'media.setInputVolume':
    case 'media.setOutputVolume':
      return method(0.5);
    case 'messageBus.publish':
      return method({ topic: 'test' });
    case 'messageBus.sendLatestMessages':
      return method({});
    case 'messageBus.subscribe':
      return method({ handler: () => {} });
    case 'messages.delete':
      return method('message-1');
    case 'messages.edit':
      return method('message-1', 'updated');
    case 'messages.moderateDelete':
      return method('message-1');
    case 'messages.import':
      return method([]);
    case 'messages.send':
      return method('hello');
    case 'messages.sendAsPluginUser':
      return method({ content: 'hello', pluginUserId: 'bot' });
    case 'messages.setTyping':
      return method(true);
    case 'messages.subscribeTyping':
      return method(() => {});
    case 'messages.sync':
      return method([]);
    case 'p2p.broadcastData':
      return method('e2e:server', {});
    case 'p2p.sendData':
      return method('peer-1', 'e2e:server', {});
    case 'profile.update':
      return method({ displayName: 'Updated' });
    case 'profile.updateAvatar':
      return method({ avatarHash: 'hash', avatarMime: 'image/png', avatarUrl: '/avatar.png' });
    case 'roles.setAssignments':
      return method([]);
    case 'server.registerPluginUser':
      return method({ displayName: 'Bot' });
    case 'server.updateIcon':
      return method('icon-hash');
    case 'server.updatePermissions':
      return method({ allowVoice: true });
    case 'server.updateSettings':
      return method({ name: 'Room' });
    case 'storage.get':
      return method('key');
    case 'storage.remove':
      return method('key');
    case 'storage.set':
      return method('key', { ok: true });
    case 'ui.mountElement':
      return method('mount', { element: { tagName: 'DIV' }, target: 'body' });
    case 'ui.registerAppPage':
    case 'ui.registerChannelSection':
    case 'ui.registerComposerAction':
    case 'ui.registerEmbedRenderer':
    case 'ui.registerProfileAction':
    case 'ui.registerSettingsPage':
    case 'ui.registerSidePanel':
    case 'ui.registerToolbarAction':
      return method('id', { label: 'Test', render: () => 'ok', run: () => {} });
    case 'users.ban':
      return method('user-2', 'reason');
    case 'users.kick':
      return method('user-2');
    case 'users.setRole':
      return method('user-2', 'member');
    default:
      return Promise.resolve(method());
  }
}

function installBrowserMediaMocks(): void {
  vi.stubGlobal('MediaStream', class MediaStream {});
  vi.stubGlobal('Audio', class Audio {
    volume = 1;

    async play(): Promise<void> {}
  });
}

function installLocalStorageMock(): void {
  const storage = new Map<string, string>();

  vi.stubGlobal('localStorage', {
    getItem: (key: string) => storage.get(key) ?? null,
    setItem: (key: string, value: string) => {
      storage.set(key, value);
    },
    removeItem: (key: string) => {
      storage.delete(key);
    },
    clear: () => {
      storage.clear();
    }
  });
}
