package ir.winix.aquamc.aquacoin;

import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import org.bukkit.Bukkit;
import org.bukkit.OfflinePlayer;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.command.ConsoleCommandSender;
import org.bukkit.configuration.file.FileConfiguration;
import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.entity.Player;
import org.bukkit.plugin.java.JavaPlugin;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.lang.reflect.Type;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.logging.Level;

/**
 * AquaCoinBridge — companion Paper/Purpur plugin for the WinixStore × AquaMC marketplace.
 *
 * Responsibilities
 *   1. Provides the command used by the marketplace:  /giveaquacoin <player> <amount>
 *   2. Exposes a restricted HTTP bridge (127.0.0.1 only) so the web app can deliver
 *      AquaCoin with an IDEMPOTENCY KEY (txid). Duplicate deliveries are rejected,
 *      which is what makes automatic retries safe.
 *
 * Security notes
 *   - The HTTP listener MUST be bound to 127.0.0.1 (or a private VPN address).
 *     Never expose it to the public internet.
 *   - Every request is authenticated with HMAC-SHA256 over (timestamp + "." + body)
 *     and a timestamp window, so a captured request cannot be replayed later.
 *   - Processed transaction ids are persisted to ledger.json, so a restart does not
 *     lose the deduplication memory.
 */
public final class AquaCoinBridge extends JavaPlugin implements CommandExecutor {

    private HttpServer server;
    private ExecutorService httpPool;
    private final Gson gson = new Gson();

    private String secret;
    private String grantCommand;      // e.g. "aquacoin give %player% %amount%"
    private long timestampWindow;     // seconds
    private boolean notifyPlayer;
    private String messageTemplate;
    private int maxAmount;

    private File ledgerFile;
    private final Set<String> processed = new LinkedHashSet<>();
    private static final int MAX_LEDGER = 20000;

    // ------------------------------------------------------------ lifecycle

    @Override
    public void onEnable() {
        saveDefaultConfig();
        reloadLocalConfig();

        ledgerFile = new File(getDataFolder(), "ledger.json");
        loadLedger();

        if (getCommand("giveaquacoin") != null) {
            getCommand("giveaquacoin").setExecutor(this);
        } else {
            getLogger().warning("Command 'giveaquacoin' is not registered in plugin.yml.");
        }

        startBridge();

        getLogger().info("AquaCoinBridge enabled. grant-command='" + grantCommand + "'");
    }

    @Override
    public void onDisable() {
        stopBridge();
        saveLedger();
        getLogger().info("AquaCoinBridge disabled.");
    }

    private void reloadLocalConfig() {
        reloadConfig();
        FileConfiguration cfg = getConfig();
        secret = cfg.getString("bridge.secret", "");
        grantCommand = cfg.getString("grant-command", "aquacoin give %player% %amount%");
        timestampWindow = cfg.getLong("bridge.timestamp-window-seconds", 300);
        notifyPlayer = cfg.getBoolean("notify-player", true);
        messageTemplate = cfg.getString("message", "&b+%amount% &aAquaCoin به حساب شما افزوده شد. &7(%player%)");
        maxAmount = cfg.getInt("max-amount", 1000000);
    }

    // -------------------------------------------------------------- command

    /**
     * /giveaquacoin <player> <amount>
     * Executable by console (RCON) or by a player with aquacoin.give.
     */
    @Override
    public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
        if (!sender.hasPermission("aquacoin.give")) {
            sender.sendMessage(color("&cشما اجازه اجرای این دستور را ندارید."));
            return true;
        }
        if (args.length != 2) {
            sender.sendMessage(color("&7کاربرد: &f/giveaquacoin <player> <amount>"));
            return true;
        }

        String player = args[0];
        int amount;
        try {
            amount = Integer.parseInt(args[1]);
        } catch (NumberFormatException e) {
            sender.sendMessage(color("&cمقدار باید عدد صحیح باشد."));
            return true;
        }

        if (!isPlayerNameValid(player) || amount <= 0 || amount > maxAmount) {
            sender.sendMessage(color("&cورودی نامعتبر است (نام کاربری ۳ تا ۱۶ کاراکتر، مقدار ۱ تا " + maxAmount + ")."));
            return true;
        }

