/**
 * memory.ts — User Memory system helpers
 *
 * Manages data/users/{userId}/memory/MEMORY.md (index) and individual
 * fact files (data/users/{userId}/memory/{name}.md) with YAML frontmatter.
 *
 * Index format (one line per entry):
 *   - [{name}](file.md) — {description}
 *
 * Fact file format:
 *   ---
 *   name: short-identifier
 *   description: one-line hook
 *   type: user | feedback | project | reference
 *   ---
 *   body content
 */

import {
  existsSync,
  statSync,
  readFileSync,
  readdirSync,
  writeFileSync,
  renameSync,
  unlinkSync,
  mkdirSync,
  openSync,
  readSync,
  closeSync,
} from 'fs';
import { join, dirname } from 'path';
import matter from 'gray-matter';
import { userRoot } from './paths.js';

// ── Constants ─────────────────────────────────────────────────────────────────

const MEMORY_INDEX_MAX_BYTES = 32 * 1024;
const INDEX_FILE = 'MEMORY.md';

// ── Name validation ───────────────────────────────────────────────────────────

export const MEMORY_NAME_RE = /^[a-zA-Z0-9_-]+$/;

/**
 * Throws if `name` is not a valid memory entry identifier.
 * Valid: 1–64 chars, alphanumeric / dash / underscore only.
 */
export function assertValidMemoryName(name: string): void {
  if (
    typeof name !== 'string' ||
    name.length === 0 ||
    name.length > 64 ||
    !MEMORY_NAME_RE.test(name)
  ) {
    throw new Error(`invalid memory name: ${JSON.stringify(name)}`);
  }
}

export const MEMORY_TYPES = ['user', 'feedback', 'project', 'reference'] as const;
export type MemoryType = typeof MEMORY_TYPES[number];

// ── Types ─────────────────────────────────────────────────────────────────────

export interface MemoryMeta {
  name: string;
  description: string;
  type: MemoryType;
}

export interface MemoryEntry {
  meta: MemoryMeta;
  body: string;
}

// ── Internal helpers ──────────────────────────────────────────────────────────

function memoryDir(rootDir: string, ownerId: string): string {
  return join(userRoot(rootDir, ownerId), 'memory');
}

function factPath(rootDir: string, ownerId: string, name: string): string {
  return join(memoryDir(rootDir, ownerId), `${name}.md`);
}

function trashDir(rootDir: string, ownerId: string): string {
  return join(userRoot(rootDir, ownerId), 'trash');
}

/** Atomic write: tmp file + rename. */
function writeAtomic(filePath: string, content: string): void {
  const dir = dirname(filePath);
  mkdirSync(dir, { recursive: true });
  const tmp = join(
    dir,
    `.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`,
  );
  let renamed = false;
  try {
    writeFileSync(tmp, content, { encoding: 'utf-8', mode: 0o600 });
    renameSync(tmp, filePath);
    renamed = true;
  } finally {
    if (!renamed) {
      try {
        unlinkSync(tmp);
      } catch {
        /* tmp may not exist if writeFileSync threw */
      }
    }
  }
}

/** UTC timestamp for trash naming (YYYYMMDD-HHMMSS). */
function utcTimestamp(d: Date = new Date()): string {
  const pad = (n: number, len = 2) => String(n).padStart(len, '0');
  return (
    `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}` +
    `-${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}`
  );
}

/**
 * Build the index line for a given entry.
 * Format: `- [{name}](file.md) — {description}`
 */
function buildIndexLine(name: string, description: string): string {
  return `- [${name}](${name}.md) — ${description}`;
}

/**
 * Update MEMORY.md: replace existing line for `name` or append a new one.
 * Does not reorder existing lines.
 */
function updateIndexFile(indexPath: string, name: string, description: string): void {
  let existing = '';
  if (existsSync(indexPath)) {
    existing = readFileSync(indexPath, 'utf-8');
  }

  const prefix = `- [${name}](`;
  const newLine = buildIndexLine(name, description);
  const lines = existing ? existing.split('\n') : [];

  const idx = lines.findIndex((l) => l.startsWith(prefix));
  if (idx !== -1) {
    lines[idx] = newLine;
  } else {
    // Remove trailing empty lines, append new entry, add trailing newline
    while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
    lines.push(newLine);
  }

  writeAtomic(indexPath, lines.join('\n') + '\n');
}

