#!/usr/bin/env python3
"""
Bigg Adventure EQEmu Server - Client Patcher & Launcher
Features:
 - Sleek Dark UI (Tkinter)
 - Auto-detects client type:
     • Rain of Fear 2 (RoF2) -> Port 5999
     • Titanium -> Port 5998
 - Supports Direct Login (the patch server it reached) & Public Login (login.eqemulator.net)
 - Downloads patch_manifest.json via HTTP from the first reachable server in PATCH_SERVERS
 - Validates file integrity via SHA-256, before and after every download
 - Downloads missing / updated patch files (spells_us.txt, dbstr_us.txt, SkillCaps, UI)
 - Auto-configures eqhost.txt
 - Launches EverQuest: eqgame.exe patchme
"""

import os
import sys
import json
import hashlib
import urllib.parse
import urllib.request
import subprocess
import threading
import tkinter as tk
from tkinter import ttk, messagebox


def retire_client_files(names, client_dir, log):
    """Move files the server has retired (manifest "retire") into <client>/_retired/<date>/. Never deletes."""
    import datetime, shutil
    stamp = datetime.date.today().isoformat()
    moved = 0
    for rel in names or []:
        if os.path.isabs(rel) or ".." in rel.replace("\\", "/").split("/"):
            continue
        src = os.path.join(client_dir, rel)
        if not os.path.lexists(src):
            continue
        dst_dir = os.path.join(client_dir, "_retired", stamp, os.path.dirname(rel))
        os.makedirs(dst_dir, exist_ok=True)
        dst = os.path.join(dst_dir, os.path.basename(rel))
        while os.path.lexists(dst):
            dst += ".old"
        shutil.move(src, dst)
        log(f"[✓] Retired {rel} -> _retired/{stamp}/")
        moved += 1
    return moved

# Default Server Configuration
# Tried in order; the first one that serves a manifest is also used as the Direct Login host.
# Only list hosts you control - the patcher installs whatever eqgame.exe the manifest names.
PATCH_SERVERS = ["192.168.4.78"]
HTTP_PORT = 8080

