/*
 * Copyright (C) 2017 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 com.android.server.power.batterysaver;

import android.content.Context;
import android.os.Environment;
import android.os.Handler;
import android.os.Looper;
import android.os.SystemProperties;
import android.util.ArrayMap;
import android.util.AtomicFile;
import android.util.Slog;
import android.util.Xml;

import com.android.internal.annotations.GuardedBy;
import com.android.internal.annotations.VisibleForTesting;
import com.android.internal.util.FastXmlSerializer;
import com.android.internal.util.XmlUtils;
import com.android.server.IoThread;

import libcore.io.IoUtils;

import org.xmlpull.v1.XmlPullParser;
import org.xmlpull.v1.XmlPullParserException;
import org.xmlpull.v1.XmlSerializer;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.FileWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Map;

/**
 * Used by {@link BatterySaverController} to write values to /sys/ (and possibly /proc/ too) files
 * with retries. It also support restoring to the file original values.
 *
 * Retries are needed because writing to "/sys/.../scaling_max_freq" returns EIO when the current
 * frequency happens to be above the new max frequency.
 *
 * Test:
 atest $ANDROID_BUILD_TOP/frameworks/base/services/tests/servicestests/src/com/android/server/power/batterysaver/FileUpdaterTest.java
 */
public class FileUpdater {
    private static final String TAG = BatterySaverController.TAG;

    private static final boolean DEBUG = BatterySaverController.DEBUG;

    /**
     * If this system property is set to 1, it'll skip all file writes. This can be used when
     * one needs to change max CPU frequency for benchmarking, for example.
     */
    private static final String PROP_SKIP_WRITE = "debug.batterysaver.no_write_files";

    private static final String TAG_DEFAULT_ROOT = "defaults";

    // Don't do disk access with this lock held.
    private final Object mLock = new Object();

    private final Context mContext;

    private final Handler mHandler;

    /**
     * Filename -> value map that holds pending writes.
     */
    @GuardedBy("mLock")
    private final ArrayMap<String, String> mPendingWrites = new ArrayMap<>();

    /**
     * Filename -> value that holds the original value of each file.
     */
    @GuardedBy("mLock")
    private final ArrayMap<String, String> mDefaultValues = new ArrayMap<>();

    /** Number of retries. We give up on writing after {@link #MAX_RETRIES} retries. */
    @GuardedBy("mLock")
    private int mRetries = 0;

    private final int MAX_RETRIES;

    private final long RETRY_INTERVAL_MS;

    /**
     * "Official" constructor. Don't use the other constructor in the production code.
     */
    public FileUpdater(Context context) {
        this(context, IoThread.get().getLooper(), 10, 5000);
    }

    /**
     * Constructor for test.
     */
    @VisibleForTesting
    FileUpdater(Context context, Looper looper, int maxRetries, int retryIntervalMs) {
        mContext = context;
        mHandler = new Handler(looper);

        MAX_RETRIES = maxRetries;
        RETRY_INTERVAL_MS = retryIntervalMs;
    }

    public void systemReady(boolean runtimeRestarted) {
        synchronized (mLock) {
            if (runtimeRestarted) {
                // If it runtime restarted, read the original values from the disk and apply.
                if (loadDefaultValuesLocked()) {
                    Slog.d(TAG, "Default values loaded after runtime restart; writing them...");
                    restoreDefault();
                }
            } else {
                // Delete it, without checking the result. (file-not-exist is not an exception.)
                injectDefaultValuesFilename().delete();
            }
        }
    }

    /**
     * Write values to files. (Note the actual writes happen ASAP but asynchronously.)
     */
    public void writeFiles(ArrayMap<String, String> fileValues) {
        synchronized (mLock) {
            for (int i = fileValues.size() - 1; i >= 0; i--) {
                final String file = fileValues.keyAt(i);
                final String value = fileValues.valueAt(i);

                if (DEBUG) {
                    Slog.d(TAG, "Scheduling write: '" + value + "' to '" + file + "'");
                }

                mPendingWrites.put(file, value);

            }
            mRetries = 0;

            mHandler.removeCallbacks(mHandleWriteOnHandlerRunnable);
            mHandler.post(mHandleWriteOnHandlerRunnable);
        }
    }

