#!/usr/bin/env python3
"""
CONTEXTUAL CHUNKING - Anthropic-style Contextual Retrieval
==========================================================
Prepends each chunk with a short LLM-generated context string explaining
where this chunk sits in the source document. Dramatically improves
retrieval accuracy (Anthropic: 65% → 89% top-20).

Pipeline:
  1. Chunk document (semantic or fixed-size with overlap)
  2. For each chunk, generate context via cheap LLM:
     "Here is the full document: <doc>{whole_doc}</doc>
      Here is the chunk we want to situate: <chunk>{chunk}</chunk>
      Give a short context (2-3 sentences) to precede this chunk
      that improves its retrievability."
  3. Concatenate: context_text + chunk_text → embed this
  4. Store original chunk_text separately for generation (LLM context window)

Supports:
  - Batch processing (multiple chunks per LLM call for efficiency)
  - Fallback to heuristic context when LLM unavailable
  - Content hashing for dedup + cache
  - Parent-child chunking: small child chunks for retrieval, parent section for LLM
"""

import asyncio
import hashlib
import logging
import os
from dataclasses import dataclass, field
from typing import Any

import httpx

logger = logging.getLogger(__name__)

# ── LLM Config ────────────────────────────────────────────────────────
OPENROUTER_KEY = os.getenv("OPENROUTER_API_KEY", "")
# Decode base64 LLM key if present, otherwise use plain LLM_API_KEY
if os.getenv("LLM_API_KEY_B64"):
    import base64 as _b64

    os.environ["LLM_API_KEY"] = _b64.b64decode(os.getenv("LLM_API_KEY_B64")).decode()
LLM_API_KEY = os.getenv("LLM_API_KEY", OPENROUTER_KEY)
LLM_BASE_URL = os.getenv("LLM_BASE_URL", "https://api.deepseek.com/v1/chat/completions")
AI_BASE = LLM_BASE_URL if LLM_API_KEY else "https://openrouter.ai/api/v1/chat/completions"
CONTEXT_MODEL = os.getenv("CONTEXT_CHUNK_MODEL", os.getenv("LLM_MODEL", "deepseek-v4-flash"))

# ── Chunking defaults ─────────────────────────────────────────────────
DEFAULT_CHUNK_SIZE = 2500  # chars per chunk
DEFAULT_OVERLAP = 300  # overlap between chunks
DEFAULT_MAX_CHUNKS = 50  # safety cap per document


@dataclass
class Chunk:
    """A single chunk with optional context."""

    index: int
    content: str  # original chunk text
    contextualized: str = ""  # context + content (for embedding)
    context_text: str = ""  # LLM-generated context (2-3 sentences)
    parent_id: str | None = None  # parent section ID (for parent-child pattern)
    parent_content: str | None = None  # full parent section text
    metadata: dict[str, Any] = field(default_factory=dict)
    content_hash: str = ""

    def __post_init__(self):
        if not self.content_hash:
            self.content_hash = hashlib.sha256(self.content.encode()).hexdigest()[:16]


@dataclass
class ChunkedDocument:
    """Result of chunking a document."""

    doc_id: str
    source: str  # source filename/URL
    chunks: list[Chunk]
    total_chars: int = 0
    processing_time_ms: float = 0.0


# ══════════════════════════════════════════════════════════════════════
# CHUNKING
# ══════════════════════════════════════════════════════════════════════


def _is_solidity(text: str) -> bool:
    """Detect if text is a Solidity smart contract."""
    indicators = [
        "pragma solidity",
        "contract ",
        "function ",
        "// SPDX-License-Identifier",
    ]
    head = text[:500].lower()
    return any(ind in head for ind in indicators)


