/**
 * ============================================================
 * Long-Term Memory (장기 기억)
 * 
 * 사용자의 취향, 프로젝트 목표, 반복 규칙, 과거 결정 사항을 
 * 영구적으로 저장하고 관리합니다.
 * 저장 위치: {brainPath}/memory/long_term.json
 * ============================================================
 */

import * as fs from 'fs';
import * as path from 'path';
import * as crypto from 'crypto';
import { LongTermEntry, LongTermStore, LongTermCategory, MemoryContextResult } from './types';

export class LongTermMemory {
    private store: LongTermStore;
    private filePath: string;
    private dirty = false;
    /** Hard cap on retained entries — oldest are trimmed when exceeded. Default 100 (matches MemoryConfig.longTermMaxEntries). */
    private maxEntries: number;

    constructor(brainPath: string, maxEntries = 100) {
        const memoryDir = path.join(brainPath, 'memory');
        if (!fs.existsSync(memoryDir)) {
            fs.mkdirSync(memoryDir, { recursive: true });
        }
        this.filePath = path.join(memoryDir, 'long_term.json');
        this.maxEntries = maxEntries > 0 ? maxEntries : 100;
        this.store = this.load();
    }

    // ─── Persistence ───

    private load(): LongTermStore {
        try {
            if (fs.existsSync(this.filePath)) {
                const raw = fs.readFileSync(this.filePath, 'utf-8');
                return JSON.parse(raw) as LongTermStore;
            }
        } catch { /* start fresh */ }
        return { version: 1, entries: [], lastUpdated: Date.now() };
    }

    public save(): void {
        if (!this.dirty) return;
        try {
            this.store.lastUpdated = Date.now();
            fs.writeFileSync(this.filePath, JSON.stringify(this.store, null, 2), 'utf-8');
            this.dirty = false;
        } catch { /* silently fail — memory is not critical path */ }
    }

    // ─── CRUD ───

    public addEntry(
        category: LongTermCategory,
        content: string,
        source: string,
        confidence = 0.8,
        opts: { expiresAt?: number } = {},
    ): LongTermEntry {
        const entry: LongTermEntry = {
            id: crypto.randomUUID(),
            category,
            content: content.trim(),
            source,
            confidence,
            createdAt: Date.now(),
            lastReferencedAt: Date.now(),
            referenceCount: 0,
            ...(opts.expiresAt ? { expiresAt: opts.expiresAt } : {}),
        };
        this.store.entries.push(entry);
        // Enforce the retention cap — drop the oldest entries (by createdAt) once
        // over the limit. The store array is append-ordered, so the oldest are at
        // the front; we trim from there.
        if (this.store.entries.length > this.maxEntries) {
            this.store.entries.splice(0, this.store.entries.length - this.maxEntries);
        }
        this.dirty = true;
        this.save();
        return entry;
    }

    public removeEntry(id: string): boolean {
        const before = this.store.entries.length;
        this.store.entries = this.store.entries.filter((e) => e.id !== id);
        if (this.store.entries.length < before) {
            this.dirty = true;
            this.save();
            return true;
        }
        return false;
    }

    /** 만료된 entry 를 필터링한 활성 entries (검색·context build 가 보는 것). */
    private getActiveEntries(): LongTermEntry[] {
        const now = Date.now();
        return this.store.entries.filter((e) => !e.expiresAt || e.expiresAt > now);
    }

    public getAllEntries(opts: { includeExpired?: boolean } = {}): LongTermEntry[] {
        return opts.includeExpired ? [...this.store.entries] : this.getActiveEntries();
    }

    public getEntriesByCategory(category: LongTermCategory): LongTermEntry[] {
        return this.getActiveEntries().filter((e) => e.category === category);
    }

    /**
     * 기존 entry 의 expiresAt 갱신. id 또는 id prefix (8자 이상) 로 식별.
     * 반환: 갱신된 entry 또는 null (못 찾음).
     */
    public setExpiration(idOrPrefix: string, expiresAt: number): LongTermEntry | null {
        const match = this.store.entries.find((e) => e.id === idOrPrefix)
            || (idOrPrefix.length >= 8 ? this.store.entries.find((e) => e.id.startsWith(idOrPrefix)) : undefined);
        if (!match) return null;
        match.expiresAt = expiresAt;
        this.dirty = true;
        this.save();
        return match;
    }

