/**
 * E2E Integration Tests for Arcforge Hooks
 *
 * Tests the full execution path: stdin JSON → hook script → stdout/stderr/exit code.
 * Input fixtures based on real Claude Code hook input captured from a live session.
 * Uses spawnSync to capture stderr on success (not just on error).
 */

const { describe, it, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { spawnSync } = require('node:child_process');

const HOOKS_DIR = path.resolve(__dirname, '..');
const PROJECT_ROOT = path.resolve(HOOKS_DIR, '..');

// ─────────────────────────────────────────────
// Test Helpers
// ─────────────────────────────────────────────

/**
 * Run a hook script as a child process with mock stdin.
 * Uses spawnSync to capture stdout AND stderr on both success and failure.
 */
function runHook(executable, scriptPath, { args = [], stdinJson = null, env = {}, cwd } = {}) {
  const opts = {
    env: { ...process.env, ...env },
    timeout: 15000,
    encoding: 'utf-8',
  };
  if (cwd) opts.cwd = cwd;
  if (stdinJson != null) {
    opts.input = typeof stdinJson === 'string' ? stdinJson : JSON.stringify(stdinJson);
  }
  const result = spawnSync(executable, [scriptPath, ...args], opts);
  return {
    stdout: result.stdout || '',
    stderr: result.stderr || '',
    exitCode: result.status ?? 1,
  };
}

function runNodeHook(scriptPath, stdinJson, env = {}) {
  return runHook('node', scriptPath, { stdinJson, env });
}

function runBashHook(scriptPath, stdinJson, env = {}) {
  return runHook('bash', scriptPath, { stdinJson, env });
}

/**
 * Build hook event input JSON matching real Claude Code schema.
 * Fields based on captured live session input (2026-03-30).
 */
function makeHookInput(eventName, extras = {}, overrides = {}) {
  return {
    session_id: `test-${Date.now()}`,
    transcript_path: '/tmp/test-transcript.jsonl',
    cwd: PROJECT_ROOT,
    permission_mode: 'default',
    hook_event_name: eventName,
    ...extras,
    ...overrides,
  };
}

function makeSessionStartInput(source = 'startup', overrides = {}) {
  return makeHookInput('SessionStart', { source }, overrides);
}

function makeUserPromptInput(prompt = 'hello', overrides = {}) {
  return makeHookInput('UserPromptSubmit', { prompt }, overrides);
}

function makeToolUseInput(eventName, toolName, toolInput = {}, overrides = {}) {
  return makeHookInput(
    eventName,
    { tool_name: toolName, tool_input: toolInput, tool_use_id: `toolu_test_${Date.now()}` },
    overrides,
  );
}

function makeStopInput(overrides = {}) {
  return makeHookInput(
    'Stop',
    { stop_hook_active: false, last_assistant_message: 'Done.' },
    overrides,
  );
}

// ─────────────────────────────────────────────
// SessionStart: inject-skills/main.sh
// ─────────────────────────────────────────────

describe('E2E: inject-skills/main.sh', () => {
  const scriptPath = path.join(HOOKS_DIR, 'inject-skills', 'main.sh');

  it('should inject minimal arcforge bootstrap context with ARCFORGE_ROOT', () => {
    const envFile = path.join(os.tmpdir(), `test-env-${Date.now()}`);
    const result = runBashHook(scriptPath, null, { CLAUDE_ENV_FILE: envFile });

    assert.strictEqual(result.exitCode, 0, `stderr: ${result.stderr}`);

    const parsed = JSON.parse(result.stdout);
    const ctx = parsed.hookSpecificOutput.additionalContext;
    assert.ok(ctx.includes('arcforge'), 'Should mention arcforge');
    assert.ok(ctx.includes('ARCFORGE_ROOT'), 'Should include ARCFORGE_ROOT');
    assert.ok(ctx.includes('smallest useful workflow'), 'Should prefer small workflows');
    assert.ok(ctx.includes('read or invoke'), 'Should tell agents how to use skills on demand');
    assert.ok(!ctx.includes('<EXTREMELY_IMPORTANT>'), 'Should not use high-pressure wrapper');
    assert.ok(!ctx.includes('Even a 1% chance'), 'Should not inject 1% routing pressure');
    assert.ok(
      !ctx.includes('Below is the full content'),
      'Should not inject full arc-using content',
    );
    assert.ok(ctx.length < 1200, `Bootstrap should stay compact. Got ${ctx.length} chars`);

    assert.ok(fs.existsSync(envFile), 'Should write CLAUDE_ENV_FILE');
    fs.rmSync(envFile, { force: true });
  });

  it('should exit 0 without CLAUDE_ENV_FILE', () => {
    const cleanEnv = { ...process.env };
    delete cleanEnv.CLAUDE_ENV_FILE;
    const result = runBashHook(scriptPath, null, cleanEnv);

    assert.strictEqual(result.exitCode, 0, `stderr: ${result.stderr}`);
    assert.ok(JSON.parse(result.stdout).hookSpecificOutput, 'Should produce valid JSON');
  });
});

// ─────────────────────────────────────────────
// SessionStart: session-tracker/inject-context.js
// ─────────────────────────────────────────────

describe('E2E: session-tracker/inject-context.js', () => {
  const scriptPath = path.join(HOOKS_DIR, 'session-tracker', 'inject-context.js');
  let testDir;

  beforeEach(() => {
    testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'test-inject-ctx-'));
  });

  afterEach(() => {
    fs.rmSync(testDir, { recursive: true, force: true });
  });

  it('should produce no output when no instincts exist', () => {
    const input = makeSessionStartInput('startup');
    const result = runNodeHook(scriptPath, input, { CLAUDE_PROJECT_DIR: testDir, HOME: testDir });

    assert.strictEqual(result.exitCode, 0, `stderr: ${result.stderr}`);
    assert.strictEqual(result.stdout.trim(), '', 'No instincts = no stdout');
  });

  it('does not auto-inject high-confidence instincts into stdout', () => {
    // Invariant (ICL-4): confidence alone never surfaces an instinct. Influence
    // requires an explicit ActivationRecord — this seeded file has none, so it
    // must not appear, however high its confidence.
    const projectName = path.basename(testDir);
    const instinctsDir = path.join(testDir, '.arcforge', 'instincts', projectName);
    fs.mkdirSync(instinctsDir, { recursive: true });
    fs.writeFileSync(
      path.join(instinctsDir, 'test-instinct.md'),
      [
        '---',
        'id: test-instinct',
        'confidence: 0.85',
        'trigger: When writing code',
        '---',
        '',
        '## Action',
        'Write tests first',
      ].join('\n'),
    );

    const input = makeSessionStartInput('startup');
    const result = runNodeHook(scriptPath, input, { CLAUDE_PROJECT_DIR: testDir, HOME: testDir });

    assert.strictEqual(result.exitCode, 0, `stderr: ${result.stderr}`);
    assert.ok(
      !result.stdout.includes('Active Behavioral Instincts'),
      'Auto-load header must not appear in SessionStart output',
    );
    assert.ok(
      !result.stdout.includes('test-instinct'),
      'Instinct id must not appear in SessionStart output',
    );
    assert.ok(
      !result.stdout.includes('instinct active'),
      'Instinct count must not appear in systemMessage',
    );
  });
});

