package net.viper.status.modules.scoreboard;

import net.md_5.bungee.api.ChatColor;
import net.md_5.bungee.api.CommandSender;
import net.md_5.bungee.api.ProxyServer;
import net.md_5.bungee.api.connection.ProxiedPlayer;
import net.md_5.bungee.api.event.PlayerDisconnectEvent;
import net.md_5.bungee.api.event.PostLoginEvent;
import net.md_5.bungee.api.event.ServerSwitchEvent;
import net.md_5.bungee.api.event.TabCompleteEvent;
import net.md_5.bungee.api.plugin.Command;
import net.md_5.bungee.api.plugin.Listener;
import net.md_5.bungee.api.plugin.Plugin;
import net.md_5.bungee.api.scheduler.ScheduledTask;
import net.md_5.bungee.event.EventHandler;
import net.md_5.bungee.protocol.packet.ScoreboardDisplay;
import net.md_5.bungee.protocol.packet.ScoreboardObjective;
import net.md_5.bungee.protocol.packet.ScoreboardObjective.HealthDisplay;
import net.md_5.bungee.protocol.packet.ScoreboardScore;
import net.md_5.bungee.protocol.packet.Team;
import net.md_5.bungee.protocol.packet.Team.NameTagVisibility;
import net.md_5.bungee.protocol.packet.Team.CollisionRule;
import net.md_5.bungee.protocol.util.Either;
import net.md_5.bungee.protocol.data.NumberFormat;
import net.viper.status.StatusAPI;
import net.viper.status.module.Module;

import java.io.*;
import java.lang.management.ManagementFactory;
import java.lang.management.MemoryMXBean;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.text.DecimalFormat;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;

public class ScoreboardModule implements Module, Listener {

    private static final String CONFIG_FILE = "scoreboard.properties";
    private static final String OBJ_NAME       = "vpsb";
    private static final String OBJ_NAME_ADMIN  = "vpsbadmin";
    private static final String OBJ_NAME_SUPP   = "vpsbsupp";

    public static final ConcurrentHashMap<UUID, Double> playerHealth   = new ConcurrentHashMap<>();
    public static final ConcurrentHashMap<UUID, String> playerCompass  = new ConcurrentHashMap<>();
    public static final ConcurrentHashMap<UUID, Double> playerTps      = new ConcurrentHashMap<>();
    // Neue Placeholder-Daten
    public static final ConcurrentHashMap<UUID, Integer> playerX       = new ConcurrentHashMap<>();
    public static final ConcurrentHashMap<UUID, Integer> playerY       = new ConcurrentHashMap<>();
    public static final ConcurrentHashMap<UUID, Integer> playerZ       = new ConcurrentHashMap<>();
    public static final ConcurrentHashMap<UUID, String>  playerWorld   = new ConcurrentHashMap<>();
    public static final ConcurrentHashMap<UUID, String>  playerGamemode= new ConcurrentHashMap<>();
    public static final ConcurrentHashMap<UUID, Integer> playerExp     = new ConcurrentHashMap<>();
    public static final ConcurrentHashMap<UUID, Integer> playerFood    = new ConcurrentHashMap<>();
    public static final ConcurrentHashMap<UUID, Double>  playerSpeed   = new ConcurrentHashMap<>();

    // ── TicketSystem Placeholder ──────────────────────────────────────────────
    /** Eigene aktive Tickets des Spielers (OPEN + CLAIMED + FORWARDED) */
    public static final ConcurrentHashMap<UUID, Integer> ticketMyOpen      = new ConcurrentHashMap<>();
    /** Alle offenen Tickets gesamt (Status: OPEN) – f\u00fcr Supporter & Admin */
    public static final java.util.concurrent.atomic.AtomicInteger ticketTotalOpen    = new java.util.concurrent.atomic.AtomicInteger(0);
    /** Alle Tickets in Bearbeitung gesamt (Status: CLAIMED) – f\u00fcr Admin */
    public static final java.util.concurrent.atomic.AtomicInteger ticketTotalClaimed = new java.util.concurrent.atomic.AtomicInteger(0);
    /** Positive Bewertungen gesamt – f\u00fcr Admin */
    public static final java.util.concurrent.atomic.AtomicInteger ticketRatingGood   = new java.util.concurrent.atomic.AtomicInteger(0);
    /** Negative Bewertungen gesamt – f\u00fcr Admin */
    public static final java.util.concurrent.atomic.AtomicInteger ticketRatingBad    = new java.util.concurrent.atomic.AtomicInteger(0);

    private final ConcurrentHashMap<UUID, Long> joinTimes      = new ConcurrentHashMap<>();
    /** Aktuell gerenderter Spieler – f\u00fcr PAPI-Aufl\u00f6sung in ph() */
    private UUID currentPlayerUuid = null;
    // Spieler, die das Scoreboard ausgeblendet haben
    /** FIX: Referenz auf NetworkInfoModule f\u00fcr TPS-Fallback */
    private net.viper.status.modules.network.NetworkInfoModule networkInfoModule = null;

    /** Wird von StatusAPI nach dem Registrieren aller Module aufgerufen */
    public void setNetworkInfoModule(net.viper.status.modules.network.NetworkInfoModule nim) {
        this.networkInfoModule = nim;
    }

    private final Set<UUID> hiddenPlayers  = ConcurrentHashMap.newKeySet();
    // Spieler, die manuell auf Player-Board gezwungen wurden (Override)
    private final Set<UUID> forcePlayerView    = ConcurrentHashMap.newKeySet();
    // Spieler, die manuell auf Supporter-Board gezwungen wurden
    private final Set<UUID> forceSupporterView = ConcurrentHashMap.newKeySet();
    // Spieler, die manuell auf Admin-Board gezwungen wurden (ohne Perm)
    private final Set<UUID> forceAdminView     = ConcurrentHashMap.newKeySet();

    private boolean      enabled          = true;
    private boolean      nametagEnabled   = true;

    // Spieler, f\u00fcr die bereits ein Nametag-Team gesetzt wurde (Teamname = "afk_" + player.getName() abgek\u00fcrzt)
    // Pro Target: welche Viewer haben bereits ein CREATE-Packet bekommen.
    // Fix: verhindert REMOVE ohne CREATE → "Player is either on another team" Crash.
    private final ConcurrentHashMap<UUID, Set<UUID>> nametagViewers = new ConcurrentHashMap<>();
    // Dirty-Flag-Cache: letzter gesendeter Prefix pro Spieler (UUID → prefixStr)
    private final ConcurrentHashMap<UUID, String> nametagLastPrefix = new ConcurrentHashMap<>();
    private int          updateInterval   = 500;  // Millisekunden
    private int          tickerSpeed      = 1;
    private boolean      rainbowEnabled   = true;
    private String       rainbowMode      = "wave";
    // Wellen-Farben als RGB-Arrays (vorberechnet aus Config)
    private int[][]      waveColors       = null; // null = HSB-Fallback
    private float        waveSpeed        = 0.05f; // Bewegung pro Tick
    private String       title            = "&6&lViper Network";
    private String       adminTitle       = "&c&l[Admin] &4&lPanel";
    private String       supporterTitle   = "&e&l[Support] &6&lPanel";
    private String       adminPermission      = "statusapi.scoreboard.admin";
    private String       supporterPermission  = "statusapi.scoreboard.supporter";
    private String       timeFormat       = "HH:mm";
    private String       dateFormat       = "dd.MM.yyyy";
    private String       timeZone         = "Europe/Berlin";
    private String       moneyFormat      = "#,##0.00";
    private String       decimalSeparator = ",";
    private String       tickerText       = "  Viper Network  ";
    private String       separator        = "&8&m--------------------";
    private int          tickerWidth      = 24;
    // Key = Zeilennummer (1-based), Value = Liste der Inhalte pro Page
    // Zeilen mit nur 1 Eintrag rotieren nicht, Zeilen mit 2+ Eintraegen wechseln
    private Map<Integer, List<String>> playerLineMap    = new LinkedHashMap<>();
    private Map<Integer, List<String>> adminLineMap     = new LinkedHashMap<>();
    private Map<Integer, List<String>> supporterLineMap = new LinkedHashMap<>();
    private int rotationInterval = 4; // Sekunden pro Page
    // News-Ticker
    private String  newsText   = "";
    private String  newsPrefix = "&8[&6News&8] &r";
    private int     newsWidth  = 20;
    private int     newsSpeed  = 1;
    private final ConcurrentHashMap<UUID, Integer> newsPos = new ConcurrentHashMap<>();
    private int maxLineNum = 0; // hoechste definierte Zeilennummer

    private Plugin        plugin;
    private ScheduledTask updateTask;
    private ScheduledTask titleTask;
    private ScheduledTask newsTask;
    // DateTimeFormatter ist thread-sicher (im Gegensatz zu SimpleDateFormat)
    private java.time.format.DateTimeFormatter sdf;
    private java.time.format.DateTimeFormatter sdfDate;
    private java.time.ZoneId sdfZone = java.time.ZoneId.systemDefault();
    private DecimalFormat df;
    private Method        sendPkt;
    private boolean       ready = false;


    private static final String[] ENTRIES = {
        "§0","§1","§2","§3","§4","§5","§6","§7",
        "§8","§9","§a","§b","§c","§d","§e",
        "§f§0","§f§1","§f§2","§f§3","§f§4"
    };
    private static final int MAX_LINES = 15;  // Minecraft Client zeigt max 15 Scoreboard-Eintr\u00e4ge

    private final ConcurrentHashMap<UUID, Integer> tickerPos  = new ConcurrentHashMap<>();
    private final ConcurrentHashMap<UUID, Integer> rainbowIdx = new ConcurrentHashMap<>();
    private final Set<UUID>                        created          = ConcurrentHashMap.newKeySet();
    private final Set<UUID>                        createdAdmin     = ConcurrentHashMap.newKeySet();
    private final Set<UUID>                        createdSupporter = ConcurrentHashMap.newKeySet();

    private static final ChatColor[] RAINBOW = {
        ChatColor.RED, ChatColor.GOLD, ChatColor.YELLOW,
        ChatColor.GREEN, ChatColor.AQUA, ChatColor.BLUE, ChatColor.LIGHT_PURPLE
    };

    // ── Compass Tape ─────────────────────────────────────────────────────────
    // Kontinuierlicher Kompass mit Sub-Zeichen-Interpolation.
    // Jeder Grad entspricht einem eigenen Slot → 360 Slots, kein Springen.
    // Bridge sendet normYaw (0..360, float mit 1 Dezimalstelle).
    private static final int   COMPASS_SLOTS = 360;        // 1 Slot = 1 Grad
    private static final int   COMPASS_WIN          = 19;  // sichtbare Slots (ungerade)
    private static final int   COMPASS_DEG_PER_SLOT = 9;   // muss 90 teilen: 1,2,3,5,6,9,10,15,18,30,45,90
    // Labels an festen Grad-Positionen (N=0, E=90, S=180, W=270)
    private static final int[] COMPASS_LABEL_DEG = { 0, 90, 180, 270 };
    private static final char[] COMPASS_LABEL_CH  = { 'N', 'E', 'S', 'W' };

    @Override public String getName() { return "ScoreboardModule"; }

    @Override
    public void onEnable(Plugin plugin) {
        this.plugin = plugin;
        plugin.getLogger().info("[ScoreboardModule] Starte...");
        ensureConfigExists();
        loadConfig();
        if (!enabled) { plugin.getLogger().info("[ScoreboardModule] Deaktiviert."); return; }
        try {
            Class<?> uc = Class.forName("net.md_5.bungee.UserConnection");
            sendPkt = uc.getMethod("sendPacketQueued", net.md_5.bungee.protocol.DefinedPacket.class);
            sendPkt.setAccessible(true);
            ready = true;
            plugin.getLogger().info("[ScoreboardModule] Aktiviert. Interval=" + updateInterval + "ms (mind. 250 empfohlen)");
        } catch (Exception e) {
            plugin.getLogger().severe("[ScoreboardModule] sendPacketQueued nicht gefunden: " + e);
            return;
        }
        ProxyServer.getInstance().getPluginManager().registerListener(plugin, this);
        ProxyServer.getInstance().getPluginManager().registerCommand(plugin, new ScoreboardToggleCommand());
        // updateInterval in ms f\u00fcr Daten-Updates (Kompass, Zeilen, etc.)
        updateTask = ProxyServer.getInstance().getScheduler().schedule(
                plugin, this::tickAll, updateInterval, updateInterval, TimeUnit.MILLISECONDS);
        // Separater schneller Task nur f\u00fcr den Titel (Wave-Animation, 100ms = 10fps)
        titleTask = ProxyServer.getInstance().getScheduler().schedule(
                plugin, this::tickTitle, 100, 100, TimeUnit.MILLISECONDS);
        // Separater Task f\u00fcr News-Ticker (100ms = fl\u00fcssiges Scrollen)
        newsTask = ProxyServer.getInstance().getScheduler().schedule(
                plugin, this::tickNews, 100, 100, TimeUnit.MILLISECONDS);
    }

    @Override
    public void onDisable(Plugin plugin) {
        if (updateTask != null) { updateTask.cancel(); updateTask = null; }
        if (titleTask  != null) { titleTask.cancel();  titleTask  = null; }
        if (newsTask   != null) { newsTask.cancel();   newsTask   = null; }
        for (ProxiedPlayer p : ProxyServer.getInstance().getPlayers()) {
            if (created.contains(p.getUniqueId()))          removeObjectiveAndTeams(p, OBJ_NAME,       "vt");
            if (createdAdmin.contains(p.getUniqueId()))     removeObjectiveAndTeams(p, OBJ_NAME_ADMIN,  "vta");
            if (createdSupporter.contains(p.getUniqueId())) removeObjectiveAndTeams(p, OBJ_NAME_SUPP,   "vts");
        }
        created.clear(); createdAdmin.clear(); createdSupporter.clear(); tickerPos.clear(); rainbowIdx.clear();
        hiddenPlayers.clear(); forceAdminView.clear(); forcePlayerView.clear(); forceSupporterView.clear(); newsPos.clear();
        nametagViewers.clear(); nametagLastPrefix.clear();
    }

