#!/bin/bash
#
# multi-repo-pipeline.sh
# Orchestrates worktree checkout, branch creation, commit, and PR
# across one primary repo plus optional dev-context repos.
#
# Subcommands:
#   prepare      <state.json>   create worktrees + branches for all repos
#   bridge       <state.json>   emit agent-state.json v2.1.0 (projects[]) for Phase 0 consumption
#   commit       <state.json>   create commit per repo (uses staged changes in each)
#   push         <state.json>   push branches to origin per repo
#   pr           <state.json>   create PR per repo with cross-links
#   teardown     <state.json>   remove worktrees (preserves logs)
#
# Picker state.json schema (input):
#   {
#     "taskId":"PROJ-1-20260427",
#     "branch":"feature/PROJ-1",
#     "baseBranch":"develop",
#     "issueRef":{"kind":"jira","key":"PROJ-1","title":"...", ...},
#                       (may carry relatedIssues[] from issue-fetcher.sh)
#     "primary":   {"name":"my-ios-app","cloneUrl":"...","provider":"github"},
#     "extras":   [{"name":"common","cloneUrl":"...","provider":"github"}, ...],
#     "worktreeRoot":".worktrees/PROJ-1-20260427",
#     "commitType":"feat",
#     "identity":{"name":"...","email":"..."}
#   }
#
# `bridge` writes agent-state.json v2.1.0 with projects[] populated  -  Phase 0
# consumes this and skips its own picker steps.

set -euo pipefail

CMD="${1:-}"
STATE="${2:-}"
[ -z "$CMD" ] || [ -z "$STATE" ] && {
  echo "usage: $0 <prepare|bridge|commit|push|pr|teardown> <state.json>" >&2
  exit 1
}
[ ! -f "$STATE" ] && { echo "ERR: state file not found: $STATE" >&2; exit 2; }

list_repos_jq() {
  python3 -c "
import json,sys
d=json.load(open('$STATE'))
repos=[d['primary']] + d.get('extras',[])
for r in repos:
    print(r['name']+'\t'+r['cloneUrl']+'\t'+r.get('provider','git'))
"
}

WORKTREE_ROOT=$(python3 -c "import json; d=json.load(open('$STATE')); print(d['worktreeRoot'])")
BRANCH=$(python3 -c "import json; d=json.load(open('$STATE')); print(d['branch'])")
TASK_ID=$(python3 -c "import json; d=json.load(open('$STATE')); print(d['taskId'])")
COMMIT_TYPE=$(python3 -c "import json; d=json.load(open('$STATE')); print(d.get('commitType','feat'))")
ISSUE_KEY=$(python3 -c "import json; d=json.load(open('$STATE')); print(d['issueRef'].get('key') or '')")
ISSUE_TITLE=$(python3 -c "import json; d=json.load(open('$STATE')); print(d['issueRef'].get('title') or '')")
IDENTITY_NAME=$(python3 -c "import json; d=json.load(open('$STATE')); print((d.get('identity') or {}).get('name') or '')")
IDENTITY_EMAIL=$(python3 -c "import json; d=json.load(open('$STATE')); print((d.get('identity') or {}).get('email') or '')")

cmd_prepare() {
  mkdir -p "$WORKTREE_ROOT"
  # Process substitution (not a pipe) so the loop runs in THIS shell  -  a piped
  # `while` runs in a subshell, so the `return 3` below would only exit the
  # subshell, not cmd_prepare, and a clone failure could not abort the function
  # with the intended exit code.
  while IFS=$'\t' read -r name url provider; do
    local target="$WORKTREE_ROOT/$name"
    if [ -d "$target/.git" ]; then
      echo "[$name] worktree already present at $target"
    else
      echo "[$name] cloning into $target"
      # Disable git's `ext::`/`fd::` remote helpers (arbitrary command execution
      # at clone time) in case a repo URL ever comes from an untrusted
      # .gitmodules suggestion, and end options with `--` so a URL like
      # `--upload-pack=...` can't inject a clone option. https/ssh/git@ and
      # local/file paths all still work.
      git -c protocol.ext.allow=never -c protocol.fd.allow=never clone --quiet -- "$url" "$target" || { echo "ERR: clone failed for $name" >&2; return 3; }
    fi
    cd "$target"
    if git rev-parse --verify "$BRANCH" >/dev/null 2>&1; then
      echo "[$name] branch $BRANCH already exists, checking out"
      git checkout --quiet "$BRANCH"
    else
      git checkout --quiet -b "$BRANCH"
    fi
    cd - >/dev/null
  done < <(list_repos_jq)
}

