// src/features/flyer/FlyerUploader.test.tsx
import React from 'react';
import { render, screen, fireEvent, waitFor, act, cleanup } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest';
import { FlyerUploader } from './FlyerUploader';
import * as aiApiClientModule from '../../services/aiApiClient';
import * as checksumModule from '../../utils/checksum';
import { useNavigate, MemoryRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider, onlineManager } from '@tanstack/react-query';

// Mock dependencies
vi.mock('../../services/aiApiClient', async (importOriginal) => {
  const actual = await importOriginal<typeof import('../../services/aiApiClient')>();
  return {
    ...actual,
    uploadAndProcessFlyer: vi.fn(),
    getJobStatus: vi.fn(),
  };
});
vi.mock('../../services/logger.client', () => ({
  // Keep the original logger.info/error but also spy on it for test assertions if needed
  logger: {
    info: vi.fn((...args) => console.log('[LOGGER.INFO]', ...args)),
    error: vi.fn((...args) => console.error('[LOGGER.ERROR]', ...args)),
    warn: vi.fn((...args) => console.warn('[LOGGER.WARN]', ...args)),
    debug: vi.fn((...args) => console.debug('[LOGGER.DEBUG]', ...args)),
  },
}));
vi.mock('../../utils/checksum', () => ({
  generateFileChecksum: vi.fn(),
}));

// Mock react-router-dom
vi.mock('react-router-dom', async () => {
  const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
  return {
    ...actual,
    useNavigate: vi.fn(),
  };
});

const mockedAiApiClient = aiApiClientModule as unknown as {
  uploadAndProcessFlyer: Mock;
  getJobStatus: Mock;
};
const mockedChecksumModule = checksumModule as unknown as {
  generateFileChecksum: Mock;
};

// Shared QueryClient - will be reset in beforeEach
let queryClient: QueryClient;

const renderComponent = (onProcessingComplete = vi.fn()) => {
  console.log('--- [TEST LOG] ---: Rendering component inside MemoryRouter.');
  return render(
    <QueryClientProvider client={queryClient}>
      <MemoryRouter>
        <FlyerUploader onProcessingComplete={onProcessingComplete} />
      </MemoryRouter>
    </QueryClientProvider>,
  );
};

