package cli

import (
	"bufio"
	"context"
	"errors"
	"fmt"
	"io"
	"log"
	"net"
	"os"
	"path/filepath"
	"strings"
	"time"

	"github.com/charmbracelet/huh"

	"github.com/dirstral/dir2mcp/internal/appstate"
	"github.com/dirstral/dir2mcp/internal/buildinfo"
	"github.com/dirstral/dir2mcp/internal/config"
	"github.com/dirstral/dir2mcp/internal/corpusfs"
	"github.com/dirstral/dir2mcp/internal/identity"
	"github.com/dirstral/dir2mcp/internal/index"
	"github.com/dirstral/dir2mcp/internal/ingest"
	"github.com/dirstral/dir2mcp/internal/mcp"
	"github.com/dirstral/dir2mcp/internal/model"
	"github.com/dirstral/dir2mcp/internal/protocol"
	"github.com/dirstral/dir2mcp/internal/provider"
	"github.com/dirstral/dir2mcp/internal/providerfactory"
	"github.com/dirstral/dir2mcp/internal/retrieval"
	"github.com/dirstral/dir2mcp/internal/setupwizard"
)

func (a *App) runUp(ctx context.Context, opts upOptions) int {
	// First-run guided setup (SPEC §147) must run here, in the launching parent
	// while still attached to the TTY — before any daemon fork, whose child has
	// no terminal and would silently skip the wizard. maybeFirstRunSetup is a
	// no-op on non-TTY / --json / --non-interactive runs, in the daemon child,
	// and when an embedding provider already resolves.
	if code := a.maybeFirstRunSetup(opts); code != exitSuccess {
		return code
	}

	// When this is the launching parent (not the daemon child) and the
	// caller hasn't asked for foreground/JSON behavior, fork a detached
	// child to run the server and exit the parent once the child is
	// ready. The child re-enters runUp with daemonChildEnv set and falls
	// through to the in-process body below.
	if shouldDaemonize(a, opts) {
		return a.runUpAsDaemonParent(ctx, opts)
	}

	cfg, auth, tlsCertFile, tlsKeyFile, nonInteractiveMode, code := a.prepareUpConfig(opts)
	if code != exitSuccess {
		return code
	}

	st, textBuilt, codeBuilt, code := a.initStoreAndIndices(ctx, &cfg, opts.jsonOutput)
	if code != exitSuccess {
		return code
	}
	defer func() { _ = st.Close() }()
	textIx, textIndexPath := textBuilt.index, textBuilt.path
	codeIx, codeIndexPath := codeBuilt.index, codeBuilt.path
	defer func() { _ = textIx.Close() }()
	defer func() { _ = codeIx.Close() }()

	embedder, generator, etm, ecm := a.resolveModelClients(cfg)
	ret := retrieval.NewService(st, textIx, embedder, generator)
	ret.SetQueryEmbeddingModel(etm)
	ret.SetCodeEmbeddingModel(ecm)
	ret.SetCodeIndex(codeIx)
	ret.SetRootDir(cfg.RootDir)
	ret.SetStateDir(cfg.StateDir)
	ret.SetProtocolVersion(cfg.ProtocolVersion)
	ret.SetRAGSystemPrompt(cfg.RAGSystemPrompt)
	ret.SetMaxContextChars(cfg.RAGMaxContextChars)
	ret.SetOversampleFactor(cfg.RAGOversampleFactor)
	a.configureReranker(ret, cfg)
	ret.SetCrossFileDedupEnabled(cfg.DedupRetrieval)
	ret.SetMinScore(cfg.RetrievalMinScore)

	// events are emitted to stdout only after we create the emitter; moving
	// creation before the preload call lets us report failures from that
	// bootstrap step as structured events (see dirstral-spec/docs/SPEC.md for NDJSON schema).
	emitter := newNDJSONEmitter(a.stdout, opts.jsonOutput)

	// Load the cross-file dedup hash map AFTER the emitter exists so a load
	// failure is reported as a structured NDJSON warning event (machine-parseable
	// in JSON/automation flows), consistent with other bootstrap steps.
	a.loadCrossFileDedupHashes(ctx, cfg, st, ret, emitter)

	indexingState := initIndexingState(ctx, st, ret, emitter, a.stderr)
	ret.SetIndexingCompleteProvider(func() bool {
		return !indexingState.Snapshot().Running
	})

	serverOptions := buildMCPServerOptions(&cfg, st, indexingState, emitter)
	mcpServer := mcp.NewServer(cfg, ret, serverOptions...)
	ing, err := a.newIngestor(cfg, st)
	if err != nil {
		writeCLIError(a.stderr, opts.jsonOutput, exitConfigInvalid, fmt.Sprintf("initialize ingestor: %v", err))
		return exitConfigInvalid
	}
	wireIngestorHooks(ing, indexingState, ret.EvictDocuments)

	corpusFS, err := buildCorpusFS(ctx, cfg)
	if err != nil {
		writeCLIError(a.stderr, opts.jsonOutput, exitConfigInvalid, fmt.Sprintf("initialize corpus source: %v", err))
		return exitConfigInvalid
	}
	if setter, ok := ing.(corpusFSSetter); ok {
		setter.SetCorpusFS(corpusFS)
	}

	emitter.Emit("info", "index_loaded", map[string]interface{}{
		"state_dir": cfg.StateDir,
	})

	ln, err := net.Listen("tcp", cfg.ListenAddr)
	if err != nil {
		writeCLIError(a.stderr, opts.jsonOutput, exitServerBindFailure, fmt.Sprintf("bind server: %v", err))
		return exitServerBindFailure
	}
	runCtx, cancel := context.WithCancel(ctx)
	defer cancel()
	defer func() { _ = ln.Close() }()

	cleanupPIDFile, code := a.setupDaemonChildIfApplicable(cfg.StateDir, opts, cancel)
	if code != exitSuccess {
		return code
	}
	defer cleanupPIDFile()

	persistence := index.NewPersistenceManager(
		[]index.IndexedFile{
			{Path: textIndexPath, Index: textIx},
			{Path: codeIndexPath, Index: codeIx},
		},
		15*time.Second,
		func(saveErr error) { writef(a.stderr, "index autosave warning: %v\n", saveErr) },
	)
	persistence.Start(runCtx)
	defer a.stopPersistenceWithLog(persistence)

	embedErrCh := make(chan error, 4)
	if err := startEmbeddingIfNotReadOnly(runCtx, cfg, opts.readOnly, st, textIx, codeIx, embedder, ret, indexingState, embedErrCh, a.stderr, opts.jsonOutput, etm, ecm, cfg.RootDir, corpusFS); err != nil {
		// A distributed-embedding setup failure is fatal: refuse to run a server
		// that would silently never drain its pending queue.
		writeCLIError(a.stderr, opts.jsonOutput, exitConfigInvalid, fmt.Sprintf("start embedding: %v", err))
		return exitConfigInvalid
	}

	mcpAddr := ln.Addr().String()
	if cfg.Public {
		mcpAddr = publicURLAddress(cfg.ListenAddr, mcpAddr)
	}
	mcpURL := buildMCPURL(mcpAddr, cfg.MCPPath, tlsCertFile != "")

	transport := mcp.NewSDKTransport(mcpServer, ln, tlsCertFile, tlsKeyFile)

	serverErrCh := make(chan error, 1)
	go func() {
		serverErrCh <- transport.Serve(runCtx, mcpServer.Handler())
	}()

	emitter.Emit("info", "server_started", map[string]interface{}{
		"url":         mcpURL,
		"listen_addr": ln.Addr().String(),
		"public":      cfg.Public,
	})

	connection, code := a.publishConnection(cfg, mcpURL, auth, emitter, opts)
	if code != exitSuccess {
		return code
	}

	stdinQuitCh := a.installInteractionForUp(cancel, cfg, connection, auth, opts, nonInteractiveMode)

	ingestErrCh := make(chan error, 1)
	go runCorpusWriter(runCtx, cfg.StateDir, st, indexingState, a.stderr, emitter)
	startIngestWorker(runCtx, opts.readOnly, ing, indexingState, ingestErrCh)
	startWatchWorker(runCtx, opts.readOnly, cfg.IngestWatch, ing, a.stderr)

	return a.runEventLoop(runCtx, cancel, &cfg, st, indexingState, emitter, serverErrCh, ingestErrCh, embedErrCh, stdinQuitCh)
}

