import re
import uuid

import httpx
from sqlalchemy import func
from sqlalchemy.orm import Session

from app.core.config import get_settings
from app.models.knowledge import KnowledgeSource
from app.models.stubs import VectorEmbedding

settings = get_settings()

CHUNK_SIZE = 800
CHUNK_OVERLAP = 150


def _hard_split(paragraph: str, chunk_size: int) -> list[str]:
    """Splits a single paragraph too long to fit in one chunk, snapping each cut to
    the nearest word boundary. No overlap here — that's applied once, uniformly,
    over the final chunk list by chunk_text."""
    pieces: list[str] = []
    start = 0
    while start < len(paragraph):
        end = min(start + chunk_size, len(paragraph))
        if end < len(paragraph):
            boundary = paragraph.rfind(" ", start, end)
            if boundary > start:
                end = boundary
        pieces.append(paragraph[start:end].strip())
        start = end
    return pieces


def chunk_text(text: str, chunk_size: int = CHUNK_SIZE, overlap: int = CHUNK_OVERLAP) -> list[str]:
    paragraphs = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]

    chunks: list[str] = []
    current = ""
    for para in paragraphs:
        candidate = f"{current}\n\n{para}" if current else para
        if len(candidate) <= chunk_size:
            current = candidate
            continue
        if current:
            chunks.append(current)
        if len(para) <= chunk_size:
            current = para
        else:
            chunks.extend(_hard_split(para, chunk_size))
            current = ""
    if current:
        chunks.append(current)

    if not overlap or len(chunks) <= 1:
        return chunks

    overlapped = [chunks[0]]
    for i in range(1, len(chunks)):
        prev_tail = chunks[i - 1][-overlap:]
        # snap to the nearest word boundary so we don't glue two words together
        space_idx = prev_tail.find(" ")
        if space_idx != -1:
            prev_tail = prev_tail[space_idx + 1 :]
        else:
            prev_tail = ""
        overlapped.append(f"{prev_tail} {chunks[i]}" if prev_tail else chunks[i])
    return overlapped


def embed_text(text: str, model: str | None = None) -> list[float]:
    model_name = model or settings.ollama_embedding_model
    resp = httpx.post(
        f"{settings.ollama_host}/api/embeddings",
        json={"model": model_name, "prompt": text},
        timeout=60,
    )
    resp.raise_for_status()
    return resp.json()["embedding"]


def index_knowledge_source(db: Session, source_id: uuid.UUID) -> int:
    from app.models.knowledge import ExtractedText

    source = db.get(KnowledgeSource, source_id)
    extracted = db.query(ExtractedText).filter_by(knowledge_source_id=source_id).one_or_none()
    if source is None or extracted is None or not extracted.cleaned_text:
        raise ValueError("Document has no confirmed cleaned text to index")

    db.query(VectorEmbedding).filter(VectorEmbedding.knowledge_source_id == source_id).delete()

    chunks = chunk_text(extracted.cleaned_text)
    for i, chunk in enumerate(chunks):
        embedding = embed_text(chunk)
        db.add(
            VectorEmbedding(
                project_id=source.project_id,
                knowledge_source_id=source_id,
                chunk_text=chunk,
                chunk_index=i,
                embedding_ref=f"{source_id}:{i}",
                embedding=embedding,
                vector_metadata={"filename": source.original_filename, "category": source.category},
            )
        )
    db.commit()
    return len(chunks)


def semantic_search(
    db: Session, project_id: uuid.UUID, query: str, top_k: int = 5, similarity_threshold: float | None = None
) -> list[dict]:
    query_vec = embed_text(query)
    distance = VectorEmbedding.embedding.cosine_distance(query_vec)

    rows = (
        db.query(VectorEmbedding, distance.label("distance"))
        .filter(VectorEmbedding.project_id == project_id, VectorEmbedding.embedding.isnot(None))
        .order_by(distance)
        .limit(top_k)
        .all()
    )

    results = []
    for chunk, dist in rows:
        score = 1 - float(dist)
        if similarity_threshold is not None and score < similarity_threshold:
            continue
        results.append(_to_result(db, chunk, score))
    return results


def hybrid_search(
    db: Session, project_id: uuid.UUID, query: str, top_k: int = 5, similarity_threshold: float | None = None
) -> list[dict]:
    query_vec = embed_text(query)
    distance = VectorEmbedding.embedding.cosine_distance(query_vec)

    vector_rows = (
        db.query(VectorEmbedding, distance.label("distance"))
        .filter(VectorEmbedding.project_id == project_id, VectorEmbedding.embedding.isnot(None))
        .order_by(distance)
        .limit(top_k * 3)
        .all()
    )
    keyword_rows = (
        db.query(VectorEmbedding)
        .filter(VectorEmbedding.project_id == project_id, VectorEmbedding.chunk_text.ilike(f"%{query}%"))
        .limit(top_k * 3)
        .all()
    )

    # Reciprocal rank fusion across the two candidate lists.
    rrf_scores: dict[uuid.UUID, float] = {}
    chunks_by_id: dict[uuid.UUID, VectorEmbedding] = {}
    sim_by_id: dict[uuid.UUID, float] = {}

    for rank, (chunk, dist) in enumerate(vector_rows):
        rrf_scores[chunk.id] = rrf_scores.get(chunk.id, 0) + 1 / (60 + rank)
        chunks_by_id[chunk.id] = chunk
        sim_by_id[chunk.id] = 1 - float(dist)

    for rank, chunk in enumerate(keyword_rows):
        rrf_scores[chunk.id] = rrf_scores.get(chunk.id, 0) + 1 / (60 + rank)
        chunks_by_id[chunk.id] = chunk
        sim_by_id.setdefault(chunk.id, 0.0)

    ranked_ids = sorted(rrf_scores, key=lambda cid: rrf_scores[cid], reverse=True)[:top_k]

    results = []
    for cid in ranked_ids:
        score = sim_by_id.get(cid, 0.0)
        if similarity_threshold is not None and score < similarity_threshold:
            continue
        results.append(_to_result(db, chunks_by_id[cid], score))
    return results


def _to_result(db: Session, chunk: VectorEmbedding, score: float) -> dict:
    source = db.get(KnowledgeSource, chunk.knowledge_source_id) if chunk.knowledge_source_id else None
    return {
        "chunk_id": chunk.id,
        "chunk_text": chunk.chunk_text,
        "chunk_index": chunk.chunk_index,
        "score": round(score, 4),
        "knowledge_source_id": chunk.knowledge_source_id,
        "source_filename": source.original_filename if source else None,
        "category": source.category if source else None,
    }


def collection_summary(db: Session, project_id: uuid.UUID) -> list[dict]:
    rows = (
        db.query(
            VectorEmbedding.knowledge_source_id,
            func.count(VectorEmbedding.id).label("chunk_count"),
            func.max(VectorEmbedding.created_at).label("indexed_at"),
        )
        .filter(VectorEmbedding.project_id == project_id)
        .group_by(VectorEmbedding.knowledge_source_id)
        .all()
    )
    summaries = []
    for source_id, chunk_count, indexed_at in rows:
        source = db.get(KnowledgeSource, source_id) if source_id else None
        summaries.append(
            {
                "knowledge_source_id": source_id,
                "filename": source.original_filename if source else "Unknown",
                "category": source.category if source else None,
                "chunk_count": chunk_count,
                "indexed_at": indexed_at,
            }
        )
    return summaries
