/**
 * Document comments — anchored, threaded discussion attached to documents.
 *
 * This module owns the *pure* comment logic: validation, normalization, and
 * the storage-agnostic CRUD entry points. The HTTP layer in `api/handlers.ts`
 * is a thin wrapper that handles auth + JSON envelope; everything else lives
 * here so the unit tests don't need a Request loop.
 *
 * Storage shape — see `packages/cms-core/prisma/cms-schema.prisma`,
 * model `DocumentComment`. We require only a minimal Prisma surface so the
 * helpers can be tested against an in-memory fake.
 *
 * Anchors are Yjs *relative positions* serialised as Base64. Storing them in
 * a JSON column (rather than Bytes) keeps the REST contract trivial: the
 * client posts `{ from: "AQ...", to: "AQ..." }`, we round-trip the strings.
 * Slice 5 reconstitutes them with `Y.createAbsolutePositionFromRelativePosition`
 * to paint anchored highlights in the editor; nothing in the API layer needs
 * to interpret the bytes.
 */
/** Maximum length we accept for a comment body (markdown source). */
export const MAX_COMMENT_BODY_LENGTH = 10_000;
/** Cap on returned comments per `listComments` call. */
export const MAX_COMMENT_LIST_PAGE_SIZE = 200;
/** Default page size when the caller does not request one. */
export const DEFAULT_COMMENT_LIST_PAGE_SIZE = 100;
const MAX_ANCHOR_FIELD_LENGTH = 4_096;
function err(code, message) {
    return { code, message };
}
function toIso(value) {
    if (value === null)
        return null;
    if (value instanceof Date)
        return value.toISOString();
    // Tolerate fakes that already store ISO strings.
    return value;
}
function toIsoRequired(value) {
    return value instanceof Date ? value.toISOString() : value;
}
/**
 * Strip a comment row down to the public DTO. Centralised so the response
 * shape stays consistent between create / list / update / resolve.
 */
export function toCommentDTO(row) {
    return {
        id: row.id,
        documentId: row.documentId,
        parentId: row.parentId,
        userId: row.userId,
        body: row.body,
        anchor: row.anchor,
        resolvedAt: toIso(row.resolvedAt),
        resolvedById: row.resolvedById,
        deletedAt: toIso(row.deletedAt),
        createdAt: toIsoRequired(row.createdAt),
        updatedAt: toIsoRequired(row.updatedAt),
        isResolved: row.resolvedAt !== null,
    };
}
/**
 * Parse + validate an anchor blob coming from an untrusted client. Returns
 * `null` when the caller did not provide one (document-level comments).
 */
export function parseAnchor(input) {
    if (input === undefined || input === null) {
        return { ok: true, value: null };
    }
    if (typeof input !== 'object' || Array.isArray(input)) {
        return { ok: false, error: err('validation', 'anchor must be an object with `from` and `to`') };
    }
    const obj = input;
    if (typeof obj.from !== 'string' || typeof obj.to !== 'string') {
        return { ok: false, error: err('validation', 'anchor.from and anchor.to must be strings') };
    }
    if (obj.from.length === 0 ||
        obj.from.length > MAX_ANCHOR_FIELD_LENGTH ||
        obj.to.length === 0 ||
        obj.to.length > MAX_ANCHOR_FIELD_LENGTH) {
        return {
            ok: false,
            error: err('validation', `anchor fields must be 1..${MAX_ANCHOR_FIELD_LENGTH} chars`),
        };
    }
    return { ok: true, value: { from: obj.from, to: obj.to } };
}
/** Normalize whitespace + enforce length limits on a posted body. */
export function normaliseBody(input) {
    if (typeof input !== 'string') {
        return { ok: false, error: err('validation', 'body must be a string') };
    }
    const trimmed = input.trim();
    if (trimmed.length === 0) {
        return { ok: false, error: err('validation', 'body cannot be empty') };
    }
    if (trimmed.length > MAX_COMMENT_BODY_LENGTH) {
        return {
            ok: false,
            error: err('validation', `body cannot exceed ${MAX_COMMENT_BODY_LENGTH} characters`),
        };
    }
    return { ok: true, value: trimmed };
}
/**
 * Create a comment. Enforces:
 *   - body validation (non-empty, length-capped)
 *   - anchor shape validation
 *   - single-level threading: a `parentId` must reference an existing,
 *     non-deleted, non-reply comment under the same document.
 *
 * The caller (HTTP handler) is responsible for auth — once we reach here we
 * trust the supplied `userId`.
 */
