import os
import shutil
import tkinter as tk
from tkinter import filedialog, messagebox, simpledialog
import re
from collections import defaultdict
import natsort

# Initialize global variables
folder_path = ""
groups = {}
display_to_group_map = {}
pending_renames = {}  # Stores pending renames: {original_group_name: new_group_name}
pending_merges = []   # Stores pending merges: list of group_names to merge into new_group_name
merge_history = []    # Stack to keep track of merges for undo functionality
rename_in_progress = False  # Flag to prevent multiple rename operations

def clean_filename(filename, redundant_words):
    """Clean filename according to specified rules (remove numbers, repeated words)."""
    filename = filename.split('：', 1)[-1]
    filename = os.path.splitext(filename)[0]
    words = filename.replace('_', ' ').lower().split()

    if all(part.isdigit() for part in words):
        return ' '.join(words)

    words = [re.sub(r'\d+', ' ', word).strip() for word in words]
    words = ' '.join(words).split()

    unique_words = [w for w in words if w not in redundant_words]
    seen = set()
    cleaned_words = [word for word in unique_words if len(word) > 2 and not (word in seen or seen.add(word))]

    cleaned_filename = ' '.join(cleaned_words)
    return cleaned_filename if cleaned_filename else "misc_files"

def find_redundant_words(files):
    if not files:
        return []

    processed_files = [file.split('：', 1)[-1].replace('_', ' ').lower().split() for file in files]

    common_words = set(processed_files[0])
    for file_words in processed_files[1:]:
        common_words.intersection_update(file_words)

    redundant_words = list(common_words)
    return redundant_words

def extract_sort_key(filename):
    match = re.search(r's_(\d+)', filename.lower())
    return int(match.group(1)) if match else 0

def prepare_groups(files, grouping_enabled):
    if not files:
        return {}

    if grouping_enabled:
        redundant_words = find_redundant_words(files)
        temp_groups = defaultdict(list)

        for file in files:
            cleaned_name = clean_filename(file, redundant_words)
            temp_groups[cleaned_name].append(file)

        for group in temp_groups:
            temp_groups[group].sort(key=extract_sort_key)

        return temp_groups
    else:
        return {"All Files": sorted(files)}

def split_into_subgroups(files, file_num):
    """Splits a list of files into subgroups each containing up to file_num files."""
    subgroups = []
    if file_num <= 0:
        file_num = 30  # default to prevent infinite loop
    total_files = len(files)
    index = 0

    while index < total_files:
        remaining = total_files - index
        if remaining <= 2 * file_num:
            subgroups.append(files[index:])
            break
        else:
            subgroups.append(files[index:index + file_num])
            index += file_num

    return subgroups

def capitalize_folder_name(folder_name):
    short_words = {'of', 'is', 'a', 'an'}
    words = folder_name.split()
    capitalized = [word.capitalize() if word.lower() not in short_words else word for word in words]
    modified_name = ' '.join(capitalized)
    return modified_name

def get_preview_groups():
    preview = groups.copy()

    # Apply pending renames
    for original_group, new_group in pending_renames.items():
        if original_group in preview:
            preview[new_group] = preview.pop(original_group)

    # Apply pending merges
    for merge_op in pending_merges:
        groups_to_merge = merge_op['groups']
        new_group_name = merge_op['new_group']
        merged_files = []

        for group in groups_to_merge:
            merged_files.extend(preview.get(group, []))
            if group in preview:
                del preview[group]

        if new_group_name in preview:
            preview[new_group_name].extend(merged_files)
        else:
            preview[new_group_name] = merged_files

    # Handle Group by File Number
    grouping_enabled_flag = enable_grouping_var.get()
    group_files_flag = group_files_var.get()
    file_num = int(file_number_var.get()) if file_number_var.get().isdigit() and int(file_number_var.get()) > 0 else 30

    if grouping_enabled_flag and group_files_flag:
        # When both Folder Grouping and Group by File Number are enabled
        temp_preview = {}
        for group_name, files in preview.items():
            subgroups = split_into_subgroups(files, file_num)
            for i, subgroup in enumerate(subgroups, 1):
                # Append subgroup index to group name for uniqueness
                subgroup_name = f"{group_name} - {i}"
                temp_preview[subgroup_name] = subgroup
        preview = temp_preview
    elif not grouping_enabled_flag and group_files_flag:
        # When Folder Grouping is disabled and Group by File Number is enabled
        all_files = sum(preview.values(), [])
        subgroups = split_into_subgroups(all_files, file_num)
        preview = {f"{parent_name_var.get().strip()} - {i+1}": subgroup for i, subgroup in enumerate(subgroups)}

    return preview

