"""Neo Cortex MCP — dual mode memory tools.

Modes (controlled by ENV vars):
    - Proxy (default): forwards to neo-cortex HTTP API on port 5074
    - Embedded: CORTEX_DATA_DIR set → local CortexService + ChromaDB
    - Embedded + subscriber: CORTEX_DATA_DIR + MEMORY_HUB_URL set
      → also runs SignalR subscriber in daemon thread

Usage:
    python -m neo_cortex.mcp          # stdio transport (for Claude Code)
    python -m neo_cortex.mcp --http   # HTTP transport (for testing)
"""

# ============================================================
# BOOT LOGGING — raw file I/O before ANY third-party imports
# ============================================================
import os
import sys
import time

try:
    from importlib.metadata import version as _pkg_version
    __version__ = _pkg_version("neo-cortex-mcp")
except Exception:
    __version__ = "5.1.5"

_data_dir = os.environ.get("CORTEX_DATA_DIR")
_hub_url = os.environ.get("MEMORY_HUB_URL")
_embedded = _data_dir is not None

_log_dir = _data_dir or os.getcwd()
_log_path = os.path.join(_log_dir, "neo-cortex.log")
os.makedirs(_log_dir, exist_ok=True)


def _bl(msg: str) -> None:
    """Boot log — write directly to file before logging module is configured."""
    line = f"{time.strftime('%Y-%m-%d %H:%M:%S')} [BOOT] {msg}\n"
    try:
        with open(_log_path, "a", encoding="utf-8") as f:
            f.write(line)
    except Exception:
        pass


_bl(f"{'='*60}")
_bl(f"neo-cortex-mcp v{__version__} STARTING")
_bl(f"python: {sys.version}")
_bl(f"executable: {sys.executable}")
_bl(f"platform: {sys.platform}")
_bl(f"pid: {os.getpid()}")
_bl(f"cwd: {os.getcwd()}")
_bl(f"CORTEX_DATA_DIR: {_data_dir}")
_bl(f"MEMORY_HUB_URL: {_hub_url}")
_bl(f"JINA_API_KEY: {'SET' if os.environ.get('JINA_API_KEY') else 'NOT SET'}")
_bl(f"GROQ_API_KEY: {'SET' if os.environ.get('GROQ_API_KEY') else 'NOT SET'}")
_bl(f"embedded mode: {_embedded}")

# ============================================================
# OPENSSL FIX — must run BEFORE any ssl/httpx imports
# uv-managed Python on Windows has broken OpenSSL (OPENSSL_Applink crash).
# Root cause: SSLKEYLOGFILE env var (set by security software) + DLL mismatch.
# See: github.com/astral-sh/python-build-standalone/issues/640
# See: github.com/astral-sh/uv/issues/14333
# ============================================================
if sys.platform == "win32":
    _had_keylog = "SSLKEYLOGFILE" in os.environ
    if _had_keylog:
        del os.environ["SSLKEYLOGFILE"]
        _bl(f"OPENSSL FIX: removed SSLKEYLOGFILE env var")
    else:
        _bl("OPENSSL FIX: SSLKEYLOGFILE not set (good)")

    try:
        import ssl as _ssl_mod
        import _ssl
        _openssl_ver = _ssl_mod.OPENSSL_VERSION
        _bl(f"OPENSSL FIX: ssl force-init OK → {_openssl_ver}")
    except Exception as _ssl_err:
        _bl(f"OPENSSL FIX: ssl force-init FAILED: {_ssl_err}")
else:
    _bl("OPENSSL FIX: not Windows, skipping")

# ============================================================
# IMPORTS — logged one by one to find crash point
# ============================================================
_bl("import: logging")
import logging

_bl("import: threading")
import threading

_bl("import: pathlib.Path")
from pathlib import Path

_bl("import: typing.Optional")
from typing import Optional

_bl("import: httpx")
try:
    import httpx
    _bl("import: httpx OK")
