import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios, { AxiosInstance } from 'axios';
import { createHash } from 'crypto';
import {
  BFF_SYSTEM,
  SERVICE_CREDENTIAL_NEGATIVE_TTL_MS,
  SERVICE_CREDENTIAL_POSITIVE_TTL_MS,
  SERVICE_CREDENTIAL_VALIDATE_TIMEOUT_MS,
} from '../constants/service-credential.constants';

/** The credential shape Laravel returns on a valid introspection (§4.4). */
export interface ServiceCredentialInfo {
  id?: string;
  prefix?: string;
  type?: string;
  owner_type?: string;
  owner_id?: string | null;
  tenant_id?: string | null;
  scopes?: string[];
  expires_at?: string | null;
}

/** Normalized validator result. Matches §4.4 plus a BFF-only `validator_unreachable` (fail-closed). */
export interface ValidationResult {
  valid: boolean;
  credential?: ServiceCredentialInfo;
  reason?: string;
}

/** Inputs the guard hands to the validator. */
export interface ValidateContext {
  presentedKey: string;
  route: string;
  method: string;
  requiredScope?: string | null;
  ip?: string | null;
  origin?: string | null;
  correlationId?: string | null;
}

interface CacheEntry {
  result: ValidationResult;
  expiresAt: number;
  prefix: string | null; // M6: enables evict-by-prefix from the internal endpoint
}

/** Raw introspection response shape from Laravel (§4.4). */
interface ValidateResponseBody {
  valid?: boolean;
  reason?: string;
  credential?: ServiceCredentialInfo;
}

/** Maximum number of entries kept in the in-memory validator cache. Prevents unbounded growth
 *  under heavy traffic (e.g. a flood of distinct keys). On overflow: expired entries are swept
 *  first; if still over, the oldest inserted entry (LRU-approximation via Map insertion order) is
 *  deleted. Security correctness is unaffected: the next request simply re-validates. */
const MAX_CACHE_ENTRIES = 5_000;

@Injectable()
export class ServiceCredentialValidatorService {
  private readonly logger = new Logger(ServiceCredentialValidatorService.name);
  private readonly http: AxiosInstance;
  private readonly cache = new Map<string, CacheEntry>();

  constructor(private readonly configService: ConfigService) {
    const baseURL =
      this.configService.get<string>('LARAVEL_URL') || 'http://localhost:8000';

    this.http = axios.create({
      baseURL,
      timeout: SERVICE_CREDENTIAL_VALIDATE_TIMEOUT_MS,
      headers: {
        'Content-Type': 'application/json',
        Accept: 'application/json',
      },
    });
  }

  /**
   * Validate a presented key against Laravel's introspection endpoint, with a short cache.
   * FAIL-CLOSED: any non-200, network error, or timeout => { valid: false, reason: 'validator_unreachable' }.
   * A 200 with `valid:false` => deny with the upstream reason (route_denied | method_denied | revoked | ...).
   */
  async validate(ctx: ValidateContext): Promise<ValidationResult> {
    const environment =
      this.configService.get<string>('SERVICE_CREDENTIALS_ENVIRONMENT') ||
      'production';

    const cacheKey = this.cacheKey(ctx, environment);
    const cached = this.fromCache(cacheKey);
    if (cached) {
      return cached;
    }

    const result = await this.callValidator(ctx, environment);
    this.toCache(cacheKey, result, this.extractPrefix(ctx.presentedKey));
    return result;
  }

