import os
import re
import uuid
from collections.abc import Callable
from datetime import UTC, datetime

from sentence_transformers import SentenceTransformer

from pydstl.llm import LLM
from pydstl.store import Store
from pydstl.types import Evidence, Skill

DEFAULT_EMBEDDING_MODEL = "all-MiniLM-L6-v2"


def _slugify(text: str) -> str:
    text = text.lower().strip()
    text = re.sub(r"[^\w\s-]", "", text)
    text = re.sub(r"[\s_]+", "-", text)
    result = text[:64].strip("-")
    return result or str(uuid.uuid4())[:8]


class Dstl:
    """Main interface for collecting evidence and distilling it into skill documents."""

    def __init__(
        self,
        db_path: str,
        model: str,
        embed_fn: Callable[[str], list[float]] | None = None,
        distill_fn: Callable | None = None,
        edit_fn: Callable | None = None,
        consolidate_fn: Callable | None = None,
    ):
        """Initialize the Dstl engine.

        Args:
            db_path: Path to the SQLite database file.
            model: LLM model string for pydantic-ai (e.g. "google-gla:gemini-3-flash-preview",
                "openai:gpt-4o", "anthropic:claude-sonnet-4-20250514").
            embed_fn: Custom embedding function. If None, uses sentence-transformers
                with the default model.
            distill_fn: Custom distillation function, bypasses LLM. Useful for testing.
            edit_fn: Custom edit function, bypasses LLM. Useful for testing.
            consolidate_fn: Custom consolidation function, bypasses LLM. Useful for testing.
        """
        self._store = Store(db_path)
        if embed_fn is not None:
            self._embed = embed_fn
        else:
            model_st = SentenceTransformer(DEFAULT_EMBEDDING_MODEL)
            self._embed = lambda text: model_st.encode(text).tolist()
        self._llm = LLM(
            model=model, distill_fn=distill_fn, edit_fn=edit_fn, consolidate_fn=consolidate_fn
        )

    def add_evidence(self, content: str, source: dict | None = None) -> int:
        """Store a piece of evidence with optional source metadata.

        Args:
            content: The text content to store.
            source: Arbitrary metadata dict (e.g. {"author": "alice", "repo": "acme/api"}).

        Returns:
            The integer ID of the newly stored evidence.
        """
        embedding = self._embed(content)
        return self._store.add_evidence(content, source, embedding)

    def retrieve(self, query: str, top_k: int = 5) -> list[Evidence]:
        """Search stored evidence by semantic similarity.

        Args:
            query: Natural language search query.
            top_k: Maximum number of results to return.

        Returns:
            List of Evidence objects ranked by similarity, each with a score.
        """
        embedding = self._embed(query)
        return self._store.search_evidence(embedding, top_k)

    def list_evidence(self, source_filter: dict | None = None) -> list[Evidence]:
        """List all evidence, optionally filtered by source metadata fields.

        Args:
            source_filter: Dict of key-value pairs to match against source metadata.
                Example: {"author": "alice"} returns only evidence from alice.

        Returns:
            List of matching Evidence objects.
        """
        return self._store.list_evidence(source_filter)

    def distill(
        self,
        topic: str | None = None,
        skill_id: str | None = None,
        output_dir: str = "skills",
        evidence: list[Evidence] | None = None,
    ) -> str:
        """Synthesize evidence into a markdown skill document using an LLM.

        Args:
            topic: Focus topic for distillation. If provided without evidence,
                retrieves the top 20 most relevant pieces via vector search.
            skill_id: Custom ID for the skill file. Auto-generated from title if None.
            output_dir: Directory to write the output markdown file.
            evidence: Explicit list of evidence to distill. If None, uses topic-based
                retrieval or all evidence.

        Returns:
            Path to the generated markdown file.

        Raises:
            ValueError: If no evidence is found to distill.
        """
        if evidence is None:
            if topic:
                evidence = self.retrieve(topic, top_k=20)
            else:
                evidence = self._store.get_all_evidence()

        if not evidence:
            raise ValueError("No evidence found to distill")

        doc = self._llm.distill(evidence, topic)

        if skill_id is None:
            skill_id = _slugify(doc.title) if doc.title else str(uuid.uuid4())[:8]

        os.makedirs(output_dir, exist_ok=True)
        file_path = os.path.join(output_dir, f"{skill_id}.md")

        with open(file_path, "w") as f:
            f.write(f"# {doc.title}\n\n{doc.content}")

        now = datetime.now(UTC).isoformat()
        skill = Skill(
            id=skill_id,
            title=doc.title,
            file_path=file_path,
            created_at=now,
        )
        evidence_ids = [e.id for e in evidence]
        self._store.save_skill(skill, evidence_ids)

        return file_path

    def consolidate(
        self,
        documents: list[tuple[str, str]],
        topic: str | None = None,
        skill_id: str | None = None,
        output_dir: str = "skills",
    ) -> str:
        """Merge multiple skill documents into a single unified document.

        Args:
            documents: List of (label, markdown_content) tuples to merge.
            topic: Optional focus topic for consolidation.
            skill_id: Custom ID for the output file. Auto-generated from title if None.
            output_dir: Directory to write the output markdown file.

        Returns:
            Path to the consolidated markdown file.

        Raises:
            ValueError: If documents list is empty.
        """
        if not documents:
            raise ValueError("No documents to consolidate")

        doc = self._llm.consolidate(documents, topic)

        if skill_id is None:
            skill_id = _slugify(doc.title) if doc.title else str(uuid.uuid4())[:8]

        os.makedirs(output_dir, exist_ok=True)
        file_path = os.path.join(output_dir, f"{skill_id}.md")

        with open(file_path, "w") as f:
            f.write(f"# {doc.title}\n\n{doc.content}")

        now = datetime.now(UTC).isoformat()
        skill = Skill(
            id=skill_id,
            title=doc.title,
            file_path=file_path,
            created_at=now,
        )
        self._store.save_skill(skill)

        return file_path

    def edit_skill(
        self,
        skill_id: str,
        instruction: str | None = None,
        content: str | None = None,
        output_dir: str | None = None,
    ) -> str:
        """Edit an existing skill document via LLM instruction or direct content replacement.

        Args:
            skill_id: ID of the skill to edit.
            instruction: Natural language editing instruction for the LLM.
            content: Direct replacement content (bypasses LLM).
            output_dir: If provided, writes the edited file to this directory
                instead of overwriting the original.

        Returns:
            Path to the updated skill file.

        Raises:
            ValueError: If skill not found or neither instruction nor content provided.
        """
        skill = self._store.get_skill(skill_id)
        if skill is None:
            raise ValueError(f"Skill '{skill_id}' not found")

        if content is not None:
            new_content = content
        elif instruction is not None:
            with open(skill.file_path) as f:
                current = f.read()
            new_content = self._llm.edit(current, instruction)
        else:
            raise ValueError("Provide either 'instruction' or 'content'")

        file_path = skill.file_path
        if output_dir is not None:
            os.makedirs(output_dir, exist_ok=True)
            file_path = os.path.join(output_dir, f"{skill_id}.md")

        with open(file_path, "w") as f:
            f.write(new_content)

        now = datetime.now(UTC).isoformat()
        updated_skill = Skill(
            id=skill_id,
            title=skill.title,
            file_path=file_path,
            created_at=skill.created_at,
            updated_at=now,
        )
        self._store.save_skill(updated_skill)

        return file_path

    def report_outcome(self, skill_id: str, success: bool, notes: str = "") -> int:
        """Record whether a skill was helpful in practice.

        Args:
            skill_id: ID of the skill to report on.
            success: True if the skill was helpful, False otherwise.
            notes: Optional free-text notes about the outcome.

        Returns:
            The integer ID of the recorded outcome.

        Raises:
            ValueError: If skill not found.
        """
        skill = self._store.get_skill(skill_id)
        if skill is None:
            raise ValueError(f"Skill '{skill_id}' not found")
        return self._store.add_outcome(skill_id, success, notes)

    def close(self) -> None:
        """Close the database connection."""
        self._store.close()
