/*
 * Copyright (C) 2009 The Android Open Source Project
 *
 * Licensed 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 android.util;

import android.annotation.CurrentTimeMillisLong;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.annotation.SuppressLint;
import android.annotation.SystemApi;
import android.os.FileUtils;
import android.os.SystemClock;

import libcore.io.IoUtils;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.function.Consumer;

/**
 * Helper class for performing atomic operations on a file by writing to a new file and renaming it
 * into the place of the original file after the write has successfully completed. If you need this
 * on older versions of the platform you can use {@link androidx.core.util.AtomicFile} in AndroidX.
 * <p>
 * Atomic file guarantees file integrity by ensuring that a file has been completely written and
 * sync'd to disk before renaming it to the original file. Previously this is done by renaming the
 * original file to a backup file beforehand, but this approach couldn't handle the case where the
 * file is created for the first time. This class will also handle the backup file created by the
 * old implementation properly.
 * <p>
 * Atomic file does not confer any file locking semantics. Do not use this class when the file may
 * be accessed or modified concurrently by multiple threads or processes. The caller is responsible
 * for ensuring appropriate mutual exclusion invariants whenever it accesses the file.
 */
public class AtomicFile {
    private static final String LOG_TAG = "AtomicFile";

    private final File mBaseName;
    private final File mNewName;
    private final File mLegacyBackupName;
    private SystemConfigFileCommitEventLogger mCommitEventLogger;

    /**
     * Create a new AtomicFile for a file located at the given File path.
     * The new file created when writing will be the same file path with ".new" appended.
     */
    public AtomicFile(File baseName) {
        this(baseName, (SystemConfigFileCommitEventLogger) null);
    }

    /**
     * @hide Internal constructor that also allows you to have the class
     * automatically log commit events.
     */
    public AtomicFile(File baseName, String commitTag) {
        this(baseName, new SystemConfigFileCommitEventLogger(commitTag));
    }

    /**
     * Internal constructor that also allows you to have the class
     * automatically log commit events.
     *
     * @hide
     */
    @SystemApi(client = SystemApi.Client.MODULE_LIBRARIES)
    @SuppressLint("StreamFiles")
    public AtomicFile(@NonNull File baseName,
            @Nullable SystemConfigFileCommitEventLogger commitEventLogger) {
        mBaseName = baseName;
        mNewName = new File(baseName.getPath() + ".new");
        mLegacyBackupName = new File(baseName.getPath() + ".bak");
        mCommitEventLogger = commitEventLogger;
    }

    /**
     * Return the path to the base file.  You should not generally use this,
     * as the data at that path may not be valid.
     */
    public File getBaseFile() {
        return mBaseName;
    }

    /**
     * Delete the atomic file.  This deletes both the base and new files.
     */
    public void delete() {
        mBaseName.delete();
        mNewName.delete();
        mLegacyBackupName.delete();
    }

    /**
     * Start a new write operation on the file.  This returns a FileOutputStream
     * to which you can write the new file data.  The existing file is replaced
     * with the new data.  You <em>must not</em> directly close the given
     * FileOutputStream; instead call either {@link #finishWrite(FileOutputStream)}
     * or {@link #failWrite(FileOutputStream)}.
     *
     * <p>Note that if another thread is currently performing
     * a write, this will simply replace whatever that thread is writing
     * with the new file being written by this thread, and when the other
     * thread finishes the write the new write operation will no longer be
     * safe (or will be lost).  You must do your own threading protection for
     * access to AtomicFile.
     */
    public FileOutputStream startWrite() throws IOException {
        return startWrite(0);
    }

    /**
     * @hide Internal version of {@link #startWrite()} that allows you to specify an earlier
     * start time of the operation to adjust how the commit is logged.
     * @param startTime The effective start time of the operation, in the time
     * base of {@link SystemClock#uptimeMillis()}.
     *
     * @deprecated Use {@link SystemConfigFileCommitEventLogger#setStartTime} followed
     * by {@link #startWrite()}
     */
    @Deprecated
    public FileOutputStream startWrite(long startTime) throws IOException {
        if (mCommitEventLogger != null) {
            if (startTime != 0) {
                mCommitEventLogger.setStartTime(startTime);
            }

            mCommitEventLogger.onStartWrite();
        }

        if (mLegacyBackupName.exists()) {
            rename(mLegacyBackupName, mBaseName);
        }

        try {
            return new FileOutputStream(mNewName);
        } catch (FileNotFoundException e) {
            File parent = mNewName.getParentFile();
            if (!parent.mkdirs()) {
                throw new IOException("Failed to create directory for " + mNewName);
            }
            FileUtils.setPermissions(parent.getPath(), FileUtils.S_IRWXU | FileUtils.S_IRWXG
                    | FileUtils.S_IXOTH, -1, -1);
            try {
                return new FileOutputStream(mNewName);
            } catch (FileNotFoundException e2) {
                throw new IOException("Failed to create new file " + mNewName, e2);
            }
        }
    }

    /**
     * Call when you have successfully finished writing to the stream
     * returned by {@link #startWrite()}.  This will close, sync, and
     * commit the new data.  The next attempt to read the atomic file
     * will return the new file stream.
     */
    public void finishWrite(FileOutputStream str) {
        if (str == null) {
            return;
        }
        if (!FileUtils.sync(str)) {
            Log.e(LOG_TAG, "Failed to sync file output stream");
        }
        try {
            str.close();
        } catch (IOException e) {
            Log.e(LOG_TAG, "Failed to close file output stream", e);
        }
        rename(mNewName, mBaseName);
        if (mCommitEventLogger != null) {
            mCommitEventLogger.onFinishWrite();
        }
    }

