package ctbrec.io;

import com.fasterxml.jackson.core.type.TypeReference;
import ctbrec.Config;
import ctbrec.LoggingInterceptor;
import ctbrec.Settings.ProxyType;
import ctbrec.io.json.ObjectMapperFactory;
import ctbrec.io.json.dto.CookieDto;
import ctbrec.io.json.mapper.CookieMapper;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import okhttp3.*;
import okhttp3.OkHttpClient.Builder;
import org.mapstruct.factory.Mappers;

import javax.net.ssl.*;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.net.Authenticator;
import java.net.PasswordAuthentication;
import java.nio.file.Files;
import java.security.KeyManagementException;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.security.cert.X509Certificate;
import java.text.NumberFormat;
import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import java.util.zip.GZIPInputStream;

import static ctbrec.io.HttpConstants.ACCEPT_ENCODING_GZIP;
import static ctbrec.io.HttpConstants.CONTENT_ENCODING;
import static java.nio.charset.StandardCharsets.UTF_8;

@Slf4j
public abstract class HttpClient {
    private static final ConnectionPool GLOBAL_HTTP_CONN_POOL = new ConnectionPool(10, 2, TimeUnit.MINUTES);

    protected OkHttpClient client;
    protected Cache cache;
    protected CookieJarImpl cookieJar;
    protected Config config;
    protected boolean loggedIn = false;
    private final String name;

    protected HttpClient(String name, Config config) {
        this.name = name;
        this.config = config;
        cookieJar = createCookieJar();
        reconfigure();
    }

    protected CookieJarImpl createCookieJar() {
        return new CookieJarImpl();
    }

    private void loadProxySettings() {
        ProxyType proxyType = config.getSettings().proxyType;
        switch (proxyType) {
            case HTTP:
                System.setProperty("http.proxyHost", config.getSettings().proxyHost);
                System.setProperty("http.proxyPort", config.getSettings().proxyPort);
                System.setProperty("https.proxyHost", config.getSettings().proxyHost);
                System.setProperty("https.proxyPort", config.getSettings().proxyPort);
                if (config.getSettings().proxyUser != null && !config.getSettings().proxyUser.isEmpty()) {
                    String username = config.getSettings().proxyUser;
                    String password = config.getSettings().proxyPassword;
                    System.setProperty("http.proxyUser", username);
                    System.setProperty("http.proxyPassword", password);
                }
                break;
            case SOCKS4:
                System.setProperty("socksProxyVersion", "4");
                System.setProperty("socksProxyHost", config.getSettings().proxyHost);
                System.setProperty("socksProxyPort", config.getSettings().proxyPort);
                break;
            case SOCKS5:
                System.setProperty("socksProxyVersion", "5");
                System.setProperty("socksProxyHost", config.getSettings().proxyHost);
                System.setProperty("socksProxyPort", config.getSettings().proxyPort);
                if (config.getSettings().proxyUser != null && !config.getSettings().proxyUser.isEmpty()) {
                    String username = config.getSettings().proxyUser;
                    String password = config.getSettings().proxyPassword;
                    Authenticator.setDefault(new SocksProxyAuth(username, password));
                }
                break;
            case DIRECT:
            default:
                System.clearProperty("http.proxyHost");
                System.clearProperty("http.proxyPort");
                System.clearProperty("https.proxyHost");
                System.clearProperty("https.proxyPort");
                System.clearProperty("socksProxyVersion");
                System.clearProperty("socksProxyHost");
                System.clearProperty("socksProxyPort");
                System.clearProperty("java.net.socks.username");
                System.clearProperty("java.net.socks.password");
                System.clearProperty("http.proxyUser");
                System.clearProperty("http.proxyPassword");
                break;
        }
    }

    public Response execute(Request req) throws IOException {
        if (cache != null) {
            log.trace("Cache hit ratio {}/{} = {}", cache.hitCount(), cache.requestCount(), NumberFormat.getPercentInstance().format(cache.hitCount() / (double) cache.requestCount()));
        }
        Response resp = client.newCall(req).execute();
        return resp;
    }

