/**
 * Tests for User Management, RBAC permissions, token versioning (session invalidation),
 * scoped role assignments, password management, and last-admin protection.
 */
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import request from 'supertest';
import jwt from 'jsonwebtoken';
import bcrypt from 'bcrypt';
import crypto from 'crypto';
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_PASSWORD, TEST_JWT_SECRET } from './helpers/setupTestDb';
import { generateApiToken } from '../utils/apiTokenFormat';
import { assertStackExistsOnNode } from '../helpers/assertStackExistsOnNode';

vi.mock('../helpers/assertStackExistsOnNode', () => ({
  assertStackExistsOnNode: vi.fn(async () => ({ ok: true as const })),
}));

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

function defaultNodeId(): number {
  const node = DatabaseService.getInstance().getDefaultNode();
  if (!node) throw new Error('test default node missing');
  return node.id;
}

/** Sign a JWT for a given user with optional token_version (tv). */
function authToken(username: string, role: string = 'admin', tv?: number): string {
  const payload: Record<string, unknown> = { username, role };
  if (tv !== undefined) payload.tv = tv;
  return jwt.sign(payload, TEST_JWT_SECRET, { expiresIn: '1m' });
}

/** Sign admin token using the current DB token_version (reads live state). */
function adminToken(): string {
  const db = DatabaseService.getInstance();
  const user = db.getUserByUsername(TEST_USERNAME)!;
  return authToken(TEST_USERNAME, 'admin', user.token_version);
}

beforeAll(async () => {
  tmpDir = await setupTestDb();
  ({ DatabaseService } = await import('../services/DatabaseService'));

  // Mock LicenseService to return the paid tier for RBAC tests
  const { LicenseService } = await import('../services/LicenseService');
  vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');

  ({ app } = await import('../index'));
});

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

// ---- User CRUD Endpoints ----

describe('POST /api/users', () => {
  it('creates a user with valid data (201)', async () => {
    const res = await request(app)
      .post('/api/users')
      .set('Authorization', `Bearer ${adminToken()}`)
      .send({ username: 'newuser', password: 'password123', role: 'viewer' });
    expect(res.status).toBe(201);
    expect(res.body.username).toBe('newuser');
    expect(res.body.role).toBe('viewer');
    expect(res.body.id).toBeDefined();
  });

  it('rejects missing fields (400)', async () => {
    const res = await request(app)
      .post('/api/users')
      .set('Authorization', `Bearer ${adminToken()}`)
      .send({ username: 'incomplete' });
    expect(res.status).toBe(400);
  });

  it('rejects invalid username format (400)', async () => {
    const res = await request(app)
      .post('/api/users')
      .set('Authorization', `Bearer ${adminToken()}`)
      .send({ username: 'a b', password: 'password123', role: 'viewer' });
    expect(res.status).toBe(400);
  });

  it('rejects short password (400)', async () => {
    const res = await request(app)
      .post('/api/users')
      .set('Authorization', `Bearer ${adminToken()}`)
      .send({ username: 'shortpw', password: '123', role: 'viewer' });
    expect(res.status).toBe(400);
  });

  it('rejects invalid role (400)', async () => {
    const res = await request(app)
      .post('/api/users')
      .set('Authorization', `Bearer ${adminToken()}`)
      .send({ username: 'badrole', password: 'password123', role: 'superadmin' });
    expect(res.status).toBe(400);
  });

  it('rejects duplicate username (409)', async () => {
    const res = await request(app)
      .post('/api/users')
      .set('Authorization', `Bearer ${adminToken()}`)
      .send({ username: 'newuser', password: 'password123', role: 'viewer' });
    expect(res.status).toBe(409);
  });

  it('requires admin role (403 for viewers)', async () => {
    const db = DatabaseService.getInstance();
    const viewer = db.getUserByUsername('newuser')!;
    const viewerToken = authToken('newuser', 'viewer', viewer.token_version);
    const res = await request(app)
      .post('/api/users')
      .set('Authorization', `Bearer ${viewerToken}`)
      .send({ username: 'test999', password: 'password123', role: 'viewer' });
    expect(res.status).toBe(403);
    expect(res.body.code).toBe('PERMISSION_DENIED');
  });

  it('blocks API tokens (403 SCOPE_DENIED)', async () => {
    const rawToken = generateApiToken();
    const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex');
    const db = DatabaseService.getInstance();
    const user = db.getUserByUsername(TEST_USERNAME);
    db.addApiToken({ token_hash: tokenHash, name: `test-crud-${Date.now()}`, scope: 'full-admin', user_id: user!.id, created_at: Date.now(), expires_at: null });

    const res = await request(app)
      .post('/api/users')
      .set('Authorization', `Bearer ${rawToken}`)
      .send({ username: 'fromtoken', password: 'password123', role: 'viewer' });
    expect(res.status).toBe(403);
    expect(res.body.code).toBe('SCOPE_DENIED');
  });

  it('creates a deployer user (201)', async () => {
    const res = await request(app)
      .post('/api/users')
      .set('Authorization', `Bearer ${adminToken()}`)
      .send({ username: 'role-deployer', password: 'password123', role: 'deployer' });
    expect(res.status).toBe(201);
    expect(res.body.role).toBe('deployer');
    DatabaseService.getInstance().deleteUser(res.body.id);
  });

  it('creates deployer, node-admin, and auditor users on the Community tier (201)', async () => {
    const { LicenseService } = await import('../services/LicenseService');
    const svc = LicenseService.getInstance();
    vi.spyOn(svc, 'getTier').mockReturnValue('community');
    const createdIds: number[] = [];
    try {
      for (const role of ['deployer', 'node-admin', 'auditor'] as const) {
        const res = await request(app)
          .post('/api/users')
          .set('Authorization', `Bearer ${adminToken()}`)
          .send({ username: `community-${role}`, password: 'password123', role });
        expect(res.status).toBe(201);
        expect(res.body.role).toBe(role);
        expect(res.body.code).not.toBe('PAID_REQUIRED');
        createdIds.push(res.body.id);
      }
    } finally {
      for (const id of createdIds) {
        DatabaseService.getInstance().deleteUser(id);
      }
      vi.spyOn(svc, 'getTier').mockReturnValue('paid');
    }
  });
});