    /**
     * Restore the default values.
     */
    public void restoreDefault() {
        synchronized (mLock) {
            if (DEBUG) {
                Slog.d(TAG, "Resetting file default values.");
            }
            mPendingWrites.clear();

            writeFiles(mDefaultValues);
        }
    }

    private Runnable mHandleWriteOnHandlerRunnable = () -> handleWriteOnHandler();

    /** Convert map keys into a single string for debug messages. */
    private String getKeysString(Map<String, String> source) {
        return new ArrayList<>(source.keySet()).toString();
    }

    /** Clone an ArrayMap. */
    private ArrayMap<String, String> cloneMap(ArrayMap<String, String> source) {
        return new ArrayMap<>(source);
    }

    /**
     * Called on the handler and writes {@link #mPendingWrites} to the disk.
     *
     * When it about to write to each file for the first time, it'll read the file and store
     * the original value in {@link #mDefaultValues}.
     */
    private void handleWriteOnHandler() {
        // We don't want to access the disk with the lock held, so copy the pending writes to
        // a local map.
        final ArrayMap<String, String> writes;
        synchronized (mLock) {
            if (mPendingWrites.size() == 0) {
                return;
            }

            if (DEBUG) {
                Slog.d(TAG, "Writing files: (# retries=" + mRetries + ") " +
                        getKeysString(mPendingWrites));
            }

            writes = cloneMap(mPendingWrites);
        }

        // Then write.

        boolean needRetry = false;

        final int size = writes.size();
        for (int i = 0; i < size; i++) {
            final String file = writes.keyAt(i);
            final String value = writes.valueAt(i);

            // Make sure the default value is loaded.
            if (!ensureDefaultLoaded(file)) {
                continue;
            }

            // Write to the file. When succeeded, remove it from the pending list.
            // Otherwise, schedule a retry.
            try {
                injectWriteToFile(file, value);

                removePendingWrite(file);
            } catch (IOException e) {
                needRetry = true;
            }
        }
        if (needRetry) {
            scheduleRetry();
        }
    }

    private void removePendingWrite(String file) {
        synchronized (mLock) {
            mPendingWrites.remove(file);
        }
    }

    private void scheduleRetry() {
        synchronized (mLock) {
            if (mPendingWrites.size() == 0) {
                return; // Shouldn't happen but just in case.
            }

            mRetries++;
            if (mRetries > MAX_RETRIES) {
                doWtf("Gave up writing files: " + getKeysString(mPendingWrites));
                return;
            }

            mHandler.removeCallbacks(mHandleWriteOnHandlerRunnable);
            mHandler.postDelayed(mHandleWriteOnHandlerRunnable, RETRY_INTERVAL_MS);
        }
    }

    /**
     * Make sure {@link #mDefaultValues} has the default value loaded for {@code file}.
     *
     * @return true if the default value is loaded. false if the file cannot be read.
     */
    private boolean ensureDefaultLoaded(String file) {
        // Has the default already?
        synchronized (mLock) {
            if (mDefaultValues.containsKey(file)) {
                return true;
            }
        }
        final String originalValue;
        try {
            originalValue = injectReadFromFileTrimmed(file);
        } catch (IOException e) {
            // If the file is not readable, assume can't write too.
            injectWtf("Unable to read from file", e);

            removePendingWrite(file);
            return false;
        }
        synchronized (mLock) {
            mDefaultValues.put(file, originalValue);
            saveDefaultValuesLocked();
        }
        return true;
    }

    @VisibleForTesting
    String injectReadFromFileTrimmed(String file) throws IOException {
        return IoUtils.readFileAsString(file).trim();
    }