def update_folder_info(preview_groups):
    total_files_label.config(text=f"Total Files: {sum(len(files) for files in preview_groups.values())}")

def show_folder_contents(event=None):
    try:
        selection = folder_listbox.curselection()
        if not selection:
            content_listbox.delete(0, tk.END)
            file_number_info.config(text="")
            return

        folder_display_name = folder_listbox.get(selection[0])
        original_group_name = display_to_group_map.get(folder_display_name, folder_display_name)

        preview_groups = get_preview_groups()

        update_folder_info(preview_groups)

        content_listbox.delete(0, tk.END)

        files_in_group = preview_groups.get(original_group_name, [])

        if not files_in_group:
            content_listbox.insert(tk.END, "No files in this group.")
            return

        for file in natsort.natsorted(files_in_group):
            content_listbox.insert(tk.END, file)

        file_number_info.config(text=f"Files in this Group: {len(files_in_group)}")
    except Exception as e:
        messagebox.showerror("Error", f"An error occurred while displaying folder contents:\n{str(e)}")

def load_directory():
    global folder_path, groups, pending_renames, pending_merges, merge_history
    folder_path = filedialog.askdirectory(title="Select the folder containing your files")
    if not folder_path:
        return
    parent_folder_name = os.path.basename(os.path.normpath(folder_path))
    parent_name_var.set(parent_folder_name)
    try:
        files = [f for f in os.listdir(folder_path) if os.path.isfile(os.path.join(folder_path, f))]
        groups = prepare_groups(files, enable_grouping_var.get())
        pending_renames.clear()
        pending_merges.clear()
        merge_history.clear()
        update_displayed_folders()
    except Exception as e:
        messagebox.showerror("Error", f"An error occurred while loading the directory:\n{str(e)}")

def apply_options(*args):
    if not folder_path:
        return
    try:
        prefix_check.config(state=tk.NORMAL)
        capitalize_check.config(state=tk.NORMAL)

        if not enable_grouping_var.get() and group_files_var.get():
            prefix_check.config(state=tk.DISABLED)
            capitalize_check.config(state=tk.DISABLED)
        
        update_displayed_folders()
    except Exception as e:
        messagebox.showerror("Error", f"An error occurred while applying options:\n{str(e)}")

def toggle_grouping(*args):
    if not folder_path:
        return
    try:
        files = [f for f in os.listdir(folder_path) if os.path.isfile(os.path.join(folder_path, f))]
        groups.clear()
        groups.update(prepare_groups(files, enable_grouping_var.get()))
        pending_renames.clear()
        pending_merges.clear()
        merge_history.clear()
        update_displayed_folders()
        show_folder_contents()
    except Exception as e:
        messagebox.showerror("Error", f"An error occurred while toggling grouping:\n{str(e)}")

def update_displayed_folders():
    global display_to_group_map

    parent_prefix = parent_name_var.get().strip()
    add_prefix = prefix_var.get()
    capitalize_names = capitalize_var.get()
    group_files = group_files_var.get()
    grouping_enabled_flag = enable_grouping_var.get()

    folder_listbox.delete(0, tk.END)
    display_to_group_map.clear()

    # Get preview_groups which includes pending renames and merges
    preview_groups = get_preview_groups()

    update_folder_info(preview_groups)

    sorted_folder_names = natsort.natsorted(preview_groups.keys())
    for folder_name in sorted_folder_names:
        display_name = folder_name
        if grouping_enabled_flag:
            if " - " in folder_name:
                # This is a subgroup, e.g., "GroupName - 1"
                base_group_name = " - ".join(folder_name.split(" - ")[:-1])
                subgroup_suffix = " - " + folder_name.split(" - ")[-1]
                if base_group_name != "All Files":
                    base_display_name = capitalize_folder_name(base_group_name) if capitalize_names else base_group_name
                    if add_prefix:
                        base_display_name = f"{parent_prefix} - {base_display_name}"
                    display_name = f"{base_display_name}{subgroup_suffix}"
                else:
                    display_name = f"{parent_prefix}{subgroup_suffix}" if add_prefix else subgroup_suffix.strip()
            else:
                if folder_name != "All Files":
                    modified_folder_name = capitalize_folder_name(display_name) if capitalize_names else display_name
                    modified_folder_name = f"{parent_prefix} - {modified_folder_name}" if add_prefix else modified_folder_name
                    display_name = modified_folder_name
                else:
                    display_name = parent_prefix if parent_prefix else "Organized Files"
        else:
            if group_files:
                modified_folder_name = folder_name  # Subgroup names already include parent_prefix
                display_name = modified_folder_name
            else:
                modified_folder_name = parent_prefix if parent_prefix else "Organized Files"
                display_name = modified_folder_name

        folder_listbox.insert(tk.END, display_name)
        display_to_group_map[display_name] = folder_name

    if folder_listbox.size() > 0:
        folder_listbox.select_set(0)
        show_folder_contents()