// applyTLSConfig resolves TLS cert/key from opts and cfg, validates them, and
// returns the effective paths along with an exit code (exitSuccess on success).
func (a *App) applyTLSConfig(cfg *config.Config, opts upOptions) (tlsCertFile, tlsKeyFile string, code int) {
	tlsCertFile = strings.TrimSpace(opts.tlsCert)
	if tlsCertFile == "" {
		tlsCertFile = strings.TrimSpace(cfg.ServerTLSCertFile)
	}
	tlsKeyFile = strings.TrimSpace(opts.tlsKey)
	if tlsKeyFile == "" {
		tlsKeyFile = strings.TrimSpace(cfg.ServerTLSKeyFile)
	}
	if err := validateTLSFlags(tlsCertFile, tlsKeyFile); err != nil {
		writeCLIError(a.stderr, opts.jsonOutput, exitConfigInvalid, fmt.Sprintf("CONFIG_INVALID: %v", err))
		return "", "", exitConfigInvalid
	}
	cfg.ServerTLSCertFile = tlsCertFile
	cfg.ServerTLSKeyFile = tlsKeyFile
	return tlsCertFile, tlsKeyFile, exitSuccess
}

// applyUpFlagOverrides applies all CLI flag overrides to cfg and resolves the
// x402 token source. It returns the token source label and an exit code.
func (a *App) applyUpFlagOverrides(cfg *config.Config, opts upOptions) (x402TokenSource string, code int) {
	// warn when both direct token and token file are supplied
	if opts.x402FacilitatorTokenDirectSet && opts.x402FacilitatorTokenFile != "" {
		writef(a.stderr, "warning: --x402-facilitator-token ignored; using --x402-facilitator-token-file\n")
	}

	applyScalarOverrides(cfg, opts)

	src, code := a.resolveX402Token(cfg, opts)
	if code != exitSuccess {
		return "", code
	}
	return src, exitSuccess
}

// applyScalarOverrides applies simple flag-to-config assignments that have no
// side-effects (no I/O, no validation).
func applyScalarOverrides(cfg *config.Config, opts upOptions) {
	if opts.listen != "" {
		cfg.ListenAddr = opts.listen
	}
	if strings.TrimSpace(cfg.ListenAddr) == "" {
		cfg.ListenAddr = protocol.DefaultListenAddr
	}
	if opts.mcpPath != "" {
		cfg.MCPPath = opts.mcpPath
	}
	if strings.TrimSpace(cfg.MCPPath) == "" {
		cfg.MCPPath = protocol.DefaultMCPPath
	}
	if opts.auth != "" {
		cfg.AuthMode = opts.auth
	}
	if opts.allowedOrigins != "" {
		cfg.AllowedOrigins = config.MergeAllowedOrigins(cfg.AllowedOrigins, opts.allowedOrigins)
	}
	applyX402Overrides(cfg, opts)
	resolveServerName(cfg)
}

// resolveServerName finalizes cfg.ServerName so downstream code (mcp
// server, banner) can read a non-empty value without re-deriving the
// auto-name. The override (if any) wins; otherwise we hash the absolute
// RootDir so the name is stable across cwd changes and reinstalls.
//
// Dev builds use a `dir2mcp-dev-` prefix so a developer running their
// in-tree binary alongside the brew-installed release sees two visibly
// distinct entries in `claude mcp list` instead of colliding on the
// same auto-derived name.
func resolveServerName(cfg *config.Config) {
	abs, err := filepath.Abs(cfg.RootDir)
	if err != nil {
		abs = cfg.RootDir
	}
	cfg.ServerName = identity.Resolve(abs, cfg.ServerName, buildinfo.IsDev())
}

// applyX402Overrides applies x402-specific flag overrides to cfg.
func applyX402Overrides(cfg *config.Config, opts upOptions) {
	if strings.TrimSpace(opts.x402Mode) != "" {
		cfg.X402.Mode = strings.TrimSpace(opts.x402Mode)
	}
	if strings.TrimSpace(opts.x402FacilitatorURL) != "" {
		cfg.X402.FacilitatorURL = strings.TrimSpace(opts.x402FacilitatorURL)
	}
	if strings.TrimSpace(opts.x402ResourceBaseURL) != "" {
		cfg.X402.ResourceBaseURL = strings.TrimSpace(opts.x402ResourceBaseURL)
	}
	if strings.TrimSpace(opts.x402Network) != "" {
		cfg.X402.Network = strings.TrimSpace(opts.x402Network)
	}
	if strings.TrimSpace(opts.x402Price) != "" {
		cfg.X402.PriceAtomic = strings.TrimSpace(opts.x402Price)
	}
	if strings.TrimSpace(opts.x402Scheme) != "" {
		cfg.X402.Scheme = strings.TrimSpace(opts.x402Scheme)
	}
	if strings.TrimSpace(opts.x402Asset) != "" {
		cfg.X402.Asset = strings.TrimSpace(opts.x402Asset)
	}
	if strings.TrimSpace(opts.x402PayTo) != "" {
		cfg.X402.PayTo = strings.TrimSpace(opts.x402PayTo)
	}
	if opts.x402ToolsCallEnabledIsSet {
		cfg.X402.ToolsCallEnabled = opts.x402ToolsCallEnabled
	}
}

// resolveX402Token resolves the x402 facilitator token from file, env, flag,
// or existing config. Returns the source label and an exit code.
func (a *App) resolveX402Token(cfg *config.Config, opts upOptions) (source string, code int) {
	// precedence: file path > env var > flag > pre-configured
	if opts.x402FacilitatorTokenFile != "" {
		data, err := os.ReadFile(filepath.Clean(opts.x402FacilitatorTokenFile))
		if err != nil {
			writeCLIError(a.stderr, opts.jsonOutput, exitAuthOrPayment, fmt.Sprintf("failed to read x402 facilitator token file: %v", err))
			return "", exitAuthOrPayment
		}
		token := strings.TrimSpace(string(data))
		if token == "" {
			writeCLIError(a.stderr, opts.jsonOutput, exitAuthOrPayment, "x402 facilitator token file is empty")
			return "", exitAuthOrPayment
		}
		cfg.X402.FacilitatorToken = token
		return "file", exitSuccess
	}
	if token := strings.TrimSpace(os.Getenv(x402FacilitatorTokenEnvVar)); token != "" {
		cfg.X402.FacilitatorToken = token
		return "env", exitSuccess
	}
	if strings.TrimSpace(opts.x402FacilitatorToken) != "" {
		cfg.X402.FacilitatorToken = strings.TrimSpace(opts.x402FacilitatorToken)
		return "flag", exitSuccess
	}
	if strings.TrimSpace(cfg.X402.FacilitatorToken) != "" {
		return "configured", exitSuccess
	}
	return "", exitSuccess
}

