/**
 * Unit tests for the v0.11.0 orchestrator's host-rewrite phase (Phase E).
 *
 * Focus is on the deterministic, side-effect-heavy parts that are the
 * highest risk: AGENTS.md marker injection, cron manifest rewriting for
 * builtin handlers, JSONL TODO emission for host-specific handlers, and
 * the safety guards (symlink escape, oversize, malformed JSON, mtime race).
 *
 * Full end-to-end orchestrator runs (schema, smoke, autopilot install)
 * live in test/e2e/migration-flow.test.ts (Lane C-5).
 */

import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync, existsSync, symlinkSync } from 'fs';
import { join, sep, dirname } from 'path';
import { tmpdir } from 'os';

import { __testing, type PendingHostWorkEntry } from '../src/commands/migrations/v0_11_0.ts';

const {
  injectAgentsMdMarker,
  rewriteCronManifest,
  findAgentsMdFiles,
  findCronManifests,
  BUILTIN_HANDLERS,
  AGENTS_MD_MARKER,
  loadPendingHostWork,
} = __testing;

let tmp: string;
let origHome: string | undefined;

beforeEach(() => {
  origHome = process.env.HOME;
  tmp = mkdtempSync(join(tmpdir(), 'gbrain-v0_11_0-test-'));
  process.env.HOME = tmp;
});

afterEach(() => {
  if (origHome === undefined) delete process.env.HOME;
  else process.env.HOME = origHome;
  try { rmSync(tmp, { recursive: true, force: true }); } catch { /* best-effort */ }
});

function writeAgentsMd(dir: string, body: string) {
  const path = join(dir, 'AGENTS.md');
  mkdirSync(dir, { recursive: true });
  writeFileSync(path, body);
  return path;
}

function writeCronJson(dir: string, jobs: unknown[]) {
  const path = join(dir, 'cron', 'jobs.json');
  mkdirSync(dirname(path), { recursive: true });
  writeFileSync(path, JSON.stringify({ jobs }, null, 2) + '\n');
  return path;
}

const DEFAULT_OPTS = {
  yes: true,
  mode: undefined,
  dryRun: false,
  hostDir: undefined,
  noAutopilotInstall: true,
};

describe('AGENTS.md marker injection', () => {
  test('injects the subagent-routing marker + ## section', () => {
    const dir = join(tmp, '.claude');
    const path = writeAgentsMd(dir, '# My AGENTS.md\n\nSome existing content.\n');
    const result = injectAgentsMdMarker(path, DEFAULT_OPTS);
    expect(result.injected).toBe(true);
    const after = readFileSync(path, 'utf-8');
    expect(after).toContain(AGENTS_MD_MARKER);
    expect(after).toContain('Subagent routing');
    expect(after).toContain('skills/conventions/subagent-routing.md');
    // Original content preserved
    expect(after).toContain('Some existing content.');
  });

  test('creates a timestamped .bak sibling before writing', () => {
    const dir = join(tmp, '.claude');
    const path = writeAgentsMd(dir, 'original content\n');
    injectAgentsMdMarker(path, DEFAULT_OPTS);
    const siblings = require('fs').readdirSync(dir) as string[];
    expect(siblings.some(n => n.startsWith('AGENTS.md.bak.'))).toBe(true);
    const bak = siblings.find(n => n.startsWith('AGENTS.md.bak.'))!;
    expect(readFileSync(join(dir, bak), 'utf-8')).toBe('original content\n');
  });

  test('idempotent — second call is a no-op', () => {
    const dir = join(tmp, '.claude');
    const path = writeAgentsMd(dir, '# Test\n');
    const first = injectAgentsMdMarker(path, DEFAULT_OPTS);
    expect(first.injected).toBe(true);
    const afterFirst = readFileSync(path, 'utf-8');

    const second = injectAgentsMdMarker(path, DEFAULT_OPTS);
    expect(second.injected).toBe(false);
    expect(second.skipReason).toContain('already has marker');
    expect(readFileSync(path, 'utf-8')).toBe(afterFirst);
  });

  test('--dry-run does not edit the file', () => {
    const dir = join(tmp, '.claude');
    const path = writeAgentsMd(dir, '# Test\n');
    const result = injectAgentsMdMarker(path, { ...DEFAULT_OPTS, dryRun: true });
    expect(result.injected).toBe(true);
    expect(result.skipReason).toBe('dry-run');
    const after = readFileSync(path, 'utf-8');
    expect(after).toBe('# Test\n');
    expect(after).not.toContain(AGENTS_MD_MARKER);
  });

  test('SKIPs symlink target that escapes scoped roots', () => {
    const outside = join(tmp, 'outside', 'AGENTS.md');
    mkdirSync(join(tmp, 'outside'), { recursive: true });
    writeFileSync(outside, '# escaped\n');

    const inside = join(tmp, '.claude', 'AGENTS.md');
    mkdirSync(join(tmp, '.claude'), { recursive: true });
    symlinkSync(outside, inside);

    const result = injectAgentsMdMarker(inside, DEFAULT_OPTS);
    expect(result.injected).toBe(false);
    expect(result.skipReason).toContain('symlink target outside scoped root');
    // Outside file must not have been edited
    expect(readFileSync(outside, 'utf-8')).toBe('# escaped\n');
  });

  test('SKIPs files larger than 1 MB', () => {
    const dir = join(tmp, '.claude');
    const path = writeAgentsMd(dir, '#'.repeat(1_100_000));
    const result = injectAgentsMdMarker(path, DEFAULT_OPTS);
    expect(result.injected).toBe(false);
    expect(result.skipReason).toMatch(/1000000|bytes/);
  });
});

