/**
 * ============================================================
 * Retrieval Evaluation Harness
 *
 * 골든셋(질문 → 기대 문서)으로 brain 검색 품질을 recall@k / MRR 로 *결정적으로* 측정한다.
 * 청킹·re-rank·embedding alpha 등 어떤 변경이 실제로 recall 을 올렸는지 숫자로 증명하기
 * 위한 토대 — 이게 있어야 RAG 개선이 "감(感)" 이 아니라 무결성 있는 엔지니어링이 된다.
 *
 * 의도적으로 LLM 을 쓰지 않는다 (재현 가능 + 무료 + CI 가능). LLM-as-Judge 기반의
 * faithfulness/answer-relevance 평가는 후속 단계에서 별도 하니스로 추가한다.
 *
 * 골든셋 위치: <brain>/.astra/eval/golden.jsonl  (한 줄당 JSON 1개)
 *   { "query": "RAG 청킹 전략 비교", "expected": ["문서 청킹 전략.md"], "note": "선택" }
 * `expected` 매칭은 대소문자 무시 + 경로 suffix 매칭이라 사용자가 파일명만 적어도 된다
 * (예: "문서 청킹 전략.md" 가 "10_Wiki/Topics/Topics_Rag/문서 청킹 전략.md" 에 매치).
 * ============================================================
 */

import * as fs from 'fs';
import * as path from 'path';

export const GOLDEN_REL_JSONL = path.join('.astra', 'eval', 'golden.jsonl');
export const GOLDEN_REL_JSON = path.join('.astra', 'eval', 'golden.json');

export interface GoldenEntry {
    query: string;
    /** 기대 문서 — 상대 경로 또는 파일명. 하나라도 top-k 에 들면 hit. */
    expected: string[];
    note?: string;
}

export interface PerQueryResult {
    query: string;
    expected: string[];
    /** 1-based rank of the first expected doc, or null if not in the returned ranking. */
    firstHitRank: number | null;
    /** k → 기대 문서가 top-k 안에 하나라도 있었는지. */
    hitAtK: Record<number, boolean>;
    /** 디버그용 — 검색이 실제로 반환한 상위 경로들. */
    topPaths: string[];
}

export interface EvalReport {
    ks: number[];
    total: number;
    /** k → recall@k (= hit-rate, 기대 문서가 top-k 에 든 질의 비율). */
    recallAtK: Record<number, number>;
    /** Mean Reciprocal Rank — 첫 hit 의 1/rank 평균. miss 는 0. */
    mrr: number;
    perQuery: PerQueryResult[];
}

/** 골든셋 작성 안내가 포함된 스캐폴드 템플릿 (jsonl — 주석 줄은 로더가 무시). */
export const GOLDEN_TEMPLATE = [
    '// Astra 검색 평가 골든셋. 한 줄당 JSON 1개. `//` 로 시작하는 줄과 빈 줄은 무시됩니다.',
    '// query: 실제로 던질 질문.  expected: 그 질문에 떠야 하는 문서(상대경로 또는 파일명) 목록.',
    '// 20~30개를 채우면 신뢰할 만한 baseline 이 됩니다. 예시 두 줄을 지우고 본인 두뇌에 맞게 작성하세요.',
    '{"query": "RAG 청킹 전략은 어떤 게 있나", "expected": ["문서 청킹 전략.md"]}',
    '{"query": "벡터 데이터베이스 어떤 걸 골라야 하나", "expected": ["벡터 데이터베이스 비교.md"]}',
    '',
].join('\n');

function normRel(p: string): string {
    return (p || '').replace(/\\/g, '/').trim().toLowerCase();
}

/** ranked 의 한 경로가 expected 항목과 매치되는지: 정확히 같거나, suffix(파일명만 적은 경우)거나. */
function pathMatches(rankedRel: string, expected: string): boolean {
    const a = normRel(rankedRel);
    const b = normRel(expected);
    if (!a || !b) return false;
    if (a === b) return true;
    // expected 가 파일명/부분 경로면 ranked 의 끝과 매치 (구분자 경계 존중).
    return a === b || a.endsWith('/' + b) || a.endsWith(b) && (a.length === b.length || a[a.length - b.length - 1] === '/');
}

/**
 * 골든셋 로드. jsonl 우선, 없으면 json 배열. 파일이 없으면 [] 반환 (호출자가 스캐폴드 안내).
 * 깨진 줄은 건너뛴다 (한 줄 오타가 전체 평가를 막지 않도록).
 */
export function loadGoldenSet(brainPath: string): { entries: GoldenEntry[]; sourcePath: string | null; parseErrors: number } {
    const jsonlPath = path.join(brainPath, GOLDEN_REL_JSONL);
    const jsonPath = path.join(brainPath, GOLDEN_REL_JSON);

    let raw = '';
    let sourcePath: string | null = null;
    if (fs.existsSync(jsonlPath)) {
        try { raw = fs.readFileSync(jsonlPath, 'utf8'); sourcePath = jsonlPath; } catch { /* fall through */ }
    }
    if (!sourcePath && fs.existsSync(jsonPath)) {
        try {
            const arr = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
            const entries = Array.isArray(arr) ? arr.filter(isValidEntry) : [];
            return { entries, sourcePath: jsonPath, parseErrors: 0 };
        } catch {
            return { entries: [], sourcePath: jsonPath, parseErrors: 1 };
        }
    }
    if (!sourcePath) return { entries: [], sourcePath: null, parseErrors: 0 };

    const entries: GoldenEntry[] = [];
    let parseErrors = 0;
    for (const line of raw.split(/\r?\n/)) {
        const t = line.trim();
        if (!t || t.startsWith('//') || t.startsWith('#')) continue;
        try {
            const obj = JSON.parse(t);
            if (isValidEntry(obj)) entries.push(obj);
            else parseErrors++;
        } catch {
            parseErrors++;
        }
    }
    return { entries, sourcePath, parseErrors };
}