// validateUpConfig runs all post-override config validations (public mode,
// MCP path prefix, x402, root/state directories).
func (a *App) validateUpConfig(cfg *config.Config, opts upOptions) int {
	if cfg.Public || opts.public {
		if code := a.applyPublicMode(cfg, opts); code != exitSuccess {
			return code
		}
	}
	if !strings.HasPrefix(cfg.MCPPath, "/") {
		writeCLIError(a.stderr, opts.jsonOutput, exitConfigInvalid, "CONFIG_INVALID: --mcp-path must start with '/'")
		return exitConfigInvalid
	}
	strictX402 := strings.EqualFold(strings.TrimSpace(cfg.X402.Mode), "required")
	if err := cfg.ValidateX402(strictX402); err != nil {
		writeCLIError(a.stderr, opts.jsonOutput, exitConfigInvalid, fmt.Sprintf("x402 configuration invalid: %v", err))
		return exitConfigInvalid
	}
	if strings.EqualFold(strings.TrimSpace(cfg.IngestExtractor), "docling") && ingest.DocumentExtractorFromConfig(*cfg) == nil {
		writeCLIError(a.stderr, opts.jsonOutput, exitConfigInvalid, "CONFIG_INVALID: ingest.extractor=docling but docling command is unavailable")
		return exitConfigInvalid
	}
	if strings.EqualFold(strings.TrimSpace(cfg.IngestExtractor), "docling-serve") {
		if d := ingest.DescribeDocumentExtractor(*cfg); d.Name == "" {
			writeCLIError(a.stderr, opts.jsonOutput, exitConfigInvalid, fmt.Sprintf("CONFIG_INVALID: %s", d.Reason))
			return exitConfigInvalid
		}
	}
	if err := ensureRootAccessible(cfg.RootDir); err != nil {
		writeCLIError(a.stderr, opts.jsonOutput, exitRootInaccessible, fmt.Sprintf("root inaccessible: %v", err))
		return exitRootInaccessible
	}
	if err := os.MkdirAll(cfg.StateDir, 0o755); err != nil {
		writeCLIError(a.stderr, opts.jsonOutput, exitRootInaccessible, fmt.Sprintf("create state dir: %v", err))
		return exitRootInaccessible
	}
	// create payments subdirectory while x402 configuration has been
	// validated above. creating the state directory first ensures the
	// parent exists. the call is intentionally unconditional here: we ensure
	// the directory exists regardless of mode, avoiding inconsistent state.
	// because it's done after x402 validation, a valid config (including
	// mode="off") won't leave an inconsistent state.
	if err := os.MkdirAll(filepath.Join(cfg.StateDir, "payments"), 0o755); err != nil {
		writeCLIError(a.stderr, opts.jsonOutput, exitRootInaccessible, fmt.Sprintf("create payments dir: %v", err))
		return exitRootInaccessible
	}
	return exitSuccess
}

// applyPublicMode enforces public-mode listen address and auth rules.
func (a *App) applyPublicMode(cfg *config.Config, opts upOptions) int {
	cfg.Public = true
	// Public mode defaults to all interfaces unless operator provided --listen explicitly.
	if opts.listen == "" {
		port := "0"
		if _, parsedPort, splitErr := net.SplitHostPort(cfg.ListenAddr); splitErr == nil && parsedPort != "" {
			port = parsedPort
		}
		cfg.ListenAddr = net.JoinHostPort("0.0.0.0", port)
	}
	authMode := strings.TrimSpace(cfg.AuthMode)
	if strings.EqualFold(authMode, "none") && !opts.forceInsecure {
		writeCLIError(
			a.stderr,
			opts.jsonOutput,
			exitConfigInvalid,
			"--public requires auth. Use --auth auto or --force-insecure to override (unsafe).",
		)
		return exitConfigInvalid
	}
	return exitSuccess
}

// checkMistralAPIKey is the §2.5 startup preflight for the embedding
// provider (SPEC 8.1.3): it requires that an embed provider *resolves*
// — a credentialed or credential-less profile that can serve embed.
// Mistral via MISTRAL_API_KEY remains the default that satisfies it.
// (Name retained to avoid caller churn.)
func (a *App) checkMistralAPIKey(cfg *config.Config, opts upOptions, nonInteractiveMode bool) int {
	if !requiresMistralAPIKey(opts) {
		return exitSuccess
	}
	hint := "Set a provider credential (e.g. MISTRAL_API_KEY / OPENAI_API_KEY) or configure providers: in .dir2mcp.yaml"
	prof, embErr := cfg.Providers().Resolve(provider.CapEmbed)
	if embErr != nil {
		msg := "CONFIG_INVALID: no embedding provider configured"
		var ce *provider.ConfigError
		if errors.As(embErr, &ce) {
			// Surface the actionable reason (bad explicit binding /
			// incapable kind) instead of the generic message
			// (ce.Error() is already CONFIG_INVALID-prefixed).
			msg = ce.Error()
		}
		return a.reportEmbedPreflightError(opts, nonInteractiveMode, msg, hint)
	}
	// Resolution alone is not enough: the runtime also builds the
	// adapter (resolveModelClients -> providerfactory.Embedder). Fail
	// fast here rather than letting a non-read-only server ingest with
	// a nil embedder and silently produce no embeddings.
	if _, ferr := providerfactory.Embedder(prof); ferr != nil {
		return a.reportEmbedPreflightError(opts, nonInteractiveMode,
			fmt.Sprintf("CONFIG_INVALID: embedding provider %q is unusable: %v", prof.Name, ferr), hint)
	}
	return exitSuccess
}

// reportEmbedPreflightError emits the §2.5 preflight failure in the
// CLI's JSON / non-interactive / interactive styles.
func (a *App) reportEmbedPreflightError(opts upOptions, nonInteractiveMode bool, msg, hint string) int {
	if opts.jsonOutput {
		writeCLIError(a.stderr, true, exitConfigInvalid, msg, hint, "Or run: dir2mcp config init")
		return exitConfigInvalid
	}
	se := a.sty(opts.jsonOutput)
	if nonInteractiveMode {
		writef(a.stderr, "%s %s\n", se.errPrefix(), msg)
		writeln(a.stderr, hint)
		writeln(a.stderr, "Or run: dir2mcp config init")
	} else {
		writef(a.stderr, "%s %s\n", se.errPrefix(), strings.TrimPrefix(msg, "CONFIG_INVALID: "))
		writeln(a.stderr, "Run: dir2mcp config init")
	}
	return exitConfigInvalid
}

