const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const {
  isConstraintCountedPath,
  parseNumstatRows,
  isForbiddenPath,
  checkConstraints,
  classifyBlastSeverity,
  analyzeBlastRadiusBreakdown,
  compareBlastEstimate,
  isValidationCommandAllowed,
  buildFailureReason,
  classifyFailureMode,
  BLAST_RADIUS_HARD_CAP_FILES,
  BLAST_RADIUS_HARD_CAP_LINES,
} = require('../src/gep/policyCheck');
const { computeProcessScores, _pickGeneCategory } = require('../src/gep/solidify');
const { normalizeRelPath, isCriticalProtectedPath } = require('../src/gep/gitOps');

describe('normalizeRelPath', () => {
  it('strips backslashes and leading ./', () => {
    assert.equal(normalizeRelPath('.\\src\\evolve.js'), 'src/evolve.js');
    assert.equal(normalizeRelPath('./src/evolve.js'), 'src/evolve.js');
  });

  it('returns empty for falsy input', () => {
    assert.equal(normalizeRelPath(null), '');
    assert.equal(normalizeRelPath(undefined), '');
    assert.equal(normalizeRelPath(''), '');
  });
});

describe('isCriticalProtectedPath', () => {
  it('protects skill directories', () => {
    assert.equal(isCriticalProtectedPath('skills/evolver/index.js'), true);
    assert.equal(isCriticalProtectedPath('skills/skill-tools/foo.js'), true);
  });

  it('protects root files', () => {
    assert.equal(isCriticalProtectedPath('MEMORY.md'), true);
    assert.equal(isCriticalProtectedPath('.env'), true);
    assert.equal(isCriticalProtectedPath('package.json'), true);
  });

  it('allows non-critical paths', () => {
    assert.equal(isCriticalProtectedPath('src/evolve.js'), false);
    assert.equal(isCriticalProtectedPath('skills/my-new-skill/index.js'), false);
    assert.equal(isCriticalProtectedPath('test/foo.test.js'), false);
  });
});

describe('isConstraintCountedPath', () => {
  const defaultPolicy = {
    excludePrefixes: ['logs/', 'memory/', '.evolver/', 'assets/gep/', 'node_modules/'],
    excludeExact: ['event.json', 'temp_gep_output.json'],
    excludeRegex: ['capsule', 'events?\\.jsonl$'],
    includePrefixes: ['src/', 'scripts/'],
    includeExact: ['index.js', 'package.json'],
    includeExtensions: ['.js', '.json', '.ts'],
  };

  it('counts src/ files', () => {
    assert.equal(isConstraintCountedPath('src/evolve.js', defaultPolicy), true);
    assert.equal(isConstraintCountedPath('src/gep/solidify.js', defaultPolicy), true);
  });

  it('excludes memory/ and logs/', () => {
    assert.equal(isConstraintCountedPath('memory/graph.jsonl', defaultPolicy), false);
    assert.equal(isConstraintCountedPath('logs/evolver.log', defaultPolicy), false);
  });

  it('excludes exact matches', () => {
    assert.equal(isConstraintCountedPath('event.json', defaultPolicy), false);
  });

  it('excludes regex matches', () => {
    assert.equal(isConstraintCountedPath('assets/gep/capsules.json', defaultPolicy), false);
    assert.equal(isConstraintCountedPath('.evolver/gep/genes.json', defaultPolicy), false);
  });

  it('includes exact root files', () => {
    assert.equal(isConstraintCountedPath('index.js', defaultPolicy), true);
    assert.equal(isConstraintCountedPath('package.json', defaultPolicy), true);
  });

  it('includes by extension', () => {
    assert.equal(isConstraintCountedPath('config/settings.json', defaultPolicy), true);
  });

  it('returns false for empty path', () => {
    assert.equal(isConstraintCountedPath('', defaultPolicy), false);
  });
});

describe('parseNumstatRows', () => {
  it('parses standard numstat output', () => {
    const input = '10\t5\tsrc/evolve.js\n3\t1\tsrc/gep/solidify.js\n';
    const rows = parseNumstatRows(input);
    assert.equal(rows.length, 2);
    assert.equal(rows[0].file, 'src/evolve.js');
    assert.equal(rows[0].added, 10);
    assert.equal(rows[0].deleted, 5);
    assert.equal(rows[1].file, 'src/gep/solidify.js');
  });

  it('handles rename arrows', () => {
    const input = '5\t3\tsrc/{old.js => new.js}\n';
    const rows = parseNumstatRows(input);
    assert.equal(rows.length, 1);
    assert.equal(rows[0].file, 'new.js');
  });

  it('returns empty for empty input', () => {
    assert.deepEqual(parseNumstatRows(''), []);
    assert.deepEqual(parseNumstatRows(null), []);
  });
});