function isValidEntry(o: any): o is GoldenEntry {
    return o && typeof o.query === 'string' && o.query.trim().length > 0
        && Array.isArray(o.expected) && o.expected.length > 0
        && o.expected.every((e: any) => typeof e === 'string');
}

/**
 * 평가 실행. `ranker` 는 한 질의에 대해 검색이 반환한 *상대 경로 랭킹(점수 내림차순)* 을
 * 돌려주는 함수다 (임베딩 배선은 호출자가 책임 → 이 모듈은 LLM/네트워크 의존 없이 순수).
 */
export async function runRetrievalEval(params: {
    entries: GoldenEntry[];
    ks: number[];
    ranker: (query: string) => Promise<string[]>;
}): Promise<EvalReport> {
    const ks = [...params.ks].sort((a, b) => a - b);
    const perQuery: PerQueryResult[] = [];

    for (const entry of params.entries) {
        let ranked: string[] = [];
        try {
            ranked = await params.ranker(entry.query);
        } catch {
            ranked = [];
        }
        let firstHitRank: number | null = null;
        for (let i = 0; i < ranked.length; i++) {
            if (entry.expected.some((exp) => pathMatches(ranked[i], exp))) {
                firstHitRank = i + 1;
                break;
            }
        }
        const hitAtK: Record<number, boolean> = {};
        for (const k of ks) hitAtK[k] = firstHitRank !== null && firstHitRank <= k;

        perQuery.push({
            query: entry.query,
            expected: entry.expected,
            firstHitRank,
            hitAtK,
            topPaths: ranked.slice(0, Math.max(...ks, 5)),
        });
    }

    const total = perQuery.length || 1;
    const recallAtK: Record<number, number> = {};
    for (const k of ks) {
        const hits = perQuery.filter((q) => q.hitAtK[k]).length;
        recallAtK[k] = hits / total;
    }
    const mrr = perQuery.reduce((sum, q) => sum + (q.firstHitRank ? 1 / q.firstHitRank : 0), 0) / total;

    return { ks, total: perQuery.length, recallAtK, mrr, perQuery };
}

/** 사람이 읽는 마크다운 리포트. baseline 비교를 위해 표 형태로. */
export function formatReportMarkdown(report: EvalReport, meta: { brainName: string; dateStr: string; embeddingModel: string; alpha: number; notes?: string }): string {
    const lines: string[] = [];
    lines.push(`# Astra 검색 평가 리포트`);
    lines.push('');
    lines.push(`- 두뇌: **${meta.brainName}**`);
    lines.push(`- 일시: ${meta.dateStr}`);
    lines.push(`- 임베딩: ${meta.embeddingModel || '(없음 — TF-IDF only)'}${meta.embeddingModel ? ` · alpha=${meta.alpha}` : ''}`);
    lines.push(`- 질의 수: ${report.total}`);
    if (meta.notes) lines.push(`- 메모: ${meta.notes}`);
    lines.push('');
    lines.push(`## 종합 지표`);
    lines.push('');
    lines.push(`| 지표 | 값 |`);
    lines.push(`|---|---|`);
    for (const k of report.ks) lines.push(`| recall@${k} | ${(report.recallAtK[k] * 100).toFixed(1)}% |`);
    lines.push(`| MRR | ${report.mrr.toFixed(3)} |`);
    lines.push('');
    lines.push(`> recall@k = 기대 문서가 상위 k개 안에 든 질의 비율. MRR = 첫 정답의 1/순위 평균 (1에 가까울수록 좋음).`);
    lines.push('');
    lines.push(`## 질의별 상세`);
    lines.push('');
    lines.push(`| # | 질의 | 첫 정답 순위 | top-k hit | 기대 문서 |`);
    lines.push(`|---|---|---|---|---|`);
    report.perQuery.forEach((q, i) => {
        const rank = q.firstHitRank ? `#${q.firstHitRank}` : '**miss**';
        const kHits = report.ks.map((k) => q.hitAtK[k] ? `@${k}✓` : `@${k}✗`).join(' ');
        const exp = q.expected.join(', ').replace(/\|/g, '\\|');
        const query = q.query.replace(/\|/g, '\\|').slice(0, 60);
        lines.push(`| ${i + 1} | ${query} | ${rank} | ${kHits} | ${exp} |`);
    });
    lines.push('');

    // miss 한 질의는 무엇이 떴는지 별도로 — 골든셋 수정 vs 엔진 개선을 가르는 진단.
    const misses = report.perQuery.filter((q) => q.firstHitRank === null);
    if (misses.length > 0) {
        lines.push(`## Miss 진단 (top 결과가 기대와 어긋난 질의)`);
        lines.push('');
        for (const q of misses) {
            lines.push(`- **${q.query}**`);
            lines.push(`  - 기대: ${q.expected.join(', ')}`);
            lines.push(`  - 실제 상위: ${q.topPaths.length ? q.topPaths.slice(0, 5).join(' · ') : '(검색 결과 없음)'}`);
        }
        lines.push('');
    }
    return lines.join('\n');
}
