/**
 * gbrain integrations — standalone CLI command for recipe discovery and health.
 *
 * NOT an operation (no database connection needed).
 * Reads embedded recipe files and heartbeat JSONL from ~/.gbrain/integrations/.
 *
 * ARCHITECTURE:
 *   recipes/*.md (embedded at build time)
 *     │
 *     ├── list    → parse frontmatter, check env vars, show status
 *     ├── show    → display recipe details + body
 *     ├── status  → check secrets + heartbeat
 *     ├── doctor  → run health_checks
 *     ├── stats   → aggregate heartbeat JSONL
 *     ├── test    → validate recipe file
 *     └── (bare)  → dashboard view
 *
 *   ~/.gbrain/integrations/<id>/heartbeat.jsonl
 *     └── append-only, pruned to 30 days on read
 */

import matter from 'gray-matter';
import { readFileSync, existsSync, writeFileSync, mkdirSync, readdirSync } from 'fs';
import { join, basename } from 'path';
import { homedir } from 'os';
import { gbrainPath, loadConfig } from '../core/config.ts';
import { buildGatewayConfig } from '../core/ai/build-gateway-config.ts';
import { execSync } from 'child_process';

// --- Types ---

interface RecipeSecret {
  name: string;
  description: string;
  where: string;
}

/**
 * Install mode discriminator. New recipes default to 'local-managed' (the
 * legacy path that writes to ~/.gbrain/skills/). 'copy-into-host-repo'
 * recipes write their bundle into the operator's host agent repo via the
 * `gbrain integrations install` subcommand.
 */
type InstallKind = 'local-managed' | 'copy-into-host-repo';

interface RecipeFrontmatter {
  id: string;
  name: string;
  version: string;
  description: string;
  category: 'infra' | 'sense' | 'reflex' | 'voice';
  install_kind: InstallKind;
  requires: string[];
  secrets: RecipeSecret[];
  health_checks: HealthCheck[];
  setup_time: string;
  cost_estimate?: string;
  /**
   * Repo-relative dirs (slug prefixes, trailing '/') this recipe's collector
   * writes files to. Ground truth for the `db_only_collector_collision`
   * doctor check (issue #2788): output inside a db_only path is silently
   * skipped by sync and import (auto-gitignored).
   */
  output_paths: string[];
}

interface ParsedRecipe {
  frontmatter: RecipeFrontmatter;
  body: string;
  filename: string;
  embedded: boolean;
}

interface HeartbeatEntry {
  ts: string;
  event: string;
  source_version?: string;
  status: string;
  details?: Record<string, unknown>;
  error?: string;
}

// --- Health Check DSL Types ---

interface HttpCheck {
  type: 'http';
  url: string;
  method?: string;
  headers?: Record<string, string>;
  body?: string;
  auth?: 'basic' | 'bearer';
  auth_user?: string;
  auth_pass?: string;
  auth_token?: string;
  label?: string;
}

interface EnvExistsCheck {
  type: 'env_exists';
  name: string;
  label?: string;
}

interface CommandCheck {
  type: 'command';
  argv: string[];
  label?: string;
}

interface AnyOfCheck {
  type: 'any_of';
  label?: string;
  checks: HealthCheck[];
}

/**
 * Staleness-aware check type (issue #2787, reported by @alexputici). All
 * other types are point-in-time — a sense whose gateway is up and env vars
 * are set passes forever even when zero data flows. This one reads the
 * integration's heartbeat file and FAILS when the newest event is older
 * than the declared cadence (`max_age`, e.g. "48h", "2d", "90m").
 */
interface HeartbeatMaxAgeCheck {
  type: 'heartbeat_max_age';
  max_age: string;
  label?: string;
}

type HealthCheck = string | HttpCheck | EnvExistsCheck | CommandCheck | AnyOfCheck | HeartbeatMaxAgeCheck;

interface CheckResult {
  integration: string;
  check: string;
  status: 'ok' | 'fail' | 'timeout' | 'blocked';
  output: string;
}

/**
 * Returns true if a string health_check contains shell metacharacters.
 * Only applied to user-created (non-embedded) recipes.
 */
