package org.jabref.logic.externalfiles;

import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

import org.jabref.logic.FilePreferences;
import org.jabref.logic.util.io.FileUtil;
import org.jabref.model.TransferInformation;
import org.jabref.model.TransferMode;
import org.jabref.model.database.BibDatabaseContext;
import org.jabref.model.entry.BibEntry;
import org.jabref.model.entry.LinkedFile;

import org.jooq.lambda.Unchecked;
import org.jspecify.annotations.NullMarked;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

@NullMarked
public class LinkedFileTransferHelper {

    private static final Logger LOGGER = LoggerFactory.getLogger(LinkedFileTransferHelper.class);

    /// Adjusts linked files when copying entries from source to target context.
    /// Files that are not reachable from the target context will be copied.
    /// Files in the target context whose relative paths differ from the source will have their paths adjusted.
    ///
    /// There is no need to know the source entry, because we are interested in the file paths only.
    ///
    /// @param filePreferences     File preferences for both contexts
    /// @param transferInformation The source database context where files are currently located - and the transferMode
    /// @param targetContext       The target database context where files should be accessible
    /// @param targetEntry         The entry in the targetContext
    public static void adjustLinkedFilesForTarget(
            FilePreferences filePreferences,
            TransferInformation transferInformation,
            BibDatabaseContext targetContext,
            BibEntry targetEntry
    ) {
        if (!filePreferences.shouldAdjustFileLinksOnTransfer()) {
            return;
        }

        LOGGER.trace("Handling {}", targetEntry.getKeyAuthorTitleYear());

        boolean fileLinksChanged = false;
        List<LinkedFile> linkedFiles = new ArrayList<>();

        for (LinkedFile linkedFile : targetEntry.getFiles()) {
            LOGGER.trace("Handling file {}", linkedFile);
            if (linkedFile.getLink().isEmpty() || linkedFile.isOnlineLink()) {
                linkedFiles.add(linkedFile);
                continue;
            }

            Path linkedFileAsPath = Path.of(linkedFile.getLink());
            if (linkedFileAsPath.isAbsolute()) {
                LOGGER.trace("File {} is an absolute path, skipping", linkedFileAsPath);
                // In case the file is an absolute path, there is no need to adjust anything
                // [impl->req~logic.externalfiles.file-transfer.reachable-no-copy~1]
                linkedFiles.add(linkedFile);
                continue;
            }

            // Check target bibdatabase context offers any directory to store files in
            // Condition works, because absolute paths are already skipped
            Optional<Path> targetPrimaryPathOpt = getPrimaryPath(targetContext, filePreferences);
            if (targetPrimaryPathOpt.isEmpty()) {
                LOGGER.trace("Target context does not have a primary directory, skipping file {}", linkedFileAsPath);
                linkedFiles.add(linkedFile);
                continue;
            }
            Path targetPrimaryPath = targetPrimaryPathOpt.get();

            Optional<Path> sourcePathOpt = linkedFile.findIn(transferInformation.bibDatabaseContext(), filePreferences);
            if (sourcePathOpt.isEmpty()) {
                LOGGER.trace("File does not exist, just keep the broken link");
                linkedFiles.add(linkedFile);
                continue;
            }
            Path sourcePath = sourcePathOpt.get();
            assert !linkedFileAsPath.isAbsolute();

            if ((linkedFileAsPath.getParent() != null) // the case with no parent is handled at the next if ("Try to find in other directory")
                    && linkedFile.findIn(targetContext, filePreferences).isPresent()) {
                // [impl->req~logic.externalfiles.file-transfer.reachable-no-copy~1]
                LOGGER.debug("File is reachable as is");
                // File is reachable as is - no need to copy
                linkedFiles.add(linkedFile);
                continue;
            }

            // Try to find in other directory

            // [impl->req~logic.externalfiles.file-transfer.reachable-no-copy~1]
            List<Path> directories = targetContext.getFileDirectories(filePreferences);
            // first check on the path directly and if not found then try based on filename
            Optional<Path> otherPlaceFile = findInSubDirs(linkedFileAsPath, directories);
            // If the path contains directory components, try searching for just the filename as a fallback.
            // This handles cases where the file was moved to a different subdirectory structure in the target.
            if (otherPlaceFile.isEmpty() && (linkedFileAsPath.getNameCount() > 1)) {
                otherPlaceFile = findInSubDirs(linkedFileAsPath.getFileName(), directories);
            }

            if (otherPlaceFile.isPresent()) {
                LOGGER.debug("Found in other place {}", otherPlaceFile);
                String newLink = FileUtil.relativize(otherPlaceFile.get(), directories).toString();
                LOGGER.debug("Setting new link {}", newLink);
                linkedFile.setLink(newLink);
                fileLinksChanged = true;
                linkedFiles.add(linkedFile);
                continue;
            }

            if ((transferInformation.transferMode() == TransferMode.COPY) && !filePreferences.shouldCopyLinkedFilesOnTransfer()) {
                LOGGER.debug("File copy not allowed by preferences");
                return;
            }
            if ((transferInformation.transferMode() == TransferMode.MOVE) && !filePreferences.shouldMoveLinkedFilesOnTransfer()) {
                LOGGER.debug("File move not allowed by preferences");
                return;
            }

            linkedFileAsPath = targetPrimaryPath.resolve(linkedFileAsPath);
            try {
                Files.createDirectories(linkedFileAsPath.getParent());
            } catch (IOException e) {
                LOGGER.error("Could not create directory for linked files at {}", linkedFileAsPath, e);
                linkedFiles.add(linkedFile);
                continue;
            }

            if (!Files.exists(linkedFileAsPath)) {
                try {
                    // [impl->req~logic.externalfiles.file-transfer.not-reachable-same-path~1]
                    if (transferInformation.transferMode() == TransferMode.COPY) {
                        LOGGER.debug("Copying file from {} to {}", sourcePath, linkedFileAsPath);
                        Files.copy(sourcePath, linkedFileAsPath, StandardCopyOption.COPY_ATTRIBUTES);
                    } else {
                        LOGGER.debug("Moving file from {} to {}", sourcePath, linkedFileAsPath);
                        assert transferInformation.transferMode() == TransferMode.MOVE;
                        Files.move(sourcePath, linkedFileAsPath, StandardCopyOption.ATOMIC_MOVE);
                    }
                } catch (IOException e) {
                    LOGGER.error("Could not copy file from {} to {}", sourcePath, linkedFileAsPath, e);
                    linkedFiles.add(linkedFile);
                }
            }
        }
        if (fileLinksChanged) {
            targetEntry.setFiles(linkedFiles);
        }
    }

    private static Optional<Path> findInSubDirs(Path path, List<Path> directories) {
        try {
            return directories
                    .stream()
                    .filter(Files::exists)
                    .flatMap(Unchecked.function(Files::walk))
                    .filter(p -> p.endsWith(path))
                    .sorted()
                    .findFirst();
        } catch (UncheckedIOException ex) {
            LOGGER.warn("Could not search for file {} in {}", path, directories, ex);
            return Optional.empty();
        }
    }

    /// Gets the primary directory path for the given context.
    /// This is a utility method extracted from the original implementation.
    ///
    /// @param context         The database context
    /// @param filePreferences File preferences for the context
    /// @return Optional containing the primary directory path, or empty if none found
    static Optional<Path> getPrimaryPath(BibDatabaseContext context, FilePreferences filePreferences) {
        return context.getFileDirectories(filePreferences).stream().findFirst();
    }
}
