/*
 * MoasdaWiki App
 * Copyright (C) 2008 - 2022 Herbert Reiter (herbert@moasdawiki.net)
 *
 * This program is free software: you can redistribute it and/or modify it
 * under the terms of the GNU General Public License version 3 as published
 * by the Free Software Foundation (GPL-3.0-only).
 *
 * This program is distributed in the hope that it will be useful, but
 * WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License along with
 * this program. If not, see <https://www.gnu.org/licenses/gpl-3.0.html>.
 */

package net.moasdawiki.app;

import android.content.Context;
import android.content.SharedPreferences;
import android.os.Build;
import android.util.Base64;
import android.util.Log;

import androidx.preference.PreferenceManager;

import net.moasdawiki.base.Logger;
import net.moasdawiki.base.ServiceException;
import net.moasdawiki.base.Settings;
import net.moasdawiki.service.repository.AnyFile;
import net.moasdawiki.service.repository.RepositoryService;
import net.moasdawiki.service.sync.*;
import net.moasdawiki.util.DateUtils;
import net.moasdawiki.util.xml.XmlGenerator;
import net.moasdawiki.util.xml.XmlParser;

import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.math.BigInteger;
import java.net.HttpURLConnection;
import java.net.InetAddress;
import java.net.NetworkInterface;
import java.net.SocketException;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.Date;
import java.util.Enumeration;

/**
 * Sucht einen Wikiserver in Netzwerk und synchronisiert alle Wikidateien
 * im eigenen Repository..
 */
public class SynchronizeWikiClient {

    private static final String TAG = "SynchronizeWikiClient";

    private static final String PROTOCOL_VERSION = "2.0";

    @NotNull
    private final Context mContext;
    @NotNull
    private final Logger logger;
    @NotNull
    private final Settings settings;
    @NotNull
    private final RepositoryService repositoryService;
    @NotNull
    private final SecureRandom random;

    public SynchronizeWikiClient(@NotNull Context mContext, @NotNull Logger logger, @NotNull Settings settings,
                                 @NotNull RepositoryService repositoryService) {
        this.mContext = mContext;
        this.logger = logger;
        this.settings = settings;
        this.repositoryService = repositoryService;
        this.random = new SecureRandom();
    }

    /**
     * Verbindet sich mit dem Wikiserver. Ist bereits eine Serversession vorhanden, wird diese
     * weiter verwendet.
     */
    public boolean createAndCheckSession() {
        String serverHostPort = getServerHostPort();
        if (serverHostPort == null) {
            return false;
        }

        PreferenceManager.getDefaultSharedPreferences(mContext);
        SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(mContext);
        String serverSessionId = preferences.getString(Constants.PREFERENCES_SYNC_SERVER_SESSION_ID, null);

        try {
            // Wenn noch keine Session vorhanden ist, eine erzeugen
            boolean createSessionCalled = false;
            if (serverSessionId == null) {
                createSessionCalled = true;
                createSession(serverHostPort);
            }

            // Ist Session freigeschaltet zur Synchronisierung?
            boolean[] checkResult = checkSession(serverHostPort);
            if (checkResult[0]) {
                return true;
            }

            // Session ist ungültig --> neue Session holen
            if (createSessionCalled) {
                // createSession nicht erneut aufrufen
                return false;
            }
            createSession(serverHostPort);

            // Session erneut prüfen
            boolean[] checkResult2 = checkSession(serverHostPort);
            return checkResult2[0];
        }
        catch (ServiceException e) {
            return false;
        }
    }

