import { readdirSync, lstatSync, existsSync } from 'fs';
import { execFileSync } from 'child_process';
import { join, relative } from 'path';
import { cpus, totalmem } from 'os';
import type { BrainEngine } from '../core/engine.ts';
import { importFile, importImageFile, isImageFilePath } from '../core/import-file.ts';
import { loadConfig, gbrainPath } from '../core/config.ts';
import { createProgress } from '../core/progress.ts';
import { getCliOptions, cliOptsToProgressOptions } from '../core/cli-options.ts';
import {
  isCodeFilePath,
  isMarkdownFilePath,
  isImageFilePath as isImageFilePathFromSync,
  matchesAnyGlob,
  pruneDir,
  SYNC_SKIP_FILES,
  type SyncStrategy,
} from '../core/sync.ts';
import { sortNewestFirst } from '../core/sort-newest-first.ts';
import {
  loadCheckpoint,
  saveCheckpoint,
  clearCheckpoint,
  resolveImportTargetDir,
  resumeFilter,
} from '../core/import-checkpoint.ts';

function defaultWorkers(): number {
  const cpuCount = cpus().length;
  const memGB = totalmem() / (1024 ** 3);
  // Network-bound, so we can go higher than CPU count.
  // Cap by: DB pool (leave 2 for other queries), CPU, memory.
  const byPool = 8;
  const byCpu = Math.max(2, cpuCount);
  const byMem = Math.floor(memGB * 2);
  return Math.min(byPool, byCpu, byMem);
}

/** Bug 9 — surface per-file failures so callers (performFullSync) can gate state advances. */
export interface RunImportResult {
  imported: number;
  skipped: number;
  errors: number;
  chunksCreated: number;
  failures: Array<{ path: string; error: string }>;
}