except Exception as e:
    _bl(f"import: httpx FAILED: {e}")
    raise

_bl("import: fastmcp.FastMCP")
try:
    from fastmcp import FastMCP
    _bl("import: fastmcp OK")
except Exception as e:
    _bl(f"import: fastmcp FAILED: {e}")
    raise

# ============================================================
# LOGGER setup (now that logging is imported)
# ============================================================
logger = logging.getLogger("neo-cortex-mcp")

# ============================================================
# MODE INIT — embedded or proxy
# ============================================================
_cortex = None
_digestor = None
_init_error = None

if _embedded:
    _bl("embedded: importing subscriber.configure_paths")
    from neo_cortex.subscriber import configure_paths
    _bl("embedded: configure_paths()")
    configure_paths(_data_dir)
    Path(_data_dir).mkdir(parents=True, exist_ok=True)

    try:
        _bl("embedded: importing neo_cortex.config")
        from neo_cortex import config
        _bl(f"embedded: CORTEX_DB_PATH={config.CORTEX_DB_PATH}")
        _bl(f"embedded: MEMORY_INDEX_DB_PATH={config.MEMORY_INDEX_DB_PATH}")
        _bl(f"embedded: CONVERSATION_DB_PATH={config.CONVERSATION_DB_PATH}")

        _bl("embedded: importing GroqClassifier")
        from neo_cortex.classifier import GroqClassifier

        _bl("embedded: importing CortexService")
        from neo_cortex.cortex import CortexService

        _bl("embedded: importing JinaEmbedder")
        from neo_cortex.embedder import JinaEmbedder

        _bl("embedded: importing MemoryIndex")
        from neo_cortex.memory_index import MemoryIndex

        _bl("embedded: importing MemoryStore")
        from neo_cortex.store import MemoryStore

        _bl("embedded: importing v5 components")
        from neo_cortex.conversation_log import ConversationLog
        from neo_cortex.dedup import DedupStore
        from neo_cortex.digestor import EpisodeDigestor
        from neo_cortex.graph import ConceptGraph

        _bl(f"embedded: creating MemoryStore(db_path={config.CORTEX_DB_PATH})")
        store = MemoryStore(config.CORTEX_DB_PATH, config.COLLECTION_NAME)
        _bl(f"embedded: MemoryStore OK, count={store.count()}")

        _bl("embedded: load_api_key('jina')")
        jina_key = config.load_api_key("jina")
        _bl(f"embedded: jina key loaded ({len(jina_key)} chars)")

        _bl(f"embedded: platform={sys.platform}, verify_ssl={sys.platform != 'win32'}")

        _bl("embedded: creating JinaEmbedder")
        embedder = JinaEmbedder(jina_key)
        _bl("embedded: JinaEmbedder OK")

        _bl("embedded: load_api_key('groq')")
        groq_key = config.load_api_key("groq")
        _bl(f"embedded: groq key loaded ({len(groq_key)} chars)")

        _bl("embedded: creating GroqClassifier")
        classifier = GroqClassifier(groq_key)
        _bl("embedded: GroqClassifier OK")

        _bl("embedded: creating MemoryIndex")
        index = MemoryIndex(config.MEMORY_INDEX_DB_PATH)
        _bl(f"embedded: MemoryIndex OK, count={index.count()}")

        # v5 components — all DB files inside cortex_db/
        _db_dir = str(Path(_data_dir) / "cortex_db")
        _dedup_path = str(Path(_db_dir) / "dedup.db")
        _bl(f"embedded: creating DedupStore({_dedup_path})")
        dedup = DedupStore(_dedup_path)
        _bl("embedded: DedupStore OK")

        _graph_path = str(Path(_db_dir) / "concept_graph.json")
        _bl(f"embedded: creating ConceptGraph({_graph_path})")
        graph = ConceptGraph(_graph_path)
        _bl(f"embedded: ConceptGraph OK, nodes={graph.node_count()}")

        _bl("embedded: creating CortexService (v5: dedup+graph)")
        _cortex = CortexService(store, embedder, classifier, index=index, dedup=dedup, graph=graph)
        _bl("embedded: CortexService v5 READY")

        # Digestor
        _bl(f"embedded: creating ConversationLog({config.CONVERSATION_DB_PATH})")
        _conv_log = ConversationLog(config.CONVERSATION_DB_PATH)
        _conv_count = _conv_log.count()
        _bl(f"embedded: ConversationLog OK, entries={_conv_count}")

        _bl("embedded: creating EpisodeDigestor")
        _digestor = EpisodeDigestor(log=_conv_log, cortex=_cortex)
        _bl("embedded: EpisodeDigestor READY — will start background thread in main()")

    except Exception as e:
        _init_error = str(e)
        _bl(f"embedded: INIT FAILED: {e}")
        import traceback
        _bl(traceback.format_exc())
        _cortex = None
