#!/usr/bin/env bash
#
# analysis-jira-write.sh  -  create the tree that analysis-story-tree.mjs planned.
#
# WHY THIS IS A SEPARATE FILE
#
# Planning is deterministic, testable offline, and safe to run a hundred times.
# Creating issues is none of those. Keeping them apart means the file that can
# write to a tracker is small enough to read in one sitting, and the file that
# decides WHAT to write can be tested without a network.
#
# `jira-publish.sh` cannot do this: it writes a comment or a description on an
# issue that already exists. This file is the one place that creates issues, so
# the gate, the ledger and the label search apply to every creation.
#
# THE WRITE IS LEDGERED, AND THE LEDGER IS THE POINT
#
# Before each POST an `intent` line is appended to the run ledger; after the
# response, the key. Crash in between and the ledger holds an intent with no key.
# The next run sees that and does a label search BEFORE sending anything, so a
# half-written tree cannot twin itself. Without the ledger, the failure mode is
# not "the run stopped" - it is "the run stopped and the retry made a second
# tree", which is the expensive one.
#
# IDENTITY IS SERVER-SIDE
#
# Each node carries a label from the plan. Finding the tree back is a JQL search
# on that label, not a lookup in a local file: a local index does not survive a
# new machine, a deleted ~/.claude, or a SECOND ANALYST - and the second analyst
# is exactly the person who would otherwise open a duplicate tree.
#
# AN EXISTING NODE IS SKIPPED, NEVER UPDATED
#
# Jira has no backup path for fields other than description. Rewriting a body an
# engineer has since edited would lose that edit with no way back.
#
# Usage:
#   analysis-jira-write.sh --plan plan.json --project KEY [--dry-run] [--ledger FILE] [--epic KEY]
#
# Exit: 0 written (or previewed), 3 the plan does not parse, 4 auth, 5 a Jira
#       call failed, 64 usage.

set -uo pipefail

SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=/dev/null
. "$SELF_DIR/_jira-auth.sh"

PLAN=""; PROJECT=""; DRY=0; LEDGER=""; EPIC=""
while [ $# -gt 0 ]; do
  case "$1" in
    --plan) PLAN="${2:-}"; shift 2 ;;
    --project) PROJECT="${2:-}"; shift 2 ;;
    --ledger) LEDGER="${2:-}"; shift 2 ;;
    --dry-run) DRY=1; shift ;;
    --epic) EPIC="${2:-}"; shift 2 ;;
    *) echo "usage: analysis-jira-write.sh --plan FILE --project KEY [--dry-run] [--ledger FILE] [--epic KEY]" >&2; exit 64 ;;
  esac
done
[ -n "$PLAN" ] && [ -f "$PLAN" ] || { echo "ERR: --plan FILE is required and must exist" >&2; exit 64; }
[ -n "$PROJECT" ] || { echo "ERR: --project KEY is required" >&2; exit 64; }

command -v jq >/dev/null 2>&1 || { echo "ERR: jq is required" >&2; exit 3; }
jq -e . "$PLAN" >/dev/null 2>&1 || { echo "ERR: the plan does not parse as JSON: $PLAN" >&2; exit 3; }

DOC_ID="$(jq -r '.document.id // "unidentified"' "$PLAN")"
VERDICT="$(jq -r '.coverage.verdict // "unverifiable"' "$PLAN")"
if [ -z "$LEDGER" ]; then
  LEDGER="$HOME/.claude/logs/multi-agent/_analysis-jira/${DOC_ID}.jsonl"
fi
mkdir -p "$(dirname "$LEDGER")" 2>/dev/null || true

# A run that could not be verified is allowed to write; it is not allowed to look
# verified afterwards. The ledger records the verdict beside every key, so the
# question "was this tree checked?" is answerable later from the tree's own trail.
ledger() {  # ledger <event> <label> [key]
  [ "$DRY" -eq 1 ] && return 0
  printf '{"ts":"%s","event":"%s","label":"%s","key":%s,"coverage":"%s"}\n' \
    "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$1" "$2" \
    "$([ -n "${3:-}" ] && printf '"%s"' "$3" || printf 'null')" \
    "$VERDICT" >> "$LEDGER"
}

if [ "$DRY" -eq 0 ]; then
  jira_auth_resolve || exit 4
fi

# An unfinished write from a previous run: an intent whose key never arrived.
# Its existence is what forces the search below to run before anything is sent.
ORPHANS=0
if [ -f "$LEDGER" ]; then
  ORPHANS=$(jq -rs '[.[] | select(.event=="intent")] as $i
                  | [.[] | select(.event=="created" or .event=="exists") | .label] as $c
                  | [$i[] | select(.label as $l | ($c | index($l)) | not)] | length' \
             "$LEDGER" 2>/dev/null || echo 0)
  if [ "${ORPHANS:-0}" -gt 0 ]; then
    echo "NOTE: $ORPHANS write(s) from an earlier run have no recorded key." >&2
    echo "      Searching Jira by label before sending anything." >&2
  fi
fi

# What already exists, by label. One search, whatever the tree's size.
declare -a EXISTING=()
existing_for() {  # existing_for <label> -> prints the key, or empty
  local label="$1" i
  for i in "${EXISTING[@]:-}"; do
    case "$i" in "$label="*) printf '%s' "${i#*=}"; return 0 ;; esac
  done
  printf ''
}

