#!/usr/bin/env bun
/**
 * gstack-brain-cache — three-tier cache for brain-aware planning skills.
 *
 * Subcommands:
 *   get <entity-name> [--project <slug>]      — return digest content; refresh if stale
 *   refresh [--full] [--entity X] [--project <slug>]  — force refresh one or all
 *   invalidate <entity-name> [--project <slug>]  — mark stale; next get triggers cold
 *   digest <entity-slug>                       — compress a brain page slug to digest
 *   meta [--project <slug>]                    — print _meta.json
 *
 * (Later commits add: bootstrap [T2b], list [T18], purge [T18], retention sweep [T18].)
 *
 * Cache layout:
 *   ~/.gstack/brain-cache/                     ← cross-project (user-profile only)
 *   ~/.gstack/projects/<slug>/brain-cache/     ← per-project (everything else)
 *
 * Atomic writes via .tmp + rename. Stale-but-usable fallback when brain
 * unreachable. Concurrent-refresh dedup is a follow-up commit (T15).
 */

import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, statSync, unlinkSync, readdirSync, openSync, closeSync } from 'fs';
import { join, dirname } from 'path';
import { homedir, hostname } from 'os';
import { spawnSync } from 'child_process';
import { execGbrainJson, spawnGbrain } from '../lib/gbrain-exec';
import {
  BRAIN_CACHE_ENTITIES,
  CACHE_REFRESH_LOCK_TIMEOUT_MS,
  GSTACK_SCHEMA_PACK_NAME,
  GSTACK_SCHEMA_PACK_VERSION,
  SALIENCE_DEFAULT_ALLOWLIST,
  type BrainCacheEntity,
} from '../scripts/brain-cache-spec';

// ──────────────────────────────────────────────────────────────────────────
// Paths + meta
// ──────────────────────────────────────────────────────────────────────────

const GSTACK_HOME = process.env.GSTACK_HOME || join(homedir(), '.gstack');

interface CacheMeta {
  /** Version of the schema pack the cache was built against. Mismatch → full rebuild. */
  schema_version: string;
  /** SHA8 hash of the brain MCP endpoint URL (or 'local' for on-disk engines). */
  endpoint_hash: string;
  /** Per-entity last-refresh epoch ms. Absent → never refreshed. */
  last_refresh: Record<string, number>;
  /** Per-entity last-attempt epoch ms (even if attempt failed). For stale-but-usable diagnostics. */
  last_attempt?: Record<string, number>;
}

/** Returns the directory holding a given entity's cache file. */
export function entityDir(entity: BrainCacheEntity, projectSlug: string | null): string {
  if (entity.scope === 'cross-project') {
    return join(GSTACK_HOME, 'brain-cache');
  }
  if (!projectSlug) {
    throw new Error(`Per-project entity needs a project slug: ${entity.file}`);
  }
  return join(GSTACK_HOME, 'projects', projectSlug, 'brain-cache');
}

/** Returns the path to the cache file for a given entity. */
export function entityPath(entityName: string, projectSlug: string | null): string {
  const entity = BRAIN_CACHE_ENTITIES[entityName];
  if (!entity) throw new Error(`Unknown brain cache entity: ${entityName}`);
  return join(entityDir(entity, projectSlug), entity.file);
}

/** Returns the path to the _meta.json for a given scope. */
export function metaPath(scope: 'cross-project' | 'per-project', projectSlug: string | null): string {
  if (scope === 'cross-project') {
    return join(GSTACK_HOME, 'brain-cache', '_meta.json');
  }
  if (!projectSlug) throw new Error('Per-project meta needs a project slug');
  return join(GSTACK_HOME, 'projects', projectSlug, 'brain-cache', '_meta.json');
}

function loadMeta(scope: 'cross-project' | 'per-project', projectSlug: string | null): CacheMeta {
  const path = metaPath(scope, projectSlug);
  if (!existsSync(path)) {
    return { schema_version: GSTACK_SCHEMA_PACK_VERSION, endpoint_hash: detectEndpointHash(), last_refresh: {}, last_attempt: {} };
  }
  try {
    const parsed = JSON.parse(readFileSync(path, 'utf-8')) as unknown;
    // #1879: a valid JSON file can still be the wrong shape. JSON.parse can return
    // null/array/string/number, and a partial object can omit last_refresh — three
    // consumers (isStale, cmdInvalidate, refreshEntity) dereference meta.last_refresh
    // unguarded and crash with a TypeError.
    if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
      return { schema_version: GSTACK_SCHEMA_PACK_VERSION, endpoint_hash: detectEndpointHash(), last_refresh: {}, last_attempt: {} };
    }
    const meta = parsed as CacheMeta;
    // Normalize ONLY the dereferenced maps. Do NOT default schema_version /
    // endpoint_hash — leaving them absent makes schemaVersionMismatch() /
    // endpointSwitched() correctly force a rebuild (missing identity = mismatch =
    // safe). Defaulting them to current values would suppress invalidation and
    // trust a stale file of unknown provenance.
    meta.last_refresh = meta.last_refresh ?? {};
    meta.last_attempt = meta.last_attempt ?? {};
    return meta;
  } catch {
    // Corrupt _meta — start fresh (entries will refresh on next access).
    return { schema_version: GSTACK_SCHEMA_PACK_VERSION, endpoint_hash: detectEndpointHash(), last_refresh: {}, last_attempt: {} };
  }
}

