# typed: true
# frozen_string_literal: true

require "json"
require "tsort"

module Homebrew
  module Bundle
    # TODO: refactor into multiple modules
    module FormulaDumper
      def self.reset!
        require "bundle/brew_services"

        Homebrew::Bundle::BrewServices.reset!
        @formulae = nil
        @formulae_by_full_name = nil
        @formulae_by_name = nil
        @formula_aliases = nil
        @formula_oldnames = nil
      end

      def self.formulae
        return @formulae if @formulae

        formulae_by_full_name
        @formulae
      end

      def self.formulae_by_full_name(name = nil)
        return @formulae_by_full_name[name] if name.present? && @formulae_by_full_name&.key?(name)

        require "formula"
        require "formulary"
        Formulary.enable_factory_cache!

        @formulae_by_name ||= {}
        @formulae_by_full_name ||= {}

        if name.nil?
          formulae = Formula.installed.map(&method(:add_formula))
          sort!(formulae)
          return @formulae_by_full_name
        end

        formula = Formula[name]
        add_formula(formula)
      rescue FormulaUnavailableError => e
        opoo "'#{name}' formula is unreadable: #{e}"
        {}
      end

      def self.formulae_by_name(name)
        formulae_by_full_name(name) || @formulae_by_name[name]
      end

      def self.dump(describe: false, no_restart: false)
        require "bundle/brew_services"

        requested_formula = formulae.select do |f|
          f[:installed_on_request?] || !f[:installed_as_dependency?]
        end
        requested_formula.map do |f|
          brewline = if describe && f[:desc].present?
            f[:desc].split("\n").map { |s| "# #{s}\n" }.join
          else
            ""
          end
          brewline += "brew \"#{f[:full_name]}\""

          args = f[:args].map { |arg| "\"#{arg}\"" }.sort.join(", ")
          brewline += ", args: [#{args}]" unless f[:args].empty?
          brewline += ", restart_service: :changed" if !no_restart && BrewServices.started?(f[:full_name])
          brewline += ", link: #{f[:link?]}" unless f[:link?].nil?
          brewline
        end.join("\n")
      end

      def self.formula_aliases
        return @formula_aliases if @formula_aliases

        @formula_aliases = {}
        formulae.each do |f|
          aliases = f[:aliases]
          next if aliases.blank?

          aliases.each do |a|
            @formula_aliases[a] = f[:full_name]
            if f[:full_name].include? "/" # tap formula
              tap_name = f[:full_name].rpartition("/").first
              @formula_aliases["#{tap_name}/#{a}"] = f[:full_name]
            end
          end
        end
        @formula_aliases
      end

      def self.formula_oldnames
        return @formula_oldnames if @formula_oldnames

        @formula_oldnames = {}
        formulae.each do |f|
          oldnames = f[:oldnames]
          next if oldnames.blank?

          oldnames.each do |oldname|
            @formula_oldnames[oldname] = f[:full_name]
            if f[:full_name].include? "/" # tap formula
              tap_name = f[:full_name].rpartition("/").first
              @formula_oldnames["#{tap_name}/#{oldname}"] = f[:full_name]
            end
          end
        end
        @formula_oldnames
      end

      private_class_method def self.add_formula(formula)
        hash = formula_to_hash formula

        @formulae_by_name[hash[:name]] = hash
        @formulae_by_full_name[hash[:full_name]] = hash

        hash
      end

      private_class_method def self.formula_to_hash(formula)
        keg = if formula.linked?
          link = true if formula.keg_only?
          formula.linked_keg
        else
          link = false unless formula.keg_only?
          formula.any_installed_prefix
        end

        if keg
          require "tab"

          tab = Tab.for_keg(keg)
          args = tab.used_options.map(&:name)
          version = begin
            keg.realpath.basename
          rescue
            # silently handle broken symlinks
            nil
          end.to_s
          args << "HEAD" if version.start_with?("HEAD")
          installed_as_dependency = tab.installed_as_dependency
          installed_on_request = tab.installed_on_request
          runtime_dependencies = if (runtime_deps = tab.runtime_dependencies)
            runtime_deps.filter_map { |d| d["full_name"] }

          end
          poured_from_bottle = tab.poured_from_bottle
        end

        runtime_dependencies ||= formula.runtime_dependencies.map(&:name)

        bottled = if (stable = formula.stable) && stable.bottle_defined?
          bottle_hash = formula.bottle_hash.deep_symbolize_keys
          stable.bottled?
        end

        {
          name:                     formula.name,
          desc:                     formula.desc,
          oldnames:                 formula.oldnames,
          full_name:                formula.full_name,
          aliases:                  formula.aliases,
          any_version_installed?:   formula.any_version_installed?,
          args:                     Array(args).uniq,
          version:,
          installed_as_dependency?: installed_as_dependency || false,
          installed_on_request?:    installed_on_request || false,
          dependencies:             runtime_dependencies,
          build_dependencies:       formula.deps.select(&:build?).map(&:name).uniq,
          conflicts_with:           formula.conflicts.map(&:name),
          pinned?:                  formula.pinned? || false,
          outdated?:                formula.outdated? || false,
          link?:                    link,
          poured_from_bottle?:      poured_from_bottle || false,
          bottle:                   bottle_hash || false,
          bottled:                  bottled || false,
          official_tap:             formula.tap&.official? || false,
        }
      end

      class Topo < Hash
        include TSort

        def each_key(&block)
          keys.each(&block)
        end
        alias tsort_each_node each_key

        def tsort_each_child(node, &block)
          fetch(node.downcase).sort.each(&block)
        end
      end

      private_class_method def self.sort!(formulae)
        # Step 1: Sort by formula full name while putting tap formulae behind core formulae.
        #         So we can have a nicer output.
        formulae = formulae.sort do |a, b|
          if a[:full_name].exclude?("/") && b[:full_name].include?("/")
            -1
          elsif a[:full_name].include?("/") && b[:full_name].exclude?("/")
            1
          else
            a[:full_name] <=> b[:full_name]
          end
        end

        # Step 2: Sort by formula dependency topology.
        topo = Topo.new
        formulae.each do |f|
          topo[f[:name]] = topo[f[:full_name]] = f[:dependencies].filter_map do |dep|
            ff = formulae_by_name(dep)
            next if ff.blank?
            next unless ff[:any_version_installed?]

            ff[:full_name]
          end
        end
        @formulae = topo.tsort
                        .map { |name| @formulae_by_full_name[name] || @formulae_by_name[name] }
                        .uniq { |f| f[:full_name] }
      rescue TSort::Cyclic => e
        e.message =~ /\["([^"]*)".*"([^"]*)"\]/
        cycle_first = Regexp.last_match(1)
        cycle_last = Regexp.last_match(2)
        odie e.message if !cycle_first || !cycle_last

        odie <<~EOS
          Formulae dependency graph sorting failed (likely due to a circular dependency):
          #{cycle_first}: #{topo[cycle_first] if topo}
          #{cycle_last}: #{topo[cycle_last] if topo}
          Please run the following commands and try again:
            brew update
            brew uninstall --ignore-dependencies --force #{cycle_first} #{cycle_last}
            brew install #{cycle_first} #{cycle_last}
        EOS
      end
    end
  end
end
