package mcp

import (
	"context"
	"encoding/json"
	"fmt"
	"strings"

	"github.com/dtoro/oikos/internal/audit"
	"github.com/dtoro/oikos/internal/db"
	"github.com/dtoro/oikos/internal/policy"
	"github.com/google/uuid"
	"github.com/modelcontextprotocol/go-sdk/mcp"
)

// toolReg pairs a tool definition with its handler. allTools returns a slice
// of these; newServer iterates it and registers each one wrapped with
// withActivityLogging.
type toolReg struct {
	tool    *mcp.Tool
	handler toolHandler
}

// allTools returns every MCP tool registration. Tool definitions, schemas,
// descriptions, and handler bodies are kept verbatim from the former inline
// newServer registrations.
func allTools(pool *db.Pool, agentID uuid.UUID) []toolReg {
	return []toolReg{
		{tool: &mcp.Tool{Name: "get_entity", Description: "Get an entity by slug or UUID",
			InputSchema: objSchema(prop{"slug_or_id", "string", "Entity slug (e.g. host:hubris) or UUID"}),
		}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
			args := argsMap(req)
			idOrSlug, _ := args["slug_or_id"].(string)
			return queryEntity(ctx, pool, idOrSlug), nil
		}},

		{tool: &mcp.Tool{Name: "list_entities", Description: "List entities filtered by type, state, or search",
			InputSchema: objSchema(
				prop{"type", "string", "Filter by entity type"},
				prop{"state", "string", "Filter by lifecycle state"},
				prop{"q", "string", "Substring match on slug or name"},
				prop{"limit", "integer", "Max rows (default 50)"}),
		}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
			args := argsMap(req)
			limit := int(getFloat(args, "limit", 50))
			return annotateJSONResult(queryRows(ctx, pool, `
			SELECT e.slug, e.type, e.name, e.state, e.version, e.created_at, e.updated_at
			FROM entities e
			WHERE ($1::text IS NULL OR e.type = $1)
			  AND ($2::text IS NULL OR e.state = $2)
			  AND ($3::text IS NULL OR e.slug ILIKE '%'||$3||'%' OR e.name ILIKE '%'||$3||'%')
			ORDER BY e.slug LIMIT $4`,
				nStr(args["type"]), nStr(args["state"]), nStr(args["q"]), limit), "entity_table"), nil
		}},

		{tool: &mcp.Tool{Name: "get_relations", Description: "Get relationships for an entity",
			InputSchema: objSchema(prop{"entity_id", "string", "Entity slug"}),
		}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
			args := argsMap(req)
			slug, _ := args["entity_id"].(string)
			return queryRows(ctx, pool, `
			SELECT r.type, src.slug AS source, tgt.slug AS target
			FROM relationships r
			JOIN entities src ON src.id = r.source_id
			JOIN entities tgt ON tgt.id = r.target_id
			WHERE (src.slug = $1 OR tgt.slug = $1) AND r.valid_to IS NULL
			ORDER BY r.type`, slug), nil
		}},

		{tool: &mcp.Tool{Name: "get_blast_radius", Description: "Find entities affected if this entity goes down",
			InputSchema: objSchema(
				prop{"entity_id", "string", "Entity slug"},
				prop{"depth", "integer", "Traversal depth (default 3)"}),
		}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
			args := argsMap(req)
			slug, _ := args["entity_id"].(string)
			depth := int(getFloat(args, "depth", 3))
			return queryRows(ctx, pool,
				"SELECT e.slug, CAST(b.depth AS int) FROM blast_radius((SELECT id FROM entities WHERE slug = $1), $2) b JOIN entities e ON e.id = b.entity_id",
				slug, depth), nil
		}},

		{tool: &mcp.Tool{Name: "get_health_summary", Description: "Current fleet health summary",
			InputSchema: objSchema(),
		}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
			return queryRows(ctx, pool, `
			SELECT e.slug, e.type, st.health, st.last_check_at
			FROM entity_status st JOIN entities e ON e.id = st.entity_id
			WHERE e.type <> 'check'
			ORDER BY e.slug`), nil
		}},

		{tool: &mcp.Tool{Name: "get_audit_trail", Description: "Query the audit log",
			InputSchema: objSchema(prop{"entity_id", "string", "Filter by affected entity UUID"}),
		}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
			args := argsMap(req)
			return queryRows(ctx, pool, `
			SELECT id, ts, actor_type, action, entity_id::text, method, path, correlation_id
			FROM audit_log
			WHERE ($1::text IS NULL OR entity_id::text = $1)
			ORDER BY ts DESC LIMIT 50`, nStr(args["entity_id"])), nil
		}},

		{tool: &mcp.Tool{Name: "search_knowledge", Description: "Full-text search across documentation (PostgreSQL FTS with ts_rank ranking). Returns a short snippet per hit, not the full note — call get_knowledge_content with the returned slug to read the whole thing.",
			InputSchema: objSchema(prop{"query", "string", "Search terms"}),
		}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
			args := argsMap(req)
			q := nStr(args["query"])
			return annotateJSONResult(queryRows(ctx, pool, `
			SELECT ke.title, e.slug,
			       ts_rank(ke.search, plainto_tsquery('english', $1)) AS rank,
			       ts_headline('english', ke.content, plainto_tsquery('english', $1),
			         'MaxWords=40, MinWords=15, ShortWord=3, MaxFragments=3,
			          FragmentDelimiter=" ... "') AS snippet,
			       ke.source, ke.tags
			FROM knowledge_entities ke
			JOIN entities e ON e.id = ke.entity_id
			WHERE ke.search @@ plainto_tsquery('english', $1)
			ORDER BY rank DESC
			LIMIT 20`, q), "knowledge_results"), nil
		}},

		{tool: &mcp.Tool{Name: "get_entity_knowledge", Description: "All documents, investigations, and runbooks linked to an entity. Returns a headline per note, not the full text — call get_knowledge_content with the returned slug to read the whole thing.",
			InputSchema: objSchema(prop{"entity_slug", "string", "Entity slug (e.g. lxc:jellyfin, service:caddy)"}),
		}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
			args := argsMap(req)
			slug, _ := args["entity_slug"].(string)
			return annotateJSONResult(queryRows(ctx, pool, `
			SELECT ke.title, ke.source, e.type AS kind, e.slug,
			       ts_headline('english', ke.content, plainto_tsquery('english', '')) AS headline
			FROM knowledge_entities ke
			JOIN entities e ON e.id = ke.entity_id
			JOIN relationships r ON r.source_id = ke.entity_id
			JOIN entities target ON target.id = r.target_id
			WHERE target.slug = $1
			  AND r.valid_to IS NULL
			  AND r.type IN ('documents', 'about')
			UNION
			SELECT ke.title, ke.source, e.type AS kind, e.slug,
			       ts_headline('english', ke.content, plainto_tsquery('english', '')) AS headline
			FROM knowledge_entities ke
			JOIN entities e ON e.id = ke.entity_id
			JOIN relationships r ON r.source_id = ke.entity_id
			JOIN entity_types target_type ON target_type.name = (SELECT type FROM entities WHERE slug = $1)
			JOIN entities ent ON ent.type = target_type.name AND ent.slug = $1
			WHERE r.valid_to IS NULL
			  AND r.type = 'procedure-for'
			ORDER BY 1`, slug), "knowledge_results"), nil
		}},

		{tool: &mcp.Tool{Name: "get_knowledge_content", Description: "Full markdown body of one document/investigation/runbook, by its own entity slug. search_knowledge and get_entity_knowledge only return short snippets/headlines — once you know which note you need (from either of those, or because you already know its slug), call this to read the whole thing before acting on it.",
			InputSchema: objSchema(prop{"slug", "string", "The knowledge entity's own slug (e.g. document:containers/101-jellyfin, runbook:client-enrollment) — not the slug of an entity it's about."}),
		}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
			args := argsMap(req)
			slug, _ := args["slug"].(string)
			return queryRows(ctx, pool, `
			SELECT ke.title, e.slug, e.type AS kind, ke.content, ke.source, ke.tags, ke.updated_at::text
			FROM knowledge_entities ke
			JOIN entities e ON e.id = ke.entity_id
			WHERE e.slug = $1`, slug), nil
		}},

		{tool: &mcp.Tool{Name: "upsert_knowledge", Description: "Write back what you learned so future sessions (and future you) benefit — this is how the system gets smarter over time. Use it AFTER solving a non-obvious problem, deploying a service, or discovering a gotcha: record the finding, the fix, and any caveats. Re-calling with the same title updates the existing note instead of duplicating. This is the ONLY way to persist knowledge; a chat message alone is forgotten. search_knowledge/get_entity_knowledge find it, get_knowledge_content reads the full body back.",
			InputSchema: objSchema(
				prop{"title", "string", "Short, specific, searchable title (e.g. 'Dragonfly memlock rlimit in unprivileged LXCs', not 'notes')."},
				prop{"content", "string", "The knowledge itself, in markdown. Be concrete: symptom, root cause, the exact fix/commands, and any caveats. Written for someone hitting this fresh."},
				prop{"about", "string", "Optional entity slug(s) this knowledge concerns. Pass a single slug (e.g. 'lxc:nfs-export') or a JSON array of slugs (e.g. '[\"lxc:nfs-export\", \"lxc:gitea\"]') to link to multiple entities. get_entity_knowledge surfaces it for each."},
				prop{"tags", "string", "Optional comma-separated tags (e.g. 'docker,networking,gotcha')."},
				prop{"kind", "string", "One of: investigation (a finding/incident analysis — default), document (reference), runbook (a repeatable procedure)."},
			),
		}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
			args := argsMap(req)
			return upsertKnowledge(ctx, pool, args)
		}},

		{tool: &mcp.Tool{Name: "update_entity_attributes", Description: "Merge new/changed attributes into an entity — the OTHER half of avoiding knowledge-base drift (upsert_knowledge records what you learned; this keeps the entity's own facts current). Use it when you discover something concrete about an entity's actual state that the graph doesn't reflect yet: a new IP, a version number, a config value, a discovered port — anything a FUTURE task would otherwise have to rediscover from scratch. Does NOT require approval (this updates the knowledge graph, not the live infrastructure). Merges shallowly — existing keys not mentioned are kept; keys you pass overwrite.",
			InputSchema: objSchema(
				prop{"slug", "string", "Entity slug to update (e.g. lxc:typetype, host:strong)."},
				prop{"attributes", "string", "JSON object string of attributes to merge in, e.g. {\"lan_ip\":\"192.168.8.50\",\"os\":\"debian-12\"}."},
			),
		}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
			args := argsMap(req)
			slug, _ := args["slug"].(string)
			attrsStr, _ := args["attributes"].(string)
			if slug == "" || attrsStr == "" {
				return textResult("error: slug and attributes are required"), nil
			}
			var attrs map[string]any
			if err := json.Unmarshal([]byte(attrsStr), &attrs); err != nil {
				return textResult(fmt.Sprintf("error: attributes is not valid JSON: %v", err)), nil
			}
			attrsJSON, _ := json.Marshal(attrs)
			ct, err := pool.Exec(ctx, `
			UPDATE entities SET attributes = attributes || $2::jsonb, updated_at = now()
			WHERE slug = $1`, slug, string(attrsJSON))
			if err != nil {
				return textResult(fmt.Sprintf("error updating %s: %v", slug, err)), nil
			}
			if ct.RowsAffected() == 0 {
				return textResult(fmt.Sprintf("error: entity %q not found", slug)), nil
			}
			return textResult(fmt.Sprintf("Updated %s with %d attribute(s).", slug, len(attrs))), nil
		}},

		{tool: &mcp.Tool{Name: "create_relationship", Description: "Record a relationship you discovered between two entities — the graph-structure half of keeping the knowledge base current (alongside update_entity_attributes and upsert_knowledge). Use it when you learn that one entity depends on, hosts, routes to, etc. another, and that edge isn't in the graph yet. type must be an existing relationship type (see get_relations output on similar entities for examples: hosts, provides, depends-on, configured-by, about, documents, ...). Idempotent — re-calling the same source/target/type is a no-op. Does NOT require approval.",
			InputSchema: objSchema(
				prop{"source", "string", "Source entity slug."},
				prop{"target", "string", "Target entity slug."},
				prop{"type", "string", "Relationship type name (must already exist in the ontology)."},
			),
		}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
			args := argsMap(req)
			source, _ := args["source"].(string)
			target, _ := args["target"].(string)
			relType, _ := args["type"].(string)
			if source == "" || target == "" || relType == "" {
				return textResult("error: source, target, and type are required"), nil
			}
			var sourceID, targetID uuid.UUID
			if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", source).Scan(&sourceID); err != nil {
				return textResult(fmt.Sprintf("error: source entity %q not found", source)), nil
			}
			if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", target).Scan(&targetID); err != nil {
				return textResult(fmt.Sprintf("error: target entity %q not found", target)), nil
			}
			_, err := pool.Exec(ctx, `
			INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
			SELECT $1, $2, $3, '{"by":"nomos"}'::jsonb, now()
			WHERE NOT EXISTS (
				SELECT 1 FROM relationships
				WHERE source_id = $1 AND target_id = $2 AND type = $3 AND valid_to IS NULL)`,
				sourceID, targetID, relType)
			if err != nil {
				return textResult(fmt.Sprintf("error creating relationship: %v (is %q a valid relationship type?)", err, relType)), nil
			}
			return textResult(fmt.Sprintf("Recorded: %s —%s→ %s", source, relType, target)), nil
		}},

		{tool: &mcp.Tool{Name: "query_metrics", Description: "Query time-series metrics",
			InputSchema: objSchema(prop{"hours", "integer", "Look-back window in hours (default 24)"}),
		}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
			args := argsMap(req)
			hours := int(getFloat(args, "hours", 24))
			return annotateJSONResult(queryRows(ctx, pool, `
			SELECT time_bucket('1 hour', ts) AS bucket,
			       entity_id::text, metric,
			       ROUND(avg(value)::numeric, 2) AS avg,
			       ROUND(min(value)::numeric, 2) AS min,
			       ROUND(max(value)::numeric, 2) AS max
			FROM metric_samples
			WHERE ts > now() - make_interval(hours => $1)
			GROUP BY bucket, entity_id, metric
			ORDER BY bucket DESC LIMIT 100`, hours), "metric_chart"), nil
		}},

		// ─── Phase 4: new tools ──────────────────────────────────────────

		{tool: &mcp.Tool{Name: "get_signal_history", Description: "Query open and recent signals",
			InputSchema: objSchema(
				prop{"entity_slug", "string", "Filter by target entity slug"},
				prop{"state", "string", "Filter by signal state (raised, resolved)"},
				prop{"limit", "integer", "Max rows (default 50)"}),
		}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
			args := argsMap(req)
			limit := int(getFloat(args, "limit", 50))
			return queryRows(ctx, pool, `
			SELECT s.entity_id::text, s.kind, s.severity, s.state,
			       s.occurrence_count, e.slug AS target_slug,
			       s.first_seen_at, s.last_seen_at
			FROM signals s
			LEFT JOIN entities e ON e.id = s.target_entity_id
			WHERE ($1::text IS NULL OR e.slug = $1)
			  AND ($2::text IS NULL OR s.state = $2)
			ORDER BY s.last_seen_at DESC LIMIT $3`,
				nStr(args["entity_slug"]), nStr(args["state"]), limit), nil
		}},

		{tool: &mcp.Tool{Name: "get_patterns", Description: "List learned action patterns",
			InputSchema: objSchema(
				prop{"status", "string", "Filter by status (hypothesized, validated, active)"},
				prop{"entity_type", "string", "Filter by applies_type"},
				prop{"action", "string", "Filter by action"}),
		}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
			args := argsMap(req)
			return queryRows(ctx, pool, `
			SELECT p.entity_id::text, p.applies_type, p.action, p.pattern,
			       p.confidence, p.evidence_count, p.success_count, p.failure_count,
			       p.status, p.quarantined, p.version, p.last_validated_at
			FROM patterns p
			WHERE ($1::text IS NULL OR p.status = $1)
			  AND ($2::text IS NULL OR p.applies_type = $2)
			  AND ($3::text IS NULL OR p.action = $3)
			ORDER BY p.applies_type, p.action`,
				nStr(args["status"]), nStr(args["entity_type"]), nStr(args["action"])), nil
		}},

		{tool: &mcp.Tool{Name: "get_skills", Description: "List available automation skills",
			InputSchema: objSchema(
				prop{"status", "string", "Filter by status (active, inactive, deprecated)"},
			),
		}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
			args := argsMap(req)
			return queryRows(ctx, pool, `
			SELECT s.entity_id::text, s.version, s.name, LEFT(s.procedure::text, 300) AS procedure_preview,
			       s.applies_type, s.action, s.status, s.success_rate,
			       s.changed_by::text, s.change_reason, s.last_used_at
			FROM skills s
			WHERE ($1::text IS NULL OR s.status = $1)
			ORDER BY s.name, s.version DESC`,
				nStr(args["status"])), nil
		}},

		// ── request_execution (legacy fixed enum) retired 2026-07-14 ──
		// All mutations now route through `run`. The handler functions
		// (runRexecRestart, runRexecSystemctl, etc.) are kept as reference
		// for future runbook extraction — especially pct_create DNS/VMID logic.
		// DO NOT re-register this tool. See plans/2026-07-10-general-gated-execution.md.

		{tool: &mcp.Tool{Name: "run", Description: "Run ANY shell command against any host, LXC, or VM. This is the general execution primitive — prefer it over asking the operator to run something manually, and don't wait for a matching fixed action to exist. Every command is automatically risk-classified: read-only inspection (cat, systemctl status, docker ps, journalctl, df, git status, ...) runs immediately; anything that changes state requires operator approval (granted by the operator replying \"go ahead\"/\"yes\" in chat, or via the Approve button); commands matching a destructive pattern (rm -rf, dd, mkfs, pct/qm destroy, DROP TABLE, reboot, piping curl into a shell, ...) always require approval regardless of what you declare. You cannot talk your way past the destructive check by declaring a lower risk.",
			InputSchema: objSchema(
				prop{"target", "string", "Target entity slug: host:<slug> (e.g. host:strong), lxc:<slug> (e.g. lxc:caddy), or vm:<slug> (e.g. vm:zimaos). LXC commands run via pct exec on their Proxmox host automatically. VM commands run via qm guest exec on their Proxmox host (requires the QEMU guest agent inside the VM — standard for Proxmox VMs)."},
				prop{"command", "string", "The shell command to run. Can be a full script (multi-line, &&-chained). Runs as root."},
				prop{"purpose", "string", "One sentence: why you're running this. Shown to the operator alongside the approval — be specific, this is what they're approving."},
				prop{"declared_risk", "string", "Optional self-assessment: read_only, reversible_low, config_mutation, or destructive. This can only ESCALATE the automatic classification, never lower it — declaring a mutating command as read_only has no effect."},
			),
		}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
			args := argsMap(req)
			targetSlug, _ := args["target"].(string)
			command, _ := args["command"].(string)
			purpose, _ := args["purpose"].(string)
			declaredRisk, _ := args["declared_risk"].(string)
			sessionID, _ := args["_session_id"].(string)
			if targetSlug == "" || command == "" {
				return textResult("error: target and command are required"), nil
			}

			var targetID uuid.UUID
			if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", targetSlug).Scan(&targetID); err != nil {
				return textResult(fmt.Sprintf("target not found: %s", targetSlug)), nil
			}

			return classifyAndGate(ctx, pool, agentID, targetID, targetSlug, command, purpose, declaredRisk, sessionID), nil
		}},

		// inspect_path is the bulk fact-gathering tool from
		// plans/2026-07-18-session-review-three-sessions.md P1.5.
		// Sessions 1e9c7691 and 55927f0a each spent ~15 `run` calls
		// gathering identical facts (`mount | grep`, `df`, `ls -la`,
		// `stat`) across hosts and LXCs to understand where a path
		// lives, who mounts it, and what permissions it has. This tool
		// collapses that fan-out into one call: pass a path and a list
		// of targets, get back per-target mount/df/ls/stat output as
		// JSON. All commands are read-only, so no approval is needed.
		{tool: &mcp.Tool{Name: "inspect_path", Description: "Bulk fact-gathering: run mount/df/ls/stat for the same path across multiple host/LXC/VM targets in ONE call. Returns a JSON object keyed by target slug, each with the target's view of the path (mount source, filesystem, size, top-level entries with ownership/permissions). Use this instead of N separate `run` calls when you need to understand a path's footprint across the fleet (e.g. tracing where a volume is mounted, checking permissions on the same NFS path from server + client). All commands are read-only — no approval needed.",
			InputSchema: objSchema(
				prop{"path", "string", "Absolute path to inspect on each target (e.g. /mnt/media_local, /media/ludo-library)."},
				prop{"targets", "array", "List of target entity slugs (host:strong, lxc:nfs-export, vm:zimaos, …). Up to 8 per call."},
			),
		}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
			args := argsMap(req)
			path, _ := args["path"].(string)
			if path == "" {
				return textResult("error: path is required"), nil
			}
			rawTargets, _ := args["targets"].([]any)
			if len(rawTargets) == 0 {
				return textResult("error: at least one target is required"), nil
			}
			if len(rawTargets) > 8 {
				return textResult("error: at most 8 targets per inspect_path call (use two calls if you need more)"), nil
			}
			targets := make([]string, 0, len(rawTargets))
			for _, t := range rawTargets {
				if s, ok := t.(string); ok && s != "" {
					targets = append(targets, s)
				}
			}
			results := inspectPathAcrossTargets(ctx, pool, path, targets)
			out, _ := json.MarshalIndent(results, "", "  ")
			return textResult(string(out)), nil
		}},

		{tool: &mcp.Tool{Name: "http_get", Description: "Fetch a public web page or raw file (e.g. a GitHub README/raw URL) and return sanitized text. Use this to research how to deploy a service before provisioning. HTTP/HTTPS only; body is truncated to ~16KB.",
			InputSchema: objSchema(
				prop{"url", "string", "Absolute http(s) URL to fetch"},
			),
		}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
			args := argsMap(req)
			rawURL, _ := args["url"].(string)
			return httpGet(ctx, rawURL), nil
		}},

		{tool: &mcp.Tool{Name: "get_execution_status", Description: "Check the status of a requested execution",
			InputSchema: objSchema(
				prop{"execution_id", "string", "Execution UUID (from request_execution output)"},
			),
		}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
			args := argsMap(req)
			execID, _ := args["execution_id"].(string)
			if execID == "" {
				return textResult("execution_id required"), nil
			}
			eid, err := uuid.Parse(execID)
			if err != nil {
				// Try finding by exec slug prefix
				var found uuid.UUID
				err2 := pool.QueryRow(ctx, "SELECT entity_id FROM executions WHERE entity_id::text LIKE $1 LIMIT 1", execID+"%").Scan(&found)
				if err2 != nil {
					return textResult(fmt.Sprintf("execution not found: %s", execID)), nil
				}
				eid = found
			}
			return queryRows(ctx, pool, `
			SELECT e.entity_id::text, e.action, e.risk_class, e.status,
			       e.result::text, e.duration_ms, e.started_at::text,
			       e.completed_at::text, e.correlation_id
			FROM executions e
			WHERE e.entity_id = $1`, eid), nil
		}},

		{tool: &mcp.Tool{Name: "get_trend", Description: "Get metric trends for an entity",
			InputSchema: objSchema(
				prop{"entity_id", "string", "Entity slug"},
				prop{"days", "integer", "Look-back window in days (default 7)"}),
		}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
			args := argsMap(req)
			slug, _ := args["entity_id"].(string)
			days := int(getFloat(args, "days", 7))
			return queryRows(ctx, pool, `
			SELECT metric,
			       ROUND(avg(value)::numeric, 2) AS avg_val,
			       ROUND(stddev(value)::numeric, 2) AS std_val,
			       count(*) AS sample_count,
			       ROUND(regr_slope(value, EXTRACT(EPOCH FROM ts)::numeric)::numeric, 4) AS slope
			FROM metric_samples ms
			JOIN entities e ON e.id = ms.entity_id
			WHERE e.slug = $1 AND ts >= now() - make_interval(days => $2)
			GROUP BY metric
			ORDER BY metric`, slug, days), nil
		}},

		{tool: &mcp.Tool{Name: "get_event_timeline", Description: "Get recent events",
			InputSchema: objSchema(
				prop{"severity", "string", "Filter by severity (info, warn, error)"},
				prop{"entity_slug", "string", "Filter by entity slug"},
				prop{"limit", "integer", "Max rows (default 50)"}),
		}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
			args := argsMap(req)
			limit := int(getFloat(args, "limit", 50))
			return queryRows(ctx, pool, `
			SELECT ev.ts, ev.type, ev.severity, ev.source, e.slug AS entity_slug,
			       ev.data::text AS message, ev.correlation_id
			FROM events ev
			LEFT JOIN entities e ON e.id = ev.entity_id
			WHERE ($1::text IS NULL OR ev.severity = $1)
			  AND ($2::text IS NULL OR e.slug = $2)
			ORDER BY ev.ts DESC LIMIT $3`,
				nStr(args["severity"]), nStr(args["entity_slug"]), limit), nil
		}},

		{tool: &mcp.Tool{Name: "get_agent_activity", Description: "Agent self-inspection: query agent activity log",
			InputSchema: objSchema(
				prop{"limit", "integer", "Max rows (default 50)"}),
		}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
			args := argsMap(req)
			limit := int(getFloat(args, "limit", 50))
			return annotateJSONResult(queryRows(ctx, pool, `
			SELECT id, ts, agent_id::text, session_id, activity_type, tool_name,
			       entity_id::text, left(input_summary, 200) AS input_summary,
			       left(output_summary, 200) AS output_summary,
			       duration_ms, token_count, success, correlation_id
			FROM agent_activity
			WHERE agent_id = $1
			ORDER BY ts DESC LIMIT $2`, agentID, limit), "change_log"), nil
		}},

		// ─── Phase 5: operational MCP tools ──────────────────────────────

		{tool: &mcp.Tool{Name: "list_lxcs", Description: "List all LXC containers with ID, host, IP, state, and last-audited hint. Pass state=\"active\" to exclude destroyed/deprecated containers. The last_audited_at column shows the most recent knowledge entry (investigation or document tagged audit/update) linked via an 'about' edge — use it to skip re-running `run` against LXCs that were already audited recently.",
			InputSchema: objSchema(
				prop{"state", "string", "Optional: filter by entity state (active, destroyed, …)"},
			),
		}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
			state, _ := argsMap(req)["state"].(string)
			var statePtr *string
			if state != "" {
				statePtr = &state
			}
			return annotateJSONResult(queryRows(ctx, pool, `
			SELECT e.slug, e.name, e.attributes->>'pve_id' AS pve_id,
			       e.attributes->>'lan_ip' AS lan_ip,
			       e.state,
			       st.health, st.last_check_at,
			       (SELECT MAX(k.created_at)
			        FROM relationships r
			        JOIN knowledge_entities k ON k.entity_id = r.source_id
			        WHERE r.target_id = e.id
			          AND r.type = 'about'
			          AND r.valid_to IS NULL
			          AND (k.tags @> ARRAY['audit']::text[]
			            OR k.tags @> ARRAY['update']::text[]
			            OR k.title ILIKE '%audit%'
			            OR k.title ILIKE '%update%')
			       ) AS last_audited_at
			FROM entities e
			LEFT JOIN entity_status st ON st.entity_id = e.id
			WHERE e.type = 'lxc'
			  AND ($1::text IS NULL OR e.state = $1)
			ORDER BY CASE WHEN e.state = 'active' THEN 0 ELSE 1 END,
			         (e.attributes->>'pve_id')::int`, statePtr), "lxc_list"), nil
		}},

		{tool: &mcp.Tool{Name: "ping_service", Description: "Check if a service is reachable via HTTP",
			InputSchema: objSchema(prop{"service_slug", "string", "Service entity slug"}),
		}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
			args := argsMap(req)
			slug, _ := args["service_slug"].(string)
			if slug == "" {
				return textResult("service_slug is required"), nil
			}
			rows, err := pool.Query(ctx, `
			SELECT st.health, st.last_check_at, e.attributes->>'url' AS url
			FROM entity_status st
			JOIN entities e ON e.id = st.entity_id
			WHERE e.slug = $1`, slug)
			if err != nil {
				return textResult(fmt.Sprintf("query error: %v", err)), nil
			}
			defer rows.Close()
			if !rows.Next() {
				return textResult(fmt.Sprintf("service not found: %s", slug)), nil
			}
			var health, lastCheck, url string
			rows.Scan(&health, &lastCheck, &url)
			if url == "" {
				url = "(no URL in entity attributes)"
			}
			return textResult(fmt.Sprintf("health=%s last_check=%s url=%s", health, lastCheck, url)), nil
		}},

		{tool: &mcp.Tool{Name: "tail_log", Description: "Get recent log lines from a service via journalctl",
			InputSchema: objSchema(
				prop{"service_slug", "string", "Service entity slug (e.g. lxc:caddy)"},
				prop{"lines", "integer", "Number of lines (default 50)"}),
		}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
			args := argsMap(req)
			slug, _ := args["service_slug"].(string)
			n := int(getFloat(args, "lines", 50))
			if slug == "" {
				return textResult("service_slug is required"), nil
			}
			host, user, err := resolveHost(ctx, pool, slug)
			if err != nil {
				return textResult(fmt.Sprintf("resolve host: %v", err)), nil
			}
			svc := strings.TrimPrefix(slug, "lxc:")
			out, err := sshExec(ctx, host, user, fmt.Sprintf("journalctl -u %s -n %d --no-pager 2>&1 || true", svc, n))
			if err != nil {
				return textResult(fmt.Sprintf("ssh: %v", err)), nil
			}
			return textResult(out), nil
		}},

		{tool: &mcp.Tool{Name: "get_service_status", Description: "Check systemd service status on a host",
			InputSchema: objSchema(
				prop{"service_slug", "string", "Service entity slug (e.g. lxc:caddy)"}),
		}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
			args := argsMap(req)
			slug, _ := args["service_slug"].(string)
			if slug == "" {
				return textResult("service_slug is required"), nil
			}
			host, user, err := resolveHost(ctx, pool, slug)
			if err != nil {
				return textResult(fmt.Sprintf("resolve host: %v", err)), nil
			}
			svc := strings.TrimPrefix(slug, "lxc:")
			out, err := sshExec(ctx, host, user,
				fmt.Sprintf("systemctl is-active %s; systemctl is-enabled %s; systemctl show %s -p ActiveEnterTimestamp -p SubState 2>&1 || true", svc, svc, svc))
			if err != nil {
				return textResult(fmt.Sprintf("ssh: %v", err)), nil
			}
			return textResult(out), nil
		}},

		{tool: &mcp.Tool{Name: "get_lxc_state", Description: "Get LXC container resource state from Proxmox host",
			InputSchema: objSchema(
				prop{"lxc_slug", "string", "LXC entity slug (e.g. lxc:caddy)"}),
		}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
			args := argsMap(req)
			slug, _ := args["lxc_slug"].(string)
			if slug == "" {
				return textResult("lxc_slug is required"), nil
			}
			var pveID string
			err := pool.QueryRow(ctx, "SELECT attributes->>'pve_id' FROM entities WHERE slug = $1", slug).Scan(&pveID)
			if err != nil || pveID == "" {
				return textResult(fmt.Sprintf("LXC not found or missing pve_id: %s", slug)), nil
			}
			// Resolve the Proxmox host — find the host that runs this LXC
			var hostID uuid.UUID
			err = pool.QueryRow(ctx, `
			SELECT t.id FROM entities t
			JOIN relationships r ON r.source_id = t.id
			JOIN entities s ON s.id = r.target_id
			WHERE s.slug = $1 AND r.type = 'hosts' AND r.valid_to IS NULL
			LIMIT 1`, slug).Scan(&hostID)
			if err != nil {
				// Fallback: use the inventory host attribute if no relationship
				var hostSlug string
				err = pool.QueryRow(ctx, "SELECT attributes->>'host' FROM entities WHERE slug = $1", slug).Scan(&hostSlug)
				if err != nil || hostSlug == "" {
					return textResult(fmt.Sprintf("cannot resolve Proxmox host for %s", slug)), nil
				}
				var host, user string
				host, user, err = resolveHost(ctx, pool, "host:"+hostSlug)
				if err != nil {
					return textResult(fmt.Sprintf("resolve: %v", err)), nil
				}
				out, err2 := sshExec(ctx, host, user, fmt.Sprintf("pct status %s --verbose 2>&1 || true", pveID))
				if err2 != nil {
					return textResult(fmt.Sprintf("ssh: %v", err2)), nil
				}
				return textResult(out), nil
			}
			var hostSlug string
			pool.QueryRow(ctx, "SELECT slug FROM entities WHERE id = $1", hostID).Scan(&hostSlug)
			host, user, err := resolveHost(ctx, pool, hostSlug)
			if err != nil {
				return textResult(fmt.Sprintf("resolve host: %v", err)), nil
			}
			out, err := sshExec(ctx, host, user, fmt.Sprintf("pct status %s --verbose 2>&1 || true", pveID))
			if err != nil {
				return textResult(fmt.Sprintf("ssh: %v", err)), nil
			}
			return textResult(out), nil
		}},

		// ─── Client introspection tools (plan: client-lifecycle Phase 3) ──

		{tool: &mcp.Tool{Name: "whoami", Description: "Get the current entity record, peers, and health for a host",
			InputSchema: objSchema(prop{"hostname", "string", "Hostname of the calling machine"}),
		}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
			args := argsMap(req)
			hostname, _ := args["hostname"].(string)
			if hostname == "" {
				return textResult("error: hostname required"), nil
			}
			slug := "ws:" + hostname
			return annotateJSONResult(queryRows(ctx, pool, `
			SELECT e.slug, e.type, e.name, e.state,
			       COALESCE(st.health, 'unknown') AS health,
			       COALESCE(st.last_check_at::text, '') AS last_check,
			       e.attributes->>'mesh_ip' AS mesh_ip,
			       e.attributes->>'age_pubkey' AS age_pubkey,
			       e.enrolled_at
			FROM entities e
			LEFT JOIN entity_status st ON st.entity_id = e.id
			WHERE e.slug = $1
			ORDER BY e.slug`, slug), "entity_card"), nil
		}},

		{tool: &mcp.Tool{Name: "explain", Description: "Compact context card for a service: type, state, health, relations, risk",
			InputSchema: objSchema(prop{"service_slug", "string", "Service entity slug (e.g. service:jellyfin, lxc:caddy)"}),
		}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
			args := argsMap(req)
			slug, _ := args["service_slug"].(string)
			if slug == "" {
				return textResult("error: service_slug required"), nil
			}
			return annotateJSONResult(queryRows(ctx, pool, `
			SELECT e.slug, e.type, e.name, e.state,
			       COALESCE(st.health, 'unknown') AS health,
			       COALESCE(st.last_check_at::text, '') AS last_check,
			       e.version, e.updated_at,
			       COALESCE(e.attributes::text, '{}') AS attrs
			FROM entities e
			LEFT JOIN entity_status st ON st.entity_id = e.id
			WHERE e.slug = $1`, slug), "entity_card"), nil
		}},

		{tool: &mcp.Tool{Name: "preflight", Description: "Risk classification for an action on a service",
			InputSchema: objSchema(
				prop{"service_slug", "string", "Entity slug"},
				prop{"action", "string", "Planned action (restart, deploy, destroy, etc.)"}),
		}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
			args := argsMap(req)
			slug, _ := args["service_slug"].(string)
			action, _ := args["action"].(string)
			if slug == "" || action == "" {
				return textResult("error: service_slug and action required"), nil
			}
			return queryRows(ctx, pool, `
			SELECT e.slug, e.type, e.state,
			       CASE
			         WHEN $2 IN ('restart', 'logs', 'status') THEN 'reversible_low'
			         WHEN $2 IN ('deploy', 'upgrade', 'configure') THEN 'config_mutation'
			         WHEN $2 IN ('destroy', 'wipe', 'revoke') THEN 'destructive'
			         ELSE 'read_only'
			       END AS risk_class,
			       CASE
			         WHEN $2 IN ('read_only','reversible_low') THEN 'auto-act'
			         WHEN $2 = 'config_mutation' THEN 'operator-approval'
			         ELSE 'operator-approval+confirmation'
			       END AS approval
			FROM entities e WHERE e.slug = $1`, slug, action), nil
		}},

		// classify_command is the command-scoped preflight from
		// plans/2026-07-20-session-review-ten-sessions.md P0.2. The
		// existing `preflight` tool is entity/action-scoped — useless when
		// the agent is composing a `run` command and needs to know whether
		// the classifier will accept it before submitting. Without this,
		// the agent has to retry with cosmetic variations until it finds
		// one that passes (see sessions a51e2086, 8acea2e3 — three
		// duplicate rclone sessions, all bouncing off the classifier).
		// Call this BEFORE `run` whenever the classification is uncertain.
		{tool: &mcp.Tool{Name: "classify_command", Description: "Pre-flight risk classification for a shell command BEFORE calling run. Returns the risk class (read_only / reversible_low / config_mutation / destructive) that `run` would assign. Use this when you're unsure whether a command will auto-execute or need approval — e.g. `pct exec`, `curl`, compound commands, or anything that might be mistaken for mutation. If this returns read_only, the same command will auto-execute via run with no approval; if it returns config_mutation, expect to need operator approval (or pre-frame the command so it classifies lower). Declared risk can only escalate, never de-escalate.",
			InputSchema: objSchema(
				prop{"command", "string", "The exact shell command you intend to pass to run."},
				prop{"declared_risk", "string", "Optional self-assessment you would pass to run (read_only, reversible_low, config_mutation, destructive). Mirrors run's declared_risk parameter."},
			),
		}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
			args := argsMap(req)
			command, _ := args["command"].(string)
			declaredRisk, _ := args["declared_risk"].(string)
			if command == "" {
				return textResult("error: command is required"), nil
			}
			risk := policy.ClassifyCommand(command, declaredRisk)
			note := ""
			switch risk {
			case policy.RiskReadOnly:
				note = "auto-acts on `run` (no approval needed)."
			case policy.RiskReversibleLow:
				note = "auto-acts on `run` (no approval needed)."
			case policy.RiskConfigMutation:
				note = "requires operator approval on `run` (or loose assent window active)."
			case policy.RiskDestructive:
				note = "requires explicit operator confirmation on `run` (typed \"I confirm\" phrase)."
			}
			out, _ := json.Marshal(map[string]any{
				"command":       command,
				"declared_risk": declaredRisk,
				"risk_class":    risk,
				"note":          note,
			})
			return textResult(string(out)), nil
		}},

		{tool: &mcp.Tool{Name: "get_change_history", Description: "Last N change-ledger entries for an entity",
			InputSchema: objSchema(
				prop{"entity_slug", "string", "Entity slug"},
				prop{"limit", "integer", "Max entries (default 20)"}),
		}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
			args := argsMap(req)
			slug, _ := args["entity_slug"].(string)
			limit := int(getFloat(args, "limit", 20))
			return annotateJSONResult(queryRows(ctx, pool, `
			SELECT al.ts AS timestamp, al.actor_type, al.actor_id::text AS actor_label,
			       al.action, al.method, al.path,
			       al.detail::text AS details
			FROM audit_log al
			JOIN entities e ON e.id = al.entity_id
			WHERE e.slug = $1
			ORDER BY al.ts DESC
			LIMIT $2`, slug, limit), "change_log"), nil
		}},

		{tool: &mcp.Tool{Name: "get_state_snapshot", Description: "Last scheduler Observe-pass: fleet health, disk, drift count",
			InputSchema: objSchema(),
		}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
			return annotateJSONResult(queryRows(ctx, pool, `
			SELECT e.slug, e.type, e.state,
			       COALESCE(st.health, 'unknown') AS health,
			       COALESCE(st.last_check_at::text, '') AS last_check
			FROM entities e
			LEFT JOIN entity_status st ON st.entity_id = e.id
			WHERE e.state IS NOT NULL
			   OR st.health IS NOT NULL
			ORDER BY st.health, e.slug
			LIMIT 200
		`), "fleet_snapshot"), nil
		}},

		{tool: &mcp.Tool{Name: "audit_knowledge_graph", Description: "Read-only drift report over the knowledge graph and monitoring: orphan check entities, checks targeting deprecated/destroyed entities, probes stuck down/unknown, unmonitored declared entity types, and live edges pointing at destroyed targets. Returns ranked findings with a suggested remediation runbook each. Use this to validate the graph is complete and consistent before trusting health/blast-radius answers. Does NOT mutate anything.",
			InputSchema: objSchema(),
		}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
			findings, summary := audit.Report(ctx, pool)
			b, _ := json.Marshal(map[string]any{"findings": findings, "summary": summary})
			return textResult(string(b)), nil
		}},

		{tool: &mcp.Tool{Name: "discover_infra_drift", Description: "Read-only live discovery: compares running Proxmox guests (pct/qm list on every proxmox host) against the DB graph. Returns guests running with no entity (missing) and entities whose pve_id is no longer live (ghost) — drift the DB-only audit_knowledge_graph cannot see. Reaches hosts over the same SSH/pct path the checks use. Does NOT mutate anything.",
			InputSchema: objSchema(),
		}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
			b, _ := json.Marshal(discoverInfraDrift(ctx, pool))
			return textResult(string(b)), nil
		}},

		{tool: &mcp.Tool{Name: "list_my_secrets", Description: "List secrets accessible to this client by public key",
			InputSchema: objSchema(prop{"caller_pubkey", "string", "Age public key of the caller (optional)"}),
		}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
			args := argsMap(req)
			pubkey, _ := args["caller_pubkey"].(string)
			// Match entities where age_pubkey attribute contains the caller's key.
			query := `
			SELECT e.slug, e.type, e.name,
			       e.attributes->>'age_pubkey' AS age_pubkey
			FROM entities e
			WHERE e.attributes->>'age_pubkey' IS NOT NULL`
			var dbArgs []any
			if pubkey != "" {
				query += ` AND e.attributes->>'age_pubkey' = $1`
				dbArgs = append(dbArgs, pubkey)
			}
			query += ` ORDER BY e.slug LIMIT 100`
			return queryRows(ctx, pool, query, dbArgs...), nil
		}},
	}
}
