# frozen_string_literal: true

require "json"
require "optparse"
require_relative "command_helpers"

module SimpleCov
  module CLI
    # `simplecov uncovered [--threshold N] [--top N] [--criterion C]` — list
    # the lowest-coverage files (by the chosen criterion, ascending), so a
    # developer can answer "where should I add tests next?" without
    # opening a browser. Reads coverage.json directly.
    module Uncovered
      extend CommandHelpers

      DEFAULT_TOP = 10

    module_function

      def run(args, stdout:, stderr:, **)
        opts = parse(args)
        keys = CoverageFile::CRITERIA[opts[:criterion]]
        return unknown_criterion(opts[:criterion], stderr) unless keys

        report(opts, keys, stdout, stderr)
      end

      def report(opts, keys, stdout, stderr)
        coverage = CoverageFile.load_coverage(opts[:input], command: "uncovered", stderr: stderr)
        return 1 unless coverage

        files = rank(coverage, opts[:threshold], keys).first(opts[:top])
        return stdout.puts(empty_message(opts[:json])) || 0 if files.empty?

        emit(stdout, files, opts)
        0
      end

      def unknown_criterion(criterion, stderr)
        stderr.puts("simplecov uncovered: unknown --criterion #{criterion.inspect} (expected line, branch, or method)")
        1
      end

      def emit(stdout, files, opts)
        opts[:json] ? emit_json(stdout, files) : emit_text(stdout, files, SimpleCov::CLI.color_enabled?(opts, stdout))
      end

      def parse(args)
        opts, = parse_common(args, threshold: 100.0, top: DEFAULT_TOP, criterion: :line) do |o, options|
          o.on("--threshold N", Float) { |v| options[:threshold] = v }
          o.on("--top N", Integer)     { |v| options[:top] = validate_top(v) }
          o.on("--criterion C")        { |v| options[:criterion] = v.to_sym }
        end
        opts
      end

      # A negative count would raise from `Array#first`; report it as
      # the parse error it is (`--top 0` is allowed and shows nothing).
      # OptionParser prepends the offending option's name, so the raise
      # carries only the reason.
      def validate_top(value)
        raise OptionParser::InvalidArgument, "must not be negative" if value.negative?

        value
      end

      def emit_text(stdout, files, color)
        files.each { |fname, pct, covered, total| stdout.puts(format_row(fname, pct, covered, total, color)) }
      end

      def emit_json(stdout, files)
        rows = files.map do |fname, pct, covered, total|
          {"file" => fname, "percent" => pct, "covered" => covered, "total" => total}
        end
        stdout.puts(JSON.pretty_generate(rows))
      end

      def empty_message(json)
        json ? "[]" : "simplecov uncovered: nothing to report"
      end

      def rank(coverage_hash, threshold, keys)
        rows = coverage_hash.filter_map { |fname, payload| row_for(fname, payload, threshold, keys) }
        rows.sort_by { |_fname, pct, _c, _t| pct }
      end

      def row_for(fname, payload, threshold, keys)
        return unless payload.is_a?(Hash) && payload[keys[:total]].to_i.positive?

        pct = payload[keys[:percent]].to_f
        return if pct >= threshold

        [fname, pct, payload[keys[:covered]].to_i, payload[keys[:total]].to_i]
      end

      def format_row(fname, pct, covered, total, color)
        format("%<pct>s  %<covered>d/%<total>d  %<fname>s",
               pct: SimpleCov::Color.colorize_percent(pct, format("%6.2f%%", pct), enabled: color),
               covered: covered, total: total, fname: fname)
      end
    end
  end
end