// ─────────────────────────────────────────────
// SessionStart: session-tracker/start.js
// ─────────────────────────────────────────────

describe('E2E: session-tracker/start.js', () => {
  const scriptPath = path.join(HOOKS_DIR, 'session-tracker', 'start.js');
  let testDir;

  beforeEach(() => {
    testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'test-start-'));
  });

  afterEach(() => {
    fs.rmSync(testDir, { recursive: true, force: true });
  });

  it('should create session JSON file on startup', () => {
    const sessionId = 'test-session-create';
    const input = makeSessionStartInput('startup', { session_id: sessionId });
    const result = runNodeHook(scriptPath, input, { CLAUDE_PROJECT_DIR: testDir, HOME: testDir });

    assert.strictEqual(result.exitCode, 0, `stderr: ${result.stderr}`);

    // Verify session file was created
    const projectName = path.basename(testDir);
    const sessionsBase = path.join(testDir, '.arcforge', 'sessions', projectName);
    if (fs.existsSync(sessionsBase)) {
      const dateDirs = fs.readdirSync(sessionsBase);
      assert.ok(dateDirs.length > 0, 'Should have a date directory');
      const dateDir = path.join(sessionsBase, dateDirs[0]);
      const sessionFiles = fs.readdirSync(dateDir).filter((f) => f.includes(sessionId));
      assert.ok(sessionFiles.length > 0, 'Should have a session file');

      const sessionData = JSON.parse(fs.readFileSync(path.join(dateDir, sessionFiles[0]), 'utf-8'));
      assert.ok(sessionData.started, 'Session file should have started timestamp');
      assert.strictEqual(sessionData.project, projectName, 'Should have correct project name');
    }
  });

  it('should exit 0 with empty stdin', () => {
    const result = runNodeHook(scriptPath, '', { CLAUDE_PROJECT_DIR: testDir, HOME: testDir });
    assert.strictEqual(result.exitCode, 0, `stderr: ${result.stderr}`);
  });
});

