/**
 * buildEnvInventory status derivation, the hard no-value guarantee, and the
 * GET /api/stacks/:stackName/env-inventory route. The effective model render is
 * mocked; the filesystem and DB are real.
 */
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
import fs from 'fs';
import path from 'path';
import request from 'supertest';
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
import { ComposeService } from '../services/ComposeService';
import { buildEnvInventory, type EnvInventory } from '../services/EnvInventoryService';

let tmpDir: string;
let app: import('express').Express;
let authCookie: string;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
let nodeId: number;

function composeDir(): string { return process.env.COMPOSE_DIR as string; }

function writeStack(stack: string, files: Record<string, string>): void {
  const dir = path.join(composeDir(), stack);
  fs.mkdirSync(dir, { recursive: true });
  for (const [name, content] of Object.entries(files)) fs.writeFileSync(path.join(dir, name), content);
}

/** Mock the effective model render with the given injected env keys per service. */
function stubRender(serviceEnv: Record<string, Record<string, string>> | null, stderr = ''): void {
  const rendered = serviceEnv === null
    ? null
    : JSON.stringify({ name: 'proj', services: Object.fromEntries(Object.entries(serviceEnv).map(([s, env]) => [s, { environment: env }])) });
  vi.spyOn(ComposeService, 'getInstance').mockReturnValue({
    renderConfig: vi.fn().mockResolvedValue({ rendered, stderr, code: rendered === null ? 1 : 0, timedOut: false }),
  } as unknown as ComposeService);
}

const itemFor = (inv: EnvInventory, key: string) => inv.items.find(i => i.key === key);

beforeAll(async () => {
  tmpDir = await setupTestDb();
  ({ app } = await import('../index'));
  ({ DatabaseService } = await import('../services/DatabaseService'));
  authCookie = await loginAsTestAdmin(app);
  nodeId = (DatabaseService.getInstance().getDb().prepare('SELECT id FROM nodes WHERE is_default = 1').get() as { id: number }).id;
});

afterAll(() => cleanupTestDb(tmpDir));
afterEach(() => vi.restoreAllMocks());

