// ── Project Memory materializer ───────────────────────────────────────────
//
// Mirrors the project memory to plain markdown under
// `<projectPath>/.agentsroom/memory/` so agents READ it with their native
// Read/Grep/Glob tools instead of paying an MCP network round-trip per note.
//
// Standalone CJS on purpose: it is required by BOTH the Electron main process
// (via electron/ipc/memory.ts) and every `app-mcp-server.cjs` child process.
// Node builtins only, no bundler, no TS — one implementation, zero drift.
//
// STRICTLY ONE-WAY: server/cache -> disk. Nothing here ever reads a .md back
// into the memory. Agents write only through `memory_save` (which upserts on
// the shared server row); a .md edited by hand is overwritten on the next
// sync. That asymmetry is what keeps the multi-user row authoritative: a
// stale local mirror can never push stale content back over a teammate's work.
//
// Layout:
//   .agentsroom/memory/
//     INDEX.md                     retrieval cues + freshness stamp
//     .sync.json                   manifest of the files WE own (prune basis)
//     <folder>/<note-slug>.md      one file per note, Obsidian-compatible

const fs = require('node:fs');
const path = require('node:path');

const MEMORY_DIRNAME = 'memory';
const AGENTSROOM_DIRNAME = '.agentsroom';
const INDEX_FILE = 'INDEX.md';
const SYNC_FILE = '.sync.json';
const ROOT_FOLDER_LABEL = '_root';

// ── Helpers ───────────────────────────────────────────────────────────────

/** Mirror of `slugifySkillName` (skillStore.ts / app-mcp-server.cjs) so a note
 *  lands on the same filename whichever path materialized it. */
function slugify(input) {
  const s = String(input || '')
    .toLowerCase()
    .normalize('NFKD')
    // eslint-disable-next-line no-misleading-character-class
    .replace(/[̀-ͯ]/g, '')
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/^-+|-+$/g, '')
    .replace(/-{2,}/g, '-');
  return s || 'note';
}

/** Atomic single-file write: tmp + rename. rename(2) is atomic on POSIX and
 *  ReplaceFile-backed on Windows, so an agent never reads a half-written note
 *  even if we crash mid-materialization. */
function atomicWrite(destPath, content) {
  fs.mkdirSync(path.dirname(destPath), { recursive: true });
  const tmp = `${destPath}.tmp.${process.pid}.${Math.random().toString(36).slice(2, 8)}`;
  fs.writeFileSync(tmp, content, 'utf-8');
  fs.renameSync(tmp, destPath);
}

function readJsonSafe(filePath, fallback) {
  try {
    return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
  } catch (_) {
    return fallback;
  }
}

/** ISO-8601 UTC strings order lexicographically, so plain `>` is a valid clock
 *  comparison here. `null` means "no server timestamp" (offline/dirty local
 *  edit): treated as always-newest, because it is the freshest local intent. */
function isOlderThan(ours, theirs) {
  if (!ours) return false;
  if (!theirs) return false;
  return ours < theirs;
}

function escapeYaml(value) {
  const s = String(value == null ? '' : value);
  return `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, ' ')}"`;
}

// ── Rendering ─────────────────────────────────────────────────────────────

function renderNote(note, folderName) {
  const tags = Array.isArray(note.tags) ? note.tags : [];
  const author = note.editedBy && note.editedBy.name ? note.editedBy.name : null;
  const lines = [
    '---',
    `name: ${escapeYaml(note.name)}`,
    `description: ${escapeYaml(note.description)}`,
    `folder: ${escapeYaml(folderName || '')}`,
    `tags: [${tags.map((t) => escapeYaml(t)).join(', ')}]`,
    `updatedAt: ${escapeYaml(note.updatedAt || '')}`,
  ];
  if (author) lines.push(`author: ${escapeYaml(author)}`);
  lines.push('---', '');
  lines.push(typeof note.content === 'string' ? note.content : '');
  return `${lines.join('\n').replace(/\s+$/, '')}\n`;
}

