/*
 * Copyright (C) 2025-2026  Tim Zimmermann <tim@aglais.org>
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * 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 Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */

package org.aglais.zms.client;

import com.google.gson.Gson;
import org.aglais.zms.api.core.gson.JsonUtils;
import org.aglais.zms.api.core.model.SpeciesAncestryData;
import org.aglais.zms.db.core.Database;
import org.aglais.zms.db.core.model.AncestryData;
import org.aglais.zms.db.core.model.Species;
import org.aglais.zms.db.core.model.User;
import org.aglais.zms.util.Log;
import org.eclipse.jetty.client.*;
import org.eclipse.jetty.client.transport.HttpClientConnectionFactory;
import org.eclipse.jetty.client.transport.HttpClientTransportDynamic;
import org.eclipse.jetty.http.HttpHeader;
import org.eclipse.jetty.http.HttpMethod;
import org.eclipse.jetty.http.MimeTypes;
import org.eclipse.jetty.http.PreEncodedHttpField;
import org.eclipse.jetty.http2.client.HTTP2Client;
import org.eclipse.jetty.http2.client.transport.ClientConnectionFactoryOverHTTP2;
import org.eclipse.jetty.http3.client.HTTP3Client;
import org.eclipse.jetty.http3.client.HTTP3ClientQuicConfiguration;
import org.eclipse.jetty.http3.client.transport.ClientConnectionFactoryOverHTTP3;
import org.eclipse.jetty.io.ClientConnectionFactory;
import org.eclipse.jetty.io.ClientConnector;
import org.eclipse.jetty.quic.quiche.client.QuicheClientQuicConfiguration;
import org.eclipse.jetty.quic.quiche.client.QuicheTransport;
import org.eclipse.jetty.util.Promise;
import org.eclipse.jetty.util.SocketAddressResolver;
import org.eclipse.jetty.util.ssl.SslContextFactory;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

import java.io.IOException;
import java.net.*;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeoutException;

import static org.aglais.zms.api.core.APIConstants.*;

/**
 * Class representing a zms-server Database connection
 */
public class AglaisZMSClient extends Database {

    private static final String LOG_TAG = AglaisZMSClient.class.getSimpleName();

    private final String url;
    private String username;
    private char[] password;
    private final Gson gson = JsonUtils.createGsonInstance();
    private final List<IClientEventListener> eventListeners = new CopyOnWriteArrayList<>();

    private HttpClient client;
    private volatile ConnectionState connectionState = ConnectionState.DISCONNECTED;

    /**
     * Create a new {@link AglaisZMSClient} connection
     *
     * @param url      The URL to connect to
     * @param username The username to use for authentication
     * @param password The password to use for authentication
     */
    public AglaisZMSClient(String url, String username, char[] password) {
        if (url.endsWith("/"))
            url = url.substring(1, url.length() - 1);

        this.url = url;
        this.username = username;
        this.password = password;
    }

