import * as plugins from '../../plugins.js';
import { logger } from '../../core/utils/logger.js';

// Rust bridge and helpers
import { RustProxyBridge } from './rust-proxy-bridge.js';
import { RoutePreprocessor } from './route-preprocessor.js';
import { SocketHandlerServer } from './socket-handler-server.js';
import { DatagramHandlerServer } from './datagram-handler-server.js';
import { ChallengeProviderRelayServer } from './challenge-provider-relay-server.js';
import { RustMetricsAdapter } from './rust-metrics-adapter.js';

// Route management
import { SharedRouteManager as RouteManager } from '../../core/routing/route-manager.js';
import { RouteValidator } from './utils/route-validator.js';
import { buildRustProxyOptions } from './utils/rust-config.js';
import { generateDefaultCertificate } from './utils/default-cert-generator.js';
import { Mutex } from './utils/mutex.js';
import { ConcurrencySemaphore } from './utils/concurrency-semaphore.js';

// Types
import type { ISmartProxyOptions, ISmartProxySecurityPolicy, TSmartProxyCertProvisionObject, IAcmeOptions, ICertProvisionEventComms, ICertificateIssuedEvent, ICertificateFailedEvent, ISmartProxyCertProvisionCertificate, IActiveConnectionSnapshot, IActiveConnectionSnapshotOptions } from './models/interfaces.js';
import type { IRouteConfig } from './models/route-types.js';
import type { IMetrics } from './models/metrics-types.js';
import type { IRustCertificateStatus, IRustChallengeOptions, IRustProxyOptions, IRustStatistics } from './models/rust-types.js';

type TChallengeProvider = plugins.smartchallenge.IChallengeProvider;

/**
 * SmartProxy - Rust-backed proxy engine with TypeScript configuration API.
 *
 * All networking (TCP, TLS, HTTP reverse proxy, connection management, security)
 * is handled by the Rust binary. TypeScript is only:
 * - The npm module interface (types, route helpers)
 * - The thin IPC wrapper (this class)
 * - Socket-handler callback relay (for JS-defined handlers)
 * - Certificate provisioning callbacks (certProvisionFunction)
 */
export class SmartProxy extends plugins.EventEmitter {
  public settings: ISmartProxyOptions;
  public routeManager: RouteManager;

  private bridge: RustProxyBridge;
  private preprocessor: RoutePreprocessor;
  private socketHandlerServer: SocketHandlerServer | null = null;
  private datagramHandlerServer: DatagramHandlerServer | null = null;
  private challengeProviderRelayServer: ChallengeProviderRelayServer | null = null;
  private challengeProviders = new Map<string, TChallengeProvider>();
  private challengeRuntimeOptions?: IRustChallengeOptions;
  private metricsAdapter: RustMetricsAdapter;
  private nftablesManager: InstanceType<typeof plugins.smartnftables.SmartNftables> | null = null;
  private routeUpdateLock: Mutex;
  private stopping = false;
  private certProvisionPromise: Promise<void> | null = null;
  private certProvisionQueued = false;
  /** domain -> certificate validUntil (epoch ms) for certs currently loaded in Rust. */
  private certValidity = new Map<string, number>();
  /** domain -> timestamp of the last failed provisioning attempt (cooldown anchor). */
  private certFailureAt = new Map<string, number>();
  private renewalTimer: NodeJS.Timeout | null = null;
  /** updateRoutes call timestamps within the last minute, for loop detection. */
  private routeUpdateTimestamps: number[] = [];
  private lastRouteUpdateRateWarnAt = 0;

  constructor(settingsArg: ISmartProxyOptions) {
    super();

    // Apply defaults
    this.settings = {
      ...settingsArg,
      initialDataTimeout: settingsArg.initialDataTimeout || 60_000,
      socketTimeout: settingsArg.socketTimeout || 60_000,
      maxConnectionLifetime: settingsArg.maxConnectionLifetime || 3_600_000,
      inactivityTimeout: settingsArg.inactivityTimeout || 75_000,
      gracefulShutdownTimeout: settingsArg.gracefulShutdownTimeout || 30_000,
      maxConnectionsPerIP: settingsArg.maxConnectionsPerIP || 100,
      connectionRateLimitPerMinute: settingsArg.connectionRateLimitPerMinute || 300,
      keepAliveTreatment: settingsArg.keepAliveTreatment || 'standard',
      keepAliveInactivityMultiplier: settingsArg.keepAliveInactivityMultiplier || 4,
      extendedKeepAliveLifetime: settingsArg.extendedKeepAliveLifetime || 3_600_000,
    };

    // Normalize ACME options
    if (this.settings.acme) {
      if (this.settings.acme.accountEmail && !this.settings.acme.email) {
        this.settings.acme.email = this.settings.acme.accountEmail;
      }
      this.settings.acme = {
        enabled: this.settings.acme.enabled !== false,
        port: this.settings.acme.port || 80,
        email: this.settings.acme.email,
        useProduction: this.settings.acme.useProduction || false,
        renewThresholdDays: this.settings.acme.renewThresholdDays || 30,
        autoRenew: this.settings.acme.autoRenew !== false,
        skipConfiguredCerts: this.settings.acme.skipConfiguredCerts || false,
        renewCheckIntervalHours: this.settings.acme.renewCheckIntervalHours || 24,
        routeForwards: this.settings.acme.routeForwards || [],
        ...this.settings.acme,
      };
    }

    // Validate routes
    if (this.settings.routes?.length) {
      const validation = RouteValidator.validateRoutes(this.settings.routes);
      if (!validation.valid) {
        RouteValidator.logValidationErrors(validation.errors);
        throw new Error(`Initial route validation failed: ${validation.errors.size} route(s) have errors`);
      }
    }

    // Create logger adapter
    const loggerAdapter = {
      debug: (message: string, data?: any) => logger.log('debug', message, data),
      info: (message: string, data?: any) => logger.log('info', message, data),
      warn: (message: string, data?: any) => logger.log('warn', message, data),
      error: (message: string, data?: any) => logger.log('error', message, data),
    };

    // Initialize components
    this.routeManager = new RouteManager({
      logger: loggerAdapter,
      enableDetailedLogging: this.settings.enableDetailedLogging,
      routes: this.settings.routes,
    });

    this.bridge = new RustProxyBridge();
    this.preprocessor = new RoutePreprocessor();
    this.metricsAdapter = new RustMetricsAdapter(
      this.bridge,
      this.settings.metrics?.sampleIntervalMs ?? 1000
    );
    this.routeUpdateLock = new Mutex();
  }

