MaterialTrie.java

package com.university.bookstore.search;

import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

import com.university.bookstore.model.Material;

/**
 * Trie (Prefix Tree) data structure for efficient prefix-based material searching.
 * Provides O(m) time complexity for prefix searches where m is the length of the prefix.
 * 
 * <p>This implementation stores materials at each node along the path, allowing for
 * efficient prefix-based lookups and autocomplete functionality.</p>
 * 
 * @author Navid Mohaghegh
 * @version 3.0
 * @since 2024-09-15
 */
public class MaterialTrie {
    
    private final TrieNode root;
    
    /**
     * Creates a new empty material trie.
     */
    public MaterialTrie() {
        this.root = new TrieNode();
    }
    
    /**
     * Inserts a material into the trie using its title as the key.
     * 
     * @param material the material to insert
     * @throws IllegalArgumentException if material is null
     */
    public void insert(Material material) {
        if (material == null) {
            throw new IllegalArgumentException("Material cannot be null");
        }
        
        String title = material.getTitle().toLowerCase();
        TrieNode current = root;
        
        // Add material to root for empty prefix searches
        current.materials.add(material);
        
        // Traverse the trie, adding material to each node along the path
        for (char c : title.toCharArray()) {
            current.children.putIfAbsent(c, new TrieNode());
            current = current.children.get(c);
            current.materials.add(material);
        }
        
        current.isEndOfWord = true;
    }
    
    /**
     * Searches for materials with titles that start with the given prefix.
     * 
     * @param prefix the prefix to search for
     * @return list of materials matching the prefix
     */
    public List<Material> searchByPrefix(String prefix) {
        if (prefix == null || prefix.trim().isEmpty()) {
            return new ArrayList<>();
        }
        
        String lowerPrefix = prefix.toLowerCase().trim();
        TrieNode current = root;
        
        // Navigate to the prefix node
        for (char c : lowerPrefix.toCharArray()) {
            if (!current.children.containsKey(c)) {
                return Collections.emptyList();
            }
            current = current.children.get(c);
        }
        
        return new ArrayList<>(current.materials);
    }
    
    /**
     * Searches for materials with titles that start with the given prefix,
     * limited to a maximum number of results.
     * 
     * @param prefix the prefix to search for
     * @param limit the maximum number of results to return
     * @return list of materials matching the prefix, limited to the specified count
     */
    public List<Material> searchByPrefixWithLimit(String prefix, int limit) {
        if (limit <= 0) {
            return new ArrayList<>();
        }
        List<Material> results = searchByPrefix(prefix);
        return results.stream()
            .limit(limit)
            .collect(Collectors.toList());
    }
    
    /**
     * Checks if any materials exist with titles starting with the given prefix.
     * 
     * @param prefix the prefix to check
     * @return true if materials exist with the prefix
     */
    public boolean hasPrefix(String prefix) {
        if (prefix == null || prefix.trim().isEmpty()) {
            return false;
        }
        
        String lowerPrefix = prefix.toLowerCase().trim();
        TrieNode current = root;
        
        for (char c : lowerPrefix.toCharArray()) {
            if (!current.children.containsKey(c)) {
                return false;
            }
            current = current.children.get(c);
        }
        
        return !current.materials.isEmpty();
    }
    
    /**
     * Gets all materials in the trie.
     * 
     * @return list of all materials
     */
    public List<Material> getAllMaterials() {
        return new ArrayList<>(root.materials);
    }
    
    /**
     * Removes a material from the trie.
     * 
     * @param material the material to remove
     * @return true if the material was removed, false if not found
     */
    public boolean remove(Material material) {
        if (material == null) {
            return false;
        }
        
        String title = material.getTitle().toLowerCase();
        List<TrieNode> path = new ArrayList<>();
        TrieNode current = root;
        
        // Build path to the material
        path.add(current);
        for (char c : title.toCharArray()) {
            if (!current.children.containsKey(c)) {
                return false; // Material not found
            }
            current = current.children.get(c);
            path.add(current);
        }
        
        // Remove material from all nodes in the path
        boolean removed = false;
        for (TrieNode node : path) {
            if (node.materials.remove(material)) {
                removed = true;
            }
        }
        
        // Clean up empty nodes (optional optimization)
        cleanupEmptyNodes(path);
        
        return removed;
    }
    
    /**
     * Clears all materials from the trie.
     */
    public void clear() {
        root.children.clear();
        root.materials.clear();
        root.isEndOfWord = false;
    }
    
    /**
     * Gets the total number of materials in the trie.
     * 
     * @return the number of materials
     */
    public int size() {
        return root.materials.size();
    }
    
    /**
     * Checks if the trie is empty.
     * 
     * @return true if no materials are stored
     */
    public boolean isEmpty() {
        return root.materials.isEmpty();
    }
    
    /**
     * Internal method to clean up empty nodes after removal.
     * 
     * @param path the path of nodes to potentially clean up
     */
    private void cleanupEmptyNodes(List<TrieNode> path) {
        // Remove empty leaf nodes (optional optimization)
        for (int i = path.size() - 1; i > 0; i--) {
            TrieNode node = path.get(i);
            if (node.materials.isEmpty() && node.children.isEmpty()) {
                TrieNode parent = path.get(i - 1);
                // Find and remove the empty child
                parent.children.entrySet().removeIf(entry -> entry.getValue() == node);
            }
        }
    }
    
    /**
     * Internal node class for the trie structure.
     */
    private static class TrieNode {
        final Map<Character, TrieNode> children;
        final List<Material> materials;
        boolean isEndOfWord;
        
        TrieNode() {
            this.children = new HashMap<>();
            this.materials = new ArrayList<>();
            this.isEndOfWord = false;
        }
    }
    
    @Override
    public String toString() {
        return String.format("MaterialTrie[Size=%d, Prefixes=%d]",
            size(),
            countPrefixes(root));
    }
    
    /**
     * Counts the total number of prefixes in the trie.
     * 
     * @param node the node to count from
     * @return the number of prefixes
     */
    private int countPrefixes(TrieNode node) {
        int count = node.isEndOfWord ? 1 : 0;
        for (TrieNode child : node.children.values()) {
            count += countPrefixes(child);
        }
        return count;
    }
}