def reset_program():
    global folder_path, groups, display_to_group_map, pending_renames, pending_merges, merge_history, rename_in_progress

    folder_path = ""
    groups.clear()
    display_to_group_map.clear()
    pending_renames.clear()
    pending_merges.clear()
    merge_history.clear()
    rename_in_progress = False

    folder_listbox.delete(0, tk.END)
    content_listbox.delete(0, tk.END)
    parent_name_var.set("")
    total_files_label.config(text="Total Files: 0")
    file_number_info.config(text="")

def execute_sorting():
    if not folder_path or not groups:
        messagebox.showerror("Error", "No directory loaded or no files grouped. Please load a directory first.")
        return

    try:
        parent_prefix = parent_name_var.get().strip()
        add_prefix = prefix_var.get()
        group_files = group_files_var.get()
        capitalize_names = capitalize_var.get()
        grouping_enabled_flag = enable_grouping_var.get()
        file_num_str = file_number_var.get().strip()
        file_num = int(file_num_str) if file_num_str.isdigit() and int(file_num_str) > 0 else 30

        # Get the latest groupings, including pending renames and merges
        preview_groups = get_preview_groups()

        if grouping_enabled_flag:
            for group_name, files in preview_groups.items():
                # Determine the actual folder name based on whether it's a subgroup
                if " - " in group_name:
                    # It's a subgroup
                    base_group_name = " - ".join(group_name.split(" - ")[:-1])
                    subgroup_suffix = " - " + group_name.split(" - ")[-1]
                    if base_group_name != "All Files":
                        base_folder_name = capitalize_folder_name(base_group_name) if capitalize_names else base_group_name
                        modified_folder_name = f"{parent_prefix} - {base_folder_name}" if add_prefix else base_folder_name
                        subfolder_name = f"{modified_folder_name}{subgroup_suffix}"
                    else:
                        subfolder_name = f"{parent_prefix}{subgroup_suffix}" if add_prefix else subgroup_suffix.strip()
                else:
                    if group_name == "All Files":
                        subfolder_name = parent_prefix if add_prefix else "Organized Files"
                    else:
                        base_folder_name = capitalize_folder_name(group_name) if capitalize_names else group_name
                        subfolder_name = f"{parent_prefix} - {base_folder_name}" if add_prefix else base_folder_name

                new_folder_path = os.path.join(folder_path, subfolder_name)

                if os.path.abspath(new_folder_path) == os.path.abspath(folder_path):
                    messagebox.showerror("Error", f"Cannot create a folder with the same name as the parent folder: {subfolder_name}")
                    continue

                os.makedirs(new_folder_path, exist_ok=True)
                for file in files:
                    src = os.path.join(folder_path, file)
                    dest = os.path.join(new_folder_path, file)
                    shutil.move(src, dest)
        elif group_files and file_num:
            # When Folder Grouping is Disabled and Group by File Number is Enabled
            for subfolder_name, files in preview_groups.items():
                new_folder_path = os.path.join(folder_path, subfolder_name)
                os.makedirs(new_folder_path, exist_ok=True)
                for file in files:
                    src = os.path.join(folder_path, file)
                    dest = os.path.join(new_folder_path, file)
                    shutil.move(src, dest)
        else:
            # When Folder Grouping is Disabled and Group by File Number is Disabled
            # Move all files into a single folder named after the parent prefix
            new_folder_path = os.path.join(folder_path, parent_prefix if parent_prefix else "Organized Files")
            os.makedirs(new_folder_path, exist_ok=True)
            for files in preview_groups.values():
                for file in files:
                    src = os.path.join(folder_path, file)
                    dest = os.path.join(new_folder_path, file)
                    shutil.move(src, dest)

        messagebox.showinfo("Success", "Files sorted and organized successfully!")
        reset_program()
    except Exception as e:
        messagebox.showerror("Error", f"An error occurred during sorting:\n{str(e)}")

