#!/usr/bin/env python3

# This file is part of I38.
                                                                                                                                                                          
# I38 is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation,
# either version 3 of the License, or (at your option) any later version.
                                                                                                                                                                          
# I38 is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
# PURPOSE. See the GNU General Public License for more details.
                                                                                                                                                                          
# You should have received a copy of the GNU General Public License along with I38. If not, see <https://www.gnu.org/licenses/>.

import os
import configparser
from pathlib import Path
from collections import defaultdict
import gi
gi.require_version('Gtk', '3.0')
gi.require_version('Atk', '1.0')
from gi.repository import Gtk, Gdk, GLib, Atk

def read_desktop_files(paths):
    desktopEntries = []
    for path in paths:
        for file in Path(path).rglob('*.desktop'):
            config = configparser.ConfigParser(interpolation=None)
            config.read(file)
            desktopEntries.append(config)
    return desktopEntries

userApplicationsPath = Path.home() / '.local/share/applications'
systemApplicationsPath = Path('/usr/share/applications')
userFlatpakApplicationsPath = Path.home() / '.local/share/flatpak/exports/share/applications'
systemFlatpakApplicationsPath = Path('/var/lib/flatpak/exports/share/applications')
desktopEntries = read_desktop_files([userApplicationsPath, systemApplicationsPath, userFlatpakApplicationsPath, systemFlatpakApplicationsPath])

# Combine some of the categories
categoryMap = {
    "2DGraphics": "Office",
    "Accessibility": "Settings",
    "Audio": "Audio and Video",
    "AudioVideo": "Audio and Video",
    "AudioVideoEditing": "Audio and Video",
    "ActionGame": "Games",
    "Building": "Development",
    "Calendar": "Office",
    "Chat": "Communication",
    "Database": "Office",
    "DesktopSettings": "Settings",
    "Emulator": "Games",
    "FlowChart": "Office",
    "Game": "Games",
    "HardwareSettings": "Settings",
    "IDE": "Development",
    "InstantMessaging": "Communication",
    "Math": "Education",
    "Midi": "Audio and Video",
    "Mixer": "Audio and Video",
    "Player": "Audio and Video",
    "Presentation": "Office",
    "Recorder": "Audio and Video",
    "Science": "Education",
    "Spreadsheet": "Office",
    "Telephony": "Communication",
    "Terminal": "Utilities",
    "TerminalEmulator": "Utilities",
    "TV": "Audio and Video",
    "Utility": "Utilities",
    "VectorGraphics": "Office",
    "Video": "Audio and Video",
    "WebDevelopment": "Development",
    "WordProcessor": "Office",
    "X-Alsa": "Audio and Video",
    "X-Fedora": "Utilities",
    "X-Jack": "Audio and Video",
    "X-LXDE-Settings": "Settings",
    "X-MATE-NetworkSettings": "Settings",
    "X-MATE-PersonalSettings": "Settings",
    "X-Red-Hat-Base": "Utilities",
    "X-SuSE-Core-Office": "Office",
    "X-XFCE": "XFCE",
    "X-XFCE-HardwareSettings": "Settings",
    "X-XFCE-PersonalSettings": "Settings",
    "X-XFCE-SettingsDialog": "Settings",
    "X-Xfce": "XFCE",
    "X-Xfce-Toplevel": "XFCE",
}

# First, gather all applications by category
categoryApps = defaultdict(dict)
subcategories = defaultdict(set)

for entry in desktopEntries:
    try:
        # Check if NoDisplay=true is set
        try:
            noDisplay = entry.getboolean('Desktop Entry', 'NoDisplay', fallback=False)
            if noDisplay:
                continue
        except:
            pass
            
        name = entry.get('Desktop Entry', 'Name')
        execCommand = entry.get('Desktop Entry', 'Exec')
        entryCategories = entry.get('Desktop Entry', 'Categories', fallback='').split(';')
        
        # For applications with categories
        mainCategory = None
        for category in entryCategories:
            if category:  # Skip empty strings
                mappedCategory = categoryMap.get(category, category)
                if mainCategory is None:
                    mainCategory = mappedCategory
                
                # Check if this might be a subcategory
                for other in entryCategories:
                    if other and other != category:
                        mappedOther = categoryMap.get(other, other)
                        if mappedCategory != mappedOther:
                            subcategories[mappedOther].add(mappedCategory)
        
        # If we found a category, add the application
        if mainCategory:
            categoryApps[mainCategory][name] = execCommand
        else:
            # If no category was found, add to "Other"
            categoryApps["Other"][name] = execCommand
            
    except (configparser.NoOptionError, KeyError):
        continue