describe('PUT /api/users/:id', () => {
  let viewerId: number;

  beforeAll(() => {
    const db = DatabaseService.getInstance();
    const user = db.getUserByUsername('newuser');
    viewerId = user!.id;
  });

  it('updates username', async () => {
    const res = await request(app)
      .put(`/api/users/${viewerId}`)
      .set('Authorization', `Bearer ${adminToken()}`)
      .send({ username: 'renameduser' });
    expect(res.status).toBe(200);
    expect(res.body.success).toBe(true);
    // Rename back for other tests
    await request(app)
      .put(`/api/users/${viewerId}`)
      .set('Authorization', `Bearer ${adminToken()}`)
      .send({ username: 'newuser' });
  });

  it('updates role', async () => {
    const res = await request(app)
      .put(`/api/users/${viewerId}`)
      .set('Authorization', `Bearer ${adminToken()}`)
      .send({ role: 'deployer' });
    expect(res.status).toBe(200);
    // Revert
    await request(app)
      .put(`/api/users/${viewerId}`)
      .set('Authorization', `Bearer ${adminToken()}`)
      .send({ role: 'viewer' });
  });

  it('prevents self-role-change (400)', async () => {
    const db = DatabaseService.getInstance();
    const adminUser = db.getUserByUsername(TEST_USERNAME)!;
    const res = await request(app)
      .put(`/api/users/${adminUser.id}`)
      .set('Authorization', `Bearer ${adminToken()}`)
      .send({ role: 'viewer' });
    expect(res.status).toBe(400);
    expect(res.body.error).toContain('Cannot change your own role');
  });

  it('prevents demoting last admin (400)', async () => {
    // testadmin is the only admin
    const db = DatabaseService.getInstance();
    const adminUser = db.getUserByUsername(TEST_USERNAME)!;
    const res = await request(app)
      .put(`/api/users/${adminUser.id}`)
      .set('Authorization', `Bearer ${adminToken()}`)
      .send({ role: 'viewer' });
    expect(res.status).toBe(400);
  });

  it('rejects password on SSO user (400)', async () => {
    // Create an SSO user directly in DB
    const db = DatabaseService.getInstance();
    const ssoId = db.addUser({ username: 'sso-user', password_hash: '$sso$fake', role: 'viewer', auth_provider: 'oidc_google', provider_id: 'google-123', email: 'sso@test.com' });

    const res = await request(app)
      .put(`/api/users/${ssoId}`)
      .set('Authorization', `Bearer ${adminToken()}`)
      .send({ password: 'newpassword123' });
    expect(res.status).toBe(400);
    expect(res.body.error).toContain('SSO-provisioned');

    // Cleanup
    db.deleteUser(ssoId);
  });
});