// ─────────────────────────────────────────────
// UserPromptSubmit: user-message-counter/main.js
// ─────────────────────────────────────────────

describe('E2E: user-message-counter/main.js', () => {
  const scriptPath = path.join(HOOKS_DIR, 'user-message-counter', 'main.js');
  let testDir;

  beforeEach(() => {
    testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'test-counter-'));
  });

  afterEach(() => {
    fs.rmSync(testDir, { recursive: true, force: true });
  });

  it('should increment counter across multiple calls', () => {
    const sessionId = 'test-counter-incr';
    const env = { TMPDIR: testDir };

    for (let i = 0; i < 3; i++) {
      const input = makeUserPromptInput(`msg ${i}`, { session_id: sessionId });
      const result = runNodeHook(scriptPath, input, env);
      assert.strictEqual(result.exitCode, 0, `Call ${i} stderr: ${result.stderr}`);
    }

    // Counter file uses getSessionId() → "session-{sessionId}"
    const counterFile = path.join(testDir, `arcforge-user-count-session-${sessionId}`);
    assert.ok(fs.existsSync(counterFile), 'Counter file should exist');
    const count = parseInt(fs.readFileSync(counterFile, 'utf-8').trim(), 10);
    assert.strictEqual(count, 3, 'Counter should be 3 after 3 calls');
  });

  it('should pass through stdin to stdout', () => {
    const input = makeUserPromptInput('test passthrough');
    const result = runNodeHook(scriptPath, input, {
      TMPDIR: testDir,
      CLAUDE_SESSION_ID: 'test-pt',
    });

    assert.strictEqual(result.exitCode, 0);
    assert.ok(result.stdout.trim().length > 0, 'Should pass stdin through to stdout');
  });
});

// ─────────────────────────────────────────────
// PreToolUse / PostToolUse: observe/main.js
// ─────────────────────────────────────────────

describe('E2E: observe/main.js', () => {
  const scriptPath = path.join(HOOKS_DIR, 'observe', 'main.js');
  let testDir;

  beforeEach(() => {
    testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'test-observe-'));
  });

  afterEach(() => {
    fs.rmSync(testDir, { recursive: true, force: true });
  });

  it('should append observation entry to JSONL on PreToolUse', () => {
    const input = makeToolUseInput('PreToolUse', 'Edit', { file_path: '/tmp/test.js' });
    const result = runNodeHook(scriptPath, input, { CLAUDE_PROJECT_DIR: testDir, HOME: testDir });

    assert.strictEqual(result.exitCode, 0, `stderr: ${result.stderr}`);

    // Check observations.jsonl was written
    const projectName = path.basename(testDir);
    const obsPath = path.join(
      testDir,
      '.claude',
      'observations',
      projectName,
      'observations.jsonl',
    );
    if (fs.existsSync(obsPath)) {
      const lines = fs.readFileSync(obsPath, 'utf-8').trim().split('\n');
      assert.ok(lines.length > 0, 'Should have at least one observation');
      const entry = JSON.parse(lines[0]);
      assert.ok(entry.tool, 'Entry should have tool field');
      assert.ok(entry.ts, 'Entry should have timestamp');
    }
  });

  it('should exit 0 on PostToolUse', () => {
    const input = makeToolUseInput('PostToolUse', 'Edit', { file_path: '/tmp/test.js' });
    const result = runNodeHook(scriptPath, input, { CLAUDE_PROJECT_DIR: testDir, HOME: testDir });
    assert.strictEqual(result.exitCode, 0, `stderr: ${result.stderr}`);
  });
});

// ─────────────────────────────────────────────
// PostToolUse: quality-check/main.js
// ─────────────────────────────────────────────

