import * as plugins from './smartnetwork.plugins.js';
import { getLogger } from './logging.js';
import { NetworkError, TimeoutError } from './errors.js';
import * as stats from './helpers/stats.js';

export interface SpeedOptions {
  parallelStreams?: number;
  duration?: number;
}
export class CloudflareSpeed {
  private opts: SpeedOptions;
  constructor(opts?: SpeedOptions) {
    this.opts = opts || {};
  }

  public async speedTest() {
    const latency = await this.measureLatency();

    const serverLocations = await this.fetchServerLocations();
    const cgiData = await this.fetchCfCdnCgiTrace();

    // speed tests: either fixed segments or duration-based mode
    const parallel = this.opts.parallelStreams ?? 1;
    const measureDownloadParallel = (bytes: number, iterations: number) => {
      if (parallel <= 1) {
        return this.measureDownload(bytes, iterations);
      }
      return Promise.all(
        Array(parallel)
          .fill(null)
          .map(() => this.measureDownload(bytes, iterations)),
      ).then((arrays) => arrays.flat());
    };
    let downloadTests: number[];
    if (this.opts.duration && this.opts.duration > 0) {
      // duration-based download: run for specified seconds
      downloadTests = [];
      const durMs = this.opts.duration * 1000;
      const startMs = Date.now();
      // use medium chunk size for download
      const chunkBytes = 25001000;
      while (Date.now() - startMs < durMs) {
        const speeds = await measureDownloadParallel(chunkBytes, 1);
        downloadTests.push(...speeds);
      }
      if (downloadTests.length === 0) downloadTests = [0];
    } else {
      // fixed download segments
      const t1 = await measureDownloadParallel(101000, 10);
      const t2 = await measureDownloadParallel(1001000, 8);
      const t3 = await measureDownloadParallel(10001000, 6);
      const t4 = await measureDownloadParallel(25001000, 4);
      const t5 = await measureDownloadParallel(100001000, 1);
      downloadTests = [...t1, ...t2, ...t3, ...t4, ...t5];
    }
    const speedDownload = downloadTests.length > 0 ? stats.quartile(downloadTests, 0.9).toFixed(2) : '0.00';

    // lets test the upload speed with configurable parallel streams
    const measureUploadParallel = (bytes: number, iterations: number) => {
      if (parallel <= 1) {
        return this.measureUpload(bytes, iterations);
      }
      return Promise.all(
        Array(parallel)
          .fill(null)
          .map(() => this.measureUpload(bytes, iterations)),
      ).then((arrays) => arrays.flat());
    };
    let uploadTests: number[];
    if (this.opts.duration && this.opts.duration > 0) {
      // duration-based upload: run for specified seconds
      uploadTests = [];
      const durMsUp = this.opts.duration * 1000;
      const startMsUp = Date.now();
      const chunkBytesUp = 1001000;
      while (Date.now() - startMsUp < durMsUp) {
        const speeds = await measureUploadParallel(chunkBytesUp, 1);
        uploadTests.push(...speeds);
      }
      if (uploadTests.length === 0) uploadTests = [0];
    } else {
      const u1 = await measureUploadParallel(11000, 10);
      const u2 = await measureUploadParallel(101000, 10);
      const u3 = await measureUploadParallel(1001000, 8);
      uploadTests = [...u1, ...u2, ...u3];
    }
    const speedUpload = uploadTests.length > 0 ? stats.quartile(uploadTests, 0.9).toFixed(2) : '0.00';

    return {
      ...latency,
      ip: cgiData.ip,
      serverLocation: {
        shortId: cgiData.colo,
        name: serverLocations[cgiData.colo],
        availableLocations: serverLocations,
      },
      downloadSpeed: speedDownload,
      uploadSpeed: speedUpload,
    };
  }

  public async measureLatency() {
    const measurements: number[] = [];

    for (let i = 0; i < 20; i += 1) {
      await this.download(1000).then(
        (response) => {
          // TTFB - Server processing time
          measurements.push(response[4] - response[0] - response[6]);
        },
        (error) => {
          getLogger().error('Error measuring latency:', error);
        },
      );
    }

    return {
      maxTime: Math.max(...measurements),
      minTime: Math.min(...measurements),
      averageTime: stats.average(measurements),
      medianTime: stats.median(measurements),
      jitter: stats.jitter(measurements),
    };
  }

  public async measureDownload(bytes: number, iterations: number) {
    const measurements: number[] = [];

    for (let i = 0; i < iterations; i += 1) {
      await this.download(bytes).then(
        async (response) => {
          const transferTime = response[5] - response[4];
          measurements.push(await this.measureSpeed(bytes, transferTime));
        },
        (error) => {
          getLogger().error('Error measuring download chunk:', error);
        },
      );
    }

    return measurements;
  }

