/**
 * Authorization and validation tests for the Federation cordon routes
 * (POST /api/nodes/:id/cordon and /uncordon).
 *
 * These guard the same boundary the NodeCard cordon control renders against, so
 * a UI gate and a route guard cannot silently drift apart. Cordon/uncordon
 * require the node:manage permission on every tier (held by admin and
 * node-admin roles). The guard order is:
 *   rejectApiTokenScope (SCOPE_DENIED) -> requirePermission (PERMISSION_DENIED)
 *   -> invalid-id 400
 *   -> reason 400 (cordon only) -> 404.
 */
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import request from 'supertest';
import type { LicenseTier } from '../services/license-types';
import type { UserRole } from '../services/DatabaseService';
import { setupTestDb, cleanupTestDb, loginAsTestAdmin, TEST_USERNAME } from './helpers/setupTestDb';
import { createTestApiToken } from './helpers/apiTokenTestHelper';

let tmpDir: string;
let app: import('express').Express;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
let LicenseService: typeof import('../services/LicenseService').LicenseService;
let adminCookie: string;
let adminUserId: number;
const roleCookie: Record<string, string> = {};
let counter = 0;

function setLicense(tier: LicenseTier): void {
    vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue(tier);
}

function seedNode(): { id: number; name: string } {
    counter += 1;
    const name = `cordon-authz-node-${counter}`;
    const db = DatabaseService.getInstance().getDb();
    const result = db.prepare(
        `INSERT INTO nodes (name, type, mode, compose_dir, is_default, status, created_at)
         VALUES (?, 'local', 'proxy', '/tmp/compose', 0, 'online', ?)`,
    ).run(name, Date.now());
    return { id: result.lastInsertRowid as number, name };
}

async function seedAndLogin(role: UserRole): Promise<string> {
    const bcrypt = (await import('bcrypt')).default;
    const supertest = (await import('supertest')).default;
    const username = `cordon-${role}`;
    const password = `cordon-${role}-pass`;
    const passwordHash = await bcrypt.hash(password, 1);
    DatabaseService.getInstance().addUser({ username, password_hash: passwordHash, role });
    const res = await supertest(app).post('/api/auth/login').send({ username, password });
    const cookies = res.headers['set-cookie'] as string | string[];
    return Array.isArray(cookies) ? cookies[0] : cookies;
}

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

    vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');

    ({ app } = await import('../index'));
    adminCookie = await loginAsTestAdmin(app);
    for (const role of ['node-admin', 'viewer', 'deployer', 'auditor'] as const) {
        roleCookie[role] = await seedAndLogin(role);
    }
    const adminRow = DatabaseService.getInstance().getDb()
        .prepare('SELECT id FROM users WHERE username = ?')
        .get(TEST_USERNAME) as { id: number } | undefined;
    if (!adminRow) throw new Error('seeded admin user not found');
    adminUserId = adminRow.id;
});

afterAll(() => cleanupTestDb(tmpDir));

beforeEach(() => {
    vi.restoreAllMocks();
    setLicense('paid');
    DatabaseService.getInstance().getDb().prepare('DELETE FROM nodes WHERE is_default = 0').run();
});

