"""
cortex/hippo/retrieval.py — Search and inspect compacted conversation history.

Three search modes: regex (LIKE fallback), full_text (FTS5 BM25), semantic (vector).
Hippo addition: semantic search via Voyage embeddings.
Hybrid search: BM25 + vector + recency via Reciprocal Rank Fusion (RRF).
Reranker: optional Voyage reranker post-retrieval for precision.

Issue: #806, #1188, #1189
"""

from __future__ import annotations

import asyncio
import logging
import math
import struct
import time
from dataclasses import dataclass, field
from datetime import datetime
from typing import TYPE_CHECKING, Literal, Optional, Protocol

if TYPE_CHECKING:
    from cortex.hippo.db.connection import HippoDatabase

from cortex.hippo.config import HippoConfig
from cortex.hippo.models import MessageRecord, SummaryRecord
from cortex.hippo.store.conversation_store import ConversationStore
from cortex.hippo.store.summary_store import SummaryStore

logger = logging.getLogger(__name__)


# ═══════════════════════════════════════════════════════════════════════════
# Protocols
# ═══════════════════════════════════════════════════════════════════════════


class EmbeddingProvider(Protocol):
    """Protocol for embedding providers (Voyage, etc.)."""

    async def embed(self, text: str) -> list[float]: ...


class RerankerProvider(Protocol):
    """Protocol for reranking search results post-retrieval.

    Issue: #1189
    """

    async def rerank(
        self, query: str, documents: list[str], top_k: int = 10
    ) -> list[tuple[int, float]]:
        """Return list of (original_index, relevance_score) sorted by relevance."""
        ...


# ═══════════════════════════════════════════════════════════════════════════
# Data types
# ═══════════════════════════════════════════════════════════════════════════


@dataclass
class GrepInput:
    pattern: str
    mode: Literal["regex", "full_text", "semantic"] = "regex"
    scope: Literal["messages", "summaries", "both"] = "both"
    conversation_id: Optional[int] = None
    all_conversations: bool = False
    owner_id: Optional[str] = None
    since: Optional[datetime] = None
    before: Optional[datetime] = None
    limit: int = 50


@dataclass
class GrepResult:
    messages: list[MessageRecord] = field(default_factory=list)
    summaries: list[SummaryRecord] = field(default_factory=list)
    total_matches: int = 0


@dataclass
class SemanticSearchResult:
    summary_id: str
    conversation_id: int
    score: float
    snippet: str
    created_at: str


@dataclass
class HybridSearchResult:
    """Result from hybrid search combining BM25 + vector + recency via RRF.

    Issue: #1188
    """

    summary_id: str
    score: float
    content: str
    conversation_id: int
    depth: int
    kind: str
    bm25_rank: Optional[int] = None
    vector_rank: Optional[int] = None
    recency_score: Optional[float] = None


@dataclass
class DescribeResult:
    id: str
    type: Literal["summary", "file"]
    summary: Optional[dict] = None
    file: Optional[dict] = None


@dataclass
class ExpandInput:
    summary_id: str
    depth: int = 1
    include_messages: bool = False
    token_cap: Optional[int] = None
    owner_id: Optional[str] = None


@dataclass
class ChildItem:
    summary_id: str
    kind: str
    content: str
    token_count: int


@dataclass
class MessageItem:
    message_id: int
    role: str
    content: str
    token_count: int


@dataclass
class ExpandResult:
    children: list[ChildItem] = field(default_factory=list)
    messages: list[MessageItem] = field(default_factory=list)
    estimated_tokens: int = 0
    truncated: bool = False


# ═══════════════════════════════════════════════════════════════════════════
# Vector utilities
# ═══════════════════════════════════════════════════════════════════════════


def pack_floats(v: list[float]) -> bytes:
    """Pack a float list into bytes (float32)."""
    return struct.pack(f"{len(v)}f", *v)


def unpack_floats(data: bytes) -> list[float]:
    """Unpack bytes into float list (float32)."""
    n = len(data) // 4
    return list(struct.unpack(f"{n}f", data))


def cosine_similarity(a: list[float], b: list[float]) -> float:
    """Compute cosine similarity between two vectors.

    Uses pure Python to avoid numpy dependency.
    """
    if len(a) != len(b) or not a:
        return 0.0

    dot = sum(x * y for x, y in zip(a, b))
    norm_a = sum(x * x for x in a) ** 0.5
    norm_b = sum(x * x for x in b) ** 0.5

    if norm_a == 0.0 or norm_b == 0.0:
        return 0.0

    return dot / (norm_a * norm_b)