  /**
   * Register a runtime challenge provider family. Routes reference providerId + challengeType;
   * deployment wiring and provider secrets stay outside route configs.
   */
  public registerChallengeProvider(providerId: string, provider: TChallengeProvider): void {
    if (!providerId || typeof providerId !== 'string') {
      throw new Error('Challenge providerId must be a non-empty string');
    }
    this.challengeProviders.set(providerId, provider);
  }

  /**
   * Start the proxy.
   * Spawns the Rust binary, configures socket relay if needed, sends routes, handles cert provisioning.
   */
  public async start(): Promise<void> {
    await this.validateChallengeRoutes(this.settings.routes);

    let didSpawn = false;
    try {
      // Spawn Rust binary
      const spawned = await this.bridge.spawn();
      if (!spawned) {
        throw new Error(
          'RustProxy binary not found. Set SMARTPROXY_RUST_BINARY env var, install the platform package, ' +
          'or build locally with: pnpm build'
        );
      }
      didSpawn = true;

      // Handle unexpected exit (only emits error if not intentionally stopping)
      this.bridge.removeAllListeners('exit');
      this.bridge.on('exit', (code: number | null, signal: string | null) => {
        if (this.stopping) return;
        logger.log('error', `RustProxy exited unexpectedly (code=${code}, signal=${signal})`, { component: 'smart-proxy' });
        this.emit('error', new Error(`RustProxy exited (code=${code}, signal=${signal})`));
      });

      const hasChallengeRoutes = this.hasChallengeRoutes(this.settings.routes);
      if (hasChallengeRoutes) {
        await this.ensureChallengeProviderRelay();
      }

      // Check if any routes need TS-side handling (socket handlers, dynamic functions)
      const hasHandlerRoutes = this.settings.routes.some(
        (r) =>
          (r.action.type === 'socket-handler' && r.action.socketHandler) ||
          r.action.targets?.some((t) => typeof t.host === 'function' || typeof t.port === 'function')
      );

      // Start socket handler relay server (but don't tell Rust yet - proxy not started)
      if (hasHandlerRoutes) {
        this.socketHandlerServer = new SocketHandlerServer(this.preprocessor);
        await this.socketHandlerServer.start();
      }

      // Check if any routes need datagram handler relay (UDP socket-handler routes)
      const hasDatagramHandlers = this.settings.routes.some(
        (r) => r.action.type === 'socket-handler' && r.action.datagramHandler
      );
      if (hasDatagramHandlers) {
        const dgPath = `/tmp/smartproxy-dgram-relay-${process.pid}.sock`;
        this.datagramHandlerServer = new DatagramHandlerServer(dgPath, this.preprocessor);
        await this.datagramHandlerServer.start();
      }

      // Preprocess routes (strip JS functions, convert socket-handler routes)
      const rustRoutes = this.preprocessor.preprocessForRust(this.settings.routes);

      // When certProvisionFunction handles cert provisioning,
      // disable Rust's built-in ACME to prevent race condition.
      let acmeForRust = this.settings.acme;
      if (this.settings.certProvisionFunction && acmeForRust?.enabled) {
        acmeForRust = { ...acmeForRust, enabled: false };
        logger.log('info', 'Rust ACME disabled — certProvisionFunction will handle certificate provisioning', { component: 'smart-proxy' });
      }

      // Build Rust config
      const config = this.buildRustConfig(rustRoutes, acmeForRust);

      // Start the Rust proxy
      await this.bridge.startProxy(config);

      // Now that Rust proxy is running, configure socket handler relay
      if (this.socketHandlerServer) {
        await this.bridge.setSocketHandlerRelay(this.socketHandlerServer.getSocketPath());
      }

      // Configure challenge provider relay. The path is also present in startup config,
      // but this hot setter keeps runtime changes explicit and mirrors other relays.
      if (this.challengeProviderRelayServer) {
        await this.bridge.setChallengeProviderRelay(
          this.challengeProviderRelayServer.getSocketPath(),
          this.challengeRuntimeOptions,
        );
      }

      // Configure datagram handler relay
      if (this.datagramHandlerServer) {
        await this.bridge.setDatagramHandlerRelay(this.datagramHandlerServer.getSocketPath());
      }

      // Load default self-signed fallback certificate (domain: '*')
      if (!this.settings.disableDefaultCert) {
        try {
          const defaultCert = generateDefaultCertificate();
          await this.bridge.loadCertificate('*', defaultCert.cert, defaultCert.key);
          logger.log('info', 'Default self-signed fallback certificate loaded', { component: 'smart-proxy' });
        } catch (err) {
          logger.log('warn', `Failed to generate default certificate: ${(err as Error).message}`, { component: 'smart-proxy' });
        }
      }

      // Load consumer-stored certificates. Their parsed validity feeds the
      // skip-valid filter, so unexpired stored certs are not re-provisioned.
      if (this.settings.certStore) {
        try {
          const stored = await this.settings.certStore.loadAll();
          for (const entry of stored) {
            await this.bridge.loadCertificate(entry.domain, entry.publicKey, entry.privateKey, entry.ca);
            this.recordCertValidity(entry.domain, entry.publicKey);
          }
          logger.log('info', `Loaded ${stored.length} certificate(s) from consumer store`, { component: 'smart-proxy' });
        } catch (err) {
          logger.log('warn', `Failed to load certificates from consumer store: ${(err as Error).message}`, { component: 'smart-proxy' });
        }
      }

      // Apply NFTables rules for routes using nftables forwarding engine
      await this.applyNftablesRules(this.settings.routes);

      // Start metrics polling BEFORE cert provisioning — the Rust engine is already
      // running and accepting connections, so metrics should be available immediately.
      // Cert provisioning can hang indefinitely (e.g. DNS-01 ACME timeouts) and must
      // not block metrics collection.
      this.metricsAdapter.startPolling();

      logger.log('info', 'SmartProxy started (Rust engine)', { component: 'smart-proxy' });

      // Fire-and-forget cert provisioning — Rust engine is already running and serving traffic.
      // Events (certificate-issued / certificate-failed) fire independently per domain.
      // Store-loaded certs are skipped via their recorded validity (not blindly),
      // so near-expiry stored certs renew right at startup.
      this.kickCertProvisioning();

      // Periodic renewal sweeps. Without a timer, renewals would only happen
      // when something calls updateRoutes(); near-expiry domains pass the
      // skip-valid filter naturally. The delay is clamped to Node's max timer
      // value — beyond 2^31-1 ms setInterval degrades to a 1ms hot loop.
      const renewCheckIntervalHours = this.settings.acme?.renewCheckIntervalHours || 24;
      const renewIntervalMs = Math.min(renewCheckIntervalHours * 3_600_000, 2 ** 31 - 1);
      this.renewalTimer = setInterval(() => {
        this.kickCertProvisioning();
      }, renewIntervalMs);
      this.renewalTimer.unref?.();
    } catch (err) {
      await this.cleanupRuntimeResourcesAfterStartFailure(didSpawn);
      throw err;
    }
  }

