/**
 * Workspace Manager
 *
 * Coordinates multiple repository graph managers.
 * Provides cross-repo search, wikilink validation, and unified operations.
 */
import { join } from 'path';
import { existsSync, readdirSync, mkdirSync, readFileSync } from 'fs';
import { execFileSync } from 'child_process';
import { homedir } from 'os';
import { BaseGraphManager } from './graph.js';
import { ENTITY_SCHEMAS } from './validation.js';
import { AssetIndexService } from '../services/assets/asset-index-service.js';
import { VectorService } from '../services/vector-service.js';
import { loadUserConfig } from './user-config.js';
import { tryResolve } from './secrets/read-flip.js';
import { SchemaRegistry } from './schema-registry.js';
/**
 * Resolve an R2 credential field, Phase 6-aware.
 *
 * Order: encrypted store (when flag on) → workspace.json field →
 * user.json field → env var → empty string. The first three are the
 * legacy precedence the original initializeGraphs reproduced
 * literally; we keep them as transitional fallback so a misconfigured
 * importer doesn't break R2 access. The env var is included for the
 * standard `R2_*` shell-override case (existing behavior).
 *
 * The store wins over workspace.json on purpose: post Phase 6, the
 * file is no longer authoritative for secrets, and a stale value in
 * it should not shadow whatever the operator set via the API.
 */
function resolveR2Field(storeKey, workspaceField, userField, envField) {
    const fromStore = tryResolve(storeKey);
    if (fromStore)
        return fromStore;
    return workspaceField || userField || envField || '';
}
/**
 * Resolve an R2 structural field (account_id / bucket / public_url).
 *
 * Same precedence shape as `resolveR2Field`: store wins, legacy
 * workspace.json block is the transitional fallback. The store path
 * covers the env-var case automatically (registry entries have R2_*
 * aliases). The legacy fallback is what keeps mis-migrated workspaces
 * working until they're either redeployed or hand-edited.
 *
 * No `endpoint` field — the S3 client below derives it from
 * `accountId` (`https://<account>.r2.cloudflarestorage.com`). If a
 * future change adds non-Cloudflare S3-compatible storage, register
 * `r2.endpoint` then alongside the R2AssetService change that
 * consumes it.
 */
