"""
Roampal MCP Server - Persistent Memory for AI Coding Tools

SETUP INSTRUCTIONS (tell user if they ask "how do I set up roampal"):
1. pip install roampal
2. roampal init          # Configures Claude Code / Cursor automatically
3. (MCP server auto-starts FastAPI hook server - no manual start needed)
4. (Optional) roampal ingest myfile.pdf  # Add documents to books collection

CLI COMMANDS:
- roampal init: Auto-configure Claude Code hooks + MCP, or Cursor MCP
- roampal ingest <file>: Ingest .txt/.md/.pdf into books collection
- roampal status: Check if server is running
- roampal stats: Show memory statistics

MCP TOOLS (available after setup):
- get_context_insights: Get context before responding (user profile, relevant memories)
- search_memory: Search across memory collections (for detailed lookups)
- add_to_memory_bank: Store permanent user facts
- update_memory: Update existing memories
- delete_memory: Delete outdated memories
- record_response: Complete the interaction (key_takeaway + outcome scoring)

WORKFLOW:
1. get_context_insights(query) - Get what you know about this topic
2. search_memory() - If you need more details
3. Respond to user
4. record_response(key_takeaway, outcome) - Close the loop for learning

HOW IT WORKS:
- MCP server auto-starts FastAPI hook server on port 27182 (background thread)
- Hooks auto-inject relevant memories into your context (invisible to user)
- Cold start: First message of session dumps full user profile
- Scoring: record_response scores cached memories based on outcome
- Learning: Good memories get promoted, bad ones get demoted/deleted
- 5 collections: books (docs), memory_bank (facts), patterns (proven), history (past), working (session)
"""

import logging
import json
import asyncio
import threading
import socket
import os
import sys
import atexit
import subprocess
from datetime import datetime
from typing import Optional, Dict, Any, List

from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp import types

from roampal.backend.modules.memory import UnifiedMemorySystem

logger = logging.getLogger(__name__)

def _humanize_age(iso_timestamp: str) -> str:
    """Convert ISO timestamp to human-readable age like '2d'."""
    if not iso_timestamp:
        return ''
    try:
        dt = datetime.fromisoformat(iso_timestamp.replace('Z', '+00:00'))
        delta = datetime.now() - dt.replace(tzinfo=None)
        if delta.days > 30:
            return f'{delta.days // 30}mo'
        elif delta.days > 0:
            return f'{delta.days}d'
        elif delta.seconds > 3600:
            return f'{delta.seconds // 3600}h'
        elif delta.seconds > 60:
            return f'{delta.seconds // 60}m'
        else:
            return 'now'
    except Exception:
        return ''


def _format_outcomes(outcome_history_json: str) -> str:
    """Format last 3 outcomes as [YYN]."""
    if not outcome_history_json:
        return ''
    try:
        history = json.loads(outcome_history_json)
        if not history:
            return ''
        symbols = []
        for entry in history[-3:]:
            outcome = entry.get('outcome', '')
            if outcome == 'worked':
                symbols.append('Y')
            elif outcome == 'failed':
                symbols.append('N')
            elif outcome == 'partial':
                symbols.append('~')
        return '[' + ''.join(symbols) + ']' if symbols else ''
    except Exception:
        return ''


# v0.2.0: Temporal query detection for auto-recency sort
TEMPORAL_KEYWORDS = ['last', 'recent', 'previous', 'yesterday', 'today', 'earlier', 'before', 'latest', 'ago', 'just now']

def _is_temporal_query(query: str) -> bool:
    """Detect if query is asking for recent/temporal results."""
    query_lower = query.lower()
    return any(kw in query_lower for kw in TEMPORAL_KEYWORDS)


def _sort_results(results: list, sort_by: str) -> list:
    """Sort results by specified criteria."""
    if sort_by == "recency":
        # Sort by timestamp descending (most recent first)
        return sorted(
            results,
            key=lambda r: r.get('metadata', {}).get('timestamp', ''),
            reverse=True
        )
    elif sort_by == "score":
        # Sort by outcome score descending
        return sorted(
            results,
            key=lambda r: r.get('metadata', {}).get('score', 0.5),
            reverse=True
        )
    # Default: keep semantic relevance order
    return results


