/**
 * Workspace manifest CRUD for OpenWriter v2.
 * Unified container model: containers hold docs in an ordered tree.
 * Manifests live in ~/.openwriter/_workspaces/*.json.
 */
import { existsSync, readFileSync, writeFileSync, readdirSync } from 'fs';
import { join, resolve, isAbsolute, sep } from 'path';
import { randomUUID } from 'crypto';
import matter from 'gray-matter';
import trash from 'trash';
import { getWorkspacesDir, ensureWorkspacesDir, sanitizeFilename, resolveDocPath, isExternalDoc } from './helpers.js';
import { markdownToTiptap, tiptapToMarkdown } from './markdown.js';
import { resolveListingTitle } from './title-resolve.js';
function getOrderFile() { return join(getWorkspacesDir(), '_order.json'); }
import { isV1, migrateV1toV2 } from './workspace-types.js';
import { addDocToContainer, addContainer as addContainerToTree, removeNode, moveNode, reorderNode, findContainer, collectAllFiles, countDocs, findDocNode } from './workspace-tree.js';
// ============================================================================
// INTERNAL HELPERS
// ============================================================================
/**
 * Resolve a workspace manifest filename to an absolute path that is GUARANTEED
 * to live inside the active profile's `_workspaces/` directory.
 *
 * The `filename` originates from MCP tool args (`wsFile`, `filename`), so it is
 * untrusted. Without this guard a value like `../../OtherProfile/_workspaces/x.json`
 * or an absolute path would escape the active profile and read/write/delete
 * another profile's manifests (and, via the doc files they reference, another
 * profile's documents). Profile scoping in OpenWriter is enforced by anchoring
 * every manifest path under `getWorkspacesDir()` (which encodes the active
 * profile) — so containment IS profile scoping. Escaping containment is the
 * only way to cross profiles, and this resolver makes that impossible.
 *
 * Rules: no separators, no `..`, not absolute, no null byte, must end in
 * `.json`, never the reserved `_order.json`. Then a `path.resolve` +
 * prefix-containment assert is the authoritative backstop.
 *
 * adr: (MCP-7 — workspace path traversal + profile scoping)
 */
function workspacePath(filename) {
    if (!filename || typeof filename !== 'string') {
        throw new Error('Invalid workspace identifier');
    }
    if (filename.includes('\0') ||
        filename.includes('/') ||
        filename.includes('\\') ||
        filename.includes('..') ||
        isAbsolute(filename) ||
        !filename.endsWith('.json') ||
        filename === '_order.json') {
        throw new Error('Invalid workspace identifier');
    }
    const baseDir = resolve(getWorkspacesDir());
    const resolved = resolve(baseDir, filename);
    if (resolved !== baseDir && !resolved.startsWith(baseDir + sep)) {
        throw new Error('Invalid workspace identifier');
    }
    return resolved;
}
/**
 * Migrate workspace-level tags into document frontmatter.
 * Old format: workspace.tags = { "tag1": ["file1.md", "file2.md"], ... }
 * New format: each doc file has `tags: ["tag1", ...]` in its frontmatter.
 * Returns true if migration occurred and the workspace was modified.
 */
