package be.iminds.ilabt.jfed.util;

import be.iminds.ilabt.jfed.lowlevel.GeniUser;
import be.iminds.ilabt.jfed.preferences.JFedPreferences;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.annotation.Nonnull;
import javax.inject.Singleton;
import java.io.*;
import java.util.*;
import java.util.stream.Collectors;

/**
 * JFedPasswordManager
 * <p>
 * This utility stores usernames and password for various services.
 * <p>
 * The usernames and password are stored ENCRYPTED by the users login PEM private key.
 * That way, they are absolutely safe.
 */
@Singleton
public class JFedPasswordManager {
    private static final Logger LOG = LoggerFactory.getLogger(JFedPasswordManager.class);

    private final List<LoginInfo> logins = new ArrayList<>();

    private static final File defaultLoginDir = new File(JFedUtils.getUserDataDirectoryFile(), "logins");
    private final File loginDir;

    public JFedPasswordManager() {
        this(defaultLoginDir);
    }

    JFedPasswordManager(File loginDir) {
        LOG.debug("Created JFedPasswordManager");
        this.loginDir = loginDir;
    }

    private String createUserId(@Nonnull GeniUser user) {

        //also add expire date. This makes sure that when a new certificate is used, it won't use the same file.
        //TODO: even better would be to use a hash of the public key. (but it's not really worth the trouble to implement that)
        Date expireDate = null;
        if (!user.getClientCertificateChain().isEmpty() && user.getClientCertificateChain().get(0) != null)
            expireDate = user.getClientCertificateChain().get(0).getNotAfter();

        return user.getUserUrn().getEncodedTopLevelAuthority().replaceAll("[^A-Za-z0-9_-]", "X")
                + "+" +
                user.getUserUrn().getEncodedResourceName().replaceAll("[^A-Za-z0-9_-]", "X")
                + "+" +
                (expireDate == null ? null : expireDate.getTime()) + "";
    }

    public File getEncryptedSaveFile(JFedPreferences prefs, GeniUser user) {
        return new File(loginDir, "logins-" + createUserId(user) + ".encrypted");
    }

    public void save(JFedPreferences prefs, GeniUser user) throws IOException {
        assert user != null;

        if (!loginDir.exists())
            //noinspection ResultOfMethodCallIgnored
            loginDir.mkdir();
        if (!loginDir.exists() || !loginDir.isDirectory())
            throw new IOException("Could not save login info: dir \"" + loginDir.getAbsolutePath() + "\" does not exist and could not be created.");

        ByteArrayOutputStream byteOut = new ByteArrayOutputStream();
        ObjectOutputStream out = new ObjectOutputStream(byteOut);
        out.writeObject(logins);
        out.close();
        byteOut.close();
        byte[] encryptedContent = FileEncrypter.encryptData(byteOut.toByteArray(), user.getPublicKey());

        File loginsFile = getEncryptedSaveFile(prefs, user);
        IOUtils.byteArrayToFile(loginsFile, encryptedContent);
    }

    public void load(JFedPreferences prefs, GeniUser user) {
        LOG.debug("Loading jFed password for user " + user.getUserUrnString());
        File loginsFile = getEncryptedSaveFile(prefs, user);

        logins.clear();
        if (!loginsFile.exists() || !loginsFile.canRead() || !loginsFile.isFile()) {
            LOG.warn("logins file does not exist: {}", loginsFile.getAbsolutePath());
            return;
        }

        byte[] encryptedContent;
        try {
            encryptedContent = IOUtils.fileToByteArray(loginsFile);
            byte[] decryptedContent = FileEncrypter.decryptData(encryptedContent, user.getPrivateKey());

            if (decryptedContent == null) {
                LOG.warn("Failed to decrypt {}", loginsFile);
                return;
            }

            ByteArrayInputStream bais = new ByteArrayInputStream(decryptedContent);
            ObjectInputStream in = new ObjectInputStream(bais);

            Object inputObj = in.readObject();

            if (!(inputObj instanceof List)) {
                LOG.warn("Expected a list of LoginInfo objects, but got a {} instead", inputObj.getClass().getName());
                return;
            }

            List newLogins = (List) inputObj;

            for (Object newLogin : newLogins) {
                if (newLogin instanceof LoginInfo)
                    logins.add((LoginInfo) newLogin);
                else
                    LOG.warn("Unexpected entry in list: got a {} instead of LoginInfo", newLogin.getClass().getName());
            }

            LOG.debug("  user has " + logins.size() + " passwords stored");
        } catch (IOException e) {
            LOG.error("Error loading loginFile: " + loginsFile.getAbsolutePath() + " -> file will be ignored.", e);
        } catch (ClassNotFoundException e) {
            LOG.error("Error deserializing loginFile: " + loginsFile.getAbsolutePath() + " -> file will be ignored.", e);
        }
    }