function saveMeta(scope: 'cross-project' | 'per-project', projectSlug: string | null, meta: CacheMeta): void {
  const path = metaPath(scope, projectSlug);
  mkdirSync(dirname(path), { recursive: true });
  atomicWrite(path, JSON.stringify(meta, null, 2));
}

// ──────────────────────────────────────────────────────────────────────────
// Endpoint hash detection
// ──────────────────────────────────────────────────────────────────────────

import { createHash } from 'crypto';

function sha8(input: string): string {
  return createHash('sha256').update(input).digest('hex').slice(0, 8);
}

/**
 * Detects the active brain endpoint (MCP URL or 'local') and returns its
 * stable identity hash. Used to detect when the user switches brains
 * (different endpoint → different cache).
 */
export function detectEndpointHash(): string {
  const claudeJsonPath = join(homedir(), '.claude.json');
  if (existsSync(claudeJsonPath)) {
    try {
      const cfg = JSON.parse(readFileSync(claudeJsonPath, 'utf-8'));
      const gbrainServer = cfg?.mcpServers?.gbrain;
      const url = gbrainServer?.url || gbrainServer?.transport?.url;
      if (typeof url === 'string' && url.length > 0) {
        return sha8(url);
      }
    } catch { /* fall through to local */ }
  }
  // Local engine — no endpoint URL; use a stable literal hash.
  return 'local';
}

// ──────────────────────────────────────────────────────────────────────────
// Atomic write (tmp + rename)
// ──────────────────────────────────────────────────────────────────────────

function atomicWrite(path: string, content: string): void {
  mkdirSync(dirname(path), { recursive: true });
  const tmp = `${path}.tmp.${process.pid}.${Date.now()}`;
  writeFileSync(tmp, content, 'utf-8');
  renameSync(tmp, path);
}

// ──────────────────────────────────────────────────────────────────────────
// Staleness + refresh logic
// ──────────────────────────────────────────────────────────────────────────

/** Returns true if the cached digest is past its TTL. */
function isStale(entityName: string, meta: CacheMeta): boolean {
  const entity = BRAIN_CACHE_ENTITIES[entityName];
  if (!entity) return true;
  const last = meta.last_refresh[entityName];
  if (!last) return true;
  return Date.now() - last > entity.ttl_ms;
}

/** Returns true if the cache file exists on disk. */
function hasFile(entityName: string, projectSlug: string | null): boolean {
  return existsSync(entityPath(entityName, projectSlug));
}

/** Returns true if schema version recorded in meta differs from current pack version. */
function schemaVersionMismatch(meta: CacheMeta): boolean {
  return meta.schema_version !== GSTACK_SCHEMA_PACK_VERSION;
}

/** Returns true if endpoint hash recorded in meta differs from current detected endpoint. */
function endpointSwitched(meta: CacheMeta): boolean {
  return meta.endpoint_hash !== detectEndpointHash();
}

// ──────────────────────────────────────────────────────────────────────────
// Subcommand: get
// ──────────────────────────────────────────────────────────────────────────

interface GetResult {
  /** Path to the digest file. */
  path: string;
  /** Cache state: 'warm' (fresh + valid), 'cold-refreshed' (was stale, refreshed inline), 'stale-fallback' (used stale because refresh failed), 'missing' (no cache and no refresh). */
  state: 'warm' | 'cold-refreshed' | 'stale-fallback' | 'missing';
  /** Optional message for diagnostics. */
  message?: string;
}

export function cmdGet(entityName: string, projectSlug: string | null): GetResult {
  const entity = BRAIN_CACHE_ENTITIES[entityName];
  if (!entity) throw new Error(`Unknown entity: ${entityName}`);
  const scope = entity.scope;
  const meta = loadMeta(scope, projectSlug);

  // Schema-version mismatch → full rebuild (D4 A4).
  if (schemaVersionMismatch(meta) || endpointSwitched(meta)) {
    rebuildAllForScope(scope, projectSlug);
    // After rebuild, meta is fresh; fall through to warm path.
    const newMeta = loadMeta(scope, projectSlug);
    if (hasFile(entityName, projectSlug) && !isStale(entityName, newMeta)) {
      return { path: entityPath(entityName, projectSlug), state: 'warm' };
    }
    // Rebuild may have failed for this entity specifically.
    return { path: entityPath(entityName, projectSlug), state: 'missing', message: 'rebuild after schema/endpoint change' };
  }

  if (hasFile(entityName, projectSlug) && !isStale(entityName, meta)) {
    return { path: entityPath(entityName, projectSlug), state: 'warm' };
  }

  // Stale or missing — try cold refresh.
  const refreshed = refreshEntity(entityName, projectSlug);
  if (refreshed) {
    return { path: entityPath(entityName, projectSlug), state: 'cold-refreshed' };
  }
  // Refresh failed. Use stale-but-usable if file exists.
  if (hasFile(entityName, projectSlug)) {
    return { path: entityPath(entityName, projectSlug), state: 'stale-fallback', message: 'brain unreachable; using stale cache' };
  }
  // No cache and no refresh = missing.
  return { path: entityPath(entityName, projectSlug), state: 'missing', message: 'brain unreachable; no cache available' };
}

