/**
 * Skill-centric 聚合 — 顶级实体从"报告"翻成"skill",每个 skill 在 4 类报告
 * (doctor / eval-评分 / eval-功能 / observe)上各取最新一份做 status 摘要,
 * 给 studio 列表页用。
 *
 * 当前 v1 范围:
 *   - eval 报告(reportsDir):按 treatment variant 名分桶,跳过 baseline 保留字。
 *     评分视角(verdict / 综合分)和功能视角(pass/fail/tripwire 计数)挤一份 snapshot,
 *     因为同一份 EvaluationReport 同时驱动两个 view tab。
 *   - observe 报告(analysesDir, SkillHealthReport):data.bySkill[name] 每个键作为
 *     一个 skill 的 observe snapshot,取最新 generatedAt。
 *   - doctor:暂无独立持久化路径(omk doctor --html 写指定路径,默认不存盘),
 *     该字段保留 null,渲染层显示"未独立运行 omk doctor"。后续 commit 加默认
 *     ~/.oh-my-knowledge/doctors/ 持久化路径再回填。
 *
 * 综合 band:eval / observe 任一红 → 红,任一黄 → 黄,全绿 → 绿,皆未跑 → gray。
 */
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { join, isAbsolute, basename, dirname } from 'node:path';
import { computeVerdict } from '../eval-core/verdict.js';
import { detectInsights } from './skill-insights.js';
import { DEFAULT_OBSERVATIONS_DIR, loadLatestObservationInboxReports } from '../observability/inbox.js';
import { buildStudioDiagnosisSummary, mergeDiagnosisBundles } from '../diagnosis/studio-projection.js';
let _indexCache = null;
/**
 * Sync 版 dir-content fingerprint helper,仿 `src/server/report-store.ts:80-92`
 * 的 async `computeListFingerprint` — 把目录本身的 mtime 跟目录下每个 `.json`
 * 文件的 "filename:mtimeMs:size" 三元组排序拼接成 stable 字符串作为 dir-level
 * 的 content-aware fingerprint。
 *
 * - 目录下任何 .json 文件**新增 / 删除 / 重命名** → 目录 mtime 跟着变,且
 *   sorted-filenames 列表变,字符串变 → cache invalidate。
 * - 任何**已有同名 .json 文件被外部进程原地覆写内容** → 该文件自己的 mtimeMs
 *   跟通常情况下 size 都变(byte 长度跟内容相关),字符串里那一 entry 的后缀
 *   变,整体字符串变 → cache invalidate。这是 pre-fix 的 fingerprint(只看
 *   dir mtime+文件数)漏掉的信号(reviewer 2026-05-11 P2-a)。
 * - 单个文件 stat fail(临时权限错或 race condition 删除)用 `<filename>:?`
 *   占位字面,跟 report-store.ts 那一侧的 sentinel 字面对齐。整个 dir-stat
 *   fail(目录不存在 / 权限错)返回固定 sentinel "missing",这样"目录持续不存
 *   在"的多次连续调用 fingerprint 字符串稳定一致 cache 复用合理(行为跟旧
 *   helper 在两个 try 的 catch 分支都返 0 拼接出"0-0"的稳定 sentinel 等价)。
 */
