module Pod
  class Installer

    # Analyzes the Podfile, the Lockfile, and the sandbox manifest to generate
    # the information relative to a CocoaPods installation.
    #
    class Analyzer

      include Config::Mixin

      # @return [Sandbox] The sandbox where the Pods should be installed.
      #
      attr_reader :sandbox

      # @return [Podfile] The Podfile specification that contains the information
      #         of the Pods that should be installed.
      #
      attr_reader :podfile

      # @return [Lockfile] The Lockfile that stores the information about the
      #         Pods previously installed on any machine.
      #
      attr_reader :lockfile

      # @param  [Sandbox]  sandbox     @see sandbox
      # @param  [Podfile]  podfile     @see podfile
      # @param  [Lockfile] lockfile    @see lockfile
      #
      def initialize(sandbox, podfile, lockfile = nil)
        @sandbox  = sandbox
        @podfile  = podfile
        @lockfile = lockfile

        @update_mode = false
        @allow_pre_downloads = true
      end

      # Performs the analysis.
      #
      # The Podfile and the Lockfile provide the information necessary to compute
      # which specification should be installed. The manifest of the sandbox
      # returns which specifications are installed.
      #
      # @return [void]
      #
      def analyze
        @podfile_state = generate_podfile_state
        update_repositories_if_needed

        @libraries             = generated_libraries
        @locked_dependencies   = generate_version_locking_dependencies
        @specs_by_target       = resolve_dependencies
        @specifications        = generate_specifications
        @sandbox_state         = generate_sandbox_state
      end

      # @return [Bool] Whether an installation should be performed or this
      #         CocoaPods project is already up to date.
      #
      def needs_install?
        podfile_needs_install? || sandbox_needs_install?
      end

      # @return [Bool] Whether the podfile has changes respect to the lockfile.
      #
      def podfile_needs_install?
        state = generate_podfile_state
        needing_install = state.added + state.changed + state.deleted
        !needing_install.empty?
      end

      # @return [Bool] Whether the sandbox is in synch with the lockfile.
      #
      def sandbox_needs_install?
        lockfile =! sandbox.manifest
      end

      #-------------------------------------------------------------------------#

      # @!group Configuration

      # @return [Bool] Whether the version of the dependencies which did non
      #         change in the Podfile should be locked.
      #
      attr_accessor :update_mode
      alias_method  :update_mode?, :update_mode

      # @return [Bool] Whether the analysis allows pre-downloads and thus
      #         modifications to the sandbox.
      #
      # @note   This flag should not be used in installations.
      #
      # @note   This is used by the `pod outdated` command to prevent
      #         modification of the sandbox in the resolution process.
      #
      attr_accessor :allow_pre_downloads
      alias_method  :allow_pre_downloads?, :allow_pre_downloads

      #-------------------------------------------------------------------------#

      # @!group Analysis products

      public

      # @return [SpecsState] the states of the Podfile specs.
      #
      attr_reader :podfile_state

      # @return [Hash{TargetDefinition => Array<Spec>}] the specifications
      #         grouped by target.
      #
      attr_reader :specs_by_target

      # @return [Array<Specification>] the specifications of the resolved version
      #         of Pods that should be installed.
      #
      attr_reader :specifications

      # @return [SpecsState] the states of the {Sandbox} respect the resolved
      #         specifications.
      #
      attr_reader :sandbox_state

      # @return [Array<Library>] the libraries generated by the target
      #         definitions.
      #
      attr_reader :libraries

      #-------------------------------------------------------------------------#

      # @!group Analysis steps

      private

      # Compares the {Podfile} with the {Lockfile} in order to detect which
      # dependencies should be locked.
      #
      # @return [SpecsState] the states of the Podfile specs.
      #
      # @note   As the target definitions share the same sandbox they should have
      #         the same version of a Pod. For this reason this method returns
      #         the name of the Pod (root name of the dependencies) and doesn't
      #         group them by target definition.
      #
      # @todo   [CocoaPods > 0.18] If there isn't a Lockfile all the Pods should
      #         be marked as added.
      #
      def generate_podfile_state
        if lockfile
          pods_state = nil
          UI.section "Finding Podfile changes:" do
            pods_by_state = lockfile.detect_changes_with_podfile(podfile)
            pods_by_state.dup.each do |state, full_names|
              pods_by_state[state] = full_names.map { |fn| Specification.root_name(fn) }
            end
            pods_state = SpecsState.new(pods_by_state)
            pods_state.print
          end
          pods_state
        else
          SpecsState.new({})
        end
      end

      # Updates the source repositories unless the config indicates to skip it.
      #
      # @return [void]
      #
      def update_repositories_if_needed
        unless config.skip_repo_update?
          UI.section 'Updating spec repositories' do
            SourcesManager.update
          end
        end
      end

      # Creates the models that represent the libraries generated by CocoaPods.
      #
      # @note   The libraries are generated before the resolution process because
      #         it might be necessary to infer the platform from the user
      #         targets, which in turns requires to identify the user project.
      #
      # @note   The specification of the libraries are added in the
      #         {#resolve_dependencies} step.
      #
      # @return [Array<Libraries>] the generated libraries.
      #
      def generated_libraries
        libraries = []
        podfile.target_definitions.values.each do |target_definition|
          lib                           = Library.new(target_definition)
          lib.support_files_root        = sandbox.library_support_files_dir(lib.name)

          if config.integrate_targets?
            lib.user_project_path         = compute_user_project_path(target_definition)
            lib.user_project              = Xcodeproj::Project.new(lib.user_project_path)
            lib.user_targets              = compute_user_project_targets(target_definition, lib.user_project)
            lib.user_build_configurations = compute_user_build_configurations(target_definition, lib.user_targets)
            lib.platform                  = compute_platform_for_target_definition(target_definition, lib.user_targets)
          else
            lib.user_project_path         = config.project_root
            lib.user_project              = nil
            lib.user_targets              = []
            lib.user_build_configurations = {}
            lib.platform                  = target_definition.platform
            raise Informative, "It is necessary to specify the platform in the Podfile if not integrating." unless target_definition.platform
          end
          libraries << lib
        end
        libraries
      end

      # Generates dependencies that require the specific version of the Pods that
      # haven't changed in the {Lockfile}.
      #
      # These dependencies are passed to the {Resolver}, unless the installer is
      # in update mode, to prevent it from upgrading the Pods that weren't
      # changed in the {Podfile}.
      #
      # @return [Array<Dependency>] the dependencies generate by the lockfile
      #         that prevent the resolver to update a Pod.
      #
      def generate_version_locking_dependencies
        return [] if update_mode?
        podfile_state.unchanged.map do |pod|
          lockfile.dependency_to_lock_pod_named(pod)
        end
      end

      # Converts the Podfile in a list of specifications grouped by target.
      #
      # @note   In this step the specs are added to the libraries.
      #
      # @note   As some dependencies might have external sources the resolver is
      #         aware of the {Sandbox} and interacts with it to download the
      #         podspecs of the external sources. This is necessary because the
      #         resolver needs their specifications to analyze their
      #         dependencies.
      #
      # @note   The specifications of the external sources which are added,
      #         modified or removed need to deleted from the sandbox before the
      #         resolution process. Otherwise the resolver might use an incorrect
      #         specification instead of pre-downloading it.
      #
      # @note   In update mode the resolver is set to always update the specs
      #         from external sources.
      #
      # @return [Hash{TargetDefinition => Array<Spec>}] the specifications
      #         grouped by target.
      #
      def resolve_dependencies
        specs_by_target = nil

        if allow_pre_downloads?
          changed_pods = podfile_state.added + podfile_state.changed + podfile_state.deleted
          changed_pods.each do |pod_name|
            podspec = sandbox.specification_path(pod_name)
            podspec.delete if podspec
          end
        end

        UI.section "Resolving dependencies of #{UI.path podfile.defined_in_file}" do
          resolver = Resolver.new(sandbox, podfile, locked_dependencies)
          resolver.update_external_specs = update_mode?
          resolver.allow_pre_downloads   = allow_pre_downloads?
          specs_by_target = resolver.resolve
        end

        specs_by_target.each do |target_definition, specs|
          lib = libraries.find { |l| l.target_definition == target_definition}
          lib.specs = specs
        end

        specs_by_target
      end

      # Returns the list of all the resolved the resolved specifications.
      #
      # @return [Array<Specification>] the list of the specifications.
      #
      def generate_specifications
        specs_by_target.values.flatten.uniq
      end

      # Computes the state of the sandbox respect to the resolved specifications.
      #
      # The logic is the following:
      #
      # Added
      # - If not present in the sandbox lockfile.
      #
      # Changed
      # - The version of the Pod changed.
      # - The specific installed (sub)specs of the same Pod changed.
      # - The SHA of the specification file changed.
      #
      # Removed
      # - If a specification is present in the lockfile but not in the resolved
      #   specs.
      #
      # Unchanged
      # - If none of the above conditions match.
      #
      # @todo   [CocoaPods > 0.18] Version 0.17 falls back to the lockfile of the
      #         Podfile for the sandbox manifest to prevent the full
      #         re-installation for upgrading users (this was the old behaviour
      #         pre sandbox manifest) of all the pods. Drop in 0.18.
      #
      # @return [SpecsState] the representation of the state of the manifest
      #         specifications.
      #
      def generate_sandbox_state
        sandbox_lockfile = sandbox.manifest  || lockfile
        sandbox_state = SpecsState.new

        UI.section "Comparing resolved specification to the sandbox manifest:" do
          resolved_subspecs_names = specifications.group_by { |s| s.root.name }
          resolved_names          = resolved_subspecs_names.keys

          if sandbox_lockfile
            sandbox_subspecs_names = sandbox_lockfile.pod_names.group_by { |name| Specification.root_name(name) }
            sandbox_names = sandbox_subspecs_names.keys
            all_names     = (resolved_names + sandbox_names).uniq
            root_specs    = specifications.map(&:root).uniq

            is_changed = lambda do |name|
              spec = root_specs.find { |spec| spec.name == name }
              spec.version != sandbox_lockfile.version(name) \
                || spec.checksum != sandbox_lockfile.checksum(name) \
                || resolved_subspecs_names[name] =! sandbox_subspecs_names[name] \
            end

            all_names.each do |name|
              state = case
                      when resolved_names.include?(name) && !sandbox_names.include?(name) then :added
                      when !resolved_names.include?(name) && sandbox_names.include?(name) then :deleted
                      when is_changed.call(name) then :changed
                      else :unchanged
                      end
              sandbox_state.add_name(name, state)
            end

          else
            sandbox_state.added.concat(resolved_names)
          end
          sandbox_state.print
        end
        sandbox_state
      end

      #-------------------------------------------------------------------------#

      # @!group Analysis internal products

      # @return [Array<Dependency>] the dependencies generate by the lockfile
      #         that prevent the resolver to update a Pod.
      #
      attr_reader :locked_dependencies

      #-------------------------------------------------------------------------#

      private

      # @!group Analysis sub-steps

      # Returns the path of the user project that the {TargetDefinition}
      # should integrate.
      #
      # @raise  If the project is implicit and there are multiple projects.
      #
      # @raise  If the path doesn't exits.
      #
      # @return [Pathname] the path of the user project.
      #
      def compute_user_project_path(target_definition)

        if target_definition.user_project_path
          user_project_path = Pathname.new(config.project_root + target_definition.user_project_path)
          user_project_path = user_project_path.sub_ext '.xcodeproj'
          unless user_project_path.exist?
            raise Informative, "Unable to find the Xcode project " \
              "`#{user_project_path}` for the target `#{target_definition.label}`."
          end

        else
          xcodeprojs = Pathname.glob(config.project_root + '*.xcodeproj')
          if xcodeprojs.size == 1
            user_project_path = xcodeprojs.first
          else
            raise Informative, "Could not automatically select an Xcode project. " \
              "Specify one in your Podfile like so:\n\n" \
              "    xcodeproj 'path/to/Project.xcodeproj'\n"
          end
        end

        user_project_path
      end

      # Returns a list of the targets from the project of {TargetDefinition}
      # that needs to be integrated.
      #
      # @note   The method first looks if there is a target specified with
      #         the `link_with` option of the {TargetDefinition}. Otherwise
      #         it looks for the target that has the same name of the target
      #         definition.  Finally if no target was found the first
      #         encountered target is returned (it is assumed to be the one
      #         to integrate in simple projects).
      #
      # @note   This will only return targets that do **not** already have
      #         the Pods library in their frameworks build phase.
      #
      #
      def compute_user_project_targets(target_definition, user_project)
        if link_with = target_definition.link_with
          targets = user_project.targets.select { |t| link_with.include? t.name }
          raise Informative, "Unable to find the targets named `#{link_with.to_sentence}` to link with target definition `#{target_definition.name}`" if targets.empty?
        elsif target_definition.name != :default
          target = user_project.targets.find { |t| t.name == target_definition.name.to_s }
          targets = [ target ].compact
          raise Informative, "Unable to find a target named `#{target_definition.name.to_s}`" if targets.empty?
        else
          targets = [ user_project.targets.first ].compact
          raise Informative, "Unable to find a target" if targets.empty?
        end
        targets
      end

      # @return [Hash{String=>Symbol}] A hash representing the user build
      #         configurations where each key corresponds to the name of a
      #         configuration and its value to its type (`:debug` or `:release`).
      #
      def compute_user_build_configurations(target_definition, user_targets)
        if user_targets
          user_targets.map { |t| t.build_configurations.map(&:name) }.flatten.inject({}) do |hash, name|
            unless name == 'Debug' || name == 'Release'
              hash[name] = :release
            end
            hash
          end.merge(target_definition.build_configurations || {})
        else
          target_definition.build_configurations || {}
        end
      end

      # @return [Platform] The platform for the library.
      #
      # @note   This resolves to the lowest deployment target across the user
      #         targets.
      #
      # @todo   Is assigning the platform to the target definition the best way
      #         to go?
      #
      def compute_platform_for_target_definition(target_definition, user_targets)
        return target_definition.platform if target_definition.platform
        name = nil
        deployment_target = nil

        user_targets.each do |target|
          name ||= target.platform_name
          raise Informative, "Targets with different platforms" unless name == target.platform_name
          if !deployment_target || deployment_target > Version.new(target.deployment_target)
            deployment_target = Version.new(target.deployment_target)
          end
        end

        platform = Platform.new(name, deployment_target)
        target_definition.platform = platform
        platform
      end

      #-------------------------------------------------------------------------#

      # This class represents the state of a collection of Pods.
      #
      # @note The names of the pods stored by this class are always the **root**
      #       name of the specification.
      #
      class SpecsState

        # @param  [Hash{Symbol=>String}] pods_by_state
        #         The **root** name of the pods grouped by their state (`:added`,
        #         `:removed`, `:changed` or `:unchanged`).
        #
        def initialize(pods_by_state = nil)
          @added     = []
          @deleted   = []
          @changed   = []
          @unchanged = []

          if pods_by_state
            @added     = pods_by_state[:added]     || []
            @deleted   = pods_by_state[:removed]   || []
            @changed   = pods_by_state[:changed]   || []
            @unchanged = pods_by_state[:unchanged] || []
          end
        end

        # @return [Array<String>] the names of the pods that were added.
        #
        attr_accessor :added

        # @return [Array<String>] the names of the pods that were changed.
        #
        attr_accessor :changed

        # @return [Array<String>] the names of the pods that were deleted.
        #
        attr_accessor :deleted

        # @return [Array<String>] the names of the pods that were unchanged.
        #
        attr_accessor :unchanged

        # Displays the state of each pod.
        #
        # @return [void]
        #
        def print
          added    .each { |pod| UI.message("A".green  + " #{pod}", '', 2) }
          deleted  .each { |pod| UI.message("R".red    + " #{pod}", '', 2) }
          changed  .each { |pod| UI.message("M".yellow + " #{pod}", '', 2) }
          unchanged.each { |pod| UI.message("-"        + " #{pod}", '', 2) }
        end

        # Adds the name of a Pod to the give state.
        #
        # @parm   [String]
        #         the name of the Pod.
        #
        # @parm   [Symbol]
        #         the state of the Pod.
        #
        # @raise  If there is an attempt to add the name of a subspec.
        #
        # @return [void]
        #
        def add_name(name, state)
          raise "[Bug] Attempt to add subspec to the pods state" if name.include?('/')
          self.send(state) << name
        end

      end
    end
  end
end
