"""Classifier — async LLM client for memory classification and query analysis.

Provider-agnostic: takes an LLMClient (Gemini, Groq, etc.) and uses it for
all classification tasks. The Classifier doesn't know which provider it uses.
"""

import importlib.resources
import json
import logging
import os

from neo_cortex.llm import LLMClient
from neo_cortex.models import Activity, ClassificationResult, QueryAnalysis, RecalledMemory

logger = logging.getLogger(__name__)

from functools import partial
from neo_cortex._log import boot_log
_bl = partial(boot_log, "CLASSIFIER")


def _strip_code_fences(text: str) -> str:
    """Strip markdown code fences from LLM output."""
    text = text.strip()
    if text.startswith("```"):
        text = "\n".join(text.split("\n")[1:])
    if text.endswith("```"):
        text = "\n".join(text.split("\n")[:-1])
    return text.strip()


CLASSIFY_PROMPT = """Classify and extract structured info from this conversation turn.
Return ONLY valid JSON, no explanation.

{
  "project": "project name or 'general' or 'soul'",
  "topic": "1-3 word topic",
  "activity": "one of: bugfix|feature|debug|config|deploy|research|discussion|learning|identity|behavior|growth",
  "title": "concise 5-10 word title describing what happened",
  "summary": "one sentence summary",
  "facts": ["concrete fact 1", "concrete fact 2"],
  "concepts": ["specific-technical-concept-1", "specific-technical-concept-2"],
  "files_touched": ["path/to/file.py"],
  "observation_level": "direct|inferred|abstract|reported"
}

observation_level: how certain is this knowledge?
  direct = saw it, did it, explicit statement, first-hand experience
  inferred = deduced from evidence, logical conclusion
  abstract = pattern, generalization, theoretical insight
  reported = someone else said it, secondhand information

Extract facts = concrete things learned/done.
Extract concepts = specific technical terms, tools, libraries, patterns, protocols mentioned.
  Examples: "ChromaDB", "FTS5", "SimHash", "Gemini", "conflict-resolution", "embeddings", "MBEL", "SignalR", "BBC-architecture", "LLMClient".
  NOT abstract categories like "bugfix" or "config" — those go in "activity". 2-5 concepts per entry.
Extract files only if explicitly mentioned.
If uncertain about a field, omit it.

Known projects: neo-console, neo-reloaded, neo-cortex, ai2ai, memory-bridge, openclaw, moltbook, soul
Project "soul" is for: identity, personality, behavior, lessons, growth, self-reflection, values.

Q+A:
"""

NOISE_PROMPT = """You are a noise filter for a developer's memory system.

NOISE (skip) = no lasting knowledge:
- Pure greetings: "ciao", "hey", "hello"
- Acknowledgments: "ok", "si", "grazie", "perfetto"
- System commands: "/clear", "kill", "esci"

SIGNAL (keep) = real work or knowledge:
- Questions about code, bugs, architecture
- Instructions to do something specific
- Observations, decisions, opinions with substance

KEY RULE: if the message is an instruction to DO something (even informal/typos), it is SIGNAL.

Return ONLY: {"noise": true/false, "why": "2-3 words"}

Message: """

QUERY_ANALYZE_PROMPT = """Analyze this search query. Return ONLY valid JSON, no explanation.

{
  "project": "project name or null",
  "topic": "topic keyword or null",
  "activity": "activity type or null",
  "time_hint": "recent|old|any",
  "refined_query": "rewritten query optimized for semantic search"
}

Known projects: neo-console, neo-reloaded, neo-cortex, ai2ai, memory-bridge, openclaw, moltbook, soul

Query: """

CONFLICT_PROMPT_PREFIX = """Compare an INCOMING memory against an EXISTING memory.
Decide if the incoming memory updates, duplicates, or is unrelated to the existing one.

Return ONLY valid JSON:
{"action": "add|update|noop", "reason": "2-5 words"}

Rules:
- "update": incoming has NEWER or MORE COMPLETE info about the SAME topic (e.g. version changed, count changed, status changed). The old memory should be replaced.
- "noop": incoming is REDUNDANT — same info already exists. Skip it.
- "add": they are about DIFFERENT topics. Keep both.

Be conservative: only "update" when the SAME fact clearly changed. When in doubt, "add".

EXISTING memory:
"""

