#!/usr/bin/env bash
# Pre-commit gates. Each runs only when the files it guards are staged, so a
# commit that touches neither pays nothing.
#
#   1. mix.lock consistency, when mix.exs/mix.lock is staged.
#   2. Markdown prose rules, when any .md is staged.
#
# Installed via `git config core.hooksPath .githooks` (run by `mix setup`).
set -euo pipefail

# --- toolchain -----------------------------------------------------------
# `MIX_HOME` on a mise setup points at that toolchain's `.mix`, while a
# Homebrew `mix` may come first on PATH. A Hex archive built for one Elixir and
# loaded by another fails deep inside dep resolution
# (`function Enum.__in__/2 is undefined`), which reads like a lockfile bug and
# is not one. So prefer the toolchain `mise.toml` names, when it is installed;
# otherwise leave PATH alone and let the gates report normally.
#
# Set RAXOL_HOOK_NO_TOOLCHAIN=1 to skip this and use PATH as-is, which is what
# you want when deliberately testing against a different Elixir.
#
# The version is VALIDATED before it is interpolated, because `mise.toml` is a
# repo-controlled file and the result of this goes on PATH. A branch carrying
# `elixir = "../../../../../../../tmp/evil"` otherwise resolves to
# `/tmp/evil/bin`, prepends it, and every `mix` and `elixir` below runs from
# there. A version is a digit followed by version punctuation; anything else is
# not one, and is skipped rather than resolved. The pattern admits no `/`, and
# cannot match `..`.
#
# What that is NOT is a general defence against a hostile branch. This hook runs
# `scripts/prose_lint.exs`, which `Code.require_file`s `lib/raxol/docs/
# prose_lint.ex`; both are repo-controlled, so a branch you do not trust already
# chooses code that runs on `git commit`, and `mix deps.get` below evaluates
# `mix.exs`. That is true of essentially any repo with hooks enabled and is not
# something a version regex changes. The regex closes one specific hole -- a
# value this hook itself puts on PATH -- and the honest scope is "the hook does
# not ADD a vector", not "committing on an untrusted branch is safe". Review the
# branch, or commit with --no-verify.
if [[ -n "${RAXOL_HOOK_NO_TOOLCHAIN:-}" ]]; then
  :
elif [[ -f mise.toml ]]; then
  _mise_installs="${MISE_DATA_DIR:-$HOME/.local/share/mise}/installs"

  # Reads `<tool> = "<version>"` out of the `[tools]` table only, taking the
  # first quoted value on the line so a trailing comment is not part of the
  # version. Deliberately not a full TOML parser: this picks one string out of
  # a file the repo controls, and the regex below is what decides whether that
  # string is allowed to reach PATH.
  for _tool in elixir erlang; do
    _ver="$(awk -v tool="$_tool" '
      /^[[:space:]]*\[/ { in_tools = ($0 ~ /^[[:space:]]*\[tools\][[:space:]]*$/); next }
      in_tools && $1 == tool && match($0, /"[^"]*"/) {
        print substr($0, RSTART + 1, RLENGTH - 2)
        exit
      }
    ' mise.toml || true)"

    if [[ "$_ver" =~ ^[0-9][0-9A-Za-z._+-]*$ ]]; then
      _cand="$_mise_installs/$_tool/$_ver/bin"
      if [[ -d "$_cand" ]]; then
        PATH="$_cand:$PATH"
      fi
    fi
  done

  export PATH
  unset _mise_installs _tool _ver _cand
fi

staged="$(git diff --cached --name-only --diff-filter=ACM)"
status=0

# --- 1. lockfile ---------------------------------------------------------
# Catches the failure mode where a dep is added or a transitive bump drifts the
# root mix.lock but it is not committed, which only surfaces in CI's
# `mix deps.get --check-locked` step after the push to master.
if printf '%s\n' "$staged" | grep -qE '(^|/)mix\.(exs|lock)$'; then
  echo "pre-commit: verifying mix.lock is consistent..."
  if ! command -v mix >/dev/null 2>&1; then
    echo "pre-commit: no \`mix\` on PATH, cannot verify mix.lock." >&2
    echo "  Install the toolchain \`mise.toml\` names, or bypass knowingly." >&2
    status=1
  elif ! output="$(mix deps.get --check-locked 2>&1)"; then
    echo "$output" >&2
    cat >&2 <<'MSG'

pre-commit: mix.lock is out of date.

  Run `mix deps.get` and stage the updated mix.lock, then commit again:

    mix deps.get && git add mix.lock

MSG
    status=1
  fi
fi

# --- 2. markdown prose ---------------------------------------------------
# Unicode punctuation, ` -- ` as an em-dash substitute, and broken relative
# links. The rules kept regressing while they lived only in a style note, so
# they are enforced here and in CI. Heading case is a warning-only sweep
# (`mix raxol.check_docs --headings`) and is deliberately not gated.
md_files="$(printf '%s\n' "$staged" | grep -E '\.md$' | grep -v 'node_modules/' || true)"

if [[ -n "$md_files" ]]; then
  echo "pre-commit: linting staged Markdown..."
  args=()
  while IFS= read -r f; do
    [[ -n "$f" ]] && args+=("$f")
  done <<<"$md_files"

  # Deliberately `elixir`, not `mix`. The rules are the same module either way,
  # but this route loads no project, so a docs commit cannot be blocked by deps
  # that do not match the branch's lock, a `_build` from another Elixir, or a
  # root lock that no longer re-resolves. None of those are facts about prose.
  # CI still runs `mix raxol.check_docs`, which additionally checks the catalog.
  #
  # Guarded the way the lockfile gate guards `mix`: without this, a missing
  # `elixir` exits 127 and the message below blames the prose, which is the one
  # diagnosis this gate exists to get right.
  if ! command -v elixir >/dev/null 2>&1; then
    echo "pre-commit: no \`elixir\` on PATH, cannot lint prose." >&2
    echo "  Install the toolchain \`mise.toml\` names, or bypass knowingly." >&2
    status=1
  else
    prose_status=0
    elixir scripts/prose_lint.exs "${args[@]}" || prose_status=$?

    if [[ "$prose_status" -eq 1 ]]; then
      cat >&2 <<'MSG'

pre-commit: Markdown prose check failed.

  Fix the findings above, or see `mix help raxol.check_docs` for the rules.

MSG
      status=1
    elif [[ "$prose_status" -ne 0 ]]; then
      echo "pre-commit: Markdown prose check could not run (exit $prose_status)." >&2
      status=1
    fi
  fi
fi

if [[ "$status" -ne 0 ]]; then
  echo "(Bypass with \`git commit --no-verify\` only if you know why.)" >&2
fi

exit "$status"