describe('cron manifest rewrite — gbrain builtins only', () => {
  test('rewrites `agentTurn` entries whose skill is a gbrain builtin', () => {
    const dir = join(tmp, '.claude');
    const path = writeCronJson(dir, [
      { schedule: '*/5 * * * *', kind: 'agentTurn', skill: 'extract' },
      { schedule: '*/10 * * * *', kind: 'agentTurn', skill: 'backlinks' },
    ]);

    const result = rewriteCronManifest(path, DEFAULT_OPTS);
    expect(result.rewritten).toBe(2);
    expect(result.todos_emitted).toBe(0);

    const after = JSON.parse(readFileSync(path, 'utf-8'));
    expect(after.jobs[0].kind).toBe('shell');
    expect(after.jobs[0].cmd).toContain('gbrain jobs submit extract');
    expect(after.jobs[0]._gbrain_migrated_by).toBe('v0.11.0');
    expect(after.jobs[1].kind).toBe('shell');
    expect(after.jobs[1].cmd).toContain('gbrain jobs submit backlinks');
  });

  test('emits JSONL TODO for non-builtin handlers (ea-inbox-sweep etc.)', () => {
    const dir = join(tmp, '.claude');
    const path = writeCronJson(dir, [
      { schedule: '0 */30 * * *', kind: 'agentTurn', skill: 'ea-inbox-sweep' },
      { schedule: '0 8 * * *', kind: 'agentTurn', skill: 'morning-briefing' },
    ]);

    const result = rewriteCronManifest(path, DEFAULT_OPTS);
    expect(result.rewritten).toBe(0);
    expect(result.todos_emitted).toBe(2);

    // Manifest itself unchanged (host hasn't registered handlers yet).
    const after = JSON.parse(readFileSync(path, 'utf-8'));
    expect(after.jobs[0].kind).toBe('agentTurn');
    expect(after.jobs[0].skill).toBe('ea-inbox-sweep');

    // JSONL TODO file created
    const todos = loadPendingHostWork();
    expect(todos.length).toBe(2);
    const handlers = todos.map(t => t.handler);
    expect(handlers).toContain('ea-inbox-sweep');
    expect(handlers).toContain('morning-briefing');
    expect(todos[0].status).toBe('pending');
    expect(todos[0].type).toBe('cron-handler-needs-host-registration');
  });

  test('mixed manifest: rewrites builtins + emits TODOs for non-builtins in one pass', () => {
    const dir = join(tmp, '.claude');
    const path = writeCronJson(dir, [
      { schedule: '*/5 * * * *', kind: 'agentTurn', skill: 'sync' },          // builtin
      { schedule: '0 */30 * * *', kind: 'agentTurn', skill: 'ea-inbox-sweep' }, // non-builtin
      { schedule: '*/10 * * * *', kind: 'agentTurn', skill: 'embed' },          // builtin
    ]);

    const result = rewriteCronManifest(path, DEFAULT_OPTS);
    expect(result.rewritten).toBe(2);
    expect(result.todos_emitted).toBe(1);

    const after = JSON.parse(readFileSync(path, 'utf-8'));
    expect(after.jobs[0].kind).toBe('shell'); // sync rewritten
    expect(after.jobs[1].kind).toBe('agentTurn'); // ea-inbox-sweep left alone
    expect(after.jobs[2].kind).toBe('shell'); // embed rewritten

    const todos = loadPendingHostWork();
    expect(todos.length).toBe(1);
    expect(todos[0].handler).toBe('ea-inbox-sweep');
  });

  test('idempotent: second run does not re-rewrite already-migrated entries', () => {
    const dir = join(tmp, '.claude');
    const path = writeCronJson(dir, [
      { schedule: '*/5 * * * *', kind: 'agentTurn', skill: 'sync' },
    ]);
    const first = rewriteCronManifest(path, DEFAULT_OPTS);
    expect(first.rewritten).toBe(1);
    const second = rewriteCronManifest(path, DEFAULT_OPTS);
    expect(second.rewritten).toBe(0);
  });

  test('TODO dedupe: running twice does not duplicate JSONL rows', () => {
    const dir = join(tmp, '.claude');
    const path = writeCronJson(dir, [
      { schedule: '*/30 * * * *', kind: 'agentTurn', skill: 'ea-inbox-sweep' },
    ]);
    rewriteCronManifest(path, DEFAULT_OPTS);
    rewriteCronManifest(path, DEFAULT_OPTS);
    const todos = loadPendingHostWork();
    expect(todos.length).toBe(1);
  });

  test('SKIPs malformed JSON manifest with a warning', () => {
    const dir = join(tmp, '.claude', 'cron');
    mkdirSync(dir, { recursive: true });
    const path = join(dir, 'jobs.json');
    writeFileSync(path, '{ this is not valid json');
    const result = rewriteCronManifest(path, DEFAULT_OPTS);
    expect(result.rewritten).toBe(0);
    expect(result.skipReason).toContain('malformed JSON');
  });

  test('SKIPs manifest with no recognizable entry shape', () => {
    const dir = join(tmp, '.claude', 'cron');
    mkdirSync(dir, { recursive: true });
    const path = join(dir, 'jobs.json');
    writeFileSync(path, JSON.stringify({ config: { enabled: true } }));
    const result = rewriteCronManifest(path, DEFAULT_OPTS);
    expect(result.rewritten).toBe(0);
    expect(result.skipReason).toContain('entries array');
  });

  test('--dry-run does not touch the file', () => {
    const dir = join(tmp, '.claude');
    const path = writeCronJson(dir, [
      { schedule: '*/5 * * * *', kind: 'agentTurn', skill: 'sync' },
    ]);
    const before = readFileSync(path, 'utf-8');
    const result = rewriteCronManifest(path, { ...DEFAULT_OPTS, dryRun: true });
    expect(result.rewritten).toBe(1);
    expect(readFileSync(path, 'utf-8')).toBe(before);
  });
});