// ──────────────────────────────────────────────────────────────────────────
// Subcommand: refresh
// ──────────────────────────────────────────────────────────────────────────

// ──────────────────────────────────────────────────────────────────────────
// Lockfile dedup (T15 / D3)
// ──────────────────────────────────────────────────────────────────────────

/**
 * Returns the lock file path for a project scope. Cross-project entities
 * still lock per-project (the project triggering the refresh holds the lock);
 * concurrent attempts from different projects on cross-project entities
 * serialize naturally because they're rare and the lock window is short.
 */
function lockPath(projectSlug: string | null): string {
  const dir = projectSlug
    ? join(GSTACK_HOME, 'projects', projectSlug, 'brain-cache')
    : join(GSTACK_HOME, 'brain-cache');
  return join(dir, '.refresh.lock');
}

interface LockHandle {
  fd: number;
  path: string;
}

/**
 * Try to acquire the refresh lock. Returns null when another process holds it
 * (and the lock is fresh). Stale locks (process dead OR older than the
 * timeout) are taken over.
 */
function tryAcquireLock(projectSlug: string | null): LockHandle | null {
  const path = lockPath(projectSlug);
  mkdirSync(dirname(path), { recursive: true });

  // If a lock exists, see if it's stale
  if (existsSync(path)) {
    try {
      const raw = readFileSync(path, 'utf-8');
      const lock = JSON.parse(raw) as { pid: number; host: string; ts: number };
      const age = Date.now() - lock.ts;
      const sameHost = lock.host === hostname();
      const processGone = sameHost && lock.pid > 0 && !isPidAlive(lock.pid);
      if (age <= CACHE_REFRESH_LOCK_TIMEOUT_MS && !processGone) {
        return null; // someone else holds a fresh lock
      }
      // Stale: take over
    } catch {
      // Corrupt lock file → take over
    }
  }

  // Write our lock (best-effort O_EXCL via tmp+rename for atomic creation)
  const payload = JSON.stringify({ pid: process.pid, host: hostname(), ts: Date.now() });
  const tmp = `${path}.tmp.${process.pid}.${Date.now()}`;
  try {
    writeFileSync(tmp, payload);
    renameSync(tmp, path);
  } catch (err) {
    return null;
  }

  // Race: another process may have raced us. Re-read and verify ownership.
  try {
    const raw = readFileSync(path, 'utf-8');
    const lock = JSON.parse(raw) as { pid: number; host: string };
    if (lock.pid !== process.pid || lock.host !== hostname()) {
      return null;
    }
  } catch {
    return null;
  }
  return { fd: -1, path };
}

function releaseLock(handle: LockHandle): void {
  try { unlinkSync(handle.path); } catch { /* best effort */ }
}

function isPidAlive(pid: number): boolean {
  try {
    process.kill(pid, 0);
    return true;
  } catch (err: any) {
    if (err?.code === 'EPERM') return true; // exists but we don't own it
    return false;
  }
}

/**
 * Run a refresh callback under the project-scoped lock. If another refresh is
 * already in flight, returns 'dedup' and the caller can either wait + retry
 * (the resolver does this) or fall through to stale-but-usable. Stale locks
 * (process dead, or older than CACHE_REFRESH_LOCK_TIMEOUT_MS) are taken over.
 */
export function withRefreshLock<T>(projectSlug: string | null, fn: () => T): T | 'dedup' {
  const handle = tryAcquireLock(projectSlug);
  if (!handle) return 'dedup';
  try {
    return fn();
  } finally {
    releaseLock(handle);
  }
}

