class Goal < ApplicationRecord
  include AASM, Monetizable

  COLORS = Category::COLORS
  ICONS = Category.icon_codes

  # Virtual attributes used by the create-modal stepper to capture an
  # optional initial contribution alongside the goal create payload.
  attr_accessor :initial_contribution_amount, :initial_contribution_account_id

  validates :icon, inclusion: { in: ICONS, allow_nil: true }

  belongs_to :family
  has_many :goal_accounts, dependent: :destroy
  has_many :linked_accounts, through: :goal_accounts, source: :account
  has_many :goal_contributions, dependent: :destroy

  validates :name, presence: true, length: { maximum: 255 }
  validates :target_amount, presence: true, numericality: { greater_than: 0 }
  validates :currency, presence: true
  validate :must_have_at_least_one_linked_account
  validate :linked_accounts_must_be_depository
  validate :linked_accounts_must_match_goal_currency
  validate :linked_accounts_must_belong_to_family
  validate :currency_locked_once_contributions_exist

  monetize :target_amount

  scope :alphabetically, -> { order(Arel.sql("LOWER(name) ASC")) }
  scope :active_first, lambda {
    order(Arel.sql("CASE state WHEN 'active' THEN 0 WHEN 'paused' THEN 1 WHEN 'completed' THEN 2 ELSE 3 END"))
  }
  scope :with_current_balance, lambda {
    left_outer_joins(:goal_contributions)
      .group(Arel.sql("goals.id"))
      .select(Arel.sql("goals.*, COALESCE(SUM(goal_contributions.amount), 0) AS current_balance_total"))
  }

  # 63-bit Postgres advisory-lock key per family. Used by future auto-fund flows
  # and any future per-family serialization of goal contributions.
  def self.advisory_lock_key_for(family_id)
    Digest::SHA1.hexdigest("goals:family:#{family_id}").to_i(16) % (2**63)
  end

  aasm column: :state do
    state :active, initial: true
    state :paused
    state :completed
    state :archived

    event :pause do
      transitions from: :active, to: :paused
    end

    event :resume do
      transitions from: :paused, to: :active
    end

    event :complete do
      transitions from: [ :active, :paused ], to: :completed
    end

    event :archive do
      transitions from: [ :active, :paused, :completed ], to: :archived
    end

    event :unarchive do
      transitions from: :archived, to: :active
    end
  end

  def current_balance
    @current_balance ||= if attributes.key?("current_balance_total")
      attributes["current_balance_total"] || 0
    else
      goal_contributions.sum(:amount)
    end
  end

  def current_balance_money
    @current_balance_money ||= Money.new(current_balance, currency)
  end

  def remaining_amount
    @remaining_amount ||= [ target_amount - current_balance, 0 ].max
  end

  def remaining_amount_money
    @remaining_amount_money ||= Money.new(remaining_amount, currency)
  end

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

    @progress_percent = if completed?
      100
    elsif target_amount.to_d.zero?
      0
    else
      [ ((current_balance.to_d / target_amount.to_d) * 100).round, 100 ].min
    end
  end

  def months_remaining
    return nil unless target_date

    months = (target_date.year - Date.current.year) * 12 + (target_date.month - Date.current.month)
    [ months, 0 ].max
  end

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

    @monthly_target_amount = if target_date.nil?
      nil
    elsif months_remaining.zero?
      remaining_amount
    else
      (remaining_amount.to_d / months_remaining).ceil(2)
    end
  end

  # Segment array consumed by the shared `donut-chart` Stimulus controller
  # (see app/javascript/controllers/donut_chart_controller.js). Same shape
  # as Budget#to_donut_segments_json: filled portion in goal color, unused
  # remainder as the system "unallocated" fill.
  def to_donut_segments_json
    filled = current_balance.to_d
    rem = remaining_amount.to_d

    if filled.zero? && rem.zero?
      return [ { color: "var(--budget-unallocated-fill)", amount: 1, id: "unused" } ]
    end

    segments = []
    segments << { color: color.presence || "var(--color-blue-500)", amount: filled, id: "saved" } if filled.positive?
    segments << { color: "var(--budget-unallocated-fill)", amount: rem, id: "unused" } if rem.positive?
    segments
  end

  # Cumulative contributions series for the projection chart, sorted by
  # date ascending. Consumed by the
  # `goal-projection-chart` Stimulus controller.
  def projection_payload
    sorted = goal_contributions.sort_by(&:contributed_at)
    running = 0
    saved_series = sorted.map do |c|
      running += c.amount.to_d
      { date: c.contributed_at.to_s, value: running.to_f }
    end

    earliest = [ created_at.to_date, sorted.first&.contributed_at ].compact.min

    {
      saved_series: saved_series,
      start_date: earliest.to_s,
      today: Date.current.to_s,
      target_date: target_date&.to_s,
      target_amount: target_amount.to_f,
      current_amount: current_balance.to_f,
      avg_monthly: average_monthly_contribution.to_f,
      required_monthly: monthly_target_amount.to_f,
      currency: currency,
      status: status.to_s
    }
  end

  # Display-layer status. Prefers AASM state for inactive goals so the UI
  # doesn't compute a misleading "Behind / On track" verdict against a goal
  # that isn't accepting contributions anymore.
  def display_status
    return @display_status if defined?(@display_status)

    @display_status = if archived?
      :archived
    elsif paused?
      :paused
    else
      status
    end
  end

  # :reached → progress_percent >= 100
  # :on_track → has target_date and current pace >= required monthly pace
  # :behind → has target_date and current pace < required monthly pace
  # :no_target_date → progress < 100 and target_date is nil
  def status
    return @status if defined?(@status)

    @status = if progress_percent >= 100
      :reached
    elsif target_date.nil?
      :no_target_date
    elsif monthly_target_amount.to_d <= average_monthly_contribution.to_d
      :on_track
    else
      :behind
    end
  end

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

    @average_monthly_contribution = if goal_contributions.empty?
      0
    else
      first_at = if goal_contributions.loaded?
        goal_contributions.map(&:contributed_at).compact.min
      else
        goal_contributions.minimum(:contributed_at)
      end
      if first_at.blank?
        current_balance
      else
        months = ((Date.current.year - first_at.year) * 12 + (Date.current.month - first_at.month)) + 1
        months = 1 if months < 1
        (current_balance.to_d / months).round(2)
      end
    end
  end

  def last_contribution_at
    @last_contribution_at ||= if goal_contributions.loaded?
      goal_contributions.map(&:contributed_at).compact.max
    else
      goal_contributions.maximum(:contributed_at)
    end
  end

  def last_contribution_days_ago
    last = last_contribution_at
    return nil if last.nil?

    (Date.current - last).to_i
  end

  private
    def must_have_at_least_one_linked_account
      return unless goal_accounts.reject(&:marked_for_destruction?).empty?

      errors.add(:base, :at_least_one_linked_account_required)
    end

    def linked_accounts_must_be_depository
      offending = goal_accounts.reject(&:marked_for_destruction?).reject do |sga|
        sga.account&.depository?
      end
      return if offending.empty?

      errors.add(:linked_accounts, :must_be_depository)
    end

    def linked_accounts_must_match_goal_currency
      return if currency.blank?

      mismatched = goal_accounts.reject(&:marked_for_destruction?).reject do |sga|
        sga.account.nil? || sga.account.currency == currency
      end
      return if mismatched.empty?

      errors.add(:linked_accounts, :currency_mismatch)
    end

    def linked_accounts_must_belong_to_family
      return if family.nil?

      foreign = goal_accounts.reject(&:marked_for_destruction?).reject do |sga|
        sga.account.nil? || sga.account.family_id == family_id
      end
      return if foreign.empty?

      errors.add(:linked_accounts, :must_belong_to_family)
    end

    # Once a goal has contributions, changing currency would orphan amounts
    # in the old currency. Lock it.
    def currency_locked_once_contributions_exist
      return unless persisted? && currency_changed?
      return unless goal_contributions.exists?

      errors.add(:currency, :locked_after_contributions)
    end
end