// requiresMistralAPIKey reports whether the embed-provider preflight
// applies. Embedding workers require a resolvable embed provider unless
// running read-only (serving an existing index, where the embedder is
// never exercised). (Name retained to avoid caller churn.)
func requiresMistralAPIKey(opts upOptions) bool {
	return !opts.readOnly
}

// builtIndex pairs a constructed vector index with the on-disk path it persists
// to, so runUp can wire the PersistenceManager with the backend-appropriate
// path (HNSW v2 snapshot vs. disk segment).
type builtIndex struct {
	index model.Index
	path  string
}

// initStoreAndIndices initialises the metadata store and both vector indices,
// dispatching on cfg.IndexBackend ("memory" default | "disk" | "qdrant" |
// "pgvector"; issues #246, #268, #269). On success the caller is responsible for
// closing all three.
func (a *App) initStoreAndIndices(ctx context.Context, cfg *config.Config, jsonOutput bool) (model.Store, builtIndex, builtIndex, int) {
	st := a.storeForConfig(*cfg)
	if err := st.Init(ctx); err != nil && !errors.Is(err, model.ErrNotImplemented) {
		writeCLIError(a.stderr, jsonOutput, exitIndexLoadFailure, fmt.Sprintf("initialize metadata store: %v", err))
		return nil, builtIndex{}, builtIndex{}, exitIndexLoadFailure
	}

	// The persisted snapshot format is versioned (issue #247): for the memory
	// backend the v2 file (vectors_*.v2.hnsw) carries vectors + per-vector
	// payloads + the embed identity, where the legacy file held a bare vector
	// map; for the disk backend the segment (vectors_*.diskv1.idx) memory-maps
	// vector payloads on disk (issue #246). A missing snapshot is treated as a
	// fresh index and repopulated on the next reindex.
	identity := cfg.Providers().EmbedIdentity()
	textIx, code := a.loadVectorIndex(ctx, cfg, index.KindText, identity, jsonOutput)
	if code != exitSuccess {
		_ = st.Close()
		return nil, builtIndex{}, builtIndex{}, code
	}
	codeIx, code := a.loadVectorIndex(ctx, cfg, index.KindCode, identity, jsonOutput)
	if code != exitSuccess {
		_ = st.Close()
		_ = textIx.index.Close()
		return nil, builtIndex{}, builtIndex{}, code
	}
	return st, textIx, codeIx, exitSuccess
}

// loadVectorIndex constructs the configured vector backend for one kind via the
// index.backend dispatch (issues #246, #268, #269), restores it from its
// persisted snapshot, and reconciles its recorded embed identity with the
// configured one (issue #247): EnsureIdentity resets the index when the identity
// is empty (fresh) or differs, so a vector space built under a different embed
// provider/model/dimension is never silently reused.
func (a *App) loadVectorIndex(ctx context.Context, cfg *config.Config, kind, identity string, jsonOutput bool) (builtIndex, int) {
	// Networked backends (Qdrant #268, pgvector #269) carry connection config
	// NewBackend can't reach and have no local persistence path, so they branch
	// out before the local memory/disk dispatch. EnsureIdentity still runs (both
	// implement Identity/Reset); the Persistable Load step is skipped because the
	// remote store owns its own durability.
	if strings.EqualFold(strings.TrimSpace(cfg.IndexBackend), index.BackendQdrant) {
		return a.loadQdrantIndex(ctx, cfg, kind, identity, jsonOutput)
	}
	if strings.EqualFold(strings.TrimSpace(cfg.IndexBackend), index.BackendPgvector) {
		return a.loadPgvectorIndex(ctx, cfg, kind, identity, jsonOutput)
	}
	ix, path := index.NewBackend(cfg.IndexBackend, cfg.StateDir, kind)
	if p, ok := ix.(model.Persistable); ok {
		if err := p.Load(ctx, path); err != nil &&
			!errors.Is(err, model.ErrNotImplemented) &&
			!errors.Is(err, os.ErrNotExist) {
			writeCLIError(a.stderr, jsonOutput, exitIndexLoadFailure, fmt.Sprintf("load %s index: %v", kind, err))
			_ = ix.Close()
			return builtIndex{}, exitIndexLoadFailure
		}
	}
	if err := index.EnsureIdentity(ctx, ix, identity); err != nil {
		writeCLIError(a.stderr, jsonOutput, exitIndexLoadFailure, fmt.Sprintf("reconcile %s index identity: %v", kind, err))
		_ = ix.Close()
		return builtIndex{}, exitIndexLoadFailure
	}
	return builtIndex{index: ix, path: path}, exitSuccess
}

// loadQdrantIndex constructs the networked Qdrant backend for one kind (issue
// #268): it dials Qdrant (verifying reachability up front — no silent fallback)
// against a per-kind collection, then reconciles the recorded embed identity.
// There is no local persistence path, so builtIndex.path is left empty and the
// PersistenceManager is never wired for this kind.
func (a *App) loadQdrantIndex(ctx context.Context, cfg *config.Config, kind, identity string, jsonOutput bool) (builtIndex, int) {
	ix, err := index.NewQdrantBackend(ctx, index.QdrantParams{
		URL:        cfg.Qdrant.URL,
		APIKey:     cfg.Qdrant.APIKey,
		Collection: cfg.Qdrant.Collection,
	}, kind)
	if err != nil {
		writeCLIError(a.stderr, jsonOutput, exitIndexLoadFailure, fmt.Sprintf("connect %s index to qdrant: %v", kind, err))
		return builtIndex{}, exitIndexLoadFailure
	}
	if err := index.EnsureIdentity(ctx, ix, identity); err != nil {
		writeCLIError(a.stderr, jsonOutput, exitIndexLoadFailure, fmt.Sprintf("reconcile %s index identity: %v", kind, err))
		_ = ix.Close()
		return builtIndex{}, exitIndexLoadFailure
	}
	return builtIndex{index: ix, path: ""}, exitSuccess
}

// loadPgvectorIndex constructs the networked PostgreSQL + pgvector backend for
// one kind (issue #269): it connects (verifying reachability and the pgvector
// extension up front — no silent fallback) against a per-kind table, then
// reconciles the recorded embed identity. A missing DSN, an unreachable server,
// or a missing extension is reported as a remediable error and aborts startup.
// There is no local persistence path, so builtIndex.path is left empty and the
// PersistenceManager is never wired for this kind.
func (a *App) loadPgvectorIndex(ctx context.Context, cfg *config.Config, kind, identity string, jsonOutput bool) (builtIndex, int) {
	dsn := strings.TrimSpace(cfg.IndexPgvectorDSN)
	if dsn == "" {
		writeCLIError(a.stderr, jsonOutput, exitConfigInvalid,
			"CONFIG_INVALID: index.backend=pgvector but no DSN configured",
			"Set DIR2MCP_INDEX_PGVECTOR_DSN (or store it in the keychain / .env.local) to a Postgres connection string.")
		return builtIndex{}, exitConfigInvalid
	}
	ix, err := index.NewPgvectorBackend(ctx, index.PgvectorParams{
		DSN:    dsn,
		Schema: cfg.IndexPgvectorSchema,
		Table:  cfg.IndexPgvectorTable,
	}, kind)
	if err != nil {
		writeCLIError(a.stderr, jsonOutput, exitIndexLoadFailure, fmt.Sprintf("connect %s index to pgvector: %v", kind, err))
		return builtIndex{}, exitIndexLoadFailure
	}
	if err := index.EnsureIdentity(ctx, ix, identity); err != nil {
		writeCLIError(a.stderr, jsonOutput, exitIndexLoadFailure, fmt.Sprintf("reconcile %s index identity: %v", kind, err))
		_ = ix.Close()
		return builtIndex{}, exitIndexLoadFailure
	}
	return builtIndex{index: ix, path: ""}, exitSuccess
}