    public List<LoginInfo> getLoginsAndRequestIfNone(String service, String serviceFriendlyName, JFedPreferences prefs, GeniUser user) {
        List<LoginInfo> res = getLogins(service);
        if (res.isEmpty()) {
            LOG.debug("Requesting login for service \"" + service + "\" to user.");
            JFedPasswordManager.LoginInfo login = ExtraInfoCallback.getAnyLogin(service, serviceFriendlyName);
            if (login != null) {
                LOG.debug("  user provided login for user " + login.getUsername());
                res.add(login);
                logins.add(login);
                try {
                    save(prefs, user);
                } catch (IOException e) {
                    LOG.error("Failed to save new login", e);
                }
            } else
                LOG.debug("  user did not provide a login");
        }
        return res;
    }

    public List<LoginInfo> getLogins(String service) {
        LOG.debug("Checking login info for service \"" + service + "\"");
        List<LoginInfo> res =
                logins.stream().filter(l -> Objects.equals(l.getService(), service)).collect(Collectors.toList());
        LOG.debug("Found " + res.size() + " logins matching service \"" + service + "\" out of " + logins.size() + " total logins");
        return res;
    }

    public List<LoginInfo> getLogins() {
        return Collections.unmodifiableList(logins);
    }

    public void removeLogin(LoginInfo itRemove) {
        for (Iterator<LoginInfo> it = logins.listIterator(); it.hasNext(); ) {
            LoginInfo cur = it.next();
            if (Objects.equals(cur, itRemove))
                it.remove();
        }
    }

    public void removeLogin(String service, String username) {
        for (Iterator<LoginInfo> it = logins.listIterator(); it.hasNext(); ) {
            LoginInfo cur = it.next();
            if (Objects.equals(cur.getService(), service) &&
                    Objects.equals(cur.getUsername(), username)) {
                it.remove();
            }
        }
    }

    public void clear() {
        logins.clear();
    }

    public void addLogin(LoginInfo loginInfo) {
        for (Iterator<LoginInfo> it = logins.listIterator(); it.hasNext(); ) {
            LoginInfo cur = it.next();
            if (Objects.equals(cur.getService(), loginInfo.getService()) &&
                    Objects.equals(cur.getUsername(), loginInfo.getUsername())) {
                LOG.debug("Removed existing loginInfo for " + cur.getService() + " " + cur.getUsername());
                it.remove();
            }
        }
        logins.add(loginInfo);
    }


    public static class LoginInfo implements Serializable {
        private final String service;
        private final String username;
        private final String password;

        public LoginInfo(String service, String username, String password) {
            this.service = service;
            this.username = username;
            this.password = password;
            assert service != null;
            assert username != null;
        }

        public String getService() {
            return service;
        }

        public String getUsername() {
            return username;
        }

        public String getPassword() {
            return password;
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (!(o instanceof LoginInfo)) return false;

            LoginInfo loginInfo = (LoginInfo) o;

            if (password != null ? !Objects.equals(password, loginInfo.password) : loginInfo.password != null)
                return false;
            if (!Objects.equals(service, loginInfo.service)) return false;
            if (!Objects.equals(username, loginInfo.username)) return false;

            return true;
        }

        @Override
        public int hashCode() {
            int result = service.hashCode();
            result = 31 * result + username.hashCode();
            result = 31 * result + (password != null ? password.hashCode() : 0);
            return result;
        }
    }
}