export async function runImport(
  engine: BrainEngine,
  args: string[],
  opts: {
    commit?: string;
    strategy?: SyncStrategy;
    sourceId?: string;
    managedBookmark?: boolean;
    /**
     * #753/#774: glob patterns to exclude from the import (same semantics as
     * `isSyncable`'s `exclude` — matched against the dir-relative path).
     * Threaded by performFullSync for `gbrain sync --exclude`.
     */
    exclude?: string[];
    /**
     * Opt out of the git-visible fast path and walk the filesystem directly,
     * so markdown/code files matched by .gitignore can still be imported.
     */
    includeGitignored?: boolean;
    /**
     * #753/#774 monorepo subdir-source support: when set, slugs and
     * `source_path` are computed relative to this root (the git repo root)
     * instead of `dir` (the sync scope), so `wiki/page1.md` lands as slug
     * `wiki/page1` consistently across full and incremental sync.
     */
    slugRoot?: string;
  } = {},
): Promise<RunImportResult> {
  const noEmbed = args.includes('--no-embed');
  const fresh = args.includes('--fresh');
  const jsonOutput = args.includes('--json');
  const includeGitignored = args.includes('--include-gitignored') || opts.includeGitignored === true;

  // #3637: under --json, stdout belongs to the JSON document alone. The
  // informational lines below are useful — they just belong on the other
  // channel, the same rule progress already follows (CLAUDE.md: "Progress
  // always writes to stderr. Stdout stays clean for data output (--json
  // payloads)"). Pre-fix, `import --json` prefixed the payload with
  // "Found N markdown files", so JSON.parse of stdout failed outright.
  const info = (msg: string): void => {
    if (jsonOutput) console.error(msg);
    else console.log(msg);
  };

  // T7 (D9): refuse cleanly when init persisted the deferred-setup sentinel,
  // unless the user is explicitly skipping embedding via `--no-embed` (in
  // which case the chunks land without vectors and the user can backfill
  // later with `gbrain embed --stale` after configuring a provider).
  if (!noEmbed) {
    const { assertEmbeddingEnabled } = await import('../core/embedding-dim-check.ts');
    const { loadConfig } = await import('../core/config.ts');
    try {
      assertEmbeddingEnabled(loadConfig());
    } catch (e) {
      console.error(`\n${e instanceof Error ? e.message : e}`);
      console.error('Tip: run `gbrain import <dir> --no-embed` to import without embedding now.');
      process.exit(1);
    }

    // v0.41.6.0 D1: preflight embedding credentials. Closes the bug class
    // where `gbrain import` per-file embed writes N identical
    // "missing OPENAI_API_KEY" failures into sync-failures.jsonl.
    const { validateEmbeddingCreds, EmbeddingCredentialError } = await import('../core/embed-preflight.ts');
    try {
      validateEmbeddingCreds();
    } catch (e) {
      if (e instanceof EmbeddingCredentialError) {
        if (jsonOutput) {
          console.log(JSON.stringify({ status: 'embedding_credentials_missing', diagnosis: e.diagnosis }));
        } else {
          console.error('');
          console.error(e.userMessage);
          console.error('');
        }
        process.exit(1);
      }
      throw e;
    }
  }
  // v0.39 T1.5: load active pack ONCE at runImport entry; thread to every
  // per-file importFile call below. Codex perf finding #7 — never per-file.
  let importActivePack: { page_types: ReadonlyArray<{ name: string; path_prefixes: ReadonlyArray<string> }> } | undefined;
  try {
    const { loadActivePack } = await import('../core/schema-pack/load-active.ts');
    const { loadConfig } = await import('../core/config.ts');
    const resolved = await loadActivePack({
      cfg: loadConfig(),
      remote: false, // CLI import is trusted
      sourceId: opts.sourceId,
    });
    importActivePack = { page_types: resolved.manifest.page_types };
  } catch {
    importActivePack = undefined;
  }

  // v0.30.x follow-up to PR #707: programmatic sourceId support so internal
  // callers (performFullSync, future Step 6 paths) can route to a named
  // source.
  //
  // v0.37.7.0 #1167+#1222: the CLI surface now also accepts a
  // `--source-id <id>` flag (named to avoid colliding with `--source`
  // which other commands use for different axes). Pre-fix, users
  // passing `gbrain import --source dept-x ...` silently fell back to
  // default because the parser ignored the flag. Now an explicit
  // `--source-id <id>` opt-in routes the import to that source.
  // Programmatic callers continue passing `opts.sourceId` directly;
  // CLI callers' flag wins over opts when both are set.
  const sourceIdIdx = args.indexOf('--source-id');
  const flagSourceId = sourceIdIdx !== -1 ? args[sourceIdIdx + 1] : null;
  let sourceId: string | undefined = flagSourceId ?? opts.sourceId;

  // v0.41.13 (#1434): when no explicit source / env / opts.sourceId is set,
  // fall through to the resolver so the new sole_non_default tier (5.5) can
  // auto-route to the only registered non-default source. Pre-fix, import
  // followed the explicit-only design from PR #707 and silently routed
  // every import to 'default', mirroring the sync bug class.
  //
  // Resolution chain (full 7 tiers): flag → env → dotfile → local_path →
  // brain_default → sole_non_default → seed_default. The nudge fires only
  // when the resolver returns tier='sole_non_default', so explicit users
  // see no behavior change.
  if (!sourceId && process.env.GBRAIN_SOURCE) {
    const { resolveSourceId } = await import('../core/source-resolver.ts');
    sourceId = await resolveSourceId(engine, null);
  } else if (!sourceId) {
    const { resolveSourceWithTier, formatSoleNonDefaultNudge } = await import('../core/source-resolver.ts');
    const resolved = await resolveSourceWithTier(engine, null);
    // Only adopt the resolution when it improves on the seed_default
    // fallback — that preserves the v0.30.x "default-only when unset"
    // contract for the common case AND opens the sole_non_default
    // auto-route for the single-source-brain case.
    if (resolved.tier === 'sole_non_default') {
      sourceId = resolved.source_id;
      const nudge = formatSoleNonDefaultNudge(sourceId);
      if (nudge) process.stderr.write(nudge + '\n');
    }
  }
  const workersIdx = args.indexOf('--workers');
  const workersArg = workersIdx !== -1 ? args[workersIdx + 1] : null;
  // v0.22.13 (PR #490 Q2): shared parseWorkers helper rejects bad input
  // (--workers 0, -3, "foo") with a loud error instead of silently falling
  // through to 1. Mirrors sync.ts's flag handling.
  const { parseWorkers } = await import('../core/sync-concurrency.ts');
  let workerCount: number;
  try {
    workerCount = parseWorkers(workersArg ?? undefined) ?? 1;
  } catch (e) {
    console.error(e instanceof Error ? e.message : String(e));
    process.exit(1);
  }
  // Find dir: first non-flag arg that isn't a value for --workers
  const flagValues = new Set<number>();
  if (workersIdx !== -1) flagValues.add(workersIdx + 1);
  if (sourceIdIdx !== -1) flagValues.add(sourceIdIdx + 1);
  const dirArg = args.find((a, i) => !a.startsWith('--') && !flagValues.has(i));

  if (!dirArg) {
    console.error('Usage: gbrain import <dir> [--no-embed] [--workers N] [--fresh] [--source-id <id>] [--include-gitignored] [--json]');
    process.exit(1);
  }
  // #1728: capture the import target ONCE as an absolute real path. Every
  // downstream consumer of `dir` (collection, checkpoint load/save, resume
  // filtering) sees the same canonical identity — never the caller's `.`/
  // relative spelling, which would make the persisted checkpoint `dir`
  // resolve against whatever CWD a later process happens to run from.
  let dir: string;
  try {
    dir = resolveImportTargetDir(dirArg);
  } catch (e) {
    const msg = e instanceof Error ? e.message : String(e);
    console.error(`Import target is not readable: ${dirArg} (${msg})`);
    process.exit(1);
  }

  // v0.31.2: collect under the right strategy. Pre-fix this called
  // collectMarkdownFiles unconditionally — code-strategy first sync
  // silently no-op'd because no code file ever made it through walker
  // enumeration (codex C11 confirms dispatch was correct; bug was here).
  const strategy: SyncStrategy = opts.strategy ?? 'markdown';
  const _walkT0 = Date.now();
  console.error(`[gbrain phase] import.collect_files start dir=${dir} strategy=${strategy}`);
  let allFiles = collectSyncableFiles(dir, { strategy, includeGitignored });
  console.error(
    `[gbrain phase] import.collect_files done ${Date.now() - _walkT0}ms files=${allFiles.length}`,
  );
  const fileTypeLabel = strategy === 'code' ? 'code'
    : strategy === 'auto' ? 'syncable' : 'markdown';
  // #753/#774: apply --exclude glob patterns (threaded by performFullSync).
  if (opts.exclude && opts.exclude.length > 0) {
    const beforeExclude = allFiles.length;
    allFiles = allFiles.filter(abs => !matchesAnyGlob(relative(dir, abs), opts.exclude));
    info(
      `Found ${allFiles.length} ${fileTypeLabel} files ` +
      `(${beforeExclude - allFiles.length} excluded by --exclude patterns)`,
    );
    // NAV-4: everything excluded is almost always a mistyped pattern — warn.
    if (beforeExclude > 0 && allFiles.length === 0) {
      console.warn(
        `[gbrain sync] No files matched after applying ${opts.exclude.length} --exclude pattern(s). ` +
        `Check your --exclude flags. Patterns: ${JSON.stringify(opts.exclude)}`,
      );
    }
  } else {
    info(`Found ${allFiles.length} ${fileTypeLabel} files`);
  }

  // Sort newest-first so date-prefixed brain paths get embedded before older ones.
  // See src/core/sort-newest-first.ts for the policy.
  sortNewestFirst(allFiles);

  // Resume from checkpoint if available. v0.33.2: path-based resume —
  // see src/core/import-checkpoint.ts for the bug-class this fixes
  // (parallel-import silent-skip and failed-file no-retry).
  const checkpointPath = gbrainPath('import-checkpoint.json');
  const completed = new Set<string>();
  if (!fresh) {
    const cp = loadCheckpoint(checkpointPath, dir);
    if (cp) {
      for (const p of cp.completedPaths) completed.add(p);
      info(`Resuming from checkpoint: skipping ${completed.size} already-processed files`);
    }
  }
  const files = resumeFilter(allFiles, dir, completed);

  // Determine actual worker count
  const actualWorkers = workerCount > 1 ? workerCount : 1;
  if (actualWorkers > 1) {
    info(`Using ${actualWorkers} parallel workers`);
  }

  let imported = 0;
  let skipped = 0;
  let errors = 0;
  let processed = 0;
  // Time-based checkpoint floor (see the save site below). Chunking cost scales
  // with paragraph count, not bytes, so a single reference-style file can take
  // many minutes; a count-only trigger leaves that work undurable.
  const CHECKPOINT_MAX_INTERVAL_MS = 120_000;
  let lastCheckpointMs = Date.now();
  let lastCheckpointSize = completed.size;
  let chunksCreated = 0;
  const importedSlugs: string[] = [];
  const errorCounts: Record<string, number> = {};
  const failures: Array<{ path: string; error: string }> = []; // Bug 9
  const startTime = Date.now();

  // Progress on stderr so stdout stays clean for the final summary / --json payload.
  const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
  progress.start('import.files', files.length);

  function tickProgress() {
    progress.tick(1, `imported=${imported} skipped=${skipped} errors=${errors}`);
  }

  async function processFile(eng: BrainEngine, filePath: string) {
    const relativePath = relative(dir, filePath);
    // #753/#774: slug + source_path base. When performFullSync syncs a
    // monorepo subdir, slugRoot is the git root so slugs stay git-root-
    // relative (matching the incremental path's git-diff paths). The
    // checkpoint (`completed`) stays dir-relative — resumeFilter's contract.
    const importRelPath = opts.slugRoot ? relative(opts.slugRoot, filePath) : relativePath;
    // v0.31.2 (D5): per-file slow-path log. Fires only when a single
    // file takes >5s. The user's hang surfaces as one file taking
    // forever — without this, the agent can't see which file.
    const _fileT0 = Date.now();
    try {
      // v0.27.1 (F2): dispatch image extensions to importImageFile when
      // multimodal is enabled. The walker (collectMarkdownFiles) only picks
      // up images when GBRAIN_EMBEDDING_MULTIMODAL=true so this branch is
      // unreachable when the gate is off; defense-in-depth check anyway.
      const result = isImageFilePath(relativePath) && process.env.GBRAIN_EMBEDDING_MULTIMODAL === 'true'
        ? await importImageFile(eng, filePath, importRelPath, { noEmbed, sourceId })
        : await importFile(eng, filePath, importRelPath, { noEmbed, sourceId, activePack: importActivePack });
      const _fileMs = Date.now() - _fileT0;
      if (_fileMs > 5000) {
        console.error(`[gbrain phase] import.process_file slow ${_fileMs}ms ${relativePath}`);
      }
      if (result.status === 'imported') {
        imported++;
        chunksCreated += result.chunks;
        importedSlugs.push(result.slug);
        // v0.33.2: path-based checkpoint — record only on success.
        completed.add(relativePath);
      } else {
        skipped++;
        if (result.error && result.error !== 'unchanged') {
          console.error(`  Skipped ${relativePath}: ${result.error}`);
          // Bug 9 — non-"unchanged" skips carry a real error reason.
          // #774: ledger paths use the slug base so an incremental sync's
          // success at the same (git-root-relative) path clears the row.
          failures.push({ path: importRelPath, error: result.error });
        } else {
          // 'unchanged' or no-error skip: content_hash matched a prior
          // successful import, so this file IS done for checkpoint purposes.
          completed.add(relativePath);
        }
      }
    } catch (e: unknown) {
      const msg = e instanceof Error ? e.message : String(e);
      const errorKey = msg.replace(/"[^"]*"/g, '""');
      errorCounts[errorKey] = (errorCounts[errorKey] || 0) + 1;
      if (errorCounts[errorKey] <= 5) {
        console.error(`  Warning: skipped ${relativePath}: ${msg}`);
      } else if (errorCounts[errorKey] === 6) {
        console.error(`  (suppressing further "${errorKey.slice(0, 60)}..." errors)`);
      }
      errors++;
      skipped++;
      failures.push({ path: importRelPath, error: msg });
    }
    processed++;
    tickProgress();
    // Save checkpoint every 100 SUCCESSFUL adds (not every 100 processed).
    // Failed files never enter `completed`, so a flaky file can't push the
    // checkpoint past it — the next run will retry it.
    // ...and ALSO save on a time interval. On a corpus with an expensive tail
    // `completed` can advance ~1 file per several minutes, so the next
    // 100-boundary may be hours away; any kill before it discards every file
    // since the last boundary and the run can never converge.
    const nowMs = Date.now();
    const dueByCount = completed.size > 0 && completed.size % 100 === 0;
    const dueByTime = completed.size > lastCheckpointSize
      && nowMs - lastCheckpointMs >= CHECKPOINT_MAX_INTERVAL_MS;
    if (dueByCount || dueByTime) {
      lastCheckpointMs = nowMs;
      lastCheckpointSize = completed.size;
      const cpDir = gbrainPath();
      if (!existsSync(cpDir)) {
        try { const { mkdirSync } = await import('fs'); mkdirSync(cpDir, { recursive: true }); }
        catch { /* non-fatal */ }
      }
      saveCheckpoint(checkpointPath, {
        schema_version: 1,
        owner: 'gbrain',
        kind: 'import',
        dir,
        completedPaths: Array.from(completed),
        timestamp: new Date().toISOString(),
      });
    }
  }

  if (actualWorkers > 1) {
    // v0.22.13 (PR #490 A1 + Q3): use engine.kind discriminator (not config.engine
    // string sniff) and fall back to serial when database_url is unset. Both
    // checks belt-and-suspenders so we never crash on a null assertion.
    const config = loadConfig();
    if (engine.kind === 'pglite' || !config?.database_url) {
      for (const file of files) {
        await processFile(engine, file);
      }
    } else {
      const { PostgresEngine } = await import('../core/postgres-engine.ts');
      const { resolvePoolSize } = await import('../core/db.ts');
      // Default per-worker pool is 2 (small, parallel import case). Users on
      // constrained poolers (e.g. Supabase port 6543) can cap below this via
      // GBRAIN_POOL_SIZE=1.
      const workerPoolSize = Math.min(2, resolvePoolSize(2));
      const databaseUrl = config.database_url;

      // v0.22.13 (PR #490 A2): connect workers serially so a partial failure
      // leaves us with the connected ones already pushed onto workerEngines
      // for the finally-block cleanup. The prior Promise.all could leak any
      // engine that connected before another's connect() rejected.
      const workerEngines: InstanceType<typeof PostgresEngine>[] = [];
      try {
        for (let i = 0; i < actualWorkers; i++) {
          const eng = new PostgresEngine();
          await eng.connect({ database_url: databaseUrl, poolSize: workerPoolSize });
          workerEngines.push(eng);
        }

        // Thread-safe queue: atomic index counter (JS is single-threaded; the
        // read-then-increment happens between awaits so no lock is needed).
        let queueIndex = 0;
        await Promise.all(workerEngines.map(async (eng) => {
          while (true) {
            const idx = queueIndex++;
            if (idx >= files.length) break;
            await processFile(eng, files[idx]);
          }
        }));
      } finally {
        // v0.22.13 (PR #490 A2): try/finally guarantees cleanup even when the
        // worker loop throws. Each disconnect is best-effort — one failing
        // disconnect must not strand the others.
        await Promise.all(
          workerEngines.map(e =>
            e.disconnect().catch((err: unknown) =>
              console.error(`  worker disconnect failed: ${err instanceof Error ? err.message : String(err)}`),
            ),
          ),
        );
      }
    } // end else (postgres parallel)
  } else {
    // Sequential: use the provided engine
    for (const filePath of files) {
      await processFile(engine, filePath);
    }
  }

  progress.finish();

  // Error summary
  for (const [err, count] of Object.entries(errorCounts)) {
    if (count > 5) {
      console.error(`  ${count} files failed: ${err.slice(0, 100)}`);
    }
  }

  // Final checkpoint save BEFORE the clear/preserve decision below. The
  // periodic triggers above are gated on a 100-file boundary or an interval,
  // so a run that ends between them would otherwise leave its tail unsaved.
  // This must run before clearCheckpoint() so a clean run still ends with no
  // checkpoint file — it only makes the ERROR path's preserved checkpoint
  // complete.
  if (errors > 0 && completed.size > lastCheckpointSize) {
    try {
      const cpDir = gbrainPath();
      if (!existsSync(cpDir)) {
        const { mkdirSync } = await import('fs');
        mkdirSync(cpDir, { recursive: true });
      }
      saveCheckpoint(checkpointPath, {
        schema_version: 1,
        owner: 'gbrain',
        kind: 'import',
        dir,
        completedPaths: Array.from(completed),
        timestamp: new Date().toISOString(),
      });
    } catch { /* non-fatal: the next run simply redoes the tail */ }
  }

  // Clear checkpoint on clean completion. On error, the path-based checkpoint
  // preserves only the successfully-completed paths, so the next run retries
  // failed files automatically (they never entered `completed`).
  if (errors === 0) {
    clearCheckpoint(checkpointPath);
  } else if (existsSync(checkpointPath)) {
    info(`  Checkpoint preserved (${errors} errors). Run again to retry failed files.`);
  }

  const totalTime = ((Date.now() - startTime) / 1000).toFixed(1);
  if (jsonOutput) {
    console.log(JSON.stringify({
      status: 'success', duration_s: parseFloat(totalTime),
      imported, skipped, errors, chunks: chunksCreated,
      total_files: allFiles.length,
    }));
  } else {
    console.log(`\nImport complete (${totalTime}s):`);
    console.log(`  ${imported} pages imported`);
    console.log(`  ${skipped} pages skipped (${skipped - errors} unchanged, ${errors} errors)`);
    console.log(`  ${chunksCreated} chunks created`);
  }

  // v0.39 T7 — end-of-run schema mismatch warn. Fires ONCE per import,
  // not per page. Counts untyped pages in the affected source AND
  // compares to import size; warns at >=10% untyped. The doctor
  // schema_pack_consistency check (also T7) gives the persistent surface.
  // Best-effort: query failure is non-fatal.
  if (imported > 0) {
    try {
      const sid = sourceId ?? 'default';
      const rows = await engine.executeRaw<{ total: string | number; untyped: string | number }>(
        `SELECT
           COUNT(*)::text AS total,
           COUNT(*) FILTER (WHERE type IS NULL OR type = '')::text AS untyped
         FROM pages
         WHERE source_id = $1 AND deleted_at IS NULL`,
        [sid],
      );
      const total = Number(rows[0]?.total ?? 0);
      const untyped = Number(rows[0]?.untyped ?? 0);
      if (total > 0 && untyped / total >= 0.1) {
        const pct = ((untyped / total) * 100).toFixed(1);
        console.error(
          `\n[schema] ${untyped} of ${total} pages (${pct}%) in source \`${sid}\` ` +
          `have no \`type\` matching the active schema pack. Run \`gbrain schema detect\` ` +
          `to propose a pack matching your content shape, or \`gbrain doctor --json\` ` +
          `for the persistent surface (schema_pack_consistency check).`,
        );
      }
    } catch {
      // best-effort
    }
  }

  // Log the ingest
  await engine.logIngest({
    source_type: 'directory',
    source_ref: dir,
    pages_updated: importedSlugs,
    summary: `Imported ${imported} pages, ${skipped} skipped, ${chunksCreated} chunks`,
  });

  // Import → sync continuity: write sync checkpoint if this is a git repo.
  // Bug 9 — gate last_commit on "no failures" so import doesn't silently
  // stomp on the sync bookmark when parsing broke. We still write
  // last_run + repo_path either way (those are progress indicators).
  let gitHead: string | null = null;
  try {
    if (existsSync(join(dir, '.git'))) {
      gitHead = execFileSync('git', ['-C', dir, 'rev-parse', 'HEAD'], { encoding: 'utf-8' }).trim();
    }
  } catch {
    // Not a git repo or git not available
  }

  // issue #1939: when performFullSync drives runImport it owns the failure
  // ledger + bookmark via the shared gate (applySyncFailureGate). Skipping the
  // internal handling here prevents double-recording (which would double-count
  // the auto-skip `attempts` streak) and a competing bookmark write.
  if (gitHead && !opts.managedBookmark) {
    // Record failures into the central JSONL so doctor can surface them.
    // Use gitHead as the commit so a later sync can tell "same broken
    // state as last time" from "new broken state." Source-scoped (#1939 #2).
    if (failures.length > 0) {
      const { recordFailures } = await import('../core/sync.ts');
      recordFailures(opts.sourceId ?? 'default', failures, gitHead);
    }
    if (failures.length === 0) {
      await engine.setConfig('sync.last_commit', gitHead);
    } else {
      console.error(
        `\nImport completed with ${failures.length} failure(s). ` +
        `sync.last_commit NOT advanced — re-run 'gbrain sync' to retry, or ` +
        `'gbrain sync --skip-failed' to acknowledge and move past them.`,
      );
    }
    await engine.setConfig('sync.last_run', new Date().toISOString());
    await engine.setConfig('sync.repo_path', dir);
  }

  return { imported, skipped, errors, chunksCreated, failures };
}

