import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';

const TTL_SECONDS = 600;
const identifier = value => typeof value === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(value);
const digest = value => createHash('sha256').update(value).digest();

function tokenBytes(value) {
  if (typeof value !== 'string') return null;
  if (/^[a-f0-9]{64}$/.test(value)) return Buffer.from(value, 'hex');
  if (!/^[A-Za-z0-9_-]{43}$/.test(value)) return null;
  const bytes = Buffer.from(value, 'base64url');
  return bytes.length === 32 && bytes.toString('base64url') === value ? bytes : null;
}

/** Generate invitation credentials using the OS cryptographic random source. */
export const generatePublisherKey = () => randomBytes(32).toString('base64url');

/** Parse environment configuration without ever echoing a supplied secret in errors. */
export function parsePublisherKeys(value = process.env.RELIO_PUBLISH_KEYS) {
  if (value === undefined || value === '') return [];
  try {
    const keys = JSON.parse(value);
    if (!Array.isArray(keys)) throw new Error();
    return keys;
  } catch { throw new Error('Invalid publisher key configuration'); }
}

/**
 * Pure invitation authentication and coturn REST credential issuer.
 * Callers MUST authorize the live session before issuing credentials for either role.
 * TURN role/session labels aid attribution; coturn does not enforce those labels as ACLs.
 * No byte quota or revocation of already-issued TURN credentials is implied here.
 */
export class PublisherAccess {
  #entries;
  #authenticated = new WeakSet();
  #turnSecret;
  #turnHost;
  #turnPort;
  #turnTlsPort;
  #now;

  constructor({ keys = parsePublisherKeys(), turnSecret, turnHost, turnPort = 3478, turnTlsPort = null, now = Date.now } = {}) {
    if (!Array.isArray(keys) || keys.length > 1000 || typeof now !== 'function') throw new Error('Invalid publisher key configuration');
    const ids = new Set(), hashes = new Set();
    this.#entries = keys.map(entry => {
      const bytes = tokenBytes(entry?.key);
      // Length/encoding and obvious low-diversity mistakes only; randomness cannot be proved from a token.
      if (!identifier(entry?.id) || !bytes || new Set(bytes).size < 8 || ids.has(entry.id)) throw new Error('Invalid publisher key configuration');
      const hash = digest(entry.key), encoded = hash.toString('hex');
      if (hashes.has(encoded)) throw new Error('Invalid publisher key configuration');
      ids.add(entry.id); hashes.add(encoded);
      return { id: entry.id, hash };
    });
    const portValid = port => Number.isInteger(port) && port > 0 && port <= 65535;
    if (Boolean(turnSecret) !== Boolean(turnHost)) throw new Error('TURN host and secret must be configured together');
    if (turnHost) {
      if (typeof turnHost !== 'string' || turnHost.length > 253 || !turnHost.split('.').every(label => /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/.test(label))) throw new Error('Invalid TURN host');
      if (typeof turnSecret !== 'string' || Buffer.byteLength(turnSecret) < 32 || Buffer.byteLength(turnSecret) > 1024) throw new Error('Invalid TURN secret');
      if (!portValid(turnPort) || (turnTlsPort !== null && !portValid(turnTlsPort))) throw new Error('Invalid TURN port');
    }
    this.#turnSecret = turnSecret;
    this.#turnHost = turnHost;
    this.#turnPort = turnPort;
    this.#turnTlsPort = turnTlsPort;
    this.#now = now;
  }

  authenticate(token) {
    // Fixed-size comparisons and no early exit across configured invitations.
    // Invalid encoding is publicly distinguishable and carries no secret information.
    if (!tokenBytes(token)) return null;
    const hash = digest(token);
    let matched = null;
    for (const entry of this.#entries) if (timingSafeEqual(hash, entry.hash)) matched = entry.id;
    if (matched === null) return null;
    const publisher = Object.freeze({ id: matched });
    this.#authenticated.add(publisher);
    return publisher;
  }

  #validateGrant(publisher, sessionId, role) {
    if (!publisher || !this.#authenticated.has(publisher)) throw new Error('Publisher authentication required');
    if (!identifier(sessionId) || !['publisher', 'receiver'].includes(role)) throw new Error('Invalid TURN session grant');
  }

  issueTurn(publisher, sessionId, { role = 'publisher' } = {}) {
    this.#validateGrant(publisher, sessionId, role);
    if (!this.#turnHost) return null;
    const milliseconds = this.#now();
    if (!Number.isSafeInteger(milliseconds) || milliseconds < 0) throw new Error('Invalid clock');
    const expiresSeconds = Math.floor(milliseconds / 1000) + TTL_SECONDS;
    const username = `${expiresSeconds}:${publisher.id}:${sessionId}:${role}`;
    return {
      username,
      credential: createHmac('sha1', this.#turnSecret).update(username).digest('base64'),
      expiresAt: expiresSeconds * 1000,
    };
  }

  getIceServers(publisher, sessionId, options) {
    const issued = this.issueTurn(publisher, sessionId, options);
    if (!issued) return [];
    const urls = [`turn:${this.#turnHost}:${this.#turnPort}?transport=udp`, `turn:${this.#turnHost}:${this.#turnPort}?transport=tcp`];
    if (this.#turnTlsPort !== null) urls.push(`turns:${this.#turnHost}:${this.#turnTlsPort}?transport=tcp`);
    return [{ urls, username: issued.username, credential: issued.credential }];
  }
}
