/**
 * Learnings Index Service
 *
 * Surfaces project-scoped record-learning entries (`.crewly/knowledge/learnings.md`)
 * as virtual `KnowledgeDocumentSummary` entries so they appear in the `/knowledge`
 * UI and are returned by recall searches.
 *
 * Background (memory.f1, 2026-04-28): the `record-learning` skill writes to
 * `learnings.md` (an append-only log owned by `ProjectMemoryService`), but the
 * `/knowledge` UI reads `KnowledgeService.listDocuments()` which only sees
 * `.crewly/docs/`. As a result, learnings persisted to disk were not visible
 * in the UI and were not returned by recall. This service bridges that gap by
 * parsing the learnings log on demand and exposing each entry under a stable
 * synthetic ID (`learning:<sha1-prefix>`) that the rest of the knowledge stack
 * can carry through `listDocuments` / `getDocument` / search.
 *
 * @module services/knowledge/learnings-index.service
 */
import * as path from 'path';
import * as fs from 'fs/promises';
import { existsSync } from 'fs';
import { createHash } from 'crypto';
import { LoggerService } from '../core/logger.service.js';
import { CREWLY_CONSTANTS, MEMORY_CONSTANTS } from '../../constants.js';
import { KNOWLEDGE_CONSTANTS } from '../../types/knowledge.types.js';
/** Prefix that marks a synthetic learning document ID. */
export const LEARNING_ID_PREFIX = 'learning:';
/** Category label used for learnings surfaced in the knowledge UI. */
export const LEARNINGS_CATEGORY = 'Learnings';
/** Maximum title length for a synthesized learning summary (kept short for list rendering). */
const MAX_TITLE_LENGTH = 100;
/** Length of the sha1 hash prefix used for synthetic IDs (16 hex chars = 64 bits collision space). */
const ID_HASH_LENGTH = 16;
/**
 * Parse `learnings.md` content into discrete learning entries.
 *
 * The expected format produced by `ProjectMemoryService.recordLearning` is:
 *
 * ```
 * ## YYYY-MM-DD
 *
 * ### [role/agentId] HH:MM:SS
 * <content>
 *
 * ---
 * ```
 *
 * Older entries may lack the `### [role/agentId]` header — those are still
 * parsed, with `author = 'unknown'` and time defaulting to `00:00:00`. Entries
 * containing only the file-level header (`# Project Learnings`) are skipped.
 *
 * Exported for testing only — callers should use {@link LearningsIndexService}.
 *
 * @param raw - Full file content (YAML frontmatter is stripped if present)
 * @returns Parsed entries in file order
 */