def _estimate_tokens(text: str) -> int:
    """Rough token estimate: ~4 chars per token."""
    return max(1, len(text) // 4)


# ═══════════════════════════════════════════════════════════════════════════
# RetrievalEngine
# ═══════════════════════════════════════════════════════════════════════════


class RetrievalEngine:
    """Search and inspect compacted conversation history.

    Three search modes: regex (LIKE fallback), full_text (FTS5 BM25), semantic (vector).
    Hippo addition: semantic search via Voyage embeddings.
    """

    def __init__(
        self,
        conversation_store: ConversationStore,
        summary_store: SummaryStore,
        embedding_provider: Optional[EmbeddingProvider] = None,
        db: Optional["HippoDatabase"] = None,
        config: Optional[HippoConfig] = None,
        reranker: Optional[RerankerProvider] = None,
    ) -> None:
        self.conversation_store = conversation_store
        self.summary_store = summary_store
        self.embedding_provider = embedding_provider
        self.db = db
        self.config = config or HippoConfig()
        self.reranker = reranker

    # ── grep ───────────────────────────────────────────────────────────

    async def grep(self, input: GrepInput) -> GrepResult:
        """Search messages and/or summaries via regex, FTS5, or semantic."""
        owner_id = input.owner_id or "default"

        # Semantic mode
        if input.mode == "semantic":
            if self.embedding_provider is not None:
                sem_results = await self.semantic_search(
                    query=input.pattern,
                    owner_id=owner_id,
                    conversation_id=input.conversation_id,
                    limit=input.limit,
                )
                # Convert to SummaryRecords for consistency
                summaries = []
                for sr in sem_results:
                    rec = await self.summary_store.get_summary(owner_id, sr.summary_id)
                    if rec:
                        summaries.append(rec)
                return GrepResult(
                    messages=[],
                    summaries=summaries,
                    total_matches=len(summaries),
                )
            else:
                # Fall back to full_text if no embedding provider
                logger.debug("No embedding provider — falling back to full_text mode")
                input.mode = "full_text"

        # FTS / regex mode
        search_mode = "full_text" if input.mode == "full_text" else "regex"

        messages: list[MessageRecord] = []
        summaries: list[SummaryRecord] = []

        if input.scope in ("messages", "both"):
            msg_coro = self.conversation_store.search_messages(
                owner_id=owner_id,
                pattern=input.pattern,
                mode=search_mode,
                conversation_id=input.conversation_id,
                limit=input.limit,
            )
        else:
            msg_coro = None

        if input.scope in ("summaries", "both"):
            sum_coro = self.summary_store.search_summaries(
                owner_id=owner_id,
                pattern=input.pattern,
                mode=search_mode,
                conversation_id=input.conversation_id,
                limit=input.limit,
            )
        else:
            sum_coro = None

        # Run in parallel when both scopes requested
        if msg_coro and sum_coro:
            messages, summaries = await asyncio.gather(msg_coro, sum_coro)
        elif msg_coro:
            messages = await msg_coro
        elif sum_coro:
            summaries = await sum_coro

        return GrepResult(
            messages=messages,
            summaries=summaries,
            total_matches=len(messages) + len(summaries),
        )

    # ── describe ───────────────────────────────────────────────────────

    async def describe(
        self,
        id: str,
        owner_id: str = "default",
        conversation_id: Optional[int] = None,
        all_conversations: bool = False,
    ) -> Optional[DescribeResult]:
        """Look up a summary (sum_xxx) or file (file_xxx) by ID."""
        if id.startswith("sum_"):
            return await self._describe_summary(id, owner_id)
        elif id.startswith("file_"):
            return await self._describe_file(id, owner_id)
        return None

    async def _describe_summary(self, id: str, owner_id: str) -> Optional[DescribeResult]:
        """Describe a summary with full lineage."""
        summary = await self.summary_store.get_summary(owner_id, id)
        if not summary:
            return None

        # Fetch lineage in parallel — scoped by owner_id for tenant isolation
        parents_task = self.summary_store.get_parent_records(id, owner_id=owner_id)
        children_task = self.summary_store.get_children_records(id, owner_id=owner_id)
        messages_task = self.summary_store.get_source_message_ids(id, owner_id=owner_id)
        subtree_task = self.summary_store.get_summary_subtree(id, owner_id=owner_id)

        parents, children, message_ids, subtree = await asyncio.gather(
            parents_task, children_task, messages_task, subtree_task
        )

        return DescribeResult(
            id=id,
            type="summary",
            summary={
                "summary_id": summary.summary_id,
                "conversation_id": summary.conversation_id,
                "kind": summary.kind,
                "content": summary.content,
                "depth": summary.depth,
                "token_count": summary.token_count,
                "descendant_count": summary.descendant_count,
                "descendant_token_count": summary.descendant_token_count,
                "source_message_token_count": summary.source_message_token_count,
                "file_ids": summary.file_ids,
                "parent_ids": [p.summary_id for p in parents],
                "child_ids": [c.summary_id for c in children],
                "message_ids": message_ids,
                "earliest_at": summary.earliest_at,
                "latest_at": summary.latest_at,
                "subtree": subtree,
                "created_at": summary.created_at,
            },
        )

    async def _describe_file(self, id: str, owner_id: str = "default") -> Optional[DescribeResult]:
        """Describe a large file record."""
        file_rec = await self.summary_store.get_large_file(id, owner_id=owner_id)
        if not file_rec:
            return None
        return DescribeResult(id=id, type="file", file=file_rec)

    # ── semantic search ────────────────────────────────────────────────

    async def semantic_search(
        self,
        query: str,
        owner_id: str,
        conversation_id: Optional[int] = None,
        limit: int = 10,
        score_threshold: float = 0.3,
    ) -> list[SemanticSearchResult]:
        """Vector similarity search — ANN (sqlite-vec) or brute-force fallback."""
        if not self.embedding_provider:
            return []

        try:
            query_vector = await self.embedding_provider.embed(query)
        except Exception:
            logger.warning("Embedding provider failed, returning empty semantic results")
            return []

        if self.db and self.db.vec_available:
            return await self._vec_semantic_search(
                query_vector, owner_id, conversation_id, limit, score_threshold,
            )
        return await self._brute_force_semantic_search(
            query_vector, owner_id, conversation_id, limit, score_threshold,
        )

    async def _vec_semantic_search(
        self,
        query_vector: list[float],
        owner_id: str,
        conversation_id: Optional[int],
        limit: int,
        score_threshold: float,
    ) -> list[SemanticSearchResult]:
        """ANN search using sqlite-vec vec0 virtual table."""
        query_blob = pack_floats(query_vector)
        # Overfetch to allow post-join filtering by conversation_id
        multiplier = 3 if conversation_id else 1
        k = limit * multiplier

        # sqlite-vec query: MATCH + k + partition key (owner_id)
        sql = """
            SELECT v.summary_id, v.distance
            FROM hippo_vec_summaries v
            WHERE v.embedding MATCH ?
                AND v.k = ?
                AND v.owner_id = ?
            ORDER BY v.distance
        """
        params: list = [query_blob, k, owner_id]

        try:
            async with self.db.conn.execute(sql, params) as cur:
                vec_rows = await cur.fetchall()
        except Exception as exc:
            logger.warning("vec search failed, falling back to brute-force: %s", exc)
            return await self._brute_force_semantic_search(
                query_vector, owner_id, conversation_id, limit, score_threshold,
            )

        # Enrich with summary data and filter by conversation_id
        results = []
        for row in vec_rows:
            summary_id = row[0]
            distance = row[1]
            similarity = 1.0 - distance  # cosine distance → similarity

            if similarity < score_threshold:
                continue

            summary = await self.summary_store.get_summary(owner_id, summary_id)
            if summary is None:
                continue
            if conversation_id is not None and summary.conversation_id != conversation_id:
                continue

            results.append(
                SemanticSearchResult(
                    summary_id=summary.summary_id,
                    conversation_id=summary.conversation_id,
                    score=similarity,
                    snippet=summary.content[:200],
                    created_at=summary.created_at,
                )
            )
            if len(results) >= limit:
                break

        return results

    async def _brute_force_semantic_search(
        self,
        query_vector: list[float],
        owner_id: str,
        conversation_id: Optional[int],
        limit: int,
        score_threshold: float,
    ) -> list[SemanticSearchResult]:
        """Brute-force cosine similarity search (original implementation)."""
        candidates = await self.summary_store.get_summaries_with_embeddings(
            owner_id, conversation_id
        )

        results = []
        for summary in candidates:
            if summary.embedding is None:
                continue
            summary_vector = unpack_floats(summary.embedding)
            score = cosine_similarity(query_vector, summary_vector)
            if score >= score_threshold:
                results.append(
                    SemanticSearchResult(
                        summary_id=summary.summary_id,
                        conversation_id=summary.conversation_id,
                        score=score,
                        snippet=summary.content[:200],
                        created_at=summary.created_at,
                    )
                )

        results.sort(key=lambda r: r.score, reverse=True)
        return results[:limit]

    # ── hybrid search (BM25 + vector + recency RRF) ─────────────────────

    async def hybrid_search(
        self,
        query: str,
        query_embedding: list[float],
        owner_id: str,
        conversation_id: Optional[int] = None,
        limit: int = 10,
        bm25_weight: Optional[float] = None,
        vector_weight: Optional[float] = None,
        recency_weight: Optional[float] = None,
    ) -> list[HybridSearchResult]:
        """Multi-signal search combining BM25 + vector + recency via RRF.

        Runs FTS5 BM25 and sqlite-vec searches in parallel, fuses via
        Reciprocal Rank Fusion, applies recency decay, and optionally
        reranks with a cross-encoder.

        Issue: #1188, #1189
        """
        bm25_w = bm25_weight if bm25_weight is not None else self.config.retrieval_bm25_weight
        vec_w = vector_weight if vector_weight is not None else self.config.retrieval_vector_weight
        rec_w = recency_weight if recency_weight is not None else self.config.retrieval_recency_weight

        # Overfetch for fusion — more candidates means better RRF
        candidates = limit * 5

        # Determine which searches to run
        has_vec = self.db and self.db.vec_available and query_embedding
        bm25_task = self._bm25_search(query, owner_id, conversation_id, candidates)

        if has_vec:
            vec_task = self._vec_semantic_search(
                query_embedding, owner_id, conversation_id, candidates, score_threshold=0.0,
            )
            bm25_results, vec_results = await asyncio.gather(bm25_task, vec_task)
        else:
            bm25_results = await bm25_task
            vec_results = []

        # ── RRF fusion ──────────────────────────────────────────────
        k = 60  # standard RRF constant

        rrf_scores: dict[str, float] = {}
        bm25_ranks: dict[str, int] = {}
        vector_ranks: dict[str, int] = {}

        for rank, result in enumerate(bm25_results):
            sid = result.summary_id
            rrf_scores[sid] = rrf_scores.get(sid, 0.0) + bm25_w * (1.0 / (k + rank + 1))
            bm25_ranks[sid] = rank

        for rank, result in enumerate(vec_results):
            sid = result.summary_id
            rrf_scores[sid] = rrf_scores.get(sid, 0.0) + vec_w * (1.0 / (k + rank + 1))
            vector_ranks[sid] = rank

        # ── Collect metadata for all candidates ─────────────────────
        all_results: dict[str, SummaryRecord] = {}
        seen_ids = set()
        for result in bm25_results:
            if result.summary_id not in seen_ids:
                all_results[result.summary_id] = result
                seen_ids.add(result.summary_id)
        # For vec results, we need to fetch the full SummaryRecord
        vec_sids_to_fetch = [
            r.summary_id for r in vec_results if r.summary_id not in seen_ids
        ]
        if vec_sids_to_fetch:
            fetched = await self.summary_store.get_records_batch(vec_sids_to_fetch)
            for rec in fetched:
                all_results[rec.summary_id] = rec
                seen_ids.add(rec.summary_id)
        # Also add vec results that are already in all_results
        for r in vec_results:
            if r.summary_id not in all_results:
                # SemanticSearchResult — fetch the full record
                rec = await self.summary_store.get_summary(owner_id, r.summary_id)
                if rec:
                    all_results[r.summary_id] = rec

        # ── Recency boost ───────────────────────────────────────────
        now = time.time()
        decay_hours = self.config.retrieval_recency_decay_hours
        lambda_ = math.log(2) / decay_hours if decay_hours > 0 else 0

        final_scores: list[tuple[str, float, Optional[float]]] = []
        for sid, rrf_score in rrf_scores.items():
            if sid not in all_results:
                continue
            result = all_results[sid]
            created_at_str = result.created_at if isinstance(result, SummaryRecord) else None
            if created_at_str and lambda_ > 0:
                try:
                    created_dt = datetime.fromisoformat(created_at_str.replace("Z", "+00:00"))
                    age_hours = (now - created_dt.timestamp()) / 3600.0
                except (ValueError, AttributeError):
                    age_hours = 0
                recency = math.exp(-lambda_ * max(age_hours, 0))
            else:
                recency = 1.0

            final = rrf_score * (1.0 + rec_w * recency)
            final_scores.append((sid, final, recency))

        final_scores.sort(key=lambda x: x[1], reverse=True)

        # ── Optional reranker ───────────────────────────────────────
        if self.reranker and self.config.retrieval_reranker_enabled:
            reranker_candidates = final_scores[: self.config.retrieval_reranker_candidates]
            docs = []
            for sid, _, _ in reranker_candidates:
                rec = all_results.get(sid)
                docs.append(rec.content if rec else "")
            try:
                reranked = await self.reranker.rerank(query, docs, top_k=limit)
                return [
                    HybridSearchResult(
                        summary_id=reranker_candidates[idx][0],
                        score=relevance,
                        content=all_results[reranker_candidates[idx][0]].content,
                        conversation_id=all_results[reranker_candidates[idx][0]].conversation_id,
                        depth=all_results[reranker_candidates[idx][0]].depth,
                        kind=all_results[reranker_candidates[idx][0]].kind,
                        bm25_rank=bm25_ranks.get(reranker_candidates[idx][0]),
                        vector_rank=vector_ranks.get(reranker_candidates[idx][0]),
                        recency_score=reranker_candidates[idx][2],
                    )
                    for idx, relevance in reranked
                    if idx < len(reranker_candidates)
                ]
            except Exception as e:
                logger.warning("Reranker failed, using RRF scores: %s", e)
                # Fall through to RRF results

        # ── Build final results ─────────────────────────────────────
        return [
            HybridSearchResult(
                summary_id=sid,
                score=score,
                content=all_results[sid].content,
                conversation_id=all_results[sid].conversation_id,
                depth=all_results[sid].depth,
                kind=all_results[sid].kind,
                bm25_rank=bm25_ranks.get(sid),
                vector_rank=vector_ranks.get(sid),
                recency_score=recency_val,
            )
            for sid, score, recency_val in final_scores[:limit]
            if sid in all_results
        ]

    async def _bm25_search(
        self,
        query: str,
        owner_id: str,
        conversation_id: Optional[int],
        limit: int,
    ) -> list[SummaryRecord]:
        """BM25 search via FTS5, returning SummaryRecords ordered by rank.

        Wraps the existing FTS5 search in summary_store. Falls back to
        LIKE search if FTS5 query sanitization fails.
        Issue: #1188
        """
        return await self.summary_store.search_summaries(
            owner_id=owner_id,
            pattern=query,
            mode="full_text",
            conversation_id=conversation_id,
            limit=limit,
        )

    # ── expand (single summary) ────────────────────────────────────────

    async def expand(self, input: ExpandInput) -> ExpandResult:
        """Expand a summary into its children/source messages.

        Uses breadth-first batch expansion for efficiency (#1187).
        """
        result = ExpandResult()
        token_cap = input.token_cap if input.token_cap is not None else float("inf")

        await self._expand_bfs(
            root_ids=[input.summary_id],
            max_depth=input.depth,
            include_messages=input.include_messages,
            token_cap=token_cap,
            result=result,
            owner_id=input.owner_id,
        )

        return result

    async def _expand_bfs(
        self,
        root_ids: list[str],
        max_depth: int,
        include_messages: bool,
        token_cap: float,
        result: ExpandResult,
        owner_id: str | None = None,
    ) -> None:
        """Breadth-first expansion with batch fetches per depth level.

        Replaces the N+1-query recursive approach with batched queries
        per level. Each depth level issues at most 2 queries (children
        fetch + optional message IDs fetch) instead of per-node queries.

        Issue: #1187
        """
        if owner_id is None:
            logger.warning("_expand_bfs called without owner_id — results are unscoped")
        if max_depth <= 0:
            return

        visited: set[str] = set()
        current_level_ids = list(root_ids)

        for depth in range(max_depth):
            if not current_level_ids or result.truncated:
                break

            new_ids = [sid for sid in current_level_ids if sid not in visited]
            if not new_ids:
                break

            # Batch fetch all children at this level
            children_map = await self.summary_store.get_children_records_batch(new_ids, owner_id=owner_id)
            visited.update(new_ids)

            next_level_ids: list[str] = []
            leaf_ids: list[str] = []

            for parent_id in new_ids:
                children = children_map.get(parent_id, [])
                if children:
                    for child in children:
                        if result.truncated:
                            break
                        if result.estimated_tokens + child.token_count > token_cap:
                            result.truncated = True
                            break
                        result.children.append(
                            ChildItem(
                                summary_id=child.summary_id,
                                kind=child.kind,
                                content=child.content,
                                token_count=child.token_count,
                            )
                        )
                        result.estimated_tokens += child.token_count
                        next_level_ids.append(child.summary_id)
                elif include_messages:
                    # This parent has no children — it's a leaf for message lookup
                    leaf_ids.append(parent_id)

            # Batch fetch messages for leaf nodes at this level
            if leaf_ids and include_messages and not result.truncated:
                msg_id_map = await self.summary_store.get_source_message_ids_batch(leaf_ids, owner_id=owner_id)
                all_msg_ids = [mid for ids in msg_id_map.values() for mid in ids]
                if all_msg_ids:
                    msg_map = await self.conversation_store.get_messages_by_ids(all_msg_ids, owner_id=owner_id)
                    # Iterate in message_id order per summary for deterministic output
                    for leaf_id in leaf_ids:
                        for mid in msg_id_map.get(leaf_id, []):
                            if result.truncated:
                                break
                            msg = msg_map.get(mid)
                            if not msg:
                                continue
                            token_count = msg.token_count or _estimate_tokens(msg.content)
                            if result.estimated_tokens + token_count > token_cap:
                                result.truncated = True
                                break
                            result.messages.append(
                                MessageItem(
                                    message_id=msg.message_id,
                                    role=msg.role,
                                    content=msg.content,
                                    token_count=token_count,
                                )
                            )
                            result.estimated_tokens += token_count

            current_level_ids = next_level_ids

    async def _expand_recursive(
        self,
        summary_id: str,
        depth: int,
        include_messages: bool,
        token_cap: float,
        result: ExpandResult,
        owner_id: Optional[str] = None,
    ) -> None:
        """Recursively expand a summary through the DAG.

        When *owner_id* is provided, all child/message lookups are scoped
        by owner for tenant isolation.
        """
        if owner_id is None:
            logger.warning("_expand_recursive called without owner_id — results are unscoped")
        if depth <= 0 or result.truncated:
            return

        child_records = await self.summary_store.get_children_records(summary_id, owner_id=owner_id)

        if child_records:
            # Condensed summary — expand children
            for child in child_records:
                if result.truncated:
                    break

                if result.estimated_tokens + child.token_count > token_cap:
                    result.truncated = True
                    break

                result.children.append(
                    ChildItem(
                        summary_id=child.summary_id,
                        kind=child.kind,
                        content=child.content,
                        token_count=child.token_count,
                    )
                )
                result.estimated_tokens += child.token_count

                if depth > 1:
                    await self._expand_recursive(
                        child.summary_id,
                        depth - 1,
                        include_messages,
                        token_cap,
                        result,
                        owner_id=owner_id,
                    )
        elif include_messages:
            # Leaf summary — expand source messages
            message_ids = await self.summary_store.get_source_message_ids(summary_id, owner_id=owner_id)
            for msg_id in message_ids:
                if result.truncated:
                    break
                msg = await self.conversation_store.get_message_by_id(msg_id, owner_id=owner_id)
                if not msg:
                    continue
                token_count = msg.token_count or _estimate_tokens(msg.content)
                if result.estimated_tokens + token_count > token_cap:
                    result.truncated = True
                    break
                result.messages.append(
                    MessageItem(
                        message_id=msg.message_id,
                        role=msg.role,
                        content=msg.content,
                        token_count=token_count,
                    )
                )
                result.estimated_tokens += token_count
