package com.termux.shared.net.socket.local;

import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.termux.shared.errors.Error;
import com.termux.shared.jni.models.JniResult;
import com.termux.shared.logger.Logger;

/**
 * Manager for an AF_UNIX/SOCK_STREAM local server.
 *
 * Usage:
 * 1. Implement the {@link ILocalSocketManager} that will receive call backs from the server including
 *    when client connects via {@link ILocalSocketManager#onClientAccepted(LocalSocketManager, LocalClientSocket)}.
 *    Optionally extend the {@link LocalSocketManagerClientBase} class that provides base implementation.
 * 2. Create a {@link LocalSocketRunConfig} instance with the run config of the server.
 * 3. Create a {@link LocalSocketManager} instance and call {@link #start()}.
 * 4. Stop server if needed with a call to {@link #stop()}.
 */
public class LocalSocketManager {

    public static final String LOG_TAG = "LocalSocketManager";

    /**
     * The native JNI local socket library.
     */
    protected static String LOCAL_SOCKET_LIBRARY = "local-socket";

    /**
     * Whether {@link #LOCAL_SOCKET_LIBRARY} has been loaded or not.
     */
    protected static boolean localSocketLibraryLoaded;

    /**
     * The {@link Context} that may needed for various operations.
     */
    @NonNull
    protected final Context mContext;

    /**
     * The {@link LocalSocketRunConfig} containing run config for the {@link LocalSocketManager}.
     */
    @NonNull
    protected final LocalSocketRunConfig mLocalSocketRunConfig;

    /**
     * The {@link LocalServerSocket} for the {@link LocalSocketManager}.
     */
    @NonNull
    protected final LocalServerSocket mServerSocket;

    /**
     * The {@link ILocalSocketManager} client for the {@link LocalSocketManager}.
     */
    @NonNull
    protected final ILocalSocketManager mLocalSocketManagerClient;

    /**
     * The {@link Thread.UncaughtExceptionHandler} used for client thread started by {@link LocalSocketManager}.
     */
    @NonNull
    protected final Thread.UncaughtExceptionHandler mLocalSocketManagerClientThreadUEH;

    /**
     * Whether the {@link LocalServerSocket} managed by {@link LocalSocketManager} in running or not.
     */
    protected boolean mIsRunning;

    /**
     * Create an new instance of {@link LocalSocketManager}.
     *
     * @param context The {@link #mContext} value.
     * @param localSocketRunConfig The {@link #mLocalSocketRunConfig} value.
     */
    public LocalSocketManager(@NonNull Context context, @NonNull LocalSocketRunConfig localSocketRunConfig) {
        mContext = context.getApplicationContext();
        mLocalSocketRunConfig = localSocketRunConfig;
        mServerSocket = new LocalServerSocket(this);
        mLocalSocketManagerClient = mLocalSocketRunConfig.getLocalSocketManagerClient();
        mLocalSocketManagerClientThreadUEH = getLocalSocketManagerClientThreadUEHOrDefault();
        mIsRunning = false;
    }

    /**
     * Create the {@link LocalServerSocket} and start listening for new {@link LocalClientSocket}.
     */
    public synchronized Error start() {
        Logger.logDebugExtended(LOG_TAG, "start\n" + mLocalSocketRunConfig);
        if (!localSocketLibraryLoaded) {
            try {
                Logger.logDebug(LOG_TAG, "Loading \"" + LOCAL_SOCKET_LIBRARY + "\" library");
                System.loadLibrary(LOCAL_SOCKET_LIBRARY);
                localSocketLibraryLoaded = true;
            } catch (Throwable t) {
                Error error = LocalSocketErrno.ERRNO_START_LOCAL_SOCKET_LIB_LOAD_FAILED_WITH_EXCEPTION.getError(t, LOCAL_SOCKET_LIBRARY, t.getMessage());
                Logger.logErrorExtended(LOG_TAG, error.getErrorLogString());
                return error;
            }
        }
        mIsRunning = true;
        return mServerSocket.start();
    }

    /**
     * Stop the {@link LocalServerSocket} and stop listening for new {@link LocalClientSocket}.
     */
    public synchronized Error stop() {
        if (mIsRunning) {
            Logger.logDebugExtended(LOG_TAG, "stop\n" + mLocalSocketRunConfig);
            mIsRunning = false;
            return mServerSocket.stop();
        }
        return null;
    }