function safeDirJsonContentFingerprint(dir) {
    // 加入 dir 路径本身,确保不同路径即使 mtime/file-list 相同也会产生不同 fingerprint
    let dirMtimeMs;
    let jsonFiles;
    try {
        dirMtimeMs = statSync(dir).mtimeMs;
        jsonFiles = readdirSync(dir).filter((f) => f.endsWith('.json')).sort();
    }
    catch {
        return `missing:${dir}`;
    }
    const fileParts = jsonFiles.map((f) => {
        try {
            const fStat = statSync(join(dir, f));
            return `${f}:${fStat.mtimeMs}:${fStat.size}`;
        }
        catch {
            return `${f}:?`;
        }
    });
    return `${dir}|${dirMtimeMs}|${fileParts.join(',')}`;
}
function buildIndexFingerprint(reports, analysesDir, doctorsDir, observationsDir) {
    // `d:` 跟 `o:` 前缀("d for doctors / o for observability analyses")沿用 pre-fix
    // 字面格式,避免外部 logging / debug 时 grep fingerprint 字符串的格式漂移。
    // 每一段的 right-hand-side 从旧的 "{dir-mtime}-{file-count}" 双标量升级成
    // safeDirJsonContentFingerprint 返回的 "{dir-mtime}|{file1}:{m}:{s},..."
    // content-aware 字符串。
    const reportIds = reports.map((r) => `${r.id}:${r.meta?.timestamp ?? ''}:${r.kind === 'evaluation' ? r.meta.evolve?.skillName ?? '' : ''}`).join(',');
    const doctorsFp = safeDirJsonContentFingerprint(doctorsDir);
    const analysesFp = safeDirJsonContentFingerprint(analysesDir);
    const observationsFp = safeDirJsonContentFingerprint(observationsDir);
    return `${reportIds}|d:${doctorsFp}|o:${analysesFp}|obs:${observationsFp}`;
}
/** 测试 / 调试用:强制清掉 in-process skill-index 缓存。 */
export function _resetSkillIndexCache() {
    _indexCache = null;
}
function isEvolveRoundVariant(report, variant) {
    return report.id.startsWith('evolve-') && /^round-\d+$/.test(variant);
}
function skillNameForEvalVariant(report, variant) {
    if (isEvolveRoundVariant(report, variant))
        return report.meta.evolve?.skillName ?? null;
    if (variant === 'baseline')
        return null;
    if (isAbsolute(variant) || variant.startsWith('~') || variant.includes('/')) {
        const base = basename(variant);
        return /^SKILL\.md$/i.test(base) ? basename(dirname(variant)) : base.replace(/\.md$/i, '');
    }
    return variant;
}
function sampleAllPassed(details) {
    if (!details || details.length === 0)
        return true;
    return details.every((d) => d.passed);
}
function buildEvalSnapshot(report, variant) {
    const summary = report.summary?.[variant];
    if (!summary)
        return null;
    let pass = 0;
    let fail = 0;
    let tripwire = 0;
    for (const r of report.results) {
        const vr = r.variants?.[variant];
        if (!vr)
            continue;
        const allPassed = sampleAllPassed(vr.assertions?.details);
        const isTripwire = report.sampleSnapshots?.[r.sample_id]?.tripwire === true
            || (vr.diagnostic?.rootCause || []).includes('tripwire_intentional');
        if (allPassed)
            pass += 1;
        else if (isTripwire)
            tripwire += 1;
        else
            fail += 1;
    }
    // verdict 是运行期计算的,不进 report,这里现算。
    // multi-treatment 报告:computeVerdict 顶层 level 是 worst-of perPair,headline 也是
    // worst pair 的描述;我们要的是"当前这个 variant 对应的 pair"。优先从 perPair 找,
    // 找不到再 fallback report-level(单 treatment 时 perPair 也只有一条,等价)。
    let verdictLevel = 'unknown';
    let verdictHeadline = '';
    try {
        const v = computeVerdict(report);
        const myPair = v.perPair?.find((p) => p.treatment === variant);
        if (myPair) {
            verdictLevel = myPair.level;
            verdictHeadline = myPair.headline;
        }
        else {
            verdictLevel = v.level;
            verdictHeadline = v.headline;
        }
    }
    catch { /* 单 variant 报告 / control 缺失等情况 — 留 unknown 默认 */ }
    return {
        reportId: report.id,
        timestamp: report.meta.timestamp || '',
        variantName: variant,
        verdictLevel,
        verdictHeadline,
        compositeScore: summary.avgCompositeScore ?? null,
        passCount: pass,
        failCount: fail,
        tripwireCount: tripwire,
        totalSamples: pass + fail + tripwire,
    };
}
/**
 * Per-skill 健康色带。**这跟 skill-health-analyzer 的 overall.healthBand 是两个视角**:
 *   - overall.healthBand(skill-health-analyzer.ts:healthBandOf):只看 weightedGapRate,
 *     回答"用户问题是否被知识库覆盖"(对外能力,跨 skill 平均)
 *   - PER_SKILL_BAND(本函数):综合 toolFailureRate + gap,回答"这个 skill 自身跑得稳不稳"
 *     (skill 内视角,失败工具调用 + 缺知识都算)
 * 不要把两边合并 — 语义不同,跨版本可比性也独立维护。
 *
 * 调整阈值会让历史 observe report 的 band 重新分类,UI 颜色变;
 * **不算 BREAKING-COMPARABILITY**(只影响 Studio 列表视觉,不动 grading/judge 输出)。
 */
