package com.lody.virtual.server.pm.installer;

import android.annotation.TargetApi;
import android.content.Context;
import android.content.Intent;
import android.content.IntentSender;
import android.content.pm.IPackageInstallObserver2;
import android.content.pm.IPackageInstallerSession;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.os.Message;
import android.os.ParcelFileDescriptor;
import android.os.RemoteException;
import android.system.ErrnoException;
import android.system.Os;
import android.system.OsConstants;
import android.text.TextUtils;

import com.lody.virtual.helper.utils.FileUtils;
import com.lody.virtual.helper.utils.VLog;

import java.io.File;
import java.io.FileDescriptor;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;

import static android.system.OsConstants.O_CREAT;
import static android.system.OsConstants.O_RDONLY;
import static android.system.OsConstants.O_WRONLY;

/**
 * @author Lody
 */
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
public class PackageInstallerSession extends IPackageInstallerSession.Stub {

    public static final int INSTALL_FAILED_INTERNAL_ERROR = -110;
    public static final int INSTALL_FAILED_ABORTED = -115;
    public static final int INSTALL_SUCCEEDED = 1;
    public static final int INSTALL_FAILED_INVALID_APK = -2;

    private static final String TAG = "PackageInstaller";
    private static final String REMOVE_SPLIT_MARKER_EXTENSION = ".removed";

    private static final int MSG_COMMIT = 0;


    private final VPackageInstallerService.InternalCallback mCallback;
    private final Context mContext;
    private final Handler mHandler;

    final int sessionId;
    final int userId;
    final int installerUid;

    final SessionParams params;
    final String installerPackageName;
    private boolean mPermissionsAccepted;

    /**
     * Staging location where client data is written.
     */
    final File stageDir;

    private final AtomicInteger mActiveCount = new AtomicInteger();

    private final Object mLock = new Object();

    private float mClientProgress = 0;
    private float mInternalProgress = 0;
    private float mProgress = 0;
    private float mReportedProgress = -1;
    private boolean mPrepared = false;
    private boolean mSealed = false;
    private boolean mDestroyed = false;
    private int mFinalStatus;
    private String mFinalMessage;

    private IPackageInstallObserver2 mRemoteObserver;

    private ArrayList<FileBridge> mBridges = new ArrayList<>();

    private File mResolvedStageDir;

    /**
     * Fields derived from commit parsing
     */
    private String mPackageName;

    private File mResolvedBaseFile;
    private final List<File> mResolvedStagedFiles = new ArrayList<>();


    private final Handler.Callback mHandlerCallback = new Handler.Callback() {
        @Override
        public boolean handleMessage(Message msg) {
            synchronized (mLock) {
                if (msg.obj != null) {
                    mRemoteObserver = (IPackageInstallObserver2) msg.obj;
                }
                try {
                    commitLocked();
                } catch (PackageManagerException e) {
                    final String completeMsg = getCompleteMessage(e);
                    VLog.e(TAG, "Commit of session " + sessionId + " failed: " + completeMsg);
                    destroyInternal();
                    dispatchSessionFinished(e.error, completeMsg, null);
                }

                return true;
            }
        }
    };

    public PackageInstallerSession(VPackageInstallerService.InternalCallback callback, Context context, Looper looper, String installerPackageName, int sessionId, int userId, int installerUid, SessionParams params, File stageDir) {
        this.mCallback = callback;
        this.mContext = context;
        this.mHandler = new Handler(looper, mHandlerCallback);
        this.installerPackageName = installerPackageName;
        this.sessionId = sessionId;
        this.userId = userId;
        this.installerUid = installerUid;
        this.mPackageName = params.appPackageName;
        this.params = params;
        this.stageDir = stageDir;
    }

