package org.jabref.logic.exporter;

import java.io.FilterOutputStream;
import java.io.IOException;
import java.io.InterruptedIOException;
import java.io.OutputStream;
import java.nio.channels.Channels;
import java.nio.channels.FileChannel;
import java.nio.channels.FileLock;
import java.nio.channels.OverlappingFileLockException;
import java.nio.file.FileSystemException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.PosixFilePermission;
import java.util.EnumSet;
import java.util.Set;

import org.jabref.logic.util.BackupFileType;
import org.jabref.logic.util.io.FileUtil;

import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/// A file output stream that is similar to the standard [java.io.FileOutputStream], except that all writes are first
/// redirected to a temporary file. When the stream is closed, the temporary file (atomically) replaces the target file.
///
///
/// In detail, the strategy is to:
///
/// 1. Write to a temporary file (with .tmp suffix) in the same directory as the destination file.
/// 2. Create a backup (with .bak suffix) of the original file (if it exists) in the same directory.
/// 3. Move the temporary file to the correct place, overwriting any file that already exists at that location.
/// 4. Delete the backup file (if configured to do so).
///
/// If all goes well, no temporary or backup files will remain on disk after closing the stream.
///
/// Errors are handled as follows:
///
/// 1. If anything goes wrong while writing to the temporary file, the temporary file will be deleted (leaving the
/// original file untouched).
/// 2. If anything goes wrong while copying the temporary file to the target file, the backup of the original file is
/// kept.
///
/// Implementation inspired by code from [Marty Lamb](https://github.com/martylamb/atomicfileoutputstream/blob/master/src/main/java/com/martiansoftware/io/AtomicFileOutputStream.java) and [Apache](https://github.com/apache/zookeeper/blob/master/src/java/main/org/apache/zookeeper/common/AtomicFileOutputStream.java).
@NullMarked
public class AtomicFileOutputStream extends FilterOutputStream {

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

    private static final String TEMPORARY_EXTENSION = ".tmp";
    private static final String SAVE_EXTENSION = "." + BackupFileType.SAVE.getExtensions().getFirst();

    /// Number of attempts to move the temporary file onto the target file. See [#moveTemporaryFileToTargetFile()].
    private static final int MOVE_ATTEMPTS = 5;

    /// Delay before the second move attempt; doubled after each further failed attempt.
    private static final long MOVE_RETRY_INITIAL_DELAY_MILLIS = 20;

    /// The file we want to create/replace.
    private final Path targetFile;

    /// The file to which writes are redirected to.
    private final Path temporaryFile;

    /// Null if the stream was constructed from an injected [OutputStream] (tests), because neither locking
    /// nor syncing is possible then.
    @Nullable private final FileChannel temporaryFileChannel;

    /// Can be null in case of failure to acquire a lock (for example, a network drive).
    /// If null, then ignore.
    @Nullable private FileLock temporaryFileLock;

    /// A backup of the target file (if it exists), created when the stream is closed
    private final Path backupFile;

    private final boolean keepBackup;

    private boolean errorDuringWrite = false;

    /// Creates a new output stream to write to or replace the file at the specified path.
    ///
    /// @param path       the path of the file to write to or replace
    /// @param keepBackup whether to keep the backup file (.sav) after a successful write process
    public AtomicFileOutputStream(Path path, boolean keepBackup) throws IOException {
        this(path,
                getPathOfTemporaryFile(path),
                FileChannel.open(getPathOfTemporaryFile(path),
                        StandardOpenOption.CREATE,
                        StandardOpenOption.WRITE,
                        StandardOpenOption.TRUNCATE_EXISTING),
                keepBackup);
    }

    /// The temporary file is opened as a [FileChannel], because the channel is needed both for [FileChannel#tryLock()]
    /// and for [FileChannel#force(boolean)]. `Files.newOutputStream(...)` returns a `sun.nio.ch.ChannelOutputStream`,
    /// which offers neither.
    private AtomicFileOutputStream(Path path, Path pathOfTemporaryFile, FileChannel temporaryFileChannel, boolean keepBackup) throws IOException {
        this(path,
                pathOfTemporaryFile,
                Channels.newOutputStream(temporaryFileChannel),
                temporaryFileChannel,
                keepBackup);
    }

