import {
  Injector,
  runInInjectionContext,
  signal,
  ɵChangeDetectionScheduler as ChangeDetectionScheduler,
  ɵEffectScheduler as EffectScheduler
} from '@angular/core';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { Subject } from 'rxjs';
import { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service';
import {
  VoiceActivityService,
  VoiceConnectionFacade,
  VoicePlaybackService
} from '../../../voice-connection';
import { VoiceSessionFacade } from '../../../voice-session';
import { DirectMessageService, PeerDeliveryService } from '../../../direct-message';
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
import type {
  ChatEvent,
  DirectMessageParticipant,
  User
} from '../../../../shared-kernel';
import type { DirectMessageConversation } from '../../../direct-message';
import type { DirectCallSession } from '../../domain/models/direct-call.model';
import { DirectCallService } from './direct-call.service';

const alice = createUser('alice', 'Alice');
const bob = createUser('bob', 'Bob');
const charlie = createUser('charlie', 'Charlie');

describe('DirectCallService', () => {
  it('only keeps sessions visible while a participant is joined', () => {
    const context = createServiceContext({ currentUser: alice, allUsers: [alice, bob] });

    expect(context.service.hasOngoingActivity(createSession('calling', false))).toBe(false);
    expect(context.service.hasOngoingActivity(createSession('ringing', false))).toBe(false);
    expect(context.service.hasOngoingActivity(createSession('connected', true))).toBe(true);
    expect(context.service.hasOngoingActivity(createSession('connected', false))).toBe(false);
    expect(context.service.hasOngoingActivity(createSession('ended', true))).toBe(false);
  });

  it('keeps a locally left call visible only until the last peer leaves', async () => {
    const context = createServiceContext({ currentUser: alice, allUsers: [alice, bob] });
    const session = createSession('connected', true);

    session.participants.bob.joined = true;
    (context.service as DirectCallService & { upsertSession: (nextSession: DirectCallSession) => void }).upsertSession(session);

    expect(context.service.visibleActiveSessions()).toHaveLength(1);

    context.service.leaveCall(session.callId);

    expect(context.service.visibleActiveSessions()).toHaveLength(1);

    context.directCallEvents.next(createCallEvent('leave', bob, ['alice', 'bob']));

    await vi.waitFor(() => expect(context.service.visibleActiveSessions()).toHaveLength(0));
  });

  it('hides an incoming call after the last joined participant leaves before answer', async () => {
    const context = createServiceContext({ currentUser: bob, allUsers: [alice, bob] });

    context.directCallEvents.next(createCallEvent('ring', alice, ['alice', 'bob']));

    await vi.waitFor(() => expect(context.service.visibleActiveSessions()).toHaveLength(1));
    await vi.waitFor(() => expect(context.audio.playLoop).toHaveBeenCalledWith(AppSound.Call));
    expect(context.service.incomingCall()?.callId).toBe('dm-alice-bob');

    context.directCallEvents.next(createCallEvent('leave', alice, ['alice', 'bob']));

    await vi.waitFor(() => expect(context.service.visibleActiveSessions()).toHaveLength(0));
    expect(context.audio.stop).toHaveBeenCalledWith(AppSound.Call);
    expect(context.service.incomingCall()).toBeNull();
  });

  it('suppresses incoming call audio and modal state while do not disturb is active', async () => {
    const busyBob = { ...bob, status: 'busy' as const };
    const context = createServiceContext({ currentUser: busyBob, allUsers: [alice, busyBob] });

    context.directCallEvents.next(createCallEvent('ring', alice, ['alice', 'bob']));

    await vi.waitFor(() => expect(context.service.sessionById('dm-alice-bob')).not.toBeNull());
    expect(context.audio.playLoop).not.toHaveBeenCalled();
    await vi.waitFor(() => expect(context.audio.stop).toHaveBeenCalledWith(AppSound.Call));
    expect(context.service.incomingCall()).toBeNull();
  });

  it('ignores incoming call events when the current user is not a participant', async () => {
    const context = createServiceContext({ currentUser: charlie, allUsers: [alice, bob, charlie] });

    context.directCallEvents.next(createCallEvent('ring', alice, ['alice', 'bob']));

    await vi.waitFor(() => expect(context.service.sessionById('dm-alice-bob')).toBeNull());
    expect(context.audio.playLoop).not.toHaveBeenCalled();
    expect(context.directMessages.createConversation).not.toHaveBeenCalled();
    expect(context.directMessages.createGroupConversation).not.toHaveBeenCalled();
  });

  it('answers an incoming call from the modal action', async () => {
    const context = createServiceContext({ currentUser: bob, allUsers: [alice, bob] });

    context.directCallEvents.next(createCallEvent('ring', alice, ['alice', 'bob']));

    await vi.waitFor(() => expect(context.service.incomingCall()?.callId).toBe('dm-alice-bob'));
    await context.service.answerIncomingCall('dm-alice-bob');

    expect(context.audio.stop).toHaveBeenCalledWith(AppSound.Call);
    expect(context.router.navigate).toHaveBeenCalledWith(['/call', 'dm-alice-bob']);
    expect(context.service.incomingCall()).toBeNull();
  });

  it('declines an incoming call from the modal action', async () => {
    const context = createServiceContext({ currentUser: bob, allUsers: [alice, bob] });

    context.directCallEvents.next(createCallEvent('ring', alice, ['alice', 'bob']));

    await vi.waitFor(() => expect(context.service.incomingCall()?.callId).toBe('dm-alice-bob'));
    context.service.declineIncomingCall('dm-alice-bob');

    expect(context.audio.stop).toHaveBeenCalledWith(AppSound.Call);
    expect(context.delivery.sendCallEvent).toHaveBeenCalledWith('alice', expect.objectContaining({
      directCall: expect.objectContaining({
        action: 'leave',
        callId: 'dm-alice-bob'
      }),
      type: 'direct-call'
    }));

    expect(context.service.sessionById('dm-alice-bob')?.status).toBe('ended');
    expect(context.service.incomingCall()).toBeNull();
  });

  it('rejoins an existing direct call instead of ringing a duplicate after leaving locally', async () => {
    const context = createServiceContext({ currentUser: alice, allUsers: [alice, bob] });
    const session = createSession('connected', true);

    session.participants.alice.joined = false;
    session.participants.bob.joined = true;
    (context.service as DirectCallService & { upsertSession: (nextSession: DirectCallSession) => void }).upsertSession(session);
    context.service.joinCall = vi.fn(async () => undefined);

    await context.service.startCall(bob);

    expect(context.service.visibleActiveSessions()).toHaveLength(1);
    expect(context.service.joinCall).toHaveBeenCalledWith('dm-alice-bob');
    expect(context.delivery.sendCallEvent).not.toHaveBeenCalled();
    expect(context.router.navigate).toHaveBeenCalledWith(['/call', 'dm-alice-bob']);
  });

  it('reuses an existing group call by conversation id instead of creating a duplicate call', async () => {
    const context = createServiceContext({ currentUser: alice, allUsers: [
      alice,
      bob,
      charlie
    ] });
    const session = createGroupSession('dm-original-call', 'dm-group-live', [
      alice,
      bob,
      charlie
    ]);
    const conversation = createGroupConversation('dm-group-live', [
      alice,
      bob,
      charlie
    ]);

    session.participants.alice.joined = false;
    session.participants.bob.joined = true;
    (context.service as DirectCallService & { upsertSession: (nextSession: DirectCallSession) => void }).upsertSession(session);
    context.service.joinCall = vi.fn(async () => undefined);

    await context.service.startConversationCall(conversation);

    expect(context.service.visibleActiveSessions()).toHaveLength(1);
    expect(context.service.joinCall).toHaveBeenCalledWith('dm-original-call');
    expect(context.directMessages.createGroupConversation).not.toHaveBeenCalled();
    expect(context.delivery.sendCallEvent).not.toHaveBeenCalled();
    expect(context.router.navigate).toHaveBeenCalledWith(['/call', 'dm-original-call']);
  });

  it('leaves a joined call before joining a different call', async () => {
    const context = createServiceContext({ currentUser: alice, allUsers: [
      alice,
      bob,
      charlie
    ] });
    const firstSession = createSession('connected', true);
    const nextSession = createDirectSession('dm-alice-charlie', alice, charlie, 'connected', false);

    (context.service as DirectCallService & { upsertSession: (nextSession: DirectCallSession) => void }).upsertSession(firstSession);
    (context.service as DirectCallService & { upsertSession: (nextSession: DirectCallSession) => void }).upsertSession(nextSession);

    await context.service.joinCall(nextSession.callId);

    expect(context.service.sessionById(firstSession.callId)?.participants.alice.joined).toBe(false);
    expect(context.delivery.sendCallEvent).toHaveBeenCalledWith('bob', expect.objectContaining({
      directCall: expect.objectContaining({
        action: 'leave',
        callId: firstSession.callId
      }),
      type: 'direct-call'
    }));
  });

  it('disconnects the current voice channel before joining a call', async () => {
    const voiceConnectedAlice: User = {
      ...alice,
      voiceState: {
        isConnected: true,
        isMuted: false,
        isDeafened: false,
        roomId: 'voice-room-1',
        serverId: 'server-1'
      }
    };
    const context = createServiceContext({ currentUser: voiceConnectedAlice, allUsers: [voiceConnectedAlice, bob] });
    const session = createSession('connected', false);

    session.participants.bob.joined = true;
    (context.service as DirectCallService & { upsertSession: (nextSession: DirectCallSession) => void }).upsertSession(session);

    await context.service.joinCall(session.callId);

    expect(context.voice.stopVoiceHeartbeat).toHaveBeenCalled();
    expect(context.voice.disableVoice).toHaveBeenCalled();
    expect(context.voice.broadcastMessage).toHaveBeenCalledWith(expect.objectContaining({
      type: 'voice-state',
      voiceState: expect.objectContaining({
        isConnected: false,
        roomId: 'voice-room-1',
        serverId: 'server-1'
      })
    }));

    expect(context.voiceSession.endSession).toHaveBeenCalled();
  });

  it('starts group calls by keeping the rail-visible call session and ringing every other participant', async () => {
    const context = createServiceContext({ currentUser: alice, allUsers: [
      alice,
      bob,
      charlie
    ] });
    const conversation = createGroupConversation('dm-group-test', [
      alice,
      bob,
      charlie
    ]);

    context.service.joinCall = vi.fn(async (callId: string) => {
      const session = context.service.sessionById(callId);

      if (!session) {
        return;
      }

      (context.service as DirectCallService & { upsertSession: (nextSession: DirectCallSession) => void }).upsertSession({
        ...session,
        status: 'connected',
        participants: {
          ...session.participants,
          alice: {
            ...session.participants.alice,
            joined: true
          }
        }
      });
    });

    await context.service.startConversationCall(conversation);

    expect(context.service.visibleActiveSessions()).toHaveLength(1);
    expect(context.delivery.sendCallEvent).toHaveBeenCalledWith('bob', expect.objectContaining({
      directCall: expect.objectContaining({
        action: 'ring',
        callId: 'dm-group-test'
      }),
      type: 'direct-call'
    }));

    expect(context.delivery.sendCallEvent).toHaveBeenCalledWith('charlie', expect.objectContaining({
      directCall: expect.objectContaining({
        action: 'ring',
        callId: 'dm-group-test'
      }),
      type: 'direct-call'
    }));

    expect(context.router.navigate).toHaveBeenCalledWith(['/call', 'dm-group-test']);
  });
});

interface ServiceContextOptions {
  allUsers: User[];
  currentUser: User;
}

interface ServiceContext {
  audio: {
    playLoop: ReturnType<typeof vi.fn>;
    stop: ReturnType<typeof vi.fn>;
  };
  delivery: {
    sendCallEvent: ReturnType<typeof vi.fn>;
  };
  directCallEvents: Subject<ChatEvent>;
  directMessages: {
    createConversation: ReturnType<typeof vi.fn>;
    createGroupConversation: ReturnType<typeof vi.fn>;
    openConversation: ReturnType<typeof vi.fn>;
  };
  router: {
    navigate: ReturnType<typeof vi.fn>;
  };
  service: DirectCallService;
  voice: {
    broadcastMessage: ReturnType<typeof vi.fn>;
    disableVoice: ReturnType<typeof vi.fn>;
    stopVoiceHeartbeat: ReturnType<typeof vi.fn>;
  };
  voiceSession: {
    endSession: ReturnType<typeof vi.fn>;
  };
}

function createServiceContext(options: ServiceContextOptions): ServiceContext {
  const currentUser = signal<User | null>(options.currentUser);
  const allUsers = signal<User[]>(options.allUsers);
  const directCallEvents = new Subject<ChatEvent>();
  const router = {
    navigate: vi.fn(async () => true)
  };
  const store = {
    dispatch: vi.fn(),
    selectSignal: vi.fn((selector: unknown) => {
      if (selector === selectCurrentUser) {
        return currentUser;
      }

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

      throw new Error('Unexpected selector requested by DirectCallService test.');
    })
  };
  const directMessages = {
    createConversation: vi.fn(async (user: User) => createDirectConversation(options.currentUser, user)),
    createGroupConversation: vi.fn(async (participants: DirectMessageParticipant[], title?: string, conversationId = 'dm-group-test') => ({
      ...createGroupConversation(conversationId, participants.map(participantToUser)),
      title
    })),
    openConversation: vi.fn(async () => undefined)
  };
  const delivery = {
    directCallEvents$: directCallEvents.asObservable(),
    sendCallEvent: vi.fn(() => true)
  };
  const audio = {
    playLoop: vi.fn(),
    stop: vi.fn()
  };
  const voice = {
    broadcastMessage: vi.fn(),
    disableVoice: vi.fn(),
    ensureSignalingConnected: vi.fn(async () => true),
    isDeafened: vi.fn(() => false),
    isMuted: vi.fn(() => false),
    setLocalStream: vi.fn(async () => undefined),
    startVoiceHeartbeat: vi.fn(),
    stopVoiceHeartbeat: vi.fn(),
    syncOutgoingVoiceRouting: vi.fn(),
    toggleMute: vi.fn()
  };
  const voiceSession = {
    endSession: vi.fn()
  };
  const injector = Injector.create({
    providers: [
      {
        provide: ChangeDetectionScheduler,
        useValue: {
          notify: vi.fn()
        }
      },
      {
        provide: EffectScheduler,
        useValue: {
          add: vi.fn(),
          flush: vi.fn(),
          remove: vi.fn(),
          schedule: vi.fn()
        }
      },
      {
        provide: DirectMessageService,
        useValue: directMessages
      },
      {
        provide: NotificationAudioService,
        useValue: audio
      },
      {
        provide: PeerDeliveryService,
        useValue: delivery
      },
      {
        provide: Router,
        useValue: router
      },
      {
        provide: Store,
        useValue: store
      },
      {
        provide: VoiceActivityService,
        useValue: {
          trackLocalMic: vi.fn(),
          untrackLocalMic: vi.fn()
        }
      },
      {
        provide: VoiceConnectionFacade,
        useValue: voice
      },
      {
        provide: VoiceSessionFacade,
        useValue: voiceSession
      },
      {
        provide: VoicePlaybackService,
        useValue: {
          playPendingStreams: vi.fn(),
          teardownAll: vi.fn()
        }
      }
    ]
  });

  return {
    audio,
    delivery,
    directCallEvents,
    directMessages,
    router,
    service: runInInjectionContext(injector, () => new DirectCallService()),
    voice,
    voiceSession
  };
}

function createCallEvent(action: 'leave' | 'ring', sender: User, participantIds: string[]): ChatEvent {
  return {
    type: 'direct-call',
    directCall: {
      action,
      callId: 'dm-alice-bob',
      conversationId: 'dm-alice-bob',
      createdAt: 10,
      sender: toParticipant(sender),
      participantIds,
      participants: [alice, bob].map(toParticipant)
    }
  };
}

function createSession(status: DirectCallSession['status'], joined: boolean): DirectCallSession {
  return {
    callId: 'dm-alice-bob',
    conversationId: 'dm-alice-bob',
    createdAt: 10,
    initiatorId: 'alice',
    participantIds: ['alice', 'bob'],
    participants: {
      alice: {
        userId: 'alice',
        profile: toParticipant(alice),
        joined
      },
      bob: {
        userId: 'bob',
        profile: toParticipant(bob),
        joined: false
      }
    },
    status
  };
}

function createDirectSession(
  callId: string,
  currentUser: User,
  peer: User,
  status: DirectCallSession['status'],
  joined: boolean
): DirectCallSession {
  const currentParticipant = toParticipant(currentUser);
  const peerParticipant = toParticipant(peer);

  return {
    callId,
    conversationId: callId,
    createdAt: 10,
    initiatorId: currentParticipant.userId,
    participantIds: [currentParticipant.userId, peerParticipant.userId],
    participants: {
      [currentParticipant.userId]: {
        userId: currentParticipant.userId,
        profile: currentParticipant,
        joined
      },
      [peerParticipant.userId]: {
        userId: peerParticipant.userId,
        profile: peerParticipant,
        joined: false
      }
    },
    status
  };
}

function createGroupSession(callId: string, conversationId: string, users: User[]): DirectCallSession {
  const participants = users.map(toParticipant);

  return {
    callId,
    conversationId,
    createdAt: 10,
    initiatorId: participants[0].userId,
    participantIds: participants.map((participant) => participant.userId),
    participants: Object.fromEntries(participants.map((participant) => [
      participant.userId,
      {
        userId: participant.userId,
        profile: participant,
        joined: false
      }
    ])),
    status: 'connected'
  };
}

function createDirectConversation(currentUser: User, peer: User): DirectMessageConversation {
  const participants = [toParticipant(currentUser), toParticipant(peer)];
  const participantIds = participants.map((participant) => participant.userId).sort();

  return {
    id: `dm-${participantIds.join('-')}`,
    kind: 'direct',
    lastMessageAt: 10,
    messages: [],
    participantProfiles: Object.fromEntries(participants.map((participant) => [participant.userId, participant])),
    participants: participantIds,
    unreadCount: 0
  };
}

function createGroupConversation(conversationId: string, users: User[]): DirectMessageConversation {
  const participants = users.map(toParticipant);
  const participantIds = participants.map((participant) => participant.userId).sort();

  return {
    id: conversationId,
    kind: 'group',
    lastMessageAt: 10,
    messages: [],
    participantProfiles: Object.fromEntries(participants.map((participant) => [participant.userId, participant])),
    participants: participantIds,
    title: participants.map((participant) => participant.displayName).join(', '),
    unreadCount: 0
  };
}

function participantToUser(participant: DirectMessageParticipant): User {
  return createUser(participant.userId, participant.displayName);
}

function toParticipant(user: User): DirectMessageParticipant {
  return {
    userId: user.oderId || user.id,
    username: user.username,
    displayName: user.displayName
  };
}

function createUser(id: string, displayName: string): User {
  return {
    id,
    oderId: id,
    username: displayName.toLowerCase(),
    displayName,
    status: 'online',
    role: 'member',
    joinedAt: 1
  };
}