cmd_commit() {
  # Process substitution (not a pipe) so the `failed` counter survives the
  # loop  -  a piped `while` runs in a subshell and the summary exit would
  # always be 0 regardless of per-repo failures.
  local failed=0
  # state.identity is the commit author/committer, as documented in the schema
  # above. Git's own config is the fallback when the picker recorded none.
  local identity_args=()
  if [ -n "$IDENTITY_NAME" ] && [ -n "$IDENTITY_EMAIL" ]; then
    identity_args=(-c "user.name=$IDENTITY_NAME" -c "user.email=$IDENTITY_EMAIL")
  fi
  while IFS=$'\t' read -r name url provider; do
    local target="$WORKTREE_ROOT/$name"
    [ ! -d "$target" ] && continue
    cd "$target"
    # Only what the dev session staged is committed. A blanket `git add -A`
    # here swept build output and nested-worktree gitlinks into the commit.
    if git diff --cached --quiet 2>/dev/null; then
      if [ -n "$(git status --porcelain 2>/dev/null)" ]; then
        echo "[$name] nothing staged (unstaged or untracked changes present)  -  stage what belongs in the commit, skipping"
      else
        echo "[$name] no changes, skipping commit"
      fi
      cd - >/dev/null
      continue
    fi
    local subject="$COMMIT_TYPE($name): $ISSUE_TITLE"
    [ -n "$ISSUE_KEY" ] && subject="$subject [$ISSUE_KEY]"
    # Trim subject to 72 chars
    subject=$(printf '%s' "$subject" | cut -c1-72)
    if git ${identity_args[@]+"${identity_args[@]}"} commit --quiet -m "$subject"; then
      echo "[$name] committed: $subject"
    else
      echo "ERR: [$name] commit failed" >&2
      failed=$((failed + 1))
    fi
    cd - >/dev/null
  done < <(list_repos_jq)
  if [ "$failed" -gt 0 ]; then
    echo "ERR: commit failed in $failed repo(s)" >&2
    return 3
  fi
}

cmd_push() {
  local failed=0
  while IFS=$'\t' read -r name url provider; do
    local target="$WORKTREE_ROOT/$name"
    [ ! -d "$target" ] && continue
    cd "$target"
    if git push --quiet -u origin "$BRANCH"; then
      echo "[$name] pushed $BRANCH"
    else
      echo "ERR: [$name] push failed" >&2
      failed=$((failed + 1))
    fi
    cd - >/dev/null
  done < <(list_repos_jq)
  if [ "$failed" -gt 0 ]; then
    echo "ERR: push failed in $failed repo(s)" >&2
    return 3
  fi
}

cmd_pr() {
  # Open PR for extras first, then primary with cross-links.
  local primary_name failed=0
  primary_name=$(python3 -c "import json; d=json.load(open('$STATE')); print(d['primary']['name'])")
  # Extra-repo PR URLs are passed to the primary PR via the _prs.log file below.
  while IFS=$'\t' read -r name url provider; do
    if [ "$name" = "$primary_name" ]; then continue; fi
    local target="$WORKTREE_ROOT/$name"
    [ ! -d "$target" ] && continue
    cd "$target"
    if [ "$provider" = "github" ] && command -v gh >/dev/null 2>&1; then
      local body="Ref: $ISSUE_KEY
Part of multi-repo task $TASK_ID."
      # Capture output first: in `gh ... | tee ... || echo failed` the `||`
      # binds to tee, so a gh failure was silently reported as success.
      local pr_out
      if pr_out=$(gh pr create --title "$COMMIT_TYPE($name): $ISSUE_TITLE" --body "$body" --fill 2>/dev/null); then
        printf '%s\n' "$pr_out" | tee -a "$WORKTREE_ROOT/_prs.log"
        echo "[$name] PR created"
      else
        echo "ERR: [$name] gh pr create failed" >&2
        failed=$((failed + 1))
      fi
    else
      echo "[$name] PR creation skipped (provider=$provider)"
    fi
    cd - >/dev/null
  done < <(list_repos_jq)
  # Now primary
  local target="$WORKTREE_ROOT/$primary_name"
  cd "$target"
  local extras_links=""
  if [ -f "$WORKTREE_ROOT/_prs.log" ]; then
    extras_links=$(grep -Eo 'https://github.com/[^ ]+/pull/[0-9]+' "$WORKTREE_ROOT/_prs.log" | sed 's/^/Related: /')
  fi
  local body="Ref: $ISSUE_KEY
$extras_links"
  if command -v gh >/dev/null 2>&1; then
    if gh pr create --title "$COMMIT_TYPE($primary_name): $ISSUE_TITLE" --body "$body" --fill 2>/dev/null; then
      echo "[$primary_name] PR created"
    else
      echo "ERR: [$primary_name] gh pr create failed" >&2
      failed=$((failed + 1))
    fi
  fi
  cd - >/dev/null
  if [ "$failed" -gt 0 ]; then
    echo "ERR: PR creation failed for $failed repo(s)" >&2
    return 3
  fi
}

# Resolve a directory to its physical path ("" when it does not resolve).
resolve_dir() { (cd "$1" 2>/dev/null && pwd -P); }

