'use strict';

const crypto = require('crypto');

const CF_HEADER = 'cf-access-jwt-assertion';
const DEFAULT_CACHE_TTL_MS = 60 * 60 * 1000;
const DEFAULT_CLOCK_SKEW_MS = 60 * 1000;

function splitList(raw) {
  if (Array.isArray(raw)) return raw.map(String).map((s) => s.trim()).filter(Boolean);
  return String(raw || '').split(/[\s,]+/).map((s) => s.trim()).filter(Boolean);
}

function boolFlag(value) {
  const v = String(value || '').trim().toLowerCase();
  return v === '1' || v === 'true' || v === 'yes' || v === 'on';
}

function positiveInt(value, fallback, min = 0, max = Number.MAX_SAFE_INTEGER) {
  const n = Number(value);
  if (!Number.isFinite(n)) return fallback;
  return Math.max(min, Math.min(max, Math.floor(n)));
}

function normalizeIssuer(value) {
  const raw = String(value || '').trim().replace(/\/+$/, '');
  if (!raw) return '';
  return /^https?:\/\//i.test(raw) ? raw : `https://${raw}`;
}

function normalizeTeamDomain(value) {
  const raw = String(value || '').trim();
  if (!raw) return '';
  try {
    const asUrl = /^https?:\/\//i.test(raw) ? new URL(raw) : new URL(`https://${raw}`);
    return asUrl.hostname.toLowerCase();
  } catch {
    return raw.replace(/^https?:\/\//i, '').replace(/\/.*$/, '').toLowerCase();
  }
}

function stripHostPort(value) {
  const raw = String(value || '').trim();
  if (!raw) return '';
  try {
    const asUrl = /^https?:\/\//i.test(raw) ? new URL(raw) : new URL(`https://${raw}`);
    return asUrl.hostname.toLowerCase();
  } catch {}
  if (raw.startsWith('[')) {
    const end = raw.indexOf(']');
    return end > 0 ? raw.slice(1, end).toLowerCase() : raw.toLowerCase();
  }
  return raw.split(':')[0].toLowerCase();
}

function normalizeHosts(raw) {
  const out = [];
  const seen = new Set();
  for (const item of splitList(raw)) {
    const host = stripHostPort(item);
    if (!host || seen.has(host)) continue;
    seen.add(host);
    out.push(host);
  }
  return out;
}

function normalizeEmails(raw) {
  const out = [];
  const seen = new Set();
  for (const item of splitList(raw)) {
    const email = item.toLowerCase();
    if (!email || !email.includes('@') || seen.has(email)) continue;
    seen.add(email);
    out.push(email);
  }
  return out;
}

function pickInput(input, names, fallback) {
  for (const name of names) {
    if (Object.prototype.hasOwnProperty.call(input, name)) return input[name];
  }
  return fallback;
}

function loadCloudflareAccessConfig(env = process.env) {
  const enabled = boolFlag(env.CTM_CF_ACCESS_ENABLED) || boolFlag(env.CTM_CF_ACCESS_REQUIRED);
  const teamDomain = normalizeTeamDomain(env.CTM_CF_ACCESS_TEAM_DOMAIN || '');
  const issuer = normalizeIssuer(env.CTM_CF_ACCESS_ISSUER || (teamDomain ? `https://${teamDomain}` : ''));
  const auds = splitList(env.CTM_CF_ACCESS_AUD || env.CTM_CF_ACCESS_AUDS || '');
  const emails = normalizeEmails(env.CTM_CF_ACCESS_EMAILS || env.CTM_CF_ACCESS_EMAIL || '');
  const hosts = normalizeHosts(env.CTM_CF_ACCESS_HOSTS || env.CTM_CF_ACCESS_HOST || '');
  const jwksUrl = String(env.CTM_CF_ACCESS_JWKS_URL || (issuer ? `${issuer}/cdn-cgi/access/certs` : '')).trim();
  const missing = [];
  if (enabled) {
    if (!issuer) missing.push('CTM_CF_ACCESS_TEAM_DOMAIN');
    if (!auds.length) missing.push('CTM_CF_ACCESS_AUD');
    if (!emails.length) missing.push('CTM_CF_ACCESS_EMAILS');
    if (!jwksUrl) missing.push('CTM_CF_ACCESS_JWKS_URL');
  }
  return {
    enabled,
    configured: enabled && missing.length === 0,
    missing,
    hosts,
    teamDomain,
    issuer,
    auds,
    emails,
    jwksUrl,
    cacheTtlMs: positiveInt(env.CTM_CF_ACCESS_JWKS_CACHE_MS, DEFAULT_CACHE_TTL_MS, 1000, 24 * 60 * 60 * 1000),
    clockSkewMs: positiveInt(env.CTM_CF_ACCESS_CLOCK_SKEW_MS, DEFAULT_CLOCK_SKEW_MS, 0, 10 * 60 * 1000),
  };
}

function publicCloudflareAccessConfig(config = loadCloudflareAccessConfig()) {
  return {
    enabled: !!config.enabled,
    configured: !!config.configured,
    missing: Array.isArray(config.missing) ? config.missing : [],
    hosts: config.hosts || [],
    team_domain: config.teamDomain || '',
    issuer: config.issuer || '',
    aud: (config.auds || []).join(','),
    allowed_emails: config.emails || [],
    jwks_url: config.jwksUrl || '',
    cache_ttl_ms: config.cacheTtlMs || DEFAULT_CACHE_TTL_MS,
    clock_skew_ms: config.clockSkewMs || DEFAULT_CLOCK_SKEW_MS,
  };
}

function normalizeCloudflareAccessSettings(input = {}, currentEnv = process.env) {
  const current = loadCloudflareAccessConfig(currentEnv);
  const enabled = Object.prototype.hasOwnProperty.call(input, 'enabled') ? !!input.enabled : current.enabled;
  const hosts = normalizeHosts(pickInput(input, ['hosts', 'host'], current.hosts || ''));
  const teamDomain = normalizeTeamDomain(pickInput(input, ['team_domain', 'teamDomain'], current.teamDomain || ''));
  const aud = splitList(pickInput(input, ['aud', 'auds'], current.auds || '')).join(',');
  const emails = normalizeEmails(pickInput(input, ['allowed_emails', 'allowedEmails', 'emails'], current.emails || '')).join(',');
  const jwksUrl = String(pickInput(input, ['jwks_url', 'jwksUrl'], current.jwksUrl || '')).trim();
  const env = {
    CTM_CF_ACCESS_ENABLED: enabled ? '1' : '',
    CTM_CF_ACCESS_REQUIRED: '',
    CTM_CF_ACCESS_HOSTS: hosts.join(','),
    CTM_CF_ACCESS_TEAM_DOMAIN: teamDomain,
    CTM_CF_ACCESS_AUD: aud,
    CTM_CF_ACCESS_EMAILS: emails,
    CTM_CF_ACCESS_JWKS_URL: jwksUrl,
  };
  const next = loadCloudflareAccessConfig(env);
  if (enabled && !next.configured) {
    throw new Error(`Cloudflare Access settings missing: ${next.missing.join(', ')}`);
  }
  return { env, config: next };
}

function hostMatchesRequest(req, config) {
  const hosts = config.hosts || [];
  if (!hosts.length) return true;
  const requestHost = stripHostPort(req?.headers?.host || '');
  return hosts.includes(requestHost);
}

function shouldValidateRequest(req, config, options = {}) {
  if (!config.enabled) return { validate: false, reason: 'disabled' };
  if ((config.hosts || []).length) {
    if (!hostMatchesRequest(req, config)) return { validate: false, reason: 'host_not_matched' };
    return { validate: true };
  }
  if (typeof options.isLocalhost === 'function' && options.isLocalhost(req)) return { validate: false, reason: 'loopback' };
  return { validate: true };
}

function base64urlDecode(value) {
  const s = String(value || '').replace(/-/g, '+').replace(/_/g, '/');
  const pad = s.length % 4 ? '='.repeat(4 - (s.length % 4)) : '';
  return Buffer.from(s + pad, 'base64');
}

function decodeJsonPart(value) {
  return JSON.parse(base64urlDecode(value).toString('utf8'));
}

function audienceMatches(aud, allowed) {
  const values = Array.isArray(aud) ? aud.map(String) : [String(aud || '')];
  return values.some((item) => allowed.includes(item));
}

function keyForHeader(keys, header) {
  const kid = String(header?.kid || '');
  if (!kid) return null;
  return (keys || []).find((key) => key
    && key.kid === kid
    && (!key.kty || key.kty === 'RSA')
    && (!key.use || key.use === 'sig')
    && (!key.alg || key.alg === 'RS256')) || null;
}

function verifyTokenWithKeys(token, keys, config, nowMs = Date.now()) {
  const parts = String(token || '').split('.');
  if (parts.length !== 3) return { ok: false, code: 'cloudflare_access_jwt_malformed' };
  let header;
  let payload;
  try {
    header = decodeJsonPart(parts[0]);
    payload = decodeJsonPart(parts[1]);
  } catch {
    return { ok: false, code: 'cloudflare_access_jwt_malformed' };
  }
  if (header.alg !== 'RS256') return { ok: false, code: 'cloudflare_access_alg_invalid' };
  if (payload.iss !== config.issuer) return { ok: false, code: 'cloudflare_access_issuer_invalid' };
  if (!audienceMatches(payload.aud, config.auds || [])) return { ok: false, code: 'cloudflare_access_audience_invalid' };
  const email = String(payload.email || '').toLowerCase();
  if (!email || !(config.emails || []).includes(email)) return { ok: false, code: 'cloudflare_access_email_invalid' };
  const nowSec = nowMs / 1000;
  const skewSec = (config.clockSkewMs || DEFAULT_CLOCK_SKEW_MS) / 1000;
  const exp = Number(payload.exp);
  if (!Number.isFinite(exp)) return { ok: false, code: 'cloudflare_access_exp_missing' };
  if (exp + skewSec < nowSec) return { ok: false, code: 'cloudflare_access_expired' };
  const nbf = payload.nbf == null ? null : Number(payload.nbf);
  if (Number.isFinite(nbf) && nbf - skewSec > nowSec) return { ok: false, code: 'cloudflare_access_not_yet_valid' };
  const iat = payload.iat == null ? null : Number(payload.iat);
  if (Number.isFinite(iat) && iat - skewSec > nowSec) return { ok: false, code: 'cloudflare_access_iat_future' };
  const jwk = keyForHeader(keys, header);
  if (!jwk) return { ok: false, code: 'cloudflare_access_key_unavailable' };
  let verified = false;
  try {
    const publicKey = crypto.createPublicKey({ key: jwk, format: 'jwk' });
    verified = crypto.verify('RSA-SHA256', Buffer.from(`${parts[0]}.${parts[1]}`), publicKey, base64urlDecode(parts[2]));
  } catch {
    verified = false;
  }
  if (!verified) return { ok: false, code: 'cloudflare_access_signature_invalid' };
  return {
    ok: true,
    email,
    issuer: payload.iss,
    aud: payload.aud,
    subject: payload.sub || '',
    kid: header.kid || '',
  };
}

class CloudflareAccessVerifier {
  constructor(options = {}) {
    this._env = options.env || process.env;
    this._fetch = options.fetch || global.fetch;
    this._now = options.now || (() => Date.now());
    this._logger = options.logger || console;
    this._config = options.config || loadCloudflareAccessConfig(this._env);
    this._keys = [];
    this._fetchedAt = 0;
    this._refreshPromise = null;
  }

  configure(env = this._env) {
    this._env = env;
    this._config = loadCloudflareAccessConfig(env);
    this._keys = [];
    this._fetchedAt = 0;
    return this._config;
  }

  publicConfig() {
    return publicCloudflareAccessConfig(this._config);
  }

  _cacheFresh() {
    return !!this._keys.length && this._now() - this._fetchedAt < this._config.cacheTtlMs;
  }

  async refreshJwks() {
    if (!this._config.configured) return { ok: false, code: 'cloudflare_access_not_configured' };
    if (this._refreshPromise) return this._refreshPromise;
    if (typeof this._fetch !== 'function') return { ok: false, code: 'cloudflare_access_fetch_unavailable' };
    this._refreshPromise = (async () => {
      const response = await this._fetch(this._config.jwksUrl, { headers: { accept: 'application/json' } });
      if (!response || !response.ok) throw new Error(`cloudflare_access_jwks_fetch_failed:${response?.status || 0}`);
      const body = await response.json();
      if (!Array.isArray(body?.keys) || body.keys.length === 0) throw new Error('cloudflare_access_jwks_invalid');
      this._keys = body.keys;
      this._fetchedAt = this._now();
      return { ok: true, keys: this._keys.length };
    })().catch((err) => {
      if (this._logger?.warn) this._logger.warn(`[cloudflare-access] JWKS refresh failed: ${err.message}`);
      return { ok: false, code: err.message || 'cloudflare_access_jwks_fetch_failed' };
    }).finally(() => {
      this._refreshPromise = null;
    });
    return this._refreshPromise;
  }

  prime() {
    if (!this._config.enabled || !this._config.configured) return Promise.resolve({ ok: true, skipped: true });
    return this.refreshJwks();
  }

  verifyTokenSync(token) {
    if (!this._config.configured) return { ok: false, code: 'cloudflare_access_not_configured' };
    if (!this._cacheFresh()) return { ok: false, code: 'cloudflare_access_key_unavailable' };
    return verifyTokenWithKeys(token, this._keys, this._config, this._now());
  }

  async verifyToken(token) {
    if (!this._config.configured) return { ok: false, code: 'cloudflare_access_not_configured' };
    if (!this._cacheFresh()) await this.refreshJwks();
    let decision = this.verifyTokenSync(token);
    if (decision.ok) return decision;
    if (decision.code === 'cloudflare_access_key_unavailable' || decision.code === 'cloudflare_access_signature_invalid') {
      await this.refreshJwks();
      decision = this.verifyTokenSync(token);
    }
    return decision;
  }

  verifyRequestSync(req, options = {}) {
    const gate = shouldValidateRequest(req, this._config, options);
    if (!gate.validate) return { ok: true, skipped: gate.reason };
    if (!this._config.configured) return { ok: false, code: 'cloudflare_access_not_configured', missing: this._config.missing };
    const token = req?.headers?.[CF_HEADER] || req?.headers?.['Cf-Access-Jwt-Assertion'];
    if (!token) return { ok: false, code: 'cloudflare_access_header_missing' };
    const decision = this.verifyTokenSync(token);
    if (!decision.ok && decision.code === 'cloudflare_access_key_unavailable') {
      this.refreshJwks().catch(() => {});
    }
    return decision;
  }

  async verifyRequest(req, options = {}) {
    const gate = shouldValidateRequest(req, this._config, options);
    if (!gate.validate) return { ok: true, skipped: gate.reason };
    if (!this._config.configured) return { ok: false, code: 'cloudflare_access_not_configured', missing: this._config.missing };
    const token = req?.headers?.[CF_HEADER] || req?.headers?.['Cf-Access-Jwt-Assertion'];
    if (!token) return { ok: false, code: 'cloudflare_access_header_missing' };
    return this.verifyToken(token);
  }
}

function createCloudflareAccessVerifier(options = {}) {
  return new CloudflareAccessVerifier(options);
}

module.exports = {
  CF_HEADER,
  CloudflareAccessVerifier,
  createCloudflareAccessVerifier,
  loadCloudflareAccessConfig,
  normalizeCloudflareAccessSettings,
  publicCloudflareAccessConfig,
  shouldValidateRequest,
  verifyTokenWithKeys,
};