    /*
     Note: Exceptions thrown from JNI must be caught with Throwable class instead of Exception,
     otherwise exception will be sent to UncaughtExceptionHandler of the thread.
    */
    /**
     * Creates an AF_UNIX/SOCK_STREAM local server socket at {@code path}, with the specified backlog.
     *
     * @param serverTitle The server title used for logging and errors.
     * @param path The path at which to create the socket.
     *             For a filesystem socket, this must be an absolute path to the socket file.
     *             For an abstract namespace socket, the first byte must be a null `\0` character.
     *             Max allowed length is 108 bytes as per sun_path size (UNIX_PATH_MAX) on Linux.
     * @param backlog The maximum length to which the queue of pending connections for the socket
     *                may grow. This value may be ignored or may not have one-to-one mapping
     *                in kernel implementation. Value must be greater than 0.
     * @return Returns the {@link JniResult}. If server creation was successful, then
     * {@link JniResult#retval} will be 0 and {@link JniResult#intData} will contain the server socket
     * fd.
     */
    @Nullable
    public static JniResult createServerSocket(@NonNull String serverTitle, @NonNull byte[] path, int backlog) {
        try {
            return createServerSocketNative(serverTitle, path, backlog);
        } catch (Throwable t) {
            String message = "Exception in createServerSocketNative()";
            Logger.logStackTraceWithMessage(LOG_TAG, message, t);
            return new JniResult(message, t);
        }
    }

    /**
     * Closes the socket with fd.
     *
     * @param serverTitle The server title used for logging and errors.
     * @param fd The socket fd.
     * @return Returns the {@link JniResult}. If closing socket was successful, then
     * {@link JniResult#retval} will be 0.
     */
    @Nullable
    public static JniResult closeSocket(@NonNull String serverTitle, int fd) {
        try {
            return closeSocketNative(serverTitle, fd);
        } catch (Throwable t) {
            String message = "Exception in closeSocketNative()";
            Logger.logStackTraceWithMessage(LOG_TAG, message, t);
            return new JniResult(message, t);
        }
    }

    /**
     * Accepts a connection on the supplied server socket fd.
     *
     * @param serverTitle The server title used for logging and errors.
     * @param fd The server socket fd.
     * @return Returns the {@link JniResult}. If accepting socket was successful, then
     * {@link JniResult#retval} will be 0 and {@link JniResult#intData} will contain the client socket
     * fd.
     */
    @Nullable
    public static JniResult accept(@NonNull String serverTitle, int fd) {
        try {
            return acceptNative(serverTitle, fd);
        } catch (Throwable t) {
            String message = "Exception in acceptNative()";
            Logger.logStackTraceWithMessage(LOG_TAG, message, t);
            return new JniResult(message, t);
        }
    }

    /**
     * Attempts to read up to data buffer length bytes from file descriptor fd into the data buffer.
     * On success, the number of bytes read is returned (zero indicates end of file).
     * It is not an error if bytes read is smaller than the number of bytes requested; this may happen
     * for example because fewer bytes are actually available right now (maybe because we were close
     * to end-of-file, or because we are reading from a pipe), or because read() was interrupted by
     * a signal. On error, the {@link JniResult#errno} and {@link JniResult#errmsg} will be set.
     *
     * If while reading the deadline elapses but all the data has not been read, the call will fail.
     *
     * @param serverTitle The server title used for logging and errors.
     * @param fd The socket fd.
     * @param data The data buffer to read bytes into.
     * @param deadline The deadline milliseconds since epoch.
     * @return Returns the {@link JniResult}. If reading was successful, then {@link JniResult#retval}
     * will be 0 and {@link JniResult#intData} will contain the bytes read.
     */
    @Nullable
    public static JniResult read(@NonNull String serverTitle, int fd, @NonNull byte[] data, long deadline) {
        try {
            return readNative(serverTitle, fd, data, deadline);
        } catch (Throwable t) {
            String message = "Exception in readNative()";
            Logger.logStackTraceWithMessage(LOG_TAG, message, t);
            return new JniResult(message, t);
        }
    }

    /**
     * Attempts to send data buffer to the file descriptor. On error, the {@link JniResult#errno} and
     * {@link JniResult#errmsg} will be set.
     *
     * If while sending the deadline elapses but all the data has not been sent, the call will fail.
     *
     * @param serverTitle The server title used for logging and errors.
     * @param fd The socket fd.
     * @param data The data buffer containing bytes to send.
     * @param deadline The deadline milliseconds since epoch.
     * @return Returns the {@link JniResult}. If sending was successful, then {@link JniResult#retval}
     * will be 0.
     */
    @Nullable
    public static JniResult send(@NonNull String serverTitle, int fd, @NonNull byte[] data, long deadline) {
        try {
            return sendNative(serverTitle, fd, data, deadline);
        } catch (Throwable t) {
            String message = "Exception in sendNative()";
            Logger.logStackTraceWithMessage(LOG_TAG, message, t);
            return new JniResult(message, t);
        }
    }

