# File: dependency_bundler_gui.py
# A simple GUI tool to bundle an executable with DLLs from its directory.

import tkinter as tk
from tkinter import ttk, filedialog, scrolledtext, messagebox
import os
import shutil
import logging
import threading
from typing import Optional, List, Set

# Basic Logging Setup for the bundler tool itself
logging.basicConfig(
    level=logging.DEBUG,
    format="%(asctime)s [%(levelname)-7s] %(name)s: %(message)s",
    datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger("DependencyBundler")


class DependencyBundlerApp(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("Dependency Bundler Tool")
        self.geometry("750x550")
        self.minsize(600, 400)

        self.target_exe_path_var = tk.StringVar()
        self.output_dir_path_var = tk.StringVar()
        # Optional: Extensions to look for in the source directory besides .dll
        self.additional_extensions_var = tk.StringVar(value=".exe")

        self._create_widgets()
        self.protocol("WM_DELETE_WINDOW", self._on_closing)

    def _create_widgets(self):
        main_frame = ttk.Frame(self, padding="10")
        main_frame.pack(expand=True, fill=tk.BOTH)

        # Configure grid weights for resizing
        main_frame.columnconfigure(1, weight=1)
        main_frame.rowconfigure(3, weight=1)  # Log area will expand

        # --- Input Path Frame ---
        input_frame = ttk.LabelFrame(main_frame, text="Paths", padding="10")
        input_frame.grid(row=0, column=0, columnspan=3, sticky="ew", padx=5, pady=5)
        input_frame.columnconfigure(1, weight=1)

        ttk.Label(input_frame, text="Target Executable:").grid(
            row=0, column=0, sticky=tk.W, padx=5, pady=3
        )
        ttk.Entry(input_frame, textvariable=self.target_exe_path_var).grid(
            row=0, column=1, sticky="ew", padx=5, pady=3
        )
        ttk.Button(input_frame, text="Browse...", command=self._browse_target_exe).grid(
            row=0, column=2, padx=5, pady=3
        )

        ttk.Label(input_frame, text="Output Bundle Directory:").grid(
            row=1, column=0, sticky=tk.W, padx=5, pady=3
        )
        ttk.Entry(input_frame, textvariable=self.output_dir_path_var).grid(
            row=1, column=1, sticky="ew", padx=5, pady=3
        )
        ttk.Button(input_frame, text="Browse...", command=self._browse_output_dir).grid(
            row=1, column=2, padx=5, pady=3
        )

        # --- Options Frame (Optional) ---
        options_frame = ttk.LabelFrame(main_frame, text="Options", padding="10")
        options_frame.grid(row=1, column=0, columnspan=3, sticky="ew", padx=5, pady=5)
        options_frame.columnconfigure(1, weight=1)

        ttk.Label(
            options_frame,
            text="Copy files with these extensions (comma-separated, e.g., .exe,.dll,.manifest):",
        ).grid(row=0, column=0, sticky=tk.W, padx=5, pady=3)
        ttk.Entry(options_frame, textvariable=self.additional_extensions_var).grid(
            row=0, column=1, columnspan=2, sticky="ew", padx=5, pady=3
        )

        # --- Action Button ---
        self.start_button = ttk.Button(
            main_frame, text="Start Bundling", command=self._start_bundling_process
        )
        self.start_button.grid(row=2, column=0, columnspan=3, pady=10)

        # --- Log Area ---
        log_frame = ttk.LabelFrame(main_frame, text="Log", padding="10")
        log_frame.grid(row=3, column=0, columnspan=3, sticky="nsew", padx=5, pady=5)
        log_frame.rowconfigure(0, weight=1)
        log_frame.columnconfigure(0, weight=1)

        self.log_text = scrolledtext.ScrolledText(
            log_frame, wrap=tk.WORD, state=tk.DISABLED, height=10, font=("Consolas", 9)
        )
        self.log_text.grid(row=0, column=0, sticky="nsew")

        # --- Status Bar ---
        self.status_var = tk.StringVar(value="Ready.")
        status_bar = ttk.Label(
            main_frame, textvariable=self.status_var, relief=tk.SUNKEN, anchor=tk.W
        )
        status_bar.grid(row=4, column=0, columnspan=3, sticky="ew", ipady=2)

    def _log_message(self, message: str, level: str = "INFO"):
        if not self.log_text.winfo_exists():
            return

        formatted_message = f"[{level.upper()}] {message}"
        self.log_text.config(state=tk.NORMAL)
        self.log_text.insert(tk.END, formatted_message + "\n")
        self.log_text.see(tk.END)
        self.log_text.config(state=tk.DISABLED)
        if level.upper() == "INFO":
            logger.info(message)
        elif level.upper() == "WARNING":
            logger.warning(message)
        elif level.upper() == "ERROR":
            logger.error(message)
        else:
            logger.debug(message)  # Default to debug for other levels
        self.update_idletasks()

    def _update_status(self, message: str):
        self.status_var.set(message)
        self._log_message(message, "STATUS")
        self.update_idletasks()

    def _browse_target_exe(self):
        # Consider initialdir based on current value if any
        initial_dir = (
            os.path.dirname(self.target_exe_path_var.get())
            if self.target_exe_path_var.get()
            else None
        )
        filepath = filedialog.askopenfilename(
            title="Select Target Executable",
            filetypes=(("Executable files", "*.exe"), ("All files", "*.*")),
            initialdir=initial_dir,
            parent=self,
        )
        if filepath:
            self.target_exe_path_var.set(filepath)
            self._log_message(f"Target executable set to: {filepath}")
            # Auto-suggest output directory based on target
            if not self.output_dir_path_var.get():
                base_name = os.path.splitext(os.path.basename(filepath))[0]
                suggested_output_dir = os.path.join(
                    os.path.dirname(filepath), f"{base_name}_bundle"
                )
                self.output_dir_path_var.set(suggested_output_dir)
                self._log_message(f"Suggested output directory: {suggested_output_dir}")

    def _browse_output_dir(self):
        initial_dir = (
            self.output_dir_path_var.get() if self.output_dir_path_var.get() else None
        )
        dirpath = filedialog.askdirectory(
            title="Select Output Bundle Directory", initialdir=initial_dir, parent=self
        )
        if dirpath:
            self.output_dir_path_var.set(dirpath)
            self._log_message(f"Output directory set to: {dirpath}")

    def _validate_inputs(self) -> bool:
        target_exe = self.target_exe_path_var.get()
        output_dir = self.output_dir_path_var.get()

        if not target_exe:
            messagebox.showerror(
                "Input Error", "Target Executable path cannot be empty.", parent=self
            )
            return False
        if not os.path.isfile(target_exe):
            messagebox.showerror(
                "Input Error", f"Target Executable not found: {target_exe}", parent=self
            )
            return False
        if not output_dir:
            messagebox.showerror(
                "Input Error", "Output Bundle Directory cannot be empty.", parent=self
            )
            return False

        extensions_str = self.additional_extensions_var.get().strip()
        if extensions_str:
            for ext in extensions_str.split(","):
                if not ext.strip().startswith("."):
                    messagebox.showerror(
                        "Input Error",
                        f"Invalid extension format: '{ext.strip()}'. "
                        "Extensions should start with a dot (e.g., .dll, .manifest).",
                        parent=self,
                    )
                    return False
        return True

    def _start_bundling_process(self):
        if not self._validate_inputs():
            return

        self.start_button.config(state=tk.DISABLED)
        self._update_status("Starting bundling process...")
        self.log_text.config(state=tk.NORMAL)
        self.log_text.delete("1.0", tk.END)  # Clear previous log
        self.log_text.config(state=tk.DISABLED)

        target_exe = self.target_exe_path_var.get()
        output_dir = self.output_dir_path_var.get()
        extensions_to_copy_str = self.additional_extensions_var.get().strip().lower()

        # Always include .dll, then add others from the input field
        extensions_set: Set[str] = {".dll"}
        if extensions_to_copy_str:
            for ext_item in extensions_to_copy_str.split(","):
                clean_ext = ext_item.strip()
                if clean_ext:  # Ensure it's not empty after strip
                    extensions_set.add(clean_ext)

        # Convert set to list for processing if needed, though set is fine for `in` checks
        final_extensions_list = list(extensions_set)
        self._log_message(
            f"Will copy executable and files with extensions: {', '.join(sorted(final_extensions_list))}"
        )

        # Run the bundling in a separate thread to keep GUI responsive
        bundling_thread = threading.Thread(
            target=self._bundle_dependencies_thread_target,
            args=(
                target_exe,
                output_dir,
                final_extensions_list,
            ),  # Pass the list of extensions
            daemon=True,
        )
        bundling_thread.start()

    def _bundle_dependencies_thread_target(
        self,
        target_exe_path: str,
        output_bundle_dir: str,
        file_extensions_to_copy: List[str],
    ):
        try:
            self._update_status("Preparing output directory...")
            if not os.path.exists(output_bundle_dir):
                os.makedirs(output_bundle_dir)
                self._log_message(f"Created output directory: {output_bundle_dir}")
            else:
                self._log_message(
                    f"Output directory already exists: {output_bundle_dir}"
                )

            source_exe_dir = os.path.dirname(target_exe_path)
            target_exe_filename = os.path.basename(target_exe_path)

            # 1. Copy the target executable itself
            dest_exe_path = os.path.join(output_bundle_dir, target_exe_filename)
            self._log_message(
                f"Copying '{target_exe_filename}' to '{output_bundle_dir}'..."
            )
            shutil.copy2(target_exe_path, dest_exe_path)
            self._log_message(f"Copied: {target_exe_filename}")

            # 2. Copy other specified files (DLLs and additional extensions) from the source directory
            copied_count = 0
            found_files_in_source_dir = os.listdir(source_exe_dir)

            self._update_status(
                f"Scanning {len(found_files_in_source_dir)} files in '{source_exe_dir}'..."
            )

            for filename in found_files_in_source_dir:
                file_path_in_source = os.path.join(source_exe_dir, filename)
                # Check if it's a file and has one of the desired extensions
                if os.path.isfile(file_path_in_source):
                    file_ext_lower = os.path.splitext(filename)[1].lower()
                    if file_ext_lower in file_extensions_to_copy:
                        # Avoid re-copying the main executable if it matches an extension (e.g. .exe)
                        if os.path.normpath(file_path_in_source) == os.path.normpath(
                            target_exe_path
                        ) and target_exe_filename.lower().endswith(
                            file_ext_lower
                        ):  # Make sure it's actually the exe
                            self._log_message(
                                f"Skipping main executable '{filename}' (already copied).",
                                "DEBUG",
                            )
                            continue

                        dest_path = os.path.join(output_bundle_dir, filename)
                        self._log_message(
                            f"Copying '{filename}' to '{output_bundle_dir}'..."
                        )
                        shutil.copy2(file_path_in_source, dest_path)
                        self._log_message(f"Copied: {filename}")
                        copied_count += 1

            self._log_message(f"Copied {copied_count} additional dependency files.")
            final_message = f"Bundling complete! Output: {output_bundle_dir}"
            self._update_status(final_message)
            if self.winfo_exists():
                self.after(
                    0,
                    lambda: messagebox.showinfo(
                        "Bundling Complete", final_message, parent=self
                    ),
                )

        except Exception as e:
            error_msg = f"Error during bundling: {e}"
            self._log_message(error_msg, "ERROR")
            self._update_status(f"Bundling failed. See log. Error: {e}")
            logger.error(error_msg, exc_info=True)
            if self.winfo_exists():
                self.after(
                    0,
                    lambda: messagebox.showerror(
                        "Bundling Error", error_msg, parent=self
                    ),
                )
        finally:
            if self.winfo_exists():
                self.after(0, lambda: self.start_button.config(state=tk.NORMAL))

    def _on_closing(self):
        logger.info("Dependency Bundler GUI closing.")
        self.destroy()


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