module Pod

  # 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 predownloads and thus
    #         modifications to the sandbox.
    #
    # @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        = config.sandbox.root

        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 the specifications to analyze their dependencies
    #         (which might be from external sources).
    #
    # @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

      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.version(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