    public Response execute(Request request, int timeoutInMillis) throws IOException {
        if (cache != null) {
            log.trace("Cache hit ratio {}/{} = {}", cache.hitCount(), cache.requestCount(), NumberFormat.getPercentInstance().format(cache.hitCount() / (double) cache.requestCount()));
        }
        return client.newBuilder() //
                .connectTimeout(timeoutInMillis, TimeUnit.MILLISECONDS) //
                .readTimeout(timeoutInMillis, TimeUnit.MILLISECONDS).build() //
                .newCall(request).execute();
    }

    public abstract boolean login() throws IOException;

    public void reconfigure() {
        loadProxySettings();
        loadCookies();
        Builder builder = new OkHttpClient.Builder()
                .cookieJar(cookieJar)
                .connectionPool(GLOBAL_HTTP_CONN_POOL)
                .connectTimeout(config.getSettings().httpTimeout, TimeUnit.MILLISECONDS)
                .readTimeout(config.getSettings().httpTimeout, TimeUnit.MILLISECONDS)
                .addNetworkInterceptor(new LoggingInterceptor());

        long cacheSize = (long) config.getSettings().thumbCacheSize * 1024 * 1024;
        if (cacheSize > 0) {
            File configDir = config.getConfigDir();
            File cacheDir = new File(configDir, "cache");
            cache = new Cache(cacheDir, cacheSize);
            builder.cache(cache);
        }

        ProxyType proxyType = config.getSettings().proxyType;
        if (proxyType == ProxyType.HTTP) {
            String username = config.getSettings().proxyUser;
            String password = config.getSettings().proxyPassword;
            if (username != null && !username.isEmpty()) {
                builder.proxyAuthenticator(createHttpProxyAuthenticator(username, password));
            }
        }

        // if transport layer security (TLS) is switched on, accept the self-signed cert from the server
        if (config.getSettings().transportLayerSecurity) {
            acceptAllTlsCerts(builder);
        }

        client = builder.build();
    }

    /**
     * This is a very simple and insecure solution to accept the self-signed cert from
     * the server. The side effect is, that certificates from other servers are neither checked!
     * TODO Delegate to the default trustmanager, if it is not the self-signed cert
     */
    private void acceptAllTlsCerts(Builder builder) {
        X509TrustManager x509TrustManager = new X509TrustManager() {
            @Override
            public X509Certificate[] getAcceptedIssuers() {
                X509Certificate[] x509Certificates = new X509Certificate[0];
                return x509Certificates;
            }

            @Override
            public void checkServerTrusted(final X509Certificate[] chain, final String authType) { /* noop*/ }

            @Override
            public void checkClientTrusted(final X509Certificate[] chain, final String authType) { /* noop*/ }
        };

        try {
            final TrustManager[] trustManagers = new TrustManager[]{x509TrustManager};
            final String PROTOCOL = "TLSv1.2";
            SSLContext sslContext = SSLContext.getInstance(PROTOCOL);
            KeyManager[] keyManagers = null;
            SecureRandom secureRandom = new SecureRandom();
            sslContext.init(keyManagers, trustManagers, secureRandom);
            SSLSocketFactory sslSocketFactory = sslContext.getSocketFactory();
            builder.sslSocketFactory(sslSocketFactory, x509TrustManager);
            builder.hostnameVerifier((hostname, sslSession) -> true);
        } catch (KeyManagementException | NoSuchAlgorithmException e) {
            log.error("Couldn't install trust managers for TLS connections");
        }
    }

    public void shutdown() {
        persistCookies();
        client.connectionPool().evictAll();
        client.dispatcher().executorService().shutdown();
    }

