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

type TChallengeRelayOperation = 'assess' | 'render' | 'verify';

interface IChallengeRelayRequest {
  id?: string;
  operation: TChallengeRelayOperation;
  providerId: string;
  request: unknown;
}

interface IChallengeRelayResponse {
  id?: string;
  success: boolean;
  result?: unknown;
  error?: string;
}

const maxChallengeRelayMessageBytes = 1024 * 1024;
const maxChallengeRelayIdBytes = 256;

export interface IChallengeProviderRelayServerOptions {
  providerTimeoutMs?: number;
  maxActiveProviderOperations?: number;
  maxUnsettledProviderOperations?: number;
}

export class ChallengeProviderRelayServer {
  private providerTimeoutMs: number;
  private maxActiveProviderOperations: number;
  private maxUnsettledProviderOperations: number;
  private activeProviderOperations = 0;
  private unsettledProviderOperations = 0;
  private server: plugins.net.Server | null = null;
  private socketPath: string;
  private activeSockets = new Set<plugins.net.Socket>();
  private startPromise?: Promise<void>;

  constructor(
    private providers: Map<string, plugins.smartchallenge.IChallengeProvider>,
    optionsArg: IChallengeProviderRelayServerOptions = {},
  ) {
    this.socketPath = `/tmp/smartproxy-challenge-relay-${process.pid}-${plugins.crypto.randomBytes(8).toString('hex')}.sock`;
    this.providerTimeoutMs = Math.max(1, optionsArg.providerTimeoutMs ?? 5_000);
    this.maxActiveProviderOperations = Math.max(1, optionsArg.maxActiveProviderOperations ?? 1024);
    this.maxUnsettledProviderOperations = Math.max(1, optionsArg.maxUnsettledProviderOperations ?? this.maxActiveProviderOperations);
  }

  public getSocketPath(): string {
    return this.socketPath;
  }

  public async start(): Promise<void> {
    if (this.server) return;
    if (this.startPromise) return this.startPromise;
    this.startPromise = this.startInternal();
    try {
      await this.startPromise;
    } finally {
      this.startPromise = undefined;
    }
  }

  private async startInternal(): Promise<void> {
    try {
      await plugins.fs.promises.unlink(this.socketPath);
    } catch {
      // Ignore stale socket cleanup failures for missing files.
    }

    const server = plugins.net.createServer((socket) => {
      this.activeSockets.add(socket);
      socket.once('close', () => this.activeSockets.delete(socket));
      this.handleConnection(socket);
    });

    server.on('error', (err) => {
      logger.log('error', `ChallengeProviderRelayServer error: ${err.message}`, { component: 'challenge-provider-relay-server' });
    });

    await new Promise<void>((resolve, reject) => {
      const handleError = (err: Error) => reject(err);
      server.once('error', handleError);
      server.listen(this.socketPath, () => {
        server.off('error', handleError);
        this.server = server;
        logger.log('info', `ChallengeProviderRelayServer listening on ${this.socketPath}`, { component: 'challenge-provider-relay-server' });
        resolve();
      });
    }).catch((err) => {
      server.close();
      throw err;
    });
  }

  public async stop(): Promise<void> {
    if (this.startPromise && !this.server) {
      await this.startPromise.catch(() => undefined);
    }

    for (const socket of this.activeSockets) {
      socket.destroy();
    }
    this.activeSockets.clear();

    const server = this.server;
    if (!server) return;
    this.server = null;
    await new Promise<void>((resolve) => {
      server.close(() => {
        plugins.fs.unlink(this.socketPath, () => resolve());
      });
    });
  }

