import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { Logger } from 'nestjs-pino';
import { PrismaService } from 'nestjs-prisma';
import { isPersonSource, type PersonSource } from './person-source';

/**
 * Minimal input slice from `AuthenticatedUser`. Defined here so the
 * provisioner stays decoupled from the auth module's internal shape
 * — the spec hand-rolls inputs without going through the OIDC flow.
 */
export interface EnsureUserInput {
  readonly oid: string;
  readonly tenantId: string;
  /**
   * Entra `displayName` — split on the first space into `firstName`
   * + `lastName` on Person. Best effort: single-word names produce
   * a Person with `lastName = ''`.
   */
  readonly displayName: string;
  /**
   * Entra `preferred_username` (typically the user's email / UPN).
   * Stored verbatim on Person.email and NOT used for dedup in v1 —
   * ADR-0026 §"Lifecycle" deliberately treats email as an attribute,
   * not a natural key.
   */
  readonly email: string | null;
}

/**
 * Identifier pair the BFF carries on the session-resident `Principal`
 * once `User` + `Person` are real rows (per ADR-0026 PR 1). Pre-PR-1
 * the BFF reused the Entra `oid` as a placeholder; the new shape
 * makes the two concerns explicit:
 *   - `userId` — `User.id` (UUID), the portal-account identifier.
 *   - `personId` — `Person.id` (UUID), the golden-record identifier.
 */
export interface ProvisionedIdentity {
  readonly userId: string;
  readonly personId: string;
}

/**
 * `PersonAndUserProvisioner` — lazy-create of the `Person` + `User`
 * pair at first OIDC callback, per
 * [ADR-0026 §"Lifecycle"](../../../../docs/decisions/0026-person-user-portal-data-model.md).
 *
 * **Blocking** per the ADR: a `Principal` cannot be built without
 * `User.id` / `Person.id`, so a Postgres outage on this path MUST
 * fail the sign-in. Distinct from `UserDirectoryService.recordSignIn`
 * (ADR-0020 admin-list cache) which is best-effort — both run from
 * `SessionEstablisher.establish`, but with different invariants.
 *
 * v1 keys ONLY on `entraOid`. Email-based dedup is deliberately not
 * implemented (ADR-0026 §"Why no email-based merging in v1" — two
 * distinct humans genuinely share emails); ADR-0029's sync flow adds
 * an operator-confirmed reconciliation path.
 */
@Injectable()
export class PersonAndUserProvisioner {
  /**
   * Source value written to `Person.source` for self-signin lazy
   * creation. Hardcoded as the constant value, not a parameter —
   * the ADR-0026 lifecycle ONLY produces 'self-signin' rows; admin-
   * UI and seed paths construct Person rows through different code.
   * Kept as a `PersonSource`-typed local so the drift gate and the
   * TS compiler both catch a future divergence.
   */
  private readonly SELF_SIGNIN_SOURCE: PersonSource = 'self-signin';

  constructor(
    private readonly prisma: PrismaService,
    private readonly logger: Logger,
  ) {}

  async ensureUser(input: EnsureUserInput): Promise<ProvisionedIdentity> {
    // Fast path: User exists. Refresh `lastSignInAt`, return the
    // UUIDs the caller needs to build the Principal.
    const existing = await this.prisma.user.findUnique({
      where: { entraOid: input.oid },
      select: { id: true, personId: true },
    });
    if (existing) {
      await this.prisma.user.update({
        where: { id: existing.id },
        data: { lastSignInAt: new Date() },
      });
      return { userId: existing.id, personId: existing.personId };
    }

    // First sign-in: create Person + linked User in one transaction.
    // The nested `create` on `person` is Prisma's idiom for a 1-to-1
    // create that emits a single SQL transaction internally — no
    // explicit `$transaction` needed.
    //
    // Race: two concurrent first sign-ins for the same `entraOid` —
    // both `findUnique` calls miss, both reach the `create`. The
    // unique constraint on `User.entraOid` rejects the loser with
    // P2002; we treat that as "the winner has now created the row"
    // and re-run `ensureUser`. Pathological infinite-loop guarded by
    // the fast path returning on the second attempt.
    const { firstName, lastName } = splitDisplayName(input.displayName);
    // Defense in depth — flagged by the drift gate (`source: 'X'`
    // literal in a file importing person-source) AND by the TS type
    // union, but we re-check the constant value here so a future
    // refactor of `SELF_SIGNIN_SOURCE` that drops the type annotation
    // doesn't silently write a non-catalogue value.
    if (!isPersonSource(this.SELF_SIGNIN_SOURCE)) {
      this.logger.error(
        {
          event: 'provisioner.invalid_person_source',
          source: this.SELF_SIGNIN_SOURCE,
        },
        'PersonAndUserProvisioner',
      );
      throw new Error(`invalid Person.source constant: ${this.SELF_SIGNIN_SOURCE}`);
    }
    try {
      const created = await this.prisma.user.create({
        data: {
          entraOid: input.oid,
          tenantId: input.tenantId,
          lastSignInAt: new Date(),
          person: {
            create: {
              firstName,
              lastName,
              email: input.email,
              source: this.SELF_SIGNIN_SOURCE,
            },
          },
        },
        select: { id: true, personId: true },
      });
      this.logger.log(
        {
          event: 'provisioner.person_and_user_created',
          oid: input.oid,
          userId: created.id,
          personId: created.personId,
        },
        'PersonAndUserProvisioner',
      );
      return { userId: created.id, personId: created.personId };
    } catch (err) {
      if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
        // Loser of the race against a concurrent first sign-in for
        // the same `entraOid`. Re-run — fast path will pick up the
        // winning row.
        this.logger.warn(
          { event: 'provisioner.race_retry', oid: input.oid },
          'PersonAndUserProvisioner',
        );
        return this.ensureUser(input);
      }
      throw err;
    }
  }
}

/**
 * Split a single-string display name into first / last on the first
 * whitespace boundary. Best effort — Entra `displayName` is
 * tenant-formatted (some "First Last", some "Last, First", some
 * single-name). v1 documents the limitation; the admin-UI scope-
 * seeding screen (ADR-0026 PR 2) will surface mis-split rows for
 * manual correction.
 *
 * Exported for the spec.
 */
export function splitDisplayName(displayName: string): {
  firstName: string;
  lastName: string;
} {
  const trimmed = displayName.trim();
  const space = trimmed.indexOf(' ');
  if (space < 0) {
    return { firstName: trimmed, lastName: '' };
  }
  return {
    firstName: trimmed.slice(0, space),
    lastName: trimmed.slice(space + 1).trim(),
  };
}