describe('E2E: quality-check/main.js', () => {
  const scriptPath = path.join(HOOKS_DIR, 'quality-check', 'main.js');
  let testDir;

  beforeEach(() => {
    testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'test-qc-'));
  });

  afterEach(() => {
    fs.rmSync(testDir, { recursive: true, force: true });
  });

  it('should detect console.log in edited JS file', () => {
    // Create a JS file with console.log
    const jsFile = path.join(testDir, 'test-consolelog.js');
    fs.writeFileSync(jsFile, 'function foo() {\n  console.log("debug");\n  return 42;\n}\n');

    const input = makeToolUseInput('PostToolUse', 'Edit', {
      file_path: jsFile,
      old_string: 'old',
      new_string: 'new',
    });
    const result = runNodeHook(scriptPath, input);

    assert.strictEqual(result.exitCode, 0, `stderr: ${result.stderr}`);

    // RV-3: console.* is model-actionable → the model channel (additionalContext),
    // emitted as exactly one JSON object on stdout.
    const parsed = JSON.parse(result.stdout.trim());
    const ctx = parsed.hookSpecificOutput?.additionalContext;
    assert.ok(ctx, `Should output additionalContext. stdout: "${result.stdout.trim()}"`);
    assert.ok(ctx.includes('console'), `model channel should mention console. Got: "${ctx}"`);
  });

  it('should exit 0 on non-JS file', () => {
    const input = makeToolUseInput('PostToolUse', 'Edit', { file_path: '/tmp/test-file.md' });
    const result = runNodeHook(scriptPath, input);
    assert.strictEqual(result.exitCode, 0);
  });

  it('should detect console.log in Write-created JS file', () => {
    // Write tool: the file exists on disk by the time PostToolUse fires
    const content = 'function foo() {\n  console.log("debug");\n  return 42;\n}\n';
    const jsFile = path.join(testDir, 'written.js');
    fs.writeFileSync(jsFile, content);

    const input = makeToolUseInput('PostToolUse', 'Write', { file_path: jsFile, content });
    const result = runNodeHook(scriptPath, input);

    assert.strictEqual(result.exitCode, 0, `stderr: ${result.stderr}`);

    // RV-3: console.* finding reaches the model via the PostToolUse model channel.
    const parsed = JSON.parse(result.stdout.trim());
    const ctx = parsed.hookSpecificOutput?.additionalContext;
    assert.ok(ctx, `Should output additionalContext. stdout: "${result.stdout.trim()}"`);
    assert.ok(ctx.includes('console'), `model channel should mention console. Got: "${ctx}"`);
  });

  it('should NOT emit output for Write-created file with only console.error', () => {
    const content = 'function fail(msg) {\n  console.error("Error: " + msg);\n}\n';
    const jsFile = path.join(testDir, 'cli-errors.js');
    fs.writeFileSync(jsFile, content);

    const input = makeToolUseInput('PostToolUse', 'Write', { file_path: jsFile, content });
    const result = runNodeHook(scriptPath, input);

    assert.strictEqual(result.exitCode, 0, `stderr: ${result.stderr}`);
    assert.strictEqual(
      result.stdout.trim(),
      '',
      `console.error-only file should produce no output. Got: "${result.stdout.trim()}"`,
    );
  });
});

// ─────────────────────────────────────────────
// PostToolUse: compact-suggester/main.js
// ─────────────────────────────────────────────

describe('E2E: compact-suggester/main.js', () => {
  const scriptPath = path.join(HOOKS_DIR, 'compact-suggester', 'main.js');
  let testDir;

  beforeEach(() => {
    testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'test-compact-'));
  });

  afterEach(() => {
    fs.rmSync(testDir, { recursive: true, force: true });
  });

  it('should suggest compact at 50 tool calls', () => {
    const sessionId = 'test-threshold';
    const env = { TMPDIR: testDir };

    // Pre-create the suggester JSON state at 49 tools (its own counter, distinct
    // from the shared diary tool-count). The 50th call triggers the suggestion.
    const stateFile = path.join(testDir, `arcforge-suggester-state-session-${sessionId}.json`);
    fs.writeFileSync(
      stateFile,
      JSON.stringify({ tools: 49, reads: 0, writes: 0, window: [], suggestions: [] }),
    );

    // The 50th call should trigger suggestion
    const input = makeToolUseInput(
      'PostToolUse',
      'Read',
      { file_path: '/tmp/test.js' },
      { session_id: sessionId },
    );
    const result = runNodeHook(scriptPath, input, env);

    assert.strictEqual(result.exitCode, 0, `stderr: ${result.stderr}`);

    // compact-suggester outputs systemMessage via stdout JSON
    const parsed = JSON.parse(result.stdout);
    assert.ok(
      parsed.systemMessage,
      `Should output systemMessage at threshold. stdout: "${result.stdout.trim()}"`,
    );
    assert.ok(
      parsed.systemMessage.includes('tool call') || parsed.systemMessage.includes('compact'),
      `systemMessage should mention tool calls or compact. Got: "${parsed.systemMessage}"`,
    );
  });

  it('should NOT suggest below threshold', () => {
    const sessionId = 'test-below';
    const env = { TMPDIR: testDir, CLAUDE_SESSION_ID: sessionId };

    const input = makeToolUseInput('PostToolUse', 'Read', { file_path: '/tmp/test.js' });
    const result = runNodeHook(scriptPath, input, env);

    assert.strictEqual(result.exitCode, 0);
    // No suggestion at count=1
    assert.ok(!result.stderr.includes('/compact'), 'Should not suggest compact below threshold');
  });

  it('should exit 0 with empty stdin', () => {
    const result = runNodeHook(scriptPath, '', {
      TMPDIR: testDir,
      CLAUDE_SESSION_ID: 'test-empty',
    });
    assert.strictEqual(result.exitCode, 0);
  });
});

