import { Inject, Injectable } from '@nestjs/common';
import { Logger } from 'nestjs-pino';
import { EntraGroupToRoleResolver, isPrivilege, type Principal, type Privilege } from 'shared-auth';
import { ENTRA_GROUP_TO_ROLE_RESOLVER } from './entra-group-to-role.token';
import { ScopeResolver } from './scope-resolver';
import type { AuthenticatedUser } from './auth.service';

/**
 * Builds the session-resident `Principal` per
 * [ADR-0025 §"Principal shape"](../../../../docs/decisions/0025-authorization-model-privileges-roles-scopes.md).
 *
 * Composes the three orthogonal axes once at sign-in:
 *   - **Privileges** — pulled from the `roles` claim, filtered to
 *     the closed `PRIVILEGES` catalogue. Unknown values are
 *     dropped (with a WARN) rather than honoured: the BFF only
 *     reasons about catalogue privileges, anything else is
 *     either a tenant misconfiguration or a leftover from a v1+1
 *     experiment that should not silently grant access.
 *   - **Functional roles** — resolved from the `groups` claim via
 *     `EntraGroupToRoleResolver`. Unknown GUIDs are logged at
 *     WARN (per ADR-0025 §"Sources of truth — Entra-side
 *     configuration") and ignored.
 *   - **Scopes** — resolved by `ScopeResolver` from the portal-side
 *     `user_scopes` table (schema landed in ADR-0026 PR 1; the
 *     `PrismaScopeResolver` consumer lands in PR 2). v1 stubs to
 *     `[{ kind: 'unrestricted' }]`.
 *
 * Built once per sign-in, not per request: the session payload
 * carries the resolved `Principal` so guards can read it without
 * re-doing the GUID lookup on every API call.
 *
 * The `identity` parameter (UUIDs from `PersonAndUserProvisioner`)
 * populates `Principal.user.{id, personId}` with the real portal-
 * side identifiers introduced in ADR-0026 PR 1 — pre-PR-1 the BFF
 * reused the Entra `oid` as a placeholder.
 */
@Injectable()
export class PrincipalBuilder {
  constructor(
    @Inject(ENTRA_GROUP_TO_ROLE_RESOLVER)
    private readonly groupToRole: EntraGroupToRoleResolver,
    private readonly scopes: ScopeResolver,
    private readonly logger: Logger,
  ) {}

  async build(
    user: AuthenticatedUser,
    identity: { userId: string; personId: string },
  ): Promise<Principal> {
    const privileges = filterPrivileges(user.roles, user.oid, this.logger);

    const roles = this.groupToRole.resolve(user.groups, (groupId: string) => {
      this.logger.warn(
        {
          event: 'auth.unknown_group_claim',
          oid: user.oid,
          groupId,
        },
        'PrincipalBuilder',
      );
    });

    const scopes = await this.scopes.resolve({ userId: identity.userId });

    return {
      user: {
        id: identity.userId,
        personId: identity.personId,
        entraOid: user.oid,
        tenantId: user.tid,
        displayName: user.displayName,
      },
      privileges,
      roles,
      scopes,
      amr: user.amr,
    };
  }
}

function filterPrivileges(
  rawRoles: readonly string[],
  oid: string,
  logger: Logger,
): ReadonlyArray<Privilege> {
  const out: Privilege[] = [];
  for (const value of rawRoles) {
    if (isPrivilege(value)) {
      out.push(value);
    } else {
      // A value in the `roles` claim that is not a known privilege
      // is either a leftover from a different app registration, a
      // typo in the Entra manifest, or a future privilege not yet
      // in the catalogue. None of those should silently grant
      // access — drop with a WARN so an operator can investigate.
      logger.warn(
        {
          event: 'auth.unknown_privilege_claim',
          oid,
          value,
        },
        'PrincipalBuilder',
      );
    }
  }
  return out;
}
