package de.simolzimol.mclogger.consent.database;

import java.net.InetAddress;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
import java.util.logging.Level;

import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;

import de.simolzimol.mclogger.consent.ConsentConfig;
import de.simolzimol.mclogger.consent.ConsentPlugin;

/**
 * HikariCP-backed database client for MCConsent.
 *
 * <p>Tables managed by this class:
 * <ul>
 *   <li>{@code player_consent}          – stores accepted consents per UUID + version</li>
 *   <li>{@code player_consent_declines} – audit trail of declined consents</li>
 * </ul>
 *
 * <p>Both tables are created automatically on first connection.
 * If {@code admin.log-to-mclogger-db} is {@code true} consent events are also
 * inserted into the {@code server_events} table that MCLogger maintains.
 *
 * @author SimolZimol
 */
public class ConsentDatabase {

    private final ConsentPlugin  plugin;
    private       HikariDataSource dataSource;

    public ConsentDatabase(ConsentPlugin plugin) {
        this.plugin = plugin;
    }

    // ─────────────────────────────────────────────────────────
    // Lifecycle
    // ─────────────────────────────────────────────────────────

    /**
     * Opens the connection pool and ensures the required tables exist.
     *
     * @return {@code true} if the connection was established successfully.
     */
    public boolean connect() {
        ConsentConfig cfg = plugin.getConsentConfig();

        HikariConfig hk = new HikariConfig();
        hk.setDriverClassName("de.simolzimol.mcconsent.lib.mariadb.jdbc.Driver");
        hk.setJdbcUrl(String.format(
                "jdbc:mariadb://%s:%d/%s?useSSL=%b&autoReconnect=true&characterEncoding=UTF-8",
                cfg.getDbHost(), cfg.getDbPort(), cfg.getDbDatabase(), cfg.isDbSsl()));
        hk.setUsername(cfg.getDbUsername());
        hk.setPassword(cfg.getDbPassword());
        hk.setMaximumPoolSize(cfg.getDbPoolSize());
        hk.setMinimumIdle(1);
        hk.setConnectionTimeout(30_000);
        hk.setIdleTimeout(600_000);
        hk.setMaxLifetime(1_800_000);
        hk.setPoolName("MCConsent");
        hk.addDataSourceProperty("cachePrepStmts",    "true");
        hk.addDataSourceProperty("prepStmtCacheSize", "100");

        try {
            dataSource = new HikariDataSource(hk);
            initTables();
            return true;
        } catch (Exception e) {
            plugin.getLogger().log(Level.SEVERE, "[MCConsent] Database connection failed!", e);
            return false;
        }
    }

    /** Closes the connection pool. */
    public void disconnect() {
        if (dataSource != null && !dataSource.isClosed()) {
            dataSource.close();
        }
    }

    // ─────────────────────────────────────────────────────────
    // Table initialisation
    // ─────────────────────────────────────────────────────────

