# typed: strict
# frozen_string_literal: true

require "utils/curl"
require "utils/github"

# Helper module for updating SPDX license data.
module SPDX
  module_function

  DATA_PATH = T.let((HOMEBREW_DATA_PATH/"spdx").freeze, Pathname)
  API_URL = "https://api.github.com/repos/spdx/license-list-data/releases/latest"
  LICENSEREF_PREFIX = "LicenseRef-Homebrew-"
  ALLOWED_LICENSE_SYMBOLS = [
    :public_domain,
    :cannot_represent,
    :truncated,
  ].freeze

  sig { returns(T::Hash[String, T.untyped]) }
  def license_data
    @license_data ||= T.let(JSON.parse((DATA_PATH/"spdx_licenses.json").read), T.nilable(T::Hash[String, T.untyped]))
  end

  sig { returns(T::Hash[String, T.untyped]) }
  def exception_data
    @exception_data ||= T.let(JSON.parse((DATA_PATH/"spdx_exceptions.json").read),
                              T.nilable(T::Hash[String, T.untyped]))
  end

  sig { returns(String) }
  def latest_tag
    @latest_tag ||= T.let(GitHub::API.open_rest(API_URL)["tag_name"], T.nilable(String))
  end

  sig { params(to: Pathname).void }
  def download_latest_license_data!(to: DATA_PATH)
    data_url = "https://raw.githubusercontent.com/spdx/license-list-data/refs/tags/#{latest_tag}/json/"
    Utils::Curl.curl_download("#{data_url}licenses.json", to: to/"spdx_licenses.json")
    Utils::Curl.curl_download("#{data_url}exceptions.json", to: to/"spdx_exceptions.json")
  end

  sig {
    params(
      license_expression: T.any(
        String,
        Symbol,
        T::Hash[T.any(Symbol, String), T.untyped],
        T::Array[String],
      ),
    ).returns(
      [
        T::Array[T.any(String, Symbol)], T::Array[String]
      ],
    )
  }
  def parse_license_expression(license_expression)
    licenses = T.let([], T::Array[T.any(String, Symbol)])
    exceptions = T.let([], T::Array[String])

    case license_expression
    when String, Symbol
      licenses.push license_expression
    when Hash, Array
      if license_expression.is_a? Hash
        license_expression = license_expression.filter_map do |key, value|
          if key.is_a? String
            licenses.push key
            exceptions.push value[:with]
            next
          end
          value
        end
      end

      license_expression.each do |license|
        sub_license, sub_exception = parse_license_expression license
        licenses += sub_license
        exceptions += sub_exception
      end
    end

    [licenses, exceptions]
  end

  sig { params(license: T.any(String, Symbol)).returns(T::Boolean) }
  def valid_license?(license)
    return ALLOWED_LICENSE_SYMBOLS.include? license if license.is_a? Symbol

    license = license.delete_suffix "+"
    license_data["licenses"].any? { |spdx_license| spdx_license["licenseId"].downcase == license.downcase }
  end

  sig { params(license: T.any(String, Symbol)).returns(T::Boolean) }
  def deprecated_license?(license)
    return false if ALLOWED_LICENSE_SYMBOLS.include? license
    return false unless valid_license?(license)

    license = license.to_s.delete_suffix "+"
    license_data["licenses"].none? do |spdx_license|
      spdx_license["licenseId"].downcase == license.downcase && !spdx_license["isDeprecatedLicenseId"]
    end
  end

  sig { params(exception: String).returns(T::Boolean) }
  def valid_license_exception?(exception)
    exception_data["exceptions"].any? do |spdx_exception|
      spdx_exception["licenseExceptionId"].downcase == exception.downcase && !spdx_exception["isDeprecatedLicenseId"]
    end
  end

  sig {
    params(
      license_expression: T.any(String, Symbol, T::Hash[T.nilable(T.any(Symbol, String)), T.untyped]),
      bracket:            T::Boolean,
      hash_type:          T.nilable(T.any(String, Symbol)),
    ).returns(T.nilable(String))
  }
  def license_expression_to_string(license_expression, bracket: false, hash_type: nil)
    case license_expression
    when String
      license_expression
    when Symbol
      LICENSEREF_PREFIX + license_expression.to_s.tr("_", "-")
    when Hash
      expressions = []

      if license_expression.keys.length == 1
        hash_type = license_expression.keys.first
        if hash_type.is_a? String
          expressions.push "#{hash_type} WITH #{license_expression[hash_type][:with]}"
        else
          expressions += license_expression[hash_type].map do |license|
            license_expression_to_string license, bracket: true, hash_type:
          end
        end
      else
        bracket = false
        license_expression.each do |expression|
          expressions.push license_expression_to_string([expression].to_h, bracket: true)
        end
      end

      operator = if hash_type == :any_of
        " OR "
      else
        " AND "
      end

      if bracket
        "(#{expressions.join operator})"
      else
        expressions.join operator
      end
    end
  end

  LicenseExpression = T.type_alias do
    T.any(
      String,
      Symbol,
      T::Hash[T.any(String, Symbol), T.anything],
    )
  end

  sig { params(string: T.nilable(String)).returns(T.nilable(LicenseExpression)) }
  def string_to_license_expression(string)
    return if string.blank?

    result = string
    result_type = nil

    and_parts = string.split(/ and (?![^(]*\))/i)
    if and_parts.length > 1
      result = and_parts
      result_type = :all_of
    else
      or_parts = string.split(/ or (?![^(]*\))/i)
      if or_parts.length > 1
        result = or_parts
        result_type = :any_of
      end
    end

    if result_type
      result.map! do |part|
        part = part[1..-2] if part[0] == "(" && part[-1] == ")"
        string_to_license_expression(part)
      end
      { result_type => result }
    else
      with_parts = string.split(/ with /i, 2)
      if with_parts.length > 1
        { with_parts.first => { with: with_parts.second } }
      else
        return result unless result.start_with?(LICENSEREF_PREFIX)

        license_sym = result.delete_prefix(LICENSEREF_PREFIX).downcase.tr("-", "_").to_sym
        ALLOWED_LICENSE_SYMBOLS.include?(license_sym) ? license_sym : result
      end
    end
  end

  # The `org.opencontainers.image.licenses` OCI annotation only accepts a limited
  # length (`limit`), so shorten an over-long licence to a valid prefix rather than
  # discarding it entirely. Only top-level `AND` expressions can be shortened safely:
  # a prefix of "A AND B AND ..." still holds, whereas dropping `OR` alternatives
  # would change the licence, so those fall back to `:cannot_represent`.
  sig { params(license: String, limit: Integer).returns(String) }
  def truncate_license(license, limit: 256)
    return license if license.length <= limit

    fallback = license_expression_to_string(:cannot_represent) || license

    # Split on top-level `AND`, re-joining any segment split inside a bracketed
    # sub-expression so each part stays a valid, balanced licence. A single part
    # means a top-level `OR` (or a lone licence) that cannot be safely shortened.
    parts = T.let([], T::Array[String])
    license.split(" AND ").each do |segment|
      previous = parts.last
      if previous && previous.count("(") > previous.count(")")
        parts[-1] = "#{previous} AND #{segment}"
      else
        parts << segment
      end
    end
    return fallback if parts.length < 2

    marker = license_expression_to_string(:truncated) || "truncated"
    kept = T.let([], T::Array[String])
    parts.each do |part|
      break if [*kept, part, marker].join(" AND ").length > limit

      kept << part
    end
    return fallback if kept.empty?

    [*kept, marker].join(" AND ")
  end

  sig {
    params(
      license: T.any(String, Symbol),
    ).returns(
      T.any(
        [T.any(String, Symbol)],
        [String, T.nilable(String), T::Boolean],
      ),
    )
  }
  def license_version_info(license)
    return [license] if ALLOWED_LICENSE_SYMBOLS.include? license

    match = license.match(/-(?<version>[0-9.]+)(?:-.*?)??(?<or_later>\+|-only|-or-later)?$/)
    return [license] if match.blank?

    license_name = license.to_s.split(match[0].to_s).first
    or_later = match["or_later"].present? && %w[+ -or-later].include?(match["or_later"])

    # [name, version, later versions allowed?]
    # e.g. GPL-2.0-or-later --> ["GPL", "2.0", true]
    [license_name, match["version"], or_later]
  end

  sig {
    params(license_expression: T.any(String, Symbol, T::Hash[T.any(Symbol, String), T.untyped]),
           forbidden_licenses: T::Hash[T.any(Symbol, String), T.untyped]).returns(T::Boolean)
  }
  def licenses_forbid_installation?(license_expression, forbidden_licenses)
    case license_expression
    when String, Symbol
      forbidden_licenses_include? license_expression, forbidden_licenses
    when Hash
      key = license_expression.keys.first
      return false if key.nil?

      case key
      when :any_of
        license_expression[key].all? { |license| licenses_forbid_installation? license, forbidden_licenses }
      when :all_of
        license_expression[key].any? { |license| licenses_forbid_installation? license, forbidden_licenses }
      else
        forbidden_licenses_include? key, forbidden_licenses
      end
    end
  end

  sig {
    params(
      license:            T.any(Symbol, String),
      forbidden_licenses: T::Hash[T.any(Symbol, String), T.untyped],
    ).returns(T::Boolean)
  }
  def forbidden_licenses_include?(license, forbidden_licenses)
    return true if forbidden_licenses.key? license

    name, version, = license_version_info license

    forbidden_licenses.each_value do |license_info|
      forbidden_name, forbidden_version, forbidden_or_later = *license_info
      next if forbidden_name != name

      return true if forbidden_or_later && forbidden_version <= version

      return true if forbidden_version == version
    end
    false
  end
end
