import { ProjectContext } from "./project-context.js";
/**
 * Central registry of ProjectContext instances.
 *
 * Manages per-project state contexts and provides aggregation methods
 * for cross-project queries (goals, sessions, search).
 */
export class ProjectContextManager {
    contexts = new Map();
    registry;
    sessionResolver = null;
    _sessionResolverWarned = false;
    /**
     * Shared dispatcher for `goal_created` / `goal_archived` staff triggers.
     * Wired post-boot by `server.ts` once the staff/inbox managers exist.
     * Stored here so every existing AND every future-lazy-created
     * ProjectContext gets the same dispatcher reference.
     */
    goalTriggerDispatcher = null;
    constructor(registry) {
        this.registry = registry;
    }
    /**
     * Wire dependencies used by cross-project services (notably the search
     * orphan filter). Called once during boot after the SessionManager is
     * instantiated. The manager itself is still constructed with only the
     * registry so existing callers need no change.
     */
    setDependencies(deps) {
        this.sessionResolver = deps.sessionManager;
    }
    /** Initialize contexts for all registered projects. */
    initAll() {
        for (const project of this.registry.list()) {
            this.getOrCreate(project.id);
        }
    }
    /** Get or lazily create a ProjectContext. */
    getOrCreate(projectId) {
        let ctx = this.contexts.get(projectId);
        if (ctx)
            return ctx;
        const project = this.registry.get(projectId);
        if (!project)
            return null;
        ctx = new ProjectContext(project);
        ctx.open();
        // Propagate any post-boot dispatcher wiring to lazily-created contexts.
        if (this.goalTriggerDispatcher) {
            ctx.setGoalTriggerDispatcher(this.goalTriggerDispatcher);
        }
        this.contexts.set(projectId, ctx);
        return ctx;
    }
    /**
     * Late-bound: register the shared `GoalTriggerDispatcher` and wire it into
     * every existing context. Subsequent `getOrCreate` calls will pick up the
     * dispatcher automatically. Safe to call before or after `initAll()`.
     */
    setGoalTriggerDispatcher(dispatcher) {
        this.goalTriggerDispatcher = dispatcher;
        for (const ctx of this.contexts.values()) {
            ctx.setGoalTriggerDispatcher(dispatcher);
        }
    }
    /** Get the underlying project registry. */
    getRegistry() {
        return this.registry;
    }
    /** Resolve which project a goal belongs to by scanning all contexts. */
    getContextForGoal(goalId) {
        for (const ctx of this.contexts.values()) {
            if (ctx.goalStore.get(goalId))
                return ctx;
        }
        return null;
    }
    /** Resolve which project a session belongs to by scanning all contexts. */
    getContextForSession(sessionId) {
        for (const ctx of this.contexts.values()) {
            if (ctx.sessionStore.get(sessionId))
                return ctx;
        }
        return null;
    }
    // ── Aggregation methods ────────────────────────────────────────
    /** All live (non-archived) goals across all projects, sorted by updatedAt desc. */
    getAllLiveGoals() {
        const goals = [];
        for (const ctx of this.visible()) {
            goals.push(...ctx.goalStore.getLive());
        }
        return goals.sort((a, b) => b.updatedAt - a.updatedAt);
    }
    /** All live (non-archived) sessions across all projects. */
    getAllLiveSessions() {
        const sessions = [];
        for (const ctx of this.visible()) {
            sessions.push(...ctx.sessionStore.getLive());
        }
        return sessions;
    }
    /** All goals (including archived) across all projects. */
    getAllGoals() {
        const goals = [];
        for (const ctx of this.visible()) {
            goals.push(...ctx.goalStore.getAll());
        }
        return goals.sort((a, b) => b.updatedAt - a.updatedAt);
    }
    /** All sessions (including archived) across all projects. */
    getAllSessions() {
        const sessions = [];
        for (const ctx of this.visible()) {
            sessions.push(...ctx.sessionStore.getAll());
        }
        return sessions;
    }
    /** Aggregate search across all (or filtered) project indexes. */
    async searchAll(query, opts = {}) {
        const rawResults = [];
        const limit = opts.limit ?? 50;
        const offset = opts.offset ?? 0;
        for (const ctx of this.contexts.values()) {
            // Skip hidden contexts (e.g. synthetic system project) — they
            // must never surface in user-facing search results.
            if (ctx.project.hidden)
                continue;
            // Filter by projectId if specified
            if (opts.projectId && ctx.project.id !== opts.projectId)
                continue;
            if (!ctx.searchIndex)
                continue;
            const out = ctx.searchIndex.search(query, {
                type: opts.type,
                // Fetch enough results for cross-project merging
                limit: limit + offset,
                offset: 0,
                projectId: undefined, // Already filtered above
                projectNames: opts.projectNames,
            });
            const { results } = out instanceof Promise ? await out : out;
            rawResults.push(...results);
        }
        // Orphan filter + weak-match drop (message-only).
        const dropped = [];
        const filtered = rawResults.filter((hit) => {
            if (!this._hitExists(hit)) {
                dropped.push(hit);
                return false;
            }
            if (hit.type === "message" && hit.matchedOn === "metadata") {
                // Phantom match — token hit metadata only, user can't see why.
                dropped.push(hit);
                return false;
            }
            return true;
        });
        // Opportunistic cleanup — fire-and-forget.
        if (dropped.length > 0)
            this._scheduleOpportunisticCleanup(dropped);
        // Sort by timestamp descending (most recent first) and apply pagination.
        filtered.sort((a, b) => b.timestamp - a.timestamp);
        return {
            results: filtered.slice(offset, offset + limit),
            total: filtered.length,
        };
    }
    /**
     * Orphan existence check. Returns false for hits whose backing entity
     * (goal, session, message's parent session, staff) no longer exists, or
     * whose project is no longer registered.
     */
    _hitExists(hit) {
        // Project gate — registry is ultimate source of truth.
        if (hit.projectId && !this.registry.get(hit.projectId))
            return false;
        const ctx = hit.projectId ? this.contexts.get(hit.projectId) : undefined;
        if (!ctx)
            return false;
        // SearchResult.id is now emitted as a bare entity id (the source
        // prefix is stripped in toSearchResult for goal/session/staff). For
        // messages, the parent session id is carried separately on hit.sessionId.
        switch (hit.type) {
            case "goal": {
                const goalId = hit.goalId ?? hit.id;
                return ctx.goalStore.get(goalId) !== undefined;
            }
            case "session": {
                // Fail-open when session resolver isn't wired (e.g. unit tests or
                // alternate bootstraps that skip setDependencies). Silently dropping
                // every session/message hit is worse than returning a stale row.
                if (!this.sessionResolver) {
                    this._warnSessionResolverMissing();
                    return true;
                }
                const sessionId = hit.sessionId ?? hit.id;
                return this.sessionResolver.getPersistedSession(sessionId) !== undefined;
            }
            case "message":
                if (!this.sessionResolver) {
                    this._warnSessionResolverMissing();
                    return true;
                }
                if (!hit.sessionId)
                    return false;
                return this.sessionResolver.getPersistedSession(hit.sessionId) !== undefined;
            case "staff": {
                return ctx.staffStore.get(hit.id) !== undefined;
            }
            case "file":
                return true; // files source not in scope
            default:
                return true;
        }
    }
    /**
     * Fire-and-forget: when a hit is dropped as orphaned, enqueue a removal
     * from the owning project's search index so subsequent queries don't
     * return the same orphan row. Not awaited.
     */
    /** One-time warning when the session resolver wiring is missing. */
    _warnSessionResolverMissing() {
        if (this._sessionResolverWarned)
            return;
        this._sessionResolverWarned = true;
        console.warn("[search] orphan filter disabled: sessionManager not wired");
    }
    _scheduleOpportunisticCleanup(dropped) {
        // De-dupe session-level message purges — one op per session is enough.
        const messageSessionsPurged = new Set();
        for (const hit of dropped) {
            const ctx = hit.projectId ? this.contexts.get(hit.projectId) : undefined;
            const idx = ctx?.searchIndex;
            if (!idx)
                continue;
            // hit.id is the bare entity id (prefix stripped in toSearchResult).
            // The remove* methods re-apply the goal:/session:/staff: prefix
            // internally when addressing the FlexSearch index.
            try {
                switch (hit.type) {
                    case "goal":
                        idx.removeGoal(hit.goalId ?? hit.id);
                        break;
                    case "session":
                        idx.removeSession(hit.sessionId ?? hit.id);
                        break;
                    case "message":
                        if (hit.sessionId && !messageSessionsPurged.has(hit.sessionId)) {
                            messageSessionsPurged.add(hit.sessionId);
                            idx.removeMessagesForSession(hit.sessionId);
                        }
                        break;
                    case "staff":
                        idx.removeStaff(hit.id);
                        break;
                }
            }
            catch (err) {
                console.warn("[search] opportunistic cleanup failed:", err);
            }
        }
    }
    // ── Generation counters (for polling optimization) ─────────────
    /** Sum of all goal store generations — any single project change is detected. */
    getGoalGeneration() {
        let gen = 0;
        for (const ctx of this.contexts.values()) {
            gen += ctx.goalStore.getGeneration();
        }
        return gen;
    }
    /** Sum of all session store generations — any single project change is detected. */
    getSessionGeneration() {
        let gen = 0;
        for (const ctx of this.contexts.values()) {
            gen += ctx.sessionStore.getGeneration();
        }
        return gen;
    }
    // ── Lifecycle ──────────────────────────────────────────────────
    /** Close all contexts on shutdown. */
    closeAll() {
        for (const ctx of this.contexts.values()) {
            ctx.close();
        }
        this.contexts.clear();
    }
    /** Remove a context when a project is unregistered. */
    remove(projectId) {
        const ctx = this.contexts.get(projectId);
        if (ctx) {
            ctx.close();
            this.contexts.delete(projectId);
        }
    }
    /**
     * Iterate **every** context the manager owns, including hidden ones
     * (e.g. the synthetic system project registered for system-scope
     * tool-assistant sessions).
     *
     * Use this for callers that legitimately need every context:
     * `getContextForSession`, `findStoreForStaff`, MCP discovery, etc.
     *
     * If you are iterating to do worktree/pool/UI work where the system
     * project must be skipped, use {@link visible} instead.
     */
    all() {
        return this.contexts.values();
    }
    /**
     * Iterate only **visible** contexts — i.e. excluding `hidden: true`
     * projects (the synthetic system project).
     *
     * Use this for worktree sweepers, worktree-pool init, goal/task
     * pool-resolver wiring, maintenance endpoints, and user-facing
     * listings/aggregations. The hidden system project must never
     * participate in any of these flows; iterating it caused
     * `pool/_pool-*` branches to be allocated in unrelated host repos
     * when the bobbit state dir was nested inside one (see
     * `tests/system-project-pool-leak.test.ts`).
     */
    *visible() {
        for (const ctx of this.contexts.values()) {
            if (ctx.project.hidden)
                continue;
            yield ctx;
        }
    }
    /** Number of initialized project contexts. */
    get size() {
        return this.contexts.size;
    }
}
//# sourceMappingURL=project-context-manager.js.map