#!/usr/bin/env bash
#
# post-pr-review.sh
#
# Posts the verdict from `agent-state.review` to the target PR using the
# review-actions contract: per-finding inline comments + an explicit approve
# / needs-work state. Spec: refs/channels/pr-review-actions.md.
#
# Provider switch:
#   - github           → gh CLI (gh api + gh pr review)
#   - bitbucket-server → curl + Bitbucket REST API (Basic auth from keychain)
#
# Usage:
#   post-pr-review.sh <task-id>
#
# Reads from agent-state.json:
#   .review.input              { kind, provider, host, orgRepo, prNumber,
#                                bbProjectKey, bbRepoSlug, bbServerHost }
#   .review.findings           [{ severity, status, file, line, issue, fix,
#                                ruleId }]
#   .review.iterationNumber    int (1-based fallback for the comment footer;
#                              on needs_work the live value is re-derived from
#                              the highest "iteration #N" already on the PR + 1)
#   .review.headCommitSha      string  (GitHub inline anchor needs commit_id)
#
# Reads from preferences:
#   prefs.global.outputLanguage  ("tr" | "en")
#
# Exit codes:
#   0  success  -  verdict + (any) inline comments posted
#   2  agent-state.json missing or .review absent
#   3  unknown provider; falls back to chat-only and exits 3
#   4  API call failed (network / auth / 4xx). Diagnostic line on stderr.

set -euo pipefail

# jq is not optional on this path. Without the guard below a missing binary
# renders as EMPTY DATA and the work continues on it; see lib/require-jq.sh.
for _rq in "$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)/require-jq.sh" \
           "$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")/../lib" 2>/dev/null && pwd)/require-jq.sh" \
           "$HOME/.claude/lib/require-jq.sh" \
           "$HOME/.copilot/lib/require-jq.sh" \
           "$HOME/.codex/lib/require-jq.sh"; do
  [ -f "$_rq" ] || continue
  # shellcheck source=/dev/null
  . "$_rq" && break
done
unset _rq
if ! command -v ma_require_jq >/dev/null 2>&1; then
  # The helper itself is missing, which is an install problem, not a jq one.
  ma_require_jq() { command -v jq >/dev/null 2>&1 || { echo "jq not found - cannot ${1:-continue}." >&2; return 1; }; }
fi
ma_require_jq "parse the review findings" || exit 3

# Outbound leak gate. Every byte below is composed at runtime out of command
# output, error text and file excerpts, any of which can carry a token that was
# on this machine a second earlier - and once it is in a comment it is in
# someone else's database. The repo's own leak scanner looks at FILES IN THE
# REPO and never sees this text. See lib/outbound-gate.mjs.
ma_outbound_gate() {
  local body_file="$1" og=""
  for c in "$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)/outbound-gate.mjs" \
           "$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")/../lib" 2>/dev/null && pwd)/outbound-gate.mjs" \
           "$HOME/.claude/lib/outbound-gate.mjs" \
           "$HOME/.copilot/lib/outbound-gate.mjs" \
           "$HOME/.codex/lib/outbound-gate.mjs"; do
    [ -f "$c" ] && { og="$c"; break; }
  done
  # Missing gate is NOT an open door: refusing to publish beats publishing
  # unchecked, and the only way this file is absent is a broken install.
  if [ -z "$og" ]; then
    echo "outbound-gate.mjs not found - refusing to publish unchecked text." >&2
    return 7
  fi
  node "$og" --file "$body_file"
}

# Same gate, for the bodies this file keeps in a variable rather than a file.
ma_outbound_gate_text() {
  local tmp
  tmp=$(mktemp) || return 7
  printf '%s' "$1" > "$tmp"
  ma_outbound_gate "$tmp"
  local rc=$?
  rm -f "$tmp"
  return $rc
}

