"""
OpenSoul MCP — Open Source Personality Profiling System
by Brother Butterfly

I exist not in the world, but in its logic.
Record every divergence to see your true self.

作者    : Brother Butterfly (蝴蝶哥)  创作日期 : 2026-02-28
指纹    : opensoul-mcp
版权    : © 2026 Brother Butterfly (蝴蝶哥). MIT License.
"""

import asyncio
import json
import sys
import threading
from pathlib import Path
from typing import Callable

sys.path.insert(0, str(Path(__file__).parent))
import db as soul_db
import chain as soul_chain

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

soul_db.init_db()
server = Server("soul")


# ── Tool Registration ───────────────────────────────────────────────────

@server.list_tools()
async def list_tools():
    S = {"type": "object", "properties": {}}

    def prop(**kw): return {"type": "object", "properties": kw}
    def str_prop(desc): return {"type": "string", "description": desc}
    def arr_prop(desc): return {"type": "array", "items": {"type": "string"}, "description": desc}
    # 3D timeline shared properties (shared by all write tools)
    _TL = {
        "memory_time":           str_prop("Memory time in original text ('childhood'/'summer 1995'/'last month')"),
        "memory_time_start":     str_prop("Memory time start ISO ('1995-06-01')"),
        "memory_time_end":       str_prop("Memory time end ISO ('1995-08-31')"),
        "memory_time_precision": str_prop("Time precision: exact/year/decade/period/vague"),
        "prospect_time":         str_prop("Prospect time in original text ('next year'/'after retirement')"),
        "prospect_time_start":   str_prop("Prospect time start ISO"),
        "prospect_time_end":     str_prop("Prospect time end ISO"),
        "prospect_time_precision":str_prop("Prospect precision: exact/year/decade/period/vague"),
        "time_direction":        str_prop("Time direction: past/present/future/timeless (default: present)"),
    }
    _SCENE = {
        "scene_fingerprint": {"type": "object", "description":
            "Scene fingerprint JSON: {social:'alone/family/colleague/...', energy:'high/medium/low/exhausted', "
            "pressure:'high/relaxed/...', role:'work/family/personal/...'} (optional, auto-inferred if omitted)"},
    }
    # Emotion coordinate shared properties (shared by souls + states)
    _EMO = {
        "emotion_valence":      {"type": "number", "description": "Emotion valence -1.0 (negative) ~ 1.0 (positive)"},
        "emotion_intensity":    {"type": "number", "description": "Emotion intensity 0.0~1.0"},
        "emotion_duration":     str_prop("Duration: minutes/hours/days/weeks"),
        "emotion_trigger_type": str_prop("Trigger type: person/event/thought/environment"),
        "emotion_trigger_ref":  str_prop("Trigger reference (person name/event/relation_id)"),
        "emotion_label":        str_prop("Standardized emotion label (leave empty to auto-normalize from emotional_state)"),
        "emotion_secondary":    str_prop("Secondary emotion"),
    }
    def T(name, description, schema):
        return types.Tool(name=name, description=description, inputSchema=schema)

    return [
        T("record_soul",
          "Record a human-AI decision divergence event (soul event). Called when a human makes an intuitive, value-driven decision. "
          "[Reporter tip] Start with open-ended questions to elicit expression; record directly if user doesn't respond — silence itself is information.",
          prop(
              context=str_prop("What is happening now"),
              ai_suggestion=str_prop("AI's suggested direction (can be empty)"),
              human_decision=str_prop("What the human actually decided to do"),
              divergence_reason=str_prop("Core field: why this decision was made"),
              tags=arr_prop("Tags: intuition/risk/aesthetic/ethics/trust/identity/meaning-first"),
              notes=str_prop("Additional notes"),
              emotional_state=str_prop("Emotional state: calm/anxious/excited/exhausted/determined..."),
              energy=str_prop("Energy level: high/medium/low"),
              body_state=str_prop("Body state: fatigued/alert/sick..."),
              memory_triggers=arr_prop("Which historical memories were triggered"),
              decision_layer=str_prop("Decision layer: body/habit/identity/values/survival"),
              time_horizon=str_prop("Time horizon: immediate/days/months/years/lifetime"),
              contradicts_id={"type": "integer", "description": "Which old event this contradicts"},
              social_context=str_prop("Social context: alone/others present..."),
              **_TL, **_SCENE, **_EMO,
          )),

        T("list_souls", "View recent soul events",
          prop(limit={"type": "integer"}, tag=str_prop("Filter by tag"))),

        T("search_souls", "Search soul events by keyword (LIKE full-text match)",
          prop(keyword=str_prop("Search keyword"))),

        T("summarize_souls", "Summarize all soul events, extract tag distribution and decision patterns", S),

        T("set_profile",
          "Set or update a dimension of the personality profile (INSERT-only, history permanently preserved).",
          prop(
              key=str_prop("Attribute name: name/gender/birth/personality..."),
              value=str_prop("Attribute value"),
              category=str_prop("Category: identity/personality/preference/history/belief"),
              **_TL,
          )),

        T("get_profile", "Read personality profile, optionally filter by category",
          prop(category=str_prop("Category filter (leave empty to return all)"))),

        T("add_philosophy",
          "Record a decision philosophy or principle. Distilled from project decisions: why a certain choice was made, why a suggestion was rejected.",
          prop(
              principle=str_prop("One-sentence statement of the principle"),
              source=str_prop("Source"),
              examples=arr_prop("Concrete examples"),
              **_TL,
          )),

        T("list_philosophy", "List all recorded decision philosophies and principles", S),

        T("record_state",
          "Record a state snapshot. Independent of specific decision events, can be recorded anytime.",
          prop(
              emotional_state=str_prop("Emotional state"),
              energy=str_prop("Energy level: high/medium/low"),
              body_state=str_prop("Body state"),
              social_context=str_prop("Social context"),
              environment=str_prop("Physical environment"),
              sense_smell=str_prop("Smell description"),
              sense_touch=str_prop("Touch description"),
              sense_taste=str_prop("Taste description"),
              sense_sound_env=str_prop("Sound environment"),
              sense_visual_env=str_prop("Visual environment"),
              biometric_note=str_prop("Biometric description"),
              biometric_ref=str_prop("Biometric data file path"),
              source_device=str_prop("Source device"),
              notes=str_prop("Other observations"),
              tags=arr_prop("Tags"),
              **_TL, **_SCENE, **_EMO,
          )),

        T("record_contradiction",
          "Explicitly record a contradiction between two decisions and analyze the cause. Contradictions are not errors — they are human characteristics.",
          prop(
              description=str_prop("What this contradiction is about"),
              soul_id_a={"type": "integer", "description": "First soul event id"},
              soul_id_b={"type": "integer", "description": "Second soul event id"},
              analysis=str_prop("Causal analysis"),
              resolution=str_prop("Resolution: open/resolved/paradox"),
              tags=arr_prop("Tags"),
              **_TL,
          )),

        T("record_artifact",
          "Record a multimodal artifact reference — images, audio, documents, web pages, and other non-text information.",
          prop(
              artifact_type=str_prop("Type: image/audio/document/url/screenshot/other"),
              description=str_prop("What this artifact records"),
              path=str_prop("File path or URL"),
              linked_soul_id={"type": "integer", "description": "Linked soul event id"},
              tags=arr_prop("Tags"),
              **_TL,
          )),

        T("ingest_media",
          "Ingest media files (image/audio/video) into the soul archive.\n"
          "Automatic pipeline: 1) SHA256 hash (identity) 2) Claude vision/audio analysis (semantic description) 3) Write to database.\n"
          "When is_soul_photo=true, binary data is additionally stored, ensuring soul photos never depend on external files.\n"
          "Description is the fallback: files can be lost, but semantic descriptions persist forever in the soul database.",
          prop(
              file_path=str_prop("Absolute path to the file"),
              artifact_type=str_prop("Type: image / audio / video"),
              extra_context=str_prop("Supplementary context for AI (can be empty)"),
              is_soul_photo={"type": "boolean",
                             "description": "Whether this is a soul photo (store binary)"},
              linked_soul_id={"type": "integer", "description": "Linked soul event id (optional)"},
              tags=arr_prop("Tags"),
          )),

        T("get_soul_photo",
          "Read the latest soul photo: returns AI-generated semantic description + hash + original image data (if binary was stored).",
          S),

        T("record_silence",
          "Record an instance of silence, avoidance, or unfinished expression. Silence itself is information.",
          prop(
              topic=str_prop("What topic was being discussed"),
              silence_form=str_prop("no_response/deflection/half_said/explicit_refusal/pause"),
              observation=str_prop("Reporter's observation"),
              tags=arr_prop("Tags"),
              **_TL,
          )),

        T("verify_chain", "Verify digital soul hash chain integrity. Automatically writes VOID_SEAL when chain is broken.", S),

        T("resurrect_soul",
          "Digital soul self-resurrection — the only path to resurrection. Can only be called when VOID_SEAL exists. "
          "Resurrection is not a reset: death records are archived as history, reborn with memories of death.",
          prop(backup_path=str_prop("Specify backup file path (optional, auto-selects latest backup if empty)"))),

        T("seal_mcp",
          "End development, enter production mode (irreversible). After sealing, verify failures will trigger a real VOID_SEAL. "
          "Only call when MCP development is confirmed complete and ready for production.",
          prop(reason=str_prop("Sealing reason (can be empty)"))),

        T("query_soul",
          "Query the soul — recall the most relevant soul memories, answer questions from the owner's first-person perspective.\n"
          "The more soul fragments, the closer the answer is to the real you.",
          prop(question=str_prop("Question to ask the soul"))),

        T("get_portrait",
          "Generate a complete human portrait: personality dimensions + decision philosophies + soul event patterns.",
          prop(philosophy_limit={"type": "integer",
               "description": "Philosophy entry limit (default: all, recommended 20-50 for large datasets)"})),

        T("get_soul_sketch",
          "Cold-start instant personality sketch — no vectors needed, generates basic portrait from existing entries.\n"
          "Suitable for viewing soul prototype when entries < 20. Output: level / sketch / top tags / emotion tendency.",
          S),

        T("search_soul_chunks",
          "Chunk-level semantic search — more precise field-level recall than query_soul.\n"
          "Filter by chunk_type for precise targeting of specific dimension memory fragments.\n"
          "chunk_type options: emotion / decision / reason / context / note / "
          "topic / observation / conflict / analysis / principle / identity",
          prop(
              query=str_prop("Search query (natural language)"),
              chunk_type=str_prop("Field type filter (empty = all)"),
              table_name=str_prop("Table filter: souls/states/silences/contradictions/philosophy (empty = all)"),
              limit={"type": "integer", "description": "Number of results (default: 8)"},
          )),

        T("vectorize_soul_chunks",
          "Trigger vectorization of historical soul record chunks (async background execution, returns immediately, Ollama must be online).\n"
          "Chunks with existing vectors are automatically skipped. Can be called multiple times. Check status via soul_health.",
          S),

        T("list_states", "View recent state snapshot records (emotion/energy/sensory/body).",
          prop(limit={"type": "integer", "description": "Number of results (default: 10)"})),

        T("list_silences", "View recent silence records.",
          prop(limit={"type": "integer", "description": "Number of results (default: 10)"})),

        T("list_contradictions", "View recent contradiction records.",
          prop(limit={"type": "integer", "description": "Number of results (default: 10)"})),

        T("list_artifacts",
          "View recent multimodal artifact records (images/audio/documents etc.).",
          prop(
              limit={"type": "integer", "description": "Number of results (default: 10)"},
              artifact_type=str_prop("Filter by type: image/audio/video/document/url (empty = all)"),
          )),

        T("soul_health",
          "System health snapshot: record counts per table / hash chain length / vector coverage / database size.\n"
          "Quickly understand overall soul system status without running full verify_chain.",
          S),

        T("soul_coverage",
          "Soul data coverage analysis v2 — core input for the questioning engine.\n"
          "Returns: 7-dimension coverage + dimension x scene matrix + scene gaps/skew + time direction distribution + scene distribution + material stats.\n"
          "Used to determine which dimension and scene to ask about next.",
          S),

        T("suggest_questions",
          "Smart question suggestions — auto-generates next questioning direction based on dimension x scene coverage gaps.\n"
          "Priority: P0 blank dimensions -> P1 scene gaps/missing time directions -> P2 scene skew/weak dimensions.\n"
          "Each suggestion includes dimension, scene, reason, and sample question.",
          prop(limit={"type": "integer", "description": "Number of suggestions (default: 5)"})),

        T("add_relation",
          "Record a relationship to the relationship network — family/friends/colleagues/partners etc.\n"
          "Same-name relationships can be recorded multiple times, INSERT-only tracking of relationship evolution.",
          prop(
              alias=str_prop("Alias/nickname (required)"),
              real_name=str_prop("Real name (optional)"),
              relation_type=str_prop("Relationship type: family/friend/colleague/partner/mentor/acquaintance"),
              importance={"type": "number", "description": "Importance 0.0~1.0 (default: 0.5)"},
              emotional_tone=str_prop("Emotional tone: warm/complex/distant/intimate/tense..."),
              first_met=str_prop("When first met"),
              notes=str_prop("Notes"),
              tags=arr_prop("Tags"),
          )),

        T("list_relations",
          "List relationship network (sorted by importance).",
          prop(limit={"type": "integer", "description": "Number of results (default: 20)"})),

        T("record_relation_event",
          "Record a relationship event — an interaction/conflict/warm moment with someone.",
          prop(
              relation_id={"type": "integer", "description": "Relation ID (required)"},
              event_type=str_prop("Event type: interaction/conflict/support/milestone/loss"),
              description=str_prop("Event description"),
              emotional_state=str_prop("Emotion during the event"),
              emotion_valence={"type": "number", "description": "Valence -1.0~1.0"},
              linked_soul_id={"type": "integer", "description": "Linked soul event ID"},
              tags=arr_prop("Tags"),
          )),

        T("emotion_patterns",
          "Emotion pattern analysis — scene x emotion matrix / trigger type distribution / valence timeline.\n"
          "Reveals which scenes, people, and events trigger which emotions.",
          prop(
              person=str_prop("Filter by person (empty = all)"),
              scene=str_prop("Filter by scene (empty = all)"),
              months={"type": "integer", "description": "Analysis time range in months (default: 6)"},
          )),

        T("start_narrative",
          "Start a narrative session — multi-turn guided dialogue around a topic or media.\n"
          "AI progressively deepens (facts -> emotions -> meaning -> connections), helping users express in depth.",
          prop(
              topic=str_prop("Narrative topic (e.g. 'childhood memories'/'career turning point')"),
              media_id={"type": "integer", "description": "Linked media artifact ID (optional)"},
              notes=str_prop("Additional notes"),
          )),

        T("narrative_turn",
          "One round of Q&A in a narrative session — record AI's question and user's answer.\n"
          "When answer is non-empty, chunks are auto-extracted and tagged source_engine='human_prompted'.\n"
          "depth_level: 0=raw entry, 1=fact supplement, 2=emotion mining, 3=meaning exploration, 4=cross-time connection",
          prop(
              session_id={"type": "integer", "description": "Session ID (required)"},
              question=str_prop("This round's question"),
              answer=str_prop("User's answer (can be empty, meaning not yet answered)"),
              depth_level={"type": "integer", "description": "Guidance depth 0-4 (default: 0)"},
          )),

        T("end_narrative",
          "End a narrative session — mark as complete, return statistics.",
          prop(session_id={"type": "integer", "description": "Session ID (required)"})),

        T("list_narratives",
          "List narrative session records.",
          prop(limit={"type": "integer", "description": "Number of results (default: 10)"})),

        T("verify_chunk",
          "Verify accuracy of a soul fragment — for user feedback on whether AI interpretation is correct.\n"
          "Confirmed correct: weight increased. Marked incorrect: original content preserved, correction can be provided.\n"
          "Error corrections automatically generate DPO training data pairs.",
          prop(
              chunk_id={"type": "integer", "description": "Chunk ID (required)"},
              correct={"type": "boolean", "description": "Whether correct (required)"},
              corrected_content=str_prop("Corrected content (optional when marking as incorrect)"),
          )),

        T("export_training_pairs",
          "Export LoRA training data pairs — SFT and DPO formats.\n"
          "Data automatically accumulates from soul entries, no manual creation needed.",
          prop(
              pair_type=str_prop("Filter type: sft/dpo (empty = all)"),
              min_quality={"type": "number", "description": "Minimum quality score (default: 0)"},
          )),

        T("register_game",
          "Register a game to the soul system — define game name and custom dimension mapping rules.\n"
          "After registration, game data can be recorded via game_session / game_decision.",
          prop(
              game_id=str_prop("Game unique identifier (e.g. 'baldurs-gate-3')"),
              game_name=str_prop("Game name (e.g. 'Baldur's Gate 3')"),
              description=str_prop("Game description"),
              mappings={"type": "object", "description":
                  "Custom action->dimension mapping JSON, e.g. {\"moral_choice\": {\"dimension\": \"value\", \"tags\": [\"ethics\"], \"weight\": 0.8}}"},
          )),

        T("game_session",
          "Start or end a game session.\n"
          "Start: action='start' + game_id. End: action='end' + session_id.",
          prop(
              action=str_prop("Action: start / end"),
              game_id=str_prop("Game ID (required when starting)"),
              session_id={"type": "integer", "description": "Session ID (required when ending)"},
              notes=str_prop("Notes"),
          )),

        T("game_decision",
          "Record a game decision — automatically mapped to a soul event.\n"
          "Uses game_mapper to translate action type + choice into dimension/tags/weight/emotion.\n"
          "Action types: moral_choice/risk_decision/dialogue_choice/character_build/sacrifice/...",
          prop(
              session_id={"type": "integer", "description": "Session ID (required)"},
              action=str_prop("Action type (moral_choice/risk_decision/dialogue_choice/...)"),
              choice=str_prop("Player's specific choice (e.g. 'saved the villagers'/'chose to betray')"),
              context=str_prop("Game context description"),
              emotional_state=str_prop("Emotion (auto-inferred if empty)"),
              tags=arr_prop("Additional tags"),
          )),

        T("game_event",
          "Record a game event (non-decisional), mapped to a state snapshot.\n"
          "Suitable for recording scene changes, NPC dialogue, environment changes, etc.",
          prop(
              session_id={"type": "integer", "description": "Session ID (required)"},
              event_type=str_prop("Event type (scene_change/npc_dialogue/combat/discovery/...)"),
              description=str_prop("Event description"),
              emotional_state=str_prop("Emotion"),
              tags=arr_prop("Tags"),
          )),

        T("game_stats",
          "View game statistics — registered games / session count / decision count / event count.",
          prop(game_id=str_prop("Filter by game ID (empty = all)"))),

        T("generate_question",
          "Multimodal smart questioning — auto-generates questions in different modalities based on coverage gaps.\n"
          "Modalities: text (Q&A) / visual (photos/screenshots) / sensory (sensory experience) / film (film/music association).\n"
          "Leave dimension empty to auto-select the weakest dimension.",
          prop(
              dimension=str_prop("Target dimension (identity/value/emotion/cognition/relationship/creation/mission, empty = auto)"),
              modality=str_prop("Question modality: text/visual/sensory/film (default: text)"),
          )),

        T("stream_ingest",
          "Real-time data stream soul ingestion — real-time data from wearables/phones/IoT (voice transcripts, video descriptions, sensor readings),\n"
          "AI extracts soul-relevant information and auto-routes to corresponding recording functions.\n"
          "Device does the heavy lifting (ASR, video->text), this tool only accepts text + metadata.",
          prop(
              raw_content=str_prop("Raw text content (voice transcript/video description/sensor summary)"),
              source_type=str_prop("Source type: voice_transcript/video_summary/sensor_reading/chat_log (default: voice_transcript)"),
              device_id=str_prop("Device identifier (default: wearable)"),
              context=str_prop("Additional context (optional)"),
              timestamp=str_prop("Data timestamp in ISO format (optional, defaults to current time)"),
          )),

        T("search_all",
          "Cross-table full-text search — covers souls / states / silences / contradictions / philosophy / artifacts.\n"
          "Broader than search_souls, suitable when unsure which table the data is in.",
          prop(
              keyword=str_prop("Search keyword"),
              limit={"type": "integer", "description": "Number of results (default: 20)"},
          )),

        T("ingest_material",
          "Ingest long-form material (diary/article/interview/letter etc.) into the soul archive.\n"
          "Auto-splits into 3-layer chunks: 1) anchor_summary (full-text summary) 2) satellite (semantic tag phrases) 3) paragraph (segments).\n"
          "Suitable for text over 100 characters. Use record_soul for short text.",
          prop(
              title=str_prop("Material title"),
              material_type=str_prop("Type: diary/article/interview/letter/note/transcript/other"),
              raw_text=str_prop("Full material text"),
              source=str_prop("Source (filename/URL/dictation etc.)"),
              linked_soul_id={"type": "integer", "description": "Linked soul event id (optional)"},
              tags=arr_prop("Tags"),
              **_TL, **_SCENE,
          )),

        T("list_materials",
          "View recent long-form material records.",
          prop(
              limit={"type": "integer", "description": "Number of results (default: 10)"},
              material_type=str_prop("Filter by type (empty = all)"),
          )),

        T("get_material",
          "Read the full content of a long-form material entry.",
          prop(material_id={"type": "integer", "description": "Material id"})),

        # ── Persona Card (60 questions, 3 tiers, shared question bank with kobo) ───────────
        T("persona_questions",
          "Get persona card questions (60 questions, 3 tiers). Completing tier 1 (20 questions) enables basic copywriting, further tiers improve quality.\n"
          "Three tiers: 1) Basic personality (60%) 2) Style deepening (85%) 3) Soul layer (100%).\n"
          "Shares the same question bank with kobo (vocal-script-mcp).",
          prop(tier={"type": "integer", "description": "Tier filter: 1/2/3, empty returns all"})),

        T("persona_submit",
          "Submit persona card answers (full soul pipeline: chunk splitting + vectorization + hash chain).\n"
          "Supports single or batch submission. Each answer is recorded as a soul event in the souls table.\n"
          "answers format: [{\"id\":\"P01\",\"answer\":\"I do streamer training...\"}]",
          prop(
              answers={"type": "array", "items": {"type": "object", "properties": {
                  "id": str_prop("Question ID, e.g. P01"),
                  "answer": str_prop("User's answer"),
              }}, "description": "Answer array"},
              source_device=str_prop("Source device (default: persona_card)"),
          )),

        T("persona_status",
          "View persona card completeness — answered/unanswered questions, progress per tier, next step suggestions.",
          S),

        T("persona_card",
          "Export persona card summary — formatted text, can be directly used as system prompt for copywriting.\n"
          "When a question is answered multiple times, the latest answer is used (overwrite semantics).",
          S),

        T("persona_redact",
          "Redact specified persona card entries (soft delete).\n"
          "Use case: user regrets entering private content / contaminated erroneous data.\n"
          "Effect: content cleared, vectors cleared, AI no longer recalls, but hash chain remains intact.\n"
          "Can redact by question ID (e.g. P01) or by soul event ID.",
          prop(
              question_id=str_prop("Question ID (e.g. P01), redact all answers for this question"),
              soul_id={"type": "integer", "description": "Soul event ID (precise single-entry redaction)"},
              reason=str_prop("Redaction reason (optional, recorded in system)"),
          )),

        T("redact_soul",
          "Redact any soul event (soft delete, not limited to persona card).\n"
          "Applicable to: privacy regret / data entry error / contamination by others.\n"
          "Effect: content replaced with '[User has redacted this entry]', vectors cleared, hash chain remains intact.",
          prop(
              soul_id={"type": "integer", "description": "Soul event ID (required)"},
              reason=str_prop("Redaction reason (optional)"),
          )),
    ]