def rename_selected_folder():
    global rename_in_progress
    selected_indices = folder_listbox.curselection()
    if len(selected_indices) != 1:
        messagebox.showinfo("Rename Folder", "Please select exactly one folder to rename.")
        return

    if rename_in_progress:
        messagebox.showinfo("Rename in Progress", "Please complete the current rename operation before starting a new one.")
        return

    rename_in_progress = True

    index = selected_indices[0]
    old_display_name = folder_listbox.get(index)
    original_group_name = display_to_group_map.get(old_display_name, old_display_name)

    entry = tk.Entry(frame, width=40)
    entry.insert(0, old_display_name)
    entry.select_range(0, tk.END)
    entry.focus()

    bbox = folder_listbox.bbox(index)
    if bbox:
        entry.place(x=folder_listbox.winfo_x() + bbox[0], y=folder_listbox.winfo_y() + bbox[1], width=bbox[2], height=bbox[3])

    def on_enter(event=None):
        global rename_in_progress
        new_display_name = entry.get().strip()
        if not new_display_name:
            messagebox.showerror("Invalid Name", "Folder name cannot be empty.")
            rename_in_progress = False
            entry.destroy()
            root.unbind("<Button-1>")
            return
        if new_display_name in display_to_group_map and new_display_name != old_display_name:
            messagebox.showerror("Duplicate Name", "A folder with this name already exists.")
            rename_in_progress = False
            entry.destroy()
            root.unbind("<Button-1>")
            return

        pending_renames[original_group_name] = new_display_name

        folder_listbox.delete(index)
        folder_listbox.insert(index, new_display_name)
        display_to_group_map[new_display_name] = new_display_name
        del display_to_group_map[old_display_name]

        entry.destroy()
        rename_in_progress = False
        root.unbind("<Button-1>")
        update_displayed_folders()

    entry.bind("<Return>", on_enter)

    def on_click_anywhere(event):
        if event.widget != entry:
            on_enter()

    root.bind("<Button-1>", on_click_anywhere)
    entry.bind("<FocusOut>", on_enter)

def merge_selected_folders():
    selected_indices = folder_listbox.curselection()
    if len(selected_indices) < 2:
        messagebox.showinfo("Merge Folders", "Please select two or more folders to merge.")
        return

    selected_display_names = [folder_listbox.get(i) for i in selected_indices]
    selected_group_names = [display_to_group_map.get(name, name) for name in selected_display_names]

    default_new_folder_name = selected_display_names[0]
    new_folder_name = simpledialog.askstring("Merge Folders", "Enter the name for the merged folder:", initialvalue=default_new_folder_name)
    if not new_folder_name:
        messagebox.showerror("Invalid Name", "Folder name cannot be empty.")
        return

    # Check for duplicate names
    if new_folder_name in display_to_group_map and new_folder_name not in selected_display_names:
        messagebox.showerror("Duplicate Name", "A folder with this name already exists.")
        return

    for merge_op in pending_merges:
        if new_folder_name == merge_op['new_group']:
            messagebox.showerror("Duplicate Name", "A pending merge to this name already exists.")
            return

    pending_merges.append({'groups': selected_group_names, 'new_group': new_folder_name})
    merge_history.append({'groups': selected_group_names, 'new_group': new_folder_name})

    for i in reversed(selected_indices):
        display_name = folder_listbox.get(i)
        folder_listbox.delete(i)
        del display_to_group_map[display_name]

    folder_listbox.insert(tk.END, new_folder_name)
    display_to_group_map[new_folder_name] = new_folder_name
    update_displayed_folders()

def undo_merge():
    if not merge_history:
        messagebox.showinfo("Undo Merge", "No merge operations to undo.")
        return

    last_merge = merge_history.pop()
    groups_to_split = last_merge['groups']
    new_group_name = last_merge['new_group']

    pending_merges[:] = [merge for merge in pending_merges if merge['new_group'] != new_group_name]

    folder_listbox.delete(tk.END)
    for group in groups_to_split:
        folder_listbox.insert(tk.END, group)
        display_to_group_map[group] = group

    if new_group_name in display_to_group_map:
        del display_to_group_map[new_group_name]

    update_displayed_folders()

def create_context_menu(event):
    try:
        context_menu = tk.Menu(root, tearoff=0)
        context_menu.add_command(label="Rename", command=rename_selected_folder)

        selected_indices = folder_listbox.curselection()
        if len(selected_indices) > 1:
            context_menu.add_command(label="Merge Selected Folders", command=merge_selected_folders)

        if merge_history:
            context_menu.add_separator()
            context_menu.add_command(label="Undo Last Merge", command=undo_merge)

        context_menu.post(event.x_root, event.y_root)
    except Exception as e:
        messagebox.showerror("Error", f"An error occurred while creating the context menu:\n{str(e)}")

# Initialize the main application window
root = tk.Tk()
root.title("File Sorter")
root.geometry("900x600")
root.resizable(False, False)

