from __future__ import annotations

from typing import Iterable

from pathlib import Path

from mikoshi.config import load_config
from mikoshi.indexing.index_store import IndexStore
from mikoshi.retrieval.lexical import LexicalIndex
from mikoshi.retrieval.rerank import rerank
from mikoshi.retrieval.semantic import SemanticSearcher, get_embeddings_provider
from mikoshi.utils.types import Chunk, SearchResult


def _normalize(scores: Iterable[tuple[int, float]]) -> dict[int, float]:
    items = list(scores)
    if not items:
        return {}
    values = [score for _, score in items]
    min_score = min(values)
    max_score = max(values)
    normalized: dict[int, float] = {}
    for idx, score in items:
        if max_score == min_score:
            normalized[idx] = 1.0
        else:
            normalized[idx] = (score - min_score) / (max_score - min_score)
    return normalized


def merge_scores(
    lexical: Iterable[tuple[int, float]],
    semantic: Iterable[tuple[int, float]],
    alpha: float = 0.5,
) -> dict[int, float]:
    lex_norm = _normalize(lexical)
    sem_norm = _normalize(semantic)

    combined: dict[int, float] = {}
    for idx, score in lex_norm.items():
        combined[idx] = combined.get(idx, 0.0) + alpha * score
    for idx, score in sem_norm.items():
        combined[idx] = combined.get(idx, 0.0) + (1.0 - alpha) * score
    return combined


def top_k(combined: dict[int, float], k: int) -> list[tuple[int, float]]:
    return sorted(combined.items(), key=lambda item: item[1], reverse=True)[:k]


def make_snippet(text: str, max_lines: int = 6, max_chars: int = 400) -> str:
    lines = text.splitlines()
    snippet = "\n".join(lines[:max_lines])
    if len(snippet) > max_chars:
        snippet = snippet[: max_chars - 3] + "..."
    return snippet


def hybrid_search(
    query: str,
    chunks: list[Chunk],
    lexical: LexicalIndex,
    semantic: SemanticSearcher,
    k: int = 8,
    alpha: float = 0.5,
) -> list[tuple[int, float]]:
    if not chunks:
        return []
    expanded = max(k * 3, k)
    lexical_hits = lexical.search(query, expanded)
    semantic_hits = semantic.search(query, expanded)
    combined = merge_scores(lexical_hits, semantic_hits, alpha=alpha)
    merged = top_k(combined, expanded)
    reranked = rerank(query, merged, chunks)
    return reranked[:k]


def search_repo(path: str, query: str, k: int = 8) -> list[SearchResult]:
    config = load_config()
    repo_root = Path(path).expanduser().resolve()
    store = IndexStore(repo_root, config.index_root)
    meta = store.load_meta()
    if not meta:
        raise RuntimeError("Repository has not been indexed yet.")
    if meta.embedding_provider != config.embeddings.provider or meta.model != config.embeddings.model:
        raise RuntimeError(
            "Index was built with different embeddings settings. Re-index the repository."
        )
    chunks = store.load_chunks()
    index = store.load_faiss()
    if index is None:
        raise RuntimeError("Index data missing. Re-run indexing.")
    if not chunks:
        return []

    lexical = LexicalIndex([chunk.text for chunk in chunks])
    provider = get_embeddings_provider(config)
    semantic = SemanticSearcher(index, provider)
    hits = hybrid_search(query, chunks, lexical, semantic, k=k)
    return [
        SearchResult(
            relpath=chunks[idx].relpath,
            start_line=chunks[idx].start_line,
            end_line=chunks[idx].end_line,
            score=float(score),
            snippet=make_snippet(chunks[idx].text),
        )
        for idx, score in hits
    ]