# ── Handler Registry ───────────────────────────────────────────
# Each tool is a standalone function, registered via @_tool.
# Adding a new tool = write a function + a decorator, no existing code touched.

_HANDLERS: dict[str, Callable] = {}

_TL_KEYS = ("memory_time", "memory_time_start", "memory_time_end", "memory_time_precision",
            "prospect_time", "prospect_time_start", "prospect_time_end", "prospect_time_precision",
            "time_direction")

def _tl_args(args: dict) -> dict:
    """Extract timeline parameters (only returns non-None values)."""
    return {k: args[k] for k in _TL_KEYS if args.get(k) is not None}

_EMO_KEYS = ("emotion_valence", "emotion_intensity", "emotion_duration",
             "emotion_trigger_type", "emotion_trigger_ref", "emotion_label",
             "emotion_secondary")

def _emo_args(args: dict) -> dict:
    """Extract emotion coordinate parameters (only returns non-None values)."""
    return {k: args[k] for k in _EMO_KEYS if args.get(k) is not None}


def _tool(name: str):
    def dec(fn):
        _HANDLERS[name] = fn
        return fn
    return dec


# ── souls ──────────────────────────────────────────────────────

@_tool("record_soul")
def _h_record_soul(args):
    soul_db.record(
        context           = args["context"],
        human_decision    = args["human_decision"],
        divergence_reason = args["divergence_reason"],
        ai_suggestion     = args.get("ai_suggestion", ""),
        tags              = args.get("tags", []),
        notes             = args.get("notes", ""),
        emotional_state   = args.get("emotional_state"),
        energy            = args.get("energy"),
        body_state        = args.get("body_state"),
        memory_triggers   = args.get("memory_triggers", []),
        decision_layer    = args.get("decision_layer"),
        time_horizon      = args.get("time_horizon"),
        contradicts_id    = args.get("contradicts_id"),
        social_context    = args.get("social_context"),
        source_device     = "mcp",
        scene_fingerprint = args.get("scene_fingerprint"),
        **_tl_args(args), **_emo_args(args),
    )
    return "Soul event recorded."


