import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ok } from 'neverthrow';
import { describe, expect, it, vi, beforeEach } from 'vitest';

import { FolderUploadComponent } from './FolderUploadComponent';
import * as multipartUpload from '../../../utils/multipartUpload';

const mockRequestMultipartUpload = vi.fn();
const mockCompleteMultipartUpload = vi.fn();

vi.mock('../../../services/backendClient', () => {
    return {
        // eslint-disable-next-line @typescript-eslint/naming-convention
        BackendClient: class {
            requestMultipartUpload = mockRequestMultipartUpload;
            completeMultipartUpload = mockCompleteMultipartUpload;
        },
    };
});

// Only mock uploadPart (external HTTP calls), keep real implementations of pure functions
vi.mock('../../../utils/multipartUpload', async () => {
    const actual = await vi.importActual<typeof import('../../../utils/multipartUpload')>(
        '../../../utils/multipartUpload',
    );
    return {
        ...actual,
        uploadPart: vi.fn(),
    };
});

const mockSetFileMapping = vi.fn();
const mockOnError = vi.fn();

const defaultProps = {
    fileCategory: {
        name: 'extraFiles',
        displayName: 'Extra Files',
    },
    inputMode: 'bulk' as const,
    accessToken: 'test-token',
    clientConfig: { backendUrl: 'http://test-backend', lapisUrls: {} },
    groupId: 1,
    fileMapping: undefined,
    setFileMapping: mockSetFileMapping,
    onError: mockOnError,
};

// In the case of previous uploads, they are keyed by a real submission id, not the dummySubmissionId
const submissionId = 'SUBMISSION_ID_123';
const defaultPropsWithFiles = {
    ...defaultProps,
    inputMode: 'form' as const,
    formSubmissionId: submissionId,
    fileMapping: {
        [submissionId]: {
            extraFiles: [
                { fileId: 'file-1', name: 'file-a.txt' },
                { fileId: 'file-2', name: 'file-b.txt' },
            ],
        },
    },
};