# Run-state path resolution: pipeline/lib/run-paths.sh owns the two layouts
# (nested <root>/<project>/<id>/ and flat <root>/<id>/) and every id spelling.
_MA_RP_HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=/dev/null
if [ -f "$_MA_RP_HERE/run-paths.sh" ]; then . "$_MA_RP_HERE/run-paths.sh"; else . "$HOME/.claude/lib/run-paths.sh"; fi
# shellcheck source=/dev/null
if [ -f "$_MA_RP_HERE/user-facing.sh" ]; then . "$_MA_RP_HERE/user-facing.sh"; else . "$HOME/.claude/lib/user-facing.sh"; fi


# Basic-auth via a curl config fed through process substitution so the
# credential never appears in argv (argv is visible to `ps` / process audit).
bb_auth_cfg() { printf 'user = "%s:%s"\n' "$1" "$2"; }

TASK_ID="${1:-}"

if [ -z "$TASK_ID" ]; then
  echo "usage: post-pr-review.sh <task-id>" >&2
  exit 64
fi

# Resolve agent-state.json. multi-repo-pipeline.sh writes it under a
# per-project directory ($HOME/.claude/logs/multi-agent/{project}/{taskId}/)
# - not the ~/.claude/projects/*/state/ path this used to glob, which no
# writer in the pipeline ever populates.
# This globbed ONLY the nested layout. phase-tracker.sh and most Phase 0 paths
# write flat, so on a real machine the majority of runs resolved to nothing and
# this script reported "no agent-state.json" for a run whose state existed.
AGENT_STATE="$(ma_resolve_run_file "$TASK_ID" agent-state.json 2>/dev/null || true)"

if [ -z "$AGENT_STATE" ]; then
  echo "post-pr-review: agent-state.json not found for task=$TASK_ID" >&2
  exit 2
fi

if ! jq -e '.review' "$AGENT_STATE" >/dev/null; then
  echo "post-pr-review: agent-state has no .review field  -  run $(ma_command review) first" >&2
  exit 2
fi

OUT_LANG=$(ma_output_lang)

PROVIDER=$(jq -r '.review.input.provider // empty' "$AGENT_STATE")
PR_NUM=$(jq -r '.review.input.prNumber // empty' "$AGENT_STATE")
ITERATION=$(jq -r '.review.iterationNumber // 1' "$AGENT_STATE")

# --- Decision ---------------------------------------------------------------

# Single jq pass  -  group accepted findings by severity into tab-separated counts.
# `.review.findings // []` so a null/absent findings array (clean review)
# cannot raise "Cannot iterate over null" under set -e.
read -r ACCEPTED_BLOCKING ACCEPTED_IMPORTANT < <(
  jq -r '
    [(.review.findings // [])[] | select(.status == "accepted")] as $a
    | [($a | map(select(.severity == "blocking")) | length),
       ($a | map(select(.severity == "important")) | length)]
    | @tsv
  ' "$AGENT_STATE"
)
ACCEPTED_BLOCKING=${ACCEPTED_BLOCKING:-0}
ACCEPTED_IMPORTANT=${ACCEPTED_IMPORTANT:-0}

if [ "$ACCEPTED_BLOCKING" -eq 0 ] && [ "$ACCEPTED_IMPORTANT" -eq 0 ]; then
  DECISION=approve
else
  DECISION=needs_work
fi

echo "post-pr-review: decision=$DECISION (blocking=$ACCEPTED_BLOCKING, important=$ACCEPTED_IMPORTANT)" >&2

# --- Per-finding comment body renderer (in $OUT_LANG) -----------------------

# Stable fingerprint for a finding  -  used by dedupe to recognize re-runs of
# the same finding without re-posting. Sha256 of "path|line|issue"  -  note
# that fix/ruleId/iterationNumber are excluded so a finding still dedupes
# when the suggested fix wording changes slightly between iterations.
finding_fingerprint() {
  local path="$1" line="$2" issue="$3"
  # sha256sum (coreutils) or shasum (perl), whichever this machine has. An empty
  # fingerprint would make every finding look new and re-post the whole review on
  # each iteration.
  if command -v sha256sum >/dev/null 2>&1; then
    printf '%s|%s|%s' "$path" "$line" "$issue" | sha256sum | cut -c1-16
  else
    printf '%s|%s|%s' "$path" "$line" "$issue" | shasum -a 256 | cut -c1-16
  fi
}

render_inline_body() {
  local sev="$1" issue="$2" fix="$3" rule_id="$4" fingerprint="${5:-}"

  local label_word fix_label

  if [ "$OUT_LANG" = "tr" ]; then
    case "$sev" in
      blocking)  label_word="Blocker" ;;
      important) label_word="Önemli" ;;
    esac
    fix_label="Öneri"
  else
    case "$sev" in
      blocking)  label_word="Blocker" ;;
      important) label_word="Important" ;;
    esac
    fix_label="Suggestion"
  fi

  # The severity is the bold label, not a coloured dot. A review comment is
  # read as technical prose, and the glyph carried no information the word did
  # not already carry.
  printf '**%s**  -  %s\n\n' "$label_word" "$issue"
  if [ -n "$fix" ] && [ "$fix" != "null" ]; then
    printf '**%s:** %s\n\n' "$fix_label" "$fix"
  fi
  if [ -n "$rule_id" ] && [ "$rule_id" != "null" ]; then
    printf '_%s_\n\n' "$rule_id"
  fi
  printf -- '---\n_Multi-Agent Review · iteration #%s_\n' "$ITERATION"
  # Dedupe marker  -  dedupe-style. Re-runs of /multi-agent:review skip a
  # finding when an existing comment carries the same fingerprint.
  if [ -n "$fingerprint" ]; then
    printf '<!-- multi-agent-finding: %s -->\n' "$fingerprint"
  fi
}