  public async measureUpload(bytes: number, iterations: number) {
    const measurements: number[] = [];

    for (let i = 0; i < iterations; i += 1) {
      await this.upload(bytes).then(
        async (response) => {
          // Prefer server-timing duration; fall back to client-side transfer time
          let transferTime = response[6];
          if (!transferTime || !isFinite(transferTime)) {
            transferTime = response[5] - response[4]; // ended - ttfb
          }
          if (transferTime > 0) {
            measurements.push(await this.measureSpeed(bytes, transferTime));
          }
        },
        (error) => {
          getLogger().error('Error measuring upload chunk:', error);
        },
      );
    }

    return measurements;
  }

  public async measureSpeed(bytes: number, duration: number) {
    return (bytes * 8) / (duration / 1000) / 1e6;
  }

  public async fetchServerLocations(): Promise<{ [key: string]: string }> {
    try {
      const raw = await this.get('speed.cloudflare.com', '/locations');
      const parsed = JSON.parse(raw);
      if (!Array.isArray(parsed)) {
        return {};
      }
      return (parsed as Array<{ iata: string; city: string }>).reduce(
        (data: Record<string, string>, entry) => {
          data[entry.iata] = entry.city;
          return data;
        },
        {} as Record<string, string>,
      );
    } catch {
      return {};
    }
  }

  public async get(hostname: string, path: string): Promise<string> {
    return new Promise((resolve, reject) => {
      const req = plugins.https.request(
        {
          hostname,
          path,
          method: 'GET',
          // disable connection pooling to avoid listener accumulation
          agent: false,
        },
        (res) => {
          const body: Array<Buffer> = [];
          res.on('data', (chunk) => {
            body.push(chunk);
          });
          res.on('end', () => {
            try {
              resolve(Buffer.concat(body).toString());
            } catch (e) {
              reject(e);
            }
          });
          req.on('error', (err: Error & { code?: string }) => {
            reject(new NetworkError(err.message, err.code));
          });
        },
      );

      req.end();
    });
  }

  public async download(bytes: number) {
    const options = {
      hostname: 'speed.cloudflare.com',
      path: `/__down?bytes=${bytes}`,
      method: 'GET',
    };

    return this.request(options);
  }

  public async upload(bytes: number) {
    const data = '0'.repeat(bytes);
    const options = {
      hostname: 'speed.cloudflare.com',
      path: '/__up',
      method: 'POST',
      headers: {
        'Content-Length': Buffer.byteLength(data),
      },
    };

    return this.request(options, data);
  }

  public async request(options: plugins.https.RequestOptions, data = ''): Promise<number[]> {
    let started: number;
    let dnsLookup: number;
    let tcpHandshake: number;
    let sslHandshake: number;
    let ttfb: number;
    let ended: number;

    return new Promise((resolve, reject) => {
      started = plugins.perfHooks.performance.now();
      // disable connection pooling to avoid listener accumulation across requests
      const reqOptions = { ...options, agent: false };
      const req = plugins.https.request(reqOptions, (res) => {
        res.once('readable', () => {
          ttfb = plugins.perfHooks.performance.now();
        });
        res.on('data', () => {});
        res.on('end', () => {
          ended = plugins.perfHooks.performance.now();
          resolve([
            started,
            dnsLookup,
            tcpHandshake,
            sslHandshake,
            ttfb,
            ended,
            (() => {
              const serverTiming = res.headers['server-timing'] as string | undefined;
              if (!serverTiming) return 0;
              const match = serverTiming.match(/dur=([\d.]+)/);
              return match ? parseFloat(match[1]) : parseFloat(serverTiming.slice(22)) || 0;
            })(),
          ]);
        });
      });

      // Listen for timing events once per new socket
      req.once('socket', (socket) => {
        socket.once('lookup', () => {
          dnsLookup = plugins.perfHooks.performance.now();
        });
        socket.once('connect', () => {
          tcpHandshake = plugins.perfHooks.performance.now();
        });
        socket.once('secureConnect', () => {
          sslHandshake = plugins.perfHooks.performance.now();
        });
      });

      req.on('error', (error: Error & { code?: string }) => {
        reject(new NetworkError(error.message, error.code));
      });

      req.write(data);
      req.end();
    });
  }

  /**
   * Fetch Cloudflare's trace endpoint and parse key=value lines to a record.
   */
  public async fetchCfCdnCgiTrace(): Promise<Record<string, string>> {
    const parseCfCdnCgiTrace = (text: string) =>
      text
        .split('\n')
        .map((i) => {
          const parts = i.split('=');
          return [parts[0], parts[1]];
        })
        .reduce(
          (data: Record<string, string>, [k, v]) => {
            if (v === undefined) return data;
            data[k] = v;
            return data;
          },
          {} as Record<string, string>,
        );

    return this.get('speed.cloudflare.com', '/cdn-cgi/trace').then(parseCfCdnCgiTrace);
  }
}
