package org.lsposed.lspatch.loader;

import static org.lsposed.lspatch.share.Constants.CONFIG_ASSET_PATH;
import static org.lsposed.lspatch.share.Constants.ORIGINAL_APK_ASSET_PATH;

import android.app.ActivityThread;
import android.app.LoadedApk;
import android.content.Context;
import android.content.pm.ApplicationInfo;
import android.content.res.CompatibilityInfo;
import android.os.Build;
import android.os.RemoteException;
import android.system.Os;
import android.util.Log;

import org.lsposed.lspatch.loader.util.FileUtils;
import org.lsposed.lspatch.loader.util.XLog;
import org.lsposed.lspatch.service.LocalApplicationService;
import org.lsposed.lspatch.service.RemoteApplicationService;
import org.lsposed.lspd.core.Startup;
import org.lsposed.lspd.service.ILSPApplicationService;
import org.json.JSONObject;

import java.io.BufferedReader;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.lang.reflect.Field;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.attribute.PosixFilePermissions;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Map;
import java.util.function.BiConsumer;
import java.util.stream.Collectors;
import java.util.zip.ZipFile;

import de.robv.android.xposed.XC_MethodHook;
import de.robv.android.xposed.XposedBridge;
import de.robv.android.xposed.XposedHelpers;
import hidden.HiddenApiBridge;

/**
 * Created by Windysha
 */
@SuppressWarnings("unused")
public class LSPApplication {

    private static final String TAG = "LSPatch";
    private static final int FIRST_APP_ZYGOTE_ISOLATED_UID = 90000;
    private static final int PER_USER_RANGE = 100000;

    private static ActivityThread activityThread;
    private static LoadedApk stubLoadedApk;
    private static LoadedApk appLoadedApk;
    private static Context stubContext;
    private static String packageName;
    private static String originalApkPath;
    private static String originalAppComponentFactory;
    private static boolean applicationInfoFixupsInstalled;

    private static JSONObject config;

    private static String describeClassLoader(ClassLoader classLoader) {
        if (classLoader == null) {
            return "null";
        }
        ClassLoader parent;
        try {
            parent = classLoader.getParent();
        } catch (Throwable t) {
            return classLoader + " parent=<error:" + t + ">";
        }
        return classLoader + " parent=" + parent;
    }

    private static void logLoadedApk(String label, LoadedApk loadedApk) {
        if (loadedApk == null) {
            Log.i(TAG, label + ": loadedApk=null");
            return;
        }
        try {
            var appInfo = loadedApk.getApplicationInfo();
            Log.i(TAG, label + ": loadedApk=" + loadedApk
                    + " package=" + loadedApk.getPackageName()
                    + " resDir=" + loadedApk.getResDir()
                    + " classLoader=" + describeClassLoader(loadedApk.getClassLoader())
                    + " appInfo.sourceDir=" + (appInfo == null ? null : appInfo.sourceDir)
                    + " appInfo.publicSourceDir=" + (appInfo == null ? null : appInfo.publicSourceDir)
                    + " appInfo.dataDir=" + (appInfo == null ? null : appInfo.dataDir)
                    + " appInfo.appComponentFactory=" + (appInfo == null ? null : appInfo.appComponentFactory));
        } catch (Throwable t) {
            Log.e(TAG, label + ": failed to describe LoadedApk", t);
        }
    }

    private static void logContext(String label, Context context) {
        if (context == null) {
            Log.i(TAG, label + ": context=null");
            return;
        }
        try {
            var appInfo = context.getApplicationInfo();
            var resources = context.getResources();
            Log.i(TAG, label + ": context=" + context
                    + " package=" + context.getPackageName()
                    + " packageResourcePath=" + context.getPackageResourcePath()
                    + " cacheDir=" + context.getCacheDir()
                    + " filesDir=" + context.getFilesDir()
                    + " classLoader=" + describeClassLoader(context.getClassLoader())
                    + " resources=" + resources
                    + " resourcesClass=" + (resources == null ? null : resources.getClass())
                    + " appInfo.sourceDir=" + (appInfo == null ? null : appInfo.sourceDir)
                    + " appInfo.publicSourceDir=" + (appInfo == null ? null : appInfo.publicSourceDir)
                    + " appInfo.dataDir=" + (appInfo == null ? null : appInfo.dataDir));
        } catch (Throwable t) {
            Log.e(TAG, label + ": failed to describe Context", t);
        }
    }