    public SessionInfo generateInfo() {
        final SessionInfo info = new SessionInfo();
        synchronized (mLock) {
            info.sessionId = sessionId;
            info.installerPackageName = installerPackageName;
            info.resolvedBaseCodePath = (mResolvedBaseFile != null) ?
                    mResolvedBaseFile.getAbsolutePath() : null;
            info.progress = mProgress;
            info.sealed = mSealed;
            info.active = mActiveCount.get() > 0;

            info.mode = params.mode;
            info.sizeBytes = params.sizeBytes;
            info.appPackageName = params.appPackageName;
            info.appIcon = params.appIcon;
            info.appLabel = params.appLabel;
        }
        return info;
    }

    private void commitLocked() throws PackageManagerException {
        if (mDestroyed) {
            throw new PackageManagerException(INSTALL_FAILED_INTERNAL_ERROR, "Session destroyed");
        }
        if (!mSealed) {
            throw new PackageManagerException(INSTALL_FAILED_INTERNAL_ERROR, "Session not sealed");
        }
        try {
            resolveStageDir();
        } catch (IOException e) {
            e.printStackTrace();
        }
        validateInstallLocked();
        mInternalProgress = 0.5f;
        computeProgressLocked(true);
        // We've reached point of no return; call into PMS to install the stage.
        // Regardless of success or failure we always destroy session.
        final IPackageInstallObserver2 localObserver = new IPackageInstallObserver2.Stub() {
            @Override
            public void onUserActionRequired(Intent intent) {
                throw new IllegalStateException();
            }

            @Override
            public void onPackageInstalled(String basePackageName, int returnCode, String msg,
                                           Bundle extras) {
                destroyInternal();
                dispatchSessionFinished(returnCode, msg, extras);
            }
        };
    }

    private void validateInstallLocked() throws PackageManagerException {
        mResolvedBaseFile = null;
        mResolvedStagedFiles.clear();
        File[] addedFiles = this.mResolvedStageDir.listFiles();
        if (addedFiles == null || addedFiles.length == 0) {
            throw new PackageManagerException(INSTALL_FAILED_INVALID_APK, "No packages staged");
        }
        for (File addedFile : addedFiles) {
            if (!addedFile.isDirectory()) {
                final String targetName = "base.apk";
                final File targetFile = new File(mResolvedStageDir, targetName);
                if (!addedFile.equals(targetFile)) {
                    addedFile.renameTo(targetFile);
                }
                mResolvedBaseFile = targetFile;
                mResolvedStagedFiles.add(targetFile);
            }
        }
        if (mResolvedBaseFile == null) {
            throw new PackageManagerException(INSTALL_FAILED_INVALID_APK,
                    "Full install must include a base package");
        }
    }

    @Override
    public void setClientProgress(float progress) throws RemoteException {
        synchronized (mLock) {
            // Always publish first staging movement
            final boolean forcePublish = (mClientProgress == 0);
            mClientProgress = progress;
            computeProgressLocked(forcePublish);
        }
    }


    private static float constrain(float amount, float low, float high) {
        return amount < low ? low : (amount > high ? high : amount);
    }

    private void computeProgressLocked(boolean forcePublish) {
        mProgress = constrain(mClientProgress * 0.8f, 0f, 0.8f)
                + constrain(mInternalProgress * 0.2f, 0f, 0.2f);

        // Only publish when meaningful change
        if (forcePublish || Math.abs(mProgress - mReportedProgress) >= 0.01) {
            mReportedProgress = mProgress;
            mCallback.onSessionProgressChanged(this, mProgress);
        }
    }

    @Override
    public void addClientProgress(float progress) throws RemoteException {
        synchronized (mLock) {
            setClientProgress(mClientProgress + progress);
        }
    }

    @Override
    public String[] getNames() throws RemoteException {
        assertPreparedAndNotSealed("getNames");
        try {
            return resolveStageDir().list();
        } catch (IOException e) {
            throw new IllegalStateException(e);
        }
    }