    // ─── Context Building ───

    /**
     * 프롬프트와 관련성이 높은 Long-Term 기억을 반환합니다.
     */
    public buildContext(currentPrompt: string, maxEntries = 10): MemoryContextResult | null {
        // 만료된 entry 는 검색에서 자동 제외 — Temporal Markers 의 핵심.
        const activeEntries = this.getActiveEntries();
        if (activeEntries.length === 0) return null;

        const promptLower = currentPrompt.toLowerCase();
        const terms = promptLower
            .split(/[^a-z0-9가-힣_]+/g)
            .filter((t) => t.length >= 2);

        // Score entries by relevance to prompt
        const scored = activeEntries.map((entry) => {
            let score = 0;
            const contentLower = entry.content.toLowerCase();

            for (const term of terms) {
                if (contentLower.includes(term)) score += 2;
            }

            // Boost high-confidence and frequently referenced entries
            score += entry.confidence * 2;
            score += Math.min(entry.referenceCount * 0.5, 3);

            // Recency boost
            const daysSinceRef = (Date.now() - entry.lastReferencedAt) / (1000 * 60 * 60 * 24);
            if (daysSinceRef < 7) score += 1;

            return { entry, score };
        });

        const relevant = scored
            .filter((s) => s.score > 0)
            .sort((a, b) => b.score - a.score)
            .slice(0, maxEntries);

        if (relevant.length === 0) {
            // Still include all rules and goals even without prompt match — 만료 제외.
            const alwaysInclude = activeEntries
                .filter((e) => e.category === 'rule' || e.category === 'goal')
                .slice(0, 5);
            if (alwaysInclude.length === 0) return null;

            // 표시되는(=사용되는) 자동 추출 항목의 만료를 연장.
            const refreshAt = Date.now() + LongTermMemory.AUTO_EXTRACT_TTL_MS;
            for (const e of alwaysInclude) {
                if (e.expiresAt) { e.expiresAt = refreshAt; this.dirty = true; }
            }

            const content = alwaysInclude
                .map((e) => `- [${e.category}] ${e.content}`)
                .join('\n');

            return {
                layer: 'long-term',
                label: 'Long-Term Memory (사용자 규칙 & 목표)',
                content,
                relevance: 0.5
            };
        }

        // Mark as referenced — 자동 추출(만료 있음) 항목은 참조 시 만료를 슬라이딩 연장해
        // '쓰면 살아남고, 안 쓰면 TTL 뒤 소멸'. 영속(수동) 항목은 expiresAt 이 없어 무영향.
        const refreshAt = Date.now() + LongTermMemory.AUTO_EXTRACT_TTL_MS;
        for (const { entry } of relevant) {
            entry.lastReferencedAt = Date.now();
            entry.referenceCount++;
            if (entry.expiresAt) entry.expiresAt = refreshAt;
        }
        this.dirty = true;

        const content = relevant
            .map(({ entry }) => `- [${entry.category}] ${entry.content}`)
            .join('\n');

        return {
            layer: 'long-term',
            label: 'Long-Term Memory (사용자 취향 / 규칙 / 결정)',
            content,
            relevance: Math.min(relevant[0]?.score / 10 || 0.5, 1.0)
        };
    }

    // ─── Extraction Helpers ───

    /** 자동 추출 장기기억 기본 TTL (14일). 참조될 때마다 슬라이딩 연장된다. */
    public static readonly AUTO_EXTRACT_TTL_MS = 14 * 24 * 60 * 60 * 1000;

