import type { Logger } from 'nestjs-pino';
import {
  EntraGroupToRoleResolver,
  parseEntraGroupMap,
  type FunctionalRole,
  type Scope,
} from 'shared-auth';
import type { AuthenticatedUser } from './auth.service';
import { PrincipalBuilder } from './principal-builder';
import type { ScopeResolver } from './scope-resolver';

/**
 * Mirrors the 24-entry `apf-role-*` catalogue: one synthetic GUID
 * per slug, in catalogue order. The exact GUIDs do not matter to
 * any assertion below — what matters is that the test resolver
 * maps them deterministically so a persona's `groups` claim can
 * be assembled by slug.
 */
const ROLE_GUID: Record<FunctionalRole, string> = {
  collaborateur: '00000000-0000-0000-0000-000000000101',
  'chef-equipe': '00000000-0000-0000-0000-000000000102',
  'chef-service': '00000000-0000-0000-0000-000000000103',
  'directeur-etablissement': '00000000-0000-0000-0000-000000000104',
  'directeur-territorial': '00000000-0000-0000-0000-000000000105',
  rh: '00000000-0000-0000-0000-000000000106',
  'responsable-paie': '00000000-0000-0000-0000-000000000107',
  comptable: '00000000-0000-0000-0000-000000000108',
  juriste: '00000000-0000-0000-0000-000000000109',
  dpo: '00000000-0000-0000-0000-00000000010a',
  rssi: '00000000-0000-0000-0000-00000000010b',
  it: '00000000-0000-0000-0000-00000000010c',
  formation: '00000000-0000-0000-0000-00000000010d',
  qualite: '00000000-0000-0000-0000-00000000010e',
  communication: '00000000-0000-0000-0000-00000000010f',
  'elu-ca': '00000000-0000-0000-0000-000000000110',
  'elu-cd': '00000000-0000-0000-0000-000000000111',
  'elu-cd-president': '00000000-0000-0000-0000-000000000112',
  'elu-cd-tresorier': '00000000-0000-0000-0000-000000000113',
  'elu-cd-secretaire': '00000000-0000-0000-0000-000000000114',
  delegue: '00000000-0000-0000-0000-000000000115',
  benevole: '00000000-0000-0000-0000-000000000116',
  'benevole-responsable': '00000000-0000-0000-0000-000000000117',
  partenaire: '00000000-0000-0000-0000-000000000118',
};

/**
 * The 19 personas provisioned in the `apfrd.onmicrosoft.com` test
 * tenant on 2026-05-20, transcribed from
 * `notes/test-tenant-role-assignments.md` (reverse view). The
 * builder's contract is that these inputs round-trip to the
 * expected `(privileges, roles, scopes)` triple — when the spec
 * passes, every persona we documented in the ADR is exercised
 * end-to-end.
 *
 * Scopes are stubbed to `[{ kind: 'unrestricted' }]` for every
 * persona in v1 per ADR-0025 §"Sources of truth — apf_portal-side
 * `user_scopes` table". The intended per-persona scopes
 * (`etablissement:…`, `delegation:33`, …) land with the Prisma
 * `user_scopes` table — the next PR after this skeleton.
 */
interface Persona {
  readonly label: string;
  readonly username: string;
  readonly privileges: readonly string[]; // raw `roles` claim values
  readonly roles: readonly FunctionalRole[];
}