/** Refreshes one entity from the brain. Returns true on success. */
export function refreshEntity(entityName: string, projectSlug: string | null): boolean {
  const entity = BRAIN_CACHE_ENTITIES[entityName];
  if (!entity) return false;

  // Mark attempt
  const meta = loadMeta(entity.scope, projectSlug);
  meta.last_attempt = meta.last_attempt || {};
  meta.last_attempt[entityName] = Date.now();

  // Fetch from brain. The actual fetch logic varies per entity — derived digests
  // (recent-decisions, salience) need different queries from direct page reads.
  // For T2a we implement the direct-page path; derived digests get filled in by
  // the resolver / write-back paths in later commits.
  const digestContent = fetchAndCompressEntity(entityName, projectSlug);
  if (digestContent === null) {
    saveMeta(entity.scope, projectSlug, meta);
    return false;
  }

  // Enforce per-entity budget by truncating from end (oldest items live there
  // by convention in our compressor). The per-skill budget is separately
  // enforced at preflight injection time.
  let final = digestContent;
  if (Buffer.byteLength(final, 'utf-8') > entity.budget_bytes) {
    final = truncateToBudget(final, entity.budget_bytes);
  }

  atomicWrite(entityPath(entityName, projectSlug), final);
  meta.last_refresh[entityName] = Date.now();
  // Keep schema/endpoint identity fresh.
  meta.schema_version = GSTACK_SCHEMA_PACK_VERSION;
  meta.endpoint_hash = detectEndpointHash();
  saveMeta(entity.scope, projectSlug, meta);
  return true;
}

/**
 * Refresh all entities for a scope (per-project or cross-project).
 * Used by --full and by schema/endpoint-change rebuilds.
 */
export function refreshAll(projectSlug: string | null): { success: number; failed: number } {
  let success = 0;
  let failed = 0;
  for (const [name, entity] of Object.entries(BRAIN_CACHE_ENTITIES)) {
    // Cross-project entities only refresh when explicitly targeted via no-slug calls
    if (entity.scope === 'cross-project' && projectSlug) continue;
    if (entity.scope === 'per-project' && !projectSlug) continue;
    if (refreshEntity(name, projectSlug)) success++; else failed++;
  }
  return { success, failed };
}

/** Rebuild on schema-version mismatch or endpoint switch. Wipes affected scope first. */
function rebuildAllForScope(scope: 'cross-project' | 'per-project', projectSlug: string | null): void {
  // Wipe files but preserve dir; meta gets fully rewritten by refreshes below.
  for (const [name, entity] of Object.entries(BRAIN_CACHE_ENTITIES)) {
    if (entity.scope !== scope) continue;
    const p = entityPath(name, projectSlug);
    if (existsSync(p)) {
      try { unlinkSync(p); } catch { /* best effort */ }
    }
  }
  // Fresh meta starts here
  const fresh: CacheMeta = {
    schema_version: GSTACK_SCHEMA_PACK_VERSION,
    endpoint_hash: detectEndpointHash(),
    last_refresh: {},
    last_attempt: {},
  };
  saveMeta(scope, projectSlug, fresh);
  // Refresh all entities in this scope
  for (const [name, entity] of Object.entries(BRAIN_CACHE_ENTITIES)) {
    if (entity.scope !== scope) continue;
    refreshEntity(name, projectSlug);
  }
}

// ──────────────────────────────────────────────────────────────────────────
// Subcommand: invalidate
// ──────────────────────────────────────────────────────────────────────────

export function cmdInvalidate(entityName: string, projectSlug: string | null): void {
  const entity = BRAIN_CACHE_ENTITIES[entityName];
  if (!entity) throw new Error(`Unknown entity: ${entityName}`);
  const meta = loadMeta(entity.scope, projectSlug);
  delete meta.last_refresh[entityName];
  saveMeta(entity.scope, projectSlug, meta);
}

// ──────────────────────────────────────────────────────────────────────────
// Fetch + compress per-entity
// ──────────────────────────────────────────────────────────────────────────

/**
 * Returns the digest markdown content for an entity, or null if the brain is
 * unreachable / the source page doesn't exist.
 *
 * For T2a we implement the entity → page-slug mapping for the simple cases.
 * Derived digests (recent-decisions, salience) get specialized paths.
 */
function fetchAndCompressEntity(entityName: string, projectSlug: string | null): string | null {
  switch (entityName) {
    case 'user-profile':
      return fetchUserProfile();
    case 'product':
      return fetchProduct(projectSlug);
    case 'goals':
      return fetchGoals(projectSlug);
    case 'developer-persona':
      return fetchSimplePage(`gstack/developer-persona/${projectSlug}`);
    case 'brand':
      return fetchSimplePage(`gstack/brand/${projectSlug}`);
    case 'competitive-intel':
      return fetchSimplePage(`gstack/competitive-intel/${projectSlug}`);
    case 'recent-decisions':
      return fetchRecentDecisions(projectSlug);
    case 'salience':
      // D9 salience allowlist applied in T17 commit; T2a returns raw output for now.
      return fetchSalience(projectSlug);
    default:
      return null;
  }
}

/** Generic single-page fetch via `gbrain get`. Returns null on miss/unreachable. */
function fetchSimplePage(slug: string): string | null {
  const result = spawnGbrain(['get', slug, '--json'], { timeout: 10_000 });
  if (result.status !== 0) return null;
  try {
    const page = JSON.parse(result.stdout) as { body?: string; title?: string };
    if (!page?.body) return null;
    return compressPage(slug, page.title || slug, page.body);
  } catch {
    return null;
  }
}