  /**
   * Stop the proxy.
   */
  public async stop(): Promise<void> {
    logger.log('info', 'SmartProxy shutting down...', { component: 'smart-proxy' });
    this.stopping = true;

    // Stop periodic renewal sweeps and drop any queued follow-up sweep
    if (this.renewalTimer) {
      clearInterval(this.renewalTimer);
      this.renewalTimer = null;
    }
    this.certProvisionQueued = false;

    // Wait for in-flight cert provisioning to bail out (it checks this.stopping)
    if (this.certProvisionPromise) {
      await this.certProvisionPromise;
      this.certProvisionPromise = null;
    }

    // Clean up NFTables rules
    if (this.nftablesManager) {
      await this.nftablesManager.cleanup();
      this.nftablesManager = null;
    }

    // Stop metrics polling
    this.metricsAdapter.stopPolling();

    // Remove exit listener before killing to avoid spurious error events
    this.bridge.removeAllListeners('exit');

    // Stop Rust proxy
    try {
      await this.bridge.stopProxy();
    } catch {
      // Ignore if already stopped
    }
    this.bridge.kill();

    // Stop socket handler relay
    if (this.socketHandlerServer) {
      await this.socketHandlerServer.stop();
      this.socketHandlerServer = null;
    }

    // Stop datagram handler relay
    if (this.datagramHandlerServer) {
      await this.datagramHandlerServer.stop();
      this.datagramHandlerServer = null;
    }

    // Stop challenge provider relay
    if (this.challengeProviderRelayServer) {
      await this.challengeProviderRelayServer.stop();
      this.challengeProviderRelayServer = null;
    }
    this.challengeRuntimeOptions = undefined;

    logger.log('info', 'SmartProxy shutdown complete.', { component: 'smart-proxy' });
  }

  /**
   * Update routes atomically.
   */
  public async updateRoutes(newRoutes: IRouteConfig[]): Promise<void> {
    this.trackRouteUpdateRate();
    await this.routeUpdateLock.runExclusive(async () => {
      await this.validateChallengeRoutes(newRoutes);

      // Validate before starting relays so failed route updates do not leak runtime resources.
      const validation = RouteValidator.validateRoutes(newRoutes);
      if (!validation.valid) {
        RouteValidator.logValidationErrors(validation.errors);
        throw new Error(`Route validation failed: ${validation.errors.size} route(s) have errors`);
      }

      const hasChallengeRoutes = this.hasChallengeRoutes(newRoutes);
      const challengeRelayWasStarted = Boolean(this.challengeProviderRelayServer);

      // Preprocess for Rust
      const rustRoutes = this.preprocessor.preprocessForRust(newRoutes);

      try {
        if (hasChallengeRoutes) {
          await this.ensureChallengeProviderRelay();
          await this.bridge.setChallengeProviderRelay(
            this.challengeProviderRelayServer!.getSocketPath(),
            this.challengeRuntimeOptions,
          );
        }

        // Send to Rust
        await this.bridge.updateRoutes(rustRoutes);
      } catch (err) {
        if (hasChallengeRoutes && !challengeRelayWasStarted && this.challengeProviderRelayServer) {
          await this.bridge.setChallengeProviderRelay('').catch(() => undefined);
          await this.challengeProviderRelayServer.stop();
          this.challengeProviderRelayServer = null;
          this.challengeRuntimeOptions = undefined;
        }
        throw err;
      }

      // Update local route manager
      this.routeManager.updateRoutes(newRoutes);

      // Update socket handler relay if handler routes changed
      const hasHandlerRoutes = newRoutes.some(
        (r) =>
          (r.action.type === 'socket-handler' && r.action.socketHandler) ||
          r.action.targets?.some((t) => typeof t.host === 'function' || typeof t.port === 'function')
      );

      if (hasHandlerRoutes && !this.socketHandlerServer) {
        this.socketHandlerServer = new SocketHandlerServer(this.preprocessor);
        await this.socketHandlerServer.start();
        await this.bridge.setSocketHandlerRelay(this.socketHandlerServer.getSocketPath());
      } else if (!hasHandlerRoutes && this.socketHandlerServer) {
        await this.socketHandlerServer.stop();
        this.socketHandlerServer = null;
      }

      // Update datagram handler relay if datagram handler routes changed
      const hasDatagramHandlers = newRoutes.some(
        (r) => r.action.type === 'socket-handler' && r.action.datagramHandler
      );

      if (hasDatagramHandlers && !this.datagramHandlerServer) {
        const dgPath = `/tmp/smartproxy-dgram-relay-${process.pid}.sock`;
        this.datagramHandlerServer = new DatagramHandlerServer(dgPath, this.preprocessor);
        await this.datagramHandlerServer.start();
        await this.bridge.setDatagramHandlerRelay(this.datagramHandlerServer.getSocketPath());
      } else if (!hasDatagramHandlers && this.datagramHandlerServer) {
        await this.datagramHandlerServer.stop();
        this.datagramHandlerServer = null;
      }

      if (!hasChallengeRoutes && this.challengeProviderRelayServer) {
        await this.bridge.setChallengeProviderRelay('').catch((err) => {
          logger.log('warn', `Failed to clear challenge provider relay in Rust: ${(err as Error).message}`, { component: 'smart-proxy' });
        });
        await this.challengeProviderRelayServer.stop();
        this.challengeProviderRelayServer = null;
        this.challengeRuntimeOptions = undefined;
      }

      // Update NFTables rules
      if (this.nftablesManager) {
        await this.nftablesManager.cleanup();
        this.nftablesManager = null;
      }
      await this.applyNftablesRules(newRoutes);

      // Update stored routes
      this.settings.routes = newRoutes;

      logger.log('info', `Routes updated (${newRoutes.length} routes)`, { component: 'smart-proxy' });
    });

    // Fire-and-forget cert provisioning outside the mutex — routes are already updated,
    // cert provisioning doesn't need the route update lock and may be slow.
    // Sweeps are coalesced: at most one in flight plus one queued follow-up,
    // and domains with valid loaded certs are skipped entirely.
    this.kickCertProvisioning();
  }