/**
 * v0.31.2: max walker depth before bailing out. 32 levels is more than
 * any real source tree on disk; reaching it is a structural cycle the
 * lstat+inode-set defenses missed (e.g., a Linux bind-mount or btrfs
 * subvolume that returns a fresh inode for the same content). Override
 * via `GBRAIN_MAX_WALK_DEPTH`.
 */
function resolveMaxWalkDepth(): number {
  const raw = process.env.GBRAIN_MAX_WALK_DEPTH;
  if (raw) {
    const n = Number(raw);
    if (Number.isFinite(n) && n > 0) return n;
  }
  return 32;
}

interface CollectOpts {
  strategy?: SyncStrategy;
  includeGitignored?: boolean;
}

/**
 * v0.27.1 + v0.31.2: walker-context image admission. `isSyncable` (the
 * incremental-diff filter at sync.ts:213) admits images only on `auto`.
 * The first-sync walker historically admitted them on markdown too when
 * `GBRAIN_EMBEDDING_MULTIMODAL=true`. Codex (C5) flagged the contradiction
 * — preserve the walker semantic explicitly.
 *
 * Closes #345: exclude `SYNC_SKIP_FILES` metafiles
 * (`README.md` / `index.md` / `log.md` / `schema.md` / `RESOLVER.md`).
 * Incremental `sync` skips these via `isSyncable`, but the bulk-import
 * walker only filtered by extension — so a directory import imported every
 * directory README as a page, titled by its folder ("People", "Companies",
 * …). Those index-titled pages then trigram-corrupt fuzzy entity resolution
 * (any `people/X` slug matches the "People" page) and inflate orphan count.
 * Funnel both admission paths through the same metafile exclusion so import
 * and sync agree on what is a page.
 */