export async function createComment(db, input) {
    if (typeof input.documentId !== 'string' || input.documentId.length === 0) {
        return { ok: false, error: err('validation', 'documentId is required') };
    }
    if (typeof input.userId !== 'string' || input.userId.length === 0) {
        return { ok: false, error: err('validation', 'userId is required') };
    }
    const body = normaliseBody(input.body);
    if (!body.ok)
        return body;
    const anchor = parseAnchor(input.anchor);
    if (!anchor.ok)
        return anchor;
    let parentId = null;
    if (input.parentId != null && input.parentId !== '') {
        if (typeof input.parentId !== 'string') {
            return { ok: false, error: err('validation', 'parentId must be a string') };
        }
        const parent = await db.documentComment.findUnique({ where: { id: input.parentId } });
        if (!parent) {
            return { ok: false, error: err('not_found', 'parent comment not found') };
        }
        if (parent.documentId !== input.documentId) {
            return { ok: false, error: err('validation', 'parent comment is on a different document') };
        }
        if (parent.parentId !== null) {
            return {
                ok: false,
                error: err('validation', 'replies cannot themselves be replied to (one level only)'),
            };
        }
        if (parent.deletedAt !== null) {
            return { ok: false, error: err('not_found', 'parent comment has been deleted') };
        }
        parentId = parent.id;
    }
    const row = await db.documentComment.create({
        data: {
            documentId: input.documentId,
            userId: input.userId,
            body: body.value,
            anchor: anchor.value,
            parentId,
        },
    });
    return { ok: true, value: toCommentDTO(row) };
}
/**
 * List comments for a document. Returns the rows ordered oldest-first so a
 * naive client can render threads top-down. Pagination is "show the first
 * N"; the higher-level UI in slice 5 will switch to cursor pagination once
 * volume justifies it.
 */
export async function listComments(db, filter) {
    if (typeof filter.documentId !== 'string' || filter.documentId.length === 0) {
        return { ok: false, error: err('validation', 'documentId is required') };
    }
    const where = { documentId: filter.documentId };
    if (!filter.includeResolved)
        where.resolvedAt = null;
    if (!filter.includeDeleted)
        where.deletedAt = null;
    const pageSize = clampPageSize(filter.pageSize);
    const rows = await db.documentComment.findMany({
        where,
        orderBy: { createdAt: 'asc' },
        take: pageSize,
    });
    return { ok: true, value: rows.map(toCommentDTO) };
}
function clampPageSize(n) {
    if (n === undefined || Number.isNaN(n))
        return DEFAULT_COMMENT_LIST_PAGE_SIZE;
    if (n < 1)
        return 1;
    if (n > MAX_COMMENT_LIST_PAGE_SIZE)
        return MAX_COMMENT_LIST_PAGE_SIZE;
    return Math.floor(n);
}
/**
 * Edit a comment body. Only the author can edit, and only if the comment is
 * not soft-deleted. Resolved threads remain editable so a moderator can
 * append context.
 */
export async function updateComment(db, id, input, actor) {
    const existing = await db.documentComment.findUnique({ where: { id } });
    if (!existing || existing.deletedAt !== null) {
        return { ok: false, error: err('not_found', 'comment not found') };
    }
    if (!actor.isAdmin && existing.userId !== actor.userId) {
        return { ok: false, error: err('forbidden', 'only the author can edit this comment') };
    }
    const body = normaliseBody(input.body);
    if (!body.ok)
        return body;
    const row = await db.documentComment.update({
        where: { id },
        data: { body: body.value },
    });
    return { ok: true, value: toCommentDTO(row) };
}
/**
 * Mark a comment thread as resolved. Either the comment's author or any
 * admin / write-role caller can resolve. Resolving an already-resolved
 * comment is a no-op (returns the existing row) so a duplicate click in the
 * UI does not surface as an error.
 */
export async function resolveComment(db, id, actor) {
    const existing = await db.documentComment.findUnique({ where: { id } });
    if (!existing || existing.deletedAt !== null) {
        return { ok: false, error: err('not_found', 'comment not found') };
    }
    if (!actor.canResolve && existing.userId !== actor.userId) {
        return { ok: false, error: err('forbidden', 'not allowed to resolve this comment') };
    }
    if (existing.resolvedAt !== null) {
        return { ok: true, value: toCommentDTO(existing) };
    }
    const row = await db.documentComment.update({
        where: { id },
        data: { resolvedAt: new Date(), resolvedById: actor.userId },
    });
    return { ok: true, value: toCommentDTO(row) };
}
/**
 * Re-open a resolved thread. Same permission as `resolveComment`.
 */
export async function reopenComment(db, id, actor) {
    const existing = await db.documentComment.findUnique({ where: { id } });
    if (!existing || existing.deletedAt !== null) {
        return { ok: false, error: err('not_found', 'comment not found') };
    }
    if (!actor.canResolve && existing.userId !== actor.userId) {
        return { ok: false, error: err('forbidden', 'not allowed to re-open this comment') };
    }
    if (existing.resolvedAt === null) {
        return { ok: true, value: toCommentDTO(existing) };
    }
    const row = await db.documentComment.update({
        where: { id },
        data: { resolvedAt: null, resolvedById: null },
    });
    return { ok: true, value: toCommentDTO(row) };
}
/**
 * Soft-delete a comment. The row is preserved (so replies don't lose their
 * thread root) but stops being returned by `listComments` unless explicitly
 * requested with `includeDeleted: true`.
 *
 * Only the author or an admin may delete.
 */
export async function deleteComment(db, id, actor) {
    const existing = await db.documentComment.findUnique({ where: { id } });
    if (!existing) {
        return { ok: false, error: err('not_found', 'comment not found') };
    }
    if (!actor.isAdmin && existing.userId !== actor.userId) {
        return { ok: false, error: err('forbidden', 'only the author can delete this comment') };
    }
    if (existing.deletedAt !== null) {
        return { ok: true, value: toCommentDTO(existing) };
    }
    const row = await db.documentComment.update({
        where: { id },
        data: { deletedAt: new Date() },
    });
    return { ok: true, value: toCommentDTO(row) };
}
//# sourceMappingURL=comments.js.map