/*
 * substitution-schedule-parser - Java library for parsing schools' substitution schedules
 * Copyright (c) 2016 Johan v. Forstner
 *
 * This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0.
 * If a copy of the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/.
 */

package me.vertretungsplan.parser;

import com.github.sardine.Sardine;
import com.github.sardine.impl.SardineImpl;
import com.mifmif.common.regex.Generex;
import me.vertretungsplan.exception.CredentialInvalidException;
import me.vertretungsplan.networking.MultiTrustManager;
import me.vertretungsplan.objects.SubstitutionSchedule;
import me.vertretungsplan.objects.SubstitutionScheduleData;
import me.vertretungsplan.objects.credential.Credential;
import me.vertretungsplan.objects.credential.UserPasswordCredential;
import org.apache.http.NameValuePair;
import org.apache.http.client.CookieStore;
import org.apache.http.client.HttpResponseException;
import org.apache.http.client.config.CookieSpecs;
import org.apache.http.client.config.RequestConfig;
import org.apache.http.client.fluent.Executor;
import org.apache.http.client.fluent.Request;
import org.apache.http.conn.socket.ConnectionSocketFactory;
import org.apache.http.conn.ssl.DefaultHostnameVerifier;
import org.apache.http.conn.ssl.NoopHostnameVerifier;
import org.apache.http.conn.ssl.SSLConnectionSocketFactory;
import org.apache.http.entity.ContentType;
import org.apache.http.impl.client.BasicCookieStore;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.impl.client.LaxRedirectStrategy;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.joda.time.LocalDateTime;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import org.mozilla.universalchardet.UniversalDetector;

import javax.net.ssl.*;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.*;
import java.security.cert.CertificateException;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * Base class for {@link SubstitutionScheduleParser} implementations.
 */
public abstract class BaseParser implements SubstitutionScheduleParser {
    public static final String PARAM_CLASS_REGEX = "classRegex";
    private static final String PARAM_SSL_HOSTNAME = "sslHostname";
    private static final String PARAM_SSL_VERIFY_HOSTNAME = "sslVerifyHostname";
    static final String PARAM_CLASS_RANGES = "classRanges";
    static final String PARAM_HEADERS = "headers";
    static final String CLASS_RANGES_CLASS_REGEX = "classRegex";
    static final String CLASS_RANGES_GRADE_REGEX = "gradeRegex";
    static final String CLASS_RANGES_RANGE_FORMAT = "rangeFormat";
    static final String CLASS_RANGES_SINGLE_FORMAT = "singleFormat";

    protected SubstitutionScheduleData scheduleData;
    protected Executor executor;
    protected Credential credential;
    protected CookieStore cookieStore;
    protected ColorProvider colorProvider;
    protected CookieProvider cookieProvider;
    protected UniversalDetector encodingDetector;
    protected DebuggingDataHandler debuggingDataHandler;
    protected Sardine sardine;
    private Path localSource;

    BaseParser(SubstitutionScheduleData scheduleData, CookieProvider cookieProvider) {
        this.scheduleData = scheduleData;
        this.cookieProvider = cookieProvider;
        this.cookieStore = new BasicCookieStore();
        this.colorProvider = new ColorProvider(scheduleData);
        this.encodingDetector = new UniversalDetector(null);
        this.debuggingDataHandler = new NoOpDebuggingDataHandler();
        this.sardine = null;

        try {
            SSLConnectionSocketFactory sslsf = getSslConnectionSocketFactory(scheduleData);

            CloseableHttpClient httpclient = HttpClients.custom()
                    .setSSLSocketFactory(sslsf)
                    .setRedirectStrategy(new LaxRedirectStrategy())
                    .setDefaultRequestConfig(RequestConfig.custom()
                            .setCookieSpec(CookieSpecs.STANDARD).build())
                    .setUserAgent(
                            "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.169 Safari/537.36")
                    .build();
            this.executor = Executor.newInstance(httpclient).use(cookieStore);
        } catch (GeneralSecurityException | JSONException | IOException e) {
            throw new RuntimeException(e);
        }
    }