cmd_teardown() {
  if [ ! -d "$WORKTREE_ROOT" ]; then
    echo "[teardown] nothing to remove ($WORKTREE_ROOT not a directory)"
    return 0
  fi
  # Guard the rm -rf: worktreeRoot comes from state.json, so a corrupted or
  # malicious value ("." / ".." / "~") must never delete the project, the
  # home directory, or the filesystem root. Resolve the physical path and
  # refuse when it equals or contains the current project root or $HOME.
  local rp cwd home_rp
  rp=$(resolve_dir "$WORKTREE_ROOT") || rp=""
  cwd=$(pwd -P)
  home_rp=$(resolve_dir "$HOME") || home_rp="$HOME"
  if [ -z "$rp" ] || [ "$rp" = "/" ] || [ "$rp" = "$home_rp" ] || [ "$rp" = "$cwd" ]; then
    echo "ERR: refusing teardown  -  unsafe worktreeRoot '$WORKTREE_ROOT' (resolved: '${rp:-unresolvable}')" >&2
    return 4
  fi
  case "$cwd/" in
    "$rp"/*)
      echo "ERR: refusing teardown  -  '$rp' contains the current project root" >&2
      return 4
      ;;
  esac
  case "$home_rp/" in
    "$rp"/*)
      echo "ERR: refusing teardown  -  '$rp' contains the home directory" >&2
      return 4
      ;;
  esac
  echo "[teardown] removing $rp (logs preserved at ~/.claude/logs/multi-agent/$TASK_ID/)"
  rm -rf "$rp"
}

# Emit agent-state.json v2.1.0 from picker state.json so Phase 0 can consume it directly.
# Logs path: $HOME/.claude/logs/multi-agent/{primary.name}/{taskId}/agent-state.json
cmd_bridge() {
  python3 - "$STATE" <<'PY'
import json, os, sys, time
state = json.load(open(sys.argv[1]))

primary = state["primary"]
extras  = state.get("extras", [])
all_repos = [primary] + extras
worktree_root = os.path.expanduser(state["worktreeRoot"])
identity = state.get("identity", {"name": "", "email": ""})

def repo_entry(r, is_primary=False):
    return {
        "project":      r["name"],
        "projectRoot":  os.path.join(worktree_root, r["name"]),
        "worktreePath": os.path.join(worktree_root, r["name"]),
        "branch":       state["branch"],
        "baseBranch":   state.get("baseBranch", "develop"),
        "remoteType":   r.get("provider", "github"),
        "platform":     r.get("provider", "github"),
        "identity":     identity,
        "commit":       None,
        "pr":           None,
        "pushAttempts": 0,
        "buildStatus":  "pending",
    }

issue = state.get("issueRef", {})
gh_issue = None
if issue.get("kind") == "github" and issue.get("owner") and issue.get("repo") and issue.get("key"):
    try:
        gh_issue = {
            "owner":  issue["owner"],
            "repo":   issue["repo"],
            "number": int(issue["key"]),
        }
    except (ValueError, TypeError):
        gh_issue = None

agent_state = {
    "schemaVersion": "2.1.0",
    "taskId":        state["taskId"],
    "shortId":       state.get("shortId", 1),
    "jiraId":        issue.get("key") if issue.get("kind") == "jira" else None,
    "githubIssue":   gh_issue,
    "project":       primary["name"],
    "projectRoot":   os.path.join(worktree_root, primary["name"]),
    "worktreePath":  os.path.join(worktree_root, primary["name"]),
    "branch":        state["branch"],
    "baseBranch":    state.get("baseBranch", "develop"),
    "remoteType":    primary.get("provider", "github"),
    "currentPhase":  0,
    "status":        "in_progress",
    "startedAt":     time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
    "phases":        {},
    "identity":      identity,
    "projects":      [repo_entry(r, i == 0) for i, r in enumerate(all_repos)],
}

# Written only when non-empty, so a GitHub or free-text bridge produces the
# same bytes it always did. Phase 1 and Phase 2 read it; without it here the
# context would be intake-only and every resume would start without it.
related = issue.get("relatedIssues") or []
if isinstance(related, list) and related:
    agent_state["relatedIssues"] = related

logs_dir = os.path.expanduser(os.path.join("~/.claude/logs/multi-agent", primary["name"], state["taskId"]))
os.makedirs(logs_dir, exist_ok=True)
out_path = os.path.join(logs_dir, "agent-state.json")
tmp_path = out_path + ".tmp"
with open(tmp_path, "w") as f:
    json.dump(agent_state, f, indent=2, ensure_ascii=False)
os.replace(tmp_path, out_path)
print(out_path)
PY
}

case "$CMD" in
  prepare)  cmd_prepare ;;
  bridge)   cmd_bridge ;;
  commit)   cmd_commit ;;
  push)     cmd_push ;;
  pr)       cmd_pr ;;
  teardown) cmd_teardown ;;
  *) echo "ERR: unknown subcommand $CMD" >&2; exit 1 ;;
esac