describe('POST /api/nodes/:id/cordon authorization', () => {
    it('lets an admin cordon a node and stores the trimmed reason', async () => {
        const node = seedNode();
        const res = await request(app)
            .post(`/api/nodes/${node.id}/cordon`)
            .set('Cookie', adminCookie)
            .send({ reason: '  patching kernel  ' });
        expect(res.status).toBe(200);
        expect(res.body.cordoned).toBe(true);
        expect(res.body.cordoned_reason).toBe('patching kernel');
    });

    it('lets a node-admin cordon a node', async () => {
        const node = seedNode();
        const res = await request(app)
            .post(`/api/nodes/${node.id}/cordon`)
            .set('Cookie', roleCookie['node-admin'])
            .send({});
        expect(res.status).toBe(200);
        expect(res.body.cordoned).toBe(true);
    });

    it.each(['viewer', 'deployer', 'auditor'])(
        'rejects a %s without node:manage with PERMISSION_DENIED',
        async (role) => {
            const node = seedNode();
            const res = await request(app)
                .post(`/api/nodes/${node.id}/cordon`)
                .set('Cookie', roleCookie[role])
                .send({});
            expect(res.status).toBe(403);
            expect(res.body.code).toBe('PERMISSION_DENIED');
        },
    );

    it('lets a Community admin cordon a node', async () => {
        setLicense('community');
        const node = seedNode();
        const res = await request(app)
            .post(`/api/nodes/${node.id}/cordon`)
            .set('Cookie', adminCookie)
            .send({});
        expect(res.status).toBe(200);
        expect(res.body.cordoned).toBe(true);
    });

    it('rejects a full-admin API token with SCOPE_DENIED (API tokens cannot manage nodes)', async () => {
        const node = seedNode();
        const token = createTestApiToken({ db: DatabaseService, scope: 'full-admin', userId: adminUserId });
        const res = await request(app)
            .post(`/api/nodes/${node.id}/cordon`)
            .set('Authorization', `Bearer ${token}`)
            .send({ reason: 'ci' });
        expect(res.status).toBe(403);
        expect(res.body.code).toBe('SCOPE_DENIED');
    });

    it('rejects a reason longer than 256 characters with 400', async () => {
        const node = seedNode();
        const res = await request(app)
            .post(`/api/nodes/${node.id}/cordon`)
            .set('Cookie', adminCookie)
            .send({ reason: 'a'.repeat(257) });
        expect(res.status).toBe(400);
    });

    it('rejects a non-string reason with 400', async () => {
        const node = seedNode();
        const res = await request(app)
            .post(`/api/nodes/${node.id}/cordon`)
            .set('Cookie', adminCookie)
            .send({ reason: 123 });
        expect(res.status).toBe(400);
    });

    it('rejects an invalid node id with 400', async () => {
        const res = await request(app)
            .post('/api/nodes/0/cordon')
            .set('Cookie', adminCookie)
            .send({});
        expect(res.status).toBe(400);
    });

    it('rejects a numeric-prefix node id with 400', async () => {
        const res = await request(app)
            .post('/api/nodes/1abc/cordon')
            .set('Cookie', adminCookie)
            .send({});
        expect(res.status).toBe(400);
    });

    it('returns 404 for a node that does not exist', async () => {
        const res = await request(app)
            .post('/api/nodes/999999/cordon')
            .set('Cookie', adminCookie)
            .send({});
        expect(res.status).toBe(404);
    });

    it('stores null when the reason is whitespace only', async () => {
        const node = seedNode();
        const res = await request(app)
            .post(`/api/nodes/${node.id}/cordon`)
            .set('Cookie', adminCookie)
            .send({ reason: '   ' });
        expect(res.status).toBe(200);
        expect(res.body.cordoned).toBe(true);
        expect(res.body.cordoned_reason).toBeNull();
    });

    it('checks node:manage before route validation (Community viewer gets PERMISSION_DENIED, not PAID_REQUIRED)', async () => {
        setLicense('community');
        const node = seedNode();
        const res = await request(app)
            .post(`/api/nodes/${node.id}/cordon`)
            .set('Cookie', roleCookie['viewer'])
            .send({});
        expect(res.status).toBe(403);
        expect(res.body.code).toBe('PERMISSION_DENIED');
    });
});

describe('POST /api/nodes/:id/uncordon authorization', () => {
    function seedCordonedNode(): { id: number; name: string } {
        const node = seedNode();
        DatabaseService.getInstance().setNodeCordoned(node.id, true, 'pre');
        return node;
    }

    it('lets an admin uncordon a node', async () => {
        const node = seedCordonedNode();
        const res = await request(app)
            .post(`/api/nodes/${node.id}/uncordon`)
            .set('Cookie', adminCookie)
            .send({});
        expect(res.status).toBe(200);
        expect(res.body.cordoned).toBe(false);
        expect(res.body.cordoned_reason).toBeNull();
    });

    it('lets a node-admin uncordon a node', async () => {
        const node = seedCordonedNode();
        const res = await request(app)
            .post(`/api/nodes/${node.id}/uncordon`)
            .set('Cookie', roleCookie['node-admin'])
            .send({});
        expect(res.status).toBe(200);
        expect(res.body.cordoned).toBe(false);
    });

    it.each(['viewer', 'deployer', 'auditor'])(
        'rejects a %s without node:manage with PERMISSION_DENIED',
        async (role) => {
            const node = seedCordonedNode();
            const res = await request(app)
                .post(`/api/nodes/${node.id}/uncordon`)
                .set('Cookie', roleCookie[role])
                .send({});
            expect(res.status).toBe(403);
            expect(res.body.code).toBe('PERMISSION_DENIED');
        },
    );

    it('lets a Community admin uncordon a node', async () => {
        setLicense('community');
        const node = seedCordonedNode();
        const res = await request(app)
            .post(`/api/nodes/${node.id}/uncordon`)
            .set('Cookie', adminCookie)
            .send({});
        expect(res.status).toBe(200);
        expect(res.body.cordoned).toBe(false);
    });

    it('rejects a full-admin API token with SCOPE_DENIED', async () => {
        const node = seedCordonedNode();
        const token = createTestApiToken({ db: DatabaseService, scope: 'full-admin', userId: adminUserId });
        const res = await request(app)
            .post(`/api/nodes/${node.id}/uncordon`)
            .set('Authorization', `Bearer ${token}`)
            .send({});
        expect(res.status).toBe(403);
        expect(res.body.code).toBe('SCOPE_DENIED');
    });

    it('returns 404 for a node that does not exist', async () => {
        const res = await request(app)
            .post('/api/nodes/999999/uncordon')
            .set('Cookie', adminCookie)
            .send({});
        expect(res.status).toBe(404);
    });

    it('rejects an invalid node id with 400', async () => {
        const res = await request(app)
            .post('/api/nodes/0/uncordon')
            .set('Cookie', adminCookie)
            .send({});
        expect(res.status).toBe(400);
    });

    it('rejects a numeric-prefix node id with 400', async () => {
        const res = await request(app)
            .post('/api/nodes/1abc/uncordon')
            .set('Cookie', adminCookie)
            .send({});
        expect(res.status).toBe(400);
    });
});
