import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import Database from 'better-sqlite3';
import { runMigrations } from '../db/migrate.js';
import { createGrantsRepo, type CreateGrantInput } from './grants-repo.js';

const validKey = 'a'.repeat(64);

function bootstrapDb(): Database.Database {
  process.env.MCP_ENCRYPTION_KEY = validKey;
  const db = new Database(':memory:');
  // Don't enable foreign_keys: we want to insert grants without creating
  // a real connection row first (grants-repo doesn't enforce FK semantically).
  db.exec(`CREATE TABLE users (id TEXT PRIMARY KEY);`);
  db.exec(`CREATE TABLE jobs (id TEXT PRIMARY KEY, wait_reason TEXT);`);
  db.exec(`CREATE TABLE local_tasks (id INTEGER PRIMARY KEY AUTOINCREMENT);`);
  runMigrations(db);
  return db;
}

function baseInput(overrides: Partial<CreateGrantInput> = {}): CreateGrantInput {
  return {
    connectionId: 'conn-1',
    subjectType: 'user',
    subjectId: 'bob',
    pieceName: 'devops',
    appliesToAllPieces: false,
    grantedByUserId: 'admin',
    reason: 'on-call escalation',
    ...overrides,
  };
}

describe('ssh/grants-repo', () => {
  let db: Database.Database;
  beforeEach(() => {
    db = bootstrapDb();
  });
  afterEach(() => {
    db.close();
    delete process.env.MCP_ENCRYPTION_KEY;
  });

  describe('create validation', () => {
    it('rejects reason shorter than 8 characters', () => {
      const repo = createGrantsRepo(db);
      expect(() => repo.create(baseInput({ reason: 'short' }))).toThrow(/at least 8/);
    });

    it('rejects appliesToAllPieces=true with non-null pieceName', () => {
      const repo = createGrantsRepo(db);
      expect(() =>
        repo.create(baseInput({ appliesToAllPieces: true, pieceName: 'devops' })),
      ).toThrow(/pieceName must be null/);
    });

    it('rejects appliesToAllPieces=false with null pieceName', () => {
      const repo = createGrantsRepo(db);
      expect(() =>
        repo.create(baseInput({ appliesToAllPieces: false, pieceName: null })),
      ).toThrow(/pieceName is required/);
    });

    it('rejects invalid subjectType', () => {
      const repo = createGrantsRepo(db);
      expect(() =>
        repo.create(baseInput({ subjectType: 'group' as 'user' })),
      ).toThrow(/invalid subjectType/);
    });

    it('creates and round-trips through getById', () => {
      const repo = createGrantsRepo(db);
      const g = repo.create(baseInput());
      expect(g.id).toBeTruthy();
      expect(g.pieceName).toBe('devops');
      const r = repo.getById(g.id);
      expect(r?.subjectId).toBe('bob');
    });

    it('accepts applies_to_all with null piece', () => {
      const repo = createGrantsRepo(db);
      const g = repo.create(
        baseInput({ appliesToAllPieces: true, pieceName: null, reason: 'admin override' }),
      );
      expect(g.appliesToAllPieces).toBe(true);
      expect(g.pieceName).toBeNull();
    });
  });

  describe('listForConnection', () => {
    it('returns grants newest-first for the given connection only', () => {
      const repo = createGrantsRepo(db);
      repo.create(baseInput({ connectionId: 'conn-A' }));
      repo.create(baseInput({ connectionId: 'conn-A', subjectId: 'carol' }));
      repo.create(baseInput({ connectionId: 'conn-B' }));
      const a = repo.listForConnection('conn-A');
      expect(a.length).toBe(2);
      expect(repo.listForConnection('conn-B').length).toBe(1);
    });
  });

  describe('delete', () => {
    it('removes the grant and returns true', () => {
      const repo = createGrantsRepo(db);
      const g = repo.create(baseInput());
      expect(repo.delete(g.id)).toBe(true);
      expect(repo.getById(g.id)).toBeNull();
      expect(repo.delete(g.id)).toBe(false);
    });
  });

  describe('findActiveGrant', () => {
    it('matches user subject + piece-specific grant', () => {
      const repo = createGrantsRepo(db);
      repo.create(baseInput({ subjectId: 'bob', pieceName: 'devops' }));
      const m = repo.findActiveGrant({
        connectionId: 'conn-1',
        userId: 'bob',
        orgIds: [],
        pieceName: 'devops',
      });
      expect(m).not.toBeNull();
      expect(m?.subjectType).toBe('user');
    });

    it('returns null when piece does not match and no all-pieces grant', () => {
      const repo = createGrantsRepo(db);
      repo.create(baseInput({ subjectId: 'bob', pieceName: 'devops' }));
      const m = repo.findActiveGrant({
        connectionId: 'conn-1',
        userId: 'bob',
        orgIds: [],
        pieceName: 'reports',
      });
      expect(m).toBeNull();
    });

    it('matches org subject when subjectId is in orgIds', () => {
      const repo = createGrantsRepo(db);
      repo.create(
        baseInput({ subjectType: 'org', subjectId: 'org-acme', pieceName: 'devops' }),
      );
      const m = repo.findActiveGrant({
        connectionId: 'conn-1',
        userId: 'mallory',
        orgIds: ['org-other', 'org-acme'],
        pieceName: 'devops',
      });
      expect(m).not.toBeNull();
      expect(m?.subjectType).toBe('org');
    });

    it('matches applies_to_all_pieces=1', () => {
      const repo = createGrantsRepo(db);
      repo.create(
        baseInput({
          subjectId: 'bob',
          appliesToAllPieces: true,
          pieceName: null,
          reason: 'broad admin grant',
        }),
      );
      const m = repo.findActiveGrant({
        connectionId: 'conn-1',
        userId: 'bob',
        orgIds: [],
        pieceName: 'anything-goes',
      });
      expect(m?.appliesToAllPieces).toBe(true);
    });

    it('prefers piece-specific over applies_to_all when both exist', () => {
      const repo = createGrantsRepo(db);
      repo.create(
        baseInput({
          subjectId: 'bob',
          appliesToAllPieces: true,
          pieceName: null,
          reason: 'broad admin grant',
        }),
      );
      repo.create(baseInput({ subjectId: 'bob', pieceName: 'devops' }));
      const m = repo.findActiveGrant({
        connectionId: 'conn-1',
        userId: 'bob',
        orgIds: [],
        pieceName: 'devops',
      });
      expect(m?.appliesToAllPieces).toBe(false);
    });

    it('prefers user-subject over org-subject when both apply', () => {
      const repo = createGrantsRepo(db);
      repo.create(
        baseInput({ subjectType: 'org', subjectId: 'org-acme', pieceName: 'devops' }),
      );
      repo.create(baseInput({ subjectType: 'user', subjectId: 'bob', pieceName: 'devops' }));
      const m = repo.findActiveGrant({
        connectionId: 'conn-1',
        userId: 'bob',
        orgIds: ['org-acme'],
        pieceName: 'devops',
      });
      expect(m?.subjectType).toBe('user');
    });

    it('respects expiry: expired grants are not returned', () => {
      const repo = createGrantsRepo(db);
      repo.create(
        baseInput({
          subjectId: 'bob',
          pieceName: 'devops',
          expiresAt: '2026-01-01T00:00:00.000Z',
        }),
      );
      const m = repo.findActiveGrant({
        connectionId: 'conn-1',
        userId: 'bob',
        orgIds: [],
        pieceName: 'devops',
        now: '2026-06-01T00:00:00.000Z',
      });
      expect(m).toBeNull();
    });

    it('includes future-expiring grants', () => {
      const repo = createGrantsRepo(db);
      repo.create(
        baseInput({
          subjectId: 'bob',
          pieceName: 'devops',
          expiresAt: '2027-01-01T00:00:00.000Z',
        }),
      );
      const m = repo.findActiveGrant({
        connectionId: 'conn-1',
        userId: 'bob',
        orgIds: [],
        pieceName: 'devops',
        now: '2026-06-01T00:00:00.000Z',
      });
      expect(m).not.toBeNull();
    });

    it('handles empty orgIds without sql injection of trailing comma', () => {
      const repo = createGrantsRepo(db);
      repo.create(baseInput({ subjectId: 'bob', pieceName: 'devops' }));
      const m = repo.findActiveGrant({
        connectionId: 'conn-1',
        userId: 'bob',
        orgIds: [],
        pieceName: 'devops',
      });
      expect(m).not.toBeNull();
    });
  });
});
