/**
 * WikiBookkeepService — vault health metrics + consolidation signals.
 *
 * Per Steve's 2026-05-22 design point #5: "还要时不时让 agent 针对自己的
 * vault 进行 bookkeeping (可以根据存入的 md 数量来 trigger), 譬如过去 N
 * 天超过 X 个 md, 然后总结一下."
 *
 * This service produces the report the bookkeeping agent reads:
 *   - md count per top-level llm-curated subfolder
 *   - new mds in the last N days
 *   - filename clusters (likely-duplicates by Jaccard of token sets)
 *   - queue stats (so the agent sees pending items too)
 *
 * The agent uses this report (+ its LLM) to decide:
 *   - which pages to consolidate / dedupe (calls wiki-ingest with a new
 *     consolidated body + deletes the originals via a separate skill —
 *     deletion is OUT of scope for Phase 1; surface as proposals).
 *   - which topics deserve a summary "rollup" page.
 *
 * The service does NOT delete or rewrite anything. It reports.
 *
 * @module services/wiki/wiki-bookkeep.service
 */
import * as path from 'path';
import * as fs from 'fs/promises';
import { existsSync } from 'fs';
import { LoggerService } from '../core/logger.service.js';
import { SchemaLoaderService } from './schema-loader.service.js';
import { WikiQueueService } from './wiki-queue.service.js';
const DEFAULT_WINDOW_DAYS = 7;
const DEFAULT_MD_THRESHOLD = 10;
const MAX_PAGES_SCANNED = 1000;
/**
 * Vault health + bookkeeping report generator. Stateless; uses the
 * schema loader + queue services it composes.
 */