const PERSONAS: readonly Persona[] = [
  {
    label: 'admin',
    username: 'admin@apfrd.onmicrosoft.com',
    privileges: ['Portal.Admin'],
    roles: ['collaborateur', 'rh'],
  },
  {
    label: 'directeur-bordeaux',
    username: 'directeur-bordeaux@apfrd.onmicrosoft.com',
    privileges: [],
    roles: ['collaborateur', 'directeur-etablissement'],
  },
  {
    label: 'directeur-complexe',
    username: 'directeur-complexe@apfrd.onmicrosoft.com',
    privileges: [],
    roles: ['collaborateur', 'directeur-etablissement'],
  },
  {
    label: 'rh-aquitaine',
    username: 'rh-aquitaine@apfrd.onmicrosoft.com',
    privileges: [],
    roles: ['collaborateur', 'rh', 'formation'],
  },
  {
    label: 'rh-siege',
    username: 'rh-siege@apfrd.onmicrosoft.com',
    privileges: [],
    roles: ['collaborateur', 'rh', 'responsable-paie', 'comptable'],
  },
  {
    label: 'collaborateur-simple',
    username: 'collaborateur-simple@apfrd.onmicrosoft.com',
    privileges: [],
    roles: ['collaborateur'],
  },
  {
    label: 'tresorier-bordeaux',
    username: 'tresorier-bordeaux@apfrd.onmicrosoft.com',
    privileges: [],
    roles: ['elu-cd', 'elu-cd-tresorier'],
  },
  {
    label: 'dpo',
    username: 'dpo@apfrd.onmicrosoft.com',
    privileges: ['Portal.DPO', 'Portal.Auditor'],
    roles: ['collaborateur', 'dpo', 'qualite'],
  },
  {
    label: 'it',
    username: 'it@apfrd.onmicrosoft.com',
    privileges: [],
    roles: ['collaborateur', 'it'],
  },
  {
    label: 'benevole-aquitaine',
    username: 'benevole-aquitaine@apfrd.onmicrosoft.com',
    privileges: [],
    // Catalogue order: delegue (governance) precedes benevole +
    // benevole-responsable (volunteer). The resolver re-sorts on
    // catalogue order regardless of the Entra claim's order.
    roles: ['delegue', 'benevole', 'benevole-responsable'],
  },
  {
    label: 'chef-equipe-bordeaux',
    username: 'chef-equipe-bordeaux@apfrd.onmicrosoft.com',
    privileges: [],
    roles: ['collaborateur', 'chef-equipe'],
  },
  {
    label: 'chef-service-bordeaux',
    username: 'chef-service-bordeaux@apfrd.onmicrosoft.com',
    privileges: [],
    roles: ['collaborateur', 'chef-service'],
  },
  {
    label: 'directeur-territorial-aquitaine',
    username: 'directeur-territorial-aquitaine@apfrd.onmicrosoft.com',
    privileges: [],
    roles: ['collaborateur', 'directeur-territorial'],
  },
  {
    label: 'juriste-siege',
    username: 'juriste-siege@apfrd.onmicrosoft.com',
    privileges: [],
    roles: ['collaborateur', 'juriste'],
  },
  {
    label: 'rssi',
    username: 'rssi@apfrd.onmicrosoft.com',
    privileges: ['Portal.SecurityOfficer'],
    roles: ['collaborateur', 'rssi'],
  },
  {
    label: 'communication-siege',
    username: 'communication-siege@apfrd.onmicrosoft.com',
    privileges: [],
    roles: ['collaborateur', 'communication'],
  },
  {
    label: 'elu-ca-national',
    username: 'elu-ca-national@apfrd.onmicrosoft.com',
    privileges: [],
    roles: ['elu-ca'],
  },
  {
    label: 'president-cd-aquitaine',
    username: 'president-cd-aquitaine@apfrd.onmicrosoft.com',
    privileges: [],
    roles: ['elu-cd', 'elu-cd-president'],
  },
  {
    label: 'secretaire-cd-aquitaine',
    username: 'secretaire-cd-aquitaine@apfrd.onmicrosoft.com',
    privileges: [],
    roles: ['elu-cd', 'elu-cd-secretaire'],
  },
];

function makeUser(p: Persona): AuthenticatedUser {
  return {
    oid: `oid-${p.label}`,
    tid: 'tenant-1',
    username: p.username,
    displayName: p.label,
    amr: ['pwd', 'mfa'],
    roles: [...p.privileges],
    groups: p.roles.map((slug) => ROLE_GUID[slug]),
  };
}

/**
 * Fixed identity used by every `build()` call in the spec. The
 * provisioner is mocked at the `SessionEstablisher` test level; the
 * `PrincipalBuilder` spec receives the UUIDs verbatim — what matters
 * here is that they round-trip onto `Principal.user.{id, personId}`.
 */
const TEST_IDENTITY = {
  userId: '00000000-0000-0000-0000-000000000fff',
  personId: '00000000-0000-0000-0000-000000000eee',
};

function makeBuilder(): {
  builder: PrincipalBuilder;
  scopeResolver: { resolve: jest.Mock };
  logger: { log: jest.Mock; warn: jest.Mock; error: jest.Mock };
} {
  const rawMap: Record<string, FunctionalRole> = {};
  for (const [slug, guid] of Object.entries(ROLE_GUID) as Array<[FunctionalRole, string]>) {
    rawMap[guid] = slug;
  }
  const resolver = new EntraGroupToRoleResolver(parseEntraGroupMap(rawMap));
  const scopeResolver = {
    resolve: jest.fn().mockResolvedValue([{ kind: 'unrestricted' } as Scope]),
  };
  const logger = { log: jest.fn(), warn: jest.fn(), error: jest.fn() };
  const builder = new PrincipalBuilder(
    resolver,
    scopeResolver as unknown as ScopeResolver,
    logger as unknown as Logger,
  );
  return { builder, scopeResolver, logger };
}