    /**
     * Call when you have failed for some reason at writing to the stream
     * returned by {@link #startWrite()}.  This will close the current
     * write stream, and delete the new file.
     */
    public void failWrite(FileOutputStream str) {
        if (str == null) {
            return;
        }
        if (!FileUtils.sync(str)) {
            Log.e(LOG_TAG, "Failed to sync file output stream");
        }
        try {
            str.close();
        } catch (IOException e) {
            Log.e(LOG_TAG, "Failed to close file output stream", e);
        }
        if (!mNewName.delete()) {
            Log.e(LOG_TAG, "Failed to delete new file " + mNewName);
        }
    }

    /** @hide
     * @deprecated This is not safe.
     */
    @Deprecated public void truncate() throws IOException {
        try {
            FileOutputStream fos = new FileOutputStream(mBaseName);
            FileUtils.sync(fos);
            fos.close();
        } catch (FileNotFoundException e) {
            throw new IOException("Couldn't append " + mBaseName);
        } catch (IOException e) {
        }
    }

    /** @hide
     * @deprecated This is not safe.
     */
    @Deprecated public FileOutputStream openAppend() throws IOException {
        try {
            return new FileOutputStream(mBaseName, true);
        } catch (FileNotFoundException e) {
            throw new IOException("Couldn't append " + mBaseName);
        }
    }

    /**
     * Open the atomic file for reading. You should call close() on the FileInputStream when you are
     * done reading from it.
     * <p>
     * You must do your own threading protection for access to AtomicFile.
     */
    public FileInputStream openRead() throws FileNotFoundException {
        if (mLegacyBackupName.exists()) {
            rename(mLegacyBackupName, mBaseName);
        }

        // It was okay to call openRead() between startWrite() and finishWrite() for the first time
        // (because there is no backup file), where openRead() would open the file being written,
        // which makes no sense, but finishWrite() would still persist the write properly. For all
        // subsequent writes, if openRead() was called in between, it would see a backup file and
        // delete the file being written, the same behavior as our new implementation. So we only
        // need a special case for the first write, and don't delete the new file in this case so
        // that finishWrite() can still work.
        if (mNewName.exists() && mBaseName.exists()) {
            if (!mNewName.delete()) {
                Log.e(LOG_TAG, "Failed to delete outdated new file " + mNewName);
            }
        }
        return new FileInputStream(mBaseName);
    }

    /**
     * @hide
     * Checks if the original or legacy backup file exists.
     * @return whether the original or legacy backup file exists.
     */
    public boolean exists() {
        return mBaseName.exists() || mLegacyBackupName.exists();
    }

    /**
     * Gets the last modified time of the atomic file.
     *
     * @return last modified time in milliseconds since epoch.  Returns zero if
     *     the file does not exist or an I/O error is encountered.
     */
    @CurrentTimeMillisLong
    public long getLastModifiedTime() {
        if (mLegacyBackupName.exists()) {
            return mLegacyBackupName.lastModified();
        }
        return mBaseName.lastModified();
    }

    /**
     * A convenience for {@link #openRead()} that also reads all of the
     * file contents into a byte array which is returned.
     */
    public byte[] readFully() throws IOException {
        FileInputStream stream = openRead();
        try {
            int pos = 0;
            int avail = stream.available();
            byte[] data = new byte[avail];
            while (true) {
                int amt = stream.read(data, pos, data.length-pos);
                //Log.i("foo", "Read " + amt + " bytes at " + pos
                //        + " of avail " + data.length);
                if (amt <= 0) {
                    //Log.i("foo", "**** FINISHED READING: pos=" + pos
                    //        + " len=" + data.length);
                    return data;
                }
                pos += amt;
                avail = stream.available();
                if (avail > data.length-pos) {
                    byte[] newData = new byte[pos+avail];
                    System.arraycopy(data, 0, newData, 0, pos);
                    data = newData;
                }
            }
        } finally {
            stream.close();
        }
    }

    /** @hide */
    public void write(Consumer<FileOutputStream> writeContent) {
        FileOutputStream out = null;
        try {
            out = startWrite();
            writeContent.accept(out);
            finishWrite(out);
        } catch (Throwable t) {
            failWrite(out);
            throw ExceptionUtils.propagate(t);
        } finally {
            IoUtils.closeQuietly(out);
        }
    }

    @Override
    public String toString() {
        return "AtomicFile[" + mBaseName + "]";
    }

    private static void rename(File source, File target) {
        // We used to delete the target file before rename, but that isn't atomic, and the rename()
        // syscall should atomically replace the target file. However in the case where the target
        // file is a directory, a simple rename() won't work. We need to delete the file in this
        // case because there are callers who erroneously called mBaseName.mkdirs() (instead of
        // mBaseName.getParentFile().mkdirs()) before creating the AtomicFile, and it worked
        // regardless, so this deletion became some kind of API.
        if (target.isDirectory()) {
            if (!target.delete()) {
                Log.e(LOG_TAG, "Failed to delete file which is a directory " + target);
            }
        }
        if (!source.renameTo(target)) {
            Log.e(LOG_TAG, "Failed to rename " + source + " to " + target);
        }
    }
}
