/**
 * MemoryStore — the agent's file-system-based memory.
 *
 * Covers two of Tulving's three memory systems:
 * - Semantic Memory: observation buffer (memories.json) + curated knowledge (MEMORY.md)
 * - Episodic Memory: conversation sessions (sessions/*.json)
 *
 * Procedural Memory (ROLE.md + skills) is managed by RoleLoader and the skill system.
 */
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, appendFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { createLogger, getTextContent, type LLMMessage, MEMORY_MD_SECTION_MAX_CHARS, MEMORY_MD_TOTAL_MAX_CHARS } from '@markus/shared';
import type { IMemoryStore, MemoryEntry, ConversationSession } from './types.js';

export type { MemoryEntry, ConversationSession, IMemoryStore } from './types.js';

const log = createLogger('memory-store');

const VALID_TYPES = new Set<string>(['conversation', 'fact', 'task_result', 'note']);

/** Reject objects that are clearly not MemoryEntry-shaped. */
function isValidEntry(raw: unknown): raw is Record<string, unknown> {
  if (typeof raw !== 'object' || raw === null) return false;
  const obj = raw as Record<string, unknown>;
  return typeof obj.id === 'string' && obj.id.length > 0;
}

/** Coerce fields to their expected types so downstream code never sees undefined. */
function sanitizeEntry(raw: Record<string, unknown> | MemoryEntry): MemoryEntry {
  const r = raw as Record<string, unknown>;
  return {
    id: String(r.id),
    timestamp: typeof r.timestamp === 'string' ? r.timestamp : new Date().toISOString(),
    type: (typeof r.type === 'string' && VALID_TYPES.has(r.type)
      ? r.type
      : 'note') as MemoryEntry['type'],
    content: typeof r.content === 'string' ? r.content : '',
    metadata: (typeof r.metadata === 'object' && r.metadata !== null)
      ? r.metadata as Record<string, unknown>
      : undefined,
  };
}

export class MemoryStore implements IMemoryStore {
  private static readonly MAX_SESSIONS_IN_MEMORY = 20;

  private dataDir: string;
  private entries: MemoryEntry[] = [];
  private sessions = new Map<string, ConversationSession>();
  private sessionAccessOrder: string[] = [];
  private sessionsDir: string;
  private logsDir: string;
  private saveDebounce: ReturnType<typeof setTimeout> | null = null;
  private longTermFile: string;

  constructor(dataDir: string) {
    this.dataDir = dataDir;
    this.sessionsDir = join(dataDir, 'sessions');
    this.logsDir = join(dataDir, 'daily-logs');
    this.longTermFile = join(dataDir, 'MEMORY.md');
    mkdirSync(this.dataDir, { recursive: true });
    mkdirSync(this.sessionsDir, { recursive: true });
    mkdirSync(this.logsDir, { recursive: true });
    this.loadFromDisk();
    this.loadSessionsFromDisk();
  }

  // --- Short-term: session messages ---

  addEntry(entry: MemoryEntry): void {
    this.entries.push(sanitizeEntry(entry));
    this.saveToDisk();
    log.debug('Memory entry added', { type: entry.type, id: entry.id });
  }

  getEntries(type?: MemoryEntry['type'], limit?: number): MemoryEntry[] {
    let result = type ? this.entries.filter((e) => e.type === type) : [...this.entries];
    if (limit) result = result.slice(-limit);
    return result;
  }

  getEntriesByTag(tag: string, limit?: number): MemoryEntry[] {
    const tagged = this.entries.filter(e =>
      Array.isArray(e.metadata?.tags) && (e.metadata!.tags as string[]).includes(tag)
    );
    return limit ? tagged.slice(-limit) : tagged;
  }

  search(query: string): MemoryEntry[] {
    const lower = query.toLowerCase();
    return this.entries.filter((e) => e.content.toLowerCase().includes(lower));
  }

  removeEntries(ids: string[]): number {
    const idSet = new Set(ids);
    const before = this.entries.length;
    this.entries = this.entries.filter(e => !idSet.has(e.id));
    const removed = before - this.entries.length;
    if (removed > 0) this.saveToDisk();
    return removed;
  }

  replaceEntries(removedIds: string[], newEntry: MemoryEntry): void {
    this.removeEntries(removedIds);
    this.entries.push(sanitizeEntry(newEntry));
    this.saveToDisk();
  }