  /**
   * Update the global ingress security policy without changing routes.
   * The Rust engine applies this before route selection and backend connection.
   */
  public async updateSecurityPolicy(policy: ISmartProxySecurityPolicy): Promise<void> {
    this.settings.securityPolicy = policy;
    await this.bridge.setSecurityPolicy(policy);
  }

  /**
   * Provision a certificate for a named route.
   */
  public async provisionCertificate(routeName: string): Promise<void> {
    await this.bridge.provisionCertificate(routeName);
  }

  /**
   * Force renewal of a certificate.
   */
  public async renewCertificate(routeName: string): Promise<void> {
    await this.bridge.renewCertificate(routeName);
  }

  /**
   * Get certificate status for a route (async - calls Rust).
   */
  public async getCertificateStatus(routeName: string): Promise<IRustCertificateStatus | null> {
    return this.bridge.getCertificateStatus(routeName);
  }

  /**
   * Get the metrics interface.
   */
  public getMetrics(): IMetrics {
    return this.metricsAdapter;
  }

  /**
   * Get sanitized active connection snapshots from the Rust engine.
   */
  public async getActiveConnectionSnapshots(
    options: IActiveConnectionSnapshotOptions = {},
  ): Promise<IActiveConnectionSnapshot[]> {
    return this.bridge.getActiveConnectionSnapshots(options);
  }

  /**
   * Get statistics (async - calls Rust).
   */
  public async getStatistics(): Promise<IRustStatistics> {
    return this.bridge.getStatistics();
  }

  /**
   * Add a listening port at runtime.
   */
  public async addListeningPort(port: number): Promise<void> {
    await this.bridge.addListeningPort(port);
  }

  /**
   * Remove a listening port at runtime.
   */
  public async removeListeningPort(port: number): Promise<void> {
    await this.bridge.removeListeningPort(port);
  }

  /**
   * Get all currently listening ports (async - calls Rust).
   */
  public async getListeningPorts(): Promise<number[]> {
    if (!this.bridge.running) return [];
    return this.bridge.getListeningPorts();
  }

  /**
   * Get eligible domains for ACME certificates (sync - reads local routes).
   */
  public getEligibleDomainsForCertificates(): string[] {
    const domains: string[] = [];
    for (const route of this.settings.routes || []) {
      if (!route.match.domains) continue;
      if (
        route.action.type !== 'forward' ||
        !route.action.tls ||
        route.action.tls.mode === 'passthrough' ||
        route.action.tls.certificate !== 'auto'
      )
        continue;

      const routeDomains = Array.isArray(route.match.domains) ? route.match.domains : [route.match.domains];
      const eligible = routeDomains.filter((d) => !d.includes('*') && this.isValidDomain(d));
      domains.push(...eligible);
    }
    return domains;
  }

  /**
   * Get NFTables status.
   */
  public getNfTablesStatus(): plugins.smartnftables.INftStatus | null {
    return this.nftablesManager?.status() ?? null;
  }

  // --- Private helpers ---

  private async cleanupRuntimeResourcesAfterStartFailure(didSpawn: boolean): Promise<void> {
    this.metricsAdapter.stopPolling();

    if (this.nftablesManager) {
      await this.nftablesManager.cleanup().catch((err) => {
        logger.log('warn', `Failed to clean NFTables after start failure: ${(err as Error).message}`, { component: 'smart-proxy' });
      });
      this.nftablesManager = null;
    }

    if (this.socketHandlerServer) {
      await this.socketHandlerServer.stop().catch((err) => {
        logger.log('warn', `Failed to stop socket handler relay after start failure: ${(err as Error).message}`, { component: 'smart-proxy' });
      });
      this.socketHandlerServer = null;
    }

    if (this.datagramHandlerServer) {
      await this.datagramHandlerServer.stop().catch((err) => {
        logger.log('warn', `Failed to stop datagram handler relay after start failure: ${(err as Error).message}`, { component: 'smart-proxy' });
      });
      this.datagramHandlerServer = null;
    }

    if (this.challengeProviderRelayServer) {
      await this.challengeProviderRelayServer.stop().catch((err) => {
        logger.log('warn', `Failed to stop challenge provider relay after start failure: ${(err as Error).message}`, { component: 'smart-proxy' });
      });
      this.challengeProviderRelayServer = null;
      this.challengeRuntimeOptions = undefined;
    }

    if (didSpawn) {
      this.bridge.removeAllListeners('exit');
      await this.bridge.stopProxy().catch(() => undefined);
      this.bridge.kill();
    }
  }