describe('buildEnvInventory status derivation', () => {
  it('classifies present, unused, interpolation, and injection sources', async () => {
    writeStack('inv1', {
      'compose.yaml': 'services:\n  web:\n    image: nginx:${USED:-x}\n    environment:\n      INLINE_KEY: "1"\n    env_file:\n      - ./svc.env\n',
      '.env': 'USED=1\nUNUSED_VAR=2\n',
      'svc.env': 'FILE_KEY=3\n',
    });
    stubRender({ web: { INLINE_KEY: '1', FILE_KEY: '3' } });
    const inv = await buildEnvInventory(nodeId, 'inv1');
    expect(inv.renderable).toBe(true);
    expect(itemFor(inv, 'USED')).toMatchObject({ status: 'present', usedForInterpolation: true, sources: expect.arrayContaining(['dotenv']) });
    expect(itemFor(inv, 'UNUSED_VAR')).toMatchObject({ status: 'unused' });
    expect(itemFor(inv, 'INLINE_KEY')).toMatchObject({ injectedIntoService: true, sources: expect.arrayContaining(['compose-inline']) });
    const fileKey = itemFor(inv, 'FILE_KEY');
    expect(fileKey?.sources).toContain('env-file');
    expect(fileKey?.sources).not.toContain('compose-inline');
  });

  it('marks a referenced-but-unset variable as missing', async () => {
    writeStack('inv2', { 'compose.yaml': 'services:\n  web:\n    image: nginx:${MISSING}\n' });
    stubRender({ web: {} }, 'The "MISSING" variable is not set. Defaulting to a blank string.');
    const inv = await buildEnvInventory(nodeId, 'inv2');
    expect(itemFor(inv, 'MISSING')).toMatchObject({ status: 'missing', usedForInterpolation: true });
  });

  it('picks up inline array and bare key forms', async () => {
    writeStack('inv3', { 'compose.yaml': 'services:\n  web:\n    image: nginx\n    environment:\n      - ARR_KEY=v\n      - BARE_KEY\n' });
    stubRender({ web: { ARR_KEY: 'v', BARE_KEY: '' } });
    const inv = await buildEnvInventory(nodeId, 'inv3');
    expect(itemFor(inv, 'ARR_KEY')?.sources).toContain('compose-inline');
    expect(itemFor(inv, 'BARE_KEY')?.sources).toContain('compose-inline');
  });

  it('drops an inline key that an override removed from the effective model', async () => {
    writeStack('inv4', { 'compose.yaml': 'services:\n  web:\n    image: nginx\n    environment:\n      KEPT: "1"\n      GONE: "1"\n' });
    stubRender({ web: { KEPT: '1' } }); // GONE not in effective model
    const inv = await buildEnvInventory(nodeId, 'inv4');
    expect(itemFor(inv, 'KEPT')).toBeTruthy();
    expect(itemFor(inv, 'GONE')).toBeUndefined();
  });

  it('does not flag .env doubling as env_file: .env as a duplicate', async () => {
    writeStack('inv5', {
      'compose.yaml': 'services:\n  web:\n    image: nginx:${SHARED}\n    env_file:\n      - .env\n',
      '.env': 'SHARED=1\n',
    });
    stubRender({ web: { SHARED: '1' } });
    const inv = await buildEnvInventory(nodeId, 'inv5');
    const shared = itemFor(inv, 'SHARED');
    expect(shared?.status).not.toBe('duplicate');
    expect(shared).toMatchObject({ usedForInterpolation: true, injectedIntoService: true });
  });

  it('reconciles inline provenance per service: an override-dropped inline key in one service is not inline because another service injects the same name', async () => {
    writeStack('invx', {
      'compose.yaml': 'services:\n  web:\n    image: nginx\n    environment:\n      FOO: "1"\n  db:\n    image: postgres\n    env_file:\n      - ./db.env\n',
      'db.env': 'FOO=2\n',
    });
    // web's effective env dropped FOO (e.g. an override); db injects FOO via env_file.
    stubRender({ web: {}, db: { FOO: '2' } });
    const inv = await buildEnvInventory(nodeId, 'invx');
    const foo = itemFor(inv, 'FOO');
    expect(foo?.sources).toContain('env-file');
    expect(foo?.sources).not.toContain('compose-inline');
    expect(foo?.status).not.toBe('duplicate');
    expect(foo?.injectedIntoService).toBe(true);
  });

  it('reports duplicate when a key is defined inline and in a separate file', async () => {
    writeStack('inv6', {
      'compose.yaml': 'services:\n  web:\n    image: nginx\n    environment:\n      DUP: "1"\n    env_file:\n      - ./other.env\n',
      'other.env': 'DUP=2\n',
    });
    stubRender({ web: { DUP: '1' } });
    const inv = await buildEnvInventory(nodeId, 'inv6');
    expect(itemFor(inv, 'DUP')?.status).toBe('duplicate');
  });

  it('degrades to renderable:false but still lists authored refs when render fails', async () => {
    writeStack('inv7', { 'compose.yaml': 'services:\n  web:\n    image: nginx:${REF}\n' });
    stubRender(null, 'yaml: line 2: mapping values are not allowed');
    const inv = await buildEnvInventory(nodeId, 'inv7');
    expect(inv.renderable).toBe(false);
    expect(itemFor(inv, 'REF')).toBeTruthy();
  });

  it('still marks inline and env_file keys as injected when the model cannot render', async () => {
    writeStack('inv8', {
      'compose.yaml': 'services:\n  web:\n    image: nginx\n    environment:\n      INLINE_X: "1"\n    env_file:\n      - ./svc.env\n',
      'svc.env': 'FILE_X=1\n',
    });
    stubRender(null, 'yaml: line 5: bad mapping');
    const inv = await buildEnvInventory(nodeId, 'inv8');
    expect(inv.renderable).toBe(false);
    expect(itemFor(inv, 'INLINE_X')).toMatchObject({ injectedIntoService: true, sources: expect.arrayContaining(['compose-inline']) });
    expect(itemFor(inv, 'FILE_X')).toMatchObject({ injectedIntoService: true, sources: expect.arrayContaining(['env-file']) });
  });
});

