import socket
import time
import re
import fnmatch
import threading
import tkinter as tk
import json
from tkinter import ttk, messagebox, filedialog
import os
import subprocess
from concurrent.futures import ThreadPoolExecutor
from pyq3serverlist import Server
try:
    import winreg
except ImportError:
    winreg = None

# ================= CONFIGURATION =================
MASTER_SERVER = ("dpmaster.deathmask.net", 27950)
PROTOCOL_VERSION = 71  # Standard for OpenArena 0.8.8
MAX_THREADS = 50       # How many servers to query at once
TIMEOUT = 1.5          # Timeout for each server query
GAME_EXE_PATH = r"C:\Games\omega_full\OmegA\omega-vulkan.x64.exe"

GAMETYPES = {
    0: "FFA",
    1: "Tournament",
    3: "Team Deathmatch",
    4: "Capture The Flag",
    5: "One Flag CTF",
    6: "Obelisk",
    7: "Harvester",
    8: "Elimination",
    9: "CTF Elimination",
    10: "Last Man Standing",
    11: "Double Domination",
    14: "Duel",
    12: "Domination"
}
# =================================================

def strip_colors(text):
    """Removes Quake 3 color codes (e.g. ^1, ^7) from text."""
    return re.sub(r'\^[0-9]', '', text)

def is_dark_mode():
    if not winreg: return False
    try:
        key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize")
        value, _ = winreg.QueryValueEx(key, "AppsUseLightTheme")
        return value == 0
    except:
        return False

def get_master_server_list():
    """Queries the dpmaster to get a list of IP:Port tuples."""
    print(f"Querying Master Server {MASTER_SERVER[0]}...")
    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    sock.settimeout(2.0)
    
    # Construct the getservers query
    msg = b'\xff\xff\xff\xffgetservers ' + str(PROTOCOL_VERSION).encode() + b' empty full'
    
    servers = set()
    try:
        sock.sendto(msg, MASTER_SERVER)
        while True:
            try:
                data, _ = sock.recvfrom(65536)
                # Response header: \xff\xff\xff\xffgetserversResponse\
                if b'getserversResponse\\' in data:
                    # The body contains 6-byte chunks (4 IP + 2 Port) separated by backslashes
                    parts = data.split(b'\\')
                    for part in parts:
                        if len(part) == 6:
                            ip = ".".join(str(b) for b in part[:4])
                            port = int.from_bytes(part[4:], 'big')
                            servers.add((ip, port))
            except socket.timeout:
                break # No more packets arriving
    except Exception as e:
        print(f"Error querying master server: {e}")
    finally:
        sock.close()
    
    return list(servers)

def query_server_details(server_info):
    """Queries a specific server for its status."""
    ip, port = server_info
    server = Server(ip, port)
    start_time = time.time()
    
    try:
        # get_status() returns a dict with server variables and a 'players' list
        info = server.get_status()
        ping = int((time.time() - start_time) * 1000)
        
        # Parse Gametype
        raw_gt = info.get('g_gametype', info.get('gametype', '0'))
        try:
            gt_str = GAMETYPES.get(int(raw_gt), raw_gt)
        except:
            gt_str = raw_gt

        return {
            'ip': ip,
            'port': port,
            'ping': ping,
            'name': strip_colors(info.get('sv_hostname', 'Unknown Server')),
            'map': info.get('mapname', 'Unknown'),
            'gametype': gt_str,
            'players': info.get('players', []),
            'num_players': len(info.get('players', [])),
            'max_players': info.get('sv_maxclients', '?')
        }
    except:
        return None # Server timed out or unreachable