const PER_SKILL_BAND_RED_FAILURE_RATE = 0.4; // 工具失败率 ≥ 40% → red
const PER_SKILL_BAND_YELLOW_FAILURE_RATE = 0.2; // ≥ 20% → yellow
const PER_SKILL_BAND_YELLOW_GAP_RATE = 0.3; // 加权 gap ≥ 30% → yellow
function bandFromObserveHealth(h) {
    if (h.toolFailureRate >= PER_SKILL_BAND_RED_FAILURE_RATE)
        return 'red';
    const gap = h.gap?.weightedGapRate ?? 0;
    if (gap >= PER_SKILL_BAND_YELLOW_GAP_RATE || h.toolFailureRate >= PER_SKILL_BAND_YELLOW_FAILURE_RATE)
        return 'yellow';
    return 'green';
}
function combineBand(doctor, evalSnap, _observe) {
    if (!doctor && !evalSnap)
        return 'gray';
    // doctor band: status fail → red, warn → yellow, pass → green
    const doctorBand = !doctor
        ? 'gray'
        : doctor.status === 'fail' ? 'red'
            : doctor.status === 'warn' ? 'yellow'
                : 'green';
    // eval band:按综合分估色,跟列表/详情页显示的 4.x/5 分数口径一致。
    const evalScore = evalSnap?.compositeScore ?? null;
    const evalBand = !evalSnap || evalScore == null
        ? 'gray'
        : evalScore < 2.5 ? 'red'
            : evalScore < 3.5 ? 'yellow'
                : 'green';
    if (doctorBand === 'red' || evalBand === 'red')
        return 'red';
    if (doctorBand === 'yellow' || evalBand === 'yellow')
        return 'yellow';
    if (doctorBand === 'green' || evalBand === 'green')
        return 'green';
    return 'gray';
}
function latestActivityTs(e) {
    const candidates = [e.doctor?.timestamp, e.eval?.timestamp, e.observe?.generatedAt]
        .filter((s) => Boolean(s));
    return candidates.sort().pop() || '';
}
function evalSnapshotSortKey(s) {
    const m = /^round-(\d+)$/.exec(s.variantName);
    const round = m ? Number(m[1]) : -1;
    return `${s.timestamp}#${String(round).padStart(8, '0')}`;
}
function latestEvalSnapshot(list) {
    if (list.length === 0)
        return null;
    return list[list.length - 1];
}
/** 扫 doctorsDir/*.json,按 skill 名分桶,**返回该 skill 的所有历史 snapshot**(asc 时序)。
 *  renderer 用最后一项做"当前",前面项画 sparkline。 */
