"""
Fractal Search Engine — Multi-scale document indexing with φ-weighted decay.

Ported from improved-fractal-search.js with enhancements:
- Python-native with type hints
- File-system aware (can index directories)
- Streaming results
- Explanation system for why each result matched
"""

import math
import re
from collections import defaultdict
from dataclasses import dataclass, field
from typing import Optional

PHI = 1.618033988749895
PHI_INV = 1 / PHI  # 0.618...
FIBONACCI = [1, 1, 2, 3, 5, 8, 13, 21, 34, 55]

# Common stop words to skip during indexing
STOP_WORDS = {
    'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for',
    'of', 'with', 'by', 'from', 'is', 'are', 'was', 'were', 'be', 'been',
    'being', 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would',
    'could', 'should', 'may', 'might', 'shall', 'can', 'need', 'dare',
    'this', 'that', 'these', 'those', 'it', 'its', 'not', 'no', 'nor',
    'as', 'if', 'then', 'than', 'too', 'very', 'just', 'about', 'above',
    'after', 'again', 'all', 'also', 'any', 'because', 'before', 'below',
    'between', 'both', 'each', 'few', 'more', 'most', 'other', 'some',
    'such', 'only', 'own', 'same', 'so', 'what', 'which', 'who', 'whom',
    'how', 'when', 'where', 'why', 'up', 'out', 'off', 'over', 'under',
    'into', 'through', 'during', 'here', 'there', 'once', 'while',
}


@dataclass
class SearchResult:
    """A single search result with score, metadata, and explanation."""
    doc_id: str
    title: str
    score: float
    matching_terms: list = field(default_factory=list)
    patterns: list = field(default_factory=list)
    depth: int = 0
    explanation: dict = field(default_factory=dict)
    snippet: str = ""

    def __repr__(self):
        return f"SearchResult(score={self.score:.2f}, title='{self.title[:50]}', terms={self.matching_terms})"