export class WikiBookkeepService {
    static instance = null;
    logger;
    schemaLoader;
    queue;
    constructor(schemaLoader, queue) {
        this.logger = LoggerService.getInstance().createComponentLogger('WikiBookkeep');
        this.schemaLoader = schemaLoader ?? new SchemaLoaderService();
        this.queue = queue ?? WikiQueueService.getInstance();
    }
    static getInstance() {
        if (!this.instance)
            this.instance = new WikiBookkeepService();
        return this.instance;
    }
    /** Test-only reset. */
    static _resetForTesting() {
        this.instance = null;
    }
    /**
     * Build the bookkeeping report. Caller (skill / cron / agent) decides
     * whether to act on `shouldFire`. The service never writes; it reports.
     */
    async generate(input) {
        if (!input.vaultPath || !path.isAbsolute(input.vaultPath)) {
            return {
                ok: false,
                reason: 'invalid_input',
                message: 'vaultPath must be absolute',
            };
        }
        if (!existsSync(input.vaultPath)) {
            return {
                ok: false,
                reason: 'vault_missing',
                message: `vault directory does not exist: ${input.vaultPath}`,
            };
        }
        let schema;
        try {
            schema = await this.schemaLoader.load(input.vaultPath);
        }
        catch (err) {
            return {
                ok: false,
                reason: 'schema_missing',
                message: err.message,
            };
        }
        const windowDays = input.windowDays ?? DEFAULT_WINDOW_DAYS;
        const threshold = input.threshold ?? DEFAULT_MD_THRESHOLD;
        const windowMs = windowDays * 24 * 3600 * 1000;
        const now = Date.now();
        const staleMs = 90 * 24 * 3600 * 1000;
        const pages = await this.collectPages(input.vaultPath, schema.llm_curated.map((l) => l.path));
        const totalMdCount = pages.length;
        let recentMdCount = 0;
        let staleCount = 0;
        const countsByFolder = {};
        for (const p of pages) {
            const mtime = Date.parse(p.modifiedAt);
            if (Number.isFinite(mtime)) {
                if (now - mtime <= windowMs)
                    recentMdCount++;
                if (now - mtime > staleMs)
                    staleCount++;
            }
            // Folder bucket: first directory segment under llm-curated/.
            const rel = p.path;
            const parts = rel.split('/').filter(Boolean);
            // rel looks like "llm-curated/<folder>/<page>.md" → bucket = "llm-curated/<folder>".
            if (parts.length >= 2) {
                const bucket = `${parts[0]}/${parts[1]}`;
                countsByFolder[bucket] = (countsByFolder[bucket] ?? 0) + 1;
            }
            else if (parts.length === 1) {
                const bucket = parts[0];
                countsByFolder[bucket] = (countsByFolder[bucket] ?? 0) + 1;
            }
        }
        const duplicateCandidates = this.findDuplicateClusters(pages);
        const queueStats = await this.queue.getStats(input.vaultPath);
        // Net-new is the robust fire signal. When a baseline is supplied, count
        // only pages added since it; otherwise fall back to the mtime window.
        // NOTE: duplicate clusters are surfaced in `recommendations` but no longer
        // force `shouldFire` — a standing set of filename clusters is not "new
        // activity", so OR-ing it in made every non-trivial vault fire forever.
        const netNewMdCount = input.baselineMdCount != null
            ? Math.max(0, totalMdCount - input.baselineMdCount)
            : recentMdCount;
        const shouldFire = netNewMdCount >= threshold;
        const recommendations = this.buildRecommendations({
            shouldFire,
            netNewMdCount,
            threshold,
            duplicates: duplicateCandidates,
            countsByFolder,
            staleCount,
            queueStats,
        });
        const report = {
            vault: {
                scope: schema.vault_scope,
                id: schema.vault_id,
                path: input.vaultPath,
            },
            generatedAt: new Date().toISOString(),
            windowDays,
            threshold,
            shouldFire,
            totalMdCount,
            recentMdCount,
            netNewMdCount,
            countsByFolder,
            duplicateCandidates,
            staleCount,
            queue: queueStats,
            recommendations,
        };
        this.logger.info('WikiBookkeep generated', {
            vault: input.vaultPath,
            totalMd: totalMdCount,
            recentMd: recentMdCount,
            netNewMd: netNewMdCount,
            baseline: input.baselineMdCount ?? null,
            duplicates: duplicateCandidates.length,
            shouldFire,
        });
        return { ok: true, report };
    }
    // ---------------------------------------------------------------------------
    async collectPages(vaultPath, llmCuratedFolders) {
        const pages = [];
        let scanned = 0;
        for (const folder of llmCuratedFolders) {
            const root = path.join(vaultPath, folder.replace(/[/\\]+$/, ''));
            if (!existsSync(root))
                continue;
            const stack = [root];
            while (stack.length > 0 && scanned < MAX_PAGES_SCANNED) {
                const current = stack.pop();
                let entries;
                try {
                    entries = await fs.readdir(current, { withFileTypes: true });
                }
                catch {
                    continue;
                }
                for (const entry of entries) {
                    if (entry.name.startsWith('.'))
                        continue;
                    const full = path.join(current, entry.name);
                    if (entry.isDirectory()) {
                        stack.push(full);
                        continue;
                    }
                    if (!entry.isFile() || !entry.name.endsWith('.md'))
                        continue;
                    scanned++;
                    try {
                        const stat = await fs.stat(full);
                        pages.push({
                            path: path.relative(vaultPath, full).replace(/\\/g, '/'),
                            bytes: stat.size,
                            modifiedAt: new Date(stat.mtimeMs).toISOString(),
                        });
                    }
                    catch {
                        // ignore
                    }
                }
            }
        }
        return pages;
    }
    /**
     * Find filename clusters by Jaccard over name tokens.
     * Two pages with ≥ 0.6 overlap on their non-trivial filename tokens
     * are flagged as potential duplicates. Token = anything ≥ 3 chars
     * after splitting on `[-_./]`, lowercased, minus date prefix.
     */
    findDuplicateClusters(pages) {
        if (pages.length < 2)
            return [];
        const tokens = pages.map((p) => ({
            path: p.path,
            tokens: this.tokenizeFilename(p.path),
        }));
        const used = new Set();
        const clusters = [];
        for (let i = 0; i < tokens.length; i++) {
            if (used.has(i))
                continue;
            const peers = [i];
            for (let j = i + 1; j < tokens.length; j++) {
                if (used.has(j))
                    continue;
                if (this.jaccard(tokens[i].tokens, tokens[j].tokens) >= 0.6) {
                    peers.push(j);
                }
            }
            if (peers.length >= 2) {
                peers.forEach((p) => used.add(p));
                const memberPaths = peers.map((p) => tokens[p].path);
                clusters.push({
                    basis: this.longestCommonPrefix(memberPaths),
                    pages: memberPaths,
                });
            }
        }
        return clusters;
    }
    tokenizeFilename(relPath) {
        const basename = relPath.split('/').pop()?.replace(/\.md$/, '') ?? '';
        // Strip ISO-date prefix (YYYY-MM-DD-)
        const withoutDate = basename.replace(/^\d{4}-\d{2}-\d{2}-/, '');
        const tokens = withoutDate
            .toLowerCase()
            .split(/[-_./\s]+/)
            .filter((t) => t.length >= 3);
        return new Set(tokens);
    }
    jaccard(a, b) {
        if (a.size === 0 && b.size === 0)
            return 0;
        let inter = 0;
        for (const x of a)
            if (b.has(x))
                inter++;
        const union = a.size + b.size - inter;
        return union === 0 ? 0 : inter / union;
    }
    longestCommonPrefix(paths) {
        if (paths.length === 0)
            return '';
        let prefix = paths[0];
        for (const p of paths.slice(1)) {
            while (p.indexOf(prefix) !== 0) {
                prefix = prefix.slice(0, -1);
                if (!prefix)
                    return '';
            }
        }
        return prefix;
    }
    buildRecommendations(args) {
        const recs = [];
        if (!args.shouldFire) {
            recs.push(`Quiet vault — only ${args.netNewMdCount} net-new md(s) since the last pass (threshold ${args.threshold}). No action required.`);
        }
        else {
            recs.push(`${args.netNewMdCount} net-new md(s) since the last pass — at or above threshold (${args.threshold}). Consider a consolidation pass.`);
        }
        if (args.duplicates.length > 0) {
            recs.push(`${args.duplicates.length} likely-duplicate cluster(s) detected. Review each with your LLM and call wiki-ingest with a consolidated body when you can merge.`);
            args.duplicates.slice(0, 3).forEach((c) => {
                recs.push(`  • cluster "${c.basis}": ${c.pages.join(', ')}`);
            });
        }
        if (args.queueStats.pending > 5) {
            recs.push(`${args.queueStats.pending} pending queue items. Drain via wiki-process-queue before doing other bookkeeping.`);
        }
        const folderEntries = Object.entries(args.countsByFolder).sort((a, b) => b[1] - a[1]);
        if (folderEntries.length > 0) {
            const top = folderEntries[0];
            recs.push(`Hottest folder: \`${top[0]}\` (${top[1]} pages). If it's grown >20 pages without a rollup, write a summary index page.`);
        }
        if (args.staleCount > 0) {
            recs.push(`${args.staleCount} page(s) untouched in 90+ days. Consider whether they're still authoritative — flag stale or archive.`);
        }
        return recs;
    }
}
//# sourceMappingURL=wiki-bookkeep.service.js.map