  removeEntriesByTag(tag: string): number {
    const before = this.entries.length;
    this.entries = this.entries.filter(e => {
      const tags = Array.isArray(e.metadata?.tags) ? e.metadata!.tags as string[] : [];
      return !tags.includes(tag);
    });
    const removed = before - this.entries.length;
    if (removed > 0) this.saveToDisk();
    return removed;
  }

  getSession(sessionId: string): ConversationSession | undefined {
    let session = this.sessions.get(sessionId);
    if (!session) {
      session = this.tryLoadSessionFromDisk(sessionId);
      if (session) this.sessions.set(session.id, session);
    }
    if (session) this.touchSession(sessionId);
    return session;
  }

  listSessions(agentId?: string): ConversationSession[] {
    const all = [...this.sessions.values()];
    if (agentId) return all.filter((s) => s.agentId === agentId);
    return all;
  }

  getLatestSession(agentId: string): ConversationSession | undefined {
    const agentSessions = this.listSessions(agentId);
    if (agentSessions.length === 0) return undefined;
    return agentSessions.sort((a, b) =>
      new Date(b.lastActivityAt).getTime() - new Date(a.lastActivityAt).getTime()
    )[0];
  }

  createSession(agentId: string): ConversationSession {
    const session: ConversationSession = {
      id: `sess_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
      agentId,
      messages: [],
      startedAt: new Date().toISOString(),
      lastActivityAt: new Date().toISOString(),
    };
    this.sessions.set(session.id, session);
    this.touchSession(session.id);
    this.debouncedSaveSession(session);
    return session;
  }

  getOrCreateSession(agentId: string, sessionId: string): ConversationSession {
    let existing = this.sessions.get(sessionId);
    if (!existing) {
      existing = this.tryLoadSessionFromDisk(sessionId);
      if (existing) this.sessions.set(existing.id, existing);
    }
    if (existing) {
      this.touchSession(sessionId);
      return existing;
    }
    const session: ConversationSession = {
      id: sessionId,
      agentId,
      messages: [],
      startedAt: new Date().toISOString(),
      lastActivityAt: new Date().toISOString(),
    };
    this.sessions.set(sessionId, session);
    this.touchSession(sessionId);
    this.debouncedSaveSession(session);
    return session;
  }

  appendMessage(sessionId: string, message: LLMMessage): void {
    const session = this.sessions.get(sessionId);
    if (!session) throw new Error(`Session not found: ${sessionId}`);
    session.messages.push(message);
    session.lastActivityAt = new Date().toISOString();
    this.debouncedSaveSession(session);

    // Auto-compact when context gets large
    this.checkAndCompact(session);
  }

  getRecentMessages(sessionId: string, limit: number): LLMMessage[] {
    const session = this.sessions.get(sessionId);
    if (!session) return [];
    return session.messages.slice(-limit);
  }

  // --- Medium-term: daily conversation logs ---

  writeDailyLog(agentId: string, summary: string): void {
    const today = new Date().toISOString().slice(0, 10);
    const logFile = join(this.logsDir, `${today}.md`);
    const timestamp = new Date().toISOString().slice(11, 19);
    const entry = `\n## [${timestamp}] Agent: ${agentId}\n\n${summary}\n`;

    appendFileSync(logFile, entry);
    log.debug('Daily log entry written', { agentId, date: today });
  }

  getDailyLog(date?: string): string {
    const d = date ?? new Date().toISOString().slice(0, 10);
    const logFile = join(this.logsDir, `${d}.md`);
    if (!existsSync(logFile)) return '';
    return readFileSync(logFile, 'utf-8');
  }

  getRecentDailyLogs(days: number = 3): string {
    const logs: string[] = [];
    const now = new Date();
    for (let i = 0; i < days; i++) {
      const d = new Date(now.getTime() - i * 86_400_000).toISOString().slice(0, 10);
      const content = this.getDailyLog(d);
      if (content) logs.push(`# ${d}\n${content}`);
    }
    return logs.join('\n\n');
  }

  // --- Long-term: MEMORY.md ---

  addLongTermMemory(key: string, content: string): void {
    let truncatedContent = content;
    if (truncatedContent.length > MEMORY_MD_SECTION_MAX_CHARS) {
      log.warn('Section content exceeds limit, truncating', {
        key, original: content.length, limit: MEMORY_MD_SECTION_MAX_CHARS,
      });
      truncatedContent = truncatedContent.slice(0, MEMORY_MD_SECTION_MAX_CHARS);
    }

    let existing = '';
    if (existsSync(this.longTermFile)) {
      existing = readFileSync(this.longTermFile, 'utf-8');
    }

    const sectionHeader = `## ${key}`;
    try {
      let updated: string;
      if (existing.includes(sectionHeader)) {
        const regex = new RegExp(`(## ${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})\\n[\\s\\S]*?(?=\\n## |$)`);
        updated = existing.replace(regex, `${sectionHeader}\n${truncatedContent}\n`);
      } else {
        updated = existing + `\n${sectionHeader}\n${truncatedContent}\n`;
      }

      if (updated.length > MEMORY_MD_TOTAL_MAX_CHARS) {
        // Attempt auto-compression first before refusing the write
        log.warn('MEMORY.md total size exceeds limit, attempting compression', {
          key, fileSize: updated.length, limit: MEMORY_MD_TOTAL_MAX_CHARS,
        });
        const compressed = this.compressLongTermMemory();
        if (compressed.charsAfter < updated.length) {
          log.info('Compression freed space, retrying write', {
            key, charsFreed: updated.length - compressed.charsAfter,
          });
          // Re-read the freshly compressed file and retry
          existing = readFileSync(this.longTermFile, "utf-8");
          if (existing.includes(sectionHeader)) {
            const regex = new RegExp(`(## ${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})\\n[\\s\\S]*?(?=\\n## |$)`);
            updated = existing.replace(regex, `${sectionHeader}\n${truncatedContent}\n`);
          } else {
            updated = existing + `\n${sectionHeader}\n${truncatedContent}\n`;
          }
          if (updated.length > MEMORY_MD_TOTAL_MAX_CHARS) {
            log.warn('MEMORY.md still exceeds limit even after compression, refusing write', {
              key, fileSize: updated.length, limit: MEMORY_MD_TOTAL_MAX_CHARS,
            });
            return;
          }
        } else {
          log.warn('MEMORY.md still exceeds limit after compression, refusing write', {
            key, fileSize: updated.length, limit: MEMORY_MD_TOTAL_MAX_CHARS,
          });
          return;
        }
      }

      writeFileSync(this.longTermFile, updated);
      log.debug('Long-term memory updated', { key, sectionChars: truncatedContent.length, totalChars: updated.length });
    } catch (err) {
      log.warn('Failed to write long-term memory', { key, error: String(err) });
    }
  }

  getLongTermMemory(): string {
    if (!existsSync(this.longTermFile)) return '';
    return readFileSync(this.longTermFile, 'utf-8');
  }

  getLongTermMemoryExcluding(sections: string[]): string {
    const full = this.getLongTermMemory();
    if (!full || sections.length === 0) return full;

    let result = full;
    for (const section of sections) {
      const escaped = section.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
      result = result.replace(
        new RegExp(`\\n?## ${escaped}\\n[\\s\\S]*?(?=\\n## |$)`), ''
      );
    }
    return result.trim();
  }

  getLongTermSection(sectionName: string): string {
    const content = this.getLongTermMemory();
    if (!content) return '';
    const escaped = sectionName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
    const match = content.match(new RegExp(`## ${escaped}\\n([\\s\\S]*?)(?=\\n## |$)`));
    return match?.[1]?.trim() ?? '';
  }

  // --- Context compaction (OpenClawd pattern) ---

  compactSession(sessionId: string, keepLast: number = 20): { summary: string; flushedCount: number } {
    const session = this.sessions.get(sessionId);
    if (!session || session.messages.length <= keepLast) {
      return { summary: '', flushedCount: 0 };
    }

    const older = session.messages.slice(0, -keepLast);
    const flushedCount = older.length;

    const summary = this.buildHeuristicSummary(older);

    // No writeDailyLog here — compaction must be side-effect-free

    const facts = older
      .filter((m) => m.role === 'assistant' && getTextContent(m.content).length > 50)
      .map((m) => getTextContent(m.content).slice(0, 150))
      .slice(0, 3);

    if (facts.length > 0) {
      this.addEntry({
        id: `compact_${Date.now()}`,
        timestamp: new Date().toISOString(),
        type: 'conversation',
        content: facts.join('\n'),
        metadata: { sessionId, compactedMessages: flushedCount },
      });
    }

    const retained = session.messages.slice(-keepLast);

    // Inject a summary message so the model retains awareness of compacted history
    const summaryMessage: LLMMessage = {
      role: 'user',
      content: `[Conversation history summary — ${flushedCount} earlier messages were compacted]\n${summary}\n[End of summary. The conversation continues below with the most recent messages.]`,
    };
    session.messages = [summaryMessage, ...retained];
    this.saveSessionToDisk(session);

    log.info('Session compacted', { sessionId, flushedCount, remaining: session.messages.length });
    return { summary, flushedCount };
  }

  /**
   * Build a heuristic summary by extracting key lines from messages.
   * Used as the default (non-LLM) summarization strategy.
   */
  buildHeuristicSummary(messages: LLMMessage[]): string {
    const summaryParts: string[] = [];
    for (const msg of messages) {
      if (msg.role === 'system') continue;
      const text = getTextContent(msg.content);
      if (msg.role === 'user') {
        summaryParts.push(`User: ${text.slice(0, 200)}`);
      } else if (msg.role === 'assistant' && text) {
        summaryParts.push(`Assistant: ${text.slice(0, 200)}`);
      } else if (msg.role === 'tool') {
        summaryParts.push(`Tool result: ${text.slice(0, 100)}`);
      }
    }
    return summaryParts.join('\n').slice(0, 2000);
  }

  summarizeAndTruncate(sessionId: string, keepLast: number): LLMMessage[] {
    this.compactSession(sessionId, keepLast);
    const session = this.sessions.get(sessionId);
    return session?.messages ?? [];
  }

  // --- Disk persistence ---

  private checkAndCompact(session: ConversationSession): void {
    // Single storage-time compaction policy: trigger at 60 messages, keep 30.
    // Context-engine handles per-LLM-call compression separately.
    if (session.messages.length <= 60) return;

    let shrunk = 0;
    const recentBoundary = session.messages.length - 30;
    for (let i = 0; i < recentBoundary; i++) {
      const m = session.messages[i]!;
      const text = getTextContent(m.content);
      if (m.role === 'tool' && text.length > 20_000) {
        const origLen = text.length;
        const headSize = Math.min(1500, Math.floor(origLen * 0.3));
        const tailSize = Math.min(500, Math.floor(origLen * 0.1));
        const head = text.slice(0, headSize);
        const tail = text.slice(-tailSize);
        m.content = `[Tool result compacted: ${origLen} chars → ${headSize + tailSize} char preview]\n${head}\n[... ${origLen - headSize - tailSize} chars omitted ...]\n${tail}`;
        shrunk++;
      }
    }

    log.info('Auto-compacting session by count', {
      sessionId: session.id, messageCount: session.messages.length, shrunkToolResults: shrunk,
    });
    this.compactSession(session.id, 30);
  }

  private loadFromDisk(): void {
    const memFile = join(this.dataDir, 'memories.json');
    if (existsSync(memFile)) {
      try {
        const raw = JSON.parse(readFileSync(memFile, 'utf-8')) as unknown[];
        const before = raw.length;
        this.entries = raw.filter(isValidEntry).map(sanitizeEntry);
        if (this.entries.length < before) {
          log.warn(`Dropped ${before - this.entries.length} malformed memory entries on load`);
        }
        log.info(`Loaded ${this.entries.length} memory entries`);
      } catch {
        log.warn('Failed to load memories from disk, starting fresh');
        this.entries = [];
      }
    }
  }

  private loadSessionsFromDisk(): void {
    try {
      const files = readdirSync(this.sessionsDir).filter((f) => f.endsWith('.json'));
      if (files.length === 0) return;

      // Sort by mtime descending, only load the N most recent into memory
      const withMtime = files.map(f => {
        try {
          const stat = statSync(join(this.sessionsDir, f));
          return { f, mtime: stat.mtimeMs };
        } catch {
          return { f, mtime: 0 };
        }
      });
      withMtime.sort((a, b) => b.mtime - a.mtime);
      const toLoad = withMtime.slice(0, MemoryStore.MAX_SESSIONS_IN_MEMORY);

      for (const { f } of toLoad) {
        try {
          const raw = readFileSync(join(this.sessionsDir, f), 'utf-8');
          const session = JSON.parse(raw) as ConversationSession;
          this.sessions.set(session.id, session);
          this.sessionAccessOrder.push(session.id);
        } catch {
          log.warn(`Failed to load session file: ${f}`);
        }
      }
      log.info(`Loaded ${this.sessions.size} of ${files.length} conversation sessions (max ${MemoryStore.MAX_SESSIONS_IN_MEMORY} in memory)`);
    } catch {
      // sessions dir may not exist yet
    }
  }

  private tryLoadSessionFromDisk(sessionId: string): ConversationSession | undefined {
    try {
      const sessionFile = join(this.sessionsDir, `${sessionId}.json`);
      if (!existsSync(sessionFile)) return undefined;
      const raw = readFileSync(sessionFile, 'utf-8');
      return JSON.parse(raw) as ConversationSession;
    } catch {
      return undefined;
    }
  }

  private touchSession(sessionId: string): void {
    const idx = this.sessionAccessOrder.indexOf(sessionId);
    if (idx !== -1) this.sessionAccessOrder.splice(idx, 1);
    this.sessionAccessOrder.push(sessionId);
    this.evictOldSessions();
  }

  private evictOldSessions(): void {
    while (this.sessions.size > MemoryStore.MAX_SESSIONS_IN_MEMORY && this.sessionAccessOrder.length > 0) {
      const oldest = this.sessionAccessOrder.shift()!;
      const session = this.sessions.get(oldest);
      if (session) {
        this.saveSessionToDisk(session);
        this.sessions.delete(oldest);
      }
    }
  }

  private saveToDisk(): void {
    try {
      const memFile = join(this.dataDir, 'memories.json');
      writeFileSync(memFile, JSON.stringify(this.entries, null, 2));
    } catch (err) {
      log.warn('Failed to save memories to disk', { error: String(err) });
    }
  }

  private saveSessionToDisk(session: ConversationSession): void {
    try {
      const sessionFile = join(this.sessionsDir, `${session.id}.json`);
      writeFileSync(sessionFile, JSON.stringify(session, null, 2));
    } catch (err) {
      log.warn('Failed to save session to disk', { sessionId: session.id, error: String(err) });
    }
  }

  private debouncedSaveSession(session: ConversationSession): void {
    if (this.saveDebounce) clearTimeout(this.saveDebounce);
    this.saveDebounce = setTimeout(() => {
      this.saveSessionToDisk(session);
      this.saveDebounce = null;
    }, 1000);
  }

  /** Compress MEMORY.md — truncate oversized sections to prevent context bloat */
  compressLongTermMemory(): { charsBefore: number; charsAfter: number; sectionsBefore: number; sectionsAfter: number; truncatedChunks: number } {
    if (!existsSync(this.longTermFile)) {
      return { charsBefore: 0, charsAfter: 0, sectionsBefore: 0, sectionsAfter: 0, truncatedChunks: 0 };
    }

    const content = readFileSync(this.longTermFile, 'utf-8');
    const charsBefore = content.length;
    const lines = content.split('\n');

    // Phase 1: walk lines to identify preamble + section layout
    let i = 0;
    const preambleLines: string[] = [];
    while (i < lines.length && !lines[i].startsWith('## ')) {
      preambleLines.push(lines[i]);
      i++;
    }

    // Sections as [headerLine, ...bodyLines]
    const sections: { headerLine: string; body: string[] }[] = [];
    let currentHeader = '';
    let currentBody: string[] = [];

    while (i < lines.length) {
      const line = lines[i];
      if (line.startsWith('## ')) {
        if (currentHeader) {
          sections.push({ headerLine: currentHeader, body: currentBody });
        }
        currentHeader = line;
        currentBody = [];
      } else {
        currentBody.push(line);
      }
      i++;
    }
    // Push last section
    if (currentHeader) {
      sections.push({ headerLine: currentHeader, body: currentBody });
    }

    const sectionsBefore = sections.length;
    let truncatedChunks = 0;
    const outputLines: string[] = [...preambleLines];

    for (const section of sections) {
      const bodyStr = section.body.join('\n');
      if (bodyStr.length > MEMORY_MD_SECTION_MAX_CHARS) {
        const truncatedBody = bodyStr.slice(0, MEMORY_MD_SECTION_MAX_CHARS);
        outputLines.push(section.headerLine, truncatedBody);
        truncatedChunks++;
      } else {
        outputLines.push(section.headerLine, bodyStr);
      }
    }

    const compressed = outputLines.join('\n');
    writeFileSync(this.longTermFile, compressed);

    return {
      charsBefore,
      charsAfter: compressed.length,
      sectionsBefore,
      sectionsAfter: sections.length,
      truncatedChunks,
    };
  }
}
