"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.TaskGraphManager = exports.createTaskGraph = void 0;
exports.proxyId = proxyId;
exports.isProxy = isProxy;
exports.cleanupProxies = cleanupProxies;
exports.nextOrderForStatus = nextOrderForStatus;
exports.rebalanceOrders = rebalanceOrders;
exports.reorderTask = reorderTask;
exports.createTask = createTask;
exports.updateTask = updateTask;
exports.moveTask = moveTask;
exports.deleteTask = deleteTask;
exports.getTask = getTask;
exports.listTasks = listTasks;
exports.createEpic = createEpic;
exports.updateEpic = updateEpic;
exports.deleteEpic = deleteEpic;
exports.getEpic = getEpic;
exports.listEpics = listEpics;
exports.linkTaskToEpic = linkTaskToEpic;
exports.unlinkTaskFromEpic = unlinkTaskFromEpic;
exports.listEpicTasks = listEpicTasks;
exports.createTaskRelation = createTaskRelation;
exports.deleteTaskRelation = deleteTaskRelation;
exports.listTaskRelations = listTaskRelations;
exports.findLinkedTasks = findLinkedTasks;
exports.createCrossRelation = createCrossRelation;
exports.deleteCrossRelation = deleteCrossRelation;
exports.saveTaskGraph = saveTaskGraph;
exports.loadTaskGraph = loadTaskGraph;
const fs_1 = __importDefault(require("fs"));
const path_1 = __importDefault(require("path"));
const task_types_1 = require("../graphs/task-types");
Object.defineProperty(exports, "createTaskGraph", { enumerable: true, get: function () { return task_types_1.createTaskGraph; } });
const knowledge_types_1 = require("../graphs/knowledge-types");
const manager_types_1 = require("../graphs/manager-types");
const tasks_1 = require("../lib/search/tasks");
const bm25_1 = require("../lib/search/bm25");
const file_mirror_1 = require("../lib/file-mirror");
const embedding_codec_1 = require("../lib/embedding-codec");
const graph_persistence_1 = require("../lib/graph-persistence");
const defaults_1 = require("../lib/defaults");
const logger_1 = require("../lib/logger");
const log = (0, logger_1.createLogger)('task-graph');
const attachment_types_1 = require("../graphs/attachment-types");
const file_import_1 = require("../lib/file-import");
// ---------------------------------------------------------------------------
// Proxy helpers
// ---------------------------------------------------------------------------
/** Build the proxy node ID. With projectId: `@docs::frontend::guide.md::Setup`, without: `@docs::guide.md::Setup` */
function proxyId(targetGraph, nodeId, projectId) {
    return projectId ? `@${targetGraph}::${projectId}::${nodeId}` : `@${targetGraph}::${nodeId}`;
}
/** Check whether a node is a cross-graph proxy. */
function isProxy(graph, nodeId) {
    if (!graph.hasNode(nodeId))
        return false;
    return graph.getNodeAttribute(nodeId, 'proxyFor') !== undefined;
}
/** Ensure a proxy node exists for the given external target. Returns its ID. */
function ensureProxyNode(graph, targetGraph, nodeId, projectId) {
    const id = proxyId(targetGraph, nodeId, projectId);
    if (!graph.hasNode(id)) {
        graph.addNode(id, {
            title: '',
            description: '',
            status: 'backlog',
            priority: 'low',
            tags: [],
            order: 0,
            dueDate: null,
            estimate: null,
            completedAt: null,
            assignee: null,
            embedding: [],
            attachments: [],
            createdAt: 0,
            updatedAt: 0,
            version: 0,
            proxyFor: { graph: targetGraph, nodeId, projectId },
        });
    }
    return id;
}
/** Remove a proxy node if it has zero incident edges. */
function cleanupProxy(graph, nodeId) {
    if (!graph.hasNode(nodeId))
        return;
    if (!isProxy(graph, nodeId))
        return;
    if (graph.degree(nodeId) === 0) {
        graph.dropNode(nodeId);
    }
}
/**
 * Remove all proxy nodes whose target no longer exists in the external graph.
 * Called after doc/code/file removal in the indexer.
 */