function renderIndex(entries, meta) {
  const stamp = meta.syncedAt;
  const out = [
    '# Project Memory: index',
    '',
    `Synced from the AgentsRoom server at **${stamp}**`,
    meta.updatedAt ? `(server revision \`${meta.updatedAt}\`).` : '(no server revision: local unsynced edits).',
    '',
    'HOW TO USE THIS DIRECTORY',
    '',
    '- READ is local: open a note with `Read`, or `Grep` this directory. No tool call, no network.',
    '- WRITE is remote: create or update notes ONLY with the `memory_save` MCP tool.',
    '  These files are a one-way mirror of a server row shared with every user of',
    '  the project. Editing a `.md` here does nothing: the next sync overwrites it.',
    '- STALE? This mirror refreshes when the project opens and after every agent',
    '  write. If the stamp above looks old, or a note contradicts the code you are',
    '  reading, call `memory_list` to force a fresh fetch and trust that instead.',
    '',
  ];

  if (!entries.length) {
    out.push('_The project memory is empty. Use `memory_save` to write the first note._', '');
    return out.join('\n');
  }

  const byFolder = new Map();
  for (const e of entries) {
    const key = e.folderName || ROOT_FOLDER_LABEL;
    if (!byFolder.has(key)) byFolder.set(key, []);
    byFolder.get(key).push(e);
  }

  out.push(`${entries.length} note${entries.length > 1 ? 's' : ''} in ${byFolder.size} folder${byFolder.size > 1 ? 's' : ''}.`, '');
  for (const [folder, items] of byFolder) {
    out.push(`## ${folder}`, '');
    for (const it of items) {
      out.push(`- **${it.name}** : ${it.description || '(no description)'}`);
      out.push(`  \`${it.relPath}\``);
    }
    out.push('');
  }
  return out.join('\n');
}

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

/**
 * Mirror the memory to disk. Idempotent, concurrency-safe, crash-safe.
 *
 * @param {object} opts
 * @param {string} opts.projectPath   absolute path to the user's project
 * @param {Array}  opts.folders       store-shape folders [{id, name}]
 * @param {Array}  opts.notes         store-shape notes (a.k.a. `skills`)
 * @param {string|null} opts.updatedAt server revision this state reflects
 * @param {string|null} opts.projectId
 * @returns {{ok: boolean, written?: number, pruned?: number, skipped?: string, error?: string}}
 */