def chunk_document(
    text: str,
    chunk_size: int = DEFAULT_CHUNK_SIZE,
    overlap: int = DEFAULT_OVERLAP,
    max_chunks: int = DEFAULT_MAX_CHUNKS,
    respect_boundaries: bool = True,
) -> list[Chunk]:
    """
    Split document into overlapping chunks.
    When respect_boundaries=True, splits at paragraph/sentence boundaries
    near the target chunk_size instead of cutting mid-sentence.

    For Solidity smart contracts, uses AST-aware chunking (function/contract
    boundaries) instead of character-level splits. Critical for code security
    analysis where splitting a function breaks its logic.
    """
    if not text or len(text) <= chunk_size:
        return [Chunk(index=0, content=text)]

    # ── Solidity AST-aware chunking ──
    if _is_solidity(text):
        try:
            from app.solidity_chunker import chunk_solidity_ast

            ast_chunks = chunk_solidity_ast(text)
            if ast_chunks:
                return [Chunk(index=i, content=c) for i, c in enumerate(ast_chunks[:max_chunks])]
        except ImportError:
            pass
        except Exception:
            pass

    chunks = []
    start = 0
    idx = 0

    while start < len(text) and idx < max_chunks:
        end = start + chunk_size

        if end >= len(text):
            # Last chunk - take everything remaining
            chunk_text = text[start:].strip()
            if chunk_text:
                chunks.append(Chunk(index=idx, content=chunk_text))
            break

        if respect_boundaries:
            # Look for a paragraph break or sentence end near the target end
            # Search backwards from end for '\n\n' or '. ' or '.\n'
            search_start = max(start + chunk_size // 2, end - 200)
            search_end = min(end + 100, len(text))

            # Prefer paragraph break
            para_break = text.rfind("\n\n", search_start, search_end)
            if para_break == -1:
                para_break = text.rfind("\n", search_start, search_end)

            if para_break != -1 and para_break > start:
                end = para_break + 1
            else:
                # Fall back to sentence boundary
                for delim in [". ", ".\n", "! ", "? ", ";\n"]:
                    sent_break = text.rfind(delim, search_start, search_end)
                    if sent_break != -1 and sent_break > start:
                        end = sent_break + len(delim)
                        break

        chunk_text = text[start:end].strip()
        if chunk_text:
            chunks.append(Chunk(index=idx, content=chunk_text))
            idx += 1

        start = end - overlap if end < len(text) else end

    return chunks


# ══════════════════════════════════════════════════════════════════════
# CONTEXT GENERATION
# ══════════════════════════════════════════════════════════════════════

CONTEXT_PROMPT = """Here is the full document:
<doc>
{doc}
</doc>

Here is the chunk we want to situate within the whole document:
<chunk>
{chunk}
</chunk>

Give a short context (2-3 sentences) to precede this chunk that improves its retrievability. The context should explain where this chunk fits in the document and what information it contains. Be specific - mention names, topics, and any key entities. Do NOT repeat the chunk content verbatim.

Context:"""


async def _generate_context_llm(
    doc_text: str,
    chunk_text: str,
    model: str = CONTEXT_MODEL,
) -> str:
    """Generate contextual preamble for a chunk using LLM."""
    if not LLM_API_KEY:
        return ""

    prompt = CONTEXT_PROMPT.format(
        doc=doc_text[:8000],  # truncate full doc to fit context
        chunk=chunk_text[:1500],
    )

    try:
        async with httpx.AsyncClient(timeout=30) as client:
            resp = await client.post(
                AI_BASE,
                headers={
                    "Authorization": f"Bearer {LLM_API_KEY}",
                    "Content-Type": "application/json",
                },
                json={
                    "model": model,
                    "messages": [{"role": "user", "content": prompt}],
                    "max_tokens": 150,
                    "temperature": 0.3,
                },
            )
            resp.raise_for_status()
            data = resp.json()
            context = data.get("choices", [{}])[0].get("message", {}).get("content", "")
            return context.strip()
    except Exception as e:
        logger.warning(f"Context generation failed: {e}")
        return ""


def _generate_heuristic_context(
    doc_text: str,
    chunk_text: str,
    chunk_index: int,
    total_chunks: int,
) -> str:
    """
    Fallback: generate context heuristically when LLM is unavailable.
    Extracts title, section headers, and position info.
    """
    # Try to find a title (first line or # header)
    lines = doc_text.strip().split("\n")
    title = ""
    for line in lines[:5]:
        if line.strip().startswith("#"):
            title = line.strip().lstrip("#").strip()
            break
        elif len(line.strip()) > 10:
            title = line.strip()[:100]
            break

    # Find section headers near this chunk
    doc_before = doc_text[: doc_text.find(chunk_text[:50])] if chunk_text[:50] in doc_text else ""
    section = ""
    for line in reversed(doc_before.split("\n")):
        if line.strip().startswith("#"):
            section = line.strip().lstrip("#").strip()
            break

    parts = []
    if title:
        parts.append(f"From: {title}")
    if section and section != title:
        parts.append(f"Section: {section}")
    parts.append(f"Chunk {chunk_index + 1} of {total_chunks}")

    return ". ".join(parts) + "."


async def contextualize_chunks(
    doc_text: str,
    chunks: list[Chunk],
    use_llm: bool = True,
    batch_size: int = 5,
) -> list[Chunk]:
    """
    Add contextual preambles to all chunks.
    Uses LLM when available, falls back to heuristic context.
    Processes in small batches to be gentle on API rate limits.
    """
    total = len(chunks)

    for i in range(0, total, batch_size):
        batch = chunks[i : i + batch_size]
        tasks = []

        for chunk in batch:
            if use_llm and LLM_API_KEY:
                tasks.append(_generate_context_llm(doc_text, chunk.content))
            else:
                tasks.append(None)

        if any(t is not None for t in tasks):
            # Only call gather if we have actual async tasks
            actual_tasks = [t for t in tasks if t is not None]
            actual_results = await asyncio.gather(*actual_tasks, return_exceptions=True)
            result_idx = 0
            for j, chunk in enumerate(batch):
                if tasks[j] is not None:
                    ctx = actual_results[result_idx]
                    result_idx += 1
                    if isinstance(ctx, Exception) or not ctx:
                        ctx = _generate_heuristic_context(doc_text, chunk.content, chunk.index, total)
                    chunk.context_text = ctx
                    chunk.contextualized = f"{ctx}\n\n{chunk.content}"
                else:
                    ctx = _generate_heuristic_context(doc_text, chunk.content, chunk.index, total)
                    chunk.context_text = ctx
                    chunk.contextualized = f"{ctx}\n\n{chunk.content}"
        else:
            # All heuristic (no LLM)
            for chunk in batch:
                ctx = _generate_heuristic_context(doc_text, chunk.content, chunk.index, total)
                chunk.context_text = ctx
                chunk.contextualized = f"{ctx}\n\n{chunk.content}"

    return chunks


# ══════════════════════════════════════════════════════════════════════
# FULL PIPELINE
# ══════════════════════════════════════════════════════════════════════


async def process_document(
    text: str,
    doc_id: str = "",
    source: str = "",
    chunk_size: int = DEFAULT_CHUNK_SIZE,
    overlap: int = DEFAULT_OVERLAP,
    use_llm_context: bool = True,
) -> ChunkedDocument:
    """
    Full contextual chunking pipeline:
    1. Chunk the document
    2. Generate contextual preambles for each chunk
    3. Return ChunkedDocument with all chunks ready for embedding

    The `contextualized` field on each chunk is what you embed.
    The `content` field is what you feed to the LLM at generation time.
    """
    import time

    start = time.time()

    if not doc_id:
        doc_id = hashlib.sha256(text.encode()).hexdigest()[:16]

    # Step 1: Chunk
    chunks = chunk_document(text, chunk_size=chunk_size, overlap=overlap)

    # Step 2: Contextualize
    chunks = await contextualize_chunks(text, chunks, use_llm=use_llm_context)

    elapsed = (time.time() - start) * 1000

    return ChunkedDocument(
        doc_id=doc_id,
        source=source,
        chunks=chunks,
        total_chars=len(text),
        processing_time_ms=round(elapsed, 1),
    )


# ══════════════════════════════════════════════════════════════════════
# PARENT-CHILD CHUNKING
# ══════════════════════════════════════════════════════════════════════


def parent_child_chunk(
    text: str,
    parent_size: int = 4000,
    child_size: int = 800,
    overlap: int = 200,
) -> list[Chunk]:
    """
    Parent-child chunking pattern:
    - Large 'parent' chunks (4K chars) for LLM context at generation time
    - Small 'child' chunks (800 chars) for retrieval precision
    - Each child knows its parent so we can retrieve the parent when a child matches

    Returns a flat list: parent chunks + their children.
    Parents have metadata.is_parent=True, children have parent_id set.
    """
    # Create parent sections first
    parent_chunks = chunk_document(text, chunk_size=parent_size, overlap=overlap)

    all_chunks = []
    for p_idx, parent in enumerate(parent_chunks):
        parent_id = f"parent_{p_idx}"
        parent.metadata = {"is_parent": True, "child_count": 0}
        all_chunks.append(parent)

        # Split parent into children
        children = chunk_document(parent.content, chunk_size=child_size, overlap=100)
        for c in children:
            c.parent_id = parent_id
            c.parent_content = parent.content
            c.index = len(all_chunks)
            all_chunks.append(c)

        parent.metadata["child_count"] = len(children)

    return all_chunks
