/*
 * 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 jakarta.servlet.http.HttpServletRequest;
import org.apache.commons.fileupload2.core.DiskFileItem;
import org.apache.commons.fileupload2.core.RequestContext;
import org.apache.commons.fileupload2.jakarta.servlet6.JakartaServletDiskFileUpload;
import org.apache.commons.lang3.StringUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;

import static org.apache.commons.lang3.StringUtils.normalizeSpace;

/**
 * Multipart form data request adapter for Jakarta Commons FileUpload package.
 * 
 * <p>This implementation provides secure handling of multipart requests with proper
 * resource management and cleanup. It tracks all {@link DiskFileItem} instances
 * created during the upload process and ensures they are properly cleaned up to
 * prevent resource leaks and security vulnerabilities. In-memory uploads are kept
 * as byte arrays and only materialized to a temporary file lazily, on demand.</p>
 *
 * <p>Key features:</p>
 * <ul>
 *   <li>Automatic tracking and cleanup of underlying disk file items</li>
 *   <li>Proper error handling with user-friendly error messages</li>
 *   <li>Support for both in-memory and disk-based file uploads</li>
 *   <li>Extensible cleanup mechanisms for customization</li>
 * </ul>
 * 
 * <p>Usage example:</p>
 * <pre>
 * JakartaMultiPartRequest multipartRequest = new JakartaMultiPartRequest();
 * try {
 *     multipartRequest.parse(request, "/tmp/uploads");
 *     // Process uploaded files
 *     for (String fieldName : multipartRequest.getFileParameterNames()) {
 *         List&lt;UploadedFile&gt; files = multipartRequest.getFile(fieldName);
 *         // Handle files
 *     }
 * } finally {
 *     multipartRequest.cleanUp(); // Always clean up resources
 * }
 * </pre>
 * 
 * @see AbstractMultiPartRequest
 * @see org.apache.commons.fileupload2.jakarta.servlet6.JakartaServletDiskFileUpload
 */
public class JakartaMultiPartRequest extends AbstractMultiPartRequest {

    private static final Logger LOG = LogManager.getLogger(JakartaMultiPartRequest.class);

    /**
     * List to track all DiskFileItem instances for proper cleanup
     */
    private final List<DiskFileItem> diskFileItems = new ArrayList<>();

    /**
     * Processes the multipart upload request using Jakarta Commons FileUpload.
     * 
     * <p>This method handles the core upload processing by:</p>
     * <ol>
     *   <li>Reading the character encoding from the request</li>
     *   <li>Preparing the Jakarta servlet file upload handler</li>
     *   <li>Creating a request context for processing</li>
     *   <li>Iterating through all form items (fields and files)</li>
     *   <li>Processing each item appropriately based on its type</li>
     * </ol>
     * 
     * <p>All {@link org.apache.commons.fileupload2.core.DiskFileItem} instances
     * are automatically tracked for proper cleanup.</p>
     * 
     * @param request the HTTP servlet request containing the multipart data
     * @param saveDir the directory where uploaded files will be stored
     * @throws IOException if an error occurs during upload processing
     * @see #processNormalFormField(DiskFileItem, Charset)
     * @see #processFileField(DiskFileItem, String)
     */
    @Override
    protected void processUpload(HttpServletRequest request, String saveDir) throws IOException {
        Charset charset = readCharsetEncoding(request);

        JakartaServletDiskFileUpload servletFileUpload =
                prepareServletFileUpload(charset, Path.of(saveDir));

        RequestContext requestContext = createRequestContext(request);
        
        int fileCount = 0;
        int parameterCount = 0;
        // parseRequest() fully materializes every part (spilling large ones to disk) before we
        // iterate, so register them all for cleanup up front - otherwise a fail-closed breach
        // mid-loop would leak the temp files of every part after the breaching one.
        List<DiskFileItem> items = servletFileUpload.parseRequest(requestContext);
        diskFileItems.addAll(items);
        for (DiskFileItem item : items) {
            if (item.isFormField()) {
                LOG.debug(() -> "Processing a form field: " + normalizeSpace(item.getFieldName()));
                // Process regular form fields (text inputs, checkboxes, etc.)
                if (item.getFieldName() != null) {
                    enforceMaxParameterCount(parameterCount, item.getFieldName());
                    parameterCount++;
                }
                processNormalFormField(item, charset);
            } else {
                // Process file upload fields (only count parts that would actually be accepted:
                // a real filename AND a non-null field name, matching JakartaStreamMultiPartRequest
                // so both parsers enforce maxFiles identically on malformed parts).
                LOG.debug(() -> "Processing a file: " + normalizeSpace(item.getFieldName()));
                if (item.getName() != null && !item.getName().trim().isEmpty() && item.getFieldName() != null) {
                    enforceMaxFiles(fileCount, item.getName());
                    fileCount++;
                }
                processFileField(item, saveDir);
            }
        }
    }

    /**
     * Processes a normal form field (non-file) from the multipart request.
     * 
     * <p>This method handles text form fields by:</p>
     * <ol>
     *   <li>Validating the field name is not null</li>
     *   <li>Extracting the field value using the specified charset</li>
     *   <li>Checking if the field value exceeds maximum string length</li>
     *   <li>Adding the value to the parameters map</li>
     * </ol>
     * 
     * <p>Fields with null names are skipped with a warning log message.</p>
     * <p>Empty form fields are stored as empty strings.</p>
     * 
     * @param item the disk file item representing the form field
     * @param charset the character set to use for decoding the field value
     * @throws IOException if an error occurs reading the field value
     * @see #exceedsMaxStringLength(String, String)
     */
    protected void processNormalFormField(DiskFileItem item, Charset charset) throws IOException {
        LOG.debug("Item: {} is a normal form field", normalizeSpace(item.getName()));

        String fieldName = item.getFieldName();
        if (fieldName == null) {
            LOG.warn("Form field has null fieldName, skipping");
            return;
        }
        
        List<String> values = parameters.computeIfAbsent(fieldName, k -> new ArrayList<>());

        String fieldValue = item.getString(charset);
        if (exceedsMaxStringLength(fieldName, fieldValue)) {
            return;
        }
        if (item.getSize() == 0) {
            values.add(StringUtils.EMPTY);
        } else {
            values.add(fieldValue);
        }
        parameters.put(fieldName, values);
    }

