module Cryload
  # Stats holder for the benchmark
  class Stats
    # HDR-style logarithmic histogram: ~1% relative precision from 1µs to 1h
    # in a few thousand buckets, instead of a dense linear bucket array.
    HISTOGRAM_MIN_MS       =       0.001
    HISTOGRAM_MAX_MS       = 3_600_000.0
    HISTOGRAM_GROWTH       =        1.01
    HISTOGRAM_LOG_GROWTH   = Math.log(HISTOGRAM_GROWTH)
    HISTOGRAM_BUCKET_COUNT = (Math.log(HISTOGRAM_MAX_MS / HISTOGRAM_MIN_MS) / HISTOGRAM_LOG_GROWTH).ceil.to_i + 1

    def self.histogram_bucket_index(value_ms : Float64) : Int32
      return 0 if value_ms <= HISTOGRAM_MIN_MS

      index = (Math.log(value_ms / HISTOGRAM_MIN_MS) / HISTOGRAM_LOG_GROWTH).floor.to_i
      {index, HISTOGRAM_BUCKET_COUNT - 1}.min
    end

    # Geometric midpoint of the bucket, the best estimate for values in it.
    def self.histogram_bucket_value(index : Int32) : Float64
      HISTOGRAM_MIN_MS * (HISTOGRAM_GROWTH ** (index + 0.5))
    end

    # Worker-local stats batch flushed periodically to the global collector.
    class Batch
      @success_status_ranges : Array(Range(Int32, Int32))
      @total_request_count : Int64
      @response_count : Int64
      @total_response_bytes : Int64
      @ok_requests : Int64
      @not_ok_requests : Int64
      @transport_error_count : Int64
      @total_request_time_ms : Float64
      @min_request_time_ms : Float64
      @max_request_time_ms : Float64
      @mean_latency_ms : Float64
      @m2_latency_ms : Float64
      @latency_buckets : Hash(Int32, Int64)
      @status_code_counts : Hash(Int32, Int64)
      @error_counts : Hash(String, Int64)

      getter :total_request_count
      getter :response_count
      getter :total_response_bytes
      getter :ok_requests
      getter :not_ok_requests
      getter :transport_error_count
      getter :total_request_time_ms
      getter :min_request_time_ms
      getter :max_request_time_ms
      getter :mean_latency_ms
      getter :m2_latency_ms
      getter :latency_buckets
      getter :status_code_counts
      getter :error_counts

      def initialize(@success_status_ranges : Array(Range(Int32, Int32)) = [200..299])
        @total_request_count = 0_i64
        @response_count = 0_i64
        @total_response_bytes = 0_i64
        @ok_requests = 0_i64
        @not_ok_requests = 0_i64
        @transport_error_count = 0_i64
        @total_request_time_ms = 0.0
        @min_request_time_ms = Float64::INFINITY
        @max_request_time_ms = 0.0
        @mean_latency_ms = 0.0
        @m2_latency_ms = 0.0
        @latency_buckets = Hash(Int32, Int64).new(0_i64)
        @status_code_counts = Hash(Int32, Int64).new(0_i64)
        @error_counts = Hash(String, Int64).new(0_i64)
      end

      def empty?
        @total_request_count == 0
      end

      def record_response(time_taken_ms : Float64, status_code : Int32, response_bytes : Int64 = 0_i64)
        @total_request_count += 1
        @response_count += 1
        @total_response_bytes += response_bytes
        @status_code_counts[status_code] += 1
        if success_status?(status_code)
          @ok_requests += 1
        else
          @not_ok_requests += 1
        end
        update_latency_metrics time_taken_ms
      end

      # Transport errors are excluded from latency metrics: connect failures
      # (~0 ms) and timeouts would otherwise skew the percentiles.
      def record_error(category : String)
        @total_request_count += 1
        @transport_error_count += 1
        @error_counts[category] += 1
      end

      private def update_latency_metrics(time_taken_ms : Float64)
        @total_request_time_ms += time_taken_ms
        @min_request_time_ms = {@min_request_time_ms, time_taken_ms}.min
        @max_request_time_ms = {@max_request_time_ms, time_taken_ms}.max

        delta = time_taken_ms - @mean_latency_ms
        @mean_latency_ms += delta / @response_count
        delta2 = time_taken_ms - @mean_latency_ms
        @m2_latency_ms += delta * delta2

        @latency_buckets[Stats.histogram_bucket_index(time_taken_ms)] += 1
      end

      private def success_status?(status_code : Int32)
        @success_status_ranges.any?(&.includes?(status_code))
      end
    end

    @total_request_count : Int64
    @response_count : Int64
    @total_response_bytes : Int64
    @ok_requests : Int64
    @not_ok_requests : Int64
    @transport_error_count : Int64
    @total_request_time_ms : Float64
    @min_request_time_ms : Float64
    @max_request_time_ms : Float64
    @mean_latency_ms : Float64
    @m2_latency_ms : Float64
    @latency_histogram : Array(Int64)
    @status_code_counts : Hash(Int32, Int64)
    @error_counts : Hash(String, Int64)
    @mutex : Mutex
    @benchmark_end : Time::Instant?

    getter :request_number
    getter :duration_mode
    getter :benchmark_start
    getter :url
    getter :output_format
    getter :success_status_ranges
    getter :ci_thresholds
    getter :progress_enabled

    TIME_IN_MILISECONDS = 1000

    def initialize(
      @request_number : Int32,
      @duration_mode : Bool = false,
      @benchmark_start : Time::Instant = Time.instant,
      @url : String = "",
      @output_format : String = "text",
      @success_status_ranges : Array(Range(Int32, Int32)) = [200..299],
      @ci_thresholds : CiThresholds = CiThresholds.new,
      @progress_enabled : Bool = false,
    )
      @total_request_count = 0_i64
      @response_count = 0_i64
      @total_response_bytes = 0_i64
      @ok_requests = 0_i64
      @not_ok_requests = 0_i64
      @transport_error_count = 0_i64
      @total_request_time_ms = 0.0
      @min_request_time_ms = Float64::INFINITY
      @max_request_time_ms = 0.0
      @mean_latency_ms = 0.0
      @m2_latency_ms = 0.0
      @latency_histogram = Array(Int64).new(HISTOGRAM_BUCKET_COUNT, 0_i64)
      @status_code_counts = Hash(Int32, Int64).new(0_i64)
      @error_counts = Hash(String, Int64).new(0_i64)
      @mutex = Mutex.new
    end

    def min_request_time
      @mutex.synchronize do
        return 0.0 if @response_count == 0
        @min_request_time_ms
      end
    end

    def max_request_time
      @mutex.synchronize do
        return 0.0 if @response_count == 0
        @max_request_time_ms
      end
    end

    def average_request_time
      @mutex.synchronize do
        return 0.0 if @response_count == 0
        @total_request_time_ms / @response_count
      end
    end

    def latency_stdev
      @mutex.synchronize do
        return 0.0 if @response_count < 2
        variance = @m2_latency_ms / @response_count
        Math.sqrt(variance)
      end
    end

    # Requests per second = total requests / wall clock time (actual throughput)
    def request_per_second
      count = total_request_count
      return 0.0 if count == 0
      elapsed = wall_clock_seconds
      elapsed > 0 ? count.to_f / elapsed : 0.0
    end

    # Wall clock time from benchmark start to report completion.
    def wall_clock_seconds
      @mutex.synchronize do
        end_at = @benchmark_end || Time.instant
        (end_at - @benchmark_start).total_seconds
      end
    end

    def mark_benchmark_end
      @mutex.synchronize do
        @benchmark_end ||= Time.instant
      end
    end

    def total_request_time_in_seconds
      total_request_time / TIME_IN_MILISECONDS
    end

    def ok_requests
      @mutex.synchronize { @ok_requests }
    end

    def not_ok_requests
      @mutex.synchronize { @not_ok_requests }
    end

    def empty?
      @mutex.synchronize { @total_request_count == 0 }
    end

    def total_request_count
      @mutex.synchronize { @total_request_count }
    end

    def total_response_bytes
      @mutex.synchronize { @total_response_bytes }
    end

    def average_bytes_per_response
      @mutex.synchronize do
        return 0.0 if @response_count == 0
        @total_response_bytes.to_f / @response_count
      end
    end

    def bytes_per_second
      bytes = total_response_bytes
      return 0.0 if bytes == 0
      elapsed = wall_clock_seconds
      elapsed > 0 ? bytes.to_f / elapsed : 0.0
    end

    def p95_request_time
      percentile_request_time(95.0)
    end

    def p99_request_time
      percentile_request_time(99.0)
    end

    def p50_request_time
      percentile_request_time(50.0)
    end

    def p25_request_time
      percentile_request_time(25.0)
    end

    def p90_request_time
      percentile_request_time(90.0)
    end

    def p75_request_time
      percentile_request_time(75.0)
    end

    def p999_request_time
      percentile_request_time(99.9)
    end

    def p10_request_time
      percentile_request_time(10.0)
    end

    def response_count
      @mutex.synchronize { @response_count }
    end

    def transport_error_count
      @mutex.synchronize { @transport_error_count }
    end

    def status_code_counts
      @mutex.synchronize { @status_code_counts.dup }
    end

    def error_counts
      @mutex.synchronize { @error_counts.dup }
    end

    def latency_histogram_bins(bin_count : Int32 = 11)
      @mutex.synchronize do
        return [] of NamedTuple(start_ms: Float64, end_ms: Float64, count: Int64, percent: Float64) if @response_count == 0

        if Stats.histogram_bucket_index(@min_request_time_ms) == Stats.histogram_bucket_index(@max_request_time_ms)
          return [{
            start_ms: @min_request_time_ms.round(2),
            end_ms:   @max_request_time_ms.round(2),
            count:    @response_count,
            percent:  100.0,
          }]
        end

        effective_bin_count = {1, bin_count}.max
        span_ms = @max_request_time_ms - @min_request_time_ms
        counts = Array(Int64).new(effective_bin_count, 0_i64)

        @latency_histogram.each_with_index do |count, index|
          next if count == 0

          latency_ms = Stats.histogram_bucket_value(index)
          bin_index = (((latency_ms - @min_request_time_ms) / span_ms) * effective_bin_count).floor.to_i
          bin_index = 0 if bin_index < 0
          bin_index = effective_bin_count - 1 if bin_index >= effective_bin_count
          counts[bin_index] += count
        end

        bins = [] of NamedTuple(start_ms: Float64, end_ms: Float64, count: Int64, percent: Float64)
        effective_bin_count.times do |index|
          start_ms = @min_request_time_ms + (span_ms * index / effective_bin_count)
          end_ms = if index == effective_bin_count - 1
                     @max_request_time_ms
                   else
                     @min_request_time_ms + (span_ms * (index + 1) / effective_bin_count)
                   end
          bins << {
            start_ms: start_ms.round(2),
            end_ms:   end_ms.round(2),
            count:    counts[index],
            percent:  ((counts[index].to_f / @response_count) * 100.0).round(2),
          }
        end

        bins
      end
    end

    def final_exit_code
      @mutex.synchronize { ci_threshold_failed_unlocked? ? 1 : 0 }
    end

    def failure_rate_percent
      @mutex.synchronize { failure_rate_percent_unlocked }
    end

    def json_output
      @output_format == "json"
    end

    def csv_output
      @output_format == "csv"
    end

    def quiet_output
      @output_format == "quiet"
    end

    def text_output?
      @output_format == "text"
    end

    def <<(request : Request)
      record_response request.time_taken, request.status_code, request.response_bytes
    end

    def record_response(time_taken_ms : Float64, status_code : Int32, response_bytes : Int64 = 0_i64)
      batch = Batch.new(@success_status_ranges)
      batch.record_response time_taken_ms, status_code, response_bytes
      merge_batch batch
    end

    def record_error(category : String)
      batch = Batch.new(@success_status_ranges)
      batch.record_error category
      merge_batch batch
    end

    def merge_batch(batch : Batch)
      return if batch.empty?

      @mutex.synchronize do
        merge_batch_without_lock batch
      end
    end

    private def total_request_time
      @mutex.synchronize { @total_request_time_ms }
    end

    private def merge_batch_without_lock(batch : Batch)
      previous_responses = @response_count
      batch_responses = batch.response_count

      @total_request_count += batch.total_request_count
      @response_count += batch_responses
      @total_response_bytes += batch.total_response_bytes
      @ok_requests += batch.ok_requests
      @not_ok_requests += batch.not_ok_requests
      @transport_error_count += batch.transport_error_count
      @total_request_time_ms += batch.total_request_time_ms
      @min_request_time_ms = @min_request_time_ms.finite? ? {@min_request_time_ms, batch.min_request_time_ms}.min : batch.min_request_time_ms
      @max_request_time_ms = {@max_request_time_ms, batch.max_request_time_ms}.max

      if batch_responses > 0
        if previous_responses == 0
          @mean_latency_ms = batch.mean_latency_ms
          @m2_latency_ms = batch.m2_latency_ms
        else
          combined_responses = @response_count
          delta = batch.mean_latency_ms - @mean_latency_ms
          @mean_latency_ms += delta * batch_responses / combined_responses
          @m2_latency_ms += batch.m2_latency_ms + delta * delta * previous_responses * batch_responses / combined_responses
        end
      end

      batch.latency_buckets.each do |bucket_index, count|
        @latency_histogram[bucket_index] += count
      end

      batch.status_code_counts.each do |status_code, count|
        @status_code_counts[status_code] += count
      end
      batch.error_counts.each do |category, count|
        @error_counts[category] += count
      end
    end

    private def ci_threshold_failed_unlocked? : Bool
      return true if transport_only_failure_unlocked?
      return true if fail_on_transport_error_threshold_unlocked?
      return true if fail_on_error_threshold_unlocked?
      return true if max_fail_rate_threshold_unlocked?
      return true if max_p99_threshold_unlocked?
      false
    end

    private def transport_only_failure_unlocked? : Bool
      @transport_error_count > 0 && @response_count == 0
    end

    private def fail_on_transport_error_threshold_unlocked? : Bool
      @ci_thresholds.fail_on_transport_error? && @transport_error_count > 0
    end

    private def fail_on_error_threshold_unlocked? : Bool
      @ci_thresholds.fail_on_error? && (@not_ok_requests > 0 || @transport_error_count > 0)
    end

    private def max_fail_rate_threshold_unlocked? : Bool
      if max_rate = @ci_thresholds.max_fail_rate
        return @total_request_count > 0 && failure_rate_percent_unlocked > max_rate
      end
      false
    end

    private def max_p99_threshold_unlocked? : Bool
      if max_p99 = @ci_thresholds.max_p99_ms
        return @total_request_count > 0 && percentile_request_time_unlocked(99.0) > max_p99
      end
      false
    end

    private def failure_rate_percent_unlocked : Float64
      return 0.0 if @total_request_count == 0
      ((@not_ok_requests + @transport_error_count).to_f / @total_request_count) * 100.0
    end

    private def percentile_request_time_unlocked(percentile : Float64) : Float64
      return 0.0 if @response_count == 0

      rank = (@response_count.to_f * (percentile / 100.0)).ceil.to_i64
      rank = 1_i64 if rank < 1
      seen = 0_i64

      @latency_histogram.each_with_index do |count, index|
        next if count == 0
        seen += count
        if seen >= rank
          return Stats.histogram_bucket_value(index).clamp(@min_request_time_ms, @max_request_time_ms)
        end
      end

      @max_request_time_ms
    end

    private def percentile_request_time(percentile : Float64)
      @mutex.synchronize { percentile_request_time_unlocked(percentile) }
    end
  end

  def self.create_stats(
    request_number,
    duration_mode : Bool = false,
    benchmark_start : Time::Instant = Time.instant,
    url : String = "",
    output_format : String = "text",
    success_status_ranges : Array(Range(Int32, Int32)) = [200..299],
    ci_thresholds : CiThresholds = CiThresholds.new,
    progress_enabled : Bool = false,
  )
    @@stats = Stats.new request_number, duration_mode, benchmark_start, url, output_format, success_status_ranges, ci_thresholds, progress_enabled
  end

  def self.stats
    stats = @@stats
    raise "Stats not initialized" unless stats
    stats
  end
end
