defmodule Mix.Tasks.Hex.OutdatedTest do
  use HexTest.IntegrationCase

  defmodule OutdatedDeps.MixProject do
    def project do
      [
        app: :outdated_app,
        version: "0.0.2",
        deps: [
          {:bar, "0.1.0"},
          {:ex_doc, "~> 0.0.1"}
        ]
      ]
    end
  end

  defmodule OutdatedBetaDeps.MixProject do
    def project do
      [
        app: :outdated_app,
        version: "0.0.1",
        deps: [
          {:beta, ">= 0.0.0"}
        ]
      ]
    end
  end

  defmodule OutdatedApp.MixProject do
    def project do
      [
        app: :outdated_app,
        version: "0.0.1",
        deps: [
          {:ex_doc, ">= 0.0.0"},
          {:postgrex, "0.2.0"},
          {:ecto, "0.2.0"}
        ]
      ]
    end
  end

  defmodule NotOutdatedApp.MixProject do
    def project do
      [
        app: :outdated_app,
        version: "0.0.1",
        deps: [
          {:ex_doc, ">= 0.0.0"}
        ]
      ]
    end
  end

  defmodule UpdateNotPossibleApp.MixProject do
    def project do
      [
        app: :outdated_app,
        version: "0.0.1",
        deps: [
          {:baz, "0.1.0"},
          {:bar, "0.1.0"}
        ]
      ]
    end
  end

  defmodule WithoutHexDeps.MixProject do
    def project do
      [
        app: :outdated_app,
        version: "0.0.1",
        deps: []
      ]
    end
  end

  defmodule OutdatedMultiDeps.MixProject do
    def project do
      [
        app: :outdated_app,
        version: "0.0.2",
        deps: [
          {:baz, "0.1.0"},
          {:bar, "0.1.0"}
        ]
      ]
    end
  end

  defmodule SingleDep.MixProject do
    def project do
      [
        app: :outdated_app,
        version: "0.0.1",
        deps: [{:foo, ">= 0.0.0"}]
      ]
    end
  end

  defmodule UnsafeLockedDeps.MixProject do
    def project do
      [
        app: :outdated_app,
        version: "0.0.1",
        deps: [
          {:tired, ">= 0.0.0"},
          {:foo, ">= 0.0.0"}
        ]
      ]
    end
  end

  defmodule OutdatedDepsWithTypes.MixProject do
    def project do
      [
        app: :outdated_app,
        version: "0.0.2",
        deps: [
          {:ex_doc, "0.0.1"},
          {:beta, "1.0.0", only: :dev},
          {:tired, "0.1.0", only: :test},
          {:foo, "0.1.0", only: [:dev, :test]}
        ]
      ]
    end
  end

  test "outdated" do
    Mix.Project.push(OutdatedDeps.MixProject)

    in_tmp(fn ->
      set_home_tmp()
      Mix.Dep.Lock.write(%{bar: {:hex, :bar, "0.1.0"}, foo: {:hex, :foo, "0.1.0"}})

      Mix.Task.run("deps.get")
      flush()

      assert catch_throw(Mix.Task.run("hex.outdated")) == {:exit_code, 1}

      bar =
        [
          [:bright, "bar", :reset],
          ["         ", :reset],
          ["      ", "0.1.0", :reset],
          ["    ", :green, "0.1.0", :reset],
          ["   ", :green, "Up-to-date", :reset],
          "           "
        ]
        |> IO.ANSI.format()
        |> List.to_string()

      assert_received {:mix_shell, :info, [^bar]}
      refute_received {:mix_shell, :info, ["foo" <> _]}
    end)
  end

  test "outdated --all" do
    Mix.Project.push(OutdatedDeps.MixProject)

    in_tmp(fn ->
      set_home_tmp()
      Mix.Dep.Lock.write(%{bar: {:hex, :bar, "0.1.0"}, foo: {:hex, :foo, "0.1.0"}})

      Mix.Task.run("deps.get")
      flush()

      assert catch_throw(Mix.Task.run("hex.outdated", ["--all"])) == {:exit_code, 1}

      bar =
        [
          [:bright, "bar", :reset],
          ["         ", :reset],
          ["      ", "0.1.0", :reset],
          ["    ", :green, "0.1.0", :reset],
          ["   ", :green, "Up-to-date", :reset],
          "           "
        ]
        |> IO.ANSI.format()
        |> List.to_string()

      foo =
        [
          [:bright, "foo", :reset],
          ["         ", :reset],
          ["      ", "0.1.0", :reset],
          ["    ", :red, "0.1.1", :reset],
          ["   ", :yellow, "Update possible", :reset],
          "      "
        ]
        |> IO.ANSI.format()
        |> List.to_string()

      ex_doc =
        [
          [:bright, "ex_doc", :reset],
          ["      ", :reset],
          ["      ", "0.0.1", :reset],
          ["    ", :red, "0.1.0", :reset],
          ["   ", :red, "Update not possible", :reset],
          "  "
        ]
        |> IO.ANSI.format()
        |> List.to_string()

      assert_received {:mix_shell, :info, [^bar]}
      assert_received {:mix_shell, :info, [^foo]}
      assert_received {:mix_shell, :info, [^ex_doc]}
    end)
  end

  test "outdated --all --sort status" do
    Mix.Project.push(OutdatedApp.MixProject)

    in_tmp(fn ->
      set_home_tmp()
      Mix.Dep.Lock.write(%{bar: {:hex, :bar, "0.1.0"}, foo: {:hex, :foo, "0.1.0"}})

      Mix.Task.run("deps.get")
      flush()

      assert catch_throw(Mix.Task.run("hex.outdated", ["--all", "--sort", "status"])) ==
               {:exit_code, 1}

      _bar =
        [
          [:bright, "bar", :reset],
          ["         ", :reset],
          ["      ", "0.1.0", :reset],
          ["    ", :green, "0.1.0", :reset],
          ["   ", :green, "Up-to-date", :reset],
          "           "
        ]
        |> IO.ANSI.format()
        |> List.to_string()

      _foo =
        [
          [:bright, "foo", :reset],
          ["         ", :reset],
          ["      ", "0.1.0", :reset],
          ["    ", :red, "0.1.1", :reset],
          ["   ", :yellow, "Update possible", :reset],
          "      "
        ]
        |> IO.ANSI.format()
        |> List.to_string()

      _ex_doc =
        [
          [:bright, "ex_doc", :reset],
          ["      ", :reset],
          ["      ", "0.0.1", :reset],
          ["    ", :red, "0.1.0", :reset],
          ["   ", :red, "Update not possible", :reset],
          "  "
        ]
        |> IO.ANSI.format()
        |> List.to_string()

      lines = flush()
      extracted_statuses = extract_statuses(lines)

      assert extracted_statuses == [
               "Up-to-date",
               "Update not possible",
               "Update not possible",
               "Update not possible",
               "Update possible"
             ]
    end)
  end

  test "outdated --all with multiple dependent packages" do
    Mix.Project.push(OutdatedMultiDeps.MixProject)

    in_tmp(fn ->
      set_home_tmp()

      Mix.Dep.Lock.write(%{
        foo: {:hex, :foo, "0.1.0"},
        bar: {:hex, :bar, "0.1.0"},
        baz: {:hex, :baz, "0.1.0"}
      })

      Mix.Task.run("deps.get")
      flush()

      assert catch_throw(Mix.Task.run("hex.outdated", ["--all"])) == {:exit_code, 1}

      foo =
        [
          [:bright, "foo", :reset],
          ["         ", :reset],
          ["      ", "0.1.0", :reset],
          ["    ", :red, "0.1.1", :reset],
          ["   ", :red, "Update not possible", :reset],
          "  "
        ]
        |> IO.ANSI.format()
        |> List.to_string()

      assert_received {:mix_shell, :info, [^foo]}
    end)
  end

  test "outdated --all --within-requirements" do
    Mix.Project.push(OutdatedDeps.MixProject)

    in_tmp(fn ->
      set_home_tmp()
      Mix.Dep.Lock.write(%{bar: {:hex, :bar, "0.1.0"}, foo: {:hex, :foo, "0.1.0"}})

      Mix.Task.run("deps.get")
      flush()

      assert catch_throw(Mix.Task.run("hex.outdated", ["--all", "--within-requirements"])) ==
               {:exit_code, 1}

      bar =
        [
          [:bright, "bar", :reset],
          ["         ", :reset],
          ["      ", "0.1.0", :reset],
          ["    ", :green, "0.1.0", :reset],
          ["   ", :green, "Up-to-date", :reset],
          "           "
        ]
        |> IO.ANSI.format()
        |> List.to_string()

      foo =
        [
          [:bright, "foo", :reset],
          ["         ", :reset],
          ["      ", "0.1.0", :reset],
          ["    ", :red, "0.1.1", :reset],
          ["   ", :yellow, "Update possible", :reset],
          "      "
        ]
        |> IO.ANSI.format()
        |> List.to_string()

      ex_doc =
        [
          [:bright, "ex_doc", :reset],
          ["      ", :reset],
          ["      ", "0.0.1", :reset],
          ["    ", :red, "0.1.0", :reset],
          ["   ", :red, "Update not possible", :reset],
          "  "
        ]
        |> IO.ANSI.format()
        |> List.to_string()

      assert_received {:mix_shell, :info, [^bar]}
      assert_received {:mix_shell, :info, [^foo]}
      assert_received {:mix_shell, :info, [^ex_doc]}
    end)
  end

  test "outdated --all --within-requirements (update not possible)" do
    Mix.Project.push(UpdateNotPossibleApp.MixProject)

    in_tmp(fn ->
      set_home_tmp()
      Mix.Dep.Lock.write(%{foo: {:hex, :foo, "0.1.0"}})

      Mix.Task.run("deps.get")
      flush()

      assert Mix.Task.run("hex.outdated", ["--all", "--within-requirements"]) ==
               nil

      bar =
        [
          [:bright, "bar", :reset],
          ["         ", :reset],
          ["      ", "0.1.0", :reset],
          ["    ", :green, "0.1.0", :reset],
          ["   ", :green, "Up-to-date", :reset],
          "           "
        ]
        |> IO.ANSI.format()
        |> List.to_string()

      baz =
        [
          [:bright, "baz", :reset],
          ["         ", :reset],
          ["      ", "0.1.0", :reset],
          ["    ", :green, "0.1.0", :reset],
          ["   ", :green, "Up-to-date", :reset],
          "           "
        ]
        |> IO.ANSI.format()
        |> List.to_string()

      foo =
        [
          [:bright, "foo", :reset],
          ["         ", :reset],
          ["      ", "0.1.0", :reset],
          ["    ", :red, "0.1.1", :reset],
          ["   ", :red, "Update not possible", :reset],
          "  "
        ]
        |> IO.ANSI.format()
        |> List.to_string()

      assert_received {:mix_shell, :info, [^bar]}
      assert_received {:mix_shell, :info, [^baz]}
      assert_received {:mix_shell, :info, [^foo]}
    end)
  end

  test "outdated --all --within-requirements (not outdated)" do
    Mix.Project.push(NotOutdatedApp.MixProject)

    in_tmp(fn ->
      set_home_tmp()
      Mix.Dep.Lock.write(%{ex_doc: {:hex, :ex_doc, "0.1.0"}})

      Mix.Task.run("deps.get")
      flush()

      assert Mix.Task.run("hex.outdated", ["ex_doc"]) == nil

      msg =
        ["Current version ", :bright, "0.1.0", :reset, " of dependency is up to date!"]
        |> IO.ANSI.format_fragment()
        |> List.to_string()

      assert_received {:mix_shell, :info, [^msg]}
    end)
  end

  test "outdated --pre" do
    Mix.Project.push(OutdatedBetaDeps.MixProject)

    in_tmp(fn ->
      set_home_tmp()
      Mix.Dep.Lock.write(%{beta: {:hex, :beta, "1.0.0"}})

      Mix.Task.run("deps.get")
      flush()

      Mix.Task.run("hex.outdated", [])

      beta =
        [
          [:bright, "beta", :reset],
          ["        ", :reset],
          ["      ", "1.0.0", :reset],
          ["    ", :green, "1.0.0", :reset],
          ["   ", :green, "Up-to-date", :reset],
          ["  "]
        ]
        |> IO.ANSI.format()
        |> List.to_string()

      assert_received {:mix_shell, :info, [^beta]}

      Mix.Task.reenable("hex.outdated")
      assert catch_throw(Mix.Task.run("hex.outdated", ["--pre"])) == {:exit_code, 1}

      beta =
        [
          [:bright, "beta", :reset],
          ["        ", :reset],
          ["      ", "1.0.0", :reset],
          ["    ", :red, "1.1.0-beta", :reset],
          ["  ", :yellow, "Update possible", :reset],
          "  "
        ]
        |> IO.ANSI.format()
        |> List.to_string()

      assert_received {:mix_shell, :info, [^beta]}
    end)
  end

  test "outdated app" do
    Mix.Project.push(OutdatedApp.MixProject)

    in_tmp(fn ->
      set_home_tmp()
      Mix.Dep.Lock.write(%{ex_doc: {:hex, :ex_doc, "0.0.1"}})

      Mix.Task.run("deps.get")
      flush()

      assert catch_throw(Mix.Task.run("hex.outdated", ["ex_doc"])) == {:exit_code, 1}

      msg =
        [
          "There is newer version of the dependency available ",
          [:bright, "0.1.0 > 0.0.1", :reset, "!"]
        ]
        |> IO.ANSI.format_fragment()
        |> List.to_string()

      assert_received {:mix_shell, :info, [^msg]}

      mix =
        [
          :bright,
          "mix.exs",
          :reset,
          "   ",
          :green,
          ">= 0.0.0",
          :reset,
          "     ",
          :green,
          "Yes",
          :reset,
          "         "
        ]
        |> IO.ANSI.format()
        |> List.to_string()

      assert_received {:mix_shell, :info, [^mix]}

      ecto =
        [
          :bright,
          "ecto",
          :reset,
          "      ",
          :red,
          "~> 0.0.1",
          :reset,
          "     ",
          :red,
          "No",
          :reset,
          "          "
        ]
        |> IO.ANSI.format()
        |> List.to_string()

      assert_received {:mix_shell, :info, [^ecto]}

      postgrex =
        [
          :bright,
          "postgrex",
          :reset,
          "  ",
          :red,
          "0.0.1",
          :reset,
          "        ",
          :red,
          "No",
          :reset,
          "          "
        ]
        |> IO.ANSI.format()
        |> List.to_string()

      assert_received {:mix_shell, :info, [^postgrex]}
    end)
  end

  test "outdated app --within-requirements still exits when latest does not match" do
    Mix.Project.push(OutdatedApp.MixProject)

    in_tmp(fn ->
      set_home_tmp()
      Mix.Dep.Lock.write(%{ex_doc: {:hex, :ex_doc, "0.0.1"}})

      Mix.Task.run("deps.get")
      flush()

      assert catch_throw(Mix.Task.run("hex.outdated", ["postgrex", "--within-requirements"])) ==
               {:exit_code, 1}
    end)
  end

  test "outdated app --only does not filter out the requested dependency" do
    Mix.Project.push(OutdatedDepsWithTypes.MixProject)

    in_tmp(fn ->
      set_home_tmp()

      Mix.Dep.Lock.write(%{
        ex_doc: {:hex, :ex_doc, "0.0.1"},
        beta: {:hex, :beta, "1.0.0"},
        tired: {:hex, :tired, "0.1.0"},
        foo: {:hex, :foo, "0.1.0"}
      })

      Mix.Task.run("deps.get")
      flush()

      Mix.Task.run("hex.outdated", ["beta", "--only", "test"])

      msg =
        ["Current version ", :bright, "1.0.0", :reset, " of dependency is up to date!"]
        |> IO.ANSI.format_fragment()
        |> List.to_string()

      assert_received {:mix_shell, :info, [^msg]}
    end)
  end

  @tag :requires_json
  test "outdated app --json" do
    Mix.Project.push(OutdatedApp.MixProject)

    in_tmp(fn ->
      set_home_tmp()
      Mix.Dep.Lock.write(%{ex_doc: {:hex, :ex_doc, "0.0.1"}})

      Mix.Task.run("deps.get")
      flush()

      assert catch_throw(Mix.Task.run("hex.outdated", ["ex_doc", "--json"])) == {:exit_code, 1}

      expected =
        [
          %{
            package: "ex_doc",
            only: "",
            lock_version: "0.0.1",
            latest_version: "0.1.0",
            requirements: [
              %{source: "mix.exs", requirement: ">= 0.0.0", up_to_date: true},
              %{source: "ecto", requirement: "~> 0.0.1", up_to_date: false},
              %{source: "postgrex", requirement: "0.0.1", up_to_date: false}
            ],
            outdated: true,
            cooldown: nil
          }
        ]
        |> :json.encode()
        |> IO.iodata_to_binary()

      assert_received {:mix_shell, :info, [^expected]}
    end)
  end

  test "not outdated app" do
    Mix.Project.push(NotOutdatedApp.MixProject)

    in_tmp(fn ->
      set_home_tmp()
      Mix.Dep.Lock.write(%{ex_doc: {:hex, :ex_doc, "0.1.0"}})

      Mix.Task.run("deps.get")
      flush()

      Mix.Task.run("hex.outdated", ["ex_doc"])

      msg =
        ["Current version ", :bright, "0.1.0", :reset, " of dependency is up to date!"]
        |> IO.ANSI.format_fragment()
        |> List.to_string()

      assert_received {:mix_shell, :info, [^msg]}
    end)
  end

  test "without hex deps" do
    Mix.Project.push(WithoutHexDeps.MixProject)

    in_tmp(fn ->
      set_home_tmp()
      Mix.Dep.Lock.write(%{})

      Mix.Task.run("deps.get")
      flush()

      Mix.Task.run("hex.outdated")
      msg = "No hex dependencies"
      assert_received {:mix_shell, :info, [^msg]}
    end)
  end

  test "umbrella projects" do
    in_tmp("umbrella", fn ->
      File.write!("mix.exs", """
      defmodule Umbrella.MixProject do
        use Mix.Project

        def project do
          [apps_path: "apps",
           version: "0.0.1",
           deps: [{:ex_doc, "~> 0.0.1"}]]
        end
      end
      """)

      Mix.Project.in_project(:umbrella, ".", fn _ ->
        File.mkdir_p!("apps/bacon")

        File.write!("apps/bacon/mix.exs", """
        defmodule Bacon.MixProject do
          use Mix.Project

          def project do
            [app: :bacon,
             version: "0.1.0",
             build_path: "../../_build",
             config_path: "../../config/config.exs",
             deps_path: "../../deps",
             lockfile: "../../mix.lock",
             deps: [{:bar, "0.1.0"}]]
          end
        end
        """)

        Mix.Project.in_project(:bacon, "apps/bacon", fn _ ->
          Mix.Task.run("deps.get")
          flush()
        end)

        Mix.Task.run("deps.get")
        flush()

        ex_doc =
          [
            [:bright, "ex_doc", :reset],
            ["      ", :reset],
            ["      ", "0.0.1", :reset],
            ["    ", :red, "0.1.0", :reset],
            ["   ", :red, "Update not possible", :reset],
            "  "
          ]
          |> IO.ANSI.format()
          |> List.to_string()

        bar =
          [
            [:bright, "bar", :reset],
            ["         ", :reset],
            ["      ", "0.1.0", :reset],
            ["    ", :green, "0.1.0", :reset],
            ["   ", :green, "Up-to-date", :reset],
            "           "
          ]
          |> IO.ANSI.format()
          |> List.to_string()

        foo =
          [
            [:bright, "foo", :reset],
            ["         ", :reset],
            ["      ", "0.1.1", :reset],
            ["    ", :green, "0.1.1", :reset],
            ["   ", :green, "Up-to-date", :reset],
            "           "
          ]
          |> IO.ANSI.format()
          |> List.to_string()

        assert catch_throw(Mix.Task.run("hex.outdated")) == {:exit_code, 1}
        assert_received {:mix_shell, :info, [^ex_doc]}
        assert_received {:mix_shell, :info, [^bar]}
        refute_received {:mix_shell, :info, [^foo]}

        assert catch_throw(Mix.Tasks.Hex.Outdated.run(["--all"])) == {:exit_code, 1}
        assert_received {:mix_shell, :info, [^ex_doc]}
        assert_received {:mix_shell, :info, [^bar]}
        assert_received {:mix_shell, :info, [^foo]}
      end)
    end)
  end

  test "outdated shows a generic diff command hint when updates are available" do
    Mix.Project.push(OutdatedDeps.MixProject)

    in_tmp(fn ->
      set_home_tmp()
      Mix.Dep.Lock.write(%{bar: {:hex, :bar, "0.1.0"}, foo: {:hex, :foo, "0.1.0"}})

      Mix.Task.run("deps.get")
      flush()

      assert catch_throw(Mix.Task.run("hex.outdated", ["--all"])) == {:exit_code, 1}

      lines = flush()
      output = Enum.map_join(lines, "\n", fn {_, _, [line]} -> line end)

      assert output =~
               "To view the diff of a specific update, run `mix hex.package diff APP FROM..TO`."
    end)
  end

  defp extract_statuses(lines) do
    Enum.flat_map(lines, fn {_, _, [line]} ->
      ~r/Up-to-date|Update not possible|Update possible/
      |> Regex.scan(line)
      |> List.flatten()
    end)
  end

  test "outdated with dependency types displays only column" do
    Mix.Project.push(OutdatedDepsWithTypes.MixProject)

    in_tmp(fn ->
      set_home_tmp()

      Mix.Dep.Lock.write(%{
        ex_doc: {:hex, :ex_doc, "0.0.1"},
        beta: {:hex, :beta, "1.0.0"},
        tired: {:hex, :tired, "0.1.0"},
        foo: {:hex, :foo, "0.1.0"}
      })

      Mix.Task.run("deps.get")
      flush()

      catch_throw(Mix.Task.run("hex.outdated"))

      # Check that the Only column is present in the header
      assert_received {:mix_shell, :info, [header_line]}
      assert header_line =~ "Dependency"
      assert header_line =~ "Only"
      assert header_line =~ "Current"
      assert header_line =~ "Latest"
      assert header_line =~ "Status"

      # Check that dependencies show their correct only values
      # Note: dependencies without :only show empty string in the Only column
      output_lines =
        Enum.map(1..10, fn _ ->
          receive do
            {:mix_shell, :info, [line]} -> line
          after
            100 -> nil
          end
        end)
        |> Enum.filter(&(&1 != nil))

      combined_output = Enum.join(output_lines, "\n")
      assert combined_output =~ "foo" and combined_output =~ "dev,test"
      assert combined_output =~ "beta" and combined_output =~ "dev"
      assert combined_output =~ "tired" and combined_output =~ "test"
    end)
  end

  test "outdated --only dev filters to only dev dependencies" do
    Mix.Project.push(OutdatedDepsWithTypes.MixProject)

    in_tmp(fn ->
      set_home_tmp()

      Mix.Dep.Lock.write(%{
        ex_doc: {:hex, :ex_doc, "0.0.1"},
        beta: {:hex, :beta, "1.0.0"},
        tired: {:hex, :tired, "0.1.0"},
        foo: {:hex, :foo, "0.1.0"}
      })

      Mix.Task.run("deps.get")
      flush()

      catch_throw(Mix.Task.run("hex.outdated", ["--only", "dev"]))

      # Should show only dependencies with :only set to :dev
      # Since we filtered to only "dev", the output should contain only ex_doc
      output_lines =
        Enum.map(1..10, fn _ ->
          receive do
            {:mix_shell, :info, [line]} -> line
          after
            100 -> nil
          end
        end)
        |> Enum.filter(&(&1 != nil))

      combined_output = Enum.join(output_lines, "\n")
      assert combined_output =~ "beta"
      # foo has [:dev, :test] not just :dev, but beta only has :dev so both should show
      assert combined_output =~ "foo"
      refute combined_output =~ "ex_doc"
    end)
  end

  test "outdated --only dev,test filters to dev and test dependencies" do
    Mix.Project.push(OutdatedDepsWithTypes.MixProject)

    in_tmp(fn ->
      set_home_tmp()

      Mix.Dep.Lock.write(%{
        ex_doc: {:hex, :ex_doc, "0.0.1"},
        beta: {:hex, :beta, "1.0.0"},
        tired: {:hex, :tired, "0.1.0"},
        foo: {:hex, :foo, "0.1.0"}
      })

      Mix.Task.run("deps.get")
      flush()

      catch_throw(Mix.Task.run("hex.outdated", ["--only", "dev,test"]))

      # Should show dependencies with :only set to :dev or :test
      # This should include ex_doc (dev) and plug (test)
      # bypass has [:dev, :test] which displays as "dev,test" but should match both "dev" and "test" individually
      output_lines =
        Enum.map(1..10, fn _ ->
          receive do
            {:mix_shell, :info, [line]} -> line
          after
            100 -> nil
          end
        end)
        |> Enum.filter(&(&1 != nil))

      combined_output = Enum.join(output_lines, "\n")
      assert combined_output =~ "tired"
      assert combined_output =~ "beta"
      # foo should be included since it has dev and test
      assert combined_output =~ "foo"
      refute combined_output =~ "ex_doc"
    end)
  end

  describe "cooldown" do
    defp inject_published_at(repo, package, version, published_at) do
      :sys.replace_state(Hex.Registry.Server, fn state ->
        :ets.insert(state.ets, {{:published_at, repo, package, version}, published_at})
        state
      end)
    end

    defp clear_all_published_at do
      :sys.replace_state(Hex.Registry.Server, fn state ->
        :ets.match_delete(state.ets, {{:published_at, :_, :_, :_}, :_})
        state
      end)
    end

    defp collect_shell_lines do
      for {:mix_shell, :info, [line]} <- flush(), is_binary(line), do: line
    end

    test "annotates row when latest version is within the cooldown window" do
      Mix.Project.push(OutdatedDeps.MixProject)

      in_tmp(fn ->
        set_home_tmp()
        Mix.Dep.Lock.write(%{bar: {:hex, :bar, "0.1.0"}, foo: {:hex, :foo, "0.1.0"}})

        Mix.Task.run("deps.get")
        flush()

        clear_all_published_at()
        inject_published_at("hexpm", "foo", "0.1.1", System.system_time(:second) - 86_400)
        Hex.State.put(:cooldown, "7d")

        assert catch_throw(Mix.Task.run("hex.outdated", ["--all"])) == {:exit_code, 1}

        lines = collect_shell_lines()

        assert Enum.any?(lines, &(&1 =~ "foo" and &1 =~ "Update possible" and &1 =~ "(cooldown)"))
        assert Enum.any?(lines, &(&1 == "Versions in cooldown:"))
        assert Enum.any?(lines, &(&1 =~ "foo 0.1.1" and &1 =~ "eligible"))
      end)
    end

    test "does not annotate when latest version is older than the cooldown window" do
      Mix.Project.push(OutdatedDeps.MixProject)

      in_tmp(fn ->
        set_home_tmp()
        Mix.Dep.Lock.write(%{bar: {:hex, :bar, "0.1.0"}, foo: {:hex, :foo, "0.1.0"}})

        Mix.Task.run("deps.get")
        flush()

        clear_all_published_at()
        inject_published_at("hexpm", "foo", "0.1.1", System.system_time(:second) - 30 * 86_400)
        Hex.State.put(:cooldown, "7d")

        assert catch_throw(Mix.Task.run("hex.outdated", ["--all"])) == {:exit_code, 1}

        lines = collect_shell_lines()
        refute Enum.any?(lines, &(&1 =~ "(cooldown)"))
        refute Enum.any?(lines, &(&1 == "Versions in cooldown:"))
      end)
    end

    test "does not annotate when cooldown is disabled" do
      Mix.Project.push(OutdatedDeps.MixProject)

      in_tmp(fn ->
        set_home_tmp()
        Mix.Dep.Lock.write(%{bar: {:hex, :bar, "0.1.0"}, foo: {:hex, :foo, "0.1.0"}})

        Mix.Task.run("deps.get")
        flush()

        clear_all_published_at()
        inject_published_at("hexpm", "foo", "0.1.1", System.system_time(:second) - 86_400)
        Hex.State.put(:cooldown, "0d")

        assert catch_throw(Mix.Task.run("hex.outdated", ["--all"])) == {:exit_code, 1}

        lines = collect_shell_lines()
        refute Enum.any?(lines, &(&1 =~ "(cooldown)"))
        refute Enum.any?(lines, &(&1 == "Versions in cooldown:"))
      end)
    end

    test "treats missing published_at as eligible" do
      Mix.Project.push(OutdatedDeps.MixProject)

      in_tmp(fn ->
        set_home_tmp()
        Mix.Dep.Lock.write(%{bar: {:hex, :bar, "0.1.0"}, foo: {:hex, :foo, "0.1.0"}})

        Mix.Task.run("deps.get")
        flush()

        clear_all_published_at()
        Hex.State.put(:cooldown, "1mo")

        assert catch_throw(Mix.Task.run("hex.outdated", ["--all"])) == {:exit_code, 1}

        lines = collect_shell_lines()
        refute Enum.any?(lines, &(&1 =~ "(cooldown)"))
        refute Enum.any?(lines, &(&1 == "Versions in cooldown:"))
      end)
    end

    test "does not annotate up-to-date dependencies even when fresh" do
      Mix.Project.push(OutdatedDeps.MixProject)

      in_tmp(fn ->
        set_home_tmp()
        Mix.Dep.Lock.write(%{bar: {:hex, :bar, "0.1.0"}, foo: {:hex, :foo, "0.1.0"}})

        Mix.Task.run("deps.get")
        flush()

        clear_all_published_at()
        inject_published_at("hexpm", "bar", "0.1.0", System.system_time(:second) - 86_400)
        Hex.State.put(:cooldown, "7d")

        assert catch_throw(Mix.Task.run("hex.outdated", ["--all"])) == {:exit_code, 1}

        lines = collect_shell_lines()
        refute Enum.any?(lines, &(&1 =~ "bar" and &1 =~ "(cooldown)"))
      end)
    end

    test "single-package mode annotates output and does not exit with error when latest is in cooldown" do
      Mix.Project.push(OutdatedDeps.MixProject)

      in_tmp(fn ->
        set_home_tmp()
        Mix.Dep.Lock.write(%{bar: {:hex, :bar, "0.1.0"}, foo: {:hex, :foo, "0.1.0"}})

        Mix.Task.run("deps.get")
        flush()

        clear_all_published_at()
        inject_published_at("hexpm", "foo", "0.1.1", System.system_time(:second) - 86_400)
        Hex.State.put(:cooldown, "7d")

        assert Mix.Task.run("hex.outdated", ["foo"]) == nil

        lines = collect_shell_lines()
        assert Enum.any?(lines, &(&1 =~ "Version 0.1.1 is in cooldown" and &1 =~ "eligible"))
      end)
    end

    test "does not exit with error when every outdated dep is cooldown-held" do
      Mix.Project.push(SingleDep.MixProject)

      in_tmp(fn ->
        set_home_tmp()
        Mix.Dep.Lock.write(%{foo: {:hex, :foo, "0.1.0"}})

        Mix.Task.run("deps.get")
        flush()

        clear_all_published_at()
        inject_published_at("hexpm", "foo", "0.1.1", System.system_time(:second) - 86_400)
        Hex.State.put(:cooldown, "7d")

        assert Mix.Task.run("hex.outdated") == nil
      end)
    end

    test "still exits with error when at least one outdated dep is not cooldown-held" do
      Mix.Project.push(OutdatedDeps.MixProject)

      in_tmp(fn ->
        set_home_tmp()
        Mix.Dep.Lock.write(%{bar: {:hex, :bar, "0.1.0"}, foo: {:hex, :foo, "0.1.0"}})

        Mix.Task.run("deps.get")
        flush()

        clear_all_published_at()
        inject_published_at("hexpm", "foo", "0.1.1", System.system_time(:second) - 86_400)
        Hex.State.put(:cooldown, "7d")

        assert catch_throw(Mix.Task.run("hex.outdated", ["--all"])) == {:exit_code, 1}
      end)
    end

    test "--sort status orders cooldown-annotated rows by their base status" do
      Mix.Project.push(OutdatedDeps.MixProject)

      in_tmp(fn ->
        set_home_tmp()
        Mix.Dep.Lock.write(%{bar: {:hex, :bar, "0.1.0"}, foo: {:hex, :foo, "0.1.0"}})

        Mix.Task.run("deps.get")
        flush()

        clear_all_published_at()
        inject_published_at("hexpm", "foo", "0.1.1", System.system_time(:second) - 86_400)
        Hex.State.put(:cooldown, "7d")

        assert catch_throw(Mix.Task.run("hex.outdated", ["--all", "--sort", "status"])) ==
                 {:exit_code, 1}

        assert extract_statuses(flush()) == [
                 "Up-to-date",
                 "Update not possible",
                 "Update possible"
               ]
      end)
    end

    test "does not annotate when locked version is retired (unsafe-lock bypass)" do
      Mix.Project.push(UnsafeLockedDeps.MixProject)

      in_tmp(fn ->
        set_home_tmp()
        # tired 0.1.0 is retired in setup_hexpm.exs; foo 0.1.0 is not.
        Mix.Dep.Lock.write(%{
          tired: {:hex, :tired, "0.1.0"},
          foo: {:hex, :foo, "0.1.0"}
        })

        Mix.Task.run("deps.get")
        flush()

        clear_all_published_at()
        inject_published_at("hexpm", "tired", "0.2.0", System.system_time(:second) - 86_400)
        inject_published_at("hexpm", "foo", "0.1.1", System.system_time(:second) - 86_400)
        Hex.State.put(:cooldown, "7d")

        assert catch_throw(Mix.Task.run("hex.outdated", ["--all"])) == {:exit_code, 1}

        lines = collect_shell_lines()

        refute Enum.any?(lines, &(&1 =~ "tired" and &1 =~ "(cooldown)"))
        assert Enum.any?(lines, &(&1 =~ "foo" and &1 =~ "(cooldown)"))
      end)
    end

    test "does not annotate when the update is not possible due to requirements" do
      Mix.Project.push(OutdatedDeps.MixProject)

      in_tmp(fn ->
        set_home_tmp()
        # ex_doc's locked version is 0.0.1; OutdatedDeps requires ~> 0.0.1,
        # which doesn't match the latest 0.1.0. Even with cooldown set on
        # the latest, the user can't take the upgrade — don't annotate.
        Mix.Dep.Lock.write(%{bar: {:hex, :bar, "0.1.0"}, foo: {:hex, :foo, "0.1.0"}})

        Mix.Task.run("deps.get")
        flush()

        clear_all_published_at()
        inject_published_at("hexpm", "ex_doc", "0.1.0", System.system_time(:second) - 86_400)
        Hex.State.put(:cooldown, "7d")

        assert catch_throw(Mix.Task.run("hex.outdated", ["--all"])) == {:exit_code, 1}

        lines = collect_shell_lines()

        assert Enum.any?(lines, &(&1 =~ "ex_doc" and &1 =~ "Update not possible"))
        refute Enum.any?(lines, &(&1 =~ "ex_doc" and &1 =~ "(cooldown)"))
        refute Enum.any?(lines, &(&1 =~ "ex_doc 0.1.0" and &1 =~ "eligible"))
      end)
    end

    @tag :requires_json
    test "JSON output exposes published_at and eligible_on when latest is in cooldown" do
      Mix.Project.push(OutdatedDeps.MixProject)

      in_tmp(fn ->
        set_home_tmp()
        Mix.Dep.Lock.write(%{bar: {:hex, :bar, "0.1.0"}, foo: {:hex, :foo, "0.1.0"}})

        Mix.Task.run("deps.get")
        flush()

        clear_all_published_at()
        inject_published_at("hexpm", "foo", "0.1.1", System.system_time(:second) - 86_400)
        Hex.State.put(:cooldown, "7d")

        Mix.Task.run("hex.outdated", ["foo", "--json"])

        [json] = collect_shell_lines()
        [decoded] = :json.decode(json)
        assert %{"published_at" => _, "eligible_on" => _} = decoded["cooldown"]
      end)
    end
  end
end