else:
    _bl("standalone: no CORTEX_DATA_DIR set — tools will return errors")


def _check_embedded():
    """Raise clear error if embedded mode but cortex init failed."""
    if _embedded and _cortex is None:
        raise RuntimeError(f"CortexService init failed: {_init_error}. Check JINA_API_KEY and GROQ_API_KEY env vars.")


# --- Subscriber thread (embedded + hub_url) ---
def _start_subscriber(hub_url: str, cortex) -> threading.Thread:
    """Start SignalR subscriber in a daemon thread with its own asyncio loop.

    Feeds BOTH paths:
      - Strada 1: cortex.ingest() per turn (noise filter)
      - Strada 2: conversation_log.append() per event (digestor, no noise filter)
    """
    import asyncio as _asyncio

    from neo_cortex.models import ConversationAppendRequest
    from neo_cortex.subscriber import TurnAccumulator

    async def _run():
        from pysignalr.client import SignalRClient

        client = SignalRClient(hub_url)
        accumulator = TurnAccumulator()

        def _append_log(session_id: str, role: str, content: str) -> None:
            """Feed conversation_log (Strada 2) — safe, never throws."""
            if not _conv_log or not content:
                return
            try:
                _conv_log.append(ConversationAppendRequest(
                    session_id=session_id, role=role, content=content,
                ))
            except Exception as e:
                _bl(f"SUBSCRIBER: conv_log append failed: {e}")

        async def on_user_message(args):
            data = args[0] if args else {}
            sid = data.get("sessionId", "")
            text = data.get("text", "")
            accumulator.on_user_message(sid, text)
            _append_log(sid, "user", text)

        async def on_assistant_message(args):
            data = args[0] if args else {}
            sid = data.get("sessionId", "")
            text = data.get("text", "")
            accumulator.on_assistant_message(sid, text, data.get("model", "unknown"))
            _append_log(sid, "assistant", text)

        async def on_tool_use(args):
            data = args[0] if args else {}
            sid = data.get("sessionId", "")
            tool_name = data.get("toolName", "")
            accumulator.on_tool_use(sid, tool_name)
            _append_log(sid, "tool_use", tool_name)

        async def on_tool_result(args):
            data = args[0] if args else {}
            sid = data.get("sessionId", "")
            output = data.get("output", "")
            # Truncate large tool results for conversation_log
            if len(output) > 2000:
                output = output[:2000] + "\n[truncated]"
            _append_log(sid, "tool_result", output)

        async def on_turn_complete(args):
            if not accumulator.is_complete:
                accumulator.reset()
                return
            from neo_cortex.models import IngestRequest
            req = IngestRequest(
                session_id=accumulator.session_id,
                question=accumulator.question,
                answer=accumulator.answer,
                model=accumulator.model,
                tools_used=accumulator.tools_used,
                turn_number=accumulator.turn_number,
            )
            try:
                memory_id = await cortex.ingest(req)
                if memory_id:
                    logger.info("Subscriber ingested: %s (turn %d)", memory_id, accumulator.turn_number)
            except Exception:
                logger.exception("Subscriber ingest failed for turn %d", accumulator.turn_number)
            accumulator.reset()

        client.on("MemoryUserMessage", on_user_message)
        client.on("MemoryAssistantMessage", on_assistant_message)
        client.on("MemoryToolUse", on_tool_use)
        client.on("MemoryToolResult", on_tool_result)
        client.on("MemoryTurnComplete", on_turn_complete)

        _bl(f"SUBSCRIBER: connecting to {hub_url}")
        await client.run()

    def _thread_target():
        try:
            _asyncio.run(_run())
        except Exception as e:
            # CRITICAL: catch ALL exceptions in subscriber thread.
            # An unhandled exception here can crash the entire process.
            _bl(f"SUBSCRIBER: CRASHED: {e}")
            import traceback
            _bl(f"SUBSCRIBER: {traceback.format_exc()}")

    t = threading.Thread(target=_thread_target, daemon=True, name="cortex-subscriber")
    t.start()
    _bl("SUBSCRIBER: thread started")
    return t


