import { promises as fs } from "node:fs";
import * as path from "node:path";
import { walkVault, buildVaultIndex, resolveWikilinkInVault, normalizeTag, makeImportId, } from "../../../../../lib/obsidian-parser/dist/index.js";
import { levenshtein } from "../../../../../lib/graph-mcp/dist/schema-cypher-parser.js";
import { getSession } from "../lib/neo4j.js";
import { memoryArchiveWrite, } from "./memory-archive-write.js";
const FUZZY_MATCH_THRESHOLD = 2;
const SUPPORTED_ATTACHMENT_MIME = {
    ".png": "image/png",
    ".jpg": "image/jpeg",
    ".jpeg": "image/jpeg",
    ".gif": "image/gif",
    ".webp": "image/webp",
    ".svg": "image/svg+xml",
    ".pdf": "application/pdf",
    ".mp3": "audio/mpeg",
    ".mp4": "video/mp4",
    ".mov": "video/quicktime",
    ".wav": "audio/wav",
    ".m4a": "audio/mp4",
    ".webm": "video/webm",
};
export async function obsidianVaultImport(params) {
    const importId = params.importId ?? makeImportId();
    const threshold = params.selectiveIngestThreshold ?? 100;
    const startedMs = Date.now();
    await assertExtractedDir(params.extractedDir);
    const walkOpts = {
        folderPrefixes: params.filters?.folderPrefixes,
        tagFilter: params.filters?.tags?.map(normalizeTag),
        modifiedSinceMs: toEpochMs(params.filters?.modifiedSince),
    };
    const allPages = await walkVault(params.extractedDir, {});
    const pagesDiscovered = allPages.length;
    // Re-walk with filters for the "eligible" set so the plan honours the
    // operator's selective-ingest choices.
    const eligiblePages = walkOpts.folderPrefixes || walkOpts.tagFilter || walkOpts.modifiedSinceMs
        ? await walkVault(params.extractedDir, walkOpts)
        : allPages;
    const vaultIndex = buildVaultIndex(eligiblePages);
    const knownPaths = new Set(eligiblePages.map((p) => p.obsidianPath));
    const session = getSession();
    let resolved;
    try {
        resolved = await resolveAllWikilinks(eligiblePages, vaultIndex, knownPaths, params.accountId, session);
    }
    finally {
        await session.close();
    }
    const tagCount = countDistinct(eligiblePages.flatMap((p) => p.tags.map(normalizeTag)));
    const attachmentCount = countDistinct(eligiblePages.flatMap((p) => p.attachments.map((a) => a.resolvedPath)));
    // Idempotency: query existing contentHashes for these paths and mark
    // unchanged. Only the dry-run plan reports the count; the commit phase
    // uses the same map to filter rows before write.
    const idemSession = getSession();
    let unchangedSet;
    try {
        unchangedSet = await findUnchangedPages(idemSession, params.accountId, eligiblePages);
    }
    finally {
        await idemSession.close();
    }
    const ambiguities = resolved.ambiguities;
    const plan = {
        importId,
        mode: params.mode,
        pagesDiscovered,
        pagesEligible: eligiblePages.length,
        pagesUnchanged: unchangedSet.size,
        wikilinks: {
            page: resolved.counts.page,
            entity: resolved.counts.entity,
            stub: resolved.counts.stub,
            ambiguous: ambiguities.length,
        },
        tagCount,
        attachmentCount,
        ambiguities,
        selectiveIngestSuggested: eligiblePages.length > threshold &&
            !walkOpts.folderPrefixes &&
            !walkOpts.tagFilter &&
            !walkOpts.modifiedSinceMs,
    };
    process.stderr.write(`[obsidian-import] event=dry-run-complete importId=${importId}` +
        ` pages=${eligiblePages.length} unchanged=${unchangedSet.size}` +
        ` wikilinks-page=${resolved.counts.page} wikilinks-entity=${resolved.counts.entity}` +
        ` wikilinks-stub=${resolved.counts.stub} wikilinks-ambiguous=${ambiguities.length}` +
        ` tags=${tagCount} attachments=${attachmentCount}` +
        ` ms=${Date.now() - startedMs}\n`);
    if (params.mode === "dry-run")
        return plan;
    // Commit phase — refuse if unresolved ambiguities remain.
    const resolutions = params.resolutions ?? {};
    const missing = ambiguities.filter((a) => !(a.id in resolutions));
    if (missing.length > 0) {
        throw new Error(`obsidian-vault-import commit refused: ${missing.length} unresolved ambiguities. Re-run dry-run, surface to operator, supply resolutions.`);
    }
    const accountDir = resolveAccountDir(params.accountId);
    const importArchiveDir = path.join(accountDir, "archive", "obsidian", importId);
    await fs.mkdir(importArchiveDir, { recursive: true });
    let attachmentsCopied = 0;
    const rows = [];
    for (const page of eligiblePages) {
        if (unchangedSet.has(page.obsidianPath))
            continue;
        const pageWikilinks = resolveWithOverrides(page, resolved.byPage.get(page.obsidianPath) ?? [], resolutions);
        const pageAttachments = await stageAttachments(page, params.extractedDir, importArchiveDir, (n) => {
            attachmentsCopied += n;
        });
        rows.push({
            obsidianPath: page.obsidianPath,
            title: page.title,
            aliases: page.aliases,
            tags: page.tags.map(normalizeTag),
            body: page.body,
            contentHash: page.contentHash,
            noteDate: page.noteDate,
            mtimeMs: page.mtimeMs,
            wikilinks: pageWikilinks,
            attachments: pageAttachments,
        });
    }
    let written = {
        createdPages: 0,
        mergedPages: 0,
        createdTags: 0,
        createdTagEdges: 0,
        createdAttachments: 0,
        createdAttachmentEdges: 0,
        createdWikilinkEdges: 0,
    };
    const errors = [];
    if (rows.length > 0) {
        const result = await memoryArchiveWrite({
            archiveType: "obsidian-vault",
            ownerNodeId: params.ownerNodeId,
            accountId: params.accountId,
            rows,
            sessionId: params.sessionId,
        });
        written = {
            createdPages: result.counters.createdPages ?? 0,
            mergedPages: result.counters.mergedPages ?? 0,
            createdTags: result.counters.createdTags ?? 0,
            createdTagEdges: result.counters.createdTagEdges ?? 0,
            createdAttachments: result.counters.createdAttachments ?? 0,
            createdAttachmentEdges: result.counters.createdAttachmentEdges ?? 0,
            createdWikilinkEdges: result.counters.createdWikilinkEdges ?? 0,
        };
        errors.push(...result.errors);
    }
    const elapsedMs = Date.now() - startedMs;
    const observabilityTag = `[obsidian-import] event=commit-complete importId=${importId}` +
        ` pages=${eligiblePages.length} written=${rows.length} skipped=${unchangedSet.size}` +
        ` wikilinks=${written.createdWikilinkEdges} tags=${written.createdTags}` +
        ` attachments=${attachmentsCopied} errors=${errors.length} ms=${elapsedMs}`;
    process.stderr.write(observabilityTag + "\n");
    return {
        ...plan,
        written,
        attachmentsCopied,
        errors,
        observabilityTag,
    };
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
async function assertExtractedDir(extractedDir) {
    if (!extractedDir || !path.isAbsolute(extractedDir)) {
        throw new Error(`obsidian-vault-import: extractedDir must be an absolute path; got "${extractedDir}".`);
    }
    const stat = await fs.stat(extractedDir).catch(() => null);
    if (!stat || !stat.isDirectory()) {
        throw new Error(`obsidian-vault-import: extractedDir does not exist or is not a directory: ${extractedDir}`);
    }
}
function toEpochMs(modifiedSince) {
    if (!modifiedSince)
        return undefined;
    if (/^\d+$/.test(modifiedSince))
        return Number.parseInt(modifiedSince, 10);
    const t = Date.parse(modifiedSince);
    return Number.isFinite(t) ? t : undefined;
}
function countDistinct(values) {
    return new Set(values.filter(Boolean)).size;
}
function resolveAccountDir(accountId) {
    // Same convention as memory plugin's attachment-store: lives under the
    // configured DATA_ROOT/accounts/<accountId>/. If neither env is set we
    // throw — fail loud rather than write attachments to an unexpected path.
    const root = process.env.MAXY_DATA_ROOT ??
        process.env.DATA_ROOT ??
        process.env.PLATFORM_DATA_ROOT;
    if (!root) {
        throw new Error("obsidian-vault-import: data root env (MAXY_DATA_ROOT / DATA_ROOT / PLATFORM_DATA_ROOT) is not set; cannot resolve attachment archive directory.");
    }
    return path.join(root, "accounts", accountId);
}
/**
 * Build the resolution plan for every wikilink in every eligible page.
 *
 * Resolution order:
 *   1. Intra-vault page (title or alias) — if unique, page target.
 *   2. Existing entity fuzzy match against :Person/:Organization/:Concept
 *      with name distance ≤ FUZZY_MATCH_THRESHOLD — if unique, entity target.
 *   3. Otherwise, stub target (the handler creates a :Concept stub at write).
 *
 * Ambiguity: both an intra-vault page AND an existing entity match; or
 * multiple intra-vault candidates; or ≥2 entity candidates within
 * threshold. Stub-only matches are NOT ambiguities — we simply create the
 * stub.
 */
async function resolveAllWikilinks(pages, index, knownPaths, accountId, session) {
    const distinctTargets = new Set();
    for (const p of pages) {
        for (const wl of p.wikilinks)
            distinctTargets.add(wl.target);
    }
    const entityIndex = await loadEntityIndex(session, accountId, distinctTargets);
    const byPage = new Map();
    const ambiguities = [];
    const counts = { page: 0, entity: 0, stub: 0 };
    let ambiguityCounter = 0;
    for (const page of pages) {
        const resolved = [];
        for (const wl of page.wikilinks) {
            const pageHits = resolveWikilinkInVault(wl.target, index, knownPaths);
            const entityHits = lookupEntities(wl.target, entityIndex);
            const decision = decideResolution(wl, pageHits, entityHits);
            if (decision.kind === "ambiguous") {
                ambiguityCounter++;
                const id = `amb-${ambiguityCounter}`;
                ambiguities.push({
                    id,
                    fromPath: page.obsidianPath,
                    target: wl.target,
                    pageCandidates: decision.pageCandidates,
                    entityCandidates: decision.entityCandidates,
                });
                // Provisional resolution for the dry-run summary: stub, until
                // the operator decides. The commit phase overrides via resolutions.
                resolved.push(toResolvedWikilink(wl, { kind: "stub", name: wl.target }, id));
                counts.stub++;
                continue;
            }
            resolved.push(toResolvedWikilink(wl, decision.target));
            counts[decision.target.kind]++;
        }
        byPage.set(page.obsidianPath, resolved);
    }
    return { byPage, ambiguities, counts };
}
async function loadEntityIndex(session, accountId, distinctTargets) {
    if (distinctTargets.size === 0) {
        return { byName: new Map(), allNames: [] };
    }
    const result = await session.run(`MATCH (n)
     WHERE n.accountId = $accountId
       AND (n:Person OR n:Organization OR n:Concept)
       AND NOT n:Trashed
       AND n.name IS NOT NULL
     RETURN elementId(n) AS nodeId, labels(n) AS labels, n.name AS name
     LIMIT 50000`, { accountId });
    const allNames = [];
    const byName = new Map();
    for (const rec of result.records) {
        const labels = rec.get("labels");
        const primary = labels.find((l) => l === "Person" || l === "Organization" || l === "Concept") ?? labels[0];
        const entry = {
            nodeId: rec.get("nodeId"),
            label: primary,
            name: rec.get("name"),
        };
        allNames.push(entry);
        const key = entry.name.toLowerCase();
        const arr = byName.get(key);
        if (arr)
            arr.push(entry);
        else
            byName.set(key, [entry]);
    }
    return { byName, allNames };
}
function lookupEntities(target, index) {
    const t = target.toLowerCase().trim();
    if (!t)
        return [];
    const exact = index.byName.get(t);
    if (exact && exact.length > 0) {
        return exact.map((e) => ({ ...e, distance: 0 }));
    }
    const out = [];
    for (const entry of index.allNames) {
        const d = levenshtein(t, entry.name.toLowerCase());
        if (d <= FUZZY_MATCH_THRESHOLD) {
            out.push({ ...entry, distance: d });
        }
    }
    out.sort((a, b) => a.distance - b.distance);
    return out;
}
function decideResolution(wl, pageHits, entityHits) {
    // Exact entity (distance 0) is treated as a strong hit alongside any page hit.
    const exactEntities = entityHits.filter((e) => e.distance === 0);
    if (pageHits.length === 1 && exactEntities.length === 0 && entityHits.length === 0) {
        return { kind: "resolved", target: { kind: "page", obsidianPath: pageHits[0] } };
    }
    if (pageHits.length === 0 && exactEntities.length === 1) {
        return {
            kind: "resolved",
            target: { kind: "entity", nodeId: exactEntities[0].nodeId, nodeLabel: exactEntities[0].label },
        };
    }
    // Two or more competing categories or candidates → ambiguous.
    if (pageHits.length >= 1 && exactEntities.length >= 1) {
        return { kind: "ambiguous", pageCandidates: pageHits, entityCandidates: entityHits };
    }
    if (pageHits.length >= 2) {
        return { kind: "ambiguous", pageCandidates: pageHits, entityCandidates: [] };
    }
    if (exactEntities.length >= 2) {
        return { kind: "ambiguous", pageCandidates: [], entityCandidates: exactEntities };
    }
    // Only fuzzy entity matches (distance > 0). Single fuzzy hit → resolve.
    // Multiple fuzzy hits → ambiguous.
    if (pageHits.length === 0 && entityHits.length === 1) {
        return {
            kind: "resolved",
            target: { kind: "entity", nodeId: entityHits[0].nodeId, nodeLabel: entityHits[0].label },
        };
    }
    if (pageHits.length === 0 && entityHits.length >= 2) {
        return { kind: "ambiguous", pageCandidates: [], entityCandidates: entityHits };
    }
    // No hits anywhere → stub.
    return { kind: "resolved", target: { kind: "stub", name: wl.target } };
}
function toResolvedWikilink(wl, target, ambiguityId) {
    return {
        linkText: wl.target,
        alias: wl.alias,
        anchor: wl.anchor,
        target,
        ...(ambiguityId ? { _ambiguityId: ambiguityId } : {}),
    };
}
function resolveWithOverrides(page, resolvedForPage, resolutions) {
    return resolvedForPage.map((wl) => {
        const tagged = wl;
        if (!tagged._ambiguityId)
            return wl;
        const decision = resolutions[tagged._ambiguityId];
        if (!decision)
            return wl;
        if (decision.kind === "page") {
            return { ...wl, target: { kind: "page", obsidianPath: decision.obsidianPath } };
        }
        if (decision.kind === "entity") {
            // nodeLabel is not strictly required by the Cypher (elementId scopes
            // by accountId guard); pass empty string when not known.
            return { ...wl, target: { kind: "entity", nodeId: decision.nodeId, nodeLabel: "" } };
        }
        return { ...wl, target: { kind: "stub", name: wl.linkText } };
    });
}
async function findUnchangedPages(session, accountId, pages) {
    if (pages.length === 0)
        return new Set();
    const payload = pages.map((p) => ({
        obsidianPath: p.obsidianPath,
        contentHash: p.contentHash,
    }));
    const result = await session.run(`UNWIND $payload AS row
     MATCH (k:KnowledgeDocument {accountId: $accountId, obsidianPath: row.obsidianPath})
     WHERE k.contentHash = row.contentHash AND NOT k:Trashed
     RETURN k.obsidianPath AS p`, { accountId, payload });
    const out = new Set();
    for (const rec of result.records)
        out.add(rec.get("p"));
    return out;
}
async function stageAttachments(page, vaultRoot, importArchiveDir, onCopied) {
    const staged = [];
    const seen = new Set();
    for (const att of page.attachments) {
        if (seen.has(att.resolvedPath))
            continue;
        seen.add(att.resolvedPath);
        const src = await findAttachmentSource(vaultRoot, page.obsidianPath, att);
        if (!src) {
            process.stderr.write(`[obsidian-import] event=attachment-missing page=${page.obsidianPath} path=${att.resolvedPath}\n`);
            continue;
        }
        const safeName = sanitizeArchiveName(src.relPath);
        const dest = path.join(importArchiveDir, safeName);
        const destDir = path.dirname(dest);
        await fs.mkdir(destDir, { recursive: true });
        try {
            await fs.copyFile(src.absPath, dest);
            onCopied(1);
        }
        catch (err) {
            process.stderr.write(`[obsidian-import] event=attachment-copy-failed page=${page.obsidianPath} path=${att.resolvedPath} reason=${err instanceof Error ? err.message : String(err)}\n`);
            continue;
        }
        const ext = path.extname(src.relPath).toLowerCase();
        staged.push({
            rawPath: att.rawPath,
            archivePath: dest,
            encodingFormat: SUPPORTED_ATTACHMENT_MIME[ext] ?? "application/octet-stream",
            alt: att.alt,
        });
    }
    return staged;
}
async function findAttachmentSource(vaultRoot, pagePath, att) {
    // 1. Vault-root-relative as written.
    const candidates = [att.resolvedPath];
    // 2. Page-relative.
    candidates.push(path.posix.join(path.posix.dirname(pagePath), att.resolvedPath));
    for (const rel of candidates) {
        const abs = path.join(vaultRoot, rel.split("/").join(path.sep));
        try {
            const stat = await fs.stat(abs);
            if (stat.isFile())
                return { absPath: abs, relPath: rel };
        }
        catch {
            // try next candidate
        }
    }
    // 3. Bare filename — search the vault for a single match (typical when
    // operator stores attachments in a default folder Obsidian inserts by name).
    const basename = path.posix.basename(att.resolvedPath);
    if (!basename.includes("/")) {
        const hit = await searchByBasename(vaultRoot, basename);
        if (hit)
            return hit;
    }
    return null;
}
async function searchByBasename(root, basename) {
    const queue = [root];
    while (queue.length > 0) {
        const dir = queue.shift();
        let entries;
        try {
            entries = await fs.readdir(dir, { withFileTypes: true });
        }
        catch {
            continue;
        }
        for (const entry of entries) {
            if (entry.name.startsWith("."))
                continue;
            const abs = path.join(dir, entry.name);
            if (entry.isDirectory()) {
                queue.push(abs);
                continue;
            }
            if (entry.isFile() && entry.name === basename) {
                const rel = path.relative(root, abs).split(path.sep).join("/");
                return { absPath: abs, relPath: rel };
            }
        }
    }
    return null;
}
function sanitizeArchiveName(rel) {
    return rel
        .split("/")
        .filter((seg) => seg && seg !== "." && seg !== "..")
        .join("/");
}
//# sourceMappingURL=obsidian-vault-import.js.map