function resolveR2StructuralField(storeKey, workspaceField) {
    const fromStore = tryResolve(storeKey);
    if (fromStore)
        return fromStore;
    return workspaceField || undefined;
}
export class WorkspaceManager {
    workspacePath;
    assetIndex;
    config;
    graphs;
    gitUser;
    vectorService;
    schemaRegistry;
    constructor(workspacePath, config, gitUser) {
        this.workspacePath = workspacePath;
        this.config = config;
        this.gitUser = gitUser;
        this.graphs = new Map();
        // Workspace-global asset ownership index (one assets.db per workspace,
        // repo is a column). Constructed before initializeGraphs so it can be
        // injected into every graph.
        this.assetIndex = new AssetIndexService(workspacePath);
        // Build SchemaRegistry from workspace config extensions (empty if none defined)
        this.schemaRegistry = new SchemaRegistry(config.schema_extensions);
        // Initialize VectorService from user config (non-fatal if unavailable)
        const userConfig = loadUserConfig();
        const dbPath = userConfig.vector_db_path || join(homedir(), '.studiograph', 'vector');
        this.vectorService = new VectorService(dbPath, userConfig.voyage_api_key);
        // Give the vector service the assets.db handle so entry upserts can fold an
        // embedded image's caption (assets.db, keyed by assetId) into the entry's
        // search text — asset-as-embed search surfacing (decision D2).
        this.vectorService.setAssetIndex(this.assetIndex);
        // Initialize graph managers for all repos (including private)
        this.initializeGraphs();
    }
    /** Absolute filesystem path to the workspace root. */
    getWorkspacePath() {
        return this.workspacePath;
    }
    /**
     * Get the workspace SchemaRegistry (includes custom extension types and field defs)
     */
    getSchemaRegistry() {
        return this.schemaRegistry;
    }
    /**
     * Check if a repo is personal (auto-provisioned per-user; access-controlled
     * and skipped from the routine member push/pull, but committed to its own git
     * repo like any other folder — an admin/owner clone pulls it down).
     */
    isPersonalRepo(repoName) {
        const repoConfig = this.getRepoConfig(repoName);
        return repoConfig?.personal === true;
    }
    /**
     * Ensure a personal folder's directory exists and has an initial commit in
     * its own git repo. Personal folders are committed like any other folder; we
     * do NOT add them to a workspace-root .gitignore (see registerRepo — that
     * entry was always inert and misleading).
     */
    ensurePersonalRepo(repo) {
        const repoPath = join(this.workspacePath, repo.path);
        // Create directory if missing
        if (!existsSync(repoPath)) {
            mkdirSync(repoPath, { recursive: true });
        }
        // Make initial commit if the private repo has no commits yet
        try {
            execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repoPath, stdio: 'pipe' });
        }
        catch {
            try {
                execFileSync('git', ['add', '-A'], { cwd: repoPath, stdio: 'pipe' });
                execFileSync('git', ['commit', '-m', 'Initialize private'], { cwd: repoPath, stdio: 'pipe' });
            }
            catch { /* non-fatal */ }
        }
    }
    /**
     * Initialize graph managers for all configured repos
     */
    initializeGraphs() {
        for (const repo of this.config.repos) {
            // Personal repos: ensure directory exists (excluded from git push/pull and git HTTP)
            if (repo.personal) {
                this.ensurePersonalRepo(repo);
            }
            const repoPath = join(this.workspacePath, repo.path);
            if (!existsSync(repoPath)) {
                console.warn(`Repository path does not exist: ${repoPath} (skipping)`);
                continue;
            }
            const assetConfig = this.buildAssetConfig();
            const graph = new BaseGraphManager({
                repoPath,
                repoName: repo.name,
                assetConfig,
                gitUser: this.gitUser,
                vectorService: this.vectorService,
                schemaRegistry: this.schemaRegistry,
                workspaceRoot: this.workspacePath,
            });
            graph.setAssetIndex(this.assetIndex);
            this.graphs.set(repo.name, graph);
        }
    }
    /**
     * Build the AssetConfig for a graph manager. Picks `r2` when both
     * account_id and bucket resolve (via the store first, falling back
     * to workspace.json:r2_config); falls back to `local` otherwise.
     *
     * Shared between initializeGraphs and reloadConfig so the resolution
     * logic stays in one place — pre-2026-05-12 the two call sites had
     * drifted enough that the store-vs-file precedence diverged.
     */
    buildAssetConfig() {
        const accountId = resolveR2StructuralField('r2.account_id', this.config.r2_config?.account_id);
        const bucket = resolveR2StructuralField('r2.bucket', this.config.r2_config?.bucket);
        const publicUrl = resolveR2StructuralField('r2.public_url', this.config.r2_config?.public_url);
        if (accountId && bucket) {
            const uc = loadUserConfig();
            return {
                storage: 'r2',
                r2Config: {
                    accountId,
                    bucket,
                    publicUrl: publicUrl ?? '',
                    accessKeyId: resolveR2Field('r2.access_key_id', this.config.r2_config?.access_key_id, uc.r2?.access_key_id, process.env.R2_ACCESS_KEY_ID),
                    secretAccessKey: resolveR2Field('r2.secret_access_key', this.config.r2_config?.secret_access_key, uc.r2?.secret_access_key, process.env.R2_SECRET_ACCESS_KEY),
                },
            };
        }
        return {
            storage: 'local',
            localPath: join(this.workspacePath, '.studiograph', 'assets'),
        };
    }
    /**
     * Re-read workspace.json and initialize graphs for any newly added folders.
     * Called after creating a folder so the workspace manager picks it up
     * without a full server restart.
     */
    reloadConfig() {
        const configPath = join(this.workspacePath, '.studiograph', 'workspace.json');
        if (!existsSync(configPath))
            return;
        const raw = JSON.parse(readFileSync(configPath, 'utf-8'));
        this.config = raw;
        const configRepoNames = new Set(this.config.repos.map(r => r.name));
        // Remove graphs for deleted repos
        for (const name of this.graphs.keys()) {
            if (!configRepoNames.has(name))
                this.graphs.delete(name);
        }
        // Initialize graphs for repos we don't already have
        for (const repo of this.config.repos) {
            if (this.graphs.has(repo.name))
                continue;
            if (repo.personal)
                this.ensurePersonalRepo(repo);
            const repoPath = join(this.workspacePath, repo.path);
            if (!existsSync(repoPath))
                continue;
            const refreshAssetConfig = this.buildAssetConfig();
            const graph = new BaseGraphManager({
                repoPath,
                repoName: repo.name,
                assetConfig: refreshAssetConfig,
                gitUser: this.gitUser,
                vectorService: this.vectorService,
                schemaRegistry: this.schemaRegistry,
                workspaceRoot: this.workspacePath,
            });
            // Inherit structural callbacks + body-write coordinator installed by
            // the server bootstrap so newly-added folders' Y.Doc snapshots and
            // body-write critical section stay in sync without a server restart.
            if (this.persistenceCallbacks)
                graph.setPersistenceCallbacks(this.persistenceCallbacks);
            if (this.bodyWriteCoordinator)
                graph.setBodyWriteCoordinator(this.bodyWriteCoordinator);
            graph.setAssetIndex(this.assetIndex);
            this.graphs.set(repo.name, graph);
        }
    }
    /** Workspace-global asset ownership index (immutable-keyed assets). */
    getAssetIndex() {
        return this.assetIndex;
    }
    /**
     * Install structural-mutation callbacks on every graph (current + future).
     * Called once at server startup by the Yjs persistence wiring so the
     * snapshot store stays in sync with rename / delete / type change.
     *
     * Stored on the WM so reloadConfig (which builds new graphs for newly-
     * added repos at runtime) can apply the same callbacks without a server
     * restart.
     */
    setPersistenceCallbacks(callbacks) {
        this.persistenceCallbacks = callbacks;
        for (const graph of this.graphs.values()) {
            graph.setPersistenceCallbacks(callbacks);
        }
    }
    /**
     * Install the body-write coordinator on every graph (current + future).
     * Routes every body write (autosave + server-write) through a single
     * critical section with assertWritable, per-doc lock, base-hash
     * precondition, atomic disk write, commit, broadcast, and
     * mdRevisionHash bookkeeping.
     */
    setBodyWriteCoordinator(coordinator) {
        this.bodyWriteCoordinator = coordinator;
        for (const graph of this.graphs.values()) {
            graph.setBodyWriteCoordinator(coordinator);
        }
    }
    persistenceCallbacks;
    bodyWriteCoordinator;
    /**
     * Get graph manager for a specific repo.
     */
    getGraph(repoName) {
        const graph = this.graphs.get(repoName);
        if (!graph) {
            throw new Error(`Repository not found: ${repoName}`);
        }
        return graph;
    }
    /**
     * Get all repository names
     */
    getRepoNames() {
        return Array.from(this.graphs.keys());
    }
    /**
     * Get repository configuration
     */
    getRepoConfig(repoName) {
        return this.config.repos.find(r => r.name === repoName);
    }
    /**
     * Get the personal folder auto-provisioned for a specific user.
     */
    getUserPersonalRepo(userId) {
        return this.config.repos.find(r => r.personal === true && r.owner_id === userId);
    }
    /**
     * Get all repository configurations
     */
    getAllRepoConfigs() {
        return this.config.repos;
    }
    /**
     * Mint/read the stable sidecar id for a folder (or the repo root), committing
     * the sidecar if a new id is minted. Saved views key folder scopes on this id
     * so they survive folder rename / move (D2).
     */
    ensureFolderId(repoName, folderPath, user) {
        return this.getGraph(repoName).ensureFolderId(folderPath, user);
    }
    /**
     * Resolve a stable folder id to its `{ repo, folderPath }` across all repos,
     * or null when the id no longer maps to any folder (e.g. the folder was
     * deleted). `folderPath` is `''` for a repo root.
     */
    resolveFolderById(id) {
        for (const cfg of this.config.repos) {
            try {
                const path = this.getGraph(cfg.name).findFolderPathById(id);
                if (path !== null)
                    return { repo: cfg.name, folderPath: path };
            }
            catch { /* skip unmounted repos */ }
        }
        return null;
    }
    /**
     * Build a single `folderId → { repo, folderPath }` index across all repos in
     * one pass (each repo's folder tree walked once). Use this when resolving many
     * folder ids in one request — e.g. enriching the caller's saved views — so the
     * cost is O(repos) tree walks instead of O(views × repos) (one
     * `resolveFolderById` per view each re-walking every repo).
     */
    buildFolderIdIndex() {
        const index = new Map();
        for (const cfg of this.config.repos) {
            try {
                for (const e of this.getGraph(cfg.name).folderIdEntries()) {
                    if (!index.has(e.id))
                        index.set(e.id, { repo: cfg.name, folderPath: e.path });
                }
            }
            catch { /* skip unmounted repos */ }
        }
        return index;
    }
    /**
     * Content/knowledge repos only — excludes `system` folders (the reserved
     * "Workspace Assets" store). Use this for knowledge surfaces that should
     * not see the app-managed asset folder: the agent folder inventory +
     * list_repos, graph maintenance, lint, and orphan detection. The asset
     * manager and authz checks (getAccessibleRepos / hasRepoAccess) keep
     * seeing system folders so assets stay listable + referenceable. See
     * ~/.claude/plans/workspace-asset-rearchitecture.md.
     */
    getContentRepoConfigs() {
        return this.config.repos.filter(r => !r.system);
    }
    /**
     * Read-only view of the live workspace config. Use this for lookups that
     * need fields beyond repos/r2 (e.g. `railway`, `team_name`). Mutations
     * go through dedicated methods so the in-memory copy and `workspace.json`
     * stay in sync.
     */
    getConfig() {
        return this.config;
    }
    /**
     * Search across all repositories.
     * Uses LanceDB vector/FTS search when available, keyword search as fallback.
     */
    async search(options) {
        const reposToSearch = options.repoNames || this.getRepoNames();
        // Vector/FTS path: use LanceDB when a text query is present.
        // mode=keyword opts out — autosuggest doesn't want a Voyage round-trip.
        if (options.query && this.vectorService && options.mode !== 'keyword') {
            try {
                const vectorResults = await this.vectorService.search(options.query, {
                    limit: options.limit ?? 10,
                    repos: reposToSearch,
                });
                const results = [];
                for (const vr of vectorResults) {
                    const graph = this.graphs.get(vr.repoName);
                    if (!graph)
                        continue;
                    try {
                        const entity = graph.get(vr.entityType, vr.entityId);
                        results.push({ ...entity, repoName: vr.repoName });
                    }
                    catch {
                        // Entity may have been deleted since last index
                    }
                }
                if (results.length > 0)
                    return results;
                // Fall through to keyword search if vector index is empty (e.g. not yet indexed)
            }
            catch (err) {
                console.warn('[vector] search failed, falling back to keyword:', err.message);
            }
        }
        // Keyword fallback
        return this.keywordSearch(options, reposToSearch);
    }
    /**
     * Keyword search across repos (original implementation).
     *
     * mode=keyword (autosuggest) forwards `nameOnly: true` so each repo
     * skips the per-entity body content scan.
     */
    keywordSearch(options, reposToSearch) {
        const results = [];
        const perRepoOptions = options.mode === 'keyword'
            ? { ...options, nameOnly: true }
            : options;
        for (const repoName of reposToSearch) {
            const graph = this.graphs.get(repoName);
            if (!graph)
                continue;
            try {
                const entities = graph.search(perRepoOptions);
                for (const entity of entities) {
                    results.push({ ...entity, repoName });
                }
                // Early-exit once we have enough top-tier matches across repos.
                if (options.limit && results.length >= options.limit * 4)
                    break;
            }
            catch (error) {
                console.warn(`Error searching repo ${repoName}:`, error);
            }
        }
        if (options.limit && options.limit > 0) {
            return results.slice(0, options.limit);
        }
        return results;
    }
    /**
     * List entities across all repositories
     */
    list(entityType, limit) {
        const results = [];
        for (const [repoName, graph] of this.graphs) {
            try {
                const entities = graph.list(entityType);
                for (const entity of entities) {
                    results.push({
                        ...entity,
                        repoName,
                    });
                }
            }
            catch (error) {
                console.warn(`Error listing entities in repo ${repoName}:`, error);
            }
        }
        // Apply limit
        if (limit && limit > 0) {
            return results.slice(0, limit);
        }
        return results;
    }
    /**
     * Find entity across all repositories
     */
    findEntity(entityType, entityId) {
        for (const [repoName, graph] of this.graphs) {
            try {
                const entity = graph.get(entityType, entityId);
                return {
                    ...entity,
                    repoName,
                };
            }
            catch {
                // Entity not found in this repo, continue searching
            }
        }
        return null;
    }
    /**
     * Find entity by (type, id) across all repositories. Use this when you
     * have BOTH the type and the id (e.g., the asset route's URL params)
     * — entity_id is unique within (repo, entity_type) but NOT across the
     * whole workspace. A `case-study` in `portfolio` and a `reference` in
     * `analogous-experiences` can both have id `actively-learn-website`;
     * the type-blind {@link findEntityById} returns whichever repo iterates
     * first and breaks consumers that need a specific type.
     */
    findEntityByTypeAndId(entityType, entityId) {
        for (const [repoName, graph] of this.graphs) {
            try {
                const all = graph.list();
                const match = all.find(e => e.data?.entity_id === entityId && e.entityType === entityType);
                if (match)
                    return { ...match, repoName };
            }
            catch {
                // continue
            }
        }
        return null;
    }
    /**
     * Find entity by ID across all repositories, regardless of entity type.
     * Used for workspace-level deduplication checks (where the question is
     * "is this id taken anywhere"). When you have both a type and an id,
     * prefer {@link findEntityByTypeAndId} — see the bug history note
     * there for why this matters.
     */
    findEntityById(entityId) {
        for (const [repoName, graph] of this.graphs) {
            try {
                const all = graph.list();
                const match = all.find(e => e.data?.entity_id === entityId);
                if (match)
                    return { ...match, repoName };
            }
            catch {
                // continue
            }
        }
        return null;
    }
    /**
     * Validate wikilinks across all repositories
     */
    validateWikilinks(repoName, entityType, entityId) {
        const graph = this.getGraph(repoName);
        const entity = graph.get(entityType, entityId);
        const valid = [];
        const broken = [];
        const crossRepo = [];
        for (const link of entity.document.wikilinks) {
            let foundInSameRepo = false;
            let foundInOtherRepo = false;
            // Try all entity types since wikilinks don't specify type
            const allTypes = Object.keys(ENTITY_SCHEMAS);
            // Check in the same repo first
            for (const searchType of allTypes) {
                try {
                    graph.get(searchType, link);
                    foundInSameRepo = true;
                    valid.push(link);
                    break;
                }
                catch {
                    // Not this type, continue
                }
            }
            // If not found in same repo, search other repos
            if (!foundInSameRepo) {
                for (const [otherRepoName, otherGraph] of this.graphs) {
                    if (otherRepoName === repoName)
                        continue;
                    for (const searchType of allTypes) {
                        try {
                            otherGraph.get(searchType, link);
                            crossRepo.push({ link, repo: otherRepoName });
                            foundInOtherRepo = true;
                            break;
                        }
                        catch {
                            // Not this type, continue
                        }
                    }
                    if (foundInOtherRepo)
                        break;
                }
                if (!foundInOtherRepo) {
                    broken.push(link);
                }
            }
        }
        return { valid, broken, crossRepo };
    }
    /**
     * Get entities related to a given entity (cross-repo)
     */
    getRelated(repoName, entityType, entityId) {
        const graph = this.getGraph(repoName);
        const entity = graph.get(entityType, entityId);
        const results = [];
        for (const link of entity.document.wikilinks) {
            for (const [searchRepoName, searchGraph] of this.graphs) {
                try {
                    // Try to find entity in this repo
                    const allTypes = Object.keys(ENTITY_SCHEMAS);
                    for (const searchType of allTypes) {
                        try {
                            const linkedEntity = searchGraph.get(searchType, link);
                            results.push({
                                ...linkedEntity,
                                repoName: searchRepoName,
                            });
                            break; // Found it, stop searching types
                        }
                        catch {
                            // Not this type, continue
                        }
                    }
                }
                catch {
                    // Entity not found in this repo
                }
            }
        }
        return results;
    }
    /**
     * Get backlinks across all repositories
     */
    getBacklinks(repoName, entityType, entityId) {
        const results = [];
        for (const [searchRepoName, searchGraph] of this.graphs) {
            try {
                const backlinks = searchGraph.getBacklinks(entityType, entityId);
                for (const backlink of backlinks) {
                    results.push({
                        ...backlink,
                        repoName: searchRepoName,
                    });
                }
            }
            catch (error) {
                console.warn(`Error getting backlinks from repo ${searchRepoName}:`, error);
            }
        }
        return results;
    }
    /**
     * Get workspace summary statistics
     */
    getSummary() {
        const summary = {
            totalRepos: 0,
            totalEntities: 0,
            entitiesByType: {},
            entitiesByRepo: {},
        };
        for (const [repoName, graph] of this.graphs) {
            summary.totalRepos++;
            const entities = graph.list();
            summary.totalEntities += entities.length;
            summary.entitiesByRepo[repoName] = entities.length;
            for (const entity of entities) {
                const type = entity.entityType;
                summary.entitiesByType[type] = (summary.entitiesByType[type] || 0) + 1;
            }
        }
        return summary;
    }
    /**
     * Discover repos in workspace (find .studiograph marker files)
     */
    static discoverRepos(workspacePath) {
        const discovered = [];
        if (!existsSync(workspacePath)) {
            return discovered;
        }
        // Scan immediate subdirectories
        const entries = readdirSync(workspacePath, { withFileTypes: true });
        for (const entry of entries) {
            if (!entry.isDirectory()) {
                continue;
            }
            const entryPath = join(workspacePath, entry.name);
            const markerPath = join(entryPath, '.studiograph');
            const gitPath = join(entryPath, '.git');
            // Loud assertion: a .git directory without a .studiograph marker
            // indicates the marker was removed out-of-band (production has shown
            // ~12 such repos with no in-code deletion path). Flag it so the
            // missing marker can be traced from logs.
            if (existsSync(gitPath) && !existsSync(markerPath)) {
                console.warn(`[workspace-manager] WARNING: ${entry.name} has .git but no .studiograph marker — ` +
                    `repo will NOT be auto-discovered. Marker may have been deleted out-of-band.`);
            }
            // Check for .studiograph marker directory
            if (existsSync(markerPath)) {
                // Infer type from directory name patterns
                let type = 'resource'; // Default
                if (entry.name.includes('project') || entry.name.match(/^[a-z]+-[a-z]+$/)) {
                    type = 'project';
                }
                else if (['accounting', 'hr', 'marketing', 'new-business', 'planning'].includes(entry.name)) {
                    type = 'function';
                }
                discovered.push({
                    name: entry.name,
                    path: entry.name,
                    type,
                });
            }
        }
        return discovered;
    }
}
//# sourceMappingURL=workspace-manager.js.map