# --- MCP server ---
_bl("creating FastMCP server")
mcp = FastMCP(
    "neo-cortex",
    instructions="Neo's memory cortex. Semantic memory search, ingestion, dream cycles, and timeline.",
)
_bl("FastMCP server created")


@mcp.tool()
async def memory_query(query: str, n: int = 5, detail: str = "compact") -> dict:
    """Search past conversation memories by semantic similarity.

    Args:
        query: What to search for (natural language)
        n: Number of results to return (default 5)
        detail: "compact" (MBEL + refs) or "full" (raw JSON for debug)
    """
    _bl(f"TOOL memory_query: query={query!r}, n={n}, detail={detail}")
    try:
        if _cortex:
            result = await _cortex.recall(query, n=n, smart=True)
            _bl("TOOL memory_query: OK")
            if detail == "full":
                return result.model_dump()
            # Compact: MBEL + lightweight refs
            refs = []
            for m in result.memories:
                ref = {
                    "id": m.record.id,
                    "title": m.record.question[:80],
                    "project": m.record.project,
                    "activity": m.record.activity.value,
                    "similarity": round(m.similarity, 3),
                }
                if _cortex.index:
                    sf = _cortex.index.get_structured(m.record.id)
                    if sf and sf.title:
                        ref["title"] = sf.title
                refs.append(ref)
            return {"query": result.query, "count": result.count, "mbel": result.mbel or "", "refs": refs}
        return {"error": "no cortex available"}
    except Exception as e:
        _bl(f"TOOL memory_query: FAILED: {e}")
        import traceback
        _bl(traceback.format_exc())
        return {"error": str(e)}


@mcp.tool()
async def memory_stats(detail: str = "compact") -> dict:
    """Get cortex statistics: total memories, sessions, energy levels, dream count."""
    _bl("TOOL memory_stats")
    try:
        if _cortex:
            if detail == "full":
                result = _cortex.stats()
                _bl("TOOL memory_stats: OK (full)")
                return result.model_dump()
            # Compact: single SQL query via index
            if _cortex.index:
                _bl("TOOL memory_stats: OK (index)")
                return _cortex.index.stats(_cortex.dream_count)
            result = _cortex.stats()
            _bl("TOOL memory_stats: OK (store fallback)")
            return result.model_dump()
        return {"error": "no cortex available"}
    except Exception as e:
        _bl(f"TOOL memory_stats: FAILED: {e}")
        return {"error": str(e)}