function fetchUserProfile(): string | null {
  // The user-slug discovery is implemented in T16 (D4 A3). For T2a we accept
  // env GSTACK_USER_SLUG as override, fallback to $USER for direct calls.
  const slug = process.env.GSTACK_USER_SLUG || process.env.USER || 'unknown';
  return fetchSimplePage(`gstack/user-profile/${slug}`);
}

function fetchProduct(projectSlug: string | null): string | null {
  if (!projectSlug) return null;
  return fetchSimplePage(`gstack/product/${projectSlug}`);
}

/**
 * Goals are LIST queries: all gstack/goal/<project>/* pages.
 * Compress the top N by recency.
 */
function fetchGoals(projectSlug: string | null): string | null {
  if (!projectSlug) return null;
  const result = execGbrainJson<{ pages?: Array<{ slug: string; title?: string; body?: string }> }>([
    'list-pages',
    '--type', 'gstack/goal',
    '--limit', '10',
    '--json',
  ]);
  if (!result?.pages) return null;
  const goals = result.pages.filter((p) => p.slug?.startsWith(`gstack/goal/${projectSlug}/`));
  if (goals.length === 0) {
    // Empty digest is valid (just header + 'no active goals' line)
    return `# Active goals (project: ${projectSlug})\n\n_No active goals recorded yet._\n`;
  }
  const lines = goals.map((g) => `- [[${g.slug}]] — ${g.title || '(untitled)'}`);
  return `# Active goals (project: ${projectSlug})\n\n${lines.join('\n')}\n`;
}

/**
 * recent-decisions: last 5 gstack/skill-run pages for this project, compressed
 * to one-line summaries.
 */
function fetchRecentDecisions(projectSlug: string | null): string | null {
  if (!projectSlug) return null;
  const result = execGbrainJson<{ pages?: Array<{ slug: string; title?: string }> }>([
    'list-pages',
    '--type', 'gstack/skill-run',
    '--limit', '5',
    '--sort', 'updated_desc',
    '--json',
  ]);
  if (!result?.pages) {
    return `# Recent decisions (project: ${projectSlug})\n\n_No prior skill runs recorded._\n`;
  }
  const lines = result.pages.map((p) => `- ${p.title || p.slug}`);
  return `# Recent decisions (project: ${projectSlug})\n\n${lines.join('\n')}\n`;
}

/**
 * Reads the user's salience allowlist override from gstack-config. If unset,
 * returns SALIENCE_DEFAULT_ALLOWLIST. The override is comma-separated; we
 * trim and drop empty entries.
 */
export function getSalienceAllowlist(): ReadonlyArray<string> {
  // Short-circuit via env var for tests + headless callers.
  const env = process.env.GSTACK_SALIENCE_ALLOWLIST;
  if (typeof env === 'string' && env.length > 0) {
    return env.split(',').map((s) => s.trim()).filter(Boolean);
  }
  // Shell out to gstack-config with a tight timeout. Falls back to defaults
  // on any failure (config script missing, command non-zero, parse error).
  try {
    const skillRoot = join(homedir(), '.claude', 'skills', 'gstack');
    const bin = join(skillRoot, 'bin', 'gstack-config');
    if (!existsSync(bin)) return SALIENCE_DEFAULT_ALLOWLIST;
    const result = spawnSync(bin, ['get', 'salience_allowlist'], { timeout: 2000, encoding: 'utf-8' });
    if (result.status !== 0 || !result.stdout) return SALIENCE_DEFAULT_ALLOWLIST;
    const trimmed = result.stdout.trim();
    if (!trimmed) return SALIENCE_DEFAULT_ALLOWLIST;
    const parts = trimmed.split(',').map((s) => s.trim()).filter(Boolean);
    return parts.length > 0 ? parts : SALIENCE_DEFAULT_ALLOWLIST;
  } catch {
    return SALIENCE_DEFAULT_ALLOWLIST;
  }
}

/**
 * D9 salience privacy gate: returns true if the slug starts with any allowlisted
 * prefix. Anything NOT matching is stripped at digest write time so that family,
 * therapy, reflection, and other sensitive content never leaks into work-flow
 * planning prompts by default.
 */
export function isSalienceSlugAllowed(slug: string, allowlist: ReadonlyArray<string>): boolean {
  for (const prefix of allowlist) {
    if (slug.startsWith(prefix)) return true;
  }
  return false;
}