# Create and layout the main frame
frame = tk.Frame(root)
frame.pack(pady=20, padx=20, fill=tk.BOTH, expand=True)

folder_listbox_width = 40
content_listbox_width = 70

# Parent Folder Name Entry
parent_name_label = tk.Label(frame, text="Parent Folder Name:")
parent_name_label.grid(row=0, column=0, padx=5, sticky='w')

parent_name_var = tk.StringVar()
parent_name_entry = tk.Entry(frame, width=50, textvariable=parent_name_var)
parent_name_entry.grid(row=0, column=1, columnspan=2, padx=5, sticky='ew')
parent_name_var.trace_add('write', lambda *args: apply_options())

# Total Files Label
total_files_label = tk.Label(frame, text="Total Files: 0")
total_files_label.grid(row=0, column=3, padx=5, sticky='w')

# Add Parent Name Prefix Checkbox
prefix_var = tk.IntVar(value=0)
prefix_check = tk.Checkbutton(frame, text="Add Parent Name Prefix", variable=prefix_var, command=apply_options)
prefix_check.grid(row=1, column=0, columnspan=2, pady=5, sticky='w')

# Group by File Number Checkbox and Entry
group_files_var = tk.IntVar(value=0)
group_files_check = tk.Checkbutton(frame, text="Group by file number", variable=group_files_var, command=apply_options)
group_files_check.grid(row=2, column=0, pady=5, sticky='w')

file_number_label = tk.Label(frame, text="Files per subfolder:")
file_number_label.grid(row=2, column=1, padx=0, sticky='w')

file_number_var = tk.StringVar(value="30")
file_number_entry = tk.Entry(frame, width=5, textvariable=file_number_var)
file_number_entry.grid(row=2, column=1, sticky='w', padx=(110, 0))
file_number_var.trace_add('write', lambda *args: apply_options())

# Capitalize Folder Names Checkbox
capitalize_var = tk.IntVar(value=1)
capitalize_check = tk.Checkbutton(frame, text="Capitalize Folder Names", variable=capitalize_var, command=apply_options)
capitalize_check.grid(row=3, column=0, columnspan=2, pady=5, sticky='w')

# Enable Folder Grouping Checkbox
enable_grouping_var = tk.IntVar(value=1)
enable_grouping_check = tk.Checkbutton(frame, text="Enable Folder Grouping", variable=enable_grouping_var, command=apply_options)
enable_grouping_check.grid(row=4, column=0, columnspan=2, pady=5, sticky='w')
enable_grouping_var.trace_add('write', toggle_grouping)

# Grouped Folders Listbox
folder_listbox_label = tk.Label(frame, text="Grouped Folders (Preview):", font=('Century Gothic', 10, 'bold'))
folder_listbox_label.grid(row=5, column=0, sticky='w')

folder_listbox = tk.Listbox(frame, width=folder_listbox_width, height=15, exportselection=False, selectmode=tk.EXTENDED)
folder_listbox.grid(row=6, column=0, columnspan=2, pady=5, sticky='nsew')
folder_listbox.bind("<<ListboxSelect>>", show_folder_contents)
folder_listbox.bind("<Double-Button-1>", lambda event: rename_selected_folder())

# Bind right-click for context menu
folder_listbox.bind("<Button-3>", create_context_menu)
folder_listbox.bind("<Button-2>", create_context_menu)  # For macOS right-click

# Folder Contents Listbox
content_listbox_label = tk.Label(frame, text="Folder Contents (Preview):", font=('Century Gothic', 10, 'bold'))
content_listbox_label.grid(row=5, column=2, sticky='w')

content_listbox = tk.Listbox(frame, width=content_listbox_width, height=15, exportselection=False)
content_listbox.grid(row=6, column=2, columnspan=2, pady=5, sticky='nsew')

# Configure grid weights for responsive resizing
frame.grid_rowconfigure(6, weight=1)
frame.grid_columnconfigure(1, weight=1)

# File Number Info Label
file_number_info = tk.Label(frame, text="", font=('Century Gothic', 8))
file_number_info.grid(row=7, column=3, pady=(0,20), sticky='ne')

# Browse Directory and Execute Sorting Buttons
browse_button = tk.Button(frame, text="Browse Directory", command=load_directory)
browse_button.grid(row=8, column=0, pady=(10, 0), sticky='w')

execute_button = tk.Button(frame, text="Execute Sorting", command=execute_sorting)
execute_button.grid(row=8, column=3, pady=(10, 0), sticky='e')

# Start the main event loop
root.mainloop()

I would like to view image (open new window) by double clicking an image file inside the filename listbox.
Is it possible to disable the context menu for those indexed folders created by file number grouping?