describe('DELETE /api/users/:id', () => {
  it('deletes a user (200)', async () => {
    // Create a disposable user
    const db = DatabaseService.getInstance();
    const hash = await bcrypt.hash('password123', 1);
    const id = db.addUser({ username: 'disposable', password_hash: hash, role: 'viewer' });

    const res = await request(app)
      .delete(`/api/users/${id}`)
      .set('Authorization', `Bearer ${adminToken()}`);
    expect(res.status).toBe(200);
    expect(res.body.success).toBe(true);
  });

  it('prevents self-deletion (400)', async () => {
    const db = DatabaseService.getInstance();
    const admin = db.getUserByUsername(TEST_USERNAME)!;
    const res = await request(app)
      .delete(`/api/users/${admin.id}`)
      .set('Authorization', `Bearer ${adminToken()}`);
    expect(res.status).toBe(400);
    expect(res.body.error).toContain('Cannot delete your own account');
  });

  it('prevents deleting last admin (400)', async () => {
    // Only one admin (testadmin), can't delete
    const db = DatabaseService.getInstance();
    const admin = db.getUserByUsername(TEST_USERNAME)!;
    const res = await request(app)
      .delete(`/api/users/${admin.id}`)
      .set('Authorization', `Bearer ${adminToken()}`);
    expect(res.status).toBe(400);
  });
});

// ---- Token Version (Session Invalidation) ----

describe('Token version (session invalidation)', () => {
  it('rejects a deleted user\'s JWT (401)', async () => {
    const db = DatabaseService.getInstance();
    const hash = await bcrypt.hash('password123', 1);
    const id = db.addUser({ username: 'willdelete', password_hash: hash, role: 'viewer' });
    const user = db.getUserById(id)!;
    const token = authToken('willdelete', 'viewer', user.token_version);

    // Token works before deletion
    const before = await request(app).get('/api/stacks').set('Authorization', `Bearer ${token}`);
    expect(before.status).not.toBe(401);

    // Delete the user
    db.deleteUser(id);

    // Token should be rejected after deletion
    const after = await request(app).get('/api/stacks').set('Authorization', `Bearer ${token}`);
    expect(after.status).toBe(401);
    expect(after.body.error).toContain('no longer exists');
  });

  it('rejects token after password change bumps tv', async () => {
    const db = DatabaseService.getInstance();
    const hash = await bcrypt.hash('oldpass123', 1);
    const id = db.addUser({ username: 'pwchange', password_hash: hash, role: 'viewer' });
    const user = db.getUserById(id)!;
    const oldToken = authToken('pwchange', 'viewer', user.token_version);

    // Token works before bump
    const before = await request(app).get('/api/stacks').set('Authorization', `Bearer ${oldToken}`);
    expect(before.status).not.toBe(401);

    // Bump token version (simulates password change)
    db.bumpTokenVersion(id);

    // Old token should be rejected
    const after = await request(app).get('/api/stacks').set('Authorization', `Bearer ${oldToken}`);
    expect(after.status).toBe(401);
    expect(after.body.error).toContain('Session invalidated');

    // Cleanup
    db.deleteUser(id);
  });

  it('admin password reset bumps token_version', async () => {
    const db = DatabaseService.getInstance();
    const hash = await bcrypt.hash('password123', 1);
    const id = db.addUser({ username: 'resetme', password_hash: hash, role: 'viewer' });
    const userBefore = db.getUserById(id)!;

    // Admin resets password via PUT /api/users/:id
    await request(app)
      .put(`/api/users/${id}`)
      .set('Authorization', `Bearer ${adminToken()}`)
      .send({ password: 'newpassword123' });

    const userAfter = db.getUserById(id)!;
    expect(userAfter.token_version).toBe(userBefore.token_version + 1);

    // Cleanup
    db.deleteUser(id);
  });

  it('pre-migration token (no tv claim) still works', async () => {
    // Sign without tv claim (simulates pre-migration token)
    const token = jwt.sign({ username: TEST_USERNAME, role: 'admin' }, TEST_JWT_SECRET, { expiresIn: '1m' });
    const res = await request(app).get('/api/stacks').set('Authorization', `Bearer ${token}`);
    // Should not be 401 (backward compat)
    expect(res.status).not.toBe(401);
  });

  it('uses DB role so role changes take effect immediately', async () => {
    const db = DatabaseService.getInstance();
    const hash = await bcrypt.hash('password123', 1);
    const id = db.addUser({ username: 'rolecheck', password_hash: hash, role: 'admin' });
    const user = db.getUserById(id)!;

    // Admin changes their role to viewer in DB directly (simulating a race)
    db.updateUser(id, { role: 'viewer' });
    // Don't bump tv, so the old token still passes version check
    // But the middleware should use DB role (viewer), not JWT role (admin)

    // The token was signed with role: admin, but DB says viewer.
    // Auth check endpoint should reflect the DB role.
    const token = authToken('rolecheck', 'admin', user.token_version);
    const res = await request(app).get('/api/auth/check').set('Authorization', `Bearer ${token}`);
    expect(res.status).toBe(200);
    expect(res.body.user.role).toBe('viewer');

    // Cleanup
    db.deleteUser(id);
  });
});

