/*
 * This file is part of LSPosed.
 *
 * LSPosed is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * LSPosed is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with LSPosed.  If not, see <https://www.gnu.org/licenses/>.
 *
 * Copyright (C) 2021 - 2022 LSPosed Contributors
 */

package org.lsposed.lspd.service;

import static org.lsposed.lspd.service.ServiceManager.TAG;
import static org.lsposed.lspd.service.ServiceManager.toGlobalNamespace;

import android.content.res.AssetManager;
import android.content.res.Resources;
import android.os.Binder;
import android.os.ParcelFileDescriptor;
import android.os.Process;
import android.os.RemoteException;
import android.os.SELinux;
import android.os.SharedMemory;
import android.system.ErrnoException;
import android.system.Os;
import android.system.OsConstants;
import android.util.Log;

import androidx.annotation.Nullable;

import org.lsposed.daemon.BuildConfig;
import org.lsposed.lspd.models.PreLoadedApk;
import org.lsposed.lspd.util.InstallerVerifier;
import org.lsposed.lspd.util.Utils;

import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.lang.reflect.Method;
import java.nio.channels.Channels;
import java.nio.channels.FileChannel;
import java.nio.channels.FileLock;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.OpenOption;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.nio.file.attribute.PosixFilePermissions;
import java.time.Instant;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.stream.Stream;
import java.util.zip.Deflater;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import java.util.zip.ZipOutputStream;

import hidden.HiddenApiBridge;

public class ConfigFileManager {
    static final Path basePath = Paths.get("/data/adb/lspd");
    static final Path modulePath = basePath.resolve("modules");
    static final Path daemonApkPath = Paths.get(System.getProperty("java.class.path", null));
    static final Path managerApkPath = daemonApkPath.getParent().resolve("manager.apk");
    static final File magiskDbPath = new File("/data/adb/magisk.db");
    private static final Path lockPath = basePath.resolve("lock");
    private static final Path configDirPath = basePath.resolve("config");
    static final File dbPath = configDirPath.resolve("modules_config.db").toFile();
    private static final Path logDirPath = basePath.resolve("log");
    private static final Path oldLogDirPath = basePath.resolve("log.old");
    private static final DateTimeFormatter formatter =
            DateTimeFormatter.ISO_LOCAL_DATE_TIME.withZone(Utils.getZoneId());
    @SuppressWarnings("FieldCanBeLocal")
    private static FileLocker locker = null;
    private static Resources res = null;
    private static ParcelFileDescriptor fd = null;
    private static SharedMemory preloadDex = null;

    static {
        try {
            Files.createDirectories(basePath);
            SELinux.setFileContext(basePath.toString(), "u:object_r:system_file:s0");
            Files.createDirectories(configDirPath);
            createLogDirPath();
        } catch (IOException e) {
            Log.e(TAG, Log.getStackTraceString(e));
        }
    }

    public static void transfer(InputStream in, OutputStream out) throws IOException {
        int size = 8192;
        var buffer = new byte[size];
        int read;
        while ((read = in.read(buffer, 0, size)) >= 0) {
            out.write(buffer, 0, read);
        }
    }

    private static void createLogDirPath() throws IOException {
        if (!Files.isDirectory(logDirPath, LinkOption.NOFOLLOW_LINKS)) {
            Files.deleteIfExists(logDirPath);
        }
        Files.createDirectories(logDirPath);
    }

    public static Resources getResources() {
        loadRes();
        return res;
    }

    private static void loadRes() {
        if (res != null) return;
        try {
            var am = AssetManager.class.newInstance();
            //noinspection JavaReflectionMemberAccess DiscouragedPrivateApi
            Method addAssetPath = AssetManager.class.getDeclaredMethod("addAssetPath", String.class);
            addAssetPath.setAccessible(true);
            //noinspection ConstantConditions
            if ((int) addAssetPath.invoke(am, daemonApkPath.toString()) > 0) {
                //noinspection deprecation
                res = new Resources(am, null, null);
            }
        } catch (Throwable e) {
            Log.e(TAG, Log.getStackTraceString(e));
        }
    }

    static void reloadConfiguration() {
        loadRes();
        try {
            var conf = ActivityManagerService.getConfiguration();
            if (conf != null)
                //noinspection deprecation
                res.updateConfiguration(conf, res.getDisplayMetrics());
        } catch (Throwable e) {
            Log.e(TAG, "reload configuration", e);
        }
    }