    /**
     * Resolve the actual location where staged data should be written. This
     * might point at an ASEC mount point, which is why we delay path resolution
     * until someone actively works with the session.
     */
    private File resolveStageDir() throws IOException {
        synchronized (mLock) {
            if (mResolvedStageDir == null && stageDir != null) {
                mResolvedStageDir = stageDir;
                if (!stageDir.exists()) {
                    stageDir.mkdirs();
                }
            }
            return mResolvedStageDir;
        }
    }

    @Override
    public ParcelFileDescriptor openWrite(String name, long offsetBytes, long lengthBytes) throws RemoteException {
        try {
            return openWriteInternal(name, offsetBytes, lengthBytes);
        } catch (IOException e) {
            throw new IllegalStateException(e);
        }
    }

    private void assertPreparedAndNotSealed(String cookie) {
        synchronized (mLock) {
            if (!mPrepared) {
                throw new IllegalStateException(cookie + " before prepared");
            }
            if (mSealed) {
                throw new SecurityException(cookie + " not allowed after commit");
            }
        }
    }


    private ParcelFileDescriptor openWriteInternal(String name, long offsetBytes, long lengthBytes)
            throws IOException {
        // Quick sanity check of state, and allocate a pipe for ourselves. We
        // then do heavy disk allocation outside the lock, but this open pipe
        // will block any attempted install transitions.
        final FileBridge bridge;
        synchronized (mLock) {
            assertPreparedAndNotSealed("openWrite");

            bridge = new FileBridge();
            mBridges.add(bridge);
        }
        try {
            final File target = new File(resolveStageDir(), name);
            // TODO: this should delegate to DCS so the system process avoids
            // holding open FDs into containers.
            final FileDescriptor targetFd = Os.open(target.getAbsolutePath(),
                    O_CREAT | O_WRONLY, 0644);
            // If caller specified a total length, allocate it for them. Free up
            // cache space to grow, if needed.
            if (lengthBytes > 0) {
                Os.posix_fallocate(targetFd, 0, lengthBytes);
            }
            if (offsetBytes > 0) {
                Os.lseek(targetFd, offsetBytes, OsConstants.SEEK_SET);
            }
            bridge.setTargetFile(targetFd);
            bridge.start();
            return ParcelFileDescriptor.dup(bridge.getClientSocket());

        } catch (ErrnoException e) {
            throw new IOException(e);
        }
    }

    @Override
    public ParcelFileDescriptor openRead(String name) throws RemoteException {
        try {
            return openReadInternal(name);
        } catch (IOException e) {
            throw new IllegalStateException(e);
        }
    }

    private ParcelFileDescriptor openReadInternal(String name) throws IOException {
        assertPreparedAndNotSealed("openRead");

        try {
            if (!FileUtils.isValidExtFilename(name)) {
                throw new IllegalArgumentException("Invalid name: " + name);
            }
            final File target = new File(resolveStageDir(), name);

            final FileDescriptor targetFd = Os.open(target.getAbsolutePath(), O_RDONLY, 0);
            return ParcelFileDescriptor.dup(targetFd);

        } catch (ErrnoException e) {
            throw new IOException(e);
        }
    }

    @Override
    public void removeSplit(String splitName) throws RemoteException {
        if (TextUtils.isEmpty(params.appPackageName)) {
            throw new IllegalStateException("Must specify package name to remove a split");
        }
        try {
            createRemoveSplitMarker(splitName);
        } catch (IOException e) {
            throw new IllegalStateException(e);
        }
    }

    private void createRemoveSplitMarker(String splitName) throws IOException {
        try {
            final String markerName = splitName + REMOVE_SPLIT_MARKER_EXTENSION;
            if (!FileUtils.isValidExtFilename(markerName)) {
                throw new IllegalArgumentException("Invalid marker: " + markerName);
            }
            final File target = new File(resolveStageDir(), markerName);
            target.createNewFile();
            Os.chmod(target.getAbsolutePath(), 0 /*mode*/);
        } catch (ErrnoException e) {
            throw new IOException(e);
        }
    }