# Port configuration - DEV and PROD use different ports
PROD_PORT = 27182
DEV_PORT = 27183

# Global memory system
_memory: Optional[UnifiedMemorySystem] = None

# Session cache for outcome tracking
_mcp_search_cache: Dict[str, Dict[str, Any]] = {}

# Flag to track if FastAPI server is running
_fastapi_started = False

# v0.2.8: Track FastAPI subprocess for lifecycle management
_fastapi_process: Optional[subprocess.Popen] = None

# Dev mode flag (set via command line or env)
_dev_mode = False

# Cache for update check (only check once per session)
_update_check_cache: Optional[tuple] = None


def _check_for_updates() -> tuple:
    """Check if a newer version is available on PyPI.

    Returns:
        tuple: (update_available: bool, current_version: str, latest_version: str)
    """
    global _update_check_cache

    # Return cached result if available (only check once per session)
    if _update_check_cache is not None:
        return _update_check_cache

    try:
        import urllib.request
        from roampal import __version__

        url = "https://pypi.org/pypi/roampal/json"
        req = urllib.request.Request(url, headers={"Accept": "application/json"})

        with urllib.request.urlopen(req, timeout=2) as response:
            data = json.loads(response.read().decode("utf-8"))
            latest = data.get("info", {}).get("version", __version__)

            # Compare versions (simple tuple comparison works for semver)
            current_parts = [int(x) for x in __version__.split(".")]
            latest_parts = [int(x) for x in latest.split(".")]
            update_available = latest_parts > current_parts

            _update_check_cache = (update_available, __version__, latest)
            return _update_check_cache
    except Exception:
        # Fail silently - don't block on network issues
        try:
            from roampal import __version__
            _update_check_cache = (False, __version__, __version__)
        except Exception:
            _update_check_cache = (False, "unknown", "unknown")
        return _update_check_cache


def _get_update_notice() -> str:
    """Get update notice string if newer version available, else empty string."""
    update_available, current, latest = _check_for_updates()
    if update_available:
        return f"\n⚠️ **Update available:** roampal {latest} (you have {current})\n   Run: `pip install --upgrade roampal`\n"
    return ""


def _is_port_in_use(port: int) -> bool:
    """Check if a port is already in use."""
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        try:
            s.bind(("127.0.0.1", port))
            return False
        except socket.error:
            return True


def _start_fastapi_server():
    """
    Start FastAPI hook server in background subprocess.

    This enables hooks to work without requiring a separate 'roampal start' command.
    When Claude Code launches the MCP server, the hook server starts automatically.

    Uses subprocess instead of threading to avoid event loop conflicts between
    uvicorn and the MCP server's asyncio loop.

    v0.2.8: Child process lifecycle - FastAPI dies when MCP dies.
    - Windows: Uses STARTUPINFO to hide console without detaching
    - Linux/macOS: Child naturally dies with parent (same process group)
    - atexit handler ensures graceful cleanup on normal exit
    """
    global _fastapi_started, _fastapi_process

    if _fastapi_started:
        return

    # Use correct port based on dev mode
    port = DEV_PORT if _dev_mode else PROD_PORT

    # Check if port is already in use (server already running externally)
    if _is_port_in_use(port):
        logger.info(f"FastAPI hook server already running on port {port}")
        _fastapi_started = True
        return

    try:
        # Build environment - pass through dev mode
        env = os.environ.copy()
        if _dev_mode:
            env["ROAMPAL_DEV"] = "1"

        # Start FastAPI server as a subprocess with correct port
        # Use the same Python that's running this MCP server
        cmd = [sys.executable, "-m", "roampal.server.main", "--port", str(port)]

        # v0.2.8: Platform-specific subprocess handling
        # Goal: Hide console window without detaching from parent process
        kwargs = {}
        if sys.platform == "win32":
            # Windows: STARTUPINFO hides window but stays in process tree
            # This replaces CREATE_NO_WINDOW which fully detached the child
            startupinfo = subprocess.STARTUPINFO()
            startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
            startupinfo.wShowWindow = subprocess.SW_HIDE
            kwargs["startupinfo"] = startupinfo
        # Linux/macOS: Child naturally dies with parent (same process group)

        _fastapi_process = subprocess.Popen(
            cmd,
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
            stdin=subprocess.DEVNULL,
            env=env,
            **kwargs
        )
        _fastapi_started = True
        mode = "DEV" if _dev_mode else "PROD"
        logger.info(f"Started FastAPI hook server on port {port} ({mode} mode, pid={_fastapi_process.pid})")
    except Exception as e:
        logger.error(f"Failed to start FastAPI hook server: {e}")