# Ensure we have an "All Applications" category that contains everything
allApps = {}
for category, apps in categoryApps.items():
    allApps.update(apps)
categoryApps["All Applications"] = allApps

class I38_Tab_Menu(Gtk.Window):
    def __init__(self):
        super().__init__(title="I38 Menu")
        self.set_default_size(500, 400)
        self.set_border_width(10)
        
        # Main container
        self.mainBox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
        self.add(self.mainBox)
        
        # Add search box at the top
        self.searchBox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
        
        # Create completion for the search entry
        self.completionStore = Gtk.ListStore(str, str)  # Name, Exec
        self.completion = Gtk.EntryCompletion()
        self.completion.set_model(self.completionStore)
        self.completion.set_text_column(0)
        self.completion.set_minimum_key_length(1)
        self.completion.set_popup_completion(True)
        self.completion.set_inline_completion(False)
        self.completion.connect("match-selected", self.on_completion_match)
        
        self.searchEntry = Gtk.Entry()
        self.searchEntry.set_completion(self.completion)
        self.searchEntry.set_placeholder_text("Search applications...")
        self.searchEntry.connect("changed", self.on_search_changed)
        self.searchEntry.connect("key-press-event", self.on_search_key_press)
        self.searchBox.pack_start(self.searchEntry, True, True, 0)
        
        # Add search button for visual users
        self.searchButton = Gtk.Button.new_from_icon_name("search", Gtk.IconSize.BUTTON)
        self.searchButton.connect("clicked", self.on_search_activated)
        self.searchBox.pack_start(self.searchButton, False, False, 0)
        
        self.mainBox.pack_start(self.searchBox, False, False, 0)
        
        # Create notebook (tabbed interface)
        self.notebook = Gtk.Notebook()
        self.notebook.set_tab_pos(Gtk.PositionType.TOP)
        self.mainBox.pack_start(self.notebook, True, True, 0)
        
        # Flag for search mode
        self.inSearchMode = False
        
        # For incremental letter navigation
        self.typedText = ""
        self.typedTextTimer = None
        
        # Add a tab for each major category
        self.treeViews = {}  # Store TreeViews for each tab
        self.stores = {}     # Store models for each tab
        
        # Sort categories alphabetically, but ensure "All Applications" is first
        sortedCategories = sorted(categoryApps.keys())
        if "All Applications" in sortedCategories:
            sortedCategories.remove("All Applications")
            sortedCategories.insert(0, "All Applications")
        
        # Create tabs
        for category in sortedCategories:
            if not categoryApps[category]:  # Skip empty categories
                continue
                
            # Create a TreeStore for this category
            store = Gtk.TreeStore(str, str)  # Columns: Name, Exec
            self.stores[category] = store
            
            # Add applications to this category's store
            sortedApps = sorted(categoryApps[category].items())
            
            # Check for potential subcategories within this category
            categorySubcategories = {}
            for appName, appExec in sortedApps:
                subcatFound = False
                for subcat in subcategories.get(category, []):
                    if appName in categoryApps.get(subcat, {}):
                        if subcat not in categorySubcategories:
                            categorySubcategories[subcat] = []
                        categorySubcategories[subcat].append((appName, appExec))
                        subcatFound = True
                        break
                
                if not subcatFound:
                    # Add directly to the category's root
                    store.append(None, [appName, appExec])
            
            # Add any subcategories
            for subcat, subcatApps in sorted(categorySubcategories.items()):
                subcatIter = store.append(None, [subcat, None])
                for appName, appExec in sorted(subcatApps):
                    store.append(subcatIter, [appName, appExec])
            
            # Create TreeView for this category
            treeView = Gtk.TreeView(model=store)
            treeView.set_headers_visible(False)
            self.treeViews[category] = treeView
            
            # Add column for application names
            renderer = Gtk.CellRendererText()
            column = Gtk.TreeViewColumn("Applications", renderer, text=0)
            treeView.append_column(column)
            
            # Set up scrolled window
            scrolledWindow = Gtk.ScrolledWindow()
            scrolledWindow.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
            scrolledWindow.add(treeView)
            
            # Connect signals
            treeView.connect("row-activated", self.on_row_activated)
            treeView.connect("key-press-event", self.on_key_press)
            
            # Create tab label
            tabLabel = Gtk.Label(label=category)
            
            # Add the tab
            self.notebook.append_page(scrolledWindow, tabLabel)
            
            # Set tab accessibility properties for screen readers
            tabChild = self.notebook.get_nth_page(self.notebook.get_n_pages() - 1)
            # Get the accessible object and set properties on it instead
            accessible = tabChild.get_accessible()
            accessible.set_name(f"{category} applications")
            # Use Atk role instead of Gtk.AccessibleRole which isn't available in GTK 3.0
            accessible.set_role(Atk.Role.LIST)
        
        # Connect notebook signals
        self.notebook.connect("switch-page", self.on_switch_page)
        
        # Connect window signals
        self.connect("key-press-event", self.on_window_key_press)
        self.connect("focus-out-event", self.on_focus_out)
        
        # Add all applications to the completion store
        self.populate_completion_store()
        
        # Set accessibility properties
        windowAccessible = self.get_accessible()
        windowAccessible.set_name("I38 Application Menu")
        windowAccessible.set_description("Tab-based application launcher menu. Press slash to search, type app name and use down arrow to navigate results. Type letters to incrementally navigate to matching applications.")
        
    def populate_completion_store(self):
        """Populate completion store with all available applications"""
        self.completionStore.clear()
        
        # Add all applications from all categories
        for tabName, appDict in categoryApps.items():
            for appName, execCommand in sorted(appDict.items()):
                self.completionStore.append([appName, execCommand])
                
    def on_switch_page(self, notebook, page, pageNum):
        # Focus the TreeView in the newly selected tab
        tab = notebook.get_nth_page(pageNum)
        for child in tab.get_children():
            if isinstance(child, Gtk.TreeView):
                child.grab_focus()
                break
    
    def on_row_activated(self, treeView, path, column):
        model = treeView.get_model()
        treeIter = model.get_iter(path)
        execCommand = model.get_value(treeIter, 1)
        
        if execCommand:
            # Launch the application
            cmdParts = execCommand.split()
            # Remove field codes like %f, %F, %u, %U
            cleanCmd = [p for p in cmdParts if not (p.startswith('%') and len(p) == 2)]
            cleanCmd = ' '.join(cleanCmd)
            
            # Use GLib.spawn_command_line_async for better process handling
            try:
                GLib.spawn_command_line_async(cleanCmd)
                Gtk.main_quit()
            except GLib.Error as e:
                dialog = Gtk.MessageDialog(
                    transient_for=self,
                    flags=0,
                    message_type=Gtk.MessageType.ERROR,
                    buttons=Gtk.ButtonsType.OK,
                    text=f"Failed to launch application: {e.message}"
                )
                dialog.run()
                dialog.destroy()
    
    def on_completion_match(self, completion, model, iterator):
        """Handle when a completion item is selected"""
        appName = model[iterator][0]
        execCommand = model[iterator][1]
        
        if execCommand:
            # Launch the application
            cmdParts = execCommand.split()
            # Remove field codes like %f, %F, %u, %U
            cleanCmd = [p for p in cmdParts if not (p.startswith('%') and len(p) == 2)]
            cleanCmd = ' '.join(cleanCmd)
            
            # Use GLib.spawn_command_line_async for better process handling
            try:
                GLib.spawn_command_line_async(cleanCmd)
                Gtk.main_quit()
            except GLib.Error as e:
                dialog = Gtk.MessageDialog(
                    transient_for=self,
                    flags=0,
                    message_type=Gtk.MessageType.ERROR,
                    buttons=Gtk.ButtonsType.OK,
                    text=f"Failed to launch application: {e.message}"
                )
                dialog.run()
                dialog.destroy()
                
        return True
    
    def on_search_changed(self, entry):
        """Handle search text changes"""
        searchText = entry.get_text().lower()
        self.inSearchMode = bool(searchText)
        
        # If entering search mode, make sure "All Applications" tab is active
        if self.inSearchMode and not self.notebook.get_current_page() == 0:
            self.notebook.set_current_page(0)
        
        currentTab = self.notebook.get_current_page()
        tabName = list(self.treeViews.keys())[currentTab]
        treeView = self.treeViews[tabName]
        store = self.stores[tabName]
        
        self.filter_applications(store, searchText)
        
        # Update the completion model with filtered results
        self.update_completion_results(searchText)
        
        # Set accessibility announcement
        if self.inSearchMode:
            treeView.get_accessible().set_name(f"Search results for {searchText}")
        else:
            treeView.get_accessible().set_name(f"{tabName} applications")
    
    def update_completion_results(self, searchText):
        """Update completion dropdown with matching applications"""
        if not searchText:
            # If empty, restore all applications
            self.populate_completion_store()
            return
            
        # Clear and repopulate with filtered results
        self.completionStore.clear()
        
        for tabName, appDict in categoryApps.items():
            for appName, execCommand in sorted(appDict.items()):
                if searchText.lower() in appName.lower():
                    self.completionStore.append([appName, execCommand])
    
    def filter_applications(self, store, searchText):
        """Show/hide applications based on search text"""
        def filterRow(model, iterator):
            if not self.inSearchMode:
                return True
                
            appName = model.get_value(iterator, 0).lower()
            
            # Always show categories (rows with no exec command)
            execCommand = model.get_value(iterator, 1)
            if execCommand is None:
                # Check if any children match
                childIter = model.iter_children(iterator)
                while childIter:
                    childName = model.get_value(childIter, 0).lower()
                    if searchText in childName:
                        return True
                    childIter = model.iter_next(childIter)
                return False
                
            # Show app if it matches search
            return searchText in appName
        
        for tabName, treeStore in self.stores.items():
            # Replace store with filtered version
            filterStore = treeStore.filter_new()
            filterStore.set_visible_func(filterRow)
            
            treeView = self.treeViews[tabName]
            treeView.set_model(filterStore)
            
            # Expand all categories in search mode
            if self.inSearchMode:
                treeView.expand_all()
            else:
                treeView.collapse_all()
    
    def on_search_key_press(self, entry, event):
        """Handle keyboard input in search box"""
        keyval = event.keyval
        
        if keyval == Gdk.KEY_Down:
            # Check if we can navigate to completion results
            if self.completion.get_popup_completion() and self.inSearchMode:
                # Try to activate completion popup and navigate to it
                self.completion.complete()
                return True
                
            # If not in completion mode, move focus to the treeview
            currentTab = self.notebook.get_current_page()
            tabName = list(self.treeViews.keys())[currentTab]
            treeView = self.treeViews[tabName]
            treeView.grab_focus()
            
            # Set cursor to first item
            model = treeView.get_model()
            iterator = model.get_iter_first()
            if iterator:
                path = model.get_path(iterator)
                treeView.set_cursor(path)
            return True
            
        elif keyval == Gdk.KEY_Escape:
            if self.searchEntry.get_text():
                # Clear search if there's text
                self.searchEntry.set_text("")
                return True
            else:
                # Otherwise close the app
                Gtk.main_quit()
                return True
                
        # Other keys pass through
        return False
    
    def on_search_activated(self, widget):
        """Process search when search button is clicked"""
        searchText = self.searchEntry.get_text()
        if searchText:
            # Focus on the first result if any
            currentTab = self.notebook.get_current_page()
            tabName = list(self.treeViews.keys())[currentTab]
            treeView = self.treeViews[tabName]
            model = treeView.get_model()
            
            # Find first visible row
            iterator = model.get_iter_first()
            if iterator:
                path = model.get_path(iterator)
                treeView.set_cursor(path)
                treeView.grab_focus()
    
    def on_key_press(self, widget, event):
        keyval = event.keyval
        
        if keyval == Gdk.KEY_Escape:
            # Reset incremental typing and close app if no ongoing search
            if self.typedText:
                self.typedText = ""
                # Announce reset
                current_tab = self.notebook.get_current_page()
                tab_name = list(self.treeViews.keys())[current_tab]
                treeView = self.treeViews[tab_name]
                treeView.get_accessible().set_name(f"Type reset. {tab_name} applications")
                return True
            else:
                Gtk.main_quit()
                return True
            
        elif keyval == Gdk.KEY_slash:
            # Forward slash activates search
            self.searchEntry.grab_focus()
            return True
            
        elif keyval == Gdk.KEY_Left:
            # If in a TreeView and Left key is pressed
            path = widget.get_cursor()[0]
            if path:
                model = widget.get_model()
                treeIter = model.get_iter(path)
                
                if widget.row_expanded(path):
                    # Collapse the current row if it's expanded
                    widget.collapse_row(path)
                    return True
                else:
                    # Move to parent if possible
                    parentIter = model.iter_parent(treeIter)
                    if parentIter:
                        parentPath = model.get_path(parentIter)
                        widget.set_cursor(parentPath)
                        return True
            
            # If we couldn't handle it in the TreeView, try to switch tabs
            currentPage = self.notebook.get_current_page()
            if currentPage > 0 and not self.inSearchMode:
                self.notebook.set_current_page(currentPage - 1)
                return True
                
        elif keyval == Gdk.KEY_Right:
            # If in a TreeView and Right key is pressed
            path = widget.get_cursor()[0]
            if path:
                model = widget.get_model()
                treeIter = model.get_iter(path)
                
                # Check if this row has children
                if model.iter_has_child(treeIter):
                    if not widget.row_expanded(path):
                        widget.expand_row(path, False)
                        # Move to the first child
                        childPath = model.get_path(model.iter_children(treeIter))
                        widget.set_cursor(childPath)
                    return True
            
            # If we couldn't handle it in the TreeView, try to switch tabs
            currentPage = self.notebook.get_current_page()
            if currentPage < self.notebook.get_n_pages() - 1 and not self.inSearchMode:
                self.notebook.set_current_page(currentPage + 1)
                return True
                
        elif keyval == Gdk.KEY_Tab:
            # Control tab navigation
            currentPage = self.notebook.get_current_page()
            if event.state & Gdk.ModifierType.SHIFT_MASK:
                # Shift+Tab -> previous tab
                if currentPage > 0 and not self.inSearchMode:
                    self.notebook.set_current_page(currentPage - 1)
                else:
                    self.notebook.set_current_page(self.notebook.get_n_pages() - 1)
            else:
                # Tab -> next tab
                if currentPage < self.notebook.get_n_pages() - 1 and not self.inSearchMode:
                    self.notebook.set_current_page(currentPage + 1)
                else:
                    self.notebook.set_current_page(0)
            return True
            
        # Incremental letter navigation
        elif Gdk.KEY_a <= keyval <= Gdk.KEY_z or Gdk.KEY_A <= keyval <= Gdk.KEY_Z:
            # Cancel any pending timer
            if self.typedTextTimer:
                GLib.source_remove(self.typedTextTimer)
                self.typedTextTimer = None
            
            # Add the new letter to the typed text
            letter = chr(keyval).lower()
            self.typedText += letter
            
            # Find item matching typed text
            found = self.find_incremental_match(widget, self.typedText)
            
            # Set timer to reset typed text after 1.5 seconds of inactivity
            self.typedTextTimer = GLib.timeout_add(1500, self.reset_typed_text)
            
            # Announce the letters being typed
            current_tab = self.notebook.get_current_page()
            tab_name = list(self.treeViews.keys())[current_tab]
            treeView = self.treeViews[tab_name]
            treeView.get_accessible().set_name(f"Typed: {self.typedText}")
            
            return True if found else False
            
        return False
    
    def reset_typed_text(self):
        """Reset the typed text after timeout"""
        self.typedText = ""
        self.typedTextTimer = None
        return False  # Don't call again
        
    def find_incremental_match(self, treeView, text):
        """Find items matching the incrementally typed text"""
        if not text:
            return False
            
        model = treeView.get_model()
        found = False
        
        def search_tree(model, path, treeIter, user_data):
            nonlocal found
            if found:
                return True  # Stop iteration if already found
                
            name = model.get_value(treeIter, 0)
            if name and name.lower().startswith(text.lower()):
                # Found a match
                treeView.set_cursor(path)
                treeView.scroll_to_cell(path, None, True, 0.5, 0.5)
                found = True
                return True  # Stop iteration
            return False  # Continue iteration
            
        # Search the entire model
        model.foreach(search_tree, None)
        return found
    
    def on_window_key_press(self, widget, event):
        # Handle window-level key events
        keyval = event.keyval
        
        if keyval == Gdk.KEY_Escape:
            Gtk.main_quit()
            return True
            
        elif keyval == Gdk.KEY_slash:
            # Forward slash activates search
            self.searchEntry.grab_focus()
            return True
            
        return False
    
    def on_focus_out(self, widget, event):
        # Quit when the window loses focus
        Gtk.main_quit()

window = I38_Tab_Menu()
window.connect("destroy", Gtk.main_quit)
window.show_all()

# Focus the first TreeView
firstTab = window.notebook.get_nth_page(0)
for child in firstTab.get_children():
    if isinstance(child, Gtk.TreeView):
        child.grab_focus()
        break

Gtk.main()