function migrateWorkspaceTags(ws) {
    if (!ws.tags || typeof ws.tags !== 'object')
        return false;
    const tagMap = ws.tags;
    const entries = Object.entries(tagMap);
    if (entries.length === 0) {
        delete ws.tags;
        return true;
    }
    for (const [tagName, files] of entries) {
        for (const file of files) {
            try {
                const targetPath = resolveDocPath(file);
                if (!existsSync(targetPath))
                    continue;
                const raw = readFileSync(targetPath, 'utf-8');
                const parsed = markdownToTiptap(raw);
                const tags = Array.isArray(parsed.metadata.tags) ? [...parsed.metadata.tags] : [];
                if (!tags.includes(tagName)) {
                    tags.push(tagName);
                    parsed.metadata.tags = tags;
                    const markdown = tiptapToMarkdown(parsed.document, parsed.title, parsed.metadata);
                    writeFileSync(targetPath, markdown, 'utf-8');
                }
            }
            catch { /* best-effort */ }
        }
    }
    delete ws.tags;
    return true;
}
function readWorkspace(filename) {
    const raw = readFileSync(workspacePath(filename), 'utf-8');
    let parsed = JSON.parse(raw);
    if (isV1(parsed)) {
        parsed = migrateV1toV2(parsed);
        writeWorkspace(filename, parsed);
        return parsed;
    }
    // Migrate workspace-level tags to doc frontmatter
    if (migrateWorkspaceTags(parsed)) {
        writeWorkspace(filename, parsed);
    }
    return parsed;
}
function writeWorkspace(filename, workspace) {
    writeFileSync(workspacePath(filename), JSON.stringify(workspace, null, 2), 'utf-8');
}
function readOrder() {
    try {
        if (!existsSync(getOrderFile()))
            return [];
        return JSON.parse(readFileSync(getOrderFile(), 'utf-8'));
    }
    catch {
        return [];
    }
}
function writeOrder(order) {
    writeFileSync(getOrderFile(), JSON.stringify(order, null, 2), 'utf-8');
}
function readDocFrontmatter(filename) {
    try {
        const filePath = resolveDocPath(filename);
        if (!existsSync(filePath))
            return null;
        const raw = readFileSync(filePath, 'utf-8');
        const { data } = matter(raw);
        return data;
    }
    catch {
        return null;
    }
}
// ============================================================================
// CRUD
// ============================================================================
export function listWorkspaces() {
    ensureWorkspacesDir();
    const files = readdirSync(getWorkspacesDir()).filter((f) => f.endsWith('.json') && f !== '_order.json');
    const infos = files.map((f) => {
        try {
            const ws = readWorkspace(f);
            return { filename: f, title: ws.title, docCount: countDocs(ws.root) };
        }
        catch {
            return null;
        }
    }).filter((b) => b !== null);
    // Sort by persisted order; unknown workspaces append at end
    const order = readOrder();
    if (order.length === 0)
        return infos;
    const orderIndex = new Map(order.map((f, i) => [f, i]));
    infos.sort((a, b) => {
        const ai = orderIndex.get(a.filename) ?? Infinity;
        const bi = orderIndex.get(b.filename) ?? Infinity;
        return ai - bi;
    });
    return infos;
}
export function getWorkspace(filename) {
    ensureWorkspacesDir();
    const p = workspacePath(filename);
    if (!existsSync(p))
        throw new Error(`Workspace not found: ${filename}`);
    return readWorkspace(filename);
}
export function createWorkspace(options) {
    ensureWorkspacesDir();
    const { title, voiceProfileId = null } = options;
    const slug = sanitizeFilename(title).toLowerCase().replace(/\s+/g, '-');
    const filename = `${slug}-${randomUUID().slice(0, 8)}.json`;
    const workspace = { version: 2, title, voiceProfileId, root: [] };
    writeWorkspace(filename, workspace);
    // Append to order
    const order = readOrder();
    order.push(filename);
    writeOrder(order);
    return { filename, title, docCount: 0 };
}
export async function deleteWorkspace(filename) {
    ensureWorkspacesDir();
    const p = workspacePath(filename);
    if (!existsSync(p))
        throw new Error(`Workspace not found: ${filename}`);
    const ws = readWorkspace(filename);
    const files = collectAllFiles(ws.root);
    const deletedFiles = [];
    const skippedExternal = [];
    for (const file of files) {
        if (isExternalDoc(file)) {
            skippedExternal.push(file);
            continue;
        }
        const filePath = resolveDocPath(file);
        if (existsSync(filePath)) {
            await trash(filePath);
            deletedFiles.push(file);
        }
    }
    await trash(p);
    // Remove from order
    const order = readOrder();
    const idx = order.indexOf(filename);
    if (idx >= 0) {
        order.splice(idx, 1);
        writeOrder(order);
    }
    return { deletedFiles, skippedExternal };
}
export function reorderWorkspaces(orderedFilenames) {
    ensureWorkspacesDir();
    writeOrder(orderedFilenames);
}
// ============================================================================
// DOC OPERATIONS
// ============================================================================
export function addDoc(wsFile, containerId, file, title, afterFile) {
    const ws = getWorkspace(wsFile);
    addDocToContainer(ws.root, containerId, file, title, afterFile);
    writeWorkspace(wsFile, ws);
    return ws;
}
export function removeDoc(wsFile, file) {
    const ws = getWorkspace(wsFile);
    removeNode(ws.root, file);
    writeWorkspace(wsFile, ws);
    return ws;
}
export function moveDoc(wsFile, file, targetContainerId, afterFile) {
    const ws = getWorkspace(wsFile);
    moveNode(ws.root, file, targetContainerId, afterFile);
    writeWorkspace(wsFile, ws);
    return ws;
}
export function reorderDoc(wsFile, file, afterFile) {
    const ws = getWorkspace(wsFile);
    reorderNode(ws.root, file, afterFile);
    writeWorkspace(wsFile, ws);
    return ws;
}
// ============================================================================
// CONTAINER OPERATIONS
// ============================================================================
export function addContainerToWorkspace(wsFile, parentContainerId, name, afterIdentifier) {
    const ws = getWorkspace(wsFile);
    const container = addContainerToTree(ws.root, parentContainerId, name, afterIdentifier ?? null);
    writeWorkspace(wsFile, ws);
    return { workspace: ws, containerId: container.id };
}
export function removeContainer(wsFile, containerId) {
    const ws = getWorkspace(wsFile);
    const found = findContainer(ws.root, containerId);
    if (!found)
        throw new Error(`Container "${containerId}" not found`);
    removeNode(ws.root, containerId);
    writeWorkspace(wsFile, ws);
    return ws;
}
export function renameContainer(wsFile, containerId, name) {
    const ws = getWorkspace(wsFile);
    const found = findContainer(ws.root, containerId);
    if (!found)
        throw new Error(`Container "${containerId}" not found`);
    found.node.name = name;
    writeWorkspace(wsFile, ws);
    return ws;
}
export function renameWorkspace(wsFile, newTitle) {
    const ws = getWorkspace(wsFile);
    ws.title = newTitle;
    writeWorkspace(wsFile, ws);
    return ws;
}
export function reorderContainer(wsFile, containerId, afterIdentifier) {
    const ws = getWorkspace(wsFile);
    reorderNode(ws.root, containerId, afterIdentifier);
    writeWorkspace(wsFile, ws);
    return ws;
}
export function moveContainer(wsFile, containerId, targetContainerId, afterIdentifier) {
    const ws = getWorkspace(wsFile);
    moveNode(ws.root, containerId, targetContainerId, afterIdentifier);
    writeWorkspace(wsFile, ws);
    return ws;
}
export function crossMoveContainer(sourceWsFile, targetWsFile, containerId, targetContainerId, afterIdentifier) {
    const sourceWs = getWorkspace(sourceWsFile);
    const removed = removeNode(sourceWs.root, containerId);
    if (removed.type !== 'container')
        throw new Error(`Node "${containerId}" is not a container`);
    writeWorkspace(sourceWsFile, sourceWs);
    const targetWs = getWorkspace(targetWsFile);
    // Insert into target — reuse moveNode-style logic
    const target = targetContainerId === null
        ? targetWs.root
        : (() => { const f = findContainer(targetWs.root, targetContainerId); if (!f)
            throw new Error('Target container not found'); return f.node.items; })();
    if (afterIdentifier === null) {
        target.unshift(removed);
    }
    else {
        const afterIdx = target.findIndex((n) => (n.type === 'doc' && n.file === afterIdentifier) || (n.type === 'container' && n.id === afterIdentifier));
        if (afterIdx === -1)
            target.push(removed);
        else
            target.splice(afterIdx + 1, 0, removed);
    }
    writeWorkspace(targetWsFile, targetWs);
    return targetWs;
}
export function promoteContainerToWorkspace(sourceWsFile, containerId, afterWorkspaceFilename) {
    const sourceWs = getWorkspace(sourceWsFile);
    const removed = removeNode(sourceWs.root, containerId);
    if (removed.type !== 'container')
        throw new Error(`Node "${containerId}" is not a container`);
    writeWorkspace(sourceWsFile, sourceWs);
    // Create new workspace with container's children as root
    const slug = sanitizeFilename(removed.name).toLowerCase().replace(/\s+/g, '-');
    const filename = `${slug}-${randomUUID().slice(0, 8)}.json`;
    const workspace = { version: 2, title: removed.name, root: removed.items };
    writeWorkspace(filename, workspace);
    // Append to order then reposition
    const order = readOrder();
    order.push(filename);
    writeOrder(order);
    if (afterWorkspaceFilename !== null || order.length > 1) {
        reorderWorkspaceAfter(filename, afterWorkspaceFilename);
    }
    return { filename, title: removed.name, docCount: countDocs(removed.items) };
}
export function reorderWorkspaceAfter(filename, afterFilename) {
    ensureWorkspacesDir();
    const order = readOrder();
    // Ensure all current workspace files are in the order array
    const files = readdirSync(getWorkspacesDir()).filter(f => f.endsWith('.json') && f !== '_order.json');
    for (const f of files) {
        if (!order.includes(f))
            order.push(f);
    }
    // Remove target
    const idx = order.indexOf(filename);
    if (idx === -1)
        throw new Error(`Workspace "${filename}" not found in order`);
    order.splice(idx, 1);
    // Insert
    if (afterFilename === null) {
        order.unshift(filename);
    }
    else {
        const afterIdx = order.indexOf(afterFilename);
        if (afterIdx === -1) {
            order.push(filename);
        }
        else {
            order.splice(afterIdx + 1, 0, filename);
        }
    }
    writeOrder(order);
}
const WRITING_CONTEXT_KEYS = new Set(['characters', 'settings', 'rules']);
// Workspace top-level config fields (enrichment + sort). Copied onto the
// workspace root by updateWorkspaceContext; `null` clears the field.
const WORKSPACE_CONFIG_FIELDS = new Set([
    'logline', 'domain', 'schema', 'vocab', 'relatedWorkspaces',
    'enrichmentVolumeThreshold', 'enrichmentDriftThreshold', 'enrichmentDisabled',
    'autoSortDisabled',
]);
export function updateWorkspaceContext(wsFile, update) {
    const ws = getWorkspace(wsFile);
    // Writing context (characters/settings/rules) merge into ws.context.
    const ctxUpdate = {};
    for (const key of WRITING_CONTEXT_KEYS) {
        if (key in update)
            ctxUpdate[key] = update[key];
    }
    if (Object.keys(ctxUpdate).length > 0) {
        ws.context = { ...ws.context, ...ctxUpdate };
    }
    // Config fields (enrichment + sort) set on the workspace top-level. `null` clears.
    for (const key of WORKSPACE_CONFIG_FIELDS) {
        if (!(key in update))
            continue;
        const value = update[key];
        if (value === null) {
            delete ws[key];
        }
        else {
            ws[key] = value;
        }
    }
    writeWorkspace(wsFile, ws);
    return ws;
}
export function getItemContext(wsFile, docFile) {
    const ws = getWorkspace(wsFile);
    const found = findDocNode(ws.root, docFile);
    if (!found)
        throw new Error(`Document "${docFile}" not found in workspace`);
    // Read tags from document frontmatter (not workspace manifest)
    const fm = readDocFrontmatter(docFile);
    const tags = Array.isArray(fm?.tags) ? fm.tags : [];
    return {
        workspaceTitle: ws.title,
        workspaceContext: ws.context || {},
        tags,
    };
}
// ============================================================================
// FIND-OR-CREATE HELPERS
// ============================================================================
/** Find an existing workspace by title (case-insensitive). Returns null if not found. */
export function findWorkspaceByTitle(title) {
    const all = listWorkspaces();
    const lower = title.toLowerCase();
    return all.find((w) => w.title.toLowerCase() === lower) || null;
}
/** Find a container by name in a workspace. Returns its ID, or null if not found. */
export function findContainerByName(wsFile, name) {
    const ws = getWorkspace(wsFile);
    const lower = name.toLowerCase();
    function scan(nodes) {
        for (const n of nodes) {
            if (n.type === 'container') {
                if (n.name.toLowerCase() === lower)
                    return n.id;
                const found = scan(n.items);
                if (found)
                    return found;
            }
        }
        return null;
    }
    return scan(ws.root);
}
/** Find workspace by title or create it. Returns workspace filename. */
export function findOrCreateWorkspace(title) {
    const existing = findWorkspaceByTitle(title);
    if (existing)
        return { filename: existing.filename, created: false };
    const info = createWorkspace({ title });
    return { filename: info.filename, created: true };
}
/** Find container by name in workspace, or create it. Returns container ID. */
export function findOrCreateContainer(wsFile, name) {
    const existing = findContainerByName(wsFile, name);
    if (existing)
        return { containerId: existing, created: false };
    const result = addContainerToWorkspace(wsFile, null, name);
    return { containerId: result.containerId, created: true };
}
// ============================================================================
// CROSS-WORKSPACE QUERIES
// ============================================================================
/** Rename a document reference in every workspace that contains it. */
export function renameDocInAllWorkspaces(oldFile, newFile, newTitle) {
    const workspaces = listWorkspaces();
    for (const info of workspaces) {
        try {
            const ws = readWorkspace(info.filename);
            const found = findDocNode(ws.root, oldFile);
            if (found) {
                found.node.file = newFile;
                found.node.title = newTitle;
                writeWorkspace(info.filename, ws);
            }
        }
        catch { /* skip corrupt manifests */ }
    }
}
/** Remove a document from every workspace that references it. */
export function removeDocFromAllWorkspaces(file) {
    const workspaces = listWorkspaces();
    for (const info of workspaces) {
        try {
            const ws = readWorkspace(info.filename);
            if (collectAllFiles(ws.root).includes(file)) {
                removeDoc(info.filename, file);
            }
        }
        catch { /* skip corrupt manifests */ }
    }
}
export function getWorkspaceAssignedFiles() {
    const assigned = new Set();
    const workspaces = listWorkspaces();
    for (const info of workspaces) {
        try {
            const ws = readWorkspace(info.filename);
            for (const file of collectAllFiles(ws.root))
                assigned.add(file);
        }
        catch { /* skip corrupt manifests */ }
    }
    return assigned;
}
/** Return every workspace manifest filename that contains this doc. A doc may
 *  appear in multiple workspaces; callers that want one usually pick the first. */
