/**
 * Graph API routes
 *
 * REST façade over WorkspaceManager.
 * Enforces per-folder access when users are authenticated via JWT.
 * API key / open mode → full access (no filtering).
 */
import { isWorkspaceOwner } from '../../services/auth-service.js';
import { WorkspaceAccessService } from '../../services/workspace-access.js';
import { Workspace } from '../../core/workspace.js';
import { createSharedFolder, FolderCreationError } from '../../services/folder-creation.js';
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
import { dirname, join } from 'path';
import { StaleBaseRevisionError } from '../../core/graph.js';
import { checkEagerSkillPermission, filterWikilinks, getAccessibleEntityIds, getAccessibleRepos, graphForAdmin as resolveGraphForAdmin, graphForRead as resolveGraphForRead, graphForWrite as resolveGraphForWrite, isFolderAdminOrOwner, reqGitUser, reqUser, } from './graph-api-access.js';
import { BridgeError, assertCanChangeUserHandle, validateUserHandleInPayload } from '../../services/user-person-bridge.js';
import { log } from '../../utils/log.js';
export async function registerGraphApiRoutes(fastify, workspaceManager, authService, wsHub, sessionManager, yjsManager) {
    // ARCHITECTURAL BOUNDARY: every per-route graph resolution flows through
    // this service so the act of getting a graph IS the access check. Do not
    // call `workspaceManager.getGraph(repo)` from a request-scoped handler —
    // use `accessService.graphFor*(reqUser(req), repo)` instead. A security
    // audit (2026-05) flagged the previous opt-in pattern (route had to
    // remember `hasRepoAccess()` before `getGraph()`) as an architectural
    // risk; if you find yourself reaching for the raw WM here, that's the
    // smell the audit was about.
    const accessService = new WorkspaceAccessService(workspaceManager, authService);
    const graphForRead = (req, reply, repoName) => resolveGraphForRead(accessService, req, reply, repoName);
    const graphForWrite = (req, reply, repoName) => resolveGraphForWrite(accessService, req, reply, repoName);
    const graphForAdmin = (req, reply, repoName) => resolveGraphForAdmin(accessService, req, reply, repoName);
    // POST /api/repos — any authenticated user can create a folder.
    // The creator is granted folder-admin access automatically.
    // Shared logic lives in services/folder-creation.ts so the MCP tool
    // create_repo behaves identically.
    fastify.post('/api/repos', async (req, reply) => {
        const user = req.user;
        const { name, display_name, description } = req.body || {};
        try {
            const repo = await createSharedFolder(workspaceManager, authService, { name, display_name, description }, user, wsHub);
            return reply.status(201).send(repo);
        }
        catch (err) {
            if (err instanceof FolderCreationError) {
                const status = err.code === 'invalid_name' ? 400
                    : err.code === 'duplicate' ? 409
                        : 500;
                return reply.status(status).send({ error: err.message });
            }
            return reply.status(500).send({ error: err?.message ?? String(err) });
        }
    });
    // DELETE /api/repos/:repo — folder admin or workspace owner only.
    fastify.delete('/api/repos/:repo', async (req, reply) => {
        if (!isFolderAdminOrOwner(req, req.params.repo, workspaceManager, authService)) {
            return reply.status(403).send({ error: 'Folder admin access required' });
        }
        const { repo } = req.params;
        try {
            const workspace = new Workspace(workspaceManager['workspacePath']);
            const config = await workspace.loadConfig();
            const repoIndex = config.repos.findIndex(r => r.name === repo);
            if (repoIndex === -1) {
                return reply.status(404).send({ error: `Folder \u201C${repo}\u201D not found` });
            }
            const repoConfig = config.repos[repoIndex];
            const repoPath = join(workspaceManager['workspacePath'], repoConfig.path);
            // Remove from config
            config.repos.splice(repoIndex, 1);
            workspace.writeConfig(config);
            workspace.pushWorkspaceConfig(`Remove folder: ${repo}`);
            // Delete files
            const { rmSync } = await import('fs');
            if (existsSync(repoPath)) {
                rmSync(repoPath, { recursive: true, force: true });
            }
            workspaceManager.reloadConfig();
            wsHub?.broadcast({
                type: 'folder_deleted', repo,
                actor: req.user?.displayName ?? 'API',
                timestamp: new Date().toISOString(),
            });
            return reply.status(200).send({ success: true });
        }
        catch (err) {
            return reply.status(500).send({ error: err.message });
        }
    });
    // PATCH /api/repos/:repo — rename/update sync rules. Folder admin or workspace owner only.
    fastify.patch('/api/repos/:repo', async (req, reply) => {
        if (!isFolderAdminOrOwner(req, req.params.repo, workspaceManager, authService)) {
            return reply.status(403).send({ error: 'Folder admin access required' });
        }
        const { repo } = req.params;
        const body = req.body || {};
        const { name: newName, display_name: newDisplayName, sync: newSync, ...rest } = body;
        if ('personal' in rest) {
            return reply.status(400).send({ error: 'The personal flag is immutable after creation' });
        }
        if (newName === undefined && newSync === undefined && newDisplayName === undefined) {
            return reply.status(400).send({ error: 'Nothing to update' });
        }
        if (newName !== undefined && (typeof newName !== 'string' || !/^[a-z0-9][a-z0-9-]*$/.test(newName))) {
            return reply.status(400).send({ error: 'New name must be kebab-case' });
        }
        try {
            const workspace = new Workspace(workspaceManager['workspacePath']);
            const config = await workspace.loadConfig();
            const repoConfig = config.repos.find(r => r.name === repo);
            if (!repoConfig) {
                return reply.status(404).send({ error: `Folder \u201C${repo}\u201D not found` });
            }
            // Handle rename
            if (newName !== undefined) {
                if (config.repos.some(r => r.name === newName)) {
                    return reply.status(409).send({ error: `Folder \u201C${newName}\u201D already exists` });
                }
                const wsPath = workspaceManager['workspacePath'];
                const oldPath = join(wsPath, repoConfig.path);
                const newPath = join(wsPath, newName);
                // Rename directory
                const { renameSync, writeFileSync: writeFS } = await import('fs');
                if (existsSync(oldPath)) {
                    renameSync(oldPath, newPath);
                }
                repoConfig.name = newName;
                repoConfig.path = newName;
                if (newDisplayName)
                    repoConfig.display_name = newDisplayName;
                // Update .studiograph marker
                const markerPath = join(newPath, '.studiograph');
                if (existsSync(markerPath)) {
                    writeFS(markerPath, JSON.stringify({ repo_name: newName }, null, 2), 'utf-8');
                }
                // Update folder_access to follow the rename
                authService.renameFolder(repo, newName);
            }
            // Handle display name update (without rename)
            if (newDisplayName !== undefined && newName === undefined) {
                repoConfig.display_name = newDisplayName;
            }
            // Handle sync rules update
            if (newSync !== undefined) {
                repoConfig.sync = newSync.length > 0 ? newSync : undefined;
            }
            workspace.writeConfig(config);
            workspace.pushWorkspaceConfig(newName !== undefined ? `Rename folder: ${repo} → ${newName}` : `Update folder: ${repo}`);
            workspaceManager.reloadConfig();
            return reply.send(repoConfig);
        }
        catch (err) {
            return reply.status(500).send({ error: err.message });
        }
    });
    // GET /api/repos — list accessible repos (re-reads config to pick up CLI changes).
    // Augments each repo with the caller's folder role ('admin' | 'editor'),
    // member_count, entity_types (distinct types present), and folders (the
    // flat list of nested-folder paths in the repo, used by the sidebar to
    // render the active-path expansion).
    fastify.get('/api/repos', async (req, reply) => {
        workspaceManager.reloadConfig();
        const repos = getAccessibleRepos(req, workspaceManager, authService);
        const user = req.user;
        const enriched = repos.map(r => {
            const memberCount = authService.getFolderMembers(r.name).length;
            const callerRole = user ? authService.getFolderRole(user.id, r.name) : null;
            let entityTypes = [];
            // `folders` carries the per-folder display_name override (when one
            // exists in the folder's .studiograph-folder.json sidecar); the client
            // falls back to toDisplayName(slug) when display_name is absent.
            let folders = [];
            try {
                // Direct WM access OK here — `repos` came from getAccessibleRepos
                // above, so every iteration is over a folder the caller can read.
                const graph = workspaceManager.getGraph(r.name);
                entityTypes = graph.listEntityTypes();
                folders = graph.listFolders().map(f => {
                    const entry = { path: f.path };
                    if (f.display_name)
                        entry.display_name = f.display_name;
                    if (f.id)
                        entry.id = f.id;
                    return entry;
                });
            }
            catch { /* repo not mounted */ }
            return {
                ...r,
                caller_role: callerRole,
                member_count: memberCount,
                entity_types: entityTypes,
                folders,
            };
        });
        return reply.send(enriched);
    });
    // GET /api/admin/folders — workspace-owner-only metadata list of every folder
    // (name, display_name, creator, member count). No content access implied —
    // owners still need to join a folder to read its entries.
    fastify.get('/api/admin/folders', async (req, reply) => {
        const user = req.user;
        if (user && !isWorkspaceOwner(user.role)) {
            return reply.status(403).send({ error: 'Workspace owner access required' });
        }
        workspaceManager.reloadConfig();
        const all = workspaceManager.getAllRepoConfigs();
        const result = all.map(r => {
            const members = authService.getFolderMembers(r.name);
            const admins = members.filter(m => m.role === 'admin');
            const creator = r.owner_id
                ? members.find(m => m.user.id === r.owner_id)?.user
                : admins[0]?.user;
            let entryCount = 0;
            // Direct WM access OK — this endpoint is owner-only (gated above) and
            // returns metadata for every folder the workspace owner needs to see
            // for admin purposes. No content is leaked: only entry counts.
            try {
                entryCount = workspaceManager.getGraph(r.name).search({}).length;
            }
            catch { /* skip */ }
            return {
                name: r.name,
                display_name: r.display_name ?? r.name,
                description: r.description,
                member_count: members.length,
                admin_count: admins.length,
                entry_count: entryCount,
                creator: creator ? { id: creator.id, displayName: creator.displayName, email: creator.email } : null,
                caller_is_member: user ? members.some(m => m.user.id === user.id) : true,
            };
        });
        return reply.send(result);
    });
    // Build the lightweight summary objects used by /entities?summary=true.
    // Extracted so the per-repo and bulk endpoints share the same shape.
    const EXCLUDED_SUMMARY_FIELDS = new Set([
        'entity_type', 'entity_id', 'created_by', 'updated_by',
        'description', 'content', 'context', 'rationale', 'alternatives', 'consequences',
        'steps', 'guidelines', 'bio', 'agenda', 'summary', 'decision', 'action_items',
    ]);
    function buildSummaries(entities, accessibleIds) {
        return entities.map(e => {
            const extra = Object.fromEntries(Object.entries(e.data ?? {}).filter(([k, v]) => !EXCLUDED_SUMMARY_FIELDS.has(k) && k !== 'name' && k !== 'created_at' && k !== 'updated_at' &&
                (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' || Array.isArray(v) || v instanceof Date)));
            return {
                id: e.id,
                entityType: e.entityType,
                folder_path: e.folder_path ?? '',
                data: {
                    name: e.data.name,
                    created_at: e.data.created_at,
                    updated_at: e.data.updated_at,
                    ...extra,
                },
                document: { wikilinks: filterWikilinks(e.document?.wikilinks ?? [], accessibleIds) },
            };
        });
    }
    // GET /api/repos/:repo/entities — list all entities in a repo
    // ?summary=true returns lightweight objects (no content, minimal data) for sidebar/graph
    fastify.get('/api/repos/:repo/entities', async (req, reply) => {
        const { repo } = req.params;
        // Access enforced by graphForRead \u2014 do not call workspaceManager.getGraph
        // directly here. The audit (2026-05) flagged direct WM access from routes
        // as the primary architectural risk; the service is the gate.
        const graph = graphForRead(req, reply, repo);
        if (!graph)
            return;
        const entities = graph.search({});
        const accessibleIds = getAccessibleEntityIds(req, workspaceManager, authService);
        if (req.query.summary === 'true') {
            return reply.send(buildSummaries(entities, accessibleIds));
        }
        // Full response — filter wikilinks in document
        const filtered = entities.map(e => {
            if (!e.document?.wikilinks?.length)
                return e;
            return { ...e, document: { ...e.document, wikilinks: filterWikilinks(e.document.wikilinks, accessibleIds) } };
        });
        return reply.send(filtered);
    });
    // GET /api/workspace/entities?summary=true — bulk entity summaries for ALL
    // accessible repos in a single round-trip. Replaces 15+ parallel
    // /api/repos/:repo/entities?summary=true calls. Important on dev where each
    // request has Vite middleware overhead and the browser's HTTP/1.1 6-slot
    // limit serializes them, blocking other navigation fetches.
    // Response shape: `{ [repoName]: EntitySummary[] }`.
    fastify.get('/api/workspace/entities', async (req, reply) => {
        // Mirror the per-repo endpoint's contract: only summary=true is supported
        // here. Returning full bodies for the whole workspace would be huge and
        // isn't a use case we have today.
        if (req.query.summary !== 'true') {
            return reply.status(400).send({ error: 'summary=true is required for the bulk entities endpoint' });
        }
        const repos = getAccessibleRepos(req, workspaceManager, authService);
        // Single-pass: collect the per-repo entity arrays AND the accessible-ids
        // set in one walk. The previous shape called graph.search({}) twice per
        // repo (once via getAccessibleEntityIds, once in this loop), doubling
        // disk I/O on the most-trafficked endpoint.
        const user = reqUser(req);
        const collectIds = user !== undefined; // unauthenticated → no wikilink filtering
        const accessibleIds = collectIds ? new Set() : null;
        const perRepoEntities = [];
        for (const r of repos) {
            let graph;
            // Direct WM access OK — `repos` is the access-filtered list above.
            try {
                graph = workspaceManager.getGraph(r.name);
            }
            catch {
                continue;
            } // skip unmounted repos rather than failing the whole request
            const entities = graph.search({});
            perRepoEntities.push({ name: r.name, entities });
            if (accessibleIds) {
                for (const e of entities)
                    accessibleIds.add(e.id);
            }
        }
        const result = {};
        for (const { name, entities } of perRepoEntities) {
            result[name] = buildSummaries(entities, accessibleIds);
        }
        return reply.send(result);
    });
    // GET /api/repos/:repo/entities/:type — list entities of a type
    fastify.get('/api/repos/:repo/entities/:type', async (req, reply) => {
        const { repo, type } = req.params;
        // Access enforced by graphForRead (WorkspaceAccessService).
        const graph = graphForRead(req, reply, repo);
        if (!graph)
            return;
        const entities = graph.search({ entityType: type });
        const accessibleIds = getAccessibleEntityIds(req, workspaceManager, authService);
        const filtered = entities.map(e => {
            if (!e.document?.wikilinks?.length)
                return e;
            return { ...e, document: { ...e.document, wikilinks: filterWikilinks(e.document.wikilinks, accessibleIds) } };
        });
        return reply.send(filtered);
    });
    // GET /api/repos/:repo/entities/:type/:id — get single entity
    fastify.get('/api/repos/:repo/entities/:type/:id', async (req, reply) => {
        const { repo, type, id } = req.params;
        // Access enforced by graphForRead (WorkspaceAccessService).
        const graph = graphForRead(req, reply, repo);
        if (!graph)
            return;
        let entity;
        try {
            entity = graph.get(type, id);
        }
        catch (err) {
            return reply.status(404).send({ error: err.message ?? `Entity not found: ${type}/${id}` });
        }
        // graph.get may also return null for a missing entity without throwing
        // (depends on the backing graph implementation). Treat that as 404 so
        // we don't blow up on the wikilinks access below.
        if (!entity) {
            return reply.status(404).send({ error: `Entity not found: ${type}/${id}` });
        }
        if (entity.document?.wikilinks?.length) {
            const accessibleIds = getAccessibleEntityIds(req, workspaceManager, authService);
            return reply.send({
                ...entity,
                document: { ...entity.document, wikilinks: filterWikilinks(entity.document.wikilinks, accessibleIds) },
            });
        }
        return reply.send(entity);
    });
    // GET /api/repos/:repo/entities/:type/:id/history — entity commit history
    fastify.get('/api/repos/:repo/entities/:type/:id/history', async (req, reply) => {
        const { repo, type, id } = req.params;
        // Access enforced by graphForRead (WorkspaceAccessService).
        const graph = graphForRead(req, reply, repo);
        if (!graph)
            return;
        const gitService = graph.getGitService();
        if (!gitService) {
            return reply.send([]);
        }
        const limit = Math.min(parseInt(req.query.limit ?? '20', 10) || 20, 50);
        // Resolve the actual on-disk path via graph.get() — it walks nested
        // folders to find the file. The previous hardcoded `${plural}/${id}.md`
        // missed any entry not at the legacy flat path (entries created in a
        // sub-folder, MCP entries that landed elsewhere, etc.) and silently
        // returned an empty history. Convert absolute path → repo-relative
        // for git log.
        let filePath;
        try {
            const entity = graph.get(type, id);
            const repoRoot = graph.getRepoPath();
            filePath = entity.path.startsWith(repoRoot + '/')
                ? entity.path.slice(repoRoot.length + 1)
                : entity.path;
        }
        catch (err) {
            return reply.status(404).send({ error: err.message });
        }
        try {
            const history = gitService.getFileHistory(filePath, limit);
            return reply.send(history);
        }
        catch {
            return reply.send([]);
        }
    });
    // POST /api/repos/:repo/entities/:type/:id/restore — restore an entry to
    // its content at a past commit. Single-entity scope. Editor-or-admin role
    // required (canWriteRepo). Returns 200 with the restore result, 404 if
    // the entry doesn't exist at that commit, 409 PATH_CONFLICT if a different
    // entry now occupies the target path, 403 if the caller lacks write access.
    fastify.post('/api/repos/:repo/entities/:type/:id/restore', async (req, reply) => {
        const { repo, type, id } = req.params;
        const { commitHash } = req.body ?? {};
        if (!commitHash || typeof commitHash !== 'string') {
            return reply.status(400).send({ error: 'commitHash is required' });
        }
        // Access enforced by graphForWrite: 404 if no read access, 403 if no write.
        const graph = graphForWrite(req, reply, repo);
        if (!graph)
            return;
        try {
            const result = await graph.restoreFile(type, id, commitHash, reqGitUser(req));
            wsHub?.broadcast({
                type: 'entity_change',
                action: result.action === 'recreate' ? 'created' : 'updated',
                repo,
                entityType: type,
                entityId: id,
                actor: reqUser(req)?.displayName ?? 'API',
                source: 'api',
                timestamp: new Date().toISOString(),
            });
            return reply.send({
                success: true,
                action: result.action,
                path: result.path,
            });
        }
        catch (err) {
            // PathConflictError carries an error code distinct from generic
            // failures so the UI can render an actionable conflict message.
            if (err?.code === 'PATH_CONFLICT' && err?.occupiedBy) {
                return reply.status(409).send({
                    error: 'PATH_CONFLICT',
                    path: err.path,
                    occupiedBy: err.occupiedBy,
                });
            }
            const message = err instanceof Error ? err.message : 'Restore failed';
            // "not found at commit" / "did not exist at commit" → 404 so the
            // client can show "that version isn't available" instead of a
            // generic 500.
            if (/not found at commit|did not exist at commit/i.test(message)) {
                return reply.status(404).send({ error: message });
            }
            return reply.status(500).send({ error: message });
        }
    });
    // GET /api/schema/:entityType/fields — field metadata for an entity type
    fastify.get('/api/schema/:entityType/fields', async (req, reply) => {
        const { entityType } = req.params;
        const registry = workspaceManager.getSchemaRegistry();
        const fields = registry.getFieldMetadata(entityType);
        return reply.send(fields);
    });
    // GET /api/repos/:repo/search?q=&type=&tags= — search within a repo
    fastify.get('/api/repos/:repo/search', async (req, reply) => {
        const { repo } = req.params;
        // Access enforced by graphForRead (WorkspaceAccessService).
        const graph = graphForRead(req, reply, repo);
        if (!graph)
            return;
        const { q, type, tags } = req.query;
        const results = graph.search({
            query: q,
            entityType: type,
            tags: tags ? tags.split(',').map(t => t.trim()) : undefined,
        });
        const accessibleIds = getAccessibleEntityIds(req, workspaceManager, authService);
        const filtered = results.map(e => {
            if (!e.document?.wikilinks?.length)
                return e;
            return { ...e, document: { ...e.document, wikilinks: filterWikilinks(e.document.wikilinks, accessibleIds) } };
        });
        return reply.send(filtered);
    });
    // GET /api/graph — full workspace graph topology (nodes + edges)
    fastify.get('/api/graph', async (req, reply) => {
        const repos = getAccessibleRepos(req, workspaceManager, authService);
        const nodes = [];
        const edgeSet = new Set();
        const edges = [];
        const entityIds = new Set();
        for (const repoConfig of repos) {
            try {
                // Direct WM access OK — `repos` came from getAccessibleRepos above.
                const graph = workspaceManager.getGraph(repoConfig.name);
                const entities = graph.search({});
                for (const entity of entities) {
                    const nodeId = entity.id;
                    if (!entityIds.has(nodeId)) {
                        entityIds.add(nodeId);
                        nodes.push({
                            id: nodeId,
                            type: entity.entityType,
                            repo: repoConfig.name,
                        });
                    }
                    // Extract wikilinks as edges
                    const wikilinks = entity.document?.wikilinks ?? [];
                    for (const target of wikilinks) {
                        const key = nodeId < target ? `${nodeId}|${target}` : `${target}|${nodeId}`;
                        if (!edgeSet.has(key) && nodeId !== target) {
                            edgeSet.add(key);
                            edges.push({ source: nodeId, target });
                        }
                    }
                }
            }
            catch {
                // Skip repos that can't be loaded
            }
        }
        // Filter edges to only include both endpoints that exist as nodes
        const validEdges = edges.filter(e => entityIds.has(e.source) && entityIds.has(e.target));
        return reply.send({ nodes, edges: validEdges });
    });
    // GET /api/search?q=&type=&tags=&repos=&mode= — cross-repo search
    fastify.get('/api/search', async (req, reply) => {
        const { q, type, tags, repos, mode } = req.query;
        const accessibleNames = new Set(getAccessibleRepos(req, workspaceManager, authService).map(r => r.name));
        // Filter requested repos to only accessible ones
        let repoNames = repos ? repos.split(',').map(r => r.trim()) : undefined;
        if (repoNames) {
            repoNames = repoNames.filter(r => accessibleNames.has(r));
        }
        else {
            repoNames = [...accessibleNames];
        }
        const keywordMode = mode === 'keyword';
        const results = await workspaceManager.search({
            query: q,
            entityType: type,
            tags: tags ? tags.split(',').map(t => t.trim()) : undefined,
            repoNames,
            mode: keywordMode ? 'keyword' : undefined,
            // Autosuggest dropdown caps at 20 visible rows; capping server-side
            // avoids serializing a long tail the client throws away.
            limit: keywordMode ? 20 : undefined,
        });
        // Autosuggest doesn't need the markdown body or wikilinks — the
        // dropdown renders {name, type, repo} only. Returning a slim shape
        // cuts response size from ~hundreds of KB to a few KB on big
        // workspaces, and skips the per-entity wikilink visibility filter.
        if (keywordMode) {
            const slim = results.map(e => ({
                id: e.id,
                path: e.path,
                folder_path: e.folder_path,
                entityType: e.entityType,
                repoName: e.repoName,
                data: {
                    name: e.data?.name,
                    updated_at: e.data?.updated_at,
                },
            }));
            return reply.send(slim);
        }
        const accessibleIds = getAccessibleEntityIds(req, workspaceManager, authService);
        const filtered = results.map((e) => {
            if (!e.document?.wikilinks?.length)
                return e;
            return { ...e, document: { ...e.document, wikilinks: filterWikilinks(e.document.wikilinks, accessibleIds) } };
        });
        return reply.send(filtered);
    });
    // PUT /api/repos/:repo/entities/:type/:id — update entity (data and/or content)
    fastify.put('/api/repos/:repo/entities/:type/:id', async (req, reply) => {
        const { repo, type, id } = req.params;
        // Access enforced by graphForWrite \u2014 PUT updates an entity, write required.
        const graph = graphForWrite(req, reply, repo);
        if (!graph)
            return;
        const eagerError = checkEagerSkillPermission(req, type, req.body.data, repo, workspaceManager, authService);
        if (eagerError)
            return reply.status(403).send({ error: eagerError });
        try {
            // Optimistic concurrency. Two preconditions:
            //   - `X-Studiograph-Base-Body-Hash` (Phase 3a): sha256 hex of the
            //     body the client last loaded. Enforced INSIDE the per-doc lock
            //     by graph.update via `baseRevision`, so it composes with
            //     serialization — two body-writing PUTs from the same baseline
            //     can't both pass and back-stomp each other.
            //   - `If-Unmodified-Since` (legacy): kept for non-web clients;
            //     RFC-7231 second-precision so aliases sub-millisecond writes,
            //     which is why the body-hash form is preferred.
            // Body-hash header is IGNORED on metadata-only PUTs — a stale body
            // baseline is irrelevant when the PUT doesn't touch the body, and
            // enforcing it would cause spurious 412s after an agent body-write.
            const baseBodyHash = req.headers['x-studiograph-base-body-hash'];
            const ifUnmodifiedSince = req.headers['if-unmodified-since'];
            if (ifUnmodifiedSince && !baseBodyHash) {
                const current = graph.get(type, id);
                const entityUpdatedAt = current?.data?.updated_at;
                if (entityUpdatedAt && new Date(entityUpdatedAt) > new Date(ifUnmodifiedSince)) {
                    return reply.status(409).send({ error: 'Conflict: entity was modified by another user', entity: current });
                }
            }
            const gitUser = reqGitUser(req);
            // Workspace-wide uniqueness check for `user_handle` on person entities.
            // Skip the entity being updated so we don't reject it for clashing
            // with itself. Also gate WHO can change the bridge: only workspace
            // owners may stamp/clear/rewrite user_handle. Other folder-writers
            // see the field but can't edit it (defense against impersonation
            // through the agent's Current User prompt).
            if (type === 'person' && req.body.data) {
                try {
                    validateUserHandleInPayload(workspaceManager, req.body.data, { repo, entityId: id });
                    if (Object.prototype.hasOwnProperty.call(req.body.data, 'user_handle')) {
                        const current = graph.get('person', id);
                        const currentHandle = (current?.data?.user_handle ?? '');
                        assertCanChangeUserHandle(reqUser(req), currentHandle, req.body.data.user_handle);
                    }
                }
                catch (err) {
                    if (err instanceof BridgeError) {
                        const status = err.code === 'OWNER_REQUIRED' ? 403 : 409;
                        return reply.status(status).send({ error: err.message, code: err.code, ...err.details });
                    }
                    throw err;
                }
            }
            const updated = await graph.update({
                entityType: type,
                entityId: id,
                data: req.body.data,
                content: req.body.content,
                commitMessage: `Update ${type}/${id} from web UI`,
                user: gitUser,
                skipCommit: !!sessionManager, // defer commit if scheduler is active
                source: 'autosave',
                // Forward the body-hash precondition into graph.update so the
                // check runs INSIDE the per-doc lock — see Phase 3 P1 fix.
                // Only meaningful for body writes; ignored when content is
                // undefined (graph.update gates on content !== undefined too).
                baseRevision: req.body.content !== undefined ? baseBodyHash : undefined,
            });
            if (sessionManager && gitUser) {
                sessionManager.markDirty(repo, gitUser, type, id, updated.path);
            }
            wsHub?.broadcast({
                type: 'entity_change', action: 'updated', repo, entityType: type, entityId: id,
                actor: req.user?.displayName ?? 'API',
                source: 'api', timestamp: new Date().toISOString(),
            });
            return reply.send(updated);
        }
        catch (err) {
            // Translate the locked-section baseRevision mismatch into the
            // same 412 shape the route used to emit pre-lock. Preserves the
            // client-side contract (code: STALE_BODY_HASH + currentBodyHash
            // + entity).
            if (err instanceof StaleBaseRevisionError) {
                const current = graph.get(type, id);
                return reply.status(412).send({
                    error: 'Precondition Failed: body was modified',
                    code: 'STALE_BODY_HASH',
                    currentBodyHash: err.actual,
                    entity: current,
                });
            }
            return reply.status(400).send({ error: err.message });
        }
    });
    // POST /api/repos/:repo/change-type — change entity type (moves file between type dirs)
    fastify.post('/api/repos/:repo/change-type', async (req, reply) => {
        const { repo } = req.params;
        const { entityType: type, entityId: id, newType } = req.body ?? {};
        if (!type || !id || !newType) {
            return reply.status(400).send({ error: 'entityType, entityId, and newType are required' });
        }
        // Access enforced by graphForRead (WorkspaceAccessService).
        const graph = graphForRead(req, reply, repo);
        if (!graph)
            return;
        try {
            // Flush any pending session saves before moving the file
            if (sessionManager) {
                await sessionManager.flush(repo);
            }
            const gitUser = reqGitUser(req);
            const updated = await graph.changeType(id, type, newType, undefined, gitUser);
            wsHub?.broadcast({
                type: 'entity_change', action: 'deleted', repo, entityType: type, entityId: id,
                actor: req.user?.displayName ?? 'API',
                source: 'api', timestamp: new Date().toISOString(),
            });
            wsHub?.broadcast({
                type: 'entity_change', action: 'created', repo, entityType: newType, entityId: id,
                actor: req.user?.displayName ?? 'API',
                source: 'api', timestamp: new Date().toISOString(),
            });
            return reply.send({ success: true, entity: updated, newUrl: `/${repo}/${newType}/${id}` });
        }
        catch (err) {
            const status = err.message?.includes('already exists') ? 409 : 400;
            return reply.status(status).send({ error: err.message });
        }
    });
    // POST /api/repos/:repo/rename — rename an entity (changes its id/filename)
    fastify.post('/api/repos/:repo/rename', async (req, reply) => {
        const { repo } = req.params;
        const { entityType: type, entityId: id, newId } = req.body ?? {};
        if (!type || !id || !newId) {
            return reply.status(400).send({ error: 'entityType, entityId, and newId are required' });
        }
        if (!/^[a-z0-9][a-z0-9_-]*$/.test(newId)) {
            return reply.status(400).send({ error: 'New ID must be kebab-case (lowercase letters, numbers, hyphens, underscores)' });
        }
        if (id === newId) {
            return reply.status(400).send({ error: 'New ID must differ from the current ID' });
        }
        // Access enforced by graphForWrite (WorkspaceAccessService).
        const graph = graphForWrite(req, reply, repo);
        if (!graph)
            return;
        // Workspace-wide uniqueness — ids must be unique across all folders
        const conflict = workspaceManager.findEntityById(newId);
        if (conflict) {
            return reply.status(409).send({ error: `An entry with id "${newId}" already exists in folder "${conflict.repoName}".` });
        }
        try {
            // Flush pending session saves so we don't PUT to the old path after rename
            if (sessionManager) {
                await sessionManager.flush(repo);
            }
            const gitUser = reqGitUser(req);
            const renamed = await graph.rename(type, id, newId, undefined, gitUser);
            // Rewrite any [[wikilinks]] that still point at the old id, across
            // every folder the caller can access. Per-repo — cross-folder links
            // are rare but supported.
            const refactorMsg = `Refactor wikilinks: ${id} \u2192 ${newId}`;
            for (const other of getAccessibleRepos(req, workspaceManager, authService)) {
                try {
                    // Direct WM access OK — iterating the access-filtered list.
                    const otherGraph = workspaceManager.getGraph(other.name);
                    await otherGraph.refactorWikilink(id, newId, refactorMsg);
                }
                catch { /* skip unloadable repo */ }
            }
            wsHub?.broadcast({
                type: 'entity_change', action: 'deleted', repo, entityType: type, entityId: id,
                actor: req.user?.displayName ?? 'API',
                source: 'api', timestamp: new Date().toISOString(),
            });
            wsHub?.broadcast({
                type: 'entity_change', action: 'created', repo, entityType: type, entityId: newId,
                actor: req.user?.displayName ?? 'API',
                source: 'api', timestamp: new Date().toISOString(),
            });
            return reply.send({ success: true, entity: renamed, newUrl: `/${repo}/${type}/${newId}` });
        }
        catch (err) {
            const status = err.message?.includes('already exists') ? 409 : 400;
            return reply.status(status).send({ error: err.message });
        }
    });
    // POST /api/repos/:repo/transfer — move or copy entity to another folder
    fastify.post('/api/repos/:repo/transfer', async (req, reply) => {
        const sourceRepo = req.params.repo;
        const { entityType, entityId, targetRepo, mode, targetFolderPath } = req.body ?? {};
        if (!entityType || !entityId || !targetRepo || !mode) {
            return reply.status(400).send({ error: 'entityType, entityId, targetRepo, and mode are required' });
        }
        if (sourceRepo === targetRepo) {
            return reply.status(400).send({ error: 'Source and target folders must be different' });
        }
        // Access enforced by graphForWrite — both source (delete on move)
        // and target (create) require write access. Resolved up-front so a
        // target-side denial never surfaces only after source has been
        // mutated. Audit (2026-05) flagged the previous opt-in pattern.
        const sourceGraph = graphForWrite(req, reply, sourceRepo);
        if (!sourceGraph)
            return;
        const targetGraph = graphForWrite(req, reply, targetRepo);
        if (!targetGraph)
            return;
        try {
            // Phase 0 — prevalidate. Audit (2026-05) required this: previously
            // the route created in target, broadcast `created`, and only then
            // tried to delete from source. A delete failure left a duplicate
            // entry the client had already been told about.
            if (sessionManager) {
                await sessionManager.flush(sourceRepo);
                if (targetRepo !== sourceRepo)
                    await sessionManager.flush(targetRepo);
            }
            // Source must exist; target must not collide. Both checks run BEFORE
            // any mutation so we never enter phase 2 unless phase 1 will succeed.
            const source = sourceGraph.get(entityType, entityId);
            const sourcePath = source.path;
            const sourceRawMarkdown = readFileSync(sourcePath, 'utf-8');
            try {
                targetGraph.get(entityType, entityId);
                return reply.status(409).send({ error: `"${entityId}" already exists in ${targetRepo}` });
            }
            catch { /* not found = good */ }
            const gitUser = reqGitUser(req);
            const { entity_type, entity_id, ...dataFields } = source.data;
            // Phase 1 — create in target. No broadcast yet: a half-completed
            // move (created in target, source still present) must NOT be
            // observable to clients, or they will display two copies until the
            // next refresh. `targetFolderPath` lets the caller drop the entry
            // straight into a subfolder of the target repo.
            const created = await targetGraph.create({
                entityType: entityType,
                entityId,
                data: { ...dataFields, entity_type: entityType, entity_id: entityId },
                content: source.document?.content ?? '',
                commitMessage: `${mode === 'move' ? 'Move' : 'Copy'} ${entityType}: ${entityId} from ${sourceRepo}`,
                user: gitUser,
                folderPath: targetFolderPath,
            });
            // Phase 2 — for `move` only: delete from source. If this fails, run
            // a compensating delete on target so we don't leave a duplicate.
            // The compensating delete is best-effort; if it also fails, surface
            // a 500 with both error messages so the residue is visible (the
            // previous behavior silently succeeded and left orphans).
            if (mode === 'move') {
                try {
                    // keepAssets: the moved entity's immutable-keyed blobs are shared
                    // (same assetId in both repos' bodies during the window); the
                    // source delete must not destroy them.
                    await sourceGraph.delete(entityType, entityId, undefined, gitUser, { keepAssets: true });
                    // Re-point ownership to the target repo ONLY after the source
                    // delete succeeds. Doing it after means a failed source delete
                    // (handled below) leaves the index pointing at the source repo,
                    // which is correct for the entity we're about to restore there.
                    // Blobs/URLs are unchanged (immutable keys); only the index's
                    // repo column moves, which is what serve-time authz reads.
                    workspaceManager.getAssetIndex?.()?.reassignOwner(sourceRepo, entityType, entityId, { repo: targetRepo });
                }
                catch (deleteErr) {
                    const deleteMsg = deleteErr instanceof Error ? deleteErr.message : String(deleteErr);
                    let recoveryMsg = null;
                    try {
                        let restoreSource = !existsSync(sourcePath);
                        if (!restoreSource) {
                            try {
                                restoreSource = readFileSync(sourcePath, 'utf-8') !== sourceRawMarkdown;
                            }
                            catch {
                                restoreSource = true;
                            }
                        }
                        if (restoreSource) {
                            mkdirSync(dirname(sourcePath), { recursive: true });
                            writeFileSync(sourcePath, sourceRawMarkdown, 'utf-8');
                            const recoveryPaths = [sourcePath];
                            const folderMarkerPath = join(dirname(sourcePath), '.gitkeep');
                            if (existsSync(folderMarkerPath)) {
                                rmSync(folderMarkerPath, { force: true });
                                recoveryPaths.push(folderMarkerPath);
                            }
                            const sourceAny = sourceGraph;
                            const recoveryGit = sourceAny.gitService;
                            const recoveryUser = gitUser ?? sourceAny.gitUser;
                            if (!recoveryGit || !recoveryUser) {
                                throw new Error('source graph git service or git user unavailable');
                            }
                            recoveryGit.commitFiles(recoveryPaths, `Recover ${entityType}: ${entityId} after failed move to ${targetRepo}`, recoveryUser);
                        }
                    }
                    catch (recoveryErr) {
                        recoveryMsg = recoveryErr instanceof Error ? recoveryErr.message : String(recoveryErr);
                        log.error('[critical] cross-repo move source recovery failed', {
                            sourceRepo,
                            targetRepo,
                            entityType,
                            entityId,
                            sourcePath,
                            deleteError: deleteMsg,
                            recoveryError: recoveryMsg,
                            capturedContent: sourceRawMarkdown,
                        });
                    }
                    try {
                        // keepAssets: ownership was NOT reassigned (that happens only
                        // after a successful source delete), so the blobs still belong
                        // to the source entity we just restored. Destroying them here
                        // would leave the recovered source pointing at dead URLs.
                        await targetGraph.delete(entityType, entityId, undefined, gitUser, { keepAssets: true });
                        if (recoveryMsg) {
                            return reply.status(500).send({
                                error: `Move failed: source delete of ${entityType}/${entityId} failed (${deleteMsg}). Target rolled back, but source recovery commit failed (${recoveryMsg}); manual source repo recovery is required.`,
                                code: 'MOVE_SOURCE_RECOVERY_FAILED',
                                sourceRepo,
                                targetRepo,
                            });
                        }
                        return reply.status(500).send({
                            error: `Move failed: source delete of ${entityType}/${entityId} failed (${deleteMsg}). Target rolled back; folders are back to their pre-move state.`,
                        });
                    }
                    catch (compErr) {
                        const compMsg = compErr instanceof Error ? compErr.message : String(compErr);
                        if (recoveryMsg) {
                            return reply.status(500).send({
                                error: `Move failed and could not be rolled back. Source delete failed (${deleteMsg}); source recovery commit failed (${recoveryMsg}); compensating target delete also failed (${compMsg}). Captured source content was logged for manual recovery.`,
                                code: 'MOVE_RECOVERY_AND_ROLLBACK_FAILED',
                                sourceRepo,
                                targetRepo,
                            });
                        }
                        return reply.status(500).send({
                            error: `Move failed and could not be rolled back. Source delete failed (${deleteMsg}); compensating target delete also failed (${compMsg}). Entry now exists in BOTH ${sourceRepo} and ${targetRepo} — manual cleanup required.`,
                            code: 'MOVE_DUPLICATE',
                            sourceRepo,
                            targetRepo,
                        });
                    }
                }
            }
            // Phase 3 — both phases succeeded; only now broadcast. Order:
            // source-delete first so subscribers don't briefly observe the
            // entity in both folders during the network race.
            const actor = req.user?.displayName ?? 'API';
            const ts = new Date().toISOString();
            if (mode === 'move') {
                wsHub?.broadcast({
                    type: 'entity_change', action: 'deleted', repo: sourceRepo, entityType, entityId,
                    actor, source: 'api', timestamp: ts,
                });
            }
            wsHub?.broadcast({
                type: 'entity_change', action: 'created', repo: targetRepo, entityType, entityId,
                actor, source: 'api', timestamp: ts,
            });
            return reply.send({
                success: true,
                entity: created,
                newUrl: `/${targetRepo}/${entityType}/${entityId}`,
            });
        }
        catch (err) {
            return reply.status(400).send({ error: err.message });
        }
    });
    // POST /api/repos/:repo/transfer-entities — bulk move a list of entries
    // into a single target folder (in this or another repo). The web UI's
    // multi-select Move flow used to loop and POST per entry; this collapses
    // to one round-trip per source repo and one commit per repo touched.
    fastify.post('/api/repos/:repo/transfer-entities', async (req, reply) => {
        const sourceRepo = req.params.repo;
        const { entries, targetRepo, targetFolder } = req.body ?? {};
        if (!Array.isArray(entries) || !targetRepo || targetFolder === undefined) {
            return reply.status(400).send({ error: 'entries, targetRepo, and targetFolder are required' });
        }
        // Access enforced by graphForWrite — both source (mutate) and
        // target (write) require write access. Resolved up-front so a
        // target-side denial never surfaces only after source mutation.
        const sourceGraph = graphForWrite(req, reply, sourceRepo);
        if (!sourceGraph)
            return;
        const targetGraph = graphForWrite(req, reply, targetRepo);
        if (!targetGraph)
            return;
        try {
            if (sessionManager) {
                await sessionManager.flush(sourceRepo);
                if (targetRepo !== sourceRepo)
                    await sessionManager.flush(targetRepo);
            }
            const result = await sourceGraph.moveEntitiesToRepo(entries.map(e => ({ entityType: e.entityType, entityId: e.entityId })), targetGraph, targetFolder, { user: reqGitUser(req) });
            const actor = req.user?.displayName ?? 'API';
            const ts = new Date().toISOString();
            wsHub?.broadcast({ type: 'repo_sync', repo: sourceRepo, source: 'api', actor, timestamp: ts });
            if (targetRepo !== sourceRepo) {
                wsHub?.broadcast({ type: 'repo_sync', repo: targetRepo, source: 'api', actor, timestamp: ts });
            }
            return reply.send({ success: true, ...result });
        }
        catch (err) {
            const status = err?.name === 'InvalidFolderPathError' ? 400
                : err.message?.includes('not found') ? 404
                    : err.message?.includes('already occupied') ? 409
                        : 400;
            return reply.status(status).send({ error: err.message });
        }
    });
    // POST /api/repos/:repo/transfer-folder — move an entire folder (with all
    // descendants) into another repo in one shot. Replaces the per-entry loop
    // the web UI used to run, which produced 2 git commits per entry; this
    // route performs a single FS-level rename and one commit per repo.
    fastify.post('/api/repos/:repo/transfer-folder', async (req, reply) => {
        const sourceRepo = req.params.repo;
        const { sourceFolder, targetRepo, targetFolder } = req.body ?? {};
        if (!sourceFolder || !targetRepo || targetFolder === undefined) {
            return reply.status(400).send({ error: 'sourceFolder, targetRepo, and targetFolder are required' });
        }
        if (sourceRepo === targetRepo && sourceFolder === targetFolder) {
            return reply.status(400).send({ error: 'Source and target are the same' });
        }
        // Access enforced by graphForWrite — both source (mutate) and
        // target (write) require write access. Resolved up-front so a
        // target-side denial never surfaces only after source mutation.
        const sourceGraph = graphForWrite(req, reply, sourceRepo);
        if (!sourceGraph)
            return;
        const targetGraph = graphForWrite(req, reply, targetRepo);
        if (!targetGraph)
            return;
        try {
            // Flush any deferred saves on either side so we don't strand a
            // pending edit in the source folder mid-move.
            if (sessionManager) {
                await sessionManager.flush(sourceRepo);
                if (targetRepo !== sourceRepo)
                    await sessionManager.flush(targetRepo);
            }
            const stats = await sourceGraph.moveFolderToRepo(sourceFolder, targetGraph, targetFolder, { user: reqGitUser(req) });
            const actor = req.user?.displayName ?? 'API';
            const ts = new Date().toISOString();
            // One refresh-trigger per repo. The receiving and sending lists
            // both need to update; using `repo_sync` keeps the realtime
            // handler simple — it already calls workspace.refreshEntities
            // on this event type. Avoids emitting 2× entries-moved entity_change
            // broadcasts (one created, one deleted) per file.
            wsHub?.broadcast({ type: 'repo_sync', repo: sourceRepo, source: 'api', actor, timestamp: ts });
            if (targetRepo !== sourceRepo) {
                wsHub?.broadcast({ type: 'repo_sync', repo: targetRepo, source: 'api', actor, timestamp: ts });
            }
            return reply.send({ success: true, ...stats });
        }
        catch (err) {
            const status = err?.name === 'InvalidFolderPathError' ? 400
                : err.message?.includes('not found') ? 404
                    : err.message?.includes('not empty') ? 409
                        : 400;
            return reply.status(status).send({ error: err.message });
        }
    });
    // DELETE /api/repos/:repo/entities/:type/:id — delete an entity
    fastify.delete('/api/repos/:repo/entities/:type/:id', async (req, reply) => {
        const { repo, type, id } = req.params;
        // Access enforced by graphForRead (WorkspaceAccessService).
        const graph = graphForRead(req, reply, repo);
        if (!graph)
            return;
        try {
            await graph.delete(type, id, `Delete ${type}/${id} from web UI`, reqGitUser(req));
            wsHub?.broadcast({
                type: 'entity_change', action: 'deleted', repo, entityType: type, entityId: id,
                actor: req.user?.displayName ?? 'API',
                source: 'api', timestamp: new Date().toISOString(),
            });
            return reply.send({ ok: true });
        }
        catch (err) {
            return reply.status(400).send({ error: err.message });
        }
    });
    // POST /api/repos/:repo/entities/bulk-delete — delete many entities in
    // one git transaction + one WS broadcast. Replaces N parallel
    // DELETE /entities/:type/:id calls, which on a 500-entry bulk delete
    // produced 500 git commits + 500 entity_change broadcasts and froze
    // the UI for minutes.
    fastify.post('/api/repos/:repo/entities/bulk-delete', async (req, reply) => {
        const { repo } = req.params;
        // Access enforced by graphForWrite (WorkspaceAccessService).
        const graph = graphForWrite(req, reply, repo);
        if (!graph)
            return;
        const body = req.body;
        if (!body?.entities || !Array.isArray(body.entities)) {
            return reply.status(400).send({ error: 'Expected { entities: [{ entityType, entityId }] }' });
        }
        try {
            const result = await graph.bulkDelete(body.entities.map(e => ({ entityType: e.entityType, entityId: e.entityId })), {
                user: reqGitUser(req),
                commitMessage: `Bulk delete ${body.entities.length} ${body.entities.length === 1 ? 'entry' : 'entries'} from web UI`,
            });
            if (result.deleted.length > 0) {
                wsHub?.broadcast({
                    type: 'repo_sync', repo,
                    actor: req.user?.displayName ?? 'API',
                    source: 'api', timestamp: new Date().toISOString(),
                });
            }
            return reply.send({
                deleted: result.deleted.length,
                errors: result.errors,
            });
        }
        catch (err) {
            return reply.status(400).send({ error: err.message });
        }
    });
    // POST /api/repos/:repo/entities — create a new entity
    fastify.post('/api/repos/:repo/entities', async (req, reply) => {
        const { repo } = req.params;
        // Access enforced by graphForWrite (WorkspaceAccessService).
        const graph = graphForWrite(req, reply, repo);
        if (!graph)
            return;
        const { entityType, entityId, data = {}, content = '', folder_path } = req.body;
        if (!entityType || !entityId) {
            return reply.status(400).send({ error: 'entityType and entityId are required' });
        }
        const eagerError = checkEagerSkillPermission(req, entityType, data, repo, workspaceManager, authService);
        if (eagerError)
            return reply.status(403).send({ error: eagerError });
        // Person entities can carry a `user_handle` pointer to an auth account;
        // enforce workspace-wide uniqueness AND owner-only authz before
        // delegating to the per-repo create path. Stamping a handle on a new
        // entity is the same trust action as stamping it on an existing one,
        // so non-owners can't create a pre-linked person entity either.
        if (entityType === 'person') {
            try {
                validateUserHandleInPayload(workspaceManager, data);
                if (Object.prototype.hasOwnProperty.call(data, 'user_handle')) {
                    assertCanChangeUserHandle(reqUser(req), '', data.user_handle);
                }
            }
            catch (err) {
                if (err instanceof BridgeError) {
                    const status = err.code === 'OWNER_REQUIRED' ? 403 : 409;
                    return reply.status(status).send({ error: err.message, code: err.code, ...err.details });
                }
                throw err;
            }
        }
        try {
            const entity = await graph.create({
                entityType: entityType,
                entityId,
                data: { entity_type: entityType, entity_id: entityId, ...data },
                content,
                folderPath: folder_path,
                commitMessage: `Create ${entityType}/${entityId} from web UI`,
                user: reqGitUser(req),
            });
            wsHub?.broadcast({
                type: 'entity_change', action: 'created', repo, entityType, entityId,
                actor: req.user?.displayName ?? 'API',
                source: 'api', timestamp: new Date().toISOString(),
            });
            return reply.status(201).send(entity);
        }
        catch (err) {
            return reply.status(400).send({ error: err.message });
        }
    });
    // POST /api/repos/:repo/move — move entry to a different folder within the
    // same repo. Distinct from /transfer (cross-repo) and /rename (id change).
    // The entry's type and id are unchanged; only its folder_path moves.
    fastify.post('/api/repos/:repo/move', async (req, reply) => {
        const { repo } = req.params;
        const { entityType, entityId, folder_path } = req.body ?? {};
        if (!entityType || !entityId || folder_path === undefined) {
            return reply.status(400).send({ error: 'entityType, entityId, and folder_path are required' });
        }
        // Access enforced by graphForWrite (WorkspaceAccessService).
        const graph = graphForWrite(req, reply, repo);
        if (!graph)
            return;
        try {
            if (sessionManager)
                await sessionManager.flush(repo);
            const moved = await graph.move(entityType, entityId, folder_path, undefined, reqGitUser(req));
            wsHub?.broadcast({
                type: 'entity_change', action: 'updated', repo, entityType, entityId,
                actor: req.user?.displayName ?? 'API',
                source: 'api', timestamp: new Date().toISOString(),
            });
            return reply.send({ success: true, entity: moved });
        }
        catch (err) {
            const status = err?.name === 'InvalidFolderPathError' ? 400
                : err.message?.includes('already occupied') ? 409
                    : err.message?.includes('not found') ? 404
                        : 400;
            return reply.status(status).send({ error: err.message });
        }
    });
    // POST /api/repos/:repo/folders — create a folder (with .gitkeep). Auto-creates parents.
    // Optional display_name is written to the folder's .studiograph-folder.json
    // sidecar when set; otherwise the client falls back to toDisplayName(slug).
    fastify.post('/api/repos/:repo/folders', async (req, reply) => {
        const { repo } = req.params;
        const { path, display_name } = req.body ?? {};
        if (!path || typeof path !== 'string') {
            return reply.status(400).send({ error: 'path is required' });
        }
        // Access enforced by graphForWrite (WorkspaceAccessService).
        const graph = graphForWrite(req, reply, repo);
        if (!graph)
            return;
        try {
            await graph.createFolder(path, undefined, reqGitUser(req), {
                displayName: display_name,
            });
            return reply.status(201).send({ success: true, path });
        }
        catch (err) {
            return reply.status(400).send({ error: err.message });
        }
    });
    // PATCH /api/repos/:repo/folders — rename / move a folder within the repo,
    // optionally updating its display_name in the same commit. Three shapes:
    //   { from, to }                       — slug-only rename / move
    //   { from, to, display_name }          — slug change + display update
    //   { from, to: from, display_name }    — display-only update (no FS move)
    // `display_name: ""` (or null) clears the override and reverts to the slug
    // fallback.
    fastify.patch('/api/repos/:repo/folders', async (req, reply) => {
        const { repo } = req.params;
        const { from, to, display_name } = req.body ?? {};
        if (!from || !to) {
            return reply.status(400).send({ error: 'from and to are required' });
        }
        // Access enforced by graphForWrite (WorkspaceAccessService).
        const graph = graphForWrite(req, reply, repo);
        if (!graph)
            return;
        try {
            if (sessionManager)
                await sessionManager.flush(repo);
            await graph.renameFolder(from, to, undefined, reqGitUser(req), {
                displayName: display_name === undefined ? undefined : display_name,
            });
            return reply.send({ success: true, from, to });
        }
        catch (err) {
            const status = err?.name === 'InvalidFolderPathError' ? 400
                : err.message?.includes('not found') ? 404
                    : err.message?.includes('already exists') ? 409
                        : 400;
            return reply.status(status).send({ error: err.message });
        }
    });
    // DELETE /api/repos/:repo/folders?path=X&recursive=true — delete a folder.
    // Without recursive=true, non-empty folders return 409 with blast-radius
    // counts so the UI can confirm before retrying.
    fastify.delete('/api/repos/:repo/folders', async (req, reply) => {
        const { repo } = req.params;
        const { path, recursive } = req.query;
        if (!path) {
            return reply.status(400).send({ error: 'path query parameter is required' });
        }
        // Access enforced by graphForWrite (WorkspaceAccessService).
        const graph = graphForWrite(req, reply, repo);
        if (!graph)
            return;
        try {
            if (sessionManager)
                await sessionManager.flush(repo);
            const result = await graph.deleteFolder(path, {
                recursive: recursive === 'true',
                user: reqGitUser(req),
            });
            return reply.send({ success: true, ...result });
        }
        catch (err) {
            if (err?.code === 'FOLDER_NOT_EMPTY') {
                return reply.status(409).send({
                    error: err.message,
                    code: 'FOLDER_NOT_EMPTY',
                    blast_radius: err.blastRadius,
                });
            }
            const status = err?.name === 'InvalidFolderPathError' ? 400
                : err.message?.includes('not found') ? 404
                    : 400;
            return reply.status(status).send({ error: err.message });
        }
    });
    // POST /api/repos/:repo/commit — flush deferred commits (navigate-away signal)
    fastify.post('/api/repos/:repo/commit', async (req, reply) => {
        if (!sessionManager) {
            return reply.send({ committed: false });
        }
        const { repo } = req.params;
        // Access enforced via accessService.canRead \u2014 flush operates on the
        // SessionManager (no graph instance needed), so a boolean check is
        // sufficient. Pending commits were already authored by an authenticated
        // writer; this endpoint just flushes them on navigate-away.
        if (!accessService.canRead(reqUser(req), repo)) {
            return reply.status(404).send({ error: `Folder \u201C${repo}\u201D not found` });
        }
        const committed = await sessionManager.flush(repo);
        return reply.send({ committed });
    });
    // POST /api/repos/:repo/import — import files into a folder.
    //
    // Routing by extension:
    //   .md / .txt / .csv          → direct write via importFile() — no LLM
    //     round-trip, preserves the source faithfully. Markdown frontmatter
    //     (entity_type, entity_id) is honored when present; otherwise
    //     sensible defaults.
    //   .pdf + opaque binaries     → asset wrapper via uploadAsset() —
    //     creates an `asset` entity, stores the binary in R2 or the local
    //     volume, writes a PDF text sidecar for vector indexing.
    //   .docx                      → text extraction + agent capture
    //     pipeline (until the mammoth → markdown workstream lands).
    fastify.post('/api/repos/:repo/import', async (req, reply) => {
        const { repo } = req.params;
        // Access enforced by graphForWrite — import creates entities, so write
        // is required. Graph captured here is reused by the inner closures so
        // they don't need to re-resolve (which would also bypass the gate).
        const repoGraph = graphForWrite(req, reply, repo);
        if (!repoGraph)
            return;
        const body = req.body;
        if (!body?.files || !Array.isArray(body.files)) {
            return reply.status(400).send({ error: 'Expected { files: [{ filename, content }] }' });
        }
        // Reject paths with traversal segments; importFile slugifies the rest.
        // Empty / undefined = repo root (existing behavior).
        const targetFolderPath = (body.targetFolderPath ?? '').trim();
        if (targetFolderPath.split('/').some(seg => seg === '..' || seg === '.')) {
            return reply.status(400).send({ error: 'Invalid targetFolderPath: contains traversal segments' });
        }
        const captureRepo = body.agentDecides ? undefined : repo;
        const gitUser = reqGitUser(req);
        try {
            const { extractText, isSupportedFile, importFile } = await import('../../services/import-service.js');
            const { isAssetUploadExtension } = await import('../../services/assets/base.js');
            const { uploadAsset } = await import('../../services/asset-upload-service.js');
            const { extname } = await import('path');
            const results = [];
            // Direct-write extensions never need an LLM. Verbatim preservation —
            // markdown stays markdown, CSV stays CSV.
            const DIRECT_WRITE_EXTS = new Set(['.md', '.txt', '.csv']);
            const CONCURRENCY = 3;
            async function processDirect(file) {
                // Reuse the access-checked graph captured in the outer scope. Do
                // NOT re-resolve via workspaceManager.getGraph here — the audit's
                // boundary is the outer graphForWrite call.
                const graph = repoGraph;
                const result = await importFile(graph, file.filename, file.content, {
                    user: gitUser,
                    // Caller-chosen destination subfolder; importFile combines it
                    // with each file's own relative directory.
                    folderPath: targetFolderPath || undefined,
                    // Defer commits — we batch them into one transaction at the
                    // end of the request so a 500-file import is one git commit
                    // instead of 500. Per-file commits dominated wall time
                    // (~50-200ms each) before this; batching collapses that to
                    // one commit for the whole batch.
                    skipCommit: true,
                });
                if (result.status === 'created' && result.path) {
                    return {
                        filename: file.filename,
                        status: 'created',
                        entities: [{ entityId: result.entityId, entityType: result.entityType }],
                        entry: {
                            operation: 'create',
                            entityType: result.entityType,
                            entityId: result.entityId,
                            filePath: result.path,
                        },
                        sidecarPaths: result.sidecarPaths,
                    };
                }
                return { filename: file.filename, status: result.status, error: result.error };
            }
            async function processAsset(file) {
                // Reuse the outer access-checked graph; re-resolving would bypass
                // the architectural boundary even though the closure captures repo.
                const graph = repoGraph;
                try {
                    const buffer = Buffer.from(file.content, 'base64');
                    const result = await uploadAsset({
                        graph,
                        filename: file.filename,
                        buffer,
                        folderPath: targetFolderPath || undefined,
                        user: gitUser,
                        workspacePath: graph.getWorkspaceRoot(),
                        // Defer commits — the dispatcher's batched commitPending
                        // call at the bottom of the route covers every entry in
                        // the same transaction.
                        skipCommit: true,
                    });
                    return {
                        filename: file.filename,
                        status: 'created',
                        entities: [{ entityId: result.entityId, entityType: result.entityType }],
                        entry: {
                            operation: 'create',
                            entityType: 'asset',
                            entityId: result.entityId,
                            filePath: result.filePath,
                        },
                        // PDF text sidecars: include in extraPaths so they land in
                        // the same commit as the entity files.
                        sidecarPaths: result.sidecarPath ? [result.sidecarPath] : undefined,
                    };
                }
                catch (err) {
                    return { filename: file.filename, status: 'error', error: err.message ?? 'Asset upload failed' };
                }
            }
            async function processViaAgent(file) {
                try {
                    const text = await extractText(file.filename, file.content);
                    if (!text) {
                        log.warn(`[import] No text extracted from ${file.filename}`);
                        return { filename: file.filename, status: 'error', error: 'No text content extracted' };
                    }
                    log.info(`[import] Extracted ${text.length} chars from ${file.filename}`);
                    const captureRes = await fastify.inject({
                        method: 'POST',
                        url: '/api/capture',
                        headers: {
                            'content-type': 'application/json',
                            ...(req.headers.authorization ? { authorization: req.headers.authorization } : {}),
                            ...(req.headers.cookie ? { cookie: req.headers.cookie } : {}),
                        },
                        payload: JSON.stringify({ text: `[Imported from ${file.filename}]\n\n${text}`, ...(captureRepo ? { repo: captureRepo } : {}) }),
                    });
                    const captureBody = JSON.parse(captureRes.body);
                    if (captureRes.statusCode !== 200 || !captureBody.success) {
                        log.warn(`[import] Capture failed for ${file.filename}: ${captureRes.statusCode} ${captureBody.error ?? JSON.stringify(captureBody).slice(0, 200)}`);
                        return { filename: file.filename, status: 'error', error: captureBody.error ?? 'Agent processing failed' };
                    }
                    return { filename: file.filename, status: 'created', entities: captureBody.entities };
                }
                catch (err) {
                    return { filename: file.filename, status: 'error', error: err.message ?? 'Processing failed' };
                }
            }
            async function processFile(file) {
                if (!file.filename) {
                    return { filename: file.filename, status: 'error', error: 'Missing filename' };
                }
                const ext = extname(file.filename).toLowerCase();
                // Direct-write text formats first — never collide with asset
                // detection. Then asset extensions (images, PDF, design files,
                // archives, …). Then the agent-extraction fallback for .docx
                // until the mammoth workstream lands. Anything else: reject.
                if (DIRECT_WRITE_EXTS.has(ext))
                    return processDirect(file);
                if (isAssetUploadExtension(file.filename))
                    return processAsset(file);
                if (isSupportedFile(file.filename))
                    return processViaAgent(file);
                return { filename: file.filename, status: 'error', error: `Unsupported file: ${file.filename}` };
            }
            // Process files in batches of CONCURRENCY
            for (let i = 0; i < body.files.length; i += CONCURRENCY) {
                const batch = body.files.slice(i, i + CONCURRENCY);
                const batchResults = await Promise.all(batch.map(processFile));
                results.push(...batchResults);
            }
            // Direct-write path deferred git commits — flush them all in one
            // transaction now. Collapses N per-file commits into one batch
            // commit (~50ms vs N×50ms). Agent-path files committed inline via
            // /api/capture so they're not in this set.
            const pendingEntries = results
                .map(r => r.entry)
                .filter((e) => Boolean(e));
            // Collect every sidecar path produced by the import — these stage in
            // the same commit as the entity files, so the folder display name
            // and the entries it labels land atomically. De-dup because two
            // imported files inside the same Schema_OS/widgets/ would otherwise
            // produce duplicate sidecar entries.
            const sidecarPaths = Array.from(new Set(results.flatMap(r => r.sidecarPaths ?? [])));
            // commitPending failure used to be swallowed and the route returned
            // success=true even though the files were never committed. The audit
            // (2026-05) flagged this: a 500-file import that failed mid-commit
            // looked successful to the client but left N untracked files. Now
            // the failure propagates as 500 with a partial-failure shape so the
            // caller knows which entries are stranded.
            let commitError = null;
            if (pendingEntries.length > 0) {
                try {
                    await repoGraph.commitPending(pendingEntries, {
                        commitMessage: `Import ${pendingEntries.length} ${pendingEntries.length === 1 ? 'entry' : 'entries'} from web UI`,
                        user: gitUser,
                        extraPaths: sidecarPaths.length > 0 ? sidecarPaths : undefined,
                    });
                }
                catch (err) {
                    commitError = err instanceof Error ? err.message : String(err);
                    log.warn(`[import] batch commit failed for ${repo}: ${commitError}`);
                }
            }
            const created = results.filter(r => r.status === 'created').length;
            const entityCount = results.reduce((sum, r) => sum + (r.entities?.length ?? 0), 0);
            // Single repo_sync broadcast for the whole import. The realtime
            // store's repo_sync handler refreshes the repo's entity list once
            // — vs. one entity_change per file, which would do N re-renders
            // on every connected client and freeze the UI for large imports.
            // Skip when commit failed: there's nothing for clients to refresh
            // beyond stranded uncommitted files, and the broadcast would lie
            // about persistence.
            if (created > 0 && !commitError) {
                wsHub?.broadcast({
                    type: 'repo_sync', repo,
                    actor: req.user?.displayName ?? 'API',
                    source: 'api', timestamp: new Date().toISOString(),
                });
            }
            const errors = results.filter(r => r.status === 'error').map(r => r.error);
            if (commitError) {
                // Files are on disk but uncommitted — partial failure. Surface as
                // 500 with details so the client doesn't show "imported N entries"
                // for entries that won't survive a server restart.
                return reply.status(500).send({
                    success: false,
                    error: `Import wrote ${pendingEntries.length} entries to disk but the git commit failed: ${commitError}`,
                    commitError,
                    uncommittedEntries: pendingEntries.length,
                    imported: created,
                    entityCount,
                    skipped: errors.length,
                    errors,
                    results,
                });
            }
            return {
                success: true,
                imported: created,
                entityCount,
                skipped: errors.length,
                errors,
                results,
            };
        }
        catch (err) {
            return reply.status(400).send({
                success: false,
                error: err instanceof Error ? err.message : 'Unknown error',
            });
        }
    });
    // POST /api/repos/:repo/import-bookmarks — import browser bookmark exports as reference entities
    fastify.post('/api/repos/:repo/import-bookmarks', async (req, reply) => {
        const { repo } = req.params;
        // Access enforced by graphForWrite — bookmark import creates entities.
        const graph = graphForWrite(req, reply, repo);
        if (!graph)
            return;
        const body = req.body;
        if (!body?.content || typeof body.content !== 'string') {
            return reply.status(400).send({ error: 'Expected { content: string }' });
        }
        try {
            const { isNetscapeBookmarkFile, importBookmarks } = await import('../../services/bookmark-import-service.js');
            if (!isNetscapeBookmarkFile(body.content)) {
                return reply.status(400).send({
                    error: "This doesn't appear to be a browser bookmark export. Export bookmarks from your browser's bookmark manager to generate the correct format.",
                });
            }
            const user = reqGitUser(req);
            const result = await importBookmarks(graph, body.content, user);
            // Single git commit for all created entities
            if (result.created > 0) {
                const git = graph.getGitService();
                if (git) {
                    const commitUser = user ?? { id: 'import', name: 'Import', email: 'import@studiograph' };
                    git.commitAll(`Import ${result.created} bookmark${result.created !== 1 ? 's' : ''} as references`, commitUser);
                }
            }
            return {
                success: true,
                created: result.created,
                skipped: result.skipped,
                errors: result.errors,
            };
        }
        catch (err) {
            return reply.status(400).send({
                success: false,
                error: err instanceof Error ? err.message : 'Unknown error',
            });
        }
    });
    // POST /api/repos/:repo/assets — generic asset upload.
    //
    // Multipart `file` field. Creates a wrapper `asset` entity whose
    // frontmatter points at the stored binary (R2 or local volume) via
    // `asset_url`. PDFs additionally get a text sidecar at
    // `<workspace>/.studiograph/assets-text/asset/<id>.txt` so the vector
    // indexer can RAG them without bloating the markdown body. See
    // docs/asset-architecture.md.
    fastify.post('/api/repos/:repo/assets', async (req, reply) => {
        const { repo } = req.params;
        // Access enforced by graphForWrite (WorkspaceAccessService).
        const graph = graphForWrite(req, reply, repo);
        if (!graph)
            return;
        // Per-route fileSize override — the global multipart limit (10MB) is
        // tuned for inline image embeds; full asset uploads accept up to the
        // 100MB cap enforced inside AssetService.upload().
        let file;
        try {
            file = await req.file({ limits: { fileSize: 100 * 1024 * 1024 } });
        }
        catch (err) {
            return reply.status(400).send({ error: err?.message ?? 'Invalid multipart upload' });
        }
        if (!file)
            return reply.status(400).send({ error: 'No file uploaded' });
        const buffer = await file.toBuffer();
        const folderPath = (req.query.folder ?? '').trim();
        if (folderPath.split('/').some(seg => seg === '..' || seg === '.')) {
            return reply.status(400).send({ error: 'Invalid folder: contains traversal segments' });
        }
        try {
            const { uploadAsset } = await import('../../services/asset-upload-service.js');
            const result = await uploadAsset({
                graph,
                filename: file.filename,
                buffer,
                folderPath: folderPath || undefined,
                name: req.query.name?.trim() || undefined,
                user: reqGitUser(req),
                workspacePath: graph.getWorkspaceRoot(),
            });
            wsHub?.broadcast({
                type: 'repo_sync',
                repo,
                actor: req.user?.displayName ?? 'API',
                source: 'api',
                timestamp: new Date().toISOString(),
            });
            return reply.send({
                success: true,
                entity: {
                    entityId: result.entityId,
                    entityType: result.entityType,
                    asset_url: result.asset_url,
                    asset_kind: result.asset_kind,
                    asset_size: result.asset_size,
                    pages: result.pages,
                },
            });
        }
        catch (err) {
            const msg = err instanceof Error ? err.message : String(err);
            const status = msg.includes('not configured') ? 501
                : msg.includes('Unsupported asset extension') ? 415
                    : 400;
            return reply.status(status).send({ success: false, error: msg });
        }
    });
}
//# sourceMappingURL=graph-api.js.map