/**
 * Notification slice unit tests.
 * Tests navigateToError behavior for sidebar session highlighting.
 */

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import type { DetectedError } from '../../../src/renderer/types/data';

const hoisted = vi.hoisted(() => ({
  notificationsGet: vi.fn(),
  notificationsMarkRead: vi.fn(),
  notificationsMarkAllRead: vi.fn(),
  notificationsDelete: vi.fn(),
  notificationsClear: vi.fn(),
  getSessionsPaginated: vi.fn(),
  getSessionDetail: vi.fn(),
  getProjects: vi.fn(),
  getRepositoryGroups: vi.fn(),
  configGet: vi.fn(),
}));

vi.mock('@renderer/api', () => ({
  api: {
    notifications: {
      get: hoisted.notificationsGet,
      markRead: hoisted.notificationsMarkRead,
      markAllRead: hoisted.notificationsMarkAllRead,
      delete: hoisted.notificationsDelete,
      clear: hoisted.notificationsClear,
      onNew: vi.fn(() => () => undefined),
      onUpdated: vi.fn(() => () => undefined),
      getUnreadCount: vi.fn(),
      markAsRead: vi.fn(),
      markAllAsRead: vi.fn(),
      testNotification: vi.fn(),
    },
    getSessionsPaginated: hoisted.getSessionsPaginated,
    getSessionDetail: hoisted.getSessionDetail,
    getProjects: hoisted.getProjects,
    getRepositoryGroups: hoisted.getRepositoryGroups,
    config: {
      get: hoisted.configGet,
    },
  },
}));

import { createTestStore, type TestStore } from './storeTestUtils';

