/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License.  You may obtain a copy of the License at
 *
 *  http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */
package org.apache.struts2.dispatcher.multipart;

import org.apache.struts2.dispatcher.LocalizedMessage;
import org.assertj.core.api.InstanceOfAssertFactories;
import org.junit.Test;

import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Field;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;

import static org.apache.commons.lang3.StringUtils.normalizeSpace;
import static org.assertj.core.api.Assertions.assertThat;

public class JakartaMultiPartRequestTest extends AbstractMultiPartRequestTest {

    @Override
    protected AbstractMultiPartRequest createMultipartRequest() {
        return new JakartaMultiPartRequest();
    }

    @Test
    public void temporaryFileCreationErrorsAreNotDuplicated() throws IOException {
        // Test that duplicate errors are not added to the errors list
        JakartaMultiPartRequest multiPartWithDuplicateErrors = new JakartaMultiPartRequest();
        
        // Simulate adding the same error twice
        IOException testException = new IOException("Test exception");
        LocalizedMessage errorMessage = multiPartWithDuplicateErrors.buildErrorMessage(
                testException.getClass(), testException.getMessage(), new Object[]{"test.csv"});
        
        // when - add same error twice
        multiPartWithDuplicateErrors.errors.add(errorMessage);
        if (!multiPartWithDuplicateErrors.errors.contains(errorMessage)) {
            multiPartWithDuplicateErrors.errors.add(errorMessage);
        }
        
        // then - only one error should be present
        assertThat(multiPartWithDuplicateErrors.getErrors()).hasSize(1);
    }

    @Test
    public void cleanupIsIdempotent() throws IOException {
        // given - process some files
        String content = formFile("file1", "test1.csv", "1,2,3,4") +
                        endline + "--" + boundary + "--";
        
        mockRequest.setContent(content.getBytes(StandardCharsets.UTF_8));
        multiPart.parse(mockRequest, tempDir);
        
        // when - call cleanup multiple times
        multiPart.cleanUp();
        multiPart.cleanUp();
        multiPart.cleanUp();
        
        // then - should not throw exceptions and should be safe
        assertThat(multiPart.uploadedFiles).isEmpty();
        assertThat(multiPart.parameters).isEmpty();
    }

    @Test
    public void endToEndMultipartProcessingWithCleanup() throws IOException {
        // Test complete multipart processing lifecycle
        String content = formFile("file1", "test1.csv", "1,2,3,4") +
                        formField("param1", "value1") +
                        formFile("file2", "test2.csv", "5,6,7,8") +
                        formField("param2", "value2") +
                        endline + "--" + boundary + "--";
        
        mockRequest.setContent(content.getBytes(StandardCharsets.UTF_8));
        
        // when - full processing
        multiPart.parse(mockRequest, tempDir);
        
        // then - verify everything was processed
        assertThat(multiPart.getErrors()).isEmpty();
        assertThat(multiPart.getFile("file1")).hasSize(1);
        assertThat(multiPart.getFile("file2")).hasSize(1);
        assertThat(multiPart.getParameter("param1")).isEqualTo("value1");
        assertThat(multiPart.getParameter("param2")).isEqualTo("value2");
        
        // when - cleanup
        multiPart.cleanUp();
        
        // then - verify complete cleanup
        assertThat(multiPart.uploadedFiles).isEmpty();
        assertThat(multiPart.parameters).isEmpty();
    }

    @Test
    public void processNormalFormFieldHandlesNullFieldName() throws IOException {
        // Test null field name handling in processNormalFormField
        String content = 
            endline + "--" + boundary + endline +
            "Content-Disposition: form-data" + endline + // No name attribute
            endline +
            "field value without name" +
            endline + "--" + boundary + endline +
            "Content-Disposition: form-data; name=\"validfield\"" + endline +
            endline +
            "valid field value" +
            endline + "--" + boundary + "--";
        
        mockRequest.setContent(content.getBytes(StandardCharsets.UTF_8));
        
        // when
        multiPart.parse(mockRequest, tempDir);
        
        // then - should only process the valid field
        assertThat(multiPart.getErrors()).isEmpty();
        assertThat(multiPart.getParameter("validfield")).isEqualTo("valid field value");
        assertThat(multiPart.getParameterNames().asIterator()).toIterable().hasSize(1);
    }