    /**
     * Processes a file field from the multipart request.
     * 
     * <p>This method handles file uploads by:</p>
     * <ol>
     *   <li>Validating the file name and field name are not null/empty</li>
     *   <li>Determining if the file is stored in memory or on disk</li>
     *   <li>For in-memory files: wrapping the content in a {@link StrutsInMemoryUploadedFile}
     *       that only writes to disk lazily, on demand</li>
     *   <li>For disk files: using the existing file directly</li>
     *   <li>Creating an {@link UploadedFile} abstraction</li>
     *   <li>Adding the file to the uploaded files collection</li>
     * </ol>
     *
     * @param item the disk file item representing the uploaded file
     * @throws IOException never thrown by this implementation (in-memory content is materialized
     *                     lazily elsewhere); the clause is retained on the signature deliberately for
     *                     source compatibility with subclasses that override this method or catch it
     *                     from {@code super.processFileField(...)}
     */
    // NOTE: `throws IOException` is intentionally retained for subclass source compatibility - do not remove.
    protected void processFileField(DiskFileItem item, String saveDir) throws IOException {
        // Skip file uploads that don't have a file name - meaning that no file was selected.
        if (item.getName() == null || item.getName().trim().isEmpty()) {
            LOG.debug(() -> "No file has been uploaded for the field: " + normalizeSpace(item.getFieldName()));
            return;
        }

        String fieldName = item.getFieldName();
        if (fieldName == null) {
            LOG.warn("File field has null fieldName, skipping");
            return;
        }
        
        // Reject empty files (0 bytes) as they are not considered valid uploads
        if (rejectEmptyFile(item.getSize(), item.getName(), fieldName)) {
            return;
        }
        
        List<UploadedFile> values = uploadedFiles.computeIfAbsent(fieldName, k -> new ArrayList<>());

        if (item.isInMemory()) {
            LOG.debug(() -> "Keeping in-memory uploaded item without writing to disk: " + normalizeSpace(item.getFieldName()));
            UploadedFile uploadedFile = StrutsInMemoryUploadedFile.Builder
                    .create(item.get(), Path.of(saveDir))
                    .withOriginalName(item.getName())
                    .withContentType(item.getContentType())
                    .withInputName(item.getFieldName())
                    .build();
            values.add(uploadedFile);
        } else {
            UploadedFile uploadedFile = StrutsUploadedFile.Builder
                    .create(item.getPath().toFile())
                    .withOriginalName(item.getName())
                    .withContentType(item.getContentType())
                    .withInputName(item.getFieldName())
                    .build();
            values.add(uploadedFile);
        }

        uploadedFiles.put(fieldName, values);
    }

    /**
     * Cleans up disk file items by deleting associated temporary files.
     * 
     * <p>This method iterates through all tracked {@link DiskFileItem} instances
     * and performs cleanup operations:</p>
     * <ul>
     *   <li>For in-memory items: logs cleanup (no files to delete)</li>
     *   <li>For disk items: deletes the associated temporary file</li>
     * </ul>
     * 
     * <p>This method is called automatically during {@link #cleanUp()} but can
     * be overridden by subclasses to customize cleanup behavior. All exceptions
     * are caught and logged to prevent cleanup failures from affecting the
     * overall cleanup process.</p>
     *
     * @see #cleanUp()
     */
    protected void cleanUpDiskFileItems() {
        LOG.debug("Clean up all DiskFileItem instances (both form fields and file uploads");
        for (DiskFileItem item : diskFileItems) {
            try {
                if (item.isInMemory()) {
                    LOG.debug(() -> "Cleaning up in-memory item: " + normalizeSpace(item.getFieldName()));
                } else {
                    Path itemPath = item.getPath();
                    if (LOG.isDebugEnabled()) {
                        LOG.debug("Cleaning up disk item: {} at {}", normalizeSpace(item.getFieldName()), itemPath);
                    }
                    deleteFile(itemPath);
                }
            } catch (Exception e) {
                LOG.warn("Error cleaning up DiskFileItem: {}", normalizeSpace(item.getFieldName()), e);
            }
        }
    }

    /**
     * Performs complete cleanup of all resources associated with this request.
     *
     * <p>This method extends the parent cleanup functionality to ensure proper
     * cleanup of Jakarta-specific resources:</p>
     * <ol>
     *   <li>Calls parent cleanup to handle base class resources</li>
     *   <li>Cleans up all tracked {@link DiskFileItem} instances</li>
     *   <li>Clears internal tracking collections</li>
     * </ol>
     *
     * <p>This method is designed to be safe to call multiple times and will
     * not throw exceptions even if cleanup operations fail. All errors are
     * logged for debugging purposes.</p>
     *
     * <p><strong>Important:</strong> This method should always be called in a
     * finally block to ensure resources are properly released, even if
     * exceptions occur during request processing.</p>
     *
     * @see #cleanUpDiskFileItems()
     * @see AbstractMultiPartRequest#cleanUp()
     */
    @Override
    public void cleanUp() {
        super.cleanUp();
        try {
            cleanUpDiskFileItems();
        } finally {
            diskFileItems.clear();
        }
    }

}