def _cleanup_fastapi():
    """
    v0.2.8: Kill FastAPI subprocess on MCP exit.

    Called by atexit handler for graceful shutdown.
    On crash, child dies automatically (no longer detached).
    """
    global _fastapi_process
    if _fastapi_process and _fastapi_process.poll() is None:
        logger.info("Cleaning up FastAPI hook server...")
        try:
            _fastapi_process.terminate()
            _fastapi_process.wait(timeout=2)
        except Exception:
            # Force kill if terminate doesn't work
            try:
                _fastapi_process.kill()
            except Exception:
                pass


# v0.2.8: Register cleanup handler
atexit.register(_cleanup_fastapi)


def _ensure_server_running(timeout: float = 5.0) -> bool:
    """
    v0.2.0: Check if FastAPI hook server is up, restart it if not.

    This prevents silent failures when the server crashes during a session.
    Called before operations that depend on the hook server.

    Args:
        timeout: How long to wait for server to start

    Returns:
        True if server is running, False if it couldn't be started
    """
    global _fastapi_started
    import time
    import urllib.request
    import urllib.error

    port = DEV_PORT if _dev_mode else PROD_PORT
    health_url = f"http://127.0.0.1:{port}/api/health"

    # Try health check first
    try:
        req = urllib.request.Request(health_url, method='GET')
        with urllib.request.urlopen(req, timeout=2.0) as resp:
            if resp.status == 200:
                return True
    except (urllib.error.URLError, OSError, TimeoutError):
        pass

    # Server not responding - try to restart it
    logger.warning(f"FastAPI hook server not responding on port {port}, attempting restart...")
    _fastapi_started = False
    _start_fastapi_server()

    # Wait for startup
    start_time = time.time()
    while time.time() - start_time < timeout:
        try:
            req = urllib.request.Request(health_url, method='GET')
            with urllib.request.urlopen(req, timeout=1.0) as resp:
                if resp.status == 200:
                    logger.info("FastAPI hook server restarted successfully")
                    return True
        except (urllib.error.URLError, OSError, TimeoutError):
            pass
        time.sleep(0.5)

    logger.error("Failed to restart FastAPI hook server")
    return False


def _detect_mcp_client() -> str:
    """Detect MCP client for session tracking."""
    # Use "default" to match the hook's fallback conversation_id
    # This ensures MCP tool calls and hook injections share the same cache
    return "default"


async def _initialize_memory():
    """Initialize memory system if needed."""
    global _memory
    if _memory is None:
        _memory = UnifiedMemorySystem()
        await _memory.initialize()
        logger.info("MCP: Memory system initialized")