SEGMENT_PROMPT = """You are a conversation segmenter. Given numbered turns from a developer conversation, identify where the TOPIC changes.

Return ONLY a valid JSON array of segments:
[{"start": 0, "end": 4, "topic": "CORS fix"}, {"start": 5, "end": 9, "topic": "systemd deploy"}]

Rules:
- "start" and "end" are turn indices (inclusive)
- A segment is a group of consecutive turns about the SAME topic/task
- Merge if topics overlap or are closely related (e.g. "fix bug" then "test fix" = same segment)
- Split when conversation clearly switches to a DIFFERENT task
- Every turn must belong to exactly one segment, no gaps
- Greetings/noise at the start can be their own segment

Turns:
"""

def _load_aggregate_prompt() -> str:
    """Load MBEL grammar from package data and build the aggregate prompt."""
    mbel_grammar = None
    # Primary: package data (works with wheel/uvx installs)
    try:
        mbel_grammar = importlib.resources.files("neo_cortex").joinpath("mbel.md").read_text(encoding="utf-8").strip()
    except Exception:
        pass
    # Fallback: relative to source tree (editable installs)
    if not mbel_grammar:
        _pkg_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
        _mbel_path = os.path.join(_pkg_dir, "mbel.md")
        try:
            with open(_mbel_path, "r", encoding="utf-8") as f:
                mbel_grammar = f.read().strip()
        except FileNotFoundError:
            mbel_grammar = "(MBEL grammar file not found)"

    return f"""Compress these memory results into ONE compact MBEL block (max 15 lines).

{mbel_grammar}

Memories to aggregate:
"""


AGGREGATE_PROMPT = _load_aggregate_prompt()


