/**
 * Unit tests for budget-override audit logger.
 *
 * The audit trail is the only check on `EVALS_BUDGET_OVERRIDE_REASON` and
 * `GSTACK_SIZE_BUDGET_OVERRIDE_REASON` — if the logger silently drops events,
 * overrides become invisible and the budget gates are theater. These tests
 * pin the contract: every override produces exactly one JSONL line with
 * timestamp + scope + reason + CI provenance.
 */

import { describe, test, expect, beforeAll, beforeEach, afterAll } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { logBudgetOverride } from './budget-override';

const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'budget-override-test-'));
const AUDIT_PATH = path.join(TMP_HOME, 'analytics', 'spend-overrides.jsonl');

// GSTACK_HOME is scoped to this file's execution window (beforeAll/afterAll),
// never set at module load: bun evaluates sibling modules before running
// their tests, so a module-scope assignment leaks into every other file in
// the shard process (pinned by test/gstack-home-module-scope.test.ts).
const ORIGINAL_GSTACK_HOME = process.env.GSTACK_HOME;
beforeAll(() => {
  process.env.GSTACK_HOME = TMP_HOME;
});
afterAll(() => {
  if (ORIGINAL_GSTACK_HOME === undefined) delete process.env.GSTACK_HOME;
  else process.env.GSTACK_HOME = ORIGINAL_GSTACK_HOME;
});

describe('logBudgetOverride', () => {
  beforeEach(() => {
    // Start each test with a clean audit file
    try { fs.unlinkSync(AUDIT_PATH); } catch { /* doesn't exist */ }
  });

  test('writes one JSONL line per call with required fields', () => {
    logBudgetOverride({
      scope: 'evals-cost-cap-e2e',
      reason: 'model price went up, will rebase the cap next sprint',
      details: { tier: 'e2e', cap: 25, observed_cost_usd: 31.4 },
    });

    expect(fs.existsSync(AUDIT_PATH)).toBe(true);
    const lines = fs.readFileSync(AUDIT_PATH, 'utf-8').split('\n').filter(Boolean);
    expect(lines.length).toBe(1);
    const entry = JSON.parse(lines[0]!);
    expect(entry.scope).toBe('evals-cost-cap-e2e');
    expect(entry.reason).toBe('model price went up, will rebase the cap next sprint');
    expect(entry.details).toEqual({ tier: 'e2e', cap: 25, observed_cost_usd: 31.4 });
    expect(typeof entry.timestamp).toBe('string');
    expect(entry.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/);
  });

  test('captures CI provenance when CI env is set', () => {
    process.env.CI = 'true';
    process.env.GITHUB_ACTIONS = 'true';
    process.env.GITHUB_REF_NAME = 'feature/x';
    process.env.GITHUB_SHA = 'deadbeefcafe1234';

    logBudgetOverride({ scope: 'skill-size-budget', reason: 'big diff bake-in' });

    const entry = JSON.parse(fs.readFileSync(AUDIT_PATH, 'utf-8').trim());
    expect(entry.ci).toBe(true);
    expect(entry.runner).toBe('github-actions');
    expect(entry.branch).toBe('feature/x');
    expect(entry.commit).toBe('deadbeef');

    delete process.env.CI;
    delete process.env.GITHUB_ACTIONS;
    delete process.env.GITHUB_REF_NAME;
    delete process.env.GITHUB_SHA;
  });

  test('defaults provenance to local when CI is unset', () => {
    delete process.env.CI;
    delete process.env.GITHUB_ACTIONS;
    delete process.env.GITHUB_REF_NAME;
    delete process.env.GITHUB_SHA;
    delete process.env.CI_RUNNER;
    delete process.env.CI_COMMIT_REF_NAME;
    delete process.env.CI_COMMIT_SHORT_SHA;

    logBudgetOverride({ scope: 'skill-size-budget-corpus', reason: 'local dev test' });

    const entry = JSON.parse(fs.readFileSync(AUDIT_PATH, 'utf-8').trim());
    expect(entry.ci).toBe(false);
    expect(entry.runner).toBe('local');
    expect(entry.branch).toBe('unknown');
    expect(entry.commit).toBe('unknown');
  });

  test('append-only: multiple calls produce multiple lines', () => {
    logBudgetOverride({ scope: 's1', reason: 'r1' });
    logBudgetOverride({ scope: 's2', reason: 'r2' });
    logBudgetOverride({ scope: 's3', reason: 'r3' });

    const lines = fs.readFileSync(AUDIT_PATH, 'utf-8').split('\n').filter(Boolean);
    expect(lines.length).toBe(3);
    const scopes = lines.map(l => JSON.parse(l).scope);
    expect(scopes).toEqual(['s1', 's2', 's3']);
  });

  test('omits details key when entry.details is absent (uses empty object)', () => {
    logBudgetOverride({ scope: 'plain', reason: 'no details' });
    const entry = JSON.parse(fs.readFileSync(AUDIT_PATH, 'utf-8').trim());
    expect(entry.details).toEqual({});
  });

  test('never throws even when audit directory is missing — creates it', () => {
    // Remove the analytics dir to force mkdir
    try { fs.rmSync(path.join(TMP_HOME, 'analytics'), { recursive: true, force: true }); } catch { /* */ }
    expect(() => logBudgetOverride({ scope: 'recreate', reason: 'test' })).not.toThrow();
    expect(fs.existsSync(AUDIT_PATH)).toBe(true);
  });

  test('survives an unwritable audit path (logs warning, does not throw)', () => {
    // Point GSTACK_HOME at a path inside a file (illegal directory location)
    const originalHome = process.env.GSTACK_HOME;
    const bogusFile = path.join(TMP_HOME, 'not-a-dir.txt');
    fs.writeFileSync(bogusFile, 'just a file');
    process.env.GSTACK_HOME = bogusFile;
    expect(() => logBudgetOverride({ scope: 'unwritable', reason: 'fs error path' })).not.toThrow();
    process.env.GSTACK_HOME = originalHome;
  });
});