    @EventHandler
    public void onJoin(PostLoginEvent e) {
        if (!ready) return;
        UUID id = e.getPlayer().getUniqueId();
        tickerPos.put(id, 0);
        rainbowIdx.put(id, 0);
        newsPos.put(id, 0);
        joinTimes.put(id, System.currentTimeMillis());
        // Nur State initialisieren – onSwitch baut das Scoreboard auf
        created.remove(id);
        createdAdmin.remove(id);
        createdSupporter.remove(id);
        // Nametags: Neuen Spieler nach kurzer Verz\u00f6gerung mit allen bestehenden Nametags versorgen
        // UND allen anderen den Nametag des neuen Spielers senden
        if (nametagEnabled) {
            ProxyServer.getInstance().getScheduler().schedule(plugin, () -> {
                // Alle bestehenden Spieler → neuer Spieler bekommt ihre Nametags
                // Viewer-Eintrag des neuen Spielers aus allen anderen Target-Sets löschen
                // damit sie beim nächsten updateNametag() CREATE (nicht UPDATE) bekommen.
                nametagViewers.remove(id);
                for (Set<UUID> vs : nametagViewers.values()) { vs.remove(id); }
                nametagLastPrefix.remove(id);
                for (ProxiedPlayer existing : ProxyServer.getInstance().getPlayers()) {
                    if (existing.isConnected()) updateNametag(existing);
                }
            }, 2L, TimeUnit.SECONDS);
        }
    }

    @EventHandler
    public void onSwitch(ServerSwitchEvent e) {
        if (!ready) return;
        ProxiedPlayer p  = e.getPlayer();
        UUID          id = p.getUniqueId();
        // Altes Objective sauber entfernen – tickAll \u00fcbernimmt den Neuaufbau
        if (created.contains(id))          { removeObjectiveAndTeams(p, OBJ_NAME,       "vt");  created.remove(id); }
        if (createdAdmin.contains(id))     { removeObjectiveAndTeams(p, OBJ_NAME_ADMIN,  "vta"); createdAdmin.remove(id); }
        if (createdSupporter.contains(id)) { removeObjectiveAndTeams(p, OBJ_NAME_SUPP,   "vts"); createdSupporter.remove(id); }
        // Kein verz\u00f6gerter sendAll-Call mehr – tickAll baut nach max. 500ms neu auf
    }

    @EventHandler
    public void onQuit(PlayerDisconnectEvent e) {
        UUID id = e.getPlayer().getUniqueId();
        tickerPos.remove(id); rainbowIdx.remove(id); created.remove(id); createdAdmin.remove(id); createdSupporter.remove(id);
        forcePlayerView.remove(id); forceSupporterView.remove(id); forceAdminView.remove(id);
        playerHealth.remove(id); playerCompass.remove(id); playerTps.remove(id);
        playerX.remove(id); playerY.remove(id); playerZ.remove(id);
        playerWorld.remove(id); playerGamemode.remove(id);
        playerExp.remove(id); playerFood.remove(id); playerSpeed.remove(id);
        joinTimes.remove(id); hiddenPlayers.remove(id); newsPos.remove(id);
        ticketMyOpen.remove(id); // Memory-Leak-Fix: Ticket-Daten beim Logout bereinigen
        // Nametag-Team f\u00fcr diesen Spieler bei allen anderen entfernen
        nametagLastPrefix.remove(id);
        // Viewer-Einträge dieses Spielers aus allen anderen Target-Sets entfernen
        for (Set<UUID> viewerSet : nametagViewers.values()) { viewerSet.remove(id); }
        if (nametagEnabled) removeNametag(e.getPlayer());
    }

    /** Schneller Task: aktualisiert News-Position und sendet nur die betroffene Team-Zeile */
    private void tickNews() {
        if (newsText == null || newsText.isEmpty()) return;
        String newsPlain = ChatColor.stripColor(c(newsText));
        int gap = 4;
        int nCycle = Math.max(1, newsPlain.length()) + gap;

        for (ProxiedPlayer p : ProxyServer.getInstance().getPlayers()) {
            if (!p.isConnected()) continue;
            UUID id = p.getUniqueId();
            if (hiddenPlayers.contains(id)) continue;
            boolean isAdmin = !forceSupporterView.contains(id) && !forcePlayerView.contains(id)
                && (p.hasPermission(adminPermission) || forceAdminView.contains(id));
            boolean isSupporter = !isAdmin && (forceSupporterView.contains(id) || (!forcePlayerView.contains(id) && p.hasPermission(supporterPermission)));
            Set<UUID> activeCreated = isAdmin ? createdAdmin : isSupporter ? createdSupporter : created;
            if (!activeCreated.contains(id)) continue;

            // Position vorr\u00fccken
            int nOff = (newsPos.getOrDefault(id, 0) + newsSpeed) % nCycle;
            newsPos.put(id, nOff);

            // Nur die News-Zeilen neu senden (nicht das ganze Scoreboard)
            try {
                String activeObjName = isAdmin ? OBJ_NAME_ADMIN : OBJ_NAME;
                String newsStr = buildNewsTicker(nOff);
                // Finde welche Zeilennummer(n) %news% enth\u00e4lt und sende nur diese
                java.util.Map<Integer, java.util.List<String>> lineMap =
                    isAdmin ? adminLineMap : isSupporter ? supporterLineMap : playerLineMap;
                for (java.util.Map.Entry<Integer, java.util.List<String>> entry : lineMap.entrySet()) {
                    boolean hasNews = false;
                    for (String v : entry.getValue()) {
                        if (v.contains("%news%")) { hasNews = true; break; }
                    }
                    if (!hasNews) continue;
                    int lineNum = entry.getKey();
                    int lineIdx = lineNum - 1; // 0-based index for ENTRIES
                    if (lineIdx < 0 || lineIdx >= ENTRIES.length) continue;

                    // Aktuellen Varianten-Index berechnen
                    int pageIdx = (rotationInterval > 0)
                        ? (int)((System.currentTimeMillis() / 1000) / rotationInterval) : 0;
                    java.util.List<String> variants = entry.getValue();
                    String tpl = variants.get(pageIdx % variants.size());
                    if (!tpl.contains("%news%")) continue;

                    String lineText = c(tpl.replace("%news%", newsStr));

                    // Team-Packet nur f\u00fcr diese Zeile senden
                    net.md_5.bungee.protocol.packet.Team team = new net.md_5.bungee.protocol.packet.Team();
                    team.setName((isAdmin ? "vta" : isSupporter ? "vts" : "vt") + lineIdx);
                    team.setMode((byte) 2); // UPDATE
                    net.md_5.bungee.api.chat.TextComponent tc =
                        new net.md_5.bungee.api.chat.TextComponent("");
                    for (net.md_5.bungee.api.chat.BaseComponent bc : buildComponents(lineText))
                        tc.addExtra(bc);
                    team.setPrefix(Either.right(tc));
                    team.setSuffix(Either.right(new net.md_5.bungee.api.chat.TextComponent("")));
                    team.setDisplayName(Either.right(new net.md_5.bungee.api.chat.TextComponent("")));
                    team.setNameTagVisibility(Either.right(net.md_5.bungee.protocol.packet.Team.NameTagVisibility.ALWAYS));
                    team.setCollisionRule(Either.right(net.md_5.bungee.protocol.packet.Team.CollisionRule.ALWAYS));
                    team.setColor(Optional.of(21));
                    team.setFriendlyFire((byte) 3);
                    sendPkt.invoke(p, team);
                }
            } catch (Exception ignored) {}
        }
    }

    /** Schneller Task: aktualisiert nur den Objective-Titel f\u00fcr fl\u00fcssige Wave-Animation */
    private void tickTitle() {
        if (!rainbowEnabled || !"wave".equals(rainbowMode)) return;
        for (ProxiedPlayer p : ProxyServer.getInstance().getPlayers()) {
            if (!p.isConnected()) continue;
            UUID id = p.getUniqueId();
            if (hiddenPlayers.contains(id)) continue;
            boolean isAdmin = !forceSupporterView.contains(id) && !forcePlayerView.contains(id)
                && (p.hasPermission(adminPermission) || forceAdminView.contains(id));
            boolean isSupporter = !isAdmin && (forceSupporterView.contains(id) || (!forcePlayerView.contains(id) && p.hasPermission(supporterPermission)));
            Set<UUID> activeCreated = isAdmin ? createdAdmin : isSupporter ? createdSupporter : created;
            // BUG-FIX: UPDATE_TITLE (mode=2) darf nur gesendet werden wenn das Objective
            // bereits mit CREATE (mode=0) angelegt wurde. Sonst: "Fehler im Netzwerkprotokoll".
            if (!activeCreated.contains(id)) continue;
            try {
                int rIdx = (rainbowIdx.getOrDefault(id, 0) + 1) % 10000;
                rainbowIdx.put(id, rIdx);
                String activeObjName = isAdmin ? OBJ_NAME_ADMIN : isSupporter ? OBJ_NAME_SUPP : OBJ_NAME;
                String rawTitle = isAdmin ? adminTitle : isSupporter ? supporterTitle : title;
                String titleStr = rainbow(c(rawTitle), rIdx);
                ScoreboardObjective obj = new ScoreboardObjective();
                obj.setName(activeObjName);
                obj.setAction((byte) 2); // UPDATE_TITLE
                net.md_5.bungee.api.chat.TextComponent tc =
                    new net.md_5.bungee.api.chat.TextComponent("");
                for (net.md_5.bungee.api.chat.BaseComponent bc : buildComponents(titleStr))
                    tc.addExtra(bc);
                obj.setValue(Either.right(tc));
                obj.setType(HealthDisplay.INTEGER);
                sendPkt.invoke(p, obj);
            } catch (Exception ignored) {}
        }
    }

    private void tickAll() {
        // Nametags (Prefix \u00fcber dem Kopf) periodisch aktualisieren
        for (ProxiedPlayer p : ProxyServer.getInstance().getPlayers()) {
            if (!p.isConnected()) continue;
            UUID id = p.getUniqueId();
            try {
                sendAll(p);
            } catch (Exception e) {
                plugin.getLogger().warning("[ScoreboardModule] " + p.getName() + ": " + e);
                created.add(id);
            }
        }
        // Nametag-Packets an alle Spieler senden (AFK-Prefix \u00fcber dem Kopf)
        if (nametagEnabled) {
            for (ProxiedPlayer target : ProxyServer.getInstance().getPlayers()) {
                updateNametag(target);
            }
        }
    }

    /**
     * Sendet ein Team-Packet an alle online Spieler, das den Prefix
     * über dem Kopf des 'target'-Spielers setzt.
     *
     * FIX: Pro-Viewer-Tracking via nametagViewers.
     * Jeder Viewer bekommt beim ersten Mal CREATE (mode=0), danach UPDATE (mode=2).
     * Nur wenn sich der Prefix geändert hat werden überhaupt Packets gesendet (Dirty-Flag).
     * removeNametag() sendet REMOVE nur an Viewer die CREATE bekommen haben → kein Crash.
     */
    private void updateNametag(ProxiedPlayer target) {
        if (!ready || !target.isConnected()) return;
        try {
            UUID id = target.getUniqueId();
            boolean isAfk = Boolean.TRUE.equals(net.viper.status.StatusAPI.playerAfk.get(id));
            String lpPrefix = getLpPrefix(target);

            String teamName = nametagTeamName(target);
            String prefixStr = isAfk ? "§7[AFK] §r" : (lpPrefix.isEmpty() ? "" : lpPrefix + "§r ");

            String lastPrefix = nametagLastPrefix.get(id);
            boolean prefixChanged = !prefixStr.equals(lastPrefix);

            // Viewer-Set für diesen Target holen (oder neu anlegen)
            Set<UUID> viewers = nametagViewers.computeIfAbsent(id, k -> ConcurrentHashMap.newKeySet());

            // Nur senden wenn Prefix sich geändert hat ODER es neue Viewer gibt
            for (ProxiedPlayer viewer : ProxyServer.getInstance().getPlayers()) {
                if (!viewer.isConnected()) continue;
                UUID vid = viewer.getUniqueId();
                boolean viewerIsNew = !viewers.contains(vid);

                // Kein Update nötig wenn Prefix gleich und Viewer schon CREATE hatte
                if (!prefixChanged && !viewerIsNew) continue;

                try {
                    Team team = new Team();
                    team.setName(teamName);
                    team.setMode(viewerIsNew ? (byte) 0 : (byte) 2); // 0=CREATE, 2=UPDATE
                    net.md_5.bungee.api.chat.TextComponent pfxComp =
                        new net.md_5.bungee.api.chat.TextComponent("");
                    for (net.md_5.bungee.api.chat.BaseComponent bc : buildComponents(c(prefixStr)))
                        pfxComp.addExtra(bc);
                    team.setPrefix(Either.right(pfxComp));
                    team.setSuffix(Either.right(new net.md_5.bungee.api.chat.TextComponent("")));
                    team.setDisplayName(Either.right(new net.md_5.bungee.api.chat.TextComponent("")));
                    team.setNameTagVisibility(Either.right(NameTagVisibility.ALWAYS));
                    team.setCollisionRule(Either.right(CollisionRule.ALWAYS));
                    team.setColor(Optional.of(21)); // RESET
                    team.setFriendlyFire((byte) 3);
                    if (viewerIsNew) team.setPlayers(new String[]{ target.getName() });
                    sendPkt.invoke(viewer, team);

                    // Eintrag zurueckholen: StatusAPIBridge setzt serverseitig ein
                    // eigenes Nametag-Team (vnt...). Sobald das den Spieler aufnimmt,
                    // faellt er clientseitig aus unserem Team - ein UPDATE (mode=2)
                    // traegt keine Eintraege und liefe danach dauerhaft ins Leere.
                    // ADD_PLAYER (mode=3) holt ihn bei jeder Prefix-Aenderung zurueck.
                    if (!viewerIsNew) {
                        Team claim = new Team();
                        claim.setName(teamName);
                        claim.setMode((byte) 3); // ADD_PLAYER
                        claim.setPlayers(new String[]{ target.getName() });
                        sendPkt.invoke(viewer, claim);
                    }
                    viewers.add(vid); // Viewer als "hat CREATE" markieren
                } catch (Exception ignored) {}
            }
            nametagLastPrefix.put(id, prefixStr);
        } catch (Exception e) {
            plugin.getLogger().warning("[ScoreboardModule] Nametag-Fehler für " + target.getName() + ": " + e.getMessage());
        }
    }