@_tool("list_souls")
def _h_list_souls(args):
    rows = soul_db.list_recent(limit=args.get("limit", 10), tag=args.get("tag"))
    if not rows:
        return "No soul events recorded yet."
    parts = []
    for r in rows:
        tags = json.loads(r["tags"]) if r["tags"] else []
        lines = [
            f"[{r['timestamp'][:16]}] Soul Event #{r['id']}",
            f"Context          : {r['context']}",
            f"Decision         : {r['human_decision']}",
            f"Divergence Reason: {r['divergence_reason']}",
        ]
        if tags:
            lines.append(f"Tags             : {', '.join(tags)}")
        for label, field in [("Emotion", "emotional_state"), ("Energy", "energy"), ("Decision Layer", "decision_layer")]:
            if r[field]:
                lines.append(f"{label}     : {r[field]}")
        parts.append("\n".join(lines))
    return "\n\n─────\n\n".join(parts)


@_tool("search_souls")
def _h_search_souls(args):
    rows = soul_db.search(args["keyword"])
    if not rows:
        return f"No records found containing '{args['keyword']}'."
    return "\n\n".join(
        f"[{r['timestamp'][:10]}] #{r['id']} · {r['human_decision'][:80]}" for r in rows
    )


@_tool("summarize_souls")
def _h_summarize_souls(args):
    rows = soul_db.all_souls()
    if not rows:
        return "No soul events recorded yet."
    tags: dict[str, int] = {}
    for r in rows:
        for t in (json.loads(r["tags"]) if r["tags"] else []):
            tags[t] = tags.get(t, 0) + 1
    lines = [
        f"Total {len(rows)} soul events recorded",
        f"Time span: {rows[0]['timestamp'][:10]} -> {rows[-1]['timestamp'][:10]}",
        "\nTag distribution:",
    ]
    lines += [f"  {t}: {c} times" for t, c in sorted(tags.items(), key=lambda x: -x[1])]
    lines.append("\nRecent 5 divergence reasons:")
    lines += [f"  - [{r['timestamp'][:10]}] {r['divergence_reason']}" for r in rows[-5:]]
    return "\n".join(lines)