// ---- Scoped Role Assignments ----

describe('Scoped Role Assignments', () => {
  let targetUserId: number;

  beforeAll(async () => {
    const db = DatabaseService.getInstance();
    const hash = await bcrypt.hash('password123', 1);
    targetUserId = db.addUser({ username: 'scopeuser', password_hash: hash, role: 'viewer' });
  });

  afterAll(() => {
    const db = DatabaseService.getInstance();
    db.deleteUser(targetUserId);
  });

  it('GET /api/users/:id/roles returns assignments', async () => {
    const res = await request(app)
      .get(`/api/users/${targetUserId}/roles`)
      .set('Authorization', `Bearer ${adminToken()}`);
    expect(res.status).toBe(200);
    expect(Array.isArray(res.body)).toBe(true);
  });

  it('POST /api/users/:id/roles creates assignment (201)', async () => {
    const nodeId = defaultNodeId();
    const res = await request(app)
      .post(`/api/users/${targetUserId}/roles`)
      .set('Authorization', `Bearer ${adminToken()}`)
      .send({ role: 'deployer', resource_type: 'stack', resource_id: 'test-stack', node_id: nodeId });
    expect(res.status).toBe(201);
    expect(res.body.role).toBe('deployer');
    expect(res.body.resource_type).toBe('stack');
    expect(res.body.node_id).toBe(nodeId);
  });

  it('POST /api/users/:id/roles rejects stack assignment without node_id (400)', async () => {
    const res = await request(app)
      .post(`/api/users/${targetUserId}/roles`)
      .set('Authorization', `Bearer ${adminToken()}`)
      .send({ role: 'deployer', resource_type: 'stack', resource_id: 'no-node-stack' });
    expect(res.status).toBe(400);
    expect(res.body.error).toMatch(/node_id/i);
  });

  it('POST /api/users/:id/roles rejects when stack does not exist on node (400)', async () => {
    vi.mocked(assertStackExistsOnNode).mockResolvedValueOnce({
      ok: false,
      error: 'Stack not found on node',
    });
    const res = await request(app)
      .post(`/api/users/${targetUserId}/roles`)
      .set('Authorization', `Bearer ${adminToken()}`)
      .send({
        role: 'deployer',
        resource_type: 'stack',
        resource_id: 'missing-stack',
        node_id: defaultNodeId(),
      });
    expect(res.status).toBe(400);
    expect(res.body.error).toMatch(/not found/i);
  });

  it('POST /api/users/:id/roles rejects node_id qualifier on node assignments (400)', async () => {
    const res = await request(app)
      .post(`/api/users/${targetUserId}/roles`)
      .set('Authorization', `Bearer ${adminToken()}`)
      .send({
        role: 'deployer',
        resource_type: 'node',
        resource_id: String(defaultNodeId()),
        node_id: defaultNodeId(),
      });
    expect(res.status).toBe(400);
    expect(res.body.error).toMatch(/must not be set/i);
  });

  it('POST /api/users/:id/roles rejects nonexistent numeric node resource_id (400)', async () => {
    const res = await request(app)
      .post(`/api/users/${targetUserId}/roles`)
      .set('Authorization', `Bearer ${adminToken()}`)
      .send({
        role: 'deployer',
        resource_type: 'node',
        resource_id: '999999',
      });
    expect(res.status).toBe(400);
    expect(res.body.error).toMatch(/Node not found/i);
  });

  it('POST /api/users/:id/roles creates node assignment without node_id (201)', async () => {
    const res = await request(app)
      .post(`/api/users/${targetUserId}/roles`)
      .set('Authorization', `Bearer ${adminToken()}`)
      .send({
        role: 'deployer',
        resource_type: 'node',
        resource_id: String(defaultNodeId()),
      });
    expect(res.status).toBe(201);
    expect(res.body.resource_type).toBe('node');
    expect(res.body.node_id).toBeNull();
  });

  it('POST /api/users/:id/roles rejects a noncanonical node resource_id (400)', async () => {
    const res = await request(app)
      .post(`/api/users/${targetUserId}/roles`)
      .set('Authorization', `Bearer ${adminToken()}`)
      .send({
        role: 'deployer',
        resource_type: 'node',
        resource_id: `0${defaultNodeId()}`,
      });
    expect(res.status).toBe(400);
    expect(res.body.error).toMatch(/canonical/i);
  });

  it('POST /api/users/:id/roles rejects duplicate (409)', async () => {
    const res = await request(app)
      .post(`/api/users/${targetUserId}/roles`)
      .set('Authorization', `Bearer ${adminToken()}`)
      .send({ role: 'deployer', resource_type: 'stack', resource_id: 'test-stack', node_id: defaultNodeId() });
    expect(res.status).toBe(409);
  });

  it('DELETE /api/users/:id/roles/:assignId removes assignment', async () => {
    const db = DatabaseService.getInstance();
    const assignments = db.getAllRoleAssignments(targetUserId);
    const assignment = assignments[0];

    const res = await request(app)
      .delete(`/api/users/${targetUserId}/roles/${assignment.id}`)
      .set('Authorization', `Bearer ${adminToken()}`);
    expect(res.status).toBe(200);
    expect(res.body.success).toBe(true);
  });

  it('POST /api/users/:id/roles succeeds on the Community tier (201)', async () => {
    const { LicenseService } = await import('../services/LicenseService');
    const svc = LicenseService.getInstance();
    const nodeId = defaultNodeId();
    vi.spyOn(svc, 'getTier').mockReturnValue('community');
    try {
      const res = await request(app)
        .post(`/api/users/${targetUserId}/roles`)
        .set('Authorization', `Bearer ${adminToken()}`)
        .send({ role: 'deployer', resource_type: 'stack', resource_id: 'community-stack', node_id: nodeId });
      expect(res.status).toBe(201);
      expect(res.body.role).toBe('deployer');
      expect(res.body.resource_id).toBe('community-stack');
      expect(res.body.code).not.toBe('PAID_REQUIRED');

      const listed = await request(app)
        .get(`/api/users/${targetUserId}/roles`)
        .set('Authorization', `Bearer ${adminToken()}`);
      expect(listed.status).toBe(200);
      expect(listed.body.some((a: { id: number }) => a.id === res.body.id)).toBe(true);

      const deleted = await request(app)
        .delete(`/api/users/${targetUserId}/roles/${res.body.id}`)
        .set('Authorization', `Bearer ${adminToken()}`);
      expect(deleted.status).toBe(200);
    } finally {
      vi.spyOn(svc, 'getTier').mockReturnValue('paid');
    }
  });
});

