/*
 * Copyright (C) 2025  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.db.mysql;

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.DateUtil;
import org.aglais.zms.util.IntUtil;
import org.aglais.zms.util.Log;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

import java.sql.*;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;

/**
 * Class representing a MySQL Database connection
 */
public class MySQLDatabase extends Database {

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

    private static final String TABLE_AGLAIS_ZMS = "aglais_zms";
    private static final String TABLE_SPECIES = "species";
    private static final String TABLE_ANCESTRY_NODES = "ancestry_nodes";
    private static final String TABLE_ANCESTRY_RELATIONS = "ancestry_relations";
    private static final String TABLE_USERS = "users";

    private final String host;
    private final String database;
    private final String username;
    private final String password;

    private Connection conn;

    /**
     * Create a new {@link MySQLDatabase} connection
     *
     * @param host     The host to connect to
     * @param database The database to operate on
     * @param username The username to use for authentication
     * @param password The password to use for authentication
     */
    public MySQLDatabase(String host, String database, String username, String password) {
        this.host = host;
        this.database = database;
        this.username = username;
        this.password = password;
    }

    @Override
    public boolean connect() {
        try {
            conn = DriverManager.getConnection("jdbc:mysql://" + host + "/" + database
                            + "?useUnicode=true&useJDBCCompliantTimezoneShift=true&useLegacyDatetimeCode=false&serverTimezone=UTC",
                    username, password);
        } catch (SQLException e) {
            Log.e(LOG_TAG, "Failed to connect to database", e);
        }

        return isConnected();
    }

    @Override
    public boolean isConnected() {
        try {
            return conn != null && conn.isValid(3000);
        } catch (SQLException e) {
            Log.e(LOG_TAG, "Failed to check connection state", e);
        }

        return false;
    }

    @Override
    public void disconnect() {
        if (isConnected()) {
            try {
                conn.close();
            } catch (SQLException e) {
                Log.e(LOG_TAG, "Failed to disconnect", e);
            }
        }
    }

    private boolean ensureConnected() {
        return isConnected() || connect();
    }

    @Override
    public int getVersion() {
        if (ensureConnected()) {
            try {
                ResultSet rs = conn.createStatement().executeQuery("SELECT Version FROM " + TABLE_AGLAIS_ZMS);

                if (rs.next())
                    return rs.getInt("Version");
            } catch (SQLException e) {
                Log.e(LOG_TAG, "Failed to query version, assuming not installed", e);
            }
        }

        return 0;
    }

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

    @Override
    public boolean upgrade(int version) {
        boolean result = false;
        if (ensureConnected()) {
            try {
                switch (version) {
                    case 1:
                        result = conn.createStatement()
                                .executeUpdate("CREATE TABLE " + TABLE_AGLAIS_ZMS + " (Version INT)") == 0;
                        result = result && conn.createStatement()
                                .executeUpdate("INSERT INTO " + TABLE_AGLAIS_ZMS + " (Version) VALUES (0)") == 1;
                        result = result && conn.createStatement()
                                .executeUpdate("CREATE TABLE " + TABLE_SPECIES + " (ID INT PRIMARY KEY AUTO_INCREMENT, "
                                        + "CommonName VARCHAR(255), ScientificName VARCHAR(255) NOT NULL, Origin VARCHAR(255), " +
                                        "Notes TEXT, Image LONGBLOB)") == 0;
                        result = result && conn.createStatement()
                                .executeUpdate("CREATE TABLE " + TABLE_ANCESTRY_NODES + " (ID INT PRIMARY KEY AUTO_INCREMENT, "
                                        + "SpeciesID INT, DateStart DATE, DateEnd DATE, Notes TEXT, Generation varchar(255), "
                                        + "OriginLatitude DOUBLE, OriginLongitude DOUBLE, OriginRadius INT, "
                                        + "FOREIGN KEY(SpeciesID) REFERENCES species(ID))") == 0;
                        result = result && conn.createStatement()
                                .executeUpdate("CREATE TABLE " + TABLE_ANCESTRY_RELATIONS + " (ChildID INT, "
                                        + "ParentID INT, FOREIGN KEY(ChildID) REFERENCES " + TABLE_ANCESTRY_NODES
                                        + "(ID), FOREIGN KEY(ParentID) REFERENCES " + TABLE_ANCESTRY_NODES + "(ID))") == 0;
                        break;
                    case 2:
                        result = conn.createStatement()
                                .executeUpdate("CREATE TABLE " + TABLE_USERS + "(ID INT PRIMARY KEY AUTO_INCREMENT, "
                                        + "Name varchar(255), Password varchar(512))") == 0;
                        result = result && createDefaultUser();
                        break;
                }
            } catch (SQLException e) {
                result = false;
                Log.e(LOG_TAG, "Failed to upgrade db to v" + version, e);
            }

            if (result) {
                try {
                    result = conn.createStatement()
                            .executeUpdate("UPDATE " + TABLE_AGLAIS_ZMS + " SET Version = " + version) == 1;
                } catch (SQLException e) {
                    result = false;
                    Log.e(LOG_TAG, "Failed to set db version to v" + version, e);
                }
            }
        }

        return result;
    }