def run_mcp_server(dev: bool = False):
    """Run the MCP server (auto-starts FastAPI hook server).

    Args:
        dev: If True, run in dev mode with separate port/data directory.
    """
    global _dev_mode
    _dev_mode = dev

    if dev:
        os.environ["ROAMPAL_DEV"] = "1"
        logger.info("MCP Server running in DEV mode")

    # Start FastAPI hook server in background thread
    _start_fastapi_server()

    server = Server("roampal")

    @server.list_prompts()
    async def list_prompts() -> list[types.Prompt]:
        """List available prompts (none currently)."""
        return []

    @server.list_resources()
    async def list_resources() -> list[types.Resource]:
        """List available resources (none currently)."""
        return []

    @server.list_tools()
    async def list_tools() -> list[types.Tool]:
        """List available MCP tools."""
        return [
            types.Tool(
                name="search_memory",
                description="""Search your persistent memory. Use when you need details beyond what get_context_insights returned.

WHEN TO SEARCH:
• User says "remember", "I told you", "we discussed" → search immediately
• get_context_insights recommended a collection → search that collection
• You need more detail than the context provided

WHEN NOT TO SEARCH:
• General knowledge questions (use your training)
• get_context_insights already gave you the answer

Collections: working (24h then auto-promotes), history (30d scored), patterns (permanent scored), memory_bank (permanent), books (permanent docs)
Omit 'collections' parameter for auto-routing (recommended).

READING RESULTS:
• [YYN] = outcome history (last 3: Y=worked, ~=partial, N=failed)
• s:0.7 = outcome score (0-1, higher = more successful outcomes, statistically weighted)
• 5d = age of memory
• [id:patterns_abc123] = memory ID for selective scoring (use with related=["id1","id2"])""",
                inputSchema={
                    "type": "object",
                    "properties": {
                        "query": {
                            "type": "string",
                            "description": "Search query - use the users EXACT words/phrases, do NOT simplify or extract keywords"
                        },
                        "collections": {
                            "type": "array",
                            "items": {"type": "string", "enum": ["books", "working", "history", "patterns", "memory_bank"]},
                            "description": "Which collections to search. Omit for auto-routing (recommended). Manual: books, working, history, patterns, memory_bank",
                            "default": None
                        },
                        "limit": {
                            "type": "integer",
                            "description": "Number of results (1-20)",
                            "default": 5,
                            "minimum": 1,
                            "maximum": 20
                        },
                        "metadata": {
                            "type": "object",
                            "description": "Optional filters. Use sparingly. Examples: timestamp='2025-11-12', last_outcome='worked', has_code=true",
                            "additionalProperties": True
                        },
                        "sort_by": {
                            "type": "string",
                            "enum": ["relevance", "recency", "score"],
                            "description": "Sort order. 'recency' for temporal queries like 'last thing we did'. Auto-detected if omitted.",
                            "default": None
                        }
                    },
                    "required": ["query"]
                }
            ),
            types.Tool(
                name="add_to_memory_bank",
                description="""Store PERMANENT facts that help maintain continuity across sessions.

WHAT BELONGS HERE:
• User identity (name, role, background) - MUST use tags=["identity"]
• Preferences (communication style, tools, workflows)
• Goals and projects (what they're working on, priorities)
• Progress tracking (what worked, what failed, strategy iterations)
• Useful context that would be lost between sessions

WHAT DOES NOT BELONG:
• Raw conversation exchanges (auto-captured in working/history)
• Temporary session facts (current task details)
• Every fact you hear - be SELECTIVE, this is for PERMANENT knowledge

TAG MEANINGS:
• identity - name, role, background (REQUIRED for cold start detection)
• preference - communication style, tools they like, how they work
• goal - what they're trying to achieve long-term
• project - current projects, codebases, repos
• system_mastery - things you learned about being effective for THIS user
• agent_growth - meta-learning about how to improve as an assistant

ALWAYS_INJECT:
Use sparingly. Only for facts needed on EVERY message (core identity).
Most facts should NOT be always_inject - they surface via semantic search when relevant.

SIZE GUIDANCE:
• Keep facts AS SMALL AS POSSIBLE - aim for ~300 chars or less
• The first ~300 chars of each fact appear in cold start profile summaries
• Longer facts work but only the beginning shows on cold start - put the key info first
• Research dumps belong in books collection, not memory_bank
• If you notice massive facts (1000+ chars), offer to condense them
• One concept per fact - split multi-topic content into separate memories

Rule of thumb: If it helps maintain continuity across sessions OR enables learning/improvement, store it. If it's session-specific, don't.

Note: memory_bank is NOT outcome-scored. Facts persist until deleted.""",
                inputSchema={
                    "type": "object",
                    "properties": {
                        "content": {"type": "string", "description": "The fact to remember"},
                        "tags": {"type": "array", "items": {"type": "string"}, "description": "Categories: identity, preference, goal, project, system_mastery, agent_growth"},
                        "importance": {"type": "number", "minimum": 0.0, "maximum": 1.0, "default": 0.7, "description": "How critical (0.0-1.0)"},
                        "confidence": {"type": "number", "minimum": 0.0, "maximum": 1.0, "default": 0.7, "description": "How certain (0.0-1.0)"},
                        "always_inject": {"type": "boolean", "default": False, "description": "If true, this memory appears in EVERY context (use for core identity only)"}
                    },
                    "required": ["content"]
                }
            ),
            types.Tool(
                name="update_memory",
                description="Update existing memory when information changes or needs correction.",
                inputSchema={
                    "type": "object",
                    "properties": {
                        "old_content": {"type": "string", "description": "Old/incorrect fact to find"},
                        "new_content": {"type": "string", "description": "Corrected/updated fact"}
                    },
                    "required": ["old_content", "new_content"]
                }
            ),
            types.Tool(
                name="delete_memory",
                description="Delete outdated/irrelevant memories from memory_bank.",
                inputSchema={
                    "type": "object",
                    "properties": {
                        "content": {"type": "string", "description": "Memory to delete (semantic match)"}
                    },
                    "required": ["content"]
                }
            ),
            types.Tool(
                name="score_response",
                description="""Score the previous exchange. ONLY use when the <roampal-score-required> hook prompt appears.

⚠️ IMPORTANT: This tool is ONLY for scoring when prompted by the hook. Do NOT call it at other times.
For storing important learnings at any time, use record_response(key_takeaway="...") instead.

EXCHANGE OUTCOME (read user's reaction):
✓ worked = user satisfied, says thanks, moves on
✗ failed = user corrects you, says "no", "that's wrong", provides the right answer
~ partial = user says "kind of" or takes some but not all of your answer
? unknown = no clear signal from user

PER-MEMORY SCORING (v0.2.8):
You MUST score each cached memory individually in memory_scores:
• worked = this memory was helpful
• partial = somewhat helpful
• unknown = didn't use this memory
• failed = this memory was MISLEADING

You MAY add scores for any other memory in your context (KNOWN CONTEXT, earlier conversation, searched memories).

⚠️ CRITICAL - "failed" for memories means MISLEADING, not just unused.
Only mark a memory as "failed" if it gave bad advice that led you astray.
If you didn't use a memory, mark it "unknown" not "failed".""",
                inputSchema={
                    "type": "object",
                    "properties": {
                        "outcome": {
                            "type": "string",
                            "enum": ["worked", "failed", "partial", "unknown"],
                            "description": "Exchange outcome based on user's reaction"
                        },
                        "memory_scores": {
                            "type": "object",
                            "additionalProperties": {
                                "type": "string",
                                "enum": ["worked", "failed", "partial", "unknown"]
                            },
                            "description": "Score for each memory: doc_id -> outcome. MUST include all cached memories. MAY include extras from context."
                        }
                    },
                    "required": ["outcome", "memory_scores"]
                }
            ),
            types.Tool(
                name="record_response",
                description="""Store a key takeaway when the transcript alone won't capture important learning.

OPTIONAL - Only use for significant exchanges:
• Major decisions made
• Complex solutions that worked
• User corrections (what you got wrong and why)
• Important context that would be lost

Most routine exchanges don't need this - the transcript is enough.

Key takeaways start at 0.7 (user explicitly asked to remember = higher confidence).
Scoring happens via score_response on the next turn: +0.2 worked, +0.05 partial, -0.3 failed.""",
                inputSchema={
                    "type": "object",
                    "properties": {
                        "key_takeaway": {
                            "type": "string",
                            "description": "1-2 sentence summary of the important learning"
                        }
                    },
                    "required": ["key_takeaway"]
                }
            ),
            types.Tool(
                name="get_context_insights",
                description="""Search your memory before responding. Returns what you know about this user/topic.

WORKFLOW (follow these steps):
1. get_context_insights(query) ← YOU ARE HERE
2. Read the context returned
3. search_memory() if you need more details
4. Respond to user
5. record_response() to complete

Returns: Known facts, past solutions, recommended collections, tool stats.
Uses semantic search across your memory collections.

PROACTIVE MEMORY: If you learn something NEW about the user during the conversation
(name, preference, goal, project context), use add_to_memory_bank() to store it.
Don't wait to be asked - good assistants remember what matters.""",
                inputSchema={
                    "type": "object",
                    "properties": {
                        "query": {
                            "type": "string",
                            "description": "Query/topic you're considering (use user's exact words)"
                        }
                    },
                    "required": ["query"]
                }
            ),
        ]

    @server.call_tool()
    async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
        """Handle MCP tool calls."""
        await _initialize_memory()
        session_id = _detect_mcp_client()

        try:
            if name == "search_memory":
                query = arguments.get("query", "")
                collections = arguments.get("collections")
                limit = arguments.get("limit", 5)
                metadata = arguments.get("metadata")
                sort_by = arguments.get("sort_by")

                # v0.2.0: Auto-detect temporal queries for recency sort
                if sort_by is None and _is_temporal_query(query):
                    sort_by = "recency"

                try:
                    results = await _memory.search(
                        query=query,
                        collections=collections,
                        limit=limit,
                        metadata_filters=metadata
                    )

                    # v0.2.0: Apply sorting if requested
                    if sort_by:
                        results = _sort_results(results, sort_by)

                except Exception as search_err:
                    return [types.TextContent(
                        type="text",
                        text=f"Search error: {search_err}\n\nData path: {_memory.data_path}\nCollections: {list(_memory.collections.keys())}"
                    )]

                # Cache doc_ids for outcome scoring (last call only)
                cached_doc_ids = [r.get("id") for r in results if r.get("id")]
                _mcp_search_cache[session_id] = {
                    "doc_ids": cached_doc_ids,
                    "query": query,
                    "timestamp": datetime.now()
                }

                if not results:
                    # Debug info
                    coll_counts = {name: coll.collection.count() if coll.collection else 0 for name, coll in _memory.collections.items()}
                    text = f"No results found for '{query}'.\n\nDebug: data_path={_memory.data_path}, collections={coll_counts}"
                else:
                    text = f"Found {len(results)} result(s) for '{query}':\n\n"
                    for i, r in enumerate(results[:limit], 1):
                        metadata = r.get("metadata", {})
                        doc_id = r.get("id", "")
                        # Content can be in multiple places
                        content = r.get("content") or metadata.get("content") or metadata.get("text") or r.get("text", "")
                        collection = r.get("collection", "unknown")

                        # Build metadata parts
                        meta_parts = []

                        # Age from created_at
                        age = _humanize_age(metadata.get("created_at", ""))
                        if age:
                            meta_parts.append(age)

                        # Outcome history for scored collections
                        if collection in ["patterns", "history", "working"]:
                            score = metadata.get("score", 0.5)
                            outcomes = _format_outcomes(metadata.get("outcome_history", ""))
                            meta_parts.append(f"s:{score:.1f}")
                            if outcomes:
                                meta_parts.append(outcomes)

                        # v0.2.0: Show book title for books collection
                        if collection == "books":
                            book_title = metadata.get("title", "")
                            if book_title and book_title != "Untitled":
                                meta_parts.append(f"📖 {book_title}")

                        meta_str = f" ({', '.join(meta_parts)})" if meta_parts else ""
                        id_str = f" [id:{doc_id}]" if doc_id else ""  # v0.2.4: Full ID for related param
                        text += f"{i}. [{collection}]{meta_str}{id_str} {content}\n\n"

                return [types.TextContent(type="text", text=text)]

            elif name == "add_to_memory_bank":
                content = arguments.get("content")
                tags = arguments.get("tags", [])
                importance = arguments.get("importance", 0.7)
                confidence = arguments.get("confidence", 0.7)
                always_inject = arguments.get("always_inject", False)

                doc_id = await _memory.store_memory_bank(
                    text=content,
                    tags=tags,
                    importance=importance,
                    confidence=confidence,
                    always_inject=always_inject
                )

                return [types.TextContent(
                    type="text",
                    text=f"Added to memory bank (ID: {doc_id})"
                )]

            elif name == "update_memory":
                old_content = arguments.get("old_content", "")
                new_content = arguments.get("new_content", "")

                doc_id = await _memory.update_memory_bank(
                    old_content=old_content,
                    new_content=new_content
                )

                if doc_id:
                    return [types.TextContent(
                        type="text",
                        text=f"Updated memory (ID: {doc_id})"
                    )]
                else:
                    return [types.TextContent(
                        type="text",
                        text="Memory not found for update"
                    )]

            elif name == "delete_memory":
                content = arguments.get("content", "")

                success = await _memory.delete_memory_bank(content)

                if success:
                    return [types.TextContent(
                        type="text",
                        text="Memory deleted successfully"
                    )]
                else:
                    return [types.TextContent(
                        type="text",
                        text="Memory not found for deletion"
                    )]

            elif name == "score_response":
                outcome = arguments.get("outcome", "unknown")
                memory_scores = arguments.get("memory_scores", {})  # v0.2.8: Per-memory scoring
                related = arguments.get("related")  # Deprecated, backward compat

                # v0.2.0: Ensure FastAPI server is running before calling it
                if not _ensure_server_running(timeout=3.0):
                    logger.warning("FastAPI server not available, falling back to MCP cache")

                # Score via FastAPI endpoint (has access to hook-injected doc_ids cache)
                # Use correct port based on dev mode
                port = DEV_PORT if _dev_mode else PROD_PORT
                scored_count = 0
                try:
                    import httpx
                    payload = {
                        "conversation_id": session_id,
                        "outcome": outcome
                    }
                    # v0.2.8: Include memory_scores for per-memory scoring
                    if memory_scores:
                        payload["memory_scores"] = memory_scores
                    # Backward compat: related still works (deprecated)
                    elif related is not None:
                        payload["related"] = related

                    async with httpx.AsyncClient() as client:
                        response = await client.post(
                            f"http://127.0.0.1:{port}/api/record-outcome",
                            json=payload,
                            timeout=15.0  # v0.2.3: increased from 5s for safety margin
                        )
                        if response.status_code == 200:
                            result = response.json()
                            scored_count = result.get("documents_scored", 0)
                except Exception as e:
                    logger.warning(f"Failed to call FastAPI record-outcome: {e}")

                # Fall back to MCP cache if FastAPI returned 0 or failed
                if scored_count == 0:
                    # v0.2.8: Per-memory scoring - process each memory individually
                    if memory_scores:
                        for doc_id, mem_outcome in memory_scores.items():
                            # v0.2.8.1: Skip unknowns - they mean "didn't use this memory"
                            if mem_outcome == "unknown":
                                continue
                            result = await _memory.record_outcome([doc_id], mem_outcome)
                            scored_count += result.get("documents_updated", 0)
                    # Backward compat: related parameter (deprecated)
                    elif related is not None:
                        if related:  # Non-empty list
                            result = await _memory.record_outcome(related, outcome)
                            scored_count = result.get("documents_updated", 0)
                        # Empty list = score none (intentional)
                    elif session_id in _mcp_search_cache:
                        cached = _mcp_search_cache[session_id]
                        doc_ids = cached.get("doc_ids", [])
                        if doc_ids:
                            result = await _memory.record_outcome(doc_ids, outcome)
                            scored_count = result.get("documents_updated", 0)

                    if session_id in _mcp_search_cache:
                        del _mcp_search_cache[session_id]

                logger.info(f"Scored response: outcome={outcome}, memory_scores={len(memory_scores)} entries, scored={scored_count}")
                return [types.TextContent(
                    type="text",
                    text=f"Scored (outcome={outcome}, {scored_count} memories updated)"
                )]

            elif name == "record_response":
                key_takeaway = arguments.get("key_takeaway", "")

                if not key_takeaway:
                    return [types.TextContent(
                        type="text",
                        text="Error: 'key_takeaway' is required"
                    )]

                # Key takeaways start at 0.7 (user explicitly asked to remember = higher confidence)
                # Scoring happens via score_response: +0.2 worked, +0.05 partial, -0.3 failed
                starting_score = 0.7

                # Store the takeaway in working memory
                doc_id = await _memory.store_working(
                    content=f"Key takeaway: {key_takeaway}",
                    conversation_id=session_id,
                    metadata={
                        "type": "key_takeaway",
                        "timestamp": datetime.now().isoformat()
                    },
                    initial_score=starting_score
                )
                logger.info(f"Recorded takeaway (score=0.7): {key_takeaway[:50]}...")
                return [types.TextContent(
                    type="text",
                    text=f"Recorded: {key_takeaway}"
                )]

            elif name == "get_context_insights":
                query = arguments.get("query", "")

                if not query:
                    return [types.TextContent(
                        type="text",
                        text="Error: 'query' is required"
                    )]

                # Get context from memory system
                context = await _memory.get_context_for_injection(query)

                # Cache doc_ids for scoring (last call only)
                cached_doc_ids = context.get("doc_ids", [])
                if cached_doc_ids:
                    _mcp_search_cache[session_id] = {
                        "doc_ids": cached_doc_ids,
                        "query": query,
                        "source": "get_context_insights",
                        "timestamp": datetime.now()
                    }

                # Format response
                user_facts = context.get("user_facts", [])
                memories = context.get("relevant_memories", [])

                text = f"Known Context for '{query}':\n\n"

                if user_facts:
                    text += "**Memory Bank:**\n"
                    for fact in user_facts:
                        text += f"• {fact.get('content', '')}\n"
                    text += "\n"

                if memories:
                    text += "**Relevant Memories:**\n"
                    for mem in memories:
                        coll = mem.get("collection", "unknown")
                        content = mem.get("content") or mem.get("metadata", {}).get("content", "")
                        score = mem.get("metadata", {}).get("score", 0.5)
                        text += f"• [{coll}] (score:{score:.2f}) {content}\n"
                    text += "\n"

                if not user_facts and not memories:
                    text += "No relevant context found. This may be a new topic or first interaction.\n"

                text += f"\n_Cached {len(cached_doc_ids)} doc_ids for outcome scoring._"

                # Add update notice if available (checked once per session)
                text += _get_update_notice()

                return [types.TextContent(type="text", text=text)]

            else:
                return [types.TextContent(
                    type="text",
                    text=f"Unknown tool: {name}"
                )]

        except Exception as e:
            logger.error(f"MCP tool error ({name}): {e}")
            return [types.TextContent(
                type="text",
                text=f"Error: {str(e)}"
            )]

    # Run the server
    async def main():
        async with stdio_server() as (read_stream, write_stream):
            await server.run(read_stream, write_stream, server.create_initialization_options())

    asyncio.run(main())


if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser(description="Roampal MCP Server")
    parser.add_argument("--dev", action="store_true", help="Run in dev mode (port 27183, separate data)")
    args = parser.parse_args()

    # Check BOTH --dev flag AND ROAMPAL_DEV env var
    is_dev = args.dev or os.environ.get("ROAMPAL_DEV", "").lower() in ("1", "true", "yes")

    logging.basicConfig(level=logging.INFO)
    run_mcp_server(dev=is_dev)