describe('FolderUploadComponent', () => {
    beforeEach(() => {
        vi.clearAllMocks();
        mockRequestMultipartUpload.mockReturnValue(ok([]));
        mockCompleteMultipartUpload.mockReturnValue(ok(undefined));
        vi.mocked(multipartUpload.uploadPart).mockResolvedValue('"etag"');
    });

    it('renders upload folder button', () => {
        render(<FolderUploadComponent {...defaultProps} />);
        expect(screen.getByText(`Upload folder: ${defaultProps.fileCategory.displayName}`)).toBeInTheDocument();
        expect(screen.getByTestId('folder-up-icon')).toBeInTheDocument();
    });

    it('displays files after selection', async () => {
        mockRequestMultipartUpload.mockReturnValue(ok([{ fileId: 'file-1', urls: ['http://test.com/url1'] }]));

        render(<FolderUploadComponent {...defaultProps} />);

        const input = screen.getByTestId('extraFiles');
        const file = new File(['content'], 'test.txt', { type: 'text/plain' });
        Object.defineProperty(file, 'webkitRelativePath', { value: 'folder/submission1/test.txt', writable: false });

        await userEvent.upload(input, file);
        await waitFor(() => expect(screen.getByText('test.txt')).toBeInTheDocument());
    });

    it('shows progress during multipart upload', async () => {
        // 20 MB file will be split into 2 parts (10 MB each) by real calculatePartSizeAndCount
        mockRequestMultipartUpload.mockReturnValue(
            ok([{ fileId: 'file-1', urls: ['http://test.com/url1', 'http://test.com/url2'] }]),
        );
        mockCompleteMultipartUpload.mockReturnValue(new Promise(() => {}));

        let uploadCount = 0;
        vi.mocked(multipartUpload.uploadPart).mockImplementation(async () => {
            uploadCount++;
            if (uploadCount === 1) {
                await new Promise((resolve) => setTimeout(resolve, 10));
            } else {
                await new Promise(() => {});
            }
            return '"etag"';
        });

        render(<FolderUploadComponent {...defaultProps} />);

        const input = screen.getByTestId('extraFiles');
        const file = new File(['x'.repeat(20_000_000)], 'large.txt', { type: 'text/plain' });
        Object.defineProperty(file, 'webkitRelativePath', { value: 'folder/submission1/large.txt', writable: false });

        await userEvent.upload(input, file);
        await waitFor(() => expect(screen.getByText('50%')).toBeInTheDocument(), { timeout: 3000 });
    });

    it('calls requestMultipartUpload with correct parameters', async () => {
        // 30 MB file will be split into 3 parts (10 MB each)
        mockRequestMultipartUpload.mockReturnValue(
            ok([
                {
                    fileId: 'file-1',
                    urls: ['http://test.com/url1', 'http://test.com/url2', 'http://test.com/url3'],
                },
            ]),
        );

        render(<FolderUploadComponent {...defaultProps} />);

        const input = screen.getByTestId('extraFiles');
        const file = new File(['x'.repeat(30_000_000)], 'large.txt', { type: 'text/plain' });
        Object.defineProperty(file, 'webkitRelativePath', { value: 'folder/submission1/large.txt', writable: false });

        await userEvent.upload(input, file);
        await waitFor(() => expect(mockRequestMultipartUpload).toHaveBeenCalledWith('test-token', 1, 1, 3));
    });

    it('calls completeMultipartUpload with ETags', async () => {
        // 20 MB file will be split into 2 parts
        mockRequestMultipartUpload.mockReturnValue(
            ok([{ fileId: 'file-1', urls: ['http://test.com/url1', 'http://test.com/url2'] }]),
        );
        vi.mocked(multipartUpload.uploadPart).mockResolvedValueOnce('"etag1"').mockResolvedValueOnce('"etag2"');

        render(<FolderUploadComponent {...defaultProps} />);

        const input = screen.getByTestId('extraFiles');
        const file = new File(['x'.repeat(20_000_000)], 'test.txt', { type: 'text/plain' });
        Object.defineProperty(file, 'webkitRelativePath', { value: 'folder/submission1/test.txt', writable: false });

        await userEvent.upload(input, file);
        await waitFor(() => {
            expect(mockCompleteMultipartUpload).toHaveBeenCalledWith('test-token', [
                { fileId: 'file-1', etags: ['"etag1"', '"etag2"'] },
            ]);
        });
    });

    it('shows success state after upload completes', async () => {
        mockRequestMultipartUpload.mockReturnValue(ok([{ fileId: 'file-1', urls: ['http://test.com/url1'] }]));

        render(<FolderUploadComponent {...defaultProps} />);

        const input = screen.getByTestId('extraFiles');
        const file = new File(['content'], 'test.txt', { type: 'text/plain' });
        Object.defineProperty(file, 'webkitRelativePath', { value: 'folder/submission1/test.txt', writable: false });

        await userEvent.upload(input, file);
        await waitFor(() => expect(screen.getByText('✓')).toBeInTheDocument());
    });

    it('filters out dot files', async () => {
        mockRequestMultipartUpload.mockReturnValue(ok([{ fileId: 'file-1', urls: ['http://test.com/url1'] }]));

        render(<FolderUploadComponent {...defaultProps} />);

        const input = screen.getByTestId('extraFiles');
        const validFile = new File(['content'], 'test.txt', { type: 'text/plain' });
        const dotFile = new File(['content'], '.DS_Store', { type: 'text/plain' });

        Object.defineProperty(validFile, 'webkitRelativePath', {
            value: 'folder/submission1/test.txt',
            writable: false,
        });
        Object.defineProperty(dotFile, 'webkitRelativePath', {
            value: 'folder/submission1/.DS_Store',
            writable: false,
        });

        await userEvent.upload(input, [validFile, dotFile]);
        await waitFor(() => {
            expect(screen.getByText('test.txt')).toBeInTheDocument();
            expect(screen.queryByText('.DS_Store')).not.toBeInTheDocument();
        });
    });

    it('individual uploads can be discarded while keeping the others', async () => {
        render(<FolderUploadComponent {...defaultPropsWithFiles} />);

        await userEvent.click(screen.getByTestId('discard_extraFiles_file-a.txt'));
        await waitFor(() => expect(screen.queryByText('file-a.txt')).not.toBeInTheDocument());
        expect(screen.getByText('file-b.txt')).toBeInTheDocument();
    });

    it('reverts to the upload folder prompt after discarding the last upload individually', async () => {
        const singleFileProps = {
            ...defaultPropsWithFiles,
            fileMapping: {
                [submissionId]: {
                    extraFiles: [{ fileId: 'file-1', name: 'file-a.txt' }],
                },
            },
        };
        render(<FolderUploadComponent {...singleFileProps} />);

        await userEvent.click(screen.getByTestId('discard_extraFiles_file-a.txt'));
        await waitFor(() =>
            expect(screen.getByText(`Upload folder: ${defaultProps.fileCategory.displayName}`)).toBeInTheDocument(),
        );
        expect(screen.queryByText('file-a.txt')).not.toBeInTheDocument();
    });

    it('shows a dialog before discarding all files and discards after confirmation', async () => {
        render(<FolderUploadComponent {...defaultPropsWithFiles} />);

        await userEvent.click(screen.getByTestId('discard_extraFiles'));
        await waitFor(() => expect(screen.getByText(/are you sure you want to discard/i)).toBeInTheDocument());

        await userEvent.click(screen.getByRole('button', { name: /^Discard$/ }));
        await waitFor(() =>
            expect(screen.getByText(`Upload folder: ${defaultProps.fileCategory.displayName}`)).toBeInTheDocument(),
        );
    });

    it('shows the upload prompt when the mapping has no files for the category', () => {
        render(
            <FolderUploadComponent
                {...defaultProps}
                inputMode='form'
                fileMapping={{ [submissionId]: { extraFiles: [] } }}
            />,
        );

        // An empty (but defined) mapping must still offer the folder upload prompt, not an empty file list.
        expect(screen.getByTestId('extraFiles')).toBeInTheDocument();
        expect(screen.queryByTestId('discard_extraFiles')).not.toBeInTheDocument();
    });

    it('renders previous uploads with an "uploaded" label', () => {
        render(<FolderUploadComponent {...defaultPropsWithFiles} />);

        expect(screen.getByText('file-a.txt')).toBeInTheDocument();
        expect(screen.getByText('file-b.txt')).toBeInTheDocument();
        expect(screen.getAllByText('(uploaded)')).toHaveLength(2);
    });

    it('keeps existing files when adding additional ones', async () => {
        mockRequestMultipartUpload.mockReturnValue(ok([{ fileId: 'added-id', urls: ['http://test.com/url1'] }]));

        render(<FolderUploadComponent {...defaultPropsWithFiles} />);

        const file = new File(['content'], 'added.txt', { type: 'text/plain' });
        Object.defineProperty(file, 'webkitRelativePath', { value: '', writable: false });
        await userEvent.upload(screen.getByTestId('add_extraFiles'), file);

        await waitFor(() => expect(screen.getByText('added.txt')).toBeInTheDocument());
        expect(screen.getByText('file-a.txt')).toBeInTheDocument();
        expect(screen.getByText('file-b.txt')).toBeInTheDocument();
    });

    it('rejects additional files containing duplicate file names', async () => {
        render(<FolderUploadComponent {...defaultPropsWithFiles} />);

        const dup1 = new File(['a'], 'dup.txt', { type: 'text/plain' });
        const dup2 = new File(['b'], 'dup.txt', { type: 'text/plain' });
        Object.defineProperty(dup1, 'webkitRelativePath', { value: '', writable: false });
        Object.defineProperty(dup2, 'webkitRelativePath', { value: '', writable: false });
        await userEvent.upload(screen.getByTestId('add_extraFiles'), [dup1, dup2]);

        expect(mockOnError).toHaveBeenCalledWith(expect.stringContaining('dup.txt'));
        expect(mockRequestMultipartUpload).not.toHaveBeenCalled();
    });

    it('confirms before overwriting an existing file with the same name', async () => {
        mockRequestMultipartUpload.mockReturnValue(ok([{ fileId: 'replacement-id', urls: ['http://test.com/url1'] }]));

        render(<FolderUploadComponent {...defaultPropsWithFiles} />);

        const file = new File(['content'], 'file-a.txt', { type: 'text/plain' });
        Object.defineProperty(file, 'webkitRelativePath', { value: '', writable: false });
        await userEvent.upload(screen.getByTestId('add_extraFiles'), file);

        await waitFor(() => expect(screen.getByText(/already exist and will be replaced/)).toBeInTheDocument());
        // Nothing is uploaded until the user confirms the overwrite.
        expect(mockRequestMultipartUpload).not.toHaveBeenCalled();

        await userEvent.click(screen.getByRole('button', { name: 'Replace' }));
        await waitFor(() => expect(mockRequestMultipartUpload).toHaveBeenCalled());
    });

    it('does not show the additional files button in bulk mode', () => {
        render(
            <FolderUploadComponent
                {...defaultProps}
                inputMode='bulk'
                fileMapping={{ [submissionId]: { extraFiles: [{ fileId: 'file-1', name: 'file-a.txt' }] } }}
            />,
        );

        expect(screen.getByText('file-a.txt')).toBeInTheDocument();
        expect(screen.queryByTestId('add_button_extraFiles')).not.toBeInTheDocument();
    });

    it('disables the additional files button while an upload is in progress', async () => {
        mockRequestMultipartUpload.mockReturnValue(ok([{ fileId: 'added-id', urls: ['http://test.com/url1'] }]));
        // Keep the upload pending so the component stays in the uploadInProgress state.
        vi.mocked(multipartUpload.uploadPart).mockReturnValue(new Promise(() => {}));

        render(<FolderUploadComponent {...defaultPropsWithFiles} />);

        const file = new File(['content'], 'added.txt', { type: 'text/plain' });
        Object.defineProperty(file, 'webkitRelativePath', { value: '', writable: false });
        await userEvent.upload(screen.getByTestId('add_extraFiles'), file);
        await waitFor(() => expect(screen.getByTestId('add_button_extraFiles')).toBeDisabled());
    });

    it('disables the individual discard buttons while an upload is in progress', async () => {
        mockRequestMultipartUpload.mockReturnValue(ok([{ fileId: 'added-id', urls: ['http://test.com/url1'] }]));
        // Keep the upload pending so the component stays in the uploadInProgress state.
        vi.mocked(multipartUpload.uploadPart).mockReturnValue(new Promise(() => {}));

        render(<FolderUploadComponent {...defaultPropsWithFiles} />);

        const file = new File(['content'], 'added.txt', { type: 'text/plain' });
        Object.defineProperty(file, 'webkitRelativePath', { value: '', writable: false });
        await userEvent.upload(screen.getByTestId('add_extraFiles'), file);
        await waitFor(() => expect(screen.getByTestId('discard_extraFiles_file-a.txt')).toBeDisabled());
    });
});