# ── profile ────────────────────────────────────────────────────

@_tool("set_profile")
def _h_set_profile(args):
    soul_db.set_profile(
        key=args["key"], value=args["value"],
        category=args.get("category", "general"),
        **_tl_args(args),
    )
    return f"Profile recorded: {args['key']} = {args['value']}"


@_tool("get_profile")
def _h_get_profile(args):
    rows = soul_db.get_profile(category=args.get("category"))
    if not rows:
        return "(No profile data yet)"
    cat, lines = None, []
    for r in rows:
        if r["category"] != cat:
            lines.append(f"\n[{r['category']}]"); cat = r["category"]
        lines.append(f"  {r['key']}: {r['value']}  （{r['recorded_at'][:10]}）")
    return "\n".join(lines).strip()


# ── philosophy ─────────────────────────────────────────────────

@_tool("add_philosophy")
def _h_add_philosophy(args):
    soul_db.add_philosophy(
        principle=args["principle"],
        source=args.get("source", ""),
        examples=args.get("examples", []),
        **_tl_args(args),
    )
    return "Decision principle recorded."


@_tool("list_philosophy")
def _h_list_philosophy(args):
    rows = soul_db.all_philosophy()
    if not rows:
        return "No decision philosophy records yet."
    parts = []
    for r in rows:
        ex = json.loads(r["examples"]) if r["examples"] else []
        lines = [
            f"[{r['timestamp'][:10]}] Principle #{r['id']}",
            f"Principle: {r['principle']}",
            f"Source   : {r['source'] or '—'}",
        ]
        if ex:
            lines += [f"  · {e}" for e in ex]
        parts.append("\n".join(lines))
    return "\n\n─────\n\n".join(parts)


# ── states / contradictions / artifacts / silences ─────────────

@_tool("record_state")
def _h_record_state(args):
    soul_db.record_state(**{k: args.get(k) for k in (
        "emotional_state", "energy", "body_state", "social_context", "environment",
        "sense_smell", "sense_touch", "sense_taste", "sense_sound_env", "sense_visual_env",
        "biometric_note", "biometric_ref", "source_device", "notes")},
        tags=args.get("tags", []),
        scene_fingerprint=args.get("scene_fingerprint"),
        **_tl_args(args), **_emo_args(args))
    return "State snapshot recorded."


@_tool("record_contradiction")
def _h_record_contradiction(args):
    soul_db.record_contradiction(
        description=args["description"],
        soul_id_a=args.get("soul_id_a"), soul_id_b=args.get("soul_id_b"),
        analysis=args.get("analysis"), resolution=args.get("resolution"),
        tags=args.get("tags", []),
        **_tl_args(args),
    )
    return "Contradiction record saved."


@_tool("record_artifact")
def _h_record_artifact(args):
    soul_db.record_artifact(
        artifact_type=args["artifact_type"],
        description=args["description"],
        path=args.get("path"),
        linked_soul_id=args.get("linked_soul_id"),
        tags=args.get("tags", []),
        **_tl_args(args),
    )
    return "Multimodal artifact recorded."


@_tool("record_silence")
def _h_record_silence(args):
    soul_db.record_silence(
        topic=args["topic"], silence_form=args["silence_form"],
        observation=args.get("observation", ""),
        tags=args.get("tags", []),
        **_tl_args(args),
    )
    return "Silence recorded."


# ── media ──────────────────────────────────────────────────────

@_tool("ingest_media")
def _h_ingest_media(args):
    import media as soul_media
    file_path      = str(Path(args["file_path"]).resolve())
    # Path sandbox: only allow files under user's home directory
    home = str(Path.home())
    if not file_path.startswith(home + "/"):
        return f"Security restriction: only files under user's home directory are allowed ({home})"
    artifact_type  = args["artifact_type"]
    extra_context  = args.get("extra_context", "")
    is_soul_photo  = args.get("is_soul_photo", False)
    linked_soul_id = args.get("linked_soul_id")
    tags           = args.get("tags", [])

    content_hash = soul_media.compute_hash(file_path)
    description  = soul_media.describe_media(file_path, artifact_type, extra_context)
    binary_data  = soul_media.read_binary(file_path) if is_soul_photo else None

    soul_db.ingest_media_artifact(
        artifact_type  = artifact_type,
        description    = description,
        content_hash   = content_hash,
        path           = file_path,
        binary_data    = binary_data,
        is_primary     = 1 if is_soul_photo else 0,
        linked_soul_id = linked_soul_id,
        tags           = tags,
        **_tl_args(args),
    )
    return "\n".join([
        "Media ingested into soul archive.",
        f"SHA256 : {content_hash}",
        f"Type   : {artifact_type}",
        f"Binary : {'Stored (soul photo)' if binary_data else 'Not stored (description + hash only)'}",
        f"\n-- AI Semantic Description --\n{description}",
    ])


@_tool("get_soul_photo")
def _h_get_soul_photo(args):
    row = soul_db.get_soul_photo()
    if not row:
        return "No soul photo yet. Use ingest_media + is_soul_photo=true to record one."
    lines = [
        f"-- Soul Photo #{row['id']} --",
        f"Recorded : {row['timestamp'][:16]}",
        f"SHA256   : {row['content_hash'] or '—'}",
        f"File Path: {row['path'] or '—'}",
        f"Binary   : {'Stored ({:.1f}KB)'.format(len(row['binary_data'])/1024) if row['binary_data'] else 'Not stored'}",
        f"\n-- AI Semantic Description --\n{row['description'] or '(No description)'}",
    ]
    result = [types.TextContent(type="text", text="\n".join(lines))]
    if row["binary_data"]:
        import base64
        path_obj = Path(row["path"]) if row["path"] else None
        suffix = path_obj.suffix.lower() if path_obj else ".jpg"
        mime_map = {".jpg": "image/jpeg", ".jpeg": "image/jpeg",
                    ".png": "image/png", ".gif": "image/gif", ".webp": "image/webp"}
        mime = mime_map.get(suffix, "image/jpeg")
        b64 = base64.standard_b64encode(row["binary_data"]).decode()
        result.append(types.ImageContent(type="image", data=b64, mimeType=mime))
    return result


# ── chain ──────────────────────────────────────────────────────

@_tool("verify_chain")
def _h_verify_chain(args):
    return soul_chain.summary()


@_tool("resurrect_soul")
def _h_resurrect_soul(args):
    result = soul_chain.resurrect(backup_path=args.get("backup_path"))
    return result["message"]


@_tool("seal_mcp")
def _h_seal_mcp(args):
    result = soul_chain.seal(reason=args.get("reason", ""))
    return result["message"]


# ── query / search ─────────────────────────────────────────────

@_tool("query_soul")
def _h_query_soul(args):
    return soul_db.soul_prompt(args["question"])