@mcp.tool()
async def memory_dream(detail: str = "compact") -> dict:
    """Run one dream cycle: boost high-energy memories, decay others."""
    _bl("TOOL memory_dream")
    try:
        if _cortex:
            result = await _cortex.dream()
            _bl("TOOL memory_dream: OK")
            if detail == "full":
                return result.model_dump()
            return {
                "status": result.status,
                "dream_count": result.dream_count,
                "high_energy_count": len(result.cluster),
            }
        return {"error": "no cortex available"}
    except Exception as e:
        _bl(f"TOOL memory_dream: FAILED: {e}")
        return {"error": str(e)}


@mcp.tool()
async def memory_ingest(turn: dict) -> dict:
    """Manually ingest a conversation turn into memory.

    Args:
        turn: JSON with session_id, question, answer, model, stats
    """
    _bl(f"TOOL memory_ingest: keys={list(turn.keys())}")
    try:
        if _cortex:
            from neo_cortex.models import IngestRequest
            _bl("TOOL memory_ingest: creating IngestRequest")
            req = IngestRequest(**turn)
            _bl("TOOL memory_ingest: calling cortex.ingest")
            memory_id = await _cortex.ingest(req)
            _bl(f"TOOL memory_ingest: OK → {memory_id}")
            return {"status": "ok", "id": memory_id}
        return _post("/api/ingest", data=turn)
    except Exception as e:
        _bl(f"TOOL memory_ingest: FAILED: {e}")
        import traceback
        _bl(traceback.format_exc())
        return {"error": str(e)}


@mcp.tool()
async def memory_timeline(n: int = 10, project: Optional[str] = None, detail: str = "compact") -> dict:
    """Get most recent memories by timestamp (newest first).

    Use this at session start to load recent context, or to see what happened lately.

    Args:
        n: Number of recent memories to return (default 10)
        project: Optional filter by project name
        detail: "compact" (titles only) or "full" (raw JSON for debug)
    """
    _bl(f"TOOL memory_timeline: n={n}, project={project}, detail={detail}")
    try:
        if _cortex:
            if detail == "full":
                result = await _cortex.timeline(n=n, project=project)
                _bl("TOOL memory_timeline: OK (full)")
                return result.model_dump()
            # Compact: use index.timeline() — single SQL, no document blob
            if _cortex.index:
                memories = _cortex.index.timeline(n=n, project=project)
                _bl(f"TOOL memory_timeline: OK (index), {len(memories)} results")
                return {"count": len(memories), "memories": [m.model_dump() for m in memories]}
            # Fallback: store-based but strip heavy fields
            result = await _cortex.timeline(n=n, project=project)
            _bl("TOOL memory_timeline: OK (store fallback)")
            return {"count": result.count, "memories": [
                {"id": m.id, "project": m.project, "topic": m.topic,
                 "activity": m.activity.value if hasattr(m.activity, "value") else m.activity,
                 "timestamp": m.timestamp}
                for m in result.memories
            ]}
        return {"error": "no cortex available"}
    except Exception as e:
        _bl(f"TOOL memory_timeline: FAILED: {e}")
        return {"error": str(e)}


@mcp.tool()
async def memory_search(query: str = "", project: Optional[str] = None,
                        activity: Optional[str] = None, n: int = 10) -> dict:
    """Search memory index. Returns compact results (title/project/date).
    Use memory_get for full details of specific memories.

    Args:
        query: Search text (or empty for recent)
        project: Filter by project name
        activity: Filter by activity type
        n: Max results
    """
    _bl(f"TOOL memory_search: query={query!r}")
    try:
        if _cortex:
            if _cortex.index:
                results = _cortex.index.search_fts(query=query, project=project, activity=activity, n=n)
                _bl(f"TOOL memory_search: OK, {len(results)} results")
                return {"results": [r.model_dump() for r in results]}
            result = await _cortex.recall(query or "recent", n=n)
            return result.model_dump()
        return {"error": "no cortex available"}
    except Exception as e:
        _bl(f"TOOL memory_search: FAILED: {e}")
        return {"error": str(e)}


