import sqlite3
import re
import time
import fnmatch
from typing import List, Tuple, Optional, Any, Dict
from .models import SearchHit, SearchOptions
from .ranking import snippet_around, get_file_extension
from .scoring import ScoringPolicy
from .engine.tantivy_engine import TantivyEngine

from .db.storage import GlobalStorageManager

class SearchEngine:
    def __init__(self, db, scoring_policy: ScoringPolicy = None, tantivy_engine: Optional[TantivyEngine] = None):
        self.db = db
        self.scoring_policy = scoring_policy or ScoringPolicy()
        self.tantivy_engine = tantivy_engine
        self._snippet_cache: Dict[tuple, str] = {}
        self._snippet_lru: List[tuple] = []
        self.storage = GlobalStorageManager.get_instance(db)

    def search_l2_only(self, opts: SearchOptions) -> Tuple[List[SearchHit], Dict[str, Any]]:
        q = (opts.query or "").strip()
        if not q:
            return [], {"total": 0, "engine": "l2", "partial": True}
        from .workspace import WorkspaceManager
        root_id = WorkspaceManager.normalize_path(list(opts.root_ids)[0]) if opts.root_ids else None
        meta: Dict[str, Any] = {"engine": "l2", "partial": True, "db_health": "error", "coverage": "l2-only"}
        try:
            recent_rows = self.storage.get_recent_files(q, root_id=root_id, limit=opts.limit)
            hits = self._process_sqlite_rows(recent_rows, opts)
            for h in hits:
                h.hit_reason = "L2 Cache (Degraded)"
                h.score = 3.0
            return hits[:opts.limit], meta
        except Exception as e:
            meta["db_error"] = str(e)
            return [], meta

    def search_v2(self, opts: SearchOptions) -> Tuple[List[SearchHit], Dict[str, Any]]:
        """Enhanced search with Score Normalization and Merged Results."""
        if hasattr(self.db, "coordinator") and self.db.coordinator:
            self.db.coordinator.notify_search_start()

        try:
            q = (opts.query or "").strip()
            if not q: return [], {"total": 0}

            from .workspace import WorkspaceManager
            root_id = WorkspaceManager.normalize_path(list(opts.root_ids)[0]) if opts.root_ids else None
            meta: Dict[str, Any] = {"engine": "hybrid", "partial": False, "db_health": "ok", "db_error": ""}

            # 1. L2 Cache (Recent) - 최상위 우선순위
            try:
                recent_rows = self.storage.get_recent_files(q, root_id=root_id, limit=opts.limit)
                recent_hits = self._process_sqlite_rows(recent_rows, opts)
                for h in recent_hits:
                    h.hit_reason = "L2 Cache (Recent)"
                    h.score = 100.0 # 매우 높은 고정 점수
            except Exception as e:
                recent_hits = []
                meta["db_health"] = "error"
                meta["db_error"] = str(e)
                meta["partial"] = True
            
            seen_paths = {h.path for h in recent_hits}
            all_hits = recent_hits

            # 2. Tantivy (Primary DB Search)
            if self.tantivy_engine and not opts.use_regex:
                try:
                    hits = self.tantivy_engine.search(q, root_id=root_id, limit=opts.limit)
                    if hits:
                        t_hits = self._process_tantivy_hits(hits, opts)
                        # Tantivy 점수 정규화
                        max_t = max((h.score for h in t_hits), default=1.0)
                        for h in t_hits:
                            h.score = (h.score / max_t) * 10.0
                            if h.path not in seen_paths:
                                all_hits.append(h)
                                seen_paths.add(h.path)
                except Exception as te:
                    import logging
                    logging.getLogger("sari.search").debug("Tantivy search failed: %s", te)

            # 3. SQLite fallback through DB facade (engine boundary kept in db/repository layer)
            if (not all_hits or len(all_hits) < opts.limit) and hasattr(self.db, "search_sqlite_v2"):
                try:
                    sqlite_hits, sqlite_meta = self.db.search_sqlite_v2(opts)
                    if isinstance(sqlite_meta, dict):
                        if "total" in sqlite_meta:
                            meta["sqlite_total"] = sqlite_meta.get("total")
                        if "total_mode" in sqlite_meta:
                            meta["sqlite_total_mode"] = sqlite_meta.get("total_mode")
                    for sqlite_hit in sqlite_hits:
                        if opts.file_types:
                            allowed_types = {
                                str(file_type).lower().lstrip(".")
                                for file_type in (opts.file_types or [])
                            }
                            hit_type = get_file_extension(sqlite_hit.path).lower().lstrip(".")
                            if hit_type not in allowed_types:
                                continue
                        if opts.path_pattern:
                            rel_path = sqlite_hit.path.split("/", 1)[1] if "/" in sqlite_hit.path else sqlite_hit.path
                            if not fnmatch.fnmatch(sqlite_hit.path, opts.path_pattern) and not fnmatch.fnmatch(rel_path, opts.path_pattern):
                                continue
                        if sqlite_hit.path in seen_paths:
                            continue
                        all_hits.append(sqlite_hit)
                        seen_paths.add(sqlite_hit.path)
                except sqlite3.Error as e:
                    meta["db_health"] = "error"
                    meta["db_error"] = str(e)
                    meta["partial"] = True
                except Exception as e:
                    meta["db_health"] = "error"
                    meta["db_error"] = str(e)
                    meta["partial"] = True

            # Root 우선 가중치 (workspace root에 속한 결과를 앞쪽으로)
            if root_id:
                for h in all_hits:
                    if h.path and h.path.startswith(root_id + "/"):
                        h.score += 50.0

            # 스코프 매칭 근거 기록
            if root_id or opts.repo:
                for h in all_hits:
                    h.scope_reason = f"root_id={root_id or 'any'}; repo={opts.repo or 'any'}"

            # 최종 정렬: 점수 내림차순 -> 시간 내림차순
            all_hits.sort(key=lambda x: (-x.score, -x.mtime))
            return all_hits[:opts.limit], meta
        finally:
            if hasattr(self.db, "coordinator") and self.db.coordinator:
                self.db.coordinator.notify_search_end()

    def _process_sqlite_rows(self, rows: list, opts: SearchOptions) -> List[SearchHit]:
        import fnmatch
        hits: List[SearchHit] = []
        for r in rows:
            # Flexible Unpacking for different row shapes
            if len(r) >= 7:
                # FTS shape: (path, rel_path, root_id, repo, mtime, size, content)
                path, rel_path, root_id, repo, mtime, size, content = r[0], r[1], r[2], r[3], r[4], r[5], r[6]
            elif len(r) == 6:
                # Legacy shape: (path, root_id, repo, mtime, size, content)
                path, root_id, repo, mtime, size, content = r[0], r[1], r[2], r[3], r[4], r[5]
                rel_path = path # Fallback
            else:
                continue
            
            # Strict Filtering
            if opts.repo and repo != opts.repo: continue
            
            # File Type Filter
            if opts.file_types:
                ext = get_file_extension(path).lower().lstrip(".")
                allowed = [t.lower().lstrip(".") for t in opts.file_types]
                if ext not in allowed:
                    continue
            
            # Path Pattern Filter (Glob)
            if opts.path_pattern:
                pat = opts.path_pattern
                # Match against rel_path or path
                if not fnmatch.fnmatch(rel_path, pat) and not fnmatch.fnmatch(path, pat):
                    continue

            hits.append(SearchHit(
                repo=repo,
                path=path,
                score=1.0,
                snippet=self._snippet_for(path, opts.query, content),
                mtime=mtime,
                size=size,
                match_count=1,
                file_type=get_file_extension(path),
                hit_reason="SQLite Fallback",
            ))
        return hits

    def _process_tantivy_hits(self, hits: list, opts: SearchOptions) -> List[SearchHit]:
        import fnmatch
        results: List[SearchHit] = []
        for h in hits:
            path = h.get("path", "")
            repo = h.get("repo", "")
            rel_path = h.get("rel_path", path)
            
            # Strict Filtering
            if opts.repo and repo != opts.repo: continue

            # File Type Filter
            if opts.file_types:
                ext = get_file_extension(path).lower()
                if ext not in [t.lower().lstrip(".") for t in opts.file_types]:
                    continue
            
            # Path Pattern Filter
            if opts.path_pattern:
                if not fnmatch.fnmatch(rel_path, opts.path_pattern) and not fnmatch.fnmatch(path, opts.path_pattern):
                    continue

            # Double check with DB to filter out deleted files not yet purged from Tantivy
            is_deleted = False
            try:
                row = self.db._get_conn().execute("SELECT deleted_ts FROM files WHERE path=?", (path,)).fetchone()
                if row and row[0] > 0:
                    is_deleted = True
            except Exception:
                pass
            
            if is_deleted:
                continue

            snippet = f"Match in {path}"
            try:
                content = self.db.read_file(path)
                if content:
                    snippet = self._snippet_for(path, opts.query, content)
            except Exception:
                pass
            results.append(SearchHit(
                repo=h.get("repo", ""),
                path=path,
                score=h.get("score", 0.0),
                snippet=snippet,
                mtime=h.get("mtime", 0),
                size=h.get("size", 0),
                match_count=1,
                file_type=get_file_extension(path),
                hit_reason="Tantivy Search",
            ))
        return results

    def _fts_query(self, q: str) -> str:
        raw = (q or "").strip()
        if not raw:
            return raw
        # Normalize common OR separators
        raw = raw.replace("||", " OR ").replace("|", " OR ")
        tokens = re.findall(r'\(|\)|"[^"]+"|\bAND\b|\bOR\b|\bNOT\b|\bNEAR/\d+\b|\bNEAR\b|[0-9A-Za-z_\u00A1-\uFFFF]+', raw, flags=re.IGNORECASE)
        if not tokens:
            return raw.replace('"', " ")
        out = []
        for t in tokens:
            upper = t.upper()
            if upper in {"AND", "OR", "NOT"} or upper.startswith("NEAR"):
                out.append(upper)
                continue
            if t in {"(", ")"}:
                out.append(t)
                continue
            if t.startswith('"') and t.endswith('"'):
                t = t[1:-1]
            out.append(f'"{t}"')
        return " ".join(out)

    def _snippet_for(self, path: str, query: str, content: str) -> str:
        cache_key = (path, query)
        cached = self._snippet_cache.get(cache_key)
        if cached is not None:
            return cached
        max_bytes = 200_000
        try:
            if getattr(self.db, "settings", None):
                max_bytes = int(getattr(self.db.settings, "SNIPPET_MAX_BYTES", max_bytes))
        except Exception:
            pass
        if len(content) > max_bytes:
            content = content[:max_bytes]
        snippet = snippet_around(content, [query], 3, highlight=True)
        cache_size = 128
        try:
            if getattr(self.db, "settings", None):
                cache_size = int(getattr(self.db.settings, "SNIPPET_CACHE_SIZE", cache_size))
        except Exception:
            pass
        if cache_size > 0:
            self._snippet_cache[cache_key] = snippet
            self._snippet_lru.append(cache_key)
            while len(self._snippet_lru) > cache_size:
                old = self._snippet_lru.pop(0)
                self._snippet_cache.pop(old, None)
        return snippet

    def repo_candidates(self, q: str, limit: int = 3, root_ids: Optional[List[str]] = None) -> List[Dict[str, Any]]:
        q = (q or "").strip()
        if not q:
            return []
        if hasattr(self.db, "repo_candidates_sqlite"):
            rows = self.db.repo_candidates_sqlite(q, limit=limit, root_ids=root_ids)
            return [
                {
                    "repo": row.get("repo", ""),
                    "score": int(row.get("score", 0)),
                    "evidence": row.get("evidence", ""),
                }
                for row in rows
            ]
        return []