    @NotNull private SSLConnectionSocketFactory getSslConnectionSocketFactory(SubstitutionScheduleData scheduleData)
            throws IOException, GeneralSecurityException, JSONException {
        KeyStore ks = loadKeyStore();
        MultiTrustManager multiTrustManager = new MultiTrustManager();
        multiTrustManager.addTrustManager(getDefaultTrustManager());
        multiTrustManager.addTrustManager(trustManagerFromKeystore(ks));

        TrustManager[] trustManagers = new TrustManager[]{multiTrustManager};
        SSLContext sslContext = SSLContext.getInstance("TLS");
        sslContext.init(null, trustManagers, null);
        final HostnameVerifier hostnameVerifier;

        if (scheduleData.getData() != null && scheduleData.getData().has(PARAM_SSL_HOSTNAME)) {
            hostnameVerifier = new CustomHostnameVerifier(scheduleData.getData().getString(PARAM_SSL_HOSTNAME));
        } else if (scheduleData.getData() != null &&
                !scheduleData.getData().optBoolean(PARAM_SSL_VERIFY_HOSTNAME, true)) {
            hostnameVerifier = new NoopHostnameVerifier();
        } else {
            hostnameVerifier = new DefaultHostnameVerifier();
        }
        return new SSLConnectionSocketFactory(
                sslContext,
                new String[]{"TLSv1", "TLSv1.1", "TLSv1.2"},
                null,
                hostnameVerifier);
    }

    /**
     * Create an appropriate parser for a given school. Automatically uses the appropriate subclass depending on
     * {@link SubstitutionScheduleData#getApi()}.
     *
     * @param data a {@link SubstitutionScheduleData} object containing information about the substitution schedule
     * @return a {@link BaseParser} subclass able to parse the given schedule.
     */
    public static BaseParser getInstance(SubstitutionScheduleData data, @Nullable CookieProvider cookieProvider) {
        BaseParser parser = null;
        switch (data.getApi()) {
            case "untis-monitor":
                parser = new UntisMonitorParser(data, cookieProvider);
                break;
            case "untis-info":
                parser = new UntisInfoParser(data, cookieProvider);
                break;
            case "untis-info-headless":
                parser = new UntisInfoHeadlessParser(data, cookieProvider);
                break;
            case "untis-subst":
                parser = new UntisSubstitutionParser(data, cookieProvider);
                break;
            case "dsbmobile":
                parser = new NotCompatibleParser(data, cookieProvider);
                break;
            case "dsblight":
                parser = new NotCompatibleParser(data, cookieProvider);
                break;
            case "svplan":
                parser = new SVPlanParser(data, cookieProvider);
                break;
            case "davinci":
                parser = new DaVinciParser(data, cookieProvider);
                break;
            case "eschool":
                parser = new ESchoolParser(data, cookieProvider);
                break;
            case "turbovertretung":
                parser = new TurboVertretungParser(data, cookieProvider);
                break;
            case "csv":
                parser = new CSVParser(data, cookieProvider);
                break;
            case "legionboard":
                parser = new LegionBoardParser(data, cookieProvider);
                break;
            case "iphis":
                parser = new IphisParser(data, cookieProvider);
                break;
            case "indiware":
                parser = new IndiwareParser(data, cookieProvider);
                break;
            case "stundenplan24":
                parser = new IndiwareStundenplan24Parser(data, cookieProvider);
                break;
            case "indiware-mobile":
                parser = new IndiwareMobileParser(data, cookieProvider);
                break;
            case "schooljoomla":
                parser = new SchoolJoomlaParser(data, cookieProvider);
                break;
        }
        return parser;
    }

    protected Sardine getWebdavClient(UserPasswordCredential credential)
            throws JSONException, GeneralSecurityException, IOException {
        if (sardine == null) {
            final SSLConnectionSocketFactory sslsf = getSslConnectionSocketFactory(scheduleData);
            sardine = new SardineImpl() {
                @Override protected ConnectionSocketFactory createDefaultSecureSocketFactory() {
                    return sslsf;
                }
            };
            if (credential != null) {
                sardine.setCredentials(credential.getUsername(), credential.getPassword());
            }
        }
        return sardine;
    }

    @Override public LocalDateTime getLastChange() throws IOException, JSONException, CredentialInvalidException {
        // default implementation returns null
        return null;
    }

