#
# Copyright:: Chef Software Inc.
# License:: Apache License, Version 2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

require "spec_helper"
require "chef-cli/policyfile_compiler"
require "chef-cli/exceptions"

describe ChefCLI::PolicyfileCompiler, "including upstream policy locks" do

  def expand_run_list(r)
    r.map do |item|
      "recipe[#{item}]"
    end
  end

  let(:run_list) { ["local::default"] }
  let(:run_list_expanded) { expand_run_list(run_list) }
  let(:named_run_list) { {} }
  let(:named_run_list_expanded) do
    named_run_list.inject({}) do |acc, (key, val)|
      acc[key] = expand_run_list(val)
      acc
    end
  end
  let(:default_attributes) { {} }
  let(:override_attributes) { {} }

  let(:default_source) { nil }

  let(:external_cookbook_universe) do
    {
      "cookbookA" => {
        "1.0.0" => [ ],
        "2.0.0" => [ ],
      },
      "cookbookB" => {
        "1.0.0" => [ ],
        "2.0.0" => [ ],
      },
      "cookbookC" => {
        "1.0.0" => [ ],
        "2.0.0" => [ ],
      },
      "local" => {
        "1.0.0" => [ ["cookbookC", "= 1.0.0" ] ],
      },
      "local_easy" => {
        "1.0.0" => [ ["cookbookC", "= 2.0.0" ] ],
      },
    }
  end

  let(:included_policy_default_attributes) { {} }
  let(:included_policy_override_attributes) { {} }
  let(:included_policy_expanded_named_runlist) { nil }
  let(:included_policy_expanded_runlist) { ["recipe[cookbookA::default]"] }
  let(:included_policy_cookbooks) do
    [
      {
        name: "cookbookA",
        version: "2.0.0",
      },
    ]
  end

  let(:included_policy_lock_data) do
    cookbook_locks = included_policy_cookbooks.inject({}) do |acc, cookbook_info|
      acc[cookbook_info[:name]] = {
        "version" => cookbook_info[:version],
        "identifier" => "identifier",
        "dotted_decimal_identifier" => "dotted_decimal_identifier",
        "cache_key" => "#{cookbook_info[:name]}-#{cookbook_info[:version]}",
        "origin" => "uri",
        "source_options" => {},
      }
      acc
    end

    solution_dependencies_lock = included_policy_cookbooks.map do |cookbook_info|
      [cookbook_info[:name], cookbook_info[:version]]
    end

    solution_dependencies_cookbooks = included_policy_cookbooks.inject({}) do |acc, cookbook_info|
      acc["#{cookbook_info[:name]} (#{cookbook_info[:version]})"] = external_cookbook_universe[cookbook_info[:name]][cookbook_info[:version]]
      acc
    end

    {
      "name" => "included_policyfile",
      "revision_id" => "myrevisionid",
      "run_list" => included_policy_expanded_runlist,
      "cookbook_locks" => cookbook_locks,
      "default_attributes" => included_policy_default_attributes,
      "override_attributes" => included_policy_override_attributes,
      "solution_dependencies" => {
        "Policyfile" => solution_dependencies_lock,
        "dependencies" => solution_dependencies_cookbooks,
      },
    }.tap do |core|
      core["named_run_lists"] = included_policy_expanded_named_runlist if included_policy_expanded_named_runlist
    end
  end

  let(:included_policy_lock_name) { "included" }
  let(:included_policy_fetcher) do
    instance_double("ChefCLI::Policyfile::LocalLockFetcher").tap do |double|
      allow(double).to receive(:lock_data).and_return(included_policy_lock_data)
      allow(double).to receive(:valid?).and_return(true)
      allow(double).to receive(:errors).and_return([])
    end
  end

  let(:lock_source_options) { { path: "somelocation" } }
  let(:included_policy_lock_spec) do
    ChefCLI::Policyfile::PolicyfileLocationSpecification.new(included_policy_lock_name, lock_source_options, nil).tap do |spec|
      allow(spec).to receive(:valid?).and_return(true)
      allow(spec).to receive(:fetcher).and_return(included_policy_fetcher)
      allow(spec).to receive(:source_options_for_lock).and_return(lock_source_options)
    end
  end

  let(:included_policies) { [] }

  let(:policyfile) do
    policyfile = ChefCLI::PolicyfileCompiler.new.build do |p|
      if default_source
        p.default_source.replace([default_source])
      else
        allow(p.default_source.first).to receive(:universe_graph).and_return(external_cookbook_universe)
        allow(p.default_source.first).to receive(:null?).and_return(false)
      end
      p.run_list(*run_list)

      named_run_list.each do |name, run_list|
        p.named_run_list(name, *run_list)
      end

      default_attributes.each do |(name, value)|
        p.default[name] = value
      end

      override_attributes.each do |(name, value)|
        p.override[name] = value
      end

      allow(p).to receive(:included_policies).and_return(included_policies)
    end

    policyfile
  end

  let(:policyfile_lock) do
    policyfile.lock
  end

  context "when no policies are included" do

    it "does not emit included policies information in the lockfile" do
      expect(policyfile_lock.to_lock["included_policies"]).to eq(nil)
    end

  end

  context "when one policy is included" do

    let(:included_policies) { [included_policy_lock_spec] }

    # currently you must have a run list in a policyfile, but it should now
    # become possible to make a combo-policy just by combining other policies
    context "when the including policy does not have a run list" do
      let(:run_list) { [] }

      it "emits a lockfile with an identical run list as the included policy" do
        expect(policyfile_lock.to_lock["run_list"]).to eq(included_policy_expanded_runlist)
      end

    end

    context "when the including policy has a run list" do

      it "appends run list items from the including policy to the included policy's run list, removing duplicates" do
        expect(policyfile_lock.to_lock["run_list"]).to eq(included_policy_expanded_runlist + run_list_expanded)
      end

    end

    context "when the policies have named run lists" do

      let(:included_policy_expanded_named_runlist) do
        {
          "shared" => ["recipe[cookbookA::included]"],
        }
      end

      context "and no named run lists are shared between the including and included policy" do

        let(:named_run_list) do
          {
            "local" => ["local::foo"],
          }
        end

        it "preserves the named run lists as given in both policies" do
          expect(policyfile_lock.to_lock["named_run_lists"]).to include(included_policy_expanded_named_runlist, named_run_list_expanded)
        end

      end

      context "and some named run lists are shared between the including and included policy" do

        let(:named_run_list) do
          {
            "shared" => ["local::foo"],
          }
        end

        it "appends run lists items from the including policy's run lists to the included policy's run lists" do
          expect(policyfile_lock.to_lock["named_run_lists"]["shared"]).to eq(included_policy_expanded_named_runlist["shared"] + named_run_list_expanded["shared"])
        end

      end

    end

    context "when no cookbooks are shared as dependencies or transitive dependencies" do
      let(:included_policy_expanded_runlist) { ["recipe[cookbookC::default]"] }
      let(:run_list) { ["cookbookA::default"] }

      it "does not raise a have conflicting dependency requirements error" do
        expect { policyfile_lock.to_lock }.not_to raise_error
      end

      it "emits a lockfile where cookbooks pulled from the upstream are at identical versions" do
        expect(policyfile_lock.to_lock["solution_dependencies"]["dependencies"]).to(
          have_key("cookbookC (2.0.0)")
        )
      end
    end

    context "when some cookbooks are shared as dependencies or transitive dependencies" do
      let(:included_policy_expanded_runlist) { ["recipe[cookbookC::default]"] }
      let(:included_policy_cookbooks) do
        [
          {
            name: "cookbookC",
            version: "2.0.0",
          },
        ]
      end

      context "and the including policy does not specify any sources" do
        let(:run_list) { [] }
        it "it defaults to those provided in the included policy lock" do
          expect(policyfile_lock.to_lock["solution_dependencies"]["dependencies"]).to(
            have_key("cookbookC (2.0.0)")
          )
        end
      end

      context "and the including policy specifies a source that is equivalent to the included policy" do
        let(:run_list) { [] }
        let(:default_source) { instance_double("ChefCLI::Policyfile::NullCookbookSource") }

        before do
          allow(default_source).to receive(:preferred_cookbooks).and_return(["cookbookC"])
          allow(default_source).to receive(:source_options_for).with("cookbookC", "2.0.0").and_return({})
          allow(default_source).to receive(:null?).and_return(false)
          allow(default_source).to receive(:universe_graph).and_return(external_cookbook_universe)
          allow(default_source).to receive(:desc).and_return("source double")
        end

        it "it defaults to those provided in the included policy lock" do
          expect { policyfile_lock.to_lock }.not_to raise_error
        end
      end

      context "and the including policy specifies a source that is not equivalent to the included policy" do
        let(:run_list) { [] }
        let(:default_source) { instance_double("ChefCLI::Policyfile::NullCookbookSource") }

        before do
          allow(default_source).to receive(:preferred_cookbooks).and_return(["cookbookC"])
          allow(default_source).to receive(:source_options_for).with("cookbookC", "2.0.0").and_return({ "foo" => "bar" })
          allow(default_source).to receive(:null?).and_return(false)
          allow(default_source).to receive(:universe_graph).and_return(external_cookbook_universe)
          allow(default_source).to receive(:desc).and_return("source double")
        end

        it "it raises an error" do
          expect { policyfile_lock.to_lock }.to raise_error(ChefCLI::IncludePolicyCookbookSourceConflict)
        end
      end

      context "and the including policy's dependencies can be solved with the included policy's locks" do
        let(:run_list) { ["local_easy::default"] }

        it "solves the dependencies added by the top-level policyfile and emits them in the lockfile" do
          expect(policyfile_lock.to_lock["solution_dependencies"]["dependencies"]).to(
            have_key("cookbookC (2.0.0)")
          )
        end

      end

      context "and the including policy's dependencies cannot be solved with the included policy's locks" do
        let(:run_list) { ["local::default"] }

        it "raises an error describing the conflict" do
          expect { policyfile_lock.to_lock }.to raise_error(Solve::Errors::NoSolutionError)
        end

        it "includes the source of the conflicting dependency constraint from the including policy" do
          expect { policyfile_lock.to_lock }.to raise_error(Solve::Errors::NoSolutionError) do |e|
            expect(e.to_s).to match(/`cookbookC \(= 2.0.0\)`/) # This one comes from the included policy
            expect(e.to_s).to match(/`cookbookC \(= 1.0.0\)` required by `local-1.0.0`/) # This one comes from the included policy
          end
        end
      end
    end

    context "when default attributes are specified" do
      let(:default_attributes) do
        {
          "shared" => {
            "foo" => "bar",
          },
        }
      end

      context "when the included policy does not have attributes that conflict with the including policy" do
        let(:included_policy_default_attributes) do
          {
            "not_shared" => {
              "foo" => "bar",
            },
          }
        end

        it "emits a lockfile with the attributes from both merged" do
          expect(policyfile_lock.to_lock["default_attributes"]).to include(included_policy_default_attributes, default_attributes)
        end

      end

      context "when the included policy has attributes that conflict with the including policy, but provide the same value" do
        let(:included_policy_default_attributes) { default_attributes }

        it "emits a lockfile with the attributes from both merged" do
          expect(policyfile_lock.to_lock["default_attributes"]).to eq(default_attributes)
        end

      end

      context "when the included policy has attributes that conflict with the including policy's attributes" do
        let(:included_policy_default_attributes) do
          {
            "shared" => {
              "foo" => "not_bar",
            },
          }
        end

        it "raises an error describing all attribute conflicts" do
          expect { policyfile_lock.to_lock }.to raise_error(
            ChefCLI::Policyfile::AttributeMergeChecker::ConflictError,
            "Attribute '[shared][foo]' provided conflicting values by the following sources [\"user-specified\", \"included\"]"
          )
        end
      end
    end

    context "when override attributes are specified" do
      let(:override_attributes) do
        {
          "shared" => {
            "foo" => "bar",
          },
        }
      end

      context "when the included policy does not have attributes that conflict with the including policy" do
        let(:included_policy_override_attributes) do
          {
            "not_shared" => {
              "foo" => "bar",
            },
          }
        end

        it "emits a lockfile with the attributes from both merged" do
          expect(policyfile_lock.to_lock["override_attributes"]).to include(included_policy_override_attributes, override_attributes)
        end

      end

      context "when the included policy has attributes that conflict with the including policy, but provide the same value" do
        let(:included_policy_override_attributes) { override_attributes }

        it "emits a lockfile with the attributes from both merged" do
          expect(policyfile_lock.to_lock["override_attributes"]).to eq(override_attributes)
        end

      end

      context "when the included policy has attributes that conflict with the including policy's attributes" do
        let(:included_policy_override_attributes) do
          {
            "shared" => {
              "foo" => "not_bar",
            },
          }
        end

        it "raises an error describing all attribute conflicts" do
          expect { policyfile_lock.to_lock }.to raise_error(
            ChefCLI::Policyfile::AttributeMergeChecker::ConflictError,
            "Attribute '[shared][foo]' provided conflicting values by the following sources [\"user-specified\", \"included\"]"
          )
        end
      end
    end
  end

  context "when several policies are included" do
    let(:included_policy_2_default_attributes) { {} }
    let(:included_policy_2_override_attributes) { {} }
    let(:included_policy_2_expanded_named_runlist) { nil }
    let(:included_policy_2_expanded_runlist) { ["recipe[cookbookA::default]"] }
    let(:included_policy_2_cookbooks) do
      [
        {
          name: "cookbookA",
          version: "2.0.0",
        },
      ]
    end

    let(:included_policy_2_lock_data) do
      cookbook_locks = included_policy_2_cookbooks.inject({}) do |acc, cookbook_info|
        acc[cookbook_info[:name]] = {
          "version" => cookbook_info[:version],
          "identifier" => "identifier",
          "dotted_decimal_identifier" => "dotted_decimal_identifier",
          "cache_key" => "#{cookbook_info[:name]}-#{cookbook_info[:version]}",
          "origin" => "uri",
          "source_options" => {},
        }
        acc
      end

      solution_dependencies_lock = included_policy_2_cookbooks.map do |cookbook_info|
        [cookbook_info[:name], cookbook_info[:version]]
      end

      solution_dependencies_cookbooks = included_policy_2_cookbooks.inject({}) do |acc, cookbook_info|
        acc["#{cookbook_info[:name]} (#{cookbook_info[:version]})"] = external_cookbook_universe[cookbook_info[:name]][cookbook_info[:version]]
        acc
      end

      {
        "name" => "included_policy_2file",
        "revision_id" => "myrevisionid",
        "run_list" => included_policy_2_expanded_runlist,
        "cookbook_locks" => cookbook_locks,
        "default_attributes" => included_policy_2_default_attributes,
        "override_attributes" => included_policy_2_override_attributes,
        "solution_dependencies" => {
          "Policyfile" => solution_dependencies_lock,
          "dependencies" => solution_dependencies_cookbooks,
        },
      }.tap do |core|
        core["named_run_lists"] = included_policy_2_expanded_named_runlist if included_policy_2_expanded_named_runlist
      end
    end

    let(:included_policy_2_lock_name) { "included2" }
    let(:included_policy_2_fetcher) do
      instance_double("ChefCLI::Policyfile::LocalLockFetcher").tap do |double|
        allow(double).to receive(:lock_data).and_return(included_policy_2_lock_data)
        allow(double).to receive(:valid?).and_return(true)
        allow(double).to receive(:errors).and_return([])
      end
    end

    let(:included_policy_2_lock_spec) do
      ChefCLI::Policyfile::PolicyfileLocationSpecification.new(included_policy_2_lock_name, lock_source_options, nil).tap do |spec|
        allow(spec).to receive(:valid?).and_return(true)
        allow(spec).to receive(:fetcher).and_return(included_policy_2_fetcher)
        allow(spec).to receive(:source_options_for_lock).and_return(lock_source_options)
      end
    end

    let(:included_policies) { [included_policy_lock_spec, included_policy_2_lock_spec] }

    let(:run_list) { ["local::default"] }

    context "when no cookbooks are shared as dependencies or transitive dependencies by included policies" do
      let(:included_policy_expanded_runlist) { ["recipe[cookbookA::default]"] }
      let(:included_policy_cookbooks) do
        [
          {
            name: "cookbookA",
            version: "2.0.0",
          },
        ]
      end

      let(:included_policy_2_expanded_runlist) { ["recipe[cookbookB::default]"] }
      let(:included_policy_2_cookbooks) do
        [
          {
            name: "cookbookB",
            version: "2.0.0",
          },
        ]
      end

      it "does not raise a have conflicting dependency requirements error" do
        expect { policyfile_lock.to_lock }.not_to raise_error
      end

      it "emits a lockfile with the correct dependencies" do
        expect(policyfile_lock.to_lock["solution_dependencies"]["dependencies"]).to eq({
          "cookbookA (2.0.0)" => [],
          "cookbookB (2.0.0)" => [],
          "cookbookC (1.0.0)" => [],
          "local (1.0.0)" => [["cookbookC", "= 1.0.0"]],
        })
      end
    end

    context "when some cookbooks appear as dependencies or transitive dependencies of some included policies" do
      let(:included_policy_expanded_runlist) { ["recipe[cookbookC::default]"] }
      let(:included_policy_2_expanded_runlist) { ["recipe[cookbookC::default]"] }

      context "and the locked versions of the cookbooks match" do
        let(:included_policy_cookbooks) do
          [
            {
              name: "cookbookC",
              version: "1.0.0",
            },
          ]
        end

        let(:included_policy_2_cookbooks) do
          [
            {
              name: "cookbookC",
              version: "1.0.0",
            },
          ]
        end

        it "solves the dependencies with the matching versions" do
          expect(policyfile_lock.to_lock["solution_dependencies"]["dependencies"]).to eq({
            "cookbookC (1.0.0)" => [],
            "local (1.0.0)" => [["cookbookC", "= 1.0.0"]],
          })
        end
      end

      context "and the locked versions of the cookbooks do not match" do
        let(:included_policy_cookbooks) do
          [
            {
              name: "cookbookC",
              version: "1.0.0",
            },
          ]
        end

        let(:included_policy_2_cookbooks) do
          [
            {
              name: "cookbookC",
              version: "2.0.0",
            },
          ]
        end

        it "raises an error describing the conflict" do
          expect { policyfile_lock }.to raise_error(
            ChefCLI::Policyfile::IncludedPoliciesCookbookSource::ConflictingCookbookVersions,
            /Multiple versions provided for cookbook cookbookC/
          )
        end
      end
    end

    context "when default attributes are specified" do
      context "when the included policies do not have conflicting attributes" do
        let(:included_policy_default_attributes) do
          {
            "not_conflict" => {
              "foo" => "bar",
            },
          }
        end
        let(:included_policy_2_default_attributes) do
          {
            "not_conflict" => {
              "foo" => "bar",
            },
          }
        end
        let(:default_attributes) do
          {
            "not_conflict" => {
              "bar" => "baz",
            },
          }
        end

        it "emits a lockfile with the included policies' attributes merged" do
          expect(policyfile_lock.to_lock["default_attributes"]).to eq({
            "not_conflict" => {
              "foo" => "bar",
              "bar" => "baz",
            },
          })
        end
      end

      context "when the included policies have conflicting attributes" do
        let(:included_policy_default_attributes) do
          {
            "conflict" => {
              "foo" => "bar",
            },
          }
        end

        let(:included_policy_2_default_attributes) do
          {
            "conflict" => {
              "foo" => "baz",
            },
          }
        end

        it "raises an error describing the conflict" do
          expect { policyfile_lock }.to raise_error(
            ChefCLI::Policyfile::AttributeMergeChecker::ConflictError,
            "Attribute '[conflict][foo]' provided conflicting values by the following sources [\"included\", \"included2\"]"
          )
        end
      end
    end

    context "when override attributes are specified" do
      context "when the included policies do not have conflicting attributes" do
        let(:included_policy_override_attributes) do
          {
            "not_conflict" => {
              "foo" => "bar",
            },
          }
        end
        let(:included_policy_2_override_attributes) do
          {
            "not_conflict" => {
              "foo" => "bar",
            },
          }
        end
        let(:override_attributes) do
          {
            "not_conflict" => {
              "bar" => "baz",
            },
          }
        end

        it "emits a lockfile with the included policies' attributes merged" do
          expect(policyfile_lock.to_lock["override_attributes"]).to eq({
            "not_conflict" => {
              "foo" => "bar",
              "bar" => "baz",
            },
          })
        end
      end

      context "when the included policies have conflicting attributes" do
        let(:included_policy_override_attributes) do
          {
            "conflict" => {
              "foo" => "bar",
            },
          }
        end

        let(:included_policy_2_override_attributes) do
          {
            "conflict" => {
              "foo" => "baz",
            },
          }
        end

        it "raises an error describing the conflict" do
          expect { policyfile_lock }.to raise_error(
            ChefCLI::Policyfile::AttributeMergeChecker::ConflictError,
            "Attribute '[conflict][foo]' provided conflicting values by the following sources [\"included\", \"included2\"]"
          )
        end
      end
    end

  end
end
