package com.secureline.installer;

import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.PackageInfo;
import android.content.pm.PackageInstaller;
import android.content.pm.PackageManager;
import android.content.pm.Signature;
import android.os.Build;
import android.util.Log;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.security.MessageDigest;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;

final class AppInstaller {
    private static final String TAG = "LemenzoInstaller";
    private static final long INSTALL_TIMEOUT_MS = 120_000L;

    private static final ConcurrentHashMap<String, PendingResult>
            PENDING = new ConcurrentHashMap<>();

    static final class Inspection {
        boolean assetExists;
        boolean packageMatches;
        boolean apkHashMatches = true;
        boolean signerMatches = true;
        String actualPackage = "";
        String versionName = "";
        long versionCode = -1L;
        String apkSha256 = "";
        String signerSha256 = "";
        String error = "";

        boolean trustedForInstall() {
            return assetExists
                    && packageMatches
                    && apkHashMatches
                    && signerMatches
                    && versionCode >= 0;
        }
    }

    static final class InstallResult {
        final boolean success;
        final int status;
        final String message;

        InstallResult(boolean success, int status, String message) {
            this.success = success;
            this.status = status;
            this.message = message == null ? "" : message;
        }
    }

    private static final class PendingResult {
        final CountDownLatch latch = new CountDownLatch(1);
        volatile InstallResult result;
    }

    static Inspection inspect(Context context, AppEntry app) {
        Inspection out = new Inspection();

        File apkFile = null;
        try {
            apkFile = copyAssetToCache(context, app.apk);
            out.assetExists = apkFile.exists();

            PackageManager pm = context.getPackageManager();
            PackageInfo info = pm.getPackageArchiveInfo(
                    apkFile.getAbsolutePath(),
                    PackageManager.GET_SIGNING_CERTIFICATES
            );

            if (info == null) {
                out.error = "Unable to parse APK";
                return out;
            }

            out.actualPackage =
                    info.packageName == null ? "" : info.packageName;
            out.packageMatches =
                    app.packageName.equals(out.actualPackage);

            if (!out.packageMatches) {
                out.error = "Package identity mismatch";
                return out;
            }

            out.versionCode = getLongVersionCode(info);
            out.versionName =
                    info.versionName == null ? "" : info.versionName;

            if (app.versionCode >= 0
                    && out.versionCode != app.versionCode) {
                out.error = "APK version metadata mismatch";
                return out;
            }

            out.apkSha256 = sha256(apkFile);
            if (!app.apkSha256.isEmpty()) {
                out.apkHashMatches =
                        app.apkSha256.equalsIgnoreCase(out.apkSha256);
                if (!out.apkHashMatches) {
                    out.error = "APK SHA-256 mismatch";
                    return out;
                }
            }

            out.signerSha256 = signerSha256(info);
            if (!app.signerSha256.isEmpty()) {
                out.signerMatches =
                        signerMatches(info, app.signerSha256);
                if (!out.signerMatches) {
                    out.error = "Signing identity mismatch";
                    return out;
                }
            }

            return out;

        } catch (Exception e) {
            out.assetExists = false;
            out.error = e.getMessage() == null
                    ? "Offline APK unavailable"
                    : e.getMessage();
            return out;
        }
    }

