#!/bin/bash
#
# fetch-graylog.sh
# Query a Graylog instance for the log messages tied to a transaction or
# conversation id and emit a normalized JSON view the pipeline can attach as
# analysis context. A log fetch must never block a run: on any network failure
# (VPN down, host unreachable) the fetcher degrades to an empty result and
# exits 0.
#
# Input forms (at least one id is required):
#   ./fetch-graylog.sh --trx <transaction-id>
#   ./fetch-graylog.sh --conv <conversation-id>
#   ./fetch-graylog.sh --trx <id> --conv <id>
#   ./fetch-graylog.sh --trx <id> --env test      # force one environment
#
# Environments. Test and production are separate Graylog instances, and a trx id
# minted by a tester simply does not exist in production - searching only prod
# returns "no logs" for a complaint that is fully logged one host over. The
# default `--env auto` searches production first and falls back to test when
# production returns nothing or is unreachable; the answer always names the
# environment that produced it, so a result is never silently from elsewhere.
# `--env prod` / `--env test` pin a single instance.
#
# Required configuration:
#   prefs.global.hosts.graylog                 -  production host (e.g. logs.example.com)
#   prefs.global.keychainMapping.graylog       -  keychain key holding the prod API token
# Optional (test environment):
#   prefs.global.hosts.graylogTest             -  test host; without it, test is skipped
#   prefs.global.keychainMapping.graylog_test  -  test token key; falls back to the prod
#                                                 key when unset (shared-token deployments)
#
# Optional env:
#   GRAYLOG_TIMEOUT_SECONDS    default 25
#   GRAYLOG_ENV                auto|prod|test, same as --env (default auto)
#   GRAYLOG_HOST_OVERRIDE      forces one host, reported as environment "override"
#                              (used by tests and CI dry-runs)
#   GRAYLOG_RANGE_SECONDS      relative search window, default 86400 (24h)
#   GRAYLOG_LIMIT              max messages to return, default 200
#
# Auth: Graylog personal access tokens authenticate as HTTP Basic with the
# token as the username and the literal string "token" as the password. The
# credential travels through a curl config via process substitution so it
# never appears in argv (argv is visible to ps).
#
# Output (stdout, single JSON object):
#   {
#     "fetchedAt": "<ISO8601>",
#     "source": { "host": "<host>", "environment": "prod|test|override",
#                 "searchedEnvironments": [ "prod", ... ],
#                 "transactionId": "..."|null,
#                 "conversationId": "..."|null, "query": "..." },
#     "totalResults": <n>,
#     "messages": [
#       { "timestamp": "...", "source": "...", "level": <n>|null,
#         "message": "...", "fields": { ... } }
#     ],
#     "degraded": <bool>, "degradeReason": "..."|null
#   }
#
# Exit codes:
#   0  success, or degraded-empty on a network failure (never blocks a run)
#   2  missing/expired credential (orchestrator handles the Save Flow)
#   3  auth rejected on a reachable host (4xx)
#   4  bad usage
#   6  host not configured (the requested environment has no host in prefs)

set -euo pipefail

TRX_ID=""
CONV_ID=""
ENVIRONMENT="${GRAYLOG_ENV:-auto}"
TIMEOUT="${GRAYLOG_TIMEOUT_SECONDS:-25}"
RANGE="${GRAYLOG_RANGE_SECONDS:-86400}"
LIMIT="${GRAYLOG_LIMIT:-200}"

while [ $# -gt 0 ]; do
  case "$1" in
    --trx)  TRX_ID="$2";  shift 2 ;;
    --conv) CONV_ID="$2"; shift 2 ;;
    --env)  ENVIRONMENT="$2"; shift 2 ;;
    -h|--help)
      echo "usage: $0 --trx <transaction-id> | --conv <conversation-id> [--env auto|prod|test]" >&2
      exit 4 ;;
    *)
      echo "ERR: unexpected arg $1" >&2; exit 4 ;;
  esac
done

case "$ENVIRONMENT" in
  auto|prod|test) ;;
  *) echo "ERR: --env must be auto, prod or test (got '$ENVIRONMENT')" >&2; exit 4 ;;
esac

# Hosts and token keys come from prefs (private config); skill templates only
# show {GRAYLOG_HOST} / {GRAYLOG_TEST_HOST}.
PREFS="$HOME/.claude/multi-agent-preferences.json"

pref_str() {
  # Usage: pref_str <dotted.path.under.global>  -> value or empty
  [ -f "$PREFS" ] || { printf ''; return 0; }
  PREF_PATH="$1" python3 -c "
import json, os
try:
    node = json.load(open('$PREFS')).get('global', {})
    for part in os.environ['PREF_PATH'].split('.'):
        node = node.get(part, {}) if isinstance(node, dict) else {}
    print(node if isinstance(node, str) else '')
except Exception:
    print('')
" 2>/dev/null || printf ''
}

HOST_PROD=$(pref_str hosts.graylog)
HOST_TEST=$(pref_str hosts.graylogTest)
HOST_PROD=${HOST_PROD%/}
HOST_TEST=${HOST_TEST%/}