    private static void logFile(String label, File file) {
        if (file == null) {
            Log.i(TAG, label + ": file=null");
            return;
        }
        Log.i(TAG, label + ": path=" + file.getAbsolutePath()
                + " exists=" + file.exists()
                + " length=" + (file.exists() ? file.length() : -1)
                + " canRead=" + file.canRead()
                + " canWrite=" + file.canWrite());
    }

    private static Object getFieldForLog(Object instance, String fieldName) {
        try {
            var field = instance.getClass().getField(fieldName);
            return field.get(instance);
        } catch (Throwable t) {
            return "<unavailable:" + t.getClass().getSimpleName() + ">";
        }
    }

    private static void fixOriginalApplicationInfo(ApplicationInfo appInfo, String reason) {
        if (appInfo == null || packageName == null || originalApkPath == null || !packageName.equals(appInfo.packageName)) {
            return;
        }
        var changed = false;
        if (!originalApkPath.equals(appInfo.sourceDir)) {
            Log.i(TAG, reason + ": rewrite sourceDir " + appInfo.sourceDir + " -> " + originalApkPath);
            appInfo.sourceDir = originalApkPath;
            changed = true;
        }
        if (!originalApkPath.equals(appInfo.publicSourceDir)) {
            Log.i(TAG, reason + ": rewrite publicSourceDir " + appInfo.publicSourceDir + " -> " + originalApkPath);
            appInfo.publicSourceDir = originalApkPath;
            changed = true;
        }
        if (appInfo.appComponentFactory != null
                && appInfo.appComponentFactory.startsWith("org.lsposed.lspatch.")) {
            Log.i(TAG, reason + ": rewrite appComponentFactory "
                    + appInfo.appComponentFactory + " -> " + originalAppComponentFactory);
            appInfo.appComponentFactory = originalAppComponentFactory;
            changed = true;
        }
        if (changed) {
            Log.i(TAG, reason + ": fixed ApplicationInfo package=" + appInfo.packageName
                    + " sourceDir=" + appInfo.sourceDir
                    + " publicSourceDir=" + appInfo.publicSourceDir
                    + " appComponentFactory=" + appInfo.appComponentFactory);
        }
    }

    private static void installApplicationInfoFixups() {
        if (applicationInfoFixupsInstalled) {
            return;
        }
        applicationInfoFixupsInstalled = true;
        Log.i(TAG, "installApplicationInfoFixups: package=" + packageName
                + " originalApkPath=" + originalApkPath
                + " originalAppComponentFactory=" + originalAppComponentFactory);
        try {
            XposedBridge.hookAllMethods(LoadedApk.class, "updateApplicationInfo", new XC_MethodHook() {
                @Override
                protected void beforeHookedMethod(MethodHookParam param) {
                    if (param.args.length > 0 && param.args[0] instanceof ApplicationInfo) {
                        fixOriginalApplicationInfo((ApplicationInfo) param.args[0], "LoadedApk.updateApplicationInfo");
                    }
                }
            });
            Log.i(TAG, "installApplicationInfoFixups: hooked LoadedApk.updateApplicationInfo");
        } catch (Throwable t) {
            Log.e(TAG, "installApplicationInfoFixups: failed to hook LoadedApk.updateApplicationInfo", t);
        }
        try {
            XposedBridge.hookAllMethods(ActivityThread.class, "getPackageInfoNoCheck", new XC_MethodHook() {
                @Override
                protected void beforeHookedMethod(MethodHookParam param) {
                    if (param.args.length > 0 && param.args[0] instanceof ApplicationInfo) {
                        fixOriginalApplicationInfo((ApplicationInfo) param.args[0], "ActivityThread.getPackageInfoNoCheck");
                    }
                }
            });
            Log.i(TAG, "installApplicationInfoFixups: hooked ActivityThread.getPackageInfoNoCheck");
        } catch (Throwable t) {
            Log.e(TAG, "installApplicationInfoFixups: failed to hook ActivityThread.getPackageInfoNoCheck", t);
        }
    }