        boolean ok = grant(player, amount, null);
        sender.sendMessage(color(ok
                ? "&a" + amount + " AquaCoin برای " + player + " اعطا شد."
                : "&cاجرای فرمان اعطای AquaCoin ناموفق بود."));
        return true;
    }

    // ---------------------------------------------------------- HTTP bridge

    private void startBridge() {
        String host = getConfig().getString("bridge.host", "127.0.0.1");
        int port = getConfig().getInt("bridge.port", 8791);

        if (secret == null || secret.isEmpty()) {
            getLogger().warning("bridge.secret is empty — the HTTP bridge is DISABLED. "
                    + "Set a strong secret in config.yml to enable idempotent delivery.");
            return;
        }
        if (!host.equals("127.0.0.1") && !host.equals("localhost")) {
            getLogger().warning("bridge.host is not 127.0.0.1 — make sure the port is firewalled "
                    + "and only reachable from the web server.");
        }

        try {
            httpPool = Executors.newFixedThreadPool(2);
            server = HttpServer.create(new InetSocketAddress(host, port), 0);
            server.createContext("/deliver", this::handleDeliver);
            server.createContext("/health", this::handleHealth);
            server.setExecutor(httpPool);
            server.start();
            getLogger().info("Bridge listening on http://" + host + ":" + port + "  (/deliver, /health)");
        } catch (IOException e) {
            getLogger().log(Level.SEVERE, "Could not start the HTTP bridge", e);
        }
    }

    private void stopBridge() {
        if (server != null) {
            server.stop(0);
            server = null;
        }
        if (httpPool != null) {
            httpPool.shutdownNow();
            httpPool = null;
        }
    }

    private void handleHealth(HttpExchange exchange) throws IOException {
        respond(exchange, 200, "{\"status\":\"ok\",\"plugin\":\"AquaCoinBridge\",\"processed\":"
                + processed.size() + "}");
    }

    private void handleDeliver(HttpExchange exchange) throws IOException {
        if (!"POST".equalsIgnoreCase(exchange.getRequestMethod())) {
            respond(exchange, 405, "{\"status\":\"error\",\"message\":\"method not allowed\"}");
            return;
        }

        String body;
        try (InputStream in = exchange.getRequestBody()) {
            body = new String(in.readAllBytes(), StandardCharsets.UTF_8);
        }

        String signature = header(exchange, "X-Aqua-Signature");
        String timestampRaw = header(exchange, "X-Aqua-Timestamp");
        String bodyTimestamp = "";

        Map<String, Object> payload;
        try {
            Type type = new TypeToken<Map<String, Object>>() {}.getType();
            payload = gson.fromJson(body, type);
            if (payload != null && payload.get("timestamp") != null) {
                bodyTimestamp = String.valueOf(payload.get("timestamp"));
            }
        } catch (Exception e) {
            respond(exchange, 400, "{\"status\":\"error\",\"message\":\"invalid json\"}");
            return;
        }

        if (signature == null || signature.isEmpty() || !verify(signature, timestampRaw, bodyTimestamp, body)) {
            getLogger().warning("Rejected /deliver request: bad signature (ip="
                    + exchange.getRemoteAddress().getAddress().getHostAddress() + ")");
            respond(exchange, 401, "{\"status\":\"error\",\"message\":\"bad signature\"}");
            return;
        }

        String txid = str(payload.get("txid"));
        String player = str(payload.get("player"));
        int amount = (int) Math.round(dbl(payload.get("amount")));

        if (txid.isEmpty() || !isPlayerNameValid(player) || amount <= 0 || amount > maxAmount) {
            respond(exchange, 400, "{\"status\":\"error\",\"message\":\"invalid payload\"}");
            return;
        }

        // ---- IDEMPOTENCY: never grant the same transaction twice.
        synchronized (processed) {
            if (processed.contains(txid)) {
                getLogger().info("Duplicate delivery blocked: txid=" + txid + " player=" + player);
                respond(exchange, 200, "{\"status\":\"ok\",\"duplicate\":true,\"message\":\"already delivered\"}");
                return;
            }
        }

        // Run on the main server thread (Bukkit API is not thread safe).
        final int finalAmount = amount;
        try {
            Future<Boolean> future = Bukkit.getScheduler().callSyncMethod(this, () -> grant(player, finalAmount, txid));
            boolean ok = future.get(10, TimeUnit.SECONDS);
            respond(exchange, ok ? 200 : 500, ok
                    ? "{\"status\":\"ok\",\"duplicate\":false,\"message\":\"delivered\"}"
                    : "{\"status\":\"error\",\"message\":\"grant command failed\"}");
        } catch (TimeoutException e) {
            respond(exchange, 504, "{\"status\":\"error\",\"message\":\"timeout\"}");
        } catch (Exception e) {
            getLogger().log(Level.SEVERE, "Delivery failed", e);
            respond(exchange, 500, "{\"status\":\"error\",\"message\":\"internal error\"}");
        }
    }

    /** The single place where coins are granted. */
    private boolean grant(String player, int amount, String txid) {
        String command = grantCommand
                .replace("%player%", player)
                .replace("%amount%", String.valueOf(amount));

        ConsoleCommandSender console = Bukkit.getConsoleSender();
        boolean ok = Bukkit.dispatchCommand(console, command);

        if (ok && notifyPlayer) {
            Player online = Bukkit.getPlayerExact(player);
            if (online != null && online.isOnline()) {
                online.sendMessage(color(messageTemplate
                        .replace("%player%", player)
                        .replace("%amount%", String.valueOf(amount))));
            }
        }

        if (ok && txid != null) {
            synchronized (processed) {
                processed.add(txid);
                trimLedger();
            }
            saveLedgerAsync();
        }

        getLogger().info("grant player=" + player + " amount=" + amount + " ok=" + ok
                + (txid != null ? " txid=" + txid : " (manual/rcon)"));
        return ok;
    }

    // ------------------------------------------------------------- helpers

    private boolean verify(String signature, String headerTimestamp, String bodyTimestamp, String body) {
        String ts = headerTimestamp != null && !headerTimestamp.isEmpty() ? headerTimestamp : bodyTimestamp;
        if (ts.isEmpty()) {
            return false;
        }
        long stamp;
        try {
            stamp = Long.parseLong(ts);
        } catch (NumberFormatException e) {
            return false;
        }
        long skew = Math.abs(System.currentTimeMillis() / 1000L - stamp);
        if (skew > timestampWindow) {
            getLogger().warning("Rejected request outside the timestamp window (skew=" + skew + "s)");
            return false;
        }
        String expected = hmac(ts + "." + body);
        return constantTimeEquals(expected, signature);
    }

    private String hmac(String data) {
        try {
            Mac mac = Mac.getInstance("HmacSHA256");
            mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
            return hex(mac.doFinal(data.getBytes(StandardCharsets.UTF_8)));
        } catch (Exception e) {
            getLogger().log(Level.SEVERE, "HMAC failure", e);
            return "";
        }
    }

    private static boolean constantTimeEquals(String a, String b) {
        if (a == null || b == null || a.length() != b.length()) {
            return false;
        }
        int result = 0;
        for (int i = 0; i < a.length(); i++) {
            result |= a.charAt(i) ^ b.charAt(i);
        }
        return result == 0;
    }

    private static String hex(byte[] bytes) {
        StringBuilder sb = new StringBuilder(bytes.length * 2);
        for (byte b : bytes) {
            sb.append(Character.forDigit((b >> 4) & 0xF, 16)).append(Character.forDigit(b & 0xF, 16));
        }
        return sb.toString();
    }

    private static boolean isPlayerNameValid(String name) {
        return name != null && name.matches("^[A-Za-z0-9_]{3,16}$");
    }

    private static String str(Object value) {
        return value == null ? "" : String.valueOf(value);
    }

    private static double dbl(Object value) {
        if (value instanceof Number n) {
            return n.doubleValue();
        }
        try {
            return Double.parseDouble(String.valueOf(value));
        } catch (Exception e) {
            return 0;
        }
    }

    private static String header(HttpExchange exchange, String name) {
        return exchange.getRequestHeaders().getFirst(name);
    }

    private void respond(HttpExchange exchange, int code, String body) throws IOException {
        byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
        exchange.getResponseHeaders().add("Content-Type", "application/json; charset=utf-8");
        exchange.sendResponseHeaders(code, bytes.length);
        try (OutputStream os = exchange.getResponseBody()) {
            os.write(bytes);
        }
        exchange.close();
    }

    private String color(String input) {
        return org.bukkit.ChatColor.translateAlternateColorCodes('&', input == null ? "" : input);
    }

    // -------------------------------------------------------------- ledger

    @SuppressWarnings("unchecked")
    private void loadLedger() {
        if (!getDataFolder().exists() && !getDataFolder().mkdirs()) {
            getLogger().warning("Could not create the plugin data folder.");
        }
        if (!ledgerFile.exists()) {
            return;
        }
        try {
            String json = Files.readString(ledgerFile.toPath(), StandardCharsets.UTF_8);
            Type type = new TypeToken<List<String>>() {}.getType();
            List<String> entries = gson.fromJson(json, type);
            if (entries != null) {
                processed.addAll(entries);
            }
            getLogger().info("Ledger loaded: " + processed.size() + " processed transaction(s).");
        } catch (Exception e) {
            getLogger().log(Level.WARNING, "Could not read ledger.json", e);
        }
    }

    private void trimLedger() {
        while (processed.size() > MAX_LEDGER) {
            var iterator = processed.iterator();
            iterator.next();
            iterator.remove();
        }
    }

    private void saveLedgerAsync() {
        Bukkit.getScheduler().runTaskAsynchronously(this, this::saveLedger);
    }

    private void saveLedger() {
        try {
            List<String> snapshot;
            synchronized (processed) {
                snapshot = new ArrayList<>(processed);
            }
            Files.writeString(ledgerFile.toPath(), gson.toJson(snapshot), StandardCharsets.UTF_8);
        } catch (IOException e) {
            getLogger().log(Level.WARNING, "Could not write ledger.json", e);
        }
    }

    /** Kept for completeness: OfflinePlayer lookup is available if the grant command needs it. */
    @SuppressWarnings("unused")
    private boolean playerExists(String name) {
        OfflinePlayer offline = Bukkit.getOfflinePlayer(name);
        return offline != null && (offline.hasPlayedBefore() || offline.isOnline());
    }

    /** Unused placeholder to keep UUID import meaningful for future ledger sharding. */
    @SuppressWarnings("unused")
    private String randomId() {
        return UUID.randomUUID().toString();
    }

    static {
        // Documented hook point: if your server exposes a YamlConfiguration reload helper
        // it can be wired here. (Kept intentionally simple.)
    }
}