describe('PrincipalBuilder — 19 test-tenant personas', () => {
  for (const persona of PERSONAS) {
    it(`builds the principal for ${persona.label}`, async () => {
      const { builder } = makeBuilder();
      const principal = await builder.build(makeUser(persona), TEST_IDENTITY);
      expect(principal.privileges).toEqual(persona.privileges);
      expect(principal.roles).toEqual(persona.roles);
      expect(principal.scopes).toEqual([{ kind: 'unrestricted' }]);
      expect(principal.user.entraOid).toBe(`oid-${persona.label}`);
      expect(principal.user.tenantId).toBe('tenant-1');
      // Real UUIDs from the provisioner, per ADR-0026 PR 1 — no
      // longer the entraOid placeholder.
      expect(principal.user.id).toBe(TEST_IDENTITY.userId);
      expect(principal.user.personId).toBe(TEST_IDENTITY.personId);
      // amr passes through verbatim — MFA freshness checks read it
      // off the principal per ADR-0011.
      expect(principal.amr).toEqual(['pwd', 'mfa']);
    });
  }

  it('asserts the 19 personas cover all 4 privileges + 23 of 24 functional roles', () => {
    // Coverage check baked into the spec so a regression that
    // drops a role from a persona is caught here, not at PR review.
    // The intentional gap is `partenaire` (placeholder per ADR-0025).
    const privilegesUsed = new Set(PERSONAS.flatMap((p) => p.privileges));
    expect(privilegesUsed).toEqual(
      new Set(['Portal.Admin', 'Portal.Auditor', 'Portal.SecurityOfficer', 'Portal.DPO']),
    );
    const rolesUsed = new Set(PERSONAS.flatMap((p) => p.roles));
    expect(rolesUsed.size).toBe(23);
    expect(rolesUsed.has('partenaire')).toBe(false);
  });
});

describe('PrincipalBuilder — edge cases', () => {
  it('returns an empty privileges array when the user has no app role assignment', async () => {
    const { builder } = makeBuilder();
    const user = makeUser({
      label: 'anon',
      username: 'anon@example',
      privileges: [],
      roles: ['collaborateur'],
    });
    const principal = await builder.build(user, TEST_IDENTITY);
    expect(principal.privileges).toEqual([]);
  });

  it('drops + warns on a claim value that is not a known privilege', async () => {
    const { builder, logger } = makeBuilder();
    const user = makeUser({
      label: 'stale',
      username: 'stale@example',
      privileges: [],
      roles: [],
    });
    // Force in a non-catalogue value the way an old assignment would.
    const userWithDrift: AuthenticatedUser = {
      ...user,
      roles: ['Portal.Admin', 'Portal.GhostRole'],
    };
    const principal = await builder.build(userWithDrift, TEST_IDENTITY);
    expect(principal.privileges).toEqual(['Portal.Admin']);
    expect(logger.warn).toHaveBeenCalledWith(
      expect.objectContaining({
        event: 'auth.unknown_privilege_claim',
        value: 'Portal.GhostRole',
      }),
      'PrincipalBuilder',
    );
  });

  it('drops + warns on an unknown group GUID (tenant misconfiguration)', async () => {
    const { builder, logger } = makeBuilder();
    const user = makeUser({
      label: 'ghost-group',
      username: 'ghost-group@example',
      privileges: [],
      roles: ['collaborateur'],
    });
    const userWithUnknownGroup: AuthenticatedUser = {
      ...user,
      groups: [...user.groups, 'ffffffff-ffff-ffff-ffff-ffffffffffff'],
    };
    const principal = await builder.build(userWithUnknownGroup, TEST_IDENTITY);
    expect(principal.roles).toEqual(['collaborateur']);
    expect(logger.warn).toHaveBeenCalledWith(
      expect.objectContaining({
        event: 'auth.unknown_group_claim',
        groupId: 'ffffffff-ffff-ffff-ffff-ffffffffffff',
      }),
      'PrincipalBuilder',
    );
  });

  it('builds an empty-permissions principal when the user has no groups or roles', async () => {
    const { builder } = makeBuilder();
    const user = makeUser({
      label: 'empty',
      username: 'empty@example',
      privileges: [],
      roles: [],
    });
    const principal = await builder.build(user, TEST_IDENTITY);
    expect(principal.privileges).toEqual([]);
    expect(principal.roles).toEqual([]);
    // Scope resolver stub returns unrestricted — the v1 behaviour
    // per ADR-0025 §331 until the user_scopes table lands.
    expect(principal.scopes).toEqual([{ kind: 'unrestricted' }]);
  });

  it('asks the scope resolver to resolve by userId (ADR-0026 PR 1 seam change)', async () => {
    const { builder, scopeResolver } = makeBuilder();
    await builder.build(
      makeUser({
        label: 'sr',
        username: 'sr@example',
        privileges: [],
        roles: ['collaborateur'],
      }),
      TEST_IDENTITY,
    );
    // Pre-ADR-0026 the resolver was called with { entraOid }; PR 1
    // switches the seam to { userId } so PR 2's PrismaScopeResolver
    // can key queries on the real User.id.
    expect(scopeResolver.resolve).toHaveBeenCalledWith({ userId: TEST_IDENTITY.userId });
  });
});