// ─────────────────────────────────────────────
// PreCompact: pre-compact/main.js
// ─────────────────────────────────────────────

describe('E2E: pre-compact/main.js', () => {
  const scriptPath = path.join(HOOKS_DIR, 'pre-compact', 'main.js');
  let testDir;

  beforeEach(() => {
    testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'test-precompact-'));
  });

  afterEach(() => {
    fs.rmSync(testDir, { recursive: true, force: true });
  });

  it('should only append compactions[] — no markdown file, no compaction log', () => {
    // getSessionId() prefixes the env value with 'session-'
    const sessionId = 'session-test-precompact';
    const projectName = path.basename(testDir);
    const date = new Date().toISOString().split('T')[0];
    const sessionDir = path.join(testDir, '.arcforge', 'sessions', projectName, date);
    fs.mkdirSync(sessionDir, { recursive: true });
    const sessionFile = path.join(sessionDir, `${sessionId}.json`);
    fs.writeFileSync(
      sessionFile,
      JSON.stringify({ project: projectName, date, toolCalls: 3, compactions: [] }),
    );

    // Push counters above threshold so the diary-trigger branch runs
    fs.writeFileSync(path.join(testDir, `arcforge-user-count-${sessionId}`), '15');
    fs.writeFileSync(path.join(testDir, `arcforge-tool-count-${sessionId}`), '60');

    const input = makeHookInput('PreCompact', {}, { session_id: 'test-precompact' });
    const result = runNodeHook(scriptPath, input, {
      CLAUDE_PROJECT_DIR: testDir,
      HOME: testDir,
      TMPDIR: testDir,
      CLAUDE_SESSION_ID: 'test-precompact',
    });

    assert.strictEqual(result.exitCode, 0, `stderr: ${result.stderr}`);

    const updated = JSON.parse(fs.readFileSync(sessionFile, 'utf-8'));
    assert.strictEqual(updated.compactions.length, 1, 'should append one compaction timestamp');
    // Counter reset proves the threshold branch (former markdown writer) executed
    assert.strictEqual(
      fs.readFileSync(path.join(testDir, `arcforge-user-count-${sessionId}`), 'utf-8'),
      '0',
      'threshold branch should run and reset the user counter',
    );
    assert.ok(
      !fs.existsSync(path.join(sessionDir, `${sessionId}.md`)),
      'should not write a markdown summary file',
    );
    assert.ok(
      !fs.existsSync(
        path.join(testDir, '.arcforge', 'sessions', projectName, 'compaction-log.txt'),
      ),
      'should not write compaction-log.txt',
    );
  });

  it('should exit 0 with empty stdin', () => {
    const result = runNodeHook(scriptPath, '', {
      CLAUDE_PROJECT_DIR: testDir,
      HOME: testDir,
      TMPDIR: testDir,
    });
    assert.strictEqual(result.exitCode, 0);
  });
});

// ─────────────────────────────────────────────
// Stop: session-tracker/end.js
// ─────────────────────────────────────────────

describe('E2E: session-tracker/end.js', () => {
  const scriptPath = path.join(HOOKS_DIR, 'session-tracker', 'end.js');
  let testDir;

  beforeEach(() => {
    testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'test-end-'));
  });

  afterEach(() => {
    fs.rmSync(testDir, { recursive: true, force: true });
  });

  it('should exit 0 on Stop event', () => {
    const input = makeStopInput({ session_id: 'test-end' });
    const result = runNodeHook(scriptPath, input, {
      CLAUDE_PROJECT_DIR: testDir,
      HOME: testDir,
      TMPDIR: testDir,
    });
    assert.strictEqual(result.exitCode, 0, `stderr: ${result.stderr}`);
  });

  it('should exit 0 with empty stdin', () => {
    const result = runNodeHook(scriptPath, '', {
      CLAUDE_PROJECT_DIR: testDir,
      HOME: testDir,
      TMPDIR: testDir,
    });
    assert.strictEqual(result.exitCode, 0);
  });
});
