import bibtexparser
from bibtexparser.bparser import BibTexParser
from bibtexparser.bwriter import BibTexWriter
from bibtexparser.bibdatabase import BibDatabase
from rapidfuzz import fuzz, utils
import re
import copy
import difflib
import os
import shutil
from typing import List, Dict, Tuple, Set

class BibManager:
    def __init__(self, filepath: str):
        self.filepath = filepath
        self.db = None
        self.history = []  # List of list of dictionaries
        self.history_index = -1
        self.load()

    def load(self):
        with open(self.filepath, 'r', encoding='utf-8') as bibtex_file:
            parser = BibTexParser(common_strings=True)
            # DO NOT convert to lower case for keys/values
            parser.ignore_nonstandard_types = False
            self.db = bibtexparser.load(bibtex_file, parser=parser)
            self._save_state() # Save initial baseline state

    def save(self, filepath=None, create_backup=True):
        if filepath is None:
            filepath = self.filepath
            
        if create_backup and os.path.exists(filepath):
            backup_path = f"{filepath}.BKP"
            shutil.copyfile(filepath, backup_path)
            
        writer = BibTexWriter()
        writer.indent = '  '
        writer.order_entries_by = None # preserve order
        with open(filepath, 'w', encoding='utf-8') as bibtex_file:
            bibtexparser.dump(self.db, bibtex_file, writer)
            
    def get_entries(self):
        return self.db.entries

    def find_duplicates(self, threshold=85.0) -> Tuple[List[List[Dict]], Dict[str, str]]:
        """
        Find duplicate entries using hard identifiers (DOI, URL, ID, exact title),
        falling back to fuzzy matching on normalized title and author.
        Returns a tuple: (list of duplicate groups, dictionary of reasons by ID).
        """
        duplicates_groups = []
        reasons = {} # Maps ID -> Explainability string
        visited_ids = set()
        
        entries = self.db.entries
        
        for i, entry1 in enumerate(entries):
            entry_id1 = entry1.get('ID')
            if entry_id1 in visited_ids:
                continue
                
            current_group = [entry1]
            
            # Normalize fields for base entry
            entry1_lower = {k.lower(): v for k, v in entry1.items()}
            title1 = utils.default_process(entry1_lower.get('title', ''))
            author1 = utils.default_process(entry1_lower.get('author', ''))
            doi1 = entry1_lower.get('doi', '').strip().lower()
            url1 = entry1_lower.get('url', '').strip().lower()
            
            for j in range(i + 1, len(entries)):
                entry2 = entries[j]
                entry_id2 = entry2.get('ID')
                if entry_id2 in visited_ids:
                    continue
                    
                entry2_lower = {k.lower(): v for k, v in entry2.items()}
                title2 = utils.default_process(entry2_lower.get('title', ''))
                author2 = utils.default_process(entry2_lower.get('author', ''))
                doi2 = entry2_lower.get('doi', '').strip().lower()
                url2 = entry2_lower.get('url', '').strip().lower()
                
                is_duplicate = False
                match_reason = ""

                # 1. Hard Matches (Fast & Certain)
                if entry_id1.lower() == entry_id2.lower():
                    is_duplicate, match_reason = True, "Same BibTeX ID"
                elif doi1 and doi2 and doi1 == doi2:
                    is_duplicate, match_reason = True, f"Same DOI ({doi1})"
                elif url1 and url2 and url1 == url2:
                    is_duplicate, match_reason = True, "Same URL"
                elif title1 and title2 and title1 == title2:
                    is_duplicate, match_reason = True, "Exact Title Match"
                    
                # 2. Fuzzy Match Fallback
                else:
                    title_sim = fuzz.ratio(title1, title2) if title1 and title2 else 0.0
                    author_sim = fuzz.token_sort_ratio(author1, author2) if author1 and author2 else 100.0
                    
                    if title_sim >= threshold and author_sim >= 70.0:
                        is_duplicate = True
                        match_reason = f"Fuzzy Match (Title: {title_sim:.0f}%, Author: {author_sim:.0f}%)"
                        
                if is_duplicate:
                    current_group.append(entry2)
                    visited_ids.add(entry_id2)
                    reasons[entry_id2] = match_reason
                    
            if len(current_group) > 1:
                duplicates_groups.append(current_group)
                visited_ids.add(entry_id1)
                reasons[entry_id1] = "Base Entry (Matched against this)"
                
        return duplicates_groups, reasons

    def find_incomplete(self) -> List[Dict]:
        """
        Check for missing critical fields and tag them with reasons.
        """
        incomplete_entries = []
        for entry in self.db.entries:
            missing = []
            
            # Create a case-insensitive dictionary for checks
            entry_lower = {k.lower(): v for k, v in entry.items()}
            
            entry_type = entry_lower.get('entrytype', '').lower()
            
            # 1. Check Global Required Fields
            if 'title' not in entry_lower or not str(entry_lower.get('title', '')).strip():
                missing.append('title')
            if ('year' not in entry_lower or not str(entry_lower.get('year', '')).strip()) and \
               ('date' not in entry_lower or not str(entry_lower.get('date', '')).strip()):
                missing.append('year/date')
            
            # 2. Check Author/Editor (Specific to common types)
            if entry_type in ['article', 'inproceedings', 'incollection', 'book']:
                if 'author' not in entry_lower and 'editor' not in entry_lower:
                    missing.append("author/editor")
            
            # 3. Check specific types (optional but helpful)
            if entry_type == 'article' and 'journal' not in entry_lower and 'journaltitle' not in entry_lower:
                missing.append("journal/journaltitle")
            elif entry_type in ['inproceedings', 'incollection'] and 'booktitle' not in entry_lower:
                missing.append("booktitle")

            if missing:
                # We create a copy to avoid mutating the original DB entry in memory 
                # if you only want the reason to show up in this specific view
                entry_copy = entry.copy()
                entry_copy['reason_incomplete'] = f"Missing: {', '.join(missing)}"
                incomplete_entries.append(entry_copy)
                
        return incomplete_entries

    def _save_state(self):
        """Save a deepcopy of the current entries to the history stack."""
        # Truncate future states if we are rewriting history
        if self.history_index < len(self.history) - 1:
            self.history = self.history[:self.history_index + 1]
            
        current_state = copy.deepcopy(self.db.entries)
        self.history.append(current_state)
        self.history_index += 1
        
    def undo(self) -> bool:
        """Revert to the previous state. Returns True if successful."""
        if self.history_index > 0:
            self.history_index -= 1
            self.db.entries = copy.deepcopy(self.history[self.history_index])
            return True
        return False
        
    def redo(self) -> bool:
        """Advance to the next state. Returns True if successful."""
        if self.history_index < len(self.history) - 1:
            self.history_index += 1
            self.db.entries = copy.deepcopy(self.history[self.history_index])
            return True
        return False
        
    def get_diff(self) -> str:
        """Generate a unified diff between the loaded baseline and the current state."""
        if not self.history:
            return "No changes."
            
        temp_db1 = copy.copy(self.db)
        temp_db1.entries = self.history[0] # Original state
        
        temp_db2 = copy.copy(self.db)
        temp_db2.entries = self.db.entries # Current state
        
        writer = BibTexWriter()
        writer.indent = '  '
        writer.order_entries_by = None
        
        str1 = bibtexparser.dumps(temp_db1, writer)
        str2 = bibtexparser.dumps(temp_db2, writer)
        
        lines1 = str1.splitlines(keepends=True)
        lines2 = str2.splitlines(keepends=True)
        
        diff = difflib.unified_diff(lines1, lines2, fromfile='Original', tofile='Modified')
        return "".join(diff)
        
    def get_summary(self) -> str:
        """Return a simple summary string of additions/modifications/deletions."""
        if not self.history:
            return "No changes from baseline."
            
        orig_ids = {e.get('ID') for e in self.history[0] if e.get('ID')}
        curr_ids = {e.get('ID') for e in self.db.entries if e.get('ID')}
        
        deleted = orig_ids - curr_ids
        added = curr_ids - orig_ids
        
        # Simple string-based modification check
        modified = set()
        for curr_id in (orig_ids & curr_ids):
            orig_e = next(e for e in self.history[0] if e.get('ID') == curr_id)
            curr_e = next(e for e in self.db.entries if e.get('ID') == curr_id)
            if orig_e != curr_e:
                modified.add(curr_id)
                
        lines = []
        if added: lines.append(f"Added {len(added)} entries.")
        if deleted: lines.append(f"Deleted {len(deleted)} entries.")
        if modified: lines.append(f"Modified {len(modified)} entries.")
        
        if not lines:
            return "No effective changes made."
            
        return "\n".join(lines)

    def delete_entry(self, entry_id: str):
        """Delete an entry by its ID"""
        self.db.entries = [e for e in self.db.entries if e.get('ID') != entry_id]
        self._save_state()
        
    def merge_entries(self, keep_id: str, delete_ids: List[str]):
        """
        Very simple merge: keep the entry with 'keep_id', delete the others.
        (Could be expanded to merge specific fields)
        """
        for d_id in delete_ids:
            self.db.entries = [e for e in self.db.entries if e.get('ID') != d_id]
        self._save_state()
            
    def update_entry(self, entry_id: str, new_data: dict):
        """Update an existing entry"""
        for i, e in enumerate(self.db.entries):
            if e.get('ID') == entry_id:
                # Update but preserve ENTRYTYPE and ID if they exist
                for k, v in new_data.items():
                    self.db.entries[i][k] = v
                break
        self._save_state()

    def auto_purge_duplicates(self) -> int:
        """
        Identify and remove entries that are 'Hard Matches' (ID, DOI, URL, or Title).
        Returns the number of entries removed.
        """
        # We reuse the logic from find_duplicates but only for hard matches
        visited_ids = set()
        to_delete = []
        entries = self.db.entries
        
        for i, entry1 in enumerate(entries):
            entry_id1 = entry1.get('ID')
            if entry_id1 in visited_ids or entry_id1 in to_delete:
                continue
                
            # Normalize fields for base entry
            e1_lower = {k.lower(): v for k, v in entry1.items()}
            title1 = utils.default_process(e1_lower.get('title', ''))
            doi1 = e1_lower.get('doi', '').strip().lower()
            url1 = e1_lower.get('url', '').strip().lower()
            
            for j in range(i + 1, len(entries)):
                entry2 = entries[j]
                entry_id2 = entry2.get('ID')
                if entry_id2 in visited_ids or entry_id2 in to_delete:
                    continue
                    
                e2_lower = {k.lower(): v for k, v in entry2.items()}
                title2 = utils.default_process(e2_lower.get('title', ''))
                doi2 = e2_lower.get('doi', '').strip().lower()
                url2 = e2_lower.get('url', '').strip().lower()
                
                is_exact = False
                if entry_id1.lower() == entry_id2.lower():
                    is_exact = True
                elif doi1 and doi2 and doi1 == doi2:
                    is_exact = True
                elif url1 and url2 and url1 == url2:
                    is_exact = True
                elif title1 and title2 and title1 == title2:
                    # For title matching, we also check if creators match to be safer
                    author1 = utils.default_process(e1_lower.get('author', ''))
                    author2 = utils.default_process(e2_lower.get('author', ''))
                    if author1 == author2 or not author1 or not author2:
                        is_exact = True
                
                if is_exact:
                    to_delete.append(entry_id2)
            
            visited_ids.add(entry_id1)
            
        count = len(to_delete)
        if count > 0:
            self.db.entries = [e for e in self.db.entries if e.get('ID') not in to_delete]
            self._save_state()
            
        return count
