package ch.threema.app.webclient.services;

import android.content.Context;

import org.koin.java.KoinJavaComponent;
import org.saltyrtc.client.crypto.CryptoException;
import org.slf4j.Logger;

import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.List;
import java.util.Queue;

import androidx.annotation.AnyThread;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.annotation.WorkerThread;

import ch.threema.android.ToastDuration;
import ch.threema.app.R;
import ch.threema.app.managers.ServiceManager;
import ch.threema.app.restrictions.AppRestrictions;
import ch.threema.app.services.notification.NotificationService;
import ch.threema.app.utils.ConfigUtils;
import ch.threema.app.webclient.Protocol;
import ch.threema.app.webclient.manager.WebClientServiceManager;
import ch.threema.app.webclient.services.instance.DisconnectContext;
import ch.threema.app.webclient.services.instance.SessionInstanceService;
import ch.threema.base.ThreemaException;

import static ch.threema.android.ToastKt.showToast;
import static ch.threema.base.utils.LoggingKt.getThreemaLogger;

import static ch.threema.app.di.DIJavaCompat.getMasterKeyManager;

@WorkerThread
public class SessionWakeUpServiceImpl implements SessionWakeUpService {
    private enum StartResult {
        OK,
        SERVICE_NOT_AVAILABLE,
        SERVICE_DISABLED,
        HOST_CONSTRAINED_BY_MDM,
        SESSION_UNKNOWN,
        RESTARTED,
        ALREADY_STARTED,
        EXCEPTION,
    }

    // Timeouts
    public static final int DEFAULT_WAKEUP_SECONDS = 60;
    public static final int DISCONNECT_WAKEUP_SECONDS = 20;

    // Logger
    @NonNull
    private static final Logger logger = getThreemaLogger("SessionWakeUpServiceImpl");

    // Queue of pending wakeups. Do not access this directly, use getPendingWakeUps instead.
    private final Queue<PendingWakeup> pendingWakeUps = new ArrayDeque<>();

    @NonNull
    private final Context appContext;

    @AnyThread
    public SessionWakeUpServiceImpl(@NonNull Context appContext) {
        this.appContext = appContext;
    }

    @NonNull
    private Queue<PendingWakeup> getPendingWakeUps() {
        return this.pendingWakeUps;
    }

    @AnyThread
    private synchronized boolean isAvailable() {
        return ServiceManager.get() != null;
    }

    /**
     * Returns the session service if...
     * <p>
     * - the service manager is available,
     * - the master key is available, and
     * - the session service is available.
     * <p>
     * Otherwise, it raises ThreemaException.
     */
    @NonNull
    private SessionService getSessionService() throws ThreemaException {
        if (!isAvailable()) {
            throw new ThreemaException("Service manager unavailable");
        }
        return ServiceManager.require().getWebClientServiceManager().getSessionService();
    }