LABELS="$(jq -r '[.nodes[] | .label, (.clones[]?.label), (.subtasks[]?.label)] | join(",")' "$PLAN")"
if [ "$DRY" -eq 0 ] && [ -n "$LABELS" ]; then
  JQL="project=${PROJECT} AND labels in (${LABELS})"
  ENC="$(printf '%s' "$JQL" | jq -sRr @uri)"
  SEARCH="$(jira_api GET "/rest/api/2/search?jql=${ENC}&fields=labels&maxResults=200" || echo "")"
  if [ -n "$SEARCH" ]; then
    while IFS= read -r pair; do
      [ -n "$pair" ] && EXISTING+=("$pair")
    done < <(printf '%s' "$SEARCH" | jq -r '.issues[]? | .key as $k | .fields.labels[]? | "\(.)=\($k)"' 2>/dev/null || true)
  fi
fi

# Two channels on purpose. The human line goes to stdout; the created key comes
# back in CREATED_KEY. Keeping the key off stdout lets a caller capture the
# report with command substitution without losing any of it.
# Same gate, same five candidate paths, same refusal as jira-publish.sh and
# post-pr-review.sh. A summary plus a description is outbound text like any
# other, so every issue this file creates passes the gate first.
ma_outbound_gate_text() {
  local text="$1" og="" tmp rc
  for c in "$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && 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
  if [ -z "$og" ]; then
    echo "outbound-gate.mjs not found - refusing to create unchecked issues." >&2
    return 7
  fi
  tmp="$(mktemp)"
  printf '%s' "$text" > "$tmp"
  node "$og" --file "$tmp"
  rc=$?
  rm -f "$tmp"
  return $rc
}

link_issues() {  # link_issues <type> <fromKey> <toKey> <cloneLabel>
  local ltype="$1" from="$2" to="$3" label="$4" linked
  if [ "$DRY" -eq 1 ]; then
    echo "  link    $ltype  ${from:-<story>} <-> ${to:-<clone>}"
    return 0
  fi
  # A link already there is left alone, so a re-run never doubles it.
  linked="$(jira_api GET "/rest/api/2/issue/${from}?fields=issuelinks" 2>/dev/null \
    | jq -r --arg t "$to" '[.fields.issuelinks[]? | (.outwardIssue.key // .inwardIssue.key)] | index($t) != null' 2>/dev/null || echo false)"
  if [ "$linked" = "true" ]; then
    echo "  linked  $from <-> $to"
    return 0
  fi
  if ! printf '%s' "$(jq -cn --arg t "$ltype" --arg a "$from" --arg b "$to" \
      '{type: {name: $t}, inwardIssue: {key: $a}, outwardIssue: {key: $b}}')" \
      | jira_api POST "/rest/api/2/issueLink" --data @- >/dev/null; then
    echo "ERR: could not link $from to $to" >&2
    return 5
  fi
  ledger link "$label" "$to"
  echo "  linked  $from <-> $to"
}

CREATED_KEY=""
create_issue() {  # create_issue <label> <summary> <issuetype> <parentKey|""> <description> [extraFieldsJson]
  local label="$1" summary="$2" itype="$3" parent="$4" desc="$5" extra="${6:-}" body key existing
  [ -n "$extra" ] || extra="{}"
  CREATED_KEY=""
  existing="$(existing_for "$label")"
  if [ -n "$existing" ]; then
    # Skipped, never updated: Jira has no backup path for fields other than
    # description, and an engineer may have edited this body since.
    echo "  exists  $existing  $summary"
    ledger exists "$label" "$existing"
    CREATED_KEY="$existing"
    return 0
  fi
  body="$(jq -n --arg p "$PROJECT" --arg s "$summary" --arg t "$itype" \
                --arg l "$label" --arg d "$desc" --arg par "$parent" --argjson x "$extra" '
    {fields: ({project: {key: $p}, summary: $s, issuetype: {name: $t},
               labels: [$l], description: $d}
              + (if $par == "" then {} else {parent: {key: $par}} end)
              + $x)}')"
  if [ "$DRY" -eq 1 ]; then
    echo "  create  $itype  $summary   [$label]"
    [ "$parent" = "" ] && printf '%s\n' "$desc" | sed 's/^/          /'
    return 0
  fi
  # After the dry-run branch, because a dry run publishes nothing, and before
  # the ledger line, because an intent recorded for a POST that never happens
  # is a false entry in the only record of what was attempted.
  if ! ma_outbound_gate_text "$summary
$desc"; then
    echo "ERR: outbound gate refused '$summary'; nothing was created" >&2
    return 1
  fi
  ledger intent "$label"
  local resp
  resp="$(printf '%s' "$body" | jira_api POST "/rest/api/2/issue" --data @- || echo "")"
  key="$(printf '%s' "$resp" | jq -r '.key // empty' 2>/dev/null || echo "")"
  if [ -z "$key" ]; then
    echo "ERR: create failed for '$summary'" >&2
    printf '%s\n' "$resp" | head -3 >&2
    return 5
  fi
  ledger created "$label" "$key"
  echo "  created $key  $summary"
  CREATED_KEY="$key"
}