class ServerBrowserApp:
    def __init__(self, root):
        self.root = root
        self.root.title("OpenArena Server Browser")
        self.root.geometry("1280x1200")
        self.servers = []
        self.favorites = []
        self.favorite_players = []
        self.load_config()
        
        # Theme Colors
        self.is_dark = is_dark_mode()
        if self.is_dark:
            self.bg = "#2b2b2b"
            self.fg = "#ffffff"
            self.entry_bg = "#404040"
            self.entry_fg = "#ffffff"
            
            style = ttk.Style()
            style.theme_use('clam')
            style.configure("Treeview", background="#333333", foreground="white", fieldbackground="#333333", borderwidth=0, font=("Arial", 12), rowheight=28)
            style.configure("Treeview.Heading", background="#444444", foreground="white", relief="flat", font=("Arial", 12, "bold"))
            style.map("Treeview.Heading", background=[('active', '#555555')])
            style.configure("TCombobox", fieldbackground=self.entry_bg, background="#444444", foreground=self.entry_fg, arrowcolor="white")
        else:
            self.bg = "#f0f0f0"
            self.fg = "#000000"
            self.entry_bg = "#ffffff"
            self.entry_fg = "#000000"
            
            style = ttk.Style()
            style.theme_use('clam')
            style.configure("Treeview", background="white", foreground="black", fieldbackground="white", borderwidth=0, font=("Arial", 12), rowheight=28)
            style.configure("Treeview.Heading", background="#e1e1e1", foreground="black", relief="flat", font=("Arial", 12, "bold"))
            style.map("Treeview.Heading", background=[('active', '#d0d0d0')])

        self.root.configure(bg=self.bg)

        # Top Frame
        top_frame = tk.Frame(root, bg=self.bg)
        top_frame.pack(fill="x", padx=10, pady=10)
        self.btn_refresh = tk.Button(top_frame, text="Refresh Servers", command=self.start_refresh, bg="#007acc", fg="white", font=("Arial", 12, "bold"))
        self.btn_refresh.pack(side="left")
        self.btn_connect = tk.Button(top_frame, text="Connect", command=self.connect_to_server, bg="#28a745", fg="white", font=("Arial", 12, "bold"))
        self.btn_connect.pack(side="left", padx=10)
        
        self.btn_settings = tk.Button(top_frame, text="⚙", command=self.browse_game_path, bg="#6c757d", fg="white", font=("Arial", 12, "bold"), width=3)
        self.btn_settings.pack(side="left")
        
        # Auto-Refresh Controls
        self.auto_refresh_var = tk.BooleanVar()
        self.refresh_interval_var = tk.StringVar(value="5")
        chk_auto = tk.Checkbutton(top_frame, text="Auto-Refresh:", variable=self.auto_refresh_var, command=self.toggle_auto_refresh, bg=self.bg, fg=self.fg, selectcolor="#444" if self.is_dark else "white", activebackground=self.bg, activeforeground=self.fg)
        chk_auto.pack(side="left", padx=(20, 5))
        cmb_interval = ttk.Combobox(top_frame, textvariable=self.refresh_interval_var, values=["5", "10", "30", "60", "120", "240"], width=4, state="readonly", font=("Arial", 12))
        cmb_interval.pack(side="left")
        tk.Label(top_frame, text="s", bg=self.bg, fg=self.fg, font=("Arial", 12)).pack(side="left")

        self.lbl_status = tk.Label(top_frame, text="Ready", font=("Arial", 12), bg=self.bg, fg=self.fg)
        self.lbl_status.pack(side="left", padx=15)

        # Search Bar
        search_frame = tk.Frame(root, bg=self.bg)
        search_frame.pack(fill="x", padx=10, pady=(0, 10))
        tk.Label(search_frame, text="Search Player:", font=("Arial", 12), bg=self.bg, fg=self.fg).pack(side="left")
        self.search_var = tk.StringVar()
        self.search_var.trace("w", self.on_search_change)
        self.entry_search = tk.Entry(search_frame, textvariable=self.search_var, font=("Arial", 12), bg=self.entry_bg, fg=self.entry_fg, insertbackground=self.fg)
        self.entry_search.pack(side="left", fill="x", expand=True, padx=5)
        btn_clear = tk.Button(search_frame, text="X", command=lambda: self.search_var.set(""), font=("Arial", 10, "bold"), bg="#d9534f", fg="white", width=3)
        btn_clear.pack(side="left", padx=(0, 5))
        tk.Label(search_frame, text="(Use * or ? for wildcards)", font=("Arial", 10), fg="gray", bg=self.bg).pack(side="left")
        
        # Filter Dropdown
        tk.Label(search_frame, text="Filter:", font=("Arial", 12), bg=self.bg, fg=self.fg).pack(side="left", padx=(15, 5))
        
        self.player_filter_var = tk.StringVar(value="All Players")
        cmb_p_filter = ttk.Combobox(search_frame, textvariable=self.player_filter_var, values=["All Players", "Hide Bots"], state="readonly", width=12, font=("Arial", 11))
        cmb_p_filter.pack(side="left", padx=(0, 5))
        cmb_p_filter.bind("<<ComboboxSelected>>", lambda e: self.populate_ui())

        self.server_filter_var = tk.StringVar(value="All Servers")
        cmb_s_filter = ttk.Combobox(search_frame, textvariable=self.server_filter_var, values=["All Servers", "Favorites Only", "Hide Empty", "Hide Bot-Only", "Hide Empty & Bot-Only"], state="readonly", width=22, font=("Arial", 11))
        cmb_s_filter.pack(side="left")
        cmb_s_filter.bind("<<ComboboxSelected>>", lambda e: self.populate_ui())

        # Ping Filter
        tk.Label(search_frame, text="Max Ping:", font=("Arial", 12), bg=self.bg, fg=self.fg).pack(side="left", padx=(15, 5))
        self.ping_var = tk.IntVar(value=500)
        
        scale_ping = tk.Scale(search_frame, from_=50, to=999, orient="horizontal", variable=self.ping_var, command=lambda v: self.populate_ui(), showvalue=1, length=100, bg=self.bg, fg=self.fg, highlightthickness=0, troughcolor="#555" if self.is_dark else "#ccc")

        def adjust_ping(delta):
            v = self.ping_var.get() + delta
            if v < 50: v = 50
            if v > 999: v = 999
            self.ping_var.set(v)
            self.populate_ui()
            scale_ping.focus_set()

        tk.Button(search_frame, text="-", command=lambda: adjust_ping(-50), font=("Arial", 10, "bold"), bg=self.bg, fg=self.fg, width=2).pack(side="left")
        scale_ping.pack(side="left", padx=5)
        
        # Bind arrow keys to scale
        scale_ping.bind("<Left>", lambda e: adjust_ping(-50) or "break")
        scale_ping.bind("<Right>", lambda e: adjust_ping(50) or "break")
        scale_ping.bind("<Up>", lambda e: adjust_ping(50) or "break")
        scale_ping.bind("<Down>", lambda e: adjust_ping(-50) or "break")
        scale_ping.bind("<Button-1>", lambda e: scale_ping.focus_set())
        
        tk.Button(search_frame, text="+", command=lambda: adjust_ping(50), font=("Arial", 10, "bold"), bg=self.bg, fg=self.fg, width=2).pack(side="left")

        # Treeview (List)
        tree_frame = tk.Frame(root, bg=self.bg)
        tree_frame.pack(fill="both", expand=True, padx=10)
        
        # Columns: Name is now the tree column (#0), others are values
        columns = ("ip", "map", "gametype", "players", "ping")
        self.tree = ttk.Treeview(tree_frame, columns=columns, show="tree headings")
        
        self.tree.heading("#0", text="Server Name / Player", command=lambda: self.sort_by("#0", False))
        self.tree.heading("ip", text="Address", command=lambda: self.sort_by("ip", False))
        self.tree.heading("map", text="Map", command=lambda: self.sort_by("map", False))
        self.tree.heading("gametype", text="Mode", command=lambda: self.sort_by("gametype", False))
        self.tree.heading("players", text="Players/Score", command=lambda: self.sort_by("players", False))
        self.tree.heading("ping", text="Ping", command=lambda: self.sort_by("ping", False))
        
        self.tree.column("#0", width=300)
        self.tree.column("ip", width=150)
        self.tree.column("map", width=100)
        self.tree.column("gametype", width=120)
        self.tree.column("players", width=100)
        self.tree.column("ping", width=60)
        
        scrollbar = ttk.Scrollbar(tree_frame, orient="vertical", command=self.tree.yview)
        self.tree.configure(yscrollcommand=scrollbar.set)
        self.tree.pack(side="left", fill="both", expand=True)
        scrollbar.pack(side="right", fill="y")

        # Quake 3 Player Colors
        # Format: Code -> (Dark Mode Color, Light Mode Color)
        q3_colors = {
            '0': ("#999999", "#333333"), # Black/Grey
            '1': ("#ff6666", "#cc0000"), # Red
            '2': ("#66ff66", "#009900"), # Green
            '3': ("#ffff66", "#bbaa00"), # Yellow
            '4': ("#6666ff", "#0000cc"), # Blue
            '5': ("#66ffff", "#009999"), # Cyan
            '6': ("#ff66ff", "#990099"), # Magenta
            '7': ("#ffffff", "#000000"), # White
            '8': ("#ffaa66", "#dd7700"), # Orange
            '9': ("#aaaaaa", "#666666"), # Grey
        }
        
        for code, (dark_col, light_col) in q3_colors.items():
            col = dark_col if self.is_dark else light_col
            self.tree.tag_configure(f"q_color_{code}", foreground=col)

        # Tag configuration for favorites
        if self.is_dark:
            self.tree.tag_configure("favorite", foreground="#ffd700")
            self.tree.tag_configure("fav_player", foreground="#00ffff")
            self.tree.tag_configure("has_fav_player", foreground="#afeeee")
        else:
            self.tree.tag_configure("favorite", foreground="#b8860b")
            self.tree.tag_configure("fav_player", foreground="#008b8b")
            self.tree.tag_configure("has_fav_player", foreground="#008b8b")

        # Context Menu & Bindings
        self.context_menu = tk.Menu(self.root, tearoff=0)
        
        self.tree.bind("<Button-3>", self.show_context_menu)
        self.tree.bind("<Double-1>", lambda e: self.connect_to_server())

        self.current_sort_col = None
        self.current_sort_desc = False

        # Initial Scan
        self.start_refresh()

    def load_config(self):
        self.game_path = GAME_EXE_PATH
        config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "oa_browser_config.json")
        try:
            if os.path.exists(config_path):
                with open(config_path, "r") as f:
                    cfg = json.load(f)
                    self.game_path = cfg.get("game_path", self.game_path)
                    self.favorites = cfg.get("favorites", [])
                    self.favorite_players = cfg.get("favorite_players", [])
        except: pass

    def save_config(self):
        config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "oa_browser_config.json")
        try:
            with open(config_path, "w") as f:
                json.dump({"game_path": self.game_path, "favorites": self.favorites, "favorite_players": self.favorite_players}, f)
        except: pass

    def browse_game_path(self):
        path = filedialog.askopenfilename(title="Select OpenArena Executable", filetypes=[("Executable", "*.exe"), ("All Files", "*.*")])
        if path:
            self.game_path = path
            self.save_config()
            messagebox.showinfo("Settings", f"Game path updated to:\n{path}")

    def show_context_menu(self, event):
        item = self.tree.identify_row(event.y)
        if not item: return
        
        self.tree.selection_set(item)
        
        # Rebuild menu dynamically
        self.context_menu.delete(0, "end")
        self.context_menu.add_command(label="Connect", command=self.connect_to_server)
        self.context_menu.add_command(label="Copy Address", command=self.copy_address)
        self.context_menu.add_separator()
        
        values = self.tree.item(item, 'values')
        parent = self.tree.parent(item)
        
        # 1. Player Options
        if parent:
            text = self.tree.item(item, "text")
            # Extract name from "[score] name"
            idx = text.find("] ")
            if idx != -1:
                player_name = text[idx+2:]
                if player_name in self.favorite_players:
                    self.context_menu.add_command(label=f"Unfavorite Player: {player_name}", command=lambda: self.toggle_player_favorite(player_name))
                else:
                    self.context_menu.add_command(label=f"Favorite Player: {player_name}", command=lambda: self.toggle_player_favorite(player_name))
            self.context_menu.add_separator()

        # 2. Server Options
        target_ip = ""
        if not parent:
            target_ip = values[0]
        else:
            target_ip = self.tree.item(parent, 'values')[0]
            
        if target_ip:
            if target_ip in self.favorites:
                self.context_menu.add_command(label="Unfavorite Server", command=self.toggle_favorite)
            else:
                self.context_menu.add_command(label="Favorite Server", command=self.toggle_favorite)

        self.context_menu.post(event.x_root, event.y_root)

    def toggle_favorite(self):
        sel = self.tree.selection()
        if not sel: return
        item = sel[0]
        values = self.tree.item(item, 'values')
        
        target_ip = ""
        if values and values[0]:
            target_ip = values[0]
        else:
            parent = self.tree.parent(item)
            if parent:
                target_ip = self.tree.item(parent, 'values')[0]
        
        if not target_ip: return

        if target_ip in self.favorites:
            self.favorites.remove(target_ip)
        else:
            self.favorites.append(target_ip)
        
        self.save_config()
        self.populate_ui()

    def toggle_player_favorite(self, name):
        if name in self.favorite_players:
            self.favorite_players.remove(name)
        else:
            self.favorite_players.append(name)
        self.save_config()
        self.populate_ui()

    def copy_address(self):
        sel = self.tree.selection()
        if sel:
            vals = self.tree.item(sel[0], 'values')
            if vals:
                self.root.clipboard_clear()
                self.root.clipboard_append(vals[0])

    def on_search_change(self, *args):
        self.populate_ui()

    def toggle_auto_refresh(self):
        if self.auto_refresh_var.get():
            self.auto_refresh_loop()

    def auto_refresh_loop(self):
        if not self.auto_refresh_var.get():
            return
        if self.btn_refresh['state'] == 'normal':
            self.start_refresh()
        try:
            interval = int(self.refresh_interval_var.get()) * 1000
        except:
            interval = 5000
        self.root.after(interval, self.auto_refresh_loop)

    def connect_to_server(self):
        sel = self.tree.selection()
        if not sel:
            messagebox.showinfo("Info", "Please select a server to connect.")
            return
        
        item_id = sel[0]
        parent_id = self.tree.parent(item_id)
        
        # If parent_id exists, the selected item is a player, so use the parent (server)
        target_id = parent_id if parent_id else item_id
        
        # Get server info from the tree values (index 0 is ip:port)
        values = self.tree.item(target_id, 'values')
        if not values: return
        
        ip_port = values[0]
        
        if not os.path.exists(self.game_path):
            messagebox.showerror("Error", f"Game executable not found at:\n{self.game_path}\nPlease update it in Settings (⚙).")
            return

        try:
            cmd = [self.game_path, "+connect", ip_port]
            subprocess.Popen(cmd, cwd=os.path.dirname(self.game_path))
        except Exception as e:
            messagebox.showerror("Error", f"Failed to launch game:\n{e}")

    def sort_by(self, col, descending):
        self.current_sort_col = col
        self.current_sort_desc = descending
        children = self.tree.get_children('')
        data = []
        for child in children:
            if col == "#0":
                val = self.tree.item(child, "text")
            else:
                val = self.tree.set(child, col)
            data.append((val, child))
        
        if col == "ping":
            data.sort(key=lambda x: int(x[0].replace('ms', '')), reverse=descending)
        elif col == "players":
            # Handle "5/16" format for servers
            def get_count(v):
                if '/' in str(v): return int(v.split('/')[0])
                try: return int(v)
                except: return 0
            data.sort(key=lambda x: get_count(x[0]), reverse=descending)
        else:
            data.sort(key=lambda x: x[0].lower(), reverse=descending)

        for index, (val, child) in enumerate(data):
            self.tree.move(child, '', index)

        self.tree.heading(col, command=lambda: self.sort_by(col, not descending))

    def start_refresh(self):
        self.btn_refresh.config(state="disabled")
        self.lbl_status.config(text="Querying Master Server...")
        threading.Thread(target=self.run_scan, daemon=True).start()

    def run_scan(self):
        server_list = get_master_server_list()
        self.root.after(0, lambda: self.lbl_status.config(text=f"Scanning {len(server_list)} servers..."))
        
        active_servers = []
        with ThreadPoolExecutor(max_workers=MAX_THREADS) as executor:
            results = executor.map(query_server_details, server_list)
        
        for r in results:
            if r: active_servers.append(r)
        
        active_servers.sort(key=lambda x: (x['num_players'] > 0, x['num_players']), reverse=True)
        self.root.after(0, self.populate_ui, active_servers)

    def populate_ui(self, servers=None):
        if servers is not None:
            self.servers = servers
            self.lbl_status.config(text=f"Found {len(servers)} active servers.")
            self.btn_refresh.config(state="normal")

        # Capture currently expanded servers
        expanded_ids = set()
        for child in self.tree.get_children():
            if self.tree.item(child, "open"):
                vals = self.tree.item(child, "values")
                if vals:
                    expanded_ids.add(vals[0])

        self.tree.delete(*self.tree.get_children())
        search_term = self.search_var.get().strip().lower()
        
        # Get filter states
        hide_bot_rows = self.player_filter_var.get() == "Hide Bots"
        server_filter = self.server_filter_var.get()
        favorites_only = server_filter == "Favorites Only"
        hide_empty = "Empty" in server_filter
        hide_bot_only = "Bot-Only" in server_filter
        
        max_ping = self.ping_var.get()

        for i, s in enumerate(self.servers):
            if s['ping'] > max_ping:
                continue
            
            ip_port = f"{s['ip']}:{s['port']}"
            is_fav = ip_port in self.favorites
            if favorites_only and not is_fav:
                continue
            
            # Check for favorite players
            has_fav_player = False
            for p in s['players']:
                if strip_colors(p.get('name', '')) in self.favorite_players:
                    has_fav_player = True
                    break

            # Determine effective player list and count
            all_players = s['players']
            human_players = [p for p in all_players if p.get('ping', 0) > 0]
            
            # Filter: Hide Bot-Only Servers (Servers with players, but no humans)
            if hide_bot_only:
                if s['num_players'] > 0 and len(human_players) == 0:
                    continue

            if hide_bot_rows:
                current_players = human_players
                player_count = len(human_players)
            else:
                current_players = all_players
                player_count = s['num_players']

            if hide_empty and player_count == 0:
                continue

            # 1. Filter Players based on search term
            matching_players = []
            for p in current_players:
                name = strip_colors(p.get('name', 'Unknown'))
                # If search is empty, include all. Check substring or wildcard match.
                if not search_term or search_term in name.lower() or fnmatch.fnmatch(name.lower(), search_term):
                    matching_players.append((name, p))

            # 2. If searching, skip servers with no matching players
            if search_term and not matching_players:
                continue

            display_name = s['name']
            tags = []
            
            if is_fav:
                display_name = "★ " + display_name
                tags.append("favorite")
            
            if has_fav_player:
                display_name = "☺ " + display_name
                tags.append("has_fav_player")

            should_open = ip_port in expanded_ids

            # Insert Server (Parent)
            server_id = self.tree.insert("", "end", text=display_name, values=(
                ip_port, 
                s['map'], 
                s['gametype'], 
                f"{player_count}/{s['max_players']}", 
                f"{s['ping']}ms"
            ), tags=tuple(tags), open=should_open)

            # Insert Matching Players (Children)
            for name, p in matching_players:
                score = p.get('frags', p.get('score', 0))
                ping = p.get('ping', 0)
                
                p_tags = []
                
                # Determine color based on raw name
                raw_name = p.get('name', '')
                c_match = re.search(r'\^([0-9])', raw_name)
                
                if name in self.favorite_players:
                    p_tags.append("fav_player")
                elif c_match:
                    p_tags.append(f"q_color_{c_match.group(1)}")
                
                self.tree.insert(server_id, "end", text=f"[{score}] {name}", values=("", "", "", score, f"{ping}ms"), tags=tuple(p_tags))
            
            # 3. Auto-expand if filtering
            if search_term:
                self.tree.item(server_id, open=True)

        if self.current_sort_col:
            self.sort_by(self.current_sort_col, self.current_sort_desc)

if __name__ == "__main__":
    root = tk.Tk()
    app = ServerBrowserApp(root)
    root.mainloop()