/**
 * Route-level tests for the update guard endpoints: the readiness GETs on the
 * stacks router and the fleet snapshot coverage lookup. UpdateGuardService and
 * FileSystemService are mocked; the focus is auth, validation, route
 * placement, and response shape.
 */
import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from 'vitest';
import request from 'supertest';
import bcrypt from 'bcrypt';
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';

const {
  mockComputeUpdateReadiness,
  mockComputeRollbackReadiness,
  MockSingleServiceUpdateReadinessError,
} = vi.hoisted(() => {
  class MockSingleServiceUpdateReadinessError extends Error {}
  return {
    mockComputeUpdateReadiness: vi.fn(),
    mockComputeRollbackReadiness: vi.fn(),
    MockSingleServiceUpdateReadinessError,
  };
});

vi.mock('../services/UpdateGuardService', () => ({
  UpdateGuardService: {
    getInstance: () => ({
      computeUpdateReadiness: mockComputeUpdateReadiness,
      computeRollbackReadiness: mockComputeRollbackReadiness,
    }),
  },
  SingleServiceUpdateReadinessError: MockSingleServiceUpdateReadinessError,
}));

vi.mock('../services/FileSystemService', () => ({
  FileSystemService: {
    getInstance: () => ({
      getStacks: vi.fn().mockResolvedValue([]),
      getBaseDir: () => '/tmp/compose',
      hasComposeFile: vi.fn().mockResolvedValue(true),
    }),
  },
}));

let tmpDir: string;
let app: import('express').Express;
let authCookie: string;
let viewerCookie: string;

beforeAll(async () => {
  tmpDir = await setupTestDb();
  ({ app } = await import('../index'));
  authCookie = await loginAsTestAdmin(app);

  const { DatabaseService } = await import('../services/DatabaseService');
  const viewerHash = await bcrypt.hash('viewerpass', 1);
  DatabaseService.getInstance().addUser({ username: 'guard-viewer', password_hash: viewerHash, role: 'viewer' });
  const viewerRes = await request(app).post('/api/auth/login').send({ username: 'guard-viewer', password: 'viewerpass' });
  const cookies = viewerRes.headers['set-cookie'] as string | string[];
  viewerCookie = Array.isArray(cookies) ? cookies[0] : cookies;
});

afterAll(() => {
  vi.restoreAllMocks();
  cleanupTestDb(tmpDir);
});

beforeEach(() => {
  mockComputeUpdateReadiness.mockReset();
  mockComputeRollbackReadiness.mockReset();
});

describe('GET /api/stacks/:stackName/update-readiness', () => {
  it('requires authentication', async () => {
    const res = await request(app).get('/api/stacks/web/update-readiness');
    expect(res.status).toBe(401);
    expect(mockComputeUpdateReadiness).not.toHaveBeenCalled();
  });

  it('returns the computed report', async () => {
    const report = { stack: 'web', computedAt: 1, verdict: 'ready', signals: [] };
    mockComputeUpdateReadiness.mockResolvedValue(report);
    const res = await request(app).get('/api/stacks/web/update-readiness').set('Cookie', authCookie);
    expect(res.status).toBe(200);
    expect(res.body).toEqual(report);
  });

  it('returns a clean 500 when the computation fails', async () => {
    mockComputeUpdateReadiness.mockRejectedValue(new Error('docker exploded'));
    const res = await request(app).get('/api/stacks/web/update-readiness').set('Cookie', authCookie);
    expect(res.status).toBe(500);
    expect(res.body).toEqual({ error: 'Failed to compute update readiness' });
  });

  it('forwards the service query param to computeUpdateReadiness', async () => {
    const report = { stack: 'web', computedAt: 1, verdict: 'ready', signals: [], serviceName: 'api', advisories: [] };
    mockComputeUpdateReadiness.mockResolvedValue(report);
    const res = await request(app).get('/api/stacks/web/update-readiness?service=api').set('Cookie', authCookie);
    expect(res.status).toBe(200);
    expect(res.body).toEqual(report);
    expect(mockComputeUpdateReadiness).toHaveBeenCalledWith(expect.any(Number), 'web', 'api');
  });

  it('omits the service argument when no service query param is given', async () => {
    mockComputeUpdateReadiness.mockResolvedValue({ stack: 'web', computedAt: 1, verdict: 'ready', signals: [] });
    await request(app).get('/api/stacks/web/update-readiness').set('Cookie', authCookie);
    expect(mockComputeUpdateReadiness).toHaveBeenCalledWith(expect.any(Number), 'web', undefined);
  });

  it('returns 400 when the service query targets a single-service stack', async () => {
    mockComputeUpdateReadiness.mockRejectedValue(new MockSingleServiceUpdateReadinessError('single service'));
    const res = await request(app).get('/api/stacks/web/update-readiness?service=api').set('Cookie', authCookie);
    expect(res.status).toBe(400);
    expect(res.body).toMatchObject({ code: 'service_update_single_service' });
  });
});

describe('GET /api/stacks/:stackName/rollback-readiness', () => {
  it('requires authentication', async () => {
    const res = await request(app).get('/api/stacks/web/rollback-readiness');
    expect(res.status).toBe(401);
    expect(mockComputeRollbackReadiness).not.toHaveBeenCalled();
  });

  it('returns the computed report', async () => {
    const report = { stack: 'web', computedAt: 1, overall: 'partial', items: [] };
    mockComputeRollbackReadiness.mockResolvedValue(report);
    const res = await request(app).get('/api/stacks/web/rollback-readiness').set('Cookie', authCookie);
    expect(res.status).toBe(200);
    expect(res.body).toEqual(report);
  });

  it('returns a clean 500 when the computation fails', async () => {
    mockComputeRollbackReadiness.mockRejectedValue(new Error('boom'));
    const res = await request(app).get('/api/stacks/web/rollback-readiness').set('Cookie', authCookie);
    expect(res.status).toBe(500);
    expect(res.body).toEqual({ error: 'Failed to compute rollback readiness' });
  });
});

