# shellcheck shell=bash

_okstra_ctl_rerun() {
  local yes="false" dry_run="false" use_filter="false"
  local project="all" task_group="all" status="all" since=""
  local project_set="false" task_group_set="false"
  local max_spawn="${OKSTRA_CTL_MAX_SPAWN:-10}"
  local from_stdin="false" use_last="false"
  local -a explicit=()
  local ignore_brief_drift="false"
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --yes) yes="true"; shift ;;
      --dry-run) dry_run="true"; shift ;;
      --filter) use_filter="true"; shift ;;
      --project) project="$2"; project_set="true"; shift 2 ;;
      --task-group) task_group="$2"; task_group_set="true"; shift 2 ;;
      --status) status="$2"; shift 2 ;;
      --since) since="$2"; shift 2 ;;
      --max-spawn) max_spawn="$2"; shift 2 ;;
      --ignore-brief-drift) ignore_brief_drift="true"; shift ;;
      last) use_last="true"; shift ;;
      -) from_stdin="true"; shift ;;
      *) explicit+=("$1"); shift ;;
    esac
  done
  # tmux 부재 검사는 실제 spawn 이 일어나는 경우에만. --dry-run 은 CI/최소 환경에서도
  # 안전 미리보기를 제공해야 하므로 우회한다.
  if [[ "$dry_run" != "true" ]] && ! command -v tmux >/dev/null 2>&1; then
    printf 'rerun: tmux is required but not found\n' >&2; exit 2
  fi
  # 'last' selector 는 부작용 있는 명령이므로 모호한 기본값을 거부한다.
  # 사용자는 --project / --task-group 을 반드시 명시해야 한다 ('all' 와일드카드 허용).
  if [[ "$use_last" == "true" ]]; then
    if [[ "$project_set" != "true" || "$task_group_set" != "true" ]]; then
      printf 'rerun last: --project 와 --task-group 을 반드시 지정해 주십시오 (all 와일드카드 명시 가능)\n' >&2
      exit 2
    fi
  fi

  # stdin selector('-') 사용 시 호출자의 파이프 데이터를 미리 읽어 env 로 전달.
  # python3 - <<'PY' 는 stdin 을 heredoc 으로 사용하므로 자식 프로세스 안에서는
  # sys.stdin 이 EOF 가 되어 caller 의 piped runId 들을 읽을 수 없다.
  local stdin_payload=""
  if [[ "$from_stdin" == "true" ]]; then
    stdin_payload="$(cat)"
  fi

  OKSTRA_HOME_RESOLVED="$(okstra_central_home)" \
  OKSTRA_CTL_LIB_DIR="$SCRIPT_DIR" \
  OKSTRA_SCRIPT="$SCRIPT_DIR/okstra.sh" \
  OK_YES="$yes" OK_DRY="$dry_run" OK_FILTER="$use_filter" \
  OK_PROJECT="$project" OK_TG="$task_group" OK_STATUS="$status" \
  OK_SINCE="$since" OK_MAX="$max_spawn" OK_LAST="$use_last" \
  OK_STDIN="$from_stdin" OK_STDIN_DATA="$stdin_payload" \
  OK_IGNORE_BRIEF_DRIFT="$ignore_brief_drift" \
  OK_EXPLICIT_JSON="$(printf '%s\n' "${explicit[@]:-}" | python3 -c 'import json,sys; print(json.dumps([l.strip() for l in sys.stdin if l.strip()]))')" \
  python3 - <<'PY'
import fcntl, hashlib, json, os, subprocess, sys, time
sys.path.insert(0, os.environ["OKSTRA_CTL_LIB_DIR"])
from pathlib import Path
from okstra_ctl import (
    expand_selectors, find_row_by_run_id, load_invocation,
    predict_next_run_seq, build_run_id, build_tmux_command,
    run_id_to_session_name, task_lock_filename, make_batch_id,
    write_batch_meta, ResolveError, central_lock,
    reserve_run_in_active, remove_reservation,
    slugify_task_segment,
)

home = Path(os.environ["OKSTRA_HOME_RESOLVED"])
explicit = json.loads(os.environ["OK_EXPLICIT_JSON"])
use_filter = os.environ["OK_FILTER"] == "true"
use_last = os.environ["OK_LAST"] == "true"
from_stdin = os.environ["OK_STDIN"] == "true"
max_spawn = int(os.environ["OK_MAX"])
yes = os.environ["OK_YES"] == "true"
dry = os.environ["OK_DRY"] == "true"

try:
    targets = expand_selectors(
        home, explicit=explicit, use_filter=use_filter,
        project=os.environ["OK_PROJECT"], task_group=os.environ["OK_TG"],
        status=os.environ["OK_STATUS"], since=os.environ["OK_SINCE"],
        last=use_last, from_stdin=from_stdin,
        stdin_data=os.environ.get("OK_STDIN_DATA", ""),
    )