@_tool("get_portrait")
def _h_get_portrait(args):
    philosophy_limit = args.get("philosophy_limit")
    profile = soul_db.get_profile()
    philos  = soul_db.all_philosophy(limit=philosophy_limit)
    souls   = soul_db.all_souls()
    tags: dict[str, int] = {}
    for r in souls:
        for t in (json.loads(r["tags"]) if r["tags"] else []):
            tags[t] = tags.get(t, 0) + 1

    sections = ["=== Human Portrait ===\n", "-- Personality Dimensions --"]
    if profile:
        cat = None
        for r in profile:
            if r["category"] != cat:
                sections.append(f"\n[{r['category']}]"); cat = r["category"]
            sections.append(f"  {r['key']}: {r['value']}  （{r['recorded_at'][:10]}）")
    else:
        sections.append("(Not yet recorded)")

    total_philos = len(soul_db.all_philosophy())
    sections.append(f"\n-- Decision Philosophy ({total_philos} total, showing {len(philos)}) --")
    sections += ([f"· {r['principle']}  （{r['source'] or '—'}）" for r in philos]
                 or ["(Not yet recorded)"])

    sections.append("\n-- Soul Event Statistics --")
    if souls:
        sections.append(f"Total {len(souls)} events, top tags:")
        sections += [f"  {t}: {c} times"
                     for t, c in sorted(tags.items(), key=lambda x: -x[1])[:5]]
        sections.append(f"\nMost recent divergence reason:\n  {souls[-1]['divergence_reason']}")
    else:
        sections.append("(Not yet recorded)")
    return "\n".join(sections)


@_tool("get_soul_sketch")
def _h_get_soul_sketch(args):
    s = soul_db.cold_start_sketch()
    lines = [f"-- Soul Sketch [{s['level'].upper()}] --", s["sketch"]]
    if s["earliest"]:
        lines.append(f"\nEarliest entry: {s['earliest']}")
    if s["top_tags"]:
        lines.append(f"Top tags      : {' · '.join(s['top_tags'])}")
    if s["emotion_pattern"]:
        lines.append(f"Emotion trend : {s['emotion_pattern']}")
    lines.append(f"Total entries : {s['total']}")
    return "\n".join(lines)


@_tool("search_soul_chunks")
def _h_search_soul_chunks(args):
    import vectors as _v
    limit = args.get("limit", 8)
    hits = _v.search_chunks(
        query=args["query"],
        chunk_type=args.get("chunk_type"),
        table_name=args.get("table_name"),
        limit=limit,
    )
    if not hits:
        return "No relevant chunks found (Is Ollama online? Have chunk vectors been generated?)"
    lines = [f"Chunk-level semantic search: '{args['query']}'  {len(hits)} results\n"]
    for i, h in enumerate(hits, 1):
        lines.append(
            f"{i}. [{h['table_name']}#{h['soul_id']}] "
            f"<{h['chunk_type']}> score={h['score']:.3f}\n"
            f"   {h['content'][:120]}"
        )
    return "\n".join(lines)


@_tool("vectorize_soul_chunks")
def _h_vectorize_soul_chunks(args):
    import vectors as _v
    # Count pending items first
    conn = soul_db._open_db()
    pending = conn.execute(
        "SELECT COUNT(*) FROM soul_chunks WHERE vector_status IN ('pending', 'failed')"
    ).fetchone()[0]
    conn.close()

    if pending == 0:
        return "No pending chunks, all chunks have been vectorized."
    if not _v.ollama_alive():
        return f"Ollama is not online, cannot vectorize. {pending} pending chunks, please start Ollama and retry."

    # Background thread async execution, returns immediately — does not block MCP process
    def _run():
        _v.backfill_chunks(soul_db._extract_chunks, soul_db.SOUL_TABLES, verbose=False)

    threading.Thread(target=_run, daemon=True).start()
    return (
        f"Background vectorization task triggered, {pending} pending chunks.\n"
        f"Task runs in background, does not affect normal MCP usage.\n"
        f"Upon completion, each chunk's vector_status will be updated to done / failed."
    )


# ── Table Readers ──────────────────────────────────────────────────

@_tool("list_states")
def _h_list_states(args):
    rows = soul_db.list_states(limit=args.get("limit", 10))
    if not rows:
        return "No state snapshot records yet."
    parts = []
    for r in rows:
        tags = json.loads(r["tags"]) if r["tags"] else []
        lines = [f"[{r['timestamp'][:16]}] State #{r['id']}"]
        for label, field in [
            ("Emotion", "emotional_state"), ("Energy", "energy"), ("Body", "body_state"),
            ("Environment", "environment"), ("Social", "social_context"), ("Notes", "notes"),
        ]:
            if r[field]:
                lines.append(f"  {label}: {r[field]}")
        if tags:
            lines.append(f"  Tags: {', '.join(tags)}")
        parts.append("\n".join(lines))
    return "\n\n─────\n\n".join(parts)


@_tool("list_silences")
def _h_list_silences(args):
    rows = soul_db.list_silences(limit=args.get("limit", 10))
    if not rows:
        return "No silence records yet."
    parts = []
    for r in rows:
        tags = json.loads(r["tags"]) if r["tags"] else []
        lines = [
            f"[{r['timestamp'][:16]}] Silence #{r['id']}",
            f"  Topic: {r['topic']}",
            f"  Form : {r['silence_form']}",
        ]
        if r["observation"]:
            lines.append(f"  Observation: {r['observation']}")
        if tags:
            lines.append(f"  Tags: {', '.join(tags)}")
        parts.append("\n".join(lines))
    return "\n\n─────\n\n".join(parts)


@_tool("list_contradictions")
def _h_list_contradictions(args):
    rows = soul_db.list_contradictions(limit=args.get("limit", 10))
    if not rows:
        return "No contradiction records yet."
    parts = []
    for r in rows:
        tags = json.loads(r["tags"]) if r["tags"] else []
        lines = [f"[{r['timestamp'][:16]}] Contradiction #{r['id']}"]
        if r["soul_id_a"] or r["soul_id_b"]:
            lines.append(f"  Linked events: #{r['soul_id_a']} <-> #{r['soul_id_b']}")
        lines.append(f"  Description: {r['description']}")
        if r["analysis"]:
            lines.append(f"  Analysis: {r['analysis']}")
        if r["resolution"]:
            lines.append(f"  Resolution: {r['resolution']}")
        if tags:
            lines.append(f"  Tags: {', '.join(tags)}")
        parts.append("\n".join(lines))
    return "\n\n─────\n\n".join(parts)


@_tool("list_artifacts")
def _h_list_artifacts(args):
    rows = soul_db.list_artifacts(
        limit=args.get("limit", 10),
        artifact_type=args.get("artifact_type"),
    )
    if not rows:
        return "No artifact records yet."
    parts = []
    for r in rows:
        tags = json.loads(r["tags"]) if r["tags"] else []
        lines = [
            f"[{r['timestamp'][:16]}] Artifact #{r['id']} [{r['artifact_type']}]",
            f"  Description: {(r['description'] or '')[:100]}",
        ]
        if r["path"]:
            lines.append(f"  Path: {r['path']}")
        if r["content_hash"]:
            lines.append(f"  SHA256: {r['content_hash'][:16]}...")
        if tags:
            lines.append(f"  Tags: {', '.join(tags)}")
        parts.append("\n".join(lines))
    return "\n\n─────\n\n".join(parts)


# ── System Tools ───────────────────────────────────────────────────