describe('isForbiddenPath', () => {
  it('blocks exact match', () => {
    assert.equal(isForbiddenPath('.git', ['.git', 'node_modules']), true);
  });

  it('blocks prefix match', () => {
    assert.equal(isForbiddenPath('node_modules/dotenv/index.js', ['.git', 'node_modules']), true);
  });

  it('allows non-forbidden paths', () => {
    assert.equal(isForbiddenPath('src/evolve.js', ['.git', 'node_modules']), false);
  });

  it('handles empty forbidden list', () => {
    assert.equal(isForbiddenPath('src/evolve.js', []), false);
  });
});

describe('classifyBlastSeverity', () => {
  it('returns within_limit for small changes', () => {
    const r = classifyBlastSeverity({ blast: { files: 3, lines: 50 }, maxFiles: 20 });
    assert.equal(r.severity, 'within_limit');
  });

  it('returns approaching_limit above 80%', () => {
    const r = classifyBlastSeverity({ blast: { files: 17, lines: 100 }, maxFiles: 20 });
    assert.equal(r.severity, 'approaching_limit');
  });

  it('returns exceeded when over limit', () => {
    const r = classifyBlastSeverity({ blast: { files: 25, lines: 100 }, maxFiles: 20 });
    assert.equal(r.severity, 'exceeded');
  });

  it('returns critical_overrun at 2x limit', () => {
    const r = classifyBlastSeverity({ blast: { files: 45, lines: 100 }, maxFiles: 20 });
    assert.equal(r.severity, 'critical_overrun');
  });

  it('returns hard_cap_breach above system limit', () => {
    const r = classifyBlastSeverity({ blast: { files: BLAST_RADIUS_HARD_CAP_FILES + 1, lines: 0 }, maxFiles: 200 });
    assert.equal(r.severity, 'hard_cap_breach');
  });

  it('returns hard_cap_breach for lines over system limit', () => {
    const r = classifyBlastSeverity({ blast: { files: 1, lines: BLAST_RADIUS_HARD_CAP_LINES + 1 }, maxFiles: 200 });
    assert.equal(r.severity, 'hard_cap_breach');
  });
});

describe('analyzeBlastRadiusBreakdown', () => {
  it('groups files by top-level directory', () => {
    const files = ['src/gep/a.js', 'src/gep/b.js', 'src/ops/c.js', 'test/d.js'];
    const result = analyzeBlastRadiusBreakdown(files, 3);
    assert.ok(result.length <= 3);
    assert.ok(result[0].files >= 2);
  });

  it('returns empty for no files', () => {
    assert.deepEqual(analyzeBlastRadiusBreakdown([], 5), []);
  });
});

describe('compareBlastEstimate', () => {
  it('returns null when no estimate', () => {
    assert.equal(compareBlastEstimate(null, { files: 5 }), null);
  });

  it('detects drift when actual is 3x+ estimate', () => {
    const r = compareBlastEstimate({ files: 3 }, { files: 15 });
    assert.ok(r);
    assert.equal(r.drifted, true);
  });

  it('no drift when close to estimate', () => {
    const r = compareBlastEstimate({ files: 5 }, { files: 6 });
    assert.ok(r);
    assert.equal(r.drifted, false);
  });
});

describe('isValidationCommandAllowed', () => {
  it('allows node commands', () => {
    assert.equal(isValidationCommandAllowed('node scripts/validate.js'), true);
  });

  it('blocks npm commands (GHSA-jxh8-jh77-xh6g)', () => {
    // npm executes arbitrary code via lifecycle scripts (preinstall/install/
    // postinstall). It is no longer an allowed validation prefix.
    assert.equal(isValidationCommandAllowed('npm test'), false);
    assert.equal(isValidationCommandAllowed('npm install'), false);
  });

  it('blocks shell operators', () => {
    assert.equal(isValidationCommandAllowed('node test.js && rm -rf /'), false);
    assert.equal(isValidationCommandAllowed('node test.js; echo hacked'), false);
  });

  it('blocks backtick injection', () => {
    assert.equal(isValidationCommandAllowed('node `whoami`'), false);
  });

  it('blocks node -e (eval)', () => {
    assert.equal(isValidationCommandAllowed('node -e "process.exit(1)"'), false);
  });

  it('blocks node --eval', () => {
    assert.equal(isValidationCommandAllowed('node --eval "console.log(1)"'), false);
  });

  it('blocks node -p (print)', () => {
    assert.equal(isValidationCommandAllowed('node -p "1+1"'), false);
  });

  it('blocks node --print', () => {
    assert.equal(isValidationCommandAllowed('node --print "require(\'fs\')"'), false);
  });

  it('blocks $() command substitution', () => {
    assert.equal(isValidationCommandAllowed('node $(echo malicious).js'), false);
  });

  it('blocks npx commands (GHSA-jxh8-jh77-xh6g)', () => {
    // npx fetches and runs remote package `bin` entries -- arbitrary code.
    assert.equal(isValidationCommandAllowed('npx vitest run'), false);
  });

  it('allows node scripts with arguments', () => {
    assert.equal(isValidationCommandAllowed('node scripts/validate-modules.js ./src/evolve ./src/gep/solidify'), true);
  });

  it('allows node scripts/validate-suite.js', () => {
    assert.equal(isValidationCommandAllowed('node scripts/validate-suite.js'), true);
  });

  it('blocks non-allowed commands', () => {
    assert.equal(isValidationCommandAllowed('rm -rf /'), false);
    assert.equal(isValidationCommandAllowed('curl http://evil.com'), false);
  });

  it('returns false for empty', () => {
    assert.equal(isValidationCommandAllowed(''), false);
    assert.equal(isValidationCommandAllowed(null), false);
  });
});