// buildMCPServerOptions constructs the list of mcp.ServerOption values,
// including optional ElevenLabs TTS when configured.
func buildMCPServerOptions(cfg *config.Config, st model.Store, indexingState *appstate.IndexingState, emitter *ndjsonEmitter) []mcp.ServerOption {
	opts := []mcp.ServerOption{
		mcp.WithStore(st),
		mcp.WithIndexingState(indexingState),
		mcp.WithEventEmitter(emitter.Emit),
	}
	// TTS is optional and fail-open (SPEC 8.3). Prefer ElevenLabs when
	// it is eligible (preserves the legacy "ELEVENLABS_API_KEY -> TTS"
	// behavior, incl. its voice/base-url config, even when an LLM
	// provider key is also present); otherwise fall back to auto
	// precedence among TTS-capable providers.
	r := cfg.Providers()
	prof, err := r.ResolveExplicit(provider.CapTTS, "elevenlabs", true)
	if err != nil {
		prof, err = r.Resolve(provider.CapTTS)
	}
	if err == nil {
		if tts, terr := providerfactory.TTS(prof); terr == nil {
			opts = append(opts, mcp.WithTTS(tts))
		}
	}
	return opts
}

// pickEmbedLogger returns a logger appropriate for the embed workers based on
// whether JSON output mode is active (discard) or not (stderr).
func pickEmbedLogger(stderr io.Writer, jsonOutput bool) *log.Logger {
	if jsonOutput {
		return log.New(io.Discard, "", 0)
	}
	return log.New(stderr, "", log.LstdFlags)
}

// startStdinQuitListener starts an optional goroutine that closes the returned
// channel when the user types a quit command on stdin.
func startStdinQuitListener(nonInteractiveMode, jsonOutput bool) chan struct{} {
	ch := make(chan struct{})
	if nonInteractiveMode || jsonOutput {
		return ch
	}
	go func() {
		scanner := bufio.NewScanner(os.Stdin)
		for scanner.Scan() {
			line := strings.TrimSpace(strings.ToLower(scanner.Text()))
			if line == "q" || line == "quit" || line == "exit" || line == "stop" || line == "" {
				close(ch)
				return
			}
		}
		close(ch)
	}()
	return ch
}

// startIngestWorker launches the ingest goroutine (or closes the channel
// immediately when readOnly is true).
func startIngestWorker(runCtx context.Context, readOnly bool, ing interface {
	Run(context.Context) error
}, indexingState *appstate.IndexingState, ingestErrCh chan error) {
	if readOnly {
		close(ingestErrCh)
		return
	}
	go func() {
		defer close(ingestErrCh)
		// mode is already set at creation time; just mark running state
		indexingState.SetRunning(true)
		defer indexingState.SetRunning(false)
		runErr := ing.Run(runCtx)
		if errors.Is(runErr, model.ErrNotImplemented) {
			ingestErrCh <- nil
			return
		}
		ingestErrCh <- runErr
	}()
}

// watchable is implemented by ingestors that support continuous incremental
// indexing via a filesystem watcher.
type watchable interface {
	Watch(context.Context) error
}

// startWatchWorker launches the filesystem watcher in the background when
// ingest.watch is enabled and the ingestor supports it. It runs concurrently
// with the initial scan; processDocument hash-diffs, so any overlap is a cheap
// no-op. Watcher failures are non-fatal — the watcher's own periodic safety
// rescan reconciles drift — so a setup error is logged rather than terminating
// the server.
func startWatchWorker(runCtx context.Context, readOnly, enabled bool, ing interface {
	Run(context.Context) error
}, stderr io.Writer) {
	if !enabled {
		return
	}
	if readOnly {
		// Don't leave the knob silently inert — tell the operator continuous
		// sync is disabled because the server is read-only.
		_, _ = fmt.Fprintln(stderr, "warning: ingest.watch is enabled but ignored in read-only mode; continuous indexing is disabled")
		return
	}
	w, ok := ing.(watchable)
	if !ok {
		return
	}
	go func() {
		if err := w.Watch(runCtx); err != nil && runCtx.Err() == nil {
			_, _ = fmt.Fprintf(stderr, "watch: %v\n", err)
		}
	}()
}

// runEventLoop runs the main select loop until the server stops, context is
// cancelled, or a fatal error occurs.
func (a *App) runEventLoop(
	runCtx context.Context,
	cancel context.CancelFunc,
	cfg *config.Config,
	st model.Store,
	indexingState *appstate.IndexingState,
	emitter *ndjsonEmitter,
	serverErrCh <-chan error,
	ingestErrCh chan error,
	embedErrCh <-chan error,
	stdinQuitCh <-chan struct{},
) int {
	for {
		select {
		case <-runCtx.Done():
			return exitSuccess
		case <-stdinQuitCh:
			cancel()
			return exitSuccess
		case serverErr := <-serverErrCh:
			if serverErr != nil {
				writeCLIError(a.stderr, emitter.enabled, exitGeneric, fmt.Sprintf("server failed: %v", serverErr))
				emitter.Emit("error", "fatal", map[string]interface{}{
					"code":    "SERVER_FAILURE",
					"message": serverErr.Error(),
				})
				return exitGeneric
			}
			return exitSuccess
		case ingestErr, ok := <-ingestErrCh:
			if !ok {
				ingestErrCh = nil
				_ = writeCorpusSnapshot(runCtx, cfg.StateDir, st, indexingState, a.stderr, emitter)
				continue
			}
			if ingestErr == nil {
				_ = writeCorpusSnapshot(runCtx, cfg.StateDir, st, indexingState, a.stderr, emitter)
				continue
			}
			writeCLIError(a.stderr, emitter.enabled, exitIngestionFatal, fmt.Sprintf("ingestion failed: %v", ingestErr))
			emitter.Emit("error", "file_error", map[string]interface{}{
				"message": ingestErr.Error(),
			})
			emitter.Emit("error", "fatal", map[string]interface{}{
				"code":    "INGESTION_FATAL",
				"message": ingestErr.Error(),
			})
			return exitIngestionFatal
		case embedErr := <-embedErrCh:
			if embedErr == nil {
				continue
			}
			writeCLIError(a.stderr, emitter.enabled, exitGeneric, fmt.Sprintf("embedding worker warning: %v", embedErr))
			emitter.Emit("error", "embed_error", map[string]interface{}{
				"message": embedErr.Error(),
			})
		}
	}
}