  /**
   * Apply NFTables rules for routes using the nftables forwarding engine.
   */
  private async applyNftablesRules(routes: IRouteConfig[]): Promise<void> {
    const nftRoutes = routes.filter(r => r.action.forwardingEngine === 'nftables');
    if (nftRoutes.length === 0) return;

    const tableName = nftRoutes.find(r => r.action.nftables?.tableName)?.action.nftables?.tableName ?? 'smartproxy';
    const nft = new plugins.smartnftables.SmartNftables({ tableName });
    await nft.initialize();

    for (const route of nftRoutes) {
      const routeId = route.name || 'unnamed';
      const targets = route.action.targets;
      if (!targets) continue;

      const nftOpts = route.action.nftables;
      const protocol: plugins.smartnftables.TNftProtocol = (nftOpts?.protocol as any) ?? 'tcp';
      const preserveSourceIP = nftOpts?.preserveSourceIP ?? false;

      const ports = Array.isArray(route.match.ports)
        ? route.match.ports.flatMap(p => typeof p === 'number' ? [p] : [])
        : typeof route.match.ports === 'number' ? [route.match.ports] : [];

      for (const target of targets) {
        const targetHost = Array.isArray(target.host) ? target.host[0] : target.host;
        if (typeof targetHost !== 'string') continue;

        for (const sourcePort of ports) {
          const targetPort = typeof target.port === 'number' ? target.port : sourcePort;
          await nft.nat.addPortForwarding(`${routeId}-${sourcePort}-${targetPort}`, {
            sourcePort,
            targetHost,
            targetPort,
            protocol,
            preserveSourceIP,
          });
        }
      }
    }

    this.nftablesManager = nft;
    logger.log('info', `Applied NFTables rules for ${nftRoutes.length} route(s)`, { component: 'smart-proxy' });
  }

  /**
   * Build the Rust configuration object from TS settings.
   */
  private buildRustConfig(routes: IRustProxyOptions['routes'], acmeOverride?: IAcmeOptions): IRustProxyOptions {
    return buildRustProxyOptions(this.settings, routes, acmeOverride, this.challengeRuntimeOptions);
  }

  private hasChallengeRoutes(routes: IRouteConfig[]): boolean {
    return routes.some((route) => Boolean(
      route.security?.challenge
      || (route.security?.rateLimit?.onExceeded?.type === 'challenge' && route.security.rateLimit.onExceeded.challenge)
    ));
  }

  private async ensureChallengeProviderRelay(): Promise<void> {
    const runtimeOptions = this.settings.challenge || {};
    if (!this.challengeProviderRelayServer) {
      this.challengeProviderRelayServer = new ChallengeProviderRelayServer(this.challengeProviders, {
        providerTimeoutMs: runtimeOptions.relayTimeoutMs ?? 5_000,
      });
      await this.challengeProviderRelayServer.start();
    }
    if (!this.challengeRuntimeOptions) {
      this.challengeRuntimeOptions = {
        cookieSigningKey: runtimeOptions.cookieSigningKey || plugins.crypto.randomBytes(32).toString('base64url'),
        pendingCookieName: runtimeOptions.pendingCookieName || '__smartproxy_challenge_pending',
        clearanceCookieName: runtimeOptions.clearanceCookieName || '__smartproxy_clearance',
        reservedPathPrefix: runtimeOptions.reservedPathPrefix || '/.well-known/smartproxy-challenge',
        relaySocketPath: this.challengeProviderRelayServer.getSocketPath(),
        relayTimeoutMs: runtimeOptions.relayTimeoutMs ?? 5_000,
        pendingTtlSeconds: runtimeOptions.pendingTtlSeconds ?? 300,
      };
    } else {
      this.challengeRuntimeOptions = {
        ...this.challengeRuntimeOptions,
        relaySocketPath: this.challengeProviderRelayServer.getSocketPath(),
      };
    }
  }

  private async validateChallengeRoutes(routes: IRouteConfig[]): Promise<void> {
    const manifestCache = new Map<string, plugins.smartchallenge.IChallengeProviderManifest>();
    const errors: string[] = [];

    for (const route of routes) {
      const routeName = route.name || route.id || 'unnamed route';
      const challengeEntries: Array<{
        path: string;
        challenge: plugins.smartchallenge.IRouteChallengeConfig;
      }> = [];

      if (route.security?.challenge) {
        challengeEntries.push({
          path: 'security.challenge',
          challenge: route.security.challenge,
        });
      }

      const rateLimitChallenge = route.security?.rateLimit?.onExceeded?.type === 'challenge'
        ? route.security.rateLimit.onExceeded.challenge
        : undefined;
      if (rateLimitChallenge) {
        challengeEntries.push({
          path: 'security.rateLimit.onExceeded.challenge',
          challenge: rateLimitChallenge,
        });
      }

      if (challengeEntries.length === 0) continue;

      if (!route.name && !route.id) {
        errors.push(`${routeName}: challenge routes must set a stable route name or id`);
      }
      if (route.action.type !== 'forward') {
        errors.push(`${routeName}: challenge routes must use forward actions`);
      }
      if (route.action.forwardingEngine === 'nftables') {
        errors.push(`${routeName}: challenge routes cannot use nftables forwarding`);
      }
      if (route.action.tls?.mode === 'passthrough') {
        errors.push(`${routeName}: challenge routes cannot use TLS passthrough`);
      }
      for (const target of route.action.targets || []) {
        if (typeof target.host === 'function' || typeof target.port === 'function') {
          errors.push(`${routeName}: challenge routes cannot use dynamic target host or port functions`);
        }
        if (target.tls?.mode === 'passthrough') {
          errors.push(`${routeName}: challenge routes cannot use target TLS passthrough`);
        }
      }
      const hasHttp3Transport = route.match.transport === 'all'
        && route.action.udp?.quic?.enableHttp3 === true;
      if (route.match.transport === 'udp') {
        errors.push(`${routeName}: challenge routes cannot use pure UDP transport`);
      }
      if (route.match.transport === 'all' && !hasHttp3Transport) {
        errors.push(`${routeName}: challenge routes using transport 'all' must enable HTTP/3 with action.udp.quic.enableHttp3`);
      }
      if (route.match.protocol !== 'http') {
        errors.push(`${routeName}: challenge routes must set match.protocol to 'http'`);
      }

      for (const { path, challenge } of challengeEntries) {
        if (!challenge.providerId || typeof challenge.providerId !== 'string') {
          errors.push(`${routeName}: ${path}.providerId must be a non-empty string`);
        }
        if (!challenge.challengeType || typeof challenge.challengeType !== 'string') {
          errors.push(`${routeName}: ${path}.challengeType must be a non-empty string`);
        }
        this.validateChallengeIntentShape(challenge, errors, routeName, path);
        this.collectForbiddenChallengeKeys(challenge, path, errors, routeName);

        const provider = this.challengeProviders.get(challenge.providerId);
        if (!provider) {
          errors.push(`${routeName}: challenge provider '${challenge.providerId}' is not registered`);
          continue;
        }
        let manifest = manifestCache.get(challenge.providerId);
        if (!manifest) {
          manifest = await provider.getManifest();
          manifestCache.set(challenge.providerId, manifest);
        }
        if (!manifest.challengeTypes.some((type) => type.challengeType === challenge.challengeType)) {
          errors.push(`${routeName}: challenge provider '${challenge.providerId}' does not support type '${challenge.challengeType}'`);
        }
      }
    }

    if (errors.length > 0) {
      throw new Error(`Challenge route validation failed:\n${errors.map((error) => `- ${error}`).join('\n')}`);
    }
  }

