name: deploy gateway

on:
  workflow_dispatch:
  push:
    branches:
      - canary
    paths:
      - 'fluxer_gateway/**'

concurrency:
  group: deploy-gateway
  cancel-in-progress: true

permissions:
  contents: read

jobs:
  deploy:
    name: Deploy (hot patch)
    runs-on: blacksmith-2vcpu-ubuntu-2404
    timeout-minutes: 10

    steps:
      - uses: actions/checkout@v6
        with:
          sparse-checkout: fluxer_gateway

      - name: Set up Erlang
        uses: erlef/setup-beam@v1
        with:
          otp-version: '28'
          rebar3-version: '3.24.0'

      - name: Compile
        working-directory: fluxer_gateway
        run: |
          set -euo pipefail
          rebar3 as prod compile

      - name: Set up SSH
        uses: webfactory/ssh-agent@v0.9.1
        with:
          ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY_SERVER }}

      - name: Add server to known hosts
        run: |
          set -euo pipefail
          mkdir -p ~/.ssh
          ssh-keyscan -H ${{ secrets.SERVER_IP }} >> ~/.ssh/known_hosts

      - name: Deploy
        env:
          SERVER: ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_IP }}
          GATEWAY_ADMIN_SECRET: ${{ secrets.GATEWAY_ADMIN_SECRET }}
        run: |
          set -euo pipefail

          CONTAINER_ID="$(ssh "${SERVER}" "docker ps -q --filter label=com.docker.swarm.service.name=fluxer-gateway_app | head -1")"
          if [ -z "${CONTAINER_ID}" ]; then
            echo "::error::No running container found for service fluxer-gateway_app"
            ssh "${SERVER}" "docker ps --filter 'name=fluxer-gateway_app' --format '{{.ID}} {{.Names}} {{.Status}}'" || true
            exit 1
          fi
          echo "Container: ${CONTAINER_ID}"

          LOCAL_MD5_LINES="$(
            erl -noshell -eval '
              Files = filelib:wildcard("fluxer_gateway/_build/prod/lib/fluxer_gateway/ebin/*.beam"),
              lists:foreach(
                fun(F) ->
                  {ok, {M, Md5}} = beam_lib:md5(F),
                  Hex = binary:encode_hex(Md5, lowercase),
                  io:format("~s ~s ~s~n", [atom_to_list(M), binary_to_list(Hex), F])
                end,
                Files
              ),
              halt().'
          )"

          REMOTE_MD5_LINES="$(
            ssh "${SERVER}" "docker exec ${CONTAINER_ID} /opt/fluxer_gateway/bin/fluxer_gateway eval '
              Mods = hot_reload:get_loaded_modules(),
              lists:foreach(
                fun(M) ->
                  case hot_reload:get_module_info(M) of
                    {ok, Info} ->
                      V = maps:get(loaded_md5, Info),
                      S = case V of
                            null -> \"null\";
                            B when is_binary(B) -> binary_to_list(B)
                          end,
                      io:format(\"~s ~s~n\", [atom_to_list(M), S]);
                    _ ->
                      ok
                  end
                end,
                Mods
              ),
              ok.
            ' " | tr -d '\r'
          )"

          LOCAL_MD5_FILE="$(mktemp)"
          REMOTE_MD5_FILE="$(mktemp)"
          CHANGED_FILE_LIST="$(mktemp)"
          CHANGED_MAIN_LIST="$(mktemp)"
          CHANGED_SELF_LIST="$(mktemp)"
          RELOAD_RESULT_MAIN="$(mktemp)"
          RELOAD_RESULT_SELF="$(mktemp)"
          trap 'rm -f "${LOCAL_MD5_FILE}" "${REMOTE_MD5_FILE}" "${CHANGED_FILE_LIST}" "${CHANGED_MAIN_LIST}" "${CHANGED_SELF_LIST}" "${RELOAD_RESULT_MAIN}" "${RELOAD_RESULT_SELF}"' EXIT

          printf '%s' "${LOCAL_MD5_LINES}" > "${LOCAL_MD5_FILE}"
          printf '%s' "${REMOTE_MD5_LINES}" > "${REMOTE_MD5_FILE}"

          python3 - <<'PY' "${LOCAL_MD5_FILE}" "${REMOTE_MD5_FILE}" "${CHANGED_FILE_LIST}"
          import sys

          local_path, remote_path, out_path = sys.argv[1:4]

          remote = {}
          with open(remote_path, "r", encoding="utf-8") as f:
              for line in f:
                  line = line.strip()
                  if not line:
                      continue
                  parts = line.split(None, 1)
                  if len(parts) != 2:
                      continue
                  mod, md5 = parts
                  remote[mod] = md5.strip()

          changed_paths = []
          with open(local_path, "r", encoding="utf-8") as f:
              for line in f:
                  line = line.strip()
                  if not line:
                      continue
                  parts = line.split(" ", 2)
                  if len(parts) != 3:
                      continue
                  mod, md5, path = parts
                  r = remote.get(mod)
                  if r is None or r == "null" or r != md5:
                      changed_paths.append(path)

          with open(out_path, "w", encoding="utf-8") as f:
              for p in changed_paths:
                  f.write(p + "\n")
          PY

          mapfile -t CHANGED_FILES < "${CHANGED_FILE_LIST}"

          if [ "${#CHANGED_FILES[@]}" -eq 0 ]; then
            echo "No BEAM changes detected, nothing to hot-reload."
            exit 0
          fi

          echo "Changed modules count: ${#CHANGED_FILES[@]}"

          while IFS= read -r p; do
            [ -n "${p}" ] || continue
            m="$(basename "${p}")"
            m="${m%.beam}"
            if [ "${m}" = "hot_reload" ] || [ "${m}" = "hot_reload_handler" ]; then
              printf '%s\n' "${p}" >> "${CHANGED_SELF_LIST}"
            else
              printf '%s\n' "${p}" >> "${CHANGED_MAIN_LIST}"
            fi
          done < "${CHANGED_FILE_LIST}"

          build_json() {
            python3 - "$1" <<'PY'
          import sys, json, base64, os
          list_path = sys.argv[1]
          beams = []
          with open(list_path, "r", encoding="utf-8") as f:
              for path in f:
                  path = path.strip()
                  if not path:
                      continue
                  mod = os.path.basename(path)
                  if not mod.endswith(".beam"):
                      continue
                  mod = mod[:-5]
                  with open(path, "rb") as bf:
                      b = bf.read()
                  beams.append({"module": mod, "beam_b64": base64.b64encode(b).decode("ascii")})
          print(json.dumps({"beams": beams, "purge": "soft"}, separators=(",", ":")))
          PY
          }

          strict_verify() {
            python3 -c '
          import json, sys
          raw = sys.stdin.read()
          if not raw.strip():
              print("::error::Empty reload response")
              raise SystemExit(1)
          try:
              data = json.loads(raw)
          except Exception as e:
              print(f"::error::Invalid JSON reload response: {e}")
              raise SystemExit(1)
          results = data.get("results", [])
          if not isinstance(results, list):
              print("::error::Reload response missing results array")
              raise SystemExit(1)
          bad = [
              r for r in results
              if r.get("status") != "ok"
              or r.get("verified") is not True
              or r.get("purged_old_code") is not True
              or (r.get("lingering_count") or 0) != 0
          ]
          if bad:
              print("::error::Hot reload verification failed")
              print(json.dumps(bad, indent=2))
              raise SystemExit(1)
          print(f"Verified {len(results)} modules")
          '
          }

          self_verify() {
            python3 -c '
          import json, sys
          raw = sys.stdin.read()
          if not raw.strip():
              print("::error::Empty reload response")
              raise SystemExit(1)
          try:
              data = json.loads(raw)
          except Exception as e:
              print(f"::error::Invalid JSON reload response: {e}")
              raise SystemExit(1)
          results = data.get("results", [])
          if not isinstance(results, list):
              print("::error::Reload response missing results array")
              raise SystemExit(1)
          bad = [
              r for r in results
              if r.get("status") != "ok"
              or r.get("verified") is not True
          ]
          if bad:
              print("::error::Hot reload verification failed")
              print(json.dumps(bad, indent=2))
              raise SystemExit(1)
          warns = [
              r for r in results
              if r.get("purged_old_code") is not True
              or (r.get("lingering_count") or 0) != 0
          ]
          if warns:
              print("::warning::Self-reload modules may linger until request completes")
              print(json.dumps(warns, indent=2))
          print(f"Verified {len(results)} self modules")
          '
          }

          if [ -s "${CHANGED_MAIN_LIST}" ]; then
            if ! build_json "${CHANGED_MAIN_LIST}" | ssh "${SERVER}" "docker exec -i ${CONTAINER_ID} curl -fsS -X POST -H 'Authorization: Bearer ${GATEWAY_ADMIN_SECRET}' -H 'Content-Type: application/json' --data @- http://localhost:8081/_admin/reload" | tee "${RELOAD_RESULT_MAIN}" | strict_verify; then
              echo "::group::Hot reload response (main)"
              cat "${RELOAD_RESULT_MAIN}" || true
              echo "::endgroup::"
              exit 1
            fi
          fi

          if [ -s "${CHANGED_SELF_LIST}" ]; then
            if ! build_json "${CHANGED_SELF_LIST}" | ssh "${SERVER}" "docker exec -i ${CONTAINER_ID} curl -fsS -X POST -H 'Authorization: Bearer ${GATEWAY_ADMIN_SECRET}' -H 'Content-Type: application/json' --data @- http://localhost:8081/_admin/reload" | tee "${RELOAD_RESULT_SELF}" | self_verify; then
              echo "::group::Hot reload response (self)"
              cat "${RELOAD_RESULT_SELF}" || true
              echo "::endgroup::"
              exit 1
            fi
          fi