function materializeMemory(opts) {
  const projectPath = opts && opts.projectPath;
  if (!projectPath || typeof projectPath !== 'string') {
    return { ok: false, error: 'projectPath is required' };
  }

  const folders = Array.isArray(opts.folders) ? opts.folders : [];
  const notes = Array.isArray(opts.notes) ? opts.notes : [];
  const updatedAt = opts.updatedAt || null;
  const memoryDir = path.join(projectPath, AGENTSROOM_DIRNAME, MEMORY_DIRNAME);
  const syncPath = path.join(memoryDir, SYNC_FILE);

  try {
    const prev = readJsonSafe(syncPath, null);

    // Concurrency guard. Several agents (one MCP process each) plus the
    // renderer can materialize at the same moment. If what is already on disk
    // reflects a NEWER server revision than ours, we are the stale writer:
    // bail out rather than roll the mirror backwards.
    if (prev && isOlderThan(updatedAt, prev.updatedAt)) {
      return { ok: true, skipped: 'stale-revision' };
    }

    fs.mkdirSync(memoryDir, { recursive: true });

    const folderById = new Map(folders.map((f) => [f.id, f]));
    // Folders can be nested (parentId chains, e.g. components > desktop):
    // resolve the full root-to-leaf name chain so the mirror lays notes out
    // in matching subdirectories. Cycle-guarded: a corrupt parentId loop
    // degrades to the visited part of the chain instead of hanging.
    const folderChain = (id) => {
      const names = [];
      const seen = new Set();
      let current = id ? folderById.get(id) : null;
      while (current && !seen.has(current.id)) {
        seen.add(current.id);
        names.unshift(current.name);
        current = current.parentId ? folderById.get(current.parentId) : null;
      }
      return names;
    };
    const usedRelPaths = new Set();
    const entries = [];

    for (const note of notes) {
      if (!note || typeof note.name !== 'string') continue;
      const chain = note.folderId ? folderChain(note.folderId) : [];
      const folderName = chain.length ? chain.join(' / ') : null;
      const dirSeg = chain.length ? chain.map(slugify).join('/') : '';
      const baseSlug = slugify(note.name);

      // Deterministic collision handling: same memory -> same filenames on
      // every machine and every run, so the mirror never churns.
      let relPath = path.posix.join(dirSeg, `${baseSlug}.md`);
      let n = 2;
      while (usedRelPaths.has(relPath)) {
        relPath = path.posix.join(dirSeg, `${baseSlug}-${n}.md`);
        n += 1;
      }
      usedRelPaths.add(relPath);
      entries.push({
        relPath,
        name: note.name,
        description: note.description || '',
        folderName,
        content: renderNote(note, folderName),
      });
    }

    // 1. Write notes first. Ordering is deliberate: if we die here the mirror
    //    holds an extra stale file (harmless, pruned next run) rather than
    //    missing knowledge. Never prune-then-write.
    let written = 0;
    for (const e of entries) {
      atomicWrite(path.join(memoryDir, e.relPath), e.content);
      written += 1;
    }

    const syncedAt = new Date().toISOString();
    atomicWrite(
      path.join(memoryDir, INDEX_FILE),
      renderIndex(entries, { syncedAt, updatedAt }),
    );

    // 2. Prune only files listed in OUR previous manifest that we no longer
    //    emit. A note deleted server-side must disappear here, otherwise
    //    agents keep reading ghost knowledge, which is worse than none. We
    //    never blind-delete the directory: anything the user dropped in by
    //    hand is not in the manifest and is therefore left alone.
    let pruned = 0;
    const prevFiles = prev && Array.isArray(prev.files) ? prev.files : [];
    for (const rel of prevFiles) {
      if (usedRelPaths.has(rel)) continue;
      if (typeof rel !== 'string' || rel.includes('..')) continue;
      try {
        fs.unlinkSync(path.join(memoryDir, rel));
        pruned += 1;
      } catch (_) { /* already gone */ }
    }

    // Drop folders we emptied (best-effort: only removes dirs that are
    // actually empty, so user files keep their parent alive). Ancestors are
    // included and processed deepest-first so an emptied nested chain
    // (components/desktop) collapses entirely, not just its leaf.
    const candidateDirs = new Set();
    for (const rel of prevFiles) {
      let dir = path.posix.dirname(rel);
      while (dir && dir !== '.') {
        candidateDirs.add(dir);
        dir = path.posix.dirname(dir);
      }
    }
    for (const folder of [...candidateDirs].sort((a, b) => b.length - a.length)) {
      try { fs.rmdirSync(path.join(memoryDir, folder)); } catch (_) { /* not empty */ }
    }

    // 3. Manifest LAST. A crash before this point leaves the older stamp, so
    //    the next run redoes the work instead of believing it is up to date.
    atomicWrite(syncPath, JSON.stringify({
      updatedAt,
      syncedAt,
      projectId: opts.projectId || null,
      files: entries.map((e) => e.relPath),
    }, null, 2));

    return { ok: true, written, pruned };
  } catch (err) {
    // Never block a save or a project open on mirror failure: the MCP tools
    // remain a complete fallback path.
    return { ok: false, error: (err && err.message) || String(err) };
  }
}

/** Absolute path of the mirror, for the agent boot prompt. */
function memoryDirFor(projectPath) {
  return path.join(projectPath, AGENTSROOM_DIRNAME, MEMORY_DIRNAME);
}

module.exports = { materializeMemory, memoryDirFor, MEMORY_DIRNAME };