function isCollectibleForWalker(
  path: string,
  strategy: SyncStrategy,
  multimodalOn: boolean,
): boolean {
  // #2607: apply the SAME segment-level prune gate as incremental sync's
  // `classifySync` (core/sync.ts). The FS walk below prunes at descent time,
  // but the git fast path enumerates via `git ls-files` and historically
  // filtered only by extension — so `sync --full` imported (and resurrected
  // previously-deleted) pages under dot-dirs / vendored trees that incremental
  // sync excludes. Full and incremental must agree on the exclusion set.
  // (In the FS-walk route `path` is a basename, so this is the same dot-file
  // check pruneDir already applied there — no behavior change on that route.)
  const segments = path.split('/');
  if (segments.some((seg) => !pruneDir(seg))) return false;

  // Metafiles are directory scaffolding (READMEs / index / log / schema /
  // resolver), not typed brain pages — same exclusion `sync`'s `isSyncable`
  // applies. Guards both the FS-walk and the git-fast-path collection routes.
  const basename = segments[segments.length - 1] || '';
  if ((SYNC_SKIP_FILES as readonly string[]).includes(basename)) return false;

  switch (strategy) {
    case 'code':
      return isCodeFilePath(path);
    case 'markdown':
      return isMarkdownFilePath(path) || (multimodalOn && isImageFilePathFromSync(path));
    case 'auto':
      return (
        isMarkdownFilePath(path) ||
        isCodeFilePath(path) ||
        (multimodalOn && isImageFilePathFromSync(path))
      );
  }
}