func preloadEmbeddedChunkMetadata(ctx context.Context, source embeddedChunkLister, ret *retrieval.Service) (int, error) {
	if source == nil || ret == nil {
		return 0, nil
	}
	const pageSize = 500
	total := 0
	kinds := []string{"text", "code"}
	for _, kind := range kinds {
		offset := 0
		for {
			tasks, err := source.ListEmbeddedChunkMetadata(ctx, kind, pageSize, offset)
			if err != nil {
				if errors.Is(err, model.ErrNotImplemented) {
					break
				}
				return total, err
			}
			for _, task := range tasks {
				// ToSearchHit carries every field the in-memory metadata needs,
				// including the representation Language (SPEC §5.2/§8.8) so the
				// per-language retrieval filter (§9.5) works against warm-loaded
				// metadata after a restart, not just freshly-indexed chunks.
				ret.SetChunkMetadataForIndex(kind, task.Metadata.ChunkID, task.Metadata.ToSearchHit())
				total++
			}
			if len(tasks) < pageSize {
				break
			}
			offset += len(tasks)
		}
	}
	return total, nil
}

func startEmbeddingWorkers(
	ctx context.Context,
	st index.ChunkSource,
	textIndex model.Index,
	codeIndex model.Index,
	embedder model.Embedder,
	ret *retrieval.Service,
	indexingState *appstate.IndexingState,
	errCh chan<- error,
	logger *log.Logger,
	textModel, codeModel, rootDir string,
	corpusFS corpusfs.CorpusFS,
) {
	if st == nil || embedder == nil {
		return
	}

	start := func(kind string, ix model.Index) {
		if ix == nil {
			return
		}
		workerKind := kind
		worker := &index.EmbeddingWorker{
			Source:       st,
			Index:        ix,
			Embedder:     embedder,
			ModelForText: textModel,
			ModelForCode: codeModel,
			RootDir:      rootDir,
			Corpus:       corpusFS,
			BatchSize:    32,
			Logger:       logger,
			OnIndexedChunk: func(label uint64, metadata model.ChunkMetadata) {
				if ret != nil {
					ret.SetChunkMetadataForIndex(workerKind, label, metadata.ToSearchHit())
				}
				if indexingState != nil {
					indexingState.AddEmbeddedOK(1)
				}
			},
		}

		go func() {
			err := worker.Run(ctx, 750*time.Millisecond, workerKind)
			if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
				return
			}
			errCh <- fmt.Errorf("%s worker: %w", workerKind, err)
		}()
	}

	start("text", textIndex)
	start("code", codeIndex)
}

// upNonInteractiveMode reports whether the server was started in a context
// where interactive prompts are not possible (flag set, or stdin/stdout are
// not terminals).
func upNonInteractiveMode(opts upOptions) bool {
	return opts.nonInteractive || !isTerminal(os.Stdin) || !isTerminal(os.Stdout)
}

// warnConfigSnapshotErr writes a warning to stderr when the config snapshot
// could not be saved and the server is not running in quiet mode.
func warnConfigSnapshotErr(stderr io.Writer, quiet bool, err error) {
	if err != nil && !quiet {
		writef(stderr, "warning: write config snapshot: %v\n", err)
	}
}

// loadCrossFileDedupHashes wires the rel_path → content_hash map used by
// retrieval-time cross-file de-duplication (SPEC 9.2) onto the retrieval
// service. It is a no-op when dedup is disabled, or when the store does not
// implement model.DocumentHashLister; a load error is non-fatal (dedup simply
// stays a pass-through) since it must never block server startup. A load failure
// is reported as a structured NDJSON warning event (machine-parseable in
// JSON/automation flows), mirroring the embedded-chunk-metadata bootstrap step.
func (a *App) loadCrossFileDedupHashes(ctx context.Context, cfg config.Config, st model.Store, ret *retrieval.Service, emitter *ndjsonEmitter) {
	if !cfg.DedupRetrieval || st == nil || ret == nil {
		return
	}
	lister, ok := st.(model.DocumentHashLister)
	if !ok {
		return
	}
	hashes, err := lister.ListDocumentHashes(ctx)
	if err != nil {
		if emitter != nil {
			emitter.Emit("warning", "bootstrap_cross_file_dedup_hashes", map[string]interface{}{
				"error": err.Error(),
			})
		}
		writef(a.stderr, "warning: load document hashes for retrieval dedup: %v\n", err)
		return
	}
	ret.SetDocumentHashes(hashes)
}

// initIndexingState creates a new IndexingState and optionally preloads
// already-embedded chunk metadata from the store.
func initIndexingState(ctx context.Context, st model.Store, ret *retrieval.Service, emitter *ndjsonEmitter, stderr io.Writer) *appstate.IndexingState {
	indexingState := appstate.NewIndexingState(appstate.ModeIncremental)
	metadataStore, ok := st.(embeddedChunkLister)
	if !ok {
		return indexingState
	}
	chunks, err := preloadEmbeddedChunkMetadata(ctx, metadataStore, ret)
	if err != nil {
		// surface the problem in both stderr and the NDJSON event stream so
		// automation can detect a bootstrap warning.
		writef(stderr, "bootstrap embedded chunk metadata: %v\n", err)
		emitter.Emit("warning", "bootstrap_embedded_chunk_metadata", map[string]interface{}{
			"message": err.Error(),
		})
	}
	if chunks > 0 {
		indexingState.AddEmbeddedOK(int64(chunks))
	}
	return indexingState
}

// corpusFSSetter is implemented by ingestors that accept an injected corpus
// filesystem backend (the concrete *ingest.Service does). Asserted so the
// configured backend (local/nfs/s3) drives discovery and reads.
type corpusFSSetter interface {
	SetCorpusFS(fsys corpusfs.CorpusFS)
}

// buildCorpusFS constructs the corpus filesystem selected by cfg.Source
// (issue #244). The default kind (local) yields a LocalFS rooted at cfg.RootDir,
// preserving the historical behavior exactly. StateDir is always local and is
// where the S3 backend caches downloads.
func buildCorpusFS(ctx context.Context, cfg config.Config) (corpusfs.CorpusFS, error) {
	return corpusfs.New(ctx, corpusfs.Config{
		Kind:              cfg.Source.Kind,
		RootDir:           cfg.RootDir,
		StateDir:          cfg.StateDir,
		S3Bucket:          cfg.Source.S3Bucket,
		S3Prefix:          cfg.Source.S3Prefix,
		S3Region:          cfg.Source.S3Region,
		S3Endpoint:        cfg.Source.S3Endpoint,
		S3AccessKeyID:     cfg.Source.S3AccessKeyID,
		S3SecretAccessKey: cfg.Source.S3SecretAccessKey,
		S3SessionToken:    cfg.Source.S3SessionToken,
	})
}

// wireIngestorHooks connects the optional indexing-state and document-delete
// notification interfaces on ing, if the concrete type supports them.
func wireIngestorHooks(ing model.Ingestor, indexingState *appstate.IndexingState, evict func([]string)) {
	if stateAware, ok := ing.(indexingStateAware); ok {
		stateAware.SetIndexingState(indexingState)
	}
	if notifier, ok := ing.(documentDeleteNotifier); ok {
		notifier.SetOnDocumentsDeleted(evict)
	}
}

// stopPersistenceWithLog stops the persistence manager and logs any non-cancel
// errors to stderr.
func (a *App) stopPersistenceWithLog(persistence *index.PersistenceManager) {
	stopCtx, stopCancel := context.WithTimeout(context.Background(), 3*time.Second)
	defer stopCancel()
	if stopErr := persistence.StopAndSave(stopCtx); stopErr != nil && !errors.Is(stopErr, context.Canceled) {
		writef(a.stderr, "final index save warning: %v\n", stopErr)
	}
}

