/**
 * Unit tests for FederationScopeService (FED-M3-04).
 *
 * Coverage:
 *  - resource allowlist deny
 *  - excluded resource deny
 *  - invalid scope deny
 *  - invalid requested limit deny
 *  - native RBAC deny as subjectUserId
 *  - scope/native filter intersection for personal and team rows
 *  - native RBAC personal deny wins over scope include_personal allow/default
 *  - max_rows_per_query cap
 */

import { beforeEach, describe, expect, it, vi } from 'vitest';
import { FederationScopeService, type FederationNativeRbacEvaluator } from '../scope.service.js';
import type { FederationContext } from '../federation-context.js';

const GRANT_ID = 'grant-1';
const PEER_ID = 'peer-1';
const SUBJECT_USER_ID = 'user-1';

function makeContext(scope: Record<string, unknown>): FederationContext {
  return {
    grantId: GRANT_ID,
    peerId: PEER_ID,
    subjectUserId: SUBJECT_USER_ID,
    scope,
  };
}

function makeNativeRbac(
  result: Awaited<ReturnType<FederationNativeRbacEvaluator['evaluateReadAccess']>>,
): FederationNativeRbacEvaluator {
  return {
    evaluateReadAccess: vi.fn().mockResolvedValue(result),
  };
}

describe('FederationScopeService', () => {
  let service: FederationScopeService;

  beforeEach(() => {
    service = new FederationScopeService();
  });

  it('allows a granted resource and returns a capped query filter', async () => {
    const nativeRbac = makeNativeRbac({
      allowed: true,
      access: { includePersonal: true, teamIds: ['team-1', 'team-2'] },
    });

    const result = await service.evaluateAccess({
      context: makeContext({
        resources: ['tasks'],
        filters: { tasks: { include_teams: ['team-1', 'team-3'], include_personal: true } },
        max_rows_per_query: 50,
      }),
      resource: 'tasks',
      requestedLimit: 500,
      nativeRbac,
    });

    expect(result).toEqual({
      allowed: true,
      filter: {
        resource: 'tasks',
        subjectUserId: SUBJECT_USER_ID,
        includePersonal: true,
        teamIds: ['team-1'],
        limit: 50,
        maxRowsPerQuery: 50,
      },
    });
    expect(nativeRbac.evaluateReadAccess).toHaveBeenCalledWith({
      grantId: GRANT_ID,
      peerId: PEER_ID,
      subjectUserId: SUBJECT_USER_ID,
      resource: 'tasks',
    });
  });

  it('defaults absent resource filters to native RBAC personal and team visibility', async () => {
    const result = await service.evaluateAccess({
      context: makeContext({ resources: ['notes'], max_rows_per_query: 100 }),
      resource: 'notes',
      nativeRbac: makeNativeRbac({
        allowed: true,
        access: { includePersonal: true, teamIds: ['team-1', 'team-2'] },
      }),
    });

    expect(result).toMatchObject({
      allowed: true,
      filter: {
        includePersonal: true,
        teamIds: ['team-1', 'team-2'],
        limit: 100,
      },
    });
  });

  it('honors include_personal false even when native RBAC allows personal rows', async () => {
    const result = await service.evaluateAccess({
      context: makeContext({
        resources: ['memory'],
        filters: { memory: { include_personal: false } },
        max_rows_per_query: 25,
      }),
      resource: 'memory',
      nativeRbac: makeNativeRbac({
        allowed: true,
        access: { includePersonal: true, teamIds: [] },
      }),
    });

    expect(result).toMatchObject({
      allowed: true,
      filter: {
        includePersonal: false,
        teamIds: [],
      },
    });
  });

  it('does not leak personal rows when scope allows personal but native RBAC denies personal', async () => {
    const result = await service.evaluateAccess({
      context: makeContext({
        resources: ['tasks'],
        filters: { tasks: { include_personal: true } },
        max_rows_per_query: 25,
      }),
      resource: 'tasks',
      nativeRbac: makeNativeRbac({
        allowed: true,
        access: { includePersonal: false, teamIds: ['team-1'] },
      }),
    });

    expect(result).toMatchObject({
      allowed: true,
      filter: {
        includePersonal: false,
        teamIds: ['team-1'],
      },
    });
  });

  it('does not widen native RBAC when scope includes teams the user cannot access', async () => {
    const result = await service.evaluateAccess({
      context: makeContext({
        resources: ['tasks'],
        filters: { tasks: { include_teams: ['team-2'], include_personal: false } },
        max_rows_per_query: 25,
      }),
      resource: 'tasks',
      nativeRbac: makeNativeRbac({
        allowed: true,
        access: { includePersonal: true, teamIds: ['team-1'] },
      }),
    });

    expect(result).toMatchObject({
      allowed: true,
      filter: {
        includePersonal: false,
        teamIds: [],
      },
    });
  });

  it('denies invalid grant scope before RBAC evaluation', async () => {
    const nativeRbac = makeNativeRbac({
      allowed: true,
      access: { includePersonal: true, teamIds: [] },
    });

    const result = await service.evaluateAccess({
      context: makeContext({ resources: [], max_rows_per_query: 100 }),
      resource: 'tasks',
      nativeRbac,
    });

    expect(result).toMatchObject({
      allowed: false,
      deny: {
        code: 'invalid_scope',
        stage: 'scope_parse',
        statusCode: 400,
        grantId: GRANT_ID,
        subjectUserId: SUBJECT_USER_ID,
        resource: 'tasks',
      },
    });
    expect(nativeRbac.evaluateReadAccess).not.toHaveBeenCalled();
  });

  it('denies unsupported resource names before RBAC evaluation', async () => {
    const nativeRbac = makeNativeRbac({
      allowed: true,
      access: { includePersonal: true, teamIds: [] },
    });

    const result = await service.evaluateAccess({
      context: makeContext({ resources: ['tasks'], max_rows_per_query: 100 }),
      resource: 'unknown_resource',
      nativeRbac,
    });

    expect(result).toMatchObject({
      allowed: false,
      deny: {
        code: 'invalid_resource',
        stage: 'resource_allowlist',
        statusCode: 403,
      },
    });
    expect(nativeRbac.evaluateReadAccess).not.toHaveBeenCalled();
  });

  it('denies resources explicitly present in excluded_resources before allowlist miss', async () => {
    const nativeRbac = makeNativeRbac({
      allowed: true,
      access: { includePersonal: true, teamIds: [] },
    });

    const result = await service.evaluateAccess({
      context: makeContext({
        resources: ['tasks'],
        excluded_resources: ['credentials'],
        max_rows_per_query: 100,
      }),
      resource: 'credentials',
      nativeRbac,
    });

    expect(result).toMatchObject({
      allowed: false,
      deny: {
        code: 'resource_excluded',
        stage: 'resource_exclusion',
        statusCode: 403,
        resource: 'credentials',
      },
    });
    expect(nativeRbac.evaluateReadAccess).not.toHaveBeenCalled();
  });

  it('denies supported resources that are not granted by scope', async () => {
    const nativeRbac = makeNativeRbac({
      allowed: true,
      access: { includePersonal: true, teamIds: [] },
    });

    const result = await service.evaluateAccess({
      context: makeContext({ resources: ['tasks'], max_rows_per_query: 100 }),
      resource: 'notes',
      nativeRbac,
    });

    expect(result).toMatchObject({
      allowed: false,
      deny: {
        code: 'resource_not_granted',
        stage: 'resource_allowlist',
        statusCode: 403,
        resource: 'notes',
      },
    });
    expect(nativeRbac.evaluateReadAccess).not.toHaveBeenCalled();
  });

  it('denies invalid requested row limits before RBAC evaluation', async () => {
    const nativeRbac = makeNativeRbac({
      allowed: true,
      access: { includePersonal: true, teamIds: [] },
    });

    const result = await service.evaluateAccess({
      context: makeContext({ resources: ['tasks'], max_rows_per_query: 100 }),
      resource: 'tasks',
      requestedLimit: 0,
      nativeRbac,
    });

    expect(result).toMatchObject({
      allowed: false,
      deny: {
        code: 'invalid_limit',
        stage: 'row_cap',
        statusCode: 400,
        details: { requestedLimit: 0 },
      },
    });
    expect(nativeRbac.evaluateReadAccess).not.toHaveBeenCalled();
  });

  it('denies when native RBAC rejects subjectUserId access to the resource', async () => {
    const result = await service.evaluateAccess({
      context: makeContext({ resources: ['tasks'], max_rows_per_query: 100 }),
      resource: 'tasks',
      nativeRbac: makeNativeRbac({
        allowed: false,
        reason: 'read:tasks denied',
        details: { permission: 'tasks:read' },
      }),
    });

    expect(result).toEqual({
      allowed: false,
      deny: {
        code: 'native_rbac_denied',
        stage: 'native_rbac',
        statusCode: 403,
        message: 'read:tasks denied',
        grantId: GRANT_ID,
        peerId: PEER_ID,
        subjectUserId: SUBJECT_USER_ID,
        resource: 'tasks',
        details: { permission: 'tasks:read' },
      },
    });
  });
});
