/**
 * Unit tests for GrantsService — federation grants CRUD + status transitions (FED-M2-06).
 *
 * Coverage:
 *  - createGrant: validates scope via parseFederationScope
 *  - createGrant: inserts with status 'pending'
 *  - getGrant: returns grant when found
 *  - getGrant: throws NotFoundException when not found
 *  - listGrants: no filters returns all grants
 *  - listGrants: filters by peerId
 *  - listGrants: filters by subjectUserId
 *  - listGrants: filters by status
 *  - listGrants: multiple filters combined
 *  - activateGrant: pending → active works
 *  - activateGrant: non-pending throws ConflictException
 *  - revokeGrant: active → revoked works, sets revokedAt
 *  - revokeGrant: non-active throws ConflictException
 *  - expireGrant: active → expired works
 *  - expireGrant: non-active throws ConflictException
 */

import 'reflect-metadata';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ConflictException, NotFoundException } from '@nestjs/common';
import type { Db } from '@mosaicstack/db';
import { GrantsService } from '../grants.service.js';
import { FederationScopeError } from '../scope-schema.js';

// ---------------------------------------------------------------------------
// Minimal valid federation scope for testing
// ---------------------------------------------------------------------------
const VALID_SCOPE = {
  resources: ['tasks'] as const,
  excluded_resources: [],
  max_rows_per_query: 100,
};

const PEER_ID = 'a1111111-1111-1111-1111-111111111111';
const USER_ID = 'u2222222-2222-2222-2222-222222222222';
const GRANT_ID = 'g3333333-3333-3333-3333-333333333333';

// ---------------------------------------------------------------------------
// Build a mock DB that mimics chained Drizzle query builder calls
// ---------------------------------------------------------------------------

function makeMockGrant(overrides: Partial<Record<string, unknown>> = {}) {
  return {
    id: GRANT_ID,
    peerId: PEER_ID,
    subjectUserId: USER_ID,
    scope: VALID_SCOPE,
    status: 'pending',
    expiresAt: null,
    createdAt: new Date('2026-01-01T00:00:00Z'),
    revokedAt: null,
    revokedReason: null,
    ...overrides,
  };
}

