#!/bin/bash
#
# issue-fetcher.sh
# Resolve any user input (Jira ID, Jira URL, GitHub URL, gh #N, repo#N, free-text)
# into a normalized issue descriptor  -  and emit a maturity score so the picker
# can warn/block on incomplete issues.
#
# Output JSON schema:
#   {
#     "kind":       "jira" | "github" | "freetext",
#     "key":        "PROJ-1" | "316" | null,
#     "title":      "...",
#     "type":       "Bug" | "Task" | "Story" | "Issue",
#     "status":     "...",
#     "description":"...",                          // raw body (truncated to 4000 chars); this
#                                                     // issue's OWN description  -  never auto-substituted
#     "parentKey":         "PROJ-1" | null,          // jira only; set when own description is empty
#                                                     // AND a parent link exists (single hop, no chasing
#                                                     // the parent's parent)
#     "parentDescription": "..." | null,             // jira only; the parent's description (truncated
#                                                     // 4000 chars), only when own is empty and parent
#                                                     // has content  -  a CANDIDATE, not applied automatically;
#                                                     // the caller must confirm before using it as "description"
#     "url":        "https://...",
#     "host":       "jira.example.com" | "github.com",
#     "owner":      "org" (gh) | null,
#     "repo":       "name" (gh) | null,
#     "branchHint": "feature/PROJ-1" | "bugfix/...",
#     "maturity": {
#        "score":     0..100,
#        "blockers":  ["status_closed", "description_empty"],
#        "warnings":  ["short_description", "no_repro_steps", "description_empty_parent_available"],
#        "summary":   "Description boş  -  pipeline başlatılamaz." (human-readable, tr|en)
#     }
#   }
#
# "description_empty_parent_available": own description is empty but parentDescription is
# non-empty. This is a WARNING, not a blocker  -  the caller must ask the user (or, in
# autopilot, auto-accept per the same "warnings auto-continue" rule) whether to proceed
# using parentDescription as the working description. See jira/SKILL.md's maturity
# section for the exact question wording.
#
# Required env:
#   ACCOUNT_JIRA_TOKEN_KEY    keychain service name (optional)
#   ACCOUNT_JIRA_HOST         e.g. jira.example.com    (optional)
#   ACCOUNT_GH_TOKEN_KEY      keychain service name (optional)
#   ACCOUNT_DEFAULT_OWNER     fallback GitHub owner (optional)
#   ACCOUNT_DEFAULT_REPO      fallback GitHub repo  (optional)
#   PROMPT_LANG               tr|en  -  controls maturity.summary language (default: tr)

set -euo pipefail

INPUT="${1:-}"
[ -z "$INPUT" ] && { echo '{"error":"input required"}'; exit 1; }

JIRA_TOKEN_KEY="${ACCOUNT_JIRA_TOKEN_KEY:-}"
JIRA_HOST="${ACCOUNT_JIRA_HOST:-}"
DEFAULT_OWNER="${ACCOUNT_DEFAULT_OWNER:-}"
DEFAULT_REPO="${ACCOUNT_DEFAULT_REPO:-}"
PROMPT_LANG="${PROMPT_LANG:-tr}"