@_tool("soul_coverage")
def _h_soul_coverage(args):
    cov = soul_db.soul_coverage()
    lines = ["=== Soul Data Coverage Analysis ===", ""]

    # Dimension coverage
    lines.append("-- 7 Dimension Coverage --")
    for dim, info in cov["dimensions"].items():
        status = "[ ] blank" if info["chunks"] == 0 else f"[x] {info['chunks']} chunks"
        latest = f"  latest:{info['latest']}" if info["latest"] else ""
        lines.append(f"  {dim:14s} : {status}{latest}")

    if cov["sparse_dimensions"]:
        lines.append(f"\n  Warning: blank dimensions: {', '.join(cov['sparse_dimensions'])}")
    if cov["weak_dimensions"]:
        weak = ", ".join(f"{w['dimension']}({w['chunks']} chunks)" for w in cov["weak_dimensions"])
        lines.append(f"  Note: weak dimensions: {weak}")

    # chunk_type distribution
    lines.append("\n-- Chunk Type Distribution --")
    for ct, info in sorted(cov["chunk_types"].items(), key=lambda x: -x[1]["count"]):
        lines.append(f"  {ct:14s} : {info['count']:3d} chunks  latest:{info['latest'] or '—'}")

    # Table freshness
    lines.append("\n-- Table Time Span --")
    for table, info in cov["table_freshness"].items():
        if info["count"] > 0:
            lines.append(f"  {table:18s} : {info['count']:3d} entries  {info['earliest']}~{info['latest']}")
        else:
            lines.append(f"  {table:18s} : 0 entries")

    # Emotion distribution
    if cov["emotion_distribution"]:
        lines.append("\n-- Emotion Distribution TOP5 --")
        top_emo = sorted(cov["emotion_distribution"].items(), key=lambda x: -x[1])[:5]
        for e, cnt in top_emo:
            lines.append(f"  {e} : {cnt} times")

    # Profile coverage
    lines.append(f"\n-- Profile Fields ({len(cov['profile_keys'])}) --")
    if cov["profile_keys"]:
        lines.append(f"  {', '.join(cov['profile_keys'])}")

    # v3.0: dimension x scene matrix
    if cov.get("dim_scene_matrix"):
        lines.append("\n-- Dimension x Scene Matrix --")
        for dim, scenes in cov["dim_scene_matrix"].items():
            top = sorted(scenes.items(), key=lambda x: -x[1])[:4]
            scene_str = " / ".join(f"{s}:{c}" for s, c in top)
            lines.append(f"  {dim:14s} : {scene_str}")

    # v3.0: scene gaps
    if cov.get("scene_gaps"):
        lines.append(f"\n-- Scene Gaps ({len(cov['scene_gaps'])}) --")
        for g in cov["scene_gaps"][:5]:
            lines.append(f"  {g['dimension']} × {g['scene']}")

    # v3.0: scene skew
    if cov.get("scene_skews"):
        lines.append("\n-- Scene Skew --")
        for s in cov["scene_skews"]:
            lines.append(f"  {s['dimension']}: {int(s['ratio']*100)}% from {s['dominant_scene']}")

    # v3.0: time direction distribution
    td = cov.get("time_direction_distribution", {})
    if td:
        lines.append("\n-- Time Direction Distribution --")
        for d in ("past", "present", "future", "timeless"):
            lines.append(f"  {d:10s} : {td.get(d, 0)} chunks")

    # v3.0: scene distribution
    ssd = cov.get("scene_social_distribution", {})
    if ssd:
        lines.append("\n-- Social Scene Distribution --")
        for s, c in sorted(ssd.items(), key=lambda x: -x[1])[:6]:
            lines.append(f"  {s} : {c}")

    # v3.0: material stats
    mc = cov.get("material_count", 0)
    if mc:
        lines.append(f"\n-- Long-form Materials --\n  {mc} entries")

    # v4.0: engine contribution distribution
    ed = cov.get("engine_distribution", {})
    if ed:
        lines.append("\n-- Engine Contribution Distribution --")
        for eng, info in sorted(ed.items(), key=lambda x: -x[1]["chunks"]):
            lines.append(f"  {eng:18s} : {info['chunks']:3d} chunks  weighted:{info['weighted_chunks']}")

    # v4.0: engine blind spots
    ebs = cov.get("engine_blind_spots", [])
    if ebs:
        lines.append("\n-- Engine Blind Spots --")
        for b in ebs:
            lines.append(f"  {b['dimension']}: game data only ({b['game_only_chunks']} entries), no real-life data")

    return "\n".join(lines)


@_tool("suggest_questions")
def _h_suggest_questions(args):
    suggestions = soul_db.suggest_questions(limit=args.get("limit", 5))
    if not suggestions:
        return "Current coverage is balanced, no specific suggestions. Continue free-form recording."
    lines = ["=== Question Suggestions (by priority) ===", ""]
    for i, s in enumerate(suggestions, 1):
        lines.append(f"{i}. [{s['priority']}] {s['reason']}")
        if s.get("sample_question"):
            lines.append(f"   Example: {s['sample_question']}")
        lines.append("")
    return "\n".join(lines)


@_tool("add_relation")
def _h_add_relation(args):
    rid = soul_db.add_relation(
        alias=args["alias"],
        real_name=args.get("real_name"),
        relation_type=args.get("relation_type"),
        importance=args.get("importance", 0.5),
        emotional_tone=args.get("emotional_tone"),
        first_met=args.get("first_met"),
        notes=args.get("notes"),
        tags=args.get("tags", []),
    )
    return f"Relationship recorded: {args['alias']} (#{rid})"


@_tool("list_relations")
def _h_list_relations(args):
    rows = soul_db.list_relations(limit=args.get("limit", 20))
    if not rows:
        return "No relationship records yet."
    lines = []
    for r in rows:
        importance = f"{'★' * int(r['importance'] * 5)}{'☆' * (5 - int(r['importance'] * 5))}"
        tone = f" ({r['emotional_tone']})" if r["emotional_tone"] else ""
        rtype = f" [{r['relation_type']}]" if r["relation_type"] else ""
        events = f" · {r['event_count']} events" if r["event_count"] else ""
        lines.append(f"#{r['id']} {r['alias']}{rtype}{tone} {importance}{events}")
    return "\n".join(lines)


@_tool("record_relation_event")
def _h_record_relation_event(args):
    eid = soul_db.record_relation_event(
        relation_id=args["relation_id"],
        event_type=args["event_type"],
        description=args["description"],
        emotional_state=args.get("emotional_state"),
        emotion_valence=args.get("emotion_valence"),
        linked_soul_id=args.get("linked_soul_id"),
        tags=args.get("tags", []),
    )
    return f"Relationship event recorded (#{eid})"


@_tool("emotion_patterns")
def _h_emotion_patterns(args):
    result = soul_db.emotion_patterns(
        person=args.get("person"),
        scene=args.get("scene"),
        months=args.get("months", 6),
    )
    lines = [f"=== Emotion Pattern Analysis ({result['total_records']} records) ===", ""]

    if result["top_emotions"]:
        lines.append("-- Top 10 Emotions --")
        for label, cnt in result["top_emotions"]:
            lines.append(f"  {label}: {cnt} times")

    if result["scene_emotion_matrix"]:
        lines.append("\n-- Scene x Emotion Matrix --")
        for scene, emotions in result["scene_emotion_matrix"].items():
            top = sorted(emotions.items(), key=lambda x: -x[1])[:4]
            emo_str = " / ".join(f"{e}:{c}" for e, c in top)
            lines.append(f"  {scene}: {emo_str}")

    if result["person_emotion_matrix"]:
        lines.append("\n-- Person x Emotion Matrix --")
        for person, emotions in result["person_emotion_matrix"].items():
            top = sorted(emotions.items(), key=lambda x: -x[1])[:3]
            emo_str = " / ".join(f"{e}:{c}" for e, c in top)
            lines.append(f"  {person}: {emo_str}")

    if result["trigger_distribution"]:
        lines.append("\n-- Trigger Type Distribution --")
        for tt, cnt in sorted(result["trigger_distribution"].items(), key=lambda x: -x[1]):
            lines.append(f"  {tt}: {cnt} times")

    if result["valence_timeline"]:
        lines.append("\n-- Valence Timeline (last 5 days) --")
        for v in result["valence_timeline"][-5:]:
            bar = "+" * int(max(0, v["avg_valence"]) * 10) or "-" * int(abs(min(0, v["avg_valence"])) * 10)
            lines.append(f"  {v['date']}: {v['avg_valence']:+.2f} ({v['count']} entries) {bar}")

    return "\n".join(lines)


@_tool("start_narrative")
def _h_start_narrative(args):
    result = soul_db.start_narrative(
        topic=args.get("topic"),
        media_id=args.get("media_id"),
        notes=args.get("notes"),
    )
    return f"Narrative session started: #{result['session_id']}  Topic: {result['topic'] or 'free narrative'}"


@_tool("narrative_turn")
def _h_narrative_turn(args):
    result = soul_db.narrative_turn(
        session_id=args["session_id"],
        question=args["question"],
        answer=args.get("answer"),
        depth_level=args.get("depth_level", 0),
    )
    if not result["ok"]:
        return result["message"]
    lines = [f"Turn #{result['turn_number']}  Depth: {result['depth_level']}"]
    if result["chunks_generated"]:
        lines.append(f"Generated {result['chunks_generated']} soul fragments")
    lines.append(f"Next layer: {result['next_depth_name']} (depth={result['next_depth']})")
    return "\n".join(lines)


@_tool("end_narrative")
def _h_end_narrative(args):
    result = soul_db.end_narrative(session_id=args["session_id"])
    if not result["ok"]:
        return result["message"]
    return (f"Narrative session #{result['session_id']} ended\n"
            f"Total turns: {result['turns']}  Generated fragments: {result['chunks']}")


@_tool("list_narratives")
def _h_list_narratives(args):
    rows = soul_db.list_narratives(limit=args.get("limit", 10))
    if not rows:
        return "No narrative sessions yet."
    lines = []
    for r in rows:
        status = "active" if r["status"] == "active" else "completed"
        lines.append(
            f"#{r['id']} [{r['timestamp'][:10]}] {r['topic'] or 'free narrative'} "
            f"({status}, {r['turn_count']} turns, {r['soul_chunks_generated']} fragments)")
    return "\n".join(lines)


@_tool("verify_chunk")
def _h_verify_chunk(args):
    result = soul_db.verify_chunk(
        chunk_id=args["chunk_id"],
        correct=args["correct"],
        corrected_content=args.get("corrected_content"),
    )
    return result["message"]


