#!/usr/bin/env node
// OSRM query runner - executes routing queries against OSRM server and collects performance metrics

import fs from 'fs';
import http from 'http';
import process from 'process';
import cla from 'command-line-args';
import clu from 'command-line-usage';
import ansi from 'ansi-escape-sequences';
import * as turf from '@turf/turf';
import { JSONPath } from 'jsonpath-plus';
import { stringify as csv_stringify } from 'csv-stringify/sync';

// Execute HTTP query against OSRM server and measure response times
const run_query = (query_options, filters, callback) => {
  let tic = () => 0.;
  http.request(query_options, (res) => {
    let body = '';
    const ttfb = tic();

    res.setEncoding('utf8');
    res.on('data', (chunk) => {
      body += chunk;
    });
    res.on('end', () => {
      const elapsed = tic();
      const json = JSON.parse(body);
      Promise.all(filters.map(filter => JSONPath({path: filter, json: json})))
        .then(values => callback(query_options.path, res.statusCode, ttfb, elapsed, values));
    });
  }).on('socket', (/*res*/) => {
    tic = ((toc) => { return () => { const t = process.hrtime(toc); return t[0] * 1000 + t[1] / 1000000; }; })(process.hrtime());
  }).on('error', (res) => {
    callback(query_options.path, res.code);
  }).end();
};

// Generate random coordinate points within a polygon boundary for route testing
function generate_points(polygon, number, coordinates_number, max_distance) {
  const query_points = [];
  while (query_points.length < number) {
    let points = [];

    while(points.length < coordinates_number) {
      const chunk = turf
        .randomPoint(coordinates_number, { bbox: turf.bbox(polygon)})
        .features
        .map(x => x.geometry.coordinates)
        .filter(pt => turf.booleanPointInPolygon(turf.point(pt), polygon));

      if (max_distance > 0)
      {
        chunk.forEach(pt => {
          if (points.length == 0)
          {
            points.push(pt);
          }
          else
          {
            const distance = turf.distance(pt, points[points.length-1], 'meters');
            if (distance < max_distance)
            {
              points.push(pt);
            }
          }
        });
      }
      else
      {
        points = points.concat(chunk);
      }
    }

    query_points.push(points);
  }

  return query_points;
}

// Convert coordinate points into OSRM API query URLs
function generate_queries(options, query_points) {
  const queries = query_points.map(points => {
    return options.path.replace(/{}/g, () =>  points.pop().join(','));
  });
  return queries;
}

// Parse server hostname:port from command line arguments
function ServerDetails(x) {
  if (!(this instanceof ServerDetails)) return new ServerDetails(x);
  const v = x.split(':');
  this.hostname = (v[0].length > 0) ? v[0] : '';
  this.port = (v.length > 1) ? Number(v[1]) : 80;
}
// Parse geographic bounding box coordinates from command line
function BoundingBox(x) {
  if (!(this instanceof BoundingBox)) return new BoundingBox(x);
  const v = x.match(/[+-]?\d+(?:\.\d*)?|\.\d+/g);
  this.poly = turf.bboxPolygon(v.slice(0,4).map(x => Number(x)));
}
const optionsList = [
  {name: 'help', alias: 'h', type: Boolean, description: 'Display this usage guide.', defaultValue: false},
  {name: 'verbose', alias: 'v', type: Boolean, description: 'Print detailed CSV output for each query', defaultValue: false},
  {name: 'server', alias: 's', type: ServerDetails, defaultValue: ServerDetails('localhost:5000'), description: 'OSRM routing server', typeLabel: '{underline hostname[:port]}'},
  {name: 'path', alias: 'p', type: String, defaultValue: '/route/v1/driving/{};{}', description: 'OSRM query path with \\{\\} coordinate placeholders, default /route/v1/driving/\\{\\};\\{\\}', typeLabel: '{underline path}'},
  {name: 'filter', alias: 'f', type: String, defaultValue: ['$.routes[0].weight'], multiple: true, description: 'JSONPath filters, default "$.routes[0].weight"', typeLabel: '{underline filter}'},
  {name: 'bounding-box', alias: 'b', type: BoundingBox, defaultValue: BoundingBox('5.86442,47.2654,15.0508,55.1478'), multiple: true, description: 'queries bounding box, default "5.86442,47.2654,15.0508,55.1478"', typeLabel: '{underline west,south,east,north}'},
  {name: 'max-sockets', alias: 'm', type: Number, defaultValue: 1, description: 'how many concurrent sockets the agent can have open per origin, default 1', typeLabel: '{underline number}'},
  {name: 'number', alias: 'n', type: Number, defaultValue: 10, description: 'number of query points, default 10', typeLabel: '{underline number}'},
  {name: 'max-distance', alias: 'd', type: Number, defaultValue: -1, description: 'maximal distance between coordinates', typeLabel: '{underline number}'},
  {name: 'queries-files', alias: 'q', type: String, description: 'CSV file with queries in the first row', typeLabel: '{underline file}'},
];
const options = cla(optionsList);
if (options.help) {
  const banner =`\
  ____  _______  __  ______  __  ___  ___  _________
 / __ \\/ __/ _ \\/  |/  / _ \\/ / / / |/ / |/ / __/ _ \\
/ /_/ /\\ \\/ , _/ /|_/ / , _/ /_/ /    /    / _// , _/
\\____/___/_/|_/_/  /_/_/|_|\\____/_/|_/_/|_/___/_/|_|`;
  const usage = clu([
    { content: ansi.format(banner, 'green'), raw: true },
    { header: 'Run OSRM queries and collect results'/*, content: 'Generates something [italic]{very} important.'*/ },
    { header: 'Options', optionList: optionsList }
  ]);
  process.stdout.write(`${usage}\n`);
  process.exit(0);
}