  private handleConnection(socket: plugins.net.Socket): void {
    let buffer: Buffer<ArrayBufferLike> = Buffer.alloc(0);
    socket.setTimeout(Math.max(10_000, this.providerTimeoutMs + 1_000));
    socket.on('timeout', () => socket.destroy());
    socket.on('error', (err) => {
      logger.log('warn', `Challenge relay socket error: ${err.message}`, { component: 'challenge-provider-relay-server' });
    });

    socket.on('data', (chunk: Buffer) => {
      buffer = buffer.length === 0 ? chunk : Buffer.concat([buffer, chunk], buffer.length + chunk.length);
      if (buffer.length > maxChallengeRelayMessageBytes) {
        socket.removeAllListeners('data');
        this.writeResponse(socket, { success: false, error: 'Challenge relay request exceeds maximum size' });
        buffer = Buffer.alloc(0);
        socket.end();
        return;
      }

      const newlineIndex = buffer.indexOf(0x0a);
      if (newlineIndex === -1) return;

      const line = buffer.subarray(0, newlineIndex).toString('utf8');
      socket.removeAllListeners('data');
      this.dispatchLine(line)
        .then((response) => this.writeResponse(socket, response))
        .catch((err) => this.writeResponse(socket, { success: false, error: (err as Error).message }))
        .finally(() => socket.end());
    });
  }

  private async dispatchLine(lineArg: string): Promise<IChallengeRelayResponse> {
    let request: IChallengeRelayRequest;
    try {
      request = JSON.parse(lineArg) as IChallengeRelayRequest;
    } catch {
      return { success: false, error: 'Invalid challenge relay JSON' };
    }

    const provider = this.providers.get(request.providerId);
    if (!provider) {
      return {
        id: request.id,
        success: false,
        error: `Challenge provider '${request.providerId}' is not registered`,
      };
    }

    switch (request.operation) {
      case 'assess':
        return { id: request.id, success: true, result: await this.withProviderTimeout(() => provider.assess(request.request as plugins.smartchallenge.IChallengeAssessRequest), 'assess') };
      case 'render':
        return { id: request.id, success: true, result: await this.withProviderTimeout(() => provider.render(request.request as plugins.smartchallenge.IChallengeRenderRequest), 'render') };
      case 'verify':
        return { id: request.id, success: true, result: await this.withProviderTimeout(() => provider.verify(request.request as plugins.smartchallenge.IChallengeVerifyRequest), 'verify') };
      default:
        const operationValue = (request as { operation?: unknown }).operation;
        return { id: request.id, success: false, error: `Unsupported challenge relay operation '${String(operationValue)}'` };
    }
  }

  private async withProviderTimeout<T>(operationArg: () => Promise<T>, operationNameArg: TChallengeRelayOperation): Promise<T> {
    if (this.activeProviderOperations >= this.maxActiveProviderOperations) {
      throw new Error(`Challenge provider ${operationNameArg} rejected because too many operations are active`);
    }
    if (this.unsettledProviderOperations >= this.maxUnsettledProviderOperations) {
      throw new Error(`Challenge provider ${operationNameArg} rejected because too many timed-out operations are still settling`);
    }

    let timer: ReturnType<typeof setTimeout> | undefined;
    let activeReleased = false;
    let unsettledReleased = false;
    const releaseActive = () => {
      if (!activeReleased) {
        activeReleased = true;
        this.activeProviderOperations -= 1;
      }
    };
    const releaseUnsettled = () => {
      if (!unsettledReleased) {
        unsettledReleased = true;
        this.unsettledProviderOperations -= 1;
      }
    };
    this.activeProviderOperations += 1;
    this.unsettledProviderOperations += 1;
    const providerPromise = Promise.resolve().then(operationArg);
    const trackedProviderPromise = providerPromise.then(
      (result) => {
        releaseActive();
        releaseUnsettled();
        return result;
      },
      (err) => {
        releaseActive();
        releaseUnsettled();
        throw err;
      },
    );
    trackedProviderPromise.catch(() => undefined);
    try {
      return await Promise.race([
        trackedProviderPromise,
        new Promise<T>((_resolve, reject) => {
          timer = setTimeout(
            () => {
              releaseActive();
              reject(new Error(`Challenge provider ${operationNameArg} timed out after ${this.providerTimeoutMs}ms`));
            },
            this.providerTimeoutMs,
          );
        }),
      ]);
    } finally {
      if (timer) clearTimeout(timer);
    }
  }

