#!/usr/bin/env bash
#
# create-jira-stories.sh
#
# Bulk-creates Jira tickets from a plain-text stories file using jira-cli
# (https://github.com/ankitpokhrel/jira-cli).
#
# Each ticket gets: project, component, type, priority, a title (summary) and a
# description built from VALUE + NOTES + ACCEPTANCE. Size is never set.
#
# ---------------------------------------------------------------------------
# STORIES FILE FORMAT (see stories.example.txt)
# ---------------------------------------------------------------------------
#   - Lines starting with '#' are comments.
#   - Stories are separated by a line that is just: ---
#   - Single-line fields are "sticky": set them once and they apply to every
#     following story until you change them:
#         PROJECT:    CKM
#         COMPONENT:  privacy-policy-administration-service
#         TYPE:       Technical Debt
#         PRIORITY:   Standard
#         EPIC:       CKM-12345   (attach each story to this existing epic;
#                                  set to a new key to switch, or blank to clear)
#   - Per-story fields (reset for each story):
#         TITLE:      <one-line summary>          (required)
#         VALUE:      <value statement>           (multi-line)
#         NOTES:      <notes / fix>               (multi-line)
#         ACCEPTANCE: <acceptance criteria>       (multi-line)
#   - Optional per-story fields (reset for each story; omit to leave unset):
#         FIX_VERSION:       <release name, e.g. "privacy-policy-administration-service: Release A">
#         STORY_POINTS:      <number, e.g. 3>
#         ORIGINAL_ESTIMATE: <time, e.g. 2d, 4h, 30m>
#         ID:                <local alias for THIS story, e.g. "skeleton">
#         AFTER:             <alias(es)/key(s) this story must be done after,
#                             e.g. "skeleton, CKM-40001" -- comma/space separated>
#   - Multi-line sections continue on following lines until the next field or
#     the --- separator.
#
# ---------------------------------------------------------------------------
# EPICS & DEPENDENCIES
# ---------------------------------------------------------------------------
#   EPIC:  attaches the created story to an ALREADY-EXISTING epic (passed to
#          jira-cli as --parent). It is sticky, so one EPIC line near the top
#          links the whole batch.
#
#   ID: / AFTER:  express ordering ("this story has to be done after that one")
#          WITHOUT needing real ticket numbers up front. Give a story a local
#          ID:, then reference it from another story's AFTER:. After all stories
#          are created, each AFTER reference is resolved to the real key and a
#          Jira link is created so the dependency shows as:
#              <this story>  is blocked by  <the AFTER story>
#          AFTER may also reference a real issue key directly (e.g. CKM-40001).
#          The link type defaults to "Blocks"; override with LINK_TYPE=... .
#          Dependency links are only created for stories created in THIS run
#          (re-running the file will not duplicate existing links).
#
# ---------------------------------------------------------------------------
# DUPLICATE PROTECTION
# ---------------------------------------------------------------------------
#   After a story is created the script writes the new key back into this file
#   as a "TICKET NUMBER:" line above that story's TITLE, e.g.:
#         TICKET NUMBER: CKM-31299
#         TITLE: Add metrics via Micrometer
#   On later runs any story that already has a TICKET NUMBER is skipped, so the
#   same file can be re-run safely (e.g. after adding more stories) without
#   creating duplicates. You normally never type TICKET NUMBER yourself.
#
# ---------------------------------------------------------------------------
# USAGE
# ---------------------------------------------------------------------------
#   ./create-jira-stories.sh stories.txt          # create the tickets
#   DRY_RUN=1 ./create-jira-stories.sh stories.txt # preview, create nothing
#   LINK_TYPE="Blocks" ./create-jira-stories.sh stories.txt # override link type
#
# Resilience knobs (env vars):
#   THROTTLE_SECONDS  seconds to pause after each successful create (default 1).
#                     Corporate Jira DC often sits behind a proxy/WAF that returns
#                     a 200-OK HTML interstitial for rapid back-to-back POSTs;
#                     jira-cli then reports "Received unexpected response '200 OK'".
#   CREATE_RETRIES    total attempts per story on a transient error (default 3).
#   RETRY_BACKOFF     seconds to wait between retries (default 5).
#   STOP_ON_ERROR     1 = abort the whole run if a story fails after retries.
#                     0 (default) = record the failure, skip that story, and keep
#                     going; failed stories are listed at the end and the script
#                     exits non-zero. Re-running the same file retries only them.
#   SKIP_PREFLIGHT    1 = skip the pre-run validation scan (not recommended).
#                     The preflight aborts before creating anything if it finds
#                     WAF-risky "${...:...}" tokens, over-long/empty/duplicate
#                     TITLEs, a title-less story block, or CRLF line endings.
#
# Prerequisites: jira-cli installed, `jira init` done, JIRA_API_TOKEN exported.

