lib/git_hub_actions/versions.ex

defmodule GitHubActions.Versions do
  @moduledoc """
  Functions to select and filter lists and tables of versions.

  The list of versions can have the following two forms.
  - A simple list:
    ```elixir
    ["1", "2.0", "2.1", "3", "3.1", "3.1.1"]
    ```
  - A table as list of keyword lists with compatible versions:
    ```elixir
    [
      [a: ["1.0.0"], b: ["1.0", "1.1", "1.2"]],
      [a: ["2.0.0"], b: ["1.2", "2.0"]]
    ]
    ```
  """

  alias GitHubActions.Config
  alias GitHubActions.Version
  alias GitHubActions.Versions.Impl

  @type versions_list :: [Version.version()]
  @type versions_table :: [keyword(Version.version())]
  @type versions :: versions_list() | versions_table()
  @type key :: atom()

  @doc """
  Returns the latest version from the configured versions list.

  ## Examples

      iex> Config.config(:versions, ["1.0.0/2", "1.1.0/3"])
      iex> Versions.latest()
      #Version<1.1.3>
  """
  @spec latest :: Version.t()
  def latest, do: latest(from_config())

  @doc """
  Returns the latest version from the configured `versions` table by the given
  `key` or from the given `versions` list.

  ## Examples

      iex> Versions.latest(["1.0.0/2", "1.1.0/3"])
      #Version<1.1.3>

      iex> Config.config(:versions, [
      ...>   [a: ["1.0.0/2", "1.1.0/3"], b: ["2.0/5"]],
      ...>   [a: ["1.2.0/1", "1.3.0/4"], b: ["3.0/5"]]
      ...> ])
      iex> Versions.latest(:a)
      #Version<1.3.4>

      iex> Versions.latest(["foo"])
      ** (GitHubActions.InvalidVersionError) invalid version: "foo"

      iex> Versions.latest([a: "1"])
      ** (ArgumentError) latest/1 expected a list or table of versions or a key, got: [a: "1"]

      iex> Versions.latest(:elixir)
      #Version<1.14.0>

      iex> Versions.latest(:otp)
      #Version<25.1>
  """
  @spec latest(versions() | key()) :: Version.t()
  def latest(versions_or_key) when is_list(versions_or_key) do
    case Impl.type(versions_or_key) do
      {:list, versions} ->
        Impl.latest(versions)

      :error ->
        raise ArgumentError,
          message: """
          latest/1 expected a list or table of versions or a key, \
          got: #{inspect(versions_or_key)}\
          """
    end
  end

  def latest(key) when is_atom(key), do: latest(from_config(), key)

  @doc """
  Returns the latest version from a `versions` table by the given `key`.

  ## Examples

      iex> Versions.latest([
      ...>   [a: ["1.0.0/2"], b: ["1.0.0/3"]],
      ...>   [a: ["1.1.0/3"], b: ["1.1.0/4"]]
      ...> ], :a)
      #Version<1.1.3>

      iex> Versions.latest([a: "1"], :a)
      ** (ArgumentError) latest/1 expected a table of versions,  got: [a: "1"]
  """
  @spec latest(versions_table(), key()) :: Version.t()
  def latest(versions, key) when is_list(versions) and is_atom(key) do
    case Impl.type(versions) do
      {:table, versions} ->
        Impl.latest(versions, key)

      :error ->
        raise ArgumentError,
          message: "latest/1 expected a table of versions,  got: #{inspect(versions)}"
    end
  end

  @doc """
  Returns the latest minor versions from the configured versions list.

  ## Examples

      iex> Config.config(:versions, ["1.0.0/2", "1.1.0/4", "2.0.0/3"])
      iex> Versions.latest_minor() |> Enum.map(&to_string/1)
      ["1.0.2", "1.1.4", "2.0.3"]
  """
  @spec latest_minor :: [Version.t()]
  def latest_minor, do: latest_minor(from_config())

  @doc """
  Returns the latest minor versions from the configured `versions` table by the
  given `key` or from the given `versions` list.

  ## Examples

      iex> minor_versions = Versions.latest_minor(["1.0.0/2", "1.1.0/3"])
      iex> Enum.map(minor_versions, &to_string/1)
      ["1.0.2", "1.1.3"]

      iex> Config.config(:versions, [
      ...>   [a: ["1.0.0/2", "1.1.0/3"], b: ["2.0/5"]],
      ...>   [a: ["1.2.0/1", "1.3.0/4"], b: ["3.0/5"]]
      ...> ])
      iex> minor_versions = Versions.latest_minor(:a)
      iex> Enum.map(minor_versions, &to_string/1)
      ["1.0.2", "1.1.3", "1.2.1", "1.3.4"]

      iex> Versions.latest_minor(["foo"])
      ** (GitHubActions.InvalidVersionError) invalid version: "foo"

      iex> Versions.latest_minor([a: "1"])
      ** (ArgumentError) latest_minor/1 expected a list or table of versions or a key, got: [a: "1"]

      iex> minor_versions = Versions.latest_minor(:elixir)
      iex> Enum.map(minor_versions, &to_string/1)
      ["1.0.5", "1.1.1", "1.2.6", "1.3.4", "1.4.5", "1.5.3", "1.6.6", "1.7.4",
       "1.8.2", "1.9.4", "1.10.4", "1.11.4", "1.12.3", "1.13.4", "1.14.0"]

      iex> minor_versions = Versions.latest_minor(:otp)
      iex> Enum.map(minor_versions, &to_string/1)
      ["17.0", "17.1", "17.2", "17.3", "17.4", "17.5", "18.0", "18.1", "18.2",
       "18.3", "19.0", "19.1", "19.2", "19.3", "20.0", "20.1", "20.2", "20.3",
       "21.0", "21.1", "21.2", "21.3", "22.0", "22.1", "22.2", "22.3", "23.0",
       "23.1", "23.2", "23.3", "24.0", "24.1", "24.2", "24.3", "25.0", "25.1"]
  """
  @spec latest_minor(versions_list() | key()) :: [Version.t()]
  def latest_minor(versions_or_key) when is_list(versions_or_key) do
    case Impl.type(versions_or_key) do
      {_type, versions} ->
        Impl.latest_minor(versions)

      _error ->
        raise ArgumentError,
          message: """
          latest_minor/1 expected a list or table of versions or a key, \
          got: #{inspect(versions_or_key)}\
          """
    end
  end

  def latest_minor(key) when is_atom(key), do: latest_minor(from_config(), key)

  @doc """
  Returns the latest minor versions from a `versions` table by the given `key`.

  ## Examples

      iex> minor_versions = Versions.latest_minor([
      ...>   [a: ["1.0.0/2"], b: ["1.0.0/3"]],
      ...>   [a: ["1.1.0/3"], b: ["1.1.0/4"]]
      ...> ], :a)
      iex> Enum.map(minor_versions, &to_string/1)
      ["1.0.2", "1.1.3"]

      iex> Versions.latest_minor([a: "1"], :a)
      ** (ArgumentError) latest_minor/1 expected a table of versions,  got: [a: "1"]
  """
  @spec latest_minor(versions_table(), key()) :: [Version.t()]
  def latest_minor(versions, key) when is_list(versions) and is_atom(key) do
    case Impl.type(versions) do
      {:table, versions} ->
        Impl.latest_minor(versions, key)

      :error ->
        raise ArgumentError,
          message: "latest_minor/1 expected a table of versions,  got: #{inspect(versions)}"
    end
  end

  @doc """
  Returns the latest major versions from the configured versions list.

  ## Examples

      iex> Config.config(:versions, ["1.0.0/2", "1.1.0/4", "2.0.0/3"])
      iex> Versions.latest_major() |> Enum.map(&to_string/1)
      ["1.1.4", "2.0.3"]
  """
  @spec latest_major :: [Version.t()]
  def latest_major, do: latest_major(from_config())

  @doc """
  Returns the latest major versions from the configured `versions` table by the
  given `key` or from the given `versions` list.

  ## Examples

      iex> major_versions = Versions.latest_major(["1.0.0/2", "1.1.0/3", "2.0.0/2"])
      iex> Enum.map(major_versions, &to_string/1)
      ["1.1.3", "2.0.2"]

      iex> Config.config(:versions, [
      ...>   [a: ["1.0.0/2", "1.1.0/3"], b: ["2.0/5"]],
      ...>   [a: ["2.2.0/1", "2.3.0/4"], b: ["3.0/5"]]
      ...> ])
      iex> major_versions = Versions.latest_major(:a)
      iex> Enum.map(major_versions, &to_string/1)
      ["1.1.3", "2.3.4"]

      iex> Versions.latest_major(["foo"])
      ** (GitHubActions.InvalidVersionError) invalid version: "foo"

      iex> Versions.latest_major([a: "1"])
      ** (ArgumentError) latest_major/1 expected a list or table of versions or a key, got: [a: "1"]

      iex> major_versions = Versions.latest_major(:elixir)
      iex> Enum.map(major_versions, &to_string/1)
      ["1.14.0"]

      iex> major_versions = Versions.latest_major(:otp)
      iex> Enum.map(major_versions, &to_string/1)
      ["17.5", "18.3", "19.3", "20.3", "21.3", "22.3", "23.3", "24.3", "25.1"]
  """
  @spec latest_major(versions_list() | key()) :: [Version.t()]
  def latest_major(versions_or_key) when is_list(versions_or_key) do
    case Impl.type(versions_or_key) do
      {_type, versions} ->
        Impl.latest_major(versions)

      :error ->
        raise ArgumentError,
          message: """
          latest_major/1 expected a list or table of versions or a key, \
          got: #{inspect(versions_or_key)}\
          """
    end
  end

  def latest_major(key) when is_atom(key), do: latest_major(from_config(), key)

  @doc """
  Returns the latest major versions from a `versions` table by the given `key`.

  ## Examples

      iex> major_versions = Versions.latest_major([
      ...>   [a: ["1.0.0/2"], b: ["1.0.0/3"]],
      ...>   [a: ["2.0.0/3"], b: ["2.0.0/4"]]
      ...> ], :a)
      iex> Enum.map(major_versions, &to_string/1)
      ["1.0.2", "2.0.3"]

      iex> Versions.latest_major([a: "1"], :a)
      ** (ArgumentError) latest_major/1 expected a table of versions,  got: [a: "1"]
  """
  @spec latest_major(versions_table(), key()) :: [Version.t()]
  def latest_major(versions, key) when is_list(versions) and is_atom(key) do
    case Impl.type(versions) do
      {:table, versions} ->
        Impl.latest_major(versions, key)

      _error ->
        raise ArgumentError,
          message: "latest_major/1 expected a table of versions,  got: #{inspect(versions)}"
    end
  end

  @doc """
  Returns all versions for `key` from a list of compatible versions.

  This function raises a `GitHubActions.InvalidVersionError` for an invalid
  version.

  ## Examples

      iex> versions = [
      ...>   [a: ["1.0.0"], b: ["1.0", "1.1", "1.2"]],
      ...>   [a: ["2.0.0"], b: ["1.2", "2.0"]]
      ...> ]
      iex> versions = Versions.get(versions, :b)
      iex> hd versions
      #Version<1.0>
      iex> Enum.map(versions, &to_string/1)
      ["1.0", "1.1", "1.2", "2.0"]

      iex> Versions.get([a: "1"], :a)
      ** (ArgumentError) get/2 expected a table of versions, got: [a: "1"]
  """
  @spec get(versions_table(), key()) :: [Version.t()]
  def get(versions \\ from_config(), key) when is_list(versions) do
    case Impl.type(versions) do
      {:table, versions} ->
        Impl.get(versions, key)

      _error ->
        raise ArgumentError,
          message: "get/2 expected a table of versions, got: #{inspect(versions)}"
    end
  end

  @doc """
  Returns the versions from the config.
  """
  @spec from_config :: versions()
  def from_config, do: Config.get(:versions)

  @doc """
  Sorts the given `versions`.

  ## Examples

      iex> versions = ["1.1", "11.1", "1.0", "2.1", "2.0.1", "2.0.0"]
      iex> versions = Versions.sort(versions)
      iex> Enum.map(versions, &to_string/1)
      ["1.0", "1.1", "2.0.0", "2.0.1", "2.1", "11.1"]

      iex> Versions.sort([a: ["1", "2"]])
      ** (ArgumentError) sort/2 expected a list or table of versions, got: [a: ["1", "2"]]
  """
  @spec sort([Version.version()]) :: [Version.version()]
  def sort(versions) do
    case Impl.type(versions) do
      {:list, versions} ->
        Impl.sort_list(versions)

      {:table, versions} ->
        Impl.sort_table(versions)

      :error ->
        raise ArgumentError,
          message: "sort/2 expected a list or table of versions, got: #{inspect(versions)}"
    end
  end

  @doc """
  Removes all duplicated versions.

  ## Examples

      iex> versions = Versions.expand(["1.0.0/4", "1.0.2/5"])
      iex> versions |> Versions.uniq() |> Enum.map(&to_string/1)
      ["1.0.0", "1.0.1", "1.0.2", "1.0.3", "1.0.4", "1.0.5"]

      iex> Versions.uniq([:a])
      ** (ArgumentError) uniq/1 expected a list or table of versions, got: [:a]
  """
  @spec uniq(versions()) :: versions()
  def uniq(versions) do
    case Impl.type(versions) do
      {_type, versions} ->
        versions

      :error ->
        raise ArgumentError,
          message: "uniq/1 expected a list or table of versions, got: #{inspect(versions)}"
    end
  end

  @doc """
  Filters the list of `versions` by the given `requirement`.

  ## Examples

      iex> versions = ["1", "1.1.0/5", "1.2.0/1", "1.3", "2.0/1"]
      iex> Versions.filter(versions, "~> 1.2")
      [
        %Version{major: 1, minor: 2, patch: 0},
        %Version{major: 1, minor: 2, patch: 1},
        %Version{major: 1, minor: 3}
      ]
      iex> Versions.filter(versions, ">= 1.3.0")
      [
        %Version{major: 1, minor: 3},
        %Version{major: 2, minor: 0},
        %Version{major: 2, minor: 1}
      ]

      iex> Versions.filter([:b, :a], "> 1.0.0")
      ** (ArgumentError) filter/2 expected a list of versions, got: [:b, :a]

      iex> Versions.filter(["1", "2", "3"], "> 1")
      ** (Version.InvalidRequirementError) invalid requirement: "> 1"
  """
  @spec filter(versions_list(), String.t()) :: [Version.t()]
  def filter(versions, requirement) when is_binary(requirement) do
    case Impl.type(versions) do
      {:list, versions} ->
        Impl.filter(versions, requirement)

      _error ->
        raise ArgumentError,
          message: "filter/2 expected a list of versions, got: #{inspect(versions)}"
    end
  end

  @doc """
  Returns true if `versions` contains the given `version`.

  ## Examples

      iex> versions = ["1.0.0", "1.1.0", "1.1.1"]
      iex> Versions.member?(versions, "1.1")
      true
      iex> Versions.member?(versions, "1.0.1")
      false

      iex> Versions.member?([a: "1"], "1.0.0")
      ** (ArgumentError) member?/2 expected a list of versions, got: [a: "1"]
  """
  @spec member?(versions_list(), Version.version()) :: boolean
  def member?(versions, version) do
    case Impl.type(versions) do
      {:list, versions} ->
        Impl.member?(versions, version)

      _error ->
        raise ArgumentError,
          message: "member?/2 expected a list of versions, got: #{inspect(versions)}"
    end
  end

  @doc """
  Returns true if `versions1` has an intersection with `versions2`.

  ## Examples

      iex> Versions.intersection?(["1.0.0/5"], ["1.0.4/7"])
      true

      iex> Versions.intersection?(["1.0.0/5"], ["2.0.0/7"])
      false

      iex> Versions.intersection?(["1.0.0/5"], [:a])
      ** (ArgumentError) intersection?/2 expected two list of versions, got: ["1.0.0/5"], [:a]
  """
  @spec intersection?(versions_list(), versions_list()) :: boolean()
  def intersection?(versions1, versions2) do
    with {:list, versions1} <- Impl.type(versions1),
         {:list, versions2} <- Impl.type(versions2) do
      Impl.intersection?(versions1, versions2)
    else
      :error ->
        raise ArgumentError,
          message: """
          intersection?/2 expected two list of versions, \
          got: #{inspect(versions1)}, #{inspect(versions2)}\
          """
    end
  end

  @doc """
  Returns the versions of `key` that are compatible with `to`.

  ## Examples

      iex> otp = Versions.compatible(:otp, elixir: "1.6.6")
      iex> Enum.map(otp, &to_string/1)
      ["19.0", "19.1", "19.2", "19.3", "20.0", "20.1", "20.2", "20.3", "21.0",
       "21.1", "21.2", "21.3"]

      iex> elixir = Versions.compatible(:elixir, otp: "20.3")
      iex> Enum.map(elixir, &to_string/1)
      ["1.4.5", "1.5.0", "1.5.1", "1.5.2", "1.5.3", "1.6.0", "1.6.1", "1.6.2",
       "1.6.3", "1.6.4", "1.6.5", "1.6.6", "1.7.0", "1.7.1", "1.7.2", "1.7.3",
       "1.7.4", "1.8.0", "1.8.1", "1.8.2", "1.9.0", "1.9.1", "1.9.2", "1.9.3",
       "1.9.4"]

      iex> :otp |> Versions.compatible(elixir: "1.10.0") |> Enum.count()
      8

      iex> :otp |> Versions.compatible(elixir: "1.10.0/4") |> Enum.count()
      12

      iex> :otp |> Versions.compatible(elixir: ["1.10.0/4", "1.11.0/4"]) |> Enum.count()
      16

      iex> Versions.compatible([], :otp, elixir: "1.6.6")
      ** (ArgumentError) compatible/3 expected a table of versions as first argument, got: []
  """
  @spec compatible(versions(), key(), [{key(), Version.version()}]) :: [Version.t()]
  def compatible(versions \\ from_config(), key, [{to_key, to_versions}])
      when is_atom(key) and is_atom(to_key) do
    versions =
      case Impl.type(versions) do
        {:table, versions} ->
          versions

        _error ->
          raise ArgumentError,
            message: """
            compatible/3 expected a table of versions as first argument, \
            got: #{inspect(versions)}\
            """
      end

    to_versions =
      case to_versions |> List.wrap() |> Impl.type() do
        {:list, versions} ->
          versions

        _error ->
          raise ArgumentError,
            message: """
            compatible/3 expected a list of versions for #{inspect(to_key)}, \
            got: #{inspect(to_versions)}\
            """
      end

    Impl.compatible(versions, key, {to_key, to_versions})
  end

  @doc """
  Returns `true` if the given `version1` is compatible to `version2`.

  ## Examples

      iex> Versions.compatible?(elixir: "1.12.3", otp: "24.0")
      true

      iex> Versions.compatible?(elixir: "1.6.0", otp: "24.0")
      false

      iex> versions = [
      ...>   [a: ["1.0.0"], b: ["1.0", "1.1", "1.2"]],
      ...>   [a: ["2.0.0"], b: ["1.2", "2.0"]]
      ...> ]
      iex> Versions.compatible?(versions, a: "1", b: "1.2")
      true
      iex> Versions.compatible?(versions, a: "2", b: "1.2")
      true
      iex> Versions.compatible?(versions, a: "2", b: "1")
      false

      iex> Versions.compatible?([], a: "1", b: "2")
      ** (ArgumentError) compatible?/2 expected a table of versions as first argument, got: []
  """
  @spec compatible?(
          versions(),
          [{key(), Version.version()}]
        ) :: boolean()
  def compatible?(versions \\ from_config(), [{key1, version1}, {key2, version2}])
      when is_atom(key1) and is_atom(key2) do
    versions =
      case Impl.type(versions) do
        {:table, versions} ->
          versions

        _error ->
          raise ArgumentError,
            message: """
            compatible?/2 expected a table of versions as first argument, \
            got: #{inspect(versions)}\
            """
      end

    version1 = Version.parse!(version1)
    version2 = Version.parse!(version2)

    Impl.compatible?(versions, {key1, version1}, {key2, version2})
  end

  @doc """
  Returns the incompatible versions between `versions1` and `versions2`.

  ## Examples

      iex> versions = Versions.incompatible(
      ...>   elixir: ["1.9.4", "1.10.4", "1.11.4", "1.12.3"],
      ...>   otp: ["21.3", "22.3", "23.3", "24.0"]
      ...> )
      iex> for [{k1, v1}, {k2, v2}] <- versions do
      ...>   [{k1, to_string(v1)}, {k2, to_string(v2)}]
      ...> end
      [
        [elixir: "1.9.4", otp: "23.3"],
        [elixir: "1.9.4", otp: "24.0"],
        [elixir: "1.10.4", otp: "24.0"],
        [elixir: "1.12.3", otp: "21.3"]
      ]
  """
  @spec incompatible(keyword()) :: [keyword()]
  def incompatible(versions \\ from_config(), [{key1, versions1}, {key2, versions2}])
      when is_atom(key1) and is_atom(key2) do
    versions =
      case Impl.type(versions) do
        {:table, versions} ->
          versions

        _error ->
          raise ArgumentError,
            message: """
            incompatible/2 expected a table of versions as first argument, \
            got: #{inspect(versions)}\
            """
      end

    versions1 =
      case Impl.type(versions1) do
        {:list, versions} ->
          versions

        _error ->
          raise ArgumentError,
            message: """
            incompatible/2 expected a list of versions for #{inspect(key1)}, \
            got: #{inspect(versions1)}\
            """
      end

    versions2 =
      case Impl.type(versions2) do
        {:list, versions} ->
          versions

        _error ->
          raise ArgumentError,
            message: """
            incompatible/2 expected a list of versions for #{inspect(key2)}, \
            got: #{inspect(versions2)}\
            """
      end

    Impl.incompatible(versions, {key1, versions1}, {key2, versions2})
  end

  @doc """
  Returns the versions matrix for the given requirements.

  ## Examples

      iex> matrix = Versions.matrix(elixir: ">= 1.9.0", otp: ">= 22.0.0")
      iex> Enum.map(matrix[:elixir], &to_string/1)
      ["1.9.4", "1.10.4", "1.11.4", "1.12.3", "1.13.4", "1.14.0"]
      iex> Enum.map(matrix[:otp], &to_string/1)
      ["22.3", "23.3", "24.3", "25.1"]
      iex> for [{k1, v1}, {k2, v2}] <- matrix[:exclude] do
      ...>   [{k1, to_string(v1)}, {k2, to_string(v2)}]
      ...> end
      [
        [elixir: "1.9.4", otp: "23.3"],
        [elixir: "1.9.4", otp: "24.3"],
        [elixir: "1.9.4", otp: "25.1"],
        [elixir: "1.10.4", otp: "24.3"],
        [elixir: "1.10.4", otp: "25.1"],
        [elixir: "1.11.4", otp: "25.1"],
        [elixir: "1.12.3", otp: "25.1"],
        [elixir: "1.14.0", otp: "22.3"]
      ]

      iex> Versions.matrix([], elixir: ">= 1.9.0", otp: ">= 22.0.0")
      ** (ArgumentError) matrix/1 expected a table of versions as first argument, got: []

  """
  @spec matrix(keyword(), keyword()) :: keyword()
  def matrix(versions \\ from_config(), opts) do
    case Impl.type(versions) do
      {:table, versions} ->
        Impl.matrix(versions, opts)

      _error ->
        raise ArgumentError,
          message: """
          matrix/1 expected a table of versions as first argument, \
          got: #{inspect(versions)}\
          """
    end
  end

  @doc """
  Expands the given `versions`.

  ## Examples

      iex> versions = Versions.expand(["1.0/2"])
      iex> Enum.map(versions, &to_string/1)
      ["1.0", "1.1", "1.2"]

      iex> versions = Versions.expand([
      ...>  [a: ["1.0/2"], b: ["1.0.0/2"]],
      ...>  [a: ["1.1.0/1"], b: ["2.0.0/2"]]
      ...> ])
      iex> versions |> Enum.at(1) |> Keyword.get(:a) |> Enum.map(&to_string/1)
      ["1.1.0", "1.1.1"]
      iex> versions |> Enum.at(1) |> Keyword.get(:b) |> Enum.map(&to_string/1)
      ["2.0.0", "2.0.1", "2.0.2"]

      iex> Versions.expand([:a])
      ** (ArgumentError) expand/1 expected a list of versions, or table of versions got: [:a]
  """
  @spec expand(versions()) :: versions()
  def expand(versions) do
    case Impl.type(versions) do
      {_type, versions} ->
        versions

      :error ->
        raise ArgumentError,
          message: """
          expand/1 expected a list of versions, or table of versions \
          got: #{inspect(versions)}\
          """
    end
  end

  defmodule Impl do
    @moduledoc false

    def type(versions) when is_list(versions) do
      case {table?(versions), list?(versions)} do
        {true, false} ->
          {:table, expand(versions) |> uniq() |> sort_table()}

        {false, true} ->
          {:list, expand(versions) |> Enum.uniq() |> sort_list()}

        _else ->
          :error
      end
    end

    def type(_versions), do: :error

    defp table?(versions) do
      Enum.all?(versions, &Keyword.keyword?/1) &&
        versions |> Enum.map(&Keyword.keys/1) |> Enum.uniq() |> Enum.count() == 1
    end

    defp list?(versions) do
      Enum.all?(versions, fn version ->
        cond do
          is_binary(version) -> true
          is_struct(version, Version) -> true
          true -> false
        end
      end)
    end

    def sort_list(versions) do
      Enum.sort(versions, fn a, b -> Version.compare(a, b) == :lt end)
    end

    def sort_table(versions) do
      versions
      |> Enum.map(&sort_table_row/1)
      |> sort_table_rows()
    end

    defp sort_table_row(row) do
      Enum.map(row, fn {key, list} -> {key, sort_list(list)} end)
    end

    defp sort_table_rows([]), do: []

    defp sort_table_rows([[]]), do: [[]]

    defp sort_table_rows([[{key, _version} | _versions] | _rows] = rows) do
      Enum.sort(rows, fn a, b ->
        case {Enum.at(a[key], 0), Enum.at(b[key], 0)} do
          {nil, nil} -> false
          {_, nil} -> false
          {nil, _} -> true
          {x, y} -> Version.compare(x, y) == :lt
        end
      end)
    end

    defp uniq(versions) do
      Enum.map(versions, fn row -> Enum.uniq(row) end)
    end

    defp expand(versions) do
      Enum.flat_map(versions, fn version -> do_expand(version) end)
    end

    defp do_expand(version) when is_binary(version) or is_struct(version) do
      version |> Version.parse!() |> List.wrap()
    end

    defp do_expand(versions) when is_list(versions) do
      [Enum.map(versions, fn {key, versions} -> {key, expand(versions)} end)]
    end

    def latest(versions), do: List.last(versions)

    def latest(versions, key), do: versions |> get(key) |> List.last()

    def latest_minor(versions), do: do_latest(versions, :minor)

    def latest_minor(versions, key), do: versions |> get(key) |> do_latest(:minor)

    def latest_major(versions), do: do_latest(versions, :major)

    def latest_major(versions, key), do: versions |> get(key) |> do_latest(:major)

    defp do_latest(versions, precision) do
      versions
      |> Enum.reduce([], fn
        version, [] ->
          [Version.parse!(version)]

        version, [current | rest] = acc ->
          case Version.compare(version, current, precision) do
            :eq -> [Version.parse!(version) | rest]
            :gt -> [Version.parse!(version) | acc]
          end
      end)
      |> Enum.reverse()
    end

    def member?(versions, version) do
      Enum.any?(versions, fn item -> Version.compare(item, version) == :eq end)
    end

    def intersection?(versions1, versions2) do
      Enum.any?(versions1, fn version -> member?(versions2, version) end)
    end

    def filter(versions, requirement) do
      Enum.filter(versions, fn version -> Version.match?(version, requirement) end)
    end

    def compatible(versions, key, {to_key, to_versions}) do
      Enum.flat_map(versions, fn row ->
        case row |> Keyword.get(to_key, []) |> intersection?(to_versions) do
          true -> Keyword.get(row, key, [])
          false -> []
        end
      end)
    end

    def compatible?(versions, {key1, version1}, {key2, version2}) do
      versions
      |> compatible(key2, {key1, List.wrap(version1)})
      |> member?(version2)
    end

    def incompatible(versions, {key1, versions1}, {key2, versions2}) do
      for version1 <- versions1,
          version2 <- versions2,
          compatible?(versions, {key1, version1}, {key2, version2}) == false do
        [{key1, version1}, {key2, version2}]
      end
    end

    def get(versions, key) do
      versions
      |> Enum.flat_map(fn lists -> Keyword.get(lists, key, []) end)
      |> Enum.uniq()
      |> sort_list()
    end

    def matrix(versions, opts) do
      elixir =
        versions
        |> get(:elixir)
        |> filter(Keyword.fetch!(opts, :elixir))
        |> latest_minor()

      otp =
        versions
        |> compatible(:otp, {:elixir, elixir})
        |> filter(Keyword.fetch!(opts, :otp))
        |> latest_major()

      case incompatible(versions, {:elixir, elixir}, {:otp, otp}) do
        [] -> [elixir: elixir, otp: otp]
        exclude -> [elixir: elixir, otp: otp, exclude: exclude]
      end
    end
  end
end