    public static boolean isIsolated() {
        return (android.os.Process.myUid() % PER_USER_RANGE) >= FIRST_APP_ZYGOTE_ISOLATED_UID;
    }

    public static void onLoad() throws RemoteException, IOException {
        Log.i(TAG, "onLoad: entry process=" + ActivityThread.currentProcessName()
                + " uid=" + android.os.Process.myUid()
                + " pid=" + android.os.Process.myPid()
                + " sdk=" + Build.VERSION.SDK_INT
                + " isolated=" + isIsolated());
        if (isIsolated()) {
            XLog.d(TAG, "Skip isolated process");
            return;
        }
        activityThread = ActivityThread.currentActivityThread();
        Log.i(TAG, "onLoad: currentActivityThread=" + activityThread);
        var context = createLoadedApkWithContext();
        if (context == null) {
            XLog.e(TAG, "Error when creating context");
            return;
        }
        installApplicationInfoFixups();
        logContext("onLoad: app context ready", context);
        logContext("onLoad: stub context ready", stubContext);

        Log.i(TAG, "onLoad: Initialize service client useManager=" + config.optBoolean("useManager"));
        ILSPApplicationService service;
        if (config.optBoolean("useManager")) {
            service = new RemoteApplicationService(context);
        } else {
            service = new LocalApplicationService(stubContext);
        }
        Log.i(TAG, "onLoad: service client ready class=" + service.getClass());

        Log.i(TAG, "onLoad: disableProfile start");
        disableProfile(context);
        Log.i(TAG, "onLoad: disableProfile done");
        Log.i(TAG, "onLoad: Startup.initXposed start appDir=" + context.getApplicationInfo().dataDir);
        Startup.initXposed(false, ActivityThread.currentProcessName(), context.getApplicationInfo().dataDir, service);
        Log.i(TAG, "onLoad: Startup.initXposed done");
        Log.i(TAG, "onLoad: Startup.bootstrapXposed start");
        Startup.bootstrapXposed();
        Log.i(TAG, "onLoad: Startup.bootstrapXposed done");
        // WARN: Since it uses `XResource`, the following class should not be initialized
        // before forkPostCommon is invoke. Otherwise, you will get failure of XResources
        Log.i(TAG, "Load modules");
        LSPLoader.initModules(appLoadedApk);
        Log.i(TAG, "Modules initialized");

        Log.i(TAG, "onLoad: switchAllClassLoader start");
        switchAllClassLoader();
        Log.i(TAG, "onLoad: switchAllClassLoader done");
        logLoadedApk("onLoad: appLoadedApk after classloader switch", appLoadedApk);
        logLoadedApk("onLoad: stubLoadedApk after classloader switch", stubLoadedApk);
        Log.i(TAG, "onLoad: SigBypass start level=" + config.optInt("sigBypassLevel"));
        SigBypass.doSigBypass(stubContext, config.optInt("sigBypassLevel"));
        Log.i(TAG, "onLoad: SigBypass done");

        Log.i(TAG, "LSPatch bootstrap completed");
    }

