# typed: strict
# frozen_string_literal: true

require "abstract_command"
require "formula"
require "utils/curl"
require "utils/github/actions"
require "utils/spdx"
require "extend/ENV"
require "formula_cellar_checks"
require "cmd/search"
require "style"
require "date"
require "missing_formula"
require "digest"
require "json"
require "formula_auditor"
require "tap_auditor"
require "utils/git"

module Homebrew
  module DevCmd
    class Audit < AbstractCommand
      cmd_args do
        description <<~EOS
          Check <formula> or <cask> for Homebrew coding style violations. This should be run
          before submitting a new formula or cask. If no <formula> or <cask> are provided, check
          all locally available formulae and casks and skip style checks. Will exit with a
          non-zero status if any errors are found.
        EOS
        flag   "--os=",
               description: "Audit the given operating system. (Pass `all` to audit all operating systems.)"
        flag   "--arch=",
               description: "Audit the given CPU architecture. (Pass `all` to audit all architectures.)"
        switch "--strict",
               description: "Run additional, stricter style checks."
        switch "--git",
               description: "Run additional, slower style checks that navigate the Git repository."
        switch "--online",
               description: "Run additional, slower style checks that require a network connection."
        switch "--installed",
               description: "Only check formulae and casks that are currently installed."
        switch "--eval-all",
               description: "Evaluate all available formulae and casks, whether installed or not, to audit them.",
               env:         :eval_all,
               odeprecated: true
        switch "--new",
               description: "Run various additional style checks to determine if a new formula or cask is eligible " \
                            "for Homebrew. This should be used when creating new formulae or casks and implies " \
                            "`--strict` and `--online`."
        switch "--[no-]signing",
               description: "Audit for app signatures, which are required by macOS on ARM.",
               odeprecated: true
        switch "--changed",
               description: "Check files that were changed from the `main` branch."
        flag   "--tap=",
               description: "Check formulae and casks within the given tap, specified as <user>`/`<repo>."
        switch "--fix",
               description: "Fix style violations automatically using RuboCop's auto-correct feature."
        switch "--display-cop-names",
               description: "Include the RuboCop cop name for each violation in the output. This is the default.",
               hidden:      true
        switch "--display-filename",
               description: "Prefix every line of output with the file or formula name being audited, to " \
                            "make output easy to grep."
        switch "--skip-style",
               description: "Skip running non-RuboCop style checks. Useful if you plan on running " \
                            "`brew style` separately. Enabled by default unless a formula is specified by name."
        switch "-D", "--audit-debug",
               description: "Enable debugging and profiling of audit methods."
        comma_array "--only",
                    description: "Specify a comma-separated <method> list to only run the methods named " \
                                 "`audit_`<method>."
        comma_array "--except",
                    description: "Specify a comma-separated <method> list to skip running the methods named " \
                                 "`audit_`<method>."
        comma_array "--only-cops",
                    description: "Specify a comma-separated <cops> list to check for violations of only the listed " \
                                 "RuboCop cops."
        comma_array "--except-cops",
                    description: "Specify a comma-separated <cops> list to skip checking for violations of the " \
                                 "listed RuboCop cops."
        switch "--formula", "--formulae",
               description: "Treat all named arguments as formulae."
        switch "--cask", "--casks",
               description: "Treat all named arguments as casks."

        conflicts "--installed", "--eval-all", "--changed", "--tap"
        conflicts "--only", "--except"
        conflicts "--only-cops", "--except-cops", "--strict"
        conflicts "--only-cops", "--except-cops", "--only"
        conflicts "--formula", "--cask"

        named_args [:formula, :cask], without_api: true
      end

      sig { override.void }
      def run
        Formulary.enable_factory_cache!

        os_arch_combinations = args.os_arch_combinations
        cask_audit_os, cask_audit_arch =
          os_arch_combinations.find { |os, _arch| os != :linux } || os_arch_combinations.fetch(0)

        Homebrew.auditing = true
        Homebrew.inject_dump_stats!(FormulaAuditor, /^audit_/) if args.audit_debug?

        strict = args.new? || args.strict?
        online = args.new? || args.online?
        tap_audit = args.tap.present?
        skip_style = args.skip_style? || args.no_named? || tap_audit
        no_named_args = T.let(false, T::Boolean)

        gem_groups = ["audit", "ast"]
        gem_groups << "style" unless skip_style
        Homebrew.install_bundler_gems!(groups: gem_groups)
        require "utils/ast"

        ENV.activate_extensions!
        ENV.setup_build_environment

        audit_formulae, audit_casks = Homebrew.with_no_api_env do # audit requires full Ruby source
          if args.changed?
            tap = Tap.from_path(Dir.pwd)
            odie "`brew audit --changed` must be run inside a tap!" if tap.blank?

            no_named_args = true

            audit_formulae = []
            audit_casks = []

            Utils::Git.changed_files(tap.path).each do |file|
              next unless file.end_with?(".rb")

              absolute_file = File.expand_path(file, tap.path)
              next unless File.exist?(absolute_file)

              if tap.formula_file?(file)
                audit_formulae << Formulary.factory(absolute_file)
              elsif tap.cask_file?(file) && (cask = cask_for_audit(absolute_file, cask_audit_os, cask_audit_arch))
                audit_casks << cask
              end
            end

            [audit_formulae, audit_casks]
          elsif args.tap
            Tap.fetch(args.tap).then do |tap|
              [
                tap.formula_files.map { |path| Formulary.factory(path) },
                tap.cask_files.filter_map { |path| cask_for_audit(path, cask_audit_os, cask_audit_arch) },
              ]
            end
          elsif args.installed?
            no_named_args = true
            [Formula.installed, Cask::Caskroom.casks]
          elsif args.no_named?
            eval_all = args.eval_all?
            eval_all ||= Homebrew::EnvConfig.tap_trust_configured?

            unless eval_all
              # This odisabled should probably stick around indefinitely.
              odisabled "`brew audit`",
                        "set `HOMEBREW_REQUIRE_TAP_TRUST=1`"
            end
            no_named_args = true
            [
              Formula.all(eval_all:),
              Cask::Cask.all(eval_all:),
            ]
          else
            if args.named.any? { |named_arg| named_arg.end_with?(".rb") }
              # This odisabled should probably stick around indefinitely,
              # until at least we have a way to exclude error on these in the CLI parser.
              odisabled "`brew audit [path ...]`",
                        "`brew audit [name ...]`"
            end

            args.named.to_formulae_and_casks_with_taps
                .partition { |formula_or_cask| formula_or_cask.is_a?(Formula) }
          end
        end

        if audit_formulae.empty? && audit_casks.empty? && !args.tap
          ofail "No matching formulae or casks to audit!"
          return
        end

        style_files = args.named.to_paths unless skip_style

        only_cops = args.only_cops
        except_cops = args.except_cops
        style_options = { fix: args.fix?, debug: args.debug?, verbose: args.verbose? }

        if only_cops
          style_options[:only_cops] = only_cops
        elsif args.new?
          nil
        elsif except_cops
          style_options[:except_cops] = except_cops
        elsif !strict
          style_options[:except_cops] = %w[FormulaAuditStrict]
        end

        # Run tap audits first
        named_arg_taps = [*audit_formulae, *audit_casks].map(&:tap).uniq if !args.tap && !no_named_args
        tap_problems = Tap.installed.each_with_object({}) do |tap, problems|
          next if args.tap && tap != args.tap
          next if named_arg_taps&.exclude?(tap)

          ta = TapAuditor.new(tap, strict: args.strict?)
          ta.audit

          problems[[tap.name, tap.path]] = ta.problems if ta.problems.any?
        end

        # Check style in a single batch run up front for performance
        style_offenses = Style.check_style_json(style_files, **style_options) if style_files
        # load licenses
        spdx_license_data = SPDX.license_data
        spdx_exception_data = SPDX.exception_data

        formula_problems = audit_formulae.sort.each_with_object({}) do |f, problems|
          path = f.path

          only = only_cops ? ["style"] : args.only
          options = {
            new_formula:         args.new?,
            strict:,
            online:,
            git:                 args.git?,
            only:,
            except:              args.except,
            spdx_license_data:,
            spdx_exception_data:,
            style_offenses:      style_offenses&.for_path(f.path),
            tap_audit:,
          }.compact

          errors = os_arch_combinations.flat_map do |os, arch|
            SimulateSystem.with(os:, arch:) do
              odebug "Auditing Formula #{f} on os #{os} and arch #{arch}"

              audit_proc = proc { FormulaAuditor.new(Formulary.factory(path), **options).tap(&:audit) }

              # Audit requires full Ruby source so disable API. We shouldn't do this for taps however so that we
              # don't unnecessarily require a full Homebrew/core clone.
              fa = if f.core_formula?
                Homebrew.with_no_api_env(&audit_proc)
              else
                audit_proc.call
              end

              fa.problems + fa.new_formula_problems
            end
          end.uniq

          problems[[f.full_name, path]] = errors if errors.any?
        end

        require "cask/auditor" if audit_casks.any?

        cask_problems = audit_casks.each_with_object({}) do |cask, problems|
          path = cask.sourcefile_path

          errors = os_arch_combinations.flat_map do |os, arch|
            # Linux-only casks have no stanza values for macOS, so audit them
            # under Linux instead.
            os = :linux if os != :linux && !cask.supports_macos?

            SimulateSystem.with(os:, arch:) do
              odebug "Auditing Cask #{cask} on os #{os} and arch #{arch}"

              Cask::Auditor.audit(
                Cask::CaskLoader.load(path),
                # For switches, we add `|| nil` so that `nil` will be passed
                # instead of `false` if they aren't set.
                # This way, we can distinguish between "not set" and "set to false".
                audit_online:   args.online? || nil,
                audit_strict:   args.strict? || nil,

                # No need for `|| nil` for `--[no-]signing`
                # because boolean switches are already `nil` if not passed
                audit_signing:  args.signing?,
                audit_new_cask: args.new? || nil,
                any_named_args: !no_named_args,
                only:           args.only || [],
                except:         args.except || [],
              ).to_a
            end
          end.uniq

          problems[[cask.full_name, path]] = errors if errors.any?
        end

        print_problems(tap_problems)
        print_problems(formula_problems)
        print_problems(cask_problems)

        tap_count = tap_problems.keys.count
        formula_count = formula_problems.keys.count
        cask_count = cask_problems.keys.count

        corrected_problem_count = (formula_problems.values + cask_problems.values)
                                  .sum { |problems| problems.count { |problem| problem.fetch(:corrected) } }

        tap_problem_count = tap_problems.sum { |_, problems| problems.count }
        formula_problem_count = formula_problems.sum { |_, problems| problems.count }
        cask_problem_count = cask_problems.sum { |_, problems| problems.count }
        total_problems_count = formula_problem_count + cask_problem_count + tap_problem_count

        if total_problems_count.positive?
          errors_summary = Utils.pluralize("problem", total_problems_count, include_count: true)

          error_sources = []
          error_sources << Utils.pluralize("formula", formula_count, include_count: true) if formula_count.positive?
          error_sources << Utils.pluralize("cask", cask_count, include_count: true) if cask_count.positive?
          error_sources << Utils.pluralize("tap", tap_count, include_count: true) if tap_count.positive?

          errors_summary += " in #{error_sources.to_sentence}" if error_sources.any?

          errors_summary += " detected"

          if corrected_problem_count.positive?
            errors_summary +=
              ", #{Utils.pluralize("problem", corrected_problem_count, include_count: true)} corrected"
          end

          ofail "#{errors_summary}."
        end

        return unless GitHub::Actions.env_set?

        annotations = formula_problems.merge(cask_problems).flat_map do |(_, path), problems|
          problems.map do |problem|
            GitHub::Actions::Annotation.new(
              :error,
              problem[:message],
              file:   path,
              line:   problem[:location]&.line,
              column: problem[:location]&.column,
            )
          end
        end.compact

        annotations.each do |annotation|
          puts annotation if annotation.relevant?
        end
      end

      private

      sig {
        params(
          path:            T.any(String, Pathname),
          cask_audit_os:   Symbol,
          cask_audit_arch: Symbol,
        ).returns(T.nilable(Cask::Cask))
      }
      def cask_for_audit(path, cask_audit_os, cask_audit_arch)
        if cask_audit_os == :linux
          return if Utils::AST::CaskAST.new(Pathname(path).read).depends_on_macos?

          cask = SimulateSystem.with(os: :macos, arch: cask_audit_arch) do
            loaded_cask = Cask::CaskLoader.load(path)
            loaded_cask if loaded_cask.supports_linux?
          end
          return unless cask

          SimulateSystem.with(os: :linux, arch: cask_audit_arch) { cask.refresh }
          return cask
        end

        SimulateSystem.with(os: cask_audit_os, arch: cask_audit_arch) { Cask::CaskLoader.load(path) }
      end

      sig {
        params(
          results: T::Hash[
            T::Array[T.any(String, Pathname)],
            T::Array[T::Hash[Symbol, T.untyped]],
          ],
        ).void
      }
      def print_problems(results)
        results.each do |(name, path), problems|
          problem_lines = format_problem_lines(problems)

          if args.display_filename?
            problem_lines.each do |l|
              puts "#{path}: #{l}"
            end
          else
            puts name, problem_lines.map { |l| l.dup.prepend("  ") }
          end
        end
      end

      sig { params(problems: T::Array[T::Hash[Symbol, T.untyped]]).returns(T::Array[String]) }
      def format_problem_lines(problems)
        problems.map do |problem|
          status = " #{Formatter.success("[corrected]")}" if problem.fetch(:corrected)
          location = problem.fetch(:location)
          if location
            location = "#{location.line&.to_s&.prepend("line ")}#{location.column&.to_s&.prepend(", col ")}: "
          end
          message = problem.fetch(:message)
          "* #{location}#{message.chomp.gsub("\n", "\n    ")}#{status}"
        end
      end
    end
  end
end