  private validateChallengeIntentShape(
    challengeArg: unknown,
    errors: string[],
    routeName: string,
    challengePath = 'security.challenge',
  ): void {
    if (!this.isPlainRecord(challengeArg)) {
      errors.push(`${routeName}: ${challengePath} must be an object`);
      return;
    }

    this.validateAllowedChallengeKeys(
      challengeArg,
      challengePath,
      new Set(['providerId', 'challengeType', 'policyRef', 'settings', 'applyTo', 'clearance']),
      errors,
      routeName,
    );

    const settings = challengeArg.settings;
    if (settings !== undefined && !this.isPlainRecord(settings)) {
      errors.push(`${routeName}: ${challengePath}.settings must be an object when set`);
    }

    const applyTo = challengeArg.applyTo;
    if (applyTo !== undefined) {
      if (!this.isPlainRecord(applyTo)) {
        errors.push(`${routeName}: ${challengePath}.applyTo must be an object when set`);
      } else {
        this.validateAllowedChallengeKeys(
          applyTo,
          `${challengePath}.applyTo`,
          new Set(['methods', 'browserNavigationsOnly', 'includePaths', 'excludePaths']),
          errors,
          routeName,
        );
      }
    }

    const clearance = challengeArg.clearance;
    if (clearance !== undefined) {
      if (!this.isPlainRecord(clearance)) {
        errors.push(`${routeName}: ${challengePath}.clearance must be an object when set`);
      } else {
        this.validateAllowedChallengeKeys(
          clearance,
          `${challengePath}.clearance`,
          new Set(['ttlSeconds', 'bindToHost', 'bindToRoute', 'bindToIp']),
          errors,
          routeName,
        );
      }
    }
  }

  private validateAllowedChallengeKeys(
    valueArg: Record<string, unknown>,
    pathArg: string,
    allowedKeysArg: Set<string>,
    errors: string[],
    routeName: string,
  ): void {
    for (const key of Object.keys(valueArg)) {
      if (!allowedKeysArg.has(key)) {
        errors.push(`${routeName}: ${pathArg}.${key} is not part of the persisted challenge intent shape`);
      }
    }
  }

  private isPlainRecord(valueArg: unknown): valueArg is Record<string, unknown> {
    return Boolean(valueArg)
      && typeof valueArg === 'object'
      && !Array.isArray(valueArg);
  }

  private collectForbiddenChallengeKeys(value: unknown, path: string, errors: string[], routeName: string): void {
    if (!value || typeof value !== 'object') return;
    const forbiddenKeyPattern = /(endpoint|url|uri|host|hostname|address|ipAddress|port|secret|token|password|credential|apiKey|apikey|accessKey|privateKey|keyfile|certfile|deployment|container|socketPath)/i;
    for (const [key, nestedValue] of Object.entries(value as Record<string, unknown>)) {
      const childPath = `${path}.${key}`;
      if (forbiddenKeyPattern.test(key) && !['providerId', 'challengeType', 'policyRef', 'bindToHost', 'bindToIp', 'includePaths', 'excludePaths'].includes(key)) {
        errors.push(`${routeName}: ${childPath} looks like runtime wiring or secret material and must not be stored in route config`);
      }
      if (typeof nestedValue === 'string' && this.looksLikeRuntimeChallengeValue(nestedValue) && !['includePaths', 'excludePaths'].includes(key)) {
        errors.push(`${routeName}: ${childPath} contains endpoint/IP-style runtime wiring and must not be stored in route config`);
      }
      if (nestedValue && typeof nestedValue === 'object') {
        this.collectForbiddenChallengeKeys(nestedValue, childPath, errors, routeName);
      }
    }
  }

  private looksLikeRuntimeChallengeValue(valueArg: string): boolean {
    return /^https?:\/\//i.test(valueArg)
      || /^wss?:\/\//i.test(valueArg)
      || /(?:^|\b)(?:\d{1,3}\.){3}\d{1,3}(?::\d+)?(?:\b|$)/.test(valueArg)
      || /^[a-z0-9.-]+:\d+$/i.test(valueArg);
  }