echo "analysis-jira-write: ${PROJECT}, coverage ${VERDICT}$([ "$DRY" -eq 1 ] && echo "  (dry run)")"
[ "$VERDICT" = "unverifiable" ] && \
  echo "  NOTE: coverage could not be checked for this document; the tree is unverified."

# The epic the stories join, if one was named. The link field depends on the
# deployment, so it is resolved once, against the live site, before any write.
STORY_EXTRA="{}"
if [ -n "${EPIC:-}" ]; then
  echo "  epic  $EPIC"
  if [ "$DRY" -eq 0 ]; then
    . "$(dirname "${BASH_SOURCE[0]}")/jira-epic-link.sh"
    E_MODE="$(jq -r '.prefsUsed.epicLinkMode // "auto"' "$PLAN")"
    E_FIELD="$(jq -r '.prefsUsed.epicLinkFieldId // empty' "$PLAN")"
    E_SERVER="" E_FIELDS=""
    if [ "$E_MODE" = "auto" ]; then
      E_SERVER="$(jira_api GET "/rest/api/2/serverInfo" 2>/dev/null || echo "")"
      [ -n "$E_FIELD" ] || E_FIELDS="$(jira_api GET "/rest/api/2/field" 2>/dev/null || echo "")"
    fi
    STORY_EXTRA="$(epic_link_fields "$E_MODE" "$EPIC" "$E_FIELD" "$E_SERVER" "$E_FIELDS")"
  fi
fi

RC=0
NODES="$(jq -c '.nodes[]' "$PLAN")"
while IFS= read -r node; do
  [ -n "$node" ] || continue
  s_label="$(printf '%s' "$node" | jq -r '.label')"
  s_title="$(printf '%s' "$node" | jq -r '.title')"
  s_src="$(printf '%s' "$node" | jq -r '.sourceIds | join(", ")')"
  s_type="$(jq -r '.prefsUsed.storyIssueType // "Story"' "$PLAN")"
  s_desc="$(printf '%s' "$node" | jq -r '.description // empty')"
  [ -n "$s_desc" ] || s_desc="Sources: ${s_src:-none (derived from the section heading)}"
  if ! create_issue "$s_label" "$s_title" "$s_type" "" "$s_desc" "$STORY_EXTRA"; then
    # A failed story does not get its sub-tasks or clones written. `create_issue`
    # omits the parent field when the parent key is empty, so they would land as
    # live, parentless issues from a run that has already reported an error.
    RC=5
    echo "  skipped   this story's sub-tasks: the story itself was not created" >&2
    continue
  fi
  skey="$CREATED_KEY"
  LINK_TYPE="$(jq -r '.prefsUsed.cloneLinkType // "Relates"' "$PLAN")"
  while IFS= read -r clone; do
    [ -n "$clone" ] || continue
    c_label="$(printf '%s' "$clone" | jq -r '.label')"
    c_title="$(printf '%s' "$clone" | jq -r '.title')"
    c_extra="$(printf '%s' "$clone" | jq -c --argjson e "$STORY_EXTRA" \
      '$e + {components: [.components[] | {name: .}]}')"
    create_issue "$c_label" "$c_title" "$s_type" "" "$s_desc" "$c_extra" || { RC=5; continue; }
    link_issues "$LINK_TYPE" "$skey" "$CREATED_KEY" "$c_label" || RC=5
  done < <(printf '%s' "$node" | jq -c '.clones[]?')
  while IFS= read -r sub; do
    [ -n "$sub" ] || continue
    t_label="$(printf '%s' "$sub" | jq -r '.label')"
    t_role="$(printf '%s' "$sub" | jq -r '.role')"
    t_type="$(jq -r '.prefsUsed.subtaskIssueType // empty' "$PLAN")"
    if [ -z "$t_type" ] && [ "$DRY" -eq 0 ]; then
      # Discover rather than assume: whichever type this site marks subtask:true,
      # under whatever name the site gave it.
      t_type="$(jira_api GET "/rest/api/2/issue/createmeta?projectKeys=${PROJECT}&expand=projects.issuetypes" \
        | jq -r '[.projects[]?.issuetypes[]? | select(.subtask==true) | .name] | first // empty' 2>/dev/null || echo "")"
    fi
    [ -n "$t_type" ] || t_type="Sub-task"
    t_desc="$(printf '%s' "$sub" | jq -r '.description // empty')"
    [ -n "$t_desc" ] || t_desc="Sources: ${s_src:-none (derived from the section heading)}"
    create_issue "$t_label" "${s_title} - ${t_role}" "$t_type" "$skey" "$t_desc" || RC=5
  done < <(printf '%s' "$node" | jq -c '.subtasks[]?')
done <<< "$NODES"

exit "$RC"