/**
 * Git-aware fast path for `collectSyncableFiles`. Returns the strategy-filtered
 * list of syncable files when `dir` is inside a git work tree (paths absolute,
 * sorted), or `null` when `dir` is not a git repo / git is unavailable — in
 * which case the caller falls back to the recursive FS walk.
 *
 * Honors `.gitignore` (the whole point): `git ls-files --cached --others
 * --exclude-standard` lists tracked + untracked-not-ignored files, so vendored
 * / build / generated trees never reach the importer. `-z` (NUL-delimited)
 * survives paths with spaces/newlines. Each path is lstat-checked to preserve
 * the walker's no-symlink policy and to drop submodule gitlinks (which surface
 * as a single non-regular entry).
 */
function gitListSyncableFiles(
  dir: string,
  strategy: SyncStrategy,
  multimodalOn: boolean,
): string[] | null {
  let stdout: string;
  try {
    stdout = execFileSync(
      'git',
      ['-C', dir, 'ls-files', '--cached', '--others', '--exclude-standard', '-z'],
      { encoding: 'utf8', maxBuffer: 512 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] },
    );
  } catch {
    return null; // not a git work tree, or git not on PATH → FS-walk fallback
  }
  const files: string[] = [];
  for (const rel of stdout.split('\0')) {
    if (!rel) continue;
    if (!isCollectibleForWalker(rel, strategy, multimodalOn)) continue;
    const full = join(dir, rel);
    let st;
    try {
      st = lstatSync(full);
    } catch {
      continue; // ls-files raced a deletion, or unreadable
    }
    if (st.isSymbolicLink() || !st.isFile()) continue;
    files.push(full);
  }
  return files.sort();
}