    @Override
    public boolean connect() {
        updateConnectionState(ConnectionState.CONNECTING, ConnectionChangeReason.CONNECT_REQUEST);

        SslContextFactory.Client sslContextFactory = new SslContextFactory.Client();

        ClientConnector connector = new ClientConnector();
        connector.setSslContextFactory(sslContextFactory);

        ClientConnectionFactory.Info http1 = HttpClientConnectionFactory.HTTP11;

        HTTP2Client http2Client = new HTTP2Client(connector);
        ClientConnectionFactoryOverHTTP2.HTTP2 http2 = new ClientConnectionFactoryOverHTTP2.HTTP2(http2Client);

        QuicheClientQuicConfiguration clientQuicConfig = HTTP3ClientQuicConfiguration.configure(
                new QuicheClientQuicConfiguration());
        HTTP3Client http3Client = new HTTP3Client(clientQuicConfig);
        QuicheTransport transport = new QuicheTransport(clientQuicConfig);
        ClientConnectionFactoryOverHTTP3.HTTP3 http3 = new ClientConnectionFactoryOverHTTP3.HTTP3(http3Client,
                transport);

        HttpClientTransportDynamic httpClientTransport = new HttpClientTransportDynamic(connector, http3, http2, http1);

        client = new HttpClient(httpClientTransport);
        client.setUserAgentField(new PreEncodedHttpField(HttpHeader.USER_AGENT, USER_AGENT));
        client.getAuthenticationStore().addAuthenticationResult(new BasicAuthentication.BasicResult(URI.create(url),
                username, new String(password)));
        client.setConnectTimeout(3000);
        client.setSocketAddressResolver(new SocketAddressResolver.Sync() {
            @Override
            public void resolve(String host, int port, Map<String, Object> context,
                                Promise<List<InetSocketAddress>> promise) {
                super.resolve(host, port, context, new Promise<>() {
                    @Override
                    public void succeeded(List<InetSocketAddress> result) {
                        // Prefer IPv6
                        result.sort((a, b) -> Boolean.compare(
                                b.getAddress() instanceof Inet6Address,
                                a.getAddress() instanceof Inet6Address
                        ));

                        promise.succeeded(result);
                    }

                    @Override
                    public void failed(Throwable x) {
                        promise.failed(x);
                    }
                });
            }
        });
        client.getHttpClientTransport().setConnectionPoolFactory(destination -> new RoundRobinConnectionPool(
                destination,
                client.getMaxConnectionsPerDestination(),
                1
        ));
        try {
            client.start();
        } catch (Exception e) {
            Log.e(LOG_TAG, "Failed to start client", e);

            return false;
        }

        return isConnected();
    }

    @Override
    public boolean isConnected() {
        if (client != null) {
            try {
                String magic = request(URL_API_HEALTH_CHECK, String.class);

                if (!API_HEALTH_CHECK_MAGIC.equals(magic)) {
                    Log.e(LOG_TAG, "Received unexpected magic from health check: " + magic);
                    updateConnectionState(ConnectionState.DISCONNECTED, ConnectionChangeReason.INVALID_MAGIC);
                    return false;
                }

                updateConnectionState(ConnectionState.CONNECTED, ConnectionChangeReason.CONNECT_REQUEST);

                return true;
            } catch (IOException | ExecutionException | InterruptedException | TimeoutException e) {
                Log.e(LOG_TAG, "Failed to check connection state", e);
            }
        }

        return false;
    }

    @Override
    public void disconnect() {
        if (client == null)
            return;

        updateConnectionState(ConnectionState.DISCONNECTING, ConnectionChangeReason.DISCONNECT_REQUEST);

        try {
            client.close();
            client = null;

            updateConnectionState(ConnectionState.DISCONNECTED, ConnectionChangeReason.DISCONNECT_REQUEST);
        } catch (Exception e) {
            Log.e(LOG_TAG, "Failed to close http client", e);
        }
    }

    private boolean ensureConnected() {
        return client != null || connect();
    }

    private <T> T request(String path, HttpMethod method, Class<T> targetClass, Object parameters) throws IOException,
            ExecutionException, InterruptedException, TimeoutException {
        String body = gson.toJson(parameters);
        Request request = client.newRequest(URI.create(url + path)).method(method);
        if (method == HttpMethod.POST || method == HttpMethod.PUT)
            request.body(new StringRequestContent(MimeTypes.Type.APPLICATION_JSON.asString(), body));

        try {
            ContentResponse response = request.send();

            if (response.getStatus() != HttpURLConnection.HTTP_OK) {
                if (response.getStatus() == HttpURLConnection.HTTP_UNAUTHORIZED)
                    updateConnectionState(ConnectionState.DISCONNECTED, ConnectionChangeReason.AUTHENTICATION_FAILURE);

                throw new IOException("Unexpected HTTP status: " + response.getStatus());
            }

            return gson.fromJson(response.getContentAsString(), targetClass);
        } catch (ExecutionException | TimeoutException | InterruptedException e) {
            if (e.getCause() instanceof SocketException || e.getCause() instanceof TimeoutException) {
                updateConnectionState(ConnectionState.DISCONNECTED, ConnectionChangeReason.NETWORK_FAILURE);
            }

            throw e;
        }
    }