    private static Context createLoadedApkWithContext() {
        try {
            Log.i(TAG, "createLoadedApk: start");
            var mBoundApplication = XposedHelpers.getObjectField(activityThread, "mBoundApplication");
            Log.i(TAG, "createLoadedApk: mBoundApplication=" + mBoundApplication);

            stubLoadedApk = (LoadedApk) XposedHelpers.getObjectField(mBoundApplication, "info");
            var appInfo = (ApplicationInfo) XposedHelpers.getObjectField(mBoundApplication, "appInfo");
            var compatInfo = (CompatibilityInfo) XposedHelpers.getObjectField(mBoundApplication, "compatInfo");
            var baseClassLoader = stubLoadedApk.getClassLoader();
            packageName = appInfo.packageName;
            logLoadedApk("createLoadedApk: stubLoadedApk before swap", stubLoadedApk);
            Log.i(TAG, "createLoadedApk: original appInfo package=" + appInfo.packageName
                    + " sourceDir=" + appInfo.sourceDir
                    + " publicSourceDir=" + appInfo.publicSourceDir
                    + " dataDir=" + appInfo.dataDir
                    + " appComponentFactory=" + appInfo.appComponentFactory
                    + " classLoaderName=" + getFieldForLog(appInfo, "classLoaderName")
                    + " splitSourceDirs=" + java.util.Arrays.toString(appInfo.splitSourceDirs));
            Log.i(TAG, "createLoadedApk: baseClassLoader=" + describeClassLoader(baseClassLoader));

            try (var is = baseClassLoader.getResourceAsStream(CONFIG_ASSET_PATH)) {
                Log.i(TAG, "createLoadedApk: config stream exists=" + (is != null) + " path=" + CONFIG_ASSET_PATH);
                BufferedReader streamReader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8));
                config = new JSONObject(streamReader.lines().collect(Collectors.joining()));
            } catch (Throwable e) {
                Log.e(TAG, "Failed to parse config file", e);
                return null;
            }
            Log.i(TAG, "Use manager: " + config.optBoolean("useManager"));
            Log.i(TAG, "Signature bypass level: " + config.optInt("sigBypassLevel"));
            Log.i(TAG, "createLoadedApk: config=" + config);

            Path originPath = Paths.get(appInfo.dataDir, "files/lspatch/origin/");
            Path cacheApkPath;
            try (ZipFile sourceFile = new ZipFile(appInfo.sourceDir)) {
                var originalEntry = sourceFile.getEntry(ORIGINAL_APK_ASSET_PATH);
                Log.i(TAG, "createLoadedApk: original apk asset entry=" + originalEntry
                        + " size=" + (originalEntry == null ? -1 : originalEntry.getSize())
                        + " crc=" + (originalEntry == null ? -1 : originalEntry.getCrc()));
                cacheApkPath = originPath.resolve(originalEntry.getCrc() + ".apk");
            }
            Log.i(TAG, "createLoadedApk: originPath=" + originPath + " cacheApkPath=" + cacheApkPath);

            appInfo.sourceDir = cacheApkPath.toString();
            appInfo.publicSourceDir = cacheApkPath.toString();
            originalApkPath = cacheApkPath.toString();
            if (config.has("appComponentFactory")) {
                originalAppComponentFactory = config.optString("appComponentFactory");
                appInfo.appComponentFactory = originalAppComponentFactory;
            } else {
                originalAppComponentFactory = null;
            }
            Log.i(TAG, "createLoadedApk: swapped appInfo sourceDir=" + appInfo.sourceDir
                    + " publicSourceDir=" + appInfo.publicSourceDir
                    + " appComponentFactory=" + appInfo.appComponentFactory);

            if (!Files.exists(cacheApkPath)) {
                Log.i(TAG, "Extract original apk");
                FileUtils.deleteFolderIfExists(originPath);
                Files.createDirectories(originPath);
                try (InputStream is = baseClassLoader.getResourceAsStream(ORIGINAL_APK_ASSET_PATH)) {
                    Log.i(TAG, "createLoadedApk: original apk asset stream exists=" + (is != null));
                    Files.copy(is, cacheApkPath);
                }
                Log.i(TAG, "createLoadedApk: original apk copied");
            } else {
                Log.i(TAG, "createLoadedApk: original apk cache already exists");
            }
            var cacheApkFile = cacheApkPath.toFile();
            cacheApkFile.setReadable(true, true);
            cacheApkFile.setWritable(false);
            logFile("createLoadedApk: cache apk after permission setup", cacheApkFile);