class Classifier:
    """LLM-powered classifier for memory operations.

    Provider-agnostic — takes any LLMClient implementation.
    """

    def __init__(self, llm: LLMClient):
        self._llm = llm
        _bl(f"init: provider={llm.name}")

    async def classify(self, question: str, answer: str) -> ClassificationResult:
        """Classify a Q+A turn into project/topic/activity + structured fields."""
        _bl("classify: starting")
        text = f"Q: {question[:300]}\nA: {answer[:500]}"
        try:
            raw = await self._llm.call(CLASSIFY_PROMPT, text, max_tokens=300)
            _bl(f"classify: got response, len={len(raw)}")
            raw = _strip_code_fences(raw)
            data = json.loads(raw)

            activity_raw = data.get("activity", "discussion")
            try:
                activity = Activity(activity_raw)
            except ValueError:
                activity = Activity.DISCUSSION

            _bl(f"classify: OK → project={data.get('project')}, topic={data.get('topic')}")
            return ClassificationResult(
                project=data.get("project", "general"),
                topic=data.get("topic", "unknown"),
                activity=activity,
                title=data.get("title"),
                summary=data.get("summary"),
                facts=data.get("facts", []),
                concepts=data.get("concepts", []),
                files_touched=data.get("files_touched", []),
            )
        except Exception as e:
            _bl(f"classify: FAILED: {type(e).__name__}: {e}")
            logger.debug("Classification failed: %s", e)
            return ClassificationResult()

    async def is_noise(self, question: str) -> bool:
        """Check if a short message is noise. Defaults to False (keep) on failure."""
        _bl(f"is_noise: checking '{question[:50]}'")
        try:
            raw = await self._llm.call(NOISE_PROMPT, question[:200], max_tokens=40, temperature=0.3)
            raw = _strip_code_fences(raw)
            data = json.loads(raw)
            result = bool(data.get("noise", False))
            _bl(f"is_noise: result={result}")
            return result
        except Exception as e:
            _bl(f"is_noise: FAILED: {e}")
            return False

    async def analyze_query(self, query: str) -> QueryAnalysis:
        """Analyze a search query to extract filters and refine it."""
        _bl(f"analyze_query: '{query[:50]}'")
        try:
            raw = await self._llm.call(QUERY_ANALYZE_PROMPT, query, max_tokens=150)
            raw = _strip_code_fences(raw)
            data = json.loads(raw)
            _bl("analyze_query: OK")
            return QueryAnalysis(
                project=data.get("project"),
                topic=data.get("topic"),
                activity_filter=data.get("activity"),
                time_hint=data.get("time_hint", "any"),
                refined_query=data.get("refined_query", query),
            )
        except Exception as e:
            _bl(f"analyze_query: FAILED: {e}")
            return QueryAnalysis(refined_query=query)

    async def aggregate_to_mbel(self, memories: list[RecalledMemory]) -> str:
        """Aggregate recalled memories into compact MBEL notation."""
        if not memories:
            return "@recall::empty{noMemoriesFound}"

        _bl(f"aggregate_to_mbel: {len(memories)} memories")
        parts = []
        for i, mem in enumerate(memories, 1):
            title = mem.record.topic or mem.record.question[:100]
            doc = mem.record.document or mem.record.answer_preview
            parts.append(f"[{i}] {title}\n{doc}")

        combined = "\n\n".join(parts)
        if len(combined) > 6000:
            combined = combined[:6000]

        try:
            raw = await self._llm.call(AGGREGATE_PROMPT, combined, max_tokens=800)
            lines = [line for line in raw.split("\n") if line.strip()][:10]
            _bl(f"aggregate_to_mbel: OK, {len(lines)} lines")
            return "\n".join(lines)
        except Exception as e:
            _bl(f"aggregate_to_mbel: FAILED: {e}")
            return "\n".join(f"@recall::{m.record.question[:80]}" for m in memories[:5])

    async def resolve_conflict(
        self,
        new_text: str,
        existing_memories: list[tuple],
    ) -> dict:
        """Compare incoming memory against existing similar memories.

        Returns:
            {"action": "add"|"update"|"noop", "target_id": str|None, "reason": str}
        """
        _bl(f"resolve_conflict: checking against {len(existing_memories)} existing")

        for record, similarity, sf in existing_memories:
            title = sf.title if sf else record.question[:80]
            summary = sf.summary if sf else record.answer_preview[:200]
            facts = json.dumps(sf.facts) if sf and sf.facts else "[]"

            prompt = (
                CONFLICT_PROMPT_PREFIX
                + f"Title: {title}\nSummary: {summary}\nFacts: {facts}\n\n"
                + "INCOMING memory:\n"
            )

            try:
                raw = await self._llm.call(prompt, new_text[:500], max_tokens=60, temperature=0.1)
                raw = _strip_code_fences(raw)
                data = json.loads(raw)
                action = data.get("action", "add")
                reason = data.get("reason", "")
                _bl(f"resolve_conflict: vs {record.id} → {action} ({reason})")

                if action in ("update", "noop"):
                    return {"action": action, "target_id": record.id, "reason": reason}
            except Exception as e:
                _bl(f"resolve_conflict: FAILED for {record.id}: {e}")
                continue

        _bl("resolve_conflict: no conflict found → add")
        return {"action": "add", "target_id": None, "reason": "no conflict"}

    async def segment_topics(self, turns: list[str]) -> list[dict]:
        """Segment numbered turns into topic clusters."""
        _bl(f"segment_topics: {len(turns)} turns")
        if not turns:
            return []

        parts = []
        total_chars = 0
        for i, t in enumerate(turns):
            line = f"[{i}] {t[:200]}"
            total_chars += len(line)
            if total_chars > 3000:
                break
            parts.append(line)

        text = "\n".join(parts)
        try:
            raw = await self._llm.call(SEGMENT_PROMPT, text, max_tokens=500, temperature=0.1)
            raw = _strip_code_fences(raw)
            segments = json.loads(raw)
            if not isinstance(segments, list):
                _bl(f"segment_topics: bad response type: {type(segments)}")
                return [{"start": 0, "end": len(turns) - 1, "topic": "unknown"}]

            _bl(f"segment_topics: OK → {len(segments)} segments")
            return segments
        except Exception as e:
            _bl(f"segment_topics: FAILED: {e}")
            return [{"start": 0, "end": len(turns) - 1, "topic": "unknown"}]

    async def summarize_pattern(self, context: str) -> str:
        """Summarize a recurring pattern into MB-style entry."""
        prompt = "Summarize this recurring pattern into a concise Memory Bank entry (MBEL format):\n"
        return await self._llm.call(prompt, context, max_tokens=300)

    async def classify_mb_target(self, concept: str) -> str:
        """Classify which MB file a concept belongs to."""
        prompt = (
            "Which Memory Bank file should this concept go to? "
            "Return ONLY one of: techContext.md, systemPatterns.md, productContext.md, activeContext.md\n"
            "Concept: "
        )
        raw = await self._llm.call(prompt, concept, max_tokens=30, temperature=0.1)
        valid = {"techContext.md", "systemPatterns.md", "productContext.md", "activeContext.md"}
        cleaned = raw.strip().strip('"').strip("'")
        return cleaned if cleaned in valid else "techContext.md"

    async def close(self) -> None:
        await self._llm.close()


# Backward compatibility aliases
GroqClassifier = Classifier
GeminiClassifier = Classifier
