package app

import (
	"context"
	"encoding/json"
	"fmt"
	"log/slog"
	"os/exec"
	"path/filepath"
	"strings"
	"time"

	"github.com/nugget/thane-ai-agent/internal/channels/email"
	sigcli "github.com/nugget/thane-ai-agent/internal/channels/messaging/signal"
	"github.com/nugget/thane-ai-agent/internal/channels/notifications"
	"github.com/nugget/thane-ai-agent/internal/connwatch"
	"github.com/nugget/thane-ai-agent/internal/integrations/forge"
	"github.com/nugget/thane-ai-agent/internal/integrations/mcp"
	"github.com/nugget/thane-ai-agent/internal/integrations/media"
	"github.com/nugget/thane-ai-agent/internal/integrations/search"
	"github.com/nugget/thane-ai-agent/internal/model/llm"
	"github.com/nugget/thane-ai-agent/internal/model/prompts"
	"github.com/nugget/thane-ai-agent/internal/model/router"
	"github.com/nugget/thane-ai-agent/internal/platform/config"
	"github.com/nugget/thane-ai-agent/internal/platform/database"
	"github.com/nugget/thane-ai-agent/internal/runtime/agent"
	"github.com/nugget/thane-ai-agent/internal/state/awareness"
	"github.com/nugget/thane-ai-agent/internal/state/contacts"
	"github.com/nugget/thane-ai-agent/internal/state/documents"
	"github.com/nugget/thane-ai-agent/internal/state/knowledge"
	"github.com/nugget/thane-ai-agent/internal/state/memory"
	"github.com/nugget/thane-ai-agent/internal/tools"
)

func toMCPToolOverrides(cfg map[string]config.MCPToolConfig) map[string]mcp.ToolOverride {
	if len(cfg) == 0 {
		return nil
	}
	out := make(map[string]mcp.ToolOverride, len(cfg))
	for name, toolCfg := range cfg {
		out[name] = mcp.ToolOverride{
			Enabled:     toolCfg.Enabled,
			Tags:        append([]string(nil), toolCfg.Tags...),
			Description: toolCfg.Description,
		}
	}
	return out
}

