# typed: strict
# frozen_string_literal: true

require "utils/curl"
require "livecheck/options"

module Homebrew
  module Livecheck
    # The `Livecheck::Strategy` module contains the various strategies as well
    # as some general-purpose methods for working with them. Within the context
    # of the `brew livecheck` command, strategies are established procedures
    # for finding new software versions at a given source.
    module Strategy
      extend Utils::Curl

      # {Strategy} priorities informally range from 1 to 10, where 10 is the
      # highest priority. 5 is the default priority because it's roughly in
      # the middle of this range. Strategies with a priority of 0 (or lower)
      # are ignored.
      DEFAULT_PRIORITY = 5

      # cURL's default `--connect-timeout` value can be up to two minutes, so
      # we need to use a more reasonable duration (in seconds) to avoid a
      # lengthy wait when a connection can't be established.
      CURL_CONNECT_TIMEOUT = 10

      # cURL does not set a default `--max-time` value, so we provide a value
      # to ensure cURL will time out in a reasonable amount of time.
      CURL_MAX_TIME = T.let(CURL_CONNECT_TIMEOUT + 5, Integer)

      # The `curl` process will sometimes hang indefinitely (despite setting
      # the `--max-time` argument) and it needs to be quit for livecheck to
      # continue. This value is used to set the `timeout` argument on
      # `Utils::Curl` method calls in {Strategy}.
      CURL_PROCESS_TIMEOUT = T.let(CURL_MAX_TIME + 5, Integer)

      # The maximum number of redirections that `curl` should allow.
      MAX_REDIRECTIONS = 5

      # This value is passed to `#parse_curl_output` to ensure that the limit
      # for the number of responses it will parse corresponds to the maximum
      # number of responses in this context. The `+ 1` here accounts for the
      # situation where there are exactly `MAX_REDIRECTIONS` number of
      # redirections, followed by a final `200 OK` response.
      MAX_PARSE_ITERATIONS = T.let(MAX_REDIRECTIONS + 1, Integer)

      # `curl` arguments used when following redirections in {Strategy}
      # methods.
      REDIRECTION_CURL_ARGS = T.let([
        "--max-redirs", MAX_REDIRECTIONS.to_s,
        *https_redirect_curl_args
      ].freeze, T::Array[String])

      # Baseline `curl` arguments used in {Strategy} methods.
      DEFAULT_CURL_ARGS = T.let([
        # Follow redirections to handle mirrors, relocations, etc.
        "--location",
        *REDIRECTION_CURL_ARGS,
        # Avoid progress bar text, so we can reliably identify `curl` error
        # messages in output
        "--silent",
      ].freeze, T::Array[String])

      # `curl` arguments used in `Strategy#page_content` method.
      PAGE_CONTENT_CURL_ARGS = T.let(([
        # Return an error when the HTTP response code is 400 or greater but
        # continue to return body content
        "--fail-with-body",
        # Include HTTP response headers in output, so we can identify the
        # final URL after any redirections
        "--include",
      ] + DEFAULT_CURL_ARGS).freeze, T::Array[String])

      # Baseline `curl` options used in {Strategy} methods.
      DEFAULT_CURL_OPTIONS = T.let({
        print_stdout:    false,
        print_stderr:    false,
        debug:           false,
        verbose:         false,
        timeout:         CURL_PROCESS_TIMEOUT,
        connect_timeout: CURL_CONNECT_TIMEOUT,
        max_time:        CURL_MAX_TIME,
        retries:         0,
      }.freeze, T::Hash[Symbol, T.untyped])

      # A regex used to identify a tarball extension at the end of a string.
      TARBALL_EXTENSION_REGEX = /
        \.t
        (?:ar(?:\.(?:bz2|gz|lz|lzma|lzo|xz|Z|zst))?|
        b2|bz2?|z2|az|gz|lz|lzma|xz|Z|aZ|zst)
        $
      /ix

      # An error message to use when a `strategy` block returns a value of
      # an inappropriate type.
      INVALID_BLOCK_RETURN_VALUE_MSG = "Return value of a strategy block must be a string or array of strings."

      # Creates and/or returns a `@strategies` `Hash`, which maps a snake
      # case strategy name symbol (e.g. `:page_match`) to the associated
      # strategy.
      #
      # At present, this should only be called after tap strategies have been
      # loaded, otherwise livecheck won't be able to use them.
      # @return [Hash]
      sig { returns(T::Hash[Symbol, T.untyped]) }
      def self.strategies
        # Strategies (including tap-provided ones) are discovered dynamically.
        # rubocop:disable Sorbet/ConstantsFromStrings
        @strategies ||= T.let(Strategy.constants.sort.each_with_object({}) do |const_symbol, hash|
          constant = Strategy.const_get(const_symbol)
          next unless constant.is_a?(Class)

          key = Utils.underscore(const_symbol).to_sym
          hash[key] = constant
        end, T.nilable(T::Hash[Symbol, T.untyped]))
        # rubocop:enable Sorbet/ConstantsFromStrings
      end
      private_class_method :strategies

      # Returns the strategy that corresponds to the provided `Symbol` (or
      # `nil` if there is no matching strategy).
      #
      # @param symbol [Symbol, nil] the strategy name in snake case as a
      #   `Symbol` (e.g. `:page_match`)
      # @return [Class, nil]
      sig { params(symbol: T.nilable(Symbol)).returns(T.untyped) }
      def self.from_symbol(symbol)
        strategies[symbol] if symbol.present?
      end

      # Returns an array of strategies that apply to the provided URL.
      #
      # @param url [String] the URL to check for matching strategies
      # @param livecheck_strategy [Symbol] a strategy symbol from the
      #   `livecheck` block
      # @param regex_provided [Boolean] whether a regex is provided in the
      #   `livecheck` block
      # @param block_provided [Boolean] whether a `strategy` block is provided
      #   in the `livecheck` block
      # @return [Array]
      sig {
        params(
          url:                String,
          livecheck_strategy: T.nilable(Symbol),
          regex_provided:     T::Boolean,
          block_provided:     T::Boolean,
        ).returns(T::Array[T.untyped])
      }
      def self.from_url(url, livecheck_strategy: nil, regex_provided: false, block_provided: false)
        usable_strategies = strategies.select do |strategy_symbol, strategy|
          if strategy == PageMatch
            # Only treat the strategy as usable if the `livecheck` block
            # contains a regex and/or `strategy` block
            next if !regex_provided && !block_provided
          elsif [Json, Xml, Yaml].include?(strategy)
            # Only treat the strategy as usable if the `livecheck` block
            # specifies the strategy and contains a `strategy` block
            next if (livecheck_strategy != strategy_symbol) || !block_provided
          # The strategy's optional `PRIORITY` constant is read dynamically.
          # rubocop:disable Sorbet/ConstantsFromStrings
          elsif strategy.const_defined?(:PRIORITY) &&
                !strategy.const_get(:PRIORITY).positive? &&
                livecheck_strategy != strategy_symbol
            # rubocop:enable Sorbet/ConstantsFromStrings
            # Ignore strategies with a priority of 0 or lower, unless the
            # strategy is specified in the `livecheck` block
            next
          end

          strategy.respond_to?(:match?) && strategy.match?(url)
        end.values

        # Sort usable strategies in descending order by priority, using the
        # DEFAULT_PRIORITY when a strategy doesn't contain a PRIORITY constant
        usable_strategies.sort_by do |strategy|
          # The strategy's optional `PRIORITY` constant is read dynamically.
          # rubocop:disable Sorbet/ConstantsFromStrings
          strategy.const_defined?(:PRIORITY) ? -strategy.const_get(:PRIORITY) : -DEFAULT_PRIORITY
          # rubocop:enable Sorbet/ConstantsFromStrings
        end
      end

      # Creates `curl` `--data` or `--json` arguments (for `POST` requests`)
      # from related `livecheck` block `url` options.
      #
      # @param post_form [Hash, nil] data to encode using `URI::encode_www_form`
      # @param post_json [Hash, nil] data to encode using `JSON::generate`
      # @return [Array]
      sig {
        params(
          post_form: T.nilable(T::Hash[Symbol, String]),
          post_json: T.nilable(T::Hash[Symbol, T.anything]),
        ).returns(T::Array[String])
      }
      def self.post_args(post_form: nil, post_json: nil)
        args = if post_form.present?
          require "uri"
          ["--data", URI.encode_www_form(post_form)]
        elsif post_json.present?
          require "json"
          ["--json", JSON.generate(post_json)]
        else
          []
        end

        if (content_length = args[1]&.length)
          args << "--header" << "Content-Length: #{content_length}"
        end

        args
      end

      # Collects HTTP response headers, starting with the provided URL.
      # Redirections will be followed and all the response headers are
      # collected into an array of hashes.
      #
      # @param url [String] the URL to fetch
      # @param options [Options] options to modify behavior
      # @return [Array]
      sig {
        params(
          url:     String,
          options: Options,
        ).returns(T::Array[T::Hash[String, T.any(String, T::Array[String])]])
      }
      def self.page_headers(url, options: Options.new)
        headers = []

        if options.post_form || options.post_json
          curl_post_args = ["--request", "POST", *post_args(
            post_form: options.post_form,
            post_json: options.post_json,
          )]
        end

        user_agents = if options.user_agent
          [options.user_agent]
        else
          [:default, :browser]
        end

        user_agents.each do |user_agent|
          begin
            parsed_output = curl_headers(
              *curl_post_args,
              *REDIRECTION_CURL_ARGS,
              url,
              wanted_headers:    ["location", "content-disposition"],
              use_homebrew_curl: options.homebrew_curl || false,
              cookies:           options.cookies,
              header:            options.header,
              referer:           options.referer,
              user_agent:,
              **DEFAULT_CURL_OPTIONS,
            )
          rescue ErrorDuringExecution
            next
          end

          parsed_output[:responses].each { |response| headers << response[:headers] }
          break if headers.present?
        end

        headers
      end

      # Fetches the content at the URL and returns a hash containing the
      # content and, if there are any redirections, the final URL.
      # If `curl` encounters an error, the hash will contain a `:messages`
      # array with the error message instead.
      #
      # @param url [String] the URL of the content to check
      # @param options [Options] options to modify behavior
      # @return [Hash]
      sig { params(url: String, options: Options).returns(T::Hash[Symbol, T.untyped]) }
      def self.page_content(url, options: Options.new)
        if options.post_form || options.post_json
          curl_post_args = ["--request", "POST", *post_args(
            post_form: options.post_form,
            post_json: options.post_json,
          )]
        end

        args = if options.compressed == false
          PAGE_CONTENT_CURL_ARGS
        else
          PAGE_CONTENT_CURL_ARGS + ["--compressed"]
        end

        user_agents = if options.user_agent
          [options.user_agent]
        else
          [:default, :browser]
        end

        stderr = T.let(nil, T.nilable(String))
        user_agents.each do |user_agent|
          stdout, stderr, status = curl_output(
            *curl_post_args,
            *args,
            url,
            **DEFAULT_CURL_OPTIONS,
            use_homebrew_curl: options.homebrew_curl ||
                               !curl_supports_fail_with_body? ||
                               false,
            cookies:           options.cookies,
            header:            options.header,
            referer:           options.referer,
            user_agent:,
          )
          next unless status.success?

          # stdout contains the header information followed by the page content.
          # We use #scrub here to avoid "invalid byte sequence in UTF-8" errors.
          output = stdout.scrub

          # Separate the head(s)/body and identify the final URL (after any
          # redirections)
          parsed_output = parse_curl_output(output, max_iterations: MAX_PARSE_ITERATIONS)
          final_url = curl_response_last_location(parsed_output[:responses], absolutize: true, base_url: url)

          data = { content: parsed_output[:body] }
          data[:final_url] = final_url if final_url.present? && final_url != url
          return data
        end

        error_msgs = stderr&.scan(/^curl:.+$/)
        { messages: error_msgs.presence || ["cURL failed without a detectable error"] }
      end

      # Handles the return value from a `strategy` block in a `livecheck`
      # block.
      #
      # @param value [] the return value from a `strategy` block
      # @return [Array]
      sig { params(value: T.untyped).returns(T::Array[String]) }
      def self.handle_block_return(value)
        case value
        when String, Version
          [value.to_s]
        when Array
          value.compact.map(&:to_s).uniq
        when nil
          []
        else
          raise TypeError, INVALID_BLOCK_RETURN_VALUE_MSG
        end
      end
    end
  end
end

require_relative "strategy/apache"
require_relative "strategy/bitbucket"
require_relative "strategy/cpan"
require_relative "strategy/crate"
require_relative "strategy/electron_builder"
require_relative "strategy/extract_plist"
require_relative "strategy/git"
require_relative "strategy/github_latest"
require_relative "strategy/github_releases"
require_relative "strategy/gnome"
require_relative "strategy/gnu"
require_relative "strategy/hackage"
require_relative "strategy/header_match"
require_relative "strategy/json"
require_relative "strategy/launchpad"
require_relative "strategy/npm"
require_relative "strategy/page_match"
require_relative "strategy/pypi"
require_relative "strategy/ruby_gems"
require_relative "strategy/sourceforge"
require_relative "strategy/sparkle"
require_relative "strategy/xml"
require_relative "strategy/xorg"
require_relative "strategy/yaml"