    @Override
    @AnyThread
    public synchronized void resume(
        @NonNull final String publicKeySha256String,
        final int version,
        @Nullable final String affiliationId
    ) {
        logger.info("Attempting to resume session (public-key={}, version={}, affiliation={})",
            publicKeySha256String, version, affiliationId);
        if (!isAvailable()) {
            logger.error("Service unavailable");
            return;
        }

        // Validate protocol version
        if (Protocol.PROTOCOL_VERSION != version) {
            logger.error("Unexpected protocol version: {}", version);
            showToast(appContext, R.string.webclient_protocol_version_to_old, ToastDuration.LONG);
        }

        var serviceManager = ServiceManager.get();
        if (serviceManager == null) {
            logger.error("Cannot resume or schedule wakeup, web client service manager unavailable");
            return;
        }

        // Ensure the web client service manager is available
        final WebClientServiceManager manager = serviceManager.getWebClientServiceManager();

        // Handle locked master key
        if (getMasterKeyManager().isLocked()) {
            logger.warn("Master key is locked, scheduling wakeup");
            manager.getHandler().post(new Runnable() {
                @Override
                @WorkerThread
                public void run() {
                    SessionWakeUpServiceImpl.this.schedule(
                        publicKeySha256String, affiliationId, DEFAULT_WAKEUP_SECONDS * 1000);
                }
            });
            return;
        }

        // Try to start session on the worker thread
        manager.getHandler().post(new Runnable() {
            @Override
            @WorkerThread
            public void run() {
                switch (SessionWakeUpServiceImpl.this.start(publicKeySha256String, affiliationId)) {
                    case SERVICE_DISABLED:
                        logger.warn("Threema Web service is disabled, store pending wakeup");
                        final String text = appContext.getString(R.string.webclient_cannot_restore) + ": "
                            + appContext.getString(R.string.webclient_disabled);
                        showToast(appContext, text, ToastDuration.LONG);
                        SessionWakeUpServiceImpl.this.schedule(publicKeySha256String, affiliationId, DEFAULT_WAKEUP_SECONDS * 1000);
                        break;
                    case SERVICE_NOT_AVAILABLE:
                        logger.warn("Service not available, store pending wakeup");
                        SessionWakeUpServiceImpl.this.schedule(publicKeySha256String, affiliationId, DEFAULT_WAKEUP_SECONDS * 1000);
                        break;
                    case SESSION_UNKNOWN:
                        logger.warn("Session unknown, ignoring");
                        break;
                    case RESTARTED:
                        logger.info("Session has been restarted");
                        break;
                    case ALREADY_STARTED:
                        logger.warn("Already started, store pending wakeup");
                        SessionWakeUpServiceImpl.this.schedule(publicKeySha256String, affiliationId, DISCONNECT_WAKEUP_SECONDS * 1000);
                        break;
                    case HOST_CONSTRAINED_BY_MDM:
                        logger.warn("Could not resume session, host constrained by administrator");
                        SessionWakeUpServiceImpl.this.showWarningNotification(R.string.webclient_constrained_by_mdm);
                        break;
                    case EXCEPTION:
                        logger.error("Exception while trying to wake up session");
                        break;
                    case OK:
                        logger.info("Session has been started");
                        break;
                    default:
                        logger.error("Warning: Unhandled StartResult!");
                        break;
                }
            }
        });
    }

    /**
     * Schedule a wakeup for the specific session.
     */
    private void schedule(
        @NonNull final String publicKeySha256String,
        @Nullable final String affiliationId,
        final int lifetimeMs
    ) {
        if (lifetimeMs > 0) {
            final long expiration = System.currentTimeMillis() + lifetimeMs;
            final Queue<PendingWakeup> pendingQueue = this.getPendingWakeUps();

            // Update existing wakeup (if any)
            for (PendingWakeup pending : pendingQueue) {
                if (pending.publicKeySha256String.equals(publicKeySha256String)) {
                    logger.info("Wakeup already scheduled, refreshing expiration +{} ms", lifetimeMs);
                    pending.expiration = expiration;
                    pending.affiliationId = affiliationId;
                    return;
                }
            }

            // Add new wakeup
            logger.info("Wakeup scheduled, expiration +{} ms", lifetimeMs);
            pendingQueue.add(new PendingWakeup(publicKeySha256String, affiliationId, expiration));
        }
    }

    private StartResult start(@NonNull final String publicKeySha256String, @Nullable final String affiliationId) {
        // Get session service
        final SessionService service;
        try {
            service = this.getSessionService();
        } catch (ThreemaException error) {
            logger.debug("Service unavailable: {}", error.getMessage());
            return StartResult.SERVICE_NOT_AVAILABLE;
        }

        // Ensure the web client is enabled
        if (!service.isEnabled()) {
            return StartResult.SERVICE_DISABLED;
        }

        // Ensure there is a session instance
        logger.debug("Retrieving session instance");
        final SessionInstanceService webClientInstanceService =
            service.getInstanceService(publicKeySha256String, true);
        if (webClientInstanceService == null) {
            return StartResult.SESSION_UNKNOWN;
        }

        // Check if the session is still running
        if (webClientInstanceService.isRunning()) {
            // Stop current session if affiliation ID has changed
            if (webClientInstanceService.needsRestart(affiliationId)) {
                logger.info("Restarting session", affiliationId);
                this.schedule(publicKeySha256String, affiliationId, DISCONNECT_WAKEUP_SECONDS * 1000);
                webClientInstanceService.stop(DisconnectContext.byPeer(DisconnectContext.REASON_SESSION_REPLACED));
                return StartResult.RESTARTED;
            } else {
                logger.debug("Session already started", affiliationId);
                return StartResult.ALREADY_STARTED;
            }
        }

        // MDM constraints
        if (ConfigUtils.isWorkRestricted()) {
            final String hostname = webClientInstanceService.getModel().getSaltyRtcHost();
            AppRestrictions appRestrictions = KoinJavaComponent.get(AppRestrictions.class);
            if (!appRestrictions.isWebHostAllowed(hostname)) {
                return StartResult.HOST_CONSTRAINED_BY_MDM;
            }
        }

        // Resume the session
        logger.info("Resuming session", affiliationId);
        try {
            webClientInstanceService.resume(affiliationId);
        } catch (CryptoException error) {
            logger.error("Unable to resume session", error);
            return StartResult.EXCEPTION;
        }
        return StartResult.OK;
    }

