# typed: strict
# frozen_string_literal: true

require "bundle_version"
require "cask/cask"
require "cask/installer"
require "system_command"
require "utils/output"

module Homebrew
  # Check unversioned casks for updates by extracting their
  # contents and guessing the version from contained files.
  class UnversionedCaskChecker
    include SystemCommand::Mixin
    include Utils::Output::Mixin

    sig { returns(Cask::Cask) }
    attr_reader :cask

    sig { params(cask: Cask::Cask).void }
    def initialize(cask)
      @cask = cask
    end

    sig { returns(Cask::Installer) }
    def installer
      @installer ||= T.let(Cask::Installer.new(cask, verify_download_integrity: false), T.nilable(Cask::Installer))
    end

    sig { returns(T::Array[Cask::Artifact::App]) }
    def apps
      @apps ||= T.let(@cask.artifacts.grep(Cask::Artifact::App), T.nilable(T::Array[Cask::Artifact::App]))
    end

    sig { returns(T::Array[Cask::Artifact::KeyboardLayout]) }
    def keyboard_layouts
      @keyboard_layouts ||= T.let(@cask.artifacts.grep(Cask::Artifact::KeyboardLayout),
                                  T.nilable(T::Array[Cask::Artifact::KeyboardLayout]))
    end

    sig { returns(T::Array[Cask::Artifact::Qlplugin]) }
    def qlplugins
      @qlplugins ||= T.let(@cask.artifacts.grep(Cask::Artifact::Qlplugin),
                           T.nilable(T::Array[Cask::Artifact::Qlplugin]))
    end

    sig { returns(T::Array[Cask::Artifact::Dictionary]) }
    def dictionaries
      @dictionaries ||= T.let(@cask.artifacts.grep(Cask::Artifact::Dictionary),
                              T.nilable(T::Array[Cask::Artifact::Dictionary]))
    end

    sig { returns(T::Array[Cask::Artifact::ScreenSaver]) }
    def screen_savers
      @screen_savers ||= T.let(@cask.artifacts.grep(Cask::Artifact::ScreenSaver),
                               T.nilable(T::Array[Cask::Artifact::ScreenSaver]))
    end

    sig { returns(T::Array[Cask::Artifact::Colorpicker]) }
    def colorpickers
      @colorpickers ||= T.let(@cask.artifacts.grep(Cask::Artifact::Colorpicker),
                              T.nilable(T::Array[Cask::Artifact::Colorpicker]))
    end

    sig { returns(T::Array[Cask::Artifact::Mdimporter]) }
    def mdimporters
      @mdimporters ||= T.let(@cask.artifacts.grep(Cask::Artifact::Mdimporter),
                             T.nilable(T::Array[Cask::Artifact::Mdimporter]))
    end

    sig { returns(T::Array[Cask::Artifact::Installer]) }
    def installers
      @installers ||= T.let(@cask.artifacts.grep(Cask::Artifact::Installer),
                            T.nilable(T::Array[Cask::Artifact::Installer]))
    end

    sig { returns(T::Array[Cask::Artifact::Pkg]) }
    def pkgs
      @pkgs ||= T.let(@cask.artifacts.grep(Cask::Artifact::Pkg), T.nilable(T::Array[Cask::Artifact::Pkg]))
    end

    sig { returns(T::Boolean) }
    def single_app_cask?
      apps.one?
    end

    sig { returns(T::Boolean) }
    def single_qlplugin_cask?
      qlplugins.one?
    end

    sig { returns(T::Boolean) }
    def single_pkg_cask?
      pkgs.one?
    end

    # Filter paths to `Info.plist` files so that ones belonging
    # to e.g. nested `.app`s are ignored.
    sig { params(paths: T::Array[Pathname]).returns(T::Array[Pathname]) }
    def top_level_info_plists(paths)
      # Go from `./Contents/Info.plist` to `./`.
      top_level_paths = paths.map { |path| path.parent.parent }

      paths.reject do |path|
        path.ascend.drop(3).intersect?(top_level_paths)
      end
    end

    sig { returns(T::Hash[String, BundleVersion]) }
    def all_versions
      versions = {}

      parse_info_plist = proc do |info_plist_path|
        plist = system_command!("plutil", args: ["-convert", "xml1", "-o", "-", info_plist_path]).plist

        id = plist["CFBundleIdentifier"]
        version = BundleVersion.from_info_plist_content(plist)

        versions[id] = version if id && version
      end

      Dir.mktmpdir("cask-checker", HOMEBREW_TEMP) do |dir|
        dir = Pathname(dir)

        installer.extract_primary_container(to: dir)
        installer.process_rename_operations(target_dir: dir)

        info_plist_paths = [
          *apps,
          *keyboard_layouts,
          *mdimporters,
          *colorpickers,
          *dictionaries,
          *qlplugins,
          *installers,
          *screen_savers,
        ].flat_map do |artifact|
          sources = if artifact.is_a?(Cask::Artifact::Installer)
            # Installers are sometimes contained within an `.app`, so try both.
            installer_path = artifact.path
            installer_path.ascend
                          .select { |path| path == installer_path || path.extname == ".app" }
                          .sort
          else
            [artifact.source.basename]
          end

          sources.flat_map do |source|
            top_level_info_plists(Pathname.glob(dir/"**"/source/"Contents"/"Info.plist")).sort
          end
        end

        info_plist_paths.each(&parse_info_plist)

        pkg_paths = pkgs.flat_map { |pkg| Pathname.glob(dir/"**"/pkg.path.basename).sort }
        pkg_paths = Pathname.glob(dir/"**"/"*.pkg").sort if pkg_paths.empty?

        pkg_paths.each do |pkg_path|
          Dir.mktmpdir("cask-checker", HOMEBREW_TEMP) do |extract_dir|
            extract_dir = Pathname(extract_dir)
            FileUtils.rmdir extract_dir

            system_command! "pkgutil", args: ["--expand-full", pkg_path, extract_dir]

            top_level_info_plist_paths = top_level_info_plists(Pathname.glob(extract_dir/"**/Contents/Info.plist"))

            top_level_info_plist_paths.each(&parse_info_plist)
          ensure
            extract_dir = Pathname(extract_dir)
            Cask::Utils.gain_permissions_remove(extract_dir)
            extract_dir.mkpath
          end
        end

        nil
      end

      versions
    end

    sig { returns(T.nilable(String)) }
    def guess_cask_version
      if apps.empty? && pkgs.empty? && qlplugins.empty?
        opoo "Cask #{cask} does not contain any apps, qlplugins or PKG installers."
        return
      end

      Dir.mktmpdir("cask-checker", HOMEBREW_TEMP) do |dir|
        dir = Pathname(dir)

        installer.then do |i|
          i.extract_primary_container(to: dir)
        rescue ErrorDuringExecution => e
          onoe e
          return nil
        end

        info_plist_paths = apps.flat_map do |app|
          top_level_info_plists(Pathname.glob(dir/"**"/app.source.basename/"Contents"/"Info.plist")).sort
        end

        info_plist_paths.each do |info_plist_path|
          if (version = BundleVersion.from_info_plist(info_plist_path))
            return version.nice_version
          end
        end

        pkg_paths = pkgs.flat_map do |pkg|
          Pathname.glob(dir/"**"/pkg.path.basename).sort
        end

        pkg_paths.each do |pkg_path|
          packages =
            system_command!("installer", args: ["-plist", "-pkginfo", "-pkg", pkg_path])
            .plist
            .map { |package| package.fetch("Package") }

          Dir.mktmpdir("cask-checker", HOMEBREW_TEMP) do |extract_dir|
            extract_dir = Pathname(extract_dir)
            FileUtils.rmdir extract_dir

            begin
              system_command! "pkgutil", args: ["--expand-full", pkg_path, extract_dir]
            rescue ErrorDuringExecution => e
              onoe "Failed to extract #{pkg_path.basename}: #{e}"
              next
            end

            top_level_info_plist_paths = top_level_info_plists(Pathname.glob(extract_dir/"**/Contents/Info.plist"))

            unique_info_plist_versions =
              top_level_info_plist_paths.filter_map { |i| BundleVersion.from_info_plist(i)&.nice_version }
                                        .uniq
            return unique_info_plist_versions.first if unique_info_plist_versions.one?

            package_info_path = extract_dir/"PackageInfo"
            if package_info_path.exist?
              if (version = BundleVersion.from_package_info(package_info_path))
                return version.nice_version
              end
            elsif packages.one?
              onoe "#{pkg_path.basename} does not contain a `PackageInfo` file."
            end

            distribution_path = extract_dir/"Distribution"
            if distribution_path.exist?
              require "rexml/document"

              xml = REXML::Document.new(distribution_path.read)

              product = xml.get_elements("//installer-gui-script//product").first
              product_version = product["version"] if product
              return product_version if product_version.present?
            end

            opoo "#{pkg_path.basename} contains multiple packages: #{packages}" if packages.count != 1

            $stderr.puts Pathname.glob(extract_dir/"**/*")
                                 .map { |path|
                                   regex = %r{\A(.*?\.(app|qlgenerator|saver|plugin|kext|bundle|osax))/.*\Z}
                                   path.to_s.sub(regex, '\1')
                                 }.uniq
          ensure
            extract_dir = Pathname(extract_dir)
            Cask::Utils.gain_permissions_remove(extract_dir)
            extract_dir.mkpath
          end
        end

        nil
      end
    end
  end
end