KEY_PROD=$(pref_str keychainMapping.graylog)
[ -z "$KEY_PROD" ] && KEY_PROD="${USER}_Graylog_Access_Token"
# A test instance usually issues its own token, but plenty of deployments share
# one; falling back to the prod key beats refusing to search test at all.
KEY_TEST=$(pref_str keychainMapping.graylog_test)
[ -z "$KEY_TEST" ] && KEY_TEST="$KEY_PROD"

if [ -z "$TRX_ID" ] && [ -z "$CONV_ID" ]; then
  echo "ERR: no id (pass --trx <transaction-id> and/or --conv <conversation-id>)" >&2
  exit 4
fi

# Build the Graylog query from whichever ids were supplied.
QUERY=""
if [ -n "$TRX_ID" ]; then
  QUERY="transactionId:\"$TRX_ID\""
fi
if [ -n "$CONV_ID" ]; then
  if [ -n "$QUERY" ]; then
    QUERY="$QUERY OR conversationId:\"$CONV_ID\""
  else
    QUERY="conversationId:\"$CONV_ID\""
  fi
fi

# Locate the resolver with an existence check, not a `.`-chain.
#
# Sourcing a file that does not exist aborts the shell under `set -e` - `||` included -
# so `. <candidate> || . <candidate> || { error }` reaches neither its later candidates
# nor its error branch. Every fetcher used that shape starting from `$HOME/.claude/...`,
# so on a Copilot-only or Codex-only install they all died with a bare exit 1 and no
# message. Reordering does not help: whichever candidate is absent aborts at that point.
# Checking for the file before sourcing it is the only safe form.
for _cred_resolver in \
  "$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)/credential-store-resolver.sh" \
  "$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")/../lib" 2>/dev/null && pwd)/credential-store-resolver.sh" \
  "$HOME/.claude/lib/credential-store-resolver.sh" \
  "$HOME/.copilot/lib/credential-store-resolver.sh" \
  "$HOME/.codex/lib/credential-store-resolver.sh"; do
  [ -f "$_cred_resolver" ] || continue
  # shellcheck source=/dev/null
  . "$_cred_resolver" 2>/dev/null || true
  # `if`, not `[ ... ] && break`: the latter is the loop body's last command and returns
  # 1 when CRED_STORE is still empty, which under `set -e` kills the loop on the first
  # candidate that does not resolve - the very case the loop exists to survive.
  if [ -n "${CRED_STORE:-}" ]; then break; fi
done
unset _cred_resolver
if [ -z "${CRED_STORE:-}" ]; then
  printf '%s\n' '{"status":"blocked","reason":"missing-credential-helper","service":"graylog","expected_key":"'"$KEY_PROD"'"}' >&2
  exit 2
fi

# Graylog relative universal search, built once and reused per environment.
ENC_QUERY=$(QUERY_IN="$QUERY" python3 -c "
import os, urllib.parse as up
print(up.quote(os.environ['QUERY_IN']))
")
SEARCH_PATH="search/universal/relative?query=$ENC_QUERY&range=$RANGE&limit=$LIMIT&sort=timestamp:desc"

# Set by run_search: the outcome of the most recent environment probe.
RUN_HTTP=""
RUN_BODY=""
RUN_HITS=0

run_search() {
  # Usage: run_search <host> <token-key>
  local host="$1" token_key="$2" token url resp
  token=$("$CRED_STORE" get "$token_key" 2>/dev/null || true)
  if [ -z "$token" ]; then
    RUN_HTTP="no-token"; RUN_BODY=""; RUN_HITS=0
    return 0
  fi
  url="https://$host/api/$SEARCH_PATH"
  # PAT basic auth: username=<token>, password=literal "token". The credential
  # goes through a curl config via process substitution so it never lands in
  # argv (argv is visible to ps).
  resp=$(curl -sS --max-time "$TIMEOUT" --connect-timeout 5 -w "\n%{http_code}" \
    -K <(printf 'user = "%s:token"\n' "$token") \
    -H "Accept: application/json" \
    -H "X-Requested-By: multi-agent-pipeline" \
    "$url" 2>/dev/null || true)
  RUN_HTTP=$(printf '%s' "$resp" | tail -n1)
  RUN_BODY=$(printf '%s' "$resp" | sed '$d')
  RUN_HITS=0
  if [ "$RUN_HTTP" = "200" ]; then
    RUN_HITS=$(BODY_IN="$RUN_BODY" python3 -c "
import json, os
try:
    print(len(json.loads(os.environ['BODY_IN']).get('messages') or []))
except Exception:
    print(0)
" 2>/dev/null || echo 0)
  fi
  # An empty RUN_HITS would make the caller's numeric test abort under `set -e`.
  case "$RUN_HITS" in (*[!0-9]*|"") RUN_HITS=0 ;; esac
}

# Which instances to try, in order. `auto` reaches for test only when prod had
# nothing to say - either no hits or no answer at all.
CANDIDATES=""
if [ -n "${GRAYLOG_HOST_OVERRIDE:-}" ]; then
  CANDIDATES="override"
else
  case "$ENVIRONMENT" in
    prod) CANDIDATES="prod" ;;
    test) CANDIDATES="test" ;;
    auto) if [ -n "$HOST_TEST" ]; then CANDIDATES="prod test"; else CANDIDATES="prod"; fi ;;
  esac