    /**
     * Process pending wakeups asynchronously.
     */
    @Override
    @AnyThread
    public synchronized void processPendingWakeupsAsync() {
        if (!isAvailable()) {
            logger.error("Service unavailable");
            return;
        }

        // Ensure the web client service manager is available
        var serviceManager = ServiceManager.get();
        if (serviceManager == null) {
            logger.error("Cannot access service manager");
            return;
        }
        final WebClientServiceManager manager = serviceManager.getWebClientServiceManager();

        // Process pending wakeups on worker thread
        manager.getHandler().post(new Runnable() {
            @Override
            @WorkerThread
            public void run() {
                SessionWakeUpServiceImpl.this.processPendingWakeups();
            }
        });
    }

    /**
     * Process pending wakeups.
     */
    @Override
    public void processPendingWakeups() {
        final Queue<PendingWakeup> pendingQueue = this.getPendingWakeUps();
        logger.info("Process {} pending wakeups", pendingQueue.size());

        // Try to resume each pending session
        PendingWakeup pending;
        final List<PendingWakeup> failed = new ArrayList<>();
        final long now = System.currentTimeMillis();
        while ((pending = pendingQueue.poll()) != null) {
            // Check if expired
            if (now > pending.expiration) {
                logger.info("Pending wakeup expired, ignoring");
                continue;
            }

            // Try a wakeup
            if (getMasterKeyManager().isLocked()) {
                logger.error("Cannot wake up {}, master key is locked", pending.publicKeySha256String);
                failed.add(pending);
                continue;
            }
            try {
                // Get session service
                final SessionService sessionService = this.getSessionService();
                if (!sessionService.isEnabled()) {
                    logger.error("Cannot wake up {}, session service is disabled", pending.publicKeySha256String);
                    failed.add(pending);
                    continue;
                }

                // Get session instance
                final SessionInstanceService webClientInstanceService =
                    sessionService.getInstanceService(pending.publicKeySha256String.trim(), true);
                if (webClientInstanceService == null) {
                    logger.error("Cannot wake up {}, session instance not found, remove from pending list", pending.publicKeySha256String);
                    continue;
                }

                // Check MDM constraints
                if (ConfigUtils.isWorkRestricted()) {
                    final String hostname = webClientInstanceService.getModel().getSaltyRtcHost();
                    AppRestrictions appRestrictions = KoinJavaComponent.get(AppRestrictions.class);
                    if (!appRestrictions.isWebHostAllowed(hostname)) {
                        logger.warn("Cannot wake up session {}, disabled by administrator", pending.publicKeySha256String);
                        continue;
                    }
                }

                // Resume session instance
                webClientInstanceService.resume(pending.affiliationId);
                logger.info("Resumed session {} from pending wakeup list", pending.publicKeySha256String);
            } catch (CryptoException error) {
                logger.error("Exception while waking up session", error);
                // Note: Don't reschedule here as this is not recoverable!
            } catch (ThreemaException error) {
                logger.error("Exception while waking up session", error);
                failed.add(pending);
            }
        }

        // Reschedule failed wakeup attempts
        if (!failed.isEmpty()) {
            logger.info("Re-scheduling {} pending wakeups", failed.size());
            pendingQueue.addAll(failed);
            failed.clear();
        }
    }

    /**
     * Discard pending wakeups.
     */
    @Override
    public void discardPendingWakeups() {
        final Queue<PendingWakeup> pendingQueue = this.getPendingWakeUps();
        logger.info("Discarding {} pending wakeups", pendingQueue.size());
        pendingQueue.clear();
    }

    private void showWarningNotification(@StringRes int message) {
        NotificationService notificationService = ServiceManager.require().getNotificationService();
        final String msg = appContext.getString(R.string.webclient_cannot_restore) + ": " + appContext.getString(message);
        notificationService.showWebclientResumeFailed(msg);
    }
}