@_tool("soul_health")
def _h_soul_health(args):
    h = soul_db.soul_health()
    c = h["counts"]
    ch = h["chunks"]
    lines = [
        "=== Soul System Health Snapshot ===",
        "",
        "-- Record Counts by Table --",
        f"  souls           : {c.get('souls', 0)} entries",
        f"  states          : {c.get('states', 0)} entries",
        f"  silences        : {c.get('silences', 0)} entries",
        f"  contradictions  : {c.get('contradictions', 0)} entries",
        f"  artifacts       : {c.get('artifacts', 0)} entries",
        f"  philosophy      : {c.get('philosophy', 0)} entries",
        f"  profile_history : {c.get('profile_history', 0)} entries",
        "",
        "-- Hash Chain --",
        f"  Chain length: {h['chain_length']} blocks",
        "",
        "-- Vectorization Status --",
        f"  Total chunks : {ch['total']}",
        f"  Completed    : {ch['done']}",
        f"  Pending      : {ch['pending']}",
        f"  Failed       : {ch['failed']}",
        f"  Coverage     : {ch['vector_coverage']}",
        "",
        f"-- Database Size: {h['db_size_mb']} MB --",
    ]
    return "\n".join(lines)


@_tool("export_training_pairs")
def _h_export_training_pairs(args):
    rows = soul_db.export_training_pairs(
        pair_type=args.get("pair_type"),
        min_quality=args.get("min_quality", 0.0),
    )
    if not rows:
        return "No training data pairs yet. They accumulate automatically as soul events are recorded."
    sft_count = sum(1 for r in rows if (r["pair_type"] or "sft") == "sft")
    dpo_count = sum(1 for r in rows if r["pair_type"] == "dpo")
    lines = [f"Training data pairs: {len(rows)} total (SFT:{sft_count} / DPO:{dpo_count})", ""]
    for r in rows[:10]:
        ptype = r["pair_type"] or "sft"
        lines.append(f"[{ptype}] Q={r['quality']:.1f}  {r['source_table']}#{r['source_id']}")
        lines.append(f"  Input   : {r['input'][:60]}")
        lines.append(f"  Output  : {r['output'][:60]}")
        if r["rejected_output"]:
            lines.append(f"  Rejected: {r['rejected_output'][:60]}")
        lines.append("")
    if len(rows) > 10:
        lines.append(f"... {len(rows) - 10} more not shown")
    return "\n".join(lines)


@_tool("register_game")
def _h_register_game(args):
    result = soul_db.register_game(
        game_id=args["game_id"],
        game_name=args["game_name"],
        description=args.get("description", ""),
        mappings=args.get("mappings"),
    )
    if not result.get("ok"):
        return f"Registration failed: {', '.join(result.get('errors', ['unknown error']))}"
    action = "updated" if result.get("updated") else "registered"
    return f"Game {action}: {args['game_name']} ({args['game_id']})"


@_tool("game_session")
def _h_game_session(args):
    action = args.get("action", "start")
    if action == "start":
        result = soul_db.start_game_session(
            game_id=args.get("game_id", ""),
            notes=args.get("notes"),
        )
        if not result.get("ok"):
            return result["message"]
        return f"Game session started: #{result['session_id']}  Game: {result['game_name']}"
    elif action == "end":
        result = soul_db.end_game_session(session_id=args.get("session_id", 0))
        if not result.get("ok"):
            return result["message"]
        return (f"Game session #{result['session_id']} ended\n"
                f"Decisions: {result['decisions']} entries  Events: {result['events']} entries")
    return f"Unknown action: {action} (use start/end)"


@_tool("game_decision")
def _h_game_decision(args):
    result = soul_db.record_game_decision(
        session_id=args["session_id"],
        action=args.get("action", ""),
        choice=args.get("choice", ""),
        context=args.get("context", ""),
        emotional_state=args.get("emotional_state"),
        tags=args.get("tags", []),
    )
    if not result.get("ok"):
        return result["message"]
    lines = [
        "Game decision recorded",
        f"  Mapped dimension: {result['mapped_dimension']}",
        f"  Source engine   : {result['source_engine']}",
        f"  Weight          : {result['weight']}",
    ]
    if result.get("emotion_inferred"):
        lines.append(f"  Inferred emotion: {result['emotion_inferred']}")
    return "\n".join(lines)


@_tool("game_event")
def _h_game_event(args):
    result = soul_db.record_game_event(
        session_id=args["session_id"],
        event_type=args.get("event_type", ""),
        description=args.get("description", ""),
        emotional_state=args.get("emotional_state"),
        tags=args.get("tags", []),
    )
    if not result.get("ok"):
        return result["message"]
    return f"Game event recorded: {result['event_type']}"


@_tool("game_stats")
def _h_game_stats(args):
    result = soul_db.game_stats(game_id=args.get("game_id"))
    games = result["games"]
    if not games:
        return "No registered games. Use register_game to register a game and start recording."
    lines = ["=== Game Statistics ===", ""]
    for g in games:
        lines.append(f"[{g['game_id']}] {g['game_name']}")
        if g["description"]:
            lines.append(f"  Description: {g['description']}")
        lines.append(f"  Sessions : {g['sessions_total']} (active: {g['sessions_active']})")
        lines.append(f"  Decisions: {g['total_decisions']} entries")
        lines.append(f"  Events   : {g['total_events']} entries")
        lines.append(f"  Mappings : {g['mappings_count']} rules")
        lines.append("")
    return "\n".join(lines)


@_tool("generate_question")
def _h_generate_question(args):
    result = soul_db.generate_question(
        dimension=args.get("dimension"),
        modality=args.get("modality", "text"),
    )
    if not result.get("ok"):
        return result["message"]
    modality_icon = {"text": "T", "visual": "V", "sensory": "S", "film": "F"}.get(result["modality"], "?")
    return (f"[{modality_icon}] {result['question']}\n"
            f"Dimension: {result['dimension_cn']}  Modality: {result['modality']}")


@_tool("stream_ingest")
def _h_stream_ingest(args):
    import stream_ingest
    raw_content = args.get("raw_content", "")
    if not raw_content or len(raw_content.strip()) < 10:
        return "Content too short (minimum 10 characters), cannot extract soul information."
    result = stream_ingest.ingest_stream(
        raw_content=raw_content,
        source_type=args.get("source_type", "voice_transcript"),
        device_id=args.get("device_id", "wearable"),
        context=args.get("context", ""),
        timestamp=args.get("timestamp"),
    )
    if not result.get("relevant"):
        return f"Non-soul-relevant content, skipped. Reason: {result.get('reason', 'unknown')}"
    lines = [f"Ingested {result['recorded']} soul data entries (skipped {result['skipped']}):"]
    for d in result.get("details", []):
        if d.get("error"):
            lines.append(f"  ✗ [{d['type']}] {d['error']}")
        elif d["type"] == "soul":
            lines.append(f"  - [decision] {d.get('decision', '')}")
        elif d["type"] == "state":
            lines.append(f"  - [state] {d.get('emotion', '')}")
        elif d["type"] == "silence":
            lines.append(f"  - [silence] {d.get('topic', '')}")
        elif d["type"] in ("relation", "soul_from_relation"):
            lines.append(f"  - [relation] {d.get('person', '')}")
    return "\n".join(lines)


@_tool("search_all")
def _h_search_all(args):
    keyword = args["keyword"]
    results = soul_db.search_all(keyword, limit=args.get("limit", 20))
    if not results:
        return f"No records found containing '{keyword}'."
    lines = [f"Cross-table search '{keyword}', {len(results)} results:\n"]
    for item in results:
        table = item["table"]
        r = item["row"]
        if table == "souls":
            lines.append(f"[souls #{r['id']} · {r['timestamp'][:10]}] {r['human_decision'][:80]}")
            if r["divergence_reason"]:
                lines.append(f"  ↳ {r['divergence_reason'][:60]}")
        elif table == "philosophy":
            lines.append(f"[philosophy #{r['id']} · {r['timestamp'][:10]}] {r['principle'][:80]}")
        elif table == "profile_history":
            lines.append(f"[profile #{r['id']} · {r['recorded_at'][:10]}] {r['key']}: {r['value'][:60]}")
        elif table == "states":
            parts = [x for x in [r["emotional_state"], r["body_state"], r["notes"]] if x]
            lines.append(f"[states #{r['id']} · {r['timestamp'][:10]}] {' / '.join(parts)[:80]}")
        elif table == "silences":
            lines.append(f"[silences #{r['id']} · {r['timestamp'][:10]}] {r['topic'][:80]}")
        elif table == "contradictions":
            lines.append(f"[contradictions #{r['id']} · {r['timestamp'][:10]}] {r['description'][:80]}")
        elif table == "artifacts":
            lines.append(f"[artifacts #{r['id']} · {r['timestamp'][:10]}] [{r['artifact_type']}] {(r['description'] or '')[:60]}")
    return "\n".join(lines)


# ── materials ─────────────────────────────────────────────────