export function findWorkspacesContainingDoc(file) {
    const workspaces = listWorkspaces();
    const result = [];
    for (const info of workspaces) {
        try {
            const ws = readWorkspace(info.filename);
            if (collectAllFiles(ws.root).includes(file))
                result.push(info);
        }
        catch { /* skip corrupt manifests */ }
    }
    return result;
}
/**
 * Walk every workspace and return true if `file` is inside one where auto-accept
 * is on at the workspace level or on any ancestor container. Returns false when
 * the doc isn't in any workspace or no ancestor has the flag set.
 *
 * A doc's own `autoAccept` frontmatter is NOT checked here — that's the caller's
 * job (combined with this lookup, OR-style).
 */
export function isAutoAcceptInheritedForDoc(file) {
    const workspaces = listWorkspaces();
    for (const info of workspaces) {
        try {
            const ws = readWorkspace(info.filename);
            // Walk root to find the doc; collect ancestor containers along the way.
            function walk(nodes, ancestors) {
                for (const n of nodes) {
                    if (n.type === 'doc' && n.file === file) {
                        if (ws.autoAccept === true)
                            return true;
                        for (const c of ancestors)
                            if (c.autoAccept === true)
                                return true;
                        return false; // doc lives here but no ancestor flag set
                    }
                    if (n.type === 'container') {
                        const result = walk(n.items, [...ancestors, n]);
                        if (result !== null)
                            return result;
                    }
                }
                return null;
            }
            const found = walk(ws.root, []);
            if (found === true)
                return true;
            // if found === false, doc IS in this workspace but no ancestor flag is on;
            // continue scanning other workspaces (a doc could be referenced in multiple)
        }
        catch { /* skip corrupt manifests */ }
    }
    return false;
}
/** Set or clear workspace-level autoAccept. */
export function setWorkspaceAutoAccept(wsFile, enabled) {
    const ws = readWorkspace(wsFile);
    if (enabled)
        ws.autoAccept = true;
    else
        delete ws.autoAccept;
    writeWorkspace(wsFile, ws);
}
/** Set or clear container-level autoAccept. */
export function setContainerAutoAccept(wsFile, containerId, enabled) {
    const ws = readWorkspace(wsFile);
    const found = findContainer(ws.root, containerId);
    if (!found)
        throw new Error(`Container ${containerId} not found in ${wsFile}`);
    if (enabled)
        found.node.autoAccept = true;
    else
        delete found.node.autoAccept;
    writeWorkspace(wsFile, ws);
}
/** Set or clear the user-authored `purpose:` hint on a workspace. Trim and
 *  treat empty string as clear, so a user can blank the field in the UI. */