except ResolveError as e:
    print(f"rerun: {e}", file=sys.stderr); sys.exit(2)

if not targets:
    print("rerun: no targets selected", file=sys.stderr); sys.exit(2)
if len(targets) > max_spawn:
    print(f"매칭 결과 {len(targets)}건은 임계({max_spawn})를 초과합니다. "
          f"--max-spawn {len(targets)} 로 재실행하거나 선택자를 좁혀 주십시오.",
          file=sys.stderr); sys.exit(2)
if len(targets) > 1 and not yes and not dry:
    print("rerun targets:")
    for t in targets: print(f"  - {t}")
    print("재실행하려면 --yes 를 추가해 주십시오.", file=sys.stderr); sys.exit(2)

ignore_brief_drift = os.environ.get("OK_IGNORE_BRIEF_DRIFT") == "true"

# 현재 okstra-ctl 프로세스의 OKSTRA_HOME / 명시적 ctl-only override 만 base 에
# 둔다. 각 target 의 inv["envOverrides"](원본 run 시점에 캡처된 환경변수) 를
# 로드 시점에 layer 한다.
extra_env_base = {"OKSTRA_HOME": str(home)}
for key in ("OKSTRA_CTL_SKIP_RECONCILE", "OKSTRA_CTL_SKIP_BACKFILL"):
    if os.environ.get(key):
        extra_env_base[key] = os.environ[key]


def _brief_sha256(brief_path):
    try:
        with open(brief_path, "rb") as f:
            return hashlib.sha256(f.read()).hexdigest()
    except OSError:
        return None


def _brief_path_from_argv(argv, cwd, project_root):
    """invocation argv 에서 --task-brief 값을 찾아 절대 경로화한다.
    okstra.sh resolve_brief_path 의 lookup 순서(cwd-relative 우선,
    PROJECT_ROOT fallback) 를 동일하게 mirror 한다 — 그렇지 않으면 사용자가
    프로젝트 밖에서 project-relative --task-brief 로 실행한 run 의 drift
    검사가 'brief missing' 으로 잘못 skip 된다.
    """
    for i, tok in enumerate(argv):
        if tok == "--task-brief" and i + 1 < len(argv):
            raw = argv[i + 1]
            p = Path(raw)
            if p.is_absolute():
                return p if p.is_file() else None
            cwd_rel = Path(cwd) / p if cwd else None
            if cwd_rel and cwd_rel.is_file():
                return cwd_rel
            root_rel = Path(project_root) / p if project_root else None
            if root_rel and root_rel.is_file():
                return root_rel
            return None
    return None