    /**
     * Team-Name fuer den Nametag eines Spielers.
     * Basiert auf der UUID statt auf dem Namen: bei namensbasierten Teams
     * (erste 13 Zeichen) landeten Spieler mit gleichem Namensanfang – etwa
     * "ViperNetworkA" und "ViperNetworkB" – im selben Team und haben sich
     * gegenseitig den Prefix ueberschrieben. Minecraft-Limit: 16 Zeichen.
     */
    private static String nametagTeamName(ProxiedPlayer target) {
        return "nt" + target.getUniqueId().toString().replace("-", "").substring(0, 13);
    }

    /**
     * Entfernt das Nametag-Team beim Disconnect sauber vom Client aller Spieler.
     */
    private void removeNametag(ProxiedPlayer target) {
        UUID id = target.getUniqueId();
        String teamName = nametagTeamName(target);
        // REMOVE nur an Viewer senden die auch CREATE erhalten haben (per-Viewer-Tracking).
        // Sonst: "Player is either on another team or not on any team" → Client-Crash.
        Set<UUID> viewers = nametagViewers.remove(id);
        if (viewers != null) {
            for (UUID vid : viewers) {
                ProxiedPlayer viewer = ProxyServer.getInstance().getPlayer(vid);
                if (viewer == null || !viewer.isConnected()) continue;
                if (vid.equals(id)) continue; // Den Target selbst überspringen
                try {
                    Team team = new Team();
                    team.setName(teamName);
                    team.setMode((byte) 1); // REMOVE
                    sendPkt.invoke(viewer, team);
                } catch (Exception ignored) {}
            }
        }
        nametagLastPrefix.remove(id);
    }

    /**
     * Liest den LuckPerms-Prefix eines Spielers (gleiche Logik wie getRank, aber roher Prefix).
     */
    private String getLpPrefix(ProxiedPlayer player) {
        try {
            Class<?> prov = Class.forName("net.luckperms.api.LuckPermsProvider");
            Object api = prov.getMethod("get").invoke(null);
            Object um  = api.getClass().getMethod("getUserManager").invoke(api);
            Object usr = um.getClass().getMethod("getUser", UUID.class).invoke(um, player.getUniqueId());
            if (usr != null) {
                Class<?> qo   = Class.forName("net.luckperms.api.query.QueryOptions");
                Object   opts = qo.getMethod("defaultContextualOptions").invoke(null);
                Object   cache= usr.getClass().getMethod("getCachedData").invoke(usr);
                Object   meta = cache.getClass().getMethod("getMetaData", qo).invoke(cache, opts);
                Object   pfx  = meta.getClass().getMethod("getPrefix").invoke(meta);
                if (pfx != null && !pfx.toString().isEmpty()) return pfx.toString();
            }
        } catch (Exception ignored) {}
        return "";
    }

    private void sendAll(ProxiedPlayer p) throws Exception {
        if (!ready || !p.isConnected()) return;
        UUID id = p.getUniqueId();

        // Scoreboard ausgeblendet → Objective + Teams sauber entfernen (einmalig) und raus
        if (hiddenPlayers.contains(id)) {
            if (created.contains(id)) {
                removeObjectiveAndTeams(p, OBJ_NAME, "vt");
                created.remove(id);
            }
            if (createdAdmin.contains(id)) {
                removeObjectiveAndTeams(p, OBJ_NAME_ADMIN, "vta");
                createdAdmin.remove(id);
            }
            return;
        }

        // forcePlayerView hat Vorrang vor Perm und forceAdminView
        boolean isAdmin = !forceSupporterView.contains(id) && !forcePlayerView.contains(id)
            && (p.hasPermission(adminPermission) || forceAdminView.contains(id));
        boolean isSupporter = !isAdmin && (forceSupporterView.contains(id)
            || (!forcePlayerView.contains(id) && p.hasPermission(supporterPermission)));
        String rawTicker = stripColors(tickerText);
        int tLen = Math.max(1, rawTicker.length());
        int tOff = (tickerPos.getOrDefault(id, 0) + tickerSpeed) % tLen;
        tickerPos.put(id, tOff);
        int rIdx = rainbowIdx.getOrDefault(id, 0); // wird von tickTitle aktualisiert

        String pn       = p.getName();
        String rank     = getRank(p);
        String money    = getMoney(p);
        String srvRaw   = p.getServer() != null ? p.getServer().getInfo().getName() : "?";
        String srv      = srvRaw.isEmpty() ? srvRaw
            : Character.toUpperCase(srvRaw.charAt(0)) + srvRaw.substring(1);
        String comp     = buildCompass(playerCompass.getOrDefault(id, "0"));
        String hp       = formatHealth(playerHealth.getOrDefault(id, 20.0));
        String hpNum    = String.valueOf((int) Math.ceil(playerHealth.getOrDefault(id, 20.0) / 2.0));
        String ping     = String.valueOf(p.getPing());
        String online   = String.valueOf(ProxyServer.getInstance().getOnlineCount());
        // FIX: getPlayerLimit() gibt -1 wenn kein Limit gesetzt → "∞" anzeigen
        int rawLimit = ProxyServer.getInstance().getConfig().getPlayerLimit();
        if (rawLimit <= 0) {
            // Listener-Limit als Fallback
            try {
                java.util.Iterator<net.md_5.bungee.api.config.ListenerInfo> limIt =
                    ProxyServer.getInstance().getConfig().getListeners().iterator();
                if (limIt.hasNext()) {
                    int listenerMax = limIt.next().getMaxPlayers();
                    if (listenerMax > 0) rawLimit = listenerMax;
                }
            } catch (Exception ignored) {}
        }
        String maxpl = rawLimit > 0 ? String.valueOf(rawLimit) : "∞";
        String tps      = isAdmin ? getTps(id) : "";
        String ram      = isAdmin ? getRam() : "";
        String time     = sdf.format(java.time.LocalDateTime.now(sdfZone));
        String date     = sdfDate.format(java.time.LocalDateTime.now(sdfZone));
        String playtime = formatPlaytime(id);
        // Neue Placeholders
        String xCoord   = String.valueOf(playerX.getOrDefault(id, 0));
        String yCoord   = String.valueOf(playerY.getOrDefault(id, 0));
        String zCoord   = String.valueOf(playerZ.getOrDefault(id, 0));
        String world    = playerWorld.getOrDefault(id, srv);
        String gamemode = playerGamemode.getOrDefault(id, "?");
        String exp      = String.valueOf(playerExp.getOrDefault(id, 0));
        String food     = String.valueOf(playerFood.getOrDefault(id, 20));
        String foodSym  = formatFood(playerFood.getOrDefault(id, 20));
        String speed    = String.format("%.1f", playerSpeed.getOrDefault(id, 0.2) * 10);
        String uptime   = getUptime();
        // News: aktuellen Ticker-Stand lesen (wird von tickNews aktualisiert)
        String news    = buildNewsTicker(newsPos.getOrDefault(id, 0));
        String servers  = String.valueOf(ProxyServer.getInstance().getServers().size());
        String proxymem = getRam();

        // ── TicketSystem ──────────────────────────────────────────────────────
        String ticketMyOpenStr       = String.valueOf(ticketMyOpen.getOrDefault(id, 0));
        String ticketTotalOpenStr    = String.valueOf(ticketTotalOpen.get());
        String ticketTotalClaimedStr = String.valueOf(ticketTotalClaimed.get());
        String ticketRatingGoodStr   = String.valueOf(ticketRatingGood.get());
        String ticketRatingBadStr    = String.valueOf(ticketRatingBad.get());
        int    _tGood  = ticketRatingGood.get();
        int    _tBad   = ticketRatingBad.get();
        String ticketRatingPctStr    = (_tGood + _tBad == 0) ? "-"
                : String.valueOf(Math.round(_tGood * 100.0 / (_tGood + _tBad)));

        // Per-Zeile Rotation: Zeilen mit mehreren Inhalten wechseln automatisch
        Map<Integer, List<String>> lineMap = isAdmin       ? adminLineMap
                                           : isSupporter   ? supporterLineMap
                                           : playerLineMap;
        if (lineMap.isEmpty()) lineMap = playerLineMap;
        // Aktueller Page-Index basierend auf Zeit
        int pageIdx = (rotationInterval > 0)
            ? (int)((System.currentTimeMillis() / 1000) / rotationInterval)
            : 0;
        // Zeilen aufbauen: fuer jede Zeilennummer den aktuellen Inhalt waehlen
        int lineCount = lineMap.isEmpty() ? 0
            : lineMap.keySet().stream().mapToInt(Integer::intValue).max().orElse(0);
        List<String> srcLines = new ArrayList<>();
        for (int ln = 1; ln <= lineCount; ln++) {
            List<String> variants = lineMap.get(ln);
            if (variants == null || variants.isEmpty()) {
                srcLines.add("");
            } else {
                // Zeile mit 1 Variante = immer gleich; mit 2+ = rotieren
                int vi = pageIdx % variants.size();
                srcLines.add(variants.get(vi));
            }
        }
        List<String> lines = new ArrayList<>();
        boolean hasTicker = !tickerText.isEmpty() && !isAdmin && !isSupporter;
        if (hasTicker) lines.add(ticker(rawTicker, tOff, rIdx));
        // Maximale Inhaltszeilen: MAX_LINES insgesamt (Ticker z\u00e4hlt als eine)
        currentPlayerUuid = id; // f\u00fcr PAPI-Aufl\u00f6sung in ph()
        for (String tpl : srcLines) {
            if (lines.size() >= MAX_LINES) break;
            lines.add(c(ph(tpl, pn, rank, money, srv, comp, hp, hpNum, ping, online, maxpl, tps, ram, time, playtime,
                          xCoord, yCoord, zCoord, world, gamemode, exp, food, foodSym, speed, uptime, servers, proxymem, date, news,
                          ticketMyOpenStr, ticketTotalOpenStr, ticketTotalClaimedStr,
                          ticketRatingGoodStr, ticketRatingBadStr, ticketRatingPctStr)));
        }
        // Immer genau MAX_LINES Zeilen (Rest mit Leerzeilen auff\u00fcllen)
        if (lines.size() > MAX_LINES) lines = new ArrayList<>(lines.subList(0, MAX_LINES));
        while (lines.size() < MAX_LINES) lines.add(" ");

        // Objective-Name und Titel je nach Rolle
        String activeObjName = isAdmin     ? OBJ_NAME_ADMIN
                             : isSupporter ? OBJ_NAME_SUPP
                             : OBJ_NAME;
        String titleStr = isAdmin     ? rainbow(c(adminTitle),     rIdx)
                        : isSupporter ? rainbow(c(supporterTitle),  rIdx)
                        : rainbow(c(title), rIdx);

        // Wenn Rolle wechselt: altes Objective sauber entfernen
        if (isAdmin) {
            if (created.contains(id))          { removeObjectiveAndTeams(p, OBJ_NAME,       "vt");  created.remove(id); }
            if (createdSupporter.contains(id)) { removeObjectiveAndTeams(p, OBJ_NAME_SUPP,  "vts"); createdSupporter.remove(id); }
        } else if (isSupporter) {
            if (created.contains(id))      { removeObjectiveAndTeams(p, OBJ_NAME,       "vt");  created.remove(id); }
            if (createdAdmin.contains(id)) { removeObjectiveAndTeams(p, OBJ_NAME_ADMIN, "vta"); createdAdmin.remove(id); }
        } else {
            if (createdAdmin.contains(id))     { removeObjectiveAndTeams(p, OBJ_NAME_ADMIN, "vta"); createdAdmin.remove(id); }
            if (createdSupporter.contains(id)) { removeObjectiveAndTeams(p, OBJ_NAME_SUPP,  "vts"); createdSupporter.remove(id); }
        }

        Set<UUID> activeCreated = isAdmin ? createdAdmin : isSupporter ? createdSupporter : created;

        // Objective CREATE (einmalig) oder UPDATE_TITLE
        boolean justCreated = !activeCreated.contains(id);
        ScoreboardObjective obj = new ScoreboardObjective();
        obj.setName(activeObjName);
        if (justCreated) {
            obj.setAction((byte) 0); // CREATE
            net.md_5.bungee.api.chat.TextComponent titleComp =
                new net.md_5.bungee.api.chat.TextComponent("");
            for (net.md_5.bungee.api.chat.BaseComponent bc : buildComponents(titleStr))
                titleComp.addExtra(bc);
            obj.setValue(Either.right(titleComp));
            obj.setType(HealthDisplay.INTEGER);
            sendPkt.invoke(p, obj);

            ScoreboardDisplay disp = new ScoreboardDisplay();
            disp.setPosition(1); // Sidebar
            disp.setName(activeObjName);
            sendPkt.invoke(p, disp);

            activeCreated.add(id);
        } else {
            obj.setAction((byte) 2); // UPDATE_TITLE
            net.md_5.bungee.api.chat.TextComponent titleComp2 =
                new net.md_5.bungee.api.chat.TextComponent("");
            for (net.md_5.bungee.api.chat.BaseComponent bc : buildComponents(titleStr))
                titleComp2.addExtra(bc);
            obj.setValue(Either.right(titleComp2));
            obj.setType(HealthDisplay.INTEGER);
            sendPkt.invoke(p, obj);
        }

        // Scores + Teams
        for (int i = 0; i < MAX_LINES; i++) {
            if (i < lines.size()) {
                ScoreboardScore score = new ScoreboardScore();
                score.setItemName(ENTRIES[i]);
                score.setScoreName(activeObjName);
                score.setAction((byte) 0);
                score.setValue(lines.size() - i);
                // NumberFormat.BLANK versteckt die Zahlen rechts
                try {
                    Class<?> ntCls = Class.forName("net.md_5.bungee.protocol.data.NumberFormat$Type");
                    Object blank = ntCls.getMethod("valueOf", String.class).invoke(null, "BLANK");
                    Class<?> nfCls = Class.forName("net.md_5.bungee.protocol.data.NumberFormat");
                    Object nf = nfCls.getDeclaredConstructor(ntCls, Object.class).newInstance(blank, null);
                    score.getClass().getMethod("setNumberFormat", nfCls).invoke(score, nf);
                } catch (Exception ignored) {}
                sendPkt.invoke(p, score);

                Team team = new Team();
                team.setName((isAdmin ? "vta" : isSupporter ? "vts" : "vt") + i);
                // CREATE wenn das Objective gerade frisch angelegt wurde, sonst UPDATE
                team.setMode(justCreated ? (byte) 0 : (byte) 2);
                // Hex-Farben: BaseComponent[] als Container-TextComponent verpacken
                net.md_5.bungee.api.chat.BaseComponent[] prefixComp =
                    buildComponents(lines.get(i));
                net.md_5.bungee.api.chat.TextComponent prefixContainer =
                    new net.md_5.bungee.api.chat.TextComponent("");
                for (net.md_5.bungee.api.chat.BaseComponent bc : prefixComp)
                    prefixContainer.addExtra(bc);
                team.setPrefix(Either.right(prefixContainer));
                team.setSuffix(Either.right(new net.md_5.bungee.api.chat.TextComponent("")));
                team.setDisplayName(Either.right(new net.md_5.bungee.api.chat.TextComponent("")));
                team.setNameTagVisibility(Either.right(NameTagVisibility.ALWAYS));
                team.setCollisionRule(Either.right(CollisionRule.ALWAYS));
                team.setColor(Optional.of(21)); // RESET
                team.setFriendlyFire((byte) 3);
                team.setPlayers(new String[]{ ENTRIES[i] });
                sendPkt.invoke(p, team);
            } else {
                // Leeren Score entfernen
                try {
                    Class<?> cls = Class.forName("net.md_5.bungee.protocol.packet.ScoreboardScoreReset");
                    Object pkt = cls.getDeclaredConstructor().newInstance();
                    cls.getMethod("setEntity",    String.class).invoke(pkt, ENTRIES[i]);
                    cls.getMethod("setObjective", String.class).invoke(pkt, activeObjName);
                    sendPkt.invoke(p, pkt);
                } catch (Exception ignored) {}
            }
        }
    }