    @Test
    public void processFileFieldHandlesNullFieldName() throws IOException {
        // Test null field name handling in processFileField
        String content = 
            endline + "--" + boundary + endline +
            "Content-Disposition: form-data; filename=\"orphan.txt\"" + endline + // No name attribute
            "Content-Type: text/plain" + endline +
            endline +
            "orphaned file content" +
            endline + "--" + boundary + endline +
            "Content-Disposition: form-data; name=\"validfile\"; filename=\"valid.txt\"" + endline +
            "Content-Type: text/plain" + endline +
            endline +
            "valid file content" +
            endline + "--" + boundary + "--";
        
        mockRequest.setContent(content.getBytes(StandardCharsets.UTF_8));
        
        // when
        multiPart.parse(mockRequest, tempDir);
        
        // then - should only process the valid file
        assertThat(multiPart.getErrors()).isEmpty();
        assertThat(multiPart.uploadedFiles).hasSize(1);
        assertThat(multiPart.getFile("validfile")).hasSize(1);
        assertThat(multiPart.getFile("validfile")[0].getContent())
                .asInstanceOf(InstanceOfAssertFactories.FILE)
                .content()
                .isEqualTo("valid file content");
    }

    @Test
    public void diskFileItemCleanupCoverage() throws IOException, NoSuchFieldException, IllegalAccessException {
        // Test disk file item cleanup paths
        String content = formFile("file1", "test1.csv", "1,2,3,4") +
                        endline + "--" + boundary + "--";
        
        mockRequest.setContent(content.getBytes(StandardCharsets.UTF_8));
        
        // when - force files to disk with small buffer
        multiPart.setBufferSize("1");
        multiPart.parse(mockRequest, tempDir);
        
        // Access private field to verify disk file items are tracked
        Field diskFileItemsField = JakartaMultiPartRequest.class.getDeclaredField("diskFileItems");
        diskFileItemsField.setAccessible(true);
        @SuppressWarnings("unchecked")
        java.util.List<org.apache.commons.fileupload2.core.DiskFileItem> diskFileItems = 
            (java.util.List<org.apache.commons.fileupload2.core.DiskFileItem>) diskFileItemsField.get(multiPart);
        
        // then - should have disk file items tracked
        assertThat(diskFileItems).isNotEmpty();
        
        // when - cleanup
        multiPart.cleanUp();
        
        // then - should clear tracking
        assertThat(diskFileItems).isEmpty();
    }

    @Test
    public void inMemoryVsDiskFileHandling() throws IOException {
        // Test both in-memory and disk file handling paths
        String smallContent = "small"; // Should be in-memory
        String largeContent = "x".repeat(20000); // Should go to disk
        
        String content = formFile("smallfile", "small.txt", smallContent) +
                        formFile("largefile", "large.txt", largeContent) +
                        endline + "--" + boundary + "--";
        
        mockRequest.setContent(content.getBytes(StandardCharsets.UTF_8));
        
        // when - use default buffer size
        multiPart.parse(mockRequest, tempDir);
        
        // then - both files should be processed
        assertThat(multiPart.getErrors()).isEmpty();
        assertThat(multiPart.uploadedFiles).hasSize(2);
        assertThat(multiPart.getFile("smallfile")).hasSize(1);
        assertThat(multiPart.getFile("largefile")).hasSize(1);
        
        // Verify content
        assertThat(multiPart.getFile("smallfile")[0].getContent())
                .asInstanceOf(InstanceOfAssertFactories.FILE)
                .content()
                .isEqualTo(smallContent);
        assertThat(multiPart.getFile("largefile")[0].getContent())
                .asInstanceOf(InstanceOfAssertFactories.FILE)
                .content()
                .isEqualTo(largeContent);
    }

    @Test
    public void errorDuplicationPrevention() throws IOException {
        // Test that duplicate errors are not added
        JakartaMultiPartRequest multiPartRequest = new JakartaMultiPartRequest();
        
        // Simulate adding the same error multiple times
        IOException testException = new IOException("Test error");
        LocalizedMessage errorMessage = multiPartRequest.buildErrorMessage(
                testException.getClass(), testException.getMessage(), new Object[]{"test.csv"});
        
        // when - try to add same error multiple times
        multiPartRequest.errors.add(errorMessage);
        if (!multiPartRequest.errors.contains(errorMessage)) {
            multiPartRequest.errors.add(errorMessage); // Should not be added
        }
        if (!multiPartRequest.errors.contains(errorMessage)) {
            multiPartRequest.errors.add(errorMessage); // Should not be added
        }
        
        // then - should only have one error
        assertThat(multiPartRequest.getErrors()).hasSize(1);
    }