export function isUnsafeHealthCheck(check: string): boolean {
  return /[;&|`$(){}\\<>\n]/.test(check);
}

/**
 * Env view for secret resolution (#2789): apply the same config.json→env
 * folding the runtime applies via buildGatewayConfig, so a credential stored
 * only in ~/.gbrain/config.json — which powers a perfectly healthy
 * integration — is not reported [missing] by show/status. process.env still
 * wins for non-empty values (buildGatewayConfig spreads it last, dropping
 * only ''/undefined entries). Falls back to bare process.env before
 * `gbrain init` (no config file yet). Mirrors the #2728 fix on the
 * providers command.
 */
export function secretEnv(): Record<string, string | undefined> {
  try {
    const cfg = loadConfig();
    if (cfg) return buildGatewayConfig(cfg).env;
  } catch { /* integrations must keep working pre-init — fall through */ }
  return process.env;
}

/**
 * Parse a heartbeat_max_age duration string ("30s", "90m", "48h", "2d")
 * into milliseconds. Returns null on anything unparseable.
 */
export function parseMaxAge(s: string): number | null {
  const m = /^(\d+(?:\.\d+)?)\s*(s|m|h|d)$/i.exec(String(s).trim());
  if (!m) return null;
  const n = Number(m[1]);
  if (!Number.isFinite(n) || n <= 0) return null;
  const unit = { s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 }[m[2].toLowerCase() as 's' | 'm' | 'h' | 'd'];
  return n * unit;
}

/** Human-readable age for heartbeat_max_age output ("3d", "17h", "42m"). */
function formatAge(ms: number): string {
  if (ms >= 86_400_000) return `${Math.floor(ms / 86_400_000)}d`;
  if (ms >= 3_600_000) return `${Math.floor(ms / 3_600_000)}h`;
  return `${Math.max(0, Math.floor(ms / 60_000))}m`;
}

/** Expand $VAR references with gateway-env (config-folded) values */
export function expandVars(s: string): string {
  const env = secretEnv();
  return s.replace(/\$([A-Z_][A-Z0-9_]*)/g, (_, name) => env[name] || '');
}

// --- SSRF Protection ---
// Helpers extracted to src/core/url-safety.ts in v0.28 so src/core/git-remote.ts
// can reuse them without inverting the layering boundary. Re-exported here for
// backward compat with existing callers + test/integrations.test.ts imports.

export {
  parseOctet,
  hostnameToOctets,
  isPrivateIpv4,
  isInternalUrl,
} from '../core/url-safety.ts';

import { isInternalUrl } from '../core/url-safety.ts';

export async function executeHealthCheck(
  check: HealthCheck,
  integrationId: string,
  isEmbedded: boolean,
): Promise<CheckResult> {
  const label = typeof check === 'string' ? check : (check as any).label || JSON.stringify(check);
  const base = { integration: integrationId, check: label };

  // String health checks (deprecated path)
  if (typeof check === 'string') {
    // B2: Hard-block string health_checks for non-embedded recipes. User-provided
    // recipes must use the typed DSL; string health_checks are a known exec/SSRF bypass.
    if (!isEmbedded) {
      return { ...base, status: 'blocked', output: 'Blocked: string health_checks are restricted to embedded recipes. Migrate to typed health_check DSL (http, command, env_exists, any_of).' };
    }
    // Defense-in-depth for embedded recipes: still reject obviously dangerous shell metachars.
    if (isUnsafeHealthCheck(check)) {
      return { ...base, status: 'blocked', output: 'Blocked: contains unsafe shell characters. Migrate to typed health_check DSL.' };
    }
    try {
      const output = execSync(check, { timeout: 10000, encoding: 'utf-8', env: process.env }).trim();
      return { ...base, status: output.includes('FAIL') ? 'fail' : 'ok', output };
    } catch (e: unknown) {
      const msg = e instanceof Error ? e.message : String(e);
      return { ...base, status: msg.includes('TIMEDOUT') ? 'timeout' : 'fail', output: msg };
    }
  }

  // Typed DSL checks
  switch (check.type) {
    case 'http': {
      // Fix 4: gate http health_checks on embedded trust. User-provided recipes
      // must NOT be able to make arbitrary outbound HTTP (SSRF / internal reconnaissance).
      if (!isEmbedded) {
        return { ...base, status: 'blocked', output: `Blocked: http health_checks are restricted to embedded recipes. (${check.label || check.url})` };
      }
      try {
        const url = expandVars(check.url);
        if (!url || url.includes('undefined')) {
          return { ...base, status: 'fail', output: `Missing env var in URL: ${check.url}` };
        }
        // B4: scheme allowlist. B3: manual redirect with per-hop re-validation.
        if (isInternalUrl(url)) {
          return { ...base, status: 'blocked', output: `Blocked: URL targets internal/private network or uses non-http(s) scheme: ${check.url}` };
        }
        const headers: Record<string, string> = {};
        if (check.headers) {
          for (const [k, v] of Object.entries(check.headers)) {
            headers[k] = expandVars(v);
          }
        }
        if (check.auth === 'basic' && check.auth_user && check.auth_pass) {
          const user = expandVars(check.auth_user);
          const pass = expandVars(check.auth_pass);
          headers['Authorization'] = 'Basic ' + Buffer.from(`${user}:${pass}`).toString('base64');
        } else if (check.auth === 'bearer' && check.auth_token) {
          headers['Authorization'] = 'Bearer ' + expandVars(check.auth_token);
        }
        const method = check.method || 'GET';
        const body = check.body ? expandVars(check.body) : undefined;
        if (body && !headers['Content-Type']) headers['Content-Type'] = 'application/json';

        // B3: manual redirect handling. Follow up to 3 hops, re-validating each Location.
        const MAX_REDIRECTS = 3;
        let currentUrl = url;
        let resp: Response | null = null;
        for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
          const fetchOpts: RequestInit = {
            method,
            headers,
            redirect: 'manual',
            signal: AbortSignal.timeout(10000),
          };
          if (body) fetchOpts.body = body;
          resp = await fetch(currentUrl, fetchOpts);
          if (resp.status < 300 || resp.status >= 400) break; // terminal
          const location = resp.headers.get('location');
          if (!location) break;
          // Resolve relative redirects against the current URL
          let next: string;
          try {
            next = new URL(location, currentUrl).toString();
          } catch {
            return { ...base, status: 'blocked', output: `Blocked: malformed redirect Location header from ${currentUrl}` };
          }
          if (isInternalUrl(next)) {
            return { ...base, status: 'blocked', output: `Blocked: redirect hop ${hop + 1} targets internal URL: ${next}` };
          }
          if (hop === MAX_REDIRECTS) {
            return { ...base, status: 'fail', output: `${check.label || 'HTTP'}: exceeded ${MAX_REDIRECTS} redirect hops` };
          }
          currentUrl = next;
        }
        if (!resp) {
          return { ...base, status: 'fail', output: `${check.label || 'HTTP'}: no response` };
        }
        const ok = resp.status >= 200 && resp.status < 400;
        return { ...base, status: ok ? 'ok' : 'fail', output: `${check.label || 'HTTP'}: ${ok ? 'OK' : `HTTP ${resp.status}`}` };
      } catch (e: unknown) {
        const msg = e instanceof Error ? e.message : String(e);
        if (msg.includes('TimeoutError') || msg.includes('abort')) {
          return { ...base, status: 'timeout', output: `${check.label || 'HTTP'}: timeout` };
        }
        return { ...base, status: 'fail', output: `${check.label || 'HTTP'}: ${msg}` };
      }
    }

    case 'env_exists': {
      const val = secretEnv()[check.name];
      return {
        ...base,
        status: val ? 'ok' : 'fail',
        output: `${check.label || check.name}: ${val ? 'set' : 'NOT SET'}`,
      };
    }

    case 'command': {
      // Fix 2: Gate command execution on embedded trust. Non-embedded recipes
      // (from $GBRAIN_RECIPES_DIR or ./recipes) must NOT be able to spawn arbitrary binaries.
      if (!isEmbedded) {
        return { ...base, status: 'blocked', output: `Blocked: command health_checks are restricted to embedded recipes. (${check.argv[0]})` };
      }
      try {
        const { spawnSync } = await import('child_process');
        const result = spawnSync(check.argv[0], check.argv.slice(1), {
          timeout: 10000,
          encoding: 'utf-8',
          env: process.env,
        });
        const ok = result.status === 0;
        const output = (result.stdout || '').trim() || (ok ? 'OK' : 'FAIL');
        return { ...base, status: ok ? 'ok' : 'fail', output: `${check.label || check.argv[0]}: ${output}` };
      } catch (e: unknown) {
        const msg = e instanceof Error ? e.message : String(e);
        return { ...base, status: 'fail', output: `${check.label || check.argv[0]}: ${msg}` };
      }
    }

    case 'heartbeat_max_age': {
      // No embedded gate: reads only the local heartbeat file — no exec, no
      // network. Safe for user-provided recipes.
      const maxMs = parseMaxAge(check.max_age);
      if (maxMs === null) {
        return { ...base, status: 'fail', output: `${check.label || 'heartbeat_max_age'}: invalid max_age '${check.max_age}' (use e.g. 90m, 48h, 2d)` };
      }
      const entries = readHeartbeat(integrationId);
      if (entries.length === 0) {
        return { ...base, status: 'fail', output: `${check.label || 'heartbeat'}: no heartbeat events in the last 30 days (expected activity within ${check.max_age}) — the sense has stopped producing data` };
      }
      let newest = 0;
      for (const e of entries) {
        const t = new Date(e.ts).getTime();
        if (Number.isFinite(t) && t > newest) newest = t;
      }
      const ageMs = Date.now() - newest;
      if (ageMs > maxMs) {
        return { ...base, status: 'fail', output: `${check.label || 'heartbeat'}: last event ${formatAge(ageMs)} ago exceeds max_age ${check.max_age} — the sense has stopped producing data` };
      }
      return { ...base, status: 'ok', output: `${check.label || 'heartbeat'}: last event ${formatAge(ageMs)} ago (within ${check.max_age})` };
    }

    case 'any_of': {
      for (const sub of check.checks) {
        const result = await executeHealthCheck(sub, integrationId, isEmbedded);
        if (result.status === 'ok') {
          return { ...base, status: 'ok', output: `${check.label || 'any_of'}: ${result.output}` };
        }
      }
      return { ...base, status: 'fail', output: `${check.label || 'any_of'}: all checks failed` };
    }

    default:
      return { ...base, status: 'fail', output: `Unknown check type: ${(check as any).type}` };
  }
}

// --- Recipe Parsing ---

/**
 * Parse a recipe markdown file. Uses gray-matter directly (NOT parseMarkdown,
 * which splits on --- as timeline separator and would corrupt recipe bodies
 * that use horizontal rules).
 */
export function parseRecipe(content: string, filename: string): ParsedRecipe | null {
  try {
    const { data, content: body } = matter(content);
    if (!data.id) return null;
    const installKind: InstallKind =
      data.install_kind === 'copy-into-host-repo' ? 'copy-into-host-repo' : 'local-managed';
    return {
      frontmatter: {
        id: data.id,
        name: data.name || data.id,
        version: data.version || '0.0.0',
        description: data.description || '',
        category: data.category || 'sense',
        install_kind: installKind,
        requires: data.requires || [],
        secrets: data.secrets || [],
        health_checks: (data.health_checks || []) as HealthCheck[],
        setup_time: data.setup_time || 'unknown',
        cost_estimate: data.cost_estimate,
        output_paths: Array.isArray(data.output_paths) ? data.output_paths.map(String) : [],
      },
      body: body.trim(),
      filename,
      embedded: false,
    };
  } catch {
    return null;
  }
}

// --- Embedded Recipes ---

// Recipes are loaded from multiple tiers with an explicit trust boundary:
//   TRUSTED (embedded=true):  package-bundled recipes shipped with gbrain
//     - source install: ../../recipes relative to this file
//     - global install: ~/.bun/install/global/node_modules/gbrain/recipes
//   UNTRUSTED (embedded=false): user-provided recipes discovered at runtime
//     - $GBRAIN_RECIPES_DIR
//     - ./recipes in process cwd
// The trust flag gates command/http health_checks and deprecated string health_checks.
// An attacker who drops a malicious recipe in ./recipes/ MUST NOT get embedded=true.
export function getRecipeDirs(): Array<{ dir: string; trusted: boolean }> {
  const dirs: Array<{ dir: string; trusted: boolean }> = [];
  const sourceDir = join(import.meta.dir, '../../recipes');
  if (existsSync(sourceDir)) dirs.push({ dir: sourceDir, trusted: true });
  const globalDir = join(homedir(), '.bun', 'install', 'global', 'node_modules', 'gbrain', 'recipes');
  if (existsSync(globalDir)) dirs.push({ dir: globalDir, trusted: true });
  if (process.env.GBRAIN_RECIPES_DIR && existsSync(process.env.GBRAIN_RECIPES_DIR)) {
    dirs.push({ dir: process.env.GBRAIN_RECIPES_DIR, trusted: false });
  }
  const cwdDir = join(process.cwd(), 'recipes');
  if (existsSync(cwdDir)) dirs.push({ dir: cwdDir, trusted: false });
  return dirs;
}

function loadAllRecipes(): ParsedRecipe[] {
  const dirs = getRecipeDirs();
  const recipes: ParsedRecipe[] = [];
  const seen = new Set<string>();

  for (const { dir, trusted } of dirs) {
    const files = readdirSync(dir).filter(f => f.endsWith('.md'));
    for (const file of files) {
      if (seen.has(file)) continue;
      try {
        const content = readFileSync(join(dir, file), 'utf-8');
        const recipe = parseRecipe(content, file);
        if (recipe) {
          recipe.embedded = trusted;
          recipes.push(recipe);
          seen.add(file);
        } else {
          console.error(`Warning: skipping ${file} (invalid or missing 'id' in frontmatter)`);
        }
      } catch {
        console.error(`Warning: skipping ${file} (unreadable)`);
      }
    }
  }

  return recipes;
}

/**
 * Output paths of every CONFIGURED recipe (secrets present — the collector
 * can actually be running). Ground truth for the
 * `db_only_collector_collision` doctor check and the sync-time warning
 * (issue #2788). Unconfigured recipes are skipped: a collector that can't
 * run can't silently die.
 */
export function getConfiguredCollectorOutputs(): Array<{ id: string; output_path: string }> {
  const out: Array<{ id: string; output_path: string }> = [];
  for (const r of loadAllRecipes()) {
    if (r.frontmatter.output_paths.length === 0) continue;
    if (getStatus(r) === 'available') continue;
    for (const p of r.frontmatter.output_paths) {
      out.push({ id: r.frontmatter.id, output_path: p });
    }
  }
  return out;
}

function findRecipe(id: string): ParsedRecipe | null {
  const recipes = loadAllRecipes();
  const exact = recipes.find(r => r.frontmatter.id === id);
  if (exact) return exact;

  // Fuzzy: check if id is a substring match
  const partial = recipes.filter(r =>
    r.frontmatter.id.includes(id) || r.frontmatter.name.toLowerCase().includes(id.toLowerCase())
  );
  if (partial.length === 1) return partial[0];
  if (partial.length > 1) {
    console.error(`Recipe '${id}' not found. Did you mean one of these?`);
    for (const r of partial) {
      console.error(`  ${r.frontmatter.id} — ${r.frontmatter.description}`);
    }
    return null;
  }

  console.error(`Recipe '${id}' not found.`);
  const all = recipes.map(r => r.frontmatter.id);
  if (all.length > 0) {
    console.error(`Available recipes: ${all.join(', ')}`);
  }
  return null;
}

// --- Heartbeat ---

function heartbeatDir(id: string): string {
  return gbrainPath('integrations', id);
}

function heartbeatPath(id: string): string {
  return join(heartbeatDir(id), 'heartbeat.jsonl');
}

function readHeartbeat(id: string): HeartbeatEntry[] {
  const path = heartbeatPath(id);
  if (!existsSync(path)) return [];

  try {
    const lines = readFileSync(path, 'utf-8').split('\n').filter(l => l.trim());
    const entries: HeartbeatEntry[] = [];
    const thirtyDaysAgo = Date.now() - 30 * 24 * 60 * 60 * 1000;

    for (const line of lines) {
      try {
        const entry = JSON.parse(line) as HeartbeatEntry;
        if (new Date(entry.ts).getTime() >= thirtyDaysAgo) {
          entries.push(entry);
        }
      } catch {
        // Skip malformed lines
      }
    }

    // Prune old entries on read
    if (entries.length < lines.length) {
      try {
        mkdirSync(heartbeatDir(id), { recursive: true });
        writeFileSync(path, entries.map(e => JSON.stringify(e)).join('\n') + '\n');
      } catch {
        // Non-fatal: pruning failed
      }
    }

    return entries;
  } catch {
    return [];
  }
}

// --- Secret Checking ---

export function checkSecrets(secrets: RecipeSecret[]): { set: string[]; missing: RecipeSecret[] } {
  const set: string[] = [];
  const missing: RecipeSecret[] = [];
  const env = secretEnv();
  for (const s of secrets) {
    if (env[s.name]) {
      set.push(s.name);
    } else {
      missing.push(s);
    }
  }
  return { set, missing };
}

type IntegrationStatus = 'available' | 'configured' | 'active';

function getStatus(recipe: ParsedRecipe): IntegrationStatus {
  const { set, missing } = checkSecrets(recipe.frontmatter.secrets);
  // All required secrets must be set to be "configured"
  if (missing.length > 0) return 'available';

  const heartbeat = readHeartbeat(recipe.frontmatter.id);
  const recentEvents = heartbeat.filter(e =>
    Date.now() - new Date(e.ts).getTime() < 24 * 60 * 60 * 1000
  );
  if (recentEvents.length > 0) return 'active';

  return 'configured';
}

// --- Dependency Resolution ---

function checkDependencies(recipe: ParsedRecipe, allRecipes: ParsedRecipe[]): string[] {
  const warnings: string[] = [];
  const visited = new Set<string>();

  function check(id: string, chain: string[]): void {
    if (visited.has(id)) return;
    if (chain.includes(id)) {
      warnings.push(`Circular dependency: ${chain.join(' -> ')} -> ${id}`);
      return;
    }
    visited.add(id);

    const r = allRecipes.find(r => r.frontmatter.id === id);
    if (!r && id !== recipe.frontmatter.id) {
      warnings.push(`${recipe.frontmatter.id} requires '${id}' (not found)`);
      return;
    }
    if (r) {
      for (const dep of r.frontmatter.requires) {
        check(dep, [...chain, id]);
      }
    }
  }

  for (const dep of recipe.frontmatter.requires) {
    check(dep, [recipe.frontmatter.id]);
  }

  return warnings;
}

// --- Subcommands ---

function cmdList(args: string[]): void {
  const jsonMode = args.includes('--json');
  const recipes = loadAllRecipes();

  if (recipes.length === 0) {
    if (jsonMode) {
      console.log(JSON.stringify({ senses: [], reflexes: [] }));
    } else {
      console.log('No integrations available.');
    }
    return;
  }

  const infra = recipes.filter(r => r.frontmatter.category === 'infra');
  const senses = recipes.filter(r => r.frontmatter.category === 'sense');
  const reflexes = recipes.filter(r => r.frontmatter.category === 'reflex');

  if (jsonMode) {
    const toJson = (r: ParsedRecipe) => ({
      id: r.frontmatter.id,
      name: r.frontmatter.name,
      version: r.frontmatter.version,
      description: r.frontmatter.description,
      category: r.frontmatter.category,
      status: getStatus(r),
      setup_time: r.frontmatter.setup_time,
      requires: r.frontmatter.requires,
    });
    console.log(JSON.stringify({
      infra: infra.map(toJson),
      senses: senses.map(toJson),
      reflexes: reflexes.map(toJson),
    }, null, 2));
    return;
  }

  const printSection = (title: string, items: ParsedRecipe[]) => {
    if (items.length === 0) return;
    console.log(`\n  ${title}`);
    console.log('  ' + '-'.repeat(62));
    for (const r of items) {
      const status = getStatus(r);
      const statusStr = status === 'active' ? 'ACTIVE' : status === 'configured' ? 'CONFIGURED' : 'AVAILABLE';
      const id = r.frontmatter.id.padEnd(22);
      const desc = r.frontmatter.description.slice(0, 28).padEnd(28);
      const deps = r.frontmatter.requires.length > 0 ? ` (needs ${r.frontmatter.requires.join(', ')})` : '';
      console.log(`  ${id}${desc}  ${statusStr}${deps}`);
    }
  };

  // Dashboard view
  printSection('INFRASTRUCTURE (set up first)', infra);
  printSection('SENSES (data inputs)', senses);
  printSection('REFLEXES (automated responses)', reflexes);

  // Stats summary
  const allHeartbeats = recipes.flatMap(r => readHeartbeat(r.frontmatter.id));
  const weekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
  const weekEvents = allHeartbeats.filter(e => new Date(e.ts).getTime() >= weekAgo);
  if (weekEvents.length > 0) {
    console.log(`\n  This week: ${weekEvents.length} events logged.`);
  }

  console.log("\n  Run 'gbrain integrations show <id>' for setup details.");
  console.log('');
}

function cmdShow(args: string[]): void {
  const id = args.find(a => !a.startsWith('-'));
  if (!id) {
    console.error('Usage: gbrain integrations show <recipe-id>');
    return;
  }

  const recipe = findRecipe(id);
  if (!recipe) return;

  const f = recipe.frontmatter;
  console.log(`\n${f.name} (${f.id} v${f.version})`);
  console.log(`${f.description}\n`);
  console.log(`Category:   ${f.category}`);
  console.log(`Setup time: ${f.setup_time}`);
  if (f.cost_estimate) console.log(`Cost:       ${f.cost_estimate}`);
  if (f.requires.length > 0) console.log(`Requires:   ${f.requires.join(', ')}`);

  console.log('\nSecrets needed:');
  const env = secretEnv();
  for (const s of f.secrets) {
    const isSet = env[s.name] ? '  [set]' : '  [missing]';
    console.log(`  ${s.name}${isSet}`);
    console.log(`    ${s.description}`);
    console.log(`    Get it: ${s.where}`);
  }

  if (f.health_checks.length > 0) {
    console.log(`\nHealth checks: ${f.health_checks.length} configured`);
  }

  console.log('\n--- Recipe Body ---\n');
  console.log(recipe.body);
}

function cmdStatus(args: string[]): void {
  const jsonMode = args.includes('--json');
  const id = args.find(a => !a.startsWith('-'));
  if (!id) {
    console.error('Usage: gbrain integrations status <recipe-id>');
    return;
  }

  const recipe = findRecipe(id);
  if (!recipe) return;

  const { set, missing } = checkSecrets(recipe.frontmatter.secrets);
  const heartbeat = readHeartbeat(recipe.frontmatter.id);
  const status = getStatus(recipe);

  if (jsonMode) {
    console.log(JSON.stringify({
      id: recipe.frontmatter.id,
      status,
      secrets: { set, missing: missing.map(m => ({ name: m.name, where: m.where })) },
      heartbeat: {
        total_events: heartbeat.length,
        last_event: heartbeat.length > 0 ? heartbeat[heartbeat.length - 1] : null,
      },
    }, null, 2));
    return;
  }

  console.log(`\n${recipe.frontmatter.name}: ${status.toUpperCase()}`);

  if (set.length > 0) {
    console.log('\nSecrets configured:');
    for (const s of set) console.log(`  ${s}  [set]`);
  }

  if (missing.length > 0) {
    console.log('\nMissing secrets:');
    for (const m of missing) {
      console.log(`  ${m.name}  [missing]`);
      console.log(`    Get it: ${m.where}`);
    }
  }

  if (heartbeat.length > 0) {
    const last = heartbeat[heartbeat.length - 1];
    const lastDate = new Date(last.ts);
    const ageMs = Date.now() - lastDate.getTime();
    const ageHours = Math.floor(ageMs / (60 * 60 * 1000));

    console.log(`\nLast event: ${last.event} (${ageHours}h ago)`);

    if (ageMs > 24 * 60 * 60 * 1000) {
      console.log(`  WARNING: no events in ${Math.floor(ageMs / (24 * 60 * 60 * 1000))} days`);
      console.log('  Check: is ngrok running? Is the voice server alive?');
      console.log('  Run: gbrain integrations doctor');
    }
  } else {
    console.log('\nNo heartbeat data yet.');
  }
  console.log('');
}

async function cmdDoctor(args: string[]): Promise<void> {
  const jsonMode = args.includes('--json');
  const recipes = loadAllRecipes();
  const configured = recipes.filter(r => getStatus(r) !== 'available');

  if (configured.length === 0) {
    if (jsonMode) {
      console.log(JSON.stringify({ checks: [], overall: 'no_integrations' }));
    } else {
      console.log('No configured integrations to check.');
    }
    return;
  }

  const results: CheckResult[] = [];

  for (const recipe of configured) {
    for (const check of recipe.frontmatter.health_checks) {
      const result = await executeHealthCheck(check, recipe.frontmatter.id, recipe.embedded);
      results.push(result);
    }
  }

  if (jsonMode) {
    const fails = results.filter(r => r.status !== 'ok');
    console.log(JSON.stringify({
      checks: results,
      overall: fails.length === 0 ? 'ok' : 'issues_found',
    }, null, 2));
    return;
  }

  for (const recipe of configured) {
    const checks = results.filter(r => r.integration === recipe.frontmatter.id);
    const allOk = checks.every(c => c.status === 'ok');
    console.log(`  ${recipe.frontmatter.id}: ${allOk ? 'OK' : 'ISSUES'}`);
    for (const c of checks) {
      const icon = c.status === 'ok' ? '  \u2713' : c.status === 'timeout' ? '  \u23F1' : '  \u2717';
      console.log(`${icon} ${c.output}`);
    }
  }

  const totalFails = results.filter(r => r.status !== 'ok').length;
  console.log(`\n  OVERALL: ${totalFails === 0 ? 'All checks passed' : `${totalFails} issue(s) found`}`);
}

function cmdStats(args: string[]): void {
  const jsonMode = args.includes('--json');
  const recipes = loadAllRecipes();

  const allEntries: (HeartbeatEntry & { integration: string })[] = [];
  for (const r of recipes) {
    const entries = readHeartbeat(r.frontmatter.id);
    for (const e of entries) {
      allEntries.push({ ...e, integration: r.frontmatter.id });
    }
  }

  if (allEntries.length === 0) {
    if (jsonMode) {
      console.log(JSON.stringify({ total_events: 0, message: 'No stats yet' }));
    } else {
      console.log('No stats yet. Set up an integration and start using it.');
    }
    return;
  }

  const weekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
  const weekEntries = allEntries.filter(e => new Date(e.ts).getTime() >= weekAgo);

  // Count by integration
  const bySense: Record<string, number> = {};
  for (const e of weekEntries) {
    bySense[e.integration] = (bySense[e.integration] || 0) + 1;
  }

  if (jsonMode) {
    console.log(JSON.stringify({
      total_events: allEntries.length,
      week_events: weekEntries.length,
      by_integration: bySense,
    }, null, 2));
    return;
  }

  console.log(`\n  This week: ${weekEntries.length} events`);
  const sorted = Object.entries(bySense).sort((a, b) => b[1] - a[1]);
  for (const [name, count] of sorted) {
    const pct = Math.round((count / weekEntries.length) * 100);
    console.log(`    ${name}: ${count} (${pct}%)`);
  }
  console.log(`\n  All time: ${allEntries.length} events`);
  console.log('');
}

function cmdTest(args: string[]): void {
  const filePath = args.find(a => !a.startsWith('-'));
  if (!filePath) {
    console.error('Usage: gbrain integrations test <recipe-file.md>');
    return;
  }

  if (!existsSync(filePath)) {
    console.error(`File not found: ${filePath}`);
    process.exit(1);
  }

  const content = readFileSync(filePath, 'utf-8');
  const recipe = parseRecipe(content, basename(filePath));

  if (!recipe) {
    console.error('FAIL: Could not parse recipe. Missing or invalid YAML frontmatter.');
    console.error('Required field: id');
    process.exit(1);
  }

  const errors: string[] = [];
  const warnings: string[] = [];

  // Validate required fields
  const f = recipe.frontmatter;
  if (!f.id) errors.push('Missing: id');
  if (!f.name) warnings.push('Missing: name (will default to id)');
  if (!f.description) warnings.push('Missing: description');
  if (!f.version) warnings.push('Missing: version');
  if (!['sense', 'reflex'].includes(f.category)) {
    errors.push(`Invalid category: '${f.category}' (must be 'sense' or 'reflex')`);
  }

  // Check secrets format
  for (const s of f.secrets) {
    if (!s.name) errors.push('Secret missing name');
    if (!s.where) warnings.push(`Secret '${s.name}' missing 'where' URL`);
  }

  // Check dependencies
  if (f.requires.length > 0) {
    const allRecipes = loadAllRecipes();
    const depWarnings = checkDependencies(recipe, allRecipes);
    warnings.push(...depWarnings);
  }

  // Check body isn't empty
  if (!recipe.body || recipe.body.length < 50) {
    warnings.push('Recipe body is very short (< 50 chars). Is the setup guide complete?');
  }

  // Report
  if (errors.length > 0) {
    console.log('FAIL:');
    for (const e of errors) console.log(`  ✗ ${e}`);
  }
  if (warnings.length > 0) {
    console.log('WARNINGS:');
    for (const w of warnings) console.log(`  ⚠ ${w}`);
  }
  if (errors.length === 0 && warnings.length === 0) {
    console.log(`PASS: ${f.id} v${f.version} — ${f.description}`);
  }

  if (errors.length > 0) process.exit(1);
}

function printHelp(): void {
  console.log(`gbrain integrations — manage integration recipes

USAGE
  gbrain integrations                  Show integration dashboard
  gbrain integrations list [--json]    List available integrations
  gbrain integrations show <id>        Show recipe details
  gbrain integrations status <id>      Check secrets + health
  gbrain integrations doctor [--json]  Run health checks
  gbrain integrations stats [--json]   Show signal statistics
  gbrain integrations test <file>      Validate a recipe file
`);
}

// --- Main Entry ---

// =============================================================================
// `gbrain integrations install <recipe-id>` — copy-into-host-repo path.
//
// Reads the recipe's `install/manifest.json` (sibling to `recipes/<id>.md`),
// validates the target host repo, copies each manifest entry to the target,
// computes SHA-256 hashes during the copy, writes
// <target>/services/voice-agent/.gbrain-source.json so future --refresh calls
// can do three-way classification (unchanged-identical / unchanged-stale /
// locally-modified).
//
// Target validation (path-traversal + privacy hardening):
//   - Must be an existing directory.
//   - Must NOT be gbrain itself OR a parent of gbrain.
//   - Must contain a `.git` directory (refuses missing-git-root).
//   - Must NOT contain existing files at any target path (unless --overwrite).
//   - All manifest target paths must be relative; rejects `..` and absolute.
//   - Symlink-escape check via realpath comparison.
//
// Refresh mode (`--refresh`) is documented in install/refresh-algorithm.md.
// The v0 install command implements the COPY path; refresh is a follow-up.
// =============================================================================

import { createHash } from 'node:crypto';
import {
  copyFileSync,
  statSync as fsStatSync,
  realpathSync,
  chmodSync,
  appendFileSync as fsAppendFileSync,
} from 'node:fs';
import { resolve as pathResolve, dirname as pathDirname } from 'node:path';

interface ManifestFileEntry {
  src: string;
  target: string;
  mode?: string;
}

interface InstallManifest {
  recipe: string;
  version: string;
  install_kind: InstallKind;
  target_root_relative_to_host_repo: string;
  skills_target_root_relative_to_host_repo: string;
  files: ManifestFileEntry[];
  skills: ManifestFileEntry[];
  resolver_rows_to_append?: string[];
}

interface InstalledFileRecord {
  src: string;
  target: string;
  sha256: string;
  mode: string;
}

interface GbrainSourceJson {
  recipe: string;
  gbrain_version: string;
  install_kind: InstallKind;
  copied_at: string;
  files: InstalledFileRecord[];
}

function sha256OfFile(path: string): string {
  const h = createHash('sha256');
  h.update(readFileSync(path));
  return h.digest('hex');
}

/**
 * Validate a target path inside the host repo. Rejects:
 *   - Absolute paths
 *   - Paths containing '..' segments
 *   - Paths that escape via symlink (resolved real path leaves target root)
 */
function validateManifestTarget(target: string): string | null {
  if (target.startsWith('/')) return `absolute path not allowed: ${target}`;
  if (target.includes('..')) return `parent-dir escape not allowed: ${target}`;
  if (target.includes('\0')) return `null byte in path: ${target}`;
  return null;
}

/**
 * Validate the host target repo.
 *   - Exists + is a directory
 *   - Has a `.git` (refuses missing-git-root)
 *   - Not gbrain itself; not a parent of gbrain
 *   - Refuses if any manifest target already exists (unless --overwrite)
 */
function validateTargetRepo(
  targetRepo: string,
  manifestEntries: ManifestFileEntry[],
  overwrite: boolean,
): string | null {
  let resolvedTarget: string;
  try {
    resolvedTarget = realpathSync(targetRepo);
  } catch {
    return `target repo does not exist or is not accessible: ${targetRepo}`;
  }

  let stat;
  try {
    stat = fsStatSync(resolvedTarget);
  } catch {
    return `target repo stat failed: ${resolvedTarget}`;
  }
  if (!stat.isDirectory()) return `target is not a directory: ${resolvedTarget}`;

  // Refuse if target is gbrain itself or contains gbrain.
  let gbrainRoot: string | null = null;
  try {
    gbrainRoot = realpathSync(pathResolve(__dirname, '..', '..'));
  } catch {
    // ignore — non-fatal
  }
  if (gbrainRoot && (resolvedTarget === gbrainRoot || gbrainRoot.startsWith(resolvedTarget + '/'))) {
    return `refusing to install into gbrain itself (or a parent dir): ${resolvedTarget}`;
  }

  // Must have a .git
  try {
    const gitStat = fsStatSync(join(resolvedTarget, '.git'));
    if (!gitStat.isDirectory() && !gitStat.isFile()) {
      return `target has no .git: ${resolvedTarget}`;
    }
  } catch {
    return `target has no .git: ${resolvedTarget}`;
  }

  if (!overwrite) {
    for (const entry of manifestEntries) {
      const targetPath = join(resolvedTarget, entry.target);
      try {
        fsStatSync(targetPath);
        return `refusing to overwrite existing file at ${entry.target} (pass --overwrite to force)`;
      } catch {
        // not exists; fine
      }
    }
  }

  return null;
}

interface InstallOpts {
  target: string;
  refresh?: boolean;
  overwrite?: boolean;
  dryRun?: boolean;
  autoMode?: 'keep-mine' | 'take-theirs' | null;
}

// Per-file refresh classification per recipes/agent-voice/install/refresh-algorithm.md.
type FileRefreshState =
  | 'unchanged-identical'
  | 'unchanged-stale'
  | 'locally-modified'
  | 'source-deleted'
  | 'host-deleted'
  | 'new-in-manifest';

interface RefreshClassification {
  src: string;
  target: string;
  state: FileRefreshState;
  recordedSha?: string;
  currentSrcSha?: string;
  currentHostSha?: string;
}

/**
 * Compute SHA-256 of a string buffer.
 */
function sha256OfBuffer(buf: Buffer): string {
  const h = createHash('sha256');
  h.update(buf);
  return h.digest('hex');
}

/**
 * Three-way classification for refresh mode.
 *
 * For every manifest entry + every host file:
 *   - identical:        host hash == src hash → no-op
 *   - stale:            host hash == recorded hash && host hash != src hash → safe to update
 *   - locally-modified: host hash != recorded hash && host hash != src hash → operator edited
 *   - source-deleted:   manifest dropped this file; host still has it
 *   - host-deleted:     manifest has it; host file missing
 *   - new-in-manifest:  file in manifest, not in prior install record
 */
function classifyForRefresh(
  manifestEntries: ManifestFileEntry[],
  recordedFiles: InstalledFileRecord[],
  recipeBundleRoot: string,
  resolvedTarget: string,
): RefreshClassification[] {
  const recordedByTarget = new Map<string, InstalledFileRecord>();
  for (const r of recordedFiles) recordedByTarget.set(r.target, r);

  const classifications: RefreshClassification[] = [];
  const manifestTargets = new Set<string>();

  // Pass 1: walk current manifest entries.
  for (const entry of manifestEntries) {
    manifestTargets.add(entry.target);
    const srcPath = pathResolve(recipeBundleRoot, entry.src);
    const targetPath = pathResolve(resolvedTarget, entry.target);

    let currentSrcSha: string | undefined;
    try {
      currentSrcSha = sha256OfBuffer(readFileSync(srcPath));
    } catch {
      // src missing? Skip — this would mean a manifest pointing at a missing file in gbrain.
      continue;
    }

    let currentHostSha: string | undefined;
    let hostExists = false;
    try {
      currentHostSha = sha256OfBuffer(readFileSync(targetPath));
      hostExists = true;
    } catch {
      hostExists = false;
    }

    const recorded = recordedByTarget.get(entry.target);

    if (!hostExists) {
      classifications.push({ src: entry.src, target: entry.target, state: 'host-deleted', recordedSha: recorded?.sha256, currentSrcSha });
      continue;
    }

    if (!recorded) {
      classifications.push({ src: entry.src, target: entry.target, state: 'new-in-manifest', currentSrcSha, currentHostSha });
      continue;
    }

    if (currentHostSha === currentSrcSha) {
      classifications.push({ src: entry.src, target: entry.target, state: 'unchanged-identical', recordedSha: recorded.sha256, currentSrcSha, currentHostSha });
    } else if (currentHostSha === recorded.sha256) {
      classifications.push({ src: entry.src, target: entry.target, state: 'unchanged-stale', recordedSha: recorded.sha256, currentSrcSha, currentHostSha });
    } else {
      classifications.push({ src: entry.src, target: entry.target, state: 'locally-modified', recordedSha: recorded.sha256, currentSrcSha, currentHostSha });
    }
  }

  // Pass 2: anything in recorded but NOT in current manifest = source-deleted.
  for (const r of recordedFiles) {
    if (!manifestTargets.has(r.target)) {
      classifications.push({ src: r.src, target: r.target, state: 'source-deleted', recordedSha: r.sha256 });
    }
  }

  return classifications;
}

/**
 * Append one event to the refresh transaction journal.
 */
function appendRefreshLog(targetVoiceAgentDir: string, event: object) {
  try {
    const logPath = pathResolve(targetVoiceAgentDir, '.gbrain-source.refresh.log');
    fsAppendFileSync(logPath, JSON.stringify({ ts: new Date().toISOString(), ...event }) + '\n');
  } catch {
    /* non-fatal */
  }
}

/**
 * Refresh mode: read `.gbrain-source.json`, classify, apply decisions.
 */
async function refreshRecipeIntoHostRepo(
  recipeId: string,
  opts: InstallOpts,
): Promise<{ classifications: RefreshClassification[]; applied: number; manifestPath: string }> {
  const recipe = findRecipe(recipeId);
  if (!recipe) throw new Error(`recipe not found: ${recipeId}`);
  if (recipe.frontmatter.install_kind !== 'copy-into-host-repo') {
    throw new Error(`recipe ${recipeId} is not copy-into-host-repo (install_kind=${recipe.frontmatter.install_kind})`);
  }

  const recipeBundleRoot = pathResolve(
    pathDirname(pathResolve(__dirname, '..', '..', 'recipes', recipe.filename)),
    recipe.filename.replace(/\.md$/, ''),
  );
  const manifestPath = join(recipeBundleRoot, 'install', 'manifest.json');
  const manifest: InstallManifest = JSON.parse(readFileSync(manifestPath, 'utf8'));

  let resolvedTarget: string;
  try {
    resolvedTarget = realpathSync(opts.target);
  } catch {
    throw new Error(`target repo does not exist: ${opts.target}`);
  }

  const sourceFilePath = pathResolve(
    resolvedTarget,
    manifest.target_root_relative_to_host_repo,
    '.gbrain-source.json',
  );
  if (!existsSync(sourceFilePath)) {
    throw new Error(`.gbrain-source.json not found at ${sourceFilePath} — this target was never installed via copy-into-host-repo; run without --refresh first`);
  }

  let recorded: GbrainSourceJson;
  try {
    recorded = JSON.parse(readFileSync(sourceFilePath, 'utf8'));
  } catch (err) {
    throw new Error(`failed to parse .gbrain-source.json: ${(err as Error).message}`);
  }

  if (recorded.recipe !== recipeId) {
    throw new Error(`.gbrain-source.json recipe="${recorded.recipe}" does not match requested recipe="${recipeId}"`);
  }

  const allManifestEntries: ManifestFileEntry[] = [...(manifest.files || []), ...(manifest.skills || [])];
  const classifications = classifyForRefresh(allManifestEntries, recorded.files || [], recipeBundleRoot, resolvedTarget);

  // Print classification summary.
  const counts: Record<string, number> = {};
  for (const c of classifications) counts[c.state] = (counts[c.state] || 0) + 1;
  console.log(`[refresh] ${recipeId} → ${resolvedTarget}`);
  for (const [state, n] of Object.entries(counts)) {
    console.log(`  ${state}: ${n}`);
  }

  if (opts.dryRun) {
    console.log('[refresh] DRY RUN — no files written. Per-file detail:');
    for (const c of classifications) {
      console.log(`  [${c.state}] ${c.target}`);
    }
    return { classifications, applied: 0, manifestPath };
  }

  const targetVoiceAgentDir = pathResolve(resolvedTarget, manifest.target_root_relative_to_host_repo);
  appendRefreshLog(targetVoiceAgentDir, { event: 'refresh_started', recipe: recipeId, counts });

  let applied = 0;
  const updatedFiles: InstalledFileRecord[] = [];

  for (const c of classifications) {
    const srcAbs = pathResolve(recipeBundleRoot, c.src);
    const targetAbs = pathResolve(resolvedTarget, c.target);
    const manifestEntry = allManifestEntries.find((e) => e.target === c.target);

    switch (c.state) {
      case 'unchanged-identical': {
        // No-op. Carry forward the recorded entry.
        const r = recorded.files.find((f) => f.target === c.target);
        if (r) updatedFiles.push(r);
        break;
      }
      case 'unchanged-stale': {
        // Operator's file matches the recorded SHA; source has moved. Auto-update.
        copyFileSync(srcAbs, targetAbs);
        if (manifestEntry?.mode) {
          try { chmodSync(targetAbs, parseInt(manifestEntry.mode, 8)); } catch { /* ignore */ }
        }
        const newSha = sha256OfBuffer(readFileSync(srcAbs));
        updatedFiles.push({ src: c.src, target: c.target, sha256: newSha, mode: manifestEntry?.mode || '0644' });
        applied++;
        appendRefreshLog(targetVoiceAgentDir, { event: 'updated', src: c.src, target: c.target, decision: 'take-theirs' });
        break;
      }
      case 'locally-modified': {
        const decision = opts.autoMode || 'keep-mine'; // Default to safety: preserve local edit.
        if (decision === 'take-theirs') {
          copyFileSync(srcAbs, targetAbs);
          if (manifestEntry?.mode) {
            try { chmodSync(targetAbs, parseInt(manifestEntry.mode, 8)); } catch { /* ignore */ }
          }
          const newSha = sha256OfBuffer(readFileSync(srcAbs));
          updatedFiles.push({ src: c.src, target: c.target, sha256: newSha, mode: manifestEntry?.mode || '0644' });
          applied++;
          appendRefreshLog(targetVoiceAgentDir, { event: 'overwrote_local', src: c.src, target: c.target, decision: 'take-theirs' });
        } else {
          // keep-mine — the operator's file stays; we update the recorded SHA to their current host SHA
          // so future refreshes don't re-flag the same file until either side changes again.
          updatedFiles.push({ src: c.src, target: c.target, sha256: c.currentHostSha!, mode: manifestEntry?.mode || '0644' });
          appendRefreshLog(targetVoiceAgentDir, { event: 'preserved_local', src: c.src, target: c.target, decision: 'keep-mine' });
        }
        console.log(`  [locally-modified] ${c.target} → ${decision}`);
        break;
      }
      case 'host-deleted': {
        // Operator removed the file. Offer to restore (auto-mode 'take-theirs') or leave it gone (default).
        const decision = opts.autoMode === 'take-theirs' ? 'restore' : 'leave-deleted';
        if (decision === 'restore') {
          mkdirSync(pathDirname(targetAbs), { recursive: true });
          copyFileSync(srcAbs, targetAbs);
          if (manifestEntry?.mode) {
            try { chmodSync(targetAbs, parseInt(manifestEntry.mode, 8)); } catch { /* ignore */ }
          }
          const newSha = sha256OfBuffer(readFileSync(srcAbs));
          updatedFiles.push({ src: c.src, target: c.target, sha256: newSha, mode: manifestEntry?.mode || '0644' });
          applied++;
          appendRefreshLog(targetVoiceAgentDir, { event: 'restored', src: c.src, target: c.target, decision: 'restore' });
        } else {
          appendRefreshLog(targetVoiceAgentDir, { event: 'host_deleted_left_alone', src: c.src, target: c.target });
          // Don't carry forward into updatedFiles — the file is genuinely gone.
        }
        console.log(`  [host-deleted] ${c.target} → ${decision}`);
        break;
      }
      case 'source-deleted': {
        // gbrain reference removed this file; offer cleanup with --auto take-theirs.
        const decision = opts.autoMode === 'take-theirs' ? 'cleanup' : 'leave-orphan';
        if (decision === 'cleanup') {
          try {
            // Just unlink — keep things conservative.
            const unlinkSync = require('node:fs').unlinkSync;
            unlinkSync(targetAbs);
            applied++;
            appendRefreshLog(targetVoiceAgentDir, { event: 'removed_orphan', target: c.target, decision: 'cleanup' });
          } catch (err) {
            console.warn(`  [source-deleted] failed to remove orphan ${c.target}: ${(err as Error).message}`);
          }
        } else {
          appendRefreshLog(targetVoiceAgentDir, { event: 'orphan_left_alone', target: c.target });
        }
        console.log(`  [source-deleted] ${c.target} → ${decision}`);
        break;
      }
      case 'new-in-manifest': {
        // Wasn't in the recorded manifest; was added in this refresh. Default: install it.
        mkdirSync(pathDirname(targetAbs), { recursive: true });
        copyFileSync(srcAbs, targetAbs);
        if (manifestEntry?.mode) {
          try { chmodSync(targetAbs, parseInt(manifestEntry.mode, 8)); } catch { /* ignore */ }
        }
        const newSha = sha256OfBuffer(readFileSync(srcAbs));
        updatedFiles.push({ src: c.src, target: c.target, sha256: newSha, mode: manifestEntry?.mode || '0644' });
        applied++;
        appendRefreshLog(targetVoiceAgentDir, { event: 'added_new', src: c.src, target: c.target });
        break;
      }
    }
  }

  // Re-write .gbrain-source.json with the updated SHAs.
  const gbrainVersion = (() => {
    try {
      const pkgPath = pathResolve(__dirname, '..', '..', 'package.json');
      return JSON.parse(readFileSync(pkgPath, 'utf8')).version || 'unknown';
    } catch { return 'unknown'; }
  })();

  const updatedRecord: GbrainSourceJson = {
    recipe: recipeId,
    gbrain_version: gbrainVersion,
    install_kind: 'copy-into-host-repo',
    copied_at: new Date().toISOString(),
    files: updatedFiles,
  };
  const fsModule = require('node:fs');
  fsModule.writeFileSync(sourceFilePath, JSON.stringify(updatedRecord, null, 2) + '\n');
  appendRefreshLog(targetVoiceAgentDir, { event: 'refresh_complete', applied });

  return { classifications, applied, manifestPath };
}

export { refreshRecipeIntoHostRepo, classifyForRefresh };

export async function installRecipeIntoHostRepo(
  recipeId: string,
  opts: InstallOpts,
): Promise<{ written: number; manifestPath: string }> {
  const recipe = findRecipe(recipeId);
  if (!recipe) throw new Error(`recipe not found: ${recipeId}`);
  if (recipe.frontmatter.install_kind !== 'copy-into-host-repo') {
    throw new Error(
      `recipe ${recipeId} uses install_kind=${recipe.frontmatter.install_kind}; ` +
        `this command only supports copy-into-host-repo recipes.`,
    );
  }

  // Find the recipe bundle root: recipes/<id>/ (sibling to recipes/<id>.md).
  const recipeBundleRoot = pathResolve(
    pathDirname(pathResolve(__dirname, '..', '..', 'recipes', recipe.filename)),
    recipe.filename.replace(/\.md$/, ''),
  );

  const manifestPath = join(recipeBundleRoot, 'install', 'manifest.json');
  let manifest: InstallManifest;
  try {
    manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
  } catch (err) {
    throw new Error(`failed to read manifest at ${manifestPath}: ${(err as Error).message}`);
  }

  // Combine file + skill entries for the validation + copy loop.
  const allEntries: ManifestFileEntry[] = [
    ...(manifest.files || []),
    ...(manifest.skills || []),
  ];

  // Validate every manifest target path.
  for (const entry of allEntries) {
    const reason = validateManifestTarget(entry.target);
    if (reason) throw new Error(`manifest entry invalid (${entry.src} → ${entry.target}): ${reason}`);
  }

  // Validate the target repo.
  const targetRepoError = validateTargetRepo(opts.target, allEntries, !!opts.overwrite);
  if (targetRepoError) throw new Error(targetRepoError);

  const resolvedTarget = realpathSync(opts.target);

  if (opts.dryRun) {
    console.log(`[install] DRY RUN — would copy ${allEntries.length} files into ${resolvedTarget}`);
    for (const entry of allEntries) {
      console.log(`  ${entry.src} → ${entry.target}`);
    }
    return { written: 0, manifestPath };
  }

  // Copy each entry.
  const installedRecords: InstalledFileRecord[] = [];
  for (const entry of allEntries) {
    const srcPath = join(recipeBundleRoot, entry.src);
    const targetPath = join(resolvedTarget, entry.target);
    mkdirSync(pathDirname(targetPath), { recursive: true });
    copyFileSync(srcPath, targetPath);
    if (entry.mode) {
      try { chmodSync(targetPath, parseInt(entry.mode, 8)); } catch { /* non-fatal */ }
    }
    const hash = sha256OfFile(srcPath);
    installedRecords.push({
      src: entry.src,
      target: entry.target,
      sha256: hash,
      mode: entry.mode || '0644',
    });
  }

  // Write the .gbrain-source.json manifest into the target repo.
  // Per D11-A: NO upstream_repo field, NO imported_from field.
  const gbrainVersion = (() => {
    try {
      const pkgPath = pathResolve(__dirname, '..', '..', 'package.json');
      return JSON.parse(readFileSync(pkgPath, 'utf8')).version || 'unknown';
    } catch { return 'unknown'; }
  })();

  const gbrainSource: GbrainSourceJson = {
    recipe: recipeId,
    gbrain_version: gbrainVersion,
    install_kind: 'copy-into-host-repo',
    copied_at: new Date().toISOString(),
    files: installedRecords,
  };

  const sourceFilePath = join(
    resolvedTarget,
    manifest.target_root_relative_to_host_repo,
    '.gbrain-source.json',
  );
  mkdirSync(pathDirname(sourceFilePath), { recursive: true });
  writeFileSync(sourceFilePath, JSON.stringify(gbrainSource, null, 2) + '\n');

  // Append resolver rows (if any) to the host's RESOLVER.md or AGENTS.md.
  if (manifest.resolver_rows_to_append && manifest.resolver_rows_to_append.length > 0) {
    const resolverCandidates = ['RESOLVER.md', 'AGENTS.md', 'skills/RESOLVER.md', 'skills/AGENTS.md'];
    let resolverPath: string | null = null;
    for (const candidate of resolverCandidates) {
      const candidatePath = join(resolvedTarget, candidate);
      try {
        fsStatSync(candidatePath);
        resolverPath = candidatePath;
        break;
      } catch { /* not present */ }
    }
    if (resolverPath) {
      // Fence the appended rows by the RECIPE id, not a hardcoded recipe name.
      // The pre-v0.42 fence was literally `gbrain:agent-voice:resolver-rows`,
      // so any second copy-into-host-repo recipe (e.g. retrieval-reflex) wrote
      // an agent-voice-labeled block — and `--refresh`/uninstall keyed on the
      // recipe id would never find it. Derive the fence from manifest.recipe.
      const fenceId = manifest.recipe || 'gbrain';
      const rowsBlock = `\n\n<!-- gbrain:${fenceId}:resolver-rows -->\n` +
        manifest.resolver_rows_to_append.map((r) => `- ${r}`).join('\n') +
        `\n<!-- /gbrain:${fenceId}:resolver-rows -->\n`;
      fsAppendFileSync(resolverPath, rowsBlock);
    } else {
      console.warn(
        `[install] no RESOLVER.md or AGENTS.md in target repo; ` +
          `add these rows manually:\n` +
          manifest.resolver_rows_to_append.map((r) => `  ${r}`).join('\n'),
      );
    }
  }

  return { written: installedRecords.length, manifestPath };
}

async function cmdInstall(args: string[]): Promise<void> {
  let recipeId: string | null = null;
  const opts: InstallOpts = { target: '' };

  for (let i = 0; i < args.length; i++) {
    const arg = args[i];
    if (arg === '--target' || arg === '-t') {
      opts.target = args[++i];
    } else if (arg === '--refresh') {
      opts.refresh = true;
    } else if (arg === '--overwrite') {
      opts.overwrite = true;
    } else if (arg === '--dry-run' || arg === '-n') {
      opts.dryRun = true;
    } else if (arg === '--auto') {
      const mode = args[++i];
      if (mode !== 'keep-mine' && mode !== 'take-theirs') {
        console.error(`--auto must be 'keep-mine' or 'take-theirs', got: ${mode}`);
        process.exit(2);
      }
      opts.autoMode = mode;
    } else if (arg === '--help' || arg === '-h') {
      console.log('Usage:');
      console.log('  gbrain integrations install <recipe-id> --target <host-repo-path> [--overwrite] [--dry-run]');
      console.log('  gbrain integrations install <recipe-id> --target <host-repo-path> --refresh [--auto keep-mine|take-theirs] [--dry-run]');
      return;
    } else if (!recipeId && !arg.startsWith('-')) {
      recipeId = arg;
    }
  }

  if (!recipeId) {
    console.error('Usage: gbrain integrations install <recipe-id> --target <host-repo-path>');
    process.exit(2);
  }
  if (!opts.target) {
    opts.target = process.env.OPENCLAW_WORKSPACE || '';
    if (!opts.target) {
      console.error('--target <host-repo-path> required (or set $OPENCLAW_WORKSPACE)');
      process.exit(2);
    }
  }

  try {
    if (opts.refresh) {
      const { applied, manifestPath } = await refreshRecipeIntoHostRepo(recipeId, opts);
      console.log(`[refresh] ${recipeId}: applied ${applied} changes to ${realpathSync(opts.target)}`);
      console.log(`[refresh] manifest: ${manifestPath}`);
      if (opts.dryRun) {
        console.log('[refresh] DRY RUN — no files written.');
      }
    } else {
      const { written, manifestPath } = await installRecipeIntoHostRepo(recipeId, opts);
      console.log(`[install] ${recipeId}: copied ${written} files into ${realpathSync(opts.target)}`);
      console.log(`[install] manifest: ${manifestPath}`);
      if (!opts.dryRun) {
        console.log('[install] next steps: see recipes/' + recipeId + '/install/post-install-hint.md');
      }
    }
  } catch (err) {
    console.error(`[install] FAIL: ${(err as Error).message}`);
    process.exit(1);
  }
}

export async function runIntegrations(args: string[]): Promise<void> {
  const sub = args[0];

  if (!sub || sub === '--help' || sub === '-h') {
    if (!sub) {
      // Bare command: show dashboard
      cmdList([]);
    } else {
      printHelp();
    }
    return;
  }

  const subArgs = args.slice(1);

  switch (sub) {
    case 'list':
      cmdList(subArgs);
      break;
    case 'show':
      cmdShow(subArgs);
      break;
    case 'install':
      await cmdInstall(subArgs);
      break;
    case 'status':
      cmdStatus(subArgs);
      break;
    case 'doctor':
      await cmdDoctor(subArgs);
      break;
    case 'stats':
      cmdStats(subArgs);
      break;
    case 'test':
      cmdTest(subArgs);
      break;
    default:
      console.error(`Unknown subcommand: ${sub}`);
      printHelp();
      process.exit(1);
  }
}