export function setWorkspacePurpose(wsFile, purpose) {
    const ws = readWorkspace(wsFile);
    const trimmed = purpose.trim();
    if (trimmed)
        ws.purpose = trimmed;
    else
        delete ws.purpose;
    writeWorkspace(wsFile, ws);
}
export function setContainerPurpose(wsFile, containerId, purpose) {
    const ws = readWorkspace(wsFile);
    const found = findContainer(ws.root, containerId);
    if (!found)
        throw new Error(`Container ${containerId} not found in ${wsFile}`);
    const trimmed = purpose.trim();
    if (trimmed)
        found.node.purpose = trimmed;
    else
        delete found.node.purpose;
    writeWorkspace(wsFile, ws);
}
/** Collect every file inside a workspace or container subtree. Used for broadcast. */
export function collectFilesInWorkspace(wsFile) {
    try {
        const ws = readWorkspace(wsFile);
        return collectAllFiles(ws.root);
    }
    catch {
        return [];
    }
}
export function collectFilesInContainer(wsFile, containerId) {
    try {
        const ws = readWorkspace(wsFile);
        const found = findContainer(ws.root, containerId);
        if (!found)
            return [];
        return collectAllFiles(found.node.items);
    }
    catch {
        return [];
    }
}
export function getWorkspaceStructure(filename) {
    return getWorkspace(filename);
}
/** Read the title for a doc file: frontmatter → first h1 in body → filename stem. */
export function getDocTitle(filename) {
    try {
        const filePath = resolveDocPath(filename);
        if (existsSync(filePath)) {
            const { data, content } = matter(readFileSync(filePath, 'utf-8'));
            return resolveListingTitle({ fmTitle: data.title, content, filename });
        }
    }
    catch { /* fall through to stem */ }
    return filename.replace(/\.md$/, '');
}