    private void createSession(@NotNull String serverHostPort) throws ServiceException {
        // Anfrage schicken
        CreateSessionXml createSessionXml = new CreateSessionXml();
        createSessionXml.version = PROTOCOL_VERSION;
        createSessionXml.clientSessionId = generateSessionId();
        createSessionXml.clientName = "MoasdaWiki-App";
        createSessionXml.clientVersion = settings.getVersion();
        createSessionXml.clientHost = getDeviceName();
        String hostName = getLocalHostname();
        if (hostName != null) {
            createSessionXml.clientHost += " / " + hostName;
        }
        String requestXml = generateXml(createSessionXml);
        String responseXml = sendXmlRequest(serverHostPort, "/sync/create-session", requestXml);

        // Antwort auswerten
        CreateSessionResponseXml response = parseXml(responseXml, CreateSessionResponseXml.class);
        Log.d(TAG, "Current sync session ID '" + response.serverSessionId + "'");

        // Session prüfen
        if (response.serverSessionId == null) {
            throw new ServiceException("Didn't get a server session ID");
        }

        SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(mContext);
        SharedPreferences.Editor editor = settings.edit();
        editor.putString(Constants.PREFERENCES_SYNC_SERVER_NAME, response.serverName);
        editor.putString(Constants.PREFERENCES_SYNC_SERVER_VERSION, response.serverVersion);
        editor.putString(Constants.PREFERENCES_SYNC_SERVER_HOST_DISPLAYNAME, response.serverHost);
        editor.putString(Constants.PREFERENCES_SYNC_SERVER_SESSION_ID, response.serverSessionId);
        editor.putString(Constants.PREFERENCES_SYNC_CLIENT_SESSION_ID, createSessionXml.clientSessionId);
        // Synchronisierungszeiten können nicht mehr genutzt werden
        editor.remove(Constants.PREFERENCES_SYNC_SERVER_TIME);
        editor.remove(Constants.PREFERENCES_SYNC_SERVER_SESSION_AUTHORIZED);
        editor.apply();
    }

    private String generateSessionId() {
        return new BigInteger(130, random).toString(32);
    }

    @NotNull
    private boolean[] checkSession(@NotNull String serverHostPort) throws ServiceException {
        SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(mContext);
        String serverSessionId = preferences.getString(Constants.PREFERENCES_SYNC_SERVER_SESSION_ID, null);
        String clientSessionId = preferences.getString(Constants.PREFERENCES_SYNC_CLIENT_SESSION_ID, null);

        // Anfrage schicken
        CheckSessionXml checkSessionXml = new CheckSessionXml();
        checkSessionXml.version = PROTOCOL_VERSION;
        checkSessionXml.serverSessionId = serverSessionId;
        String requestXml = generateXml(checkSessionXml);
        String responseXml = sendXmlRequest(serverHostPort, "/sync/check-session", requestXml);

        // Antwort auswerten
        CheckSessionResponseXml response = parseXml(responseXml, CheckSessionResponseXml.class);
        if (response.valid == null || !response.valid) {
            Log.d(TAG, "Sync server session ID '" + serverSessionId + "' is not valid any more");
            SharedPreferences.Editor editor = preferences.edit();
            editor.remove(Constants.PREFERENCES_SYNC_SERVER_SESSION_ID);
            editor.remove(Constants.PREFERENCES_SYNC_CLIENT_SESSION_ID);
            editor.apply();
            return new boolean[]{ false, false };
        }
        if (clientSessionId != null && !clientSessionId.equals(response.clientSessionId)) {
            Log.d(TAG, "Sync server authentication failed, client session ID does not match");
            SharedPreferences.Editor editor = preferences.edit();
            editor.remove(Constants.PREFERENCES_SYNC_SERVER_SESSION_ID);
            editor.remove(Constants.PREFERENCES_SYNC_CLIENT_SESSION_ID);
            editor.apply();
            return new boolean[]{ false, false };
        }

        SharedPreferences.Editor editor = preferences.edit();
        boolean authorized = response.authorized != null && response.authorized;
        editor.putBoolean(Constants.PREFERENCES_SYNC_SERVER_SESSION_AUTHORIZED, authorized);
        editor.apply();
        return new boolean[]{ true, authorized };
    }