describe('GET /api/stacks/:stackName/health-gate', () => {
  const seedRun = async (id: string, status: 'observing' | 'passed' | 'failed' | 'unknown', startedAt: number, reason: string | null = null) => {
    const { DatabaseService } = await import('../services/DatabaseService');
    const db = DatabaseService.getInstance();
    // The route resolves req.nodeId to the seeded default node, so the rows
    // must carry that id, not a literal.
    const defaultNodeId = db.getNodes().find(n => n.is_default)!.id;
    db.insertHealthGateRun({
      id, node_id: defaultNodeId, stack_name: 'web', trigger_action: 'update', status, reason,
      window_seconds: 90, containers_json: '[]', started_at: startedAt, ended_at: status === 'observing' ? null : startedAt + 1000, created_by: 'tester',
      target_scope: 'stack', service_name: null, failure_source: null,
    });
  };

  it('requires authentication', async () => {
    const res = await request(app).get('/api/stacks/web/health-gate');
    expect(res.status).toBe(401);
  });

  it('returns the never-run sentinel before any run exists', async () => {
    const res = await request(app).get('/api/stacks/web/health-gate').set('Cookie', authCookie);
    expect(res.status).toBe(200);
    expect(res.body).toMatchObject({ stack: 'web', id: null, status: 'never-run' });
  });

  it('returns the latest run without a gateId and a specific (superseded) run with one', async () => {
    await seedRun('gate-old', 'unknown', 1_000, 'superseded by a newer update');
    await seedRun('gate-new', 'passed', 2_000);

    const latest = await request(app).get('/api/stacks/web/health-gate').set('Cookie', authCookie);
    expect(latest.status).toBe(200);
    expect(latest.body).toMatchObject({ id: 'gate-new', status: 'passed' });

    const byId = await request(app).get('/api/stacks/web/health-gate?gateId=gate-old').set('Cookie', authCookie);
    expect(byId.status).toBe(200);
    expect(byId.body).toMatchObject({ id: 'gate-old', status: 'unknown', reason: 'superseded by a newer update' });
  });

  it('returns never-run for an unknown gateId', async () => {
    const res = await request(app).get('/api/stacks/web/health-gate?gateId=no-such-id').set('Cookie', authCookie);
    expect(res.status).toBe(200);
    expect(res.body).toMatchObject({ status: 'never-run' });
  });
});

describe('GET /api/fleet/snapshots/coverage', () => {
  it('requires authentication', async () => {
    const res = await request(app).get('/api/fleet/snapshots/coverage?nodeId=0&stackName=web');
    expect(res.status).toBe(401);
  });

  it('hits the coverage handler, not the /snapshots/:id route', async () => {
    const res = await request(app)
      .get('/api/fleet/snapshots/coverage?nodeId=0&stackName=web')
      .set('Cookie', authCookie);
    // The /:id handler would have rejected "coverage" as a bad snapshot ID;
    // the coverage handler returns the latestAt shape instead.
    expect(res.status).toBe(200);
    expect(res.body).toEqual({ latestAt: null });
  });

  it('scopes coverage to the requested node and stack', async () => {
    const { DatabaseService } = await import('../services/DatabaseService');
    const db = DatabaseService.getInstance();
    const matching = db.createSnapshot('covers web on node 0', 'tester', 1, 1, '[]');
    db.insertSnapshotFiles(matching, [
      { nodeId: 0, nodeName: 'local', stackName: 'web', filename: 'compose.yaml', content: 'services: {}' },
    ]);
    const decoy = db.createSnapshot('covers other things', 'tester', 2, 2, '[]');
    db.insertSnapshotFiles(decoy, [
      { nodeId: 1, nodeName: 'remote', stackName: 'web', filename: 'compose.yaml', content: 'services: {}' },
      { nodeId: 0, nodeName: 'local', stackName: 'other', filename: 'compose.yaml', content: 'services: {}' },
    ]);
    const matchingCreatedAt = db.getSnapshots().find(s => s.id === matching)!.created_at;

    const res = await request(app)
      .get('/api/fleet/snapshots/coverage?nodeId=0&stackName=web')
      .set('Cookie', authCookie);
    expect(res.status).toBe(200);
    expect(res.body).toEqual({ latestAt: matchingCreatedAt });
  });

  it('rejects non-admin users', async () => {
    const res = await request(app)
      .get('/api/fleet/snapshots/coverage?nodeId=0&stackName=web')
      .set('Cookie', viewerCookie);
    expect(res.status).toBe(403);
  });

  it('validates nodeId and stackName', async () => {
    const missingNode = await request(app)
      .get('/api/fleet/snapshots/coverage?stackName=web')
      .set('Cookie', authCookie);
    expect(missingNode.status).toBe(400);

    const badNode = await request(app)
      .get('/api/fleet/snapshots/coverage?nodeId=-2&stackName=web')
      .set('Cookie', authCookie);
    expect(badNode.status).toBe(400);

    // A trailing-garbage nodeId must be rejected, not coerced by parseInt.
    const garbageNode = await request(app)
      .get('/api/fleet/snapshots/coverage?nodeId=1abc&stackName=web')
      .set('Cookie', authCookie);
    expect(garbageNode.status).toBe(400);

    const badStack = await request(app)
      .get('/api/fleet/snapshots/coverage?nodeId=0&stackName=..%2Fetc')
      .set('Cookie', authCookie);
    expect(badStack.status).toBe(400);
  });
});