    @Test
    public void manyFormFieldsWithFewFilesAreAccepted() throws IOException {
        // Regression for WW-5474: maxFiles must not count form fields.
        StringBuilder content = new StringBuilder();
        for (int i = 0; i < 10; i++) {
            content.append(formField("field" + i, "value" + i));
        }
        content.append(formFile("file1", "test1.csv", "1,2,3,4"));
        content.append(formFile("file2", "test2.csv", "5,6,7,8"));
        content.append(endline).append("--").append(boundary).append("--");
        mockRequest.setContent(content.toString().getBytes(StandardCharsets.UTF_8));

        multiPart.setMaxFiles("2"); // only 2 files, but 10 fields present
        multiPart.parse(mockRequest, tempDir);

        assertThat(multiPart.getErrors()).isEmpty();
        assertThat(multiPart.getFileParameterNames().asIterator()).toIterable()
                .asInstanceOf(InstanceOfAssertFactories.LIST).containsOnly("file1", "file2");
    }

    @Test
    public void exceedsMaxFilesIsFailClosed() throws IOException {
        String content = formField("param1", "value1") +
                formFile("file1", "test1.csv", "1,2,3,4") +
                formFile("file2", "test2.csv", "5,6,7,8") +
                endline + "--" + boundary + "--";
        mockRequest.setContent(content.getBytes(StandardCharsets.UTF_8));

        multiPart.setMaxFiles("1");
        multiPart.parse(mockRequest, tempDir);

        assertThat(multiPart.getErrors()).map(LocalizedMessage::getTextKey)
                .containsExactly("struts.messages.upload.error.FileUploadFileCountLimitException");
        assertThat(multiPart.getFileParameterNames().asIterator()).toIterable().isEmpty();
        assertThat(multiPart.getParameterNames().asIterator()).toIterable().isEmpty();
    }

    @Test
    public void exceedsMaxParameterCountIsFailClosed() throws IOException {
        String content = formField("field1", "a") +
                formField("field2", "b") +
                formField("field3", "c") +
                endline + "--" + boundary + "--";
        mockRequest.setContent(content.getBytes(StandardCharsets.UTF_8));

        multiPart.setMaxParameterCount("2");
        multiPart.parse(mockRequest, tempDir);

        assertThat(multiPart.getErrors()).map(LocalizedMessage::getTextKey)
                .containsExactly("struts.messages.upload.error.FileUploadParameterCountLimitException");
        assertThat(multiPart.getParameterNames().asIterator()).toIterable().isEmpty();
    }

    @Test
    public void multipleFilesUnderOneFieldNameAreCounted() throws IOException {
        String content = formFile("file", "a.csv", "1") +
                formFile("file", "b.csv", "2") +
                formFile("file", "c.csv", "3") +
                endline + "--" + boundary + "--";
        mockRequest.setContent(content.getBytes(StandardCharsets.UTF_8));

        multiPart.setMaxFiles("2"); // 3 files share one field name -> still 3 files
        multiPart.parse(mockRequest, tempDir);

        assertThat(multiPart.getErrors()).map(LocalizedMessage::getTextKey)
                .containsExactly("struts.messages.upload.error.FileUploadFileCountLimitException");
    }

    @Test
    public void processFileFieldHandlesEmptyFileName() throws IOException {
        String content =
            endline + "--" + boundary + endline +
            "Content-Disposition: form-data; name=\"emptyfile\"; filename=\"\"" + endline +
            "Content-Type: text/plain" + endline +
            endline +
            "some content that should be ignored" +
            endline + "--" + boundary + endline +
            "Content-Disposition: form-data; name=\"validfile\"; filename=\"test.txt\"" + endline +
            "Content-Type: text/plain" + endline +
            endline +
            "valid file content" +
            endline + "--" + boundary + "--";

        mockRequest.setContent(content.getBytes(StandardCharsets.UTF_8));

        // when
        multiPart.parse(mockRequest, tempDir);

        // then - should only process the file with valid filename
        assertThat(multiPart.getErrors()).isEmpty();
        assertThat(multiPart.uploadedFiles).hasSize(1);
        assertThat(multiPart.getFile("validfile")).hasSize(1);
        assertThat(multiPart.getFile("emptyfile")).isEmpty();
        assertThat(multiPart.getFile("validfile")[0].getContent())
                .asInstanceOf(InstanceOfAssertFactories.FILE)
                .content()
                .isEqualTo("valid file content");
    }