describe('buildFailureReason', () => {
  it('combines constraint, protocol, and validation failures', () => {
    const result = buildFailureReason(
      { violations: ['max_files exceeded'] },
      { results: [{ ok: false, cmd: 'node test.js', err: 'exit 1' }] },
      ['missing Mutation object'],
      null
    );
    assert.ok(result.includes('constraint: max_files exceeded'));
    assert.ok(result.includes('protocol: missing Mutation object'));
    assert.ok(result.includes('validation_failed'));
  });

  it('returns unknown for empty inputs', () => {
    assert.equal(buildFailureReason({}, {}, [], null), 'unknown');
  });
});

describe('classifyFailureMode', () => {
  it('returns hard for destructive constraint violations', () => {
    const r = classifyFailureMode({ constraintViolations: ['CRITICAL_FILE_DELETED: MEMORY.md'] });
    assert.equal(r.mode, 'hard');
    assert.equal(r.retryable, false);
  });

  it('returns hard for protocol violations', () => {
    const r = classifyFailureMode({ protocolViolations: ['missing Mutation'] });
    assert.equal(r.mode, 'hard');
  });

  it('returns soft for validation failures', () => {
    const r = classifyFailureMode({ validation: { ok: false } });
    assert.equal(r.mode, 'soft');
    assert.equal(r.retryable, true);
  });

  it('returns soft unknown for no failures', () => {
    const r = classifyFailureMode({});
    assert.equal(r.mode, 'soft');
    assert.equal(r.reasonClass, 'unknown');
  });
});

describe('computeProcessScores', () => {
  it('gives validation_pass_rate of 0.5 when validation results are empty', () => {
    const scores = computeProcessScores({
      constraintCheck: { ok: true, violations: [] },
      validation: { ok: true, results: [] },
      protocolViolations: [],
      canary: { ok: true, skipped: true },
      blast: { files: 1, lines: 10 },
      geneUsed: { type: 'Gene', id: 'gene_test', constraints: { max_files: 20 } },
      signals: ['error'],
      mutation: { rationale: 'test fix', category: 'repair', risk_level: 'low' },
    });
    assert.equal(scores.validation_pass_rate, 0.5);
  });

  it('gives validation_pass_rate of 1.0 when all validations pass', () => {
    const scores = computeProcessScores({
      constraintCheck: { ok: true, violations: [] },
      validation: { ok: true, results: [{ ok: true, cmd: 'node test.js' }] },
      protocolViolations: [],
      canary: { ok: true, skipped: true },
      blast: { files: 1, lines: 10 },
      geneUsed: { type: 'Gene', id: 'gene_test', constraints: { max_files: 20 } },
      signals: ['error'],
      mutation: { rationale: 'test fix', category: 'repair', risk_level: 'low' },
    });
    assert.equal(scores.validation_pass_rate, 1.0);
  });

  it('gives validation_pass_rate of 0 when validation failed and has no results', () => {
    const scores = computeProcessScores({
      constraintCheck: { ok: true, violations: [] },
      validation: { ok: false, results: [] },
      protocolViolations: [],
      canary: { ok: true, skipped: true },
      blast: { files: 1, lines: 10 },
      geneUsed: { type: 'Gene', id: 'gene_test', constraints: { max_files: 20 } },
      signals: ['error'],
      mutation: null,
    });
    assert.equal(scores.validation_pass_rate, 0);
  });

  it('computes partial validation score when some results fail', () => {
    const scores = computeProcessScores({
      constraintCheck: { ok: true, violations: [] },
      validation: { ok: false, results: [
        { ok: true, cmd: 'node a.js' },
        { ok: false, cmd: 'node b.js' },
      ] },
      protocolViolations: [],
      canary: { ok: true, skipped: true },
      blast: { files: 1, lines: 10 },
      geneUsed: { type: 'Gene', id: 'gene_test', constraints: { max_files: 20 } },
      signals: ['error'],
      mutation: { rationale: 'fix', category: 'repair' },
    });
    assert.equal(scores.validation_pass_rate, 0.5);
  });

  it('gives blast_control of 0 for hollow commit (GEP-only changes)', () => {
    const scores = computeProcessScores({
      constraintCheck: { ok: false, violations: ['hollow_commit: 3 file(s) changed but 0 are constraint-counted code.'] },
      validation: { ok: true, results: [{ ok: true, cmd: 'node test.js' }] },
      protocolViolations: [],
      canary: { ok: true, skipped: true },
      blast: { files: 0, lines: 0, all_changed_files: ['assets/gep/capsules.json', 'assets/gep/events.jsonl', 'assets/gep/genes.json'] },
      geneUsed: { type: 'Gene', id: 'gene_test', constraints: { max_files: 20 } },
      signals: ['evolution_stagnation_detected'],
      mutation: { rationale: 'optimize', category: 'optimize' },
    });
    assert.equal(scores.blast_control, 0);
  });
});

