import { describe, it, expect } from 'vitest';
import os from 'os';
import fs from 'fs';
import path from 'path';
import {
  parseInterpolationRefs,
  extractEnvKeyFromLine,
  readEnvFileKeys,
  parseUnsetEnvVars,
  parseMissingRequiredVars,
  parseBareDollarRefs,
  parseIntentionalBareDollarRefs,
} from '../helpers/envVarParse';

describe('parseInterpolationRefs', () => {
  it('classifies all seven operator forms, including the no-colon variants', () => {
    const src = [
      'a: ${BARE}',
      'b: ${DEF:-fallback}',
      'c: ${DEF2-fallback}',
      'd: ${REQ:?must be set}',
      'e: ${REQ2?must be set}',
      'f: ${ALT:+present}',
      'g: ${ALT2+present}',
    ].join('\n');
    const refs = new Map(parseInterpolationRefs(src).map(r => [r.name, r]));
    expect(refs.get('BARE')).toMatchObject({ required: false, hasDefault: false, alternate: false });
    expect(refs.get('DEF')).toMatchObject({ hasDefault: true, required: false });
    expect(refs.get('DEF2')).toMatchObject({ hasDefault: true, required: false });
    expect(refs.get('REQ')).toMatchObject({ required: true });
    expect(refs.get('REQ2')).toMatchObject({ required: true });
    expect(refs.get('ALT')).toMatchObject({ alternate: true, required: false, hasDefault: false });
    expect(refs.get('ALT2')).toMatchObject({ alternate: true });
  });

  it('skips the $${ESCAPED} literal', () => {
    expect(parseInterpolationRefs('x: $${ESCAPED}').map(r => r.name)).not.toContain('ESCAPED');
  });

  it('extracts bare $VAR refs but skips $$ escapes and ${VAR} forms', () => {
    expect(parseBareDollarRefs('host: $DB_HOST and $${LIT} and ${BRACED}')).toEqual(['DB_HOST']);
  });

  it('keeps intentional bare refs for self-refs and whole-value refs only', () => {
    const src = [
      '- TOKEN=$TOKEN',
      '- FOO=$BAR',
      '- EXAMPLE_AUTH_HASH=$2b$10$E6SDEbshpc$vCSrREDACTED',
      'EXAMPLE_AUTH_HASH: $2b$10$E6SDEbshpc$vCSrREDACTED',
      'command: echo $HELLO',
    ].join('\n');
    expect(parseIntentionalBareDollarRefs(src).sort()).toEqual(['BAR', 'HELLO', 'TOKEN']);
  });

  it('merges flags across repeated references of one name', () => {
    const refs = parseInterpolationRefs('${X} then ${X:?e}');
    expect(refs).toHaveLength(1);
    expect(refs[0]).toMatchObject({ name: 'X', required: true });
  });

  it('ORs default and alternate flags across occurrences too', () => {
    const refs = parseInterpolationRefs('${Y:-d} then ${Y:+a}');
    expect(refs).toHaveLength(1);
    expect(refs[0]).toMatchObject({ name: 'Y', hasDefault: true, alternate: true, required: false });
  });
});

describe('extractEnvKeyFromLine', () => {
  it('returns the key name only for each line form', () => {
    expect(extractEnvKeyFromLine('FOO=bar')).toBe('FOO');
    expect(extractEnvKeyFromLine('export BAZ=qux')).toBe('BAZ');
    expect(extractEnvKeyFromLine('BARE')).toBe('BARE');
    expect(extractEnvKeyFromLine('# comment')).toBeNull();
    expect(extractEnvKeyFromLine('   ')).toBeNull();
    expect(extractEnvKeyFromLine('1BAD=x')).toBeNull();
  });

  it('never returns the value', () => {
    expect(extractEnvKeyFromLine('SECRET=supersecretvalue')).toBe('SECRET');
  });
});

describe('readEnvFileKeys', () => {
  const base = fs.mkdtempSync(path.join(os.tmpdir(), 'envkeys-'));

  it('reads key names only and never the value', async () => {
    const p = path.join(base, '.env');
    fs.writeFileSync(p, 'FOO=secretvalue\n# c\nexport BAR=2\nBARE\n');
    const res = await readEnvFileKeys(p, base);
    expect(res.keys.sort()).toEqual(['BAR', 'BARE', 'FOO']);
    expect(res.unverifiable).toBe(false);
    expect(JSON.stringify(res)).not.toContain('secretvalue');
  });

  it('caps bytes/lines on a large fixture, flags truncated, and leaks no value', async () => {
    const p = path.join(base, 'big.env');
    let content = '';
    for (let i = 0; i < 20000; i++) content += `K${i}=verylongsecret${'x'.repeat(40)}\n`;
    fs.writeFileSync(p, content);
    const res = await readEnvFileKeys(p, base, { maxBytes: 4096, maxLines: 100, maxLineLen: 8192 });
    expect(res.truncated).toBe(true);
    expect(res.keys.length).toBeLessThanOrEqual(100);
    expect(JSON.stringify(res)).not.toContain('verylongsecret');
  });

  it('marks a path escaping the base directory as unverifiable', async () => {
    // A secure temp dir that is a sibling of `base`, so the file is outside it.
    const otherBase = fs.mkdtempSync(path.join(os.tmpdir(), 'envkeys-out-'));
    const outside = path.join(otherBase, 'x.env');
    fs.writeFileSync(outside, 'X=1');
    const res = await readEnvFileKeys(outside, base);
    expect(res.unverifiable).toBe(true);
    expect(res.keys).toEqual([]);
    fs.rmSync(otherBase, { recursive: true, force: true });
  });

  it('marks a missing file as unverifiable', async () => {
    const res = await readEnvFileKeys(path.join(base, 'nope.env'), base);
    expect(res.unverifiable).toBe(true);
    expect(res.keys).toEqual([]);
  });
});

describe('parseUnsetEnvVars / parseMissingRequiredVars', () => {
  it('extracts unset variable names (escaped, quoted, and bare forms)', () => {
    const stderr =
      'time="t" level=warning msg="The \\"DB_HOST\\" variable is not set. Defaulting to a blank string."\n'
      + 'The "TOKEN" variable is not set.\n'
      + 'The PLAIN variable is not set.';
    expect(parseUnsetEnvVars(stderr).sort()).toEqual(['DB_HOST', 'PLAIN', 'TOKEN']);
  });

  it('extracts the name from a required-variable error', () => {
    const stderr = 'error while interpolating services.web.environment.TOKEN: required variable REQ_TOKEN is missing a value: must be provided';
    expect(parseMissingRequiredVars(stderr)).toEqual(['REQ_TOKEN']);
  });
});