    private static X509TrustManager getDefaultTrustManager()
            throws GeneralSecurityException {
        return trustManagerFromKeystore(null);
    }

    private static X509TrustManager trustManagerFromKeystore(
            final KeyStore keystore) throws GeneralSecurityException {
        final TrustManagerFactory trustManagerFactory = TrustManagerFactory
                .getInstance("PKIX", "SunJSSE");
        trustManagerFactory.init(keystore);

        final TrustManager[] tms = trustManagerFactory.getTrustManagers();

        for (final TrustManager tm : tms) {
            if (tm instanceof X509TrustManager) {
                return X509TrustManager.class.cast(tm);
            }
        }
        throw new IllegalStateException("Could not locate X509TrustManager!");
    }

    protected static String recognizeType(String text) {
        if (text.toLowerCase().contains("f.a.") || text.toLowerCase().contains("fällt aus")
                || text.toLowerCase().contains("faellt aus") || text.toLowerCase().contains("entfällt") || text
                .toLowerCase().contains("entfall")) {
            return "Entfall";
        } else if (equalsOneOf(text, "Raumänderung", "Klasse frei", "Unterrichtstausch", "Freistunde", "Raumverlegung",
                "Selbstlernen", "Zusammenlegung", "HA", "Raum beachten", "Stundentausch", "Klausur", "Raum-Vertr.",
                "Betreuung", "Frei/Veranstaltung", "Raumwechsel", "selbstständiges Arbeiten")) {
            return text;
        } else if (text.startsWith("Ausfallstunde:")) {
            return "Ausfallstunde";
        } else if (text.startsWith("Raumwechsel/ Stillarbeit:")) {
            return "Raumwechsel/ Stillarbeit";
        } else if (text.startsWith("Stillarbeit:")) {
            return "Stillarbeit";
        } else if (text.contains("verschoben")) {
            return "Verlegung";
        } else if (text.contains("geänderter Raum")) {
            return "Raumänderung";
        } else if (text.contains("frei")) {
            return "Entfall";
        } else if (text.contains("Aufgaben")) {
            return "Aufgaben";
        } else {
            return null;
        }
    }

    private static boolean equalsOneOf(String container, String... strings) {
        for (String string : strings) {
            if (container.equals(string)) return true;
        }
        return false;
    }

    public abstract SubstitutionSchedule getSubstitutionSchedule()
            throws IOException, JSONException, CredentialInvalidException;

    /**
     * Get a list of all available classes.
     *
     * @return a list of all available classes (also those not currently affected by the substitution schedule)
     * @throws IOException Connection or parsing error
     * @throws JSONException Error with the JSON configuration
     */
    public abstract List<String> getAllClasses() throws IOException, JSONException, CredentialInvalidException;

    /**
     * Get a list of all available teachers. Can also be <code>null</code>.
     *
     * @return a list of all available teachers (also those not currently affected by the substitution schedule)
     * @throws IOException   Connection or parsing error
     * @throws JSONException Error with the JSON configuration
     */
    @SuppressWarnings("SameReturnValue")
    public abstract List<String> getAllTeachers() throws IOException, JSONException, CredentialInvalidException;

    public Credential getCredential() {
        return credential;
    }

    public void setCredential(Credential credential) {
        if (!scheduleData.getAuthenticationData().getCredentialType().equals(credential.getClass())) {
            throw new IllegalArgumentException("Wrong credential type");
        }
        this.credential = credential;
    }

    public void setDebuggingDataHandler(DebuggingDataHandler handler) {
        this.debuggingDataHandler = handler;
    }

    protected String httpGet(String url) throws IOException, CredentialInvalidException {
        return httpGet(url, null, null);
    }

    protected String httpGet(String url, String encoding) throws IOException, CredentialInvalidException {
        return httpGet(url, encoding, null);
    }

