// src/features/voice-assistant/VoiceAssistant.test.tsx
import React from 'react';
import { render, screen, fireEvent, act, waitFor, within } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, Mock } from 'vitest';
import { VoiceAssistant } from './VoiceAssistant';
import * as aiApiClient from '../../services/aiApiClient';
import { encode } from '../../utils/audioUtils';

// Unmock the component to test the real implementation
vi.unmock('./VoiceAssistant');

// Mock dependencies to isolate the component
vi.mock('../../services/aiApiClient', () => ({
  startVoiceSession: vi.fn(),
}));
vi.mock('../../utils/audioUtils', () => ({
  encode: vi.fn(),
}));
vi.mock('../../services/logger.client', () => ({
  logger: {
    info: vi.fn(),
    debug: vi.fn(),
    error: vi.fn(),
  },
}));
vi.mock('../../components/icons/MicrophoneIcon', () => ({
  MicrophoneIcon: () => <div data-testid="mic-icon" />,
}));
vi.mock('../../components/icons/XMarkIcon', () => ({
  XMarkIcon: () => <div data-testid="x-icon" />,
}));

// Capture the onaudioprocess callback to trigger it manually in tests.
let capturedOnaudioprocess: ((event: any) => void) | null = null;
const mockScriptProcessor = {
  connect: vi.fn(),
  disconnect: vi.fn(),
  // Use a setter to capture the callback when the component assigns it.
  set onaudioprocess(callback: ((event: any) => void) | null) {
    capturedOnaudioprocess = callback;
  },
  get onaudioprocess() {
    return capturedOnaudioprocess;
  },
};

// Mock browser APIs that are not available in JSDOM
Object.defineProperty(window, 'AudioContext', {
  writable: true,
  value: vi.fn().mockImplementation(function () {
    return {
      createMediaStreamSource: vi.fn(() => ({
        connect: vi.fn(),
      })),
      createScriptProcessor: vi.fn(() => mockScriptProcessor),
      close: vi.fn(),
    };
  }),
});

Object.defineProperty(navigator, 'mediaDevices', {
  writable: true,
  value: {
    getUserMedia: vi.fn(),
  },
});

// Define a mock session object for testing
interface MockLiveSession {
  close: Mock;
  sendRealtimeInput: Mock;
}