    /**
     * Gets the number of bytes available to read on the socket.
     *
     * @param serverTitle The server title used for logging and errors.
     * @param fd The socket fd.
     * @return Returns the {@link JniResult}. If checking availability was successful, then
     * {@link JniResult#retval} will be 0 and {@link JniResult#intData} will contain the bytes available.
     */
    @Nullable
    public static JniResult available(@NonNull String serverTitle, int fd) {
        try {
            return availableNative(serverTitle, fd);
        } catch (Throwable t) {
            String message = "Exception in availableNative()";
            Logger.logStackTraceWithMessage(LOG_TAG, message, t);
            return new JniResult(message, t);
        }
    }

    /**
     * Set receiving (SO_RCVTIMEO) timeout in milliseconds for socket.
     *
     * @param serverTitle The server title used for logging and errors.
     * @param fd The socket fd.
     * @param timeout The timeout value in milliseconds.
     * @return Returns the {@link JniResult}. If setting timeout was successful, then
     * {@link JniResult#retval} will be 0.
     */
    @Nullable
    public static JniResult setSocketReadTimeout(@NonNull String serverTitle, int fd, int timeout) {
        try {
            return setSocketReadTimeoutNative(serverTitle, fd, timeout);
        } catch (Throwable t) {
            String message = "Exception in setSocketReadTimeoutNative()";
            Logger.logStackTraceWithMessage(LOG_TAG, message, t);
            return new JniResult(message, t);
        }
    }

    /**
     * Set sending (SO_SNDTIMEO) timeout in milliseconds for fd.
     *
     * @param serverTitle The server title used for logging and errors.
     * @param fd The socket fd.
     * @param timeout The timeout value in milliseconds.
     * @return Returns the {@link JniResult}. If setting timeout was successful, then
     * {@link JniResult#retval} will be 0.
     */
    @Nullable
    public static JniResult setSocketSendTimeout(@NonNull String serverTitle, int fd, int timeout) {
        try {
            return setSocketSendTimeoutNative(serverTitle, fd, timeout);
        } catch (Throwable t) {
            String message = "Exception in setSocketSendTimeoutNative()";
            Logger.logStackTraceWithMessage(LOG_TAG, message, t);
            return new JniResult(message, t);
        }
    }

    /**
     * Get the {@link PeerCred} for the socket.
     *
     * @param serverTitle The server title used for logging and errors.
     * @param fd The socket fd.
     * @param peerCred The {@link PeerCred} object that should be filled.
     * @return Returns the {@link JniResult}. If setting timeout was successful, then
     * {@link JniResult#retval} will be 0.
     */
    @Nullable
    public static JniResult getPeerCred(@NonNull String serverTitle, int fd, PeerCred peerCred) {
        try {
            return getPeerCredNative(serverTitle, fd, peerCred);
        } catch (Throwable t) {
            String message = "Exception in getPeerCredNative()";
            Logger.logStackTraceWithMessage(LOG_TAG, message, t);
            return new JniResult(message, t);
        }
    }

    /**
     * Wrapper for {@link #onError(LocalClientSocket, Error)} for {@code null} {@link LocalClientSocket}.
     */
    public void onError(@NonNull Error error) {
        onError(null, error);
    }

    /**
     * Wrapper to call {@link ILocalSocketManager#onError(LocalSocketManager, LocalClientSocket, Error)} in a new thread.
     */
    public void onError(@Nullable LocalClientSocket clientSocket, @NonNull Error error) {
        startLocalSocketManagerClientThread(() -> mLocalSocketManagerClient.onError(this, clientSocket, error));
    }

    /**
     * Wrapper to call {@link ILocalSocketManager#onDisallowedClientConnected(LocalSocketManager, LocalClientSocket, Error)} in a new thread.
     */
    public void onDisallowedClientConnected(@NonNull LocalClientSocket clientSocket, @NonNull Error error) {
        startLocalSocketManagerClientThread(() -> mLocalSocketManagerClient.onDisallowedClientConnected(this, clientSocket, error));
    }

    /**
     * Wrapper to call {@link ILocalSocketManager#onClientAccepted(LocalSocketManager, LocalClientSocket)} in a new thread.
     */
    public void onClientAccepted(@NonNull LocalClientSocket clientSocket) {
        startLocalSocketManagerClientThread(() -> mLocalSocketManagerClient.onClientAccepted(this, clientSocket));
    }

