mix.exs

defmodule Zvex.MixProject do
  use Mix.Project

  # x-release-please-version
  @zvec_version "0.4.0"
  @sentinel ".zvex_precompiled"
  @manifest_vsn 1

  def project do
    [
      app: :zvex,
      zvec_version: @zvec_version,
      description: description(),
      package: package(),
      version: "0.4.1",
      elixir: "~> 1.19",
      start_permanent: Mix.env() == :prod,
      elixirc_paths: elixirc_paths(Mix.env()),
      compilers: [:zvex_precompiled, :elixir_make] ++ Mix.compilers(),
      make_targets: ["all"],
      make_clean: ["clean"],
      make_env: %{"ZVEX_VERSION" => @zvec_version},
      docs: docs(),
      dialyzer: [
        plt_core_path: "_plts/core"
      ],
      deps: deps(),
      aliases: aliases()
    ]
  end

  def application do
    [
      extra_applications: [:logger],
      mod: {Zvex.Application, []}
    ]
  end

  defp aliases do
    [
      "compile.zvex_precompiled": &precompiled/1,
      "bench.vector": ["run bench/vector_bench.exs"],
      "bench.document": ["run bench/document_bench.exs"],
      "bench.collection": ["run bench/collection_bench.exs"],
      "bench.query": ["run bench/query_bench.exs"],
      "bench.all": [
        "bench.vector",
        "bench.document",
        "bench.collection",
        "bench.query"
      ]
    ]
  end

  defp docs do
    benchmark_extras =
      "bench/output/*.md"
      |> Path.wildcard()
      |> Enum.sort()
      |> Enum.map(fn path ->
        name =
          path
          |> Path.basename(".md")
          |> String.replace("_", " ")
          |> String.split()
          |> Enum.map_join(" ", &String.capitalize/1)

        {path, title: name}
      end)

    [
      main: "readme",
      source_url: "https://github.com/edlontech/zvex",
      extras:
        [
          {"README.md", title: "Overview"},
          {"LICENSE", title: "License"}
        ] ++ benchmark_extras,
      groups_for_extras: [
        Benchmarks: ~r/bench\/output\/.+/,
        About: [
          "LICENSE"
        ]
      ],
      groups_for_modules: [
        "Core API": [
          Zvex,
          Zvex.Collection,
          Zvex.Collection.Schema,
          Zvex.Collection.Schema.IndexParams,
          Zvex.Collection.Stats
        ],
        Documents: [
          Zvex.Document,
          Zvex.Vector
        ],
        Search: [
          Zvex.Query,
          Zvex.Query.Result
        ],
        Configuration: [
          Zvex.Application,
          Zvex.Config
        ],
        Types: [
          Zvex.Types
        ],
        Errors: [
          Zvex.Error,
          ~r/Zvex\.Error\./
        ],
        Internal: [
          Zvex.Native
        ]
      ],
      nest_modules_by_prefix: [
        Zvex.Error,
        Zvex.Collection,
        Zvex.Query
      ]
    ]
  end

  defp elixirc_paths(:test), do: ["lib", "test/support"]
  defp elixirc_paths(_), do: ["lib"]

  defp deps do
    [
      {:bandit, "~> 1.8", only: :dev, runtime: false},
      {:benchee, "~> 1.0", only: :dev},
      {:castore, "~> 1.0"},
      {:benchee_markdown, "~> 0.3", only: :dev},
      {:benchee_json, "~> 1.0", only: :dev},
      {:credo, "~> 1.7", only: [:dev, :test], runtime: false},
      {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false},
      {:doctor, "~> 0.22", only: :dev},
      {:elixir_make, "~> 0.9", runtime: false},
      {:ex_check, "~> 0.16", only: [:dev, :test], runtime: false},
      {:excoveralls, "~> 0.18", only: [:dev, :test]},
      {:ex_doc, "~> 0.34", only: :dev, runtime: false},
      {:assert_eventually, "~> 1.0", only: :test},
      {:mimic, "~> 2.0", only: :test},
      {:mix_audit, ">= 0.0.0", only: [:dev, :test], runtime: false},
      {:recode, "~> 0.8", only: [:dev], runtime: false},
      {:splode, "~> 0.3"},
      {:telemetry, "~> 1.3"},
      {:tidewave, "~> 0.5", only: :dev, runtime: false},
      {:zigler, "~> 0.15.2", runtime: false},
      {:zoi, "~> 0.11"}
    ]
  end

  def cli do
    [
      preferred_envs: [
        coveralls: :test,
        "coveralls.detail": :test,
        "coveralls.post": :test,
        "coveralls.html": :test,
        "coveralls.cobertura": :test
      ]
    ]
  end

  defp description() do
    "An Elixir Library wrapping the ZVEC Vector Database Engine"
  end

  defp package() do
    [
      licenses: ["MIT"],
      links: %{"GitHub" => "https://github.com/edlontech/zvex"},
      files: ~w(lib mix.exs Makefile README.md CHANGELOG.md LICENSE .formatter.exs),
      exclude_patterns: [~r/\.Elixir\..*\.Native\.zig$/]
    ]
  end

  defp precompiled(_args) do
    cond do
      System.get_env("ZVEX_BUILD") == "true" ->
        Mix.shell().info("[zvex] ZVEX_BUILD=true — skipping precompiled download")
        {:noop, []}

      true ->
        case detect_target() do
          {:ok, target} ->
            case fetch_precompiled(target) do
              :ok ->
                {:ok, []}

              {:error, reason} ->
                Mix.shell().info(
                  "[zvex] precompiled binary unavailable (#{reason}) — falling back to source build"
                )

                {:noop, []}
            end

          :unsupported ->
            Mix.shell().info(
              "[zvex] no precompiled binary for this target — building from source " <>
                "(requires cmake, a working C/C++ toolchain, and git to fetch zvec)"
            )

            {:noop, []}
        end
    end
  end

  defp fetch_precompiled(target) do
    version = @zvec_version
    cache_dir = Path.join([cache_root(), version, target])
    tarball = Path.join(cache_dir, "zvec-v#{version}-#{target}.tar.gz")
    sha_file = tarball <> ".sha256"

    File.mkdir_p!(cache_dir)

    if sentinel_valid?(version, target) do
      :ok
    else
      with :ok <- ensure_downloaded(version, target, tarball, sha_file),
           :ok <- verify_sha256(tarball, sha_file),
           :ok <- replace_priv(tarball) do
        write_sentinel(version, target)
        :ok
      end
    end
  end

  defp ensure_downloaded(version, target, tarball, sha_file) do
    if File.exists?(tarball) and File.exists?(sha_file) do
      :ok
    else
      with :ok <- download(url(version, target), tarball),
           :ok <- download(url(version, target) <> ".sha256", sha_file) do
        :ok
      end
    end
  end

  defp replace_priv(tarball) do
    wipe_priv()
    extract(tarball, priv_dir())
  end

  defp cache_root do
    :user_cache |> :filename.basedir(~c"zvex") |> to_string()
  end

  defp priv_dir do
    Path.join(Mix.Project.app_path(), "priv")
  end

  defp url(version, target) do
    prefix =
      System.get_env("ZVEX_BUILD_URL") ||
        "https://github.com/edlontech/zvex/releases/download"

    "#{String.trim_trailing(prefix, "/")}/zvec-v#{version}/zvec-v#{version}-#{target}.tar.gz"
  end

  defp detect_target do
    :system_architecture
    |> :erlang.system_info()
    |> to_string()
    |> normalize_architecture()
  end

  defp normalize_architecture("x86_64-" <> rest), do: classify(rest, "x86_64")
  defp normalize_architecture("aarch64-" <> rest), do: classify(rest, "aarch64")
  defp normalize_architecture("arm64-" <> rest), do: classify(rest, "aarch64")
  defp normalize_architecture(_other), do: :unsupported

  defp classify(rest, arch) do
    cond do
      String.contains?(rest, "linux-musl") ->
        :unsupported

      String.contains?(rest, "linux-gnu") or String.contains?(rest, "linux") ->
        {:ok, "linux-#{arch}-gnu"}

      String.contains?(rest, "apple-darwin") and arch == "aarch64" ->
        {:ok, "darwin-aarch64"}

      true ->
        :unsupported
    end
  end

  defp download(url, dest) do
    Mix.shell().info("[zvex] downloading #{url}")
    {:ok, _} = Application.ensure_all_started(:inets)
    {:ok, _} = Application.ensure_all_started(:ssl)

    request = {String.to_charlist(url), []}

    http_opts = [
      ssl: [
        verify: :verify_peer,
        cacertfile: String.to_charlist(CAStore.file_path()),
        depth: 4,
        customize_hostname_check: [
          match_fun: :public_key.pkix_verify_hostname_match_fun(:https)
        ]
      ],
      autoredirect: true
    ]

    opts = [stream: String.to_charlist(dest)]

    case :httpc.request(:get, request, http_opts, opts) do
      {:ok, :saved_to_file} ->
        :ok

      {:ok, {{_, status, _}, _, _}} ->
        File.rm(dest)
        {:error, "HTTP #{status} for #{url}"}

      {:error, reason} ->
        File.rm(dest)
        {:error, "transport error for #{url}: #{inspect(reason)}"}
    end
  end

  defp verify_sha256(tarball, sha_file) do
    expected =
      sha_file
      |> File.read!()
      |> String.split(~r/\s+/, trim: true)
      |> List.first()
      |> String.downcase()

    actual =
      tarball
      |> File.read!()
      |> then(&:crypto.hash(:sha256, &1))
      |> Base.encode16(case: :lower)

    if actual == expected do
      :ok
    else
      File.rm(tarball)
      File.rm(sha_file)
      {:error, "checksum mismatch for #{Path.basename(tarball)} (cache wiped)"}
    end
  end

  defp wipe_priv do
    priv = priv_dir()
    if File.exists?(priv), do: File.rm_rf!(priv)
    File.mkdir_p!(priv)
  end

  defp extract(tarball, dest) do
    tarball_charlist = String.to_charlist(tarball)

    with {:ok, entries} <- :erl_tar.table(tarball_charlist, [:compressed]),
         :ok <- validate_entries(entries) do
      case :erl_tar.extract(tarball_charlist, [:compressed, {:cwd, String.to_charlist(dest)}]) do
        :ok ->
          :ok

        {:error, reason} ->
          File.rm(tarball)
          {:error, "failed to extract #{Path.basename(tarball)}: #{inspect(reason)}"}
      end
    else
      {:unsafe, name} ->
        File.rm(tarball)

        {:error,
         "tarball #{Path.basename(tarball)} contains unsafe entry path #{inspect(name)} (cache wiped)"}

      {:error, reason} ->
        File.rm(tarball)
        {:error, "failed to read #{Path.basename(tarball)}: #{inspect(reason)}"}
    end
  end

  defp validate_entries(entries) do
    Enum.reduce_while(entries, :ok, fn entry, :ok ->
      name = to_string(entry)

      cond do
        String.starts_with?(name, "/") -> {:halt, {:unsafe, name}}
        name == ".." or String.contains?(name, "../") -> {:halt, {:unsafe, name}}
        true -> {:cont, :ok}
      end
    end)
  end

  defp write_sentinel(version, target) do
    File.write!(
      Path.join(priv_dir(), @sentinel),
      "#{@manifest_vsn}\n#{version}\n#{target}\n"
    )
  end

  defp sentinel_valid?(version, target) do
    path = Path.join(priv_dir(), @sentinel)

    with true <- File.exists?(path),
         {:ok, contents} <- File.read(path),
         [vsn, stored_version, stored_target] <- String.split(contents, "\n", trim: true),
         true <- vsn == Integer.to_string(@manifest_vsn),
         true <- stored_version == version,
         true <- stored_target == target,
         true <- File.exists?(Path.join([priv_dir(), "lib", shared_lib()])) do
      true
    else
      _ -> false
    end
  end

  defp shared_lib do
    case :os.type() do
      {:unix, :darwin} -> "libzvec_c_api.dylib"
      {:win32, _} -> "zvec_c_api.dll"
      _ -> "libzvec_c_api.so"
    end
  end
end