function cleanupProxies(graph, targetGraph, externalGraph, projectId) {
    const toRemove = [];
    graph.forEachNode((id, attrs) => {
        if (attrs.proxyFor && attrs.proxyFor.graph === targetGraph) {
            if (projectId && attrs.proxyFor.projectId && attrs.proxyFor.projectId !== projectId)
                return;
            if (!externalGraph.hasNode(attrs.proxyFor.nodeId)) {
                toRemove.push(id);
            }
        }
    });
    for (const id of toRemove) {
        graph.dropNode(id); // also drops incident edges
    }
}
// ---------------------------------------------------------------------------
// Order helpers
// ---------------------------------------------------------------------------
const ORDER_GAP = 1000;
/** Compute the next order value for a given status group. */
function nextOrderForStatus(graph, status) {
    let max = -ORDER_GAP;
    graph.forEachNode((_id, attrs) => {
        if (attrs.proxyFor)
            return;
        if (attrs.status === status && (attrs.order ?? 0) > max) {
            max = attrs.order ?? 0;
        }
    });
    return max + ORDER_GAP;
}
/** Rebalance order values for all tasks in a status group to multiples of ORDER_GAP. */
function rebalanceOrders(graph, status) {
    const nodes = [];
    graph.forEachNode((id, attrs) => {
        if (attrs.proxyFor)
            return;
        if (attrs.status === status) {
            nodes.push({ id, order: attrs.order ?? 0 });
        }
    });
    nodes.sort((a, b) => a.order - b.order);
    for (let i = 0; i < nodes.length; i++) {
        graph.setNodeAttribute(nodes[i].id, 'order', i * ORDER_GAP);
    }
}
/** Reorder a task: set new order (and optionally new status). Rebalances if gap is too small. */
function reorderTask(graph, taskId, newOrder, newStatus) {
    if (!graph.hasNode(taskId))
        return false;
    if (isProxy(graph, taskId))
        return false;
    const oldStatus = graph.getNodeAttribute(taskId, 'status');
    const targetStatus = newStatus ?? oldStatus;
    if (newStatus !== undefined && newStatus !== oldStatus) {
        // Handle status change with completedAt auto-logic
        graph.setNodeAttribute(taskId, 'status', newStatus);
        if ((0, task_types_1.isTerminal)(newStatus) && !(0, task_types_1.isTerminal)(oldStatus)) {
            graph.setNodeAttribute(taskId, 'completedAt', Date.now());
        }
        else if (!(0, task_types_1.isTerminal)(newStatus) && (0, task_types_1.isTerminal)(oldStatus)) {
            graph.setNodeAttribute(taskId, 'completedAt', null);
        }
    }
    graph.setNodeAttribute(taskId, 'order', newOrder);
    graph.setNodeAttribute(taskId, 'version', graph.getNodeAttribute(taskId, 'version') + 1);
    graph.setNodeAttribute(taskId, 'updatedAt', Date.now());
    // Check if rebalance needed: look for collisions in the target status group
    let needsRebalance = false;
    graph.forEachNode((id, attrs) => {
        if (id === taskId || attrs.proxyFor)
            return;
        if (attrs.status === targetStatus && attrs.order === newOrder) {
            needsRebalance = true;
        }
    });
    if (needsRebalance) {
        rebalanceOrders(graph, targetStatus);
    }
    return true;
}
// ---------------------------------------------------------------------------
// CRUD — Tasks
// ---------------------------------------------------------------------------
/** Create a task, return its UUID ID. */
function createTask(graph, title, description, status, priority, tags, embedding, dueDate = null, estimate = null, author = '', assignee = null, order) {
    const id = (0, knowledge_types_1.generateId)();
    const now = Date.now();
    const effectiveOrder = order ?? nextOrderForStatus(graph, status);
    graph.addNode(id, {
        title,
        description,
        status,
        priority,
        tags,
        order: effectiveOrder,
        dueDate,
        estimate,
        completedAt: null,
        assignee,
        embedding,
        attachments: [],
        createdAt: now,
        updatedAt: now,
        version: 1,
        createdBy: author || undefined,
        updatedBy: author || undefined,
    });
    return id;
}
/** Partial update of a task. Returns true if found and updated. Throws VersionConflictError if expectedVersion is provided and doesn't match. */
function updateTask(graph, taskId, patch, embedding, author = '', expectedVersion) {
    if (!graph.hasNode(taskId))
        return false;
    if (isProxy(graph, taskId))
        return false;
    if (expectedVersion !== undefined) {
        const current = graph.getNodeAttribute(taskId, 'version');
        if (current !== expectedVersion)
            throw new manager_types_1.VersionConflictError(current, expectedVersion);
    }
    if (patch.title !== undefined)
        graph.setNodeAttribute(taskId, 'title', patch.title);
    if (patch.description !== undefined)
        graph.setNodeAttribute(taskId, 'description', patch.description);
    if (patch.priority !== undefined)
        graph.setNodeAttribute(taskId, 'priority', patch.priority);
    if (patch.tags !== undefined)
        graph.setNodeAttribute(taskId, 'tags', patch.tags);
    if (patch.order !== undefined)
        graph.setNodeAttribute(taskId, 'order', patch.order);
    if (patch.dueDate !== undefined)
        graph.setNodeAttribute(taskId, 'dueDate', patch.dueDate);
    if (patch.estimate !== undefined)
        graph.setNodeAttribute(taskId, 'estimate', patch.estimate);
    if (patch.assignee !== undefined)
        graph.setNodeAttribute(taskId, 'assignee', patch.assignee);
    if (embedding !== undefined)
        graph.setNodeAttribute(taskId, 'embedding', embedding);
    if (author)
        graph.setNodeAttribute(taskId, 'updatedBy', author);
    // Handle status change with completedAt auto-logic
    if (patch.status !== undefined) {
        const oldStatus = graph.getNodeAttribute(taskId, 'status');
        graph.setNodeAttribute(taskId, 'status', patch.status);
        if ((0, task_types_1.isTerminal)(patch.status) && !(0, task_types_1.isTerminal)(oldStatus)) {
            graph.setNodeAttribute(taskId, 'completedAt', Date.now());
        }
        else if (!(0, task_types_1.isTerminal)(patch.status) && (0, task_types_1.isTerminal)(oldStatus)) {
            graph.setNodeAttribute(taskId, 'completedAt', null);
        }
    }
    graph.setNodeAttribute(taskId, 'version', graph.getNodeAttribute(taskId, 'version') + 1);
    graph.setNodeAttribute(taskId, 'updatedAt', Date.now());
    return true;
}
/** Move a task to a new status. Handles completedAt auto-logic. Returns true if found. Throws VersionConflictError if expectedVersion is provided and doesn't match. */
function moveTask(graph, taskId, newStatus, expectedVersion, targetOrder) {
    if (!graph.hasNode(taskId))
        return false;
    if (isProxy(graph, taskId))
        return false;
    if (expectedVersion !== undefined) {
        const current = graph.getNodeAttribute(taskId, 'version');
        if (current !== expectedVersion)
            throw new manager_types_1.VersionConflictError(current, expectedVersion);
    }
    const oldStatus = graph.getNodeAttribute(taskId, 'status');
    graph.setNodeAttribute(taskId, 'status', newStatus);
    if ((0, task_types_1.isTerminal)(newStatus) && !(0, task_types_1.isTerminal)(oldStatus)) {
        graph.setNodeAttribute(taskId, 'completedAt', Date.now());
    }
    else if (!(0, task_types_1.isTerminal)(newStatus) && (0, task_types_1.isTerminal)(oldStatus)) {
        graph.setNodeAttribute(taskId, 'completedAt', null);
    }
    // Update order: use provided targetOrder, or append to end of new status group
    const effectiveOrder = targetOrder ?? (oldStatus !== newStatus ? nextOrderForStatus(graph, newStatus) : graph.getNodeAttribute(taskId, 'order'));
    graph.setNodeAttribute(taskId, 'order', effectiveOrder);
    graph.setNodeAttribute(taskId, 'version', graph.getNodeAttribute(taskId, 'version') + 1);
    graph.setNodeAttribute(taskId, 'updatedAt', Date.now());
    return true;
}
/** Delete a task and all its incident edges. Also cleans up orphaned proxy nodes. */
function deleteTask(graph, taskId) {
    if (!graph.hasNode(taskId))
        return false;
    if (isProxy(graph, taskId))
        return false;
    const proxyNeighbors = [];
    graph.forEachNeighbor(taskId, (neighbor) => {
        if (isProxy(graph, neighbor))
            proxyNeighbors.push(neighbor);
    });
    graph.dropNode(taskId);
    for (const p of proxyNeighbors) {
        cleanupProxy(graph, p);
    }
    return true;
}
/** Get a task by ID, or null if not found. Excludes proxy nodes and epics. */
function getTask(graph, taskId) {
    if (!graph.hasNode(taskId))
        return null;
    if (isProxy(graph, taskId))
        return null;
    if (graph.getNodeAttribute(taskId, 'nodeType') === 'epic')
        return null;
    const attrs = graph.getNodeAttributes(taskId);
    const subtasks = [];
    const blockedBy = [];
    const blocks = [];
    const related = [];
    const crossLinks = [];
    // Incoming edges: subtask_of (child → this) means child is a subtask
    graph.forEachInEdge(taskId, (_edge, edgeAttrs, source) => {
        if (isProxy(graph, source)) {
            // Incoming cross-graph link (e.g. note → this task via mirror proxy)
            const proxyFor = graph.getNodeAttribute(source, 'proxyFor');
            if (proxyFor) {
                crossLinks.push({
                    nodeId: proxyFor.nodeId,
                    targetGraph: proxyFor.graph,
                    kind: edgeAttrs.kind,
                    direction: 'incoming',
                });
            }
            return;
        }
        const srcAttrs = graph.getNodeAttributes(source);
        if (edgeAttrs.kind === 'subtask_of') {
            subtasks.push({ id: source, title: srcAttrs.title, status: srcAttrs.status });
        }
        else if (edgeAttrs.kind === 'blocks') {
            // source blocks this task
            blockedBy.push({ id: source, title: srcAttrs.title });
        }
        else if (edgeAttrs.kind === 'related_to') {
            related.push({ id: source, title: srcAttrs.title });
        }
    });
    // Outgoing edges
    graph.forEachOutEdge(taskId, (_edge, edgeAttrs, _source, target) => {
        if (isProxy(graph, target)) {
            // Outgoing cross-graph link (task → external node via proxy)
            const proxyFor = graph.getNodeAttribute(target, 'proxyFor');
            if (proxyFor) {
                crossLinks.push({
                    nodeId: proxyFor.nodeId,
                    targetGraph: proxyFor.graph,
                    kind: edgeAttrs.kind,
                    direction: 'outgoing',
                });
            }
            return;
        }
        const tgtAttrs = graph.getNodeAttributes(target);
        if (edgeAttrs.kind === 'subtask_of') {
            // this task is a subtask of target — skip, handled via parent lookup
        }
        else if (edgeAttrs.kind === 'blocks') {
            blocks.push({ id: target, title: tgtAttrs.title });
        }
        else if (edgeAttrs.kind === 'related_to') {
            if (!related.some(r => r.id === target)) {
                related.push({ id: target, title: tgtAttrs.title });
            }
        }
    });
    return {
        id: taskId,
        title: attrs.title,
        description: attrs.description,
        status: attrs.status,
        priority: attrs.priority,
        tags: attrs.tags,
        order: attrs.order ?? 0,
        dueDate: attrs.dueDate,
        estimate: attrs.estimate,
        completedAt: attrs.completedAt,
        assignee: attrs.assignee ?? null,
        createdAt: attrs.createdAt,
        updatedAt: attrs.updatedAt,
        version: attrs.version,
        attachments: attrs.attachments ?? [],
        subtasks,
        blockedBy,
        blocks,
        related,
        crossLinks,
    };
}
/** List tasks with optional filters. Excludes proxy nodes and epics. */
function listTasks(graph, opts = {}) {
    const { status, priority, tag, filter, assignee, limit = defaults_1.LIST_PAGE_SIZE, offset = 0 } = opts;
    const lowerFilter = filter?.toLowerCase();
    const lowerTag = tag?.toLowerCase();
    const results = [];
    graph.forEachNode((id, attrs) => {
        if (attrs.proxyFor)
            return;
        if (attrs.nodeType === 'epic')
            return;
        if (status && attrs.status !== status)
            return;
        if (priority && attrs.priority !== priority)
            return;
        if (assignee !== undefined && attrs.assignee !== assignee)
            return;
        if (lowerTag && !attrs.tags.some(t => t.toLowerCase() === lowerTag))
            return;
        if (lowerFilter) {
            const match = id.toLowerCase().includes(lowerFilter) ||
                attrs.title.toLowerCase().includes(lowerFilter);
            if (!match)
                return;
        }
        results.push({
            id,
            title: attrs.title,
            description: attrs.description?.slice(0, defaults_1.CONTENT_PREVIEW_LEN),
            status: attrs.status,
            priority: attrs.priority,
            tags: attrs.tags,
            order: attrs.order ?? 0,
            dueDate: attrs.dueDate,
            estimate: attrs.estimate,
            completedAt: attrs.completedAt,
            assignee: attrs.assignee ?? null,
            version: attrs.version,
            createdAt: attrs.createdAt,
            updatedAt: attrs.updatedAt,
            attachments: attrs.attachments ?? [],
        });
    });
    const sorted = results.sort((a, b) => {
        // Sort by priority (critical first), then order (ascending), then dueDate (earliest first, nulls last)
        const pDiff = task_types_1.PRIORITY_ORDER[a.priority] - task_types_1.PRIORITY_ORDER[b.priority];
        if (pDiff !== 0)
            return pDiff;
        const oDiff = a.order - b.order;
        if (oDiff !== 0)
            return oDiff;
        if (a.dueDate === null && b.dueDate === null)
            return 0;
        if (a.dueDate === null)
            return 1;
        if (b.dueDate === null)
            return -1;
        return a.dueDate - b.dueDate;
    });
    return { results: sorted.slice(offset, offset + limit), total: sorted.length };
}
function epicProgress(graph, epicId) {
    let done = 0;
    let total = 0;
    graph.forEachInEdge(epicId, (_edge, edgeAttrs, source) => {
        if (edgeAttrs.kind !== 'belongs_to')
            return;
        if (isProxy(graph, source))
            return;
        total++;
        const s = graph.getNodeAttribute(source, 'status');
        if ((0, task_types_1.isTerminal)(s))
            done++;
    });
    return { done, total };
}
function createEpic(graph, title, description, status, priority, tags, embedding, author = '', order) {
    const id = (0, knowledge_types_1.generateId)();
    const now = Date.now();
    const effectiveOrder = order ?? nextOrderForStatus(graph, status);
    graph.addNode(id, {
        title,
        description,
        status: status,
        priority,
        tags,
        order: effectiveOrder,
        dueDate: null,
        estimate: null,
        completedAt: null,
        assignee: null,
        nodeType: 'epic',
        embedding,
        attachments: [],
        createdAt: now,
        updatedAt: now,
        version: 1,
        createdBy: author || undefined,
        updatedBy: author || undefined,
    });
    return id;
}
function updateEpic(graph, epicId, patch, newStatus, expectedVersion, author) {
    if (!graph.hasNode(epicId))
        return false;
    if (graph.getNodeAttribute(epicId, 'nodeType') !== 'epic')
        return false;
    const current = graph.getNodeAttribute(epicId, 'version');
    if (expectedVersion !== undefined && current !== expectedVersion) {
        throw new manager_types_1.VersionConflictError(current, expectedVersion);
    }
    const now = Date.now();
    if (patch.title !== undefined)
        graph.setNodeAttribute(epicId, 'title', patch.title);
    if (patch.description !== undefined)
        graph.setNodeAttribute(epicId, 'description', patch.description);
    if (patch.priority !== undefined)
        graph.setNodeAttribute(epicId, 'priority', patch.priority);
    if (patch.tags !== undefined)
        graph.setNodeAttribute(epicId, 'tags', patch.tags);
    if (newStatus !== undefined) {
        graph.setNodeAttribute(epicId, 'status', newStatus);
        if ((0, task_types_1.isTerminal)(newStatus) && !graph.getNodeAttribute(epicId, 'completedAt')) {
            graph.setNodeAttribute(epicId, 'completedAt', now);
        }
        else if (!(0, task_types_1.isTerminal)(newStatus)) {
            graph.setNodeAttribute(epicId, 'completedAt', null);
        }
    }
    graph.setNodeAttribute(epicId, 'version', current + 1);
    graph.setNodeAttribute(epicId, 'updatedAt', now);
    if (author)
        graph.setNodeAttribute(epicId, 'updatedBy', author);
    return true;
}
function deleteEpic(graph, epicId) {
    if (!graph.hasNode(epicId))
        return false;
    if (graph.getNodeAttribute(epicId, 'nodeType') !== 'epic')
        return false;
    graph.dropNode(epicId);
    return true;
}
function getEpic(graph, epicId) {
    if (!graph.hasNode(epicId))
        return null;
    if (graph.getNodeAttribute(epicId, 'nodeType') !== 'epic')
        return null;
    const attrs = graph.getNodeAttributes(epicId);
    const crossLinks = [];
    graph.forEachOutEdge(epicId, (_edge, edgeAttrs, _source, target) => {
        if (!isProxy(graph, target))
            return;
        const proxyFor = graph.getNodeAttribute(target, 'proxyFor');
        if (proxyFor) {
            crossLinks.push({ nodeId: proxyFor.nodeId, targetGraph: proxyFor.graph, kind: edgeAttrs.kind, direction: 'outgoing' });
        }
    });
    graph.forEachInEdge(epicId, (_edge, edgeAttrs, source) => {
        if (!isProxy(graph, source))
            return;
        const proxyFor = graph.getNodeAttribute(source, 'proxyFor');
        if (proxyFor) {
            crossLinks.push({ nodeId: proxyFor.nodeId, targetGraph: proxyFor.graph, kind: edgeAttrs.kind, direction: 'incoming' });
        }
    });
    return {
        id: epicId,
        title: attrs.title,
        description: attrs.description,
        status: attrs.status,
        priority: attrs.priority,
        tags: attrs.tags,
        order: attrs.order ?? 0,
        createdAt: attrs.createdAt,
        updatedAt: attrs.updatedAt,
        version: attrs.version,
        attachments: attrs.attachments ?? [],
        progress: epicProgress(graph, epicId),
        crossLinks,
    };
}
function listEpics(graph, opts = {}) {
    const { status, priority, tag, filter, limit = defaults_1.LIST_PAGE_SIZE, offset = 0 } = opts;
    const lowerFilter = filter?.toLowerCase();
    const lowerTag = tag?.toLowerCase();
    const results = [];
    graph.forEachNode((id, attrs) => {
        if (attrs.proxyFor)
            return;
        if (attrs.nodeType !== 'epic')
            return;
        if (status && attrs.status !== status)
            return;
        if (priority && attrs.priority !== priority)
            return;
        if (lowerTag && !attrs.tags.some(t => t.toLowerCase() === lowerTag))
            return;
        if (lowerFilter) {
            const match = id.toLowerCase().includes(lowerFilter) || attrs.title.toLowerCase().includes(lowerFilter);
            if (!match)
                return;
        }
        results.push({
            id,
            title: attrs.title,
            description: attrs.description?.slice(0, defaults_1.CONTENT_PREVIEW_LEN),
            status: attrs.status,
            priority: attrs.priority,
            tags: attrs.tags,
            order: attrs.order ?? 0,
            createdAt: attrs.createdAt,
            updatedAt: attrs.updatedAt,
            version: attrs.version,
            attachments: attrs.attachments ?? [],
            progress: epicProgress(graph, id),
        });
    });
    const sorted = results.sort((a, b) => {
        const pDiff = task_types_1.PRIORITY_ORDER[a.priority] - task_types_1.PRIORITY_ORDER[b.priority];
        if (pDiff !== 0)
            return pDiff;
        return a.order - b.order;
    });
    return { results: sorted.slice(offset, offset + limit), total: sorted.length };
}
function linkTaskToEpic(graph, taskId, epicId) {
    if (!graph.hasNode(taskId) || !graph.hasNode(epicId))
        return false;
    if (graph.getNodeAttribute(epicId, 'nodeType') !== 'epic')
        return false;
    if (isProxy(graph, taskId))
        return false;
    const edgeKey = `${taskId}→${epicId}`;
    if (graph.hasEdge(edgeKey))
        return false;
    graph.addEdgeWithKey(edgeKey, taskId, epicId, { kind: 'belongs_to' });
    return true;
}
function unlinkTaskFromEpic(graph, taskId, epicId) {
    const edgeKey = `${taskId}→${epicId}`;
    if (!graph.hasEdge(edgeKey))
        return false;
    graph.dropEdge(edgeKey);
    return true;
}
/** List tasks belonging to an epic (via belongs_to edges). */
function listEpicTasks(graph, epicId) {
    if (!graph.hasNode(epicId))
        return [];
    const results = [];
    graph.forEachInEdge(epicId, (_edge, edgeAttrs, source) => {
        if (edgeAttrs.kind !== 'belongs_to')
            return;
        if (isProxy(graph, source))
            return;
        const attrs = graph.getNodeAttributes(source);
        results.push({
            id: source,
            title: attrs.title,
            description: attrs.description?.slice(0, defaults_1.CONTENT_PREVIEW_LEN),
            status: attrs.status,
            priority: attrs.priority,
            tags: attrs.tags,
            order: attrs.order ?? 0,
            dueDate: attrs.dueDate,
            estimate: attrs.estimate,
            completedAt: attrs.completedAt,
            assignee: attrs.assignee ?? null,
            createdAt: attrs.createdAt,
            updatedAt: attrs.updatedAt,
            version: attrs.version,
            attachments: attrs.attachments ?? [],
        });
    });
    return results.sort((a, b) => task_types_1.PRIORITY_ORDER[a.priority] - task_types_1.PRIORITY_ORDER[b.priority] || a.order - b.order);
}
// ---------------------------------------------------------------------------
// CRUD — Task Relations (task ↔ task)
// ---------------------------------------------------------------------------
/** Create a directed relation between two tasks. Returns true if created. */
function createTaskRelation(graph, fromId, toId, kind) {
    if (!graph.hasNode(fromId) || !graph.hasNode(toId))
        return false;
    if (isProxy(graph, fromId) || isProxy(graph, toId))
        return false;
    if (graph.hasEdge(fromId, toId))
        return false;
    graph.addEdgeWithKey(`${fromId}→${toId}`, fromId, toId, { kind });
    return true;
}
/** Delete a task relation. Returns true if it existed. */
function deleteTaskRelation(graph, fromId, toId) {
    if (!graph.hasEdge(fromId, toId))
        return false;
    graph.dropEdge(fromId, toId);
    return true;
}
/** List all relations for a task (both incoming and outgoing). Resolves proxy IDs and titles. */
function listTaskRelations(graph, taskId, externalGraphs) {
    if (!graph.hasNode(taskId))
        return [];
    const results = [];
    function resolveTitle(nodeId, targetGraph) {
        if (!targetGraph) {
            // Same-graph task
            if (graph.hasNode(nodeId) && !isProxy(graph, nodeId)) {
                return graph.getNodeAttribute(nodeId, 'title') || undefined;
            }
            return undefined;
        }
        if (!externalGraphs)
            return undefined;
        const extGraph = (0, manager_types_1.resolveExternalGraph)(externalGraphs, targetGraph);
        if (!extGraph || !extGraph.hasNode(nodeId))
            return undefined;
        const attrs = extGraph.getNodeAttributes(nodeId);
        return attrs.title || attrs.name || undefined;
    }
    function resolveEntry(source, target, kind) {
        const sourceProxy = graph.hasNode(source) ? graph.getNodeAttribute(source, 'proxyFor') : undefined;
        const targetProxy = graph.hasNode(target) ? graph.getNodeAttribute(target, 'proxyFor') : undefined;
        if (targetProxy) {
            const title = resolveTitle(targetProxy.nodeId, targetProxy.graph);
            return { fromId: source, toId: targetProxy.nodeId, kind, targetGraph: targetProxy.graph, ...(title ? { title } : {}) };
        }
        if (sourceProxy) {
            const title = resolveTitle(sourceProxy.nodeId, sourceProxy.graph);
            return { fromId: sourceProxy.nodeId, toId: target, kind, targetGraph: sourceProxy.graph, ...(title ? { title } : {}) };
        }
        const otherId = source === taskId ? target : source;
        const title = resolveTitle(otherId);
        return { fromId: source, toId: target, kind, ...(title ? { title } : {}) };
    }
    graph.forEachOutEdge(taskId, (_edge, attrs, source, target) => {
        results.push(resolveEntry(source, target, attrs.kind));
    });
    graph.forEachInEdge(taskId, (_edge, attrs, source, target) => {
        results.push(resolveEntry(source, target, attrs.kind));
    });
    return results;
}
/**
 * Find all tasks that have a cross-graph relation to the given target node.
 * Optionally filter by relation kind.
 */
