/**
 * Tests for API token scope enforcement, blocked endpoints, expiration,
 * revocation, creation validation, limits, and ownership.
 */
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import request from 'supertest';
import crypto from 'crypto';
import bcrypt from 'bcrypt';
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
import { generateApiToken } from '../utils/apiTokenFormat';

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

/** Create an API token directly in the DB and return its raw value. */
function createTestApiToken(
  scope: 'read-only' | 'deploy-only' | 'full-admin',
  expiresAt: number | null = null,
  userId?: number,
): string {
  const rawToken = generateApiToken();
  const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex');
  const db = DatabaseService.getInstance();
  const resolvedUserId = userId ?? db.getUserByUsername('testadmin')!.id;
  db.addApiToken({
    token_hash: tokenHash,
    name: `test-${scope}-${Date.now()}-${Math.random().toString(36).slice(2)}`,
    scope,
    user_id: resolvedUserId,
    created_at: Date.now(),
    expires_at: expiresAt,
  });
  return rawToken;
}

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

  // Suite runs at Community tier to prove token routes work without a paid license.
  const { LicenseService } = await import('../services/LicenseService');
  vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');

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

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

// --- Scope Enforcement Middleware ---

describe('enforceApiTokenScope', () => {
  it('read-only token allows GET requests', async () => {
    const token = createTestApiToken('read-only');
    const res = await request(app)
      .get('/api/stacks')
      .set('Authorization', `Bearer ${token}`);
    // Should not be 403 SCOPE_DENIED (may be 200 or other non-scope error)
    expect(res.body.code).not.toBe('SCOPE_DENIED');
  });

  it('read-only token blocks POST requests', async () => {
    const token = createTestApiToken('read-only');
    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('SCOPE_DENIED');
  });

  it('deploy-only token allows GET requests', async () => {
    const token = createTestApiToken('deploy-only');
    const res = await request(app)
      .get('/api/stacks')
      .set('Authorization', `Bearer ${token}`);
    expect(res.body.code).not.toBe('SCOPE_DENIED');
  });

  it('deploy-only token allows POST to deploy patterns', async () => {
    const token = createTestApiToken('deploy-only');
    const res = await request(app)
      .post('/api/stacks/test-stack/deploy')
      .set('Authorization', `Bearer ${token}`);
    // Should not be SCOPE_DENIED (may fail for other reasons like stack not found)
    expect(res.body.code).not.toBe('SCOPE_DENIED');
  });

  it('deploy-only token blocks POST to non-deploy endpoints', async () => {
    const token = createTestApiToken('deploy-only');
    const res = await request(app)
      .post('/api/stacks')
      .set('Authorization', `Bearer ${token}`)
      .send({ name: 'test', content: 'version: "3"' });
    expect(res.status).toBe(403);
    expect(res.body.code).toBe('SCOPE_DENIED');
  });

  it('full-admin token passes scope enforcement on stack routes', async () => {
    const token = createTestApiToken('full-admin');
    const res = await request(app)
      .get('/api/stacks')
      .set('Authorization', `Bearer ${token}`);
    expect(res.body.code).not.toBe('SCOPE_DENIED');
  });
});

// --- Blocked Endpoints (human-session-only) ---

describe('API token blocked endpoints', () => {
  let fullAdminToken: string;

  beforeAll(() => {
    fullAdminToken = createTestApiToken('full-admin');
  });

  const blockedEndpoints: Array<{ method: 'get' | 'post' | 'put' | 'delete'; path: string; body?: Record<string, unknown> }> = [
    // Password management
    { method: 'put', path: '/api/auth/password', body: { oldPassword: 'x', newPassword: 'y' } },
    // Node token generation
    { method: 'post', path: '/api/auth/generate-node-token' },
    // User management
    { method: 'get', path: '/api/users' },
    { method: 'post', path: '/api/users', body: { username: 'test', password: 'test123', role: 'viewer' } },
    { method: 'put', path: '/api/users/1', body: { role: 'viewer' } },
    { method: 'delete', path: '/api/users/1' },
    // SSO configuration
    { method: 'get', path: '/api/sso/config' },
    { method: 'get', path: '/api/sso/config/ldap' },
    { method: 'put', path: '/api/sso/config/ldap', body: { enabled: true } },
    { method: 'delete', path: '/api/sso/config/ldap' },
    { method: 'post', path: '/api/sso/config/ldap/test' },
    // SSO role sync configuration
    { method: 'get', path: '/api/sso/config/role-sync' },
    { method: 'put', path: '/api/sso/config/role-sync', body: { enabled: true } },
    // Node management
    { method: 'post', path: '/api/nodes', body: { name: 'test', type: 'local' } },
    { method: 'put', path: '/api/nodes/1', body: { name: 'updated' } },
    { method: 'delete', path: '/api/nodes/1' },
    // License management
    { method: 'post', path: '/api/license/activate', body: { license_key: 'test' } },
    { method: 'post', path: '/api/license/deactivate' },
    // Console token
    { method: 'post', path: '/api/system/console-token' },
    // Token self-management
    { method: 'get', path: '/api/api-tokens' },
    { method: 'post', path: '/api/api-tokens', body: { name: 'test', scope: 'read-only' } },
    { method: 'delete', path: '/api/api-tokens/1' },
    // Registry management
    { method: 'get', path: '/api/registries' },
    { method: 'post', path: '/api/registries', body: { name: 'test', type: 'dockerhub', url: 'https://index.docker.io' } },
    { method: 'put', path: '/api/registries/1', body: { name: 'updated' } },
    { method: 'delete', path: '/api/registries/1' },
    { method: 'post', path: '/api/registries/1/test' },
  ];

  for (const { method, path, body } of blockedEndpoints) {
    it(`${method.toUpperCase()} ${path} returns 403 SCOPE_DENIED`, async () => {
      let req = request(app)[method](path).set('Authorization', `Bearer ${fullAdminToken}`);
      if (body) req = req.send(body);
      const res = await req;
      expect(res.status).toBe(403);
      expect(res.body.code).toBe('SCOPE_DENIED');
    });
  }
});