    /// Creates a new output stream to write to or replace the file at the specified path.
    /// The backup file (.sav) is deleted when write was successful.
    ///
    /// @param path the path of the file to write to or replace
    public AtomicFileOutputStream(Path path) throws IOException {
        this(path, false);
    }

    /// Required for proper testing
    AtomicFileOutputStream(Path path, Path pathOfTemporaryFile, OutputStream temporaryFileOutputStream, boolean keepBackup) throws IOException {
        this(path, pathOfTemporaryFile, temporaryFileOutputStream, null, keepBackup);
    }

    private AtomicFileOutputStream(Path path,
                                   Path pathOfTemporaryFile,
                                   OutputStream temporaryFileOutputStream,
                                   @Nullable FileChannel temporaryFileChannel,
                                   boolean keepBackup) throws IOException {
        super(temporaryFileOutputStream);
        this.targetFile = path;
        this.temporaryFile = pathOfTemporaryFile;
        this.backupFile = getPathOfSaveBackupFile(path);
        this.keepBackup = keepBackup;
        this.temporaryFileChannel = temporaryFileChannel;

        if (temporaryFileChannel == null) {
            return;
        }

        try {
            // Lock files (so that at least not another JabRef instance writes at the same time to the same tmp file)
            temporaryFileLock = temporaryFileChannel.tryLock();
        } catch (IOException ex) {
            // workaround for https://bugs.openjdk.org/browse/JDK-8167023
            LOGGER.warn("Could not acquire file lock. Maybe we are on a network drive?", ex);
            temporaryFileLock = null;
        } catch (OverlappingFileLockException exception) {
            throw new IOException("Could not obtain write access to " + temporaryFile + ". Maybe another instance of JabRef is currently writing to the same file?", exception);
        }
    }

    private static Path getPathOfTemporaryFile(Path targetFile) {
        return FileUtil.addExtension(targetFile, TEMPORARY_EXTENSION);
    }

    private static Path getPathOfSaveBackupFile(Path targetFile) {
        return FileUtil.addExtension(targetFile, SAVE_EXTENSION);
    }

    /// Returns the path of the backup copy of the original file (may not exist)
    public Path getBackup() {
        return backupFile;
    }

    /// Overridden because of cleanup actions in case of an error
    @Override
    public void write(byte[] b, int off, int len) throws IOException {
        try {
            out.write(b, off, len);
        } catch (IOException exception) {
            cleanup();
            errorDuringWrite = true;
            throw exception;
        }
    }

    /// Closes the write process to the temporary file but does not commit to the target file.
    public void abort() {
        errorDuringWrite = true;
        try {
            super.close();
            Files.deleteIfExists(temporaryFile);
            Files.deleteIfExists(backupFile);
        } catch (IOException exception) {
            LOGGER.debug("Unable to abort writing to file {}", temporaryFile, exception);
        }
    }

    private void cleanup() {
        try {
            if (temporaryFileLock != null && temporaryFileLock.isValid()) {
                temporaryFileLock.release();
            }
        } catch (IOException exception) {
            // In case we still get an exception
            LOGGER.debug("Unable to release lock on file {}", temporaryFile, exception);
        }
        try {
            Files.deleteIfExists(temporaryFile);
        } catch (IOException exception) {
            LOGGER.debug("Unable to delete file {}", temporaryFile, exception);
        }
    }

