import { Inject, Injectable } from '@nestjs/common';
import { Logger } from 'nestjs-pino';
import { REDIS_CLIENT, type Redis } from '../redis/redis.token';
import { decrypt, encrypt } from '../session/session-crypto';
import { OBO_CACHE_KEY } from './downstream.token';

/**
 * Safety buffer subtracted from the upstream token's `expiresAt`
 * before computing the cache TTL. Per ADR-0014 §"Token cache (for
 * OBO)": "TTL equal to the token's expiry minus a safety buffer
 * (60 s)". Prevents the cache from returning a token that will
 * expire mid-call.
 */
const SAFETY_BUFFER_MS = 60_000;

/**
 * Minimum positive TTL written to Redis. Anything shorter would
 * race against the BFF's own clock — better to skip the cache and
 * re-fetch from MSAL than persist a token that will already be
 * stale by the time the downstream sees it.
 */
const MIN_CACHE_TTL_MS = 1_000;

/**
 * Decoded entry returned to the OBO strategy on a cache hit. The
 * `expiresAt` field is verbatim from the upstream `AuthenticationResult`
 * so the strategy can compute its own freshness check independently
 * of the cache layer (e.g. for logging "we served a cached token
 * with 47 s left").
 */
export interface CachedToken {
  readonly accessToken: string;
  /** Epoch ms when the upstream token expires. */
  readonly expiresAt: number;
}

/**
 * Encrypted-at-rest cache for OBO-acquired downstream tokens per
 * ADR-0014. Key shape: `obo:{actorIdHash}:{resource}`. Values are
 * encrypted via the shared AES-256-GCM helpers (same algorithm as
 * `session-crypto`) but under a **dedicated key**
 * ({@link OBO_CACHE_KEY}) so a cache-key compromise can't cascade
 * into session compromise.
 *
 * The cache is a strict short-circuit on the MSAL OBO round-trip:
 *
 *   - Hit → return the cached token verbatim. Freshness check is
 *     the caller's job (the strategy applies a buffer beyond what
 *     the TTL enforces in Redis itself).
 *   - Miss / tampered / wrong-key → return `null`. The strategy
 *     re-acquires from Entra and writes the new value.
 *
 * The cache **never throws** on read — every failure is logged and
 * collapses to a miss. Per ADR-0014 §"OBO strategy", the rare
 * worst-case (MSAL unreachable AND cache useless) is the strategy's
 * concern, not the cache's.
 */
@Injectable()
export class DownstreamTokenCache {
  constructor(
    @Inject(REDIS_CLIENT) private readonly redis: Redis,
    @Inject(OBO_CACHE_KEY) private readonly key: Buffer,
    private readonly logger: Logger,
  ) {}

  async get(input: { actorIdHash: string; resource: string }): Promise<CachedToken | null> {
    const redisKey = cacheKey(input);
    let payload: string | null;
    try {
      payload = await this.redis.get(redisKey);
    } catch (err) {
      // Redis hiccup — log and pretend the cache is empty. The
      // strategy will pay an MSAL round-trip on this request and
      // try the cache again on the next one.
      this.logger.warn(
        {
          event: 'downstream.obo_cache.read_failed',
          reason: err instanceof Error ? err.message : String(err),
        },
        'DownstreamTokenCache',
      );
      return null;
    }
    if (payload === null) {
      return null;
    }
    try {
      const decoded = JSON.parse(decrypt(payload, this.key));
      if (!isCachedToken(decoded)) {
        // Stored shape changed under us — refuse to serve. The
        // miss will trigger a re-acquisition; the bad value gets
        // overwritten on the next write.
        this.logger.warn({ event: 'downstream.obo_cache.shape_mismatch' }, 'DownstreamTokenCache');
        return null;
      }
      return decoded;
    } catch (err) {
      // Tampered ciphertext, wrong key, or unknown version — the
      // safe move is to treat it as a miss. Per ADR-0014's
      // confirmation: "tampering is rejected". Surface the rejection
      // in the log so ops can spot a key-rotation gone wrong.
      this.logger.warn(
        {
          event: 'downstream.obo_cache.decrypt_failed',
          reason: err instanceof Error ? err.message : String(err),
        },
        'DownstreamTokenCache',
      );
      return null;
    }
  }

  async set(input: { actorIdHash: string; resource: string; token: CachedToken }): Promise<void> {
    const redisKey = cacheKey(input);
    const ttlMs = input.token.expiresAt - Date.now() - SAFETY_BUFFER_MS;
    if (ttlMs < MIN_CACHE_TTL_MS) {
      // The token is already inside the safety buffer — caching it
      // would only encourage a stale read. Skip the write entirely;
      // the strategy will re-acquire on the next call.
      return;
    }
    const plaintext = JSON.stringify(input.token);
    const ciphertext = encrypt(plaintext, this.key);
    try {
      await this.redis.set(redisKey, ciphertext, 'PX', ttlMs);
    } catch (err) {
      // Redis write failure is non-fatal: the call already has its
      // freshly-acquired token in hand. Worst case we'll pay an
      // extra MSAL round-trip on the next request — not a
      // correctness issue. Surface the failure for ops.
      this.logger.warn(
        {
          event: 'downstream.obo_cache.write_failed',
          reason: err instanceof Error ? err.message : String(err),
        },
        'DownstreamTokenCache',
      );
    }
  }
}

function cacheKey(input: { actorIdHash: string; resource: string }): string {
  return `obo:${input.actorIdHash}:${input.resource}`;
}

function isCachedToken(value: unknown): value is CachedToken {
  if (typeof value !== 'object' || value === null) return false;
  const v = value as Record<string, unknown>;
  return typeof v['accessToken'] === 'string' && typeof v['expiresAt'] === 'number';
}