describe('FlyerUploader', () => {
  const navigateSpy = vi.fn();

  beforeEach(() => {
    // Create a fresh QueryClient for each test to ensure isolation
    queryClient = new QueryClient({
      defaultOptions: {
        queries: {
          retry: false,
        },
      },
    });
    // Disable react-query's online manager to prevent it from interfering with fake timers
    onlineManager.setEventListener((_setOnline) => {
      return () => {};
    });
    console.log(`\n--- [TEST LOG] ---: Starting test: "${expect.getState().currentTestName}"`);
    vi.resetAllMocks(); // Resets mock implementations AND call history.
    console.log('--- [TEST LOG] ---: Mocks reset.');
    mockedChecksumModule.generateFileChecksum.mockResolvedValue('mock-checksum');
    (useNavigate as Mock).mockReturnValue(navigateSpy);
  });

  afterEach(async () => {
    console.log(`--- [TEST LOG] ---: Finished test: "${expect.getState().currentTestName}"\n`);
    // Cancel all pending queries to stop any in-flight polling
    queryClient.cancelQueries();
    // Clear all pending queries to prevent async leakage
    queryClient.clear();
    // Ensure cleanup after each test to prevent DOM leakage
    cleanup();
    // Small delay to allow any pending microtasks to settle
    await new Promise((resolve) => setTimeout(resolve, 0));
  });

  it('should render the initial state correctly', () => {
    renderComponent();
    expect(screen.getByText('Upload New Flyer')).toBeInTheDocument();
    expect(screen.getByText(/click to select a file/i)).toBeInTheDocument();
  });

  it('should handle file upload and start polling', async () => {
    console.log('--- [TEST LOG] ---: 1. Setting up mocks for upload and polling.');
    mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue({ jobId: 'job-123' });
    mockedAiApiClient.getJobStatus.mockResolvedValue({
      state: 'active',
      progress: { message: 'Checking...' },
    });

    console.log('--- [TEST LOG] ---: 2. Rendering component and preparing file.');
    renderComponent();
    const file = new File(['content'], 'flyer.pdf', { type: 'application/pdf' });
    const input = screen.getByLabelText(/click to select a file/i);

    console.log('--- [TEST LOG] ---: 3. Firing file change event.');
    fireEvent.change(input, { target: { files: [file] } });
    console.log('--- [TEST LOG] ---: 4. File change event fired. Now AWAITING UI update.');

    try {
      console.log('--- [TEST LOG] ---: 5a. Awaiting screen.findByText("Checking...")');
      await screen.findByText('Checking...');
      console.log('--- [TEST LOG] ---: 5b. SUCCESS: UI updated to polling state.');
    } catch (error) {
      console.error('--- [TEST LOG] ---: 5c. ERROR: findByText("Checking...") timed out.');
      console.log('--- [DEBUG] ---: DOM at time of failure:');
      screen.debug(); // Print the DOM when the error occurs
      throw error; // Re-throw the error to fail the test
    }

    console.log('--- [TEST LOG] ---: 6. Asserting mock calls after UI update.');
    expect(mockedAiApiClient.uploadAndProcessFlyer).toHaveBeenCalledWith(file, 'mock-checksum');
    expect(mockedAiApiClient.getJobStatus).toHaveBeenCalledTimes(1);
    console.log('--- [TEST LOG] ---: 7. Mocks verified. Advancing timers now...');

    // With real timers, we now wait for the polling interval to elapse.
    console.log(
      `--- [TEST LOG] ---: 9. Act block finished. Now checking if getJobStatus was called again.`,
    );

    try {
      // The polling interval is 3s, so we wait for a bit longer.
      await waitFor(
        () => {
          const calls = mockedAiApiClient.getJobStatus.mock.calls.length;
          console.log(`--- [TEST LOG] ---: 10. waitFor check: getJobStatus calls = ${calls}`);
          expect(mockedAiApiClient.getJobStatus).toHaveBeenCalledTimes(2);
        },
        { timeout: 4000 },
      );
      console.log('--- [TEST LOG] ---: 11. SUCCESS: Second poll confirmed.');
    } catch (error) {
      console.error('--- [TEST LOG] ---: 11. ERROR: waitFor for second poll timed out.');
      console.log('--- [DEBUG] ---: DOM at time of failure:');
      screen.debug();
      throw error;
    }
  });

  it('should handle file upload via drag and drop', async () => {
    console.log('--- [TEST LOG] ---: 1. Setting up mocks for drag and drop.');
    mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue({ jobId: 'job-dnd' });
    mockedAiApiClient.getJobStatus.mockResolvedValue({
      state: 'active',
      progress: { message: 'Dropped...' },
    });

    console.log('--- [TEST LOG] ---: 2. Rendering component and preparing file for drop.');
    renderComponent();
    const file = new File(['dnd-content'], 'dnd-flyer.pdf', { type: 'application/pdf' });
    // The dropzone is the label element
    const dropzone = screen.getByText(/click to select a file/i).closest('label')!;

    console.log('--- [TEST LOG] ---: 3. Firing drop event.');
    // Simulate the drop event
    fireEvent.drop(dropzone, {
      dataTransfer: { files: [file] },
    });

    console.log('--- [TEST LOG] ---: 4. Awaiting UI update to "Dropped...".');
    await screen.findByText('Dropped...');
    console.log('--- [TEST LOG] ---: 5. Asserting upload was called.');
    expect(mockedAiApiClient.uploadAndProcessFlyer).toHaveBeenCalledWith(file, 'mock-checksum');
  });

  it(
    'should poll for status, complete successfully, and redirect',
    { timeout: 10000 },
    async () => {
      const onProcessingComplete = vi.fn();
      console.log('--- [TEST LOG] ---: 1. Setting up mock sequence for polling.');
      mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue({ jobId: 'job-123' });
      mockedAiApiClient.getJobStatus
        .mockResolvedValueOnce({ state: 'active', progress: { message: 'Analyzing...' } })
        .mockResolvedValueOnce({ state: 'completed', returnValue: { flyerId: 42 } });

      console.log('--- [TEST LOG] ---: 2. Rendering component and uploading file.');
      renderComponent(onProcessingComplete);
      const file = new File(['content'], 'flyer.pdf', { type: 'application/pdf' });
      const input = screen.getByLabelText(/click to select a file/i);
      fireEvent.change(input, { target: { files: [file] } });

      console.log('--- [TEST LOG] ---: 3. Fired event. Now AWAITING UI update to "Analyzing...".');
      try {
        await screen.findByText('Analyzing...');
        console.log('--- [TEST LOG] ---: 4. SUCCESS: UI is showing "Analyzing...".');
      } catch (error) {
        console.error('--- [TEST LOG] ---: 4. ERROR: findByText("Analyzing...") timed out.');
        console.log('--- [DEBUG] ---: DOM at time of failure:');
        screen.debug();
        throw error;
      }
      expect(mockedAiApiClient.getJobStatus).toHaveBeenCalledTimes(1);
      console.log('--- [TEST LOG] ---: 5. First poll confirmed. Now AWAITING timer advancement.');

      try {
        console.log(
          '--- [TEST LOG] ---: 8a. waitFor check: Waiting for completion text and job status count.',
        );
        // Wait for the second poll to occur and the UI to update.
        await waitFor(
          () => {
            console.log(
              `--- [TEST LOG] ---: 8b. waitFor interval: calls=${
                mockedAiApiClient.getJobStatus.mock.calls.length
              }`,
            );
            expect(
              screen.getByText('Processing complete! Redirecting to flyer 42...'),
            ).toBeInTheDocument();
          },
          { timeout: 4000 },
        );
        console.log('--- [TEST LOG] ---: 9. SUCCESS: Completion message found.');
      } catch (error) {
        console.error('--- [TEST LOG] ---: 9. ERROR: waitFor for completion message timed out.');
        console.log('--- [DEBUG] ---: DOM at time of failure:');
        screen.debug();
        throw error;
      }
      expect(mockedAiApiClient.getJobStatus).toHaveBeenCalledTimes(2);

      // Wait for the redirect timer (1.5s in component) to fire.
      await act(() => new Promise((r) => setTimeout(r, 2000)));
      console.log(`--- [TEST LOG] ---: 11. Timers advanced. Now asserting navigation.`);
      expect(onProcessingComplete).toHaveBeenCalled();
      expect(navigateSpy).toHaveBeenCalledWith('/flyers/42');
      console.log('--- [TEST LOG] ---: 12. Callback and navigation confirmed.');
    },
  );

  it('should handle a failed job', async () => {
    console.log('--- [TEST LOG] ---: 1. Setting up mocks for a failed job.');
    mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue({ jobId: 'job-fail' });
    // The getJobStatus function throws a specific error when the job fails,
    // which is then caught by react-query and placed in the `error` state.
    const jobFailedError = new aiApiClientModule.JobFailedError(
      'AI model exploded',
      'UNKNOWN_ERROR',
    );
    mockedAiApiClient.getJobStatus.mockRejectedValue(jobFailedError);

    console.log('--- [TEST LOG] ---: 2. Rendering and uploading.');
    renderComponent();
    const file = new File(['content'], 'flyer.pdf', { type: 'application/pdf' });
    const input = screen.getByLabelText(/click to select a file/i);

    console.log('--- [TEST LOG] ---: 3. Firing file change event.');
    fireEvent.change(input, { target: { files: [file] } });
    console.log('--- [TEST LOG] ---: 3. File upload triggered.');

    try {
      console.log('--- [TEST LOG] ---: 4. AWAITING failure message...');
      // The UI should now display the error from the `pollError` state, which includes the "Polling failed" prefix.
      expect(await screen.findByText(/Polling failed: AI model exploded/i)).toBeInTheDocument();
      console.log('--- [TEST LOG] ---: 5. SUCCESS: Failure message found.');
    } catch (error) {
      console.error('--- [TEST LOG] ---: 5. ERROR: findByText for failure message timed out.');
      console.log('--- [DEBUG] ---: DOM at time of failure:');
      screen.debug();
      throw error;
    }

    expect(screen.getByText('Upload Another Flyer')).toBeInTheDocument();
    console.log('--- [TEST LOG] ---: 6. "Upload Another" button confirmed.');
  });

  it('should clear the polling timeout when a job fails', async () => {
    console.log('--- [TEST LOG] ---: 1. Setting up mocks for failed job timeout clearance.');
    mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue({ jobId: 'job-fail-timeout' });

    // We need at least one 'active' response to establish a timeout loop so we have something to clear
    // The second call should be a rejection, as this is how getJobStatus signals a failure.
    mockedAiApiClient.getJobStatus
      .mockResolvedValueOnce({
        state: 'active',
        progress: { message: 'Working...' },
      } as aiApiClientModule.JobStatus)
      .mockRejectedValueOnce(new aiApiClientModule.JobFailedError('Fatal Error', 'UNKNOWN_ERROR'));

    renderComponent();
    const file = new File(['content'], 'flyer.pdf', { type: 'application/pdf' });
    const input = screen.getByLabelText(/click to select a file/i);

    fireEvent.change(input, { target: { files: [file] } });

    // Wait for the first poll to complete and UI to update to "Working..."
    await screen.findByText('Working...');

    // Wait for the failure UI
    await waitFor(
      () => expect(screen.getByText(/Polling failed: Fatal Error/i)).toBeInTheDocument(),
      { timeout: 4000 },
    );
  });

  it('should stop polling for job status when the component unmounts', async () => {
    console.log('--- [TEST LOG] ---: 1. Setting up mocks for unmount polling stop.');
    mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue({ jobId: 'job-unmount' });
    // Mock getJobStatus to always return 'active' to keep polling
    mockedAiApiClient.getJobStatus.mockResolvedValue({
      state: 'active',
      progress: { message: 'Polling...' },
    });

    const { unmount } = renderComponent();
    const file = new File(['content'], 'flyer.pdf', { type: 'application/pdf' });
    const input = screen.getByLabelText(/click to select a file/i);

    fireEvent.change(input, { target: { files: [file] } });

    // Wait for the first poll to complete and UI to update
    await screen.findByText('Polling...');

    // Wait for exactly one call to be sure polling has started.
    await waitFor(() => {
      expect(mockedAiApiClient.getJobStatus).toHaveBeenCalledTimes(1);
    });
    console.log('--- [TEST LOG] ---: 2. First poll confirmed.');

    // Record the number of calls before unmounting.
    const callsBeforeUnmount = mockedAiApiClient.getJobStatus.mock.calls.length;

    // Now unmount the component, which should stop the polling.
    console.log('--- [TEST LOG] ---: 3. Unmounting component.');
    unmount();

    // Wait for a duration longer than the polling interval (3s) to see if more calls are made.
    console.log('--- [TEST LOG] ---: 4. Waiting for 4 seconds to check for further polling.');
    await act(() => new Promise((resolve) => setTimeout(resolve, 4000)));

    // Verify that getJobStatus was not called again after unmounting.
    console.log('--- [TEST LOG] ---: 5. Asserting no new polls occurred.');
    expect(mockedAiApiClient.getJobStatus).toHaveBeenCalledTimes(callsBeforeUnmount);
  });

  it('should handle a duplicate flyer error (409)', async () => {
    console.log('--- [TEST LOG] ---: 1. Setting up mock for 409 duplicate error.');
    // The API client throws a structured error, which useFlyerUploader now parses
    // to set both the errorMessage and the duplicateFlyerId.
    mockedAiApiClient.uploadAndProcessFlyer.mockRejectedValue({
      status: 409,
      body: { flyerId: 99, message: 'This flyer has already been processed.' },
    });

    console.log('--- [TEST LOG] ---: 2. Rendering and uploading.');
    renderComponent();
    const file = new File(['content'], 'flyer.pdf', { type: 'application/pdf' });
    const input = screen.getByLabelText(/click to select a file/i);

    console.log('--- [TEST LOG] ---: 3. Firing file change event.');
    fireEvent.change(input, { target: { files: [file] } });
    console.log('--- [TEST LOG] ---: 3. File upload triggered.');

    try {
      console.log('--- [TEST LOG] ---: 4. AWAITING duplicate flyer message...');
      // With the fix, the duplicate error message and the link are combined into a single paragraph.
      // We now look for this combined message.
      const errorMessage = await screen.findByText(
        /This flyer has already been processed. You can view it here:/i,
      );
      expect(errorMessage).toBeInTheDocument();
      console.log('--- [TEST LOG] ---: 5. SUCCESS: Duplicate message found.');
    } catch (error) {
      console.error('--- [TEST LOG] ---: 5. ERROR: findByText for duplicate message timed out.');
      console.log('--- [DEBUG] ---: DOM at time of failure:');
      screen.debug();
      throw error;
    }

    const link = screen.getByRole('link', { name: /Flyer #99/i });
    expect(link).toHaveAttribute('href', '/flyers/99');
    console.log('--- [TEST LOG] ---: 6. Duplicate link confirmed.');
  });

  it('should allow the user to stop watching progress', async () => {
    console.log('--- [TEST LOG] ---: 1. Setting up mocks for infinite polling.');
    mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue({ jobId: 'job-stop' });
    mockedAiApiClient.getJobStatus.mockResolvedValue({
      state: 'active',
      progress: { message: 'Analyzing...' },
    } as any);

    console.log('--- [TEST LOG] ---: 2. Rendering and uploading.');
    renderComponent();
    const file = new File(['content'], 'flyer.pdf', { type: 'application/pdf' });
    const input = screen.getByLabelText(/click to select a file/i);

    console.log('--- [TEST LOG] ---: 3. Firing file change event.');
    fireEvent.change(input, { target: { files: [file] } });
    console.log('--- [TEST LOG] ---: 3. File upload triggered.');

    let stopButton;
    try {
      console.log('--- [TEST LOG] ---: 4. AWAITING polling UI...');
      stopButton = await screen.findByRole('button', { name: 'Stop Watching Progress' });
      console.log('--- [TEST LOG] ---: 5. SUCCESS: Polling UI is visible.');
    } catch (error) {
      console.error('--- [TEST LOG] ---: 5. ERROR: findByRole for stop button timed out.');
      console.log('--- [DEBUG] ---: DOM at time of failure:');
      screen.debug();
      throw error;
    }

    console.log('--- [TEST LOG] ---: 6. Clicking "Stop Watching Progress" button.');
    fireEvent.click(stopButton);
    console.log('--- [TEST LOG] ---: 7. Click event fired.');

    try {
      console.log('--- [TEST LOG] ---: 8. AWAITING UI reset to idle state...');
      expect(await screen.findByText(/click to select a file/i)).toBeInTheDocument();
      // Fix typo: queryText -> queryByText
      expect(screen.queryByText('Analyzing...')).not.toBeInTheDocument();
      console.log('--- [TEST LOG] ---: 9. SUCCESS: UI has reset and message removed.');
    } catch (error) {
      console.error('--- [TEST LOG] ---: 9. ERROR: findByText for idle state timed out.');
      console.log('--- [DEBUG] ---: DOM at time of failure:');
      screen.debug();
      throw error;
    }
  });

  describe('Error Handling and Edge Cases', () => {
    it('should handle checksum generation failure', async () => {
      console.log('--- [TEST LOG] ---: 1. Setting up mock for checksum failure.');
      mockedChecksumModule.generateFileChecksum.mockRejectedValue(
        new Error('Checksum generation failed'),
      );
      renderComponent();
      const file = new File(['content'], 'flyer.pdf', { type: 'application/pdf' });
      const input = screen.getByLabelText(/click to select a file/i);

      console.log('--- [TEST LOG] ---: 2. Firing file change event.');
      fireEvent.change(input, { target: { files: [file] } });

      console.log('--- [TEST LOG] ---: 3. Awaiting error message.');
      expect(await screen.findByText(/Checksum generation failed/i)).toBeInTheDocument();
      expect(mockedAiApiClient.uploadAndProcessFlyer).not.toHaveBeenCalled();
      console.log('--- [TEST LOG] ---: 4. Assertions passed.');
    });

    it('should handle a generic network error during upload', async () => {
      console.log('--- [TEST LOG] ---: 1. Setting up mock for generic upload error.');
      // Simulate a structured error from the API client
      mockedAiApiClient.uploadAndProcessFlyer.mockRejectedValue({
        status: 500,
        body: { message: 'Network Error During Upload' },
      });
      renderComponent();
      const file = new File(['content'], 'flyer.pdf', { type: 'application/pdf' });
      const input = screen.getByLabelText(/click to select a file/i);

      console.log('--- [TEST LOG] ---: 2. Firing file change event.');
      fireEvent.change(input, { target: { files: [file] } });

      console.log('--- [TEST LOG] ---: 3. Awaiting error message.');
      expect(await screen.findByText(/Network Error During Upload/i)).toBeInTheDocument();
      console.log('--- [TEST LOG] ---: 4. Assertions passed.');
    });

    it('should handle a generic network error during polling', async () => {
      console.log('--- [TEST LOG] ---: 1. Setting up mock for polling error.');
      mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue({ jobId: 'job-poll-fail' });
      mockedAiApiClient.getJobStatus.mockRejectedValue(new Error('Polling Network Error'));

      renderComponent();
      const file = new File(['content'], 'flyer.pdf', { type: 'application/pdf' });
      const input = screen.getByLabelText(/click to select a file/i);

      console.log('--- [TEST LOG] ---: 2. Firing file change event.');
      fireEvent.change(input, { target: { files: [file] } });

      console.log('--- [TEST LOG] ---: 3. Awaiting error message.');
      expect(await screen.findByText(/Polling failed: Polling Network Error/i)).toBeInTheDocument();
      console.log('--- [TEST LOG] ---: 4. Assertions passed.');
    });

    it('should handle a completed job with a missing flyerId', async () => {
      console.log('--- [TEST LOG] ---: 1. Setting up mock for malformed completion payload.');
      mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue({ jobId: 'job-no-flyerid' });
      mockedAiApiClient.getJobStatus.mockResolvedValue(
        { state: 'completed', returnValue: {} }, // No flyerId
      );

      renderComponent();
      const file = new File(['content'], 'flyer.pdf', { type: 'application/pdf' });
      const input = screen.getByLabelText(/click to select a file/i);

      console.log('--- [TEST LOG] ---: 2. Firing file change event.');
      fireEvent.change(input, { target: { files: [file] } });

      console.log('--- [TEST LOG] ---: 3. Awaiting error message.');
      expect(
        await screen.findByText(/Job completed but did not return a flyer ID/i),
      ).toBeInTheDocument();
      console.log('--- [TEST LOG] ---: 4. Assertions passed.');
    });

    it('should handle a non-JSON response during polling', async () => {
      console.log('--- [TEST LOG] ---: 1. Setting up mock for non-JSON response.');
      // The actual function would throw, so we mock the rejection.
      // The new getJobStatus would throw an error like "Failed to parse JSON..."
      mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue({ jobId: 'job-bad-json' });
      mockedAiApiClient.getJobStatus.mockRejectedValue(
        new Error('Failed to parse JSON response from server. Body: <html>502 Bad Gateway</html>'),
      );

      renderComponent();
      const file = new File(['content'], 'flyer.pdf', { type: 'application/pdf' });
      const input = screen.getByLabelText(/click to select a file/i);

      console.log('--- [TEST LOG] ---: 2. Firing file change event.');
      fireEvent.change(input, { target: { files: [file] } });

      console.log('--- [TEST LOG] ---: 3. Awaiting error message.');
      expect(
        await screen.findByText(/Polling failed: Failed to parse JSON response from server/i),
      ).toBeInTheDocument();
      console.log('--- [TEST LOG] ---: 4. Assertions passed.');
    });

    it('should do nothing if the file input is cancelled', () => {
      renderComponent();
      const input = screen.getByLabelText(/click to select a file/i);
      fireEvent.change(input, { target: { files: [] } }); // Empty file list
      expect(mockedAiApiClient.uploadAndProcessFlyer).not.toHaveBeenCalled();
    });
  });
});
