// Per-file extraction (Epic #189, Issue #194). Given a discovered file, a parser registry,
// and an open KnowledgeStore, this module:
//
//   1. Resolves the file's realPath through the WorkspaceFs port and re-asserts the
//      realpath-containment gate (defence in depth — the walker already filtered, but a
//      consumer calling extractDocument() directly must not bypass the boundary).
//   2. Reads bytes via WorkspaceFs.readFileBytes (the boundary-checked byte-read path).
//   3. Computes the content hash (SHA-256 hex) over the raw bytes.
//   4. Detects the incremental fast-path: if a documents row with the same id already has
//      this content_hash AND status="extracted"/"unsupported", we skip the parse entirely
//      and leave last_extracted_at untouched.
//   5. Resolves a parser through the registry; rejects an oversized file BEFORE we hand it
//      to the parser (the OVERSIZED_FILE diagnostic is the same code parsers emit).
//   6. Inside a single transaction: REPLACEs the documents row, deletes prior dependent
//      rows, then inserts the new pages/sections/parsed_units/diagnostics.
import { createHash } from "node:crypto";
import { buildParserOptions, unsupportedParser } from "../parsers/index.js";
import { redactDiagnosticMessage } from "../privacy/diagnostic-redactor.js";
import { basenameOf, extensionOf, mediaTypeFor } from "./media-type.js";
import { deleteDependentRows, insertDiagnosticRow, insertDocumentRow, insertDocumentTextRow, insertPageRow, insertParsedUnitRow, insertSectionRow, readExistingDocumentRow, } from "./persist.js";
import { documentIdFor, } from "./types.js";
// ─── Path helpers (re-derived to keep extract.ts self-contained for the realpath gate) ──
// On Windows, WorkspaceFs.realPath() may return backslash-separated paths
// (e.g. C:\Users\workspace\file). Normalise both sides to forward slashes so
// containment checks work cross-platform.
function normaliseSep(p) {
    return p.replace(/\\/g, "/");
}
function isContained(absoluteRoot, absolutePath) {
    const normRoot = normaliseSep(absoluteRoot);
    const normPath = normaliseSep(absolutePath);
    if (normPath === normRoot)
        return true;
    const prefix = normRoot.endsWith("/") ? normRoot : `${normRoot}/`;
    return normPath.startsWith(prefix);
}
function joinAbs(root, rel) {
    if (root.endsWith("/"))
        return `${root}${rel}`;
    return `${root}/${rel}`;
}
function scopeRoot(source) {
    const { scope } = source;
    if (scope.kind === "folder")
        return scope.rootPath;
    if (scope.kind === "repository")
        return scope.repositoryRoot;
    return scope.rootPath;
}
function hashBytes(bytes) {
    return createHash("sha256").update(bytes).digest("hex");
}
function safeDisplay(relativePath) {
    const base = basenameOf(relativePath);
    return base.length === 0 ? relativePath : base;
}
function redactionPrefixFor(source) {
    return scopeRoot(source);
}
function redactMessage(message, source) {
    return redactDiagnosticMessage(message, redactionPrefixFor(source));
}
function redactDiagnostic(diagnostic, source) {
    return {
        ...diagnostic,
        message: redactMessage(diagnostic.message, source),
    };
}
function redactDiagnostics(diagnostics, source) {
    return diagnostics.map((diagnostic) => redactDiagnostic(diagnostic, source));
}
function redactParserResult(parserResult, source) {
    return {
        ...parserResult,
        diagnostics: redactDiagnostics(parserResult.diagnostics, source),
    };
}
// ─── Failure / unsupported helpers ───────────────────────────────────────────
function buildFailureResult(params, documentId, error) {
    const redactedMessage = redactMessage(error.message, params.source);
    const diagnostic = {
        severity: "error",
        code: error.code,
        message: redactedMessage,
        documentId,
    };
    const document = {
        id: documentId,
        capsuleId: params.capsuleId,
        sourceId: params.source.id,
        documentPath: params.file.relativePath,
        sizeBytes: params.file.sizeBytes,
        mediaType: mediaTypeFor(extensionOf(params.file.relativePath)),
        contentHash: "",
        parser: { parserId: "none", parserVersion: "0" },
        lastExtractedAt: 0,
        status: "failed",
        safeDisplayName: safeDisplay(params.file.relativePath),
    };
    const outcome = {
        kind: "failed",
        document,
        error: { ...error, message: redactedMessage },
    };
    return {
        capsuleId: params.capsuleId,
        sourceId: params.source.id,
        relativePath: params.file.relativePath,
        outcome,
        diagnostics: [diagnostic],
    };
}
// ─── Persist helpers (run inside the per-file transaction) ───────────────────
function persistDependentRows(deps, capsuleId, documentId, parserResult, now) {
    const db = deps.store._internal.db;
    deleteDependentRows(db, capsuleId, documentId);
    if (parserResult.normalizedText !== undefined) {
        insertDocumentTextRow(db, capsuleId, documentId, parserResult.normalizedText);
    }
    for (const page of parserResult.pages)
        insertPageRow(db, capsuleId, page);
    for (const section of parserResult.sections)
        insertSectionRow(db, capsuleId, section);
    parserResult.units.forEach((unit, index) => {
        insertParsedUnitRow(db, capsuleId, `${String(documentId)}#u${String(index)}`, unit);
    });
    parserResult.diagnostics.forEach((diagnostic, index) => {
        insertDiagnosticRow(db, {
            id: `${String(documentId)}#d${String(index)}`,
            capsuleId,
            diagnostic,
            createdAt: now(),
        });
    });
}
function buildDocumentRecord(input) {
    return {
        id: input.documentId,
        capsuleId: input.params.capsuleId,
        sourceId: input.params.source.id,
        documentPath: input.params.file.relativePath,
        sizeBytes: input.params.file.sizeBytes,
        mediaType: input.mediaType,
        contentHash: input.contentHash,
        parser: input.parserResult.parser,
        lastExtractedAt: input.parserResult.extractedAt,
        status: input.status,
        safeDisplayName: safeDisplay(input.params.file.relativePath),
    };
}
function persistDocumentAndDependents(deps, params, documentId, document, parserResult, now) {
    const db = deps.store._internal.db;
    db.exec("BEGIN");
    try {
        insertDocumentRow(db, {
            id: documentId,
            capsuleId: params.capsuleId,
            sourceId: String(params.source.id),
            documentPath: document.documentPath,
            sizeBytes: document.sizeBytes,
            mediaType: document.mediaType,
            contentHash: document.contentHash,
            parserId: document.parser.parserId,
            parserVersion: document.parser.parserVersion,
            lastExtractedAt: document.lastExtractedAt,
            status: document.status,
            safeDisplayName: document.safeDisplayName,
        });
        persistDependentRows(deps, params.capsuleId, documentId, parserResult, now);
        db.exec("COMMIT");
    }
    catch (cause) {
        db.exec("ROLLBACK");
        throw cause;
    }
}
function resolveTargetPath(deps, params) {
    const root = scopeRoot(params.source);
    const absolute = joinAbs(root, params.file.relativePath);
    let real;
    try {
        real = deps.fs.realPath(absolute);
    }
    catch (cause) {
        return {
            code: "READ_FAILED",
            message: `realPath failed: ${cause instanceof Error ? cause.message : String(cause)}`,
            relativePath: params.file.relativePath,
        };
    }
    if (!isContained(root, real)) {
        return {
            code: "PATH_ESCAPE",
            message: `realpath escapes scope root: ${params.file.relativePath}`,
            relativePath: params.file.relativePath,
        };
    }
    // Normalise to forward slashes so subsequent IO calls (readFileBytes, stat) receive
    // a consistent path even when realPath returned a Windows backslash path.
    return { absolutePath: normaliseSep(real) };
}
async function readBytes(deps, absolutePath, maxBytes) {
    const reader = deps.fs.readFileBytes;
    if (reader === undefined) {
        return { code: "READ_FAILED", message: "WorkspaceFs.readFileBytes is unavailable" };
    }
    try {
        return await reader(absolutePath, maxBytes);
    }
    catch (cause) {
        return {
            code: "READ_FAILED",
            message: `readFileBytes failed: ${cause instanceof Error ? cause.message : String(cause)}`,
        };
    }
}
// ─── Incremental fast-path ───────────────────────────────────────────────────
function readUnchangedFastPath(deps, params, documentId, contentHash) {
    const existing = readExistingDocumentRow(deps.store._internal.db, params.capsuleId, documentId);
    if (existing === undefined)
        return undefined;
    if (existing.content_hash !== contentHash)
        return undefined;
    if (existing.status === "failed")
        return undefined;
    const document = {
        id: documentId,
        capsuleId: params.capsuleId,
        sourceId: params.source.id,
        documentPath: existing.document_path,
        sizeBytes: existing.size_bytes,
        mediaType: existing.media_type,
        contentHash: existing.content_hash,
        parser: {
            parserId: existing.parser_id,
            parserVersion: existing.parser_version,
        },
        lastExtractedAt: existing.last_extracted_at,
        status: existing.status,
        safeDisplayName: existing.safe_display_name,
    };
    return {
        capsuleId: params.capsuleId,
        sourceId: params.source.id,
        relativePath: params.file.relativePath,
        outcome: { kind: "skipped", document, reason: "unchanged" },
        diagnostics: [],
    };
}
// ─── Top-level entry point ───────────────────────────────────────────────────
function selectionInput(documentId, relativePath, bytes) {
    const extension = extensionOf(relativePath);
    return {
        documentId,
        bytes,
        extension,
        mediaType: mediaTypeFor(extension),
    };
}
function isUnsupportedResult(result) {
    return (result.parser.parserId === "unsupported" ||
        (result.units.length > 0 && result.units.every((unit) => unit.kind === "unsupported-media")));
}
function statusForResult(result) {
    return isUnsupportedResult(result) ? "unsupported" : "extracted";
}
const SOURCE_TEXT_PARSER_IDS = new Set(["text", "json", "csv", "html"]);
function decodeUtf8ForStorage(bytes) {
    const raw = new TextDecoder("utf-8", { fatal: false }).decode(bytes);
    return raw.length > 0 && raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw;
}
function normalizedTextForPersistence(parserResult, bytes) {
    if (parserResult.normalizedText !== undefined) {
        return parserResult.normalizedText;
    }
    if (!SOURCE_TEXT_PARSER_IDS.has(parserResult.parser.parserId)) {
        return undefined;
    }
    if (parserResult.units.length === 0) {
        return undefined;
    }
    return decodeUtf8ForStorage(bytes);
}
function withPersistedNormalizedText(parserResult, bytes) {
    const normalizedText = normalizedTextForPersistence(parserResult, bytes);
    return normalizedText === undefined ? parserResult : { ...parserResult, normalizedText };
}
function hasAsyncParse(adapter) {
    return typeof adapter.parseAsync === "function";
}
async function runParser(deps, documentId, params, bytes, options) {
    const input = selectionInput(documentId, params.file.relativePath, bytes);
    const resolution = deps.parserRegistry.resolve(input);
    const adapter = resolution.kind === "matched" ? resolution.adapter : unsupportedParser;
    if (hasAsyncParse(adapter)) {
        return adapter.parseAsync(input, options);
    }
    return adapter.parse(input, options);
}
async function runParserForPersistence(deps, documentId, params, bytes, options) {
    const result = await runParser(deps, documentId, params, bytes, options);
    return withPersistedNormalizedText(result, bytes);
}
function persistExtractedDocument(deps, params, documentId, document, parserResult) {
    persistDocumentAndDependents(deps, params, documentId, document, parserResult, deps.store._internal.now);
}
export async function extractDocument(deps, params) {
    const documentId = documentIdFor({
        capsuleId: params.capsuleId,
        sourceId: params.source.id,
        relativePath: params.file.relativePath,
    });
    const resolved = resolveTargetPath(deps, params);
    if ("code" in resolved) {
        return buildFailureResult(params, documentId, resolved);
    }
    const options = params.parserOptions ?? buildParserOptions();
    if (params.file.sizeBytes > options.maxBytes) {
        return buildOversizedFailure(deps, params, documentId, options);
    }
    const bytes = await readBytes(deps, resolved.absolutePath, options.maxBytes);
    if (!(bytes instanceof Uint8Array)) {
        return buildFailureResult(params, documentId, bytes);
    }
    const contentHash = hashBytes(bytes);
    const fast = readUnchangedFastPath(deps, params, documentId, contentHash);
    if (fast !== undefined)
        return fast;
    const parserResult = await runParserForPersistence(deps, documentId, params, bytes, options);
    const redactedParserResult = redactParserResult(parserResult, params.source);
    const status = statusForResult(redactedParserResult);
    const document = buildDocumentRecord({
        documentId,
        params,
        mediaType: mediaTypeFor(extensionOf(params.file.relativePath)),
        contentHash,
        parserResult: redactedParserResult,
        status,
    });
    persistExtractedDocument(deps, params, documentId, document, redactedParserResult);
    return {
        capsuleId: params.capsuleId,
        sourceId: params.source.id,
        relativePath: params.file.relativePath,
        outcome: { kind: "persisted", document },
        diagnostics: redactedParserResult.diagnostics,
    };
}
function oversizedDocumentRecord(params, documentId, lastExtractedAt) {
    return {
        id: documentId,
        capsuleId: params.capsuleId,
        sourceId: params.source.id,
        documentPath: params.file.relativePath,
        sizeBytes: params.file.sizeBytes,
        mediaType: mediaTypeFor(extensionOf(params.file.relativePath)),
        contentHash: "",
        parser: { parserId: "none", parserVersion: "0" },
        lastExtractedAt,
        status: "failed",
        safeDisplayName: safeDisplay(params.file.relativePath),
    };
}
function persistOversizedRow(deps, params, documentId, document, diagnostic, now) {
    const db = deps.store._internal.db;
    db.exec("BEGIN");
    try {
        insertDocumentRow(db, {
            id: documentId,
            capsuleId: params.capsuleId,
            sourceId: String(params.source.id),
            documentPath: document.documentPath,
            sizeBytes: document.sizeBytes,
            mediaType: document.mediaType,
            contentHash: document.contentHash,
            parserId: document.parser.parserId,
            parserVersion: document.parser.parserVersion,
            lastExtractedAt: document.lastExtractedAt,
            status: document.status,
            safeDisplayName: document.safeDisplayName,
        });
        deleteDependentRows(db, params.capsuleId, documentId);
        insertDiagnosticRow(db, {
            id: `${String(documentId)}#d0`,
            capsuleId: params.capsuleId,
            diagnostic,
            createdAt: now(),
        });
        db.exec("COMMIT");
    }
    catch (cause) {
        db.exec("ROLLBACK");
        throw cause;
    }
}
function buildOversizedFailure(deps, params, documentId, options) {
    const now = deps.store._internal.now;
    const message = redactMessage(`file size ${String(params.file.sizeBytes)} exceeds maxBytes=${String(options.maxBytes)}`, params.source);
    const diagnostic = {
        severity: "error",
        code: "OVERSIZED_FILE",
        message,
        documentId,
    };
    const document = oversizedDocumentRecord(params, documentId, now());
    persistOversizedRow(deps, params, documentId, document, diagnostic, now);
    return {
        capsuleId: params.capsuleId,
        sourceId: params.source.id,
        relativePath: params.file.relativePath,
        outcome: {
            kind: "failed",
            document,
            error: { code: "OVERSIZED_FILE", message },
        },
        diagnostics: [diagnostic],
    };
}