set -euo pipefail

STORIES_FILE="${1:-stories.txt}"
if [[ ! -f "$STORIES_FILE" ]]; then
  echo "ERROR: stories file not found: $STORIES_FILE" >&2
  exit 1
fi

# Sticky (carry across stories) ---------------------------------------------
project=""; component=""; type=""; priority=""; epic=""
# Per-story (reset each story) ----------------------------------------------
title=""; value=""; notes=""; acceptance=""; section=""
fix_version=""; story_points=""; original_estimate=""; ticket=""
story_id=""; after_refs=""
created=0; skipped=0; failed=0

# Records "KEY<TAB>title" for every ticket created in this run; consumed at the
# end to annotate the stories file with TICKET NUMBER: lines.
MAP_FILE="$(mktemp)"
# Records "alias<TAB>key" for every story that has an ID: (created this run or
# already created), so AFTER: references can be resolved to real keys.
ALIAS_FILE="$(mktemp)"
# Records "storyKey<TAB>afterRef" for the AFTER: dependencies of stories created
# in this run; resolved into Jira links once every key is known.
LINK_FILE="$(mktemp)"
# Records the title of every story that could NOT be created this run (after
# retries), so the end-of-run summary can list them for a follow-up re-run.
FAIL_FILE="$(mktemp)"
# On exit -- including an early error exit -- stamp any tickets already created in
# this run back into the stories file BEFORE removing the temp files. This is what
# guarantees a mid-batch failure never leaves a created ticket un-recorded (which
# would otherwise recreate it as a duplicate on the next run).
cleanup() {
  local rc=$?
  [[ "${DRY_RUN:-0}" == "1" ]] || annotate_stories_file || true
  rm -f "$MAP_FILE" "$ALIAS_FILE" "$LINK_FILE" "$FAIL_FILE"
  return $rc
}
trap cleanup EXIT

trim() {
  local s="$1"
  s="${s#"${s%%[![:space:]]*}"}"
  s="${s%"${s##*[![:space:]]}"}"
  printf '%s' "$s"
}

reset_story() {
  title=""; value=""; notes=""; acceptance=""; section=""
  fix_version=""; story_points=""; original_estimate=""; ticket=""
  story_id=""; after_refs=""
}

# Queue this story's AFTER: dependencies (comma/space separated) against the
# given key/alias, one "sref<TAB>ref" line per dependency, for later linking.
record_after() {
  local sref="$1" r
  [[ -n "$after_refs" ]] || return 0
  for r in ${after_refs//,/ }; do
    r="$(trim "$r")"
    [[ -n "$r" ]] && printf '%s\t%s\n' "$sref" "$r" >> "$LINK_FILE"
  done
  return 0
}

append_section() {
  local line="$1"
  case "$section" in
    VALUE)      value+="${value:+$'\n'}$line" ;;
    NOTES)      notes+="${notes:+$'\n'}$line" ;;
    ACCEPTANCE) acceptance+="${acceptance:+$'\n'}$line" ;;
  esac
}