function fetchSalience(projectSlug: string | null): string | null {
  // get-recent-salience is a gbrain CLI sub-shape; we use the MCP-shape JSON
  const result = execGbrainJson<{ pages?: Array<{ slug: string; title?: string; emotional_weight?: number }> }>([
    'get-recent-salience',
    '--days', '14',
    '--limit', '10',
    '--json',
  ]);
  if (!result?.pages) return `# Recent salience\n\n_No salient pages in last 14d._\n`;

  // D9 privacy gate: strip entries outside the allowlist BEFORE rendering.
  // Sensitive personal content (family, therapy, reflection) is never written
  // into the digest cache file, even when the brain itself ranks it salient.
  const allowlist = getSalienceAllowlist();
  const filtered = result.pages.filter((p) => p.slug && isSalienceSlugAllowed(p.slug, allowlist));
  const stripped = result.pages.length - filtered.length;
  if (filtered.length === 0) {
    const header = `# Recent salience (last 14d)`;
    const note = stripped > 0
      ? `\n_All ${stripped} salient entries stripped by allowlist gate (no work-flow content in window)._\n`
      : `\n_No salient pages in last 14d._\n`;
    return `${header}\n${note}`;
  }
  const lines = filtered.map((p) => `- [[${p.slug}]] — ${p.title || ''} (weight: ${p.emotional_weight?.toFixed(2) ?? 'n/a'})`);
  const footer = stripped > 0
    ? `\n\n_${stripped} private entries stripped by allowlist gate._`
    : '';
  return `# Recent salience (last 14d)\n\n${lines.join('\n')}${footer}\n`;
}

/**
 * Compress a brain page body into a digest. The compressor keeps frontmatter
 * out, trims body to the first H2/H3 sections, and prepends a slug header.
 * Per-entity budget enforcement happens at the caller (refreshEntity).
 */
function compressPage(slug: string, title: string, body: string): string {
  const trimmed = body
    .replace(/^---[\s\S]*?---\s*\n/m, '') // strip frontmatter
    .trim();
  return `# ${title}\nslug: ${slug}\n\n${trimmed}\n`;
}

/**
 * Truncate a digest to a byte budget. Tries to cut at the last newline before
 * the budget so the digest stays readable.
 */
function truncateToBudget(content: string, budgetBytes: number): string {
  const buf = Buffer.from(content, 'utf-8');
  if (buf.byteLength <= budgetBytes) return content;
  const truncated = buf.slice(0, budgetBytes).toString('utf-8');
  const lastNewline = truncated.lastIndexOf('\n');
  const cleanCut = lastNewline > budgetBytes * 0.8 ? truncated.slice(0, lastNewline) : truncated;
  return `${cleanCut}\n\n_(digest truncated to ${budgetBytes}-byte budget)_\n`;
}

// ──────────────────────────────────────────────────────────────────────────
// Subcommand: digest
// ──────────────────────────────────────────────────────────────────────────

/**
 * Public: compress a brain page slug to digest format. Used by callers that
 * want to know what the digest WOULD look like without writing to cache.
 */
export function cmdDigest(slug: string): string | null {
  return fetchSimplePage(slug);
}

// ──────────────────────────────────────────────────────────────────────────
// Subcommand: meta
// ──────────────────────────────────────────────────────────────────────────

export function cmdMeta(projectSlug: string | null): CacheMeta {
  if (projectSlug) return loadMeta('per-project', projectSlug);
  return loadMeta('cross-project', null);
}

// ──────────────────────────────────────────────────────────────────────────
// Subcommand: bootstrap (T2b)
// ──────────────────────────────────────────────────────────────────────────

/**
 * Bootstrap synthesizes draft entity content from CLAUDE.md + README +
 * recent commits + learnings.jsonl for a fresh project. Emits as JSON for
 * the caller (skill template) to AUQ-confirm before any write to the brain.
 *
 * This keeps the CLI pure (no AUQ logic) while preventing silent
 * auto-extraction garbage (D10 T4 fix). The agent is responsible for the
 * "Synthesized X — looks right?" prompt per entity.
 */
export interface BootstrapDraft {
  product?: { slug: string; title: string; body: string };
  goals?: Array<{ slug: string; title: string; body: string }>;
  developer_persona?: { slug: string; title: string; body: string };
  brand?: { slug: string; title: string; body: string };
  competitive_intel?: { slug: string; title: string; body: string };
}

export function cmdBootstrap(projectSlug: string): BootstrapDraft {
  const draft: BootstrapDraft = {};
  const repoRoot = process.env.GSTACK_REPO_ROOT || process.cwd();

  // Product synthesis: CLAUDE.md headline + README first paragraph
  let claudeMd = '';
  try { claudeMd = readFileSync(join(repoRoot, 'CLAUDE.md'), 'utf-8'); } catch { /* missing is fine */ }
  let readmeMd = '';
  try { readmeMd = readFileSync(join(repoRoot, 'README.md'), 'utf-8'); } catch { /* missing is fine */ }

  const productLead = synthesizeProductLead(claudeMd, readmeMd, projectSlug);
  if (productLead) {
    draft.product = {
      slug: `gstack/product/${projectSlug}`,
      title: projectSlug,
      body: productLead,
    };
  }

  // Goals: try learnings.jsonl + recent commit messages mentioning "goal" or "ship"
  const learningsPath = join(GSTACK_HOME, 'projects', projectSlug, 'learnings.jsonl');
  const goalsHints = synthesizeGoalsHints(learningsPath, repoRoot);
  if (goalsHints.length > 0) {
    draft.goals = goalsHints.slice(0, 3).map((hint, idx) => ({
      slug: `gstack/goal/${projectSlug}/bootstrap-${idx + 1}`,
      title: hint.title,
      body: hint.body,
    }));
  }

  return draft;
}

