import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Logger } from 'nestjs-pino';
import { PrismaService } from 'nestjs-prisma';
import { isScopeKind, type ScopeKind } from 'shared-auth';
import type { UserScopeDto, UserScopesPageDto } from './user-scopes.dto';

/**
 * Kinds that carry a value referencing an ADR-0027 row. The service
 * resolves each to its matching table at `grant` time.
 */
const VALUE_BEARING_KINDS = new Set<ScopeKind>(['etablissement', 'delegation', 'region']);

export interface ResolvedUser {
  readonly oid: string;
  readonly userId: string;
  readonly displayName: string;
  readonly email: string | null;
}

/**
 * `UserScopesService` — owns the CRUD surface behind the ADR-0026
 * PR 2b admin scope-management screen.
 *
 * Read side reads `user_scopes` joined to `User` + `Person` (so the
 * SPA gets the affected user's display info in the same round-trip).
 * Write side validates the scope tuple before insert:
 *
 *   - `kind` is enforced by the DTO's `IsIn(SCOPE_KINDS)` plus the
 *     type-guard `isScopeKind` defensively.
 *   - For value-bearing kinds (`etablissement` / `delegation` /
 *     `region`), `value` must match an existing row in the
 *     corresponding ADR-0027 table. The lookup is the only DB call
 *     beyond the insert itself.
 *   - For valueless kinds (`self` / `siege` / `unrestricted`), the
 *     service coerces `value` to `''` and rejects any non-empty
 *     attempt.
 *
 * `UserScope.source` is hardcoded to `'admin-ui'` per the
 * PERSON_SOURCES catalogue — see ADR-0026 PR 1's `person-source.ts`
 * for the shared catalogue rules. (`UserScope.source` is not in that
 * catalogue at the drift-gate level today; the seed writes 'seed'
 * and we write 'admin-ui'. ADR-0029 will add upstream-sync values
 * and likely formalise the UserScope.source catalogue then.)
 */
@Injectable()
export class UserScopesService {
  constructor(
    private readonly prisma: PrismaService,
    private readonly logger: Logger,
  ) {}

  /**
   * Resolves an Entra `oid` to the new ADR-0026 `User` row + its
   * linked `Person`. Throws 404 when no User row exists for the oid
   * — caller may need to surface "this persona has never signed in
   * and is not in the seed" with a hint.
   */
  async resolveUserByOid(oid: string): Promise<ResolvedUser> {
    const user = await this.prisma.user.findUnique({
      where: { entraOid: oid },
      select: {
        id: true,
        person: { select: { firstName: true, lastName: true, email: true } },
      },
    });
    if (user === null) {
      throw new NotFoundException(`No portal User found for Entra oid ${oid}.`);
    }
    return {
      oid,
      userId: user.id,
      displayName: composeDisplay(user.person.firstName, user.person.lastName),
      email: user.person.email,
    };
  }

  async list(oid: string): Promise<UserScopesPageDto> {
    const target = await this.resolveUserByOid(oid);
    const rows = await this.prisma.userScope.findMany({
      where: { userId: target.userId },
      orderBy: [{ kind: 'asc' }, { value: 'asc' }],
      select: {
        id: true,
        kind: true,
        value: true,
        source: true,
        createdAt: true,
        expiresAt: true,
      },
    });
    return {
      user: target,
      scopes: rows.map(toDto),
    };
  }

  /**
   * Grant a scope. Validates the tuple, then inserts. Returns the
   * resolved User (so the controller can include it in the audit
   * payload) plus the freshly-created `UserScope` row.
   */
  async grant(
    oid: string,
    input: { kind: string; value?: string; expiresAt?: string },
  ): Promise<{ user: ResolvedUser; scope: UserScopeDto }> {
    const target = await this.resolveUserByOid(oid);
    if (!isScopeKind(input.kind)) {
      // The DTO already enforces this — defensive belt-and-braces.
      throw new BadRequestException(`Unknown scope kind: ${input.kind}.`);
    }
    const normalisedValue = await this.validateValueForKind(input.kind, input.value ?? '');
    const expiresAt = input.expiresAt ? new Date(input.expiresAt) : null;

    try {
      const row = await this.prisma.userScope.create({
        data: {
          userId: target.userId,
          kind: input.kind,
          value: normalisedValue,
          source: 'admin-ui',
          expiresAt,
        },
        select: {
          id: true,
          kind: true,
          value: true,
          source: true,
          createdAt: true,
          expiresAt: true,
        },
      });
      return { user: target, scope: toDto(row) };
    } catch (err) {
      // Prisma's unique-constraint on (userId, kind, value) raises
      // P2002. Surface it as 409-ish via a BadRequest so the admin UI
      // can show "already granted" without a stack trace.
      if (isPrismaP2002(err)) {
        throw new BadRequestException(
          `Scope already granted to this user: kind=${input.kind}, value=${normalisedValue || '(empty)'}.`,
        );
      }
      throw err;
    }
  }