// initChannels wires tools and external channels into the agent loop.
// Sections include fact store, contact directory, notifications, email,
// forge, working memory, fact extraction, provenance,
// attachments, file tools, content resolution, usage, shell exec, web
// search/fetch, media, archive, embeddings, MCP servers, and Signal.
func (a *App) initChannels(s *newState) error {
	// --- Fact store ---
	// Long-term memory backed by SQLite. Facts are discrete pieces of
	// knowledge that persist across conversations and restarts.
	factDB, err := database.Open(a.cfg.DataDir + "/knowledge.db")
	if err != nil {
		return fmt.Errorf("open knowledge database: %w", err)
	}
	a.onCloseErr("facts", factDB.Close)
	factStore, err := knowledge.NewStore(factDB, a.logger)
	if err != nil {
		return fmt.Errorf("open fact store: %w", err)
	}
	a.factStore = factStore

	factTools := knowledge.NewTools(factStore)
	a.loop.Tools().SetFactTools(factTools)
	a.logger.Info("fact store initialized", "path", a.cfg.DataDir+"/knowledge.db")

	// --- Contact directory ---
	// Structured storage for people and organizations. Separate database
	// from facts to keep concerns isolated.
	contactDB, err := database.Open(a.cfg.DataDir + "/contacts.db")
	if err != nil {
		return fmt.Errorf("open contacts database: %w", err)
	}
	a.onCloseErr("contacts", contactDB.Close)
	contactStore, err := contacts.NewStore(contactDB, a.logger)
	if err != nil {
		return fmt.Errorf("open contact store: %w", err)
	}
	a.contactStore = contactStore

	// Wire summarizer → contact interaction tracking now that the
	// contact store is available. Register the callback before Start()
	// to avoid a race where the startup scan reads the field concurrently.
	a.summaryWorker.SetInteractionCallback(func(conversationID, sessionID string, endedAt time.Time, topics []string) {
		updateContactInteraction(contactStore, a.logger, conversationID, sessionID, endedAt, topics)
	})
	a.deferWorker("summary-worker", func(ctx context.Context) error {
		a.summaryWorker.Start(ctx)
		a.onClose("summary-worker", a.summaryWorker.Stop)
		return nil
	})

	contactTools := contacts.NewTools(contactStore)
	if a.cfg.Identity.ContactName != "" {
		contactTools.SetSelfContactName(a.cfg.Identity.ContactName)
	}
	if a.cfg.Identity.OwnerContactName != "" {
		contactTools.SetOwnerContactName(a.cfg.Identity.OwnerContactName)
	}
	ownerActivity := (&ownerChannelActivityAdapter{
		loops: &channelLoopAdapter{registry: a.loopRegistry},
	}).ActiveOwnerChannels
	contactTools.SetOwnerActivitySource(ownerActivity)
	a.loop.Tools().SetContactTools(contactTools)
	a.logger.Info("contact store initialized", "path", a.cfg.DataDir+"/contacts.db")

	// --- Notifications ---
	// Push notifications via HA companion app. Requires both the HA client
	// and the contact store for recipient → device resolution.
	if a.ha != nil {
		a.notifSender = notifications.NewSender(a.ha, contactStore, a.opStore, a.cfg.MQTT.DeviceName, a.logger)
		a.loop.Tools().SetHANotifier(a.notifSender)
		a.logger.Info("HA notification sender initialized")

		var nrErr error
		a.notifRecords, nrErr = notifications.NewRecordStore(a.mem.DB(), a.logger)
		if nrErr != nil {
			return fmt.Errorf("initialize notification record store: %w", nrErr)
		}
		a.loop.Tools().SetNotificationRecords(a.notifRecords)
		a.logger.Info("notification record store initialized")

		// Provider-agnostic notification router — wraps the HA push sender
		// behind a routing layer that selects delivery channel per recipient.
		a.notifRouter = notifications.NewNotificationRouter(contactStore, a.notifRecords, a.logger)
		a.notifRouter.RegisterProvider(notifications.NewHAPushProvider(a.notifSender))
		a.notifRouter.SetActivitySource(&channelActivityAdapter{
			loops: &channelLoopAdapter{registry: a.loopRegistry},
			store: contactStore,
		})
		a.notifRouter.SetSourceFunc(tools.NotificationSource)
		a.loop.Tools().SetNotificationRouter(a.notifRouter)
		a.logger.Info("notification router initialized", "providers", "ha_push")
	}

	// --- Email ---
	// Native IMAP/SMTP email. Replaces the MCP email server approach
	// with direct IMAP connections for reading and SMTP for sending,
	// supporting multiple accounts with trust zone gating.
	if a.cfg.Email.Configured() {
		emailMgr := email.NewManager(a.cfg.Email, a.logger)
		a.emailMgr = emailMgr
		a.onClose("email", emailMgr.Close)

		emailTools := email.NewTools(emailMgr, &emailContactResolver{store: contactStore})
		a.loop.Tools().SetEmailTools(emailTools)

		// Register each account with connwatch for health monitoring.
		for _, name := range emailMgr.AccountNames() {
			acctName := name // capture for closure
			acct, _ := emailMgr.Account(acctName)
			a.connMgr.Watch(s.ctx, connwatch.WatcherConfig{
				Name:    "email-" + acctName,
				Probe:   func(pCtx context.Context) error { return acct.Ping(pCtx) },
				Backoff: connwatch.DefaultBackoffConfig(),
				Logger:  a.logger,
			})
		}

		// --- Email polling ---
		// Periodic IMAP check for new messages via the loop infrastructure.
		// The handler checks UIDs against a high-water mark and dispatches
		// an agent conversation only when new mail is detected.
		if a.cfg.Email.PollIntervalSec > 0 {
			poller := email.NewPoller(emailMgr, a.opStore, a.logger)
			a.emailPoller = poller
		}

		a.logger.Info("email enabled", "accounts", emailMgr.AccountNames(), "poll_interval", a.cfg.Email.PollIntervalSec)
	} else {
		a.logger.Info("email disabled (not configured)")
	}

	// --- Forge integration ---
	// Native GitHub (and future Gitea/GitLab) integration. Replaces the
	// MCP github server with direct API calls via go-github.
	var forgeOpLog *forge.OperationLog
	if a.cfg.Forge.Configured() {
		var err error
		a.forgeMgr, err = forge.NewManager(a.cfg.Forge, a.logger)
		if err != nil {
			return fmt.Errorf("create forge manager: %w", err)
		}

		forgeOpLog = forge.NewOperationLog()
		forgeSubStore := forge.NewSubscriptionStore(a.opStore, a.logger, a.cfg.Forge.MaxSubscriptions)
		forgeTools := forge.NewTools(a.forgeMgr, forgeOpLog, a.logger, forgeSubStore)
		forgeTools.SetLoopResolver(a.loopRegistry)
		a.loop.Tools().SetForgeTools(forgeTools)

		if a.cfg.Forge.SubscriptionCheckInterval > 0 {
			a.forgeSubPoller = forge.NewSubscriptionPoller(a.forgeMgr, forgeSubStore, a.messageBus, a.logger)
			a.logger.Info("forge subscription polling enabled",
				"interval", time.Duration(a.cfg.Forge.SubscriptionCheckInterval)*time.Second,
				"max_subscriptions", a.cfg.Forge.MaxSubscriptions,
			)
		}

		a.logger.Info("forge enabled", "accounts", len(a.cfg.Forge.Accounts))
	} else {
		a.logger.Info("forge disabled (not configured)")
	}
	s.forgeOpLog = forgeOpLog

	// --- Working memory tool ---
	// Gives the agent a read/write scratchpad for experiential context
	// that survives compaction. Auto-injected via context provider below.
	a.loop.Tools().SetWorkingMemoryStore(a.wmStore)

	// --- Fact extraction ---
	// Automatic extraction of facts from conversations. Runs async after
	// each interaction using a local model. Opt-in via config.
	if a.cfg.Extraction.Enabled {
		extractionModel := a.cfg.Extraction.Model
		a.logger.Info("fact extraction enabled", "model", extractionModel)

		// FactSetter adapter with confidence reinforcement: if a fact already
		// exists, bump its confidence rather than overwriting.
		factSetterAdapter := &factSetterFunc{store: factStore, logger: a.logger}

		extractor := memory.NewExtractor(factSetterAdapter, a.logger, a.cfg.Extraction.MinMessages)
		extractor.SetTimeout(time.Duration(a.cfg.Extraction.TimeoutSeconds) * time.Second)
		extractor.SetExtractFunc(func(ctx context.Context, userMsg, assistantResp string, history []memory.Message) (*memory.ExtractionResult, error) {
			// Build transcript from recent history (only complete messages).
			var transcript strings.Builder
			for _, m := range history {
				line := fmt.Sprintf("[%s] %s\n", m.Role, m.Content)
				if transcript.Len()+len(line) > 4000 {
					break
				}
				transcript.WriteString(line)
			}

			prompt := prompts.FactExtractionPrompt(userMsg, assistantResp, transcript.String())
			msgs := []llm.Message{{Role: "user", Content: prompt}}

			start := time.Now()
			resp, err := a.llmClient.Chat(ctx, extractionModel, msgs, nil)
			if err != nil {
				a.logger.Warn("fact extraction LLM call failed",
					"model", extractionModel,
					"elapsed_ms", time.Since(start).Milliseconds(),
					"error", err)
				return nil, err
			}
			a.logger.Debug("fact extraction LLM call complete",
				"model", extractionModel,
				"elapsed_ms", time.Since(start).Milliseconds(),
				"response_len", len(resp.Message.Content))

			// Parse JSON (strip code fences, same pattern as metadata gen)
			content := resp.Message.Content
			content = strings.TrimPrefix(content, "```json\n")
			content = strings.TrimPrefix(content, "```\n")
			content = strings.TrimSuffix(content, "\n```")
			content = strings.TrimSpace(content)

			var result memory.ExtractionResult
			if err := json.Unmarshal([]byte(content), &result); err != nil {
				preview := content
				if len(preview) > 500 {
					preview = preview[:500]
				}
				a.logger.Debug("extraction JSON parse failed",
					"raw_response", preview)
				return nil, fmt.Errorf("parse extraction result: %w", err)
			}
			return &result, nil
		})

		a.loop.ConfigureSessionStores(agent.SessionStoreWiring{Extractor: extractor})
	}

	// Git provenance is initialized per managed document root below when
	// doc_roots.<root>.git enables signed commits. The legacy top-level
	// provenance block is retained only for older config compatibility.

	// --- Attachment store ---
	// Content-addressed file storage with SHA-256 deduplication.
	// When configured, channels (Signal, email) store attachments
	// by content hash with a SQLite metadata index.
	if err := a.initAttachmentRuntime(); err != nil {
		return err
	}

	// --- File tools ---
	// When a workspace path is configured, the agent can read and write
	// files within that directory. All paths are sandboxed.
	a.initFileTools(s)

	// --- Indexed document roots ---
	// Model-facing corpus navigation over managed markdown roots. This
	// complements raw file tools by giving the model rooted discovery,
	// structured search, and section retrieval when the exact path is not
	// yet known.
	if s.resolver != nil {
		documentRoots := buildDocumentRoots(s.resolver)
		if len(documentRoots) > 0 {
			docOptions, err := a.buildDocumentStoreOptions(documentRoots, s.resolver)
			if err != nil {
				return err
			}
			docStore, err := documents.NewStoreWithOptions(a.mem.DB(), documentRoots, a.logger.With("component", "documents"), docOptions)
			if err != nil {
				return fmt.Errorf("create document index: %w", err)
			}
			a.documentStore = docStore
			a.documentTools = documents.NewTools(docStore)
			tools.RegisterDocumentTools(a.loop.Tools(), a.documentTools)
			a.deferWorker("documents-index", func(ctx context.Context) error {
				go docStore.RunRefresher(ctx)
				return nil
			})
			roots := sortedDocumentRootNames(documentRoots)
			attrs := append([]slog.Attr{slog.Any("roots", roots)}, documentRootPolicyAttrs(docOptions, roots)...)
			a.logger.LogAttrs(context.Background(), slog.LevelInfo, "document index enabled", attrs...)

			// Close the verification bypass paths surfaced by #788:
			// the model's file tools and core context provider read raw
			// filesystem paths that may fall inside a managed root.
			// Route those through the store's verification policies.
			// Paths outside any managed root are no-ops inside VerifyPath,
			// so non-managed workspaces keep their existing behavior.
			if ft := a.loop.Tools().FileTools(); ft != nil {
				ft.SetPathVerifier(docStore)
			}
			a.loop.UseInjectFileVerifier(docStore.VerifyPath)
			if err := a.verifyStartupReads(s.ctx, docStore, s.resolvedInjectFiles); err != nil {
				return err
			}
		}
	}

	var talentVerifier func(context.Context, string, string) error
	if a.documentStore != nil {
		talentVerifier = a.documentStore.VerifyPath
	}
	parsedTalents, err := a.loadTalents(s.ctx, talentVerifier)
	if err != nil {
		return err
	}
	s.parsedTalents = parsedTalents
	a.loop.ConfigureCapabilityWiring(agent.CapabilityWiring{ParsedTalents: parsedTalents})

	// --- Temp file store ---
	// Provides create_temp_file tool for orchestrator-delegate data passing.
	// Files are stored in the workspace's .tmp subdirectory and cleaned up
	// when conversations end. Requires both workspace and opstate.
	if a.cfg.Workspace.Path != "" {
		tempFileStore := tools.NewTempFileStore(
			filepath.Join(a.cfg.Workspace.Path, ".tmp"),
			a.opStore,
			a.logger,
		)
		a.loop.Tools().SetTempFileStore(tempFileStore)
		a.logger.Info("temp file store enabled",
			"base_dir", filepath.Join(a.cfg.Workspace.Path, ".tmp"),
		)
	}

	// --- Universal content resolution ---
	// Wire prefix-to-content resolution into the tool registry so that bare
	// prefix references (temp:LABEL, kb:file.md, etc.) in any tool's string
	// arguments are automatically replaced with file content before the
	// handler runs. File tools opt out via SkipContentResolve (they need
	// the path, not the content).
	cr := tools.NewContentResolver(s.resolver, a.loop.Tools().TempFileStore(), a.logger)
	if cr != nil {
		a.loop.Tools().SetContentResolver(cr)
		a.logger.Info("content resolver enabled for tool arguments")
	}

	// --- Usage recording ---
	// Wire persistent token usage recording into the agent loop and
	// register the cost_summary tool so the agent can query its own spend.
	a.loop.ConfigureSessionStores(agent.SessionStoreWiring{
		UsageStore:   a.usageStore,
		Pricing:      a.cfg.Pricing,
		UsageCatalog: a.modelCatalog,
	})
	a.loop.Tools().SetUsageStore(a.usageStore)
	if a.loopDefinitionRuntime == nil {
		a.loopDefinitionRuntime = newAppLoopDefinitionRuntime(a)
	}
	a.loop.Tools().ConfigureModelRegistryTools(tools.ModelRegistryToolDeps{
		Registry:                a.modelRegistry,
		Router:                  a.rtr,
		SyncRouter:              a.syncRouterConfig,
		PersistDeploymentPolicy: a.persistModelRegistryPolicy,
		DeleteDeploymentPolicy:  a.deletePersistedModelRegistryPolicy,
		PersistResourcePolicy:   a.persistModelRegistryResourcePolicy,
		DeleteResourcePolicy:    a.deletePersistedModelRegistryResourcePolicy,
	})
	a.loop.Tools().ConfigureLoopDefinitionTools(tools.LoopDefinitionToolDeps{
		Registry:         a.loopDefinitionRegistry,
		View:             a.loopDefinitionView,
		PersistSpec:      a.persistLoopDefinition,
		DeleteSpec:       a.deletePersistedLoopDefinition,
		PersistPolicy:    a.persistLoopDefinitionPolicy,
		DeletePolicy:     a.deletePersistedLoopDefinitionPolicy,
		Reconcile:        a.reconcileLoopDefinition,
		LaunchDefinition: a.launchLoopDefinition,
	})
	if a.documentTools != nil {
		// RegisterTagProvider mirrors the TagRegistrar callback wired into
		// awareness.WatchlistTools (see initAwareness): when thane_curate
		// mints a new focus tag for a loop's entity subscriptions, this
		// callback registers the matching tag-scoped context provider so
		// the loop's iterations actually see those entities in prompt.
		registerTagProvider := func(tag string) {
			if a.ha == nil || strings.TrimSpace(tag) == "" {
				return
			}
			tagProvider := awareness.NewWatchlistTagProvider(tag, a.watchlistStore, a.ha, a.logger)
			tagProvider.SetRegistryClient(a.ha)
			a.loop.RegisterTagContextProvider(tag, tagProvider)
		}
		a.loop.Tools().ConfigureLoopIntentTools(tools.LoopIntentToolDeps{
			DocTools:            a.documentTools,
			Registry:            a.loopDefinitionRegistry,
			PersistSpec:         a.persistLoopDefinition,
			Reconcile:           a.reconcileLoopDefinition,
			LaunchDefinition:    a.launchLoopDefinition,
			WatchlistStore:      a.watchlistStore,
			RegisterTagProvider: registerTagProvider,
		})
	}
	a.loop.Tools().ConfigureLoopRuntimeTools(tools.LoopRuntimeToolDeps{
		Registry:   a.loopRegistry,
		LaunchLoop: a.launchLoop,
	})
	a.initMessageBus()
	a.loop.Tools().ConfigureMessageTools(tools.MessageToolDeps{
		Bus: a.messageBus,
	})

	// --- Log index query ---
	// Expose the structured log index so the agent can query its own
	// logs for self-diagnostics and forensics.
	if a.indexDB != nil {
		a.loop.Tools().SetLogIndexDB(a.indexDB)
	}

	// --- Shell exec ---
	// Optional and disabled by default. When enabled, the agent can
	// execute shell commands on the host, subject to allow/deny lists.
	if a.cfg.ShellExec.Enabled {
		shellCfg := tools.ShellExecConfig{
			Enabled:        true,
			WorkingDir:     a.cfg.ShellExec.WorkingDir,
			AllowedCmds:    a.cfg.ShellExec.AllowedPrefixes,
			DeniedCmds:     a.cfg.ShellExec.DeniedPatterns,
			DefaultTimeout: time.Duration(a.cfg.ShellExec.DefaultTimeoutSec) * time.Second,
		}
		if len(shellCfg.DeniedCmds) == 0 {
			shellCfg.DeniedCmds = tools.DefaultShellExecConfig().DeniedCmds
		}
		shellExec := tools.NewShellExec(shellCfg)
		a.loop.Tools().SetShellExec(shellExec)
		a.logger.Info("shell exec enabled", "working_dir", a.cfg.ShellExec.WorkingDir)
	} else {
		a.logger.Info("shell exec disabled")
	}

	// --- Web Search ---
	// Optional web search tool. Supports multiple providers; the first
	// configured provider becomes the default if none is specified.
	if a.cfg.Search.Configured() {
		primary := a.cfg.Search.Default
		mgr := search.NewManager(primary)

		if a.cfg.Search.SearXNG.Configured() {
			mgr.Register(search.NewSearXNG(a.cfg.Search.SearXNG.URL))
			if primary == "" {
				primary = "searxng"
			}
		}
		if a.cfg.Search.Brave.Configured() {
			mgr.Register(search.NewBrave(a.cfg.Search.Brave.APIKey))
			if primary == "" {
				primary = "brave"
			}
		}

		// Re-create manager with resolved primary if it was empty.
		if a.cfg.Search.Default == "" && primary != "" {
			mgr = search.NewManager(primary)
			if a.cfg.Search.SearXNG.Configured() {
				mgr.Register(search.NewSearXNG(a.cfg.Search.SearXNG.URL))
			}
			if a.cfg.Search.Brave.Configured() {
				mgr.Register(search.NewBrave(a.cfg.Search.Brave.APIKey))
			}
		}

		a.loop.Tools().SetSearchManager(mgr)
		a.logger.Info("web search enabled", "primary", primary, "providers", mgr.Providers())
	} else {
		a.logger.Warn("web search disabled (no providers configured)")
	}

	// --- Web Fetch ---
	// Always available — no configuration needed. Fetches web pages and
	// extracts readable text content.
	a.loop.Tools().SetFetcher(search.NewFetcher())

	// --- Media transcript ---
	// Wraps yt-dlp for on-demand transcript retrieval from YouTube,
	// Vimeo, podcasts, and other supported sources.
	ytdlpPath := a.cfg.Media.YtDlpPath
	if ytdlpPath == "" {
		ytdlpPath, _ = exec.LookPath("yt-dlp")
	}
	if ytdlpPath != "" {
		mc := media.New(media.Config{
			YtDlpPath:          ytdlpPath,
			CookiesFile:        a.cfg.Media.CookiesFile,
			CookiesFromBrowser: a.cfg.Media.CookiesFromBrowser,
			SubtitleLanguage:   a.cfg.Media.SubtitleLanguage,
			MaxTranscriptChars: a.cfg.Media.MaxTranscriptChars,
			WhisperModel:       a.cfg.Media.WhisperModel,
			TranscriptDir:      a.cfg.Media.TranscriptDir,
			OllamaURL:          a.modelCatalog.PrimaryOllamaURL(),
		}, a.logger)

		// Wire up LLM summarization for map-reduce transcript processing.
		// Uses a local model via router for chunk summarization.
		mc.SetSummarizer(func(ctx context.Context, prompt string) (string, error) {
			hints := map[string]string{
				router.HintMission:      "background",
				router.HintLocalOnly:    "true",
				router.HintQualityFloor: "3",
				router.HintPreferSpeed:  "true",
			}
			if a.cfg.Media.SummarizeModel != "" {
				hints[router.HintModelPreference] = a.cfg.Media.SummarizeModel
			}
			model, _ := a.rtr.Route(ctx, router.Request{
				Query:    "transcript summarization",
				Priority: router.PriorityBackground,
				Hints:    hints,
			})
			msgs := []llm.Message{{Role: "user", Content: prompt}}
			resp, err := a.llmClient.Chat(ctx, model, msgs, nil)
			if err != nil {
				return "", err
			}
			return resp.Message.Content, nil
		})

		a.loop.Tools().SetMediaClient(mc)
		a.logger.Info("media_transcript enabled", "yt_dlp", ytdlpPath)
	} else {
		a.logger.Warn("media_transcript disabled (yt-dlp not found)")
	}

	// --- Media feed tools ---
	// Feed management tools (media_follow, media_unfollow, media_feeds)
	// are always registered so the agent can manage feeds. Feed polling
	// is a separate concern controlled by FeedCheckInterval.
	feedTools := media.NewFeedTools(a.opStore, a.logger, a.cfg.Media.MaxFeeds)
	feedTools.SetLoopResolver(a.loopRegistry)
	a.loop.Tools().SetMediaFeedTools(feedTools)

	// --- Media analysis tools ---
	// The media_save_analysis tool lets the agent persist structured
	// analysis to an Obsidian-compatible vault and track engagement.
	// It requires either a per-feed output_path or the global default.
	// If the engagement store fails to open, the tool is still registered
	// without engagement tracking (vault writes still work).
	a.mediaStore, err = media.NewMediaStore(a.cfg.Media.Analysis.DatabasePath, a.logger)
	if err != nil {
		a.logger.Warn("media engagement store unavailable; analysis will persist to vault only", "error", err)
	} else if a.mediaStore != nil {
		a.onCloseErr("media", a.mediaStore.Close)
	}
	vaultWriter := media.NewVaultWriter(a.logger)
	analysisTools := media.NewAnalysisTools(
		a.opStore, a.mediaStore, vaultWriter,
		a.cfg.Media.Analysis.DefaultOutputPath, a.logger,
	)
	a.loop.Tools().SetMediaAnalysisTools(analysisTools)

	// --- Media feed polling ---
	// Periodic RSS/Atom check for new entries via the loop infrastructure.
	// The handler checks feeds against high-water marks and dispatches an
	// agent conversation only when new content is detected.
	if a.cfg.Media.FeedCheckInterval > 0 {
		feedPoller := media.NewFeedPoller(a.opStore, a.logger, media.WithFeedMessageBus(a.messageBus))
		a.mediaFeedPoller = feedPoller

		a.logger.Info("media feed polling enabled",
			"interval", time.Duration(a.cfg.Media.FeedCheckInterval)*time.Second,
			"max_feeds", a.cfg.Media.MaxFeeds,
		)
	}

	// --- Archive tools ---
	// Gives the agent the ability to search and recall past conversations.
	a.loop.Tools().SetArchiveStore(a.archiveStore)
	a.loop.Tools().SetConversationResetter(a.loop)
	a.loop.Tools().SetSessionManager(a.loop)

	// --- Embeddings ---
	// Optional semantic search over fact and contact stores. When enabled,
	// records are indexed with vector embeddings generated by a local model.
	// The client is declared here so it's available to context providers below.
	if a.cfg.Embeddings.Enabled {
		embClient := knowledge.New(knowledge.Config{
			BaseURL: a.cfg.Embeddings.BaseURL,
			Model:   a.cfg.Embeddings.Model,
		})
		factTools.SetEmbeddingClient(embClient)
		contactTools.SetEmbeddingClient(embClient)
		s.embClient = embClient
		a.logger.Info("embeddings enabled", "model", a.cfg.Embeddings.Model, "url", a.cfg.Embeddings.BaseURL)
	}

	// --- MCP servers ---
	// Connect to configured MCP servers and bridge their tools into the
	// registry. This must happen before delegate executor creation so
	// delegates have access to MCP tools.
	for _, serverCfg := range a.cfg.MCP.Servers {
		var transport mcp.Transport
		switch serverCfg.Transport {
		case "stdio":
			transport = mcp.NewStdioTransport(mcp.StdioConfig{
				Command: serverCfg.Command,
				Args:    serverCfg.Args,
				Env:     serverCfg.Env,
				Logger:  a.logger,
			})
		case "http":
			transport = mcp.NewHTTPTransport(mcp.HTTPConfig{
				URL:     serverCfg.URL,
				Headers: serverCfg.Headers,
				Logger:  a.logger,
			})
		}

		client := mcp.NewClient(serverCfg.Name, transport, a.logger)

		initCtx, initCancel := context.WithTimeout(s.ctx, 30*time.Second)
		err := client.Initialize(initCtx)
		initCancel()
		if err != nil {
			a.logger.Error("MCP server initialization failed",
				"server", serverCfg.Name,
				"error", err,
			)
			client.Close()
			continue
		}

		bridgeCtx, bridgeCancel := context.WithTimeout(s.ctx, 30*time.Second)
		count, err := mcp.BridgeTools(
			bridgeCtx,
			client, serverCfg.Name, a.loop.Tools(),
			mcp.BridgeOptions{
				Include:       serverCfg.IncludeTools,
				Exclude:       serverCfg.ExcludeTools,
				Tags:          serverCfg.Tags,
				ToolOverrides: toMCPToolOverrides(serverCfg.Tools),
			},
			a.logger,
		)
		bridgeCancel()
		if err != nil {
			a.logger.Error("MCP tool bridge failed",
				"server", serverCfg.Name,
				"error", err,
			)
			client.Close()
			continue
		}

		a.mcpClients = append(a.mcpClients, client)
		mcpName := serverCfg.Name // capture for closure
		a.onCloseErr("mcp-"+mcpName, client.Close)

		a.connMgr.Watch(s.ctx, connwatch.WatcherConfig{
			Name:    "mcp-" + serverCfg.Name,
			Probe:   func(pCtx context.Context) error { return client.Ping(pCtx) },
			Backoff: connwatch.DefaultBackoffConfig(),
			Logger:  a.logger,
		})

		a.logger.Info("MCP server connected",
			"server", serverCfg.Name,
			"tools", count,
		)
	}

	// --- Signal message bridge ---
	// Launches a native signal-cli jsonRpc subprocess and receives
	// messages event-driven, routing them through the agent loop.
	// Deferred to StartWorkers because Start() spawns a subprocess and
	// the entire tool/bridge/notification wiring depends on it running.
	//
	// deferredTools is now a narrow escape hatch for the macOS
	// companion calendar tool, whose registration still happens inside
	// a deferWorker closure. Signal used to live here too; it has
	// since migrated to the tools.Provider async-binding pattern
	// (see internal/channels/messaging/signal/tool_provider.go). Tools
	// registered synchronously in any init phase land in the
	// capability-tag snapshot naturally (finalizeCapabilityTags runs
	// last) and never belong here. New subsystems should follow the
	// Signal pattern rather than adding entries here.
	s.deferredTools = make(map[string]bool)
	if a.cfg.Companion.Configured() {
		s.deferredTools["macos_calendar_events"] = true
	}
	if a.cfg.Signal.Configured() {
		// Signal tools are declared at init time via the Provider so
		// they appear in the capability-tag snapshot immediately. The
		// handlers return tools.ErrUnavailable until Bind is called
		// from the deferred worker below, once signal-cli has started.
		signalToolProvider := sigcli.NewToolProvider()
		a.loop.Tools().RegisterProvider(signalToolProvider)
		a.loop.Tools().RegisterChannelReactionHandler("signal", signalToolProvider.HandleChannelReaction)

		signalArgs := append([]string{"-a", a.cfg.Signal.Account, "jsonRpc"}, a.cfg.Signal.Args...)
		signalClient := sigcli.NewClient(a.cfg.Signal.Command, signalArgs, a.logger)

		a.deferWorker("signal", func(ctx context.Context) error {
			if err := signalClient.Start(ctx); err != nil {
				a.logger.Error("signal-cli start failed", "error", err)
				return nil // non-fatal: system works without Signal
			}
			a.signalClient = signalClient
			if dispatcher := a.ensureLoopCompletionDispatcher(); dispatcher != nil {
				dispatcher.ConfigureSignalSender(func(ctx context.Context, recipient, message string) error {
					return (&signalChannelSender{client: signalClient}).SendMessage(ctx, recipient, message)
				})
			}
			a.onCloseErr("signal", signalClient.Close)

			bridge := sigcli.NewBridge(sigcli.BridgeConfig{
				Client:        signalClient,
				Runner:        &loopAdapter{agentLoop: a.loop, router: a.rtr, capSurface: a.capSurfaceGetter()},
				Logger:        a.logger,
				RateLimit:     a.cfg.Signal.RateLimitPerMinute,
				HandleTimeout: a.cfg.Signal.HandleTimeout,
				Routing:       a.cfg.Signal.Routing,
				Resolver: &contactChannelBindingResolver{
					store:            contactStore,
					ownerContactName: a.cfg.Identity.OwnerContactName,
				},
				BindConversation: a.mem.BindConversationChannel,
				Attachments: sigcli.AttachmentConfig{
					SourceDir: a.cfg.Signal.AttachmentSourceDir,
					DestDir:   a.cfg.Signal.AttachmentDir,
					MaxSize:   a.cfg.Signal.MaxAttachmentSize,
				},
				AttachmentStore: a.attachmentStore,
				VisionAnalyzer:  a.visionAnalyzer,
				Registry:        a.loopRegistry,
				EventBus:        a.eventBus,
			})
			if err := bridge.Register(ctx); err != nil {
				a.logger.Error("signal bridge registration failed", "error", err)
			}
			a.signalBridge = bridge

			// Bind the declared Signal tools now that the client and
			// bridge are ready. Invocations that happened before this
			// point returned tools.ErrUnavailable; subsequent calls
			// succeed.
			signalToolProvider.Bind(signalClient, bridge)

			a.connMgr.Watch(ctx, connwatch.WatcherConfig{
				Name:    "signal",
				Probe:   func(pCtx context.Context) error { return signalClient.Ping(pCtx) },
				Backoff: connwatch.DefaultBackoffConfig(),
				Logger:  a.logger,
			})

			// Register Signal as a notification delivery channel so the
			// notification router can route to Signal when the contact
			// has an active Signal session.
			if a.notifRouter != nil {
				sp := notifications.NewSignalProvider(
					signalClient, contactStore, a.logger,
				)
				sp.SetRecorder(&signalMemoryRecorder{mem: a.mem})
				a.notifRouter.RegisterProvider(sp)
				a.logger.Info("signal notification provider registered")
			}

			a.logger.Info("signal bridge started",
				"command", a.cfg.Signal.Command,
				"account", a.cfg.Signal.Account,
				"rate_limit", a.cfg.Signal.RateLimitPerMinute,
			)
			return nil
		})
	}

	// MQTT wake tools (mqtt_wake_list/add/remove) are registered in
	// initServers, which runs before finalizeCapabilityTags, so they
	// land in the capability-tag snapshot naturally and don't need
	// a deferredTools exemption. See #733.

	return nil
}