describe('VoiceAssistant Component', () => {
  const mockOnClose = vi.fn();
  // To hold the callbacks passed to startVoiceSession
  let capturedCallbacks: any = {};
  const mockSession: MockLiveSession = {
    close: vi.fn(),
    sendRealtimeInput: vi.fn(),
  };

  beforeEach(() => {
    vi.clearAllMocks();
    capturedCallbacks = {}; // Reset before each test
    capturedOnaudioprocess = null; // Reset before each test
    // FIX: The component's `startSession` function awaits `getUserMedia`.
    // We must provide a mock resolved value for the promise to prevent the test
    // from hanging or erroring, and to allow the async function to proceed.
    (navigator.mediaDevices.getUserMedia as Mock).mockResolvedValue({
      getTracks: () => [{ stop: vi.fn() }],
    });

    // Mock startVoiceSession to capture callbacks and return a mock session promise
    (aiApiClient.startVoiceSession as unknown as Mock).mockImplementation((callbacks) => {
      Object.assign(capturedCallbacks, callbacks);
      return Promise.resolve(mockSession);
    });
  });

  it('should not render when isOpen is false', () => {
    const { container } = render(<VoiceAssistant isOpen={false} onClose={mockOnClose} />);
    expect(container.firstChild).toBeNull();
  });

  it('should render correctly when isOpen is true', () => {
    render(<VoiceAssistant isOpen={true} onClose={mockOnClose} />);

    expect(screen.getByRole('heading', { name: /voice assistant/i })).toBeInTheDocument();
    expect(screen.getByText('Click the mic to start')).toBeInTheDocument();
    expect(screen.getByRole('button', { name: /close/i })).toBeInTheDocument();
    expect(screen.getByTestId('mic-icon')).toBeInTheDocument();
  });

  it('should call onClose when the close button is clicked', () => {
    render(<VoiceAssistant isOpen={true} onClose={mockOnClose} />);
    fireEvent.click(screen.getByRole('button', { name: /close/i }));
    expect(mockOnClose).toHaveBeenCalledTimes(1);
  });

  it('should call handleClose on unmount to clean up resources', async () => {
    // 1. Render the component, which returns an unmount function.
    const { unmount } = render(<VoiceAssistant isOpen={true} onClose={mockOnClose} />);

    // 2. Start a session to ensure there are resources (session promise, media stream) to clean up.
    fireEvent.click(screen.getByRole('button', { name: /start voice session/i }));
    await waitFor(() => expect(aiApiClient.startVoiceSession).toHaveBeenCalled());
    act(() => {
      capturedCallbacks.onopen();
    });
    await screen.findByText('Listening...'); // Wait for session to be fully active.

    // 3. Unmount the component, which should trigger the useEffect cleanup function.
    unmount();

    // 4. Assert that cleanup functions were called.
    expect(mockOnClose).toHaveBeenCalledTimes(1); // Called synchronously.
    await waitFor(() => {
      expect(mockSession.close).toHaveBeenCalledTimes(1); // Called asynchronously in a promise.
    });
  });

  it('should call onClose when the overlay is clicked', () => {
    render(<VoiceAssistant isOpen={true} onClose={mockOnClose} />);
    // The overlay is the root div of the modal
    fireEvent.click(screen.getByRole('dialog').parentElement!);
    expect(mockOnClose).toHaveBeenCalledTimes(1);
  });

  it('should not call onClose when the modal content is clicked', () => {
    render(<VoiceAssistant isOpen={true} onClose={mockOnClose} />);
    fireEvent.click(screen.getByRole('heading', { name: /voice assistant/i }));
    expect(mockOnClose).not.toHaveBeenCalled();
  });

  // FIX: The test must be async to handle the async `startSession` function.
  it('should call startVoiceSession when the microphone button is clicked in idle state', async () => {
    render(<VoiceAssistant isOpen={true} onClose={mockOnClose} />);
    const micButton = screen.getByRole('button', { name: /start voice session/i });
    fireEvent.click(micButton);

    // FIX: Because `startSession` is async (it awaits `getUserMedia`), the call
    // to `startVoiceSession` within it does not happen immediately. We must
    // wait for the asynchronous operations to complete before asserting the result.
    await vi.waitFor(() => {
      expect(aiApiClient.startVoiceSession).toHaveBeenCalled();
    });
  });

  it('should display history and current transcripts', () => {
    render(<VoiceAssistant isOpen={true} onClose={mockOnClose} />);

    // This test is a bit more involved as it requires manipulating internal state.
    // For a simple test, we can check that the container for history exists.
    // A more advanced test would involve mocking the `startVoiceSession` callbacks.
    const historyContainer = screen.getByRole('heading', { name: /voice assistant/i }).parentElement
      ?.nextElementSibling;
    expect(historyContainer).toBeInTheDocument();
    expect(historyContainer).toBeEmptyDOMElement(); // Initially empty
  });

  describe('Voice Session Lifecycle and Callbacks', () => {
    it('should transition status to "connecting" then "listening" on session start', async () => {
      render(<VoiceAssistant isOpen={true} onClose={mockOnClose} />);
      const micButton = screen.getByRole('button', { name: /start voice session/i });

      fireEvent.click(micButton);

      // Status becomes 'connecting' immediately
      expect(await screen.findByText('Connecting...')).toBeInTheDocument();

      // Wait for getUserMedia and startVoiceSession to resolve
      await waitFor(() => {
        expect(aiApiClient.startVoiceSession).toHaveBeenCalled();
      });

      // Manually trigger the onopen callback
      act(() => {
        capturedCallbacks.onopen();
      });

      // Status should now be 'listening'
      expect(await screen.findByText('Listening...')).toBeInTheDocument();
      // The button's label should change
      expect(screen.getByRole('button', { name: /stop voice session/i })).toBeInTheDocument();
    });

    it('should handle getUserMedia failure gracefully', async () => {
      // Override the default mock for this test
      (navigator.mediaDevices.getUserMedia as Mock).mockRejectedValue(
        new Error('Permission denied'),
      );

      render(<VoiceAssistant isOpen={true} onClose={mockOnClose} />);
      const micButton = screen.getByRole('button', { name: /start voice session/i });

      fireEvent.click(micButton);

      // Should show an error status
      expect(await screen.findByText('Connection error. Please try again.')).toBeInTheDocument();
      expect(aiApiClient.startVoiceSession).not.toHaveBeenCalled();
    });

    it('should update transcripts on message and add to history on turnComplete', async () => {
      render(<VoiceAssistant isOpen={true} onClose={mockOnClose} />);
      fireEvent.click(screen.getByRole('button', { name: /start voice session/i }));
      await waitFor(() => expect(aiApiClient.startVoiceSession).toHaveBeenCalled());
      act(() => capturedCallbacks.onopen());

      // Simulate user speaking
      act(() => {
        capturedCallbacks.onmessage({
          serverContent: { inputTranscription: { text: 'User says this.' } },
        });
      });
      expect(await screen.findByText('User says this.')).toBeInTheDocument();

      // Simulate model responding
      act(() => {
        capturedCallbacks.onmessage({
          serverContent: { outputTranscription: { text: 'Model says that.' } },
        });
      });
      expect(await screen.findByText('Model says that.')).toBeInTheDocument();

      // Simulate turn completion
      act(() => {
        capturedCallbacks.onmessage({ serverContent: { turnComplete: true } });
      });

      // Wait for the UI to update: transcripts should move to history, clearing the live view.
      // We check the container children count to verify both history addition and live clearing.
      const historyContainer = screen.getByRole('heading', { name: /voice assistant/i })
        .parentElement?.nextElementSibling as HTMLElement;

      await waitFor(() => {
        // We expect 2 children: one for user history, one for model history.
        // If live transcripts were still present, there would be more children.
        expect(historyContainer.children.length).toBe(2);
      });

      // Verify the content of the history
      const { getByText } = within(historyContainer);
      expect(getByText('User says this.')).toBeInTheDocument();
      expect(getByText('Model says that.')).toBeInTheDocument();
    });

    it('should send audio data via the session on onaudioprocess', async () => {
      // Mock the encode function to return a predictable value
      (encode as Mock).mockReturnValue('encoded-data-string');

      render(<VoiceAssistant isOpen={true} onClose={mockOnClose} />);
      fireEvent.click(screen.getByRole('button', { name: /start voice session/i }));
      await waitFor(() => expect(aiApiClient.startVoiceSession).toHaveBeenCalled());

      // Trigger session open to set up audio processing
      act(() => {
        capturedCallbacks.onopen();
      });

      // Wait for the onaudioprocess callback to be assigned
      await waitFor(() => {
        expect(capturedOnaudioprocess).toBeInstanceOf(Function);
      });

      // Create a mock audio event
      const mockAudioData = new Float32Array([0.1, -0.2, 0.3]);
      const mockAudioEvent = {
        inputBuffer: {
          getChannelData: vi.fn().mockReturnValue(mockAudioData),
        },
      };

      // Trigger the audio process event
      act(() => {
        capturedOnaudioprocess!(mockAudioEvent);
      });

      // Wait for the async `then` block in onaudioprocess to execute
      await waitFor(() => {
        expect(mockSession.sendRealtimeInput).toHaveBeenCalledTimes(1);
      });

      // Assert the payload
      expect(mockSession.sendRealtimeInput).toHaveBeenCalledWith({
        media: { data: 'encoded-data-string', mimeType: 'audio/pcm;rate=16000' },
      });
    });

    it('should handle session error and update status', async () => {
      render(<VoiceAssistant isOpen={true} onClose={mockOnClose} />);
      fireEvent.click(screen.getByRole('button', { name: /start voice session/i }));
      await waitFor(() => expect(aiApiClient.startVoiceSession).toHaveBeenCalled());
      act(() => capturedCallbacks.onopen());

      // Simulate an error
      act(() => {
        capturedCallbacks.onerror(new ErrorEvent('error', { message: 'WebSocket closed' }));
      });

      expect(await screen.findByText('Connection error. Please try again.')).toBeInTheDocument();
    });

    it('should call handleClose when mic is clicked while listening', async () => {
      render(<VoiceAssistant isOpen={true} onClose={mockOnClose} />);
      fireEvent.click(screen.getByRole('button', { name: /start voice session/i }));
      await waitFor(() => expect(aiApiClient.startVoiceSession).toHaveBeenCalled());
      act(() => capturedCallbacks.onopen());

      const stopButton = await screen.findByRole('button', { name: /stop voice session/i });
      fireEvent.click(stopButton);

      expect(mockOnClose).toHaveBeenCalledTimes(1);
      await waitFor(() => {
        expect(mockSession.close).toHaveBeenCalledTimes(1);
      });
    });
  });
});