    @Override
    public Species addSpecies(@NotNull Species species) {
        if (ensureConnected()) {
            try {
                PreparedStatement ps = conn.prepareStatement("INSERT INTO " + TABLE_SPECIES
                                + " (ID, CommonName, ScientificName, Origin, Notes, Image) VALUES (?, ?, ?, ?, ?, ?)",
                        Statement.RETURN_GENERATED_KEYS);
                ps.setObject(1, species.getID() > 0 ? species.getID() : null);
                ps.setString(2, species.getCommonName());
                ps.setString(3, species.getScientificName());
                ps.setString(4, species.getOrigin());
                ps.setString(5, species.getNotes());
                ps.setBlob(6, species.getImage());

                if (ps.executeUpdate() == 1) {
                    ResultSet rs = ps.getGeneratedKeys();
                    if (rs.next()) {
                        species.setID(rs.getInt(1));
                    }

                    return species;
                }
            } catch (SQLException 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 {
                ResultSet rs = conn.createStatement().executeQuery("SELECT * FROM " + TABLE_SPECIES);

                while (rs.next())
                    species.add(new Species(rs.getInt("ID"), rs.getString("CommonName"),
                            rs.getString("ScientificName"), rs.getString("Origin"), rs.getString("Notes"),
                            rs.getBlob("Image")));
            } catch (SQLException e) {
                Log.e(LOG_TAG, "Failed to query all species", e);
            }
        }

        return species;
    }

    @Override
    public boolean deleteSpecies(Species species) {
        if (ensureConnected()) {
            try {
                conn.setAutoCommit(false);

                PreparedStatement ps = conn.prepareStatement("DELETE R FROM " + TABLE_ANCESTRY_RELATIONS
                        + " R, " + TABLE_ANCESTRY_NODES + " WHERE (ChildID = " + TABLE_ANCESTRY_NODES
                        + ".ID OR ParentID = " + TABLE_ANCESTRY_NODES + ".ID) AND " + TABLE_ANCESTRY_NODES
                        + ".SpeciesID = ?");
                ps.setInt(1, species.getID());
                ps.executeUpdate();

                ps = conn.prepareStatement("DELETE FROM " + TABLE_ANCESTRY_NODES + " WHERE SpeciesID = ?");
                ps.setInt(1, species.getID());
                ps.executeUpdate();

                ps = conn.prepareStatement("DELETE FROM " + TABLE_SPECIES + " WHERE ID = ?");
                ps.setInt(1, species.getID());

                if (ps.executeUpdate() == 1) {
                    conn.commit();
                    conn.setAutoCommit(true);

                    return true;
                }
            } catch (SQLException e) {
                Log.e(LOG_TAG, "Failed to delete species from db", e);
                try {
                    conn.rollback();
                    conn.setAutoCommit(true);
                } catch (SQLException ex) {
                    Log.e(LOG_TAG, "Failed to rollback transaction", ex);
                }
            }
        }
        return false;
    }

    @Override
    public boolean updateSpecies(Species species) {
        if (ensureConnected()) {
            try {
                PreparedStatement ps = conn.prepareStatement("UPDATE " + TABLE_SPECIES +
                        " SET CommonName = ?, ScientificName = ?, Origin = ?, Notes = ?, Image = ? WHERE ID = ?");
                ps.setString(1, species.getCommonName());
                ps.setString(2, species.getScientificName());
                ps.setString(3, species.getOrigin());
                ps.setString(4, species.getNotes());
                ps.setBlob(5, species.getImage());
                ps.setInt(6, species.getID());

                return ps.executeUpdate() == 1;
            } catch (SQLException 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 {
                PreparedStatement ps = conn.prepareStatement("SELECT * FROM " + TABLE_ANCESTRY_NODES
                        + " WHERE SpeciesID = ?");

                ps.setInt(1, species.getID());

                ResultSet rs = ps.executeQuery();
                while (rs.next()) {
                    List<Integer> parents = new ArrayList<>();
                    PreparedStatement parentPs = conn.prepareStatement("SELECT * FROM " + TABLE_ANCESTRY_RELATIONS
                            + " WHERE ChildID = ?");

                    parentPs.setInt(1, rs.getInt("ID"));

                    ResultSet parentRs = parentPs.executeQuery();
                    while (parentRs.next()) {
                        parents.add(parentRs.getInt("ParentID"));
                    }

                    AncestryData data = new AncestryData(rs.getInt("ID"), rs.getDate("DateStart"),
                            rs.getDate("DateEnd"), rs.getString("Notes"), rs.getString("Generation"),
                            IntUtil.toArray(parents), rs.getObject("OriginLatitude", Double.class),
                            rs.getObject("OriginLongitude", Double.class),
                            rs.getObject("OriginRadius", Integer.class));
                    ancestryData.add(data);
                }
            } catch (SQLException 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()) {
            return false;
        }

        List<AncestryData> previousAncestry = getSpeciesAncestry(species);
        HashMap<Integer, Integer> temporaryIdMap = new HashMap<>();

        try {
            conn.setAutoCommit(false);

            // First delete all elements that are gone
            for (AncestryData data : previousAncestry) {
                if (!ancestry.contains(data)) {
                    PreparedStatement ps = conn.prepareStatement("DELETE FROM " + TABLE_ANCESTRY_RELATIONS
                            + " WHERE ParentID = ? OR ChildID = ?");
                    ps.setInt(1, data.getID());
                    ps.setInt(2, data.getID());
                    ps.executeUpdate();

                    ps = conn.prepareStatement("DELETE FROM " + TABLE_ANCESTRY_NODES + " WHERE ID = ?");
                    ps.setInt(1, data.getID());
                    ps.executeUpdate();
                }
            }

            // Insert new elements and update existing ones
            for (AncestryData data : ancestry) {
                if (!previousAncestry.contains(data)) {
                    // Insert new elements
                    PreparedStatement ps = conn.prepareStatement("INSERT INTO " + TABLE_ANCESTRY_NODES
                                    + " (ID, SpeciesID, DateStart, DateEnd, Notes, Generation, OriginLatitude, OriginLongitude, " +
                                    "OriginRadius) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
                            Statement.RETURN_GENERATED_KEYS);
                    ps.setObject(1, data.getID() > 0 ? data.getID() : null);
                    ps.setInt(2, species.getID());
                    ps.setDate(3, DateUtil.convertToSqlDate(data.getDateStart()));
                    ps.setDate(4, DateUtil.convertToSqlDate(data.getDateEnd()));
                    ps.setString(5, data.getNotes());
                    ps.setString(6, data.getGeneration());
                    ps.setObject(7, data.getOriginLatitude());
                    ps.setObject(8, data.getOriginLongitude());
                    ps.setObject(9, data.getOriginRadius());
                    if (ps.executeUpdate() == 1) {
                        ResultSet rs = ps.getGeneratedKeys();
                        if (rs.next()) {
                            temporaryIdMap.put(data.getID(), rs.getInt(1));
                            data.setID(rs.getInt(1));
                        } else {
                            temporaryIdMap.put(data.getID(), data.getID());
                        }
                    }
                } else {
                    PreparedStatement ps = conn.prepareStatement("UPDATE " + TABLE_ANCESTRY_NODES
                            + " SET DateStart = ?, DateEnd = ?, Notes = ?, Generation = ?, OriginLatitude = ?, "
                            + "OriginLongitude = ?, OriginRadius = ? WHERE ID = ?");
                    ps.setDate(1, DateUtil.convertToSqlDate(data.getDateStart()));
                    ps.setDate(2, DateUtil.convertToSqlDate(data.getDateEnd()));
                    ps.setString(3, data.getNotes());
                    ps.setString(4, data.getGeneration());
                    ps.setObject(5, data.getOriginLatitude());
                    ps.setObject(6, data.getOriginLongitude());
                    ps.setObject(7, data.getOriginRadius());
                    ps.setInt(8, data.getID());
                    ps.executeUpdate();
                }
            }

            // Now that everything is inserted and all IDs are known, store relations
            for (AncestryData data : ancestry) {
                int[] parents = data.getParents();
                for (int i = 0; i < parents.length; i++) {
                    parents[i] = temporaryIdMap.getOrDefault(parents[i], parents[i]);
                }

                if (!previousAncestry.contains(data)) {
                    // Insert parents
                    for (int parent : data.getParents()) {
                        PreparedStatement ps = conn.prepareStatement("INSERT INTO " + TABLE_ANCESTRY_RELATIONS
                                + " (ParentID, ChildID) VALUES (?, ?)");
                        ps.setInt(1, parent);
                        ps.setInt(2, data.getID());
                        ps.executeUpdate();
                    }
                } else {
                    AncestryData previousData = previousAncestry.get(previousAncestry.indexOf(data));

                    // Delete removed parents
                    List<Integer> newParents = IntUtil.toList(data.getParents());
                    for (int parent : previousData.getParents()) {
                        if (!newParents.contains(parent)) {
                            PreparedStatement ps = conn.prepareStatement("DELETE FROM " + TABLE_ANCESTRY_RELATIONS
                                    + " WHERE ParentID = ? AND ChildID = ?");
                            ps.setInt(1, parent);
                            ps.setInt(2, data.getID());
                            ps.executeUpdate();
                        }
                    }

                    // Insert new parents
                    List<Integer> previousParents = IntUtil.toList(previousData.getParents());
                    for (int parent : data.getParents()) {
                        if (!previousParents.contains(parent)) {
                            PreparedStatement ps = conn.prepareStatement("INSERT INTO " + TABLE_ANCESTRY_RELATIONS
                                    + " (ParentID, ChildID) VALUES (?, ?)");
                            ps.setInt(1, parent);
                            ps.setInt(2, data.getID());
                            ps.executeUpdate();
                        }
                    }
                }
            }

            conn.commit();
            conn.setAutoCommit(true);
            return true;
        } catch (SQLException e) {
            Log.e(LOG_TAG, "Failed to update species ancestry", e);
            try {
                conn.rollback();
                conn.setAutoCommit(true);
            } catch (SQLException ex) {
                Log.e(LOG_TAG, "Failed to rollback transaction", ex);
            }
            return false;
        }
    }

    @Override
    public User addUser(@NotNull User user) {
        if (ensureConnected()) {
            try {
                PreparedStatement ps = conn.prepareStatement("INSERT INTO " + TABLE_USERS + "(ID, Name, Password) "
                        + "VALUES (?, ?, ?)", Statement.RETURN_GENERATED_KEYS);
                ps.setObject(1, user.getID() > 0 ? user.getID() : null);
                ps.setString(2, user.getName());
                ps.setString(3, user.getPassword() != null ? new String(user.getPassword()) : null);

                if (ps.executeUpdate() == 1) {
                    ResultSet rs = ps.getGeneratedKeys();
                    if (rs.next()) {
                        user.setID(rs.getInt(1));
                    }

                    return user;
                }
            } catch (SQLException 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 {
                ResultSet rs = conn.createStatement().executeQuery("SELECT * FROM " + TABLE_USERS);

                while (rs.next()) {
                    String password = rs.getString("Password");

                    users.add(new User(rs.getInt("ID"), rs.getString("Name"),
                            password != null ? password.toCharArray() : null));
                }
            } catch (SQLException e) {
                Log.e(LOG_TAG, "Failed to query all users", e);
            }
        }

        return users;
    }

    @Override
    public boolean deleteUser(User user) {
        if (ensureConnected()) {
            try {
                conn.setAutoCommit(false);

                PreparedStatement ps = conn.prepareStatement("DELETE FROM " + TABLE_USERS + " WHERE ID = ?");
                ps.setInt(1, user.getID());

                if (ps.executeUpdate() == 1) {
                    conn.commit();
                    conn.setAutoCommit(true);

                    return true;
                }
            } catch (SQLException e) {
                Log.e(LOG_TAG, "Failed to delete user from db", e);
                try {
                    conn.rollback();
                    conn.setAutoCommit(true);
                } catch (SQLException ex) {
                    Log.e(LOG_TAG, "Failed to rollback transaction", ex);
                }
            }
        }

        return false;
    }

    @Override
    public boolean updateUser(User user) {
        if (ensureConnected()) {
            try {
                PreparedStatement ps = conn.prepareStatement("UPDATE " + TABLE_USERS + " SET Name = ?, Password = ? "
                        + "WHERE ID = ?");
                ps.setString(1, user.getName());
                ps.setString(2, user.getPassword() != null ? new String(user.getPassword()) : null);
                ps.setInt(3, user.getID());

                return ps.executeUpdate() == 1;
            } catch (SQLException e) {
                Log.e(LOG_TAG, "Failed to update user in db", e);
            }
        }
        return false;
    }

    @Override
    public @Nullable User getUserByName(String name) {
        if (ensureConnected()) {
            try {
                PreparedStatement ps = conn.prepareStatement("SELECT * FROM " + TABLE_USERS + " WHERE Name = ?");
                ps.setString(1, name);
                ResultSet rs = ps.executeQuery();

                if (rs.next()) {
                    String password = rs.getString("Password");

                    return new User(rs.getInt("ID"), rs.getString("Name"),
                            password != null ? password.toCharArray() : null);
                }
            } catch (SQLException e) {
                Log.e(LOG_TAG, "Failed to query user by name", e);
            }
        }

        return null;
    }

    @Override
    public @Nullable User getUserByID(int id) {
        if (ensureConnected()) {
            try {
                PreparedStatement ps = conn.prepareStatement("SELECT * FROM " + TABLE_USERS + " WHERE ID = ?");
                ps.setInt(1, id);
                ResultSet rs = ps.executeQuery();

                if (rs.next()) {
                    String password = rs.getString("Password");

                    return new User(rs.getInt("ID"), rs.getString("Name"),
                            password != null ? password.toCharArray() : null);
                }
            } catch (SQLException e) {
                Log.e(LOG_TAG, "Failed to query user by id", e);
            }
        }

        return null;
    }
}