    @Test
    public void unlimitedMaxFilesIsNotClampedByTotalPartsBackstop() throws IOException {
        // Regression for WW-5474: maxFiles=-1 (unlimited) combined with a finite
        // maxParameterCount must not compute a finite total-parts backstop
        // (maxFiles + maxParameterCount) and pass it to commons-fileupload2's
        // setMaxFileCount, which counts ALL parts. Before the fix, -1 + 256 = 255
        // wrongly rejected a 300-file/0-field upload at part 256.
        int fileCount = 300;
        StringBuilder content = new StringBuilder();
        for (int i = 0; i < fileCount; i++) {
            content.append(formFile("file" + i, "test" + i + ".csv", "1,2,3,4"));
        }
        content.append(endline).append("--").append(boundary).append("--");
        mockRequest.setContent(content.toString().getBytes(StandardCharsets.UTF_8));

        multiPart.setMaxSize("-1"); // isolate: don't let the size backstop interfere
        multiPart.setMaxFiles("-1"); // unlimited files
        multiPart.setMaxParameterCount("256"); // finite, but must not clamp file count

        multiPart.parse(mockRequest, tempDir);

        assertThat(multiPart.getErrors()).isEmpty();
        assertThat(multiPart.getFileParameterNames().asIterator()).toIterable().hasSize(fileCount);
    }

    @Test
    public void trailingDiskFileItemsAreCleanedUpAfterFailClosedBreach() throws IOException {
        // Regression for WW-5474: parseRequest() fully materializes every part - spilling
        // large ones to disk - before processUpload() ever starts iterating. If items were
        // only registered for cleanup as the loop reached them, a fail-closed breach mid-loop
        // (e.g. enforceMaxFiles throwing) would leak the temp files of every part positioned
        // after the breaching one, since cleanUp()/cleanUpDiskFileItems() never learns about them.
        // Use a fresh, empty saveDir so the leftover-file assertion isn't polluted by other tests.
        File freshSaveDir = Files.createTempDirectory("struts-ww5474-leak-test").toFile();
        try {
            StringBuilder content = new StringBuilder();
            for (int i = 1; i <= 5; i++) {
                content.append(formFile("file" + i, "test" + i + ".csv", "1,2,3,4"));
            }
            content.append(endline).append("--").append(boundary).append("--");
            mockRequest.setContent(content.toString().getBytes(StandardCharsets.UTF_8));

            // force every part to spill to disk instead of staying in-memory
            multiPart.setBufferSize("1");
            multiPart.setMaxFiles("2"); // breach occurs at file3, leaving file4/file5 as trailing parts
            multiPart.parse(mockRequest, freshSaveDir.getAbsolutePath());

            // then - fail-closed: breach reported, no files exposed
            assertThat(multiPart.getErrors()).map(LocalizedMessage::getTextKey)
                    .containsExactly("struts.messages.upload.error.FileUploadFileCountLimitException");
            assertThat(multiPart.getFileParameterNames().asIterator()).toIterable().isEmpty();

            // when - cleanup runs
            multiPart.cleanUp();

            // then - no leftover temp files remain, including the trailing parts after the breach
            File[] leftover = freshSaveDir.listFiles((dir, name) -> name.startsWith("upload_") && name.endsWith(".tmp"));
            assertThat(leftover).isNullOrEmpty();
        } finally {
            File[] remaining = freshSaveDir.listFiles();
            if (remaining != null) {
                for (File f : remaining) {
                    f.delete();
                }
            }
            freshSaveDir.delete();
        }
    }

    @Test
    public void inMemoryUploadIsNotWrittenToDiskUntilContentRequested() throws IOException {
        // given - a small file that Commons FileUpload keeps in memory
        String content = formFile("file1", "test1.csv", "a,b,c,d") +
                endline + "--" + boundary + "--";
        mockRequest.setContent(content.getBytes(StandardCharsets.UTF_8));

        // when
        multiPart.parse(mockRequest, tempDir);

        UploadedFile file = multiPart.getFile("file1")[0];

        // then - nothing written to disk right after parse
        assertThat(file.isFile()).isFalse();

        // and - content is readable via the stream path without materializing
        try (InputStream in = file.getInputStream()) {
            assertThat(new String(in.readAllBytes(), StandardCharsets.UTF_8)).isEqualTo("a,b,c,d");
        }
        assertThat(file.isFile()).isFalse();

        // and - getContent() materializes a real file on demand
        File materialized = (File) file.getContent();
        assertThat(materialized).exists().hasContent("a,b,c,d");
        assertThat(file.isFile()).isTrue();

        // and - cleanUp removes the materialized file
        multiPart.cleanUp();
        assertThat(materialized).doesNotExist();
    }

}