function findLinkedTasks(graph, targetGraph, targetNodeId, kind, projectId) {
    const candidates = [proxyId(targetGraph, targetNodeId, projectId)];
    if (projectId)
        candidates.push(proxyId(targetGraph, targetNodeId));
    const results = [];
    const seen = new Set();
    for (const pId of candidates) {
        if (!graph.hasNode(pId))
            continue;
        graph.forEachInEdge(pId, (_edge, attrs, source) => {
            if (seen.has(source))
                return;
            if (isProxy(graph, source))
                return;
            if (kind && attrs.kind !== kind)
                return;
            const taskAttrs = graph.getNodeAttributes(source);
            seen.add(source);
            results.push({
                taskId: source,
                title: taskAttrs.title,
                kind: attrs.kind,
                status: taskAttrs.status,
                priority: taskAttrs.priority,
                tags: taskAttrs.tags,
            });
        });
    }
    return results;
}
// ---------------------------------------------------------------------------
// Cross-graph relations (task → doc/code/file/knowledge node)
// ---------------------------------------------------------------------------
/**
 * Create a cross-graph relation from a task to a node in an external graph.
 * Optionally validates that the target exists in the external graph.
 */
function createCrossRelation(graph, fromTaskId, targetGraph, targetNodeId, kind, externalGraph, projectId) {
    if (!graph.hasNode(fromTaskId) || isProxy(graph, fromTaskId))
        return false;
    if (externalGraph && !externalGraph.hasNode(targetNodeId))
        return false;
    const pId = ensureProxyNode(graph, targetGraph, targetNodeId, projectId);
    if (graph.hasEdge(fromTaskId, pId))
        return false;
    graph.addEdgeWithKey(`${fromTaskId}→${pId}`, fromTaskId, pId, { kind });
    return true;
}
/**
 * Delete a cross-graph relation. Cleans up orphaned proxy node.
 */