    @Override
    public void close() throws RemoteException {
        if (mActiveCount.decrementAndGet() == 0) {
            mCallback.onSessionActiveChanged(this, false);
        }
    }

    @Override
    public void commit(IntentSender statusReceiver) throws RemoteException {
        final boolean wasSealed;
        synchronized (mLock) {
            wasSealed = mSealed;
            if (!mSealed) {
                // Verify that all writers are hands-off
                for (FileBridge bridge : mBridges) {
                    if (!bridge.isClosed()) {
                        throw new SecurityException("Files still open");
                    }
                }
                mSealed = true;
            }

            // Client staging is fully done at this point
            mClientProgress = 1f;
            computeProgressLocked(true);
        }

        if (!wasSealed) {
            // Persist the fact that we've sealed ourselves to prevent
            // mutations of any hard links we create. We do this without holding
            // the session lock, since otherwise it's a lock inversion.
            mCallback.onSessionSealedBlocking(this);
        }

        // This ongoing commit should keep session active, even though client
        // will probably close their end.
        mActiveCount.incrementAndGet();

        final VPackageInstallerService.PackageInstallObserverAdapter adapter
                = new VPackageInstallerService.PackageInstallObserverAdapter(mContext,
                statusReceiver, sessionId, userId);
        mHandler.obtainMessage(MSG_COMMIT, adapter.getBinder()).sendToTarget();
    }

    @Override
    public void abandon() throws RemoteException {
        destroyInternal();
        dispatchSessionFinished(INSTALL_FAILED_ABORTED, "Session was abandoned", null);
    }

    private void destroyInternal() {
        synchronized (mLock) {
            mSealed = true;
            mDestroyed = true;

            // Force shut down all bridges
            for (FileBridge bridge : mBridges) {
                bridge.forceClose();
            }
        }
        if (stageDir != null) {
            FileUtils.deleteDir(stageDir.getAbsolutePath());
        }
    }

    private void dispatchSessionFinished(int returnCode, String msg, Bundle extras) {
        mFinalStatus = returnCode;
        mFinalMessage = msg;

        if (mRemoteObserver != null) {
            try {
                mRemoteObserver.onPackageInstalled(mPackageName, returnCode, msg, extras);
            } catch (RemoteException ignored) {
            }
        }

        final boolean success = (returnCode == INSTALL_SUCCEEDED);
        mCallback.onSessionFinished(this, success);
    }

    void setPermissionsResult(boolean accepted) {
        if (!mSealed) {
            throw new SecurityException("Must be sealed to accept permissions");
        }

        if (accepted) {
            // Mark and kick off another install pass
            synchronized (mLock) {
                mPermissionsAccepted = true;
            }
            mHandler.obtainMessage(MSG_COMMIT).sendToTarget();
        } else {
            destroyInternal();
            dispatchSessionFinished(INSTALL_FAILED_ABORTED, "User rejected permissions", null);
        }
    }

    public void open() throws IOException {
        if (mActiveCount.getAndIncrement() == 0) {
            mCallback.onSessionActiveChanged(this, true);
        }

        synchronized (mLock) {
            if (!mPrepared) {
                if (stageDir == null) {
                    throw new IllegalArgumentException(
                            "Exactly one of stageDir or stageCid stage must be set");
                }
                mPrepared = true;
                mCallback.onSessionPrepared(this);
            }
        }
    }


    public static String getCompleteMessage(Throwable t) {
        final StringBuilder builder = new StringBuilder();
        builder.append(t.getMessage());
        while ((t = t.getCause()) != null) {
            builder.append(": ").append(t.getMessage());
        }
        return builder.toString();
    }

    private class PackageManagerException extends Exception {
        public final int error;

        PackageManagerException(int error, String detailMessage) {
            super(detailMessage);
            this.error = error;
        }
    }

}