// ---- GET /api/permissions/me ----

describe('GET /api/permissions/me', () => {
  it('returns correct structure for admin', async () => {
    const res = await request(app)
      .get('/api/permissions/me')
      .set('Authorization', `Bearer ${adminToken()}`);
    expect(res.status).toBe(200);
    expect(res.body.globalRole).toBe('admin');
    expect(Array.isArray(res.body.globalPermissions)).toBe(true);
    expect(res.body.globalPermissions).toContain('stack:read');
    expect(res.body.globalPermissions).toContain('system:users');
  });

  it('returns 401 when not authenticated', async () => {
    const res = await request(app).get('/api/permissions/me');
    expect(res.status).toBe(401);
  });

  it('includes scoped permissions when assignments exist', async () => {
    const db = DatabaseService.getInstance();
    const hash = await bcrypt.hash('password123', 1);
    const id = db.addUser({ username: 'permcheck', password_hash: hash, role: 'viewer' });
    const nodeId = defaultNodeId();
    db.addRoleAssignment({ user_id: id, role: 'deployer', resource_type: 'stack', resource_id: 'my-stack', node_id: nodeId });

    const user = db.getUserById(id)!;
    const token = authToken('permcheck', 'viewer', user.token_version);
    const res = await request(app)
      .get('/api/permissions/me')
      .set('Authorization', `Bearer ${token}`);
    expect(res.status).toBe(200);
    expect(res.body.globalRole).toBe('viewer');
    expect(res.body.scopedPermissions[`stack:${nodeId}:my-stack`]).toBeDefined();

    // Cleanup
    db.deleteRoleAssignmentsByUser(id);
    db.deleteUser(id);
  });

  it('includes scoped permissions on the Community tier when assignments exist', async () => {
    const { LicenseService } = await import('../services/LicenseService');
    const db = DatabaseService.getInstance();
    const svc = LicenseService.getInstance();
    const hash = await bcrypt.hash('password123', 1);
    const nodeId = defaultNodeId();
    const id = db.addUser({ username: 'permcheck-community', password_hash: hash, role: 'viewer' });
    db.addRoleAssignment({
      user_id: id,
      role: 'deployer',
      resource_type: 'stack',
      resource_id: 'my-stack',
      node_id: nodeId,
    });
    const user = db.getUserById(id)!;
    const token = authToken('permcheck-community', 'viewer', user.token_version);

    vi.spyOn(svc, 'getTier').mockReturnValue('community');
    const res = await request(app)
      .get('/api/permissions/me')
      .set('Authorization', `Bearer ${token}`);
    expect(res.status).toBe(200);
    expect(res.body.scopedPermissions[`stack:${nodeId}:my-stack`]).toBeDefined();
    expect(res.body.scopedPermissions[`stack:${nodeId}:my-stack`]).toContain('stack:deploy');

    // Cleanup
    vi.spyOn(svc, 'getTier').mockReturnValue('paid');
    db.deleteRoleAssignmentsByUser(id);
    db.deleteUser(id);
  });
});