build_body() {
  local body="" v="$value" n="$notes" a="$acceptance"
  # strip trailing blank lines/whitespace captured before the next field
  v="${v%"${v##*[![:space:]]}"}"
  n="${n%"${n##*[![:space:]]}"}"
  a="${a%"${a##*[![:space:]]}"}"
  [[ -n "$v" ]] && body+="$v"$'\n'
  if [[ -n "$n" ]]; then
    [[ -n "$body" ]] && body+=$'\n'
    body+="Notes / Fix:"$'\n'"$n"$'\n'
  fi
  if [[ -n "$a" ]]; then
    [[ -n "$body" ]] && body+=$'\n'
    body+="Acceptance Criteria:"$'\n'"$a"$'\n'
  fi
  printf '%s' "$body"
}

# Best-effort lookup of an already-created issue by exact summary, scoped to this
# project/component and the last hour, used to recover from a transient create
# error WITHOUT risking a duplicate. Prints the key (or nothing). Never fails the
# script (callers add "|| true").
find_existing_key() {
  local summary="$1" q
  q="${summary//\\/}"; q="${q//\"/}"   # strip chars that would break the JQL string
  jira issue list \
      -q "project = \"$project\" AND component = \"$component\" AND created >= -1h AND summary ~ \"$q\"" \
      --plain --no-headers --columns key,summary 2>/dev/null \
  | awk -v want="$summary" '
      {
        key=$1
        rest=$0; sub(/^[^[:space:]]+[[:space:]]+/, "", rest)   # drop the key column
        gsub(/^[[:space:]]+|[[:space:]]+$/, "", rest)
        if (rest == want) { print key; exit }
      }'
}

emit() {
  [[ -z "$title" ]] && { reset_story; return; }  # nothing accumulated yet

  # Already created on a previous run: a TICKET NUMBER is recorded for it.
  if [[ -n "$ticket" ]]; then
    echo "-- skipping (already created as $ticket): $title"
    skipped=$((skipped + 1))
    # Still record this story's alias so a newly-created story can resolve an
    # AFTER dependency that points at an already-created story.
    [[ -n "$story_id" ]] && printf '%s\t%s\n' "$story_id" "$ticket" >> "$ALIAS_FILE"
    reset_story
    return
  fi

  local missing=()
  [[ -z "$project" ]]   && missing+=("PROJECT")
  [[ -z "$component" ]] && missing+=("COMPONENT")
  [[ -z "$type" ]]      && missing+=("TYPE")
  [[ -z "$priority" ]]  && missing+=("PRIORITY")
  if (( ${#missing[@]} )); then
    echo "ERROR: story \"$title\" is missing required field(s): ${missing[*]}" >&2
    exit 1
  fi

  local body; body="$(build_body)"

  # Optional flags, only added when the field is set.
  local extra=()
  [[ -n "$epic" ]]             && extra+=(--parent "$epic")
  [[ -n "$fix_version" ]]       && extra+=(--fix-version "$fix_version")
  [[ -n "$story_points" ]]      && extra+=(--custom "story-points=$story_points")
  [[ -n "$original_estimate" ]] && extra+=(--original-estimate "$original_estimate")

  if [[ "${DRY_RUN:-0}" == "1" ]]; then
    echo "------------------------------------------------------------"
    echo "project=$project | type=$type | priority=$priority"
    echo "component=$component"
    echo "summary=$title"
    [[ -n "$epic" ]]             && echo "epic (parent)=$epic"
    [[ -n "$story_id" ]]          && echo "id=$story_id"
    [[ -n "$after_refs" ]]        && echo "after (blocked by)=$after_refs"
    [[ -n "$fix_version" ]]       && echo "fix-version=$fix_version"
    [[ -n "$story_points" ]]      && echo "story-points=$story_points"
    [[ -n "$original_estimate" ]] && echo "original-estimate=$original_estimate"
    echo "body:"
    echo "$body"
    echo
    # Record alias + AFTER refs so the dependency preview/validation can run.
    [[ -n "$story_id" ]] && printf '%s\t%s\n' "$story_id" "$story_id" >> "$ALIAS_FILE"
    record_after "${story_id:-$title}"
    created=$((created + 1))
  else
    echo ">> creating: $title"
    local out="" key="" attempt=1 max="${CREATE_RETRIES:-3}"
    while :; do
      if out="$(jira issue create \
          --project "$project" \
          --type "$type" \
          --priority "$priority" \
          --component "$component" \
          --summary "$title" \
          --body "$body" \
          "${extra[@]+"${extra[@]}"}" \
          --no-input </dev/null 2>&1)"; then
        printf '%s\n' "$out"
        # Extract the new issue key (e.g. CKM-1234) from jira's output / URL so we
        # can stamp it back into the stories file and never recreate this story.
        key="$(printf '%s' "$out" | grep -oE '[A-Z][A-Z0-9]+-[0-9]+' | tail -n1 || true)"
        break
      fi

      printf '%s\n' "$out" >&2

      # "Received unexpected response '200 OK'" (and similar) is almost always a
      # transient proxy/WAF/rate-limit interstitial, NOT a real failure -- and the
      # issue usually was NOT created. First make sure we didn't actually create it
      # (avoids duplicates), then retry with backoff.
      if printf '%s' "$out" | grep -qiE 'unexpected response'; then
        key="$(find_existing_key "$title" || true)"
        if [[ -n "$key" ]]; then
          echo "-- recovered after a transient error; already created as $key: $title"
          break
        fi
        if (( attempt < max )); then
          echo "-- transient error creating \"$title\"; retry $attempt/$((max - 1)) in ${RETRY_BACKOFF:-5}s..." >&2
          sleep "${RETRY_BACKOFF:-5}"
          attempt=$((attempt + 1))
          continue
        fi
      fi

      # Out of retries, or a non-transient error. By default, record the failure
      # and move on to the next story so one bad story doesn't abort the whole
      # batch (already-created tickets are still stamped by the EXIT trap). Set
      # STOP_ON_ERROR=1 to abort the run immediately instead.
      printf '%s\n' "$title" >> "$FAIL_FILE"
      failed=$((failed + 1))
      echo "ERROR: jira issue create failed for: $title" >&2
      if [[ "${STOP_ON_ERROR:-0}" == "1" ]]; then
        exit 1
      fi
      echo "-- skipping this story and continuing with the next one." >&2
      reset_story
      return
    done

    if [[ -n "$key" ]]; then
      printf '%s\t%s\n' "$key" "$title" >> "$MAP_FILE"
      # Map this story's alias to its new key, and queue its AFTER dependencies
      # (linked at the end, once every story's key is known).
      [[ -n "$story_id" ]] && printf '%s\t%s\n' "$story_id" "$key" >> "$ALIAS_FILE"
      record_after "$key"
    else
      echo "WARNING: created '$title' but could not parse its ticket key from the" >&2
      echo "         jira output; the stories file will NOT be annotated for it." >&2
    fi
    created=$((created + 1))
    [[ "${THROTTLE_SECONDS:-1}" == "0" ]] || sleep "${THROTTLE_SECONDS:-1}"
  fi

  reset_story
}

# Writes "TICKET NUMBER: <key>" immediately above the matching TITLE line for
# every ticket created this run, editing the stories file in place.
annotate_stories_file() {
  [[ -s "$MAP_FILE" ]] || return 0
  local tmp; tmp="$(mktemp)"
  awk -v mapfile="$MAP_FILE" '
    BEGIN {
      while ((getline ln < mapfile) > 0) {
        t = index(ln, "\t")
        if (t > 0) key[substr(ln, t + 1)] = substr(ln, 1, t - 1)
      }
    }
    {
      if ($0 ~ /^[[:space:]]*TITLE:[[:space:]]*/) {
        ws = $0;  sub(/[^[:space:]].*$/, "", ws)               # leading indent
        ttl = $0; sub(/^[[:space:]]*TITLE:[[:space:]]*/, "", ttl)
        sub(/[[:space:]]+$/, "", ttl)
        if (ttl in key) { print ws "TICKET NUMBER: " key[ttl]; delete key[ttl] }
      }
      print
    }
  ' "$STORIES_FILE" > "$tmp" && cat "$tmp" > "$STORIES_FILE"
  rm -f "$tmp"
}

# Resolves an AFTER reference (an alias or a real key) to a real issue key.
# Real keys (e.g. CKM-123) are returned as-is; aliases are looked up in ALIAS_FILE.
resolve_ref() {
  local ref="$1"
  if [[ "$ref" =~ ^[A-Z][A-Z0-9]+-[0-9]+$ ]]; then
    printf '%s' "$ref"; return 0
  fi
  awk -F'\t' -v a="$ref" '$1==a{print $2; exit}' "$ALIAS_FILE"
}

# Creates the "has to be done after" links (default type: Blocks) for stories
# created in this run: "<afterRef> Blocks <thisStory>" == "<thisStory> is blocked
# by <afterRef>". Only runs on real (non dry) runs.
create_dependency_links() {
  [[ -s "$LINK_FILE" ]] || return 0
  local storykey ref depkey out linked=0
  while IFS=$'\t' read -r storykey ref; do
    depkey="$(resolve_ref "$ref")"
    if [[ -z "$depkey" ]]; then
      echo "WARNING: could not resolve AFTER dependency '$ref' for $storykey; skipping link." >&2
      continue
    fi
    [[ "$depkey" == "$storykey" ]] && continue
    echo ">> linking: $storykey is blocked by $depkey  ($depkey ${LINK_TYPE:-Blocks} $storykey)"
    if ! out="$(jira issue link "$depkey" "$storykey" "${LINK_TYPE:-Blocks}" 2>&1 </dev/null)"; then
      printf '%s\n' "$out" >&2
      echo "WARNING: failed to link $depkey -> $storykey (it may already exist); continuing." >&2
    else
      linked=$((linked + 1))
    fi
  done < "$LINK_FILE"
  (( linked > 0 )) && echo "Linked ${linked} dependency relationship(s)."
  return 0
}

# Dry-run: previews the AFTER links and validates that every reference resolves
# to a known story ID or a real issue key; exits non-zero if any do not.
preview_dependency_links() {
  [[ -s "$LINK_FILE" ]] || return 0
  echo "------------------------------------------------------------"
  echo "Dependency links to create (\"has to be done after\" => ${LINK_TYPE:-Blocks}):"
  local storyref ref unresolved=0
  while IFS=$'\t' read -r storyref ref; do
    if [[ -n "$(resolve_ref "$ref")" ]]; then
      echo "  \"$storyref\" is blocked by \"$ref\"   [ok]"
    else
      echo "  \"$storyref\" is blocked by \"$ref\"   [UNRESOLVED]" >&2
      unresolved=$((unresolved + 1))
    fi
  done < "$LINK_FILE"
  if (( unresolved > 0 )); then
    echo "ERROR: ${unresolved} AFTER dependency reference(s) did not match any story ID or issue key." >&2
    echo "       Fix the AFTER: values (use a matching ID: or a real issue key) and retry." >&2
    exit 1
  fi
  return 0
}

# ---------------------------------------------------------------------------
# PREFLIGHT
# ---------------------------------------------------------------------------
# Scans the WHOLE stories file once, before creating anything, for problems that
# would otherwise fail (or silently drop) a story mid-run. Hard-fails with a list
# of every issue so they can all be fixed in one pass. Set SKIP_PREFLIGHT=1 to
# bypass (not recommended). Checks:
#   - WAF-risky "${...:...}" tokens (colon inside braces == Log4Shell/JNDI
#     signature). The proxy in front of Jira returns an HTML 200 for these and
#     jira-cli reports "Received unexpected response '200 OK'" -- the story is
#     never created. (An empty default "${VAR:}" and a bare "${VAR}" are fine.)
#   - TITLE longer than Jira's 255-char summary limit.
#   - Empty TITLE.
#   - Duplicate TITLE text (breaks TICKET NUMBER stamping / dedup, which matches
#     on the title).
#   - A story block that has VALUE/NOTES/ACCEPTANCE but no TITLE (emit() would
#     silently skip it).
#   - CR / CRLF line endings (trailing \r corrupts field values).
preflight() {
  [[ "${SKIP_PREFLIGHT:-0}" == "1" ]] && return 0
  local report rc
  report="$(awk '
    function trim(s){ sub(/^[[:space:]]+/,"",s); sub(/[[:space:]]+$/,"",s); return s }
    function checkblock(   msg) {
      if (bcontent && btitle == 0)
        printf("  [near line %d] story block has VALUE/NOTES/ACCEPTANCE but no TITLE (would be skipped)\n", bstart)
    }
    BEGIN { err = 0; maxlen = 255; bstart = 1 }
    {
      raw = $0
      if (raw ~ /\r/) { crlf = 1; gsub(/\r/, "", raw) }
      line = raw

      is_comment = (line ~ /^[[:space:]]*#/)

      if (line ~ /^[[:space:]]*---/) { checkblock(); btitle = 0; bcontent = 0; bstart = NR + 1; next }
      if (is_comment) next

      # WAF-risky token on any content line (summary or body).
      if (line ~ /\$\{[^}]+:[^}]+\}/) {
        printf("  [line %d] WAF-risky \"${...:...}\" token (rewrite to split ${VAR} + :default): %s\n", NR, trim(line))
        err++
      }

      if (line ~ /^[[:space:]]*(VALUE|NOTES|ACCEPTANCE):/) bcontent = 1

      if (line ~ /^[[:space:]]*TITLE:[[:space:]]*/) {
        btitle++
        t = line; sub(/^[[:space:]]*TITLE:[[:space:]]*/, "", t); t = trim(t)
        if (length(t) == 0) { printf("  [line %d] empty TITLE\n", NR); err++ }
        else {
          if (length(t) > maxlen) { printf("  [line %d] TITLE is %d chars (> %d Jira summary limit): %s\n", NR, length(t), maxlen, t); err++ }
          if (t in seen) { printf("  [line %d] duplicate TITLE (first at line %d): %s\n", NR, seen[t], t); err++ }
          else seen[t] = NR
        }
      }
    }
    END {
      checkblock()
      if (crlf) { print "  file has CR/CRLF line endings; convert to LF (e.g. dos2unix)"; err++ }
      exit (err > 0) ? 1 : 0
    }
  ' "$STORIES_FILE")" && rc=0 || rc=1

  if (( rc != 0 )); then
    echo "ERROR: preflight found problem(s) in $STORIES_FILE that would break creation:" >&2
    printf '%s\n' "$report" >&2
    echo "Fix the above (or re-run with SKIP_PREFLIGHT=1 to bypass) and try again." >&2
    exit 1
  fi
  return 0
}

preflight

while IFS= read -r line <&3 || [[ -n "$line" ]]; do
  case "$line" in
    \#*)   continue ;;        # comment
    ---*)  emit; continue ;;  # story separator
  esac

  if [[ "$line" =~ ^(PROJECT|COMPONENT|TYPE|PRIORITY|EPIC|TITLE|ID|AFTER|TICKET\ NUMBER|FIX_VERSION|STORY_POINTS|ORIGINAL_ESTIMATE|VALUE|NOTES|ACCEPTANCE):(.*)$ ]]; then
    key="${BASH_REMATCH[1]}"
    rest="$(trim "${BASH_REMATCH[2]}")"
    case "$key" in
      PROJECT)           project="$rest";           section="" ;;
      COMPONENT)         component="$rest";         section="" ;;
      TYPE)              type="$rest";              section="" ;;
      PRIORITY)          priority="$rest";          section="" ;;
      EPIC)              epic="$rest";              section="" ;;
      TITLE)             title="$rest";             section="" ;;
      ID)                story_id="$rest";          section="" ;;
      AFTER)             after_refs="$rest";        section="" ;;
      "TICKET NUMBER")   ticket="$rest";            section="" ;;
      FIX_VERSION)       fix_version="$rest";       section="" ;;
      STORY_POINTS)      story_points="$rest";      section="" ;;
      ORIGINAL_ESTIMATE) original_estimate="$rest"; section="" ;;
      VALUE)      section="VALUE";      [[ -n "$rest" ]] && append_section "$rest" ;;
      NOTES)      section="NOTES";      [[ -n "$rest" ]] && append_section "$rest" ;;
      ACCEPTANCE) section="ACCEPTANCE"; [[ -n "$rest" ]] && append_section "$rest" ;;
    esac
  else
    # continuation line for the current multi-line section
    [[ -n "$section" ]] && append_section "$line"
  fi
done 3< "$STORIES_FILE"

emit  # flush the final story if the file did not end with ---

# Newly created ticket numbers are stamped back into the stories file by the EXIT
# trap (see cleanup), so annotation happens even if a story fails mid-batch.

# Create (real run) or preview+validate (dry run) the AFTER dependency links.
if [[ "${DRY_RUN:-0}" == "1" ]]; then
  preview_dependency_links
else
  create_dependency_links
fi

echo "Done. ${created} ticket(s) $([[ "${DRY_RUN:-0}" == "1" ]] && echo 'previewed' || echo 'created'), ${skipped} skipped (already created), ${failed} failed."

if (( failed > 0 )); then
  echo "The following story/stories FAILED to create (re-run the same file to retry them):" >&2
  sed 's/^/  - /' "$FAIL_FILE" >&2
  exit 1
fi