function makeDb(
  overrides: {
    insertReturning?: unknown[];
    selectRows?: unknown[];
    updateReturning?: unknown[];
  } = {},
) {
  const insertReturning = overrides.insertReturning ?? [makeMockGrant()];
  const selectRows = overrides.selectRows ?? [makeMockGrant()];
  const updateReturning = overrides.updateReturning ?? [makeMockGrant({ status: 'active' })];

  // Drizzle returns a chainable builder; we need to mock the full chain.
  const returningInsert = vi.fn().mockResolvedValue(insertReturning);
  const valuesInsert = vi.fn().mockReturnValue({ returning: returningInsert });
  const insertMock = vi.fn().mockReturnValue({ values: valuesInsert });

  // select().from().where().limit()
  const limitSelect = vi.fn().mockResolvedValue(selectRows);
  const whereSelect = vi.fn().mockReturnValue({ limit: limitSelect });
  // from returns something that is both thenable (for full-table select) and has .where()
  const fromSelect = vi.fn().mockReturnValue({
    where: whereSelect,
    limit: limitSelect,
    // Make it thenable for listGrants with no filters (await db.select().from(federationGrants))
    then: (resolve: (v: unknown) => unknown) => resolve(selectRows),
  });
  const selectMock = vi.fn().mockReturnValue({ from: fromSelect });

  const returningUpdate = vi.fn().mockResolvedValue(updateReturning);
  const whereUpdate = vi.fn().mockReturnValue({ returning: returningUpdate });
  const setMock = vi.fn().mockReturnValue({ where: whereUpdate });
  const updateMock = vi.fn().mockReturnValue({ set: setMock });

  return {
    insert: insertMock,
    select: selectMock,
    update: updateMock,
    // Expose internals for assertions
    _mocks: {
      insertReturning,
      valuesInsert,
      insertMock,
      limitSelect,
      whereSelect,
      fromSelect,
      selectMock,
      returningUpdate,
      whereUpdate,
      setMock,
      updateMock,
    },
  };
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

describe('GrantsService', () => {
  let db: ReturnType<typeof makeDb>;
  let service: GrantsService;

  beforeEach(() => {
    db = makeDb();
    service = new GrantsService(db as unknown as Db);
  });

  // ─── createGrant ──────────────────────────────────────────────────────────

  describe('createGrant', () => {
    it('calls parseFederationScope — rejects an invalid scope', async () => {
      const invalidScope = { resources: [], max_rows_per_query: 0 };
      await expect(
        service.createGrant({ peerId: PEER_ID, subjectUserId: USER_ID, scope: invalidScope }),
      ).rejects.toBeInstanceOf(FederationScopeError);
    });

    it('inserts a grant with status pending and returns it', async () => {
      const result = await service.createGrant({
        peerId: PEER_ID,
        subjectUserId: USER_ID,
        scope: VALID_SCOPE,
      });

      expect(db._mocks.valuesInsert).toHaveBeenCalledWith(
        expect.objectContaining({ status: 'pending', peerId: PEER_ID, subjectUserId: USER_ID }),
      );
      expect(result.status).toBe('pending');
    });

    it('passes expiresAt as a Date when provided', async () => {
      await service.createGrant({
        peerId: PEER_ID,
        subjectUserId: USER_ID,
        scope: VALID_SCOPE,
        expiresAt: '2027-01-01T00:00:00Z',
      });

      expect(db._mocks.valuesInsert).toHaveBeenCalledWith(
        expect.objectContaining({ expiresAt: expect.any(Date) }),
      );
    });

    it('sets expiresAt to null when not provided', async () => {
      await service.createGrant({ peerId: PEER_ID, subjectUserId: USER_ID, scope: VALID_SCOPE });

      expect(db._mocks.valuesInsert).toHaveBeenCalledWith(
        expect.objectContaining({ expiresAt: null }),
      );
    });
  });

  // ─── getGrant ─────────────────────────────────────────────────────────────

  describe('getGrant', () => {
    it('returns the grant when found', async () => {
      const result = await service.getGrant(GRANT_ID);
      expect(result.id).toBe(GRANT_ID);
    });

    it('throws NotFoundException when no rows returned', async () => {
      db = makeDb({ selectRows: [] });
      service = new GrantsService(db as unknown as Db);
      await expect(service.getGrant(GRANT_ID)).rejects.toBeInstanceOf(NotFoundException);
    });
  });

  // ─── listGrants ───────────────────────────────────────────────────────────

  describe('listGrants', () => {
    it('queries without where clause when no filters provided', async () => {
      const result = await service.listGrants({});
      expect(Array.isArray(result)).toBe(true);
    });

    it('applies peerId filter', async () => {
      await service.listGrants({ peerId: PEER_ID });
      expect(db._mocks.whereSelect).toHaveBeenCalled();
    });

    it('applies subjectUserId filter', async () => {
      await service.listGrants({ subjectUserId: USER_ID });
      expect(db._mocks.whereSelect).toHaveBeenCalled();
    });

    it('applies status filter', async () => {
      await service.listGrants({ status: 'active' });
      expect(db._mocks.whereSelect).toHaveBeenCalled();
    });

    it('applies multiple filters combined', async () => {
      await service.listGrants({ peerId: PEER_ID, status: 'pending' });
      expect(db._mocks.whereSelect).toHaveBeenCalled();
    });
  });

  // ─── activateGrant ────────────────────────────────────────────────────────

  describe('activateGrant', () => {
    it('transitions pending → active and returns updated grant', async () => {
      db = makeDb({
        selectRows: [makeMockGrant({ status: 'pending' })],
        updateReturning: [makeMockGrant({ status: 'active' })],
      });
      service = new GrantsService(db as unknown as Db);

      const result = await service.activateGrant(GRANT_ID);

      expect(db._mocks.setMock).toHaveBeenCalledWith({ status: 'active' });
      expect(result.status).toBe('active');
    });

    it('throws ConflictException when grant is already active', async () => {
      db = makeDb({ selectRows: [makeMockGrant({ status: 'active' })] });
      service = new GrantsService(db as unknown as Db);

      await expect(service.activateGrant(GRANT_ID)).rejects.toBeInstanceOf(ConflictException);
    });

    it('throws ConflictException when grant is revoked', async () => {
      db = makeDb({ selectRows: [makeMockGrant({ status: 'revoked' })] });
      service = new GrantsService(db as unknown as Db);

      await expect(service.activateGrant(GRANT_ID)).rejects.toBeInstanceOf(ConflictException);
    });

    it('throws ConflictException when grant is expired', async () => {
      db = makeDb({ selectRows: [makeMockGrant({ status: 'expired' })] });
      service = new GrantsService(db as unknown as Db);

      await expect(service.activateGrant(GRANT_ID)).rejects.toBeInstanceOf(ConflictException);
    });
  });

  // ─── revokeGrant ──────────────────────────────────────────────────────────

  describe('revokeGrant', () => {
    it('transitions active → revoked and sets revokedAt', async () => {
      const revokedAt = new Date();
      db = makeDb({
        selectRows: [makeMockGrant({ status: 'active' })],
        updateReturning: [makeMockGrant({ status: 'revoked', revokedAt })],
      });
      service = new GrantsService(db as unknown as Db);

      const result = await service.revokeGrant(GRANT_ID, 'test reason');

      expect(db._mocks.setMock).toHaveBeenCalledWith(
        expect.objectContaining({
          status: 'revoked',
          revokedAt: expect.any(Date),
          revokedReason: 'test reason',
        }),
      );
      expect(result.status).toBe('revoked');
    });

    it('sets revokedReason to null when not provided', async () => {
      db = makeDb({
        selectRows: [makeMockGrant({ status: 'active' })],
        updateReturning: [makeMockGrant({ status: 'revoked', revokedAt: new Date() })],
      });
      service = new GrantsService(db as unknown as Db);

      await service.revokeGrant(GRANT_ID);

      expect(db._mocks.setMock).toHaveBeenCalledWith(
        expect.objectContaining({ revokedReason: null }),
      );
    });

    it('throws ConflictException when grant is pending', async () => {
      db = makeDb({ selectRows: [makeMockGrant({ status: 'pending' })] });
      service = new GrantsService(db as unknown as Db);

      await expect(service.revokeGrant(GRANT_ID)).rejects.toBeInstanceOf(ConflictException);
    });

    it('throws ConflictException when grant is already revoked', async () => {
      db = makeDb({ selectRows: [makeMockGrant({ status: 'revoked' })] });
      service = new GrantsService(db as unknown as Db);

      await expect(service.revokeGrant(GRANT_ID)).rejects.toBeInstanceOf(ConflictException);
    });

    it('throws ConflictException when grant is expired', async () => {
      db = makeDb({ selectRows: [makeMockGrant({ status: 'expired' })] });
      service = new GrantsService(db as unknown as Db);

      await expect(service.revokeGrant(GRANT_ID)).rejects.toBeInstanceOf(ConflictException);
    });
  });

  // ─── expireGrant ──────────────────────────────────────────────────────────

  describe('expireGrant', () => {
    it('transitions active → expired and returns updated grant', async () => {
      db = makeDb({
        selectRows: [makeMockGrant({ status: 'active' })],
        updateReturning: [makeMockGrant({ status: 'expired' })],
      });
      service = new GrantsService(db as unknown as Db);

      const result = await service.expireGrant(GRANT_ID);

      expect(db._mocks.setMock).toHaveBeenCalledWith({ status: 'expired' });
      expect(result.status).toBe('expired');
    });

    it('throws ConflictException when grant is pending', async () => {
      db = makeDb({ selectRows: [makeMockGrant({ status: 'pending' })] });
      service = new GrantsService(db as unknown as Db);

      await expect(service.expireGrant(GRANT_ID)).rejects.toBeInstanceOf(ConflictException);
    });

    it('throws ConflictException when grant is already expired', async () => {
      db = makeDb({ selectRows: [makeMockGrant({ status: 'expired' })] });
      service = new GrantsService(db as unknown as Db);

      await expect(service.expireGrant(GRANT_ID)).rejects.toBeInstanceOf(ConflictException);
    });

    it('throws ConflictException when grant is revoked', async () => {
      db = makeDb({ selectRows: [makeMockGrant({ status: 'revoked' })] });
      service = new GrantsService(db as unknown as Db);

      await expect(service.expireGrant(GRANT_ID)).rejects.toBeInstanceOf(ConflictException);
    });
  });
});