  private async callValidator(
    ctx: ValidateContext,
    environment: string,
  ): Promise<ValidationResult> {
    const internalSecret =
      this.configService.get<string>('SERVICE_CREDENTIALS_INTERNAL_SECRET') ??
      '';

    try {
      const response = await this.http.post<ValidateResponseBody>(
        '/v1/internal/service-credentials/validate',
        {
          presented_key: ctx.presentedKey,
          environment,
          system: BFF_SYSTEM,
          route: ctx.route,
          method: ctx.method,
          required_scope: ctx.requiredScope ?? null,
          ip: ctx.ip ?? null,
          origin: ctx.origin ?? null,
          correlation_id: ctx.correlationId ?? null,
        },
        {
          headers: { 'X-Internal-Auth': internalSecret },
          // We inspect status ourselves so a 4xx/5xx is fail-closed, never a thrown surprise.
          validateStatus: () => true,
        },
      );

      // Non-200 => validator said something other than a clean yes/no => treat as unreachable.
      if (
        response.status !== 200 ||
        typeof response.data?.valid !== 'boolean'
      ) {
        this.logger.warn(
          `service_credential.validate non-200: status=${response.status} prefix=${this.redact(ctx.presentedKey)} route=${ctx.route} method=${ctx.method}`,
        );
        return { valid: false, reason: 'validator_unreachable' };
      }

      const body = response.data;
      if (body.valid) {
        return { valid: true, credential: body.credential };
      }
      return { valid: false, reason: body.reason ?? 'invalid' };
    } catch (err: unknown) {
      const message = err instanceof Error ? err.message : 'unknown error';
      // NEVER log the raw key — only the public prefix.
      this.logger.error(
        `service_credential.validate unreachable: ${message} prefix=${this.redact(ctx.presentedKey)} route=${ctx.route} method=${ctx.method}`,
      );
      return { valid: false, reason: 'validator_unreachable' };
    }
  }

  /** Cache key: sha256(presented_key)|system|route|method|environment (never the raw key). */
  private cacheKey(ctx: ValidateContext, environment: string): string {
    const keyHash = createHash('sha256').update(ctx.presentedKey).digest('hex');
    return [
      keyHash,
      BFF_SYSTEM,
      ctx.route,
      ctx.method.toUpperCase(),
      environment,
    ].join('|');
  }

  private fromCache(key: string): ValidationResult | null {
    const entry = this.cache.get(key);
    if (!entry) {
      return null;
    }
    if (entry.expiresAt <= Date.now()) {
      this.cache.delete(key);
      return null;
    }
    return entry.result;
  }

  private toCache(
    key: string,
    result: ValidationResult,
    prefix: string | null,
  ): void {
    // Do NOT cache transient validator-unreachable answers — retry next request immediately.
    if (!result.valid && result.reason === 'validator_unreachable') {
      return;
    }
    const positiveTtl =
      this.configService.get<number>('SERVICE_CRED_CACHE_TTL_MS') ??
      SERVICE_CREDENTIAL_POSITIVE_TTL_MS;
    const ttl = result.valid ? positiveTtl : SERVICE_CREDENTIAL_NEGATIVE_TTL_MS;

    // Enforce size cap to prevent unbounded growth under key-flooding.
    if (this.cache.size >= MAX_CACHE_ENTRIES) {
      // First sweep: purge expired entries (cheapest eviction path).
      const now = Date.now();
      for (const [k, entry] of this.cache.entries()) {
        if (entry.expiresAt <= now) {
          this.cache.delete(k);
        }
      }
      // If still at capacity, drop the oldest entry (Map preserves insertion order).
      if (this.cache.size >= MAX_CACHE_ENTRIES) {
        const oldest = this.cache.keys().next().value;
        if (oldest !== undefined) {
          this.cache.delete(oldest);
        }
      }
    }

    this.cache.set(key, { result, expiresAt: Date.now() + ttl, prefix });
  }

  /** Public prefix only (everything before the first dot); safe to log. */
  private redact(presentedKey: string): string {
    const dot = presentedKey.indexOf('.');
    return dot === -1 ? '[no-prefix]' : presentedKey.slice(0, dot);
  }

  /** Public prefix only (everything before the first dot); used for evict-by-prefix. */
  private extractPrefix(presentedKey: string): string | null {
    const dot = presentedKey.indexOf('.');
    return dot === -1 ? null : presentedKey.slice(0, dot);
  }

  /**
   * Drop every cached entry for a credential prefix (M6 — best-effort eviction push target).
   * Called by the internal evict endpoint on revoke/rotate so the reached replica revokes at once.
   * Returns the number of entries removed. Correctness never depends on this — the positive TTL
   * (SERVICE_CRED_CACHE_TTL_MS, ~30s) already bounds staleness on every replica.
   */
  evict(prefix: string): number {
    let removed = 0;
    for (const [key, entry] of this.cache.entries()) {
      if (entry.prefix === prefix) {
        this.cache.delete(key);
        removed += 1;
      }
    }
    if (removed > 0) {
      this.logger.log(
        `service_credential.cache_evicted prefix=${prefix} entries=${removed}`,
      );
    }
    return removed;
  }
}
