#!/usr/bin/env bash
# pi-feature-complete <feature-id> [--skip-tests] [--skip-evidence]
#
# - Verifies we're in the feature's worktree on the feature branch
# - Runs build/test (autodetected or from epic-config.yaml) unless --skip-tests
# - Verifies worker-report.md has a "## Completion evidence" section
#   (non-spike only) unless --skip-evidence
# - Squash-merges the feature branch into the epic branch
# - Deletes the feature branch and worktree
# - Moves feature folder to features/done/
# - Updates STATE.md back to epic-only
# - Appends run-log entry

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"

[[ $# -ge 1 ]] || { echo "usage: pi-feature-complete <feature-id> [--skip-tests] [--skip-evidence]" >&2; exit 1; }
fid=$1; shift
skip_tests=0
skip_evidence=0
while [[ $# -gt 0 ]]; do
    case "$1" in
        --skip-tests) skip_tests=1 ;;
        --skip-evidence) skip_evidence=1 ;;
        *) echo "unknown flag: $1" >&2; exit 1 ;;
    esac
    shift
done

repo=$(repo_root)
cd "$repo"

epic_dir=$(active_epic_dir)
epic_id=$(active_epic_id)
epic_slug=${epic_id#*-}

# Find feature folder
feat_dir=$(ls -d "$epic_dir/features/$fid"-* 2>/dev/null | head -1 || true)
[[ -n "$feat_dir" && -d "$feat_dir" ]] || { echo "ERROR: feature folder for $fid not found" >&2; exit 1; }

feat_branch=$(yaml_get "$feat_dir/meta.yaml" branch)
worktree=$(yaml_get "$feat_dir/meta.yaml" worktree)

# Detect kind from decomposition.yaml (spike features skip tests by default
# and may merge with no code diff — their deliverable is the deviations
# entry, not a binary).
kind=$(python3 - "$epic_dir/decomposition.yaml" "$fid" <<'PY' 2>/dev/null || echo feature
import sys, re
p, want = sys.argv[1], sys.argv[2]
cur = None
try:
    f = open(p, encoding='utf-8')
except Exception:
    print("feature"); sys.exit(0)
with f:
    for raw in f:
        s = raw.rstrip('\n').strip()
        if not s or s.startswith('#'): continue
        if s.startswith('- ') and 'id:' in s:
            m = re.match(r'^id\s*:\s*(.*)$', s[2:])
            cur = (m.group(1).strip().strip('"').strip("'") if m else None)
            continue
        if cur == want:
            m = re.match(r'^kind\s*:\s*(.*)$', s)
            if m:
                print(m.group(1).strip().strip('"').strip("'")); sys.exit(0)
print("feature")
PY
)
kind=${kind:-feature}

[[ -n "$feat_branch" ]] || { echo "ERROR: meta.yaml missing branch" >&2; exit 1; }
[[ -n "$worktree" && -d "$worktree" ]] || { echo "ERROR: worktree $worktree not found" >&2; exit 1; }

# Detect test command
detect_test_cmd() {
    # v0.10.1: don't blindly emit 'npm test' — verify the test script exists.
    # Real-world friction: many Vite/Next/CRA repos have package.json but no
    # 'test' script (or have only 'test:unit' / 'test:e2e' etc.). Returning
    # 'npm test' caused pi-feature-complete to bail with 'Missing script: test'.
    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
        # package.json present but no test script — skip silently (operator can set test_cmd).
        return
    fi
    if compgen -G "$repo/*.sln" > /dev/null || compgen -G "$repo/**/*.csproj" > /dev/null; then echo "dotnet test"; return; fi
    if [[ -f "$repo/pyproject.toml" || -f "$repo/setup.py" ]]; 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 ""
}

if [[ $skip_tests -eq 0 && "$kind" != "spike" ]]; then
    test_cmd=$(yaml_get "$epic_dir/epic-config.yaml" test_cmd)
    [[ -z "$test_cmd" ]] && test_cmd=$(detect_test_cmd)
    if [[ -z "$test_cmd" ]]; then
        log "no test command detected; skipping (set test_cmd in epic-config.yaml)"
    else
        log "running tests in worktree: $test_cmd"
        ( cd "$worktree" && eval "$test_cmd" ) || {
            echo "ERROR: tests failed. Fix or pass --skip-tests to override." >&2
            yaml_set "$feat_dir/meta.yaml" state "halted"
            exit 1
        }
    fi
elif [[ "$kind" == "spike" ]]; then
    log "spike feature: skipping tests (deliverable is decision artifact)"
fi

# v0.6: completion-evidence gate. Non-spike features must have a
# "## Completion evidence" section in their worker-report.md. This forces
# the worker to show its work (commands run, output captured) and gives
# the reviewer something concrete to audit. Spikes are exempt — their
# evidence lives in the spike template's §3/§5 + deviations.md entry.
#
# Override with --skip-evidence (e.g. for legacy features authored before
# v0.6 that have a valid review but no evidence block).
if [[ $skip_evidence -eq 0 && "$kind" != "spike" ]]; then
    report="$feat_dir/worker-report.md"
    if [[ ! -f "$report" ]]; then
        echo "ERROR: worker-report.md missing at $report" >&2
        echo "  The worker must emit a report before pi-feature-complete runs." >&2
        echo "  Pass --skip-evidence to merge anyway (NOT recommended)." >&2
        yaml_set "$feat_dir/meta.yaml" state "halted"
        exit 1
    fi
    if ! grep -qE '^## Completion evidence' "$report"; then
        echo "ERROR: worker-report.md is missing the '## Completion evidence' section." >&2
        echo "  Required for non-spike features as of v0.6 (see agents/feature-worker.md §7)." >&2
        echo "  Re-spawn the worker with a hint to add per-AC evidence blocks," >&2
        echo "  or pass --skip-evidence to merge anyway (NOT recommended)." >&2
        yaml_set "$feat_dir/meta.yaml" state "halted"
        exit 1
    fi
    log "completion-evidence section present in worker-report.md"
fi

# v0.10 / L-056 — pre-merge deliverables check.
#
# For features that declare deliverable files (e2e_scenarios, mock_fixtures,
# docs_updates, changelog_entry) in decomposition.yaml, verify each declared
# file exists in the worktree AND was modified in the feature branch's commits.
# Features without any deliverable fields (v0.9 era) bypass this check.
if [[ "$kind" != "spike" ]]; then
    decomp="$epic_dir/decomposition.yaml"
    declared=$(feature_declared_deliverables "$decomp" "$fid")
    if [[ -z "$declared" ]]; then
        log "deliverables check: no declared deliverables; skipping"
    else
        log "deliverables check: verifying declared deliverable files"
        epic_branch="epic/$epic_slug"
        diff_files=$( cd "$worktree" && git diff "$epic_branch"..HEAD --name-only 2>/dev/null || true )
        deliverable_fail=0
        while IFS= read -r line; do
            [[ -z "$line" ]] && continue
            category="${line%%:*}"
            path="${line#*:}"
            if [[ "$category" == "changelog" ]]; then
                # AC 6: changelog_entry handling
                if [[ ! -f "$worktree/CHANGELOG.md" ]]; then
                    log "[warn] changelog_entry is true but CHANGELOG.md does not exist in repo. Consider adding one."
                elif ! echo "$diff_files" | grep -qxF "CHANGELOG.md"; then
                    echo "Declared deliverable not produced: CHANGELOG.md (feature $fid). changelog_entry is true but CHANGELOG.md was not modified." >&2
                    deliverable_fail=1
                else
                    # Check for [Unreleased] section (warn only)
                    if ! grep -q '\[Unreleased\]' "$worktree/CHANGELOG.md"; then
                        log "[warn] CHANGELOG.md was modified but has no [Unreleased] section."
                    fi
                fi
            else
                # AC 4-5: file must exist + appear in diff
                if [[ ! -f "$worktree/$path" ]]; then
                    echo "Declared deliverable not produced: $path (feature $fid). Worker may have skipped this output; re-dispatch or update decomposition.yaml." >&2
                    deliverable_fail=1
                elif ! echo "$diff_files" | grep -qxF "$path"; then
                    echo "Declared deliverable not produced: $path (feature $fid). Worker may have skipped this output; re-dispatch or update decomposition.yaml." >&2
                    deliverable_fail=1
                fi
            fi
        done <<< "$declared"
        if (( deliverable_fail )); then
            yaml_set "$feat_dir/meta.yaml" state "halted"
            exit 1
        fi
        log "deliverables check: all declared deliverables verified"
    fi
fi

# Commit any final pending changes in the worktree
( cd "$worktree" && git add -A && git diff --cached --quiet || git commit --quiet --no-verify -m "wip: $fid pre-complete" )

# L-023 (v0.5.1): spike epic-branch journal commit.
#
# Spike workers write journal artifacts (populated feature.md, deviations.md)
# directly to MAIN_REPO's epic-branch working tree because there's no code
# work in the worktree. Those edits are uncommitted at this point and would
# trip the `git checkout epic/<slug>` below ("local changes would be
# overwritten"). Commit them to the epic branch as the spike's deliverable.
# The feat-branch squash that follows will be empty — we allow that.
if [[ "$kind" == "spike" ]]; then
    if [[ -n $(git status --porcelain -- ".pi/epics/$epic_id") ]]; then
        git add ".pi/epics/$epic_id"
        # Halt files never ride the auto-commit train (L-012).
        git reset --quiet HEAD -- ".pi/epics/$epic_id"/halt-*.md 2>/dev/null || true
        if [[ -n $(git diff --cached --name-only) ]]; then
            git commit --quiet --no-verify -m "spike($fid): decision + journal"
            log "spike: committed MAIN_REPO journal to epic branch"
        fi
    fi
fi

# Switch to epic branch (in main repo) and squash-merge
git checkout "epic/$epic_slug" --quiet

# Get the feature's branch tip
feat_tip=$(git rev-parse "$feat_branch")

# Build squash commit message from feature meta + last commits
title=$(yaml_get "$feat_dir/meta.yaml" title)
commit_msg=$(mktemp)
{
    echo "feat($fid): $title"
    echo
    echo "Squash-merge of $feat_branch into epic/$epic_slug."
    echo
    echo "Original commits:"
    git log --oneline "epic/$epic_slug..$feat_branch" | sed 's/^/  /'
} > "$commit_msg"

# Squash-merge.
#
# Common case for this skill: the only conflict path is the agent-authored
# journal under .pi/epics/<epic>/features/<fid>-*/ (feature.md / meta.yaml).
# The main-repo (epic-branch) version is canonical there — the orchestrator
# (and the spike worker, see L-023 above) updates the journal on epic
# mid-feature, while the worktree may have a stale scaffold copy.
# Auto-resolve those paths with --ours and retry the commit. Any other
# unmerged path is a genuine code conflict and must be resolved by hand.
if ! git merge --squash "$feat_branch" --quiet; then
    journal_glob=".pi/epics/$epic_id/features/$fid-"
    unmerged=$(git diff --name-only --diff-filter=U)
    other=$(echo "$unmerged" | grep -v "^$journal_glob" || true)
    if [[ -n "$unmerged" && -z "$other" ]]; then
        log "auto-resolving journal-only conflicts under $journal_glob* (taking epic-branch version)"
        echo "$unmerged" | while IFS= read -r p; do
            [[ -z "$p" ]] && continue
            git checkout --ours -- "$p"
            git add -- "$p"
        done
    else
        # v0.8.0 / L-049 — the parallel-merge conflict path.
        # Classify conflicting files as in-scope or out-of-scope of this
        # feature's declared scope_files. In-scope means the decomposition
        # predicted disjoint scopes and was wrong (decomposition-feedback).
        # Out-of-scope means the worker went beyond what it declared and
        # collided with an already-merged sibling (worker-discipline
        # failure; per-feature reviewer should have caught).
        scope_decl=$(python3 - "$epic_dir/decomposition.yaml" "$fid" <<'PYS' 2>/dev/null || true
import sys, re
p, fid = sys.argv[1], sys.argv[2]
in_block = False; cur=None; sf=[]
with open(p) as f:
    for line in f:
        s=line.rstrip('\n')
        if not s.strip() or s.lstrip().startswith('#'): continue
        if s.startswith('  - ') and 'id:' in s:
            m=re.match(r'^\s*-\s*id:\s*(\S+)', s)
            cur=m.group(1).strip().strip('"').strip("'") if m else None
            in_block=False
            continue
        if cur==fid:
            m=re.match(r'^\s+scope_files\s*:\s*(.*)$', s)
            if m:
                rest=m.group(1).strip()
                if rest and rest.startswith('['):
                    inner=rest[1:-1].strip()
                    sf=[x.strip().strip('"').strip("'") for x in inner.split(',') if x.strip()]
                    in_block=False
                else:
                    in_block=True
                continue
            if in_block:
                m=re.match(r'^\s+-\s*(.+)$', s)
                if m: sf.append(m.group(1).strip().strip('"').strip("'"))
                else: in_block=False
print('\n'.join(sf))
PYS
)
        in_scope=""
        out_scope=""
        while IFS= read -r conflict_path; do
            [[ -z "$conflict_path" ]] && continue
            matched=0
            while IFS= read -r decl; do
                [[ -z "$decl" ]] && continue
                if [[ "$conflict_path" == "$decl" || "$conflict_path" == "$decl"/* ]]; then
                    matched=1; break
                fi
            done <<< "$scope_decl"
            if (( matched )); then
                in_scope="${in_scope}${conflict_path}\n"
            else
                out_scope="${out_scope}${conflict_path}\n"
            fi
        done <<< "$other"

        echo "" >&2
        echo "⛔ HALT: H6 — squash-merge conflict (parallel-merge collision)" >&2
        echo "" >&2
        if [[ -n "$in_scope" ]]; then
            echo "  In-scope conflicts (declared by $fid in decomposition.yaml):" >&2
            printf "$in_scope" | sed '/^$/d; s/^/    /' >&2
            echo "    → Decomposition predicted disjoint scopes and was wrong." >&2
            echo "      Decomposition-feedback class. Resolve by hand, then either:" >&2
            echo "        - retry pi-feature-complete --skip-tests, or" >&2
            echo "        - amend decomposition.yaml so future runs serialize these." >&2
            echo "" >&2
        fi
        if [[ -n "$out_scope" ]]; then
            echo "  Out-of-scope conflicts (NOT in $fid's declared scope_files):" >&2
            printf "$out_scope" | sed '/^$/d; s/^/    /' >&2
            echo "    → Worker went out of scope and collided with an already-" >&2
            echo "      merged sibling feature. Worker-discipline failure; per-" >&2
            echo "      feature reviewer should have caught this. Log a deviation." >&2
            echo "" >&2
        fi
        echo "  Recovery: docs/recovery.md §R9 (parallel-merge conflict)." >&2
        echo "  After resolving, re-run pi-feature-complete --skip-tests." >&2
        # Append a structured deviations.md entry for the orchestrator/reviewer.
        dev="$epic_dir/deviations.md"
        {
            echo ""
            echo "### H6: parallel-merge conflict on $fid ($(date -u +%FT%TZ))"
            if [[ -n "$in_scope" ]]; then
                echo ""
                echo "**In-scope conflicts (decomposition-feedback):**"
                printf "$in_scope" | sed '/^$/d; s/^/- /'
            fi
            if [[ -n "$out_scope" ]]; then
                echo ""
                echo "**Out-of-scope conflicts (worker-discipline):**"
                printf "$out_scope" | sed '/^$/d; s/^/- /'
            fi
        } >> "$dev"
        yaml_set "$feat_dir/meta.yaml" state "halted"
        yaml_set "$feat_dir/meta.yaml" halt_code "H6"
        exit 1
    fi
fi
# L-023: spikes commit their deliverable directly to the epic branch (the
# block above) and the feat branch carries only the L-019 scaffold, so the
# squash produces an empty diff. Allow that with --allow-empty; the merge
# commit serves as a marker that the spike landed.
if ! git diff --cached --quiet; then
    git commit --quiet --no-verify -F "$commit_msg"
elif [[ "$kind" == "spike" ]]; then
    git commit --quiet --no-verify --allow-empty -F "$commit_msg"
    log "spike: empty squash committed with --allow-empty (decision landed via journal commit above)"
else
    echo "ERROR: squash produced empty diff for non-spike feature $fid" >&2
    exit 1
fi
rm -f "$commit_msg"
merge_sha=$(git rev-parse HEAD)

# Remove worktree + branch (older git versions don't support --quiet on these)
git worktree remove "$worktree" --force
git branch -D "$feat_branch" >/dev/null

# Archive feature folder
mkdir -p "$epic_dir/features/done"
mv "$feat_dir" "$epic_dir/features/done/"
moved_dir="$epic_dir/features/done/$(basename "$feat_dir")"

today=$(date -u +%Y-%m-%d)
if [[ ! -f "$moved_dir/meta.yaml" ]]; then
    log "WARNING: $moved_dir/meta.yaml missing post-merge; reconstructing minimal record"
    cat > "$moved_dir/meta.yaml" <<EOF
id: $(basename "$moved_dir")
state: merged
branch: $feat_branch
worktree: ""
started: ""
updated: $today
merged_at: $today
merge_commit_sha: $merge_sha
last_halt: ""
EOF
else
    sed -i.bak \
        -e "s|^state:.*|state: merged|" \
        -e "s|^updated:.*|updated: $today|" \
        -e "s|^merged_at:.*|merged_at: $today|" \
        -e "s|^merge_commit_sha:.*|merge_commit_sha: $merge_sha|" \
        "$moved_dir/meta.yaml"
    rm -f "$moved_dir/meta.yaml.bak"
fi

# L-023 / L-025: commit the features/<fid> → features/done/<fid> rename
# (and the updated meta.yaml) right here. Previously this dirty state was
# left for the next `pi-feature-start`'s pending-edits auto-commit to
# sweep up — fine for sequential features, fragile for the last feature
# in an epic (the dirty state persists until `pi-epic-complete`).
if [[ -n $(git status --porcelain -- ".pi/epics/$epic_id") ]]; then
    git add -A ".pi/epics/$epic_id"
    # Halt files never ride the auto-commit train (L-012).
    git reset --quiet HEAD -- ".pi/epics/$epic_id"/halt-*.md 2>/dev/null || true
    if [[ -n $(git diff --cached --name-only) ]]; then
        # L-039 (v0.6.2): journal/archive commits must skip husky/lint-staged
        # hooks — they target user source code, not .pi/ markdown bookkeeping.
        git commit --quiet --no-verify -m "chore(epic): archive $fid to features/done/"
        log "committed feature archive rename (L-039 --no-verify)"
    fi
fi

# Reset STATE.md to epic-only
def=$(yaml_get "$epic_dir/meta.yaml" default_branch)
cat > "$repo/.pi/STATE.md" <<EOF
# Active epic

\`.pi/epics/$epic_id/\`

Branch: \`epic/$epic_slug\` → PR target \`$def\`

Last completed feature: \`$fid\` (squash-merge $merge_sha)

Run \`pi-epic-next-feature\` to pick up the next one.
EOF

runlog_append "$epic_dir" "\"event\":\"feature-complete\",\"feature\":\"$fid\",\"merge_sha\":\"$merge_sha\""

cat <<EOF

✓ Feature complete: $fid
  Squash-merged into epic/$epic_slug as $merge_sha
  Branch + worktree deleted
  Folder archived: features/done/$(basename "$moved_dir")

EOF
