import { createHmac } from "node:crypto";
import {
  ICE_CONFIG_PROTOCOL_VERSION,
  type IceConfigGrant,
  type IceConfigRequest,
  type IceConfigUnavailable,
  type StunIceServer,
  type TurnIceServer,
} from "../../../src/media/iceTypes.ts";
import type { IceConfig } from "../config.ts";

export type IceCredentialResult =
  | { ok: true; value: IceConfigGrant }
  | { ok: false; code: "unavailable" | "rate-limited"; value: IceConfigUnavailable };

export interface IceCredentialRateIdentity {
  /** Used transiently to derive a keyed rate bucket; never retained. */
  subject: string;
  /** Fastify's trusted client address; used transiently and never retained. */
  trustedIp: string;
}

export interface IceCredentialProvider {
  issue(request: IceConfigRequest, rateIdentity: IceCredentialRateIdentity, authorizationExpiresAtMs: number): IceCredentialResult;
}

export interface IceCredentialProviderOptions {
  now?: () => number;
}

interface RateEntry { startedAtMs: number; count: number }
const MAX_RATE_KEYS = 4_096;

export function createIceCredentialProvider(
  config: IceConfig,
  options: IceCredentialProviderOptions = {},
): IceCredentialProvider {
  const now = options.now ?? Date.now;
  const subjectRates = new Map<string, RateEntry>();
  const ipRates = new Map<string, RateEntry>();
  const windowMs = config.rateWindowSeconds * 1_000;

  return {
    issue(request, rateIdentity, authorizationExpiresAtMs) {
      const at = now();
      if (!config.configured) return unavailable(request.requestId, windowMs, "unavailable");
      // Derive immediately and retain only keyed digests in the bounded maps.
      const rateKeys = deriveIceCredentialRateKeys(config.sharedSecret, rateIdentity);
      const subjectRetry = rate(subjectRates, rateKeys.subject, at);
      const ipRetry = rate(ipRates, rateKeys.ip, at);
      const retryAfterMs = Math.max(subjectRetry ?? 0, ipRetry ?? 0);
      if (retryAfterMs > 0) return unavailable(request.requestId, retryAfterMs, "rate-limited");

      const expiresAtSeconds = Math.min(
        Math.floor(at / 1_000) + config.credentialTtlSeconds,
        Math.floor(authorizationExpiresAtMs / 1_000),
      );
      if (expiresAtSeconds <= Math.floor(at / 1_000)) {
        return unavailable(request.requestId, 1_000, "unavailable");
      }
      // Coturn applies user-quota after stripping the REST expiry prefix. A new
      // random suffix per issuance would therefore evade quota. This keyed,
      // domain-separated suffix is stable only for one opaque media participant
      // and contains no auth subject, email, profile id, or IP address.
      const opaqueParticipant = createHmac("sha256", config.sharedSecret)
        .update("tera-turn-user-v1\0")
        .update(request.credential.sessionId)
        .update("\0")
        .update(request.credential.participantId)
        .digest("base64url");
      const username = `${expiresAtSeconds}:${opaqueParticipant}`;
      const credential = createHmac("sha1", config.sharedSecret).update(username).digest("base64");
      const stunUrls = config.urls.filter((url) => url.startsWith("stun:") || url.startsWith("stuns:"));
      const turnUrls = config.urls.filter((url) => url.startsWith("turn:") || url.startsWith("turns:"));
      const iceServers: Array<StunIceServer | TurnIceServer> = [];
      if (stunUrls.length > 0) iceServers.push({ urls: stunUrls });
      iceServers.push({ urls: turnUrls, username, credential, credentialType: "password" });
      return {
        ok: true,
        value: {
          type: "ice-config-grant",
          protocolVersion: ICE_CONFIG_PROTOCOL_VERSION,
          requestId: request.requestId,
          issuedAtMs: at,
          expiresAtMs: expiresAtSeconds * 1_000,
          iceServers,
        },
      };
    },
  };

  function rate(entries: Map<string, RateEntry>, key: string, at: number): number | null {
    let entry = entries.get(key);
    if (!entry && entries.size >= MAX_RATE_KEYS) {
      for (const [candidate, value] of entries) {
        if (at - value.startedAtMs >= windowMs) entries.delete(candidate);
      }
      if (entries.size >= MAX_RATE_KEYS) return windowMs;
      entry = entries.get(key);
    }
    if (!entry || at - entry.startedAtMs >= windowMs) {
      entries.set(key, { startedAtMs: at, count: 1 });
      return null;
    }
    entry.count += 1;
    return entry.count > config.rateAttempts
      ? Math.max(1_000, windowMs - (at - entry.startedAtMs))
      : null;
  }
}

/** Server-only keyed pseudonyms for the two independent limiter dimensions. */
export function deriveIceCredentialRateKeys(
  sharedSecret: string,
  identity: IceCredentialRateIdentity,
): { subject: string; ip: string } {
  const derive = (domain: string, value: string) => createHmac("sha256", sharedSecret)
    .update(domain).update("\0").update(value).digest("hex");
  return {
    subject: derive("tera-ice-subject-v1", identity.subject),
    ip: derive("tera-ice-ip-v1", identity.trustedIp),
  };
}

function unavailable(
  requestId: string,
  retryAfterMs: number,
  code: "unavailable" | "rate-limited",
): IceCredentialResult {
  return {
    ok: false,
    code,
    value: {
      type: "ice-config-unavailable",
      protocolVersion: ICE_CONFIG_PROTOCOL_VERSION,
      requestId,
      retryAfterMs: Math.min(3_600_000, Math.max(1_000, Math.ceil(retryAfterMs))),
    },
  };
}