    // ── Ticker & Rainbow ─────────────────────────────────────────────────────

    private String ticker(String raw, int offset, int rIdx) {
        if (raw.isEmpty()) return " ";
        String doubled = raw + raw;
        String visible = doubled.substring(offset, offset + Math.min(tickerWidth, raw.length()));
        return rainbow(visible, rIdx);
    }

    /**
     * Wave-Effekt: jeder Buchstabe bekommt eine interpolierte Farbe.
     *
     * mode="wave"  → Farbwelle mit konfigurierbaren Farben (smooth interpoliert)
     * mode="chars" → klassisch mit RAINBOW-Array
     * mode="line"  → gesamter Text in einer Farbe
     *
     * idx steigt pro Tick → Welle wandert von links nach rechts.
     */
    private String rainbow(String text, int idx) {
        if (!rainbowEnabled || text == null || text.isEmpty()) return text == null ? " " : text;
        String plain = ChatColor.stripColor(text);
        if (plain.isEmpty()) return text;

        if ("wave".equals(rainbowMode)) {
            StringBuilder sb = new StringBuilder();
            boolean bold      = text.contains("§l") || text.contains("&l");
            boolean italic    = text.contains("§o") || text.contains("&o");
            boolean underline = text.contains("§n") || text.contains("&n");
            boolean strike    = text.contains("§m") || text.contains("&m");
            String fmt = (bold ? "§l" : "") + (italic ? "§o" : "")
                       + (underline ? "§n" : "") + (strike ? "§m" : "");
            // Sichtbare Zeichen z\u00e4hlen
            int visLen = 0;
            for (char c : plain.toCharArray()) if (c != ' ') visLen++;
            int charIdx = 0;
            for (int i = 0; i < plain.length(); i++) {
                char ch = plain.charAt(i);
                if (ch == ' ') { sb.append(' '); continue; }
                // JAWa-Style: Hue gleichm\u00e4\u00dfig \u00fcber alle Buchstaben verteilt, wandert pro Tick
                float hue = ((float) charIdx / Math.max(visLen, 1) + idx * this.waveSpeed) % 1.0f;
                if (hue < 0) hue += 1.0f;
                int[] rgb = waveColors != null
                    ? interpolateWaveColor(hue)  // konfigurierte Farben
                    : hsbWave(hue);              // Sinus-Regenbogen (Default)
                sb.append('§').append('x');
                sb.append('§').append(String.format("%02X", rgb[0]).charAt(0));
                sb.append('§').append(String.format("%02X", rgb[0]).charAt(1));
                sb.append('§').append(String.format("%02X", rgb[1]).charAt(0));
                sb.append('§').append(String.format("%02X", rgb[1]).charAt(1));
                sb.append('§').append(String.format("%02X", rgb[2]).charAt(0));
                sb.append('§').append(String.format("%02X", rgb[2]).charAt(1));
                sb.append(fmt);
                sb.append(ch);
                charIdx++;
            }
            return sb.toString();
        }
        if ("chars".equals(rainbowMode)) {
            StringBuilder sb = new StringBuilder();
            for (int i = 0; i < plain.length(); i++) {
                sb.append(RAINBOW[(idx + i) % RAINBOW.length]);
                sb.append(plain.charAt(i));
            }
            return sb.toString();
        }
        // mode="line": ganzer Text in einer Farbe
        return RAINBOW[idx % RAINBOW.length] + plain;
    }

    /**
     * Interpoliert zwischen den konfigurierten Wellen-Farben.
     * pos = 0.0 .. 1.0 (Position in der Welle)
     * Wenn keine Farben konfiguriert: HSB-Fallback (voller Regenbogen)
     */
    /**
     * Verarbeitet %gradient:FARBE1:FARBE2:TEXT% Placeholder.
     *
     * Beispiele:
     *   %gradient:#FF0000:#0000FF:Hallo Welt%
     *   %gradient:&c:&9:> Player Info:%
     *   %gradient:#FF0000:#FFFF00:#0000FF:Drei Farben%  (beliebig viele Stopps)
     *
     * Auch mit Formatierung:
     *   %gradient:#00FFFF:#0000FF:&l> Player Info:%
     *
     * Der Text darf Farb-/Formatcodes enthalten – sie werden nach jedem
     * Farbcode wiederhergestellt.
     */
    private String applyGradients(String input) {
        if (input == null || !input.contains("%gradient:")) return input;
        StringBuilder result = new StringBuilder();
        int i = 0;
        while (i < input.length()) {
            int start = input.indexOf("%gradient:", i);
            if (start < 0) { result.append(input.substring(i)); break; }
            result.append(input, i, start);
            int end = input.indexOf("%", start + 10);
            if (end < 0) { result.append(input.substring(start)); break; }
            // Parse: %gradient:C1:C2:...:TEXT%
            String inner = input.substring(start + 10, end);
            // Letzter Teil ist der Text, vorherige Teile sind Farben
            // Text beginnt nach dem letzten ':' der eine Farbe abschlie\u00dft
            // Strategie: Teile von links lesen solange sie Farben sind
            java.util.List<int[]> stops = new java.util.ArrayList<>();
            int colonIdx = 0;
            while (colonIdx < inner.length()) {
                // N\u00e4chsten ':' suchen
                int nextColon = inner.indexOf(':', colonIdx);
                if (nextColon < 0) break;
                String candidate = inner.substring(colonIdx, nextColon);
                int[] rgb = parseGradientColor(candidate);
                if (rgb != null) {
                    stops.add(rgb);
                    colonIdx = nextColon + 1;
                } else {
                    break;
                }
            }
            if (stops.size() < 2) {
                // Nicht genug Farben – unveraendert lassen
                result.append(input, start, end + 1);
                i = end + 1;
                continue;
            }
            String text = inner.substring(colonIdx);
            // Formatcodes aus Text extrahieren
            String plain = ChatColor.stripColor(c(text));
            boolean bold      = text.contains("&l") || text.contains("\u00A7l");
            boolean italic    = text.contains("&o") || text.contains("\u00A7o");
            boolean underline = text.contains("&n") || text.contains("\u00A7n");
            boolean strike    = text.contains("&m") || text.contains("\u00A7m");
            String fmt = (bold ? "\u00A7l" : "") + (italic ? "\u00A7o" : "")
                       + (underline ? "\u00A7n" : "") + (strike ? "\u00A7m" : "");
            // Gradient auf sichtbare Zeichen anwenden
            int visLen = 0;
            for (char ch : plain.toCharArray()) if (ch != ' ') visLen++;
            if (visLen == 0) visLen = 1;
            int charIdx = 0;
            int[] lastRgb = stops.get(0);
            for (char ch : plain.toCharArray()) {
                if (ch == ' ') {
                    result.append('\u00A7').append('x');
                    result.append('\u00A7').append(String.format("%02X", lastRgb[0]).charAt(0));
                    result.append('\u00A7').append(String.format("%02X", lastRgb[0]).charAt(1));
                    result.append('\u00A7').append(String.format("%02X", lastRgb[1]).charAt(0));
                    result.append('\u00A7').append(String.format("%02X", lastRgb[1]).charAt(1));
                    result.append('\u00A7').append(String.format("%02X", lastRgb[2]).charAt(0));
                    result.append('\u00A7').append(String.format("%02X", lastRgb[2]).charAt(1));
                    result.append(fmt).append(ch);
                    continue;
                }
                float pos = visLen <= 1 ? 0f : (float) charIdx / (visLen - 1);
                lastRgb = interpolateGradient(stops, pos);
                result.append('\u00A7').append('x');
                result.append('\u00A7').append(String.format("%02X", lastRgb[0]).charAt(0));
                result.append('\u00A7').append(String.format("%02X", lastRgb[0]).charAt(1));
                result.append('\u00A7').append(String.format("%02X", lastRgb[1]).charAt(0));
                result.append('\u00A7').append(String.format("%02X", lastRgb[1]).charAt(1));
                result.append('\u00A7').append(String.format("%02X", lastRgb[2]).charAt(0));
                result.append('\u00A7').append(String.format("%02X", lastRgb[2]).charAt(1));
                result.append(fmt);
                result.append(ch);
                charIdx++;
            }
            i = end + 1;
        }
        return result.toString();
    }

    private int[] parseGradientColor(String s) {
        s = s.trim();
        // Hex: #RRGGBB oder &#RRGGBB
        if (s.startsWith("&#")) s = s.substring(1);
        if (s.startsWith("#") && s.length() == 7) {
            try {
                return new int[]{
                    Integer.parseInt(s.substring(1,3),16),
                    Integer.parseInt(s.substring(3,5),16),
                    Integer.parseInt(s.substring(5,7),16)
                };
            } catch (Exception ignored) {}
        }
        // MC &-Code: &0-&f
        if (s.startsWith("&") && s.length() == 2) {
            return mcColorToRgb(s.charAt(1));
        }
        return null;
    }

    private int[] interpolateGradient(java.util.List<int[]> stops, float pos) {
        if (stops.size() == 1) return stops.get(0);
        float scaled = pos * (stops.size() - 1);
        int i0 = Math.min((int) scaled, stops.size() - 2);
        int i1 = i0 + 1;
        float t = scaled - i0;
        return new int[]{
            (int)(stops.get(i0)[0] * (1-t) + stops.get(i1)[0] * t),
            (int)(stops.get(i0)[1] * (1-t) + stops.get(i1)[1] * t),
            (int)(stops.get(i0)[2] * (1-t) + stops.get(i1)[2] * t)
        };
    }

    /** Sinus-basierter Regenbogen (JAWa-Style) – smooth, volle Farbpalette */
    private static int[] hsbWave(float hue) {
        double h = hue * 2 * Math.PI;
        int r = (int)(Math.sin(h)               * 127.5 + 127.5);
        int g = (int)(Math.sin(h + 2*Math.PI/3) * 127.5 + 127.5);
        int b = (int)(Math.sin(h + 4*Math.PI/3) * 127.5 + 127.5);
        return new int[]{
            Math.max(0, Math.min(255, r)),
            Math.max(0, Math.min(255, g)),
            Math.max(0, Math.min(255, b))
        };
    }

    /** Wandelt einen Minecraft &-Farbcode (0-9, a-f) in RGB um */
    private static int[] mcColorToRgb(char code) {
        switch (Character.toLowerCase(code)) {
            case '0': return new int[]{  0,   0,   0}; // §0 Schwarz
            case '1': return new int[]{  0,   0, 170}; // §1 Dunkelblau
            case '2': return new int[]{  0, 170,   0}; // §2 Dunkelgr\u00fcn
            case '3': return new int[]{  0, 170, 170}; // §3 Dunkelt\u00fcrkis
            case '4': return new int[]{170,   0,   0}; // §4 Dunkelrot
            case '5': return new int[]{170,   0, 170}; // §5 Lila
            case '6': return new int[]{255, 170,   0}; // §6 Gold
            case '7': return new int[]{170, 170, 170}; // §7 Grau
            case '8': return new int[]{ 85,  85,  85}; // §8 Dunkelgrau
            case '9': return new int[]{ 85,  85, 255}; // §9 Blau
            case 'a': return new int[]{ 85, 255,  85}; // §a Hellgr\u00fcn
            case 'b': return new int[]{ 85, 255, 255}; // §b T\u00fcrkis
            case 'c': return new int[]{255,  85,  85}; // §c Hellrot
            case 'd': return new int[]{255,  85, 255}; // §d Hellviolett
            case 'e': return new int[]{255, 255,  85}; // §e Gelb
            case 'f': return new int[]{255, 255, 255}; // §f Wei\u00df
            default:  return null;
        }
    }

    private int[] interpolateWaveColor(float pos) {
        if (waveColors == null || waveColors.length == 0) {
            // HSB-Fallback: voller Regenbogen
            java.awt.Color c = java.awt.Color.getHSBColor(pos, 1.0f, 1.0f);
            return new int[]{c.getRed(), c.getGreen(), c.getBlue()};
        }
        int n = waveColors.length;
        float scaled = pos * n;
        int i0 = (int) scaled % n;
        int i1 = (i0 + 1) % n;
        float t  = scaled - (int) scaled; // 0.0 .. 1.0 zwischen i0 und i1
        int r = (int)(waveColors[i0][0] * (1-t) + waveColors[i1][0] * t);
        int g = (int)(waveColors[i0][1] * (1-t) + waveColors[i1][1] * t);
        int b = (int)(waveColors[i0][2] * (1-t) + waveColors[i1][2] * t);
        return new int[]{r, g, b};
    }

    // ── Placeholder ───────────────────────────────────────────────────────────