// ---- PUT /api/auth/password ----

describe('PUT /api/auth/password', () => {
  it('changes password with valid old password', async () => {
    const res = await request(app)
      .put('/api/auth/password')
      .set('Authorization', `Bearer ${adminToken()}`)
      .send({ oldPassword: TEST_PASSWORD, newPassword: 'newpassword123' });
    expect(res.status).toBe(200);
    expect(res.body.success).toBe(true);

    // Revert password for other tests (must use fresh token since tv was bumped)
    const revert = await request(app)
      .put('/api/auth/password')
      .set('Authorization', `Bearer ${adminToken()}`)
      .send({ oldPassword: 'newpassword123', newPassword: TEST_PASSWORD });
    expect(revert.status).toBe(200);
  });

  it('rejects wrong old password (401)', async () => {
    const res = await request(app)
      .put('/api/auth/password')
      .set('Authorization', `Bearer ${adminToken()}`)
      .send({ oldPassword: 'wrongpassword', newPassword: 'newpassword123' });
    expect(res.status).toBe(401);
  });

  it('rejects short new password (400)', async () => {
    const res = await request(app)
      .put('/api/auth/password')
      .set('Authorization', `Bearer ${adminToken()}`)
      .send({ oldPassword: TEST_PASSWORD, newPassword: '123' });
    expect(res.status).toBe(400);
  });

  it('rejects missing fields (400)', async () => {
    const res = await request(app)
      .put('/api/auth/password')
      .set('Authorization', `Bearer ${adminToken()}`)
      .send({});
    expect(res.status).toBe(400);
  });

  it('blocks API tokens (403)', async () => {
    const rawToken = generateApiToken();
    const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex');
    const db = DatabaseService.getInstance();
    const user = db.getUserByUsername(TEST_USERNAME);
    db.addApiToken({ token_hash: tokenHash, name: `test-pwchange-${Date.now()}`, scope: 'full-admin', user_id: user!.id, created_at: Date.now(), expires_at: null });

    const res = await request(app)
      .put('/api/auth/password')
      .set('Authorization', `Bearer ${rawToken}`)
      .send({ oldPassword: TEST_PASSWORD, newPassword: 'newpassword123' });
    expect(res.status).toBe(403);
    expect(res.body.code).toBe('SCOPE_DENIED');
  });
});

// ---- User creation is uncapped on every tier ----

describe('User creation seat caps', () => {
  it('creates additional admins and viewers without a seat cap', async () => {
    const adminRes = await request(app)
      .post('/api/users')
      .set('Authorization', `Bearer ${adminToken()}`)
      .send({ username: 'extraadmin', password: 'password123', role: 'admin' });
    expect(adminRes.status).toBe(201);

    const viewerRes = await request(app)
      .post('/api/users')
      .set('Authorization', `Bearer ${adminToken()}`)
      .send({ username: 'extraviewer', password: 'password123', role: 'viewer' });
    expect(viewerRes.status).toBe(201);

    const db = DatabaseService.getInstance();
    db.deleteUser(db.getUserByUsername('extraadmin')!.id);
    db.deleteUser(db.getUserByUsername('extraviewer')!.id);
  });

  it('creates additional users on the Community tier (no seat cap)', async () => {
    const { LicenseService } = await import('../services/LicenseService');
    const svc = LicenseService.getInstance();
    vi.spyOn(svc, 'getTier').mockReturnValue('community');
    try {
      const res = await request(app)
        .post('/api/users')
        .set('Authorization', `Bearer ${adminToken()}`)
        .send({ username: 'communityviewer', password: 'password123', role: 'viewer' });
      expect(res.status).toBe(201);
      DatabaseService.getInstance().deleteUser(DatabaseService.getInstance().getUserByUsername('communityviewer')!.id);
    } finally {
      vi.spyOn(svc, 'getTier').mockReturnValue('paid');
    }
  });
});