class BiggAdventurePatcher(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("Bigg Adventure - Client Patcher & Launcher")
        self.geometry("720x520")
        self.resizable(False, False)

        # Style configuration (Sleek Dark Theme)
        self.configure(bg="#121212")
        self.style = ttk.Style(self)
        self.style.theme_use("clam")
        self.style.configure("TFrame", background="#121212")
        self.style.configure("TLabel", background="#121212", foreground="#E0E0E0", font=("Segoe UI", 10))
        self.style.configure("Header.TLabel", background="#121212", foreground="#00E5FF", font=("Segoe UI", 16, "bold"))
        self.style.configure("SubHeader.TLabel", background="#121212", foreground="#FFB300", font=("Segoe UI", 10, "italic"))
        self.style.configure("TProgressbar", thickness=15, troughcolor="#222222", background="#00E5FF")
        self.style.configure("TRadiobutton", background="#121212", foreground="#E0E0E0", font=("Segoe UI", 9))

        # Detect Client Type
        self.client_type, self.login_port = self.detect_client_type()

        # UI Components
        self.create_widgets()

    def detect_client_type(self):
        """Detects whether current folder is RoF2 (Port 5999) or Titanium (Port 5998)."""
        cwd = os.getcwd()
        if os.path.exists(os.path.join(cwd, "d3dx9_43.dll")) or os.path.exists(os.path.join(cwd, "EQGraphicsDX9.dll")):
            return "Rain of Fear 2 (RoF2)", 5999
        if os.path.exists(os.path.join(cwd, "d3dx9_30.dll")):
            return "Titanium", 5998
        return "Rain of Fear 2 (RoF2)", 5999

    def create_widgets(self):
        # Header Banner
        header_frame = ttk.Frame(self, padding=(15, 12, 15, 5))
        header_frame.pack(fill="x")

        title_label = ttk.Label(header_frame, text="BIGG ADVENTURE", style="Header.TLabel")
        title_label.pack(anchor="w")

        subtitle_label = ttk.Label(header_frame, text="DoDH Era (Level 70 Cap) • Tri-Class Multiclassing • 5-Spec Switcher", style="SubHeader.TLabel")
        subtitle_label.pack(anchor="w", pady=(2, 0))

        # Server / Login Selection Frame
        options_frame = ttk.Frame(self, padding=(15, 5))
        options_frame.pack(fill="x")

        client_info = f"Detected Client: {self.client_type} (Login Port {self.login_port})"
        self.client_badge = ttk.Label(options_frame, text=client_info, foreground="#76FF03", font=("Segoe UI", 9, "bold"))
        self.client_badge.pack(side="left")

        self.login_target_var = tk.StringVar(value="direct")
        r_direct = ttk.Radiobutton(options_frame, text="Direct Login (Bigg Adventure)", variable=self.login_target_var, value="direct")
        r_direct.pack(side="right", padx=5)
        r_eqemu = ttk.Radiobutton(options_frame, text="EQEmulator.net Public", variable=self.login_target_var, value="eqemu")
        r_eqemu.pack(side="right", padx=5)

        # News & Status Box (tk.Text takes padx/pady; "padding" is ttk-only and raises TclError)
        self.news_text = tk.Text(self, height=11, bg="#1E1E1E", fg="#D4D4D4", insertbackground="white", relief="flat", highlightthickness=1, highlightbackground="#333333", font=("Consolas", 9), padx=10, pady=10)
        self.news_text.pack(fill="both", expand=True, padx=15, pady=5)
        self.log_msg("Welcome to Bigg Adventure Patcher!\nClick 'CHECK FOR UPDATES' to sync custom spells, UI, and skill caps.\n" + "="*70 + "\n")

        # Progress Bar & Status Text
        progress_frame = ttk.Frame(self, padding=(15, 5))
        progress_frame.pack(fill="x")

        self.status_label = ttk.Label(progress_frame, text="Ready to check client files.")
        self.status_label.pack(anchor="w", pady=(0, 5))

        self.progress_bar = ttk.Progressbar(progress_frame, orient="horizontal", mode="determinate")
        self.progress_bar.pack(fill="x")

        # Bottom Button Panel
        button_frame = ttk.Frame(self, padding=(15, 10))
        button_frame.pack(fill="x")

        self.check_btn = tk.Button(button_frame, text="CHECK FOR UPDATES", bg="#2979FF", fg="white", font=("Segoe UI", 10, "bold"), relief="flat", activebackground="#2962FF", activeforeground="white", padx=15, pady=8, command=self.start_update_thread)
        self.check_btn.pack(side="left")

        self.play_btn = tk.Button(button_frame, text="PLAY BIGG ADVENTURE", bg="#00C853", fg="white", font=("Segoe UI", 11, "bold"), relief="flat", activebackground="#009688", activeforeground="white", padx=25, pady=8, command=self.launch_game, state="normal")
        self.play_btn.pack(side="right")

    def log_msg(self, text):
        self.news_text.insert(tk.END, text + "\n")
        self.news_text.see(tk.END)

    def start_update_thread(self):
        self.check_btn.config(state="disabled")
        threading.Thread(target=self.run_patch_process, daemon=True).start()

    def get_sha256(self, filepath):
        if not os.path.exists(filepath):
            return None
        sha256 = hashlib.sha256()
        with open(filepath, 'rb') as f:
            while chunk := f.read(65536):
                sha256.update(chunk)
        return sha256.hexdigest()

    def update_eqhost(self, server):
        use_public = (self.login_target_var.get() == "eqemu")
        host_domain = "login.eqemulator.net" if use_public else server
        host_string = f"Host={host_domain}:{self.login_port}"

        eqhost_path = os.path.join(os.getcwd(), "eqhost.txt")
        content = f"[LoginServer]\n{host_string}\n"
        try:
            with open(eqhost_path, "w") as f:
                f.write(content)
            self.log_msg(f"[✓] Configured eqhost.txt -> {host_string}")
        except Exception as e:
            self.log_msg(f"[!] Warning: Could not write eqhost.txt ({e})")

    def fetch_manifest(self):
        """Returns (server, manifest) from the first patch server that answers, else (None, None)."""
        for server in PATCH_SERVERS:
            manifest_url = f"http://{server}:{HTTP_PORT}/patch_manifest.json"
            self.log_msg(f"Connecting to manifest server: {manifest_url}")
            try:
                req = urllib.request.Request(manifest_url, headers={'User-Agent': 'BiggAdventurePatcher/1.1'})
                with urllib.request.urlopen(req, timeout=5) as response:
                    return server, json.loads(response.read().decode())
            except Exception as e:
                self.log_msg(f"[!] {server} unavailable: {e}")
        return None, None

    def run_patch_process(self):
        self.status_label.config(text="Fetching server manifest...")

        server, manifest_data = self.fetch_manifest()
        if manifest_data is None:
            self.log_msg("[!] No patch server reachable. Falling back to local eqhost configuration.")
            self.update_eqhost(PATCH_SERVERS[0])
            self.status_label.config(text="Offline mode ready.")
            self.check_btn.config(state="normal")
            return

        server_news = manifest_data.get("news", "")
        if server_news:
            self.log_msg(f"\nSERVER NEWS:\n{server_news}\n" + "-"*70)

        self.update_eqhost(server)
        retire_client_files(manifest_data.get("retire"), ".", self.log_msg)

        files_to_patch = manifest_data.get("files", [])
        total_files = len(files_to_patch)

        if total_files == 0:
            self.log_msg("[✓] No custom patch files required.")
            self.status_label.config(text="Client is up to date!")
            self.progress_bar["value"] = 100
            self.check_btn.config(state="normal")
            return

        updated_count = 0
        failed_count = 0
        for idx, file_info in enumerate(files_to_patch):
            rel_path = file_info["path"]
            expected_hash = file_info["sha256"]

            progress_pct = int(((idx + 1) / total_files) * 100)
            self.progress_bar["value"] = progress_pct
            self.status_label.config(text=f"Checking file ({idx+1}/{total_files}): {rel_path}")

            # Never write outside the client folder, whatever the manifest says
            if os.path.isabs(rel_path) or ".." in rel_path.replace("\\", "/").split("/"):
                self.log_msg(f"[!] Skipping unsafe path in manifest: {rel_path}")
                failed_count += 1
                continue

            if self.get_sha256(rel_path) != expected_hash:
                self.log_msg(f"[-->] Downloading updated asset: {rel_path}")
                download_url = f"http://{server}:{HTTP_PORT}/patch_files/{urllib.parse.quote(rel_path)}"
                dir_name = os.path.dirname(rel_path)
                if dir_name:
                    os.makedirs(dir_name, exist_ok=True)

                # Download beside the target and swap in only once the hash checks out,
                # so a dropped connection can never leave a truncated eqgame.exe behind.
                part_path = rel_path + ".part"
                try:
                    urllib.request.urlretrieve(download_url, part_path)
                    if self.get_sha256(part_path) != expected_hash:
                        raise ValueError("checksum mismatch after download")
                    os.replace(part_path, rel_path)
                    updated_count += 1

                    # Auto-extract MacroQuest or other zip files
                    if rel_path.lower().endswith(".zip"):
                        self.log_msg(f"[-->] Extracting {rel_path}...")
                        import zipfile
                        with zipfile.ZipFile(rel_path, 'r') as zip_ref:
                            zip_ref.extractall(os.path.dirname(rel_path) or ".")
                        self.log_msg(f"[✓] Extracted {rel_path}")

                except Exception as e:
                    failed_count += 1
                    if os.path.exists(part_path):
                        os.remove(part_path)
                    self.log_msg(f"[!] Error downloading/extracting {rel_path}: {e}")

        if failed_count:
            self.log_msg(f"\n[!] Patch check finished with {failed_count} failure(s) ({updated_count} file(s) updated). Run it again before playing.")
            self.status_label.config(text=f"{failed_count} file(s) failed to update - click CHECK FOR UPDATES to retry.")
        else:
            self.log_msg(f"\n[✓] Patch check completed! ({updated_count} file(s) updated)")
            self.status_label.config(text="Client is fully patched and ready!")
        self.check_btn.config(state="normal")

    def launch_game(self):
        eqgame_exe = os.path.join(os.getcwd(), "eqgame.exe")
        if not os.path.exists(eqgame_exe):
            messagebox.showerror("Launcher Error", f"eqgame.exe was not found in current directory:\n{os.getcwd()}\n\nPlease place this patcher in your EverQuest client directory.")
            return

        self.log_msg("Launching EverQuest (eqgame.exe patchme)...")
        try:
            subprocess.Popen([eqgame_exe, "patchme"], cwd=os.getcwd())
            self.after(2000, self.destroy)
        except Exception as e:
            messagebox.showerror("Launch Failed", f"Could not start eqgame.exe:\n{e}")

if __name__ == "__main__":
    app = BiggAdventurePatcher()
    app.mainloop()