batch_id = make_batch_id()
items = []
spawned = skipped = rejected = 0
# 같은 (project, group, task_id, task_type) 에 속한 다중 rerun 이 같은 batch 내에서
# 같은 seq 를 받지 않도록 메모리상 reservation 추적. tmux spawn 후 detached okstra 가
# 디스크에 manifest/report 를 쓰기 전에 락이 풀리므로 filesystem-only 예측은 충돌한다.
batch_reserved = {}
for original in targets:
    row = find_row_by_run_id(home, original)
    # implementation 은 stage 격리 task-type 이다. 한 run = 런타임에 live
    # registry/consumers 상태로 auto-resolve 되는 단일 stage(stage_targets.resolve_effective_stages)
    # 이므로, cmd-rerun 은 spawn 전에 어느 stage-<N> 가 선택될지 알 수 없어
    # runs/implementation/stage-<N> 경로도 per-stage run_seq 도 예측할 수 없다.
    # OKSTRA_RUN_SEQ_OVERRIDE(run 전체에 단일 seq) 는 per-stage seq 카운터와
    # 구조적으로 양립 불가하고, 원본 invocation 재생은 진짜 rerun 이 아니다
    # (forced --stage N 은 이미 done 이라 거부되고, --stage auto 는 다른 ready
    # stage 로 조용히 전진한다). 따라서 선예약/spawn 이전에 거부하고 신규
    # implementation run 으로 안내한다.
    if row and slugify_task_segment(row.get("taskType", "")) == "implementation":
        items.append({"originalRunId": original, "newRunId": None,
                      "newRunSeq": None, "sessionName": None,
                      "status": "rejected", "spawnedAt": None,
                      "skipReason": "implementation 은 stage 격리되어 okstra-ctl rerun 으로 "
                                    "재실행할 수 없습니다. 다음 ready stage 를 실행하려면 "
                                    "새 implementation run 을 시작하십시오 "
                                    "(okstra.sh --task-type implementation --stage auto)."})
        rejected += 1
        continue
    inv = (load_invocation(home, row["projectId"], row["taskGroup"],
                           row["taskId"], row["taskType"], row["runSeq"])
           if row else None)
    if inv is None or inv.get("backfilled") or not Path(row["projectRoot"]).is_dir():
        items.append({"originalRunId": original, "newRunId": None,
                      "newRunSeq": None, "sessionName": None,
                      "status": "skipped", "spawnedAt": None,
                      "skipReason": "no invocation / backfilled / project root missing"})
        skipped += 1
        continue
    # brief drift 검사: 원래 run 시점에 캡처한 briefSha256 과 현재 brief 파일의
    # 해시가 다르면, --ignore-brief-drift 가 없는 한 skip 하고 사유를 기록한다.
    # 그렇지 않으면 batch/--yes 재실행이 다른 입력으로 silently 분석을 수행한다.
    recorded_sha = inv.get("briefSha256") or ""
    if recorded_sha and not ignore_brief_drift:
        brief_abs = _brief_path_from_argv(inv.get("argv", []),
                                            inv.get("cwd", ""),
                                            row["projectRoot"])
        current_sha = _brief_sha256(brief_abs) if brief_abs else None
        if current_sha is None:
            items.append({"originalRunId": original, "newRunId": None,
                          "newRunSeq": None, "sessionName": None,
                          "status": "skipped", "spawnedAt": None,
                          "skipReason": "brief missing for drift check"})
            skipped += 1
            continue
        if current_sha != recorded_sha:
            items.append({"originalRunId": original, "newRunId": None,
                          "newRunSeq": None, "sessionName": None,
                          "status": "skipped", "spawnedAt": None,
                          "skipReason": "brief drift (use --ignore-brief-drift to override)"})
            skipped += 1
            continue
    lock = home / ".locks" / task_lock_filename(
        row["projectId"], row["taskGroup"], row["taskId"], row["taskType"])
    lock.parent.mkdir(parents=True, exist_ok=True); lock.touch()
    with lock.open("r+") as lf:
        fcntl.flock(lf.fileno(), fcntl.LOCK_EX)
        rkey = (row["projectId"], row["taskGroup"], row["taskId"], row["taskType"])
        used = batch_reserved.setdefault(rkey, set())
        # 중앙 인덱스(active.jsonl) 와 manifest 디렉터리까지 함께 보고 다음 seq 를 계산.
        # 다른 okstra-ctl 프로세스가 이미 예약했거나 in-flight 인 run 의 seq 도 회피된다.
        with central_lock(home):
            next_seq = predict_next_run_seq(
                Path(row["projectRoot"]),
                row["taskGroup"], row["taskId"], row["taskType"],
                home=home, project_id=row["projectId"])
            while next_seq in used:
                next_seq += 1
            used.add(next_seq)
            new_run_id = build_run_id(row["projectId"], row["taskGroup"],
                                      row["taskId"], row["taskType"], next_seq)
            session = run_id_to_session_name(new_run_id)
            # dry-run 이 아닌 경우 reservation 을 중앙 인덱스에 영속한다.
            # 후속 okstra-ctl 호출이 즉시 이 seq 를 회피할 수 있게 된다.
            if not dry:
                # rerun 시 새 run 의 RUN_DIR 은 같은 task-type 디렉터리, FINAL_REPORT 는 새 seq.
                # okstra.sh 는 디렉터리 segment 를 slugify_value 로 정규화하므로
                # (공백/대문자/`/` 등이 포함된 raw taskGroup/taskId 그대로 쓰면
                # 실제 디스크 경로와 어긋나 tail -F 가 영구 wait 하거나 startup
                # 실패 시 row 가 잘못된 경로를 가리킨다) 같은 슬러그로 예측 경로를
                # 만든다. 정상 spawn 시 record_start 가 RUN_DIR_RELATIVE_PATH 로
                # 정확 값을 다시 update 한다.
                slug_group = slugify_task_segment(row["taskGroup"])
                slug_task = slugify_task_segment(row["taskId"])
                slug_type = slugify_task_segment(row["taskType"])
                base_run = (".okstra/tasks/"
                            f"{slug_group}/{slug_task}/runs/{slug_type}")
                final_rel = (f"{base_run}/reports/"
                             f"final-report-{slug_type}-{next_seq:03d}.md")
                # 예약 시점에 final status 경로도 best-effort 로 박아 둔다.
                # RUN_STATUS_SEQ 는 RUN_MANIFESTS_SEQ 와 별개 카운터지만,
                # 정상 run 에선 두 카운터가 함께 advance 한다(render-only/
                # 실패 prep 만 manifest 만 advance). 추후 record_start 가
                # 정확한 finalStatusRel 로 row 를 update 하므로 여기서 값을
                # 비워 두면 spawn 직후 tail 이 status dir 부재로 race-fail
                # 한다 — 기대 경로를 미리 채워 tail -F 가 등장 대기하게 한다.
                final_status_rel = (f"{base_run}/status/"
                                    f"final-{slug_type}-{next_seq:03d}.status")
                reserve_run_in_active(
                    home, project_id=row["projectId"],
                    project_root=row["projectRoot"],
                    task_group=row["taskGroup"], task_id=row["taskId"],
                    task_type=row["taskType"], run_seq=next_seq,
                    when=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
                    run_dir_rel=base_run, final_report_record_rel=final_rel,
                    final_status_rel=final_status_rel,
                )
        # 원본 invocation 시점에 캡처된 envOverrides 를 base 에 layer 한다.
        # 신규 모델은 PROJECT_ROOT 를 project.json 에서 자기 해석하므로
        # invocation-specific 환경변수 override 가 사실상 사라졌지만, 호환을
        # 위해 envOverrides 가 dict 형태로 남아있을 수 있다. base(현재 ctl
        # 환경) 키와 충돌하면 base 가 우선해 사용자의 OKSTRA_HOME 등이 보존된다.
        target_env = {}
        for k, v in (inv.get("envOverrides") or {}).items():
            if isinstance(v, str):
                target_env[k] = v
        target_env.update(extra_env_base)
        cmd = build_tmux_command(session_name=session, cwd=inv["cwd"],
                                 run_seq=next_seq, argv=inv["argv"],
                                 okstra_script=os.environ["OKSTRA_SCRIPT"],
                                 extra_env=target_env)
        if dry:
            items.append({"originalRunId": original, "newRunId": new_run_id,
                          "newRunSeq": next_seq, "sessionName": session,
                          "status": "dry-run", "spawnedAt": None,
                          "skipReason": None})
            continue
        # 핵심 모델: tmux 세션을 detached 로 띄우고 즉시 다음 대상으로 넘어간다.
        rc = subprocess.run(cmd, capture_output=True, text=True)
        if rc.returncode != 0:
            # spawn 실패 시 reservation 을 정리한다(중앙 인덱스 누수 방지).
            with central_lock(home):
                remove_reservation(
                    home, project_id=row["projectId"],
                    task_group=row["taskGroup"], task_id=row["taskId"],
                    task_type=row["taskType"], run_seq=next_seq,
                )
            items.append({"originalRunId": original, "newRunId": new_run_id,
                          "newRunSeq": next_seq, "sessionName": session,
                          "status": "skipped", "spawnedAt": None,
                          "skipReason": f"tmux: {rc.stderr.strip()}"})
            skipped += 1
            continue
        items.append({"originalRunId": original, "newRunId": new_run_id,
                      "newRunSeq": next_seq, "sessionName": session,
                      "status": "spawned",
                      "spawnedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
                      "skipReason": None})
        spawned += 1

