# frozen_string_literal: true

# License is the artifact of purchasing a GitLab subscription for self-managed
# and it is installed at instance level.
# GitLab SaaS is a special self-managed instance which has a license installed
# that is mapped to an Ultimate plan.
class License < ApplicationRecord
  include ActionView::Helpers::NumberHelper
  include Gitlab::Utils::StrongMemoize

  STARTER_PLAN = 'starter'
  PREMIUM_PLAN = 'premium'
  ULTIMATE_PLAN = 'ultimate'
  ONLINE_CLOUD_TYPE = 'online_cloud'
  OFFLINE_CLOUD_TYPE = 'offline_cloud'
  LEGACY_LICENSE_TYPE = 'legacy_license'
  ALLOWED_PERCENTAGE_OF_USERS_OVERAGE = (10 / 100.0)

  NOTIFICATION_DAYS_BEFORE_TRIAL_EXPIRY = 1.week
  ADMIN_NOTIFICATION_DAYS_BEFORE_EXPIRY = 15.days
  GRACE_PERIOD = 2.weeks

  EE_ALL_PLANS = [STARTER_PLAN, PREMIUM_PLAN, ULTIMATE_PLAN].freeze

  ACTIVE_USER_COUNT_THRESHOLD_LEVELS = [
    { range: (2..15), percentage: false, value: 1 },
    { range: (16..25), percentage: false, value: 2 },
    { range: (26..99), percentage: true, value: 10 },
    { range: (100..999), percentage: true, value: 8 },
    { range: (1000..nil), percentage: true, value: 5 }
  ].freeze

  LICENSEE_ATTRIBUTES = %w[Name Email Company].freeze

  validate :valid_license
  validate :check_users_limit, if: :new_record?, unless: [:validate_with_trueup?, :reconciliation_completed?]
  validate :check_trueup, unless: :reconciliation_completed?, if: [:new_record?, :validate_with_trueup?]
  validate :check_available_seats, if: [:new_record?, :reconciliation_completed?]

  # When an online cloud license subscription is cancelled, the license_key received from the seat link sync
  # is an expired license but we still want to be able to create it to reflect that the license is not active anymore.
  # That is why we skip the validation on this specific case.
  validate :not_expired, if: :new_record?, unless: :subscription_cancelled?

  before_validation :reset_license, if: :data_changed?

  after_create :update_trial_setting
  after_commit :reset_current
  after_commit :reset_future_dated, on: [:create, :destroy]

  scope :cloud, -> { where(cloud: true) }
  scope :recent, -> { reorder(id: :desc) }
  scope :last_hundred, -> { recent.limit(100) }

  CACHE_KEY = :current_license

  class << self
    def current
      cache.fetch(CACHE_KEY, as: License, expires_in: 1.minute) { load_license }
    end

    def reset_current
      cache.expire(CACHE_KEY)
    end

    def cache
      Gitlab::SafeRequestStore[:license_cache] ||=
        Gitlab::Cache::JsonCaches::RedisKeyed.new(namespace: :ee, backend: ::Gitlab::ProcessMemoryCache.cache_backend, cache_key_strategy: :version)
    end

    def all_plans
      EE_ALL_PLANS
    end

    def block_changes?
      !!current&.block_changes?
    end

    def feature_available?(feature)
      # Include features available per plan + usage ping features if Usage Pings is enabled
      # as instance setting.
      !!current&.feature_available?(feature) ||
        GitlabSubscriptions::Features.usage_ping_feature?(feature)
    end

    def load_license
      return unless self.table_exists?

      last_hundred = self.last_hundred

      # Gather the names of existing online cloud licenses that are `generated_from_cancellation=true` here
      # to make sure we don't fall back to an outdated active version of a license that belongs to
      # a cancelled subscription.
      cancelled_subscription_names = last_hundred.filter_map do |license|
        license.subscription_name if license.subscription_cancelled?
      end

      last_hundred.find { |license| license.valid_started? && !license.expired? && cancelled_subscription_names.exclude?(license.subscription_name) } ||
        last_hundred.find(&:valid_started?)
    end

    def future_dated
      Gitlab::SafeRequestStore.fetch(:future_dated_license) { load_future_dated }
    end

    def reset_future_dated
      Gitlab::SafeRequestStore.delete(:future_dated_license)
    end

    def eligible_for_trial?
      Gitlab::CurrentSettings.license_trial_ends_on.nil?
    end

    def trial_ends_on
      Gitlab::CurrentSettings.license_trial_ends_on
    end

    def history
      decryptable_licenses = all.select { |license| license.license.present? }
      decryptable_licenses.sort_by { |license| [license.starts_at, license.created_at, license.expires_at] }.reverse
    end

    def with_valid_license
      current_license = License.current

      return unless current_license
      return if current_license.trial?

      yield(current_license) if block_given?
    end

    def current_cloud_license?(key)
      current_license = License.current
      return false unless current_license&.cloud_license?

      current_license.data == key
    end

    # rubocop: disable Gitlab/FeatureAvailableUsage -- `License.feature_available?` is allowed, but the cop doesn't detect that we're inside the `License` class itself.
    def ai_features_available?
      feature_available?(:ai_features) || feature_available?(:ai_chat)
    end
    # rubocop: enable Gitlab/FeatureAvailableUsage

    # rubocop: disable Gitlab/FeatureAvailableUsage -- `License.feature_available?` is allowed, but the cop doesn't detect that we're inside the `License` class itself.
    def duo_core_features_available?
      feature_available?(:code_suggestions) || feature_available?(:ai_chat)
    end
    # rubocop: enable Gitlab/FeatureAvailableUsage

    private

    def load_future_dated
      self.last_hundred.find { |license| license.valid? && license.future_dated? }
    end
  end

  def valid_started?
    valid? && started?
  end

  def offline_cloud_license?
    cloud_license? && !!license&.offline_cloud_licensing?
  end

  def seats
    restricted_attr(:active_user_count)
  end

  def ultimate?
    plan == License::ULTIMATE_PLAN
  end

  def premium?
    plan == License::PREMIUM_PLAN
  end

  def customer_service_enabled?
    !!license&.operational_metrics?
  end

  def trial?
    restricted_attr(:trial)
  end

  def data_filename
    company_name = self.licensee_company || self.licensee.each_value.first
    clean_company_name = company_name.gsub(/[^A-Za-z0-9]/, "")
    "#{clean_company_name}.gitlab-license"
  end

  def data_file=(file)
    self.data = file.read
  end

  def normalized_data
    data.gsub("\r\n", "\n").gsub(/\n+$/, '') + "\n"
  end

  def md5
    return if Gitlab::FIPS.enabled?

    Digest::MD5.hexdigest(normalized_data)
  end

  def sha256
    Digest::SHA256.hexdigest(normalized_data)
  end

  def license
    return unless self.data

    @license ||=
      begin
        Gitlab::License.import(self.data)
      rescue Gitlab::License::ImportError
        nil
      end
  end

  def license?
    self.license && self.license.valid?
  end

  def method_missing(method_name, *arguments, &block)
    if License.column_names.include?(method_name.to_s)
      super
    elsif license && license.respond_to?(method_name)
      license.__send__(method_name, *arguments, &block) # rubocop:disable GitlabSecurity/PublicSend
    else
      super
    end
  end

  def respond_to_missing?(method_name, include_private = false)
    if License.column_names.include?(method_name.to_s)
      super
    elsif license && license.respond_to?(method_name)
      true
    else
      super
    end
  end

  # New licenses persists only the `plan` (premium, starter, ..). But, old licenses
  # keep `add_ons`.
  def add_ons
    restricted_attr(:add_ons, {})
  end

  # License zuora_subscription_id
  def subscription_id
    restricted_attr(:subscription_id)
  end

  def subscription_name
    restricted_attr(:subscription_name)
  end

  def reconciliation_completed?
    restricted_attr(:reconciliation_completed)
  end

  def features
    @features ||= GitlabSubscriptions::Features.features(plan: plan, add_ons: add_ons)
  end

  def feature_available?(feature)
    return false if trial? && expired?

    features.include?(feature)
  end

  def license_id
    restricted_attr(:id)
  end

  def previous_user_count
    restricted_attr(:previous_user_count)
  end

  def plan
    restricted_attr(:plan).presence || STARTER_PLAN
  end

  def edition
    case restricted_attr(:plan)
    when 'ultimate'
      'EEU'
    when 'premium'
      'EEP'
    when 'starter'
      'EES'
    else # Older licenses
      'EE'
    end
  end

  def daily_billable_users_count
    daily_billable_users.count
  end

  def daily_billable_users_updated_time
    (daily_billable_users.try(:recorded_at) || Time.zone.now).to_s
  end

  def validate_with_trueup?
    return false if cloud_license?

    [restricted_attr(:trueup_quantity),
      restricted_attr(:trueup_from),
      restricted_attr(:trueup_to)].all?(&:present?)
  end

  alias_method :exclude_guests_from_active_count?, :ultimate?

  def remaining_days
    return 0 if expired?

    (expires_at - Date.today).to_i
  end

  def overage(user_count = nil)
    return 0 if seats.nil?

    user_count ||= daily_billable_users_count

    [user_count - seats, 0].max
  end

  def overage_with_historical_max
    overage(maximum_user_count)
  end

  def historical_data(from: nil, to: nil)
    from ||= starts_at_for_historical_data
    to ||= expires_at_for_historical_data

    HistoricalData.during(from..to)
  end

  def historical_max(from: nil, to: nil)
    from ||= starts_at_for_historical_data
    to ||= expires_at_for_historical_data

    HistoricalData.max_historical_user_count(from: from, to: to)
  end

  def maximum_user_count
    [historical_max(from: starts_at), daily_billable_users_count].max
  end

  def update_trial_setting
    return unless license.restrictions[:trial]
    return if license.expires_at.nil?

    settings = ApplicationSetting.current
    return if settings.nil?
    return if settings.license_trial_ends_on.present?

    settings.update license_trial_ends_on: license.expires_at
  end

  def paid?
    [License::STARTER_PLAN, License::PREMIUM_PLAN, License::ULTIMATE_PLAN].include?(plan)
  end

  def started?
    starts_at <= Date.current
  end

  def future_dated?
    starts_at > Date.current
  end

  def cloud_license?
    !!license&.cloud_licensing?
  end

  def online_cloud_license?
    cloud_license? && !license&.offline_cloud_licensing?
  end

  def subscription_cancelled?
    online_cloud_license? && license&.generated_from_cancellation?
  end

  def current?
    self == License.current
  end

  def license_type
    return OFFLINE_CLOUD_TYPE if offline_cloud_license?
    return ONLINE_CLOUD_TYPE if online_cloud_license?

    LEGACY_LICENSE_TYPE
  end

  def grace_period_expired?
    return false if expires_at.blank?

    (expires_at + GRACE_PERIOD).past?
  end

  def auto_renew
    false
  end

  def active_user_count_threshold
    ACTIVE_USER_COUNT_THRESHOLD_LEVELS.find do |threshold|
      threshold[:range].include?(seats)
    end
  end

  def active_user_count_threshold_reached?
    return false if seats.nil?
    return false if daily_billable_users_count <= 1
    return false if daily_billable_users_count > seats

    active_user_count_threshold[:value] >= if active_user_count_threshold[:percentage]
                                             remaining_user_count.fdiv(daily_billable_users_count) * 100
                                           else
                                             remaining_user_count
                                           end
  end

  def remaining_user_count
    seats - daily_billable_users_count
  end

  LICENSEE_ATTRIBUTES.each do |attribute|
    define_method "licensee_#{attribute.downcase}" do
      licensee[attribute]
    end
  end

  def activated_at
    super || created_at
  end

  # Overrides method from Gitlab::License which will be removed in a future version
  def notify_admins?
    return false if expires_at.blank?
    return true if expired?

    notification_days = trial? ? NOTIFICATION_DAYS_BEFORE_TRIAL_EXPIRY : ADMIN_NOTIFICATION_DAYS_BEFORE_EXPIRY

    Date.current >= (expires_at - notification_days)
  end

  # Overrides method from Gitlab::License which will be removed in a future version
  def notify_users?
    return false if expires_at.blank?

    notification_start_date = trial? ? expires_at - NOTIFICATION_DAYS_BEFORE_TRIAL_EXPIRY : block_changes_at

    Date.current >= notification_start_date
  end

  private

  def restricted_attr(name, default = nil)
    return default unless license? && restricted?(name)

    restrictions[name]
  end

  def reset_current
    self.class.reset_current
  end

  def reset_future_dated
    self.class.reset_future_dated
  end

  def reset_license
    @license = nil
  end

  def valid_license
    return if license?

    error_message = if online_cloud_license?
                      _('The license key is invalid.')
                    else
                      _('The license key is invalid. Make sure it is exactly as you received it from GitLab Inc.')
                    end

    self.errors.add(:base, error_message)
  end

  # This method, `previous_started_at` and `previous_expired_at` are
  # only used in the validation methods `check_users_limit` and check_trueup
  # which are only used when uploading/creating a new license.
  # The method will not work in other workflows since it has a dependency to
  # use the current license as the previous in the system.
  def prior_historical_max
    strong_memoize(:prior_historical_max) do
      historical_max(from: previous_started_at, to: previous_expired_at)
    end
  end

  # See comment for `prior_historical_max`.
  def previous_started_at
    (License.current&.starts_at || (starts_at - 1.year)).beginning_of_day
  end

  # See comment for `prior_historical_max`.
  def previous_expired_at
    (License.current&.expires_at || (expires_at && (expires_at - 1.year)) || starts_at).end_of_day
  end

  def seats_with_threshold
    (seats * (1 + ALLOWED_PERCENTAGE_OF_USERS_OVERAGE)).to_i
  end

  def check_users_limit
    return if cloud_license?
    return unless seats

    user_count = daily_billable_users_count
    current_period = true

    if previous_user_count && (prior_historical_max <= previous_user_count)
      return if seats_with_threshold >= daily_billable_users_count
    else
      return if seats_with_threshold >= prior_historical_max

      user_count = prior_historical_max
      current_period = false
    end

    add_limit_error(current_period: current_period, user_count: user_count)
  end

  def trueup_from
    Date.parse(restrictions[:trueup_from]).beginning_of_day
  rescue StandardError
    previous_started_at
  end

  def trueup_to
    Date.parse(restrictions[:trueup_to]).end_of_day
  rescue StandardError
    previous_expired_at
  end

  def check_trueup
    unless trueup_period_seat_count
      check_available_seats
      return
    end

    trueup_qty = restrictions[:trueup_quantity]
    max_historical = historical_max(from: trueup_from, to: trueup_to)
    expected_trueup_qty = max_historical - trueup_period_seat_count

    if trueup_quantity_with_threshold >= expected_trueup_qty
      check_available_seats
    else
      message = ["You have applied a True-up for #{trueup_qty} #{'user'.pluralize(trueup_qty)}"]
      message << "but you need one for #{expected_trueup_qty} #{'user'.pluralize(expected_trueup_qty)}."
      message << "Please contact sales at https://about.gitlab.com/sales/"

      self.errors.add(:base, :check_trueup, message: message.join(' '))
    end
  end

  def trueup_quantity_with_threshold
    (restrictions[:trueup_quantity] * (1 + ALLOWED_PERCENTAGE_OF_USERS_OVERAGE)).to_i
  end

  def check_available_seats
    return if cloud_license?
    return unless seats && seats_with_threshold < daily_billable_users_count

    add_limit_error(type: :check_available_seats, user_count: daily_billable_users_count)
  end

  def add_limit_error(user_count:, current_period: true, type: :invalid)
    overage_count = overage(user_count)

    message =  [current_period ? "This GitLab installation currently has" : "During the year before this license started, this GitLab installation had"]
    message << "#{number_with_delimiter(user_count)} active #{'user'.pluralize(user_count)},"
    message << "exceeding this license's limit of #{number_with_delimiter(seats)} by"
    message << "#{number_with_delimiter(overage_count)} #{'user'.pluralize(overage_count)}."
    message << "Please add a license for at least"
    message << "#{number_with_delimiter(user_count)} #{'user'.pluralize(user_count)} or contact sales at https://about.gitlab.com/sales/"

    self.errors.add(:base, type, message: message.join(' '))
  end

  def not_expired
    return unless self.license? && self.expired?

    self.errors.add(:base, _('This license has already expired.'))
  end

  def starts_at_for_historical_data
    (starts_at || (Time.current - 1.year)).beginning_of_day
  end

  def expires_at_for_historical_data
    (expires_at || Time.current).end_of_day
  end

  def daily_billable_users
    strong_memoize(:daily_billable_users) do
      ::Analytics::UsageTrends::Measurement.find_latest_or_fallback(:billable_users)
    end
  end

  def trueup_period_seat_count
    restricted_attr(:trueup_period_seat_count) || previous_user_count
  end
end

License.prepend_mod