function scanDoctorReports(dir) {
    const out = {};
    if (!existsSync(dir))
        return out;
    for (const file of readdirSync(dir)) {
        if (!file.endsWith('.json'))
            continue;
        try {
            const data = JSON.parse(readFileSync(join(dir, file), 'utf-8'));
            if (data?.kind !== 'doctor' || !Array.isArray(data.skills))
                continue;
            const ts = data.timestamp;
            for (const sr of data.skills) {
                const passN = sr.results.filter((r) => r.status === 'pass').length;
                const warnN = sr.results.filter((r) => r.status === 'warn').length;
                const failN = sr.results.filter((r) => r.status === 'fail').length;
                const snap = {
                    reportId: data.id, timestamp: ts, status: sr.status,
                    passCount: passN, warnCount: warnN, failCount: failN, results: sr.results,
                };
                if (!out[sr.skillName])
                    out[sr.skillName] = [];
                out[sr.skillName].push(snap);
            }
        }
        catch { /* skip corrupt */ }
    }
    for (const list of Object.values(out))
        list.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
    return out;
}
/**
 * 扫 reports + analyses 目录,按 skill 名聚合。reports 通过 reportStore.list() 传入
 * (避免重复读盘 + 避免再写一份 list 逻辑);analysesDir 直接扫。
 *
 * 同 skill 多份报告时取 timestamp 最新的一份。
 */