/**
 * v0.31.2 (codex C4 + C5 + C8): unified walker with five hardenings:
 *
 * 1. `lstatSync` + explicit `isSymbolicLink()` skip — never follow symlinks.
 *    Replaces the old `collectMarkdownFiles` lstat path AND the old
 *    `walkSyncableFiles` `statSync` path (the latter was the cost-preview
 *    walker, weaker than the import walker for no good reason).
 * 2. Inode-set cycle detection keyed on `${st_dev}:${st_ino}` — defense in
 *    depth for non-symlink cycles (bind mounts, ZFS snapshots).
 * 3. `MAX_WALK_DEPTH` bailout — last-line backstop if both layers above miss.
 * 4. Strategy-aware filter via `isCollectibleForWalker` — single helper that
 *    surfaces the markdown+multimodal carve-out at one site instead of
 *    leaking it across two filter paths.
 * 5. `.sort()` output — `runImport`'s checkpoint-resume at line 68–74 is
 *    index-based against a sorted list. Unstable order skips the wrong
 *    files on resume.
 */
export function collectSyncableFiles(dir: string, opts: CollectOpts = {}): string[] {
  const strategy: SyncStrategy = opts.strategy ?? 'markdown';
  const multimodalOn = process.env.GBRAIN_EMBEDDING_MULTIMODAL === 'true';

  // v0.42.x (#1159 --respect-gitignore / #1483 .gbrainignore): when `dir` is a
  // git work tree, enumerate via `git ls-files` so the walk honors
  // `.gitignore`. Pre-fix the recursive FS walk below descended into every
  // git-ignored tree — `vendor/` (PHP Composer), `storage/`, `public/build/`,
  // etc. — so a Laravel/PHP repo's `--strategy code` sync tried to import ~50k
  // dependency/build files (and bloated DB + embedding cost on any repo with
  // vendored data/fixtures). `--cached --others --exclude-standard` = tracked
  // PLUS untracked-not-ignored, so uncommitted source is still indexed. Non-git
  // dirs (or git unavailable) fall through to the FS walk below.
  if (!opts.includeGitignored) {
    const gitFiles = gitListSyncableFiles(dir, strategy, multimodalOn);
    if (gitFiles) return gitFiles;
  }

  const maxDepth = resolveMaxWalkDepth();
  const visitedInodes = new Map<string, true>();
  const files: string[] = [];

  function walk(d: string, depth: number): void {
    if (depth >= maxDepth) {
      console.warn(`[gbrain] walker depth limit reached at ${d}; skipping`);
      return;
    }
    let entries: string[];
    try {
      entries = readdirSync(d);
    } catch {
      return;
    }
    for (const entry of entries) {
      // Descent-time prune through the canonical gate (single source of truth
      // in core/sync.ts) instead of a hand-maintained inline list that drifted
      // from it. Skips hidden dirs (`.git`, `.raw`, etc.), `node_modules`,
      // `vendor`, `dist`, `build`, `venv` (#2020), `ops`, and git submodules.
      if (!pruneDir(entry, d)) continue;

      const full = join(d, entry);
      let stat;
      try {
        stat = lstatSync(full);
      } catch {
        console.warn(`[gbrain import] Skipping unreadable path: ${full}`);
        continue;
      }

      if (stat.isSymbolicLink()) {
        console.warn(`[gbrain import] Skipping symlink: ${full}`);
        continue;
      }

      if (stat.isDirectory()) {
        const inodeKey = `${stat.dev}:${stat.ino}`;
        if (visitedInodes.has(inodeKey)) {
          console.warn(`[gbrain] walker cycle detected at ${full}; skipping`);
          continue;
        }
        visitedInodes.set(inodeKey, true);
        walk(full, depth + 1);
      } else if (stat.isFile()) {
        if (!isCollectibleForWalker(entry, strategy, multimodalOn)) continue;
        files.push(full);
      }
    }
  }

  walk(dir, 0);
  return files.sort();
}

/**
 * @deprecated v0.31.2: kept as a thin wrapper so legacy callers keep
 * compiling. Prefer `collectSyncableFiles(dir, { strategy: 'markdown' })`.
 */
export function collectMarkdownFiles(dir: string): string[] {
  return collectSyncableFiles(dir, { strategy: 'markdown' });
}