function deleteCrossRelation(graph, fromTaskId, targetGraph, targetNodeId, projectId) {
    const candidates = [proxyId(targetGraph, targetNodeId, projectId)];
    if (projectId)
        candidates.push(proxyId(targetGraph, targetNodeId));
    for (const pId of candidates) {
        // Outgoing: taskId → proxy(targetId) — task created this link
        if (graph.hasEdge(fromTaskId, pId)) {
            graph.dropEdge(fromTaskId, pId);
            cleanupProxy(graph, pId);
            return true;
        }
        // Incoming: proxy(targetId) → taskId — mirror from another graph
        if (graph.hasEdge(pId, fromTaskId)) {
            graph.dropEdge(pId, fromTaskId);
            cleanupProxy(graph, pId);
            return true;
        }
    }
    // Also check reverse proxy direction: proxy(fromId) → targetId
    // This handles the case when resolveEntry swaps fromId/toId for incoming mirrors,
    // e.g. UI sends {fromId: noteId, toId: taskId} but the edge is @knowledge::noteId → taskId
    const reverseCandidates = [proxyId(targetGraph, fromTaskId, projectId)];
    if (projectId)
        reverseCandidates.push(proxyId(targetGraph, fromTaskId));
    for (const pId of reverseCandidates) {
        if (graph.hasEdge(pId, targetNodeId)) {
            graph.dropEdge(pId, targetNodeId);
            cleanupProxy(graph, pId);
            return true;
        }
        if (graph.hasEdge(targetNodeId, pId)) {
            graph.dropEdge(targetNodeId, pId);
            cleanupProxy(graph, pId);
            return true;
        }
    }
    return false;
}
// ---------------------------------------------------------------------------
// Persistence
// ---------------------------------------------------------------------------
function saveTaskGraph(graph, graphMemory, embeddingFingerprint) {
    fs_1.default.mkdirSync(graphMemory, { recursive: true });
    const file = path_1.default.join(graphMemory, 'tasks.json');
    const tmp = file + '.tmp';
    try {
        const exported = graph.export();
        (0, embedding_codec_1.compressEmbeddings)(exported);
        fs_1.default.writeFileSync(tmp, JSON.stringify({ version: defaults_1.GRAPH_DATA_VERSION, embeddingModel: embeddingFingerprint, graph: exported }));
        fs_1.default.renameSync(tmp, file);
    }
    catch (err) {
        try {
            fs_1.default.unlinkSync(tmp);
        }
        catch { /* ignore cleanup error */ }
        throw err;
    }
}
function loadTaskGraph(graphMemory, fresh = false, embeddingFingerprint) {
    const graph = (0, task_types_1.createTaskGraph)();
    if (fresh)
        return graph;
    const file = path_1.default.join(graphMemory, 'tasks.json');
    const data = (0, graph_persistence_1.readJsonWithTmpFallback)(file);
    if (!data)
        return graph;
    try {
        const storedVersion = data.version;
        const stored = data.embeddingModel;
        const needsReEmbed = (storedVersion !== defaults_1.GRAPH_DATA_VERSION) ||
            (embeddingFingerprint != null && stored !== embeddingFingerprint);
        if (needsReEmbed && storedVersion !== defaults_1.GRAPH_DATA_VERSION) {
            log.warn({ storedVersion: storedVersion ?? 'none', currentVersion: defaults_1.GRAPH_DATA_VERSION }, 'Data version changed, preserving user data, clearing embeddings');
        }
        else if (needsReEmbed) {
            log.warn('Embedding config changed, preserving user data, clearing embeddings');
        }
        if (!(0, graph_persistence_1.validateGraphStructure)(data.graph)) {
            log.warn({ file }, 'Invalid graph structure, starting fresh');
            return graph;
        }
        (0, embedding_codec_1.decompressEmbeddings)(data.graph);
        graph.import(data.graph);
        if (needsReEmbed) {
            graph.forEachNode((id, attrs) => {
                if (!attrs.proxyFor)
                    graph.setNodeAttribute(id, 'embedding', []);
            });
            log.info({ nodes: graph.order }, 'Loaded graph (embeddings cleared for re-generation)');
        }
        else {
            log.info({ nodes: graph.order, edges: graph.size }, 'Loaded graph');
        }
    }
    catch (err) {
        log.error({ err }, 'Failed to load graph, starting fresh');
    }
    return graph;
}
// ---------------------------------------------------------------------------
// Bidirectional mirror helpers (Task ↔ Knowledge)
// ---------------------------------------------------------------------------
/**
 * Create a mirror proxy in KnowledgeGraph when a task links to a note.
 * Creates `@tasks::taskId` proxy node + edge proxy→noteId in KnowledgeGraph.
 */
