class Match < ApplicationRecord belongs_to :team has_many :stream_sessions, dependent: :destroy has_one_attached :opponent_logo_file ACTIVE_SESSION_STATUSES = %w[live connecting reconnecting paused].freeze DEFAULT_OPPONENT_COLOR = "#1E3A8A" validates :opponent_name, presence: true validates :sets_to_win, numericality: { greater_than: 0, less_than: 6 } validates :sport_key, presence: true validates :opponent_primary_color, format: { with: Brandable::HEX_COLOR }, allow_blank: true validate :sport_key_known validate :overlay_kind_allowed validate :scoring_rules_shape validate :opponent_logo_file_type, if: -> { opponent_logo_file.attached? } before_validation :normalize_sport_key before_validation :inherit_sport_from_team, on: :create scope :scheduled_for_live, -> { where.not(scheduled_at: nil).where("scheduled_at >= ?", Time.zone.now) } scope :scheduled_upcoming, -> { scheduled_for_live } def scheduled_upcoming? scheduled_at.present? && scheduled_at >= Time.zone.now end def scheduled_on_calendar? scheduled_at.present? && scheduled_at >= Time.zone.now.beginning_of_day end def active_stream_session latest = stream_sessions.max_by(&:created_at) return nil if latest.nil? || latest.status.in?(%w[ended error]) latest end def deletable? session = active_stream_session session.nil? || !session.resumable? end def public_status_label active = active_stream_session return "In corso / da riprendere" if active return "Programmata" if scheduled_upcoming? return "Pronta" if scheduled_at.present? "Senza orario" end scope :search_teams_or_opponents, lambda { |query| q = query.to_s.strip return all if q.blank? term = "%#{sanitize_sql_like(q)}%" joins(team: :club).where( "teams.name ILIKE :term OR matches.opponent_name ILIKE :term OR clubs.name ILIKE :term OR matches.location ILIKE :term", term: term ) } def broadcast_active? stream_sessions.where(status: ACTIVE_SESSION_STATUSES).exists? end def stream_completed? stream_sessions.where(status: %w[ended error]).exists? end def opponent_logo_url return unless opponent_logo_file.attached? Rails.application.routes.url_helpers.rails_blob_path(opponent_logo_file, only_path: true) end def effective_opponent_primary_color opponent_primary_color.presence || DEFAULT_OPPONENT_COLOR end def coach_hub_visible? active = active_stream_session return true if active&.resumable? return true if active&.idle? return false if stream_completed? scheduled_upcoming? end def effective_board_type Sports::Catalog.board_for(sport_key) end def effective_overlay_kind kind = overlay_kind.presence || Sports::Catalog.overlay_for(sport_key) allowed = Sports::Catalog.allowed_overlays_for(sport_key) allowed.include?(kind) ? kind : Sports::Catalog.overlay_for(sport_key) end def effective_scoring_rules merged = Sports::Catalog.default_rules_for(sport_key).deep_merge(normalized_scoring_rules) merged["sets_to_win"] = sets_to_win if merged["sets_to_win"].blank? && sets_to_win.present? merged end def sport_label Sports::Catalog.find_optional(sport_key)&.dig(:label) || sport_key.to_s.humanize end # Alias legacy API/spec (colonna rinominata in sport_key). def sport sport_key end def sport=(value) self.sport_key = Sports::Catalog.normalize_key(value) end private def normalize_sport_key self.sport_key = Sports::Catalog.normalize_key(sport_key) end def inherit_sport_from_team self.sport_key = team.sport_key if sport_key.blank? && team.present? end def sport_key_known return if sport_key.blank? return if Sports::Catalog.find_optional(sport_key) errors.add(:sport_key, "non valido") end def overlay_kind_allowed return if overlay_kind.blank? unless Sports::OverlayKind.valid?(overlay_kind) errors.add(:overlay_kind, "non valido") return end allowed = Sports::Catalog.allowed_overlays_for(sport_key) errors.add(:overlay_kind, "non consentito per questo sport") unless allowed.include?(overlay_kind) end def normalized_scoring_rules return {} unless scoring_rules.is_a?(Hash) scoring_rules.each_with_object({}) do |(k, v), h| h[k.to_s] = v end end def opponent_logo_file_type return if opponent_logo_file.content_type.in?(%w[image/png image/jpeg image/webp]) errors.add(:opponent_logo_file, "deve essere PNG, JPEG o WebP") end def scoring_rules_shape Sports::RulesSchema.validate!(effective_board_type, normalized_scoring_rules) rescue ArgumentError => e errors.add(:scoring_rules, e.message) end end