/**
 * v0.31.2 walker hardening regression tests.
 *
 * Closes the bug class where `gbrain sync --strategy code` could hang on
 * repos with self-referencing symlinks (the gstack `.claude/skills/gstack
 * -> repo-root` dev pattern was the trigger). Pins:
 *
 * 1. Self-referencing symlink does not loop.
 * 2. Symlink chain through real dirs does not loop (inode-set defense).
 * 3. MAX_WALK_DEPTH=32 bailout is structural backstop.
 * 4. Strategy filter (code/markdown/auto) admits the right files.
 * 5. Dot-prefixed dirs (.git/.claude/.raw) and node_modules still skipped.
 * 6. Multimodal preservation under markdown-strategy (codex C5).
 * 7. Deterministic ordering — runImport's index-based resume depends on it
 *    (codex C8).
 */
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from 'fs';
import { basename, join, sep } from 'path';
import { tmpdir } from 'os';
import { collectSyncableFiles } from '../src/commands/import.ts';
import { withEnv } from './helpers/with-env.ts';

/**
 * PLATFORM NOTE: collectSyncableFiles returns absolute paths built with
 * join(), so on win32 they carry '\' — correct, since callers feed them to
 * readFileSync. Two POSIX-only idioms silently no-op there: `split('/').pop()`
 * returns the WHOLE path instead of a basename, and `replace(tmp, '')` leaves
 * a '\'-prefixed tail. Both turn the `startsWith('/.git')` style negative
 * assertions below into vacuous passes, so the skip-list regressions this file
 * exists to pin would stop being tested at all.
 *
 * Expectations therefore stay NATIVE: only the separator comes from `path`,
 * the directory structure in each one stays hand-written. Byte-identical to
 * the old POSIX literals when sep is '/'.
 */

let tmp: string;

/** Path relative to `tmp`, native separators, leading separator preserved. */
const relToTmp = (p: string): string => p.slice(tmp.length);

beforeEach(() => {
  tmp = mkdtempSync(join(tmpdir(), 'gbrain-walker-'));
});

afterEach(() => {
  rmSync(tmp, { recursive: true, force: true });
});