/**
 * Re-derives MEMORY.md from the on-disk fact files. Called before every
 * upsert / delete to self-heal after a process crash between the two
 * atomic writes in upsertMemoryEntry (fact file written, index update
 * missed) or in deleteMemoryEntry (fact moved to trash, index line still
 * present).
 *
 * Behavior:
 *   - Lists memory/*.md (excluding MEMORY.md itself).
 *   - For each fact file: parse its frontmatter description; if not in the
 *     index, append a line.
 *   - For each existing index line: drop it if the referenced *.md file is
 *     missing from the directory.
 *   - Preserves the order of existing index lines; new orphans appended at end.
 *   - Atomic-writes the result only when it differs from the on-disk index.
 *
 * Returns the names added and removed for visibility / tests.
 */
export function repairMemoryIndex(
  rootDir: string,
  ownerId: string,
): { added: string[]; removed: string[] } {
  const dir = memoryDir(rootDir, ownerId);
  const indexPath = join(dir, INDEX_FILE);
  const added: string[] = [];
  const removed: string[] = [];

  let entries: string[];
  try {
    entries = readdirSync(dir);
  } catch (err) {
    if ((err as NodeJS.ErrnoException).code === 'ENOENT') return { added, removed };
    throw err;
  }

  // Build canonical name → description map from the fact files on disk.
  const factDescriptions = new Map<string, string>();
  for (const entry of entries) {
    if (entry === INDEX_FILE) continue;
    if (!entry.endsWith('.md')) continue;
    const name = entry.slice(0, -3);
    if (!MEMORY_NAME_RE.test(name)) continue;
    try {
      const source = readFileSync(join(dir, entry), 'utf-8');
      const parsed = matter(source);
      const desc = typeof (parsed.data as Record<string, unknown>)['description'] === 'string'
        ? String((parsed.data as Record<string, unknown>)['description'])
        : '';
      factDescriptions.set(name, desc);
    } catch {
      // Corrupted fact file — skip; will be reconciled by user.
    }
  }

  const existing = existsSync(indexPath) ? readFileSync(indexPath, 'utf-8') : '';
  const lines = existing ? existing.split('\n') : [];
  const seenInIndex = new Set<string>();
  const kept: string[] = [];

  for (const line of lines) {
    // Match `- [name](file.md) — description` or any line starting with the prefix.
    const m = line.match(/^- \[([a-zA-Z0-9_-]+)\]\(/);
    if (m) {
      const name = m[1]!;
      if (factDescriptions.has(name)) {
        seenInIndex.add(name);
        kept.push(line);
      } else {
        removed.push(name);
      }
    } else {
      // Non-entry line (blank, comment, etc.) — keep verbatim.
      kept.push(line);
    }
  }

  for (const [name, desc] of factDescriptions) {
    if (seenInIndex.has(name)) continue;
    while (kept.length > 0 && kept[kept.length - 1] === '') kept.pop();
    kept.push(buildIndexLine(name, desc));
    added.push(name);
  }

  // Normalize trailing blank lines.
  while (kept.length > 0 && kept[kept.length - 1] === '') kept.pop();

  const next = kept.length > 0 ? kept.join('\n') + '\n' : '';
  if (next !== existing) {
    if (next.length === 0) {
      if (existsSync(indexPath)) {
        try { unlinkSync(indexPath); } catch { /* ignore */ }
      }
    } else {
      writeAtomic(indexPath, next);
    }
  }

  return { added, removed };
}

/**
 * Remove the index line for `name` from MEMORY.md.
 * If the file is missing or the line is not found, does nothing.
 */
function removeIndexLine(indexPath: string, name: string): void {
  if (!existsSync(indexPath)) return;

  const existing = readFileSync(indexPath, 'utf-8');
  const prefix = `- [${name}](`;
  const lines = existing.split('\n').filter((l) => !l.startsWith(prefix));

  // Remove trailing blank lines added by filter
  while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();

  writeAtomic(indexPath, lines.length > 0 ? lines.join('\n') + '\n' : '');
}

// ── Public API ────────────────────────────────────────────────────────────────

/**
 * Reads MEMORY.md for a user.
 * Returns the content (up to 32KB with truncation notice on overflow), or null.
 */
export function readMemoryIndex(rootDir: string, ownerId: string): string | null {
  let indexPath: string;
  try {
    indexPath = join(memoryDir(rootDir, ownerId), INDEX_FILE);
  } catch {
    return null;
  }

  let stat;
  try {
    stat = statSync(indexPath);
  } catch {
    return null;
  }

  if (!stat.isFile() || stat.size === 0) return null;

  if (stat.size <= MEMORY_INDEX_MAX_BYTES) {
    return readFileSync(indexPath, 'utf-8');
  }

  // Read first MEMORY_INDEX_MAX_BYTES and walk back to a UTF-8 boundary
  const buf = Buffer.alloc(MEMORY_INDEX_MAX_BYTES);
  const fd = openSync(indexPath, 'r');
  let bytesRead: number;
  try {
    bytesRead = readSync(fd, buf, 0, MEMORY_INDEX_MAX_BYTES, 0);
  } finally {
    closeSync(fd);
  }
  let safe = bytesRead;
  while (safe > 0 && (buf[safe - 1]! & 0xc0) === 0x80) safe--;
  return buf.subarray(0, safe).toString('utf-8') + `\n\n[truncated: original was ${stat.size} bytes]`;
}

/**
 * Validates and parses a memory fact file source string.
 * Throws on invalid type.
 */
export function parseMemoryEntry(source: string): MemoryEntry {
  const parsed = matter(source);
  const data = parsed.data as Record<string, unknown>;
  const body = parsed.content.startsWith('\n') ? parsed.content.slice(1) : parsed.content;

  const name = typeof data['name'] === 'string' ? data['name'] : '';
  const description = typeof data['description'] === 'string' ? data['description'] : '';
  const rawType = data['type'];

  if (!MEMORY_TYPES.includes(rawType as MemoryType)) {
    throw new Error(
      `invalid memory type: "${rawType}". Must be one of: ${MEMORY_TYPES.join(', ')}`,
    );
  }

  return {
    meta: { name, description, type: rawType as MemoryType },
    body,
  };
}

/**
 * Serializes a memory entry to a string (frontmatter + body) in stable key order.
 */
export function serializeMemoryEntry(entry: MemoryEntry): string {
  const { meta, body } = entry;
  const data: Record<string, unknown> = {
    name: meta.name,
    description: meta.description,
    type: meta.type,
  };
  return matter.stringify('\n' + body, data);
}

/**
 * Writes a fact file atomically AND updates MEMORY.md index.
 * Returns the fact file path and whether the index was updated.
 */
export function upsertMemoryEntry(
  rootDir: string,
  ownerId: string,
  opts: { name: string; type: MemoryType; description: string; body: string },
): { path: string; indexUpdated: boolean } {
  const { name, type, description, body } = opts;

  // Validate name at the helper layer (defense-in-depth)
  assertValidMemoryName(name);

  // Validate description: must be a single line (no CR or LF)
  if (typeof description === 'string' && (description.includes('\n') || description.includes('\r'))) {
    throw new Error('memory description must be a single line (no CR/LF)');
  }

  // Validate type
  if (!MEMORY_TYPES.includes(type)) {
    throw new Error(`invalid memory type: "${type}". Must be one of: ${MEMORY_TYPES.join(', ')}`);
  }

  const entry: MemoryEntry = { meta: { name, description, type }, body };
  const serialized = serializeMemoryEntry(entry);

  const dir = memoryDir(rootDir, ownerId);
  const filePath = join(dir, `${name}.md`);
  const indexPath = join(dir, INDEX_FILE);

  // Self-heal from any previous half-completed upsert / delete (orphan fact
  // file, dangling index line) BEFORE applying our own changes. This makes
  // the system tolerant of crashes between the two atomic writes below:
  // the next memory operation reconciles whatever the previous one missed.
  try {
    repairMemoryIndex(rootDir, ownerId);
  } catch {
    // Repair is best-effort; never block an upsert because of it.
  }

  // Write fact file atomically
  writeAtomic(filePath, serialized);

  // Update index
  updateIndexFile(indexPath, name, description);

  return { path: filePath, indexUpdated: true };
}

/**
 * Moves a fact file to trash and removes its index line.
 * Returns true if the file was found and moved, false if not found.
 */
export function deleteMemoryEntry(rootDir: string, ownerId: string, name: string): boolean {
  assertValidMemoryName(name);
  const dir = memoryDir(rootDir, ownerId);
  const filePath = join(dir, `${name}.md`);
  const indexPath = join(dir, INDEX_FILE);

  if (!existsSync(filePath)) return false;

  // Self-heal stale state from a previous half-completed write before doing
  // ours. See the comment in upsertMemoryEntry for the rationale.
  try {
    repairMemoryIndex(rootDir, ownerId);
  } catch {
    /* best-effort */
  }

  // Move to trash
  const tDir = trashDir(rootDir, ownerId);
  mkdirSync(tDir, { recursive: true });
  const ts = utcTimestamp();
  const suffix = Math.random().toString(36).slice(2, 8);
  const trashedName = `${ts}-${suffix}-${name}.md`;
  renameSync(filePath, join(tDir, trashedName));

  // Remove index line
  removeIndexLine(indexPath, name);

  return true;
}

/**
 * Alias for `deleteMemoryEntry` used by the reflection applier.
 * Moves the fact file to trash and removes its index line.
 * Returns true if found and removed, false if not found.
 */
export function removeMemoryEntry(rootDir: string, ownerId: string, name: string): boolean {
  return deleteMemoryEntry(rootDir, ownerId, name);
}

/**
 * Loads a specific fact file and parses it.
 * Returns null if the file does not exist.
 */
export function readMemoryEntry(rootDir: string, ownerId: string, name: string): MemoryEntry | null {
  assertValidMemoryName(name);
  const filePath = factPath(rootDir, ownerId, name);
  if (!existsSync(filePath)) return null;

  const source = readFileSync(filePath, 'utf-8');
  try {
    return parseMemoryEntry(source);
  } catch {
    return null;
  }
}

// ── Read-only helpers (used by reflection engine) ─────────────────────────────

/**
 * Returns true when `name` is a syntactically valid memory entry identifier
 * (1–64 chars, alphanumeric / dash / underscore only).
 */
export function isValidMemoryName(name: unknown): name is string {
  return (
    typeof name === 'string' &&
    name.length > 0 &&
    name.length <= 64 &&
    MEMORY_NAME_RE.test(name)
  );
}

/**
 * Lists all memory entries in a memory directory (absolute path).
 * Reads every `*.md` file other than `MEMORY.md`, parses the frontmatter, and
 * returns an array of `{ name, description, type, body }` objects.
 * Entries that cannot be parsed are silently skipped.
 */
export function listMemoryEntries(
  memDir: string,
): Array<{ name: string; description: string; type: string; body: string }> {
  let files: string[];
  try {
    files = readdirSync(memDir);
  } catch (err) {
    if ((err as NodeJS.ErrnoException).code === 'ENOENT') return [];
    throw err;
  }

  const results: Array<{ name: string; description: string; type: string; body: string }> = [];
  for (const file of files) {
    if (file === INDEX_FILE) continue;
    if (!file.endsWith('.md')) continue;
    const name = file.slice(0, -3);
    if (!isValidMemoryName(name)) continue;
    try {
      const source = readFileSync(join(memDir, file), 'utf-8');
      const entry = parseMemoryEntry(source);
      results.push({
        name: entry.meta.name || name,
        description: entry.meta.description,
        type: entry.meta.type,
        body: entry.body,
      });
    } catch {
      // corrupted file — skip
    }
  }
  return results;
}

/**
 * Reads MEMORY.md from an absolute memory directory path.
 * Returns the content (up to 32KB with truncation notice), or null if missing.
 *
 * This is a thin convenience wrapper over the two-argument `readMemoryIndex`
 * for callers that already have the resolved directory path.
 */
export function readMemoryIndexFromDir(memDir: string): string | null {
  const indexPath = join(memDir, INDEX_FILE);

  let stat;
  try {
    stat = statSync(indexPath);
  } catch {
    return null;
  }

  if (!stat.isFile() || stat.size === 0) return null;

  if (stat.size <= MEMORY_INDEX_MAX_BYTES) {
    return readFileSync(indexPath, 'utf-8');
  }

  const buf = Buffer.alloc(MEMORY_INDEX_MAX_BYTES);
  const fd = openSync(indexPath, 'r');
  let bytesRead: number;
  try {
    bytesRead = readSync(fd, buf, 0, MEMORY_INDEX_MAX_BYTES, 0);
  } finally {
    closeSync(fd);
  }
  let safe = bytesRead;
  while (safe > 0 && (buf[safe - 1]! & 0xc0) === 0x80) safe--;
  return buf.subarray(0, safe).toString('utf-8') + `\n\n[truncated: original was ${stat.size} bytes]`;
}