  /**
   * Schedule a certificate provisioning sweep, coalescing with any sweep
   * already in flight: at most one sweep runs at a time, plus at most one
   * queued follow-up. Replaces unbounded fire-and-forget sweep stacking,
   * which under repeated updateRoutes calls produced overlapping sweeps and
   * continuous re-provisioning.
   */
  private kickCertProvisioning(): void {
    if (this.stopping) return;
    if (this.certProvisionPromise) {
      this.certProvisionQueued = true;
      return;
    }
    this.certProvisionPromise = (async () => {
      try {
        await this.provisionCertificatesViaCallback();
        while (this.certProvisionQueued && !this.stopping) {
          this.certProvisionQueued = false;
          await this.provisionCertificatesViaCallback();
        }
      } catch (err: any) {
        logger.log('error', `Unexpected error in cert provisioning: ${err.message}`, { component: 'smart-proxy' });
      } finally {
        this.certProvisionPromise = null;
      }
    })();
  }

  /**
   * Warn (with caller stack) when updateRoutes is called at loop-like rates.
   * A healthy deployment updates routes on config changes only; anything
   * beyond a dozen calls per minute indicates a feedback loop upstream.
   */
  private trackRouteUpdateRate(): void {
    const now = Date.now();
    this.routeUpdateTimestamps.push(now);
    while (this.routeUpdateTimestamps.length && this.routeUpdateTimestamps[0] < now - 60_000) {
      this.routeUpdateTimestamps.shift();
    }
    if (this.routeUpdateTimestamps.length > 12 && now - this.lastRouteUpdateRateWarnAt > 60_000) {
      this.lastRouteUpdateRateWarnAt = now;
      logger.log(
        'warn',
        `updateRoutes called ${this.routeUpdateTimestamps.length} times in the last 60s — possible route update loop. Caller stack: ${new Error().stack}`,
        { component: 'smart-proxy' },
      );
    }
  }

  /**
   * Record the validity window of a certificate loaded into Rust.
   * Prefers an explicit validUntil; otherwise parses the PEM.
   */
  private recordCertValidity(domain: string, publicKeyPem: string, validUntil?: number): void {
    let resolved = validUntil;
    if (!resolved && publicKeyPem) {
      try {
        const x509 = new plugins.crypto.X509Certificate(publicKeyPem);
        resolved = new Date(x509.validTo).getTime();
      } catch {
        // Unparseable PEM — leave validity unknown so the domain re-provisions.
      }
    }
    if (resolved && Number.isFinite(resolved)) {
      this.certValidity.set(domain, resolved);
    }
  }

  /** Whether the domain has a loaded cert that is not yet inside the renewal window. */
  private hasValidCert(domain: string, nowMs: number): boolean {
    const validUntil = this.certValidity.get(domain);
    if (!validUntil) return false;
    const renewThresholdDays = this.settings.acme?.renewThresholdDays || 30;
    return validUntil - nowMs > renewThresholdDays * 24 * 3_600_000;
  }

  /** Whether the domain failed provisioning recently and is still cooling down. */
  private isInFailureCooldown(domain: string, nowMs: number): boolean {
    const failedAt = this.certFailureAt.get(domain);
    if (!failedAt) return false;
    const cooldownMs = this.settings.certProvisionFailureCooldownMs ?? 1_800_000;
    return nowMs - failedAt < cooldownMs;
  }

  /**
   * For routes with certificate: 'auto', call certProvisionFunction if set.
   * If the callback returns a cert object, load it into Rust.
   * If it returns 'http01', let Rust handle ACME.
   */
  private async provisionCertificatesViaCallback(skipDomains: Set<string> = new Set()): Promise<void> {
    const provisionFn = this.settings.certProvisionFunction;
    if (!provisionFn) return;

    // Phase 1: Collect all unique (domain, route) pairs that need provisioning.
    // Domains whose loaded cert is still comfortably valid are skipped, as are
    // domains in failure cooldown — so sweeps only do real work.
    const seen = new Set<string>(skipDomains);
    const tasks: Array<{ domain: string; route: IRouteConfig }> = [];
    const nowMs = Date.now();
    let skippedValid = 0;
    let skippedCooldown = 0;

    for (const route of this.settings.routes) {
      if (route.action.tls?.certificate !== 'auto') continue;
      if (!route.match.domains) continue;

      const rawDomains = Array.isArray(route.match.domains) ? route.match.domains : [route.match.domains];
      const certDomains = this.normalizeDomainsForCertProvisioning(rawDomains);

      for (const domain of certDomains) {
        if (seen.has(domain)) continue;
        seen.add(domain);
        if (this.hasValidCert(domain, nowMs)) {
          skippedValid++;
          continue;
        }
        if (this.isInFailureCooldown(domain, nowMs)) {
          skippedCooldown++;
          continue;
        }
        tasks.push({ domain, route });
      }
    }

    // Prune tracking state for domains that left the route config, so the
    // maps stay bounded by the current domain set.
    for (const domain of this.certValidity.keys()) {
      if (!seen.has(domain)) this.certValidity.delete(domain);
    }
    for (const domain of this.certFailureAt.keys()) {
      if (!seen.has(domain)) this.certFailureAt.delete(domain);
    }

    if (tasks.length === 0) return;

    logger.log(
      'info',
      `Cert provisioning sweep: ${tasks.length} domain(s) to provision (${skippedValid} valid skipped, ${skippedCooldown} in cooldown)`,
      { component: 'smart-proxy' },
    );

    // Phase 2: Process all domains in parallel with concurrency limit
    const concurrency = this.settings.certProvisionConcurrency ?? 4;
    const semaphore = new ConcurrencySemaphore(concurrency);

    const promises = tasks.map(async ({ domain, route }) => {
      await semaphore.acquire();
      try {
        await this.provisionSingleDomain(domain, route, provisionFn);
      } finally {
        semaphore.release();
      }
    });

    await Promise.allSettled(promises);
  }