// ---- Last-Admin Protection ----

describe('Last-admin protection', () => {
  it('cannot demote the only admin', async () => {
    const db = DatabaseService.getInstance();
    const admin = db.getUserByUsername(TEST_USERNAME)!;
    const res = await request(app)
      .put(`/api/users/${admin.id}`)
      .set('Authorization', `Bearer ${adminToken()}`)
      .send({ role: 'viewer' });
    // Should fail with 400 (self-role-change) or last-admin check
    expect(res.status).toBe(400);
  });

  it('cannot delete the only admin', async () => {
    const db = DatabaseService.getInstance();
    const admin = db.getUserByUsername(TEST_USERNAME)!;
    const res = await request(app)
      .delete(`/api/users/${admin.id}`)
      .set('Authorization', `Bearer ${adminToken()}`);
    expect(res.status).toBe(400);
  });

  it('can demote admin when another admin exists', async () => {
    const db = DatabaseService.getInstance();
    const hash = await bcrypt.hash('password123', 1);
    const secondAdminId = db.addUser({ username: 'secondadmin', password_hash: hash, role: 'admin' });

    // Now demote second admin (testadmin does the demotion)
    const res = await request(app)
      .put(`/api/users/${secondAdminId}`)
      .set('Authorization', `Bearer ${adminToken()}`)
      .send({ role: 'viewer' });
    expect(res.status).toBe(200);

    // Cleanup
    db.deleteUser(secondAdminId);
  });
});

// ---- Role Promotion (uncapped) ----

describe('Role promotion', () => {
  it('promotes a viewer to admin without a seat cap', async () => {
    const db = DatabaseService.getInstance();
    const hash = await bcrypt.hash('password123', 1);
    const viewerId = db.addUser({ username: 'promoteok', password_hash: hash, role: 'viewer' });
    const res = await request(app)
      .put(`/api/users/${viewerId}`)
      .set('Authorization', `Bearer ${adminToken()}`)
      .send({ role: 'admin' });
    expect(res.status).toBe(200);
    expect(db.getUser(viewerId)!.role).toBe('admin');
    db.deleteUser(viewerId);
  });
});

// ---- Atomic Last-Admin Guard (TOCTOU protection) ----

describe('Atomic last-admin guard', () => {
  // These lock the guard contract: the admin-count re-check and the mutation run
  // in one transaction, so a refusal writes nothing (no partial state) and the
  // count is unchanged. That re-check inside the transaction is what closes the
  // TOCTOU window a route-level pre-check left open.
  it('updateUserIfNotLastAdmin refuses to demote the sole admin and applies otherwise', async () => {
    const db = DatabaseService.getInstance();
    expect(db.getAdminCount()).toBe(1);
    const sole = db.getUserByUsername(TEST_USERNAME)!;
    expect(db.updateUserIfNotLastAdmin(sole.id, { role: 'viewer' })).toBe(false);
    // Refusal is side-effect free: role intact and count unchanged.
    expect(db.getUser(sole.id)!.role).toBe('admin');
    expect(db.getAdminCount()).toBe(1);

    const hash = await bcrypt.hash('password123', 1);
    const extra = db.addUser({ username: 'raceadmin', password_hash: hash, role: 'admin' });
    expect(db.updateUserIfNotLastAdmin(extra, { role: 'viewer' })).toBe(true);
    expect(db.getUser(extra)!.role).toBe('viewer');
    expect(db.getAdminCount()).toBe(1);
    db.deleteUser(extra);
  });

  it('deleteUserIfNotLastAdmin refuses to delete the sole admin and applies otherwise', async () => {
    const db = DatabaseService.getInstance();
    expect(db.getAdminCount()).toBe(1);
    const sole = db.getUserByUsername(TEST_USERNAME)!;
    expect(db.deleteUserIfNotLastAdmin(sole.id)).toBe(false);
    // Refusal is side-effect free: row intact and count unchanged.
    expect(db.getUser(sole.id)).toBeTruthy();
    expect(db.getAdminCount()).toBe(1);

    const hash = await bcrypt.hash('password123', 1);
    const extra = db.addUser({ username: 'raceadmin2', password_hash: hash, role: 'admin' });
    expect(db.deleteUserIfNotLastAdmin(extra)).toBe(true);
    expect(db.getAdminCount()).toBe(1);
  });
});