    /**
     * Synchronisiert alle Repository-Dateien mit dem Wikiserver. Vorher wird geprüft, ob
     * die Server-Session noch gültig ist und wir mit demselben Server verbunden sind.
     *
     * @return Anzahl der synchronisierten Dateien.
     */
    public int synchronizeRepository(ProgressFeedback feedback) throws ServiceException {
        boolean sessionValid = createAndCheckSession();
        if (!sessionValid) {
            throw new ServiceException("No valid server session found");
        }

        String serverHostPort = getServerHostPort();
        if (serverHostPort == null) {
            throw new ServiceException("No wiki server configured");
        }

        SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(mContext);
        String serverSessionId = preferences.getString(Constants.PREFERENCES_SYNC_SERVER_SESSION_ID, null);
        if (serverSessionId == null) {
            throw new ServiceException("Not server session ID found");
        }
        long lastSyncServerTimeMs = preferences.getLong(Constants.PREFERENCES_SYNC_SERVER_TIME, 0);
        Date lastSyncServerTime = null;
        if (lastSyncServerTimeMs > 0) {
            lastSyncServerTime = new Date(lastSyncServerTimeMs);
        }

        // Anfrage schicken
        ListModifiedFilesXml listModifiedFilesXml = new ListModifiedFilesXml();
        listModifiedFilesXml.version = PROTOCOL_VERSION;
        listModifiedFilesXml.serverSessionId = serverSessionId;
        listModifiedFilesXml.lastSyncServerTime = DateUtils.formatUtcDate(lastSyncServerTime);
        String requestXml = generateXml(listModifiedFilesXml);
        String responseXml = sendXmlRequest(serverHostPort, "/sync/list-modified-files", requestXml);

        // Antwort auswerten
        ListModifiedFilesResponseXml response = parseXml(responseXml, ListModifiedFilesResponseXml.class);
        int fileCount = response.fileList.size();
        Log.d(TAG, "Downloading " + fileCount + " files from server");
        if (fileCount == 0) {
            // no files to download, cancel process
            return 0;
        }

        for (int i = 0; i < fileCount; i++) {
            feedback.progress(i, fileCount);
            SingleFileXml serverFile = response.fileList.get(i);
            try {
                downloadFileFromServer(serverHostPort, serverSessionId, serverFile.filePath);
            }
            catch (ServiceException e) {
                Log.w(TAG, "Error reading file from server, ignoring it", e);
            }
        }

        // Reset internal caches
        WikiEngineApplication app = (WikiEngineApplication) mContext.getApplicationContext();
        app.resetServices();

        // Update last sync time
        Date currentServerDate = DateUtils.parseUtcDate(response.currentServerTime);
        if (currentServerDate != null) {
            long newLastSyncServerTimeMs = currentServerDate.getTime();
            SharedPreferences.Editor editor = preferences.edit();
            editor.putLong(Constants.PREFERENCES_SYNC_SERVER_TIME, newLastSyncServerTimeMs);
            editor.apply();
        }

        return fileCount;
    }

    private void downloadFileFromServer(@NotNull String serverHostPort, @NotNull String serverSessionId, @NotNull String filePath) throws ServiceException {
        // Anfrage schicken
        ReadFileXml readFileXml = new ReadFileXml();
        readFileXml.version = PROTOCOL_VERSION;
        readFileXml.serverSessionId = serverSessionId;
        readFileXml.filePath = filePath;
        String requestXml = generateXml(readFileXml);
        String responseXml = sendXmlRequest(serverHostPort, "/sync/read-file", requestXml);

        // Datei aus Antwort speichern
        ReadFileResponseXml response = parseXml(responseXml, ReadFileResponseXml.class);
        byte[] fileContent = Base64.decode(response.content, Base64.NO_WRAP);
        Date fileTimestamp = DateUtils.parseUtcDate(response.timestamp);
        AnyFile anyFile = new AnyFile(filePath);
        repositoryService.writeBinaryFile(anyFile, fileContent, fileTimestamp);
        Log.d(TAG, "File '" + filePath + "' replaced by newer content from server");
    }

    @Nullable
    private String getServerHostPort() {
        PreferenceManager.getDefaultSharedPreferences(mContext);
        SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(mContext);

        String host = preferences.getString(Constants.PREFERENCES_SYNC_SERVER_HOST, null);
        if (host == null) {
            return null;
        }

        String port = preferences.getString(Constants.PREFERENCES_SYNC_SERVER_PORT, null);
        if (port == null || port.trim().isEmpty()) {
            port = Integer.toString(settings.getServerPort());
        }

        return host + ':' + port;
    }

    /**
     * Versucht den Wifi-Hostnamen zu ermitteln und gibt ihn zurück.
     */
    @Nullable
    private String getLocalHostname() {
        try {
            String wlanHostname = null;
            String ethHostname = null;
            Enumeration<NetworkInterface> networkInterfaces = NetworkInterface.getNetworkInterfaces();
            while (networkInterfaces.hasMoreElements()) {
                NetworkInterface ni = networkInterfaces.nextElement();
                Enumeration<InetAddress> inetAddresses = ni.getInetAddresses();
                while (inetAddresses.hasMoreElements()) {
                    InetAddress ia = inetAddresses.nextElement();
                    // Indikator: Hostname und Adresse sind unterschiedlich
                    // http://stackoverflow.com/questions/21898456/get-android-wifi-net-hostname-from-code
                    if (!ia.getCanonicalHostName().equals(ia.getHostAddress())) {
                        if ("wlan0".equals(ni.getDisplayName())) {
                            wlanHostname = ia.getCanonicalHostName();
                        } else if ("eth0".equals(ia.getHostAddress())) {
                            ethHostname = ia.getCanonicalHostName();
                        }
                    }
                }
            }
            if (wlanHostname != null) {
                Log.d(TAG, "Hostname (wlan0): " + wlanHostname);
                return wlanHostname;
            }
            if (ethHostname != null) {
                Log.d(TAG, "Hostname (eth0): " + ethHostname);
                return ethHostname;
            }
            Log.d(TAG, "No hostname found");
        } catch (SocketException e) {
            Log.e(TAG, "Error determining hostname", e);
        }
        return null;
    }