    @VisibleForTesting
    void injectWriteToFile(String file, String value) throws IOException {
        if (injectShouldSkipWrite()) {
            Slog.i(TAG, "Skipped writing to '" + file + "'");
            return;
        }
        if (DEBUG) {
            Slog.d(TAG, "Writing: '" + value + "' to '" + file + "'");
        }
        try (FileWriter out = new FileWriter(file)) {
            out.write(value);
        } catch (IOException | RuntimeException e) {
            Slog.w(TAG, "Failed writing '" + value + "' to '" + file + "': " + e.getMessage());
            throw e;
        }
    }

    @GuardedBy("mLock")
    private void saveDefaultValuesLocked() {
        final AtomicFile file = new AtomicFile(injectDefaultValuesFilename());

        FileOutputStream outs = null;
        try {
            file.getBaseFile().getParentFile().mkdirs();
            outs = file.startWrite();

            // Write to XML
            XmlSerializer out = new FastXmlSerializer();
            out.setOutput(outs, StandardCharsets.UTF_8.name());
            out.startDocument(null, true);
            out.startTag(null, TAG_DEFAULT_ROOT);

            XmlUtils.writeMapXml(mDefaultValues, out, null);

            // Epilogue.
            out.endTag(null, TAG_DEFAULT_ROOT);
            out.endDocument();

            // Close.
            file.finishWrite(outs);
        } catch (IOException | XmlPullParserException | RuntimeException e) {
            Slog.e(TAG, "Failed to write to file " + file.getBaseFile(), e);
            file.failWrite(outs);
        }
    }

    @GuardedBy("mLock")
    @VisibleForTesting
    boolean loadDefaultValuesLocked() {
        final AtomicFile file = new AtomicFile(injectDefaultValuesFilename());
        if (DEBUG) {
            Slog.d(TAG, "Loading from " + file.getBaseFile());
        }
        Map<String, String> read = null;
        try (FileInputStream in = file.openRead()) {
            XmlPullParser parser = Xml.newPullParser();
            parser.setInput(in, StandardCharsets.UTF_8.name());

            int type;
            while ((type = parser.next()) != XmlPullParser.END_DOCUMENT) {
                if (type != XmlPullParser.START_TAG) {
                    continue;
                }
                final int depth = parser.getDepth();
                // Check the root tag
                final String tag = parser.getName();
                if (depth == 1) {
                    if (!TAG_DEFAULT_ROOT.equals(tag)) {
                        Slog.e(TAG, "Invalid root tag: " + tag);
                        return false;
                    }
                    continue;
                }
                final String[] tagName = new String[1];
                read = (ArrayMap<String, String>) XmlUtils.readThisArrayMapXml(parser,
                        TAG_DEFAULT_ROOT, tagName, null);
            }
        } catch (FileNotFoundException e) {
            read = null;
        } catch (IOException | XmlPullParserException | RuntimeException e) {
            Slog.e(TAG, "Failed to read file " + file.getBaseFile(), e);
        }
        if (read != null) {
            mDefaultValues.clear();
            mDefaultValues.putAll(read);
            return true;
        }
        return false;
    }

    private void doWtf(String message) {
        injectWtf(message, null);
    }

    @VisibleForTesting
    void injectWtf(String message, Throwable e) {
        Slog.wtf(TAG, message, e);
    }

    File injectDefaultValuesFilename() {
        final File dir = new File(Environment.getDataSystemDirectory(), "battery-saver");
        dir.mkdirs();
        return new File(dir, "default-values.xml");
    }

    @VisibleForTesting
    boolean injectShouldSkipWrite() {
        return SystemProperties.getBoolean(PROP_SKIP_WRITE, false);
    }

    @VisibleForTesting
    ArrayMap<String, String> getDefaultValuesForTest() {
        return mDefaultValues;
    }
}
