# frozen_string_literal: true

require "digest"
require "set"

require "roda"
require "rate"
require "weekly_rate"
require "monthly_rate"
require "roundable"
require "blender"
require "carry_forward"
require "money/currency"
require "peg"
require "peg_anchor"

module Versions
  class V2 < Roda
    class RateQuery
      include Roundable

      class ValidationError < StandardError; end

      ALLOWED_EXPANSIONS = ["providers"].freeze
      ALLOWED_PARAMS = ["base", "quotes", "providers", "date", "from", "to", "group", "expand"].freeze
      CHUNK_MONTHS = { "week" => 21, "month" => 84 }.freeze
      DEFAULT_CHUNK_MONTHS = 3
      PIVOT = "USD"

      def initialize(params)
        @params = params
        validate!
      end

      def to_a
        @rates ||= [].tap { |a| each { |r| a << r } }
      end

      def each(&block)
        return to_enum(:each) unless block

        if date_scope.is_a?(Range)
          each_chunk(date_scope) do |chunk_range|
            ds = range_dataset
            date_col = ds.model.date_column

            rows = ds.between(chunk_range).all
            normalize_dates!(rows, date_col) if date_col != :date
            rows.group_by { |r| r[:date] }.each do |_, group_rows|
              emit_blended(group_rows, &block)
            end
          end
        else
          window = raw_dataset.where(date: (date_scope - CarryForward::LOOKBACK_DAYS)..date_scope)
          rows = CarryForward.apply(window.naked.all, date: date_scope)
          emit_blended(rows, &block)
        end
      end

      def range?
        date_scope.is_a?(Range)
      end

      def cache_key
        Digest::MD5.hexdigest([max_date, expand].join("|"))
      end

      def expand_providers?
        expand&.include?("providers") || false
      end

      private

      def max_date
        ds = raw_dataset
        if date_scope.is_a?(Range)
          ds.where(date: date_scope).max(:date)
        else
          ds.where(date: (date_scope - CarryForward::LOOKBACK_DAYS)..date_scope).max(:date)
        end
      end

      def rollup?
        range? && ["week", "month"].include?(group)
      end

      def rollup_model
        case group
        when "week" then WeeklyRate
        when "month" then MonthlyRate
        end
      end

      def apply_filters(ds)
        ds = ds.where(provider: providers) if providers
        if quotes
          currencies = Set.new(quotes)
          currencies << effective_base
          currencies << PIVOT
          quotes.each { |q| (peg = Peg.find(q)) && currencies << peg.base }
          ds = ds.only(*currencies)
        end
        ds
      end

      def raw_dataset
        apply_filters(Rate.dataset)
      end

      def range_dataset
        apply_filters(rollup? ? rollup_model.dataset : Rate.dataset)
      end

      def base
        @params[:base]&.upcase || "EUR"
      end

      def base_peg
        return @base_peg if defined?(@base_peg)

        @base_peg = Peg.find(base)
      end

      def effective_base
        base_peg&.base || base
      end

      def quotes
        @params[:quotes]&.upcase&.split(",")
      end

      def providers
        @params[:providers]&.upcase&.split(",")
      end

      def group
        @params[:group]&.downcase
      end

      def expand
        @params[:expand]&.downcase&.split(",")
      end

      def date
        parse_date(@params[:date])
      end

      def start_date
        parse_date(@params[:from])
      end

      def end_date
        parse_date(@params[:to])
      end

      def parse_date(value)
        return unless value

        Date.parse(value)
      rescue Date::Error
        nil
      end

      def validate!
        validate_params!
        validate_dates!
        validate_conflicting_params!
        validate_group!
        validate_expand!
        validate_currencies!
      end

      def validate_params!
        unknown = @params.keys.map(&:to_s) - ALLOWED_PARAMS
        raise ValidationError, "unknown parameter: #{unknown.join(", ")}" if unknown.any?
      end

      def validate_dates!
        raise ValidationError, "invalid date" if [:date, :from, :to].any? { |key| @params[key] && !parse_date(@params[key]) }
      end

      def validate_conflicting_params!
        raise ValidationError, "conflicting params" if date && (start_date || end_date)
      end

      def validate_group!
        raise ValidationError, "invalid group" if group && !["week", "month"].include?(group)
      end

      def validate_expand!
        return unless expand

        unknown = expand - ALLOWED_EXPANSIONS
        raise ValidationError, "invalid expand: #{unknown.join(",")}" if unknown.any?
      end

      def validate_currencies!
        invalid = []
        invalid << base if @params[:base] && !Money::Currency.find(base)
        invalid.concat(quotes.reject { |q| Money::Currency.find(q) }) if quotes
        raise ValidationError, "invalid currency: #{invalid.join(",")}" if invalid.any?
      end

      def date_scope
        if date
          date
        elsif start_date
          start_date..(end_date || Date.today)
        else
          Date.today
        end
      end

      def emit_blended(rows, &block)
        blended = if fast_path?(rows)
          fast_path_blend(rows)
        else
          pivot_path_blend(rows)
        end

        return if blended.empty?

        records = blended.filter_map do |r|
          next if quotes && !quotes.include?(r[:quote])

          record = { date: r[:date].to_s, base: r[:base], quote: r[:quote], rate: round(r[:rate]) }
          if expand_providers? && r[:providers]
            record[:providers] = r[:providers].map do |p|
              entry = { key: p[:key], rate: round(p[:rate]) }
              entry[:excluded] = true if p[:excluded]
              entry
            end
          end
          record
        end

        records.sort_by! { |r| r[:quote] }
        records.each(&block)
      end

      def fast_path_blend(rows)
        return [] if providers && base_peg

        blended = Blender.new(rows, base: effective_base).blend
        blended = PegAnchor.apply(blended, base: effective_base) unless providers
        scale_for_pegged_base(blended)
      end

      def pivot_path_blend(rows)
        blended = Blender.new(rows, base: PIVOT).blend
        blended = PegAnchor.apply(blended, base: PIVOT) unless providers
        return [] if blended.empty?
        return blended if base == PIVOT

        derive(blended, target: base)
      end

      # Fast-path counterpart to derive: when the request base is pegged but every input row already lives in the
      # peg's base (effective_base), the blend stays in effective_base. We then scale the rows to the user's base by
      # 1/peg.rate and append a base->peg.base row. Mirrors the legacy PegAnchor#scale_to_user_base + base_peg_row,
      # confined to the fast path; the pivot path's `derive` already yields rows in the requested base.
      def scale_for_pegged_base(rows)
        return rows unless base_peg
        return [] if rows.empty?

        scaled = rows.filter_map do |r|
          next if r[:quote] == base

          rebase_row(r, base: base) { |x| x / base_peg.rate }
        end
        unless scaled.any? { |r| r[:quote] == base_peg.base }
          ref = scaled.map { |r| r[:date] }.max
          scaled << { date: ref, base: base, quote: base_peg.base, rate: 1.0 / base_peg.rate }
        end
        scaled
      end

      def normalize_dates!(rows, date_col)
        rows.each { |r| r.values[:date] = r.values.delete(date_col) }
      end

      def each_chunk(range)
        months = CHUNK_MONTHS.fetch(group, DEFAULT_CHUNK_MONTHS)
        cursor = range.begin
        while cursor <= range.end
          chunk_end = [(cursor >> months) - 1, range.end].min
          yield cursor..chunk_end
          cursor = chunk_end + 1
        end
      end

      # True iff every input row already has effective_base as its :base. In that case there is nothing to
      # cross-convert, so the blend can run directly in user's base without round-tripping through PIVOT.
      def fast_path?(rows)
        rows.all? { |r| r[:base] == effective_base }
      end

      # Given rows blended in PIVOT (one per :quote), return rows rebased to `target` by division. Appends a
      # `target -> PIVOT` row. Rows whose quote is `target` are dropped (no base->base row). Returns [] if
      # `target` is not present in the input (no path to derive).
      def derive(rows, target:)
        return [] if rows.empty?

        pivot_to_target = rows.find { |r| r[:quote] == target }
        return [] unless pivot_to_target

        target_rate = pivot_to_target[:rate]
        derived = rows.filter_map do |r|
          next if r[:quote] == target

          rebase_row(r, base: target) { |x| x / target_rate }
        end

        derived << rebase_row(pivot_to_target, base: target, quote: pivot_to_target[:base]) { |x| 1.0 / x }
        derived
      end

      # Apply the same transform to a row's :rate and to each provider's :rate (when present). Other row fields
      # can be overridden via keyword args (e.g. base:, quote:). Used to rebase blended rows together with the
      # per-provider rates that produced them, so the providers list stays consistent with the row's base.
      def rebase_row(row, **overrides)
        new_row = row.merge(**overrides, rate: yield(row[:rate]))
        if row[:providers]
          new_row[:providers] = row[:providers].map { |p| p.merge(rate: yield(p[:rate])) }
        end
        new_row
      end
    end
  end
end