    private void initTables() throws SQLException {
        try (Connection conn = dataSource.getConnection();
             Statement  stmt = conn.createStatement()) {

            // Accepted consents – primary record of who agreed to what version.
            // ip_address is pseudonymized (last octet/80 bits zeroed) at insert time.
            // policy_hash is the SHA-256 of the policy_version string for accountability.
            stmt.execute(
                "CREATE TABLE IF NOT EXISTS player_consent (" +
                "  uuid           VARCHAR(36)  NOT NULL," +
                "  username       VARCHAR(16)  NOT NULL," +
                "  policy_version VARCHAR(64)  NOT NULL," +
                "  policy_hash    VARCHAR(64)  NULL," +
                "  consented_at   TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3)," +
                "  ip_address     VARCHAR(45)  NULL," +
                "  PRIMARY KEY (uuid, policy_version)," +
                "  INDEX idx_consent_uuid    (uuid)," +
                "  INDEX idx_consent_version (policy_version)" +
                ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");

            // Migrate existing tables: add policy_hash if not present
            stmt.execute(
                "ALTER TABLE player_consent " +
                "ADD COLUMN IF NOT EXISTS policy_hash VARCHAR(64) NULL");

            // Decline audit trail
            stmt.execute(
                "CREATE TABLE IF NOT EXISTS player_consent_declines (" +
                "  id             BIGINT AUTO_INCREMENT PRIMARY KEY," +
                "  uuid           VARCHAR(36)  NOT NULL," +
                "  username       VARCHAR(16)  NOT NULL," +
                "  policy_version VARCHAR(64)  NOT NULL," +
                "  declined_at    TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3)," +
                "  ip_address     VARCHAR(45)  NULL," +
                "  INDEX idx_decline_uuid (uuid)" +
                ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
        }
    }

    // ─────────────────────────────────────────────────────────
    // Query helpers
    // ─────────────────────────────────────────────────────────

    /**
     * Returns {@code true} if the player has accepted the given policy version.
     * Runs synchronously; should be called from the main thread or a context
     * where a brief pause is acceptable (e.g. {@code PlayerLoginEvent}).
     */
    public boolean hasConsented(String uuid, String policyVersion) {
        final String sql =
            "SELECT 1 FROM player_consent WHERE uuid = ? AND policy_version = ? LIMIT 1";
        try (Connection conn = dataSource.getConnection();
             PreparedStatement ps = conn.prepareStatement(sql)) {
            ps.setString(1, uuid);
            ps.setString(2, policyVersion);
            try (ResultSet rs = ps.executeQuery()) {
                return rs.next();
            }
        } catch (SQLException e) {
            plugin.getLogger().log(Level.WARNING, "[MCConsent] hasConsented() failed", e);
            // Fail-open: if we can't check, allow the player in (avoids locking everyone out)
            return true;
        }
    }

    /**
     * Records that the player has accepted the current policy version.
     * Uses {@code INSERT IGNORE} so duplicate calls are safe.
     *
     * <p>The IP address is pseudonymized before storage (Art. 5(1)(c) GDPR):
     * last octet zeroed for IPv4, last 80 bits zeroed for IPv6.
     * A SHA-256 hash of the policy version string is stored for accountability
     * (Art. 5(2) GDPR).
     */
    public void recordConsent(String uuid, String username, String policyVersion, String ip) {
        final String sql =
            "INSERT IGNORE INTO player_consent (uuid, username, policy_version, policy_hash, ip_address)" +
            " VALUES (?, ?, ?, ?, ?)";
        try (Connection conn = dataSource.getConnection();
             PreparedStatement ps = conn.prepareStatement(sql)) {
            ps.setString(1, uuid);
            ps.setString(2, username);
            ps.setString(3, policyVersion);
            ps.setString(4, computePolicyHash(policyVersion));
            ps.setString(5, pseudonymizeIp(ip));
            ps.executeUpdate();
        } catch (SQLException e) {
            plugin.getLogger().log(Level.WARNING, "[MCConsent] recordConsent() failed", e);
        }
    }

    /**
     * Records that the player declined the current policy version.
     * Declines are appended to an audit table; their IP is pseudonymized.
     */
    public void recordDecline(String uuid, String username, String policyVersion, String ip) {
        final String sql =
            "INSERT INTO player_consent_declines (uuid, username, policy_version, ip_address)" +
            " VALUES (?, ?, ?, ?)";
        try (Connection conn = dataSource.getConnection();
             PreparedStatement ps = conn.prepareStatement(sql)) {
            ps.setString(1, uuid);
            ps.setString(2, username);
            ps.setString(3, policyVersion);
            ps.setString(4, pseudonymizeIp(ip));
            ps.executeUpdate();
        } catch (SQLException e) {
            plugin.getLogger().log(Level.WARNING, "[MCConsent] recordDecline() failed", e);
        }
    }

    /**
     * Removes a player's accepted consent for the given version.
     * Used by {@code /consent admin revoke <player>} to force re-acceptance.
     *
     * @param uuid          Player UUID string
     * @param policyVersion Version to revoke; {@code null} revokes ALL versions
     */
    public void revokeConsent(String uuid, String policyVersion) {
        final String sql = policyVersion == null
            ? "DELETE FROM player_consent WHERE uuid = ?"
            : "DELETE FROM player_consent WHERE uuid = ? AND policy_version = ?";
        try (Connection conn = dataSource.getConnection();
             PreparedStatement ps = conn.prepareStatement(sql)) {
            ps.setString(1, uuid);
            if (policyVersion != null) ps.setString(2, policyVersion);
            ps.executeUpdate();
        } catch (SQLException e) {
            plugin.getLogger().log(Level.WARNING, "[MCConsent] revokeConsent() failed", e);
        }
    }

    /**
     * Returns a list of UUIDs (as strings) that have no consent record for
     * {@code currentVersion}.  Only considers players known to the
     * {@code players} table (MCLogger integration); returns an empty list if
     * that table does not exist.
     */
    public List<String> getPendingPlayerUuids(String currentVersion) {
        final String sql =
            "SELECT p.uuid FROM players p" +
            " WHERE NOT EXISTS (" +
            "   SELECT 1 FROM player_consent c" +
            "   WHERE c.uuid = p.uuid AND c.policy_version = ?" +
            " ) LIMIT 100";
        List<String> result = new ArrayList<>();
        try (Connection conn = dataSource.getConnection();
             PreparedStatement ps = conn.prepareStatement(sql)) {
            ps.setString(1, currentVersion);
            try (ResultSet rs = ps.executeQuery()) {
                while (rs.next()) result.add(rs.getString("uuid"));
            }
        } catch (SQLException e) {
            // players table may not exist in standalone mode
            plugin.getLogger().fine("[MCConsent] getPendingPlayerUuids(): " + e.getMessage());
        }
        return result;
    }

    /**
     * Optionally writes a consent event into the MCLogger {@code server_events}
     * table so it shows up in the web panel audit stream.
     * Silently ignored if the table does not exist or the insert fails.
     *
     * @param eventType  {@code "consent_accepted"} or {@code "consent_declined"}
     * @param playerName In-game name for the log message
     * @param uuid       Player UUID string
     * @param version    Policy version string
     */
    // ─────────────────────────────────────────────────────────
    // GDPR – Art. 5(1)(e) Storage limitation
    // ─────────────────────────────────────────────────────────

    /**
     * Deletes consent and decline records older than the configured retention
     * periods.  Pass 0 to skip deletion for that table.
     *
     * <p>Intended to be called from an async scheduled task once per day.
     */
    public void purgeOldConsents(int consentRetentionDays, int declineRetentionDays) {
        if (consentRetentionDays > 0) {
            final String sql =
                "DELETE FROM player_consent " +
                "WHERE consented_at < DATE_SUB(UTC_TIMESTAMP(), INTERVAL ? DAY)";
            try (Connection conn = dataSource.getConnection();
                 PreparedStatement ps = conn.prepareStatement(sql)) {
                ps.setInt(1, consentRetentionDays);
                int rows = ps.executeUpdate();
                if (rows > 0) {
                    plugin.getLogger().info("[MCConsent] GDPR retention: removed " + rows
                            + " consent record(s) older than " + consentRetentionDays + " days.");
                }
            } catch (SQLException e) {
                plugin.getLogger().log(Level.WARNING, "[MCConsent] purgeOldConsents() consent failed", e);
            }
        }
        if (declineRetentionDays > 0) {
            final String sql =
                "DELETE FROM player_consent_declines " +
                "WHERE declined_at < DATE_SUB(UTC_TIMESTAMP(), INTERVAL ? DAY)";
            try (Connection conn = dataSource.getConnection();
                 PreparedStatement ps = conn.prepareStatement(sql)) {
                ps.setInt(1, declineRetentionDays);
                int rows = ps.executeUpdate();
                if (rows > 0) {
                    plugin.getLogger().info("[MCConsent] GDPR retention: removed " + rows
                            + " decline record(s) older than " + declineRetentionDays + " days.");
                }
            } catch (SQLException e) {
                plugin.getLogger().log(Level.WARNING, "[MCConsent] purgeOldConsents() declines failed", e);
            }
        }
    }

    // ─────────────────────────────────────────────────────────
    // Privacy helpers
    // ─────────────────────────────────────────────────────────

    /**
     * Pseudonymizes an IP address for storage (Art. 5(1)(c) GDPR – data minimisation).
     * <ul>
     *   <li>IPv4:  last octet set to 0   (e.g. {@code 192.168.1.100} → {@code 192.168.1.0})</li>
     *   <li>IPv6:  last 80 bits set to 0 (keeps the first 48 bits / ISP prefix)</li>
     * </ul>
     * Returns {@code "unknown"} if the address cannot be parsed.
     */
    static String pseudonymizeIp(String ip) {
        if (ip == null || ip.isBlank() || ip.equals("unknown") || ip.equals("admin-forced")) {
            return ip;
        }
        try {
            InetAddress addr = InetAddress.getByName(ip);
            byte[] bytes = addr.getAddress();
            if (bytes.length == 4) {
                // IPv4 – zero last octet
                bytes[3] = 0;
            } else if (bytes.length == 16) {
                // IPv6 – zero last 10 bytes (80 bits)
                for (int i = 6; i < 16; i++) bytes[i] = 0;
            }
            return InetAddress.getByAddress(bytes).getHostAddress();
        } catch (Exception e) {
            return "unknown";
        }
    }

    /**
     * Computes a SHA-256 hex digest of the policy version string.
     * Stored alongside each consent record so the exact version accepted can
     * be verified later (Art. 5(2) GDPR – accountability).
     */
    static String computePolicyHash(String policyVersion) {
        try {
            MessageDigest md = MessageDigest.getInstance("SHA-256");
            byte[] digest = md.digest(policyVersion.getBytes(StandardCharsets.UTF_8));
            StringBuilder sb = new StringBuilder(digest.length * 2);
            for (byte b : digest) sb.append(String.format("%02x", b));
            return sb.toString();
        } catch (NoSuchAlgorithmException e) {
            return "unavailable";
        }
    }

    public void logToMCLogger(String eventType, String playerName, String uuid, String version) {
        if (!plugin.getConsentConfig().isLogToMCLoggerDb()) return;

        final String sql =
            "INSERT INTO server_events (event_type, server_name, message, details)" +
            " VALUES (?, ?, ?, ?)";
        String serverName = plugin.getConsentConfig().getDbDatabase();
        String message    = playerName + " " + eventType.replace('_', ' ') + " (v" + version + ")";
        // Build minimal JSON manually to avoid a Gson dependency
        String details    = "{\"uuid\":\"" + uuid + "\",\"policy_version\":\"" + version + "\"}";

        try (Connection conn = dataSource.getConnection();
             PreparedStatement ps = conn.prepareStatement(sql)) {
            ps.setString(1, eventType);
            ps.setString(2, serverName);
            ps.setString(3, message);
            ps.setString(4, details);
            ps.executeUpdate();
        } catch (SQLException ignored) {
            // Non-critical; server_events table may not exist in standalone mode
        }
    }
}