    static InstallResult installAndWait(
            Context context,
            AppEntry app,
            Inspection inspection
    ) {
        if (inspection == null || !inspection.trustedForInstall()) {
            return new InstallResult(
                    false,
                    PackageInstaller.STATUS_FAILURE_INVALID,
                    "APK verification failed"
            );
        }

        String token = UUID.randomUUID().toString();
        PendingResult pending = new PendingResult();
        PENDING.put(token, pending);

        PackageInstaller.Session session = null;
        PackageInstaller installer = null;
        int sessionId = -1;
        boolean committed = false;
        File apkFile = null;

        try {
            apkFile = copyAssetToCache(context, app.apk);

            installer =
                    context.getPackageManager().getPackageInstaller();

            PackageInstaller.SessionParams params =
                    new PackageInstaller.SessionParams(
                            PackageInstaller.SessionParams.MODE_FULL_INSTALL
                    );

            params.setAppPackageName(app.packageName);
            params.setSize(apkFile.length());
            params.setPackageSource(
                    PackageInstaller.PACKAGE_SOURCE_OTHER
            );
            params.setRequireUserAction(
                    PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED
            );

            sessionId = installer.createSession(params);
            session = installer.openSession(sessionId);

            try (InputStream in = new FileInputStream(apkFile);
                 OutputStream out = session.openWrite(
                         "base.apk",
                         0,
                         apkFile.length()
                 )) {

                byte[] buffer = new byte[128 * 1024];
                int count;

                while ((count = in.read(buffer)) != -1) {
                    out.write(buffer, 0, count);
                }

                session.fsync(out);
            }

            Intent callback =
                    new Intent(context, InstallReceiver.class);
            callback.setAction(
                    "com.secureline.installer.INSTALL_RESULT"
            );
            callback.putExtra("token", token);
            callback.putExtra("package", app.packageName);
            callback.putExtra("name", app.name);

            PendingIntent pendingIntent =
                    PendingIntent.getBroadcast(
                            context,
                            sessionId,
                            callback,
                            PendingIntent.FLAG_UPDATE_CURRENT
                                    | PendingIntent.FLAG_MUTABLE
                    );

            session.commit(pendingIntent.getIntentSender());
            committed = true;
            session.close();
            session = null;

            boolean completed = pending.latch.await(
                    INSTALL_TIMEOUT_MS,
                    TimeUnit.MILLISECONDS
            );

            if (!completed || pending.result == null) {
                return new InstallResult(
                        false,
                        PackageInstaller.STATUS_FAILURE,
                        "Installation timed out"
                );
            }

            return pending.result;

        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return new InstallResult(
                    false,
                    PackageInstaller.STATUS_FAILURE_ABORTED,
                    "Installation interrupted"
            );

        } catch (Exception e) {
            Log.e(TAG, "Installation failed for " + app.packageName, e);
            return new InstallResult(
                    false,
                    PackageInstaller.STATUS_FAILURE,
                    e.getMessage()
            );

        } finally {
            if (session != null) {
                try {
                    session.close();
                } catch (Exception ignored) {}
            }

            if (!committed && installer != null && sessionId > 0) {
                try {
                    installer.abandonSession(sessionId);
                } catch (Exception ignored) {}
            }

            PENDING.remove(token);
        }
    }

    static void complete(
            String token,
            int status,
            String message
    ) {
        if (token == null) {
            return;
        }

        PendingResult pending = PENDING.get(token);
        if (pending == null) {
            return;
        }

        pending.result = new InstallResult(
                status == PackageInstaller.STATUS_SUCCESS,
                status,
                message
        );
        pending.latch.countDown();
    }

    static long installedVersionCode(
            Context context,
            String packageName
    ) {
        try {
            PackageInfo info =
                    context.getPackageManager()
                            .getPackageInfo(packageName, 0);
            return getLongVersionCode(info);
        } catch (Exception e) {
            return -1L;
        }
    }

    static String installedVersionName(
            Context context,
            String packageName
    ) {
        try {
            PackageInfo info =
                    context.getPackageManager()
                            .getPackageInfo(packageName, 0);
            return info.versionName == null
                    ? ""
                    : info.versionName;
        } catch (Exception e) {
            return "";
        }
    }

    private static File copyAssetToCache(
            Context context,
            String assetName
    ) throws Exception {
        File dir = new File(context.getCacheDir(), "offline_apks");
        ensureOfflineCacheIsCurrent(context, dir);

        if (!dir.exists() && !dir.mkdirs()) {
            throw new IllegalStateException(
                    "Unable to prepare installer cache"
            );
        }

        String safeName = new File(assetName).getName();
        if (!safeName.equals(assetName) || safeName.contains("..")) {
            throw new SecurityException("Invalid asset APK name");
        }

        File outFile = new File(dir, safeName);

        /*
         * Cache is invalidated whenever this system app is updated, so an
         * already-extracted APK from the same build can be reused for inspect
         * + install without copying hundreds of MB twice.
         */
        if (outFile.exists() && outFile.length() > 0L) {
            return outFile;
        }

        File temp = new File(dir, safeName + ".part");

        try (InputStream in =
                     context.getAssets().open(assetName);
             FileOutputStream out =
                     new FileOutputStream(temp, false)) {

            byte[] buffer = new byte[128 * 1024];
            int count;

            while ((count = in.read(buffer)) != -1) {
                out.write(buffer, 0, count);
            }

            out.flush();
            out.getFD().sync();
        }

        if (!temp.renameTo(outFile)) {
            try (InputStream in = new FileInputStream(temp);
                 FileOutputStream out =
                         new FileOutputStream(outFile, false)) {
                byte[] buffer = new byte[128 * 1024];
                int count;
                while ((count = in.read(buffer)) != -1) {
                    out.write(buffer, 0, count);
                }
                out.flush();
                out.getFD().sync();
            }
            if (!temp.delete()) {
                temp.deleteOnExit();
            }
        }

        return outFile;
    }