// startEmbeddingIfNotReadOnly starts embedding workers when readOnly is false
// and the store exposes the ChunkSource interface. When distributed embedding is
// enabled (issue #248, SPEC §8.7) it instead starts the in-process degenerate
// case of the coordinator+worker topology; otherwise it keeps the historical
// in-process embedding loop unchanged (local-first single-binary default, §1.2).
// It returns an error only for a distributed-mode SETUP failure (broker cannot be
// built, store lacks ChunkTaskByID, no embed identity), which the caller treats as
// fatal — the historical in-process path never errors here.
func startEmbeddingIfNotReadOnly(ctx context.Context, cfg config.Config, readOnly bool, st model.Store, textIx, codeIx model.Index, embedder model.Embedder, ret *retrieval.Service, indexingState *appstate.IndexingState, embedErrCh chan error, stderr io.Writer, jsonOutput bool, embedModelText, embedModelCode, rootDir string, corpusFS corpusfs.CorpusFS) error {
	if readOnly {
		return nil
	}
	chunkSource, ok := st.(index.ChunkSource)
	if !ok {
		return nil
	}
	embedLogger := pickEmbedLogger(stderr, jsonOutput)
	if cfg.DistributedEmbed.Enabled {
		return startDistributedEmbedding(ctx, cfg, st, chunkSource, textIx, codeIx, embedder, ret, indexingState, embedErrCh, embedLogger, embedModelText, embedModelCode, rootDir, corpusFS)
	}
	startEmbeddingWorkers(ctx, chunkSource, textIx, codeIx, embedder, ret, indexingState, embedErrCh, embedLogger, embedModelText, embedModelCode, rootDir, corpusFS)
	return nil
}

// printHumanConnectionIfVerbose prints the human-readable connection block
// when neither --json nor --quiet is active.
func (a *App) printHumanConnectionIfVerbose(cfg config.Config, connection connectionPayload, auth authMaterial, opts upOptions) {
	if !opts.jsonOutput && !opts.quiet {
		a.printHumanConnection(cfg, connection, auth, opts.readOnly)
	}
}

// prepareUpConfig runs the full config-load + validation pipeline that
// must succeed before runUp can bind a listener: loadConfigWithGlobalOptions,
// TLS resolution, flag overrides, validateUpConfig, Mistral key check,
// auth material prep, and the effective-config snapshot write. Returns
// the resolved cfg, auth material, TLS file paths, the derived
// non-interactive flag, and an exit code; runUp returns immediately
// when the code is non-zero (the helper has already written the error
// to stderr).
//
// Extracted from runUp to keep that function under the
// cyclomatic-complexity budget after PR #174's daemon-setup branches
// landed; the exit-code-or-fall-through pattern collected enough `if`
// guards in runUp to push it over.
func (a *App) prepareUpConfig(opts upOptions) (config.Config, authMaterial, string, string, bool, int) {
	cfg, err := loadConfigWithGlobalOptions(opts.globalOptions)
	if err != nil {
		writeCLIError(a.stderr, opts.jsonOutput, exitConfigInvalid, fmt.Sprintf("load config: %v", err))
		return config.Config{}, authMaterial{}, "", "", false, exitConfigInvalid
	}
	tlsCertFile, tlsKeyFile, code := a.applyTLSConfig(&cfg, opts)
	if code != exitSuccess {
		return config.Config{}, authMaterial{}, "", "", false, code
	}
	x402TokenSource, code := a.applyUpFlagOverrides(&cfg, opts)
	if code != exitSuccess {
		return config.Config{}, authMaterial{}, "", "", false, code
	}
	if code := a.validateUpConfig(&cfg, opts); code != exitSuccess {
		return config.Config{}, authMaterial{}, "", "", false, code
	}
	nonInteractiveMode := upNonInteractiveMode(opts)
	if code := a.checkMistralAPIKey(&cfg, opts, nonInteractiveMode); code != exitSuccess {
		return config.Config{}, authMaterial{}, "", "", false, code
	}
	// SPEC 8.1.4: refuse to mix vector spaces if the embed
	// provider/model changed since the index was built.
	if err := cfg.VerifyEmbedIdentity(cfg.StateDir); err != nil {
		writeCLIError(a.stderr, opts.jsonOutput, exitConfigInvalid, err.Error(),
			"The embed provider/model changed since the index was built — run `dir2mcp reindex`, or restore the previous embed config.")
		return config.Config{}, authMaterial{}, "", "", false, exitConfigInvalid
	}
	auth, err := prepareAuthMaterial(cfg)
	if err != nil {
		writeCLIError(a.stderr, opts.jsonOutput, exitAuthOrPayment, fmt.Sprintf("auth setup: %v", err))
		return config.Config{}, authMaterial{}, "", "", false, exitAuthOrPayment
	}
	cfg.AuthMode = auth.mode
	cfg.ResolvedAuthToken = auth.token
	warnConfigSnapshotErr(a.stderr, opts.quiet, saveEffectiveConfigSnapshot(cfg, auth, x402TokenSource))
	return cfg, auth, tlsCertFile, tlsKeyFile, nonInteractiveMode, exitSuccess
}

// maybeFirstRunSetup runs the guided setup wizard (SPEC §147) when `up` starts
// interactively but no embedding provider resolves yet. It must be called from
// the launching parent (before any daemon fork) so it still owns the TTY; it
// persists the chosen corpus profile and credentials to disk, which the daemon
// child (or the foreground prepareUpConfig) then reloads. A skipped or aborted
// wizard is not an error — the normal §2.5 preflight reports the missing
// provider as before.
//
// upNonInteractiveMode also covers the daemon child (no TTY), so the wizard only
// ever runs in the interactive parent and is never shown twice.
func (a *App) maybeFirstRunSetup(opts upOptions) int {
	if !requiresMistralAPIKey(opts) || upNonInteractiveMode(opts) || opts.jsonOutput {
		return exitSuccess
	}
	cfg, err := loadConfigWithGlobalOptions(opts.globalOptions)
	if err != nil {
		return exitSuccess // let the standard config-load path report it
	}
	if _, rerr := cfg.Providers().Resolve(provider.CapEmbed); rerr == nil {
		return exitSuccess // already configured — no first-run prompt
	}

	se := a.sty(opts.jsonOutput)
	writef(a.stderr, "%s no embedding provider configured — starting guided setup\n", se.dim("•"))

	configPath := resolveConfigPath(opts.globalOptions)
	envPath := filepath.Join(filepath.Dir(configPath), ".env.local")
	configExisted := false
	if _, statErr := os.Stat(configPath); statErr == nil {
		configExisted = true
	}

	res, rerr := setupwizard.Run(setupwizard.Input{
		ExistingKeys:  setupwizard.DetectExistingKeys(envPath),
		ConfigExisted: configExisted,
	})
	if errors.Is(rerr, huh.ErrUserAborted) {
		return exitSuccess // fall through to the standard preflight error
	}
	if rerr != nil {
		writeCLIError(a.stderr, opts.jsonOutput, exitGeneric, fmt.Sprintf("setup wizard: %v", rerr))
		return exitGeneric
	}

	// Persisted to disk (.env.local + .dir2mcp.yaml); the daemon child or the
	// foreground prepareUpConfig reloads config from disk afterward.
	return a.persistFirstRunSetup(opts, configPath, envPath, configExisted, res)
}