  /**
   * Revoke a scope by its row id. Returns the resolved user + the
   * deleted tuple (kind / value) so the audit row reflects what the
   * row WAS — the row itself is gone by the time the controller
   * audits.
   */
  async revoke(
    oid: string,
    scopeId: string,
  ): Promise<{ user: ResolvedUser; revoked: { kind: string; value: string } }> {
    const target = await this.resolveUserByOid(oid);
    const existing = await this.prisma.userScope.findUnique({
      where: { id: scopeId },
      select: { id: true, userId: true, kind: true, value: true },
    });
    if (existing === null || existing.userId !== target.userId) {
      // Don't leak "this scope id exists but is for someone else" —
      // 404 either way.
      throw new NotFoundException(`Scope ${scopeId} not found for user ${oid}.`);
    }
    await this.prisma.userScope.delete({ where: { id: scopeId } });
    this.logger.log(
      {
        event: 'user_scopes.revoked',
        oid,
        scopeId,
        kind: existing.kind,
        value: existing.value,
      },
      'UserScopesService',
    );
    return {
      user: target,
      revoked: { kind: existing.kind, value: existing.value },
    };
  }

  private async validateValueForKind(kind: ScopeKind, rawValue: string): Promise<string> {
    if (!VALUE_BEARING_KINDS.has(kind)) {
      if (rawValue !== '') {
        throw new BadRequestException(`Scope kind '${kind}' is valueless — value must be empty.`);
      }
      return '';
    }
    if (rawValue === '') {
      throw new BadRequestException(`Scope kind '${kind}' requires a non-empty value.`);
    }
    // Look up the referenced row. Single existence check per kind;
    // ADR-0026 PR 1's "no FK on UserScope.value" rationale lets the
    // value persist even after the target is decommissioned, so we
    // validate only at the write path.
    //
    // Initialise `exists = false` so TypeScript sees it as definitely
    // assigned regardless of how the switch lands (the type-system
    // can't see that `VALUE_BEARING_KINDS.has` already narrowed
    // `kind` to the three branches). If somehow a non-value-bearing
    // kind reached this code, the false default makes the next `if`
    // throw with the same friendly "does not match" message — safe
    // by construction.
    let exists = false;
    switch (kind) {
      case 'etablissement':
        exists =
          (await this.prisma.structure.findUnique({
            where: { code: rawValue },
            select: { code: true },
          })) !== null;
        break;
      case 'delegation':
        exists =
          (await this.prisma.delegation.findUnique({
            where: { code: rawValue },
            select: { code: true },
          })) !== null;
        break;
      case 'region':
        exists =
          (await this.prisma.region.findUnique({
            where: { code: rawValue },
            select: { code: true },
          })) !== null;
        break;
    }
    if (!exists) {
      throw new BadRequestException(`Scope value '${rawValue}' does not match any ${kind} row.`);
    }
    return rawValue;
  }
}

function toDto(row: {
  id: string;
  kind: string;
  value: string;
  source: string;
  createdAt: Date;
  expiresAt: Date | null;
}): UserScopeDto {
  return {
    id: row.id,
    kind: row.kind,
    value: row.value,
    source: row.source,
    createdAt: row.createdAt.toISOString(),
    expiresAt: row.expiresAt === null ? null : row.expiresAt.toISOString(),
  };
}

function composeDisplay(firstName: string, lastName: string): string {
  const combined = `${firstName} ${lastName}`.trim();
  return combined === '' ? '(unnamed)' : combined;
}

function isPrismaP2002(err: unknown): boolean {
  return (
    typeof err === 'object' &&
    err !== null &&
    'code' in err &&
    (err as { code: unknown }).code === 'P2002'
  );
}