describe('buildEnvInventory secret safety', () => {
  it('never includes an inline environment value, only the key', async () => {
    const value = 'actual-secret-value-zzz';
    writeStack('sec1', { 'compose.yaml': `services:\n  web:\n    image: nginx\n    environment:\n      SECRET: ${value}\n` });
    stubRender({ web: { SECRET: value } });
    const inv = await buildEnvInventory(nodeId, 'sec1');
    expect(itemFor(inv, 'SECRET')).toMatchObject({ likelySecret: true });
    expect(JSON.stringify(inv)).not.toContain(value);
  });

  it('never emits an inventory row for an unreferenced process.env key', async () => {
    process.env.UNRELATED_HOST_SECRET_XYZ = 'leak-me';
    try {
      writeStack('sec2', { 'compose.yaml': 'services:\n  web:\n    image: nginx\n' });
      stubRender({ web: {} });
      const inv = await buildEnvInventory(nodeId, 'sec2');
      expect(itemFor(inv, 'UNRELATED_HOST_SECRET_XYZ')).toBeUndefined();
      expect(JSON.stringify(inv)).not.toContain('UNRELATED_HOST_SECRET_XYZ');
    } finally {
      delete process.env.UNRELATED_HOST_SECRET_XYZ;
    }
  });

  it('marks a shell-resolved, unpersisted referenced var as unpersisted', async () => {
    process.env.SHELL_ONLY_VAR_ABC = 'present-in-shell';
    try {
      writeStack('sec3', { 'compose.yaml': 'services:\n  web:\n    image: nginx:${SHELL_ONLY_VAR_ABC}\n' });
      stubRender({ web: {} }); // resolved (no unset warning) because shell has it
      const inv = await buildEnvInventory(nodeId, 'sec3');
      expect(itemFor(inv, 'SHELL_ONLY_VAR_ABC')).toMatchObject({ status: 'unpersisted', sources: expect.arrayContaining(['process-env']) });
    } finally {
      delete process.env.SHELL_ONLY_VAR_ABC;
    }
  });
});

describe('GET /api/stacks/:stackName/env-inventory', () => {
  it('requires authentication', async () => {
    const res = await request(app).get('/api/stacks/inv1/env-inventory');
    expect(res.status).toBe(401);
  });

  it('returns 404 for an unknown stack', async () => {
    const res = await request(app).get('/api/stacks/does-not-exist/env-inventory').set('Cookie', authCookie);
    expect(res.status).toBe(404);
  });

  it('returns the inventory without leaking a value', async () => {
    const value = 'route-secret-value-qqq';
    writeStack('route1', { 'compose.yaml': `services:\n  web:\n    image: nginx\n    environment:\n      SECRET: ${value}\n` });
    stubRender({ web: { SECRET: value } });
    const res = await request(app).get('/api/stacks/route1/env-inventory').set('Cookie', authCookie);
    expect(res.status).toBe(200);
    expect(res.body.stackName).toBe('route1');
    // The key must appear (so the test fails if the row is dropped, not just if the
    // value happens to be absent), and the value must never appear.
    const secret = (res.body.items as { key: string; likelySecret: boolean }[]).find(i => i.key === 'SECRET');
    expect(secret).toMatchObject({ likelySecret: true });
    expect(JSON.stringify(res.body)).not.toContain(value);
  });
});