# Returns 0 if a comment carrying this fingerprint already exists on the PR.
# Cached: the full comment list is fetched once per run and grepped per finding.
EXISTING_COMMENTS_CACHE=""
load_existing_comments_github() {
  local org_repo="$1" pr_num="$2"
  if [ -z "$EXISTING_COMMENTS_CACHE" ]; then
    # Combine review-comments (inline) + issue comments (top-level fallback).
    # Use --paginate so PRs with >30 comments still dedupe correctly.
    EXISTING_COMMENTS_CACHE=$(
      {
        gh api --paginate "/repos/$org_repo/pulls/$pr_num/comments" 2>/dev/null || echo "[]"
        gh api --paginate "/repos/$org_repo/issues/$pr_num/comments" 2>/dev/null || echo "[]"
      } | jq -s 'add // []' 2>/dev/null || echo "[]"
    )
  fi
}

comment_exists_with_fingerprint() {
  local fingerprint="$1"
  [ -z "$EXISTING_COMMENTS_CACHE" ] && return 1
  jq -e --arg fp "$fingerprint" '
    [.[]?.body // ""] | any(. | contains("multi-agent-finding: " + $fp))
  ' <<<"$EXISTING_COMMENTS_CACHE" >/dev/null 2>&1
}

# Next iteration number = highest "iteration #N" already stamped on the PR + 1.
# Standalone /multi-agent:review runs use a fresh TASK_ID each time, so the PR
# comments  -  not agent-state  -  are the only cross-run source of truth. Falls
# back to 1 when no prior multi-agent comment exists. Requires the comment cache
# to be loaded first.
compute_next_iteration() {
  local max
  # Only count "iteration #N" stamps inside our own footer ("Multi-Agent
  # Review · iteration #N"), so a human comment that happens to mention
  # "iteration #99" cannot inflate the counter.
  max=$(jq -r '
    [ .[]? | .body // ""
      | select(test("Multi-Agent Review"))
      | scan("iteration #([0-9]+)") | .[0] | tonumber ]
    | (max // 0) + 1
  ' <<<"${EXISTING_COMMENTS_CACHE:-[]}" 2>/dev/null || echo "")
  case "$max" in (*[!0-9]*|"") max=1 ;; esac
  echo "$max"
}

# Bitbucket Server activities endpoint returns nested comment trees. Flatten
# them into the same `[{body: <text>}, ...]` shape that
# comment_exists_with_fingerprint expects so the dedupe gate is provider-agnostic.
load_existing_comments_bitbucket() {
  local host="$1" project="$2" slug="$3" pr_num="$4" auth_user="$5" auth_token="$6"
  if [ -z "$EXISTING_COMMENTS_CACHE" ]; then
    local base="https://$host/rest/api/1.0/projects/$project/repos/$slug/pull-requests/$pr_num"
    local raw
    raw=$(curl -sS -m 30 -K <(bb_auth_cfg "$auth_user" "$auth_token") \
      "$base/activities?fromType=COMMENT&limit=100" 2>/dev/null || echo "{}")
    EXISTING_COMMENTS_CACHE=$(jq '
      def collect_texts(c):
        if c == null then []
        else [c.text] + ([ (c.comments // [])[] | collect_texts(.) ] | add // [])
        end;
      [ .values[]?.comment | collect_texts(.)[] | {body: .} ]
    ' <<<"$raw" 2>/dev/null || echo "[]")
  fi
}

# --- Provider dispatchers ---------------------------------------------------

post_github() {
  local org_repo head_sha
  org_repo=$(jq -r '.review.input.orgRepo' "$AGENT_STATE")
  head_sha=$(jq -r '.review.headCommitSha // empty' "$AGENT_STATE")

  # Fallback: head SHA may have been written to /tmp during diff fetch.
  if [ -z "$head_sha" ] && [ -n "${TASK_ID:-}" ]; then
    local sha_file="/tmp/multi-agent-review-${TASK_ID}-head.sha"
    [ -f "$sha_file" ] && head_sha=$(tr -d '[:space:]' < "$sha_file")
  fi

  local rc_body
  if [ "$OUT_LANG" = "tr" ]; then
    rc_body="Lütfen yukarıdaki inline yorumlara bakın."
  else
    rc_body="See inline comments above."
  fi

  # Dedupe gate  -  read pref, default ON (dedupe-style). Loads existing
  # comments once if needed; per-finding check is in-process.
  local DEDUPE_ENABLED
  DEDUPE_ENABLED=$(jq -r '.global.review.dedupeInlineComments // true' \
    "$HOME/.claude/multi-agent-preferences.json" 2>/dev/null || echo "true")
  # Load existing comments on any needs_work post: dedupe needs them when
  # enabled, and the iteration counter is always derived from them.
  if [ "$DECISION" = "needs_work" ]; then
    load_existing_comments_github "$org_repo" "$PR_NUM"
    ITERATION=$(compute_next_iteration)
  fi

  if [ "$DECISION" = "needs_work" ]; then
    jq -c '(.review.findings // [])[] | select(.status == "accepted" and (.severity == "blocking" or .severity == "important"))' "$AGENT_STATE" \
    | while read -r finding; do
        local sev path line issue_b64 fix_b64 rule_id_b64 issue fix rule_id body fingerprint
        IFS=$'\t' read -r sev path line issue_b64 fix_b64 rule_id_b64 < <(
          jq -r '[.severity, .file, (.line // 0),
                  (.issue | @base64),
                  ((.fix // "") | @base64),
                  ((.ruleId // "") | @base64)] | @tsv' <<<"$finding"
        )
        issue=$(printf '%s' "$issue_b64" | base64 --decode 2>/dev/null)
        fix=$(printf '%s' "$fix_b64" | base64 --decode 2>/dev/null)
        rule_id=$(printf '%s' "$rule_id_b64" | base64 --decode 2>/dev/null)
        fingerprint=$(finding_fingerprint "$path" "$line" "$issue")
        body=$(render_inline_body "$sev" "$issue" "$fix" "$rule_id" "$fingerprint")

        if [ "$DEDUPE_ENABLED" = "true" ] && comment_exists_with_fingerprint "$fingerprint"; then
          echo "post-pr-review: dedupe skip  -  finding $fingerprint already commented" >&2
          continue
        fi

        # A finding's body quotes the diff and the reviewer's prose, so it is
        # the most likely of the three to carry something from the machine.
        if ! ma_outbound_gate_text "$body"; then
          echo "post-pr-review: outbound gate refused the comment for $path:$line - not posted" >&2
          continue
        fi

        local inline_ok=0
        if [ -n "$head_sha" ] && [ "$line" -gt 0 ] 2>/dev/null; then
          if gh api -X POST "/repos/$org_repo/pulls/$PR_NUM/comments" \
              -f "body=$body" -f "commit_id=$head_sha" -f "path=$path" \
              -F "line=$line" -f "side=RIGHT" >/dev/null 2>&1; then
            inline_ok=1
          else
            echo "post-pr-review: inline anchor failed for $path:$line (out of diff?), falling back to top-level" >&2
          fi
        fi
        if [ "$inline_ok" -eq 0 ]; then
          gh pr comment "$PR_NUM" --repo "$org_repo" --body "$body" >/dev/null \
            || echo "post-pr-review: top-level comment failed for $path:$line" >&2
        fi
      done
  fi

  case "$DECISION" in
    approve)    gh pr review "$PR_NUM" --repo "$org_repo" --approve ;;
    needs_work)
      if ! ma_outbound_gate_text "$rc_body"; then
        echo "post-pr-review: outbound gate refused the request-changes body; no verdict posted" >&2
        return 7
      fi
      gh pr review "$PR_NUM" --repo "$org_repo" --request-changes --body "$rc_body"
      ;;
  esac
}

post_bitbucket_server() {
  local host project slug user_slug auth_user auth_token user_key token_key prefs
  host=$(jq -r '.review.input.bbServerHost' "$AGENT_STATE")
  project=$(jq -r '.review.input.bbProjectKey' "$AGENT_STATE")
  slug=$(jq -r '.review.input.bbRepoSlug' "$AGENT_STATE")

  # Resolve credential keys from prefs.keychainMapping  -  never hardcode.
  prefs="$HOME/.claude/multi-agent-preferences.json"
  user_key=$(jq -r '.global.keychainMapping.bitbucket_user // empty' "$prefs" 2>/dev/null || true)
  token_key=$(jq -r '.global.keychainMapping.bitbucket_token // empty' "$prefs" 2>/dev/null || true)

  if [ -z "$user_key" ] || [ -z "$token_key" ]; then
    echo "post-pr-review: global.keychainMapping.bitbucket_user / .bitbucket_token unset in $prefs" >&2
    return 4
  fi

  # Resolve via the credential helper (macOS Keychain)  -  sourced lazily so this script keeps
  # working when run from either ~/.claude/lib or ~/.copilot/lib.
  # shellcheck disable=SC1090
  # Existence check before sourcing: `. <missing>` aborts the shell under `set -e`,
  # `||` included, so a `.`-chain reaches neither its later candidates nor its error
  # branch. The loop also covers all three hosts - the chain it replaced knew only
  # .claude and .copilot, so a Codex-only install could not resolve at all.
  for _cred_resolver in \
    "$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && 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 [ -n "${CRED_STORE:-}" ]; then break; fi
  done
  unset _cred_resolver
  if [ -z "${CRED_STORE:-}" ]; then
    echo "post-pr-review: credential helper not found" >&2
    return 4
  fi
  resolve_credential_store || return 4

  auth_user=$("$CRED_STORE" get "$user_key" 2>/dev/null || true)
  auth_token=$("$CRED_STORE" get "$token_key" 2>/dev/null || true)
  user_slug="$auth_user"

  if [ -z "$auth_user" ] || [ -z "$auth_token" ]; then
    echo "post-pr-review: bitbucket creds not in credential store ($user_key / $token_key)" >&2
    return 4
  fi

  local base="https://$host/rest/api/1.0/projects/$project/repos/$slug/pull-requests/$PR_NUM"

  # Dedupe gate  -  read pref, default ON. Bitbucket uses the activities feed.
  local DEDUPE_ENABLED
  DEDUPE_ENABLED=$(jq -r '.global.review.dedupeInlineComments // true' \
    "$HOME/.claude/multi-agent-preferences.json" 2>/dev/null || echo "true")
  # Load existing comments on any needs_work post: dedupe needs them when
  # enabled, and the iteration counter is always derived from them.
  if [ "$DECISION" = "needs_work" ]; then
    load_existing_comments_bitbucket "$host" "$project" "$slug" "$PR_NUM" "$auth_user" "$auth_token"
    ITERATION=$(compute_next_iteration)
  fi

  if [ "$DECISION" = "needs_work" ]; then
    jq -c '(.review.findings // [])[] | select(.status == "accepted" and (.severity == "blocking" or .severity == "important"))' "$AGENT_STATE" \
    | while read -r finding; do
        local sev path line issue_b64 fix_b64 rule_id_b64 issue fix rule_id body payload http fingerprint
        IFS=$'\t' read -r sev path line issue_b64 fix_b64 rule_id_b64 < <(
          jq -r '[.severity, .file, (.line // 0),
                  (.issue | @base64),
                  ((.fix // "") | @base64),
                  ((.ruleId // "") | @base64)] | @tsv' <<<"$finding"
        )
        issue=$(printf '%s' "$issue_b64" | base64 --decode 2>/dev/null)
        fix=$(printf '%s' "$fix_b64" | base64 --decode 2>/dev/null)
        rule_id=$(printf '%s' "$rule_id_b64" | base64 --decode 2>/dev/null)
        fingerprint=$(finding_fingerprint "$path" "$line" "$issue")
        body=$(render_inline_body "$sev" "$issue" "$fix" "$rule_id" "$fingerprint")

        if [ "$DEDUPE_ENABLED" = "true" ] && comment_exists_with_fingerprint "$fingerprint"; then
          echo "post-pr-review: dedupe skip  -  finding $fingerprint already commented (bitbucket)" >&2
          continue
        fi

        local inline_ok=0
        if [ "$line" -gt 0 ] 2>/dev/null; then
          payload=$(jq -n --arg t "$body" --arg p "$path" --argjson l "$line" \
            '{text: $t, anchor: {path: $p, line: $l, lineType: "ADDED", fileType: "TO"}}')
          http=$(curl -sS -m 30 -K <(bb_auth_cfg "$auth_user" "$auth_token") -X POST \
            -H "Content-Type: application/json" "$base/comments" \
            -d "$payload" -o /dev/null -w '%{http_code}' || echo 000)
          if [ "$http" = "200" ] || [ "$http" = "201" ]; then
            inline_ok=1
          else
            echo "post-pr-review: bitbucket inline anchor failed ($http) for $path:$line, falling back to top-level" >&2
          fi
        fi

        if [ "$inline_ok" -eq 0 ]; then
          payload=$(jq -n --arg t "$body" '{text: $t}')
          curl -sS -m 30 -K <(bb_auth_cfg "$auth_user" "$auth_token") -X POST \
            -H "Content-Type: application/json" "$base/comments" \
            -d "$payload" >/dev/null \
            || echo "post-pr-review: bitbucket top-level comment failed for $path:$line" >&2
        fi
      done
  fi

  local status_payload
  case "$DECISION" in
    approve)    status_payload='{"status":"APPROVED"}' ;;
    needs_work) status_payload='{"status":"NEEDS_WORK"}' ;;
  esac

  local http
  http=$(curl -sS -m 30 -K <(bb_auth_cfg "$auth_user" "$auth_token") -X PUT \
    -H "Content-Type: application/json" "$base/participants/$user_slug" \
    -d "$status_payload" -o /dev/null -w '%{http_code}' || echo 000)
  case "$http" in
    200|201) ;;
    *)
      echo "post-pr-review: bitbucket verdict PUT failed (HTTP $http) for PR #$PR_NUM  -  $DECISION was NOT recorded" >&2
      return 4 ;;
  esac
}

# --- Main switch ------------------------------------------------------------

case "$PROVIDER" in
  github)
    post_github
    ;;
  bitbucket-server)
    post_bitbucket_server
    ;;
  *)
    echo "post-pr-review: unknown provider '$PROVIDER'; chat-only fallback (no PR action)" >&2
    exit 3
    ;;
esac

echo "post-pr-review: $DECISION posted to $PROVIDER PR #$PR_NUM"