    protected String httpGet(String url, String encoding,
                             Map<String, String> headers) throws IOException, CredentialInvalidException {
        if (url.startsWith("local://")) {
            Path file = localSource.resolve(url.substring("local://".length()));
            byte[] bytes = Files.readAllBytes(file);
            encoding = getEncoding(encoding, bytes);
            return new String(bytes, encoding);
        } else {
            Request request = Request.Get(url).connectTimeout(getTimeout())
                    .socketTimeout(getTimeout());
            if (headers != null) {
                for (Entry<String, String> entry : headers.entrySet()) {
                    request.addHeader(entry.getKey(), entry.getValue());
                }
            }
            JSONObject jsonHeaders = scheduleData.getData().optJSONObject(PARAM_HEADERS);
            if (jsonHeaders != null) {
                for (String key : JSONObject.getNames(jsonHeaders)) {
                    request.addHeader(key, jsonHeaders.optString(key));
                }
            }
            return executeRequest(encoding, request);
        }
    }

    @Nullable private String executeRequest(String encoding, Request request)
            throws IOException, CredentialInvalidException {
        try {
            byte[] bytes = executor.execute(request).returnContent().asBytes();
            encoding = getEncoding(encoding, bytes);
            return new String(bytes, encoding);
        } catch (HttpResponseException e) {
            handleHttpResponseException(e);
            return null;
        } finally {
            encodingDetector.reset();
        }
    }

    @NotNull private String getEncoding(String defaultEncoding, byte[] bytes) {
        encodingDetector.handleData(bytes, 0, bytes.length);
        encodingDetector.dataEnd();
        String encoding = encodingDetector.getDetectedCharset();
        if (encoding == null || encoding.equals("GB18030")) encoding = defaultEncoding;
        if (encoding == null) encoding = "UTF-8";
        encodingDetector.reset();
        return encoding;
    }

    @SuppressWarnings("SameParameterValue")
    protected String httpPost(String url, String encoding,
                              List<NameValuePair> formParams) throws IOException, CredentialInvalidException {
        return httpPost(url, encoding, formParams, null);
    }

    protected String httpPost(String url, String encoding,
                              List<NameValuePair> formParams, Map<String, String> headers)
            throws IOException, CredentialInvalidException {
        Request request = Request.Post(url).bodyForm(formParams)
                .connectTimeout(getTimeout()).socketTimeout(getTimeout());
        if (headers != null) {
            for (Entry<String, String> entry : headers.entrySet()) {
                request.addHeader(entry.getKey(), entry.getValue());
            }
        }
        return executeRequest(encoding, request);
    }

    private int getTimeout() {
        return 30000;
    }

    @SuppressWarnings("SameParameterValue")
    protected String httpPost(String url, String encoding, String body, ContentType contentType)
            throws IOException, CredentialInvalidException {
        return httpPost(url, encoding, body, contentType, null);
    }

    @SuppressWarnings("SameParameterValue")
    protected String httpPost(String url, String encoding, String body, ContentType contentType,
                              Map<String, String> headers) throws IOException, CredentialInvalidException {
        Request request = Request.Post(url).bodyString(body, contentType)
                .connectTimeout(getTimeout()).socketTimeout(getTimeout());
        if (headers != null) {
            for (Entry<String, String> entry : headers.entrySet()) {
                request.addHeader(entry.getKey(), entry.getValue());
            }
        }
        return executeRequest(encoding, request);
    }

    private void handleHttpResponseException(HttpResponseException e)
            throws CredentialInvalidException, HttpResponseException {
        if (e.getStatusCode() == 401 || e.getStatusCode() == 403) {
            throw new CredentialInvalidException();
        } else {
            throw e;
        }
    }