            var mPackages = (Map<?, ?>) XposedHelpers.getObjectField(activityThread, "mPackages");
            Log.i(TAG, "createLoadedApk: mPackages size before remove=" + mPackages.size()
                    + " containsPackage=" + mPackages.containsKey(appInfo.packageName));
            mPackages.remove(appInfo.packageName);
            Log.i(TAG, "createLoadedApk: mPackages size after remove=" + mPackages.size()
                    + " containsPackage=" + mPackages.containsKey(appInfo.packageName));
            Log.i(TAG, "createLoadedApk: getPackageInfoNoCheck start compatInfo=" + compatInfo);
            appLoadedApk = activityThread.getPackageInfoNoCheck(appInfo, compatInfo);
            logLoadedApk("createLoadedApk: appLoadedApk after getPackageInfoNoCheck", appLoadedApk);
            XposedHelpers.setObjectField(mBoundApplication, "info", appLoadedApk);
            Log.i(TAG, "createLoadedApk: mBoundApplication.info swapped");

            var activityClientRecordClass = XposedHelpers.findClass("android.app.ActivityThread$ActivityClientRecord", ActivityThread.class.getClassLoader());
            var fixActivityClientRecord = (BiConsumer<Object, Object>) (k, v) -> {
                if (activityClientRecordClass.isInstance(v)) {
                    var pkgInfo = XposedHelpers.getObjectField(v, "packageInfo");
                    if (pkgInfo == stubLoadedApk) {
                        Log.d(TAG, "fix loadedapk from ActivityClientRecord");
                        XposedHelpers.setObjectField(v, "packageInfo", appLoadedApk);
                    }
                }
            };
            var mActivities = (Map<?, ?>) XposedHelpers.getObjectField(activityThread, "mActivities");
            Log.i(TAG, "createLoadedApk: mActivities size=" + mActivities.size());
            mActivities.forEach(fixActivityClientRecord);
            try {
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
                    var launchingActivitiesField = XposedHelpers.findFieldIfExists(ActivityThread.class, "mLaunchingActivities");
                    if (launchingActivitiesField != null) {
                        var mLaunchingActivities = (Map<?, ?>) launchingActivitiesField.get(activityThread);
                        Log.i(TAG, "createLoadedApk: mLaunchingActivities size=" + mLaunchingActivities.size());
                        mLaunchingActivities.forEach(fixActivityClientRecord);
                    } else {
                        Log.i(TAG, "createLoadedApk: mLaunchingActivities field not present on this Android build");
                    }
                }
            } catch (Throwable t) {
                Log.w(TAG, "createLoadedApk: unable to inspect mLaunchingActivities", t);
            }
            Log.i(TAG, "hooked app initialized: " + appLoadedApk);

            var contextImplClass = Class.forName("android.app.ContextImpl");
            Log.i(TAG, "createLoadedApk: create stub context start");
            stubContext = (Context) XposedHelpers.callStaticMethod(contextImplClass, "createAppContext", activityThread, stubLoadedApk);
            logContext("createLoadedApk: stub context created", stubContext);
            Log.i(TAG, "createLoadedApk: create app context start");
            var context = (Context) XposedHelpers.callStaticMethod(contextImplClass, "createAppContext", activityThread, appLoadedApk);
            logContext("createLoadedApk: app context created", context);
            if (context.getResources() == null) {
                Log.e(TAG, "App context has null resources after LoadedApk switch");
                return null;
            }
            if (config.has("appComponentFactory")) {
                try {
                    Log.i(TAG, "createLoadedApk: verifying original AppComponentFactory " + appInfo.appComponentFactory);
                    context.getClassLoader().loadClass(appInfo.appComponentFactory);
                    Log.i(TAG, "createLoadedApk: original AppComponentFactory found");
                } catch (ClassNotFoundException e) { // This will happen on some strange shells like 360
                    Log.w(TAG, "Original AppComponentFactory not found: " + appInfo.appComponentFactory);
                    appInfo.appComponentFactory = null;
                }
            }
            Log.i(TAG, "createLoadedApk: done");
            return context;
        } catch (Throwable e) {
            Log.e(TAG, "createLoadedApk", e);
            return null;
        }
    }

    public static void disableProfile(Context context) {
        final ArrayList<String> codePaths = new ArrayList<>();
        var appInfo = context.getApplicationInfo();
        var pkgName = context.getPackageName();
        if (appInfo == null) {
            Log.i(TAG, "disableProfile: appInfo=null");
            return;
        }
        if ((appInfo.flags & ApplicationInfo.FLAG_HAS_CODE) != 0) {
            codePaths.add(appInfo.sourceDir);
        }
        if (appInfo.splitSourceDirs != null) {
            Collections.addAll(codePaths, appInfo.splitSourceDirs);
        }
        Log.i(TAG, "disableProfile: package=" + pkgName + " codePaths=" + codePaths);

        if (codePaths.isEmpty()) {
            // If there are no code paths there's no need to setup a profile file and register with
            // the runtime,
            Log.i(TAG, "disableProfile: no code paths");
            return;
        }

        var profileDir = HiddenApiBridge.Environment_getDataProfilesDePackageDirectory(appInfo.uid / PER_USER_RANGE, pkgName);
        Log.i(TAG, "disableProfile: profileDir=" + profileDir);

        var attrs = PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("r--------"));

        for (int i = codePaths.size() - 1; i >= 0; i--) {
            String splitName = i == 0 ? null : appInfo.splitNames[i - 1];
            File curProfileFile = new File(profileDir, splitName == null ? "primary.prof" : splitName + ".split.prof").getAbsoluteFile();
            Log.d(TAG, "Processing " + curProfileFile.getAbsolutePath());
            try {
                if (!curProfileFile.exists()) {
                    Files.createFile(curProfileFile.toPath(), attrs);
                    Log.d(TAG, "Created read-only profile placeholder " + curProfileFile.getAbsolutePath());
                    continue;
                }
                if (!curProfileFile.canWrite() && Files.size(curProfileFile.toPath()) == 0) {
                    Log.d(TAG, "Skip profile " + curProfileFile.getAbsolutePath());
                    continue;
                }
                if (curProfileFile.exists() && !curProfileFile.delete()) {
                    try (var writer = new FileOutputStream(curProfileFile)) {
                        Log.d(TAG, "Failed to delete, try to clear content " + curProfileFile.getAbsolutePath());
                    } catch (Throwable e) {
                        Log.e(TAG, "Failed to delete and clear profile file " + curProfileFile.getAbsolutePath(), e);
                    }
                    Os.chmod(curProfileFile.getAbsolutePath(), 00400);
                }
            } catch (Throwable e) {
                Log.e(TAG, "Failed to disable profile file " + curProfileFile.getAbsolutePath(), e);
            }
        }
    }

    private static void switchAllClassLoader() {
        var fields = LoadedApk.class.getDeclaredFields();
        for (Field field : fields) {
            if (field.getType() == ClassLoader.class) {
                var obj = XposedHelpers.getObjectField(appLoadedApk, field.getName());
                Log.i(TAG, "switchAllClassLoader: copy field " + field.getName()
                        + " value=" + describeClassLoader((ClassLoader) obj));
                XposedHelpers.setObjectField(stubLoadedApk, field.getName(), obj);
            }
        }
    }
}