  /**
   * Provision a single domain's certificate via the callback.
   * Includes per-domain timeout and shutdown checks.
   */
  private async provisionSingleDomain(
    domain: string,
    route: IRouteConfig,
    provisionFn: (domain: string, eventComms: ICertProvisionEventComms) => Promise<TSmartProxyCertProvisionObject>,
  ): Promise<void> {
    if (this.stopping) return;

    let expiryDate: string | undefined;
    let source = 'certProvisionFunction';

    const eventComms: ICertProvisionEventComms = {
      log: (msg) => logger.log('info', `[certProvision ${domain}] ${msg}`, { component: 'smart-proxy' }),
      warn: (msg) => logger.log('warn', `[certProvision ${domain}] ${msg}`, { component: 'smart-proxy' }),
      error: (msg) => logger.log('error', `[certProvision ${domain}] ${msg}`, { component: 'smart-proxy' }),
      setExpiryDate: (date) => { expiryDate = date.toISOString(); },
      setSource: (s) => { source = s; },
    };

    const timeoutMs = this.settings.certProvisionTimeout ?? 300_000; // 5 min default

    try {
      const result: TSmartProxyCertProvisionObject = await this.withTimeout(
        provisionFn(domain, eventComms),
        timeoutMs,
        `Certificate provisioning timed out for ${domain} after ${timeoutMs}ms`,
      );

      if (this.stopping) return;

      if (result === 'http01') {
        if (route.name) {
          try {
            await this.bridge.provisionCertificate(route.name);
            logger.log('info', `Triggered Rust ACME for ${domain} (route: ${route.name})`, { component: 'smart-proxy' });
          } catch (provisionErr: any) {
            logger.log('warn', `Cannot provision cert for ${domain} — callback returned 'http01' but Rust ACME failed: ${provisionErr.message}. ` +
              'Note: Rust ACME is disabled when certProvisionFunction is set.', { component: 'smart-proxy' });
          }
        }
        return;
      }

      if (result && typeof result === 'object') {
        if (this.stopping) return;

        const certObj = result as ISmartProxyCertProvisionCertificate;
        await this.bridge.loadCertificate(
          domain,
          certObj.publicKey,
          certObj.privateKey,
          certObj.ca,
        );
        this.certFailureAt.delete(domain);
        this.recordCertValidity(
          domain,
          certObj.publicKey,
          typeof certObj.validUntil === 'number' ? certObj.validUntil : undefined,
        );
        logger.log('info', `Certificate loaded via provision function for ${domain}`, { component: 'smart-proxy' });

        // Persist to consumer store
        if (this.settings.certStore?.save) {
          try {
            await this.settings.certStore.save(domain, certObj.publicKey, certObj.privateKey, certObj.ca);
          } catch (storeErr: any) {
            logger.log('warn', `certStore.save() failed for ${domain}: ${storeErr.message}`, { component: 'smart-proxy' });
          }
        }

        this.emit('certificate-issued', {
          domain,
          expiryDate: expiryDate || (certObj.validUntil ? new Date(certObj.validUntil).toISOString() : undefined),
          source,
        } satisfies ICertificateIssuedEvent);
      }
    } catch (err: any) {
      logger.log('warn', `certProvisionFunction failed for ${domain}: ${err.message}`, { component: 'smart-proxy' });

      // Start the per-domain failure cooldown so subsequent sweeps skip this
      // domain instead of re-running ACME orders back to back.
      this.certFailureAt.set(domain, Date.now());

      this.emit('certificate-failed', {
        domain,
        error: err.message,
        source,
      } satisfies ICertificateFailedEvent);

      // Fallback to ACME if enabled and route has a name
      if (this.settings.certProvisionFallbackToAcme !== false && route.name) {
        try {
          await this.bridge.provisionCertificate(route.name);
          logger.log('info', `Falling back to Rust ACME for ${domain} (route: ${route.name})`, { component: 'smart-proxy' });
        } catch (acmeErr: any) {
          logger.log('warn', `ACME fallback also failed for ${domain}: ${acmeErr.message}` +
            (this.settings.disableDefaultCert
              ? ' — TLS will fail for this domain (disableDefaultCert is true)'
              : ' — default self-signed fallback cert will be used'), { component: 'smart-proxy' });
        }
      }
    }
  }

  /**
   * Race a promise against a timeout. Rejects with the given message if the timeout fires first.
   */
  private withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
    return new Promise<T>((resolve, reject) => {
      const timer = setTimeout(() => reject(new Error(message)), ms);
      promise.then(
        (val) => { clearTimeout(timer); resolve(val); },
        (err) => { clearTimeout(timer); reject(err); },
      );
    });
  }

  /**
   * Normalize routing glob patterns into valid domain identifiers for cert provisioning.
   * - `*nevermind.cloud` → `['nevermind.cloud', '*.nevermind.cloud']`
   * - `*.lossless.digital` → `['*.lossless.digital']` (already valid wildcard)
   * - `code.foss.global` → `['code.foss.global']` (plain domain)
   * - `*mid*.example.com` → skipped with warning (unsupported glob)
   */
  private normalizeDomainsForCertProvisioning(rawDomains: string[]): string[] {
    const result: string[] = [];
    for (const raw of rawDomains) {
      // Plain domain — no glob characters
      if (!raw.includes('*')) {
        result.push(raw);
        continue;
      }

      // Valid wildcard: *.example.com
      if (raw.startsWith('*.') && !raw.slice(2).includes('*')) {
        result.push(raw);
        continue;
      }

      // Routing glob like *example.com (leading star, no dot after it)
      // Convert to bare domain + wildcard pair
      if (raw.startsWith('*') && !raw.startsWith('*.') && !raw.slice(1).includes('*')) {
        const baseDomain = raw.slice(1); // Remove leading *
        result.push(baseDomain);
        result.push(`*.${baseDomain}`);
        continue;
      }

      // Unsupported glob pattern (e.g. *mid*.example.com)
      logger.log('warn', `Skipping unsupported glob pattern for cert provisioning: ${raw}`, { component: 'smart-proxy' });
    }
    return result;
  }

  private isValidDomain(domain: string): boolean {
    if (!domain || domain.length === 0) return false;
    if (domain.includes('*')) return false;
    const validDomainRegex =
      /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
    return validDomainRegex.test(domain);
  }
}