    private KeyStore loadKeyStore() throws KeyStoreException,
            NoSuchAlgorithmException, CertificateException, IOException {
        Security.addProvider(new BouncyCastleProvider());
        InputStream is = null;
        try {
            KeyStore ks = KeyStore.getInstance("BKS");
            is = getClass().getClassLoader().getResourceAsStream(
                    "trustStore.bks");
            if (is == null) {
                throw new RuntimeException();
            }
            ks.load(is, "Vertretungsplan".toCharArray());
            return ks;
        } finally {
            if (is != null) {
                try {
                    is.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    static String getClassName(String text, JSONObject data) throws JSONException {
        text = text.replace("(", "").replace(")", "");
        if (data.has(PARAM_CLASS_REGEX)) {
            Pattern pattern = Pattern.compile(data.getString(PARAM_CLASS_REGEX));
            Matcher matcher = pattern.matcher(text);
            if (matcher.find()) {
                if (matcher.groupCount() > 0) {
                    return matcher.group(1);
                } else {
                    return matcher.group();
                }
            } else {
                return "";
            }
        } else {
            return text;
        }
    }

    protected static boolean contains(JSONArray array, String string) throws JSONException {
        for (int i = 0; i < array.length(); i++) {
            if (array.getString(i).equals(string)) {
                return true;
            }
        }
        return false;
    }


    @Nullable
    protected List<String> getClassesFromJson() throws JSONException {
        final JSONObject data = scheduleData.getData();
        return ParserUtils.getClassesFromJson(data);
    }

    static Set<String> handleClassRanges(String klasse, JSONObject data) throws JSONException {
        HashSet<String> classes = new HashSet<>();
        classes.add(klasse);
        return handleClassRanges(classes, data);
    }

    static Set<String> handleClassRanges(Set<String> classes, JSONObject data) throws JSONException {
        if (data == null || !data.has(PARAM_CLASS_RANGES)) return classes;
        JSONObject options = data.getJSONObject(PARAM_CLASS_RANGES);
        String rangeFormat = options.getString(CLASS_RANGES_RANGE_FORMAT);
        String singleFormat = options.getString(CLASS_RANGES_SINGLE_FORMAT);
        String classRegex = options.getString(CLASS_RANGES_CLASS_REGEX);
        String gradeRegex = options.getString(CLASS_RANGES_GRADE_REGEX);

        int gradePos = -1;
        int minClassPos = -1;
        int maxClassPos = -1;

        StringBuilder regex = new StringBuilder();
        int i = 0;
        for (char c: rangeFormat.toCharArray()) {
            switch (c) {
                case 'g':
                    if (gradePos == -1) {
                        regex.append("(").append(gradeRegex).append(")");
                        i++;
                        gradePos = i;
                    } else {
                        regex.append("\\").append(gradePos);
                    }
                    break;
                case 'c':
                    regex.append("(").append(classRegex).append(")");
                    i++;
                    if (minClassPos == -1) {
                        minClassPos = i;
                    } else if (maxClassPos == -1) {
                        maxClassPos = i;
                    } else {
                        throw new IllegalArgumentException("more than two classes in classRanges.rangeFormat");
                    }
                    break;
                default:
                    regex.append(c);
                    break;
            }
        }
        Pattern pattern = Pattern.compile(regex.toString());

        Set<String> processedClasses = new HashSet<>();
        for (String klasse:classes) {
            Matcher matcher = pattern.matcher(klasse);
            if (matcher.matches()) {
                String grade = matcher.group(gradePos);
                String minClass = matcher.group(minClassPos);
                String maxClass = matcher.group(maxClassPos);

                StringBuilder rangeRegex = new StringBuilder();
                for (char c: singleFormat.toCharArray()) {
                    switch (c) {
                        case 'g':
                            rangeRegex.append(grade);
                            break;
                        case 'c':
                            rangeRegex.append("[").append(minClass).append("-").append(maxClass).append("]");
                            break;
                        default:
                            rangeRegex.append(c);
                            break;
                    }
                }
                processedClasses.addAll(new Generex(rangeRegex.toString()).getAllMatchedStrings());
            } else {
                processedClasses.add(klasse);
            }
        }

        return processedClasses;
    }

    public void setLocalSource(Path localSource) {
        this.localSource = localSource;
    }

    private class CustomHostnameVerifier implements HostnameVerifier {
        private String host;
        private DefaultHostnameVerifier defaultHostnameVerifier;

        public CustomHostnameVerifier(String host) {
            this.host = host;
            this.defaultHostnameVerifier = new DefaultHostnameVerifier();
        }

        @Override public boolean verify(String s, SSLSession sslSession) {
            return defaultHostnameVerifier.verify(host, sslSession) |
                    defaultHostnameVerifier.verify(this.host, sslSession);
        }
    }

    public boolean isPersonal() {
        return false;
    }

    private class NoOpDebuggingDataHandler implements DebuggingDataHandler {
        @Override public void columnTitles(List<String> columnTitles) {

        }
    }
}