selector_raw = (
    explicit
    + (["--filter", "--project", os.environ["OK_PROJECT"],
        "--task-group", os.environ["OK_TG"],
        "--status", os.environ["OK_STATUS"], "--since", os.environ["OK_SINCE"]]
       if use_filter else [])
    + (["last"] if use_last else [])
    + (["-"] if from_stdin else [])
)
write_batch_meta(home, batch_id, {
    "batchId": batch_id,
    "createdAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
    "selectorRaw": selector_raw, "maxSpawn": max_spawn,
    "items": items,
    "summary": {"total": len(targets), "spawned": spawned,
                "skipped": skipped, "rejected": rejected},
})

dry_run_count = sum(1 for it in items if it["status"] == "dry-run")
if dry_run_count:
    print(f"batch  {batch_id}   dry-run: {dry_run_count}   skipped: {skipped}   rejected: {rejected}")
else:
    print(f"batch  {batch_id}   spawned: {spawned}   skipped: {skipped}   rejected: {rejected}")
print()
header = ("RUN-ID", "SESSION-NAME", "ATTACH")
rows = []
# dry-run 도 미리보기 대상이다 — preview 의 본래 목적이 spawn 없이 target/
# reservation 을 보여주는 것이므로 spawned 와 동일하게 row 에 포함한다.
for it in items:
    if it["status"] in ("spawned", "dry-run"):
        attach = (f"tmux attach -t {it['sessionName']}"
                  if it["status"] == "spawned" else "(dry-run)")
        rows.append((it["newRunId"] or "-", it["sessionName"] or "-", attach))
if rows:
    table = [header] + rows
    widths = [max(len(t[i]) for t in table) for i in range(3)]
    for t in table:
        print("  ".join(c.ljust(widths[i]) for i, c in enumerate(t)))
print()
print(f"monitor batch    : okstra-ctl batch status {batch_id}")
print( "list sessions    : tmux ls | grep ^okstra-")
PY
}