export function buildSkillIndex(reports, analysesDir, doctorsDir, observationsDir = DEFAULT_OBSERVATIONS_DIR) {
    // 命中缓存就直接返回(fingerprint 覆盖 reports + 两个 dir 的变化信号)
    const fp = buildIndexFingerprint(reports, analysesDir, doctorsDir, observationsDir);
    if (_indexCache && _indexCache.fingerprint === fp) {
        return _indexCache.result;
    }
    // ── eval 聚合(历史 list)─────────────────────────────────
    const evalBy = {};
    for (const r of reports) {
        if (r.kind !== 'evaluation')
            continue;
        const variants = r.meta.variants || [];
        for (const v of variants) {
            const skillName = skillNameForEvalVariant(r, v);
            if (!skillName)
                continue;
            const snap = buildEvalSnapshot(r, v);
            if (!snap)
                continue;
            if (!evalBy[skillName])
                evalBy[skillName] = [];
            evalBy[skillName].push(snap);
        }
    }
    for (const list of Object.values(evalBy))
        list.sort((a, b) => evalSnapshotSortKey(a).localeCompare(evalSnapshotSortKey(b)));
    // ── observe 聚合(历史 list)──────────────────────────────
    const observeBy = {};
    if (existsSync(analysesDir)) {
        for (const file of readdirSync(analysesDir)) {
            if (!file.endsWith('.json'))
                continue;
            try {
                const data = JSON.parse(readFileSync(join(analysesDir, file), 'utf-8'));
                if (!data?.bySkill || !data.meta)
                    continue;
                const generatedAt = data.meta.generatedAt;
                const id = file.replace(/\.json$/, '');
                for (const [skill, h] of Object.entries(data.bySkill)) {
                    const snap = {
                        analysisId: id, generatedAt,
                        healthBand: bandFromObserveHealth(h),
                        failureRate: h.toolFailureRate,
                        segmentCount: h.segmentCount,
                        gapRate: h.gap?.weightedGapRate ?? 0,
                    };
                    if (!observeBy[skill])
                        observeBy[skill] = [];
                    observeBy[skill].push(snap);
                }
            }
            catch { /* skip corrupt */ }
        }
    }
    for (const list of Object.values(observeBy))
        list.sort((a, b) => a.generatedAt.localeCompare(b.generatedAt));
    // ── doctor 聚合(历史 list)──────────────────────────────
    const doctorBy = scanDoctorReports(doctorsDir);
    const diagnosisBundle = mergeDiagnosisBundles(loadLatestObservationInboxReports(observationsDir).flatMap((report) => report.diagnostics ? [report.diagnostics] : []), new Date().toISOString());
    // ── 合并 ──────────────────────────────────────────────────
    const allSkills = new Set([
        ...Object.keys(evalBy), ...Object.keys(observeBy), ...Object.keys(doctorBy), ...Object.keys(diagnosisBundle.bySkill),
    ]);
    const entries = [];
    for (const name of allSkills) {
        const doctorHistory = doctorBy[name] ?? [];
        const evalHistory = evalBy[name] ?? [];
        const observeHistory = observeBy[name] ?? [];
        const doctor = doctorHistory.length > 0 ? doctorHistory[doctorHistory.length - 1] : null;
        const evalSnap = latestEvalSnapshot(evalHistory);
        const observe = observeHistory.length > 0 ? observeHistory[observeHistory.length - 1] : null;
        entries.push({
            skillName: name,
            doctor, eval: evalSnap, observe,
            doctorHistory, evalHistory, observeHistory,
            band: combineBand(doctor, evalSnap, observe),
        });
    }
    entries.sort((a, b) => latestActivityTs(b).localeCompare(latestActivityTs(a)));
    // ── summary ───────────────────────────────────────────────
    const summary = {
        totalSkills: entries.length,
        withEval: entries.filter((e) => e.eval).length,
        withObserve: entries.filter((e) => e.observe).length,
        withDoctor: entries.filter((e) => e.doctor).length,
        red: entries.filter((e) => e.band === 'red').length,
        yellow: entries.filter((e) => e.band === 'yellow').length,
        green: entries.filter((e) => e.band === 'green').length,
        gray: entries.filter((e) => e.band === 'gray').length,
    };
    // 跟 SkillIndex 一起算 insightsBySkill,享受同一份 fingerprint 缓存。
    // list 页对每个 entry 跑 detectInsights 的 CPU 开销迁移到这里,只 miss 时算一次。
    const insightsBySkill = new Map();
    for (const ent of entries) {
        const evalReport = ent.eval ? reports.find((r) => r.id === ent.eval.reportId && r.kind === 'evaluation') : undefined;
        insightsBySkill.set(ent.skillName, detectInsights(ent, evalReport ?? null, {
            diagnostics: diagnosisBundle.bySkill[ent.skillName] ?? [],
        }));
    }
    // 跨层口径统一:三大 snapshot(doctor / eval / observe)都空但 Diagnosis / Insight 投影出
    // high / medium 信号的 skill,把 entry.band 从 gray 升级。否则 HTML renderer(assessHealth)
    // 会把卡片标红、API(/api/skills)却返回 band='gray' summary.gray+=1,renderNextSteps 又会
    // 追加「完全没报告」建议 —— 用户视角看到的是「红卡 + 待优化 N + 完全没报告」矛盾态。
    for (const ent of entries) {
        if (ent.band !== 'gray')
            continue;
        const ins = insightsBySkill.get(ent.skillName) ?? [];
        const hasHigh = ins.some((i) => i.severity === 'high');
        const hasMed = ins.some((i) => i.severity === 'medium');
        if (hasHigh)
            ent.band = 'red';
        else if (hasMed)
            ent.band = 'yellow';
    }
    summary.red = entries.filter((e) => e.band === 'red').length;
    summary.yellow = entries.filter((e) => e.band === 'yellow').length;
    summary.green = entries.filter((e) => e.band === 'green').length;
    summary.gray = entries.filter((e) => e.band === 'gray').length;
    const diagnosticsBySkill = new Map(Object.entries(diagnosisBundle.bySkill));
    const diagnosisSummary = buildStudioDiagnosisSummary(diagnosisBundle);
    const result = { entries, summary, insightsBySkill, diagnosticsBySkill, diagnosisSummary };
    _indexCache = { fingerprint: fp, result };
    return result;
}
/** 单 skill 详情查询(用于 /skills/&lt;name&gt; 详情页)— 复用 buildSkillIndex 的扫描结果。 */
export function getSkillEntry(idx, skillName) {
    return idx.entries.find((e) => e.skillName === skillName) ?? null;
}
//# sourceMappingURL=skill-index.js.map