// --- Token Expiration ---

describe('API token expiration', () => {
  it('expired token returns 401', async () => {
    const token = createTestApiToken('full-admin', Date.now() - 1000); // expired 1s ago
    const res = await request(app)
      .get('/api/stacks')
      .set('Authorization', `Bearer ${token}`);
    expect(res.status).toBe(401);
  });

  it('non-expired token is accepted', async () => {
    const token = createTestApiToken('full-admin', Date.now() + 86400000); // expires in 1 day
    const res = await request(app)
      .get('/api/stacks')
      .set('Authorization', `Bearer ${token}`);
    expect(res.status).not.toBe(401);
  });
});

// --- Token Revocation ---

describe('API token revocation', () => {
  it('revoked token returns 401', async () => {
    const rawToken = createTestApiToken('full-admin');
    const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex');

    // Revoke by finding the token ID
    const db = DatabaseService.getInstance();
    const apiToken = db.getApiTokenByHash(tokenHash)!;
    db.revokeApiToken(apiToken.id);

    const res = await request(app)
      .get('/api/stacks')
      .set('Authorization', `Bearer ${rawToken}`);
    expect(res.status).toBe(401);
  });
});

// --- Creation Validation ---

describe('API token creation validation', () => {
  it('rejects missing name', async () => {
    const res = await request(app)
      .post('/api/api-tokens')
      .set('Cookie', authCookie)
      .send({ scope: 'read-only' });
    expect(res.status).toBe(400);
    expect(res.body.error).toMatch(/name/i);
  });

  it('rejects empty/whitespace name', async () => {
    const res = await request(app)
      .post('/api/api-tokens')
      .set('Cookie', authCookie)
      .send({ name: '   ', scope: 'read-only' });
    expect(res.status).toBe(400);
    expect(res.body.error).toMatch(/name/i);
  });

  it('rejects name longer than 100 characters', async () => {
    const res = await request(app)
      .post('/api/api-tokens')
      .set('Cookie', authCookie)
      .send({ name: 'a'.repeat(101), scope: 'read-only' });
    expect(res.status).toBe(400);
    expect(res.body.error).toMatch(/100/);
  });

  it('rejects invalid scope', async () => {
    const res = await request(app)
      .post('/api/api-tokens')
      .set('Cookie', authCookie)
      .send({ name: 'test-invalid-scope', scope: 'superadmin' });
    expect(res.status).toBe(400);
    expect(res.body.error).toMatch(/scope/i);
  });

  it('rejects invalid expiry value', async () => {
    const res = await request(app)
      .post('/api/api-tokens')
      .set('Cookie', authCookie)
      .send({ name: 'test-invalid-expiry', scope: 'read-only', expires_in: 7 });
    expect(res.status).toBe(400);
    expect(res.body.error).toMatch(/expires_in/i);
  });

  it('accepts null expiry (no expiration)', async () => {
    const res = await request(app)
      .post('/api/api-tokens')
      .set('Cookie', authCookie)
      .send({ name: `test-null-expiry-${Date.now()}`, scope: 'read-only', expires_in: null });
    expect(res.status).toBe(201);
    expect(res.body.token).toBeDefined();
  });

  it('accepts valid expiry values', async () => {
    for (const days of [30, 60, 90, 365]) {
      const res = await request(app)
        .post('/api/api-tokens')
        .set('Cookie', authCookie)
        .send({ name: `test-expiry-${days}-${Date.now()}`, scope: 'read-only', expires_in: days });
      expect(res.status).toBe(201);
    }
  });
});

// --- Tier Accessibility (Community) ---