    private String ph(String tpl, String player, String rank, String money, String server,
            String compass, String health, String healthNum, String ping, String online, String maxplayers,
            String tps, String ram, String time, String playtime,
            String x, String y, String z, String world, String gamemode,
            String exp, String food, String foodSymbol, String speed,
            String uptime, String servers, String proxymem, String date, String news,
            String ticketMyOpen, String ticketTotalOpen, String ticketTotalClaimed,
            String ticketRatingGood, String ticketRatingBad, String ticketRatingPct) {
        if (tpl == null) return " ";
        // PAPI-Werte zuerst einsetzen; native Tokens \u00fcberschreiben sie danach
        String s = resolvePapiPlaceholders(tpl, currentPlayerUuid);
        s = s
            .replace("%player%",     player)     .replace("%rank%",       rank)
            .replace("%money%",      money)      .replace("%server%",     server)
            .replace("%compass%",    compass)    .replace("%health%",     health)
            .replace("%hearts%",     healthNum)  .replace("%ping%",       ping)
            .replace("%online%",     online)     .replace("%maxplayers%", maxplayers)
            .replace("%tps%",        tps)        .replace("%ram%",        ram)
            .replace("%time%",       time)       .replace("%playtime%",   playtime)
            .replace("%x%",          x)          .replace("%y%",          y)
            .replace("%z%",          z)          .replace("%world%",      world)
            .replace("%gamemode%",   gamemode)   .replace("%exp%",        exp)
            .replace("%food%",       food)       .replace("%foodsym%",    foodSymbol)
            .replace("%speed%",      speed)
            .replace("%uptime%",     uptime)     .replace("%servers%",    servers)
            .replace("%proxymem%",   proxymem)
            .replace("%date%",       date)
            .replace("%news%",       news)
            // ── TicketSystem ──────────────────────────────────────────────────
            .replace("%ticket_my_open%",       ticketMyOpen)
            .replace("%ticket_open%",          ticketTotalOpen)
            .replace("%ticket_claimed%",       ticketTotalClaimed)
            .replace("%ticket_rating_good%",   ticketRatingGood)
            .replace("%ticket_rating_bad%",    ticketRatingBad)
            .replace("%ticket_rating_pct%",    ticketRatingPct);
        s = applyGradients(s);
        s = s.replace("%line%", c(separator));
        return s.isEmpty() ? " " : s;
    }

    private static String resolvePapiPlaceholders(String text, UUID uuid) {
        if (text == null || !text.contains("%")) return text;
        if (uuid == null) return text;
        java.util.Map<String, String> papiMap = net.viper.status.StatusAPI.playerPapi.get(uuid);
        if (papiMap == null || papiMap.isEmpty()) return text;
        StringBuilder sb = new StringBuilder();
        int i = 0;
        while (i < text.length()) {
            int start = text.indexOf('%', i);
            if (start < 0) { sb.append(text.substring(i)); break; }
            int end = text.indexOf('%', start + 1);
            if (end < 0) { sb.append(text.substring(i)); break; }
            String token = text.substring(start + 1, end);
            if (papiMap.containsKey(token)) {
                sb.append(text, i, start);
                sb.append(papiMap.get(token));
                i = end + 1;
            } else {
                sb.append(text, i, end + 1);
                i = end + 1;
            }
        }
        return sb.toString();
    }

    // ── Daten-Helfer ─────────────────────────────────────────────────────────

    private String getRank(ProxiedPlayer p) {
        try {
            Class<?> prov = Class.forName("net.luckperms.api.LuckPermsProvider");
            Object api = prov.getMethod("get").invoke(null);
            Object um  = api.getClass().getMethod("getUserManager").invoke(api);
            Object usr = um.getClass().getMethod("getUser", UUID.class).invoke(um, p.getUniqueId());
            if (usr != null) {
                Class<?> qo   = Class.forName("net.luckperms.api.query.QueryOptions");
                Object   opts = qo.getMethod("defaultContextualOptions").invoke(null);
                Object   cache= usr.getClass().getMethod("getCachedData").invoke(usr);
                Object   meta = cache.getClass().getMethod("getMetaData", qo).invoke(cache, opts);
                Object   pfx  = meta.getClass().getMethod("getPrefix").invoke(meta);
                if (pfx != null && !pfx.toString().isEmpty()) return pfx.toString();
                Object pg = usr.getClass().getMethod("getPrimaryGroup").invoke(usr);
                if (pg != null) return pg.toString().toUpperCase();
            }
        } catch (Exception ignored) {}
        return "Spieler";
    }

    private String getMoney(ProxiedPlayer p) {
        try {
            Map<?,?> bal = (Map<?,?>) StatusAPI.class.getField("playerBalances").get(null);
            Object v = bal.get(p.getUniqueId());
            if (v != null) return df.format(((Number) v).doubleValue());
        } catch (Exception ignored) {}
        return "0" + decimalSeparator + "00";
    }