    /** 짧은 후보 문자열에 박힌 구체적 에러 시그니처(예외명/에러코드/스택 조각) 탐지. */
    private static readonly ERROR_NOISE = /\b(?:TypeError|ReferenceError|SyntaxError|RangeError|KeyError|ValueError|Exception|Traceback|ECONNREFUSED|ETIMEDOUT|ENOENT|EACCES|ECONNRESET|errno|npm ERR!)\b|error\s+TS\d|:\d+:\d+\)|File ".+", line \d/i;

    /**
     * 붙여넣은 에러 로그·스택 트레이스·실패 출력처럼 보이는 텍스트인지 *보수적으로* 추정.
     * 이런 입력은 '분석 대상'(휘발)이지 '지식'(영속)이 아니므로 장기 기억 채굴에서 제외한다.
     * 일반 산문이 'error' 를 한 번 언급한 정도로는 걸리지 않게 강한/약한 신호를 구분한다.
     */
    public static looksLikeErrorLog(text: string): boolean {
        if (!text) return false;
        const strong = [
            /Traceback \(most recent call last\)/,
            /^\s*at\s+.+\(.+:\d+:\d+\)/m,        // JS 스택 프레임
            /\bFile ".+", line \d+/,              // Python 프레임
            /npm ERR!/,
            /\b(?:TypeError|ReferenceError|SyntaxError|RangeError|KeyError|ValueError)\b/,
            /\b(?:ECONNREFUSED|ETIMEDOUT|ENOENT|EACCES|ECONNRESET)\b/,
            /error\s+TS\d{3,}/i,                  // tsc 에러
        ];
        if (strong.some((re) => re.test(text))) return true;
        const weak = (text.match(/\b(?:error|errors|exception|failed|failure|stacktrace|errno|fatal|panic|assertion)\b/gi) || []).length
            + (text.match(/\[(?:error|warn|fatal)\]/gi) || []).length;
        return weak >= 3 && text.split('\n').length >= 3;
    }

    /**
     * 대화 메시지에서 장기 기억 후보를 패턴 매칭으로 추출합니다.
     * LLM 호출 없이 동작합니다.
     */
    public static extractCandidates(
        messages: Array<{ role: string; content: string }>
    ): Array<{ category: LongTermCategory; content: string }> {
        const candidates: Array<{ category: LongTermCategory; content: string }> = [];

        const rulePatterns = [
            /(?:항상|언제나|무조건|반드시)\s+(.{5,80})/g,
            /(?:규칙|rule|원칙)[\s:：]+(.{5,120})/gi,
            /(?:앞으로는?|이후에는?|다음부터는?)\s+(.{5,80})/g
        ];

        const preferencePatterns = [
            /(?:난|나는|저는|제가)\s+(.{5,60})\s*(?:좋아|선호|원해|싫어|안 ?좋아)/g,
            /(?:prefer|always use|don't use|never use)\s+(.{5,80})/gi
        ];

        const goalPatterns = [
            /(?:목표|goal|방향|direction)[\s:：]+(.{5,120})/gi,
            /(?:최종\s*목표|궁극적으로|결국에는?)\s+(.{5,80})/g
        ];

        const decisionPatterns = [
            /(?:결정|decided|결론|conclusion)[\s:：]+(.{5,120})/gi,
            /(?:으로\s*하자|으로\s*가자|으로\s*결정|으로\s*확정)/g
        ];

        for (const msg of messages) {
            if (msg.role !== 'user') continue;
            const text = msg.content;
            // 에러 로그/스택 트레이스 덤프는 '분석 대상'(휘발)이므로 통째로 채굴 제외.
            if (LongTermMemory.looksLikeErrorLog(text)) continue;

            for (const pattern of rulePatterns) {
                pattern.lastIndex = 0;
                let match;
                while ((match = pattern.exec(text)) !== null) {
                    candidates.push({ category: 'rule', content: match[0].trim() });
                }
            }

            for (const pattern of preferencePatterns) {
                pattern.lastIndex = 0;
                let match;
                while ((match = pattern.exec(text)) !== null) {
                    candidates.push({ category: 'preference', content: match[0].trim() });
                }
            }

            for (const pattern of goalPatterns) {
                pattern.lastIndex = 0;
                let match;
                while ((match = pattern.exec(text)) !== null) {
                    candidates.push({ category: 'goal', content: match[0].trim() });
                }
            }

            for (const pattern of decisionPatterns) {
                pattern.lastIndex = 0;
                let match;
                while ((match = pattern.exec(text)) !== null) {
                    candidates.push({ category: 'decision', content: match[0].trim() });
                }
            }
        }

        // Deduplicate by content + 에러 시그니처가 박힌 후보 제거
        // ('goal: fix ECONNREFUSED ...' 같은 에러 내용이 지식으로 흡수되는 오염 방지).
        const seen = new Set<string>();
        return candidates.filter((c) => {
            if (LongTermMemory.ERROR_NOISE.test(c.content)) return false;
            const key = c.content.toLowerCase();
            if (seen.has(key)) return false;
            seen.add(key);
            return true;
        });
    }
}