    @NotNull
    private String getDeviceName() {
        String manufacturer = Build.MANUFACTURER;
        String model = Build.MODEL;
        if (model.startsWith(manufacturer)) {
            return capitalize(model);
        } else {
            return capitalize(manufacturer) + " " + model;
        }
    }

    private String capitalize(String s) {
        if (s == null || s.isEmpty()) {
            return "";
        }
        return Character.toUpperCase(s.charAt(0)) + s.substring(1);
    }

    /**
     * Schickt einen XML-Request und liest die XML-Antwort ein.
     *
     * @param urlPath    HTTP-URL, nicht null
     * @param requestXml XML-Anfrage, nicht null.
     * @return XML-Antwort, nicht null.
     */
    @NotNull
    private String sendXmlRequest(@NotNull String serverHostPort, @NotNull String urlPath, @NotNull String requestXml) throws ServiceException {
        try {
            String url = "http://" + serverHostPort + urlPath;
            Log.d(TAG, "Request to " + url + ": " + truncateLogText(requestXml, 200));
            byte[] requestBytes = requestXml.getBytes(StandardCharsets.UTF_8);

            URI uri = new URI(url);
            HttpURLConnection conn = (HttpURLConnection) uri.toURL().openConnection();
            conn.setRequestMethod("POST");
            conn.setRequestProperty("Content-Type", "text/xml");
            conn.setRequestProperty("Content-Length", Integer.toString(requestBytes.length));
            conn.setUseCaches(false);
            conn.setDoOutput(true);
            conn.setDoInput(true);
            conn.setConnectTimeout(1000); // 1 Sekunde
            conn.setReadTimeout(10000); // 10 Sekunden
            conn.connect();

            OutputStream out = conn.getOutputStream();
            out.write(requestBytes);
            out.flush();

            InputStream in = conn.getInputStream();
            ByteArrayOutputStream byteStream = new ByteArrayOutputStream(in.available());
            int bytesRead;
            byte[] buffer = new byte[1024];
            while ((bytesRead = in.read(buffer)) != -1) {
                byteStream.write(buffer, 0, bytesRead);
            }
            byte[] responseBytes = byteStream.toByteArray();

            String responseXml = new String(responseBytes, StandardCharsets.UTF_8);
            Log.d(TAG, "Response: " + truncateLogText(responseXml, 400));
            return responseXml;
        } catch (Exception e) {
            Log.e(TAG, "Error sending XML request", e);
            throw new ServiceException("Error sending XML request", e);
        }
    }

    private String truncateLogText(String logText, int maxLength) {
        if (logText.length() <= maxLength) {
            return logText;
        }
        return logText.substring(0, maxLength) + '…';
    }

    /**
     * Wandelt eine JAXB-Bean in einen XML-Strom um.
     */
    @NotNull
    private String generateXml(@NotNull AbstractSyncXml xmlBean) throws ServiceException {
        XmlGenerator xmlGenerator = new XmlGenerator(logger);
        return xmlGenerator.generate(xmlBean);
    }

    /**
     * Wandelt einen XML-Strom in eine JAXB-Bean um.
     */
    @NotNull
    private <T extends AbstractSyncXml> T parseXml(@NotNull String xml, @NotNull Class<T> xmlBeanType) throws ServiceException {
        try {
            XmlParser xmlParser = new XmlParser(logger);
            return xmlParser.parse(xml, xmlBeanType);
        } catch (ServiceException e) {
            logger.write("Failed to parse XML for class " + xmlBeanType.getSimpleName() + ", try class ErrorResponseXml", e);
            // Versuche eine Fehlerantwort zu parsen
            XmlParser xmlParser = new XmlParser(logger);
            ErrorResponseXml errorResponseXml = xmlParser.parse(xml, ErrorResponseXml.class);
            throw new ServiceException(errorResponseXml.message);
        }
    }

    /**
     * Interface, um den Fortschritt während der Synchronisierung mit dem Server
     * zurückzuübermitteln.
     */
    @FunctionalInterface
    public interface ProgressFeedback {
        void progress(int step, int total);
    }
}