describe('API token tier accessibility', () => {
  it('Community tier mints all three scopes via POST /api/api-tokens', async () => {
    const db = DatabaseService.getInstance();
    const user = db.getUserByUsername('testadmin')!;
    // Reset to avoid hitting the 25-token cap from prior suites.
    for (const t of db.getApiTokensByUser(user.id)) {
      if (!t.revoked_at) db.revokeApiToken(t.id);
    }

    for (const scope of ['read-only', 'deploy-only', 'full-admin'] as const) {
      const res = await request(app)
        .post('/api/api-tokens')
        .set('Cookie', authCookie)
        .send({ name: `community-${scope}-${Date.now()}`, scope });
      expect(res.status).toBe(201);
      expect(res.body.token).toMatch(/^sen_sk_/);
    }
  });
});

// --- Token Count Limit ---

describe('API token count limit', () => {
  it('rejects creation when user has 25 active tokens', async () => {
    const db = DatabaseService.getInstance();
    const user = db.getUserByUsername('testadmin')!;

    // Seed up to 25 active tokens using the shared helper
    const existing = db.getActiveApiTokenCountByUser(user.id);
    const toCreate = 25 - existing;
    for (let i = 0; i < toCreate; i++) {
      createTestApiToken('read-only', null, user.id);
    }

    expect(db.getActiveApiTokenCountByUser(user.id)).toBeGreaterThanOrEqual(25);

    const res = await request(app)
      .post('/api/api-tokens')
      .set('Cookie', authCookie)
      .send({ name: `overflow-${Date.now()}`, scope: 'read-only' });
    expect(res.status).toBe(400);
    expect(res.body.error).toMatch(/maximum/i);
  });
});

// --- Token Name Uniqueness ---

describe('API token name uniqueness', () => {
  it('rejects duplicate token name for same user', async () => {
    // Revoke existing tokens to make room (limit test may have filled to 25)
    const db = DatabaseService.getInstance();
    const user = db.getUserByUsername('testadmin')!;
    const existing = db.getApiTokensByUser(user.id);
    for (const t of existing) {
      if (!t.revoked_at) db.revokeApiToken(t.id);
    }

    const uniqueName = `dup-test-${Date.now()}`;

    // Create the first token
    const res1 = await request(app)
      .post('/api/api-tokens')
      .set('Cookie', authCookie)
      .send({ name: uniqueName, scope: 'read-only' });
    expect(res1.status).toBe(201);

    // Attempt to create another with the same name
    const res2 = await request(app)
      .post('/api/api-tokens')
      .set('Cookie', authCookie)
      .send({ name: uniqueName, scope: 'read-only' });
    expect(res2.status).toBe(409);
    expect(res2.body.error).toMatch(/already exists/i);
  });
});

// --- last_used_at Tracking ---

describe('API token last_used_at tracking', () => {
  it('updates last_used_at on API token usage', async () => {
    const rawToken = createTestApiToken('read-only');
    const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex');
    const db = DatabaseService.getInstance();

    // Verify last_used_at is null initially
    const before = db.getApiTokenByHash(tokenHash)!;
    expect(before.last_used_at).toBeNull();

    // Make a request with the token
    await request(app)
      .get('/api/stacks')
      .set('Authorization', `Bearer ${rawToken}`);

    // Verify last_used_at is now set
    const after = db.getApiTokenByHash(tokenHash)!;
    expect(after.last_used_at).toBeTypeOf('number');
    expect(after.last_used_at!).toBeGreaterThan(0);
  });
});

// --- Ownership Constraint ---

describe('API token ownership', () => {
  it('returns 403 when deleting another user\'s token', async () => {
    const db = DatabaseService.getInstance();

    // Create a second user
    const otherHash = await bcrypt.hash('otherpass123', 1);
    db.addUser({ username: 'otheruser', password_hash: otherHash, role: 'admin' });
    const otherUser = db.getUserByUsername('otheruser')!;

    // Create a token for the other user using the shared helper
    const rawToken = createTestApiToken('read-only', null, otherUser.id);
    const hash = crypto.createHash('sha256').update(rawToken).digest('hex');
    const apiToken = db.getApiTokenByHash(hash)!;
    const tokenId = apiToken.id;

    // Try to delete it as testadmin
    const res = await request(app)
      .delete(`/api/api-tokens/${tokenId}`)
      .set('Cookie', authCookie);
    expect(res.status).toBe(403);
    expect(res.body.error).toMatch(/your own/i);
  });
});

// --- Delete Edge Cases ---

describe('API token delete edge cases', () => {
  it('returns 404 for nonexistent token ID', async () => {
    const res = await request(app)
      .delete('/api/api-tokens/99999')
      .set('Cookie', authCookie);
    expect(res.status).toBe(404);
  });

  it('returns 400 for non-numeric token ID', async () => {
    const res = await request(app)
      .delete('/api/api-tokens/abc')
      .set('Cookie', authCookie);
    expect(res.status).toBe(400);
    expect(res.body.error).toMatch(/invalid/i);
  });
});