    private void persistCookies() {
        try {
            List<CookieContainer> containers = new ArrayList<>();
            cookieJar.getCookies().forEach((domain, cookieList) -> {
                CookieContainer cookies = new CookieContainer();
                cookies.setDomain(domain);
                List<CookieDto> dtos = cookieList.stream().map(Mappers.getMapper(CookieMapper.class)::toDto).toList();
                cookies.setCookies(dtos);
                containers.add(cookies);
            });
            String json = ObjectMapperFactory.getMapper().writeValueAsString(containers);
            File cookieFile = new File(config.getConfigDir(), "cookies-" + name + ".json");
            Files.writeString(cookieFile.toPath(), json);
        } catch (Exception e) {
            log.error("Couldn't persist cookies for {}", name, e);
        }
    }

    private void loadCookies() {
        try {
            File cookieFile = new File(config.getConfigDir(), "cookies-" + name + ".json");
            if (!cookieFile.exists()) {
                return;
            }
            String json = Files.readString(cookieFile.toPath());
            Map<String, List<Cookie>> cookies = cookieJar.getCookies();
            List<CookieContainer> fromJson = ObjectMapperFactory.getMapper().readValue(json, new TypeReference<>() {
            });
            for (CookieContainer container : fromJson) {
                List<Cookie> filteredCookies = container.getCookies().stream()
                        .filter(c -> !Objects.equals("deleted", c.getValue()))
                        .map(Mappers.getMapper(CookieMapper.class)::toCookie)
                        .collect(Collectors.toList());
                cookies.put(container.getDomain(), filteredCookies);
            }
        } catch (Exception e) {
            log.error("Couldn't load cookies for {}", name, e);
        }
    }

    @Data
    public static class CookieContainer {
        private String domain;
        private List<CookieDto> cookies;
    }

    private okhttp3.Authenticator createHttpProxyAuthenticator(String username, String password) {
        return (route, response) -> {
            String credential = Credentials.basic(username, password);
            return response.request().newBuilder().header("Proxy-Authorization", credential).build();
        };
    }

    public static class SocksProxyAuth extends Authenticator {
        private final PasswordAuthentication auth;

        private SocksProxyAuth(String user, String password) {
            auth = new PasswordAuthentication(user, password == null ? new char[]{} : password.toCharArray());
        }

        @Override
        protected PasswordAuthentication getPasswordAuthentication() {
            return auth;
        }
    }

    public CookieJarImpl getCookieJar() {
        return cookieJar;
    }

    public void logout() {
        getCookieJar().clear();
        loggedIn = false;
    }

    public WebSocket newWebSocket(Request request, WebSocketListener l) {
        return client.newWebSocket(request, l);
    }

    public List<Cookie> getCookiesByName(String... names) {
        List<Cookie> result = new ArrayList<>();
        Map<String, List<Cookie>> cookies = getCookieJar().getCookies();
        for (List<Cookie> cookieList : cookies.values()) {
            for (Cookie cookie : cookieList) {
                for (String cookieName : names) {
                    if (Objects.equals(cookieName, cookie.name())) {
                        result.add(cookie);
                    }
                }
            }
        }
        return result;
    }

    public static String bodyToJsonObject(Response response) {
        return Optional.ofNullable(response.body()).map(b -> {
            try {
                return b.string();
            } catch (IOException e) {
                return "{}";
            }
        }).orElse("{}");
    }

    public static String bodyToJsonArray(Response response) {
        return Optional.ofNullable(response.body()).map(b -> {
            try {
                return b.string();
            } catch (IOException e) {
                return "[]";
            }
        }).orElse("[]");
    }


    public static String gunzipBody(Response response) throws IOException {
        if (Objects.equals(response.header(CONTENT_ENCODING), ACCEPT_ENCODING_GZIP)) {
            GZIPInputStream gzipIn = new GZIPInputStream(Objects.requireNonNull(response.body()).byteStream());
            ByteArrayOutputStream bos = new ByteArrayOutputStream();
            byte[] b = new byte[1024];
            int len;
            while ((len = gzipIn.read(b)) >= 0) {
                bos.write(b, 0, len);
            }
            return bos.toString(UTF_8);
        } else {
            return Objects.requireNonNull(response.body()).string();
        }
    }

    public void clearCookies() {
        logout();
    }
}