    /// perform the final operations to move the temporary file to its final destination
    @Override
    public void close() throws IOException {
        try {
            try {
                // Make sure we have written everything to the temporary file
                flush();
                if (temporaryFileChannel != null) {
                    temporaryFileChannel.force(true);
                }
            } catch (IOException exception) {
                // Try to close nonetheless
                super.close();
                throw exception;
            }
            super.close();

            if (errorDuringWrite) {
                // in case there was an error during write, we do not replace the original file
                return;
            }

            // We successfully wrote everything to the temporary file, lets copy it to the correct place
            // First, make backup of original file and try to save file permissions to restore them later (by default: 664)
            Set<PosixFilePermission> oldFilePermissions = EnumSet.of(PosixFilePermission.OWNER_READ,
                    PosixFilePermission.OWNER_WRITE,
                    PosixFilePermission.GROUP_READ,
                    PosixFilePermission.GROUP_WRITE,
                    PosixFilePermission.OTHERS_READ);
            if (Files.exists(targetFile)) {
                try {
                    Files.copy(targetFile, backupFile, StandardCopyOption.REPLACE_EXISTING);
                } catch (IOException e) {
                    LOGGER.warn("Could not create backup file {}", backupFile);
                }
                if (FileUtil.IS_POSIX_COMPLIANT) {
                    try {
                        oldFilePermissions = Files.getPosixFilePermissions(targetFile);
                    } catch (IOException exception) {
                        LOGGER.warn("Error getting file permissions for file {}.", targetFile, exception);
                    }
                }
            }

            // Move temporary file (replace original if it exists)
            moveTemporaryFileToTargetFile();

            // Restore file permissions
            if (FileUtil.IS_POSIX_COMPLIANT) {
                try {
                    Files.setPosixFilePermissions(targetFile, oldFilePermissions);
                } catch (IOException exception) {
                    LOGGER.warn("Error writing file permissions to file {}.", targetFile, exception);
                }
            }

            if (!keepBackup) {
                // Remove backup file for saving
                Files.deleteIfExists(backupFile);
            }
        } finally {
            // Remove temporary file (but not the backup!)
            cleanup();
        }
    }

    /// Moves the temporary file onto the target file, replacing it.
    ///
    /// On Windows, replacing a file requires `DELETE` access to it. That fails with a sharing violation
    /// (`ERROR_SHARING_VIOLATION`) while any other process holds a handle that was opened without
    /// `FILE_SHARE_DELETE`. Qt-based editors (for example TeXstudio, which re-reads `.bib` files while the user
    /// types), anti-virus scanners and the search indexer all open files that way, typically only for a few
    /// milliseconds. Retrying briefly therefore clears virtually all of these collisions.
    ///
    /// See <[#11916](https://github.com/JabRef/jabref/issues/11916)>.
    private void moveTemporaryFileToTargetFile() throws IOException {
        for (int attempt = 1; attempt <= MOVE_ATTEMPTS; attempt++) {
            try {
                Files.move(temporaryFile, targetFile, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
                return;
            } catch (FileSystemException exception) {
                if (attempt == MOVE_ATTEMPTS) {
                    LOGGER.warn("Could not move temporary file", exception);
                    throw exception;
                }
                LOGGER.debug("Attempt {} of {} to move {} onto {} failed", attempt, MOVE_ATTEMPTS, temporaryFile, targetFile, exception);
                try {
                    Thread.sleep(MOVE_RETRY_INITIAL_DELAY_MILLIS << (attempt - 1));
                } catch (InterruptedException interruptedException) {
                    Thread.currentThread().interrupt();
                    InterruptedIOException interruptedIOException = new InterruptedIOException("Interrupted while moving temporary file " + temporaryFile + " onto " + targetFile);
                    interruptedIOException.initCause(interruptedException);
                    interruptedIOException.addSuppressed(exception);
                    LOGGER.warn("Interrupted while moving temporary file {} onto {}", temporaryFile, targetFile, interruptedIOException);
                    throw interruptedIOException;
                }
            }
        }
    }

    @Override
    public void flush() throws IOException {
        try {
            super.flush();
        } catch (IOException exception) {
            cleanup();
            throw exception;
        }
    }

    @Override
    public void write(int b) throws IOException {
        try {
            super.write(b);
        } catch (IOException exception) {
            cleanup();
            throw exception;
        }
    }
}

