import { ConfidentialClientApplication, type AuthenticationResult } from '@azure/msal-node';
import { Inject, Injectable } from '@nestjs/common';
import { Logger } from 'nestjs-pino';
import { MSAL_CLIENT } from '../../auth/msal-client.token';
import { DownstreamTokenCache, type CachedToken } from '../downstream-token-cache.service';

/**
 * Caller-supplied input. The user's Entra access token is passed in
 * by the future framework integration — when the first concrete
 * downstream consumer ships, the framework will fetch it from the
 * session (per ADR-0014 §"OBO strategy") and forward here. Keeping
 * the strategy parameter-driven for now means it stays a testable
 * primitive without coupling to CLS / session shape.
 *
 * `actorIdHash` is the salted user-id hash from the audit module —
 * used as the cache namespace so two different users acquiring the
 * same downstream-scoped token never collide.
 *
 * `resource` is the downstream's stable identifier (typically the
 * Entra app-id URI or a custom scope namespace); it is the cache
 * key's resource segment so a user acquiring tokens for multiple
 * downstreams has one cache entry per downstream.
 *
 * `scopes` is the scope array MSAL forwards to Entra's OBO endpoint.
 */
export interface OboAcquireInput {
  readonly userAccessToken: string;
  readonly actorIdHash: string;
  readonly resource: string;
  readonly scopes: readonly string[];
}

export class OboAcquireError extends Error {
  /**
   * Discriminator forwarded to the framework's audit-event emission.
   * `'cache-miss'` is reserved for the path where MSAL itself
   * refused (network, refusal, expired user assertion) — a true
   * cache miss alone is not an error.
   */
  readonly reason: 'msal-refused' | 'msal-no-result';
  override readonly cause: unknown;

  constructor(reason: 'msal-refused' | 'msal-no-result', cause: unknown) {
    super(`OBO token acquisition failed (${reason})`);
    this.name = 'OboAcquireError';
    this.reason = reason;
    this.cause = cause;
  }
}

/**
 * On-Behalf-Of strategy per ADR-0014 §"OBO strategy (Entra-protected
 * downstreams)". Wraps MSAL Node's `acquireTokenOnBehalfOf` with
 * the encrypted-at-rest Redis cache from
 * {@link DownstreamTokenCache}.
 *
 * Flow on each `acquire()` call:
 *
 *   1. Cache lookup. If the entry exists and isn't past its safety
 *      buffer, return it verbatim — saves an Entra round-trip.
 *   2. Cache miss → call MSAL `acquireTokenOnBehalfOf` with the
 *      user's access token as the OBO assertion. MSAL handles the
 *      Entra round-trip + token validation.
 *   3. Cache the result with TTL = expiry − 60 s (per ADR-0014).
 *   4. Return the token to the caller.
 *
 * Failures (MSAL refusal, MSAL null result) throw {@link OboAcquireError}.
 * The future framework integration will translate this to a 502 +
 * `auth.token.validation.failed` audit event (ADR-0013) at the call
 * site — per ADR-0014 the BFF does NOT silently fall back to the
 * user's original token.
 */
@Injectable()
export class OboStrategy {
  constructor(
    @Inject(MSAL_CLIENT) private readonly msal: ConfidentialClientApplication,
    private readonly cache: DownstreamTokenCache,
    private readonly logger: Logger,
  ) {}

  async acquire(input: OboAcquireInput): Promise<CachedToken> {
    const cached = await this.cache.get({
      actorIdHash: input.actorIdHash,
      resource: input.resource,
    });
    if (cached !== null && isFreshEnough(cached)) {
      return cached;
    }

    let result: AuthenticationResult | null;
    try {
      result = await this.msal.acquireTokenOnBehalfOf({
        oboAssertion: input.userAccessToken,
        scopes: [...input.scopes],
      });
    } catch (err) {
      this.logger.warn(
        {
          event: 'downstream.obo.msal_refused',
          resource: input.resource,
          reason: err instanceof Error ? err.message : String(err),
        },
        'OboStrategy',
      );
      throw new OboAcquireError('msal-refused', err);
    }

    if (result === null || result.accessToken === '' || result.expiresOn === null) {
      this.logger.warn(
        {
          event: 'downstream.obo.msal_no_result',
          resource: input.resource,
          hasResult: result !== null,
          hasExpiry: result?.expiresOn !== null,
        },
        'OboStrategy',
      );
      throw new OboAcquireError('msal-no-result', null);
    }

    // MSAL's `expiresOn` is a `Date | null`. We just checked it
    // wasn't null above, so `.getTime()` is safe.
    const token: CachedToken = {
      accessToken: result.accessToken,
      expiresAt: result.expiresOn.getTime(),
    };

    await this.cache.set({
      actorIdHash: input.actorIdHash,
      resource: input.resource,
      token,
    });

    return token;
  }
}

/**
 * In-strategy freshness check that mirrors the cache's write-side
 * buffer (60 s). Two readers checking the same buffer means a token
 * that survives Redis's TTL but slipped inside the buffer between
 * write and read still gets re-acquired. Redundant under steady
 * state; load-bearing during clock skew or extreme contention.
 */
function isFreshEnough(token: CachedToken): boolean {
  return token.expiresAt - Date.now() > 60_000;
}