// ---- Orphaned Role Assignment Cleanup ----
// Proxied remote stack DELETE clears hub grants only on 2xx in
// remoteNodeProxy (deleteRoleAssignmentsByStack). Non-2xx preserves rows.
// Orchestrated proxyRes coverage lives in proxy-scoped-stack-evidence.test.ts;
// these cases lock the DB helper isolation that the proxy calls.

describe('Orphaned role assignment cleanup', () => {
  it('deleting a node removes its node and stack role assignments', async () => {
    const db = DatabaseService.getInstance();
    const nodeId = db.addNode({
      name: 'test-cleanup-node', type: 'remote', api_url: 'http://test:1852',
      api_token: '', compose_dir: '/tmp', is_default: false,
    });
    const hash = await bcrypt.hash('password123', 1);
    const userId = db.addUser({ username: 'nodeorphan', password_hash: hash, role: 'viewer' });
    db.addRoleAssignment({
      user_id: userId, role: 'deployer', resource_type: 'node', resource_id: String(nodeId),
    });
    db.addRoleAssignment({
      user_id: userId, role: 'deployer', resource_type: 'stack',
      resource_id: 'on-doomed', node_id: nodeId,
    });

    expect(db.getAllRoleAssignments(userId)).toHaveLength(2);

    db.deleteNode(nodeId);

    expect(db.getAllRoleAssignments(userId)).toHaveLength(0);
    db.deleteUser(userId);
  });

  it('deleteRoleAssignmentsByStack clears only that node+stack tuple', async () => {
    const db = DatabaseService.getInstance();
    const hash = await bcrypt.hash('password123', 1);
    const userId = db.addUser({ username: 'tupleorphan', password_hash: hash, role: 'viewer' });
    const nodeA = db.addNode({
      name: 'tuple-cleanup-node-a', type: 'remote', api_url: 'http://test-a:1852',
      api_token: '', compose_dir: '/tmp', is_default: false,
    });
    const nodeB = db.addNode({
      name: 'tuple-cleanup-node-b', type: 'remote', api_url: 'http://test-b:1852',
      api_token: '', compose_dir: '/tmp', is_default: false,
    });
    db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: 'shared-name', node_id: nodeA });
    db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: 'shared-name', node_id: nodeB });
    db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: 'keep-stack', node_id: nodeA });
    db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'node', resource_id: String(nodeA) });

    db.deleteRoleAssignmentsByStack(nodeA, 'shared-name');

    const after = db.getAllRoleAssignments(userId);
    expect(after.some((a) => a.resource_type === 'stack' && a.resource_id === 'shared-name' && a.node_id === nodeA)).toBe(false);
    expect(after.some((a) => a.resource_type === 'stack' && a.resource_id === 'shared-name' && a.node_id === nodeB)).toBe(true);
    expect(after.some((a) => a.resource_type === 'stack' && a.resource_id === 'keep-stack' && a.node_id === nodeA)).toBe(true);
    expect(after.some((a) => a.resource_type === 'node' && a.resource_id === String(nodeA))).toBe(true);

    db.deleteUser(userId);
    db.deleteNode(nodeA);
    db.deleteNode(nodeB);
  });
});

// ---- Role-Based Permission Checks (via API) ----

describe('ROLE_PERMISSIONS enforcement via API', () => {
  it('viewer is blocked from deploying (403)', async () => {
    const db = DatabaseService.getInstance();
    const viewerUser = db.getUserByUsername('newuser');
    if (!viewerUser) return; // Created in earlier test

    const token = authToken('newuser', 'viewer', viewerUser.token_version);
    const res = await request(app)
      .post('/api/stacks/test-stack/deploy')
      .set('Authorization', `Bearer ${token}`);
    expect(res.status).toBe(403);
    expect(res.body.code).toBe('PERMISSION_DENIED');
  });

  it('viewer can read stacks', async () => {
    const db = DatabaseService.getInstance();
    const viewerUser = db.getUserByUsername('newuser');
    if (!viewerUser) return;

    const token = authToken('newuser', 'viewer', viewerUser.token_version);
    const res = await request(app)
      .get('/api/stacks')
      .set('Authorization', `Bearer ${token}`);
    // Should not be 403 (may be 200 or 500 depending on Docker state)
    expect(res.status).not.toBe(403);
  });

  it('viewer is blocked from system settings (403)', async () => {
    const db = DatabaseService.getInstance();
    const viewerUser = db.getUserByUsername('newuser');
    if (!viewerUser) return;

    const token = authToken('newuser', 'viewer', viewerUser.token_version);
    const res = await request(app)
      .get('/api/users')
      .set('Authorization', `Bearer ${token}`);
    expect(res.status).toBe(403);
    expect(res.body.code).toBe('PERMISSION_DENIED');
  });
});