function createMirrorInKnowledgeGraph(knowledgeGraph, taskId, noteId, kind, projectId) {
    const mirrorProxyId = projectId ? `@tasks::${projectId}::${taskId}` : `@tasks::${taskId}`;
    if (!knowledgeGraph.hasNode(mirrorProxyId)) {
        knowledgeGraph.addNode(mirrorProxyId, {
            title: '',
            content: '',
            tags: [],
            embedding: [],
            attachments: [],
            createdAt: 0,
            updatedAt: 0,
            version: 0,
            proxyFor: { graph: 'tasks', nodeId: taskId, ...(projectId ? { projectId } : {}) },
        });
    }
    if (!knowledgeGraph.hasNode(noteId))
        return;
    const edgeKey = `${mirrorProxyId}→${noteId}`;
    if (!knowledgeGraph.hasEdge(edgeKey)) {
        knowledgeGraph.addEdgeWithKey(edgeKey, mirrorProxyId, noteId, { kind });
    }
}
/**
 * Remove the mirror proxy edge/node from KnowledgeGraph when a task→knowledge relation is deleted.
 */
function deleteMirrorFromKnowledgeGraph(knowledgeGraph, taskId, noteId, projectId) {
    const candidates = projectId
        ? [`@tasks::${projectId}::${taskId}`, `@tasks::${taskId}`]
        : [`@tasks::${taskId}`];
    for (const mirrorProxyId of candidates) {
        const edgeKey = `${mirrorProxyId}→${noteId}`;
        if (knowledgeGraph.hasEdge(edgeKey)) {
            knowledgeGraph.dropEdge(edgeKey);
        }
        // Cleanup orphan proxy
        if (knowledgeGraph.hasNode(mirrorProxyId)) {
            const proxyFor = knowledgeGraph.getNodeAttribute(mirrorProxyId, 'proxyFor');
            if (proxyFor && knowledgeGraph.degree(mirrorProxyId) === 0) {
                knowledgeGraph.dropNode(mirrorProxyId);
            }
        }
    }
}
// ---------------------------------------------------------------------------
// TaskGraphManager — unified API for task graph operations
// ---------------------------------------------------------------------------
class TaskGraphManager {
    _graph;
    embedFns;
    ctx;
    ext;
    knowledgeGraph;
    mirrorTracker;
    _bm25Index;
    get projectDir() { return this.ctx.projectDir; }
    constructor(_graph, embedFns, ctx, ext = {}) {
        this._graph = _graph;
        this.embedFns = embedFns;
        this.ctx = ctx;
        this.ext = ext;
        this.knowledgeGraph = ext.knowledgeGraph;
        this._bm25Index = new bm25_1.BM25Index((attrs) => `${attrs.title} ${attrs.description} ${attrs.tags.join(' ')}`);
        // Migration: assign order to existing nodes that don't have it
        this._migrateOrderField();
        this._graph.forEachNode((id, attrs) => {
            if (!attrs.proxyFor)
                this._bm25Index.addDocument(id, attrs);
        });
    }
    /** One-time migration: assign order to nodes that lack it (pre-order-field data). */
    _migrateOrderField() {
        const byStatus = new Map();
        this._graph.forEachNode((id, attrs) => {
            if (attrs.proxyFor)
                return;
            if (attrs.order !== undefined && attrs.order !== null)
                return;
            let arr = byStatus.get(attrs.status);
            if (!arr) {
                arr = [];
                byStatus.set(attrs.status, arr);
            }
            arr.push({ id, priority: task_types_1.PRIORITY_ORDER[attrs.priority] ?? 3, dueDate: attrs.dueDate });
        });
        for (const [, nodes] of byStatus) {
            nodes.sort((a, b) => {
                const pDiff = a.priority - b.priority;
                if (pDiff !== 0)
                    return pDiff;
                if (a.dueDate === null && b.dueDate === null)
                    return 0;
                if (a.dueDate === null)
                    return 1;
                if (b.dueDate === null)
                    return -1;
                return a.dueDate - b.dueDate;
            });
            for (let i = 0; i < nodes.length; i++) {
                this._graph.setNodeAttribute(nodes[i].id, 'order', i * 1000);
            }
        }
    }
    get graph() { return this._graph; }
    get bm25Index() { return this._bm25Index; }
    rebuildBm25Index() {
        this._bm25Index.clear();
        this._graph.forEachNode((id, attrs) => {
            if (!attrs.proxyFor)
                this._bm25Index.addDocument(id, attrs);
        });
    }
    setMirrorTracker(tracker) {
        this.mirrorTracker = tracker;
    }
    /** Returns updatedAt for a node, or null if not found. Used by startup scan. */
    getNodeUpdatedAt(taskId) {
        if (!this._graph.hasNode(taskId))
            return null;
        if (isProxy(this._graph, taskId))
            return null;
        return this._graph.getNodeAttribute(taskId, 'updatedAt') ?? null;
    }
    get tasksDir() {
        const base = this.ctx.mirrorDir ?? this.ctx.projectDir;
        return base ? path_1.default.join(base, '.tasks') : undefined;
    }
    recordMirrorWrites(taskId) {
        const dir = this.tasksDir;
        if (!dir || !this.mirrorTracker)
            return;
        const entityDir = path_1.default.join(dir, taskId);
        this.mirrorTracker.recordWrite(path_1.default.join(entityDir, 'events.jsonl'));
        this.mirrorTracker.recordWrite(path_1.default.join(entityDir, 'task.md'));
        this.mirrorTracker.recordWrite(path_1.default.join(entityDir, 'description.md'));
    }
    // -- Write (mutations with embed + dirty + emit + cross-graph cleanup) --
    async createTask(title, description, status = 'backlog', priority = 'medium', tags = [], dueDate = null, estimate = null, assignee = null, order) {
        const embedding = await this.embedFns.document(`${title} ${description}`);
        const taskId = createTask(this._graph, title, description, status, priority, tags, embedding, dueDate, estimate, this.ctx.author, assignee, order);
        this._bm25Index.addDocument(taskId, this._graph.getNodeAttributes(taskId));
        this.ctx.markDirty();
        this.ctx.emit('task:created', { projectId: this.ctx.projectId, taskId });
        const dir = this.tasksDir;
        if (dir) {
            const attrs = this._graph.getNodeAttributes(taskId);
            (0, file_mirror_1.mirrorTaskCreate)(dir, taskId, attrs, []);
            this.recordMirrorWrites(taskId);
        }
        return taskId;
    }
    async updateTask(taskId, patch, expectedVersion) {
        const existing = getTask(this._graph, taskId);
        if (!existing)
            return false;
        const embedText = `${patch.title ?? existing.title} ${patch.description ?? existing.description}`;
        const embedding = await this.embedFns.document(embedText);
        updateTask(this._graph, taskId, patch, embedding, this.ctx.author, expectedVersion);
        this._bm25Index.updateDocument(taskId, this._graph.getNodeAttributes(taskId));
        this.ctx.markDirty();
        this.ctx.emit('task:updated', { projectId: this.ctx.projectId, taskId });
        const dir = this.tasksDir;
        if (dir) {
            const attrs = this._graph.getNodeAttributes(taskId);
            const relations = listTaskRelations(this._graph, taskId, this.ext);
            (0, file_mirror_1.mirrorTaskUpdate)(dir, taskId, { ...patch, by: this.ctx.author }, attrs, relations);
            this.recordMirrorWrites(taskId);
        }
        return true;
    }
    deleteTask(taskId) {
        if (this.tasksDir)
            (0, file_mirror_1.deleteMirrorDir)(this.tasksDir, taskId);
        this._bm25Index.removeDocument(taskId);
        const ok = deleteTask(this._graph, taskId);
        if (!ok)
            return false;
        // Clean up proxy in KnowledgeGraph if any note links to this task
        if (this.knowledgeGraph) {
            const toRemove = [];
            this.knowledgeGraph.forEachNode((id, attrs) => {
                if (attrs.proxyFor?.graph === 'tasks' && attrs.proxyFor.nodeId === taskId) {
                    toRemove.push(id);
                }
            });
            for (const id of toRemove)
                this.knowledgeGraph.dropNode(id);
        }
        this.ctx.markDirty();
        this.ctx.emit('task:deleted', { projectId: this.ctx.projectId, taskId });
        return true;
    }
    moveTask(taskId, status, expectedVersion, targetOrder) {
        const ok = moveTask(this._graph, taskId, status, expectedVersion, targetOrder);
        if (!ok)
            return false;
        this.ctx.markDirty();
        this.ctx.emit('task:moved', { projectId: this.ctx.projectId, taskId, status });
        const dir = this.tasksDir;
        if (dir) {
            const attrs = this._graph.getNodeAttributes(taskId);
            const relations = listTaskRelations(this._graph, taskId, this.ext);
            (0, file_mirror_1.mirrorTaskUpdate)(dir, taskId, { status, completedAt: attrs.completedAt, by: this.ctx.author }, attrs, relations);
            this.recordMirrorWrites(taskId);
        }
        return true;
    }
    reorderTask(taskId, order, status) {
        const ok = reorderTask(this._graph, taskId, order, status);
        if (!ok)
            return false;
        this.ctx.markDirty();
        this.ctx.emit('task:reordered', { projectId: this.ctx.projectId, taskId });
        const dir = this.tasksDir;
        if (dir) {
            const attrs = this._graph.getNodeAttributes(taskId);
            const relations = listTaskRelations(this._graph, taskId, this.ext);
            (0, file_mirror_1.mirrorTaskUpdate)(dir, taskId, { order, ...(status ? { status, completedAt: attrs.completedAt } : {}), by: this.ctx.author }, attrs, relations);
            this.recordMirrorWrites(taskId);
        }
        return true;
    }
    linkTasks(fromId, toId, kind) {
        const ok = createTaskRelation(this._graph, fromId, toId, kind);
        if (ok) {
            this.ctx.markDirty();
            this.ctx.emit('task:relation:added', { projectId: this.ctx.projectId, taskId: fromId, toId, kind });
            const dir = this.tasksDir;
            if (dir) {
                const fromAttrs = this._graph.getNodeAttributes(fromId);
                const fromRels = listTaskRelations(this._graph, fromId, this.ext);
                (0, file_mirror_1.mirrorTaskRelation)(dir, fromId, 'add', kind, toId, fromAttrs, fromRels);
                this.recordMirrorWrites(fromId);
            }
        }
        return ok;
    }
    createCrossLink(taskId, targetId, targetGraph, kind, projectId) {
        const pid = projectId || this.ctx.projectId;
        const extGraph = (0, manager_types_1.resolveExternalGraph)(this.ext, targetGraph, pid);
        const ok = createCrossRelation(this._graph, taskId, targetGraph, targetId, kind, extGraph, pid);
        // Bidirectional: create mirror proxy in KnowledgeGraph
        if (ok && targetGraph === 'knowledge' && this.knowledgeGraph) {
            createMirrorInKnowledgeGraph(this.knowledgeGraph, taskId, targetId, kind, pid);
        }
        if (ok) {
            this.ctx.markDirty();
            this.ctx.emit('task:relation:added', { projectId: this.ctx.projectId, taskId, toId: targetId, kind, targetGraph });
            const dir = this.tasksDir;
            if (dir) {
                const attrs = this._graph.getNodeAttributes(taskId);
                const relations = listTaskRelations(this._graph, taskId, this.ext);
                (0, file_mirror_1.mirrorTaskRelation)(dir, taskId, 'add', kind, targetId, attrs, relations, targetGraph);
                this.recordMirrorWrites(taskId);
            }
        }
        return ok;
    }
    deleteCrossLink(taskId, targetId, targetGraph, projectId) {
        const pid = projectId || this.ctx.projectId;
        // Read edge kind before deleting — check both directions
        let kind = '';
        try {
            for (const tid of [targetId, taskId]) {
                const pnId = proxyId(targetGraph, tid, pid);
                const other = tid === targetId ? taskId : targetId;
                if (this._graph.hasEdge(other, pnId)) {
                    const ek = this._graph.edge(other, pnId);
                    if (ek) {
                        kind = this._graph.getEdgeAttribute(ek, 'kind') ?? '';
                        break;
                    }
                }
                if (this._graph.hasEdge(pnId, other)) {
                    const ek = this._graph.edge(pnId, other);
                    if (ek) {
                        kind = this._graph.getEdgeAttribute(ek, 'kind') ?? '';
                        break;
                    }
                }
            }
        }
        catch { /* ignore */ }
        const ok = deleteCrossRelation(this._graph, taskId, targetGraph, targetId, pid);
        if (ok && targetGraph === 'knowledge' && this.knowledgeGraph) {
            // Remove mirror/original edge from KnowledgeGraph in both directions.
            // Case 1: task created the link → mirror is @tasks::taskId → noteId
            deleteMirrorFromKnowledgeGraph(this.knowledgeGraph, taskId, targetId, pid);
            // Case 2: note created the link → original is noteId → @tasks::taskId (or targetId → @tasks::taskId)
            // Try both ID interpretations since resolveEntry may have swapped them
            for (const [noteCandidate, taskCandidate] of [[targetId, taskId], [taskId, targetId]]) {
                const proxyCandidates = pid
                    ? [`@tasks::${pid}::${taskCandidate}`, `@tasks::${taskCandidate}`]
                    : [`@tasks::${taskCandidate}`];
                for (const taskProxy of proxyCandidates) {
                    if (this.knowledgeGraph.hasNode(taskProxy)) {
                        // noteId → @tasks::taskId
                        if (this.knowledgeGraph.hasEdge(noteCandidate, taskProxy)) {
                            this.knowledgeGraph.dropEdge(noteCandidate, taskProxy);
                            if (this.knowledgeGraph.degree(taskProxy) === 0) {
                                this.knowledgeGraph.dropNode(taskProxy);
                            }
                        }
                        // @tasks::taskId → noteId (mirror direction)
                        if (this.knowledgeGraph.hasEdge(taskProxy, noteCandidate)) {
                            this.knowledgeGraph.dropEdge(taskProxy, noteCandidate);
                            if (this.knowledgeGraph.degree(taskProxy) === 0) {
                                this.knowledgeGraph.dropNode(taskProxy);
                            }
                        }
                    }
                }
            }
        }
        if (ok) {
            this.ctx.markDirty();
            this.ctx.emit('task:relation:deleted', { projectId: this.ctx.projectId, taskId, toId: targetId, kind, targetGraph });
            const dir = this.tasksDir;
            if (dir) {
                // taskId might actually be a noteId for incoming mirrors — use the real task node
                const realTaskId = this._graph.hasNode(taskId) && !isProxy(this._graph, taskId) ? taskId : targetId;
                if (this._graph.hasNode(realTaskId) && !isProxy(this._graph, realTaskId)) {
                    const attrs = this._graph.getNodeAttributes(realTaskId);
                    const relations = listTaskRelations(this._graph, realTaskId, this.ext);
                    (0, file_mirror_1.mirrorTaskRelation)(dir, realTaskId, 'remove', kind, targetId, attrs, relations, targetGraph);
                    this.recordMirrorWrites(realTaskId);
                }
            }
        }
        return ok;
    }
    deleteTaskLink(fromId, toId) {
        // Read edge kind before deleting
        let kind = '';
        try {
            if (this._graph.hasEdge(fromId, toId)) {
                const ek = this._graph.edge(fromId, toId);
                if (ek)
                    kind = this._graph.getEdgeAttribute(ek, 'kind') ?? '';
            }
        }
        catch { /* ignore */ }
        const ok = deleteTaskRelation(this._graph, fromId, toId);
        if (ok) {
            this.ctx.markDirty();
            this.ctx.emit('task:relation:deleted', { projectId: this.ctx.projectId, taskId: fromId, toId, kind });
            const dir = this.tasksDir;
            if (dir) {
                const fromAttrs = this._graph.getNodeAttributes(fromId);
                const fromRels = listTaskRelations(this._graph, fromId, this.ext);
                (0, file_mirror_1.mirrorTaskRelation)(dir, fromId, 'remove', kind, toId, fromAttrs, fromRels);
                this.recordMirrorWrites(fromId);
            }
        }
        return ok;
    }
    // -- Attachments --
    addAttachment(taskId, filename, data) {
        const dir = this.tasksDir;
        if (!dir)
            return null;
        if (!this._graph.hasNode(taskId) || isProxy(this._graph, taskId))
            return null;
        if (data.length > attachment_types_1.MAX_ATTACHMENT_SIZE)
            return null;
        const entityDir = path_1.default.join(dir, taskId);
        const existing = (0, attachment_types_1.scanAttachments)(entityDir);
        if (existing.length >= attachment_types_1.MAX_ATTACHMENTS_PER_ENTITY)
            return null;
        const safe = (0, file_mirror_1.sanitizeFilename)(filename);
        if (!safe)
            return null;
        (0, file_mirror_1.writeAttachment)(dir, taskId, safe, data);
        this.mirrorTracker?.recordWrite(path_1.default.join(entityDir, 'attachments', safe));
        (0, file_mirror_1.mirrorAttachmentEvent)(entityDir, 'add', safe);
        this.mirrorTracker?.recordWrite(path_1.default.join(entityDir, 'events.jsonl'));
        const attachments = (0, attachment_types_1.scanAttachments)(entityDir);
        this._graph.setNodeAttribute(taskId, 'attachments', attachments);
        this._graph.setNodeAttribute(taskId, 'updatedAt', Date.now());
        this.ctx.markDirty();
        this.ctx.emit('task:attachment:added', { projectId: this.ctx.projectId, taskId, filename: safe });
        return attachments.find(a => a.filename === safe) ?? null;
    }
    removeAttachment(taskId, filename) {
        const dir = this.tasksDir;
        if (!dir)
            return false;
        if (!this._graph.hasNode(taskId) || isProxy(this._graph, taskId))
            return false;
        const safe = (0, file_mirror_1.sanitizeFilename)(filename);
        const entityDir = path_1.default.join(dir, taskId);
        const deleted = (0, file_mirror_1.deleteAttachment)(dir, taskId, safe);
        if (!deleted)
            return false;
        this.mirrorTracker?.recordWrite(path_1.default.join(entityDir, 'attachments', safe));
        (0, file_mirror_1.mirrorAttachmentEvent)(entityDir, 'remove', safe);
        this.mirrorTracker?.recordWrite(path_1.default.join(entityDir, 'events.jsonl'));
        const attachments = (0, attachment_types_1.scanAttachments)(entityDir);
        this._graph.setNodeAttribute(taskId, 'attachments', attachments);
        this._graph.setNodeAttribute(taskId, 'updatedAt', Date.now());
        this.ctx.markDirty();
        this.ctx.emit('task:attachment:deleted', { projectId: this.ctx.projectId, taskId, filename: safe });
        return true;
    }
    syncAttachments(taskId) {
        const dir = this.tasksDir;
        if (!dir)
            return;
        if (!this._graph.hasNode(taskId) || isProxy(this._graph, taskId))
            return;
        const attachments = (0, attachment_types_1.scanAttachments)(path_1.default.join(dir, taskId));
        this._graph.setNodeAttribute(taskId, 'attachments', attachments);
        this._graph.setNodeAttribute(taskId, 'updatedAt', Date.now());
        this._graph.setNodeAttribute(taskId, 'version', (this._graph.getNodeAttribute(taskId, 'version') ?? 0) + 1);
        this.ctx.markDirty();
    }
    listAttachments(taskId) {
        if (!this._graph.hasNode(taskId) || isProxy(this._graph, taskId))
            return [];
        return this._graph.getNodeAttribute(taskId, 'attachments') ?? [];
    }
    getAttachmentPath(taskId, filename) {
        const dir = this.tasksDir;
        if (!dir)
            return null;
        return (0, file_mirror_1.getAttachmentPath)(dir, taskId, filename);
    }
    // -- Import from file (reverse mirror — does NOT write back to file) --
    async importFromFile(parsed) {
        const exists = this._graph.hasNode(parsed.id) && !isProxy(this._graph, parsed.id);
        const embedding = await this.embedFns.document(`${parsed.title} ${parsed.description}`);
        const now = Date.now();
        if (exists) {
            const existing = this._graph.getNodeAttributes(parsed.id);
            this._graph.mergeNodeAttributes(parsed.id, {
                title: parsed.title,
                description: parsed.description,
                status: parsed.status,
                priority: parsed.priority,
                tags: parsed.tags,
                order: parsed.order ?? existing.order,
                dueDate: parsed.dueDate,
                estimate: parsed.estimate,
                completedAt: parsed.completedAt,
                assignee: parsed.assignee ?? existing.assignee,
                embedding,
                attachments: parsed.attachments,
                updatedAt: now,
                createdAt: existing.createdAt,
                version: parsed.version ?? existing.version + 1,
                ...(parsed.createdBy != null ? { createdBy: parsed.createdBy } : {}),
                ...(parsed.updatedBy != null ? { updatedBy: parsed.updatedBy } : {}),
            });
        }
        else {
            this._graph.addNode(parsed.id, {
                title: parsed.title,
                description: parsed.description,
                status: parsed.status,
                priority: parsed.priority,
                tags: parsed.tags,
                order: parsed.order ?? nextOrderForStatus(this._graph, parsed.status),
                dueDate: parsed.dueDate,
                estimate: parsed.estimate,
                completedAt: parsed.completedAt,
                assignee: parsed.assignee ?? null,
                embedding,
                attachments: parsed.attachments ?? [],
                createdAt: parsed.createdAt ?? now,
                updatedAt: now,
                version: parsed.version ?? 1,
                createdBy: parsed.createdBy ?? undefined,
                updatedBy: parsed.updatedBy ?? undefined,
            });
        }
        this._bm25Index.updateDocument(parsed.id, this._graph.getNodeAttributes(parsed.id));
        this.syncRelationsFromFile(parsed.id, parsed.relations);
        this.ctx.markDirty();
        this.ctx.emit(exists ? 'task:updated' : 'task:created', { projectId: this.ctx.projectId, taskId: parsed.id });
    }
    updateDescriptionFromFile(taskId, description) {
        if (!this._graph.hasNode(taskId) || isProxy(this._graph, taskId))
            return;
        this._graph.setNodeAttribute(taskId, 'description', description);
        this._graph.setNodeAttribute(taskId, 'updatedAt', Date.now());
        this._graph.setNodeAttribute(taskId, 'version', (this._graph.getNodeAttribute(taskId, 'version') ?? 0) + 1);
        this.ctx.markDirty();
        this.ctx.emit('task:updated', { projectId: this.ctx.projectId, taskId });
    }
    deleteFromFile(taskId) {
        if (!this._graph.hasNode(taskId))
            return;
        if (isProxy(this._graph, taskId))
            return;
        this._bm25Index.removeDocument(taskId);
        deleteTask(this._graph, taskId);
        if (this.knowledgeGraph) {
            const pId = `@tasks::${taskId}`;
            if (this.knowledgeGraph.hasNode(pId))
                this.knowledgeGraph.dropNode(pId);
        }
        this.ctx.markDirty();
        this.ctx.emit('task:deleted', { projectId: this.ctx.projectId, taskId });
    }
    syncRelationsFromFile(taskId, desired) {
        const current = [];
        this._graph.forEachOutEdge(taskId, (_edge, attrs, _src, target) => {
            const proxy = this._graph.hasNode(target) ? this._graph.getNodeAttribute(target, 'proxyFor') : undefined;
            if (proxy) {
                current.push({ to: proxy.nodeId, kind: attrs.kind, graph: proxy.graph });
            }
            else {
                current.push({ to: target, kind: attrs.kind });
            }
        });
        const diff = (0, file_import_1.diffRelations)(current, desired);
        for (const rel of diff.toRemove) {
            if (rel.graph) {
                deleteCrossRelation(this._graph, taskId, rel.graph, rel.to);
                if (rel.graph === 'knowledge' && this.knowledgeGraph) {
                    deleteMirrorFromKnowledgeGraph(this.knowledgeGraph, taskId, rel.to);
                }
            }
            else {
                deleteTaskRelation(this._graph, taskId, rel.to);
            }
        }
        for (const rel of diff.toAdd) {
            if (rel.graph) {
                const extGraph = (0, manager_types_1.resolveExternalGraph)(this.ext, rel.graph);
                createCrossRelation(this._graph, taskId, rel.graph, rel.to, rel.kind, extGraph);
                if (rel.graph === 'knowledge' && this.knowledgeGraph) {
                    createMirrorInKnowledgeGraph(this.knowledgeGraph, taskId, rel.to, rel.kind);
                }
            }
            else {
                createTaskRelation(this._graph, taskId, rel.to, rel.kind);
            }
        }
    }
    // -- Read --
    getTask(taskId) {
        return getTask(this._graph, taskId);
    }
    listTasks(opts) {
        return listTasks(this._graph, opts);
    }
    async searchTasks(query, opts) {
        const embedding = opts?.searchMode === 'keyword' ? [] : await this.embedFns.query(query);
        return (0, tasks_1.searchTasks)(this._graph, embedding, { ...opts, queryText: query, bm25Index: this._bm25Index });
    }
    listRelations(taskId) {
        return listTaskRelations(this._graph, taskId, this.ext);
    }
    findLinkedTasks(targetGraph, targetNodeId, kind, projectId) {
        return findLinkedTasks(this._graph, targetGraph, targetNodeId, kind, projectId || this.ctx.projectId);
    }
    // -- Epic operations --
    get epicsDir() {
        const base = this.ctx.mirrorDir ?? this.ctx.projectDir;
        return base ? path_1.default.join(base, '.epics') : undefined;
    }
    recordEpicMirrorWrites(epicId) {
        const dir = this.epicsDir;
        if (!dir || !this.mirrorTracker)
            return;
        const entityDir = path_1.default.join(dir, epicId);
        this.mirrorTracker.recordWrite(path_1.default.join(entityDir, 'events.jsonl'));
        this.mirrorTracker.recordWrite(path_1.default.join(entityDir, 'task.md'));
        this.mirrorTracker.recordWrite(path_1.default.join(entityDir, 'description.md'));
    }
    async createEpic(title, description, status = 'open', priority = 'medium', tags = []) {
        const embedding = await this.embedFns.document(`${title}\n${description}`);
        const epicId = createEpic(this._graph, title, description, status, priority, tags, embedding, this.ctx.author);
        this._bm25Index.addDocument(epicId, this._graph.getNodeAttributes(epicId));
        this.ctx.markDirty();
        this.ctx.emit('epic:created', { projectId: this.ctx.projectId, epicId, title, status });
        const dir = this.epicsDir;
        if (dir) {
            (0, file_mirror_1.mirrorTaskCreate)(dir, epicId, this._graph.getNodeAttributes(epicId), []);
            this.recordEpicMirrorWrites(epicId);
        }
        return epicId;
    }
    async updateEpic(epicId, patch, newStatus, expectedVersion) {
        const ok = updateEpic(this._graph, epicId, patch, newStatus, expectedVersion, this.ctx.author);
        if (!ok)
            return false;
        if (patch.title !== undefined || patch.description !== undefined || patch.tags !== undefined) {
            const attrs = this._graph.getNodeAttributes(epicId);
            this._bm25Index.updateDocument(epicId, attrs);
            const embedding = await this.embedFns.document(`${attrs.title}\n${attrs.description}`);
            this._graph.setNodeAttribute(epicId, 'embedding', embedding);
        }
        this.ctx.markDirty();
        this.ctx.emit('epic:updated', { projectId: this.ctx.projectId, epicId });
        const dir = this.epicsDir;
        if (dir) {
            const attrs = this._graph.getNodeAttributes(epicId);
            const relations = listTaskRelations(this._graph, epicId, this.ext);
            (0, file_mirror_1.mirrorTaskUpdate)(dir, epicId, { ...patch, ...(newStatus ? { status: newStatus } : {}), by: this.ctx.author }, attrs, relations);
            this.recordEpicMirrorWrites(epicId);
        }
        return true;
    }
    deleteEpic(epicId) {
        const dir = this.epicsDir;
        if (dir)
            (0, file_mirror_1.deleteMirrorDir)(dir, epicId);
        this._bm25Index.removeDocument(epicId);
        const ok = deleteEpic(this._graph, epicId);
        if (!ok)
            return false;
        this.ctx.markDirty();
        this.ctx.emit('epic:deleted', { projectId: this.ctx.projectId, epicId });
        return true;
    }
    getEpic(epicId) {
        return getEpic(this._graph, epicId);
    }
    listEpics(opts) {
        return listEpics(this._graph, opts);
    }
    async searchEpics(query, opts) {
        const embedding = opts?.searchMode === 'keyword' ? [] : await this.embedFns.query(query);
        const results = (0, tasks_1.searchTasks)(this._graph, embedding, { ...opts, queryText: query, bm25Index: this._bm25Index });
        return results.filter(r => this._graph.hasNode(r.id) && this._graph.getNodeAttribute(r.id, 'nodeType') === 'epic');
    }
    linkTaskToEpic(taskId, epicId) {
        const ok = linkTaskToEpic(this._graph, taskId, epicId);
        if (!ok)
            return false;
        this.ctx.markDirty();
        this.ctx.emit('epic:linked', { projectId: this.ctx.projectId, taskId, epicId });
        // Update task mirror so file watcher doesn't remove the edge from stale file
        const dir = this.tasksDir;
        if (dir && this._graph.hasNode(taskId) && !isProxy(this._graph, taskId)) {
            const attrs = this._graph.getNodeAttributes(taskId);
            const relations = listTaskRelations(this._graph, taskId, this.ext);
            (0, file_mirror_1.mirrorTaskUpdate)(dir, taskId, { by: this.ctx.author }, attrs, relations);
            this.recordMirrorWrites(taskId);
        }
        return true;
    }
    unlinkTaskFromEpic(taskId, epicId) {
        const ok = unlinkTaskFromEpic(this._graph, taskId, epicId);
        if (!ok)
            return false;
        this.ctx.markDirty();
        this.ctx.emit('epic:unlinked', { projectId: this.ctx.projectId, taskId, epicId });
        // Update task mirror so file watcher doesn't re-add the edge from stale file
        const dir = this.tasksDir;
        if (dir && this._graph.hasNode(taskId) && !isProxy(this._graph, taskId)) {
            const attrs = this._graph.getNodeAttributes(taskId);
            const relations = listTaskRelations(this._graph, taskId, this.ext);
            (0, file_mirror_1.mirrorTaskUpdate)(dir, taskId, { by: this.ctx.author }, attrs, relations);
            this.recordMirrorWrites(taskId);
        }
        return true;
    }
    listEpicTasks(epicId) {
        return listEpicTasks(this._graph, epicId);
    }
}
exports.TaskGraphManager = TaskGraphManager;