    private <T> T request(String url, Class<T> targetClass) throws IOException, ExecutionException,
            InterruptedException, TimeoutException {
        return request(url, HttpMethod.GET, targetClass, null);
    }

    @Override
    public int getVersion() {
        if (ensureConnected()) {
            try {
                return request(URL_API_VERSION_CURRENT, Integer.class);
            } catch (IOException | ExecutionException | InterruptedException | TimeoutException e) {
                Log.e(LOG_TAG, "Failed to query version, assuming not installed", e);
            }
        }

        return 0;
    }

    @Override
    public int getRequiredVersion() {
        return getCurrentClientVersion();
    }

    @Override
    public boolean upgrade(int version) {
        throw new UnsupportedOperationException(getClass().getName() + " cannot be upgraded automatically");
    }

    @Override
    public Species addSpecies(@NotNull Species species) {
        if (ensureConnected()) {
            try {
                return request(URL_DB_ADD_SPECIES, HttpMethod.POST, Species.class, species);
            } catch (IOException | ExecutionException | InterruptedException | TimeoutException e) {
                Log.e(LOG_TAG, "Failed to add species to db", e);
            }
        }

        return null;
    }

    @Override
    public List<Species> getAllSpecies() {
        List<Species> species = new ArrayList<>();

        if (ensureConnected()) {
            try {
                species.addAll(List.of(request(URL_DB_ALL_SPECIES, Species[].class)));
            } catch (IOException | ExecutionException | InterruptedException | TimeoutException e) {
                Log.e(LOG_TAG, "Failed to query all species", e);
            }
        }

        return species;
    }

    @Override
    public boolean deleteSpecies(Species species) {
        if (ensureConnected()) {
            try {
                return request(URL_DB_DELETE_SPECIES, HttpMethod.POST, Boolean.class, species);
            } catch (IOException | ExecutionException | InterruptedException | TimeoutException e) {
                Log.e(LOG_TAG, "Failed to delete species from db", e);
            }
        }

        return false;
    }

    @Override
    public boolean updateSpecies(Species species) {
        if (ensureConnected()) {
            try {
                return request(URL_DB_UPDATE_SPECIES, HttpMethod.PUT, Boolean.class, species);
            } catch (IOException | ExecutionException | InterruptedException | TimeoutException e) {
                Log.e(LOG_TAG, "Failed to update species in db", e);
            }
        }

        return false;
    }

    @Override
    public List<AncestryData> getSpeciesAncestry(Species species) {
        List<AncestryData> ancestryData = new ArrayList<>();

        if (ensureConnected()) {
            try {
                ancestryData.addAll(List.of(request(URL_DB_SPECIES_ANCESTRY, HttpMethod.POST, AncestryData[].class,
                        species)));
            } catch (IOException | ExecutionException | InterruptedException | TimeoutException e) {
                Log.e(LOG_TAG, "Failed to query species ancestry nodes", e);
            }
        }

        return ancestryData;
    }

    @Override
    public boolean updateSpeciesAncestry(Species species, List<AncestryData> ancestry) {
        if (ensureConnected()) {
            try {
                return request(URL_DB_UPDATE_SPECIES_ANCESTRY, HttpMethod.PUT, Boolean.class,
                        new SpeciesAncestryData(species, ancestry));
            } catch (IOException | ExecutionException | InterruptedException | TimeoutException e) {
                Log.e(LOG_TAG, "Failed to update species ancestry", e);
            }
        }

        return false;
    }

    @Override
    public User addUser(@NotNull User user) {
        if (ensureConnected()) {
            try {
                return request(URL_DB_ADD_USER, HttpMethod.POST, User.class, user);
            } catch (IOException | ExecutionException | InterruptedException | TimeoutException e) {
                Log.e(LOG_TAG, "Failed to add user to db", e);
            }
        }

        return null;
    }

    @Override
    public List<User> getAllUsers() {
        List<User> users = new ArrayList<>();

        if (ensureConnected()) {
            try {
                users.addAll(List.of(request(URL_DB_ALL_USERS, User[].class)));
            } catch (IOException | ExecutionException | InterruptedException | TimeoutException e) {
                Log.e(LOG_TAG, "Failed to query all users", e);
            }
        }

        return users;
    }