# --- Detect input kind --------------------------------------------------------
detect_kind() {
  local in="$1"
  case "$in" in
    *atlassian.net/browse/*|*jira*/browse/*|http*://*/browse/*) echo "jira-url"; return ;;
    *github.com/*/issues/*) echo "gh-url"; return ;;
  esac
  # A bare Jira key is PROJECT-NUMBER, anchored end-to-end. The case glob this
  # replaced ([A-Z]*-[0-9]*) matches whenever that shape appears ANYWHERE in a
  # longer string (case patterns match the whole string, but `*` is greedy
  # enough to swallow surrounding words) - "Add iOS-16 support" contains
  # "S-16" and satisfied it, silently misrouting a free-text task description
  # into a Jira lookup instead of the freetext path below.
  if [[ "$in" =~ ^[A-Z][A-Z0-9]*-[0-9]+$ ]]; then
    echo "jira-id"; return
  fi
  # Bare issue number ("316" or "#316") must resolve BEFORE the repo#N glob:
  # `*\#[0-9]*` also matches "#316" with an empty repo prefix, which used to
  # misroute it to gh-short and build https://github.com/<owner>//issues/316.
  local num="${in#\#}"
  case "$num" in
    "") ;;
    *[!0-9]*) ;;
    *) echo "gh-num"; return ;;
  esac
  case "$in" in
    *\#[0-9]*) echo "gh-short" ;;
    *) echo "freetext" ;;
  esac
}

KIND=$(detect_kind "$INPUT")

# --- Helpers ------------------------------------------------------------------
slugify() {
  printf '%s' "$1" \
    | tr '[:upper:]' '[:lower:]' \
    | sed -E 's/[^a-z0-9]+/-/g; s/^-+|-+$//g' \
    | cut -c1-50
}

infer_type_from_label() {
  case "$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" in
    *bug*|*defect*|*hotfix*) echo "Bug" ;;
    *) echo "Task" ;;
  esac
}

branch_for() {
  local key="$1" type="$2" title="$3"
  local prefix="feature"
  [ "$type" = "Bug" ] && prefix="bugfix"
  if [ -n "$key" ]; then
    printf '%s/%s' "$prefix" "$key"
  else
    printf '%s/%s' "$prefix" "$(slugify "$title")"
  fi
}

# --- Provider fetchers (return raw JSON or empty) -----------------------------

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

fetch_jira() {
  local key="$1"
  if [ -z "$JIRA_HOST" ] || [ -z "$JIRA_TOKEN_KEY" ]; then
    echo "ERR: ACCOUNT_JIRA_HOST and ACCOUNT_JIRA_TOKEN_KEY must be set" >&2
    return 1
  fi
  # Resolve the credential helper via the shared resolver (works from the
  # repo checkout and from both install trees)  -  never hardcode the path.
  # A pre-set CRED_STORE (tests, custom installs) is honored as-is.
  if [ -z "${CRED_STORE:-}" ]; then
    # shellcheck disable=SC1090,SC1091
    # 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 "ERR: credential helper not found" >&2
      return 1
    fi
  fi
  local token
  token=$("$CRED_STORE" get "$JIRA_TOKEN_KEY" 2>/dev/null || true)
  if [ -z "$token" ]; then
    echo "ERR: Jira token not found in credential store ($JIRA_TOKEN_KEY)" >&2
    return 1
  fi
  curl -sf -K <(jira_auth_cfg "$token") \
    "https://$JIRA_HOST/rest/api/2/issue/$key?fields=summary,status,issuetype,description,priority,resolution,fixVersions,parent" \
    || { echo "ERR: Jira fetch failed for $key" >&2; return 1; }
}

fetch_github() {
  local owner="$1" repo="$2" num="$3"
  if ! command -v gh >/dev/null 2>&1; then
    echo "ERR: gh CLI not found" >&2
    return 1
  fi
  gh issue view "$num" --repo "$owner/$repo" \
    --json number,title,labels,state,url,body 2>/dev/null \
    || { echo "ERR: GitHub fetch failed for $owner/$repo#$num" >&2; return 1; }
}

# Maturity scoring lives inline inside emit_descriptor (single python invocation,
# avoids double-pass marshalling). The labels and rules below mirror the picker's
# expectation; see `descriptor.maturity` in the output schema.

# Build the descriptor JSON, then attach maturity. Pythonized so escaping stays sane.
emit_descriptor() {
  python3 - "$PROMPT_LANG" "$@" <<'PY'
import json, sys, re

lang = sys.argv[1]
fields = dict(arg.split("=",1) for arg in sys.argv[2:])

# Truncate description to keep payload reasonable.
desc = fields.get("description","") or ""
if len(desc) > 4000:
    desc = desc[:4000] + "..."

priority = fields.get("priority","").strip()
resolution = fields.get("resolution","").strip()
fix_versions = [v for v in (fields.get("fixVersions","").split(",")) if v.strip()]

# Priority weight (used by picker for sorting)
priority_weights = {
    "blocker": 100, "highest": 95, "critical": 90, "p0": 100, "p1": 90,
    "high": 80, "p2": 80,
    "medium": 50, "normal": 50, "p3": 50,
    "low": 30, "p4": 30,
    "lowest": 10, "trivial": 10, "minor": 20, "p5": 10,
}
priority_weight = priority_weights.get(priority.lower(), 0)

descriptor = {
    "kind":        fields.get("kind"),
    "key":         fields.get("key") or None,
    "title":       fields.get("title") or None,
    "type":        fields.get("type") or "Task",
    "status":      fields.get("status") or "Unknown",
    "priority":    priority or None,
    "priorityWeight": priority_weight,
    "resolution":  resolution or None,
    "fixVersions": fix_versions,
    "description": desc,
    "parentKey":   fields.get("parentKey") or None,
    "parentDescription": fields.get("parentDescription") or None,
    "host":        fields.get("host") or None,
    "url":         fields.get("url") or None,
    "owner":       fields.get("owner") or None,
    "repo":        fields.get("repo") or None,
    "branchHint":  fields.get("branchHint") or None,
}
extra = {}
if fields.get("needsRepoPicker"):
    extra["needsRepoPicker"] = True
descriptor.update(extra)

# Compute maturity inline (avoid second python invocation).
title = (descriptor.get("title") or "").strip()
status = (descriptor.get("status") or "").lower()
itype = (descriptor.get("type") or "").lower()

parent_desc = (descriptor.get("parentDescription") or "").strip()
parent_key = descriptor.get("parentKey") or ""

blockers, warnings = [], []
closed = {"done","closed","cancelled","canceled","resolved"}
if status in closed:
    blockers.append("status_closed")
# Jira "resolution" field: if set (Fixed, Won't Do, Done...), issue was already resolved.
if resolution:
    blockers.append("already_resolved")
if not desc.strip():
    # A parent with actual content is a candidate, not an auto-fix  -  surface it
    # as a warning so the caller asks the user before substituting it in (or,
    # under autopilot, auto-accepts per the same "warnings auto-continue" rule).
    # No parent / an equally-empty parent keeps the hard blocker as before.
    if parent_desc:
        warnings.append("description_empty_parent_available")
    else:
        blockers.append("description_empty")
elif len(desc.strip()) < 80:
    warnings.append("short_description")
if len(title) < 10:
    warnings.append("short_title")
if "bug" in itype or "defect" in itype:
    if desc and not re.search(r"(steps to reproduce|reproduce|repro|adımlar|adimlar|nasıl|how to reproduce)", desc, re.I):
        warnings.append("no_repro_steps")
if any(w in itype for w in ("story","task","feature","sub-task")):
    if desc and len(desc) >= 80 and not re.search(r"(acceptance criteria|kabul kriter|definition of done|\bAC[: ]|\bDoD\b)", desc, re.I):
        warnings.append("no_acceptance_criteria")

score = max(0, min(100, 100 - 40*len(blockers) - 10*len(warnings)))

labels_tr = {
    "status_closed":         "Issue kapalı veya iptal edilmiş",
    "already_resolved":      "Issue zaten resolved/merged ({})".format(resolution or " - "),
    "description_empty":     "Açıklama boş",
    "description_empty_parent_available":"Açıklama boş ama parent'ta ({}) içerik var  -  oradan devam edilsin mi?".format(parent_key or " - "),
    "short_description":     "Açıklama çok kısa (<80 karakter)",
    "short_title":           "Başlık çok kısa (<10 karakter)",
    "no_repro_steps":        "Bug için repro adımları yok",
    "no_acceptance_criteria":"Kabul kriteri / AC görünmüyor",
}
labels_en = {
    "status_closed":         "Issue is closed/cancelled",
    "already_resolved":      "Issue already resolved/merged ({})".format(resolution or " - "),
    "description_empty":     "Description is empty",
    "description_empty_parent_available":"Description is empty but the parent ({}) has content  -  continue from there?".format(parent_key or " - "),
    "short_description":     "Description too short (<80 chars)",
    "short_title":           "Title too short (<10 chars)",
    "no_repro_steps":        "Bug missing reproduction steps",
    "no_acceptance_criteria":"No acceptance criteria detected",
}
labels = labels_tr if lang == "tr" else labels_en
lines = []
for c in blockers:
    lines.append(("⛔ " if lang == "tr" else "BLOCK ") + labels.get(c, c))
for c in warnings:
    lines.append(("⚠ " if lang == "tr" else "WARN  ") + labels.get(c, c))
summary = "\n".join(lines) if lines else (
    "Issue olgun, devam edilebilir." if lang == "tr" else "Issue mature; ready to proceed."
)

descriptor["maturity"] = {
    "score":    score,
    "blockers": blockers,
    "warnings": warnings,
    "summary":  summary,
}

# For freetext, no description fetched  -  score the title instead.
if descriptor.get("kind") == "freetext":
    # No real issue, no maturity to assess. Mark as N/A.
    descriptor["maturity"] = {
        "score":    None,
        "blockers": [],
        "warnings": [],
        "summary":  "Free-text input  -  maturity check skipped." if lang == "en"
                    else "Free-text girdi  -  maturity kontrolü atlandı.",
    }

print(json.dumps(descriptor, ensure_ascii=False))
PY
}

# --- Dispatch -----------------------------------------------------------------
case "$KIND" in
  jira-id)
    KEY="$INPUT"
    raw=$(fetch_jira "$KEY") || raw=""
    if [ -n "$raw" ]; then
      # Single python3 pass  -  emit all fields joined by U+001F (Unit Separator)
      # so we avoid spawning 7 interpreter startups per Jira fetch
      # (~200-300ms each). The separator is non-whitespace, so empty fields
      # (e.g. unresolved issues) are preserved by `read` instead of collapsed.
      # `read -d ''` lets descriptions with embedded newlines pass through.
      parsed=$(printf '%s' "$raw" | python3 -c '
import json, sys
d = json.load(sys.stdin)
f = d.get("fields", {}) or {}
def _name(x):
    return (x or {}).get("name", "") or ""
title       = f.get("summary", "") or ""
itype       = _name(f.get("issuetype"))
status      = _name(f.get("status"))
description = (f.get("description") or "")
priority    = _name(f.get("priority"))
resolution  = _name(f.get("resolution"))
fixversions = ",".join((v or {}).get("name", "") for v in (f.get("fixVersions") or []))
parentkey   = (f.get("parent") or {}).get("key", "") or ""
parts = [title, itype, status, description, priority, resolution, fixversions, parentkey]
parts = [v.replace("\x1f", " ") for v in parts]
sys.stdout.write("\x1f".join(parts))
')
      IFS=$'\x1f' read -r -d '' title itype status description priority resolution fixversions parentkey <<< "$parsed" || true
      # `<<<` appends a trailing newline to its input; `read -d ''` has no
      # delimiter to stop on, so that newline folds into whichever field
      # comes last  -  now parentkey. Strip it, or a genuinely-empty parent
      # (no fallback needed) or a real key gets a stray \n appended, which
      # breaks the parent lookup's URL and misreports parentKey in output.
      parentkey="${parentkey%$'\n'}"
    else
      title="(unfetched)"; itype="Task"; status="Unknown"; description=""
      priority=""; resolution=""; fixversions=""; parentkey=""
    fi
    # Development sub-tasks are frequently filed with an empty description while
    # the real requirements live on the parent (story/epic). Single hop only  -
    # if the parent is also empty/unreachable, description stays empty and
    # description_empty blocks below, same as before this existed. This fetcher
    # only surfaces the parent's description as a CANDIDATE (parentDescription)
    # - it never substitutes it into "description" itself. Whether to actually
    # use it is a decision for the caller to put in front of the user (or
    # auto-accept under autopilot), same as any other maturity warning.
    parentdesc=""
    if [ -z "$(printf '%s' "$description" | tr -d '[:space:]')" ] && [ -n "${parentkey:-}" ]; then
      praw=$(fetch_jira "$parentkey") || praw=""
      if [ -n "$praw" ]; then
        pdesc=$(printf '%s' "$praw" | python3 -c '
import json, sys
d = json.load(sys.stdin)
f = d.get("fields", {}) or {}
sys.stdout.write((f.get("description") or "").replace("\x1f", " "))
')
        if [ -n "$(printf '%s' "$pdesc" | tr -d '[:space:]')" ]; then
          parentdesc="$pdesc"
        fi
      fi
    fi
    branch=$(branch_for "$KEY" "$itype" "$title")
    emit_descriptor \
      "kind=jira" "key=$KEY" "title=$title" "type=$itype" "status=$status" \
      "description=$description" "host=$JIRA_HOST" \
      "url=https://$JIRA_HOST/browse/$KEY" "branchHint=$branch" \
      "priority=$priority" "resolution=$resolution" "fixVersions=$fixversions" \
      "parentKey=$parentkey" "parentDescription=$parentdesc"
    ;;

  jira-url)
    HOST=$(printf '%s' "$INPUT" | sed -E 's#https?://##; s#/.*##')
    KEY=$(printf '%s' "$INPUT" | sed -E 's#.*/browse/##; s#[/?].*##')
    ACCOUNT_JIRA_HOST="$HOST" JIRA_HOST="$HOST" exec "$0" "$KEY"
    ;;

  gh-url)
    OWNER=$(printf '%s' "$INPUT" | sed -E 's#https?://github.com/##; s#/.*##')
    REPO=$(printf '%s' "$INPUT" | sed -E 's#https?://github.com/[^/]+/##; s#/.*##')
    NUM=$(printf '%s' "$INPUT" | sed -E 's#.*/issues/##; s#[/?#].*##')
    raw=$(fetch_github "$OWNER" "$REPO" "$NUM") || raw=""
    if [ -n "$raw" ]; then
      # Single python3 pass  -  see jira-id branch for rationale on the
      # U+001F separator + `read -d ''` combo.
      parsed=$(printf '%s' "$raw" | python3 -c '
import json, sys
d = json.load(sys.stdin)
title       = d.get("title", "") or ""
labels      = ",".join((l or {}).get("name", "") for l in (d.get("labels") or []))
status      = d.get("state", "open") or "open"
description = d.get("body", "") or ""
parts = [title, labels, status, description]
parts = [v.replace("\x1f", " ") for v in parts]
sys.stdout.write("\x1f".join(parts))
')
      IFS=$'\x1f' read -r -d '' title labels status description <<< "$parsed" || true
    else
      title="(unfetched)"; labels=""; status="open"; description=""
    fi
    itype=$(infer_type_from_label "$labels")
    branch=$(branch_for "${REPO}-${NUM}" "$itype" "$title")
    emit_descriptor \
      "kind=github" "key=$NUM" "title=$title" "type=$itype" "status=$status" \
      "description=$description" "host=github.com" \
      "url=https://github.com/$OWNER/$REPO/issues/$NUM" \
      "owner=$OWNER" "repo=$REPO" "branchHint=$branch"
    ;;

  gh-short)
    REPO=$(printf '%s' "$INPUT" | sed -E 's/#.*//')
    NUM=$(printf '%s' "$INPUT" | sed -E 's/.*#//')
    OWNER="${DEFAULT_OWNER:-}"
    if [ -z "$OWNER" ]; then
      echo '{"error":"ACCOUNT_DEFAULT_OWNER not set; cannot resolve repo#N"}'
      exit 2
    fi
    exec "$0" "https://github.com/$OWNER/$REPO/issues/$NUM"
    ;;

  gh-num)
    NUM="${INPUT#\#}"
    OWNER="${DEFAULT_OWNER:-}"
    REPO="${DEFAULT_REPO:-}"
    if [ -z "$OWNER" ] || [ -z "$REPO" ]; then
      emit_descriptor \
        "kind=github" "key=$NUM" "title=" "type=Task" "status=unresolved" \
        "description=" "host=github.com" \
        "needsRepoPicker=1"
      exit 0
    fi
    exec "$0" "https://github.com/$OWNER/$REPO/issues/$NUM"
    ;;

  freetext)
    title="$INPUT"
    branch=$(branch_for "" "Task" "$title")
    emit_descriptor \
      "kind=freetext" "key=" "title=$title" "type=Task" "status=new" \
      "description=" "branchHint=$branch"
    ;;
esac