    /**
     * All client accept logic must be run on separate threads so that incoming client acceptance is not blocked.
     */
    public void startLocalSocketManagerClientThread(@NonNull Runnable runnable) {
        Thread thread = new Thread(runnable);
        thread.setUncaughtExceptionHandler(getLocalSocketManagerClientThreadUEH());
        try {
            thread.start();
        } catch (Exception e) {
            Logger.logStackTraceWithMessage(LOG_TAG, "LocalSocketManagerClientThread start failed", e);
        }
    }

    /**
     * Get {@link #mContext}.
     */
    public Context getContext() {
        return mContext;
    }

    /**
     * Get {@link #mLocalSocketRunConfig}.
     */
    public LocalSocketRunConfig getLocalSocketRunConfig() {
        return mLocalSocketRunConfig;
    }

    /**
     * Get {@link #mLocalSocketManagerClient}.
     */
    public ILocalSocketManager getLocalSocketManagerClient() {
        return mLocalSocketManagerClient;
    }

    /**
     * Get {@link #mServerSocket}.
     */
    public LocalServerSocket getServerSocket() {
        return mServerSocket;
    }

    /**
     * Get {@link #mLocalSocketManagerClientThreadUEH}.
     */
    public Thread.UncaughtExceptionHandler getLocalSocketManagerClientThreadUEH() {
        return mLocalSocketManagerClientThreadUEH;
    }

    /**
     * Get {@link Thread.UncaughtExceptionHandler} returned by call to
     * {@link ILocalSocketManager#getLocalSocketManagerClientThreadUEH(LocalSocketManager)}
     * or the default handler that just logs the exception.
     */
    protected Thread.UncaughtExceptionHandler getLocalSocketManagerClientThreadUEHOrDefault() {
        Thread.UncaughtExceptionHandler uncaughtExceptionHandler = mLocalSocketManagerClient.getLocalSocketManagerClientThreadUEH(this);
        if (uncaughtExceptionHandler == null)
            uncaughtExceptionHandler = (t, e) -> Logger.logStackTraceWithMessage(LOG_TAG, "Uncaught exception for " + t + " in " + mLocalSocketRunConfig.getTitle() + " server", e);
        return uncaughtExceptionHandler;
    }

    /**
     * Get {@link #mIsRunning}.
     */
    public boolean isRunning() {
        return mIsRunning;
    }

    /**
     * Get an error log {@link String} for the {@link LocalSocketManager}.
     */
    public static String getErrorLogString(@NonNull Error error, @NonNull LocalSocketRunConfig localSocketRunConfig, @Nullable LocalClientSocket clientSocket) {
        StringBuilder logString = new StringBuilder();
        logString.append(localSocketRunConfig.getTitle()).append(" Socket Server Error:\n");
        logString.append(error.getErrorLogString());
        logString.append("\n\n\n");
        logString.append(localSocketRunConfig.getLogString());
        if (clientSocket != null) {
            logString.append("\n\n\n");
            logString.append(clientSocket.getLogString());
        }
        return logString.toString();
    }

    /**
     * Get an error markdown {@link String} for the {@link LocalSocketManager}.
     */
    public static String getErrorMarkdownString(@NonNull Error error, @NonNull LocalSocketRunConfig localSocketRunConfig, @Nullable LocalClientSocket clientSocket) {
        StringBuilder markdownString = new StringBuilder();
        markdownString.append(error.getErrorMarkdownString());
        markdownString.append("\n##\n\n\n");
        markdownString.append(localSocketRunConfig.getMarkdownString());
        if (clientSocket != null) {
            markdownString.append("\n\n\n");
            markdownString.append(clientSocket.getMarkdownString());
        }
        return markdownString.toString();
    }

    @Nullable
    private static native JniResult createServerSocketNative(@NonNull String serverTitle, @NonNull byte[] path, int backlog);

    @Nullable
    private static native JniResult closeSocketNative(@NonNull String serverTitle, int fd);

    @Nullable
    private static native JniResult acceptNative(@NonNull String serverTitle, int fd);

    @Nullable
    private static native JniResult readNative(@NonNull String serverTitle, int fd, @NonNull byte[] data, long deadline);

    @Nullable
    private static native JniResult sendNative(@NonNull String serverTitle, int fd, @NonNull byte[] data, long deadline);

    @Nullable
    private static native JniResult availableNative(@NonNull String serverTitle, int fd);

    private static native JniResult setSocketReadTimeoutNative(@NonNull String serverTitle, int fd, int timeout);

    @Nullable
    private static native JniResult setSocketSendTimeoutNative(@NonNull String serverTitle, int fd, int timeout);

    @Nullable
    private static native JniResult getPeerCredNative(@NonNull String serverTitle, int fd, PeerCred peerCred);
}