  private writeResponse(socket: plugins.net.Socket, responseArg: IChallengeRelayResponse): void {
    let response = responseArg;
    if (this.estimateJsonByteLength(response, maxChallengeRelayMessageBytes) === undefined) {
      response = this.createErrorResponse(responseArg.id, 'Challenge relay response exceeds maximum size');
    }

    let serializedResponse: string;
    try {
      serializedResponse = JSON.stringify(response);
    } catch {
      serializedResponse = JSON.stringify(this.createErrorResponse(responseArg.id, 'Challenge relay response is not serializable'));
    }

    if (Buffer.byteLength(serializedResponse, 'utf8') > maxChallengeRelayMessageBytes) {
      serializedResponse = JSON.stringify({
        success: false,
        error: 'Challenge relay response exceeds maximum size',
      });
    }

    socket.write(`${serializedResponse}\n`);
  }

  private createErrorResponse(idArg: string | undefined, errorArg: string): IChallengeRelayResponse {
    const response: IChallengeRelayResponse = {
      success: false,
      error: errorArg,
    };
    if (idArg && Buffer.byteLength(idArg, 'utf8') <= maxChallengeRelayIdBytes) {
      response.id = idArg;
    }
    return response;
  }

  private estimateJsonByteLength(
    valueArg: unknown,
    maxBytesArg: number,
    seenArg = new Set<object>(),
  ): number | undefined {
    const bytes = this.estimateJsonByteLengthInternal(valueArg, maxBytesArg, seenArg);
    return bytes !== undefined && bytes <= maxBytesArg ? bytes : undefined;
  }

  private estimateJsonByteLengthInternal(
    valueArg: unknown,
    maxBytesArg: number,
    seenArg: Set<object>,
  ): number | undefined {
    if (valueArg === null) return 4;
    if (typeof valueArg === 'string') return this.estimateJsonStringByteLength(valueArg, maxBytesArg);
    if (typeof valueArg === 'number') return Number.isFinite(valueArg) ? String(valueArg).length : 4;
    if (typeof valueArg === 'boolean') return valueArg ? 4 : 5;
    if (typeof valueArg === 'undefined') return 4;
    if (typeof valueArg === 'bigint' || typeof valueArg === 'symbol' || typeof valueArg === 'function') return undefined;
    if (typeof valueArg !== 'object') return undefined;
    if (seenArg.has(valueArg)) return undefined;

    seenArg.add(valueArg);
    try {
      let totalBytes = 2;
      if (Array.isArray(valueArg)) {
        for (let i = 0; i < valueArg.length; i++) {
          const childBytes = this.estimateJsonByteLengthInternal(valueArg[i], maxBytesArg, seenArg);
          if (childBytes === undefined) return undefined;
          totalBytes += childBytes + (i > 0 ? 1 : 0);
          if (totalBytes > maxBytesArg) return undefined;
        }
        return totalBytes;
      }

      let propertyCount = 0;
      for (const [key, value] of Object.entries(valueArg as Record<string, unknown>)) {
        if (typeof value === 'undefined' || typeof value === 'function' || typeof value === 'symbol') continue;
        const keyBytes = this.estimateJsonStringByteLength(key, maxBytesArg);
        const valueBytes = this.estimateJsonByteLengthInternal(value, maxBytesArg, seenArg);
        if (keyBytes === undefined || valueBytes === undefined) return undefined;
        totalBytes += keyBytes + valueBytes + 1 + (propertyCount > 0 ? 1 : 0);
        propertyCount += 1;
        if (totalBytes > maxBytesArg) return undefined;
      }
      return totalBytes;
    } finally {
      seenArg.delete(valueArg);
    }
  }

  private estimateJsonStringByteLength(valueArg: string, maxBytesArg: number): number | undefined {
    let bytes = Buffer.byteLength(valueArg, 'utf8') + 2;
    if (bytes > maxBytesArg) return undefined;

    for (let i = 0; i < valueArg.length; i++) {
      const code = valueArg.charCodeAt(i);
      if (code === 0x22 || code === 0x5c) {
        bytes += 1;
      } else if (code === 0x08 || code === 0x09 || code === 0x0a || code === 0x0c || code === 0x0d) {
        bytes += 1;
      } else if (code < 0x20) {
        bytes += 5;
      }
      if (bytes > maxBytesArg) return undefined;
    }

    return bytes;
  }
}