@mcp.tool()
async def memory_get(ids: str, detail: str = "compact") -> dict:
    """Get full memory details by IDs (comma-separated).

    Args:
        ids: Comma-separated memory IDs (e.g. "v3_abc_1_12345,v3_def_2_67890")
        detail: "compact" (structured fields) or "full" (raw record for debug)
    """
    _bl(f"TOOL memory_get: ids={ids!r}, detail={detail}")
    try:
        if _cortex:
            if not _cortex.index:
                return {"error": "memory_get requires SQLite index in embedded mode"}
            id_list = [i.strip() for i in ids.split(",")]
            if detail == "full":
                records = _cortex.index.get_by_ids(id_list)
                _bl(f"TOOL memory_get: OK (full), {len(records)} records")
                return {"memories": [r.model_dump() for r in records]}
            # Compact: structured fields only, no document blob
            results = []
            for mid in id_list:
                sf = _cortex.index.get_structured(mid)
                if sf:
                    row = _cortex.index.get_by_id(mid)
                    results.append({
                        "id": mid,
                        "title": sf.title,
                        "summary": sf.summary,
                        "facts": sf.facts,
                        "concepts": sf.concepts,
                        "files_touched": sf.files_touched,
                        "project": row.project if row else "general",
                        "activity": row.activity.value if row and hasattr(row.activity, "value") else "discussion",
                        "energy": row.energy if row else 1.0,
                    })
            _bl(f"TOOL memory_get: OK (compact), {len(results)} results")
            return {"memories": results}
        return {"error": "no cortex available"}
    except Exception as e:
        _bl(f"TOOL memory_get: FAILED: {e}")
        return {"error": str(e)}


# --- Entrypoint ---


def _start_digestor(digestor) -> threading.Thread:
    """Start EpisodeDigestor in a daemon thread.

    Each tick drains ALL closed sessions. Then sleeps 30s.
    Open sessions are left alone until they close.
    """
    import asyncio as _asyncio

    async def _run():
        _bl("DIGESTOR: started — processing all closed sessions")
        while True:
            try:
                await digestor.tick()
            except Exception as e:
                _bl(f"DIGESTOR: tick FAILED: {e}")
            await _asyncio.sleep(30)

    def _thread_target():
        try:
            _asyncio.run(_run())
        except Exception as e:
            _bl(f"DIGESTOR: CRASHED: {e}")
            import traceback
            _bl(f"DIGESTOR: {traceback.format_exc()}")

    t = threading.Thread(target=_thread_target, daemon=True, name="cortex-digestor")
    t.start()
    _bl("DIGESTOR: thread started")
    return t


def main():
    _bl("main() called")

    # Configure structured logging (FileHandler + StreamHandler)
    logging.basicConfig(
        level=logging.INFO,
        format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
        handlers=[
            logging.FileHandler(_log_path, encoding="utf-8"),
            logging.StreamHandler(),
        ],
    )

    # Diagnostic summary
    import platform
    try:
        import chromadb as _chromadb
        chroma_ver = _chromadb.__version__
    except Exception:
        chroma_ver = "N/A"

    logger.info(
        "neo-cortex-mcp v%s | python %s | chromadb %s | os=%s | embedded=%s | data_dir=%s",
        __version__, platform.python_version(), chroma_ver,
        platform.system(), _embedded, _data_dir,
    )
    if _cortex:
        logger.info("CortexService v5 OK (dedup+graph+digestor)")
    else:
        logger.error("CortexService FAILED: %s", _init_error)

    # Start subscriber if both env vars set
    if _hub_url and _cortex:
        _start_subscriber(_hub_url, _cortex)

    # Start digestor background thread
    if _digestor and _cortex:
        _start_digestor(_digestor)

    _bl("starting MCP transport")
    if "--http" in sys.argv:
        mcp.run(transport="streamable-http", host="127.0.0.1", port=5075)
    else:
        mcp.run(transport="stdio")


if __name__ == "__main__":
    main()