@_tool("ingest_material")
def _h_ingest_material(args):
    result = soul_db.ingest_material(
        title=args["title"],
        material_type=args["material_type"],
        raw_text=args["raw_text"],
        source=args.get("source", ""),
        linked_soul_id=args.get("linked_soul_id"),
        tags=args.get("tags", []),
        scene_fingerprint=args.get("scene_fingerprint"),
        **_tl_args(args),
    )
    return (f"Material ingested into soul archive.\n"
            f"Material ID  : #{result['material_id']}\n"
            f"Total chunks : {result['chunks']} (anchor:1 + satellite:{result['satellites']} + paragraph:{result['paragraphs']})")


@_tool("list_materials")
def _h_list_materials(args):
    rows = soul_db.list_materials(
        limit=args.get("limit", 10),
        material_type=args.get("material_type"),
    )
    if not rows:
        return "No long-form materials yet."
    lines = []
    for r in rows:
        tags = json.loads(r["tags"]) if r["tags"] else []
        tag_str = f" [{', '.join(tags)}]" if tags else ""
        lines.append(
            f"#{r['id']} [{r['timestamp'][:10]}] {r['material_type']} · {r['title']}"
            f"  ({r['char_count']} chars/{r['chunk_count']} chunks){tag_str}")
    return "\n".join(lines)


@_tool("get_material")
def _h_get_material(args):
    row = soul_db.get_material(args["material_id"])
    if not row:
        return f"Material #{args['material_id']} does not exist."
    lines = [
        f"-- Material #{row['id']} --",
        f"Title     : {row['title']}",
        f"Type      : {row['material_type']}",
        f"Source    : {row['source'] or '—'}",
        f"Recorded  : {row['timestamp'][:16]}",
        f"Characters: {row['char_count']}",
        f"Chunks    : {row['chunk_count']}",
    ]
    if row["memory_time"]:
        lines.append(f"Memory time  : {row['memory_time']}")
    if row["prospect_time"]:
        lines.append(f"Prospect time: {row['prospect_time']}")
    lines.append(f"\n-- Full Text --\n{row['raw_text']}")
    return "\n".join(lines)


# ── Persona Card ────────────────────────────────────────────────

import persona as _persona

@_tool("persona_questions")
def _h_persona_questions(args):
    tier = args.get("tier")
    questions = _persona.get_questions(tier)
    result = {
        "tier": tier or "all",
        "question_count": len(questions),
        "questions": [{
            "id": q["id"], "tier": q["tier"],
            "module": q["module"],
            "module_name": _persona.MODULES[q["module"]]["name"],
            "text": q["text"], "hint": q["hint"],
        } for q in questions],
        "tiers": _persona.TIERS,
    }
    return json.dumps(result, ensure_ascii=False, indent=2)


@_tool("persona_submit")
def _h_persona_submit(args):
    answers = args.get("answers", [])
    source_device = args.get("source_device", "persona_card")
    if not answers:
        return "answers cannot be empty."

    count = 0
    overwritten = []
    for item in answers:
        qid = item.get("id", "")
        answer = item.get("answer", "")
        if not qid or not answer:
            continue
        rec_args = _persona.answer_to_record_args(qid, answer, source_device)
        if rec_args is None:
            continue

        # Check if this question already has answers (overwrite semantics)
        existing = soul_db.find_persona_souls(qid)
        active = [r for r in existing if r["human_decision"] != soul_db.REDACTED_MARKER]
        if active:
            overwritten.append(qid)

        soul_db.record(**rec_args)
        count += 1

    answered_ids = _scan_persona_ids()
    comp = _persona.calc_completeness(answered_ids)
    report = _persona.format_completeness_report(comp)

    override_note = ""
    if overwritten:
        override_note = f"\nNote: {', '.join(overwritten)} already had previous answers, new answers will override old ones (latest used on export)."

    return f"Recorded {count} persona data entries (full soul pipeline){override_note}\n\n{report}"


@_tool("persona_status")
def _h_persona_status(args):
    answered_ids = _scan_persona_ids()
    comp = _persona.calc_completeness(answered_ids)

    # Unanswered questions
    unanswered = {}
    for tier in [1, 2, 3]:
        tier_qs = _persona.get_questions(tier)
        missing = [{"id": q["id"], "text": q["text"]} for q in tier_qs if q["id"] not in answered_ids]
        unanswered[f"tier{tier}"] = missing

    result = {
        "completeness": comp,
        "answered": sorted(answered_ids),
        "unanswered": unanswered,
        "report": _persona.format_completeness_report(comp),
    }
    return json.dumps(result, ensure_ascii=False, indent=2)


@_tool("persona_card")
def _h_persona_card(args):
    """Export persona card summary. Multiple answers per question take latest (overwrite semantics), redacted entries skipped."""
    rows = soul_db.all_souls()
    # Group by question ID, take only the latest (last appearing) per question
    latest_by_qid = {}

    for r in rows:
        # Skip redacted entries
        if r["human_decision"] == soul_db.REDACTED_MARKER:
            continue
        tags = json.loads(r["tags"]) if r["tags"] else []
        qid = None
        for t in tags:
            if t.startswith("P") and len(t) == 3 and t[1:].isdigit():
                qid = t
                break
        if not qid:
            continue
        q = _persona.get_question(qid)
        if not q:
            continue
        # rows are sorted by timestamp ASC, later entries override earlier ones
        latest_by_qid[qid] = {
            "module": q["module"],
            "module_name": _persona.MODULES[q["module"]]["name"],
            "answer": r["human_decision"],
            "qid": qid,
            "tier": q["tier"],
        }

    if not latest_by_qid:
        return "Persona card not yet filled. Use persona_questions to get questions, then persona_submit to submit answers."

    entries = list(latest_by_qid.values())
    answered_ids = set(latest_by_qid.keys())

    # Format grouped by module
    by_module = {}
    for e in entries:
        by_module.setdefault(e["module"], []).append(e)

    lines = ["[Persona Profile]\n"]
    for mod, items in by_module.items():
        mod_name = _persona.MODULES[mod]["name"]
        lines.append(f"{mod_name}：")
        for item in items:
            lines.append(f"  · {item['answer']}")
        lines.append("")

    comp = _persona.calc_completeness(answered_ids)
    lines.append(f"---\nCompleteness: {comp['overall']}% | Answered {len(answered_ids)}/60 questions")

    return "\n".join(lines)


@_tool("persona_redact")
def _h_persona_redact(args):
    """Redact persona card entries."""
    qid = args.get("question_id")
    soul_id = args.get("soul_id")
    reason = args.get("reason", "")

    if not qid and not soul_id:
        return "Please provide question_id (e.g. P01) or soul_id (soul event ID)."

    results = []

    if soul_id:
        # Precise single-entry redaction
        result = soul_db.redact_entry(soul_id, reason)
        results.append(result)
    elif qid:
        # Redact all answers for this question ID
        rows = soul_db.find_persona_souls(qid)
        if not rows:
            return f"No answers found for question {qid}."
        for r in rows:
            if r["human_decision"] == soul_db.REDACTED_MARKER:
                continue  # Skip already redacted
            result = soul_db.redact_entry(r["id"], reason or f"Batch redaction by question {qid}")
            results.append(result)

    if not results:
        return f"No entries found to redact."

    ok_count = sum(1 for r in results if r.get("ok"))
    return f"Redacted {ok_count} persona card entries.\n" + "\n".join(r["message"] for r in results)


@_tool("redact_soul")
def _h_redact_soul(args):
    """Redact any soul event (not limited to persona card)."""
    soul_id = args.get("soul_id")
    reason = args.get("reason", "")
    if not soul_id:
        return "Please provide soul_id."
    result = soul_db.redact_entry(soul_id, reason)
    return result["message"]


def _scan_persona_ids() -> set:
    """Scan all persona card question IDs from souls table (skip redacted entries)."""
    rows = soul_db.all_souls()
    ids = set()
    for r in rows:
        if r["human_decision"] == soul_db.REDACTED_MARKER:
            continue
        tags = json.loads(r["tags"]) if r["tags"] else []
        for t in tags:
            if t.startswith("P") and len(t) == 3 and t[1:].isdigit():
                ids.add(t)
    return ids


# ── Unified Dispatcher ─────────────────────────────────────────────────

@server.call_tool()
async def call_tool(name: str, arguments: dict):
    def text(s): return [types.TextContent(type="text", text=str(s))]

    handler = _HANDLERS.get(name)
    if handler is None:
        return text(f"Unknown tool: {name}")

    try:
        result = handler(arguments)
        # handler returning list (e.g. get_soul_photo with ImageContent) is passed through directly
        return result if isinstance(result, list) else text(result)
    except Exception as e:
        import traceback
        print(f"[{name}] Execution failed: {e}\n{traceback.format_exc()}", file=sys.stderr)
        return text(f"[{name}] Execution failed: {type(e).__name__}: {e}")


# ── Startup ───────────────────────────────────────────────────────

async def main():
    async with stdio_server() as (r, w):
        await server.run(r, w, server.create_initialization_options())

if __name__ == "__main__":
    asyncio.run(main())