describe('collectSyncableFiles symlink + cycle hardening', () => {
  test('1. self-referencing symlink does not loop', async () => {
    await withEnv({ GBRAIN_EMBEDDING_MULTIMODAL: undefined }, () => {
      writeFileSync(join(tmp, 'notes.md'), '# top\n');
      // Symlink "loop" inside tempdir pointing back to itself.
      symlinkSync(tmp, join(tmp, 'loop'));

      const t0 = Date.now();
      const files = collectSyncableFiles(tmp, { strategy: 'markdown' });
      const ms = Date.now() - t0;

      expect(ms).toBeLessThan(1000); // would hang if walker followed the loop
      expect(files).toContain(join(tmp, 'notes.md'));
      expect(files.every(f => !f.includes(`${sep}loop${sep}`))).toBe(true);
    });
  });

  test('2. symlink chain through real dirs does not loop', async () => {
    await withEnv({ GBRAIN_EMBEDDING_MULTIMODAL: undefined }, () => {
      // a/ contains a real subdir b/, which contains a symlink "back" -> a.
      // The lstat skip catches "back" before recursion. If somehow it
      // missed, the inode-cycle Map catches the second visit to a/.
      mkdirSync(join(tmp, 'a/b'), { recursive: true });
      writeFileSync(join(tmp, 'a/b/leaf.md'), 'leaf\n');
      symlinkSync(join(tmp, 'a'), join(tmp, 'a/b/back'));

      const t0 = Date.now();
      const files = collectSyncableFiles(tmp, { strategy: 'markdown' });
      const ms = Date.now() - t0;

      expect(ms).toBeLessThan(2000);
      expect(files).toContain(join(tmp, 'a/b/leaf.md'));
    });
  });

  test('3. max-depth bailout terminates pathological deep trees', async () => {
    await withEnv({ GBRAIN_EMBEDDING_MULTIMODAL: undefined }, () => {
      // Build a 40-level real directory tree. 32 is the default cap; the
      // file at the deepest level is intentionally past the bailout so we
      // can assert it is NOT collected (and the walker still terminates).
      let cur = tmp;
      for (let i = 0; i < 40; i++) {
        cur = join(cur, `d${i}`);
        mkdirSync(cur);
      }
      writeFileSync(join(cur, 'deep.md'), 'deep\n');
      writeFileSync(join(tmp, 'shallow.md'), 'shallow\n');

      const files = collectSyncableFiles(tmp, { strategy: 'markdown' });

      expect(files).toContain(join(tmp, 'shallow.md'));
      expect(files.every(f => !f.includes(`${sep}d35${sep}`))).toBe(true); // past depth 32
    });
  });

  test('4. strategy filter admits the right files', async () => {
    await withEnv({ GBRAIN_EMBEDDING_MULTIMODAL: undefined }, () => {
      writeFileSync(join(tmp, 'notes.md'), '# r\n');
      writeFileSync(join(tmp, 'foo.ts'), '// f\n');
      writeFileSync(join(tmp, 'bar.py'), '# b\n');

      const code = collectSyncableFiles(tmp, { strategy: 'code' });
      const markdown = collectSyncableFiles(tmp, { strategy: 'markdown' });
      const auto = collectSyncableFiles(tmp, { strategy: 'auto' });

      expect(code.map(f => basename(f)).sort()).toEqual(['bar.py', 'foo.ts']);
      expect(markdown.map(f => basename(f))).toEqual(['notes.md']);
      expect(auto.map(f => basename(f)).sort()).toEqual(['bar.py', 'foo.ts', 'notes.md']);
    });
  });

  test('5. dot-prefixed dirs and node_modules still skipped', async () => {
    await withEnv({ GBRAIN_EMBEDDING_MULTIMODAL: undefined }, () => {
      writeFileSync(join(tmp, 'real.md'), 'r\n');
      mkdirSync(join(tmp, '.git'));
      writeFileSync(join(tmp, '.git/HEAD'), 'ref: refs/heads/main\n');
      mkdirSync(join(tmp, '.claude/skills'), { recursive: true });
      writeFileSync(join(tmp, '.claude/skills/SKILL.md'), 's\n');
      mkdirSync(join(tmp, 'node_modules/foo'), { recursive: true });
      writeFileSync(join(tmp, 'node_modules/foo/index.md'), 'no\n');

      const files = collectSyncableFiles(tmp, { strategy: 'markdown' });
      const names = files.map(f => relToTmp(f));

      expect(names).toContain(`${sep}real.md`);
      expect(names.every(n => !n.startsWith(`${sep}.git`))).toBe(true);
      expect(names.every(n => !n.startsWith(`${sep}.claude`))).toBe(true);
      expect(names.every(n => !n.startsWith(`${sep}node_modules`))).toBe(true);
    });
  });

  test('6. multimodal preservation under markdown strategy (codex C5)', async () => {
    writeFileSync(join(tmp, 'r.md'), 'r\n');
    writeFileSync(join(tmp, 'p.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47]));
    writeFileSync(join(tmp, 'j.jpg'), Buffer.from([0xff, 0xd8, 0xff]));

    // Off → markdown only.
    await withEnv({ GBRAIN_EMBEDDING_MULTIMODAL: undefined }, () => {
      const off = collectSyncableFiles(tmp, { strategy: 'markdown' });
      expect(off.map(f => basename(f)).sort()).toEqual(['r.md']);
    });

    // On → markdown + images (preserves v0.27.1 F2 collectMarkdownFiles
    // behavior; codex C5 carve-out).
    await withEnv({ GBRAIN_EMBEDDING_MULTIMODAL: 'true' }, () => {
      const on = collectSyncableFiles(tmp, { strategy: 'markdown' });
      expect(on.map(f => basename(f)).sort()).toEqual(['j.jpg', 'p.png', 'r.md']);
    });
  });

  test('7. deterministic ordering — two walks return identical arrays (codex C8)', async () => {
    await withEnv({ GBRAIN_EMBEDDING_MULTIMODAL: undefined }, () => {
      // runImport's checkpoint resume at import.ts:68-74 is index-based
      // against a sorted file list. Unstable order skips the wrong files
      // on resume.
      writeFileSync(join(tmp, 'b.md'), 'b\n');
      writeFileSync(join(tmp, 'a.md'), 'a\n');
      mkdirSync(join(tmp, 'sub'));
      writeFileSync(join(tmp, 'sub/c.md'), 'c\n');

      const first = collectSyncableFiles(tmp, { strategy: 'markdown' });
      const second = collectSyncableFiles(tmp, { strategy: 'markdown' });

      expect(first).toEqual(second);
      // Sorted: a.md, b.md, sub/c.md (lexicographic on absolute paths).
      expect(first.map(f => relToTmp(f))).toEqual([
        `${sep}a.md`, `${sep}b.md`, `${sep}sub${sep}c.md`,
      ]);
    });
  });
});