class FractalSearchEngine:
    """
    Multi-scale document search engine using fractal indexing.

    Documents are fingerprinted at three scales:
    - Global (whole document)
    - Section (split by headings or ~1/3 chunks)
    - Paragraph (split by double newlines)

    Search uses φ-weighted decay: deeper matches score 1/φ of direct matches.
    Co-occurring terms create sub-indices (fractal branching).
    """

    def __init__(
        self,
        depth_weight: float = PHI_INV,
        max_depth: int = 4,
        similarity_threshold: float = 0.3,
        min_term_length: int = 3,
    ):
        self.depth_weight = depth_weight
        self.max_depth = max_depth
        self.similarity_threshold = similarity_threshold
        self.min_term_length = min_term_length

        self.documents: dict = {}          # id → {title, content}
        self.fingerprints: dict = {}       # id → {global, sections, paragraphs}
        self.index: dict = defaultdict(lambda: {"docs": [], "sub": defaultdict(list)})
        self.related_cache: dict = {}
        self.doc_count: int = 0

    def add(self, doc_id: str, title: str, content: str) -> 'FractalSearchEngine':
        """Add a document to the index."""
        self.documents[doc_id] = {"title": title, "content": content}
        self.fingerprints[doc_id] = self._multi_scale_fingerprint(content)
        self._index_document(doc_id)
        self.doc_count += 1
        return self

    def add_file(self, filepath: str, doc_id: Optional[str] = None) -> 'FractalSearchEngine':
        """Add a file to the index. Reads content automatically."""
        import os
        if doc_id is None:
            doc_id = filepath
        title = os.path.basename(filepath).rsplit('.', 1)[0].replace('_', ' ')
        try:
            with open(filepath, 'r', errors='ignore') as f:
                content = f.read(50000)  # Limit to 50KB per file
        except (PermissionError, OSError):
            return self
        if len(content) < 20:
            return self
        return self.add(doc_id, title, content)

    def add_directory(self, dirpath: str, extensions: tuple = ('.md', '.txt', '.py', '.sh', '.html', '.js', '.jsx', '.json')) -> int:
        """Add all matching files from a directory. Returns count added."""
        import os
        count = 0
        for root, dirs, files in os.walk(dirpath):
            dirs[:] = [d for d in dirs if d not in {'.git', 'node_modules', '__pycache__', '.venv', 'target'}]
            depth = root.replace(dirpath, '').count(os.sep)
            if depth > 5:
                dirs.clear()
                continue
            for fname in files:
                if any(fname.endswith(ext) for ext in extensions):
                    filepath = os.path.join(root, fname)
                    if os.path.islink(filepath):
                        continue
                    self.add_file(filepath)
                    count += 1
        return count

    def search(self, query: str, max_results: int = 10, depth: Optional[int] = None) -> list:
        """
        Search indexed documents. Returns list of SearchResult sorted by score.

        Args:
            query: Search query string
            max_results: Maximum results to return
            depth: Search depth (None = auto-calculate based on query complexity)
        """
        if depth is None:
            depth = self._calculate_depth(query)

        query_terms = self._tokenize(query)
        if not query_terms:
            return []

        scores: dict = defaultdict(lambda: {
            "score": 0.0,
            "terms": [],
            "patterns": [],
            "depth": 0,
        })

        # Layer 1: Direct term matches
        for term in query_terms:
            if term in self.index:
                for entry in self.index[term]["docs"]:
                    doc_id = entry["id"]
                    weight = entry["weight"]
                    scores[doc_id]["score"] += weight * PHI_INV
                    if term not in scores[doc_id]["terms"]:
                        scores[doc_id]["terms"].append(term)
                    scores[doc_id]["depth"] = max(scores[doc_id]["depth"], 1)

        # Layer 2: Sub-index matches (co-occurring terms at section level)
        if depth >= 2:
            for term in query_terms:
                if term in self.index:
                    for other_term in query_terms:
                        if other_term != term and other_term in self.index[term]["sub"]:
                            for doc_id in self.index[term]["sub"][other_term]:
                                boost = PHI_INV ** 2  # φ^-2 ≈ 0.382
                                scores[doc_id]["score"] += boost
                                scores[doc_id]["depth"] = max(scores[doc_id]["depth"], 2)
                                scores[doc_id]["patterns"].append({
                                    "type": "co-occurrence",
                                    "terms": [term, other_term],
                                    "boost": boost,
                                })

        # Layer 3: Self-similarity patterns
        if depth >= 3:
            query_fp = self._create_fingerprint(' '.join(query_terms))
            for doc_id, fps in self.fingerprints.items():
                # Check similarity at each scale
                for scale_name, scale_fps in [("sections", fps["sections"]), ("paragraphs", fps["paragraphs"])]:
                    for i, sfp in enumerate(scale_fps):
                        sim = self._cosine_similarity(query_fp, sfp)
                        if sim > self.similarity_threshold:
                            decay = PHI_INV ** 3  # φ^-3 ≈ 0.236
                            boost = sim * decay * 10
                            scores[doc_id]["score"] += boost
                            scores[doc_id]["depth"] = max(scores[doc_id]["depth"], 3)
                            scores[doc_id]["patterns"].append({
                                "type": "self-similarity",
                                "scale": scale_name,
                                "index": i,
                                "similarity": round(sim, 3),
                            })

        # Layer 4: Related terms expansion
        if depth >= 4:
            for term in query_terms:
                related = self._find_related_terms(term)
                for rel_term, rel_score in related:
                    if rel_term in self.index:
                        for entry in self.index[rel_term]["docs"]:
                            decay = PHI_INV ** 4  # φ^-4 ≈ 0.146
                            boost = entry["weight"] * rel_score * decay
                            scores[entry["id"]]["score"] += boost
                            scores[entry["id"]]["depth"] = max(scores[entry["id"]]["depth"], 4)

        # Build results
        results = []
        for doc_id, data in scores.items():
            if data["score"] <= 0:
                continue
            doc = self.documents.get(doc_id, {})
            content = doc.get("content", "")

            # Generate snippet — find first matching paragraph
            snippet = self._extract_snippet(content, query_terms)

            results.append(SearchResult(
                doc_id=doc_id,
                title=doc.get("title", doc_id),
                score=data["score"],
                matching_terms=data["terms"],
                patterns=data["patterns"],
                depth=data["depth"],
                snippet=snippet,
                explanation={
                    "query_terms": query_terms,
                    "search_depth": depth,
                    "pattern_count": len(data["patterns"]),
                },
            ))

        results.sort(key=lambda r: -r.score)
        return results[:max_results]

    # ─── Internal Methods ───

    def _tokenize(self, text: str) -> list:
        """Tokenize text into searchable terms."""
        words = re.sub(r'[^\w\s]', '', text.lower()).split()
        return [w for w in words if len(w) >= self.min_term_length and w not in STOP_WORDS]

    def _create_fingerprint(self, text: str) -> dict:
        """Create term frequency map for text."""
        terms = self._tokenize(text)
        freq = defaultdict(int)
        for t in terms:
            freq[t] += 1
        return dict(freq)

    def _multi_scale_fingerprint(self, content: str) -> dict:
        """Create fingerprints at document, section, and paragraph scales."""
        # Global
        global_fp = self._create_fingerprint(content)

        # Sections (split by headings or into thirds)
        heading_split = re.split(r'\n#{1,6}\s+[^\n]+\n', content)
        if len(heading_split) > 1:
            sections = heading_split
        else:
            n = len(content)
            third = n // 3
            sections = [content[:third], content[third:2*third], content[2*third:]]
        section_fps = [self._create_fingerprint(s) for s in sections if len(s.strip()) > 20]

        # Paragraphs
        paragraphs = [p for p in content.split('\n\n') if len(p.strip()) > 20]
        para_fps = [self._create_fingerprint(p) for p in paragraphs]

        return {"global": global_fp, "sections": section_fps, "paragraphs": para_fps}

    def _index_document(self, doc_id: str):
        """Index a document's terms and co-occurrences."""
        fps = self.fingerprints[doc_id]
        global_fp = fps["global"]

        for term, freq in global_fp.items():
            self.index[term]["docs"].append({"id": doc_id, "weight": freq})

            # Create sub-indices for frequent terms (fractal branching)
            if freq > 2:
                for section_fp in fps["sections"]:
                    if term in section_fp:
                        for co_term in section_fp:
                            if co_term != term:
                                self.index[term]["sub"][co_term].append(doc_id)

    def _cosine_similarity(self, fp1: dict, fp2: dict) -> float:
        """Cosine similarity between two fingerprints."""
        if not fp1 or not fp2:
            return 0.0
        common = set(fp1.keys()) & set(fp2.keys())
        if not common:
            return 0.0
        dot = sum(fp1[k] * fp2[k] for k in common)
        mag1 = math.sqrt(sum(v**2 for v in fp1.values()))
        mag2 = math.sqrt(sum(v**2 for v in fp2.values()))
        if mag1 == 0 or mag2 == 0:
            return 0.0
        return dot / (mag1 * mag2)

    def _find_related_terms(self, term: str, threshold: float = 0.3) -> list:
        """Find terms that co-occur with the given term."""
        cache_key = f"{term}:{threshold}"
        if cache_key in self.related_cache:
            return self.related_cache[cache_key]

        related = defaultdict(float)
        if term in self.index:
            for co_term, doc_ids in self.index[term]["sub"].items():
                score = len(doc_ids) / max(len(self.index[term]["docs"]), 1)
                if score >= threshold:
                    related[co_term] = score

        result = sorted(related.items(), key=lambda x: -x[1])[:10]
        self.related_cache[cache_key] = result
        return result

    def _calculate_depth(self, query: str) -> int:
        """Auto-calculate optimal search depth based on query complexity."""
        terms = self._tokenize(query)
        n = len(terms)

        base = 2
        # More terms → deeper search
        if n > 3:
            base += 1
        # Long/specific terms → deeper
        avg_len = sum(len(t) for t in terms) / max(n, 1)
        if avg_len > 8:
            base += 1

        # Fibonacci modulation
        fib_idx = min(n // 2, len(FIBONACCI) - 1)
        fib_adj = FIBONACCI[fib_idx] / 20

        depth = min(self.max_depth, base + fib_adj)
        return max(1, round(depth))

    def _extract_snippet(self, content: str, query_terms: list, max_len: int = 200) -> str:
        """Extract a relevant snippet from content."""
        paragraphs = content.split('\n\n')
        best_para = ""
        best_score = 0
        lower_terms = set(query_terms)

        for para in paragraphs:
            para_lower = para.lower()
            score = sum(1 for t in lower_terms if t in para_lower)
            if score > best_score:
                best_score = score
                best_para = para

        if best_para:
            return best_para[:max_len].strip()
        return content[:max_len].strip()

    def stats(self) -> dict:
        """Return index statistics."""
        return {
            "documents": self.doc_count,
            "terms": len(self.index),
            "phi": PHI,
            "max_depth": self.max_depth,
        }