describe('findAgentsMdFiles + findCronManifests scoping', () => {
  test('finds AGENTS.md in $HOME/.claude and $HOME/.openclaw scopes', () => {
    mkdirSync(join(tmp, '.claude'), { recursive: true });
    writeFileSync(join(tmp, '.claude', 'AGENTS.md'), '# claude\n');
    mkdirSync(join(tmp, '.openclaw'), { recursive: true });
    writeFileSync(join(tmp, '.openclaw', 'AGENTS.md'), '# openclaw\n');

    const found = findAgentsMdFiles(DEFAULT_OPTS);
    expect(found.length).toBe(2);
    expect(found.some(p => p.includes('.claude'))).toBe(true);
    expect(found.some(p => p.includes('.openclaw'))).toBe(true);
  });

  test('does NOT walk $PWD unless --host-dir is passed', () => {
    mkdirSync(join(tmp, 'project'), { recursive: true });
    writeFileSync(join(tmp, 'project', 'AGENTS.md'), '# project\n');
    // findAgentsMdFiles returns join()-built native paths, so a '/project/'
    // literal matches nothing on win32 and the negative assertion below would
    // pass vacuously. Only the separator comes from `path`; the directory name
    // being probed stays hand-written.
    // No --host-dir
    const found = findAgentsMdFiles(DEFAULT_OPTS);
    expect(found.some(p => p.includes(`${sep}project${sep}`))).toBe(false);

    // With --host-dir
    const foundWithHostDir = findAgentsMdFiles({ ...DEFAULT_OPTS, hostDir: join(tmp, 'project') });
    expect(foundWithHostDir.some(p => p.includes(`${sep}project${sep}`))).toBe(true);
  });

  test('findCronManifests picks up cron/jobs.json under scoped roots', () => {
    mkdirSync(join(tmp, '.claude', 'cron'), { recursive: true });
    writeFileSync(join(tmp, '.claude', 'cron', 'jobs.json'), JSON.stringify({ jobs: [] }));
    const found = findCronManifests(DEFAULT_OPTS);
    expect(found.length).toBe(1);
    expect(found[0]).toContain('jobs.json');
  });
});

describe('BUILTIN_HANDLERS — lock the canonical set', () => {
  test('includes exactly the seven v0.11.0 builtins', () => {
    expect([...BUILTIN_HANDLERS].sort()).toEqual([
      'autopilot-cycle', 'backlinks', 'embed', 'extract', 'import', 'lint', 'sync',
    ]);
  });
});