fi

host_for() {
  case "$1" in
    override) printf '%s' "${GRAYLOG_HOST_OVERRIDE%/}" ;;
    test)     printf '%s' "$HOST_TEST" ;;
    *)        printf '%s' "$HOST_PROD" ;;
  esac
}
key_for() {
  case "$1" in
    test) printf '%s' "$KEY_TEST" ;;
    *)    printf '%s' "$KEY_PROD" ;;
  esac
}

# A pinned environment with no host is a configuration error, not a degrade:
# silently searching the other instance would attach the wrong logs.
for _env in $CANDIDATES; do
  if [ -z "$(host_for "$_env")" ]; then
    if [ "$_env" = "test" ]; then
      printf '%s\n' '{"status":"blocked","reason":"host-not-configured","service":"graylog","environment":"test","expected_pref":"global.hosts.graylogTest"}' >&2
    else
      printf '%s\n' '{"status":"blocked","reason":"host-not-configured","service":"graylog","environment":"prod","expected_pref":"global.hosts.graylog"}' >&2
    fi
    exit 6
  fi
done

HOST=""
ENV_USED=""
SEARCHED=""
HTTP=""
BODY=""
LAST_CANDIDATE="${CANDIDATES##* }"
for _env in $CANDIDATES; do
  SEARCHED="${SEARCHED:+$SEARCHED }$_env"
  run_search "$(host_for "$_env")" "$(key_for "$_env")"
  HOST=$(host_for "$_env")
  ENV_USED="$_env"
  HTTP="$RUN_HTTP"
  BODY="$RUN_BODY"
  # An auth rejection on a reachable host is the only hard failure, and it is
  # per-instance: a dead test token must not mask a working prod answer, so it
  # aborts only when no candidate is left to try.
  if [ "$HTTP" = "401" ] || [ "$HTTP" = "403" ]; then
    if [ "$_env" = "$LAST_CANDIDATE" ]; then
      echo "ERR: Graylog auth rejected on $_env (HTTP $HTTP)" >&2
      exit 3
    fi
    continue
  fi
  if [ "$RUN_HITS" -gt 0 ]; then break; fi
done

if [ "$HTTP" = "no-token" ]; then
  printf '%s\n' '{"status":"blocked","reason":"missing-token","service":"graylog","expected_key":"'"$(key_for "$ENV_USED")"'"}' >&2
  exit 2
fi

# Any other non-200 (empty code on connection failure, 5xx, timeout) degrades
# to an empty result so a log fetch never blocks a run.
DEGRADED="false"
DEGRADE_REASON=""
if [ "$HTTP" != "200" ]; then
  DEGRADED="true"
  DEGRADE_REASON="graylog-unreachable"
  BODY=""
fi

SRC_HOST="$HOST" SRC_ENV="$ENV_USED" SRC_SEARCHED="$SEARCHED" \
SRC_TRX="$TRX_ID" SRC_CONV="$CONV_ID" SRC_QUERY="$QUERY" \
DEGRADED_IN="$DEGRADED" DEGRADE_REASON_IN="$DEGRADE_REASON" \
BODY_IN="$BODY" LIMIT_IN="$LIMIT" \
python3 - <<'PY'
import json, os, datetime

def safe_load(s):
    try:
        return json.loads(s) if s else None
    except Exception:
        return None

body = safe_load(os.environ.get("BODY_IN") or "") or {}
raw = body.get("messages") or []

try:
    limit = int(os.environ.get("LIMIT_IN") or "200")
except Exception:
    limit = 200

messages = []
for item in raw[:limit]:
    m = item.get("message") if isinstance(item, dict) else None
    if not isinstance(m, dict):
        continue
    reserved = ("timestamp", "source", "level", "message")
    fields = {k: v for k, v in m.items() if k not in reserved}
    messages.append({
        "timestamp": m.get("timestamp") or "",
        "source": m.get("source") or "",
        "level": m.get("level") if isinstance(m.get("level"), (int, float)) else None,
        "message": (m.get("message") or "")[:2000],
        "fields": fields,
    })

trx = os.environ.get("SRC_TRX") or None
conv = os.environ.get("SRC_CONV") or None
total = body.get("total_results")
if not isinstance(total, int):
    total = len(messages)

result = {
    "fetchedAt": datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z"),
    "source": {
        "host": os.environ["SRC_HOST"],
        "environment": os.environ.get("SRC_ENV") or "prod",
        "searchedEnvironments": (os.environ.get("SRC_SEARCHED") or "").split(),
        "transactionId": trx,
        "conversationId": conv,
        "query": os.environ.get("SRC_QUERY") or "",
    },
    "totalResults": total,
    "messages": messages,
    "degraded": os.environ.get("DEGRADED_IN") == "true",
    "degradeReason": (os.environ.get("DEGRADE_REASON_IN") or None),
}
print(json.dumps(result, ensure_ascii=False))
PY