    static ParcelFileDescriptor getManagerApk() throws IOException {
        if (fd != null) return fd.dup();
        InstallerVerifier.verifyInstallerSignature(managerApkPath.toString());

        SELinux.setFileContext(managerApkPath.toString(), "u:object_r:system_file:s0");
        fd = ParcelFileDescriptor.open(managerApkPath.toFile(), ParcelFileDescriptor.MODE_READ_ONLY);
        return fd.dup();
    }

    static void deleteFolderIfExists(Path target) throws IOException {
        if (Files.notExists(target)) return;
        Files.walkFileTree(target, new SimpleFileVisitor<>() {
            @Override
            public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)
                    throws IOException {
                Files.delete(file);
                return FileVisitResult.CONTINUE;
            }

            @Override
            public FileVisitResult postVisitDirectory(Path dir, IOException e)
                    throws IOException {
                if (e == null) {
                    Files.delete(dir);
                    return FileVisitResult.CONTINUE;
                } else {
                    throw e;
                }
            }
        });
    }

    public static boolean chattr0(Path path) {
        try {
            var dir = Os.open(path.toString(), OsConstants.O_RDONLY, 0);
            // Clear all special file attributes on the directory
            HiddenApiBridge.Os_ioctlInt(dir, Process.is64Bit() ? 0x40086602 : 0x40046602, 0);
            Os.close(dir);
            return true;
        } catch (ErrnoException e) {
            // If the operation is not supported (ENOTSUP), it means the filesystem doesn't support attributes.
            // We can assume the file is not immutable and proceed.
            if (e.errno == OsConstants.ENOTSUP) {
                return true;
            }
            Log.d(TAG, "chattr 0", e);
            return false;
        } catch (Throwable e) {
            Log.d(TAG, "chattr 0", e);
            return false;
        }
    }

    static void moveLogDir() {
        try {
            if (Files.exists(logDirPath)) {
                if (chattr0(logDirPath)) {
                    deleteFolderIfExists(oldLogDirPath);
                    Files.move(logDirPath, oldLogDirPath);
                }
            }
            Files.createDirectories(logDirPath);
        } catch (IOException e) {
            Log.e(TAG, Log.getStackTraceString(e));
        }
    }

    private static String getNewLogFileName(String prefix) {
        return prefix + "_" + formatter.format(Instant.now()) + ".log";
    }

    static File getNewVerboseLogPath() throws IOException {
        createLogDirPath();
        return logDirPath.resolve(getNewLogFileName("verbose")).toFile();
    }

    static File getNewModulesLogPath() throws IOException {
        createLogDirPath();
        return logDirPath.resolve(getNewLogFileName("modules")).toFile();
    }

    static File getPropsPath() throws IOException {
        createLogDirPath();
        return logDirPath.resolve("props.txt").toFile();
    }

    static File getKmsgPath() throws IOException {
        createLogDirPath();
        return logDirPath.resolve("kmsg.log").toFile();
    }

    static void getLogs(ParcelFileDescriptor zipFd) throws IllegalStateException {
        try (zipFd; var os = new ZipOutputStream(new FileOutputStream(zipFd.getFileDescriptor()))) {
            var comment = String.format(Locale.ROOT, "LSPosed %s %s (%d)",
                    BuildConfig.BUILD_TYPE, BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE);
            os.setComment(comment);
            os.setLevel(Deflater.BEST_COMPRESSION);
            zipAddDir(os, logDirPath);
            zipAddDir(os, oldLogDirPath);
            zipAddDir(os, Paths.get("/data/tombstones"));
            zipAddDir(os, Paths.get("/data/anr"));
            var data = Paths.get("/data/data");
            var app1 = data.resolve(BuildConfig.MANAGER_INJECTED_PKG_NAME + "/cache/crash");
            var app2 = data.resolve(BuildConfig.DEFAULT_MANAGER_PACKAGE_NAME + "/cache/crash");
            zipAddDir(os, app1);
            zipAddDir(os, app2);
            zipAddProcOutput(os, "full.log", "logcat", "-b", "all", "-d");
            zipAddProcOutput(os, "dmesg.log", "dmesg");
            var magiskDataDir = Paths.get("/data/adb");
            try (var l = Files.list(magiskDataDir.resolve("modules"))) {
                l.forEach(p -> {
                    zipAddFile(os, p, magiskDataDir);
                    zipAddFile(os, p.resolve("module.prop"), magiskDataDir);
                    zipAddFile(os, p.resolve("remove"), magiskDataDir);
                    zipAddFile(os, p.resolve("disable"), magiskDataDir);
                    zipAddFile(os, p.resolve("update"), magiskDataDir);
                    zipAddFile(os, p.resolve("sepolicy.rule"), magiskDataDir);
                });
            }
            var proc = Paths.get("/proc");
            for (var pid : new String[]{"self", String.valueOf(Binder.getCallingPid())}) {
                var pidPath = proc.resolve(pid);
                zipAddFile(os, pidPath.resolve("maps"), proc);
                zipAddFile(os, pidPath.resolve("mountinfo"), proc);
                zipAddFile(os, pidPath.resolve("status"), proc);
            }
            zipAddFile(os, dbPath.toPath(), configDirPath);
            ConfigManager.getInstance().exportScopes(os);
        } catch (Throwable e) {
            Log.w(TAG, "get log", e);
            throw new IllegalStateException(e);
        }
    }

    private static void zipAddProcOutput(ZipOutputStream os, String name, String... command) {
        try (var is = new ProcessBuilder(command).start().getInputStream()) {
            os.putNextEntry(new ZipEntry(name));
            transfer(is, os);
            os.closeEntry();
        } catch (IOException e) {
            Log.w(TAG, name, e);
        }
    }

    private static void zipAddFile(ZipOutputStream os, Path path, Path base) {
        var name = base.relativize(path).toString();
        if (Files.isDirectory(path)) {
            try {
                os.putNextEntry(new ZipEntry(name + "/"));
                os.closeEntry();
            } catch (IOException e) {
                Log.w(TAG, name, e);
            }
        } else if (Files.exists(path)) {
            try (var is = new FileInputStream(path.toFile())) {
                os.putNextEntry(new ZipEntry(name));
                transfer(is, os);
                os.closeEntry();
            } catch (IOException e) {
                Log.w(TAG, name, e);
            }
        }
    }

    private static void zipAddDir(ZipOutputStream os, Path path) throws IOException {
        if (!Files.isDirectory(path)) return;
        Files.walkFileTree(path, new SimpleFileVisitor<>() {
            public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
                if (Files.isRegularFile(file)) {
                    var name = path.getParent().relativize(file).toString();
                    try (var is = new FileInputStream(file.toFile())) {
                        os.putNextEntry(new ZipEntry(name));
                        transfer(is, os);
                        os.closeEntry();
                    } catch (IOException e) {
                        Log.w(TAG, name, e);
                    }
                }
                return FileVisitResult.CONTINUE;
            }
        });
    }

    private static SharedMemory readDex(InputStream in, boolean obfuscate) throws IOException, ErrnoException {
        var memory = SharedMemory.create(null, in.available());
        var byteBuffer = memory.mapReadWrite();
        try (var channel = Channels.newChannel(in)) {
            while (byteBuffer.hasRemaining()) {
                if (channel.read(byteBuffer) < 0) {
                    throw new IOException("Unexpected end of dex stream");
                }
            }
        }
        SharedMemory.unmap(byteBuffer);
        if (obfuscate) {
            var newMemory = ObfuscationManager.obfuscateDex(memory);
            if (memory != newMemory) {
                memory.close();
                memory = newMemory;
            }
        }
        memory.setProtect(OsConstants.PROT_READ);
        return memory;
    }

    private static void readDexes(ZipFile apkFile, List<SharedMemory> preLoadedDexes,
                                  boolean obfuscate) {
        int secondary = 2;
        for (var dexFile = apkFile.getEntry("classes.dex"); dexFile != null;
             dexFile = apkFile.getEntry("classes" + secondary + ".dex"), secondary++) {
            try (var is = apkFile.getInputStream(dexFile)) {
                preLoadedDexes.add(readDex(is, obfuscate));
            } catch (IOException | ErrnoException e) {
                Log.w(TAG, "Can not load " + dexFile + " in " + apkFile, e);
            }
        }
    }

    private static void readName(ZipFile apkFile, String initName, List<String> names) {
        var initEntry = apkFile.getEntry(initName);
        if (initEntry == null) return;
        try (var in = apkFile.getInputStream(initEntry)) {
            var reader = new BufferedReader(new InputStreamReader(in));
            String name;
            while ((name = reader.readLine()) != null) {
                name = name.trim();
                if (name.isEmpty() || name.startsWith("#")) continue;
                names.add(name);
            }
        } catch (IOException | OutOfMemoryError e) {
            Log.e(TAG, "Can not open " + initEntry, e);
        }
    }

    @Nullable
    static PreLoadedApk loadModule(String path, boolean obfuscate) {
        if (path == null) return null;
        var file = new PreLoadedApk();
        var preLoadedDexes = new ArrayList<SharedMemory>();
        var moduleClassNames = new ArrayList<String>(1);
        var moduleLibraryNames = new ArrayList<String>(1);
        try (var apkFile = new ZipFile(toGlobalNamespace(path))) {
            readDexes(apkFile, preLoadedDexes, obfuscate);
            readName(apkFile, "META-INF/xposed/java_init.list", moduleClassNames);
            if (moduleClassNames.isEmpty()) {
                file.legacy = true;
                readName(apkFile, "assets/xposed_init", moduleClassNames);
                readName(apkFile, "assets/native_init", moduleLibraryNames);
            } else {
                file.legacy = false;
                readName(apkFile, "META-INF/xposed/native_init.list", moduleLibraryNames);
            }
        } catch (IOException e) {
            Log.e(TAG, "Can not open " + path, e);
            return null;
        }
        if (preLoadedDexes.isEmpty()) return null;
        if (moduleClassNames.isEmpty()) return null;

        if (obfuscate) {
            var signatures = ObfuscationManager.getSignatures();
            for (int i = 0; i < moduleClassNames.size(); i++) {
                var s = moduleClassNames.get(i);
                for (var entry : signatures.entrySet()) {
                    if (s.startsWith(entry.getKey())) {
                        moduleClassNames.add(i, s.replace(entry.getKey(), entry.getValue()));
                    }
                }
            }
        }

        file.preLoadedDexes = preLoadedDexes;
        file.moduleClassNames = moduleClassNames;
        file.moduleLibraryNames = moduleLibraryNames;
        return file;
    }

    static boolean tryLock() {
        var openOptions = new HashSet<OpenOption>();
        openOptions.add(StandardOpenOption.CREATE);
        openOptions.add(StandardOpenOption.WRITE);
        var p = PosixFilePermissions.fromString("rw-------");
        var permissions = PosixFilePermissions.asFileAttribute(p);

        try {
            var lockChannel = FileChannel.open(lockPath, openOptions, permissions);
            locker = new FileLocker(lockChannel);
            return locker.isValid();
        } catch (Throwable e) {
            return false;
        }
    }

    synchronized static SharedMemory getPreloadDex(boolean obfuscate) {
        if (preloadDex == null) {
            try (var is = new FileInputStream("framework/lspd.dex")) {
                preloadDex = readDex(is, obfuscate);
            } catch (Throwable e) {
                Log.e(TAG, "preload dex", e);
            }
        }
        return preloadDex;
    }

    static void ensureModuleFilePath(String path) throws RemoteException {
        if (path == null || path.indexOf(File.separatorChar) >= 0 || ".".equals(path) || "..".equals(path)) {
            throw new RemoteException("Invalid path: " + path);
        }
    }

    static Path resolveModuleDir(String packageName, String dir, int userId, int uid) throws IOException {
        var path = modulePath.resolve(String.valueOf(userId)).resolve(packageName).resolve(dir).normalize();
        // Ensure the directory and any necessary parent directories exist.
        path.toFile().mkdirs();

        if (SELinux.getFileContext(path.toString()) != "u:object_r:xposed_data:s0") {
            // SELinux label could be reset after a reboot.
            try {
                setSelinuxContextRecursive(path, "u:object_r:xposed_data:s0");
                Os.chown(path.toString(), uid, uid);
                Os.chmod(path.toString(), 0755);
            } catch (ErrnoException e) {
                throw new IOException(e);
            }
        }
        return path;
    }

     private static void setSelinuxContextRecursive(Path path, String context) throws IOException {
        try {
            SELinux.setFileContext(path.toString(), context);

            if (Files.isDirectory(path)) {
                try (Stream<Path> stream = Files.list(path)) {
                    for (Path entry : (Iterable<Path>) stream::iterator) {
                        setSelinuxContextRecursive(entry, context);
                    }
                }
            }
        } catch (Exception e) {
            throw new IOException("Failed to recursively set SELinux context for " + path, e);
        }
    }

    private static class FileLocker {
        private final FileChannel lockChannel;
        private final FileLock locker;

        FileLocker(FileChannel lockChannel) throws IOException {
            this.lockChannel = lockChannel;
            this.locker = lockChannel.tryLock();
        }

        boolean isValid() {
            return this.locker != null && this.locker.isValid();
        }

        @Override
        protected void finalize() throws Throwable {
            this.locker.release();
            this.lockChannel.close();
        }
    }
}