    private static void ensureOfflineCacheIsCurrent(
            Context context,
            File cacheDir
    ) throws Exception {
        long currentUpdateTime =
                context.getPackageManager()
                        .getPackageInfo(
                                context.getPackageName(),
                                0
                        ).lastUpdateTime;

        SharedPreferences prefs =
                context.getSharedPreferences(
                        "installer_cache_state",
                        Context.MODE_PRIVATE
                );

        long cachedUpdateTime =
                prefs.getLong("app_last_update_time", -1L);

        if (cachedUpdateTime == currentUpdateTime) {
            return;
        }

        deleteContents(cacheDir);

        prefs.edit()
                .putLong("app_last_update_time", currentUpdateTime)
                .apply();
    }

    private static void deleteContents(File directory) {
        if (directory == null || !directory.exists()) {
            return;
        }

        File[] files = directory.listFiles();
        if (files == null) {
            return;
        }

        for (File file : files) {
            if (file.isDirectory()) {
                deleteContents(file);
            }
            if (!file.delete()) {
                file.deleteOnExit();
            }
        }
    }

    private static long getLongVersionCode(PackageInfo info) {
        if (Build.VERSION.SDK_INT >= 28) {
            return info.getLongVersionCode();
        }
        return info.versionCode;
    }

    private static String sha256(File file) throws Exception {
        MessageDigest digest = MessageDigest.getInstance("SHA-256");

        try (InputStream in = new FileInputStream(file)) {
            byte[] buffer = new byte[128 * 1024];
            int count;
            while ((count = in.read(buffer)) != -1) {
                digest.update(buffer, 0, count);
            }
        }

        return hex(digest.digest());
    }

    private static String signerSha256(PackageInfo info)
            throws Exception {
        if (info.signingInfo == null) {
            return "";
        }

        Signature[] signatures =
                info.signingInfo.hasMultipleSigners()
                        ? info.signingInfo.getApkContentsSigners()
                        : info.signingInfo.getSigningCertificateHistory();

        if (signatures == null || signatures.length == 0) {
            return "";
        }

        MessageDigest digest =
                MessageDigest.getInstance("SHA-256");
        return hex(digest.digest(signatures[0].toByteArray()));
    }

    private static boolean signerMatches(
            PackageInfo info,
            String expectedFingerprint
    ) throws Exception {
        String expected = normalizeDigest(expectedFingerprint);
        if (expected.isEmpty() || info.signingInfo == null) {
            return false;
        }

        Signature[] signatures =
                info.signingInfo.hasMultipleSigners()
                        ? info.signingInfo.getApkContentsSigners()
                        : info.signingInfo.getSigningCertificateHistory();

        if (signatures == null || signatures.length == 0) {
            return false;
        }

        MessageDigest digest =
                MessageDigest.getInstance("SHA-256");

        for (Signature signature : signatures) {
            String fingerprint = hex(
                    digest.digest(signature.toByteArray())
            );
            digest.reset();

            if (expected.equalsIgnoreCase(fingerprint)) {
                return true;
            }
        }

        return false;
    }

    private static String hex(byte[] bytes) {
        StringBuilder out =
                new StringBuilder(bytes.length * 2);
        for (byte value : bytes) {
            out.append(String.format("%02x", value & 0xff));
        }
        return out.toString();
    }
    private static String normalizeDigest(String value) {
        return value == null ? ""
                : value.replace(":", "")
                       .replace(" ", "")
                       .trim()
                       .toLowerCase(java.util.Locale.ROOT);
    }

}