// Load queries from file or generate random coordinate-based queries
let queries = [];
if (options.hasOwnProperty('queries-files')) {
  queries = fs.readFileSync(options['queries-files'])
    .toString()
    .split('\n')
    .map(r => { const match = /^"([^\"]+)"/.exec(r); return match ? match[1] : null; })
    .filter(q => q)
    .map(q => {
      // If query is a full URL, extract just the path portion
      const url_match = /^https?:\/\/[^\/]+(\/.*?)$/.exec(q);
      return url_match ? url_match[1] : q;
    });
} else {
  const polygon = options['bounding-box'].map(x => x.poly).reduce((x,y) => turf.union(x, y));
  const coordinates_number = (options.path.match(/{}/g) || []).length;
  const query_points = generate_points(polygon, options.number, coordinates_number, options['max-distance']);
  queries = generate_queries(options, query_points);
}
queries = queries.map(q => { return {hostname: options.server.hostname, port: options.server.port, path: q}; });

// Execute all queries and output results in CSV format
http.globalAgent.maxSockets = options['max-sockets'];

// Collect all results for statistics
const all_results = [];
let completed_queries = 0;

queries.forEach(query => {
  run_query(query, options.filter, (query, code, ttfb, total, results) => {
    const data = results ? JSON.stringify(results[0]) : '';
    const record = {
      query,
      code,
      ttfb: ttfb ?? 0,
      total: total ?? 0,
      data
    };
    all_results.push(record);
    completed_queries++;

    // When all queries are done, print statistics and CSV output
    if (completed_queries === queries.length) {
      print_statistics(all_results);

      // Output CSV data only if verbose flag is set
      if (options.verbose) {
        const csv_records = all_results.map(r => [r.query, r.code, r.ttfb, r.total, r.data]);
        process.stdout.write(csv_stringify(csv_records));
      }
    }
  });
});

// Calculate and print query statistics
function print_statistics(results) {
  const total_queries = results.length;
  const successful = results.filter(r => r.code === 200 && r.total > 0);
  const successful_count = successful.length;
  const failed_count = total_queries - successful_count;

  process.stderr.write('\n=== Query Statistics ===\n');
  process.stderr.write(`Total queries: ${total_queries}\n`);
  process.stderr.write(`Successful: ${successful_count}\n`);
  process.stderr.write(`Failed: ${failed_count}\n`);

  if (successful_count === 0) {
    // Show sample of error codes to help debug
    const error_codes = {};
    results.forEach(r => {
      error_codes[r.code] = (error_codes[r.code] || 0) + 1;
    });
    process.stderr.write('\nError breakdown:\n');
    Object.entries(error_codes).forEach(([code, count]) => {
      process.stderr.write(`  ${code}: ${count} queries\n`);
    });
    process.stderr.write('========================\n\n');
    return;
  }

  // Extract total times from successful queries (filter out any 0 or invalid times)
  const times = successful.map(r => r.total).filter(t => t > 0).sort((a, b) => a - b);

  if (times.length === 0) {
    process.stderr.write('No valid response times recorded\n');
    process.stderr.write('========================\n\n');
    return;
  }

  const min = times[0];
  const max = times[times.length - 1];
  const avg = times.reduce((sum, t) => sum + t, 0) / times.length;

  // Calculate median
  let median;
  const mid = Math.floor(times.length / 2);
  if (times.length % 2 === 0) {
    median = (times[mid - 1] + times[mid]) / 2;
  } else {
    median = times[mid];
  }

  process.stderr.write(`\nResponse times (ms):\n`);
  process.stderr.write(`  Min:    ${min.toFixed(2)}\n`);
  process.stderr.write(`  Max:    ${max.toFixed(2)}\n`);
  process.stderr.write(`  Avg:    ${avg.toFixed(2)}\n`);
  process.stderr.write(`  Median: ${median.toFixed(2)}\n`);
  process.stderr.write('========================\n\n');
}