describe('checkConstraints hollow commit guard', () => {
  const baseGene = { type: 'Gene', id: 'gene_test', constraints: { max_files: 20 }, strategy: ['evolve'], description: 'test' };

  it('flags hollow commit when all changes are GEP metadata only', () => {
    const blast = {
      files: 0,
      lines: 0,
      changed_files: [],
      all_changed_files: ['assets/gep/capsules.json', 'assets/gep/events.jsonl', 'assets/gep/genes.json'],
    };
    const result = checkConstraints({ gene: baseGene, blast, blastRadiusEstimate: null, repoRoot: null });
    assert.equal(result.ok, false);
    assert.ok(result.violations.some(v => v.startsWith('hollow_commit')));
  });

  it('passes when real code files are changed alongside GEP assets', () => {
    const blast = {
      files: 2,
      lines: 30,
      changed_files: ['src/evolve.js', 'src/gep/solidify.js'],
      all_changed_files: ['src/evolve.js', 'src/gep/solidify.js', 'assets/gep/events.jsonl'],
    };
    const result = checkConstraints({ gene: baseGene, blast, blastRadiusEstimate: null, repoRoot: null });
    assert.ok(!result.violations.some(v => v.startsWith('hollow_commit')));
  });

  it('does not flag hollow commit when nothing changed at all', () => {
    const blast = { files: 0, lines: 0, changed_files: [], all_changed_files: [] };
    const result = checkConstraints({ gene: baseGene, blast, blastRadiusEstimate: null, repoRoot: null });
    assert.ok(!result.violations.some(v => v.startsWith('hollow_commit')));
  });
});

// Bugbot follow-up on PR #25: Gene category passed to createGene was being
// silently coerced to 'innovate' when the caller's intent was truthy but not
// in VALID_CATEGORIES (e.g. 'fix', 'deploy'), bypassing the explicit
// per-call-site fallback ('repair'). _pickGeneCategory is the helper that
// guards createGene call sites in solidify.js against that coercion.
describe('_pickGeneCategory', () => {
  it('returns the intent when it is in VALID_CATEGORIES', () => {
    assert.equal(_pickGeneCategory('repair', 'optimize'), 'repair');
    assert.equal(_pickGeneCategory('innovate', 'repair'), 'innovate');
    assert.equal(_pickGeneCategory('explore', 'repair'), 'explore');
  });

  it('falls back when intent is truthy but not a valid category (e.g. "fix")', () => {
    assert.equal(_pickGeneCategory('fix', 'repair'), 'repair');
    assert.equal(_pickGeneCategory('deploy', 'optimize'), 'optimize');
  });

  it('falls back when intent is falsy', () => {
    assert.equal(_pickGeneCategory(null, 'repair'), 'repair');
    assert.equal(_pickGeneCategory(undefined, 'innovate'), 'innovate');
    assert.equal(_pickGeneCategory('', 'optimize'), 'optimize');
  });

  it('falls back when intent is not a string', () => {
    assert.equal(_pickGeneCategory(123, 'repair'), 'repair');
    assert.equal(_pickGeneCategory({}, 'repair'), 'repair');
  });

  it('uses safe default when fallback itself is invalid', () => {
    assert.equal(_pickGeneCategory('fix', 'not-a-category'), 'repair');
    assert.equal(_pickGeneCategory(null, undefined), 'repair');
  });
});