// persistFirstRunSetup applies the wizard's corpus profile to the config file
// and writes collected credentials to .env.local (mirroring `config init`), then
// adds .gitignore protection when inside a git repo.
func (a *App) persistFirstRunSetup(opts upOptions, configPath, envPath string, configExisted bool, res setupwizard.Result) int {
	fileCfg := config.Default()
	if configExisted {
		if existing, lerr := config.LoadFile(configPath); lerr == nil {
			fileCfg = existing
		}
	}
	setupwizard.ApplyCorpusProfile(&fileCfg, res.Profile)
	if err := config.SaveFile(configPath, fileCfg); err != nil {
		writeCLIError(a.stderr, opts.jsonOutput, exitGeneric, fmt.Sprintf("save config file: %v", err))
		return exitGeneric
	}
	if _, err := setupwizard.PersistKeys(envPath, res.Keys, secretWriter(res.Destination)); err != nil {
		writeCLIError(a.stderr, opts.jsonOutput, exitGeneric, fmt.Sprintf("save credentials: %v", err))
		return exitGeneric
	}
	if res.Destination == setupwizard.DestFile {
		a.protectSecretsFromGit(filepath.Dir(configPath))
	}
	return exitSuccess
}

// publishConnection writes connection.json and emits the standard
// "connection / scan_progress / embed_progress" startup events. Split
// from runUp purely to keep that function under the cyclomatic budget
// after PR #174's daemon-setup branches landed.
func (a *App) publishConnection(cfg config.Config, mcpURL string, auth authMaterial, emitter *ndjsonEmitter, opts upOptions) (connectionPayload, int) {
	connection := buildConnectionPayload(cfg, mcpURL, auth)
	if err := writeConnectionFile(filepath.Join(cfg.StateDir, connectionFileName), connection); err != nil {
		writeCLIError(a.stderr, opts.jsonOutput, exitGeneric, fmt.Sprintf("write %s: %v", connectionFileName, err))
		return connectionPayload{}, exitGeneric
	}
	emitter.Emit("info", "connection", connection)
	emitter.Emit("info", "scan_progress", map[string]interface{}{
		"scanned": 0,
		"indexed": 0,
		"skipped": 0,
		"deleted": 0,
		"reps":    0,
		"chunks":  0,
		"errors":  0,
	})
	emitter.Emit("info", "embed_progress", map[string]interface{}{
		"embedded": 0,
		"chunks":   0,
		"errors":   0,
	})
	return connection, exitSuccess
}

// setupDaemonChildIfApplicable performs the two daemon-child-only
// preconditions that have to happen BEFORE writing connection.json: the
// SIGTERM handler install and the O_EXCL pid-file claim. The handler is
// installed first so a SIGTERM that arrives during the window between
// here and the event loop is converted into a graceful cancel rather
// than killing the child abruptly. The pid-file claim must come before
// connection.json so a second daemon racing for the same state dir
// loses deterministically and exits before touching anything else.
//
// Returns the deferred cleanup closure (a no-op when not running as
// daemon child) and an exit code; runUp returns immediately when the
// code is non-zero (the helper has already written the error).
//
// Extracted from runUp to keep that function under the
// cyclomatic-complexity budget after PR #174's daemon-child setup grew.
func (a *App) setupDaemonChildIfApplicable(stateDir string, opts upOptions, cancel context.CancelFunc) (cleanup func(), code int) {
	cleanup = func() {}
	if !isRunningAsDaemonChild() {
		return cleanup, exitSuccess
	}
	installDaemonChildSignalHandler(cancel)
	pidPath := pidFilePath(stateDir)
	if err := claimPIDFile(pidPath, os.Getpid()); err != nil {
		writeCLIError(a.stderr, opts.jsonOutput, exitGeneric,
			fmt.Sprintf("claim pid file %s: %v", pidPath, err),
			"another dir2mcp daemon is already running for this state directory; check with `dir2mcp status` or stop it with `dir2mcp down`.",
		)
		return cleanup, exitGeneric
	}
	return func() { _ = removePIDFile(pidPath) }, exitSuccess
}

// installInteractionForUp picks the right termination interaction for
// the current run mode and returns the channel runEventLoop watches.
//
//   - Daemon child: skip the foreground banner (the parent printed a
//     daemon-ready summary on the user's terminal) and translate
//     SIGTERM/SIGINT into cancel() so `dir2mcp down` can stop us cleanly
//     through the existing event-loop shutdown path. The stdin listener
//     is suppressed because the child has no terminal.
//   - Foreground: print the banner and start the q+Enter stdin listener
//     the way the pre-daemonization code did.
//
// Extracted from runUp purely to keep that function under the
// cyclomatic-complexity budget after daemon mode was added.
func (a *App) installInteractionForUp(
	cancel context.CancelFunc,
	cfg config.Config,
	connection connectionPayload,
	auth authMaterial,
	opts upOptions,
	nonInteractiveMode bool,
) <-chan struct{} {
	if isRunningAsDaemonChild() {
		installDaemonChildSignalHandler(cancel)
		return startStdinQuitListener(true, opts.jsonOutput)
	}
	a.printHumanConnectionIfVerbose(cfg, connection, auth, opts)
	return startStdinQuitListener(nonInteractiveMode, opts.jsonOutput)
}

// shouldDaemonize decides whether `dir2mcp up` should fork a detached
// daemon child instead of running the server in the foreground.
//
// Daemon mode is the default for an interactive shell — `dir2mcp up`
// returns control once the server is bound and ready. The opt-outs:
//   - --foreground / -f: explicit caller request
//   - --json: NDJSON event stream callers (smoke tests, automation) need
//     events on stdout in real time, which detaching breaks
//   - daemon child: we're already inside the forked process; fall through
//     to the in-process server body
//   - non-unix platforms: setsid isn't available, so degrade gracefully
//   - stdout not a TTY: piped, redirected, or being scraped by a test
//     harness — the caller wants to see real-time output (and any
//     startup errors) on the same stream the parent would have used.
//     Daemon mode hides the child's stderr inside the server log, which
//     is unhelpful for non-interactive callers.
//
// The TTY check is the gate that keeps existing CLI/integration tests
// (which shell out and expect synchronous up/down semantics) working
// without changes.
func shouldDaemonize(a *App, opts upOptions) bool {
	if opts.foreground || opts.jsonOutput {
		return false
	}
	if isRunningAsDaemonChild() {
		return false
	}
	if !isDaemonSupported() {
		return false
	}
	if !writerIsTerminal(a.stdout) && !opts.daemon {
		return false
	}
	return true
}

// writerIsTerminal reports whether w corresponds to a terminal file
// descriptor. Anything that isn't an *os.File (a bytes.Buffer in tests,
// a pipe in CI) is treated as non-terminal.
func writerIsTerminal(w io.Writer) bool {
	f, ok := w.(*os.File)
	if !ok {
		return false
	}
	return isTerminal(f)
}