export function parseLearnings(raw) {
    // Strip YAML frontmatter (if present) so the first `---` after it doesn't
    // get treated as a section separator.
    let body = raw;
    if (body.startsWith('---')) {
        const end = body.indexOf('\n---', 3);
        if (end !== -1) {
            body = body.slice(end + 4);
        }
    }
    // Sections are separated by a markdown horizontal rule (a line containing
    // only `---`). The recordLearning writer always emits this between entries.
    const sections = body
        .split(/^---\s*$/m)
        .map((s) => s.trim())
        .filter((s) => s.length > 0);
    const entries = [];
    for (const section of sections) {
        // Skip file-level headers (`# Project Learnings: ...`).
        if (/^#\s+(Project Learnings|Learnings from)/i.test(section)) {
            continue;
        }
        const dateMatch = section.match(/^##\s+(\d{4}-\d{2}-\d{2})/m);
        const date = dateMatch?.[1] ?? '';
        const agentMatch = section.match(/^###\s+\[([^\]]+)\]\s+(\d{2}:\d{2}:\d{2})/m);
        const author = agentMatch?.[1] ?? 'unknown';
        const time = agentMatch?.[2] ?? '00:00:00';
        // ISO timestamp: combine date + time as UTC. If date is missing (truly
        // malformed), fall back to epoch so the entry still surfaces but sorts
        // last. This is intentionally permissive: better to show a learning with
        // a wrong-looking timestamp than to drop it silently.
        const createdAt = date ? `${date}T${time}.000Z` : new Date(0).toISOString();
        // Strip the headers we already parsed from the content body so they don't
        // appear duplicated in the synthesized document.
        const content = section
            .replace(/^##\s+\d{4}-\d{2}-\d{2}\s*$/m, '')
            .replace(/^###\s+\[[^\]]+\]\s+\d{2}:\d{2}:\d{2}\s*$/m, '')
            .trim();
        if (!content) {
            continue;
        }
        // Title selection priority:
        //   1. Karpathy-lite `[[Entity]]` opener — entity name as title.
        //   2. First sentence/line of the body, capped at MAX_TITLE_LENGTH.
        const entityMatch = content.match(/^\[\[([^\]]+)\]\]/);
        let title;
        if (entityMatch) {
            title = entityMatch[1].trim().slice(0, MAX_TITLE_LENGTH);
        }
        else {
            const firstSentence = content.split(/[.\n]/)[0].trim();
            title = firstSentence.slice(0, MAX_TITLE_LENGTH) || 'Learning';
        }
        // Tags: 'learning' plus any `[tag-name]` markers in the body. Matches the
        // existing convention used by record-learning callers (e.g. `[best-practice]`,
        // `[gotcha]`, `[architecture-decision]`).
        const tags = ['learning'];
        const tagPattern = /\[([a-z][a-z0-9-]{0,49})\]/g;
        let tagMatch;
        while ((tagMatch = tagPattern.exec(content)) !== null) {
            const tag = tagMatch[1];
            if (!tags.includes(tag) && tags.length < KNOWLEDGE_CONSTANTS.MAX_TAGS) {
                tags.push(tag);
            }
        }
        // Stable synthetic ID. We hash (timestamp + author + content prefix) so
        // that re-parsing the same file produces the same IDs, but two entries
        // written at the same second by the same author with different content
        // get distinct IDs. SHA-1 + 16 hex chars is sufficient (64 bits) for the
        // append-only log scale.
        const idHash = createHash('sha1')
            .update(`${createdAt}|${author}|${content.slice(0, 200)}`)
            .digest('hex')
            .slice(0, ID_HASH_LENGTH);
        entries.push({
            id: `${LEARNING_ID_PREFIX}${idHash}`,
            title,
            tags,
            content,
            createdAt,
            updatedAt: createdAt,
            author,
        });
    }
    return entries;
}
/**
 * Service that surfaces `learnings.md` entries as virtual knowledge documents.
 *
 * Singleton, file-system-backed, no caching — `learnings.md` is small enough
 * (a single project's append-only log) that re-parsing on every list call is
 * acceptable and avoids stale-cache bugs across writes from the agent skill.
 */
export class LearningsIndexService {
    static instance = null;
    logger;
    constructor() {
        this.logger = LoggerService.getInstance().createComponentLogger('LearningsIndexService');
    }
    /**
     * Get the singleton instance.
     *
     * @returns LearningsIndexService instance
     */
    static getInstance() {
        if (!LearningsIndexService.instance) {
            LearningsIndexService.instance = new LearningsIndexService();
        }
        return LearningsIndexService.instance;
    }
    /**
     * Reset the singleton (for testing).
     */
    static resetInstance() {
        LearningsIndexService.instance = null;
    }
    /**
     * Detect whether an ID belongs to this service's synthetic namespace.
     *
     * @param id - Document ID to test
     * @returns True if the ID has the learning prefix
     */
    static isLearningId(id) {
        return typeof id === 'string' && id.startsWith(LEARNING_ID_PREFIX);
    }
    /**
     * Resolve the absolute path to `learnings.md` for the given scope.
     *
     * Learnings only exist at project scope today (the `record-learning` skill
     * always requires a `projectPath`). Global scope returns null so callers
     * can short-circuit cleanly.
     *
     * @param scope - 'global' or 'project'
     * @param projectPath - Required when scope is 'project'
     * @returns Absolute file path, or null if learnings cannot exist for this scope
     */
    learningsPath(scope, projectPath) {
        if (scope !== 'project' || !projectPath) {
            return null;
        }
        return path.join(projectPath, CREWLY_CONSTANTS.PATHS.CREWLY_HOME, MEMORY_CONSTANTS.PATHS.KNOWLEDGE_DIR, MEMORY_CONSTANTS.PROJECT_FILES.LEARNINGS);
    }
    /**
     * Read and parse `learnings.md` for the given scope.
     *
     * Missing-file and read-error cases both return an empty array — the
     * indexer must never fail callers just because no learnings exist yet.
     *
     * @param scope - Document scope
     * @param projectPath - Project path (required for 'project' scope)
     * @returns Parsed learning entries (empty if file missing or unreadable)
     */
    async readLearnings(scope, projectPath) {
        const filePath = this.learningsPath(scope, projectPath);
        if (!filePath || !existsSync(filePath)) {
            return [];
        }
        try {
            const raw = await fs.readFile(filePath, 'utf-8');
            return parseLearnings(raw);
        }
        catch (error) {
            this.logger.warn('Failed to read learnings.md', {
                path: filePath,
                error: error instanceof Error ? error.message : String(error),
            });
            return [];
        }
    }
    /**
     * List all learnings for the given scope as `KnowledgeDocumentSummary` entries.
     *
     * Sorted newest-first so the UI shows recent learnings at the top of the
     * unified knowledge list (matching the user mental model that learnings
     * are a chronological journal).
     *
     * @param scope - 'global' or 'project'
     * @param projectPath - Required when scope is 'project'
     * @returns Array of summaries (empty if scope is 'global' or no learnings exist)
     */
    async listLearnings(scope, projectPath) {
        const parsed = await this.readLearnings(scope, projectPath);
        return parsed
            .map((e) => this.toSummary(e, scope))
            .sort((a, b) => b.createdAt.localeCompare(a.createdAt));
    }
    /**
     * Fetch a single learning by its synthetic ID.
     *
     * @param id - Synthetic document ID (must start with `learning:`)
     * @param scope - Document scope
     * @param projectPath - Required when scope is 'project'
     * @returns Full document, or null if id is not a learning id or not found
     */
    async getLearning(id, scope, projectPath) {
        if (!LearningsIndexService.isLearningId(id)) {
            return null;
        }
        const parsed = await this.readLearnings(scope, projectPath);
        const found = parsed.find((e) => e.id === id);
        return found ? this.toDocument(found, scope, projectPath) : null;
    }
    /**
     * Project a parsed entry into a `KnowledgeDocumentSummary`.
     *
     * @param e - Parsed learning entry
     * @param scope - Target scope label
     * @returns Summary suitable for the knowledge list API
     */
    toSummary(e, scope) {
        return {
            id: e.id,
            title: e.title,
            category: LEARNINGS_CATEGORY,
            tags: e.tags,
            preview: e.content.slice(0, KNOWLEDGE_CONSTANTS.PREVIEW_LENGTH),
            scope,
            createdBy: e.author,
            updatedBy: e.author,
            createdAt: e.createdAt,
            updatedAt: e.updatedAt,
        };
    }
    /**
     * Project a parsed entry into a full `KnowledgeDocument`.
     *
     * @param e - Parsed learning entry
     * @param scope - Target scope label
     * @param projectPath - Carried into the document for project-scoped entries
     * @returns Full document suitable for the knowledge document API
     */
    toDocument(e, scope, projectPath) {
        return {
            id: e.id,
            title: e.title,
            category: LEARNINGS_CATEGORY,
            tags: e.tags,
            content: e.content,
            scope,
            projectPath: scope === 'project' ? projectPath : undefined,
            createdBy: e.author,
            updatedBy: e.author,
            createdAt: e.createdAt,
            updatedAt: e.updatedAt,
        };
    }
}
//# sourceMappingURL=learnings-index.service.js.map