describe('notificationSlice', () => {
  let store: TestStore;

  beforeEach(() => {
    vi.useFakeTimers();

    hoisted.notificationsGet.mockResolvedValue({ notifications: [], unreadCount: 0 });
    hoisted.notificationsMarkRead.mockResolvedValue(true);
    hoisted.notificationsMarkAllRead.mockResolvedValue(true);
    hoisted.notificationsDelete.mockResolvedValue(true);
    hoisted.notificationsClear.mockResolvedValue(true);
    hoisted.getSessionsPaginated.mockResolvedValue({
      sessions: [],
      nextCursor: null,
      hasMore: false,
      totalCount: 0,
    });
    hoisted.getSessionDetail.mockResolvedValue(null);
    hoisted.configGet.mockResolvedValue({
      notifications: { enabled: true },
      sessions: { pinnedSessions: {}, hiddenSessions: {} },
    });

    store = createTestStore();

    // Mock crypto.randomUUID for predictable tab IDs
    let uuidCounter = 0;
    vi.stubGlobal('crypto', {
      randomUUID: () => `test-uuid-${++uuidCounter}`,
    });
  });

  afterEach(() => {
    vi.useRealTimers();
    vi.restoreAllMocks();
  });

  describe('notification mutation fallbacks', () => {
    it('re-fetches notifications when markRead returns false', async () => {
      store.setState({
        notifications: [
          {
            id: 'n1',
            message: 'msg',
            isRead: false,
          },
        ] as never[],
      });

      hoisted.notificationsMarkRead.mockResolvedValue(false);
      hoisted.notificationsGet.mockResolvedValue({
        notifications: [{ id: 'n1', message: 'msg', isRead: false }],
      });

      await store.getState().markNotificationRead('n1');

      expect(hoisted.notificationsGet).toHaveBeenCalled();
    });

    it('re-fetches notifications when clear returns false', async () => {
      store.setState({
        notifications: [{ id: 'n1', message: 'msg', isRead: true }] as never[],
      });

      hoisted.notificationsClear.mockResolvedValue(false);
      hoisted.notificationsGet.mockResolvedValue({
        notifications: [{ id: 'n1', message: 'msg', isRead: true }],
      });

      await store.getState().clearNotifications();

      expect(hoisted.notificationsGet).toHaveBeenCalled();
    });
  });

  describe('scoped markAllNotificationsRead', () => {
    const makeNotification = (
      id: string,
      triggerName: string | undefined,
      isRead: boolean
    ): DetectedError => ({
      id,
      sessionId: 's1',
      projectId: 'p1',
      filePath: '/path/to/session.jsonl',
      source: 'tool',
      lineNumber: 1,
      timestamp: Date.now(),
      createdAt: Date.now(),
      triggerName,
      message: `msg-${id}`,
      isRead,
      context: { projectName: 'test-project' },
    });

    it('marks only matching trigger notifications as read', async () => {
      const n1 = makeNotification('n1', 'tool result error', false);
      const n2 = makeNotification('n2', 'high token usage', false);
      const n3 = makeNotification('n3', 'tool result error', false);
      store.setState({ notifications: [n1, n2, n3] as never[], unreadCount: 3 });

      await store.getState().markAllNotificationsRead('tool result error');

      const state = store.getState();
      expect(state.notifications.find((n) => n.id === 'n1')!.isRead).toBe(true);
      expect(state.notifications.find((n) => n.id === 'n2')!.isRead).toBe(false);
      expect(state.notifications.find((n) => n.id === 'n3')!.isRead).toBe(true);
      expect(state.unreadCount).toBe(1);
    });

    it('calls markRead individually for each matching notification', async () => {
      const n1 = makeNotification('n1', 'trigger-a', false);
      const n2 = makeNotification('n2', 'trigger-a', false);
      store.setState({ notifications: [n1, n2] as never[], unreadCount: 2 });

      await store.getState().markAllNotificationsRead('trigger-a');

      expect(hoisted.notificationsMarkRead).toHaveBeenCalledWith('n1');
      expect(hoisted.notificationsMarkRead).toHaveBeenCalledWith('n2');
      expect(hoisted.notificationsMarkAllRead).not.toHaveBeenCalled();
    });

    it('uses markAllRead API when no triggerName is provided', async () => {
      const n1 = makeNotification('n1', 'trigger-a', false);
      store.setState({ notifications: [n1] as never[], unreadCount: 1 });

      await store.getState().markAllNotificationsRead();

      expect(hoisted.notificationsMarkAllRead).toHaveBeenCalled();
      expect(hoisted.notificationsMarkRead).not.toHaveBeenCalled();
    });

    it('treats notifications without triggerName as "Other"', async () => {
      const n1 = makeNotification('n1', undefined, false);
      const n2 = makeNotification('n2', 'trigger-a', false);
      store.setState({ notifications: [n1, n2] as never[], unreadCount: 2 });

      await store.getState().markAllNotificationsRead('Other');

      expect(store.getState().notifications.find((n) => n.id === 'n1')!.isRead).toBe(true);
      expect(store.getState().notifications.find((n) => n.id === 'n2')!.isRead).toBe(false);
      expect(store.getState().unreadCount).toBe(1);
    });

    it('skips already-read notifications in scoped mode', async () => {
      const n1 = makeNotification('n1', 'trigger-a', true);
      const n2 = makeNotification('n2', 'trigger-a', false);
      store.setState({ notifications: [n1, n2] as never[], unreadCount: 1 });

      await store.getState().markAllNotificationsRead('trigger-a');

      // Only n2 should be sent to API (n1 already read)
      expect(hoisted.notificationsMarkRead).toHaveBeenCalledTimes(1);
      expect(hoisted.notificationsMarkRead).toHaveBeenCalledWith('n2');
    });

    it('no-ops when no unread notifications match the trigger', async () => {
      const n1 = makeNotification('n1', 'trigger-a', true);
      store.setState({ notifications: [n1] as never[], unreadCount: 0 });

      await store.getState().markAllNotificationsRead('trigger-a');

      expect(hoisted.notificationsMarkRead).not.toHaveBeenCalled();
    });

    it('re-fetches when any scoped markRead call fails', async () => {
      const n1 = makeNotification('n1', 'trigger-a', false);
      const n2 = makeNotification('n2', 'trigger-a', false);
      store.setState({ notifications: [n1, n2] as never[], unreadCount: 2 });

      hoisted.notificationsMarkRead.mockResolvedValueOnce(true).mockResolvedValueOnce(false);
      hoisted.notificationsGet.mockResolvedValue({ notifications: [] });

      await store.getState().markAllNotificationsRead('trigger-a');

      expect(hoisted.notificationsGet).toHaveBeenCalled();
    });
  });

  describe('scoped clearNotifications', () => {
    const makeNotification = (
      id: string,
      triggerName: string | undefined,
      isRead: boolean
    ): DetectedError => ({
      id,
      sessionId: 's1',
      projectId: 'p1',
      filePath: '/path/to/session.jsonl',
      source: 'tool',
      lineNumber: 1,
      timestamp: Date.now(),
      createdAt: Date.now(),
      triggerName,
      message: `msg-${id}`,
      isRead,
      context: { projectName: 'test-project' },
    });

    it('deletes only matching trigger notifications', async () => {
      const n1 = makeNotification('n1', 'tool result error', false);
      const n2 = makeNotification('n2', 'high token usage', false);
      const n3 = makeNotification('n3', 'tool result error', true);
      store.setState({ notifications: [n1, n2, n3] as never[], unreadCount: 2 });

      await store.getState().clearNotifications('tool result error');

      const state = store.getState();
      expect(state.notifications).toHaveLength(1);
      expect(state.notifications[0].id).toBe('n2');
      expect(state.unreadCount).toBe(1);
    });

    it('calls delete individually for each matching notification', async () => {
      const n1 = makeNotification('n1', 'trigger-a', false);
      const n2 = makeNotification('n2', 'trigger-a', true);
      store.setState({ notifications: [n1, n2] as never[], unreadCount: 1 });

      await store.getState().clearNotifications('trigger-a');

      expect(hoisted.notificationsDelete).toHaveBeenCalledWith('n1');
      expect(hoisted.notificationsDelete).toHaveBeenCalledWith('n2');
      expect(hoisted.notificationsClear).not.toHaveBeenCalled();
    });

    it('uses clear API when no triggerName is provided', async () => {
      const n1 = makeNotification('n1', 'trigger-a', false);
      store.setState({ notifications: [n1] as never[], unreadCount: 1 });

      await store.getState().clearNotifications();

      expect(hoisted.notificationsClear).toHaveBeenCalled();
      expect(hoisted.notificationsDelete).not.toHaveBeenCalled();
    });

    it('treats notifications without triggerName as "Other"', async () => {
      const n1 = makeNotification('n1', undefined, false);
      const n2 = makeNotification('n2', 'trigger-a', false);
      store.setState({ notifications: [n1, n2] as never[], unreadCount: 2 });

      await store.getState().clearNotifications('Other');

      const state = store.getState();
      expect(state.notifications).toHaveLength(1);
      expect(state.notifications[0].id).toBe('n2');
      expect(state.unreadCount).toBe(1);
    });

    it('clears both read and unread notifications for the trigger', async () => {
      const n1 = makeNotification('n1', 'trigger-a', false);
      const n2 = makeNotification('n2', 'trigger-a', true);
      store.setState({ notifications: [n1, n2] as never[], unreadCount: 1 });

      await store.getState().clearNotifications('trigger-a');

      expect(store.getState().notifications).toHaveLength(0);
      expect(store.getState().unreadCount).toBe(0);
    });

    it('no-ops when no notifications match the trigger', async () => {
      const n1 = makeNotification('n1', 'trigger-b', false);
      store.setState({ notifications: [n1] as never[], unreadCount: 1 });

      await store.getState().clearNotifications('trigger-a');

      expect(hoisted.notificationsDelete).not.toHaveBeenCalled();
      expect(store.getState().notifications).toHaveLength(1);
    });

    it('re-fetches when any scoped delete call fails', async () => {
      const n1 = makeNotification('n1', 'trigger-a', false);
      const n2 = makeNotification('n2', 'trigger-a', false);
      store.setState({ notifications: [n1, n2] as never[], unreadCount: 2 });

      hoisted.notificationsDelete.mockResolvedValueOnce(true).mockResolvedValueOnce(false);
      hoisted.notificationsGet.mockResolvedValue({ notifications: [] });

      await store.getState().clearNotifications('trigger-a');

      expect(hoisted.notificationsGet).toHaveBeenCalled();
    });

    it('correctly recalculates unreadCount after scoped clear', async () => {
      const n1 = makeNotification('n1', 'trigger-a', false);
      const n2 = makeNotification('n2', 'trigger-b', false);
      const n3 = makeNotification('n3', 'trigger-b', true);
      store.setState({ notifications: [n1, n2, n3] as never[], unreadCount: 2 });

      await store.getState().clearNotifications('trigger-a');

      // n1 removed (trigger-a, unread), n2+n3 remain
      expect(store.getState().notifications).toHaveLength(2);
      expect(store.getState().unreadCount).toBe(1); // only n2 is unread
    });
  });

  describe('navigateToError', () => {
    const createMockError = (overrides?: Partial<DetectedError>): DetectedError => ({
      id: 'error-1',
      sessionId: 'session-target',
      projectId: 'project-1',
      filePath: '/path/to/session.jsonl',
      source: 'tool',
      lineNumber: 42,
      timestamp: Date.now(),
      createdAt: Date.now(),
      toolUseId: 'tool-1',
      triggerName: 'test-trigger',
      message: 'Test error message',
      isRead: false,
      context: { projectName: 'test-project' },
      ...overrides,
    });

    describe('flat mode (viewMode !== grouped)', () => {
      beforeEach(() => {
        store.setState({
          viewMode: 'flat',
          projects: [
            {
              id: 'project-1',
              name: 'Project 1',
              path: '/path/1',
              sessions: ['session-1', 'session-target'],
            },
          ] as never[],
        });

        hoisted.getSessionsPaginated.mockResolvedValue({
          sessions: [{ id: 'session-1' }] as never[],
          nextCursor: null,
          hasMore: false,
          totalCount: 1,
        });

        hoisted.getSessionDetail.mockResolvedValue({
          session: { id: 'session-target' },
          chunks: [],
        } as never);
      });

      it('should set selectedSessionId when navigating to error', () => {
        const error = createMockError();

        store.getState().navigateToError(error);

        // selectedSessionId should be set to the target session
        expect(store.getState().selectedSessionId).toBe('session-target');
      });

      it('should create new tab with correct sessionId and pendingNavigation', () => {
        const error = createMockError();

        store.getState().navigateToError(error);

        expect(store.getState().openTabs).toHaveLength(1);
        expect(store.getState().openTabs[0].sessionId).toBe('session-target');
        expect(store.getState().openTabs[0].projectId).toBe('project-1');
        expect(store.getState().openTabs[0].pendingNavigation?.kind).toBe('error');
      });

      it('should set selectedSessionId even when switching from different project', () => {
        // Start with a different project selected
        store.setState({
          selectedProjectId: 'project-other',
          selectedSessionId: 'session-other',
        });

        const error = createMockError();

        store.getState().navigateToError(error);

        // Should update to target session
        expect(store.getState().selectedSessionId).toBe('session-target');
        expect(store.getState().selectedProjectId).toBe('project-1');
      });

      it('should not highlight wrong session from previous tab state', () => {
        // Setup: Have an old session selected
        store.setState({
          selectedProjectId: 'project-1',
          selectedSessionId: 'session-old',
        });

        const error = createMockError();

        store.getState().navigateToError(error);

        // Should NOT retain old session, should be updated to target
        expect(store.getState().selectedSessionId).not.toBe('session-old');
        expect(store.getState().selectedSessionId).toBe('session-target');
      });
    });

    describe('grouped mode (viewMode === grouped)', () => {
      beforeEach(() => {
        store.setState({
          viewMode: 'grouped',
          repositoryGroups: [
            {
              id: 'repo-1',
              name: 'Repo 1',
              worktrees: [
                {
                  id: 'project-1',
                  name: 'Worktree 1',
                  path: '/path/1',
                  sessions: ['session-1', 'session-target'],
                },
              ],
            },
          ] as never[],
        });

        hoisted.getSessionsPaginated.mockResolvedValue({
          sessions: [{ id: 'session-1' }] as never[],
          nextCursor: null,
          hasMore: false,
          totalCount: 1,
        });

        hoisted.getSessionDetail.mockResolvedValue({
          session: { id: 'session-target' },
          chunks: [],
        } as never);
      });

      it('should set selectedSessionId when navigating to error in grouped mode', () => {
        const error = createMockError();

        store.getState().navigateToError(error);

        // selectedSessionId should be set to the target session
        expect(store.getState().selectedSessionId).toBe('session-target');
      });

      it('should set repository and worktree selection', () => {
        const error = createMockError();

        store.getState().navigateToError(error);

        expect(store.getState().selectedRepositoryId).toBe('repo-1');
        expect(store.getState().selectedWorktreeId).toBe('project-1');
      });

      it('should not highlight wrong session from previous state in grouped mode', () => {
        // Setup: Have an old session selected
        store.setState({
          selectedRepositoryId: 'repo-1',
          selectedWorktreeId: 'project-1',
          selectedSessionId: 'session-old',
        });

        const error = createMockError();

        store.getState().navigateToError(error);

        // Should NOT retain old session
        expect(store.getState().selectedSessionId).not.toBe('session-old');
        expect(store.getState().selectedSessionId).toBe('session-target');
      });
    });

    describe('team notification navigation', () => {
      it('should open team tab for any team notification (sessionId starts with "team:")', () => {
        const teamError = createMockError({
          sessionId: 'team:alpha-team',
          source: 'user_inbox',
          category: 'team' as never,
          teamEventType: 'user_inbox' as never,
        });

        store.getState().navigateToError(teamError);

        // Should open a team tab, not a session tab
        const tabs = store.getState().openTabs;
        expect(tabs).toHaveLength(1);
        expect(tabs[0].type).toBe('team');
      });

      it('should open team tab for rate-limit notification with team sessionId', () => {
        const teamError = createMockError({
          sessionId: 'team:beta-team',
          source: 'rate_limit',
          category: 'team' as never,
          teamEventType: 'rate_limit' as never,
        });

        store.getState().navigateToError(teamError);

        const tabs = store.getState().openTabs;
        expect(tabs).toHaveLength(1);
        expect(tabs[0].type).toBe('team');
      });

      it('should open team tab regardless of source field value', () => {
        const teamError = createMockError({
          sessionId: 'team:gamma-team',
          source: 'task_clarification',
        });

        store.getState().navigateToError(teamError);

        const tabs = store.getState().openTabs;
        expect(tabs).toHaveLength(1);
        expect(tabs[0].type).toBe('team');
      });
    });

    describe('existing tab behavior', () => {
      it('should focus existing tab if session is already open', () => {
        // Open target session tab first
        store.getState().openTab({
          type: 'session',
          sessionId: 'session-target',
          projectId: 'project-1',
          label: 'Target Session',
        });
        const existingTabId = store.getState().activeTabId;

        // Open another tab
        store.getState().openDashboard();

        const error = createMockError();

        store.getState().navigateToError(error);

        // Should focus existing tab, not create new
        expect(store.getState().openTabs).toHaveLength(2);
        expect(store.getState().activeTabId).toBe(existingTabId);
      });

      it('should enqueue error navigation request on existing tab', () => {
        // Open target session tab first
        store.getState().openTab({
          type: 'session',
          sessionId: 'session-target',
          projectId: 'project-1',
          label: 'Target Session',
        });

        const error = createMockError({
          lineNumber: 100,
        });

        store.getState().navigateToError(error);

        const tab = store.getState().openTabs[0];
        expect(tab.pendingNavigation).toBeDefined();
        expect(tab.pendingNavigation?.kind).toBe('error');
        expect(tab.pendingNavigation?.highlight).toBe('red');
        expect(tab.pendingNavigation?.payload).toMatchObject({
          errorId: 'error-1',
          lineNumber: 100,
          toolUseId: 'tool-1',
        });
      });

      it('should create new nonce on repeated clicks', () => {
        store.getState().openTab({
          type: 'session',
          sessionId: 'session-target',
          projectId: 'project-1',
          label: 'Target Session',
        });

        const error = createMockError();

        store.getState().navigateToError(error);
        const firstId = store.getState().openTabs[0].pendingNavigation?.id;

        store.getState().navigateToError(error);
        const secondId = store.getState().openTabs[0].pendingNavigation?.id;

        expect(firstId).toBeDefined();
        expect(secondId).toBeDefined();
        expect(firstId).not.toBe(secondId);
      });
    });

    describe('sidebar highlighting with pagination', () => {
      /**
       * Test scenario: Session exists but is not in the first page (pagination).
       *
       * The sidebar only renders sessions that are in the `sessions` array.
       * If selectedSessionId is set to a session not in the loaded list,
       * nothing will be highlighted (correct behavior).
       *
       * The fix ensures selectedSessionId is always set to the target session,
       * rather than retaining a stale value that might match a loaded session.
       */
      it('should set selectedSessionId to target even if not in loaded sessions list', () => {
        store.setState({
          viewMode: 'flat',
          projects: [
            {
              id: 'project-1',
              name: 'Project 1',
              path: '/path/1',
              sessions: ['session-1', 'session-target'],
            },
          ] as never[],
          // Simulating: first page loaded, target session not included
          sessions: [{ id: 'session-1', createdAt: '2024-01-15' }] as never[],
        });

        hoisted.getSessionsPaginated.mockResolvedValue({
          sessions: [{ id: 'session-1' }] as never[],
          nextCursor: 'cursor-1',
          hasMore: true,
          totalCount: 100,
        });

        hoisted.getSessionDetail.mockResolvedValue({
          session: { id: 'session-target' },
          chunks: [],
        } as never);

        const error = createMockError();

        store.getState().navigateToError(error);

        // selectedSessionId should be set to target, even if not in loaded sessions
        expect(store.getState().selectedSessionId).toBe('session-target');

        // Verify the session is NOT in the current loaded list (simulating pagination)
        const loadedSessionIds = store.getState().sessions.map((s) => s.id);
        expect(loadedSessionIds).not.toContain('session-target');

        // Sidebar behavior: isActive = selectedSessionId === item.session.id
        // Since 'session-target' is not in sessions array, it won't be rendered
        // and therefore won't be highlighted. Only 'session-1' is rendered,
        // but selectedSessionId doesn't match it, so nothing is highlighted.
        // This is the correct behavior.
      });

      it('should correctly highlight when target session IS in loaded list', async () => {
        store.setState({
          viewMode: 'flat',
          projects: [
            {
              id: 'project-1',
              name: 'Project 1',
              path: '/path/1',
              sessions: ['session-1', 'session-target'],
            },
          ] as never[],
        });

        hoisted.getSessionsPaginated.mockResolvedValue({
          sessions: [{ id: 'session-1' }, { id: 'session-target' }] as never[],
          nextCursor: null,
          hasMore: false,
          totalCount: 2,
        });

        hoisted.getSessionDetail.mockResolvedValue({
          session: { id: 'session-target' },
          chunks: [],
        } as never);

        const error = createMockError();

        store.getState().navigateToError(error);

        // selectedSessionId should match target immediately
        expect(store.getState().selectedSessionId).toBe('session-target');

        // Wait for async fetch to complete
        await vi.runAllTimersAsync();

        // Verify the session IS in the loaded list after fetch
        const loadedSessionIds = store.getState().sessions.map((s) => s.id);
        expect(loadedSessionIds).toContain('session-target');

        // Sidebar behavior: isActive = selectedSessionId === item.session.id
        // Since 'session-target' is in sessions array and selectedSessionId matches,
        // it will be highlighted correctly.
      });

      it('should not highlight unrelated session when target is not loaded', () => {
        store.setState({
          viewMode: 'flat',
          projects: [
            {
              id: 'project-1',
              name: 'Project 1',
              path: '/path/1',
              sessions: ['session-1', 'session-target'],
            },
          ] as never[],
          // Only session-1 is loaded, and it was previously selected
          sessions: [{ id: 'session-1', createdAt: '2024-01-15' }] as never[],
          selectedSessionId: 'session-1', // Previous selection that might cause wrong highlight
        });

        hoisted.getSessionsPaginated.mockResolvedValue({
          sessions: [{ id: 'session-1' }] as never[],
          nextCursor: 'cursor-1',
          hasMore: true,
          totalCount: 100,
        });

        hoisted.getSessionDetail.mockResolvedValue({
          session: { id: 'session-target' },
          chunks: [],
        } as never);

        const error = createMockError();

        // Before fix: selectedSessionId would remain 'session-1' (from selectProject reset)
        // causing session-1 to be highlighted incorrectly

        store.getState().navigateToError(error);

        // After fix: selectedSessionId is updated to 'session-target'
        expect(store.getState().selectedSessionId).toBe('session-target');
        // Since 'session-target' is not in sessions array, nothing will be highlighted
        // (session-1 is in the array but doesn't match selectedSessionId anymore)
      });
    });
  });
});