    @Override
    public boolean deleteUser(User user) {
        if (ensureConnected()) {
            try {
                return request(URL_DB_DELETE_USER, HttpMethod.POST, Boolean.class, user);
            } catch (IOException | ExecutionException | InterruptedException | TimeoutException e) {
                Log.e(LOG_TAG, "Failed to delete user from db", e);
            }
        }

        return false;
    }

    @Override
    public boolean updateUser(User user) {
        if (ensureConnected()) {
            try {
                return request(URL_DB_UPDATE_USER, HttpMethod.PUT, Boolean.class, user);
            } catch (IOException | ExecutionException | InterruptedException | TimeoutException e) {
                Log.e(LOG_TAG, "Failed to update user in db", e);
            }
        }

        return false;
    }

    @Override
    public @Nullable User getUserByName(String name) {
        if (ensureConnected()) {
            try {
                return request(URL_DB_USER_BY_NAME, HttpMethod.POST, User.class, name);
            } catch (IOException | ExecutionException | InterruptedException | TimeoutException e) {
                Log.e(LOG_TAG, "Failed to query user by name", e);
            }
        }

        return null;
    }

    @Override
    public @Nullable User getUserByID(int id) {
        if (ensureConnected()) {
            try {
                return request(URL_DB_USER_BY_ID, HttpMethod.POST, User.class, id);
            } catch (IOException | ExecutionException | InterruptedException | TimeoutException e) {
                Log.e(LOG_TAG, "Failed to query user by id", e);
            }
        }

        return null;
    }

    /**
     * Get the client's current version
     *
     * @return The client's currently implemented API version
     */
    public int getCurrentClientVersion() {
        return API_VERSION_CURRENT;
    }

    /**
     * Get the server's minimum supported client version
     *
     * @return The server's minimum supported client version
     */
    public int getMinSupportedClientVersion() {
        if (ensureConnected()) {
            try {
                return request(URL_API_VERSION_MIN, Integer.class);
            } catch (IOException | ExecutionException | InterruptedException | TimeoutException e) {
                Log.e(LOG_TAG, "Failed to query server minimum client version, assuming none", e);
            }
        }

        return 0;
    }

    /**
     * Get the client's minimum supported server version
     *
     * @return The client's minimum supported server version
     */
    public int getMinSupportedServerVersion() {
        return API_VERSION_MIN;
    }

    /**
     * Get the own user
     *
     * @return The currently used {@link User} as reported by the server
     */
    public User getSelf() {
        try {
            return request(URL_API_USER_SELF, User.class);
        } catch (IOException | ExecutionException | InterruptedException | TimeoutException e) {
            Log.e(LOG_TAG, "Failed to fetch own user information", e);
        }

        return null;
    }

    /**
     * Update the credentials used by the client
     *
     * @param username New username to use, null to keep current
     * @param password New password to use, null to keep current
     * @return True if the new username was successfully applied
     */
    public boolean setCredentials(@Nullable String username, char @Nullable [] password) {
        if (username != null)
            this.username = username;
        if (password != null)
            this.password = password;

        if (client != null) {
            disconnect();
            return connect();
        }

        return true;
    }

    /**
     * Registers a {@link IClientEventListener} to be notified of client events.
     *
     * @param listener The listener to be notified
     */
    public void registerListener(@NotNull IClientEventListener listener) {
        eventListeners.add(listener);
    }

    /**
     * Unregisters a previously registered {@link IClientEventListener}.
     *
     * @param listener The listener to be removed
     */
    public void unregisterListener(@NotNull IClientEventListener listener) {
        eventListeners.remove(listener);
    }

    /**
     * Update the internal connection state and notify the registered connection listeners if needed
     *
     * @param newConnectionState The new connection state, true if connected
     */
    private void updateConnectionState(ConnectionState newConnectionState, ConnectionChangeReason reason) {
        if (connectionState != newConnectionState) {
            connectionState = newConnectionState;

            for (IClientEventListener listener : eventListeners)
                listener.onConnectionStateChanged(connectionState, reason);
        }
    }
}