function synthesizeProductLead(claudeMd: string, readmeMd: string, slug: string): string | null {
  // First H1 in CLAUDE.md or README, plus first paragraph after it.
  const source = claudeMd || readmeMd;
  if (!source) return null;
  const h1Match = source.match(/^#\s+(.+)$/m);
  const heading = h1Match?.[1]?.trim() || slug;
  // First non-heading paragraph
  const paraMatch = source.match(/(?:^|\n)([^#\n][^\n]+(?:\n[^#\n][^\n]+)*)/);
  const lead = paraMatch?.[1]?.trim() || '(no description found in CLAUDE.md or README)';
  return [
    `# ${heading}`,
    '',
    '## What',
    lead.slice(0, 500),
    '',
    '## Stage',
    '(fill in current stage, e.g., v1.x shipped, in development, paused)',
    '',
    '## Team',
    '(fill in team composition + size)',
    '',
    '## Active goals',
    '(populated by /office-hours over time)',
    '',
    '## Recent decisions',
    '(populated by /plan-ceo-review over time)',
    '',
  ].join('\n');
}

function synthesizeGoalsHints(learningsPath: string, repoRoot: string): Array<{ title: string; body: string }> {
  const hints: Array<{ title: string; body: string }> = [];
  if (existsSync(learningsPath)) {
    try {
      const lines = readFileSync(learningsPath, 'utf-8').split('\n').filter(Boolean);
      for (const line of lines.slice(-10)) {
        try {
          const entry = JSON.parse(line);
          if (entry?.insight && (entry?.type === 'pattern' || entry?.type === 'architecture')) {
            hints.push({
              title: entry.insight.slice(0, 80),
              body: `Source: learnings.jsonl\nType: ${entry.type}\n\n${entry.insight}\n`,
            });
          }
        } catch { /* skip malformed line */ }
      }
    } catch { /* unreadable file, skip */ }
  }
  return hints;
}

// ──────────────────────────────────────────────────────────────────────────
// Subcommand: list (T18)
// ──────────────────────────────────────────────────────────────────────────

/**
 * Lists all gstack-owned pages currently in the brain for a project, grouped
 * by type. Powers the user's ability to audit what gstack has written.
 */
export function cmdList(projectSlug: string | null): Array<{ type: string; slug: string; title?: string }> {
  // We probe each gstack/<type>/ namespace via list-pages with a type filter.
  const types = ['gstack/user-profile', 'gstack/product', 'gstack/goal', 'gstack/developer-persona', 'gstack/brand', 'gstack/competitive-intel', 'gstack/skill-run', 'gstack/take'];
  const all: Array<{ type: string; slug: string; title?: string }> = [];
  for (const type of types) {
    const result = execGbrainJson<{ pages?: Array<{ slug: string; title?: string }> }>([
      'list-pages',
      '--type', type,
      '--limit', '200',
      '--json',
    ]);
    if (!result?.pages) continue;
    for (const page of result.pages) {
      if (projectSlug && !page.slug?.includes(`/${projectSlug}`) && type !== 'gstack/user-profile') {
        continue;
      }
      all.push({ type, slug: page.slug, title: page.title });
    }
  }
  return all;
}

// ──────────────────────────────────────────────────────────────────────────
// Subcommand: purge (T18)
// ──────────────────────────────────────────────────────────────────────────

/**
 * Delete one gstack-owned page from the brain. Caller (skill template) is
 * responsible for the confirm prompt; this is the raw operation.
 */
export function cmdPurge(slug: string): { deleted: boolean; error?: string } {
  if (!slug.startsWith('gstack/')) {
    return { deleted: false, error: 'refusing to purge non-gstack page' };
  }
  const result = spawnGbrain(['delete-page', slug], { timeout: 10_000 });
  if (result.status !== 0) {
    return { deleted: false, error: result.stderr?.trim() || `exit ${result.status}` };
  }
  // Also invalidate any cached digests that referenced this page.
  // Best-effort — derived digests may need explicit invalidate.
  return { deleted: true };
}

// ──────────────────────────────────────────────────────────────────────────
// CLI dispatch
// ──────────────────────────────────────────────────────────────────────────

function parseArgs(argv: string[]): { cmd: string; positional: string[]; flags: Record<string, string | boolean> } {
  const cmd = argv[2] || '';
  const rest = argv.slice(3);
  const positional: string[] = [];
  const flags: Record<string, string | boolean> = {};
  for (let i = 0; i < rest.length; i++) {
    const arg = rest[i];
    if (arg.startsWith('--')) {
      const key = arg.slice(2);
      const next = rest[i + 1];
      if (next && !next.startsWith('--')) {
        flags[key] = next;
        i++;
      } else {
        flags[key] = true;
      }
    } else {
      positional.push(arg);
    }
  }
  return { cmd, positional, flags };
}

function projectSlugFromFlag(flags: Record<string, string | boolean>): string | null {
  const v = flags.project;
  return typeof v === 'string' ? v : null;
}

function printUsage(): void {
  process.stderr.write(`Usage: gstack-brain-cache <subcommand>

Subcommands:
  get <entity-name> [--project <slug>]
  refresh [--full] [--entity X] [--project <slug>]
  invalidate <entity-name> [--project <slug>]
  digest <entity-slug>
  meta [--project <slug>]
  bootstrap --project <slug>           — emit synthesized entity drafts (JSON)
  list [--project <slug>]              — list gstack-owned pages in brain
  purge <slug>                         — delete a gstack-owned brain page (refuses non-gstack/ slugs)
`);
}

async function main(): Promise<number> {
  const { cmd, positional, flags } = parseArgs(process.argv);
  const projectSlug = projectSlugFromFlag(flags);

  try {
    switch (cmd) {
      case 'get': {
        const entityName = positional[0];
        if (!entityName) { printUsage(); return 1; }
        const result = cmdGet(entityName, projectSlug);
        if (result.state === 'missing') {
          process.stderr.write(`(${result.state}: ${result.message ?? 'no cache'})\n`);
          return 2;
        }
        if (result.state !== 'warm') {
          process.stderr.write(`(${result.state}${result.message ? ': ' + result.message : ''})\n`);
        }
        process.stdout.write(readFileSync(result.path, 'utf-8'));
        return 0;
      }
      case 'refresh': {
        // D3: dedup concurrent refreshes via lockfile. Skipped (dedup) when
        // another process is already mid-refresh on the same project.
        if (flags.entity) {
          const entityName = String(flags.entity);
          const result = withRefreshLock(projectSlug, () => refreshEntity(entityName, projectSlug));
          if (result === 'dedup') {
            process.stderr.write(`(dedup: another refresh in flight)\n`);
            return 3;
          }
          process.stdout.write(result ? `refreshed ${entityName}\n` : `failed to refresh ${entityName}\n`);
          return result ? 0 : 1;
        }
        const allResult = withRefreshLock(projectSlug, () => refreshAll(projectSlug));
        if (allResult === 'dedup') {
          process.stderr.write(`(dedup: another refresh in flight)\n`);
          return 3;
        }
        process.stdout.write(`refreshed=${allResult.success} failed=${allResult.failed}\n`);
        return allResult.failed > 0 ? 1 : 0;
      }
      case 'invalidate': {
        const entityName = positional[0];
        if (!entityName) { printUsage(); return 1; }
        cmdInvalidate(entityName, projectSlug);
        process.stdout.write(`invalidated ${entityName}\n`);
        return 0;
      }
      case 'digest': {
        const slug = positional[0];
        if (!slug) { printUsage(); return 1; }
        const content = cmdDigest(slug);
        if (content === null) {
          process.stderr.write('brain unreachable or page not found\n');
          return 2;
        }
        process.stdout.write(content);
        return 0;
      }
      case 'meta': {
        const meta = cmdMeta(projectSlug);
        process.stdout.write(JSON.stringify(meta, null, 2) + '\n');
        return 0;
      }
      case 'bootstrap': {
        if (!projectSlug) {
          process.stderr.write('bootstrap requires --project <slug>\n');
          return 1;
        }
        const draft = cmdBootstrap(projectSlug);
        process.stdout.write(JSON.stringify(draft, null, 2) + '\n');
        return 0;
      }
      case 'list': {
        const pages = cmdList(projectSlug);
        if (flags.json) {
          process.stdout.write(JSON.stringify(pages, null, 2) + '\n');
        } else {
          for (const p of pages) {
            process.stdout.write(`${p.type}\t${p.slug}\t${p.title ?? ''}\n`);
          }
        }
        return 0;
      }
      case 'purge': {
        const slug = positional[0];
        if (!slug) { printUsage(); return 1; }
        const result = cmdPurge(slug);
        if (result.deleted) {
          process.stdout.write(`deleted ${slug}\n`);
          return 0;
        }
        process.stderr.write(`failed: ${result.error}\n`);
        return 1;
      }
      case '':
      case 'help':
      case '--help':
      case '-h':
        printUsage();
        return 0;
      default:
        process.stderr.write(`unknown subcommand: ${cmd}\n`);
        printUsage();
        return 1;
    }
  } catch (err) {
    process.stderr.write(`error: ${err instanceof Error ? err.message : String(err)}\n`);
    return 1;
  }
}

// Only run main when invoked as a script (not when imported by tests)
if (import.meta.main) {
  main().then((code) => process.exit(code));
}