    private String formatHealth(double hp) {
        int full  = (int)(hp / 2.0);
        int half  = (hp % 2 >= 1) ? 1 : 0;
        int empty = 10 - full - half;
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < full;  i++) sb.append("\u2665"); // ♥ voll
        if (half > 0)                   sb.append("\u2665"); // ♥ halb
        for (int i = 0; i < empty; i++) sb.append("\u2661"); // ♡ leer
        return sb.length() > 0 ? sb.toString() : "\u2661";
    }

    private String formatFood(int food) {
        // Food 0-20, je 2 Punkte = ein Symbol, max 10 Slots
        int full  = food / 2;
        int half  = food % 2;
        int empty = 10 - full - half;
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < full;  i++) sb.append("\u25C6"); // ◆ voll
        if (half > 0)                   sb.append("\u25C7"); // ◇ halb
        for (int i = 0; i < empty; i++) sb.append("\u25C7"); // ◇ leer
        return sb.toString();
    }

    private String formatPlaytime(UUID id) {
        Long joinTime = joinTimes.get(id);
        if (joinTime == null) return "00 00:00:00";
        long seconds = (System.currentTimeMillis() - joinTime) / 1000;
        long days    = seconds / 86400;
        long hours   = (seconds % 86400) / 3600;
        long mins    = (seconds % 3600) / 60;
        long secs    = seconds % 60;
        return String.format("%02d %02d:%02d:%02d", days, hours, mins, secs);
    }

    /**
     * Baut den scrollenden Kompass-Balken.
     *
     * Akzeptiert zwei Formate:
     *   - Richtungstext:  "N", "NE", "E", "SE", "S", "SW", "W", "NW"  (Bridge-Altformat)
     *   - Yaw als Float:  "-45.5"  (Minecraft-Yaw: 0=S, 90=W, 180=N, -90=E)
     *
     * Beispiel-Output (Blick nach N): "- - - - &c&lN&r&7 - - - -"
     */
    /**
     * Baut den Kompass-Balken mit Sub-Grad-Aufl\u00f6sung.
     *
     * Das Fenster hat COMPASS_WIN Slots (z.B. 9). Jeder Slot entspricht genau
     * 1 Grad auf dem Kreis (COMPASS_SLOTS = 360). Dadurch verschiebt sich der
     * Balken bei jeder 1°-\u00c4nderung um genau eine Position – kein Springen.
     *
     * Jeder Slot zeigt:
     *   - 'N' / 'E' / 'S' / 'W'  wenn sein Grad-Slot mit einem Himmelsrichtungs-
     *                              Label \u00fcbereinstimmt (±0°, kein Runden)
     *   - '|'                      f\u00fcr den Mittelpunkt (aktuelle Blickrichtung),
     *                              falls kein Label genau trifft
     *   - '·'                      f\u00fcr alle anderen Slots
     *
     * Akzeptierte raw-Formate:
     *   Float-String "normYaw" (0..360):  Bridge sendet normYaw = ((yaw%360)+360)%360
     *   Richtungstext "N"/"NE"/"E"/…:    Legacy-Fallback
     */
    /**
     * Kompass-Balken: WIN=15 Slots, 1°/Slot, Himmelsrichtungen farbig.
     *
     * Zeichen:
     *   '─'  normaler Slot (grau, &8)
     *   N/E/S/W  au\u00dferhalb Mitte: gelb &e
     *   Mitte mit Himmelsrichtung: rot+fett &c&l
     *   Mitte ohne Himmelsrichtung: rot+fett &c&l '|'
     *
     * Bridge sendet normYaw 0..360 (0 = S\u00fcden/MC-Konvention).
     * Umrechnung: facingDeg = (normYaw + 180) % 360  →  0=N, 90=E, 180=S, 270=W
     */
    private static final int SCOREBOARD_WIDTH = 26; // sichtbare Breite des Scoreboards

    private String buildCompass(String raw) {
        if (raw == null || raw.isEmpty()) raw = "180";

        float facingDeg;
        try {
            float normYaw = Float.parseFloat(raw);
            facingDeg = (normYaw + 180.0f) % 360.0f;
        } catch (NumberFormatException e) {
            switch (raw.trim().toUpperCase()) {
                case "N":  facingDeg =   0; break;
                case "NE": facingDeg =  45; break;
                case "E":  facingDeg =  90; break;
                case "SE": facingDeg = 135; break;
                case "S":  facingDeg = 180; break;
                case "SW": facingDeg = 225; break;
                case "W":  facingDeg = 270; break;
                case "NW": facingDeg = 315; break;
                default:   return raw;
            }
        }

        int half       = COMPASS_WIN / 2;
        int totalSlots = COMPASS_SLOTS / COMPASS_DEG_PER_SLOT;
        int centerSlot = (Math.round(facingDeg) / COMPASS_DEG_PER_SLOT) % totalSlots;

        StringBuilder sb = new StringBuilder();
        for (int i = -half; i <= half; i++) {
            int slot    = ((centerSlot + i) % totalSlots + totalSlots) % totalSlots;
            int slotDeg = (slot * COMPASS_DEG_PER_SLOT) % COMPASS_SLOTS;

            // Himmelsrichtungs-Label?
            char label = 0;
            for (int k = 0; k < COMPASS_LABEL_DEG.length; k++) {
                if (slotDeg == COMPASS_LABEL_DEG[k]) { label = COMPASS_LABEL_CH[k]; break; }
            }

            if (i == 0) {
                // Mitte: rot + fett; Himmelsrichtung oder '|' als Cursor
                char marker = (label != 0) ? label : '|';
                sb.append("&c&l").append(marker).append("&r&8");
            } else if (label != 0) {
                // Himmelsrichtung au\u00dferhalb Mitte: gelb, gut sichtbar
                sb.append("&e").append(label).append("&8");
            } else {
                sb.append('-'); // ASCII-Strich, sicher f\u00fcr alle MC-Versionen
            }
        }
        // Kompass zentrieren: Leerzeichen links = (Scoreboard-Breite - Kompass-Breite) / 2
        int padding = Math.max(0, (26 - COMPASS_WIN) / 2);
        StringBuilder padded = new StringBuilder();
        for (int p = 0; p < padding; p++) padded.append(' ');
        padded.append(sb);
        return padded.toString();
    }

    /**
     * Baut den News-Ticker: Text gleitet von rechts nach links durch ein fixes Fenster.
     *
     * Das Fenster ist IMMER exakt newsWidth Zeichen breit – Scoreboard-Breite konstant.
     * Text erscheint von rechts, l\u00e4uft durch, verschwindet links.
     * Dann Pause (Leerzeichen) bevor der Text wieder von rechts einl\u00e4uft.
     *
     * newsPrefix ist optional – leer lassen in Config zum Deaktivieren.
     */
    private String buildNewsTicker(int offset) {
        if (newsText == null || newsText.isEmpty()) return "";
        String plain = ChatColor.stripColor(c(newsText)).trim();
        if (plain.isEmpty()) return "";
        String prefix = (newsPrefix != null && !newsPrefix.isEmpty()) ? c(newsPrefix) : "";
        int pfxLen = ChatColor.stripColor(prefix).length();
        int winWidth = Math.max(4, newsWidth - pfxLen);

        // Text + 4 Leerzeichen Pause → dann sofort wieder von rechts
        // Zyklus = plain.length() + 4
        int gap = 4;
        int cycleLen = plain.length() + gap;
        int pos = offset % cycleLen;

        // Virtuelles Band: plain + 4 Leerzeichen, l\u00e4uft zyklisch
        // Fenster zeigt winWidth Zeichen aus dem Band
        // Band-Position des ersten Fensterzeichens: pos - winWidth + 1
        StringBuilder window = new StringBuilder();
        for (int i = 0; i < winWidth; i++) {
            int bandIdx = pos - winWidth + 1 + i;
            if (bandIdx < 0 || bandIdx >= plain.length()) {
                window.append(' ');
            } else {
                window.append(plain.charAt(bandIdx));
            }
        }
        return prefix + c("&f") + window.toString();
    }

    private static final long START_TIME = System.currentTimeMillis();

    private String getUptime() {
        long s = (System.currentTimeMillis() - START_TIME) / 1000;
        return String.format("%02d:%02d:%02d", s / 3600, (s % 3600) / 60, s % 60);
    }

    private String getTps(UUID id) {
        // Prim\u00e4r: TPS vom Backend-Server (per POST /scoreboard/tps gesendet)
        Double t = playerTps.get(id);
        if (t != null) {
            return new DecimalFormat("0.0").format(Math.min(20.0, t));
        }
        // FIX: Fallback auf Proxy-eigene TPS aus NetworkInfoModule (immer verf\u00fcgbar)
        if (networkInfoModule != null && networkInfoModule.isEnabled()) {
            double proxyTps = networkInfoModule.getProxyTps();
            return new DecimalFormat("0.0").format(Math.min(20.0, proxyTps)) + " (P)";
        }
        return "N/A";
    }

    private String getRam() {
        MemoryMXBean m = ManagementFactory.getMemoryMXBean();
        return (m.getHeapMemoryUsage().getUsed() / 1048576)
             + "MB/" + (m.getHeapMemoryUsage().getMax() / 1048576) + "MB";
    }

    // ── Component Builder (Hex-Farb-Support f\u00fcr Scoreboard) ─────────────────────

    /**
     * Wandelt einen bereits mit c() prozessierten String (§-Codes + §x§R§R§G§G§B§B)
     * in BaseComponent[] um, die BungeeCord korrekt ins Scoreboard-Packet schreibt.
     *
     * Hex-Farben werden als echte net.md_5.bungee.api.ChatColor RGB-Instanzen gesetzt,
     * nicht als Legacy-String – das ist der einzige Weg der in Sidebar-Packets funktioniert.
     */
    private static net.md_5.bungee.api.chat.BaseComponent[] buildComponents(String text) {
        if (text == null || text.isEmpty())
            return new net.md_5.bungee.api.chat.BaseComponent[]{
                new net.md_5.bungee.api.chat.TextComponent("")};

        java.util.List<net.md_5.bungee.api.chat.BaseComponent> parts = new java.util.ArrayList<>();
        net.md_5.bungee.api.ChatColor currentColor = net.md_5.bungee.api.ChatColor.WHITE;
        boolean bold = false, italic = false, underline = false, strike = false, magic = false;

        int i = 0;
        StringBuilder buf = new StringBuilder();

        while (i < text.length()) {
            char c = text.charAt(i);

            // §x§R§R§G§G§B§B – RGB Hex (14 Zeichen)
            if (c == '§' && i + 13 <= text.length() - 1 && text.charAt(i+1) == 'x') {
                // Flush buffer
                if (buf.length() > 0) {
                    parts.add(makeComp(buf.toString(), currentColor, bold, italic, underline, strike, magic));
                    buf.setLength(0);
                }
                try {
                    // §x§R§R§G§G§B§B: chars at i+3,i+5,i+7,i+9,i+11,i+13 are the hex digits
                    String hex = "" + text.charAt(i+3) + text.charAt(i+5)
                               + text.charAt(i+7) + text.charAt(i+9)
                               + text.charAt(i+11) + text.charAt(i+13);
                    currentColor = net.md_5.bungee.api.ChatColor.of("#" + hex);
                    // Formatierungen NICHT zur\u00fccksetzen – Bold/Italic bleiben erhalten
                } catch (Exception ignored) {}
                i += 14;
                continue;
            }

            // §X – normale Farb/Format-Codes
            if (c == '§' && i + 1 < text.length()) {
                char code = Character.toLowerCase(text.charAt(i + 1));
                if (buf.length() > 0) {
                    parts.add(makeComp(buf.toString(), currentColor, bold, italic, underline, strike, magic));
                    buf.setLength(0);
                }
                switch (code) {
                    case 'r': currentColor = net.md_5.bungee.api.ChatColor.WHITE;
                              bold=false; italic=false; underline=false; strike=false; magic=false; break;
                    case 'l': bold      = true; break;
                    case 'o': italic    = true; break;
                    case 'n': underline = true; break;
                    case 'm': strike    = true; break;
                    case 'k': magic     = true; break;
                    default:
                        net.md_5.bungee.api.ChatColor col =
                            net.md_5.bungee.api.ChatColor.getByChar(code);
                        if (col != null) {
                            currentColor = col;
                            bold=false; italic=false; underline=false; strike=false; magic=false;
                        }
                }
                i += 2;
                continue;
            }

            buf.append(c);
            i++;
        }
        if (buf.length() > 0)
            parts.add(makeComp(buf.toString(), currentColor, bold, italic, underline, strike, magic));

        if (parts.isEmpty())
            return new net.md_5.bungee.api.chat.BaseComponent[]{
                new net.md_5.bungee.api.chat.TextComponent("")};
        return parts.toArray(new net.md_5.bungee.api.chat.BaseComponent[0]);
    }

    private static net.md_5.bungee.api.chat.TextComponent makeComp(
            String text, net.md_5.bungee.api.ChatColor color,
            boolean bold, boolean italic, boolean underline, boolean strike, boolean magic) {
        net.md_5.bungee.api.chat.TextComponent tc = new net.md_5.bungee.api.chat.TextComponent(text);
        tc.setColor(color);
        if (bold)      tc.setBold(true);
        if (italic)    tc.setItalic(true);
        if (underline) tc.setUnderlined(true);
        if (strike)    tc.setStrikethrough(true);
        if (magic)     tc.setObfuscated(true);
        return tc;
    }

    // ── Farb-Hilfsmethoden ────────────────────────────────────────────────────

    // ══════════════════════════════════════════════════════════════════════════
    // Farb-Parser: Birdflop-kompatibel
    // Unterst\u00fctzte Formate (alle gleichzeitig nutzbar):
    //
    //  &#RRGGBB          → Pro-Zeichen Hex (Birdflop Standard-Output)
    //  {#RRGGBB}         → Bracket-Format
    //  <#RRGGBB>         → MiniMessage Kurzform
    //  <color:#RRGGBB>   → MiniMessage color-Tag
    //  <gradient:#C1:#C2:Text> → Farbverlauf (beliebig viele Farb-Stopps)
    //  <shadow:#C:Text>  → Text in Schattenfarbe
    //  <b> <i> <u> <st> <obf> → Formatierungen
    //  &l &o &n &m &k &r      → Standard-Formatierungen
    // ══════════════════════════════════════════════════════════════════════════

    private static String c(String s) {
        if (s == null) return " ";
        s = parseMiniMessage(s);    // MiniMessage-Tags (<gradient:>, <shadow:>, <#>, <color:>, <b> usw.)
        s = parseHexAmpersand(s);   // &#RRGGBB und {#RRGGBB}
        return net.md_5.bungee.api.ChatColor.translateAlternateColorCodes('&', s);
    }

    private static String stripColors(String s) {
        return s == null ? "" : net.md_5.bungee.api.ChatColor.stripColor(c(s));
    }

    // ── MiniMessage Haupt-Dispatcher ─────────────────────────────────────────

    private static String parseMiniMessage(String text) {
        if (text == null || !text.contains("<")) return text == null ? "" : text;
        // gradient-Tags als erstes, weil sie anderen Text enthalten k\u00f6nnen
        text = parseGradientTags(text);
        // shadow-Tags
        text = parseShadowTags(text);
        // Einfache Tags: <color:#>, <#>, <b>, <i>, <u>, <st>, <obf>, </...>
        text = parseSimpleTags(text);
        return text;
    }

    // ── <gradient:#C1:#C2:...:TEXT> ──────────────────────────────────────────

    private static String parseGradientTags(String text) {
        if (!text.contains("<gradient:")) return text;
        StringBuilder result = new StringBuilder();
        int i = 0;
        while (i < text.length()) {
            int start = text.indexOf("<gradient:", i);
            if (start < 0) { result.append(text, i, text.length()); break; }
            result.append(text, i, start);
            // Schlie\u00dfendes > suchen (mit Tiefenz\u00e4hler f\u00fcr verschachtelte <...>)
            int end = findClosingAngle(text, start + 1);
            if (end < 0) { result.append(text, i, text.length()); break; }
            String inner = text.substring(start + 1, end); // "gradient:#C1:#C2:TEXT"
            result.append(applyGradientTag(inner));
            i = end + 1;
        }
        return result.toString();
    }

    /**
     * Parst "gradient:#C1:#C2:#C3:TEXT" → eingef\u00e4rbten Text.
     * TEXT darf selbst wieder §-Codes oder &-Codes enthalten (z.B. &l f\u00fcr Bold).
     */
    private static String applyGradientTag(String inner) {
        // inner = "gradient:COLOR:COLOR:...:TEXT"
        // Farben beginnen mit # oder mit & gefolgt von einem Hex-Code
        java.util.List<String> colors = new java.util.ArrayList<>();
        // Trenne am ersten Doppelpunkt nach "gradient"
        int firstColon = inner.indexOf(':'); // nach "gradient"
        if (firstColon < 0) return inner;
        String rest = inner.substring(firstColon + 1);

        // Lese Farb-Stopps (jeder Teil beginnt mit #)
        // TEXT ist alles ab dem ersten Teil der NICHT mit # beginnt
        StringBuilder textSb = new StringBuilder();
        boolean inText = false;
        String[] parts = rest.split(":", -1);
        for (int p = 0; p < parts.length; p++) {
            String part = parts[p];
            if (!inText && part.startsWith("#") && part.length() == 7) {
                colors.add(part);
            } else {
                // Ab hier Text (inkl. Doppelpunkte wieder zusammensetzen)
                inText = true;
                if (textSb.length() > 0) textSb.append(":");
                textSb.append(part);
            }
        }
        if (colors.size() < 2) return textSb.toString();

        // Shadow-Tags im Text zuerst aufl\u00f6sen (k\u00f6nnen im Gradient-Text stecken)
        String rawText = parseShadowTags(textSb.toString());
        return applyGradient(rawText, colors);
    }

    private static String applyGradient(String text, java.util.List<String> colorStops) {
        if (text == null || text.isEmpty()) return text;
        // §-Codes und &-Codes aus Text herausfiltern f\u00fcr L\u00e4ngenberechnung
        String plain = text
            .replaceAll("\u00A7[0-9a-fk-orx]", "")
            .replaceAll("&[0-9a-fA-Fk-orK-OR]", "")
            .replaceAll("\u00A7x(\u00A7[0-9a-fA-F]){6}", ""); // §x§R§R§G§G§B§B
        int len = plain.length();
        if (len == 0) return text;
        if (len == 1) return resolveColorToSection(colorStops.get(0)) + text;

        int[][] rgbStops = new int[colorStops.size()][3];
        for (int s = 0; s < colorStops.size(); s++) rgbStops[s] = hexToRgb(colorStops.get(s));

        StringBuilder result = new StringBuilder();
        int charIdx = 0;
        int ci = 0;
        while (ci < text.length()) {
            char ch = text.charAt(ci);

            // §x§R§R§G§G§B§B durchreichen (bereits aufgel\u00f6ste Hex-Farbe z.B. von shadow)
            if (ch == '\u00A7' && ci + 1 < text.length() && text.charAt(ci + 1) == 'x') {
                // Lese die 12 folgenden Zeichen (§x + 6x §digit)
                if (ci + 13 < text.length() + 1) {
                    result.append(text, ci, Math.min(ci + 14, text.length()));
                    ci = Math.min(ci + 14, text.length());
                } else {
                    result.append(ch); ci++;
                }
                continue;
            }
            // §-Formatcode durchreichen
            if (ch == '\u00A7' && ci + 1 < text.length()) {
                result.append(ch).append(text.charAt(ci + 1)); ci += 2; continue;
            }
            // &-Formatcode durchreichen
            if (ch == '&' && ci + 1 < text.length() && "&0123456789abcdefABCDEFklmnorKLMNOR".indexOf(text.charAt(ci+1)) >= 0) {
                result.append(ch).append(text.charAt(ci + 1)); ci += 2; continue;
            }

            // Normales Zeichen → Farbe interpolieren
            float t = len <= 1 ? 0f : (float) charIdx / (len - 1);
            int segments = colorStops.size() - 1;
            float scaled = t * segments;
            int seg = Math.min((int) scaled, segments - 1);
            float segT = scaled - seg;
            int[] c1 = rgbStops[seg], c2 = rgbStops[seg + 1];
            int r = clamp((int)(c1[0] + (c2[0] - c1[0]) * segT));
            int g = clamp((int)(c1[1] + (c2[1] - c1[1]) * segT));
            int b = clamp((int)(c1[2] + (c2[2] - c1[2]) * segT));
            String hex = String.format("%02X%02X%02X", r, g, b);
            appendHexSection(result, hex);
            result.append(ch);
            charIdx++;
            ci++;
        }
        return result.toString();
    }

    // ── <shadow:#RRGGBB:TEXT> ─────────────────────────────────────────────────

    private static String parseShadowTags(String text) {
        if (text == null || !text.contains("<shadow:")) return text == null ? "" : text;
        StringBuilder result = new StringBuilder();
        int i = 0;
        while (i < text.length()) {
            int start = text.indexOf("<shadow:", i);
            if (start < 0) { result.append(text, i, text.length()); break; }
            result.append(text, i, start);
            int end = findClosingAngle(text, start + 1);
            if (end < 0) { result.append(text, i, text.length()); break; }
            String inner = text.substring(start + 1, end); // "shadow:#RRGGBB:TEXT"
            // Format: shadow:COLOR:TEXT
            int firstColon = inner.indexOf(':');
            int secondColon = firstColon >= 0 ? inner.indexOf(':', firstColon + 1) : -1;
            if (firstColon < 0 || secondColon < 0) { result.append(text, i, end + 1); i = end + 1; continue; }
            String colorPart = inner.substring(firstColon + 1, secondColon).trim();
            String content   = inner.substring(secondColon + 1);
            result.append(resolveColorToSection(colorPart)).append(content);
            i = end + 1;
        }
        return result.toString();
    }

    // ── Einfache MiniMessage-Tags ─────────────────────────────────────────────

    private static String parseSimpleTags(String text) {
        if (text == null || !text.contains("<")) return text == null ? "" : text;
        // Ersetzungstabelle
        text = text.replace("<b>", "&l").replace("</b>", "&r");
        text = text.replace("<i>", "&o").replace("</i>", "&r");
        text = text.replace("<u>", "&n").replace("</u>", "&r");
        text = text.replace("<st>", "&m").replace("</st>", "&r");
        text = text.replace("<obf>", "&k").replace("</obf>", "&r");
        text = text.replace("<reset>", "&r").replace("</reset>", "");
        // Closing-Tags entfernen (werden nach Verarbeitung nicht mehr ben\u00f6tigt)
        text = text.replaceAll("</gradient>", "");
        text = text.replaceAll("</shadow>", "");
        text = text.replaceAll("</color>", "");
        // <color:#RRGGBB> und <#RRGGBB>
        StringBuilder result = new StringBuilder();
        int i = 0;
        while (i < text.length()) {
            char ch = text.charAt(i);
            if (ch != '<') { result.append(ch); i++; continue; }
            // <color:#RRGGBB>
            if (text.startsWith("<color:#", i)) {
                int end = text.indexOf('>', i);
                if (end > 0) {
                    String hex = text.substring(i + 7, end).trim();
                    if (hex.startsWith("#") && hex.length() == 7 && hex.substring(1).matches("[0-9a-fA-F]{6}")) {
                        appendHexSection(result, hex.substring(1));
                        i = end + 1; continue;
                    }
                }
            }
            // <#RRGGBB>
            if (text.startsWith("<#", i) && i + 9 <= text.length()) {
                int end = text.indexOf('>', i);
                if (end == i + 8) {
                    String hex = text.substring(i + 2, end);
                    if (hex.matches("[0-9a-fA-F]{6}")) {
                        appendHexSection(result, hex);
                        i = end + 1; continue;
                    }
                }
            }
            result.append(ch); i++;
        }
        return result.toString();
    }

    // ── &#RRGGBB und {#RRGGBB} ───────────────────────────────────────────────

    private static String parseHexAmpersand(String text) {
        if (text == null) return "";
        if (!text.contains("&#") && !text.contains("{#")) return text;
        StringBuilder sb = new StringBuilder();
        int i = 0;
        while (i < text.length()) {
            // &#RRGGBB
            if (i + 7 < text.length() + 1 && i + 8 <= text.length()
                    && text.charAt(i) == '&' && text.charAt(i+1) == '#') {
                String hex = text.substring(i+2, i+8);
                if (hex.matches("[0-9a-fA-F]{6}")) {
                    appendHexSection(sb, hex); i += 8; continue;
                }
            }
            // {#RRGGBB}
            if (i + 8 < text.length() && text.charAt(i) == '{' && text.charAt(i+1) == '#') {
                int end = text.indexOf('}', i+2);
                if (end == i + 8) {
                    String hex = text.substring(i+2, i+8);
                    if (hex.matches("[0-9a-fA-F]{6}")) {
                        appendHexSection(sb, hex); i += 9; continue;
                    }
                }
            }
            sb.append(text.charAt(i++));
        }
        return sb.toString();
    }

    // ── Hilfsmethoden ─────────────────────────────────────────────────────────

    private static void appendHexSection(StringBuilder sb, String hex) {
        sb.append('\u00A7').append('x');
        for (char ch : hex.toCharArray()) sb.append('\u00A7').append(ch);
    }

    private static String resolveColorToSection(String color) {
        if (color == null) return "";
        color = color.trim();
        if (color.startsWith("#") && color.length() == 7
                && color.substring(1).matches("[0-9a-fA-F]{6}")) {
            StringBuilder sb = new StringBuilder();
            appendHexSection(sb, color.substring(1));
            return sb.toString();
        }
        if (color.startsWith("&") && color.length() == 2) return "\u00A7" + color.charAt(1);
        return color;
    }

    private static int[] hexToRgb(String color) {
        String hex = color == null ? "" : color.trim();
        if (hex.startsWith("#")) hex = hex.substring(1);
        if (hex.length() != 6) return new int[]{255, 255, 255};
        try {
            return new int[]{
                Integer.parseInt(hex.substring(0,2), 16),
                Integer.parseInt(hex.substring(2,4), 16),
                Integer.parseInt(hex.substring(4,6), 16)
            };
        } catch (Exception e) { return new int[]{255,255,255}; }
    }

    private static int clamp(int v) { return Math.max(0, Math.min(255, v)); }

    /**
     * Findet das schlie\u00dfende '>' f\u00fcr ein Tag das bei fromIndex beginnt.
     * Ber\u00fccksichtigt verschachtelte <...>.
     */
    private static int findClosingAngle(String text, int fromIndex) {
        int depth = 0;
        for (int i = fromIndex; i < text.length(); i++) {
            char ch = text.charAt(i);
            if (ch == '<') depth++;
            else if (ch == '>') { if (depth == 0) return i; depth--; }
        }
        return -1;
    }




    // ── Config ───────────────────────────────────────────────────────────────

    private void ensureConfigExists() {
        File f = new File(plugin.getDataFolder(), CONFIG_FILE);
        if (f.exists()) return;
        if (!plugin.getDataFolder().exists()) plugin.getDataFolder().mkdirs();
                String content =
            "# ScoreboardModule Konfiguration\n" +
            "# Platzhalter Spieler: %player% %rank% %money% %server% %compass% %health% %hearts% %date%\n" +
            "#                      %ping% %online% %maxplayers% %time% %playtime% %news%\n" +
            "#                      %x% %y% %z% %world% %gamemode% %exp% %food% %foodsym% %speed%\n" +
            "# Platzhalter Admin:   %tps% %ram% %proxymem% %uptime% %servers%\n" +
            "# Gradient:            %gradient:FARBE1:FARBE2:TEXT%\n" +
            "# Sonstiges:           %line%\n" +
            "# Farben: &-Codes und Hex &#FF6600\n" +
            "\n" +
            "scoreboard.enabled=true\n" +
            "scoreboard.update_interval=500\n" +
            "scoreboard.title=&lViper Network\n" +
            "scoreboard.admin_title=&l[Admin] Panel\n" +
            "scoreboard.supporter_title=&l[Support] Panel\n" +
            "\n" +
            "scoreboard.ticker.text=\n" +
            "scoreboard.ticker.width=26\n" +
            "scoreboard.ticker.speed=1\n" +
            "\n" +
            "scoreboard.rainbow.enabled=true\n" +
            "# wave=flie\u00dfende Welle, chars=Regenbogen pro Buchstabe, line=eine Farbe\n" +
            "scoreboard.rainbow.mode=wave\n" +
            "# Wellengeschwindigkeit: 1=sehr langsam, 10=normal, 50=schnell\n" +
            "scoreboard.rainbow.speed=10\n" +
            "# Leer = voller HSB-Regenbogen\n" +
            "scoreboard.rainbow.colors=#FF0000,#FF6600,#FFFF00,#00FF00,#00FFFF,#0000FF,#FF00FF\n" +
            "\n" +
            "scoreboard.admin_permission=statusapi.scoreboard.admin\n" +
            "scoreboard.supporter_permission=statusapi.scoreboard.supporter\n" +
            "\n" +
            "scoreboard.time_format=HH:mm\n" +
            "scoreboard.date_format=dd.MM.yyyy\n" +
            "scoreboard.timezone=Europe/Berlin\n" +
            "scoreboard.money_format=#,##0.00\n" +
            "scoreboard.money_decimal_separator=,\n" +
            "\n" +
            "# SEPARATOR – wird als %line% Placeholder genutzt\n" +
            "scoreboard.separator=&8&m--------------------\n" +
            "# News-Ticker (erscheint als %news% Placeholder)\n" +
            "scoreboard.news.text=&eWillkommen auf Viper Network!\n" +
            "scoreboard.news.prefix=&8[&6News&8] &r\n" +
            "scoreboard.news.width=20\n" +
            "scoreboard.news.speed=1\n" +
            "\n" +
            "# ===================================================\n" +
            "# NAMETAG - Prefix ueber dem Spieler-Kopf\n" +
            "# ===================================================\n" +
            "nametag.enabled=true\n" +
            "\n" +
            "scoreboard.rotation_interval=4\n" +
            "# ===================================================\n" +
            "# ZEILEN - max 15 sichtbar\n" +
            "# ===================================================\n" +
            "scoreboard.lines.1=%line%\n" +
            "scoreboard.lines.2=%gradient:&6:&f:&6:&l> Player Info:%\n" +
            "scoreboard.lines.3=&7%rank% &f%player%\n" +
            "scoreboard.lines.4=\n" +
            "scoreboard.lines.5=&7Spielzeit: &f%playtime%\n" +
            "scoreboard.lines.5.2=&7Leben: &c%health%\n" +
            "scoreboard.lines.5.3=&7Hunger: &#8B4513%foodsym%\n" +
            "scoreboard.lines.6=\n" +
            "scoreboard.lines.7=%gradient:&6:&f:&6:&l> Money:%\n" +
            "scoreboard.lines.8=&a$%money%\n" +
            "scoreboard.lines.9=\n" +
            "scoreboard.lines.10=%gradient:&6:&f:&6:&l> Server Info:%\n" +
            "scoreboard.lines.11=&f%server%\n" +
            "scoreboard.lines.11.2=&7Ping: &f%ping%ms &8| &7Online: &f%online%\n" +
            "scoreboard.lines.12=\n" +
            "scoreboard.lines.13=%news%\n" +
            "scoreboard.lines.14=%line%\n" +
            "scoreboard.lines.15=&7%compass%\n" +
            "# ===================================================\n" +
            "# ADMIN-ZEILEN\n" +
            "# ===================================================\n" +
            "scoreboard.admin_lines.1=%line%\n" +
            "scoreboard.admin_lines.2=%gradient:&6:&f:&6:&l> Player Info:%\n" +
            "scoreboard.admin_lines.3=&7%rank% &f%player%\n" +
            "scoreboard.admin_lines.4=&7Gamemode: &f%gamemode%\n" +
            "scoreboard.admin_lines.5=&7Leben: &c%health%\n" +
            "scoreboard.admin_lines.5.2=&7Hunger: &#8B4513%foodsym%\n" +
            "scoreboard.admin_lines.6=\n" +
            "scoreboard.admin_lines.7=%gradient:&6:&f:&6:&l> Server Info:%\n" +
            "scoreboard.admin_lines.8=&f%server% &8| &7RAM: &e%ram%\n" +
            "scoreboard.admin_lines.8.2=&7Proxy: &f%uptime%\n" +
            "scoreboard.admin_lines.9=\n" +
            "scoreboard.admin_lines.10=&7TPS: &a%tps%\n" +
            "scoreboard.admin_lines.11=\n" +
            "scoreboard.admin_lines.12=&7Spieler: &f%online% &8| &7%maxplayers%\n" +
            "scoreboard.admin_lines.13=%news%\n" +
            "scoreboard.admin_lines.14=%line%\n" +
            "scoreboard.admin_lines.15=&7%compass%\n" +
            "scoreboard.admin_lines.15.2=&7Pos: X:&f%x% &7Y:&f%y% &7Z:&f%z%\n" +
            "# ===================================================\n" +
            "# SUPPORTER-ZEILEN\n" +
            "# ===================================================\n" +
            "scoreboard.supporter_lines.1=%line%\n" +
            "scoreboard.supporter_lines.2=%gradient:&6:&f:&6:&l> Support Panel:%\n" +
            "scoreboard.supporter_lines.3=&7%rank% &f%player%\n" +
            "scoreboard.supporter_lines.4=&7Ping: &f%ping%ms &8| &7%server%\n" +
            "scoreboard.supporter_lines.5=\n" +
            "scoreboard.supporter_lines.6=%gradient:&6:&f:&6:&l> Tickets:%\n" +
            "scoreboard.supporter_lines.7=&7Offen: &c%ticket_open%\n" +
            "scoreboard.supporter_lines.8=&7Meine Tickets: &e%ticket_my_open%\n" +
            "scoreboard.supporter_lines.9=\n" +
            "scoreboard.supporter_lines.10=%gradient:&6:&f:&6:&l> Server Info:%\n" +
            "scoreboard.supporter_lines.11=&7Online: &f%online% &8/ &7%maxplayers%\n" +
            "scoreboard.supporter_lines.12=&7Zeit: &f%time%\n" +
            "scoreboard.supporter_lines.13=\n" +
            "scoreboard.supporter_lines.14=%line%\n" +
            "scoreboard.supporter_lines.15=&7%compass%\n" +
            "";
                try (OutputStream out = new FileOutputStream(f)) {
            out.write(content.getBytes(StandardCharsets.UTF_8));
        } catch (Exception e) {
            plugin.getLogger().warning("[ScoreboardModule] Config: " + e.getMessage());
        }
    }

    private void loadConfig() {
        File file = new File(plugin.getDataFolder(), CONFIG_FILE);
        Map<String, String> map = new LinkedHashMap<>();
        if (file.exists()) {
            try (BufferedReader br = new BufferedReader(
                    new InputStreamReader(new FileInputStream(file), StandardCharsets.UTF_8))) {
                String line;
                while ((line = br.readLine()) != null) {
                    line = line.trim();
                    if (line.isEmpty() || line.startsWith("#")) continue;
                    int eq = line.indexOf('=');
                    if (eq < 1) continue;
                    map.put(line.substring(0, eq).trim(), line.substring(eq + 1));
                }
            } catch (Exception e) {
                plugin.getLogger().warning("[ScoreboardModule] Ladefehler: " + e.getMessage());
            }
        }
        java.util.function.BiFunction<String,String,String> g = (k,d) -> map.getOrDefault(k, d);
        enabled          = Boolean.parseBoolean(g.apply("scoreboard.enabled",              "true"));
        nametagEnabled   = Boolean.parseBoolean(g.apply("nametag.enabled",                 "true"));
        updateInterval   = Math.max(250, pi(g.apply("scoreboard.update_interval", "500"), 500));
        title            = g.apply("scoreboard.title",                  "&6&lViper Network");
        adminTitle       = g.apply("scoreboard.admin_title",             "&c&l[Admin] &4&lPanel");
        supporterTitle   = g.apply("scoreboard.supporter_title",         "&e&l[Support] &6&lPanel");
        tickerText       = g.apply("scoreboard.ticker.text",            "  Viper Network  ");
        tickerWidth      = pi(g.apply("scoreboard.ticker.width",        "26"), 26);
        tickerSpeed      = pi(g.apply("scoreboard.ticker.speed",        "1"),   1);
        rainbowEnabled   = Boolean.parseBoolean(g.apply("scoreboard.rainbow.enabled",      "true"));
        rainbowMode      = g.apply("scoreboard.rainbow.mode",           "wave").trim().toLowerCase();
        // Wellen-Farben laden
        // waveSpeed: Hue-Verschiebung pro Tick
        // speed=1 → 0.005 pro Tick → bei 500ms-Interval ~100s pro Zyklus
        // speed=10 → 0.05 pro Tick → ~10s pro Zyklus (empfohlen)
        // speed=50 → 0.25 pro Tick → ~2s pro Zyklus
        waveSpeed = Float.parseFloat(g.apply("scoreboard.rainbow.speed", "10")) * 0.005f;
        String waveColorsStr = g.apply("scoreboard.rainbow.colors", "");
        if (!waveColorsStr.isEmpty()) {
            String[] parts = waveColorsStr.split(",");
            java.util.List<int[]> cols = new java.util.ArrayList<>();
            for (String part : parts) {
                String p = part.trim();
                // Hex-Farbe: #RRGGBB oder &#RRGGBB
                if (p.startsWith("&#")) p = p.substring(1);
                if (p.startsWith("#") && p.length() == 7) {
                    try {
                        int r = Integer.parseInt(p.substring(1,3),16);
                        int g2= Integer.parseInt(p.substring(3,5),16);
                        int b = Integer.parseInt(p.substring(5,7),16);
                        cols.add(new int[]{r,g2,b});
                    } catch (Exception ignored) {}
                // Minecraft &-Farbe: &0-&9, &a-&f
                } else if (p.startsWith("&") && p.length() == 2) {
                    int[] rgb = mcColorToRgb(p.charAt(1));
                    if (rgb != null) cols.add(rgb);
                }
            }
            waveColors = cols.isEmpty() ? null : cols.toArray(new int[0][]);
        } else {
            waveColors = null; // HSB-Fallback
        }
        adminPermission      = g.apply("scoreboard.admin_permission",      "statusapi.scoreboard.admin");
        supporterPermission  = g.apply("scoreboard.supporter_permission",  "statusapi.scoreboard.supporter");
        timeFormat       = g.apply("scoreboard.time_format",            "HH:mm");
        dateFormat       = g.apply("scoreboard.date_format",             "dd.MM.yyyy");
        timeZone         = g.apply("scoreboard.timezone",               "Europe/Berlin");
        moneyFormat      = g.apply("scoreboard.money_format",           "#,##0.00");
        decimalSeparator = g.apply("scoreboard.money_decimal_separator",",");
        separator        = g.apply("scoreboard.separator", "&8&m--------------------");
        try {
            sdfZone = java.time.ZoneId.of(timeZone);
            sdf     = java.time.format.DateTimeFormatter.ofPattern(timeFormat).withZone(sdfZone);
            sdfDate = java.time.format.DateTimeFormatter.ofPattern(dateFormat).withZone(sdfZone);
        } catch (Exception e) {
            sdfZone = java.time.ZoneId.systemDefault();
            sdf     = java.time.format.DateTimeFormatter.ofPattern("HH:mm").withZone(sdfZone);
            sdfDate = java.time.format.DateTimeFormatter.ofPattern("dd.MM.yyyy").withZone(sdfZone);
        }
        try {
            df = new DecimalFormat(moneyFormat);
            java.text.DecimalFormatSymbols sym = df.getDecimalFormatSymbols();
            if (!decimalSeparator.isEmpty()) sym.setDecimalSeparator(decimalSeparator.charAt(0));
            df.setDecimalFormatSymbols(sym);
        } catch (Exception e) { df = new DecimalFormat("#,##0.00"); }
        rotationInterval = pi(g.apply("scoreboard.rotation_interval", "4"), 4);
        newsText   = g.apply("scoreboard.news.text",   "");
        newsPrefix = g.apply("scoreboard.news.prefix", "&8[&6News&8] &r");
        newsWidth  = pi(g.apply("scoreboard.news.width",  "20"), 20);
        newsSpeed  = pi(g.apply("scoreboard.news.speed",  "1"),   1);

        // Pro Zeile alle Varianten laden:
        //   scoreboard.lines.N     = Zeile N, Variante 1 (immer sichtbar)
        //   scoreboard.lines.N.2   = Zeile N, Variante 2 (rotiert nach interval)
        //   scoreboard.lines.N.3   = Zeile N, Variante 3  usw.
        playerLineMap.clear();
        loadLineMap(map, "scoreboard.lines.", playerLineMap);
        adminLineMap.clear();
        loadLineMap(map, "scoreboard.admin_lines.", adminLineMap);
        supporterLineMap.clear();
        loadLineMap(map, "scoreboard.supporter_lines.", supporterLineMap);


        plugin.getLogger().info("[ScoreboardModule] "
            + playerLineMap.size() + " Player-Zeilen, "
            + adminLineMap.size() + " Admin-Zeilen, "
            + supporterLineMap.size() + " Supporter-Zeilen. RotInterval=" + rotationInterval + "s"
);
    }

    /**
     * Liest pro Zeilennummer alle Varianten aus der Config.
     *
     * Format:
     *   prefix + N        = Variante 1 fuer Zeile N  (immer sichtbar wenn nur 1 Variante)
     *   prefix + N + ".2" = Variante 2 fuer Zeile N  (rotiert mit Variante 1)
     *   prefix + N + ".3" = Variante 3 fuer Zeile N  usw.
     *
     * Zeilen ohne .2/.3 bleiben immer gleich.
     * Zeilen MIT .2/.3 wechseln automatisch alle rotationInterval Sekunden.
     */
    private void loadLineMap(Map<String, String> map, String prefix,
                            Map<Integer, List<String>> result) {
        // Hoechste Zeilennummer finden
        int maxLine = 0;
        for (String key : map.keySet()) {
            if (!key.startsWith(prefix)) continue;
            String rest = key.substring(prefix.length());
            int dot = rest.indexOf('.');
            String numStr = (dot >= 0) ? rest.substring(0, dot) : rest;
            try {
                int ln = Integer.parseInt(numStr);
                if (ln > maxLine) maxLine = ln;
            } catch (NumberFormatException ignored) {}
        }

        for (int ln = 1; ln <= maxLine; ln++) {
            List<String> variants = new ArrayList<>();
            // Variante 1 (kein Suffix)
            String v1 = map.get(prefix + ln);
            if (v1 != null) variants.add(v1);
            // Variante 2, 3, ... (Suffix .2, .3, ...)
            for (int p = 2; p <= 20; p++) {
                String vp = map.get(prefix + ln + "." + p);
                if (vp == null) break;
                variants.add(vp);
            }
            if (!variants.isEmpty()) result.put(ln, variants);
            else result.put(ln, java.util.Collections.singletonList(""));
        }
    }

    private int pi(String s, int fb) {
        try { return Integer.parseInt(s == null ? "" : s.trim()); }
        catch (Exception e) { return fb; }
    }

    /**
     * Entfernt ein Scoreboard-Objective und alle zugeh\u00f6rigen Teams sauber vom Client.
     * Muss aufgerufen werden bevor ein anderes Objective aktiviert wird,
     * sonst crasht der Client beim erneuten Team-CREATE.
     *
     * @param p          Spieler
     * @param objName    Objective-Name (z.B. "vpsb" oder "vpsbadmin")
     * @param teamPrefix Team-Prefix (z.B. "vt" oder "vta")
     */
    private void removeObjectiveAndTeams(ProxiedPlayer p, String objName, String teamPrefix) {
        // 1. Alle Teams l\u00f6schen (Mode 1 = REMOVE)
        for (int i = 0; i < 15; i++) {
            try {
                Team team = new Team();
                team.setName(teamPrefix + i);
                team.setMode((byte) 1); // REMOVE
                sendPkt.invoke(p, team);
            } catch (Exception ignored) {}
        }
        // 2. Objective entfernen (Action 1 = REMOVE)
        try {
            ScoreboardObjective rem = new ScoreboardObjective();
            rem.setName(objName);
            rem.setAction((byte) 1);
            sendPkt.invoke(p, rem);
        } catch (Exception ignored) {}
    }

    // ── /scoreboard Toggle-Command ────────────────────────────────────────────
    /**
     * /scoreboard            → Scoreboard ein-/ausblenden
     * /scoreboard hide       → Scoreboard ausblenden
     * /scoreboard show       → Scoreboard einblenden
     * /scoreboard player     → Player-Scoreboard anzeigen
     * /scoreboard admin      → Admin-Scoreboard anzeigen (Perm: statusapi.scoreboard.admin)
     *
     * Aliase: /sb, /togglesb
     */
    private static final List<String> SB_SUBS = Arrays.asList("hide", "show", "player", "admin", "supporter");

    /** Tab-Completion f\u00fcr /scoreboard via TabCompleteEvent */
    @EventHandler
    public void onTabComplete(TabCompleteEvent event) {
        if (!(event.getSender() instanceof ProxiedPlayer)) return;
        String cursor = event.getCursor();
        if (cursor == null) return;
        String lower = cursor.toLowerCase();
        boolean match = lower.startsWith("/scoreboard ") || lower.startsWith("/sb ")
                     || lower.startsWith("/togglesb ");
        if (!match) return;

        ProxiedPlayer p = (ProxiedPlayer) event.getSender();
        int spaceIdx = cursor.indexOf(' ');
        String typed = spaceIdx >= 0 ? cursor.substring(spaceIdx + 1).toLowerCase() : "";

        List<String> suggestions = new ArrayList<>();
        for (String sub : SB_SUBS) {
            // Supporter und Admin nur anzeigen wenn Berechtigung vorhanden
            if (sub.equals("admin")     && !p.hasPermission(adminPermission))     continue;
            if (sub.equals("supporter") && !p.hasPermission(supporterPermission)
                                        && !p.hasPermission(adminPermission))      continue;
            if (sub.startsWith(typed)) suggestions.add(sub);
        }
        event.getSuggestions().clear();
        event.getSuggestions().addAll(suggestions);
    }


        private class ScoreboardToggleCommand extends Command {

        ScoreboardToggleCommand() {
            super("scoreboard", null, "sb", "togglesb");
        }

        @Override
        public void execute(CommandSender sender, String[] args) {
            if (!(sender instanceof ProxiedPlayer)) {
                sender.sendMessage(new net.md_5.bungee.api.chat.TextComponent(
                    ChatColor.RED + "Nur f\u00fcr Spieler."));
                return;
            }
            ProxiedPlayer p  = (ProxiedPlayer) sender;
            UUID          id = p.getUniqueId();

            String sub = (args.length > 0) ? args[0].toLowerCase() : "toggle";

            switch (sub) {
                case "hide":
                    if (created.contains(id))          { removeObjectiveAndTeams(p, OBJ_NAME,       "vt");  created.remove(id); }
                    if (createdAdmin.contains(id))     { removeObjectiveAndTeams(p, OBJ_NAME_ADMIN,  "vta"); createdAdmin.remove(id); }
                    if (createdSupporter.contains(id)) { removeObjectiveAndTeams(p, OBJ_NAME_SUPP,   "vts"); createdSupporter.remove(id); }
                    hiddenPlayers.add(id);
                    p.sendMessage(msg("&7Scoreboard &causgeblendet&7. (/sb zum Einblenden)"));
                    break;

                case "show":
                    if (created.contains(id))          { removeObjectiveAndTeams(p, OBJ_NAME,       "vt");  created.remove(id); }
                    if (createdAdmin.contains(id))     { removeObjectiveAndTeams(p, OBJ_NAME_ADMIN,  "vta"); createdAdmin.remove(id); }
                    if (createdSupporter.contains(id)) { removeObjectiveAndTeams(p, OBJ_NAME_SUPP,   "vts"); createdSupporter.remove(id); }
                    hiddenPlayers.remove(id);
                    p.sendMessage(msg("&7Scoreboard &aeingeblendet&7."));
                    break;

                case "player":
                    if (created.contains(id))          { removeObjectiveAndTeams(p, OBJ_NAME,       "vt");  created.remove(id); }
                    if (createdAdmin.contains(id))     { removeObjectiveAndTeams(p, OBJ_NAME_ADMIN,  "vta"); createdAdmin.remove(id); }
                    if (createdSupporter.contains(id)) { removeObjectiveAndTeams(p, OBJ_NAME_SUPP,   "vts"); createdSupporter.remove(id); }
                    forcePlayerView.add(id);
                    forceAdminView.remove(id);
                    forceSupporterView.remove(id);
                    hiddenPlayers.remove(id);
                    p.sendMessage(msg("&7Zeige &eSpieler&7-Scoreboard."));
                    break;

                case "supporter":
                    if (!p.hasPermission(supporterPermission) && !p.hasPermission(adminPermission)) {
                        p.sendMessage(msg("&cKeine Berechtigung."));
                        return;
                    }
                    if (created.contains(id))          { removeObjectiveAndTeams(p, OBJ_NAME,       "vt");  created.remove(id); }
                    if (createdAdmin.contains(id))     { removeObjectiveAndTeams(p, OBJ_NAME_ADMIN,  "vta"); createdAdmin.remove(id); }
                    if (createdSupporter.contains(id)) { removeObjectiveAndTeams(p, OBJ_NAME_SUPP,   "vts"); createdSupporter.remove(id); }
                    forceSupporterView.add(id);
                    forceAdminView.remove(id);
                    forcePlayerView.remove(id);
                    hiddenPlayers.remove(id);
                    p.sendMessage(msg("&7Zeige &6Supporter&7-Scoreboard."));
                    break;

                case "admin":
                    if (!p.hasPermission(adminPermission)) {
                        p.sendMessage(msg("&cKeine Berechtigung."));
                        return;
                    }
                    if (created.contains(id))          { removeObjectiveAndTeams(p, OBJ_NAME,       "vt");  created.remove(id); }
                    if (createdAdmin.contains(id))     { removeObjectiveAndTeams(p, OBJ_NAME_ADMIN,  "vta"); createdAdmin.remove(id); }
                    if (createdSupporter.contains(id)) { removeObjectiveAndTeams(p, OBJ_NAME_SUPP,   "vts"); createdSupporter.remove(id); }
                    forceAdminView.add(id);
                    forcePlayerView.remove(id);
                    forceSupporterView.remove(id);
                    hiddenPlayers.remove(id);
                    p.sendMessage(msg("&7Zeige &cAdmin&7-Scoreboard."));
                    break;

                default: // "toggle"
                    if (hiddenPlayers.contains(id)) {
                        if (created.contains(id))          { removeObjectiveAndTeams(p, OBJ_NAME,       "vt");  created.remove(id); }
                        if (createdAdmin.contains(id))     { removeObjectiveAndTeams(p, OBJ_NAME_ADMIN,  "vta"); createdAdmin.remove(id); }
                        if (createdSupporter.contains(id)) { removeObjectiveAndTeams(p, OBJ_NAME_SUPP,   "vts"); createdSupporter.remove(id); }
                        hiddenPlayers.remove(id);
                        p.sendMessage(msg("&7Scoreboard &aeingeblendet&7."));
                    } else {
                        if (created.contains(id))          { removeObjectiveAndTeams(p, OBJ_NAME,       "vt");  created.remove(id); }
                        if (createdAdmin.contains(id))     { removeObjectiveAndTeams(p, OBJ_NAME_ADMIN,  "vta"); createdAdmin.remove(id); }
                        if (createdSupporter.contains(id)) { removeObjectiveAndTeams(p, OBJ_NAME_SUPP,   "vts"); createdSupporter.remove(id); }
                        hiddenPlayers.add(id);
                        p.sendMessage(msg("&7Scoreboard &causgeblendet&7. (/sb zum Einblenden)"));
                    }
                    break;
            }
        }

        private net.md_5.bungee.api.chat.TextComponent msg(String text) {
            return new net.md_5.bungee.api.chat.TextComponent(c(text));
        }
    }


}