#!/usr/bin/env bash
# pi-epic-complete [--no-pr] [--draft]
#
# Final epic shipping ritual:
#   1. Verifies all features are merged (or halted)
#   2. Rebases epic branch onto latest default branch
#   3. Runs full test suite
#   4. Distills deviations.md → lessons.md (lessons-candidate.md for review first)
#   5. Pushes the epic branch
#   6. Opens PR via `gh pr create` (if available) or prints the manual command
#   7. Archives epic to .pi/epics/done/

set -euo pipefail
# Resolve script dir through symlinks so we can source siblings reliably.
__src="${BASH_SOURCE[0]}"
while [ -L "$__src" ]; do
    __dir="$(cd -P "$(dirname "$__src")" && pwd)"
    __src="$(readlink "$__src")"
    [[ $__src != /* ]] && __src="$__dir/$__src"
done
__SCRIPT_DIR="$(cd -P "$(dirname "$__src")" && pwd)"
source "$__SCRIPT_DIR/_common.sh"

no_pr=0; draft=0; contribute_lesson=""; SKIP_EXT_CHECK=0; SKIP_EPIC_REVIEW=0
while [[ $# -gt 0 ]]; do
    case "$1" in
        --no-pr) no_pr=1; shift ;;
        --draft) draft=1; shift ;;
        --contribute-lesson)
            contribute_lesson=$2; shift 2 ;;
        --skip-extension-check) SKIP_EXT_CHECK=1; shift ;;
        --skip-epic-review) SKIP_EPIC_REVIEW=1; shift ;;
        *) echo "unknown arg: $1" >&2; exit 1 ;;
    esac
done
export SKIP_EXT_CHECK SKIP_EPIC_REVIEW

# --contribute-lesson L-XYZ: print copy-paste-friendly upstream PR text. Does not
# modify the framework lessons.md in place (the user opens a PR themselves).
if [[ -n "$contribute_lesson" ]]; then
    p=$(user_lessons_path)
    if [[ ! -f "$p" ]]; then
        echo "ERROR: no user-lessons.md at $p — nothing to contribute." >&2
        exit 1
    fi
    # Extract the named lesson block (### L-XYZ ... up to next ### or ##).
    awk -v id="$contribute_lesson" '
        $0 ~ "^### " id "(:|$| )" { found=1; print; next }
        found && /^###|^## / { exit }
        found { print }
    ' "$p" > /tmp/pe-lesson-$$.md
    if [[ ! -s /tmp/pe-lesson-$$.md ]]; then
        echo "ERROR: lesson $contribute_lesson not found in $p" >&2
        rm -f /tmp/pe-lesson-$$.md
        exit 1
    fi
    cat <<EOF
# Contribute upstream

Copy the block below into a PR against:
  skills/epic-feature-workflow/lessons.md
on https://github.com/shankar029/pi-epicflow

Review it first — strip ANY project-specific names, paths, or internal details.

----- BEGIN $contribute_lesson -----
EOF
    cat /tmp/pe-lesson-$$.md
    echo "----- END $contribute_lesson -----"
    rm -f /tmp/pe-lesson-$$.md
    exit 0
fi

repo=$(repo_root)
cd "$repo"
epic_dir=$(active_epic_dir)
epic_id=$(active_epic_id)
epic_slug=${epic_id#*-}
def=$(yaml_get "$epic_dir/meta.yaml" default_branch)

# Check all features merged
remaining=$(pi-epic-next-feature 2>/dev/null || true)
if [[ "$remaining" != "DONE" ]]; then
    echo "ERROR: epic not done — pi-epic-next-feature returned: $remaining" >&2
    exit 1
fi

git checkout "epic/$epic_slug" --quiet
[[ -z $(git status --porcelain) ]] || { echo "ERROR: working tree dirty on epic branch" >&2; exit 1; }

# v0.10.0 — E2E gate (between feature-merge and epic-review).
# Opt-in: only fires when epic-config.yaml has e2e.enabled: true.
e2e_enabled=$(yaml_get "$epic_dir/epic-config.yaml" e2e.enabled 2>/dev/null || echo "")
if [[ "$e2e_enabled" != "true" ]]; then
    log "[e2e-gate] skipped (e2e.enabled: false)"
else
    log "[e2e-gate] starting..."
    e2e_start_cmd=$(yaml_get "$epic_dir/epic-config.yaml" e2e.start_cmd)
    e2e_ready_check=$(yaml_get "$epic_dir/epic-config.yaml" e2e.ready_check)
    e2e_ready_timeout=$(yaml_get "$epic_dir/epic-config.yaml" e2e.ready_timeout_sec 2>/dev/null || echo "")
    e2e_shutdown_cmd=$(yaml_get "$epic_dir/epic-config.yaml" e2e.shutdown_cmd)
    e2e_run_cmd=$(yaml_get "$epic_dir/epic-config.yaml" e2e.run_cmd)
    [[ -z "$e2e_ready_timeout" ]] && e2e_ready_timeout=60

    # Cleanup function — always tears down the app.
    E2E_START_PID=""
    e2e_cleanup() {
        if [[ -n "$e2e_shutdown_cmd" ]]; then
            eval "$e2e_shutdown_cmd" 2>/dev/null || true
        fi
        if [[ -n "$E2E_START_PID" ]]; then
            kill "$E2E_START_PID" 2>/dev/null || true
            wait "$E2E_START_PID" 2>/dev/null || true
        fi
    }
    trap e2e_cleanup EXIT INT TERM

    # Start app in background.
    eval "$e2e_start_cmd" &
    E2E_START_PID=$!

    # Poll ready_check.
    elapsed=0
    while ! eval "$e2e_ready_check" >/dev/null 2>&1; do
        sleep 2
        elapsed=$((elapsed + 2))
        if (( elapsed >= e2e_ready_timeout )); then
            log "[e2e-gate] ready_check timed out after ${e2e_ready_timeout}s"
            e2e_cleanup
            trap - EXIT INT TERM
            # Write halt file for timeout.
            halt_file="$epic_dir/halt-h11-e2e-$(date -u +%Y%m%dT%H%M%SZ).md"
            cat > "$halt_file" <<HALT
# Halt H11 — E2E gate failure

**Command:** $e2e_ready_check (ready_check timeout)
**Exit code:** 1 (timeout after ${e2e_ready_timeout}s)
**Timestamp:** $(date -u +%Y-%m-%dT%H:%M:%SZ)
**Output log:** .pi/epics/$epic_id/e2e-output.log

## Last 50 lines of output

\`\`\`
(ready_check never succeeded within ${e2e_ready_timeout}s)
\`\`\`

## Recovery

See [docs/recovery.md#r11-e2e-failure](../../docs/recovery.md#r11-e2e-failure)
HALT
            exit 1
        fi
    done
    log "[e2e-gate] app ready after ${elapsed}s"

    # Run test command — capture exit code without pipefail abort.
    set +e
    eval "$e2e_run_cmd" > "$epic_dir/e2e-output.log" 2>&1
    run_exit=$?
    set -e

    # Explicit cleanup + clear trap.
    e2e_cleanup
    trap - EXIT INT TERM

    if (( run_exit != 0 )); then
        # AC 4: write halt file.
        halt_file="$epic_dir/halt-h11-e2e-$(date -u +%Y%m%dT%H%M%SZ).md"
        cat > "$halt_file" <<HALT
# Halt H11 — E2E gate failure

**Command:** $e2e_run_cmd
**Exit code:** $run_exit
**Timestamp:** $(date -u +%Y-%m-%dT%H:%M:%SZ)
**Output log:** .pi/epics/$epic_id/e2e-output.log

## Last 50 lines of output

\`\`\`
$(tail -50 "$epic_dir/e2e-output.log" 2>/dev/null || echo "(no output)")
\`\`\`

## Recovery

See [docs/recovery.md#r11-e2e-failure](../../docs/recovery.md#r11-e2e-failure)
HALT
        log "[e2e-gate] FAILED (exit $run_exit). Halt file: $halt_file"
        exit 1
    else
        # AC 5: write e2e-report.json.
        if [[ -f "$repo/tests/e2e-report.json" ]]; then
            cp "$repo/tests/e2e-report.json" "$epic_dir/e2e-report.json"
        else
            cat > "$epic_dir/e2e-report.json" <<REPORT
{"schema_version":1,"exit_code":0,"run_cmd":"$e2e_run_cmd","completed_at":"$(date -u +%Y-%m-%dT%H:%M:%SZ)"}
REPORT
        fi
        log "[e2e-gate] passed. Report: $epic_dir/e2e-report.json"
    fi
fi

# v0.7.0 / L-043 — epic-review gate.
# Refuse to archive an epic that hasn't been through feature-epic-reviewer.
# The reviewer writes EPIC_DIR/epic-review.md whose final non-empty line is
# `Verdict: APPROVE_EPIC | REQUEST_CHANGES_EPIC | BLOCK_EPIC`. We accept only
# APPROVE_EPIC. Bypassable for spike epics, smoke tests, and emergencies via
# --skip-epic-review (logs a loud warning).
if [[ "${SKIP_EPIC_REVIEW:-0}" != "1" ]]; then
    epic_review_file="$epic_dir/epic-review.md"
    if [[ ! -f "$epic_review_file" ]]; then
        printf '\033[31m✖ HALT (L-043): no epic-review.md at %s.\033[0m\n' "$epic_review_file" >&2
        cat >&2 <<EOF
   pi-epic-complete refuses to archive an epic without an end-to-end review.
   The feature-epic-reviewer agent catches the cross-feature bug classes
   per-feature reviewers cannot see (lockfile drift, no-op stubs, orphaned
   references, design-section coverage gaps, rubber-stamping).

   Run the orchestrator's epic-review step, or invoke the agent directly:
     subagent({ agent: "feature-epic-reviewer", context: "fresh",
                cwd: "$repo",
                task: "...",
                output: "$epic_review_file",
                outputMode: "file-only" })

   Escape hatch (logs a warning, NOT the default path):
     pi-epic-complete --skip-epic-review
EOF
        exit 1
    fi
    # Extract the LAST non-empty line and look for an APPROVE_EPIC verdict.
    verdict_line=$(awk 'NF { last=$0 } END { print last }' "$epic_review_file")
    case "$verdict_line" in
        *Verdict:*APPROVE_EPIC*)
            log "epic-review verdict: APPROVE_EPIC — gate passed."
            ;;
        *Verdict:*REQUEST_CHANGES_EPIC*|*Verdict:*BLOCK_EPIC*)
            printf '\033[31m✖ HALT (L-043): epic-review verdict is not APPROVE_EPIC.\033[0m\n' >&2
            printf '   Last line of %s:\n' "$epic_review_file" >&2
            printf '     %s\n' "$verdict_line" >&2
            printf '   Address the findings, then re-run the reviewer. Do NOT bypass unless you understand why.\n' >&2
            exit 1
            ;;
        *)
            printf '\033[31m✖ HALT (L-043): epic-review.md exists but has no parseable Verdict: line.\033[0m\n' >&2
            printf '   Last line read: %s\n' "$verdict_line" >&2
            printf '   The feature-epic-reviewer contract requires the LAST non-empty line to be:\n' >&2
            printf '     Verdict: APPROVE_EPIC | REQUEST_CHANGES_EPIC | BLOCK_EPIC\n' >&2
            exit 1
            ;;
    esac
else
    printf '\033[33m⚠  --skip-epic-review used: bypassing the L-043 epic-review gate.\033[0m\n' >&2
    printf '   This is a documented escape hatch (spike epics, smoke tests, emergencies)\n' >&2
    printf '   but means no cross-feature consistency check ran. Logged to run-log.jsonl.\n' >&2
    runlog_append "$epic_dir" "\"event\":\"epic-review-skipped\",\"epic\":\"$epic_id\""
fi

# v0.6.3 / L-042 — extension guardrails.
# If this epic has been extended, surface a visible reminder; hard-halt when
# the extension features grew the epic by ≥30% AND no decomposition lesson
# has been contributed (the operator is on the hook to explain why the
# original scope under-shot).
if grep -qE '^extensions:' "$epic_dir/meta.yaml"; then
    ext_count=$(awk '
        /^extensions:/ { in_ext=1; next }
        in_ext && /^[a-zA-Z]/ { in_ext=0 }
        in_ext && /^  - / { n++ }
        END { print n+0 }
    ' "$epic_dir/meta.yaml")
    if [[ "$ext_count" -gt 0 ]]; then
        # Count original (pre-extension) features = features whose id <= the
        # max id recorded in run-log BEFORE any epic-extended event. Cheap
        # approximation: total features minus features added on/after the
        # earliest extension date (we don't have per-feature created_at, so
        # we use a simpler heuristic — decomposition.yaml order is stable).
        total_feats=$(grep -cE "^  - id:" "$epic_dir/decomposition.yaml" || echo 0)
        # If the user recorded `original_feature_count` in meta.yaml, prefer
        # it; otherwise we can't reliably split, so we just report total.
        orig_feats=$(yaml_get "$epic_dir/meta.yaml" original_feature_count 2>/dev/null || echo "")
        if [[ -n "$orig_feats" ]] && [[ "$orig_feats" =~ ^[0-9]+$ ]] && (( orig_feats > 0 )); then
            added=$(( total_feats - orig_feats ))
            (( added < 0 )) && added=0
            pct=$(( added * 100 / orig_feats ))
        else
            added="?"
            pct="?"
        fi
        printf '\033[33m⚠  This epic has been extended %s time(s).\033[0m\n' "$ext_count" >&2
        if [[ "$pct" != "?" ]]; then
            printf '   Feature growth: %s → %s (+%s%%; %s added).\n' "$orig_feats" "$total_feats" "$pct" "$added" >&2
        else
            printf '   Feature count now: %s (original count not recorded — set original_feature_count in meta.yaml to enable growth tracking).\n' "$total_feats" >&2
        fi
        # Hard-halt rule: ≥30% growth AND no lesson contribution flag yet on
        # this run. The user can override by re-running with the flag.
        if [[ "$pct" != "?" ]] && (( pct >= 30 )); then
            # Did a decomposition lesson get logged in run-log? Look for a
            # `decomposition-lesson-recorded` event or any L-XXX reference in
            # deviations.md tagged "Decomposition lesson:".
            recorded=0
            if grep -q 'Decomposition lesson:' "$epic_dir/deviations.md" 2>/dev/null; then
                recorded=1
            fi
            if (( recorded == 0 )); then
                printf '\033[31m✖ HALT (L-042): extension growth ≥ 30%% but no decomposition lesson recorded.\033[0m\n' >&2
                printf '   Either add a "Decomposition lesson: ..." line to deviations.md explaining why\n' >&2
                printf '   the original scope under-shot, or re-run with --skip-extension-check after\n' >&2
                printf '   you have manually reviewed.\n' >&2
                if [[ "${SKIP_EXT_CHECK:-0}" != "1" ]]; then
                    exit 1
                fi
            fi
        fi
    fi
fi

log "fetching latest $def..."
git fetch --quiet origin "$def" 2>/dev/null || true
git rebase "origin/$def" --quiet || git rebase "$def" --quiet || {
    echo "ERROR: rebase onto $def failed; resolve conflicts and retry" >&2
    exit 1
}

# Detect + run full test suite
detect_test_cmd() {
    # v0.10.1: see pi-feature-complete — don't emit 'npm test' if no test script.
    if [[ -f "$repo/package.json" ]]; then
        if command -v node >/dev/null 2>&1 && \
           node -e 'process.exit(require("./package.json").scripts && require("./package.json").scripts.test ? 0 : 1)' 2>/dev/null; then
            echo "npm test"; return
        fi
        return
    fi
    if compgen -G "$repo/*.sln" > /dev/null; then echo "dotnet test"; return; fi
    if [[ -f "$repo/pyproject.toml" ]]; then echo "pytest"; return; fi
    if [[ -f "$repo/go.mod" ]]; then echo "go test ./..."; return; fi
    if [[ -f "$repo/Cargo.toml" ]]; then echo "cargo test"; return; fi
    echo ""
}
test_cmd=$(yaml_get "$epic_dir/epic-config.yaml" test_cmd)
[[ -z "$test_cmd" ]] && test_cmd=$(detect_test_cmd)
if [[ -n "$test_cmd" ]]; then
    log "running full test suite: $test_cmd"
    eval "$test_cmd" || { echo "ERROR: tests failed on epic branch" >&2; exit 1; }
fi

# Distill deviations into lessons-candidate.md
candidate="$epic_dir/lessons-candidate.md"
if [[ -f "$epic_dir/deviations.md" ]]; then
    {
        echo "# Lessons candidates from $epic_id"
        echo
        echo "> Pi reviews this file, removes epic-specific noise, and appends the"
        echo "> generalizable rules to ~/.pi/agent/skills/epic-feature-workflow/lessons.md"
        echo
        echo "## Source deviations"
        echo
        cat "$epic_dir/deviations.md"
    } > "$candidate"
    log "distilled deviations → $candidate (review and append to global lessons.md)"
fi

# v0.6.2 / L-036: also append the distilled lessons to the per-machine
# user-lessons.md (in $HOME/.pi/epicflow/). This is the curated cross-epic
# record agents read alongside the framework's skills/.../lessons.md, and it
# stays out of the pi-epicflow repo entirely (no privacy leak on upstream PRs).
if [[ -f "$candidate" ]]; then
    append_user_lessons_from_candidate "$candidate" "$epic_id"
fi

# Auto-PR body
pr_body=$(mktemp)
{
    echo "## Epic: $(yaml_get "$epic_dir/meta.yaml" title)"
    echo
    echo "Implements [\`design.md\`](.pi/epics/$epic_id/design.md)."
    echo
    echo "### Features merged"
    echo
    if [[ -d "$epic_dir/features/done" ]]; then
        for sub in "$epic_dir/features/done"/*/; do
            [[ -d "$sub" ]] || continue
            t=$(yaml_get "$sub/meta.yaml" title)
            sha=$(yaml_get "$sub/meta.yaml" merge_commit_sha)
            id=$(basename "$sub")
            echo "- **$id** — $t (\`${sha:0:8}\`)"
        done
    fi
    echo
    if [[ -s "$epic_dir/deviations.md" ]] && grep -q '^### ' "$epic_dir/deviations.md"; then
        echo "### Deviations from plan"
        echo
        echo "See \`.pi/epics/$epic_id/deviations.md\` for the full log; lessons distilled to \`lessons-candidate.md\`."
        echo
    fi
    echo "### Design decisions"
    echo
    sed -n '/^## 4. Decisions log/,/^## /p' "$epic_dir/design.md" | sed '$d' | tail -n +2 || true
} > "$pr_body"

# Push + PR
if [[ $no_pr -eq 0 ]]; then
    # L-052 (v0.8.1): tolerate missing 'origin' remote so sample/scratch repos
    # and offline development don't error out at the very end of an otherwise
    # successful epic-complete. Discovered by v0.8.0 real-app verification.
    if ! git remote get-url origin >/dev/null 2>&1; then
        log "no 'origin' remote configured — skipping push + PR."
        log "epic/$epic_slug archived locally. To publish later:"
        log "  git remote add origin <url> && git push -u origin epic/$epic_slug"
        log "  (PR body draft: $pr_body)"
    else
    log "pushing epic/$epic_slug..."
    git push --set-upstream origin "epic/$epic_slug" --quiet

    if command -v gh >/dev/null 2>&1; then
        log "opening PR with gh..."
        args=(--base "$def" --head "epic/$epic_slug" --title "$(yaml_get "$epic_dir/meta.yaml" title)" --body-file "$pr_body")
        [[ $draft -eq 1 ]] && args+=(--draft)
        gh pr create "${args[@]}" || log "gh pr create failed; you can run it manually with the body in $pr_body"
    else
        log "gh CLI not available. Push complete; create PR manually:"
        echo
        echo "  Base: $def"
        echo "  Head: epic/$epic_slug"
        echo "  Body: $pr_body"
        echo
    fi
    fi  # close 'origin exists?' guard (v0.8.1 L-052)
fi

# Archive epic folder. We use `git mv` so the rename is staged + committed
# as a single tree-rename in git's view (preserves history). L-025: prior
# to v0.5.1 this was a plain `mv`, which left the working tree dirty
# (epic-branch closeout commit references the old path; filesystem has the
# new path; nothing staged). Now the rename ships as its own commit and the
# epic ends with a clean tree.
mkdir -p "$repo/.pi/epics/done"
yaml_set "$epic_dir/meta.yaml" status "done"
yaml_bump_updated "$epic_dir/meta.yaml"

# Stage any pending meta.yaml status flip first.
if [[ -n $(git status --porcelain -- ".pi/epics/$epic_id/meta.yaml") ]]; then
    git add ".pi/epics/$epic_id/meta.yaml"
fi

# Move the epic dir. Prefer `git mv` (stages the rename); fall back to a
# plain `mv` + `git add -A` if the source has any untracked files git mv
# refuses to handle.
if git mv "$epic_dir" "$repo/.pi/epics/done/$epic_id" 2>/dev/null; then
    :
else
    mv "$epic_dir" "$repo/.pi/epics/done/$epic_id"
    git add -A ".pi/epics/$epic_id" 2>/dev/null || true
fi
# Always sweep the destination for newly-written siblings (lessons-candidate.md,
# any epic-review artifact, etc.) that the rename didn't stage.
git add -A ".pi/epics/done/$epic_id" 2>/dev/null || true

# Defensive: drop any stray halt-*.md the move may have staged. They are
# operator artifacts, not branch history (L-012 / L-017).
git reset --quiet HEAD -- ".pi/epics/done/$epic_id/halt-*.md" 2>/dev/null || true

if [[ -n $(git diff --cached --name-only) ]]; then
    # L-039 (v0.6.2): use --no-verify for journal/archive commits so husky /
    # lint-staged hooks that lint `.pi/` markdown don't block the rename.
    # gen-ui 0001-gen-ui F01: lint-staged ran ESLint --max-warnings 0 on .pi/
    # docs which ESLint ignores by default, producing warnings that exceeded
    # the threshold and aborted the journal commit.
    git commit --quiet --no-verify -m "chore(epic): archive $epic_id to .pi/epics/done/"
    log "committed epic archive rename (L-025, L-039 --no-verify)"
fi

# Reset STATE.md
cat > "$repo/.pi/STATE.md" <<EOF
# Active feature: (none)

Last shipped epic: \`.pi/epics/done/$epic_id/\`
EOF

cat <<EOF

✓ Epic complete: $epic_id
  Branch: epic/$epic_slug pushed; PR opened (if gh available).
  Folder archived: .pi/epics/done/$epic_id/
  Lessons candidate: .pi/epics/done/$epic_id/lessons-candidate.md
  User lessons:      $(user_lessons_path) (machine-private, agents read this)

Manually review lessons-candidate.md. To CONTRIBUTE a specific lesson upstream
to pi-epicflow (after stripping project-specific names), run:

  pi-epic-complete --contribute-lesson L-XYZ

Project-specific lessons stay in your user-lessons.md and are read by agents
automatically.

EOF
